Skip to main content

oxirs_ttl/toolkit/
string_interner.rs

1//! String interning for efficient deduplication of repeated strings (IRIs, prefixes, language tags)
2//!
3//! This module provides a high-performance string interner specifically optimized for RDF parsing,
4//! where many strings (especially IRIs, predicates, and prefixes) are repeated frequently.
5
6use std::borrow::Cow;
7use std::collections::HashMap;
8use std::hash::{Hash, Hasher};
9use std::sync::Arc;
10
11/// A string interner that deduplicates strings to reduce memory usage
12///
13/// This is particularly useful for RDF parsing where:
14/// - Common predicates (rdf:type, rdfs:label, etc.) appear many times
15/// - Namespace URIs are repeated frequently
16/// - Language tags and datatypes are reused
17///
18/// # Example
19///
20/// ```
21/// use oxirs_ttl::toolkit::StringInterner;
22/// use std::sync::Arc;
23///
24/// let mut interner = StringInterner::new();
25///
26/// // These will all point to the same underlying string
27/// let s1 = interner.intern("http://www.w3.org/1999/02/22-rdf-syntax-ns#type");
28/// let s2 = interner.intern("http://www.w3.org/1999/02/22-rdf-syntax-ns#type");
29///
30/// // The Arc pointers should be the same (cheap to clone)
31/// assert!(Arc::ptr_eq(&s1, &s2));
32/// ```
33#[derive(Debug, Clone)]
34pub struct StringInterner {
35    /// Map from string content to interned `Arc<String>`
36    map: HashMap<InternedString, Arc<String>>,
37    /// Statistics for monitoring performance
38    stats: InternerStats,
39}
40
41/// Statistics about string interning performance
42#[derive(Debug, Clone, Default)]
43pub struct InternerStats {
44    /// Total number of intern() calls
45    pub total_requests: usize,
46    /// Number of times a string was found in the cache (hit)
47    pub cache_hits: usize,
48    /// Number of times a new string was allocated (miss)
49    pub cache_misses: usize,
50    /// Total number of unique strings stored
51    pub unique_strings: usize,
52    /// Total bytes saved by deduplication (approximate)
53    pub bytes_saved: usize,
54}
55
56/// Wrapper for hash map key that allows lookup by &str without allocating
57#[derive(Debug, Clone, Eq)]
58struct InternedString(Arc<String>);
59
60impl InternedString {
61    fn new(s: Arc<String>) -> Self {
62        Self(s)
63    }
64
65    fn as_str(&self) -> &str {
66        &self.0
67    }
68}
69
70impl PartialEq for InternedString {
71    fn eq(&self, other: &Self) -> bool {
72        self.0.as_str() == other.0.as_str()
73    }
74}
75
76impl Hash for InternedString {
77    fn hash<H: Hasher>(&self, state: &mut H) {
78        self.0.as_str().hash(state);
79    }
80}
81
82impl std::borrow::Borrow<str> for InternedString {
83    fn borrow(&self) -> &str {
84        self.as_str()
85    }
86}
87
88impl StringInterner {
89    /// Create a new string interner
90    pub fn new() -> Self {
91        Self::with_capacity(1024)
92    }
93
94    /// Create a new string interner with pre-allocated capacity
95    pub fn with_capacity(capacity: usize) -> Self {
96        Self {
97            map: HashMap::with_capacity(capacity),
98            stats: InternerStats::default(),
99        }
100    }
101
102    /// Create a new interner pre-populated with common RDF namespaces
103    pub fn with_common_namespaces() -> Self {
104        let mut interner = Self::with_capacity(2048);
105
106        // Pre-populate with common RDF namespaces
107        let common_namespaces = [
108            "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
109            "http://www.w3.org/2000/01/rdf-schema#",
110            "http://www.w3.org/2001/XMLSchema#",
111            "http://www.w3.org/2002/07/owl#",
112            "http://xmlns.com/foaf/0.1/",
113            "http://purl.org/dc/elements/1.1/",
114            "http://purl.org/dc/terms/",
115            "http://schema.org/",
116            "http://www.w3.org/ns/shacl#",
117            "http://www.w3.org/2004/02/skos/core#",
118            // Common predicates
119            "http://www.w3.org/1999/02/22-rdf-syntax-ns#type",
120            "http://www.w3.org/2000/01/rdf-schema#label",
121            "http://www.w3.org/2000/01/rdf-schema#comment",
122            "http://www.w3.org/2000/01/rdf-schema#subClassOf",
123            "http://www.w3.org/2000/01/rdf-schema#subPropertyOf",
124            "http://www.w3.org/2000/01/rdf-schema#domain",
125            "http://www.w3.org/2000/01/rdf-schema#range",
126            "http://www.w3.org/2002/07/owl#sameAs",
127            "http://www.w3.org/2002/07/owl#equivalentClass",
128            "http://www.w3.org/2002/07/owl#equivalentProperty",
129        ];
130
131        for ns in &common_namespaces {
132            interner.intern(ns);
133        }
134
135        // Reset stats after pre-population
136        interner.stats = InternerStats {
137            unique_strings: interner.map.len(),
138            ..Default::default()
139        };
140
141        interner
142    }
143
144    /// Intern a string, returning an Arc to the deduplicated version
145    ///
146    /// If the string has been seen before, returns the existing Arc.
147    /// Otherwise, allocates a new String and stores it.
148    pub fn intern(&mut self, s: &str) -> Arc<String> {
149        self.stats.total_requests += 1;
150
151        // Try to find existing interned string
152        if let Some(interned_key) = self.map.get_key_value(s) {
153            self.stats.cache_hits += 1;
154            // Estimate bytes saved: we would have allocated this string, but didn't
155            self.stats.bytes_saved += s.len();
156            return interned_key.0 .0.clone();
157        }
158
159        // Not found - allocate new string
160        self.stats.cache_misses += 1;
161        self.stats.unique_strings += 1;
162
163        let arc_string = Arc::new(s.to_string());
164        let key = InternedString::new(arc_string.clone());
165        self.map.insert(key, arc_string.clone());
166
167        arc_string
168    }
169
170    /// Intern a string only if it's likely to be repeated
171    ///
172    /// Uses heuristics to decide whether to intern:
173    /// - Always intern if it contains common namespace patterns
174    /// - Always intern if it's a common language tag
175    /// - Otherwise, intern if length > threshold
176    pub fn intern_if_beneficial<'a>(&mut self, s: &'a str, min_length: usize) -> Cow<'a, str> {
177        // Always intern common patterns
178        if s.contains("www.w3.org")
179            || s.contains("schema.org")
180            || s.contains("xmlns.com")
181            || s.contains("purl.org")
182            || matches!(s, "en" | "de" | "fr" | "es" | "ja" | "zh" | "ar" | "hi")
183        {
184            return Cow::Owned((*self.intern(s)).clone());
185        }
186
187        // Intern longer strings that are likely to be repeated
188        if s.len() >= min_length {
189            Cow::Owned((*self.intern(s)).clone())
190        } else {
191            Cow::Borrowed(s)
192        }
193    }
194
195    /// Get the number of unique strings stored
196    pub fn len(&self) -> usize {
197        self.map.len()
198    }
199
200    /// Check if the interner is empty
201    pub fn is_empty(&self) -> bool {
202        self.map.is_empty()
203    }
204
205    /// Get interning statistics
206    pub fn stats(&self) -> &InternerStats {
207        &self.stats
208    }
209
210    /// Get the cache hit rate (0.0 to 1.0)
211    pub fn hit_rate(&self) -> f64 {
212        if self.stats.total_requests == 0 {
213            return 0.0;
214        }
215        self.stats.cache_hits as f64 / self.stats.total_requests as f64
216    }
217
218    /// Clear all interned strings and reset statistics
219    pub fn clear(&mut self) {
220        self.map.clear();
221        self.stats = InternerStats::default();
222    }
223
224    /// Estimate memory usage in bytes
225    pub fn memory_usage(&self) -> usize {
226        // Each HashMap entry has overhead + key (Arc<String>) + value (Arc<String>)
227        const HASHMAP_ENTRY_OVERHEAD: usize = 24; // Approximate
228        const ARC_OVERHEAD: usize = 16; // Arc has refcount + pointer
229
230        let mut total = 0;
231
232        // HashMap structure overhead
233        total += self.map.capacity() * HASHMAP_ENTRY_OVERHEAD;
234
235        // String data + Arc overhead
236        for key in self.map.keys() {
237            total += ARC_OVERHEAD; // InternedString wrapper
238            total += ARC_OVERHEAD; // Key Arc<String>
239            total += ARC_OVERHEAD; // Value Arc<String>
240            total += key.as_str().len(); // String data (counted once, shared by key and value)
241            total += std::mem::size_of::<String>(); // String struct overhead
242        }
243
244        total
245    }
246
247    /// Shrink the hash map to fit the current number of entries
248    pub fn shrink_to_fit(&mut self) {
249        self.map.shrink_to_fit();
250    }
251}
252
253impl Default for StringInterner {
254    fn default() -> Self {
255        Self::new()
256    }
257}
258
259impl InternerStats {
260    /// Get a human-readable report of interning statistics
261    pub fn report(&self) -> String {
262        let hit_rate = if self.total_requests > 0 {
263            (self.cache_hits as f64 / self.total_requests as f64) * 100.0
264        } else {
265            0.0
266        };
267
268        format!(
269            "String Interning Statistics:\n\
270             - Total requests: {}\n\
271             - Cache hits: {} ({:.1}%)\n\
272             - Cache misses: {}\n\
273             - Unique strings: {}\n\
274             - Bytes saved: {} ({:.1} KB)",
275            self.total_requests,
276            self.cache_hits,
277            hit_rate,
278            self.cache_misses,
279            self.unique_strings,
280            self.bytes_saved,
281            self.bytes_saved as f64 / 1024.0
282        )
283    }
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289
290    #[test]
291    fn test_basic_interning() {
292        let mut interner = StringInterner::new();
293
294        let s1 = interner.intern("http://example.org/");
295        let s2 = interner.intern("http://example.org/");
296
297        // Should be the exact same Arc (pointer equality)
298        assert!(Arc::ptr_eq(&s1, &s2));
299        assert_eq!(interner.len(), 1);
300    }
301
302    #[test]
303    fn test_different_strings() {
304        let mut interner = StringInterner::new();
305
306        let s1 = interner.intern("http://example.org/a");
307        let s2 = interner.intern("http://example.org/b");
308
309        // Should be different Arcs
310        assert!(!Arc::ptr_eq(&s1, &s2));
311        assert_eq!(interner.len(), 2);
312    }
313
314    #[test]
315    fn test_stats() {
316        let mut interner = StringInterner::new();
317
318        interner.intern("test");
319        interner.intern("test");
320        interner.intern("test");
321        interner.intern("other");
322
323        let stats = interner.stats();
324        assert_eq!(stats.total_requests, 4);
325        assert_eq!(stats.cache_hits, 2); // "test" hit twice
326        assert_eq!(stats.cache_misses, 2); // "test" and "other" each missed once
327        assert_eq!(stats.unique_strings, 2);
328    }
329
330    #[test]
331    fn test_hit_rate() {
332        let mut interner = StringInterner::new();
333
334        interner.intern("a");
335        interner.intern("a");
336        interner.intern("b");
337        interner.intern("b");
338
339        // 4 total requests, 2 hits (50%)
340        assert_eq!(interner.hit_rate(), 0.5);
341    }
342
343    #[test]
344    fn test_common_namespaces() {
345        let interner = StringInterner::with_common_namespaces();
346
347        // Should have pre-populated common namespaces
348        assert!(interner.len() > 10);
349
350        // Stats should be reset after pre-population
351        assert_eq!(interner.stats().total_requests, 0);
352        assert_eq!(interner.stats().cache_hits, 0);
353    }
354
355    #[test]
356    fn test_intern_if_beneficial() {
357        let mut interner = StringInterner::new();
358
359        // Short string - should not intern
360        let result = interner.intern_if_beneficial("ab", 10);
361        assert!(matches!(result, Cow::Borrowed(_)));
362
363        // Long string - should intern
364        let result = interner.intern_if_beneficial("http://example.org/very/long/uri", 10);
365        assert!(matches!(result, Cow::Owned(_)));
366
367        // Common namespace - should always intern regardless of length
368        let result = interner.intern_if_beneficial("www.w3.org", 100);
369        assert!(matches!(result, Cow::Owned(_)));
370    }
371
372    #[test]
373    fn test_memory_usage() {
374        let mut interner = StringInterner::new();
375
376        interner.intern("short");
377        interner.intern("a much longer string that takes more memory");
378
379        let usage = interner.memory_usage();
380        assert!(usage > 0);
381        println!("Memory usage: {} bytes", usage);
382    }
383
384    #[test]
385    fn test_clear() {
386        let mut interner = StringInterner::new();
387
388        interner.intern("test1");
389        interner.intern("test2");
390        assert_eq!(interner.len(), 2);
391
392        interner.clear();
393        assert_eq!(interner.len(), 0);
394        assert_eq!(interner.stats().total_requests, 0);
395    }
396
397    #[test]
398    fn test_rdf_use_case() {
399        let mut interner = StringInterner::with_common_namespaces();
400
401        // Simulate parsing RDF with repeated predicates
402        let rdf_type = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
403
404        let initial_unique = interner.len();
405
406        // Intern the same predicate many times (simulates parsing many triples)
407        for _ in 0..1000 {
408            interner.intern(rdf_type);
409        }
410
411        // Should still have the same number of unique strings
412        assert_eq!(interner.len(), initial_unique);
413
414        // Should have very high hit rate
415        assert!(interner.hit_rate() > 0.95);
416
417        println!("{}", interner.stats().report());
418    }
419
420    #[test]
421    fn test_stats_report() {
422        let mut interner = StringInterner::new();
423
424        interner.intern("test");
425        interner.intern("test");
426        interner.intern("other");
427
428        let report = interner.stats().report();
429        assert!(report.contains("Total requests: 3"));
430        assert!(report.contains("Cache hits: 1"));
431        assert!(report.contains("Unique strings: 2"));
432    }
433}