#!/usr/bin/env xcrun swift import CreateML import Foundation private struct CorpusRecord: Codable { let id: String let text: String let language: String let split: String let family: String let task: Bool let question: Bool let invitation: Bool let complaint: Bool let sentiment: String let replyable: Bool } private struct BinaryMetrics: Codable { let total: Int let truePositive: Int let trueNegative: Int let falsePositive: Int let falseNegative: Int let accuracy: Double let precision: Double let recall: Double let f1: Double } private struct MulticlassMetrics: Codable { let total: Int let accuracy: Double let macroF1: Double let perLabel: [String: BinaryMetrics] let confusion: [String: [String: Int]] } private struct CandidateReport: Codable { let algorithm: String let modelBytes: Int let runtimeAssetIndependent: Bool let balancedTrainingCount: Int let balancedValidationCount: Int let threshold: Double? let acceptedForAutomaticRouting: Bool let validationBinary: BinaryMetrics? let testBinary: BinaryMetrics? let goldenBinary: BinaryMetrics? let testFalsePositiveExamples: [String]? let testFalseNegativeExamples: [String]? let goldenFalsePositiveExamples: [String]? let goldenFalseNegativeExamples: [String]? let binaryByLanguage: [String: BinaryMetrics]? let validationMulticlass: MulticlassMetrics? let testMulticlass: MulticlassMetrics? let goldenMulticlass: MulticlassMetrics? let multiclassByLanguage: [String: MulticlassMetrics]? } private struct ClassifierReport: Codable { let id: String let labels: [String] let positiveLabel: String? let selectedAlgorithm: String let selectedModelFile: String let candidates: [CandidateReport] } private struct TrainingReport: Codable { let generatedAt: String let corpusPath: String let corpusCount: Int let trainingCount: Int let validationCount: Int let testCount: Int let goldenCount: Int let selectionPolicy: String let classifiers: [ClassifierReport] } private struct ManifestClassifier: Codable { let id: String let modelFile: String let algorithm: String let labels: [String] let positiveLabel: String? let confidenceThreshold: Double? let acceptedForAutomaticRouting: Bool } private struct ModelManifest: Codable { let schemaVersion: Int let generatedAt: String let corpusRecordCount: Int let classifiers: [ManifestClassifier] } private enum CandidateAlgorithm: String, CaseIterable { case maxEnt case bert var fileSuffix: String { switch self { case .maxEnt: "maxent" case .bert: "bert" } } var createMLAlgorithm: MLTextClassifier.ModelAlgorithmType { switch self { case .maxEnt: return .maxEnt(revision: 1) case .bert: return .transferLearning(.bertEmbedding, revision: 1) } } } private enum ClassifierID: String, CaseIterable { case task case question case invitation case complaint case replyableMessage case sentiment var resourceName: String { switch self { case .task: "TaskIntentClassifier" case .question: "QuestionIntentClassifier" case .invitation: "InvitationIntentClassifier" case .complaint: "ComplaintIntentClassifier" case .replyableMessage: "ConversationalReplyIntentClassifier" case .sentiment: "SentimentClassifier" } } var labels: [String] { switch self { case .task: ["notTask", "task"] case .question: ["notQuestion", "question"] case .invitation: ["notInvitation", "invitation"] case .complaint: ["notComplaint", "complaint"] case .replyableMessage: ["notReplyableMessage", "replyableMessage"] case .sentiment: ["negative", "neutral", "positive"] } } var positiveLabel: String? { switch self { case .task: "task" case .question: "question" case .invitation: "invitation" case .complaint: "complaint" case .replyableMessage: "replyableMessage" case .sentiment: nil } } func label(for record: CorpusRecord) -> String { switch self { case .task: record.task ? "task" : "notTask" case .question: record.question ? "question" : "notQuestion" case .invitation: record.invitation ? "invitation" : "notInvitation" case .complaint: record.complaint ? "complaint" : "notComplaint" case .replyableMessage: record.replyable ? "replyableMessage" : "notReplyableMessage" case .sentiment: record.sentiment } } } 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 struct TrainedCandidate { let algorithm: CandidateAlgorithm let modelURL: URL let report: CandidateReport } private let fileManager = FileManager.default private let repositoryRoot = URL(fileURLWithPath: fileManager.currentDirectoryPath) private let corpusURL = repositoryRoot .appendingPathComponent("ModelTraining/ClipboardSemantics/clipboard_semantic_corpus.jsonl") private let candidateDirectory = repositoryRoot .appendingPathComponent("ModelTraining/ClipboardSemantics/Candidates") private let resourceDirectory = repositoryRoot .appendingPathComponent("OSGKeyboardShared/Resources/ClipboardSemantics") private let reportURL = repositoryRoot .appendingPathComponent("ModelTraining/ClipboardSemantics/evaluation-report.json") private func loadCorpus() throws -> [CorpusRecord] { let content = try String(contentsOf: corpusURL, encoding: .utf8) let decoder = JSONDecoder() return try content.split(separator: "\n").map { line in try decoder.decode(CorpusRecord.self, from: Data(line.utf8)) } } private func stableSeed(for classifier: ClassifierID, split: String) -> UInt64 { let material = "\(classifier.rawValue)|\(split)|20260821" return material.utf8.reduce(0xcbf2_9ce4_8422_2325) { partial, byte in (partial ^ UInt64(byte)) &* 0x0000_0100_0000_01B3 } } private func balancedTexts( records: [CorpusRecord], classifier: ClassifierID, split: String ) -> [String: [String]] { let grouped = Dictionary(grouping: records) { classifier.label(for: $0) } let requiredLabels = classifier.labels let minimumCount = requiredLabels .compactMap { grouped[$0]?.count } .min() ?? 0 precondition(minimumCount > 0, "Missing labels for \(classifier.rawValue) \(split)") var result: [String: [String]] = [:] for (offset, label) in requiredLabels.enumerated() { var generator = SeededGenerator( seed: stableSeed(for: classifier, split: split) &+ UInt64(offset) ) let texts = (grouped[label] ?? []) .map(\.text) .shuffled(using: &generator) result[label] = Array(texts.prefix(minimumCount)) } return result } private func totalCount(_ dictionary: [String: [String]]) -> Int { dictionary.values.reduce(0) { $0 + $1.count } } private func rounded(_ value: Double) -> Double { guard value.isFinite else { return 0 } return (value * 10_000).rounded() / 10_000 } private func binaryMetrics( records: [CorpusRecord], classifier: ClassifierID, positiveLabel: String, threshold: Double, scores: [Double] ) -> BinaryMetrics { precondition(records.count == scores.count) var truePositive = 0 var trueNegative = 0 var falsePositive = 0 var falseNegative = 0 for (record, score) in zip(records, scores) { let expectedPositive = classifier.label(for: record) == positiveLabel let predictedPositive = score >= threshold switch (expectedPositive, predictedPositive) { case (true, true): truePositive += 1 case (false, false): trueNegative += 1 case (false, true): falsePositive += 1 case (true, false): falseNegative += 1 } } let total = records.count let precisionDenominator = truePositive + falsePositive let recallDenominator = truePositive + falseNegative let precision = precisionDenominator > 0 ? Double(truePositive) / Double(precisionDenominator) : 0 let recall = recallDenominator > 0 ? Double(truePositive) / Double(recallDenominator) : 0 let f1 = precision + recall > 0 ? 2 * precision * recall / (precision + recall) : 0 return BinaryMetrics( total: total, truePositive: truePositive, trueNegative: trueNegative, falsePositive: falsePositive, falseNegative: falseNegative, accuracy: rounded( total > 0 ? Double(truePositive + trueNegative) / Double(total) : 0 ), precision: rounded(precision), recall: rounded(recall), f1: rounded(f1) ) } private func scores( classifier: MLTextClassifier, records: [CorpusRecord], positiveLabel: String ) throws -> [Double] { try records.map { record in try classifier.predictionWithConfidence(from: record.text)[positiveLabel] ?? 0 } } private func binaryErrorExamples( records: [CorpusRecord], classifier: ClassifierID, positiveLabel: String, threshold: Double, scores: [Double], expectedPositive: Bool, predictedPositive: Bool, limit: Int = 12 ) -> [String] { zip(records, scores).compactMap { record, score -> String? in let isExpectedPositive = classifier.label(for: record) == positiveLabel let isPredictedPositive = score >= threshold guard isExpectedPositive == expectedPositive, isPredictedPositive == predictedPositive else { return nil } return "[\(record.language)] \(record.text) (score=\(rounded(score)))" } .prefix(limit) .map { $0 } } private func calibratedThreshold( records: [CorpusRecord], classifierID: ClassifierID, positiveLabel: String, scores: [Double] ) -> (threshold: Double, metrics: BinaryMetrics) { var candidates: [(Double, BinaryMetrics)] = [] // Low-confidence positives are too unstable for automatic keyboard // routing even when a synthetic validation split happens to accept them. for integer in 60...99 { let threshold = Double(integer) / 100 candidates.append( ( threshold, binaryMetrics( records: records, classifier: classifierID, positiveLabel: positiveLabel, threshold: threshold, scores: scores ) ) ) } let highPrecision = candidates.filter { $0.1.precision >= 0.97 } if let best = highPrecision.max(by: { if $0.1.recall == $1.1.recall { return $0.1.precision < $1.1.precision } return $0.1.recall < $1.1.recall }) { return best } return candidates.max(by: { $0.1.f1 < $1.1.f1 }) ?? (0.50, binaryMetrics( records: records, classifier: classifierID, positiveLabel: positiveLabel, threshold: 0.50, scores: scores )) } private func multiclassMetrics( records: [CorpusRecord], classifierID: ClassifierID, predictions: [String] ) -> MulticlassMetrics { precondition(records.count == predictions.count) let labels = classifierID.labels var confusion = Dictionary( uniqueKeysWithValues: labels.map { expected in (expected, Dictionary(uniqueKeysWithValues: labels.map { ($0, 0) })) } ) for (record, predicted) in zip(records, predictions) { let expected = classifierID.label(for: record) confusion[expected, default: [:]][predicted, default: 0] += 1 } var perLabel: [String: BinaryMetrics] = [:] for label in labels { let binaryRecords = records.enumerated().map { index, record in (expected: classifierID.label(for: record) == label, predicted: predictions[index] == label) } let truePositive = binaryRecords.filter { $0.expected && $0.predicted }.count let trueNegative = binaryRecords.filter { !$0.expected && !$0.predicted }.count let falsePositive = binaryRecords.filter { !$0.expected && $0.predicted }.count let falseNegative = binaryRecords.filter { $0.expected && !$0.predicted }.count let precision = truePositive + falsePositive > 0 ? Double(truePositive) / Double(truePositive + falsePositive) : 0 let recall = truePositive + falseNegative > 0 ? Double(truePositive) / Double(truePositive + falseNegative) : 0 let f1 = precision + recall > 0 ? 2 * precision * recall / (precision + recall) : 0 perLabel[label] = BinaryMetrics( total: records.count, truePositive: truePositive, trueNegative: trueNegative, falsePositive: falsePositive, falseNegative: falseNegative, accuracy: rounded( records.isEmpty ? 0 : Double(truePositive + trueNegative) / Double(records.count) ), precision: rounded(precision), recall: rounded(recall), f1: rounded(f1) ) } let correct = zip(records, predictions).filter { classifierID.label(for: $0.0) == $0.1 }.count let macroF1 = labels.compactMap { perLabel[$0]?.f1 }.reduce(0, +) / Double(labels.count) return MulticlassMetrics( total: records.count, accuracy: rounded(records.isEmpty ? 0 : Double(correct) / Double(records.count)), macroF1: rounded(macroF1), perLabel: perLabel, confusion: confusion ) } private func modelFileSize(at url: URL) -> Int { let attributes = try? fileManager.attributesOfItem(atPath: url.path) return attributes?[.size] as? Int ?? 0 } private func train( classifierID: ClassifierID, algorithm: CandidateAlgorithm, trainingRecords: [CorpusRecord], validationRecords: [CorpusRecord], testRecords: [CorpusRecord], goldenRecords: [CorpusRecord] ) throws -> TrainedCandidate { let trainingTexts = balancedTexts( records: trainingRecords, classifier: classifierID, split: "train" ) let validationTexts = balancedTexts( records: validationRecords, classifier: classifierID, split: "validation" ) var parameters = MLTextClassifier.ModelParameters( validation: .dictionary(validationTexts), algorithm: algorithm.createMLAlgorithm, language: nil ) parameters.maxIterations = algorithm == .bert ? 20 : 50 print( "TRAIN_BEGIN classifier=\(classifierID.rawValue) " + "algorithm=\(algorithm.rawValue) samples=\(totalCount(trainingTexts))" ) let classifier = try MLTextClassifier( trainingData: trainingTexts, parameters: parameters ) try fileManager.createDirectory( at: candidateDirectory, withIntermediateDirectories: true ) let modelURL = candidateDirectory.appendingPathComponent( "\(classifierID.resourceName)-\(algorithm.fileSuffix).mlmodel" ) if fileManager.fileExists(atPath: modelURL.path) { try fileManager.removeItem(at: modelURL) } let metadata = MLModelMetadata( author: "OSGKeyboard", shortDescription: "Local clipboard \(classifierID.rawValue) classifier", license: nil, version: "1.0.0", additional: [ "Corpus": "Synthetic bilingual clipboard semantics corpus", "ContainsUserClipboardData": "false", "Algorithm": algorithm.rawValue ] ) try classifier.write(to: modelURL, metadata: metadata) let report: CandidateReport if let positiveLabel = classifierID.positiveLabel { let validationScores = try scores( classifier: classifier, records: validationRecords, positiveLabel: positiveLabel ) let calibration = calibratedThreshold( records: validationRecords, classifierID: classifierID, positiveLabel: positiveLabel, scores: validationScores ) let testScores = try scores( classifier: classifier, records: testRecords, positiveLabel: positiveLabel ) let testMetrics = binaryMetrics( records: testRecords, classifier: classifierID, positiveLabel: positiveLabel, threshold: calibration.threshold, scores: testScores ) let goldenScores = try scores( classifier: classifier, records: goldenRecords, positiveLabel: positiveLabel ) let goldenMetrics = binaryMetrics( records: goldenRecords, classifier: classifierID, positiveLabel: positiveLabel, threshold: calibration.threshold, scores: goldenScores ) var byLanguage: [String: BinaryMetrics] = [:] for language in Set(testRecords.map(\.language)).sorted() { let indexed = testRecords.enumerated().filter { $0.element.language == language } let records = indexed.map(\.element) let languageScores = indexed.map { testScores[$0.offset] } byLanguage[language] = binaryMetrics( records: records, classifier: classifierID, positiveLabel: positiveLabel, threshold: calibration.threshold, scores: languageScores ) } report = CandidateReport( algorithm: algorithm.rawValue, modelBytes: modelFileSize(at: modelURL), runtimeAssetIndependent: algorithm == .maxEnt, balancedTrainingCount: totalCount(trainingTexts), balancedValidationCount: totalCount(validationTexts), threshold: rounded(calibration.threshold), acceptedForAutomaticRouting: algorithm == .maxEnt && calibration.metrics.precision >= 0.97 && testMetrics.precision >= 0.90 && goldenMetrics.precision >= 0.90, validationBinary: calibration.metrics, testBinary: testMetrics, goldenBinary: goldenMetrics, testFalsePositiveExamples: binaryErrorExamples( records: testRecords, classifier: classifierID, positiveLabel: positiveLabel, threshold: calibration.threshold, scores: testScores, expectedPositive: false, predictedPositive: true ), testFalseNegativeExamples: binaryErrorExamples( records: testRecords, classifier: classifierID, positiveLabel: positiveLabel, threshold: calibration.threshold, scores: testScores, expectedPositive: true, predictedPositive: false ), goldenFalsePositiveExamples: binaryErrorExamples( records: goldenRecords, classifier: classifierID, positiveLabel: positiveLabel, threshold: calibration.threshold, scores: goldenScores, expectedPositive: false, predictedPositive: true ), goldenFalseNegativeExamples: binaryErrorExamples( records: goldenRecords, classifier: classifierID, positiveLabel: positiveLabel, threshold: calibration.threshold, scores: goldenScores, expectedPositive: true, predictedPositive: false ), binaryByLanguage: byLanguage, validationMulticlass: nil, testMulticlass: nil, goldenMulticlass: nil, multiclassByLanguage: nil ) } else { let validationPredictions = try classifier.predictions( from: validationRecords.map(\.text) ) let testPredictions = try classifier.predictions( from: testRecords.map(\.text) ) let validationMetrics = multiclassMetrics( records: validationRecords, classifierID: classifierID, predictions: validationPredictions ) let testMetrics = multiclassMetrics( records: testRecords, classifierID: classifierID, predictions: testPredictions ) let goldenPredictions = try classifier.predictions( from: goldenRecords.map(\.text) ) let goldenMetrics = multiclassMetrics( records: goldenRecords, classifierID: classifierID, predictions: goldenPredictions ) var byLanguage: [String: MulticlassMetrics] = [:] for language in Set(testRecords.map(\.language)).sorted() { let indexed = testRecords.enumerated().filter { $0.element.language == language } byLanguage[language] = multiclassMetrics( records: indexed.map(\.element), classifierID: classifierID, predictions: indexed.map { testPredictions[$0.offset] } ) } report = CandidateReport( algorithm: algorithm.rawValue, modelBytes: modelFileSize(at: modelURL), runtimeAssetIndependent: algorithm == .maxEnt, balancedTrainingCount: totalCount(trainingTexts), balancedValidationCount: totalCount(validationTexts), threshold: nil, acceptedForAutomaticRouting: algorithm == .maxEnt && validationMetrics.macroF1 >= 0.85 && testMetrics.macroF1 >= 0.85 && goldenMetrics.macroF1 >= 0.75, validationBinary: nil, testBinary: nil, goldenBinary: nil, testFalsePositiveExamples: nil, testFalseNegativeExamples: nil, goldenFalsePositiveExamples: nil, goldenFalseNegativeExamples: nil, binaryByLanguage: nil, validationMulticlass: validationMetrics, testMulticlass: testMetrics, goldenMulticlass: goldenMetrics, multiclassByLanguage: byLanguage ) } print( "TRAIN_DONE classifier=\(classifierID.rawValue) " + "algorithm=\(algorithm.rawValue) bytes=\(report.modelBytes)" ) return TrainedCandidate( algorithm: algorithm, modelURL: modelURL, report: report ) } private func selectionScore(_ candidate: TrainedCandidate) -> (Int, Double, Double) { if let binary = candidate.report.validationBinary { return ( candidate.report.acceptedForAutomaticRouting ? 2 : (candidate.report.runtimeAssetIndependent ? 1 : 0), binary.recall, binary.precision ) } if let multiclass = candidate.report.validationMulticlass { return ( candidate.report.acceptedForAutomaticRouting ? 2 : (candidate.report.runtimeAssetIndependent ? 1 : 0), multiclass.macroF1, multiclass.accuracy ) } return (0, 0, 0) } private func isBetter(_ lhs: TrainedCandidate, than rhs: TrainedCandidate) -> Bool { let left = selectionScore(lhs) let right = selectionScore(rhs) if left.0 != right.0 { return left.0 > right.0 } if left.1 != right.1 { return left.1 > right.1 } if left.2 != right.2 { return left.2 > right.2 } return lhs.report.modelBytes < rhs.report.modelBytes } private func selectedAlgorithms() -> [CandidateAlgorithm] { guard let index = CommandLine.arguments.firstIndex(of: "--algorithms"), CommandLine.arguments.indices.contains(index + 1) else { return CandidateAlgorithm.allCases } let requested = Set( CommandLine.arguments[index + 1] .split(separator: ",") .map(String.init) ) return CandidateAlgorithm.allCases.filter { requested.contains($0.rawValue) } } private func selectedClassifiers() -> [ClassifierID] { guard let index = CommandLine.arguments.firstIndex(of: "--classifiers"), CommandLine.arguments.indices.contains(index + 1) else { return ClassifierID.allCases } let requested = Set( CommandLine.arguments[index + 1] .split(separator: ",") .map(String.init) ) return ClassifierID.allCases.filter { requested.contains($0.rawValue) } } private func writeJSON(_ value: T, to url: URL) throws { let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] let data = try encoder.encode(value) try fileManager.createDirectory( at: url.deletingLastPathComponent(), withIntermediateDirectories: true ) try data.write(to: url, options: .atomic) } private func main() throws { let records = try loadCorpus() let trainingRecords = records.filter { $0.split == "train" } let validationRecords = records.filter { $0.split == "validation" } let testRecords = records.filter { $0.split == "test" } let goldenRecords = records.filter { $0.split == "golden" } let algorithms = selectedAlgorithms() let classifiers = selectedClassifiers() precondition(!algorithms.isEmpty, "No supported algorithms requested") precondition(!classifiers.isEmpty, "No supported classifiers requested") try fileManager.createDirectory( at: resourceDirectory, withIntermediateDirectories: true ) let generatedAt = ISO8601DateFormatter().string(from: Date()) var classifierReports: [ClassifierReport] = [] var manifestClassifiers: [ManifestClassifier] = [] for classifierID in classifiers { var candidates: [TrainedCandidate] = [] for algorithm in algorithms { do { candidates.append( try train( classifierID: classifierID, algorithm: algorithm, trainingRecords: trainingRecords, validationRecords: validationRecords, testRecords: testRecords, goldenRecords: goldenRecords ) ) } catch { print( "TRAIN_FAILED classifier=\(classifierID.rawValue) " + "algorithm=\(algorithm.rawValue) error=\(error)" ) } } guard let selected = candidates.max(by: { isBetter($1, than: $0) }) else { throw NSError( domain: "ClipboardSemanticTraining", code: 1, userInfo: [ NSLocalizedDescriptionKey: "All training candidates failed for \(classifierID.rawValue)" ] ) } let selectedURL = resourceDirectory.appendingPathComponent( "\(classifierID.resourceName).mlmodel" ) if fileManager.fileExists(atPath: selectedURL.path) { try fileManager.removeItem(at: selectedURL) } try fileManager.copyItem(at: selected.modelURL, to: selectedURL) classifierReports.append( ClassifierReport( id: classifierID.rawValue, labels: classifierID.labels, positiveLabel: classifierID.positiveLabel, selectedAlgorithm: selected.algorithm.rawValue, selectedModelFile: selectedURL.lastPathComponent, candidates: candidates.map(\.report) ) ) manifestClassifiers.append( ManifestClassifier( id: classifierID.rawValue, modelFile: selectedURL.lastPathComponent, algorithm: selected.algorithm.rawValue, labels: classifierID.labels, positiveLabel: classifierID.positiveLabel, confidenceThreshold: selected.report.threshold, acceptedForAutomaticRouting: selected.report.acceptedForAutomaticRouting ) ) print( "SELECTED classifier=\(classifierID.rawValue) " + "algorithm=\(selected.algorithm.rawValue)" ) } let report = TrainingReport( generatedAt: generatedAt, corpusPath: "ModelTraining/ClipboardSemantics/clipboard_semantic_corpus.jsonl", corpusCount: records.count, trainingCount: trainingRecords.count, validationCount: validationRecords.count, testCount: testRecords.count, goldenCount: goldenRecords.count, selectionPolicy: "Validation only: binary models require precision >= 0.97, then maximize recall; " + "sentiment prioritizes macro-F1. Automatic routing also requires a self-contained " + "maxEnt model because BERT embedding assets are not guaranteed in extensions. " + "Test and golden data gate deployment but never tune model weights.", classifiers: classifierReports ) try writeJSON(report, to: reportURL) try writeJSON( ModelManifest( schemaVersion: 1, generatedAt: generatedAt, corpusRecordCount: records.count, classifiers: manifestClassifiers ), to: resourceDirectory.appendingPathComponent( "clipboard-semantic-models.json" ) ) print("TRAINING_REPORT \(reportURL.path)") } do { try main() } catch { fputs("Training failed: \(error)\n", stderr) exit(1) }