bleam 0.0.10 → 0.0.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/dist/ai.cjs +608 -59
  2. package/dist/ai.d.cts +217 -89
  3. package/dist/ai.d.ts +217 -89
  4. package/dist/ai.js +601 -59
  5. package/dist/cli.cjs +3 -183
  6. package/dist/cli.d.cts +2 -46
  7. package/dist/cli.d.ts +2 -46
  8. package/dist/cli.js +3 -183
  9. package/dist/files.d.cts +1 -1
  10. package/dist/files.d.ts +1 -1
  11. package/dist/platform.cjs +1 -13
  12. package/dist/platform.d.cts +1 -28
  13. package/dist/platform.d.ts +1 -28
  14. package/dist/platform.js +1 -10
  15. package/dist/schema.d.cts +1 -1
  16. package/dist/schema.d.ts +1 -1
  17. package/dist/state.d.cts +1 -1
  18. package/dist/state.d.ts +1 -1
  19. package/dist/{ui-Bg11tvlc.d.ts → ui-1WepaMS4.d.cts} +1 -1
  20. package/dist/{ui-Dd7SXdbg.d.cts → ui-CHc4xEs_.d.ts} +7 -7
  21. package/dist/ui.d.cts +1 -1
  22. package/dist/ui.d.ts +1 -1
  23. package/dist/window.d.cts +1 -1
  24. package/dist/window.d.ts +1 -1
  25. package/package.json +1 -1
  26. package/templates/image-generation/app/index.tsx +3 -1
  27. package/templates/native/ios/Bleam/AI/Flux2/Configuration/ModelRegistry.swift +1 -1
  28. package/templates/native/ios/Bleam/AI/Flux2/Pipeline/FluxImageRunner.swift +7 -2
  29. package/templates/native/ios/Bleam/AI/TextGeneration/Bonsai/BonsaiTextGenerator.swift +46 -0
  30. package/templates/native/ios/Bleam/AI/TextGeneration/Bonsai/BonsaiTextModel.swift +107 -0
  31. package/templates/native/ios/Bleam/AI/TextGeneration/Bonsai/BonsaiTextSnapshotValidator.swift +201 -0
  32. package/templates/native/ios/Bleam/AI/TextGeneration/Core/GenerationTypes.swift +85 -0
  33. package/templates/native/ios/Bleam/AI/TextGeneration/Core/ModelCapabilities.swift +21 -0
  34. package/templates/native/ios/Bleam/AI/TextGeneration/Core/TextKVCache.swift +23 -0
  35. package/templates/native/ios/Bleam/AI/TextGeneration/Core/TokenSampler.swift +79 -0
  36. package/templates/native/ios/Bleam/AI/TextGeneration/Qwen3/Qwen3CheckpointLoader.swift +161 -0
  37. package/templates/native/ios/Bleam/AI/TextGeneration/Qwen3/Qwen3Configuration.swift +119 -0
  38. package/templates/native/ios/Bleam/AI/TextGeneration/Qwen3/Qwen3InferenceSession.swift +84 -0
  39. package/templates/native/ios/Bleam/AI/TextGeneration/Qwen3/Qwen3Layers.swift +197 -0
  40. package/templates/native/ios/Bleam/AI/TextGeneration/Qwen3/Qwen3Model.swift +130 -0
  41. package/templates/native/ios/Bleam/AI/TextGeneration/Qwen35/Qwen35Checkpoint.swift +102 -0
  42. package/templates/native/ios/Bleam/AI/TextGeneration/Qwen35/Qwen35Configuration.swift +140 -0
  43. package/templates/native/ios/Bleam/AI/TextGeneration/Qwen35/Qwen35Model.swift +112 -0
  44. package/templates/native/ios/Bleam/AI/TextGeneration/Qwen35/Qwen35Session.swift +121 -0
  45. package/templates/native/ios/Bleam/AI/TextGeneration/QwenHybrid/QwenHybridCache.swift +42 -0
  46. package/templates/native/ios/Bleam/AI/TextGeneration/QwenHybrid/QwenHybridFullAttention.swift +57 -0
  47. package/templates/native/ios/Bleam/AI/TextGeneration/QwenHybrid/QwenHybridLayers.swift +58 -0
  48. package/templates/native/ios/Bleam/AI/TextGeneration/QwenHybrid/QwenHybridLinearAttention.swift +111 -0
  49. package/templates/native/ios/Bleam/AppDelegate.swift +0 -206
  50. package/templates/native/ios/Bleam.xcodeproj/project.pbxproj +128 -122
  51. package/templates/native/ios/GenerationService/BonsaiTextGenerationRunner.swift +103 -0
  52. package/templates/native/ios/GenerationService/ImageGenerationRunner.swift +19 -7
  53. package/templates/native/ios/GenerationService/main.swift +174 -48
  54. package/templates/native/ios/Shared/Generation/GenerationServiceProtocol.swift +3 -0
  55. package/templates/native/ios/Shared/Generation/GenerationWorkerProtocol.swift +44 -0
  56. package/templates/native/modules/bleam-runtime/ios/AIModule.swift +225 -5
  57. package/templates/native/modules/bleam-runtime/ios/GenerationContracts.swift +47 -0
  58. package/templates/native/modules/bleam-runtime/ios/PlatformModule.swift +0 -191
  59. package/templates/text-generation/app/index.tsx +93 -0
  60. package/templates/text-generation/app.config.ts +5 -0
  61. package/templates/updates/README.md +43 -199
  62. package/templates/updates/src/index.ts +5 -98
  63. package/templates/updates/src/schema.ts +18 -173
  64. package/templates/native/ios/PlatformHelper/main.swift +0 -726
  65. /package/dist/{files-DwA7pzr3.d.cts → files-4ZEoAWiv.d.ts} +0 -0
  66. /package/dist/{files-VrkQlKIT.d.ts → files-Dt5mbzLq.d.cts} +0 -0
  67. /package/dist/{schema-DOOjfXvs.d.ts → schema-CYh6n8GS.d.ts} +0 -0
  68. /package/dist/{schema-ENSMX_1t.d.cts → schema-oeOrd3i1.d.cts} +0 -0
