Skip to main content

codehelion_core/
control_flow.rs

1//! Structural-mode candidate generation from the control-flow skeleton.
2//!
3//! The other two candidate stages both describe a unit by the *pieces* it is
4//! made of: [`crate::candidate`] indexes statement windows and subtrees and
5//! pairs units that share one exactly, and [`crate::near_match`] treats those
6//! same pieces as a set and pairs units whose sets overlap. Both lose the same
7//! edit, and lose it hardest where code is smallest.
8//!
9//! Inserting a statement rewrites every piece that encloses it. In a short
10//! function almost every piece encloses almost everything: the body block, the
11//! loop, the whole unit. A copy with two statements added can therefore share
12//! *no* window and *no* subtree with its original, so the exact stage proposes
13//! nothing and the set stage sees two disjoint sets — while a reader would call
14//! them the same function. The loss is not a threshold that could be relaxed;
15//! there is no overlap left to find.
16//!
17//! What such an edit does leave untouched is the shape of the control flow. A
18//! statement that is not a loop, a branch, a match or a jump does not appear in
19//! [`crate::features::CfgFeature::skeleton_hash`] at all, so a unit and its
20//! gapped copy hash to the same skeleton. This stage indexes that hash and
21//! pairs the units that share one. It is an exact-match index like the seed
22//! layer, not an approximation: units either have the same skeleton or they do
23//! not.
24//!
25//! A skeleton says much less than a subtree does — it is why this stage
26//! proposes rather than concludes, and every pair it emits is judged by
27//! [`crate::verify`] like any other. Three controls keep what it proposes
28//! bounded (AGENTS.md invariant 10), and each counts what it drops:
29//!
30//! - **a minimum skeleton size** — a unit with fewer than
31//!   [`ControlFlowConfig::min_ops`] control operations has a skeleton so common
32//!   that sharing it is no evidence at all, and is not indexed;
33//! - **high-frequency suppression** — a skeleton shared by more than
34//!   [`ControlFlowConfig::posting_cap`] units is common structure rather than a
35//!   family of copies, and its whole posting list is dropped;
36//! - **a length-ratio gate and a global pair budget** — as in the near-match
37//!   stage, a pair spanning too great a size difference is not a gapped copy,
38//!   and posting lists are paired rarest-first so exhaustion sacrifices the
39//!   lowest-signal candidates.
40//!
41//! As in [`crate::candidate`], the budget stops between posting lists and never
42//! inside one: a list compared only in part reaches grouping as a family whose
43//! members disagree, and comes back out as the stray pairs the ceiling allowed
44//! rather than as the one group it is. What a list costs is counted after the
45//! length-ratio gate, so a list of widely differing sizes is charged for the
46//! few pairs it really contributes.
47//!
48//! Output is a pure function of the input: the index is ordered, pairing is
49//! deterministic, and the emitted pairs are sorted.
50
51use std::collections::BTreeMap;
52
53use crate::features::{FeatureHash, FileFeatures, UnitRef};
54
55/// Default smallest skeleton size, in control operations, for a unit to be
56/// indexed.
57///
58/// Four operations is one control construct nested inside another — a branch
59/// inside a loop, say — which is the point at which a skeleton starts to
60/// distinguish one function from the next. Below it the skeletons of unrelated
61/// code coincide constantly, and the posting cap would be doing all the work.
62pub const DEFAULT_MIN_OPS: u32 = 4;
63
64/// Default largest unit-size ratio a pair may span.
65pub const DEFAULT_MAX_LENGTH_RATIO: f64 = 3.0;
66
67/// Default posting-list cap; longer lists are common structure and dropped.
68pub const DEFAULT_POSTING_CAP: usize = 256;
69
70/// Default global candidate-pair upper bound.
71pub const DEFAULT_PAIR_BUDGET: usize = 2_000_000;
72
73/// Tuning for control-flow candidate generation.
74#[derive(Debug, Clone, PartialEq)]
75pub struct ControlFlowConfig {
76    /// Units whose skeleton holds fewer operations than this are not indexed.
77    pub min_ops: u32,
78    /// Largest ratio of unit sizes (in nodes) a pair may span.
79    pub max_length_ratio: f64,
80    /// Longest posting list that still enters pairing; longer ones are dropped
81    /// as common structure and counted.
82    pub posting_cap: usize,
83    /// Upper bound on candidate pairs emitted.
84    pub pair_budget: usize,
85}
86
87impl Default for ControlFlowConfig {
88    fn default() -> Self {
89        Self {
90            min_ops: DEFAULT_MIN_OPS,
91            max_length_ratio: DEFAULT_MAX_LENGTH_RATIO,
92            posting_cap: DEFAULT_POSTING_CAP,
93            pair_budget: DEFAULT_PAIR_BUDGET,
94        }
95    }
96}
97
98/// A control-flow candidate: two units with the same control-flow skeleton.
99/// Canonical: `a < b`.
100#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
101pub struct ControlFlowPair {
102    /// The lower unit.
103    pub a: UnitRef,
104    /// The higher unit.
105    pub b: UnitRef,
106    /// The skeleton hash the two share.
107    pub hash: FeatureHash,
108}
109
110/// Counters describing what control-flow generation saw and dropped.
111#[derive(Debug, Clone, Default, PartialEq, Eq)]
112pub struct ControlFlowStats {
113    /// Units across all files.
114    pub units: usize,
115    /// Units indexed (cleared `min_ops`).
116    pub indexed_units: usize,
117    /// Units skipped for having too small a skeleton.
118    pub skipped_shallow: usize,
119    /// Distinct skeletons in the index.
120    pub distinct_skeletons: usize,
121    /// Skeletons dropped for exceeding the posting cap.
122    pub stop_skeletons: usize,
123    /// Units dropped with them.
124    pub stop_postings: usize,
125    /// Pairs dropped by the length-ratio gate.
126    pub filtered_by_size: usize,
127    /// Candidate pairs emitted.
128    pub candidate_pairs: usize,
129    /// Whether the pair budget ran out before all posting lists were paired.
130    pub budget_exhausted: bool,
131    /// Candidate pairs in lists the pair budget refused after their
132    /// length-ratio gate was evaluated.
133    pub budget_dropped: usize,
134}
135
136/// The control-flow stage's output: candidate unit pairs plus funnel counters.
137#[derive(Debug, Clone, PartialEq, Eq)]
138pub struct ControlFlowSet {
139    /// Candidate pairs, deterministically ordered by `(a, b)`.
140    pub pairs: Vec<ControlFlowPair>,
141    /// What the stage saw and dropped.
142    pub stats: ControlFlowStats,
143}
144
145/// Generate control-flow candidate unit pairs across `files`.
146///
147/// The result is a pure function of the input: file order only moves the `file`
148/// indices inside the unit references.
149#[must_use]
150pub fn generate(files: &[FileFeatures], config: &ControlFlowConfig) -> ControlFlowSet {
151    let mut index: BTreeMap<FeatureHash, Vec<UnitRef>> = BTreeMap::new();
152    let mut stats = ControlFlowStats::default();
153
154    for (file, features) in files.iter().enumerate() {
155        stats.units += features.units.len();
156        for (unit, unit_features) in features.units.iter().enumerate() {
157            if unit_features.cfg.skeleton_ops < config.min_ops {
158                stats.skipped_shallow += 1;
159                continue;
160            }
161            index
162                .entry(unit_features.cfg.skeleton_hash)
163                .or_default()
164                .push(UnitRef {
165                    file,
166                    unit,
167                    node_count: unit_features.vector.node_count,
168                });
169        }
170    }
171    stats.indexed_units = index.values().map(Vec::len).sum();
172    stats.distinct_skeletons = index.len();
173
174    // Posting lists eligible for pairing: at least two units and within the
175    // high-frequency cap. Everything else is dropped and counted.
176    let mut eligible: Vec<(&FeatureHash, &Vec<UnitRef>)> = Vec::new();
177    for (hash, postings) in &index {
178        if postings.len() > config.posting_cap {
179            stats.stop_skeletons += 1;
180            stats.stop_postings += postings.len();
181        } else if postings.len() >= 2 {
182            eligible.push((hash, postings));
183        }
184    }
185    // Rarest-first: a skeleton shared by two units says far more than one
186    // shared by fifty, so budget exhaustion sacrifices the common ones.
187    eligible.sort_by(|a, b| a.1.len().cmp(&b.1.len()).then_with(|| a.0.cmp(b.0)));
188
189    let mut pairs = Vec::new();
190    let mut remaining = config.pair_budget;
191    for (index, &(hash, postings)) in eligible.iter().enumerate() {
192        // Charge the closed-form upper bound before entering the quadratic
193        // length-ratio loop. Lists are shortest-first, so a later list cannot
194        // fit after this one fails; stopping here makes the budget bound work.
195        let possible = postings
196            .len()
197            .saturating_mul(postings.len().saturating_sub(1))
198            / 2;
199        if possible > remaining {
200            stats.budget_exhausted = true;
201            stats.budget_dropped = eligible[index..].iter().fold(0, |total, (_, list)| {
202                total.saturating_add(list.len().saturating_mul(list.len().saturating_sub(1)) / 2)
203            });
204            break;
205        }
206        remaining -= possible;
207        let mut filtered = 0usize;
208        for (i, &a) in postings.iter().enumerate() {
209            for &b in &postings[i + 1..] {
210                if a.within_length_ratio(b, config.max_length_ratio) {
211                } else {
212                    filtered += 1;
213                }
214            }
215        }
216        stats.filtered_by_size += filtered;
217        for (i, &a) in postings.iter().enumerate() {
218            for &b in &postings[i + 1..] {
219                if a.within_length_ratio(b, config.max_length_ratio) {
220                    let (a, b) = if a <= b { (a, b) } else { (b, a) };
221                    pairs.push(ControlFlowPair { a, b, hash: *hash });
222                }
223            }
224        }
225    }
226
227    // Sort into a stable output order independent of the rarest-first walk.
228    pairs.sort();
229    stats.candidate_pairs = pairs.len();
230    ControlFlowSet { pairs, stats }
231}
232
233#[cfg(test)]
234#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
235mod tests {
236    use super::*;
237    use crate::features::{
238        ApiCallFeature, CfgFeature, CharacteristicVector, SubtreeFeature, UnitFeatures,
239        WindowFeature,
240    };
241    use crate::ir::ByteRange;
242
243    fn hash(seed: u8) -> FeatureHash {
244        FeatureHash::from_bytes([seed; 16])
245    }
246
247    /// A unit with the given skeleton hash, op count and node count. The
248    /// window and subtree sets are left empty: this stage never reads them,
249    /// which is the whole point of having it.
250    fn unit(skeleton: u8, op_count: u32, node_count: u32) -> UnitFeatures {
251        UnitFeatures {
252            name: None,
253            shape_tag: 1,
254            range: ByteRange { start: 0, end: 100 },
255            windows: Vec::<WindowFeature>::new(),
256            subtrees: Vec::<SubtreeFeature>::new(),
257            vector: CharacteristicVector {
258                node_count,
259                ..CharacteristicVector::default()
260            },
261            cfg: CfgFeature {
262                hash: hash(skeleton),
263                skeleton_hash: hash(skeleton),
264                op_count,
265                skeleton_ops: op_count,
266                max_loop_depth: 1,
267                branch_count: 1,
268            },
269            api: ApiCallFeature {
270                names: Vec::new(),
271                sequence_hash: hash(0),
272                multiset_hash: hash(0),
273            },
274        }
275    }
276
277    fn file(units: Vec<UnitFeatures>) -> FileFeatures {
278        FileFeatures { units }
279    }
280
281    #[test]
282    fn two_units_sharing_a_skeleton_are_a_candidate_pair() {
283        // Neither unit has a single window or subtree in common with the
284        // other — they have none at all — and they still pair.
285        let files = vec![file(vec![unit(1, 4, 20)]), file(vec![unit(1, 4, 24)])];
286        let set = generate(&files, &ControlFlowConfig::default());
287        assert_eq!(set.pairs.len(), 1);
288        assert_eq!(set.pairs[0].hash, hash(1));
289        assert_eq!((set.pairs[0].a.file, set.pairs[0].b.file), (0, 1));
290        assert_eq!(set.stats.indexed_units, 2);
291        assert!(!set.stats.budget_exhausted);
292    }
293
294    #[test]
295    fn different_skeletons_do_not_pair() {
296        let files = vec![file(vec![unit(1, 4, 20), unit(2, 4, 20)])];
297        let set = generate(&files, &ControlFlowConfig::default());
298        assert!(set.pairs.is_empty());
299        assert_eq!(set.stats.distinct_skeletons, 2);
300    }
301
302    #[test]
303    fn a_skeleton_too_small_to_mean_anything_is_not_indexed() {
304        // Three control ops, one below the minimum: two units that would
305        // otherwise pair are left out, and the skip is counted.
306        let files = vec![file(vec![unit(1, 3, 20), unit(1, 3, 20)])];
307        let set = generate(&files, &ControlFlowConfig::default());
308        assert!(set.pairs.is_empty());
309        assert_eq!(set.stats.skipped_shallow, 2);
310        assert_eq!(set.stats.indexed_units, 0);
311    }
312
313    #[test]
314    fn a_size_mismatched_pair_is_rejected_and_counted() {
315        // Same skeleton, sizes 10 and 40: ratio 4 exceeds the cap of 3.
316        let files = vec![file(vec![unit(1, 4, 10), unit(1, 4, 40)])];
317        let set = generate(&files, &ControlFlowConfig::default());
318        assert!(set.pairs.is_empty());
319        assert_eq!(set.stats.filtered_by_size, 1);
320    }
321
322    #[test]
323    fn a_common_skeleton_is_dropped_whole_and_counted() {
324        let files = vec![file(vec![
325            unit(1, 4, 20),
326            unit(1, 4, 20),
327            unit(1, 4, 20),
328            unit(1, 4, 20),
329        ])];
330        let config = ControlFlowConfig {
331            posting_cap: 3,
332            ..ControlFlowConfig::default()
333        };
334        let set = generate(&files, &config);
335        assert!(set.pairs.is_empty());
336        assert_eq!(set.stats.stop_skeletons, 1);
337        assert_eq!(set.stats.stop_postings, 4);
338    }
339
340    #[test]
341    fn the_pair_budget_refuses_a_list_it_cannot_pair_whole() {
342        // One skeleton over four units => C(4,2) = 6 pairs, budget 2. Taking
343        // two of them would hand grouping four units compared to each other in
344        // part, which reads there as four units that disagree.
345        let files = vec![file(vec![
346            unit(1, 4, 20),
347            unit(1, 4, 20),
348            unit(1, 4, 20),
349            unit(1, 4, 20),
350        ])];
351        let config = ControlFlowConfig {
352            pair_budget: 2,
353            ..ControlFlowConfig::default()
354        };
355        let set = generate(&files, &config);
356        assert!(set.pairs.is_empty());
357        assert!(set.stats.budget_exhausted);
358        assert_eq!(set.stats.budget_dropped, 6);
359    }
360
361    #[test]
362    fn a_refused_list_stops_before_later_quadratic_work() {
363        // Rarest-first meets the three-unit list first. Its C(3,2) work bound
364        // exceeds the allowance, so the longer list is never walked even
365        // though its length-ratio gate would have left one pair.
366        let files = vec![file(vec![
367            unit(1, 4, 20),
368            unit(1, 4, 20),
369            unit(1, 4, 20),
370            unit(2, 4, 20),
371            unit(2, 4, 20),
372            unit(2, 4, 100),
373            unit(2, 4, 400),
374        ])];
375        let config = ControlFlowConfig {
376            pair_budget: 1,
377            ..ControlFlowConfig::default()
378        };
379        let set = generate(&files, &config);
380        assert!(set.pairs.is_empty());
381        assert!(set.stats.budget_exhausted);
382        assert_eq!(set.stats.filtered_by_size, 0);
383        assert_eq!(set.stats.budget_dropped, 9);
384    }
385
386    #[test]
387    fn generation_is_deterministic() {
388        let files = vec![
389            file(vec![unit(1, 4, 20), unit(2, 5, 30)]),
390            file(vec![unit(1, 4, 22), unit(2, 5, 31)]),
391        ];
392        let a = generate(&files, &ControlFlowConfig::default());
393        let b = generate(&files, &ControlFlowConfig::default());
394        assert_eq!(a, b);
395        assert_eq!(a.pairs.len(), 2);
396    }
397}