feat: add multimodal search and granular performance diagnostics
Core CI / core (ubuntu-latest) (push) Failing after 3m31s
Core CI / core (macos-latest) (push) Canceled after 0s
Core CI / core (windows-latest) (push) Canceled after 0s
Core CI / desktop (ubuntu-latest) (push) Successful in 5m39s
Core CI / desktop (macos-latest) (push) Canceled after 0s
Core CI / desktop (windows-latest) (push) Canceled after 0s

This commit is contained in:
karl committed 2026-10-12 19:16:23 +02:00
1 parent d3bb8c7618
commit b8400c4fbf
110 files changed
+79395 -250

No files matched your search

+3
View File
@@ -11,6 +11,9 @@ minilm-proof = ["dep:ort", "dep:tokenizers"]
[dependencies]
anyhow.workspace = true
base64 = "0.22"
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "bmp"] }
jpeg-decoder = { version = "0.3.2", default-features = false }
ort = { version = "=2.0.0-rc.13", optional = true, default-features = false, features = ["std", "load-dynamic", "api-27"] }
sha2 = "0.10"
tokenizers = { version = "=0.22.2", optional = true, default-features = false, features = ["fancy-regex"] }
+44
View File
@@ -0,0 +1,44 @@
# Google worker image decoding
The production adapter verifies model/projector SHA256 and performs genuine
Google MTMD image/audio inference. Images are decoded with established Rust
libraries, not filename captions. `WorkerInfo.media_supported` comes from the
native handshake; absent fields on older text workers mean false. Media APIs
return an explicit runtime repair error for such workers while text remains
compatible.
JPEG sources up to 64 Mi pixels (including 48 MP camera photos), 16,384 pixels
per side, and 64 MiB compressed size are supported. For JPEG above 16 Mi pixels,
`image-rs/jpeg-decoder` 0.3.2 applies scaled IDCT before final model resizing.
Its default features are disabled, so this path adds no Rayon worker pool.
The source dimensions are checked before pixels are decoded. The secondary
header must match the first to detect file changes. RGB24 and grayscale camera
JPEGs are supported on this scaled path; unusual large CMYK/16-bit JPEGs fail
explicitly rather than silently taking an unbounded full-resolution fallback.
The published decoder offers 1/8, 1/4, 1/2, and full-scale decoding. It is in
maintenance mode; the current image 0.25 zune JPEG adapter exposes no scaled
IDCT interface. See the [decoder API](https://docs.rs/jpeg-decoder/0.3.2/jpeg_decoder/struct.Decoder.html)
and [upstream maintenance note](https://github.com/image-rs/jpeg-decoder).
Small JPEGs retain the existing image/zune path and pinned parity behavior.
PNG, WebP, and BMP remain capped at 16 Mi pixels with explicit diagnostics.
Both paths enforce a 256 MiB advertised decoding-buffer ceiling and apply
EXIF orientation. That ceiling is a buffer/output bound, not an absolute
process RSS guarantee: compressed data, codec working state, and resizing
have additional allocations. Extreme/progressive files need broader resource
profiling. The media fingerprint includes `largejpeg-scaled-v2`; generations
must additionally include visual token budget. Text fingerprints are unchanged.
An executed Windows CPU release test encodes and decodes an actual 8000x6000
JPEG with EXIF rotation, verifies orientation and region colors, and resizes it
to a 280-token grid. Decode plus resize took 176–220 ms and produced 672x912
RGB (1,838,592 bytes). A sampled whole-test peak was 146.86 MiB, including
144 MB of source fixture construction; it is not a decoder-only measurement
or a semantic quality benchmark. An 81 MP JPEG header is rejected before
pixel decode in the default tests.
```powershell
cargo test --release -p searchi-semantic large_phone_jpeg -- --ignored --nocapture
cargo test -p searchi-semantic --lib
```
@@ -41,11 +41,12 @@ set(LLAMA_BUILD_TOOLS OFF CACHE BOOL "No runtime tools" FORCE)
set(LLAMA_BUILD_SERVER OFF CACHE BOOL "No HTTP server" FORCE)
set(LLAMA_BUILD_EXAMPLES OFF CACHE BOOL "No examples" FORCE)
set(LLAMA_BUILD_TESTS OFF CACHE BOOL "No upstream tests in worker build" FORCE)
set(LLAMA_BUILD_MTMD OFF CACHE BOOL "Text worker; media encoder follows separately" FORCE)
set(LLAMA_BUILD_MTMD ON CACHE BOOL "Google image/audio projector" FORCE)
set(MTMD_VIDEO OFF CACHE BOOL "Frames decoded by bounded media adapter" FORCE)
set(LLAMA_OPENSSL OFF CACHE BOOL "No network runtime dependencies" FORCE)
add_subdirectory("${LLAMA_CPP_DIR}" llama-build)
add_executable(searchi-gemma-worker worker.cpp)
target_include_directories(searchi-gemma-worker PRIVATE "${LLAMA_CPP_DIR}/vendor")
target_link_libraries(searchi-gemma-worker PRIVATE llama)
target_link_libraries(searchi-gemma-worker PRIVATE llama mtmd)
target_compile_definitions(searchi-gemma-worker PRIVATE SEARCHI_LLAMA_TAG="b11461-no-logits-v1")
install(TARGETS searchi-gemma-worker RUNTIME DESTINATION bin)
+108 -6
View File
@@ -1,11 +1,14 @@
// Local NDJSON helper around the pinned llama.cpp encoder. stdout is exclusively
// protocol output; llama diagnostics go to stderr. No model download or HTTP.
#include "llama.h"
#include "mtmd.h"
#include "nlohmann/json.hpp"
#include <algorithm>
#include <cmath>
#include <chrono>
#include <cstdlib>
#include <cstdio>
#include <cstring>
#include <iostream>
#include <memory>
#include <stdexcept>
@@ -13,7 +16,7 @@
#include <vector>
using json = nlohmann::json;
constexpr size_t max_request_bytes = 2 * 1024 * 1024;
constexpr size_t max_request_bytes = 16 * 1024 * 1024;
constexpr size_t max_passage_bytes = 1024 * 1024;
constexpr size_t max_text_bytes = 65536;
constexpr size_t max_windows = 256;
@@ -34,12 +37,14 @@ class Encoder {
llama_context * context = nullptr;
llama_batch_ext * batch = nullptr;
const llama_vocab * vocab = nullptr;
mtmd_context * media = nullptr;
std::string model_path;
void release() {
if (media) mtmd_free(media);
if (batch) llama_batch_ext_free(batch);
if (context) llama_free(context);
if (model) llama_model_free(model);
batch = nullptr; context = nullptr; model = nullptr; vocab = nullptr;
batch = nullptr; context = nullptr; model = nullptr; vocab = nullptr; media = nullptr;
}
bool open(bool gpu) {
auto mp = llama_model_default_params();
@@ -68,6 +73,9 @@ public:
llama_log_set([](ggml_log_level level, const char * text, void *) {
if (level >= GGML_LOG_LEVEL_WARN) std::fputs(text,stderr);
},nullptr);
mtmd_log_set([](ggml_log_level level, const char * text, void *) {
if (level >= GGML_LOG_LEVEL_WARN) std::fputs(text,stderr);
},nullptr);
llama_backend_init();
bool use_gpu = want_gpu && llama_supports_gpu_offload();
require(open(use_gpu) || (use_gpu && open(false)),"Cannot initialize local EmbeddingGemma 2 model/context");
@@ -77,6 +85,52 @@ public:
llama_backend_free();
}
bool healthy() const {return context != nullptr && batch != nullptr;}
void ensure_media(const std::string & projector) {
if (media) return;
auto params = mtmd_context_params_default();
params.use_gpu = device == "gpu"; params.n_threads = threads;
params.print_timings = false; params.warmup = false;
params.image_min_tokens = 1; params.image_max_tokens = 1120;
media = mtmd_init_from_file(projector.c_str(),model,params);
require_inference(media != nullptr,"Cannot initialize Google media projector");
}
std::vector<float> embed_bitmap(mtmd_bitmap * bitmap) {
std::unique_ptr<mtmd_input_chunks,decltype(&mtmd_input_chunks_free)> chunks(mtmd_input_chunks_init(),mtmd_input_chunks_free);
require_inference(chunks != nullptr,"Cannot allocate media chunks");
const char * marker = mtmd_default_marker();
mtmd_input_text text{marker,std::strlen(marker),true,true};
const mtmd_bitmap * bitmaps[] = {bitmap};
require_inference(mtmd_tokenize(media,chunks.get(),&text,bitmaps,1) == 0,"Media tokenization failed");
llama_batch_ext_clear(batch); size_t position = 0;
auto place = [&](int index) {
require_inference(index >= 0 && position < context_tokens,"Media batch exceeds context");
llama_pos pos[4] = {llama_pos(position++),0,0,0};
require_inference(llama_batch_ext_set_pos(batch,index,pos) && llama_batch_ext_set_output_embd(batch,index,true),"Cannot place media row");
};
for (size_t i=0;i<mtmd_input_chunks_size(chunks.get());++i) {
const auto * chunk = mtmd_input_chunks_get(chunks.get(),i);
if (mtmd_input_chunk_get_type(chunk) == MTMD_INPUT_CHUNK_TYPE_TEXT) {
size_t count=0; const auto * tokens=mtmd_input_chunk_get_tokens_text(chunk,&count);
for(size_t j=0;j<count;++j) place(llama_batch_ext_add_token(batch,0,tokens[j]));
} else {
require_inference(mtmd_encode_chunk(media,chunk)==0,"Google media encoder failed");
const float * rows=mtmd_get_output_embd(media);
require_inference(rows != nullptr,"No projected media rows");
size_t width=llama_model_n_embd_inp(model);
for(size_t j=0;j<mtmd_input_chunk_get_n_tokens(chunk);++j) {
llama_embd embd{rows+j*width,1,width};
place(llama_batch_ext_add_embd(batch,0,embd));
}
}
}
require_inference(llama_process(context,LLAMA_PROCESS_TYPE_ENCODE,batch)==0,"Media text encoder failed");
const float * pooled=llama_get_embeddings_seq(context,0);
require_inference(pooled != nullptr,"No pooled media vector");
double norm=0; for(int i=0;i<dimensions;++i){require_inference(std::isfinite(pooled[i]),"Nonfinite media vector");norm+=double(pooled[i])*pooled[i];}
require_inference(norm>0 && std::isfinite(norm),"Invalid media vector norm");
std::vector<float> vector(dimensions);for(int i=0;i<dimensions;++i)vector[i]=float(pooled[i]/std::sqrt(norm));
return vector;
}
void retry_cpu() {
require(device == "gpu","Only a GPU inference failure can request a CPU retry");
release();
@@ -103,10 +157,21 @@ public:
require(title.size() <= 512, "Document title exceeds 512 UTF-8 bytes");
return "title: " + (title.empty() ? "none" : title) + " | text: ";
}
std::vector<float> embed(const std::string & text, int cap) {
std::vector<float> embed(const std::string & text, int cap, json * timings = nullptr) {
using clock = std::chrono::steady_clock;
auto started = timings ? clock::now() : clock::time_point{};
auto mark = [&](const char * name) {
if (timings) {
auto now = clock::now();
(*timings)[name] = std::chrono::duration_cast<std::chrono::microseconds>(now-started).count();
started = now;
}
};
require(!text.empty() && text.size() <= max_text_bytes, "Embedding text must be nonempty and at most 64 KiB");
auto tokens = tokenize(text,true);
require(!tokens.empty() && tokens.size() <= size_t(cap), "Prompt exceeds model token budget; split document windows first");
if (timings) (*timings)["token_count"] = tokens.size();
mark("tokenization_us");
llama_batch_ext_clear(batch);
for (size_t i = 0; i < tokens.size(); ++i) {
int index = llama_batch_ext_add_token(batch,0,tokens[i]);
@@ -115,7 +180,9 @@ public:
require(llama_batch_ext_set_pos(batch,index,position), "Cannot set encoder position");
require(llama_batch_ext_set_output_embd(batch,index,true), "Cannot select embedding output");
}
mark("batch_prepare_us");
require_inference(llama_process(context,LLAMA_PROCESS_TYPE_ENCODE,batch) == 0, "llama_process encoder failed");
mark("inference_us");
const float * pooled = llama_get_embeddings_seq(context,0);
require_inference(pooled != nullptr, "Encoder returned no pooled vector");
double norm = 0;
@@ -123,6 +190,7 @@ public:
require_inference(norm > 0 && std::isfinite(norm), "Zero or nonfinite embedding norm");
std::vector<float> vector(dimensions);
for (int i = 0; i < dimensions; ++i) vector[i] = float(pooled[i]/std::sqrt(norm));
mark("pool_normalize_us");
return vector;
}
json windows(const std::string & text, const std::string & title) const {
@@ -157,8 +225,42 @@ public:
}
json handle(const json & request) {
auto op = request.at("op").get<std::string>();
if(op=="image" || op=="audio") {
auto encoded=request.at("data").get<std::string>();
require(encoded.size()<=12000000 && encoded.size()%4==0,"Invalid media payload length");
static const std::string alphabet="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
std::vector<unsigned char> bytes; bytes.reserve(encoded.size()/4*3);
unsigned accumulator=0;int bits=0;bool padding=false;
for(char c:encoded){if(c=='='){padding=true;continue;} require(!padding,"Invalid base64 padding");auto n=alphabet.find(c);require(n!=std::string::npos,"Invalid base64");accumulator=(accumulator<<6)|unsigned(n);bits+=6;if(bits>=8){bits-=8;bytes.push_back(static_cast<unsigned char>(accumulator>>bits));}}
auto projector=request.at("projector").get<std::string>();
require(!projector.empty() && projector.size()<32768,"Invalid projector path");
ensure_media(projector);
mtmd_bitmap * raw=nullptr;
if(op=="image") {
int w=request.at("width"),h=request.at("height");
require(w>=48 && h>=48 && w%48==0 && h%48==0 && uint64_t(w)*h<=1120*48*48,"Invalid bounded image dimensions");
require(bytes.size()==size_t(w)*h*3,"RGB image length mismatch");
raw=mtmd_bitmap_init(w,h,bytes.data());
} else {
require(bytes.size()%4==0 && !bytes.empty() && bytes.size()/4<=30*16000,"Audio must be at most30seconds mono16kHz PCM");
std::vector<float> pcm(bytes.size()/4);
std::memcpy(pcm.data(),bytes.data(),bytes.size());
for(float f:pcm)require(std::isfinite(f) && std::abs(f)<=1,"Audio PCM outside normalized range");
raw=mtmd_bitmap_init_from_audio(pcm.size(),pcm.data());
}
std::unique_ptr<mtmd_bitmap,decltype(&mtmd_bitmap_free)> bitmap(raw,mtmd_bitmap_free);
require_inference(bitmap != nullptr,"Cannot allocate media bitmap");
return {{"vectors",json::array({embed_bitmap(bitmap.get())})}};
}
if (op == "windows") return {{"windows",windows(request.at("text").get<std::string>(),request.value("title",std::string()))}};
if (op == "query") return {{"vectors",json::array({embed("task: search result | query: "+request.at("text").get<std::string>(),512)})}};
if (op == "query") {
bool debug = request.value("debug_performance",false);
json timings;
auto vector = embed("task: search result | query: "+request.at("text").get<std::string>(),512,debug ? &timings : nullptr);
json response = {{"vectors",json::array({vector})}};
if (debug) response["performance"] = timings;
return response;
}
if (op == "documents") {
const auto & texts = request.at("texts");
require(texts.is_array() && !texts.empty() && texts.size() <= 8,"Batch size must be between 1 and 8");
@@ -187,11 +289,11 @@ int main(int argc, char ** argv) {
bool force_cpu = std::getenv("SEARCHI_FORCE_CPU") && std::string(std::getenv("SEARCHI_FORCE_CPU")) == "1";
Encoder encoder(path,threads,device == "auto" && !force_cpu);
std::cout << json({{"ready",true},{"model","google/embeddinggemma-2"},{"dimensions",dimensions},
{"device",encoder.device},{"threads",threads},{"runtime",SEARCHI_LLAMA_TAG}}).dump() << '\n' << std::flush;
{"device",encoder.device},{"threads",threads},{"runtime",SEARCHI_LLAMA_TAG},{"media_supported",true}}).dump() << '\n' << std::flush;
for (;;) {
std::string line; char c;
while (std::cin.get(c) && c != '\n') {
require(line.size() < max_request_bytes,"Request exceeds 2 MiB"); line.push_back(c);
require(line.size() < max_request_bytes,"Request exceeds 16 MiB"); line.push_back(c);
}
if (!std::cin && line.empty()) break;
json response;
+390 -6
View File
@@ -1,24 +1,35 @@
use crate::TextWindow;
use anyhow::{ensure, Context, Result};
use base64::Engine;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use std::{
collections::BTreeMap,
fs::File,
io::{BufRead, BufReader, Read, Write},
path::{Path, PathBuf},
process::{Child, ChildStdin, Command, Stdio},
sync::mpsc::{self, Receiver},
thread,
time::Duration,
time::{Duration, Instant},
};
pub const GEMMA_DIMENSIONS: usize = 768;
pub const GEMMA_FINGERPRINT: &str = "google/embeddinggemma-2:bfcd298762cc34d0357ece5ebdd31791a3a374d8:q8_0:llama-b11461-no-logits-v1:mean:l2:query512:doc2048:windows-overlap128:prompts-v1";
pub const GEMMA_MEDIA_FINGERPRINT: &str = "google/embeddinggemma-2:bfcd298762cc34d0357ece5ebdd31791a3a374d8:q8_0:llama-b11461-no-logits-v1:mtmd:mean:l2:rgb-catmullrom48-exif-largejpeg-scaled-v2:pcm16k30s-v1";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImageEmbedding {
pub vector: Vec<f32>,
pub encoded_width: u32,
pub encoded_height: u32,
pub token_budget: u32,
}
const MODEL_FILE: &str = "embeddinggemma-2-Q8_0.gguf";
const MODEL_BYTES: u64 = 309855456;
const MODEL_SHA256: &str = "2188ac1deca4b77dffefd603c2776a9d76d9d74ec01841392982ebb840b09135";
const MAX_REQUEST: usize = 2 * 1024 * 1024;
const MAX_REQUEST: usize = 16 * 1024 * 1024;
const MAX_RESPONSE: usize = 4 * 1024 * 1024;
#[derive(Debug, Clone, Copy)]
@@ -35,6 +46,8 @@ pub struct WorkerInfo {
pub device: String,
pub threads: usize,
pub runtime: String,
#[serde(default)]
pub media_supported: bool,
}
/// Persistent isolated native worker. No HTTP or remote model fallback.
@@ -47,6 +60,8 @@ pub struct GemmaEmbedder {
force_cpu: bool,
model_dir: PathBuf,
worker_path: PathBuf,
projector_verified: bool,
query_profile: Option<BTreeMap<String, u64>>,
}
impl Drop for GemmaEmbedder {
@@ -161,6 +176,8 @@ impl GemmaEmbedder {
force_cpu: cpu,
model_dir,
worker_path: worker,
projector_verified: false,
query_profile: None,
})
}
@@ -169,10 +186,12 @@ impl GemmaEmbedder {
self.healthy,
"Gemma worker session failed; reopen it explicitly"
);
let encode_started = self.query_profile.as_ref().map(|_| Instant::now());
let encoded = serde_json::to_vec(&request)?;
self.record_query_duration("request_encode_us", encode_started);
ensure!(
encoded.len() < MAX_REQUEST,
"Gemma request exceeds 2 MiB protocol budget"
"Gemma request exceeds 16 MiB protocol budget"
);
let response = match self.exchange(&encoded) {
Ok(response) => response,
@@ -180,6 +199,7 @@ impl GemmaEmbedder {
// GPU runtimes can abort rather than return an encoder error.
// Restart once as CPU and retry this same bounded request.
let _ = self.child.wait();
let restart_started = self.query_profile.as_ref().map(|_| Instant::now());
let replacement = Self::load_with_device(
&self.model_dir,
&self.worker_path,
@@ -189,7 +209,13 @@ impl GemmaEmbedder {
.with_context(|| {
format!("CPU worker restart failed after GPU transport error: {error}")
})?;
let diagnostics = self.query_profile.take();
*self = replacement;
self.query_profile = diagnostics;
self.record_query_duration("cpu_restart_us", restart_started);
if let Some(profile) = &mut self.query_profile {
profile.insert("cpu_restart_count".into(), 1);
}
self.exchange(&encoded)?
}
Err(error) => return Err(error),
@@ -201,6 +227,11 @@ impl GemmaEmbedder {
);
self.info.device = device.into();
}
if response["fallback"].as_str() == Some("gpu_inference_failure") {
if let Some(profile) = &mut self.query_profile {
profile.insert("worker_cpu_retry_count".into(), 1);
}
}
if response["fatal"] == true {
self.healthy = false;
self.info.ready = false;
@@ -215,18 +246,21 @@ impl GemmaEmbedder {
}
fn exchange(&mut self, encoded: &[u8]) -> Result<Value> {
let write_started = self.query_profile.as_ref().map(|_| Instant::now());
let sent = (|| {
self.stdin.write_all(encoded)?;
self.stdin.write_all(b"\n")?;
self.stdin.flush()
})()
.context("Cannot write local Gemma worker request");
self.record_query_duration("request_write_us", write_started);
if let Err(error) = sent {
self.healthy = false;
self.info.ready = false;
let _ = self.child.kill();
return Err(error);
}
let wait_started = self.query_profile.as_ref().map(|_| Instant::now());
let response = match self.responses.recv_timeout(Duration::from_secs(180)) {
Ok(Ok(value)) => value,
other => {
@@ -236,6 +270,7 @@ impl GemmaEmbedder {
anyhow::bail!("Gemma worker request failed: {other:?}");
}
};
self.record_query_duration("worker_wait_and_response_decode_us", wait_started);
Ok(response)
}
@@ -281,15 +316,265 @@ impl GemmaEmbedder {
}
pub fn embed_query(&mut self, query: &str) -> Result<Vec<f32>> {
self.query_profile = None;
self.embed_query_inner(query, false)
}
/// Opt-in diagnostics contain only durations and counts, never query text.
pub fn embed_query_debug(&mut self, query: &str) -> Result<Vec<f32>> {
self.query_profile = Some(BTreeMap::new());
self.embed_query_inner(query, true)
}
pub fn query_diagnostics(&self) -> BTreeMap<String, u64> {
self.query_profile.clone().unwrap_or_default()
}
fn record_query_duration(&mut self, name: &str, started: Option<Instant>) {
if let (Some(profile), Some(started)) = (&mut self.query_profile, started) {
let micros = started.elapsed().as_micros().min(u64::MAX as u128) as u64;
*profile.entry(name.into()).or_default() += micros;
}
}
fn embed_query_inner(&mut self, query: &str, debug: bool) -> Result<Vec<f32>> {
ensure!(
!query.trim().is_empty() && query.len() <= 4096,
"Query must be nonempty and at most 4096 UTF-8 bytes"
);
let response = self.request(json!({"op":"query","text":query}))?;
let response =
self.request(json!({"op":"query","text":query,"debug_performance":debug}))?;
if debug {
if let Some(profile) = &mut self.query_profile {
for name in [
"tokenization_us",
"batch_prepare_us",
"inference_us",
"pool_normalize_us",
"token_count",
] {
if let Some(value) = response["performance"][name].as_u64() {
profile.insert(format!("worker_{name}"), value);
}
}
}
}
let validation_started = self.query_profile.as_ref().map(|_| Instant::now());
let result = extract_vectors(response, 1)?.remove(0);
self.record_query_duration("response_validate_us", validation_started);
Ok(result)
}
fn projector(&mut self) -> Result<PathBuf> {
ensure!(
self.info.media_supported,
"Installed embedding worker does not support media; repair/rebuild the native runtime"
);
let path = self.model_dir.join("mmproj-embeddinggemma-2-Q8_0.gguf");
if !self.projector_verified {
verify_artifact(
&path,
554821024,
"c4a8a52691ecef40618438928bdf9e68379b854e24166f292592353db0aab64f",
)?;
self.projector_verified = true;
}
Ok(path)
}
/// Genuine Google pixel embeddings, serial bounded image operations.
pub fn embed_images(&mut self, paths: &[&Path]) -> Result<Vec<Vec<f32>>> {
self.embed_images_with_budget(paths, 280)
}
/// 70 tokens for video frames, 140 for photos, 280 or 1120 for screenshots.
pub fn embed_images_with_budget(
&mut self,
paths: &[&Path],
budget: u32,
) -> Result<Vec<Vec<f32>>> {
Ok(self
.embed_images_with_metadata(paths, budget)?
.into_iter()
.map(|image| image.vector)
.collect())
}
pub fn embed_images_with_metadata(
&mut self,
paths: &[&Path],
budget: u32,
) -> Result<Vec<ImageEmbedding>> {
ensure!(
!paths.is_empty() && paths.len() <= 8,
"Image batch must contain 1–8 paths"
);
let projector = self.projector()?;
let mut vectors = Vec::with_capacity(paths.len());
for path in paths {
let pixels = decode_image(path, budget)?;
let response=self.request(json!({"op":"image","projector":projector,"width":pixels.width(),"height":pixels.height(),"data":base64::engine::general_purpose::STANDARD.encode(pixels.as_raw())}))?;
vectors.push(ImageEmbedding {
vector: extract_vectors(response, 1)?.remove(0),
encoded_width: pixels.width(),
encoded_height: pixels.height(),
token_budget: budget,
});
}
Ok(vectors)
}
/// One mono 16 kHz normalized PCM window, at most 30 seconds. Callers
/// segment entire recordings and preserve real timestamps; no truncation.
pub fn embed_audio_pcm(&mut self, samples: &[f32]) -> Result<Vec<f32>> {
ensure!(
!samples.is_empty() && samples.len() <= 480000,
"Audio window must contain 1–480000 mono 16 kHz samples"
);
ensure!(
samples.iter().all(|v| v.is_finite() && v.abs() <= 1.0),
"Invalid normalized audio PCM"
);
ensure!(
cfg!(target_endian = "little"),
"Native PCM worker requires little-endian host"
);
let projector = self.projector()?;
let bytes: Vec<u8> = samples.iter().flat_map(|v| v.to_le_bytes()).collect();
let response=self.request(json!({"op":"audio","projector":projector,"data":base64::engine::general_purpose::STANDARD.encode(bytes)}))?;
Ok(extract_vectors(response, 1)?.remove(0))
}
}
fn decode_image(path: &Path, budget: u32) -> Result<image::RgbImage> {
ensure!(
(70..=1120).contains(&budget),
"Image token budget must be 70–1120"
);
ensure!(
std::fs::metadata(path)?.len() <= 64 * 1024 * 1024,
"Image file exceeds 64 MiB"
);
let mut reader = image::ImageReader::open(path)?.with_guessed_format()?;
let is_jpeg = reader.format() == Some(image::ImageFormat::Jpeg);
let mut limits = image::Limits::default();
limits.max_alloc = Some(256 * 1024 * 1024);
limits.max_image_width = Some(16384);
limits.max_image_height = Some(16384);
reader.limits(limits);
use image::ImageDecoder;
let mut decoder = reader
.into_decoder()
.context("Cannot initialize bounded image decoder")?;
let (source_width, source_height) = decoder.dimensions();
ensure!(
u64::from(source_width) * u64::from(source_height) <= 64 * 1024 * 1024
&& decoder.total_bytes() <= 256 * 1024 * 1024,
"Decoded image exceeds memory/pixel budget"
);
let orientation = decoder
.orientation()
.context("Cannot read image EXIF orientation")?;
if is_jpeg && u64::from(source_width) * u64::from(source_height) > 16 * 1024 * 1024 {
drop(decoder);
return decode_large_jpeg(path, budget, source_width, source_height, orientation);
}
ensure!(u64::from(source_width)*u64::from(source_height)<=16*1024*1024,"Non-JPEG image exceeds16megapixels; bounded scaled decoding is currently available for JPEG");
let mut decoded = image::DynamicImage::from_decoder(decoder)
.context("Cannot decode supported image pixels")?;
decoded.apply_orientation(orientation);
ensure!(
u64::from(decoded.width()) * u64::from(decoded.height()) <= 16 * 1024 * 1024,
"Image exceeds 16 megapixels"
);
let (w, h) = image_target(decoded.width(), decoded.height(), budget)?;
Ok(image::imageops::resize(
&decoded.to_rgb8(),
w,
h,
image::imageops::FilterType::CatmullRom,
))
}
fn decode_large_jpeg(
path: &Path,
budget: u32,
width: u32,
height: u32,
orientation: image::metadata::Orientation,
) -> Result<image::RgbImage> {
let (target_width, target_height) = image_target(width, height, budget)?;
let mut decoder = jpeg_decoder::Decoder::new(BufReader::new(File::open(path)?));
decoder.set_max_decoding_buffer_size(256 * 1024 * 1024);
decoder
.read_info()
.context("Cannot read scaled JPEG header")?;
let source = decoder.info().context("Missing scaled JPEG header")?;
ensure!(
u32::from(source.width) == width && u32::from(source.height) == height,
"JPEG changed during decoding"
);
ensure!(
matches!(
source.pixel_format,
jpeg_decoder::PixelFormat::RGB24 | jpeg_decoder::PixelFormat::L8
),
"Large JPEG requires supported RGB24 or grayscale scaled decoding"
);
let (w, h) = decoder.scale(u16::try_from(target_width)?, u16::try_from(target_height)?)?;
ensure!(
u64::from(w) * u64::from(h) * 4 <= 256 * 1024 * 1024,
"Scaled JPEG output exceeds memory budget"
);
let pixels = decoder
.decode()
.context("Cannot decode large JPEG with scaled IDCT")?;
let mut decoded = match source.pixel_format {
jpeg_decoder::PixelFormat::RGB24 => image::DynamicImage::ImageRgb8(
image::RgbImage::from_raw(u32::from(w), u32::from(h), pixels)
.context("Invalid scaled JPEG RGB length")?,
),
jpeg_decoder::PixelFormat::L8 => image::DynamicImage::ImageLuma8(
image::GrayImage::from_raw(u32::from(w), u32::from(h), pixels)
.context("Invalid scaled JPEG grayscale length")?,
),
other => anyhow::bail!(
"Large JPEG pixel format {other:?} is not supported by bounded scaled decoder"
),
};
decoded.apply_orientation(orientation);
let (w, h) = image_target(decoded.width(), decoded.height(), budget)?;
Ok(image::imageops::resize(
&decoded.into_rgb8(),
w,
h,
image::imageops::FilterType::CatmullRom,
))
}
fn image_target(width: u32, height: u32, budget: u32) -> Result<(u32, u32)> {
ensure!(
width > 0 && height > 0 && (70..=1120).contains(&budget),
"Invalid image dimensions/budget"
);
let scale = (f64::from(budget) * 2304.0 / (f64::from(width) * f64::from(height))).sqrt();
let mut w = ((f64::from(width) * scale / 48.0).floor() as u32) * 48;
let mut h = ((f64::from(height) * scale / 48.0).floor() as u32) * 48;
if w == 0 {
w = 48;
h = (height / width * 48).min(budget * 48);
}
if h == 0 {
h = 48;
w = (width / height * 48).min(budget * 48);
}
ensure!(
w >= 48 && h >= 48 && u64::from(w) * u64::from(h) <= u64::from(budget) * 2304,
"Image target outside token budget"
);
Ok((w, h))
}
fn can_retry_cpu(actual_device: &str, force_cpu: bool) -> bool {
actual_device == "gpu" && !force_cpu
}
@@ -331,6 +616,10 @@ fn extract_vectors(response: Value, count: usize) -> Result<Vec<Vec<f32>>> {
}
fn verify_model(path: &Path) -> Result<()> {
verify_artifact(path, MODEL_BYTES, MODEL_SHA256)
}
fn verify_artifact(path: &Path, bytes: u64, sha: &str) -> Result<()> {
let file = File::open(path).with_context(|| {
format!(
"Missing Google EmbeddingGemma 2 model {}; run explicit model setup first",
@@ -338,7 +627,7 @@ fn verify_model(path: &Path) -> Result<()> {
)
})?;
ensure!(
file.metadata()?.len() == MODEL_BYTES,
file.metadata()?.len() == bytes,
"EmbeddingGemma 2 model size mismatch"
);
let mut reader = BufReader::new(file);
@@ -352,7 +641,7 @@ fn verify_model(path: &Path) -> Result<()> {
hash.update(&buffer[..count]);
}
ensure!(
format!("{:x}", hash.finalize()) == MODEL_SHA256,
format!("{:x}", hash.finalize()) == sha,
"EmbeddingGemma 2 model SHA256 mismatch"
);
Ok(())
@@ -362,6 +651,101 @@ fn verify_model(path: &Path) -> Result<()> {
mod tests {
use super::*;
#[test]
fn old_text_worker_handshake_does_not_claim_media_support() {
let mut handshake = json!({"ready":true,"model":"google/embeddinggemma-2","dimensions":768,"device":"cpu","threads":1,"runtime":"b11461-no-logits-v1"});
let old: WorkerInfo = serde_json::from_value(handshake.clone()).unwrap();
assert!(!old.media_supported);
handshake["media_supported"] = json!(true);
assert!(
serde_json::from_value::<WorkerInfo>(handshake)
.unwrap()
.media_supported
);
}
#[test]
fn media_image_grid_is_bounded_for_extreme_aspects_and_full_budget() {
for budget in [70, 140, 280, 1120] {
for (width, height) in [(1, 16384), (16384, 1), (1920, 1080), (500, 500)] {
let (w, h) = image_target(width, height, budget).unwrap();
assert_eq!(w % 48, 0);
assert_eq!(h % 48, 0);
assert!(u64::from(w) * u64::from(h) <= u64::from(budget) * 2304);
}
}
assert!(image_target(0, 100, 280).is_err());
assert!(image_target(100, 100, 1121).is_err());
}
#[test]
fn artifact_verification_rejects_projector_size_and_checksum_mismatch() {
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/LICENSE-DigUp.txt");
let bytes = std::fs::read(&path).unwrap();
let sha = format!("{:x}", Sha256::digest(&bytes));
assert!(verify_artifact(&path, bytes.len() as u64, &sha).is_ok());
assert!(verify_artifact(&path, bytes.len() as u64 + 1, &sha).is_err());
assert!(verify_artifact(&path, bytes.len() as u64, &"0".repeat(64)).is_err());
}
#[test]
#[ignore = "Explicit48MP JPEG resource test; no model needed, run release CPU"]
fn large_phone_jpeg_scaled_decode_preserves_exif_and_bounds() {
use image::ImageEncoder;
let image = image::RgbImage::from_fn(8000, 6000, |x, _| {
if x < 4000 {
image::Rgb([240, 10, 10])
} else {
image::Rgb([10, 10, 240])
}
});
let mut encoded = Vec::new();
let mut encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut encoded, 80);
// Little-endian TIFF orientation tag6: rotate90clockwise.
encoder
.set_exif_metadata(vec![
b'I', b'I', 42, 0, 8, 0, 0, 0, 1, 0, 0x12, 1, 3, 0, 1, 0, 0, 0, 6, 0, 0, 0, 0, 0,
0, 0,
])
.unwrap();
encoder.encode_image(&image).unwrap();
drop(image);
let path =
std::env::temp_dir().join(format!("searchi-phone48mp-{}.jpg", std::process::id()));
std::fs::write(&path, &encoded).unwrap();
drop(encoded);
let started = std::time::Instant::now();
let result = decode_image(&path, 280);
let _ = std::fs::remove_file(&path);
let pixels = result.unwrap();
println!(
"48MPactualJPEG scaleddecode+EXIF+resize {:?}; output{}x{} RGB{}bytes",
started.elapsed(),
pixels.width(),
pixels.height(),
pixels.as_raw().len()
);
assert!(pixels.height() > pixels.width());
assert!(pixels.get_pixel(pixels.width() / 2, pixels.height() / 4)[0] > 200);
assert!(pixels.get_pixel(pixels.width() / 2, pixels.height() * 3 / 4)[2] > 200);
assert!(u64::from(pixels.width()) * u64::from(pixels.height()) <= 280 * 2304);
}
#[test]
fn oversized_jpeg_header_rejected_before_pixel_decode() {
let mut encoded = Vec::new();
image::codecs::jpeg::JpegEncoder::new(&mut encoded)
.encode_image(&image::RgbImage::new(16, 16))
.unwrap();
let sof = encoded.windows(2).position(|p| p == [0xff, 0xc0]).unwrap();
encoded[sof + 5..sof + 7].copy_from_slice(&9000u16.to_be_bytes());
encoded[sof + 7..sof + 9].copy_from_slice(&9000u16.to_be_bytes());
let path =
std::env::temp_dir().join(format!("searchi-too-large-{}.jpg", std::process::id()));
std::fs::write(&path, encoded).unwrap();
let result = decode_image(&path, 280);
let _ = std::fs::remove_file(&path);
assert!(result
.unwrap_err()
.to_string()
.contains("memory/pixel budget"));
}
#[test]
fn missing_google_model_fails_without_starting_worker() {
assert!(GemmaEmbedder::load("missing-google-model", "missing-worker", 1).is_err());
}
+4 -1
View File
@@ -1,7 +1,10 @@
//! Local Google EmbeddingGemma 2 inference through an isolated native worker.
//! The rejected MiniLM experiment is available only under `minilm-proof`.
mod gemma;
pub use gemma::{Device, GemmaEmbedder, WorkerInfo, GEMMA_DIMENSIONS, GEMMA_FINGERPRINT};
pub use gemma::{
Device, GemmaEmbedder, ImageEmbedding, WorkerInfo, GEMMA_DIMENSIONS, GEMMA_FINGERPRINT,
GEMMA_MEDIA_FINGERPRINT,
};
/// A verified source byte range covered by a model token window.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
+14
View File
@@ -0,0 +1,14 @@
# Pinned Google media reference fixtures
The `digup-photo.jpg`, `digup-screen.png`, `digup-speech.wav`, and
`digup-media-reference.json` files are copied unchanged from
`ARahim3/DigUp`, commit `6faa29e4c315a3a591de1b41bd78751bdf39588d`,
`Tests/LlamaRuntimeTests/Reference/`. They and the existing text reference
fixture are retained under the adjacent `LICENSE-DigUp.txt` MIT notice.
The source test uses image budgets 280 (screen), 140 (photo), 70 (frame),
and a mono 16 kHz speech recording. The native CPU test compares actual
768-dimensional Google vectors with the pinned llama.cpp b11461 reference
at cosine >=0.999. This is runtime parity evidence, not a production media
relevance corpus. Independent images, audio, and video require separate
quality evaluation.
@@ -0,0 +1 @@
{"made_with":"llama.cpp b11461","vector_version":1,"vectors":{"document":[0.0023304052,-0.021797102,-0.025986522,-0.025894934,0.0046086097,-0.029744277,-0.04186634,0.03234369,0.015701862,-0.13444579,-0.049679857,-0.067124754,-0.008888296,0.049783457,0.04075608,-0.0027419934,0.0494676,0.0092225745,-0.003042357,0.021539804,-0.066572726,-0.04184304,-0.008146685,-0.015800655,0.0028940293,0.026850378,0.023585942,0.012522315,-0.018817414,0.19809185,-0.0541878,-0.033899978,0.06363426,-0.012184333,0.026218394,-0.030783277,-0.016514625,0.0010143976,0.016249185,0.00673781,0.07569905,-0.032087695,0.01618515,0.008316216,0.009405525,0.035788074,0.011760172,-0.0018986556,0.03811712,0.04686964,-0.0019338331,-0.06858261,-0.020950682,0.0061196685,-0.012488069,0.055386145,0.00828158,-0.028973866,0.029866058,0.019298207,0.0064102146,0.03210796,0.031232368,-0.024290783,-0.009193791,-0.00788917,0.07007842,-0.036232397,0.048318822,0.03329114,-0.021271994,-0.01810213,0.056723867,0.026635557,-0.0066668643,-0.03339305,-0.09275154,-0.005457068,-0.037602138,0.06852603,-0.086277,0.0108406255,-0.022301262,0.054799795,0.048695795,-0.0094250385,0.04326083,-0.05584021,0.009427591,0.018164966,0.0077456376,0.012890579,0.011140378,0.009403773,-0.013322473,0.014014791,0.0045265807,0.0026267597,-0.02777846,0.03337617,-0.014311906,-0.0027436074,0.0016559366,0.04267254,-0.0074306116,0.010555651,0.00801582,0.019190526,0.026427642,-0.02305468,0.017346293,-0.254231,-0.037654083,0.004254816,-0.029559055,0.30139065,-0.0017701226,0.012945749,0.045082387,-0.005745192,0.023981802,0.11611545,0.02849787,0.0050043385,0.019032571,0.01543062,0.013486391,-0.0073420904,-0.0120351305,0.027507395,0.017718071,0.0048503615,0.0070377868,-0.00946547,0.015142535,0.013139711,0.019906517,-0.038481727,0.060138095,0.0027306078,-0.010124241,0.020947324,-0.005082965,0.009129657,-0.06960446,0.04814667,0.012469531,-0.017168682,-0.007835558,-0.018339474,0.04499317,-0.048769984,-0.021295255,0.0008460758,0.042394932,0.02629723,0.015862137,-0.027827857,0.02877754,0.044078708,0.02864664,0.015502866,-0.011931367,0.006051371,-0.0676744,-0.032051444,-0.0051915697,0.022663914,0.010766299,-0.045957707,-0.071434505,-0.0071970616,-0.00080499344,-0.019176258,0.039329063,0.07615527,-0.02934876,0.03178097,0.030727895,-0.0014563488,0.033275653,-0.016663045,0.016650142,0.03149862,-0.042355653,-0.01447139,-0.031222051,-0.0371221,-0.071084455,0.02895739,0.012906462,-0.034588896,0.005292062,0.04567936,0.057948757,0.016161263,0.026154758,0.037441123,0.020264363,0.0021852585,0.010503882,0.02564951,-0.0034963347,-0.002975977,-0.05896342,0.090469755,-0.02141068,-0.018266847,0.03979448,0.006404804,0.048994806,-0.0013431136,0.003767527,0.009192836,0.03172842,0.0018301627,-0.032883424,0.070755646,-0.008929331,0.007731602,0.039488316,-0.0026821592,0.047416102,0.017939921,-0.046493225,-0.003571527,0.017453436,0.027679116,-0.045083456,-0.0018627502,-0.0058596916,-0.016741525,-0.041559335,-0.07398639,0.035852198,-0.01250213,-0.027620463,-0.022970477,-0.007722595,0.006369175,-0.027909646,0.05087572,0.04319605,0.0249135,-0.045503743,-0.050388955,-0.07399011,-0.031614304,-0.009977491,-0.011919013,0.017587284,0.046735547,0.051120915,-0.01913088,-0.0040965537,-0.030126736,0.022246089,-0.024694057,-0.0011967963,-0.0076620826,-0.0001045683,0.025630128,0.0023922755,-0.016251149,0.029261485,0.026775412,-0.0011348778,-0.023079926,0.0046075485,0.013615946,-0.022271913,0.0063756416,-0.011097959,-0.057059642,0.017879428,0.039326653,-0.010385263,-0.024296982,-0.0056849266,-0.021441974,0.023893535,-0.004312103,-0.0029023795,0.011346968,-0.05022057,-0.00962676,-0.030292084,0.0022458949,-0.0012382208,0.014016518,0.0130033605,-0.0075721424,0.022480633,0.0378293,-0.0060748337,0.020296352,-0.038013346,0.02737668,0.051704004,0.029559156,-0.008555367,-0.040540576,0.0028047708,-0.01888827,-0.017475538,0.022049233,-0.018655647,0.00035712068,0.0007242559,-0.031585597,0.002809768,-0.010847336,0.019464653,-0.038227003,0.004094894,-0.0045381063,-0.032818098,-0.025449943,-0.0048597767,-0.052715924,0.024247607,-0.025617195,-0.028776655,-0.01491966,0.027141426,0.022653097,0.01347185,0.0010195217,0.0077305734,0.006165836,-0.009121481,-0.011312435,0.0010023145,0.033321623,0.0020216978,-0.04601626,-0.020557044,-0.023531808,0.013099387,0.049464565,-0.0083905,-0.12218178,-0.0090951715,-0.037435923,-0.043180246,0.008593723,0.003133877,0.03239143,-0.02740052,0.028175933,0.00713765,0.010822759,-0.011402951,-0.042760924,-0.03611006,0.011457286,0.060568936,0.0027335465,-0.024305783,-0.004420147,0.0068441983,0.002837347,-0.02218109,-0.022486687,-0.01941801,0.01667096,-0.03958557,-0.0077348636,-0.0012779543,-0.009598079,-0.008962751,0.033793483,0.018625712,-0.006633453,0.026242023,0.021685809,0.013799533,-0.0060389466,0.0020937775,0.02297482,-0.02592566,0.0011351658,0.00793861,-0.01809629,0.050504446,-0.026299631,-0.028017156,0.006029957,-0.02648004,0.03030571,0.016809817,0.29351303,-0.028621448,0.01694766,0.021401705,0.012320341,0.0015229117,-0.Line truncated
Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.
@@ -1,6 +1,49 @@
use anyhow::Result;
use searchi_semantic::{Device, GemmaEmbedder, GEMMA_DIMENSIONS};
#[test]
#[ignore = "Requires explicitly provisioned Google model and CPU worker"]
fn google_cpu_debug_query_reports_actual_worker_stages_without_changing_vectors() -> Result<()> {
let model = std::env::var_os("SEARCHI_GEMMA_MODEL_DIR").expect("Set SEARCHI_GEMMA_MODEL_DIR");
let worker = std::env::var_os("SEARCHI_GEMMA_WORKER").expect("Set SEARCHI_GEMMA_WORKER");
let mut embedder = GemmaEmbedder::load_with_device(model, worker, 1, Device::Cpu)?;
let query = "supplier contract cancellation policy";
let debug = embedder.embed_query_debug(query)?;
let diagnostics = embedder.query_diagnostics();
for name in [
"request_encode_us",
"request_write_us",
"worker_wait_and_response_decode_us",
"response_validate_us",
"worker_tokenization_us",
"worker_batch_prepare_us",
"worker_inference_us",
"worker_pool_normalize_us",
"worker_token_count",
] {
assert!(
diagnostics.contains_key(name),
"Missing actual stage {name}: {diagnostics:?}"
);
}
assert!(diagnostics["worker_inference_us"] > 0);
assert!(diagnostics["worker_token_count"] > 0);
assert!(!serde_json::to_string(&diagnostics)?.contains(query));
let normal = embedder.embed_query(query)?;
assert!(embedder.query_diagnostics().is_empty());
let cosine: f64 = debug
.iter()
.zip(&normal)
.map(|(a, b)| f64::from(*a) * f64::from(*b))
.sum();
assert!(
cosine >= 0.99999,
"Debug tracing changed embeddings: {cosine}"
);
println!("CPU query diagnostics: {diagnostics:?}; debug/normal cosine {cosine:.8}");
Ok(())
}
#[test]
#[ignore = "Requires explicitly provisioned Google model and CPU worker"]
fn google_cpu_real_embeddings_and_verified_tail_windows() -> Result<()> {
@@ -0,0 +1,57 @@
use anyhow::Result;
use searchi_semantic::{Device, GemmaEmbedder};
use std::path::Path;
#[test]
#[ignore = "Requires explicitly provisioned Google media model and CPU worker"]
fn google_cpu_pixels_match_digup_reference() -> Result<()> {
let model = std::env::var_os("SEARCHI_GEMMA_MODEL_DIR").expect("Set model path");
let worker = std::env::var_os("SEARCHI_GEMMA_WORKER").expect("Set worker path");
let mut embedder = GemmaEmbedder::load_with_device(model, worker, 1, Device::Cpu)?;
let fixtures = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures");
let reference: serde_json::Value =
serde_json::from_str(include_str!("fixtures/digup-media-reference.json"))?;
for (name, file, budget) in [
("screen-280", "digup-screen.png", 280),
("photo-140", "digup-photo.jpg", 140),
("frame-70", "digup-photo.jpg", 70),
] {
let path = fixtures.join(file);
let vector = embedder
.embed_images_with_budget(&[path.as_path()], budget)?
.remove(0);
let cosine: f64 = vector
.iter()
.zip(reference["vectors"][name].as_array().unwrap())
.map(|(a, b)| f64::from(*a) * b.as_f64().unwrap())
.sum();
println!("Google image {name} reference cosine={cosine:.8}");
assert!(cosine >= 0.999, "Image parity below0.999: {name} {cosine}");
}
assert_eq!(embedder.info.device, "cpu");
let wav = include_bytes!("fixtures/digup-speech.wav");
let mut offset = 12;
let mut samples = Vec::new();
while offset + 8 <= wav.len() {
let length = u32::from_le_bytes(wav[offset + 4..offset + 8].try_into()?) as usize;
if &wav[offset..offset + 4] == b"data" {
for pair in wav[offset + 8..offset + 8 + length].as_chunks::<2>().0 {
samples.push(f32::from(i16::from_le_bytes(*pair)) / 32768.0);
}
break;
}
offset += 8 + length + (length % 2);
}
let vector = embedder.embed_audio_pcm(&samples)?;
let cosine: f64 = vector
.iter()
.zip(reference["vectors"]["speech"].as_array().unwrap())
.map(|(a, b)| f64::from(*a) * b.as_f64().unwrap())
.sum();
println!("Google audio reference cosine={cosine:.8}");
assert!(
cosine >= 0.999,
"Audio reference parity below0.999: {cosine}"
);
Ok(())
}