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}