File size: 66,148 Bytes
dfe210b
 
 
 
 
 
 
 
 
 
 
 
 
80c945e
 
305fc2a
 
80c945e
 
305fc2a
dfe210b
 
 
 
305fc2a
80c945e
 
305fc2a
 
 
 
80c945e
 
 
 
 
 
 
 
 
 
305fc2a
 
 
 
 
80c945e
 
 
dfe210b
 
 
 
 
305fc2a
 
dfe210b
 
 
305fc2a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dfe210b
 
 
 
305fc2a
dfe210b
 
80c945e
 
305fc2a
80c945e
 
 
 
305fc2a
 
80c945e
305fc2a
 
 
dfe210b
 
 
 
305fc2a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dfe210b
 
 
305fc2a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dfe210b
80c945e
dfe210b
 
305fc2a
80c945e
305fc2a
80c945e
 
305fc2a
 
80c945e
dfe210b
80c945e
 
dfe210b
 
80c945e
 
 
 
 
 
 
 
 
dfe210b
80c945e
 
 
 
dfe210b
305fc2a
 
 
 
 
 
 
 
 
 
 
 
 
 
dfe210b
 
 
 
305fc2a
 
 
 
80c945e
dfe210b
 
 
 
 
305fc2a
 
80c945e
 
305fc2a
 
80c945e
 
305fc2a
 
 
dfe210b
 
305fc2a
 
80c945e
dfe210b
 
305fc2a
 
 
 
 
 
 
 
 
 
dfe210b
 
 
80c945e
dfe210b
 
 
 
 
305fc2a
80c945e
 
305fc2a
dfe210b
80c945e
 
305fc2a
 
dfe210b
80c945e
dfe210b
80c945e
dfe210b
 
305fc2a
 
 
 
dfe210b
80c945e
 
 
 
 
 
 
305fc2a
80c945e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dfe210b
80c945e
 
 
 
 
 
 
 
 
 
 
305fc2a
80c945e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dfe210b
80c945e
 
 
 
 
dfe210b
80c945e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
305fc2a
80c945e
 
 
 
305fc2a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80c945e
 
 
 
305fc2a
 
80c945e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dfe210b
80c945e
 
 
 
 
305fc2a
80c945e
 
 
 
305fc2a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80c945e
 
 
 
305fc2a
 
 
 
80c945e
 
 
 
 
 
 
 
 
305fc2a
 
 
 
80c945e
 
305fc2a
 
80c945e
305fc2a
80c945e
305fc2a
 
 
 
 
80c945e
 
 
 
 
 
 
dfe210b
 
 
80c945e
305fc2a
 
80c945e
 
 
 
 
 
 
 
 
305fc2a
80c945e
305fc2a
 
 
80c945e
305fc2a
 
 
 
dfe210b
 
 
80c945e
305fc2a
 
80c945e
 
 
 
305fc2a
80c945e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
305fc2a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80c945e
305fc2a
80c945e
 
 
 
305fc2a
dfe210b
 
 
 
 
 
 
 
80c945e
dfe210b
 
80c945e
305fc2a
dfe210b
 
305fc2a
 
 
 
80c945e
dfe210b
80c945e
 
 
 
dfe210b
80c945e
305fc2a
 
 
 
 
dfe210b
 
 
 
 
305fc2a
 
 
 
dfe210b
 
305fc2a
 
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
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
---
license: apache-2.0
base_model: google/embeddinggemma-2
library_name: coreai
pipeline_tag: feature-extraction
language:
  - multilingual
tags:
  - coreai
  - apple-neural-engine
  - on-device
  - embeddings
  - sentence-similarity
  - image-embeddings
  - image-feature-extraction
  - audio-embeddings
  - audio-feature-extraction
  - multimodal
  - text-to-image-retrieval
  - text-to-audio-retrieval
  - gemma
  - macos
---

# EmbeddingGemma 2 for Apple Core AI (text + images + audio)

[google/embeddinggemma-2](https://huggingface.co/google/embeddinggemma-2) (Gemma 4 family, 768-d Matryoshka
embeddings) converted to Apple's **Core AI** runtime for **macOS 27 on Apple silicon**: the text encoder, since
1.1 the image encoder and since 1.2 the audio encoder, in one shared 768-d space. A screenshot, a chart, a slide,
a photo, a voice memo or a sound becomes one vector, and the vector of the query "bar chart of lamp units sold per
city" (or "the night train to the coast") lands next to it.

- **Text** β€” everything `SentenceTransformer("google/embeddinggemma-2").encode()` does for text runs in one graph:
  token embedding, 24 bidirectional encoder layers, final norm, 512β†’768 projection, masked mean pooling and L2
  normalization. One Neural Engine region plus the token lookup (a small GPU gather): about **5 ms, 0.3 ms of CPU
  and 0.4–0.5 ms of GPU time** per short text.
- **Images** β€” the 16-layer vision encoder turns an image into 280 "soft tokens" that go through the same text
  encoder, exactly as upstream does, plus an exact host-side preprocessing (decode, resize, patches) shipped in
  Swift and numpy. Two compute routes for images, both shipped and validated: the **Neural Engine** (default,
  ~0.37 s per image, ~2 ms of CPU, almost no GPU) or the **GPU** (~0.13 s per image, about 3Γ— faster, more power),
  chosen independently of the text route (see "Images: Neural Engine or GPU").
