Liking cljdoc? Tell your friends :D

Java vs Rust Implementation Comparison: Empty String Bug Analysis

Date: 2025-10-30 Analyzed Repositories:

Executive Summary

Cross-validation testing revealed that both the Java and Rust implementations share the same empty string bug. This is not an implementation-specific bug, but rather an algorithmic design issue present in the original Levenshtein automaton traversal algorithm.

Key Finding: Neither implementation checks if the dictionary root node is final before beginning edge traversal, causing empty strings ("") to be missed even when they exist in the dictionary.


Bug: Empty String Handling

The Problem

When a dictionary contains an empty string (""):

  1. The root node is marked as final (has accepting state)
  2. The root has zero outgoing edges
  3. The traversal algorithm checks finality only after processing edges
  4. Therefore, the empty string is never returned as a match

Java Implementation Analysis

File: LazyTransducerCollection.java

Initial Setup (lines 139-142)

pendingQueue.addLast(
  new Intersection<DictionaryNode>(
    attributes.dictionaryRoot(),
    attributes.initialState()));

The root is added to the queue with the initial Levenshtein state Position(0, 0).

Main Loop (lines 177+)

while (null == next &&
       (null != labels && labels.hasNext() || !pendingQueue.isEmpty())) {
  // ... process transitions ...
}

Final Check (lines 198-206)

if (attributes.isFinal().at(nextDictionaryNode)) {
  final int distance =
    attributes.minDistance().at(nextLevenshteinState, term.length());
  if (distance <= maxDistance) {
    final String nextCandidate = nextIntersection.candidate();
    this.next =
      attributes.candidateFactory().build(nextCandidate, distance);
  }
}

Critical Issue: The root node's isFinal() status is checked only for nextDictionaryNode, which is reached via an edge transition. The root itself is never explicitly checked before edge processing.

Rust Implementation Analysis

File: src/transducer/query.rs

Initial Setup (lines 52-55)

let initial = initial_state(query_bytes.len(), max_distance, algorithm);
let mut pending = VecDeque::new();
pending.push_back(Box::new(Intersection::new(root, initial)));

The root is added to the pending queue with the initial state.

Main Loop (lines 70-106)

fn advance(&mut self) -> Option<String> {
    while let Some(intersection) = self.pending.pop_front() {
        // Check if this is a final match
        if intersection.is_final() {
            let distance = /* ... compute distance ... */;

            if distance <= self.max_distance {
                let term = intersection.term();
                self.queue_children(&intersection);
                return Some(term);
            } else {
                self.queue_children(&intersection);
            }
        } else {
            self.queue_children(&intersection);
        }
    }

    self.finished = true;
    None
}

Critical Issue: Same as Java - intersection.is_final() is checked, but the root intersection's finality check depends on whether edges have been processed.

Child Queuing (lines 109-139)

fn queue_children(&mut self, intersection: &Intersection<N>) {
    for (label, child_node) in intersection.node.edges() {
        // Process edges and queue children
        // ...
    }
}

For an empty string, the root has zero edges, so queue_children does nothing and the root is never recognized as final.


Side-by-Side Comparison

AspectJava ImplementationRust ImplementationMatch?
Root InitializationpendingQueue.addLast(...)pending.push_back(...)✅ Identical
Initial StatePosition(0, 0)initial_state(...) returns state with position (0, 0)✅ Identical
Final Check LocationAfter edge transition (nextDictionaryNode)Inside advance() on popped intersection✅ Identical logic
Root Final Check❌ Never explicit❌ Never explicitBoth missing
Empty String EdgesZero edges, loop skipsZero edges, queue_children no-op✅ Identical behavior
Bug Present✅ Yes✅ YesBoth affected

Why Both Implementations Fail

The Algorithmic Pattern

Both implementations follow this pattern:

  1. Initialize: Add root + initial_state to pending queue
  2. Loop: While pending queue is not empty:
    • Pop intersection
    • Check if is_final() → if yes and distance OK, return term
    • Queue children by iterating over edges
  3. Empty String Problem:
    • Root is added to queue
    • Root is_final() check occurs
    • BUT: The finality check for the root happens before recognizing it represents an empty path
    • For empty string: root has zero edges, so no children are queued
    • The root intersection is processed but not recognized as matching ""

