Skip to content

feat: the pure core for listening and dictation practice (M11, 1/4) - #35

Merged
widgetii merged 2 commits into
mainfrom
feat/m11-listening-core
Oct 19, 2026
Merged

widgetii merged 2 commits into
mainfrom
feat/m11-listening-core

Conversation

@widgetii

Copy link
Copy Markdown
Member

First of four for M11 — listening and dictation (听力 / 听写). No UI; nothing user-visible yet. This is the pure core the rest is built on, plus the schema to keep its results.

The reuse that makes this one milestone

SequenceAlignment serves both halves:

  • Scoring a dictation lines up what the learner typed against the transcript.
  • Forced alignment lines up what the speech recogniser heard against the same transcript, so the recogniser's mistakes stop mattering — its correct neighbours carry the timing across them.

Both are character-level alignment of two Chinese strings, so there is one implementation of it.

It is banded. The backtrace needs the matrix: two minutes of practice is 600×600, a third of a megabyte; an hour is over a gigabyte. The two sequences are near-identical by construction, so only a corridor around the diagonal is computed — O(n × band). A path that would leave the corridor is not found; the best one inside it comes back instead, degrading the alignment rather than failing it.

Measured, not estimated

PhraseSplitter was built against 听力64, the transcript already in the library:

609 characters over ~147 s 4.1 characters/second
7-second cap (WorkAudioBook's default) 29-character budget
22 sentences → 29 phrases, longest exactly 29, none over cap

Splitting at every comma instead gives 61 fragments of three or four characters — not a listening test, just a slow way to read. Sentences within budget stay whole; only the over-long ones are cut, then clauses are packed back up to the budget.

Half-width punctuation is first-class, not an edge case. 听力64 contains 39 half-width commas and zero full-width ones — it was typed by someone whose input method produced ,. A splitter that knew only , would leave its 42-character sentence whole and the cap would silently not apply to the one text this was built for.

Five outcomes, not right and wrong

DictationComparison distinguishes three kinds of wrong, using readings(forCharacter:) from the dictionaries already open:

Outcome Example What it tells the learner
homophone 真 for 针, both zhen1 Heard it correctly; the IME offered the wrong candidate
tone slip 是 shi4 for 时 shi2 Heard the syllable, missed the tone
misheard no reading in common Actually did not hear it

Plus missing and extra. This is the feature WorkAudioBook structurally cannot have, and the reason to build this inside HanReader rather than keep using it.

Punctuation and spacing are dropped from both sides before scoring: a transcription convention, not evidence about anyone's ears — and a Chinese IME inserts punctuation for the learner, so scoring it would mark some people down for their keyboard.

One structural change

LibraryRepository.dbQueue is now internal. Swift's private reaches extensions in the same file only, so the repository could not be split across files at all — and it had gone past the 400-line limit. GRDB still cannot leave the module; internal import GRDB and the SwiftLint boundary rule are what guarantee that, not the access level of one stored property.

Tests

49 new (433 core, 108 persistence). Both app targets built locally before pushing, macOS and generic/platform=iOS Simulator.

Nineteen mutants planted, all killed — but four survived a first attempt, and each one exposed a test proving less than it claimed: a zero-slack case using identical sequences, where a band of nought and a band of one agree; a half-width test that did not isolate terminators from separators; a merge-budget guard no input ever reached; and a case-folding fixture whose capitalised reading sat beside a lowercase one, making the folding redundant. A fifth survivor was a genuinely equivalent mutant — a guard isDisjoint had already made unreachable — so the guard is deleted rather than kept as a line that reads as load-bearing.

Known

The plan predicted 32 phrases for 听力64; the implementation gives 29, because it packs clauses back up to the budget rather than emitting one per comma. The tests carry the real figure.

Next: PR 2 is the alignment itself — SFSpeechURLRecognitionRequest with requiresOnDeviceRecognition = true, never the network. zh-CN offline dictation is offered on macOS (High Quality = true) but is not installed on the dev machine, so PR 2 also brings the notice that explains how to install it, modelled on the existing "no Chinese voice" alert.

🤖 Generated with Claude Code

M11, first of four. No UI and nothing user-visible yet: the types the rest
of the milestone is built from, and the schema to keep their results.

`SequenceAlignment` is why the two halves of this milestone are one job.
Scoring a dictation lines up what the learner typed against the transcript;
forced alignment lines up what the speech recogniser heard against the same
transcript. Both are character-level alignment of two Chinese strings, so
there is one implementation.

It is banded. The backtrace needs the matrix, and two minutes of practice is
600 characters against 600 — a third of a megabyte, nothing — but an hour is
over a gigabyte. The two sequences are near-identical by construction, one
being a recognition of the speech the other transcribes, so the true path
never strays far from the diagonal and only a corridor around it is
computed. A path that would leave the corridor is not found; what comes back
is the best one inside it, which degrades the alignment rather than failing
it.

`PhraseSplitter` cuts sentences first and clauses only where a sentence
overruns its budget, then packs clauses back up to it. Measured on 听力64:
609 characters over about 147 seconds is 4.1 a second, so a seven-second cap
is 29 characters, and its 22 sentences become 29 phrases with the longest
exactly at the cap. Splitting at every comma instead would give 61 fragments
of three or four characters, which is not a listening test. Half-width
punctuation is first-class rather than an edge case: that transcript has 39
half-width commas and no full-width ones, so a splitter that knew only ,
would leave its longest sentence whole and the cap would silently not apply.

`DictationComparison` reports five outcomes, not right and wrong. A wrong
character that shares a reading with the right one is a homophone — heard
correctly, wrong candidate from the input method. One that shares only the
syllable is a tone slip, which is what a learner's ear loses first. Only a
character with no reading in common means they did not hear it. The readings
come from the dictionaries already open, which is the whole reason to build
this here rather than run a general-purpose dictation tool.

Punctuation and spacing are dropped from both sides before scoring. They are
a transcription convention rather than evidence about anyone's ears, and a
Chinese input method inserts them for the learner anyway, so scoring them
would mark some people down for their keyboard.

`LibraryRepository.dbQueue` becomes internal. Swift's `private` reaches
extensions in the same file only, so the repository could not be split at
all, and it was over the file-length limit. GRDB still cannot leave the
module: `internal import` and the lint boundary rule are what guarantee
that, not the access level of one stored property.

Nineteen mutants planted and all killed. Four survived a first attempt and
each showed a test proving less than it claimed: a zero-slack case using
identical sequences, where a band of nought and a band of one agree; a
half-width test that did not isolate terminators from separators; a
merge-budget guard no input ever reached; and a case-folding fixture whose
capitalised reading sat beside a lowercase one, making the folding
redundant. A fifth survivor was a genuinely equivalent mutant — a guard
`isDisjoint` had already made unreachable — so the guard is gone rather
than kept as a line that reads as load-bearing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@widgetii
widgetii enabled auto-merge (squash) October 19, 2026 19:14
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Add the listening and dictation practice core

✨ Enhancement 🧪 Tests 🕐 40+ Minutes

Grey Divider

AI Description

• Add phrase splitting, shared character alignment, and pronunciation-aware dictation scoring
 without a UI.
• Store phrase boundaries, grades, and latest attempts in a new library migration.
• Test alignment, scoring, splitting, and persistence, including UTF-16 offsets and half-width
 punctuation.
Diagram

graph TD
  T["Transcript"] --> S["Phrase Splitter"] --> P["Phrase Models"] --> R["Library Repository"] --> DB[("Library DB")]
  T --> A["Sequence Alignment"] --> C["Dictation Comparison"]
  D["Dictionary Readings"] --> C
  C -->|"attempt summaries"| R
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Full-matrix alignment
  • ➕ Always finds a globally optimal edit path, regardless of drift.
  • ➖ Backtrace storage grows quadratically and becomes costly for long recordings.
2. Stable phrase identifiers
  • ➕ Attempts remain attached to the same material when phrase indices change after re-splitting.
  • ➖ Requires a phrase-matching policy across alignments and more complex persistence.

Recommendation: Keep the shared banded alignment and separate pure-core and persistence layers: they fit the expected near-identical sequences without introducing platform dependencies. Before wiring up re-alignment, define how changed phrase indices affect retained attempts and how manually adjusted boundaries survive wholesale phrase replacement; the current repository does not enforce either association.

Files changed (12) +2078 / -4

Enhancement (6) +1161 / -0
DictationComparison.swiftScore dictation with pronunciation-aware character marks +271/-0

Score dictation with pronunciation-aware character marks

• Aligns scorable characters while ignoring punctuation and spacing, then labels matches, substitutions, omissions, and extras. Classifies substitutions using supplied dictionary readings and retains UTF-16 offsets for rendering.

Sources/HanReaderCore/Listening/DictationComparison.swift

Phrase.swiftDefine phrase and attempt domain models +122/-0

Define phrase and attempt domain models

• Adds timed phrase ranges, confidence, boundary origin, and practice grade, plus a model for the latest dictation attempt. Provides text and audio range helpers.

Sources/HanReaderCore/Listening/Phrase.swift

PhraseSplitter.swiftSplit transcripts into duration-budgeted phrases +302/-0

Split transcripts into duration-budgeted phrases

• Keeps sentences whole when they fit, divides longer ones at clauses, and merges short fragments within a character budget. Supports both punctuation widths and returns UTF-16 ranges.

Sources/HanReaderCore/Listening/PhraseSplitter.swift

SequenceAlignment.swiftAdd reusable banded character alignment +233/-0

Add reusable banded character alignment

• Implements edit-distance alignment with match, substitution, insertion, and deletion steps. Limits matrix storage to a diagonal band for use by dictation scoring and future transcript-to-recognition alignment.

Sources/HanReaderCore/Listening/SequenceAlignment.swift

LibraryRepository+Listening.swiftPersist phrases, grades, boundaries, and attempts +167/-0

Persist phrases, grades, boundaries, and attempts

• Adds repository operations to replace or forget phrase sets, adjust boundaries, assign grades, and record the latest attempt with an incrementing count. Keeps listening persistence in a separate repository extension.

Sources/HanReaderPersistence/LibraryRepository+Listening.swift

LibrarySchema.swiftAdd listening-practice database migration +66/-0

Add listening-practice database migration

• Registers a v3 migration creating phrase and dictationAttempt tables keyed by text and phrase index. Both tables cascade when their parent text is deleted.

Sources/HanReaderPersistence/LibrarySchema.swift

Refactor (1) +7 / -1
LibraryRepository.swiftAllow repository extensions to access the database queue +7/-1

Allow repository extensions to access the database queue

• Changes dbQueue from private to internal so the listening extension in another file can use it. The queue remains unavailable outside the persistence module.

Sources/HanReaderPersistence/LibraryRepository.swift

Tests (5) +910 / -3
DictationComparisonTests.swiftTest scoring outcomes and mark offsets +239/-0

Test scoring outcomes and mark offsets

• Covers pronunciation categories, missing readings, punctuation handling, accuracy, insertions, deletions, and UTF-16 offsets. Uses fixed reading fixtures rather than installed dictionaries.

Tests/HanReaderCoreTests/DictationComparisonTests.swift

PhraseSplitterTests.swiftTest phrase boundaries and character budgets +242/-0

Test phrase boundaries and character budgets

• Checks sentence and clause splitting, both punctuation widths, quote handling, budget limits, merging, and UTF-16 ranges. Also verifies conversion from recording duration to a character budget.

Tests/HanReaderCoreTests/PhraseSplitterTests.swift

SequenceAlignmentTests.swiftTest banded alignment against edit-distance invariants +220/-0

Test banded alignment against edit-distance invariants

• Checks ordered coverage of both sequences, operation shapes, and distances against an independent full-matrix implementation. Exercises empty inputs, narrow bands, out-of-band paths, and zero slack.

Tests/HanReaderCoreTests/SequenceAlignmentTests.swift

ListeningPracticeTests.swiftTest listening-practice storage behavior +200/-0

Test listening-practice storage behavior

• Verifies phrase replacement and round-trips, manual boundaries, grades, attempt counts, and retained attempts after re-alignment. Also checks explicit forgetting and deletion cascades.

Tests/HanReaderPersistenceTests/ListeningPracticeTests.swift

MigrationTests.swiftUpdate schema and migration expectations for v3 +9/-3

Update schema and migration expectations for v3

• Adds both listening tables to the schema snapshot and updates the expected migration list and table count.

Tests/HanReaderPersistenceTests/MigrationTests.swift

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Short attempts can exhaust app memory ✓ Resolved
Description
Band adds the full difference in sequence lengths to its width, and fill allocates two arrays
using that width for every reference row. A long transcript paired with a short but nonempty
dictation reaches this path, making allocation grow roughly with the square of the transcript length
instead of remaining narrowly banded.
Code

Sources/HanReaderCore/Listening/SequenceAlignment.swift[R120-121]

+            band = max(1, abs(rows - columns) + max(0, slack))
+            width = 2 * band + 1
Evidence
Both nonempty inputs reach Band; its width includes their entire length difference, and that width
determines the size of both allocated arrays. The dictation caller supplies complete scorable
sequences without a length limit.

Sources/HanReaderCore/Listening/SequenceAlignment.swift[85-98]
Sources/HanReaderCore/Listening/SequenceAlignment.swift[114-139]
Sources/HanReaderCore/Listening/SequenceAlignment.swift[150-154]
Sources/HanReaderCore/Listening/DictationComparison.swift[131-137]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A large length difference makes banded alignment allocate a near-quadratic matrix, even for a short nonempty attempt.
## Fix Focus Areas
- Sources/HanReaderCore/Listening/SequenceAlignment.swift[114-139]
- Sources/HanReaderCore/Listening/SequenceAlignment.swift[150-154]
## Recommended Fix
Store only the reachable columns of each row, or otherwise cap allocation while preserving a path to the endpoint. Add a long-reference, short-candidate test.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Re-alignment erases hand-set boundaries ✓ Resolved
Description
savePhrases deletes every stored phrase and reinserts only the supplied boundaries and origins,
without consulting existing manual rows. Saving a newly recognized set after movePhrase therefore
replaces a reader's corrected boundary with the recognizer's boundary.
Code

Sources/HanReaderPersistence/LibraryRepository+Listening.swift[R24-27]

+    public func savePhrases(_ phrases: [Phrase], for id: TextID) async throws {
+        try await dbQueue.write { db in
+            try db.execute(sql: "DELETE FROM phrase WHERE textId = ?", arguments: [id.rawValue])
+            for phrase in phrases {
Evidence
movePhrase records the manual origin, but the sole replacement method unconditionally deletes that
row. The schema identifies surviving re-alignment as the purpose of distinguishing manual origins.

Sources/HanReaderPersistence/LibraryRepository+Listening.swift[24-39]
Sources/HanReaderPersistence/LibraryRepository+Listening.swift[78-94]
Sources/HanReaderPersistence/LibrarySchema.swift[198-206]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Replacing recognized phrases discards manually corrected boundaries despite retaining their origin for re-alignment.
## Fix Focus Areas
- Sources/HanReaderPersistence/LibraryRepository+Listening.swift[24-39]
- Sources/HanReaderPersistence/LibraryRepository+Listening.swift[78-94]
## Recommended Fix
During re-alignment, reconcile incoming phrases with existing manual rows and retain their corrected boundaries where the corresponding text span remains identifiable. Test a move followed by replacement.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. New phrases inherit unrelated attempts ✓ Resolved
Description
savePhrases replaces positional phrase rows while retaining every attempt keyed by the old phrase
index. If re-alignment inserts, removes, or reorders phrases, attempts(for:) associates an earlier
answer and score with whatever new phrase occupies that index.
Code

Sources/HanReaderPersistence/LibraryRepository+Listening.swift[R20-23]

+    /// Attempts are deliberately **not** cleared. Re-aligning a recording
+    /// usually moves boundaries by fractions of a second, and throwing away
+    /// the reader's record of what they have already mastered would make
+    /// fixing one boundary cost them the whole text.
Evidence
The replacement deliberately leaves attempts intact, while both tables and the attempt reader use
only textId and positional idx to relate an attempt to a phrase.

Sources/HanReaderPersistence/LibraryRepository+Listening.swift[20-39]
Sources/HanReaderPersistence/LibraryRepository+Listening.swift[134-162]
Sources/HanReaderPersistence/LibrarySchema.swift[179-206]
Sources/HanReaderPersistence/LibrarySchema.swift[215-225]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Retaining index-keyed attempts across changed phrase divisions assigns old practice results to different text.
## Fix Focus Areas
- Sources/HanReaderPersistence/LibraryRepository+Listening.swift[20-39]
- Sources/HanReaderPersistence/LibraryRepository+Listening.swift[126-162]
## Recommended Fix
When replacing phrases, preserve attempts only for unchanged, identifiable text spans; remap matching attempts or remove those whose phrase changed. Test insertion and removal of a phrase before an attempted one.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

4. Long texts make phrase splitting quadratic ✓ Resolved
Description
characters(of:in:) copies the entire text's UTF-16 storage each time it measures a range.
phrases(in:) measures each sentence and then repeatedly measures phrases and packed clauses, so
splitting a document with many sentences or clauses repeatedly copies the whole document.
Code

Sources/HanReaderCore/Listening/PhraseSplitter.swift[R279-282]

+    private static func characters(of text: String, in range: Range<Int>) -> [Character] {
+        let utf16 = Array(text.utf16)
+        guard range.lowerBound >= 0, range.upperBound <= utf16.count else { return [] }
+        return Array(String(decoding: utf16[range], as: UTF16.self))
Evidence
The splitter calls characterCount per sentence, during clause packing, and during merging; each
call reaches the full-text Array(text.utf16) conversion.

Sources/HanReaderCore/Listening/PhraseSplitter.swift[112-121]
Sources/HanReaderCore/Listening/PhraseSplitter.swift[211-223]
Sources/HanReaderCore/Listening/PhraseSplitter.swift[241-258]
Sources/HanReaderCore/Listening/PhraseSplitter.swift[268-282]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Measuring each phrase copies the entire document, making splitting quadratic for documents with many phrases.
## Fix Focus Areas
- Sources/HanReaderCore/Listening/PhraseSplitter.swift[106-121]
- Sources/HanReaderCore/Listening/PhraseSplitter.swift[268-282]
## Recommended Fix
Prepare a shared UTF-16 representation or scorable-character offsets once per split, and measure individual ranges without copying the full text.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. Homophone answers fail the passing check 🐞 Bug ≡ Correctness
Description
DictationAttempt.isPassing() tests only accuracy, but DictationComparison counts only exact
matches as correct when calculating that accuracy. An attempt containing a homophone cannot meet the
default threshold, even when it has no other error and the passing contract explicitly treats that
input-method slip as acceptable.
Code

Sources/HanReaderCore/Listening/Phrase.swift[R114-120]

+    /// Whether this attempt is good enough to stop drilling the phrase.
+    ///
+    /// Not a perfect score: a learner who typed every character correctly but
+    /// chose one wrong homophone heard the phrase, and being sent back to it
+    /// for an input-method slip teaches nothing about listening.
+    public func isPassing(threshold: Double = 1) -> Bool {
+        accuracy >= threshold
Evidence
The classifier emits .homophone, but accuracy counts .correct marks alone and the default
passing threshold is one.

Sources/HanReaderCore/Listening/DictationComparison.swift[84-89]
Sources/HanReaderCore/Listening/DictationComparison.swift[139-145]
Sources/HanReaderCore/Listening/DictationComparison.swift[217-220]
Sources/HanReaderCore/Listening/Phrase.swift[114-120]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The default passing check rejects homophones that its own contract says should pass.
## Fix Focus Areas
- Sources/HanReaderCore/Listening/Phrase.swift[114-120]
- Sources/HanReaderCore/Listening/DictationComparison.swift[139-145]
## Recommended Fix
Define a passing score that credits acceptable listening outcomes, and persist enough of that result for `DictationAttempt` to apply it. Test an otherwise correct homophone attempt.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


6. Extra characters can pass as accurate ✓ Resolved
Description
DictationResult.accuracy divides exact matches only by expected characters, so insertion marks
never reduce the score. Typing the entire expected phrase plus additional characters yields accuracy
one and passes DictationAttempt.isPassing() despite its .extra error marks.
Code

Sources/HanReaderCore/Listening/DictationComparison.swift[R84-89]

+    /// Zero to one. An empty phrase scores one: there was nothing to get
+    /// wrong, and dividing by zero to say otherwise would be arithmetic
+    /// masquerading as judgement.
+    public var accuracy: Double {
+        expected == 0 ? 1 : Double(correct) / Double(expected)
+    }
Evidence
An insertion becomes an .extra error, but neither the numerator nor denominator of accuracy
accounts for it; passing consults only that accuracy.

Sources/HanReaderCore/Listening/DictationComparison.swift[38-43]
Sources/HanReaderCore/Listening/DictationComparison.swift[84-93]
Sources/HanReaderCore/Listening/DictationComparison.swift[139-145]
Sources/HanReaderCore/Listening/DictationComparison.swift[187-194]
Sources/HanReaderCore/Listening/Phrase.swift[114-120]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Extra typed characters are marked as errors but do not affect the persisted accuracy used for passing.
## Fix Focus Areas
- Sources/HanReaderCore/Listening/DictationComparison.swift[84-93]
- Sources/HanReaderCore/Listening/DictationComparison.swift[139-145]
- Sources/HanReaderCore/Listening/Phrase.swift[114-120]
## Recommended Fix
Penalize extra marks in the score used for passing, or store and check their count separately. Test a correct phrase with extra characters appended.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View medium (2)
7. Closing quotes become standalone phrases ✓ Resolved
Description
divide ends a clause at a sentence terminator and emits the following closing quote or bracket as
another clause. When the preceding clause is itself over budget, packing and minimum-length merging
both refuse to reunite them, leaving a punctuation-only practice phrase.
Code

Sources/HanReaderCore/Listening/PhraseSplitter.swift[R200-206]

+            if separators.contains(character) || terminators.contains(character) {
+                clauses.append(clauseStart ..< offset)
+                clauseStart = offset
+            }
+        }
+        if clauseStart < sentence.upperBound {
+            clauses.append(clauseStart ..< sentence.upperBound)
Evidence
Sentence scanning includes trailing marks, but clause scanning splits immediately at the terminator.
Both later combination steps reject a combined range that remains over budget.

Sources/HanReaderCore/Listening/PhraseSplitter.swift[91-99]
Sources/HanReaderCore/Listening/PhraseSplitter.swift[140-145]
Sources/HanReaderCore/Listening/PhraseSplitter.swift[197-228]
Sources/HanReaderCore/Listening/PhraseSplitter.swift[243-257]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Dividing an over-budget quoted sentence can detach its closing punctuation into a phrase of its own.
## Fix Focus Areas
- Sources/HanReaderCore/Listening/PhraseSplitter.swift[197-206]
- Sources/HanReaderCore/Listening/PhraseSplitter.swift[209-228]
## Recommended Fix
Extend a terminator-ending clause over immediately following closing marks before packing. Test a quoted, unsplittable sentence longer than the character budget.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


8. Repeated stops start the next phrase ✓ Resolved
Description
sentences(in:) finalizes a pending sentence before processing another terminator, because it
extends the pending end only for closing marks. In text such as 真的吗?!接下来我们继续。, the exclamation
mark starts the next sentence instead of staying with the question it closes.
Code

Sources/HanReaderCore/Listening/PhraseSplitter.swift[R140-148]

+            if pendingEnd != nil, isTrailing {
+                // A closing quote after the full stop still belongs to the
+                // sentence it closes.
+                pendingEnd = offset + width
+                offset += width
+                continue
+            }
+            if let end = pendingEnd, let began = start {
+                ranges.append(began ..< end)
Evidence
A terminator sets pendingEnd; the next character extends it only if it belongs to trailing, so a
second terminator triggers finalization and becomes the start of a new range.

Sources/HanReaderCore/Listening/PhraseSplitter.swift[79-99]
Sources/HanReaderCore/Listening/PhraseSplitter.swift[134-162]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Consecutive sentence-ending marks are assigned to different practice phrases.
## Fix Focus Areas
- Sources/HanReaderCore/Listening/PhraseSplitter.swift[134-160]
## Recommended Fix
While a sentence end is pending, extend it over adjacent terminators as well as closing marks. Test mixed question/exclamation marks and repeated half-width periods.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Tip of the day
💡 Did you know, you can import rules and skills only from the repositories your team follows

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread Sources/HanReaderCore/Listening/SequenceAlignment.swift
Comment thread Sources/HanReaderCore/Listening/PhraseSplitter.swift Outdated
Comment thread Sources/HanReaderPersistence/LibraryRepository+Listening.swift
Comment thread Sources/HanReaderPersistence/LibraryRepository+Listening.swift Outdated
Comment thread Sources/HanReaderCore/Listening/Phrase.swift
Comment thread Sources/HanReaderCore/Listening/DictationComparison.swift Outdated
Comment thread Sources/HanReaderCore/Listening/PhraseSplitter.swift
Comment thread Sources/HanReaderCore/Listening/PhraseSplitter.swift Outdated
All eight were real. Two of them were comments promising what the code did
not do, which is worse than a missing feature because it reads as done.

`.manual` was decorative. The doc said the origin exists so a re-alignment
can leave a corrected boundary alone; `savePhrases` then deleted everything
and reinserted the recogniser's times, so a reader would have fixed the same
boundary after every run for ever. Corrections now survive, matched by text
range rather than by index — re-aligning can insert or drop phrases, and an
index means nothing across that.

`isPassing` could never pass a homophone. Its contract said such an attempt
is acceptable because the learner heard the phrase; the accuracy it tested
counted only exact matches, so it could not reach the threshold. There are
two scores now: `accuracy`, which is strict, and `listeningAccuracy`, which
counts a homophone as heard and a tone slip as not. The gate uses the
second, which is the thesis of the feature rather than a concession to it.

Attempts were inherited across a re-alignment. They are keyed by index and
phrases are positional, so re-splitting handed an earlier answer and score
to a phrase the reader had never seen, marking it passed. An attempt now
survives only where its phrase still covers the same text.

Invented characters were free: typing the phrase correctly and appending
nonsense scored one and passed. They are in the denominator now.

真的吗?! split into a question and a phrase consisting of !, because only
closing marks extended a pending sentence end and a second terminator did
not. Dividing an over-long sentence could likewise leave a closing quote as
a practice phrase of its own, which neither packing nor the minimum-length
merge would take back once the clause before it was over budget.

Splitting was quadratic. `characters(of:in:)` copied the whole document's
UTF-16 storage on every measurement, and the splitter measures every
sentence, then every packing step, then every merge. A prefix sum built once
makes measuring a range a subtraction.

And the band could take the app with it. It widens by the difference in
length, so a long transcript against a near-empty recognition — speech
recognition that gave up, which is exactly what the next milestone will meet
— made it as wide as the transcript: about 367 MB for 6,000 characters
against three.

Clamping the band was the wrong fix and the first one tried: the band has to
span the difference in length or a row past the end of the shorter sequence
has a lower bound above its upper bound, which trapped. The right fix was to
stop paying for it. The cost matrix is needed for two rows only — a cell
depends on the row above and the cell to its left — so only the moves need
the full matrix, and a move is one byte. That is five times less memory, and
an hour-long chapter against its own transcript now fits in single-digit
megabytes. A pair too mismatched to band is declined rather than bought: the
trivial alignment says almost nothing lines up, which is what a real one
would have said at four hundred megabytes.

Fourteen mutants planted and killed. One further survivor was equivalent — a
per-row array clear whose cells nothing could read — so it is gone rather
than kept.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@widgetii

Copy link
Copy Markdown
Member Author

All eight addressed in ee98235. Every one was real, and two of them were comments promising what the code did not do — which is worse than a missing feature, because it reads as done.

2 — .manual was decorative. The doc said the origin exists so a re-alignment can leave a corrected boundary alone; savePhrases then deleted everything and reinserted the recogniser's times. A reader would have fixed the same boundary after every run, for ever. Corrections now survive, matched by text range rather than index — re-aligning can insert or drop phrases, and an index means nothing across that.

5 — isPassing could never pass a homophone. Its contract said such an attempt is acceptable because the learner heard the phrase; the accuracy it tested counted only exact matches. There are two scores now: accuracy (strict) and listeningAccuracy (a homophone counts as heard, a tone slip does not). The gate uses the second, which is the thesis of the feature rather than a concession to it.

3 — attempts were inherited across a re-alignment. Keyed by index, phrases positional, so re-splitting handed an earlier answer and score to a phrase the reader had never seen, marking it passed. An attempt now survives only where its phrase still covers the same text.

6 — invented characters were free. Typing the phrase correctly and appending nonsense scored one and passed. They are in the denominator now.

8 and 7 — punctuation became practice phrases. 真的吗?! split into a question plus a phrase consisting of !, because only closing marks extended a pending sentence end. Dividing an over-long sentence could likewise strand a closing quote, which neither packing nor the minimum-length merge would take back once the clause before it was over budget.

4 — splitting was quadratic. characters(of:in:) copied the whole document's UTF-16 storage on every measurement, and the splitter measures every sentence, then every packing step, then every merge. A prefix sum built once makes measuring a range a subtraction.

1 — the band could take the app with it, and this one is worth spelling out because my first fix was wrong.

The band widens by the difference in length, so a long transcript against a near-empty recognition — recognition that gave up, which is exactly what PR 2 will meet — made it as wide as the transcript: ~367 MB for 6,000 characters against 3.

Clamping the band broke it: the band has to span the difference in length or a row past the end of the shorter sequence has a lower bound above its upper bound, and the range traps. I had documented that invariant myself earlier in the same file and then violated it.

The right fix was to stop paying. The cost matrix is needed for two rows only — a cell depends on the row above and the cell to its left — so only the moves need the full matrix, and a move is one byte. Five times less memory: an hour-long chapter against its own transcript now fits in single-digit megabytes, where before it would have been tens. A pair genuinely too mismatched to band is declined rather than bought, returning the trivial alignment — which says almost nothing lines up, which is what a real one would have said at four hundred megabytes.

441 core tests, 111 persistence. Fourteen more mutants planted and killed; one further survivor was equivalent — a per-row array clear whose cells nothing could read — so it is deleted rather than kept. Both app targets built locally before pushing.

🤖 Generated with Claude Code

@widgetii
widgetii merged commit 575269b into main Oct 19, 2026
8 checks passed
@widgetii
widgetii deleted the feat/m11-listening-core branch October 19, 2026 20:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant