bleam 0.0.9 → 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 +142 -94
  6. package/dist/cli.d.cts +11 -27
  7. package/dist/cli.d.ts +11 -27
  8. package/dist/cli.js +143 -95
  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 -26
  13. package/dist/platform.d.ts +1 -26
  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-CHc4xEs_.d.ts} +7 -7
  20. package/dist/ui.d.ts +1 -1
  21. package/dist/window.d.ts +1 -1
  22. package/package.json +2 -1
  23. package/templates/image-generation/app/index.tsx +3 -1
  24. package/templates/native/ios/Bleam/AI/Flux2/Configuration/ModelRegistry.swift +1 -1
  25. package/templates/native/ios/Bleam/AI/Flux2/Pipeline/FluxImageRunner.swift +7 -2
  26. package/templates/native/ios/Bleam/AI/TextGeneration/Bonsai/BonsaiTextGenerator.swift +46 -0
  27. package/templates/native/ios/Bleam/AI/TextGeneration/Bonsai/BonsaiTextModel.swift +107 -0
  28. package/templates/native/ios/Bleam/AI/TextGeneration/Bonsai/BonsaiTextSnapshotValidator.swift +201 -0
  29. package/templates/native/ios/Bleam/AI/TextGeneration/Core/GenerationTypes.swift +85 -0
  30. package/templates/native/ios/Bleam/AI/TextGeneration/Core/ModelCapabilities.swift +21 -0
  31. package/templates/native/ios/Bleam/AI/TextGeneration/Core/TextKVCache.swift +23 -0
  32. package/templates/native/ios/Bleam/AI/TextGeneration/Core/TokenSampler.swift +79 -0
  33. package/templates/native/ios/Bleam/AI/TextGeneration/Qwen3/Qwen3CheckpointLoader.swift +161 -0
  34. package/templates/native/ios/Bleam/AI/TextGeneration/Qwen3/Qwen3Configuration.swift +119 -0
  35. package/templates/native/ios/Bleam/AI/TextGeneration/Qwen3/Qwen3InferenceSession.swift +84 -0
  36. package/templates/native/ios/Bleam/AI/TextGeneration/Qwen3/Qwen3Layers.swift +197 -0
  37. package/templates/native/ios/Bleam/AI/TextGeneration/Qwen3/Qwen3Model.swift +130 -0
  38. package/templates/native/ios/Bleam/AI/TextGeneration/Qwen35/Qwen35Checkpoint.swift +102 -0
  39. package/templates/native/ios/Bleam/AI/TextGeneration/Qwen35/Qwen35Configuration.swift +140 -0
  40. package/templates/native/ios/Bleam/AI/TextGeneration/Qwen35/Qwen35Model.swift +112 -0
  41. package/templates/native/ios/Bleam/AI/TextGeneration/Qwen35/Qwen35Session.swift +121 -0
  42. package/templates/native/ios/Bleam/AI/TextGeneration/QwenHybrid/QwenHybridCache.swift +42 -0
  43. package/templates/native/ios/Bleam/AI/TextGeneration/QwenHybrid/QwenHybridFullAttention.swift +57 -0
  44. package/templates/native/ios/Bleam/AI/TextGeneration/QwenHybrid/QwenHybridLayers.swift +58 -0
  45. package/templates/native/ios/Bleam/AI/TextGeneration/QwenHybrid/QwenHybridLinearAttention.swift +111 -0
  46. package/templates/native/ios/Bleam/AppDelegate.swift +0 -97
  47. package/templates/native/ios/Bleam.xcodeproj/project.pbxproj +128 -121
  48. package/templates/native/ios/GenerationService/BonsaiTextGenerationRunner.swift +103 -0
  49. package/templates/native/ios/GenerationService/ImageGenerationRunner.swift +19 -7
  50. package/templates/native/ios/GenerationService/main.swift +174 -48
  51. package/templates/native/ios/Shared/Generation/GenerationServiceProtocol.swift +3 -0
  52. package/templates/native/ios/Shared/Generation/GenerationWorkerProtocol.swift +44 -0
  53. package/templates/native/modules/bleam-runtime/ios/AIModule.swift +225 -5
  54. package/templates/native/modules/bleam-runtime/ios/GenerationContracts.swift +47 -0
  55. package/templates/native/modules/bleam-runtime/ios/PlatformModule.swift +0 -175
  56. package/templates/text-generation/app/index.tsx +93 -0
  57. package/templates/text-generation/app.config.ts +5 -0
  58. package/templates/updates/README.md +116 -0
  59. package/templates/updates/src/index.ts +292 -0
  60. package/templates/updates/src/schema.ts +230 -0
  61. package/templates/updates/tsconfig.json +12 -0
  62. package/templates/updates/wrangler.jsonc +19 -0
  63. package/templates/native/ios/PlatformHelper/Info.plist +0 -29
  64. package/templates/native/ios/PlatformHelper/main.swift +0 -335
  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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bleam",
