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}