File size: 28,274 Bytes
dfe210b
 
305fc2a
 
 
80c945e
 
 
305fc2a
 
 
80c945e
 
305fc2a
 
 
 
dfe210b
 
80c945e
dfe210b
 
 
80c945e
 
 
 
 
 
305fc2a
 
 
 
 
 
80c945e
dfe210b
 
 
 
80c945e
305fc2a
 
80c945e
dfe210b
 
 
 
 
 
80c945e
 
 
305fc2a
 
 
 
 
 
 
 
 
 
 
dfe210b
 
 
 
 
 
 
 
 
 
 
80c945e
 
 
 
 
 
 
 
 
 
 
 
 
 
dfe210b
 
 
 
 
 
80c945e
dfe210b
80c945e
 
305fc2a
 
80c945e
 
dfe210b
 
 
80c945e
 
 
 
305fc2a
 
dfe210b
305fc2a
 
80c945e
 
305fc2a
 
80c945e
dfe210b
80c945e
 
 
 
 
305fc2a
 
80c945e
305fc2a
 
dfe210b
 
80c945e
305fc2a
dfe210b
 
 
 
 
 
 
305fc2a
 
 
 
 
 
 
dfe210b
80c945e
dfe210b
 
80c945e
dfe210b
 
 
 
 
80c945e
 
 
 
 
 
 
 
 
 
 
305fc2a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dfe210b
 
 
80c945e
 
 
 
 
 
 
 
 
dfe210b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80c945e
dfe210b
80c945e
dfe210b
 
80c945e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
305fc2a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80c945e
 
 
 
 
 
 
dfe210b
80c945e
 
 
dfe210b
 
80c945e
 
dfe210b
80c945e
 
 
 
dfe210b
80c945e
 
 
 
 
 
 
 
 
dfe210b
80c945e
 
 
dfe210b
 
80c945e
dfe210b
 
 
 
80c945e
dfe210b
 
 
80c945e
dfe210b
80c945e
 
 
 
dfe210b
 
80c945e
 
dfe210b
 
 
 
 
 
80c945e
 
 
305fc2a
 
 
 
 
 
 
dfe210b
 
 
80c945e
 
 
 
 
 
 
dfe210b
 
80c945e
 
305fc2a
80c945e
dfe210b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
// Copyright 2026 Everpaper. Licensed under the Apache License, Version 2.0 (see ../../../LICENSE).
//
// EmbeddingGemma 2 (text + images + audio) on Apple Core AI, macOS 27. Loads the bundle folder (config.json,
// tokenizer/, the text asset and, on request, the vision and audio assets) and embeds texts, images and audio clips
// into the same 768-d space:
//   text   -> exact Gemma tokenizer -> <bos> prompt text <eos> -> `mm_<S>` with zero soft-token inputs -> embedding
//   image  -> decode (alpha dropped, no EXIF, no colour management) -> exact resize -> patches -> `vision_2520`
//             -> n soft tokens -> sequence <bos> <|image> [n slots] <image|> <eos> -> `mm_288` -> embedding
//   audio  -> decode -> mono -> 16 kHz -> log-mel frames -> `audio_<Tb>` -> n soft tokens
//             -> sequence <bos> <|audio> [n slots] <audio|> <eos> -> `mm_<S>` -> embedding
// All outputs are L2-normalized by the graph; Matryoshka prefixes (512/256/128) are re-normalized on the host.
//
// The v1.0 API (init(bundle:compute:), tokenize, embed(ids:), embed(_:kind:dim:), prewarm) is unchanged and still
// loads the text asset only; pass `loadVision: true` to embed images and `loadAudio: true` to embed audio. `compute`
// places the text asset, `imageCompute` and `audioCompute` the image and audio encoders (default: the same as
// `compute`): the Neural Engine uses the least CPU, GPU and power, the GPU is faster (README, "Images: Neural Engine
// or GPU" and "Audio: Neural Engine or GPU").

