From c81044e56641aeb336b30197b2f7095f6fcf4c83 Mon Sep 17 00:00:00 2001 From: Alex-Wengg Date: Fri, 25 Sep 2026 14:26:42 -0400 Subject: [PATCH 1/2] Add GLiNER2.5-Decide support and Sort Anything / Sort Decisions demos GLiNER2Manager learns the Decide packages from FluidInference/gliner2-5-decide-coreml: `.decide` (128 tokens) and `.decideLong` (256 tokens), up to 4 heads x 32 labels per call. Adds a multi-head schema sequence ([SEP_STRUCT] between heads, as GLiNER's SchemaTransformer builds it) and classifyConcurrently(text:heads:), which uses per-call inputs and Core ML's async prediction so several calls can be in flight on one model. Single-head sequences are unchanged (all saved base and multilingual fixtures still match). The fp16 packages are pinned rather than W8: on the GPU the W8 packages trigger a load-time preparation that peaked at 8.7 GB and added about 8 s of load, while fp16 peaks near 1 GB. SortAnythingDemo sorts 1,000 DBpedia-14 test abstracts into categories editable at run time (Show: paced flying cards; Turbo: 4 calls in flight). SortDecisionsDemo streams Fastino's Fast Decisions dev split with every head of a document answered in one call. Both fetch their data from Hugging Face at pinned revisions and cache it; nothing is bundled. Headless checks on an M5 Pro: 1,000 DBpedia items in about 5.9 s at 89.0%, identical to the gliner2 PyTorch release on all 1,000; Fast Decisions 62.7% average, identical to the Python Core ML path. --- Package.swift | 5 + Sources/FluidUse/GLiNER2/GLiNER2Manager.swift | 147 ++++++-- .../FluidUse/GLiNER2/GLiNER2ModelStore.swift | 47 ++- .../FluidUse/GLiNER2/GLiNER2Tokenizer.swift | 37 +- Sources/FluidUse/GLiNER2/GLiNER2Types.swift | 28 ++ Sources/SortAnything/DBpediaSample.swift | 105 ++++++ Sources/SortAnything/FastDecisions.swift | 138 +++++++ Sources/SortAnything/Sorter.swift | 53 +++ Sources/SortAnythingCheck/main.swift | 66 ++++ Sources/SortAnythingDemo/ContentView.swift | 350 ++++++++++++++++++ Sources/SortAnythingDemo/README.md | 26 ++ .../SortAnythingDemoApp.swift | 29 ++ Sources/SortAnythingDemo/SortModel.swift | 322 ++++++++++++++++ Sources/SortDecisionsCheck/main.swift | 64 ++++ Sources/SortDecisionsDemo/ContentView.swift | 198 ++++++++++ .../SortDecisionsDemo/DecisionsModel.swift | 236 ++++++++++++ Sources/SortDecisionsDemo/README.md | 17 + .../SortDecisionsDemoApp.swift | 25 ++ .../GLiNER2IntegrationTests.swift | 8 + .../FluidUseTests/GLiNER2TokenizerTests.swift | 2 +- 20 files changed, 1856 insertions(+), 47 deletions(-) create mode 100644 Sources/SortAnything/DBpediaSample.swift create mode 100644 Sources/SortAnything/FastDecisions.swift create mode 100644 Sources/SortAnything/Sorter.swift create mode 100644 Sources/SortAnythingCheck/main.swift create mode 100644 Sources/SortAnythingDemo/ContentView.swift create mode 100644 Sources/SortAnythingDemo/README.md create mode 100644 Sources/SortAnythingDemo/SortAnythingDemoApp.swift create mode 100644 Sources/SortAnythingDemo/SortModel.swift create mode 100644 Sources/SortDecisionsCheck/main.swift create mode 100644 Sources/SortDecisionsDemo/ContentView.swift create mode 100644 Sources/SortDecisionsDemo/DecisionsModel.swift create mode 100644 Sources/SortDecisionsDemo/README.md create mode 100644 Sources/SortDecisionsDemo/SortDecisionsDemoApp.swift diff --git a/Package.swift b/Package.swift index 7ab7069..1b032aa 100644 --- a/Package.swift +++ b/Package.swift @@ -45,6 +45,11 @@ let package = Package( dependencies: ["FluidUse", "Game2048"], exclude: ["README.md"] ), + .target(name: "SortAnything", dependencies: ["FluidUse"]), + .executableTarget(name: "SortAnythingCheck", dependencies: ["SortAnything"]), + .executableTarget(name: "SortDecisionsCheck", dependencies: ["SortAnything"]), + .executableTarget(name: "SortDecisionsDemo", dependencies: ["SortAnything"], exclude: ["README.md"]), + .executableTarget(name: "SortAnythingDemo", dependencies: ["SortAnything"], exclude: ["README.md"]), .testTarget( name: "FluidUseTests", dependencies: ["FluidUse", "LayaTetris"], resources: [.copy("Fixtures")] diff --git a/Sources/FluidUse/GLiNER2/GLiNER2Manager.swift b/Sources/FluidUse/GLiNER2/GLiNER2Manager.swift index 420dc43..4417547 100644 --- a/Sources/FluidUse/GLiNER2/GLiNER2Manager.swift +++ b/Sources/FluidUse/GLiNER2/GLiNER2Manager.swift @@ -1,27 +1,36 @@ @preconcurrency import CoreML import Foundation -/// On-device, dynamic-label classification with GLiNER 2.5 base or multilingual. +/// On-device, dynamic-label classification with GLiNER 2.5 small, base, multilingual, or Decide. public actor GLiNER2Manager { - public static let maximumLength = 128 - public static let maximumOptions = 8 + public nonisolated let maximumLength: Int + public nonisolated let maximumOptions: Int - private let model: MLModel - private let tokenizer: GLiNER2Tokenizer + private nonisolated let markerShape: [Int] + private nonisolated let model: MLModel + private nonisolated let tokenizer: GLiNER2Tokenizer private let inputIds: MLMultiArray private let attentionMask: MLMultiArray private let markerIndices: MLMultiArray private let markerMask: MLMultiArray private let features: MLDictionaryFeatureProvider - public init(model: MLModel, tokenizer: GLiNER2Tokenizer) throws { - try Self.validate(model.modelDescription) + public init(model: MLModel, tokenizer: GLiNER2Tokenizer, variant: GLiNER2Variant = .base) throws { + maximumLength = variant.maximumLength + maximumOptions = variant.maximumOptions + markerShape = variant.markerShape + try Self.validate(model.modelDescription, length: maximumLength, markerShape: markerShape) self.model = model self.tokenizer = tokenizer - inputIds = try MLMultiArray(shape: [1, 128], dataType: .int32) - attentionMask = try MLMultiArray(shape: [1, 128], dataType: .int32) - markerIndices = try MLMultiArray(shape: [1, 8], dataType: .int32) - markerMask = try MLMultiArray(shape: [1, 8], dataType: .float32) + let length = NSNumber(value: maximumLength) + let markers = markerShape.map { NSNumber(value: $0) } + inputIds = try MLMultiArray(shape: [1, length], dataType: .int32) + attentionMask = try MLMultiArray(shape: [1, length], dataType: .int32) + // Zero-filled, so every head after the first stays masked. + markerIndices = try MLMultiArray(shape: markers, dataType: .int32) + markerMask = try MLMultiArray(shape: markers, dataType: .float32) + markerIndices.dataPointer.initializeMemory(as: Int32.self, repeating: 0, count: markerIndices.count) + markerMask.dataPointer.initializeMemory(as: Float.self, repeating: 0, count: markerMask.count) features = try MLDictionaryFeatureProvider(dictionary: [ "input_ids": MLFeatureValue(multiArray: inputIds), "attention_mask": MLFeatureValue(multiArray: attentionMask), @@ -30,7 +39,7 @@ public actor GLiNER2Manager { ]) } - /// Download and load the published W8 classification package. + /// Download and load the variant's published classification package. public static func load( variant: GLiNER2Variant, cacheDirectory: URL? = nil, computeUnits: MLComputeUnits = .all, progress: GLiNER2ModelStore.Progress? = nil @@ -44,9 +53,9 @@ public actor GLiNER2Manager { public static func load( from directory: URL, variant: GLiNER2Variant, computeUnits: MLComputeUnits = .all ) async throws -> GLiNER2Manager { - let tokenizerURL = directory.appendingPathComponent("tokenizer/tokenizer.json") + let tokenizerURL = directory.appendingPathComponent(variant.tokenizerPath) guard FileManager.default.fileExists(atPath: tokenizerURL.path) else { - throw GLiNER2Error.invalidAsset("Missing tokenizer/tokenizer.json") + throw GLiNER2Error.invalidAsset("Missing \(variant.tokenizerPath)") } let tokenizer = try GLiNER2Tokenizer(tokenizerJsonURL: tokenizerURL) let package = directory.appendingPathComponent(variant.packageName) @@ -63,22 +72,23 @@ public actor GLiNER2Manager { let configuration = MLModelConfiguration() configuration.computeUnits = computeUnits let model = try await MLModel.load(contentsOf: modelURL, configuration: configuration) - return try GLiNER2Manager(model: model, tokenizer: tokenizer) + return try GLiNER2Manager(model: model, tokenizer: tokenizer, variant: variant) } - /// Classify `text` against 1–8 labels using the checkpoint's native schema format. + /// Classify `text` against 1…`maximumOptions` labels using the checkpoint's native schema format. public func classify(text: String, task: String, labels: [String]) throws -> GLiNER2Answer { try Task.checkCancellation() - guard (1...Self.maximumOptions).contains(labels.count) else { - throw GLiNER2Error.invalidInput("Expected 1–8 labels; received \(labels.count)") + guard (1...maximumOptions).contains(labels.count) else { + throw GLiNER2Error.invalidInput("Expected 1–\(maximumOptions) labels; received \(labels.count)") } guard !task.isEmpty else { throw GLiNER2Error.invalidInput("Task must be nonempty") } guard !labels.contains(where: \.isEmpty) else { throw GLiNER2Error.invalidInput("Labels must be nonempty") } let sequence = try tokenizer.classificationSequence(text: text, task: task, labels: labels) - guard sequence.ids.count <= Self.maximumLength else { - throw GLiNER2Error.invalidInput("Schema and text require \(sequence.ids.count) tokens; maximum is 128") + guard sequence.ids.count <= maximumLength else { + throw GLiNER2Error.invalidInput( + "Schema and text require \(sequence.ids.count) tokens; maximum is \(maximumLength)") } guard sequence.markers.count == labels.count else { throw GLiNER2Error.invalidInput("A label marker was lost during tokenization") @@ -88,6 +98,79 @@ public actor GLiNER2Manager { labels: labels, probabilities: values.probabilities, logits: values.logits, tokenCount: sequence.ids.count) } + /// Same result as `classify`, but with per-call inputs and Core ML's async prediction, so several calls can be + /// in flight on one model at once (the async API is thread-safe; the synchronous one is not). + public nonisolated func classifyConcurrently( + text: String, task: String, labels: [String] + ) async throws -> GLiNER2Answer { + try await classifyConcurrently(text: text, heads: [(task, labels)])[0] + } + + /// Answers up to `maximumHeads` classification heads over `text` in one prediction, in the order given. + public nonisolated func classifyConcurrently( + text: String, heads: [(task: String, labels: [String])] + ) async throws -> [GLiNER2Answer] { + let headCount = markerShape.count == 3 ? markerShape[1] : 1 + guard (1...headCount).contains(heads.count) else { + throw GLiNER2Error.invalidInput("Expected 1–\(headCount) heads; received \(heads.count)") + } + for head in heads { + guard (1...maximumOptions).contains(head.labels.count), !head.task.isEmpty, + !head.labels.contains(where: \.isEmpty) + else { + throw GLiNER2Error.invalidInput("Each head needs a task and 1–\(maximumOptions) nonempty labels") + } + } + let sequence = try tokenizer.classificationSequence(text: text, heads: heads) + guard sequence.ids.count <= maximumLength else { + throw GLiNER2Error.invalidInput( + "Schema and text require \(sequence.ids.count) tokens; maximum is \(maximumLength)") + } + guard zip(sequence.markers, heads).allSatisfy({ $0.count == $1.labels.count }) else { + throw GLiNER2Error.invalidInput("A label marker was lost during tokenization") + } + let length = NSNumber(value: maximumLength) + let shape = markerShape.map { NSNumber(value: $0) } + let ids = try MLMultiArray(shape: [1, length], dataType: .int32) + let attention = try MLMultiArray(shape: [1, length], dataType: .int32) + let markers = try MLMultiArray(shape: shape, dataType: .int32) + let mask = try MLMultiArray(shape: shape, dataType: .float32) + let idPointer = ids.dataPointer.assumingMemoryBound(to: Int32.self) + let attentionPointer = attention.dataPointer.assumingMemoryBound(to: Int32.self) + for index in 0.. (logits: [Float], probabilities: [Float]) { let idPointer = inputIds.dataPointer.assumingMemoryBound(to: Int32.self) let attentionPointer = attentionMask.dataPointer.assumingMemoryBound(to: Int32.self) - for index in 0.. [Float] { + private nonisolated func read( + _ name: String, from output: MLFeatureProvider, count: Int? = nil + ) throws -> [Float] { guard let array = output.featureValue(for: name)?.multiArrayValue, - array.shape.map(\.intValue) == [1, Self.maximumOptions], array.dataType == .float32 - else { throw GLiNER2Error.invalidOutput("\(name) must be float32 [1, 8]") } + array.shape.map(\.intValue) == markerShape, array.dataType == .float32 + else { throw GLiNER2Error.invalidOutput("\(name) must be float32 \(markerShape)") } let pointer = array.dataPointer.assumingMemoryBound(to: Float.self) - return (0.. Void @@ -15,6 +15,7 @@ public enum GLiNER2ModelStore { case .small: "9dcac8a315ca49e71412cf5dec2ee7b7609b614e" case .base: "c1843f2c193b11b05f09ac7f258cb9202d8f5e71" case .multilingual: "5dab512eb89b88a3680bd6c86841877c3ea49893" + case .decide, .decideLong: "cd0d7b1ef32b10e1e3a5a73c9d9ac8411d819c5a" } } @@ -88,10 +89,52 @@ public enum GLiNER2ModelStore { path: "\(package)/Data/com.apple.CoreML/weights/weight.bin", sha256: "655460ce8e9420b55130f3b6b976d4797aa197cd14c909aee96967cea24d766e"), ] + case .decide: + return [ + Asset(path: "config.json", sha256: "e748e5b80575471c91b3f0dd00f513ba58242544fcb7e1236e0021e61abd7673"), + Asset( + path: "encoder_config/config.json", + sha256: "bd32f1484ba5a199f7a63df44df3814b839fffcf6e64478323c4689868ef6015"), + Asset( + path: "tokenizer.json", sha256: "3ad87d9ffe669147063e70850927dd2da90249e2acc5c8527f1eb65df467bcc8"), + Asset( + path: "tokenizer_config.json", + sha256: "323199a4e946039410899f3779f2aa3eaef1500213c512727ad0f623d4f21309"), + Asset( + path: "\(package)/Manifest.json", + sha256: "6d62c3f4c7331d836cebf29c541815e0c6d7da9e4612ee45ca726847acbf6ed8"), + Asset( + path: "\(package)/Data/com.apple.CoreML/model.mlmodel", + sha256: "b9d4d8a497ea986b5d3163259694e8fc2571c9e7e2cdca2bf1b568bab8111c2a"), + Asset( + path: "\(package)/Data/com.apple.CoreML/weights/weight.bin", + sha256: "54501158f56baf0ebe295d99ac251eb0204cea2594881062b731bef9154032e2"), + ] + case .decideLong: + return [ + Asset(path: "config.json", sha256: "e748e5b80575471c91b3f0dd00f513ba58242544fcb7e1236e0021e61abd7673"), + Asset( + path: "encoder_config/config.json", + sha256: "bd32f1484ba5a199f7a63df44df3814b839fffcf6e64478323c4689868ef6015"), + Asset( + path: "tokenizer.json", sha256: "3ad87d9ffe669147063e70850927dd2da90249e2acc5c8527f1eb65df467bcc8"), + Asset( + path: "tokenizer_config.json", + sha256: "323199a4e946039410899f3779f2aa3eaef1500213c512727ad0f623d4f21309"), + Asset( + path: "\(package)/Manifest.json", + sha256: "542249fd40bfd27f7da11b303dda14932e8027be4279aab5e65d1c9abcc730c2"), + Asset( + path: "\(package)/Data/com.apple.CoreML/model.mlmodel", + sha256: "626d74aac1448e0666e5a80d79302139dfd84d5ca80c44ec213c1c6a5497d823"), + Asset( + path: "\(package)/Data/com.apple.CoreML/weights/weight.bin", + sha256: "54501158f56baf0ebe295d99ac251eb0204cea2594881062b731bef9154032e2"), + ] } } - /// Ensure one variant's tokenizer and W8 Core ML package exist in the FluidUse cache. + /// Ensure one variant's tokenizer and Core ML package exist in the FluidUse cache. public static func ensure( variant: GLiNER2Variant, cacheDirectory: URL? = nil, progress: Progress? = nil ) async throws -> URL { diff --git a/Sources/FluidUse/GLiNER2/GLiNER2Tokenizer.swift b/Sources/FluidUse/GLiNER2/GLiNER2Tokenizer.swift index d58edf2..73db66d 100644 --- a/Sources/FluidUse/GLiNER2/GLiNER2Tokenizer.swift +++ b/Sources/FluidUse/GLiNER2/GLiNER2Tokenizer.swift @@ -152,25 +152,36 @@ public struct GLiNER2Tokenizer: Sendable { public func classificationSequence( text: String, task: String, labels: [String] ) throws -> (ids: [Int], markers: [Int]) { + let sequence = try classificationSequence(text: text, heads: [(task, labels)]) + return (sequence.ids, sequence.markers[0]) + } + + /// Several classification heads in one schema, joined by `[SEP_STRUCT]` as GLiNER's SchemaTransformer does. + /// `markers[h]` holds the token position of each `[L]` marker of head `h`. + public func classificationSequence( + text: String, heads: [(task: String, labels: [String])] + ) throws -> (ids: [Int], markers: [[Int]]) { var source = text if source.isEmpty || !source.hasSuffix(".") && !source.hasSuffix("!") && !source.hasSuffix("?") { source += "." } - var items = ["(", "[P]", task, "("] - for label in labels { - items.append("[L]") - items.append(label) - } - items += [")", ")", "[SEP_TEXT]"] - items += try Self.splitText(source) - var ids: [Int] = [] - var markers: [Int] = [] - for (index, item) in items.enumerated() { - if index >= 4 && index < 4 + labels.count * 2 && index.isMultiple(of: 2) { - markers.append(ids.count) + var markers: [[Int]] = [] + for (index, head) in heads.enumerated() { + if index > 0 { ids += encode("[SEP_STRUCT]") } + ids += encode("(") + encode("[P]") + encode(head.task) + encode("(") + var positions: [Int] = [] + for label in head.labels { + positions.append(ids.count) + ids.append(labelTokenId) + ids += encode(label) } - ids.append(contentsOf: encode(item)) + ids += encode(")") + encode(")") + markers.append(positions) + } + ids.append(separatorTokenId) + for word in try Self.splitText(source) { + ids += encode(word) } return (ids, markers) } diff --git a/Sources/FluidUse/GLiNER2/GLiNER2Types.swift b/Sources/FluidUse/GLiNER2/GLiNER2Types.swift index 0b516d3..b524893 100644 --- a/Sources/FluidUse/GLiNER2/GLiNER2Types.swift +++ b/Sources/FluidUse/GLiNER2/GLiNER2Types.swift @@ -5,12 +5,18 @@ public enum GLiNER2Variant: String, Sendable, CaseIterable { case small case base case multilingual + /// GLiNER2.5-Decide (DeBERTa-v3-large) with 128 tokens and up to 32 labels; its package scores four heads. + /// The fp16 packages are used: on the GPU the W8 ones trigger a load-time preparation that peaks at several GB. + case decide + /// GLiNER2.5-Decide with 256 tokens, for longer documents or several heads per call. + case decideLong public var repository: String { switch self { case .small: "FluidInference/gliner2-5-small-coreml" case .base: "FluidInference/gliner2-5-base-coreml" case .multilingual: "FluidInference/gliner2-5-multi-coreml" + case .decide, .decideLong: "FluidInference/gliner2-5-decide-coreml" } } @@ -19,8 +25,30 @@ public enum GLiNER2Variant: String, Sendable, CaseIterable { case .small: "gliner2_small_classification_embedding_w8_L128_K8.mlpackage" case .base: "gliner2_base_classification_embedding_w8_L128_K8.mlpackage" case .multilingual: "gliner2_multi_classification_embedding_w8_linear_L128_K8.mlpackage" + case .decide: "gliner2_decide_classification_fp16_L128_H4_K32.mlpackage" + case .decideLong: "gliner2_decide_classification_fp16_L256_H4_K32.mlpackage" } } + + var isDecide: Bool { self == .decide || self == .decideLong } + + /// Token budget for schema plus text. + public var maximumLength: Int { self == .decideLong ? 256 : 128 } + + /// Labels per head. + public var maximumOptions: Int { isDecide ? 32 : 8 } + + /// Classification heads per call. + public var maximumHeads: Int { isDecide ? 4 : 1 } + + var tokenizerPath: String { + isDecide ? "tokenizer.json" : "tokenizer/tokenizer.json" + } + + /// Shape of `marker_indices`, `marker_mask`, and both outputs. + var markerShape: [Int] { + isDecide ? [1, maximumHeads, maximumOptions] : [1, maximumOptions] + } } public enum GLiNER2Error: Error, LocalizedError, Sendable, Equatable { diff --git a/Sources/SortAnything/DBpediaSample.swift b/Sources/SortAnything/DBpediaSample.swift new file mode 100644 index 0000000..c090005 --- /dev/null +++ b/Sources/SortAnything/DBpediaSample.swift @@ -0,0 +1,105 @@ +import Foundation + +/// One Wikipedia abstract from the DBpedia-14 test split with its gold category. +public struct SortItem: Codable, Sendable, Identifiable, Hashable { + public let id: Int + public let title: String + public let content: String + /// Demo category name of the gold DBpedia class. + public let gold: String + + public var text: String { title + "\n" + content } +} + +/// Balanced, seeded sample of the DBpedia-14 test split (CC BY-SA 3.0, Wikipedia via DBpedia), +/// fetched from the Hugging Face dataset viewer API and cached locally. Nothing is bundled. +public enum DBpediaSample { + public static let attribution = + "DBpedia-14 test split (Zhang et al., 2015) · Wikipedia text via DBpedia · CC BY-SA 3.0" + + /// Short names for the 14 DBpedia classes, in dataset label order. + public static let categories = [ + "company", "school", "artist", "athlete", "politician", "transportation", "building", "nature", "village", + "animal", "plant", "album", "film", "book", + ] + + static let rowsPerClass = 5000 + static let endpoint = "https://datasets-server.huggingface.co/rows" + + private struct Page: Decodable { + struct Entry: Decodable { + struct Row: Decodable { + let label: Int + let title: String + let content: String + } + let rowIndex: Int + let row: Row + + enum CodingKeys: String, CodingKey { + case row + case rowIndex = "row_idx" + } + } + let rows: [Entry] + } + + public static func cacheURL(count: Int, seed: UInt64) -> URL { + let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] + return base.appendingPathComponent("FluidUse/sort-anything/dbpedia-test-\(count)-seed\(seed).json") + } + + /// `count` items, as even across the 14 classes as possible, in a seeded shuffled order. + public static func load(count: Int = 1000, seed: UInt64 = 0) async throws -> [SortItem] { + let cache = cacheURL(count: count, seed: seed) + if let data = try? Data(contentsOf: cache), let items = try? JSONDecoder().decode([SortItem].self, from: data), + items.count == count + { + return items + } + var generator = SeededGenerator(seed: seed) + let perClass = (count + categories.count - 1) / categories.count + var items: [SortItem] = [] + for label in categories.indices { + let offset = label * rowsPerClass + Int(generator.next() % UInt64(rowsPerClass - perClass)) + var components = URLComponents(string: endpoint)! + components.queryItems = [ + URLQueryItem(name: "dataset", value: "fancyzhx/dbpedia_14"), + URLQueryItem(name: "config", value: "dbpedia_14"), + URLQueryItem(name: "split", value: "test"), + URLQueryItem(name: "offset", value: String(offset)), + URLQueryItem(name: "length", value: String(perClass)), + ] + let (data, response) = try await URLSession.shared.data(from: components.url!) + guard (response as? HTTPURLResponse)?.statusCode == 200 else { + throw URLError(.badServerResponse) + } + for entry in try JSONDecoder().decode(Page.self, from: data).rows where entry.row.label == label { + items.append( + SortItem( + id: entry.rowIndex, title: entry.row.title.trimmingCharacters(in: .whitespaces), + content: entry.row.content.trimmingCharacters(in: .whitespaces), gold: categories[label])) + } + } + items.shuffle(using: &generator) + items = Array(items.prefix(count)) + try FileManager.default.createDirectory( + at: cache.deletingLastPathComponent(), withIntermediateDirectories: true) + try JSONEncoder().encode(items).write(to: cache) + return items + } +} + +/// xorshift64*, so a seed gives the same sample on every machine. +struct SeededGenerator: RandomNumberGenerator { + private var state: UInt64 + + init(seed: UInt64) { state = seed &+ 0x9E37_79B9_7F4A_7C15 } + + mutating func next() -> UInt64 { + state ^= state >> 12 + state ^= state << 25 + state ^= state >> 27 + return state &* 0x2545_F491_4F6C_DD1D + } +} diff --git a/Sources/SortAnything/FastDecisions.swift b/Sources/SortAnything/FastDecisions.swift new file mode 100644 index 0000000..6225d4d --- /dev/null +++ b/Sources/SortAnything/FastDecisions.swift @@ -0,0 +1,138 @@ +import FluidUse +import Foundation + +/// One Fast Decisions row: a document and the decisions a product has to make about it. +public struct DecisionDocument: Codable, Sendable, Identifiable, Hashable { + public struct Head: Codable, Sendable, Hashable { + public let task: String + public let labels: [String] + /// Gold labels; one for single-label heads. + public let gold: [String] + public let multiLabel: Bool + } + + public let id: String + public let domain: String + public let input: String + public let heads: [Head] +} + +/// Fastino's Fast Decisions development split (Apache-2.0): 17 domains × 100 rows, fetched from Hugging Face at a +/// pinned revision and cached. The published benchmark numbers use a held-out test split that is not public. +public enum FastDecisions { + public static let attribution = "fastino/fast-decisions, development split · Apache-2.0" + public static let revision = "1a33070cabf94ce2e29105482dd2ef6c157ad7f2" + public static let domains = [ + "support_intent", "support_topic", "document_type", "review_sentiment", "agent_handoff", "email_triage", + "ticket_route", "product_feedback", "banking_intent", "clinic_request", "travel_request", "news_topic", + "paper_field", "sports_recap", "restaurant_review", "benefits_request", "screen_tags", + ] + + private struct Row: Decodable { + struct Output: Decodable { + struct Classification: Decodable { + let task: String + let trueLabel: [String] + let labels: [String] + let multiLabel: Bool? + + enum CodingKeys: String, CodingKey { + case task, labels + case trueLabel = "true_label" + case multiLabel = "multi_label" + } + } + let classifications: [Classification] + } + let input: String + let output: Output + } + + public static func load() async throws -> [DecisionDocument] { + let cache = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] + .appendingPathComponent("FluidUse/sort-decisions/fast-decisions-\(revision.prefix(8)).json") + if let data = try? Data(contentsOf: cache), + let documents = try? JSONDecoder().decode([DecisionDocument].self, from: data) + { + return documents + } + var documents: [DecisionDocument] = [] + for domain in domains { + let url = URL( + string: "https://huggingface.co/datasets/fastino/fast-decisions/resolve/\(revision)/\(domain).jsonl")! + let (data, response) = try await URLSession.shared.data(from: url) + guard (response as? HTTPURLResponse)?.statusCode == 200 else { throw URLError(.badServerResponse) } + for (index, line) in data.split(separator: UInt8(ascii: "\n")).enumerated() where !line.isEmpty { + let row = try JSONDecoder().decode(Row.self, from: Data(line)) + documents.append( + DecisionDocument( + id: "\(domain)-\(index)", domain: domain, input: row.input, + heads: row.output.classifications.map { + .init( + task: $0.task, labels: $0.labels, gold: $0.trueLabel, + multiLabel: $0.multiLabel ?? false) + })) + } + } + try FileManager.default.createDirectory( + at: cache.deletingLastPathComponent(), withIntermediateDirectories: true) + try JSONEncoder().encode(documents).write(to: cache) + return documents + } +} + +/// Answers every head of a document in one GLiNER2.5-Decide call (fp16 256-token package). +public final class DecisionSorter: Sendable { + public struct Answer: Sendable, Hashable { + public let task: String + public let label: String + public let confidence: Float + public let gold: [String] + /// Fast Decisions card scoring: the prediction and gold compared as sets. + public var correct: Bool { gold == [label] } + } + + public struct Result: Sendable { + public let answers: [Answer] + public let milliseconds: Double + public let truncated: Bool + } + + private let manager: GLiNER2Manager + + private init(manager: GLiNER2Manager) { self.manager = manager } + + public static func load(progress: GLiNER2ModelStore.Progress? = nil) async throws -> DecisionSorter { + let manager: GLiNER2Manager + if let path = ProcessInfo.processInfo.environment["GLINER2_DECIDE_MODEL_DIR"], !path.isEmpty { + manager = try await GLiNER2Manager.load(from: URL(fileURLWithPath: path), variant: .decideLong) + } else { + manager = try await GLiNER2Manager.load(variant: .decideLong, progress: progress) + } + return DecisionSorter(manager: manager) + } + + /// All heads in one call; trailing words are dropped until the request fits 256 tokens. + public func decide(_ document: DecisionDocument) async throws -> Result { + let start = DispatchTime.now().uptimeNanoseconds + let heads = document.heads.map { (task: $0.task, labels: $0.labels) } + var words = document.input.split(separator: " ", omittingEmptySubsequences: false) + var truncated = false + while true { + do { + let answers = try await manager.classifyConcurrently( + text: words.joined(separator: " "), heads: heads) + return Result( + answers: zip(document.heads, answers).map { head, answer in + Answer( + task: head.task, label: answer.selectedLabel, + confidence: answer.probabilities[answer.selectedIndex], gold: head.gold.sorted()) + }, + milliseconds: Double(DispatchTime.now().uptimeNanoseconds - start) / 1e6, truncated: truncated) + } catch GLiNER2Error.invalidInput(let reason) where reason.contains("tokens") && words.count > 8 { + words.removeLast(max(1, words.count / 8)) + truncated = true + } + } + } +} diff --git a/Sources/SortAnything/Sorter.swift b/Sources/SortAnything/Sorter.swift new file mode 100644 index 0000000..168f555 --- /dev/null +++ b/Sources/SortAnything/Sorter.swift @@ -0,0 +1,53 @@ +import FluidUse +import Foundation + +/// Sorts text into caller-chosen categories with GLiNER2.5-Decide on Core ML. +/// Calls are not serialized: callers may keep several `sort` calls in flight. +public final class Sorter: Sendable { + public struct Result: Sendable { + public let category: String + public let confidence: Float + public let milliseconds: Double + /// The abstract was shortened to fit the model's token budget. + public let truncated: Bool + } + + public static let task = "category" + + private let manager: GLiNER2Manager + + private init(manager: GLiNER2Manager) { self.manager = manager } + + /// Downloads (once) and loads the fp16 128-token Decide package. + public static func load(progress: GLiNER2ModelStore.Progress? = nil) async throws -> Sorter { + let manager: GLiNER2Manager + if let path = ProcessInfo.processInfo.environment["GLINER2_DECIDE_MODEL_DIR"], !path.isEmpty { + manager = try await GLiNER2Manager.load(from: URL(fileURLWithPath: path), variant: .decide) + } else { + manager = try await GLiNER2Manager.load(variant: .decide, progress: progress) + } + return Sorter(manager: manager) + } + + public var maximumCategories: Int { manager.maximumOptions } + + /// Picks one of `categories` for `item`, dropping trailing words until the request fits. + public func sort(_ item: SortItem, into categories: [String]) async throws -> Result { + let start = DispatchTime.now().uptimeNanoseconds + var words = item.content.split(separator: " ", omittingEmptySubsequences: true) + var truncated = false + while true { + let text = item.title + "\n" + words.joined(separator: " ") + do { + let answer = try await manager.classifyConcurrently(text: text, task: Self.task, labels: categories) + let milliseconds = Double(DispatchTime.now().uptimeNanoseconds - start) / 1e6 + return Result( + category: answer.selectedLabel, confidence: answer.probabilities[answer.selectedIndex], + milliseconds: milliseconds, truncated: truncated) + } catch GLiNER2Error.invalidInput(let reason) where reason.contains("tokens") && words.count > 8 { + words.removeLast(max(1, words.count / 8)) + truncated = true + } + } + } +} diff --git a/Sources/SortAnythingCheck/main.swift b/Sources/SortAnythingCheck/main.swift new file mode 100644 index 0000000..6a0586e --- /dev/null +++ b/Sources/SortAnythingCheck/main.swift @@ -0,0 +1,66 @@ +import Foundation +import SortAnything + +/// Headless Sort Anything: sorts a balanced DBpedia-14 test sample and reports accuracy and throughput. +/// +/// swift run -c release SortAnythingCheck [--count=1000] [--seed=0] [--inflight=1] [--dump=path.jsonl] +@main +struct SortAnythingCheck { + static func main() async throws { + let arguments = CommandLine.arguments.dropFirst() + func value(_ name: String) -> String? { + arguments.first { $0.hasPrefix("--\(name)=") }.map { String($0.dropFirst(name.count + 3)) } + } + let count = value("count").flatMap(Int.init) ?? 1000 + let seed = value("seed").flatMap(UInt64.init) ?? 0 + let items = try await DBpediaSample.load(count: count, seed: seed) + let sorter = try await Sorter.load() + let categories = DBpediaSample.categories + _ = try await sorter.sort(items[0], into: categories) + + let inflight = max(1, value("inflight").flatMap(Int.init) ?? 1) + var correct = 0 + var truncated = 0 + var latencies: [Double] = [] + var dump = "" + let wall = DispatchTime.now().uptimeNanoseconds + let results = try await withThrowingTaskGroup(of: (Int, Sorter.Result).self) { group in + var results = [Sorter.Result?](repeating: nil, count: items.count) + var next = 0 + while next < min(inflight, items.count) { + let index = next + group.addTask { (index, try await sorter.sort(items[index], into: categories)) } + next += 1 + } + while let (index, result) = try await group.next() { + results[index] = result + if next < items.count { + let index = next + group.addTask { (index, try await sorter.sort(items[index], into: categories)) } + next += 1 + } + } + return results.compactMap { $0 } + } + for (item, result) in zip(items, results) { + correct += result.category == item.gold ? 1 : 0 + truncated += result.truncated ? 1 : 0 + latencies.append(result.milliseconds) + if value("dump") != nil { + dump += "{\"id\":\(item.id),\"predicted\":\"\(result.category)\",\"gold\":\"\(item.gold)\"}\n" + } + } + let seconds = Double(DispatchTime.now().uptimeNanoseconds - wall) / 1e9 + latencies.sort() + if let path = value("dump") { try dump.write(toFile: path, atomically: true, encoding: .utf8) } + print( + """ + items \(items.count) in flight \(inflight) accuracy \(String(format: "%.3f", Double(correct) / Double(items.count))) \ + truncated \(truncated) + wall \(String(format: "%.2f", seconds)) s \ + \(String(format: "%.1f", Double(items.count) / seconds)) items/s \ + p50 \(String(format: "%.2f", latencies[latencies.count / 2])) ms \ + p95 \(String(format: "%.2f", latencies[latencies.count * 95 / 100])) ms + """) + } +} diff --git a/Sources/SortAnythingDemo/ContentView.swift b/Sources/SortAnythingDemo/ContentView.swift new file mode 100644 index 0000000..c3c633e --- /dev/null +++ b/Sources/SortAnythingDemo/ContentView.swift @@ -0,0 +1,350 @@ +import SortAnything +import SwiftUI + +private struct FramesKey: PreferenceKey { + static let defaultValue: [String: CGRect] = [:] + static func reduce(value: inout [String: CGRect], nextValue: () -> [String: CGRect]) { + value.merge(nextValue()) { $1 } + } +} + +extension View { + fileprivate func reportFrame(_ key: String) -> some View { + background( + GeometryReader { proxy in + Color.clear.preference(key: FramesKey.self, value: [key: proxy.frame(in: .named("board"))]) + }) + } +} + +private let incomingKey = "__incoming" + +struct ContentView: View { + @EnvironmentObject private var model: SortModel + @State private var frames: [String: CGRect] = [:] + @State private var newCategory = "" + + var body: some View { + VStack(spacing: 0) { + header + Divider() + categoryBar + Divider() + switch model.phase { + case .loading(let message): + status(message, spinning: true) + case .failed(let message): + status("Failed: \(message)", spinning: false) + default: + board + } + Divider() + footer + } + .background(Color(nsColor: .windowBackgroundColor)) + } + + // MARK: Header and controls + + private var header: some View { + ViewThatFits(in: .horizontal) { + HStack(alignment: .center, spacing: 18) { + titleBlock + Spacer(minLength: 12) + stats + controls + } + VStack(alignment: .leading, spacing: 10) { + titleBlock + HStack(alignment: .center, spacing: 14) { + stats + Spacer(minLength: 8) + controls + } + } + } + .padding(.horizontal, 20) + .padding(.vertical, 12) + } + + private var titleBlock: some View { + VStack(alignment: .leading, spacing: 2) { + Text("Sort anything").font(.system(size: 26, weight: .bold)) + Text("GLiNER2.5-Decide · Core ML · on this Mac · categories chosen at run time") + .font(.callout).foregroundStyle(.secondary).lineLimit(1) + } + } + + @ViewBuilder + private var stats: some View { + HStack(spacing: 16) { + stat("Sorted", "\(model.sorted) / \(model.total)") + stat("Items / s", model.sorted > 0 ? String(format: "%.0f", model.itemsPerSecond) : "–") + stat("Elapsed", String(format: "%.1f s", model.elapsed)) + stat("ms / item", model.medianMilliseconds.map { String(format: "%.1f", $0) } ?? "–") + stat("Matches label", model.accuracy.map { String(format: "%.1f%%", $0 * 100) } ?? "–") + } + } + + private func stat(_ title: String, _ value: String) -> some View { + VStack(alignment: .trailing, spacing: 2) { + Text(title.uppercased()).font(.caption2.weight(.semibold)).foregroundStyle(.secondary) + Text(value).font(.system(size: 20, weight: .semibold, design: .rounded)).monospacedDigit() + .contentTransition(.numericText()).fixedSize() + } + } + + private var controls: some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Button(model.phase == .running ? "Pause" : "Start") { model.toggleRun() } + .keyboardShortcut(.space, modifiers: []) + .disabled(!(model.phase == .ready || model.phase == .running || model.phase == .paused)) + .buttonStyle(.borderedProminent) + Button("Reset") { model.reset() }.disabled(model.phase == .running) + } + Picker("Mode", selection: $model.mode) { + ForEach(SortModel.Mode.allCases) { Text($0.rawValue).tag($0) } + } + .pickerStyle(.segmented).labelsHidden().frame(width: 150) + if model.mode == .show { + HStack(spacing: 6) { + Text("Pace").font(.caption).fixedSize() + Slider(value: $model.pace, in: 2...40).frame(width: 90) + Text(String(format: "%.0f/s", model.pace)).font(.caption).monospacedDigit().fixedSize() + } + } else { + Text("\(SortModel.turboInFlight) calls in flight").font(.caption).foregroundStyle(.secondary) + } + } + } + + private var categoryBar: some View { + HStack(spacing: 8) { + Text("Categories").font(.callout.weight(.semibold)) + FlowLayout(spacing: 6) { + ForEach(model.categories, id: \.self) { name in + HStack(spacing: 4) { + Text(name) + Button { + model.removeCategory(name) + } label: { + Image(systemName: "xmark").font(.caption2.weight(.bold)) + } + .buttonStyle(.plain).foregroundStyle(.secondary) + } + .padding(.horizontal, 10).padding(.vertical, 5) + .background(Capsule().fill(color(for: name).opacity(0.18))) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + TextField("Add a category…", text: $newCategory) + .textFieldStyle(.roundedBorder).frame(minWidth: 110, idealWidth: 180, maxWidth: 180) + .onSubmit { + model.addCategory(newCategory) + newCategory = "" + } + } + .padding(.horizontal, 20).padding(.vertical, 10) + } + + // MARK: Board + + private var board: some View { + GeometryReader { outer in + let incomingWidth = min(240, max(170, outer.size.width * 0.2)) + HStack(alignment: .top, spacing: 14) { + incoming(showQueue: outer.size.height > 520).frame(width: incomingWidth) + GeometryReader { proxy in + let columns = max(2, min(6, Int(proxy.size.width / 170))) + let rows = (model.bucketNames.count + columns - 1) / columns + let height = max(64, min(120, (proxy.size.height - CGFloat(rows - 1) * 10) / CGFloat(rows))) + ScrollView { + LazyVGrid( + columns: Array(repeating: GridItem(.flexible(), spacing: 10), count: columns), spacing: 10 + ) { + ForEach(model.bucketNames, id: \.self) { name in + BucketView( + name: name, placed: model.buckets[name] ?? [], color: color(for: name), + active: model.categories.contains(name), height: height + ) + .reportFrame(name) + } + } + } + } + } + } + .padding(14) + .coordinateSpace(name: "board") + .onPreferenceChange(FramesKey.self) { frames = $0 } + .overlay(alignment: .topLeading) { flyingCard } + } + + private func incoming(showQueue: Bool) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text("INCOMING · \(model.queue.count) left").font(.caption.weight(.semibold)).foregroundStyle(.secondary) + ZStack { + RoundedRectangle(cornerRadius: 12).fill(Color.secondary.opacity(0.06)) + if let next = model.queue.first(where: { item in !model.flights.contains { $0.id == item.id } }) { + ItemCard(item: next, result: nil, tint: .secondary) + } + } + .frame(height: 140) + .reportFrame(incomingKey) + ForEach( + Array(model.queue.dropFirst().prefix(showQueue ? 5 : 0).enumerated()), id: \.element.id + ) { offset, item in + ItemCard(item: item, result: nil, tint: .secondary, compact: true) + .opacity(1 - Double(offset) * 0.16) + } + Spacer(minLength: 0) + } + } + + private var flyingCard: some View { + ZStack(alignment: .topLeading) { + ForEach(model.flights) { flight in + if let from = frames[incomingKey], let to = frames[flight.placed.result.category] { + let target = flight.arrived ? to : from + ItemCard( + item: flight.placed.item, result: flight.placed.result, + tint: color(for: flight.placed.result.category) + ) + .frame(width: from.width - 8, height: from.height - 8) + .scaleEffect(flight.arrived ? 0.4 : 1) + .opacity(flight.arrived ? 0.2 : 1) + .position(x: target.midX, y: target.midY) + } + } + } + .allowsHitTesting(false) + } + + private func status(_ message: String, spinning: Bool) -> some View { + VStack(spacing: 12) { + if spinning { ProgressView() } + Text(message).foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var footer: some View { + HStack { + Text(DBpediaSample.attribution) + Spacer() + Text("Model: fastino/GLiNER2.5-Decide (Apache-2.0) → FluidInference/gliner2-5-decide-coreml") + } + .font(.caption).foregroundStyle(.secondary) + .padding(.horizontal, 20).padding(.vertical, 8) + } + + private func color(for name: String) -> Color { + let palette: [Color] = [ + .blue, .orange, .green, .pink, .purple, .teal, .red, .indigo, .mint, .brown, .cyan, .yellow, + ] + let index = model.bucketNames.firstIndex(of: name) ?? abs(name.hashValue) + return palette[index % palette.count] + } +} + +private struct ItemCard: View { + let item: SortItem + let result: Sorter.Result? + let tint: Color + var compact = false + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(item.title).font(compact ? .callout.weight(.semibold) : .headline).lineLimit(1) + if !compact { + Text(item.content).font(.caption).foregroundStyle(.secondary).lineLimit(4) + } + if let result { + Text( + "→ \(result.category) · \(Int(result.confidence * 100))% · \(String(format: "%.0f ms", result.milliseconds))" + ) + .font(.caption.weight(.semibold)).foregroundStyle(tint) + } + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(RoundedRectangle(cornerRadius: 10).fill(Color(nsColor: .controlBackgroundColor))) + .overlay(RoundedRectangle(cornerRadius: 10).stroke(tint.opacity(0.5), lineWidth: 1)) + .shadow(color: .black.opacity(0.08), radius: 3, y: 1) + } +} + +private struct BucketView: View { + let name: String + let placed: [SortModel.Placed] + let color: Color + let active: Bool + let height: CGFloat + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(name).font(.headline) + Spacer() + Text("\(placed.count)").font(.title3.weight(.bold)).monospacedDigit() + .contentTransition(.numericText()) + } + ForEach(placed.prefix(height >= 104 ? 3 : height >= 80 ? 2 : 1)) { entry in + HStack(spacing: 4) { + Image(systemName: entry.matchesGold ? "checkmark.circle.fill" : "xmark.circle.fill") + .foregroundStyle(entry.matchesGold ? Color.green : Color.red) + .help("DBpedia label: \(entry.item.gold)") + Text(entry.item.title).lineLimit(1) + } + .font(.caption) + } + Spacer(minLength: 0) + } + .padding(10) + .frame(height: height) + .background(RoundedRectangle(cornerRadius: 12).fill(color.opacity(active ? 0.12 : 0.05))) + .overlay(RoundedRectangle(cornerRadius: 12).stroke(color.opacity(active ? 0.45 : 0.15), lineWidth: 1)) + } +} + +/// Lays children out left to right, wrapping to a new line when the width runs out. +private struct FlowLayout: Layout { + var spacing: CGFloat = 6 + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + let rows = arrange(width: proposal.width ?? .infinity, subviews: subviews) + let height = rows.map(\.height).reduce(0, +) + CGFloat(max(rows.count - 1, 0)) * spacing + return CGSize(width: proposal.width ?? rows.map(\.width).max() ?? 0, height: height) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + var y = bounds.minY + for row in arrange(width: bounds.width, subviews: subviews) { + var x = bounds.minX + for index in row.indices { + let size = subviews[index].sizeThatFits(.unspecified) + subviews[index].place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size)) + x += size.width + spacing + } + y += row.height + spacing + } + } + + private func arrange(width: CGFloat, subviews: Subviews) -> [(indices: [Int], width: CGFloat, height: CGFloat)] { + var rows: [(indices: [Int], width: CGFloat, height: CGFloat)] = [] + var current: (indices: [Int], width: CGFloat, height: CGFloat) = ([], 0, 0) + for index in subviews.indices { + let size = subviews[index].sizeThatFits(.unspecified) + let needed = current.indices.isEmpty ? size.width : current.width + spacing + size.width + if needed > width, !current.indices.isEmpty { + rows.append(current) + current = ([index], size.width, size.height) + } else { + current = (current.indices + [index], needed, max(current.height, size.height)) + } + } + if !current.indices.isEmpty { rows.append(current) } + return rows + } +} diff --git a/Sources/SortAnythingDemo/README.md b/Sources/SortAnythingDemo/README.md new file mode 100644 index 0000000..8cacf84 --- /dev/null +++ b/Sources/SortAnythingDemo/README.md @@ -0,0 +1,26 @@ +# Sort anything + +Streams 1,000 Wikipedia abstracts (a balanced, seeded sample of the DBpedia-14 test split) and sorts each one into +categories you can edit while it runs. The model is GLiNER2.5-Decide, converted to Core ML and run on-device with +the fp16 128-token package from `FluidInference/gliner2-5-decide-coreml`. Nothing is trained on DBpedia: the category +names are the only hint the model gets. + +```bash +swift run -c release SortAnythingDemo +``` + +- **Show** animates one card at a time into its bucket; **Pace** sets cards per second. +- `SORT_AUTOSTART=show` or `SORT_AUTOSTART=turbo` starts a run on launch; `SORT_AUTOPLAY=12` flies 12 cards in + Show mode and then switches to Turbo, with no clicks. +- **Turbo** keeps four model calls in flight and sorts as fast as the Mac allows. +- Type a new category to add it; the next item can land there. Removing a category keeps the items already sorted. +- "Matches DBpedia label" counts only items whose DBpedia class is an active category, so custom categories that + split a class (for example `musician` next to `artist`) lower it even when the sort looks right. + +Headless numbers on the same 1,000 items: `swift run -c release SortAnythingCheck --inflight=4` +(M5 Pro, macOS 27: 89.0% match, 1,000 items in about 5.9 s, peak memory about 1 GB; identical +predictions to Fastino's PyTorch release on all 1,000). + +Data: DBpedia-14 test split (Zhang et al., 2015), Wikipedia text via DBpedia, CC BY-SA 3.0, fetched from the +Hugging Face dataset viewer on first launch and cached; it is not bundled. Model: fastino/GLiNER2.5-Decide, +Apache-2.0. diff --git a/Sources/SortAnythingDemo/SortAnythingDemoApp.swift b/Sources/SortAnythingDemo/SortAnythingDemoApp.swift new file mode 100644 index 0000000..92ea8e3 --- /dev/null +++ b/Sources/SortAnythingDemo/SortAnythingDemoApp.swift @@ -0,0 +1,29 @@ +import AppKit +import SwiftUI + +@main +struct SortAnythingDemoApp: App { + @StateObject private var model = SortModel() + + init() { + setvbuf(stdout, nil, _IOLBF, 0) + // Bare SwiftPM executables start as background processes; make this one a regular windowed app. + // Forget any saved window frame so the default size below (all 14 buckets visible) applies on launch. + for key in UserDefaults.standard.dictionaryRepresentation().keys where key.hasPrefix("NSWindow Frame") { + UserDefaults.standard.removeObject(forKey: key) + } + NSApplication.shared.setActivationPolicy(.regular) + NSApplication.shared.activate(ignoringOtherApps: true) + } + + var body: some Scene { + WindowGroup("Sort anything — GLiNER2.5-Decide on-device") { + ContentView() + .environmentObject(model) + .frame(minWidth: 760, minHeight: 560) + .task { await model.prepare() } + } + .defaultSize(width: 1500, height: 940) + .windowResizability(.contentMinSize) + } +} diff --git a/Sources/SortAnythingDemo/SortModel.swift b/Sources/SortAnythingDemo/SortModel.swift new file mode 100644 index 0000000..c7be89e --- /dev/null +++ b/Sources/SortAnythingDemo/SortModel.swift @@ -0,0 +1,322 @@ +import Foundation +import SortAnything +import SwiftUI + +/// Drives the stream: pulls DBpedia items, sorts them with Decide, and keeps the live statistics. +@MainActor +final class SortModel: ObservableObject { + enum Phase: Equatable { + case loading(String) + case ready + case running + case paused + case finished + case failed(String) + } + + enum Mode: String, CaseIterable, Identifiable { + /// One card at a time, animated into its bucket. + case show = "Show" + /// Several calls in flight, as fast as the model goes. + case turbo = "Turbo" + var id: String { rawValue } + } + + struct Placed: Identifiable { + let item: SortItem + let result: Sorter.Result + var id: Int { item.id } + var matchesGold: Bool { result.category == item.gold } + } + + struct Flight: Identifiable { + let placed: Placed + var arrived = false + var id: Int { placed.id } + } + + @Published private(set) var phase: Phase = .loading("Starting…") + @Published var mode: Mode = .show + /// Cards per second in Show mode. + @Published var pace: Double = 12 + @Published private(set) var categories: [String] = DBpediaSample.categories + @Published private(set) var buckets: [String: [Placed]] = [:] + @Published private(set) var queue: [SortItem] = [] + /// Items already in a bucket, so a card that lands after a pause and resume is not counted twice. + private var landed: Set = [] + /// Cards currently travelling from the incoming slot to their bucket. + @Published private(set) var flights: [Flight] = [] + @Published private(set) var sorted = 0 + @Published private(set) var scored = 0 + @Published private(set) var correct = 0 + @Published private(set) var elapsed: Double = 0 + @Published private(set) var lastMilliseconds: Double = 0 + + let total = 1000 + static let turboInFlight = 4 + static let turboFlush = 0.05 + /// Cards that visibly fly per Turbo flush (about 60 per second at a 50 ms flush). + static let turboFlightsPerFlush = 3 + private let autoplayShowCount = ProcessInfo.processInfo.environment["SORT_AUTOPLAY"].flatMap(Int.init) + /// SORT_LOG=1 prints every decision to stdout (for a terminal next to the window). + private let logDecisions = ProcessInfo.processInfo.environment["SORT_LOG"] == "1" + private var shownInShow = 0 + private var items: [SortItem] = [] + private var sorter: Sorter? + private var runner: Task? + private var modelMilliseconds: [Double] = [] + private var runStart: Date? + private var elapsedBeforePause: Double = 0 + + var itemsPerSecond: Double { elapsed > 0 ? Double(sorted) / elapsed : 0 } + var accuracy: Double? { scored > 0 ? Double(correct) / Double(scored) : nil } + var medianMilliseconds: Double? { + guard !modelMilliseconds.isEmpty else { return nil } + return modelMilliseconds.sorted()[modelMilliseconds.count / 2] + } + var maximumCategories: Int { sorter?.maximumCategories ?? 32 } + + /// Set on the first call: SwiftUI can start the window's `.task` again, and a second run would toggle + /// autoplay back off. + private var preparing = false + + func prepare() async { + guard !preparing else { return } + preparing = true + do { + phase = .loading("Fetching 1,000 Wikipedia abstracts…") + items = try await DBpediaSample.load(count: total) + phase = .loading("Loading GLiNER2.5-Decide (first run downloads 923 MB)…") + sorter = try await Sorter.load { [weak self] file, bytes in + guard bytes > 0 else { return } + Task { @MainActor in self?.phase = .loading("Downloaded \(file)") } + } + phase = .loading("Compiling for this Mac…") + _ = try await sorter?.sort(items[0], into: categories) + reset() + // SORT_AUTOSTART=show|turbo starts a run without a click; SORT_AUTOPLAY=N flies N cards in Show mode and + // then switches to Turbo (for recordings). + let environment = ProcessInfo.processInfo.environment + print("ready at \(Date().timeIntervalSince1970)") + if autoplayShowCount != nil { + // Give a recorder a moment to show the empty board before cards start flying. + try? await Task.sleep(for: .seconds(1.5)) + mode = .show + toggleRun() + } else if let start = environment["SORT_AUTOSTART"], let mode = Mode(rawValue: start.capitalized) { + self.mode = mode + toggleRun() + } + } catch { + phase = .failed(error.localizedDescription) + } + } + + func reset() { + runner?.cancel() + runner = nil + categories = DBpediaSample.categories + buckets = Dictionary(uniqueKeysWithValues: categories.map { ($0, []) }) + queue = items + flights = [] + landed = [] + sorted = 0 + scored = 0 + correct = 0 + elapsed = 0 + elapsedBeforePause = 0 + modelMilliseconds = [] + lastMilliseconds = 0 + shownInShow = 0 + phase = .ready + } + + func toggleRun() { + switch phase { + case .running: + runner?.cancel() + runner = nil + elapsedBeforePause = elapsed + phase = .paused + case .ready, .paused: + phase = .running + runStart = Date() + runner = Task { [weak self] in await self?.run() } + default: + break + } + } + + /// Adds a category; it takes effect from the next item. + func addCategory(_ raw: String) { + let name = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !name.isEmpty, !categories.contains(name), categories.count < maximumCategories else { return } + categories.append(name) + buckets[name] = buckets[name] ?? [] + } + + /// Removes a category from future decisions; items already sorted stay in its bucket. + func removeCategory(_ name: String) { + guard categories.count > 2 else { return } + categories.removeAll { $0 == name } + if buckets[name]?.isEmpty == true { buckets[name] = nil } + } + + var bucketNames: [String] { + categories + buckets.keys.filter { !categories.contains($0) }.sorted() + } + + private func run() async { + guard let sorter else { return } + while !Task.isCancelled, !queue.isEmpty { + if mode == .turbo { + await runTurbo(sorter) + } else { + await runShow(sorter) + } + } + if !Task.isCancelled, queue.isEmpty { + tick() + phase = .finished + print("finished at \(Date().timeIntervalSince1970)") + print( + "finished \(sorted) items in \(String(format: "%.2f", elapsed)) s " + + "(\(String(format: "%.0f", itemsPerSecond)) items/s), " + + "matches \(String(format: "%.1f", (accuracy ?? 0) * 100))% (\(mode.rawValue))") + } + } + + /// Seconds a card takes to fly from the incoming slot to its bucket. + static let travel = 0.45 + + /// Classifies ahead in the background and launches one card every `1 / pace` seconds; several cards can be in + /// the air at once. Returns when the queue is empty, the run is paused, or the mode changes. + private func runShow(_ sorter: Sorter) async { + let stream = Self.turboStream(sorter, items: queue, categories: categories, inFlight: 2) + for await placed in stream { + if Task.isCancelled || mode != .show { break } + launch(placed) + let started = Date() + try? await Task.sleep(for: .seconds(1 / pace)) + if Date().timeIntervalSince(started) < 1 / pace { break } // cancelled mid-sleep + } + // On a mode switch, let cards already in the air land before Turbo snapshots the queue. On pause, return at + // once: they land by themselves, and `land` ignores anything sorted twice. + while !Task.isCancelled, !flights.isEmpty { try? await Task.sleep(for: .milliseconds(20)) } + } + + /// A decorative flight only animates: the item has already been counted by Turbo. + private func launch(_ placed: Placed, decorative: Bool = false) { + guard !flights.contains(where: { $0.id == placed.id }) else { return } + flights.append(Flight(placed: placed)) + Task { @MainActor [weak self] in + try? await Task.sleep(for: .milliseconds(20)) + withAnimation(.easeInOut(duration: Self.travel)) { + if let index = self?.flights.firstIndex(where: { $0.id == placed.id }) { + self?.flights[index].arrived = true + } + } + try? await Task.sleep(for: .seconds(Self.travel)) + guard let self else { return } + flights.removeAll { $0.id == placed.id } + guard !decorative else { return } + land([placed]) + shownInShow += 1 + if let count = autoplayShowCount, shownInShow >= count { mode = .turbo } + } + } + + /// Sorts `items` with `inFlight` calls always running, independent of the main thread, and streams each result. + private nonisolated static func turboStream( + _ sorter: Sorter, items: [SortItem], categories: [String], inFlight: Int + ) -> AsyncStream { + AsyncStream { continuation in + let producer = Task.detached { + await withTaskGroup(of: Placed?.self) { group in + var next = 0 + func launch() { + guard next < items.count, !Task.isCancelled else { return } + let item = items[next] + next += 1 + group.addTask { + guard let result = try? await sorter.sort(item, into: categories) else { return nil } + return Placed(item: item, result: result) + } + } + for _ in 0..= Self.turboFlush { + // Counts land at full speed; a few cards per flush also fly, so Turbo still shows motion. + for placed in pending.suffix(Self.turboFlightsPerFlush) where flights.count < 40 { + launch(placed, decorative: true) + } + land(pending) + pending.removeAll(keepingCapacity: true) + lastFlush = Date() + } + if Task.isCancelled || mode != .turbo { break } + } + land(pending) + } + + private func land(_ incoming: [Placed]) { + let batch = incoming.filter { self.landed.insert($0.id).inserted } + guard !batch.isEmpty else { return } + var updated = buckets + for placed in batch { + updated[placed.result.category, default: []].insert(placed, at: 0) + if categories.contains(placed.item.gold) { + scored += 1 + correct += placed.matchesGold ? 1 : 0 + } + modelMilliseconds.append(placed.result.milliseconds) + } + buckets = updated + let batchIDs = Set(batch.map(\.id)) + queue.removeAll { batchIDs.contains($0.id) } + sorted += batch.count + lastMilliseconds = batch.last?.result.milliseconds ?? lastMilliseconds + tick() + if logDecisions { log(batch) } + } + + private func log(_ batch: [Placed]) { + let (cyan, yellow, red, green, dim, reset) = + ("\u{1B}[1;36m", "\u{1B}[33m", "\u{1B}[31m", "\u{1B}[32m", "\u{1B}[2m", "\u{1B}[0m") + var lines = "" + for (offset, placed) in batch.enumerated() { + let number = sorted - batch.count + offset + 1 + let mark = placed.matchesGold ? "\(green)✓\(reset)" : "\(red)✗ label: \(placed.item.gold)\(reset)" + lines += "\(cyan)▶ #\(number) \(placed.item.title)\(reset)\n" + lines += + " \(yellow)→ \(placed.result.category)\(reset) · " + + "\(Int(placed.result.confidence * 100))% · " + + "\(red)model call \(String(format: "%.1f", placed.result.milliseconds)) ms\(reset) \(mark)\n" + } + lines += "\(dim) sorted \(sorted)/\(total) · \(String(format: "%.1f", elapsed)) s\(reset)\n" + print(lines, terminator: "") + } + + private func tick() { + if let runStart { elapsed = elapsedBeforePause + Date().timeIntervalSince(runStart) } + } +} diff --git a/Sources/SortDecisionsCheck/main.swift b/Sources/SortDecisionsCheck/main.swift new file mode 100644 index 0000000..7b82c3c --- /dev/null +++ b/Sources/SortDecisionsCheck/main.swift @@ -0,0 +1,64 @@ +import Foundation +import SortAnything + +/// Headless Sort Decisions: every Fast Decisions document, all of its heads in one GLiNER2.5-Decide call. +/// +/// swift run -c release SortDecisionsCheck [--inflight=4] [--dump=path.jsonl] +@main +struct SortDecisionsCheck { + static func main() async throws { + let arguments = CommandLine.arguments.dropFirst() + func value(_ name: String) -> String? { + arguments.first { $0.hasPrefix("--\(name)=") }.map { String($0.dropFirst(name.count + 3)) } + } + let inflight = max(1, value("inflight").flatMap(Int.init) ?? 4) + let documents = try await FastDecisions.load() + let sorter = try await DecisionSorter.load() + _ = try await sorter.decide(documents[0]) + + let wall = DispatchTime.now().uptimeNanoseconds + let results = try await withThrowingTaskGroup(of: (Int, DecisionSorter.Result).self) { group in + var results = [DecisionSorter.Result?](repeating: nil, count: documents.count) + var next = 0 + func launch() { + guard next < documents.count else { return } + let index = next + next += 1 + group.addTask { (index, try await sorter.decide(documents[index])) } + } + for _ in 0.. 0 ? String(format: "%.0f", model.decisionsPerSecond) : "–") + stat("Elapsed", String(format: "%.1f s", model.elapsed)) + stat("Match Fastino's labels", model.accuracy.map { String(format: "%.1f%%", $0 * 100) } ?? "–") + controls + } + .padding(.horizontal, 20).padding(.vertical, 14) + } + + private func stat(_ title: String, _ value: String) -> some View { + VStack(alignment: .trailing, spacing: 2) { + Text(title.uppercased()).font(.caption2.weight(.semibold)).foregroundStyle(.secondary) + Text(value).font(.system(size: 22, weight: .semibold, design: .rounded)).monospacedDigit() + } + } + + private var controls: some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Button(model.phase == .running ? "Pause" : "Start") { model.toggleRun() } + .keyboardShortcut(.space, modifiers: []) + .disabled(!(model.phase == .ready || model.phase == .running || model.phase == .paused)) + .buttonStyle(.borderedProminent) + Button("Reset") { model.reset() }.disabled(model.phase == .running) + } + Picker("Mode", selection: $model.mode) { + ForEach(DecisionsModel.Mode.allCases) { Text($0.rawValue).tag($0) } + } + .pickerStyle(.segmented).labelsHidden().frame(width: 150) + Text( + model.mode == .turbo + ? "\(DecisionsModel.turboInFlight) calls in flight" + : String(format: "%.1f s per document", model.dwell) + ) + .font(.caption).foregroundStyle(.secondary) + } + } + + private var domains: some View { + ScrollView { + LazyVGrid(columns: [GridItem(.adaptive(minimum: 196), spacing: 10)], spacing: 10) { + ForEach(FastDecisions.domains, id: \.self) { domain in + DomainTile( + domain: domain, score: model.scores[domain] ?? .init(), + active: model.current?.document.domain == domain) + } + } + } + } + + private func status(_ message: String, spinning: Bool) -> some View { + VStack(spacing: 12) { + if spinning { ProgressView() } + Text(message).foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var footer: some View { + HStack { + Text(FastDecisions.attribution + " · scored as on the dataset card: exact match per decision") + Spacer() + Text("Model: fastino/GLiNER2.5-Decide (Apache-2.0) → FluidInference/gliner2-5-decide-coreml") + } + .font(.caption).foregroundStyle(.secondary) + .padding(.horizontal, 20).padding(.vertical, 8) + } +} + +func humanize(_ name: String) -> String { + name.replacingOccurrences(of: "_", with: " ") +} + +private struct DocumentPanel: View { + let decided: DecisionsModel.Decided? + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + if let decided { + HStack { + Text(humanize(decided.document.domain).uppercased()) + .font(.caption.weight(.bold)).foregroundStyle(.secondary) + Spacer() + Text( + "\(decided.result.answers.count) questions · 1 call · " + + String(format: "%.0f ms", decided.result.milliseconds) + ) + .font(.caption.weight(.semibold)).foregroundStyle(.secondary) + } + ScrollView { + Text(decided.document.input) + .font(.system(size: 13)).frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + } + .frame(height: 300) + .padding(12) + .background(RoundedRectangle(cornerRadius: 10).fill(Color(nsColor: .textBackgroundColor))) + VStack(spacing: 8) { + ForEach(decided.result.answers, id: \.task) { answer in + AnswerRow(answer: answer) + } + } + .id(decided.id) + .transition(.opacity) + } else { + Text("Press Start").foregroundStyle(.secondary).frame(maxWidth: .infinity, maxHeight: .infinity) + } + Spacer(minLength: 0) + } + .padding(16) + .background(RoundedRectangle(cornerRadius: 14).fill(Color.secondary.opacity(0.07))) + } +} + +private struct AnswerRow: View { + let answer: DecisionSorter.Answer + + var body: some View { + HStack(spacing: 10) { + Text(humanize(answer.task)).font(.callout.weight(.semibold)).frame(width: 140, alignment: .leading) + Text(humanize(answer.label)) + .font(.callout.weight(.bold)) + .padding(.horizontal, 10).padding(.vertical, 4) + .background(Capsule().fill((answer.correct ? Color.green : Color.red).opacity(0.22))) + Text(String(format: "%.0f%%", answer.confidence * 100)).font(.caption).foregroundStyle(.secondary) + .monospacedDigit() + Spacer() + Image(systemName: answer.correct ? "checkmark.circle.fill" : "xmark.circle.fill") + .foregroundStyle(answer.correct ? Color.green : Color.red) + if !answer.correct { + Text("label: " + answer.gold.map(humanize).joined(separator: ", ")) + .font(.caption).foregroundStyle(.secondary).lineLimit(1) + } + } + .padding(10) + .background(RoundedRectangle(cornerRadius: 10).fill(Color(nsColor: .controlBackgroundColor))) + } +} + +private struct DomainTile: View { + let domain: String + let score: DecisionsModel.DomainScore + let active: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(humanize(domain)).font(.headline).lineLimit(1) + Spacer() + Text(score.accuracy.map { String(format: "%.0f%%", $0 * 100) } ?? "–") + .font(.title3.weight(.bold)).monospacedDigit() + } + ProgressView(value: Double(score.documents), total: 100) + Text("\(score.documents)/100 documents · \(score.decisions) decisions") + .font(.caption).foregroundStyle(.secondary).monospacedDigit() + } + .padding(12) + .background(RoundedRectangle(cornerRadius: 12).fill(Color.accentColor.opacity(active ? 0.22 : 0.07))) + .overlay(RoundedRectangle(cornerRadius: 12).stroke(Color.accentColor.opacity(active ? 0.8 : 0.15))) + } +} diff --git a/Sources/SortDecisionsDemo/DecisionsModel.swift b/Sources/SortDecisionsDemo/DecisionsModel.swift new file mode 100644 index 0000000..5ef6720 --- /dev/null +++ b/Sources/SortDecisionsDemo/DecisionsModel.swift @@ -0,0 +1,236 @@ +import Foundation +import SortAnything +import SwiftUI + +/// Streams Fast Decisions documents through GLiNER2.5-Decide, all heads of a document in one call. +@MainActor +final class DecisionsModel: ObservableObject { + enum Phase: Equatable { + case loading(String) + case ready + case running + case paused + case finished + case failed(String) + } + + enum Mode: String, CaseIterable, Identifiable { + /// One document at a time, answers revealed on screen. + case show = "Show" + /// Several calls in flight, as fast as the model goes. + case turbo = "Turbo" + var id: String { rawValue } + } + + struct Decided: Identifiable { + let document: DecisionDocument + let result: DecisionSorter.Result + var id: String { document.id } + } + + struct DomainScore { + var documents = 0 + var decisions = 0 + var correct = 0 + var accuracy: Double? { decisions > 0 ? Double(correct) / Double(decisions) : nil } + } + + @Published private(set) var phase: Phase = .loading("Starting…") + @Published var mode: Mode = .show + /// Seconds each document stays on screen in Show mode. + @Published var dwell: Double = 1.6 + @Published private(set) var current: Decided? + @Published private(set) var scores: [String: DomainScore] = [:] + @Published private(set) var documentsDone = 0 + @Published private(set) var decisions = 0 + @Published private(set) var correct = 0 + @Published private(set) var elapsed: Double = 0 + + static let turboInFlight = 4 + static let turboFlush = 0.05 + /// DECISIONS_AUTOPLAY=N shows N documents in Show mode, then switches to Turbo, with no clicks. + private let autoplayShowCount = ProcessInfo.processInfo.environment["DECISIONS_AUTOPLAY"].flatMap(Int.init) + + private(set) var documents: [DecisionDocument] = [] + private var queue: [DecisionDocument] = [] + private var sorter: DecisionSorter? + private var runner: Task? + private var runStart: Date? + private var elapsedBeforePause: Double = 0 + private var shownInShow = 0 + + var total: Int { documents.count } + var remaining: Int { queue.count } + var decisionsPerSecond: Double { elapsed > 0 ? Double(decisions) / elapsed : 0 } + var accuracy: Double? { decisions > 0 ? Double(correct) / Double(decisions) : nil } + + /// Set on the first call: SwiftUI can start the window's `.task` again, and a second run would toggle + /// autoplay back off. + private var preparing = false + + func prepare() async { + guard !preparing else { return } + preparing = true + do { + phase = .loading("Fetching Fast Decisions (1,700 documents)…") + documents = try await FastDecisions.load() + // Interleave domains so every tile moves from the start. + documents = interleaved(documents) + phase = .loading("Loading GLiNER2.5-Decide (first run downloads 923 MB)…") + sorter = try await DecisionSorter.load { [weak self] file, bytes in + guard bytes > 0 else { return } + Task { @MainActor in self?.phase = .loading("Downloaded \(file)") } + } + phase = .loading("Compiling for this Mac…") + _ = try await sorter?.decide(documents[0]) + reset() + if autoplayShowCount != nil { + mode = .show + toggleRun() + } + } catch { + phase = .failed(error.localizedDescription) + } + } + + private func interleaved(_ documents: [DecisionDocument]) -> [DecisionDocument] { + let byDomain = Dictionary(grouping: documents, by: \.domain) + let longest = byDomain.values.map(\.count).max() ?? 0 + return (0..= count { mode = .turbo } + try? await Task.sleep(for: .seconds(dwell)) + } + + /// Runs the model off the main thread with several calls in flight and streams each result. + private nonisolated static func turboStream( + _ sorter: DecisionSorter, documents: [DecisionDocument], inFlight: Int + ) -> AsyncStream { + AsyncStream { continuation in + let producer = Task.detached { + await withTaskGroup(of: Decided?.self) { group in + var next = 0 + func launch() { + guard next < documents.count, !Task.isCancelled else { return } + let document = documents[next] + next += 1 + group.addTask { + guard let result = try? await sorter.decide(document) else { return nil } + return Decided(document: document, result: result) + } + } + for _ in 0..= Self.turboFlush { + current = pending.last + land(pending) + pending.removeAll(keepingCapacity: true) + lastFlush = Date() + } + if Task.isCancelled || mode != .turbo { break } + } + if let last = pending.last { current = last } + land(pending) + } + + private func land(_ batch: [Decided]) { + guard !batch.isEmpty else { return } + var updated = scores + for decided in batch { + var score = updated[decided.document.domain, default: DomainScore()] + score.documents += 1 + for answer in decided.result.answers { + score.decisions += 1 + score.correct += answer.correct ? 1 : 0 + decisions += 1 + correct += answer.correct ? 1 : 0 + } + updated[decided.document.domain] = score + } + scores = updated + let done = Set(batch.map(\.id)) + queue.removeAll { done.contains($0.id) } + documentsDone += batch.count + tick() + } + + private func tick() { + if let runStart { elapsed = elapsedBeforePause + Date().timeIntervalSince(runStart) } + } +} diff --git a/Sources/SortDecisionsDemo/README.md b/Sources/SortDecisionsDemo/README.md new file mode 100644 index 0000000..c60d175 --- /dev/null +++ b/Sources/SortDecisionsDemo/README.md @@ -0,0 +1,17 @@ +# Sort decisions + +Streams Fastino's Fast Decisions development split (17 domains × 100 documents, Apache-2.0) through +GLiNER2.5-Decide on Core ML. Every question a document carries (for an email: category, action, needs reply, +is phishing) is answered in one call, and each answer is checked against the dataset's label. + +```bash +swift run -c release SortDecisionsDemo +DECISIONS_AUTOPLAY=6 swift run -c release SortDecisionsDemo # 6 documents in Show, then Turbo, no clicks +``` + +Headless: `swift run -c release SortDecisionsCheck --inflight=4` (M5 Pro, macOS 27: 1,700 documents, 2,900 +decisions in about 24 s; 62.7% average over the 17 domains). Documents longer than the 256-token package are trimmed +from the end (198 of 1,700), which costs about half a point against untrimmed 512-token calls. + +Scoring follows the dataset card (exact match per decision, multi-label heads answered with one label). Fastino's +published 60.2% is on a held-out test split that is not public; these are development-split numbers. diff --git a/Sources/SortDecisionsDemo/SortDecisionsDemoApp.swift b/Sources/SortDecisionsDemo/SortDecisionsDemoApp.swift new file mode 100644 index 0000000..50db9d1 --- /dev/null +++ b/Sources/SortDecisionsDemo/SortDecisionsDemoApp.swift @@ -0,0 +1,25 @@ +import AppKit +import SwiftUI + +@main +struct SortDecisionsDemoApp: App { + @StateObject private var model = DecisionsModel() + + init() { + setvbuf(stdout, nil, _IOLBF, 0) + // Bare SwiftPM executables start as background processes; make this one a regular windowed app. + NSApplication.shared.setActivationPolicy(.regular) + NSApplication.shared.activate(ignoringOtherApps: true) + } + + var body: some Scene { + WindowGroup("Sort decisions — GLiNER2.5-Decide on-device") { + ContentView() + .environmentObject(model) + .frame(minWidth: 1280, minHeight: 820) + .task { await model.prepare() } + } + .defaultSize(width: 1500, height: 940) + .windowResizability(.contentMinSize) + } +} diff --git a/Tests/FluidUseTests/GLiNER2IntegrationTests.swift b/Tests/FluidUseTests/GLiNER2IntegrationTests.swift index 6127ec7..898249a 100644 --- a/Tests/FluidUseTests/GLiNER2IntegrationTests.swift +++ b/Tests/FluidUseTests/GLiNER2IntegrationTests.swift @@ -20,6 +20,7 @@ final class GLiNER2IntegrationTests: XCTestCase { case .small: variable = "FLUIDUSE_GLINER2_SMALL_MODEL_DIR" case .base: variable = "FLUIDUSE_GLINER2_BASE_MODEL_DIR" case .multilingual: variable = "FLUIDUSE_GLINER2_MULTI_MODEL_DIR" + case .decide: variable = "FLUIDUSE_GLINER2_DECIDE_MODEL_DIR" } guard let path = ProcessInfo.processInfo.environment[variable], !path.isEmpty else { throw XCTSkip("Set \(variable) to run real GLiNER 2.5 integration tests") @@ -92,5 +93,12 @@ final class GLiNER2IntegrationTests: XCTestCase { XCTAssertEqual( GLiNER2Variant.multilingual.packageName, "gliner2_multi_classification_embedding_w8_linear_L128_K8.mlpackage") + XCTAssertEqual(GLiNER2Variant.decide.packageName, "gliner2_decide_classification_fp16_L128_H4_K32.mlpackage") + XCTAssertEqual( + GLiNER2Variant.decideLong.packageName, "gliner2_decide_classification_fp16_L256_H4_K32.mlpackage") + XCTAssertEqual(GLiNER2Variant.decide.maximumOptions, 32) + XCTAssertEqual(GLiNER2Variant.decideLong.maximumLength, 256) + XCTAssertEqual(GLiNER2Variant.decide.maximumHeads, 4) + XCTAssertEqual(GLiNER2Variant.base.maximumOptions, 8) } } diff --git a/Tests/FluidUseTests/GLiNER2TokenizerTests.swift b/Tests/FluidUseTests/GLiNER2TokenizerTests.swift index a500775..c066d8b 100644 --- a/Tests/FluidUseTests/GLiNER2TokenizerTests.swift +++ b/Tests/FluidUseTests/GLiNER2TokenizerTests.swift @@ -28,7 +28,7 @@ final class GLiNER2TokenizerTests: XCTestCase { } func testPinnedTokenizersMatchUpstreamEdgeSequences() throws { - for variant in GLiNER2Variant.allCases { + for variant in [GLiNER2Variant.base, .multilingual] { let tokenizer = try tokenizer(for: variant) let name = variant == .base ? "gliner2-base-edge-sequences" : "gliner2-multilingual-edge-sequences" let file = try XCTUnwrap( From aa23ccc19d312e55184e303b10cf44f24a9c42b5 Mon Sep 17 00:00:00 2001 From: Alex-Wengg Date: Fri, 25 Sep 2026 14:29:29 -0400 Subject: [PATCH 2/2] Cover decideLong in the integration test directory switch --- Tests/FluidUseTests/GLiNER2IntegrationTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/FluidUseTests/GLiNER2IntegrationTests.swift b/Tests/FluidUseTests/GLiNER2IntegrationTests.swift index 898249a..ea9c6a6 100644 --- a/Tests/FluidUseTests/GLiNER2IntegrationTests.swift +++ b/Tests/FluidUseTests/GLiNER2IntegrationTests.swift @@ -20,7 +20,7 @@ final class GLiNER2IntegrationTests: XCTestCase { case .small: variable = "FLUIDUSE_GLINER2_SMALL_MODEL_DIR" case .base: variable = "FLUIDUSE_GLINER2_BASE_MODEL_DIR" case .multilingual: variable = "FLUIDUSE_GLINER2_MULTI_MODEL_DIR" - case .decide: variable = "FLUIDUSE_GLINER2_DECIDE_MODEL_DIR" + case .decide, .decideLong: variable = "FLUIDUSE_GLINER2_DECIDE_MODEL_DIR" } guard let path = ProcessInfo.processInfo.environment[variable], !path.isEmpty else { throw XCTSkip("Set \(variable) to run real GLiNER 2.5 integration tests")