When implementing code completion or context-aware search, filtering results is essential. However, the placement of filtering in the pipeline dramatically affects performance. This guide explores three strategies with increasing efficiency.
Given a dictionary of 10,000 identifiers and a query "getVal", we want only public functions in ClassA scope. This might filter down to just 50 relevant identifiers.
Question: Should we search all 10,000 terms then filter, or filter first then search 50 terms?
How it works:
transducer
.query_ordered("getVal", 1)
.prefix()
.filter(|c| is_public_function_in_class_a(c.term)) // ❌ After traversal
.take(5)
Characteristics:
Best for:
Performance:
How it works:
// Pre-filter the dictionary
let filtered_terms: Vec<&str> = all_identifiers
.iter()
.filter(|id| id.is_public && id.class == "ClassA")
.map(|id| id.name)
.collect();
// Build a sub-dictionary
let sub_dict = PathMapDictionary::from_iter(filtered_terms);
// Query the smaller dictionary
transducer_with_sub_dict
.query_ordered("getVal", 1)
.prefix()
.take(5)
Characteristics:
Best for:
Performance:
Implementation patterns:
Pattern A: Context Cache
struct ContextCache<D: Dictionary> {
contexts: HashMap<ContextId, D>,
}
impl ContextCache {
fn get_or_build(&mut self, ctx: ContextId, terms: Vec<&str>) -> &D {
self.contexts.entry(ctx).or_insert_with(|| {
PathMapDictionary::from_iter(terms)
})
}
}
Pattern B: Lazy Context Switching
struct ContextualTransducer {
current_context: ContextId,
current_dict: PathMapDictionary,
all_identifiers: Vec<Identifier>,
}
impl ContextualTransducer {
fn switch_context(&mut self, new_context: ContextId) {
if new_context != self.current_context {
let filtered = self.all_identifiers
.iter()
.filter(|id| id.matches_context(new_context))
.map(|id| id.name.as_str());
self.current_dict = PathMapDictionary::from_iter(filtered);
self.current_context = new_context;
}
}
}
How it works:
struct ContextualDictionary {
full_dict: PathMapDictionary,
active_mask: Vec<bool>, // Bitmap: which terms are active
term_to_index: HashMap<String, usize>,
}
// Set context (fast - just flip bits)
ctx_dict.set_context(|term| is_public_function_in_class_a(term));
// Query with masked filter (O(1) lookup)
transducer
.query_ordered("getVal", 1)
.prefix()
.filter(|c| ctx_dict.is_active(&c.term)) // ✅ O(1) bitmap lookup
.take(5)
Characteristics:
Best for:
Performance:
| Strategy | Dictionary Size | Context Changes | Filter Complexity | Speedup | Memory |
|---|---|---|---|---|---|
| Post-Filter | < 1K | Every query | Simple | 1x | Low |
| Bitmap Mask | 1K-100K | < 1/10 queries | Complex | 2-5x | Medium |
| Sub-Trie | > 1K | < 1/100 queries | Any | 10-200x | High |
Scenario: User typing in a method body, completion context changes every few characters.
Recommendation: Bitmap Masking
// On scope change (infrequent)
ctx_dict.set_context(|term| {
current_scope.contains(term) || imports.contains(term)
});
// On every keystroke (frequent)
transducer.query_ordered(user_input, 1).prefix()
.filter(|c| ctx_dict.is_active(&c.term))
.take(10)
Scenario: User searches across entire project (millions of symbols).
Recommendation: Sub-Trie Construction
// User sets filter (one-time)
let filtered = symbols.filter(|s| s.module == "X" && s.is_class());
let sub_dict = PathMapDictionary::from_iter(filtered);
// User performs many searches
for query in user_queries {
transducer_with_sub_dict.query_ordered(query, 2).prefix().take(20)
}
Scenario: Tab-completion for file paths, commands, or options.
Recommendation: Post-Filtering
transducer.query_ordered(user_input, 1).prefix()
.filter(|c| c.term.starts_with('.'))
.take(5)
Dictionary Size < 1,000:
└─> Use Post-Filtering
Dictionary Size 1,000-100,000:
├─> Context changes every query?
│ └─> Use Post-Filtering
│
└─> Context changes < 1/10 queries?
├─> Filter removes < 50% terms?
│ └─> Use Bitmap Masking
│
└─> Filter removes > 50% terms?
└─> Use Bitmap Masking
Dictionary Size > 100,000:
├─> Context changes every query?
│ └─> Use Bitmap Masking (setup cost amortized)
│
├─> Context changes < 1/100 queries?
│ └─> Use Sub-Trie Construction
│
└─> Filter removes > 90% terms?
└─> Use Sub-Trie Construction
For maximum performance, use a hybrid approach:
struct SmartContextualTransducer {
// For very restrictive contexts (>90% filtered)
sub_tries: HashMap<ContextId, PathMapDictionary>,
// For moderate contexts (20-90% filtered)
bitmap_mask: Vec<bool>,
// Fallback for everything else
full_dict: PathMapDictionary,
}
impl SmartContextualTransducer {
fn query(&mut self, ctx: Context, term: &str) -> impl Iterator<Item=Candidate> {
// Estimate filter selectivity
let selectivity = ctx.estimate_selectivity();
if selectivity > 0.9 {
// Use sub-trie (if cached) or build one
let sub_dict = self.get_or_build_subtrie(ctx);
sub_dict.query_ordered(term, 1).prefix()
} else if selectivity > 0.2 {
// Use bitmap masking
self.update_bitmap(ctx);
self.full_dict.query_ordered(term, 1).prefix()
.filter(|c| self.is_active(c.term))
} else {
// Use post-filtering
self.full_dict.query_ordered(term, 1).prefix()
.filter(|c| ctx.matches(c.term))
}
}
}
| Strategy | Setup Time | Query Time (1x) | Query Time (100x) | Total (100 queries) |
|---|---|---|---|---|
| Post-Filter | 0ms | 0.8ms | 80ms | 80ms |
| Bitmap Mask | 12ms | 0.2ms | 20ms | 32ms |
| Sub-Trie | 45ms | 0.05ms | 5ms | 50ms |
Winner: Bitmap masking for 100 queries, sub-trie for 1000+ queries
Default recommendation: Start with post-filtering for simplicity. Profile your application:
If queries are slow → measure dictionary size
If context switches are slow → use bitmap masking
If both are slow → use sub-tries with caching
The choice depends on your specific workload characteristics. The examples in this repository demonstrate all three approaches for you to benchmark in your environment.
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 |