import CoreAI
import CoreGraphics
import Foundation

public struct BundleConfig: Decodable, Sendable {
    public struct Vision: Decodable, Sendable {
        public let asset: String
        public let function: String
        public let max_patches: Int
        public let max_soft_tokens: Int
    }
    public struct Audio: Decodable, Sendable {
        public let asset: String
        public let functions: [String]
        public let frame_buckets: [Int]
        public let max_soft_tokens: Int
    }
    /// The text asset (`mm_<S>` functions: text, images, or both in one sequence).
    public let asset: String
    public let functions: [String]
    public let buckets: [Int]
    public let max_tokens: Int
    public let vision: Vision
    /// The audio asset (`audio_<Tb>` functions); absent in a 1.1 bundle.
    public let audio: Audio?
    public let hidden_size: Int
    public let embedding_dim: Int
    public let matryoshka_dims: [Int]
    public let prompts: [String: String]
    public let bos_token_id: Int32
    public let eos_token_id: Int32
    public let pad_token_id: Int32
    public let boi_token_id: Int32
    public let image_token_id: Int32
    public let eoi_token_id: Int32
    public let boa_token_id: Int32?
    public let audio_token_id: Int32?
    public let eoa_token_id: Int32?
}

/// How audio longer than the model's 30 s window is embedded.
public enum LongAudioPolicy: String, Sendable {
    /// The first 30 s only (what the upstream model does).
    case truncate
    /// The mean of the normalized embeddings of consecutive 30 s windows, re-normalized.
    case chunkMean = "chunk_mean"
}

public enum ComputePreference: String, Sendable {
    case neuralEngine = "ane"
    case gpu
    case cpu
    case `default`
}

public struct EmbedderError: Error, CustomStringConvertible {
    public let description: String
    public init(description: String) { self.description = description }
}

/// A prepared sequence for `mm_<S>`: token ids with placeholder slots, and the soft tokens that fill them.
public struct MultimodalSequence: Sendable {
    public var ids: [Int32]
    public var softTokens: [Float16]   // [slots * 512] in sequence order
    public var slots: [Int]            // positions in `ids` that receive soft tokens (same count as rows)

    public init(ids: [Int32], softTokens: [Float16] = [], slots: [Int] = []) {
        self.ids = ids
        self.softTokens = softTokens
        self.slots = slots
    }
}

public final class EmbeddingGemma2 {
    public let bundleURL: URL
    public let config: BundleConfig
    public let tokenizer: GemmaTokenizer
    /// Where the text asset (texts, and the image sequence after the image encoder) runs.
    public let compute: ComputePreference
    /// Where the image encoder (`vision_2520`) runs; equals `compute` unless set with `imageCompute:`.
    public let imageCompute: ComputePreference
    /// Where the audio encoder (`audio_<Tb>`) runs; equals `compute` unless set with `audioCompute:`.
    public let audioCompute: ComputePreference
    /// Wall-clock seconds spent in `AIModel(contentsOf:)` + `loadFunction` (specialization) for the loaded assets.
    /// About 1 s warm; the first load by an executable that has not loaded these assets before takes minutes (README).
    public let loadSeconds: Double
    public let tokenizerLoadSeconds: Double

    private let textModel: AIModel
    private let functions: [Int: InferenceFunction]   // bucket -> mm_<S>
    private let visionModel: AIModel?                 // nil unless created with loadVision: true
    private let visionFunction: InferenceFunction?
    private let audioModel: AIModel?                  // nil unless created with loadAudio: true
    private let audioFunctions: [Int: InferenceFunction]   // frame bucket -> audio_<Tb>
    public var buckets: [Int] { functions.keys.sorted() }
    /// Frame buckets of the loaded audio asset (empty unless loaded).
    public var audioBuckets: [Int] { audioFunctions.keys.sorted() }
    /// True when the vision asset is loaded and `embedImage` / `softTokens` can be used.
    public var hasVision: Bool { visionFunction != nil }
    /// True when the audio asset is loaded and `embedAudio` / `audioSoftTokens` can be used.
    public var hasAudio: Bool { !audioFunctions.isEmpty }
    private var hidden: Int { config.hidden_size }

