diff --git a/Sources/HanReaderCore/Listening/DictationComparison.swift b/Sources/HanReaderCore/Listening/DictationComparison.swift new file mode 100644 index 0000000..4759bc2 --- /dev/null +++ b/Sources/HanReaderCore/Listening/DictationComparison.swift @@ -0,0 +1,306 @@ +// 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 typed exactly right. + public let correct: Int + /// Characters the transcript expected. + public let expected: Int + + public init(marks: [DictationMark], correct: Int, expected: Int) { + self.marks = marks + self.correct = correct + self.expected = expected + } + + /// 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 { + 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 { + 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..2bfcb9c --- /dev/null +++ b/Sources/HanReaderCore/Listening/Phrase.swift @@ -0,0 +1,126 @@ +// 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. + /// + /// 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 new file mode 100644 index 0000000..0f3fa51 --- /dev/null +++ b/Sources/HanReaderCore/Listening/PhraseSplitter.swift @@ -0,0 +1,358 @@ +// 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] + { + let metrics = Metrics(text) + var phrases: [Range] = [] + for sentence in sentences(in: text) { + if metrics.count(in: sentence) <= splitting.maximumLength { + phrases.append(sentence) + continue + } + phrases.append(contentsOf: divide(sentence, metrics: metrics, splitting: splitting)) + } + return merge(phrases, metrics: metrics, 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 || 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 + } + 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, + metrics: Metrics, + splitting: PhraseSplitting, + ) + -> [Range] + { + var clauses: [Range] = [] + var clauseStart = sentence.lowerBound + var offset = sentence.lowerBound + + for character in metrics.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 + // 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) + } + } + // 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], + metrics: Metrics, + splitting: PhraseSplitting, + ) + -> [Range] + { + guard splitting.minimumLength > 0 else { return phrases } + var merged: [Range] = [] + for phrase in phrases { + guard metrics.count(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 metrics.count(in: combined) <= splitting.maximumLength { + merged[merged.count - 1] = combined + } else { + merged.append(phrase) + } + } + return merged + } + + // MARK: - Measuring + + /// 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) + } + } + + /// 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( + _ 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..f5d1833 --- /dev/null +++ b/Sources/HanReaderCore/Listening/SequenceAlignment.swift @@ -0,0 +1,319 @@ +// 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) + 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. + /// + /// 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. + // + // 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) + } + + 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 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], + band: Band, + ) + -> [UInt8] + { + let unreachable = Int32.max / 4 + var moves = [UInt8](repeating: 0, count: band.cellCount) + var previous = [Int32](repeating: unreachable, count: band.width) + var current = [Int32](repeating: unreachable, count: band.width) + + for row in 0 ... band.rows { + 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 { + 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 = above(column - 1) + (same ? 0 : 1) + var move = same ? AlignmentOperation.match : .substitute + if above(column) + 1 < best { + best = above(column) + 1 + move = .delete + } + if left(column - 1) + 1 < best { + best = left(column - 1) + 1 + move = .insert + } + current[offset] = best + if let slot = band.slot(row, column) { + moves[slot] = move.rawValue + } + } + swap(&previous, ¤t) + } + 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..48c230e --- /dev/null +++ b/Sources/HanReaderPersistence/LibraryRepository+Listening.swift @@ -0,0 +1,213 @@ +// 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, 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. + /// + /// 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, + fixed?.start ?? phrase.start, fixed?.end ?? phrase.end, + phrase.textRange.lowerBound, phrase.textRange.upperBound, + phrase.confidence, + (fixed == nil ? phrase.origin : .manual).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..4e97faf --- /dev/null +++ b/Tests/HanReaderCoreTests/DictationComparisonTests.swift @@ -0,0 +1,287 @@ +// 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 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 == 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") + 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..863c4b1 --- /dev/null +++ b/Tests/HanReaderCoreTests/PhraseSplitterTests.swift @@ -0,0 +1,271 @@ +// 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 }) + } + + /// 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("第一句。没有句号的结尾") == ["第一句。", "没有句号的结尾"]) + } + + @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..e0706aa --- /dev/null +++ b/Tests/HanReaderCoreTests/SequenceAlignmentTests.swift @@ -0,0 +1,258 @@ +// 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. + /// 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( + 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..2358f2f --- /dev/null +++ b/Tests/HanReaderPersistenceTests/ListeningPracticeTests.swift @@ -0,0 +1,270 @@ +// 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 attempts whose phrase still covers the same text") + 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) + } + + /// 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() + 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