diff --git a/.claude/board/LATEST_STATE.md b/.claude/board/LATEST_STATE.md index 26c37bf78..03585eaf4 100644 --- a/.claude/board/LATEST_STATE.md +++ b/.claude/board/LATEST_STATE.md @@ -657,3 +657,18 @@ PR sequence: #360 → #361 → post-#360 substrate-sweep (this PR). - **`canonical_node::ValueSchema` + `ValueTenant` + `VALUE_TENANTS`** (NEW; re-exported from `lib.rs`). The **value-side analog of `EdgeCodecFlavor`**: per-class presets for which tenants the 480-byte `NodeRow::value` slab materialises. `ValueTenant` = 9 stable append-only positions (discriminant == `FieldMask` bit == `VALUE_TENANTS` index): Meta(`MetaWord`) · Qualia(`QualiaI4_16D`) · MaterializedEdges(4×`CausalEdge64`) · Fingerprint(32 B) · **HelixResidue(48 B)** · **TurbovecResidue(`Pq32x4` 16 B)** · Energy(f32) · Plasticity(u32) · EntityType(u16). `VALUE_TENANTS` = the stable row-relative byte carve `[32,186)` (reserve-don't-reclaim, contiguous, compile-time asserted ≤ 480). Presets: `Bootstrap` (EMPTY, zero-fallback **default**) · `Cognitive` (58 B: hot SoA columns, no codec residues) · `Compressed` (98 B: codec stack + fingerprint, no hot lifecycle) · `Full` (154 B: all 9 tenants). Built on existing `class_view::FieldMask` (presence) + `soa_envelope::ColumnDescriptor` — **no new presence type** (per "refactor into what exists"). `is_layout_preserving()` always `true` (carves WITHIN the reserved slab; `NODE_ROW_STRIDE=512` untouched → no `ENVELOPE_LAYOUT_VERSION` bump). Selection surface: defaulted `ClassView::value_schema(&self, ClassId) -> ValueSchema` (default `Bootstrap`, non-breaking — mirrors `edge_codec_flavor`). **Closes the SoA-extension dilution gap**: the formerly-comment-only helix-48 is now a first-class tenant alongside turbovec-`Pq32x4` + signed-`CoarseResidue`. +6 tests + 3 compile-time canon asserts; contract lib 611 green; clippy `-D warnings` + fmt clean. - **Encode/measure kernels live in `ndarray` (the hardware layer), not the contract:** `ndarray::hpc::edge_codec` (Codebook k-means, `CoarseResidueCodec`, `ProductQuantizer`, `reconstruct_coarse`) + `ndarray::hpc::reliability` (Pearson r, Spearman ρ, Cronbach α, ICC(2,1), `FidelityReport`). Harness `examples/edge_codec_compare` measures all flavors × {blob, continuous} regimes. **Measured:** CoarseResidue dominates agreement (ICC 0.97–0.99, ρ 0.98, α 0.99); Pq32x4 keeps rank (ρ 0.60–0.67) but not absolute distance (ICC 0.11–0.29); CoarseOnly collapses on continuous (ICC 0.003); AMX `matmul_i8_to_i32` assign 100% vs scalar, 24–28 GMAC/s. ndarray commit `d3b608f`. - **Deferred (flagged):** turbovec PQ4 *throughput* path (the FastScan nibble-LUT for the Pq32x4 flavor) blocked on the **#493 P2** build break — `lance-graph-turbovec` requests the `ndarray-simd` turbovec feature that was REMOVED (turbovec commit `7fa217c`); the polyfill fns are gone. turbovec's API is end-to-end (owns encode+scan), so it is a *PQ4 flavor*, not a residue-nibble-scan primitive. Fidelity (what ICC/Pearson/α measure) is independent of the fast kernel, so this is throughput-only follow-up. + +## 2026-06-29 — Append: `facet::CascadeShape` cascade algebra (branch `claude/odoo-rs-transcode-lf8ya5`) + +(Per APPEND-ONLY rule: new top-of-inventory entry. Branch work; records the contract type so a new session does not re-derive it. Part of the OGAR transpile-substrate arc — the compiled-`ClassView`-spine work, `OGAR/docs/OGAR-TRANSPILE-SUBSTRATE.md` §1.5/§1.6.) + +### Current Contract Inventory — new entry + +- **`facet::CascadeShape` + `facet::ClassArm` + `facet::CASCADE_UNITS` + `FacetCascade::{tier_bytes, cascade_byte, cascade_group_shared}`** (NEW; `lance-graph-contract::facet`, zero-dep `const fn`). Carving = "Both — one cascade algebra"; home = "lance-graph-contract (FacetCascade/ClassView)". `CASCADE_UNITS = 12` = the facet's 6×2 tier-bytes = a 12-field class's fields — unit-agnostic. **Carvings are VIEW rotations, not function layouts** (operator correction 2026-06-29): + - `CascadeShape::{G6D2, G4D3, G3D4}` carves the 12-unit ladder as `G groups × D levels`, `G·D = 12`. `CascadeShape::ALIGNED = [G3D4, G6D2]` are the byte-aligned **defaults** (`group_of` is a pure SHIFT — `shift()` returns `Some(2)`/`Some(1)` — the canon's "tier-of-level is a shift, never a branch"). `CascadeShape::ROTATIONS = [G3D4, G4D3, G6D2]` is the full **rotation set** a ClassView may rotate through: a ClassView can **always rotate** (read the same bytes under a different carving) per class — the relief valve for **classid-stacking entropy** (rare, e.g. some Odoo classes — rotate instead of minting another classid). + - **`G4D3` is the WORST CASE, not a co-equal carving** (operator: "4 group_of is a very bad and unwanted example"): it straddles tier boundaries, `shift()` is `None`, `group_of` must DIVIDE; excluded from `ALIGNED`, kept in `ROTATIONS` only as the deliberate rare rotation. `is_byte_aligned()` is the guard (`false` for G4D3) — reject the straddle on the common path. + - **`ClassArm::{View, Functions}`** — the classid is an **additional switch** (operator: "functions should be encoded using the Classid as an additional switch (functions, view)"). Functions are NOT a facet carving — they are the DO arm (`ActionDef`/`KausalSpec` on the resolved node), reached by switching the classid to `Functions`; carvings address the THINK/`View` arm only. (OGAR THINK/DO split, `OGAR-AST-CONTRACT.md`.) `ClassArm::BOTH`, `is_functions()`. + - `FacetCascade::tier_bytes()` = the 12 cascade bytes as one coarse→fine ladder (`hi` then `lo` per tier); `cascade_byte(shape,g,l)`; `cascade_group_shared(other,shape,g)` = per-group coarse→fine LCP redout. + - **`canonical_node::GUIDS_PER_NODE = 32` + the clean/SoC-over-packed doctrine** (operator 2026-06-29): the 512-byte node = `NODE_ROW_STRIDE / 16` = **32 × 16-byte GUID slots** (`key` + `edges` = 2; value slab = 30). Doctrine: when a class needs more than fits cleanly in one slot, **Tetris each concern into its own slot (SoC)** rather than bit-pack — the 32-slot capacity is *why* the `G4D3` straddle / packing is almost never needed (it's the headroom that also lets a ClassView rotate and lets the rare classid-stacking-entropy case spread to a fresh slot instead of minting another classid). Compile-time assert (`GUIDS_PER_NODE == 32 && ·16 == NODE_ROW_STRIDE`) + test `guids_per_node_is_32_slots_clean_soc_over_packed` (asserts the 2+30 split). `facet::CascadeShape` cross-refs it from the `G4D3` worst-case doc. + This generalises the OGAR GUID `3×4`-vs-`4×3` debate from nibble-units to byte/field-units and lands on the canon's verdict (aligned 3×4 default; straddling 4×3 worst-case). **The shared substrate the three language SDKs (§1.6) all read.** +4 facet tests (`cascade_rotations_are_total_but_only_aligned_are_defaults`, `classid_switch_separates_view_from_functions`, `tier_bytes_ladder_and_per_carving_grouping`, `cascade_group_shared_is_per_group_lcp`) + canonical_node `guids_per_node_*` + 4 compile-time asserts. Lib facet 8/8 + canonical_node 43/43 green; clippy `-D warnings` + rustfmt clean (probe-workspace verified offline — the workspace ndarray git dep is 403 offline). + - **2026-06-29 correction (operator veto):** the "G4D3 = worst case to prevent" framing above is SOFTENED — **the shape is class-conditioned, not locked**. A ClassView is mapped from the class's *inherited* format and selected by `classid` (the filter); the shape follows: **Rails → `6×2`, other frameworks → `4×3`, the GUID → `3×4`** (operator: "Rails might need 6x2x8bit, others 4x3x8bit"). So `4×3` (`G4D3`) is **legitimate per-class**, not a thing to "reject" — its `group_of` divides (a per-class *cost* a class opts into), and `is_byte_aligned()`/`shift()`/`ALIGNED` now read as "distinguishes the shift fast-path from the divide shape," not "prevent." NEW `CascadeShape::from_levels(d)` — the class-conditioned `D ∈ {2,3,4}` selector (`2→G6D2`/`3→G4D3`/`4→G3D4`), inverse of `levels()`; the classid resolves `D`, never a global lock. Test renamed → `cascade_shapes_are_total_and_class_conditioned` (adds the `from_levels` round-trip). The earlier "quadruplet/4-bucket FieldMask" framing in ruff `soc` was likewise unlocked → byte-cardinality cap, class-conditioned shape (ruff #36). Facet 7/7 + canonical_node 43/43 green post-correction. diff --git a/crates/bge-m3/rust-toolchain.toml b/crates/bge-m3/rust-toolchain.toml index 76a06e6b8..c2127f491 100644 --- a/crates/bge-m3/rust-toolchain.toml +++ b/crates/bge-m3/rust-toolchain.toml @@ -1,2 +1,6 @@ [toolchain] -channel = "1.94.0" +# Aligned to the ecosystem-wide pin; matches this crate's own Cargo.toml +# rust-version = "1.95" (the channel was a 1.94.0 straggler). EXACT pin +# "1.95.0" (not floating "1.95") so this subdir reuses the installed root +# toolchain instead of syncing a separate channel — offline-safe (Codex #621). +channel = "1.95.0" diff --git a/crates/lance-graph-contract/src/canonical_node.rs b/crates/lance-graph-contract/src/canonical_node.rs index 4fb4a9440..129da4e1a 100644 --- a/crates/lance-graph-contract/src/canonical_node.rs +++ b/crates/lance-graph-contract/src/canonical_node.rs @@ -578,6 +578,19 @@ impl EdgeCodecFlavor { /// The 480-byte value is deferred — energy/meta/qualia/entity_type, materialized /// CausalEdge64, helix residue, fingerprint, class extensions all land here later, /// Lance-compressible. This is the row the MailboxSoA owns and the MailboxSoaView reads. +/// +/// **Two doctrines (operator 2026-06-29), neither a blocker:** +/// 1. **Clean ⇒ expansion is `classid`-inherited.** When a clean class's field +/// set / capacity grows, the `classid` selects the (expanded) shape — no +/// global layout change (cf. RESERVE-DON'T-RECLAIM + the class-conditioned +/// [`CascadeShape`](crate::facet::CascadeShape)). Expansion is never a blocker. +/// 2. **Bulk raw data lives out-of-line — a *separate* Lance table, not this +/// 480-byte value.** The value slab is for structured/compressible columns; a +/// raw payload that can't fit even compressed (a ~3.2 Gbp genome; the +/// FMA / BodyParts3D anatomy mesh at 4M vertices / 6M triangles) is its own +/// table, referenced by `key`/`classid` — and still not a blocker (the +/// anatomy mesh baked cleanly as a SoA release). The node stays 512 B; bulk +/// is addressed, not inlined. #[derive(Clone, Copy)] #[repr(C, align(64))] pub struct NodeRow { @@ -643,6 +656,29 @@ pub const NODE_ROW_COLUMNS: &[ColumnDescriptor] = &[ /// Row stride for [`NodeRow`] in bytes — equal to `size_of::()`. pub const NODE_ROW_STRIDE: usize = 512; +/// The node viewed as fixed-size **GUID slots**: `NODE_ROW_STRIDE / +/// size_of::()` = `512 / 16` = **32**. A 512-byte SoA row can carry up +/// to 32 GUID-sized (16-byte) entries; `key` + `edges` occupy 2, leaving the +/// 480-byte value slab = 30 slots for the class-resolved layout. +/// +/// **Doctrine — clean / SoC over packed (operator, 2026-06-29).** When a class +/// needs more structure than fits cleanly in one slot, *Tetris it across the +/// slots* — give each concern its own 16-byte slot — rather than cram two +/// *distinct concerns* into one. The 32-slot capacity is *why* that cramming is +/// almost never needed — separation-of-concerns layout is the default, packing +/// the rare last resort. (This is also the headroom that lets a ClassView +/// *rotate* and lets the rare classid-stacking-entropy case spread to a fresh +/// slot instead of minting another classid.) The per-class *shape* of one +/// facet — [`CascadeShape`](crate::facet::CascadeShape) `6×2`/`4×3`/`3×4`, +/// selected by `classid` — is a separate, class-conditioned choice (a `4×3` +/// class is clean); this doctrine is about not mixing concerns, not about shape. +pub const GUIDS_PER_NODE: usize = NODE_ROW_STRIDE / 16; + +const _: () = assert!( + GUIDS_PER_NODE == 32 && GUIDS_PER_NODE * core::mem::size_of::() == NODE_ROW_STRIDE, + "512-byte node = 32 × 16-byte GUID slots" +); + // ── Value-slab schema presets: which tenants a class materialises ───────────── /// Full-row byte offset of the value slab (key 16 + edges 16). @@ -1596,6 +1632,26 @@ mod tests { assert_eq!(NODE_ROW_STRIDE, core::mem::size_of::()); } + #[test] + fn guids_per_node_is_32_slots_clean_soc_over_packed() { + // The 512-byte node is 32 × 16-byte GUID-sized slots. + assert_eq!(GUIDS_PER_NODE, 32); + assert_eq!( + GUIDS_PER_NODE * core::mem::size_of::(), + NODE_ROW_STRIDE + ); + // key + edges occupy 2 slots; the value slab is the remaining 30 to + // Tetris a class's concerns into (SoC over packed — no straddle needed). + let key_edges_slots = + (core::mem::size_of::() + core::mem::size_of::()) / 16; + assert_eq!(key_edges_slots, 2); + assert_eq!( + GUIDS_PER_NODE - key_edges_slots, + 30, + "value slab = 30 slots" + ); + } + #[test] fn uniqueness_guard_is_noop_outside_bootstrap() { // family != 0 ⇒ no longer the bootstrap address: the guard is a no-op diff --git a/crates/lance-graph-contract/src/facet.rs b/crates/lance-graph-contract/src/facet.rs index 8d6eb4418..306208f2d 100644 --- a/crates/lance-graph-contract/src/facet.rs +++ b/crates/lance-graph-contract/src/facet.rs @@ -286,8 +286,316 @@ impl FacetCascade { } m } + + /// The 12 cascade tier-bytes as **one coarse→fine ladder** — `hi` then `lo` + /// per tier, tiers in order: + /// `[t0.hi, t0.lo, t1.hi, t1.lo, … t5.hi, t5.lo]`. Excludes the 4-byte + /// [`facet_classid`](Self::facet_classid). This is the input the + /// [`CascadeShape`] algebra re-carves; it is byte-for-byte the same 12-unit + /// ladder a 12-field class exposes, so a ClassView addresses *either* the + /// facet bytes or its own fields with the identical `(group, level)` math. + /// (Distinct from [`hi_chain`](Self::hi_chain)/[`lo_chain`](Self::lo_chain), + /// which group by *axis* across tiers; this groups by *position* in the + /// ladder — the `(1:2)`/`(1:2:3)`/`(1:2:3:4)` view.) + #[inline] + #[must_use] + pub const fn tier_bytes(self) -> [u8; CASCADE_UNITS] { + let t = &self.tiers; + [ + t[0].hi, t[0].lo, t[1].hi, t[1].lo, t[2].hi, t[2].lo, t[3].hi, t[3].lo, t[4].hi, + t[4].lo, t[5].hi, t[5].lo, + ] + } + + /// The cascade byte at `(group, level)` under `shape` — + /// `tier_bytes()[shape.index(group, level)]`. The same lookup whether the + /// ClassView reads the facet as `6×2`, `4×3`, or `3×4`; the bytes never move. + #[inline] + #[must_use] + pub const fn cascade_byte(self, shape: CascadeShape, group: u8, level: u8) -> u8 { + self.tier_bytes()[shape.index(group, level)] + } + + /// Coarse→fine shared-prefix length (`0..=D`) of one group `g` between two + /// facets under `shape` — the **per-group LCP redout**; `D` ⇒ that group's + /// whole `(1:…:D)` hierarchy agrees. The per-carving refinement of the + /// whole-facet [`shared_prefix_tiles`](Self::shared_prefix_tiles): pick the + /// carving, then read locality one group (one axis of meaning) at a time. + #[inline] + #[must_use] + pub const fn cascade_group_shared(self, other: Self, shape: CascadeShape, group: u8) -> u8 { + let (a, b) = (self.tier_bytes(), other.tier_bytes()); + let d = shape.levels(); + let base = shape.index(group, 0); + let mut n = 0u8; + while n < d && a[base + n as usize] == b[base + n as usize] { + n += 1; + } + n + } +} + +/// The number of cascade tier-bytes a [`FacetCascade`] carries (excludes the +/// 4-byte [`FacetCascade::facet_classid`]): `6 tiers × 2 bytes`. Equivalently +/// the field-count of a 12-field class — the cascade algebra is unit-agnostic, +/// so the same `G·D = CASCADE_UNITS` invariant binds bytes and fields alike. +pub const CASCADE_UNITS: usize = 12; + +/// **One cascade algebra; carvings are VIEW rotations, not function layouts.** +/// The 12 cascade units (the facet's [`tier_bytes`](FacetCascade::tier_bytes), +/// or a 12-field class's fields) are read as `G groups × D levels` with +/// `G·D = CASCADE_UNITS` *always*. The units never move — a `ClassView` picks +/// the carving (its **rotation**) and the same index math (`i = g·D + l`) +/// addresses any of them. A ClassView can **always rotate** — read the SAME +/// bytes under a different carving — per class. +/// +/// **This addresses the VIEW only.** Functions (behaviour) are NOT a facet +/// carving — they are reached by switching the classid to the +/// [`ClassArm::Functions`] arm (the OGAR THINK/DO split). Never slice the +/// tier-bytes to reach a function. +/// +/// **The shape is class-conditioned, not locked.** A `ClassView` is *mapped +/// from the class's inherited format* and selected by `classid` (the filter), so +/// a framework picks the carving its schema implies — **Rails → `6×2`, other +/// frameworks → `4×3`, the canonical GUID → `3×4`** (all `G·D = 12`, 8-bit +/// tiers; the per-group depth `D ∈ {2,3,4}` is the per-class knob, see +/// [`from_levels`](Self::from_levels)). Each is legitimate for the class that +/// needs it; none is restated or locked here. +/// +/// | shape | G×D | notation | framework | `group_of` | +/// |---|---|---|---|---| +/// | [`G6D2`](Self::G6D2) | 6 × 2 | `6×(1:2)` | Rails (native `hi:lo`) | `i >> 1` (shift) | +/// | [`G4D3`](Self::G4D3) | 4 × 3 | `4×(1:2:3)` | other frameworks | `i / 3` (divide) | +/// | [`G3D4`](Self::G3D4) | 3 × 4 | `3×(1:2:3:4)` | canonical GUID (tier-pair super-groups) | `i >> 2` (shift) | +/// +/// `G6D2`/`G3D4` carve on tier boundaries so `group_of` is a pure shift +/// ([`ALIGNED`](Self::ALIGNED) — the canon's "tier-of-level is a shift, never a +/// branch"); `G4D3` straddles, so its `group_of` divides — a **per-class cost a +/// class opts into when its schema needs `4×3`**, not a prohibition. This is the +/// OGAR GUID `3×4`-vs-`4×3` debate generalized from nibble-units to byte/field- +/// units: `3×4` is the GUID default, the others are class-conditioned. With 12 +/// fields a class may also map a sub-range as a hierarchy and stack **nested** +/// ClassViews into constructors before materializing the `32×GUID` SoA — see +/// `docs/OGAR-TRANSPILE-SUBSTRATE.md` §1.5. +/// +/// **Clean / SoC over packed.** What stays the last resort is cramming two +/// *distinct concerns* into one facet (independent of shape): a node has +/// [`GUIDS_PER_NODE`](crate::canonical_node::GUIDS_PER_NODE) = 32 sixteen-byte +/// slots, so the cheap move is to *Tetris* each concern into its own slot +/// (separation-of-concerns) rather than bit-pack. (`G4D3`'s divide is a per-class +/// *shape* cost, separate from this — a class whose schema needs `4×3` is clean.) +/// +/// **Encoding-lane scope.** These byte-shapes (8-bit tiers) are the **transpile / +/// ClassView field-grouping** lane. A *separate* `G2×48bit` lane reads the same 12 +/// tier-bytes as the two 48-bit chains ([`hi_chain`](FacetCascade::hi_chain) / +/// [`lo_chain`](FacetCascade::lo_chain), cf. the CAM-PQ `6×256` path code) — for +/// **helix** (location) and **CAM-PQ** (centroid) encoding. That lane is **not +/// required by transpile** and is never dragged into ClassView shape selection. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum CascadeShape { + /// 6 groups × 2 levels — native `(hi:lo)` per tier (`6×(1:2)`). The Rails + /// shape; `group_of` is the shift `i >> 1`. + G6D2, + /// 4 groups × 3 levels (`4×(1:2:3)`) — the **class-conditioned** shape for + /// frameworks whose inherited schema implies `4×3` ("other frameworks"; not + /// the GUID default `3×4`, not Rails' `6×2`). It straddles tier boundaries so + /// `group_of` divides (`i / 3`) — the per-class *cost* the class opts into, + /// not a prohibition. [`is_byte_aligned`](Self::is_byte_aligned) is `false` + /// (it distinguishes the divide shape from the shift shapes — not a "reject"). + G4D3, + /// 3 groups × 4 levels — tier-pair super-groups (`3×(1:2:3:4)`). The + /// canonical GUID shape; `group_of` is the shift `i >> 2`. + G3D4, } +impl CascadeShape { + /// Every shape, group-count ascending — the full set a class may be + /// carved/rotated through (`G·D = 12` each). Which one a class uses is + /// **class-conditioned**: `classid` selects it from the inherited schema + /// (Rails `6×2`, other frameworks `4×3`, the GUID `3×4`) — see + /// [`from_levels`](Self::from_levels). A ClassView can also always rotate + /// (read the same bytes under a different grouping) per class. + pub const ROTATIONS: [CascadeShape; 3] = + [CascadeShape::G3D4, CascadeShape::G4D3, CascadeShape::G6D2]; + + /// The byte-**aligned** shapes — `group_of` is a pure shift + /// ([`shift`](Self::shift) is `Some`), the canon's "tier-of-level is a shift, + /// never a branch". `G6D2` (Rails) and `G3D4` (GUID). [`G4D3`](Self::G4D3) + /// (other frameworks) is excluded because its `group_of` *divides*, not + /// because it is forbidden — it is a legitimate class-conditioned shape. + pub const ALIGNED: [CascadeShape; 2] = [CascadeShape::G3D4, CascadeShape::G6D2]; + + /// Select the shape by its per-group depth `D` — the **class-conditioned + /// knob** (operator 2026-06-29): a framework/class picks `D ∈ {2,3,4}` from + /// its inherited format and the shape follows — `2 → G6D2` (Rails), + /// `3 → G4D3` (other frameworks), `4 → G3D4` (the GUID default). `None` for + /// any other `D` (only 2/3/4 divide the 12-unit ladder). This is the + /// inverse of [`levels`](Self::levels); the classid resolves `D`, not a lock. + #[inline] + #[must_use] + pub const fn from_levels(d: u8) -> Option { + match d { + 2 => Some(CascadeShape::G6D2), + 3 => Some(CascadeShape::G4D3), + 4 => Some(CascadeShape::G3D4), + _ => None, + } + } + + /// `G` — number of groups (axes of meaning). `groups() · levels() == CASCADE_UNITS`. + #[inline] + #[must_use] + pub const fn groups(self) -> u8 { + match self { + CascadeShape::G6D2 => 6, + CascadeShape::G4D3 => 4, + CascadeShape::G3D4 => 3, + } + } + + /// `D` — levels per group: the depth of the `(1:2:…:D)` coarse→fine ladder. + /// `groups() · levels() == CASCADE_UNITS`. + #[inline] + #[must_use] + pub const fn levels(self) -> u8 { + match self { + CascadeShape::G6D2 => 2, + CascadeShape::G4D3 => 3, + CascadeShape::G3D4 => 4, + } + } + + /// Linear unit index of `(group, level)`: `group · D + level` — groups laid + /// out in order, coarse→fine within each. The single shared addressing rule + /// for facet bytes *and* class fields. + /// + /// **Precondition:** `group < groups()` and `level < levels()` (the result is + /// then in `0..CASCADE_UNITS`). The multiply/add is done in `usize` (widen + /// first), so an out-of-range argument cannot wrap a `u8` — and a + /// `debug_assert` catches the misuse in debug builds. + #[inline] + #[must_use] + pub const fn index(self, group: u8, level: u8) -> usize { + debug_assert!( + group < self.groups() && level < self.levels(), + "CascadeShape::index: (group, level) out of range for this shape" + ); + group as usize * self.levels() as usize + level as usize + } + + /// Inverse of [`index`](Self::index): which group linear unit `i` belongs to + /// (`i / D`). For the [`ALIGNED`](Self::ALIGNED) shapes this is a pure shift + /// (see [`shift`](Self::shift)); for [`G4D3`](Self::G4D3) it is a real divide + /// — the per-class cost of the `4×3` shape. Dispatch on [`shift`](Self::shift) + /// when you want the shift fast-path for the aligned shapes. + /// + /// **Precondition:** `unit < CASCADE_UNITS` — the inverse identity + /// `index(group_of(u), level_of(u)) == u` holds only on the 12-unit ladder + /// (`debug_assert`-checked). + #[inline] + #[must_use] + pub const fn group_of(self, unit: usize) -> u8 { + debug_assert!( + unit < CASCADE_UNITS, + "CascadeShape::group_of: unit out of range" + ); + (unit / self.levels() as usize) as u8 + } + + /// Inverse of [`index`](Self::index): the within-group level of unit `i` + /// (`i % D`). **Precondition:** `unit < CASCADE_UNITS` (`debug_assert`-checked; + /// the inverse identity holds only on the 12-unit ladder). + #[inline] + #[must_use] + pub const fn level_of(self, unit: usize) -> u8 { + debug_assert!( + unit < CASCADE_UNITS, + "CascadeShape::level_of: unit out of range" + ); + (unit % self.levels() as usize) as u8 + } + + /// The bit-shift that implements [`group_of`](Self::group_of) for a + /// byte-aligned shape — `Some(1)` for [`G6D2`](Self::G6D2) (`i >> 1`), + /// `Some(2)` for [`G3D4`](Self::G3D4) (`i >> 2`) — or `None` for + /// [`G4D3`](Self::G4D3), whose `group_of` divides. `Some` carvings satisfy the + /// canon's "tier-of-level is a shift, never a branch"; `None` marks the + /// `4×3` shape's per-class divide cost (a property, not a verdict). + #[inline] + #[must_use] + pub const fn shift(self) -> Option { + match self { + CascadeShape::G6D2 => Some(1), + CascadeShape::G3D4 => Some(2), + CascadeShape::G4D3 => None, + } + } + + /// A shape is byte-aligned iff its group boundaries fall on tier boundaries — + /// [`group_of`](Self::group_of) is a shift, not a divide. True for the + /// [`ALIGNED`](Self::ALIGNED) shapes (`6×2`/`3×4`); **false** for + /// [`G4D3`](Self::G4D3) (`4×3`), whose `group_of` divides. This distinguishes + /// the shift fast-path from the divide shape — it is not a "prevent" gate; + /// `4×3` is a legitimate class-conditioned shape. Functions are never reached + /// by any carving — see [`ClassArm`]. + #[inline] + #[must_use] + pub const fn is_byte_aligned(self) -> bool { + self.shift().is_some() + } +} + +/// The classid is an **additional switch**, not only a data address: resolving a +/// classid selects one of two ARMS — the same THINK/DO split the OGAR AST draws +/// (`docs/OGAR-AST-CONTRACT.md`). +/// +/// - [`View`](Self::View) — the THINK arm: the class's **data layout**, read by +/// a `ClassView` over the facet bytes carved/rotated per [`CascadeShape`] +/// (byte-aligned on the common path). +/// - [`Functions`](Self::Functions) — the DO arm: the class's **behaviour** +/// (`ActionDef` / `KausalSpec`) on the Core node the classid resolves to. +/// +/// **Functions are NOT a facet carving.** Behaviour is reached by switching the +/// classid to the `Functions` arm — never by slicing the facet's tier-bytes (a +/// straddling carve like the worst-case [`CascadeShape::G4D3`] is exactly what +/// that mistake looks like). The carving addresses the VIEW; this switch reaches +/// the functions. (Canon: neither u16 half of the classid carries behaviour — +/// behaviour is a property of the resolved node, *selected* by this arm; see +/// `OGAR/docs/OGAR-CONSUMER-BEST-PRACTICES.md`.) +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ClassArm { + /// THINK arm — data layout (a `ClassView` over the carved/rotated facet bytes). + View, + /// DO arm — behaviour (`ActionDef` / `KausalSpec` on the resolved Core node). + Functions, +} + +impl ClassArm { + /// The two arms the classid switch selects, `View` first (the + /// prerender-from-key default; canon "THE GUID IS THE KEY OF KEY-VALUE"). + pub const BOTH: [ClassArm; 2] = [ClassArm::View, ClassArm::Functions]; + + /// Whether this arm reaches behaviour (the DO arm). `false` for `View`. + #[inline] + #[must_use] + pub const fn is_functions(self) -> bool { + matches!(self, ClassArm::Functions) + } +} + +const _: () = assert!( + CascadeShape::G6D2.groups() as usize * CascadeShape::G6D2.levels() as usize == CASCADE_UNITS, + "6×2 = 12" +); +const _: () = assert!( + CascadeShape::G4D3.groups() as usize * CascadeShape::G4D3.levels() as usize == CASCADE_UNITS, + "4×3 = 12" +); +const _: () = assert!( + CascadeShape::G3D4.groups() as usize * CascadeShape::G3D4.levels() as usize == CASCADE_UNITS, + "3×4 = 12" +); + #[cfg(test)] mod tests { use super::*; @@ -366,6 +674,150 @@ mod tests { assert_eq!(h.row_match_mask(f), 0b1110); } + #[test] + fn cascade_shapes_are_total_and_class_conditioned() { + // Every shape covers all 12 units; index/group_of/level_of are inverses. + assert_eq!(CascadeShape::ROTATIONS.len(), 3); + for s in CascadeShape::ROTATIONS { + assert_eq!(s.groups() as usize * s.levels() as usize, CASCADE_UNITS); + for unit in 0..CASCADE_UNITS { + let (g, l) = (s.group_of(unit), s.level_of(unit)); + assert!(g < s.groups() && l < s.levels()); + assert_eq!(s.index(g, l), unit, "{s:?}: index∘(group,level) = id"); + } + } + + // The shape is CLASS-CONDITIONED, selected by depth D (the classid knob): + // 2 → G6D2 (Rails), 3 → G4D3 (other frameworks), 4 → G3D4 (GUID). Round- + // trips with levels(); only 2/3/4 divide 12. + assert_eq!(CascadeShape::from_levels(2), Some(CascadeShape::G6D2)); + assert_eq!(CascadeShape::from_levels(3), Some(CascadeShape::G4D3)); + assert_eq!(CascadeShape::from_levels(4), Some(CascadeShape::G3D4)); + assert_eq!(CascadeShape::from_levels(1), None); + assert_eq!(CascadeShape::from_levels(5), None); + for s in CascadeShape::ROTATIONS { + assert_eq!(CascadeShape::from_levels(s.levels()), Some(s)); + } + + // The aligned shapes (Rails 6×2, GUID 3×4) have a shift group_of; G4D3 + // (other frameworks, 4×3) divides — a per-class cost, NOT a prohibition. + assert_eq!( + CascadeShape::ALIGNED, + [CascadeShape::G3D4, CascadeShape::G6D2] + ); + for s in CascadeShape::ALIGNED { + let sh = s.shift().expect("aligned shape has a shift"); + for unit in 0..CASCADE_UNITS { + assert_eq!( + s.group_of(unit) as usize, + unit >> sh, + "{s:?} group_of is a shift" + ); + } + } + assert_eq!(CascadeShape::G6D2.shift(), Some(1)); + assert_eq!(CascadeShape::G3D4.shift(), Some(2)); + // G4D3 is legitimate but not a shift shape: divide group_of, not in ALIGNED. + assert!(!CascadeShape::G4D3.is_byte_aligned()); + assert_eq!(CascadeShape::G4D3.shift(), None); + assert!(!CascadeShape::ALIGNED.contains(&CascadeShape::G4D3)); + assert!(CascadeShape::ROTATIONS.contains(&CascadeShape::G4D3)); + assert_eq!(CascadeShape::G4D3.group_of(2) as usize, 2 / 3); // a divide, its per-class cost + } + + #[test] + fn classid_switch_separates_view_from_functions() { + // The classid is an additional (functions, view) switch; functions are + // NOT a facet carving — they are the DO arm, reached by this switch. + assert_eq!(CascadeShape::ROTATIONS.len(), 3); // carvings address the VIEW only + assert_eq!(ClassArm::BOTH, [ClassArm::View, ClassArm::Functions]); + assert_ne!(ClassArm::View, ClassArm::Functions); + assert!(!ClassArm::View.is_functions(), "View is the THINK/data arm"); + assert!( + ClassArm::Functions.is_functions(), + "Functions is the DO/behaviour arm" + ); + } + + #[test] + fn tier_bytes_ladder_and_per_carving_grouping() { + let f = FacetCascade::from_bytes(&sample()); + + // The 12-unit ladder: hi then lo per tier, coarse→fine. (hi = sample odd + // bytes 0xAB..0x56; lo = even bytes 0x01..0x06.) + assert_eq!( + f.tier_bytes(), + [0xAB, 0x01, 0xCD, 0x02, 0xEF, 0x03, 0x12, 0x04, 0x34, 0x05, 0x56, 0x06] + ); + + // 6×2: group g == tier g's (hi, lo) — the native pairing. + for g in 0..6u8 { + assert_eq!( + f.cascade_byte(CascadeShape::G6D2, g, 0), + f.tiers[g as usize].hi + ); + assert_eq!( + f.cascade_byte(CascadeShape::G6D2, g, 1), + f.tiers[g as usize].lo + ); + } + // 3×4: group 0 spans tiers 0–1 (4 bytes), byte-aligned super-group. + assert_eq!( + [ + f.cascade_byte(CascadeShape::G3D4, 0, 0), + f.cascade_byte(CascadeShape::G3D4, 0, 1), + f.cascade_byte(CascadeShape::G3D4, 0, 2), + f.cascade_byte(CascadeShape::G3D4, 0, 3), + ], + [0xAB, 0x01, 0xCD, 0x02] + ); + // 4×3 (the worst-case rare rotation): group 0 straddles tier 0 fully + + // tier 1's hi (the 1.5-tier cost) — shown only to demonstrate why it is + // NOT a default, not to endorse it. + assert!(!CascadeShape::G4D3.is_byte_aligned()); + assert_eq!( + [ + f.cascade_byte(CascadeShape::G4D3, 0, 0), + f.cascade_byte(CascadeShape::G4D3, 0, 1), + f.cascade_byte(CascadeShape::G4D3, 0, 2), + ], + [0xAB, 0x01, 0xCD] + ); + } + + #[test] + fn cascade_group_shared_is_per_group_lcp() { + let f = FacetCascade::from_bytes(&sample()); + + // identical ⇒ every group's whole ladder agrees (== D), for every rotation. + for s in CascadeShape::ROTATIONS { + for g in 0..s.groups() { + assert_eq!(f.cascade_group_shared(f, s, g), s.levels()); + } + } + + // Perturb tier1's hi byte (ladder unit 2). Under 6×2 that is group 1, + // level 0 ⇒ group 1 diverges immediately (shared 0); group 0 untouched. + let mut b = sample(); + b[7] = 0x99; // tier1.hi + let g = FacetCascade::from_bytes(&b); + assert_eq!( + f.cascade_group_shared(g, CascadeShape::G6D2, 0), + 2, + "tier0 intact" + ); + assert_eq!( + f.cascade_group_shared(g, CascadeShape::G6D2, 1), + 0, + "tier1.hi differs first" + ); + + // Under 3×4 unit 2 is group 0, level 2 ⇒ group 0 shares its first 2 + // levels then breaks; group 1 (tiers 2–3) fully intact. + assert_eq!(f.cascade_group_shared(g, CascadeShape::G3D4, 0), 2); + assert_eq!(f.cascade_group_shared(g, CascadeShape::G3D4, 1), 4); + } + #[test] fn reinterpret_is_a_no_op() { // align(16) ⇒ the facet's own bytes are 16-aligned, so the zero-copy borrow diff --git a/crates/reader-lm/rust-toolchain.toml b/crates/reader-lm/rust-toolchain.toml index 76a06e6b8..c2127f491 100644 --- a/crates/reader-lm/rust-toolchain.toml +++ b/crates/reader-lm/rust-toolchain.toml @@ -1,2 +1,6 @@ [toolchain] -channel = "1.94.0" +# Aligned to the ecosystem-wide pin; matches this crate's own Cargo.toml +# rust-version = "1.95" (the channel was a 1.94.0 straggler). EXACT pin +# "1.95.0" (not floating "1.95") so this subdir reuses the installed root +# toolchain instead of syncing a separate channel — offline-safe (Codex #621). +channel = "1.95.0" diff --git a/python/rust-toolchain.toml b/python/rust-toolchain.toml index 76a06e6b8..5470ef311 100644 --- a/python/rust-toolchain.toml +++ b/python/rust-toolchain.toml @@ -1,2 +1,6 @@ [toolchain] -channel = "1.94.0" +# Aligned to the AdaWorldAPI ecosystem-wide pin (root lance-graph +# rust-toolchain.toml). Was a 1.94.0 straggler. EXACT pin "1.95.0" (not +# floating "1.95") so this subdir reuses the installed root toolchain +# instead of syncing a separate channel — offline-safe (Codex #621). +channel = "1.95.0"