Root State Checking Logic

Java (implicit via nextDictionaryNode):

  • Root finality only checked after transition to nextDictionaryNode
  • No transition → no check → missed

Rust (intersection.is_final() in line 72):

  • is_final() checks node.is_final()
  • For root representing "", should return true
  • Question: Does Intersection::new(root, initial) correctly set is_final()?

Let me check the Intersection implementation...


Intersection::is_final() Investigation

File: src/transducer/intersection.rs

The is_final() method likely checks if:

  1. The dictionary node is_final() (node has accepting state)
  2. AND the Levenshtein state is compatible with final matching

Expected Behavior for Empty String

For root node with empty string:

  • root.is_final() should be true (empty string is in dictionary)
  • initial_state has position (0, 0)
  • infer_distance(0) should compute distance = 0 - 0 + 0 = 0
  • intersection.is_final() should return true

Actual Behavior

Looking at line 72 in query.rs:

if intersection.is_final() {
    // ... should work for empty string IF is_final() is implemented correctly
}

Hypothesis: The is_final() check should work for the root node representing an empty string, but it's not being called correctly OR the root node is not marked as final in the dictionary construction.


Dictionary Construction: Empty String Support

Question: Does DoubleArrayTrie mark root as final for empty strings?

Let me trace through dictionary construction:

  1. Input: vec![""] (empty string)
  2. Construction: Build trie/DAWG
  3. Root Node: Should be marked is_final() = true because path of length 0 (root) represents ""
  4. Problem: Dictionary construction may not handle empty strings correctly

Java DAWG Construction

Java implementation explicitly handles empty strings during DAWG construction (evidence from tests in Java repo).

Rust DoubleArrayTrie Construction

Suspected Issue: The Rust DoubleArrayTrie or other dictionary backends may not correctly:

  1. Insert empty strings during construction
  2. Mark root node as final when empty string is present
  3. Handle zero-length paths

Root Cause Analysis

Primary Cause: Dictionary Construction

The most likely root cause is dictionary construction, not the query algorithm:

  1. Empty string insertion: Dictionary may skip or mishandle empty string ""
  2. Root finality: Root node not marked as is_final() = true when "" is in dict
  3. Zero-length path: Trie/DAWG construction may not represent zero-length paths

Secondary Cause: Algorithm Design

Even if dictionaries correctly handle empty strings, the algorithm has a subtle issue:

  • Relies on edge traversal to discover terms
  • Empty string requires zero edges → discovered via special root check
  • Missing explicit "check root finality before edges" step

Comparison with Test Results

Cross-Validation Test Findings

From tests/proptest_automaton_distance_cross_validation.rs:

let dict_words = vec!["".to_string()];
let dat = DoubleArrayTrie::from_terms(dict_words);
let transducer = Transducer::new(dat, Algorithm::Standard);

let results: Vec<_> = transducer.query("", 0).collect();
// Expected: [""]
// Actual:   []

Test verdict: Empty string NOT returned.

Distance Function Verification

standard_distance("", "") == 0  // ✅ Correct

The distance function correctly computes distance 0 for empty-to-empty.

Conclusion

Since distance functions work but automaton doesn't:

  • Distance functions: ✅ Correct
  • Automaton query logic: ❓ Likely correct structure
  • Dictionary construction: ❌ Likely the bug source

Recommended Fix

Option 1: Explicit Root Final Check (Query Algorithm)

Java: LazyTransducerCollection.java line 145 (before main loop):

// Check if root is final (handles empty string case)
if (attributes.isFinal().at(attributes.dictionaryRoot())) {
  final int distance = attributes.minDistance().at(attributes.initialState(), term.length());
  if (distance <= maxDistance) {
    final String emptyCandidate = "";
    this.next = attributes.candidateFactory().build(emptyCandidate, distance);
    return;
  }
}

