File size: 37,664 Bytes
dfe210b
 
305fc2a
dfe210b
 
 
 
305fc2a
80c945e
 
305fc2a
 
80c945e
 
305fc2a
dfe210b
80c945e
dfe210b
80c945e
dfe210b
305fc2a
 
 
dfe210b
80c945e
dfe210b
80c945e
 
305fc2a
 
80c945e
305fc2a
 
80c945e
 
 
 
305fc2a
 
 
 
dfe210b
 
 
 
 
 
 
 
 
 
 
 
 
 
80c945e
 
305fc2a
80c945e
 
dfe210b
 
 
 
305fc2a
 
80c945e
 
305fc2a
 
 
 
 
dfe210b
 
 
 
 
 
80c945e
dfe210b
 
 
 
80c945e
305fc2a
 
 
 
 
 
80c945e
 
305fc2a
dfe210b
 
 
 
 
 
80c945e
 
 
 
 
305fc2a
 
 
 
 
 
 
 
dfe210b
 
80c945e
305fc2a
 
80c945e
 
 
 
dfe210b
305fc2a
 
 
 
dfe210b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80c945e
dfe210b
80c945e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
305fc2a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80c945e
 
 
 
 
dfe210b
 
 
80c945e
dfe210b
 
80c945e
 
 
 
 
 
 
 
 
 
 
dfe210b
 
80c945e
dfe210b
 
80c945e
dfe210b
 
 
80c945e
dfe210b
 
 
80c945e
 
305fc2a
 
dfe210b
 
 
 
305fc2a
 
80c945e
 
 
 
 
 
 
 
 
 
 
 
dfe210b
 
 
 
 
 
 
 
 
 
80c945e
 
305fc2a
80c945e
 
dfe210b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80c945e
305fc2a
 
 
 
80c945e
 
305fc2a
 
 
80c945e
305fc2a
 
80c945e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
305fc2a
 
 
 
 
 
 
 
 
 
 
80c945e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
305fc2a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80c945e
 
 
 
 
 
 
 
dfe210b
 
 
 
 
 
 
 
 
 
80c945e
 
 
 
 
 
 
 
 
 
 
dfe210b
80c945e
 
 
305fc2a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80c945e
 
 
305fc2a
dfe210b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
305fc2a
 
 
 
dfe210b
305fc2a
 
80c945e
305fc2a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80c945e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dfe210b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
305fc2a
 
dfe210b
305fc2a
 
dfe210b
 
 
 
 
305fc2a
 
dfe210b
305fc2a
 
80c945e
 
305fc2a
 
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
#!/usr/bin/env python3
# Copyright 2026 Everpaper. Licensed under the Apache License, Version 2.0 (see ../LICENSE).
"""EmbeddingGemma 2 (text + images + audio) on Apple Core AI, macOS 27 — Python runner (library + CLI).

Library:

    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 (about 3x faster, more power), text on the Neural Engine:
    # m = EmbeddingGemma2CoreAI("../model", compute="ane", load_vision=True, image_compute="gpu")
    # audio on the GPU, independently:  ..., load_audio=True, audio_compute="gpu"
    m.prewarm()                                                  # optional: one call per bucket (+ the vision/audio functions)
    q = m.embed("bar chart of lamp units sold per city", kind="search_query")    # 768-d, L2-normalized
    v = m.embed_image("chart.png")                               # path or PIL image, same 768-d space
    a = m.embed_audio("clip.wav")                                # path (wav/aiff/flac/m4a/mp3/caf...) or (samples, rate)
    d = m.embed("The credits expire on the last day of March.", kind="document", dim=256)
    score = float(q @ v)
    del m
    remove_own_mpsgraph_temp_files()                             # see "Temp files" in the README

`EmbeddingGemma2CoreAI(bundle, compute)` without `load_vision=True` / `load_audio=True` loads the text asset only
(as in v1.0). `compute` places the text asset; `image_compute` / `audio_compute` place the image and audio encoders
(default: the same as `compute`).

CLI:

    python3 embeddinggemma2_coreai.py embed       ../model [--compute ane] [--kind document] [--dim 768] "text" ...
    python3 embeddinggemma2_coreai.py embed-image ../model [--compute ane] [--image-compute gpu] [--exif] [--dim 768] image ...
    python3 embeddinggemma2_coreai.py embed-audio ../model [--compute ane] [--audio-compute gpu] [--long truncate|chunk_mean] [--dim 768] clip ...
    python3 embeddinggemma2_coreai.py smoke       ../model ../tests/smoke-reference.json ../tests/image-smoke-reference.json ../tests/audio-smoke-reference.json [--image-compute gpu] [--audio-compute gpu] [--cleanup-temp]
    python3 embeddinggemma2_coreai.py tokcheck    ../model ../tests/tokenizer-cases.json
    python3 embeddinggemma2_coreai.py latency     ../model [--compute ane] [--image-compute gpu] [--audio-compute gpu] [--trials 50] [--text-only]
    python3 embeddinggemma2_coreai.py prewarm     ../model [--compute ane] [--image-compute gpu] [--audio-compute gpu] [--text-only]

Text goes through `mm_<S>` with zero soft-token inputs (identical to the v1.0 text graph). Images are decoded with
Pillow (`convert("RGB")`: alpha dropped, EXIF orientation not applied unless --exif), resized with the
torchvision-exact bicubic in eg2_image.py and fed to `vision_2520`; the soft tokens then go through `mm_288` inside
the sequence <bos> <|image> [n slots] <image|> <eos>. Audio is decoded, mixed to mono, resampled to 16 kHz and turned
into log-mel frames by eg2_audio_frontend.py (numpy; soundfile or macOS afconvert for decoding), fed to
`audio_<Tb>` and then through `mm_<S>` inside <bos> <|audio> [n slots] <audio|> <eos>. Tokenization uses the Hugging
Face `tokenizers` library on the bundle's tokenizer.json (the reference tokenizer).
"""
from __future__ import annotations

import argparse
import asyncio
import ctypes
import json
import os
import sys
import time
from pathlib import Path

import numpy as np

HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
import eg2_audio_frontend  # noqa: E402
import eg2_image  # noqa: E402

_PROCESS_START = time.time()


class EmbeddingGemma2CoreAI:
    """Loads the bundle folder (config.json, tokenizer/, the text asset and optionally the vision and audio assets)
    and embeds texts, images and audio clips into the same 768-d space."""

    def __init__(self, bundle: str | os.PathLike, compute: str = "ane", quiet: bool = True, load_vision: bool = False,
                 image_compute: str | None = None, load_audio: bool = False, audio_compute: str | None = None):
        """compute: where the text asset runs (ane | gpu | cpu | default). image_compute / audio_compute: where the
        image and audio encoders run, independently (None = same as compute). "ane" uses the least CPU, GPU and
        power; "gpu" is about 3x faster per image (README, "Images: Neural Engine or GPU") and about 2x faster per
        30 s of audio (README, "Audio: Neural Engine or GPU")."""
        import coreai.runtime as rt
        from tokenizers import Tokenizer

        self._rt = rt
        self.bundle = Path(bundle)
        self.config = json.loads((self.bundle / "config.json").read_text(encoding="utf-8"))
        self.hidden = self.config["hidden_size"]
        t = time.perf_counter()
        self.tokenizer = Tokenizer.from_file(str(self.bundle / "tokenizer" / "tokenizer.json"))
        self.tokenizer_load_s = time.perf_counter() - t
        self.compute = compute
        self.image_compute = image_compute or compute
        self.audio_compute = audio_compute or compute
        # refuse an unsupported audio request before loading anything (no specialization, no temp files)
        if load_audio and self.config.get("audio") is None:
            raise ValueError("this bundle has no audio asset (config.json 'audio')")
        if load_audio and self.audio_compute == "cpu":
            raise ValueError("audio_compute='cpu' is not supported for audio; use 'ane' or 'gpu'")
        opts = _specialization_options(rt, compute)
        image_opts = _specialization_options(rt, self.image_compute)
        audio_opts = _specialization_options(rt, self.audio_compute)
        t = time.perf_counter()
        self.model = asyncio.run(rt.AIModel.load(self.bundle / self.config["asset"], opts))
        self.functions: dict[int, object] = {}
        for name in self.config["functions"]:
            self.functions[int(name.rsplit("_", 1)[1])] = self.model.load_function(name)
        self.buckets = sorted(self.functions)
        self.vision_model = self.vision_function = None
        if load_vision:
            vc = self.config["vision"]
            self.vision_model = asyncio.run(rt.AIModel.load(self.bundle / vc["asset"], image_opts))
            self.vision_function = self.vision_model.load_function(vc["function"])
        self.audio_model = None
        self.audio_functions: dict[int, object] = {}
        if load_audio:
            ac = self.config["audio"]
            self.audio_model = asyncio.run(rt.AIModel.load(self.bundle / ac["asset"], audio_opts))
            for name in ac["functions"]:
                self.audio_functions[int(name.rsplit("_", 1)[1])] = self.audio_model.load_function(name)
        self.audio_buckets = sorted(self.audio_functions)
        self.load_s = time.perf_counter() - t
        if not quiet:
            print(f"loaded {self.bundle.name} compute={compute} image_compute={self.image_compute if load_vision else '-'} "
                  f"audio_compute={self.audio_compute if load_audio else '-'} buckets={self.buckets} vision={load_vision} "
                  f"audio={load_audio} in {self.load_s:.1f}s", flush=True)

    @property
    def has_vision(self) -> bool:
        return self.vision_function is not None

    @property
    def has_audio(self) -> bool:
        return bool(self.audio_functions)

    # ---- tokenization -------------------------------------------------------------------------------------
    def tokenize(self, text: str, kind: str | None = "document") -> list[int]:
        """<bos> + prompt prefix + text + <eos>, truncated to the largest bucket (longer input is cut)."""
        prefix = self.config["prompts"].get(kind, "") if kind else ""
        ids = self.tokenizer.encode(prefix + text, add_special_tokens=False).ids
        max_body = self.buckets[-1] - 2
        if len(ids) > max_body:
            ids = ids[:max_body]
        return [self.config["bos_token_id"]] + ids + [self.config["eos_token_id"]]

    def bucket(self, n_tokens: int) -> int:
        for b in self.buckets:
            if b >= n_tokens:
                return b
        raise ValueError(f"{n_tokens} tokens exceeds the largest bucket {self.buckets[-1]}")

    # ---- text ---------------------------------------------------------------------------------------------
    def embed_ids(self, ids: list[int], bucket: int | None = None) -> np.ndarray:
        """Runs mm_<bucket> on ids (with <bos>/<eos>), right-padded with <pad>, no soft tokens.
        Returns the raw 768-d output (already L2-normalized by the graph)."""
        return self.embed_sequence(ids, bucket=bucket)

    def embed(self, text: str, kind: str | None = "document", dim: int = 768) -> np.ndarray:
        """L2-normalized embedding. dim 768, or a Matryoshka size (512/256/128): leading dims, re-normalized."""
        return self._finish(self.embed_ids(self.tokenize(text, kind)), dim)

    # ---- images -------------------------------------------------------------------------------------------
    def embed_image(self, image, apply_exif_orientation: bool = False, dim: int = 768) -> np.ndarray:
        """Path or PIL image -> L2-normalized 768-d embedding (or a Matryoshka prefix). The default
        apply_exif_orientation=False reproduces the upstream reference; True embeds camera photos upright."""
        inputs = eg2_image.prepare_vision_inputs(image, apply_exif_orientation)
        ids, soft, slots = self.image_sequence(self.soft_tokens(inputs))
        return self._finish(self.embed_sequence(ids, soft, slots), dim)

    def soft_tokens(self, inputs: eg2_image.VisionInputs) -> np.ndarray:
        """[n_soft, 512] float32 soft tokens of a prepared image (vision_2520)."""
        if self.vision_function is None:
            raise RuntimeError("vision asset not loaded: create the model with load_vision=True")
        rt = self._rt
        out = asyncio.run(self.vision_function(inputs={
            "patches": rt.NDArray(inputs.patches), "pos_x": rt.NDArray(inputs.pos_x), "pos_y": rt.NDArray(inputs.pos_y),
            "cos": rt.NDArray(inputs.cos), "sin": rt.NDArray(inputs.sin), "patch_mask": rt.NDArray(inputs.patch_mask)}))
        return np.array(out["soft_tokens"].numpy(), dtype=np.float32)[0, : inputs.n_soft]

    def image_sequence(self, soft: np.ndarray) -> tuple[list[int], np.ndarray, list[int]]:
        """(ids, soft tokens, slots) of the upstream single-image sequence <bos> <|image> [n] <image|> <eos>."""
        c = self.config
        n = soft.shape[0]
        ids = eg2_image.image_sequence(n, c["bos_token_id"], c["boi_token_id"], c["image_token_id"], c["eoi_token_id"], c["eos_token_id"])
        return ids, soft, list(range(2, 2 + n))

    # ---- audio --------------------------------------------------------------------------------------------
    def embed_audio(self, audio, sample_rate: int | None = None, dim: int = 768, long_audio: str = "truncate") -> np.ndarray:
        """Path (anything soundfile or macOS afconvert decodes) or a numpy array of samples ([n] or [n, channels],
        with sample_rate) -> L2-normalized 768-d embedding (or a Matryoshka prefix). The clip is mixed to mono,
        resampled to 16 kHz and, with long_audio="truncate" (upstream behaviour), its first 30 s are embedded;
        "chunk_mean" averages the embeddings of consecutive 30 s windows instead. A channel-first array
        ([channels, n], the librosa/torchaudio layout) is rejected: pass audio.T."""
        if isinstance(audio, (str, os.PathLike)):
            wav = eg2_audio_frontend.load_mono_16k(audio)
        else:
            if sample_rate is None or int(sample_rate) != sample_rate or sample_rate <= 0:
                raise ValueError(f"a positive integer sample_rate is required for sample arrays (got {sample_rate!r})")
            x = np.asarray(audio, dtype=np.float32)
            if x.ndim not in (1, 2):
                raise ValueError(f"expected samples of shape [n] or [n, channels], got {x.shape}")
            if x.ndim == 2 and x.shape[1] > max(x.shape[0], 1):
                raise ValueError(f"samples of shape {x.shape} look channel-first ([channels, n], the librosa/torchaudio "
                                 "layout): pass [n, channels], e.g. audio.T")
            wav = eg2_audio_frontend.resample_16k(eg2_audio_frontend.to_mono(x), int(sample_rate))
        return self.embed_audio_16k(wav, dim, long_audio)

    def embed_audio_16k(self, wav16k: np.ndarray, dim: int = 768, long_audio: str = "truncate") -> np.ndarray:
        """16 kHz mono float32 samples -> embedding."""
        if long_audio == "truncate":
            return self._finish(self.embed_audio_inputs(eg2_audio_frontend.prepare_audio_inputs(wav16k, self.audio_buckets)), dim)
        if long_audio != "chunk_mean":
            raise ValueError("long_audio must be truncate or chunk_mean")
        acc = np.zeros(self.config["embedding_dim"], np.float64)
        for chunk in eg2_audio_frontend.chunks_30s(wav16k):
            acc += self.embed_audio_inputs(eg2_audio_frontend.prepare_audio_inputs(chunk, self.audio_buckets))
        return self._finish(acc.astype(np.float32), dim)

    def embed_audio_inputs(self, inputs: eg2_audio_frontend.AudioInputs) -> np.ndarray:
        """Prepared inputs -> raw 768-d output of mm_<S> (already L2-normalized by the graph)."""
        soft = self.audio_soft_tokens(inputs)
        ids, soft, slots = self.audio_sequence(soft)
        return self.embed_sequence(ids, soft if slots else None, slots or None)

    def audio_soft_tokens(self, inputs: eg2_audio_frontend.AudioInputs) -> np.ndarray:
        """[n_tokens, 512] float32 soft tokens of prepared audio (audio_<bucket>)."""
        fn = self.audio_functions.get(inputs.bucket)
        if fn is None:
            raise RuntimeError("audio asset not loaded: create the model with load_audio=True" if not self.audio_functions
                               else f"no audio function for bucket {inputs.bucket}")
        rt = self._rt
        out = asyncio.run(fn(inputs={"features": rt.NDArray(inputs.features), "frame_mask": rt.NDArray(inputs.frame_mask),
                                     "half_mask": rt.NDArray(inputs.half_mask), "token_mask": rt.NDArray(inputs.token_mask)}))
        return np.array(out["soft_tokens"].numpy(), dtype=np.float32)[0, : inputs.n_tokens]

    def audio_sequence(self, soft: np.ndarray) -> tuple[list[int], np.ndarray, list[int]]:
        """(ids, soft tokens, slots) of the upstream single-clip sequence <bos> <|audio> [n] <audio|> <eos> (no prompt)."""
        c = self.config
        n = soft.shape[0]
        ids = eg2_audio_frontend.audio_sequence(n, c["bos_token_id"], c.get("boa_token_id", 256000), c.get("audio_token_id", 258881),
                                                c.get("eoa_token_id", 258883), c["eos_token_id"])
        return ids, soft, list(range(2, 2 + n))

    # ---- any sequence -------------------------------------------------------------------------------------
    def embed_sequence(self, ids: list[int], soft: np.ndarray | None = None, slots: list[int] | None = None,
                       bucket: int | None = None) -> np.ndarray:
        """Runs mm_<bucket> on ids (with <bos>/<eos>), soft tokens [len(slots), 512] placed at `slots` (any mix of
        text and images that fits a bucket). Returns the raw 768-d output (already L2-normalized by the graph)."""
        S = bucket or self.bucket(len(ids))
        if len(ids) > S:
            raise ValueError(f"{len(ids)} ids do not fit bucket {S}")
        rt = self._rt
        x = np.full((1, S), self.config["pad_token_id"], dtype=np.int32)
        x[0, : len(ids)] = ids
        am = np.zeros((1, S), dtype=np.float16)
        am[0, : len(ids)] = 1
        mm = np.zeros((1, S, self.hidden), dtype=np.float16)
        mmm = np.zeros((1, S), dtype=np.float16)
        if slots:
            if soft is None or soft.shape != (len(slots), self.hidden):
                raise ValueError("soft tokens do not match slots")
            mm[0, slots] = soft.astype(np.float16)
            mmm[0, slots] = 1
        out = asyncio.run(self.functions[S](inputs={"input_ids": rt.NDArray(x), "attention_mask": rt.NDArray(am),
                                                     "mm_embeds": rt.NDArray(mm), "mm_mask": rt.NDArray(mmm)}))
        return np.array(out["embedding"].numpy(), dtype=np.float32).reshape(-1)

    def _finish(self, v: np.ndarray, dim: int) -> np.ndarray:
        if dim not in self.config["matryoshka_dims"]:
            raise ValueError(f"dim must be one of {self.config['matryoshka_dims']}")
        v = v[:dim]
        return v / max(float(np.linalg.norm(v)), 1e-12)

    def prewarm(self) -> float:
        """One call per bucket (and one vision call, if loaded) so the first real call pays no first-call cost."""
        t = time.perf_counter()
        for S in self.buckets:
            self.embed_ids([self.config["bos_token_id"], self.config["eos_token_id"]], S)
        if self.vision_function is not None:
            self.soft_tokens(eg2_image.patchify_cell_major(np.full((48, 48, 3), 128, np.uint8)))
        for tb in self.audio_buckets:
            self.audio_soft_tokens(eg2_audio_frontend.prepare_audio_inputs(np.zeros(min(tb, 3072) * 160, np.float32), [tb]))
        return time.perf_counter() - t

    def close(self) -> None:
        self.functions.clear()
        self.audio_functions.clear()
        self.model = self.vision_model = self.vision_function = self.audio_model = None


