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.
This commit is contained in:
2026-10-20 20:05:35 +02:00
parent f71f598ac7
commit eaacc35cf7
11 changed files with 777 additions and 43 deletions
@@ -0,0 +1,96 @@
// 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)
}
}
@@ -91,6 +91,9 @@ public struct PolishStyleLearningEvidence: Codable, Equatable, Sendable {
case replyFinalEdit
case replyCrossContextSelection
case replyAcceptance
/// A trait retained from the previous generation's accumulated
/// evidence, which is no longer present as raw examples.
case priorProfile
}
public struct Trait: Codable, Equatable, Sendable {
@@ -147,6 +150,20 @@ public struct PolishStyleLearningCorpus: Equatable, Sendable {
effectiveCharacterCount
>= PolishStyleLearningCorpusBuilder.requiredEffectiveCharacterCount
}
/// Examples recorded after `date`, i.e. not yet absorbed by a prior
/// generation.
public func examples(after date: Date) -> PolishStyleLearningCorpus {
let newer = examples.filter { $0.createdAt > date }
return PolishStyleLearningCorpus(
examples: newer,
effectiveCharacterCount: newer.reduce(into: 0) { count, example in
count += PolishStyleLearningCorpusBuilder.effectiveCharacterCount(
in: example.prePolishText
)
}
)
}
}
public enum PolishStyleLearningCorpusBuilder {
@@ -160,6 +177,10 @@ public enum PolishStyleLearningCorpusBuilder {
/// still a real gate; the previous "0 / unlimited" bypass is gone.
public static let testBuildEffectiveCharacterCount = 1_250
/// New effective characters required since the last saved generation
/// before an incremental relearn is worth a request.
public static let incrementalEffectiveCharacterCount = 500
/// Upper bound on effective characters included in a single
/// training-corpus export. Even when the user has accumulated
/// significantly more history than the unlock threshold, the
@@ -459,8 +480,16 @@ public actor PolishStyleLearningService {
let examples: [ReplyExamplePayload]
}
private struct PriorInput: Codable {
let learnedThrough: Date
let asr: PolishStyleLearningPriorEvidence.Domain
let reply: PolishStyleLearningPriorEvidence.Domain
}
private struct EvidenceRequestPayload: Codable {
let schemaVersion: Int
/// Omitted from the JSON entirely on a full (non-incremental) run.
let prior: PriorInput?
let asr: ASRInput
let reply: ReplyInput
}
@@ -488,7 +517,16 @@ public actor PolishStyleLearningService {
private static let maximumEvidenceItemsPerDomain = 24
private static let maximumContradictionsPerDomain = 12
private static let maximumEvidenceFieldCharacters = 320
private static let learningSchemaVersion = 3
private static let learningSchemaVersion = 4
/// Each relearn without fresh support shrinks a prior trait; below the
/// floor it is no longer sent, so unsupported traits fade out in a few
/// rounds without per-trait bookkeeping.
static let priorConfidenceDecay = 0.8
static let priorRetentionFloor = 0.15
static let maximumPriorTraitsPerDomain = 8
static let maximumPriorContradictionsPerDomain = 6
/// Keeps the prior well inside the 30,000-character request budget.
static let maximumPriorCharacters = 4_000
private static let generationOptions = LLMGenerationOptions(
temperature: 0.1,
topP: 0.9,
@@ -513,7 +551,33 @@ public actor PolishStyleLearningService {
minimumEffectiveCharacterCount: Int =
PolishStyleLearningCorpusBuilder.requiredEffectiveCharacterCount
) async throws -> PolishStylePack {
let requiredCharacterCount = max(0, minimumEffectiveCharacterCount)
try await learnStyle(
from: corpus,
replyExamples: replyExamples,
outputLanguage: outputLanguage,
minimumEffectiveCharacterCount: minimumEffectiveCharacterCount
).pack
}
/// With a `prior`, learns incrementally: only examples recorded after
/// `prior.learnedThrough` are sent, merged with the decayed prior, and
/// the incremental gate replaces `minimumEffectiveCharacterCount`.
public func learnStyle(
from fullCorpus: PolishStyleLearningCorpus,
replyExamples allReplyExamples: [PolishStyleReplyLearningExample] = [],
outputLanguage: AppUILanguage,
minimumEffectiveCharacterCount: Int =
PolishStyleLearningCorpusBuilder.requiredEffectiveCharacterCount,
prior: PolishStyleLearningPriorEvidence? = nil
) async throws -> PolishStyleLearningResult {
let corpus = prior.map { fullCorpus.examples(after: $0.learnedThrough) }
?? fullCorpus
let replyExamples = prior.map { prior in
allReplyExamples.filter { $0.createdAt > prior.learnedThrough }
} ?? allReplyExamples
let requiredCharacterCount = prior == nil
? max(0, minimumEffectiveCharacterCount)
: PolishStyleLearningCorpusBuilder.incrementalEffectiveCharacterCount
let verifiedCharacterCount = corpus.examples.reduce(into: 0) { count, example in
count += PolishStyleLearningCorpusBuilder.effectiveCharacterCount(
in: example.prePolishText
@@ -540,7 +604,8 @@ public actor PolishStyleLearningService {
replyExamples: selectedReplyExamples,
activeStyleID: configuration.activePolishStyleId,
catalog: configuration.polishStyleCatalog,
outputLanguage: outputLanguage
outputLanguage: outputLanguage,
prior: prior.map(Self.decayedPrior)
)
let service = PolishingService(
store: configuration,
@@ -554,21 +619,30 @@ public actor PolishStyleLearningService {
requiresBestEffortASRCandidate: !selectedASRExamples.isEmpty,
notifiesManagedCredits: notifiesManagedCredits
)
// Counts are cumulative across incremental generations so the card
// reflects everything this style has learned, not just this round.
let asrExampleCount = (prior?.cumulativeASRExampleCount ?? 0)
+ selectedASRExamples.count
let asrEffectiveCharacterCount = (prior?.cumulativeASREffectiveCharacterCount ?? 0)
+ selectedASRExamples.reduce(into: 0) { count, example in
count += PolishStyleLearningCorpusBuilder.effectiveCharacterCount(
in: example.prePolishText
)
}
let replyExampleCount = (prior?.cumulativeReplyExampleCount ?? 0)
+ selectedReplyExamples.count
let replyFinalEditCount = (prior?.cumulativeReplyFinalEditCount ?? 0)
+ selectedReplyExamples.filter { Self.normalized($0.finalEdit) != nil }.count
let generatedAt = Date()
let metadata = PolishStylePack.LearningMetadata(
schemaVersion: Self.learningSchemaVersion,
evidenceStatus: evidence.status.rawValue,
confidence: evidence.confidence,
asrExampleCount: selectedASRExamples.count,
asrEffectiveCharacterCount: selectedASRExamples.reduce(into: 0) { count, example in
count += PolishStyleLearningCorpusBuilder.effectiveCharacterCount(
in: example.prePolishText
)
},
replyExampleCount: selectedReplyExamples.count,
replyFinalEditCount: selectedReplyExamples.filter {
Self.normalized($0.finalEdit) != nil
}.count,
generatedAt: Date()
asrExampleCount: asrExampleCount,
asrEffectiveCharacterCount: asrEffectiveCharacterCount,
replyExampleCount: replyExampleCount,
replyFinalEditCount: replyFinalEditCount,
generatedAt: generatedAt
)
// Always synthesize from this operation's evidence. Low-confidence
// profiles use their strongest candidate traits; only genuinely empty
@@ -577,13 +651,30 @@ public actor PolishStyleLearningService {
evidence: evidence,
metadata: metadata
)
return try await synthesizeStyle(
let pack = try await synthesizeStyle(
payload: synthesisPayload,
service: service,
learningMetadata: metadata,
outputLanguage: outputLanguage,
notifiesManagedCredits: notifiesManagedCredits
)
let newestLearnedExample = (
selectedASRExamples.map(\.createdAt) + selectedReplyExamples.map(\.createdAt)
).max()
let learnedThrough = [newestLearnedExample, prior?.learnedThrough]
.compactMap { $0 }
.max() ?? generatedAt
return PolishStyleLearningResult(
pack: pack,
prior: Self.compactPrior(
from: evidence,
learnedThrough: learnedThrough,
cumulativeASRExampleCount: asrExampleCount,
cumulativeASREffectiveCharacterCount: asrEffectiveCharacterCount,
cumulativeReplyExampleCount: replyExampleCount,
cumulativeReplyFinalEditCount: replyFinalEditCount
)
)
}
private func extractEvidence(
@@ -687,7 +778,8 @@ public actor PolishStyleLearningService {
replyExamples: [PolishStyleReplyLearningExample],
activeStyleID: String,
catalog: PolishStyleCatalog,
outputLanguage: AppUILanguage
outputLanguage: AppUILanguage,
prior: PolishStyleLearningPriorEvidence? = nil
) throws -> String {
let activeStyle = PolishStylePackCatalog.resolve(
id: activeStyleID,
@@ -706,6 +798,9 @@ public actor PolishStyleLearningService {
)
let payload = EvidenceRequestPayload(
schemaVersion: learningSchemaVersion,
prior: prior.map {
PriorInput(learnedThrough: $0.learnedThrough, asr: $0.asr, reply: $0.reply)
},
asr: ASRInput(
residualBaseline: reference(
for: baselineStyle,
@@ -896,9 +991,33 @@ public actor PolishStyleLearningService {
The ASR domain may be empty only when asr.examples itself is empty.
- Set status to "sufficient" only when total confidence is at least 0.5.
INCREMENTAL MERGE (only when the payload contains prior):
- prior is this user's previously learned profile, already decayed for
age. It summarizes older evidence that is no longer in the payload.
Unlike the contamination style prompts, it is evidence about the user.
asr.examples and reply.examples contain only newer observations.
- Return one merged profile covering prior and the newer examples.
- Keep every prior trait the newer examples do not contradict, at its
given confidence or lower. Never raise a prior trait without new
support.
- When newer examples support a prior trait, keep its name, add the new
support to its supportCount, and you may raise its confidence.
- When newer examples contradict a prior trait, lower its confidence and
add a contradictions entry; drop it only when the contradiction is
clear and repeated. Keep prior contradictions unless resolved.
- Cite retained prior traits with source priorProfile and the prior
supportCount. priorProfile ranks with asrRepeatedBefore and
replyCrossContextSelection: after user edits, before single
observations.
- Keep prior ASR and reply traits in their own domains.
- When over the trait limit, drop the lowest-confidence prior traits.
- status and confidence describe the merged profile. "insufficient"
requires overall and every trait confidence at most 0.35;
"sufficient" requires overall confidence at least 0.5.
Allowed source values:
asrUserEdit, asrRepeatedBefore, asrObservedBefore, replyFinalEdit,
replyCrossContextSelection, replyAcceptance.
replyCrossContextSelection, replyAcceptance, priorProfile.
Return this exact Codable shape:
{
@@ -1096,6 +1215,118 @@ public actor PolishStyleLearningService {
return references
}
// MARK: - Incremental prior
/// Ages the prior before it is sent: every trait loses confidence, and
/// traits that fall below the retention floor are dropped. Decay is local
/// and deterministic rather than delegated to the model.
static func decayedPrior(
_ prior: PolishStyleLearningPriorEvidence
) -> PolishStyleLearningPriorEvidence {
func decayed(
_ domain: PolishStyleLearningPriorEvidence.Domain
) -> PolishStyleLearningPriorEvidence.Domain {
PolishStyleLearningPriorEvidence.Domain(
traits: domain.traits.compactMap { trait in
let confidence = trait.confidence * priorConfidenceDecay
guard confidence >= priorRetentionFloor else { return nil }
return PolishStyleLearningEvidence.Trait(
name: trait.name,
description: trait.description,
confidence: confidence,
supportCount: trait.supportCount
)
},
contradictions: domain.contradictions
)
}
return PolishStyleLearningPriorEvidence(
asr: decayed(prior.asr),
reply: decayed(prior.reply),
learnedThrough: prior.learnedThrough,
cumulativeASRExampleCount: prior.cumulativeASRExampleCount,
cumulativeASREffectiveCharacterCount: prior.cumulativeASREffectiveCharacterCount,
cumulativeReplyExampleCount: prior.cumulativeReplyExampleCount,
cumulativeReplyFinalEditCount: prior.cumulativeReplyFinalEditCount
)
}
/// Reduces validated evidence to what the next relearn needs: the
/// strongest traits and a few contradictions per domain, within
/// `maximumPriorCharacters`. Evidence items are not kept; the next run
/// cites retained traits as `priorProfile`.
static func compactPrior(
from evidence: PolishStyleLearningEvidence,
learnedThrough: Date,
cumulativeASRExampleCount: Int,
cumulativeASREffectiveCharacterCount: Int,
cumulativeReplyExampleCount: Int,
cumulativeReplyFinalEditCount: Int
) -> PolishStyleLearningPriorEvidence {
func strongest(
_ traits: [PolishStyleLearningEvidence.Trait]
) -> [PolishStyleLearningEvidence.Trait] {
Array(
traits
.filter { $0.confidence >= priorRetentionFloor }
.sorted { $0.confidence > $1.confidence }
.prefix(maximumPriorTraitsPerDomain)
)
}
var asrTraits = strongest(evidence.asr.traits)
var replyTraits = strongest(evidence.reply.traits)
var asrContradictions = Array(
evidence.asr.contradictions.prefix(maximumPriorContradictionsPerDomain)
)
var replyContradictions = Array(
evidence.reply.contradictions.prefix(maximumPriorContradictionsPerDomain)
)
func makePrior() -> PolishStyleLearningPriorEvidence {
PolishStyleLearningPriorEvidence(
asr: .init(traits: asrTraits, contradictions: asrContradictions),
reply: .init(traits: replyTraits, contradictions: replyContradictions),
learnedThrough: learnedThrough,
cumulativeASRExampleCount: cumulativeASRExampleCount,
cumulativeASREffectiveCharacterCount: cumulativeASREffectiveCharacterCount,
cumulativeReplyExampleCount: cumulativeReplyExampleCount,
cumulativeReplyFinalEditCount: cumulativeReplyFinalEditCount
)
}
var prior = makePrior()
// Over budget: shed contradictions first, then the weakest trait
// across both domains, until the prior fits.
while priorCharacterCount(prior) > maximumPriorCharacters {
if !replyContradictions.isEmpty {
replyContradictions.removeLast()
} else if !asrContradictions.isEmpty {
asrContradictions.removeLast()
} else if let weakestASR = asrTraits.last,
weakestASR.confidence <= (replyTraits.last?.confidence ?? .infinity) {
asrTraits.removeLast()
} else if !replyTraits.isEmpty {
replyTraits.removeLast()
} else {
break
}
prior = makePrior()
}
return prior
}
static func priorCharacterCount(_ prior: PolishStyleLearningPriorEvidence) -> Int {
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
let domains = [prior.asr, prior.reply]
guard let data = try? encoder.encode(domains),
let text = String(data: data, encoding: .utf8) else {
return 0
}
return text.count
}
private static func boundedExample(
_ example: PolishStyleLearningExample
) -> PolishStyleLearningExample {
@@ -1303,7 +1534,8 @@ public actor PolishStyleLearningService {
allowedSources: [
.asrUserEdit,
.asrRepeatedBefore,
.asrObservedBefore
.asrObservedBefore,
.priorProfile
]
),
isValid(
@@ -1311,7 +1543,8 @@ public actor PolishStyleLearningService {
allowedSources: [
.replyFinalEdit,
.replyCrossContextSelection,
.replyAcceptance
.replyAcceptance,
.priorProfile
]
) else {
return false
@@ -1369,7 +1602,8 @@ public actor PolishStyleLearningService {
switch item.source {
case .asrRepeatedBefore, .replyCrossContextSelection:
return item.supportCount >= 2
case .asrUserEdit, .asrObservedBefore, .replyFinalEdit, .replyAcceptance:
case .asrUserEdit, .asrObservedBefore, .replyFinalEdit, .replyAcceptance,
.priorProfile:
return true
}
}
@@ -1388,7 +1622,7 @@ public actor PolishStyleLearningService {
switch source {
case .asrUserEdit, .replyFinalEdit:
return 0
case .asrRepeatedBefore, .replyCrossContextSelection:
case .asrRepeatedBefore, .replyCrossContextSelection, .priorProfile:
return 1
case .asrObservedBefore, .replyAcceptance:
return 2