@@ -0,0 +1,201 @@
1
+ import Foundation
2
+
3
+ public enum BonsaiTextSnapshotValidator {
4
+ private struct Quantization: Decodable, Equatable {
5
+ let groupSize: Int
6
+ let bits: Int
7
+ let mode: String
8
+
9
+ private enum CodingKeys: String, CodingKey {
10
+ case groupSize = "group_size"
11
+ case bits
12
+ case mode
13
+ }
14
+
15
+ init(groupSize: Int, bits: Int, mode: String = "affine") {
16
+ self.groupSize = groupSize
17
+ self.bits = bits
18
+ self.mode = mode
19
+ }
20
+
21
+ init(from decoder: Decoder) throws {
22
+ let values = try decoder.container(keyedBy: CodingKeys.self)
23
+ groupSize = try values.decode(Int.self, forKey: .groupSize)
24
+ bits = try values.decode(Int.self, forKey: .bits)
25
+ mode = try values.decodeIfPresent(String.self, forKey: .mode) ?? "affine"
26
+ }
27
+ }
28
+
29
+ private struct Qwen3Identity: Decodable {
30
+ struct RopeScaling: Decodable {
31
+ let ropeType: String
32
+ let factor: Float
33
+ let originalMaximumPositions: Int
34
+
35
+ private enum CodingKeys: String, CodingKey {
36
+ case ropeType = "rope_type"
37
+ case factor
38
+ case originalMaximumPositions = "original_max_position_embeddings"
39
+ }
40
+ }
41
+
42
+ let hiddenActivation: String
43
+ let layerTypes: [String]
44
+ let ropeScaling: RopeScaling
45
+ let quantization: Quantization
46
+
47
+ private enum CodingKeys: String, CodingKey {
48
+ case hiddenActivation = "hidden_act"
49
+ case layerTypes = "layer_types"
50
+ case ropeScaling = "rope_scaling"
51
+ case quantization
52
+ }
53
+ }
54
+
55
+ private struct Qwen35TextIdentity: Decodable {
56
+ let modelType: String
57
+ let vocabularySize: Int
58
+ let hiddenSize: Int
59
+ let intermediateSize: Int
60
+ let hiddenLayerCount: Int
61
+ let attentionHeadCount: Int
62
+ let keyValueHeadCount: Int
63
+ let headDimension: Int
64
+ let maximumPositionCount: Int
65
+ let quantization: Quantization
66
+
67
+ private enum CodingKeys: String, CodingKey {
68
+ case modelType = "model_type"
69
+ case vocabularySize = "vocab_size"
70
+ case hiddenSize = "hidden_size"
71
+ case intermediateSize = "intermediate_size"
72
+ case hiddenLayerCount = "num_hidden_layers"
73
+ case attentionHeadCount = "num_attention_heads"
74
+ case keyValueHeadCount = "num_key_value_heads"
75
+ case headDimension = "head_dim"
76
+ case maximumPositionCount = "max_position_embeddings"
77
+ case quantization
78
+ }
79
+ }
80
+
81
+ private struct Qwen35Identity: Decodable {
82
+ let architectures: [String]
83
+ let modelType: String
84
+ let textConfiguration: Qwen35TextIdentity
85
+ let quantization: Quantization
86
+
87
+ private enum CodingKeys: String, CodingKey {
88
+ case architectures
89
+ case modelType = "model_type"
90
+ case textConfiguration = "text_config"
91
+ case quantization
92
+ }
93
+ }
94
+
95
+ public static func validate(modelURL: URL, profile: BonsaiTextModelProfile) throws {
96
+ var isDirectory: ObjCBool = false
97
+ guard FileManager.default.fileExists(atPath: modelURL.path, isDirectory: &isDirectory),
98
+ isDirectory.boolValue else {
99
+ throw BonsaiTextError.invalidModelPath(modelURL.path)
100
+ }
101
+
102
+ do {
103
+ switch profile.backend {
104
+ case .qwen3:
105
+ try validateQwen3(modelURL: modelURL, profile: profile)
106
+ case .qwen35:
107
+ try validateQwen35(modelURL: modelURL, profile: profile)
108
+ }
109
+ } catch let error as BonsaiTextError {
110
+ throw error
111
+ } catch {
112
+ throw BonsaiTextError.snapshotMismatch(model: profile.id, detail: error.localizedDescription)
113
+ }
114
+ }
115
+
116
+ private static func validateQwen3(modelURL: URL, profile: BonsaiTextModelProfile) throws {
117
+ let configuration = try Qwen3ModelConfiguration.load(from: modelURL)
118
+ try configuration.validate()
119
+ let data = try Data(contentsOf: modelURL.appendingPathComponent("config.json"))
120
+ let identity = try JSONDecoder().decode(Qwen3Identity.self, from: data)
121
+ guard configuration.architectures == ["Qwen3ForCausalLM"], configuration.modelType == "qwen3" else {
122
+ throw mismatch(profile, "expected Qwen3ForCausalLM/qwen3 architecture")
123
+ }
124
+ guard configuration.vocabularySize == profile.vocabularySize,
125
+ configuration.hiddenSize == profile.hiddenSize,
126
+ configuration.intermediateSize == profile.intermediateSize,
127
+ configuration.hiddenLayerCount == profile.hiddenLayerCount,
128
+ configuration.attentionHeadCount == profile.attentionHeadCount,
129
+ configuration.keyValueHeadCount == profile.keyValueHeadCount,
130
+ configuration.headDimension == profile.headDimension,
131
+ configuration.maximumPositionCount == profile.contextLength else {
132
+ throw mismatch(profile, "Qwen3 dimensions do not match the stable model ID")
133
+ }
134
+ let expectedRopeTheta: Float = switch profile.id {
135
+ case .ternaryBonsai4B: 5_000_000
136
+ default: 1_000_000
137
+ }
138
+ guard configuration.rmsNormEpsilon == 1e-6,
139
+ configuration.ropeTheta == expectedRopeTheta,
140
+ configuration.attentionBias == false,
141
+ configuration.tieWordEmbeddings == (profile.id != .ternaryBonsai8B),
142
+ identity.hiddenActivation == "silu",
143
+ identity.layerTypes.count == profile.hiddenLayerCount,
144
+ identity.layerTypes.allSatisfy({ $0 == "full_attention" }),
145
+ identity.ropeScaling.ropeType == "yarn",
146
+ identity.ropeScaling.factor == 4,
147
+ identity.ropeScaling.originalMaximumPositions == profile.contextLength / 4 else {
148
+ throw mismatch(profile, "Qwen3 architecture settings do not match the stable model ID")
149
+ }
150
+ guard configuration.quantization?.bits == 2,
151
+ configuration.quantization?.groupSize == 128,
152
+ identity.quantization == Quantization(groupSize: 128, bits: 2) else {
153
+ throw mismatch(profile, "expected packed 2-bit/group-128 quantization")
154
+ }
155
+ }
156
+
157
+ private static func validateQwen35(modelURL: URL, profile: BonsaiTextModelProfile) throws {
158
+ let data = try Data(contentsOf: modelURL.appendingPathComponent("config.json"))
159
+ let identity = try JSONDecoder().decode(Qwen35Identity.self, from: data)
160
+ let text = identity.textConfiguration
161
+ guard identity.architectures == ["Qwen3_5ForConditionalGeneration"],
162
+ identity.modelType == "qwen3_5", text.modelType == "qwen3_5_text" else {
163
+ throw mismatch(profile, "expected Qwen3.5 conditional-generation/text architecture")
164
+ }
165
+ guard text.vocabularySize == profile.vocabularySize,
166
+ text.hiddenSize == profile.hiddenSize,
167
+ text.intermediateSize == profile.intermediateSize,
168
+ text.hiddenLayerCount == profile.hiddenLayerCount,
169
+ text.attentionHeadCount == profile.attentionHeadCount,
170
+ text.keyValueHeadCount == profile.keyValueHeadCount,
171
+ text.headDimension == profile.headDimension,
172
+ text.maximumPositionCount == profile.contextLength else {
173
+ throw mismatch(profile, "Qwen3.5 dimensions do not match the stable model ID")
174
+ }
175
+ let requiredQuantization = Quantization(groupSize: 128, bits: 2)
176
+ guard identity.quantization == requiredQuantization,
177
+ text.quantization == requiredQuantization else {
178
+ throw mismatch(profile, "Qwen3.5 requires 2-bit/group-128 at both snapshot levels")
179
+ }
180
+
181
+ let backendConfiguration = try Qwen35Configuration.load(from: modelURL)
182
+ try backendConfiguration.validate(expectedQuantization: Qwen35Quantization(groupSize: 128, bits: 2))
183
+ guard backendConfiguration.rmsNormEps == 1e-6,
184
+ backendConfiguration.attentionBias == false,
185
+ backendConfiguration.tieWordEmbeddings == false,
186
+ backendConfiguration.fullAttentionInterval == 4,
187
+ backendConfiguration.linearConvKernelDim == 4,
188
+ backendConfiguration.linearKeyHeadDim == 128,
189
+ backendConfiguration.linearValueHeadDim == 128,
190
+ backendConfiguration.linearNumKeyHeads == 16,
191
+ backendConfiguration.linearNumValueHeads == 48,
192
+ backendConfiguration.ropeTheta == 10_000_000,
193
+ backendConfiguration.partialRotaryFactor == 0.25 else {
194
+ throw mismatch(profile, "Qwen3.5 architecture settings do not match the stable model ID")
195
+ }
196
+ }
197
+
198
+ private static func mismatch(_ profile: BonsaiTextModelProfile, _ detail: String) -> BonsaiTextError {
199
+ .snapshotMismatch(model: profile.id, detail: detail)
200
+ }
201
+ }
@@ -0,0 +1,85 @@
1
+ import Foundation
2
+
3
+ public struct GenerationParameters: Sendable {
4
+ public var maximumNewTokens: Int
5
+ public var temperature: Float
6
+ public var topP: Float
7
+ public var topK: Int?
8
+ public var repetitionPenalty: Float
9
+ public var repetitionContextSize: Int
10
+ public var seed: UInt64?
11
+
12
+ public init(
13
+ maximumNewTokens: Int = 512,
14
+ temperature: Float = 0.7,
15
+ topP: Float = 0.95,
16
+ topK: Int? = nil,
17
+ repetitionPenalty: Float = 1,
18
+ repetitionContextSize: Int = 64,
19
+ seed: UInt64? = nil
20
+ ) {
21
+ self.maximumNewTokens = maximumNewTokens
22
+ self.temperature = temperature
23
+ self.topP = topP
24
+ self.topK = topK
25
+ self.repetitionPenalty = repetitionPenalty
26
+ self.repetitionContextSize = repetitionContextSize
27
+ self.seed = seed
28
+ }
29
+
30
+ public static let balanced = GenerationParameters()
31
+ }
32
+
33
+ public enum GenerationFinishReason: String, Codable, Sendable {
34
+ case endOfSequence
35
+ case length
36
+ case cancelled
37
+ case callbackStopped
38
+ }
39
+
40
+ public struct TokenGenerationResult: Sendable {
41
+ public let promptTokenCount: Int
42
+ public let tokenIDs: [Int]
43
+ public let finishReason: GenerationFinishReason
44
+
45
+ public init(promptTokenCount: Int, tokenIDs: [Int], finishReason: GenerationFinishReason) {
46
+ self.promptTokenCount = promptTokenCount
47
+ self.tokenIDs = tokenIDs
48
+ self.finishReason = finishReason
49
+ }
50
+ }
51
+
52
+ public enum TextGenerationError: LocalizedError {
53
+ case emptyPrompt
54
+ case invalidMaximumNewTokens(Int)
55
+ case contextExceeded(requested: Int, maximum: Int)
56
+ case invalidTokenID(Int)
57
+ case invalidTemperature(Float)
58
+ case invalidTopP(Float)
59
+ case invalidTopK(Int)
60
+ case invalidRepetitionPenalty(Float)
61
+ case invalidRepetitionContextSize(Int)
62
+
63
+ public var errorDescription: String? {
64
+ switch self {
65
+ case .emptyPrompt:
66
+ "The pre-tokenized prompt is empty"
67
+ case .invalidMaximumNewTokens(let value):
68
+ "Maximum new tokens must be nonnegative, got \(value)"
69
+ case .contextExceeded(let requested, let maximum):
70
+ "Requested context length \(requested) exceeds model maximum \(maximum)"
71
+ case .invalidTokenID(let value):
72
+ "Token ID \(value) is outside the model vocabulary"
73
+ case .invalidTemperature(let value):
74
+ "Temperature must be nonnegative, got \(value)"
75
+ case .invalidTopP(let value):
76
+ "Top-p must be in (0, 1], got \(value)"
77
+ case .invalidTopK(let value):
78
+ "Top-k must be positive, got \(value)"
79
+ case .invalidRepetitionPenalty(let value):
80
+ "Repetition penalty must be positive, got \(value)"
81
+ case .invalidRepetitionContextSize(let value):
82
+ "Repetition context size must be nonnegative, got \(value)"
83
+ }
84
+ }
85
+ }
@@ -0,0 +1,21 @@
1
+ import MLX
2
+
3
+ /// Encoding is an independent capability; callers do not need a causal LM head.
4
+ public protocol HiddenStateModel: AnyObject {
5
+ var vocabularySize: Int { get }
6
+ var maximumContextLength: Int { get }
7
+
8
+ func hiddenStates(
9
+ for inputIDs: MLXArray,
10
+ at layerIndices: Set<Int>,
11
+ attentionMask: MLXArray?
12
+ ) -> [Int: MLXArray]
13
+ }
14
+
15
+ public protocol CausalLanguageModel: AnyObject {
16
+ var vocabularySize: Int { get }
17
+ var maximumContextLength: Int { get }
18
+
19
+ func makeCache() -> [TextKVCache]
20
+ func logits(for inputIDs: MLXArray, cache: [TextKVCache]) -> MLXArray
21
+ }
@@ -0,0 +1,23 @@
1
+ import MLX
2
+
3
+ public final class TextKVCache {
4
+ private var keys: MLXArray?
5
+ private var values: MLXArray?
6
+
7
+ public private(set) var length = 0
8
+
9
+ public init() {}
10
+
11
+ public func update(keys newKeys: MLXArray, values newValues: MLXArray) -> (MLXArray, MLXArray) {
12
+ keys = keys.map { concatenated([$0, newKeys], axis: 2) } ?? newKeys
13
+ values = values.map { concatenated([$0, newValues], axis: 2) } ?? newValues
14
+ length += newKeys.dim(2)
15
+ return (keys!, values!)
16
+ }
17
+
18
+ public func clear() {
19
+ keys = nil
20
+ values = nil
21
+ length = 0
22
+ }
23
+ }
@@ -0,0 +1,79 @@
1
+ import MLX
2
+
3
+ public enum TokenSampler {
4
+ public static func validate(_ parameters: GenerationParameters) throws {
5
+ guard parameters.maximumNewTokens >= 0 else {
6
+ throw TextGenerationError.invalidMaximumNewTokens(parameters.maximumNewTokens)
7
+ }
8
+ guard parameters.temperature >= 0 else {
9
+ throw TextGenerationError.invalidTemperature(parameters.temperature)
10
+ }
11
+ guard parameters.topP > 0, parameters.topP <= 1 else {
12
+ throw TextGenerationError.invalidTopP(parameters.topP)
13
+ }
14
+ if let topK = parameters.topK, topK <= 0 {
15
+ throw TextGenerationError.invalidTopK(topK)
16
+ }
17
+ guard parameters.repetitionPenalty > 0 else {
18
+ throw TextGenerationError.invalidRepetitionPenalty(parameters.repetitionPenalty)
19
+ }
20
+ guard parameters.repetitionContextSize >= 0 else {
21
+ throw TextGenerationError.invalidRepetitionContextSize(parameters.repetitionContextSize)
22
+ }
23
+ }
24
+
25
+ public static func sample(
26
+ logits: MLXArray,
27
+ parameters: GenerationParameters,
28
+ tokenHistory: [Int]
29
+ ) throws -> MLXArray {
30
+ try validate(parameters)
31
+ let selected: MLXArray
32
+ switch logits.ndim {
33
+ case 3: selected = logits[0, -1]
34
+ case 2: selected = logits[0]
35
+ default: selected = logits
36
+ }
37
+ let values = selected
38
+
39
+ if parameters.repetitionPenalty != 1,
40
+ parameters.repetitionContextSize > 0,
41
+ !tokenHistory.isEmpty {
42
+ let recent = Set(tokenHistory.suffix(parameters.repetitionContextSize))
43
+ let valid = recent.filter { $0 >= 0 && $0 < values.dim(-1) }
44
+ if !valid.isEmpty {
45
+ let indices = MLXArray(valid.sorted().map(Int32.init))
46
+ let repeated = MLX.take(values, indices, axis: -1)
47
+ values[indices] = MLX.where(
48
+ repeated .> 0,
49
+ repeated / parameters.repetitionPenalty,
50
+ repeated * parameters.repetitionPenalty
51
+ )
52
+ }
53
+ }
54
+
55
+ if parameters.temperature == 0 {
56
+ return argMax(values).asType(Int32.self)
57
+ }
58
+
59
+ // Match mlx-lm ordering: select top-p from untempered probabilities.
60
+ let probabilities = softmax(values, axis: -1)
61
+ let candidateIndices: MLXArray
62
+ if let topK = parameters.topK, topK < probabilities.dim(-1) {
63
+ candidateIndices = argPartition(probabilities, kth: -topK, axis: -1)[(-topK)...]
64
+ } else {
65
+ candidateIndices = MLXArray.arange(probabilities.dim(-1))
66
+ }
67
+ let candidates = take(probabilities, candidateIndices, axis: -1)
68
+ let order = argSort(-candidates, axis: -1)
69
+ let sortedIndices = take(candidateIndices, order, axis: -1)
70
+ let sortedProbabilities = take(candidates, order, axis: -1)
71
+ let cumulativeBefore = cumsum(sortedProbabilities, axis: -1) - sortedProbabilities
72
+ let filtered = MLX.where(
73
+ cumulativeBefore .< parameters.topP,
74
+ log(sortedProbabilities) / parameters.temperature,
75
+ MLX.full(sortedProbabilities.shape, values: -Float.infinity)
76
+ )
77
+ return sortedIndices[MLXRandom.categorical(filtered)].asType(Int32.self)
78
+ }
79
+ }
@@ -0,0 +1,161 @@
1
+ import Foundation
2
+ import MLX
3
+ import MLXNN
4
+
5
+ public enum Qwen3CheckpointError: LocalizedError {
6
+ case missingFile(String)
7
+ case unsupportedArchitecture(String)
8
+ case unsupportedQuantization(bits: Int?, groupSize: Int?)
9
+ case invalidConfiguration(String)
10
+ case invalidCheckpoint(String)
11
+
12
+ public var errorDescription: String? {
13
+ switch self {
14
+ case .missingFile(let file): "Qwen3 checkpoint is missing \(file)"
15
+ case .unsupportedArchitecture(let architecture): "Unsupported Qwen3 architecture \(architecture)"
16
+ case .unsupportedQuantization(let bits, let groupSize):
17
+ "Bonsai Qwen3 requires 2-bit/group-128 affine weights, got bits=\(bits.map(String.init) ?? "nil") group=\(groupSize.map(String.init) ?? "nil")"
18
+ case .invalidConfiguration(let message): "Invalid Qwen3 configuration: \(message)"
19
+ case .invalidCheckpoint(let message): "Invalid Qwen3 checkpoint: \(message)"
20
+ }
21
+ }
22
+ }
23
+
24
+ public enum Qwen3CheckpointLoader {
25
+ private struct SafetensorsIndex: Decodable {
26
+ let weightMap: [String: String]
27
+
28
+ enum CodingKeys: String, CodingKey {
29
+ case weightMap = "weight_map"
30
+ }
31
+ }
32
+
33
+ public static func load(from modelURL: URL) throws -> Qwen3CausalLM {
34
+ let (configuration, quantization, tensors) = try loadSnapshot(from: modelURL, requireLanguageHead: true)
35
+
36
+ let model = Qwen3CausalLM(configuration: configuration)
37
+ MLXNN.quantize(
38
+ model: model,
39
+ groupSize: quantization.groupSize,
40
+ bits: quantization.bits,
41
+ mode: .affine
42
+ )
43
+ do {
44
+ try model.update(
45
+ parameters: ModuleParameters.unflattened(tensors),
46
+ verify: [.noUnusedKeys, .shapeMismatch]
47
+ )
48
+ } catch {
49
+ throw Qwen3CheckpointError.invalidCheckpoint(error.localizedDescription)
50
+ }
51
+ model.usesTiedOutputHead = configuration.tieWordEmbeddings && tensors["lm_head.weight"] == nil
52
+ MLX.eval(model)
53
+ MLX.Memory.clearCache()
54
+ return model
55
+ }
56
+
57
+ public static func loadTransformer(from modelURL: URL) throws -> Qwen3TransformerModel {
58
+ let (configuration, quantization, tensors) = try loadSnapshot(from: modelURL, requireLanguageHead: false)
59
+ let model = Qwen3TransformerModel(configuration: configuration)
60
+ MLXNN.quantize(
61
+ model: model,
62
+ groupSize: quantization.groupSize,
63
+ bits: quantization.bits,
64
+ mode: .affine
65
+ )
66
+ let transformerTensors: [String: MLXArray] = Dictionary(uniqueKeysWithValues: tensors.compactMap { name, value in
67
+ guard name.hasPrefix("model.") else { return nil }
68
+ return (String(name.dropFirst("model.".count)), value)
69
+ })
70
+ do {
71
+ try model.update(
72
+ parameters: ModuleParameters.unflattened(transformerTensors),
73
+ verify: [.noUnusedKeys, .shapeMismatch]
74
+ )
75
+ } catch {
76
+ throw Qwen3CheckpointError.invalidCheckpoint(error.localizedDescription)
77
+ }
78
+ MLX.eval(model)
79
+ MLX.Memory.clearCache()
80
+ return model
81
+ }
82
+
83
+ private static func loadSnapshot(
84
+ from modelURL: URL,
85
+ requireLanguageHead: Bool
86
+ ) throws -> (Qwen3ModelConfiguration, Qwen3ModelConfiguration.Quantization, [String: MLXArray]) {
87
+ let configuration = try Qwen3ModelConfiguration.load(from: modelURL)
88
+ try configuration.validate()
89
+ guard let quantization = configuration.quantization,
90
+ quantization.bits == 2, quantization.groupSize == 128 else {
91
+ throw Qwen3CheckpointError.unsupportedQuantization(
92
+ bits: configuration.quantization?.bits,
93
+ groupSize: configuration.quantization?.groupSize
94
+ )
95
+ }
96
+ let files = try checkpointFiles(at: modelURL)
97
+ var tensors: [String: MLXArray] = [:]
98
+ for file in files {
99
+ for (name, value) in try loadArrays(url: modelURL.appendingPathComponent(file)) {
100
+ tensors[name] = value
101
+ }
102
+ }
103
+ try validateTensors(
104
+ tensors,
105
+ configuration: configuration,
106
+ requireLanguageHead: requireLanguageHead
107
+ )
108
+ return (configuration, quantization, tensors)
109
+ }
110
+
111
+ private static func checkpointFiles(at modelURL: URL) throws -> [String] {
112
+ let fileManager = FileManager.default
113
+ let indexURL = modelURL.appendingPathComponent("model.safetensors.index.json")
114
+ if fileManager.fileExists(atPath: indexURL.path) {
115
+ let index = try JSONDecoder().decode(SafetensorsIndex.self, from: Data(contentsOf: indexURL))
116
+ let files = Set(index.weightMap.values).sorted()
117
+ for file in files where !fileManager.fileExists(atPath: modelURL.appendingPathComponent(file).path) {
118
+ throw Qwen3CheckpointError.missingFile(file)
119
+ }
120
+ return files
121
+ }
122
+ let file = "model.safetensors"
123
+ guard fileManager.fileExists(atPath: modelURL.appendingPathComponent(file).path) else {
124
+ throw Qwen3CheckpointError.missingFile(file)
125
+ }
126
+ return [file]
127
+ }
128
+
129
+ private static func validateTensors(
130
+ _ tensors: [String: MLXArray],
131
+ configuration: Qwen3ModelConfiguration,
132
+ requireLanguageHead: Bool
133
+ ) throws {
134
+ var requiredModules = ["model.embed_tokens"]
135
+ for index in 0..<configuration.hiddenLayerCount {
136
+ let prefix = "model.layers.\(index)"
137
+ requiredModules += [
138
+ "\(prefix).self_attn.q_proj", "\(prefix).self_attn.k_proj",
139
+ "\(prefix).self_attn.v_proj", "\(prefix).self_attn.o_proj",
140
+ "\(prefix).mlp.gate_proj", "\(prefix).mlp.up_proj", "\(prefix).mlp.down_proj",
141
+ ]
142
+ for norm in ["input_layernorm", "post_attention_layernorm", "self_attn.q_norm", "self_attn.k_norm"] {
143
+ let key = "\(prefix).\(norm).weight"
144
+ guard tensors[key] != nil else { throw Qwen3CheckpointError.invalidCheckpoint("missing tensor \(key)") }
145
+ }
146
+ }
147
+ if requireLanguageHead, !configuration.tieWordEmbeddings { requiredModules.append("lm_head") }
148
+ for module in requiredModules {
149
+ for suffix in ["weight", "scales", "biases"] {
150
+ let key = "\(module).\(suffix)"
151
+ guard tensors[key] != nil else { throw Qwen3CheckpointError.invalidCheckpoint("missing tensor \(key)") }
152
+ }
153
+ guard tensors["\(module).weight"]?.dtype == .uint32 else {
154
+ throw Qwen3CheckpointError.invalidCheckpoint("\(module).weight is not packed uint32")
155
+ }
156
+ }
157
+ guard tensors["model.norm.weight"] != nil else {
158
+ throw Qwen3CheckpointError.invalidCheckpoint("missing tensor model.norm.weight")
159
+ }
160
+ }
161
+ }