def _specialization_options(rt, compute: str):
    if compute == "cpu":
        return rt.SpecializationOptions.cpu_only()
    if compute == "gpu":
        return rt.SpecializationOptions.from_preferred_compute_unit_kind(rt.ComputeUnitKind.gpu())
    if compute == "ane":
        return rt.SpecializationOptions.from_preferred_compute_unit_kind(rt.ComputeUnitKind.neural_engine())
    if compute == "default":
        return rt.SpecializationOptions.default()
    raise ValueError("compute must be ane | gpu | cpu | default")


# ---- temp-file hygiene ----------------------------------------------------------------------------------
def mpsgraph_temp_root() -> Path:
    import tempfile

    return Path(os.environ.get("TMPDIR") or tempfile.gettempdir()) / "com.apple.MetalPerformanceShadersGraph"


def remove_own_mpsgraph_temp_files(pid: int | None = None, since: float | None = None) -> tuple[int, int]:
    """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), and the
    Python runtime (coreai.runtime, macOS 27) does NOT delete them when the model is released or the interpreter
    exits. This removes the folders of the CURRENT process only (its pid, created after it started). Call it after
    the model object has been released, or at exit. Returns (folders, bytes)."""
    import shutil

    pid = pid or os.getpid()
    since = _PROCESS_START - 2 if since is None else since
    root = mpsgraph_temp_root()
    folders = nbytes = 0
    if not root.is_dir():
        return 0, 0
    for d in root.iterdir():
        if not d.name.startswith(f"mpsgraph-{pid}-") or not d.is_dir():
            continue
        try:
            if d.stat().st_birthtime < since:
                continue  # left by an older process that happened to have the same pid
            nbytes += sum(f.stat().st_size for f in d.rglob("*") if f.is_file())
            shutil.rmtree(d)
            folders += 1
        except OSError:
            pass
    return folders, nbytes


