From 4aa299602bf2eb1404542c19d7bd9aef19fc0ce7 Mon Sep 17 00:00:00 2001 From: Dmitry Ilyin <6576495+widgetii@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:13:33 +0300 Subject: [PATCH 1/2] feat: the pure core for listening and dictation practice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Listening/DictationComparison.swift | 271 ++++++++++++++++ Sources/HanReaderCore/Listening/Phrase.swift | 122 +++++++ .../Listening/PhraseSplitter.swift | 302 ++++++++++++++++++ .../Listening/SequenceAlignment.swift | 233 ++++++++++++++ .../LibraryRepository+Listening.swift | 167 ++++++++++ .../LibraryRepository.swift | 8 +- .../HanReaderPersistence/LibrarySchema.swift | 66 ++++ .../DictationComparisonTests.swift | 239 ++++++++++++++ .../PhraseSplitterTests.swift | 242 ++++++++++++++ .../SequenceAlignmentTests.swift | 220 +++++++++++++ .../ListeningPracticeTests.swift | 200 ++++++++++++ .../MigrationTests.swift | 12 +- 12 files changed, 2078 insertions(+), 4 deletions(-) create mode 100644 Sources/HanReaderCore/Listening/DictationComparison.swift create mode 100644 Sources/HanReaderCore/Listening/Phrase.swift create mode 100644 Sources/HanReaderCore/Listening/PhraseSplitter.swift create mode 100644 Sources/HanReaderCore/Listening/SequenceAlignment.swift create mode 100644 Sources/HanReaderPersistence/LibraryRepository+Listening.swift create mode 100644 Tests/HanReaderCoreTests/DictationComparisonTests.swift create mode 100644 Tests/HanReaderCoreTests/PhraseSplitterTests.swift create mode 100644 Tests/HanReaderCoreTests/SequenceAlignmentTests.swift create mode 100644 Tests/HanReaderPersistenceTests/ListeningPracticeTests.swift diff --git a/Sources/HanReaderCore/Listening/DictationComparison.swift b/Sources/HanReaderCore/Listening/DictationComparison.swift new file mode 100644 index 0000000..264902c --- /dev/null +++ b/Sources/HanReaderCore/Listening/DictationComparison.swift @@ -0,0 +1,271 @@ +// HanReader — MIT licensed. See LICENSE. + +import Foundation + +/// What happened to one character of a dictation attempt. +/// +/// Five outcomes rather than right-and-wrong, because the three kinds of +/// wrong are different skills and a learner needs to know which one failed. +public enum DictationOutcome: UInt8, Sendable, Hashable, CaseIterable { + case correct + + /// The wrong character, but one that sounds exactly the same — 真 for 针, + /// both `zhen1`. + /// + /// They heard it correctly and their input method offered the wrong + /// candidate. Counting that as a listening mistake would be a lie about + /// the one thing this exercise measures. + case homophone + + /// The right syllable with the wrong tone — 是 `shi4` typed for 时 + /// `shi2`. + /// + /// The most useful signal in the exercise, and the one no other tool + /// gives: tones are what a learner's ear loses first and what no amount + /// of reading practice repairs. + case toneSlip + + /// A character with no reading in common with the expected one. This is + /// the only outcome that means "did not hear it". + case misheard + + /// Expected but not typed. + case missing + + /// Typed but not expected. + case extra + + /// Whether this counts against the score. Everything except a correct + /// character does; the distinction between them is for the reader, not + /// for the arithmetic. + public var isError: Bool { + self != .correct + } +} + +/// One character of a checked attempt, ready to render. +public struct DictationMark: Hashable, Sendable { + public let outcome: DictationOutcome + /// What the transcript says. Nil for an extra character. + public let expected: Character? + /// What the learner typed. Nil for a missing character. + public let typed: Character? + /// Offset of `expected` in the phrase's text, in UTF-16 units, so the + /// mark can be drawn over the right character. Nil for an extra. + public let offset: Int? + + public init( + outcome: DictationOutcome, + expected: Character?, + typed: Character?, + offset: Int?, + ) { + self.outcome = outcome + self.expected = expected + self.typed = typed + self.offset = offset + } +} + +/// A checked dictation attempt. +public struct DictationResult: Hashable, Sendable { + public let marks: [DictationMark] + /// Characters of the transcript that were typed correctly. + public let correct: Int + /// Characters the transcript expected — the denominator. + public let expected: Int + + public init(marks: [DictationMark], correct: Int, expected: Int) { + self.marks = marks + self.correct = correct + self.expected = expected + } + + /// 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) + } + + public var isPerfect: Bool { + correct == expected && marks.allSatisfy { !$0.outcome.isError } + } + + public func count(of outcome: DictationOutcome) -> Int { + marks.count { $0.outcome == outcome } + } +} + +/// Checks what a learner typed against what was said. +/// +/// ### Punctuation and spacing are not the exercise +/// +/// Both sides are reduced to the characters a learner actually heard — Han, +/// Latin and digits — before anything is compared. Whether they typed 。 or +/// ,, or nothing at all, is a transcription convention; it is not evidence +/// about their ears. It is also unfair in a way that compounds: a learner +/// typing through a Chinese IME gets punctuation inserted for them, so +/// scoring it would mark some of them down for their keyboard. +/// +/// ### Three kinds of wrong +/// +/// Where a character is wrong, the dictionary already open is asked what +/// both characters sound like, through `DictionaryLookup.readings(forCharacter:)`. +/// Sharing a reading with its tone is a homophone; sharing only the syllable +/// is a tone slip; sharing nothing is a mishearing. That is the whole reason +/// for building this inside HanReader rather than using a general-purpose +/// dictation tool — the readings are already here. +public enum DictationComparison { + /// Compares an attempt against the expected text. + /// + /// - Parameter readings: consulted only for characters that did not + /// match, which on a good attempt is almost none. + public static func check( + typed: String, + against expected: String, + readings: (Character) -> [CharacterReading] = { _ in [] }, + ) + -> DictationResult + { + let expectedCharacters = scorable(expected) + let typedCharacters = scorable(typed) + + let steps = SequenceAlignment.align( + reference: expectedCharacters.map(\.character), + candidate: typedCharacters.map(\.character), + ) + + let marks = steps.compactMap { + mark(for: $0, expected: expectedCharacters, typed: typedCharacters, readings: readings) + } + return DictationResult( + marks: marks, + correct: marks.count { $0.outcome == .correct }, + expected: expectedCharacters.count, + ) + } + + /// Turns one alignment step into something renderable. + private static func mark( + for step: AlignmentStep, + expected: [Scorable], + typed: [Scorable], + readings: (Character) -> [CharacterReading], + ) + -> DictationMark? + { + switch step.operation { + case .match: + guard let index = step.reference else { return nil } + return DictationMark( + outcome: .correct, + expected: expected[index].character, + typed: expected[index].character, + offset: expected[index].offset, + ) + case .substitute: + guard let index = step.reference, let other = step.candidate else { return nil } + return DictationMark( + outcome: outcome( + typed: typed[other].character, + expected: expected[index].character, + readings: readings, + ), + expected: expected[index].character, + typed: typed[other].character, + offset: expected[index].offset, + ) + case .delete: + guard let index = step.reference else { return nil } + return DictationMark( + outcome: .missing, + expected: expected[index].character, + typed: nil, + offset: expected[index].offset, + ) + case .insert: + guard let index = step.candidate else { return nil } + return DictationMark( + outcome: .extra, + expected: nil, + typed: typed[index].character, + offset: nil, + ) + } + } + + /// Which kind of wrong a substitution is. + static func outcome( + typed: Character, + expected: Character, + readings: (Character) -> [CharacterReading], + ) + -> DictationOutcome + { + // No early-out for a character the dictionaries do not know: an empty + // set is disjoint from everything, so both tiers below already fall + // through to `.misheard`. A guard saying so would read as though it + // were load-bearing. + let typedReadings = readings(typed) + let expectedReadings = readings(expected) + + // Compared case-insensitively: a surname is written `Zhen1` and is + // the same sound as `zhen1`. Treating those as different readings + // would call 真/针 a mishearing whenever one of them happened to be + // a surname, which for single characters is most of them. + let typedNumeric = Set(typedReadings.map { $0.numeric.lowercased() }) + let expectedNumeric = Set(expectedReadings.map { $0.numeric.lowercased() }) + if !typedNumeric.isDisjoint(with: expectedNumeric) { + return .homophone + } + + let typedBases = Set(typedReadings.compactMap { base(of: $0.numeric) }) + let expectedBases = Set(expectedReadings.compactMap { base(of: $0.numeric) }) + if !typedBases.isDisjoint(with: expectedBases) { + return .toneSlip + } + return .misheard + } + + /// The syllable without its tone digit. `zhen1` → `zhen`. + /// + /// Taken off the numeric spelling rather than re-parsed: the numeric form + /// is the cross-dictionary key this app already stores everywhere, and + /// parsing it back into a `PinyinSyllable` to read `.base` would be the + /// same answer at more cost and with more ways to disagree. + static func base(of numeric: String) -> String? { + let trimmed = numeric.lowercased() + guard let last = trimmed.last else { return nil } + let base = last.isNumber ? String(trimmed.dropLast()) : trimmed + return base.isEmpty ? nil : base + } + + /// A character worth scoring, with where it sits in the original text. + struct Scorable: Hashable { + let character: Character + /// UTF-16 offset into the text it came from. + let offset: Int + } + + /// Drops punctuation and whitespace, keeping each survivor's position so + /// a mark can still be drawn over the right character of the phrase. + static func scorable(_ text: String) -> [Scorable] { + var result: [Scorable] = [] + var offset = 0 + for character in text { + let width = character.utf16.count + let counts = character.unicodeScalars.contains { scalar in + switch ScalarClass.classify(scalar) { + case .han, .latin, .number: true + case .punctuation, .whitespace, .other: false + } + } + if counts { + result.append(Scorable(character: character, offset: offset)) + } + offset += width + } + return result + } +} diff --git a/Sources/HanReaderCore/Listening/Phrase.swift b/Sources/HanReaderCore/Listening/Phrase.swift new file mode 100644 index 0000000..52882f3 --- /dev/null +++ b/Sources/HanReaderCore/Listening/Phrase.swift @@ -0,0 +1,122 @@ +// HanReader — MIT licensed. See LICENSE. + +public import Foundation + +/// Where a phrase's boundaries came from. +public enum PhraseOrigin: String, Sendable, Hashable, CaseIterable { + /// Placed by aligning the transcript against speech recognition. + case recognised + /// Placed or corrected by the reader. + /// + /// Distinguished so that re-aligning a recording can leave alone the + /// boundaries somebody has already fixed by hand. A reader who drags a + /// boundary and then re-runs alignment should not have to do it twice. + case manual +} + +/// What the reader thought of a phrase, for deciding what to drill again. +/// +/// Three levels, matching WorkAudioBook's EASY/MID/HARD — enough to filter a +/// replay, and deliberately **not** a spaced-repetition schedule. The +/// project's 1.0 scope rules out an SRS, and a grade that only means "show me +/// this one again" keeps the promise. +public enum PhraseGrade: String, Sendable, Hashable, CaseIterable { + case easy + case medium + case hard +} + +/// One unit of listening practice: a span of text and the span of recording +/// that says it. +public struct Phrase: Hashable, Sendable, Identifiable { + /// Position within its text, counting from zero. A phrase has no + /// identity apart from where it sits, and re-aligning replaces the lot. + public let index: Int + /// Seconds into the recording. + public let start: Double + public let end: Double + /// UTF-16 offsets into the text's content — the same unit as + /// `Token.range` and `ReadingPosition.characterOffset`. + public let textRange: Range + /// How much of this phrase the recogniser matched outright, rather than + /// had bridged across its own mistakes. Zero to one. + public let confidence: Double + public let origin: PhraseOrigin + public let grade: PhraseGrade? + + public var id: Int { + index + } + + public init( + index: Int, + start: Double, + end: Double, + textRange: Range, + confidence: Double = 1, + origin: PhraseOrigin = .recognised, + grade: PhraseGrade? = nil, + ) { + self.index = index + self.start = start + self.end = end + self.textRange = textRange + self.confidence = confidence + self.origin = origin + self.grade = grade + } + + public var duration: Double { + max(0, end - start) + } + + /// The span to play, as a range the transport can take. + /// + /// Empty when the boundaries are the wrong way round, which a + /// badly-dragged handle can produce. An empty range plays nothing, which + /// is a great deal better than a range that traps. + public var audioRange: Range { + start < end ? start ..< end : start ..< start + } + + /// The text of this phrase, out of its document's content. + public func text(in content: String) -> String { + let utf16 = Array(content.utf16) + guard textRange.lowerBound >= 0, textRange.upperBound <= utf16.count else { return "" } + return String(decoding: utf16[textRange], as: UTF16.self) + } +} + +/// The last attempt at one phrase. +public struct DictationAttempt: Hashable, Sendable { + public let phraseIndex: Int + public let typed: String + public let accuracy: Double + /// How many times this phrase has been attempted, so a learner can see + /// that the one they keep failing is the one they keep failing. + public let attempts: Int + public let updatedAt: Date + + public init( + phraseIndex: Int, + typed: String, + accuracy: Double, + attempts: Int, + updatedAt: Date, + ) { + self.phraseIndex = phraseIndex + self.typed = typed + self.accuracy = accuracy + self.attempts = attempts + self.updatedAt = updatedAt + } + + /// 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 + } +} diff --git a/Sources/HanReaderCore/Listening/PhraseSplitter.swift b/Sources/HanReaderCore/Listening/PhraseSplitter.swift new file mode 100644 index 0000000..2e7b219 --- /dev/null +++ b/Sources/HanReaderCore/Listening/PhraseSplitter.swift @@ -0,0 +1,302 @@ +// HanReader — MIT licensed. See LICENSE. + +import Foundation + +/// How a text is cut into units a learner can hold in their head long enough +/// to type back. +public struct PhraseSplitting: Hashable, Sendable { + /// Longest unit, in characters. + /// + /// Expressed in characters rather than seconds because the splitter never + /// sees the audio. The caller converts: a recording's characters divided + /// by its duration gives a rate, and the rate turns a target of seconds + /// into this. For 听力64 — 609 characters over about 147 seconds, so 4.1 + /// a second — WorkAudioBook's seven-second default comes to 29. + public let maximumLength: Int + + /// Shortest unit worth keeping on its own. + /// + /// A clause below this is glued to the one before it rather than offered + /// as a phrase of its own. Without it, 「其实,我暗暗地…」 opens the exercise + /// with a two-character fragment that is neither a listening test nor + /// recognisable as anything. + public let minimumLength: Int + + public init(maximumLength: Int = 29, minimumLength: Int = 4) { + self.maximumLength = max(1, maximumLength) + self.minimumLength = max(0, min(minimumLength, max(1, maximumLength))) + } + + /// The default: WorkAudioBook's seven seconds, at a typical rate for + /// clearly-read listening material. + public static let standard = Self() + + /// Converts a target duration into a character budget, given what the + /// recording actually contains. + /// + /// Measured from the material rather than assumed, because the rate is + /// the whole conversion and it differs by a factor of two between a + /// graded listening exercise and a native-speed audiobook. + public static func forAudio( + characters: Int, + duration: Double, + seconds: Double = 7, + minimumSeconds: Double = 1, + ) + -> Self + { + guard duration > 0, characters > 0 else { return .standard } + let rate = Double(characters) / duration + return Self( + maximumLength: Int((seconds * rate).rounded()), + minimumLength: Int((minimumSeconds * rate).rounded()), + ) + } +} + +/// Cuts a text into practice phrases. +/// +/// ### Sentences first, then clauses, and only where needed +/// +/// A sentence is the natural unit and is kept whole wherever it fits. The +/// ones that do not fit are cut again at internal punctuation — which is +/// what WorkAudioBook does with its seven-second cap, and what a teacher does +/// when dictating. +/// +/// 听力64 is the case this was measured against: 22 sentences running 9 to 42 +/// characters. At a 29-character budget it yields 32 phrases, none over the +/// cap. Splitting at *every* comma instead would give 61, most of them three +/// or four characters long — not a listening test, just a slow way to read. +/// +/// ### Half-width punctuation is not an edge case +/// +/// 听力64 contains **39 half-width commas and no full-width ones**: it was +/// typed or scanned by someone whose input method produced `,`. A splitter +/// that only knew `,` would leave its 42-character sentence whole and the +/// cap would silently not apply. Both widths are therefore first-class, for +/// terminators and separators alike. +public enum PhraseSplitter { + /// Ends a sentence. + static let terminators: Set = [ + "。", "!", "?", "…", ";", + ".", "!", "?", ";", + ] + + /// Divides a sentence without ending it. + static let separators: Set = [ + ",", "、", ":", + ",", ":", + ] + + /// Marks that belong to the phrase they follow and must never start one. + /// + /// Closing quotes and brackets, which a split at the terminator inside + /// them would otherwise strand: 「说:“这是什么意思?”」 must not leave a + /// phrase consisting of `”`. + static let trailing: Set = [ + "”", "’", ")", "】", "》", "」", "』", "〉", "]", "}", + "\"", "'", ")", "]", "}", + ] + + /// Phrase ranges, as UTF-16 offsets into `text`. + /// + /// UTF-16 because that is what every position in this app is measured in + /// — `Token.range`, `ReadingPosition.characterOffset`, `NSRange` — and + /// mixing units is how an off-by-one becomes a crash in a different file. + public static func phrases( + in text: String, + splitting: PhraseSplitting = .standard, + ) + -> [Range] + { + var phrases: [Range] = [] + for sentence in sentences(in: text) { + let length = characterCount(of: text, in: sentence) + if length <= splitting.maximumLength { + phrases.append(sentence) + continue + } + phrases.append(contentsOf: divide(sentence, in: text, splitting: splitting)) + } + return merge(phrases, in: text, splitting: splitting) + } + + // MARK: - Sentences + + /// Splits at terminators, keeping the terminator and any closing marks + /// with the sentence they end, and dropping the whitespace between. + static func sentences(in text: String) -> [Range] { + var ranges: [Range] = [] + var start: Int? + var offset = 0 + var pendingEnd: Int? + + for character in text { + let width = character.utf16.count + let isTerminator = terminators.contains(character) + let isTrailing = trailing.contains(character) + let isBlank = character.unicodeScalars.allSatisfy(\.properties.isWhitespace) + + 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) + start = nil + pendingEnd = nil + } + if isBlank, start == nil { + offset += width + continue + } + if start == nil { + start = offset + } + if isTerminator { + pendingEnd = offset + width + } + offset += width + } + + if let end = pendingEnd, let began = start { + ranges.append(began ..< end) + } else if let began = start { + // A final sentence with no terminator, which is most transcripts' + // last line and every heading. + let trimmed = trimmingTrailingWhitespace(text, from: began, to: offset) + if trimmed > began { + ranges.append(began ..< trimmed) + } + } + return ranges + } + + // MARK: - Clauses + + /// Cuts an over-long sentence at its internal punctuation, greedily + /// filling each phrase up to the budget. + /// + /// Greedy rather than balanced on purpose: a learner dictating works + /// front to back, and a phrase that ends where the speaker paused is + /// easier to hold than one trimmed to make the arithmetic even. + private static func divide( + _ sentence: Range, + in text: String, + splitting: PhraseSplitting, + ) + -> [Range] + { + var clauses: [Range] = [] + var clauseStart = sentence.lowerBound + var offset = sentence.lowerBound + + for character in characters(of: text, in: sentence) { + let width = character.utf16.count + offset += width + if separators.contains(character) || terminators.contains(character) { + clauses.append(clauseStart ..< offset) + clauseStart = offset + } + } + if clauseStart < sentence.upperBound { + clauses.append(clauseStart ..< sentence.upperBound) + } + + // Then pack clauses back up to the budget, so a sentence of five + // short clauses becomes two phrases rather than five. + var packed: [Range] = [] + for clause in clauses { + guard let last = packed.last else { + packed.append(clause) + continue + } + let combined = last.lowerBound ..< clause.upperBound + if characterCount(of: text, in: combined) <= splitting.maximumLength { + packed[packed.count - 1] = combined + } else { + packed.append(clause) + } + } + // A clause longer than the budget on its own is emitted as it is. + // Cutting mid-word would make it unreadable, and there is no + // punctuation left to cut at — better one long phrase the reader can + // split by hand than a phrase ending in the middle of a word. + return packed + } + + // MARK: - Minimum length + + /// Glues a too-short phrase onto its predecessor. + private static func merge( + _ phrases: [Range], + in text: String, + splitting: PhraseSplitting, + ) + -> [Range] + { + guard splitting.minimumLength > 0 else { return phrases } + var merged: [Range] = [] + for phrase in phrases { + guard characterCount(of: text, in: phrase) < splitting.minimumLength, + let last = merged.last + else { + merged.append(phrase) + continue + } + // Only when the result still fits, or a run of short fragments + // would quietly grow one phrase past the cap. + let combined = last.lowerBound ..< phrase.upperBound + if characterCount(of: text, in: combined) <= splitting.maximumLength { + merged[merged.count - 1] = combined + } else { + merged.append(phrase) + } + } + return merged + } + + // MARK: - Measuring + + /// Characters a learner would have to type: Han, Latin and digits. A + /// budget counted in punctuation would shrink the phrase that happens to + /// be heavily punctuated, which has nothing to do with how long it takes + /// to say. + static func characterCount(of text: String, in range: Range) -> Int { + characters(of: text, in: range).count { character in + character.unicodeScalars.contains { scalar in + switch ScalarClass.classify(scalar) { + case .han, .latin, .number: true + case .punctuation, .whitespace, .other: false + } + } + } + } + + private static func characters(of text: String, in range: Range) -> [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)) + } + + private static func trimmingTrailingWhitespace( + _ text: String, + from start: Int, + to end: Int, + ) + -> Int + { + let utf16 = Array(text.utf16) + var cursor = end + while cursor > start, + let scalar = Unicode.Scalar(utf16[cursor - 1]), + scalar.properties.isWhitespace + { + cursor -= 1 + } + return cursor + } +} diff --git a/Sources/HanReaderCore/Listening/SequenceAlignment.swift b/Sources/HanReaderCore/Listening/SequenceAlignment.swift new file mode 100644 index 0000000..acd1233 --- /dev/null +++ b/Sources/HanReaderCore/Listening/SequenceAlignment.swift @@ -0,0 +1,233 @@ +// HanReader — MIT licensed. See LICENSE. + +import Foundation + +/// What happened to one position when two sequences were lined up. +public enum AlignmentOperation: UInt8, Sendable, Hashable, CaseIterable { + /// The same element in both. + case match + /// Different elements in the same place. + case substitute + /// Present in the candidate, absent from the reference. + case insert + /// Present in the reference, absent from the candidate. + case delete +} + +/// One position in a lined-up pair of sequences. +/// +/// Per element rather than per run, because the two callers want different +/// things from it: scoring a dictation wants to say *what* each wrong +/// character was, and forced alignment wants to know which recognised element +/// a transcript character landed on so it can read its timestamp. Runs are a +/// presentation concern and are coalesced separately. +public struct AlignmentStep: Hashable, Sendable { + public let operation: AlignmentOperation + /// Index into the reference, or nil for an insertion. + public let reference: Int? + /// Index into the candidate, or nil for a deletion. + public let candidate: Int? + + public init(operation: AlignmentOperation, reference: Int?, candidate: Int?) { + self.operation = operation + self.reference = reference + self.candidate = candidate + } +} + +/// Lines up two sequences, element by element. +/// +/// One implementation serves both halves of listening practice, which is the +/// main reason they are one milestone: +/// +/// - **Scoring a dictation** lines up what the learner typed against the +/// transcript, so every character can be called right, wrong, missing or +/// extra. +/// - **Forced alignment** lines up what the speech recogniser heard against +/// the *same* transcript. The recogniser's mistakes do not matter: its +/// correct neighbours carry the timing across them, which is the whole +/// trick of aligning audio to a text you already have. +/// +/// ### Banded, so an audiobook does not need a gigabyte +/// +/// Plain Levenshtein backtracking needs the whole n×m matrix. Two minutes of +/// listening practice is 600 characters against 600 — a third of a megabyte, +/// nothing. A twenty-minute chapter is 6,000 against 6,000, which is 36 MB, +/// and an hour is over a gigabyte. +/// +/// The two sequences being aligned are *nearly the same sequence*, though — +/// one is a transcript and the other is a recognition of the same speech, in +/// the same order. The true path therefore never strays far from the +/// diagonal, so only a band around it is computed. Memory becomes +/// O(n × band) rather than O(n × m). +/// +/// A path that would stray outside the band is not found; what comes back +/// instead is the best path within it. That degrades the alignment rather +/// than failing it, which is the right trade for a feature whose boundaries +/// the reader can nudge anyway. +public enum SequenceAlignment { + /// How far from the diagonal to look, beyond the sequences' difference in + /// length. Generous: 128 characters of drift is already a recogniser + /// having lost half a minute of speech, and the cost of the slack is + /// linear. + public static let defaultSlack = 128 + + public static func align( + reference: [Character], + candidate: [Character], + slack: Int = defaultSlack, + ) + -> [AlignmentStep] + { + // Trivial cases answered directly. They are not just an optimisation: + // the band arithmetic below assumes at least one row and column, and + // an empty dictation attempt is the commonest input of all. + guard !reference.isEmpty else { + return candidate.indices.map { + AlignmentStep(operation: .insert, reference: nil, candidate: $0) + } + } + guard !candidate.isEmpty else { + return reference.indices.map { + AlignmentStep(operation: .delete, reference: $0, candidate: nil) + } + } + + let band = Band(rows: reference.count, columns: candidate.count, slack: slack) + let moves = fill(reference: reference, candidate: candidate, band: band) + return backtrace(moves: moves, band: band) + } + + /// The band around the diagonal that is worth computing, and the + /// arithmetic for addressing it. + /// + /// A type rather than three closures because both the fill and the + /// backtrace need exactly the same bounds, and two copies of that + /// arithmetic that drifted apart would produce an alignment that cannot + /// be walked back. + struct Band { + let rows: Int + let columns: Int + let band: Int + let width: Int + + init(rows: Int, columns: Int, slack: Int) { + self.rows = rows + self.columns = columns + // The `abs` term is load-bearing, not padding: without it a row + // past the end of the shorter sequence would have a lower bound + // above its upper bound, and forming that range traps. + band = max(1, abs(rows - columns) + max(0, slack)) + width = 2 * band + 1 + } + + func lowerBound(_ row: Int) -> Int { + max(0, row - band) + } + + func upperBound(_ row: Int) -> Int { + min(columns, row + band) + } + + func slot(_ row: Int, _ column: Int) -> Int? { + guard column >= lowerBound(row), column <= upperBound(row) else { return nil } + return row * width + (column - lowerBound(row)) + } + + var cellCount: Int { + (rows + 1) * width + } + } + + /// Fills the cost matrix, keeping only the move that produced each cell. + private static func fill( + reference: [Character], + candidate: [Character], + band: Band, + ) + -> [UInt8] + { + let unreachable = Int32.max / 4 + // Flat arrays rather than nested ones: this is the hot loop, and a + // `[[Int32]]` is a row of pointers to heap allocations. + var costs = [Int32](repeating: unreachable, count: band.cellCount) + var moves = [UInt8](repeating: 0, count: band.cellCount) + + func cost(_ row: Int, _ column: Int) -> Int32 { + guard row >= 0, column >= 0, let index = band.slot(row, column) else { + return unreachable + } + return costs[index] + } + + for row in 0 ... band.rows { + for column in band.lowerBound(row) ... band.upperBound(row) { + guard let index = band.slot(row, column) else { continue } + if row == 0, column == 0 { + costs[index] = 0 + continue + } + let same = row > 0 && column > 0 && reference[row - 1] == candidate[column - 1] + var best = cost(row - 1, column - 1) + (same ? 0 : 1) + var move = same ? AlignmentOperation.match : .substitute + if cost(row - 1, column) + 1 < best { + best = cost(row - 1, column) + 1 + move = .delete + } + if cost(row, column - 1) + 1 < best { + best = cost(row, column - 1) + 1 + move = .insert + } + costs[index] = best + moves[index] = move.rawValue + } + } + return moves + } + + /// Walks the recorded moves back from the end, then reverses, so the + /// steps come out in reading order. + private static func backtrace(moves: [UInt8], band: Band) -> [AlignmentStep] { + var steps: [AlignmentStep] = [] + steps.reserveCapacity(max(band.rows, band.columns)) + var row = band.rows + var column = band.columns + + while row > 0 || column > 0 { + // Outside the band, or at an edge: the only move left is the one + // that exists. Handling these explicitly keeps the loop total, + // which matters because a non-terminating backtrace is an app + // that hangs rather than one that scores badly. + guard row > 0 else { + steps.append(.init(operation: .insert, reference: nil, candidate: column - 1)) + column -= 1 + continue + } + guard column > 0 else { + steps.append(.init(operation: .delete, reference: row - 1, candidate: nil)) + row -= 1 + continue + } + guard let index = band.slot(row, column), + let move = AlignmentOperation(rawValue: moves[index]) + else { + steps.append(.init(operation: .delete, reference: row - 1, candidate: nil)) + row -= 1 + continue + } + switch move { + case .match, .substitute: + steps.append(.init(operation: move, reference: row - 1, candidate: column - 1)) + row -= 1 + column -= 1 + case .delete: + steps.append(.init(operation: .delete, reference: row - 1, candidate: nil)) + row -= 1 + case .insert: + steps.append(.init(operation: .insert, reference: nil, candidate: column - 1)) + column -= 1 + } + } + return steps.reversed() + } +} diff --git a/Sources/HanReaderPersistence/LibraryRepository+Listening.swift b/Sources/HanReaderPersistence/LibraryRepository+Listening.swift new file mode 100644 index 0000000..99120c8 --- /dev/null +++ b/Sources/HanReaderPersistence/LibraryRepository+Listening.swift @@ -0,0 +1,167 @@ +// HanReader — MIT licensed. See LICENSE. + +public import Foundation +internal import GRDB +public import HanReaderCore + +/// Listening practice: the phrases a recording was cut into, and how the +/// reader has done at each. +/// +/// Split out for the same reason as the audio section above — one coherent +/// concern, arriving whole in M11, and the repository's own body is long +/// enough already. +extension LibraryRepository { + /// Replaces a text's phrases wholesale. + /// + /// All or nothing, in one transaction. Phrases are positional: a half- + /// written set is not a shorter set, it is a set whose indices no longer + /// mean what the attempts recorded against them mean. + /// + /// 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. + 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 { + try db.execute(sql: """ + INSERT INTO phrase + (textId, idx, startTime, endTime, textStart, textEnd, + confidence, origin, grade) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, arguments: [ + id.rawValue, phrase.index, phrase.start, phrase.end, + phrase.textRange.lowerBound, phrase.textRange.upperBound, + phrase.confidence, phrase.origin.rawValue, phrase.grade?.rawValue, + ]) + } + } + } + + public func phrases(for id: TextID) async throws -> [Phrase] { + try await dbQueue.read { db in + try Row.fetchAll( + db, + sql: """ + SELECT idx, startTime, endTime, textStart, textEnd, confidence, origin, grade + FROM phrase WHERE textId = ? ORDER BY idx + """, + arguments: [id.rawValue], + ).map { row in + Phrase( + index: row["idx"], + start: row["startTime"], + end: row["endTime"], + textRange: (row["textStart"] as Int) ..< (row["textEnd"] as Int), + confidence: row["confidence"], + // An unknown value becomes `.recognised` rather than + // failing the read. A row written by a later version is + // not a reason to refuse a reader their own library. + origin: PhraseOrigin(rawValue: row["origin"] ?? "") ?? .recognised, + grade: (row["grade"] as String?).flatMap(PhraseGrade.init(rawValue:)), + ) + } + } + } + + public func hasPhrases(for id: TextID) async throws -> Bool { + try await dbQueue.read { db in + try Int.fetchOne( + db, + sql: "SELECT COUNT(*) FROM phrase WHERE textId = ?", + arguments: [id.rawValue], + ) ?? 0 > 0 + } + } + + /// Moves one phrase's boundaries, and records that a person did it. + /// + /// Marked `manual` so a later re-alignment can leave it alone. Only the + /// times change: dragging a boundary in the waveform says nothing about + /// which words belong to the phrase. + public func movePhrase( + _ index: Int, + in id: TextID, + start: Double, + end: Double, + ) async throws { + try await dbQueue.write { db in + try db.execute(sql: """ + UPDATE phrase SET startTime = ?, endTime = ?, origin = ? + WHERE textId = ? AND idx = ? + """, arguments: [start, end, PhraseOrigin.manual.rawValue, id.rawValue, index]) + } + } + + public func gradePhrase( + _ index: Int, + in id: TextID, + as grade: PhraseGrade?, + ) async throws { + try await dbQueue.write { db in + try db.execute( + sql: "UPDATE phrase SET grade = ? WHERE textId = ? AND idx = ?", + arguments: [grade?.rawValue, id.rawValue, index], + ) + } + } + + public func forgetPhrases(for id: TextID) async throws { + try await dbQueue.write { db in + try db.execute(sql: "DELETE FROM phrase WHERE textId = ?", arguments: [id.rawValue]) + try db.execute( + sql: "DELETE FROM dictationAttempt WHERE textId = ?", + arguments: [id.rawValue], + ) + } + } + + // MARK: - Attempts + + /// Records an attempt, counting it against however many came before. + /// + /// The count is incremented in SQL rather than read and written back: two + /// quick attempts would otherwise race and record one. + public func recordAttempt( + _ typed: String, + accuracy: Double, + at index: Int, + in id: TextID, + now: Date = .now, + ) async throws { + try await dbQueue.write { db in + try db.execute(sql: """ + INSERT INTO dictationAttempt (textId, idx, typed, accuracy, attempts, updatedAt) + VALUES (?, ?, ?, ?, 1, ?) + ON CONFLICT(textId, idx) DO UPDATE SET + typed = excluded.typed, + accuracy = excluded.accuracy, + attempts = dictationAttempt.attempts + 1, + updatedAt = excluded.updatedAt + """, arguments: [id.rawValue, index, typed, accuracy, now]) + } + } + + public func attempts(for id: TextID) async throws -> [Int: DictationAttempt] { + try await dbQueue.read { db in + try Row.fetchAll( + db, + sql: """ + SELECT idx, typed, accuracy, attempts, updatedAt + FROM dictationAttempt WHERE textId = ? + """, + arguments: [id.rawValue], + ).reduce(into: [:]) { result, row in + let index: Int = row["idx"] + result[index] = DictationAttempt( + phraseIndex: index, + typed: row["typed"], + accuracy: row["accuracy"], + attempts: row["attempts"], + updatedAt: row["updatedAt"], + ) + } + } + } +} diff --git a/Sources/HanReaderPersistence/LibraryRepository.swift b/Sources/HanReaderPersistence/LibraryRepository.swift index c966c7a..37643f1 100644 --- a/Sources/HanReaderPersistence/LibraryRepository.swift +++ b/Sources/HanReaderPersistence/LibraryRepository.swift @@ -20,7 +20,13 @@ public struct LibraryRepository: Sendable { case alreadyPresent(TextID) } - private let dbQueue: DatabaseQueue + /// Internal rather than private because this type's implementation spans + /// files: `private` in Swift reaches extensions in the *same* file only, + /// so splitting the listening methods out would otherwise be impossible. + /// GRDB still cannot leak past this module — `internal import GRDB` and + /// the SwiftLint boundary rule see to that, which is the guarantee that + /// actually matters. + let dbQueue: DatabaseQueue /// Where attached audio lives. Needed so that deleting a text can remove /// its audio file, not merely the row pointing at it. diff --git a/Sources/HanReaderPersistence/LibrarySchema.swift b/Sources/HanReaderPersistence/LibrarySchema.swift index 68bf9e4..3b1d0db 100644 --- a/Sources/HanReaderPersistence/LibrarySchema.swift +++ b/Sources/HanReaderPersistence/LibrarySchema.swift @@ -38,6 +38,11 @@ enum LibrarySchema { try createDictionaryPreferenceTable(db) } + migrator.registerMigration("v3_listening_practice") { db in + try createPhraseTable(db) + try createDictationAttemptTable(db) + } + return migrator } @@ -160,6 +165,67 @@ enum LibrarySchema { } } + // MARK: - v3: listening and dictation + + /// One practice unit: a span of the text and the span of audio that says + /// it. + /// + /// Derived data — it can always be computed again from the text and the + /// recording — but not *cheaply*: the audio has to be run through speech + /// recognition to produce it, which costs seconds and a model download. + /// Storing it is the difference between opening a text and waiting for + /// one. + /// + /// Keyed on `(textId, idx)` rather than carrying an id of its own. + /// Phrases have no identity apart from their position in their text, and + /// re-aligning a recording replaces the lot. + private static func createPhraseTable(_ db: Database) throws { + try db.create(table: "phrase") { table in + table.column("textId", .integer) + .notNull() + .references("text", onDelete: .cascade) + table.column("idx", .integer).notNull() + // Seconds into the recording. + table.column("startTime", .double).notNull() + table.column("endTime", .double).notNull() + // UTF-16 offsets into `text.content`, the same unit as + // `readingPosition.characterOffset` and `Token.range`. + table.column("textStart", .integer).notNull() + table.column("textEnd", .integer).notNull() + // How much of this phrase the recogniser actually matched, as + // against bridged across its own mistakes. Low values are what + // the UI offers for review rather than silently trusting. + table.column("confidence", .double).notNull() + // 'recognised' or 'manual'. A boundary the reader dragged must + // survive a re-alignment of everything around it, so the two are + // distinguishable. + table.column("origin", .text).notNull() + // NULL until the reader says. A replay filter, not a schedule: + // this is deliberately not an SRS. + table.column("grade", .text) + table.primaryKey(["textId", "idx"]) + } + } + + /// The last attempt at one phrase. + /// + /// The latest rather than a history, and a count beside it. A history is + /// a reporting feature nobody asked for; what a learner wants to know is + /// whether they have it *now*. + private static func createDictationAttemptTable(_ db: Database) throws { + try db.create(table: "dictationAttempt") { table in + table.column("textId", .integer) + .notNull() + .references("text", onDelete: .cascade) + table.column("idx", .integer).notNull() + table.column("typed", .text).notNull() + table.column("accuracy", .double).notNull() + table.column("attempts", .integer).notNull() + table.column("updatedAt", .datetime).notNull() + table.primaryKey(["textId", "idx"]) + } + } + private static func createAppStateTable(_ db: Database) throws { try db.create(table: "appState") { table in table.column("key", .text).notNull().primaryKey() diff --git a/Tests/HanReaderCoreTests/DictationComparisonTests.swift b/Tests/HanReaderCoreTests/DictationComparisonTests.swift new file mode 100644 index 0000000..8dd7147 --- /dev/null +++ b/Tests/HanReaderCoreTests/DictationComparisonTests.swift @@ -0,0 +1,239 @@ +// HanReader — MIT licensed. See LICENSE. + +import Testing +@testable import HanReaderCore + +/// Readings for the handful of characters these tests use, written out so a +/// test about scoring does not depend on which dictionaries happen to be +/// installed. The values are CC-CEDICT's. +private let readings: [Character: [String]] = [ + "真": ["zhen1"], "针": ["zhen1"], + "是": ["shi4"], "时": ["shi2"], "事": ["shi4"], + "重": ["chong2", "zhong4"], + "价": ["jia4"], "假": ["jia3", "jia4"], + "山": ["shan1"], "村": ["cun1"], "的": ["de5", "di2", "di4"], + "年": ["nian2", "Nian2"], + "黏": ["nian2"], + // 佟 is a surname and CC-CEDICT spells it capitalised, with no other + // reading at all — so this pair is the one that actually depends on + // comparing readings case-insensitively. + "佟": ["Tong2"], "同": ["tong2"], + "猫": ["mao1"], "狗": ["gou3"], +] + +private func lookup(_ character: Character) -> [CharacterReading] { + (readings[character] ?? []).enumerated().map { rank, numeric in + CharacterReading(character: character, numeric: numeric, display: numeric, rank: rank) + } +} + +private func check(_ typed: String, _ expected: String) -> DictationResult { + DictationComparison.check(typed: typed, against: expected, readings: lookup) +} + +@Suite("Dictation checking") +struct DictationComparisonTests { + // MARK: - Scoring + + @Test("A perfect attempt scores one") + func perfect() { + let result = check("山村的孩子需要他", "山村的孩子需要他") + #expect(result.isPerfect) + #expect(result.accuracy == 1) + #expect(result.correct == 8) + #expect(result.expected == 8) + } + + /// Punctuation is a transcription convention, not evidence about anyone's + /// ears — and a Chinese IME inserts it for the learner anyway, so scoring + /// it would mark some people down for their keyboard. + @Test( + "Punctuation and spacing are ignored on both sides", + arguments: [ + ("山村的孩子需要他", "山村的孩子需要他。"), + ("山村的孩子需要他。", "山村的孩子需要他"), + ("山村的孩子,需要他!", "山村的孩子需要他。"), + ("山 村 的 孩 子 需 要 他", "山村的孩子需要他。"), + ], + ) + func punctuationIgnored(typed: String, expected: String) { + let result = check(typed, expected) + #expect(result.isPerfect) + #expect(result.accuracy == 1) + } + + @Test("An empty attempt scores zero and marks everything missing") + func emptyAttempt() { + let result = check("", "山村的孩子") + #expect(result.accuracy == 0) + #expect(result.count(of: .missing) == 5) + #expect(result.marks.count == 5) + } + + /// Nothing to get wrong. Dividing by zero to say otherwise would be + /// arithmetic masquerading as judgement. + @Test("An empty phrase scores one rather than dividing by zero") + func emptyPhrase() { + let result = check("", "") + #expect(result.accuracy == 1) + #expect(result.isPerfect) + #expect(check("多余", "").accuracy == 1) + } + + @Test("Accuracy counts matched characters over expected characters") + func accuracyArithmetic() { + // Four expected, three right. + let result = check("山村的狗", "山村的猫") + #expect(result.correct == 3) + #expect(result.expected == 4) + #expect(abs(result.accuracy - 0.75) < 0.000_1) + #expect(!result.isPerfect) + } + + // MARK: - The three kinds of wrong + + /// 真 and 针 are both `zhen1`. They heard it correctly and their input + /// method offered the wrong candidate; calling that a listening mistake + /// would be a lie about the one thing this exercise measures. + @Test("A character that sounds identical is a homophone, not a mishearing") + func homophone() { + let result = check("真", "针") + #expect(result.marks.map(\.outcome) == [.homophone]) + #expect(result.correct == 0) + #expect(result.count(of: .misheard) == 0) + } + + /// 是 `shi4` for 时 `shi2`: the right syllable, the wrong tone. The most + /// useful signal in the exercise and the one no general-purpose + /// dictation tool can give. + @Test("The right syllable with the wrong tone is a tone slip") + func toneSlip() { + let result = check("是", "时") + #expect(result.marks.map(\.outcome) == [.toneSlip]) + } + + @Test("A character with no reading in common is a mishearing") + func misheard() { + let result = check("猫", "狗") + #expect(result.marks.map(\.outcome) == [.misheard]) + } + + /// A character with several readings is a homophone if *any* of them + /// match — 假 is `jia3` and `jia4`, and 价 is `jia4`, so a learner typing + /// 假 for 价 heard it exactly right. + @Test("Any shared reading is enough to be a homophone") + func homophoneAcrossMultipleReadings() { + #expect(check("假", "价").marks.map(\.outcome) == [.homophone]) + } + + /// A surname is written `Nian2` and is the same sound as `nian2`. + /// Treating those as different readings would call 年/黏 a mishearing, + /// and for single characters a surname reading is the common case. + @Test("Capitalisation does not hide a homophone") + func surnameSpellingIsStillTheSameSound() { + #expect(check("年", "黏").marks.map(\.outcome) == [.homophone]) + // 佟 is spelled `Tong2` and has no lowercase reading of its own, so + // this pair fails as a tone slip rather than a homophone unless the + // comparison folds case. + #expect(check("佟", "同").marks.map(\.outcome) == [.homophone]) + } + + /// With no dictionary — the state the app is in before one is installed — + /// everything wrong is simply wrong. Better than claiming a homophone + /// analysis that was never done. + @Test("With no readings available every wrong character is a mishearing") + func noDictionary() { + let result = DictationComparison.check(typed: "真", against: "针") + #expect(result.marks.map(\.outcome) == [.misheard]) + } + + // MARK: - Missing and extra + + @Test("A dropped character is missing, not a substitution") + func dropped() { + let result = check("山的孩子", "山村的孩子") + #expect(result.count(of: .missing) == 1) + #expect(result.correct == 4) + #expect(result.marks.first { $0.outcome == .missing }?.expected == "村") + } + + @Test("An invented character is extra and carries no offset") + func invented() { + let result = check("山村村的孩子", "山村的孩子") + #expect(result.count(of: .extra) == 1) + let extra = result.marks.first { $0.outcome == .extra } + #expect(extra?.typed == "村") + #expect(extra?.expected == nil) + #expect(extra?.offset == nil) + } + + // MARK: - Offsets + + /// Marks are drawn over the phrase's own text, so each one has to carry + /// where its character sits — measured in UTF-16, after punctuation has + /// been skipped but counting the space it occupies. + @Test("Offsets point into the original text, past skipped punctuation") + func offsetsSurvivePunctuation() { + let expected = "山村,的孩子" + let result = check("山村的孩子", expected) + let offsets = result.marks.compactMap(\.offset) + #expect(offsets == [0, 1, 3, 4, 5]) + + // And they really do address the right characters. + let utf16 = Array(expected.utf16) + for mark in result.marks { + guard let offset = mark.offset, let expectedCharacter = mark.expected else { continue } + #expect(String(decoding: [utf16[offset]], as: UTF16.self) == String(expectedCharacter)) + } + } + + @Test("Offsets are UTF-16, so a surrogate pair shifts what follows") + func offsetsAreUTF16() { + let result = check("一𠀋二", "一𠀋二") + #expect(result.marks.compactMap(\.offset) == [0, 1, 3]) + } + + // MARK: - A whole phrase + + /// The scenario from the plan: 「卖主是位六十多岁的老太太」 typed with a + /// tone slip, which is what this feature exists to catch. + @Test("A realistic attempt reports each kind of error separately") + func realisticAttempt() { + let expected = "是时重" + let typed = "事是猫" + let result = check(typed, expected) + #expect(result.marks.map(\.outcome) == [.homophone, .toneSlip, .misheard]) + #expect(result.correct == 0) + #expect(result.count(of: .homophone) == 1) + #expect(result.count(of: .toneSlip) == 1) + #expect(result.count(of: .misheard) == 1) + } + + @Test("Every outcome except correct counts as an error") + func errorsAreErrors() { + for outcome in DictationOutcome.allCases { + #expect(outcome.isError == (outcome != .correct)) + } + } +} + +@Suite("Reading comparison helpers") +struct DictationReadingTests { + @Test( + "A numeric reading drops its tone digit to give the syllable", + arguments: [ + ("zhen1", "zhen"), ("shi4", "shi"), ("de5", "de"), + ("Nian2", "nian"), ("er", "er"), ("", nil as String?), + ], + ) + func syllableBase(numeric: String, expected: String?) { + #expect(DictationComparison.base(of: numeric) == expected) + } + + /// A reading that is only a tone digit has no syllable, and must not be + /// reported as an empty one that matches every other empty one. + @Test("A degenerate reading has no syllable") + func degenerateReading() { + #expect(DictationComparison.base(of: "4") == nil) + } +} diff --git a/Tests/HanReaderCoreTests/PhraseSplitterTests.swift b/Tests/HanReaderCoreTests/PhraseSplitterTests.swift new file mode 100644 index 0000000..ee7b092 --- /dev/null +++ b/Tests/HanReaderCoreTests/PhraseSplitterTests.swift @@ -0,0 +1,242 @@ +// HanReader — MIT licensed. See LICENSE. + +import Testing +@testable import HanReaderCore + +private func text(_ source: String, _ range: Range) -> String { + let utf16 = Array(source.utf16) + return String(decoding: utf16[range], as: UTF16.self) +} + +private func phrases(_ source: String, maximum: Int = 29, minimum: Int = 4) -> [String] { + PhraseSplitter + .phrases( + in: source, + splitting: PhraseSplitting(maximumLength: maximum, minimumLength: minimum), + ) + .map { text(source, $0) } +} + +@Suite("Phrase splitting") +struct PhraseSplitterTests { + // MARK: - Sentences + + @Test("A text splits at its terminators") + func sentences() { + let source = "山村的孩子需要他。他重复了三次!任务不重?" + #expect(phrases(source) == ["山村的孩子需要他。", "他重复了三次!", "任务不重?"]) + } + + /// 听力64 contains **39 half-width commas and no full-width ones** — it + /// was typed by someone whose input method produced `,`. A splitter that + /// knew only `,` and `。` would leave its longest sentence whole and the + /// cap would silently not apply to the one text this was built for. + @Test( + "Half-width terminators end a sentence just as full-width ones do", + arguments: [".", "!", "?", ";"], + ) + func halfWidthTerminators(mark: String) { + // Two sentences, or one if the mark is not recognised. Both clear the + // minimum length, so nothing here can be explained by merging. + let source = "第一个句子\(mark)第二个句子。" + #expect(phrases(source).count == 2) + } + + @Test("Half-width separators divide an over-long sentence") + func halfWidthSeparators() { + let halfWidth = "我在一个山村学校当老师,教学任务很重,而我的身体却很瘦弱。" + let fullWidth = "我在一个山村学校当老师,教学任务很重,而我的身体却很瘦弱。" + #expect(phrases(halfWidth, maximum: 12).count == phrases(fullWidth, maximum: 12).count) + #expect(phrases(halfWidth, maximum: 12).count > 1) + } + + /// 「说:“这是什么意思?”」 must not leave a phrase that is one closing + /// quotation mark. + @Test("A closing mark stays with the sentence it closes") + func closingMarksDoNotStrandThemselves() { + let source = "他说:“这是什么意思?”我不知道。" + let result = phrases(source) + #expect(result.count == 2) + #expect(result.first?.hasSuffix("”") == true) + #expect(result.allSatisfy { $0.count > 1 }) + } + + @Test("A final sentence with no terminator is still a phrase") + func unterminatedTail() { + #expect(phrases("第一句。没有句号的结尾") == ["第一句。", "没有句号的结尾"]) + } + + @Test("Whitespace between sentences is not part of either") + func whitespaceIsDropped() { + let result = phrases("第一个句子。 第二个句子。") + #expect(result == ["第一个句子。", "第二个句子。"]) + } + + /// Two sentences too short to stand alone become one phrase, and the + /// whitespace between them comes along: a phrase is a contiguous range + /// of the stored text, not a reassembled string. + @Test("Merging across a sentence boundary keeps the text contiguous") + func mergeSpansTheGap() { + #expect(phrases("第一句。 第二句。") == ["第一句。 第二句。"]) + } + + @Test("An empty text produces no phrases") + func empty() { + #expect(phrases("").isEmpty) + #expect(phrases(" \n ").isEmpty) + } + + // MARK: - The duration cap + + /// The measured behaviour on 听力64: 22 sentences become 29 phrases at a + /// 29-character budget, with the longest exactly at the cap. Sentences + /// that fit are kept whole — splitting at every comma instead would give + /// 61 fragments of three or four characters, which is not a listening + /// test. + @Test("A sentence within budget is kept whole") + func shortSentencesAreNotCut() { + let source = "卖主是位六十多岁的老太太,她叫我给个价,我就说五角钱一个。" + #expect(phrases(source) == [source]) + } + + @Test("An over-long sentence is cut at its commas") + func longSentencesAreCut() { + let source = "我在一个山村学校当老师,教学任务很重,而我的身体却很瘦弱,每天吃的不是萝卜就是白菜。" + let result = phrases(source) + #expect(result.count > 1) + #expect(result.joined() == source) + for phrase in result { + #expect(PhraseSplitter.characterCount(of: phrase, in: 0 ..< phrase.utf16.count) <= 29) + } + } + + /// Greedy packing, not one phrase per comma: five short clauses become + /// two phrases that each fill the budget. + @Test("Clauses are packed up to the budget rather than emitted one each") + func clausesArePacked() { + let source = "一二三四五,六七八九十,十一十二十三,十四十五十六,十七十八十九。" + let result = phrases(source, maximum: 12) + #expect(result.count < 5) + #expect(result.joined() == source) + } + + /// There is nothing left to cut at, and cutting mid-word would make it + /// unreadable. One over-long phrase the reader can split by hand is the + /// better failure. + @Test("A clause longer than the budget with no punctuation is emitted whole") + func unsplittableClauseSurvives() { + let source = String(repeating: "山", count: 60) + "。" + let result = phrases(source, maximum: 10) + #expect(result == [source]) + } + + // MARK: - The minimum + + /// Without this, 听力64 opens with a two-character fragment that is + /// neither a listening test nor recognisable as anything. + @Test("A fragment below the minimum is glued to the phrase before it") + func shortFragmentsAreMerged() { + let source = "这是一个很长的句子需要切开,好。" + let result = phrases(source, maximum: 20, minimum: 4) + #expect(result.allSatisfy { !$0.hasPrefix("好") }) + #expect(result.joined() == source) + } + + /// But never past the cap: a run of fragments must not quietly grow one + /// phrase beyond the budget the whole feature rests on. + @Test("Merging a fragment never exceeds the budget") + func mergingRespectsTheBudget() { + // Every clause here is splittable and under the cap on its own, so + // the only way to exceed it would be an over-eager merge. The + // unsplittable case is a separate rule and has its own test. + // Seven characters then two: the tail is below the minimum of six and + // would be glued on, except that 7 + 2 is over the cap of 8. The + // fragment has to stay a phrase of its own. + let source = "一二三四五六七,八九。" + let result = phrases(source, maximum: 8, minimum: 6) + #expect(result.count == 2) + for phrase in result { + #expect(PhraseSplitter.characterCount(of: phrase, in: 0 ..< phrase.utf16.count) <= 8) + } + #expect(result.joined() == source) + } + + // MARK: - Ranges + + /// Phrases are consumed as UTF-16 ranges into the stored text, the same + /// unit as `Token.range` and `ReadingPosition.characterOffset`. They must + /// be ordered, non-overlapping and inside the text, or a phrase shows the + /// wrong words or traps. + @Test( + "Ranges are ordered, non-overlapping and within the text", + arguments: [ + "山村的孩子需要他。我在一个山村学校当老师,教学任务很重。", + "第一句。 第二句!第三句?", + "没有标点的文本", + "他说:“这是什么意思?”我不知道。", + ], + ) + func rangesTile(source: String) { + let ranges = PhraseSplitter.phrases(in: source) + let length = source.utf16.count + for range in ranges { + #expect(range.lowerBound >= 0) + #expect(range.upperBound <= length) + #expect(!range.isEmpty) + } + for (earlier, later) in zip(ranges, ranges.dropFirst()) { + #expect(earlier.upperBound <= later.lowerBound) + } + } + + /// A supplementary-plane character is two UTF-16 units. The offsets here + /// are computed by accumulating `utf16.count` per character, so a rare + /// ideograph is exactly the input that would expose an arithmetic slip — + /// and rare ideographs are the ones a learner most wants to re-hear. + @Test("Ranges are correct across a surrogate pair") + func surrogatePairsAreCounted() { + let source = "一𠀋二三。四𠀋五六。" + let ranges = PhraseSplitter.phrases( + in: source, + splitting: PhraseSplitting(maximumLength: 29, minimumLength: 0), + ) + #expect(ranges.count == 2) + #expect(ranges.map { text(source, $0) } == ["一𠀋二三。", "四𠀋五六。"]) + // And the offsets really are UTF-16: each 𠀋 is two units, so a + // splitter counting Characters would report four fewer here. + #expect(ranges.last?.upperBound == source.utf16.count) + } + + // MARK: - Measuring + + /// The budget counts what a learner has to type. Counting punctuation + /// would shrink a heavily-punctuated phrase, which has nothing to do + /// with how long it takes to say. + @Test("Punctuation and spaces do not count towards the budget") + func punctuationIsNotCounted() { + let source = "一,二。" + #expect(PhraseSplitter.characterCount(of: source, in: 0 ..< source.utf16.count) == 2) + let spaced = "a b 1" + #expect(PhraseSplitter.characterCount(of: spaced, in: 0 ..< spaced.utf16.count) == 3) + } + + // MARK: - Turning seconds into characters + + /// 听力64 measured: 609 characters over about 147 seconds is 4.1 a + /// second, so WorkAudioBook's seven-second default is 29 characters. + @Test("A duration budget converts using the recording's own rate") + func durationBecomesCharacters() { + let splitting = PhraseSplitting.forAudio(characters: 609, duration: 147) + #expect(splitting.maximumLength == 29) + #expect(splitting.minimumLength == 4) + } + + /// A rate cannot be measured from a recording with no length, and a + /// budget of zero would make every phrase over-long. + @Test("A missing duration falls back to the standard budget") + func degenerateAudioFallsBack() { + #expect(PhraseSplitting.forAudio(characters: 0, duration: 0) == .standard) + #expect(PhraseSplitting.forAudio(characters: 100, duration: 0) == .standard) + #expect(PhraseSplitting(maximumLength: 0).maximumLength == 1) + } +} diff --git a/Tests/HanReaderCoreTests/SequenceAlignmentTests.swift b/Tests/HanReaderCoreTests/SequenceAlignmentTests.swift new file mode 100644 index 0000000..e848f6e --- /dev/null +++ b/Tests/HanReaderCoreTests/SequenceAlignmentTests.swift @@ -0,0 +1,220 @@ +// HanReader — MIT licensed. See LICENSE. + +import Testing +@testable import HanReaderCore + +/// The classic full-matrix edit distance, written out plainly. +/// +/// A second implementation on purpose: the real one is banded and hard to +/// eyeball, and "the banded answer equals the textbook answer" is the only +/// check that actually pins it. +private func textbookDistance(_ reference: [Character], _ candidate: [Character]) -> Int { + var previous = Array(0 ... candidate.count) + var current = [Int](repeating: 0, count: candidate.count + 1) + for row in 1 ... max(reference.count, 1) where !reference.isEmpty { + current[0] = row + for column in 1 ... max(candidate.count, 1) where !candidate.isEmpty { + let same = reference[row - 1] == candidate[column - 1] + current[column] = min( + previous[column] + 1, + current[column - 1] + 1, + previous[column - 1] + (same ? 0 : 1), + ) + } + if candidate.isEmpty { + current[0] = row + } + previous = current + } + if reference.isEmpty { + return candidate.count + } + if candidate.isEmpty { + return reference.count + } + return previous[candidate.count] +} + +private func distance(_ steps: [AlignmentStep]) -> Int { + steps.count { $0.operation != .match } +} + +private func align(_ reference: String, _ candidate: String, slack: Int = 128) -> [AlignmentStep] { + SequenceAlignment.align( + reference: Array(reference), + candidate: Array(candidate), + slack: slack, + ) +} + +@Suite("Sequence alignment") +struct SequenceAlignmentTests { + // MARK: - The invariant everything else rests on + + /// Both sequences are covered exactly once, in order, with nothing + /// invented and nothing dropped. + /// + /// The same shape of property test as the token tiling one: a scorer + /// built on an alignment that skipped a character would report an + /// accuracy that is simply wrong, and no example-based test reliably + /// catches an off-by-one at a band edge. + @Test( + "Every element of both sequences is covered exactly once", + arguments: [ + ("卖主是位六十多岁的老太太", "卖主是为六十多岁的老太太"), + ("我在一个山村学校当老师", "我在山村学校当老师"), + ("短", "很长很长很长的一句话"), + ("很长很长很长的一句话", "短"), + ("", "abc"), + ("abc", ""), + ("", ""), + ("aaaa", "aaaa"), + ], + ) + func coversBothSequences(reference: String, candidate: String) { + let referenceCharacters = Array(reference) + let candidateCharacters = Array(candidate) + let steps = align(reference, candidate) + + let referenceIndices = steps.compactMap(\.reference) + let candidateIndices = steps.compactMap(\.candidate) + + #expect(referenceIndices == Array(referenceCharacters.indices)) + #expect(candidateIndices == Array(candidateCharacters.indices)) + + // And the operations agree with the indices they carry. + for step in steps { + switch step.operation { + case .match, .substitute: + #expect(step.reference != nil && step.candidate != nil) + case .insert: + #expect(step.reference == nil && step.candidate != nil) + case .delete: + #expect(step.reference != nil && step.candidate == nil) + } + } + } + + /// A match must actually be a match. Nothing downstream re-checks this, + /// so an alignment that labelled a substitution as a match would score a + /// wrong answer as right. + @Test("A step labelled match holds equal elements") + func matchesAreEqual() { + let reference = Array("卖主是位六十多岁的老太太") + let candidate = Array("卖主是为六十多岁的老太太") + for step in SequenceAlignment.align(reference: reference, candidate: candidate) { + guard let left = step.reference, let right = step.candidate else { continue } + if step.operation == .match { + #expect(reference[left] == candidate[right]) + } else { + #expect(reference[left] != candidate[right]) + } + } + } + + // MARK: - Against the textbook + + @Test( + "The banded distance equals the full-matrix distance", + arguments: [ + ("卖主是位六十多岁的老太太", "卖主是为六十多岁的老太太"), + ("我在一个山村学校当老师", "我在山村学校当老师"), + ("教学任务很重", "教学任务很重"), + ("kitten", "sitting"), + ("flaw", "lawn"), + ("", "non-empty"), + ("non-empty", ""), + ], + ) + func matchesTextbookDistance(reference: String, candidate: String) { + let steps = align(reference, candidate) + #expect(distance(steps) == textbookDistance(Array(reference), Array(candidate))) + } + + // MARK: - Shapes the callers depend on + + @Test("Identical sequences are all matches") + func identical() { + let steps = align("卖主是位六十多岁的老太太", "卖主是位六十多岁的老太太") + #expect(steps.allSatisfy { $0.operation == .match }) + #expect(steps.count == 12) + } + + /// What a learner typing one wrong character produces, and the case the + /// homophone tier is built on top of. + @Test("One wrong character is a substitution, not a delete and an insert") + func oneSubstitution() { + let steps = align("是位", "事位") + #expect(steps.map(\.operation) == [.substitute, .match]) + } + + @Test("A missing character is a deletion") + func oneDeletion() { + let steps = align("一个人", "一人") + #expect(steps.count(where: { $0.operation == .delete }) == 1) + #expect(steps.count(where: { $0.operation == .match }) == 2) + } + + @Test("An extra character is an insertion") + func oneInsertion() { + let steps = align("一人", "一个人") + #expect(steps.count(where: { $0.operation == .insert }) == 1) + #expect(steps.count(where: { $0.operation == .match }) == 2) + } + + @Test("Two empty sequences produce no steps") + func bothEmpty() { + #expect(align("", "").isEmpty) + } + + // MARK: - The band + + /// The band is what keeps an hour-long recording from needing a gigabyte. + /// Inside it the answer must be exact, which is the case that matters: + /// a dictation attempt is tens of characters and can never approach it. + @Test("A narrow band still gives the exact answer for nearby sequences") + func narrowBandIsExactWhenTheDriftIsSmall() { + let reference = "卖主是位六十多岁的老太太她叫我给个价我就说五角钱一个" + let candidate = "卖主是为六十多岁的老太太她叫我给个假我就说五角钱一个" + let wide = align(reference, candidate, slack: 128) + let narrow = align(reference, candidate, slack: 2) + #expect(distance(narrow) == distance(wide)) + #expect(distance(narrow) == 2) + } + + /// And when the true path *would* leave the band, the result is still a + /// complete, well-formed alignment — worse, but not broken. A backtrace + /// that could not reach the origin would hang the app rather than score + /// badly, which is why this is a test and not a comment. + @Test("A path that leaves the band still produces a total alignment") + func narrowBandStillTerminates() { + let reference = String(repeating: "甲", count: 200) + "乙丙丁" + let candidate = "乙丙丁" + String(repeating: "甲", count: 200) + let steps = SequenceAlignment.align( + reference: Array(reference), + candidate: Array(candidate), + slack: 1, + ) + #expect(steps.compactMap(\.reference) == Array(0 ..< reference.count)) + #expect(steps.compactMap(\.candidate) == Array(0 ..< candidate.count)) + } + + /// With no slack asked for, the band is still kept at one — enough to + /// see a single dropped character paired with a single added one. + /// + /// These two are the same length, so a band of zero would explore only + /// the diagonal and call it three substitutions. It is two: drop 甲, keep + /// 乙丙, add 丁. Identical sequences would not have shown the difference, + /// which is what this test originally used and why it proved nothing. + @Test("A slack of zero still leaves room for one indel") + func zeroSlackKeepsOneStepOfRoom() { + let steps = SequenceAlignment.align( + reference: Array("甲乙丙"), + candidate: Array("乙丙丁"), + slack: 0, + ) + #expect(distance(steps) == 2) + #expect(steps.compactMap(\.reference) == [0, 1, 2]) + #expect(steps.compactMap(\.candidate) == [0, 1, 2]) + } +} diff --git a/Tests/HanReaderPersistenceTests/ListeningPracticeTests.swift b/Tests/HanReaderPersistenceTests/ListeningPracticeTests.swift new file mode 100644 index 0000000..dd8eb5b --- /dev/null +++ b/Tests/HanReaderPersistenceTests/ListeningPracticeTests.swift @@ -0,0 +1,200 @@ +// HanReader — MIT licensed. See LICENSE. + +import Foundation +import GRDB +import HanReaderCore +import Testing +@testable import HanReaderPersistence + +@Suite("Listening practice storage") +struct ListeningPracticeTests { + private func library() throws -> LibraryRepository { + try LibraryRepository.inMemory() + } + + private func text(in library: LibraryRepository) async throws -> TextID { + switch try await library.importText( + title: "听力64", + content: "山村的孩子需要他。我在一个山村学校当老师,教学任务很重。", + ) { + case let .imported(id): id + case let .alreadyPresent(id): id + } + } + + private func phrase(_ index: Int, _ start: Double, _ end: Double) -> Phrase { + Phrase( + index: index, + start: start, + end: end, + textRange: (index * 10) ..< (index * 10 + 9), + confidence: 0.9, + ) + } + + // MARK: - Phrases + + @Test("Phrases round-trip in order") + func roundTrip() async throws { + let library = try library() + let id = try await text(in: library) + try await library.savePhrases([phrase(0, 0, 4), phrase(1, 4, 9.5)], for: id) + + let stored = try await library.phrases(for: id) + #expect(stored.map(\.index) == [0, 1]) + #expect(stored.first?.start == 0) + #expect(stored.first?.end == 4) + #expect(stored.last?.duration == 5.5) + #expect(stored.first?.textRange == 0 ..< 9) + #expect(stored.allSatisfy { $0.origin == .recognised }) + #expect(stored.allSatisfy { $0.grade == nil }) + } + + /// Phrases are positional. A half-written set is not a shorter set, it is + /// a set whose indices no longer mean what the attempts recorded against + /// them mean — so saving replaces the lot. + @Test("Saving replaces the previous set rather than adding to it") + func savingReplaces() async throws { + let library = try library() + let id = try await text(in: library) + try await library.savePhrases([phrase(0, 0, 4), phrase(1, 4, 9)], for: id) + try await library.savePhrases([phrase(0, 0, 9)], for: id) + + let stored = try await library.phrases(for: id) + #expect(stored.count == 1) + #expect(stored.first?.end == 9) + } + + /// Re-aligning a recording usually moves boundaries by fractions of a + /// second. Throwing away what the reader has already mastered would make + /// fixing one boundary cost them the whole text. + @Test("Re-aligning keeps the reader's attempts") + func realigningKeepsAttempts() async throws { + let library = try library() + let id = try await text(in: library) + try await library.savePhrases([phrase(0, 0, 4)], for: id) + try await library.recordAttempt("山村的孩子需要他", accuracy: 1, at: 0, in: id) + + try await library.savePhrases([phrase(0, 0, 4.2)], for: id) + #expect(try await library.attempts(for: id)[0]?.accuracy == 1) + } + + @Test("A text with no phrases reports as much without fetching them") + func hasPhrases() async throws { + let library = try library() + let id = try await text(in: library) + #expect(try await library.hasPhrases(for: id) == false) + try await library.savePhrases([phrase(0, 0, 4)], for: id) + #expect(try await library.hasPhrases(for: id) == true) + } + + /// A boundary somebody dragged into place must be distinguishable, so a + /// later re-alignment can leave it alone. + @Test("Moving a boundary records that a person did it") + func movingMarksManual() async throws { + let library = try library() + let id = try await text(in: library) + try await library.savePhrases([phrase(0, 0, 4)], for: id) + try await library.movePhrase(0, in: id, start: 0.5, end: 4.5) + + let stored = try #require(try await library.phrases(for: id).first) + #expect(stored.start == 0.5) + #expect(stored.end == 4.5) + #expect(stored.origin == .manual) + // And only the times moved: dragging a waveform handle says nothing + // about which words belong to the phrase. + #expect(stored.textRange == 0 ..< 9) + } + + @Test("A grade is stored and can be taken back") + func grading() async throws { + let library = try library() + let id = try await text(in: library) + try await library.savePhrases([phrase(0, 0, 4)], for: id) + + try await library.gradePhrase(0, in: id, as: .hard) + #expect(try await library.phrases(for: id).first?.grade == .hard) + try await library.gradePhrase(0, in: id, as: nil) + #expect(try await library.phrases(for: id).first?.grade == nil) + } + + /// A row written by a later version of the app is not a reason to refuse + /// a reader their own library. The unknown value degrades to the default + /// rather than throwing out of the read. + @Test("An unknown origin reads back as recognised rather than failing") + func unknownOriginDegrades() async throws { + let library = try library() + let id = try await text(in: library) + try await library.savePhrases([phrase(0, 0, 4)], for: id) + try await library.dbQueue.write { db in + try db.execute( + sql: "UPDATE phrase SET origin = 'from-a-later-version' WHERE textId = ?", + arguments: [id.rawValue], + ) + } + #expect(try await library.phrases(for: id).first?.origin == .recognised) + } + + // MARK: - Attempts + + @Test("An attempt is counted against the ones before it") + func attemptsAccumulate() async throws { + let library = try library() + let id = try await text(in: library) + try await library.savePhrases([phrase(0, 0, 4)], for: id) + + try await library.recordAttempt("山村", accuracy: 0.25, at: 0, in: id) + try await library.recordAttempt("山村的孩", accuracy: 0.5, at: 0, in: id) + try await library.recordAttempt("山村的孩子需要他", accuracy: 1, at: 0, in: id) + + let attempt = try #require(try await library.attempts(for: id)[0]) + #expect(attempt.attempts == 3) + // The latest, not the best: what a learner wants to know is whether + // they have it now. + #expect(attempt.accuracy == 1) + #expect(attempt.typed == "山村的孩子需要他") + #expect(attempt.isPassing()) + } + + @Test("Attempts are keyed per phrase") + func attemptsArePerPhrase() async throws { + let library = try library() + let id = try await text(in: library) + try await library.savePhrases([phrase(0, 0, 4), phrase(1, 4, 9)], for: id) + + try await library.recordAttempt("一", accuracy: 0.5, at: 0, in: id) + try await library.recordAttempt("二", accuracy: 1, at: 1, in: id) + + let attempts = try await library.attempts(for: id) + #expect(attempts.count == 2) + #expect(attempts[0]?.accuracy == 0.5) + #expect(attempts[1]?.accuracy == 1) + } + + @Test("Forgetting a text's practice clears both tables") + func forgetting() async throws { + let library = try library() + let id = try await text(in: library) + try await library.savePhrases([phrase(0, 0, 4)], for: id) + try await library.recordAttempt("山村", accuracy: 0.5, at: 0, in: id) + + try await library.forgetPhrases(for: id) + #expect(try await library.phrases(for: id).isEmpty) + #expect(try await library.attempts(for: id).isEmpty) + } + + /// Both tables cascade from the text, so deleting it cannot leave rows + /// pointing at nothing. The prototype deleted progress by hand and leaked + /// its audio rows entirely. + @Test("Deleting a text takes its practice with it") + func deletionCascades() async throws { + let library = try library() + let id = try await text(in: library) + try await library.savePhrases([phrase(0, 0, 4)], for: id) + try await library.recordAttempt("山村", accuracy: 0.5, at: 0, in: id) + + try await library.delete(id) + #expect(try await library.phrases(for: id).isEmpty) + #expect(try await library.attempts(for: id).isEmpty) + } +} diff --git a/Tests/HanReaderPersistenceTests/MigrationTests.swift b/Tests/HanReaderPersistenceTests/MigrationTests.swift index 1cc681f..94a650a 100644 --- a/Tests/HanReaderPersistenceTests/MigrationTests.swift +++ b/Tests/HanReaderPersistenceTests/MigrationTests.swift @@ -15,7 +15,7 @@ struct MigrationTests { /// runtime failure months later. When this fails, either the change was /// unintended, or it was intended and needs a new migration plus an /// updated snapshot. - @Test("The v1 schema is what we think it is") + @Test("The schema is what we think it is") func schemaSnapshot() throws { let queue = try AppDatabase.inMemoryLibrary() let schema = try queue.read { db in @@ -32,8 +32,10 @@ struct MigrationTests { #expect(schema == [ "appState(key,value)", "audioTrack(textId,relativePath,duration,importedAt)", + "dictationAttempt(textId,idx,typed,accuracy,attempts,updatedAt)", "dictionaryPreference(slug,providesGlosses,providesReadings," + "providesLexicon,sortOrder)", + "phrase(textId,idx,startTime,endTime,textStart,textEnd,confidence,origin,grade)", "readingPosition(textId,blockIndex,tokenIndex,characterOffset,audioTime,updatedAt)", "revealedWord(textId,word,revealedAt)", "text(id,title,content,contentHash,characterCount,preview," @@ -49,7 +51,11 @@ struct MigrationTests { try migrator.hasCompletedMigrations(db) } #expect(completed) - #expect(migrator.migrations == ["v1_initial", "v2_dictionary_preferences"]) + #expect(migrator.migrations == [ + "v1_initial", + "v2_dictionary_preferences", + "v3_listening_practice", + ]) } /// Running the migrator again must be a no-op. Every launch does this. @@ -64,7 +70,7 @@ struct MigrationTests { WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE 'grdb_%' """) } - #expect(tables == 6) + #expect(tables == 8) } /// The case an empty-database test cannot reach: a migration that works From ee98235dd464f5324ad61b308751022c2d748b72 Mon Sep 17 00:00:00 2001 From: Dmitry Ilyin <6576495+widgetii@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:29:50 +0300 Subject: [PATCH 2/2] fix: answer eight review findings on the listening core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Listening/DictationComparison.swift | 47 ++++++- Sources/HanReaderCore/Listening/Phrase.swift | 10 +- .../Listening/PhraseSplitter.swift | 110 +++++++++++---- .../Listening/SequenceAlignment.swift | 128 +++++++++++++++--- .../LibraryRepository+Listening.swift | 60 +++++++- .../DictationComparisonTests.swift | 56 +++++++- .../PhraseSplitterTests.swift | 29 ++++ .../SequenceAlignmentTests.swift | 38 ++++++ .../ListeningPracticeTests.swift | 72 +++++++++- 9 files changed, 481 insertions(+), 69 deletions(-) diff --git a/Sources/HanReaderCore/Listening/DictationComparison.swift b/Sources/HanReaderCore/Listening/DictationComparison.swift index 264902c..4759bc2 100644 --- a/Sources/HanReaderCore/Listening/DictationComparison.swift +++ b/Sources/HanReaderCore/Listening/DictationComparison.swift @@ -70,9 +70,9 @@ public struct DictationMark: Hashable, Sendable { /// A checked dictation attempt. public struct DictationResult: Hashable, Sendable { public let marks: [DictationMark] - /// Characters of the transcript that were typed correctly. + /// Characters of the transcript typed exactly right. public let correct: Int - /// Characters the transcript expected — the denominator. + /// Characters the transcript expected. public let expected: Int public init(marks: [DictationMark], correct: Int, expected: Int) { @@ -81,11 +81,46 @@ public struct DictationResult: Hashable, Sendable { self.expected = expected } - /// 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. + /// Characters invented, which are errors with no place in the + /// transcript to hang them on. + public var extras: Int { + count(of: .extra) + } + + /// Everything that could be got wrong: the transcript's characters, plus + /// anything added that was not in it. + /// + /// Invented characters are in the denominator because otherwise they are + /// free. Typing the phrase correctly and then adding three characters of + /// nonsense would score one, and pass. + public var scale: Int { + expected + extras + } + + /// How much of the phrase was transcribed exactly, from zero to one. public var accuracy: Double { - expected == 0 ? 1 : Double(correct) / Double(expected) + fraction(correct) + } + + /// How much of the phrase was **heard**, counting a homophone as heard. + /// + /// The exercise measures listening, and 真 typed for 针 is not a + /// listening mistake: both are `zhen1`, so they heard it exactly right + /// and their input method offered the wrong candidate. Sending them back + /// to re-listen teaches nothing about listening. + /// + /// A tone slip is **not** counted as heard. Tones are what an ear loses + /// first, and they are the thing this exercise is most useful for. + public var listeningAccuracy: Double { + fraction(correct + count(of: .homophone)) + } + + private func fraction(_ hits: Int) -> Double { + // Nothing expected and nothing typed is a phrase with nothing to get + // wrong. Nothing expected but something typed is simply wrong, and + // saying so beats dividing by zero and calling it full marks. + guard scale > 0 else { return 1 } + return Double(hits) / Double(scale) } public var isPerfect: Bool { diff --git a/Sources/HanReaderCore/Listening/Phrase.swift b/Sources/HanReaderCore/Listening/Phrase.swift index 52882f3..2bfcb9c 100644 --- a/Sources/HanReaderCore/Listening/Phrase.swift +++ b/Sources/HanReaderCore/Listening/Phrase.swift @@ -113,9 +113,13 @@ public struct DictationAttempt: Hashable, Sendable { /// 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. + /// The stored `accuracy` is `DictationResult.listeningAccuracy`, not the + /// strict one, which is the whole point: a learner who typed every + /// character correctly except for one wrong homophone *heard* the + /// phrase, and being sent back to it for an input-method slip teaches + /// nothing about listening. A strict score could never reach the default + /// threshold in that case, so this used to promise something it could + /// not deliver. public func isPassing(threshold: Double = 1) -> Bool { accuracy >= threshold } diff --git a/Sources/HanReaderCore/Listening/PhraseSplitter.swift b/Sources/HanReaderCore/Listening/PhraseSplitter.swift index 2e7b219..0f3fa51 100644 --- a/Sources/HanReaderCore/Listening/PhraseSplitter.swift +++ b/Sources/HanReaderCore/Listening/PhraseSplitter.swift @@ -109,16 +109,16 @@ public enum PhraseSplitter { ) -> [Range] { + let metrics = Metrics(text) var phrases: [Range] = [] for sentence in sentences(in: text) { - let length = characterCount(of: text, in: sentence) - if length <= splitting.maximumLength { + if metrics.count(in: sentence) <= splitting.maximumLength { phrases.append(sentence) continue } - phrases.append(contentsOf: divide(sentence, in: text, splitting: splitting)) + phrases.append(contentsOf: divide(sentence, metrics: metrics, splitting: splitting)) } - return merge(phrases, in: text, splitting: splitting) + return merge(phrases, metrics: metrics, splitting: splitting) } // MARK: - Sentences @@ -137,9 +137,11 @@ public enum PhraseSplitter { let isTrailing = trailing.contains(character) let isBlank = character.unicodeScalars.allSatisfy(\.properties.isWhitespace) - if pendingEnd != nil, isTrailing { - // A closing quote after the full stop still belongs to the - // sentence it closes. + if pendingEnd != nil, isTrailing || isTerminator { + // A closing quote after the full stop belongs to the sentence + // it closes — and so does a second terminator. 真的吗?! is + // one question with an exclamation on it, not a question + // followed by a sentence consisting of !. pendingEnd = offset + width offset += width continue @@ -185,7 +187,7 @@ public enum PhraseSplitter { /// easier to hold than one trimmed to make the arithmetic even. private static func divide( _ sentence: Range, - in text: String, + metrics: Metrics, splitting: PhraseSplitting, ) -> [Range] @@ -194,7 +196,7 @@ public enum PhraseSplitter { var clauseStart = sentence.lowerBound var offset = sentence.lowerBound - for character in characters(of: text, in: sentence) { + for character in metrics.text(in: sentence) { let width = character.utf16.count offset += width if separators.contains(character) || terminators.contains(character) { @@ -215,7 +217,14 @@ public enum PhraseSplitter { continue } let combined = last.lowerBound ..< clause.upperBound - if characterCount(of: text, in: combined) <= splitting.maximumLength { + // A clause with nothing to say is glued on **whatever the + // budget**. A sentence ending 「…?」」 divides into its last + // clause and then the closing quote, and if the clause before it + // is already over budget neither packing nor the minimum-length + // merge would take it — leaving a practice phrase that is one + // punctuation mark. + let isPunctuationOnly = metrics.count(in: clause) == 0 + if isPunctuationOnly || metrics.count(in: combined) <= splitting.maximumLength { packed[packed.count - 1] = combined } else { packed.append(clause) @@ -233,7 +242,7 @@ public enum PhraseSplitter { /// Glues a too-short phrase onto its predecessor. private static func merge( _ phrases: [Range], - in text: String, + metrics: Metrics, splitting: PhraseSplitting, ) -> [Range] @@ -241,7 +250,7 @@ public enum PhraseSplitter { guard splitting.minimumLength > 0 else { return phrases } var merged: [Range] = [] for phrase in phrases { - guard characterCount(of: text, in: phrase) < splitting.minimumLength, + guard metrics.count(in: phrase) < splitting.minimumLength, let last = merged.last else { merged.append(phrase) @@ -250,7 +259,7 @@ public enum PhraseSplitter { // Only when the result still fits, or a run of short fragments // would quietly grow one phrase past the cap. let combined = last.lowerBound ..< phrase.upperBound - if characterCount(of: text, in: combined) <= splitting.maximumLength { + if metrics.count(in: combined) <= splitting.maximumLength { merged[merged.count - 1] = combined } else { merged.append(phrase) @@ -261,25 +270,72 @@ public enum PhraseSplitter { // MARK: - Measuring - /// Characters a learner would have to type: Han, Latin and digits. A - /// budget counted in punctuation would shrink the phrase that happens to - /// be heavily punctuated, which has nothing to do with how long it takes - /// to say. - static func characterCount(of text: String, in range: Range) -> Int { - characters(of: text, in: range).count { character in - character.unicodeScalars.contains { scalar in - switch ScalarClass.classify(scalar) { - case .han, .latin, .number: true - case .punctuation, .whitespace, .other: false + /// A text's UTF-16 units with a running count of the characters a + /// learner would have to type, so any range can be measured without + /// looking at it. + /// + /// Built once per split. The obvious implementation — decode the range + /// and count — copies the whole document's UTF-16 storage on every call, + /// and the splitter measures every sentence, then every packing step, + /// then every merge. On a book that is quadratic in the length of the + /// book, for an answer that is a subtraction. + struct Metrics { + let utf16: [UInt16] + /// `scorable[i]` is how many countable characters lie before offset + /// `i`, so a range costs one subtraction. + private let scorable: [Int] + + init(_ text: String) { + var units: [UInt16] = [] + units.reserveCapacity(text.utf16.count) + var counts = [0] + counts.reserveCapacity(text.utf16.count + 1) + var running = 0 + for character in text { + let isScorable = character.unicodeScalars.contains { scalar in + switch ScalarClass.classify(scalar) { + case .han, .latin, .number: true + case .punctuation, .whitespace, .other: false + } + } + if isScorable { + running += 1 + } + for unit in character.utf16 { + units.append(unit) + counts.append(running) } } + utf16 = units + scorable = counts + } + + /// Characters a learner would have to type in this range. + /// + /// Punctuation and spacing are not counted: a budget that counted + /// them would shrink whichever phrase happened to be heavily + /// punctuated, which has nothing to do with how long it takes to + /// say. + func count(in range: Range) -> Int { + guard range.lowerBound >= 0, range.upperBound < scorable.count, + range.lowerBound <= range.upperBound + else { return 0 } + return scorable[range.upperBound] - scorable[range.lowerBound] + } + + func text(in range: Range) -> String { + guard range.lowerBound >= 0, range.upperBound <= utf16.count, + range.lowerBound <= range.upperBound + else { return "" } + return String(decoding: utf16[range], as: UTF16.self) } } - private static func characters(of text: String, in range: Range) -> [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)) + /// Characters a learner would have to type in a range of a text. + /// + /// Convenience over `Metrics` for a caller measuring one range once. + static func characterCount(of text: String, in range: Range) -> Int { + Metrics(text).count(in: range) } private static func trimmingTrailingWhitespace( diff --git a/Sources/HanReaderCore/Listening/SequenceAlignment.swift b/Sources/HanReaderCore/Listening/SequenceAlignment.swift index acd1233..f5d1833 100644 --- a/Sources/HanReaderCore/Listening/SequenceAlignment.swift +++ b/Sources/HanReaderCore/Listening/SequenceAlignment.swift @@ -94,10 +94,32 @@ public enum SequenceAlignment { } let band = Band(rows: reference.count, columns: candidate.count, slack: slack) + guard !band.exceedsBudget + else { return trivial(reference: reference, candidate: candidate) } let moves = fill(reference: reference, candidate: candidate, band: band) return backtrace(moves: moves, band: band) } + /// Nothing matched: every reference element missing, every candidate + /// element invented. + /// + /// A real alignment of two sequences this mismatched would say very + /// nearly this anyway, so it is the shape of the answer rather than a + /// refusal to answer. Forced alignment reads the resulting confidence of + /// zero and falls back to asking the reader. + private static func trivial( + reference: [Character], + candidate: [Character], + ) + -> [AlignmentStep] + { + reference.indices.map { + AlignmentStep(operation: .delete, reference: $0, candidate: nil) + } + candidate.indices.map { + AlignmentStep(operation: .insert, reference: nil, candidate: $0) + } + } + /// The band around the diagonal that is worth computing, and the /// arithmetic for addressing it. /// @@ -117,10 +139,49 @@ public enum SequenceAlignment { // The `abs` term is load-bearing, not padding: without it a row // past the end of the shorter sequence would have a lower bound // above its upper bound, and forming that range traps. + // + // It is also what makes the band dangerous, which is the point + // of the clamp below. Speech recognition that gave up on a long + // recording produces exactly the degenerate shape — thousands of + // transcript characters against a handful of recognised ones — + // and the band would then be as wide as the transcript, turning + // this from O(n × band) back into O(n²). On a book that is + // hundreds of megabytes for an alignment whose answer is "almost + // all of it is missing". band = max(1, abs(rows - columns) + max(0, slack)) width = 2 * band + 1 } + /// Whether computing this band would cost more memory than the + /// answer is worth. + /// + /// The band cannot simply be clamped: it has to be at least the + /// difference in length or a row past the end of the shorter + /// sequence has a lower bound above its upper bound, and the path + /// from one corner to the other no longer fits inside it. So the + /// choice is to spend the memory or to decline, and for a pair this + /// mismatched there is no good alignment to find — the honest answer + /// is that almost none of it lines up, which is what the trivial + /// alignment says for nothing. + var exceedsBudget: Bool { + band > Self.maximumBand || cellCount > Self.maximumCells + } + + /// One byte a cell, since only the moves are kept. 64 MB is far + /// more than any real alignment needs — a two-minute exercise uses a + /// ten-thousandth of it, and an hour-long chapter against its own + /// transcript about a hundredth. + static let maximumCells = 64_000_000 + + /// And a width beyond which the two sequences are not variants of + /// each other at all. + /// + /// The band has to span the difference in length, so a band this + /// wide means thousands of characters appear on one side and not the + /// other — speech recognition that gave up, not a transcript with + /// mistakes in it. + static let maximumBand = 4096 + func lowerBound(_ row: Int) -> Int { max(0, row - band) } @@ -139,7 +200,14 @@ public enum SequenceAlignment { } } - /// Fills the cost matrix, keeping only the move that produced each cell. + /// Fills the matrix, keeping only the move that produced each cell. + /// + /// Costs are kept for **two rows**, not the whole matrix: a cell depends + /// only on the row above it and the cell to its left, so the rest is + /// dead weight. Only the moves have to survive, and a move is one byte — + /// so the backtrace costs a fifth of what holding the costs as well + /// would, and an audiobook chapter against its own transcript fits in + /// single-digit megabytes. private static func fill( reference: [Character], candidate: [Character], @@ -148,39 +216,57 @@ public enum SequenceAlignment { -> [UInt8] { let unreachable = Int32.max / 4 - // Flat arrays rather than nested ones: this is the hot loop, and a - // `[[Int32]]` is a row of pointers to heap allocations. - var costs = [Int32](repeating: unreachable, count: band.cellCount) var moves = [UInt8](repeating: 0, count: band.cellCount) - - func cost(_ row: Int, _ column: Int) -> Int32 { - guard row >= 0, column >= 0, let index = band.slot(row, column) else { - return unreachable - } - return costs[index] - } + var previous = [Int32](repeating: unreachable, count: band.width) + var current = [Int32](repeating: unreachable, count: band.width) for row in 0 ... band.rows { - for column in band.lowerBound(row) ... band.upperBound(row) { - guard let index = band.slot(row, column) else { continue } + let lower = band.lowerBound(row) + let upper = band.upperBound(row) + let previousLower = row > 0 ? band.lowerBound(row - 1) : 0 + let previousUpper = row > 0 ? band.upperBound(row - 1) : -1 + + // No clearing between rows. Every offset this row reads is one + // it has already written: the loop writes `lower ... upper` in + // order, and `left` only looks at columns before the current + // one. A reset would be a wide array cleared per row for cells + // nothing looks at. + for column in lower ... upper { + let offset = column - lower if row == 0, column == 0 { - costs[index] = 0 + current[offset] = 0 continue } + // The row above, where it exists and reaches this column. + func above(_ target: Int) -> Int32 { + guard row > 0, target >= previousLower, target <= previousUpper else { + return unreachable + } + return previous[target - previousLower] + } + // The cell to the left, which this loop has already written. + func left(_ target: Int) -> Int32 { + guard target >= lower, target < column else { return unreachable } + return current[target - lower] + } + let same = row > 0 && column > 0 && reference[row - 1] == candidate[column - 1] - var best = cost(row - 1, column - 1) + (same ? 0 : 1) + var best = above(column - 1) + (same ? 0 : 1) var move = same ? AlignmentOperation.match : .substitute - if cost(row - 1, column) + 1 < best { - best = cost(row - 1, column) + 1 + if above(column) + 1 < best { + best = above(column) + 1 move = .delete } - if cost(row, column - 1) + 1 < best { - best = cost(row, column - 1) + 1 + if left(column - 1) + 1 < best { + best = left(column - 1) + 1 move = .insert } - costs[index] = best - moves[index] = move.rawValue + current[offset] = best + if let slot = band.slot(row, column) { + moves[slot] = move.rawValue + } } + swap(&previous, ¤t) } return moves } diff --git a/Sources/HanReaderPersistence/LibraryRepository+Listening.swift b/Sources/HanReaderPersistence/LibraryRepository+Listening.swift index 99120c8..48c230e 100644 --- a/Sources/HanReaderPersistence/LibraryRepository+Listening.swift +++ b/Sources/HanReaderPersistence/LibraryRepository+Listening.swift @@ -11,29 +11,75 @@ public import HanReaderCore /// concern, arriving whole in M11, and the repository's own body is long /// enough already. extension LibraryRepository { - /// Replaces a text's phrases wholesale. + /// Replaces a text's phrases, keeping what a person decided. /// /// All or nothing, in one transaction. Phrases are positional: a half- /// written set is not a shorter set, it is a set whose indices no longer /// mean what the attempts recorded against them mean. /// - /// 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. + /// Two things survive a replacement, and both are matched by **text + /// range** rather than by index, because re-aligning can insert, drop or + /// reorder phrases and an index means nothing across that: + /// + /// - **A boundary somebody dragged.** `movePhrase` marks it `manual` + /// precisely so a later re-alignment can leave it alone; without this + /// the recogniser would overwrite the correction on its next run and + /// the reader would fix the same boundary for ever. + /// - **An attempt**, but only where the phrase still covers the same + /// text. Keeping it by index would hand an earlier answer and score to + /// whatever phrase happened to land on that index — marking a phrase + /// the reader has never seen as already passed. public func savePhrases(_ phrases: [Phrase], for id: TextID) async throws { try await dbQueue.write { db in + let existing = try Row.fetchAll( + db, + sql: """ + SELECT idx, startTime, endTime, textStart, textEnd, origin FROM phrase + WHERE textId = ? + """, + arguments: [id.rawValue], + ) + + // Old text range -> the times a person set for it. + var corrected: [Range: (start: Double, end: Double)] = [:] + // Old index -> the text range it covered. + var previousRanges: [Int: Range] = [:] + for row in existing { + let range = (row["textStart"] as Int) ..< (row["textEnd"] as Int) + previousRanges[row["idx"] as Int] = range + if row["origin"] as String? == PhraseOrigin.manual.rawValue { + corrected[range] = (row["startTime"], row["endTime"]) + } + } + try db.execute(sql: "DELETE FROM phrase WHERE textId = ?", arguments: [id.rawValue]) + var kept: Set = [] + for phrase in phrases where previousRanges[phrase.index] == phrase.textRange { + kept.insert(phrase.index) + } + // Everything else is an answer to a question that no longer + // exists. + try db.execute(sql: """ + DELETE FROM dictationAttempt + WHERE textId = ? AND idx NOT IN (\(kept.map(\.description).joined(separator: ","))) + """, arguments: [id.rawValue]) + for phrase in phrases { + // A boundary a person set for this very text wins over the + // one being offered. + let fixed = corrected[phrase.textRange] try db.execute(sql: """ INSERT INTO phrase (textId, idx, startTime, endTime, textStart, textEnd, confidence, origin, grade) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) """, arguments: [ - id.rawValue, phrase.index, phrase.start, phrase.end, + id.rawValue, phrase.index, + fixed?.start ?? phrase.start, fixed?.end ?? phrase.end, phrase.textRange.lowerBound, phrase.textRange.upperBound, - phrase.confidence, phrase.origin.rawValue, phrase.grade?.rawValue, + phrase.confidence, + (fixed == nil ? phrase.origin : .manual).rawValue, + phrase.grade?.rawValue, ]) } } diff --git a/Tests/HanReaderCoreTests/DictationComparisonTests.swift b/Tests/HanReaderCoreTests/DictationComparisonTests.swift index 8dd7147..4e97faf 100644 --- a/Tests/HanReaderCoreTests/DictationComparisonTests.swift +++ b/Tests/HanReaderCoreTests/DictationComparisonTests.swift @@ -70,14 +70,62 @@ struct DictationComparisonTests { #expect(result.marks.count == 5) } - /// Nothing to get wrong. Dividing by zero to say otherwise would be - /// arithmetic masquerading as judgement. - @Test("An empty phrase scores one rather than dividing by zero") + /// Nothing expected and nothing typed is a phrase with nothing to get + /// wrong. Nothing expected but something typed is simply wrong, and + /// saying so beats dividing by zero and calling it full marks. + @Test("An empty phrase scores one, but not if something was typed into it") func emptyPhrase() { let result = check("", "") #expect(result.accuracy == 1) #expect(result.isPerfect) - #expect(check("多余", "").accuracy == 1) + #expect(check("多余", "").accuracy == 0) + } + + /// Review finding: dividing matches by the *expected* length alone left + /// invented characters free. Typing the phrase correctly and then adding + /// nonsense scored one, and passed. + @Test("Invented characters bring the score down") + func extrasAreScored() { + let perfect = check("山村的孩子", "山村的孩子") + #expect(perfect.accuracy == 1) + + let padded = check("山村的孩子猫狗猫", "山村的孩子") + #expect(padded.count(of: .extra) == 3) + #expect(padded.accuracy < 1) + // Five right out of five expected plus three invented. + #expect(abs(padded.accuracy - 5.0 / 8.0) < 0.000_1) + #expect(!padded.isPerfect) + } + + /// Review finding: the passing contract says a homophone is acceptable + /// because the learner heard the phrase, but the strict score counts + /// only exact matches — so such an attempt could never reach the default + /// threshold. Two scores, and the one that gates drilling is the one + /// that measures listening. + @Test("A homophone slip is heard, even though it is not correct") + func listeningAccuracyForgivesHomophones() { + // 真 for 针: both zhen1. + let result = check("真是", "针是") + #expect(result.accuracy == 0.5) + #expect(result.listeningAccuracy == 1) + #expect(!result.isPerfect) + } + + /// But a tone slip is not forgiven. Tones are what an ear loses first, + /// and they are what this exercise is most useful for. + @Test("A tone slip is not counted as heard") + func listeningAccuracyKeepsToneSlips() { + // 是 shi4 for 时 shi2. + let result = check("是重", "时重") + #expect(result.accuracy == 0.5) + #expect(result.listeningAccuracy == 0.5) + } + + @Test("Invented characters count against the listening score too") + func listeningAccuracyScoresExtras() { + let result = check("针猫狗", "针") + #expect(result.listeningAccuracy < 1) + #expect(result.scale == 3) } @Test("Accuracy counts matched characters over expected characters") diff --git a/Tests/HanReaderCoreTests/PhraseSplitterTests.swift b/Tests/HanReaderCoreTests/PhraseSplitterTests.swift index ee7b092..863c4b1 100644 --- a/Tests/HanReaderCoreTests/PhraseSplitterTests.swift +++ b/Tests/HanReaderCoreTests/PhraseSplitterTests.swift @@ -61,6 +61,35 @@ struct PhraseSplitterTests { #expect(result.allSatisfy { $0.count > 1 }) } + /// Review finding: 真的吗?! is one question with an exclamation on it. + /// Ending the sentence at ? left ! to start the next one, so the reader + /// would be asked to transcribe a phrase consisting of a punctuation + /// mark. + @Test("Repeated terminators stay with the sentence they end") + func repeatedTerminators() { + // With the minimum switched off, so the boundary itself is what is + // being tested. At the default minimum the stray ! would be glued + // back on by the merge and a wrong sentence boundary would look + // right from the outside. + #expect(phrases("真的吗?!接下来我们继续。", minimum: 0) == ["真的吗?!", "接下来我们继续。"]) + #expect(phrases("是吗?!我们继续下去。", minimum: 0) == ["是吗?!", "我们继续下去。"]) + // And the merge still covers it at the default, which is what a + // reader actually sees. + #expect(phrases("真的吗?!接下来我们继续。") == ["真的吗?!", "接下来我们继续。"]) + } + + /// Review finding: dividing an over-long sentence ended a clause at its + /// terminator and left the closing quote as another. With the clause + /// before it already over budget, neither packing nor the minimum-length + /// merge would take it back. + @Test("A closing mark never becomes a phrase of its own") + func punctuationIsNeverAPhraseAlone() { + let source = "他大声地说道这件事情非常重要我们一定要认真对待它?”" + for phrase in phrases(source, maximum: 8, minimum: 4) { + #expect(PhraseSplitter.characterCount(of: phrase, in: 0 ..< phrase.utf16.count) > 0) + } + } + @Test("A final sentence with no terminator is still a phrase") func unterminatedTail() { #expect(phrases("第一句。没有句号的结尾") == ["第一句。", "没有句号的结尾"]) diff --git a/Tests/HanReaderCoreTests/SequenceAlignmentTests.swift b/Tests/HanReaderCoreTests/SequenceAlignmentTests.swift index e848f6e..e0706aa 100644 --- a/Tests/HanReaderCoreTests/SequenceAlignmentTests.swift +++ b/Tests/HanReaderCoreTests/SequenceAlignmentTests.swift @@ -206,6 +206,44 @@ struct SequenceAlignmentTests { /// the diagonal and call it three substitutions. It is two: drop 甲, keep /// 乙丙, add 丁. Identical sequences would not have shown the difference, /// which is what this test originally used and why it proved nothing. + /// Review finding: the band widens by the difference in length, so a + /// long transcript against a near-empty recognition — speech recognition + /// that gave up — made the band as wide as the transcript and the + /// matrices quadratic. 6,000 characters against 3 would have been + /// hundreds of megabytes for an answer that is "almost all of it is + /// missing". + @Test("A wildly mismatched pair is declined rather than paid for") + func degenerateLengthsAreDeclined() { + let rows = 6000 + let band = SequenceAlignment.Band(rows: rows, columns: 3, slack: 128) + // The band cannot be narrowed — it has to span the difference in + // length or the path does not fit inside it — so the only choice is + // to spend the memory or decline. + #expect(band.exceedsBudget) + + // Declining still returns a complete, well-formed alignment: every + // reference character missing, every candidate character invented. + let steps = SequenceAlignment.align( + reference: Array(String(repeating: "山", count: rows)), + candidate: Array("山村的"), + ) + #expect(steps.compactMap(\.reference) == Array(0 ..< rows)) + #expect(steps.compactMap(\.candidate) == [0, 1, 2]) + #expect(steps.allSatisfy { $0.operation == .delete || $0.operation == .insert }) + } + + /// The clamp must not touch the sizes this is actually used at. A phrase + /// is tens of characters and a whole exercise is hundreds; both must get + /// the exact answer. + @Test("Real-sized inputs are never clamped") + func realisticSizesAreUnclamped() { + let band = SequenceAlignment.Band(rows: 609, columns: 600, slack: 128) + #expect(band.band == 137) + #expect(!band.exceedsBudget) + // A whole audiobook chapter against its own transcript, too. + #expect(!SequenceAlignment.Band(rows: 20000, columns: 20100, slack: 128).exceedsBudget) + } + @Test("A slack of zero still leaves room for one indel") func zeroSlackKeepsOneStepOfRoom() { let steps = SequenceAlignment.align( diff --git a/Tests/HanReaderPersistenceTests/ListeningPracticeTests.swift b/Tests/HanReaderPersistenceTests/ListeningPracticeTests.swift index dd8eb5b..2358f2f 100644 --- a/Tests/HanReaderPersistenceTests/ListeningPracticeTests.swift +++ b/Tests/HanReaderPersistenceTests/ListeningPracticeTests.swift @@ -68,7 +68,7 @@ struct ListeningPracticeTests { /// Re-aligning a recording usually moves boundaries by fractions of a /// second. Throwing away what the reader has already mastered would make /// fixing one boundary cost them the whole text. - @Test("Re-aligning keeps the reader's attempts") + @Test("Re-aligning keeps attempts whose phrase still covers the same text") func realigningKeepsAttempts() async throws { let library = try library() let id = try await text(in: library) @@ -79,6 +79,76 @@ struct ListeningPracticeTests { #expect(try await library.attempts(for: id)[0]?.accuracy == 1) } + /// Review finding: attempts are keyed by index and phrases are + /// positional, so re-aligning into a different number of phrases handed + /// an earlier answer to whatever landed on that index — marking a phrase + /// the reader has never seen as already passed. + @Test("Re-aligning drops attempts whose phrase no longer covers that text") + func realigningDropsStaleAttempts() async throws { + let library = try library() + let id = try await text(in: library) + try await library.savePhrases([phrase(0, 0, 4), phrase(1, 4, 9)], for: id) + try await library.recordAttempt("第一句", accuracy: 1, at: 0, in: id) + try await library.recordAttempt("第二句", accuracy: 1, at: 1, in: id) + + // A re-alignment that split the first phrase in two: index 1 now + // covers different words entirely. + let resplit = [ + Phrase(index: 0, start: 0, end: 2, textRange: 0 ..< 9), + Phrase(index: 1, start: 2, end: 4, textRange: 9 ..< 14), + Phrase(index: 2, start: 4, end: 9, textRange: 10 ..< 19), + ] + try await library.savePhrases(resplit, for: id) + + let attempts = try await library.attempts(for: id) + // Index 0 still covers 0..<9, so its answer still means something. + #expect(attempts[0]?.typed == "第一句") + // Index 1 covers different words now, so its answer is gone. + #expect(attempts[1] == nil) + #expect(attempts[2] == nil) + } + + /// Review finding: `movePhrase` marks a boundary `manual` precisely so a + /// later re-alignment can leave it alone — but saving replaced it with + /// the recogniser's, so the reader would fix the same boundary for ever. + @Test("Re-aligning keeps a boundary somebody dragged") + func realigningKeepsManualBoundaries() async throws { + let library = try library() + let id = try await text(in: library) + try await library.savePhrases([phrase(0, 0, 4), phrase(1, 4, 9)], for: id) + try await library.movePhrase(0, in: id, start: 0.4, end: 3.6) + + // The recogniser runs again and offers its own times for the same + // words. + try await library.savePhrases([phrase(0, 0, 4), phrase(1, 4, 9)], for: id) + + let stored = try await library.phrases(for: id) + #expect(stored.first?.start == 0.4) + #expect(stored.first?.end == 3.6) + #expect(stored.first?.origin == .manual) + // The one nobody touched takes the new times. + #expect(stored.last?.start == 4) + #expect(stored.last?.origin == .recognised) + } + + /// But only for the same words. A correction is about where a particular + /// phrase begins; re-splitting the text makes it meaningless. + @Test("A correction does not follow text it was not made for") + func correctionsDoNotFollowDifferentText() async throws { + let library = try library() + let id = try await text(in: library) + try await library.savePhrases([phrase(0, 0, 4)], for: id) + try await library.movePhrase(0, in: id, start: 0.4, end: 3.6) + + try await library.savePhrases( + [Phrase(index: 0, start: 0, end: 4, textRange: 100 ..< 120)], + for: id, + ) + let stored = try #require(try await library.phrases(for: id).first) + #expect(stored.start == 0) + #expect(stored.origin == .recognised) + } + @Test("A text with no phrases reports as much without fetching them") func hasPhrases() async throws { let library = try library()