1use std::collections::{HashMap, HashSet};
2
3use super::change::{ChangeType, SemanticChange};
4use super::entity::SemanticEntity;
5
6fn parent_name(entity: &SemanticEntity) -> Option<String> {
8 let pid = entity.parent_id.as_ref()?;
9 pid.rsplit("::").next().map(String::from)
10}
11
12pub struct MatchResult {
13 pub changes: Vec<SemanticChange>,
14}
15
16fn classify_match(before: &SemanticEntity, after: &SemanticEntity) -> ChangeType {
17 if before.file_path != after.file_path {
18 ChangeType::Moved
19 } else if before.parent_id != after.parent_id {
20 ChangeType::Moved } else {
22 ChangeType::Renamed
23 }
24}
25
26fn make_change(
27 after_entity: &SemanticEntity,
28 change_type: ChangeType,
29 before_entity: Option<&SemanticEntity>,
30 commit_sha: Option<&str>,
31 author: Option<&str>,
32) -> SemanticChange {
33 let prefix = match change_type {
34 ChangeType::Added => "added::",
35 ChangeType::Deleted => "deleted::",
36 ChangeType::Reordered => "reordered::",
37 _ => "",
38 };
39 let primary = if change_type == ChangeType::Deleted {
41 before_entity.unwrap_or(after_entity)
42 } else {
43 after_entity
44 };
45 SemanticChange {
46 id: format!("change::{prefix}{}", primary.id),
47 entity_id: primary.id.clone(),
48 change_type,
49 entity_type: primary.entity_type.clone(),
50 entity_name: primary.name.clone(),
51 entity_line: primary.start_line,
52 parent_name: parent_name(primary),
53 file_path: primary.file_path.clone(),
54 old_entity_name: before_entity.and_then(|b| {
55 (b.name != after_entity.name).then(|| b.name.clone())
56 }),
57 old_file_path: before_entity.and_then(|b| {
58 (b.file_path != after_entity.file_path).then(|| b.file_path.clone())
59 }),
60 old_parent_id: before_entity.and_then(|b| {
61 (b.parent_id != after_entity.parent_id).then(|| b.parent_id.clone()).flatten()
62 }),
63 before_content: before_entity.map(|b| b.content.clone()),
64 after_content: if change_type == ChangeType::Deleted || change_type == ChangeType::Reordered {
65 None
66 } else {
67 Some(after_entity.content.clone())
68 },
69 commit_sha: commit_sha.map(String::from),
70 author: author.map(String::from),
71 timestamp: None,
72 structural_change: None,
73 }
74}
75
76pub fn match_entities(
81 before: &[SemanticEntity],
82 after: &[SemanticEntity],
83 _file_path: &str,
84 _similarity_fn: Option<&dyn Fn(&SemanticEntity, &SemanticEntity) -> f64>,
85 commit_sha: Option<&str>,
86 author: Option<&str>,
87) -> MatchResult {
88 let mut changes: Vec<SemanticChange> = Vec::new();
89 let mut matched_before: HashSet<&str> = HashSet::new();
90 let mut matched_after: HashSet<&str> = HashSet::new();
91
92 let before_by_id: HashMap<&str, &SemanticEntity> =
93 before.iter().map(|e| (e.id.as_str(), e)).collect();
94 let after_by_id: HashMap<&str, &SemanticEntity> =
95 after.iter().map(|e| (e.id.as_str(), e)).collect();
96
97 for (&id, after_entity) in &after_by_id {
99 if let Some(before_entity) = before_by_id.get(id) {
100 matched_before.insert(id);
101 matched_after.insert(id);
102
103 if before_entity.content_hash != after_entity.content_hash {
104 let mut change = make_change(after_entity, ChangeType::Modified, Some(before_entity), commit_sha, author);
105 change.structural_change = match (&before_entity.structural_hash, &after_entity.structural_hash) {
106 (Some(before_sh), Some(after_sh)) => Some(before_sh != after_sh),
107 _ => None,
108 };
109 changes.push(change);
110 }
111 }
112 }
113
114 let unmatched_before: Vec<&SemanticEntity> = before
116 .iter()
117 .filter(|e| !matched_before.contains(e.id.as_str()))
118 .collect();
119 let unmatched_after: Vec<&SemanticEntity> = after
120 .iter()
121 .filter(|e| !matched_after.contains(e.id.as_str()))
122 .collect();
123
124 let mut before_by_hash: HashMap<&str, Vec<&SemanticEntity>> = HashMap::new();
126 let mut before_by_structural: HashMap<&str, Vec<&SemanticEntity>> = HashMap::new();
127 for entity in &unmatched_before {
128 before_by_hash
129 .entry(entity.content_hash.as_str())
130 .or_default()
131 .push(entity);
132 if let Some(ref sh) = entity.structural_hash {
133 before_by_structural
134 .entry(sh.as_str())
135 .or_default()
136 .push(entity);
137 }
138 }
139
140 for after_entity in &unmatched_after {
141 if matched_after.contains(after_entity.id.as_str()) {
142 continue;
143 }
144 let found = before_by_hash
146 .get_mut(after_entity.content_hash.as_str())
147 .and_then(|c| c.pop());
148 let found = found.or_else(|| {
150 after_entity.structural_hash.as_ref().and_then(|sh| {
151 before_by_structural.get_mut(sh.as_str()).and_then(|c| {
152 c.iter()
153 .position(|e| !matched_before.contains(e.id.as_str()))
154 .map(|i| c.remove(i))
155 })
156 })
157 });
158
159 if let Some(before_entity) = found {
160 matched_before.insert(&before_entity.id);
161 matched_after.insert(&after_entity.id);
162
163 if before_entity.name == after_entity.name
167 && before_entity.file_path == after_entity.file_path
168 && before_entity.content_hash == after_entity.content_hash
169 && before_entity.parent_id == after_entity.parent_id
170 {
171 continue;
172 }
173
174 changes.push(make_change(after_entity, classify_match(before_entity, after_entity), Some(before_entity), commit_sha, author));
175 }
176 }
177
178 let still_unmatched_before: Vec<&SemanticEntity> = unmatched_before
181 .iter()
182 .filter(|e| !matched_before.contains(e.id.as_str()))
183 .copied()
184 .collect();
185 let still_unmatched_after: Vec<&SemanticEntity> = unmatched_after
186 .iter()
187 .filter(|e| !matched_after.contains(e.id.as_str()))
188 .copied()
189 .collect();
190
191 if !still_unmatched_before.is_empty() && !still_unmatched_after.is_empty() {
192 const THRESHOLD: f64 = 0.8;
193 const SIZE_RATIO_CUTOFF: f64 = 0.5;
194
195 let before_sets: Vec<HashSet<&str>> = still_unmatched_before
197 .iter()
198 .map(|e| e.content.split_whitespace().collect())
199 .collect();
200 let after_sets: Vec<HashSet<&str>> = still_unmatched_after
201 .iter()
202 .map(|e| e.content.split_whitespace().collect())
203 .collect();
204
205 let mut before_by_type: HashMap<&str, Vec<usize>> = HashMap::new();
207 for (i, e) in still_unmatched_before.iter().enumerate() {
208 before_by_type
209 .entry(e.entity_type.as_str())
210 .or_default()
211 .push(i);
212 }
213
214 for (ai, after_entity) in still_unmatched_after.iter().enumerate() {
215 let candidates = match before_by_type.get(after_entity.entity_type.as_str()) {
216 Some(indices) => indices,
217 None => continue,
218 };
219
220 let a_set = &after_sets[ai];
221 let a_len = a_set.len();
222 let mut best_idx: Option<usize> = None;
223 let mut best_score: f64 = 0.0;
224
225 for &bi in candidates {
226 if matched_before.contains(still_unmatched_before[bi].id.as_str()) {
227 continue;
228 }
229
230 let b_set = &before_sets[bi];
231 let b_len = b_set.len();
232
233 let (min_l, max_l) = if a_len < b_len {
235 (a_len, b_len)
236 } else {
237 (b_len, a_len)
238 };
239 if max_l > 0 && (min_l as f64 / max_l as f64) < SIZE_RATIO_CUTOFF {
240 continue;
241 }
242
243 let intersection = a_set.intersection(b_set).count();
245 let union = a_len + b_len - intersection;
246 let score = if union == 0 {
247 0.0
248 } else {
249 intersection as f64 / union as f64
250 };
251
252 if score >= THRESHOLD && score > best_score {
253 best_score = score;
254 best_idx = Some(bi);
255 }
256 }
257
258 if let Some(bi) = best_idx {
259 let matched = still_unmatched_before[bi];
260 matched_before.insert(&matched.id);
261 matched_after.insert(&after_entity.id);
262
263 if matched.name == after_entity.name
265 && matched.file_path == after_entity.file_path
266 && matched.content_hash == after_entity.content_hash
267 && matched.parent_id == after_entity.parent_id
268 {
269 continue;
270 }
271
272 changes.push(make_change(after_entity, classify_match(matched, after_entity), Some(matched), commit_sha, author));
273 }
274 }
275 }
276
277 detect_reorders(before, after, &matched_before, &matched_after, &mut changes, commit_sha, author);
281
282 for entity in before.iter().filter(|e| !matched_before.contains(e.id.as_str())) {
284 changes.push(make_change(entity, ChangeType::Deleted, Some(entity), commit_sha, author));
285 }
286
287 for entity in after.iter().filter(|e| !matched_after.contains(e.id.as_str())) {
289 changes.push(make_change(entity, ChangeType::Added, None, commit_sha, author));
290 }
291
292 MatchResult { changes }
293}
294
295pub fn default_similarity(a: &SemanticEntity, b: &SemanticEntity) -> f64 {
297 let tokens_a: Vec<&str> = a.content.split_whitespace().collect();
298 let tokens_b: Vec<&str> = b.content.split_whitespace().collect();
299
300 let (min_c, max_c) = if tokens_a.len() < tokens_b.len() {
302 (tokens_a.len(), tokens_b.len())
303 } else {
304 (tokens_b.len(), tokens_a.len())
305 };
306 if max_c > 0 && (min_c as f64 / max_c as f64) < 0.6 {
307 return 0.0;
308 }
309
310 let set_a: HashSet<&str> = tokens_a.into_iter().collect();
311 let set_b: HashSet<&str> = tokens_b.into_iter().collect();
312
313 let intersection_size = set_a.intersection(&set_b).count();
314 let union_size = set_a.union(&set_b).count();
315
316 if union_size == 0 {
317 return 0.0;
318 }
319
320 intersection_size as f64 / union_size as f64
321}
322
323fn detect_reorders(
329 before: &[SemanticEntity],
330 after: &[SemanticEntity],
331 matched_before: &HashSet<&str>,
332 matched_after: &HashSet<&str>,
333 changes: &mut Vec<SemanticChange>,
334 commit_sha: Option<&str>,
335 author: Option<&str>,
336) {
337 let before_by_id: HashMap<&str, &SemanticEntity> =
339 before.iter().map(|e| (e.id.as_str(), e)).collect();
340
341 let mut by_file: HashMap<&str, Vec<(&SemanticEntity, &SemanticEntity)>> = HashMap::new();
344 for after_entity in after {
345 if !matched_after.contains(after_entity.id.as_str()) {
346 continue;
347 }
348 if let Some(before_entity) = before_by_id.get(after_entity.id.as_str()) {
349 if !matched_before.contains(before_entity.id.as_str()) {
350 continue;
351 }
352 if before_entity.content_hash != after_entity.content_hash {
354 continue;
355 }
356 if before_entity.file_path != after_entity.file_path {
358 continue;
359 }
360 by_file
361 .entry(after_entity.file_path.as_str())
362 .or_default()
363 .push((before_entity, after_entity));
364 }
365 }
366
367 for (_file, pairs) in &mut by_file {
368 if pairs.len() < 2 {
369 continue;
370 }
371
372 pairs.sort_by_key(|(b, _)| b.start_line);
374
375 let after_lines: Vec<usize> = pairs.iter().map(|(_, a)| a.start_line).collect();
377
378 let lis_set = longest_increasing_subsequence_indices(&after_lines);
380
381 for (i, (_before_entity, after_entity)) in pairs.iter().enumerate() {
383 if lis_set.contains(&i) {
384 continue;
385 }
386 changes.push(make_change(after_entity, ChangeType::Reordered, None, commit_sha, author));
387 }
388 }
389}
390
391fn longest_increasing_subsequence_indices(seq: &[usize]) -> HashSet<usize> {
394 let n = seq.len();
395 if n == 0 {
396 return HashSet::new();
397 }
398
399 let mut tails: Vec<usize> = Vec::new();
401 let mut parent: Vec<Option<usize>> = vec![None; n];
403 let mut tail_idx: Vec<usize> = Vec::new();
405
406 for i in 0..n {
407 let pos = tails.partition_point(|&t| t < seq[i]);
408 if pos == tails.len() {
409 tails.push(seq[i]);
410 tail_idx.push(i);
411 } else {
412 tails[pos] = seq[i];
413 tail_idx[pos] = i;
414 }
415 parent[i] = if pos > 0 { Some(tail_idx[pos - 1]) } else { None };
416 }
417
418 let mut result = HashSet::new();
420 let mut idx = *tail_idx.last().unwrap();
421 result.insert(idx);
422 while let Some(p) = parent[idx] {
423 result.insert(p);
424 idx = p;
425 }
426 result
427}
428
429#[cfg(test)]
430mod tests {
431 use super::*;
432 use crate::utils::hash::content_hash;
433
434 fn make_entity(id: &str, name: &str, content: &str, file_path: &str) -> SemanticEntity {
435 SemanticEntity {
436 id: id.to_string(),
437 file_path: file_path.to_string(),
438 entity_type: "function".to_string(),
439 name: name.to_string(),
440 parent_id: None,
441 content: content.to_string(),
442 content_hash: content_hash(content),
443 structural_hash: None,
444 start_line: 1,
445 end_line: 1,
446 metadata: None,
447 }
448 }
449
450 #[test]
451 fn test_exact_match_modified() {
452 let before = vec![make_entity("a::f::foo", "foo", "old content", "a.ts")];
453 let after = vec![make_entity("a::f::foo", "foo", "new content", "a.ts")];
454 let result = match_entities(&before, &after, "a.ts", None, None, None);
455 assert_eq!(result.changes.len(), 1);
456 assert_eq!(result.changes[0].change_type, ChangeType::Modified);
457 }
458
459 #[test]
460 fn test_exact_match_unchanged() {
461 let before = vec![make_entity("a::f::foo", "foo", "same", "a.ts")];
462 let after = vec![make_entity("a::f::foo", "foo", "same", "a.ts")];
463 let result = match_entities(&before, &after, "a.ts", None, None, None);
464 assert_eq!(result.changes.len(), 0);
465 }
466
467 #[test]
468 fn test_added_deleted() {
469 let before = vec![make_entity("a::f::old", "old", "content", "a.ts")];
470 let after = vec![make_entity("a::f::new", "new", "different", "a.ts")];
471 let result = match_entities(&before, &after, "a.ts", None, None, None);
472 assert_eq!(result.changes.len(), 2);
473 let types: Vec<ChangeType> = result.changes.iter().map(|c| c.change_type).collect();
474 assert!(types.contains(&ChangeType::Deleted));
475 assert!(types.contains(&ChangeType::Added));
476 }
477
478 #[test]
479 fn test_content_hash_rename() {
480 let before = vec![make_entity("a::f::old", "old", "same content", "a.ts")];
481 let after = vec![make_entity("a::f::new", "new", "same content", "a.ts")];
482 let result = match_entities(&before, &after, "a.ts", None, None, None);
483 assert_eq!(result.changes.len(), 1);
484 assert_eq!(result.changes[0].change_type, ChangeType::Renamed);
485 }
486
487 #[test]
488 fn test_parent_child_dedup_class_method() {
489 let class_before = SemanticEntity {
492 id: "a.ts::class::DataStack".to_string(),
493 file_path: "a.ts".to_string(),
494 entity_type: "class".to_string(),
495 name: "DataStack".to_string(),
496 parent_id: None,
497 content: "class DataStack { constructor() {} genPg() { old } }".to_string(),
498 content_hash: content_hash("class DataStack { constructor() {} genPg() { old } }"),
499 structural_hash: None,
500 start_line: 1,
501 end_line: 10,
502 metadata: None,
503 };
504 let method_before = SemanticEntity {
505 id: "a.ts::a.ts::class::DataStack::genPg".to_string(),
506 file_path: "a.ts".to_string(),
507 entity_type: "method".to_string(),
508 name: "genPg".to_string(),
509 parent_id: Some("a.ts::class::DataStack".to_string()),
510 content: "genPg() { old }".to_string(),
511 content_hash: content_hash("genPg() { old }"),
512 structural_hash: None,
513 start_line: 5,
514 end_line: 8,
515 metadata: None,
516 };
517
518 let class_after = SemanticEntity {
519 id: "a.ts::class::DataStack".to_string(),
520 file_path: "a.ts".to_string(),
521 entity_type: "class".to_string(),
522 name: "DataStack".to_string(),
523 parent_id: None,
524 content: "class DataStack { constructor() {} genPg() { new } }".to_string(),
525 content_hash: content_hash("class DataStack { constructor() {} genPg() { new } }"),
526 structural_hash: None,
527 start_line: 1,
528 end_line: 10,
529 metadata: None,
530 };
531 let method_after = SemanticEntity {
532 id: "a.ts::a.ts::class::DataStack::genPg".to_string(),
533 file_path: "a.ts".to_string(),
534 entity_type: "method".to_string(),
535 name: "genPg".to_string(),
536 parent_id: Some("a.ts::class::DataStack".to_string()),
537 content: "genPg() { new }".to_string(),
538 content_hash: content_hash("genPg() { new }"),
539 structural_hash: None,
540 start_line: 5,
541 end_line: 8,
542 metadata: None,
543 };
544
545 let before = vec![class_before, method_before];
546 let after = vec![class_after, method_after];
547 let result = match_entities(&before, &after, "a.ts", None, None, None);
548
549 assert_eq!(result.changes.len(), 2);
552 let types: Vec<ChangeType> = result.changes.iter().map(|c| c.change_type).collect();
553 assert!(types.iter().all(|t| *t == ChangeType::Modified));
554 }
555
556 #[test]
557 fn test_parent_not_deduped_when_no_child_changes() {
558 let class_before = SemanticEntity {
560 id: "a.ts::class::Foo".to_string(),
561 file_path: "a.ts".to_string(),
562 entity_type: "class".to_string(),
563 name: "Foo".to_string(),
564 parent_id: None,
565 content: "class Foo { bar() {} }".to_string(),
566 content_hash: content_hash("class Foo { bar() {} }"),
567 structural_hash: None,
568 start_line: 1,
569 end_line: 5,
570 metadata: None,
571 };
572 let method_before = SemanticEntity {
573 id: "a.ts::a.ts::class::Foo::bar".to_string(),
574 file_path: "a.ts".to_string(),
575 entity_type: "method".to_string(),
576 name: "bar".to_string(),
577 parent_id: Some("a.ts::class::Foo".to_string()),
578 content: "bar() {}".to_string(),
579 content_hash: content_hash("bar() {}"),
580 structural_hash: None,
581 start_line: 2,
582 end_line: 4,
583 metadata: None,
584 };
585
586 let class_after = SemanticEntity {
587 id: "a.ts::class::Foo".to_string(),
588 file_path: "a.ts".to_string(),
589 entity_type: "class".to_string(),
590 name: "Foo".to_string(),
591 parent_id: None,
592 content: "class Foo { x = 1; bar() {} }".to_string(),
593 content_hash: content_hash("class Foo { x = 1; bar() {} }"),
594 structural_hash: None,
595 start_line: 1,
596 end_line: 6,
597 metadata: None,
598 };
599 let method_after = SemanticEntity {
600 id: "a.ts::a.ts::class::Foo::bar".to_string(),
601 file_path: "a.ts".to_string(),
602 entity_type: "method".to_string(),
603 name: "bar".to_string(),
604 parent_id: Some("a.ts::class::Foo".to_string()),
605 content: "bar() {}".to_string(),
606 content_hash: content_hash("bar() {}"),
607 structural_hash: None,
608 start_line: 3,
609 end_line: 5,
610 metadata: None,
611 };
612
613 let before = vec![class_before, method_before];
614 let after = vec![class_after, method_after];
615 let result = match_entities(&before, &after, "a.ts", None, None, None);
616
617 assert_eq!(result.changes.len(), 1);
619 assert_eq!(result.changes[0].entity_name, "Foo");
620 assert_eq!(result.changes[0].change_type, ChangeType::Modified);
621 }
622
623 fn make_entity_with_parent(id: &str, name: &str, content: &str, file_path: &str, parent_id: Option<&str>) -> SemanticEntity {
624 SemanticEntity {
625 id: id.to_string(),
626 file_path: file_path.to_string(),
627 entity_type: "method".to_string(),
628 name: name.to_string(),
629 parent_id: parent_id.map(String::from),
630 content: content.to_string(),
631 content_hash: content_hash(content),
632 structural_hash: None,
633 start_line: 1,
634 end_line: 1,
635 metadata: None,
636 }
637 }
638
639 #[test]
640 fn test_intra_file_move_between_classes() {
641 let before = vec![make_entity_with_parent(
643 "a.rs::class::ClassA::foo", "foo", "fn foo() { do_thing() }",
644 "a.rs", Some("a.rs::class::ClassA"),
645 )];
646 let after = vec![make_entity_with_parent(
647 "a.rs::class::ClassB::foo", "foo", "fn foo() { do_thing() }",
648 "a.rs", Some("a.rs::class::ClassB"),
649 )];
650 let result = match_entities(&before, &after, "a.rs", None, None, None);
651 assert_eq!(result.changes.len(), 1);
652 assert_eq!(result.changes[0].change_type, ChangeType::Moved);
653 assert_eq!(result.changes[0].old_parent_id, Some("a.rs::class::ClassA".to_string()));
654 }
655
656 #[test]
657 fn test_same_parent_is_rename_not_move() {
658 let body = "fn method(&self) { let x = self.compute(); self.validate(x); self.store(x) }";
661 let before = vec![make_entity_with_parent(
662 "a.rs::class::Foo::old_method", "old_method", body,
663 "a.rs", Some("a.rs::class::Foo"),
664 )];
665 let after = vec![make_entity_with_parent(
666 "a.rs::class::Foo::new_method", "new_method", body,
667 "a.rs", Some("a.rs::class::Foo"),
668 )];
669 let result = match_entities(&before, &after, "a.rs", None, None, None);
670 assert_eq!(result.changes.len(), 1);
671 assert_eq!(result.changes[0].change_type, ChangeType::Renamed);
672 assert!(result.changes[0].old_parent_id.is_none());
673 }
674
675 fn make_entity_at(id: &str, name: &str, content: &str, file_path: &str, line: usize) -> SemanticEntity {
676 SemanticEntity {
677 id: id.to_string(),
678 file_path: file_path.to_string(),
679 entity_type: "function".to_string(),
680 name: name.to_string(),
681 parent_id: None,
682 content: content.to_string(),
683 content_hash: content_hash(content),
684 structural_hash: None,
685 start_line: line,
686 end_line: line + 2,
687 metadata: None,
688 }
689 }
690
691 #[test]
692 fn test_reorder_detection() {
693 let before = vec![
694 make_entity_at("a::f::alpha", "alpha", "fn alpha() {}", "a.rs", 1),
695 make_entity_at("a::f::beta", "beta", "fn beta() {}", "a.rs", 5),
696 make_entity_at("a::f::gamma", "gamma", "fn gamma() {}", "a.rs", 9),
697 ];
698 let after = vec![
699 make_entity_at("a::f::alpha", "alpha", "fn alpha() {}", "a.rs", 1),
700 make_entity_at("a::f::gamma", "gamma", "fn gamma() {}", "a.rs", 5),
701 make_entity_at("a::f::beta", "beta", "fn beta() {}", "a.rs", 9),
702 ];
703 let result = match_entities(&before, &after, "a.rs", None, None, None);
704 assert_eq!(result.changes.len(), 1);
705 assert_eq!(result.changes[0].change_type, ChangeType::Reordered);
706 assert!(result.changes[0].entity_name == "beta" || result.changes[0].entity_name == "gamma");
708 }
709
710 #[test]
711 fn test_no_reorder_when_order_preserved() {
712 let before = vec![
713 make_entity_at("a::f::alpha", "alpha", "fn alpha() {}", "a.rs", 1),
714 make_entity_at("a::f::beta", "beta", "fn beta() {}", "a.rs", 5),
715 ];
716 let after = vec![
717 make_entity_at("a::f::alpha", "alpha", "fn alpha() {}", "a.rs", 1),
718 make_entity_at("a::f::beta", "beta", "fn beta() {}", "a.rs", 10),
719 ];
720 let result = match_entities(&before, &after, "a.rs", None, None, None);
721 assert_eq!(result.changes.len(), 0);
723 }
724
725 #[test]
726 fn test_default_similarity() {
727 let a = make_entity("a", "a", "the quick brown fox", "a.ts");
728 let b = make_entity("b", "b", "the quick brown dog", "a.ts");
729 let score = default_similarity(&a, &b);
730 assert!(score > 0.5);
731 assert!(score < 1.0);
732 }
733}