Skip to main content

codehelion_core/
candidate.rs

1//! Structural-mode candidate generation: the inverted-index seed layer.
2//!
3//! Verifying every pair of fragments in a corpus is quadratic and hopeless at
4//! scale, so detection never starts from pairs. It starts from an inverted
5//! index: statement-window and subtree feature hashes ([`crate::features`])
6//! map to the fragments that produced them, and only fragments that landed in
7//! the same posting list — an exact structural match under the feature recipe
8//! — become candidate pairs. The approximate near-match layer (characteristic
9//! vector nearest-neighbour, `MinHash`/LSH) plugs in behind the same
10//! candidate-emitting interface later; this layer is the exact-match seed.
11//!
12//! Candidate-explosion control is a first-class concern, not an afterthought
13//! (AGENTS.md invariant 10). Two controls act here, both before any pair
14//! leaves the stage, and both count what they drop into [`CandidateStats`]
15//! rather than letting it vanish:
16//!
17//! - **high-frequency suppression** — a hash whose posting list exceeds
18//!   [`CandidateConfig::posting_cap`] is boilerplate-shaped noise that would
19//!   dominate the pair budget; it is dropped whole and counted;
20//! - **a global candidate upper bound** — posting lists are paired
21//!   rarest-first, so when [`CandidateConfig::pair_budget`] runs out the
22//!   high-frequency, low-signal lists are the ones sacrificed, and the set
23//!   records that it was truncated.
24//!
25//! # A list is paired whole or not at all
26//!
27//! The ceiling stops between posting lists, never inside one. That costs
28//! coverage — the list the allowance could not hold entirely is skipped rather
29//! than half-paired — and it is worth the cost because of what grouping does
30//! with a half-paired list.
31//!
32//! Grouping treats a pair nothing proposed as a pair that is not similar
33//! ([`crate::grouping`]), which is sound while the stage above it ran to
34//! completion and is not sound once a ceiling cut a list in two. A family whose
35//! members were compared to each other only in part looks, from there, like a
36//! family whose members mostly disagree: the complete-linkage floor ejects them
37//! and the surviving comparisons come back out one by one, as pairs no group
38//! holds both halves of. One duplication that a whole list states once is then
39//! restated as many times as the ceiling happened to leave edges.
40//!
41//! Measured against the labelled corpora with the ceiling lowered until it
42//! bites, cutting inside a list turned one twenty-seven-member family into a
43//! hundred and fifty-five pairs, and made the report *grow* as the ceiling came
44//! down — seven times its untruncated size at one setting, while the findings
45//! anybody had ruled on stayed exactly the same. Stopping between lists costs
46//! a few per cent of those findings at the same ceiling and leaves the rest of
47//! the report the size it was. It also makes the ceiling monotone: a run given
48//! more allowance can no longer report more.
49//!
50//! Output is a pure function of the input: the emitted pairs are sorted
51//! deterministically, so file order only moves the `file` indices inside the
52//! fragment references and never changes which pairs appear or in what order.
53
54use std::collections::BTreeMap;
55
56use crate::features::{FeatureHash, FeatureKind, FileFeatures};
57
58/// Default posting-list cap. Provisional; the corpus funnel measurement
59/// calibrates it against real high-frequency structure.
60pub const DEFAULT_POSTING_CAP: usize = 256;
61
62/// Default global candidate-pair upper bound. Provisional, as above.
63pub const DEFAULT_PAIR_BUDGET: usize = 2_000_000;
64
65/// Tuning for candidate generation.
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct CandidateConfig {
68    /// Longest posting list that still enters pairing; longer ones are dropped
69    /// as high-frequency noise and counted.
70    pub posting_cap: usize,
71    /// Upper bound on candidate pairs emitted. Pairing is rarest-first and
72    /// stops between posting lists, so exhaustion sacrifices the lowest-signal
73    /// lists whole rather than leaving one of them half-compared.
74    pub pair_budget: usize,
75}
76
77impl Default for CandidateConfig {
78    fn default() -> Self {
79        Self {
80            posting_cap: DEFAULT_POSTING_CAP,
81            pair_budget: DEFAULT_PAIR_BUDGET,
82        }
83    }
84}
85
86/// Where a statement-window fragment sits in its unit's statement sequences.
87///
88/// This is position, not identity: it lets adjacent window matches be folded
89/// back into one maximal statement run ([`crate::maximal`]), and it never
90/// enters a fingerprint (AGENTS.md invariant 3).
91#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
92pub struct StatementRun {
93    /// Ordinal of the enclosing block within the unit, in walk order.
94    pub block: u32,
95    /// Index of the run's first statement within that block.
96    pub start: u32,
97    /// Length of the run, in statements.
98    pub length: u32,
99}
100
101impl StatementRun {
102    /// Index one past the run's last statement.
103    #[must_use]
104    pub const fn end(self) -> u32 {
105        self.start.saturating_add(self.length)
106    }
107}
108
109/// One occurrence of a hashed fragment (a statement window or a subtree) at a
110/// source location. `file` indexes the slice given to [`generate`]; `unit`
111/// indexes that file's [`FileFeatures::units`].
112#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
113pub struct FragmentRef {
114    /// Index of the file in the input slice.
115    pub file: usize,
116    /// Index of the enclosing unit in the file's units.
117    pub unit: usize,
118    /// Anchor: first byte the fragment covers.
119    pub start_byte: usize,
120    /// Anchor: one past the last byte the fragment covers.
121    pub end_byte: usize,
122    /// Kind-specific size: window length or subtree node count.
123    pub extent: u32,
124    /// The statement run this fragment covers, for a statement window. `None`
125    /// for a subtree, which is a tree region rather than a run of siblings.
126    pub run: Option<StatementRun>,
127}
128
129/// An exact-hash candidate pair: two fragments that share one feature hash.
130///
131/// The pair is canonical: `a < b` by fragment reference, so the same two
132/// fragments never appear in both orders.
133#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
134pub struct CandidatePair {
135    /// Which feature family the shared hash came from.
136    pub kind: FeatureKind,
137    /// The shared feature hash.
138    pub hash: FeatureHash,
139    /// The lower fragment.
140    pub a: FragmentRef,
141    /// The higher fragment.
142    pub b: FragmentRef,
143}
144
145/// Counters describing what candidate generation saw and dropped: the head of
146/// the detection funnel, recorded for the `doctor`/verbose view.
147#[derive(Debug, Clone, Default, PartialEq, Eq)]
148pub struct CandidateStats {
149    /// Units across all files.
150    pub units: usize,
151    /// Window and subtree occurrences indexed.
152    pub fragments: usize,
153    /// Distinct feature hashes in the index.
154    pub distinct_fingerprints: usize,
155    /// Distinct hashes dropped for exceeding the posting cap.
156    pub stop_fingerprints: usize,
157    /// Occurrences dropped with them.
158    pub stop_postings: usize,
159    /// Candidate pairs emitted.
160    pub candidate_pairs: usize,
161    /// Pairs the eligible posting lists held in total.
162    ///
163    /// Reported beside the emitted count so a truncated run says how much of
164    /// its work it did. "The budget ran out" is compatible with having skipped
165    /// one candidate and with having skipped nine in ten, and those are not
166    /// the same result to hand someone.
167    pub available_pairs: usize,
168    /// Whether the pair budget ran out before all posting lists were paired.
169    pub budget_exhausted: bool,
170}
171
172/// The candidate stage's output: exact-hash pairs plus funnel statistics.
173#[derive(Debug, Clone, PartialEq, Eq)]
174pub struct CandidateSet {
175    /// Candidate pairs, deterministically ordered.
176    pub pairs: Vec<CandidatePair>,
177    /// What the stage saw and dropped.
178    pub stats: CandidateStats,
179}
180
181/// A remaining candidate-pair allowance, spent a posting list at a time.
182struct PairBudget {
183    remaining: usize,
184    exhausted: bool,
185}
186
187impl PairBudget {
188    const fn new(limit: usize) -> Self {
189        Self {
190            remaining: limit,
191            exhausted: false,
192        }
193    }
194
195    /// Take a whole posting list's worth; `false` means it does not fit.
196    ///
197    /// Lists arrive shortest-first, so a list that does not fit is followed
198    /// only by lists that do not fit either: refusing one ends the pairing.
199    const fn take_list(&mut self, wanted: usize) -> bool {
200        if wanted > self.remaining {
201            self.exhausted = true;
202            return false;
203        }
204        self.remaining -= wanted;
205        true
206    }
207}
208
209/// Generate exact-hash candidate pairs across `files`.
210///
211/// The result is a pure function of the input: file order only affects the
212/// `file` indices inside fragment references, and the emitted pairs are sorted
213/// deterministically.
214#[must_use]
215pub fn generate(files: &[FileFeatures], config: &CandidateConfig) -> CandidateSet {
216    let mut index: BTreeMap<(FeatureKind, FeatureHash), Vec<FragmentRef>> = BTreeMap::new();
217    let mut stats = CandidateStats::default();
218
219    for (file, features) in files.iter().enumerate() {
220        stats.units += features.units.len();
221        for (unit, unit_features) in features.units.iter().enumerate() {
222            for window in &unit_features.windows {
223                push_occurrence(
224                    &mut index,
225                    FeatureKind::StatementWindow,
226                    window.hash,
227                    FragmentRef {
228                        file,
229                        unit,
230                        start_byte: window.range.start,
231                        end_byte: window.range.end,
232                        extent: clamp_u32(window.length),
233                        run: Some(StatementRun {
234                            block: window.block,
235                            start: window.offset,
236                            length: clamp_u32(window.length),
237                        }),
238                    },
239                );
240                stats.fragments += 1;
241            }
242            for subtree in &unit_features.subtrees {
243                push_occurrence(
244                    &mut index,
245                    FeatureKind::Subtree,
246                    subtree.hash,
247                    FragmentRef {
248                        file,
249                        unit,
250                        start_byte: subtree.range.start,
251                        end_byte: subtree.range.end,
252                        extent: clamp_u32(subtree.node_count),
253                        run: None,
254                    },
255                );
256                stats.fragments += 1;
257            }
258        }
259    }
260    stats.distinct_fingerprints = index.len();
261
262    // Posting lists eligible for pairing: at least two occurrences and within
263    // the high-frequency cap. Everything else is dropped and counted.
264    let mut eligible: Vec<(&(FeatureKind, FeatureHash), &Vec<FragmentRef>)> = Vec::new();
265    for (key, postings) in &index {
266        if postings.len() > config.posting_cap {
267            stats.stop_fingerprints += 1;
268            stats.stop_postings += postings.len();
269        } else if postings.len() >= 2 {
270            eligible.push((key, postings));
271        }
272    }
273    // Rarest-first: shortest lists carry the highest signal, so when the
274    // budget runs out the frequent lists are the ones left unpaired. The key
275    // tiebreak keeps the order total and deterministic. Shortest-first is also
276    // what lets the budget stop between lists without scanning further: the
277    // first list too big to fit is the smallest of the ones remaining.
278    //
279    // Most lists hold exactly two fragments, so the allowance nearly always
280    // runs out inside one length and the key tiebreak is what actually decides
281    // the last of them. Deciding it by how much matched instead — the window
282    // length or subtree node count the hash covers — was measured against the
283    // labelled corpora, in both directions, and neither is a win: longest-first
284    // loses a tenth of the confirmed findings once the ceiling bites hard,
285    // because long windows crowd into one family and buying more of them buys
286    // redundancy rather than reach; shortest-first buys reach at a worse rate
287    // than it costs precision. Length of list, not length of match, is the
288    // axis that carries the signal, and it is already the primary key.
289    eligible.sort_by(|a, b| a.1.len().cmp(&b.1.len()).then_with(|| a.0.cmp(b.0)));
290    stats.available_pairs = eligible
291        .iter()
292        .map(|(_, postings)| pairs_within(postings.len()))
293        .sum();
294
295    let mut budget = PairBudget::new(config.pair_budget);
296    let mut pairs = Vec::new();
297    for (&(kind, hash), postings) in eligible {
298        if !budget.take_list(pairs_within(postings.len())) {
299            break;
300        }
301        for (i, &a) in postings.iter().enumerate() {
302            for &b in &postings[i + 1..] {
303                let (a, b) = if a <= b { (a, b) } else { (b, a) };
304                pairs.push(CandidatePair { kind, hash, a, b });
305            }
306        }
307    }
308
309    // Sort into a stable output order independent of the rarest-first walk.
310    pairs.sort();
311    stats.candidate_pairs = pairs.len();
312    stats.budget_exhausted = budget.exhausted;
313    CandidateSet { pairs, stats }
314}
315
316fn push_occurrence(
317    index: &mut BTreeMap<(FeatureKind, FeatureHash), Vec<FragmentRef>>,
318    kind: FeatureKind,
319    hash: FeatureHash,
320    fragment: FragmentRef,
321) {
322    index.entry((kind, hash)).or_default().push(fragment);
323}
324
325/// Pairs a posting list of `len` occurrences holds.
326const fn pairs_within(len: usize) -> usize {
327    len.saturating_mul(len.saturating_sub(1)) / 2
328}
329
330fn clamp_u32(value: usize) -> u32 {
331    u32::try_from(value).unwrap_or(u32::MAX)
332}
333
334#[cfg(test)]
335#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
336mod tests {
337    use super::*;
338    use crate::features::{
339        ApiCallFeature, CfgFeature, CharacteristicVector, SubtreeFeature, UnitFeatures,
340        WindowFeature,
341    };
342    use crate::ir::ByteRange;
343
344    fn hash(seed: u8) -> FeatureHash {
345        FeatureHash::from_bytes([seed; 16])
346    }
347
348    /// A unit carrying the given window hashes and subtree hashes, each with a
349    /// distinct byte anchor so fragments stay distinguishable.
350    fn unit_with(windows: &[u8], subtrees: &[u8]) -> UnitFeatures {
351        let windows = windows
352            .iter()
353            .enumerate()
354            .map(|(i, &seed)| WindowFeature {
355                hash: hash(seed),
356                length: 4,
357                range: ByteRange {
358                    start: i * 10,
359                    end: i * 10 + 8,
360                },
361                block: 0,
362                offset: u32::try_from(i).unwrap(),
363            })
364            .collect();
365        let subtrees = subtrees
366            .iter()
367            .enumerate()
368            .map(|(i, &seed)| SubtreeFeature {
369                hash: hash(seed),
370                node_count: 6,
371                range: ByteRange {
372                    start: 100 + i * 10,
373                    end: 100 + i * 10 + 8,
374                },
375            })
376            .collect();
377        UnitFeatures {
378            name: None,
379            shape_tag: 1,
380            range: ByteRange { start: 0, end: 200 },
381            windows,
382            subtrees,
383            vector: CharacteristicVector::default(),
384            cfg: CfgFeature {
385                hash: hash(0),
386                skeleton_hash: hash(0),
387                op_count: 0,
388                skeleton_ops: 0,
389                max_loop_depth: 0,
390                branch_count: 0,
391            },
392            api: ApiCallFeature {
393                names: Vec::new(),
394                sequence_hash: hash(0),
395                multiset_hash: hash(0),
396            },
397        }
398    }
399
400    fn file_with(units: Vec<UnitFeatures>) -> FileFeatures {
401        FileFeatures { units }
402    }
403
404    #[test]
405    fn a_shared_hash_across_two_files_is_one_candidate_pair() {
406        let files = vec![
407            file_with(vec![unit_with(&[7], &[])]),
408            file_with(vec![unit_with(&[7], &[])]),
409        ];
410        let set = generate(&files, &CandidateConfig::default());
411        assert_eq!(set.pairs.len(), 1);
412        let pair = &set.pairs[0];
413        assert_eq!(pair.kind, FeatureKind::StatementWindow);
414        assert_eq!(pair.hash, hash(7));
415        assert_eq!(pair.a.file, 0);
416        assert_eq!(pair.b.file, 1);
417        assert_eq!(set.stats.fragments, 2);
418        assert_eq!(set.stats.distinct_fingerprints, 1);
419        assert_eq!(set.stats.candidate_pairs, 1);
420        assert!(!set.stats.budget_exhausted);
421    }
422
423    #[test]
424    fn a_singleton_hash_yields_no_pair() {
425        let files = vec![file_with(vec![unit_with(&[7], &[8])])];
426        let set = generate(&files, &CandidateConfig::default());
427        assert!(set.pairs.is_empty());
428        assert_eq!(set.stats.distinct_fingerprints, 2);
429        assert_eq!(set.stats.stop_fingerprints, 0);
430    }
431
432    #[test]
433    fn window_and_subtree_hashes_do_not_cross_match() {
434        // Same 16 bytes, but one is a window hash and one a subtree hash: they
435        // key on different families and never pair.
436        let files = vec![file_with(vec![unit_with(&[9], &[9])])];
437        let set = generate(&files, &CandidateConfig::default());
438        assert!(set.pairs.is_empty());
439        assert_eq!(set.stats.distinct_fingerprints, 2);
440    }
441
442    #[test]
443    fn a_high_frequency_hash_is_dropped_whole_and_counted() {
444        // Four occurrences of hash 5, cap of 3: the whole list is stopped.
445        let files = vec![file_with(vec![
446            unit_with(&[5], &[]),
447            unit_with(&[5], &[]),
448            unit_with(&[5], &[]),
449            unit_with(&[5], &[]),
450        ])];
451        let config = CandidateConfig {
452            posting_cap: 3,
453            ..CandidateConfig::default()
454        };
455        let set = generate(&files, &config);
456        assert!(set.pairs.is_empty());
457        assert_eq!(set.stats.stop_fingerprints, 1);
458        assert_eq!(set.stats.stop_postings, 4);
459        assert_eq!(set.stats.candidate_pairs, 0);
460    }
461
462    #[test]
463    fn the_pair_budget_refuses_a_list_it_cannot_pair_whole() {
464        // One hash with four occurrences => C(4,2) = 6 pairs, budget 2. Two of
465        // those six would leave the four occurrences compared to each other
466        // only in part, and grouping reads an absent comparison as a failed
467        // one — so a family that is really a family comes back out as the
468        // stray pairs the ceiling happened to allow. The list is skipped
469        // instead, and the run still says the ceiling was reached.
470        let files = vec![file_with(vec![
471            unit_with(&[5], &[]),
472            unit_with(&[5], &[]),
473            unit_with(&[5], &[]),
474            unit_with(&[5], &[]),
475        ])];
476        let config = CandidateConfig {
477            posting_cap: 64,
478            pair_budget: 2,
479        };
480        let set = generate(&files, &config);
481        assert!(set.pairs.is_empty());
482        assert!(set.stats.budget_exhausted);
483        assert_eq!(set.stats.candidate_pairs, 0);
484        // And says how much it did not do: the ceiling is what stopped this,
485        // not a shortage of anything to pair.
486        assert_eq!(set.stats.available_pairs, 6);
487    }
488
489    #[test]
490    fn a_budget_that_holds_one_list_and_not_the_next_pairs_the_first_whole() {
491        // Two hashes, one with two occurrences (1 pair) and one with four (6),
492        // against an allowance of five. Rarest-first reaches the short list
493        // first and it fits; the long one does not, and no part of it is taken.
494        let files = vec![file_with(vec![
495            unit_with(&[5], &[]),
496            unit_with(&[5], &[]),
497            unit_with(&[5], &[]),
498            unit_with(&[5], &[]),
499            unit_with(&[9], &[]),
500            unit_with(&[9], &[]),
501        ])];
502        let config = CandidateConfig {
503            posting_cap: 64,
504            pair_budget: 5,
505        };
506        let set = generate(&files, &config);
507        assert_eq!(set.pairs.len(), 1);
508        assert_eq!(set.pairs[0].hash, hash(9));
509        assert!(set.stats.budget_exhausted);
510        assert_eq!(set.stats.available_pairs, 7);
511    }
512
513    #[test]
514    fn generation_is_deterministic() {
515        let files = vec![
516            file_with(vec![unit_with(&[7, 8], &[20])]),
517            file_with(vec![unit_with(&[8], &[20, 21])]),
518        ];
519        let a = generate(&files, &CandidateConfig::default());
520        let b = generate(&files, &CandidateConfig::default());
521        assert_eq!(a, b);
522        // Hash 8 (2 occurrences) and hash 20 (2 occurrences) each pair once.
523        assert_eq!(a.pairs.len(), 2);
524    }
525}