codehelion_core/structural/model.rs
1use super::{
2 BTreeMap, BTreeSet, Boilerplate, ByteRange, CandidateConfig, CandidateStats, CloneClass,
3 CloneGroupFingerprint, ControlFlowConfig, ControlFlowStats, CrossVariantComparisonId,
4 CrossVariantGroupId, FragmentFingerprint, GroupingConfig, GroupingSet, GroupingStats, Language,
5 Lexeme, LiteralNorm, MaximalConfig, NearMatchConfig, NearMatchStats, RegionStats,
6 SimilarityBreakdown, TestCodeEvidence, Token, UnitFingerprint, UnitKind, VerifyConfig,
7 stable_id, test_code, verify,
8};
9
10/// Default largest shape-mix divergence a candidate pair may span.
11///
12/// Chosen to say about the shape mix what
13/// [`DEFAULT_MAX_LENGTH_RATIO`](crate::near_match::DEFAULT_MAX_LENGTH_RATIO)
14/// says about size: at 3.0 the two sizes alone put a pair at 0.5. Measured
15/// over four projects between 39 and 480 kLOC, the most divergent pair
16/// verification has ever accepted sat at 0.41, and the limit takes 15% to 36%
17/// of the proposals out of verification without touching a single one of them.
18///
19/// Removing it entirely changes no group on any corpus this project has, which
20/// is what a gate that only sheds work should do. That is also why the value
21/// is not tuned against results: there are none to tune it on. What would move
22/// it is a measurement of what it costs to keep, not of what it finds.
23pub const DEFAULT_MAX_SHAPE_DIVERGENCE: f64 = 0.5;
24
25/// Largest number of distinct unit pairs the precise verifier may inspect.
26///
27/// Candidate generation is deliberately broader than verification; without a
28/// second ceiling, every candidate stage can be bounded while their union
29/// still asks the expensive sequence aligner to do unbounded work.
30pub const DEFAULT_VERIFICATION_BUDGET: usize = 2_000_000;
31
32/// Tuning for a whole structural run: one config per stage.
33#[derive(Debug, Clone, PartialEq)]
34pub struct StructuralConfig {
35 /// Smallest whole-unit or statement-run clone length, in parsed tokens,
36 /// that the structural pipeline reports.
37 ///
38 /// Candidate extraction can still see shorter code so its other funnel
39 /// counters describe the full search space. Short candidates leave before
40 /// precise verification and are accounted for in [`StructuralStats`].
41 pub min_clone_tokens: u32,
42 /// Exact-seed candidate extraction.
43 pub candidate: CandidateConfig,
44 /// MinHash/LSH near-match extraction.
45 pub near_match: NearMatchConfig,
46 /// Control-flow skeleton extraction.
47 pub control_flow: ControlFlowConfig,
48 /// Folding seed matches into maximal shared runs.
49 pub maximal: MaximalConfig,
50 /// Literal strategy the duplicated runs are confirmed under: it decides
51 /// whether two runs differing only in literal values are the same run.
52 pub literals: LiteralNorm,
53 /// Precise verification.
54 pub verify: VerifyConfig,
55 /// Upper bound on distinct unit pairs passed to precise verification.
56 ///
57 /// Pairs are ordered canonically before this budget is spent, so lowering
58 /// it changes coverage deterministically and is reported in the funnel.
59 pub verification_budget: usize,
60 /// How far apart two units' shape mixes may be and still be worth
61 /// verifying; see
62 /// [`shape_divergence`](crate::features::CharacteristicVector::shape_divergence).
63 pub max_shape_divergence: f64,
64 /// Medoid grouping.
65 pub grouping: GroupingConfig,
66 /// Bounded post-grouping search for incomplete copies beside an established
67 /// group. This never changes primary group membership.
68 pub siblings: SiblingConfig,
69}
70
71impl Default for StructuralConfig {
72 fn default() -> Self {
73 Self {
74 min_clone_tokens: 20,
75 candidate: CandidateConfig::default(),
76 near_match: NearMatchConfig::default(),
77 control_flow: ControlFlowConfig::default(),
78 maximal: MaximalConfig::default(),
79 literals: LiteralNorm::default(),
80 verify: VerifyConfig::default(),
81 verification_budget: DEFAULT_VERIFICATION_BUDGET,
82 max_shape_divergence: DEFAULT_MAX_SHAPE_DIVERGENCE,
83 grouping: GroupingConfig::default(),
84 siblings: SiblingConfig::default(),
85 }
86 }
87}
88
89/// Tuning for the post-grouping sibling sweep.
90///
91/// A sibling is deliberately weaker than a group member: it is an ungrouped
92/// unit in a file that already hosts a cohesive group member, compared only
93/// to that group's canonical unit. The sweep finds incomplete local mirrors
94/// without inventing primary similarity edges or allowing a near-copy to pull
95/// a group apart or together.
96#[derive(Debug, Clone, PartialEq)]
97pub struct SiblingConfig {
98 /// How far below the normal Type-3 threshold a sibling may land.
99 ///
100 /// The effective threshold is clamped to the normal threshold's
101 /// non-negative range, so an invalidly large delta cannot turn every
102 /// unrelated unit into a sibling.
103 pub similarity_delta: f64,
104 /// Maximum canonical-to-ungrouped comparisons in the sweep.
105 pub candidate_budget: usize,
106 /// Maximum siblings retained for one primary group.
107 pub per_group_cap: usize,
108 /// Maximum siblings retained over the whole structural report.
109 pub total_cap: usize,
110}
111
112impl Default for SiblingConfig {
113 fn default() -> Self {
114 Self {
115 // The normal Type-3 gate has a narrow measured 0.69/0.71 gap.
116 // Siblings are intentionally triage evidence, not primary clone
117 // membership, so they may recover a small omitted tail while
118 // still requiring substantial verifier agreement.
119 similarity_delta: 0.10,
120 candidate_budget: 50_000,
121 per_group_cap: 8,
122 total_cap: 1_000,
123 }
124 }
125}
126
127/// One analysed unit, kept so a caller can map a group's member indices back to
128/// source locations. The index of a unit in [`StructuralReport::units`] is the
129/// index grouping refers to.
130#[derive(Debug, Clone, PartialEq, Eq)]
131pub struct StructuralUnit {
132 /// Index of the file this unit belongs to (into the input slice).
133 pub file: usize,
134 /// What kind of unit this is; reporting only.
135 pub kind: UnitKind,
136 /// Source bytes the unit covers; reporting only.
137 pub range: ByteRange,
138 /// 1-based first line; reporting only, never an identity input.
139 pub start_line: u32,
140 /// 1-based last line; reporting only, never an identity input.
141 pub end_line: u32,
142 /// Index of the unit's first token in its file's stream.
143 pub token_start: usize,
144 /// Index one past the unit's last token in its file's stream.
145 pub token_end: usize,
146 /// The unit's declared name, when the frontend recovered one.
147 pub name: Option<Lexeme>,
148 /// The boilerplate shape the unit matches, when it matches one. Recorded,
149 /// not acted on: a classified unit is analysed and grouped like any other.
150 pub boilerplate: Option<Boilerplate>,
151 /// Whether the unit is test code: marked as a test itself, or sitting
152 /// inside an item that is. Recorded, not acted on, as `boilerplate` is.
153 pub test_code: bool,
154 /// Why this unit is test code, when it is test code.
155 ///
156 /// Structural analysis initially records marker evidence. A caller that
157 /// knows the scan's configured test paths can add path evidence after the
158 /// analysis, without changing candidate extraction or grouping.
159 pub test_code_evidence: Option<TestCodeEvidence>,
160 /// The unit's raw content fingerprint: its stable grouping key and unit
161 /// identity.
162 pub fingerprint: UnitFingerprint,
163 /// The unit's content fingerprint in fragment form, used as its member
164 /// content id when composing a group fingerprint (a whole-unit clone is a
165 /// fragment spanning the unit; keeping this as a fragment fingerprint keeps
166 /// the group id forward-compatible with sub-unit members).
167 pub content: FragmentFingerprint,
168 /// Identifier-normalized content used only for non-Type-1 group identity.
169 /// Unit identity remains raw so distinct renamed occurrences never merge.
170 pub normalized_content: FragmentFingerprint,
171}
172
173/// A verified clone relation between two contents that no reported group
174/// could hold.
175///
176/// The two contents are clones of each other by the judge's own verdict; what
177/// they are not is members of one set whose every pair is a clone, which is
178/// what a group asserts. Similarity is not transitive, so a unit can be a
179/// clone of two others that are not clones of each other, and a partition into
180/// groups can keep only one of those relations. The other is evidence the
181/// judge accepted, and it leaves the analysis here rather than being dropped.
182///
183/// The entry describes *contents*, not one pair of places. Where a codebase
184/// holds eight copies of one content and eight of another, the judge reaches
185/// the same verdict about all sixty-four crossings of them, and reporting
186/// sixty-four entries states one fact sixty-four times — all of them under one
187/// identity, because a clone id is composed from member content and two
188/// entries over the same two contents cannot differ. So every unit the folded
189/// verdicts touched is a member here, and there is one entry per pair of
190/// contents.
191#[derive(Debug, Clone, PartialEq)]
192pub struct VerifiedPair {
193 /// Every unit the folded verdicts touched, in unit-index order.
194 pub members: Vec<usize>,
195 /// Which member is the canonical instance. Which *content* is canonical
196 /// follows content order, so it does not depend on where either was found;
197 /// among the occurrences of that content the first in member order stands
198 /// for it, and they are interchangeable by construction.
199 pub canonical: usize,
200 /// The relation's stable, position-free clone id, composed exactly as a
201 /// group's is: a pair is a group of two contents, and nothing about its
202 /// identity should say otherwise.
203 pub fingerprint: CloneGroupFingerprint,
204 /// The strongest composite similarity among the folded verdicts.
205 pub similarity: f64,
206 /// Per-dimension evidence for the weakest accepted crossing represented
207 /// by this pair. It is absent only for callers that constructed a scalar
208 /// edge without verifier evidence.
209 pub breakdown: Option<SimilarityBreakdown>,
210 /// What the judge classified the relation as.
211 pub class: CloneClass,
212 /// The judge's confidence in that classification.
213 pub confidence: verify::Confidence,
214 /// The boilerplate category shared by the relation's members, when one
215 /// category dominates them under the same policy used for normal groups.
216 pub boilerplate: Option<Boilerplate>,
217 /// Whether the relation is one routine written once per integer width.
218 pub width_family: bool,
219}
220
221impl VerifiedPair {
222 /// Whether `unit` is one of the members.
223 #[must_use]
224 pub fn holds(&self, unit: usize) -> bool {
225 self.members.binary_search(&unit).is_ok()
226 }
227}
228
229/// Reporting detail for one clone group, parallel to the group at the same
230/// index in [`StructuralReport::groups`].
231#[derive(Debug, Clone, PartialEq)]
232pub struct GroupDetail {
233 /// The group's stable, position-free fingerprint (its clone id).
234 pub fingerprint: CloneGroupFingerprint,
235 /// The similarity breakdown of the medoid against each member, parallel to
236 /// the group's `members` (the medoid's own entry is a perfect self-match).
237 pub member_breakdowns: Vec<SimilarityBreakdown>,
238 /// The verifier breakdown for the actual weakest pair in the cohesive
239 /// group. This is the evidence that establishes `min_pairwise`.
240 pub cohesion_breakdown: SimilarityBreakdown,
241 /// Smallest raw-identifier Jaccard agreement against the canonical unit.
242 /// It is evidence only: detection, clone class, and priority ignore it.
243 pub identifier_jaccard: f64,
244 /// Conservative evidence that every member carries a material body.
245 ///
246 /// This is not a code-size estimate. It records only syntactic work that
247 /// a maintainer must understand while changing each copy.
248 pub body_materiality: BodyMateriality,
249 /// The dominant boilerplate shape of the whole group, when at least four
250 /// fifths of its members match it. The per-member classifications remain
251 /// available in reports so the exceptional bodies are never hidden.
252 pub boilerplate: Option<Boilerplate>,
253 /// Whether every member is test code. A group with even one member outside
254 /// the suite is duplication between test and tested code, which is the
255 /// interesting case and must not be ranked with the suite.
256 pub test_code: bool,
257 /// The aggregate evidence for [`Self::test_code`].
258 ///
259 /// Marker takes precedence when any member has it; path is named only
260 /// when every member is path-derived. `None` means at least one member is
261 /// not test code.
262 pub test_code_evidence: Option<TestCodeEvidence>,
263 /// Whether the group reads as one routine written once per integer width.
264 ///
265 /// A [`Boilerplate`] category is a judgement about one body, aggregated to
266 /// the group only when every member agrees. This is not: it is a statement
267 /// about how two bodies differ, which no member can carry on its own, so it
268 /// sits beside the category rather than inside it.
269 pub width_family: bool,
270}
271
272/// Material operations shared by every member of a structural clone group.
273#[derive(Debug, Clone, Copy, PartialEq, Eq)]
274pub struct BodyMateriality {
275 /// Every member contains at least one loop.
276 pub has_loop: bool,
277 /// Every member calls a recognised allocation API.
278 pub has_dynamic_allocation: bool,
279 /// Fewest recovered call sites in any member.
280 pub call_count: u64,
281}
282
283/// One occurrence of a duplicated statement run, resolved against the source
284/// it was found in.
285#[derive(Debug, Clone, PartialEq, Eq)]
286pub struct RegionOccurrence {
287 /// Index of the file this occurrence sits in.
288 pub file: usize,
289 /// Index of the enclosing unit in [`StructuralReport::units`].
290 pub unit: usize,
291 /// Source bytes the run covers; reporting only.
292 pub range: ByteRange,
293 /// 1-based first line; reporting only, never an identity input.
294 pub start_line: u32,
295 /// 1-based last line; reporting only, never an identity input.
296 pub end_line: u32,
297 /// Index of the run's first token in its file's stream.
298 pub token_start: usize,
299 /// Index one past the run's last token in its file's stream.
300 pub token_end: usize,
301 /// The occurrence's raw content fingerprint: its member content id.
302 pub content: FragmentFingerprint,
303}
304
305/// A half-open token span in one [`SyntaxIrFile`](crate::ir::SyntaxIrFile).
306///
307/// This is a reporting anchor, not an identity input. Callers use it to
308/// measure raw source evidence after structural detection has completed.
309#[derive(Debug, Clone, Copy, PartialEq, Eq)]
310pub struct SourceTokenSpan {
311 /// Index of the source file in the IR slice.
312 pub file: usize,
313 /// Index of the first token in the span.
314 pub token_start: usize,
315 /// Index one past the final token in the span.
316 pub token_end: usize,
317}
318
319impl SourceTokenSpan {
320 /// Construct a source-token span from half-open token indices.
321 #[must_use]
322 pub const fn new(file: usize, token_start: usize, token_end: usize) -> Self {
323 Self {
324 file,
325 token_start,
326 token_end,
327 }
328 }
329}
330
331/// A duplicated run of statements and every place it occurs.
332///
333/// Unlike a [`GroupDetail`], whose members are only *similar*, every
334/// occurrence here holds the same content under the group's classification:
335/// the same tokens for [`CloneClass::Type1`], the same tokens up to consistent
336/// renaming for [`CloneClass::Type2`]. The enclosing units need not be clones
337/// of each other — that is the point of reporting runs separately.
338#[derive(Debug, Clone, PartialEq, Eq)]
339pub struct StructuralRegion {
340 /// The run's stable, position-free clone-group fingerprint.
341 pub fingerprint: CloneGroupFingerprint,
342 /// How the occurrences match: verbatim or up to renaming.
343 pub clone_type: CloneClass,
344 /// Length of the run, in statements.
345 pub statements: u32,
346 /// Where the run occurs, at least twice, in ascending source order.
347 pub occurrences: Vec<RegionOccurrence>,
348}
349
350/// Funnel counters across the whole run: how many fragments, candidate pairs
351/// and verified pairs each stage saw.
352#[derive(Debug, Clone, Default, PartialEq, Eq)]
353pub struct StructuralStats {
354 /// Files analysed.
355 pub files: usize,
356 /// Units across all files.
357 pub units: usize,
358 /// Exact-seed candidate extraction statistics.
359 pub candidate: CandidateStats,
360 /// Near-match extraction statistics.
361 pub near_match: NearMatchStats,
362 /// Control-flow skeleton extraction statistics.
363 pub control_flow: ControlFlowStats,
364 /// Maximal-region consolidation statistics.
365 pub maximal: RegionStats,
366 /// Duplicated runs confirmed against the source tokens.
367 pub regions: usize,
368 /// Occurrences dropped for holding content no other occurrence of their
369 /// candidate run shared: the statement summaries agreed but the code did
370 /// not.
371 pub region_singletons: usize,
372 /// Occurrences dropped for covering source a kept occurrence of the same
373 /// run already covers, which makes them one stretch of code rather than
374 /// two instances of it.
375 pub region_overlapping: usize,
376 /// Occurrences dropped for continuing a kept occurrence of the same run,
377 /// statement for statement, inside one block. Those tile one stretch of
378 /// code, so the run is that stretch's period rather than a copy of it.
379 pub region_adjoining: usize,
380 /// Confirmed runs dropped because a longer run covers every one of their
381 /// occurrences and claims at least as much about them.
382 pub region_subsumed: usize,
383 /// Longer runs made by joining confirmed runs that describe one stretch at
384 /// two offsets. The parts they cover leave through `region_subsumed`.
385 pub region_merged: usize,
386 /// Confirmed duplicated statement runs dropped because at least one
387 /// occurrence is shorter than the configured minimum clone length.
388 pub below_min_clone_token_regions: usize,
389 /// Candidate pairs dropped because one unit encloses the other.
390 pub nested_pairs: usize,
391 /// Candidate pairs dropped because the two units sit under different arms
392 /// of one preprocessor conditional, so no build holds both.
393 pub alternative_pairs: usize,
394 /// Candidate pairs dropped because the two units hold too different a mix
395 /// of shapes for verification to have anything to find.
396 pub divergent_shape_pairs: usize,
397 /// Candidate unit pairs dropped because one member is shorter than the
398 /// configured minimum clone length.
399 pub below_min_clone_token_pairs: usize,
400 /// Distinct unit pairs handed to verification.
401 pub unit_pairs: usize,
402 /// Candidate unit pairs left unverified after the verification budget was
403 /// spent. A nonzero count means the reported groups can be incomplete.
404 pub verification_budget_dropped: usize,
405 /// Unit pairs that verification accepted as clones.
406 pub verified_pairs: usize,
407 /// Verified pairs no reported group holds both halves of.
408 pub unrepresented_pairs: usize,
409 /// Verified pairs left out of that carry-out because a group already
410 /// relates their two sides, one of them holding a unit nested inside the
411 /// other side.
412 pub described_pairs: usize,
413 /// Verified pairs left out because the component ceiling cut their two
414 /// sides into separate pieces, so no group was ever in a position to hold
415 /// both. Zero unless [`GroupingConfig::max_component`] fired.
416 pub severed_pairs: usize,
417 /// Grouping statistics.
418 pub grouping: GroupingStats,
419 /// Post-grouping sibling-sweep accounting.
420 pub siblings: SiblingSweepStats,
421}
422
423/// Counters for the bounded post-grouping sibling sweep.
424#[derive(Debug, Clone, Default, PartialEq, Eq)]
425pub struct SiblingSweepStats {
426 /// Established primary groups considered for local siblings.
427 pub groups_considered: usize,
428 /// Canonical-to-ungrouped comparisons eligible under the file, minimum,
429 /// nesting, conditional-arm, and shape-divergence rules.
430 pub eligible_candidates: usize,
431 /// Candidates handed to the verifier.
432 pub candidates_examined: usize,
433 /// Siblings retained after the relaxed verifier threshold.
434 pub accepted: usize,
435 /// Candidates left unexamined because `candidate_budget` was reached.
436 pub candidate_budget_dropped: usize,
437 /// Candidates left unexamined after their group reached `per_group_cap`.
438 pub per_group_cap_dropped: usize,
439 /// Candidates left unexamined after the report reached `total_cap`.
440 pub total_cap_dropped: usize,
441}
442
443/// One incomplete local mirror attached to an established primary group.
444#[derive(Debug, Clone, PartialEq)]
445pub struct StructuralSibling {
446 /// The ungrouped unit. It is never added to `StructuralGroup::members`.
447 pub unit: usize,
448 /// The verifier's clone classification, or Type-3 for a relaxed-only hit.
449 pub clone_type: CloneClass,
450 /// The verifier confidence, clamped to low below the normal Type-3
451 /// threshold even when an exact-structure shortcut classified the pair.
452 pub confidence: verify::Confidence,
453 /// The canonical-to-sibling similarity breakdown.
454 pub breakdown: SimilarityBreakdown,
455}
456
457/// Siblings of one primary group, addressed by its index in
458/// [`StructuralReport::groups`].
459#[derive(Debug, Clone, PartialEq)]
460pub struct GroupSiblings {
461 /// Index of the owning primary group.
462 pub group: usize,
463 /// Siblings in deterministic unit-fingerprint order.
464 pub siblings: Vec<StructuralSibling>,
465}
466
467/// One LSH-proposed unit pair that passed the size gate but landed inside the
468/// bounded estimate band immediately below the primary near-match threshold.
469///
470/// This is run-scoped diagnostic telemetry, not a similarity edge. It never
471/// reaches verification, grouping, group membership, or primary findings.
472#[derive(Debug, Clone, Copy, PartialEq)]
473pub struct StructuralNearMiss {
474 /// Index of the lower unit in [`StructuralReport::units`].
475 pub a: usize,
476 /// Index of the higher unit in [`StructuralReport::units`].
477 pub b: usize,
478 /// MinHash-estimated Jaccard similarity that missed the primary gate.
479 pub estimated_jaccard: f64,
480}
481
482/// The structural run's output: cohesive groups over [`Self::units`], plus the
483/// funnel statistics.
484#[derive(Debug, Clone, PartialEq)]
485pub struct StructuralReport {
486 /// Analysed units; a group's member indices index this slice.
487 pub units: Vec<StructuralUnit>,
488 /// Cohesive clone groups.
489 pub groups: GroupingSet,
490 /// Duplicated statement runs, each with every place it occurs. The units
491 /// involved need not be clones of each other: this is the sub-unit view of
492 /// the same corpus.
493 pub regions: Vec<StructuralRegion>,
494 /// Reporting detail per group, parallel to `groups.groups`: stable clone id
495 /// and the medoid-to-member similarity breakdowns.
496 pub details: Vec<GroupDetail>,
497 /// Verified clone pairs no group holds both halves of, strongest first.
498 /// Real copies that a partition into groups cannot express.
499 pub unrepresented: Vec<VerifiedPair>,
500 /// Incomplete local mirrors attached to primary groups without changing
501 /// the primary grouping relation.
502 pub siblings: Vec<GroupSiblings>,
503 /// Bounded LSH diagnostics immediately below the primary near-match
504 /// estimate gate. These pairs are not primary findings.
505 pub near_misses: Vec<StructuralNearMiss>,
506 /// Funnel statistics.
507 pub stats: StructuralStats,
508}
509
510impl StructuralReport {
511 /// Add configured test-path evidence to units in matching files.
512 ///
513 /// `test_files` is indexed like the source slice passed to structural
514 /// analysis. Marker evidence is never overwritten. The method then
515 /// recomputes the parallel group facts, keeping `test_code` true only
516 /// where every member remains test code. Candidate extraction,
517 /// verification, and grouping have already finished and are untouched.
518 pub fn apply_test_path_evidence(&mut self, test_files: &[bool]) {
519 for unit in &mut self.units {
520 if unit.test_code_evidence.is_none()
521 && test_files.get(unit.file).copied().unwrap_or(false)
522 {
523 unit.test_code_evidence = Some(TestCodeEvidence::Path);
524 }
525 unit.test_code = unit.test_code_evidence.is_some();
526 }
527 for (group, detail) in self.groups.groups.iter().zip(&mut self.details) {
528 let evidence = test_code::aggregate_evidence(
529 group
530 .members
531 .iter()
532 .map(|&member| self.units[member].test_code_evidence),
533 );
534 detail.test_code = evidence.is_some();
535 detail.test_code_evidence = evidence;
536 }
537 }
538}
539
540/// One unit offered to an explicit build-variant comparison.
541///
542/// The unit still records the variant that produced it. This is deliberately
543/// not a `BuildVariant`-less intermediate representation: comparison is an
544/// opt-in relation between independent programs, not another program.
545#[derive(Debug, Clone, Copy)]
546pub struct CrossVariantUnit<'a> {
547 /// Fingerprint of the partition that produced this unit.
548 pub origin_variant: &'a str,
549 /// Language of the source unit.
550 pub language: Language,
551 /// Reporting anchor relative to the scanned root.
552 pub file_path: &'a str,
553 /// Reporting anchor, 1-based.
554 pub start_line: u32,
555 /// Reporting anchor, 1-based.
556 pub end_line: u32,
557 /// The unit's declared name, when parsing recovered it.
558 pub name: Option<&'a str>,
559 /// Tokens covering precisely this unit.
560 pub tokens: &'a [Token],
561}
562
563/// A member of a cross-build-variant exact clone group.
564#[derive(Debug, Clone, PartialEq, Eq)]
565pub struct CrossVariantMember {
566 /// Stable occurrence identity; source anchors are reporting only.
567 pub id: stable_id::CrossVariantMemberId,
568 /// The normal partition that produced this member; never synthesized.
569 pub origin_variant: String,
570 /// Language of the source unit.
571 pub language: Language,
572 /// Reporting anchor relative to the scanned root.
573 pub file_path: String,
574 /// Reporting anchor, 1-based.
575 pub start_line: u32,
576 /// Reporting anchor, 1-based.
577 pub end_line: u32,
578 /// Best-effort unit name.
579 pub name: Option<String>,
580 /// Token count of the matched unit.
581 pub token_count: usize,
582}
583
584/// One exact group found across independent build variants.
585#[derive(Debug, Clone, PartialEq, Eq)]
586pub struct CrossVariantGroup {
587 /// Comparison-domain stable identifier, distinct from clone-group ids.
588 pub id: CrossVariantGroupId,
589 /// Exact clones only in the current policy.
590 pub clone_type: CloneClass,
591 /// Every occurrence, each retaining its origin variant.
592 pub members: Vec<CrossVariantMember>,
593}
594
595/// The result of an explicit cross-build-variant comparison.
596#[derive(Debug, Clone, PartialEq, Eq)]
597pub struct CrossVariantComparison {
598 /// Comparison-domain identity, including policy and the origin set.
599 pub id: CrossVariantComparisonId,
600 /// Sorted, deduplicated fingerprints of all partitions compared.
601 pub origin_variants: Vec<String>,
602 /// Exact groups with members from at least two origin variants.
603 pub groups: Vec<CrossVariantGroup>,
604}
605
606/// Compare exact whole units across C/C++ build partitions.
607///
608/// This deliberately covers Type-1 units only. It is a separate, bounded
609/// operation from a partition's structural pipeline: normal Type-2/3 groups,
610/// their snapshots, baselines and histories remain partition-local. The
611/// function does compare source units directly; it never joins groups that a
612/// partition happened to report.
613#[must_use]
614pub fn compare_build_variants(units: &[CrossVariantUnit<'_>]) -> Option<CrossVariantComparison> {
615 let mut origins: Vec<String> = units
616 .iter()
617 .map(|unit| unit.origin_variant.to_string())
618 .collect();
619 origins.sort_unstable();
620 origins.dedup();
621 if origins.len() < 2 {
622 return None;
623 }
624 let id = stable_id::cross_variant_comparison_id(&origins);
625 let mut classes: BTreeMap<(String, [u8; 16]), Vec<&CrossVariantUnit<'_>>> = BTreeMap::new();
626 for unit in units {
627 let mut content = blake3::Hasher::new();
628 content.update(b"cross-variant-raw-unit-v1");
629 for token in unit.tokens {
630 content.update(&[token.kind.tag()]);
631 let length = u32::try_from(token.text.len()).unwrap_or(u32::MAX);
632 content.update(&length.to_le_bytes());
633 content.update(token.text.as_bytes());
634 }
635 let mut digest = [0_u8; 16];
636 digest.copy_from_slice(&content.finalize().as_bytes()[..16]);
637 classes
638 .entry((unit.language.name().to_string(), digest))
639 .or_default()
640 .push(unit);
641 }
642 let mut groups = Vec::new();
643 for ((language, content), members) in classes {
644 let origins_in_group: BTreeSet<&str> =
645 members.iter().map(|member| member.origin_variant).collect();
646 if origins_in_group.len() < 2 {
647 continue;
648 }
649 let mut members: Vec<CrossVariantMember> = members
650 .into_iter()
651 .map(|member| CrossVariantMember {
652 id: stable_id::CrossVariantMemberId::from_bytes([0; 16]),
653 origin_variant: member.origin_variant.to_string(),
654 language: member.language,
655 file_path: member.file_path.to_string(),
656 start_line: member.start_line,
657 end_line: member.end_line,
658 name: member.name.map(ToString::to_string),
659 token_count: member.tokens.len(),
660 })
661 .collect();
662 members.sort_by(|left, right| {
663 left.origin_variant
664 .cmp(&right.origin_variant)
665 .then_with(|| left.file_path.cmp(&right.file_path))
666 .then_with(|| left.start_line.cmp(&right.start_line))
667 .then_with(|| left.end_line.cmp(&right.end_line))
668 .then_with(|| left.name.cmp(&right.name))
669 });
670 let language = match language.as_str() {
671 "c" => Language::C,
672 "cpp" => Language::Cpp,
673 _ => Language::Rust,
674 };
675 let group_id =
676 stable_id::cross_variant_group_id(&id, CloneClass::Type1, language, &content);
677 let mut origin_ranks = BTreeMap::<(&str, &str), u32>::new();
678 for member in &mut members {
679 let rank = origin_ranks
680 .entry((&member.origin_variant, member.language.name()))
681 .or_default();
682 member.id = stable_id::cross_variant_member_id(
683 &group_id,
684 &member.origin_variant,
685 member.language,
686 *rank,
687 );
688 *rank = rank.saturating_add(1);
689 }
690 groups.push(CrossVariantGroup {
691 id: group_id,
692 clone_type: CloneClass::Type1,
693 members,
694 });
695 }
696 groups.sort_by_key(|group| group.id);
697 Some(CrossVariantComparison {
698 id,
699 origin_variants: origins,
700 groups,
701 })
702}