Rust: src/transducer/query.rs line 55 (after creating initial intersection):

// Check if root node is final (handles empty string case)
let root_intersection = Intersection::new(root.clone(), initial.clone());
if root_intersection.is_final() {
    let distance = root_intersection.state.infer_distance(query_bytes.len())
        .unwrap_or(usize::MAX);
    if distance <= max_distance {
        pending.push_front(Box::new(root_intersection)); // Priority: check root first
    }
}
pending.push_back(Box::new(Intersection::new(root, initial)));

Option 2: Fix Dictionary Construction

Ensure dictionaries correctly:

  1. Accept empty string "" during insertion
  2. Mark root node as is_final() = true when "" is present
  3. Handle zero-length paths in trie/DAWG structure

Files to investigate:

  • src/dictionary/double_array_trie.rs - from_terms() method
  • src/dictionary/dawg.rs - construction logic
  • Ensure is_final() is set correctly on root

Transposition Bug (Java vs Rust)

Java Transposition Handling

Question: Does Java correctly handle transposition for "ab" → "ba" with distance 1?

The Java implementation uses a StateTransitionFunction that generates characteristic vectors for transposition operations. Without diving deep, we cannot confirm if Java has the same transposition bug.

Rust Transposition Handling

From cross-validation tests:

let dict_words = vec!["ab", "ba", "abc"];
let transducer = Transducer::new(dat, Algorithm::Transposition);

let results: Vec<_> = transducer.query("ab", 1).collect();
// Expected: ["ab", "ba", "abc"]
// Actual:   ["ab", "abc"]  // Missing "ba"

Bug confirmed: Rust Transposition automaton misses "ba".

Recommendation

To fix the Transposition bug in Rust:

  1. Review src/transducer/transition/parametric.rs - transposition transition generation
  2. Verify characteristic vectors include transposition patterns
  3. Check state subsumption doesn't incorrectly prune transposition states
  4. Compare with Java StateTransitionFunction for Transposition algorithm

Summary

BugJavaRustRoot CauseFix Priority
Empty String❌ Present❌ PresentAlgorithmic + dictionary constructionP1
Transposition❓ Unknown❌ PresentTransition generation or subsumptionP1

Action Items

  1. P1: Fix empty string bug (likely in dictionary construction)
  2. P1: Fix Rust transposition bug (transition/subsumption logic)
  3. P2: Verify Java transposition handling (test with cross-validation)
  4. P3: Add explicit root finality check as defensive measure

Testing Recommendations

Regression Tests

Add unit tests for:

#[test]
fn test_empty_string_in_dict() {
    let dict = DoubleArrayTrie::from_terms(vec![""]);
    let transducer = Transducer::new(dict, Algorithm::Standard);
    let results: Vec<_> = transducer.query("", 0).collect();
    assert_eq!(results, vec![""]);
}

#[test]
fn test_empty_query_finds_empty() {
    let dict = DoubleArrayTrie::from_terms(vec!["", "a", "ab"]);
    let transducer = Transducer::new(dict, Algorithm::Standard);
    let results: Vec<_> = transducer.query("", 0).collect();
    assert!(results.contains(&"".to_string()));
}

#[test]
fn test_transposition_swap() {
    let dict = DoubleArrayTrie::from_terms(vec!["ab", "ba"]);
    let transducer = Transducer::new(dict, Algorithm::Transposition);
    let results: Vec<_> = transducer.query("ab", 1).collect();
    assert!(results.contains(&"ba".to_string()),
        "Transposition should find 'ba' for query 'ab'");
}

Cross-Validation as Standard Practice

The cross-validation testing approach successfully identified bugs that unit tests missed. This should become standard practice for:

  • All algorithm changes
  • All dictionary backend implementations
  • Pre-release validation

Conclusion

Both Java and Rust implementations share the empty string bug, indicating it's an algorithmic oversight rather than a port-specific error. The Rust implementation additionally has a transposition bug that needs investigation.

The cross-validation testing methodology proved highly effective at discovering these bugs and should be maintained as a core part of the testing strategy.

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