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}