Files
OSGKeyboard/OSGKeyboardShared/Services/PersonalStyleLearningState.swift
T
rocky eaacc35cf7 feat(styles): learn the personal reply style incrementally
Regeneration merges the on-device learned evidence with dictation and reply
choices since the last generation instead of re-reading only the newest
~2,500 characters. It unlocks after 500 new characters, fades traits that
stop getting support, and fully relearns styles without saved evidence.
2026-10-20 20:05:35 +02:00

97 lines
3.6 KiB
Swift

// PersonalStyleLearningState.swift
// OSGKeyboard · Shared
//
// Device-local memory that lets a personal style be relearned incrementally:
// the compacted evidence of the last saved generation and the corpus it has
// already absorbed. Dictation history keeps only a rolling window, so this is
// the only place older learning survives. It is never synced — a device
// without a matching record simply relearns from scratch.
import Foundation
/// Compacted stage-one evidence carried into the next generation.
public struct PolishStyleLearningPriorEvidence: Codable, Equatable, Sendable {
public struct Domain: Codable, Equatable, Sendable {
public let traits: [PolishStyleLearningEvidence.Trait]
public let contradictions: [PolishStyleLearningEvidence.Contradiction]
}
public let asr: Domain
public let reply: Domain
/// Newest learned example timestamp. Only later examples count as new.
public let learnedThrough: Date
public let cumulativeASRExampleCount: Int
public let cumulativeASREffectiveCharacterCount: Int
public let cumulativeReplyExampleCount: Int
public let cumulativeReplyFinalEditCount: Int
}
/// A reviewed pack plus the evidence to persist once the user saves it.
public struct PolishStyleLearningResult: Sendable {
public let pack: PolishStylePack
/// Nil when the generator produced no reusable evidence (fixtures).
public let prior: PolishStyleLearningPriorEvidence?
public init(pack: PolishStylePack, prior: PolishStyleLearningPriorEvidence?) {
self.pack = pack
self.prior = prior
}
}
@MainActor
public final class PersonalStyleLearningStateStore {
public static let shared = PersonalStyleLearningStateStore()
private struct Record: Codable {
let packID: String
let generatedAt: Date
let prior: PolishStyleLearningPriorEvidence
}
private static let storageKey = "personalStyle.learningState.v1"
/// Cloud-merged metadata may round dates, so match within one second.
private static let generatedAtTolerance: TimeInterval = 1
private let defaults: UserDefaults?
public init(defaults: UserDefaults? = AppGroup.defaultsIfAvailable) {
self.defaults = defaults
}
/// Returns the prior only when it belongs to exactly this saved
/// generation. A pack replaced elsewhere (another device, an older build)
/// has a different `generatedAt` and falls back to full relearning.
public func prior(for pack: PolishStylePack) -> PolishStyleLearningPriorEvidence? {
guard let generatedAt = pack.learningMetadata?.generatedAt,
let record = load(),
record.packID == pack.id,
abs(record.generatedAt.timeIntervalSince(generatedAt))
<= Self.generatedAtTolerance else {
return nil
}
return record.prior
}
/// Only one personal style is learned at a time, so a save replaces any
/// earlier record.
public func save(_ prior: PolishStyleLearningPriorEvidence, for pack: PolishStylePack) {
guard let defaults,
let generatedAt = pack.learningMetadata?.generatedAt,
let data = try? JSONEncoder().encode(
Record(packID: pack.id, generatedAt: generatedAt, prior: prior)
) else {
return
}
defaults.set(data, forKey: Self.storageKey)
}
public func clear() {
defaults?.removeObject(forKey: Self.storageKey)
}
private func load() -> Record? {
guard let data = defaults?.data(forKey: Self.storageKey) else { return nil }
return try? JSONDecoder().decode(Record.self, from: data)
}
}