Navigation: ← Dictionary Layer | DoubleArrayTrie | Algorithms Home
DynamicDawg is a Directed Acyclic Word Graph that supports runtime insertions and deletions while maintaining thread-safe access. Unlike static DAWG implementations, DynamicDawg allows the dictionary to evolve during application lifetime.
✅ Use DynamicDawg when:
⚠️ Consider alternatives when:
DoubleArrayTrie (3x faster)DoubleArrayTrieDynamicDawgCharA Directed Acyclic Word Graph is a compressed trie that shares common suffixes, not just prefixes.
Example: Terms ["car", "card", "cart", "star", "start"]
Regular Trie (prefix sharing only):
(root)
/ \
c s
| |
a t
| |
r a
/ \ |
d t r
/ \
t (nothing - "star")
DAWG (prefix AND suffix sharing):
(root)
/ \
c s
| |
a t
| |
r ─────┘ ← Shares "ar" suffix
/ \
d t
Space savings: DAWG nodes = ~50-70% of trie nodes for natural language.
Multiple prefixes can point to the same suffix:
"card" = c→a→r→d(final)
"cart" = c→a→r→t(final)
"hard" = h→a→r→d(final) ← Shares "r→d" with "card"
"hart" = h→a→r→t(final) ← Shares "r→t" with "cart"
This is achieved by hashing node signatures and reusing nodes with identical right languages.
Adding a term while maintaining minimality:
fn insert(&self, term: &str) {
let mut lock = self.inner.write(); // simplified pseudocode — the real backend is
// lock-free: it mutates a cloned snapshot and
// publishes it via CAS (see "Thread Safety" below)
// Traverse existing path
let mut node_idx = 0; // Root
let mut path = Vec::new();
for byte in term.bytes() {
path.push(node_idx);
// Find or create edge
node_idx = match lock.find_edge(node_idx, byte) {
Some(child_idx) => child_idx,
None => {
// Create new suffix
let new_suffix = lock.create_suffix(&term[pos..]);
lock.add_edge(node_idx, byte, new_suffix);
return;
}
};
}
// Mark final
lock.nodes[node_idx].is_final = true;
}
Complexity: $\mathcal{O}(m)$ where m = term length
Removing a term requires reference counting:
fn remove(&self, term: &str) -> bool {
let mut lock = self.inner.write();
// Traverse to term
let mut node_idx = 0;
let mut path = Vec::new();
for byte in term.bytes() {
path.push(node_idx);
node_idx = lock.find_edge(node_idx, byte)?;
}
if !lock.nodes[node_idx].is_final {
return false; // Term not in dictionary
}
// Mark as non-final
lock.nodes[node_idx].is_final = false;
// Decrement reference counts along path
for &idx in path.iter().rev() {
lock.nodes[idx].ref_count -= 1;
// Delete node if no longer referenced
if lock.nodes[idx].ref_count == 0 && !lock.nodes[idx].is_final {
lock.delete_node(idx);
} else {
break; // Still in use
}
}
lock.needs_compaction = true;
true
}
Complexity: $\mathcal{O}(m)$
Over time, deletions create orphaned branches. Compaction restores minimality:
pub fn compact(&self) {
let mut lock = self.inner.write();
if !lock.needs_compaction {
return;
}
// Rebuild suffix cache
lock.suffix_cache.clear();
lock.rebuild_suffix_cache();
// Merge equivalent nodes
lock.merge_equivalent_nodes();
lock.needs_compaction = false;
}
Complexity: $\mathcal{O}(n)$ where n = total nodes
When to compact:
pub struct DynamicDawg<V: DictionaryValue = ()> {
inner: Arc<DynamicDawgInner<V>>,
}
// `DynamicDawgInner` is the unit-generic lock-free DAWG core,
// `LockFreeDawg<u8, V>`. Published nodes are immutable. Readers retain one
// root revision; writers path-copy an affected route and publish the new
// GraphVersion through one ArcSwap compare_exchange (CAS) loop.
type DynamicDawgInner<V = ()> = LockFreeDawg<u8, V>;
┌─────────────────┬─────────────┬────────────────┐
│ Component │ Size │ Per Node │
├─────────────────┼─────────────┼────────────────┤
│ SmallVec edges │ Inline ≤4 │ ~16 bytes │
│ is_final │ 1 byte │ 1 byte │
│ ref_count │ 8 bytes │ 8 bytes │
│ value (Option) │ V or 1 byte │ Varies │
├─────────────────┼─────────────┼────────────────┤
│ Total per node │ ~25+ bytes │ ~25 bytes │
│ Overhead │ Arc │ 8 bytes total │
└─────────────────┴─────────────┴────────────────┘
Example: 10,000-term dictionary $\approx$ 250KB (nodes) + 32KB (suffix cache)
DynamicDawg uses Arc<...> internally (the lock-free LockFreeDawg core), making .clone() a shallow copy that shares all underlying data structures between clones:
use libdictenstein::dynamic_dawg::DynamicDawg;
let dict1 = DynamicDawg::from_iter(vec!["test", "testing"]);
let dict2 = dict1.clone(); // O(1) - only increments Arc refcount
// Both dict1 and dict2 point to the SAME underlying data
dict1.insert("new_term");
assert!(dict2.contains("new_term")); // ✅ Mutations visible through dict2!
// Term count reflects changes made via either clone
assert_eq!(dict1.len(), Some(3));
assert_eq!(dict2.len(), Some(3)); // Same count
| Property | Behavior | Impact |
|---|---|---|
| Time Complexity | $\mathcal{O}(1)$ | Single atomic increment |
| Space Complexity | $\mathcal{O}(1)$ | ~16 bytes (Arc pointer only) |
| Data Sharing | ✅ Complete | All clones share same node graph |
| Mutation Visibility | ✅ Global | Changes via any clone affect all |
| Thread Safety | ✅ Lock-free | Readers never block; writers publish via CAS |
| Independence | ❌ None | No isolation between clones |
The clone operation only increments an atomic reference counter:
pub struct DynamicDawg<V> {
inner: Arc<DynamicDawgInner<V>>, // ← Single Arc
}
// Cloning increments Arc's atomic refcount
let dict2 = dict1.clone();
// Equivalent to: Arc::clone(&dict1.inner)
// Cost: ~1-2 CPU cycles (atomic increment)
What gets cloned:
Memory allocation:
✅ Good use cases:
Multi-threaded access - Share across threads:
use std::thread;
let dict = DynamicDawg::from_iter(vec!["hello", "world"]);
let handles: Vec<_> = (0..4).map(|_| {
let dict_clone = dict.clone(); // Cheap clone for each thread
thread::spawn(move || {
// Each thread can read concurrently
dict_clone.contains("hello")
})
}).collect();
Storing in multiple data structures:
let mut map1 = HashMap::new();
let mut map2 = HashMap::new();
let dict = DynamicDawg::from_iter(vec!["term1", "term2"]);
map1.insert("key1", dict.clone());
map2.insert("key2", dict.clone()); // Same underlying data
Convenience aliases:
let system_dict = DynamicDawg::from_iter(vec!["system"]);
let dict = system_dict.clone(); // Short alias
❌ Bad use cases (common mistakes):
Expecting independent copies:
let dict1 = DynamicDawg::from_iter(vec!["original"]);
let dict2 = dict1.clone();
dict1.insert("modified");
// ❌ WRONG: Expecting dict2 to still have only "original"
// ✅ REALITY: dict2 also contains "modified"
Avoiding mutation visibility:
let dict1 = build_dictionary();
let dict2 = dict1.clone(); // ❌ Won't create independent copy
modify_dictionary(&dict1);
// dict2 sees all modifications - they share data!
Creating snapshots:
let dict = DynamicDawg::from_iter(vec!["v1"]);
let snapshot = dict.clone(); // ❌ NOT a snapshot!
dict.insert("v2");
// "snapshot" now also contains "v2" - not a true snapshot
If you need independent copies where modifications don't affect other instances, clone() is insufficient. Options include:
Option 1: Serialize/Deserialize
use libdictenstein::serialization::{BincodeSerializer, DictionarySerializer};
// Create deep copy via serialization (`Vec<u8>: Write`, `&[u8]: Read`)
let mut bytes = Vec::new();
BincodeSerializer::serialize(&dict1, &mut bytes)?;
let dict2: DynamicDawg = BincodeSerializer::deserialize(&bytes[..])?;
// Now dict1 and dict2 are truly independent
dict1.insert("new");
assert!(!dict2.contains("new")); // ✅ Independent
Option 2: Rebuild from terms
// Extract all terms
let terms: Vec<String> = dict1.iter().collect();
// Build new independent dictionary
let dict2 = DynamicDawg::from_iter(terms);
// dict2 is now completely independent
Cost comparison:
| Method | Time | Space | Independence |
|---|---|---|---|
.clone() | $\mathcal{O}(1)$ | $\mathcal{O}(1)$ | ❌ Shared |
| Serialize/Deserialize | $\mathcal{O}(n)$ | $\mathcal{O}(n)$ | ✅ Full |
| Rebuild from terms | $\mathcal{O}(n \cdot m)$ | $\mathcal{O}(n)$ | ✅ Full |
Different dictionary implementations have different clone semantics:
| Dictionary | Clone Type | Cost | Shared Data? |
|---|---|---|---|
| DynamicDawg | Shallow (Arc) | $\mathcal{O}(1)$ | ✅ Yes |
| DynamicDawgChar | Shallow (Arc) | $\mathcal{O}(1)$ | ✅ Yes |
| PathMapDictionary | Shallow (Arc) | $\mathcal{O}(1)$ | ✅ Yes |
| DoubleArrayTrie | Deep copy | $\mathcal{O}(n)$ | ❌ No |
| DoubleArrayTrieChar | Deep copy | $\mathcal{O}(n)$ | ❌ No |
Why the difference?
The Arc-based clone enables safe concurrent access patterns:
use std::sync::Arc;
use std::thread;
let dict = DynamicDawg::from_iter(vec!["concurrent", "access"]);
// Multiple concurrent readers (fast - no blocking)
let readers: Vec<_> = (0..10).map(|i| {
let dict = dict.clone();
thread::spawn(move || {
dict.contains(&format!("term{}", i)) // Many readers OK
})
}).collect();
// Concurrent writer (lock-free; does not block readers)
let writer = {
let dict = dict.clone();
thread::spawn(move || {
dict.insert("new_term") // Lock-free insertion (CAS publication)
})
};
Concurrency semantics:
compare_exchange (CAS) loopsinsert(), remove(), union_with(), compact()contains(), get_value(), len(), iterationPerformance impact (historical):
RwLock-based releases measured ~10-20ns read-lock and ~50-100ns write-lock overheadArcSwap load, and writes a CAS publicationKey Takeaways:
.clone() creates a shallow copy - all clones share the same data\mathcal{O}(1)$ time and space - just increments atomic reference countcompare_exchange (CAS)\mathcal{O}(n)$ cost)Most nodes have $\le 4$ edges. SmallVec avoids heap allocation:
// Inline storage for ≤4 edges (stack allocated)
edges: SmallVec<[(u8, usize); 4]>
// Typical case: 2 edges → no heap allocation
// Rare case: >4 edges → heap allocation
Impact: 30-40% faster node access
Hash node signatures to detect identical suffixes:
fn compute_signature(node: &DawgNode) -> u64 {
let mut hasher = FxHasher::default();
node.is_final.hash(&mut hasher);
for (label, child_idx) in &node.edges {
label.hash(&mut hasher);
child_signature(child_idx).hash(&mut hasher);
}
hasher.finish()
}
// Check cache before creating new nodes
if let Some(&existing_idx) = suffix_cache.get(&signature) {
return existing_idx; // Reuse existing
}
Impact: 20-40% space reduction
Fast negative lookup rejection:
fn contains(&self, term: &str) -> bool {
let lock = self.inner.read();
// Fast rejection (no DAWG traversal needed)
if let Some(ref bloom) = lock.bloom_filter {
if !bloom.might_contain(term) {
return false; // Definitely not present
}
}
// Full DAWG traversal
lock.traverse(term)
}
Impact: 5-10x faster negative lookups
Defer expensive minimization until threshold reached:
// Minimize only when node count grows significantly
if nodes.len() > last_minimized * auto_minimize_threshold {
self.minimize();
last_minimized = nodes.len();
}
Impact: Amortizes $\mathcal{O}(n)$ cost over many insertions
DynamicDawg provides multiple constructors for different initialization patterns, enabling both incremental construction and bulk loading scenarios.
| Constructor | Complexity | Use Case | Thread-Safe |
|---|---|---|---|
new() | $\mathcal{O}(1)$ | Empty start, incremental | ✅ |
from_iter() | $\mathcal{O}(n \cdot m)$ | Bulk load from iterator | ✅ |
from_terms() | $\mathcal{O}(n \cdot m)$ | Simple term list | ✅ |
insert_with_value() | $\mathcal{O}(m)$ amortized | Per-term values | ✅ |
Where n = number of terms, m = average term length
Create an empty dictionary for incremental population:
use libdictenstein::dynamic_dawg::DynamicDawg;
// Create empty dictionary
let dict: DynamicDawg = DynamicDawg::new();
// Incrementally add terms
dict.insert("hello");
dict.insert("world");
// Or with values
let valued_dict: DynamicDawg<u32> = DynamicDawg::new();
valued_dict.insert_with_value("hello", 100);
valued_dict.insert_with_value("world", 200);
Characteristics:
\mathcal{O}(1)$ - Allocates minimal structureWhen to use:
Build dictionary from any iterator over string-like items:
use libdictenstein::dynamic_dawg::DynamicDawg;
// From Vec
let terms = vec!["apple", "banana", "cherry"];
let dict = DynamicDawg::from_iter(terms);
// From HashSet
use std::collections::HashSet;
let term_set: HashSet<&str> = ["dog", "cat", "bird"].iter().copied().collect();
let dict = DynamicDawg::from_iter(term_set);
// From file lines
use std::fs::File;
use std::io::{BufRead, BufReader};
let file = File::open("dictionary.txt")?;
let lines = BufReader::new(file).lines().filter_map(|l| l.ok());
let dict = DynamicDawg::from_iter(lines);
Characteristics:
\mathcal{O}(n \cdot m)$ where n=terms, m=avg lengthPerformance tip:
// Sort terms first for better performance
let mut terms = vec!["zebra", "apple", "mango"];
terms.sort_unstable(); // ~10-15% faster construction
let dict = DynamicDawg::from_iter(terms);
Convenience wrapper for common case of Vec/slice of terms:
use libdictenstein::dynamic_dawg::DynamicDawg;
// Direct from slice
let dict = DynamicDawg::from_terms(&["test", "testing", "tester"]);
// From Vec
let terms = vec!["hello".to_string(), "world".to_string()];
let dict = DynamicDawg::from_terms(terms);
Equivalent to from_iter() but more concise for simple cases.
Insert terms with associated metadata (frequencies, IDs, etc.):
use libdictenstein::dynamic_dawg::DynamicDawg;
// Example: Term frequencies
let dict: DynamicDawg<u32> = DynamicDawg::new();
dict.insert_with_value("the", 1000000); // Very common
dict.insert_with_value("hello", 50000); // Common
dict.insert_with_value("xylophone", 100); // Rare
// Example: Context IDs (for code completion)
type ContextId = u32;
let dict: DynamicDawg<Vec<ContextId>> = DynamicDawg::new();
dict.insert_with_value("println", vec![1, 2, 3]); // Visible in contexts 1,2,3
dict.insert_with_value("my_func", vec![42]); // Only in context 42
// Retrieve values
if let Some(freq) = dict.get_value("the") {
println!("Frequency: {}", freq); // 1000000
}
Value type requirements:
DictionaryValue traitClone + Send + Sync + 'staticu32, String, Vec<T>, custom structsPerformance (10,000 terms, Intel Xeon E5-2699 v3 @ 2.30GHz):
| Method | Time | Memory Peak | Notes |
|---|---|---|---|
new() + inserts | ~8.2ms | ~250KB | Sequential, more lock overhead |
from_iter() | ~4.1ms | ~250KB | Bulk construction, less overhead |
from_terms() | ~4.1ms | ~250KB | Same as from_iter |
| Pre-sorted input | ~3.5ms | ~250KB | 15% faster due to cache locality |
Memory usage (varies with term count and length):
Small (1K terms): ~30KB
Medium (10K terms): ~250KB
Large (100K terms): ~2.5MB
Very large (1M terms): ~25MB
1. Choose the right constructor:
// ✅ Good: Bulk load with from_iter()
let dict = DynamicDawg::from_iter(large_term_list);
// ❌ Avoid: Many individual inserts when you have all data
let dict = DynamicDawg::new();
for term in large_term_list {
dict.insert(term); // Slower due to per-insert overhead
}
2. Pre-sort for performance:
let mut terms = load_terms();
terms.sort_unstable(); // 10-15% speedup
let dict = DynamicDawg::from_iter(terms);
3. Choose appropriate value types:
// ✅ Good: Use u32 for IDs (4 bytes)
let dict: DynamicDawg<u32> = DynamicDawg::new();
// ⚠️ Acceptable but larger: Use String for metadata
let dict: DynamicDawg<String> = DynamicDawg::new(); // Higher memory
// ✅ Best for code completion: Vec<ContextId>
let dict: DynamicDawg<Vec<u32>> = DynamicDawg::new();
4. Error handling with file input:
use std::fs::File;
use std::io::{BufRead, BufReader};
fn load_dictionary(path: &Path) -> Result<DynamicDawg, Box<dyn std::error::Error>> {
let file = File::open(path)?;
let reader = BufReader::new(file);
let terms: Vec<String> = reader
.lines()
.filter_map(|line| line.ok())
.filter(|line| !line.trim().is_empty()) // Skip empty
.collect();
Ok(DynamicDawg::from_iter(terms))
}
For workspace-scale dictionaries (100+ documents):
use rayon::prelude::*;
// Build per-document dictionaries in parallel
let dicts: Vec<DynamicDawg<Vec<u32>>> = documents
.par_iter()
.map(|(ctx_id, doc)| {
let terms = extract_terms(doc);
let dict = DynamicDawg::new();
for term in terms {
dict.insert_with_value(term, vec![*ctx_id]);
}
dict
})
.collect();
// Merge using union_with (see Union Operations section)
// Full pattern documented in Contextual Completion guide
→ See Parallel Workspace Indexing for complete pattern with ~150× speedup.
DynamicDawg provides comprehensive methods for querying dictionary contents and metadata.
| Method | Returns | Complexity | Thread-Safe | Description |
|---|---|---|---|---|
contains(term) | bool | $\mathcal{O}(m)$ | ✅ Yes | Check if term exists |
get_value(term) | Option<V> | $\mathcal{O}(m)$ | ✅ Yes | Retrieve associated value |
len() | Option<usize> | $\mathcal{O}(1)$ | ✅ Yes | Get term count (Dictionary trait) |
is_empty() | bool | $\mathcal{O}(1)$ | ✅ Yes | Check if empty (Dictionary trait) |
term_count() | usize | $\mathcal{O}(1)$ | ✅ Yes | Get exact term count |
node_count() | usize | $\mathcal{O}(1)$ | ✅ Yes | Get internal node count |
needs_compaction() | bool | $\mathcal{O}(1)$ | ✅ Yes | Check if compaction recommended |
root() | DynamicDawgNode | $\mathcal{O}(1)$ | ✅ Yes | Get root node for traversal |
Note: m = term length (in bytes).
Check if a term exists in the dictionary.
Signature:
pub fn contains(&self, term: &str) -> bool
Performance:
\mathcal{O}(m)$ where m is term lengthExample:
use libdictenstein::dynamic_dawg::DynamicDawg;
let dict = DynamicDawg::from_terms(vec!["cat", "dog"]);
assert!(dict.contains("cat"));
assert!(dict.contains("dog"));
assert!(!dict.contains("bird"));
assert!(!dict.contains("ca")); // Prefix doesn't count
Bloom Filter Optimization (enabled by default):
// With Bloom filter (default)
let dict = DynamicDawg::new(); // Bloom filter auto-enabled
dict.insert("term1");
dict.insert("term2");
// Fast negative lookup (~100× faster than full traversal)
assert!(!dict.contains("nonexistent")); // Bloom filter rejects immediately
// Custom Bloom filter capacity
let dict = DynamicDawg::with_config(2.0, Some(10_000));
// Optimized for ~10,000 terms
Thread Safety:
use std::sync::Arc;
use std::thread;
let dict = Arc::new(DynamicDawg::from_terms(vec!["hello", "world"]));
// Concurrent reads are safe
let handles: Vec<_> = (0..10)
.map(|_| {
let d = Arc::clone(&dict);
thread::spawn(move || d.contains("hello"))
})
.collect();
for h in handles {
assert!(h.join().unwrap());
}
Get the value associated with a term (for value-storing dictionaries).
Signature:
pub fn get_value(&self, term: &str) -> Option<V>
where
V: Clone + Send + Sync + 'static
Returns:
Some(value) if term exists and has associated valueNone if term doesn't exist or has no valuePerformance:
\mathcal{O}(m)$ where m is term lengthExample:
use libdictenstein::dynamic_dawg::DynamicDawg;
// Dictionary with integer values
let dict: DynamicDawg<u32> = DynamicDawg::new();
dict.insert_with_value("apple", 42);
dict.insert_with_value("banana", 100);
assert_eq!(dict.get_value("apple"), Some(42));
assert_eq!(dict.get_value("banana"), Some(100));
assert_eq!(dict.get_value("cherry"), None); // Doesn't exist
// Dictionary with vector values (contextual completion)
let dict: DynamicDawg<Vec<u32>> = DynamicDawg::new();
dict.insert_with_value("function", vec![1, 2, 3]); // Context IDs
dict.insert_with_value("variable", vec![2, 4]);
assert_eq!(dict.get_value("function"), Some(vec![1, 2, 3]));
assert_eq!(dict.get_value("variable"), Some(vec![2, 4]));
Value Type Requirements:
// ✓ Valid value types
DynamicDawg<()> // Unit type (no values)
DynamicDawg<u32> // Primitive
DynamicDawg<String> // Owned string
DynamicDawg<Vec<u32>> // Vector (for contextual completion)
DynamicDawg<Arc<Data>> // Shared data
// ✗ Invalid (doesn't implement DictionaryValue trait)
// DynamicDawg<&str> // References not allowed
// DynamicDawg<Rc<Data>> // !Send
Standard collection size queries via Dictionary trait.
Signatures:
fn len(&self) -> Option<usize> // Dictionary trait
fn is_empty(&self) -> bool // Dictionary trait
Returns:
len(): Some(count) for DynamicDawg (exact count always available)is_empty(): true if no terms, false otherwisePerformance:
\mathcal{O}(1)$ - stored counterExample:
use libdictenstein::{Dictionary, dynamic_dawg::DynamicDawg};
let dict = DynamicDawg::new();
assert_eq!(dict.len(), Some(0));
assert!(dict.is_empty());
dict.insert("test");
assert_eq!(dict.len(), Some(1));
assert!(!dict.is_empty());
dict.insert("another");
assert_eq!(dict.len(), Some(2));
dict.remove("test");
assert_eq!(dict.len(), Some(1));
Get exact number of terms (DynamicDawg-specific method, bypasses Option wrapper).
Signature:
pub fn term_count(&self) -> usize
Performance:
\mathcal{O}(1)$ - stored counterExample:
let dict = DynamicDawg::from_terms(vec!["apple", "banana", "cherry"]);
assert_eq!(dict.term_count(), 3);
dict.remove("banana");
assert_eq!(dict.term_count(), 2);
// Compare with Dictionary::len()
assert_eq!(dict.len(), Some(2)); // Wrapped in Option
assert_eq!(dict.term_count(), 2); // Direct usize
Use Cases:
Get number of internal nodes (useful for performance analysis).
Signature:
pub fn node_count(&self) -> usize
Returns: Total number of DAWG nodes (including non-final nodes)
Performance:
\mathcal{O}(1)$ - stored counterExample:
let dict = DynamicDawg::new();
assert_eq!(dict.node_count(), 1); // Just root node
dict.insert("cat");
dict.insert("car");
dict.insert("card");
// Nodes: root, 'c', 'a', 'r'/'t', 'd'
// Note: Exact count depends on suffix sharing
let nodes = dict.node_count();
assert!(nodes >= 4); // At least one node per unique character position
// After compaction, node count may decrease
let removed = dict.compact();
assert_eq!(removed, 0); // Already minimal
Interpretation:
node_count() $\approx$ term_count() → Good compression (lots of sharing)node_count() >> term_count() → Poor compression (deletions without compaction)Monitoring Example:
let dict = DynamicDawg::new();
for term in generate_terms(10_000) {
dict.insert(term);
}
// Check compression ratio
let ratio = dict.node_count() as f64 / dict.term_count() as f64;
println!("Nodes per term: {:.2}", ratio);
// Typical: 0.6-0.8 for natural language
// Lower is better (more suffix sharing)
Check if deletion has left orphaned nodes requiring compaction.
Signature:
pub fn needs_compaction(&self) -> bool
Returns:
true if deletions have occurred and compaction recommendedfalse if structure is minimal or only insertions occurredPerformance:
\mathcal{O}(1)$ - flag checkExample:
let dict = DynamicDawg::from_terms(vec!["test", "testing", "tested"]);
assert!(!dict.needs_compaction()); // Freshly built
dict.remove("tested");
assert!(dict.needs_compaction()); // Deletion creates orphaned nodes
let removed = dict.compact();
assert!(!dict.needs_compaction()); // Compacted
assert!(removed > 0); // Some nodes were removed
Best Practices:
// Pattern: Batch deletions + single compaction
let dict = DynamicDawg::from_terms(generate_terms(10_000));
// Delete many terms
for term in terms_to_delete {
dict.remove(&term);
}
// Check before compacting
if dict.needs_compaction() {
let removed_nodes = dict.compact();
println!("Compaction freed {} nodes", removed_nodes);
}
Performance Guidance:
\mathcal{O}(n)$ where n = total charactersGet the root node for manual graph traversal (Dictionary trait method).
Signature:
fn root(&self) -> DynamicDawgNode // From Dictionary trait
Returns: Node at root of DAWG (entry point for traversal)
Performance:
\mathcal{O}(1)$Example:
use libdictenstein::{Dictionary, DictionaryNode};
let dict = DynamicDawg::from_terms(vec!["cat", "car", "card"]);
// Manual traversal
let root = dict.root();
assert!(!root.is_final()); // Root typically not final
// Navigate to "car"
if let Some(c_node) = root.transition(b'c') {
if let Some(a_node) = c_node.transition(b'a') {
if let Some(r_node) = a_node.transition(b'r') {
assert!(r_node.is_final()); // "car" exists
// Check if "card" exists
if let Some(d_node) = r_node.transition(b'd') {
assert!(d_node.is_final()); // "card" exists
}
}
}
}
Zipper-Based Traversal (preferred for complex navigation):
use libdictenstein::zipper::DictZipper;
use libdictenstein::dynamic_dawg::DynamicDawgZipper;
let dict: DynamicDawg<u32> = DynamicDawg::new();
dict.insert_with_value("hello", 42);
let zipper = DynamicDawgZipper::new_from_dict(&dict);
// Traverse with zipper
let result = zipper
.descend(b'h')
.and_then(|z| z.descend(b'e'))
.and_then(|z| z.descend(b'l'))
.and_then(|z| z.descend(b'l'))
.and_then(|z| z.descend(b'o'));
if let Some(final_zipper) = result {
assert!(final_zipper.is_final());
assert_eq!(final_zipper.value(), Some(42));
}
Accessor Method Latencies (10K term dictionary):
| Method | Latency | Throughput | Notes |
|---|---|---|---|
contains() (hit) | ~250ns | 4M ops/sec | Full traversal |
contains() (miss, Bloom) | ~50ns | 20M ops/sec | Bloom rejection |
get_value() | ~260ns | 3.8M ops/sec | Traversal + clone |
len() / term_count() | ~5ns | 200M ops/sec | Counter read |
is_empty() | ~5ns | 200M ops/sec | Counter comparison |
node_count() | proportional to live graph | — | Unique-node traversal |
needs_compaction() | ~2ns | 500M ops/sec | Flag read |
root() | ~3ns | 333M ops/sec | Return node 0 |
Concurrency:
compare_exchange (CAS)Memory Overhead:
fn insert_with_sharing(&mut self, term: &[u8], value: Option<V>) {
let mut node_idx = 0;
for (i, &byte) in term.iter().enumerate() {
// Try to follow existing edge
if let Some(child_idx) = self.find_edge(node_idx, byte) {
node_idx = child_idx;
continue;
}
// Need to create new branch
// Check if remainder matches existing suffix
let remainder = &term[i..];
let signature = self.compute_suffix_signature(remainder, value.clone());
if let Some(&cached_idx) = self.suffix_cache.get(&signature) {
// Reuse existing suffix!
self.add_edge(node_idx, byte, cached_idx);
self.nodes[cached_idx].ref_count += 1;
return;
}
// Create new suffix
let new_idx = self.create_suffix(remainder, value);
self.add_edge(node_idx, byte, new_idx);
self.suffix_cache.insert(signature, new_idx);
return;
}
// Mark final
self.nodes[node_idx].is_final = true;
self.nodes[node_idx].value = value;
}
fn remove_with_ref_counting(&mut self, term: &[u8]) -> bool {
// Traverse and record path
let mut path = Vec::new();
let mut node_idx = 0;
for &byte in term {
path.push((node_idx, byte));
node_idx = self.find_edge(node_idx, byte)?;
}
if !self.nodes[node_idx].is_final {
return false;
}
// Unmark final
self.nodes[node_idx].is_final = false;
self.nodes[node_idx].value = None;
// Decrement reference counts
for (parent_idx, label) in path.iter().rev() {
let child_idx = self.find_edge(*parent_idx, *label).unwrap();
self.nodes[child_idx].ref_count -= 1;
// Delete if unreferenced
if self.nodes[child_idx].ref_count == 0 &&
!self.nodes[child_idx].is_final &&
self.nodes[child_idx].edges.is_empty() {
self.remove_edge(*parent_idx, *label);
} else {
break; // Still in use
}
}
self.needs_compaction = true;
true
}
The union_with() and union_replace() methods enable merging two DynamicDawg dictionaries with custom value combination logic. This is essential for scenarios like:
Key Characteristics:
insert_with_value()\mathcal{O}(n \cdot m)$ traversal with minimal memory overheadCombines two dictionaries by inserting all terms from the source dictionary, applying a custom merge function when values conflict.
Signature:
fn union_with<F>(&self, other: &Self, merge_fn: F) -> usize
where
F: Fn(&Self::Value, &Self::Value) -> Self::Value,
Self::Value: Clone
Parameters:
other: Source dictionary to merge frommerge_fn: Function (existing_value, new_value) -> merged_value for conflictsotherAlgorithm: Depth-First Search (DFS) traversal
(node_idx=0, path=Vec::new())(node_idx, path) from stackselfmerge_fn and updateComplexity:
\mathcal{O}(n \cdot m)$ where n = terms in other, m = average term length
\mathcal{O}(n \cdot m)$ for DFS traversal\mathcal{O}(m)$ per term for insert_with_value()\mathcal{O}(d)$ where d = maximum trie depth (typically < 50)
Merge term counts by summing conflicting values:
use libdictenstein::dynamic_dawg::DynamicDawg;
use libdictenstein::MutableMappedDictionary;
// First dataset: word frequencies
let dict1: DynamicDawg<u32> = DynamicDawg::new();
dict1.insert_with_value("apple", 10);
dict1.insert_with_value("banana", 5);
dict1.insert_with_value("cherry", 3);
// Second dataset: more frequencies
let dict2: DynamicDawg<u32> = DynamicDawg::new();
dict2.insert_with_value("apple", 7); // Overlap - will sum
dict2.insert_with_value("banana", 2); // Overlap - will sum
dict2.insert_with_value("date", 4); // New entry
// Merge by summing counts
let processed = dict1.union_with(&dict2, |left, right| left + right);
// Results:
// - apple: 17 (10 + 7)
// - banana: 7 (5 + 2)
// - cherry: 3 (unchanged)
// - date: 4 (new)
assert_eq!(dict1.get_value("apple"), Some(17));
assert_eq!(dict1.get_value("date"), Some(4));
assert_eq!(processed, 3); // Processed 3 terms from dict2
Merge lists of associated IDs, eliminating duplicates:
use libdictenstein::dynamic_dawg::DynamicDawg;
use libdictenstein::MutableMappedDictionary;
// First dictionary: terms with associated document IDs
let dict1: DynamicDawg<Vec<u32>> = DynamicDawg::new();
dict1.insert_with_value("algorithm", vec![1, 2, 5]);
dict1.insert_with_value("database", vec![3, 7]);
// Second dictionary: more document associations
let dict2: DynamicDawg<Vec<u32>> = DynamicDawg::new();
dict2.insert_with_value("algorithm", vec![2, 4, 5]); // Overlap: [2,5]
dict2.insert_with_value("distributed", vec![6, 8]);
// Merge by concatenating and deduplicating
dict1.union_with(&dict2, |left, right| {
let mut merged = left.clone();
merged.extend(right.clone());
merged.sort_unstable();
merged.dedup();
merged
});
// Results:
// - algorithm: [1, 2, 4, 5] (merged and deduplicated)
// - database: [3, 7] (unchanged)
// - distributed: [6, 8] (new)
assert_eq!(dict1.get_value("algorithm"), Some(vec![1, 2, 4, 5]));
Keep the highest value when terms conflict:
use libdictenstein::dynamic_dawg::DynamicDawg;
use libdictenstein::MutableMappedDictionary;
// Dictionary 1: initial scores
let dict1: DynamicDawg<i32> = DynamicDawg::new();
dict1.insert_with_value("performance", 85);
dict1.insert_with_value("reliability", 92);
// Dictionary 2: updated scores
let dict2: DynamicDawg<i32> = DynamicDawg::new();
dict2.insert_with_value("performance", 90); // Higher score
dict2.insert_with_value("reliability", 88); // Lower score
dict2.insert_with_value("security", 95); // New metric
// Keep maximum value for conflicts
dict1.union_with(&dict2, |left, right| (*left).max(*right));
// Results:
// - performance: 90 (max of 85, 90)
// - reliability: 92 (max of 92, 88)
// - security: 95 (new)
assert_eq!(dict1.get_value("performance"), Some(90));
assert_eq!(dict1.get_value("reliability"), Some(92));
Demonstrates correct behavior with terms sharing common prefixes:
use libdictenstein::dynamic_dawg::DynamicDawg;
use libdictenstein::MutableMappedDictionary;
// Dictionary with "test" prefix family
let dict1: DynamicDawg<u32> = DynamicDawg::new();
dict1.insert_with_value("test", 1);
dict1.insert_with_value("testing", 2);
dict1.insert_with_value("tester", 3);
// More "test" variants
let dict2: DynamicDawg<u32> = DynamicDawg::new();
dict2.insert_with_value("test", 10); // Conflict
dict2.insert_with_value("tested", 4); // New, shares "test" prefix
dict2.insert_with_value("testimony", 5); // New, shares "test" prefix
dict1.union_with(&dict2, |left, right| left + right);
// All terms preserved correctly despite shared prefixes
assert_eq!(dict1.len().unwrap(), 5);
assert_eq!(dict1.get_value("test"), Some(11)); // 1 + 10
assert_eq!(dict1.get_value("tested"), Some(4)); // New
assert_eq!(dict1.get_value("testimony"), Some(5)); // New
Convenience method equivalent to union_with(other, |_, right| right.clone()). Keeps values from other when terms conflict.
Signature:
fn union_replace(&self, other: &Self) -> usize
where
Self::Value: Clone
Example:
use libdictenstein::dynamic_dawg::DynamicDawg;
use libdictenstein::MutableMappedDictionary;
let dict1: DynamicDawg<&str> = DynamicDawg::new();
dict1.insert_with_value("version", "1.0");
dict1.insert_with_value("status", "beta");
let dict2: DynamicDawg<&str> = DynamicDawg::new();
dict2.insert_with_value("version", "2.0"); // Override
dict2.insert_with_value("author", "alice"); // New
// Replace conflicting values with those from dict2
dict1.union_replace(&dict2);
// Results:
// - version: "2.0" (replaced)
// - status: "beta" (unchanged)
// - author: "alice" (new)
assert_eq!(dict1.get_value("version"), Some("2.0"));
assert_eq!(dict1.get_value("status"), Some("beta"));
The union operation uses iterative depth-first search to traverse all terms in the source dictionary:
// Simplified pseudocode
fn union_with<F>(&self, other: &Self, merge_fn: F) -> usize {
let other_inner = other.inner.read();
let mut processed = 0;
// Initialize DFS with root: (node_index, accumulated_path)
let mut stack: Vec<(usize, Vec<u8>)> = vec![(0, Vec::new())];
while let Some((node_idx, path)) = stack.pop() {
let node = &other_inner.nodes[node_idx];
// Process final nodes (complete terms)
if node.is_final {
if let Ok(term) = std::str::from_utf8(&path) {
processed += 1;
if let Some(other_value) = &node.value {
if let Some(self_value) = self.get_value(term) {
// Term exists - merge values
let merged = merge_fn(&self_value, other_value);
self.insert_with_value(term, merged);
} else {
// New term - insert directly
self.insert_with_value(term, other_value.clone());
}
}
}
}
// Push children onto stack (reversed for consistent order)
for &(label, target_idx) in node.edges.iter().rev() {
let mut child_path = path.clone();
child_path.push(label);
stack.push((target_idx, child_path));
}
}
processed
}
Why Iterative DFS?
\mathcal{O}(d)$ space vs $\mathcal{O}(n)$ for recursionWhy Use insert_with_value()?
The implementation delegates to insert_with_value() rather than manipulating nodes directly. This design choice:
Trade-off: Slightly slower than direct node manipulation, but correctness > speed for complex structures.
| Operation | Time Complexity | Space Complexity | Typical Performance (10K terms) |
|---|---|---|---|
union_with() | $\mathcal{O}(n \cdot m)$ | $\mathcal{O}(d)$ | ~50ms |
union_replace() | $\mathcal{O}(n \cdot m)$ | $\mathcal{O}(d)$ | ~50ms |
| DFS traversal | $\mathcal{O}(n)$ | $\mathcal{O}(d)$ | ~20ms |
| Per-term insertion | $\mathcal{O}(m)$ | $\mathcal{O}(1)$ amortized | ~2-5µs |
Variables:
Memory Profile:
Stack size: ~200-2000 bytes (depth × 40 bytes per frame)
Peak allocation: O(m) for path accumulation
No heap allocations during traversal (Vec reused)
Benchmark Results (Intel Xeon E5-2699 v3 @ 2.30GHz):
| Dictionary Size | union_with() | Throughput |
|---|---|---|
| 1,000 terms | 4.2ms | 238K terms/s |
| 10,000 terms | 48ms | 208K terms/s |
| 100,000 terms | 520ms | 192K terms/s |
Note: Performance includes merge function execution. Simple operations (e.g., sum) add minimal overhead.
✅ Use union_with() when:
✅ Use union_replace() when:
⚠️ Consider alternatives when:
from_terms_with_values()for (term, value) in dict2.iter() { dict1.insert_with_value(term, value); }Union operations are fully thread-safe (lock-free reads; CAS-published writes):
use std::sync::Arc;
use std::thread;
let dict1 = Arc::new(DynamicDawg::new());
let dict2 = Arc::new(DynamicDawg::new());
// Populate dictionaries from multiple threads
let handles: Vec<_> = (0..4).map(|i| {
let d1 = Arc::clone(&dict1);
let d2 = Arc::clone(&dict2);
thread::spawn(move || {
if i % 2 == 0 {
d1.insert_with_value(&format!("term_{}", i), i);
} else {
d2.insert_with_value(&format!("term_{}", i), i);
}
})
}).collect();
for h in handles { h.join().unwrap(); }
// Merge from any thread
dict1.union_with(&dict2, |a, b| a + b);
Concurrency: Union traverses an immutable snapshot of other while publishing lock-free compare_exchange (CAS) insertions into self:
self never block (readers load an atomic snapshot)other are always safeself contend only at the CAS boundary (retry on conflict)For high-concurrency scenarios, consider:
use libdictenstein::dynamic_dawg::DynamicDawg;
// Create empty DAWG
let dict = DynamicDawg::new();
// Insert terms
dict.insert("test");
dict.insert("testing");
dict.insert("tested");
assert!(dict.contains("test"));
assert_eq!(dict.len(), Some(3));
// Remove term
dict.remove("tested");
assert!(!dict.contains("tested"));
assert_eq!(dict.len(), Some(2));
use libdictenstein::dynamic_dawg::DynamicDawg;
use libdictenstein::MappedDictionary;
let dict: DynamicDawg<u32> = DynamicDawg::new();
// Insert with values
dict.insert_with_value("test", 1);
dict.insert_with_value("testing", 2);
// Query values
assert_eq!(dict.get_value("test"), Some(1));
assert_eq!(dict.get_value("testing"), Some(2));
// Remove preserves other terms
dict.remove("test");
assert_eq!(dict.get_value("testing"), Some(2));
use libdictenstein::dynamic_dawg::DynamicDawg;
let dict = DynamicDawg::from_terms(vec![
"algorithm", "approximate", "automaton"
]);
// Add new terms at runtime
dict.insert("analysis");
assert!(dict.contains("algorithm"));
assert!(dict.contains("analysis"));
use libdictenstein::dynamic_dawg::DynamicDawg;
use std::sync::Arc;
use std::thread;
let dict = Arc::new(DynamicDawg::from_terms(vec!["initial"]));
// Spawn writer thread
let dict_writer = Arc::clone(&dict);
let writer = thread::spawn(move || {
dict_writer.insert("new_term");
});
// Spawn reader threads
let handles: Vec<_> = (0..4).map(|_| {
let dict_reader = Arc::clone(&dict);
thread::spawn(move || {
dict_reader.contains("initial")
})
}).collect();
writer.join().unwrap();
for handle in handles {
assert!(handle.join().unwrap());
}
use libdictenstein::dynamic_dawg::DynamicDawg;
let dict = DynamicDawg::from_terms(vec![
"test1", "test2", "test3", "test4", "test5"
]);
println!("Before deletion: {} nodes", dict.node_count());
// Remove many terms
for i in 1..=4 {
dict.remove(&format!("test{}", i));
}
println!("After deletion: {} nodes (may have orphans)", dict.node_count());
// Compact to restore minimality
dict.compact();
println!("After compaction: {} nodes", dict.node_count());
use libdictenstein::dynamic_dawg::DynamicDawg;
use liblevenshtein::levenshtein::Algorithm;
use liblevenshtein::levenshtein_automaton::LevenshteinAutomaton;
let dict = DynamicDawg::from_terms(vec!["test", "testing"]);
// Fuzzy search
let automaton = LevenshteinAutomaton::new("tset", 1, Algorithm::Standard);
let results: Vec<String> = automaton.query(&dict).collect();
println!("{:?}", results); // ["test"]
// Add term dynamically
dict.insert("tester");
// Search again (sees new term)
let results: Vec<String> = automaton.query(&dict).collect();
println!("{:?}", results); // ["test", "tester"]
| Operation | Complexity | Notes |
|---|---|---|
| Insert | $\mathcal{O}(m)$ | m = term length |
| Remove | $\mathcal{O}(m)$ | Plus ref count updates |
| Contains | $\mathcal{O}(m)$ | With Bloom filter: $\mathcal{O}(1)$ rejection |
| Compact | $\mathcal{O}(n)$ | n = total nodes |
| Query (fuzzy) | $\mathcal{O}(m \times d^{2} \times b)$ | d = distance, b = branching |
Build from 10,000 terms:
DynamicDawg: 4.1ms
DoubleArrayTrie: 3.2ms (22% faster)
Single insertion (amortized):
DynamicDawg: ~800ns
Single deletion:
DynamicDawg: ~1.2µs
Contains check:
With Bloom filter: ~150ns (negative)
Without Bloom filter: ~350ns (negative)
Positive lookup: ~450ns
Query "test" (distance 2) in 10K-term dict:
DynamicDawg: 42.3µs
DoubleArrayTrie: 16.3µs (2.6x faster)
10,000-term dictionary:
Nodes: ~250KB
Suffix cache: ~32KB
Bloom filter: ~12KB
Total: ~294KB
vs DoubleArrayTrie: ~100KB (3x smaller)
Trade-off: DynamicDawg uses more memory for update flexibility
After removing 30% of terms:
Before compaction: 350KB (orphaned nodes)
After compaction: 210KB (40% reduction)
Compaction time: ~8ms for 10K terms
| Scenario | Recommended | Alternative |
|---|---|---|
| Frequent adds + removes | ✅ DynamicDawg | - |
| Append-only | ⚠️ DoubleArrayTrie | 3x faster |
| Static dictionary | ⚠️ DoubleArrayTrie | 3x faster, 3x smaller |
| Unicode text | ⚠️ DynamicDawgChar | Correct distances |
| Maximum performance | ⚠️ DoubleArrayTrie | Faster queries |
| Real-time collaboration | ✅ DynamicDawg | Thread-safe |
User Dictionaries
Session-Specific Terms
Collaborative Editing
Adaptive Systems
Blumer, A., Blumer, J., Haussler, D., McConnell, R., & Ehrenfeucht, A. (1987). "Complete inverted files for efficient text retrieval and analysis"
Crochemore, M., & Vérin, R. (1997). "Direct construction of compact directed acyclic word graphs"
Inenaga, S., Hoshino, H., Shinohara, A., Takeda, M., & Arikawa, S. (2001). "On-line construction of compact directed acyclic word graphs"
Navigation: ← Dictionary Layer | DoubleArrayTrie | Algorithms Home
Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |