Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions KeyType/Logic/Completion/CompletionController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1551,6 +1551,10 @@ final class CompletionController {

static func correctionSuffixWindow(from suffix: String) -> String {
let trimmed = suffix.prefix(80)
// A whitespace-only window (the usual case right after typing "word ") carries no join
// signal — probing P(bare space | prefix + replacement) is pure tokenization noise that
// vetoes valid corrections (ADR-120).
guard trimmed.contains(where: { !$0.isWhitespace }) else { return "" }
if let firstNonWhitespace = trimmed.first(where: { !$0.isWhitespace }),
!firstNonWhitespace.isLetter,
!firstNonWhitespace.isNumber {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,15 @@ struct SystemSpellcheckCorrectionDetector: CorrectionDetecting {
case let (.success(closed), _):
target = closed
case let (.failure, .success(current)):
// An open fragment that can still extend into a dictionary word is typing in
// progress (shou → should), not a typo: leave it to the completion lane (ADR-113)
// and only correct dead-end fragments. Without this gate the recalibrated validator
// (ADR-120) happily proves "show" more probable than "shou" mid-word and badge-spams
// while the user types. Mirrors the completion typo guard's closed-word-only rule.
guard !SystemWordRecognizer().canCompleteWord(
prefix: current.original,
language: context.detectedLanguage
) else { return nil }
target = current
case (.failure, .failure):
return nil
Expand Down
36 changes: 24 additions & 12 deletions Packages/CompletionUI/Sources/CompletionUI/CompletionUI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -404,23 +404,35 @@ public struct CorrectionBadgeView: View {
}

public var body: some View {
// Strike only the letters that are wrong and embolden only the letters that change
// (ADR-119): defin̶a̶tely → definitely. A pure insertion has an empty original core and
// nothing to strike; the arrow still carries the meaning.
let diff = CorrectionLetterDiff.between(original, replacement)
let badgeFont = Font(font as CTFont)
HStack(spacing: 6) {
Text(original)
.font(Font(font as CTFont))
.foregroundStyle(Color(nsColor: .systemRed))
.strikethrough(true, color: Color(nsColor: .systemRed))
.lineLimit(1)
.fixedSize()
(
Text(diff.prefix)
+ Text(diff.originalCore).strikethrough(true, color: Color(nsColor: .systemRed))
+ Text(diff.suffix)
)
.font(badgeFont)
.foregroundStyle(Color(nsColor: .systemRed))
.lineLimit(1)
.fixedSize()
Text("→")
.font(Font(font as CTFont))
.font(badgeFont)
.foregroundStyle(Color(nsColor: .secondaryLabelColor))
.lineLimit(1)
.fixedSize()
Text(replacement)
.font(Font(font as CTFont))
.foregroundStyle(Color(nsColor: .systemGreen))
.lineLimit(1)
.fixedSize()
(
Text(diff.prefix)
+ Text(diff.replacementCore).fontWeight(.semibold)
+ Text(diff.suffix)
)
.font(badgeFont)
.foregroundStyle(Color(nsColor: .systemGreen))
.lineLimit(1)
.fixedSize()
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import Foundation

/// Letter-level split between a misspelled word and its correction, so the badge can strike
/// through only the letters that are actually wrong (ADR-119) instead of the whole word:
/// `definately → definitely` renders as defin~a~tely → defin**i**tely.
///
/// The split is the longest common prefix, then the longest common suffix of the remainders
/// (never overlapping the prefix). Either core may be empty — a pure insertion (`wich → which`)
/// has an empty original core and nothing to strike; a pure deletion has an empty replacement
/// core and nothing to embolden.
public struct CorrectionLetterDiff: Equatable, Sendable {
public let prefix: String
public let originalCore: String
public let replacementCore: String
public let suffix: String

public static func between(_ original: String, _ replacement: String) -> CorrectionLetterDiff {
let o = Array(original)
let r = Array(replacement)

var prefixLength = 0
while prefixLength < o.count, prefixLength < r.count, o[prefixLength] == r[prefixLength] {
prefixLength += 1
}

var suffixLength = 0
while suffixLength < o.count - prefixLength,
suffixLength < r.count - prefixLength,
o[o.count - 1 - suffixLength] == r[r.count - 1 - suffixLength] {
suffixLength += 1
}

return CorrectionLetterDiff(
prefix: String(o.prefix(prefixLength)),
originalCore: String(o[prefixLength..<(o.count - suffixLength)]),
replacementCore: String(r[prefixLength..<(r.count - suffixLength)]),
suffix: String(o.suffix(suffixLength))
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,29 @@ final class CompletionUITests: XCTestCase {
XCTAssertEqual(resolver.placement(for: context)?.mode, .inline)
}

func testCorrectionLetterDiffSplitsSubstitution() {
let diff = CorrectionLetterDiff.between("definately", "definitely")
XCTAssertEqual(diff, CorrectionLetterDiff(prefix: "defin", originalCore: "a", replacementCore: "i", suffix: "tely"))
}

func testCorrectionLetterDiffSplitsTransposition() {
let diff = CorrectionLetterDiff.between("recieve", "receive")
XCTAssertEqual(diff, CorrectionLetterDiff(prefix: "rec", originalCore: "ie", replacementCore: "ei", suffix: "ve"))
}

func testCorrectionLetterDiffPureInsertionHasEmptyOriginalCore() {
let diff = CorrectionLetterDiff.between("wich", "which")
XCTAssertEqual(diff, CorrectionLetterDiff(prefix: "w", originalCore: "", replacementCore: "h", suffix: "ich"))
}

func testCorrectionLetterDiffDisjointWordsFallBackToWholeWordCores() {
let diff = CorrectionLetterDiff.between("teh", "the")
XCTAssertEqual(diff.prefix, "t")
XCTAssertEqual(diff.originalCore, "eh")
XCTAssertEqual(diff.replacementCore, "he")
XCTAssertEqual(diff.suffix, "")
}

@MainActor
func testCorrectionBadgeLayoutPlacesBesideCaretAndClampsToField() {
let font = NSFont.systemFont(ofSize: 14)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,48 @@ import AutocompleteCore
import Foundation
import ModelRuntime

/// All word-vs-word comparisons are in **joint (summed) log-probability** — the log of the actual
/// probability of the text (ADR-120). The previous per-token *mean* systematically favoured
/// multi-token strings (their trailing pieces are near-deterministic), to the point of ranking a
/// misspelling above its correction: measured on the live model, " definitely" (1 token) scored
/// mean −8.09 vs " definately" (2 tokens) mean −6.56, while the joint scores are −8.09 vs −13.12
/// — the correction wins by 5 nats once the maths stops lying.
public struct CorrectionValidationThresholds: Equatable, Sendable {
public var minimumMeanLogProbability: Double
/// A replacement must beat the *original* — what the user actually typed — by at least this
/// many nats of joint log-probability. 0 means "the model must not prefer the original at
/// all": deliberate spellings, slang, and proper nouns the model knows keep their veto, and
/// this single gate replaces both the old absolute floor (which asked the wrong question —
/// "is this word a likely continuation?" — and suppressed valid corrections like an adverb
/// after "I") and the old original-is-much-better check. Dictionary flagging already
/// guarantees the replacement is a real word.
public var minimumAdvantageOverOriginal: Double
/// Runner-up separation (joint nats) for ordinary candidates.
public var minimumMargin: Double
public var minimumSuffixMeanLogProbability: Double
public var priorPredictionConfidence: Double
/// Relaxed runner-up separation for *clear-cut* spellcheck corrections (dictionary-flagged
/// word, edit distance ≤ `clearCutMaximumEditDistance`, model-top guess). Common typos
/// attract look-alike checker guesses that compress the margin (definately → definitely vs
/// defiantly measured at 5.97 joint nats, but mean-scale margins sat at 0.15); genuinely
/// ambiguous near-ties stay suppressed, and the advantage-over-original gate is never
/// relaxed. See ADR-119/120.
public var clearCutMargin: Double
public var clearCutMaximumEditDistance: Int

public init(
minimumMeanLogProbability: Double = -6.0,
minimumMargin: Double = 0.20,
minimumAdvantageOverOriginal: Double = 0.0,
minimumMargin: Double = 2.0,
minimumSuffixMeanLogProbability: Double = -7.0,
priorPredictionConfidence: Double = 0.97
priorPredictionConfidence: Double = 0.97,
clearCutMargin: Double = 0.5,
clearCutMaximumEditDistance: Int = 2
) {
self.minimumMeanLogProbability = minimumMeanLogProbability
self.minimumAdvantageOverOriginal = minimumAdvantageOverOriginal
self.minimumMargin = minimumMargin
self.minimumSuffixMeanLogProbability = minimumSuffixMeanLogProbability
self.priorPredictionConfidence = priorPredictionConfidence
self.clearCutMargin = clearCutMargin
self.clearCutMaximumEditDistance = clearCutMaximumEditDistance
}
}

Expand Down Expand Up @@ -58,22 +84,34 @@ public final class CorrectionValidationScorer {
return [candidate]
}

let prefixTokens = try runtime.tokenizer.tokenize(prefixBeforeWord)
// Score across the word boundary the way the model actually tokenizes it (ADR-120, the
// correction-lane analogue of ADR-017's caret-boundary sanitization): an anchor ending in
// whitespace forces the word to tokenize standalone, a split BPE models essentially never
// produce after a space token, which floors every candidate's mean log-probability. Trim
// the trailing whitespace off the anchor and carry it as the words' leading separator so
// "I " + "definitely" is scored as "I" + " definitely" (one space-prefixed token).
let boundary = Self.splitTrailingWhitespace(of: prefixBeforeWord)
let prefixTokens = try runtime.tokenizer.tokenize(boundary.head)
let original = candidates.first?.original ?? ""
let originalScore = try await meanLogProbability(
of: original,
let originalScore = try await jointLogProbability(
of: boundary.separator + original,
anchor: prefixTokens
)

var scored: [(candidate: CorrectionCandidate, score: Double, suffixScore: Double?)] = []
for candidate in candidates {
try Task.checkCancellation()
let score = try await meanLogProbability(of: candidate.replacement, anchor: prefixTokens)
let suffixScore = suffixWindow.isEmpty
let score = try await jointLogProbability(
of: boundary.separator + candidate.replacement,
anchor: prefixTokens
)
// Whitespace-only windows carry no join signal (defensive twin of the controller's
// producer-side check — probing a bare space token is tokenization noise, ADR-120).
let suffixScore = suffixWindow.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? nil
: try await meanLogProbability(
of: suffixWindow,
anchor: prefixTokens + runtime.tokenizer.tokenize(candidate.replacement)
anchor: prefixTokens + runtime.tokenizer.tokenize(boundary.separator + candidate.replacement)
)
scored.append((candidate, score, suffixScore))
}
Expand All @@ -83,18 +121,28 @@ public final class CorrectionValidationScorer {

return scored.compactMap { entry in
let margin = runnerUp.map { entry.score - $0 } ?? .infinity
let originalIsMuchBetter = originalScore > entry.score + max(1.0, thresholds.minimumMargin * 2)
let advantage = entry.score - originalScore
let suffixPass = entry.suffixScore.map { $0 >= thresholds.minimumSuffixMeanLogProbability } ?? true
let passesValidation: Bool
if entry.candidate.source == .systemGrammarOnly {
// Grammar edits rewrite phrases the checker already vetted; the model only vetoes
// when it clearly prefers the user's phrasing (more than the standard margin).
passesValidation = entry.score.isFinite
&& !originalIsMuchBetter
&& advantage >= -thresholds.minimumMargin
&& suffixPass
} else {
passesValidation = entry.score >= thresholds.minimumMeanLogProbability
&& margin >= thresholds.minimumMargin
&& !originalIsMuchBetter
&& suffixPass
passesValidation = Self.spellcheckValidationPasses(
advantage: advantage,
margin: margin,
suffixPass: suffixPass,
isClearCut: entry.candidate.source == .spellcheckOnly
&& Self.boundedEditDistance(
entry.candidate.original.lowercased(),
entry.candidate.replacement.lowercased(),
cap: thresholds.clearCutMaximumEditDistance
) != nil,
thresholds: thresholds
)
}

guard passesValidation else {
Expand Down Expand Up @@ -126,22 +174,97 @@ public final class CorrectionValidationScorer {
}
}

/// The pass/fail decision for a model-validated spellcheck candidate, extracted pure so tests
/// can assert real logged cases without a model. All quantities are joint log-probability
/// nats (ADR-120).
///
/// `advantage` (replacement − original) is the primary gate: the correction must be at least
/// as plausible as what the user actually typed, which both admits valid-but-unlikely
/// continuations (an adverb after "I") and vetoes deliberate spellings the model knows. A
/// clear-cut candidate (dictionary-flagged + edit distance within the cap) only needs to beat
/// the runner-up by `clearCutMargin` — dominance, not the full separation bar — because
/// common typos systematically attract look-alike checker guesses.
static func spellcheckValidationPasses(
advantage: Double,
margin: Double,
suffixPass: Bool,
isClearCut: Bool,
thresholds: CorrectionValidationThresholds
) -> Bool {
let requiredMargin = isClearCut ? thresholds.clearCutMargin : thresholds.minimumMargin
return advantage >= thresholds.minimumAdvantageOverOriginal
&& margin >= requiredMargin
&& suffixPass
}

/// Splits `text` into everything before its trailing whitespace run and that run itself,
/// so the whitespace can lead the scored word instead of dangling off the anchor (ADR-120).
static func splitTrailingWhitespace(of text: String) -> (head: String, separator: String) {
var head = Substring(text)
var separator = ""
while let last = head.last, last.isWhitespace {
separator = String(last) + separator
head = head.dropLast()
}
return (String(head), separator)
}

/// Levenshtein distance capped at `cap`: returns the distance when ≤ cap, else `nil`.
/// Early-outs on length difference and abandons rows whose minimum already exceeds the cap,
/// so pathological inputs stay cheap.
static func boundedEditDistance(_ a: String, _ b: String, cap: Int) -> Int? {
let s = Array(a), t = Array(b)
if abs(s.count - t.count) > cap { return nil }
if s.isEmpty { return t.count <= cap ? t.count : nil }
if t.isEmpty { return s.count <= cap ? s.count : nil }

var previous = Array(0...t.count)
var current = [Int](repeating: 0, count: t.count + 1)
for i in 1...s.count {
current[0] = i
var rowMinimum = i
for j in 1...t.count {
let substitution = previous[j - 1] + (s[i - 1] == t[j - 1] ? 0 : 1)
current[j] = min(previous[j] + 1, current[j - 1] + 1, substitution)
rowMinimum = min(rowMinimum, current[j])
}
if rowMinimum > cap { return nil }
swap(&previous, &current)
}
return previous[t.count] <= cap ? previous[t.count] : nil
}

/// Joint log-probability of `text` after `anchor` — the log of the actual probability of the
/// string, used for all word-vs-word comparisons (ADR-120).
private func jointLogProbability(of text: String, anchor: [TokenID]) async throws -> Double {
try await totalLogProbability(of: text, anchor: anchor).total
}

/// Per-token mean, kept for the suffix-join probe where the window text is identical across
/// candidates (so length normalisation can't distort a comparison) and the existing
/// `minimumSuffixMeanLogProbability` calibration still applies.
private func meanLogProbability(of text: String, anchor: [TokenID]) async throws -> Double {
let (total, count) = try await totalLogProbability(of: text, anchor: anchor)
guard count > 0, total.isFinite else { return total }
return total / Double(count)
}

private func totalLogProbability(of text: String, anchor: [TokenID]) async throws -> (total: Double, count: Int) {
let tokens = try runtime.tokenizer.tokenize(text)
guard !tokens.isEmpty else { return -.infinity }
guard !tokens.isEmpty else { return (-.infinity, 0) }

var suffix: [TokenID] = []
var total = 0.0
for token in tokens {
try Task.checkCancellation()
let logits = try await runtime.anchoredLogits(anchor: anchor, suffix: suffix)
guard let logProbability = Self.logProbability(of: token, in: logits) else {
return -.infinity
return (-.infinity, tokens.count)
}
total += logProbability
suffix.append(token)
}
return total / Double(tokens.count)
return (total, tokens.count)
}

private static func logProbability(of token: TokenID, in logits: [TokenLogit]) -> Double? {
Expand Down
Loading