use crate::matching::{
BatchCandidateConfig, BatchCandidateGenerator, ComponentIndex, ComponentMatcher,
CrossEcosystemDb, FuzzyMatchConfig,
};
use crate::model::{CanonicalId, NormalizedSbom};
use std::collections::{HashMap, HashSet};
use super::engine_config::LargeSbomConfig;
pub type MatchResult = HashMap<CanonicalId, Option<CanonicalId>>;
#[derive(Debug, Clone)]
pub struct ComponentMatchResult {
pub matches: MatchResult,
pub pairs: HashMap<(CanonicalId, CanonicalId), f64>,
}
impl ComponentMatchResult {
pub fn new() -> Self {
Self {
matches: HashMap::new(),
pairs: HashMap::new(),
}
}
}
impl Default for ComponentMatchResult {
fn default() -> Self {
Self::new()
}
}
pub fn match_components(
old: &NormalizedSbom,
new: &NormalizedSbom,
matcher: &dyn ComponentMatcher,
fuzzy_config: &FuzzyMatchConfig,
large_sbom_config: &LargeSbomConfig,
) -> ComponentMatchResult {
let _span = tracing::info_span!(
"diff_engine::match_components",
old_count = old.component_count(),
new_count = new.component_count(),
)
.entered();
let mut result = ComponentMatchResult::new();
let mut used_new_ids: HashSet<CanonicalId> = HashSet::new();
for old_id in old.components.keys() {
if new.components.contains_key(old_id) {
let id = old_id.clone();
result.pairs.insert((id.clone(), id.clone()), 1.0);
result.matches.insert(id.clone(), Some(id.clone()));
used_new_ids.insert(id);
}
}
let unmatched_old: Vec<_> = old
.components
.keys()
.filter(|id| !result.matches.contains_key(*id))
.collect();
let total_components = old.component_count().max(new.component_count());
let use_batch_generator = total_components >= large_sbom_config.lsh_threshold;
let candidates: Vec<(CanonicalId, CanonicalId, f64)> = if use_batch_generator {
match_with_batch_generator(
old,
new,
&unmatched_old,
&used_new_ids,
matcher,
fuzzy_config,
large_sbom_config,
)
} else {
match_with_component_index(
old,
new,
&unmatched_old,
&used_new_ids,
matcher,
fuzzy_config,
large_sbom_config,
)
};
let assignment = sparse_assignment(&candidates);
for (old_id, new_id, score) in assignment {
if used_new_ids.insert(new_id.clone()) {
result.pairs.insert((old_id.clone(), new_id.clone()), score);
result.matches.insert(old_id, Some(new_id));
}
}
for old_id in old.components.keys() {
if !result.matches.contains_key(old_id) {
result.matches.insert(old_id.clone(), None);
}
}
result
}
fn match_with_batch_generator(
old: &NormalizedSbom,
new: &NormalizedSbom,
unmatched_old: &[&CanonicalId],
used_new_ids: &HashSet<CanonicalId>,
matcher: &dyn ComponentMatcher,
fuzzy_config: &FuzzyMatchConfig,
large_sbom_config: &LargeSbomConfig,
) -> Vec<(CanonicalId, CanonicalId, f64)> {
use rayon::prelude::*;
let batch_config = BatchCandidateConfig {
max_candidates: large_sbom_config.max_candidates,
max_length_diff: 10,
lsh_threshold: large_sbom_config.lsh_threshold,
enable_cross_ecosystem: large_sbom_config.cross_ecosystem.enabled,
};
let generator = BatchCandidateGenerator::build(new, batch_config);
let sources: Vec<_> = unmatched_old
.iter()
.filter_map(|id| old.components.get(*id).map(|comp| (*id, comp)))
.collect();
let parallel_threshold = 50;
if sources.len() > parallel_threshold {
sources
.par_iter()
.flat_map(|(old_id, old_comp)| {
let batch_result = generator.find_candidates(old_id, old_comp);
let mut all_candidates = batch_result.index_candidates;
all_candidates.extend(batch_result.lsh_candidates);
all_candidates.extend(batch_result.cross_ecosystem_candidates);
all_candidates
.iter()
.filter(|new_id| !used_new_ids.contains(*new_id))
.filter_map(|new_id| {
new.components.get(new_id).and_then(|new_comp| {
let score = matcher.match_score(old_comp, new_comp);
if score >= fuzzy_config.threshold {
Some(((*old_id).clone(), new_id.clone(), score))
} else {
None
}
})
})
.collect::<Vec<_>>()
})
.collect()
} else {
let mut candidates = Vec::new();
for (old_id, old_comp) in sources {
let batch_result = generator.find_candidates(old_id, old_comp);
let mut all_candidates = batch_result.index_candidates;
all_candidates.extend(batch_result.lsh_candidates);
all_candidates.extend(batch_result.cross_ecosystem_candidates);
for new_id in all_candidates {
if used_new_ids.contains(&new_id) {
continue;
}
if let Some(new_comp) = new.components.get(&new_id) {
let score = matcher.match_score(old_comp, new_comp);
if score >= fuzzy_config.threshold {
candidates.push((old_id.clone(), new_id, score));
}
}
}
}
candidates
}
}
fn match_with_component_index(
old: &NormalizedSbom,
new: &NormalizedSbom,
unmatched_old: &[&CanonicalId],
used_new_ids: &HashSet<CanonicalId>,
matcher: &dyn ComponentMatcher,
fuzzy_config: &FuzzyMatchConfig,
large_sbom_config: &LargeSbomConfig,
) -> Vec<(CanonicalId, CanonicalId, f64)> {
use rayon::prelude::*;
let new_index = ComponentIndex::build(new);
let old_index = ComponentIndex::build(old);
let cross_eco_db = if large_sbom_config.cross_ecosystem.enabled {
Some(CrossEcosystemDb::default())
} else {
None
};
let new_by_ecosystem: HashMap<_, Vec<_>> = if cross_eco_db.is_some() {
let mut map: HashMap<crate::model::Ecosystem, Vec<_>> = HashMap::new();
for (id, comp) in &new.components {
if let Some(eco) = &comp.ecosystem {
map.entry(eco.clone()).or_default().push((id.clone(), comp));
}
}
map
} else {
HashMap::new()
};
let max_candidates = 50;
let max_length_diff = 10;
let parallel_threshold = 50;
let cross_eco_config = &large_sbom_config.cross_ecosystem;
if unmatched_old.len() > parallel_threshold {
unmatched_old
.par_iter()
.flat_map(|old_id| {
let old_entry = old_index.get_entry(old_id);
let old_comp = old.components.get(*old_id);
match (old_entry, old_comp) {
(Some(entry), Some(old_comp)) => {
let candidate_ids = new_index.find_candidates(
old_id,
entry,
max_candidates,
max_length_diff,
);
let mut results: Vec<_> = candidate_ids
.iter()
.filter(|new_id| !used_new_ids.contains(*new_id))
.filter_map(|new_id| {
new.components.get(new_id).and_then(|new_comp| {
let score = matcher.match_score(old_comp, new_comp);
if score >= fuzzy_config.threshold {
Some(((*old_id).clone(), new_id.clone(), score))
} else {
None
}
})
})
.collect();
if let (Some(db), Some(old_eco)) = (&cross_eco_db, &old_comp.ecosystem) {
let cross_matches = find_cross_ecosystem_candidates(
old_id,
old_comp,
old_eco,
db,
&new_by_ecosystem,
used_new_ids,
matcher,
cross_eco_config,
);
results.extend(cross_matches);
}
results
}
_ => Vec::new(),
}
})
.collect()
} else {
let mut candidates = Vec::new();
for old_id in unmatched_old {
if let (Some(old_entry), Some(old_comp)) =
(old_index.get_entry(old_id), old.components.get(*old_id))
{
let candidate_ids =
new_index.find_candidates(old_id, old_entry, max_candidates, max_length_diff);
for new_id in candidate_ids {
if used_new_ids.contains(&new_id) {
continue;
}
if let Some(new_comp) = new.components.get(&new_id) {
let score = matcher.match_score(old_comp, new_comp);
if score >= fuzzy_config.threshold {
candidates.push(((*old_id).clone(), new_id, score));
}
}
}
if let (Some(db), Some(old_eco)) = (&cross_eco_db, &old_comp.ecosystem) {
let cross_matches = find_cross_ecosystem_candidates(
old_id,
old_comp,
old_eco,
db,
&new_by_ecosystem,
used_new_ids,
matcher,
cross_eco_config,
);
candidates.extend(cross_matches);
}
}
}
candidates
}
}
#[allow(clippy::too_many_arguments)]
fn find_cross_ecosystem_candidates(
old_id: &CanonicalId,
old_comp: &crate::model::Component,
old_eco: &crate::model::Ecosystem,
db: &CrossEcosystemDb,
new_by_ecosystem: &HashMap<
crate::model::Ecosystem,
Vec<(CanonicalId, &crate::model::Component)>,
>,
used_new_ids: &HashSet<CanonicalId>,
matcher: &dyn ComponentMatcher,
config: &crate::matching::CrossEcosystemConfig,
) -> Vec<(CanonicalId, CanonicalId, f64)> {
let mut results = Vec::new();
let equivalents = db.find_equivalents(old_eco, &old_comp.name);
for equiv in equivalents {
if config.verified_only && !equiv.verified {
continue;
}
if let Some(target_comps) = new_by_ecosystem.get(&equiv.target_ecosystem) {
let mut count = 0;
for (new_id, new_comp) in target_comps {
if count >= config.max_candidates {
break;
}
if used_new_ids.contains(new_id) {
continue;
}
if new_comp.name.eq_ignore_ascii_case(&equiv.target_name) {
let base_score = matcher.match_score(old_comp, new_comp);
let adjusted_score = (base_score - config.score_penalty).max(0.0);
if adjusted_score >= config.min_score {
results.push((old_id.clone(), new_id.clone(), adjusted_score));
count += 1;
}
}
}
}
}
results
}
const ASSIGNMENT_COST_SCALE: i64 = 1_000_000;
fn sparse_assignment(
candidates: &[(CanonicalId, CanonicalId, f64)],
) -> Vec<(CanonicalId, CanonicalId, f64)> {
let old_ids = sorted_unique_ids(candidates.iter().map(|(o, _, _)| o));
let new_ids = sorted_unique_ids(candidates.iter().map(|(_, n, _)| n));
let old_idx: HashMap<&CanonicalId, usize> =
old_ids.iter().enumerate().map(|(i, id)| (id, i)).collect();
let new_idx: HashMap<&CanonicalId, usize> =
new_ids.iter().enumerate().map(|(i, id)| (id, i)).collect();
let num_old = old_ids.len();
let num_real = new_ids.len();
let num_obj = num_real + num_old;
let mut adjacency: Vec<Vec<(usize, i64)>> = vec![Vec::new(); num_old];
{
let mut edge_best: HashMap<(usize, usize), i64> = HashMap::new();
for (old_id, new_id, score) in candidates {
if let (Some(&oi), Some(&ni)) = (old_idx.get(old_id), new_idx.get(new_id)) {
let clamped = score.clamp(0.0, 1.0);
let cost = ASSIGNMENT_COST_SCALE - (clamped * ASSIGNMENT_COST_SCALE as f64) as i64;
let entry = edge_best.entry((oi, ni)).or_insert(i64::MAX);
if cost < *entry {
*entry = cost;
}
}
}
for ((oi, ni), cost) in edge_best {
adjacency[oi].push((ni, cost));
}
for (src, edges) in adjacency.iter_mut().enumerate() {
edges.push((num_real + src, ASSIGNMENT_COST_SCALE));
edges.sort_by_key(|&(obj, _)| obj);
}
}
let mut object_owner: Vec<Option<usize>> = vec![None; num_obj];
let mut source_obj: Vec<Option<usize>> = vec![None; num_old];
let mut potential: Vec<i64> = vec![0; num_obj];
let mut dist: Vec<i64> = vec![i64::MAX; num_obj];
let mut src_for_obj: Vec<Option<usize>> = vec![None; num_obj];
let mut visited: Vec<bool> = vec![false; num_obj];
let mut touched: Vec<usize> = Vec::new();
for start in 0..num_old {
for &obj in &touched {
dist[obj] = i64::MAX;
src_for_obj[obj] = None;
visited[obj] = false;
}
touched.clear();
for &(obj, cost) in &adjacency[start] {
let reduced = cost - potential[obj];
if reduced < dist[obj] {
if dist[obj] == i64::MAX {
touched.push(obj);
}
dist[obj] = reduced;
src_for_obj[obj] = Some(start);
}
}
let mut found_obj: Option<usize> = None;
loop {
let mut best_obj = None;
let mut best_dist = i64::MAX;
for &obj in &touched {
if !visited[obj] && dist[obj] < best_dist {
best_dist = dist[obj];
best_obj = Some(obj);
}
}
let Some(obj) = best_obj else { break };
visited[obj] = true;
match object_owner[obj] {
None => {
found_obj = Some(obj);
break;
}
Some(owner) => {
let owner_obj_reduced = adjacency[owner]
.iter()
.find(|(o, _)| *o == obj)
.map_or(0, |&(_, c)| c - potential[obj]);
for &(obj2, cost) in &adjacency[owner] {
if visited[obj2] {
continue;
}
let reduced = cost - potential[obj2];
let nd = dist[obj] - owner_obj_reduced + reduced;
if nd < dist[obj2] {
if dist[obj2] == i64::MAX {
touched.push(obj2);
}
dist[obj2] = nd;
src_for_obj[obj2] = Some(owner);
}
}
}
}
}
for &obj in &touched {
if visited[obj] {
potential[obj] += dist[obj];
}
}
if let Some(mut obj) = found_obj {
loop {
let src = src_for_obj[obj].expect("path object has a source");
let prev_obj = source_obj[src];
object_owner[obj] = Some(src);
source_obj[src] = Some(obj);
match prev_obj {
Some(p) => obj = p,
None => break,
}
}
}
}
let edge_cost: HashMap<(usize, usize), i64> = adjacency
.iter()
.enumerate()
.flat_map(|(src, edges)| {
edges
.iter()
.filter(move |(obj, _)| *obj < num_real)
.map(move |&(obj, cost)| ((src, obj), cost))
})
.collect();
let mut result = Vec::new();
for (src, obj) in source_obj.iter().enumerate() {
if let Some(obj) = obj
&& *obj < num_real
&& let Some(&cost) = edge_cost.get(&(src, *obj))
{
let score = (ASSIGNMENT_COST_SCALE - cost) as f64 / ASSIGNMENT_COST_SCALE as f64;
if score > 0.0 {
result.push((old_ids[src].clone(), new_ids[*obj].clone(), score));
}
}
}
result
}
fn sorted_unique_ids<'a, I>(ids: I) -> Vec<CanonicalId>
where
I: Iterator<Item = &'a CanonicalId>,
{
let set: HashSet<&CanonicalId> = ids.collect();
let mut ids: Vec<CanonicalId> = set.into_iter().cloned().collect();
ids.sort_by(|a, b| a.value().cmp(b.value()));
ids
}
#[cfg(test)]
mod tests {
use super::*;
fn cid(s: &str) -> CanonicalId {
CanonicalId::from_format_id(s)
}
fn total_score(assignment: &[(CanonicalId, CanonicalId, f64)]) -> f64 {
assignment.iter().map(|(_, _, s)| s).sum()
}
#[test]
fn empty_candidates_yield_empty_assignment() {
assert!(sparse_assignment(&[]).is_empty());
}
#[test]
fn sparse_assignment_is_one_to_one() {
let candidates = vec![
(cid("o1"), cid("n1"), 0.9),
(cid("o1"), cid("n2"), 0.6),
(cid("o2"), cid("n1"), 0.8),
(cid("o2"), cid("n2"), 0.5),
];
let result = sparse_assignment(&candidates);
let old_used: HashSet<_> = result.iter().map(|(o, _, _)| o.clone()).collect();
let new_used: HashSet<_> = result.iter().map(|(_, n, _)| n.clone()).collect();
assert_eq!(
old_used.len(),
result.len(),
"each old id used at most once"
);
assert_eq!(
new_used.len(),
result.len(),
"each new id used at most once"
);
assert_eq!(result.len(), 2);
}
#[test]
fn sparse_assignment_finds_global_optimum_over_greedy_trap() {
let candidates = vec![
(cid("o1"), cid("n1"), 0.95),
(cid("o1"), cid("n2"), 0.80),
(cid("o2"), cid("n1"), 0.90),
(cid("o2"), cid("n2"), 0.10),
];
let result = sparse_assignment(&candidates);
assert!(
(total_score(&result) - 1.70).abs() < 1e-9,
"expected optimal total 1.70, got {} from {:?}",
total_score(&result),
result
);
}
#[test]
fn sparse_assignment_handles_more_sources_than_objects() {
let candidates = vec![
(cid("o1"), cid("n1"), 0.9),
(cid("o2"), cid("n1"), 0.7),
(cid("o2"), cid("n2"), 0.6),
(cid("o3"), cid("n2"), 0.8),
];
let result = sparse_assignment(&candidates);
assert!(result.len() <= 2);
let new_used: HashSet<_> = result.iter().map(|(_, n, _)| n.clone()).collect();
assert_eq!(new_used.len(), result.len());
assert!(
(total_score(&result) - 1.70).abs() < 1e-9,
"expected optimal total 1.70, got {}",
total_score(&result)
);
}
#[test]
fn sparse_assignment_is_deterministic() {
let candidates = vec![
(cid("a"), cid("x"), 0.5),
(cid("a"), cid("y"), 0.5),
(cid("b"), cid("x"), 0.5),
(cid("b"), cid("y"), 0.5),
];
let mut shuffled = candidates.clone();
shuffled.reverse();
let r1 = sparse_assignment(&candidates);
let r2 = sparse_assignment(&candidates);
let r3 = sparse_assignment(&shuffled);
assert_eq!(r1, r2, "repeated runs must match");
assert_eq!(r1, r3, "result must not depend on edge insertion order");
}
}