Date: 2025-11-18 Status: REJECTED FOR CURRENT RELEASE - Safety concerns identified Hypothesis: Position-based early termination can reduce O(n^1.5) complexity impact
After eliminating H1 (allocation overhead - 27% gain), H3 (cache - optimal), and H4 (slice copying - 3% overhead), the remaining performance is dominated by fundamental O(n^1.5) algorithmic complexity.
Remaining Breakdown (50-phone case):
Track the position where the last rule was applied and start the next iteration's search from that position instead of position 0.
Rationale: After applying a rule at position last_pos, positions far before last_pos are unlikely to have new matches.
pub fn apply_rules_seq_optimized(rules: &[RewriteRule], s: &[Phone], fuel: usize) -> Option<Vec<Phone>> {
let mut current = s.to_vec();
let mut remaining_fuel = fuel;
let mut last_pos = 0; // Track last modification position
loop {
if remaining_fuel == 0 {
return Some(current);
}
let mut applied = false;
for rule in rules {
// Start search from last_pos instead of 0
if let Some(pos) = find_first_match_from(rule, ¤t, last_pos) {
if let Some(new_s) = apply_rule_at(rule, ¤t, pos) {
last_pos = pos;
current = new_s;
remaining_fuel -= 1;
applied = true;
break;
}
}
}
if !applied {
return Some(current);
}
}
}
Question: After applying a rule at position last_pos, can a rule match at position p < last_pos that didn't match in the previous iteration?
Answer: YES, for Context::Final rules!
Counterexample:
String: "eye"
Rules:
1. "e" → "" / Final (remove final 'e')
2. "y" → "" (remove 'y')
WITH position skipping:
- Iteration 1: Rule 1 at pos 2: matches "e" at end → "ey" (last_pos=2)
- Iteration 2: Start from pos 2
- Rule 1 at pos 2: out of bounds
- Rule 2 at pos 2: out of bounds
- Rule 2 at pos 1: "y" matches → "e" (last_pos=1)
- Iteration 3: Start from pos 1
- All positions >= 1 checked, no matches
- Terminate with result: "e" ❌ WRONG!
WITHOUT position skipping:
- Iteration 1: Same as above → "ey" (last_pos=2)
- Iteration 2: Start from pos 0
- Rule 1 at pos 1: "y" doesn't match
- Rule 2 at pos 1: "y" matches → "e" (last_pos=1)
- Iteration 3: Start from pos 0
- Rule 1 at pos 0: "e" matches AND is final! → "" (last_pos=0)
- Iteration 4: Empty string, no matches
- Terminate with result: "" ✅ CORRECT!
The issue: After applying a rule that shortens the string, an earlier position might become final, but we skip checking it!
Test Program: examples/position_skip_test.rs
Test Results:
✅ MATCH: phone
✅ MATCH: phonetics
✅ MATCH: phonograph
✅ MATCH: telephone
✅ MATCH: symphony
✅ All tests passed - optimization preserves correctness!
Full Test Suite: All 147 phonetic tests pass ✅
Interpretation:
Contexts in Orthography Rules:
Context::Anywhere: Safe (no position dependencies)Context::Initial: Safe (only depends on pos == 0, unchanged)Context::BeforeVowel: Probably safe (looks ahead, but ahead positions unchanged if modification is behind)Context::Final: UNSAFE (depends on string length, which can change)Rules with Context::Final:
Reasons:
Context::FinalFor v0.8.0: Accept current performance (27% improvement from H1)
Acceptance criteria for any revived treatment:
Track per-rule last match position:
let mut rule_hints: Vec<usize> = vec![0; rules.len()];
for (rule_idx, rule) in rules.iter().enumerate() {
let start_pos = rule_hints[rule_idx];
if let Some(pos) = find_first_match_from(rule, ¤t, start_pos) {
// Apply rule
rule_hints[rule_idx] = pos; // Remember for next iteration
// Reset other rules that might be affected
for hint in &mut rule_hints {
*hint = 0; // Conservative: reset all
}
}
}
Safety: Conservative reset ensures correctness
Complexity: Higher memory overhead (per-rule state)
After applying a rule at pos, search in a window around pos:
let window_start = pos.saturating_sub(max_context_len);
let window_end = (pos + replacement.len() + max_context_len).min(current.len());
// First try the window
if let Some(pos) = find_first_match_in_range(rule, ¤t, window_start, window_end) {
// Found match in window
} else {
// Fallback: search entire string (rare)
if let Some(pos) = find_first_match(rule, ¤t) {
// Found match outside window
}
}
Safety: Always correct (fallback to full search)
Performance: Optimizes common case, no slowdown in worst case
Rationale:
Documentation: Clearly document O(n^1.5) scaling in performance baseline
| Hypothesis | Tested | Overhead | Status | Optimization |
|---|---|---|---|---|
| H1 (Allocations in find_first_match) | ✅ | 27% | Fixed | ✅ v0.8.0 |
| H2 (Algorithmic complexity) | ✅ | O(n^1.5) | Identified | Rejected for current release |
| H3 (Cache misses) | ✅ | <2% | Optimal | ❌ None needed |
| H4 (Slice copying) | ✅ | 2-3% | Efficient | ❌ None needed |
| H5 (Iteration count) | ✅ | O(√n) | Proven | N/A (fundamental) |
Total Investigation Time: ~6 hours Optimization Achieved: 27-30% speedup (H1) Remaining Opportunity: ~3-5% (H3+H4 combined) Fundamental Limitation: O(n^1.5) algorithmic complexity (expected behavior)
Justification:
Performance Summary (Post-H1 Optimization):
O(n^1.5) Scaling: This is expected behavior for sequential rewrite systems, NOT a bug!
Further optimization (targeting O(n^1.5)) rejected for the current release unless these conditions change:
Investigation Status: ✅ COMPLETE Optimization Implemented: ✅ H1 (27% speedup) Production Readiness: ✅ READY for v0.8.0 Future Work: 📋 Documented for v0.9.0+
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 |