Skip to main content

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;