From d3f4255f9a05db71129f2d6caa57433a4723cd8f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 05:44:50 +0000 Subject: [PATCH 1/4] =?UTF-8?q?quack:=20two-world=20frontend=20=E2=80=94?= =?UTF-8?q?=20describe=20once,=20resolve=20once,=20execute=20numeric?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A developer-facing binder in front of Quack's numeric IR: table("users").where_eq("smtp", "alice@x.de").count() .bind(&binder) -> Query { and([Plane(live), Cmp(Col, EqU32(id))]), Count } - quack::bind (the crate's only text-bearing module): Draft / FieldRef / Literal / Op, the Binder trait (catalog + codebook, never mints), and BindError in the developer's vocabulary. quack::Query is already the resolved form, so no ResolvedQuery type is added. - Schema follows the same lifecycle: create_table(..).width(..) -> Registrar -> ResolvedTable { id, width }. No crate owns a table catalog or allocation yet; Registrar has only a test implementation. - tests/string_fence.rs: lib.rs (the executable IR and lowering) holds no text types; bind.rs is the only other module. - Consumers keep their identity contracts behind the same frontend: report/tests/quack_bind.rs (CAM ordinal survives a label rename), dir-sim/tests/quack_bind.rs (ValueId exact vs KeyId comparison; the key-bound query lowers to exactly key_eq_program). - Board entry: the two-world contract, the storage/query/schema lifecycle table, and the Java / SAP / IAM / SQL / DDL seams. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G22yT6htkcdyXsihxxXdrg --- .../2026-10-05-quack-two-world-frontend.md | 44 ++ .claude/board/entries/README.md | 3 +- .../lance-graph-dir-sim/tests/quack_bind.rs | 72 ++ crates/lance-graph-quack/src/bind.rs | 618 ++++++++++++++++++ crates/lance-graph-quack/src/lib.rs | 2 + .../lance-graph-quack/tests/string_fence.rs | 59 ++ crates/lance-graph-report/tests/quack_bind.rs | 94 +++ 7 files changed, 891 insertions(+), 1 deletion(-) create mode 100644 .claude/board/entries/2026-10-05-quack-two-world-frontend.md create mode 100644 crates/lance-graph-dir-sim/tests/quack_bind.rs create mode 100644 crates/lance-graph-quack/src/bind.rs create mode 100644 crates/lance-graph-quack/tests/string_fence.rs create mode 100644 crates/lance-graph-report/tests/quack_bind.rs diff --git a/.claude/board/entries/2026-10-05-quack-two-world-frontend.md b/.claude/board/entries/2026-10-05-quack-two-world-frontend.md new file mode 100644 index 000000000..94e28ee62 --- /dev/null +++ b/.claude/board/entries/2026-10-05-quack-two-world-frontend.md @@ -0,0 +1,44 @@ +# 2026-10-05 — Quack two-world frontend: describe once, resolve once, execute numeric + +## DECISION — the two worlds and the one membrane + +- **Developer world:** table and field names, textual literals, typed descriptors and (later) SQL text. This side may allocate, normalize, hash, consult catalogs, KV and CAM, and fail. +- **Execution world:** fixed-width only — `TableId`, `Col`, `Mask`, numeric literals, ids (`ValueId`, `KeyId`, CAM ordinal, SAP code, `Guid128`, `Dn128`), `ResolvedReading`. +- **One crossing:** `lance_graph_quack::bind::Draft::bind(&dyn Binder) -> Query`. +- **No `ResolvedQuery` type.** `quack::Query` is already that form: `Filter` / `Cmp` / `Col` / `Agg` are all numeric, and `tests/string_fence.rs` keeps it so. +- **SCOPE:** the frontend lives in `quack::bind`, the crate's only text-bearing module. The executable IR and the mask-risc / ndarray layers stay text-free. +- **BASIS:** report already ran this pattern in production (`Catalog`, `CamLabels`, `Selection` → `Cmp::EqU32`). `bind` generalises the *lifecycle*, not report's types. + +## WORKING-MODEL — one lifecycle, three domains, distinct types + +| domain | description (developer world) | resolution | stable numeric contract | executed by | +|---|---|---|---|---| +| storage (#1326) | `SlabDeclaration` + SPOG concept | `Activation::resolve_for_context` | `ResolvedReading` | population reader | +| query | `table(..).where_eq(..)` | `Draft::bind(&dyn Binder)` | `quack::Query` | `lower` → mask-risc | +| schema | `create_table(..).width(..)` | `TableDeclaration::register(&mut dyn Registrar)` | `ResolvedTable { id, width }` | **no owner yet** | + +The shared invariant is the lifecycle, not a unified type. Each resolved type keeps its domain's semantics. + +## MEASURED — the proofs (tests) + +- `quack/src/bind.rs` tests: + - A text literal binds with exactly 4 binder calls (table, live plane, field, code). + - The bound `Query` `==` the hand-written expert `Query`, and their lowered `Program`s are equal. + - Executing twice makes 0 more binder calls; the live plane excludes the dead row. + - A typed descriptor (`FieldRef`) binds to the same query. + - Bind errors are reported in the developer's vocabulary and mint nothing. +- `quack/tests/string_fence.rs`: no text tokens in `lib.rs` outside tests (more than 1,000 lines scanned); `bind.rs` is the only other module. +- `report/tests/quack_bind.rs`: + - Report's `Catalog` + `CamLabels` acting as a `Binder` cost one CAM lookup at bind. + - After a label rename, the new label binds to the *identical* query and the old label is `UnknownValue`. The ordinal identity survives. +- `dir-sim/tests/quack_bind.rs`: + - `smtp` (`KeyId`) binds two spellings to one query; `smtp_exact` (`ValueId`) binds them to two. + - The key-bound query lowers to exactly `key_eq_program(key)`, the program dir-sim executes. + +## OPEN — seams, not built here + +- **DDL actuator:** nothing owns a table catalog or physical allocation, so `Registrar` has only a test implementation. `CREATE TABLE … WIDTH …` stops at `ResolvedTable` until an owner exists. +- **Java:** today `View.where` → `plan_lower` → mask-risc; answer parity with Quack is proven by `lowering_convergence`. Convergence target: Java DSL → `Binder` → `quack::Query` → `lower`, which retires `plan_lower`. +- **SAP:** its forward text → code map is dropped at bind, so it has no `Binder::code`. Keeping that map, cold, per batch would let `activity = 'Consulting'` bind after ingest; codes stay batch-local. +- **IAM:** `users_with_key` can become `table("users").where_eq("smtp", …)` over an IAM `Binder` with no identity change (the test above is that binder). +- **SQL frontend:** `SELECT COUNT(*) FROM users WHERE smtp = '…'` parses to the same `Draft`. No parser is in scope. diff --git a/.claude/board/entries/README.md b/.claude/board/entries/README.md index 049bc5ed4..f4e6edc24 100644 --- a/.claude/board/entries/README.md +++ b/.claude/board/entries/README.md @@ -25,11 +25,12 @@ index row, (3) no duplicate entry id. Checks 1 and 2 are deliberately opposite directions; the stranding this convention prevents shows up in exactly one of them, never both. -214 entries, 2026-08-06 .. 2026-10-05. +215 entries, 2026-08-06 .. 2026-10-05. | date | entry id | finding | file | |---|---|---|---| | 2026-10-05 | `text-to-numeric-boundary-inventory` | | [2026-10-05-text-to-numeric-boundary-inventory.md](2026-10-05-text-to-numeric-boundary-inventory.md) | +| 2026-10-05 | `quack-two-world-frontend` | | [2026-10-05-quack-two-world-frontend.md](2026-10-05-quack-two-world-frontend.md) | | 2026-10-04 | `D-LXC-22` | | [2026-10-04-wordnet-clam-chaoda-scope-and-grammar-read-params.md](2026-10-04-wordnet-clam-chaoda-scope-and-grammar-read-params.md) | | 2026-10-04 | `D-HPS-1` | | [2026-10-04-spog-slab-hotplug-resolution.md](2026-10-04-spog-slab-hotplug-resolution.md) | | 2026-10-04 | `D-HPS-2` | | [2026-10-04-resolve-once-population-execution.md](2026-10-04-resolve-once-population-execution.md) | diff --git a/crates/lance-graph-dir-sim/tests/quack_bind.rs b/crates/lance-graph-dir-sim/tests/quack_bind.rs new file mode 100644 index 000000000..85f9bde90 --- /dev/null +++ b/crates/lance-graph-dir-sim/tests/quack_bind.rs @@ -0,0 +1,72 @@ +//! The Quack frontend over IAM's label/value store. Two fields bind the same +//! attribute under different identity contracts — `smtp` by comparison key +//! (`KeyId`, case-insensitive), `smtp_exact` by exact value (`ValueId`) — +//! and the generic frontend must keep them apart even though both end as +//! `EqU32`. The key form must bind to exactly the program dir-sim runs. + +use lance_graph_dir_sim::*; +use lance_graph_quack::bind::{table, Binder, BoundField, FieldKind, TableId}; +use lance_graph_quack::{lower, Col, Mask, Query}; +use ogar_dir_core::Guid128; + +/// Lane 0 = SMTP key ids, lane 1 = SMTP value ids; plane 0 = candidates. +struct IamBinder<'a>(&'a Dicts); + +impl Binder for IamBinder<'_> { + fn table(&self, name: &str) -> Option { + (name == "users").then_some(TableId(0)) + } + fn live(&self, _: TableId) -> Mask { + Mask(0) + } + fn field(&self, _: TableId, name: &str) -> Option { + let col = match name { + "smtp" => Col(0), + "smtp_exact" => Col(1), + _ => return None, + }; + Some(BoundField { + col, + kind: FieldKind::Code, + }) + } + fn code(&self, _: TableId, col: Col, literal: &str) -> Option { + match col { + Col(0) => self.0.key_lookup(literal).map(|k| k.0), + _ => self.0.lookup(literal).map(|v| v.0), + } + } +} + +fn eq(field: &str, value: &str, b: &IamBinder<'_>) -> Query { + table("users").where_eq(field, value).bind(b).unwrap() +} + +#[test] +fn exact_value_and_comparison_key_bind_under_their_own_contracts() { + let mut st = VersionStore::new(); + let obs = Observation { + nodes: vec![ + (Guid128([1; 16]), ObservedNode::user("a@x.de", "alice@x.de")), + (Guid128([2; 16]), ObservedNode::user("b@x.de", "Alice@X.de")), + ], + ..Observation::default() + }; + st.observe("lab", 0, obs).unwrap(); + let b = IamBinder(st.dicts()); + let lookups = || b.0.counters.snapshot()[1]; + + let before = lookups(); + let by_key = eq("smtp", "alice@x.de", &b); + assert_eq!(lookups(), before + 1, "one lookup, at bind"); + // Comparison form: both spellings are one key. + assert_eq!(eq("smtp", "ALICE@x.DE", &b), by_key); + // Exact form: the two spellings are two values. + assert_ne!( + eq("smtp_exact", "alice@x.de", &b), + eq("smtp_exact", "Alice@X.de", &b) + ); + // The key-bound query is exactly the program dir-sim executes. + let key = b.0.key_lookup("alice@x.de").unwrap(); + assert_eq!(lower(&by_key).unwrap(), key_eq_program(key)); +} diff --git a/crates/lance-graph-quack/src/bind.rs b/crates/lance-graph-quack/src/bind.rs new file mode 100644 index 000000000..fa19bafb1 --- /dev/null +++ b/crates/lance-graph-quack/src/bind.rs @@ -0,0 +1,618 @@ +//! The developer world: names and textual literals, bound ONCE into a +//! numeric [`Query`]. +//! +//! ```text +//! table("users").where_eq("smtp", "alice@x.de").count() ← may hold text +//! │ bind(&binder) names → TableId / Col +//! │ literals → the field's own numeric id +//! ▼ +//! Query { filter: and([Plane(live), Cmp(Col(k), EqU32(id))]), agg: Count } +//! │ lower ← no text exists past this line +//! ▼ +//! Program → mask-risc → ndarray +//! ``` +//! +//! **Describe once, resolve once, execute numeric.** [`Query`] is already +//! the resolved form: every type in it is fixed-width. So there is no +//! separate "resolved query"; binding produces a `Query`, and a `Query` can +//! be lowered and executed any number of times without touching a name, a +//! label or a codebook again. This module is the only part of the crate +//! that holds text (`tests/string_fence.rs`). +//! +//! **Quack is ID-agnostic after binding.** A [`Binder`] is the consumer's +//! resolution membrane: its catalog names tables and fields, and its own +//! codebook turns a textual literal into whatever numeric identity that +//! field uses — a report CAM ordinal (a category whose label can be +//! renamed), an IAM `ValueId` (the exact text) or `KeyId` (its comparison +//! form), a batch-local SAP code. All of them arrive here as a `u32` in an +//! `EqU32`; their meaning, and what survives a rename or a re-observation, +//! stays with the binder that issued them. +//! +//! The same lifecycle as `lance_graph_contract::hotplug`'s +//! `SlabDeclaration → resolve_for_context → ResolvedReading`: an external +//! description is resolved at one boundary into a stable numeric contract, +//! which is then executed over a population as often as needed. +//! +//! Schema declarations follow the same shape ([`create_table`] → +//! [`ResolvedTable`]); no crate owns a table catalog or storage allocation +//! yet, so [`Registrar`] has no production implementation. + +use crate::{Agg, Cmp, Col, Filter, Mask, Query}; + +/// A table, after its name has been resolved. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct TableId(pub u32); + +/// What literal a bound field compares against. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum FieldKind { + /// A signed integer lane (`I32`). + I32, + /// A `U32` lane of numeric identities issued by the binder's codebook. + /// Textual literals for it are resolved by [`Binder::code`]. + Code, +} + +/// A field, after its name has been resolved. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct BoundField { + /// The lane the executor reads. + pub col: Col, + /// What it compares against. + pub kind: FieldKind, +} + +/// A consumer's resolution membrane: catalog plus codebook. Called only by +/// [`Draft::bind`]; nothing in execution holds or calls it. +pub trait Binder { + /// A table by name. + fn table(&self, name: &str) -> Option; + /// The plane every row of the table must pass (its validity plane). + fn live(&self, table: TableId) -> Mask; + /// A field of the table by name. + fn field(&self, table: TableId, name: &str) -> Option; + /// The numeric identity of a textual literal in a field's domain. + /// Never mints: a literal the domain has never seen matches nothing and + /// is reported as [`BindError::UnknownValue`]. + fn code(&self, table: TableId, field: Col, literal: &str) -> Option; +} + +/// A literal as a developer writes it. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum Literal { + /// Text, resolved by the field's codebook. + Text(String), + /// An integer. + Int(i64), +} + +impl From<&str> for Literal { + fn from(s: &str) -> Self { + Literal::Text(s.to_string()) + } +} +impl From for Literal { + fn from(s: String) -> Self { + Literal::Text(s) + } +} +impl From for Literal { + fn from(v: i64) -> Self { + Literal::Int(v) + } +} +impl From for Literal { + fn from(v: i32) -> Self { + Literal::Int(i64::from(v)) + } +} + +/// A comparison operator a frontend can express on any field kind. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Op { + /// `=` + Eq, + /// `<>` + Ne, +} + +/// One unbound predicate: `field literal`. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Predicate { + table: Option, + field: String, + op: Op, + value: Literal, +} + +/// A typed field descriptor, for a schema written down in code +/// (`const SMTP: FieldRef = FieldRef::new("users", "smtp");`). It holds +/// names only; binding resolves them like any other. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct FieldRef { + table: &'static str, + field: &'static str, +} + +impl FieldRef { + /// A field of a table. + pub const fn new(table: &'static str, field: &'static str) -> Self { + Self { table, field } + } + /// `field = value`. + pub fn eq(self, value: impl Into) -> Predicate { + self.pred(Op::Eq, value) + } + /// `field <> value`. + pub fn ne(self, value: impl Into) -> Predicate { + self.pred(Op::Ne, value) + } + fn pred(self, op: Op, value: impl Into) -> Predicate { + Predicate { + table: Some(self.table.to_string()), + field: self.field.to_string(), + op, + value: value.into(), + } + } +} + +/// What the query returns. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +enum Want { + Count, + Rows, +} + +/// An unbound query over one table. Holds text; [`Draft::bind`] is the only +/// way out of it. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Draft { + table: String, + preds: Vec, + want: Want, +} + +/// Start a query over a table. +pub fn table(name: impl Into) -> Draft { + Draft { + table: name.into(), + preds: Vec::new(), + want: Want::Rows, + } +} + +/// Why a draft did not bind. Each names the developer-world term that +/// failed, so the error is reported in the developer's vocabulary. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BindError { + /// No such table. + UnknownTable(String), + /// No such field in the table. + UnknownField { + /// Table. + table: String, + /// Field. + field: String, + }, + /// The field's domain has no such value (nothing is minted). + UnknownValue { + /// Field. + field: String, + /// The literal. + value: String, + }, + /// A text literal for an integer field, or an integer for a coded one. + KindMismatch { + /// Field. + field: String, + }, + /// An integer literal outside the field's lane. + OutOfRange { + /// Field. + field: String, + }, + /// A typed descriptor of another table used on this one. + WrongTable { + /// The query's table. + query: String, + /// The descriptor's table. + field: String, + }, +} + +impl Draft { + /// `AND field = value`. + pub fn where_eq(self, field: &str, value: impl Into) -> Self { + self.where_(Predicate { + table: None, + field: field.to_string(), + op: Op::Eq, + value: value.into(), + }) + } + /// `AND predicate` (e.g. from a [`FieldRef`]). + pub fn where_(mut self, p: Predicate) -> Self { + self.preds.push(p); + self + } + /// Return the number of matching rows. + pub fn count(mut self) -> Self { + self.want = Want::Count; + self + } + /// Return the matching rows (the default). + pub fn rows(mut self) -> Self { + self.want = Want::Rows; + self + } + + /// Resolve every name and literal once and produce the numeric + /// [`Query`]. Calls the binder once per table, field and textual + /// literal; the result holds none of them. + /// + /// # Errors + /// + /// The first [`BindError`]; nothing is minted or registered on failure. + pub fn bind(&self, b: &dyn Binder) -> Result { + let t = b + .table(&self.table) + .ok_or_else(|| BindError::UnknownTable(self.table.clone()))?; + let mut parts = vec![Filter::plane(b.live(t))]; + for p in &self.preds { + if let Some(owner) = &p.table { + if owner != &self.table { + return Err(BindError::WrongTable { + query: self.table.clone(), + field: owner.clone(), + }); + } + } + parts.push(self.bind_pred(b, t, p)?); + } + let agg = match self.want { + Want::Count => Agg::Count, + Want::Rows => Agg::Rows, + }; + Ok(Query { + filter: Filter::and(parts), + agg, + }) + } + + fn bind_pred(&self, b: &dyn Binder, t: TableId, p: &Predicate) -> Result { + let f = b + .field(t, &p.field) + .ok_or_else(|| BindError::UnknownField { + table: self.table.clone(), + field: p.field.clone(), + })?; + let mismatch = || BindError::KindMismatch { + field: p.field.clone(), + }; + let cmp = match (f.kind, &p.value) { + (FieldKind::I32, Literal::Int(v)) => { + let v = i32::try_from(*v).map_err(|_| BindError::OutOfRange { + field: p.field.clone(), + })?; + match p.op { + Op::Eq => Cmp::EqI32(v), + Op::Ne => Cmp::NeI32(v), + } + } + (FieldKind::Code, Literal::Text(s)) => { + let id = b.code(t, f.col, s).ok_or_else(|| BindError::UnknownValue { + field: p.field.clone(), + value: s.clone(), + })?; + match p.op { + Op::Eq => Cmp::EqU32(id), + Op::Ne => Cmp::NeU32(id), + } + } + _ => return Err(mismatch()), + }; + Ok(Filter::cmp(f.col, cmp)) + } +} + +// ---- schema: the same lifecycle for declarations --------------------------- + +/// An unbound table declaration: `CREATE TABLE WIDTH `. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct TableDeclaration { + name: String, + width: u32, +} + +/// `CREATE TABLE name`; set the row width with [`TableDeclaration::width`]. +pub fn create_table(name: impl Into) -> TableDeclaration { + TableDeclaration { + name: name.into(), + width: 0, + } +} + +/// A table after registration: the name is gone, only the id and the +/// physical declaration remain. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct ResolvedTable { + /// The table's id; every later query and write addresses this. + pub id: TableId, + /// Declared row width in bytes. + pub width: u32, +} + +/// Why a declaration did not register. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RegisterError { + /// No width, or zero. + NoWidth(String), + /// The name is taken. + Exists(String), + /// The owner refused the physical declaration (e.g. an unsupported width). + Refused { + /// Table. + table: String, + /// Width asked for. + width: u32, + }, +} + +/// The owner of a table catalog and its physical allocation. No crate owns +/// that today; this trait names the seam, and only tests implement it. +pub trait Registrar { + /// Register a new table, minting its id, or refuse. + /// + /// # Errors + /// + /// [`RegisterError::Exists`] or [`RegisterError::Refused`]. + fn register(&mut self, name: &str, width: u32) -> Result; +} + +impl TableDeclaration { + /// `WIDTH bytes`. + pub fn width(mut self, bytes: u32) -> Self { + self.width = bytes; + self + } + /// Register once; the result carries no name. + /// + /// # Errors + /// + /// [`RegisterError::NoWidth`], or whatever the registrar refuses. + pub fn register(&self, r: &mut dyn Registrar) -> Result { + if self.width == 0 { + return Err(RegisterError::NoWidth(self.name.clone())); + } + Ok(ResolvedTable { + id: r.register(&self.name, self.width)?, + width: self.width, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::lower; + use lance_graph_mask_risc::{ + execute_into, materialize_rows, words_for, Foreign, LaneRef, Out, Planes, Scratch, Value, + }; + use std::cell::Cell; + + /// A users table: plane 0 = live rows, lane 0 = smtp key (coded), + /// lane 1 = age (i32). Every binder call is counted. + struct Users { + smtp: Vec<&'static str>, + calls: Cell, + } + impl Users { + fn new() -> Self { + Self { + smtp: vec!["alice@x.de", "bob@x.de", "carol@x.de"], + calls: Cell::new(0), + } + } + fn tick(&self) { + self.calls.set(self.calls.get() + 1); + } + } + impl Binder for Users { + fn table(&self, name: &str) -> Option { + self.tick(); + (name == "users").then_some(TableId(7)) + } + fn live(&self, _: TableId) -> Mask { + self.tick(); + Mask(0) + } + fn field(&self, _: TableId, name: &str) -> Option { + self.tick(); + match name { + "smtp" => Some(BoundField { + col: Col(0), + kind: FieldKind::Code, + }), + "age" => Some(BoundField { + col: Col(1), + kind: FieldKind::I32, + }), + _ => None, + } + } + fn code(&self, _: TableId, _: Col, literal: &str) -> Option { + self.tick(); + self.smtp + .iter() + .position(|s| *s == literal.to_ascii_lowercase()) + .map(|i| i as u32) + } + } + + /// 200 rows: smtp code = i % 3, age = 20 + i % 50, row 13 not live. + fn run(q: &Query) -> (Value, Vec) { + let n = 200; + let codes: Vec = (0..n).map(|i| (i % 3) as u32).collect(); + let ages: Vec = (0..n).map(|i| 20 + (i % 50) as i32).collect(); + let mut live = vec![u64::MAX; words_for(n)]; + live[3] &= (1u64 << (n % 64)) - 1; + live[0] &= !(1 << 13); + let lanes = [LaneRef::U32(&codes), LaneRef::I32(&ages)]; + let masks: [&[u64]; 1] = [&live]; + let planes = Planes { + n_rows: n, + masks: &masks, + lanes: &lanes, + }; + let p = lower(q).unwrap(); + let mut scratch = Scratch::for_program(&p, n).unwrap(); + let mut bits = vec![0u64; words_for(n)]; + let out = if q.agg == Agg::Rows { + Out::Mask(&mut bits) + } else { + Out::None + }; + let v = execute_into(&p, &planes, &Foreign::NONE, &mut scratch, out).unwrap(); + (v, materialize_rows(&bits, n)) + } + + #[test] + fn a_text_literal_binds_once_to_the_query_an_expert_writes() { + let users = Users::new(); + let draft = table("users").where_eq("smtp", "Alice@X.de").count(); + let bound = draft.bind(&users).unwrap(); + // table + live + field + code: one call each, no more. + assert_eq!(users.calls.get(), 4); + let expert = Query { + filter: Filter::and([Filter::plane(Mask(0)), Filter::cmp(Col(0), Cmp::EqU32(0))]), + agg: Agg::Count, + }; + assert_eq!(bound, expert); + assert_eq!(lower(&bound).unwrap(), lower(&expert).unwrap()); + // Execute twice: the binder is not reachable from here, and the + // counter proves it was not called. + let first = run(&bound).0; + let second = run(&bound).0; + assert_eq!(first, second); + // Rows with code 0 are i % 3 == 0: 67 of 200, minus none (row 13 is + // code 1, the dead row is not one of them). + assert_eq!(first, Value::Count(67)); + assert_eq!(users.calls.get(), 4); + } + + #[test] + fn a_typed_descriptor_binds_to_the_same_query() { + const SMTP: FieldRef = FieldRef::new("users", "smtp"); + const AGE: FieldRef = FieldRef::new("users", "age"); + let users = Users::new(); + let by_name = table("users") + .where_eq("smtp", "carol@x.de") + .where_eq("age", 21) + .rows() + .bind(&users) + .unwrap(); + let typed = table("users") + .where_(SMTP.eq("carol@x.de")) + .where_(AGE.eq(21)) + .bind(&users) + .unwrap(); + assert_eq!(by_name, typed); + // smtp code 2 ⇔ i % 3 == 2; age 21 ⇔ i % 50 == 1. i ≡ 101 (mod 150). + assert_eq!(run(&typed).1, vec![101]); + // The dead row is excluded by the bound live plane. + let row13 = table("users") + .where_eq("age", 33) + .rows() + .bind(&users) + .unwrap(); + assert_eq!(run(&row13).1, vec![63, 113, 163]); + } + + #[test] + fn binding_fails_in_the_developers_vocabulary_and_never_mints() { + let users = Users::new(); + let err = |d: Draft| d.bind(&users).unwrap_err(); + assert_eq!( + err(table("people")), + BindError::UnknownTable("people".into()) + ); + assert_eq!( + err(table("users").where_eq("mail", "a")), + BindError::UnknownField { + table: "users".into(), + field: "mail".into() + } + ); + assert_eq!( + err(table("users").where_eq("smtp", "dave@x.de")), + BindError::UnknownValue { + field: "smtp".into(), + value: "dave@x.de".into() + } + ); + assert_eq!( + err(table("users").where_eq("age", "old")), + BindError::KindMismatch { + field: "age".into() + } + ); + assert_eq!( + err(table("users").where_eq("age", i64::MAX)), + BindError::OutOfRange { + field: "age".into() + } + ); + const OTHER: FieldRef = FieldRef::new("groups", "smtp"); + assert_eq!( + err(table("users").where_(OTHER.eq("alice@x.de"))), + BindError::WrongTable { + query: "users".into(), + field: "groups".into() + } + ); + assert_eq!(users.smtp.len(), 3, "an unknown value minted nothing"); + } + + #[test] + fn create_table_registers_once_and_keeps_no_name() { + #[derive(Default)] + struct Mem(Vec<(String, u32)>); + impl Registrar for Mem { + fn register(&mut self, name: &str, width: u32) -> Result { + if self.0.iter().any(|(n, _)| n == name) { + return Err(RegisterError::Exists(name.into())); + } + if width != 512 { + return Err(RegisterError::Refused { + table: name.into(), + width, + }); + } + self.0.push((name.into(), width)); + Ok(TableId(self.0.len() as u32 - 1)) + } + } + let mut mem = Mem::default(); + let users = create_table("users").width(512).register(&mut mem).unwrap(); + assert_eq!( + users, + ResolvedTable { + id: TableId(0), + width: 512 + } + ); + assert_eq!( + create_table("users").width(512).register(&mut mem), + Err(RegisterError::Exists("users".into())) + ); + assert_eq!( + create_table("groups").register(&mut mem), + Err(RegisterError::NoWidth("groups".into())) + ); + assert!(matches!( + create_table("groups").width(100).register(&mut mem), + Err(RegisterError::Refused { .. }) + )); + } +} diff --git a/crates/lance-graph-quack/src/lib.rs b/crates/lance-graph-quack/src/lib.rs index b20457ece..c99e48a17 100644 --- a/crates/lance-graph-quack/src/lib.rs +++ b/crates/lance-graph-quack/src/lib.rs @@ -162,6 +162,8 @@ #![forbid(unsafe_code)] +pub mod bind; + use std::cmp::Reverse; use lance_graph_contract::facet::{SemanticAperture, SemanticPrefix}; diff --git a/crates/lance-graph-quack/tests/string_fence.rs b/crates/lance-graph-quack/tests/string_fence.rs new file mode 100644 index 000000000..9e0c30e91 --- /dev/null +++ b/crates/lance-graph-quack/tests/string_fence.rs @@ -0,0 +1,59 @@ +//! The two-world fence: the executable IR holds no text. +//! +//! Text may appear only in `bind.rs`, the developer-world frontend. Every +//! other line of the crate's non-test source — the `Query` / `Filter` / +//! `Cmp` / `Agg` vocabulary and its lowering — must be free of text types, +//! so a bound query cannot carry a name, a label or a literal string into +//! `lower` or the executor. + +const FORBIDDEN: &[&str] = &["String", "&str", "str::", "format!", "to_string", "char"]; + +fn violations(src: &str) -> Vec { + src.lines() + .enumerate() + .take_while(|(_, l)| l.trim() != "#[cfg(test)]") + .filter(|(_, l)| !l.trim_start().starts_with("//")) + .filter(|(_, l)| FORBIDDEN.iter().any(|t| l.contains(t))) + .map(|(i, l)| format!("{}: {}", i + 1, l.trim())) + .collect() +} + +#[test] +fn the_executable_ir_is_string_free() { + let src = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/src/lib.rs")).unwrap(); + let bad = violations(&src); + assert!( + bad.is_empty(), + "text in the executable IR:\n{}", + bad.join("\n") + ); + // Anti-vacuity: the scan covered the IR, not an empty prefix. + let scanned = src + .lines() + .take_while(|l| l.trim() != "#[cfg(test)]") + .count(); + assert!(scanned > 1_000, "only {scanned} lines scanned"); +} + +#[test] +fn the_only_text_module_is_bind() { + let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/src"); + let mut modules: Vec = std::fs::read_dir(dir) + .unwrap() + .map(|e| e.unwrap().file_name().into_string().unwrap()) + .collect(); + modules.sort(); + assert_eq!( + modules, + ["bind.rs", "lib.rs"], + "a new module must be fenced" + ); +} + +#[test] +fn the_fence_can_fire() { + assert_eq!(violations(" Label(String),").len(), 1); + assert_eq!(violations(" name: &str,").len(), 1); + assert!(violations(" // a String is presentation").is_empty()); + assert!(violations("#[cfg(test)]\n Label(String),").is_empty()); +} diff --git a/crates/lance-graph-report/tests/quack_bind.rs b/crates/lance-graph-report/tests/quack_bind.rs new file mode 100644 index 000000000..9723b573b --- /dev/null +++ b/crates/lance-graph-report/tests/quack_bind.rs @@ -0,0 +1,94 @@ +//! The Quack frontend over report's own boundary: `Catalog` names fields, +//! `CamLabels` turns a categorical label into its ordinal. The bound query +//! keeps the ORDINAL, so a presentation rename leaves it valid — report's +//! identity contract survives the generic frontend. + +use lance_graph_contract::content_store::ContentId; +use lance_graph_quack::bind::{table, BindError, Binder, BoundField, FieldKind, TableId}; +use lance_graph_quack::{Col, Mask, Query}; +use lance_graph_report::boundary::{BoundaryCounters, CamLabels, Catalog, MemKv}; +use lance_graph_report::FieldId; + +/// Report's catalog + CAM as a binder. The `Col` is the `FieldId`; a +/// production adapter takes the lane from the batch instead. +struct ReportBinder<'a> { + cat: &'a Catalog, + cam: &'a CamLabels, + categorical: &'a [FieldId], +} + +impl Binder for ReportBinder<'_> { + fn table(&self, name: &str) -> Option { + (name == "sales").then_some(TableId(0)) + } + fn live(&self, _: TableId) -> Mask { + Mask(0) + } + fn field(&self, _: TableId, name: &str) -> Option { + let id = self.cat.field(name)?; + let kind = if self.categorical.contains(&id) { + FieldKind::Code + } else { + FieldKind::I32 + }; + Some(BoundField { + col: Col(id.0 as u16), + kind, + }) + } + fn code(&self, _: TableId, col: Col, literal: &str) -> Option { + self.cam.ordinal(FieldId(u32::from(col.0)), literal) + } +} + +#[test] +fn a_presentation_rename_keeps_the_bound_ordinal() { + let region = FieldId(1); + let cat = Catalog::default() + .with("region", region) + .with("revenue", FieldId(2)); + let (mut cam, mut kv) = (CamLabels::default(), MemKv::default()); + cam.canonicalize(region, ["North", "South", "Coast"], &mut kv); + let lookups = |c: &CamLabels| BoundaryCounters::get(&c.counters.cam_lookups); + + let before = lookups(&cam); + let bound: Query = { + let b = ReportBinder { + cat: &cat, + cam: &cam, + categorical: &[region], + }; + table("sales") + .where_eq("region", "North") + .where_eq("revenue", 100) + .count() + .bind(&b) + .unwrap() + }; + assert_eq!(lookups(&cam), before + 1, "one CAM lookup, at bind"); + + // Rename the category's label: the ordinal is the identity. + assert!(cam.rename(region, 0, "Nordland", &mut kv)); + let b = ReportBinder { + cat: &cat, + cam: &cam, + categorical: &[region], + }; + let renamed = table("sales") + .where_eq("region", "Nordland") + .where_eq("revenue", 100) + .count() + .bind(&b) + .unwrap(); + assert_eq!(renamed, bound, "same category, same numeric query"); + assert_eq!( + table("sales").where_eq("region", "North").bind(&b), + Err(BindError::UnknownValue { + field: "region".into(), + value: "North".into() + }), + "the old label no longer names it" + ); + // The label lives in KV under its content address, never in the query. + assert_eq!(kv.text(ContentId::of_str("Nordland")), Some("Nordland")); +} From 78a75503a8ca33df361b15a0997123dcff884eae Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 05:52:59 +0000 Subject: [PATCH 2/4] quack: pin the canonical LE byte reading of the strided u32 lane Known-vector falsifier: Cmp::EqU32Strided(0x12345678) matches exactly the records holding [0x78,0x56,0x34,0x12] and never the byte-swapped spelling, through the executor (ndarray kernel) and the reference interpreter, for a contiguous and a strided lane, in both the 16-lane group path and the tail. Literal byte fixtures, so the test does not pass merely on an LE host. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G22yT6htkcdyXsihxxXdrg --- .../lance-graph-quack/tests/canonical_le.rs | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 crates/lance-graph-quack/tests/canonical_le.rs diff --git a/crates/lance-graph-quack/tests/canonical_le.rs b/crates/lance-graph-quack/tests/canonical_le.rs new file mode 100644 index 000000000..2c2b91f54 --- /dev/null +++ b/crates/lance-graph-quack/tests/canonical_le.rs @@ -0,0 +1,119 @@ +//! The resolved world's byte rule: a multi-byte integer read from raw bytes is +//! little-endian. `Cmp::EqU32Strided(0x1234_5678)` is a NUMBER; the strided +//! lane under it is BYTES, and the bytes `[0x78, 0x56, 0x34, 0x12]` are what +//! that number looks like on the substrate. +//! +//! The fixtures are written as literal byte vectors, never with +//! `to_ne_bytes`/`to_le_bytes`, so the test means the same thing on every +//! host: it cannot pass merely because the CI machine is little-endian. Each +//! fixture also stores the byte-swapped spelling `[0x12, 0x34, 0x56, 0x78]` +//! in other rows, which a big-endian reader would match instead. +//! +//! `Register128`'s word encoding has its own known-vector test in the owning +//! crate (`register128::tests::words_round_trip_little_endian`); here it is +//! only used as the production encoder of one record, to show that what the +//! contract writes and what the strided kernel reads agree. + +use lance_graph_contract::register128::Register128; +use lance_graph_mask_risc::{ + execute, materialize_rows, reference_execute, words_for, Foreign, LaneRef, Out, Planes, + Scratch, StridedRef, Value, +}; +use lance_graph_quack::{lower, Agg, Cmp, Col, Filter, Query}; + +const V: u32 = 0x1234_5678; +const LE: [u8; 4] = [0x78, 0x56, 0x34, 0x12]; +const BE: [u8; 4] = [0x12, 0x34, 0x56, 0x78]; + +/// `n` records of `stride` bytes. Rows `0`, `17` hold the LE spelling, rows +/// `1`, `18` the BE spelling, everything else zero. `n = 20` puts rows in +/// both the 16-lane group path and the scalar tail of the kernel. +fn fixture(stride: usize) -> Vec { + let n = 20; + let mut b = vec![0u8; n * stride]; + for (row, word) in [(0, LE), (17, LE), (1, BE), (18, BE)] { + b[row * stride..row * stride + 4].copy_from_slice(&word); + } + b +} + +/// Run `cmp` on the strided lane through both mask-risc readers — the +/// executor (ndarray's kernel) and the row-by-row reference — and return +/// the matching rows, asserting the two agree. +fn rows(bytes: &[u8], stride: usize, cmp: Cmp) -> Vec { + let n = bytes.len() / stride; + let p = lower(&Query { + filter: Filter::cmp(Col(0), cmp), + agg: Agg::Rows, + }) + .unwrap(); + let lanes = [LaneRef::Strided(StridedRef { + bytes, + first_offset: 0, + stride, + records: n, + })]; + let planes = Planes { + n_rows: n, + masks: &[], + lanes: &lanes, + }; + let mut scratch = Scratch::for_program(&p, n).unwrap(); + let mut out = vec![0u64; words_for(n)]; + lance_graph_mask_risc::execute_into( + &p, + &planes, + &Foreign::NONE, + &mut scratch, + Out::Mask(&mut out), + ) + .unwrap(); + let got = materialize_rows(&out, n); + + // The reference interpreter decodes the same bytes independently. + let count = lower(&Query { + filter: Filter::cmp(Col(0), cmp), + agg: Agg::Count, + }) + .unwrap(); + let mut s2 = Scratch::for_program(&count, n).unwrap(); + let exec = execute(&count, &planes, &mut s2, None).unwrap(); + let refr = reference_execute(&count, &planes, None).unwrap(); + assert_eq!(exec, refr, "executor and reference disagree"); + assert_eq!(exec, Value::Count(got.len())); + got +} + +/// The numeric value `0x12345678` matches the rows whose bytes are +/// `[0x78, 0x56, 0x34, 0x12]`, and not the rows that spell it big-endian. +/// Covered for a contiguous lane (stride 4) and a lane inside wider +/// records (stride 16). +#[test] +fn eq_u32_strided_reads_canonical_le_bytes() { + for stride in [4, 16] { + let b = fixture(stride); + assert_eq!(rows(&b, stride, Cmp::EqU32Strided(V)), vec![0, 17], "stride {stride}"); + // Can-fire: a big-endian reader would match rows 1 and 18 instead. + assert_eq!( + rows(&b, stride, Cmp::EqU32Strided(V.swap_bytes())), + vec![1, 18], + "stride {stride}" + ); + // `!=` is the complement over the same canonical reading. + let ne = rows(&b, stride, Cmp::NeU32Strided(V)); + assert_eq!(ne.len(), 18); + assert!(!ne.contains(&0) && !ne.contains(&17)); + } +} + +/// The contract's own encoder (`Register128::from_words`) writes the bytes the +/// strided kernel reads back as the same number: one byte rule on both sides +/// of the boundary. +#[test] +fn register128_encoding_is_read_back_by_the_strided_kernel() { + let r = Register128::from_words([V, 0, 0, 0]); + assert_eq!(r.0[..4], LE); + let mut b = vec![0u8; 20 * 16]; + b[5 * 16..6 * 16].copy_from_slice(&r.0); + assert_eq!(rows(&b, 16, Cmp::EqU32Strided(V)), vec![5]); +} From 277cb16195b43545c45a5c0bab36aaab66249189 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 05:53:47 +0000 Subject: [PATCH 3/4] quack: make the LE fixture asymmetric so a BE reference reader cannot tie Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G22yT6htkcdyXsihxxXdrg --- crates/lance-graph-quack/tests/canonical_le.rs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/crates/lance-graph-quack/tests/canonical_le.rs b/crates/lance-graph-quack/tests/canonical_le.rs index 2c2b91f54..d1063fa6d 100644 --- a/crates/lance-graph-quack/tests/canonical_le.rs +++ b/crates/lance-graph-quack/tests/canonical_le.rs @@ -25,13 +25,15 @@ const V: u32 = 0x1234_5678; const LE: [u8; 4] = [0x78, 0x56, 0x34, 0x12]; const BE: [u8; 4] = [0x12, 0x34, 0x56, 0x78]; -/// `n` records of `stride` bytes. Rows `0`, `17` hold the LE spelling, rows -/// `1`, `18` the BE spelling, everything else zero. `n = 20` puts rows in +/// `n` records of `stride` bytes. Rows `0`, `17` hold the LE spelling, row +/// `1` the BE spelling, everything else zero. The two counts differ on +/// purpose: with equal counts a big-endian reader in the reference +/// interpreter would still report the same `Count` and slip through. `n = 20` puts rows in /// both the 16-lane group path and the scalar tail of the kernel. fn fixture(stride: usize) -> Vec { let n = 20; let mut b = vec![0u8; n * stride]; - for (row, word) in [(0, LE), (17, LE), (1, BE), (18, BE)] { + for (row, word) in [(0, LE), (17, LE), (1, BE)] { b[row * stride..row * stride + 4].copy_from_slice(&word); } b @@ -93,15 +95,15 @@ fn eq_u32_strided_reads_canonical_le_bytes() { for stride in [4, 16] { let b = fixture(stride); assert_eq!(rows(&b, stride, Cmp::EqU32Strided(V)), vec![0, 17], "stride {stride}"); - // Can-fire: a big-endian reader would match rows 1 and 18 instead. + // Can-fire: a big-endian reader would match row 1 instead. assert_eq!( rows(&b, stride, Cmp::EqU32Strided(V.swap_bytes())), - vec![1, 18], + vec![1], "stride {stride}" ); // `!=` is the complement over the same canonical reading. let ne = rows(&b, stride, Cmp::NeU32Strided(V)); - assert_eq!(ne.len(), 18); + assert_eq!(ne.len(), 18); // 20 rows minus the two LE matches assert!(!ne.contains(&0) && !ne.contains(&17)); } } From b2c6d456bcfcd8e3de3d45311b1d27b5592aea8f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 05:56:08 +0000 Subject: [PATCH 4/4] =?UTF-8?q?quack:=20canonicalize=20once=20=E2=80=94=20?= =?UTF-8?q?the=20two-world=20contract=20owns=20the=20LE=20byte=20ABI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The resolution membrane also fixes the physical representation: Quack's Query stays semantic numeric IR (no Endian field, no ResolvedQuery), and byte order exists only where a fixed-width integer meets raw bytes, where it is little-endian. Byte arrays (Dn128, facet pattern/care) are order- neutral. Documented in quack::bind and the board entry, with the byte- boundary audit: every production reader is explicit LE; FacetCascade's compute-lens reinterpret is fenced by a compile-time LE guard and reported, not changed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G22yT6htkcdyXsihxxXdrg --- .../2026-10-05-quack-two-world-frontend.md | 60 ++++++++++++++++++- crates/lance-graph-quack/src/bind.rs | 21 ++++++- .../lance-graph-quack/tests/canonical_le.rs | 6 +- 3 files changed, 83 insertions(+), 4 deletions(-) diff --git a/.claude/board/entries/2026-10-05-quack-two-world-frontend.md b/.claude/board/entries/2026-10-05-quack-two-world-frontend.md index 94e28ee62..19ae39cf4 100644 --- a/.claude/board/entries/2026-10-05-quack-two-world-frontend.md +++ b/.claude/board/entries/2026-10-05-quack-two-world-frontend.md @@ -1,4 +1,4 @@ -# 2026-10-05 — Quack two-world frontend: describe once, resolve once, execute numeric +# 2026-10-05 — Quack two-world frontend: describe once, resolve once, canonicalize once, execute numeric ## DECISION — the two worlds and the one membrane @@ -19,6 +19,62 @@ The shared invariant is the lifecycle, not a unified type. Each resolved type keeps its domain's semantics. +The membrane does two things once: it resolves meaning (names, labels, source types) and it fixes the physical representation. The common law: + +``` +external ambiguity + ↓ once +resolved semantic contract + canonical physical contract + ↓ +no reinterpretation in the population hot path +``` + +| domain | semantic contract | canonical physical contract | +|---|---|---| +| storage | `ResolvedReading` (which reading applies) | the stored row's integers are read little-endian (`NodeGuid`, `FacetCascade::from_bytes`, `Register128::words`) | +| query | `quack::Query` | none of its own: `Cmp::EqU32(v)` is a number. Byte order applies only where a strided lane reads raw bytes (`EqU32Strided`, `NeU32Strided`, `MaskedStridedGroupSum`) | +| schema / DTO | `ResolvedTable` | the fixed-width row ABI is little-endian; `CREATE TABLE … WIDTH` names no endianness | + +The lifecycle is shared; the types are not. Nothing is routed through `ResolvedReading`. + +## DECISION — the canonical byte rule (substrate policy, never application vocabulary) + +- `u8` / `i8` and byte arrays: order-neutral, the declared sequence is the value. This covers `Dn128 = [u8; 16]` and the `MatchFacet(16)Strided` pattern/care bytes, so no endianness conversion is added to them. +- `u16` / `u32` / `u64` / `u128` and their signed twins, when represented as bytes: little-endian. +- A byte array's sub-field read as an integer: little-endian. +- `Register128`: four little-endian `u32` words (unchanged). +- No `Endian` field on `Cmp`, `Query` or `Program`, and no `ResolvedQuery`. Logical `LaneRef::U32` / `I32` / `U64` lanes stay numeric. + +## MEASURED — byte-boundary audit (#1328 scope) + +| site | representation | width | order | explicit? | canonical? | Quack sees | +|---|---|---|---|---|---|---| +| `ndarray::simd::eq_u32_strided_to_mask` (`EqU32Strided` / `NeU32Strided` executor) | strided bytes → `u32` | 4 | LE | `from_le_bytes` | yes | number (`Cmp`), kernel reads bytes | +| `ndarray` `masked_strided_group_sum` | strided bytes → `u64` group | 1–8 | LE | `b << 8k` assembly | yes | terminal over bytes | +| `mask-risc` `reference.rs` `strided_u32_at` | strided bytes → `u32` | 4 | LE | `from_le_bytes` | yes | number | +| `mask-risc` `reference.rs` strided group sum | bytes → `u64` | 1–8 | LE | `b << 8k` | yes | — | +| `ternary_match_strided(16)_to_mask` | bytes | 12 / 16 | n/a | byte compare | neutral | byte arrays | +| `quack` `aperture_facet_strided` | facet bytes → classid `u32` | 4 | LE | `from_le_bytes` | yes | number | +| `contract` `Register128::{words, from_words}` | 16 bytes ↔ 4×`u32` | 4 | LE | `from/to_le_bytes` | yes | — | +| `contract` `NodeGuid` accessors / constructors | 16 bytes ↔ classid, tiers | 2 / 3 / 4 | LE | `from/to_le_bytes` | yes | — | +| `contract` `NodeRow` ↔ `&[u8]` (`as_le_bytes`, `node_rows_from_le_bytes`) | pointer cast | 512 | n/a | all fields `[u8; N]` | neutral | — | +| `contract` `FacetCascade::{as_bytes, ref_from_bytes}` | pointer reinterpret of a native `u32` field | 16 | host | reinterpret | **LE only by compile-time guard** | — | +| `contract` `mul.rs` `transmute` | `u8` discriminant → enum | 1 | n/a | — | neutral | — | +| `quack` `LaneRef::U32` / `I32` / `U64`, dir-sim, report, sap | numeric `Vec` lanes | — | — | no byte decode | numeric | number | + +No production ABI is intentionally native-endian. The one reinterpret, `FacetCascade`'s compute lens, is not a stored representation (`NodeRow::edges` is `[u8; 16]`). It is fenced by `const _: () = assert!(cfg!(target_endian = "little"))`, so a big-endian build fails to compile instead of reading a different value. It is reported here, not changed. + +`quack/tests/canonical_le.rs` pins the rule: +- `EqU32Strided(0x12345678)` matches exactly the records holding `[0x78, 0x56, 0x34, 0x12]`, never the byte-swapped spelling, for strides 4 and 16 across the group path and the tail. +- The executor and the reference interpreter must agree on it. +- `Register128::from_words` writes the bytes the kernel reads back. + +The fixtures are literal byte vectors, so the test does not pass merely on a little-endian host. Disable runs, each red then restored green: +- the ndarray kernel switched to `from_be_bytes` fails both tests; +- the reference reader switched to `from_be_bytes` fails both, with an executor/reference count mismatch. + +The second disable first came back GREEN: equal LE and BE row counts let the count comparison tie. The fixture is now asymmetric. + ## MEASURED — the proofs (tests) - `quack/src/bind.rs` tests: @@ -27,7 +83,7 @@ The shared invariant is the lifecycle, not a unified type. Each resolved type ke - Executing twice makes 0 more binder calls; the live plane excludes the dead row. - A typed descriptor (`FieldRef`) binds to the same query. - Bind errors are reported in the developer's vocabulary and mint nothing. -- `quack/tests/string_fence.rs`: no text tokens in `lib.rs` outside tests (more than 1,000 lines scanned); `bind.rs` is the only other module. +- `quack/tests/string_fence.rs` (the representation fence's text half): no text tokens in `lib.rs` outside tests (more than 1,000 lines scanned); `bind.rs` is the only other module. - `report/tests/quack_bind.rs`: - Report's `Catalog` + `CamLabels` acting as a `Binder` cost one CAM lookup at bind. - After a label rename, the new label binds to the *identical* query and the old label is `UnknownValue`. The ordinal identity survives. diff --git a/crates/lance-graph-quack/src/bind.rs b/crates/lance-graph-quack/src/bind.rs index fa19bafb1..a05f89580 100644 --- a/crates/lance-graph-quack/src/bind.rs +++ b/crates/lance-graph-quack/src/bind.rs @@ -12,7 +12,7 @@ //! Program → mask-risc → ndarray //! ``` //! -//! **Describe once, resolve once, execute numeric.** [`Query`] is already +//! **Describe once, resolve once, canonicalize once, execute numeric.** [`Query`] is already //! the resolved form: every type in it is fixed-width. So there is no //! separate "resolved query"; binding produces a `Query`, and a `Query` can //! be lowered and executed any number of times without touching a name, a @@ -28,6 +28,25 @@ //! `EqU32`; their meaning, and what survives a rename or a re-observation, //! stays with the binder that issued them. //! +//! **Canonicalize once.** The membrane also fixes the physical +//! representation. A `Cmp::EqU32(v)` is a semantic number, not bytes, and +//! carries no byte order; `U32`/`I32`/`U64` lanes hand already-bound numbers +//! to the executor. Byte order exists only where a fixed-width integer is +//! read from or written to raw bytes, and there it is little-endian: +//! +//! - `u8`/`i8` and byte arrays: order-neutral, the sequence is the value +//! (`Dn128 = [u8; 16]`, `MatchFacet16Strided` pattern/care bytes); +//! - `u16`/`u32`/`u64`/`u128` and signed twins: little-endian; +//! - a byte array's sub-field read as an integer: little-endian; +//! - `Register128`: four little-endian `u32` words. +//! +//! `0x1234_5678` therefore lives on the substrate as `[0x78, 0x56, 0x34, +//! 0x12]` (`tests/canonical_le.rs`, through `EqU32Strided`). This is +//! substrate policy, not application vocabulary: no query, table +//! declaration or `Cmp` names an endianness. Population execution sees no +//! names, no strings, no catalog or label lookup and no ambiguous byte +//! order. +//! //! The same lifecycle as `lance_graph_contract::hotplug`'s //! `SlabDeclaration → resolve_for_context → ResolvedReading`: an external //! description is resolved at one boundary into a stable numeric contract, diff --git a/crates/lance-graph-quack/tests/canonical_le.rs b/crates/lance-graph-quack/tests/canonical_le.rs index d1063fa6d..c43872e73 100644 --- a/crates/lance-graph-quack/tests/canonical_le.rs +++ b/crates/lance-graph-quack/tests/canonical_le.rs @@ -94,7 +94,11 @@ fn rows(bytes: &[u8], stride: usize, cmp: Cmp) -> Vec { fn eq_u32_strided_reads_canonical_le_bytes() { for stride in [4, 16] { let b = fixture(stride); - assert_eq!(rows(&b, stride, Cmp::EqU32Strided(V)), vec![0, 17], "stride {stride}"); + assert_eq!( + rows(&b, stride, Cmp::EqU32Strided(V)), + vec![0, 17], + "stride {stride}" + ); // Can-fire: a big-endian reader would match row 1 instead. assert_eq!( rows(&b, stride, Cmp::EqU32Strided(V.swap_bytes())),