diff --git a/.claude/board/LATEST_STATE.md b/.claude/board/LATEST_STATE.md index 3e2f5745..00b9c104 100644 --- a/.claude/board/LATEST_STATE.md +++ b/.claude/board/LATEST_STATE.md @@ -10,6 +10,18 @@ --- +> **2026-06-15 — REVERTED (operator)** — the tesseract-rs `soa` wiring below was **deleted** (branch reset to master `420de08`). Operator: *"we don't want to use original Tesseract, we want to transcode it into Rust — delete everything you copied from original Tesseract into tesseract-rs."* Wrapping the original Tesseract C engine + parsing its TSV is the wrong direction; the real goal is a **pure-Rust OCR**. The contract-side transcode (`LayoutBlock::to_node_row`) + keystone STAY — they are OCR-engine-agnostic (a pure-Rust OCR feeds the same `LayoutBlock` → `NodeRow`); only the original-Tesseract coupling was removed. The strike-through entry below is retained per APPEND-ONLY. +> +> ~~**2026-06-15 — cross-repo landed** — **tesseract-rs fork wired to the transcode.**~~ *(REVERTED — see above)* `AdaWorldAPI/tesseract-rs` branch `claude/wonderful-hawking-lodtql` commit `1687c718`: opt-in `soa` feature (default-OFF — standalone OCR build untouched) + `src/soa.rs::tsv_to_nodes(tsv, classid, min_conf) -> Vec` parsing tesseract `get_tsv_text` word rows → `contract::ocr::LayoutBlock` → `to_node_row`. Contract dep is a path dep mirroring smb-office-rs (sibling checkout). **Edition-2015 compatible** (the fork has no `edition` field → 2015: root `extern crate` + submodule root-relative `use` + explicit `TryInto` — all caught + fixed by verifying in a 2015 scratch crate against the real contract before pushing, 2 tests green). Pushed via `GH_TOKEN`+pygithub (out-of-MCP-scope fork). Could NOT compile the full crate here (no tesseract C-lib) — the transcode LOGIC is what's verified; the fork's own CI needs a co-located lance-graph for `--features soa`. +> +> **2026-06-15 — branch work (post-#496)** — **tesseract OCR → NodeRow transcode POC (keystone payoff).** `lance_graph_contract::ocr::LayoutBlock::to_node_row(classid, identity) -> NodeRow` — the reference transcode any `OcrProvider` (tesseract-rs + others) reuses, the keystone end-to-end: `classid → classid_read_mode → ValueSchema` gates WHICH tenants land; `BlockKind::entity_type() -> u16` → `ValueTenant::EntityType`, `confidence: f32` → `ValueTenant::Energy`, each written at its canon slab offset via the new `ValueTenant::{value_offset(), byte_len()}` (derived accessors over the locked carve — not new properties). **`text`/`bbox` are NOT bundled** (`I-VSA-IDENTITIES`: node = identity + typed scalars; the string + pixel geometry live in an external content store keyed by `identity`). Schema-gated (`schema.has(t)` before each write) so a Bootstrap-resolving class writes an empty slab; transcoded rows ride the `SoaEnvelope` zero-copy (verified). §0 anti-invention: reuses the existing EntityType/Energy tenants, no "ocr_kind" field. +4 tests; **623 contract lib green; clippy `-D warnings` + fmt clean.** Lives in the contract (next to the `ocr` types it uses, zero-dep, testable here — no OCR C-lib, no fork); tesseract-rs just adds the contract dep + calls it (integration step). Branch, not yet a PR. +> +> **2026-06-15 — branch work (post-#496)** — **keystone (contract half): GUID decode + classid→read-mode `LazyLock`.** `lance_graph_contract::canonical_node::{GuidParts, ReadMode, classid_read_mode}` + `NodeGuid::{heel(), hip(), twig(), decode() -> GuidParts, read_mode() -> ReadMode}` (re-exported from `lib.rs`). **The "read the GUID as a GUID" surface** the operator spec'd: `decode()` returns all six canon groups (classid + HHT·HEEL/HIP/TWIG + family·"Leaf" + identity) in one read; `ReadMode` bundles the two *already-existing* read-mode axes (`ValueSchema` + `EdgeCodecFlavor`) — **NOT a new node property, NOT a SoA column** (§0 anti-invention; it's the resolution lens, nothing stored on the row); `classid_read_mode(u32)` is the **single source both the consumer and OGAR inherit** — a `LazyLock>` builtin registry (same immutable-after-init pattern `lance-graph-ontology` uses for its seed namespace registry), zero-fallback to `ReadMode::DEFAULT` for any unconfigured classid. `ReadMode::DEFAULT = {Full, CoarseOnly}` mirrors the `ClassView::value_schema` POC default (paired revert; `read_mode_default_is_full_poc` guards it). `Display` deduped onto the new HHT accessors. +6 tests (decode round-trip, HHT↔Display, read-mode single-source, carrier delegation, full-slab connect); **619 contract lib green; clippy `-D warnings` + fmt clean.** Delivers the contract-side half of the #496 keystone; the ontology-side `NiblePath::from_guid_prefix` (20→≤16-nibble subset) meets it at the classid (follow-up). Branch, not yet a PR. +> +> **2026-06-15 — branch work (post-#496)** — **helix `Signed360` codec + `HelixResidue` right-sized 48 B → 6 B.** Operator caught a slab over-allocation: `HelixResidue` reserved **48 *bytes*** but the intent was a 24-bit equal-area hemisphere **doubled = 48 *bit* = 6 B** (a bits→bytes slip; 42 dead bytes), and the tenant used **none** of the `helix` crate (zero-dep contract — only a doc string). Fixed: **(1) `helix::Signed360`** — the signed full-sphere codec: `HemispherePoint::signed_lift(n,N,sign)` (`y = sign·√(1−u)` → full sphere, `r²+y²=1`), `Sign{Pos,Neg}`, and `Signed360 {rim: ResidueEdge, polar: signed-lift centred@128 (sign recoverable), azimuth: u16 over 360°}` + `ResidueEncoder::encode_signed`. +9 tests; **helix 72 lib + 7 doctests green; lib clippy `-D warnings` + fmt clean.** **(2) contract** `HelixResidue.elems_per_row` 48→6, downstream tenants shifted (Turbovec 118 / Energy 134 / Plasticity 138 / EntityType 142), budgets re-locked (**Full 154→112, Compressed 98→56**); **613 contract green.** **NO `HelixFlavour` enum** — one canonical encoding, one tenant size (a fixed-offset SoA can't vary width per-class; Hemisphere = degenerate `sign=+`); the contract stays zero-dep, the producer writes `Signed360::to_bytes` into the 6 B. Cheap NOW (POC FULL default, no persisted real instances); after instances persist it's a version bump. Branch, not yet a PR. New: `TD-HELIX-PROBE-CLIPPY` (pre-existing `probe_mantissa_fill` clippy/fmt drift, NOT introduced here — helix is excluded so CI-invisible, same class as the standing `causal-edge` 47/1 red). +> +> **2026-06-15 — MERGED #496** (integrated-cognitive-planner reference map + ValueSchema + FULL POC default): `lance_graph_contract::canonical_node::{ValueSchema, ValueTenant, VALUE_TENANTS}` — the value-side `EdgeCodecFlavor` analog (9 append-only tenants carving `[32,186)`; presets Bootstrap/Cognitive/Compressed/Full). `ClassView::value_schema()` default flipped **Bootstrap→Full (TEMPORARY POC** — every unconfigured class materialises the full slab so consumers transcode against it; `TD-VALUESCHEMA-FULL-POC-DEFAULT` revert-when-POC-concludes; type-level `ValueSchema::default()` stays Bootstrap, only class→schema *resolution* flips). New reference plan `.claude/plans/integrated-cognitive-planner-v1.md` — **§0 ANTI-INVENTION GUARDRAIL (READ FIRST)**, §1–§7 grounded file:line map, §8 7-item additive ledger, §9 3-hardener verdicts; the SPEC for the integrated-planner refactor (~90% exists; remaining = the keystone + 6 seams, NOT a new build). CI 5/5 green; contract 613 lib tests; merge `2e58e034`. **The keystone = `NiblePath::from_guid_prefix` (the 20→≤16-nibble subset) + classid→ClassView read-mode on `lance-graph-ontology::registry` (already an immutable conflict-refusing `entity_type↔NiblePath` bijection)** — the single next unblock that converges the refactor, the tesseract-rs OCR transcode (`contract::ocr` → NodeRow), AND the OGAR-identity migration (`soa-migration-diff-resolution-2026-06-13.md`). HEEL=cache `dolce_id` / HIP·TWIG=deterministic subClassOf descent / registry=recorder-not-minter (verified `registry.rs`+`wikidata_hhtl.rs`). New: `TD-COARSERESIDUE-NO-VALUE-TENANT`, `TD-LAZY-IMPORT-VERSION-PIN`; IDEAS CLAM-residue-ladder TODO. +> > **2026-06-13 — shipped (autoattended, cross-repo)** (turbovec ⇄ ndarray): new excluded standalone crate **`crates/lance-graph-turbovec`** — Google TurboQuant (arXiv 2504.19874, the AdaWorldAPI `turbovec` fork) bridged onto the spine. `TurboVec` wraps `turbovec::TurboQuantIndex` with a `Kernel::{NativeLut, PolyfillGemm}` A/B switch. **Cross-repo (branch `claude/wonderful-hawking-lodtql` in turbovec + ndarray + lance-graph):** turbovec re-pointed from crates.io `ndarray 0.17` → the AdaWorldAPI fork (path, P0 forks-only; `blas` opt-in so default builds BLAS-free; `rust-toolchain.toml` = 1.95.0); new `turbovec::search_polyfill` (feature `ndarray-simd`) expresses scoring as a batched int8 GEMM via **`ndarray::simd::matmul_i8_to_i32`** (re-exported through `simd.rs` — AMX `TDPBUSD` tile → AVX-512 VPDPBUSD → AVX-VNNI → scalar, dispatched inside ndarray, zero intrinsics in turbovec). **Measured finding (E-TURBOVEC-AMX-WRONG-TOOL-1):** the polyfill GEMM is 11.4× SLOWER than the native nibble-LUT (TurboQuant trades the matmul away → AMX accelerates the op it removed); native LUT stays production, polyfill is the AMX-ready baseline. Placement: index → spine, kernel-math → ndarray (already owns clam/cam_pq/cascade/amx_matmul). Synergy map (HDR popcount stacking early-exit, Belichtungsmesser σ thresholds, preheating vs palette256) in `crates/lance-graph-turbovec/KNOWLEDGE.md`. Tests green in all three repos; benchmark via `examples/kernel_speed.rs`. NOT a merged PR yet (branch work). > > **2026-06-03 — hardened (follow-up after #460)** (D-HELIX-1 wiring): `crates/helix` now takes **ndarray as a MANDATORY, non-optional git dependency** (`git = AdaWorldAPI/ndarray @ master`), replacing the optional `path` dep + `ndarray-hpc` feature. Why: (1) codex P2 — an optional *path* dep still forces Cargo to read the local sibling manifest at resolution, so a clean checkout failed before feature selection; (2) directive "ndarray is mandatory for lance-graph". `simd.rs` always uses `ndarray::simd` (no scalar fallback); the self-contained fork → no import cycle. 63 unit + 6 doctests green; clippy/fmt clean. See E-HELIX-NDARRAY-MANDATORY. diff --git a/.claude/board/PR_ARC_INVENTORY.md b/.claude/board/PR_ARC_INVENTORY.md index a46a67e1..91136c3a 100644 --- a/.claude/board/PR_ARC_INVENTORY.md +++ b/.claude/board/PR_ARC_INVENTORY.md @@ -35,6 +35,34 @@ --- +## #498 GUID decode→read-mode keystone + helix Signed360 right-size + OCR→NodeRow transcode + +**Status:** OPEN 2026-06-15 (branch `claude/wonderful-hawking-lodtql`, 8 commits post-#496). In review (CodeRabbit + codex). **NOTE:** this entry documents the helix / keystone / OCR / causal-edge work that CodeRabbit on PR #498 mis-attributed to #496 — those changes are **#498's, not #496's**. #496 shipped only ValueSchema presets + the reference plan (its immutable entry below correctly shows the pre-right-size 154/98 B budgets). + +**Added:** (1) **Keystone** `canonical_node::{GuidParts, ReadMode, classid_read_mode}` + `NodeGuid::{heel/hip/twig, decode()→GuidParts, read_mode()}` — read-the-GUID-as-a-GUID decode + a `LazyLock` classid→read-mode registry (the single source consumer + OGAR inherit); `ReadMode` bundles the two existing axes (`ValueSchema` + `EdgeCodecFlavor`), no new property. (2) `helix::{Sign, Signed360}` + `HemispherePoint::signed_lift` + `ResidueEncoder::encode_signed` — signed full-sphere codec; **`HelixResidue` value-tenant right-sized 48 B → 6 B** (bits→bytes slip fix) → downstream offsets shifted (Turbovec 160→118, Energy 176→134, …), budgets re-locked (Full 154→112, Compressed 98→56), value carve now `[32,144)`. (3) `ocr::{BlockKind::entity_type, LayoutBlock::to_node_row}` + `ValueTenant::{value_offset, byte_len}` — OCR-engine-agnostic transcode. (4) causal-edge `test_build_fast` boundary `<`→`<=` (standing red on main fixed). (5) **`ENVELOPE_LAYOUT_VERSION` 1→2** — gates the value-slab offset shift (codex P2). Tests: contract 623 lib, helix 73 lib + 7 doc, causal-edge green. + +**Locked:** (1) **one `NodeGuid` only** — the #490-retired `identity::NodeGuid` (UUIDv8) stays retired; the keystone extends the canon `canonical_node::NodeGuid`. (2) `ReadMode::DEFAULT = {Full, CoarseOnly}` mirrors the ClassView POC default; both flip back to Bootstrap together (guard `read_mode_default_is_full_poc`). (3) **`Signed360` sign-partition** — `|y|`-in-7-bits + sign in the partition (Pos ⇒ polar [128,255], Neg ⇒ [0,127]); sign is exact at `|y|≈0` at the rim (codex P2 fix, regression test `signed360_neg_sign_survives_near_rim_at_high_total`). (4) text/bbox never bundled into the node — content store keyed by identity (I-VSA-IDENTITIES). (5) a value-slab offset shift is **version-gated, not reserved-gap** — safe pre-persistence (FULL is POC-only; codex P2 disposition). + +**Deferred:** ontology-side `NiblePath::from_guid_prefix` (the keystone's other half); pure-Rust OCR via `ocrs`/`rten` (the tesseract-rs C-wrapper POC was reverted — wrong direction). `TD-VALUESCHEMA-FULL-POC-DEFAULT` paired-revert note updated (ReadMode::DEFAULT pairs with ClassView). + +**Docs:** board `LATEST_STATE` + `TECH_DEBT` updated; this entry. + +**Confidence (2026-06-15):** open — both codex P2s dispositioned (ENVELOPE_LAYOUT_VERSION bump for the offset shift; Signed360 sign-partition fix + regression test); CodeRabbit's #496-vs-#498 misattribution corrected here. + +## #496 integrated-cognitive-planner reference map + ValueSchema presets + FULL POC default + +**Status:** MERGED 2026-06-15 (merge commit `2e58e034`), branch `claude/wonderful-hawking-lodtql`. CI 5/5 green (format/clippy/linux-build/test/test-with-coverage). CodeRabbit 2 threads resolved; codex 2×P2 dispositioned (FULL-default intentional; CoarseResidue tracked as TD). + +**Added:** `lance_graph_contract::canonical_node::{ValueSchema, ValueTenant, VALUE_TENANTS}` — value-side analog of `EdgeCodecFlavor`; 9 append-only tenants carve `[32,186)`; 4 presets (Bootstrap EMPTY / Cognitive 58 B / Compressed 98 B / Full 154 B). `ClassView::value_schema()` default flipped **Bootstrap→Full (TEMPORARY POC)** + guard test `value_schema_default_is_full_temporary_poc`. New `.claude/plans/integrated-cognitive-planner-v1.md` (file:line reference map). Lance pin doc-sweep 6→7 / 0.29→0.30 across CLAUDE.md + boards + plans. Contract 613 lib tests. + +**Locked:** (1) **§0 ANTI-INVENTION GUARDRAIL** — no new skewed SoA properties; the 9 ValueTenants + 4 BindSpace columns are closed; new capability = new column/class, never a new layer; specialisation is opt-IN (mint a class). (2) FULL POC default is class→schema *resolution* only; type-level `ValueSchema::default()` stays Bootstrap (substrate zero-fallback intact). (3) emit channels `emitted_edges`(CausalEdge64 words) vs `emitted_moves`(KanbanMove) are SEPARATE — no `KanbanMove→u64` cast. (4) `cycle()` stays inherent (object-safety, keeps `Box` consumers). (5) seam #2 as-of read is closure-injected (planner ⊥ async `at_version`). (6) dual `RungLevel` — mirror thinking-engine's `should_elevate`, don't duplicate. + +**Deferred:** the §8 7-item additive ledger (CognitiveCycle sequencer / RungLevel constructors / temporal.rs A→contract + B core temporal_read / ScopedReference / MarkingRow / NiblePath `from_guid_prefix` / ExecTarget::can_drive) — gated on D-MBX-A6-P3 + the keystone. `TD-VALUESCHEMA-FULL-POC-DEFAULT` (revert FULL→Bootstrap when POC concludes), `TD-COARSERESIDUE-NO-VALUE-TENANT`, `TD-LAZY-IMPORT-VERSION-PIN`. + +**Docs:** `integrated-cognitive-planner-v1.md` (§0 guardrail, §1–§7 grounded map, §2.1 ExecTarget, §3.1 causal-arc, §4.1 0-friction, §8 cross-savant synthesis, §9 hardening verdicts). 5-savant expansion + 3-hardener (PP-13/15/16) folded. + +**Confidence (2026-06-15):** working — merged clean, CI green, 613 contract lib tests. The plan is the SPEC for the integrated-planner refactor; the keystone (`from_guid_prefix` + classid→ClassView read-mode on `registry.rs`) is the single next unblock for refactor + tesseract + OGAR-identity migration. + ## #459 helix-place-residue-codec — golden-spiral Place/Residue codec (zero-dep + optional ndarray-hpc) **Status:** MERGED 2026-06-03 (merge commit `ef35ff1`), branch `claude/gallant-rubin-Y9pQd`. New standalone crate; autoattended wave (5 read-only research agents + 4 parallel Sonnet leaf workers + central consolidation). 63 unit + 6 doctests green on both feature configs; clippy -D warnings + fmt clean. One CodeRabbit review round resolved pre-merge. diff --git a/.claude/board/TECH_DEBT.md b/.claude/board/TECH_DEBT.md index 573594a1..ea56ea6f 100644 --- a/.claude/board/TECH_DEBT.md +++ b/.claude/board/TECH_DEBT.md @@ -15,6 +15,28 @@ ## Open Debt +### TD-CAUSAL-EDGE-LINT — `causal-edge` crate-wide pre-existing clippy (15) + fmt drift (2026-06-15) + +**Surfaced by** fixing the long-standing `test_build_fast` red (2026-06-15, this commit: `tables.rs:144` `<`→`<=`, the `c_levels=1` 256 KB floor — test now **48/48 green**). With the test green, `cargo clippy --manifest-path crates/causal-edge/Cargo.toml --lib -- -D warnings` still fails with **15 errors** (edge.rs `to_mantissa`/`from_mantissa`/`plasticity` — match-arm + needless-block lints) and `cargo fmt --check` shows **crate-wide drift** (edge.rs, v2_layout_tests.rs — arm spacing, long `assert_eq!` lines, nested if/else). **Pre-existing, NOT from the test-bound fix** (one line, tables.rs, fmt-clean). **CI-invisible** — `causal-edge` is not a lance-graph workspace member, so the workspace gate never clippy/fmt-checks it (same class as helix `probe_mantissa_fill`). + +**Pay it by** `cargo fmt --manifest-path crates/causal-edge/Cargo.toml` + `cargo clippy --fix` + manual residual-lint resolution. Mechanical (~30 min); deferred to keep the test-red fix scoped. The 15 lints are quality (formatting / needless blocks), not correctness — the crate's logic is tested (48 lib green). + +### TD-HELIX-PROBE-CLIPPY — `helix` `tests/probe_mantissa_fill.rs` pre-existing clippy + fmt drift (2026-06-15) + +**Surfaced by** the helix `Signed360` work (2026-06-15): `cargo clippy --manifest-path crates/helix/Cargo.toml --all-targets -- -D warnings` fails on `tests/probe_mantissa_fill.rs:170` — `clippy::needless_range_loop` (`for b in 0..BINS*BINS` indexing `counts`), under clippy 1.95.0. The same file has pre-existing `cargo fmt` drift (the `SEEDS` const + several `assert_eq!`/`assert!` calls exceed the width, unwrapped). **Pre-existing, NOT caused by the `Signed360` addition** (that's all in `src/`, additive; this integration test was never touched). **CI-invisible** because `helix` is a root-`exclude`d crate — the lance-graph workspace gate never builds it. Same class as the standing `causal-edge` 47/1 red (`test_build_fast`) on main. + +**Pay it by** rewriting the `for b in 0..BINS*BINS` loop as `counts.iter().enumerate()` (or `iter_mut`) + `cargo fmt --manifest-path crates/helix/Cargo.toml`. Trivial; deferred ONLY to keep the `Signed360` commit scoped. `cargo test` on helix is green (the probe passes as a test; only `clippy --all-targets -D warnings` flags the lint). Cross-ref: the helix `Signed360` branch commit. + +### TD-LAZY-IMPORT-VERSION-PIN — lazy OGIT/ontology imports MUST be version-pinned + reserve-don't-reclaim sibling nibbles (2026-06-15) + +**Surfaced by** operator design dialogue (2026-06-15: "OGIT as a lazy import in Rust-based OGAR — could DOLCE / Odoo etc. drift?"). The drift-control architecture is layered + sound; two disciplines must be LOCKED before the OGAR-identity migration runs a real (non-fixture) lazy import. + +**Drift-control that already EXISTS (recorded so it's not re-derived):** (1) `entity_type↔NiblePath` address bijection is IMMUTABLE / conflict-refusing (`registry.rs:385-401`) — drift surfaces as a LOUD registration error, never silent corruption; (2) DOLCE basin = static enum (4 frozen upper categories, collision-free) — no drift; (3) shape/metadata evolves via `ContextBundle` overwrite on same `G` (`register_bundle` :466), a version bump = new `(g, version)` slot, old preserved — Odoo field changes are absorbed as bundle updates, NOT address drift; (4) `StructuralSignature` (class_signature.rs:31) is the shape-drift DETECTOR. + +**The debt (disciplines to enforce):** (a) **every lazy import MUST declare a version** so a drifted upstream → a NEW `(g, version)` namespace, never a conflicting re-mint into the old slot (an I-LEGACY-API-style version-gate on the hydrator/import path). (b) the **sibling-nibble assignment MUST be deterministic + reserve-don't-reclaim** — a new sibling class gets a NEW nibble, never reassigns an existing sibling's; >16 fan-out per level needs the CLAM 16-way sub-tiering (IDEAS.md 2026-06-15 CLAM-residue-ladder). + +**Pay it by** adding the version-pin gate + the deterministic sibling rule when the OGAR-identity migration (`soa-migration-diff-resolution-2026-06-13.md`) executes a real load. Until then imports are curated/fixture (deterministic by hand). Cross-ref: `guid-canon-and-prefix-routing.md` (OGAR canon, cited never forked), `registry.rs:385-466`, IDEAS CLAM-ladder. **EPIPHANY candidate (council-gated):** "lazy-import drift control = immutable-address + evolvable-shape + versioned-namespace + StructuralSignature-detector; identity immutable, shape evolvable, versions side-by-side." + ### TD-COARSERESIDUE-NO-VALUE-TENANT — `EdgeCodecFlavor::CoarseResidue` residue has no dedicated value-slab tenant (codex #496, 2026-06-15) **Surfaced by** codex P2 on PR #496 (`canonical_node.rs:336`). `EdgeCodecFlavor::CoarseResidue` (`canonical_node.rs:213`) declares its per-dimension signed-4-bit residue is "carried in the reserved value slab" (cost `1 + ⌈D/2⌉`, `bytes_per_vector` :228), but the `ValueTenant` catalogue (`canonical_node.rs:324-343`) has a slot only for `Pq32x4` (`TurbovecResidue = 5`, 16 B at offset 160) — **none for `CoarseResidue`**. So `Full`/`Compressed` presets report all codec tenants present while a class pairing `CoarseResidue` with those schemas has no addressable column for its residue (it would collide with another tenant). @@ -29,7 +51,7 @@ **The debt:** `ClassView::value_schema` (`class_view.rs:233`) returns `ValueSchema::Full` instead of the canon zero-fallback `ValueSchema::Bootstrap`. This INVERTS the zero-fallback ladder for the value-slab *resolution* (specialisation is now opt-IN: mint a class to go smaller/denser). It is layout-preserving (no `NODE_ROW_STRIDE` / `ENVELOPE_LAYOUT_VERSION` change — Full carves within the reserved 480 B) and a one-line revert. The TYPE-level `ValueSchema::default()` stays `Bootstrap`, so the substrate zero-fallback semantics are intact — only the class→schema resolution default flipped. **No invention** (honours the operator's anti-skew guardrail): `Full` activates the already-existing, already-tested 9 `ValueTenant`s; it adds no new property. -**Pay it by:** reverting `class_view.rs:233` to `ValueSchema::Bootstrap` once the consumer POCs settle on their real per-class presets, AND flipping the guard test `value_schema_default_is_full_temporary_poc` (`class_view.rs`) back to assert Bootstrap. The edge-codec axis (`edge_codec_flavor` → `CoarseOnly`) is a separate knob, untouched; flip it to a residue/PQ flavor only if a consumer POC needs full edge fidelity too. Tests: 613 lib green. +**Pay it by:** reverting `class_view.rs:233` to `ValueSchema::Bootstrap` once the consumer POCs settle on their real per-class presets, AND flipping the guard test `value_schema_default_is_full_temporary_poc` (`class_view.rs`) back to assert Bootstrap. The edge-codec axis (`edge_codec_flavor` → `CoarseOnly`) is a separate knob, untouched; flip it to a residue/PQ flavor only if a consumer POC needs full edge fidelity too. **PAIRED SITE (2026-06-15):** `ReadMode::DEFAULT` (`canonical_node.rs`) also POC-defaults `value_schema = Full` so the `classid → read-mode` resolver (`classid_read_mode`) agrees with `ClassView` — revert BOTH `value_schema` fields to `Bootstrap` together; the test `read_mode_default_is_full_poc` (`canonical_node.rs`) guards the pairing and flips with them. Tests: 619 lib green. ### TD-NDARRAY-SIMD-POPCNT-NATIVE — `extract_rules` SIGILLs under `-C target-cpu=native` on larger RowMasks (2026-06-14) @@ -170,7 +192,7 @@ FINDING D-CLS↔D-ARM-14 (EPIPHANIES). ### TD-SURREALDB-KVLANCE-LANCE7 (deps — surrealdb-core still pins lance =6.0.0) -**Status: Open.** The 2026-05-31 lance `6.0.0 → =7.0.0` / lancedb `0.29.0 → +**Status: Open — blocker CONFIRMED SHALLOW (operator 2026-06-15: "surrealdb just needed 7.0.0 / lancedb 0.30 pin").** The kv-lance backend is already implemented in-tree; the ONLY remaining blocker is the version pin (no deep architectural work). The 2026-05-31 lance `6.0.0 → =7.0.0` / lancedb `0.29.0 → =0.30.0` bump (lance-graph, `claude/jolly-cori-clnf9` → PR #445) moved this workspace's `object_store` transitive `0.12 → 0.13.2`. The AdaWorldAPI/surrealdb fork's `surrealdb-core` already runs `object_store = "0.13.0"`, but its @@ -184,6 +206,8 @@ EPIPHANIES E-LANCE7-OBJECTSTORE-SURREALDB; root `Cargo.toml` RESOLVED(A2/B2). (The earlier `TD-LANCE-6.0.1-PIN` — only ever a root Cargo.toml comment, never a row here — is moot: no lancedb pinned lance `=6.0.1`; `0.30.0 → 7.0.0` superseded it.) +**2026-06-15 update (operator ground truth):** the fix is exactly the 3-pin bump (`lance` / `lance-index` `=7.0.0`, `lancedb` `=0.30.0`) in `AdaWorldAPI/surrealdb` `surrealdb/core/Cargo.toml` — a write to the surrealdb FORK, **outside this session's 3-repo scope** (ndarray / lance-graph / turbovec; `add_repo` unavailable this session, so it can't be pushed from here). Once it lands, `surreal_container` resolves → unblocks **D-PG-6** (Rubicon kanban VIEW), **D-MBX-9 OUT** (`LanceVersionScheduler` over `versions()`), **identity-architecture Phase H** (SurrealQL read glove). **Companion (ractor):** the `--features supervisor` blocker is the `AdaWorldAPI/ractor` fork's `MessagingErr::Saturated` non-exhaustive match (fork ~2 commits behind upstream's error-enum fix) — sync the fork with upstream; also a fork write, also out of this session's scope. We don't use the messaging path; default builds are unaffected (ractor is `optional`, off by default). + --- ### TD-ARM-CARRIER-FORK (D-ARM-13 / streaming-arm-nars-discovery-v1) diff --git a/crates/causal-edge/src/tables.rs b/crates/causal-edge/src/tables.rs index 4a6b8f8d..e44c36a7 100644 --- a/crates/causal-edge/src/tables.rs +++ b/crates/causal-edge/src/tables.rs @@ -141,7 +141,11 @@ mod tests { fn test_build_fast() { let tables = NarsTables::build(1); // fast path: single c-level assert_eq!(tables.revision.len(), 1); - assert!(tables.byte_size() < 256 * 1024); // < 256 KB + // 256 KB is the c_levels=1 FLOOR: 1 revision table + 1 deduction table, + // 256·256·2 = 128 KB each. The bound is ≤, not < (the prior `<` was + // impossible to satisfy — a boundary-by-one bug, red on main since the + // deduction table landed). + assert!(tables.byte_size() <= 256 * 1024); } #[test] diff --git a/crates/helix/src/lib.rs b/crates/helix/src/lib.rs index 3409af25..159f2c04 100644 --- a/crates/helix/src/lib.rs +++ b/crates/helix/src/lib.rs @@ -74,7 +74,7 @@ pub use constants::{ pub use curve_ruler::CurveRuler; pub use distance::DistanceLut; pub use fisher_z::Similarity; -pub use placement::HemispherePoint; +pub use placement::{HemispherePoint, Sign}; pub use prove::{prove, ProofResult}; pub use quantize::RollingFloor; -pub use residue::{ResidueEdge, ResidueEncoder}; +pub use residue::{ResidueEdge, ResidueEncoder, Signed360}; diff --git a/crates/helix/src/placement.rs b/crates/helix/src/placement.rs index 49c1a76c..e69b1322 100644 --- a/crates/helix/src/placement.rs +++ b/crates/helix/src/placement.rs @@ -22,6 +22,42 @@ use crate::constants::GOLDEN_RATIO; +/// Hemisphere sign for the signed full-sphere lift +/// ([`HemispherePoint::signed_lift`], the 48-bit +/// [`Signed360`](crate::residue::Signed360) residue). `Pos` = upper hemisphere +/// (`y ≥ 0`, the base [`lift`](HemispherePoint::lift)); `Neg` = the +/// equator-mirrored lower hemisphere (`y ≤ 0`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Sign { + /// Upper hemisphere (`y ≥ 0`). Reproduces [`HemispherePoint::lift`] exactly. + Pos, + /// Lower hemisphere (`y ≤ 0`) — the base lift mirrored across the equator. + Neg, +} + +impl Sign { + /// `+1.0` for [`Pos`](Sign::Pos), `−1.0` for [`Neg`](Sign::Neg). + #[inline] + #[must_use] + pub fn as_f64(self) -> f64 { + match self { + Sign::Pos => 1.0, + Sign::Neg => -1.0, + } + } + + /// The sign of a value: [`Neg`](Sign::Neg) iff `v < 0` (zero ⇒ [`Pos`](Sign::Pos)). + #[inline] + #[must_use] + pub fn of(v: f64) -> Self { + if v < 0.0 { + Sign::Neg + } else { + Sign::Pos + } + } +} + /// A single residue index lifted onto the equal-area hemisphere. /// /// All three fields are computed in one call to [`HemispherePoint::lift`]; they @@ -125,6 +161,32 @@ impl HemispherePoint { pub fn rim(&self) -> f64 { self.r } + + /// Signed full-sphere lift — the equal-area hemisphere [`lift`](Self::lift) + /// mirrored across the equator by `sign`, so `y = sign·√(1 − u)` ranges over + /// the FULL sphere (`y ∈ [−1, 1]`) instead of one hemisphere (`y ∈ (0, 1]`). + /// The unit-sphere identity `r² + y² = 1` still holds (`r` is unchanged). + /// [`Sign::Pos`] reproduces [`lift`](Self::lift) exactly; [`Sign::Neg`] is the + /// lower hemisphere. The two 24-bit hemispheres = the 48-bit + /// [`Signed360`](crate::residue::Signed360) residue. + /// + /// # Examples + /// + /// ``` + /// use helix::placement::{HemispherePoint, Sign}; + /// + /// let up = HemispherePoint::lift(3, 10); + /// let dn = HemispherePoint::signed_lift(3, 10, Sign::Neg); + /// assert_eq!(dn.r, up.r); // same rim radius + /// assert_eq!(dn.y, -up.y); // mirrored lift + /// assert!((dn.r * dn.r + dn.y * dn.y - 1.0).abs() < 1e-12); // still on the sphere + /// ``` + #[must_use] + pub fn signed_lift(n: usize, total: usize, sign: Sign) -> Self { + let mut p = Self::lift(n, total); + p.y *= sign.as_f64(); + p + } } #[cfg(test)] @@ -328,4 +390,48 @@ mod tests { frac_500 * 100.0 ); } + + // ── signed full-sphere lift (Signed360) ────────────────────────────────── + + #[test] + fn signed_lift_pos_equals_lift() { + for &(n, total) in &[(0usize, 10usize), (3, 10), (7, 17), (500, 1000)] { + let up = HemispherePoint::lift(n, total); + let s = HemispherePoint::signed_lift(n, total, Sign::Pos); + assert_eq!(s, up, "Sign::Pos must reproduce lift exactly"); + } + } + + #[test] + fn signed_lift_neg_mirrors_y_keeps_r() { + for &(n, total) in &[(0usize, 10usize), (3, 10), (9, 10), (499, 1000)] { + let up = HemispherePoint::lift(n, total); + let dn = HemispherePoint::signed_lift(n, total, Sign::Neg); + assert_eq!(dn.r, up.r, "Neg must keep the rim radius"); + assert_eq!(dn.y, -up.y, "Neg must mirror the lift across the equator"); + } + } + + #[test] + fn signed_lift_stays_on_unit_sphere_both_signs() { + for &(n, total) in &[(0usize, 1usize), (5, 10), (999, 1000), (7, 17)] { + for sign in [Sign::Pos, Sign::Neg] { + let p = HemispherePoint::signed_lift(n, total, sign); + let sum = p.r * p.r + p.y * p.y; + assert!( + (sum - 1.0).abs() < 1e-12, + "r²+y² must be 1 for ({n},{total},{sign:?}), got {sum}" + ); + } + } + } + + #[test] + fn sign_helpers() { + assert_eq!(Sign::Pos.as_f64(), 1.0); + assert_eq!(Sign::Neg.as_f64(), -1.0); + assert_eq!(Sign::of(-0.5), Sign::Neg); + assert_eq!(Sign::of(0.0), Sign::Pos); + assert_eq!(Sign::of(2.0), Sign::Pos); + } } diff --git a/crates/helix/src/residue.rs b/crates/helix/src/residue.rs index f411ee7c..32f985b2 100644 --- a/crates/helix/src/residue.rs +++ b/crates/helix/src/residue.rs @@ -6,11 +6,11 @@ //! *is* the residue. `encode` is the compute path (`&self`, read-only); `observe` //! / `roll` are the calibration paths (`&mut self`) — honouring "no `&mut self` //! during computation". -use crate::constants::{EULER_GAMMA, LN_17, MODULUS, STRIDE}; +use crate::constants::{EULER_GAMMA, GOLDEN_RATIO, LN_17, MODULUS, STRIDE}; use crate::curve_ruler::CurveRuler; use crate::distance::DistanceLut; use crate::fisher_z::Similarity; -use crate::placement::HemispherePoint; +use crate::placement::{HemispherePoint, Sign}; use crate::quantize::RollingFloor; /// A residue edge: the `(start, end)` endpoint pair on the φ-spiral curve-ruler, @@ -60,6 +60,61 @@ impl ResidueEdge { } } +/// Signed full-sphere residue — the 24-bit hemisphere [`ResidueEdge`] **doubled +/// to 48 bit (6 bytes)**. Maps a signed magnitude to the FULL sphere: the +/// unsigned hemisphere `rim` edge (rim radius + place anchor via the existing +/// pipeline), the signed `polar` byte (the equal-area lift `y = sign·√(1 − u)` +/// quantised as `|y|`-in-7-bits with the sign in the partition — `≥ 128` upper +/// hemisphere, `< 128` lower, so the hemisphere sign is recoverable even at the +/// rim where `|y| ≈ 0`), and the 16-bit `azimuth` (`n·φ` wrapped to +/// `[0, 2π)` over the full **360°**). Wire layout (LE): +/// `[rim.start, rim.end, rim.floor_version, polar, azimuth_lo, azimuth_hi]`. +/// +/// This is the codec the contract `HelixResidue` value-tenant reserves 6 bytes +/// for; the producer writes [`to_bytes`](Signed360::to_bytes). The contract +/// itself is zero-dep and only reserves the bytes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Signed360 { + /// Unsigned hemisphere edge (rim radius + place anchor). 3 bytes. + pub rim: ResidueEdge, + /// Signed equal-area lift `y` quantised: `|y|` in 7 bits, sign in the + /// partition — `[128, 255]` = upper hemisphere ([`Sign::Pos`]), `[0, 127]` = + /// lower ([`Sign::Neg`]). The partition (not a centred-at-128 round) keeps + /// the sign exact even when `|y| ≈ 0` at the rim. 1 byte. + pub polar: u8, + /// Golden azimuth `n·φ mod 2π` mapped to `[0, 65536)` over the full 360°. 2 bytes. + pub azimuth: u16, +} + +impl Signed360 { + /// Serialise to 6 bytes (LE): + /// `[rim.start, rim.end, rim.floor_version, polar, azimuth_lo, azimuth_hi]`. + pub fn to_bytes(self) -> [u8; 6] { + let r = self.rim.to_bytes(); + let a = self.azimuth.to_le_bytes(); + [r[0], r[1], r[2], self.polar, a[0], a[1]] + } + + /// Deserialise from 6 bytes. + pub fn from_bytes(b: [u8; 6]) -> Self { + Self { + rim: ResidueEdge::from_bytes([b[0], b[1], b[2]]), + polar: b[3], + azimuth: u16::from_le_bytes([b[4], b[5]]), + } + } + + /// Which hemisphere this residue sits in — recovered from the `polar` byte + /// (`>= 128` ⇒ upper [`Sign::Pos`], `< 128` ⇒ lower [`Sign::Neg`]). + pub fn sign(&self) -> Sign { + if self.polar >= 128 { + Sign::Pos + } else { + Sign::Neg + } + } +} + /// The four-stage residue encoder: total residue count `N` + the rolling /// 256-palette floor. #[derive(Debug, Clone)] @@ -117,6 +172,37 @@ impl ResidueEncoder { } } + /// Encode `(place, n, sign)` into a 6-byte [`Signed360`] — the signed + /// full-sphere residue (the doubled-hemisphere companion to + /// [`encode`](Self::encode)). The `rim` reuses the unsigned hemisphere + /// pipeline; `polar` carries the signed equal-area lift `y = sign·√(1 − u)` + /// (`|y|`-in-7-bits + sign-partition, so the hemisphere sign is recoverable + /// via [`Signed360::sign`] even at the rim); `azimuth` is the golden angle + /// `n·φ` over the full 360°. + pub fn encode_signed(&self, place: u64, n: usize, sign: Sign) -> Signed360 { + let n = n.min(self.total - 1); + let rim = self.encode(place, n); + // Signed equal-area lift y ∈ [−1, 1]. Encode |y| in 7 bits and the sign + // in the PARTITION (Pos ⇒ [128, 255], Neg ⇒ [0, 127]) so the hemisphere + // sign survives even when |y| ≈ 0 near the rim. A naive `128 + y·127` + // rounds a tiny negative lift up to 128, which `sign()` reads as Pos + // (codex P2 on #498). The partition makes the sign exact at every magnitude. + let p = HemispherePoint::signed_lift(n, self.total, sign); + let mag = (p.y.abs() * 127.0).round().clamp(0.0, 127.0) as u8; + let polar = match sign { + Sign::Pos => 128 + mag, + Sign::Neg => 127 - mag, + }; + // Golden azimuth n·φ wrapped to [0, 2π) → u16 over the full 360°. + let az = (n as f64 * GOLDEN_RATIO).rem_euclid(core::f64::consts::TAU); + let azimuth = ((az / core::f64::consts::TAU) * 65536.0) as u16; + Signed360 { + rim, + polar, + azimuth, + } + } + /// Calibration: feed an observation through the floor's occupancy monitor. pub fn observe(&mut self, place: u64, n: usize) { let n = n.min(self.total - 1); @@ -213,4 +299,86 @@ mod tests { let (_d, _below) = a.distance_heuristic(&a); assert_eq!(a.distance_heuristic(&a).0, 0); } + + // ── Signed360 (signed full-sphere, 48-bit) ─────────────────────────────── + + #[test] + fn signed360_byte_roundtrip_is_6_bytes() { + let enc = ResidueEncoder::new(4096); + let s = enc.encode_signed(0x1234, 1700, Sign::Neg); + assert_eq!(Signed360::from_bytes(s.to_bytes()), s); + assert_eq!( + s.to_bytes().len(), + 6, + "Signed360 is exactly 6 bytes (48 bit)" + ); + } + + #[test] + fn signed360_rim_matches_unsigned_encode() { + let enc = ResidueEncoder::new(4096); + // The rim edge is the existing unsigned hemisphere encode (sign-independent). + let rim = enc.encode(0x1234, 1700); + assert_eq!(enc.encode_signed(0x1234, 1700, Sign::Pos).rim, rim); + assert_eq!(enc.encode_signed(0x1234, 1700, Sign::Neg).rim, rim); + } + + #[test] + fn signed360_sign_recoverable_from_polar() { + let enc = ResidueEncoder::new(4096); + for n in [1usize, 100, 1700, 4000] { + let pos = enc.encode_signed(7, n, Sign::Pos); + let neg = enc.encode_signed(7, n, Sign::Neg); + assert_eq!( + pos.sign(), + Sign::Pos, + "Pos ⇒ upper hemisphere (polar ≥ 128)" + ); + assert_eq!( + neg.sign(), + Sign::Neg, + "Neg ⇒ lower hemisphere (polar < 128)" + ); + assert!(pos.polar >= 128 && neg.polar < 128); + } + } + + #[test] + fn signed360_azimuth_varies_with_n() { + let enc = ResidueEncoder::new(4096); + let a = enc.encode_signed(7, 100, Sign::Pos).azimuth; + let b = enc.encode_signed(7, 101, Sign::Pos).azimuth; + assert_ne!(a, b, "consecutive residues get distinct golden azimuths"); + } + + #[test] + fn signed360_is_deterministic() { + let enc = ResidueEncoder::new(4096); + assert_eq!( + enc.encode_signed(0x99, 2000, Sign::Neg), + enc.encode_signed(0x99, 2000, Sign::Neg) + ); + } + + #[test] + fn signed360_neg_sign_survives_near_rim_at_high_total() { + // Regression for codex P2 (#498): near the rim √(1−u) → 0, so |y| ≈ 0. + // A centred-at-128 round mapped a negative lift to polar 128, which + // `sign()` reads as Pos — losing the sign. The |y|-in-7-bits + partition + // encoding makes Neg ⇒ polar ∈ [0, 127] for EVERY magnitude, so the sign + // is exact even when the lift vanishes. Large total ⇒ rim n has tiny |y|. + let enc = ResidueEncoder::new(65_536); + for n in [65_530usize, 65_534, 65_535] { + let neg = enc.encode_signed(0x55, n, Sign::Neg); + assert_eq!( + neg.sign(), + Sign::Neg, + "Neg must survive at the rim (n={n}, polar={})", + neg.polar + ); + assert!(neg.polar < 128, "Neg ⇒ polar < 128 even at |y| ≈ 0"); + // And the Pos companion stays in the upper partition. + assert!(enc.encode_signed(0x55, n, Sign::Pos).polar >= 128); + } + } } diff --git a/crates/helix/tests/probe_mantissa_fill.rs b/crates/helix/tests/probe_mantissa_fill.rs index abfaf9cb..c498f1ce 100644 --- a/crates/helix/tests/probe_mantissa_fill.rs +++ b/crates/helix/tests/probe_mantissa_fill.rs @@ -124,7 +124,11 @@ fn random_disk_points(k: usize, seed: u64) -> Vec<(f64, f64)> { fn probe_mantissa_fill_golden_beats_uniform_random() { // Three independent baseline seeds — golden must beat ALL of them on // BOTH metrics at BOTH sample counts; no cherry-picking. - const SEEDS: [u64; 3] = [0x9E37_79B9_7F4A_7C15, 0xD1B5_4A32_D192_ED03, 0x2545_F491_4F6C_DD1D]; + const SEEDS: [u64; 3] = [ + 0x9E37_79B9_7F4A_7C15, + 0xD1B5_4A32_D192_ED03, + 0x2545_F491_4F6C_DD1D, + ]; for &k in &[256usize, 1024] { let (g_occ, g_max) = fill_metrics(golden_points(k).into_iter()); @@ -189,7 +193,11 @@ fn probe_phase1_curve_ruler_regeneration_is_bit_exact() { for depth in [0u8, 1, 7, 16] { let a = CurveRuler::from_hhtl(path, depth); let b = CurveRuler::from_hhtl(path, depth); - assert_eq!(a.arc(), b.arc(), "regeneration drift at ({path:#x},{depth})"); + assert_eq!( + a.arc(), + b.arc(), + "regeneration drift at ({path:#x},{depth})" + ); } } } @@ -207,6 +215,9 @@ fn probe_phase1_full_permutation_for_every_offset() { assert!(!seen[v as usize], "residue {v} repeated at place {place}"); seen[v as usize] = true; } - assert!(seen.iter().all(|&s| s), "incomplete permutation at place {place}"); + assert!( + seen.iter().all(|&s| s), + "incomplete permutation at place {place}" + ); } } diff --git a/crates/lance-graph-contract/src/canonical_node.rs b/crates/lance-graph-contract/src/canonical_node.rs index 69b0ee5d..2538e428 100644 --- a/crates/lance-graph-contract/src/canonical_node.rs +++ b/crates/lance-graph-contract/src/canonical_node.rs @@ -99,6 +99,53 @@ impl NodeGuid { u32::from_le_bytes([self.0[13], self.0[14], self.0[15], 0]) } + /// HEEL — HHT cascade tier 1 (bytes 4..6, LE `u16`). + #[inline] + pub const fn heel(&self) -> u16 { + u16::from_le_bytes([self.0[4], self.0[5]]) + } + + /// HIP — HHT cascade tier 2 (bytes 6..8, LE `u16`). + #[inline] + pub const fn hip(&self) -> u16 { + u16::from_le_bytes([self.0[6], self.0[7]]) + } + + /// TWIG — HHT cascade tier 3 (bytes 8..10, LE `u16`). + #[inline] + pub const fn twig(&self) -> u16 { + u16::from_le_bytes([self.0[8], self.0[9]]) + } + + /// Decode the whole key in one read — every canon group as its native + /// LE-decoded integer. This is the "read the GUID as a GUID" surface: a + /// consumer or OGAR gets `classid + HHT (HEEL/HIP/TWIG) + family + identity` + /// from one call instead of re-deriving each group from raw bytes. The six + /// fields ARE the canon print order — nothing invented, nothing dropped (cf. + /// [`Display`](NodeGuid#impl-Display-for-NodeGuid), which renders the same six). + #[inline] + pub const fn decode(&self) -> GuidParts { + GuidParts { + classid: self.classid(), + heel: self.heel(), + hip: self.hip(), + twig: self.twig(), + family: self.family(), + identity: self.identity(), + } + } + + /// The [`ReadMode`] this node's `classid` resolves to — which value tenants + /// to materialise + how to read the edge block. The carrier-method form (the + /// object speaks for itself): a consumer reads `guid.read_mode()`, OGAR reads + /// [`classid_read_mode`]`(guid.classid())`; both inherit the SAME answer from + /// the one [`LazyLock`] registry, so the LE interpretation of the node's bytes + /// is single-sourced. Not `const` — it consults the runtime registry. + #[inline] + pub fn read_mode(&self) -> ReadMode { + classid_read_mode(self.classid()) + } + /// Basin-local key: trailing 6 bytes (family ++ identity), zero-padded to u64. /// After an HHTL radix walk has bound classid+HEEL+HIP+TWIG, this is the only /// part that still discriminates — a single masked load, no gather. @@ -147,6 +194,30 @@ impl NodeGuid { } } +/// The whole canonical key decoded in one shot — `classid · HEEL · HIP · TWIG · +/// family · identity`, each as its native LE-decoded integer. +/// +/// This is the "read the GUID as a GUID and return classid + HHT + Leaf + +/// identity" contract: one decode, six fields, in canon print order. It invents +/// nothing — it is exactly [`NodeGuid::decode`] of the existing 16-byte key, the +/// same six groups [`NodeGuid`]'s `Display` renders. `family` is the basin +/// "Leaf" and `family ++ identity` is the trailing-6-byte [`NodeGuid::local_key`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct GuidParts { + /// 0..4 — prefix-routable class id (default `0x0000_0000`). + pub classid: u32, + /// 4..6 — HEEL (HHT cascade tier 1). + pub heel: u16, + /// 6..8 — HIP (HHT cascade tier 2). + pub hip: u16, + /// 8..10 — TWIG (HHT cascade tier 3). + pub twig: u16, + /// 10..13 — family (u24, the basin "Leaf"). + pub family: u32, + /// 13..16 — identity (u24). + pub identity: u32, +} + /// Canonical self-describing print: `classid-HEEL-HIP-TWIG-family·identity`. /// /// The dash-groups ARE the semantic delimiters — every printed GUID is @@ -155,16 +226,13 @@ impl NodeGuid { /// order (the field accessors fold LE bytes into u32/u16/u24 first). impl core::fmt::Display for NodeGuid { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - let h = u16::from_le_bytes([self.0[4], self.0[5]]); - let p = u16::from_le_bytes([self.0[6], self.0[7]]); - let t = u16::from_le_bytes([self.0[8], self.0[9]]); write!( f, "{:08x}-{:04x}-{:04x}-{:04x}-{:06x}{:06x}", self.classid(), - h, - p, - t, + self.heel(), + self.hip(), + self.twig(), self.family(), self.identity(), ) @@ -261,6 +329,8 @@ const _: () = assert!(core::mem::size_of::() == 512); use crate::class_view::FieldMask; use crate::soa_envelope::{ColumnDescriptor, ColumnKind, SoaEnvelope}; +use std::collections::HashMap; +use std::sync::LazyLock; /// Stable column-id ordinals for [`NodeRow`]'s three top-level slots. /// `name_id` in the [`ColumnDescriptor`] table; the registry-resolved value @@ -330,7 +400,9 @@ pub enum ValueTenant { MaterializedEdges = 2, /// `Fingerprint<256>` — 32-byte identity print. Fingerprint = 3, - /// helix golden-spiral Place/Residue (48 B). + /// helix golden-spiral Place/Residue — signed full-sphere `Signed360`, + /// 48-bit = 6 B (2× the 24-bit equal-area hemisphere; produced by the `helix` + /// crate's `Signed360`, written here zero-copy). HelixResidue = 4, /// turbovec PQ residue ([`EdgeCodecFlavor::Pq32x4`], 16 B). TurbovecResidue = 5, @@ -342,6 +414,24 @@ pub enum ValueTenant { EntityType = 8, } +impl ValueTenant { + /// This tenant's byte offset **within the 480-byte value slab** (its row + /// offset minus [`VALUE_SLAB_ROW_OFFSET`]). The companion to its + /// [`VALUE_TENANTS`] descriptor — lets a transcode write into + /// [`NodeRow::value`] without hardcoding the carve. Not a new property: a + /// derived accessor over the already-locked, compile-asserted carve. + #[inline] + pub const fn value_offset(self) -> usize { + VALUE_TENANTS[self as usize].row_offset as usize - VALUE_SLAB_ROW_OFFSET + } + + /// This tenant's byte length in the slab (from its [`VALUE_TENANTS`] descriptor). + #[inline] + pub const fn byte_len(self) -> usize { + VALUE_TENANTS[self as usize].col_bytes_per_row() + } +} + /// Stable byte carve of the value slab. Offsets are **row-relative** (within one /// row packet, in the value region `[32, 512)`) — consistent with /// [`NODE_ROW_COLUMNS`], one level finer. Contiguous, in [`ValueTenant`] @@ -376,32 +466,34 @@ pub const VALUE_TENANTS: &[ColumnDescriptor] = &[ ColumnDescriptor { name_id: ValueTenant::HelixResidue as u16, kind: ColumnKind::U8, - elems_per_row: 48, + // 6 B = 48 bit = 2× the 24-bit equal-area hemisphere (helix `Signed360`, + // signed full sphere). Was 48 B — a bits→bytes slip; right-sized 2026-06-15. + elems_per_row: 6, row_offset: 112, }, ColumnDescriptor { name_id: ValueTenant::TurbovecResidue as u16, kind: ColumnKind::U8, elems_per_row: 16, - row_offset: 160, + row_offset: 118, }, ColumnDescriptor { name_id: ValueTenant::Energy as u16, kind: ColumnKind::F32, elems_per_row: 1, - row_offset: 176, + row_offset: 134, }, ColumnDescriptor { name_id: ValueTenant::Plasticity as u16, kind: ColumnKind::U32, elems_per_row: 1, - row_offset: 180, + row_offset: 138, }, ColumnDescriptor { name_id: ValueTenant::EntityType as u16, kind: ColumnKind::U16, elems_per_row: 1, - row_offset: 184, + row_offset: 142, }, ]; @@ -454,8 +546,8 @@ pub enum ValueSchema { /// Hot self-thinking set: Meta + Qualia + Fingerprint + Energy + Plasticity + /// EntityType. No materialised edges, no codec residues. Cognitive = 1, - /// Cold / compressed codec stack: Fingerprint + Helix-48 + turbovec residue + - /// EntityType. No hot lifecycle columns. + /// Cold / compressed codec stack: Fingerprint + Helix `Signed360` (6 B) + + /// turbovec residue + EntityType. No hot lifecycle columns. Compressed = 2, /// Every [`ValueTenant`] materialised — the densest node. Full = 3, @@ -529,6 +621,80 @@ const _: () = assert!(ValueSchema::Full.tenant_bytes() <= VALUE_SLAB_LEN); const _: () = assert!(ValueSchema::Full.field_mask().count() as usize == VALUE_TENANTS.len()); const _: () = assert!(ValueSchema::Bootstrap.field_mask().is_empty()); +// ── classid → read-mode: the LE contract both the consumer and OGAR inherit ──── + +/// The **read mode** a `classid` resolves to: the pair of *already-existing* +/// read-mode axes — [`ValueSchema`] (which value tenants to materialise) and +/// [`EdgeCodecFlavor`] (how to read the 16-byte edge block). +/// +/// It is NOT a new node property and NOT a SoA column — nothing is stored on the +/// row. This is the *resolution result* (the lens): the value-side analog of +/// "which XSD parses this document". §0 anti-invention — it bundles the two +/// read-mode enums that already exist, adding zero new fields to the node. +/// +/// Both consumers and OGAR resolve `classid → ReadMode` through the one +/// [`LazyLock`] registry ([`classid_read_mode`]), so the LE interpretation of a +/// node's bytes is single-sourced: a consumer transcoding a [`NodeRow`] and OGAR +/// minting/projecting the same class read the identical schema. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct ReadMode { + /// Which value-slab tenants this class materialises. + pub value_schema: ValueSchema, + /// How this class reads its 16-byte edge block. + pub edge_codec: EdgeCodecFlavor, +} + +impl ReadMode { + /// The zero-fallback / POC default an *unconfigured* classid resolves to. + /// + /// **TEMPORARY (2026-06-15 POC):** `value_schema = Full` mirrors the + /// [`ClassView::value_schema`](crate::class_view::ClassView::value_schema) + /// POC default so an unconfigured class materialises the whole slab for + /// transcode; `edge_codec = CoarseOnly` is the canon zero-fallback edge + /// reading. When the POC ends, flip `value_schema` back to + /// [`ValueSchema::Bootstrap`] HERE and in `ClassView` together (one revert, + /// two sites — the test `read_mode_default_is_full_poc` guards the pairing). + pub const DEFAULT: ReadMode = ReadMode { + value_schema: ValueSchema::Full, + edge_codec: EdgeCodecFlavor::CoarseOnly, + }; + + /// Both axes are layout-preserving (a preset/flavor re-interprets reserved + /// bytes, never a stride change), so adopting any read-mode needs no + /// `ENVELOPE_LAYOUT_VERSION` bump. + #[inline] + pub const fn is_layout_preserving(self) -> bool { + self.value_schema.is_layout_preserving() && self.edge_codec.is_layout_preserving() + } +} + +/// Builtin `classid → ReadMode` registry, built once on first use. +/// +/// Immutable after init — the canon "already-immutable ontology registry" shape, +/// the same [`LazyLock`] pattern `lance-graph-ontology` uses for its seed +/// namespace registry. Holds only the canon builtins; a minted class's read-mode +/// is layered in by OGAR one level up. Any classid NOT in the map falls through +/// to [`ReadMode::DEFAULT`] — the same zero-fallback ladder as the key itself +/// (`classid 0 ⇒ default class`). +static BUILTIN_READ_MODES: LazyLock> = LazyLock::new(|| { + let mut m = HashMap::new(); + // The canon default class materialises the POC-Full slab (see ReadMode::DEFAULT). + m.insert(NodeGuid::CLASSID_DEFAULT, ReadMode::DEFAULT); + m +}); + +/// Resolve a `classid` to its [`ReadMode`] — the single source both consumers +/// and OGAR inherit. Reads the [`BUILTIN_READ_MODES`] registry, falling through +/// to [`ReadMode::DEFAULT`] for any unconfigured classid (the key's own +/// zero-fallback ladder). [`NodeGuid::read_mode`] is the carrier-method form. +#[inline] +pub fn classid_read_mode(classid: u32) -> ReadMode { + BUILTIN_READ_MODES + .get(&classid) + .copied() + .unwrap_or(ReadMode::DEFAULT) +} + /// Zero-copy [`SoaEnvelope`] wrapper over a contiguous slice of [`NodeRow`]. /// /// `NodeRow` is `#[repr(C, align(64))]` with the locked 16/16/480 byte @@ -899,8 +1065,8 @@ mod tests { assert!(prev_end <= NODE_ROW_STRIDE); assert_eq!( prev_end - VALUE_SLAB_ROW_OFFSET, - 154, - "current Full carve uses 154 of 480 B" + 112, + "current Full carve uses 112 of 480 B (helix right-sized 48→6)" ); assert!(prev_end - VALUE_SLAB_ROW_OFFSET <= VALUE_SLAB_LEN); } @@ -935,8 +1101,8 @@ mod tests { fn value_schema_byte_budgets_are_locked() { assert_eq!(ValueSchema::Bootstrap.tenant_bytes(), 0); assert_eq!(ValueSchema::Cognitive.tenant_bytes(), 58); - assert_eq!(ValueSchema::Compressed.tenant_bytes(), 98); - assert_eq!(ValueSchema::Full.tenant_bytes(), 154); + assert_eq!(ValueSchema::Compressed.tenant_bytes(), 56); + assert_eq!(ValueSchema::Full.tenant_bytes(), 112); for s in [ ValueSchema::Bootstrap, ValueSchema::Cognitive, @@ -971,4 +1137,91 @@ mod tests { assert_eq!(NODE_ROW_STRIDE, core::mem::size_of::()); assert_eq!(VALUE_SLAB_ROW_OFFSET + VALUE_SLAB_LEN, NODE_ROW_STRIDE); } + + // ── GUID decode + classid → read-mode (the keystone) ───────────────────── + + #[test] + fn decode_returns_all_six_canon_groups() { + // One read yields classid + HHT (HEEL/HIP/TWIG) + family + identity, in + // canon print order — the "read the GUID as a GUID" contract. + let g = NodeGuid::new(0xDEAD_BEEF, 0x1111, 0x2222, 0x3333, 0x00_00AB, 0x00_00CD); + let p = g.decode(); + assert_eq!(p.classid, 0xDEAD_BEEF); + assert_eq!(p.heel, 0x1111); + assert_eq!(p.hip, 0x2222); + assert_eq!(p.twig, 0x3333); + assert_eq!(p.family, 0x00_00AB); + assert_eq!(p.identity, 0x00_00CD); + // decode() is exactly the field accessors, no field invented/dropped. + assert_eq!(p.classid, g.classid()); + assert_eq!(p.family, g.family()); + assert_eq!(p.identity, g.identity()); + } + + #[test] + fn hht_accessors_match_display_groups() { + // The new HEEL/HIP/TWIG accessors fold the same LE bytes Display renders. + let g = NodeGuid::new(0xDEAD_BEEF, 0xA1B2, 0xC3D4, 0xE5F6, 0x12_3456, 0x78_9ABC); + assert_eq!(g.heel(), 0xA1B2); + assert_eq!(g.hip(), 0xC3D4); + assert_eq!(g.twig(), 0xE5F6); + // Display's middle three groups are exactly heel-hip-twig in hex. + let s = g.to_string(); + let groups: Vec<&str> = s.split('-').collect(); + assert_eq!(groups[1], format!("{:04x}", g.heel())); + assert_eq!(groups[2], format!("{:04x}", g.hip())); + assert_eq!(groups[3], format!("{:04x}", g.twig())); + } + + #[test] + fn read_mode_default_is_full_poc() { + // The default classid resolves to the POC read-mode: Full value slab + + // CoarseOnly edges. This GUARDS the ClassView pairing — ReadMode::DEFAULT + // .value_schema MUST equal the ClassView POC default (Full). When the POC + // ends, both flip to Bootstrap together and this test flips with them. + let rm = classid_read_mode(NodeGuid::CLASSID_DEFAULT); + assert_eq!(rm, ReadMode::DEFAULT); + assert_eq!(rm.value_schema, ValueSchema::Full); + assert_eq!(rm.edge_codec, EdgeCodecFlavor::CoarseOnly); + assert!(rm.is_layout_preserving()); + } + + #[test] + fn read_mode_zero_fallback_for_unconfigured_classid() { + // Any classid NOT in the builtin registry falls through to DEFAULT — the + // key's own zero-fallback ladder (classid 0 ⇒ default class), extended to + // read-mode resolution. + assert_eq!(classid_read_mode(0xDEAD_BEEF), ReadMode::DEFAULT); + assert_eq!(classid_read_mode(0x0000_0001), ReadMode::DEFAULT); + assert_eq!(classid_read_mode(u32::MAX), ReadMode::DEFAULT); + } + + #[test] + fn guid_read_mode_method_delegates_to_registry() { + // The carrier method (guid.read_mode()) and the free resolver + // (classid_read_mode(classid)) are the SAME answer — consumer and OGAR + // inherit one source. + let g = NodeGuid::new(0xCAFE_BABE, 1, 2, 3, 0x00_0001, 0x00_0002); + assert_eq!(g.read_mode(), classid_read_mode(g.classid())); + // A default-class node reads the Full POC slab. + assert_eq!(NodeGuid::local(0x00_00CD).read_mode(), ReadMode::DEFAULT); + } + + #[test] + fn default_class_node_materialises_full_slab() { + // End-to-end connect: a bootstrap NodeRow → its classid resolves to Full → + // the Full preset covers every tenant and uses the locked 112-byte carve. + let row = sample_row(NodeGuid::CLASSID_DEFAULT, 0x00_00CD); + let rm = row.key.read_mode(); + assert_eq!(rm.value_schema, ValueSchema::Full); + assert_eq!( + rm.value_schema.field_mask().count() as usize, + VALUE_TENANTS.len(), + "Full read-mode materialises every value tenant" + ); + assert_eq!(rm.value_schema.tenant_bytes(), 112); + // The slab has room (112 ≤ 480) and the choice never grows the stride. + assert!(rm.value_schema.tenant_bytes() <= VALUE_SLAB_LEN); + assert!(rm.is_layout_preserving()); + } } diff --git a/crates/lance-graph-contract/src/lib.rs b/crates/lance-graph-contract/src/lib.rs index a881e84d..70ac68fe 100644 --- a/crates/lance-graph-contract/src/lib.rs +++ b/crates/lance-graph-contract/src/lib.rs @@ -107,7 +107,8 @@ pub mod world_model; // Re-exports for the most commonly used collapse_gate types. pub use canonical_node::{ - EdgeBlock, EdgeCodecFlavor, NodeGuid, NodeRow, ValueSchema, ValueTenant, VALUE_TENANTS, + classid_read_mode, EdgeBlock, EdgeCodecFlavor, GuidParts, NodeGuid, NodeRow, ReadMode, + ValueSchema, ValueTenant, VALUE_TENANTS, }; pub use class_view::{ClassId, ClassProjection, ClassView, FieldMask, RenderRow}; pub use collapse_gate::{GateDecision, MailboxId, MergeMode}; diff --git a/crates/lance-graph-contract/src/ocr.rs b/crates/lance-graph-contract/src/ocr.rs index 0fe071b8..d6d69d8b 100644 --- a/crates/lance-graph-contract/src/ocr.rs +++ b/crates/lance-graph-contract/src/ocr.rs @@ -1,5 +1,8 @@ //! OCR contract. Zero-dep. +use crate::canonical_node::{ + classid_read_mode, EdgeBlock, NodeGuid, NodeRow, ValueTenant, VALUE_SLAB_LEN, +}; use core::future::Future; pub trait OcrProvider: Send + Sync { @@ -51,9 +54,182 @@ pub enum BlockKind { Other, } +impl BlockKind { + /// Stable OGIT entity-type discriminant for this block kind — the value + /// written into the [`ValueTenant::EntityType`] slot. Append-only (a mini + /// class table); `0` is the `Other`/unknown sentinel (matches the registry's + /// "id 0 is unknown" convention). Reusing the existing EntityType tenant — + /// no invented "ocr_kind" property (§0 anti-invention). + pub const fn entity_type(self) -> u16 { + match self { + BlockKind::Other => 0, + BlockKind::Text => 1, + BlockKind::Heading => 2, + BlockKind::Table => 3, + BlockKind::Figure => 4, + BlockKind::Signature => 5, + BlockKind::Stamp => 6, + } + } +} + pub struct LayoutBlock<'a> { pub kind: BlockKind, pub bbox: Bbox, pub text: &'a str, pub confidence: f32, } + +impl<'a> LayoutBlock<'a> { + /// Transcode one OCR layout block into a canonical SoA [`NodeRow`] — the + /// keystone end-to-end, the reference transcode any [`OcrProvider`] + /// (tesseract-rs and others) reuses. + /// + /// `classid` resolves (via [`classid_read_mode`]) to the [`ReadMode`] that + /// says WHICH value tenants to materialise; this writes only the tenants the + /// OCR block populates AND the resolved schema includes, each at its canon + /// byte offset ([`ValueTenant::value_offset`]): + /// - [`ValueTenant::EntityType`] ← [`BlockKind::entity_type`] (the semantic class). + /// - [`ValueTenant::Energy`] ← `confidence` (POC: the OCR confidence seeded as + /// the node's `f32` energy scalar; a Qualia *certainty* channel is the + /// richer follow-up). + /// + /// **`text` and `bbox` are NOT bundled into the node** (`I-VSA-IDENTITIES`: + /// the node carries identity + typed scalars; the recognised string and pixel + /// geometry live in an external content store keyed by `identity`). The node + /// is the *identity that points to* the OCR content, never the content's + /// register. + /// + /// [`ReadMode`]: crate::canonical_node::ReadMode + pub fn to_node_row(&self, classid: u32, identity: u32) -> NodeRow { + let schema = classid_read_mode(classid).value_schema; + let mut value = [0u8; VALUE_SLAB_LEN]; + + if schema.has(ValueTenant::EntityType) { + let o = ValueTenant::EntityType.value_offset(); + value[o..o + 2].copy_from_slice(&self.kind.entity_type().to_le_bytes()); + } + if schema.has(ValueTenant::Energy) { + let o = ValueTenant::Energy.value_offset(); + value[o..o + 4].copy_from_slice(&self.confidence.to_le_bytes()); + } + + NodeRow { + // HHT unbound (0) and default basin for the POC — only `identity` + // discriminates (the canon bootstrap address); `classid` still selects + // the read-mode. Minting HEEL/HIP/TWIG + family is the OGAR follow-up. + key: NodeGuid::new(classid, 0, 0, 0, NodeGuid::FAMILY_DEFAULT, identity), + edges: EdgeBlock::default(), + value, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::canonical_node::{NodeRowPacket, ValueSchema}; + use crate::soa_envelope::SoaEnvelope; + + fn heading(text: &str, conf: f32) -> LayoutBlock<'_> { + LayoutBlock { + kind: BlockKind::Heading, + bbox: Bbox { + x: 10, + y: 20, + w: 100, + h: 30, + }, + text, + confidence: conf, + } + } + + #[test] + fn block_kind_entity_types_are_stable_and_other_is_zero() { + // 0 is the unknown sentinel (registry convention); the rest are distinct. + assert_eq!(BlockKind::Other.entity_type(), 0); + let ids = [ + BlockKind::Text.entity_type(), + BlockKind::Heading.entity_type(), + BlockKind::Table.entity_type(), + BlockKind::Figure.entity_type(), + BlockKind::Signature.entity_type(), + BlockKind::Stamp.entity_type(), + ]; + for (i, a) in ids.iter().enumerate() { + assert_ne!(*a, 0, "non-Other kind must not be the sentinel"); + for b in &ids[i + 1..] { + assert_ne!(a, b, "entity types must be distinct"); + } + } + } + + #[test] + fn layout_block_transcodes_to_node_row_via_keystone() { + let blk = heading("Invoice", 0.97); + let row = blk.to_node_row(NodeGuid::CLASSID_DEFAULT, 0x00_0042); + + // Identity is preserved in the key; classid selects the read-mode. + assert_eq!(row.key.identity(), 0x00_0042); + assert_eq!(row.key.classid(), NodeGuid::CLASSID_DEFAULT); + assert!(row.key.is_bootstrap_address()); + + // The keystone: classid → read-mode → Full (POC) → tenants materialised. + let rm = row.key.read_mode(); + assert_eq!(rm.value_schema, ValueSchema::Full); + + // EntityType ← BlockKind::Heading (2), at its canon slab offset. + let o = ValueTenant::EntityType.value_offset(); + assert_eq!(u16::from_le_bytes([row.value[o], row.value[o + 1]]), 2); + + // Energy ← OCR confidence 0.97, at its canon slab offset. + let o = ValueTenant::Energy.value_offset(); + let energy = f32::from_le_bytes(row.value[o..o + 4].try_into().unwrap()); + assert!((energy - 0.97).abs() < 1e-6, "energy ← confidence"); + } + + #[test] + fn transcoded_row_packs_zero_copy_through_envelope() { + // The transcoded row is a plain NodeRow → it rides the SoaEnvelope with + // no special-casing (512-byte stride, zero-copy LE view, verifies). + let rows = [ + heading("Invoice", 0.9).to_node_row(NodeGuid::CLASSID_DEFAULT, 1), + heading("Total", 0.8).to_node_row(NodeGuid::CLASSID_DEFAULT, 2), + ]; + let pkt = NodeRowPacket::new(&rows, 0); + assert_eq!(pkt.n_rows(), 2); + assert_eq!(pkt.as_le_bytes().len(), 2 * 512); + assert_eq!( + pkt.as_le_bytes().as_ptr() as usize, + rows.as_ptr() as usize, + "transcoded rows pack zero-copy" + ); + assert!(pkt.verify_layout().is_ok()); + } + + #[test] + fn transcode_is_schema_gated_only_present_tenants_written() { + // The transcode honors the resolved schema: it writes a tenant ONLY if + // the read-mode includes it. Under the POC Full default both EntityType + // and Energy are present, so both slots are populated; tenants the schema + // omits would stay zero. (No classid resolves to Bootstrap today — when + // one is minted, the same `schema.has()` gate leaves its slab empty.) + let row = heading("x", 1.0).to_node_row(NodeGuid::CLASSID_DEFAULT, 7); + let schema = row.key.read_mode().value_schema; + assert!(schema.has(ValueTenant::EntityType) && schema.has(ValueTenant::Energy)); + let et = ValueTenant::EntityType.value_offset(); + assert_ne!( + row.value[et], 0, + "present EntityType is written (Heading=2)" + ); + // A slab byte that belongs to NO tenant the transcode writes stays zero + // (e.g. the Meta tenant at offset 0 — present in Full but the OCR transcode + // doesn't populate it, so it remains the zero default). + assert_eq!( + row.value[ValueTenant::Meta.value_offset()], + 0, + "a tenant the transcode doesn't populate stays zero" + ); + } +} diff --git a/crates/lance-graph-contract/src/soa_envelope.rs b/crates/lance-graph-contract/src/soa_envelope.rs index 6fe45bc2..e6a5ce5a 100644 --- a/crates/lance-graph-contract/src/soa_envelope.rs +++ b/crates/lance-graph-contract/src/soa_envelope.rs @@ -44,7 +44,14 @@ /// changes. A reader MUST refuse to decode a packet whose stamped version it /// does not understand (per `I-LEGACY-API-FEATURE-GATED`: layout reclaim is /// paired with a version gate on the serialization path). -pub const ENVELOPE_LAYOUT_VERSION: u8 = 1; +/// +/// - **v1** — initial canonical `NodeRow` value carve. +/// - **v2** — `HelixResidue` value-tenant right-sized 48 B → 6 B (a bits→bytes +/// slip fix), which shifted every downstream tenant offset (`TurbovecResidue` +/// 160→118, `Energy` 176→134, …). The offsets moved, so the version gates it: +/// a v1 blob now refuses to decode rather than read tenants from the wrong +/// bytes. Safe because nothing persisted under v1 (FULL is POC-only). +pub const ENVELOPE_LAYOUT_VERSION: u8 = 2; /// The little-endian element type of one column. ///