    /// `loadVision: false` (the default, as in v1.0) loads the text asset only. `loadVision: true` also loads the
    /// vision asset (~300 MB more memory, its own first-load specialization) so that images can be embedded.
    /// `imageCompute` places the image encoder independently of the text asset (nil = same as `compute`):
    /// `.neuralEngine` ~0.37 s per image with ~2 ms CPU and ~1 ms GPU time (lowest power); `.gpu` ~0.13 s per image
    /// with ~20-35 ms CPU and ~0.1 s GPU time (about 3x the power draw). Both match the reference within float16 noise.
    /// `loadAudio: true` also loads the audio asset (~560 MB) so that audio clips can be embedded; `audioCompute`
    /// places it independently (nil = same as `compute`), see README "Audio: Neural Engine or GPU".
    public init(bundle: URL, compute: ComputePreference = .neuralEngine, loadVision: Bool = false,
                imageCompute: ComputePreference? = nil, loadAudio: Bool = false,
                audioCompute: ComputePreference? = nil) async throws {
        bundleURL = bundle
        self.compute = compute
        self.imageCompute = imageCompute ?? compute
        self.audioCompute = audioCompute ?? compute
        config = try JSONDecoder().decode(BundleConfig.self, from: Data(contentsOf: bundle.appendingPathComponent("config.json")))
        var t0 = Date()
        tokenizer = try GemmaTokenizer(contentsOf: bundle.appendingPathComponent("tokenizer/tokenizer.json"))
        tokenizerLoadSeconds = Date().timeIntervalSince(t0)
        guard tokenizer.bosID == config.bos_token_id, tokenizer.eosID == config.eos_token_id, tokenizer.padID == config.pad_token_id else {
            throw EmbedderError(description: "tokenizer special ids do not match config.json")
        }
        // Refuse an unsupported audio request before loading anything (no specialization, no temp files).
        if loadAudio {
            guard config.audio != nil else { throw EmbedderError(description: "this bundle has no audio asset (config.json \"audio\")") }
            guard self.audioCompute != .cpu else {
                throw EmbedderError(description: "audioCompute: .cpu is not supported for audio; use .neuralEngine or .gpu")
            }
        }
        t0 = Date()
        textModel = try await AIModel(contentsOf: bundle.appendingPathComponent(config.asset), options: Self.options(compute))
        var fns: [Int: InferenceFunction] = [:]
        for name in config.functions {
            guard let s = Int(name.split(separator: "_").last ?? ""), let fn = try textModel.loadFunction(named: name) else {
                throw EmbedderError(description: "function \(name) missing from \(config.asset)")
            }
            fns[s] = fn
        }
        functions = fns
        if loadVision {
            let vm = try await AIModel(contentsOf: bundle.appendingPathComponent(config.vision.asset), options: Self.options(self.imageCompute))
            guard let vf = try vm.loadFunction(named: config.vision.function) else {
                throw EmbedderError(description: "function \(config.vision.function) missing from \(config.vision.asset)")
            }
            visionModel = vm
            visionFunction = vf
        } else {
            visionModel = nil
            visionFunction = nil
        }
        if loadAudio {
            guard let ac = config.audio else { throw EmbedderError(description: "this bundle has no audio asset (config.json \"audio\")") }
            let am = try await AIModel(contentsOf: bundle.appendingPathComponent(ac.asset), options: Self.options(self.audioCompute))
            var afns: [Int: InferenceFunction] = [:]
            for name in ac.functions {
                guard let tb = Int(name.split(separator: "_").last ?? ""), let fn = try am.loadFunction(named: name) else {
                    throw EmbedderError(description: "function \(name) missing from \(ac.asset)")
                }
                afns[tb] = fn
            }
            audioModel = am
            audioFunctions = afns
        } else {
            audioModel = nil
            audioFunctions = [:]
        }
        loadSeconds = Date().timeIntervalSince(t0)
    }

