codehelion_core/features.rs
1//! Structural-mode candidate-extraction features over the Syntax IR.
2//!
3//! Structural mode never compares whole units pairwise — that is quadratic in
4//! corpus size. Instead every unit (function, method, closure) is reduced to a
5//! set of cheap per-unit features, and candidate pairs are proposed only where
6//! features collide or lie close. One pass over a [`SyntaxIrFile`] extracts
7//! four feature families per unit:
8//!
9//! - statement windows ([`WindowFeature`]): hashes of fixed-length runs of
10//! adjacent statements, the fragment-level candidate signal;
11//! - subtree fingerprints ([`SubtreeFeature`]): Merkle hashes over the IR
12//! tree, the exact structural-match signal;
13//! - a characteristic vector ([`CharacteristicVector`]): shape-tag counts
14//! used as a cheap candidate filter;
15//! - an approximate control-flow profile ([`CfgFeature`]) and an API-call
16//! profile ([`ApiCallFeature`]).
17//!
18//! # Rename invariance
19//!
20//! Candidate extraction must survive Type-2 edits, so no identifier text and
21//! no literal text enters any hash, with one deliberate exception: API-call
22//! names. Lexical signal comes exclusively from token kind tags
23//! ([`TokenKind::tag`]) and shape tags ([`Shape::tag`]). API-call names are
24//! exempt because external API names are normalization-exempt, matching the
25//! Fast engine's treatment of external names.
26//!
27//! # The control-flow profile is syntactic
28//!
29//! [`CfgFeature`] is a syntactic approximation built from AST control shapes,
30//! not a real control-flow graph: it linearises loop, branch and match
31//! nesting plus control statements in source order. A compiler-provided CFG
32//! can replace it behind the same feature interface in a later phase; doing
33//! so changes feature derivation and therefore bumps
34//! [`FEATURE_SCHEMA_VERSION`].
35//!
36//! # Determinism
37//!
38//! Every output is derived from source order alone; no hash-map iteration
39//! order reaches any feature. Extracting twice from the same IR yields
40//! identical results.
41
42use core::fmt;
43
44use crate::frontend::{Lexeme, Token, TokenKind};
45use crate::ir::{ByteRange, IrNode, SUMMARY_HEAD_TOKENS, Shape, SyntaxIrFile};
46
47/// Version of the feature-derivation recipe.
48///
49/// Written into every feature hash after the domain string. Bump it when any
50/// feature's input derivation changes, so features from incompatible recipes
51/// never collide silently.
52pub const FEATURE_SCHEMA_VERSION: &str = "ir-features-v1";
53
54/// Statement-window lengths, in statements. Windows slide with stride 1 over
55/// each block's statement sequence; a block shorter than a length yields no
56/// window of that length.
57pub const WINDOW_LENGTHS: &[usize] = &[4, 8, 16];
58
59/// Minimum subtree size, in nodes (the subtree root included), for a
60/// [`SubtreeFeature`] to be emitted. Smaller subtrees are ubiquitous and
61/// would only inflate the candidate index.
62pub const MIN_SUBTREE_NODES: usize = 5;
63
64/// Number of slots in [`CharacteristicVector::counts`]: one per [`Shape`]
65/// tag, with slot 0 unused because tags start at 1.
66pub const SHAPE_TAG_SLOTS: usize = 23;
67
68/// The kind of a persisted feature hash.
69///
70/// These name the hash-valued feature families the candidate index keys on.
71/// Unlike a stable identifier, a feature hash is only meaningful within one
72/// [`FEATURE_SCHEMA_VERSION`]; the persistence layer stores that version
73/// alongside the hash so hashes from incompatible recipes never merge.
74///
75/// The [`CharacteristicVector`] is deliberately absent: it is a count vector,
76/// not a single hash, and is persisted as scalars rather than an index key.
77#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
78pub enum FeatureKind {
79 /// A [`WindowFeature`]: a fixed-length run of adjacent statements.
80 StatementWindow,
81 /// A [`SubtreeFeature`]: a Merkle hash over an IR subtree.
82 Subtree,
83 /// A [`CfgFeature`]: the approximate control-flow op sequence.
84 Cfg,
85 /// An [`ApiCallFeature::sequence_hash`]: callee names in source order.
86 ApiCallSequence,
87 /// An [`ApiCallFeature::multiset_hash`]: the order-independent callee set.
88 ApiCallMultiset,
89}
90
91impl FeatureKind {
92 /// Every kind, in declaration order.
93 pub const ALL: [Self; 5] = [
94 Self::StatementWindow,
95 Self::Subtree,
96 Self::Cfg,
97 Self::ApiCallSequence,
98 Self::ApiCallMultiset,
99 ];
100
101 /// The stable snake-case name used in storage and reports.
102 #[must_use]
103 pub const fn name(self) -> &'static str {
104 match self {
105 Self::StatementWindow => "statement_window",
106 Self::Subtree => "subtree",
107 Self::Cfg => "cfg",
108 Self::ApiCallSequence => "api_call_sequence",
109 Self::ApiCallMultiset => "api_call_multiset",
110 }
111 }
112
113 /// Parse a [`name`](Self::name) back into its kind.
114 #[must_use]
115 pub fn from_name(name: &str) -> Option<Self> {
116 Self::ALL.into_iter().find(|kind| kind.name() == name)
117 }
118}
119
120/// A 128-bit feature hash.
121///
122/// Feature hashes are candidate-index keys, not stable identifiers: they are
123/// valid only within one [`FEATURE_SCHEMA_VERSION`]. Each is a BLAKE3 digest
124/// over a domain string, the schema version and the feature's length-prefixed
125/// inputs, truncated to 16 bytes.
126#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
127pub struct FeatureHash([u8; 16]);
128
129impl FeatureHash {
130 /// Wrap hash bytes produced earlier by this module.
131 #[must_use]
132 pub const fn from_bytes(bytes: [u8; 16]) -> Self {
133 Self(bytes)
134 }
135
136 /// The hash's raw bytes.
137 #[must_use]
138 pub const fn as_bytes(&self) -> &[u8; 16] {
139 &self.0
140 }
141
142 /// Lowercase hex form used in reports.
143 #[must_use]
144 pub fn to_hex(&self) -> String {
145 self.to_string()
146 }
147}
148
149impl fmt::Display for FeatureHash {
150 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
151 for byte in self.0 {
152 write!(f, "{byte:02x}")?;
153 }
154 Ok(())
155 }
156}
157
158/// Length-prefixed BLAKE3 hashing with a leading domain tag, following the
159/// same conventions as the stable-identifier hasher: the domain string is
160/// written first, then [`FEATURE_SCHEMA_VERSION`], then the caller's fields;
161/// variable-length fields are length-prefixed.
162struct FeatureHasher {
163 hasher: blake3::Hasher,
164}
165
166impl FeatureHasher {
167 fn new(domain: &str) -> Self {
168 let mut this = Self {
169 hasher: blake3::Hasher::new(),
170 };
171 this.write_bytes(domain.as_bytes());
172 this.write_bytes(FEATURE_SCHEMA_VERSION.as_bytes());
173 this
174 }
175
176 fn write_bytes(&mut self, bytes: &[u8]) {
177 let len = u32::try_from(bytes.len()).unwrap_or(u32::MAX);
178 self.hasher.update(&len.to_le_bytes());
179 self.hasher.update(bytes);
180 }
181
182 fn write_str(&mut self, text: &str) {
183 self.write_bytes(text.as_bytes());
184 }
185
186 fn write_u8(&mut self, value: u8) {
187 self.hasher.update(&[value]);
188 }
189
190 fn write_u32(&mut self, value: u32) {
191 self.hasher.update(&value.to_le_bytes());
192 }
193
194 fn finish(self) -> FeatureHash {
195 let digest = self.hasher.finalize();
196 let mut out = [0u8; 16];
197 out.copy_from_slice(&digest.as_bytes()[..16]);
198 FeatureHash(out)
199 }
200}
201
202/// The features of every unit in one file, in pre-order source order.
203#[derive(Debug, Clone, PartialEq, Eq)]
204pub struct FileFeatures {
205 /// Per-unit features, one entry per function, method or closure node.
206 pub units: Vec<UnitFeatures>,
207}
208
209/// The candidate-extraction features of one unit.
210///
211/// A unit's features are computed over its full subtree, nested closures and
212/// local functions included, while each nested unit also gets an entry of its
213/// own. This double counting is deliberate v0 granularity: the outer unit
214/// stays comparable as a whole, and the nested unit remains independently
215/// discoverable.
216#[derive(Debug, Clone, PartialEq, Eq)]
217pub struct UnitFeatures {
218 /// The unit's declared name, when the frontend recovered one.
219 pub name: Option<Lexeme>,
220 /// Shape tag of the unit node (see [`Shape::tag`]).
221 pub shape_tag: u8,
222 /// Source bytes the unit covers; reporting only.
223 pub range: ByteRange,
224 /// Statement-window hashes over every block in the unit subtree.
225 pub windows: Vec<WindowFeature>,
226 /// Merkle subtree fingerprints of size [`MIN_SUBTREE_NODES`] and up,
227 /// emitted in post-order.
228 pub subtrees: Vec<SubtreeFeature>,
229 /// The unit's characteristic vector.
230 pub vector: CharacteristicVector,
231 /// The unit's approximate control-flow profile.
232 pub cfg: CfgFeature,
233 /// The unit's API-call profile.
234 pub api: ApiCallFeature,
235}
236
237/// A reference to one unit inside a slice of [`FileFeatures`].
238///
239/// The unit-level candidate stages all speak in these: `file` indexes the slice
240/// they were given, `unit` indexes that file's [`FileFeatures::units`], and
241/// `node_count` is carried along because every stage that proposes unit pairs
242/// gates them on relative size.
243#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
244pub struct UnitRef {
245 /// Index of the file in the input slice.
246 pub file: usize,
247 /// Index of the unit in the file's units.
248 pub unit: usize,
249 /// Node count of the unit subtree; the size used by length-ratio gates.
250 pub node_count: u32,
251}
252
253impl UnitRef {
254 /// Whether this unit and `other` are within `max_ratio` of each other in
255 /// size. A large and a small unit are not a gapped copy of one another
256 /// however their features happened to meet, so every stage that proposes
257 /// unit pairs applies this before emitting one.
258 #[must_use]
259 pub fn within_length_ratio(self, other: Self, max_ratio: f64) -> bool {
260 let (small, large) = if self.node_count <= other.node_count {
261 (self.node_count, other.node_count)
262 } else {
263 (other.node_count, self.node_count)
264 };
265 if small == 0 {
266 return large == 0;
267 }
268 f64::from(large) / f64::from(small) <= max_ratio
269 }
270}
271
272/// One statement window: a fixed-length run of adjacent statements inside one
273/// block, hashed from per-statement summaries.
274///
275/// The statements of a block are its direct children selected exactly as
276/// [`IrNode::statement_summaries`] selects them: statement shapes plus
277/// [`Shape::Native`] children. Each statement contributes its shape tag, its
278/// native kind name (empty for common shapes) and the kind tags of its first
279/// [`SUMMARY_HEAD_TOKENS`] tokens — kinds, never texts, so consistent renames
280/// leave the hash unchanged.
281#[derive(Debug, Clone, PartialEq, Eq)]
282pub struct WindowFeature {
283 /// Hash over the window's per-statement summaries.
284 pub hash: FeatureHash,
285 /// Window length, in statements.
286 pub length: usize,
287 /// Bytes from the first through the last statement; reporting only.
288 pub range: ByteRange,
289 /// Ordinal of the enclosing block within the unit, in walk order.
290 ///
291 /// Position, never identity: this locates the window so adjacent windows
292 /// can be folded back into one statement run, and it never enters a hash
293 /// (AGENTS.md invariant 3).
294 pub block: u32,
295 /// Index of the window's first statement within its block's statement
296 /// sequence. Position, never identity, as for [`Self::block`].
297 pub offset: u32,
298}
299
300/// One subtree fingerprint: a Merkle hash over an IR subtree.
301///
302/// `hash(node)` covers the node's shape tag, its native kind name and its
303/// children's hashes in order — names and tokens are excluded, so two
304/// subtrees match exactly when their shapes match node for node.
305#[derive(Debug, Clone, PartialEq, Eq)]
306pub struct SubtreeFeature {
307 /// The subtree's Merkle hash.
308 pub hash: FeatureHash,
309 /// Number of nodes in the subtree, its root included.
310 pub node_count: usize,
311 /// Source bytes the subtree root covers; reporting only.
312 pub range: ByteRange,
313}
314
315/// Shape-tag counts plus tree size and depth: a candidate filter.
316///
317/// The count vector is a cheap lower-bound proxy for tree edit distance: two
318/// subtrees within edit distance `d` differ by at most `2 * d` in L1 count
319/// distance, so a large [`CharacteristicVector::l1_distance`] rules a pair
320/// out without touching either tree. That is what
321/// [`CharacteristicVector::shape_divergence`] gates candidate pairs on, and
322/// what [`CharacteristicVector::cosine_similarity`] contributes to the
323/// structural dimension of a verdict.
324#[derive(Debug, Clone, Default, PartialEq, Eq)]
325pub struct CharacteristicVector {
326 /// Node count per shape tag; index = tag, index 0 unused.
327 pub counts: [u32; SHAPE_TAG_SLOTS],
328 /// Number of nodes on the longest root-to-leaf path of the unit subtree;
329 /// a lone node has depth 1.
330 pub max_depth: u32,
331 /// Total number of nodes in the unit subtree.
332 pub node_count: u32,
333}
334
335impl CharacteristicVector {
336 /// L1 distance between the two count vectors. Depth and node count do
337 /// not participate.
338 #[must_use]
339 pub fn l1_distance(&self, other: &Self) -> u64 {
340 self.counts
341 .iter()
342 .zip(other.counts.iter())
343 .map(|(&a, &b)| u64::from(a.abs_diff(b)))
344 .sum()
345 }
346
347 /// How far apart the two shape mixes are, on a `0.0`–`1.0` scale: the L1
348 /// count distance over the nodes the two units have between them. `0.0`
349 /// when they hold the same shapes in the same numbers, `1.0` when they
350 /// share no shape at all. `0.0` for two empty vectors, which are not
351 /// divergent — they are simply nothing to tell apart.
352 ///
353 /// Size is part of it, and deliberately so: the vectors sum to their unit
354 /// node counts, so the distance is at least `|na - nb| / (na + nb)` and a
355 /// pair whose sizes differ by a factor of `r` scores at least
356 /// `(r - 1) / (r + 1)` before any difference in shape mix is counted.
357 /// A limit of 0.5 therefore says exactly what
358 /// [`max_length_ratio`](crate::near_match::NearMatchConfig::max_length_ratio)'s
359 /// 3.0 says about size, and says it about the shape mix too.
360 #[must_use]
361 pub fn shape_divergence(&self, other: &Self) -> f64 {
362 let span = u64::from(self.node_count) + u64::from(other.node_count);
363 if span == 0 {
364 return 0.0;
365 }
366 // Both vectors sum to at most their node counts, so the distance
367 // cannot exceed the span and the result stays inside the unit range.
368 #[expect(
369 clippy::cast_precision_loss,
370 reason = "node counts of this size lose nothing a threshold comparison would notice"
371 )]
372 {
373 self.l1_distance(other) as f64 / span as f64
374 }
375 }
376
377 /// Cosine similarity of the two count vectors, `0.0` when either vector
378 /// is all-zero. Depth and node count do not participate.
379 #[must_use]
380 pub fn cosine_similarity(&self, other: &Self) -> f64 {
381 if self.counts.iter().all(|&c| c == 0) || other.counts.iter().all(|&c| c == 0) {
382 return 0.0;
383 }
384 let mut dot = 0.0f64;
385 let mut norm_self = 0.0f64;
386 let mut norm_other = 0.0f64;
387 for (&a, &b) in self.counts.iter().zip(other.counts.iter()) {
388 let (a, b) = (f64::from(a), f64::from(b));
389 dot = a.mul_add(b, dot);
390 norm_self = a.mul_add(a, norm_self);
391 norm_other = b.mul_add(b, norm_other);
392 }
393 dot / (norm_self * norm_other).sqrt()
394 }
395}
396
397/// The approximate control-flow profile of one unit.
398///
399/// Built by one pre-order walk that emits a control-op byte sequence: loop,
400/// branch and match-arm enters and exits, a match enter carrying its arm
401/// count, and single ops for `try`, `return`, `break`, `continue` and calls.
402/// See the module documentation for why this is a syntactic approximation.
403#[derive(Debug, Clone, PartialEq, Eq)]
404pub struct CfgFeature {
405 /// Hash over the control-op sequence.
406 pub hash: FeatureHash,
407 /// Hash over the same sequence with calls left out: the unit's branching
408 /// and looping shape alone.
409 ///
410 /// A call is an operation the unit performs, not a fork in the path
411 /// through it, and codehelion already describes calls separately in
412 /// [`ApiCallFeature`]. Keeping them out of one of the two hashes gives a
413 /// key that survives an edit which only adds calls, which is what makes it
414 /// usable as a candidate-extraction index.
415 pub skeleton_hash: FeatureHash,
416 /// Number of control ops emitted.
417 pub op_count: u32,
418 /// Number of ops behind [`Self::skeleton_hash`]: `op_count` less the calls.
419 pub skeleton_ops: u32,
420 /// Deepest loop nesting in the unit subtree; `0` without loops.
421 pub max_loop_depth: u32,
422 /// Number of two-way conditionals in the unit subtree.
423 pub branch_count: u32,
424}
425
426/// The API-call profile of one unit.
427///
428/// This is the one feature where identifier text enters hashes — by design:
429/// external API names are normalization-exempt, matching the Fast engine's
430/// treatment of external names. The callee of a call is approximated as the
431/// last identifier token strictly before the call's first `(` token, which
432/// covers `f(...)`, `obj.method(...)` and `ns::f(...)`; calls where no such
433/// identifier exists are skipped.
434#[derive(Debug, Clone, PartialEq, Eq)]
435pub struct ApiCallFeature {
436 /// Callee names in source order.
437 pub names: Vec<Lexeme>,
438 /// Hash over the names in source order.
439 pub sequence_hash: FeatureHash,
440 /// Hash over the sorted names: the order-independent multiset view.
441 pub multiset_hash: FeatureHash,
442}
443
444/// Control-op byte values of the [`CfgFeature`] sequence.
445const OP_LOOP_ENTER: u8 = 1;
446const OP_LOOP_EXIT: u8 = 2;
447const OP_BRANCH_ENTER: u8 = 3;
448const OP_BRANCH_EXIT: u8 = 4;
449const OP_MATCH_ENTER: u8 = 5;
450const OP_MATCH_EXIT: u8 = 6;
451const OP_ARM_ENTER: u8 = 7;
452const OP_ARM_EXIT: u8 = 8;
453const OP_TRY: u8 = 9;
454const OP_RETURN: u8 = 10;
455const OP_BREAK: u8 = 11;
456const OP_CONTINUE: u8 = 12;
457const OP_CALL: u8 = 13;
458
459/// Extract the candidate features of every unit in `file`.
460///
461/// Units are the nodes whose shape is [`Shape::Function`], [`Shape::Method`]
462/// or [`Shape::Closure`], visited in pre-order, so a nested closure or local
463/// function yields its own entry after its host's.
464#[must_use]
465pub fn extract(file: &SyntaxIrFile) -> FileFeatures {
466 let mut units = Vec::new();
467 file.walk(&mut |node| {
468 if matches!(node.shape, Shape::Function | Shape::Method | Shape::Closure) {
469 units.push(unit_features(node, &file.tokens));
470 }
471 });
472 FileFeatures { units }
473}
474
475/// Compute all four feature families for one unit subtree.
476fn unit_features(unit: &IrNode, tokens: &[Token]) -> UnitFeatures {
477 let mut windows = Vec::new();
478 let mut block = 0u32;
479 unit.walk(&mut |node| {
480 if matches!(node.shape, Shape::Block) {
481 block_windows(node, block, tokens, &mut windows);
482 block = block.saturating_add(1);
483 }
484 });
485
486 let mut subtrees = Vec::new();
487 let _ = subtree_features(unit, &mut subtrees);
488
489 let mut vector = CharacteristicVector::default();
490 accumulate_vector(unit, 1, &mut vector);
491
492 UnitFeatures {
493 name: unit.name.clone(),
494 shape_tag: unit.shape.tag(),
495 range: unit.range,
496 windows,
497 subtrees,
498 vector,
499 cfg: cfg_feature(unit),
500 api: api_feature(unit, tokens),
501 }
502}
503
504/// The native kind name of a shape; empty for the common shapes.
505fn native_kind(shape: &Shape) -> &str {
506 match shape {
507 Shape::Native(kind) => kind.as_str(),
508 _ => "",
509 }
510}
511
512/// Slide every window length over one block's statement sequence.
513fn block_windows(block: &IrNode, ordinal: u32, tokens: &[Token], out: &mut Vec<WindowFeature>) {
514 let statements: Vec<&IrNode> = block
515 .children
516 .iter()
517 .filter(|child| child.shape.is_statement() || matches!(child.shape, Shape::Native(_)))
518 .collect();
519 for &length in WINDOW_LENGTHS {
520 for (offset, window) in statements.windows(length).enumerate() {
521 let mut hasher = FeatureHasher::new("stmt-window");
522 hasher.write_u32(u32::try_from(length).unwrap_or(u32::MAX));
523 for statement in window {
524 write_statement(&mut hasher, statement, tokens);
525 }
526 out.push(WindowFeature {
527 hash: hasher.finish(),
528 length,
529 range: ByteRange {
530 start: window[0].range.start,
531 end: window[length - 1].range.end,
532 },
533 block: ordinal,
534 offset: u32::try_from(offset).unwrap_or(u32::MAX),
535 });
536 }
537 }
538}
539
540/// Write one statement's summary: shape tag, native kind name, and the kind
541/// tags — never the texts — of its leading tokens.
542fn write_statement(hasher: &mut FeatureHasher, statement: &IrNode, tokens: &[Token]) {
543 hasher.write_u8(statement.shape.tag());
544 hasher.write_str(native_kind(&statement.shape));
545 let end = statement.token_end.min(tokens.len());
546 let start = statement.token_start.min(end);
547 let head_tags: Vec<u8> = tokens[start..end]
548 .iter()
549 .take(SUMMARY_HEAD_TOKENS)
550 .map(|token| token.kind.tag())
551 .collect();
552 hasher.write_bytes(&head_tags);
553}
554
555/// One post-order pass computing every node's Merkle hash and subtree size,
556/// emitting a [`SubtreeFeature`] for subtrees of qualifying size. Children
557/// are emitted before their ancestors.
558fn subtree_features(node: &IrNode, out: &mut Vec<SubtreeFeature>) -> (FeatureHash, usize) {
559 struct Frame<'a> {
560 node: &'a IrNode,
561 next_child: usize,
562 child_hashes: Vec<FeatureHash>,
563 node_count: usize,
564 }
565
566 let mut pending = vec![Frame {
567 node,
568 next_child: 0,
569 child_hashes: Vec::with_capacity(node.children.len()),
570 node_count: 1,
571 }];
572 loop {
573 let Some(frame) = pending.last_mut() else {
574 unreachable!("the root frame is retained until its result is returned");
575 };
576 if let Some(child) = frame.node.children.get(frame.next_child) {
577 frame.next_child += 1;
578 pending.push(Frame {
579 node: child,
580 next_child: 0,
581 child_hashes: Vec::with_capacity(child.children.len()),
582 node_count: 1,
583 });
584 continue;
585 }
586
587 let mut hasher = FeatureHasher::new("subtree");
588 hasher.write_u8(frame.node.shape.tag());
589 hasher.write_str(native_kind(&frame.node.shape));
590 hasher.write_u32(u32::try_from(frame.child_hashes.len()).unwrap_or(u32::MAX));
591 for hash in &frame.child_hashes {
592 hasher.write_bytes(hash.as_bytes());
593 }
594 let hash = hasher.finish();
595 if frame.node_count >= MIN_SUBTREE_NODES {
596 out.push(SubtreeFeature {
597 hash,
598 node_count: frame.node_count,
599 range: frame.node.range,
600 });
601 }
602 let result = (hash, frame.node_count);
603 pending.pop();
604 let Some(parent) = pending.last_mut() else {
605 return result;
606 };
607 parent.child_hashes.push(result.0);
608 parent.node_count += result.1;
609 }
610}
611
612/// Accumulate shape counts, depth and size over one subtree. The subtree
613/// root is at depth 1.
614fn accumulate_vector(node: &IrNode, depth: u32, vector: &mut CharacteristicVector) {
615 let mut pending = vec![(node, depth)];
616 while let Some((node, depth)) = pending.pop() {
617 vector.node_count += 1;
618 vector.max_depth = vector.max_depth.max(depth);
619 vector.counts[usize::from(node.shape.tag())] += 1;
620 pending.extend(
621 node.children
622 .iter()
623 .rev()
624 .map(|child| (child, depth.saturating_add(1))),
625 );
626 }
627}
628
629/// State of the control-op walk behind [`CfgFeature`].
630#[derive(Default)]
631struct CfgWalk {
632 ops: Vec<u8>,
633 skeleton: Vec<u8>,
634 op_count: u32,
635 skeleton_ops: u32,
636 branch_count: u32,
637 loop_depth: u32,
638 max_loop_depth: u32,
639}
640
641impl CfgWalk {
642 fn push_op(&mut self, op: u8) {
643 self.ops.push(op);
644 self.op_count += 1;
645 if op != OP_CALL {
646 self.skeleton.push(op);
647 self.skeleton_ops += 1;
648 }
649 }
650
651 /// Write raw operand bytes to both sequences: they qualify the op they
652 /// follow rather than being ops themselves, so they are not counted.
653 fn push_operand(&mut self, bytes: &[u8]) {
654 self.ops.extend_from_slice(bytes);
655 self.skeleton.extend_from_slice(bytes);
656 }
657
658 fn visit(&mut self, node: &IrNode) {
659 enum Visit<'a> {
660 Enter(&'a IrNode),
661 Exit { op: u8, leaves_loop: bool },
662 }
663
664 let mut pending = vec![Visit::Enter(node)];
665 while let Some(visit) = pending.pop() {
666 match visit {
667 Visit::Exit { op, leaves_loop } => {
668 if leaves_loop {
669 self.loop_depth -= 1;
670 }
671 self.push_op(op);
672 }
673 Visit::Enter(node) => {
674 let exit = match &node.shape {
675 Shape::Loop => {
676 self.push_op(OP_LOOP_ENTER);
677 self.loop_depth += 1;
678 self.max_loop_depth = self.max_loop_depth.max(self.loop_depth);
679 Some(Visit::Exit {
680 op: OP_LOOP_EXIT,
681 leaves_loop: true,
682 })
683 }
684 Shape::Branch => {
685 self.push_op(OP_BRANCH_ENTER);
686 self.branch_count += 1;
687 Some(Visit::Exit {
688 op: OP_BRANCH_EXIT,
689 leaves_loop: false,
690 })
691 }
692 Shape::Match => {
693 self.push_op(OP_MATCH_ENTER);
694 let arms = node
695 .children
696 .iter()
697 .filter(|child| matches!(child.shape, Shape::MatchArm))
698 .count();
699 let arms = u32::try_from(arms).unwrap_or(u32::MAX);
700 self.push_operand(&arms.to_le_bytes());
701 Some(Visit::Exit {
702 op: OP_MATCH_EXIT,
703 leaves_loop: false,
704 })
705 }
706 Shape::MatchArm => {
707 self.push_op(OP_ARM_ENTER);
708 Some(Visit::Exit {
709 op: OP_ARM_EXIT,
710 leaves_loop: false,
711 })
712 }
713 Shape::Try => {
714 self.push_op(OP_TRY);
715 None
716 }
717 Shape::Return => {
718 self.push_op(OP_RETURN);
719 None
720 }
721 Shape::Break => {
722 self.push_op(OP_BREAK);
723 None
724 }
725 Shape::Continue => {
726 self.push_op(OP_CONTINUE);
727 None
728 }
729 Shape::Call => {
730 self.push_op(OP_CALL);
731 None
732 }
733 _ => None,
734 };
735 if let Some(exit) = exit {
736 pending.push(exit);
737 }
738 pending.extend(node.children.iter().rev().map(Visit::Enter));
739 }
740 }
741 }
742 }
743}
744
745/// Build the control-flow profile of one unit subtree.
746fn cfg_feature(unit: &IrNode) -> CfgFeature {
747 let mut walk = CfgWalk::default();
748 walk.visit(unit);
749 let mut hasher = FeatureHasher::new("cfg");
750 hasher.write_bytes(&walk.ops);
751 // A separate domain, so the two never collide for a unit that calls
752 // nothing and whose sequences are therefore byte-identical.
753 let mut skeleton = FeatureHasher::new("cfg-skeleton");
754 skeleton.write_bytes(&walk.skeleton);
755 CfgFeature {
756 hash: hasher.finish(),
757 skeleton_hash: skeleton.finish(),
758 op_count: walk.op_count,
759 skeleton_ops: walk.skeleton_ops,
760 max_loop_depth: walk.max_loop_depth,
761 branch_count: walk.branch_count,
762 }
763}
764
765/// The callee name of one call node: the text of the last identifier token
766/// strictly before the call's first `(` token, or `None` when either is
767/// missing from the call's token range.
768fn callee_name(call: &IrNode, tokens: &[Token]) -> Option<Lexeme> {
769 let end = call.token_end.min(tokens.len());
770 let start = call.token_start.min(end);
771 let slice = &tokens[start..end];
772 let open = slice
773 .iter()
774 .position(|token| matches!(token.kind, TokenKind::Punctuation) && token.text == "(")?;
775 slice[..open]
776 .iter()
777 .rev()
778 .find(|token| matches!(token.kind, TokenKind::Identifier))
779 .map(|token| token.text.clone())
780}
781
782/// Build the API-call profile of one unit subtree.
783fn api_feature(unit: &IrNode, tokens: &[Token]) -> ApiCallFeature {
784 let mut names: Vec<Lexeme> = Vec::new();
785 unit.walk(&mut |node| {
786 if matches!(node.shape, Shape::Call) {
787 if let Some(name) = callee_name(node, tokens) {
788 names.push(name);
789 }
790 }
791 });
792
793 let mut sequence = FeatureHasher::new("api-call");
794 for name in &names {
795 sequence.write_str(name);
796 }
797
798 let mut sorted: Vec<&Lexeme> = names.iter().collect();
799 sorted.sort_unstable_by(|a, b| a.as_str().cmp(b.as_str()));
800 let mut multiset = FeatureHasher::new("api-call-set");
801 for name in sorted {
802 multiset.write_str(name);
803 }
804
805 ApiCallFeature {
806 names,
807 sequence_hash: sequence.finish(),
808 multiset_hash: multiset.finish(),
809 }
810}
811
812#[cfg(test)]
813#[allow(clippy::unwrap_used, clippy::expect_used)]
814mod tests;