From fd1302fccaddcdc761a9586e055604ec76979f18 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 13:20:09 +0000 Subject: [PATCH 1/2] feat(contract): ReadMode tail_variant + OSINT-V3 high-u16 gen-marker (P-A) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the single classid→ReadMode dispatch with a third axis tail_variant {V1,V2,V3} resolved by classid_read_mode(), mirroring OGAR #128's classid → {tail_variant, value_schema, edge_codec} envelope parser. Never a public new_v3 — the registry field IS the mechanism, symmetric with value_schema. Additive / default-V1 / non-breaking (nothing reads tail_variant yet). DEFAULT + OSINT/FMA/PROJECT/ERP = V1 (L1, no corpus re-mint). Structural parity fuse names all three axes vs the #128 canon. OSINT-V3 is the first wired V3 class (gated `guid-v3-tail`): CLASSID_OSINT_V3 = 0x1000_0700 — the generation marker sits in the HIGH (custom) u16, leaving the canon LOW u16 (0xDDCC) untouched, so classid_concept_domain (which masks `classid as u16`) still routes ConceptDomain::Osint. This is the Codex-P1 fix: the rejected low-half 0x0000_1007 read domain 0x10 = Unassigned. No router flip — the live Custom:Canon byte order is preserved. OSINT_V3 ReadMode = {V3, Cognitive, CoarseOnly} (same value model as OSINT, V3 tail). Gated test proves BOTH facts: tail_variant == V3 AND classid_concept_domain(0x1000_0700) == Osint. FMA-V3 (0x1000_0A01) + Genetics (domain TBD — 0x0D is HR, not Genetics) deferred to a follow-up. Plan: .claude/plans/soa-value-tenant-migration-v2.md §2.1/§2.2. Co-Authored-By: Claude Opus 4.6 Claude-Session: https://claude.ai/code/session_01TzqvDqbFRzyx17EkLKBoZF --- crates/lance-graph-contract/Cargo.toml | 9 + .../src/canonical_node.rs | 198 +++++++++++++++++- 2 files changed, 199 insertions(+), 8 deletions(-) diff --git a/crates/lance-graph-contract/Cargo.toml b/crates/lance-graph-contract/Cargo.toml index 7ea09de7..5c6e4d45 100644 --- a/crates/lance-graph-contract/Cargo.toml +++ b/crates/lance-graph-contract/Cargo.toml @@ -43,6 +43,15 @@ trajectory-audit = [] # I-LEGACY-API-FEATURE-GATED (field-isolation matrix + version gate). guid-v2-tail = [] +# guid-v3-tail (P-A, plan: soa-value-tenant-migration-v2.md §2.1/§2.2) — gates the +# V3 cascade-key per-classid entries. The V3 identity AXIS itself (TailVariant::V3 +# + ReadMode::tail_variant) is unconditional, latent (nothing reads tail_variant +# yet) → non-breaking. The gated entries pin the generation marker in the HIGH +# (custom) u16, preserving the canon LOW-u16 0xDDCC domain byte (so +# `classid_concept_domain` still routes the legacy domain). OSINT-V3 (0x1000_0700) +# is the wired exemplar; FMA-V3 (0x1000_0A01) + Genetics follow. Default OFF. +guid-v3-tail = [] + # tenant-counters — per-ValueTenant update counters for debug instrumentation of # the SoA write cascade (the capstone NaN-census / seam-wiring measurement). OFF # by default: `tenant_counter::tenant_update` is a zero-cost no-op unless enabled diff --git a/crates/lance-graph-contract/src/canonical_node.rs b/crates/lance-graph-contract/src/canonical_node.rs index ef65c188..7c73594d 100644 --- a/crates/lance-graph-contract/src/canonical_node.rs +++ b/crates/lance-graph-contract/src/canonical_node.rs @@ -71,6 +71,25 @@ impl NodeGuid { /// partners, payments, …). OGAR codebook `0x02XX`. Resolves to [`ReadMode::ERP`]. pub const CLASSID_ERP: u32 = 0x0000_0200; + // ── V3 cascade-key classids (feature `guid-v3-tail`) ─────────────────────── + // The V3 generation marker lives in the HIGH (custom) u16, leaving the canon + // LOW u16 untouched, because the live contract routes domain on `classid as + // u16` (`0xDDCC`; CLASSID_OSINT = 0x0700, CLASSID_FMA = 0x0A01). So + // `classid_concept_domain` masks the marker off and still routes the legacy + // domain — the Codex-P1 fix vs the rejected low-half `0x1007` (which read + // domain 0x10 = Unassigned). OSINT-V3 (`0x1000_0700`) is the WIRED exemplar; + // FMA-V3 (`0x1000_0A01`) + Genetics (domain TBD — `0x0D` is HR, not Genetics) + // follow once their value models are pinned. + + /// **OSINT-V3** — OSINT on a [`TailVariant::V3`] cascade tail. The generation + /// marker `0x1000` sits in the HIGH/custom u16; the canon `0x0700` is preserved + /// in the LOW u16, so [`classid_concept_domain`](crate::ogar_codebook::classid_concept_domain) + /// still routes [`Osint`](crate::ogar_codebook::ConceptDomain::Osint) (it masks + /// `classid as u16` → low half) — unlike the rejected low-half `0x0000_1007`, + /// which read domain `0x10` = `Unassigned`. Resolves to [`ReadMode::OSINT_V3`]. + #[cfg(feature = "guid-v3-tail")] + pub const CLASSID_OSINT_V3: u32 = 0x1000_0700; + /// Construct from the six canonical groups. `family`/`identity` use their low 3 bytes. /// /// Panics (incl. const-eval) when `family` or `identity` exceed 24 bits — the @@ -798,14 +817,61 @@ 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). +/// Which tail / identity shape a class uses to *read* its node's 16-byte key — +/// the key-side analog of [`ValueSchema`] (value slab) and [`EdgeCodecFlavor`] +/// (edge block). It is the THIRD axis of OGAR #128's reusable envelope parser +/// (`E-CLASSID-ENVELOPE-PARSER`): `classid → {tail_variant, value_schema, +/// edge_codec}`, resolved by the SAME [`classid_read_mode`] registry, so the +/// key-side read is symmetric with the value-side (minting consults +/// `tail_variant`; [`NodeRow`] value transcode consults `value_schema`). +/// +/// **Layout-preserving:** every variant re-interprets the SAME 16 key bytes — +/// `family·identity` vs `leaf·family·identity` vs the cascade `(part_of:is_a)` +/// tile are all readings of bytes 10..16, never a stride change (no +/// `ENVELOPE_LAYOUT_VERSION` bump — canon "registry-resolved via +/// `classid → ClassView`"). [`V1`] is the zero-fallback default: the canonical +/// original tail that [`NodeGuid::new`] mints. +/// +/// [`V1`]: TailVariant::V1 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +#[repr(u8)] +pub enum TailVariant { + /// The canonical original tail `family(u24)·identity(u24)` (bytes 10..16) — + /// what [`NodeGuid::new`] mints. The canon zero-fallback default. + #[default] + V1 = 0, + /// The v2 basin tail `leaf(u16)·family(u16)·identity(u16)` — what + /// [`NodeGuid::new_v2`] mints (feature `guid-v2-tail`, leaf as the 4th HHTL + /// tier). + V2 = 1, + /// The new-generation `cascade_key` tail: the `(part_of:is_a)` 8:8 tile (the + /// two-axis cascade key). Feature `guid-v3-tail`. + V3 = 2, +} + +impl TailVariant { + /// Every variant re-interprets the SAME 16 key bytes (the tail is a reading + /// of bytes 10..16, never a stride change), so no variant forces an + /// `ENVELOPE_LAYOUT_VERSION` bump — the canon invariant, encoded so a + /// regression test can assert it. + #[inline] + pub const fn is_layout_preserving(self) -> bool { + true + } +} + +/// The **read mode** a `classid` resolves to: the trio of *already-existing* +/// read-mode axes — [`TailVariant`] (which key/identity shape to read), +/// [`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 +/// "which XSD parses this document". §0 anti-invention — it bundles the three /// read-mode enums that already exist, adding zero new fields to the node. +/// There is NO public `new_v3` dispatch — the [`tail_variant`](ReadMode::tail_variant) +/// registry field IS the mechanism, symmetric with +/// [`value_schema`](ReadMode::value_schema). /// /// Both consumers and OGAR resolve `classid → ReadMode` through the one /// [`LazyLock`] registry ([`classid_read_mode`]), so the LE interpretation of a @@ -813,6 +879,10 @@ const _: () = assert!(ValueSchema::Bootstrap.field_mask().is_empty()); /// minting/projecting the same class read the identical schema. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct ReadMode { + /// Which tail / identity shape this class reads from its 16-byte key (the + /// FIRST axis — resolved upstream of the value slab per OGAR #128's parse + /// order: `classid → {tail_variant, value_schema, edge_codec}`). + pub tail_variant: TailVariant, /// Which value-slab tenants this class materialises. pub value_schema: ValueSchema, /// How this class reads its 16-byte edge block. @@ -822,6 +892,14 @@ pub struct ReadMode { impl ReadMode { /// The zero-fallback / POC default an *unconfigured* classid resolves to. /// + /// `tail_variant = V1` is the conservative legacy default (L1: every + /// un-minted classid stays [`TailVariant::V1`] → zero re-mint of the V1/V2 + /// corpus, RESERVE-DON'T-RECLAIM). It is **latent, not behavioural** here — + /// nothing reads `tail_variant` yet, so V1 is a pin, not a key reformat. The + /// precise per-classid legacy tail (e.g. an OSINT class that mints via + /// [`NodeGuid::new_v2`] = [`TailVariant::V2`]) is fixed when the reusable + /// envelope parser dispatches per classid — not by this conservative default. + /// /// **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 @@ -830,6 +908,7 @@ impl ReadMode { /// [`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 { + tail_variant: TailVariant::V1, value_schema: ValueSchema::Full, edge_codec: EdgeCodecFlavor::CoarseOnly, }; @@ -840,6 +919,7 @@ impl ReadMode { /// over [`EdgeCodecFlavor::CoarseOnly`] adjacency (the 12 in-family + 4 /// out-of-family slots read literally as the neo4j-emulation edges). pub const OSINT: ReadMode = ReadMode { + tail_variant: TailVariant::V1, value_schema: ValueSchema::Cognitive, edge_codec: EdgeCodecFlavor::CoarseOnly, }; @@ -849,6 +929,7 @@ impl ReadMode { /// Helix + Turbovec + EntityType; no hot lifecycle columns, it is static /// reference data) over [`EdgeCodecFlavor::CoarseOnly`] part-of adjacency. pub const FMA: ReadMode = ReadMode { + tail_variant: TailVariant::V1, value_schema: ValueSchema::Compressed, edge_codec: EdgeCodecFlavor::CoarseOnly, }; @@ -858,6 +939,7 @@ impl ReadMode { /// (live lifecycle: status / assignee / version edges queried + reasoned over) /// with [`EdgeCodecFlavor::CoarseOnly`] adjacency (parent / blocks / relates). pub const PROJECT: ReadMode = ReadMode { + tail_variant: TailVariant::V1, value_schema: ValueSchema::Cognitive, edge_codec: EdgeCodecFlavor::CoarseOnly, }; @@ -867,19 +949,46 @@ impl ReadMode { /// partners / payments queried live) with [`EdgeCodecFlavor::CoarseOnly`] /// adjacency (partner-of / line-of / paid-by). pub const ERP: ReadMode = ReadMode { + tail_variant: TailVariant::V1, + value_schema: ValueSchema::Cognitive, + edge_codec: EdgeCodecFlavor::CoarseOnly, + }; + + /// The **OSINT-V3** read-mode ([`NodeGuid::CLASSID_OSINT_V3`]): the same hot + /// [`ValueSchema::Cognitive`] value model as legacy [`OSINT`](ReadMode::OSINT), + /// read through the new-generation [`TailVariant::V3`] cascade tail. The wired + /// V3 exemplar; FMA-V3 + Genetics-V3 follow once their classids/value models + /// are pinned. + #[cfg(feature = "guid-v3-tail")] + pub const OSINT_V3: ReadMode = ReadMode { + tail_variant: TailVariant::V3, value_schema: ValueSchema::Cognitive, 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. + /// All three axes are layout-preserving (a tail-variant/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() + self.tail_variant.is_layout_preserving() + && self.value_schema.is_layout_preserving() + && self.edge_codec.is_layout_preserving() } } +/// Structural parity fuse — names all THREE [`ReadMode`] axes so a field +/// add/remove is a compile error. This is the structural-against-canon guard +/// vs OGAR #128's (`E-CLASSID-ENVELOPE-PARSER`) `{tail_variant, value_schema, +/// edge_codec}` tuple. OGAR #128 is doc-only today, so there is no runtime OGAR +/// struct to compare against yet; this upgrades to a runtime fuse when OGAR +/// codes its registry's `tail_variant`. +const _: ReadMode = ReadMode { + tail_variant: TailVariant::V1, + value_schema: ValueSchema::Bootstrap, + edge_codec: EdgeCodecFlavor::CoarseOnly, +}; + /// Builtin `classid → ReadMode` registry, built once on first use. /// /// Immutable after init — the canon "already-immutable ontology registry" shape, @@ -901,6 +1010,15 @@ static BUILTIN_READ_MODES: LazyLock> = LazyLock::new(|| { // the OGAR `0x01XX` / `0x02XX` domains; both hot business graphs (Cognitive). m.insert(NodeGuid::CLASSID_PROJECT, ReadMode::PROJECT); m.insert(NodeGuid::CLASSID_ERP, ReadMode::ERP); + // V3 cascade-key classes (feature `guid-v3-tail`): same value model as their + // legacy domain, on a TailVariant::V3 tail. OSINT-V3 (`0x1000_0700`) is the + // wired exemplar — the high-u16 gen-marker is masked off by the domain router, + // so `classid_concept_domain` still resolves Osint. FMA-V3 + Genetics-V3 land + // here once their classids/value models are pinned. + #[cfg(feature = "guid-v3-tail")] + { + m.insert(NodeGuid::CLASSID_OSINT_V3, ReadMode::OSINT_V3); + } m }); @@ -1802,6 +1920,70 @@ mod tests { assert!(project.is_layout_preserving() && erp.is_layout_preserving()); } + // ── ReadMode tail_variant (P-A) — the V3 identity axis ──────────────────── + + #[test] + fn read_mode_tail_variant_is_v1_legacy_default() { + // The default classid resolves to the conservative legacy tail (V1): every + // un-minted classid stays V1 → zero re-mint of the V1/V2 corpus (L1, + // RESERVE-DON'T-RECLAIM). V1 is latent (nothing reads tail_variant yet), + // and the whole DEFAULT read-mode stays layout-preserving across all three + // axes — adopting tail_variant never bumps ENVELOPE_LAYOUT_VERSION. + assert_eq!( + classid_read_mode(NodeGuid::CLASSID_DEFAULT).tail_variant, + TailVariant::V1 + ); + assert_eq!(ReadMode::DEFAULT.tail_variant, TailVariant::V1); + assert!(TailVariant::default() == TailVariant::V1); + assert!(TailVariant::V1.is_layout_preserving()); + assert!(ReadMode::DEFAULT.is_layout_preserving()); + // The mechanism axis exists for all three variants (V3 is the new-gen key). + // OSINT-V3 is the wired exemplar (see read_mode_osint_v3_routes_v3_tail_and_osint_domain); + // FMA-V3 + Genetics-V3 entries are DEFERRED until their classids are pinned. + assert!(TailVariant::V3.is_layout_preserving()); + assert!(TailVariant::V2.is_layout_preserving()); + } + + #[cfg(feature = "guid-v3-tail")] + #[test] + fn read_mode_osint_v3_routes_v3_tail_and_osint_domain() { + // The wired V3 exemplar proves BOTH facts at once — the whole point of the + // high-u16 gen-marker scheme: + // (1) the third axis IS the registry field — classid_read_mode resolves + // CLASSID_OSINT_V3 to TailVariant::V3 (never a public new_v3 dispatch); + // (2) the Codex-P1 fix — the gen-marker 0x1000 sits in the HIGH u16, so the + // domain router (which masks `classid as u16`) still resolves Osint, + // unlike the rejected low-half 0x1007 (which read domain 0x10). + assert_eq!( + classid_read_mode(NodeGuid::CLASSID_OSINT_V3).tail_variant, + TailVariant::V3 + ); + assert_eq!( + crate::ogar_codebook::classid_concept_domain(NodeGuid::CLASSID_OSINT_V3), + crate::ogar_codebook::ConceptDomain::Osint + ); + // The whole read-mode resolves to the V3 exemplar (same hot value model as + // legacy OSINT) and stays layout-preserving on all three axes. + assert_eq!( + classid_read_mode(NodeGuid::CLASSID_OSINT_V3), + ReadMode::OSINT_V3 + ); + assert_eq!(ReadMode::OSINT_V3.value_schema, ValueSchema::Cognitive); + assert!(ReadMode::OSINT_V3.is_layout_preserving()); + // Concretely: marker in the HIGH half, canon domain in the LOW half. + assert_eq!(NodeGuid::CLASSID_OSINT_V3, 0x1000_0700); + assert_eq!( + NodeGuid::CLASSID_OSINT_V3 >> 16, + 0x1000, + "gen-marker in high u16" + ); + assert_eq!( + NodeGuid::CLASSID_OSINT_V3 as u16, + NodeGuid::CLASSID_OSINT as u16, + "low u16 == canon OSINT concept (0x0700)" + ); + } + // ── GUID v2 tail (D-GV2-1) — field-isolation matrix + coexistence ───────── #[cfg(feature = "guid-v2-tail")] From 28c526427290d32e3744f5518ac0dafe4bb6cd0a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 15:16:11 +0000 Subject: [PATCH 2/2] feat(contract): 6-tier 8:8 FacetCascade + V3 (part_of:is_a) routing fold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves the Codex P2 on #613 (OSINT-V3 0x1000_0700 folded to EMPTY on the graph surface) and lands the operator-directed 6-tier homogeneous facet. Key-side routing (hhtl.rs / soa_graph.rs): - NiblePath::from_guid_prefix_v3 (feature guid-v3-tail) folds the 4 HHTL tiers HEEL·HIP·TWIG·LEAF in FULL — BOTH bytes per 8:8 tile (part_of high + is_a low), depth 16. classid is NOT folded, so a V3 classid's high-u16 generation marker never gates routing. - soa_graph::hhtl_path is now schema-driven: a V3 classid (tail_variant==V3) routes via from_guid_prefix_v3 -> non-empty path; every other classid keeps the v1 fold. Fixes the latent EMPTY-fold. - from_guid_prefix's "high u16 reserved-zero" doc + guard scoped to v1-fold; NOT a global classid law (V3 abolishes it by never folding classid). Value-side facet (canonical_node.rs): - FacetTier { lo, hi } (2B) + FacetCascade { facet_classid: u32, tiers: [FacetTier;6] } (16B = facet_classid(4) | 6x(8:8)=12, harvest 5.1). - ALWAYS 8:8, content-blind: only the consumer projects meaning (part_of:is_a / 256:256 palette centroid / group:member / column:row / concatenated u16 ...). as_u16 + morton (the 2bit x 2bit Morton-tile reading, the amortization benefit), hi_chain/lo_chain, hi_distance/lo_distance. - A reading over a borrowed [u8;16] - carries NO value-slab offset, so it does not touch the operator-LOCKED 480B layout (the classid->ClassView byte-pick is the separate, panel-gated step). Board hygiene: EPIPHANIES E-FACET-8-8-ALWAYS + LATEST_STATE Contract Inventory. 739 lib tests green (default 737 + guid-v3-tail), clippy -D warnings + fmt clean. Co-Authored-By: Claude Opus 4.6 Claude-Session: https://claude.ai/code/session_01TzqvDqbFRzyx17EkLKBoZF --- .claude/board/EPIPHANIES.md | 42 ++++ .claude/board/LATEST_STATE.md | 2 + .../src/canonical_node.rs | 234 ++++++++++++++++++ crates/lance-graph-contract/src/hhtl.rs | 128 +++++++++- crates/lance-graph-contract/src/soa_graph.rs | 18 +- 5 files changed, 411 insertions(+), 13 deletions(-) diff --git a/.claude/board/EPIPHANIES.md b/.claude/board/EPIPHANIES.md index f1f46b70..df023133 100644 --- a/.claude/board/EPIPHANIES.md +++ b/.claude/board/EPIPHANIES.md @@ -1,3 +1,45 @@ +## 2026-06-25 — E-FACET-8-8-ALWAYS — the homogeneous facet is ALWAYS 8:8 (content-blind, consumer-projected); it amortizes to a 2bit×2bit Morton tile cascade + +**Status:** FINDING `[H]` (operator-locked 2026-06-25; impl PR #613). Refines +`E-HOMOGENEITY-CLOSES-AS-CONTAINED-FACET`'s `place⊕search` framing to its general +form: the facet tier is **ALWAYS 8:8** — two opaque bytes `hi:lo` — and the +**producer bakes in NO meaning**; only the CONSUMER (the `facet_classid`'s ClassView) +projects it: `(part_of:is_a)`, a 256:256 palette/CAM-PQ centroid pair, a concatenated +`u16`, `(group:member)`, `(mixin:identity)`, `(column:row)`, `(Y:Z)`, … (AGI-as-glove: +the SoA is content-blind, the reader interprets). **Benefit (why ALWAYS-8:8 is the +right substrate):** every interpretation amortizes to ONE `2 bit × 2 bit` **Morton +tile cascade** — Morton-interleave `hi:lo` and each nibble is a quad-tree quadrant in +BOTH bytes at once (`256 = 4⁴` hierarchical ancestry), so hierarchical-prefix routing +is uniform regardless of meaning. (Operator analogy: chess bitboards / `shakmaty` / +Stockfish — one fixed bit substrate, many consumers; magic-bitboard + NNUE +index-into-quantized-table = the centroid reading.) + +**Contract (#613, `canonical_node.rs`):** `FacetTier { lo, hi }` (2 B, `as_u16` + +`morton` projections) + `FacetCascade { facet_classid: u32, tiers: [FacetTier; 6] }` +(16 B = `facet_classid(4) | 6×(8:8)=12`, harvest §5.1) — a *reading* over a borrowed +`[u8; 16]`, carrying NO value-slab offset (it does NOT touch the LOCKED 480 B layout; +the `classid → ClassView` byte-pick is the separate, panel-gated step). `hi_chain`/ +`lo_chain` + `hi_distance`/`lo_distance` are the two orthogonal prefix metrics. + +**Key-side V3 routing (#613, `hhtl.rs` / `soa_graph.rs`):** +`NiblePath::from_guid_prefix_v3` folds the 4 HHTL tiers `HEEL·HIP·TWIG·LEAF` in FULL +(BOTH bytes per tier, depth 16) — the routing **prefix** of the 6-tier facet; +`family`/`identity` (tiers 5-6) stay the basin tail (`local_key`), exactly as v1/v2 +keep their tail out of the path (the full 12 B cascade does not fit one `u64` +NiblePath). `classid` is NOT folded, so `hhtl_path` (schema-driven by `tail_variant`) +routes OSINT-V3 `0x1000_0700` to a non-empty depth-16 path — fixing the Codex-P2 +latent EMPTY-fold. + +**Correction (resolves Codex P2 on #613):** "high `u16` is reserved-zero" is a +**v1-fold** statement (v1 folds `classid_lo` as the coarse tier), NOT a global classid +law — V3 abolishes it by never folding `classid`. `from_guid_prefix`'s doc + guard +scoped to v1 accordingly. + +**Cross-ref:** `E-HOMOGENEITY-CLOSES-AS-CONTAINED-FACET` (the place⊕search facet this +generalizes to content-neutral 8:8), `soa-value-tenant-migration-v1-harvest.md` §5.1, +`perturbation-sim/src/cascade_key.rs` (`CascadeKeyV3`), q2 `fma/docs/V3_SOA_WIRING.md`, +`I-VSA-IDENTITIES` (concatenate disjoint bytes, never XOR-bundle codes). + ## 2026-06-25 — E-TWO-SOA-WORLDS — the value-tenant migration's real object is the slab↔parallel-MailboxSoA seam, not homogenization **Status:** FINDING `[G]` (confirmed-by-read; Phase-1 harvest of `soa-value-tenant-migration-v1`). The 480 B `NodeRow.value` slab (10 `ValueTenant`s, `canonical_node.rs:606`) and the parallel `MailboxSoA` (`cognitive-shader-driver/src/mailbox_soa.rs`, separate `[T;N]` columns) BOTH implement `MailboxSoaView`/`Owner` (`soa_view.rs`) but are **disjoint** — they share exactly one semantic column, `class_id()≡entity_type()` (`soa_view.rs:75`). **6 of 10 slab tenants have NO live producer** (Meta/MaterializedEdges/HelixResidue/TurbovecResidue/Plasticity — only schema tests or parallel-SoA mirrors); only Energy/EntityType/Kanban/Fingerprint are written into the actual slab. `SymbiontBoard` straddles both (carries `Vec` but exposes parallel mirror `Vec`s). **Consequence:** the migration's load-bearing decision is which world becomes canonical (the A↔B reconciliation), NOT homogenizing tenants. Open (un-answerable from source, a §6-panel question): does `MailboxSoA.edges` become the slab `MaterializedEdges` tenant? Full inventory: `soa-value-tenant-migration-v1-harvest.md`. diff --git a/.claude/board/LATEST_STATE.md b/.claude/board/LATEST_STATE.md index 4fcd675a..17099dad 100644 --- a/.claude/board/LATEST_STATE.md +++ b/.claude/board/LATEST_STATE.md @@ -140,6 +140,8 @@ Membrane consumers can now pull BOTH halves of a render `classid` BBB-safely fro ## Current Contract Inventory (lance-graph-contract) +> **2026-06-25 — ADDED (#613, the 6-tier 8:8 homogeneous facet + V3 routing fold)**: `lance_graph_contract::canonical_node::{FacetTier, FacetCascade}` — the **ALWAYS-8:8** content-blind facet substrate. `FacetTier{lo, hi}` (2 B, `const`; `as_u16` concatenated + `morton` 2bit×2bit Morton-tile projections); `FacetCascade{facet_classid: u32, tiers: [FacetTier; 6]}` (16 B = `facet_classid(4) | 6×(8:8)=12`, harvest §5.1) — a *reading* over a borrowed `[u8;16]` with `from_bytes`/`to_bytes`/`hi_chain`/`lo_chain`/`hi_distance`/`lo_distance`. **Carries NO value-slab offset** → does NOT touch the operator-LOCKED 480 B layout (the `classid→ClassView` byte-pick is the separate, panel-gated step); content-blind — only the consumer projects meaning (`part_of:is_a` / 256:256 palette centroid / `group:member` / `column:row` / concatenated u16 …), every reading amortizing to one 2bit×2bit Morton tile cascade. **Key-side V3 routing:** `hhtl::NiblePath::from_guid_prefix_v3` (feature `guid-v3-tail`) folds the 4 HHTL tiers `HEEL·HIP·TWIG·LEAF` in FULL (both bytes, depth 16) — the facet's routing prefix; `family`/`identity` stay the basin tail. `classid` NOT folded, so `soa_graph::hhtl_path` (schema-driven by `tail_variant`) routes OSINT-V3 `0x1000_0700` non-empty — fixes the Codex-P2 latent EMPTY-fold. `from_guid_prefix`'s "reserved-zero" doc/guard scoped to **v1-fold** (NOT a global classid law). Additive, zero-dep; 739 lib green (default + `guid-v3-tail`), clippy `-D warnings` + fmt clean. EPIPHANIES `E-FACET-8-8-ALWAYS`. Branch `claude/p-a-readmode-tail-variant`. + > **2026-06-21 — ADDED (content-store for the AriGraph/OSINT episodic arc)**: `lance_graph_contract::content_store::{ContentId, SourceSpan, ContentError, ContentStore, ContentSink}` — the content-addressed **cold text/blob store** contract. `ContentId(u64)` = `hash::fnv1a` of the bytes (stable across versions — the correct content address; `DefaultHasher` must never key one; `0` = sentinel). `SourceSpan{ContentId,u32,u32}` = the fixed-size, `Copy` typed form of `template-equivalence`'s `(source_id,start,end)` provenance; `is_cited()` = "no source span → no claim" (non-sentinel content + non-empty span). `ContentStore` (cold read: `resolve(id) -> Option<&[u8]>` zero-copy slice into the mmap/backing store; `resolve_span`/`contains` defaulted) + `ContentSink` (idempotent `put -> ContentId`, dedup by content-address: many episodes → one source row). **Hot/cold firewall (ADR-022)**: the hot path (SIMD sweep, AriGraph edge traversal) touches only the fixed-size `ContentId`/`SourceSpan`; bytes hydrate cold at the membrane (the fingerprint is the hot-path stand-in for text). Nothing variable-length enters the 512 B node. Additive, zero-dep; +6 tests (stable/dedup, idempotent put, resolve_span slice, OOB/missing errors, uncited-rejected); clippy clean. Consumers: `rs-graph-llm/episodic-arc-task` (replaces its local fnv1a), `template-equivalence` (typed provenance). Plan: `.claude/plans/arigraph-osint-episodic-v1.md` (D-CC-ARI-3). Branch `claude/content-store-contract-draft`. > **2026-06-18 — ADDED (probe-excel-compute-dag-v1 Inc 0, the `compute_dag` Core gap)**: `lance_graph_contract::class_view::{ComputeEdge, compute_dag_is_acyclic}` + `ClassView::compute_dag(class) -> &[ComputeEdge]` (default `&[]`, zero-fallback). `ComputeEdge {target: u8, inputs: &'static [u8]}` is the harvest-sourced recompute edge (`emitted_by` target ← `depends_on` inputs; field positions index the class `FieldMask`), `const`-constructible like `MethodSig`/`ActionDef` (the harvest IS the manifest). `compute_dag_is_acyclic` is the **registry-build gate** — a cyclic recompute DAG (formula loop / `@api.depends` cycle / self-loop) is rejected at build (Kahn over ≤64 positions, allocation-free; out-of-range positions ignored, no panic, mirrors `FieldMask::from_positions`). This is the Core home for computed-field recompute *dispatch* that EVERY computed-field AR consumer needs (Odoo `@api.depends`, Excel formulas, medcare lab-trends, woa calc, q2 cells — they reduce to a sheet; `E-EXCEL-SHADER-PROJECTION`) and the NNUE-incremental existence-proof shape (`E-CHESS-TENSOR-PROVEN`). **Layout-preserving**: a default trait method + a free fn, resolution metadata ABOVE the SoA, stores nothing on the row, zero `NODE_ROW_STRIDE`/`ENVELOPE_LAYOUT_VERSION` impact (core-gap-auditor's EXTEND-CORE, never an adapter-state hack). The instance recompute that consumes it is gated per-cell by the cycle-aware `write_row` (`E-SOA-CYCLE-OWNERSHIP`). Additive, zero-dep; +4 tests (default-empty, acyclic-chain, cycle/self-loop/3-cycle rejected, out-of-range ignored); 10/10 class_view, clippy/fmt clean. Sibling `ClassView::constraints` (`validation_kind`-sourced) deferred to Inc-follow-up. Plan: `.claude/plans/probe-excel-compute-dag-v1.md`. Branch `claude/particle-wave-click-epiphany`. diff --git a/crates/lance-graph-contract/src/canonical_node.rs b/crates/lance-graph-contract/src/canonical_node.rs index 7c73594d..0c410c79 100644 --- a/crates/lance-graph-contract/src/canonical_node.rs +++ b/crates/lance-graph-contract/src/canonical_node.rs @@ -1213,6 +1213,184 @@ impl KanbanTenant { } } +// ── FacetCascade value tenant (the 6-tier 8:8 homogeneous facet) ───────────────── + +/// One **8:8 tile** of a [`FacetCascade`] — ALWAYS exactly two bytes, `hi` and `lo`. +/// The substrate is **content-blind**: only the CONSUMER (the +/// [`FacetCascade::facet_classid`]'s ClassView) decides what the 8:8 *means*. The +/// same two bytes project as any of: +/// +/// - `(part_of : is_a)` — mereology : taxonomy (the anatomy / `converge.rs` default) +/// - a **256:256 palette centroid** pair (CAM-PQ — `hi`/`lo` index a 256-codebook) +/// - a concatenated `u16` ([`as_u16`](Self::as_u16)) +/// - `(group : member)`, `(mixin : identity)`, `(column : row)`, `(memberof : name)`, +/// a `(Y : Z)` coordinate, … +/// +/// The producer never bakes a meaning in; the reader projects one (AGI-as-glove: the +/// SoA is content-blind). `hi` is the coarse-side byte, `lo` the fine-side byte. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +#[repr(C)] +pub struct FacetTier { + /// Low byte of the LE 8:8 tile (is_a / member / row / centroid-lo / …). + pub lo: u8, + /// High byte of the LE 8:8 tile (part_of / group / column / centroid-hi / …). + pub hi: u8, +} + +impl FacetTier { + /// The two bytes as the LE `u16 = (hi << 8) | lo` — the "consumer reads the 8:8 + /// as one concatenated 16-bit value" projection. + #[inline] + #[must_use] + pub const fn as_u16(self) -> u16 { + ((self.hi as u16) << 8) | self.lo as u16 + } + + /// The `hi:lo` pair **Morton-interleaved** into a `u16` Z-order code (`lo` on + /// even bits, `hi` on odd) — the amortization benefit of the always-8:8 + /// substrate: every nibble of the result is a **2 bit × 2 bit Morton tile** (2 + /// bits of `hi` interleaved with 2 bits of `lo`), so a nibble prefix is a + /// quad-tree quadrant in BOTH bytes at once (`256 = 4⁴` hierarchical ancestry). + /// Whatever the consumer decides the 8:8 *means* (part_of:is_a, centroid:centroid, + /// group:member …), it ALWAYS amortizes to this one Morton tile cascade — so + /// hierarchical-prefix routing is uniform across every interpretation. + #[inline] + #[must_use] + pub const fn morton(self) -> u16 { + Self::spread8(self.lo) | (Self::spread8(self.hi) << 1) + } + + /// Spread a byte's 8 bits to the even positions `0,2,…,14` of a `u16` (the + /// Morton building block). + const fn spread8(x: u8) -> u16 { + let mut v = x as u16; // ........ abcdefgh + v = (v | (v << 4)) & 0x0F0F; // ....abcd ....efgh + v = (v | (v << 2)) & 0x3333; // ..ab..cd ..ef..gh + v = (v | (v << 1)) & 0x5555; // .a.b.c.d .e.f.g.h + v + } +} + +/// The **FacetCascade** — the 6-tier **8:8** homogeneous facet, read at an +/// **alternative location to the key**: a 16-byte ClassView reading over the value +/// slab (`soa-value-tenant-migration-v1-harvest.md` §5.1, +/// `facet_classid(4) | 6×(8:8)=12 = 16B`). +/// +/// **The substrate is ALWAYS 8:8.** Six tiers, each two opaque bytes (`hi:lo`); the +/// `facet_classid`'s ClassView decides the interpretation — `(part_of:is_a)`, +/// 256:256 palette centroid, `(group:member)`, `(column:row)`, concatenated `u16`, +/// … (see [`FacetTier`]). Both bytes of every tier are carried (lossless): the `hi` +/// chain prefix-routes one hierarchy, the `lo` chain the orthogonal one. +/// +/// The full 6-tier facet does NOT fit the 64-bit key `NiblePath` — that carries only +/// the 4-tier HHTL routing **prefix** ([`crate::hhtl::NiblePath::from_guid_prefix_v3`], +/// `HEEL·HIP·TWIG·LEAF`); the complete 6-tier address (HEEL·HIP·TWIG·LEAF·family· +/// identity) lives here, at the alternative value-slab location. +/// +/// This type is a *reading* over a borrowed `[u8; 16]` — it carries NO value-slab +/// offset, so it does not touch the operator-LOCKED 480-byte layout. The +/// `classid → ClassView` wiring that picks which 16 value bytes it reads is a +/// separate, panel-gated step (harvest §5 + §6). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +#[repr(C)] +pub struct FacetCascade { + /// The facet's own class id — which ClassView interprets the 6 tiers' 8:8. + pub facet_classid: u32, + /// 6 tiers coarse→fine: HEEL·HIP·TWIG·LEAF·family·identity, each an 8:8 tile. + pub tiers: [FacetTier; 6], +} + +const _: () = assert!(core::mem::size_of::() == 2, "one 8:8 tile"); +const _: () = assert!( + core::mem::size_of::() == 16, + "facet_classid(4) | 6×(8:8)=12 = 16B (harvest §5.1)" +); + +impl FacetCascade { + /// Decode from the 16 facet bytes (LE): `facet_classid` in `[0..4)`, then 6 + /// tiers, each an LE `u16 = (hi << 8) | lo` — on the wire `[lo, hi]` (matching + /// the key tiers' `converge.rs` byte order). + #[inline] + #[must_use] + pub const fn from_bytes(b: &[u8; 16]) -> Self { + FacetCascade { + facet_classid: u32::from_le_bytes([b[0], b[1], b[2], b[3]]), + tiers: [ + FacetTier { lo: b[4], hi: b[5] }, + FacetTier { lo: b[6], hi: b[7] }, + FacetTier { lo: b[8], hi: b[9] }, + FacetTier { + lo: b[10], + hi: b[11], + }, + FacetTier { + lo: b[12], + hi: b[13], + }, + FacetTier { + lo: b[14], + hi: b[15], + }, + ], + } + } + + /// Encode to the 16 facet bytes (LE), the inverse of [`from_bytes`](Self::from_bytes). + #[inline] + #[must_use] + pub const fn to_bytes(self) -> [u8; 16] { + let c = self.facet_classid.to_le_bytes(); + let t = &self.tiers; + [ + c[0], c[1], c[2], c[3], t[0].lo, t[0].hi, t[1].lo, t[1].hi, t[2].lo, t[2].hi, t[3].lo, + t[3].hi, t[4].lo, t[4].hi, t[5].lo, t[5].hi, + ] + } + + /// The `hi`-byte chain, coarse→fine — one hierarchy (part_of / group / column / + /// centroid-hi, per the consumer). + #[inline] + #[must_use] + pub const fn hi_chain(self) -> [u8; 6] { + let t = &self.tiers; + [t[0].hi, t[1].hi, t[2].hi, t[3].hi, t[4].hi, t[5].hi] + } + + /// The `lo`-byte chain, coarse→fine — the orthogonal hierarchy (is_a / member / + /// row / centroid-lo, per the consumer). + #[inline] + #[must_use] + pub const fn lo_chain(self) -> [u8; 6] { + let t = &self.tiers; + [t[0].lo, t[1].lo, t[2].lo, t[3].lo, t[4].lo, t[5].lo] + } + + /// Shared coarse→fine prefix length (0..=6) of two 6-byte chains. + const fn shared(a: [u8; 6], b: [u8; 6]) -> u8 { + let mut n = 0u8; + while (n as usize) < 6 && a[n as usize] == b[n as usize] { + n += 1; + } + n + } + + /// `hi`-chain distance: `6 − shared hi-prefix` — locality along the `hi` + /// hierarchy (e.g. `part_of` place), orthogonal to [`lo_distance`](Self::lo_distance). + #[inline] + #[must_use] + pub const fn hi_distance(self, other: Self) -> u8 { + 6 - Self::shared(self.hi_chain(), other.hi_chain()) + } + + /// `lo`-chain distance: `6 − shared lo-prefix` — locality along the orthogonal + /// `lo` hierarchy (e.g. `is_a` type), on the SAME facet. + #[inline] + #[must_use] + pub const fn lo_distance(self, other: Self) -> u8 { + 6 - Self::shared(self.lo_chain(), other.lo_chain()) + } +} + impl NodeRow { /// Read the [`KanbanTenant`] phase cursor from the [`ValueTenant::Kanban`] /// slab bytes — zero-copy decode, `Copy` result. The per-node Rubicon phase. @@ -1273,6 +1451,62 @@ impl NodeRow { mod tests { use super::*; + #[test] + fn facet_cascade_is_16_bytes_always_8_8_consumer_neutral() { + // 16-byte facet: facet_classid(4) | 6×(8:8)=12 (harvest §5.1). The bytes are + // content-blind — this test reads them as raw hi:lo, no part_of/is_a baked in. + assert_eq!(core::mem::size_of::(), 16); + assert_eq!(core::mem::size_of::(), 2); + + let bytes: [u8; 16] = [ + 0xEF, 0xBE, 0xAD, 0xDE, // facet_classid = 0xDEAD_BEEF (LE) + 0x01, 0xAB, // tier0: lo=0x01, hi=0xAB + 0x02, 0xCD, // tier1 + 0x03, 0xEF, // tier2 + 0x04, 0x12, // tier3 + 0x05, 0x34, // tier4 + 0x06, 0x56, // tier5 + ]; + let f = FacetCascade::from_bytes(&bytes); + assert_eq!(f.facet_classid, 0xDEAD_BEEF); + // round-trip is exact (the substrate stores the 8:8 verbatim). + assert_eq!(f.to_bytes(), bytes); + + // The two orthogonal chains: hi (one hierarchy) and lo (the other). + assert_eq!(f.hi_chain(), [0xAB, 0xCD, 0xEF, 0x12, 0x34, 0x56]); + assert_eq!(f.lo_chain(), [0x01, 0x02, 0x03, 0x04, 0x05, 0x06]); + + // Consumer projections of ONE tier's 8:8 — concatenated u16 and Morton tile. + let t0 = f.tiers[0]; // hi=0xAB, lo=0x01 + assert_eq!(t0.as_u16(), 0xAB01, "concatenated 16-bit reading"); + // Morton: lo on even bits, hi on odd. Every nibble = 2 bits hi × 2 bits lo. + assert_eq!(t0.morton(), { + let spread = |x: u8| { + let mut v = x as u16; + v = (v | (v << 4)) & 0x0F0F; + v = (v | (v << 2)) & 0x3333; + v = (v | (v << 1)) & 0x5555; + v + }; + spread(0x01) | (spread(0xAB) << 1) + }); + // Morton de-interleaves back to the two bytes (lossless amortization). + assert_eq!(t0.morton() & 0x5555, FacetTier { lo: 0x01, hi: 0 }.morton()); + + // Distances are prefix metrics, orthogonal: same hi-chain, different lo-chain + // ⇒ hi_distance 0, lo_distance > 0 (and vice-versa). + let same_hi_diff_lo = FacetCascade::from_bytes(&{ + let mut b = bytes; + b[4] = 0x99; // tier0 lo (the fine end of the lo chain... actually coarsest) + b + }); + assert_eq!(f.hi_distance(same_hi_diff_lo), 0, "hi chain unchanged"); + assert!( + f.lo_distance(same_hi_diff_lo) > 0, + "lo chain diverges at tier0" + ); + } + #[test] fn kanban_tenant_round_trip_and_field_isolation() { let mut row = NodeRow { diff --git a/crates/lance-graph-contract/src/hhtl.rs b/crates/lance-graph-contract/src/hhtl.rs index b165c8bc..cc0786a3 100644 --- a/crates/lance-graph-contract/src/hhtl.rs +++ b/crates/lance-graph-contract/src/hhtl.rs @@ -273,12 +273,15 @@ impl NiblePath { /// /// The 20-nibble prefix `classid(8) | HEEL(4) | HIP(4) | TWIG(4)` overflows /// `MAX_DEPTH = 16`. The deterministic fold drops the **HIGH 4 classid - /// nibbles** (the canon-reserved high `u16` of `classid`) and packs the - /// remaining 16 nibbles root-first as - /// `classid_lo(4) | HEEL(4) | HIP(4) | TWIG(4)`. Returns `None` when the - /// HIGH 4 classid nibbles are nonzero — the fold would be lossy, and the - /// caller must mint within the low `u16` space (CANON: RESERVE, DON'T - /// RECLAIM — high classid bits are documented as reserved-zero). + /// nibbles** and packs the remaining 16 nibbles root-first as + /// `classid_lo(4) | HEEL(4) | HIP(4) | TWIG(4)`. Returns `None` when the HIGH + /// 4 classid nibbles are nonzero — **this v1 fold** uses `classid_lo` as the + /// coarse tier, so it needs the high `u16` clear; a nonzero high `u16` is + /// reported, not silently re-routed. This is a **v1-fold constraint, NOT a + /// global classid law**: the v3 fold [`from_guid_prefix_v3`] reads the + /// `(part_of:is_a)` `HEEL·HIP·TWIG·LEAF` tiers and does NOT fold `classid`, so + /// a V3 classid carries its high-`u16` generation marker freely (the schema's + /// `tail_variant` selects the fold — there is no global reserved-zero after V3). /// /// **Bijection invariant.** For any GUID whose `classid >> 16 == 0`, /// `from_guid_prefix(guid).prefix(d).is_ancestor_of(from_guid_prefix(guid))` @@ -290,10 +293,11 @@ impl NiblePath { #[must_use] pub const fn from_guid_prefix(guid: &crate::canonical_node::NodeGuid) -> Option { let parts = guid.decode(); - // High 4 classid nibbles (the canon-reserved high u16) must be zero — - // otherwise the 20→16 nibble fold drops information. The caller mints - // into the low u16 of classid (see CANON / identity-architecture v1 - // §3): a nonzero high u16 is not silently re-routed, it's reported. + // In THIS v1 fold the high 4 classid nibbles must be zero — it folds + // classid_lo as the coarse tier, so a nonzero high u16 would make the + // 20→16 nibble fold lossy. It is reported, not silently re-routed. (The + // v3 fold does NOT fold classid — see from_guid_prefix_v3 — so this is a + // v1-fold constraint, not a global reserved-zero law.) if (parts.classid >> 16) != 0 { return None; } @@ -333,6 +337,47 @@ impl NiblePath { } } + /// v3 GUID→path lowering (feature `guid-v3-tail`): each HHTL tier is an 8:8 + /// `(part_of : is_a)` = `(place : tissue)` tile, and **BOTH bytes are routed** + /// — lossless: the `part_of` high byte (WHERE — `galaxy`, `city`, `class`) + /// AND the `is_a` low byte (WHAT — `universe`, `school`, `student`) co-refine + /// at every level. (Folding only the high byte, as an earlier draft did, drops + /// the whole `is_a` hierarchy.) + /// + /// The full v3 address is the **6-tier** `(part_of:is_a)` FacetCascade + /// (`facet_classid(4) | 6×(8:8)=12`, harvest §5.1 — HEEL·HIP·TWIG·LEAF·family· + /// identity). This `NiblePath` carries its **routing prefix**: the 4 HHTL + /// tiers `HEEL·HIP·TWIG·LEAF` in FULL (4 × 16 bits = 64 = [`MAX_DEPTH`]). The + /// 5th/6th tiers (`family`/`identity`) are the basin tail + /// ([`local_key`](crate::canonical_node::NodeGuid::local_key)) — preserved, + /// not dropped, exactly as v1/v2 keep their tail out of the `u64` path (which + /// holds only 8 bytes; the full 12-byte cascade does not fit one `NiblePath`). + /// + /// **`classid` is NOT folded in** (unlike v1's `classid_lo·HEEL·HIP·TWIG`), so + /// a V3 classid's high-`u16` generation marker (e.g. OSINT-V3 `0x1000_0700`) + /// is irrelevant to routing and never collapses to [`EMPTY`](NiblePath::EMPTY). + /// This is why "high `u16` is reserved-zero" is a **v1-fold** statement, NOT a + /// global classid law — the schema's `tail_variant` selects the fold. + #[cfg(feature = "guid-v3-tail")] + #[must_use] + pub const fn from_guid_prefix_v3(guid: &crate::canonical_node::NodeGuid) -> Self { + // Read the 4 HHTL tiers as full LE `u16` from the raw key bytes [4..12] — + // BOTH the part_of (high) and is_a (low) byte of each 8:8 tile. Reading + // raw bytes avoids the `guid-v2-tail` gate on `leaf()` and is robust to the + // v1/v2 tail interpretation (the tier offsets are fixed by the canon). + let b = guid.as_bytes(); + let heel = (b[4] as u64) | ((b[5] as u64) << 8); + let hip = (b[6] as u64) | ((b[7] as u64) << 8); + let twig = (b[8] as u64) | ((b[9] as u64) << 8); + let leaf = (b[10] as u64) | ((b[11] as u64) << 8); + let path = (heel << 48) | (hip << 32) | (twig << 16) | leaf; + // 16 nibbles = full depth; from_packed is always Some at MAX_DEPTH. + match Self::from_packed(path, MAX_DEPTH) { + Some(p) => p, + None => Self::EMPTY, + } + } + /// **Family hop count** — the CLAM tree distance to `other`: the number of /// edges between the two nodes through their lowest common ancestor in the /// 16ⁿ tree. `(self.depth − common) + (other.depth − common)` where `common = @@ -833,6 +878,69 @@ mod tests { assert!(NiblePath::from_guid_prefix(&g).is_some()); } + #[cfg(feature = "guid-v3-tail")] + #[test] + fn from_guid_prefix_v3_routes_both_bytes_of_part_of_is_a_and_ignores_classid() { + use crate::canonical_node::NodeGuid; + // OSINT-V3: classid high u16 = 0x1000 (the generation marker), so the v1 + // fold REFUSES this GUID — the latent EMPTY-fold Codex flagged. + let g = NodeGuid::new( + NodeGuid::CLASSID_OSINT_V3, + 0xAB12, + 0xCD34, + 0xEF56, + 0x00_789A, + 0xBC_DEF0, + ); + assert_eq!( + NiblePath::from_guid_prefix(&g), + None, + "v1 fold refuses the high-u16 marker — the break v3 resolves" + ); + + // v3 routes the 4 HHTL tiers HEEL·HIP·TWIG·LEAF in FULL (both the part_of + // high byte AND the is_a low byte of each 8:8 tile), depth 16, classid NOT + // folded. + let b = g.as_bytes(); + let heel = (b[4] as u64) | ((b[5] as u64) << 8); + let hip = (b[6] as u64) | ((b[7] as u64) << 8); + let twig = (b[8] as u64) | ((b[9] as u64) << 8); + let leaf = (b[10] as u64) | ((b[11] as u64) << 8); + let expected = (heel << 48) | (hip << 32) | (twig << 16) | leaf; + let p = NiblePath::from_guid_prefix_v3(&g); + assert_ne!( + p, + NiblePath::EMPTY, + "the gen marker must NOT collapse routing" + ); + assert_eq!( + p.packed(), + (expected, MAX_DEPTH), + "HEEL·HIP·TWIG·LEAF in full — both bytes per tier" + ); + + // Routing is INDEPENDENT of classid: drop the generation marker → same path. + let unmarked = NodeGuid::new(0x0000_0700, 0xAB12, 0xCD34, 0xEF56, 0x00_789A, 0xBC_DEF0); + assert_eq!(NiblePath::from_guid_prefix_v3(&unmarked), p); + + // BOTH bytes routed: flipping an is_a LOW byte (HEEL.lo 0x12 → 0x99) MUST + // change the path — proving the is_a hierarchy is folded, not just part_of. + // (The earlier part_of-only fold would wrongly keep this identical.) + let diff_isa = NodeGuid::new( + NodeGuid::CLASSID_OSINT_V3, + 0xAB99, + 0xCD34, + 0xEF56, + 0x00_789A, + 0xBC_DEF0, + ); + assert_ne!( + NiblePath::from_guid_prefix_v3(&diff_isa), + p, + "is_a low byte must move routing — both bytes, not just part_of" + ); + } + #[test] fn from_guid_prefix_bootstrap_classid_is_all_zero_path() { use crate::canonical_node::NodeGuid; diff --git a/crates/lance-graph-contract/src/soa_graph.rs b/crates/lance-graph-contract/src/soa_graph.rs index 6c4080ef..78adfb7f 100644 --- a/crates/lance-graph-contract/src/soa_graph.rs +++ b/crates/lance-graph-contract/src/soa_graph.rs @@ -140,11 +140,23 @@ fn family_node_id(family: u32) -> String { format!("family:{family:06x}") } -/// HHTL routing path of a GUID, via the canonical [`NiblePath::from_guid_prefix`] -/// lowering (`classid_lo·HEEL·HIP·TWIG`). Falls back to [`NiblePath::EMPTY`] for -/// the (canon-reserved) case of a non-zero high `classid` u16. +/// HHTL routing path of a GUID. The fold is selected by the classid's +/// `tail_variant` — **the schema decides how** (OGAR #128 `classid → {tail_variant, +/// …}`). A **V3** classid routes on the `(part_of:is_a)` `HEEL·HIP·TWIG·LEAF` +/// cascade ([`NiblePath::from_guid_prefix_v3`], both bytes per tier); `classid` +/// is NOT folded, so its high-`u16` generation marker does not gate routing and +/// never collapses to [`NiblePath::EMPTY`]. Every other classid uses the canonical +/// v1 lowering (`classid_lo·HEEL·HIP·TWIG`), which falls back to +/// [`NiblePath::EMPTY`] only for the v1-fold case of a non-zero high `classid` u16. #[inline] fn hhtl_path(guid: &NodeGuid) -> NiblePath { + #[cfg(feature = "guid-v3-tail")] + { + use crate::canonical_node::{classid_read_mode, TailVariant}; + if classid_read_mode(guid.classid()).tail_variant == TailVariant::V3 { + return NiblePath::from_guid_prefix_v3(guid); + } + } NiblePath::from_guid_prefix(guid).unwrap_or(NiblePath::EMPTY) }