Skip to main content

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