Skip to main content

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}