# ---- process accounting helpers (macOS) ------------------------------------------------------------------
def _rusage_v6():
    class RU6(ctypes.Structure):
        _fields_ = [("uuid", ctypes.c_uint8 * 16)] + [(f"f{i}", ctypes.c_uint64) for i in range(47)] + [("reserved", ctypes.c_uint64 * 9)]

    libc = ctypes.CDLL("/usr/lib/libSystem.B.dylib")
    r = RU6()
    return r if libc.proc_pid_rusage(os.getpid(), 6, ctypes.byref(r)) == 0 else None


def footprints_mb() -> tuple[float, float]:
    """(lifetime max physical footprint, Neural Engine footprint) in MB; the latter is > 0 only if the ANE ran."""
    r = _rusage_v6()
    if r is None:
        return -1.0, -1.0
    return r.f28 / 2**20, r.f44 / 2**20  # ri_lifetime_max_phys_footprint, ri_neural_footprint


def cosine(a, b) -> float:
    a = np.asarray(a, np.float64)
    b = np.asarray(b, np.float64)
    return float(a @ b / (np.linalg.norm(a) * np.linalg.norm(b)))


# ---- smoke test (used by the CLI and smoke_test.py) -------------------------------------------------------
def run_smoke(bundle, ref_paths: list, compute: str = "ane", image_compute: str | None = None, audio_compute: str | None = None) -> bool:
    """Runs every reference file: text files (v1.0 format, "cases"), image files ("images" + "texts") and audio
    files ("audio" + "texts"). Returns True when every cosine is above its threshold, all values are finite and
    every query ranks its own image / clip first."""
    refs = [(Path(p), json.loads(Path(p).read_text(encoding="utf-8"))) for p in ref_paths]
    need_vision = any(r.get("images") for _, r in refs)
    need_audio = any(r.get("audio") for _, r in refs)
    m = EmbeddingGemma2CoreAI(bundle, compute, load_vision=need_vision, image_compute=image_compute, load_audio=need_audio,
                              audio_compute=audio_compute)
    print(f"loaded in {m.load_s:.1f} s (tokenizer {m.tokenizer_load_s:.2f} s), compute={compute}, "
          f"image_compute={m.image_compute if need_vision else '-'}, audio_compute={m.audio_compute if need_audio else '-'}, "
          f"buckets={m.buckets}, vision {'loaded' if m.has_vision else 'not loaded'}, audio {'loaded' if m.has_audio else 'not loaded'}")
    ok = True
    for path, ref in refs:
        print(f"-- {path.name}")
        thr = ref["threshold_cosine"]
        for c in ref.get("cases", []):
            v = m.embed(c["text"], c["kind"])
            cos = cosine(v, c["embedding"])
            passed = cos >= thr and bool(np.all(np.isfinite(v)))
            ok = ok and passed
            print(f"{'PASS' if passed else 'FAIL'} cosine {cos:.6f} (threshold {thr})  {c['kind']}: {c['text'][:60]}")
        if len(ref.get("cases", [])) > 1:
            c = ref["cases"][1]  # Matryoshka prefixes vs the truncated + re-normalized reference
            for dim in (512, 256, 128):
                cos = cosine(m.embed(c["text"], c["kind"], dim=dim), c["embedding"][:dim])
                ok = ok and cos >= thr
                print(f"{'PASS' if cos >= thr else 'FAIL'} cosine {cos:.6f} at dim {dim}")
        images = ref.get("images", [])
        thr_img = ref.get("threshold_cosine_image", thr)
        img_vecs = []
        for c in images:
            t0 = time.perf_counter()
            v = m.embed_image(path.parent / c["file"])
            cos = cosine(v, c["embedding"])
            passed = cos >= thr_img and bool(np.all(np.isfinite(v)))
            ok = ok and passed
            img_vecs.append(v)
            print(f"{'PASS' if passed else 'FAIL'} cosine {cos:.6f} (threshold {thr_img})  image {c['file']}  {int((time.perf_counter() - t0) * 1000)} ms")
        audio = ref.get("audio", [])
        aud_vecs = []
        for c in audio:
            t0 = time.perf_counter()
            v = m.embed_audio(path.parent / c["file"])
            cos = cosine(v, c["embedding"])
            thr_a = c.get("threshold_cosine", ref.get("threshold_cosine_audio", thr))
            passed = cos >= thr_a and bool(np.all(np.isfinite(v)))
            ok = ok and passed
            aud_vecs.append(v)
            print(f"{'PASS' if passed else 'FAIL'} cosine {cos:.6f} (threshold {thr_a})  audio {c['file']}  {int((time.perf_counter() - t0) * 1000)} ms")
        queries = []
        for c in ref.get("texts", []):
            v = m.embed(c["text"], c["kind"])
            cos = cosine(v, c["embedding"])
            passed = cos >= thr and bool(np.all(np.isfinite(v)))
            ok = ok and passed
            if c["kind"] == "search_query":
                queries.append((c["text"], v))
            print(f"{'PASS' if passed else 'FAIL'} cosine {cos:.6f} (threshold {thr})  {c['kind']}: {c['text'][:60]}")
        if images:
            hits = 0
            for q, qv in queries:
                scores = [cosine(qv, iv) for iv in img_vecs]
                best = int(np.argmax(scores))
                want = next((i for i, c in enumerate(images) if c["query"] == q), -1)
                hits += best == want
                print(f"{'PASS' if best == want else 'FAIL'} query \"{q}\" -> {images[best]['file']} ({scores[best]:.3f})")
            ok = ok and hits == len(queries)
            print(f"text -> image: {hits}/{len(queries)} queries rank their own image first")
        if audio:
            with_q = [i for i, c in enumerate(audio) if c.get("query")]
            hits = total = 0
            for q, qv in queries:
                want = next((i for i, c in enumerate(audio) if c.get("query") == q), -1)
                if want < 0:
                    continue
                scores = [cosine(qv, aud_vecs[i]) for i in with_q]
                best = with_q[int(np.argmax(scores))]
                if audio[want].get("fp32_rank1") is False:   # checked only where fp32 itself ranks the clip first
                    print(f"SKIP query \"{q}\" -> {audio[best]['file']} ({max(scores):.3f}) (fp32 does not rank this clip first either)")
                    continue
                total += 1
                hits += best == want
                print(f"{'PASS' if best == want else 'FAIL'} query \"{q}\" -> {audio[best]['file']} ({max(scores):.3f})")
            ok = ok and hits == total
            print(f"text -> audio: {hits}/{total} queries rank their own clip first (as fp32 does)")
    fp, nf = footprints_mb()
    print(f"peak footprint {fp:.0f} MB, neural footprint {nf:.0f} MB ({'Neural Engine in use' if nf > 0 else 'Neural Engine not used'})")
    print("SMOKE PASS" if ok else "SMOKE FAIL")
    m.close()
    del m
    return ok