3
- "version": "0.0.9",
3
+ "version": "0.0.11",
4
4
  "packageManager": "yarn@1.22.22",
5
5
  "type": "module",
6
6
  "files": [
@@ -39,6 +39,7 @@
39
39
  "react-native": "0.86.0",
40
40
  "tsx": "^4.21.0",
41
41
  "typescript": "~6.0.3",
42
+ "wrangler": "4.110.0",
42
43
  "ws": "^8.21.0",
43
44
  "yocto-spinner": "^1.2.1",
44
45
  "yoctocolors": "^2.1.2"
@@ -1,4 +1,4 @@
1
- import { generateImage } from 'bleam/ai'
1
+ import { bonsai, generateImage } from 'bleam/ai'
2
2
  import { sx } from 'bleam/styles'
3
3
  import { Button } from 'bleam/ui'
4
4
  import { Window } from 'bleam/window'
@@ -6,6 +6,7 @@ import { useState } from 'react'
6
6
  import { Image, Text, View } from 'react-native'
7
7
 
8
8
  const appName = 'Bleam Basic'
9
+ const model = bonsai('ternary-bonsai-image-4b')
9
10
 
10
11
  export default function App() {
11
12
  const [uri, setUri] = useState<string>()
@@ -15,6 +16,7 @@ export default function App() {
15
16
  setStatus('Generating...')
16
17
  try {
17
18
  const result = await generateImage({
19
+ model,
18
20
  prompt: 'A precise editorial illustration of a small native Mac app',
19
21
  aspectRatio: '1:1',
20
22
  })
@@ -152,7 +152,7 @@ public enum ModelRegistry {
152
152
  case klein4B_bf16 = "klein4b-bf16"
153
153
  case klein4B_8bit = "klein4b-8bit"
154
154
  case klein4B_4bit = "klein4b-4bit"
155
- case bonsaiKlein4B_2bit = "bonsai-klein4b-2bit"
155
+ case bonsaiKlein4B_2bit = "ternary-bonsai-image-4b"
156
156
  case bonsaiKlein4B_1bit = "bonsai-klein4b-1bit"
157
157
  case klein9B_8bit = "klein9b-8bit"
158
158
  case klein9B_4bit = "klein9b-4bit"
@@ -198,12 +198,17 @@ public final class FluxImageRunner: @unchecked Sendable {
198
198
  private static let referenceImageMaxLongSide = 768
199
199
 
200
200
  private let paths: FluxImageRunnerPaths
201
+ private let transformerProfile: Flux2TransformerRepoProfile
201
202
  private let scheduler = FlowMatchEulerScheduler()
202
203
  private var transformer: Flux2Transformer2DModel?
203
204
  private var vae: AutoencoderKLFlux2?
204
205
 
205
- public init(paths: FluxImageRunnerPaths) {
206
+ public init(
207
+ paths: FluxImageRunnerPaths,
208
+ transformerProfile: Flux2TransformerRepoProfile = ModelRegistry.TransformerRepo.moxinOrgKlein4B8bit.profile
209
+ ) {
206
210
  self.paths = paths
211
+ self.transformerProfile = transformerProfile
207
212
  }
208
213
 
209
214
  public func generate(
@@ -1237,7 +1242,7 @@ public final class FluxImageRunner: @unchecked Sendable {
1237
1242
  return transformer
1238
1243
  }
1239
1244
 
1240
- let profile = ModelRegistry.TransformerRepo.moxinOrgKlein4B8bit.profile
1245
+ let profile = transformerProfile
1241
1246
  let configURL = paths.transformer.appendingPathComponent("config.json")
1242
1247
  let config = FileManager.default.fileExists(atPath: configURL.path)
1243
1248
  ? (try? Flux2TransformerConfig.load(from: configURL)) ?? .klein4B
@@ -0,0 +1,46 @@
1
+ import Foundation
2
+ import MLX
3
+
4
+ public enum BonsaiTextGenerator {
5
+ public static func generate(
6
+ profile: BonsaiTextModelProfile,
7
+ modelURL: URL,
8
+ inputIDs: [Int],
9
+ endTokenIDs: Set<Int>? = nil,
10
+ parameters: GenerationParameters? = nil,
11
+ isCancelled: @escaping @Sendable () -> Bool,
12
+ onToken: @escaping @Sendable (Int) -> Bool
13
+ ) async throws -> TokenGenerationResult {
14
+ try BonsaiTextSnapshotValidator.validate(modelURL: modelURL, profile: profile)
15
+ let parameters = parameters ?? profile.defaultGenerationParameters
16
+ let endTokenIDs = endTokenIDs ?? profile.defaultEndTokenIDs
17
+ defer { MLX.Memory.clearCache() }
18
+
19
+ switch profile.backend {
20
+ case .qwen3:
21
+ try Task.checkCancellation()
22
+ let model = try Qwen3CheckpointLoader.load(from: modelURL)
23
+ try Task.checkCancellation()
24
+ return try Qwen3InferenceSession(model: model, endTokenIDs: endTokenIDs).generate(
25
+ inputIDs: inputIDs,
26
+ parameters: parameters,
27
+ isCancelled: { Task.isCancelled || isCancelled() },
28
+ onToken: onToken
29
+ )
30
+ case .qwen35:
31
+ let model = try Qwen35Checkpoint.load(
32
+ from: modelURL,
33
+ expectedQuantization: Qwen35Quantization(groupSize: 128, bits: 2)
34
+ )
35
+ let session = Qwen35Session(model: model)
36
+ defer { session.reset() }
37
+ return try await session.generate(
38
+ inputIDs: inputIDs,
39
+ parameters: parameters,
40
+ endTokenIDs: endTokenIDs,
41
+ isCancelled: { Task.isCancelled || isCancelled() },
42
+ onToken: onToken
43
+ )
44
+ }
45
+ }
46
+ }
@@ -0,0 +1,107 @@
1
+ import Foundation
2
+
3
+ public enum BonsaiTextModelID: String, CaseIterable, Codable, Sendable {
4
+ case ternaryBonsai17B = "ternary-bonsai-1.7b"
5
+ case ternaryBonsai4B = "ternary-bonsai-4b"
6
+ case ternaryBonsai8B = "ternary-bonsai-8b"
7
+ case ternaryBonsai27B = "ternary-bonsai-27b"
8
+ }
9
+
10
+ public enum BonsaiTextBackend: Sendable {
11
+ case qwen3
12
+ case qwen35
13
+ }
14
+
15
+ public struct BonsaiTextModelProfile: Sendable {
16
+ public let id: BonsaiTextModelID
17
+ public let backend: BonsaiTextBackend
18
+ public let vocabularySize: Int
19
+ public let hiddenSize: Int
20
+ public let intermediateSize: Int
21
+ public let hiddenLayerCount: Int
22
+ public let attentionHeadCount: Int
23
+ public let keyValueHeadCount: Int
24
+ public let headDimension: Int
25
+ public let contextLength: Int
26
+ public let defaultEndTokenIDs: Set<Int>
27
+ public let defaultGenerationParameters: GenerationParameters
28
+
29
+ public static func resolve(_ value: String) throws -> Self {
30
+ guard let id = BonsaiTextModelID(rawValue: value) else {
31
+ throw BonsaiTextError.unsupportedModel(value)
32
+ }
33
+ return profile(for: id)
34
+ }
35
+
36
+ public static func profile(for id: BonsaiTextModelID) -> Self {
37
+ switch id {
38
+ case .ternaryBonsai17B:
39
+ return Self(
40
+ id: id, backend: .qwen3, vocabularySize: 151_669, hiddenSize: 2_048,
41
+ intermediateSize: 6_144, hiddenLayerCount: 28, attentionHeadCount: 16,
42
+ keyValueHeadCount: 8, headDimension: 128, contextLength: 32_768,
43
+ defaultEndTokenIDs: [151_643, 151_645],
44
+ defaultGenerationParameters: GenerationParameters(
45
+ maximumNewTokens: 256, temperature: 0.5, topP: 0.85,
46
+ repetitionPenalty: 1, repetitionContextSize: 64
47
+ )
48
+ )
49
+ case .ternaryBonsai4B:
50
+ return Self(
51
+ id: id, backend: .qwen3, vocabularySize: 151_669, hiddenSize: 2_560,
52
+ intermediateSize: 9_728, hiddenLayerCount: 36, attentionHeadCount: 32,
53
+ keyValueHeadCount: 8, headDimension: 128, contextLength: 32_768,
54
+ defaultEndTokenIDs: [151_643, 151_645],
55
+ defaultGenerationParameters: GenerationParameters(
56
+ maximumNewTokens: 256, temperature: 0.5, topP: 0.85,
57
+ repetitionPenalty: 1, repetitionContextSize: 64
58
+ )
59
+ )
60
+ case .ternaryBonsai8B:
61
+ return Self(
62
+ id: id, backend: .qwen3, vocabularySize: 151_669, hiddenSize: 4_096,
63
+ intermediateSize: 12_288, hiddenLayerCount: 36, attentionHeadCount: 32,
64
+ keyValueHeadCount: 8, headDimension: 128, contextLength: 65_536,
65
+ defaultEndTokenIDs: [151_643, 151_645],
66
+ defaultGenerationParameters: GenerationParameters(
67
+ maximumNewTokens: 256, temperature: 0.5, topP: 0.85,
68
+ repetitionPenalty: 1, repetitionContextSize: 64
69
+ )
70
+ )
71
+ case .ternaryBonsai27B:
72
+ return Self(
73
+ id: id, backend: .qwen35, vocabularySize: 248_320, hiddenSize: 5_120,
74
+ intermediateSize: 17_408, hiddenLayerCount: 64, attentionHeadCount: 24,
75
+ keyValueHeadCount: 4, headDimension: 256, contextLength: 262_144,
76
+ defaultEndTokenIDs: [248_044, 248_046],
77
+ defaultGenerationParameters: GenerationParameters(
78
+ maximumNewTokens: 2_048, temperature: 0.7, topP: 0.95, topK: 20,
79
+ repetitionPenalty: 1, repetitionContextSize: 128
80
+ )
81
+ )
82
+ }
83
+ }
84
+ }
85
+
86
+ public enum BonsaiTextError: LocalizedError {
87
+ case unsupportedModel(String)
88
+ case invalidModelPath(String)
89
+ case snapshotMismatch(model: BonsaiTextModelID, detail: String)
90
+ case invalidSeed(Int)
91
+ case invalidGenerationParameters(String)
92
+
93
+ public var errorDescription: String? {
94
+ switch self {
95
+ case .unsupportedModel(let value):
96
+ "Unsupported Bonsai text model: \(value)"
97
+ case .invalidModelPath(let path):
98
+ "Bonsai model path is not a directory: \(path)"
99
+ case .snapshotMismatch(let model, let detail):
100
+ "Snapshot does not match \(model.rawValue): \(detail)"
101
+ case .invalidSeed(let seed):
102
+ "Seed must be nonnegative, got \(seed)"
103
+ case .invalidGenerationParameters(let detail):
104
+ "Invalid generation parameters: \(detail)"
105
+ }
106
+ }
107
+ }
@@ -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
+ }