From 975d60078d0d5146647e9107b2c63cf6c16712c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 15:37:56 +0000 Subject: [PATCH 01/11] lance-graph-dt64: the ClassId-addressed bind store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DT is the existing DTO binding store, addressed by ClassId (D-DRDT §1). This crate is that store as production code: a workspace member whose only dependency is the zero-dep contract. It holds bindings and their version log, never business data, and evaluates nothing. Closes the four D-DRDT §4 gaps: - MethodId + SigRev: identity independent of the source name; - Guard = set_algebra::SetExpr + Logic, the description Quack's WHERE evaluates; Guard::witness is its normal form (Kleene for K3); - ordered effects (field position + AlgebraRef, the D4 polyfill ref); - one lookup: resolve(view, classid, schema_rev), fields borrowed from the ClassView, methods from the store; resolve_at by DR position. D5: register is idempotent for an identical binding and refuses any other (BehaviourImmutable); an override is register_derived plus a re-route. Only routing is versioned: an append-only route log with routed_at(version, position). D-DRDT §5: admit(MethodRef) refuses a dropped, added or mismatched guard and a stale signature revision; resolve refuses another schema revision and a ClassView whose field basis moved. The first DR #4 falsifier passes: equal read/write sets, different effect order, both kept and distinct. 11 tests; clippy -D warnings and fmt clean. Board: STATUS_BOARD D-DT64-6-BS, plan Amendment A.5, entry 2026-10-30-d-dt64-6-bs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XAqKMPh2Seh2WLccCksAdY --- .claude/board/STATUS_BOARD.md | 1 + ...2026-10-30-d-dt64-6-bs-bind-store-crate.md | 25 + .claude/board/entries/README.md | 1 + .../d-dt64-6-routing-bindstore-behavior-v1.md | 21 + Cargo.toml | 3 + crates/lance-graph-dt64/Cargo.toml | 10 + crates/lance-graph-dt64/src/lib.rs | 555 ++++++++++++++++++ crates/lance-graph-dt64/tests/bind_store.rs | 455 ++++++++++++++ 8 files changed, 1071 insertions(+) create mode 100644 .claude/board/entries/2026-10-30-d-dt64-6-bs-bind-store-crate.md create mode 100644 crates/lance-graph-dt64/Cargo.toml create mode 100644 crates/lance-graph-dt64/src/lib.rs create mode 100644 crates/lance-graph-dt64/tests/bind_store.rs diff --git a/.claude/board/STATUS_BOARD.md b/.claude/board/STATUS_BOARD.md index fabb37896..d5d3669c8 100644 --- a/.claude/board/STATUS_BOARD.md +++ b/.claude/board/STATUS_BOARD.md @@ -55,6 +55,7 @@ | D-DT64-6-3 | register OGIT verbs as DT64 predicates in 64-wide blocks | Queued | D-DT64-6-2 green | | D-DT64-6-4 | consumer read-only access via HotPlug activation | Queued | D-DT64-6-3 green | | D-DT64-6-5 | OGIT becomes cross-check oracle once a domain has attested predicates | Queued | one attested domain | +| D-DT64-6-BS | `crates/lance-graph-dt64`: the bind store (workspace member, contract-only dep). Closes D-DRDT §4 gaps 1–4 (MethodId + SigRev, K3 guard as `SetExpr` + `Logic`, ordered effects, one `resolve`); D5 immutable classid, derived classid + versioned route log; D-DRDT §5 falsifier | In PR (#1862) | 11 tests green, clippy/fmt clean; disable runs (Amendment A.5) | ## D-DT64 — DTO Tensor 64: versioned semantic predicate adapter and DR projections (2026-10-26) diff --git a/.claude/board/entries/2026-10-30-d-dt64-6-bs-bind-store-crate.md b/.claude/board/entries/2026-10-30-d-dt64-6-bs-bind-store-crate.md new file mode 100644 index 000000000..668def579 --- /dev/null +++ b/.claude/board/entries/2026-10-30-d-dt64-6-bs-bind-store-crate.md @@ -0,0 +1,25 @@ +# D-DT64-6-BS — the bind store as production code: `crates/lance-graph-dt64` + +**Date 2026-10-30 · Status VERIFIED-IN-CODE · Plan [`d-dt64-6-routing-bindstore-behavior-v1.md`](../../plans/d-dt64-6-routing-bindstore-behavior-v1.md) Amendment A.5 · Crate `crates/lance-graph-dt64` (workspace member, depends on `lance-graph-contract` only)** + +DT is the ClassId-addressed binding store (D-DRDT §1). The crate stores bindings and their version log, never business data, and evaluates nothing. It closes the four D-DRDT §4 gaps: +- `MethodId` + `SigRev`; +- a K3 guard as `set_algebra::SetExpr` + `Logic`, with `Guard::witness` as the normal form; +- an ordered `effects` list; +- one `resolve(view, classid, schema_rev)`. + +Class behaviour is immutable (D5). An override is a derived classid plus a re-route. Only the route log is versioned: `routed_at(version, position)`. + +Refusals are named. UNKNOWN is never empty: +- unknown class, unrouted position, unknown method; +- schema revision and field-basis drift; +- a dropped, added or mismatched guard; +- a stale signature revision. + +The D-DRDT §5 falsifier passes: two methods on one cell with equal read and write sets and different effect order stay distinct, and a dropped guard refuses. 11 tests; clippy and fmt clean. + +**OPEN:** +- OGAR `CapabilityAuthority` wiring (D6); +- persistence (EXO-55); +- a DR #4 router carrying `MethodRef`; +- the immutable unit (§6 Q1). The crate implements the D5 wording: the classid. diff --git a/.claude/board/entries/README.md b/.claude/board/entries/README.md index e3fd3fb7e..2fda64ff9 100644 --- a/.claude/board/entries/README.md +++ b/.claude/board/entries/README.md @@ -35,6 +35,7 @@ exactly one of them, never both. | 2026-10-30 | `D-TRACT-HARVEST-0` | | [2026-10-30-d-tract-harvest-0-native-algebra-bridge.md](2026-10-30-d-tract-harvest-0-native-algebra-bridge.md) | | 2026-10-30 | `D-LITHO` | | [2026-10-30-d-litho-photolithography-contract.md](2026-10-30-d-litho-photolithography-contract.md) | | 2026-10-30 | `D-LITHO-1` | | [2026-10-30-d-litho-1-xlog-register-steps-and-bag-fixture.md](2026-10-30-d-litho-1-xlog-register-steps-and-bag-fixture.md) | +| 2026-10-30 | `D-DT64-6-BS` | | [2026-10-30-d-dt64-6-bs-bind-store-crate.md](2026-10-30-d-dt64-6-bs-bind-store-crate.md) | | 2026-10-30 | `D-DRCA-3` | | [2026-10-30-d-drca-3-dr4-method-routing.md](2026-10-30-d-drca-3-dr4-method-routing.md) | | 2026-10-29 | `palette512-turbovec9-nsl-worksteal` | | [2026-10-29-palette512-turbovec9-nsl-worksteal.md](2026-10-29-palette512-turbovec9-nsl-worksteal.md) | | 2026-10-28 | `thought-layers-nan-skip` | | [2026-10-28-thought-layers-nan-skip.md](2026-10-28-thought-layers-nan-skip.md) | diff --git a/.claude/plans/d-dt64-6-routing-bindstore-behavior-v1.md b/.claude/plans/d-dt64-6-routing-bindstore-behavior-v1.md index 7d47bbb1b..c92b232f6 100644 --- a/.claude/plans/d-dt64-6-routing-bindstore-behavior-v1.md +++ b/.claude/plans/d-dt64-6-routing-bindstore-behavior-v1.md @@ -238,3 +238,24 @@ Production step (registry module doc): the lab ordinals become OGAR-minted conce ## A.4 Open (unchanged owners) The Bag carrier (who admits a counted population into `bind_dr64`); the D-DRDT §4 DT gaps (MethodId, K3 guards, effect order, one lookup); §6 questions 1–9. + +## A.5 (2026-10-30) The bind store as production code: `crates/lance-graph-dt64` + +**Status:** VERIFIED-IN-CODE. This supersedes A.2's status line ("no bind store type exists in the codebase") and closes the four D-DRDT §4 gaps listed in A.4. A workspace member; its only dependency is `lance-graph-contract` (zero-dep). It stores bindings and their version log, never business data, and evaluates nothing. + +| Decision / gap | In `lance-graph-dt64` | Test (`tests/bind_store.rs`) | +|---|---|---| +| D-DRDT §4 gap 1: method identity independent of name | `MethodId` + `SigRev`; the source name rides in `MethodBinding::name` | `same_read_write_sets_…` (stale `SigRev` refused) | +| gap 2: K3, conjunctive WHEN | `Guard { expr: SetExpr, logic: Logic }` from `set_algebra`, the description Quack's WHERE evaluates (§3); `Guard::witness` is the normal form under its own logic (`normalize_kleene` for K3) | `a_kleene_guard_never_treats_unknown_as_true` (`x<5 ∨ x≥5` is `All` two-valued, not under Kleene) | +| gap 3: effect order | `MethodBinding::effects: Vec` (field position + `AlgebraRef`, the D4 polyfill reference); `writes()` is the unordered set | `same_read_write_sets_…` (equal reads/writes, unequal bindings, both kept) | +| gap 4: one lookup | `BindStore::resolve(view, classid, schema_rev)`: fields borrowed from the `ClassView`, methods from the store; `resolve_at` by DR position and route version | `one_lookup_joins_…`, `override_is_…` | +| D5 immutable classid; override = derived classid + re-route | `register` idempotent for an identical binding, else `BehaviourImmutable`; `register_derived` records the base | `class_behaviour_is_immutable`, `override_is_…` | +| Only routing is versioned (§3) | append-only route log, `route` / `retract` / `routed_at(version, position)`; future versions refused | `override_is_…`, `routes_and_derivations_…` | +| F06: unregistered is UNKNOWN | `Refusal::{UnknownClass, Unrouted, UnknownMethod}`, never an empty answer | `an_unregistered_class_is_unknown_not_empty` | +| D-DRDT §5: refuse another schema revision; preserve guard witnesses; the first DR #4 falsifier | `resolve` refuses `SchemaRevisionMismatch` and `FieldBasisChanged`; `admit(MethodRef)` refuses `GuardDropped` / `GuardAdded` / `GuardMismatch` / `SignatureRevisionMismatch` | `schema_revision_and_field_basis_are_checked`, `same_read_write_sets_…`, `a_guard_added_…` | +| A.1: population, truth, value demand | `Population` / `Truth` from `set_rules` and `ValueDemand` ride the binding; a Bag is recorded, never refused here (admitting it is the binder's call) | `population_truth_and_demand_ride_the_binding` | +| Registration hygiene | `DuplicateMethodId`, `FieldOutOfBasis` (ON, reads, effect targets), `BasisTooWide` (> 256) | `registration_refuses_…` | + +Disable runs (committed first, each guard removed, the named test red, restored with `git checkout`): listed in the commit that records them. + +Not done: wiring OGAR's `CapabilityAuthority` (D6, the production route for consumer classids); persistence of the store (EXO-55 owns the writer path); a router (DR #4) that carries `MethodRef`s; §6 questions 1–9, including whether the immutable unit is the classid or `(classid, binding version)` — this crate implements the D5 wording (the classid). diff --git a/Cargo.toml b/Cargo.toml index f4f274c88..779d3b4a0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,9 @@ members = [ # ONNX mathematical compatibility facade over existing ndarray functions # (D-ONNX-NATIVE-0). Depends on ndarray only; nothing native depends on it. "crates/lance-graph-onnx", + # DT64: the ClassId-addressed binding store (D-DRDT §1/§4, D-DT64-6 §1/§3). + # Depends on the zero-dep contract only. + "crates/lance-graph-dt64", # EXPERIMENTAL, routed nowhere: Cypher (upstream front end, read-only) → # bound LogicalOperator → Quack, with typed refusals. "crates/lance-graph-cypher-quack", diff --git a/crates/lance-graph-dt64/Cargo.toml b/crates/lance-graph-dt64/Cargo.toml new file mode 100644 index 000000000..5f20b0326 --- /dev/null +++ b/crates/lance-graph-dt64/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "lance-graph-dt64" +version = "0.1.0" +edition = "2021" +publish = false +description = "DT64: the ClassId-addressed binding store. Class schema (through ClassView), method schema (MethodId, signature revision, K3 guard, ordered effects), immutable class behaviour, and the versioned route log from DR positions to classids. Stores bindings and their version log, never business data; executes nothing." + +[dependencies] +# The one dependency. The contract is zero-dep, so this crate is too. +lance-graph-contract = { path = "../lance-graph-contract" } diff --git a/crates/lance-graph-dt64/src/lib.rs b/crates/lance-graph-dt64/src/lib.rs new file mode 100644 index 000000000..4141c0ea1 --- /dev/null +++ b/crates/lance-graph-dt64/src/lib.rs @@ -0,0 +1,555 @@ +//! DT64: the ClassId-addressed binding store. +//! +//! D-DRDT §1: "DT is the existing DTO binding store, addressed by ClassId: +//! `ClassId → class schema + method schema`". D-DT64-6 §1 (D4, D5, D6) and §3 +//! give its rules; D-DRDT §4 lists what the contract did not yet carry, and +//! this crate closes those four gaps: +//! +//! 1. **Method identity independent of its name.** [`MethodId`] plus a +//! [`SigRev`] signature revision. The source name +//! (`MethodSig::name`, `ActionDef::predicate`) rides along as +//! [`MethodBinding::name`] and is never the identity. +//! 2. **K3, conjunctive guards.** [`Guard`] is a +//! [`set_algebra::SetExpr`](lance_graph_contract::set_algebra::SetExpr) +//! under a declared [`Logic`]: the same description Quack's WHERE +//! evaluates (D-DT64-6 §3, "WHEN is a Quack WHERE into a guard mask G"). +//! The store keeps it; it evaluates nothing. +//! 3. **Effect order.** [`MethodBinding::effects`] is an ordered list, so two +//! methods with the same read and write sets and different order stay +//! distinct (the CR-H0 negative mold, D-DT64-6 §3). +//! 4. **One lookup.** [`BindStore::resolve`] joins the class schema (read +//! through a [`ClassView`], never copied) with the method schema for one +//! classid at one schema revision. +//! +//! # What is stored, and what is not +//! +//! Bindings and their version log only (the D-DT64-6 §4 reading of the Q0 +//! contract): per classid, its [`ClassSchema`] and [`MethodBinding`]s; and the +//! append-only route log from a DR position to a classid. No business value, +//! no field label (labels come from the [`ClassView`] at resolution), no +//! evaluator, no persistence path. +//! +//! # Immutability (D5) and replay +//! +//! A classid's behaviour never changes once registered. +//! [`BindStore::register`] is idempotent for an identical binding and refuses +//! any other with [`Refusal::BehaviourImmutable`]. An override is a new +//! derived classid ([`BindStore::register_derived`]) plus a re-route +//! ([`BindStore::route`]). Only routing is versioned: [`BindStore::routed_at`] +//! answers which classid a position routed to at a [`RouteVersion`], and that +//! classid's behaviour is the same today as then. +//! +//! # UNKNOWN is never empty +//! +//! An unregistered classid, an unrouted position, an unknown method, a +//! dropped guard, a mismatched revision: each is a named [`Refusal`], never an +//! empty answer that reads as "no behaviour" (D-SAG-6 F06). +//! +//! # Classids +//! +//! A bound class is a `u32`, the key [`ActionDef::object_class`] and +//! [`ClassMethods::classid`] already use (for a consumer: its composed +//! `concept << 16 | app prefix`, D-DT64-6 §3). Its field basis is read from +//! the [`ClassView`] under [`ClassSchema::view_class`], declared explicitly at +//! registration; this crate never derives one id from the other's bits. +//! +//! [`ActionDef::object_class`]: lance_graph_contract::action::ActionDef::object_class +//! [`ClassMethods::classid`]: lance_graph_contract::codegen_manifest::ClassMethods::classid + +#![forbid(unsafe_code)] + +use std::collections::BTreeMap; + +use lance_graph_contract::class_view::{ClassId, ClassView, WideFieldMask}; +use lance_graph_contract::ontology::FieldRef; +use lance_graph_contract::set_algebra::{normalize, normalize_kleene, Logic, SetExpr}; +use lance_graph_contract::set_rules::{Population, Truth}; + +/// A method's identity within its class, independent of its source name. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct MethodId(pub u32); + +/// A method's signature revision. A reference made against one revision is +/// refused under another. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct SigRev(pub u32); + +/// A class's schema revision. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct SchemaRev(pub u32); + +/// A position in the append-only route log. `RouteVersion(0)` is the empty +/// log; each route or retract advances it by one. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct RouteVersion(pub u64); + +/// A reference to the polyfill that carries out one effect (D4: classid → +/// polyfill over mask-risc / ndarray; odoo-rs #46 `algebra_ref`). Opaque to +/// this store: it is compared, never resolved or run. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct AlgebraRef(String); + +impl AlgebraRef { + pub fn new(name: impl Into) -> AlgebraRef { + AlgebraRef(name.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// One DO step: which field position of the class it writes, and the +/// polyfill that writes it. A method's effects are an ORDERED list. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Effect { + /// Field position in the class basis (the `WideFieldMask` bit). + pub target: u8, + pub algebra: AlgebraRef, +} + +/// WHEN: a guard over the population, stated as the set description Quack's +/// WHERE evaluates, under a declared logic. Conjunction is +/// [`SetExpr::And`]; a [`Logic::Kleene`] guard is three-valued, and a row +/// where it is UNKNOWN does not fire. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Guard { + pub expr: SetExpr, + pub logic: Logic, +} + +impl Guard { + pub fn new(expr: SetExpr, logic: Logic) -> Guard { + Guard { expr, logic } + } + + /// What a router carries to prove it kept this guard: the expression's + /// normal form under the guard's own logic. Two guards with equal + /// witnesses fire on the same rows. Under [`Logic::Kleene`] the witness is + /// the TRUE-rows form ([`normalize_kleene`]), which is all a guard is read + /// as. + pub fn witness(&self) -> GuardWitness { + let normal = match self.logic { + Logic::TwoValued => normalize(&self.expr), + Logic::Kleene => normalize_kleene(&self.expr).into_where_true(), + }; + GuardWitness { + normal, + logic: self.logic, + } + } +} + +/// See [`Guard::witness`]. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct GuardWitness { + normal: SetExpr, + logic: Logic, +} + +/// Whether a method needs field values, or only the map (D-DRAV-1 §2: map and +/// value lifetimes are separate). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ValueDemand { + MapOnly, + Values, +} + +/// One method's binding: identity, ON × WHEN × DO, and what it needs from the +/// population. Several bindings may share a cell; all are kept. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct MethodBinding { + pub id: MethodId, + pub sig_rev: SigRev, + /// The source name (`MethodSig::name`, `ActionDef::predicate`). Carried, + /// never used as identity. + pub name: String, + /// ON: the field positions whose change triggers the method. + pub on: WideFieldMask, + /// WHEN: `None` for an unguarded method. + pub when: Option, + pub reads: WideFieldMask, + /// DO, in order. + pub effects: Vec, + pub population: Population, + pub truth: Truth, + pub demand: ValueDemand, +} + +impl MethodBinding { + /// The field positions the effects write, as an unordered set. Equal + /// write sets do not make two bindings equal: the order is in + /// [`MethodBinding::effects`]. + pub fn writes(&self) -> WideFieldMask { + let targets: Vec = self.effects.iter().map(|e| e.target).collect(); + WideFieldMask::from_positions(&targets) + } +} + +/// A class's schema as bound: which [`ClassView`] class carries its field +/// basis, the schema revision, and the field count the methods were bound +/// against. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct ClassSchema { + pub view_class: ClassId, + pub schema_rev: SchemaRev, + pub field_count: usize, +} + +/// The widest basis a [`WideFieldMask`] addresses (positions are `u8`). +pub const MAX_FIELDS: usize = 256; + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ClassEntry { + schema: ClassSchema, + methods: Vec, + derived_from: Option, +} + +/// A reference a router (DR #4) carries to a method: the class, the method +/// identity, the signature revision it was made against, and the witness of +/// the guard it kept. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct MethodRef { + pub class: u32, + pub method: MethodId, + pub sig_rev: SigRev, + pub guard: Option, +} + +impl MethodRef { + /// The faithful reference to `binding` in `class`. + pub fn of(class: u32, binding: &MethodBinding) -> MethodRef { + MethodRef { + class, + method: binding.id, + sig_rev: binding.sig_rev, + guard: binding.when.as_ref().map(Guard::witness), + } + } +} + +/// Why the store refused. Every arm is a named UNKNOWN or conflict; none is an +/// empty answer. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Refusal { + /// The classid has no binding. + UnknownClass(u32), + /// The classid is bound to other behaviour (D5). Override with a derived + /// classid and a re-route. + BehaviourImmutable(u32), + /// Two bindings in one class share a MethodId. + DuplicateMethodId { class: u32, method: MethodId }, + /// A binding names a field position at or past the class's field count. + FieldOutOfBasis { + class: u32, + method: MethodId, + position: u8, + }, + /// The declared field count exceeds [`MAX_FIELDS`]. + BasisTooWide { class: u32, field_count: usize }, + /// Asked at another schema revision than the one bound. + SchemaRevisionMismatch { + class: u32, + bound: SchemaRev, + asked: SchemaRev, + }, + /// The [`ClassView`] no longer has the field count the methods were bound + /// against. + FieldBasisChanged { + class: u32, + bound: usize, + view: usize, + }, + /// The class has no method with this identity. + UnknownMethod { class: u32, method: MethodId }, + SignatureRevisionMismatch { + class: u32, + method: MethodId, + bound: SigRev, + asked: SigRev, + }, + /// The method is guarded and the reference carries no guard witness. + GuardDropped { class: u32, method: MethodId }, + /// The reference carries a guard the method does not have. + GuardAdded { class: u32, method: MethodId }, + /// The reference carries another guard than the bound one. + GuardMismatch { class: u32, method: MethodId }, + /// No classid was routed to the position at that version. + Unrouted { + position: u16, + version: RouteVersion, + }, + /// The version is past the end of the route log. + FutureVersion { + asked: RouteVersion, + current: RouteVersion, + }, +} + +impl std::fmt::Display for Refusal { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{self:?}") + } +} + +impl std::error::Error for Refusal {} + +/// The result of one lookup: the class schema as bound, its fields as the +/// [`ClassView`] holds them now (borrowed), and its methods. +#[derive(Debug, Clone, Copy)] +pub struct Resolved<'s, 'v> { + pub class: u32, + pub schema: &'s ClassSchema, + pub fields: &'v [FieldRef], + pub methods: &'s [MethodBinding], + pub derived_from: Option, +} + +impl<'s> Resolved<'s, '_> { + /// Every method whose ON includes `field`, in registration order. Several + /// are normal and all are returned. + pub fn methods_on(&self, field: u8) -> impl Iterator { + self.methods.iter().filter(move |m| m.on.has(field)) + } +} + +/// The binding store. Building it takes `&mut self`; every read is `&self`. +#[derive(Debug, Clone, Default)] +pub struct BindStore { + classes: BTreeMap, + /// Per position, the route events in version order: `(version, class)`, + /// `None` for a retract. + routes: BTreeMap)>>, + version: RouteVersion, +} + +impl BindStore { + pub fn new() -> BindStore { + BindStore::default() + } + + /// Bind `class`. Idempotent for an identical binding; any other binding of + /// an already bound class is refused (D5). + pub fn register( + &mut self, + class: u32, + schema: ClassSchema, + methods: Vec, + ) -> Result<(), Refusal> { + self.insert(class, schema, methods, None) + } + + /// Bind `class` as an override of the bound class `base` (D-DT64-6 §3). + /// `base` keeps its behaviour; route positions to `class` to use it. + pub fn register_derived( + &mut self, + base: u32, + class: u32, + schema: ClassSchema, + methods: Vec, + ) -> Result<(), Refusal> { + if !self.classes.contains_key(&base) { + return Err(Refusal::UnknownClass(base)); + } + if base == class { + return Err(Refusal::BehaviourImmutable(class)); + } + self.insert(class, schema, methods, Some(base)) + } + + fn insert( + &mut self, + class: u32, + schema: ClassSchema, + methods: Vec, + derived_from: Option, + ) -> Result<(), Refusal> { + validate(class, &schema, &methods)?; + let entry = ClassEntry { + schema, + methods, + derived_from, + }; + match self.classes.get(&class) { + Some(bound) if *bound == entry => Ok(()), + Some(_) => Err(Refusal::BehaviourImmutable(class)), + None => { + self.classes.insert(class, entry); + Ok(()) + } + } + } + + /// Number of bound classes. + pub fn class_count(&self) -> usize { + self.classes.len() + } + + /// The current end of the route log. + pub fn current_version(&self) -> RouteVersion { + self.version + } + + /// Route `position` to the bound `class`; returns the new version. + pub fn route(&mut self, position: u16, class: u32) -> Result { + if !self.classes.contains_key(&class) { + return Err(Refusal::UnknownClass(class)); + } + Ok(self.push_route(position, Some(class))) + } + + /// Remove `position`'s route; returns the new version. The classid it + /// routed to keeps its behaviour. + pub fn retract(&mut self, position: u16) -> RouteVersion { + self.push_route(position, None) + } + + fn push_route(&mut self, position: u16, class: Option) -> RouteVersion { + self.version = RouteVersion(self.version.0 + 1); + self.routes + .entry(position) + .or_default() + .push((self.version, class)); + self.version + } + + /// The classid `position` routed to at `version`, `None` if it was + /// unrouted then. + pub fn routed_at(&self, version: RouteVersion, position: u16) -> Result, Refusal> { + if version > self.version { + return Err(Refusal::FutureVersion { + asked: version, + current: self.version, + }); + } + let Some(events) = self.routes.get(&position) else { + return Ok(None); + }; + // Events are in version order; take the last one at or before `version`. + let seen = events.partition_point(|(v, _)| *v <= version); + Ok(seen.checked_sub(1).and_then(|i| events[i].1)) + } + + /// The one lookup: `(classid, schema revision) → { fields, methods }`. + pub fn resolve<'s, 'v, V: ClassView + ?Sized>( + &'s self, + view: &'v V, + class: u32, + schema_rev: SchemaRev, + ) -> Result, Refusal> { + let entry = self + .classes + .get(&class) + .ok_or(Refusal::UnknownClass(class))?; + if entry.schema.schema_rev != schema_rev { + return Err(Refusal::SchemaRevisionMismatch { + class, + bound: entry.schema.schema_rev, + asked: schema_rev, + }); + } + let fields = view.fields(entry.schema.view_class); + if fields.len() != entry.schema.field_count { + return Err(Refusal::FieldBasisChanged { + class, + bound: entry.schema.field_count, + view: fields.len(), + }); + } + Ok(Resolved { + class, + schema: &entry.schema, + fields, + methods: &entry.methods, + derived_from: entry.derived_from, + }) + } + + /// The lookup by DR position: the classid routed there at `version`, then + /// [`BindStore::resolve`]. + pub fn resolve_at<'s, 'v, V: ClassView + ?Sized>( + &'s self, + view: &'v V, + version: RouteVersion, + position: u16, + schema_rev: SchemaRev, + ) -> Result, Refusal> { + let class = self + .routed_at(version, position)? + .ok_or(Refusal::Unrouted { position, version })?; + self.resolve(view, class, schema_rev) + } + + /// Check a router's method reference against the binding and return the + /// binding (D-DRDT §5): the method must exist, at the referenced signature + /// revision, with exactly the guard the reference witnesses. + pub fn admit(&self, r: &MethodRef) -> Result<&MethodBinding, Refusal> { + let entry = self + .classes + .get(&r.class) + .ok_or(Refusal::UnknownClass(r.class))?; + let (class, method) = (r.class, r.method); + let binding = entry + .methods + .iter() + .find(|m| m.id == method) + .ok_or(Refusal::UnknownMethod { class, method })?; + if binding.sig_rev != r.sig_rev { + return Err(Refusal::SignatureRevisionMismatch { + class, + method, + bound: binding.sig_rev, + asked: r.sig_rev, + }); + } + match (&binding.when, &r.guard) { + (None, None) => Ok(binding), + (Some(_), None) => Err(Refusal::GuardDropped { class, method }), + (None, Some(_)) => Err(Refusal::GuardAdded { class, method }), + (Some(g), Some(w)) if g.witness() == *w => Ok(binding), + (Some(_), Some(_)) => Err(Refusal::GuardMismatch { class, method }), + } + } +} + +/// Registration checks: the basis fits a [`WideFieldMask`], MethodIds are +/// unique in the class, and every field position a binding names is inside +/// the declared basis. +fn validate(class: u32, schema: &ClassSchema, methods: &[MethodBinding]) -> Result<(), Refusal> { + if schema.field_count > MAX_FIELDS { + return Err(Refusal::BasisTooWide { + class, + field_count: schema.field_count, + }); + } + for (i, m) in methods.iter().enumerate() { + if methods[..i].iter().any(|p| p.id == m.id) { + return Err(Refusal::DuplicateMethodId { + class, + method: m.id, + }); + } + } + let inside = |p: u8| usize::from(p) < schema.field_count; + for m in methods { + let positions = mask_positions(&m.on) + .chain(mask_positions(&m.reads)) + .chain(m.effects.iter().map(|e| e.target)); + for position in positions { + if !inside(position) { + return Err(Refusal::FieldOutOfBasis { + class, + method: m.id, + position, + }); + } + } + } + Ok(()) +} + +/// The set positions of a mask, ascending. +fn mask_positions(mask: &WideFieldMask) -> impl Iterator + '_ { + (0..=u8::MAX).filter(move |&p| mask.has(p)) +} diff --git a/crates/lance-graph-dt64/tests/bind_store.rs b/crates/lance-graph-dt64/tests/bind_store.rs new file mode 100644 index 000000000..8b71eef83 --- /dev/null +++ b/crates/lance-graph-dt64/tests/bind_store.rs @@ -0,0 +1,455 @@ +//! The DT64 bind store against the decisions it implements: D-DT64-6 §1 (D4, +//! D5, D6) and §3, D-DRDT §4 (the four gaps) and §5 (the DR #4 falsifier). + +use lance_graph_contract::class_view::{ClassId, ClassView, WideFieldMask}; +use lance_graph_contract::ontology::{DisplayTemplate, FieldRef}; +use lance_graph_contract::set_algebra::{Cmp, Logic, SetExpr}; +use lance_graph_contract::set_rules::{Population, Truth}; +use lance_graph_dt64::{ + AlgebraRef, BindStore, ClassSchema, Effect, Guard, MethodBinding, MethodId, MethodRef, Refusal, + RouteVersion, SchemaRev, SigRev, ValueDemand, +}; + +/// A ClassView with one class of `n` fields; `n` can be changed to model a +/// schema that moved under the binding. +struct View { + class: ClassId, + fields: Vec, +} + +impl View { + fn with_fields(class: ClassId, n: usize) -> View { + View { + class, + fields: (0..n) + .map(|i| FieldRef::new(format!("urn:f{i}"), format!("f{i}"))) + .collect(), + } + } +} + +impl ClassView for View { + fn fields(&self, class: ClassId) -> &[FieldRef] { + if class == self.class { + &self.fields + } else { + &[] + } + } + fn template(&self, _class: ClassId) -> DisplayTemplate { + DisplayTemplate::Detail + } + fn dolce_category_id(&self, _class: ClassId) -> u8 { + 0 + } +} + +const ORDER: u32 = 0x0A1E_0001; +const ORDER_V2: u32 = 0x0A1E_0002; +const VIEW_CLASS: ClassId = 0x0A1E; + +fn schema(rev: u32, fields: usize) -> ClassSchema { + ClassSchema { + view_class: VIEW_CLASS, + schema_rev: SchemaRev(rev), + field_count: fields, + } +} + +fn effect(target: u8, algebra: &str) -> Effect { + Effect { + target, + algebra: AlgebraRef::new(algebra), + } +} + +/// A method fired by a change of field 0, guarded by `state == 1`, writing +/// field 2 then field 3 in the given order. +fn method(id: u32, effects: Vec) -> MethodBinding { + MethodBinding { + id: MethodId(id), + sig_rev: SigRev(1), + name: format!("m{id}"), + on: WideFieldMask::from_positions(&[0]), + when: Some(Guard::new(SetExpr::eq_u32(1, 1), Logic::Kleene)), + reads: WideFieldMask::from_positions(&[0, 1]), + effects, + population: Population::Distinct, + truth: Truth::ThreeValued, + demand: ValueDemand::Values, + } +} + +fn store_with_order() -> BindStore { + let mut s = BindStore::new(); + s.register( + ORDER, + schema(1, 4), + vec![method( + 10, + vec![effect(2, "assign"), effect(3, "accumulate")], + )], + ) + .unwrap(); + s +} + +/// One lookup: class schema from the ClassView, method schema from the store, +/// for one classid at one schema revision (D-DRDT §4 gap 4). +#[test] +fn one_lookup_joins_class_schema_and_method_schema() { + let s = store_with_order(); + let view = View::with_fields(VIEW_CLASS, 4); + let r = s.resolve(&view, ORDER, SchemaRev(1)).unwrap(); + assert_eq!(r.fields.len(), 4, "fields come from the ClassView"); + assert_eq!(r.fields[2].label, "f2"); + assert_eq!(r.methods.len(), 1); + assert_eq!(r.methods[0].id, MethodId(10)); + assert_eq!(r.derived_from, None); +} + +/// An unregistered classid is UNKNOWN, never "no behaviour" (D-SAG-6 F06). +#[test] +fn an_unregistered_class_is_unknown_not_empty() { + let s = store_with_order(); + let view = View::with_fields(VIEW_CLASS, 4); + assert_eq!( + s.resolve(&view, 0xDEAD_0001, SchemaRev(1)).unwrap_err(), + Refusal::UnknownClass(0xDEAD_0001) + ); + // The registered one still resolves, so the refusal is about the id. + assert!(s.resolve(&view, ORDER, SchemaRev(1)).is_ok()); +} + +/// D5: a classid's behaviour is permanently immutable. Re-registering the same +/// binding is idempotent; any change is refused, and the old binding stands. +#[test] +fn class_behaviour_is_immutable() { + let mut s = store_with_order(); + let same = vec![method( + 10, + vec![effect(2, "assign"), effect(3, "accumulate")], + )]; + assert_eq!(s.register(ORDER, schema(1, 4), same), Ok(())); + + let reordered = vec![method( + 10, + vec![effect(3, "accumulate"), effect(2, "assign")], + )]; + assert_eq!( + s.register(ORDER, schema(1, 4), reordered), + Err(Refusal::BehaviourImmutable(ORDER)) + ); + let view = View::with_fields(VIEW_CLASS, 4); + let r = s.resolve(&view, ORDER, SchemaRev(1)).unwrap(); + assert_eq!( + r.methods[0].effects[0].target, 2, + "the first binding stands" + ); +} + +/// An override is a new derived classid plus a re-route (D-DT64-6 §3). Only +/// routing is versioned: replay as of a version reads the classid routed +/// then, and that classid's behaviour is the same as today. +#[test] +fn override_is_a_derived_class_and_a_reroute_and_replay_reads_the_old_route() { + let mut s = store_with_order(); + let v1 = s.route(7, ORDER).unwrap(); + s.register_derived( + ORDER, + ORDER_V2, + schema(1, 4), + vec![method( + 11, + vec![effect(3, "accumulate"), effect(2, "assign")], + )], + ) + .unwrap(); + let v2 = s.route(7, ORDER_V2).unwrap(); + let v3 = s.retract(7); + + assert_eq!(s.routed_at(RouteVersion(0), 7), Ok(None)); + assert_eq!(s.routed_at(v1, 7), Ok(Some(ORDER))); + assert_eq!(s.routed_at(v2, 7), Ok(Some(ORDER_V2))); + assert_eq!(s.routed_at(v3, 7), Ok(None)); + assert_eq!( + s.routed_at(v2, 8), + Ok(None), + "another position never routed" + ); + assert_eq!( + s.routed_at(RouteVersion(v3.0 + 1), 7), + Err(Refusal::FutureVersion { + asked: RouteVersion(v3.0 + 1), + current: v3 + }) + ); + + let view = View::with_fields(VIEW_CLASS, 4); + let old = s.resolve_at(&view, v1, 7, SchemaRev(1)).unwrap(); + assert_eq!(old.class, ORDER); + assert_eq!(old.methods[0].id, MethodId(10), "old behaviour unchanged"); + let new = s.resolve_at(&view, v2, 7, SchemaRev(1)).unwrap(); + assert_eq!(new.class, ORDER_V2); + assert_eq!(new.derived_from, Some(ORDER)); + assert_eq!( + s.resolve_at(&view, v3, 7, SchemaRev(1)).unwrap_err(), + Refusal::Unrouted { + position: 7, + version: v3 + } + ); +} + +/// Routing refuses a classid the store does not bind, and a derived class +/// needs a bound base. +#[test] +fn routes_and_derivations_need_bound_classes() { + let mut s = store_with_order(); + assert_eq!( + s.route(1, 0xBEEF_0001), + Err(Refusal::UnknownClass(0xBEEF_0001)) + ); + assert_eq!( + s.register_derived(0xBEEF_0001, ORDER_V2, schema(1, 4), vec![]), + Err(Refusal::UnknownClass(0xBEEF_0001)) + ); + assert_eq!( + s.register_derived(ORDER, ORDER, schema(1, 4), vec![]), + Err(Refusal::BehaviourImmutable(ORDER)) + ); + assert_eq!( + s.current_version(), + RouteVersion(0), + "no refused call moved the log" + ); +} + +/// D-DRDT §5, the first DR #4 falsifier: two methods on one cell with the same +/// read and write sets and different effect order stay distinct, and a dropped +/// guard refuses. +#[test] +fn same_read_write_sets_different_effect_order_stay_distinct_and_a_dropped_guard_refuses() { + let mut s = BindStore::new(); + let a = method(20, vec![effect(2, "assign"), effect(2, "accumulate")]); + let b = method(21, vec![effect(2, "accumulate"), effect(2, "assign")]); + // The negative mold: equal unordered read and write sets. + assert_eq!(a.reads, b.reads); + assert_eq!(a.writes(), b.writes()); + assert_ne!(a, b, "effect order is part of the binding"); + s.register(ORDER, schema(1, 4), vec![a.clone(), b.clone()]) + .unwrap(); + + let view = View::with_fields(VIEW_CLASS, 4); + let r = s.resolve(&view, ORDER, SchemaRev(1)).unwrap(); + let on_cell: Vec = r.methods_on(0).map(|m| m.id).collect(); + assert_eq!( + on_cell, + vec![MethodId(20), MethodId(21)], + "both kept, none collapsed" + ); + assert_eq!(r.methods_on(1).count(), 0, "field 1 triggers nothing"); + + // Each faithful reference admits its own binding with its own order. + let ra = MethodRef::of(ORDER, &a); + let rb = MethodRef::of(ORDER, &b); + assert_eq!(s.admit(&ra).unwrap().effects[0].algebra.as_str(), "assign"); + assert_eq!( + s.admit(&rb).unwrap().effects[0].algebra.as_str(), + "accumulate" + ); + + // A route that dropped the guard witness refuses. + let dropped = MethodRef { + guard: None, + ..ra.clone() + }; + assert_eq!( + s.admit(&dropped).unwrap_err(), + Refusal::GuardDropped { + class: ORDER, + method: MethodId(20) + } + ); + // A guard that is not the bound one refuses. + let other = MethodRef { + guard: Some(Guard::new(SetExpr::eq_u32(1, 2), Logic::Kleene).witness()), + ..ra.clone() + }; + assert_eq!( + s.admit(&other).unwrap_err(), + Refusal::GuardMismatch { + class: ORDER, + method: MethodId(20) + } + ); + // A stale signature revision refuses. + let stale = MethodRef { + sig_rev: SigRev(0), + ..ra + }; + assert_eq!( + s.admit(&stale).unwrap_err(), + Refusal::SignatureRevisionMismatch { + class: ORDER, + method: MethodId(20), + bound: SigRev(1), + asked: SigRev(0) + } + ); +} + +/// A guard on an unguarded method is not a witness either. +#[test] +fn a_guard_added_to_an_unguarded_method_refuses() { + let mut s = BindStore::new(); + let free = MethodBinding { + when: None, + ..method(30, vec![effect(2, "assign")]) + }; + s.register(ORDER, schema(1, 4), vec![free.clone()]).unwrap(); + assert!(s.admit(&MethodRef::of(ORDER, &free)).is_ok()); + let added = MethodRef { + guard: Some(Guard::new(SetExpr::All, Logic::TwoValued).witness()), + ..MethodRef::of(ORDER, &free) + }; + assert_eq!( + s.admit(&added).unwrap_err(), + Refusal::GuardAdded { + class: ORDER, + method: MethodId(30) + } + ); +} + +/// WHEN is three-valued: under Kleene logic `x < 5 OR x >= 5` is not the whole +/// population (a NULL row is UNKNOWN, and UNKNOWN never fires), while the +/// two-valued reading of the same expression is. Two-sided, so the witness is +/// neither always-equal nor always-different. +#[test] +fn a_kleene_guard_never_treats_unknown_as_true() { + let covering = SetExpr::Or(vec![ + SetExpr::i32(4, Cmp::Lt, 5), + SetExpr::i32(4, Cmp::Ge, 5), + ]); + let all = |logic| Guard::new(SetExpr::All, logic).witness(); + assert_eq!( + Guard::new(covering.clone(), Logic::TwoValued).witness(), + all(Logic::TwoValued) + ); + assert_ne!( + Guard::new(covering, Logic::Kleene).witness(), + all(Logic::Kleene) + ); + // The logic is part of the witness: the same expression under the other + // logic is a different guard. + assert_ne!(all(Logic::TwoValued), all(Logic::Kleene)); +} + +/// A method reference resolved at another schema revision is refused +/// (D-DRDT §5), and so is a ClassView whose field basis moved under the +/// binding. +#[test] +fn schema_revision_and_field_basis_are_checked() { + let s = store_with_order(); + let view = View::with_fields(VIEW_CLASS, 4); + assert_eq!( + s.resolve(&view, ORDER, SchemaRev(2)).unwrap_err(), + Refusal::SchemaRevisionMismatch { + class: ORDER, + bound: SchemaRev(1), + asked: SchemaRev(2) + } + ); + let grown = View::with_fields(VIEW_CLASS, 5); + assert_eq!( + s.resolve(&grown, ORDER, SchemaRev(1)).unwrap_err(), + Refusal::FieldBasisChanged { + class: ORDER, + bound: 4, + view: 5 + } + ); +} + +/// Registration refuses what would make a binding ambiguous or point outside +/// its class: a repeated MethodId, a field position past the schema, and a +/// basis wider than a `WideFieldMask` addresses. +#[test] +fn registration_refuses_ambiguity_and_out_of_basis_positions() { + let mut s = BindStore::new(); + assert_eq!( + s.register( + ORDER, + schema(1, 4), + vec![method(1, vec![]), method(1, vec![effect(2, "assign")])] + ), + Err(Refusal::DuplicateMethodId { + class: ORDER, + method: MethodId(1) + }) + ); + assert_eq!( + s.register( + ORDER, + schema(1, 4), + vec![method(1, vec![effect(4, "assign")])] + ), + Err(Refusal::FieldOutOfBasis { + class: ORDER, + method: MethodId(1), + position: 4 + }) + ); + let reads_past = MethodBinding { + reads: WideFieldMask::from_positions(&[9]), + ..method(1, vec![]) + }; + assert_eq!( + s.register(ORDER, schema(1, 4), vec![reads_past]), + Err(Refusal::FieldOutOfBasis { + class: ORDER, + method: MethodId(1), + position: 9 + }) + ); + assert_eq!( + s.register(ORDER, schema(1, 257), vec![]), + Err(Refusal::BasisTooWide { + class: ORDER, + field_count: 257 + }) + ); + // None of the refused registrations bound the class. + assert_eq!(s.class_count(), 0); + // The largest legal basis is accepted, with a write on its last position. + assert_eq!( + s.register( + ORDER, + schema(1, 256), + vec![method(1, vec![effect(255, "assign")])] + ), + Ok(()) + ); +} + +/// The store records what a method needs (D-DT64-6 A.1): population, truth and +/// value demand ride the binding unchanged; the store never refuses a Bag, +/// because admitting a counted population is the binder's call, not DT's. +#[test] +fn population_truth_and_demand_ride_the_binding() { + let mut s = BindStore::new(); + let count = MethodBinding { + population: Population::Bag, + truth: Truth::TwoValued, + demand: ValueDemand::MapOnly, + when: None, + ..method(40, vec![]) + }; + s.register(ORDER, schema(1, 4), vec![count.clone()]) + .unwrap(); + let got = s.admit(&MethodRef::of(ORDER, &count)).unwrap(); + assert_eq!(got.population, Population::Bag); + assert_eq!(got.truth, Truth::TwoValued); + assert_eq!(got.demand, ValueDemand::MapOnly); +} From c46645fe0c806cec31ff4490c35ced5e5e5aca5c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 15:38:31 +0000 Subject: [PATCH 02/11] D-DT64-6 A.5: record the eight lance-graph-dt64 disable runs Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XAqKMPh2Seh2WLccCksAdY --- .claude/plans/d-dt64-6-routing-bindstore-behavior-v1.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.claude/plans/d-dt64-6-routing-bindstore-behavior-v1.md b/.claude/plans/d-dt64-6-routing-bindstore-behavior-v1.md index c92b232f6..c234f84f2 100644 --- a/.claude/plans/d-dt64-6-routing-bindstore-behavior-v1.md +++ b/.claude/plans/d-dt64-6-routing-bindstore-behavior-v1.md @@ -256,6 +256,6 @@ The Bag carrier (who admits a counted population into `bind_dr64`); the D-DRDT | A.1: population, truth, value demand | `Population` / `Truth` from `set_rules` and `ValueDemand` ride the binding; a Bag is recorded, never refused here (admitting it is the binder's call) | `population_truth_and_demand_ride_the_binding` | | Registration hygiene | `DuplicateMethodId`, `FieldOutOfBasis` (ON, reads, effect targets), `BasisTooWide` (> 256) | `registration_refuses_…` | -Disable runs (committed first, each guard removed, the named test red, restored with `git checkout`): listed in the commit that records them. +Disable runs (on commit `7936665e2`; each patch asserted to apply exactly once; restored with `git checkout`): each of the eight guards removed in turn leaves exactly one test red (10 passed, 1 failed): the `GuardDropped` arm, the `BehaviourImmutable` arm, the route-log version cut, the Kleene witness, the field-basis check, the schema-revision check, the duplicate-MethodId check, the out-of-basis check. Not done: wiring OGAR's `CapabilityAuthority` (D6, the production route for consumer classids); persistence of the store (EXO-55 owns the writer path); a router (DR #4) that carries `MethodRef`s; §6 questions 1–9, including whether the immutable unit is the classid or `(classid, binding version)` — this crate implements the D5 wording (the classid). From 1be74a4cfba6d61f4a17d90ecabf172d7f03b8f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 15:45:34 +0000 Subject: [PATCH 03/11] lance-graph-dt64: classid as lens; reuse of lens entries by reference A classid is a codebook lens, never one per item (operator). So: - ClassSchema drops the declared view class; view_class(classid) is ogar_codebook::classid_concept, the codebook's own split. - Effect carries EffectOp::{Algebra(AlgebraRef), Call(MethodRef)}. A Call reuses another lens's entry by reference, is admitted when the caller registers (CallRefused wraps the reason), and callers_of(lens) lists who reuses it. Shaped by the XLOG session's critique: its per-Y count is three Calls into one algebra lens (group-count twice, checked i64 Mul), two classes bound in total. Route targets stay bare classids. 14 tests; clippy and fmt clean. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XAqKMPh2Seh2WLccCksAdY --- .../d-dt64-6-routing-bindstore-behavior-v1.md | 6 + crates/lance-graph-dt64/src/lib.rs | 81 ++++++- crates/lance-graph-dt64/tests/bind_store.rs | 214 +++++++++++++++++- 3 files changed, 282 insertions(+), 19 deletions(-) diff --git a/.claude/plans/d-dt64-6-routing-bindstore-behavior-v1.md b/.claude/plans/d-dt64-6-routing-bindstore-behavior-v1.md index c234f84f2..94d7eddb0 100644 --- a/.claude/plans/d-dt64-6-routing-bindstore-behavior-v1.md +++ b/.claude/plans/d-dt64-6-routing-bindstore-behavior-v1.md @@ -259,3 +259,9 @@ The Bag carrier (who admits a counted population into `bind_dr64`); the D-DRDT Disable runs (on commit `7936665e2`; each patch asserted to apply exactly once; restored with `git checkout`): each of the eight guards removed in turn leaves exactly one test red (10 passed, 1 failed): the `GuardDropped` arm, the `BehaviourImmutable` arm, the route-log version cut, the Kleene witness, the field-basis check, the schema-revision check, the duplicate-MethodId check, the out-of-basis check. Not done: wiring OGAR's `CapabilityAuthority` (D6, the production route for consumer classids); persistence of the store (EXO-55 owns the writer path); a router (DR #4) that carries `MethodRef`s; §6 questions 1–9, including whether the immutable unit is the classid or `(classid, binding version)` — this crate implements the D5 wording (the classid). + +**A.5 addendum (2026-10-30, after the XLOG session's critique and the operator's correction).** A classid is a codebook lens, never one per codebook item. Two changes follow: +- `ClassSchema` no longer declares a view class. `view_class(classid)` is the codebook's own split (`ogar_codebook::classid_concept`). +- `Effect` carries an `EffectOp`: `Algebra(AlgebraRef)`, or `Call(MethodRef)` for reusing another lens's entry by reference. A `Call` is admitted when the caller registers (`CallRefused` wraps the reason), and `callers_of(lens)` lists who reuses it. + +In the test, XLOG's per-Y count is three Calls into one algebra lens: group-count twice, then checked i64 Mul. That binds two classes in total; no algebra item gets a classid. Route targets stay bare classids (XLOG: a per-route mask would break D5). Open, with the owners: whether tract-harvest-probe's per-op lab classids (`0x0F01…`) become entries under one lens (EXO-50 message 27); the `MethodSignature` vs `MethodRef` identity (dr-math owner); where the ONNX/XLOG algebra entries are registered. diff --git a/crates/lance-graph-dt64/src/lib.rs b/crates/lance-graph-dt64/src/lib.rs index 4141c0ea1..fd4e92bd4 100644 --- a/crates/lance-graph-dt64/src/lib.rs +++ b/crates/lance-graph-dt64/src/lib.rs @@ -48,10 +48,12 @@ //! # Classids //! //! A bound class is a `u32`, the key [`ActionDef::object_class`] and -//! [`ClassMethods::classid`] already use (for a consumer: its composed -//! `concept << 16 | app prefix`, D-DT64-6 §3). Its field basis is read from -//! the [`ClassView`] under [`ClassSchema::view_class`], declared explicitly at -//! registration; this crate never derives one id from the other's bits. +//! [`ClassMethods::classid`] already use. A classid is a codebook lens: its +//! field basis is the [`ClassView`] of its concept half ([`view_class`], the +//! codebook's own `classid_concept` split), labels are resolved late through +//! that view, and its entries are the [`MethodId`]s inside it. A codebook item +//! never gets a classid of its own; another class reuses it by reference +//! ([`EffectOp::Call`]). //! //! [`ActionDef::object_class`]: lance_graph_contract::action::ActionDef::object_class //! [`ClassMethods::classid`]: lance_graph_contract::codegen_manifest::ClassMethods::classid @@ -61,6 +63,7 @@ use std::collections::BTreeMap; use lance_graph_contract::class_view::{ClassId, ClassView, WideFieldMask}; +use lance_graph_contract::ogar_codebook::classid_concept; use lance_graph_contract::ontology::FieldRef; use lance_graph_contract::set_algebra::{normalize, normalize_kleene, Logic, SetExpr}; use lance_graph_contract::set_rules::{Population, Truth}; @@ -99,13 +102,26 @@ impl AlgebraRef { } } -/// One DO step: which field position of the class it writes, and the -/// polyfill that writes it. A method's effects are an ORDERED list. +/// One DO step: which field position of the class it writes, and what writes +/// it. A method's effects are an ORDERED list. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct Effect { /// Field position in the class basis (the `WideFieldMask` bit). pub target: u8, - pub algebra: AlgebraRef, + pub op: EffectOp, +} + +/// What carries out one effect. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum EffectOp { + /// A polyfill named by reference; compared, never resolved or run here. + Algebra(AlgebraRef), + /// An entry of another class's codebook, reused by reference: the method + /// `(class, MethodId)` at its signature revision with its guard witness. + /// Admitted against the store when the calling class registers, so an + /// unknown or stale callee is refused then, never at run time. A class + /// cannot call itself: it is not bound yet while it registers. + Call(MethodRef), } /// WHEN: a guard over the population, stated as the set description Quack's @@ -186,16 +202,22 @@ impl MethodBinding { } } -/// A class's schema as bound: which [`ClassView`] class carries its field -/// basis, the schema revision, and the field count the methods were bound -/// against. +/// A class's schema as bound: the schema revision and the field count the +/// methods were bound against. The field basis itself is the [`ClassView`] +/// class [`view_class`] of the classid, never declared here. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct ClassSchema { - pub view_class: ClassId, pub schema_rev: SchemaRev, pub field_count: usize, } +/// The [`ClassView`] class a classid is read through: its concept half, by +/// the codebook's own split ([`classid_concept`]). The classid is the lens; +/// this crate does no bit arithmetic of its own. +pub fn view_class(class: u32) -> ClassId { + classid_concept(class) +} + /// The widest basis a [`WideFieldMask`] addresses (positions are `u8`). pub const MAX_FIELDS: usize = 256; @@ -275,6 +297,12 @@ pub enum Refusal { GuardAdded { class: u32, method: MethodId }, /// The reference carries another guard than the bound one. GuardMismatch { class: u32, method: MethodId }, + /// An [`EffectOp::Call`] was not admitted; `callee` says why. + CallRefused { + class: u32, + method: MethodId, + callee: Box, + }, /// No classid was routed to the position at that version. Unrouted { position: u16, @@ -366,6 +394,17 @@ impl BindStore { derived_from: Option, ) -> Result<(), Refusal> { validate(class, &schema, &methods)?; + for m in &methods { + for e in &m.effects { + if let EffectOp::Call(callee) = &e.op { + self.admit(callee).map_err(|r| Refusal::CallRefused { + class, + method: m.id, + callee: Box::new(r), + })?; + } + } + } let entry = ClassEntry { schema, methods, @@ -381,6 +420,24 @@ impl BindStore { } } + /// Every `(class, method)` whose DO calls an entry of `class`: who reuses + /// that lens. + pub fn callers_of(&self, class: u32) -> Vec<(u32, MethodId)> { + let mut out = Vec::new(); + for (&caller, entry) in &self.classes { + for m in &entry.methods { + let calls = m + .effects + .iter() + .any(|e| matches!(&e.op, EffectOp::Call(r) if r.class == class)); + if calls { + out.push((caller, m.id)); + } + } + } + out + } + /// Number of bound classes. pub fn class_count(&self) -> usize { self.classes.len() @@ -449,7 +506,7 @@ impl BindStore { asked: schema_rev, }); } - let fields = view.fields(entry.schema.view_class); + let fields = view.fields(view_class(class)); if fields.len() != entry.schema.field_count { return Err(Refusal::FieldBasisChanged { class, diff --git a/crates/lance-graph-dt64/tests/bind_store.rs b/crates/lance-graph-dt64/tests/bind_store.rs index 8b71eef83..18b7bbfb0 100644 --- a/crates/lance-graph-dt64/tests/bind_store.rs +++ b/crates/lance-graph-dt64/tests/bind_store.rs @@ -6,8 +6,8 @@ use lance_graph_contract::ontology::{DisplayTemplate, FieldRef}; use lance_graph_contract::set_algebra::{Cmp, Logic, SetExpr}; use lance_graph_contract::set_rules::{Population, Truth}; use lance_graph_dt64::{ - AlgebraRef, BindStore, ClassSchema, Effect, Guard, MethodBinding, MethodId, MethodRef, Refusal, - RouteVersion, SchemaRev, SigRev, ValueDemand, + view_class, AlgebraRef, BindStore, ClassSchema, Effect, EffectOp, Guard, MethodBinding, + MethodId, MethodRef, Refusal, RouteVersion, SchemaRev, SigRev, ValueDemand, }; /// A ClassView with one class of `n` fields; `n` can be changed to model a @@ -46,11 +46,11 @@ impl ClassView for View { const ORDER: u32 = 0x0A1E_0001; const ORDER_V2: u32 = 0x0A1E_0002; +/// The lens ORDER is read through: its concept half (`view_class`). const VIEW_CLASS: ClassId = 0x0A1E; fn schema(rev: u32, fields: usize) -> ClassSchema { ClassSchema { - view_class: VIEW_CLASS, schema_rev: SchemaRev(rev), field_count: fields, } @@ -59,7 +59,7 @@ fn schema(rev: u32, fields: usize) -> ClassSchema { fn effect(target: u8, algebra: &str) -> Effect { Effect { target, - algebra: AlgebraRef::new(algebra), + op: EffectOp::Algebra(AlgebraRef::new(algebra)), } } @@ -253,10 +253,13 @@ fn same_read_write_sets_different_effect_order_stay_distinct_and_a_dropped_guard // Each faithful reference admits its own binding with its own order. let ra = MethodRef::of(ORDER, &a); let rb = MethodRef::of(ORDER, &b); - assert_eq!(s.admit(&ra).unwrap().effects[0].algebra.as_str(), "assign"); assert_eq!( - s.admit(&rb).unwrap().effects[0].algebra.as_str(), - "accumulate" + s.admit(&ra).unwrap().effects[0].op, + EffectOp::Algebra(AlgebraRef::new("assign")) + ); + assert_eq!( + s.admit(&rb).unwrap().effects[0].op, + EffectOp::Algebra(AlgebraRef::new("accumulate")) ); // A route that dropped the guard witness refuses. @@ -453,3 +456,200 @@ fn population_truth_and_demand_ride_the_binding() { assert_eq!(got.truth, Truth::TwoValued); assert_eq!(got.demand, ValueDemand::MapOnly); } + +/// A classid is a lens: its field basis is the ClassView of its concept half, +/// derived by the codebook's split, never declared. A view that carries the +/// fields under another class does not satisfy it. +#[test] +fn the_lens_is_the_classids_concept_half() { + assert_eq!(view_class(ORDER), VIEW_CLASS); + assert_eq!( + view_class(ORDER_V2), + VIEW_CLASS, + "app half does not move the lens" + ); + let s = store_with_order(); + assert!(s + .resolve(&View::with_fields(VIEW_CLASS, 4), ORDER, SchemaRev(1)) + .is_ok()); + assert_eq!( + s.resolve(&View::with_fields(0x0A1F, 4), ORDER, SchemaRev(1)) + .unwrap_err(), + Refusal::FieldBasisChanged { + class: ORDER, + bound: 4, + view: 0 + } + ); +} + +/// Lab stand-ins: one algebra lens whose entries are two native leaves, and +/// one consumer lens (XLOG's per-Y count). No classid per algebra item. +const ALG: u32 = 0x7F02_0001; +const XLOG: u32 = 0x7F01_0001; +const GROUP_COUNT: MethodId = MethodId(1); +const MUL_I64: MethodId = MethodId(2); + +fn leaf(id: MethodId, native: &str) -> MethodBinding { + MethodBinding { + id, + sig_rev: SigRev(1), + name: native.to_string(), + on: WideFieldMask::from_positions(&[]), + when: None, + reads: WideFieldMask::from_positions(&[]), + effects: vec![effect(0, native)], + population: Population::Distinct, + truth: Truth::TwoValued, + demand: ValueDemand::Values, + } +} + +fn algebra_store() -> BindStore { + let mut s = BindStore::new(); + s.register( + ALG, + schema(1, 1), + vec![ + leaf(GROUP_COUNT, "ndarray::simd::masked_group_count_u32"), + leaf( + MUL_I64, + "ndarray::hpc::checked_i64::checked_binary_i64(Mul)", + ), + ], + ) + .unwrap(); + s +} + +fn call(s: &BindStore, class: u32, method: MethodId) -> EffectOp { + let entry = MethodRef { + class, + method, + sig_rev: SigRev(1), + guard: None, + }; + s.admit(&entry).unwrap(); + EffectOp::Call(entry) +} + +/// XLOG's count `q(Y) :- r(X,Y), s(Y,Z)`: fields r=0, s=1, two counts 2 and +/// 3, the product 4. Its DO calls the algebra lens's entries in order. +fn xlog_count(s: &BindStore) -> MethodBinding { + MethodBinding { + id: MethodId(1), + sig_rev: SigRev(1), + name: "count q(Y)".to_string(), + on: WideFieldMask::from_positions(&[0, 1]), + when: None, + reads: WideFieldMask::from_positions(&[0, 1]), + effects: vec![ + Effect { + target: 2, + op: call(s, ALG, GROUP_COUNT), + }, + Effect { + target: 3, + op: call(s, ALG, GROUP_COUNT), + }, + Effect { + target: 4, + op: call(s, ALG, MUL_I64), + }, + ], + population: Population::Bag, + truth: Truth::TwoValued, + demand: ValueDemand::Values, + } +} + +#[test] +fn a_consumer_reuses_algebra_entries_by_reference() { + let mut s = algebra_store(); + let count = xlog_count(&s); + s.register(XLOG, schema(1, 5), vec![count.clone()]).unwrap(); + + assert_eq!(s.callers_of(ALG), vec![(XLOG, MethodId(1))]); + assert!(s.callers_of(XLOG).is_empty()); + let got = s.admit(&MethodRef::of(XLOG, &count)).unwrap(); + let callees: Vec = got + .effects + .iter() + .map(|e| match &e.op { + EffectOp::Call(r) => r.method, + EffectOp::Algebra(_) => panic!("XLOG declares no algebra of its own"), + }) + .collect(); + assert_eq!(callees, vec![GROUP_COUNT, GROUP_COUNT, MUL_I64]); + // Two classes bound, not one per algebra item. + assert_eq!(s.class_count(), 2); + + // The reused lens cannot change under its caller (D5). + assert_eq!( + s.register(ALG, schema(1, 1), vec![leaf(GROUP_COUNT, "other")]), + Err(Refusal::BehaviourImmutable(ALG)) + ); +} + +/// A call is admitted when the caller registers: an unknown entry, a stale +/// signature revision, an unbound lens and a dropped guard are refused then, +/// and the caller is not bound. +#[test] +fn a_call_to_an_unknown_or_stale_entry_is_refused_at_registration() { + let refused = |s: &mut BindStore, callee: MethodRef| { + let m = MethodBinding { + effects: vec![Effect { + target: 2, + op: EffectOp::Call(callee), + }], + ..method(5, vec![]) + }; + s.register(XLOG, schema(1, 5), vec![m]).unwrap_err() + }; + let entry = |class, method, sig| MethodRef { + class, + method, + sig_rev: SigRev(sig), + guard: None, + }; + let wrap = |callee| Refusal::CallRefused { + class: XLOG, + method: MethodId(5), + callee: Box::new(callee), + }; + + let mut empty = BindStore::new(); + assert_eq!( + refused(&mut empty, entry(ALG, GROUP_COUNT, 1)), + wrap(Refusal::UnknownClass(ALG)) + ); + + let mut s = algebra_store(); + assert_eq!( + refused(&mut s, entry(ALG, MethodId(9), 1)), + wrap(Refusal::UnknownMethod { + class: ALG, + method: MethodId(9) + }) + ); + assert_eq!( + refused(&mut s, entry(ALG, MUL_I64, 0)), + wrap(Refusal::SignatureRevisionMismatch { + class: ALG, + method: MUL_I64, + bound: SigRev(1), + asked: SigRev(0) + }) + ); + // A guard the entry does not have is refused too. + let mut guarded = entry(ALG, MUL_I64, 1); + guarded.guard = Some(Guard::new(SetExpr::All, Logic::TwoValued).witness()); + assert_eq!( + refused(&mut s, guarded), + wrap(Refusal::GuardAdded { + class: ALG, + method: MUL_I64 + }) + ); + assert_eq!(s.class_count(), 1, "no refused caller was bound"); +} From f73633777b8e208ddfb8b2494d9813c6ef4ef269 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 15:47:31 +0000 Subject: [PATCH 04/11] lance-graph-dt64: ordered operands on effects; arity in the entry signature The XLOG session found that a Call recorded where a result goes but not what it reads: the binding could not tell counting R and S apart from counting R twice. Effect now carries ordered args (Field position or Const). MethodBinding::inputs is part of the signature, and a Call with another arity is refused at registration (ArityMismatch in CallRefused). Field args are checked against the caller's basis. 15 tests. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XAqKMPh2Seh2WLccCksAdY --- .../d-dt64-6-routing-bindstore-behavior-v1.md | 2 + crates/lance-graph-dt64/src/lib.rs | 44 ++++++++++++- crates/lance-graph-dt64/tests/bind_store.rs | 64 +++++++++++++++++-- 3 files changed, 103 insertions(+), 7 deletions(-) diff --git a/.claude/plans/d-dt64-6-routing-bindstore-behavior-v1.md b/.claude/plans/d-dt64-6-routing-bindstore-behavior-v1.md index 94d7eddb0..9c7d9b116 100644 --- a/.claude/plans/d-dt64-6-routing-bindstore-behavior-v1.md +++ b/.claude/plans/d-dt64-6-routing-bindstore-behavior-v1.md @@ -265,3 +265,5 @@ Not done: wiring OGAR's `CapabilityAuthority` (D6, the production route for cons - `Effect` carries an `EffectOp`: `Algebra(AlgebraRef)`, or `Call(MethodRef)` for reusing another lens's entry by reference. A `Call` is admitted when the caller registers (`CallRefused` wraps the reason), and `callers_of(lens)` lists who reuses it. In the test, XLOG's per-Y count is three Calls into one algebra lens: group-count twice, then checked i64 Mul. That binds two classes in total; no algebra item gets a classid. Route targets stay bare classids (XLOG: a per-route mask would break D5). Open, with the owners: whether tract-harvest-probe's per-op lab classids (`0x0F01…`) become entries under one lens (EXO-50 message 27); the `MethodSignature` vs `MethodRef` identity (dr-math owner); where the ONNX/XLOG algebra entries are registered. + +**A.5 addendum 2 (2026-10-30, XLOG correction).** A Call had no operands, so the stored binding could not tell XLOG's count from one that counts R twice. Fix: `Effect { target, op, args: Vec }`, where `Arg` is `Field(u8)` (a position checked against the caller's basis) or `Const(i64)` (a key column, a lane). `MethodBinding::inputs` is part of the entry's signature, and a Call passing another arity is refused at registration (`ArityMismatch` inside `CallRefused`). XLOG's count: `cnt_r = Call(group-count, [r, key 1])`, `cnt_s = Call(group-count, [s, key 0])`, `count = Call(Mul i64, [cnt_r, cnt_s])`. `demand` stays `Values`, because the key column is read as a value; that is stated here, not settled. diff --git a/crates/lance-graph-dt64/src/lib.rs b/crates/lance-graph-dt64/src/lib.rs index fd4e92bd4..e49bdbbbb 100644 --- a/crates/lance-graph-dt64/src/lib.rs +++ b/crates/lance-graph-dt64/src/lib.rs @@ -109,6 +109,19 @@ pub struct Effect { /// Field position in the class basis (the `WideFieldMask` bit). pub target: u8, pub op: EffectOp, + /// What the step reads, in order. Order is load-bearing (a `Sub` or a + /// keyed count), so two steps with the same op and different arguments + /// are different steps. + pub args: Vec, +} + +/// One input of an effect. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Arg { + /// A field position of the calling class (checked against its basis). + Field(u8), + /// A constant parameter: a key column index, a lane. + Const(i64), } /// What carries out one effect. @@ -180,6 +193,9 @@ pub struct MethodBinding { /// The source name (`MethodSig::name`, `ActionDef::predicate`). Carried, /// never used as identity. pub name: String, + /// How many arguments a [`EffectOp::Call`] of this method passes. Part of + /// the signature: a change is a new [`SigRev`]. + pub inputs: u8, /// ON: the field positions whose change triggers the method. pub on: WideFieldMask, /// WHEN: `None` for an unguarded method. @@ -297,6 +313,13 @@ pub enum Refusal { GuardAdded { class: u32, method: MethodId }, /// The reference carries another guard than the bound one. GuardMismatch { class: u32, method: MethodId }, + /// A call passes another number of arguments than the entry takes. + ArityMismatch { + class: u32, + method: MethodId, + expected: u8, + got: usize, + }, /// An [`EffectOp::Call`] was not admitted; `callee` says why. CallRefused { class: u32, @@ -397,11 +420,20 @@ impl BindStore { for m in &methods { for e in &m.effects { if let EffectOp::Call(callee) = &e.op { - self.admit(callee).map_err(|r| Refusal::CallRefused { + let refused = |r| Refusal::CallRefused { class, method: m.id, callee: Box::new(r), - })?; + }; + let entry = self.admit(callee).map_err(refused)?; + if usize::from(entry.inputs) != e.args.len() { + return Err(refused(Refusal::ArityMismatch { + class: callee.class, + method: callee.method, + expected: entry.inputs, + got: e.args.len(), + })); + } } } } @@ -592,7 +624,13 @@ fn validate(class: u32, schema: &ClassSchema, methods: &[MethodBinding]) -> Resu for m in methods { let positions = mask_positions(&m.on) .chain(mask_positions(&m.reads)) - .chain(m.effects.iter().map(|e| e.target)); + .chain(m.effects.iter().map(|e| e.target)) + .chain(m.effects.iter().flat_map(|e| { + e.args.iter().filter_map(|a| match a { + Arg::Field(p) => Some(*p), + Arg::Const(_) => None, + }) + })); for position in positions { if !inside(position) { return Err(Refusal::FieldOutOfBasis { diff --git a/crates/lance-graph-dt64/tests/bind_store.rs b/crates/lance-graph-dt64/tests/bind_store.rs index 18b7bbfb0..dd7a72d9e 100644 --- a/crates/lance-graph-dt64/tests/bind_store.rs +++ b/crates/lance-graph-dt64/tests/bind_store.rs @@ -6,7 +6,7 @@ use lance_graph_contract::ontology::{DisplayTemplate, FieldRef}; use lance_graph_contract::set_algebra::{Cmp, Logic, SetExpr}; use lance_graph_contract::set_rules::{Population, Truth}; use lance_graph_dt64::{ - view_class, AlgebraRef, BindStore, ClassSchema, Effect, EffectOp, Guard, MethodBinding, + view_class, AlgebraRef, Arg, BindStore, ClassSchema, Effect, EffectOp, Guard, MethodBinding, MethodId, MethodRef, Refusal, RouteVersion, SchemaRev, SigRev, ValueDemand, }; @@ -60,6 +60,7 @@ fn effect(target: u8, algebra: &str) -> Effect { Effect { target, op: EffectOp::Algebra(AlgebraRef::new(algebra)), + args: vec![], } } @@ -70,6 +71,7 @@ fn method(id: u32, effects: Vec) -> MethodBinding { id: MethodId(id), sig_rev: SigRev(1), name: format!("m{id}"), + inputs: 0, on: WideFieldMask::from_positions(&[0]), when: Some(Guard::new(SetExpr::eq_u32(1, 1), Logic::Kleene)), reads: WideFieldMask::from_positions(&[0, 1]), @@ -490,11 +492,12 @@ const XLOG: u32 = 0x7F01_0001; const GROUP_COUNT: MethodId = MethodId(1); const MUL_I64: MethodId = MethodId(2); -fn leaf(id: MethodId, native: &str) -> MethodBinding { +fn leaf(id: MethodId, native: &str, inputs: u8) -> MethodBinding { MethodBinding { id, sig_rev: SigRev(1), name: native.to_string(), + inputs, on: WideFieldMask::from_positions(&[]), when: None, reads: WideFieldMask::from_positions(&[]), @@ -511,10 +514,12 @@ fn algebra_store() -> BindStore { ALG, schema(1, 1), vec![ - leaf(GROUP_COUNT, "ndarray::simd::masked_group_count_u32"), + // (relation field, key column) + leaf(GROUP_COUNT, "ndarray::simd::masked_group_count_u32", 2), leaf( MUL_I64, "ndarray::hpc::checked_i64::checked_binary_i64(Mul)", + 2, ), ], ) @@ -540,6 +545,7 @@ fn xlog_count(s: &BindStore) -> MethodBinding { id: MethodId(1), sig_rev: SigRev(1), name: "count q(Y)".to_string(), + inputs: 0, on: WideFieldMask::from_positions(&[0, 1]), when: None, reads: WideFieldMask::from_positions(&[0, 1]), @@ -547,14 +553,19 @@ fn xlog_count(s: &BindStore) -> MethodBinding { Effect { target: 2, op: call(s, ALG, GROUP_COUNT), + // R keyed by its y column (1) + args: vec![Arg::Field(0), Arg::Const(1)], }, Effect { target: 3, op: call(s, ALG, GROUP_COUNT), + // S keyed by its y column (0) + args: vec![Arg::Field(1), Arg::Const(0)], }, Effect { target: 4, op: call(s, ALG, MUL_I64), + args: vec![Arg::Field(2), Arg::Field(3)], }, ], population: Population::Bag, @@ -586,7 +597,7 @@ fn a_consumer_reuses_algebra_entries_by_reference() { // The reused lens cannot change under its caller (D5). assert_eq!( - s.register(ALG, schema(1, 1), vec![leaf(GROUP_COUNT, "other")]), + s.register(ALG, schema(1, 1), vec![leaf(GROUP_COUNT, "other", 2)]), Err(Refusal::BehaviourImmutable(ALG)) ); } @@ -601,6 +612,7 @@ fn a_call_to_an_unknown_or_stale_entry_is_refused_at_registration() { effects: vec![Effect { target: 2, op: EffectOp::Call(callee), + args: vec![Arg::Const(0), Arg::Const(0)], }], ..method(5, vec![]) }; @@ -653,3 +665,47 @@ fn a_call_to_an_unknown_or_stale_entry_is_refused_at_registration() { ); assert_eq!(s.class_count(), 1, "no refused caller was bound"); } + +/// The operands are part of the binding (XLOG's correction): counting R twice +/// is a different binding from counting R and S, and a call with the wrong +/// number of operands, or an operand outside the caller's basis, is refused at +/// registration. +#[test] +fn call_operands_are_bound_and_checked() { + let s = algebra_store(); + let count = xlog_count(&s); + let mut twice_r = count.clone(); + twice_r.effects[1].args = vec![Arg::Field(0), Arg::Const(1)]; + assert_ne!(count, twice_r, "operands tell the two counts apart"); + let mut swapped = count.clone(); + swapped.effects[2].args = vec![Arg::Field(3), Arg::Field(2)]; + assert_ne!(count, swapped, "operand order is part of the step"); + + let mut s2 = algebra_store(); + let mut short = count.clone(); + short.effects[2].args = vec![Arg::Field(2)]; + assert_eq!( + s2.register(XLOG, schema(1, 5), vec![short]), + Err(Refusal::CallRefused { + class: XLOG, + method: MethodId(1), + callee: Box::new(Refusal::ArityMismatch { + class: ALG, + method: MUL_I64, + expected: 2, + got: 1 + }) + }) + ); + let mut outside = count.clone(); + outside.effects[2].args = vec![Arg::Field(2), Arg::Field(7)]; + assert_eq!( + s2.register(XLOG, schema(1, 5), vec![outside]), + Err(Refusal::FieldOutOfBasis { + class: XLOG, + method: MethodId(1), + position: 7 + }) + ); + assert_eq!(s2.register(XLOG, schema(1, 5), vec![count]), Ok(())); +} From 033de0b4d0f0f5ec10455dc7625cac4754ba7965 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 15:52:05 +0000 Subject: [PATCH 05/11] lance-graph-dt64: classid switches over a classid-free algebra catalogue Operator: the classid switches behaviour, labels and polyfill over otherwise agnostic addresses and algebra methods. The algebra lens classid and class-to-class Call were wrong and are removed. The store holds a classid-free algebra catalogue (AlgebraEntry: id, sig_rev, polyfill, inputs; immutable; D-DT64-6 D6). A consumer's effects bind an AlgebraRef with ordered operands, checked at registration (UnknownAlgebra, AlgebraRevisionMismatch, ArityMismatch). users_of lists the classids switching over an entry. XLOG's count and an ONNX Mul(int64) node reuse one entry; only the consumers carry classids. 15 tests. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XAqKMPh2Seh2WLccCksAdY --- .../d-dt64-6-routing-bindstore-behavior-v1.md | 8 + crates/lance-graph-dt64/src/lib.rs | 179 ++++++---- crates/lance-graph-dt64/tests/bind_store.rs | 334 ++++++++---------- 3 files changed, 275 insertions(+), 246 deletions(-) diff --git a/.claude/plans/d-dt64-6-routing-bindstore-behavior-v1.md b/.claude/plans/d-dt64-6-routing-bindstore-behavior-v1.md index 9c7d9b116..97baf9b8d 100644 --- a/.claude/plans/d-dt64-6-routing-bindstore-behavior-v1.md +++ b/.claude/plans/d-dt64-6-routing-bindstore-behavior-v1.md @@ -267,3 +267,11 @@ Not done: wiring OGAR's `CapabilityAuthority` (D6, the production route for cons In the test, XLOG's per-Y count is three Calls into one algebra lens: group-count twice, then checked i64 Mul. That binds two classes in total; no algebra item gets a classid. Route targets stay bare classids (XLOG: a per-route mask would break D5). Open, with the owners: whether tract-harvest-probe's per-op lab classids (`0x0F01…`) become entries under one lens (EXO-50 message 27); the `MethodSignature` vs `MethodRef` identity (dr-math owner); where the ONNX/XLOG algebra entries are registered. **A.5 addendum 2 (2026-10-30, XLOG correction).** A Call had no operands, so the stored binding could not tell XLOG's count from one that counts R twice. Fix: `Effect { target, op, args: Vec }`, where `Arg` is `Field(u8)` (a position checked against the caller's basis) or `Const(i64)` (a key column, a lane). `MethodBinding::inputs` is part of the entry's signature, and a Call passing another arity is refused at registration (`ArityMismatch` inside `CallRefused`). XLOG's count: `cnt_r = Call(group-count, [r, key 1])`, `cnt_s = Call(group-count, [s, key 0])`, `count = Call(Mul i64, [cnt_r, cnt_s])`. `demand` stays `Values`, because the key column is read as a value; that is stated here, not settled. + +**A.5 addendum 3 (2026-10-30, operator).** "The classid switches the behaviour, labels and polyfill over otherwise agnostic addresses or algebra methods." Addendum 1 gave the algebra its own lens classid and had consumers reach it by a class-to-class `Call`, which was wrong. Now: +- The algebra is a classid-free catalogue in the store: `AlgebraEntry { id: AlgebraId, sig_rev, polyfill, inputs }`, declared once with `declare_algebra`, immutable, and versioned in DT directly. This is D6: "global behaviour needs no classid". +- A consumer classid switches labels (its `ClassView`) and polyfill (`Effect { target, algebra: AlgebraRef, args }`) over those entries. `register` refuses `UnknownAlgebra`, `AlgebraRevisionMismatch` and `ArityMismatch`. +- `users_of(AlgebraId)` lists the classids that use an entry. +- `EffectOp::Call` and `MethodBinding::inputs` are removed. + +In the test, XLOG's count and an ONNX `Mul(int64)` node reuse the same entry under their own classids; only the two consumers carry classids. diff --git a/crates/lance-graph-dt64/src/lib.rs b/crates/lance-graph-dt64/src/lib.rs index e49bdbbbb..551d9734c 100644 --- a/crates/lance-graph-dt64/src/lib.rs +++ b/crates/lance-graph-dt64/src/lib.rs @@ -52,8 +52,16 @@ //! field basis is the [`ClassView`] of its concept half ([`view_class`], the //! codebook's own `classid_concept` split), labels are resolved late through //! that view, and its entries are the [`MethodId`]s inside it. A codebook item -//! never gets a classid of its own; another class reuses it by reference -//! ([`EffectOp::Call`]). +//! never gets a classid of its own. +//! +//! # Agnostic algebra, switched by the classid +//! +//! Addresses and algebra methods are agnostic. The algebra catalogue +//! ([`AlgebraEntry`], [`BindStore::declare_algebra`]) is classid-free global +//! behaviour (D-DT64-6 D6). A classid switches labels (its [`ClassView`]) and +//! polyfill (its bindings' [`Effect`]s, each an [`AlgebraRef`] with ordered +//! operands) over them. Two consumers reuse one entry by referring to the +//! same [`AlgebraId`]; [`BindStore::users_of`] lists them. //! //! [`ActionDef::object_class`]: lance_graph_contract::action::ActionDef::object_class //! [`ClassMethods::classid`]: lance_graph_contract::codegen_manifest::ClassMethods::classid @@ -86,31 +94,53 @@ pub struct SchemaRev(pub u32); #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct RouteVersion(pub u64); -/// A reference to the polyfill that carries out one effect (D4: classid → -/// polyfill over mask-risc / ndarray; odoo-rs #46 `algebra_ref`). Opaque to -/// this store: it is compared, never resolved or run. -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct AlgebraRef(String); +/// A classid-free algebra method: global behaviour (D-DT64-6 D6, "global +/// behaviour needs no classid and lives in lance-graph"). Addresses and +/// algebra methods are agnostic; a classid's binding is what switches labels +/// and polyfill over them. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct AlgebraId(pub u32); -impl AlgebraRef { - pub fn new(name: impl Into) -> AlgebraRef { - AlgebraRef(name.into()) - } +/// One entry of the algebra catalogue: the polyfill (D4: over mask-risc / +/// ndarray) named by reference, how many operands it takes, and its signature +/// revision. Immutable once declared; versioned in the store directly (D6). +/// The store never resolves or runs the polyfill. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct AlgebraEntry { + pub id: AlgebraId, + pub sig_rev: SigRev, + pub polyfill: String, + pub inputs: u8, +} - pub fn as_str(&self) -> &str { - &self.0 +/// What a class binds an effect to: an algebra entry at the signature +/// revision it was bound against. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct AlgebraRef { + pub id: AlgebraId, + pub sig_rev: SigRev, +} + +impl AlgebraRef { + /// The faithful reference to `entry`. + pub fn of(entry: &AlgebraEntry) -> AlgebraRef { + AlgebraRef { + id: entry.id, + sig_rev: entry.sig_rev, + } } } -/// One DO step: which field position of the class it writes, and what writes -/// it. A method's effects are an ORDERED list. +/// One DO step: which field position of the class it writes, the algebra +/// entry that writes it, and what it reads. A method's effects are an ORDERED +/// list. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct Effect { /// Field position in the class basis (the `WideFieldMask` bit). pub target: u8, - pub op: EffectOp, + pub algebra: AlgebraRef, /// What the step reads, in order. Order is load-bearing (a `Sub` or a - /// keyed count), so two steps with the same op and different arguments + /// keyed count), so two steps with the same entry and different arguments /// are different steps. pub args: Vec, } @@ -118,25 +148,12 @@ pub struct Effect { /// One input of an effect. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Arg { - /// A field position of the calling class (checked against its basis). + /// A field position of the bound class (checked against its basis). Field(u8), /// A constant parameter: a key column index, a lane. Const(i64), } -/// What carries out one effect. -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub enum EffectOp { - /// A polyfill named by reference; compared, never resolved or run here. - Algebra(AlgebraRef), - /// An entry of another class's codebook, reused by reference: the method - /// `(class, MethodId)` at its signature revision with its guard witness. - /// Admitted against the store when the calling class registers, so an - /// unknown or stale callee is refused then, never at run time. A class - /// cannot call itself: it is not bound yet while it registers. - Call(MethodRef), -} - /// WHEN: a guard over the population, stated as the set description Quack's /// WHERE evaluates, under a declared logic. Conjunction is /// [`SetExpr::And`]; a [`Logic::Kleene`] guard is three-valued, and a row @@ -193,9 +210,6 @@ pub struct MethodBinding { /// The source name (`MethodSig::name`, `ActionDef::predicate`). Carried, /// never used as identity. pub name: String, - /// How many arguments a [`EffectOp::Call`] of this method passes. Part of - /// the signature: a change is a new [`SigRev`]. - pub inputs: u8, /// ON: the field positions whose change triggers the method. pub on: WideFieldMask, /// WHEN: `None` for an unguarded method. @@ -313,19 +327,30 @@ pub enum Refusal { GuardAdded { class: u32, method: MethodId }, /// The reference carries another guard than the bound one. GuardMismatch { class: u32, method: MethodId }, - /// A call passes another number of arguments than the entry takes. - ArityMismatch { + /// An effect names an algebra entry the catalogue does not hold. + UnknownAlgebra { class: u32, method: MethodId, - expected: u8, - got: usize, + algebra: AlgebraId, }, - /// An [`EffectOp::Call`] was not admitted; `callee` says why. - CallRefused { + /// An effect was bound against another revision of the entry. + AlgebraRevisionMismatch { class: u32, method: MethodId, - callee: Box, + algebra: AlgebraId, + bound: SigRev, + asked: SigRev, }, + /// An effect passes another number of operands than the entry takes. + ArityMismatch { + class: u32, + method: MethodId, + algebra: AlgebraId, + expected: u8, + got: usize, + }, + /// The algebra entry is already declared with other content. + AlgebraImmutable(AlgebraId), /// No classid was routed to the position at that version. Unrouted { position: u16, @@ -369,6 +394,8 @@ impl<'s> Resolved<'s, '_> { #[derive(Debug, Clone, Default)] pub struct BindStore { classes: BTreeMap, + /// The classid-free algebra catalogue (D6). + algebra: BTreeMap, /// Per position, the route events in version order: `(version, class)`, /// `None` for a retract. routes: BTreeMap)>>, @@ -419,21 +446,29 @@ impl BindStore { validate(class, &schema, &methods)?; for m in &methods { for e in &m.effects { - if let EffectOp::Call(callee) = &e.op { - let refused = |r| Refusal::CallRefused { + let (method, algebra) = (m.id, e.algebra.id); + let entry = self.algebra.get(&algebra).ok_or(Refusal::UnknownAlgebra { + class, + method, + algebra, + })?; + if entry.sig_rev != e.algebra.sig_rev { + return Err(Refusal::AlgebraRevisionMismatch { class, - method: m.id, - callee: Box::new(r), - }; - let entry = self.admit(callee).map_err(refused)?; - if usize::from(entry.inputs) != e.args.len() { - return Err(refused(Refusal::ArityMismatch { - class: callee.class, - method: callee.method, - expected: entry.inputs, - got: e.args.len(), - })); - } + method, + algebra, + bound: entry.sig_rev, + asked: e.algebra.sig_rev, + }); + } + if usize::from(entry.inputs) != e.args.len() { + return Err(Refusal::ArityMismatch { + class, + method, + algebra, + expected: entry.inputs, + got: e.args.len(), + }); } } } @@ -452,18 +487,32 @@ impl BindStore { } } - /// Every `(class, method)` whose DO calls an entry of `class`: who reuses - /// that lens. - pub fn callers_of(&self, class: u32) -> Vec<(u32, MethodId)> { + /// Declare a classid-free algebra entry. Idempotent for an identical + /// entry; any other content under a declared id is refused. + pub fn declare_algebra(&mut self, entry: AlgebraEntry) -> Result<(), Refusal> { + match self.algebra.get(&entry.id) { + Some(bound) if *bound == entry => Ok(()), + Some(_) => Err(Refusal::AlgebraImmutable(entry.id)), + None => { + self.algebra.insert(entry.id, entry); + Ok(()) + } + } + } + + /// The algebra entry `id`, if declared. + pub fn algebra(&self, id: AlgebraId) -> Option<&AlgebraEntry> { + self.algebra.get(&id) + } + + /// Every `(classid, method)` whose DO uses algebra entry `id`: which + /// classids switch over it. + pub fn users_of(&self, id: AlgebraId) -> Vec<(u32, MethodId)> { let mut out = Vec::new(); - for (&caller, entry) in &self.classes { + for (&class, entry) in &self.classes { for m in &entry.methods { - let calls = m - .effects - .iter() - .any(|e| matches!(&e.op, EffectOp::Call(r) if r.class == class)); - if calls { - out.push((caller, m.id)); + if m.effects.iter().any(|e| e.algebra.id == id) { + out.push((class, m.id)); } } } diff --git a/crates/lance-graph-dt64/tests/bind_store.rs b/crates/lance-graph-dt64/tests/bind_store.rs index dd7a72d9e..0f1bc1e69 100644 --- a/crates/lance-graph-dt64/tests/bind_store.rs +++ b/crates/lance-graph-dt64/tests/bind_store.rs @@ -6,8 +6,8 @@ use lance_graph_contract::ontology::{DisplayTemplate, FieldRef}; use lance_graph_contract::set_algebra::{Cmp, Logic, SetExpr}; use lance_graph_contract::set_rules::{Population, Truth}; use lance_graph_dt64::{ - view_class, AlgebraRef, Arg, BindStore, ClassSchema, Effect, EffectOp, Guard, MethodBinding, - MethodId, MethodRef, Refusal, RouteVersion, SchemaRev, SigRev, ValueDemand, + view_class, AlgebraEntry, AlgebraId, AlgebraRef, Arg, BindStore, ClassSchema, Effect, Guard, + MethodBinding, MethodId, MethodRef, Refusal, RouteVersion, SchemaRev, SigRev, ValueDemand, }; /// A ClassView with one class of `n` fields; `n` can be changed to model a @@ -56,10 +56,56 @@ fn schema(rev: u32, fields: usize) -> ClassSchema { } } +/// The classid-free algebra catalogue the tests bind against (lab ids). +const ASSIGN: AlgebraId = AlgebraId(1); +const ACCUMULATE: AlgebraId = AlgebraId(2); +const GROUP_COUNT: AlgebraId = AlgebraId(10); +const MUL_I64: AlgebraId = AlgebraId(11); + +fn entry(id: AlgebraId, polyfill: &str, inputs: u8) -> AlgebraEntry { + AlgebraEntry { + id, + sig_rev: SigRev(1), + polyfill: polyfill.to_string(), + inputs, + } +} + +/// A store holding only the algebra catalogue: no classid anywhere. +fn catalogue() -> BindStore { + let mut s = BindStore::new(); + for e in [ + entry(ASSIGN, "assign", 0), + entry(ACCUMULATE, "accumulate", 0), + // (relation field, key column) + entry(GROUP_COUNT, "ndarray::simd::masked_group_count_u32", 2), + entry( + MUL_I64, + "ndarray::hpc::checked_i64::checked_binary_i64(Mul)", + 2, + ), + ] { + s.declare_algebra(e).unwrap(); + } + s +} + +fn uses(id: AlgebraId) -> AlgebraRef { + AlgebraRef { + id, + sig_rev: SigRev(1), + } +} + fn effect(target: u8, algebra: &str) -> Effect { + let id = match algebra { + "assign" => ASSIGN, + "accumulate" => ACCUMULATE, + other => panic!("no lab entry {other}"), + }; Effect { target, - op: EffectOp::Algebra(AlgebraRef::new(algebra)), + algebra: uses(id), args: vec![], } } @@ -71,7 +117,6 @@ fn method(id: u32, effects: Vec) -> MethodBinding { id: MethodId(id), sig_rev: SigRev(1), name: format!("m{id}"), - inputs: 0, on: WideFieldMask::from_positions(&[0]), when: Some(Guard::new(SetExpr::eq_u32(1, 1), Logic::Kleene)), reads: WideFieldMask::from_positions(&[0, 1]), @@ -83,7 +128,7 @@ fn method(id: u32, effects: Vec) -> MethodBinding { } fn store_with_order() -> BindStore { - let mut s = BindStore::new(); + let mut s = catalogue(); s.register( ORDER, schema(1, 4), @@ -232,7 +277,7 @@ fn routes_and_derivations_need_bound_classes() { /// guard refuses. #[test] fn same_read_write_sets_different_effect_order_stay_distinct_and_a_dropped_guard_refuses() { - let mut s = BindStore::new(); + let mut s = catalogue(); let a = method(20, vec![effect(2, "assign"), effect(2, "accumulate")]); let b = method(21, vec![effect(2, "accumulate"), effect(2, "assign")]); // The negative mold: equal unordered read and write sets. @@ -255,14 +300,8 @@ fn same_read_write_sets_different_effect_order_stay_distinct_and_a_dropped_guard // Each faithful reference admits its own binding with its own order. let ra = MethodRef::of(ORDER, &a); let rb = MethodRef::of(ORDER, &b); - assert_eq!( - s.admit(&ra).unwrap().effects[0].op, - EffectOp::Algebra(AlgebraRef::new("assign")) - ); - assert_eq!( - s.admit(&rb).unwrap().effects[0].op, - EffectOp::Algebra(AlgebraRef::new("accumulate")) - ); + assert_eq!(s.admit(&ra).unwrap().effects[0].algebra.id, ASSIGN); + assert_eq!(s.admit(&rb).unwrap().effects[0].algebra.id, ACCUMULATE); // A route that dropped the guard witness refuses. let dropped = MethodRef { @@ -307,7 +346,7 @@ fn same_read_write_sets_different_effect_order_stay_distinct_and_a_dropped_guard /// A guard on an unguarded method is not a witness either. #[test] fn a_guard_added_to_an_unguarded_method_refuses() { - let mut s = BindStore::new(); + let mut s = catalogue(); let free = MethodBinding { when: None, ..method(30, vec![effect(2, "assign")]) @@ -382,7 +421,7 @@ fn schema_revision_and_field_basis_are_checked() { /// basis wider than a `WideFieldMask` addresses. #[test] fn registration_refuses_ambiguity_and_out_of_basis_positions() { - let mut s = BindStore::new(); + let mut s = catalogue(); assert_eq!( s.register( ORDER, @@ -443,7 +482,7 @@ fn registration_refuses_ambiguity_and_out_of_basis_positions() { /// because admitting a counted population is the binder's call, not DT's. #[test] fn population_truth_and_demand_ride_the_binding() { - let mut s = BindStore::new(); + let mut s = catalogue(); let count = MethodBinding { population: Population::Bag, truth: Truth::TwoValued, @@ -485,86 +524,37 @@ fn the_lens_is_the_classids_concept_half() { ); } -/// Lab stand-ins: one algebra lens whose entries are two native leaves, and -/// one consumer lens (XLOG's per-Y count). No classid per algebra item. -const ALG: u32 = 0x7F02_0001; +/// Lab consumer classids. Each switches labels (its own ClassView) and +/// polyfill (its bindings) over the shared, classid-free algebra. const XLOG: u32 = 0x7F01_0001; -const GROUP_COUNT: MethodId = MethodId(1); -const MUL_I64: MethodId = MethodId(2); +const ONNX: u32 = 0x7F03_0001; -fn leaf(id: MethodId, native: &str, inputs: u8) -> MethodBinding { - MethodBinding { - id, - sig_rev: SigRev(1), - name: native.to_string(), - inputs, - on: WideFieldMask::from_positions(&[]), - when: None, - reads: WideFieldMask::from_positions(&[]), - effects: vec![effect(0, native)], - population: Population::Distinct, - truth: Truth::TwoValued, - demand: ValueDemand::Values, - } -} - -fn algebra_store() -> BindStore { - let mut s = BindStore::new(); - s.register( - ALG, - schema(1, 1), - vec![ - // (relation field, key column) - leaf(GROUP_COUNT, "ndarray::simd::masked_group_count_u32", 2), - leaf( - MUL_I64, - "ndarray::hpc::checked_i64::checked_binary_i64(Mul)", - 2, - ), - ], - ) - .unwrap(); - s -} - -fn call(s: &BindStore, class: u32, method: MethodId) -> EffectOp { - let entry = MethodRef { - class, - method, - sig_rev: SigRev(1), - guard: None, - }; - s.admit(&entry).unwrap(); - EffectOp::Call(entry) -} - -/// XLOG's count `q(Y) :- r(X,Y), s(Y,Z)`: fields r=0, s=1, two counts 2 and -/// 3, the product 4. Its DO calls the algebra lens's entries in order. -fn xlog_count(s: &BindStore) -> MethodBinding { +/// XLOG's count `q(Y) :- r(X,Y), s(Y,Z)`: fields r=0, s=1, the counts 2 and 3, +/// the product 4. +fn xlog_count() -> MethodBinding { MethodBinding { id: MethodId(1), sig_rev: SigRev(1), name: "count q(Y)".to_string(), - inputs: 0, on: WideFieldMask::from_positions(&[0, 1]), when: None, reads: WideFieldMask::from_positions(&[0, 1]), effects: vec![ + // R keyed by its y column (1) Effect { target: 2, - op: call(s, ALG, GROUP_COUNT), - // R keyed by its y column (1) + algebra: uses(GROUP_COUNT), args: vec![Arg::Field(0), Arg::Const(1)], }, + // S keyed by its y column (0) Effect { target: 3, - op: call(s, ALG, GROUP_COUNT), - // S keyed by its y column (0) + algebra: uses(GROUP_COUNT), args: vec![Arg::Field(1), Arg::Const(0)], }, Effect { target: 4, - op: call(s, ALG, MUL_I64), + algebra: uses(MUL_I64), args: vec![Arg::Field(2), Arg::Field(3)], }, ], @@ -574,106 +564,71 @@ fn xlog_count(s: &BindStore) -> MethodBinding { } } -#[test] -fn a_consumer_reuses_algebra_entries_by_reference() { - let mut s = algebra_store(); - let count = xlog_count(&s); - s.register(XLOG, schema(1, 5), vec![count.clone()]).unwrap(); - - assert_eq!(s.callers_of(ALG), vec![(XLOG, MethodId(1))]); - assert!(s.callers_of(XLOG).is_empty()); - let got = s.admit(&MethodRef::of(XLOG, &count)).unwrap(); - let callees: Vec = got - .effects - .iter() - .map(|e| match &e.op { - EffectOp::Call(r) => r.method, - EffectOp::Algebra(_) => panic!("XLOG declares no algebra of its own"), - }) - .collect(); - assert_eq!(callees, vec![GROUP_COUNT, GROUP_COUNT, MUL_I64]); - // Two classes bound, not one per algebra item. - assert_eq!(s.class_count(), 2); - - // The reused lens cannot change under its caller (D5). - assert_eq!( - s.register(ALG, schema(1, 1), vec![leaf(GROUP_COUNT, "other", 2)]), - Err(Refusal::BehaviourImmutable(ALG)) - ); +/// An ONNX `Mul(int64)` node: fields a=0, b=1, y=2. +fn onnx_mul() -> MethodBinding { + MethodBinding { + name: "Mul(int64)".to_string(), + effects: vec![Effect { + target: 2, + algebra: uses(MUL_I64), + args: vec![Arg::Field(0), Arg::Field(1)], + }], + population: Population::Distinct, + ..xlog_count() + } } -/// A call is admitted when the caller registers: an unknown entry, a stale -/// signature revision, an unbound lens and a dropped guard are refused then, -/// and the caller is not bound. #[test] -fn a_call_to_an_unknown_or_stale_entry_is_refused_at_registration() { - let refused = |s: &mut BindStore, callee: MethodRef| { - let m = MethodBinding { - effects: vec![Effect { - target: 2, - op: EffectOp::Call(callee), - args: vec![Arg::Const(0), Arg::Const(0)], - }], - ..method(5, vec![]) - }; - s.register(XLOG, schema(1, 5), vec![m]).unwrap_err() - }; - let entry = |class, method, sig| MethodRef { - class, - method, - sig_rev: SigRev(sig), - guard: None, - }; - let wrap = |callee| Refusal::CallRefused { - class: XLOG, - method: MethodId(5), - callee: Box::new(callee), - }; +fn consumers_switch_over_shared_classid_free_algebra() { + let mut s = catalogue(); + s.register(XLOG, schema(1, 5), vec![xlog_count()]).unwrap(); + s.register(ONNX, schema(1, 3), vec![onnx_mul()]).unwrap(); - let mut empty = BindStore::new(); + // One algebra entry, reused by both classids; nothing minted for it. assert_eq!( - refused(&mut empty, entry(ALG, GROUP_COUNT, 1)), - wrap(Refusal::UnknownClass(ALG)) + s.users_of(MUL_I64), + vec![(XLOG, MethodId(1)), (ONNX, MethodId(1))] ); - - let mut s = algebra_store(); + assert_eq!(s.users_of(GROUP_COUNT), vec![(XLOG, MethodId(1))]); + assert!(s.users_of(ASSIGN).is_empty()); + assert_eq!(s.class_count(), 2, "only the consumers carry classids"); assert_eq!( - refused(&mut s, entry(ALG, MethodId(9), 1)), - wrap(Refusal::UnknownMethod { - class: ALG, - method: MethodId(9) - }) + s.algebra(MUL_I64).unwrap().polyfill, + "ndarray::hpc::checked_i64::checked_binary_i64(Mul)" ); - assert_eq!( - refused(&mut s, entry(ALG, MUL_I64, 0)), - wrap(Refusal::SignatureRevisionMismatch { - class: ALG, - method: MUL_I64, - bound: SigRev(1), - asked: SigRev(0) - }) + + // The classid switches the labels: each reads its own lens. + let xv = View::with_fields(view_class(XLOG), 5); + let ov = View::with_fields(view_class(ONNX), 3); + assert_eq!(s.resolve(&xv, XLOG, SchemaRev(1)).unwrap().fields.len(), 5); + assert_eq!(s.resolve(&ov, ONNX, SchemaRev(1)).unwrap().fields.len(), 3); + assert!( + s.resolve(&ov, XLOG, SchemaRev(1)).is_err(), + "not XLOG's lens" ); - // A guard the entry does not have is refused too. - let mut guarded = entry(ALG, MUL_I64, 1); - guarded.guard = Some(Guard::new(SetExpr::All, Logic::TwoValued).witness()); +} + +/// An algebra entry is immutable once declared (D5 applied to D6). +#[test] +fn an_algebra_entry_is_immutable() { + let mut s = catalogue(); + let same = entry( + MUL_I64, + "ndarray::hpc::checked_i64::checked_binary_i64(Mul)", + 2, + ); + assert_eq!(s.declare_algebra(same), Ok(())); assert_eq!( - refused(&mut s, guarded), - wrap(Refusal::GuardAdded { - class: ALG, - method: MUL_I64 - }) + s.declare_algebra(entry(MUL_I64, "wrapping_mul", 2)), + Err(Refusal::AlgebraImmutable(MUL_I64)) ); - assert_eq!(s.class_count(), 1, "no refused caller was bound"); } -/// The operands are part of the binding (XLOG's correction): counting R twice -/// is a different binding from counting R and S, and a call with the wrong -/// number of operands, or an operand outside the caller's basis, is refused at -/// registration. +/// The operands are part of the binding (XLOG's correction), and every effect +/// is checked against the catalogue when its class registers. #[test] -fn call_operands_are_bound_and_checked() { - let s = algebra_store(); - let count = xlog_count(&s); +fn effect_operands_and_entries_are_checked() { + let count = xlog_count(); let mut twice_r = count.clone(); twice_r.effects[1].args = vec![Arg::Field(0), Arg::Const(1)]; assert_ne!(count, twice_r, "operands tell the two counts apart"); @@ -681,31 +636,48 @@ fn call_operands_are_bound_and_checked() { swapped.effects[2].args = vec![Arg::Field(3), Arg::Field(2)]; assert_ne!(count, swapped, "operand order is part of the step"); - let mut s2 = algebra_store(); - let mut short = count.clone(); - short.effects[2].args = vec![Arg::Field(2)]; + let mut s = catalogue(); + let refused = |s: &mut BindStore, f: &dyn Fn(&mut Effect)| { + let mut m = xlog_count(); + f(&mut m.effects[2]); + s.register(XLOG, schema(1, 5), vec![m]).unwrap_err() + }; assert_eq!( - s2.register(XLOG, schema(1, 5), vec![short]), - Err(Refusal::CallRefused { + refused(&mut s, &|e| e.args = vec![Arg::Field(2)]), + Refusal::ArityMismatch { class: XLOG, method: MethodId(1), - callee: Box::new(Refusal::ArityMismatch { - class: ALG, - method: MUL_I64, - expected: 2, - got: 1 - }) - }) + algebra: MUL_I64, + expected: 2, + got: 1 + } ); - let mut outside = count.clone(); - outside.effects[2].args = vec![Arg::Field(2), Arg::Field(7)]; assert_eq!( - s2.register(XLOG, schema(1, 5), vec![outside]), - Err(Refusal::FieldOutOfBasis { + refused(&mut s, &|e| e.args = vec![Arg::Field(2), Arg::Field(7)]), + Refusal::FieldOutOfBasis { class: XLOG, method: MethodId(1), position: 7 - }) + } + ); + assert_eq!( + refused(&mut s, &|e| e.algebra = uses(AlgebraId(99))), + Refusal::UnknownAlgebra { + class: XLOG, + method: MethodId(1), + algebra: AlgebraId(99) + } + ); + assert_eq!( + refused(&mut s, &|e| e.algebra.sig_rev = SigRev(0)), + Refusal::AlgebraRevisionMismatch { + class: XLOG, + method: MethodId(1), + algebra: MUL_I64, + bound: SigRev(1), + asked: SigRev(0) + } ); - assert_eq!(s2.register(XLOG, schema(1, 5), vec![count]), Ok(())); + assert_eq!(s.class_count(), 0, "no refused class was bound"); + assert_eq!(s.register(XLOG, schema(1, 5), vec![count]), Ok(())); } From dd4466abea220b27d706766d51b2f0e14359d4f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 15:53:55 +0000 Subject: [PATCH 06/11] lance-graph-dt64: the fieldview projection (classid x WideFieldMask) Operator: classid x WideFieldMask ergonomics is the ERB Redmine fieldview pattern, as in OGAR ogar-render-askama::field_view. Resolved::field_view(mask) yields FieldRow { position, label, predicate } ascending, the position as layout address, labels borrowed late from the ClassView (OGAR FieldView minus the value; the store holds none). Resolved::action_ordinals() gives the methods as action addresses. 16 tests. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XAqKMPh2Seh2WLccCksAdY --- .../d-dt64-6-routing-bindstore-behavior-v1.md | 2 ++ crates/lance-graph-dt64/src/lib.rs | 36 ++++++++++++++++++- crates/lance-graph-dt64/tests/bind_store.rs | 22 ++++++++++++ 3 files changed, 59 insertions(+), 1 deletion(-) diff --git a/.claude/plans/d-dt64-6-routing-bindstore-behavior-v1.md b/.claude/plans/d-dt64-6-routing-bindstore-behavior-v1.md index 97baf9b8d..90bb41c71 100644 --- a/.claude/plans/d-dt64-6-routing-bindstore-behavior-v1.md +++ b/.claude/plans/d-dt64-6-routing-bindstore-behavior-v1.md @@ -275,3 +275,5 @@ In the test, XLOG's per-Y count is three Calls into one algebra lens: group-coun - `EffectOp::Call` and `MethodBinding::inputs` are removed. In the test, XLOG's count and an ONNX `Mul(int64)` node reuse the same entry under their own classids; only the two consumers carry classids. + +**A.5 addendum 4 (2026-10-30, operator).** "Classid widefieldmask ergonomics is the ERB Redmine fieldview pattern." This matches OGAR `ogar-render-askama::field_view` ("the ERB pattern fieldview ClassView rendering from memory without serialization") and openproject-nexgen-rs ("fieldview/erb → classview/askama"). `Resolved::field_view(&WideFieldMask)` yields `FieldRow { position, label, predicate }`, ascending, with the position as the layout address and labels borrowed late from the ClassView. It is OGAR's `FieldView` minus the value, since the store holds none. `Resolved::action_ordinals()` gives the methods as action addresses (T2). Not wired into OGAR's renderer: OGAR builds its `FieldView`s itself, and that call site is the OGAR owner's. diff --git a/crates/lance-graph-dt64/src/lib.rs b/crates/lance-graph-dt64/src/lib.rs index 551d9734c..bda8318f1 100644 --- a/crates/lance-graph-dt64/src/lib.rs +++ b/crates/lance-graph-dt64/src/lib.rs @@ -382,12 +382,46 @@ pub struct Resolved<'s, 'v> { pub derived_from: Option, } -impl<'s> Resolved<'s, '_> { +/// One row of the fieldview projection (the Redmine ERB fieldview pattern, +/// OGAR `ogar-render-askama::field_view::FieldView` minus the value): the +/// field's mask position is its layout address, and its label and predicate +/// are borrowed late from the [`ClassView`]. The store holds no values; a +/// renderer pairs each row with the consumer's value. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct FieldRow<'v> { + pub position: u8, + pub label: &'v str, + pub predicate: &'v str, +} + +impl<'s, 'v> Resolved<'s, 'v> { /// Every method whose ON includes `field`, in registration order. Several /// are normal and all are returned. pub fn methods_on(&self, field: u8) -> impl Iterator { self.methods.iter().filter(move |m| m.on.has(field)) } + + /// The fieldview: the fields `mask` selects, ascending by position, with + /// their labels. A position past the class's basis selects nothing. + pub fn field_view<'m>(&self, mask: &'m WideFieldMask) -> impl Iterator> + 'm + where + 'v: 'm, + { + let fields = self.fields; + mask_positions(mask).filter_map(move |p| { + fields.get(usize::from(p)).map(|f| FieldRow { + position: p, + label: f.label.as_str(), + predicate: f.predicate_iri.as_str(), + }) + }) + } + + /// The action addresses the class exposes: its method ordinals, in + /// registration order (fieldview charter T2: behaviour by address only). + pub fn action_ordinals(&self) -> impl Iterator + 's { + self.methods.iter().map(|m| m.id) + } } /// The binding store. Building it takes `&mut self`; every read is `&self`. diff --git a/crates/lance-graph-dt64/tests/bind_store.rs b/crates/lance-graph-dt64/tests/bind_store.rs index 0f1bc1e69..e4edd0404 100644 --- a/crates/lance-graph-dt64/tests/bind_store.rs +++ b/crates/lance-graph-dt64/tests/bind_store.rs @@ -681,3 +681,25 @@ fn effect_operands_and_entries_are_checked() { assert_eq!(s.class_count(), 0, "no refused class was bound"); assert_eq!(s.register(XLOG, schema(1, 5), vec![count]), Ok(())); } + +/// The classid × WideFieldMask ergonomics are the fieldview (the Redmine ERB +/// pattern, OGAR `field_view`): the classid picks the lens, the mask selects +/// positions, and each row carries its position as its layout address, with +/// labels from the ClassView. Behaviour is exposed by ordinal only. +#[test] +fn the_fieldview_projects_mask_positions_with_late_labels() { + let mut s = catalogue(); + s.register(XLOG, schema(1, 5), vec![xlog_count()]).unwrap(); + let view = View::with_fields(view_class(XLOG), 5); + let r = s.resolve(&view, XLOG, SchemaRev(1)).unwrap(); + + let mask = WideFieldMask::from_positions(&[4, 0, 9]); + let rows: Vec<(u8, &str, &str)> = r + .field_view(&mask) + .map(|f| (f.position, f.label, f.predicate)) + .collect(); + // Ascending, labels from the lens; 9 is past the basis and selects nothing. + assert_eq!(rows, vec![(0, "f0", "urn:f0"), (4, "f4", "urn:f4")]); + assert_eq!(r.field_view(&WideFieldMask::from_positions(&[])).count(), 0); + assert_eq!(r.action_ordinals().collect::>(), vec![MethodId(1)]); +} From 3745f473e194dc453c153af5f17e237ca75c6ab7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 15:54:38 +0000 Subject: [PATCH 07/11] D-DT64-6 A.5: the fieldview is one masking pattern, not render-only Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XAqKMPh2Seh2WLccCksAdY --- .claude/plans/d-dt64-6-routing-bindstore-behavior-v1.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.claude/plans/d-dt64-6-routing-bindstore-behavior-v1.md b/.claude/plans/d-dt64-6-routing-bindstore-behavior-v1.md index 90bb41c71..c9ad0cbee 100644 --- a/.claude/plans/d-dt64-6-routing-bindstore-behavior-v1.md +++ b/.claude/plans/d-dt64-6-routing-bindstore-behavior-v1.md @@ -277,3 +277,4 @@ In the test, XLOG's per-Y count is three Calls into one algebra lens: group-coun In the test, XLOG's count and an ONNX `Mul(int64)` node reuse the same entry under their own classids; only the two consumers carry classids. **A.5 addendum 4 (2026-10-30, operator).** "Classid widefieldmask ergonomics is the ERB Redmine fieldview pattern." This matches OGAR `ogar-render-askama::field_view` ("the ERB pattern fieldview ClassView rendering from memory without serialization") and openproject-nexgen-rs ("fieldview/erb → classview/askama"). `Resolved::field_view(&WideFieldMask)` yields `FieldRow { position, label, predicate }`, ascending, with the position as the layout address and labels borrowed late from the ClassView. It is OGAR's `FieldView` minus the value, since the store holds none. `Resolved::action_ordinals()` gives the methods as action addresses (T2). Not wired into OGAR's renderer: OGAR builds its `FieldView`s itself, and that call site is the OGAR owner's. +Scope of that projection (XLOG session; quote verified at `.claude/knowledge/ogar-consumer-preflight.md:277` and `cross-glove-business-parity-v1.md:881`): operator, 2026-10-10, *"Classview can be used for any compute masking, we explicitly expanded the ERB redmine fieldview pattern for risk mask of everything."* `ClassView × WideFieldMask` is one masking pattern; rendering, RBAC, SPOG and risk masks are uses of it, and so are a binding's ON and `reads` sets. `field_view` is that pattern's read, not a render-only API. From 2bdcb08205e8635285fbb719453ad1951a9a3b3c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 15:56:53 +0000 Subject: [PATCH 08/11] lance-graph-dt64: field meaning and meaning-masks through the lens DT owns field semantics; the contract carries them per predicate as PropertySpec (obligation, codec route, semantic type, marking). field_meaning joins fieldview rows to their spec; mask_where derives WideFieldMasks from meaning (risk: marking >= Pii; obligation: Required); unspecified surfaces fields with no spec, which never take the spec default. 17 tests. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XAqKMPh2Seh2WLccCksAdY --- .../d-dt64-6-routing-bindstore-behavior-v1.md | 7 +++ crates/lance-graph-dt64/src/lib.rs | 56 +++++++++++++++++++ crates/lance-graph-dt64/tests/bind_store.rs | 43 ++++++++++++++ 3 files changed, 106 insertions(+) diff --git a/.claude/plans/d-dt64-6-routing-bindstore-behavior-v1.md b/.claude/plans/d-dt64-6-routing-bindstore-behavior-v1.md index c9ad0cbee..1df1b21d5 100644 --- a/.claude/plans/d-dt64-6-routing-bindstore-behavior-v1.md +++ b/.claude/plans/d-dt64-6-routing-bindstore-behavior-v1.md @@ -278,3 +278,10 @@ In the test, XLOG's count and an ONNX `Mul(int64)` node reuse the same entry und **A.5 addendum 4 (2026-10-30, operator).** "Classid widefieldmask ergonomics is the ERB Redmine fieldview pattern." This matches OGAR `ogar-render-askama::field_view` ("the ERB pattern fieldview ClassView rendering from memory without serialization") and openproject-nexgen-rs ("fieldview/erb → classview/askama"). `Resolved::field_view(&WideFieldMask)` yields `FieldRow { position, label, predicate }`, ascending, with the position as the layout address and labels borrowed late from the ClassView. It is OGAR's `FieldView` minus the value, since the store holds none. `Resolved::action_ordinals()` gives the methods as action addresses (T2). Not wired into OGAR's renderer: OGAR builds its `FieldView`s itself, and that call site is the OGAR owner's. Scope of that projection (XLOG session; quote verified at `.claude/knowledge/ogar-consumer-preflight.md:277` and `cross-glove-business-parity-v1.md:881`): operator, 2026-10-10, *"Classview can be used for any compute masking, we explicitly expanded the ERB redmine fieldview pattern for risk mask of everything."* `ClassView × WideFieldMask` is one masking pattern; rendering, RBAC, SPOG and risk masks are uses of it, and so are a binding's ON and `reads` sets. `field_view` is that pattern's read, not a render-only API. + +**A.5 addendum 5 (2026-10-30): field meaning and meaning-masks through the lens.** DT owns field semantics (D-DRDT §1). The contract already carries them per predicate: `property::PropertySpec { kind, codec_route, semantic_type, marking }`, keyed by the same predicate as `FieldRef::predicate_iri`. +- `Resolved::field_meaning(mask, schema)` joins each fieldview row to its spec. +- `Resolved::mask_where(schema, pred)` derives `WideFieldMask`s from meaning: a risk mask from `marking >= Pii`, an obligation mask from `kind == Required`. This is the "risk mask of everything" over the same pattern. +- `Resolved::unspecified(schema)` surfaces the fields with no spec. A field with no spec is UNKNOWN and never takes the spec default (`Internal` / `PlainText`); a test pins that. + +The physical lane kind (u32 code vs i32, which picks XLOG's comparator) is the SoA column's (`soa_envelope::ColumnKind`), not a meaning. The store does not hold it. diff --git a/crates/lance-graph-dt64/src/lib.rs b/crates/lance-graph-dt64/src/lib.rs index bda8318f1..ab62e33f6 100644 --- a/crates/lance-graph-dt64/src/lib.rs +++ b/crates/lance-graph-dt64/src/lib.rs @@ -73,6 +73,7 @@ use std::collections::BTreeMap; use lance_graph_contract::class_view::{ClassId, ClassView, WideFieldMask}; use lance_graph_contract::ogar_codebook::classid_concept; use lance_graph_contract::ontology::FieldRef; +use lance_graph_contract::property::{PropertySchema, PropertySpec}; use lance_graph_contract::set_algebra::{normalize, normalize_kleene, Logic, SetExpr}; use lance_graph_contract::set_rules::{Population, Truth}; @@ -394,6 +395,14 @@ pub struct FieldRow<'v> { pub predicate: &'v str, } +/// A fieldview row with its meaning (see [`Resolved::field_meaning`]). +#[derive(Debug, Clone, Copy)] +pub struct FieldMeaning<'v, 'p> { + pub row: FieldRow<'v>, + /// The field's semantics by predicate; `None` is UNKNOWN. + pub spec: Option<&'p PropertySpec>, +} + impl<'s, 'v> Resolved<'s, 'v> { /// Every method whose ON includes `field`, in registration order. Several /// are normal and all are returned. @@ -417,6 +426,53 @@ impl<'s, 'v> Resolved<'s, 'v> { }) } + /// The fieldview with meaning: each selected row joined by predicate to + /// its [`PropertySpec`] (obligation, semantic type, marking). `spec` is + /// `None` when the schema has no entry: UNKNOWN meaning, never a default. + pub fn field_meaning<'m, 'p>( + &self, + mask: &'m WideFieldMask, + schema: &'p PropertySchema, + ) -> impl Iterator> + 'm + where + 'v: 'm, + 'p: 'm, + { + self.field_view(mask).map(move |row| FieldMeaning { + row, + spec: schema.get(row.predicate), + }) + } + + /// A mask from meaning: the lens's fields whose spec satisfies `pred` + /// (a risk mask is `|s| s.marking >= Marking::Pii`, a presence obligation + /// is `|s| s.kind == PropertyKind::Required`). A field with no spec is + /// never selected, whatever `pred` would say about a default. + pub fn mask_where( + &self, + schema: &PropertySchema, + pred: impl Fn(&PropertySpec) -> bool, + ) -> WideFieldMask { + self.positions(|f| schema.get(&f.predicate_iri).is_some_and(&pred)) + } + + /// The lens's fields the schema says nothing about: UNKNOWN meaning, + /// surfaced rather than defaulted. + pub fn unspecified(&self, schema: &PropertySchema) -> WideFieldMask { + self.positions(|f| schema.get(&f.predicate_iri).is_none()) + } + + fn positions(&self, keep: impl Fn(&FieldRef) -> bool) -> WideFieldMask { + let picked: Vec = self + .fields + .iter() + .enumerate() + .filter(|(_, f)| keep(f)) + .filter_map(|(i, _)| u8::try_from(i).ok()) + .collect(); + WideFieldMask::from_positions(&picked) + } + /// The action addresses the class exposes: its method ordinals, in /// registration order (fieldview charter T2: behaviour by address only). pub fn action_ordinals(&self) -> impl Iterator + 's { diff --git a/crates/lance-graph-dt64/tests/bind_store.rs b/crates/lance-graph-dt64/tests/bind_store.rs index e4edd0404..71440d333 100644 --- a/crates/lance-graph-dt64/tests/bind_store.rs +++ b/crates/lance-graph-dt64/tests/bind_store.rs @@ -703,3 +703,46 @@ fn the_fieldview_projects_mask_positions_with_late_labels() { assert_eq!(r.field_view(&WideFieldMask::from_positions(&[])).count(), 0); assert_eq!(r.action_ordinals().collect::>(), vec![MethodId(1)]); } + +/// Field meaning through the lens: the fieldview row joined by predicate to +/// the contract's `PropertySpec` (obligation, semantic type, marking). The +/// same pattern yields masks from meaning: a risk mask, an obligation mask. +/// A field with no spec is UNKNOWN and never defaults into a mask. +#[test] +fn meaning_masks_come_from_the_same_fieldview() { + use lance_graph_contract::cam::CodecRoute; + use lance_graph_contract::property::{ + Marking, PropertyKind, PropertySchema, PropertySpec, SemanticType, + }; + static SPECS: [PropertySpec; 3] = [ + PropertySpec::required("urn:f0") + .with_semantic_type(SemanticType::Iban) + .with_marking(Marking::Pii), + PropertySpec::optional("urn:f1", CodecRoute::Passthrough), + PropertySpec::required("urn:f2").with_marking(Marking::Financial), + // urn:f3 is deliberately absent: UNKNOWN meaning. + ]; + let schema = PropertySchema { + entity_type: "order", + properties: &SPECS, + }; + let s = store_with_order(); + let view = View::with_fields(VIEW_CLASS, 4); + let r = s.resolve(&view, ORDER, SchemaRev(1)).unwrap(); + + let rows: Vec<(u8, Option)> = r + .field_meaning(&WideFieldMask::from_positions(&[0, 3]), &schema) + .map(|m| (m.row.position, m.spec.map(|s| s.marking))) + .collect(); + assert_eq!(rows, vec![(0, Some(Marking::Pii)), (3, None)]); + + let risk = r.mask_where(&schema, |s| s.marking >= Marking::Pii); + assert_eq!(risk, WideFieldMask::from_positions(&[0, 2])); + let required = r.mask_where(&schema, |s| s.kind == PropertyKind::Required); + assert_eq!(required, WideFieldMask::from_positions(&[0, 2])); + // Falsifier: a default spec would be Internal; the unspecified field 3 + // must still not join a mask that Internal satisfies. + let low = r.mask_where(&schema, |s| s.marking <= Marking::Internal); + assert_eq!(low, WideFieldMask::from_positions(&[1])); + assert_eq!(r.unspecified(&schema), WideFieldMask::from_positions(&[3])); +} From 71ed7d341b1b307de670c6d100828b0e10772a36 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 15:58:20 +0000 Subject: [PATCH 09/11] lance-graph-dt64: recipe dataflow check; behaviour footprint for meaning masks register refuses UndeclaredRead: a step may read only declared reads or what an earlier step of the same method wrote, so a recipe's order is what it says. MethodBinding::footprint (ON, reads, writes) and Resolved::methods_touching(mask) let a risk or RBAC mask reach behaviour. 19 tests. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XAqKMPh2Seh2WLccCksAdY --- .../d-dt64-6-routing-bindstore-behavior-v1.md | 6 ++ crates/lance-graph-dt64/src/lib.rs | 47 ++++++++++++++ crates/lance-graph-dt64/tests/bind_store.rs | 62 +++++++++++++++++++ 3 files changed, 115 insertions(+) diff --git a/.claude/plans/d-dt64-6-routing-bindstore-behavior-v1.md b/.claude/plans/d-dt64-6-routing-bindstore-behavior-v1.md index 1df1b21d5..8209568b6 100644 --- a/.claude/plans/d-dt64-6-routing-bindstore-behavior-v1.md +++ b/.claude/plans/d-dt64-6-routing-bindstore-behavior-v1.md @@ -285,3 +285,9 @@ Scope of that projection (XLOG session; quote verified at `.claude/knowledge/oga - `Resolved::unspecified(schema)` surfaces the fields with no spec. A field with no spec is UNKNOWN and never takes the spec default (`Internal` / `PlainText`); a test pins that. The physical lane kind (u32 code vs i32, which picks XLOG's comparator) is the SoA column's (`soa_envelope::ColumnKind`), not a meaning. The store does not hold it. + +**A.5 addendum 6 (2026-10-30): dataflow and behaviour footprint.** +- `register` refuses `UndeclaredRead` when a step reads a field that is neither in the method's `reads` nor written by an earlier step. A recipe's order must be what it says: XLOG's count with the multiply moved first is refused. +- `MethodBinding::footprint()` is ON ∪ reads ∪ writes, and `Resolved::methods_touching(mask)` lists the methods a meaning mask reaches. A risk or RBAC mask thereby applies to behaviour, not only to fields. + +Open, for the frame owner (D8): registering a class or declaring an algebra entry moves no revision a frame can check; only routes are versioned. Classes and entries are immutable and append-only, so nothing a frame read can change. Whether a frame should still record the store's catalogue extent (for example a count of bindings) is undecided. diff --git a/crates/lance-graph-dt64/src/lib.rs b/crates/lance-graph-dt64/src/lib.rs index ab62e33f6..7ce4f4b70 100644 --- a/crates/lance-graph-dt64/src/lib.rs +++ b/crates/lance-graph-dt64/src/lib.rs @@ -231,6 +231,12 @@ impl MethodBinding { let targets: Vec = self.effects.iter().map(|e| e.target).collect(); WideFieldMask::from_positions(&targets) } + + /// Every field the method touches: ON, reads and writes. Intersected with + /// a meaning mask it says what behaviour touches risk. + pub fn footprint(&self) -> WideFieldMask { + self.on.union(&self.reads).union(&self.writes()) + } } /// A class's schema as bound: the schema revision and the field count the @@ -350,6 +356,14 @@ pub enum Refusal { expected: u8, got: usize, }, + /// Step `effect` reads a field that is neither in the method's `reads` + /// nor written by an earlier step: read before written. + UndeclaredRead { + class: u32, + method: MethodId, + effect: usize, + position: u8, + }, /// The algebra entry is already declared with other content. AlgebraImmutable(AlgebraId), /// No classid was routed to the position at that version. @@ -473,6 +487,20 @@ impl<'s, 'v> Resolved<'s, 'v> { WideFieldMask::from_positions(&picked) } + /// The methods whose [`MethodBinding::footprint`] meets `mask`: the + /// behaviour a risk or RBAC mask reaches, in registration order. + pub fn methods_touching<'m>( + &self, + mask: &'m WideFieldMask, + ) -> impl Iterator + 'm + where + 's: 'm, + { + self.methods + .iter() + .filter(move |m| !m.footprint().intersect(mask).is_empty()) + } + /// The action addresses the class exposes: its method ordinals, in /// registration order (fieldview charter T2: behaviour by address only). pub fn action_ordinals(&self) -> impl Iterator + 's { @@ -779,6 +807,25 @@ fn validate(class: u32, schema: &ClassSchema, methods: &[MethodBinding]) -> Resu }); } } + // Dataflow: a step reads only what the method declares it reads or + // what an earlier step of the same method wrote. Otherwise the order + // of the recipe is not what it says. + let mut available = m.reads.clone(); + for (effect, e) in m.effects.iter().enumerate() { + for a in &e.args { + if let Arg::Field(position) = *a { + if !available.has(position) { + return Err(Refusal::UndeclaredRead { + class, + method: m.id, + effect, + position, + }); + } + } + } + available = available.with(e.target); + } } Ok(()) } diff --git a/crates/lance-graph-dt64/tests/bind_store.rs b/crates/lance-graph-dt64/tests/bind_store.rs index 71440d333..42b94e66e 100644 --- a/crates/lance-graph-dt64/tests/bind_store.rs +++ b/crates/lance-graph-dt64/tests/bind_store.rs @@ -746,3 +746,65 @@ fn meaning_masks_come_from_the_same_fieldview() { assert_eq!(low, WideFieldMask::from_positions(&[1])); assert_eq!(r.unspecified(&schema), WideFieldMask::from_positions(&[3])); } + +/// The recipe's order must be what it says: a step reads only declared reads +/// or what an earlier step wrote. XLOG's count with the multiply moved first +/// reads the counts before they exist and is refused. +#[test] +fn a_step_cannot_read_what_no_earlier_step_wrote() { + let mut s = catalogue(); + let mut mul_first = xlog_count(); + mul_first.effects.rotate_right(1); + assert_eq!( + s.register(XLOG, schema(1, 5), vec![mul_first]), + Err(Refusal::UndeclaredRead { + class: XLOG, + method: MethodId(1), + effect: 0, + position: 2 + }) + ); + // A read of a field outside `reads` and never written is refused too. + let mut stray = xlog_count(); + stray.effects[0].args = vec![Arg::Field(4), Arg::Const(1)]; + assert_eq!( + s.register(XLOG, schema(1, 5), vec![stray]), + Err(Refusal::UndeclaredRead { + class: XLOG, + method: MethodId(1), + effect: 0, + position: 4 + }) + ); + // The faithful order binds. + assert_eq!(s.register(XLOG, schema(1, 5), vec![xlog_count()]), Ok(())); +} + +/// Meaning reaches behaviour: a method's footprint (ON, reads, writes) +/// intersected with a risk mask says which actions touch risk. +#[test] +fn a_risk_mask_reaches_the_methods_that_touch_it() { + let mut s = catalogue(); + s.register(XLOG, schema(1, 5), vec![xlog_count()]).unwrap(); + let view = View::with_fields(view_class(XLOG), 5); + let r = s.resolve(&view, XLOG, SchemaRev(1)).unwrap(); + + assert_eq!( + r.methods[0].footprint(), + WideFieldMask::from_positions(&[0, 1, 2, 3, 4]) + ); + // A mask over the product (written) and one over the inputs (read) both + // reach the count; a mask over nothing it touches reaches nothing. + for m in [[4u8], [0u8]] { + let ids: Vec = r + .methods_touching(&WideFieldMask::from_positions(&m)) + .map(|m| m.id) + .collect(); + assert_eq!(ids, vec![MethodId(1)]); + } + assert_eq!( + r.methods_touching(&WideFieldMask::from_positions(&[200])) + .count(), + 0 + ); +} From b1ea774162766023d23e110668b9c11fb7072bf2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 16:07:32 +0000 Subject: [PATCH 10/11] board: #1862 merged; lance-graph-dt64 follows in a new PR #1862 merged at head 4707683dc, before the lance-graph-dt64 commits were pushed. PR_ARC_INVENTORY and LATEST_STATE record what landed and that the crate follows. Entries index regenerated after the rebase. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XAqKMPh2Seh2WLccCksAdY --- .claude/board/LATEST_STATE.md | 5 +++++ .claude/board/PR_ARC_INVENTORY.md | 6 ++++++ .claude/board/entries/README.md | 2 +- 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/.claude/board/LATEST_STATE.md b/.claude/board/LATEST_STATE.md index 53ecd0586..4e62d5d32 100644 --- a/.claude/board/LATEST_STATE.md +++ b/.claude/board/LATEST_STATE.md @@ -1,3 +1,8 @@ +## 2026-10-30 — main after #1862 (merged `abe0b7afc`) + +- #1862 landed docs and two tests: D-DT64-6 Amendment A, the Morton aligned-square test, and the mask-risc dose test; D-LITHO folded into D-DT64-6. +- No bind store on main yet: `crates/lance-graph-dt64` is in the follow-up PR (stranded on the branch after the merge, rebased onto main). + ## 2026-10-26 — main after #1803 (merged `13e37026`) - Code on main since #1759: `crates/drf-focus-probe` (D-DRF P1/P2, writeback W1–W4/W6–W8, budget, churn, TLB, DRAV-AV1; workspace-excluded, no CI job) and, from #1792, D-NFR-1 falsifier tests only (`cognitive-shader-driver/tests/escaped_reuse.rs`, `lance-graph-planner/tests/horizon_is_not_a_snapshot.rs`, `soa-view-probe/tests/falsifiers.rs`). No production source changed. DR256 global-mask labs (#1790, #1791, #1792 P2) and D-TFLP 6.5 probes (#1800, #1801, #1803) are lab evidence, production impact none. diff --git a/.claude/board/PR_ARC_INVENTORY.md b/.claude/board/PR_ARC_INVENTORY.md index 10a6fc7a6..e7debab20 100644 --- a/.claude/board/PR_ARC_INVENTORY.md +++ b/.claude/board/PR_ARC_INVENTORY.md @@ -1,3 +1,9 @@ +## 2026-10-30 — lance-graph PR #1862 (merged `abe0b7afc`, branch `ccr-45a41fb4-0j3i0l`, head `4707683dc`) — D-DT64-6 Amendment A; D-LITHO folded + +- **Added:** D-DT64-6 Amendment A (the DR facts a DT binding applies, the `tract-harvest-probe` reference path, measured gates); `morton8x8::tests::every_aligned_code_run_is_an_aligned_square`; `lance-graph-mask-risc/tests/dose.rs`; D-LITHO retired and folded into D-DT64-6. +- **Not in it:** the `lance-graph-dt64` crate. Its commits were pushed after the merge and land in the follow-up PR. +- **Confidence:** two lab tests PASS (disable runs red); the rest is docs. + ## 2026-10-26 — lance-graph PR #1806 (merged `99a08622`, branch `docs/d-tflp-v2-synergy-20261026`, head `19a75ae3`) — docs(D-TFLP v2): synergy revision after #1786–#1803 - **Added:** docs only: plan `d-tflp-tflite-palette-polyfill-capstone-v2.md`, entry `2026-10-26-d-tflp-v2-synergy-revision.md`, v1 plan note, STATUS_BOARD/INTEGRATION_PLANS rows. D-TFLP-4 now defaults to the source reciprocal, with #1800's fixtures as its test oracle. diff --git a/.claude/board/entries/README.md b/.claude/board/entries/README.md index 2fda64ff9..8750a21ef 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. -509 entries, 2026-08-06 .. 2026-10-30. +510 entries, 2026-08-06 .. 2026-10-30. | date | entry id | finding | file | |---|---|---|---| From 1761d4ce8a1e281e688fb844e01ee8931ec53ef2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 16:07:46 +0000 Subject: [PATCH 11/11] STATUS_BOARD: D-DT64-6-BS is in PR #1869 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XAqKMPh2Seh2WLccCksAdY --- .claude/board/STATUS_BOARD.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.claude/board/STATUS_BOARD.md b/.claude/board/STATUS_BOARD.md index d5d3669c8..42f04c304 100644 --- a/.claude/board/STATUS_BOARD.md +++ b/.claude/board/STATUS_BOARD.md @@ -55,7 +55,7 @@ | D-DT64-6-3 | register OGIT verbs as DT64 predicates in 64-wide blocks | Queued | D-DT64-6-2 green | | D-DT64-6-4 | consumer read-only access via HotPlug activation | Queued | D-DT64-6-3 green | | D-DT64-6-5 | OGIT becomes cross-check oracle once a domain has attested predicates | Queued | one attested domain | -| D-DT64-6-BS | `crates/lance-graph-dt64`: the bind store (workspace member, contract-only dep). Closes D-DRDT §4 gaps 1–4 (MethodId + SigRev, K3 guard as `SetExpr` + `Logic`, ordered effects, one `resolve`); D5 immutable classid, derived classid + versioned route log; D-DRDT §5 falsifier | In PR (#1862) | 11 tests green, clippy/fmt clean; disable runs (Amendment A.5) | +| D-DT64-6-BS | `crates/lance-graph-dt64`: the bind store (workspace member, contract-only dep). Closes D-DRDT §4 gaps 1–4 (MethodId + SigRev, K3 guard as `SetExpr` + `Logic`, ordered effects, one `resolve`); D5 immutable classid, derived classid + versioned route log; D-DRDT §5 falsifier | In PR (#1869) | 19 tests green, clippy/fmt clean; disable runs (Amendment A.5) | ## D-DT64 — DTO Tensor 64: versioned semantic predicate adapter and DR projections (2026-10-26)