# ---- CLI -------------------------------------------------------------------------------------------------
def cmd_embed(a) -> int:
    m = EmbeddingGemma2CoreAI(a.bundle, a.compute)
    for t in a.args:
        v = m.embed(t, None if a.kind == "none" else a.kind, a.dim)
        print(json.dumps({"text": t, "kind": a.kind, "dim": int(v.shape[0]), "embedding": [round(float(x), 6) for x in v]}, ensure_ascii=False))
    m.close()
    return 0


def cmd_embed_image(a) -> int:
    m = EmbeddingGemma2CoreAI(a.bundle, a.compute, load_vision=True, image_compute=a.image_compute)
    for p in a.args:
        t0 = time.perf_counter()
        inputs = eg2_image.prepare_vision_inputs(p, a.exif)
        t1 = time.perf_counter()
        ids, soft, slots = m.image_sequence(m.soft_tokens(inputs))
        v = m._finish(m.embed_sequence(ids, soft, slots), a.dim)
        print(json.dumps({"image": p, "dim": a.dim, "n_soft": inputs.n_soft, "resized": [inputs.resized[1], inputs.resized[0]],
                          "exif_orientation_applied": a.exif, "preprocess_ms": int((t1 - t0) * 1000),
                          "model_ms": int((time.perf_counter() - t1) * 1000), "embedding": [round(float(x), 6) for x in v]}))
    m.close()
    return 0


