717 lines
24 KiB
Swift
717 lines
24 KiB
Swift
#!/usr/bin/env xcrun swift
|
|
|
|
import CreateML
|
|
import Foundation
|
|
|
|
private struct BaseRecord: Decodable {
|
|
let id: String
|
|
let text: String
|
|
let language: String
|
|
let split: String
|
|
let task: Bool
|
|
let question: Bool
|
|
let invitation: Bool
|
|
let complaint: Bool
|
|
let scheduleNegotiation: Bool
|
|
let confirmationDecision: Bool
|
|
let followUpReminder: Bool
|
|
}
|
|
|
|
private struct SilverRecord: Decodable {
|
|
let id: String
|
|
let text: String
|
|
let language: String
|
|
let split: String
|
|
let actionVerifierLabel: String
|
|
let coordinationVerifierLabel: String
|
|
let sourceDataset: String?
|
|
}
|
|
|
|
private struct VerifierExample {
|
|
let id: String
|
|
let text: String
|
|
let language: String
|
|
let split: String
|
|
let label: String
|
|
let sourceDataset: String?
|
|
}
|
|
|
|
private struct RoutingMetrics: Encodable {
|
|
let total: Int
|
|
let expectedSpecialized: Int
|
|
let routed: Int
|
|
let correctRouted: Int
|
|
let falseRouted: Int
|
|
let missedSpecialized: Int
|
|
let precision: Double
|
|
let recall: Double
|
|
let f1: Double
|
|
let routedByLabel: [String: Int]
|
|
let correctByLabel: [String: Int]
|
|
}
|
|
|
|
private struct ThresholdSelection: Encodable {
|
|
let confidenceThreshold: Double
|
|
let minimumMargin: Double
|
|
let metrics: RoutingMetrics
|
|
}
|
|
|
|
private struct GateResult: Encodable {
|
|
let accepted: Bool
|
|
let reason: String
|
|
let minimumPredictedPositivesPerLabelAndLanguage: Int
|
|
let supportByLabelAndLanguage: [String: Int]
|
|
let precisionByLabelAndLanguage: [String: Double]
|
|
let maximumSourceShareByLabelAndLanguage: [String: Double]
|
|
}
|
|
|
|
private struct VerifierReport: Encodable {
|
|
let id: String
|
|
let labels: [String]
|
|
let modelFile: String
|
|
let modelBytes: Int
|
|
let trainingCount: Int
|
|
let calibrationCount: Int
|
|
let thresholdCalibrationCount: Int
|
|
let acceptanceCount: Int
|
|
let threshold: ThresholdSelection
|
|
let thresholdsByLanguage: [String: ThresholdSelection]
|
|
let calibrationMetrics: RoutingMetrics
|
|
let acceptanceMetrics: RoutingMetrics
|
|
let acceptanceByLanguage: [String: RoutingMetrics]
|
|
let gate: GateResult
|
|
}
|
|
|
|
private struct TrainingReport: Encodable {
|
|
let generatedAt: String
|
|
let baseCorpusPath: String
|
|
let silverDirectoryPath: String
|
|
let selectionPolicy: String
|
|
let verifiers: [VerifierReport]
|
|
}
|
|
|
|
private struct Observation {
|
|
let example: VerifierExample
|
|
let predictedLabel: String
|
|
let confidence: Double
|
|
let margin: Double
|
|
}
|
|
|
|
private enum VerifierID: String, CaseIterable {
|
|
case action
|
|
case coordination
|
|
|
|
var labels: [String] {
|
|
switch self {
|
|
case .action:
|
|
["taskOnly", "complaintOnly", "both", "questionRequest", "neither"]
|
|
case .coordination:
|
|
[
|
|
"invitation",
|
|
"scheduleNegotiation",
|
|
"confirmationDecision",
|
|
"followUpReminder",
|
|
"neither"
|
|
]
|
|
}
|
|
}
|
|
|
|
var modelFile: String {
|
|
switch self {
|
|
case .action: "ActionIntentVerifier.mlmodel"
|
|
case .coordination: "CoordinationIntentVerifier.mlmodel"
|
|
}
|
|
}
|
|
|
|
func baseLabel(for record: BaseRecord) -> String? {
|
|
switch self {
|
|
case .action:
|
|
if record.task && record.complaint {
|
|
return "both"
|
|
}
|
|
if record.task {
|
|
return "taskOnly"
|
|
}
|
|
if record.complaint {
|
|
return "complaintOnly"
|
|
}
|
|
if record.question {
|
|
return "questionRequest"
|
|
}
|
|
return "neither"
|
|
case .coordination:
|
|
let matches = [
|
|
record.invitation ? "invitation" : nil,
|
|
record.scheduleNegotiation ? "scheduleNegotiation" : nil,
|
|
record.confirmationDecision ? "confirmationDecision" : nil,
|
|
record.followUpReminder ? "followUpReminder" : nil
|
|
].compactMap { $0 }
|
|
guard matches.count <= 1 else { return nil }
|
|
return matches.first ?? "neither"
|
|
}
|
|
}
|
|
|
|
func silverLabel(for record: SilverRecord) -> String {
|
|
switch self {
|
|
case .action: record.actionVerifierLabel
|
|
case .coordination: record.coordinationVerifierLabel
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct SeededGenerator: RandomNumberGenerator {
|
|
private var state: UInt64
|
|
|
|
init(seed: UInt64) {
|
|
state = seed
|
|
}
|
|
|
|
mutating func next() -> UInt64 {
|
|
state &+= 0x9E37_79B9_7F4A_7C15
|
|
var value = state
|
|
value = (value ^ (value >> 30)) &* 0xBF58_476D_1CE4_E5B9
|
|
value = (value ^ (value >> 27)) &* 0x94D0_49BB_1331_11EB
|
|
return value ^ (value >> 31)
|
|
}
|
|
}
|
|
|
|
private let fileManager = FileManager.default
|
|
private let root = URL(fileURLWithPath: fileManager.currentDirectoryPath)
|
|
|
|
private func argumentValue(after flag: String) -> String? {
|
|
guard let index = CommandLine.arguments.firstIndex(of: flag),
|
|
CommandLine.arguments.indices.contains(index + 1) else {
|
|
return nil
|
|
}
|
|
return CommandLine.arguments[index + 1]
|
|
}
|
|
|
|
private func resolvedURL(flag: String, defaultPath: String) -> URL {
|
|
URL(
|
|
fileURLWithPath: argumentValue(after: flag) ?? defaultPath,
|
|
relativeTo: root
|
|
).standardizedFileURL
|
|
}
|
|
|
|
private let baseCorpusURL = resolvedURL(
|
|
flag: "--base-corpus",
|
|
defaultPath: "ModelTraining/ClipboardSemantics/clipboard_semantic_corpus.jsonl"
|
|
)
|
|
private let silverDirectoryURL = resolvedURL(
|
|
flag: "--silver-directory",
|
|
defaultPath: "ModelTraining/ClipboardSemantics/Consensus"
|
|
)
|
|
private let baseManifestURL = resolvedURL(
|
|
flag: "--base-manifest",
|
|
defaultPath:
|
|
"OSGKeyboardShared/Resources/ClipboardSemantics/clipboard-semantic-models.json"
|
|
)
|
|
private let outputDirectoryURL = resolvedURL(
|
|
flag: "--output-directory",
|
|
defaultPath: "ModelTraining/ClipboardSemantics/VerifierCandidates"
|
|
)
|
|
private let reportURL = resolvedURL(
|
|
flag: "--report",
|
|
defaultPath: "ModelTraining/ClipboardSemantics/verifier-training-report.json"
|
|
)
|
|
|
|
private func readJSONLines<T: Decodable>(_ type: T.Type, from url: URL) throws -> [T] {
|
|
let content = try String(contentsOf: url, encoding: .utf8)
|
|
let decoder = JSONDecoder()
|
|
return try content.split(separator: "\n").map {
|
|
try decoder.decode(type, from: Data($0.utf8))
|
|
}
|
|
}
|
|
|
|
private func rounded(_ value: Double) -> Double {
|
|
guard value.isFinite else { return 0 }
|
|
return (value * 10_000).rounded() / 10_000
|
|
}
|
|
|
|
private func stableSeed(_ value: String) -> UInt64 {
|
|
value.utf8.reduce(0xcbf2_9ce4_8422_2325) { partial, byte in
|
|
(partial ^ UInt64(byte)) &* 0x0000_0100_0000_01B3
|
|
}
|
|
}
|
|
|
|
private func examples(
|
|
verifier: VerifierID,
|
|
baseRecords: [BaseRecord],
|
|
silverRecords: [SilverRecord],
|
|
baseSplit: String,
|
|
silverSplit: String
|
|
) -> [VerifierExample] {
|
|
let base = baseRecords.compactMap { record -> VerifierExample? in
|
|
guard record.split == baseSplit,
|
|
let label = verifier.baseLabel(for: record) else {
|
|
return nil
|
|
}
|
|
return VerifierExample(
|
|
id: record.id,
|
|
text: record.text,
|
|
language: record.language,
|
|
split: baseSplit,
|
|
label: label,
|
|
sourceDataset: nil
|
|
)
|
|
}
|
|
let silver = silverRecords.compactMap { record -> VerifierExample? in
|
|
guard record.split == silverSplit else { return nil }
|
|
return VerifierExample(
|
|
id: record.id,
|
|
text: record.text,
|
|
language: record.language,
|
|
split: silverSplit,
|
|
label: verifier.silverLabel(for: record),
|
|
sourceDataset: record.sourceDataset
|
|
)
|
|
}
|
|
return base + silver
|
|
}
|
|
|
|
private func balancedTexts(
|
|
examples: [VerifierExample],
|
|
verifier: VerifierID,
|
|
split: String
|
|
) -> [String: [String]] {
|
|
var grouped = Dictionary(grouping: examples, by: \.label)
|
|
if verifier == .action {
|
|
let taskExamples = grouped["taskOnly"] ?? []
|
|
let complaintExamples = grouped["complaintOnly"] ?? []
|
|
let targetCount = min(taskExamples.count, complaintExamples.count)
|
|
var bothExamples = grouped["both"] ?? []
|
|
if !taskExamples.isEmpty, !complaintExamples.isEmpty {
|
|
for index in bothExamples.count..<targetCount {
|
|
let task = taskExamples[index % taskExamples.count]
|
|
let complaint = complaintExamples[
|
|
Int(
|
|
stableSeed("\(split)|both|\(index)")
|
|
% UInt64(complaintExamples.count)
|
|
)
|
|
]
|
|
bothExamples.append(
|
|
VerifierExample(
|
|
id: "composed-both-\(split)-\(index)",
|
|
text: "\(complaint.text)\n\(task.text)",
|
|
language: task.language,
|
|
split: split,
|
|
label: "both",
|
|
sourceDataset: "generated-composition"
|
|
)
|
|
)
|
|
}
|
|
grouped["both"] = bothExamples
|
|
}
|
|
}
|
|
let minimumCount = verifier.labels.compactMap { grouped[$0]?.count }.min() ?? 0
|
|
precondition(minimumCount > 0, "Missing \(verifier.rawValue) label in \(split)")
|
|
return Dictionary(uniqueKeysWithValues: verifier.labels.enumerated().map { offset, label in
|
|
var generator = SeededGenerator(
|
|
seed: stableSeed("\(verifier.rawValue)|\(split)|\(label)|\(offset)")
|
|
)
|
|
let texts = (grouped[label] ?? []).map(\.text).shuffled(using: &generator)
|
|
return (label, Array(texts.prefix(minimumCount)))
|
|
})
|
|
}
|
|
|
|
private func observations(
|
|
model: MLTextClassifier,
|
|
examples: [VerifierExample]
|
|
) throws -> [Observation] {
|
|
try examples.map { example in
|
|
let hypotheses = try model.predictionWithConfidence(from: example.text)
|
|
.sorted { $0.value > $1.value }
|
|
let winner = hypotheses.first ?? (key: "neither", value: 0)
|
|
let runnerUp = hypotheses.dropFirst().first?.value ?? 0
|
|
return Observation(
|
|
example: example,
|
|
predictedLabel: winner.key,
|
|
confidence: winner.value,
|
|
margin: winner.value - runnerUp
|
|
)
|
|
}
|
|
}
|
|
|
|
private func routingMetrics(
|
|
observations: [Observation],
|
|
confidenceThreshold: Double,
|
|
minimumMargin: Double
|
|
) -> RoutingMetrics {
|
|
var routed = 0
|
|
var correctRouted = 0
|
|
var expectedSpecialized = 0
|
|
var missedSpecialized = 0
|
|
var routedByLabel: [String: Int] = [:]
|
|
var correctByLabel: [String: Int] = [:]
|
|
for observation in observations {
|
|
let expectedIsSpecialized = observation.example.label != "neither"
|
|
if expectedIsSpecialized {
|
|
expectedSpecialized += 1
|
|
}
|
|
let shouldRoute = observation.predictedLabel != "neither"
|
|
&& observation.confidence >= confidenceThreshold
|
|
&& observation.margin >= minimumMargin
|
|
if shouldRoute {
|
|
routed += 1
|
|
routedByLabel[observation.predictedLabel, default: 0] += 1
|
|
if observation.predictedLabel == observation.example.label {
|
|
correctRouted += 1
|
|
correctByLabel[observation.predictedLabel, default: 0] += 1
|
|
}
|
|
} else if expectedIsSpecialized {
|
|
missedSpecialized += 1
|
|
}
|
|
}
|
|
let precision = routed > 0 ? Double(correctRouted) / Double(routed) : 0
|
|
let recall = expectedSpecialized > 0
|
|
? Double(correctRouted) / Double(expectedSpecialized)
|
|
: 0
|
|
return RoutingMetrics(
|
|
total: observations.count,
|
|
expectedSpecialized: expectedSpecialized,
|
|
routed: routed,
|
|
correctRouted: correctRouted,
|
|
falseRouted: routed - correctRouted,
|
|
missedSpecialized: missedSpecialized,
|
|
precision: rounded(precision),
|
|
recall: rounded(recall),
|
|
f1: rounded(
|
|
precision + recall > 0
|
|
? 2 * precision * recall / (precision + recall)
|
|
: 0
|
|
),
|
|
routedByLabel: routedByLabel,
|
|
correctByLabel: correctByLabel
|
|
)
|
|
}
|
|
|
|
private func selectedThreshold(
|
|
observations: [Observation]
|
|
) -> ThresholdSelection {
|
|
var selections: [ThresholdSelection] = []
|
|
for confidenceStep in 50...99 {
|
|
for marginStep in 0...10 {
|
|
let confidence = Double(confidenceStep) / 100
|
|
let margin = Double(marginStep) / 20
|
|
let metrics = routingMetrics(
|
|
observations: observations,
|
|
confidenceThreshold: confidence,
|
|
minimumMargin: margin
|
|
)
|
|
if metrics.precision >= 0.95 {
|
|
selections.append(
|
|
ThresholdSelection(
|
|
confidenceThreshold: confidence,
|
|
minimumMargin: margin,
|
|
metrics: metrics
|
|
)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
return selections.max {
|
|
if $0.metrics.recall != $1.metrics.recall {
|
|
return $0.metrics.recall < $1.metrics.recall
|
|
}
|
|
if $0.metrics.routed != $1.metrics.routed {
|
|
return $0.metrics.routed < $1.metrics.routed
|
|
}
|
|
if $0.confidenceThreshold != $1.confidenceThreshold {
|
|
return $0.confidenceThreshold > $1.confidenceThreshold
|
|
}
|
|
return $0.minimumMargin > $1.minimumMargin
|
|
} ?? ThresholdSelection(
|
|
confidenceThreshold: 1,
|
|
minimumMargin: 1,
|
|
metrics: routingMetrics(
|
|
observations: observations,
|
|
confidenceThreshold: 1,
|
|
minimumMargin: 1
|
|
)
|
|
)
|
|
}
|
|
|
|
private func gateResult(
|
|
verifier: VerifierID,
|
|
observations: [Observation],
|
|
thresholdsByLanguage: [String: ThresholdSelection],
|
|
globalThreshold: ThresholdSelection
|
|
) -> GateResult {
|
|
var support: [String: Int] = [:]
|
|
var correct: [String: Int] = [:]
|
|
var sourceSupport: [String: [String: Int]] = [:]
|
|
for observation in observations {
|
|
let selection = thresholdsByLanguage[observation.example.language]
|
|
?? globalThreshold
|
|
let routed = observation.predictedLabel != "neither"
|
|
&& observation.confidence >= selection.confidenceThreshold
|
|
&& observation.margin >= selection.minimumMargin
|
|
guard routed else { continue }
|
|
let key = "\(observation.example.language)|\(observation.predictedLabel)"
|
|
support[key, default: 0] += 1
|
|
let source = observation.example.sourceDataset ?? "generated-base"
|
|
sourceSupport[key, default: [:]][source, default: 0] += 1
|
|
if observation.predictedLabel == observation.example.label {
|
|
correct[key, default: 0] += 1
|
|
}
|
|
}
|
|
let precision = Dictionary(uniqueKeysWithValues: support.map { key, count in
|
|
(
|
|
key,
|
|
rounded(Double(correct[key] ?? 0) / Double(max(count, 1)))
|
|
)
|
|
})
|
|
let maximumSourceShare = Dictionary(uniqueKeysWithValues: support.map { key, count in
|
|
let maximum = sourceSupport[key]?.values.max() ?? 0
|
|
return (key, rounded(Double(maximum) / Double(max(count, 1))))
|
|
})
|
|
let requiredKeys = ["en", "zh-Hans"].flatMap { language in
|
|
verifier.labels
|
|
.filter { $0 != "neither" }
|
|
.map { "\(language)|\($0)" }
|
|
}
|
|
let accepted = requiredKeys.allSatisfy {
|
|
support[$0, default: 0] >= 100
|
|
&& precision[$0, default: 0] >= 0.95
|
|
&& maximumSourceShare[$0, default: 1] <= 0.65
|
|
}
|
|
return GateResult(
|
|
accepted: accepted,
|
|
reason: accepted
|
|
? "Every routed label/language meets precision, support, and source-diversity gates."
|
|
: "Shadow only: at least one label/language misses precision, support, or source-diversity gates.",
|
|
minimumPredictedPositivesPerLabelAndLanguage: 100,
|
|
supportByLabelAndLanguage: support,
|
|
precisionByLabelAndLanguage: precision,
|
|
maximumSourceShareByLabelAndLanguage: maximumSourceShare
|
|
)
|
|
}
|
|
|
|
private func writeJSON<T: Encodable>(_ value: T, to url: URL) throws {
|
|
let encoder = JSONEncoder()
|
|
encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]
|
|
try encoder.encode(value).write(to: url, options: .atomic)
|
|
}
|
|
|
|
private func train(
|
|
verifier: VerifierID,
|
|
baseRecords: [BaseRecord],
|
|
silverRecords: [SilverRecord]
|
|
) throws -> (VerifierReport, [String: Any]) {
|
|
let training = examples(
|
|
verifier: verifier,
|
|
baseRecords: baseRecords,
|
|
silverRecords: silverRecords,
|
|
baseSplit: "train",
|
|
silverSplit: "silverTrain"
|
|
)
|
|
let calibration = examples(
|
|
verifier: verifier,
|
|
baseRecords: baseRecords,
|
|
silverRecords: silverRecords,
|
|
baseSplit: "validation",
|
|
silverSplit: "silverCalibration"
|
|
)
|
|
let acceptance = examples(
|
|
verifier: verifier,
|
|
baseRecords: baseRecords,
|
|
silverRecords: silverRecords,
|
|
baseSplit: "golden",
|
|
silverSplit: "silverAcceptance"
|
|
)
|
|
let trainingTexts = balancedTexts(
|
|
examples: training,
|
|
verifier: verifier,
|
|
split: "train"
|
|
)
|
|
let calibrationTexts = balancedTexts(
|
|
examples: calibration,
|
|
verifier: verifier,
|
|
split: "calibration"
|
|
)
|
|
let parameters = MLTextClassifier.ModelParameters(
|
|
validation: .dictionary(calibrationTexts),
|
|
algorithm: .maxEnt(revision: 1)
|
|
)
|
|
let model = try MLTextClassifier(
|
|
trainingData: trainingTexts,
|
|
parameters: parameters
|
|
)
|
|
let modelURL = outputDirectoryURL.appendingPathComponent(verifier.modelFile)
|
|
try model.write(to: modelURL)
|
|
let calibrationObservations = try observations(model: model, examples: calibration)
|
|
let acceptanceObservations = try observations(model: model, examples: acceptance)
|
|
let externalCalibrationObservations = calibrationObservations.filter {
|
|
$0.example.sourceDataset != nil
|
|
}
|
|
let thresholdObservations = externalCalibrationObservations.count >= 40
|
|
? externalCalibrationObservations
|
|
: calibrationObservations
|
|
let globalThreshold = selectedThreshold(observations: thresholdObservations)
|
|
let languages = Set(calibration.map(\.language)).sorted()
|
|
let thresholdsByLanguage = Dictionary(uniqueKeysWithValues: languages.map { language in
|
|
let externalLanguageObservations = thresholdObservations.filter {
|
|
$0.example.language == language
|
|
}
|
|
let languageObservations = externalLanguageObservations.count >= 15
|
|
? externalLanguageObservations
|
|
: calibrationObservations.filter {
|
|
$0.example.language == language
|
|
}
|
|
return (
|
|
language,
|
|
selectedThreshold(
|
|
observations: languageObservations
|
|
)
|
|
)
|
|
})
|
|
let finalAcceptanceMetrics = routingMetrics(
|
|
observations: acceptanceObservations,
|
|
confidenceThreshold: globalThreshold.confidenceThreshold,
|
|
minimumMargin: globalThreshold.minimumMargin
|
|
)
|
|
let acceptanceByLanguage: [String: RoutingMetrics] = Dictionary(
|
|
uniqueKeysWithValues: languages.map { language in
|
|
let threshold = thresholdsByLanguage[language] ?? globalThreshold
|
|
return (
|
|
language,
|
|
routingMetrics(
|
|
observations: acceptanceObservations.filter {
|
|
$0.example.language == language
|
|
},
|
|
confidenceThreshold: threshold.confidenceThreshold,
|
|
minimumMargin: threshold.minimumMargin
|
|
)
|
|
)
|
|
}
|
|
)
|
|
let gate = gateResult(
|
|
verifier: verifier,
|
|
observations: acceptanceObservations,
|
|
thresholdsByLanguage: thresholdsByLanguage,
|
|
globalThreshold: globalThreshold
|
|
)
|
|
let modelBytes = (
|
|
try fileManager.attributesOfItem(atPath: modelURL.path)[.size] as? NSNumber
|
|
)?.intValue ?? 0
|
|
let report = VerifierReport(
|
|
id: verifier.rawValue,
|
|
labels: verifier.labels,
|
|
modelFile: verifier.modelFile,
|
|
modelBytes: modelBytes,
|
|
trainingCount: trainingTexts.values.reduce(0) { $0 + $1.count },
|
|
calibrationCount: calibration.count,
|
|
thresholdCalibrationCount: thresholdObservations.count,
|
|
acceptanceCount: acceptance.count,
|
|
threshold: globalThreshold,
|
|
thresholdsByLanguage: thresholdsByLanguage,
|
|
calibrationMetrics: globalThreshold.metrics,
|
|
acceptanceMetrics: finalAcceptanceMetrics,
|
|
acceptanceByLanguage: acceptanceByLanguage,
|
|
gate: gate
|
|
)
|
|
let configuration: [String: Any] = [
|
|
"id": verifier.rawValue,
|
|
"modelFile": verifier.modelFile,
|
|
"labels": verifier.labels,
|
|
"confidenceThreshold": globalThreshold.confidenceThreshold,
|
|
"confidenceThresholdsByLanguage": thresholdsByLanguage.mapValues {
|
|
$0.confidenceThreshold
|
|
},
|
|
"minimumMargin": globalThreshold.minimumMargin,
|
|
"minimumMarginsByLanguage": thresholdsByLanguage.mapValues {
|
|
$0.minimumMargin
|
|
},
|
|
"acceptedForAutomaticRouting": gate.accepted,
|
|
"deploymentMode": gate.accepted ? "automatic" : "shadow"
|
|
]
|
|
return (report, configuration)
|
|
}
|
|
|
|
private func main() throws {
|
|
try fileManager.createDirectory(
|
|
at: outputDirectoryURL,
|
|
withIntermediateDirectories: true
|
|
)
|
|
let baseRecords = try readJSONLines(BaseRecord.self, from: baseCorpusURL)
|
|
let silverURLs = [
|
|
silverDirectoryURL.appendingPathComponent("silver-train.jsonl"),
|
|
silverDirectoryURL.appendingPathComponent("silver-calibration.jsonl"),
|
|
silverDirectoryURL.appendingPathComponent("silver-acceptance.jsonl")
|
|
]
|
|
let silverRecords = try silverURLs.flatMap {
|
|
try readJSONLines(SilverRecord.self, from: $0)
|
|
}
|
|
var reports: [VerifierReport] = []
|
|
var configurations: [[String: Any]] = []
|
|
for verifier in VerifierID.allCases {
|
|
let (report, configuration) = try train(
|
|
verifier: verifier,
|
|
baseRecords: baseRecords,
|
|
silverRecords: silverRecords
|
|
)
|
|
reports.append(report)
|
|
configurations.append(configuration)
|
|
print(
|
|
"VERIFIER_SELECTED id=\(verifier.rawValue) "
|
|
+ "accepted=\(report.gate.accepted) "
|
|
+ "precision=\(report.acceptanceMetrics.precision)"
|
|
)
|
|
}
|
|
|
|
let generatedAt = ISO8601DateFormatter().string(from: Date())
|
|
let report = TrainingReport(
|
|
generatedAt: generatedAt,
|
|
baseCorpusPath: baseCorpusURL.path,
|
|
silverDirectoryPath: silverDirectoryURL.path,
|
|
selectionPolicy:
|
|
"Thresholds and top-1/top-2 margins are calibrated without acceptance data. "
|
|
+ "Automatic routing requires >=95% exact-route precision and >=100 routed "
|
|
+ "positives for every specialized label in both English and Simplified Chinese. "
|
|
+ "Without human gold, passing values remain consensus-relative.",
|
|
verifiers: reports
|
|
)
|
|
try writeJSON(report, to: reportURL)
|
|
|
|
let manifestData = try Data(contentsOf: baseManifestURL)
|
|
guard var manifest = try JSONSerialization.jsonObject(
|
|
with: manifestData
|
|
) as? [String: Any] else {
|
|
throw NSError(
|
|
domain: "VerifierTraining",
|
|
code: 1,
|
|
userInfo: [NSLocalizedDescriptionKey: "Invalid base manifest"]
|
|
)
|
|
}
|
|
for classifier in manifest["classifiers"] as? [[String: Any]] ?? [] {
|
|
guard let modelFile = classifier["modelFile"] as? String else { continue }
|
|
let source = baseManifestURL.deletingLastPathComponent()
|
|
.appendingPathComponent(modelFile)
|
|
let destination = outputDirectoryURL.appendingPathComponent(modelFile)
|
|
if fileManager.fileExists(atPath: destination.path) {
|
|
try fileManager.removeItem(at: destination)
|
|
}
|
|
try fileManager.copyItem(at: source, to: destination)
|
|
}
|
|
manifest["schemaVersion"] = 3
|
|
manifest["verifiers"] = configurations
|
|
manifest["verifierGeneratedAt"] = generatedAt
|
|
manifest["verifierLabelPolicy"] = "multi-model-consensus-without-human-gold"
|
|
let outputManifestURL = outputDirectoryURL.appendingPathComponent(
|
|
"clipboard-semantic-models.json"
|
|
)
|
|
let outputManifestData = try JSONSerialization.data(
|
|
withJSONObject: manifest,
|
|
options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]
|
|
)
|
|
try outputManifestData.write(to: outputManifestURL, options: .atomic)
|
|
print("VERIFIER_REPORT \(reportURL.path)")
|
|
}
|
|
|
|
do {
|
|
try main()
|
|
} catch {
|
|
fputs("Verifier training failed: \(error)\n", stderr)
|
|
exit(1)
|
|
}
|