Liking cljdoc? Tell your friends :D

Contextual Filtering Optimization Guide

Overview

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.

The Problem

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?

Strategy Comparison

Strategy 1: Post-Filtering (Current Implementation)

How it works:

transducer
    .query_ordered("getVal", 1)
    .prefix()
    .filter(|c| is_public_function_in_class_a(c.term))  // ❌ After traversal
    .take(5)

Characteristics:

  • ✅ Simple to implement
  • ✅ Flexible - any filter predicate
  • ✅ No setup cost
  • ❌ Searches entire dictionary (10,000 terms)
  • ❌ Generates candidates that get filtered out
  • ❌ Wasted CPU on irrelevant branches

Best for:

  • Dictionary < 1,000 terms
  • Context changes every query
  • Simple filter predicates
  • Exploratory queries

Performance:

  • Time: O(D × E) where D = total dictionary size, E = edit distance
  • Space: O(max_distance)

Strategy 2: Sub-Trie Construction (Pre-Filtering)

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:

  • ✅ Searches only relevant terms (50 instead of 10,000)
  • ✅ No runtime filter overhead
  • Massive speedup for restrictive contexts (90%+ filtered)
  • ❌ Requires rebuilding dictionary for context changes
  • ❌ Memory overhead (multiple tries)
  • ❌ Setup cost per context switch

Best for:

  • Context changes infrequently (< once per 100 queries)
  • Very restrictive filters (>90% filtered out)
  • Multiple queries within same context
  • Memory is not a constraint

Performance:

  • Setup: O(F × log F) where F = filtered size
  • Query: O(F × E) where F << D
  • Space: O(F) per context
  • Speedup: 10-200x for restrictive contexts

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;
        }
    }
}

Strategy 3: Bitmap Masking (Hybrid Approach)

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:

  • ✅ No dictionary rebuilding
  • ✅ Fast context switching (just update bitmap)
  • ✅ O(1) filter checks during traversal
  • ✅ Single dictionary in memory
  • ⚠️ Still traverses full dictionary structure
  • ⚠️ Filter overhead during traversal (though minimal)

Best for:

  • Moderate context changes (1 per 10-100 queries)
  • Complex filter predicates
  • Memory-constrained environments
  • Medium-sized dictionaries (1,000-100,000 terms)

Performance:

  • Context setup: O(D) to update bitmap
  • Query: O(D × E) traversal, but O(1) filter checks
  • Space: O(D) for bitmap (minimal overhead)
  • Speedup: 2-5x compared to post-filtering

Performance Matrix

StrategyDictionary SizeContext ChangesFilter ComplexitySpeedupMemory
Post-Filter< 1KEvery querySimple1xLow
Bitmap Mask1K-100K< 1/10 queriesComplex2-5xMedium
Sub-Trie> 1K< 1/100 queriesAny10-200xHigh

Real-World Use Cases

Code Completion in IDE

Scenario: User typing in a method body, completion context changes every few characters.

Recommendation: Bitmap Masking

  • Context = current scope symbols + imported names
  • Context changes moderately (new scope every N lines)
  • Need fast response time
  • Complex visibility rules
// 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)

Search in Large Codebase

Scenario: User searches across entire project (millions of symbols).

Recommendation: Sub-Trie Construction

  • User specifies filter upfront: "only classes in module X"
  • Filters out 99% of symbols
  • Many searches within same filter
// 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)
}

Autocomplete in Command Line

Scenario: Tab-completion for file paths, commands, or options.

Recommendation: Post-Filtering

  • Small dictionaries (hundreds of items)
  • Context changes every command
  • Simple filters (e.g., "starts with '.'")
transducer.query_ordered(user_input, 1).prefix()
    .filter(|c| c.term.starts_with('.'))
    .take(5)

Implementation Guide

When to Use Each Strategy

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

Combining Strategies

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))
        }
    }
}

Benchmarks

Setup

  • Dictionary: 10,000 identifiers
  • Context filters to: 500 identifiers (95% reduction)
  • Query: "getValue" with distance 1

Results

StrategySetup TimeQuery Time (1x)Query Time (100x)Total (100 queries)
Post-Filter0ms0.8ms80ms80ms
Bitmap Mask12ms0.2ms20ms32ms
Sub-Trie45ms0.05ms5ms50ms

Winner: Bitmap masking for 100 queries, sub-trie for 1000+ queries

Conclusion

Default recommendation: Start with post-filtering for simplicity. Profile your application:

  • If queries are slow → measure dictionary size

    • Large dictionary (>10K) → use bitmap masking
    • Very large + restrictive context → use sub-tries
  • 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

Keyboard shortcuts
Ctrl+kJump to recent docs
Move to previous article
Move to next article
Ctrl+/Jump to the search field
× close