def cmd_embed_audio(a) -> int:
    m = EmbeddingGemma2CoreAI(a.bundle, a.compute, load_audio=True, audio_compute=a.audio_compute)
    for p in a.args:
        t0 = time.perf_counter()
        d = eg2_audio_frontend.decode(p)
        wav = eg2_audio_frontend.resample_16k(eg2_audio_frontend.to_mono(d.samples), d.sample_rate)
        t1 = time.perf_counter()
        v = m.embed_audio_16k(wav, a.dim, a.long)
        frames = eg2_audio_frontend.num_valid_frames(len(wav))
        print(json.dumps({"audio": p, "dim": a.dim, "sample_rate": d.sample_rate, "channels": int(d.samples.shape[1]),
                          "decoder": d.decoder, "seconds": round(len(wav) / 16000, 3), "n_frames": frames,
                          "n_soft": eg2_audio_frontend.n_audio_tokens(frames),
                          "truncated_to_30s": bool(len(wav) > eg2_audio_frontend.MAX_SAMPLES and a.long == "truncate"),
                          "long_audio": a.long, "decode_ms": int((t1 - t0) * 1000), "model_ms": int((time.perf_counter() - t1) * 1000),
                          "embedding": [round(float(x), 6) for x in v]}))
    m.close()
    return 0


