Date: November 17, 2025 Duration: Extended debugging session Status: Root cause identified, formal model needs revision
Two phonetic split tests were failing:
test_phonetic_split_multiple: "kat" → "chath" (two splits: k→ch, t→th)test_phonetic_split_with_standard_ops: "graf" → "graphe" (split f→ph + insert 'e')Initial state: 22/24 phonetic tests passing (91.7%)
Per user directive: "Try all your suggestions to debug the issues"
Discovery: Positions use a sliding subword window, not absolute word positions!
for (i, input_char) in input.chars().enumerate() {
let subword = self.relevant_subword(word, i + 1); // CHANGES each iteration!
// ...
}
subword advances with each input charactermatch_index = offset + n indexes into the current subwordExample: For word "kat", n=1:
The subword SLIDES forward, so offset=0 sees different characters at each input step!
Current Implementation:
// Entry:
GeneralizedPosition::new_i_splitting(
offset - 1, // Decrement
errors,
max_distance,
input_char
)
// Completion:
let new_offset = offset + 1; // Increment
GeneralizedPosition::new_i(new_offset, errors, max_distance)
Net Effect: (offset - 1) + 1 = offset → NO ADVANCEMENT!
Debug Evidence ("kat" → "chath"):
[DEBUG] === Input position i=0, char='c' ===
Subword: "$kat"
[Enter k→ch split: I+0#0 → ISplitting+-1#0]
[DEBUG] === Input position i=1, char='h' ===
Subword: "kat"
[Complete k→ch split: ISplitting+-1#0 → I+0#0]
[DEBUG] === Input position i=2, char='a' ===
Subword: "at"
[Match 'a' → 3 positions]
[DEBUG] === Input position i=3, char='t' ===
Subword: "t"
[State: 3 positions → 1 position]
[DEBUG] === Input position i=4, char='h' ===
Subword: "" ← EMPTY!
✗ Transition failed, rejecting
At input i=3 (char='t'), subword="t" has length 1. With offset=0, match_index=1 is out of bounds!
At input i=4 (char='h'), subword="" is empty because:
// relevant_subword for position i=5, word="kat" (len=3), n=1:
start = 5 - 1 = 4
v = min(3, 5 + 1 + 1) = min(3, 7) = 3
range = 4..=3 // EMPTY!
Expected Behavior: After k→ch split consumes 'k', the next operation should see 'a', not 'k' again.
Merge Operations (Working Correctly):
// Merge (2-to-1) operations:
if let Ok(merge) = GeneralizedPosition::new_i(
offset + 1, // ADVANCES by +1
new_errors,
self.max_distance
) {
successors.push(merge);
}
Merge operations do offset + 1 directly, advancing past consumed characters.
Split Operations (Broken):
Should be:
offset + 0, Completion to offset + 1// Entry:
GeneralizedPosition::new_i_splitting(
offset, // No decrement
errors,
...
)
// Completion:
let new_offset = offset + 1; // Increment
Result: ❌ Broke MORE tests (18 → 12 passing)
Tests that started failing:
Conclusion: Simply changing offset manipulation isn't enough. The formal model's offset-1/offset+1 pattern must serve a purpose we don't yet understand.
offset + 2// Entry:
GeneralizedPosition::new_i_splitting(
offset - 1, // Keep original
errors,
...
)
// Completion:
let new_offset = offset + 2; // More increment
Result: ❌ Broke test_phonetic_split_multiple (different assertion)
Conclusion: offset+2 overshoots the correct position.
The issue is NOT a simple offset bug. It's a fundamental mismatch between:
Hypothesis: The formal model in PhoneticOperations.v was derived from the incorrect example in the Coq file (lines 41-65), which states:
(* Example: "graf" → "graphe" with f→ph split *)
(* Start: I+0#0 (processing 'gra') *)
(* Step 3 (Completion): Create: I+0#0 (offset -1+1=0, errors same) *)
This example shows offset returning to 0 after the split, implying NO net advancement. But this cannot be correct if splits consume word characters!
Alternative Hypothesis: The offset semantics are RELATIVE to the sliding subword window, and advancement happens IMPLICITLY through the window sliding, not explicitly through offset changes. But then why do MERGE operations explicitly increment offset?
Does the formal model match the intended behavior?
Looking at the example more carefully:
"Start: I+0#0 (processing 'gra')"
What does "(processing 'gra')" mean?
If Option A, then after f→ph split, we should be at offset=0 pointing to next char after 'f', which would be past the word (graf has no char after 'f' → goes to M-type). This matches the example!
But this interpretation doesn't explain why the tests are failing.
Action: Document the precise semantics of offset in relation to:
Method: Trace through a working example (e.g., "graf" → "graph" with f→ph split ONLY, which passes) to understand correct offset behavior.
The formal model needs ONE of these fixes:
Option A: Keep offset-1/offset+1, update invariants and examples
Option B: Change to offset+0/offset+1 for net +1
Option C: Change to offset-1/offset+2 for net +1
Create property tests that validate:
// Property: Split consumes exactly 1 word character
#[test]
fn split_consumes_one_word_char() {
// For any valid split from position p:
// - Before: match_index points to char 'c'
// - After: match_index points to char AFTER 'c'
}
Check consistency across:
offset + 1 directly ✓offset - 1 then offset + 1 (net 0) ✗Ensure all operations that consume N word characters advance offset by N.
The debugging session successfully identified the root cause: phonetic splits do not advance past consumed word characters due to the offset-1/offset+1 pattern giving net 0 advancement.
The fix requires updating the formal model first to specify correct offset semantics, then deriving the Rust implementation from the corrected specification. This maintains the formal-verification-first approach.
Next Action: User decision on which fix option (A, B, or C) to pursue for the formal model.
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 |