Skip to main content

codehelion_artifact/
metrics.rs

1//! Format-neutral duplicate grouping over [`ArtifactIr`].
2//!
3//! Exact and normalized equality are equivalence relations, so their groups
4//! are keyed directly by content rather than by a transitive similarity graph.
5//! Near-match grouping is deliberately a later operation: it must use the
6//! source engine's complete-linkage policy instead of union-find.
7
8use std::collections::{BTreeMap, BTreeSet};
9
10use serde::{Deserialize, Serialize};
11
12use crate::{ArtifactDataSegment, ArtifactFingerprint, ArtifactIr, ArtifactSymbol};
13
14/// The smallest data region that duplicate-data analysis reports by default.
15///
16/// Tiny constants occur frequently and are not useful bloat signals. Callers
17/// may use [`find_duplicate_data`] with another threshold when they have a
18/// format- or project-specific reason to do so.
19pub const DEFAULT_MIN_DUPLICATE_DATA_BYTES: u64 = 16;
20
21/// Maximum independent root closures considered for shared-dependency bytes.
22///
23/// Each root needs one reachability traversal. Above this limit the value is
24/// unavailable rather than allowing a large export table to monopolize the
25/// artifact worker.
26const MAX_SHARED_DEPENDENCY_ROOTS: usize = 1024;
27
28/// A model-derived estimate of a refactoring's byte impact.
29///
30/// Estimates may be negative when required call overhead outweighs the
31/// duplicate bytes attributed to the proposed refactoring.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(transparent)]
34pub struct EstimatedRefactorSavingsBytes(pub i64);
35
36/// A before/after reduction verified for one controlled refactoring.
37///
38/// A verified change may be negative when the controlled change grows the
39/// artifact, so it cannot be represented by an unsigned observed count.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
41#[serde(transparent)]
42pub struct VerifiedSavingsBytes(pub i64);
43
44/// Duplicate groups found in one artifact.
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46pub struct DuplicateReport {
47    /// Groups whose machine code bytes are identical.
48    pub exact: Vec<DuplicateGroup>,
49    /// Groups whose version-compatible normalized instructions are identical.
50    pub normalized: Vec<DuplicateGroup>,
51}
52
53/// Size categories kept separate in artifact reports.
54///
55/// A `None` value means the current parser evidence cannot establish the
56/// category. In particular, retained and shared-dependency sizes require a
57/// resolved call graph, which not every format backend can provide.
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59pub struct SizeClassification {
60    /// Complete byte length observed directly from the input.
61    pub observed_bytes: u64,
62    /// Excess bytes in exact duplicate code groups.
63    pub duplicated_bytes: u64,
64    /// Bytes retained by call-graph reachability, when calculated.
65    pub retained_bytes: Option<u64>,
66    /// Bytes shared by several dependency closures, when calculated.
67    pub shared_dependency_bytes: Option<u64>,
68    /// Excess bytes in exact duplicate data groups, when regions were
69    /// independently established rather than inferred from whole sections.
70    pub duplicated_data_bytes: Option<u64>,
71    /// A theoretical maximum from directly observed exact duplication.
72    ///
73    /// This is explicitly not a claim that a linker or refactoring can remove
74    /// the bytes without changing behaviour or layout.
75    pub upper_bound_savings_bytes: Option<u64>,
76    /// A source-informed refactoring estimate, unavailable before mapping.
77    pub estimated_refactor_savings_bytes: Option<EstimatedRefactorSavingsBytes>,
78    /// A before/after measured reduction, unavailable for one artifact.
79    pub verified_savings_bytes: Option<VerifiedSavingsBytes>,
80    /// Confidence in the duplicate observation. Exact byte equality is a
81    /// direct observation, while normalized equality stays separate in the
82    /// duplicate report.
83    pub clone_confidence: EvidenceConfidence,
84    /// Confidence in a possible size reduction. This is unavailable before
85    /// source mapping and a measured refactoring supply actual evidence.
86    pub savings_confidence: EvidenceConfidence,
87    /// Conditions and omissions that qualify the derived categories.
88    pub assumptions: Vec<String>,
89}
90
91/// Evidence strength reported without turning an observation into a promise.
92#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
93#[serde(rename_all = "kebab-case")]
94pub enum EvidenceConfidence {
95    /// Direct parser-observed facts establish the value.
96    High,
97    /// The result uses a conservative inference with known incompleteness.
98    Medium,
99    /// The result has substantial unresolved evidence.
100    Low,
101    /// The evidence necessary to calculate the value is absent.
102    Unavailable,
103}
104
105/// Reachability result derived only from resolved local call edges.
106#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
107pub struct DeadCodeReport {
108    /// Symbols not reached from a parser-established export.
109    pub symbols: Vec<ArtifactFingerprint>,
110    /// Whether every relevant dispatch edge was resolved.
111    pub definitive: bool,
112    /// Why the result is conservative or unavailable.
113    pub assumptions: Vec<String>,
114}
115
116/// The bytes exclusively retained by one reachable symbol's dominator region.
117#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
118pub struct RetainedSize {
119    /// The symbol whose removal makes the dominated region unreachable.
120    pub symbol: ArtifactFingerprint,
121    /// Sum of observed code sizes in its dominated region.
122    pub retained_bytes: u64,
123}
124
125/// One equality class of duplicate artifact symbols.
126#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
127pub struct DuplicateGroup {
128    /// Stable content identity for this group.
129    pub fingerprint: ArtifactFingerprint,
130    /// The byte size that could be removed if every member except the largest
131    /// canonical member were safely merged. It is an observed duplicate count,
132    /// not a claimed binary-size saving.
133    pub duplicated_bytes: u64,
134    /// Each observed occurrence. Offset distinguishes occurrences within this
135    /// one artifact but never participates in the stable fingerprint.
136    pub members: Vec<DuplicateMember>,
137}
138
139/// One occurrence in a [`DuplicateGroup`].
140#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
141pub struct DuplicateMember {
142    /// Stable content fingerprint of the symbol.
143    pub symbol: ArtifactFingerprint,
144    /// Artifact offset for this occurrence.
145    pub offset: u64,
146    /// Observed symbol size in bytes.
147    pub size: u64,
148}
149
150/// Find exact and normalized duplicate groups in `artifact`.
151#[must_use]
152pub fn find_duplicates(artifact: &ArtifactIr) -> DuplicateReport {
153    let exact = groups(&artifact.symbols, |symbol| {
154        Some(("exact", symbol.code.as_slice()))
155    });
156    let normalized = if artifact.capabilities.normalized_duplicates {
157        groups(&artifact.symbols, |symbol| {
158            symbol.normalized.as_ref().map(|normalized| {
159                // One byte separator is unambiguous because the version gets a
160                // length prefix in `group_fingerprint` below.
161                (normalized.version.as_str(), normalized.bytes.as_slice())
162            })
163        })
164    } else {
165        Vec::new()
166    };
167    DuplicateReport { exact, normalized }
168}
169
170/// Find exact duplicate data regions at or above `min_bytes`.
171///
172/// Data has no normalized representation: a match here means the byte stream
173/// itself is equal. Short regions are deliberately excluded before grouping.
174#[must_use]
175pub fn find_duplicate_data(artifact: &ArtifactIr, min_bytes: u64) -> Vec<DuplicateGroup> {
176    if !artifact.capabilities.independent_data_segments {
177        return Vec::new();
178    }
179    groups_data(&artifact.data_segments, min_bytes)
180}
181
182/// Derive the size categories supported by the currently observed IR.
183#[must_use]
184pub fn classify_sizes(artifact: &ArtifactIr) -> SizeClassification {
185    let duplicates = find_duplicates(artifact);
186    let duplicate_data = find_duplicate_data(artifact, DEFAULT_MIN_DUPLICATE_DATA_BYTES);
187    classify_sizes_from_duplicates(artifact, &duplicates, &duplicate_data)
188}
189
190/// Derive size categories while reusing duplicate groups already calculated
191/// for another report surface.
192#[must_use]
193pub fn classify_sizes_from_duplicates(
194    artifact: &ArtifactIr,
195    duplicates: &DuplicateReport,
196    duplicate_data: &[DuplicateGroup],
197) -> SizeClassification {
198    let duplicated_bytes = duplicates
199        .exact
200        .iter()
201        .map(|group| group.duplicated_bytes)
202        .sum();
203    let duplicated_data_bytes = artifact.capabilities.independent_data_segments.then(|| {
204        duplicate_data
205            .iter()
206            .map(|group| group.duplicated_bytes)
207            .sum()
208    });
209    let mut assumptions = vec![
210        "upper_bound_savings_bytes is not a guaranteed reduction".to_owned(),
211        "estimated_refactor_savings_bytes needs source-artifact mapping".to_owned(),
212    ];
213    if duplicated_data_bytes.is_none() {
214        assumptions
215            .push("duplicated_data_bytes needs independently established data regions".to_owned());
216    }
217    let graph_sizes = resolved_graph(artifact);
218    if graph_sizes.is_none() {
219        assumptions
220            .push("retained and shared dependency sizes need a resolved call graph".to_owned());
221    }
222    let (retained_bytes, shared_dependency_bytes) = graph_sizes.map_or((None, None), |graph| {
223        let retained_bytes = graph
224            .reachable
225            .iter()
226            .map(|symbol| graph.sizes[symbol])
227            .sum();
228        let mut root_reach_counts: BTreeMap<ArtifactFingerprint, u64> = BTreeMap::new();
229        for root in &graph.roots {
230            for symbol in reachable_from(BTreeSet::from([*root]), &graph.successors) {
231                *root_reach_counts.entry(symbol).or_default() += 1;
232            }
233        }
234        let shared_dependency_bytes = root_reach_counts
235            .into_iter()
236            .filter(|(_, count)| *count > 1)
237            .map(|(symbol, _)| graph.sizes[&symbol])
238            .sum();
239        (Some(retained_bytes), Some(shared_dependency_bytes))
240    });
241    SizeClassification {
242        observed_bytes: artifact.observed_bytes,
243        duplicated_bytes,
244        retained_bytes,
245        shared_dependency_bytes,
246        duplicated_data_bytes,
247        upper_bound_savings_bytes: Some(duplicated_bytes),
248        estimated_refactor_savings_bytes: None,
249        verified_savings_bytes: None,
250        clone_confidence: EvidenceConfidence::High,
251        savings_confidence: EvidenceConfidence::Unavailable,
252        assumptions,
253    }
254}
255
256/// Find symbols not reachable from parser-established exports.
257///
258/// An unresolved dispatch can target any local function, so it changes the
259/// result from a definitive dead-code finding into a candidate list. No
260/// exports means no trustworthy root set and therefore returns no finding.
261#[must_use]
262pub fn dead_code_candidates(artifact: &ArtifactIr) -> Option<DeadCodeReport> {
263    if !artifact.capabilities.call_graph {
264        return None;
265    }
266    let mut reachable: BTreeSet<ArtifactFingerprint> = artifact
267        .symbols
268        .iter()
269        .filter(|symbol| symbol.exported)
270        .map(|symbol| symbol.fingerprint)
271        .collect();
272    reachable.extend(artifact.entry_points.iter().copied());
273    reachable.extend(artifact.indirect_references.iter().copied());
274    if reachable.is_empty() {
275        return None;
276    }
277    loop {
278        let before = reachable.len();
279        for call in &artifact.calls {
280            if reachable.contains(&call.caller) {
281                if let Some(target) = call.target {
282                    reachable.insert(target);
283                }
284            }
285        }
286        if reachable.len() == before {
287            break;
288        }
289    }
290    let unresolved = artifact.calls.iter().any(|call| call.unresolved.is_some());
291    let mut symbols: Vec<_> = artifact
292        .symbols
293        .iter()
294        .map(|symbol| symbol.fingerprint)
295        .filter(|fingerprint| !reachable.contains(fingerprint))
296        .collect();
297    symbols.sort();
298    symbols.dedup();
299    Some(DeadCodeReport {
300        symbols,
301        definitive: !unresolved,
302        assumptions: if unresolved {
303            vec!["unresolved dispatch prevents proving unreachable symbols are dead".to_owned()]
304        } else {
305            vec!["all recorded call edges were resolved locally".to_owned()]
306        },
307    })
308}
309
310/// Calculate retained code sizes from a complete, unambiguous local call graph.
311///
312/// The returned regions overlap (a dominator retains its descendants too), so
313/// callers must never add them together as a total saving. Ambiguous duplicate
314/// fingerprints and unresolved calls are refused rather than guessed.
315///
316/// The immediate-dominator tree is derived with Lengauer--Tarjan. A virtual
317/// root joins parser-established roots, so a symbol shared by two entry points
318/// is not incorrectly retained by either one. The algorithm stores a constant
319/// amount of state per reachable symbol rather than a reachability set per
320/// symbol.
321#[must_use]
322pub fn retained_sizes(artifact: &ArtifactIr) -> Option<Vec<RetainedSize>> {
323    let graph = resolved_graph(artifact)?;
324    let symbols: Vec<_> = graph.reachable.iter().copied().collect();
325    let index: BTreeMap<_, _> = symbols
326        .iter()
327        .enumerate()
328        .map(|(position, symbol)| (*symbol, position + 1))
329        .collect();
330    let mut successors = vec![Vec::new(); symbols.len() + 1];
331    successors[0] = graph.roots.iter().map(|root| index[root]).collect();
332    for (caller, targets) in &graph.successors {
333        if !graph.reachable.contains(caller) {
334            continue;
335        }
336        for target in targets {
337            if graph.reachable.contains(target) {
338                successors[index[caller]].push(index[target]);
339            }
340        }
341    }
342
343    let (dfs_vertices, parents) = depth_first_tree(&successors);
344    let mut dfs_index = vec![None; successors.len()];
345    for (position, vertex) in dfs_vertices.iter().copied().enumerate() {
346        dfs_index[vertex] = Some(position);
347    }
348    let mut predecessors = vec![Vec::new(); dfs_vertices.len()];
349    for (vertex, edges) in successors.iter().enumerate() {
350        let Some(from) = dfs_index[vertex] else {
351            continue;
352        };
353        for target in edges {
354            if let Some(to) = dfs_index[*target] {
355                predecessors[to].push(from);
356            }
357        }
358    }
359    let immediate = lengauer_tarjan(&predecessors, &parents);
360    let mut retained = dfs_vertices
361        .iter()
362        .map(|vertex| {
363            if *vertex == 0 {
364                0
365            } else {
366                graph.sizes[&symbols[*vertex - 1]]
367            }
368        })
369        .collect::<Vec<_>>();
370    for node in (1..retained.len()).rev() {
371        if let Some(parent) = immediate[node] {
372            retained[parent] = retained[parent].saturating_add(retained[node]);
373        }
374    }
375    let mut result: Vec<_> = dfs_vertices
376        .iter()
377        .enumerate()
378        .skip(1)
379        .map(|(position, vertex)| RetainedSize {
380            symbol: symbols[*vertex - 1],
381            retained_bytes: retained[position],
382        })
383        .collect();
384    result.sort_by(|left, right| {
385        right
386            .retained_bytes
387            .cmp(&left.retained_bytes)
388            .then_with(|| left.symbol.cmp(&right.symbol))
389    });
390    Some(result)
391}
392
393/// Iterative DFS ordering and its parent relation, both in DFS indexes.
394fn depth_first_tree(successors: &[Vec<usize>]) -> (Vec<usize>, Vec<Option<usize>>) {
395    let mut vertices = vec![0];
396    let mut parents = vec![None];
397    let mut index = vec![None; successors.len()];
398    index[0] = Some(0);
399    let mut stack = vec![(0usize, 0usize)];
400    while let Some((vertex, next_edge)) = stack.last_mut() {
401        if *next_edge == successors[*vertex].len() {
402            stack.pop();
403            continue;
404        }
405        let target = successors[*vertex][*next_edge];
406        *next_edge += 1;
407        if index[target].is_some() {
408            continue;
409        }
410        let Some(parent) = index[*vertex] else {
411            continue;
412        };
413        index[target] = Some(vertices.len());
414        vertices.push(target);
415        parents.push(Some(parent));
416        stack.push((target, 0));
417    }
418    (vertices, parents)
419}
420
421/// Immediate dominators from a DFS predecessor graph, using Lengauer--Tarjan.
422fn lengauer_tarjan(predecessors: &[Vec<usize>], parents: &[Option<usize>]) -> Vec<Option<usize>> {
423    let nodes = predecessors.len();
424    let mut semi: Vec<_> = (0..nodes).collect();
425    let mut labels: Vec<_> = (0..nodes).collect();
426    let mut ancestors = vec![None; nodes];
427    let mut buckets = vec![Vec::new(); nodes];
428    let mut immediate = vec![None; nodes];
429
430    for node in (1..nodes).rev() {
431        for predecessor in &predecessors[node] {
432            let candidate = lt_eval(*predecessor, &mut ancestors, &mut labels, &semi);
433            semi[node] = semi[node].min(semi[candidate]);
434        }
435        buckets[semi[node]].push(node);
436        let Some(parent) = parents[node] else {
437            continue;
438        };
439        ancestors[node] = Some(parent);
440        for member in std::mem::take(&mut buckets[parent]) {
441            let candidate = lt_eval(member, &mut ancestors, &mut labels, &semi);
442            immediate[member] = Some(if semi[candidate] < semi[member] {
443                candidate
444            } else {
445                parent
446            });
447        }
448    }
449    for node in 1..nodes {
450        let Some(parent) = immediate[node] else {
451            continue;
452        };
453        if parent != semi[node] {
454            immediate[node] = immediate[parent];
455        }
456    }
457    immediate
458}
459
460/// Evaluate one union-find label while applying path compression.
461fn lt_eval(
462    node: usize,
463    ancestors: &mut [Option<usize>],
464    labels: &mut [usize],
465    semi: &[usize],
466) -> usize {
467    if ancestors[node].is_none() {
468        return node;
469    }
470    lt_compress(node, ancestors, labels, semi);
471    labels[node]
472}
473
474/// Compress the union-find path used by Lengauer--Tarjan evaluation.
475fn lt_compress(node: usize, ancestors: &mut [Option<usize>], labels: &mut [usize], semi: &[usize]) {
476    let mut path = Vec::new();
477    let mut current = node;
478    while let Some(parent) = ancestors[current] {
479        if ancestors[parent].is_none() {
480            break;
481        }
482        path.push(current);
483        current = parent;
484    }
485    for current in path.into_iter().rev() {
486        let Some(parent) = ancestors[current] else {
487            continue;
488        };
489        if semi[labels[parent]] < semi[labels[current]] {
490            labels[current] = labels[parent];
491        }
492        ancestors[current] = ancestors[parent];
493    }
494}
495
496/// Facts available only when every local graph edge and identity is sound.
497struct ResolvedGraph {
498    sizes: BTreeMap<ArtifactFingerprint, u64>,
499    roots: BTreeSet<ArtifactFingerprint>,
500    reachable: BTreeSet<ArtifactFingerprint>,
501    successors: BTreeMap<ArtifactFingerprint, Vec<ArtifactFingerprint>>,
502}
503
504fn resolved_graph(artifact: &ArtifactIr) -> Option<ResolvedGraph> {
505    if !artifact.capabilities.call_graph
506        || artifact.calls.iter().any(|call| call.unresolved.is_some())
507    {
508        return None;
509    }
510    let mut sizes = BTreeMap::new();
511    for symbol in &artifact.symbols {
512        if sizes.insert(symbol.fingerprint, symbol.size).is_some() {
513            return None;
514        }
515    }
516    let roots: BTreeSet<_> = artifact
517        .symbols
518        .iter()
519        .filter(|symbol| symbol.exported)
520        .map(|symbol| symbol.fingerprint)
521        .chain(artifact.entry_points.iter().copied())
522        .chain(artifact.indirect_references.iter().copied())
523        .collect();
524    if roots.is_empty()
525        || roots.len() > MAX_SHARED_DEPENDENCY_ROOTS
526        || !roots.iter().all(|root| sizes.contains_key(root))
527    {
528        return None;
529    }
530    if artifact.calls.iter().any(|call| {
531        !sizes.contains_key(&call.caller)
532            || !call
533                .target
534                .is_some_and(|target| sizes.contains_key(&target))
535    }) {
536        return None;
537    }
538    let mut successors: BTreeMap<_, Vec<_>> = sizes
539        .keys()
540        .copied()
541        .map(|symbol| (symbol, Vec::new()))
542        .collect();
543    for call in &artifact.calls {
544        if let Some(target) = call.target {
545            successors.entry(call.caller).or_default().push(target);
546        }
547    }
548    for targets in successors.values_mut() {
549        targets.sort_unstable();
550        targets.dedup();
551    }
552    let reachable = reachable_from(roots.clone(), &successors);
553    Some(ResolvedGraph {
554        sizes,
555        roots,
556        reachable,
557        successors,
558    })
559}
560
561fn reachable_from(
562    mut reachable: BTreeSet<ArtifactFingerprint>,
563    successors: &BTreeMap<ArtifactFingerprint, Vec<ArtifactFingerprint>>,
564) -> BTreeSet<ArtifactFingerprint> {
565    let mut pending: Vec<_> = reachable.iter().copied().collect();
566    while let Some(symbol) = pending.pop() {
567        if let Some(targets) = successors.get(&symbol) {
568            for target in targets {
569                if reachable.insert(*target) {
570                    pending.push(*target);
571                }
572            }
573        }
574    }
575    reachable
576}
577
578fn groups<'a>(
579    symbols: &'a [ArtifactSymbol],
580    key: impl Fn(&'a ArtifactSymbol) -> Option<(&'a str, &'a [u8])>,
581) -> Vec<DuplicateGroup> {
582    let mut buckets: BTreeMap<(&str, &[u8]), Vec<&ArtifactSymbol>> = BTreeMap::new();
583    for symbol in symbols {
584        let Some((version, content)) = key(symbol) else {
585            continue;
586        };
587        buckets.entry((version, content)).or_default().push(symbol);
588    }
589    let mut result: Vec<DuplicateGroup> = buckets
590        .into_iter()
591        .filter(|(_, members)| members.len() > 1)
592        .map(|((version, content), members)| group(version, content, members))
593        .collect();
594    result.sort_by(|left, right| {
595        right
596            .duplicated_bytes
597            .cmp(&left.duplicated_bytes)
598            .then_with(|| left.fingerprint.cmp(&right.fingerprint))
599    });
600    result
601}
602
603fn group(version: &str, content: &[u8], symbols: Vec<&ArtifactSymbol>) -> DuplicateGroup {
604    let mut members: Vec<DuplicateMember> = symbols
605        .into_iter()
606        .map(|symbol| DuplicateMember {
607            symbol: symbol.fingerprint,
608            offset: symbol.offset,
609            size: symbol.size,
610        })
611        .collect();
612    members.sort_by_key(|member| (member.offset, member.symbol));
613    let total = members.iter().map(|member| member.size).sum::<u64>();
614    let canonical = members.iter().map(|member| member.size).max().unwrap_or(0);
615    DuplicateGroup {
616        fingerprint: group_fingerprint(version, content),
617        duplicated_bytes: total.saturating_sub(canonical),
618        members,
619    }
620}
621
622fn groups_data(segments: &[ArtifactDataSegment], min_bytes: u64) -> Vec<DuplicateGroup> {
623    let mut buckets: BTreeMap<&[u8], Vec<&ArtifactDataSegment>> = BTreeMap::new();
624    for segment in segments {
625        if segment.bytes.len() as u64 >= min_bytes {
626            buckets
627                .entry(segment.bytes.as_slice())
628                .or_default()
629                .push(segment);
630        }
631    }
632    let mut result: Vec<DuplicateGroup> = buckets
633        .into_iter()
634        .filter(|(_, members)| members.len() > 1)
635        .map(|(bytes, segments)| {
636            let mut members: Vec<DuplicateMember> = segments
637                .into_iter()
638                .map(|segment| DuplicateMember {
639                    symbol: segment.fingerprint,
640                    offset: segment.offset,
641                    size: segment.bytes.len() as u64,
642                })
643                .collect();
644            members.sort_by_key(|member| (member.offset, member.symbol));
645            let total = members.iter().map(|member| member.size).sum::<u64>();
646            let canonical = members.iter().map(|member| member.size).max().unwrap_or(0);
647            DuplicateGroup {
648                fingerprint: group_fingerprint("data-exact", bytes),
649                duplicated_bytes: total.saturating_sub(canonical),
650                members,
651            }
652        })
653        .collect();
654    result.sort_by(|left, right| {
655        right
656            .duplicated_bytes
657            .cmp(&left.duplicated_bytes)
658            .then_with(|| left.fingerprint.cmp(&right.fingerprint))
659    });
660    result
661}
662
663fn group_fingerprint(version: &str, content: &[u8]) -> ArtifactFingerprint {
664    let mut identity = Vec::new();
665    identity.extend((version.len() as u64).to_le_bytes());
666    identity.extend(version.as_bytes());
667    identity.extend(content);
668    ArtifactFingerprint::from_content("artifact-duplicate-group", &identity)
669}
670
671#[cfg(test)]
672#[allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]
673mod tests {
674    use super::*;
675    use crate::{ArtifactDataSegment, ArtifactFormat, NormalizedInstructions};
676    use proptest::prelude::*;
677
678    fn symbol(offset: u64, code: &[u8], normalized: Option<&[u8]>) -> ArtifactSymbol {
679        ArtifactSymbol {
680            fingerprint: ArtifactFingerprint::from_content("test-symbol", &offset.to_le_bytes()),
681            name: None,
682            exported: false,
683            section: Some(1),
684            offset,
685            size: code.len() as u64,
686            size_inferred: false,
687            code: code.to_vec(),
688            normalized: normalized.map(|bytes| NormalizedInstructions {
689                version: "test-normal-v1".to_owned(),
690                bytes: bytes.to_vec(),
691            }),
692            inline_stack: Vec::new(),
693        }
694    }
695
696    #[test]
697    fn exact_and_normalized_groups_are_reported_separately_and_deterministically() {
698        let mut artifact = ArtifactIr::empty(ArtifactFormat::Wasm, b"input");
699        artifact.capabilities.normalized_duplicates = true;
700        artifact.symbols = vec![
701            symbol(30, &[1, 2], Some(&[9])),
702            symbol(10, &[1, 2], Some(&[9])),
703            symbol(20, &[1, 3], Some(&[9])),
704            symbol(40, &[5], None),
705        ];
706        let duplicates = find_duplicates(&artifact);
707        assert_eq!(duplicates.exact.len(), 1);
708        assert_eq!(duplicates.exact[0].members.len(), 2);
709        assert_eq!(duplicates.exact[0].duplicated_bytes, 2);
710        assert_eq!(
711            duplicates.exact[0]
712                .members
713                .iter()
714                .map(|member| member.offset)
715                .collect::<Vec<_>>(),
716            vec![10, 30]
717        );
718        assert_eq!(duplicates.normalized.len(), 1);
719        assert_eq!(duplicates.normalized[0].members.len(), 3);
720        assert_eq!(duplicates.normalized[0].duplicated_bytes, 4);
721        assert_eq!(find_duplicates(&artifact), duplicates);
722    }
723
724    #[test]
725    fn normalized_groups_are_unavailable_without_a_supported_normalizer() {
726        let mut artifact = ArtifactIr::empty(ArtifactFormat::Elf, b"input");
727        artifact.symbols = vec![
728            symbol(10, &[1, 2], Some(&[9])),
729            symbol(20, &[3, 4], Some(&[9])),
730        ];
731
732        let duplicates = find_duplicates(&artifact);
733
734        assert!(duplicates.exact.is_empty());
735        assert!(duplicates.normalized.is_empty());
736    }
737
738    #[test]
739    fn size_categories_separate_observed_data_and_unavailable_estimates() {
740        let mut artifact = ArtifactIr::empty(ArtifactFormat::Wasm, b"input bytes");
741        artifact.capabilities.independent_data_segments = true;
742        artifact.symbols = vec![symbol(10, &[1, 2, 3], None), symbol(20, &[1, 2, 3], None)];
743        let bytes = vec![7; 16];
744        artifact.data_segments = vec![
745            ArtifactDataSegment {
746                fingerprint: ArtifactFingerprint::from_content("data", b"one"),
747                section: None,
748                offset: 100,
749                bytes: bytes.clone(),
750            },
751            ArtifactDataSegment {
752                fingerprint: ArtifactFingerprint::from_content("data", b"two"),
753                section: None,
754                offset: 200,
755                bytes,
756            },
757        ];
758        let sizes = classify_sizes(&artifact);
759        assert_eq!(sizes.observed_bytes, 11);
760        assert_eq!(sizes.duplicated_bytes, 3);
761        assert_eq!(sizes.duplicated_data_bytes, Some(16));
762        assert_eq!(sizes.upper_bound_savings_bytes, Some(3));
763        assert!(sizes.estimated_refactor_savings_bytes.is_none());
764        assert!(sizes.verified_savings_bytes.is_none());
765        assert_eq!(sizes.clone_confidence, EvidenceConfidence::High);
766        assert_eq!(sizes.savings_confidence, EvidenceConfidence::Unavailable);
767        assert!(sizes.duplicated_bytes >= sizes.upper_bound_savings_bytes.unwrap_or(u64::MAX));
768    }
769
770    proptest! {
771        #[test]
772        fn size_categories_keep_exact_duplicate_bounds_for_disjoint_regions(
773            lengths in prop::collection::vec(16_usize..128, 0..24),
774        ) {
775            let mut artifact = ArtifactIr::empty(ArtifactFormat::Wasm, b"");
776            artifact.capabilities.independent_data_segments = true;
777            let mut offset = 0_u64;
778            for (index, length) in lengths.iter().copied().enumerate() {
779                let bytes = vec![u8::try_from(index).unwrap_or(u8::MAX); length];
780                artifact.symbols.push(symbol(offset, &bytes, None));
781                offset += length as u64;
782                artifact.symbols.push(symbol(offset, &bytes, None));
783                offset += length as u64;
784                artifact.data_segments.push(ArtifactDataSegment {
785                    fingerprint: ArtifactFingerprint::from_content("property-data", &bytes),
786                    section: Some(11),
787                    offset,
788                    bytes: bytes.clone(),
789                });
790                offset += length as u64;
791                artifact.data_segments.push(ArtifactDataSegment {
792                    fingerprint: ArtifactFingerprint::from_content("property-data", &bytes),
793                    section: Some(11),
794                    offset,
795                    bytes,
796                });
797                offset += length as u64;
798            }
799            artifact.observed_bytes = offset;
800            let sizes = classify_sizes(&artifact);
801            prop_assert!(sizes.duplicated_bytes <= sizes.observed_bytes);
802            prop_assert!(sizes.duplicated_data_bytes.is_some_and(|value| value <= sizes.observed_bytes));
803            prop_assert_eq!(
804                sizes.upper_bound_savings_bytes,
805                Some(sizes.duplicated_bytes)
806            );
807            prop_assert!(
808                sizes.estimated_refactor_savings_bytes.is_none()
809                    && sizes.verified_savings_bytes.is_none()
810            );
811        }
812    }
813
814    #[test]
815    fn unresolved_dispatch_downgrades_unreachable_symbols_to_candidates() {
816        let mut artifact = ArtifactIr::empty(ArtifactFormat::Wasm, b"input");
817        let entry = symbol(1, &[1], None);
818        let live = symbol(2, &[2], None);
819        let dead = symbol(3, &[3], None);
820        artifact.symbols = vec![entry.clone(), live.clone(), dead.clone()];
821        artifact.symbols[0].exported = true;
822        artifact.capabilities.call_graph = true;
823        artifact.calls = vec![crate::ArtifactCall {
824            caller: entry.fingerprint,
825            target: Some(live.fingerprint),
826            unresolved: None,
827        }];
828        let report = dead_code_candidates(&artifact).unwrap();
829        assert!(report.definitive);
830        assert_eq!(report.symbols, vec![dead.fingerprint]);
831        artifact.calls.push(crate::ArtifactCall {
832            caller: live.fingerprint,
833            target: None,
834            unresolved: Some(crate::UnresolvedCall::IndirectTable),
835        });
836        assert!(!dead_code_candidates(&artifact).unwrap().definitive);
837    }
838
839    #[test]
840    fn retained_size_uses_dominator_regions_without_summing_their_overlap() {
841        let mut artifact = ArtifactIr::empty(ArtifactFormat::Wasm, b"input");
842        let entry = symbol(1, &[1], None);
843        let middle = symbol(2, &[2, 2], None);
844        let leaf = symbol(3, &[3, 3, 3], None);
845        artifact.symbols = vec![entry.clone(), middle.clone(), leaf.clone()];
846        artifact.symbols[0].exported = true;
847        artifact.capabilities.call_graph = true;
848        artifact.calls = vec![
849            crate::ArtifactCall {
850                caller: entry.fingerprint,
851                target: Some(middle.fingerprint),
852                unresolved: None,
853            },
854            crate::ArtifactCall {
855                caller: middle.fingerprint,
856                target: Some(leaf.fingerprint),
857                unresolved: None,
858            },
859        ];
860        let retained = retained_sizes(&artifact).unwrap();
861        let value = |fingerprint| {
862            retained
863                .iter()
864                .find(|item| item.symbol == fingerprint)
865                .unwrap()
866                .retained_bytes
867        };
868        assert_eq!(value(entry.fingerprint), 6);
869        assert_eq!(value(middle.fingerprint), 5);
870        assert_eq!(value(leaf.fingerprint), 3);
871        let sizes = classify_sizes(&artifact);
872        assert_eq!(sizes.retained_bytes, Some(6));
873        assert_eq!(sizes.shared_dependency_bytes, Some(0));
874        artifact.calls[1].unresolved = Some(crate::UnresolvedCall::IndirectTable);
875        assert!(retained_sizes(&artifact).is_none());
876    }
877
878    #[test]
879    fn path_compression_handles_a_deep_ancestor_chain_iteratively() {
880        let nodes = 100_000_usize;
881        let mut ancestors = (0..nodes)
882            .map(|node| node.checked_sub(1))
883            .collect::<Vec<_>>();
884        let mut labels = (0..nodes).collect::<Vec<_>>();
885        let semi = (0..nodes).collect::<Vec<_>>();
886
887        lt_compress(nodes - 1, &mut ancestors, &mut labels, &semi);
888
889        assert!(ancestors[0].is_none());
890        assert_eq!(ancestors[1], Some(0));
891        assert!(ancestors[2..].iter().all(|ancestor| *ancestor == Some(0)));
892        assert!(labels[1..].iter().all(|label| *label == 1));
893    }
894
895    #[test]
896    fn retained_size_converges_for_a_cycle() {
897        let mut artifact = ArtifactIr::empty(ArtifactFormat::Wasm, b"input");
898        let entry = symbol(1, &[1], None);
899        let left = symbol(2, &[2, 2], None);
900        let right = symbol(3, &[3, 3, 3], None);
901        artifact.symbols = vec![entry.clone(), left.clone(), right.clone()];
902        artifact.symbols[0].exported = true;
903        artifact.capabilities.call_graph = true;
904        artifact.calls = vec![
905            crate::ArtifactCall {
906                caller: entry.fingerprint,
907                target: Some(left.fingerprint),
908                unresolved: None,
909            },
910            crate::ArtifactCall {
911                caller: left.fingerprint,
912                target: Some(right.fingerprint),
913                unresolved: None,
914            },
915            crate::ArtifactCall {
916                caller: right.fingerprint,
917                target: Some(left.fingerprint),
918                unresolved: None,
919            },
920        ];
921        let retained = retained_sizes(&artifact).unwrap();
922        let value = |fingerprint| {
923            retained
924                .iter()
925                .find(|item| item.symbol == fingerprint)
926                .unwrap()
927                .retained_bytes
928        };
929        assert_eq!(value(entry.fingerprint), 6);
930        assert_eq!(value(left.fingerprint), 5);
931        assert_eq!(value(right.fingerprint), 3);
932    }
933
934    #[test]
935    fn retained_size_handles_a_deep_call_chain_without_quadratic_state() {
936        const DEPTH: usize = 10_000;
937        let mut artifact = ArtifactIr::empty(ArtifactFormat::Wasm, b"input");
938        artifact.symbols = (0..DEPTH)
939            .map(|offset| symbol(u64::try_from(offset).unwrap(), &[1], None))
940            .collect();
941        artifact.symbols[0].exported = true;
942        artifact.capabilities.call_graph = true;
943        artifact.calls = artifact
944            .symbols
945            .windows(2)
946            .map(|pair| crate::ArtifactCall {
947                caller: pair[0].fingerprint,
948                target: Some(pair[1].fingerprint),
949                unresolved: None,
950            })
951            .collect();
952
953        let retained = retained_sizes(&artifact).unwrap();
954        assert_eq!(retained.len(), DEPTH);
955        let value = |fingerprint| {
956            retained
957                .iter()
958                .find(|item| item.symbol == fingerprint)
959                .unwrap()
960                .retained_bytes
961        };
962        assert_eq!(
963            value(artifact.symbols[0].fingerprint),
964            u64::try_from(DEPTH).unwrap()
965        );
966        assert_eq!(value(artifact.symbols[DEPTH - 1].fingerprint), 1);
967    }
968
969    #[test]
970    fn size_categories_keep_shared_dependencies_separate() {
971        let mut artifact = ArtifactIr::empty(ArtifactFormat::Wasm, b"input");
972        let left_root = symbol(1, &[1], None);
973        let right_root = symbol(2, &[2, 2], None);
974        let shared = symbol(3, &[3, 3, 3], None);
975        artifact.symbols = vec![left_root.clone(), right_root.clone(), shared.clone()];
976        artifact.symbols[0].exported = true;
977        artifact.symbols[1].exported = true;
978        artifact.capabilities.call_graph = true;
979        artifact.calls = vec![
980            crate::ArtifactCall {
981                caller: left_root.fingerprint,
982                target: Some(shared.fingerprint),
983                unresolved: None,
984            },
985            crate::ArtifactCall {
986                caller: right_root.fingerprint,
987                target: Some(shared.fingerprint),
988                unresolved: None,
989            },
990        ];
991        let sizes = classify_sizes(&artifact);
992        assert_eq!(sizes.retained_bytes, Some(6));
993        assert_eq!(sizes.shared_dependency_bytes, Some(3));
994    }
995
996    #[test]
997    fn excessive_root_count_makes_shared_dependency_sizes_unavailable() {
998        let mut artifact = ArtifactIr::empty(ArtifactFormat::Wasm, b"input");
999        artifact.symbols = (0..=MAX_SHARED_DEPENDENCY_ROOTS)
1000            .map(|offset| symbol(u64::try_from(offset).unwrap(), &[1], None))
1001            .collect();
1002        artifact
1003            .symbols
1004            .iter_mut()
1005            .for_each(|symbol| symbol.exported = true);
1006        artifact.capabilities.call_graph = true;
1007
1008        let sizes = classify_sizes(&artifact);
1009
1010        assert_eq!(sizes.retained_bytes, None);
1011        assert_eq!(sizes.shared_dependency_bytes, None);
1012        assert!(retained_sizes(&artifact).is_none());
1013    }
1014}