uttrflow / uttrflow/uttrflow-swift

AI suggestions compare typed text by whole grapheme, so each half-typed Devanagari syllable counts as rejecting a suggestion, drops the model's lines, and turns acceptance into replacing the user's own letter

Open
#712 1 comment 0 reactions 0 assignees View on GitHub
area:ai-suggestions bug P1
Dominant language
Swift
Stars
4
Forks
17
Avg merge
3h 32m
Merged PRs (30d)
277

Description

## What happens

Swift's `hasPrefix`, `==` on `Character`, and `zip` over `String` all compare extended grapheme clusters. In Devanagari, a syllable is one grapheme made of several scalars, and typing builds it up one key at a time: न, then न+ह, then न+ह+ी, then न+ह+ी+ं. Most of those intermediate states aren't a grapheme prefix of the finished word:

```
"नहीं जाना".hasPrefix("नह") // false ("नहीं" is 2 graphemes, 4 scalars)
"नहीं".unicodeScalars.starts(with: "नह".unicodeScalars) // true
```

That breaks three things. Each was run by copying the function verbatim from `origin/main` 26d7bc1 into a throwaway script.

**1. Typing toward a store suggestion is recorded as rejecting it.** `SuggestionSession.adopt` (`Sources/UttrflowPredict/SuggestionSession.swift:366-380`) calls a suggestion "typed past" when `!offered.lowercased().hasPrefix(lowered)`.

- With "नहीं जाना" offered, typing न → नह returns `rejected = "नहीं जाना"`, and `rejectionsHere` becomes 1.
- Typing on from "नहीं " to "नहीं ज" rejects it again.
- The Latin sequence t → th → tha against "thanks" rejects nothing.

`SuggestionCoordinator` writes each rejection to the store (`Sources/Uttrflow/Suggestion/SuggestionCoordinator.swift:360-361`), which lowers that line's rank. At `rejectionsBeforeSilence`, `Quieting.reason` silences the field with `.rejectedTooOften` (`Sources/UttrflowPredict/Quieting.swift:21`). So a Hindi typist who types the very line being suggested teaches the store that they don't want it, and switches suggestions off for the field.

**2. The model's completions are thrown away mid-syllable.** Two filters reject them:

- `SuggestionSession.drawable` (`SuggestionSession.swift:294-300`, `hasPrefix`): `drawable(["नहीं जाना"], past: "नह") == []`. The same happens for "नहीं ज"; only "नहीं जा" passes.
- In `Sources/UttrflowLocalModel/MLXCandidateScorer.swift`, `continuation(of:past:)` (`:485`) and its `echo`/`repaired` helpers compare `Character`s: typed "मैं कल नह" with answer "मैं कल नहीं आऊँगा" gives `nil`, and `joined` (`:281`) also gives `nil`. Typed "मैं कल न" works and returns "हीं आऊँगा".

Token healing itself is byte-based and fine. The lines are lost after it.

**3. Accepting replaces the user's own consonant.** `Acceptance.edit` (`Sources/UttrflowPredict/Acceptance.swift:28-33`) splits at `CommonPrefix.of` (`Sources/UttrflowPredict/CommonPrefix.swift:9`, `zip` over Characters). Store suggestions reach it without a prefix check (`SuggestionSession.swift:224`).

| typed | suggestion | `replaced` | `inserted` |
|---|---|---|---|
| नह | नहीं जाना | ह | हीं जाना |
| नही | नहीं जाना | ही | हीं जाना |
| क | की बात | क | की बात |

The ghost draws the user's "ह" struck through as if it were a typo. VoiceOver hears "replacing 1 character" (`Sources/UttrflowUX/SuggestionPresentation.swift:153-157`). On the typed route, `TypedTextInsertionEngine.write` sends `replaced.count` backspaces (`Sources/UttrflowInput/TypedTextInsertionEngine.swift:39-47`), one per grapheme. Many apps delete one scalar per backspace in Indic text, which would leave "नह" + "हीं जाना". That last step is not verified in a live app; the Accessibility route converts correctly through `BackwardSelection`.

## Why it matters

This is every syllable with a vowel sign, on any Devanagari keyboard, with no input method involved. Hindi typists get suggestions that flicker away while they type, and a field that goes quiet after three syllables. When they do accept, they get an edit that deletes what they typed. It does not affect Hinglish typed in Latin letters.

## Acceptance criteria

- The prefix checks on the typing path compare Unicode scalars (or NFC-normalised scalars; see #717), not graphemes: `adopt`, `drawable`, `resolve`, `CommonPrefix`, and `MLXCandidateScorer`'s `continuation`/`echo`/`joined`.
- `Acceptance.edit` never marks as replaced a text that the suggestion extends at scalar level. `replacedCount` stays in the unit the route deletes by.
- Tests with the examples above in `Tests/UttrflowPredictTests/SuggestionSessionTests.swift`, `AcceptanceTests.swift`, `CommonPrefixTests.swift` and `Tests/UttrflowLocalModelTests/CompletionParsingTests.swift`. Include a property test that typing any suggestion scalar by scalar never produces a rejection (`SuggestionSessionPropertyTests.swift`).

Contributor guide

Open the contributing guide

Research direction

Start with the named tests in SuggestionSessionTests.swift, AcceptanceTests.swift, CommonPrefixTests.swift, and CompletionParsingTests.swift, then read the referenced functions in SuggestionSession, Acceptance, CommonPrefix, and MLXCandidateScorer. Reproduce the Devanagari examples and trace rejection, filtering, prefix, and replacement behavior. Done means the listed scalar-level acceptance criteria pass, including the property test in SuggestionSessionPropertyTests.swift.

Written by the indexing model from the issue text.

Assessment

Tech stack
macos, swift
Domain
accessibility, desktop, testing
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
70/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.