uttrflow / uttrflow/uttrflow-swift

The doubtful-word path drops the dictionary entry's identity, so an entry offered as a reading is never counted used or reverted and undo cannot retire it

Open
#219 1 comment 0 reactions 0 assignees View on GitHub
area:dictation bug claimed P2
Dominant language
Swift
Stars
4
Forks
17
Avg merge
3h 32m
Merged PRs (30d)
277

Description

**P2 — reliability.**

### What happens

A dictionary entry that only ever reaches the user through the doubtful-word offering — never through the correction engine — stays at zero uses and zero reverts for its whole life. Undoing a dictation the entry was applied to teaches the app nothing, because the undo counter is keyed by entry id and this path never learnt which entry it used. `isTrustworthy` is therefore permanently true and the entry can never retire itself; the only way out is for the user to find the row in Settings and delete it, with nothing in the row to say it was the one at fault.

### Why

The candidate protocol was typed for the prompt line rather than for the feedback loop. Provenance is dropped at the source boundary, and no later stage can reconstruct it, so the store's retirement machinery is blind to one of the two ways the dictionary is now applied.

### What the code shows

The provenance chain is broken exactly as described. `CandidateSource.candidates` returns `[String]` (CandidateSource.swift:7); `DictionaryCandidates` looks up real entries and maps them away with `.map(\.word)` (DictionaryCandidates.swift:14); `GenerativeTextTransformer.transform` returns `TransformationResult(text:producedBy:cleaning:)` carrying no entry ids (GenerativeTextTransformer.swift:71-73); and `DictationPipeline.count(_:)` iterates only `changes.corrections`, reading `correction.entryID` (DictationPipeline.swift:621-634), which is produced solely by `WordCorrectionEngine.proposal` (CorrectionEngine.swift:58-60). `StoreCounters.recordUse(ofEntry:)` (AppDelegate.swift:1716-1722) is therefore unreachable from the doubtful-word path. I also confirmed the path is live in the shipping app, not just in tests: `TextTransformers.all` builds `DoubtfulWords.including(dictionary:)` whenever `spellings` is supplied (TextTransformers.swift:12), and AppDelegate.swift:370-374 always supplies it. `isTrustworthy` returning true below three uses (DictionaryEntry.swift:47-50) and `PhoneticIndex.init` filtering on it (PhoneticIndex.swift:21) are both as cited. Two corrections. The worked example is wrong: `main` is in `GeneralVocabulary.commonHinglish` (GeneralVocabulary.swift:81), so `knows("main")` is true, `isWorthLearning("main")` is false, and "main" can never be learnt off a window title at all — the mechanism is real but needs a different word. And the severity is overstated: the consequence is a missing feedback signal, not a wrong word on screen. An entry used only through this path is no worse than one never used; what is lost is the ability for undo to retire it, and the documented recourse — deleting the row, or `removeLearned()` (Docs/app-dictionary-store.md:78-87) — still works.

### Where

- `Sources/UttrflowAI/Candidates/CandidateSource.swift:7`
- `Sources/UttrflowAI/Candidates/DictionaryCandidates.swift:14`
- `Sources/UttrflowAI/GenerativeTextTransformer.swift:71`
- `Sources/UttrflowPipeline/DictationPipeline.swift:621`
- `Sources/Uttrflow/AppDelegate.swift:1716`
- `Sources/UttrflowDictionary/DictionaryEntry.swift:47`
- `Sources/UttrflowDictionary/PhoneticIndex.swift:21`
- `Docs/app-dictionary-store.md:78`

### Fix direction

Carry provenance across the one boundary that loses it, and make the two applying paths one. `WordCorrection` already models this exactly — heard, replacement, wordRange, entryID, reason, heardConfidence (CorrectionEngine.swift:58-60) — and `DoubtfulSpan` is a weaker copy of it, so the DRY answer is one type rather than two. Change `CandidateSource.candidates` to return `[Reading]` where `Reading` is a `Sendable` value holding the spelling and an optional `entryID`; `DictionaryCandidates` stops calling `.map(\.word)`; `DoubtfulSpan` keeps the readings as values. The positional alignment from the finding-2 fix already computes which reading the model actually took at each span, so `TransformationResult` gains those taken readings, `AppliedChanges` carries their entry ids beside `corrections`, and `DictationPipeline.count(_:)` records use for them through the same `learner.recordUse(ofEntry:)` loop it runs today (DictationPipeline.swift:625-629) — one path of propose, apply, count, retire, instead of two of which only one reports back. Sequence it after finding 2: without the alignment there is no honest answer to which reading was taken, and guessing one would put false counts into the store, which is worse than none. `Reading` must be `Sendable` for the existing `withTaskGroup` in `DoubtfulWords.readings`; no force unwraps (the entryID is optional by construction, since the screen and phonetic sources have none). Add store-level tests asserting a reading taken from the dictionary increments `timesUsed` and an undone one increments `timesReverted`, to hold the 95% floor on the new branch.

### Principles

SRP and DIP: the store owns whether a word has earned its place, and it can only own that if every applying path reports back; `[String]` is a lossy boundary that moves that responsibility nowhere. DRY: `WordCorrection` and `DoubtfulSpan` are two descriptions of the same event and should be one.

---

Found by a code-path audit of the dictation pipeline. Traced in the source and checked against `Docs/` by a second reader; **not reproduced at runtime**, which is why this carries no `confirmed` label. Any rule or prompt change proposed above is measured against the corpus with `make bakeoff` before it lands.

Contributor guide

Open the contributing guide

Research direction

Start at CandidateSource.swift and trace the reading through DictionaryCandidates.swift, GenerativeTextTransformer.swift, AppliedChanges, and DictationPipeline.swift:621-634. Compare the existing correction counting path with StoreCounters.recordUse(ofEntry:) and the undo flow. Done means dictionary readings retain optional entry IDs, taken readings are counted and reverted correctly, and store-level tests cover both counters without guessing a reading.

Written by the indexing model from the issue text.

Assessment

Tech stack
swift
Domain
backend, desktop
Issue type
Bug
Difficulty
5/5
Estimated time
Over a week
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
38/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.