- **Audio** β€” the 12-layer conformer audio encoder turns up to 30 s of audio into 25 soft tokens per second that go
  through the same text encoder, exactly as upstream does, plus the exact host-side front end (decode, mono,
  16 kHz, log-mel) in Swift and numpy. Neural Engine (default, 18 ms end to end for a 3 s clip, 0.16 s for 30 s,
  ~1–2 ms of CPU) or GPU (22 ms / 0.12 s), chosen independently of text and images (see "Audio: Neural Engine or
  GPU"). Speech (18 languages tested), music, sounds: a text query finds the clip and a clip finds its caption.

Ported by Everpaper β€” everpaper.app. Weights are the original ones (bf16 rounded to fp16), with a few constant
scales folded in and unused position rows cut (listed in NOTICE). Apache-2.0, see
[LICENSE](LICENSE) and [NOTICE](NOTICE) (modification list, Gemma Prohibited Use Policy pointer).

## Download

```bash
hf download EverPaper/embeddinggemma-2-coreai --local-dir EmbeddingGemma-2-CoreAI-v1.2
cd EmbeddingGemma-2-CoreAI-v1.2
```

(`hf` comes with `pip install -U huggingface_hub`.) Then run one of the quick starts below; both begin with a
smoke test that checks the model on your machine. Text only? Add `--exclude "model/embeddinggemma-2-vision.aimodel/*"
--exclude "model/embeddinggemma-2-audio.aimodel/*"` to skip the 293 MiB image encoder and the 562 MiB audio
encoder (or only one of them).

## What's new in 1.2

- **Audio**: `model/embeddinggemma-2-audio.aimodel` (functions `audio_192` … `audio_3000`, one per frame bucket),
  `embedAudio` / `embed_audio` in both runners, the exact audio front end (decode, mono, 16 kHz, log-mel) in Swift
  and numpy, audio smoke tests and 16 generated test clips. Up to 30 s per clip (the model's window, as upstream);
  longer audio is truncated or, on request, embedded as the mean of its 30 s windows.
- **Two audio routes**: the audio encoder runs on the Neural Engine (default) or on the GPU, set independently of
  text and images (`audioCompute:` / `audio_compute=` / `--audio-compute`).
- **Text and images are unchanged**: the text and image assets are byte-identical to 1.1, so vectors indexed with
  1.1 (and text vectors indexed with 1.0) stay valid. The audio sequence goes through the same `mm_<S>` functions.
- **Same API**: without `loadAudio: true` / `load_audio=True` nothing changes; the audio asset is not even loaded.

What was new in 1.1: images (`model/embeddinggemma-2-vision.aimodel`, `embedImage` / `embed_image`, exact image
preprocessing, two image routes); the text asset became `embeddinggemma-2-text-mm.aimodel` (functions `mm_<S>`,
bit-identical to the 1.0 text encoder with no image in the sequence; the 256 bucket became 288). Versions 1.0 and
1.1 remain available as the repository's earlier revisions; see CHANGELOG.md.

## What is in the package

```
EmbeddingGemma-2-CoreAI-v1.2/
  README.md, LICENSE, NOTICE, CHANGELOG.md, CHECKSUMS.sha256
  model/
    embeddinggemma-2-text-mm.aimodel/   text encoder, functions mm_32 … mm_2048 (fp16, 632.2 MB = 602.9 MiB)
    embeddinggemma-2-vision.aimodel/    image encoder, function vision_2520 (fp16, 307.0 MB = 292.8 MiB)
    embeddinggemma-2-audio.aimodel/     audio encoder, functions audio_192 … audio_3000 (fp16, 589.3 MB = 562.0 MiB)
    config.json                         assets, buckets, shapes, special ids, prompts, Matryoshka dims
    tokenizer/                          tokenizer.json + tokenizer_config.json, unchanged from google/embeddinggemma-2
    MODEL-ATTRIBUTION.json              base model, checkpoint sha256, exporter versions, modification list
  swift/      SwiftPM package: library `EmbeddingGemma2CoreAI` + tool `eg2-cli` (no third-party dependencies)
  python/     embeddinggemma2_coreai.py (library + CLI), eg2_image.py (exact image preprocessing),
              eg2_audio_frontend.py (exact audio front end), smoke_test.py, requirements(.lock)
  tests/      smoke-reference.json + tokenizer-cases.json (text), images/ (12 generated test images),
              image-smoke-reference.json, preprocess-cases.json, check_preprocess.py, audio/ (16 generated clips
              + ref/ front-end references), audio-smoke-reference.json, audio-preprocess-cases.json, and the generators
  export/     eg2_text.py, eg2_text_mm.py, eg2_vision.py, eg2_audio.py, export.py: reproduce the assets from the checkpoint
```

## Requirements

- macOS 27 or later on Apple silicon (validated on an M5 with 24 GB).
- Swift: Xcode 27 / Swift 6 toolchain; the `CoreAI`, `ImageIO`, `CoreGraphics`, `AVFoundation` (audio decoding)
  and `Accelerate` (audio FFT) frameworks of the macOS 27 SDK.
- Python (runtime): Python 3.12 (the validated version; 3.14 with numpy 2.5 also passed the text smoke tests). macOS
  does not ship 3.12: install it with `brew install python@3.12` or `uv python install 3.12` (see "Quick start β€”
  Python"). `python/requirements.txt` installs Apple's `coreai-core` Python runtime (provides `coreai.runtime`),
  `numpy`, `tokenizers`, `pillow` (images) and `soundfile` (audio decoding with libsndfile, the decoder upstream
  uses; if it is missing, the runner falls back to macOS `afconvert`). The exact validated versions are in
  `python/requirements.lock`. Only the exporter (`export/`, optional) needs torch 2.9.0.
- Disk: 1.56 GB for `model/` (1488 MiB): text asset 0.63 GB, image asset 0.31 GB, audio asset 0.59 GB, tokenizer
  0.03 GB (text + image assets 0.94 GB, 0.97 GB with the tokenizer). The system keeps a per-app specialization
  cache under `~/Library/Caches/coreai-cache/` (about 1.4 GB for the text asset, 5.5 GB with the image and audio
  assets).
- Memory with all three assets loaded on the Neural Engine: Python ~415 MB process footprint plus ~2.1 GB on the
  Neural Engine (the audio asset accounts for ~1.4 GB of it: five functions); the Swift CLI reported ~300 MB plus
  ~0.77 GB. 16 GB Macs: fine; 8 GB Macs were not tested.

## Quick start β€” Swift

Add the package to your app's `Package.swift`. SwiftPM names a local package after its folder, so the package
identity is `swift`:

```swift
// swift-tools-version: 6.0
import PackageDescription

let package = Package(
    name: "MyApp",
    platforms: [.macOS("27.0")],
    dependencies: [.package(path: "…/EmbeddingGemma-2-CoreAI-v1.2/swift")],
    targets: [
        .executableTarget(name: "MyApp", dependencies: [.product(name: "EmbeddingGemma2CoreAI", package: "swift")]),
    ]
)
```

```swift
import Foundation
import EmbeddingGemma2CoreAI

let pkg = URL(fileURLWithPath: "…/EmbeddingGemma-2-CoreAI-v1.2")            // the downloaded package folder
let modelURL = pkg.appendingPathComponent("model")
let model = try await EmbeddingGemma2(bundle: modelURL, compute: .neuralEngine, loadVision: true, loadAudio: true)
// images on the GPU instead (β‰ˆ3Γ— faster, more power), text stays on the Neural Engine:
// let model = try await EmbeddingGemma2(bundle: modelURL, compute: .neuralEngine, loadVision: true, imageCompute: .gpu)
// audio on the GPU instead (faster for long clips, ~16 ms of CPU per clip), text stays on the Neural Engine:
// let model = try await EmbeddingGemma2(bundle: modelURL, compute: .neuralEngine, loadAudio: true, audioCompute: .gpu)
try await model.prewarm()                                    // optional, ~1 s warm

// text (unchanged from 1.0)
let q = try await model.embed("bar chart of lamp units sold per city", kind: "search_query")   // [Float], 768
let d = try await model.embed("The lease runs until the last day of March.", kind: "document", dim: 256)

// images, same space (two of the package's test images)
let imageURL = pkg.appendingPathComponent("tests/images/chart_bar.png")
let photoURL = pkg.appendingPathComponent("tests/images/scene_sunset.jpg")
let img = try await model.embedImage(url: imageURL)                                         // as stored, no EXIF rotation
let photo = try await model.embedImage(url: photoURL, applyEXIFOrientation: true)           // camera photos upright
// let shot = try await model.embedImage(cgImage: someCGImage)                              // raw samples of a CGImage
let score = cosineSimilarity(q, img)

// lower level
let ids = model.tokenize("some text", kind: "document")      // <bos> + prefix + text + <eos>, truncated to 2048
let raw = try await model.embed(ids: ids)                    // runs mm_<smallest bucket that fits>
let inputs = try ImagePreprocessor.prepare(url: imageURL)    // decode + exact resize + patches (CPU, ~10 ms typical)
let soft = try await model.softTokens(inputs)                // [n * 512] Float16 (vision_2520)
let rawImage = try await model.embed(sequence: model.imageSequence(softTokens: soft))       // runs mm_288

// audio, same space (two of the package's test clips)
let clipURL = pkg.appendingPathComponent("tests/audio/speech_en_train_16k.wav")
let a = try await model.embedAudio(url: clipURL)                                            // first 30 s, as upstream
let longURL = pkg.appendingPathComponent("tests/audio/long_melody_35s_16k.mp3")
let long = try await model.embedAudio(url: longURL, longAudio: .chunkMean)                  // mean of the 30 s windows
// let live = try await model.embedAudio(samples: [leftChannel, rightChannel], sampleRate: 48000)   // PCM from AVAudioEngine etc.
let spoken = try await model.embed("the night train to the coast", kind: "search_query")
let hit = cosineSimilarity(spoken, a)
// lower level: decode -> mono -> 16 kHz -> log-mel frames -> audio_<Tb> -> sequence <bos> <|audio> [n] <audio|> <eos> -> mm_<S>
let wav = try AudioFrontend.loadMono16k(url: clipURL)        // [Float] at 16 kHz (AudioDecoder + AudioResampler)
let audioInputs = try AudioFrontend.prepare(wav16k: wav)     // log-mel features + masks for one frame bucket
let audioSoft = try await model.audioSoftTokens(audioInputs) // [n * 512] Float16 (audio_<bucket>)
let rawAudio = try await model.embed(sequence: model.audioSequence(softTokens: audioSoft))

// when the model is released (or at exit) you may delete the temp files this process created, see "Temp files"
EmbeddingGemma2.removeOwnMPSGraphTempFiles()
```

`EmbeddingGemma2(bundle:compute:)` without `loadVision: true` / `loadAudio: true` loads only the text asset (no
extra memory, no extra first-load cost); image and audio calls then throw. `compute` places the text asset;
`imageCompute` and `audioCompute` (default: the same as `compute`) place the image and audio encoders, see
"Images: Neural Engine or GPU" and "Audio: Neural Engine or GPU". `GemmaTokenizer` is public too (`encode`, `encodeWithSpecials`,
`decode`): an exact re-implementation of the Hugging Face `tokenizers` pipeline for this `tokenizer.json`.

Command line (`cd swift && swift build -c release`; the binary is `.build/release/eg2-cli` or
`.build/out/Products/Release/eg2-cli` depending on the toolchain):

```
eg2-cli smoke       ../model ../tests/smoke-reference.json ../tests/image-smoke-reference.json ../tests/audio-smoke-reference.json --compute ane --cleanup-temp
eg2-cli embed       ../model --kind search_query "bar chart of lamp units sold per city"      (text only, no vision/audio asset)
eg2-cli embed-image ../model ../tests/images/chart_bar.png ../tests/images/scene_sunset.jpg [--image-compute gpu] [--exif] [--dim 768]
                                                            (one JSON line per image)
eg2-cli embed-audio ../model ../tests/audio/speech_en_train_16k.wav ../tests/audio/long_melody_35s_16k.mp3 [--audio-compute gpu]
                    [--long truncate|chunk_mean] [--dim 768]  (one JSON line per clip)
eg2-cli tokcheck    ../model ../tests/tokenizer-cases.json
eg2-cli pixcheck    ../tests/preprocess-cases.json          (decode + resize vs the Python reference, no model)
eg2-cli audiocheck  ../tests/audio-preprocess-cases.json    (decode + resample + log-mel vs the Python reference, no model)
eg2-cli latency     ../model --compute ane [--image-compute gpu] [--audio-compute gpu] --trials 50 [--text-only]
eg2-cli prewarm     ../model [--image-compute gpu] [--audio-compute gpu] [--text-only]
```

`--compute ane|gpu|cpu` places the text asset (and the image and audio encoders, unless `--image-compute` /
`--audio-compute` are given); `--image-compute` / `--audio-compute ane|gpu` place only that encoder.

## Quick start β€” Python

From the package folder. macOS has no Python 3.12 of its own (its `python3` is 3.9 and Homebrew's default is
newer), so install one first, with Homebrew or with uv:

```
brew install python@3.12                                   # then:
python3.12 -m venv .venv && .venv/bin/pip install -r python/requirements.txt

# or, with uv instead of Homebrew:
uv python install 3.12 && uv venv --python 3.12 .venv && uv pip install --python .venv -r python/requirements.txt
```

```
source .venv/bin/activate
cd python && python3 smoke_test.py --compute ane --cleanup-temp      # first run: a few minutes to specialize, see below
```

```python
from embeddinggemma2_coreai import EmbeddingGemma2CoreAI, remove_own_mpsgraph_temp_files

m = EmbeddingGemma2CoreAI("../model", compute="ane", load_vision=True, load_audio=True)   # ane | gpu | cpu | default
# images on the GPU (β‰ˆ3Γ— faster, more power), text on the Neural Engine:
# m = EmbeddingGemma2CoreAI("../model", compute="ane", load_vision=True, image_compute="gpu")
# audio on the GPU, text on the Neural Engine:  ..., load_audio=True, audio_compute="gpu"
m.prewarm()
q = m.embed("bar chart of lamp units sold per city", kind="search_query")   # numpy float32 [768]
v = m.embed_image("../tests/images/chart_bar.png")            # path or PIL image; apply_exif_orientation=True for photos
a = m.embed_audio("../tests/audio/speech_en_train_16k.wav")   # path, or (samples array, sample_rate=...); first 30 s
long = m.embed_audio("../tests/audio/long_melody_35s_16k.mp3", long_audio="chunk_mean")   # mean of the 30 s windows
d = m.embed("The lease runs until the last day of March.", kind="document", dim=256)
score = float(q @ v)
del m
remove_own_mpsgraph_temp_files()                              # the Python runtime leaks ~650 MB per load, see below
```

`python3 embeddinggemma2_coreai.py embed|embed-image|embed-audio|smoke|tokcheck|latency|prewarm …` mirrors the
Swift tool, including `--compute`, `--image-compute` and `--audio-compute` (run it with the venv's Python).
`smoke_test.py --text-only` checks the text path without loading the image and audio encoders; `--no-audio` skips
the audio reference; `smoke_test.py --image-compute gpu --audio-compute gpu` checks the GPU routes.

## Text

| | |
|---|---|
| Functions | `mm_32`, `mm_64`, `mm_128`, `mm_288`, `mm_512`, `mm_1024`, `mm_2048` (one per sequence bucket S) in `embeddinggemma-2-text-mm.aimodel` |
| `input_ids` | `[1, S]` int32: `<bos>`=2, prompt-prefix tokens, text tokens, `<eos>`=1, right-padded with `<pad>`=0 |
| `attention_mask` | `[1, S]` float16: 1 for real tokens, 0 for padding |
| `mm_embeds`, `mm_mask` | `[1, S, 512]` / `[1, S]` float16: **zeros for text** (see "Images" and "Audio" for what goes there) |
| `embedding` | `[1, 768]` float16, already L2-normalized |
| Max length | 2048 tokens including `<bos>`/`<eos>` and the prefix. Longer input is **truncated** by the runners (the original model accepts 8192; chunk long documents at ≀ 512 tokens for retrieval, the embedding is a mean over tokens anyway). |
| Bucket choice | the smallest S β‰₯ token count. Padding does not change the result (padded and unpadded runs are bit-identical). |
| Matryoshka | 512 / 256 / 128 dims: take the first N values and re-normalize (`embed(..., dim:)` does it). |

**Task prompts** (exactly the upstream `config_sentence_transformers.json`; the runners prepend them by `kind`):

| kind | prefix |
|---|---|
| `document` | `title: none \| text: ` β€” for titled documents use `title: {title} \| text: {content}` yourself |
| `search_query` | `task: search result \| query: ` |
| `question_answering` | `task: question answering \| query: ` |
| `fact_checking` | `task: fact checking \| query: ` |
| `code_retrieval` | `task: code retrieval \| query: ` |
| `classification` | `task: classification \| query: ` |
| `clustering` | `task: clustering \| query: ` |
| `sentence_similarity` | `task: sentence similarity \| query: ` |

The runners add `<bos>` and `<eos>` themselves, as sentence-transformers does. Special tokens written literally in a
text (e.g. `<eos>`) are recognised as special tokens, exactly as in Hugging Face `tokenizers`; strip them if that
is not what you want.

## Images

### Neural Engine or GPU

The image encoder (`vision_2520`, ~0.92 TFLOP per image) runs on either the Neural Engine or the GPU. Both routes
use the same asset, both are validated, and you choose the route **for images independently of text**. Text always
runs where `compute` says (default: Neural Engine), and so does the image's final pass through the text encoder
(`mm_288`, ~15 ms). Measured on an M5 (24 GB, macOS 27) for one typical image (273 soft tokens):

| | **Neural Engine** (default) | **GPU** |
|---|---|---|
| time per image, end to end (excl. decode + resize) | 369 ms | 125 ms (**~3Γ— faster**) |
| throughput, one process | ~2.7 images/s | ~8 images/s |
| CPU time per image (Swift / Python) | **2 ms** / 8.5 ms | 21 ms / 27 ms |
| GPU time per image | **~1 ms** (the GPU stays free) | ~103 ms (the GPU is ~80 % busy while indexing) |
| whole-machine power while indexing, above idle ΒΉ | **+8.3 W** | +28.5 W |
| energy per image, above idle ΒΉ | **3.1 J** | 3.8 J |
| memory (Swift, both assets loaded) | 260–275 MB + 608–1061 MB on the Neural Engine | 435–440 MB + 608–766 MB on the Neural Engine (text) |
| accuracy vs fp32, 373 + 30 tiny + 19 photo files (Python runner) | median 0.99998, min 0.9946 Β² | median 0.99998, min **0.9989** Β² |
| cross-modal retrieval (205 captions, both directions) | identical to fp32 at 768 dims | identical to fp32 at 768 dims |
| first load of the image encoder by a new executable | minutes (3–4 min for both assets; once per app and OS build) | 2–25 s |

ΒΉ SMC whole-system power, MacBook on AC power, battery not charging, display on; idle β‰ˆ 8 W. Python runner, 40 s
of continuous embedding, two runs each (8.1 / 8.4 W, 28.1 / 28.9 W). Running *everything* on the GPU (`compute:
.gpu`) costs +30.8 W and 4.2 J per image for the same speed.
Β² Both routes give median β‰₯ 0.99998 and β‰₯ 0.9997 on every ordinary image; the minimum is a 16Γ—16 image upscaled to
768Γ—768 (see "Limits"). On the GPU route with text on the GPU too, every image is β‰₯ 0.99975.

**Default: the Neural Engine.** It needs a fraction of the CPU and almost no GPU, draws about a third of the power,
and uses less energy per image, so indexing in the background does not heat the Mac, drain the battery or compete
with the UI, games or a local LLM on the GPU. **Choose the GPU** when wall-clock time matters more than power: a bulk
import on a plugged-in Mac, or a user waiting for a dropped image. Keep text on the Neural Engine in both cases
(text on the GPU is slower for short texts and costs ~10–30 ms of CPU per call).

How to switch (the route is fixed when the model is loaded; create a second instance to use both):

```swift
// Swift: images on the GPU, text on the Neural Engine
let model = try await EmbeddingGemma2(bundle: url, compute: .neuralEngine, loadVision: true, imageCompute: .gpu)
model.imageCompute          // .gpu   (if the `imageCompute:` argument is omitted, it equals `compute`)
```

```
# CLI (Swift eg2-cli and the Python runner take the same flags)
eg2-cli embed-image ../model --image-compute gpu ../tests/images/chart_bar.png ../tests/images/scene_sunset.jpg
eg2-cli smoke ../model ../tests/smoke-reference.json ../tests/image-smoke-reference.json --image-compute gpu
python3 embeddinggemma2_coreai.py embed-image ../model --image-compute gpu ../tests/images/chart_bar.png
```

```python
# Python
m = EmbeddingGemma2CoreAI("../model", compute="ane", load_vision=True, image_compute="gpu")
m.image_compute             # "gpu"   (if the image_compute= argument is omitted or None, it equals compute)
```

`imageCompute: .cpu` / `--image-compute cpu` also exists (~520 ms per image, the least precise route; for testing).
The shipped graph splits the attention queries into 12 blocks, which the Neural Engine needs (560 β†’ 355 ms); an
un-split export takes ~10 % less time on the GPU (104 vs 116 ms for `vision_2520`, Python runner medians; `eg2-cli
latency` measures the shipped split graph at 109–111 ms) but was not worth a second 300 MB asset
(`export.py --qchunks 1` builds it for a GPU-only deployment).

### Prompts and cross-modal search

Images take **no prompt** (as the model card prescribes): the image sequence is `<bos> <|image> [soft tokens]
<image|> <eos>`. Text keeps its prompts. For "find the image that matches this text", embed the text with
`kind: "search_query"` and compare it with the image vectors by cosine (dot product: all vectors are
L2-normalized). Image vectors and document vectors live in the same space, so one index can hold both; for
image β†’ text (caption search), embed the captions as `document`. Matryoshka prefixes work for images too.

### How an image becomes a vector

1. **Decode** to 8-bit RGB the way the reference does: alpha is dropped (not composited), EXIF orientation is
   **not** applied, no colour management. Palette, gray, gray+alpha, CMYK and 1-bit images are expanded; 16-bit
   samples are reduced to their high byte.
2. **Size**: the aspect ratio is kept, each side becomes a multiple of 48 px and the image fits 2520 patches of
   16Γ—16 px (280 soft tokens). Small images are upscaled (1Γ—1 β†’ 768Γ—768); extreme aspect ratios are allowed (a
   1-px-wide image becomes 48Γ—13440). Most images end up with 250–280 soft tokens.
3. **Resize** with the exact antialiased bicubic filter the upstream processor uses on uint8 pixels (torchvision's
   kernel, width pass then height pass, integer arithmetic). This matters: Lanczos or bilinear resampling (what
   Core Image / vImage offer) moves the embedding down to cosine 0.96 (bilinear) or 0.86 (Lanczos) on the
   validation set, and wrong normalisation, BGR order or row-major patch order break it entirely.
4. **Patches** 16Γ—16Γ—3 of integer pixel values 0–255 (exact in float16; the graph applies upstream's Γ—1/255 and
   2p βˆ’ 1 itself, folded into its first projection) in "cell-major" order (the 9 patches of each 3Γ—3 pooling cell
   contiguous), with (x, y) positions, the axial-RoPE cos/sin tables and a padding mask β†’ `vision_2520` β†’
   `[280, 512]` soft tokens, of which the first n are valid.
5. **Sequence** `<bos>`=2, `<|image>`=255999, n Γ— `<|image|>`=258880, `<image|>`=258882, `<eos>`=1 (n + 4 ≀ 284
   tokens β†’ bucket 288), the n soft tokens placed in the placeholder slots β†’ `mm_288` β†’ `[768]`, L2-normalized.

The runners do all of this; `VisionInputs` / `prepare_vision_inputs` expose the intermediate step for callers that
want to run the functions themselves (several images in one sequence, text + image).

### `vision_2520` (asset `embeddinggemma-2-vision.aimodel`)

| | |
|---|---|
| `patches` | `[1, 2520, 768]` float16: 16Γ—16Γ—3 patches flattened (py, px, c), **integer pixel values 0–255** (not Γ·255), cell-major order, zero padded |
| `pos_x`, `pos_y` | `[1, 2520]` int32 patch column / row (0 for padding) |
| `cos`, `sin` | `[1, 1, 2520, 64]` float16 axial RoPE tables: channels 0–31 from x, 32–63 from y, 16 frequencies (ΞΈ = 100) duplicated |
| `patch_mask` | `[1, 2520]` float16, 1 for a patch, 0 for padding |
| `soft_tokens` | `[1, 280, 512]` float16; rows at index β‰₯ n are padding and must be ignored |

In `mm_<S>` the graph computes `x = embed(input_ids)Β·(1 βˆ’ mm_mask) + mm_embedsΒ·mm_mask` and then the text encoder,
so the soft tokens go into `mm_embeds` at the placeholder positions (not scaled) with `mm_mask` = 1 there. Any mix
fits a bucket: two images = 2Β·282 + 2 tokens β†’ `mm_1024`, or text followed by an image.

### EXIF orientation: a decision for the integrator

The reference embeds a photo as stored: a portrait photo whose EXIF tag says "rotate 90Β°" is embedded sideways.
`applyEXIFOrientation: true` / `apply_exif_orientation=True` / `--exif` rotates it upright first; the upright
vector differs from the as-stored one by cosine ~0.97 and matches the same picture saved upright (0.998). For photos
from cameras and phones an app almost certainly wants `true`; for screenshots, renders and documents it makes no
difference. The default is `false` for parity with the reference. Pick one and index consistently.

### Video

Not included. Upstream embeds a video as **one** sequence of frames sampled at 1 fps (up to 32 frames, 140 soft
tokens per frame, `<|video|>` placeholders), which needs a 140-token vision shape and longer text buckets than this
package exports. Embedding sampled frames one by one as images works with this package and is a common
approximation, but it is not what upstream does and was not compared with upstream video vectors.

## Audio

### Neural Engine or GPU

The audio encoder (`audio_<Tb>`, ~0.6 GFLOP per soft token, ~450 GFLOP for 30 s) runs on either the Neural Engine
or the GPU; the clip's final pass through the text encoder (`mm_<S>`, S = tokens + 4) runs where `compute` says.
Measured on an M5 (24 GB, macOS 27), Swift `eg2-cli latency`, text on the Neural Engine:

| clip | bucket / tokens | `audio_<Tb>` Neural Engine | `audio_<Tb>` GPU | `mm_<S>` (Neural Engine) | end to end NE / GPU | CPU per clip NE / GPU |
|---|---|---|---|---|---|---|
| 3 s | 384 / 75 | 10.9 ms | 14.4 ms | 7 ms (`mm_128`) | **18 ms** / 22 ms | **1.1 ms** / 16 ms |
| 10 s | 1024 / 250 | 23.6 ms | 19–21 ms | 15 ms (`mm_288`) | 38 ms / **33–44 ms** | 1.2 ms / 14–22 ms |
| 30 s | 3000 / 750 | 86 ms | **50 ms** | 70 ms (`mm_1024`) | 156 ms / **119 ms** | 1.8 ms / 20 ms |

Front end (Swift, one core): decode + mono + resample of a 30 s stereo 48 kHz clip 39 ms (Accelerate dot products;
16 kHz input needs no resampling), log-mel + inputs 3 ms. The numpy front end takes ~0.6 s for the same resample.
Accuracy of the two routes is the same (table below). **Default: the Neural Engine** β€” a few milliseconds slower on
long clips, ~10–20Γ— less CPU, almost no GPU (0.3–0.5 ms of GPU time per clip, against 10–47 ms per clip on the
GPU route). **Choose the GPU** for bulk indexing of long recordings when wall-clock time matters.
`audioCompute: .cpu` / `audio_compute="cpu"` / `--audio-compute cpu` is refused with an error before any asset is
loaded: the CPU route is not supported for audio.

```swift
let model = try await EmbeddingGemma2(bundle: url, compute: .neuralEngine, loadAudio: true, audioCompute: .gpu)
model.audioCompute          // .gpu   (if `audioCompute:` is omitted, it equals `compute`)
```

```
eg2-cli embed-audio ../model --audio-compute gpu ../tests/audio/speech_en_train_16k.wav
python3 embeddinggemma2_coreai.py embed-audio ../model --audio-compute gpu ../tests/audio/speech_en_train_16k.wav
```

### How an audio clip becomes a vector

1. **Decode** at the file's own sample rate, every channel, float32. Swift: AVAudioFile (AudioToolbox: WAV, AIFF,
   CAF, FLAC, ALAC, AAC/M4A, MP3, …). Python: `soundfile` (libsndfile, what upstream's `librosa.load` uses;
   installed by `requirements.txt`), and macOS `afconvert` for what libsndfile cannot read (AAC/M4A) or when
   `soundfile` is not installed.
2. **Mono**: the mean of the channels (as librosa does).
3. **Resample to 16 kHz** with the package's Kaiser-windowed sinc (identical in Swift and numpy, see "Audio:
   decoding and resampling"). Clips already at 16 kHz are untouched.
4. **Window**: the first 30 s (480 000 samples). Longer audio: see "Long audio".
5. **Log-mel frames** exactly as the upstream feature extractor: 160 zeros in front, frames of 320 samples every
   160 (periodic Hann), |FFT 512|, 128 triangular HTK mel bands 0–8 kHz, ln(mel + 0.001). A frame counts only if
   its whole window lies in real audio: n frames = ⌊(samples βˆ’ 161) / 160βŒ‹ + 1 (2999 for 30 s; fewer than 161
   samples give 0 frames and 0 tokens).
6. **Bucket**: the smallest of 192, 384, 1024, 1536, 3000 frames β‰₯ n; features zero-padded, three masks (frames,
   half frames, tokens) β†’ `audio_<Tb>` β†’ `[Tb/4, 512]` soft tokens, of which the first ⌈⌈n/2βŒ‰/2βŒ‰ are valid (25 per
   second; 750 for 30 s).
7. **Sequence** `<bos>`=2, `<|audio>`=256000, n Γ— `<|audio|>`=258881, `<audio|>`=258883, `<eos>`=1 (n + 4 ≀ 754
   tokens β†’ up to bucket 1024), the soft tokens placed in the placeholder slots β†’ `mm_<S>` β†’ `[768]`, L2-normalized.

Audio takes **no prompt** (as the model card prescribes). Text queries use `search_query`; captions as documents
use `document`. Image, audio and text vectors share the space, so one index can hold all three. Padding is exact:
the encoder is causal, so a clip's tokens do not depend on the bucket (verified: the same clip through its own
bucket and through `audio_3000` gives bit-identical soft tokens on the Neural Engine and tokens within float16
noise, cosine β‰₯ 0.99999, on the GPU).

### `audio_<Tb>` (asset `embeddinggemma-2-audio.aimodel`)

| | |
|---|---|
| Functions | `audio_192`, `audio_384`, `audio_1024`, `audio_1536`, `audio_3000` (frames of 10 ms; 1.92 s, 3.84 s, 10.24 s, 15.36 s, 30 s) |
| `features` | `[1, Tb, 128]` float16 log-mel frames, the first n rows real, zero after (values βˆ’6.9 … +7) |
| `frame_mask` | `[1, Tb]` float16: 1 for the first n rows |
| `half_mask` | `[1, Tb/2]` float16: 1 for the first ⌈n/2βŒ‰ rows (after the first stride-2 convolution) |
| `token_mask` | `[1, Tb/4]` float16: 1 for the first ⌈⌈n/2βŒ‰/2βŒ‰ rows (= the soft-token count) |
| `soft_tokens` | `[1, Tb/4, 512]` float16; rows at index β‰₯ tokens are padding and must be ignored |

Why these buckets: they are the frame counts validated on the Neural Engine (placement and correctness); other
sizes are excluded for compatibility. Clips of 3.9–10 s therefore use the 1024 bucket (24 ms instead of ~15).
`export.py --audio-buckets` builds other sets; **verify every bucket you add on the Neural Engine**: correctness
with `eg2-cli smoke` and placement with the Neural Engine footprint that `eg2-cli latency` prints, not only that it
loads.

### Audio: decoding and resampling

The reference (`sentence-transformers` β†’ `librosa.load(sr=16000)`) decodes with **libsndfile**, mixes to mono and
resamples with **libsoxr "HQ"**. The package reproduces the first two exactly and replaces the third:

- **Decoders.** Apple's AudioToolbox decoder (Swift; also the Python `afconvert` fallback) is sample-identical to
  libsndfile for WAV/AIFF/AIFC/CAF (any PCM depth, 8-bit to 64-bit float), FLAC and ALAC, and within 3e-7 for AAC
  (same decoder family). For **MP3** AudioToolbox does not always honour the LAME/Xing/Info tag: most files come out
  gapless, some as whole frames (encoder delay and padding still in place), and some with the delay removed but one
  or two whole frames too many at the end (`afinfo` then counts one packet more than the tag). The Swift decoder and
  the Python fallback read the tag and bring every case to its gapless length, after which the samples match
  libsndfile/mpg123 within 3e-5 (all 55 tagged MP3s tested: 16–48 kHz, mono/stereo, CBR/VBR). The residue sits
  where Apple's decoder outputs exact zeros and mpg123 values around 1e-5: inaudible, but the log-mel floor (0.001)
  makes the model sensitive to it in **digitally silent** passages. Clips with long digital silence (synthesized
  speech with pauses, synthesized sounds with silent decays) scored 0.990–0.9999 against the libsndfile-decoded
  reference (fp32, the same model on both waveforms; β‰₯ 0.99998 when the same files are decoded with libsndfile);
  MP3s without digital silence β‰₯ 0.99998. The Python runner decodes MP3 with `soundfile` (installed by
  `requirements.txt`) and is libsndfile-exact; only its `afconvert` fallback has this difference. In
  Swift, decode MP3 with another decoder and pass the samples to `embedAudio(samples:sampleRate:)`. **MP3 files
  without a LAME/Xing tag** cannot be trimmed: Apple's decoder removes the 529-sample decoder delay and libsndfile
  does not, so the two are 529 samples apart (cosine 0.92–0.993 on speech). **Ogg Vorbis**: libsndfile (Python with
  `soundfile`) is exact; Apple's decoder (Swift, `afconvert`) is 128 samples off libsndfile (cosine 0.990–0.9985 on
  speech).
- **Resampler.** libsoxr is LGPL and its "HQ" / "VHQ" settings already differ from each other by cosine up to
  0.004, so the package ships its own Kaiser-windowed sinc (polyphase, pass band 0.913 and stop band 1.0 of the
  lower Nyquist, 125 dB attenuation, unity DC gain, output length ⌈nΒ·16000/rateβŒ‰ like librosa), computed in double
  precision by the same formulas in Swift and numpy: the two give **bit-identical** 16 kHz waveforms on all 147
  resampled validation clips. Against libsoxr HQ, measured through the fp32 model on those 147 clips (8–96 kHz):
  median cosine 0.999999, 140 of 147 β‰₯ 0.9999, minimum 0.9953 (96 kHz synthesized wind noise; the 7 clips below
  0.9999 are all synthesized noise-like sounds), waveform SNR median 81 dB. Speech is unaffected (β‰₯ 0.9999).
  An independent set (171 resampled lossless/AAC clips, 7.35–192 kHz) gave median 0.999999, 163 of 171 β‰₯ 0.9999,
  speech β‰₯ 0.99988, minimum 0.9939 (a synthesized 22.05 kHz engine hum whose upper mel bands sit at the log-mel
  floor); Swift and numpy were bit-identical on 191 of its 201 resampled clips and within 4e-15 on the rest (the AAC
  ones within the 3e-7 decoder difference). 16 kHz input bypasses the resampler entirely. Apple's own
  `AudioConverter` (even at maximum quality) would cost down to 0.93.
- **Features.** The numpy log-mel is bit-identical to the upstream extractor (214/214 clips, and 279/279 of the
  independent set). The Swift log-mel (Accelerate single-precision FFT) differs from numpy's by at most 0.006 per
  value (same waveform), which costs ≀ 0.0005 of cosine on the Neural Engine route (Swift vs Python vectors on 287
  clips: median 0.999996, min 0.99954).

### Long audio

The model's window is 30 s (`processor` default, 480 000 samples): upstream silently drops the rest. The runners
offer two policies: **`truncate`** (default; the first 30 s, exactly upstream) and **`chunk_mean`** (the mean of
the L2-normalized embeddings of consecutive 30 s windows, re-normalized; a trailing window too short for one
frame is dropped). Measured against the fp32 model on clips of 45–130 s (Neural Engine route): `chunk_mean`
matches the same policy in fp32 to β‰₯ 0.99996, and the mean is closer to an (unsupported) whole-clip embedding than the truncated one is (0.95
vs 0.94 at 130 s). Neither is what upstream returns for long audio, which is the truncated vector. For retrieval
over long recordings consider indexing each 30 s window as its own document.

### Audio accuracy

Parity with fp32 sentence-transformers on 214 generated clips (112 spoken sentences in 11 languages from 50+
macOS `say` voices and rates, used for local validation only and not shipped, 42 synthesized sounds, 30 edge cases, 17 copies of one sentence in different formats and
rates, 11 long clips, 3 mixtures; features from the upstream extractor so that the table measures the model):

| route (audio / text) | median | min | speech min | sounds min | edge / format / long min |
|---|---|---|---|---|---|
| Neural Engine / Neural Engine (default) | 0.99996 | 0.9981 (a synthesized chord) | 0.9987 | 0.9981 | 0.9991 / 0.99994 / 0.9997 |
| GPU / Neural Engine | 0.99998 | 0.9994 (phase-inverted stereo noise) | 0.99978 | 0.99943 | 0.9994 / 0.99997 / 0.99977 |

Cross-modal retrieval, 154 captions ↔ 154 clips (captions as `search_query` / `document`):

| | text β†’ audio R@1 / R@5 / MRR | audio β†’ text R@1 / R@5 / MRR |
|---|---|---|
| fp32 reference, 768 dims | 0.545 / 0.701 / 0.629 | 0.597 / 0.682 / 0.642 |
| Neural Engine route, 768 dims | 0.545 / 0.727 / 0.629 | 0.597 / 0.688 / 0.643 |
| GPU route, 768 dims | 0.545 / 0.714 / 0.628 | 0.597 / 0.682 / 0.642 |
| fp32 reference, 128 dims | 0.532 / 0.643 / 0.603 | 0.500 / 0.643 / 0.573 |
| Neural Engine route, 128 dims | 0.532 / 0.649 / 0.603 | 0.500 / 0.630 / 0.573 |

(Spoken sentences alone retrieve at MRR 0.79; the 42 synthesized sounds are a parity check, not a retrieval
benchmark: fp32 itself ranks only a third of them first.) The re-authored graph run in fp32 reproduces the
reference soft tokens to cosine β‰₯ 0.99999998 (213 clips).

On the Neural Engine the least precise inputs are digital silence (0.9993 on a 1 s silent clip, 0.9991 on 30 s)
and dense synthesized chords (0.998): float16 arithmetic on near-constant spectra. Speech from every voice and
language tested stayed β‰₯ 0.9983 (median 0.99997) across both validation sets.

**Independent validation set** (generated separately from the set above: 287 clips β€” 100 spoken sentences in 18
languages from 84 macOS `say` voices and rates (local only, not shipped), 46 synthesized sounds, 74 copies of two sentences in 17 containers/codecs
at 7.35–192 kHz, 54 edge cases (1 sample to 40 s, every bucket boundary, silence, clipping, DC offset, a 30 kHz
tone at 96 kHz, 2/4/6 channels, phase-inverted stereo), 8 long clips of 29.9–150 s, 5 speech-over-sound mixtures;
reference: fp32 sentence-transformers on the runner's own 16 kHz waveform, so the table measures features + model;
282 clips, upstream fails below 129 samples). Cosine at 768 dims, both runners:

| route (audio / text) | median | min | speech min (any format) | sounds min | edge min | Swift vs Python |
|---|---|---|---|---|---|---|
| Neural Engine / Neural Engine | 0.99996 | 0.9972 (30 kHz tone at 96 kHz: near-silence after resampling) | 0.9983 | 0.9984 | 0.9972 | median 0.999996, min 0.99954 |
| GPU / Neural Engine | 0.99998 | 0.9994 (speech at βˆ’90 dBFS) | 0.99996 | 0.9997 | 0.9994 | min 0.9999 |
| GPU / GPU | 1.00000 | 0.9995 (the same clip) | 0.99997 | 0.99985 | 0.9995 | min 0.99993 |

512 / 256 / 128 dims are the same or better (Neural Engine minimum 0.9973 / 0.9979 / 0.9987); the runners'
`dim:` output equals the truncated, re-normalized 768-d vector. Long clips: `truncate` β‰₯ 0.9999 and `chunk_mean` β‰₯
0.99978 (Neural Engine), β‰₯ 0.99985 (audio on the GPU, text on the Neural Engine) and β‰₯ 0.99996 (everything on the
GPU) against the same policy in fp32. Retrieval with the 146 captions (100
sentences, 46 sounds) as `search_query` / `document`: fp32 text β†’ audio R@1 / R@5 / MRR 0.548 / 0.699 / 0.625,
audio β†’ text 0.568 / 0.664 / 0.625; at 768 dims every route is within one query (0.007) of fp32 on every metric
(R@1 identical); at every size R@1 stays within one query and MRR within 0.007, and R@5 within one query except
text β†’ audio R@5 at 256 dims (2 of 146 queries lower on the Neural Engine and GPU-audio routes) and at 128 dims
(2 of 146, Python GPU-audio route): synthesized-sound captions whose own clip tied the 5th result within 0.002 in
fp32.
End to end against upstream's own file path (libsndfile + libsoxr), the decoder and resampler effects described
above come on top: median 0.99995, minimum 0.990 for MP3/Ogg via Apple's decoder, 0.92 for an MP3 without a tag.

## Accuracy (measured)

Reference: sentence-transformers 6.1 / transformers 5.19 / torch 2.14.1 / torchvision 0.29.1, fp32, CPU, original
checkpoint; images passed as paths with no prompt; audio as 16 kHz arrays (libsndfile/ffmpeg + libsoxr HQ) with
no prompt (audio figures: "Audio accuracy" above). All test texts, images and audio clips are fictional and generated from code
(charts, UI-like screens, slides, documents, diagrams, drawn scenes, shapes, plus size, aspect-ratio, colour-mode,
JPEG-quality and EXIF stress cases); 19 files made from 14 public-domain camera photos (16 JPEGs plus 3 lossless PNG
re-saves) were used for local validation only and are not shipped.

### Images

Cosine between this port and the fp32 reference, 768 dims, on the independent validation set (373 generated
images, 205 of them with distinct captions, 30 extra tiny images, plus the 19 photo files; 512/256/128 dims behave
the same or better):

| route (images / text) | median | min | min, captioned | min, 30 tiny images | photos |
|---|---|---|---|---|---|
| Neural Engine / Neural Engine (default), Python | 0.99998 | 0.9946 ΒΉ | 0.99972 | 0.9958 | β‰₯ 0.99992 |
| GPU / Neural Engine, Python | 0.99998 | 0.9989 ΒΉ | 0.99994 | 0.99975 | β‰₯ 0.99998 |
| GPU / GPU, Python | 1.00000 | 0.99975 | 0.99996 | 0.99992 | β‰₯ 0.99998 |
| CPU only, Python | 0.99993 | 0.946 ΒΉ | 0.99930 | 0.966 | β‰₯ 0.99980 |
| any route, Swift, lossless files (PNG, WebP lossless, GIF, …) | same as Python (cosine to the Python vector β‰₯ 0.99988) | | | | |
| any route, Swift, JPEG and lossy WebP files | 0.9998 | 0.982 Β² | | | β‰₯ 0.9981 (median 0.9994) |

ΒΉ One 16Γ—16 image upscaled to 768Γ—768 (see "Limits"); every other image of the main set is β‰₯ 0.9997 on the Neural
Engine route. Β² Synthetic charts with saturated colour edges saved as chroma-subsampled JPEG (see "JPEG in Swift").

A first, separate set (239 generated images, 77 queries) gave Neural Engine min 0.99966 / median 0.99997; it was
measured on an earlier build of the image asset (pixel/255 inputs, attention queries not split), not on the shipped
one, which was validated on the set above.
The Python preprocessing is bit-identical to the upstream processor on all 389 files upstream can read (its
patches Γ— 1/255 equal the processor's `pixel_values` exactly); the Swift decoder + resampler is bit-identical to the
Python one on 280/280 lossless files. The re-authored graphs in fp32 reproduce the reference exactly (embedding
cosine 1.000000).

**Cross-modal retrieval** (205 captions ↔ 205 images, captions as `search_query` / `document`):

| | text β†’ image R@1 / R@5 / MRR | image β†’ text R@1 / R@5 / MRR |
|---|---|---|
| fp32 reference, 768 dims | 0.937 / 1.000 / 0.968 | 0.917 / 0.990 / 0.952 |
| this port, every tested route (Python: Neural Engine, GPU, mixed, CPU; Swift: Neural Engine, GPU, mixed), 768 dims | 0.937 / 1.000 / 0.968 | 0.917 / 0.990 / 0.952 |
| fp32 reference, 128 dims | 0.912 / 1.000 / 0.956 | 0.888 / 0.966 / 0.922 |

At 768 dims all seven routes rank every caption and every image exactly where fp32 does (205/205 in both
directions); at 512/256/128 dims every metric stays within 0.005 of fp32 and 201–205 of 205 keep their rank.

**JPEG in Swift.** ImageIO's JPEG decoder upsamples chroma differently from libjpeg (which the reference uses via
Pillow/torchvision), so JPEG pixels differ by 1–4 values on documents and up to 80 at saturated colour edges of
chroma-subsampled charts, before any resizing. Camera photos stay β‰₯ 0.998 (median 0.9994); JPEGs saved without
chroma subsampling β‰₯ 0.9999; retrieval ranks at 768 dims were unchanged for all 205 captions on every route. PNG and
every lossless format are exact. If you need reference-exact JPEG vectors, decode JPEGs with a libjpeg-compatible
decoder (e.g. the Python runner, which uses Pillow) and pass the pixels to `ImagePreprocessor`.

### Text

Unchanged from 1.0: the text functions give bit-identical outputs to 1.0 on 466 texts across every bucket, so the
1.0 figures hold. Cosine to fp32 on 357 fictional texts (English, Italian, code, Unicode stress, bucket
boundaries; 2–2048 tokens): Neural Engine min 0.99992 / median 0.99997; GPU min 0.99998; CPU only min 0.99916.
A second, independent set of 100 texts: min 0.99985 (an emoji-heavy text). **Expect β‰₯ 0.9998 on the Neural Engine
and GPU routes, β‰₯ 0.999 on the CPU route.** Retrieval on 1,712 fictional passages / 53 queries: MRR 0.766 at 768
dims, identical to fp32 (53/53 same first-relevant rank); 0.782 / 0.785 / 0.735 at 512 / 256 / 128 (fp32 0.790 /
0.786 / 0.735). On the image validation set, its 410 captions and documents scored β‰₯ 0.99993 on the Neural Engine.

**About float16.** The upstream model card says *"Do not use float16"*: the activations exceed the fp16 range
and a naive fp16 run returns NaN or degraded vectors. This port runs in fp16 on purpose (the Neural Engine is
fp16-only) and avoids the overflow by dividing every RMSNorm row and the final L2 normalization by its max-abs
value before squaring (see NOTICE). The figures above are the evidence; if you change the graph, keep that rescaling.

**Tokenizer.** The Swift `GemmaTokenizer` re-implements the Hugging Face `tokenizers` pipeline described by
`tokenizer.json` (special-token extraction, `Replace " " β†’ "▁"`, BPE with byte fallback, rank-ordered merges),
on Unicode scalars and exact UTF-8 bytes, and reads `tokenizer.json` with an exact JSON reader (Foundation's
`JSONSerialization` strips a leading U+FEFF from string values). `tests/tokenizer-cases.json` holds 3,758
fictional texts (608,225 ids: CRLF, NFD/NFKD accents, emoji ZWJ sequences, Arabic with diacritics, Thai, CJK, 30+
languages, whitespace runs, control characters, special-token look-alikes, 5,000–80,000-character inputs);
`eg2-cli tokcheck` reports **3758 / 3758 identical** to `tokenizers` 0.23.3. The Python runner uses `tokenizers`.

### Smoke tests

`tests/smoke-reference.json` (3 fictional sentences, threshold 0.999) and `tests/image-smoke-reference.json` (10
generated images, their 10 queries and 2 documents; thresholds 0.998 for images and 0.999 for texts; every query
must rank its own image first). This package on the validation machine, all SMOKE PASS:

| route (images / text) | images (10) min, Swift / Python | texts (15) min | text β†’ image |
|---|---|---|---|
| Neural Engine / Neural Engine (default) | 0.99972 (the JPEG; PNGs β‰₯ 0.99994) / 0.99995 | 0.99995 | 10 / 10 |
| GPU / Neural Engine (`--image-compute gpu`) | 0.99973 (the JPEG; PNGs β‰₯ 0.99997) / 0.99997 | 0.99995 | 10 / 10 |
| GPU / GPU (`--compute gpu`) | 0.99974 (the JPEG; PNGs β‰₯ 0.99998) / 0.99999 | 0.99999 | 10 / 10 |
| CPU only (Swift) | 0.99965 | 0.99996 | 10 / 10 |

Text-only smoke (vision and audio assets not loaded) passes with the same text cosines as 1.0 (0.999948 / 0.999979 /
0.999976; Matryoshka 512 / 256 / 128: 0.999979 / 0.999981 / 0.999989). `eg2-cli tokcheck` 3758 / 3758,
`eg2-cli pixcheck` 11 / 12 bit-identical plus the JPEG (decoder difference, expected), `tests/check_preprocess.py`
identical to torchvision and the upstream processor on all 12 test images.

**Audio smoke** (`tests/audio-smoke-reference.json`: 15 generated clips β€” 8 invented sentences in 7 languages
spoken by the eSpeak NG formant synthesizer, 4 synthesized sounds, silence, a tone and a 35 s MP3 melody β€” with
their 13 captions as queries and documents; per-clip thresholds 0.9995 for 16 kHz lossless clips, 0.995 for
resampled and MP3 clips, two measured exceptions carried in the file: digital silence 0.998 and the MP3 melody
0.985; a query must rank its own clip first wherever fp32 does, which is 11 of 13 β€” fp32 itself misses two
synthesized sounds). All SMOKE PASS:

| route (audio / text) | 12 speech + sound clips, min (Swift / Python) | silence | MP3 melody (Swift / Python with `soundfile` / Python `afconvert` fallback) | text β†’ audio |
|---|---|---|---|---|
| Neural Engine / Neural Engine (default) | 0.9983 / 0.9991 (the piano chord; speech β‰₯ 0.99995) | 0.99926 | 0.9908 / 0.9994 / 0.9909 | 11 / 11 |
| GPU / Neural Engine (`--audio-compute gpu`) | 0.99989 / 0.99986 (speech β‰₯ 0.99997) | 0.99986 | 0.9902 / 0.9997 / 0.9903 | 11 / 11 |
| GPU / GPU (`--compute gpu`, Swift) | 0.99993 | 0.99996 | 0.9904 | 11 / 11 |

The MP3 melody is the one clip where the decoder matters: Apple's decoder (Swift, the Python `afconvert` fallback)
against libsndfile on a clip with silent decays (see "Audio: decoding and resampling"). `eg2-cli audiocheck` 16 /
16 within tolerance of the Python front end (11–13 waveforms bit-identical: every PCM, FLAC and ALAC file except
the 96 kHz tone, which is within 4e-17, and the two AAC files in some runs: Apple's AAC decoder varies by about one
float32 step between runs, ≀ 2e-7; MP3 within 3e-5; features within 0.0022 except MP3).

Caveats: one machine (M5, macOS 27). Core AI specializes per machine and OS build; re-run
`smoke` on your targets.

## Performance (measured, M5, Swift `eg2-cli latency`)

All timings in this README were measured on AC power. On battery with Low Power Mode on, the same machine took
1.4–1.6Γ— as long for every figure (e.g. a 30 s clip 218 ms end to end on the Neural Engine, text `mm_1024` 107 ms
instead of 69 ms).

Images: see "Images: Neural Engine or GPU" for the side-by-side comparison (time, CPU, GPU, power, energy,
memory). Per function:

| | Neural Engine | GPU |
|---|---|---|
| `vision_2520` (one image, 273 soft tokens) | 355 ms | 109–111 ms |
| `mm_288` (the image's sequence) | 15 ms | 17 ms (14 ms when text stays on the Neural Engine) |
| image end to end (excl. decode + resize) | 369 ms | 125 ms |
| CPU time per image (excl. decode + resize) | 2.0 ms | 21 ms (text on the Neural Engine) / 34 ms (all on the GPU) |
| `audio_384` / `audio_1024` / `audio_3000` (3 s / 10 s / 30 s clips) | 10.9 / 23.6 / 86 ms | 14.4 / 19–21 / 50 ms |
| `mm_128` / `mm_288` / `mm_1024` (those clips' sequences, Neural Engine) | 7 / 15 / 70 ms | β€” |
| audio end to end, 3 s / 10 s / 30 s (excl. decode + front end) | 18 / 38 / 156 ms | 22 / 33–44 / 119 ms (text on the Neural Engine) |
| CPU time per clip (excl. decode + front end) | 0.7–1.9 ms | 14–22 ms |
| text buckets 32 / 64 / 128 / 288 / 512 / 1024 / 2048, p50 | 4.9 / 5.3 / 6.8 / 14.4 / 28 / 69 / 196 ms | 8.1 / 8.4 / 10.3 / 16 / 22 / 50 / 143 ms |
| text CPU per call | 0.3–0.7 ms | 10–31 ms |
| process footprint + Neural Engine footprint (text + images) | 260–275 MB + 608–1061 MB | 435–440 MB + 608–766 MB (images on the GPU, text on the Neural Engine); 0.9–1.1 GB, no Neural Engine (all on the GPU) |
| process footprint + Neural Engine footprint (all three assets) | 300–475 MB + 1.6–2.5 GB (Python; the Swift CLI reported 0.77 GB) | 650–850 MB + 766 MB (audio and images on the GPU) |

Text-only use (`loadVision` and `loadAudio` off): ~225 MB footprint plus ~770 MB on the Neural Engine. Decode + exact resize +
patches on the CPU (Swift, multi-threaded resampler): 10 ms median, 43 ms at the 90th percentile, 125 ms for a
6000Γ—4000 JPEG (numpy: 45 ms median). Audio front end (Swift, one core): 14 ms median per clip over the 215
validation clips, 39 ms for the resample of a 30 s 48 kHz stereo clip, 3 ms for the log-mel of 30 s (numpy: ~0.6 s
for that resample, 6 ms log-mel). The CPU-only route takes ~520 ms per image and does not work for audio. The
Python runner gives the same model latencies within a few percent (8.5 ms CPU per image on the Neural Engine,
27 ms on the GPU; 1.3–4.7 ms CPU and 0.3–0.5 ms GPU time per audio clip on the Neural Engine, 15–25 ms CPU and 10–47 ms GPU time with audio on the GPU).

On the Neural Engine the 12 Γ— 2520 Γ— 2520 attention is the cost of the vision graph. Each function is batch-1; the
Neural Engine serializes concurrent calls. Nothing stops an app from running two routes at once (e.g. one instance
with images on the Neural Engine for background work and one on the GPU for interactive imports), at the memory cost
of a second instance.

**Load time** (`AIModel(contentsOf:)` + `loadFunction`):

| situation | time |
|---|---|
| warm (this executable has loaded these assets before) | 0.5–1.8 s, plus ~0.7 s for `prewarm()` |
| first load by an executable that has not loaded them before (a new app, a new binary name, the Python interpreter) | **about 5 minutes for all three assets** on the Neural Engine route (5 min 01 s measured for a new executable in a clean-room test); **about 3–4 minutes** for text + images (about 200 s observed for a Python interpreter that had never loaded them; the text asset alone took ~140 s in 1.0); about 30 s was observed for a renamed `eg2-cli` when another executable on the same machine had already compiled the same assets β€” do not rely on it |
| audio encoder, first load | 90–200 s on the Neural Engine when the assets were new to the machine; 19.5 s (Neural Engine) and 16 s (GPU) for a renamed `eg2-cli` after another executable had compiled them (text asset 28.5 s in the same situation) |
| image encoder on the GPU, first load | 2–25 s |

Load and `prewarm()` in the background at app start (or in a first-run step) and show progress; never on the main
thread and never lazily in front of the first query. The cache lives in
`~/Library/Caches/coreai-cache/<OS build>/<app>/`, where `<app>` is the executable name for a plain binary such as
`eg2-cli` and the bundle identifier for a bundled executable (`org.python.python` for a framework build of Python,
e.g. Homebrew's). It survives reboots but not OS updates, a renamed executable or a re-exported asset (including the
1.0 β†’ 1.1 upgrade).

## Why three assets (and why `mm_<S>` also serves text)

- The image and audio encoders are separate assets so that users neither download nor load what they do not
  need (`--exclude` at download, `loadVision` / `loadAudio` off at runtime). The three graphs share nothing.
- The text asset carries the soft-token inputs so that one 600 MB text encoder serves texts, images and audio. With
  zero soft tokens it is the 1.0 encoder, bit for bit, so there is no reason to ship both.
- The text buckets are 32, 64, 128, **288**, 512, 1024, 2048: 288 holds one image sequence (≀ 284 tokens) and
  replaces 256 so that an image does not pay for the 512 bucket; a 30 s clip (754 tokens) uses 1024.
- The audio buckets (192, 384, 1024, 1536, 3000 frames) are the sizes validated on the Neural Engine, see "Audio:
  `audio_<Tb>`".

## Temp files (please read)

Each Neural Engine load writes compiled-graph copies to `$TMPDIR/com.apple.MetalPerformanceShadersGraph/
mpsgraph-<pid>-<date>-<n>/` (~360 MB for the text asset, ~300 MB for the image asset; the audio asset added
nothing on either route in our measurements, only an empty folder on the GPU route). Measured on macOS 27:

- Swift (`CoreAI` framework): the runtime deletes the folder when the model is released; our CLI leaves nothing
  behind. If your app exits or is killed while the model is loaded, the folder may remain.
- Python (`coreai.runtime`): the folder is **never** deleted, not even after `del model` and a clean interpreter
  exit (~650 MB per load with all three assets). Over a day of development this fills a disk.

Both runners ship a cleanup that removes only the folders belonging to the **current process** (its pid, created
after the process started): `EmbeddingGemma2.removeOwnMPSGraphTempFiles()`, `remove_own_mpsgraph_temp_files()`,
or `--cleanup-temp` on every CLI command. Call it after the model has been released, or at exit. Folders of other
processes are never touched; a leftover from a crashed run has to be removed by hand (the name carries pid and date).

## Limits and known issues

- **Very small images upscaled a lot** (a 10–16 px image becomes 768Γ—768, every patch a smooth gradient) are the
  least precise case on the Neural Engine. Since 1.1 the graph takes exact integer pixels (the input rounding that
  cost such images up to 0.004 is gone). On the GPU image route the worst image is 0.9989 with text on the Neural
  Engine (`imageCompute: .gpu`; 30 targeted tiny images β‰₯ 0.99975) and 0.99975 with text on the GPU too (`compute:
  .gpu`). On the Neural Engine its own float16 arithmetic remains: worst image 0.9946 (a 16Γ—16 image), 30 targeted
  tiny images 0.9958–0.99999 (median 0.99993), every other validation image β‰₯ 0.9997. Retrieval is unaffected. If
  you index many icons or thumbnails of ≀ 16 px and want the closest match to the reference, put the images on the
  GPU: `imageCompute: .gpu` (worst 0.9989) or, closest, `compute: .gpu` (worst 0.99975).
- **JPEG decoding in Swift** differs from libjpeg (above): photos β‰₯ 0.998, chroma-subsampled synthetic charts
  down to 0.982; lossless formats are exact. This is the same on every compute route.
- **CPU-only route** (`compute: .cpu` / `imageCompute: .cpu`): fine on ordinary images (median 0.99993, captioned
  β‰₯ 0.9993) and texts (β‰₯ 0.9991) but the least precise route (tiny upscaled images down to 0.946) and the slowest;
  use the Neural Engine or the GPU.
- **One image budget.** Only the default 280-soft-token budget (2520 patches) is exported. The upstream processor
  also supports 70, 140 (video frames), 560 and 1120; those need other static shapes (`export/eg2_vision.py`:
  `MAX_SOFT`), and 1120 is probably GPU-only.
- **File formats**: the reference reads only JPEG, PNG, WebP and GIF from a path. The runners also read BMP and
  TIFF (Swift: also HEIC and anything else ImageIO decodes); there is no upstream vector to compare those with.
  16-bit images: upstream divides the 16-bit samples by 255 (garbage); the runners take the high byte, on purpose.
  Premultiplied-alpha CGImages cannot be un-premultiplied exactly; exotic pixel formats (float, Lab, …) fall back
  to a colour-managed Core Graphics draw over black.
- **2048-token cap** for text (longer input is truncated); buckets of 4096/8192 can be exported but were not
  validated and would run on the GPU.
- **Audio: 30 s window.** Like upstream, one call embeds at most 30 s; longer audio is truncated by default or
  averaged over 30 s windows (`chunk_mean`, our extension, not an upstream vector). Fewer than 161 samples (10 ms)
  give zero audio tokens and the embedding of an empty audio sequence (upstream crashes below 129 samples).
- **Audio: MP3 and Ogg decoding** with Apple's decoder (Swift and the Python `afconvert` fallback): tagged MP3s
  match the reference decoder within ~3e-5 per sample after the gapless trim; inaudible, but digitally silent
  passages amplify it in the log-mel floor: clips with long digital silence (synthesized speech with pauses,
  synthesized sounds with silent decays) score 0.990–0.9999 against the reference, other MP3s β‰₯ 0.99998. MP3 files
  without a LAME/Xing tag are 529 samples off the reference (cosine 0.92–0.993); Ogg Vorbis 128 samples (0.990–
  0.9985). The Python runner with `soundfile` (the default install) is exact; in Swift, decode these formats
  yourself when that matters.
- **Audio: resampling** uses the package's sinc instead of libsoxr: ≀ 0.00012 of cosine on speech, up to 0.006 on
  synthesized sounds whose upper bands sit at the log-mel floor. 16 kHz input is exact.
- **Audio on the Neural Engine**: near-silence (digital silence, a 30 kHz tone resampled to nothing, speech at
  βˆ’90 dBFS) and dense synthesized chords are the least precise inputs (0.997–0.9993); speech β‰₯ 0.9983. The GPU
  route is β‰₯ 0.9994 on everything. Only the shipped frame buckets are supported (see "Audio: `audio_<Tb>`"), and
  the CPU route is not supported for audio, so the runners refuse it.
- **Audio sample arrays**: Python `embed_audio(array, sample_rate)` takes `[n]` or `[n, channels]` and rejects a
  channel-first `[channels, n]` array (librosa / torchaudio layout: pass `array.T`); Swift
  `embedAudio(samples:sampleRate:)` takes channel-major `samples[channel][frame]` and rejects frame-major arrays,
  channels of different lengths and a non-positive rate (before 1.2's validation pass, these were silently embedded
  as near-empty audio or crashed).
- **Audio asset memory**: ~1.4 GB on the Neural Engine for the five functions (the runtime does not share the
  weights between them on the Neural Engine); on the GPU route ~300 MB of process memory instead.
- **An interrupted first load** (the process is killed while the runtime specializes an asset) can leave the
  per-app cache entry unusable, so later loads of that asset fail in `loadFunction`. Delete
  `~/Library/Caches/coreai-cache/<OS build>/<app>/<asset hash>/` and reload (one more first-load specialization).
- **Batch 1.** Concurrency did not increase throughput (the Neural Engine serializes). Short texts are not faster
  than ~4.8 ms (per-call floor in our measurements).
- **Latency is not shape-stable across bucket sets**: Core AI specializes the whole function set together; measure
  the exact set you ship.
- **Specialization cache is per app** (executable name or bundle identifier); **temp files** as described above;
  **fp16 output** (relative precision ~5e-4; convert to float32 before accumulating dot products, as the runners do).
- Validated on one machine (macOS 27). Core AI is new in macOS 27 and its runtime
  behaviour (first-load time, placement, temp files) may change between OS releases.

## Reproducing the export

```
# a separate environment with Python 3.12 (torch 2.9.0); exact versions in python/requirements.lock
python3.12 -m venv .venv-export && source .venv-export/bin/activate
pip install coreai-core==1.0.0b3 torch==2.9.0 coreai-torch==0.4.3 coreai-opt==0.3.0 torchao==0.17.0 \
    safetensors numpy pillow tokenizers huggingface-hub
hf download google/embeddinggemma-2 --local-dir ./embeddinggemma-2
cd export
python3 -W ignore export.py --model-dir ../embeddinggemma-2 --out ../model-rebuilt \
    --buckets 32,64,128,288,512,1024,2048 --qchunks 12 --audio-buckets 192,384,1024,1536,3000 --date 2026-10-12
```

About 40 s on an M5 (`--part text|vision|audio` rebuilds one asset). `eg2_text.py` is the re-authored text encoder
(byte-identical to 1.0), `eg2_text_mm.py` adds the soft-token mix, `eg2_vision.py` is the re-authored image
encoder, `eg2_audio.py` the re-authored audio encoder; `export.py` does one `torch.export` per asset, converts with
`coreai_torch.TorchConverter`, clones the text and audio entry points per bucket with `set_static_shape_config`
and writes the bundle. torchao must be 0.17.0 with torch 2.9 (0.18 breaks `import coreai_opt`).

A rebuilt bundle is **functionally identical, not byte-identical**: the converter names internal graph resources at
random (`main.mlirb` / `main.hash` differ by a few bytes) and the runtime writes `metadata.json` with the export
time. `config.json`, `MODEL-ATTRIBUTION.json` and the tokenizer files come out byte-identical. Verify a rebuild
with `smoke`, not with checksums; a rebuilt asset has a new content hash, so its first load takes the full time.

Neural Engine eligibility without running: `xcrun coreai-build compile model/<asset>.aimodel --output ./compiled
--preferred-compute neural-engine --architecture h17g 2>&1 | grep -c ane_validation_message` prints 0 for the
text and image assets (no op rejected; delete `./compiled` afterwards). The specialized text and image graphs have
one Neural Engine region and one GPU region (the token / position-table gather) per function; each audio function
is a single Neural Engine region. For the audio asset this offline check is not sufficient: verify placement with
the process's Neural Engine footprint (`eg2-cli latency` prints it) and correctness with `smoke`.

## Credits and license

- Model: EmbeddingGemma 2 by the Gemma Team, Google DeepMind β€” Apache-2.0. Deployments must adhere to the
  [Gemma Prohibited Use Policy](https://ai.google.dev/gemma/prohibited_use_policy).
- Architecture, image-processor and audio-feature-extractor reference: Hugging Face transformers (Apache-2.0).
  Resize arithmetic: ported from the logic of ATen's uint8 antialiased bicubic kernel (PyTorch, BSD-3-Clause),
  itself modelled on Pillow's resampling; CMYK conversion ported from Pillow's formula (MIT-CMU). Their license
  notices are in NOTICE. The audio resampler is the package's own design (upstream uses libsoxr, not included).
- The max-abs RMSNorm rescaling idea comes from the FluidInference Core ML port of this model (Apache-2.0); no code
  was copied.
- Port, exporter, runners, Swift tokenizer implementation, preprocessing and audio front end, tests, test images
  and test audio: Copyright 2026 Everpaper, Apache-2.0.
- Gemma and EmbeddingGemma are trademarks of Google LLC; Apple, Core AI, Apple silicon and Neural Engine are
  trademarks of Apple Inc. This port is not affiliated with, sponsored by or endorsed by Google or Apple.