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}