feat(keyboard): add English QuickType bar and system lexicon
Show verbatim/correction/completion slots, mmap a 40k-word list, and use UITextChecker plus supplementary lexicon for conservative autocorrect.
This commit is contained in:
@@ -14,7 +14,8 @@ public struct AIUserSkill: Codable, Equatable, Identifiable, Sendable {
|
||||
public var summary: String
|
||||
public var systemImage: String
|
||||
public var prompt: String
|
||||
public var shortcutICloudURL: URL
|
||||
/// Optional iCloud share URL. Nil means the skill only transforms text.
|
||||
public var shortcutICloudURL: URL?
|
||||
/// Name used by `shortcuts://run-shortcut?name=`. Independent of `name`.
|
||||
public var shortcutName: String
|
||||
/// Per-skill reasoning. Built-in skills are always off; custom defaults off.
|
||||
@@ -28,8 +29,8 @@ public struct AIUserSkill: Codable, Equatable, Identifiable, Sendable {
|
||||
summary: String = "",
|
||||
systemImage: String = AIUserSkillLimits.defaultSystemImage,
|
||||
prompt: String,
|
||||
shortcutICloudURL: URL,
|
||||
shortcutName: String,
|
||||
shortcutICloudURL: URL? = nil,
|
||||
shortcutName: String = "",
|
||||
thinkingEnabled: Bool = false,
|
||||
createdAt: Date = Date(),
|
||||
updatedAt: Date? = nil
|
||||
@@ -49,15 +50,16 @@ public struct AIUserSkill: Codable, Equatable, Identifiable, Sendable {
|
||||
public var isUserCreated: Bool { id.hasPrefix("user.") }
|
||||
|
||||
public func asClipboardSkill() -> AIClipboardSkill {
|
||||
AIClipboardSkill(
|
||||
let exportsToShortcut = shortcutICloudURL != nil
|
||||
return AIClipboardSkill(
|
||||
id: id,
|
||||
systemImage: systemImage,
|
||||
titleKey: "",
|
||||
cardTitleKey: "",
|
||||
descriptionKey: "",
|
||||
kind: .export,
|
||||
kind: exportsToShortcut ? .export : .transform,
|
||||
isDefault: false,
|
||||
shortcutName: shortcutName,
|
||||
shortcutName: exportsToShortcut ? shortcutName : nil,
|
||||
shortcutICloudURL: shortcutICloudURL,
|
||||
customName: name,
|
||||
customSummary: summary,
|
||||
@@ -149,9 +151,13 @@ public struct AIUserSkillCatalog: Codable, Equatable, Sendable {
|
||||
maximum: AIUserSkillLimits.maximumPromptCharacters
|
||||
)
|
||||
}
|
||||
guard !shortcutName.isEmpty else { throw AIUserSkillValidationError.emptyShortcutName }
|
||||
guard AIShortcutShareLink.isValid(skill.shortcutICloudURL) else {
|
||||
throw AIUserSkillValidationError.invalidShortcutLink
|
||||
if let shortcutURL = skill.shortcutICloudURL {
|
||||
guard !shortcutName.isEmpty else {
|
||||
throw AIUserSkillValidationError.emptyShortcutName
|
||||
}
|
||||
guard AIShortcutShareLink.isValid(shortcutURL) else {
|
||||
throw AIUserSkillValidationError.invalidShortcutLink
|
||||
}
|
||||
}
|
||||
guard !icon.isEmpty else { throw AIUserSkillValidationError.emptyIcon }
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -130,13 +130,26 @@ public final class AIAgentSkillLayoutStore: ObservableObject {
|
||||
}
|
||||
|
||||
public func saveUserSkill(_ skill: AIUserSkill) throws {
|
||||
let previousURL = userCatalog.skill(id: skill.id)?.shortcutICloudURL
|
||||
let previousSkill = userCatalog.skill(id: skill.id)
|
||||
let previousURL = previousSkill?.shortcutICloudURL
|
||||
let previousLayout = layout.sanitized(catalog: mergedCatalog)
|
||||
var catalog = userCatalog
|
||||
try catalog.upsert(skill)
|
||||
commitUserCatalog(catalog)
|
||||
if previousURL != nil, previousURL != skill.shortcutICloudURL {
|
||||
dropShortcutConfirmation(for: skill.id)
|
||||
guard previousSkill != nil, previousURL != skill.shortcutICloudURL else {
|
||||
return
|
||||
}
|
||||
let keepsKeyboardSlot = skill.shortcutICloudURL == nil
|
||||
commitLayout(
|
||||
AIAgentSkillLayout(
|
||||
enabledIDs: keepsKeyboardSlot
|
||||
? previousLayout.enabledIDs
|
||||
: previousLayout.enabledIDs.filter { $0 != skill.id },
|
||||
confirmedShortcutIDs: previousLayout.confirmedShortcutIDs.filter {
|
||||
$0 != skill.id
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
public func deleteUserSkill(id: String) {
|
||||
@@ -152,16 +165,6 @@ public final class AIAgentSkillLayoutStore: ObservableObject {
|
||||
)
|
||||
}
|
||||
|
||||
private func dropShortcutConfirmation(for id: String) {
|
||||
let current = layout.sanitized(catalog: mergedCatalog)
|
||||
commitLayout(
|
||||
AIAgentSkillLayout(
|
||||
enabledIDs: current.enabledIDs.filter { $0 != id },
|
||||
confirmedShortcutIDs: current.confirmedShortcutIDs.filter { $0 != id }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func commitLayout(_ layout: AIAgentSkillLayout) {
|
||||
persistLayout(layout)
|
||||
self.layout = loadLayout()
|
||||
|
||||
@@ -2,180 +2,359 @@
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Offline English word list + bigrams for the typing extension.
|
||||
// Loaded once, kept compact for the keyboard RSS budget.
|
||||
// The 40k-word table is a mmap'd binary (`english_lexicon.bin`); dirty heap
|
||||
// stays near zero until a lookup materializes a handful of result strings.
|
||||
// TSV files in the repo are the build input, not the runtime format.
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct EnglishScoredCorrection: Equatable, Sendable {
|
||||
public var word: String
|
||||
public var spatialCost: Int
|
||||
public var frequency: Int
|
||||
public var isTransposition: Bool
|
||||
public var isShortening: Bool
|
||||
|
||||
public init(
|
||||
word: String,
|
||||
spatialCost: Int,
|
||||
frequency: Int,
|
||||
isTransposition: Bool,
|
||||
isShortening: Bool
|
||||
) {
|
||||
self.word = word
|
||||
self.spatialCost = spatialCost
|
||||
self.frequency = frequency
|
||||
self.isTransposition = isTransposition
|
||||
self.isShortening = isShortening
|
||||
}
|
||||
}
|
||||
|
||||
/// Ranked English lexicon used by autocomplete / autocorrect / next-word.
|
||||
public final class EnglishLexicon: @unchecked Sendable {
|
||||
public static let shared = EnglishLexicon()
|
||||
|
||||
/// Lowercased word → relative frequency (higher is more common).
|
||||
private var frequencies: [String: Int] = [:]
|
||||
/// Sorted lowercased words for prefix binary search.
|
||||
private var sortedWords: [String] = []
|
||||
/// previous(lower) → next-word candidates (lower).
|
||||
private var bigrams: [String: [String]] = [:]
|
||||
private var mapped: Data?
|
||||
private var header: FileHeader?
|
||||
private var loaded = false
|
||||
private let lock = NSLock()
|
||||
|
||||
public init() {}
|
||||
|
||||
/// True after a successful mmap. Tests use this to prove Chinese typing
|
||||
/// does not pull the English table into the extension.
|
||||
public var isLoaded: Bool {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return loaded
|
||||
}
|
||||
|
||||
public func prepare() {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
guard !loaded else { return }
|
||||
loadLexicon()
|
||||
loadBigrams()
|
||||
loaded = true
|
||||
loadMappedLexicon()
|
||||
}
|
||||
|
||||
/// Release in-memory tables when leaving the typing surface (jetsam recovery).
|
||||
/// Release the mapped file when leaving English / the typing surface.
|
||||
public func unload() {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
frequencies.removeAll(keepingCapacity: false)
|
||||
sortedWords.removeAll(keepingCapacity: false)
|
||||
bigrams.removeAll(keepingCapacity: false)
|
||||
mapped = nil
|
||||
header = nil
|
||||
loaded = false
|
||||
}
|
||||
|
||||
public var wordCount: Int {
|
||||
prepareIfNeeded()
|
||||
return sortedWords.count
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return header?.unigramCount ?? 0
|
||||
}
|
||||
|
||||
public func frequency(of word: String) -> Int {
|
||||
prepareIfNeeded()
|
||||
return frequencies[word.lowercased()] ?? 0
|
||||
withMap { buf, header in
|
||||
guard let index = lookupIndex(asciiLowered(word), header: header, buf: buf) else {
|
||||
return 0
|
||||
}
|
||||
return frequency(at: index, header: header, buf: buf)
|
||||
} ?? 0
|
||||
}
|
||||
|
||||
public func contains(_ word: String) -> Bool {
|
||||
prepareIfNeeded()
|
||||
return frequencies[word.lowercased()] != nil
|
||||
withMap { buf, header in
|
||||
lookupIndex(asciiLowered(word), header: header, buf: buf) != nil
|
||||
} ?? false
|
||||
}
|
||||
|
||||
/// Highest-frequency unigrams, for next-word fallback when no bigram hits.
|
||||
public func topWords(limit: Int = 6) -> [String] {
|
||||
guard limit > 0 else { return [] }
|
||||
return withMap { buf, header in
|
||||
let count = min(limit, header.unigramCount)
|
||||
var words: [String] = []
|
||||
words.reserveCapacity(count)
|
||||
for rank in 0..<count {
|
||||
let index = Int(
|
||||
readU16(buf, header.freqRankOffset + rank * 2)
|
||||
)
|
||||
guard index < header.unigramCount else { continue }
|
||||
if let word = string(at: index, header: header, buf: buf) {
|
||||
words.append(word)
|
||||
}
|
||||
}
|
||||
return words
|
||||
} ?? []
|
||||
}
|
||||
|
||||
/// Prefix completions, highest frequency first.
|
||||
public func completions(prefix: String, limit: Int = 8) -> [String] {
|
||||
prepareIfNeeded()
|
||||
let needle = prefix.lowercased()
|
||||
let needle = asciiLowered(prefix)
|
||||
guard !needle.isEmpty, limit > 0 else { return [] }
|
||||
|
||||
var results: [(String, Int)] = []
|
||||
var index = lowerBound(needle)
|
||||
while index < sortedWords.count {
|
||||
let word = sortedWords[index]
|
||||
guard word.hasPrefix(needle) else { break }
|
||||
if word != needle {
|
||||
results.append((word, frequencies[word] ?? 0))
|
||||
return withMap { buf, header in
|
||||
var scored: [(Int, Int)] = []
|
||||
var index = lowerBound(needle, header: header, buf: buf)
|
||||
while index < header.unigramCount {
|
||||
guard let bytes = wordBytes(at: index, header: header, buf: buf) else { break }
|
||||
guard hasPrefix(bytes, needle) else { break }
|
||||
if !bytesEqual(bytes, needle) {
|
||||
scored.append((index, frequency(at: index, header: header, buf: buf)))
|
||||
}
|
||||
index += 1
|
||||
// Soft cap scan to keep keystroke path cheap.
|
||||
if scored.count >= limit * 8 { break }
|
||||
}
|
||||
index += 1
|
||||
// Soft cap scan to keep keystroke path cheap.
|
||||
if results.count >= limit * 8 { break }
|
||||
}
|
||||
results.sort { lhs, rhs in
|
||||
if lhs.1 != rhs.1 { return lhs.1 > rhs.1 }
|
||||
return lhs.0 < rhs.0
|
||||
}
|
||||
return Array(results.prefix(limit).map(\.0))
|
||||
scored.sort { lhs, rhs in
|
||||
if lhs.1 != rhs.1 { return lhs.1 > rhs.1 }
|
||||
return lhs.0 < rhs.0
|
||||
}
|
||||
return scored.prefix(limit).compactMap { pair in
|
||||
string(at: pair.0, header: header, buf: buf)
|
||||
}
|
||||
} ?? []
|
||||
}
|
||||
|
||||
/// Best edit-distance ≤ 2 correction, or nil when the typed word is fine.
|
||||
/// Uses Damerau–Levenshtein so adjacent swaps (teh → the) count as 1.
|
||||
/// Scans only same-initial-letter candidates (not the full frequency table).
|
||||
public func bestCorrection(for typed: String) -> String? {
|
||||
prepareIfNeeded()
|
||||
let needle = typed.lowercased()
|
||||
guard needle.count >= 2, let first = needle.first else { return nil }
|
||||
if frequencies[needle] != nil { return nil }
|
||||
/// Nearby words scored by QWERTY proximity + frequency. Does not decide
|
||||
/// whether autocorrect should fire — the suggestion engine does.
|
||||
public func scoredCorrections(for typed: String, limit: Int = 6) -> [EnglishScoredCorrection] {
|
||||
let needle = asciiLowered(typed)
|
||||
guard needle.count >= 3, let firstByte = needle.first, limit > 0 else { return [] }
|
||||
let first = Character(UnicodeScalar(firstByte))
|
||||
var initials = Set(EnglishQWERTYProximity.neighbors(of: first, includingSelf: true))
|
||||
initials.insert(first)
|
||||
|
||||
var best: (word: String, distance: Int, freq: Int)?
|
||||
var index = lowerBound(String(first))
|
||||
while index < sortedWords.count {
|
||||
let word = sortedWords[index]
|
||||
guard word.first == first else { break }
|
||||
defer { index += 1 }
|
||||
guard abs(word.count - needle.count) <= 2 else { continue }
|
||||
let freq = frequencies[word] ?? 0
|
||||
let distance = damerauLevenshtein(needle, word, max: 2)
|
||||
guard distance > 0, distance <= 2 else { continue }
|
||||
if let current = best {
|
||||
if distance < current.distance
|
||||
|| (distance == current.distance && freq > current.freq) {
|
||||
best = (word, distance, freq)
|
||||
return withMap { buf, header in
|
||||
var best: [ScoredIndex] = []
|
||||
best.reserveCapacity(limit)
|
||||
for initial in initials {
|
||||
guard let letter = initial.asciiLetterIndex else { continue }
|
||||
let rangeOffset = header.initialOffset + letter * 4
|
||||
let start = Int(readU16(buf, rangeOffset))
|
||||
let count = Int(readU16(buf, rangeOffset + 2))
|
||||
guard start >= 0, count >= 0, start + count <= header.unigramCount else { continue }
|
||||
for index in start..<(start + count) {
|
||||
guard let bytes = wordBytes(at: index, header: header, buf: buf) else { continue }
|
||||
let delta = abs(bytes.count - needle.count)
|
||||
guard delta <= 2, !bytesEqual(bytes, needle) else { continue }
|
||||
guard let alignment = EnglishQWERTYProximity.align(
|
||||
typedASCII: needle,
|
||||
candidateASCII: bytes
|
||||
) else { continue }
|
||||
guard alignment.cost > 0 else { continue }
|
||||
insertBest(
|
||||
ScoredIndex(
|
||||
index: index,
|
||||
spatialCost: alignment.cost,
|
||||
frequency: frequency(at: index, header: header, buf: buf),
|
||||
isTransposition: alignment.isTransposition,
|
||||
isShortening: alignment.isShortening
|
||||
),
|
||||
into: &best,
|
||||
limit: limit
|
||||
)
|
||||
}
|
||||
} else {
|
||||
best = (word, distance, freq)
|
||||
}
|
||||
}
|
||||
guard let best else { return nil }
|
||||
// Distance-2 corrections need a common word so rare near-misses don't win.
|
||||
if best.distance == 2, best.freq < 200 { return nil }
|
||||
return best.word
|
||||
return best.compactMap { scored in
|
||||
guard let word = string(at: scored.index, header: header, buf: buf) else {
|
||||
return nil
|
||||
}
|
||||
return EnglishScoredCorrection(
|
||||
word: word,
|
||||
spatialCost: scored.spatialCost,
|
||||
frequency: scored.frequency,
|
||||
isTransposition: scored.isTransposition,
|
||||
isShortening: scored.isShortening
|
||||
)
|
||||
}
|
||||
} ?? []
|
||||
}
|
||||
|
||||
/// Best proximity correction, or nil when the typed word is already known.
|
||||
public func bestCorrection(for typed: String) -> String? {
|
||||
if contains(typed) { return nil }
|
||||
return scoredCorrections(for: typed, limit: 1).first?.word
|
||||
}
|
||||
|
||||
public func nextWords(after previous: String, limit: Int = 6) -> [String] {
|
||||
prepareIfNeeded()
|
||||
let key = previous.lowercased()
|
||||
guard let list = bigrams[key] else { return [] }
|
||||
return Array(list.prefix(limit))
|
||||
guard limit > 0 else { return [] }
|
||||
let needle = asciiLowered(previous)
|
||||
return withMap { buf, header in
|
||||
guard let prevIndex = lookupIndex(needle, header: header, buf: buf) else {
|
||||
return []
|
||||
}
|
||||
guard let group = lookupBigramGroup(prevIndex: prevIndex, header: header, buf: buf) else {
|
||||
return []
|
||||
}
|
||||
let count = min(limit, group.nextCount)
|
||||
var words: [String] = []
|
||||
words.reserveCapacity(count)
|
||||
for offset in 0..<count {
|
||||
let index = Int(readU16(buf, header.bigramNextOffset + (group.firstNext + offset) * 2))
|
||||
if let word = string(at: index, header: header, buf: buf) {
|
||||
words.append(word)
|
||||
}
|
||||
}
|
||||
return words
|
||||
} ?? []
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
// MARK: - Mapped file
|
||||
|
||||
private func prepareIfNeeded() {
|
||||
if !loaded { prepare() }
|
||||
private struct FileHeader {
|
||||
var unigramCount: Int
|
||||
var bigramGroupCount: Int
|
||||
var stringPoolOffset: Int
|
||||
var stringPoolSize: Int
|
||||
var unigramOffset: Int
|
||||
var freqRankOffset: Int
|
||||
var initialOffset: Int
|
||||
var bigramIndexOffset: Int
|
||||
var bigramNextOffset: Int
|
||||
var fileSize: Int
|
||||
|
||||
static let magic = "OSGENG01"
|
||||
static let version = 1
|
||||
static let headerSize = 64
|
||||
static let initialCount = 26
|
||||
|
||||
static func parse(_ data: Data) -> FileHeader? {
|
||||
guard data.count >= headerSize else { return nil }
|
||||
return data.withUnsafeBytes { buf -> FileHeader? in
|
||||
let magicBytes = UnsafeRawBufferPointer(rebasing: buf[0..<8])
|
||||
let magic = String(bytes: magicBytes, encoding: .ascii)
|
||||
guard magic == Self.magic else { return nil }
|
||||
guard Int(readU32(buf, 8)) == version else { return nil }
|
||||
let unigramCount = Int(readU32(buf, 12))
|
||||
let bigramGroupCount = Int(readU32(buf, 16))
|
||||
let stringPoolOffset = Int(readU32(buf, 20))
|
||||
let stringPoolSize = Int(readU32(buf, 24))
|
||||
let unigramOffset = Int(readU32(buf, 28))
|
||||
let freqRankOffset = Int(readU32(buf, 32))
|
||||
let initialOffset = Int(readU32(buf, 36))
|
||||
let bigramIndexOffset = Int(readU32(buf, 40))
|
||||
let bigramNextOffset = Int(readU32(buf, 44))
|
||||
let fileSize = data.count
|
||||
|
||||
guard unigramCount >= 0, unigramCount <= 200_000 else { return nil }
|
||||
guard bigramGroupCount >= 0, bigramGroupCount <= 100_000 else { return nil }
|
||||
guard region(unigramOffset, unigramCount * 8, in: fileSize),
|
||||
region(freqRankOffset, unigramCount * 2, in: fileSize),
|
||||
region(initialOffset, initialCount * 4, in: fileSize),
|
||||
region(bigramIndexOffset, bigramGroupCount * 8, in: fileSize),
|
||||
region(stringPoolOffset, stringPoolSize, in: fileSize)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return FileHeader(
|
||||
unigramCount: unigramCount,
|
||||
bigramGroupCount: bigramGroupCount,
|
||||
stringPoolOffset: stringPoolOffset,
|
||||
stringPoolSize: stringPoolSize,
|
||||
unigramOffset: unigramOffset,
|
||||
freqRankOffset: freqRankOffset,
|
||||
initialOffset: initialOffset,
|
||||
bigramIndexOffset: bigramIndexOffset,
|
||||
bigramNextOffset: bigramNextOffset,
|
||||
fileSize: fileSize
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private static func region(_ offset: Int, _ size: Int, in fileSize: Int) -> Bool {
|
||||
offset >= 0 && size >= 0 && offset <= fileSize && size <= fileSize - offset
|
||||
}
|
||||
}
|
||||
|
||||
private func loadLexicon() {
|
||||
private struct ScoredIndex {
|
||||
var index: Int
|
||||
var spatialCost: Int
|
||||
var frequency: Int
|
||||
var isTransposition: Bool
|
||||
var isShortening: Bool
|
||||
}
|
||||
|
||||
private struct BigramGroup {
|
||||
var nextCount: Int
|
||||
var firstNext: Int
|
||||
}
|
||||
|
||||
private func loadMappedLexicon() {
|
||||
guard let url = Bundle(for: EnglishLexicon.self)
|
||||
.url(forResource: "english_lexicon", withExtension: "tsv", subdirectory: nil)
|
||||
?? Bundle(for: EnglishLexicon.self)
|
||||
.url(forResource: "english_lexicon", withExtension: "tsv")
|
||||
?? Bundle.main.url(forResource: "english_lexicon", withExtension: "tsv")
|
||||
.url(forResource: "english_lexicon", withExtension: "bin")
|
||||
?? Bundle.main.url(forResource: "english_lexicon", withExtension: "bin")
|
||||
else {
|
||||
return
|
||||
}
|
||||
guard let data = try? String(contentsOf: url, encoding: .utf8) else { return }
|
||||
var map: [String: Int] = [:]
|
||||
for line in data.split(whereSeparator: \.isNewline) {
|
||||
let parts = line.split(separator: "\t", maxSplits: 1)
|
||||
guard parts.count == 2,
|
||||
let freq = Int(parts[1]) else { continue }
|
||||
let word = String(parts[0]).lowercased()
|
||||
guard !word.isEmpty else { continue }
|
||||
map[word] = freq
|
||||
}
|
||||
frequencies = map
|
||||
sortedWords = map.keys.sorted()
|
||||
}
|
||||
|
||||
private func loadBigrams() {
|
||||
guard let url = Bundle(for: EnglishLexicon.self)
|
||||
.url(forResource: "english_bigrams", withExtension: "tsv")
|
||||
?? Bundle.main.url(forResource: "english_bigrams", withExtension: "tsv")
|
||||
// `.mappedIfSafe` keeps the 40k table on file-backed pages. Jetsam
|
||||
// charges dirty heap, not these clean mapped pages.
|
||||
guard let data = try? Data(contentsOf: url, options: [.mappedIfSafe]),
|
||||
let parsed = FileHeader.parse(data)
|
||||
else {
|
||||
return
|
||||
}
|
||||
guard let data = try? String(contentsOf: url, encoding: .utf8) else { return }
|
||||
var map: [String: [String]] = [:]
|
||||
for line in data.split(whereSeparator: \.isNewline) {
|
||||
let parts = line.split(separator: "\t", maxSplits: 1)
|
||||
guard parts.count == 2 else { continue }
|
||||
let prev = String(parts[0]).lowercased()
|
||||
let nexts = parts[1].split(whereSeparator: \.isWhitespace).map { String($0).lowercased() }
|
||||
guard !prev.isEmpty, !nexts.isEmpty else { continue }
|
||||
map[prev] = nexts
|
||||
}
|
||||
bigrams = map
|
||||
mapped = data
|
||||
header = parsed
|
||||
loaded = true
|
||||
}
|
||||
|
||||
private func lowerBound(_ prefix: String) -> Int {
|
||||
private func withMap<T>(_ body: (UnsafeRawBufferPointer, FileHeader) -> T) -> T? {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
guard loaded, let data = mapped, let header else { return nil }
|
||||
return data.withUnsafeBytes { buf in
|
||||
body(buf, header)
|
||||
}
|
||||
}
|
||||
|
||||
private func lookupIndex(
|
||||
_ needle: [UInt8],
|
||||
header: FileHeader,
|
||||
buf: UnsafeRawBufferPointer
|
||||
) -> Int? {
|
||||
let index = lowerBound(needle, header: header, buf: buf)
|
||||
guard index < header.unigramCount,
|
||||
let bytes = wordBytes(at: index, header: header, buf: buf),
|
||||
bytesEqual(bytes, needle)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
private func lowerBound(
|
||||
_ needle: [UInt8],
|
||||
header: FileHeader,
|
||||
buf: UnsafeRawBufferPointer
|
||||
) -> Int {
|
||||
var low = 0
|
||||
var high = sortedWords.count
|
||||
var high = header.unigramCount
|
||||
while low < high {
|
||||
let mid = (low + high) / 2
|
||||
if sortedWords[mid] < prefix {
|
||||
guard let bytes = wordBytes(at: mid, header: header, buf: buf) else {
|
||||
high = mid
|
||||
continue
|
||||
}
|
||||
if compare(bytes, needle) < 0 {
|
||||
low = mid + 1
|
||||
} else {
|
||||
high = mid
|
||||
@@ -184,40 +363,140 @@ public final class EnglishLexicon: @unchecked Sendable {
|
||||
return low
|
||||
}
|
||||
|
||||
/// Damerau–Levenshtein with early exit when distance would exceed `max`.
|
||||
private func damerauLevenshtein(_ a: String, _ b: String, max: Int) -> Int {
|
||||
let aChars = Array(a)
|
||||
let bChars = Array(b)
|
||||
let aCount = aChars.count
|
||||
let bCount = bChars.count
|
||||
if abs(aCount - bCount) > max { return max + 1 }
|
||||
|
||||
var prevPrev = [Int](repeating: 0, count: bCount + 1)
|
||||
var prev = Array(0...bCount)
|
||||
for i in 1...aCount {
|
||||
var current = [Int](repeating: 0, count: bCount + 1)
|
||||
current[0] = i
|
||||
var rowMin = current[0]
|
||||
for j in 1...bCount {
|
||||
let cost = aChars[i - 1] == bChars[j - 1] ? 0 : 1
|
||||
var value = min(
|
||||
prev[j] + 1,
|
||||
current[j - 1] + 1,
|
||||
prev[j - 1] + cost
|
||||
)
|
||||
// Adjacent transposition
|
||||
if i > 1, j > 1,
|
||||
aChars[i - 1] == bChars[j - 2],
|
||||
aChars[i - 2] == bChars[j - 1] {
|
||||
value = min(value, prevPrev[j - 2] + 1)
|
||||
}
|
||||
current[j] = value
|
||||
rowMin = min(rowMin, value)
|
||||
private func lookupBigramGroup(
|
||||
prevIndex: Int,
|
||||
header: FileHeader,
|
||||
buf: UnsafeRawBufferPointer
|
||||
) -> BigramGroup? {
|
||||
var low = 0
|
||||
var high = header.bigramGroupCount
|
||||
while low < high {
|
||||
let mid = (low + high) / 2
|
||||
let midPrev = Int(readU16(buf, header.bigramIndexOffset + mid * 8))
|
||||
if midPrev < prevIndex {
|
||||
low = mid + 1
|
||||
} else {
|
||||
high = mid
|
||||
}
|
||||
if rowMin > max { return max + 1 }
|
||||
prevPrev = prev
|
||||
prev = current
|
||||
}
|
||||
return prev[bCount]
|
||||
guard low < header.bigramGroupCount else { return nil }
|
||||
let offset = header.bigramIndexOffset + low * 8
|
||||
guard Int(readU16(buf, offset)) == prevIndex else { return nil }
|
||||
return BigramGroup(
|
||||
nextCount: Int(readU16(buf, offset + 2)),
|
||||
firstNext: Int(readU32(buf, offset + 4))
|
||||
)
|
||||
}
|
||||
|
||||
private func frequency(at index: Int, header: FileHeader, buf: UnsafeRawBufferPointer) -> Int {
|
||||
Int(readU16(buf, header.unigramOffset + index * 8 + 6))
|
||||
}
|
||||
|
||||
private func wordBytes(
|
||||
at index: Int,
|
||||
header: FileHeader,
|
||||
buf: UnsafeRawBufferPointer
|
||||
) -> UnsafeBufferPointer<UInt8>? {
|
||||
guard index >= 0, index < header.unigramCount else { return nil }
|
||||
let record = header.unigramOffset + index * 8
|
||||
let poolOff = Int(readU32(buf, record))
|
||||
let length = Int(buf[record + 4])
|
||||
let start = header.stringPoolOffset + poolOff
|
||||
guard length >= 0,
|
||||
start >= header.stringPoolOffset,
|
||||
start + length <= header.stringPoolOffset + header.stringPoolSize,
|
||||
start + length <= header.fileSize,
|
||||
let base = buf.baseAddress
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return UnsafeBufferPointer(
|
||||
start: base.advanced(by: start).assumingMemoryBound(to: UInt8.self),
|
||||
count: length
|
||||
)
|
||||
}
|
||||
|
||||
private func string(
|
||||
at index: Int,
|
||||
header: FileHeader,
|
||||
buf: UnsafeRawBufferPointer
|
||||
) -> String? {
|
||||
guard let bytes = wordBytes(at: index, header: header, buf: buf) else { return nil }
|
||||
return String(bytes: bytes, encoding: .ascii)
|
||||
}
|
||||
|
||||
private func insertBest(_ scored: ScoredIndex, into best: inout [ScoredIndex], limit: Int) {
|
||||
if let existing = best.firstIndex(where: { $0.index == scored.index }) {
|
||||
if isOrderedBefore(scored, best[existing]) {
|
||||
best[existing] = scored
|
||||
best.sort(by: isOrderedBefore)
|
||||
}
|
||||
return
|
||||
}
|
||||
if best.count < limit {
|
||||
best.append(scored)
|
||||
best.sort(by: isOrderedBefore)
|
||||
return
|
||||
}
|
||||
if let last = best.last, isOrderedBefore(scored, last) {
|
||||
best[best.count - 1] = scored
|
||||
best.sort(by: isOrderedBefore)
|
||||
}
|
||||
}
|
||||
|
||||
private func isOrderedBefore(_ lhs: ScoredIndex, _ rhs: ScoredIndex) -> Bool {
|
||||
if lhs.spatialCost != rhs.spatialCost { return lhs.spatialCost < rhs.spatialCost }
|
||||
if lhs.frequency != rhs.frequency { return lhs.frequency > rhs.frequency }
|
||||
return lhs.index < rhs.index
|
||||
}
|
||||
}
|
||||
|
||||
private func readU16(_ buf: UnsafeRawBufferPointer, _ offset: Int) -> UInt16 {
|
||||
UInt16(littleEndian: buf.loadUnaligned(fromByteOffset: offset, as: UInt16.self))
|
||||
}
|
||||
|
||||
private func readU32(_ buf: UnsafeRawBufferPointer, _ offset: Int) -> UInt32 {
|
||||
UInt32(littleEndian: buf.loadUnaligned(fromByteOffset: offset, as: UInt32.self))
|
||||
}
|
||||
|
||||
private func asciiLowered(_ string: String) -> [UInt8] {
|
||||
string.utf8.map { byte in
|
||||
(byte >= 65 && byte <= 90) ? byte + 32 : byte
|
||||
}
|
||||
}
|
||||
|
||||
private func compare(_ word: UnsafeBufferPointer<UInt8>, _ needle: [UInt8]) -> Int {
|
||||
let count = min(word.count, needle.count)
|
||||
for index in 0..<count {
|
||||
let left = word[index]
|
||||
let right = needle[index]
|
||||
if left < right { return -1 }
|
||||
if left > right { return 1 }
|
||||
}
|
||||
if word.count < needle.count { return -1 }
|
||||
if word.count > needle.count { return 1 }
|
||||
return 0
|
||||
}
|
||||
|
||||
private func hasPrefix(_ word: UnsafeBufferPointer<UInt8>, _ prefix: [UInt8]) -> Bool {
|
||||
guard word.count >= prefix.count else { return false }
|
||||
for index in prefix.indices where word[index] != prefix[index] {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private func bytesEqual(_ word: UnsafeBufferPointer<UInt8>, _ needle: [UInt8]) -> Bool {
|
||||
guard word.count == needle.count else { return false }
|
||||
for index in needle.indices where word[index] != needle[index] {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private extension Character {
|
||||
var asciiLetterIndex: Int? {
|
||||
guard let value = utf8.first, value >= 97, value <= 122 else { return nil }
|
||||
return Int(value - 97)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
// EnglishQWERTYProximity.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Spatial cost for English autocorrect. Adjacent (including diagonal) keys
|
||||
// are cheap; far substitutions are expensive. Inspired by AOSP LatinIME's
|
||||
// proximity weighting — formula only, no Android code.
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct EnglishAlignment: Equatable, Sendable {
|
||||
/// Weighted edit cost. `0` means identical.
|
||||
public var cost: Int
|
||||
public var isTransposition: Bool
|
||||
public var isShortening: Bool
|
||||
}
|
||||
|
||||
public enum EnglishQWERTYProximity: Sendable {
|
||||
/// Two adjacent substitutions, or one farther miss, still eligible.
|
||||
public static let maxAutocorrectCost = 34
|
||||
public static let adjacentCost = 10
|
||||
public static let nearCost = 22
|
||||
public static let farCost = 34
|
||||
public static let insDelCost = 18
|
||||
public static let transpositionCost = 10
|
||||
|
||||
/// US QWERTY, staggered rows matching the on-screen letter grid.
|
||||
private static let coordinates: [Character: (x: Double, y: Double)] = {
|
||||
let rows: [[Character]] = [
|
||||
Array("qwertyuiop"),
|
||||
Array("asdfghjkl"),
|
||||
Array("zxcvbnm")
|
||||
]
|
||||
let offsets: [Double] = [0, 0.5, 1.5]
|
||||
var map: [Character: (x: Double, y: Double)] = [:]
|
||||
for (rowIndex, row) in rows.enumerated() {
|
||||
let origin = offsets[rowIndex]
|
||||
for (column, letter) in row.enumerated() {
|
||||
map[letter] = (origin + Double(column), Double(rowIndex))
|
||||
}
|
||||
}
|
||||
return map
|
||||
}()
|
||||
|
||||
public static func neighbors(of letter: Character, includingSelf: Bool) -> [Character] {
|
||||
let needle = Character(letter.lowercased())
|
||||
guard let origin = coordinates[needle] else {
|
||||
return includingSelf ? [needle] : []
|
||||
}
|
||||
var hits: [Character] = []
|
||||
for (candidate, point) in coordinates {
|
||||
let distance = chebyshev(origin, point)
|
||||
if distance == 0 {
|
||||
if includingSelf { hits.append(candidate) }
|
||||
} else if distance <= 1.01 {
|
||||
hits.append(candidate)
|
||||
}
|
||||
}
|
||||
return hits
|
||||
}
|
||||
|
||||
public static func keyDistance(_ a: Character, _ b: Character) -> Int {
|
||||
let left = Character(a.lowercased())
|
||||
let right = Character(b.lowercased())
|
||||
if left == right { return 0 }
|
||||
guard let origin = coordinates[left], let other = coordinates[right] else {
|
||||
return farCost
|
||||
}
|
||||
let distance = chebyshev(origin, other)
|
||||
if distance <= 1.01 { return adjacentCost }
|
||||
if distance <= 2.01 { return nearCost }
|
||||
return farCost
|
||||
}
|
||||
|
||||
public static func align(typed: String, candidate: String) -> EnglishAlignment? {
|
||||
let source = asciiLowered(typed)
|
||||
let targetBytes = asciiLowered(candidate)
|
||||
return targetBytes.withUnsafeBufferPointer { pointer in
|
||||
align(typedASCII: source, candidateASCII: pointer)
|
||||
}
|
||||
}
|
||||
|
||||
/// Same cost model as `align(typed:candidate:)`, but the candidate stays in
|
||||
/// a mapped file — no Swift `String` per scanned word.
|
||||
public static func align(
|
||||
typedASCII: [UInt8],
|
||||
candidateASCII: UnsafeBufferPointer<UInt8>
|
||||
) -> EnglishAlignment? {
|
||||
let source = typedASCII
|
||||
let target = candidateASCII
|
||||
let delta = abs(source.count - target.count)
|
||||
guard delta <= 2 else { return nil }
|
||||
if delta == 0, bytesEqual(source, target) {
|
||||
return EnglishAlignment(cost: 0, isTransposition: false, isShortening: false)
|
||||
}
|
||||
|
||||
if source.count == target.count, isAdjacentTransposition(source, target) {
|
||||
return EnglishAlignment(
|
||||
cost: transpositionCost,
|
||||
isTransposition: true,
|
||||
isShortening: false
|
||||
)
|
||||
}
|
||||
|
||||
if source.count == target.count {
|
||||
var cost = 0
|
||||
for index in source.indices {
|
||||
cost += keyDistance(source[index], target[index])
|
||||
if cost > maxAutocorrectCost { return nil }
|
||||
}
|
||||
return EnglishAlignment(
|
||||
cost: cost,
|
||||
isTransposition: false,
|
||||
isShortening: false
|
||||
)
|
||||
}
|
||||
|
||||
let cost = bandedEditCost(source, target)
|
||||
guard cost <= maxAutocorrectCost else { return nil }
|
||||
return EnglishAlignment(
|
||||
cost: cost,
|
||||
isTransposition: false,
|
||||
isShortening: target.count < source.count
|
||||
)
|
||||
}
|
||||
|
||||
private static func keyDistance(_ a: UInt8, _ b: UInt8) -> Int {
|
||||
if a == b { return 0 }
|
||||
guard a >= 97, a <= 122, b >= 97, b <= 122 else { return farCost }
|
||||
return keyDistance(Character(UnicodeScalar(a)), Character(UnicodeScalar(b)))
|
||||
}
|
||||
|
||||
private static func isAdjacentTransposition(
|
||||
_ source: [UInt8],
|
||||
_ target: UnsafeBufferPointer<UInt8>
|
||||
) -> Bool {
|
||||
guard source.count == target.count, source.count >= 2 else { return false }
|
||||
var mismatch = -1
|
||||
for index in source.indices where source[index] != target[index] {
|
||||
if mismatch == -1 {
|
||||
mismatch = index
|
||||
} else if index == mismatch + 1,
|
||||
source[mismatch] == target[index],
|
||||
source[index] == target[mismatch] {
|
||||
for rest in (index + 1)..<source.count where source[rest] != target[rest] {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/// Banded Levenshtein with proximity substitutions and a Damerau swap.
|
||||
private static func bandedEditCost(
|
||||
_ source: [UInt8],
|
||||
_ target: UnsafeBufferPointer<UInt8>
|
||||
) -> Int {
|
||||
let aCount = source.count
|
||||
let bCount = target.count
|
||||
var previous = Array(0...bCount).map { $0 * insDelCost }
|
||||
var older = previous
|
||||
for i in 1...aCount {
|
||||
var current = [Int](repeating: 0, count: bCount + 1)
|
||||
current[0] = i * insDelCost
|
||||
var rowMin = current[0]
|
||||
for j in 1...bCount {
|
||||
let substitution = previous[j - 1] + keyDistance(source[i - 1], target[j - 1])
|
||||
var value = min(
|
||||
previous[j] + insDelCost,
|
||||
current[j - 1] + insDelCost,
|
||||
substitution
|
||||
)
|
||||
if i > 1, j > 1,
|
||||
source[i - 1] == target[j - 2],
|
||||
source[i - 2] == target[j - 1] {
|
||||
value = min(value, older[j - 2] + transpositionCost)
|
||||
}
|
||||
current[j] = value
|
||||
rowMin = min(rowMin, value)
|
||||
}
|
||||
if rowMin > maxAutocorrectCost { return maxAutocorrectCost + 1 }
|
||||
older = previous
|
||||
previous = current
|
||||
}
|
||||
return previous[bCount]
|
||||
}
|
||||
|
||||
private static func asciiLowered(_ string: String) -> [UInt8] {
|
||||
string.utf8.map { byte in
|
||||
(byte >= 65 && byte <= 90) ? byte + 32 : byte
|
||||
}
|
||||
}
|
||||
|
||||
private static func bytesEqual(_ source: [UInt8], _ target: UnsafeBufferPointer<UInt8>) -> Bool {
|
||||
guard source.count == target.count else { return false }
|
||||
for index in source.indices where source[index] != target[index] {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private static func chebyshev(
|
||||
_ a: (x: Double, y: Double),
|
||||
_ b: (x: Double, y: Double)
|
||||
) -> Double {
|
||||
max(abs(a.x - b.x), abs(a.y - b.y))
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
// EnglishSuggestionEngine.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Builds TypingComposition for English: completions while composing,
|
||||
// high-confidence corrections on commit, next-word predictions after.
|
||||
// Builds a 3-slot English QuickType board: verbatim / correction / completion
|
||||
// (or next-word after commit). Space applies only the correction slot.
|
||||
|
||||
import Foundation
|
||||
|
||||
@@ -12,19 +12,29 @@ public struct EnglishSuggestionContext: Sendable {
|
||||
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
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +57,10 @@ public struct EnglishCorrectionDecision: Equatable, Sendable {
|
||||
|
||||
/// 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
|
||||
|
||||
private let lexicon: EnglishLexicon
|
||||
|
||||
public init(lexicon: EnglishLexicon = .shared) {
|
||||
@@ -57,112 +71,238 @@ public struct EnglishSuggestionEngine: Sendable {
|
||||
lexicon.prepare()
|
||||
}
|
||||
|
||||
/// Suggestions while the user is mid-word.
|
||||
/// 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 nextWordComposition(context)
|
||||
}
|
||||
|
||||
var ranked: [(text: String, score: Int, id: String)] = []
|
||||
var seen = Set<String>()
|
||||
|
||||
func append(_ raw: String, baseScore: Int, tag: String, preserveCase: Bool = false) {
|
||||
let display = preserveCase ? raw : matchCase(of: prefix, to: raw)
|
||||
let key = display.lowercased()
|
||||
guard seen.insert(key).inserted else { return }
|
||||
let boost = context.learnedBoosts[key] ?? 0
|
||||
let personalBoost = context.personalTerms.contains { $0.lowercased() == key } ? 5_000 : 0
|
||||
ranked.append((display, baseScore + boost + personalBoost, "\(tag)|\(key)"))
|
||||
}
|
||||
|
||||
for term in context.personalTerms where term.lowercased().hasPrefix(prefix.lowercased())
|
||||
&& term.lowercased() != prefix.lowercased() {
|
||||
append(term, baseScore: 8_000 + term.count, tag: "personal", preserveCase: true)
|
||||
}
|
||||
|
||||
for word in lexicon.completions(prefix: prefix, limit: 12) {
|
||||
append(word, baseScore: lexicon.frequency(of: word), tag: "complete")
|
||||
}
|
||||
|
||||
ranked.sort { lhs, rhs in
|
||||
if lhs.score != rhs.score { return lhs.score > rhs.score }
|
||||
return lhs.text.count < rhs.text.count
|
||||
}
|
||||
|
||||
let candidates = ranked.prefix(8).map {
|
||||
TypingCandidate(id: $0.id, text: $0.text, engineIndex: 0)
|
||||
}
|
||||
return TypingComposition(preedit: prefix, candidates: Array(candidates))
|
||||
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]
|
||||
learnedBoosts: [String: Int],
|
||||
previousWord: String = "",
|
||||
systemWords: [String] = [],
|
||||
systemGuesses: [String] = []
|
||||
) -> EnglishCorrectionDecision? {
|
||||
let trimmed = typed
|
||||
guard trimmed.count >= 2 else { return nil }
|
||||
let lower = trimmed.lowercased()
|
||||
|
||||
if personalTerms.contains(where: { $0.lowercased() == lower }) { return nil }
|
||||
if (learnedBoosts[lower] ?? 0) >= 5 { return nil }
|
||||
if shouldSkipAutocorrect(trimmed) { return nil }
|
||||
if lexicon.contains(lower) { return nil }
|
||||
|
||||
guard let correction = lexicon.bestCorrection(for: lower) else { return nil }
|
||||
// Personal dictionary wins over lexicon corrections.
|
||||
if personalTerms.contains(where: { $0.lowercased() == correction }) {
|
||||
return EnglishCorrectionDecision(original: trimmed, replacement: matchCase(of: trimmed, to: correction))
|
||||
}
|
||||
let typedBoost = learnedBoosts[lower] ?? 0
|
||||
let correctionFreq = lexicon.frequency(of: correction) + (learnedBoosts[correction] ?? 0)
|
||||
// High-confidence gate: correction must clearly beat defending the typo.
|
||||
guard correctionFreq >= 80, correctionFreq > typedBoost + 40 else { return nil }
|
||||
return EnglishCorrectionDecision(
|
||||
original: trimmed,
|
||||
replacement: matchCase(of: trimmed, to: correction)
|
||||
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, id: String)] = []
|
||||
var ranked: [(text: String, score: Int, role: TypingCandidateRole, quoted: Bool)] = []
|
||||
var seen = Set<String>()
|
||||
|
||||
func append(_ raw: String, baseScore: Int, tag: 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 = context.personalTerms.contains { $0.lowercased() == key } ? 2_000 : 0
|
||||
ranked.append((raw, baseScore + boost + personalBoost, "\(tag)|\(key)"))
|
||||
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, tag: "original")
|
||||
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_000 - index * 10, tag: "next")
|
||||
append(word, baseScore: 1_200 - index * 10, role: .nextWord)
|
||||
}
|
||||
}
|
||||
|
||||
for term in context.personalTerms.prefix(4) {
|
||||
append(term, baseScore: 500, tag: "personal")
|
||||
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(8).map {
|
||||
TypingCandidate(id: $0.id, text: $0.text, engineIndex: 0)
|
||||
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))
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
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
|
||||
}
|
||||
|
||||
private func shouldSkipAutocorrect(_ typed: String) -> Bool {
|
||||
if typed.count <= 1 { return true }
|
||||
// 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.contains(where: \.isNumber) { return true }
|
||||
if typed.contains("@") || typed.contains(".") || typed.contains("/") { return true }
|
||||
@@ -170,6 +310,16 @@ public struct EnglishSuggestionEngine: Sendable {
|
||||
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()
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// EnglishSystemLexicon.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Apple's sanctioned English sources for a custom keyboard: UITextChecker
|
||||
// completions / guesses, plus UILexicon names from
|
||||
// `requestSupplementaryLexicon`. The engine stays pure; the keyboard
|
||||
// extension fills these fields on each refresh.
|
||||
|
||||
import Foundation
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
@MainActor
|
||||
public protocol EnglishSystemLexiconProviding: AnyObject {
|
||||
func completions(prefix: String, limit: Int) -> [String]
|
||||
func guesses(for word: String, limit: Int) -> [String]
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public final class EmptyEnglishSystemLexicon: EnglishSystemLexiconProviding {
|
||||
public init() {}
|
||||
|
||||
public func completions(prefix: String, limit: Int) -> [String] {
|
||||
[]
|
||||
}
|
||||
|
||||
public func guesses(for word: String, limit: Int) -> [String] {
|
||||
[]
|
||||
}
|
||||
}
|
||||
|
||||
#if canImport(UIKit)
|
||||
/// System spellchecker. Always called from `TypingSessionController` (@MainActor).
|
||||
@MainActor
|
||||
public final class UIKitEnglishSystemLexicon: EnglishSystemLexiconProviding {
|
||||
public var language: String
|
||||
|
||||
public init(language: String = "en_US") {
|
||||
self.language = language
|
||||
}
|
||||
|
||||
public func completions(prefix: String, limit: Int) -> [String] {
|
||||
guard !prefix.isEmpty, limit > 0 else { return [] }
|
||||
let checker = UITextChecker()
|
||||
let range = NSRange(location: 0, length: (prefix as NSString).length)
|
||||
let hits = checker.completions(forPartialWordRange: range, in: prefix, language: language) ?? []
|
||||
return Array(hits.prefix(limit))
|
||||
}
|
||||
|
||||
public func guesses(for word: String, limit: Int) -> [String] {
|
||||
guard word.count >= 3, limit > 0 else { return [] }
|
||||
let checker = UITextChecker()
|
||||
let range = NSRange(location: 0, length: (word as NSString).length)
|
||||
let hits = checker.guesses(forWordRange: range, in: word, language: language) ?? []
|
||||
return Array(hits.prefix(limit))
|
||||
}
|
||||
|
||||
public static func learnWord(_ word: String) {
|
||||
let trimmed = word.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
if !UITextChecker.hasLearnedWord(trimmed) {
|
||||
UITextChecker.learnWord(trimmed)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -21,6 +21,18 @@ public enum TypingInputLanguage: String, CaseIterable, Identifiable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Role of an English QuickType slot. Chinese candidates stay `.completion`.
|
||||
public enum TypingCandidateRole: String, Equatable, Sendable {
|
||||
/// The word currently being typed. Space does not replace it.
|
||||
case verbatim
|
||||
/// The unique slot Space will apply when autocorrect is armed.
|
||||
case correction
|
||||
/// Prefix completion; tap to accept, Space ignores it.
|
||||
case completion
|
||||
/// Next-word prediction after a committed word; tap to insert.
|
||||
case nextWord
|
||||
}
|
||||
|
||||
/// One candidate row item after composing.
|
||||
public struct TypingCandidate: Identifiable, Equatable, Sendable {
|
||||
public let id: String
|
||||
@@ -28,17 +40,24 @@ public struct TypingCandidate: Identifiable, Equatable, Sendable {
|
||||
public let annotation: String?
|
||||
/// Absolute engine index for Chinese selection (may differ from display order).
|
||||
public let engineIndex: Int
|
||||
public let role: TypingCandidateRole
|
||||
/// Unknown verbatim shown in quotes, matching the system / KeyboardKit contract.
|
||||
public let isQuoted: Bool
|
||||
|
||||
public init(
|
||||
id: String = UUID().uuidString,
|
||||
text: String,
|
||||
annotation: String? = nil,
|
||||
engineIndex: Int = 0
|
||||
engineIndex: Int = 0,
|
||||
role: TypingCandidateRole = .completion,
|
||||
isQuoted: Bool = false
|
||||
) {
|
||||
self.id = id
|
||||
self.text = text
|
||||
self.annotation = annotation
|
||||
self.engineIndex = engineIndex
|
||||
self.role = role
|
||||
self.isQuoted = isQuoted
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,10 @@ public final class TypingSessionController: ObservableObject {
|
||||
|
||||
/// When true, English suggestions / autocorrect stay off (secure fields).
|
||||
@Published public var suggestionsEnabled: Bool = true
|
||||
/// `UITextChecker` completions / guesses. Empty in unit tests.
|
||||
public var systemLexicon: EnglishSystemLexiconProviding = EmptyEnglishSystemLexicon()
|
||||
/// Names and text replacements from `requestSupplementaryLexicon`.
|
||||
public var supplementaryWords: [String] = []
|
||||
|
||||
/// Chevron appears only for Chinese composition with at least two candidates.
|
||||
public var canExpandCandidatePanel: Bool {
|
||||
@@ -128,9 +132,15 @@ public final class TypingSessionController: ObservableObject {
|
||||
)
|
||||
TypingInputConfiguration.shared.reload()
|
||||
refreshPersonalTerms()
|
||||
// English lexicon is small; load when entering typing (not at KVC init).
|
||||
englishEngine.prepare()
|
||||
OSGDiag.log("typing.enter after englishPrepare \(OSGDiag.memoryTag())", category: "boot")
|
||||
// mmap the English table only while English is active. Chinese typing
|
||||
// already has Rime; loading both on appear is what jetsams the extension.
|
||||
if language == .english {
|
||||
englishEngine.prepare()
|
||||
OSGDiag.log("typing.enter after englishPrepare \(OSGDiag.memoryTag())", category: "boot")
|
||||
} else {
|
||||
EnglishLexicon.shared.unload()
|
||||
OSGDiag.log("typing.enter skip englishPrepare lang=\(language.rawValue) \(OSGDiag.memoryTag())", category: "boot")
|
||||
}
|
||||
syncAutocapitalization()
|
||||
if FlowSessionBridge.isHostHeavy() {
|
||||
OSGDiag.log("typing.enter defer rime hostHeavy=1 — retry scheduled", category: "boot")
|
||||
@@ -216,6 +226,7 @@ public final class TypingSessionController: ObservableObject {
|
||||
synchronizeEnglishDocumentContext(caretMoved: true)
|
||||
} else {
|
||||
clearEnglishWordState(keepPrevious: false)
|
||||
EnglishLexicon.shared.unload()
|
||||
composition = engine.composition
|
||||
}
|
||||
return output
|
||||
@@ -427,6 +438,9 @@ public final class TypingSessionController: ObservableObject {
|
||||
pendingAutocorrection = nil
|
||||
englishCurrentWord = pending.original
|
||||
learningStore.recordDefense(of: pending.original)
|
||||
#if canImport(UIKit)
|
||||
UIKitEnglishSystemLexicon.learnWord(pending.original)
|
||||
#endif
|
||||
refreshEnglishSuggestions()
|
||||
return .replace(deleteCount: deleteCount, with: pending.original)
|
||||
}
|
||||
@@ -461,15 +475,18 @@ public final class TypingSessionController: ObservableObject {
|
||||
var decision = englishEngine.correctionDecision(
|
||||
for: word,
|
||||
personalTerms: personalTermsCache,
|
||||
learnedBoosts: learningStore.snapshot()
|
||||
learnedBoosts: learningStore.snapshot(),
|
||||
previousWord: englishPreviousWord,
|
||||
systemWords: supplementaryWords,
|
||||
systemGuesses: systemLexicon.guesses(for: word, limit: 6)
|
||||
) {
|
||||
decision.appliedSuffix = suffix
|
||||
pendingAutocorrection = decision
|
||||
englishPreviousWord = decision.replacement
|
||||
englishCurrentWord = ""
|
||||
learningStore.recordAcceptance(of: decision.replacement)
|
||||
// Suggestions stay hidden until the user starts the next word.
|
||||
composition = .empty
|
||||
// Machine-applied correction does not count as the user accepting
|
||||
// the replacement — otherwise names train the wrong word.
|
||||
refreshEnglishSuggestions(afterCommittedWord: decision.replacement)
|
||||
return .replace(
|
||||
deleteCount: word.count,
|
||||
with: decision.replacement + suffix
|
||||
@@ -479,7 +496,17 @@ public final class TypingSessionController: ObservableObject {
|
||||
englishPreviousWord = word
|
||||
englishCurrentWord = ""
|
||||
pendingAutocorrection = nil
|
||||
learningStore.recordAcceptance(of: word, amount: 1)
|
||||
// Learn OOV / names the user actually committed; skip common words.
|
||||
if !englishEngine.isKnownWord(
|
||||
word,
|
||||
personalTerms: personalTermsCache,
|
||||
systemWords: supplementaryWords
|
||||
) {
|
||||
learningStore.recordDefense(of: word, amount: 2)
|
||||
#if canImport(UIKit)
|
||||
UIKitEnglishSystemLexicon.learnWord(word)
|
||||
#endif
|
||||
}
|
||||
refreshEnglishSuggestions(afterCommittedWord: word)
|
||||
return suffix.isEmpty ? .none : .insert(suffix)
|
||||
}
|
||||
@@ -487,7 +514,8 @@ public final class TypingSessionController: ObservableObject {
|
||||
private func selectEnglishCandidate(at index: Int) -> TypingOutput {
|
||||
guard composition.candidates.indices.contains(index) else { return .none }
|
||||
guard englishCandidateAnchorMatchesDocument() else { return .none }
|
||||
let chosen = composition.candidates[index].text
|
||||
let candidate = composition.candidates[index]
|
||||
let chosen = candidate.text
|
||||
|
||||
// Restoring original after autocorrect (no current word).
|
||||
if englishCurrentWord.isEmpty,
|
||||
@@ -498,16 +526,27 @@ public final class TypingSessionController: ObservableObject {
|
||||
englishPreviousWord = pending.original
|
||||
englishCurrentWord = ""
|
||||
learningStore.recordDefense(of: pending.original)
|
||||
#if canImport(UIKit)
|
||||
UIKitEnglishSystemLexicon.learnWord(pending.original)
|
||||
#endif
|
||||
refreshEnglishSuggestions(afterCommittedWord: pending.original)
|
||||
return .replace(deleteCount: deleteCount, with: pending.original + " ")
|
||||
}
|
||||
|
||||
if candidate.role == .verbatim {
|
||||
learningStore.recordDefense(of: chosen)
|
||||
#if canImport(UIKit)
|
||||
UIKitEnglishSystemLexicon.learnWord(chosen)
|
||||
#endif
|
||||
} else {
|
||||
learningStore.recordAcceptance(of: chosen)
|
||||
}
|
||||
|
||||
if !englishCurrentWord.isEmpty {
|
||||
let deleteCount = englishCurrentWord.count
|
||||
englishPreviousWord = chosen
|
||||
englishCurrentWord = ""
|
||||
pendingAutocorrection = nil
|
||||
learningStore.recordAcceptance(of: chosen)
|
||||
refreshEnglishSuggestions(afterCommittedWord: chosen)
|
||||
return .replace(deleteCount: deleteCount, with: chosen + " ")
|
||||
}
|
||||
@@ -516,7 +555,6 @@ public final class TypingSessionController: ObservableObject {
|
||||
englishPreviousWord = chosen
|
||||
englishCurrentWord = ""
|
||||
pendingAutocorrection = nil
|
||||
learningStore.recordAcceptance(of: chosen)
|
||||
refreshEnglishSuggestions(afterCommittedWord: chosen)
|
||||
return .insert(chosen + " ")
|
||||
}
|
||||
@@ -537,19 +575,23 @@ public final class TypingSessionController: ObservableObject {
|
||||
composition = .empty
|
||||
return
|
||||
}
|
||||
// Idle / between words: no candidate bar. Completions start after
|
||||
// the first letter of the current word.
|
||||
// With no active English word, keep the candidate bar empty. This also
|
||||
// prevents next-word predictions from appearing between committed words.
|
||||
guard !englishCurrentWord.isEmpty else {
|
||||
composition = .empty
|
||||
return
|
||||
}
|
||||
let previous = word ?? englishPreviousWord
|
||||
let typed = englishCurrentWord
|
||||
let context = EnglishSuggestionContext(
|
||||
currentWord: englishCurrentWord,
|
||||
currentWord: typed,
|
||||
previousWord: previous,
|
||||
personalTerms: personalTermsCache,
|
||||
learnedBoosts: learningStore.snapshot(),
|
||||
includeOriginalAfterCorrection: nil
|
||||
includeOriginalAfterCorrection: pendingAutocorrection?.original,
|
||||
systemWords: supplementaryWords,
|
||||
systemCompletions: typed.isEmpty ? [] : systemLexicon.completions(prefix: typed, limit: 6),
|
||||
systemGuesses: typed.count >= 3 ? systemLexicon.guesses(for: typed, limit: 6) : []
|
||||
)
|
||||
composition = englishEngine.compositionWhileTyping(context)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user