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}