Skip to main content

sqry_core/graph/unified/storage/
shape.rs

1//! Identifier-blind per-function body-shape descriptor types (V15+).
2//!
3//! A [`ShapeDescriptor`] is a deterministic, renaming-invariant fingerprint of a
4//! function or method body. It is distinct from `body_hash` (the exact
5//! copy-paste hash in `crate::graph::body_hash`): `body_hash` is whitespace-,
6//! identifier-, and literal-sensitive by design, whereas the shape descriptor
7//! erases identifiers and literals and survives reformatting and renaming.
8//!
9//! This module holds only the data model (WS1). The shared walker that POPULATES
10//! these fields (the AST shingle, the `Weisfeiler-Lehman` relabel, the `MinHash`, and
11//! the `shape_hash` itself) lives in `crate::graph::unified::build::shape` (WS2),
12//! and the `NodeId`-keyed side table that STORES them hangs off
13//! [`super::metadata::NodeMetadataStore`] (WS1 side-table step).
14//!
15//! # Determinism and on-disk stability
16//!
17//! Everything here is plain data with a fixed field order so postcard
18//! serialization is deterministic across processes and runs (AC-1, AC-8). The
19//! three constants below ([`SHAPE_SCHEMA_VERSION`], [`CF_BUCKET_COUNT`],
20//! [`MINHASH_LANES`]) are on-disk-affecting: the control-flow bucket count and
21//! the `MinHash` lane count are baked into every serialized descriptor, and the
22//! hashing seeds (registered in `crate::graph::body_hash`) feed `shape_hash`.
23//! Changing any of them changes the bytes of every descriptor, so any such
24//! change MUST bump [`SHAPE_SCHEMA_VERSION`], which forces a recompute on load
25//! mismatch.
26
27use serde::{Deserialize, Serialize};
28
29/// Schema version for the shape descriptor. Bump on ANY change to the canonical
30/// bucket set, the `MinHash` lane count, the shingle/WL algorithm, or the hashing
31/// seeds. A load-time mismatch forces descriptors to recompute on reindex.
32///
33/// This is the shape-side sibling of `INDEX_SCHEMA_VERSION` (the body-hash seed
34/// discipline in `crate::graph::body_hash`).
35pub const SHAPE_SCHEMA_VERSION: u16 = 1;
36
37/// Number of canonical, language-neutral control-flow buckets. Frozen: the order
38/// is the cross-language contract (see `CfBucket` in
39/// `crate::graph::unified::build::shape`). Changes are additive-only (append,
40/// never reorder) and bump [`SHAPE_SCHEMA_VERSION`].
41pub const CF_BUCKET_COUNT: usize = 15;
42
43/// Number of `MinHash` lanes per descriptor. Pinned on disk (it sizes the
44/// serialized `minhash` array); retuning it is a [`SHAPE_SCHEMA_VERSION`] bump.
45pub const MINHASH_LANES: usize = 64;
46
47/// Minimum token count for a body to be hashable. Bodies below this carry
48/// [`ShapeFlags::UNHASHABLE`] instead of a meaningless fingerprint (AC-1).
49pub const MIN_HASHABLE_TOKENS: u16 = 4;
50
51/// Renaming-invariant 128-bit structural hash of a function/method body.
52///
53/// Deliberately mirrors `crate::graph::body_hash::BodyHash128`'s `{ high, low }`
54/// layout so the two stay visually and structurally distinct in review (one is
55/// the exact copy-paste hash, this is the structural hash) and so the 32-char
56/// hex `Display` convention is shared. It is NOT `body_hash`: keeping them
57/// separate preserves the `find_duplicates` Body ladder unchanged (AC-5).
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
59pub struct ShapeHash128 {
60    /// High 64 bits (computed with the `SQRYSHP0` seed in WS2).
61    pub high: u64,
62    /// Low 64 bits (computed with the `SQRYSHP1` seed in WS2).
63    pub low: u64,
64}
65
66impl ShapeHash128 {
67    /// Pack into a single `u128` for comparison and compact storage.
68    #[must_use]
69    pub const fn as_u128(self) -> u128 {
70        ((self.high as u128) << 64) | (self.low as u128)
71    }
72
73    /// Unpack from a `u128`.
74    #[must_use]
75    pub const fn from_u128(value: u128) -> Self {
76        Self {
77            high: (value >> 64) as u64,
78            low: (value & 0xFFFF_FFFF_FFFF_FFFF) as u64,
79        }
80    }
81
82    /// True when both halves are zero (the never-computed sentinel value carried
83    /// by an unhashable descriptor).
84    #[must_use]
85    pub const fn is_zero(self) -> bool {
86        self.high == 0 && self.low == 0
87    }
88}
89
90impl std::fmt::Display for ShapeHash128 {
91    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92        write!(f, "{:016x}{:016x}", self.high, self.low)
93    }
94}
95
96/// Structural signature shape: arities and flags read directly from a function's
97/// parameter list, independent of the return-type-only `signature` slot on
98/// `NodeEntry` (which is frequently null). Language-neutral.
99///
100/// The four booleans are independent, orthogonal signature properties (defaults,
101/// varargs, kwargs, return annotation); collapsing them into an enum or bitset
102/// would obscure the data model, so the `struct_excessive_bools` lint is allowed.
103#[allow(clippy::struct_excessive_bools)]
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
105pub struct SignatureShape {
106    /// Count of positional parameters.
107    pub arity_positional: u16,
108    /// Count of keyword-only / named-only parameters (0 in languages without them).
109    pub arity_keyword_only: u16,
110    /// At least one parameter has a default value.
111    pub has_defaults: bool,
112    /// A variadic positional parameter is present (`*args`, `...`, rest).
113    pub has_varargs: bool,
114    /// A variadic keyword parameter is present (`**kwargs`).
115    pub has_kwargs: bool,
116    /// The function declares a return-type annotation.
117    pub has_return_annotation: bool,
118}
119
120/// Bucketed callee fan-out shape (DEPENDENT EXTENSION, SPEC §3).
121///
122/// Always present on a descriptor. It carries [`CalleeShape::Unresolved`] for
123/// this effort and is never silently zeroed; a `Resolved` value is populated only
124/// once the externally-filed `call-resolution-completeness` gap lands. The
125/// `Resolved` arm exists so the on-disk shape is stable when that work arrives.
126#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
127pub enum CalleeShape {
128    /// Call resolution is not complete; the fan-out shape is unknown (not zero).
129    #[default]
130    Unresolved,
131    /// Resolved fan-out: total call-site count plus a small degree histogram.
132    Resolved {
133        /// Number of outgoing call sites.
134        count: u16,
135        /// Degree buckets (e.g. by callee fan-in tier); shape pinned for stability.
136        degree_buckets: [u16; 4],
137    },
138}
139
140/// Explicit per-descriptor state markers. A bit set means the descriptor is an
141/// honest marker rather than a silently-zeroed fingerprint.
142#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
143pub struct ShapeFlags(pub u8);
144
145impl ShapeFlags {
146    /// Body had fewer than [`MIN_HASHABLE_TOKENS`] tokens; the fingerprint is not
147    /// meaningful (AC-1).
148    pub const UNHASHABLE: u8 = 0b0000_0001;
149    /// Body exceeded the walker node-count budget; the descriptor is partial
150    /// (design §9 over-budget marker).
151    pub const TRUNCATED: u8 = 0b0000_0010;
152
153    /// An empty flag set (the common case for a normal, fully-computed descriptor).
154    #[must_use]
155    pub const fn empty() -> Self {
156        Self(0)
157    }
158
159    /// True when the unhashable marker is set.
160    #[must_use]
161    pub const fn is_unhashable(self) -> bool {
162        self.0 & Self::UNHASHABLE != 0
163    }
164
165    /// True when the truncated marker is set.
166    #[must_use]
167    pub const fn is_truncated(self) -> bool {
168        self.0 & Self::TRUNCATED != 0
169    }
170
171    /// Set the unhashable marker.
172    pub const fn set_unhashable(&mut self) {
173        self.0 |= Self::UNHASHABLE;
174    }
175
176    /// Set the truncated marker.
177    pub const fn set_truncated(&mut self) {
178        self.0 |= Self::TRUNCATED;
179    }
180}
181
182/// Identifier-blind structural fingerprint of one function/method body.
183///
184/// Deterministic function of the source AST (never the source text); versioned
185/// via [`SHAPE_SCHEMA_VERSION`]. Stored in a `NodeId`-keyed side table on
186/// `NodeMetadataStore`, kept off the hot-path `NodeEntry` because descriptors are
187/// larger and not needed for most traversals.
188///
189/// Not `Copy`: it carries a [`MINHASH_LANES`]-wide array, so it is cloned (not
190/// memcpy'd by value) into snapshots.
191#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
192pub struct ShapeDescriptor {
193    /// Canonical control-flow bucket counts, fixed order (see `CfBucket`).
194    pub cf_histogram: [u16; CF_BUCKET_COUNT],
195    /// Structural signature shape (arities + flags).
196    pub signature_shape: SignatureShape,
197    /// Bucketed callee fan-out (dependent extension; `Unresolved` this effort).
198    pub callee_shape: CalleeShape,
199    /// Renaming-invariant structural hash (NOT `body_hash`).
200    pub shape_hash: ShapeHash128,
201    /// `MinHash` signature over identifier-erased WL labels, for LSH banding.
202    ///
203    /// Serialized via [`minhash_serde`] because serde only derives array
204    /// `Deserialize` up to length 32; the fixed-tuple encoding is byte-identical
205    /// to a native `[u32; MINHASH_LANES]` in postcard.
206    #[serde(with = "minhash_serde")]
207    pub minhash: [u32; MINHASH_LANES],
208    /// Explicit state markers (unhashable / truncated).
209    pub flags: ShapeFlags,
210}
211
212impl Default for ShapeDescriptor {
213    fn default() -> Self {
214        // `[u32; MINHASH_LANES]` does not derive `Default` (std only derives it
215        // for arrays up to length 32), so construct the whole value by hand.
216        Self {
217            cf_histogram: [0; CF_BUCKET_COUNT],
218            signature_shape: SignatureShape::default(),
219            callee_shape: CalleeShape::default(),
220            shape_hash: ShapeHash128::default(),
221            minhash: [0; MINHASH_LANES],
222            flags: ShapeFlags::empty(),
223        }
224    }
225}
226
227impl ShapeDescriptor {
228    /// Construct the explicit "unhashable" descriptor for a sub-[`MIN_HASHABLE_TOKENS`]
229    /// body: empty histogram and `MinHash`, zero `shape_hash`, the unhashable flag
230    /// set, and the given signature shape (which is still meaningful for tiny
231    /// bodies). This is an honest marker, never a silently-zeroed descriptor (AC-1).
232    #[must_use]
233    pub fn unhashable(signature_shape: SignatureShape) -> Self {
234        let mut flags = ShapeFlags::empty();
235        flags.set_unhashable();
236        Self {
237            signature_shape,
238            flags,
239            ..Self::default()
240        }
241    }
242
243    /// True when this descriptor is the unhashable marker.
244    #[must_use]
245    pub const fn is_unhashable(&self) -> bool {
246        self.flags.is_unhashable()
247    }
248
249    /// True when this descriptor was truncated by the walker node-count budget.
250    #[must_use]
251    pub const fn is_truncated(&self) -> bool {
252        self.flags.is_truncated()
253    }
254}
255
256/// Serde adapter for the fixed-size `minhash` array.
257///
258/// serde only derives `Deserialize` for arrays up to length 32, so a
259/// [`MINHASH_LANES`]-wide array needs a hand-written adapter. Serializing as a
260/// fixed-length tuple (the technique `serde-big-array` uses) keeps the on-disk
261/// encoding byte-identical to a native array under postcard (no length prefix)
262/// and produces a plain JSON array under `serde_json`.
263mod minhash_serde {
264    use super::MINHASH_LANES;
265    use serde::de::{self, Deserializer, SeqAccess, Visitor};
266    use serde::ser::{SerializeTuple, Serializer};
267    use std::fmt;
268
269    /// Serialize the array as a fixed-length tuple of `MINHASH_LANES` elements.
270    ///
271    /// # Errors
272    ///
273    /// Propagates the serializer's error if an element cannot be encoded.
274    pub fn serialize<S>(value: &[u32; MINHASH_LANES], serializer: S) -> Result<S::Ok, S::Error>
275    where
276        S: Serializer,
277    {
278        let mut tup = serializer.serialize_tuple(MINHASH_LANES)?;
279        for lane in value {
280            tup.serialize_element(lane)?;
281        }
282        tup.end()
283    }
284
285    /// Deserialize exactly `MINHASH_LANES` elements back into the fixed array.
286    ///
287    /// # Errors
288    ///
289    /// Returns an `invalid_length` error if fewer than `MINHASH_LANES` elements
290    /// are present, or any element fails to decode.
291    pub fn deserialize<'de, D>(deserializer: D) -> Result<[u32; MINHASH_LANES], D::Error>
292    where
293        D: Deserializer<'de>,
294    {
295        struct LanesVisitor;
296
297        impl<'de> Visitor<'de> for LanesVisitor {
298            type Value = [u32; MINHASH_LANES];
299
300            fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
301                write!(f, "an array of {MINHASH_LANES} u32 `MinHash` lanes")
302            }
303
304            fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
305            where
306                A: SeqAccess<'de>,
307            {
308                let mut lanes = [0u32; MINHASH_LANES];
309                for (i, slot) in lanes.iter_mut().enumerate() {
310                    *slot = seq
311                        .next_element()?
312                        .ok_or_else(|| de::Error::invalid_length(i, &self))?;
313                }
314                Ok(lanes)
315            }
316        }
317
318        deserializer.deserialize_tuple(MINHASH_LANES, LanesVisitor)
319    }
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325
326    #[test]
327    fn shape_hash_u128_roundtrip() {
328        let h = ShapeHash128 {
329            high: 0x1234_5678_9abc_def0,
330            low: 0x0fed_cba9_8765_4321,
331        };
332        assert_eq!(ShapeHash128::from_u128(h.as_u128()), h);
333    }
334
335    #[test]
336    fn shape_hash_display_is_32_hex() {
337        let h = ShapeHash128 {
338            high: 0x1234_5678_90ab_cdef,
339            low: 0xfedc_ba09_8765_4321,
340        };
341        let s = format!("{h}");
342        assert_eq!(s, "1234567890abcdeffedcba0987654321");
343        assert_eq!(s.len(), 32);
344    }
345
346    #[test]
347    fn shape_hash_zero_sentinel() {
348        assert!(ShapeHash128::default().is_zero());
349        assert!(!ShapeHash128 { high: 0, low: 1 }.is_zero());
350    }
351
352    #[test]
353    fn callee_shape_defaults_unresolved() {
354        assert_eq!(CalleeShape::default(), CalleeShape::Unresolved);
355    }
356
357    #[test]
358    fn flags_set_and_query() {
359        let mut f = ShapeFlags::empty();
360        assert!(!f.is_unhashable() && !f.is_truncated());
361        f.set_unhashable();
362        assert!(f.is_unhashable() && !f.is_truncated());
363        f.set_truncated();
364        assert!(f.is_unhashable() && f.is_truncated());
365    }
366
367    #[test]
368    fn default_descriptor_is_empty_but_present() {
369        let d = ShapeDescriptor::default();
370        assert_eq!(d.cf_histogram, [0; CF_BUCKET_COUNT]);
371        assert_eq!(d.minhash, [0; MINHASH_LANES]);
372        assert_eq!(d.callee_shape, CalleeShape::Unresolved);
373        assert!(d.shape_hash.is_zero());
374        assert!(!d.is_unhashable());
375        assert!(!d.is_truncated());
376    }
377
378    #[test]
379    fn unhashable_constructor_sets_marker_and_keeps_signature() {
380        let sig = SignatureShape {
381            arity_positional: 2,
382            has_return_annotation: true,
383            ..SignatureShape::default()
384        };
385        let d = ShapeDescriptor::unhashable(sig);
386        assert!(d.is_unhashable());
387        assert_eq!(d.signature_shape, sig);
388        assert!(d.shape_hash.is_zero());
389        assert_eq!(d.cf_histogram, [0; CF_BUCKET_COUNT]);
390    }
391
392    #[test]
393    fn descriptor_postcard_roundtrip_with_full_minhash() {
394        let mut d = ShapeDescriptor::default();
395        for (i, slot) in d.minhash.iter_mut().enumerate() {
396            *slot = (i as u32).wrapping_mul(2_654_435_761);
397        }
398        d.cf_histogram[0] = 7;
399        d.cf_histogram[CF_BUCKET_COUNT - 1] = 3;
400        d.signature_shape.arity_positional = 4;
401        d.signature_shape.has_kwargs = true;
402        d.callee_shape = CalleeShape::Resolved {
403            count: 9,
404            degree_buckets: [1, 2, 3, 4],
405        };
406        d.shape_hash = ShapeHash128 { high: 11, low: 22 };
407        d.flags.set_truncated();
408
409        let bytes = postcard::to_allocvec(&d).expect("serialize");
410        let back: ShapeDescriptor = postcard::from_bytes(&bytes).expect("deserialize");
411        assert_eq!(d, back);
412    }
413
414    #[test]
415    fn descriptor_json_roundtrip() {
416        let d = ShapeDescriptor::default();
417        let json = serde_json::to_string(&d).expect("to json");
418        let back: ShapeDescriptor = serde_json::from_str(&json).expect("from json");
419        assert_eq!(d, back);
420    }
421
422    #[test]
423    fn constants_are_frozen() {
424        // Guards against accidental edits that would silently break on-disk
425        // stability without a SHAPE_SCHEMA_VERSION bump.
426        assert_eq!(CF_BUCKET_COUNT, 15);
427        assert_eq!(MINHASH_LANES, 64);
428        assert_eq!(SHAPE_SCHEMA_VERSION, 1);
429        assert_eq!(MIN_HASHABLE_TOKENS, 4);
430    }
431}