1use crate::model::Component;
7
8#[derive(Debug, Clone)]
10#[must_use]
11pub struct MatchResult {
12 pub score: f64,
14 pub tier: MatchTier,
16 pub metadata: MatchMetadata,
18}
19
20impl MatchResult {
21 pub fn new(score: f64, tier: MatchTier) -> Self {
23 Self {
24 score,
25 tier,
26 metadata: MatchMetadata::default(),
27 }
28 }
29
30 pub const fn with_metadata(score: f64, tier: MatchTier, metadata: MatchMetadata) -> Self {
32 Self {
33 score,
34 tier,
35 metadata,
36 }
37 }
38
39 pub fn no_match() -> Self {
41 Self {
42 score: 0.0,
43 tier: MatchTier::None,
44 metadata: MatchMetadata::default(),
45 }
46 }
47
48 #[must_use]
50 pub fn is_match(&self) -> bool {
51 self.score > 0.0 && self.tier != MatchTier::None
52 }
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
57#[non_exhaustive]
58pub enum MatchTier {
59 None,
61 ExactIdentifier,
63 Alias,
65 EcosystemRule,
67 NameIdentity,
69 Fuzzy,
71 CustomRule,
73 CrossEcosystem,
76}
77
78impl MatchTier {
79 #[must_use]
81 pub const fn default_score(&self) -> f64 {
82 match self {
83 Self::None => 0.0,
84 Self::ExactIdentifier => 1.0,
85 Self::NameIdentity => 0.98,
86 Self::Alias => 0.95,
87 Self::EcosystemRule => 0.90,
88 Self::CustomRule => 0.92,
89 Self::CrossEcosystem => 0.85,
90 Self::Fuzzy => 0.80,
91 }
92 }
93}
94
95#[derive(Debug, Clone, Default)]
97pub struct MatchMetadata {
98 pub matched_fields: Vec<String>,
100 pub normalization: Option<String>,
102 pub rule_id: Option<String>,
104}
105
106#[derive(Debug, Clone)]
110pub struct MatchExplanation {
111 pub tier: MatchTier,
113 pub score: f64,
115 pub reason: String,
117 pub score_breakdown: Vec<ScoreComponent>,
119 pub normalizations_applied: Vec<String>,
121 pub is_match: bool,
123}
124
125#[derive(Debug, Clone)]
127pub struct ScoreComponent {
128 pub name: String,
130 pub weight: f64,
132 pub raw_score: f64,
134 pub weighted_score: f64,
136 pub description: String,
138}
139
140impl MatchExplanation {
141 pub fn matched(tier: MatchTier, score: f64, reason: impl Into<String>) -> Self {
143 Self {
144 tier,
145 score,
146 reason: reason.into(),
147 score_breakdown: Vec::new(),
148 normalizations_applied: Vec::new(),
149 is_match: true,
150 }
151 }
152
153 pub fn no_match(reason: impl Into<String>) -> Self {
155 Self {
156 tier: MatchTier::None,
157 score: 0.0,
158 reason: reason.into(),
159 score_breakdown: Vec::new(),
160 normalizations_applied: Vec::new(),
161 is_match: false,
162 }
163 }
164
165 #[must_use]
167 pub fn with_score_component(mut self, component: ScoreComponent) -> Self {
168 self.score_breakdown.push(component);
169 self
170 }
171
172 #[must_use]
174 pub fn with_normalization(mut self, normalization: impl Into<String>) -> Self {
175 self.normalizations_applied.push(normalization.into());
176 self
177 }
178
179 #[must_use]
181 pub fn summary(&self) -> String {
182 if self.is_match {
183 format!(
184 "MATCH ({:.0}% confidence via {:?}): {}",
185 self.score * 100.0,
186 self.tier,
187 self.reason
188 )
189 } else {
190 format!("NO MATCH: {}", self.reason)
191 }
192 }
193
194 #[must_use]
196 pub fn detailed(&self) -> String {
197 let mut lines = vec![self.summary()];
198
199 if !self.score_breakdown.is_empty() {
200 lines.push("Score breakdown:".to_string());
201 for component in &self.score_breakdown {
202 lines.push(format!(
203 " - {}: {:.2} × {:.2} = {:.2} ({})",
204 component.name,
205 component.raw_score,
206 component.weight,
207 component.weighted_score,
208 component.description
209 ));
210 }
211 }
212
213 if !self.normalizations_applied.is_empty() {
214 lines.push(format!(
215 "Normalizations: {}",
216 self.normalizations_applied.join(", ")
217 ));
218 }
219
220 lines.join("\n")
221 }
222}
223
224impl std::fmt::Display for MatchExplanation {
225 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
226 write!(f, "{}", self.summary())
227 }
228}
229
230pub trait ComponentMatcher: Send + Sync {
244 fn match_score(&self, a: &Component, b: &Component) -> f64;
248
249 fn match_detailed(&self, a: &Component, b: &Component) -> MatchResult {
253 let score = self.match_score(a, b);
254 if score > 0.0 {
255 MatchResult::new(score, MatchTier::Fuzzy)
256 } else {
257 MatchResult::no_match()
258 }
259 }
260
261 fn explain_match(&self, a: &Component, b: &Component) -> MatchExplanation {
265 let result = self.match_detailed(a, b);
266 if result.is_match() {
267 MatchExplanation::matched(
268 result.tier,
269 result.score,
270 format!("'{}' matches '{}' via {:?}", a.name, b.name, result.tier),
271 )
272 } else {
273 MatchExplanation::no_match(format!(
274 "'{}' does not match '{}' (score {:.2} below threshold)",
275 a.name, b.name, result.score
276 ))
277 }
278 }
279
280 fn find_best_match<'a>(
284 &self,
285 target: &Component,
286 candidates: &'a [&Component],
287 threshold: f64,
288 ) -> Option<(&'a Component, f64)> {
289 candidates
290 .iter()
291 .map(|c| (*c, self.match_score(target, c)))
292 .filter(|(_, score)| *score >= threshold)
293 .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
294 }
295
296 fn name(&self) -> &'static str {
298 "ComponentMatcher"
299 }
300
301 fn threshold(&self) -> f64 {
303 0.0
304 }
305}
306
307#[derive(Debug, Clone)]
309pub struct CacheConfig {
310 pub max_entries: usize,
312 pub cache_detailed: bool,
314}
315
316impl Default for CacheConfig {
317 fn default() -> Self {
318 Self {
319 max_entries: 100_000,
320 cache_detailed: false,
321 }
322 }
323}
324
325impl CacheConfig {
326 #[must_use]
328 pub const fn small() -> Self {
329 Self {
330 max_entries: 10_000,
331 cache_detailed: true,
332 }
333 }
334
335 #[must_use]
337 pub const fn large() -> Self {
338 Self {
339 max_entries: 500_000,
340 cache_detailed: false,
341 }
342 }
343}
344
345#[derive(Hash, Eq, PartialEq, Clone)]
347struct CacheKey {
348 hash: u64,
349}
350
351impl CacheKey {
352 fn new(a_id: &str, b_id: &str) -> Self {
353 use xxhash_rust::xxh3::xxh3_64;
354
355 let (first, second) = if a_id < b_id {
357 (a_id, b_id)
358 } else {
359 (b_id, a_id)
360 };
361
362 let combined = format!("{first}|{second}");
363 Self {
364 hash: xxh3_64(combined.as_bytes()),
365 }
366 }
367}
368
369#[derive(Clone)]
371struct CacheEntry {
372 score: f64,
373 detailed: Option<MatchResult>,
374}
375
376pub struct CachedMatcher<M: ComponentMatcher> {
393 inner: M,
394 config: CacheConfig,
395 cache: std::sync::RwLock<std::collections::HashMap<CacheKey, CacheEntry>>,
396 stats: std::sync::atomic::AtomicUsize,
397 hits: std::sync::atomic::AtomicUsize,
398}
399
400impl<M: ComponentMatcher> CachedMatcher<M> {
401 pub fn new(inner: M) -> Self {
403 Self::with_config(inner, CacheConfig::default())
404 }
405
406 pub fn with_config(inner: M, config: CacheConfig) -> Self {
408 Self {
409 inner,
410 config,
411 cache: std::sync::RwLock::new(std::collections::HashMap::new()),
412 stats: std::sync::atomic::AtomicUsize::new(0),
413 hits: std::sync::atomic::AtomicUsize::new(0),
414 }
415 }
416
417 pub const fn inner(&self) -> &M {
419 &self.inner
420 }
421
422 pub fn cache_stats(&self) -> CacheStats {
424 let total = self.stats.load(std::sync::atomic::Ordering::Relaxed);
425 let hits = self.hits.load(std::sync::atomic::Ordering::Relaxed);
426 let size = self.cache.read().map(|c| c.len()).unwrap_or(0);
427 CacheStats {
428 total_lookups: total,
429 cache_hits: hits,
430 cache_misses: total.saturating_sub(hits),
431 hit_rate: if total > 0 {
432 hits as f64 / total as f64
433 } else {
434 0.0
435 },
436 cache_size: size,
437 }
438 }
439
440 pub fn clear_cache(&self) {
442 if let Ok(mut cache) = self.cache.write() {
443 cache.clear();
444 }
445 self.stats.store(0, std::sync::atomic::Ordering::Relaxed);
446 self.hits.store(0, std::sync::atomic::Ordering::Relaxed);
447 }
448
449 fn get_cached(&self, key: &CacheKey) -> Option<CacheEntry> {
451 self.stats
452 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
453 if let Ok(cache) = self.cache.read()
454 && let Some(entry) = cache.get(key)
455 {
456 self.hits.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
457 return Some(entry.clone());
458 }
459 None
460 }
461
462 fn store_cached(&self, key: CacheKey, entry: CacheEntry) {
464 if let Ok(mut cache) = self.cache.write() {
465 if cache.len() >= self.config.max_entries {
467 let to_remove: Vec<CacheKey> = cache
468 .keys()
469 .take(self.config.max_entries / 2)
470 .cloned()
471 .collect();
472 for k in to_remove {
473 cache.remove(&k);
474 }
475 }
476 cache.insert(key, entry);
477 }
478 }
479}
480
481#[derive(Debug, Clone)]
483pub struct CacheStats {
484 pub total_lookups: usize,
486 pub cache_hits: usize,
488 pub cache_misses: usize,
490 pub hit_rate: f64,
492 pub cache_size: usize,
494}
495
496impl<M: ComponentMatcher> ComponentMatcher for CachedMatcher<M> {
497 fn match_score(&self, a: &Component, b: &Component) -> f64 {
498 let key = CacheKey::new(a.canonical_id.value(), b.canonical_id.value());
499
500 if let Some(entry) = self.get_cached(&key) {
502 return entry.score;
503 }
504
505 let score = self.inner.match_score(a, b);
507 self.store_cached(
508 key,
509 CacheEntry {
510 score,
511 detailed: None,
512 },
513 );
514 score
515 }
516
517 fn match_detailed(&self, a: &Component, b: &Component) -> MatchResult {
518 if !self.config.cache_detailed {
519 return self.inner.match_detailed(a, b);
520 }
521
522 let key = CacheKey::new(a.canonical_id.value(), b.canonical_id.value());
523
524 if let Some(entry) = self.get_cached(&key)
526 && let Some(detailed) = entry.detailed
527 {
528 return detailed;
529 }
530
531 let result = self.inner.match_detailed(a, b);
533 self.store_cached(
534 key,
535 CacheEntry {
536 score: result.score,
537 detailed: Some(result.clone()),
538 },
539 );
540 result
541 }
542
543 fn explain_match(&self, a: &Component, b: &Component) -> MatchExplanation {
544 self.inner.explain_match(a, b)
546 }
547
548 fn name(&self) -> &'static str {
549 "CachedMatcher"
550 }
551
552 fn threshold(&self) -> f64 {
553 self.inner.threshold()
554 }
555}
556
557#[must_use]
559pub struct CompositeMatcherBuilder {
560 matchers: Vec<Box<dyn ComponentMatcher>>,
561}
562
563impl CompositeMatcherBuilder {
564 pub fn new() -> Self {
566 Self {
567 matchers: Vec::new(),
568 }
569 }
570
571 pub fn with_matcher(mut self, matcher: Box<dyn ComponentMatcher>) -> Self {
573 self.matchers.push(matcher);
574 self
575 }
576
577 #[must_use]
579 pub fn build(self) -> CompositeMatcher {
580 CompositeMatcher {
581 matchers: self.matchers,
582 }
583 }
584}
585
586impl Default for CompositeMatcherBuilder {
587 fn default() -> Self {
588 Self::new()
589 }
590}
591
592pub struct CompositeMatcher {
594 matchers: Vec<Box<dyn ComponentMatcher>>,
595}
596
597impl ComponentMatcher for CompositeMatcher {
598 fn match_score(&self, a: &Component, b: &Component) -> f64 {
599 self.matchers
601 .iter()
602 .map(|m| m.match_score(a, b))
603 .max_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
604 .unwrap_or(0.0)
605 }
606
607 fn match_detailed(&self, a: &Component, b: &Component) -> MatchResult {
608 self.matchers
610 .iter()
611 .map(|m| m.match_detailed(a, b))
612 .max_by(|a, b| {
613 a.score
614 .partial_cmp(&b.score)
615 .unwrap_or(std::cmp::Ordering::Equal)
616 })
617 .unwrap_or_else(MatchResult::no_match)
618 }
619
620 fn name(&self) -> &'static str {
621 "CompositeMatcher"
622 }
623
624 fn threshold(&self) -> f64 {
630 self.matchers
631 .iter()
632 .map(|m| m.threshold())
633 .min_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
634 .unwrap_or(1.0)
635 }
636}
637
638#[cfg(test)]
639mod tests {
640 use super::*;
641
642 struct FixedScoreMatcher(f64);
644
645 impl ComponentMatcher for FixedScoreMatcher {
646 fn match_score(&self, _a: &Component, _b: &Component) -> f64 {
647 self.0
648 }
649
650 fn name(&self) -> &'static str {
651 "FixedScoreMatcher"
652 }
653 }
654
655 #[test]
656 fn test_match_result_creation() {
657 let result = MatchResult::new(0.95, MatchTier::Alias);
658 assert_eq!(result.score, 0.95);
659 assert_eq!(result.tier, MatchTier::Alias);
660 assert!(result.is_match());
661 }
662
663 #[test]
664 fn test_no_match_result() {
665 let result = MatchResult::no_match();
666 assert_eq!(result.score, 0.0);
667 assert_eq!(result.tier, MatchTier::None);
668 assert!(!result.is_match());
669 }
670
671 #[test]
672 fn test_match_tier_default_scores() {
673 assert_eq!(MatchTier::ExactIdentifier.default_score(), 1.0);
674 assert_eq!(MatchTier::Alias.default_score(), 0.95);
675 assert_eq!(MatchTier::EcosystemRule.default_score(), 0.90);
676 assert_eq!(MatchTier::None.default_score(), 0.0);
677 }
678
679 #[test]
680 fn test_composite_matcher() {
681 let matcher = CompositeMatcherBuilder::new()
682 .with_matcher(Box::new(FixedScoreMatcher(0.5)))
683 .with_matcher(Box::new(FixedScoreMatcher(0.8)))
684 .with_matcher(Box::new(FixedScoreMatcher(0.3)))
685 .build();
686
687 let comp_a = Component::new("test".to_string(), "id-1".to_string());
688 let comp_b = Component::new("test".to_string(), "id-2".to_string());
689
690 assert_eq!(matcher.match_score(&comp_a, &comp_b), 0.8);
692 }
693
694 #[test]
695 fn test_find_best_match() {
696 let matcher = FixedScoreMatcher(0.85);
697 let target = Component::new("target".to_string(), "id-0".to_string());
698 let candidates: Vec<Component> = vec![
699 Component::new("candidate1".to_string(), "id-1".to_string()),
700 Component::new("candidate2".to_string(), "id-2".to_string()),
701 ];
702 let candidate_refs: Vec<&Component> = candidates.iter().collect();
703
704 let result = matcher.find_best_match(&target, &candidate_refs, 0.8);
706 assert!(result.is_some());
707
708 let result = matcher.find_best_match(&target, &candidate_refs, 0.9);
710 assert!(result.is_none());
711 }
712
713 #[test]
714 fn test_match_explanation_matched() {
715 let explanation =
716 MatchExplanation::matched(MatchTier::ExactIdentifier, 1.0, "Test match reason");
717
718 assert!(explanation.is_match);
719 assert_eq!(explanation.score, 1.0);
720 assert_eq!(explanation.tier, MatchTier::ExactIdentifier);
721 assert!(explanation.summary().contains("MATCH"));
722 assert!(explanation.summary().contains("100%"));
723 }
724
725 #[test]
726 fn test_match_explanation_no_match() {
727 let explanation = MatchExplanation::no_match("Components are too different");
728
729 assert!(!explanation.is_match);
730 assert_eq!(explanation.score, 0.0);
731 assert_eq!(explanation.tier, MatchTier::None);
732 assert!(explanation.summary().contains("NO MATCH"));
733 }
734
735 #[test]
736 fn test_match_explanation_with_breakdown() {
737 let explanation = MatchExplanation::matched(MatchTier::Fuzzy, 0.85, "Fuzzy match")
738 .with_score_component(ScoreComponent {
739 name: "Jaro-Winkler".to_string(),
740 weight: 0.7,
741 raw_score: 0.9,
742 weighted_score: 0.63,
743 description: "name similarity".to_string(),
744 })
745 .with_score_component(ScoreComponent {
746 name: "Levenshtein".to_string(),
747 weight: 0.3,
748 raw_score: 0.73,
749 weighted_score: 0.22,
750 description: "edit distance".to_string(),
751 })
752 .with_normalization("lowercase");
753
754 assert_eq!(explanation.score_breakdown.len(), 2);
755 assert_eq!(explanation.normalizations_applied.len(), 1);
756
757 let detailed = explanation.detailed();
758 assert!(detailed.contains("Score breakdown:"));
759 assert!(detailed.contains("Jaro-Winkler"));
760 assert!(detailed.contains("Normalizations: lowercase"));
761 }
762
763 #[test]
764 fn test_match_explanation_display() {
765 let explanation = MatchExplanation::matched(MatchTier::Alias, 0.95, "Known alias");
766 let display = format!("{}", explanation);
767 assert!(display.contains("MATCH"));
768 assert!(display.contains("95%"));
769 }
770}