    private static func options(_ compute: ComputePreference) -> SpecializationOptions {
        switch compute {
        case .gpu: return SpecializationOptions(preferredComputeUnitKind: .gpu)
        case .cpu: return SpecializationOptions.cpuOnly
        case .neuralEngine: return SpecializationOptions(preferredComputeUnitKind: .neuralEngine)
        case .default: return SpecializationOptions.default
        }
    }

    // MARK: - Tokenization

    /// `<bos>` + prompt prefix + text + `<eos>`, truncated to the largest bucket (2048 tokens: longer input is cut).
    /// `kind` is a key of `config.prompts` ("document", "search_query", ...); nil = no prefix.
    public func tokenize(_ text: String, kind: String? = "document") -> [Int32] {
        let prefix = kind.flatMap { config.prompts[$0] } ?? ""
        var ids = tokenizer.encode(prefix + text)
        let maxBody = (buckets.last ?? 32) - 2
        if ids.count > maxBody { ids = Array(ids.prefix(maxBody)) }
        return [config.bos_token_id] + ids + [config.eos_token_id]
    }

    public func bucket(forTokenCount n: Int) throws -> Int {
        guard let b = buckets.first(where: { $0 >= n }) else {
            throw EmbedderError(description: "\(n) tokens exceeds the largest bucket \(buckets.last ?? 0)")
        }
        return b
    }

    // MARK: - Text

    /// Runs `mm_<bucket>` on already-tokenized ids (with `<bos>`/`<eos>`), right-padded with `<pad>`, no soft tokens.
    /// Returns the raw 768-d output (already L2-normalized by the graph), as Float.
    public func embed(ids: [Int32], bucket: Int? = nil) async throws -> [Float] {
        try await embed(sequence: MultimodalSequence(ids: ids), bucket: bucket)
    }

    /// Embeds one text. `dim` 768 (full) or a Matryoshka size 512/256/128: the leading dims, re-normalized.
    public func embed(_ text: String, kind: String? = "document", dim: Int = 768) async throws -> [Float] {
        try normalized(try await embed(ids: tokenize(text, kind: kind)), dim: dim)
    }

    // MARK: - Images

    /// Embeds an image file. `applyEXIFOrientation: false` reproduces the upstream reference exactly; `true`
    /// embeds camera photos upright (what an app usually wants; the two vectors differ by cosine ~0.97).
    public func embedImage(url: URL, applyEXIFOrientation: Bool = false, dim: Int = 768) async throws -> [Float] {
        try await embedImage(try ImagePreprocessor.prepare(url: url, applyEXIFOrientation: applyEXIFOrientation), dim: dim)
    }

    /// Embeds a CGImage (e.g. a screenshot); its raw samples are used, alpha dropped.
    public func embedImage(cgImage: CGImage, dim: Int = 768) async throws -> [Float] {
        try await embedImage(try ImagePreprocessor.prepare(try ImageDecoder.decodeRGB(cgImage: cgImage)), dim: dim)
    }

    /// Embeds an already prepared image (`ImagePreprocessor.prepare`).
    public func embedImage(_ inputs: VisionInputs, dim: Int = 768) async throws -> [Float] {
        let soft = try await softTokens(inputs)
        return try normalized(try await embed(sequence: imageSequence(softTokens: soft)), dim: dim)
    }

