368 lines
14 KiB
Swift
368 lines
14 KiB
Swift
// EnglishSuggestionEngine.swift
|
|
// OSGKeyboard · Shared
|
|
//
|
|
// Builds a 3-slot English QuickType board: verbatim / correction / completion
|
|
// (or next-word after commit). Space applies only the correction slot.
|
|
|
|
import Foundation
|
|
|
|
public struct EnglishSuggestionContext: Sendable {
|
|
public var currentWord: String
|
|
public var previousWord: String
|
|
public var personalTerms: [String]
|
|
public var learnedBoosts: [String: Int]
|
|
public var includeOriginalAfterCorrection: String?
|
|
/// Contacts / text replacements from `UILexicon`.
|
|
public var systemWords: [String]
|
|
public var systemCompletions: [String]
|
|
public var systemGuesses: [String]
|
|
|
|
public init(
|
|
currentWord: String = "",
|
|
previousWord: String = "",
|
|
personalTerms: [String] = [],
|
|
learnedBoosts: [String: Int] = [:],
|
|
includeOriginalAfterCorrection: String? = nil,
|
|
systemWords: [String] = [],
|
|
systemCompletions: [String] = [],
|
|
systemGuesses: [String] = []
|
|
) {
|
|
self.currentWord = currentWord
|
|
self.previousWord = previousWord
|
|
self.personalTerms = personalTerms
|
|
self.learnedBoosts = learnedBoosts
|
|
self.includeOriginalAfterCorrection = includeOriginalAfterCorrection
|
|
self.systemWords = systemWords
|
|
self.systemCompletions = systemCompletions
|
|
self.systemGuesses = systemGuesses
|
|
}
|
|
}
|
|
|
|
public struct EnglishCorrectionDecision: Equatable, Sendable {
|
|
public var original: String
|
|
public var replacement: String
|
|
/// Trailing characters inserted with the replacement (`" "`, `"\n"`, punct).
|
|
public var appliedSuffix: String
|
|
|
|
public init(original: String, replacement: String, appliedSuffix: String = "") {
|
|
self.original = original
|
|
self.replacement = replacement
|
|
self.appliedSuffix = appliedSuffix
|
|
}
|
|
|
|
public var undoDeleteCount: Int {
|
|
replacement.count + appliedSuffix.count
|
|
}
|
|
}
|
|
|
|
/// Pure ranking / candidate builder — no UITextDocumentProxy access.
|
|
public struct EnglishSuggestionEngine: Sendable {
|
|
public static let slotCount = 3
|
|
/// In-vocabulary words only yield to a much more common transposition / neighbor.
|
|
public static let inVocabularyFrequencyGap = 250
|
|
/// Rare words benefit from explicit personal-dictionary treatment; common
|
|
/// words are already covered well by the bundled lexicon.
|
|
public static let personalTermFrequencyCeiling = 680
|
|
|
|
private let lexicon: EnglishLexicon
|
|
|
|
public init(lexicon: EnglishLexicon = .shared) {
|
|
self.lexicon = lexicon
|
|
}
|
|
|
|
public func prepare() {
|
|
lexicon.prepare()
|
|
}
|
|
|
|
/// Suggestions only while the user is actively typing an English word.
|
|
public func compositionWhileTyping(_ context: EnglishSuggestionContext) -> TypingComposition {
|
|
let prefix = context.currentWord
|
|
guard !prefix.isEmpty else { return .empty }
|
|
return makeBoard(context).composition
|
|
}
|
|
|
|
/// Decide whether to autocorrect on space / punctuation.
|
|
public func correctionDecision(
|
|
for typed: String,
|
|
personalTerms: [String],
|
|
learnedBoosts: [String: Int],
|
|
previousWord: String = "",
|
|
systemWords: [String] = [],
|
|
systemGuesses: [String] = []
|
|
) -> EnglishCorrectionDecision? {
|
|
let context = EnglishSuggestionContext(
|
|
currentWord: typed,
|
|
previousWord: previousWord,
|
|
personalTerms: personalTerms,
|
|
learnedBoosts: learnedBoosts,
|
|
systemWords: systemWords,
|
|
systemGuesses: systemGuesses
|
|
)
|
|
return makeBoard(context).decision
|
|
}
|
|
|
|
public func nextWordComposition(_ context: EnglishSuggestionContext) -> TypingComposition {
|
|
var ranked: [(text: String, score: Int, role: TypingCandidateRole, quoted: Bool)] = []
|
|
var seen = Set<String>()
|
|
|
|
func append(_ raw: String, baseScore: Int, role: TypingCandidateRole, quoted: Bool = false) {
|
|
let key = raw.lowercased()
|
|
guard seen.insert(key).inserted else { return }
|
|
let boost = context.learnedBoosts[key] ?? 0
|
|
let personalBoost = isPersonal(key, in: context) ? 2_000 : 0
|
|
ranked.append((raw, baseScore + boost + personalBoost, role, quoted))
|
|
}
|
|
|
|
if let original = context.includeOriginalAfterCorrection {
|
|
append(original, baseScore: 20_000, role: .verbatim, quoted: true)
|
|
}
|
|
|
|
if !context.previousWord.isEmpty {
|
|
for (index, word) in lexicon.nextWords(after: context.previousWord, limit: 8).enumerated() {
|
|
append(word, baseScore: 1_200 - index * 10, role: .nextWord)
|
|
}
|
|
}
|
|
|
|
for term in context.personalTerms.prefix(4) {
|
|
append(term, baseScore: 500, role: .nextWord)
|
|
}
|
|
|
|
if ranked.filter({ $0.role == .nextWord }).isEmpty {
|
|
for (index, word) in lexicon.topWords(limit: 6).enumerated() {
|
|
append(word, baseScore: 200 - index, role: .nextWord)
|
|
}
|
|
}
|
|
|
|
ranked.sort { $0.score > $1.score }
|
|
let candidates = ranked.prefix(Self.slotCount).map {
|
|
TypingCandidate(
|
|
id: "\($0.role.rawValue)|\($0.text.lowercased())",
|
|
text: $0.text,
|
|
role: $0.role,
|
|
isQuoted: $0.quoted
|
|
)
|
|
}
|
|
return TypingComposition(preedit: "", candidates: Array(candidates))
|
|
}
|
|
|
|
public func isKnownWord(_ word: String, personalTerms: [String], systemWords: [String]) -> Bool {
|
|
let lower = word.lowercased()
|
|
if lexicon.contains(lower) { return true }
|
|
if personalTerms.contains(where: { $0.lowercased() == lower }) { return true }
|
|
if systemWords.contains(where: { $0.lowercased() == lower }) { return true }
|
|
return false
|
|
}
|
|
|
|
/// Whether repeated use of this word would add value to PersonalDictionary.
|
|
/// Existing personal/system terms and common lexicon words need no prompt.
|
|
func isPersonalTermCandidate(
|
|
_ word: String,
|
|
personalTerms: [String],
|
|
systemWords: [String]
|
|
) -> Bool {
|
|
guard FrequentTermStore.normalizedCandidate(from: word) != nil,
|
|
PersonalDictionary.isEnglishTypingHotword(word) else {
|
|
return false
|
|
}
|
|
|
|
let lower = word.lowercased()
|
|
guard !personalTerms.contains(where: { $0.lowercased() == lower }),
|
|
!systemWords.contains(where: { $0.lowercased() == lower }) else {
|
|
return false
|
|
}
|
|
|
|
// Preserve intentional product spelling even when the base word is
|
|
// common enough to exist in the bundled lexicon (OpenAI, iOS26, GPT-5).
|
|
let hasDistinctiveShape = word.dropFirst().contains(where: \.isUppercase)
|
|
|| word.contains(where: \.isNumber)
|
|
|| word.contains(where: { "-.+#".contains($0) })
|
|
if hasDistinctiveShape {
|
|
return true
|
|
}
|
|
|
|
return lexicon.frequency(of: lower) <= Self.personalTermFrequencyCeiling
|
|
}
|
|
|
|
// MARK: - Board
|
|
|
|
private struct Board {
|
|
var composition: TypingComposition
|
|
var decision: EnglishCorrectionDecision?
|
|
}
|
|
|
|
private func makeBoard(_ context: EnglishSuggestionContext) -> Board {
|
|
let typed = context.currentWord
|
|
let decision = makeCorrectionDecision(context)
|
|
var slots: [TypingCandidate] = []
|
|
var seen = Set<String>()
|
|
|
|
func add(_ text: String, role: TypingCandidateRole, quoted: Bool = false) {
|
|
let key = text.lowercased()
|
|
guard seen.insert(key).inserted else { return }
|
|
slots.append(
|
|
TypingCandidate(
|
|
id: "\(role.rawValue)|\(key)",
|
|
text: text,
|
|
role: role,
|
|
isQuoted: quoted
|
|
)
|
|
)
|
|
}
|
|
|
|
let known = isKnownWord(
|
|
typed,
|
|
personalTerms: context.personalTerms,
|
|
systemWords: context.systemWords
|
|
)
|
|
add(typed, role: .verbatim, quoted: !known)
|
|
|
|
if let decision {
|
|
add(decision.replacement, role: .correction)
|
|
}
|
|
|
|
for term in context.personalTerms where term.lowercased().hasPrefix(typed.lowercased())
|
|
&& term.lowercased() != typed.lowercased() {
|
|
add(term, role: .completion)
|
|
if slots.count >= Self.slotCount { break }
|
|
}
|
|
|
|
for word in context.systemCompletions {
|
|
let display = matchCase(of: typed, to: word)
|
|
add(display, role: .completion)
|
|
if slots.count >= Self.slotCount { break }
|
|
}
|
|
|
|
for word in lexicon.completions(prefix: typed, limit: 8) {
|
|
add(matchCase(of: typed, to: word), role: .completion)
|
|
if slots.count >= Self.slotCount { break }
|
|
}
|
|
|
|
let composition = TypingComposition(
|
|
preedit: typed,
|
|
candidates: Array(slots.prefix(Self.slotCount))
|
|
)
|
|
return Board(composition: composition, decision: decision)
|
|
}
|
|
|
|
private func makeCorrectionDecision(_ context: EnglishSuggestionContext) -> EnglishCorrectionDecision? {
|
|
let typed = context.currentWord
|
|
guard typed.count >= 3 else { return nil }
|
|
let lower = typed.lowercased()
|
|
|
|
if isProtectedToken(typed) { return nil }
|
|
if isPersonal(lower, in: context) { return nil }
|
|
if context.systemWords.contains(where: { $0.lowercased() == lower }) { return nil }
|
|
if (context.learnedBoosts[lower] ?? 0) >= 5 { return nil }
|
|
|
|
let inLexicon = lexicon.contains(lower)
|
|
let typedFreq = lexicon.frequency(of: lower) + (context.learnedBoosts[lower] ?? 0)
|
|
|
|
var pool = lexicon.scoredCorrections(for: lower, limit: 8)
|
|
for guess in context.systemGuesses {
|
|
let word = guess.lowercased()
|
|
guard word != lower else { continue }
|
|
if pool.contains(where: { $0.word == word }) { continue }
|
|
guard let alignment = EnglishQWERTYProximity.align(typed: lower, candidate: word) else { continue }
|
|
pool.append(
|
|
EnglishScoredCorrection(
|
|
word: word,
|
|
spatialCost: alignment.cost,
|
|
frequency: max(lexicon.frequency(of: word), 1),
|
|
isTransposition: alignment.isTransposition,
|
|
isShortening: alignment.isShortening
|
|
)
|
|
)
|
|
}
|
|
|
|
var best: (EnglishScoredCorrection, Int)?
|
|
for candidate in pool {
|
|
guard allowsAutocorrect(
|
|
typed: typed,
|
|
replacement: candidate.word,
|
|
inLexicon: inLexicon,
|
|
typedFreq: typedFreq,
|
|
candidate: candidate
|
|
) else { continue }
|
|
var score = candidate.frequency * 2 - candidate.spatialCost
|
|
if isPersonal(candidate.word, in: context) { score += 5_000 }
|
|
score += context.learnedBoosts[candidate.word] ?? 0
|
|
if lexicon.nextWords(after: context.previousWord).contains(candidate.word) {
|
|
score += 80
|
|
}
|
|
if let current = best {
|
|
if score > current.1 { best = (candidate, score) }
|
|
} else {
|
|
best = (candidate, score)
|
|
}
|
|
}
|
|
|
|
guard let best else { return nil }
|
|
let keepScore = inLexicon ? typedFreq * 2 : 0
|
|
guard best.1 > keepScore + 40 else { return nil }
|
|
return EnglishCorrectionDecision(
|
|
original: typed,
|
|
replacement: matchCase(of: typed, to: best.0.word)
|
|
)
|
|
}
|
|
|
|
private func allowsAutocorrect(
|
|
typed: String,
|
|
replacement: String,
|
|
inLexicon: Bool,
|
|
typedFreq: Int,
|
|
candidate: EnglishScoredCorrection
|
|
) -> Bool {
|
|
if isTitleCase(typed) {
|
|
// Teh → The is a same-length transposition. Rocky → Rock is not.
|
|
guard candidate.isTransposition, !candidate.isShortening else { return false }
|
|
}
|
|
if inLexicon {
|
|
let gap = candidate.frequency - typedFreq
|
|
// Web-corpus dumps leak typos (`teh`, `adn`) at the floor of the
|
|
// list. Real words like `form` sit much higher and must not yield
|
|
// to `from`.
|
|
let looksLikeLeakedTypo = typedFreq <= 680
|
|
if candidate.isTransposition {
|
|
return looksLikeLeakedTypo && gap >= 40
|
|
}
|
|
if typed.count == replacement.count,
|
|
candidate.spatialCost <= EnglishQWERTYProximity.adjacentCost {
|
|
return looksLikeLeakedTypo && gap >= Self.inVocabularyFrequencyGap
|
|
}
|
|
return false
|
|
}
|
|
return candidate.frequency > 0
|
|
}
|
|
|
|
private func isProtectedToken(_ typed: String) -> Bool {
|
|
if typed.count <= 2 { return true }
|
|
if typed.allSatisfy(\.isUppercase) { return true }
|
|
if typed.dropFirst().contains(where: \.isUppercase) { return true }
|
|
if typed.contains(where: \.isNumber) { return true }
|
|
if typed.contains("@") || typed.contains(".") || typed.contains("/") { return true }
|
|
if typed.contains("-") || typed.contains("_") { return true }
|
|
if typed.contains("'") || typed.contains("+") || typed.contains("#") { return true }
|
|
return false
|
|
}
|
|
|
|
private func isTitleCase(_ typed: String) -> Bool {
|
|
guard let first = typed.first, first.isUppercase else { return false }
|
|
let rest = typed.dropFirst()
|
|
return !rest.isEmpty && rest.allSatisfy(\.isLowercase)
|
|
}
|
|
|
|
private func isPersonal(_ key: String, in context: EnglishSuggestionContext) -> Bool {
|
|
context.personalTerms.contains { $0.lowercased() == key }
|
|
}
|
|
|
|
private func matchCase(of sample: String, to word: String) -> String {
|
|
if sample.allSatisfy(\.isUppercase) {
|
|
return word.uppercased()
|
|
}
|
|
if let first = sample.first, first.isUppercase {
|
|
return word.prefix(1).uppercased() + word.dropFirst().lowercased()
|
|
}
|
|
return word.lowercased()
|
|
}
|
|
}
|