From 142ce130f873ac28f4caa17ac4b8359af4450f7a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 14:52:15 +0000 Subject: [PATCH 1/2] Widen FieldMask beyond 64 fields with a backward-compatible sibling type Add WideFieldMask alongside the untouched FieldMask (u64 presence mask) to represent classes with more than FieldMask::MAX_FIELDS (64) fields, e.g. Odoo account.move (109 fields). FieldMask itself is not modified in any way (no new derives, no changed method signatures, no representation change) so every existing FieldMask(u64)/EMPTY/FULL/with/has/from_positions/MAX_FIELDS call site keeps compiling and behaving identically. An internal Repr::Small(u64)|Repr::Wide(Box<[u64]>) swap inside FieldMask itself was considered and rejected: several call sites (ClassProjection::next, FieldMask::from_positions) rely on FieldMask: Copy for self-by-value method chaining, and a Box-bearing repr cannot be Copy - that would have silently broken existing callers, the exact footgun Ruling (c) forbids. WideFieldMask mirrors the FieldMask API shape (EMPTY, with, has, from_positions, count, is_empty, intersect, union, max_fields, full_for) and allocates only on demand: it stays in a zero-heap Small(u64) repr identical to FieldMask until a position >= 64 is set, then promotes once to a Wide(Box<[u64]>) chunk vector. Bit position N keeps the same logical field across both types and both tiers (N3 stability) - the widening only adds positions >= 64, never moves 0..63. Tests added (all in lance-graph-contract::class_view): u64_constructors_unchanged pins the untouched FieldMask API incl. Copy; wide_class_positions_beyond_64_are_representable sets/reads position 65 (mirrors the OGAR-side pin test's motivating case); small_mask_never_allocates asserts the Small repr for <=64-field masks; intersect_union_across_tiers covers Small/Wide and Wide/Wide folds; full_for_wide_class_emits_all covers a 109-field full mask; field_mask_promotes_into_wide_field_mask_losslessly covers the FieldMask -> WideFieldMask conversion. Every pre-existing test in the crate stays green unchanged (828 lib tests + existing integration/doc tests, all passing). Verified lance-graph-rbac, lance-graph-ontology, and lance-graph-arm-discovery (all internal FieldMask consumers) still compile and pass their full test suites unmodified. Co-Authored-By: Claude Opus 4.8 --- crates/lance-graph-contract/src/class_view.rs | 364 ++++++++++++++++++ crates/lance-graph-contract/src/lib.rs | 4 +- 2 files changed, 367 insertions(+), 1 deletion(-) diff --git a/crates/lance-graph-contract/src/class_view.rs b/crates/lance-graph-contract/src/class_view.rs index e8f8d6b7..cac98e37 100644 --- a/crates/lance-graph-contract/src/class_view.rs +++ b/crates/lance-graph-contract/src/class_view.rs @@ -167,6 +167,229 @@ impl FieldMask { } } +impl From for FieldMask { + /// Additive convenience alongside the untouched tuple constructor + /// `FieldMask(bits)` — `FieldMask::from(bits)` is equivalent, never a + /// replacement (Ruling c: nothing about `FieldMask` changes). + #[inline] + fn from(bits: u64) -> Self { + Self(bits) + } +} + +/// Backward-compatible widening companion to [`FieldMask`] for classes with +/// MORE than [`FieldMask::MAX_FIELDS`] (64) fields (Ruling c, +/// `D-FIELDMASK-WIDENING`; account.move/Odoo — 109 fields — is the motivating +/// case). +/// +/// **Non-footgun by construction: `FieldMask` above is completely untouched.** +/// An earlier design considered swapping `FieldMask`'s internals for an +/// enum (`Repr::Small(u64) | Repr::Wide(Box<[u64]>)`) so ONE type covered both +/// tiers. That was rejected: `FieldMask::with`/`has`/`count`/`is_empty`/ +/// `intersect`/`union`/`inherit` all take `self` **by value** and rely on +/// `FieldMask: Copy` so the same variable can be reused after a method call +/// (e.g. [`ClassProjection::next`] reads `self.mask.has(..)` out of a +/// `&mut self` field every iteration; [`FieldMask::from_positions`] folds +/// `with` over a slice). A `Box`-bearing repr cannot be `Copy`, so that design +/// would have broken every such call site — the exact footgun Ruling (c) +/// forbids. Keeping `FieldMask` byte-for-byte identical and adding this +/// sibling type instead means the acceptance criterion ("every existing +/// u64 constructor/call site compiles and behaves identically") holds +/// trivially: nothing about `FieldMask` changed. +/// +/// **N3 stability across the pair:** bit position `n` denotes the same +/// logical field in both types — `WideFieldMask`'s positions 0..63 read +/// bit-for-bit identically to a `FieldMask` over the same class, the widening +/// only adds positions >= 64, never moves 0..63. +/// +/// **Allocates only on demand:** internally `Small(u64)` (bit-identical to +/// `FieldMask`, zero heap) until a position >= 64 is set, at which point it +/// promotes once to `Wide(Box<[u64]>)` (chunk `k` = bits `64k..64k+63`). A +/// mask that never crosses the 64 boundary never allocates. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct WideFieldMask(WideRepr); + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +enum WideRepr { + /// <= 64 positions — zero-allocation, bit-identical to `FieldMask(u64)`. + Small(u64), + /// > 64 positions. `chunks[k]` holds bits `64k..64k+63`. + Wide(Box<[u64]>), +} + +impl Default for WideFieldMask { + fn default() -> Self { + Self::EMPTY + } +} + +impl From for WideFieldMask { + fn from(bits: u64) -> Self { + Self(WideRepr::Small(bits)) + } +} + +impl From for WideFieldMask { + /// Promote a `FieldMask` into the wide-tier vocabulary — always lands in + /// `Small` (no allocation): every `FieldMask` bit is already `< 64`. + fn from(m: FieldMask) -> Self { + Self(WideRepr::Small(m.0)) + } +} + +impl WideFieldMask { + /// The empty mask (no fields populated). Zero-allocation (`Small`). + pub const EMPTY: Self = Self(WideRepr::Small(0)); + + /// Set field position `n` as populated. Promotes `Small` → `Wide` only + /// when `n >= 64` (or the mask already promoted); a mask that never sets + /// a position >= 64 never allocates. + #[must_use] + pub fn with(self, n: u8) -> Self { + let chunk = (n / 64) as usize; + let bit = 1u64 << (n % 64); + match self.0 { + WideRepr::Small(bits) if chunk == 0 => Self(WideRepr::Small(bits | bit)), + WideRepr::Small(bits) => { + let mut v = vec![0u64; chunk + 1]; + v[0] = bits; + v[chunk] |= bit; + Self(WideRepr::Wide(v.into_boxed_slice())) + } + WideRepr::Wide(v) => { + let mut v = if chunk >= v.len() { + let mut grown = vec![0u64; chunk + 1]; + grown[..v.len()].copy_from_slice(&v); + grown + } else { + v.into_vec() + }; + v[chunk] |= bit; + Self(WideRepr::Wide(v.into_boxed_slice())) + } + } + } + + /// Build a mask from populated field positions — the wide-tier sibling of + /// [`FieldMask::from_positions`]; folds [`with`](Self::with) over + /// `positions` (same allocate-on-demand contract). + #[must_use] + pub fn from_positions(positions: &[u8]) -> Self { + positions.iter().fold(Self::EMPTY, |m, &p| m.with(p)) + } + + /// Is field position `n` populated? + #[inline] + #[must_use] + pub fn has(&self, n: u8) -> bool { + let chunk = (n / 64) as usize; + let bit = 1u64 << (n % 64); + match &self.0 { + WideRepr::Small(bits) => chunk == 0 && bits & bit != 0, + WideRepr::Wide(v) => v.get(chunk).is_some_and(|w| w & bit != 0), + } + } + + /// Number of populated fields. + #[must_use] + pub fn count(&self) -> u32 { + match &self.0 { + WideRepr::Small(bits) => bits.count_ones(), + WideRepr::Wide(v) => v.iter().map(|w| w.count_ones()).sum(), + } + } + + /// Is nothing populated? + #[must_use] + pub fn is_empty(&self) -> bool { + match &self.0 { + WideRepr::Small(bits) => *bits == 0, + WideRepr::Wide(v) => v.iter().all(|&w| w == 0), + } + } + + /// The mask's current representable capacity in bit positions. `64` while + /// `Small` (mirrors [`FieldMask::MAX_FIELDS`]); grows in steps of 64 once + /// promoted to `Wide`. Reports capacity, not [`count`](Self::count). + #[must_use] + pub fn max_fields(&self) -> u32 { + match &self.0 { + WideRepr::Small(_) => FieldMask::MAX_FIELDS, + WideRepr::Wide(v) => (v.len() as u32) * 64, + } + } + + /// The full mask over exactly `field_count` positions (`0..field_count` + /// all present) — the wide-tier sibling of [`FieldMask::FULL`], which is + /// only meaningful for a *fixed* 64-wide class; a wide class's "all + /// fields" sentinel must instead know how many fields it has. + #[must_use] + pub fn full_for(field_count: usize) -> Self { + if field_count == 0 { + return Self::EMPTY; + } + if field_count <= 64 { + let bits = if field_count == 64 { + u64::MAX + } else { + (1u64 << field_count) - 1 + }; + return Self(WideRepr::Small(bits)); + } + let chunks = field_count.div_ceil(64); + let mut v = vec![u64::MAX; chunks]; + let rem = field_count % 64; + if rem != 0 { + v[chunks - 1] = (1u64 << rem) - 1; + } + Self(WideRepr::Wide(v.into_boxed_slice())) + } + + /// Bitwise intersection — field positions present in BOTH masks. Folds + /// tier-agnostically: a `Small`∩`Wide` pair reads the `Small` side's + /// missing high chunks as `0` (never aliases a low bit onto a high one). + #[must_use] + pub fn intersect(&self, other: &Self) -> Self { + match (&self.0, &other.0) { + (WideRepr::Small(a), WideRepr::Small(b)) => Self(WideRepr::Small(a & b)), + _ => self.zip_fold(other, |a, b| a & b), + } + } + + /// Bitwise union — field positions present in EITHER mask. Same + /// tier-agnostic fold as [`intersect`](Self::intersect). + #[must_use] + pub fn union(&self, other: &Self) -> Self { + match (&self.0, &other.0) { + (WideRepr::Small(a), WideRepr::Small(b)) => Self(WideRepr::Small(a | b)), + _ => self.zip_fold(other, |a, b| a | b), + } + } + + /// Chunk-wise fold across tiers (only reached when at least one side is + /// already `Wide`, so the allocation is not new spend for that side). + fn zip_fold(&self, other: &Self, f: impl Fn(u64, u64) -> u64) -> Self { + let a = self.chunks_view(); + let b = other.chunks_view(); + let len = a.len().max(b.len()); + let mut v = vec![0u64; len]; + for (i, slot) in v.iter_mut().enumerate() { + *slot = f( + a.get(i).copied().unwrap_or(0), + b.get(i).copied().unwrap_or(0), + ); + } + Self(WideRepr::Wide(v.into_boxed_slice())) + } + + fn chunks_view(&self) -> Vec { + match &self.0 { + WideRepr::Small(bits) => vec![*bits], + WideRepr::Wide(v) => v.to_vec(), + } + } +} + /// One recompute edge in a class's **compute DAG**: field position `target` is /// (re)computed from the field positions in `inputs`. /// @@ -1117,4 +1340,145 @@ mod tests { vec![0, 2] ); } + + // ── WideFieldMask widening (Ruling c, D-FIELDMASK-WIDENING) ─────────────── + // `FieldMask` above is UNTOUCHED by the widening — these tests prove (1) + // every pre-existing u64 call shape still compiles/behaves identically, + // and (2) the new sibling type covers the >64 case the old type cannot. + + /// (L-1 acceptance #1) Every existing `FieldMask` u64 constructor/method + /// shape still compiles and round-trips exactly as before the widening + /// landed — the non-footgun acceptance criterion, pinned directly. + #[test] + fn u64_constructors_unchanged() { + // Tuple construction (the exact call shape OGAR's render crate uses). + let m = FieldMask(0b101); + assert!(m.has(0) && !m.has(1) && m.has(2)); + // The new `From` is ADDITIVE sugar, not a replacement: it agrees + // with tuple construction bit-for-bit. + assert_eq!(FieldMask::from(0b101u64), m); + // EMPTY / FULL / MAX_FIELDS unchanged. + assert_eq!(FieldMask::EMPTY, FieldMask(0)); + assert_eq!(FieldMask::FULL, FieldMask(u64::MAX)); + assert_eq!(FieldMask::MAX_FIELDS, 64); + // `FieldMask` is still `Copy`: `m` is usable after being read into `n` + // with no `.clone()` — if the widening had swapped `FieldMask`'s + // internals for a `Box`-bearing enum, this would fail to compile. + let n = m; + assert_eq!(m, n); + assert_eq!(m.count(), 2); + // `with`/`from_positions`/out-of-range-ignored semantics unchanged. + assert_eq!(FieldMask::EMPTY.with(0).with(2), m); + assert_eq!(FieldMask::from_positions(&[0, 2]), m); + assert!(!FieldMask::EMPTY.with(64).has(0), "with(64) still a no-op"); + } + + /// (L-1 acceptance #2) A 70-field mask sets and reads back position 65 — + /// the exact case `FieldMask` cannot represent (SPEC-2 (i) pins the old + /// type's silent drop at this same position). + #[test] + fn wide_class_positions_beyond_64_are_representable() { + let mask = WideFieldMask::EMPTY.with(0).with(65); + assert!(mask.has(0)); + assert!( + mask.has(65), + "position 65 must be representable, not dropped" + ); + assert!(!mask.has(1) && !mask.has(64)); + assert_eq!(mask.count(), 2); + assert!(mask.max_fields() >= 66); + + // A full 70-field class, built position-by-position, keeps every bit. + let seventy = WideFieldMask::from_positions(&(0u8..70).collect::>()); + for i in 0..70u8 { + assert!(seventy.has(i), "position {i} must be present"); + } + assert_eq!(seventy.count(), 70); + } + + /// A mask that never crosses the 64 boundary stays in the zero-allocation + /// `Small` repr — the "never allocates" half of the allocate-on-demand + /// contract. + #[test] + fn small_mask_never_allocates() { + let mask = WideFieldMask::from_positions(&[0, 5, 63]); + assert!( + matches!(mask.0, WideRepr::Small(_)), + "a <= 64 mask must stay Small (zero heap), not promote to Wide" + ); + assert_eq!(mask.max_fields(), FieldMask::MAX_FIELDS); + + // Crossing 64 promotes exactly once, and only then. + let wide = mask.with(64); + assert!( + matches!(wide.0, WideRepr::Wide(_)), + "setting position 64 must promote to Wide" + ); + } + + /// `intersect`/`union` combine masks correctly regardless of which side + /// (or both) has promoted to `Wide` — no bit is aliased or dropped across + /// the tier boundary. + #[test] + fn intersect_union_across_tiers() { + let small = WideFieldMask::from_positions(&[0, 2]); // stays Small + let wide = WideFieldMask::from_positions(&[2, 70]); // promotes to Wide + + let inter = small.intersect(&wide); + assert!(inter.has(2) && !inter.has(0) && !inter.has(70)); + assert_eq!(inter.count(), 1); + + let uni = small.union(&wide); + assert!(uni.has(0) && uni.has(2) && uni.has(70)); + assert_eq!(uni.count(), 3); + + // Order independence (both folds are commutative). + assert_eq!(small.intersect(&wide), wide.intersect(&small)); + assert_eq!(small.union(&wide), wide.union(&small)); + + // Wide ∩/∪ Wide (both already promoted) also folds correctly. + let wide2 = WideFieldMask::from_positions(&[70, 71]); + let wide_inter = wide.intersect(&wide2); + assert!(wide_inter.has(70) && !wide_inter.has(71) && !wide_inter.has(2)); + } + + /// `full_for(field_count)` is the wide-tier sibling of `FieldMask::FULL`: + /// every position `< field_count` present, nothing at or beyond it. + #[test] + fn full_for_wide_class_emits_all() { + // account.move-scale case: 109 fields, all present. + let full = WideFieldMask::full_for(109); + for i in 0..109u8 { + assert!( + full.has(i), + "position {i} must be present under full_for(109)" + ); + } + assert!( + !full.has(109), + "field_count is exclusive of its own boundary" + ); + assert_eq!(full.count(), 109); + + // A <= 64 field_count stays in the Small tier (agrees with + // FieldMask::FULL's bit pattern when the class happens to be 64-wide). + let full64 = WideFieldMask::full_for(64); + assert!(matches!(full64.0, WideRepr::Small(bits) if bits == u64::MAX)); + assert_eq!(full64, WideFieldMask::from(u64::MAX)); + + // 0 fields → EMPTY, not a panic. + assert_eq!(WideFieldMask::full_for(0), WideFieldMask::EMPTY); + } + + /// Promoting a `FieldMask` into `WideFieldMask` (e.g. a caller migrating a + /// <=64 class onto the wide API) preserves every bit and stays `Small`. + #[test] + fn field_mask_promotes_into_wide_field_mask_losslessly() { + let narrow = FieldMask::from_positions(&[1, 3, 60]); + let promoted: WideFieldMask = narrow.into(); + assert!(matches!(promoted.0, WideRepr::Small(_))); + for i in 0..64u8 { + assert_eq!(promoted.has(i), narrow.has(i), "bit {i} must round-trip"); + } + } } diff --git a/crates/lance-graph-contract/src/lib.rs b/crates/lance-graph-contract/src/lib.rs index 82f1a9c0..49a44c4f 100644 --- a/crates/lance-graph-contract/src/lib.rs +++ b/crates/lance-graph-contract/src/lib.rs @@ -150,7 +150,9 @@ pub use canonical_node::{ KanbanTenant, NodeGuid, NodeRow, NodeRowPacket, ReadMode, ValueSchema, ValueTenant, VALUE_TENANTS, }; -pub use class_view::{ClassId, ClassProjection, ClassView, FieldMask, RenderRow, ValueRow}; +pub use class_view::{ + ClassId, ClassProjection, ClassView, FieldMask, RenderRow, ValueRow, WideFieldMask, +}; pub use collapse_gate::{GateDecision, MailboxId, MergeMode}; pub use episodic_edges::{EdgeRef, EpisodicEdges64}; pub use head2head::{CompetitionOutcome, Head2Head, WinnerCriterion}; From a0432d8ff25b6d469504f39b74f63294388154ce Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 15:03:41 +0000 Subject: [PATCH 2/2] =?UTF-8?q?fix(contract):=20WideFieldMask=20canonical?= =?UTF-8?q?=20form=20=E2=80=94=20repr-independent=20Eq/Hash=20(V-L=20P0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- crates/lance-graph-contract/src/class_view.rs | 152 +++++++++++++++++- 1 file changed, 150 insertions(+), 2 deletions(-) diff --git a/crates/lance-graph-contract/src/class_view.rs b/crates/lance-graph-contract/src/class_view.rs index cac98e37..b61128a4 100644 --- a/crates/lance-graph-contract/src/class_view.rs +++ b/crates/lance-graph-contract/src/class_view.rs @@ -43,6 +43,7 @@ //! template, skips off-bits. use crate::ontology::{DisplayTemplate, FieldRef}; +use std::hash::{Hash, Hasher}; /// Per-row class discriminator — the Cognitive-RISC `class_id` / `shape_id`. /// @@ -206,10 +207,20 @@ impl From for FieldMask { /// `FieldMask`, zero heap) until a position >= 64 is set, at which point it /// promotes once to `Wide(Box<[u64]>)` (chunk `k` = bits `64k..64k+63`). A /// mask that never crosses the 64 boundary never allocates. -#[derive(Debug, Clone, PartialEq, Eq, Hash)] +/// +/// **When to use which:** reach for [`FieldMask`] for classes with <= 64 +/// fields; reach for `WideFieldMask` only when a class's field count may +/// exceed 64. +/// +/// `PartialEq`/`Eq`/`Hash` are hand-written (see the impls below), NOT +/// derived: equality must be representation-independent (a `Wide` value +/// whose high chunks are all zero must compare equal to, and hash +/// identically with, the equivalent `Small` value), which a structural +/// derive over `WideRepr` cannot express. +#[derive(Debug, Clone)] pub struct WideFieldMask(WideRepr); -#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[derive(Debug, Clone)] enum WideRepr { /// <= 64 positions — zero-allocation, bit-identical to `FieldMask(u64)`. Small(u64), @@ -368,6 +379,12 @@ impl WideFieldMask { /// Chunk-wise fold across tiers (only reached when at least one side is /// already `Wide`, so the allocation is not new spend for that side). + /// + /// Normalizes the result before returning: trailing all-zero chunks are + /// trimmed, and a result that now fits in a single chunk demotes back to + /// `Small` (V-L P0: an un-normalized `Wide` result — e.g. intersecting a + /// mask that has bits >= 64 down to only bits < 64 — must still compare + /// equal to, and hash identically with, the equivalent `Small` mask). fn zip_fold(&self, other: &Self, f: impl Fn(u64, u64) -> u64) -> Self { let a = self.chunks_view(); let b = other.chunks_view(); @@ -379,6 +396,12 @@ impl WideFieldMask { b.get(i).copied().unwrap_or(0), ); } + while v.last() == Some(&0) { + v.pop(); + } + if v.len() <= 1 { + return Self(WideRepr::Small(v.first().copied().unwrap_or(0))); + } Self(WideRepr::Wide(v.into_boxed_slice())) } @@ -388,6 +411,70 @@ impl WideFieldMask { WideRepr::Wide(v) => v.to_vec(), } } + + /// Chunk `i` of this mask, `0` past the end — the tier-agnostic accessor + /// the canonical-equality/hash impls fold over. + #[inline] + fn chunk_at(&self, i: usize) -> u64 { + match &self.0 { + WideRepr::Small(bits) => { + if i == 0 { + *bits + } else { + 0 + } + } + WideRepr::Wide(v) => v.get(i).copied().unwrap_or(0), + } + } + + /// The number of chunks in this mask's *canonical* form: the raw chunk + /// count with trailing all-zero chunks trimmed. Two masks denote the same + /// field set, regardless of tier, iff they agree on this length AND on + /// every chunk below it — this is the representation-independent view + /// `PartialEq`/`Hash` are built over. + fn canonical_len(&self) -> usize { + let raw = match &self.0 { + WideRepr::Small(_) => 1, + WideRepr::Wide(v) => v.len(), + }; + let mut n = raw; + while n > 0 && self.chunk_at(n - 1) == 0 { + n -= 1; + } + n + } +} + +impl PartialEq for WideFieldMask { + /// Representation-independent equality: two masks are equal iff they + /// denote the same populated field set, regardless of whether either + /// side is `Small` or `Wide` (or, for two `Wide` values, regardless of + /// their chunk-vector lengths) — trailing all-zero chunks never affect + /// equality (V-L P0). + fn eq(&self, other: &Self) -> bool { + let len = self.canonical_len(); + if len != other.canonical_len() { + return false; + } + (0..len).all(|i| self.chunk_at(i) == other.chunk_at(i)) + } +} + +impl Eq for WideFieldMask {} + +impl Hash for WideFieldMask { + /// Must stay consistent with [`PartialEq`]: hashes exactly the canonical + /// (trailing-zero-trimmed) chunk sequence, length-prefixed like a slice's + /// `Hash` impl, so `a == b` implies `hash(a) == hash(b)` regardless of + /// which tier either side happens to be stored in. + fn hash(&self, state: &mut H) { + let len = self.canonical_len(); + len.hash(state); + for i in 0..len { + self.chunk_at(i).hash(state); + } + } } /// One recompute edge in a class's **compute DAG**: field position `target` is @@ -1481,4 +1568,65 @@ mod tests { assert_eq!(promoted.has(i), narrow.has(i), "bit {i} must round-trip"); } } + + /// (V-L P0 regression) `intersect`/`union` results whose high chunks + /// collapse away (down to <= 64 populated positions, or fewer significant + /// chunks than either input) must compare equal to — and hash identically + /// with — the canonical `Small`/`Wide` mask built directly from the same + /// position set. Before this fix, `zip_fold` always returned an + /// un-normalized `Wide`, so e.g. `from_positions(&[2, 70]).intersect(& + /// from_positions(&[2]))` was logically `{2}` but compared UNEQUAL to + /// `from_positions(&[2])` and hashed differently — a footgun for any + /// `HashSet`/`HashMap` keyed on `WideFieldMask` (RBAC folds, DTO dedup). + #[test] + fn intersect_result_collapsing_to_small_equals_canonical_and_hashes_identically() { + use std::collections::hash_map::DefaultHasher; + + fn hash_of(m: &WideFieldMask) -> u64 { + let mut h = DefaultHasher::new(); + m.hash(&mut h); + h.finish() + } + + // intersect: {2, 70} ∩ {2} = {2}, but the left side is Wide — the + // fold must not leave a phantom high chunk behind. + let wide = WideFieldMask::from_positions(&[2, 70]); + let small = WideFieldMask::from_positions(&[2]); + let inter = wide.intersect(&small); + let canonical = WideFieldMask::from_positions(&[2]); + + assert!(matches!(inter.0, WideRepr::Small(_)), "collapsed intersect result must demote to Small, not stay a Wide value with an all-zero high chunk"); + assert_eq!(inter, canonical); + assert_eq!(canonical, inter, "PartialEq must be symmetric"); + assert_eq!(hash_of(&inter), hash_of(&canonical)); + + // union: also exercised, since it shares `zip_fold`. + let a = WideFieldMask::from_positions(&[70]); + let b = WideFieldMask::from_positions(&[70]); + // Force the fold path (both sides already Wide) rather than the + // Small/Small fast path in `union`. + let uni = a.union(&b); + let canonical_uni = WideFieldMask::from_positions(&[70]); + assert_eq!(uni, canonical_uni); + assert_eq!(hash_of(&uni), hash_of(&canonical_uni)); + + // Wide-vs-Wide with different underlying chunk-vector *lengths* that + // denote the same field set: {2, 70} built directly (2 chunks) vs a + // 3-chunk Wide value ({2, 70, 200} minus {200}) that collapses back + // down to the same 2 significant chunks. + let direct_two_chunks = WideFieldMask::from_positions(&[2, 70]); + let three_chunk_source = WideFieldMask::from_positions(&[2, 70, 200]); + let only_200 = WideFieldMask::from_positions(&[200]); + // {2, 70, 200} minus {200}, expressed as intersect-with-complement via + // union/xor-free construction: intersect against the union of the + // low two chunks' full range plus nothing at chunk 3, i.e. simply + // intersect the 3-chunk value against a mask that has {2, 70} set + // (which itself is 2 chunks) — this drives zip_fold's `len = + // a.len().max(b.len())` down from 4 chunks (chunk index 3 for + // position 200) to the 2 significant chunks that remain non-zero. + let three_chunk_minus_200 = three_chunk_source.intersect(&direct_two_chunks); + assert!(!only_200.is_empty()); // sanity: 200 really set a bit somewhere + assert_eq!(three_chunk_minus_200, direct_two_chunks); + assert_eq!(hash_of(&three_chunk_minus_200), hash_of(&direct_two_chunks)); + } }