    /// Soft tokens of one prepared image (`vision_2520`): `[n, 512]` as a flat array (n = inputs.softTokenCount).
    public func softTokens(_ inputs: VisionInputs) async throws -> [Float16] {
        guard let visionFunction else {
            throw EmbedderError(description: "vision asset not loaded: create the model with loadVision: true")
        }
        let N = VisionInputs.maxPatches, D = VisionInputs.patchDim
        guard inputs.patches.count == N * D else { throw EmbedderError(description: "VisionInputs has the wrong size") }
        var patches = NDArray(shape: [1, N, D], scalarType: .float16)
        var posX = NDArray(shape: [1, N], scalarType: .int32)
        var posY = NDArray(shape: [1, N], scalarType: .int32)
        var cos = NDArray(shape: [1, 1, N, VisionInputs.headDim], scalarType: .float16)
        var sin = NDArray(shape: [1, 1, N, VisionInputs.headDim], scalarType: .float16)
        var mask = NDArray(shape: [1, N], scalarType: .float16)
        fill(&patches, inputs.patches)
        fill(&posX, inputs.posX)
        fill(&posY, inputs.posY)
        fill(&cos, inputs.cos)
        fill(&sin, inputs.sin)
        fill(&mask, inputs.patchMask)
        var out = try await visionFunction.run(inputs: ["patches": patches, "pos_x": posX, "pos_y": posY, "cos": cos, "sin": sin, "patch_mask": mask])
        guard let soft = out.remove("soft_tokens")?.ndArray else { throw EmbedderError(description: "output 'soft_tokens' missing") }
        let n = inputs.softTokenCount
        var result = [Float16](repeating: 0, count: n * hidden)
        let view = soft.view(as: Float16.self)
        view.withUnsafePointer { p, _, _ in
            for i in 0..<(n * hidden) { result[i] = p[i] }
        }
        return result
    }

    /// The upstream single-image sequence: `<bos> <|image> <|image|> x n <image|> <eos>` (no prompt).
    public func imageSequence(softTokens: [Float16]) -> MultimodalSequence {
        let n = softTokens.count / hidden
        var ids = [config.bos_token_id, config.boi_token_id]
        ids.append(contentsOf: [Int32](repeating: config.image_token_id, count: n))
        ids.append(contentsOf: [config.eoi_token_id, config.eos_token_id])
        return MultimodalSequence(ids: ids, softTokens: softTokens, slots: Array(2..<(2 + n)))
    }

    // MARK: - Audio

    /// Embeds an audio file (anything AVAudioFile reads). The clip is decoded, mixed to mono, resampled to 16 kHz
    /// and, with `.truncate` (the upstream behaviour), its first 30 s are embedded; `.chunkMean` averages the
    /// embeddings of consecutive 30 s windows instead.
    public func embedAudio(url: URL, dim: Int = 768, longAudio: LongAudioPolicy = .truncate) async throws -> [Float] {
        try await embedAudio(samples16k: try AudioFrontend.loadMono16k(url: url), dim: dim, longAudio: longAudio)
    }

    /// Embeds PCM samples (any rate, any channel count: `channels[c][i]`, channel-major, every channel the same
    /// length); they are mixed to mono and resampled. Throws for a non-positive rate, channels of different lengths
    /// and arrays that look frame-major (more "channels" than samples per channel: pass `channels[c][i]`, not `[i][c]`).
    public func embedAudio(samples: [[Float]], sampleRate: Int, dim: Int = 768, longAudio: LongAudioPolicy = .truncate) async throws -> [Float] {
        guard sampleRate > 0 else { throw EmbedderError(description: "sampleRate must be positive (got \(sampleRate))") }
        guard let n = samples.first?.count, samples.allSatisfy({ $0.count == n }) else {
            throw EmbedderError(description: samples.isEmpty ? "samples has no channels" : "every channel must have the same number of samples")
        }
        guard samples.count <= max(n, 1) else {
            throw EmbedderError(description: "samples looks frame-major (\(samples.count) channels of \(n) samples): pass channel-major samples[channel][frame]")
        }
        let mono = AudioFrontend.toMono(DecodedAudio(sampleRate: sampleRate, channelCount: samples.count, channels: samples))
        return try await embedAudio(samples16k: AudioResampler.resampleTo16k(mono, rate: sampleRate), dim: dim, longAudio: longAudio)
    }

