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::{HighBitset, 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    /// Indexed densely by `RuleId::raw()` (mirroring Python's `sets_by_rid = []`
109    /// list). Entries are `None` for rule IDs that hold no set (e.g. false-positive
110    /// rules), so the candidate-selection hot loop reads a `Vec` slot instead of a
111    /// hashed lookup. Use [`LicenseIndex::set_for_rid`] to access.
112    pub sets_by_rid: Vec<Option<TokenSet>>,
113
114    pub rule_metadata_by_identifier: HashMap<String, IndexedRuleMetadata>,
115
116    /// Token ID multisets per rule for candidate ranking.
117    ///
118    /// Indexed densely by `RuleId::raw()` (mirroring Python's `msets_by_rid = []`).
119    /// `None` where no multiset exists. Use [`LicenseIndex::mset_for_rid`].
120    pub msets_by_rid: Vec<Option<TokenMultiset>>,
121
122    /// High-value token sets per rule for early candidate rejection.
123    ///
124    /// Indexed densely by `RuleId::raw()`. A subset of `sets_by_rid` containing
125    /// only high-value (legalese) token IDs, for early rejection of candidates that
126    /// won't pass the high-token threshold. `None` where the rule has no high-value
127    /// tokens. Use [`LicenseIndex::high_set_for_rid`].
128    pub high_sets_by_rid: Vec<Option<TokenSet>>,
129
130    /// High-value token sets as fixed-width bitsets (width = `len_legalese`),
131    /// densely indexed by `RuleId::raw()` and aligned with `high_sets_by_rid`
132    /// (`Some` exactly where that is `Some`). Used by the candidate-selection
133    /// high-token gate, where `AND`+`popcount` replaces the sorted-set merge
134    /// walk. Use [`LicenseIndex::high_bitset_for_rid`].
135    pub high_bitsets_by_rid: Vec<Option<HighBitset>>,
136
137    /// Inverted index of high-value token positions per rule.
138    ///
139    /// Maps rule IDs to a mapping from high-value token IDs to their positions
140    /// within the rule. Only contains positions for tokens with IDs < len_legalese.
141    ///
142    /// This structure speeds up sequence matching by allowing quick lookup of
143    /// where high-value tokens appear in each rule.
144    ///
145    /// Corresponds to Python: `self.high_postings_by_rid = []` (line 209)
146    /// In Python: `postings = {tid: array('h', [positions, ...])}`
147    pub high_postings_by_rid: HashMap<RuleId, HashMap<TokenId, Vec<usize>>>,
148
149    /// Mapping from ScanCode license key to License object.
150    ///
151    /// Provides access to license metadata for building SPDX mappings
152    /// and validating license expressions.
153    ///
154    /// Corresponds to Python: `get_licenses_db()` in models.py
155    pub licenses_by_key: HashMap<String, crate::license_detection::models::License>,
156
157    /// Mapping from SPDX license key to rule ID.
158    ///
159    /// Enables direct lookup of rules by their SPDX license key,
160    /// including aliases like "GPL-2.0+" -> gpl-2.0-plus.
161    ///
162    /// Keys are stored lowercase for case-insensitive lookup.
163    ///
164    /// Corresponds to Python: `self.licenses_by_spdx_key` in cache.py
165    pub rid_by_spdx_key: HashMap<String, RuleId>,
166
167    /// Rule ID for the unknown-spdx license.
168    ///
169    /// Used as a fallback when an SPDX identifier is not recognized.
170    ///
171    /// Corresponds to Python: `get_unknown_spdx_symbol()` in cache.py
172    pub unknown_spdx_rid: Option<RuleId>,
173
174    /// Inverted index mapping high-value token IDs to rule IDs.
175    ///
176    /// This enables fast candidate selection by only examining rules
177    /// that share at least one high-value (legalese) token with the query.
178    /// Without this index, candidate selection would iterate over all 37,000+
179    /// rules for every file, making license detection extremely slow.
180    ///
181    /// Only contains entries for tokens with ID < len_legalese (high-value tokens).
182    /// Only approx-matchable rules are included in this index.
183    pub rids_by_high_tid: HashMap<TokenId, HashSet<RuleId>>,
184
185    /// SPDX license list version used to build this index.
186    pub spdx_license_list_version: Option<String>,
187}
188
189impl LicenseIndex {
190    /// Returns the rule for the given ID, or `None` if the ID is invalid
191    /// or out of range.
192    pub fn rule(&self, id: RuleId) -> Option<&crate::license_detection::models::Rule> {
193        if id.is_none() {
194            return None;
195        }
196        self.rules_by_rid.get(id.raw())
197    }
198
199    /// Returns the token ID sequence for the given rule, or `None` if the ID
200    /// is invalid or out of range.
201    pub fn rule_tokens(&self, id: RuleId) -> Option<&[TokenId]> {
202        if id.is_none() {
203            return None;
204        }
205        self.tids_by_rid.get(id.raw()).map(|v| v.as_slice())
206    }
207
208    pub fn is_false_positive(&self, id: RuleId) -> bool {
209        self.rule(id).is_some_and(|r| r.is_false_positive)
210    }
211}
212
213impl LicenseIndex {
214    /// Create a new empty license index.
215    ///
216    /// This constructor initializes all index structures with empty collections.
217    /// The index can be populated with rules using the indexing methods (to be
218    /// implemented in future phases).
219    ///
220    /// # Returns
221    /// A new LicenseIndex instance with empty index structures
222    pub fn new(dictionary: TokenDictionary) -> Self {
223        use crate::license_detection::automaton::AutomatonBuilder;
224
225        let len_legalese = dictionary.legalese_count();
226        Self {
227            dictionary,
228            len_legalese,
229            rid_by_hash: HashMap::new(),
230            rules_by_rid: Vec::new(),
231            tids_by_rid: Vec::new(),
232            rules_automaton: AutomatonBuilder::new().build(),
233            unknown_automaton: AutomatonBuilder::new().build(),
234            sets_by_rid: Vec::new(),
235            rule_metadata_by_identifier: HashMap::new(),
236            msets_by_rid: Vec::new(),
237            high_sets_by_rid: Vec::new(),
238            high_bitsets_by_rid: Vec::new(),
239            high_postings_by_rid: HashMap::new(),
240            licenses_by_key: HashMap::new(),
241            rid_by_spdx_key: HashMap::new(),
242            unknown_spdx_rid: None,
243            rids_by_high_tid: HashMap::new(),
244            spdx_license_list_version: None,
245        }
246    }
247
248    /// Unique-token set for a rule, or `None` if the rule holds no set
249    /// (e.g. a false-positive rule). Dense `Vec` lookup by `RuleId`.
250    #[inline]
251    pub fn set_for_rid(&self, rid: RuleId) -> Option<&TokenSet> {
252        self.sets_by_rid.get(rid.raw()).and_then(Option::as_ref)
253    }
254
255    /// High-value (legalese) token set for a rule, or `None` if the rule has no
256    /// high-value tokens. Dense `Vec` lookup by `RuleId`.
257    #[inline]
258    pub fn high_set_for_rid(&self, rid: RuleId) -> Option<&TokenSet> {
259        self.high_sets_by_rid
260            .get(rid.raw())
261            .and_then(Option::as_ref)
262    }
263
264    /// Token multiset for a rule, or `None` if the rule holds no multiset.
265    /// Dense `Vec` lookup by `RuleId`.
266    #[inline]
267    pub fn mset_for_rid(&self, rid: RuleId) -> Option<&TokenMultiset> {
268        self.msets_by_rid.get(rid.raw()).and_then(Option::as_ref)
269    }
270
271    /// High-value token bitset for a rule, or `None` if the rule has no
272    /// high-value tokens. Dense `Vec` lookup by `RuleId`.
273    #[inline]
274    pub fn high_bitset_for_rid(&self, rid: RuleId) -> Option<&HighBitset> {
275        self.high_bitsets_by_rid
276            .get(rid.raw())
277            .and_then(Option::as_ref)
278    }
279
280    /// Create a new empty license index with the specified legalese count.
281    ///
282    /// Convenience method that creates a new TokenDictionary and LicenseIndex
283    /// in one call.
284    ///
285    /// # Arguments
286    /// * `legalese_count` - Number of reserved legalese token IDs
287    ///
288    /// # Returns
289    /// A new LicenseIndex instance with a new TokenDictionary
290    pub fn with_legalese_count(legalese_count: usize) -> Self {
291        Self::new(TokenDictionary::new(legalese_count))
292    }
293}
294
295impl Default for LicenseIndex {
296    fn default() -> Self {
297        Self::with_legalese_count(0)
298    }
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304
305    fn simple_license(key: &str, name: &str, spdx: &str, category: &str, text: &str) -> License {
306        License {
307            key: key.to_string(),
308            short_name: Some(name.to_string()),
309            name: name.to_string(),
310            language: Some("en".to_string()),
311            spdx_license_key: Some(spdx.to_string()),
312            other_spdx_license_keys: vec![],
313            category: Some(category.to_string()),
314            owner: None,
315            homepage_url: None,
316            text: text.to_string(),
317            reference_urls: vec![],
318            osi_license_key: Some(spdx.to_string()),
319            text_urls: vec![],
320            osi_url: None,
321            faq_url: None,
322            other_urls: vec![],
323            notes: None,
324            is_deprecated: false,
325            is_exception: false,
326            is_unknown: false,
327            is_generic: false,
328            replaced_by: vec![],
329            minimum_coverage: None,
330            standard_notice: None,
331            ignorable_copyrights: None,
332            ignorable_holders: None,
333            ignorable_authors: None,
334            ignorable_urls: None,
335            ignorable_emails: None,
336        }
337    }
338    use crate::license_detection::models::License;
339
340    #[test]
341    fn test_license_index_new() {
342        let dict = TokenDictionary::new(10);
343        let index = LicenseIndex::new(dict);
344
345        assert_eq!(index.dictionary.legalese_count(), 10);
346        assert!(index.rid_by_hash.is_empty());
347        assert!(index.sets_by_rid.is_empty());
348        assert!(index.msets_by_rid.is_empty());
349        assert!(index.high_postings_by_rid.is_empty());
350        assert!(index.licenses_by_key.is_empty());
351    }
352
353    #[test]
354    fn test_license_index_with_legalese_count() {
355        let index = LicenseIndex::with_legalese_count(15);
356
357        assert_eq!(index.dictionary.legalese_count(), 15);
358        assert!(index.rid_by_hash.is_empty());
359    }
360
361    #[test]
362    fn test_license_index_default() {
363        let index = LicenseIndex::default();
364
365        assert_eq!(index.dictionary.legalese_count(), 0);
366        assert!(index.rid_by_hash.is_empty());
367    }
368
369    #[test]
370    fn test_automaton_default() {
371        use crate::license_detection::automaton::AutomatonBuilder;
372
373        let automaton = AutomatonBuilder::new().build();
374        let _ = format!("{:?}", automaton);
375    }
376
377    #[test]
378    fn test_license_index_clone() {
379        let index = LicenseIndex::with_legalese_count(5);
380        let cloned = index.clone();
381
382        assert_eq!(cloned.dictionary.legalese_count(), 5);
383        assert!(cloned.rid_by_hash.is_empty());
384    }
385
386    #[test]
387    fn test_license_index_add_license() {
388        let mut index = LicenseIndex::default();
389
390        let license = simple_license(
391            "test-license",
392            "Test License",
393            "TEST",
394            "Permissive",
395            "Test license text",
396        );
397
398        index.licenses_by_key.insert(license.key.clone(), license);
399
400        assert_eq!(index.licenses_by_key.len(), 1);
401        assert!(index.licenses_by_key.contains_key("test-license"));
402    }
403
404    #[test]
405    fn test_license_index_add_licenses() {
406        let mut index = LicenseIndex::default();
407
408        let licenses = vec![
409            simple_license(
410                "license-1",
411                "License 1",
412                "LIC1",
413                "Permissive",
414                "License 1 text",
415            ),
416            simple_license(
417                "license-2",
418                "License 2",
419                "LIC2",
420                "Copyleft",
421                "License 2 text",
422            ),
423        ];
424
425        for license in licenses {
426            index.licenses_by_key.insert(license.key.clone(), license);
427        }
428
429        assert_eq!(index.licenses_by_key.len(), 2);
430        assert!(index.licenses_by_key.contains_key("license-1"));
431        assert!(index.licenses_by_key.contains_key("license-2"));
432    }
433
434    #[test]
435    fn test_license_index_get_license() {
436        let mut index = LicenseIndex::default();
437
438        let license = simple_license(
439            "mit",
440            "MIT License",
441            "MIT",
442            "Permissive",
443            "MIT License text",
444        );
445
446        index.licenses_by_key.insert(license.key.clone(), license);
447
448        let retrieved = index.licenses_by_key.get("mit");
449        assert!(retrieved.is_some());
450        assert_eq!(retrieved.unwrap().name, "MIT License");
451
452        assert!(!index.licenses_by_key.contains_key("unknown"));
453    }
454
455    #[test]
456    fn test_license_index_license_count() {
457        let mut index = LicenseIndex::default();
458
459        assert_eq!(index.licenses_by_key.len(), 0);
460
461        let license = simple_license("test", "Test", "TEST", "Permissive", "Text");
462
463        index.licenses_by_key.insert(license.key.clone(), license);
464
465        assert_eq!(index.licenses_by_key.len(), 1);
466    }
467}