def cmd_smoke(a) -> int:
    if not a.args:
        raise SystemExit("smoke needs at least one reference file")
    return 0 if run_smoke(a.bundle, a.args, a.compute, a.image_compute, a.audio_compute) else 1


def cmd_tokcheck(a) -> int:
    from tokenizers import Tokenizer

    cases = json.loads(Path(a.args[0]).read_text(encoding="utf-8"))
    tok = Tokenizer.from_file(str(Path(a.bundle) / "tokenizer" / "tokenizer.json"))
    prefixes = {"q": "task: search result | query: ", "d": "title: none | text: ", "raw": ""}
    same = 0
    t = time.perf_counter()
    for c in cases:
        ids = [2] + tok.encode(prefixes[c["kind"]] + c["text"], add_special_tokens=False).ids + [1]
        same += ids == c["ids"]
    print(f"TOKCHECK {same}/{len(cases)} identical (tokenizers {__import__('tokenizers').__version__}) in {time.perf_counter() - t:.2f} s")
    return 0 if same == len(cases) else 1


def _has_audio_asset(bundle) -> bool:
    return "audio" in json.loads((Path(bundle) / "config.json").read_text(encoding="utf-8"))


def cmd_latency(a) -> int:
    m = EmbeddingGemma2CoreAI(a.bundle, a.compute, load_vision=not a.text_only, image_compute=a.image_compute,
                              load_audio=not a.text_only and _has_audio_asset(a.bundle), audio_compute=a.audio_compute)
    print(f"loaded in {m.load_s:.1f} s, compute={a.compute}, image_compute={m.image_compute if m.has_vision else '-'}, "
          f"audio_compute={m.audio_compute if m.has_audio else '-'}, vision {'loaded' if m.has_vision else 'not loaded'}, "
          f"audio {'loaded' if m.has_audio else 'not loaded'}; prewarm {m.prewarm():.2f} s")
    if m.has_audio:
        n_a = max(5, a.trials // 2)
        print("audio   seconds  bucket  tokens  audio_<Tb> p50 ms   mm_<S> p50 ms   end-to-end p50 ms   CPU ms/clip   front end ms")
        for seconds in (3.0, 10.0, 30.0):
            t = np.arange(int(seconds * 16000)) / 16000.0
            wav = (0.3 * np.sin(2 * np.pi * 220 * t) + 0.2 * np.sin(2 * np.pi * 1375 * t + 0.5 * np.sin(2 * np.pi * 3 * t))
                   + 0.05 * ((np.arange(len(t)) * 7919) % 1000) / 1000 - 0.025).astype(np.float32)
            tf0 = time.perf_counter()
            inputs = eg2_audio_frontend.prepare_audio_inputs(wav, m.audio_buckets)
            front_ms = (time.perf_counter() - tf0) * 1000
            for _ in range(3):
                m.embed_audio_inputs(inputs)
            ta, tt, te = [], [], []
            c0 = time.process_time()
            for _ in range(n_a):
                t0 = time.perf_counter()
                soft = m.audio_soft_tokens(inputs)
                t1 = time.perf_counter()
                ids, soft, slots = m.audio_sequence(soft)
                m.embed_sequence(ids, soft, slots)
                t2 = time.perf_counter()
                ta.append((t1 - t0) * 1000); tt.append((t2 - t1) * 1000); te.append((t2 - t0) * 1000)
            cpu = (time.process_time() - c0) / n_a * 1000
            print(f"        {seconds:7.1f}  {inputs.bucket:6d}  {inputs.n_tokens:6d}  {np.median(ta):17.1f}  {np.median(tt):14.1f}  "
                  f"{np.median(te):18.1f}  {cpu:12.1f}  {front_ms:12.1f}")
        n48 = 30 * 48000
        st = np.stack([np.sin(np.arange(n48) * 0.01), np.sin(np.arange(n48) * 0.013)], 1).astype(np.float32)
        tp0 = time.perf_counter()
        w16 = eg2_audio_frontend.resample_16k(eg2_audio_frontend.to_mono(st), 48000)
        tp1 = time.perf_counter()
        eg2_audio_frontend.prepare_audio_inputs(w16, m.audio_buckets)
        print(f"audio front end, 30 s stereo 48 kHz (numpy): mono + resample {(tp1 - tp0) * 1000:.0f} ms, log-mel + inputs {(time.perf_counter() - tp1) * 1000:.0f} ms")
    if m.has_vision:
        rng = np.random.RandomState(0)
        px = rng.randint(0, 256, (624, 1008, 3)).astype(np.uint8)
        inputs = eg2_image.patchify_cell_major(px)
        tv, tt = [], []
        n = max(5, a.trials // 2)
        c0 = time.process_time()
        for _ in range(n):
            t0 = time.perf_counter()
            soft = m.soft_tokens(inputs)
            t1 = time.perf_counter()
            ids, soft, slots = m.image_sequence(soft)
            m.embed_sequence(ids, soft, slots)
            tv.append((t1 - t0) * 1000)
            tt.append((time.perf_counter() - t1) * 1000)
        cpu = (time.process_time() - c0) / n * 1000
        print(f"image (273 soft tokens, {n} trials): vision_2520 p50 {np.median(tv):.1f} ms (min {min(tv):.1f}), mm_288 p50 {np.median(tt):.1f} ms, "
              f"end-to-end p50 {np.median(np.array(tv) + np.array(tt)):.1f} ms, CPU {cpu:.1f} ms/image (excl. decode+resize)")
        big = rng.randint(0, 256, (1600, 2560, 3)).astype(np.uint8)
        t0 = time.perf_counter()
        for _ in range(3):
            eg2_image.patchify_cell_major(eg2_image.resize_bicubic_aa_u8(big, 624, 1008))
        print(f"preprocess (numpy resize 2560x1600 -> 1008x624 + patches): {(time.perf_counter() - t0) / 3 * 1000:.0f} ms")
    print("bucket   p50 ms   min ms   p90 ms   CPU ms/call   tok/s")
    for S in m.buckets:
        ids = [m.config["bos_token_id"]] + [1000 + (i * 7919) % 200000 for i in range(1, S - 1)] + [m.config["eos_token_id"]]
        for _ in range(3):
            m.embed_ids(ids, S)
        ts = []
        c0 = time.process_time()
        for _ in range(a.trials):
            t = time.perf_counter()
            m.embed_ids(ids, S)
            ts.append((time.perf_counter() - t) * 1000)
        cpu = (time.process_time() - c0) / a.trials * 1000
        p50 = float(np.median(ts))
        print(f"{S:5d}  {p50:7.2f}  {min(ts):7.2f}  {float(np.percentile(ts, 90)):7.2f}  {cpu:11.2f}  {S / p50 * 1000:6.0f}")
    fp, nf = footprints_mb()
    print(f"peak footprint {fp:.0f} MB, neural footprint {nf:.0f} MB ({'Neural Engine in use' if nf > 0 else 'Neural Engine NOT used'})")
    m.close()
    return 0


def cmd_prewarm(a) -> int:
    t0 = time.perf_counter()
    m = EmbeddingGemma2CoreAI(a.bundle, a.compute, load_vision=not a.text_only, image_compute=a.image_compute,
                              load_audio=not a.text_only and _has_audio_asset(a.bundle), audio_compute=a.audio_compute)
    w = m.prewarm()
    print(f"load {m.load_s:.1f} s + prewarm {w:.2f} s = ready in {time.perf_counter() - t0:.1f} s "
          f"(vision {'loaded' if m.has_vision else 'not loaded'}, audio {'loaded' if m.has_audio else 'not loaded'})")
    m.close()
    return 0


def main() -> None:
    ap = argparse.ArgumentParser(description="EmbeddingGemma 2 (text + images + audio) on Apple Core AI")
    ap.add_argument("cmd", choices=["embed", "embed-image", "embed-audio", "smoke", "tokcheck", "latency", "prewarm"])
    ap.add_argument("bundle", help="the model folder (config.json, tokenizer/, *.aimodel)")
    ap.add_argument("args", nargs="*", help="texts (embed), image files (embed-image), audio files (embed-audio) or json files (smoke, tokcheck)")
    ap.add_argument("--compute", default="ane", choices=["ane", "gpu", "cpu", "default"], help="text asset (and the encoders, unless --image-compute / --audio-compute)")
    ap.add_argument("--image-compute", default=None, choices=["ane", "gpu", "cpu", "default"],
                    help="image encoder only (default: same as --compute); gpu is ~3x faster per image, ane uses the least power")
    ap.add_argument("--audio-compute", default=None, choices=["ane", "gpu", "cpu", "default"],
                    help="audio encoder only (default: same as --compute)")
    ap.add_argument("--kind", default="document", help="prompt kind from config.json (document, search_query, ...) or none")
    ap.add_argument("--exif", action="store_true", help="embed-image: apply the EXIF orientation first (upstream does not)")
    ap.add_argument("--long", default="truncate", choices=["truncate", "chunk_mean"], help="embed-audio: audio longer than 30 s")
    ap.add_argument("--dim", type=int, default=768)
    ap.add_argument("--trials", type=int, default=50)
    ap.add_argument("--text-only", action="store_true", help="latency, prewarm: do not load the vision and audio assets")
    ap.add_argument("--cleanup-temp", action="store_true", help="remove this process's MPSGraph temp folders at exit")
    a = ap.parse_args()
    try:
        code = {"embed": cmd_embed, "embed-image": cmd_embed_image, "embed-audio": cmd_embed_audio, "smoke": cmd_smoke,
                "tokcheck": cmd_tokcheck, "latency": cmd_latency, "prewarm": cmd_prewarm}[a.cmd](a)
    except ValueError as e:  # bad input or an unsupported route: a message, not a traceback
        print(f"error: {e}", file=sys.stderr)
        code = 2
    finally:
        if a.cleanup_temp:  # also after an error, so that a failed run does not leave its temp folders behind
            import gc

            gc.collect()
            n, b = remove_own_mpsgraph_temp_files()
            print(f"cleanup-temp: removed {n} MPSGraph temp folder(s), {b / 2**20:.1f} MB")
    sys.exit(code)


if __name__ == "__main__":
    main()