Skip to main content

provenant/license_detection/index/
mod.rs

1// SPDX-FileCopyrightText: nexB Inc. and others
2// SPDX-FileCopyrightText: Provenant contributors
3// SPDX-License-Identifier: Apache-2.0
4// Derived from ScanCode Toolkit (Apache-2.0); modified. See NOTICE.
5
6//! License index construction and querying.
7
8pub mod builder;
9pub mod dictionary;
10
11#[allow(unused_imports)]
12pub use builder::{
13    build_index, build_index_from_loaded, loaded_license_to_license, loaded_rule_to_rule,
14};
15
16use crate::license_detection::automaton::{AsBytes, Automaton};
17use crate::license_detection::index::dictionary::{TokenDictionary, TokenId};
18use crate::license_detection::models::RuleId;
19use crate::license_detection::{TokenMultiset, TokenSet};
20use rkyv::Archive;
21use std::collections::{HashMap, HashSet};
22
23#[derive(Debug, Clone, Default, PartialEq, Eq, Archive, rkyv::Serialize, rkyv::Deserialize)]
24pub struct IndexedRuleMetadata {
25    pub license_expression_spdx: Option<String>,
26    pub skip_for_required_phrase_generation: bool,
27    pub replaced_by: Vec<String>,
28}
29
30/// License index containing all data structures for efficient license detection.
31///
32/// The LicenseIndex holds multiple index structures that enable different matching
33/// strategies: hash-based exact matching, Aho-Corasick automaton matching, set-based
34/// candidate selection, and sequence matching.
35///
36/// Based on the Python ScanCode Toolkit implementation at:
37/// reference/scancode-toolkit/src/licensedcode/index.py
38///
39/// # Index Structures
40///
41/// The index maintains several data structures for different matching strategies:
42///
43/// - **Hash matching**: `rid_by_hash` for exact hash-based matches
44/// - **Automaton matching**: `rules_automaton` and `unknown_automaton` for pattern matching
45/// - **Candidate selection**: `sets_by_rid` and `msets_by_rid` for set-based ranking
46/// - **Sequence matching**: `high_postings_by_rid` for high-value token position tracking
47#[derive(Debug, Clone, Archive, rkyv::Serialize, rkyv::Deserialize)]
48pub struct LicenseIndex {
49    /// Token dictionary mapping token strings to integer IDs.
50    ///
51    /// IDs 0 to len_legalese-1 are reserved for legalese tokens (high-value words).
52    /// IDs len_legalese and above are assigned to other tokens as encountered.
53    pub dictionary: TokenDictionary,
54
55    /// Number of legalese tokens.
56    ///
57    /// Tokens with ID < len_legalese are considered high-value legalese words.
58    /// Tokens with ID >= len_legalese are considered low-value tokens.
59    ///
60    /// Corresponds to Python: `self.len_legalese = 0` (line 185)
61    pub len_legalese: usize,
62
63    /// Mapping from rule hash to rule ID for hash-based exact matching.
64    ///
65    /// This enables fast exact matches using a hash of the rule\'s token IDs.
66    /// Each hash maps to exactly one rule ID.
67    ///
68    /// Note: The hash is a 20-byte SHA1 digest, stored as a key in HashMap.
69    /// In practice, we use a HashMap<[u8; 20], RuleId>.
70    ///
71    /// Corresponds to Python: `self.rid_by_hash = {}` (line 216)
72    pub rid_by_hash: HashMap<[u8; 20], RuleId>,
73
74    /// Rules indexed by rule ID.
75    ///
76    /// Maps rule IDs to Rule objects for quick lookup.
77    ///
78    /// Corresponds to Python: `self.rules_by_rid = []` (line 201)
79    pub rules_by_rid: Vec<crate::license_detection::models::Rule>,
80
81    /// Token ID sequences indexed by rule ID.
82    ///
83    /// Maps rule IDs to their token ID sequences.
84    ///
85    /// Corresponds to Python: `self.tids_by_rid = []` (line 204)
86    pub tids_by_rid: Vec<Vec<TokenId>>,
87
88    /// Aho-Corasick automaton built from all rule token sequences.
89    ///
90    /// Supports efficient multi-pattern matching of token ID sequences.
91    /// Used for exact matching of complete rules or rule fragments in query text.
92    ///
93    /// Corresponds to Python: `self.rules_automaton = match_aho.get_automaton()` (line 219)
94    #[rkyv(with = AsBytes)]
95    pub rules_automaton: Automaton,
96
97    /// Aho-Corasick automaton for unknown license detection.
98    ///
99    /// Separate automaton used to detect license-like text that doesn\'t match
100    /// any known rule. Populated with ngrams from all approx-matchable rules.
101    ///
102    /// Corresponds to Python: `self.unknown_automaton = match_unknown.get_automaton()` (line 222)
103    #[rkyv(with = AsBytes)]
104    pub unknown_automaton: Automaton,
105
106    /// Token ID sets per rule for candidate selection.
107    ///
108    /// Maps rule IDs to sets of unique token IDs present in that rule.
109    /// Used for efficient candidate selection based on token overlap.
110    ///
111    /// Corresponds to Python: `self.sets_by_rid = []` (line 212)
112    pub sets_by_rid: HashMap<RuleId, TokenSet>,
113
114    pub rule_metadata_by_identifier: HashMap<String, IndexedRuleMetadata>,
115
116    /// Token ID multisets per rule for candidate ranking.
117    ///
118    /// Maps rule IDs to multisets (bags) of token IDs with their frequencies.
119    /// Used for ranking candidates by token frequency overlap.
120    ///
121    /// Corresponds to Python: `self.msets_by_rid = []` (line 213)
122    pub msets_by_rid: HashMap<RuleId, TokenMultiset>,
123
124    /// High-value token sets per rule for early candidate rejection.
125    ///
126    /// Maps rule IDs to sets containing only high-value (legalese) token IDs.
127    /// This is a subset of `sets_by_rid` for faster intersection computation
128    /// and early rejection of candidates that won't pass the high-token threshold.
129    ///
130    /// Precomputed during index building to avoid redundant filtering at runtime.
131    pub high_sets_by_rid: HashMap<RuleId, TokenSet>,
132
133    /// Inverted index of high-value token positions per rule.
134    ///
135    /// Maps rule IDs to a mapping from high-value token IDs to their positions
136    /// within the rule. Only contains positions for tokens with IDs < len_legalese.
137    ///
138    /// This structure speeds up sequence matching by allowing quick lookup of
139    /// where high-value tokens appear in each rule.
140    ///
141    /// Corresponds to Python: `self.high_postings_by_rid = []` (line 209)
142    /// In Python: `postings = {tid: array('h', [positions, ...])}`
143    pub high_postings_by_rid: HashMap<RuleId, HashMap<TokenId, Vec<usize>>>,
144
145    /// Mapping from ScanCode license key to License object.
146    ///
147    /// Provides access to license metadata for building SPDX mappings
148    /// and validating license expressions.
149    ///
150    /// Corresponds to Python: `get_licenses_db()` in models.py
151    pub licenses_by_key: HashMap<String, crate::license_detection::models::License>,
152
153    /// Mapping from SPDX license key to rule ID.
154    ///
155    /// Enables direct lookup of rules by their SPDX license key,
156    /// including aliases like "GPL-2.0+" -> gpl-2.0-plus.
157    ///
158    /// Keys are stored lowercase for case-insensitive lookup.
159    ///
160    /// Corresponds to Python: `self.licenses_by_spdx_key` in cache.py
161    pub rid_by_spdx_key: HashMap<String, RuleId>,
162
163    /// Rule ID for the unknown-spdx license.
164    ///
165    /// Used as a fallback when an SPDX identifier is not recognized.
166    ///
167    /// Corresponds to Python: `get_unknown_spdx_symbol()` in cache.py
168    pub unknown_spdx_rid: Option<RuleId>,
169
170    /// Inverted index mapping high-value token IDs to rule IDs.
171    ///
172    /// This enables fast candidate selection by only examining rules
173    /// that share at least one high-value (legalese) token with the query.
174    /// Without this index, candidate selection would iterate over all 37,000+
175    /// rules for every file, making license detection extremely slow.
176    ///
177    /// Only contains entries for tokens with ID < len_legalese (high-value tokens).
178    /// Only approx-matchable rules are included in this index.
179    pub rids_by_high_tid: HashMap<TokenId, HashSet<RuleId>>,
180
181    /// SPDX license list version used to build this index.
182    pub spdx_license_list_version: Option<String>,
183}
184
185impl LicenseIndex {
186    /// Returns the rule for the given ID, or `None` if the ID is invalid
187    /// or out of range.
188    pub fn rule(&self, id: RuleId) -> Option<&crate::license_detection::models::Rule> {
189        if id.is_none() {
190            return None;
191        }
192        self.rules_by_rid.get(id.raw())
193    }
194
195    /// Returns the token ID sequence for the given rule, or `None` if the ID
196    /// is invalid or out of range.
197    pub fn rule_tokens(&self, id: RuleId) -> Option<&[TokenId]> {
198        if id.is_none() {
199            return None;
200        }
201        self.tids_by_rid.get(id.raw()).map(|v| v.as_slice())
202    }
203
204    pub fn is_false_positive(&self, id: RuleId) -> bool {
205        self.rule(id).is_some_and(|r| r.is_false_positive)
206    }
207}
208
209impl LicenseIndex {
210    /// Create a new empty license index.
211    ///
212    /// This constructor initializes all index structures with empty collections.
213    /// The index can be populated with rules using the indexing methods (to be
214    /// implemented in future phases).
215    ///
216    /// # Returns
217    /// A new LicenseIndex instance with empty index structures
218    pub fn new(dictionary: TokenDictionary) -> Self {
219        use crate::license_detection::automaton::AutomatonBuilder;
220
221        let len_legalese = dictionary.legalese_count();
222        Self {
223            dictionary,
224            len_legalese,
225            rid_by_hash: HashMap::new(),
226            rules_by_rid: Vec::new(),
227            tids_by_rid: Vec::new(),
228            rules_automaton: AutomatonBuilder::new().build(),
229            unknown_automaton: AutomatonBuilder::new().build(),
230            sets_by_rid: HashMap::new(),
231            rule_metadata_by_identifier: HashMap::new(),
232            msets_by_rid: HashMap::new(),
233            high_sets_by_rid: HashMap::new(),
234            high_postings_by_rid: HashMap::new(),
235            licenses_by_key: HashMap::new(),
236            rid_by_spdx_key: HashMap::new(),
237            unknown_spdx_rid: None,
238            rids_by_high_tid: HashMap::new(),
239            spdx_license_list_version: None,
240        }
241    }
242
243    /// Create a new empty license index with the specified legalese count.
244    ///
245    /// Convenience method that creates a new TokenDictionary and LicenseIndex
246    /// in one call.
247    ///
248    /// # Arguments
249    /// * `legalese_count` - Number of reserved legalese token IDs
250    ///
251    /// # Returns
252    /// A new LicenseIndex instance with a new TokenDictionary
253    pub fn with_legalese_count(legalese_count: usize) -> Self {
254        Self::new(TokenDictionary::new(legalese_count))
255    }
256}
257
258impl Default for LicenseIndex {
259    fn default() -> Self {
260        Self::with_legalese_count(0)
261    }
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267
268    fn simple_license(key: &str, name: &str, spdx: &str, category: &str, text: &str) -> License {
269        License {
270            key: key.to_string(),
271            short_name: Some(name.to_string()),
272            name: name.to_string(),
273            language: Some("en".to_string()),
274            spdx_license_key: Some(spdx.to_string()),
275            other_spdx_license_keys: vec![],
276            category: Some(category.to_string()),
277            owner: None,
278            homepage_url: None,
279            text: text.to_string(),
280            reference_urls: vec![],
281            osi_license_key: Some(spdx.to_string()),
282            text_urls: vec![],
283            osi_url: None,
284            faq_url: None,
285            other_urls: vec![],
286            notes: None,
287            is_deprecated: false,
288            is_exception: false,
289            is_unknown: false,
290            is_generic: false,
291            replaced_by: vec![],
292            minimum_coverage: None,
293            standard_notice: None,
294            ignorable_copyrights: None,
295            ignorable_holders: None,
296            ignorable_authors: None,
297            ignorable_urls: None,
298            ignorable_emails: None,
299        }
300    }
301    use crate::license_detection::models::License;
302
303    #[test]
304    fn test_license_index_new() {
305        let dict = TokenDictionary::new(10);
306        let index = LicenseIndex::new(dict);
307
308        assert_eq!(index.dictionary.legalese_count(), 10);
309        assert!(index.rid_by_hash.is_empty());
310        assert!(index.sets_by_rid.is_empty());
311        assert!(index.msets_by_rid.is_empty());
312        assert!(index.high_postings_by_rid.is_empty());
313        assert!(index.licenses_by_key.is_empty());
314    }
315
316    #[test]
317    fn test_license_index_with_legalese_count() {
318        let index = LicenseIndex::with_legalese_count(15);
319
320        assert_eq!(index.dictionary.legalese_count(), 15);
321        assert!(index.rid_by_hash.is_empty());
322    }
323
324    #[test]
325    fn test_license_index_default() {
326        let index = LicenseIndex::default();
327
328        assert_eq!(index.dictionary.legalese_count(), 0);
329        assert!(index.rid_by_hash.is_empty());
330    }
331
332    #[test]
333    fn test_automaton_default() {
334        use crate::license_detection::automaton::AutomatonBuilder;
335
336        let automaton = AutomatonBuilder::new().build();
337        let _ = format!("{:?}", automaton);
338    }
339
340    #[test]
341    fn test_license_index_clone() {
342        let index = LicenseIndex::with_legalese_count(5);
343        let cloned = index.clone();
344
345        assert_eq!(cloned.dictionary.legalese_count(), 5);
346        assert!(cloned.rid_by_hash.is_empty());
347    }
348
349    #[test]
350    fn test_license_index_add_license() {
351        let mut index = LicenseIndex::default();
352
353        let license = simple_license(
354            "test-license",
355            "Test License",
356            "TEST",
357            "Permissive",
358            "Test license text",
359        );
360
361        index.licenses_by_key.insert(license.key.clone(), license);
362
363        assert_eq!(index.licenses_by_key.len(), 1);
364        assert!(index.licenses_by_key.contains_key("test-license"));
365    }
366
367    #[test]
368    fn test_license_index_add_licenses() {
369        let mut index = LicenseIndex::default();
370
371        let licenses = vec![
372            simple_license(
373                "license-1",
374                "License 1",
375                "LIC1",
376                "Permissive",
377                "License 1 text",
378            ),
379            simple_license(
380                "license-2",
381                "License 2",
382                "LIC2",
383                "Copyleft",
384                "License 2 text",
385            ),
386        ];
387
388        for license in licenses {
389            index.licenses_by_key.insert(license.key.clone(), license);
390        }
391
392        assert_eq!(index.licenses_by_key.len(), 2);
393        assert!(index.licenses_by_key.contains_key("license-1"));
394        assert!(index.licenses_by_key.contains_key("license-2"));
395    }
396
397    #[test]
398    fn test_license_index_get_license() {
399        let mut index = LicenseIndex::default();
400
401        let license = simple_license(
402            "mit",
403            "MIT License",
404            "MIT",
405            "Permissive",
406            "MIT License text",
407        );
408
409        index.licenses_by_key.insert(license.key.clone(), license);
410
411        let retrieved = index.licenses_by_key.get("mit");
412        assert!(retrieved.is_some());
413        assert_eq!(retrieved.unwrap().name, "MIT License");
414
415        assert!(!index.licenses_by_key.contains_key("unknown"));
416    }
417
418    #[test]
419    fn test_license_index_license_count() {
420        let mut index = LicenseIndex::default();
421
422        assert_eq!(index.licenses_by_key.len(), 0);
423
424        let license = simple_license("test", "Test", "TEST", "Permissive", "Text");
425
426        index.licenses_by_key.insert(license.key.clone(), license);
427
428        assert_eq!(index.licenses_by_key.len(), 1);
429    }
430}