    /// Embeds 16 kHz mono samples.
    public func embedAudio(samples16k: [Float], dim: Int = 768, longAudio: LongAudioPolicy = .truncate) async throws -> [Float] {
        switch longAudio {
        case .truncate:
            return try await embedAudio(try AudioFrontend.prepare(wav16k: samples16k), dim: dim)
        case .chunkMean:
            var acc = [Float](repeating: 0, count: config.embedding_dim)
            for chunk in AudioFrontend.chunks30s(samples16k) {
                let v = try await embedAudio(try AudioFrontend.prepare(wav16k: chunk), dim: config.embedding_dim)
                for i in acc.indices { acc[i] += v[i] }
            }
            return try normalized(acc, dim: dim)
        }
    }

    /// Embeds already prepared audio inputs (`AudioFrontend.prepare`).
    public func embedAudio(_ inputs: AudioInputs, dim: Int = 768) async throws -> [Float] {
        let soft = try await audioSoftTokens(inputs)
        return try normalized(try await embed(sequence: audioSequence(softTokens: soft)), dim: dim)
    }

    /// Soft tokens of one prepared clip (`audio_<bucket>`): `[n, 512]` as a flat array (n = inputs.softTokenCount).
    public func audioSoftTokens(_ inputs: AudioInputs) async throws -> [Float16] {
        guard let fn = audioFunctions[inputs.bucket] else {
            throw EmbedderError(description: hasAudio ? "no audio function for bucket \(inputs.bucket)" : "audio asset not loaded: create the model with loadAudio: true")
        }
        let Tb = inputs.bucket, M = AudioInputs.nMels
        guard inputs.features.count == Tb * M, inputs.frameMask.count == Tb, inputs.halfMask.count == Tb / 2, inputs.tokenMask.count == Tb / 4 else {
            throw EmbedderError(description: "AudioInputs has the wrong size")
        }
        var features = NDArray(shape: [1, Tb, M], scalarType: .float16)
        var frameMask = NDArray(shape: [1, Tb], scalarType: .float16)
        var halfMask = NDArray(shape: [1, Tb / 2], scalarType: .float16)
        var tokenMask = NDArray(shape: [1, Tb / 4], scalarType: .float16)
        fill(&features, inputs.features)
        fill(&frameMask, inputs.frameMask)
        fill(&halfMask, inputs.halfMask)
        fill(&tokenMask, inputs.tokenMask)
        var out = try await fn.run(inputs: ["features": features, "frame_mask": frameMask, "half_mask": halfMask, "token_mask": tokenMask])
        guard let soft = out.remove("soft_tokens")?.ndArray else { throw EmbedderError(description: "output 'soft_tokens' missing") }
        let n = inputs.softTokenCount
        var result = [Float16](repeating: 0, count: n * hidden)
        let view = soft.view(as: Float16.self)
        view.withUnsafePointer { p, _, _ in
            for i in 0..<(n * hidden) { result[i] = p[i] }
        }
        return result
    }

    /// The upstream single-clip sequence: `<bos> <|audio> <|audio|> x n <audio|> <eos>` (no prompt).
    public func audioSequence(softTokens: [Float16]) -> MultimodalSequence {
        let n = softTokens.count / hidden
        let boa = config.boa_token_id ?? 256000, audio = config.audio_token_id ?? 258881, eoa = config.eoa_token_id ?? 258883
        var ids = [config.bos_token_id, boa]
        ids.append(contentsOf: [Int32](repeating: audio, count: n))
        ids.append(contentsOf: [eoa, config.eos_token_id])
        return MultimodalSequence(ids: ids, softTokens: softTokens, slots: Array(2..<(2 + n)))
    }

