Skip to main content

provenant/license_detection/
unknown_match.rs

1// SPDX-FileCopyrightText: nexB Inc. and others
2// ScanCode is a trademark of nexB Inc.
3// SPDX-FileCopyrightText: Provenant contributors
4// SPDX-License-Identifier: Apache-2.0
5// Derived from ScanCode Toolkit (Apache-2.0); modified. See NOTICE.
6
7//! Unknown license detection using ngram matching.
8
9use crate::license_detection::automaton::Automaton;
10use regex::Regex;
11use sha1::{Digest, Sha1};
12use std::sync::LazyLock;
13
14use crate::license_detection::index::LicenseIndex;
15use crate::license_detection::index::dictionary::{TokenId, TokenKind};
16use crate::license_detection::models::position_span::PositionSpan;
17use crate::license_detection::models::{LicenseMatch, MatchCoordinates, MatcherKind, RuleId};
18use crate::license_detection::position_set::PositionSet;
19use crate::license_detection::query::Query;
20use crate::license_detection::tokenize::STOPWORDS;
21use crate::models::LineNumber;
22use crate::models::MatchScore;
23
24pub const MATCH_UNKNOWN: MatcherKind = MatcherKind::Unknown;
25
26// ScanCode parity: 6-token ngram window, from upstream `UNKNOWN_NGRAM_LENGTH`
27// (match_unknown.py).
28const UNKNOWN_NGRAM_LENGTH: usize = 6;
29
30// Provenant-specific tuning: ScanCode runs unknown matching per query_run and has no
31// explicit ngram-count or region-length pre-gate. Provenant scans self-discovered
32// unmatched regions, so these two pre-gates bound that scan before attempting a match.
33const MIN_NGRAM_MATCHES: usize = 3;
34
35const MIN_REGION_LENGTH: usize = 5;
36
37static QUERY_PATTERN: LazyLock<Regex> =
38    LazyLock::new(|| Regex::new(r"[^_\W]+\+?[^_\W]*").expect("Invalid regex pattern"));
39static MATCHED_TEXT_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
40    Regex::new(r"(?P<token>[^_\W]+\+?[^_\W]*)|(?P<punct>[_\W\s\+]+[_\W\s]?)")
41        .expect("Invalid matched text regex pattern")
42});
43
44#[derive(Clone)]
45struct MatchedTextToken {
46    value: String,
47    line_num: usize,
48    pos: Option<usize>,
49    is_text: bool,
50    is_known: bool,
51    is_matched: bool,
52}
53
54pub fn unknown_match(
55    index: &LicenseIndex,
56    query: &Query,
57    known_matches: &[LicenseMatch],
58) -> Vec<LicenseMatch> {
59    let mut unknown_matches = Vec::new();
60
61    if query.tokens.is_empty() {
62        return unknown_matches;
63    }
64
65    let query_len = query.tokens.len();
66
67    let covered_positions = compute_covered_positions(query, known_matches);
68
69    let unmatched_regions = find_unmatched_regions(query_len, &covered_positions);
70
71    let automaton = &index.unknown_automaton;
72
73    for region in unmatched_regions {
74        let start = region.0;
75        let end = region.1;
76
77        let region_length = end - start;
78        if region_length < MIN_REGION_LENGTH {
79            continue;
80        }
81
82        let matched_ngrams = get_matched_ngrams(&query.tokens, start, end, automaton);
83
84        if matched_ngrams.len() < MIN_NGRAM_MATCHES {
85            continue;
86        }
87
88        let qspan = compute_qspan_union(&matched_ngrams);
89
90        if qspan.is_empty() {
91            continue;
92        }
93
94        let qspan_length = qspan.len();
95
96        // DEBUG
97        #[cfg(debug_assertions)]
98        {
99            eprintln!("\n=== UNKNOWN MATCH DEBUG ===");
100            eprintln!("Region: {}-{} ({} tokens)", start, end, region_length);
101            eprintln!("matched_ngrams: {} matches", matched_ngrams.len());
102            eprintln!("qspan: {:?}", qspan);
103            eprintln!(
104                "qspan_length: {} (threshold: {})",
105                qspan_length,
106                UNKNOWN_NGRAM_LENGTH * 4
107            );
108        }
109
110        // ScanCode parity: minimum qspan and hispan gates from upstream
111        // `match_unknowns()` (match_unknown.py: `len(qspan) < unknown_ngram_length * 4 or
112        // len(hispan) < 5`).
113        if qspan_length < UNKNOWN_NGRAM_LENGTH * 4 {
114            continue;
115        }
116
117        let hispan = compute_hispan_from_qspan(&query.tokens, &qspan, index);
118
119        #[cfg(debug_assertions)]
120        {
121            eprintln!("hispan: {} (threshold: 5)", hispan);
122        }
123
124        if hispan < 5 {
125            continue;
126        }
127
128        if let Some(match_result) = create_unknown_match_from_qspan(query, &qspan) {
129            unknown_matches.push(match_result);
130        }
131    }
132
133    unknown_matches
134}
135
136fn compute_covered_positions(_query: &Query, known_matches: &[LicenseMatch]) -> PositionSet {
137    let mut covered = PositionSet::new();
138    for m in known_matches {
139        covered.extend_from_span(m.query_span());
140    }
141    covered
142}
143
144fn find_unmatched_regions(
145    query_len: usize,
146    covered_positions: &PositionSet,
147) -> Vec<(usize, usize)> {
148    let mut regions = Vec::new();
149
150    if query_len == 0 {
151        return regions;
152    }
153
154    let mut region_start = None;
155
156    for pos in 0..query_len {
157        if !covered_positions.contains(pos) {
158            if region_start.is_none() {
159                region_start = Some(pos);
160            }
161        } else if let Some(start) = region_start {
162            regions.push((start, pos));
163            region_start = None;
164        }
165    }
166
167    if let Some(start) = region_start {
168        regions.push((start, query_len));
169    }
170
171    regions
172}
173
174fn get_matched_ngrams(
175    tokens: &[TokenId],
176    start: usize,
177    end: usize,
178    automaton: &Automaton,
179) -> Vec<(usize, usize)> {
180    if start >= end || end > tokens.len() {
181        return Vec::new();
182    }
183
184    let region_tokens = &tokens[start..end];
185
186    let region_bytes: Vec<u8> = region_tokens
187        .iter()
188        .flat_map(|tid| tid.to_le_bytes())
189        .collect();
190
191    let offset = UNKNOWN_NGRAM_LENGTH;
192    let mut matches = Vec::new();
193
194    for m in automaton.find_overlapping_iter(&region_bytes) {
195        let local_qend = m.end / 2;
196        let qend = start + local_qend;
197        let qstart = qend.saturating_sub(offset);
198        matches.push((qstart, qend));
199    }
200
201    matches
202}
203
204fn compute_qspan_union(positions: &[(usize, usize)]) -> PositionSet {
205    if positions.is_empty() {
206        return PositionSet::new();
207    }
208
209    let mut sorted: Vec<_> = positions.to_vec();
210    sorted.sort_by_key(|p| p.0);
211
212    let mut merged: Vec<(usize, usize)> = Vec::new();
213    let mut current = sorted[0];
214
215    for (start, end) in sorted.into_iter().skip(1) {
216        if start <= current.1 {
217            current.1 = current.1.max(end);
218        } else {
219            merged.push(current);
220            current = (start, end);
221        }
222    }
223    merged.push(current);
224
225    let mut result = PositionSet::new();
226    for (start, end) in merged {
227        result.extend_from_span(&PositionSpan::range(start, end));
228    }
229    result
230}
231
232fn compute_hispan_from_qspan(
233    tokens: &[TokenId],
234    qspan: &PositionSet,
235    index: &LicenseIndex,
236) -> usize {
237    qspan
238        .iter()
239        .filter(|&pos| {
240            tokens
241                .get(pos)
242                .is_some_and(|&tid| index.dictionary.token_kind(tid) == TokenKind::Legalese)
243        })
244        .count()
245}
246
247fn create_unknown_match_from_qspan(query: &Query, qspan: &PositionSet) -> Option<LicenseMatch> {
248    if qspan.is_empty() {
249        return None;
250    }
251
252    let match_len = qspan.len();
253
254    let start = qspan.min_pos();
255    let end = qspan.max_pos() + 1;
256
257    let start_line = query
258        .line_by_pos
259        .get(start)
260        .copied()
261        .and_then(LineNumber::new)
262        .unwrap_or(LineNumber::ONE);
263    let end_line = query
264        .line_by_pos
265        .get(end.saturating_sub(1))
266        .copied()
267        .and_then(LineNumber::new)
268        .unwrap_or(start_line);
269
270    let qspan_positions: Vec<usize> = qspan.iter().collect();
271    let synthetic_rule_text =
272        build_unknown_rule_text(query, &qspan_positions, start_line, end_line);
273    let rule_identifier = build_unknown_rule_identifier(&synthetic_rule_text);
274
275    let ngram_count = qspan.len();
276
277    let score = calculate_score(ngram_count, match_len);
278
279    let qspan_span = qspan.to_position_span();
280
281    LicenseMatch {
282        rid: RuleId::NONE,
283        license_expression: "unknown".to_string(),
284        license_expression_spdx: None,
285        from_file: None,
286        start_line,
287        end_line,
288        start_token: start,
289        end_token: end,
290        matcher: MATCH_UNKNOWN,
291        score,
292        matched_length: match_len,
293        rule_length: match_len,
294        match_coverage: 100.0,
295        rule_relevance: 50,
296        rule_identifier,
297        rule_url: String::new(),
298        matched_text: None,
299        referenced_filenames: None,
300        rule_kind: crate::license_detection::models::RuleKind::None,
301        is_from_license: false,
302        rule_start_token: 0,
303        coordinates: MatchCoordinates::query_region(qspan_span),
304    }
305    .into()
306}
307
308fn build_unknown_rule_text(
309    query: &Query,
310    qspan_positions: &[usize],
311    start_line: LineNumber,
312    end_line: LineNumber,
313) -> String {
314    let Some(&start_pos) = qspan_positions.first() else {
315        return String::new();
316    };
317    let Some(&end_pos) = qspan_positions.last() else {
318        return String::new();
319    };
320
321    let matched_positions: PositionSet = qspan_positions.iter().copied().collect();
322    let tokens = tokenize_matched_unknown_text(&query.text, query);
323    let reportable_tokens = collect_reportable_unknown_tokens(
324        tokens,
325        &matched_positions,
326        start_pos,
327        end_pos,
328        start_line.get(),
329        end_line.get(),
330    );
331    let line_endings = collect_line_endings(&query.text);
332
333    render_unknown_rule_tokens(&reportable_tokens, &line_endings)
334}
335
336fn tokenize_matched_unknown_text(text: &str, query: &Query) -> Vec<MatchedTextToken> {
337    let mut tokens = Vec::new();
338    let mut pos = 0usize;
339    for (line_num, line) in (1usize..).zip(text.split_inclusive('\n')) {
340        for capture in MATCHED_TEXT_PATTERN.captures_iter(line) {
341            if let Some(token_match) = capture.name("token") {
342                let token_text = token_match.as_str();
343                let retokenized: Vec<String> = QUERY_PATTERN
344                    .find_iter(&token_text.to_lowercase())
345                    .map(|m| m.as_str().to_string())
346                    .filter(|token| !STOPWORDS.contains(token.as_str()))
347                    .collect();
348
349                if retokenized.is_empty() {
350                    tokens.push(MatchedTextToken {
351                        value: token_text.to_string(),
352                        line_num,
353                        pos: None,
354                        is_text: true,
355                        is_known: false,
356                        is_matched: false,
357                    });
358                } else if retokenized.len() == 1 {
359                    let token = &retokenized[0];
360                    let is_known = query.index.dictionary.get(token).is_some();
361                    let token_pos = if is_known {
362                        let current_pos = pos;
363                        pos += 1;
364                        Some(current_pos)
365                    } else {
366                        None
367                    };
368
369                    tokens.push(MatchedTextToken {
370                        value: token_text.to_string(),
371                        line_num,
372                        pos: token_pos,
373                        is_text: true,
374                        is_known,
375                        is_matched: false,
376                    });
377                } else {
378                    for token in retokenized {
379                        let is_known = query.index.dictionary.get(&token).is_some();
380                        let token_pos = if is_known {
381                            let current_pos = pos;
382                            pos += 1;
383                            Some(current_pos)
384                        } else {
385                            None
386                        };
387
388                        tokens.push(MatchedTextToken {
389                            value: token,
390                            line_num,
391                            pos: token_pos,
392                            is_text: true,
393                            is_known,
394                            is_matched: false,
395                        });
396                    }
397                }
398            } else if let Some(punct_match) = capture.name("punct") {
399                tokens.push(MatchedTextToken {
400                    value: punct_match.as_str().to_string(),
401                    line_num,
402                    pos: None,
403                    is_text: false,
404                    is_known: false,
405                    is_matched: false,
406                });
407            }
408        }
409    }
410
411    tokens
412}
413
414fn collect_reportable_unknown_tokens(
415    tokens: Vec<MatchedTextToken>,
416    matched_positions: &PositionSet,
417    start_pos: usize,
418    end_pos: usize,
419    start_line: usize,
420    end_line: usize,
421) -> Vec<MatchedTextToken> {
422    let mut reportable = Vec::new();
423    let mut started = false;
424    let mut finished = false;
425    let mut end_real_pos = None;
426    let mut last_real_pos = None;
427
428    for (real_pos, mut token) in tokens.into_iter().enumerate() {
429        if token.line_num < start_line {
430            continue;
431        }
432
433        if token.line_num > end_line {
434            break;
435        }
436
437        let mut is_included = false;
438
439        if token
440            .pos
441            .is_some_and(|pos| token.is_known && matched_positions.contains(pos))
442        {
443            token.is_matched = true;
444            is_included = true;
445        }
446
447        if !started && token.pos == Some(start_pos) {
448            started = true;
449            is_included = true;
450        }
451
452        if started && !finished {
453            is_included = true;
454        }
455
456        if token.pos == Some(end_pos) {
457            finished = true;
458            started = false;
459            end_real_pos = Some(real_pos);
460        }
461
462        if finished && !started && end_real_pos.is_some() && last_real_pos == end_real_pos {
463            end_real_pos = None;
464            if !token.is_text && !token.value.trim().is_empty() {
465                is_included = true;
466            }
467        }
468
469        last_real_pos = Some(real_pos);
470
471        if is_included {
472            reportable.push(token);
473        }
474    }
475
476    reportable
477}
478
479fn collect_line_endings(text: &str) -> Vec<String> {
480    text.split_inclusive('\n')
481        .map(|line| {
482            if line.ends_with("\r\n") {
483                "\r\n".to_string()
484            } else if line.ends_with('\n') {
485                "\n".to_string()
486            } else {
487                String::new()
488            }
489        })
490        .collect()
491}
492
493fn render_unknown_rule_tokens(tokens: &[MatchedTextToken], line_endings: &[String]) -> String {
494    let mut rendered = String::new();
495    let mut previous_line: Option<usize> = None;
496
497    for token in tokens {
498        if let Some(prev_line) = previous_line
499            && token.line_num > prev_line
500        {
501            for line in prev_line..token.line_num {
502                if let Some(line_ending) = line_endings.get(line.saturating_sub(1)) {
503                    rendered.push_str(line_ending.as_str());
504                }
505            }
506        }
507
508        let token_value = if token.is_text {
509            token.value.as_str()
510        } else {
511            token
512                .value
513                .strip_suffix("\r\n")
514                .or_else(|| token.value.strip_suffix('\n'))
515                .unwrap_or(token.value.as_str())
516        };
517
518        if token.is_text && !STOPWORDS.contains(token.value.to_lowercase().as_str()) {
519            if token.is_matched {
520                rendered.push_str(token_value);
521            } else {
522                rendered.push('.');
523            }
524        } else {
525            rendered.push_str(token_value);
526        }
527
528        previous_line = Some(token.line_num);
529    }
530
531    rendered
532}
533
534fn build_unknown_rule_identifier(rule_text: &str) -> String {
535    let content = format!("None{}", python_str_repr(rule_text));
536    let mut hasher = Sha1::new();
537    hasher.update(content.as_bytes());
538    let digest = hasher.finalize();
539
540    format!("license-detection-unknown-{}", hex::encode(digest))
541}
542
543fn python_str_repr(text: &str) -> String {
544    let use_double_quotes = text.contains('\'') && !text.contains('"');
545    let quote = if use_double_quotes { '"' } else { '\'' };
546    let mut escaped = String::with_capacity(text.len());
547
548    for ch in text.chars() {
549        match ch {
550            '\\' => escaped.push_str("\\\\"),
551            '\n' => escaped.push_str("\\n"),
552            '\r' => escaped.push_str("\\r"),
553            '\t' => escaped.push_str("\\t"),
554            '\'' if !use_double_quotes => escaped.push_str("\\'"),
555            '"' if use_double_quotes => escaped.push_str("\\\""),
556            _ => escaped.push(ch),
557        }
558    }
559
560    format!("{quote}{escaped}{quote}")
561}
562
563fn calculate_score(ngram_count: usize, match_len: usize) -> MatchScore {
564    if match_len == 0 {
565        return MatchScore::default();
566    }
567
568    let density = ngram_count as f64 / match_len as f64;
569
570    MatchScore::from_percentage(density.min(1.0) * 100.0)
571}
572
573#[cfg(test)]
574mod tests {
575    use super::*;
576    use crate::license_detection::index::LicenseIndex;
577    use crate::license_detection::index::dictionary::{TokenId, tid};
578    use crate::license_detection::query::Query;
579
580    fn tids(values: &[u16]) -> Vec<TokenId> {
581        values.iter().copied().map(tid).collect()
582    }
583
584    #[test]
585    fn test_unknown_match_empty_query() {
586        let index = LicenseIndex::with_legalese_count(10);
587        let query = Query::from_extracted_text("", &index, false).expect("Failed to create query");
588        let known_matches = vec![];
589
590        let matches = unknown_match(&index, &query, &known_matches);
591
592        assert!(matches.is_empty());
593    }
594
595    #[test]
596    fn test_find_unmatched_regions_no_coverage() {
597        let query_len = 10;
598        let covered_positions = PositionSet::new();
599
600        let regions = find_unmatched_regions(query_len, &covered_positions);
601
602        assert_eq!(regions, vec![(0, 10)]);
603    }
604
605    #[test]
606    fn test_find_unmatched_regions_full_coverage() {
607        let query_len = 10;
608        let covered_positions: PositionSet = (0..10).collect();
609
610        let regions = find_unmatched_regions(query_len, &covered_positions);
611
612        assert!(regions.is_empty());
613    }
614
615    #[test]
616    fn test_find_unmatched_regions_partial_coverage() {
617        let query_len = 20;
618        let covered_positions: PositionSet = [0, 1, 2, 12, 13, 14, 15, 16, 17, 18, 19]
619            .iter()
620            .copied()
621            .collect();
622
623        let regions = find_unmatched_regions(query_len, &covered_positions);
624
625        assert_eq!(regions.len(), 1);
626        assert_eq!(regions[0], (3, 12));
627    }
628
629    #[test]
630    fn test_find_unmatched_regions_trailing_unmatched() {
631        let query_len = 20;
632        let covered_positions: PositionSet = [0, 1, 2, 3, 4, 5].iter().copied().collect();
633
634        let regions = find_unmatched_regions(query_len, &covered_positions);
635
636        assert_eq!(regions.len(), 1);
637        assert_eq!(regions[0], (6, 20));
638    }
639
640    #[test]
641    fn test_compute_qspan_union_empty() {
642        let positions: Vec<(usize, usize)> = Vec::new();
643        let merged = compute_qspan_union(&positions);
644        assert!(merged.is_empty());
645    }
646
647    #[test]
648    fn test_compute_qspan_union_single() {
649        let positions = vec![(5, 11)];
650        let merged = compute_qspan_union(&positions);
651        assert_eq!(merged.len(), 6);
652        assert!(merged.contains(5));
653        assert!(merged.contains(10));
654        assert!(!merged.contains(4));
655        assert!(!merged.contains(11));
656    }
657
658    #[test]
659    fn test_compute_qspan_union_overlapping() {
660        let positions = vec![(5, 11), (8, 14), (20, 26)];
661        let merged = compute_qspan_union(&positions);
662        assert_eq!(merged.len(), 15);
663        assert!(merged.contains(5));
664        assert!(merged.contains(13));
665        assert!(!merged.contains(14));
666        assert!(merged.contains(20));
667        assert!(merged.contains(25));
668        assert!(!merged.contains(26));
669    }
670
671    #[test]
672    fn test_compute_qspan_union_adjacent() {
673        let positions = vec![(5, 11), (11, 17)];
674        let merged = compute_qspan_union(&positions);
675        assert_eq!(merged.len(), 12);
676        assert!(merged.contains(5));
677        assert!(merged.contains(16));
678        assert!(!merged.contains(4));
679        assert!(!merged.contains(17));
680    }
681
682    #[test]
683    fn test_compute_qspan_union_unsorted() {
684        let positions = vec![(20, 26), (5, 11), (8, 14)];
685        let merged = compute_qspan_union(&positions);
686        assert_eq!(merged.len(), 15);
687        assert!(merged.contains(5));
688        assert!(merged.contains(13));
689        assert!(merged.contains(20));
690        assert!(merged.contains(25));
691    }
692
693    #[test]
694    fn test_compute_hispan_from_qspan() {
695        let mut index = LicenseIndex::with_legalese_count(0);
696        let legalese_entries: Vec<(String, u16)> =
697            (0u16..15).map(|i| (format!("legalese-{i}"), i)).collect();
698        index.dictionary =
699            crate::license_detection::index::dictionary::TokenDictionary::new_with_legalese_pairs(
700                &legalese_entries
701                    .iter()
702                    .map(|(token, id)| (token.as_str(), *id))
703                    .collect::<Vec<_>>(),
704            );
705
706        let mut tokens: Vec<TokenId> = (0..15)
707            .map(|i| {
708                index
709                    .dictionary
710                    .get_token_id(&format!("legalese-{i}"))
711                    .unwrap()
712            })
713            .collect();
714        for i in 15..30 {
715            tokens.push(index.dictionary.get_or_assign(&format!("regular-{i}")));
716        }
717        let qspan: PositionSet = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 20, 21, 22, 23, 24]
718            .iter()
719            .copied()
720            .collect();
721        let hispan = compute_hispan_from_qspan(&tokens, &qspan, &index);
722        assert_eq!(hispan, 10);
723    }
724
725    #[test]
726    fn test_get_matched_ngrams_empty_automaton() {
727        use crate::license_detection::automaton::AutomatonBuilder;
728
729        let tokens = tids(&[1, 2, 3, 4, 5, 6, 7, 8]);
730        let automaton = AutomatonBuilder::new().build();
731
732        let matches = get_matched_ngrams(&tokens, 0, 8, &automaton);
733
734        assert!(matches.is_empty());
735    }
736
737    #[test]
738    fn test_get_matched_ngrams_with_matches() {
739        use crate::license_detection::automaton::AutomatonBuilder;
740
741        let tokens: Vec<TokenId> = (0..30).map(tid).collect();
742        let ngram: Vec<u8> = vec![0, 0, 1, 0, 2, 0, 3, 0, 4, 0, 5, 0];
743
744        let mut builder = AutomatonBuilder::new();
745        builder.add_pattern(&ngram);
746        let automaton = builder.build();
747
748        let matches = get_matched_ngrams(&tokens, 0, 30, &automaton);
749
750        assert!(!matches.is_empty(), "Should find ngram matches");
751
752        for (qstart, qend) in &matches {
753            assert_eq!(*qend - *qstart, UNKNOWN_NGRAM_LENGTH);
754        }
755    }
756
757    #[test]
758    fn test_get_matched_ngrams_keeps_overlapping_matches() {
759        use crate::license_detection::automaton::AutomatonBuilder;
760
761        let tokens = tids(&[1, 2, 3, 1, 2, 3, 1, 2, 3]);
762        let overlapping_ngram: Vec<u8> = tokens[..UNKNOWN_NGRAM_LENGTH]
763            .iter()
764            .flat_map(|tid| tid.to_le_bytes())
765            .collect();
766
767        let mut builder = AutomatonBuilder::new();
768        builder.add_pattern(&overlapping_ngram);
769        let automaton = builder.build();
770
771        let matches = get_matched_ngrams(&tokens, 0, tokens.len(), &automaton);
772
773        assert_eq!(matches, vec![(0, 6), (3, 9)]);
774    }
775
776    #[test]
777    fn test_calculate_score() {
778        let score1 = calculate_score(5, 10);
779        let score2 = calculate_score(10, 10);
780        let score3 = calculate_score(0, 10);
781
782        assert!(score2 > score1);
783        assert!(score2 <= MatchScore::MAX);
784        assert_eq!(score3, MatchScore::default());
785    }
786
787    #[test]
788    fn test_find_unmatched_regions_leading_unmatched() {
789        let query_len = 20;
790        let covered_positions: PositionSet = [10, 11, 12, 13, 14, 15, 16, 17, 18, 19]
791            .iter()
792            .copied()
793            .collect();
794
795        let regions = find_unmatched_regions(query_len, &covered_positions);
796
797        assert_eq!(regions.len(), 1);
798        assert_eq!(regions[0], (0, 10));
799    }
800
801    #[test]
802    fn test_find_unmatched_regions_middle_gap() {
803        let query_len = 30;
804        let covered_positions: PositionSet =
805            [0, 1, 2, 3, 4, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29]
806                .iter()
807                .copied()
808                .collect();
809
810        let regions = find_unmatched_regions(query_len, &covered_positions);
811
812        assert_eq!(regions.len(), 1);
813        assert_eq!(regions[0], (5, 20));
814    }
815
816    #[test]
817    fn test_compute_covered_positions_gapped_qspan() {
818        let index = LicenseIndex::with_legalese_count(10);
819        let query = Query::from_extracted_text("some license text here", &index, false)
820            .expect("Failed to create query");
821
822        let known_matches = vec![LicenseMatch {
823            rid: RuleId::NONE,
824            license_expression: "test".to_string(),
825            license_expression_spdx: Some("TEST".to_string()),
826            from_file: None,
827            start_line: LineNumber::ONE,
828            end_line: LineNumber::ONE,
829            start_token: 0,
830            end_token: 10,
831            matcher: MatcherKind::Aho,
832            score: MatchScore::MAX,
833            matched_length: 6,
834            rule_length: 6,
835            match_coverage: 100.0,
836            rule_relevance: 100,
837            rule_identifier: "test-rule".to_string(),
838            rule_url: String::new(),
839            matched_text: Some("matched text".to_string()),
840            referenced_filenames: None,
841            rule_kind: crate::license_detection::models::RuleKind::None,
842            is_from_license: false,
843            rule_start_token: 0,
844            coordinates: MatchCoordinates::query_region(PositionSpan::from_positions(vec![
845                0, 1, 2, 7, 8, 9,
846            ])),
847        }];
848
849        let covered = compute_covered_positions(&query, &known_matches);
850
851        assert!(covered.contains(0), "Should contain position 0");
852        assert!(covered.contains(2), "Should contain position 2");
853        assert!(covered.contains(7), "Should contain position 7");
854        assert!(covered.contains(9), "Should contain position 9");
855        assert!(!covered.contains(3), "Should NOT contain position 3 (gap)");
856        assert!(!covered.contains(5), "Should NOT contain position 5 (gap)");
857        assert!(
858            !covered.contains(10),
859            "Should NOT contain position 10 (outside)"
860        );
861    }
862
863    #[test]
864    fn test_compute_covered_positions_fallback_contiguous() {
865        let index = LicenseIndex::with_legalese_count(10);
866        let query = Query::from_extracted_text("some license text here", &index, false)
867            .expect("Failed to create query");
868
869        let known_matches = vec![LicenseMatch {
870            rid: RuleId::NONE,
871            license_expression: "test".to_string(),
872            license_expression_spdx: Some("TEST".to_string()),
873            from_file: None,
874            start_line: LineNumber::ONE,
875            end_line: LineNumber::ONE,
876            start_token: 5,
877            end_token: 10,
878            matcher: MatcherKind::Aho,
879            score: MatchScore::MAX,
880            matched_length: 5,
881            rule_length: 5,
882            match_coverage: 100.0,
883            rule_relevance: 100,
884            rule_identifier: "test-rule".to_string(),
885            rule_url: String::new(),
886            matched_text: Some("matched text".to_string()),
887            referenced_filenames: None,
888            rule_kind: crate::license_detection::models::RuleKind::None,
889            is_from_license: false,
890            rule_start_token: 0,
891            coordinates: MatchCoordinates::query_region(PositionSpan::range(5, 10)),
892        }];
893
894        let covered = compute_covered_positions(&query, &known_matches);
895
896        assert!(covered.contains(5), "Should contain position 5");
897        assert!(covered.contains(7), "Should contain position 7");
898        assert!(covered.contains(9), "Should contain position 9");
899        assert!(
900            !covered.contains(4),
901            "Should NOT contain position 4 (before)"
902        );
903        assert!(
904            !covered.contains(10),
905            "Should NOT contain position 10 (after)"
906        );
907    }
908
909    #[test]
910    fn test_compute_covered_positions_qspan_creates_extra_unmatched_region() {
911        let index = LicenseIndex::with_legalese_count(10);
912        let query = Query::from_extracted_text("some license text here", &index, false)
913            .expect("Failed to create query");
914
915        let known_matches = vec![LicenseMatch {
916            rid: RuleId::NONE,
917            license_expression: "test".to_string(),
918            license_expression_spdx: Some("TEST".to_string()),
919            from_file: None,
920            start_line: LineNumber::ONE,
921            end_line: LineNumber::ONE,
922            start_token: 0,
923            end_token: 15,
924            matcher: MatcherKind::Aho,
925            score: MatchScore::MAX,
926            matched_length: 8,
927            rule_length: 8,
928            match_coverage: 100.0,
929            rule_relevance: 100,
930            rule_identifier: "test-rule".to_string(),
931            rule_url: String::new(),
932            matched_text: Some("matched text".to_string()),
933            referenced_filenames: None,
934            rule_kind: crate::license_detection::models::RuleKind::None,
935            is_from_license: false,
936            rule_start_token: 0,
937            coordinates: MatchCoordinates::query_region(PositionSpan::from_positions(vec![
938                0, 1, 2, 3, 11, 12, 13, 14,
939            ])),
940        }];
941
942        let covered = compute_covered_positions(&query, &known_matches);
943        let regions = find_unmatched_regions(20, &covered);
944
945        assert!(
946            regions.contains(&(4, 11)),
947            "Should have unmatched region 4-11 (the gap in qspan_positions), got: {:?}",
948            regions
949        );
950        assert!(
951            regions.contains(&(15, 20)),
952            "Should have trailing unmatched region 15-20, got: {:?}",
953            regions
954        );
955
956        let contiguous_covered: PositionSet = (0..15).collect();
957        let contiguous_regions = find_unmatched_regions(20, &contiguous_covered);
958        assert_eq!(
959            contiguous_regions,
960            vec![(15, 20)],
961            "Contiguous coverage would collapse the gap, producing only trailing region"
962        );
963    }
964
965    #[test]
966    fn test_create_unknown_match_from_qspan_valid() {
967        use crate::license_detection::test_utils::create_mock_query_with_tokens;
968
969        let index = LicenseIndex::with_legalese_count(10);
970
971        let tokens: Vec<u16> = (0..30).collect();
972        let query = create_mock_query_with_tokens(&tokens, &index);
973
974        let qspan: PositionSet = (0..30).collect();
975
976        let match_result = create_unknown_match_from_qspan(&query, &qspan);
977
978        assert!(
979            match_result.is_some(),
980            "Should create unknown match for sufficient length"
981        );
982
983        let m = match_result.unwrap();
984        assert_eq!(m.license_expression, "unknown");
985        assert_eq!(m.matcher, MATCH_UNKNOWN);
986        assert!(!m.coordinates.query_span().is_empty());
987    }
988
989    #[test]
990    fn test_unknown_match_with_known_matches() {
991        let index = LicenseIndex::with_legalese_count(10);
992        let text = "some text that is license related and should be detected";
993        let query =
994            Query::from_extracted_text(text, &index, false).expect("Failed to create query");
995
996        let known_matches = vec![LicenseMatch {
997            rid: RuleId::NONE,
998            license_expression: "mit".to_string(),
999            license_expression_spdx: Some("MIT".to_string()),
1000            from_file: None,
1001            start_line: LineNumber::ONE,
1002            end_line: LineNumber::ONE,
1003            start_token: 0,
1004            end_token: 5,
1005            matcher: MatcherKind::Aho,
1006            score: MatchScore::MAX,
1007            matched_length: 5,
1008            rule_length: 5,
1009            match_coverage: 100.0,
1010            rule_relevance: 100,
1011            rule_identifier: "test-rule".to_string(),
1012            rule_url: String::new(),
1013            matched_text: Some("some text".to_string()),
1014            referenced_filenames: None,
1015            rule_kind: crate::license_detection::models::RuleKind::None,
1016            is_from_license: false,
1017            rule_start_token: 0,
1018            coordinates: MatchCoordinates::query_region(PositionSpan::range(0, 5)),
1019        }];
1020
1021        let matches = unknown_match(&index, &query, &known_matches);
1022
1023        assert!(
1024            matches.is_empty() || matches[0].start_line > LineNumber::ONE,
1025            "Should not re-detect known regions"
1026        );
1027    }
1028
1029    #[test]
1030    fn test_calculate_score_edge_cases() {
1031        let score_zero_length = calculate_score(10, 0);
1032        assert_eq!(
1033            score_zero_length,
1034            MatchScore::default(),
1035            "Zero length should have zero score"
1036        );
1037
1038        let score_zero_ngrams = calculate_score(0, 100);
1039        assert_eq!(
1040            score_zero_ngrams,
1041            MatchScore::default(),
1042            "Zero ngrams should have zero score"
1043        );
1044
1045        let score_high_density = calculate_score(100, 50);
1046        assert_eq!(
1047            score_high_density,
1048            MatchScore::MAX,
1049            "High density should be capped at 100.0"
1050        );
1051    }
1052
1053    #[test]
1054    fn test_get_matched_ngrams_out_of_bounds() {
1055        use crate::license_detection::automaton::AutomatonBuilder;
1056
1057        let tokens = tids(&[1, 2, 3]);
1058        let automaton = AutomatonBuilder::new().build();
1059
1060        let matches = get_matched_ngrams(&tokens, 5, 10, &automaton);
1061        assert!(matches.is_empty(), "Out of bounds should return empty");
1062
1063        let matches = get_matched_ngrams(&tokens, 2, 1, &automaton);
1064        assert!(matches.is_empty(), "Invalid range should return empty");
1065    }
1066
1067    // Build an index whose dictionary marks token ids `0..legalese_count` as legalese, plus an
1068    // unknown automaton containing one 6-gram pattern per window start in `window_starts`. With
1069    // all-distinct tokens each pattern matches exactly once, so the matched-ngram windows are
1070    // deterministic. Returns (index, tokens) for driving `unknown_match` end to end.
1071    fn unknown_index_with_windows(
1072        token_count: usize,
1073        legalese_count: u16,
1074        window_starts: &[usize],
1075    ) -> (LicenseIndex, Vec<u16>) {
1076        use crate::license_detection::automaton::AutomatonBuilder;
1077
1078        let legalese_entries: Vec<(String, u16)> = (0u16..legalese_count)
1079            .map(|i| (format!("legalese-{i}"), i))
1080            .collect();
1081        let mut index = LicenseIndex::with_legalese_count(0);
1082        index.dictionary =
1083            crate::license_detection::index::dictionary::TokenDictionary::new_with_legalese_pairs(
1084                &legalese_entries
1085                    .iter()
1086                    .map(|(token, id)| (token.as_str(), *id))
1087                    .collect::<Vec<_>>(),
1088            );
1089
1090        let token_ids: Vec<TokenId> = (0..token_count as u16).map(TokenId::new).collect();
1091
1092        let mut builder = AutomatonBuilder::new();
1093        for &start in window_starts {
1094            let window = &token_ids[start..start + UNKNOWN_NGRAM_LENGTH];
1095            let pattern: Vec<u8> = window.iter().flat_map(|tid| tid.to_le_bytes()).collect();
1096            builder.add_pattern(&pattern);
1097        }
1098        index.unknown_automaton = builder.build();
1099
1100        let tokens: Vec<u16> = (0..token_count as u16).collect();
1101        (index, tokens)
1102    }
1103
1104    // Pins the minimum-qspan gate (`qspan_length < UNKNOWN_NGRAM_LENGTH * 4`, i.e. 24; ScanCode
1105    // parity). Four non-overlapping 6-gram windows cover exactly 24 positions (kept); three
1106    // cover only 18 (dropped). All tokens are legalese here so the hispan gate is satisfied.
1107    #[test]
1108    fn test_unknown_match_pins_min_qspan_at_24() {
1109        use crate::license_detection::test_utils::create_mock_query_with_tokens;
1110
1111        // Three windows -> qspan == 18 (< 24) -> no match.
1112        let (index, tokens) = unknown_index_with_windows(30, 30, &[0, 6, 12]);
1113        let query = create_mock_query_with_tokens(&tokens, &index);
1114        assert!(unknown_match(&index, &query, &[]).is_empty());
1115
1116        // Four windows -> qspan == 24 (== 24, not < 24) -> match produced.
1117        let (index, tokens) = unknown_index_with_windows(30, 30, &[0, 6, 12, 18]);
1118        let query = create_mock_query_with_tokens(&tokens, &index);
1119        assert_eq!(unknown_match(&index, &query, &[]).len(), 1);
1120    }
1121
1122    // Pins the minimum-hispan gate (`hispan < 5`; ScanCode parity). Four windows give a qspan of
1123    // 24 positions; hispan counts how many of those are legalese. With 5 legalese tokens (ids
1124    // 0..4) hispan == 5 and the match is kept; with 4 (ids 0..3) hispan == 4 and it is dropped.
1125    #[test]
1126    fn test_unknown_match_pins_min_hispan_at_5() {
1127        use crate::license_detection::test_utils::create_mock_query_with_tokens;
1128
1129        // Only 4 legalese tokens within the qspan -> hispan == 4 (< 5) -> dropped.
1130        let (index, tokens) = unknown_index_with_windows(30, 4, &[0, 6, 12, 18]);
1131        let query = create_mock_query_with_tokens(&tokens, &index);
1132        assert!(unknown_match(&index, &query, &[]).is_empty());
1133
1134        // 5 legalese tokens within the qspan -> hispan == 5 (not < 5) -> kept.
1135        let (index, tokens) = unknown_index_with_windows(30, 5, &[0, 6, 12, 18]);
1136        let query = create_mock_query_with_tokens(&tokens, &index);
1137        assert_eq!(unknown_match(&index, &query, &[]).len(), 1);
1138    }
1139
1140    // Pins the MIN_REGION_LENGTH=5 early-out. A region of length 4 (one shorter than the gate)
1141    // yields no unknown match. The constant is a fast-path guard below the 6-token ngram window,
1142    // so any region this short cannot contain an ngram regardless.
1143    #[test]
1144    fn test_unknown_match_pins_min_region_length_early_out() {
1145        use crate::license_detection::test_utils::create_mock_query_with_tokens;
1146
1147        let (index, tokens) = unknown_index_with_windows(30, 30, &[0, 6, 12, 18]);
1148        let query = create_mock_query_with_tokens(&tokens, &index);
1149
1150        // Cover everything except a 4-token region [0,4); below MIN_REGION_LENGTH so it is
1151        // skipped before any ngram scan.
1152        let known = vec![LicenseMatch {
1153            matcher: MatcherKind::Aho,
1154            coordinates: MatchCoordinates::query_region(PositionSpan::range(4, 30)),
1155            ..Default::default()
1156        }];
1157        assert!(unknown_match(&index, &query, &known).is_empty());
1158    }
1159
1160    // Pins the MIN_NGRAM_MATCHES=3 early-out. A region long enough to clear MIN_REGION_LENGTH but
1161    // containing only two matched ngrams produces no unknown match. Like the region guard this is
1162    // a cheap pre-gate: two 6-grams can cover at most 12 positions, below the 24-position qspan
1163    // floor, so it cannot reach a match anyway.
1164    #[test]
1165    fn test_unknown_match_pins_min_ngram_matches_early_out() {
1166        use crate::license_detection::test_utils::create_mock_query_with_tokens;
1167
1168        let (index, tokens) = unknown_index_with_windows(30, 30, &[0, 6]);
1169        let query = create_mock_query_with_tokens(&tokens, &index);
1170        assert!(unknown_match(&index, &query, &[]).is_empty());
1171    }
1172}