    // MARK: - Any sequence

    /// Runs `mm_<bucket>` on a sequence (ids with `<bos>`/`<eos>`, right-padded with `<pad>`; soft tokens placed at
    /// `slots`). Any mix of text and images works as long as it fits a bucket (e.g. several images, or text + image).
    /// Returns the raw 768-d output (L2-normalized by the graph), as Float.
    public func embed(sequence seq: MultimodalSequence, bucket: Int? = nil) async throws -> [Float] {
        let S = try bucket ?? self.bucket(forTokenCount: seq.ids.count)
        guard let fn = functions[S] else { throw EmbedderError(description: "no function for bucket \(S)") }
        guard seq.ids.count <= S else { throw EmbedderError(description: "\(seq.ids.count) ids do not fit bucket \(S)") }
        guard seq.slots.count * hidden == seq.softTokens.count else { throw EmbedderError(description: "soft tokens do not match slots") }
        guard seq.slots.allSatisfy({ $0 >= 0 && $0 < seq.ids.count }) else { throw EmbedderError(description: "slot outside the sequence") }
        var inputIDs = NDArray(shape: [1, S], scalarType: .int32)
        var mask = NDArray(shape: [1, S], scalarType: .float16)
        var mm = NDArray(shape: [1, S, hidden], scalarType: .float16)
        var mmMask = NDArray(shape: [1, S], scalarType: .float16)
        do {
            let v = inputIDs.mutableView(as: Int32.self)
            let m = mask.mutableView(as: Float16.self)
            let e = mm.mutableView(as: Float16.self)
            let em = mmMask.mutableView(as: Float16.self)
            let pad = config.pad_token_id
            let ids = seq.ids
            v.withUnsafeMutablePointer { p, _, _ in for i in 0..<S { p[i] = i < ids.count ? ids[i] : pad } }
            m.withUnsafeMutablePointer { p, _, _ in for i in 0..<S { p[i] = i < ids.count ? 1 : 0 } }
            let h = hidden
            e.withUnsafeMutablePointer { p, _, _ in
                for i in 0..<(S * h) { p[i] = 0 }
                for (r, slot) in seq.slots.enumerated() {
                    for j in 0..<h { p[slot * h + j] = seq.softTokens[r * h + j] }
                }
            }
            em.withUnsafeMutablePointer { p, _, _ in
                for i in 0..<S { p[i] = 0 }
                for slot in seq.slots { p[slot] = 1 }
            }
        }
        var out = try await fn.run(inputs: ["input_ids": inputIDs, "attention_mask": mask, "mm_embeds": mm, "mm_mask": mmMask])
        guard let emb = out.remove("embedding")?.ndArray else { throw EmbedderError(description: "output 'embedding' missing") }
        let dim = config.embedding_dim
        var result = [Float](repeating: 0, count: dim)
        let view = emb.view(as: Float16.self)
        view.withUnsafePointer { p, _, _ in for i in 0..<dim { result[i] = Float(p[i]) } }
        return result
    }

    private func normalized(_ v: [Float], dim: Int) throws -> [Float] {
        guard config.matryoshka_dims.contains(dim) else { throw EmbedderError(description: "dim must be one of \(config.matryoshka_dims)") }
        var r = Array(v.prefix(dim))
        let norm = max(sqrt(r.reduce(0) { $0 + $1 * $1 }), 1e-12)
        for i in r.indices { r[i] /= norm }
        return r
    }

    /// Runs every text bucket (and the vision function, if loaded) once so the first real call pays no
    /// first-call cost. Call it right after init, off the main thread. Returns the seconds it took.
    @discardableResult
    public func prewarm() async throws -> Double {
        let t0 = Date()
        for S in buckets {
            _ = try await embed(ids: [config.bos_token_id, config.eos_token_id], bucket: S)
        }
        if visionFunction != nil {
            _ = try await softTokens(ImagePreprocessor.patchify(resizedRGB: [UInt8](repeating: 128, count: 48 * 48 * 3), width: 48, height: 48))
        }
        for tb in audioBuckets {
            let n = AudioInputs.nMels
            let inputs = AudioInputs(bucket: tb, frameCount: 4, softTokenCount: 1, features: [Float16](repeating: 0, count: tb * n),
                                     frameMask: [Float16](repeating: 0, count: tb), halfMask: [Float16](repeating: 0, count: tb / 2),
                                     tokenMask: [Float16](repeating: 0, count: tb / 4))
            _ = try await audioSoftTokens(inputs)
        }
        return Date().timeIntervalSince(t0)
    }

    private func fill<T: BitwiseCopyable>(_ arr: inout NDArray, _ values: [T]) {
        let v = arr.mutableView(as: T.self)
        v.withUnsafeMutablePointer { p, _, _ in
            values.withUnsafeBufferPointer { src in p.update(from: src.baseAddress!, count: values.count) }
        }
    }

    // MARK: - Temp-file hygiene

    /// Each Neural Engine load makes MPSGraph write compiled-graph copies into `$TMPDIR/com.apple.MetalPerformance
    /// ShadersGraph/mpsgraph-<pid>-<date>-<n>/` (~360 MB for the text asset, ~300 MB for the vision asset). With the
    /// Swift `CoreAI` framework (macOS 27) the runtime deletes that folder itself when the model is released, so this
    /// usually finds nothing; it is a safety net for a folder that survives (e.g. the process exits while the model is
    /// alive). It removes only the folders that belong to the CURRENT process (its pid, created after it started).
    /// Call it after the `EmbeddingGemma2` instance has been released, or at exit. Returns the number of folders
    /// removed and their total bytes.
    @discardableResult
    public static func removeOwnMPSGraphTempFiles() -> (folders: Int, bytes: Int64) {
        let fm = FileManager.default
        let root = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("com.apple.MetalPerformanceShadersGraph")
        let prefix = "mpsgraph-\(getpid())-"
        let started = processStartDate()
        var folders = 0
        var bytes: Int64 = 0
        guard let entries = try? fm.contentsOfDirectory(at: root, includingPropertiesForKeys: [.creationDateKey]) else { return (0, 0) }
        for url in entries where url.lastPathComponent.hasPrefix(prefix) {
            if let started, let created = (try? url.resourceValues(forKeys: [.creationDateKey]))?.creationDate, created < started - 2 {
                continue   // a folder left by an older process that happened to have the same pid
            }
            if let e = fm.enumerator(at: url, includingPropertiesForKeys: [.fileSizeKey]) {
                for case let f as URL in e { bytes += Int64((try? f.resourceValues(forKeys: [.fileSizeKey]))?.fileSize ?? 0) }
            }
            if (try? fm.removeItem(at: url)) != nil { folders += 1 }
        }
        return (folders, bytes)
    }

    private static func processStartDate() -> Date? {
        var info = kinfo_proc()
        var size = MemoryLayout<kinfo_proc>.stride
        var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, getpid()]
        guard sysctl(&mib, UInt32(mib.count), &info, &size, nil, 0) == 0 else { return nil }
        let tv = info.kp_proc.p_starttime
        return Date(timeIntervalSince1970: Double(tv.tv_sec) + Double(tv.tv_usec) / 1e6)
    }
}

// MARK: - Small helpers for callers and tests

public func cosineSimilarity(_ a: [Float], _ b: [Float]) -> Double {
    var dot = 0.0, na = 0.0, nb = 0.0
    for i in 0..<min(a.count, b.count) {
        dot += Double(a[i]) * Double(b[i]); na += Double(a[i]) * Double(a[i]); nb += Double(b[i]) * Double(b[i])
    }
    return na > 0 && nb > 0 ? dot / (sqrt(na) * sqrt(nb)) : 0
}