Coordinate foreground search and background inference CPU budgets

This commit is contained in:
karl committed 2026-10-13 01:56:03 +02:00
1 parent 6b78c8bb7f
commit d85746db5b
12 files changed
+858 -60

No files matched your search

+5
View File
@@ -46,6 +46,11 @@ readiness without completed release gates and recorded evidence.
graphs before SQL activation; retain the query model/LRU across publication
instead of repeating warmup under the foreground mutex. Measure startup,
actual first input, and warm p95 separately.
- Desktop CPU threads are a shared inference budget (settings version two),
default two and hardware-clamped. Keep foreground priority and one background
thread; yield between document encodes. Trace admission separately from native
inference. This does not bound decoder, extraction or ANN CPU work. Do not
claim preemption of an already executing media encode on a one-thread budget.
- UI should belong to each OS: native typography/chrome/dialogs, themes,
keyboard access, reduced motion and high contrast. Brief transitions must not
delay requests. Preserve selection/focus as progressive results arrive.
+1 -3
View File
@@ -113,11 +113,9 @@ fn set_cpu_threads(threads: usize, state: State<'_, AppState>) -> Result<Setting
let mut next = store.value.clone();
next.cpu_threads = threads;
next.validate()?;
state.semantic.reset_session()?;
store.save(next)?;
state
.semantic
.begin_automatic_setup(state.database.clone(), threads);
.change_resources(state.database.clone(), threads, || store.save(next))?;
Ok(store.value.clone())
}
+191 -41
View File
@@ -2,7 +2,7 @@
use crate::{measured_outcome, model_download, AppState, Filters, MeasuredFailure};
use searchi_core::metrics::{MetricsMode, SearchResponse, TraceCollector};
use searchi_core::SearchResult;
use searchi_service::{EmbeddingReport, HybridSearch};
use searchi_service::{EmbeddingReport, HybridSearch, InferenceBudget, InferenceRole};
use serde::Serialize;
use std::{
path::PathBuf,
@@ -26,6 +26,8 @@ pub struct SemanticState {
error: Mutex<Option<String>>,
service: Mutex<Option<HybridSearch>>,
prepared_graphs: searchi_service::PreparedGraphs,
inference_budget: InferenceBudget,
lifecycle: Mutex<()>,
media_tools: Option<searchi_media::FfmpegTools>,
media_setup_error: Option<String>,
}
@@ -53,12 +55,27 @@ impl SemanticState {
error: Mutex::new(None),
service: Mutex::new(None),
prepared_graphs: searchi_service::PreparedGraphs::default(),
inference_budget: InferenceBudget::new(2),
lifecycle: Mutex::new(()),
media_tools,
media_setup_error,
}
}
pub fn reset_session(&self) -> Result<(), String> {
fn ensure_idle(&self) -> Result<(), String> {
if self.embedding.load(Ordering::Acquire)
|| self.downloading.load(Ordering::Acquire)
|| self.warming.load(Ordering::Acquire)
|| self.querying.load(Ordering::Acquire)
{
return Err("Wait for semantic work before changing resource settings.".into());
}
Ok(())
}
#[cfg(test)]
fn reset_session_locked(&self) -> Result<(), String> {
self.ensure_idle()?;
*self
.service
.try_lock()
@@ -75,9 +92,19 @@ impl SemanticState {
"The installed embedding worker is missing. Repair or reinstall Searchi.".into(),
);
}
self.inference_budget.configure(threads);
let threads = self
.inference_budget
.worker_threads(InferenceRole::Foreground);
let admission = self
.inference_budget
.acquire(InferenceRole::Foreground, threads)
.map_err(|error| error.to_string())?;
let mut service =
HybridSearch::open(database, &self.model_directory, &self.worker, threads)
.map_err(|error| error.to_string())?;
drop(admission);
service = service.with_budget(&self.inference_budget, InferenceRole::Foreground, threads);
service
.share_prepared_graphs(&self.prepared_graphs)
.map_err(|error| error.to_string())?;
@@ -92,6 +119,12 @@ impl SemanticState {
/// First-launch setup is authorized product behavior; inference remains offline.
pub fn begin_automatic_setup(self: &std::sync::Arc<Self>, database: PathBuf, threads: usize) {
let Ok(admission) = self.lifecycle.lock() else {
return;
};
if self.embedding.load(Ordering::Acquire) || self.querying.load(Ordering::Acquire) {
return;
}
if self
.downloading
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
@@ -99,6 +132,11 @@ impl SemanticState {
{
return;
}
drop(admission);
self.spawn_setup(database, threads);
}
fn spawn_setup(self: &std::sync::Arc<Self>, database: PathBuf, threads: usize) {
let semantic = self.clone();
tauri::async_runtime::spawn_blocking(move || {
let _guard = WorkGuard(&semantic.downloading);
@@ -111,6 +149,34 @@ impl SemanticState {
});
}
/// Linearize resource reset, persisted settings and setup admission. The
/// lifecycle lock never spans worker startup, inference or a service wait.
pub fn change_resources(
self: &std::sync::Arc<Self>,
database: PathBuf,
threads: usize,
save: impl FnOnce() -> Result<(), String>,
) -> Result<(), String> {
let admission = self
.lifecycle
.lock()
.map_err(|_| "Semantic admission is unavailable.")?;
self.ensure_idle()?;
let mut service = self
.service
.try_lock()
.map_err(|_| "Wait for the current semantic query before changing resources.")?;
// A failed settings write must keep the existing warm session usable.
save()?;
*service = None;
self.warmed.store(false, Ordering::Release);
self.downloading.store(true, Ordering::Release);
drop(service);
drop(admission);
self.spawn_setup(database, threads);
Ok(())
}
fn record_error(&self, value: Option<String>) {
if let Ok(mut error) = self.error.lock() {
*error = value;
@@ -188,6 +254,9 @@ pub fn semantic_status(state: State<'_, AppState>) -> Result<SemanticStatus, Str
#[tauri::command]
pub async fn setup_model(state: State<'_, AppState>) -> Result<(), String> {
let semantic = state.semantic.clone();
if semantic.embedding.load(Ordering::Acquire) || semantic.querying.load(Ordering::Acquire) {
return Err("Wait for semantic work before restarting model setup.".into());
}
let database = state.database.clone();
let threads = state
.settings
@@ -195,6 +264,13 @@ pub async fn setup_model(state: State<'_, AppState>) -> Result<(), String> {
.map_err(|_| "Settings are unavailable.")?
.value
.cpu_threads;
let admission = semantic
.lifecycle
.lock()
.map_err(|_| "Semantic admission is unavailable.")?;
if semantic.embedding.load(Ordering::Acquire) || semantic.querying.load(Ordering::Acquire) {
return Err("Wait for semantic work before restarting model setup.".into());
}
if semantic
.downloading
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
@@ -202,6 +278,7 @@ pub async fn setup_model(state: State<'_, AppState>) -> Result<(), String> {
{
return Err("Google model setup is already running.".into());
}
drop(admission);
tauri::async_runtime::spawn_blocking(move || {
let _guard = WorkGuard(&semantic.downloading);
semantic.record_error(None);
@@ -219,15 +296,16 @@ pub async fn setup_model(state: State<'_, AppState>) -> Result<(), String> {
#[tauri::command]
pub async fn index_embeddings(state: State<'_, AppState>) -> Result<EmbeddingReport, String> {
let semantic = state.semantic.clone();
let admission = semantic
.lifecycle
.lock()
.map_err(|_| "Semantic admission is unavailable.")?;
if semantic.downloading.load(Ordering::Acquire) || semantic.warming.load(Ordering::Acquire) {
return Err("Wait for model setup to finish before semantic indexing.".into());
}
if !semantic.verified.load(Ordering::Acquire) || !semantic.worker.is_file() {
return Err("Set up the Google model and native worker first.".into());
}
let threads = state
.settings
.lock()
.map_err(|_| "Settings are unavailable.")?
.value
.cpu_threads;
if semantic
.embedding
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
@@ -236,23 +314,29 @@ pub async fn index_embeddings(state: State<'_, AppState>) -> Result<EmbeddingRep
return Err("Semantic indexing is already running.".into());
}
let database = state.database.clone();
drop(admission);
tauri::async_runtime::spawn_blocking(move || {
let _guard = WorkGuard(&semantic.embedding);
semantic.record_error(None);
// Indexing uses its own isolated worker; interactive queries keep their own session.
let result = HybridSearch::open(
// Both workers share one clamped budget. Admission is per encoder call,
// so waiting queries stop the next background batch without a service lock.
let threads = semantic.inference_budget.worker_threads(InferenceRole::Background);
let result = (|| {
let admission = semantic.inference_budget.acquire(InferenceRole::Background, threads)?;
let service = HybridSearch::open(
&database,
&semantic.model_directory,
&semantic.worker,
threads,
)
.and_then(|mut service| {
)?;
drop(admission);
let mut service = service.with_budget(&semantic.inference_budget, InferenceRole::Background, threads);
service.share_prepared_graphs(&semantic.prepared_graphs)?;
let tools = semantic.media_tools.as_ref().ok_or_else(|| {
anyhow::anyhow!("The media decoder is missing from this installation. Repair or reinstall searchi.")
})?;
service.index_embeddings_with_media(tools, &searchi_media::MediaLimits::default())
})
})()
.map_err(|error| error.to_string());
// The publisher shares its prepared immutable ANN before activation.
// Keep the query model and its cached vectors warm throughout indexing.
@@ -270,18 +354,16 @@ pub async fn hybrid_search(
state: State<'_, AppState>,
) -> Result<Vec<SearchResult>, String> {
let semantic = state.semantic.clone();
let admission = semantic
.lifecycle
.lock()
.map_err(|_| "Semantic admission is unavailable.")?;
if !semantic.warmed.load(Ordering::Acquire) {
return Err(
"Semantic search is preparing in the background; keyword results are available.".into(),
);
}
let database = state.database.clone();
let threads = state
.settings
.lock()
.map_err(|_| "Settings are unavailable.")?
.value
.cpu_threads;
if semantic
.querying
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
@@ -289,22 +371,36 @@ pub async fn hybrid_search(
{
return Err("Semantic worker is busy; keyword results remain available.".into());
}
let pending = semantic.inference_budget.pending_foreground();
drop(admission);
tauri::async_runtime::spawn_blocking(move || {
let _guard = WorkGuard(&semantic.querying);
let _pending = pending;
let mut cached = semantic
.service
.lock()
.map_err(|_| "Semantic session is unavailable.")?;
if cached.is_none() {
*cached = Some(
HybridSearch::open(
database,
&semantic.model_directory,
&semantic.worker,
threads,
)
.map_err(|error| error.to_string())?,
);
let threads = semantic
.inference_budget
.worker_threads(InferenceRole::Foreground);
let admission = semantic
.inference_budget
.acquire(InferenceRole::Foreground, threads)
.map_err(|error| error.to_string())?;
let session = HybridSearch::open(
database,
&semantic.model_directory,
&semantic.worker,
threads,
)
.map_err(|error| error.to_string())?;
drop(admission);
*cached = Some(session.with_budget(
&semantic.inference_budget,
InferenceRole::Foreground,
threads,
));
}
cached
.as_mut()
@@ -330,23 +426,18 @@ pub async fn hybrid_search_measured(
MetricsMode::Basic
});
let semantic = state.semantic.clone();
let admission = semantic.lifecycle.lock().map_err(|_| {
MeasuredFailure::new(
"Semantic admission is unavailable.",
TraceCollector::new(MetricsMode::Basic),
)
})?;
if !semantic.warmed.load(Ordering::Acquire) {
return Err(MeasuredFailure::new(
"Semantic search is preparing in the background; keyword results are available.",
trace,
));
}
let threads = trace.timed("desktop.settings_lock", None, |_, _| {
state
.settings
.lock()
.map(|settings| settings.value.cpu_threads)
.map_err(|_| "Settings are unavailable.")
});
let threads = match threads {
Ok(threads) => threads,
Err(error) => return Err(MeasuredFailure::new(error, trace)),
};
if semantic
.querying
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
@@ -359,8 +450,11 @@ pub async fn hybrid_search_measured(
}
let database = state.database.clone();
let queue = trace.begin("desktop.worker_queue", None);
let pending = semantic.inference_budget.pending_foreground();
drop(admission);
tauri::async_runtime::spawn_blocking(move || {
let _guard = WorkGuard(&semantic.querying);
let _pending = pending;
trace.end(queue, Default::default());
let result = (|| {
let mut cached = trace.timed("desktop.semantic_lock", None, |_, _| {
@@ -370,7 +464,14 @@ pub async fn hybrid_search_measured(
.map_err(|_| "Semantic session is unavailable.")
})?;
if cached.is_none() {
*cached = Some(trace.timed("desktop.session_initialize", None, |_, _| {
let threads = semantic
.inference_budget
.worker_threads(InferenceRole::Foreground);
let admission = semantic
.inference_budget
.acquire(InferenceRole::Foreground, threads)
.map_err(|error| error.to_string())?;
let session = trace.timed("desktop.session_initialize", None, |_, _| {
HybridSearch::open(
database,
&semantic.model_directory,
@@ -378,7 +479,13 @@ pub async fn hybrid_search_measured(
threads,
)
.map_err(|error| error.to_string())
})?);
})?;
drop(admission);
*cached = Some(session.with_budget(
&semantic.inference_budget,
InferenceRole::Foreground,
threads,
));
}
cached
.as_mut()
@@ -397,3 +504,46 @@ pub async fn hybrid_search_measured(
.await
.map_err(|error| MeasuredFailure::task(error.to_string(), started))?
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn failed_resource_save_preserves_ready_session_and_admission() {
let state = std::sync::Arc::new(SemanticState::new(
PathBuf::new(),
PathBuf::new(),
Err("not installed".into()),
));
state.warmed.store(true, Ordering::Release);
state.verified.store(true, Ordering::Release);
let result = state.change_resources(PathBuf::new(), 2, || Err("disk write failed".into()));
assert_eq!(result.unwrap_err(), "disk write failed");
assert!(state.warmed.load(Ordering::Acquire));
assert!(state.verified.load(Ordering::Acquire));
assert!(!state.downloading.load(Ordering::Acquire));
assert!(!state.warming.load(Ordering::Acquire));
assert!(state.service.try_lock().is_ok());
assert!(state.lifecycle.try_lock().is_ok());
}
#[test]
fn resources_cannot_reset_active_semantic_work() {
let state = SemanticState::new(PathBuf::new(), PathBuf::new(), Err("not installed".into()));
for busy in [
&state.embedding,
&state.downloading,
&state.warming,
&state.querying,
] {
state.warmed.store(true, Ordering::Release);
busy.store(true, Ordering::Release);
assert!(state.reset_session_locked().is_err());
assert!(state.warmed.load(Ordering::Acquire));
busy.store(false, Ordering::Release);
}
state.reset_session_locked().unwrap();
assert!(!state.warmed.load(Ordering::Acquire));
}
}
+34 -4
View File
@@ -7,7 +7,7 @@ use std::{
path::{Path, PathBuf},
};
const SETTINGS_VERSION: u32 = 1;
const SETTINGS_VERSION: u32 = 2;
const MAX_SETTINGS_BYTES: u64 = 128 * 1024;
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
@@ -27,7 +27,7 @@ impl Default for Settings {
Self {
version: SETTINGS_VERSION,
roots: Vec::new(),
cpu_threads: 1,
cpu_threads: 2,
model_directory: None,
}
}
@@ -71,7 +71,7 @@ pub struct SettingsStore {
impl SettingsStore {
pub fn load(path: PathBuf) -> Result<Self, String> {
let value = match fs::metadata(&path) {
let mut value = match fs::metadata(&path) {
Ok(metadata) => {
if metadata.len() > MAX_SETTINGS_BYTES {
return Err("Settings file exceeds the size limit.".into());
@@ -82,6 +82,15 @@ impl SettingsStore {
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Settings::default(),
Err(error) => return Err(error.to_string()),
};
// Version 1 applied the value independently to both workers. Reserve
// one background thread while retaining the old query allocation.
if value.version == 1 {
if !(1..=8).contains(&value.cpu_threads) {
return Err("CPU threads must be between 1 and 8.".into());
}
value.version = SETTINGS_VERSION;
value.cpu_threads = (value.cpu_threads + 1).min(8);
}
let store = Self { path, value };
store.value.validate()?;
Ok(store)
@@ -126,7 +135,7 @@ mod tests {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("settings.json");
let mut store = SettingsStore::load(path.clone()).unwrap();
assert_eq!(store.value.cpu_threads, 1);
assert_eq!(store.value.cpu_threads, 2);
store.add_root(directory.path()).unwrap();
store.add_root(directory.path()).unwrap();
let mut next = store.value.clone();
@@ -175,6 +184,8 @@ mod tests {
fs::write(&path, r#"{"version":1,"roots":[],"cpu_threads":1}"#).unwrap();
let store = SettingsStore::load(path).unwrap();
assert!(store.value.model_directory.is_none());
assert_eq!(store.value.version, 2);
assert_eq!(store.value.cpu_threads, 2);
let invalid = Settings {
model_directory: Some(PathBuf::from("relative")),
..Settings::default()
@@ -199,3 +210,22 @@ mod tests {
);
}
}
#[cfg(test)]
mod budget_migration_tests {
use super::*;
#[test]
fn version_two_single_thread_is_explicit_and_invalid_legacy_budget_is_rejected() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("settings.json");
fs::write(&path, r#"{"version":2,"roots":[],"cpu_threads":1}"#).unwrap();
assert_eq!(
SettingsStore::load(path.clone()).unwrap().value.cpu_threads,
1
);
fs::write(&path, r#"{"version":1,"roots":[],"cpu_threads":9}"#).unwrap();
assert!(SettingsStore::load(path.clone()).is_err());
fs::write(&path, r#"{"version":1,"roots":[],"cpu_threads":8}"#).unwrap();
assert_eq!(SettingsStore::load(path).unwrap().value.cpu_threads, 8);
}
}
+1 -1
View File
@@ -115,7 +115,7 @@ interface SearchRequest {
}
document.querySelector<HTMLDivElement>("#app")!.innerHTML = `
<header><div class="brand"><img class="brand-symbol" src="/searchi-symbol.png" alt=""><span class="wordmark">searchi</span></div><span id="index-status" class="index-status">Loading your index…</span><div class="header-actions"><button id="add-folder" class="quiet-button" type="button">${icon("folderAdd")}<span>Add folder</span></button><details id="filters" class="popover"><summary>${icon("filter")}<span id="scope-label">All files</span>${icon("chevron")}</summary><div class="popover-panel"><h2>Filter your search</h2><div class="filters-grid"><label>Search scope<select id="filter-scope"><option value="all">All files</option><option value="code">Code</option></select></label><label>Within folder<input id="filter-folder" placeholder="Absolute folder path"></label><label>Extension<input id="filter-extension" placeholder="pdf, md, txt…"></label><label>Minimum size (bytes)<input id="filter-min" type="number" min="0" step="1"></label><label>Maximum size (bytes)<input id="filter-max" type="number" min="0" step="1"></label><label>Minimum duration (seconds)<input id="filter-duration-min" type="number" min="0" step="0.1"></label><label>Maximum duration (seconds)<input id="filter-duration-max" type="number" min="0" step="0.1"></label><label>Modified from<input id="filter-after" type="date"></label><label>Modified through<input id="filter-before" type="date"></label></div></div></details><details id="settings" class="popover"><summary class="icon-button" aria-label="Settings" title="Settings">${icon("settings")}</summary><div class="popover-panel"><h2>Folders & resources</h2><p id="shortcut-status" class="hint"></p><label class="resource-label" for="cpu-threads">CPU thread budget</label><select id="cpu-threads">${Array.from({ length: 8 }, (_, i) => `<option value="${i + 1}">${i + 1} ${i ? "threads" : "thread (default)"}</option>`).join("")}</select><p class="hint">Search automatically uses supported acceleration with CPU fallback.</p><ul id="roots" aria-label="Selected folders"></ul><label class="performance-toggle"><input id="performance-toggle" type="checkbox">Performance details</label><p class="hint">Local timing diagnostics for new searches. Queries and file paths are excluded from copied reports.</p><details id="ai-settings" class="ai-settings"><summary>Optional AI answers</summary><label class="performance-toggle"><input id="ai-enabled" type="checkbox">Enable summaries and questions</label><p class="hint">Disabled by default. Only an explicit action sends the shown passage to your configured endpoint.</p><label>OpenAI-compatible base URL<input id="ai-endpoint" type="url" placeholder="http://localhost:1234/v1" autocomplete="off" maxlength="2048"></label><label>Model<input id="ai-model" placeholder="Your model name" autocomplete="off" maxlength="200"></label><label>API key (optional, this session only)<input id="ai-key" type="password" autocomplete="off" maxlength="2048"></label><button id="ai-clear-key" type="button">Clear key</button><p class="hint">Endpoint, model and key stay in this app session. The key authenticates requests to your endpoint.</p></details></div></details></div></header>
<header><div class="brand"><img class="brand-symbol" src="/searchi-symbol.png" alt=""><span class="wordmark">searchi</span></div><span id="index-status" class="index-status">Loading your index…</span><div class="header-actions"><button id="add-folder" class="quiet-button" type="button">${icon("folderAdd")}<span>Add folder</span></button><details id="filters" class="popover"><summary>${icon("filter")}<span id="scope-label">All files</span>${icon("chevron")}</summary><div class="popover-panel"><h2>Filter your search</h2><div class="filters-grid"><label>Search scope<select id="filter-scope"><option value="all">All files</option><option value="code">Code</option></select></label><label>Within folder<input id="filter-folder" placeholder="Absolute folder path"></label><label>Extension<input id="filter-extension" placeholder="pdf, md, txt…"></label><label>Minimum size (bytes)<input id="filter-min" type="number" min="0" step="1"></label><label>Maximum size (bytes)<input id="filter-max" type="number" min="0" step="1"></label><label>Minimum duration (seconds)<input id="filter-duration-min" type="number" min="0" step="0.1"></label><label>Maximum duration (seconds)<input id="filter-duration-max" type="number" min="0" step="0.1"></label><label>Modified from<input id="filter-after" type="date"></label><label>Modified through<input id="filter-before" type="date"></label></div></div></details><details id="settings" class="popover"><summary class="icon-button" aria-label="Settings" title="Settings">${icon("settings")}</summary><div class="popover-panel"><h2>Folders & resources</h2><p id="shortcut-status" class="hint"></p><label class="resource-label" for="cpu-threads">CPU thread budget</label><select id="cpu-threads">${Array.from({ length: 8 }, (_, i) => `<option value="${i + 1}">${i + 1} ${i === 1 ? "threads (default)" : i ? "threads" : "thread (low power)"}</option>`).join("")}</select><p class="hint">Search automatically uses supported acceleration with CPU fallback. Two or more threads let search run alongside indexing; one thread can delay semantic search during indexing.</p><ul id="roots" aria-label="Selected folders"></ul><label class="performance-toggle"><input id="performance-toggle" type="checkbox">Performance details</label><p class="hint">Local timing diagnostics for new searches. Queries and file paths are excluded from copied reports.</p><details id="ai-settings" class="ai-settings"><summary>Optional AI answers</summary><label class="performance-toggle"><input id="ai-enabled" type="checkbox">Enable summaries and questions</label><p class="hint">Disabled by default. Only an explicit action sends the shown passage to your configured endpoint.</p><label>OpenAI-compatible base URL<input id="ai-endpoint" type="url" placeholder="http://localhost:1234/v1" autocomplete="off" maxlength="2048"></label><label>Model<input id="ai-model" placeholder="Your model name" autocomplete="off" maxlength="200"></label><label>API key (optional, this session only)<input id="ai-key" type="password" autocomplete="off" maxlength="2048"></label><button id="ai-clear-key" type="button">Clear key</button><p class="hint">Endpoint, model and key stay in this app session. The key authenticates requests to your endpoint.</p></details></div></details></div></header>
<main>
<form id="search-form" role="search"><label class="sr-only" for="query">Search your files</label>${icon("search")}<input id="query" type="search" placeholder="Search a filename, phrase, or what you remember…" autocomplete="off" spellcheck="false" maxlength="1000"><kbd id="focus-shortcut">Ctrl K</kbd><button id="clear-query" class="icon-button" type="button" aria-label="Clear search" title="Clear search">${icon("dismiss")}</button></form>
<details id="semantic-panel"><summary>${icon("sparkles")}<span id="semantic-status" role="status" aria-live="polite">Preparing local semantic search…</span><span id="semantic-device" class="kind">Local model</span>${icon("chevron")}</summary><div class="semantic-content"><strong>Google EmbeddingGemma 2</strong><progress id="semantic-progress" aria-label="Local model setup progress"></progress><p id="semantic-detail" class="hint">The verified local model downloads automatically on first launch (865 MB). Your files stay on this device.</p><div class="semantic-actions"><button id="retry-model" type="button" hidden>Retry model setup</button><button id="rebuild-semantic" type="button" hidden>Rebuild semantic index</button></div></div></details>
+353
View File
@@ -0,0 +1,353 @@
//! Shared admission for persistent inference sessions. This bounds admitted
//! native worker threads, not all extraction/ANN/OS work. Already executing
//! inference cannot be preempted; long audio windows remain a latency limit.
use crate::EmbeddingProvider;
use anyhow::{bail, Result};
use searchi_core::metrics::{SpanToken, TraceCollector};
use searchi_semantic::TextWindow;
use std::{
collections::BTreeMap,
path::Path,
sync::{Arc, Condvar, Mutex},
time::{Duration, Instant},
};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum InferenceRole {
Foreground,
Background,
}
#[derive(Default)]
struct State {
limit: usize,
used: usize,
foreground: usize,
}
struct Shared {
state: Mutex<State>,
changed: Condvar,
}
/// Clone this handle across query, setup and indexing sessions.
#[derive(Clone)]
pub struct InferenceBudget(Arc<Shared>);
impl InferenceBudget {
pub fn new(requested: usize) -> Self {
Self(Arc::new(Shared {
state: Mutex::new(State {
limit: clamp(requested),
..State::default()
}),
changed: Condvar::new(),
}))
}
pub fn threads(&self) -> usize {
self.0.state.lock().unwrap_or_else(|e| e.into_inner()).limit
}
/// Leave one admitted worker thread for background indexing. On a
/// single-thread budget, already executing inference still cannot yield.
pub fn worker_threads(&self, role: InferenceRole) -> usize {
match role {
InferenceRole::Foreground => self.threads().saturating_sub(1).max(1),
InferenceRole::Background => 1,
}
}
/// Update admission immediately; already admitted work is not cancelled.
/// Recreate sessions whose worker thread count exceeds a reduced limit.
pub fn configure(&self, requested: usize) -> usize {
let limit = clamp(requested);
self.0.state.lock().unwrap_or_else(|e| e.into_inner()).limit = limit;
self.0.changed.notify_all();
limit
}
/// Register before waiting for a service mutex. This reserves priority, not
/// threads, so the foreground does not deadlock with its indexing session.
pub fn pending_foreground(&self) -> PendingForeground {
self.0
.state
.lock()
.unwrap_or_else(|e| e.into_inner())
.foreground += 1;
self.0.changed.notify_all();
PendingForeground(self.clone())
}
pub fn acquire(
&self,
role: InferenceRole,
actual_worker_threads: usize,
) -> Result<InferencePermit> {
let weight = actual_worker_threads.max(1);
let _pending = (role == InferenceRole::Foreground).then(|| self.pending_foreground());
let deadline = Instant::now() + Duration::from_secs(30);
let mut state = self.0.state.lock().unwrap_or_else(|e| e.into_inner());
loop {
if weight > state.limit {
bail!("Inference session uses {weight} threads but shared budget is {}; recreate the session after changing its thread count", state.limit);
}
if state.used <= state.limit - weight
&& (role == InferenceRole::Foreground || state.foreground == 0)
{
state.used += weight;
return Ok(InferencePermit {
budget: self.clone(),
weight,
});
}
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
bail!("Timed out waiting for shared inference budget");
}
state = self
.0
.changed
.wait_timeout(state, remaining)
.unwrap_or_else(|e| e.into_inner())
.0;
}
}
}
fn clamp(requested: usize) -> usize {
requested
.clamp(1, 8)
.min(std::thread::available_parallelism().map_or(1, usize::from))
}
pub struct PendingForeground(InferenceBudget);
impl Drop for PendingForeground {
fn drop(&mut self) {
let mut s = self.0 .0.state.lock().unwrap_or_else(|e| e.into_inner());
s.foreground -= 1;
self.0 .0.changed.notify_all();
}
}
pub struct InferencePermit {
budget: InferenceBudget,
weight: usize,
}
impl Drop for InferencePermit {
fn drop(&mut self) {
let mut s = self
.budget
.0
.state
.lock()
.unwrap_or_else(|e| e.into_inner());
s.used -= self.weight;
self.budget.0.changed.notify_all();
}
}
/// Delegates genuine inference unchanged. Background documents yield between
/// individual encodes even when the caller supplies a larger batch.
pub struct BudgetedProvider {
inner: Box<dyn EmbeddingProvider>,
budget: InferenceBudget,
role: InferenceRole,
threads: usize,
query_started: bool,
}
impl BudgetedProvider {
pub fn new(
inner: Box<dyn EmbeddingProvider>,
budget: &InferenceBudget,
role: InferenceRole,
actual_worker_threads: usize,
) -> Self {
Self {
inner,
budget: budget.clone(),
role,
threads: actual_worker_threads.max(1),
query_started: false,
}
}
fn permit(&self) -> Result<InferencePermit> {
self.budget.acquire(self.role, self.threads)
}
}
impl EmbeddingProvider for BudgetedProvider {
fn document_batch_limit(&self) -> usize {
if self.role == InferenceRole::Background {
1
} else {
self.inner.document_batch_limit()
}
}
fn fingerprint(&self) -> &str {
self.inner.fingerprint()
}
fn dimensions(&self) -> usize {
self.inner.dimensions()
}
fn split_windows(&mut self, text: &str, title: &str) -> Result<Vec<TextWindow>> {
let _p = self.permit()?;
self.inner.split_windows(text, title)
}
fn embed_documents(&mut self, texts: &[&str], title: &str) -> Result<Vec<Vec<f32>>> {
if self.role == InferenceRole::Background {
let mut output = Vec::with_capacity(texts.len());
for text in texts {
let _p = self.permit()?;
output.extend(self.inner.embed_documents(&[*text], title)?);
}
Ok(output)
} else {
let _p = self.permit()?;
self.inner.embed_documents(texts, title)
}
}
fn embed_query(&mut self, query: &str) -> Result<Vec<f32>> {
self.query_started = false;
let _p = self.permit()?;
self.query_started = true;
self.inner.embed_query(query)
}
fn embed_query_debug(&mut self, query: &str) -> Result<Vec<f32>> {
self.query_started = false;
let _p = self.permit()?;
self.query_started = true;
self.inner.embed_query_debug(query)
}
fn embed_code_query(&mut self, query: &str) -> Result<Vec<f32>> {
self.query_started = false;
let _p = self.permit()?;
self.query_started = true;
self.inner.embed_code_query(query)
}
fn embed_code_query_debug(&mut self, query: &str) -> Result<Vec<f32>> {
self.query_started = false;
let _p = self.permit()?;
self.query_started = true;
self.inner.embed_code_query_debug(query)
}
fn query_diagnostics(&self) -> BTreeMap<String, u64> {
if self.query_started {
self.inner.query_diagnostics()
} else {
Default::default()
}
}
fn embed_query_traced(
&mut self,
query: &str,
debug: bool,
trace: &mut TraceCollector,
parent: Option<SpanToken>,
) -> Result<Vec<f32>> {
self.query_started = false;
let span = trace.begin("semantic.inference.admission_wait", parent);
let permit = self.permit();
trace.end(
span,
if trace.is_debug() {
BTreeMap::from([
("worker_threads".into(), self.threads as u64),
("failed".into(), u64::from(permit.is_err())),
])
} else {
Default::default()
},
);
let _permit = permit?;
self.query_started = true;
self.inner.embed_query_traced(query, debug, trace, parent)
}
fn embed_code_query_traced(
&mut self,
query: &str,
debug: bool,
trace: &mut TraceCollector,
parent: Option<SpanToken>,
) -> Result<Vec<f32>> {
self.query_started = false;
let span = trace.begin("semantic.inference.admission_wait", parent);
let permit = self.permit();
trace.end(
span,
if trace.is_debug() {
BTreeMap::from([
("worker_threads".into(), self.threads as u64),
("failed".into(), u64::from(permit.is_err())),
])
} else {
Default::default()
},
);
let _permit = permit?;
self.query_started = true;
self.inner
.embed_code_query_traced(query, debug, trace, parent)
}
fn embed_images(&mut self, paths: &[&Path], budget: u32) -> Result<Vec<Vec<f32>>> {
if self.role == InferenceRole::Background {
let mut output = Vec::with_capacity(paths.len());
for path in paths {
let _p = self.permit()?;
output.extend(self.inner.embed_images(&[*path], budget)?);
}
return Ok(output);
}
let _p = self.permit()?;
self.inner.embed_images(paths, budget)
}
fn embed_audio(&mut self, pcm: &[f32]) -> Result<Vec<f32>> {
let _p = self.permit()?;
self.inner.embed_audio(pcm)
}
fn worker_info(&self) -> Option<searchi_semantic::WorkerInfo> {
self.inner.worker_info()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reserved_query_capacity_admits_foreground_during_background() {
let budget = InferenceBudget::new(1);
// Deliberately exercise a two-thread scheduler independent of CI hardware.
budget.0.state.lock().unwrap().limit = 2;
assert_eq!(budget.worker_threads(InferenceRole::Foreground), 1);
assert_eq!(budget.worker_threads(InferenceRole::Background), 1);
let background = budget.acquire(InferenceRole::Background, 1).unwrap();
let foreground = budget.acquire(InferenceRole::Foreground, 1).unwrap();
assert_eq!(budget.0.state.lock().unwrap().used, 2);
drop(foreground);
drop(background);
}
#[test]
fn pending_query_blocks_background_until_released() {
let budget = InferenceBudget::new(1);
let pending = budget.pending_foreground();
let other = budget.clone();
let (tx, rx) = std::sync::mpsc::channel();
let worker = std::thread::spawn(move || {
let _permit = other.acquire(InferenceRole::Background, 1).unwrap();
tx.send(()).unwrap();
});
assert!(rx.recv_timeout(Duration::from_millis(50)).is_err());
let foreground = budget.acquire(InferenceRole::Foreground, 1).unwrap();
drop(pending);
assert!(rx.recv_timeout(Duration::from_millis(50)).is_err());
drop(foreground);
rx.recv_timeout(Duration::from_secs(2)).unwrap();
worker.join().unwrap();
}
#[test]
fn panic_releases_threads_and_priority() {
let budget = InferenceBudget::new(1);
let shared = budget.clone();
assert!(std::panic::catch_unwind(move || {
let _pending = shared.pending_foreground();
let _permit = shared.acquire(InferenceRole::Foreground, 1).unwrap();
panic!("owned inference failure");
})
.is_err());
let _permit = budget.acquire(InferenceRole::Background, 1).unwrap();
}
#[test]
fn invalid_session_weight_fails_instead_of_oversubscribing() {
let budget = InferenceBudget::new(1);
assert!(budget.acquire(InferenceRole::Foreground, 2).is_err());
assert_eq!(budget.configure(0), 1);
assert!(InferenceBudget::new(usize::MAX).threads() <= 8);
}
}
+56 -5
View File
@@ -1,6 +1,10 @@
//! Shared offline hybrid search using real Google embeddings and persisted,
//! filtered HNSW retrieval. Production-scale validation remains a release gate.
mod coordination;
mod media;
pub use coordination::{
BudgetedProvider, InferenceBudget, InferencePermit, InferenceRole, PendingForeground,
};
mod prepared;
pub use prepared::PreparedGraphs;
mod pipeline;
@@ -52,6 +56,11 @@ fn reusable_policy(expected: &str, active: &str) -> bool {
/// Model-specific query/document prompts and actual tokenizer windows belong
/// to the provider. Storage and ranking never assume a particular dimension.
pub trait EmbeddingProvider: Send {
/// Maximum document batch admitted as one inference call. Coordinated
/// background sessions yield at every document boundary.
fn document_batch_limit(&self) -> usize {
MAX_BATCH
}
fn fingerprint(&self) -> &str;
fn dimensions(&self) -> usize;
fn split_windows(&mut self, text: &str, title: &str) -> Result<Vec<TextWindow>>;
@@ -66,6 +75,32 @@ pub trait EmbeddingProvider: Send {
fn embed_query_debug(&mut self, query: &str) -> Result<Vec<f32>> {
self.embed_query(query)
}
fn embed_query_traced(
&mut self,
query: &str,
debug: bool,
_trace: &mut TraceCollector,
_parent: Option<SpanToken>,
) -> Result<Vec<f32>> {
if debug {
self.embed_query_debug(query)
} else {
self.embed_query(query)
}
}
fn embed_code_query_traced(
&mut self,
query: &str,
debug: bool,
_trace: &mut TraceCollector,
_parent: Option<SpanToken>,
) -> Result<Vec<f32>> {
if debug {
self.embed_code_query_debug(query)
} else {
self.embed_code_query(query)
}
}
fn query_diagnostics(&self) -> std::collections::BTreeMap<String, u64> {
Default::default()
}
@@ -174,6 +209,21 @@ pub struct HybridSearch {
}
impl HybridSearch {
/// Attach shared admission without replacing the warmed model or query LRU.
pub fn with_budget(
mut self,
budget: &InferenceBudget,
role: InferenceRole,
actual_worker_threads: usize,
) -> Self {
self.embedder = Box::new(BudgetedProvider::new(
self.embedder,
budget,
role,
actual_worker_threads,
));
self
}
/// Connect background publication to an existing warmed reader.
pub fn share_prepared_graphs(&mut self, graphs: &PreparedGraphs) -> Result<()> {
self.store.share_prepared_graphs(graphs)
@@ -442,11 +492,12 @@ impl HybridSearch {
trace.counter(span, "query_cache_hit", 0);
trace.counter(span, "query_cache_miss", 1);
let vector = (|| {
let vector = match (code, trace.is_debug()) {
(true, true) => self.embedder.embed_code_query_debug(query)?,
(true, false) => self.embedder.embed_code_query(query)?,
(false, true) => self.embedder.embed_query_debug(query)?,
(false, false) => self.embedder.embed_query(query)?,
let vector = if code {
self.embedder
.embed_code_query_traced(query, trace.is_debug(), trace, span)?
} else {
self.embedder
.embed_query_traced(query, trace.is_debug(), trace, span)?
};
ensure!(
vector.len() == self.embedder.dimensions()
+3 -2
View File
@@ -258,14 +258,15 @@ impl HybridSearch {
work.report.chunks + windows.len() <= MAX_SCAN_CHUNKS,
"Corpus exceeds ANN generation resource budget of {MAX_SCAN_CHUNKS} chunks"
);
for (batch_index, batch) in windows.chunks(MAX_BATCH).enumerate() {
let batch_limit = self.embedder.document_batch_limit().clamp(1, MAX_BATCH);
for (batch_index, batch) in windows.chunks(batch_limit).enumerate() {
let texts: Vec<_> = batch.iter().map(|window| window.text.as_str()).collect();
work.report.document_inference_calls += 1;
let vectors = self.embedder.embed_documents(&texts, title)?;
self.store.insert(
generation,
passage,
batch_index * MAX_BATCH,
batch_index * batch_limit,
batch,
&vectors,
)?;
@@ -0,0 +1,177 @@
//! Actual Google CPU inference during background indexing; not a scale benchmark.
use anyhow::Result;
use searchi_core::SearchIndex;
use searchi_semantic::{GemmaEmbedder, TextWindow};
use searchi_service::{
EmbeddingProvider, HybridSearch, InferenceBudget, InferenceRole, PreparedGraphs, SearchOptions,
};
use std::{
fs,
sync::{
atomic::{AtomicBool, AtomicUsize, Ordering},
mpsc, Arc,
},
time::Duration,
};
struct ObservedGoogle {
inner: GemmaEmbedder,
background: bool,
in_flight: Arc<AtomicBool>,
overlap: Arc<AtomicBool>,
entered: mpsc::Sender<()>,
calls: Arc<AtomicUsize>,
}
impl EmbeddingProvider for ObservedGoogle {
fn fingerprint(&self) -> &str {
EmbeddingProvider::fingerprint(&self.inner)
}
fn dimensions(&self) -> usize {
EmbeddingProvider::dimensions(&self.inner)
}
fn split_windows(&mut self, text: &str, title: &str) -> Result<Vec<TextWindow>> {
self.inner.split_windows(text, title)
}
fn embed_documents(&mut self, text: &[&str], title: &str) -> Result<Vec<Vec<f32>>> {
assert!(self.background);
self.calls.fetch_add(1, Ordering::Relaxed);
self.in_flight.store(true, Ordering::Release);
let _ = self.entered.send(());
let result = self.inner.embed_documents(text, title);
self.in_flight.store(false, Ordering::Release);
result
}
fn embed_query(&mut self, text: &str) -> Result<Vec<f32>> {
if self.in_flight.load(Ordering::Acquire) {
self.overlap.store(true, Ordering::Release);
}
self.inner.embed_query(text)
}
fn embed_query_debug(&mut self, text: &str) -> Result<Vec<f32>> {
if self.in_flight.load(Ordering::Acquire) {
self.overlap.store(true, Ordering::Release);
}
self.inner.embed_query_debug(text)
}
fn query_diagnostics(&self) -> std::collections::BTreeMap<String, u64> {
self.inner.query_diagnostics()
}
fn worker_info(&self) -> Option<searchi_semantic::WorkerInfo> {
Some(self.inner.info.clone())
}
}
#[test]
#[ignore = "Requires verified real Google worker, two CPU cores; CPU only"]
fn foreground_google_query_runs_while_background_encoder_is_admitted() -> Result<()> {
assert_eq!(std::env::var("SEARCHI_FORCE_CPU")?, "1");
let model = std::env::var_os("SEARCHI_GEMMA_MODEL_DIR").expect("Verified model required");
let worker = std::env::var_os("SEARCHI_GEMMA_WORKER").expect("Normal worker required");
let budget = InferenceBudget::new(2);
assert_eq!(
budget.threads(),
2,
"This concurrent test requires two allocated CPU cores"
);
let temp = tempfile::tempdir()?;
let root = temp.path().join("sources");
fs::create_dir(&root)?;
fs::write(
root.join("garden.txt"),
"Tomato plants need regular watering and plenty of sunlight.",
)?;
fs::write(
root.join("supplier.txt"),
"The supplier contract ends with sixty days written notice.",
)?;
let db = temp.path().join("index.db");
let mut lexical = SearchIndex::open(&db)?;
lexical.index_folder(&root)?;
let graphs = PreparedGraphs::default();
let mut initial = HybridSearch::open(&db, &model, &worker, 1)?;
initial.share_prepared_graphs(&graphs)?;
initial.index_embeddings()?;
drop(initial);
let in_flight = Arc::new(AtomicBool::new(false));
let overlap = Arc::new(AtomicBool::new(false));
let calls = Arc::new(AtomicUsize::new(0));
let (entered, events) = mpsc::channel();
let make = |background| -> Result<HybridSearch> {
let role = if background {
InferenceRole::Background
} else {
InferenceRole::Foreground
};
let threads = budget.worker_threads(role);
let startup = budget.acquire(role, threads)?;
let inner = GemmaEmbedder::load(&model, &worker, threads)?;
assert_eq!(inner.info.device, "cpu");
drop(startup);
let mut service = HybridSearch::with_provider(
&db,
Box::new(ObservedGoogle {
inner,
background,
in_flight: in_flight.clone(),
overlap: overlap.clone(),
entered: entered.clone(),
calls: calls.clone(),
}),
)?
.with_budget(&budget, role, threads);
service.share_prepared_graphs(&graphs)?;
Ok(service)
};
let mut reader = make(false)?;
reader.warmup()?;
let mut indexer = make(true)?;
fs::write(
root.join("maintenance.txt"),
"Technical maintenance notes for equipment inventory scheduling and routine inspections. "
.repeat(800),
)?;
lexical.index_folder(&root)?;
let (report, sample) = std::thread::scope(|scope| -> Result<_> {
let indexing = scope.spawn(move || indexer.index_embeddings());
let mut sample = None;
for query in [
"How should we water tomato plants?",
"What do tomato plants need for growth?",
"How can we keep tomatoes healthy?",
] {
events.recv_timeout(Duration::from_secs(90))?;
let pending = budget.pending_foreground();
let response = reader.search_measured(query, 10, &SearchOptions::default(), true)?;
drop(pending);
assert_eq!(response.results[0].filename, "garden.txt");
let trace = response.metrics.trace.as_ref().expect("Detailed trace");
assert!(trace
.iter()
.any(|span| span.counters.get("query_cache_hit") == Some(&0)));
let wait = trace
.iter()
.find(|span| span.name == "semantic.inference.admission_wait")
.expect("Actual admission span")
.duration_ms;
sample = Some((response.metrics.total_ms, wait));
if overlap.load(Ordering::Acquire) {
break;
}
}
let report = indexing.join().expect("Index thread panic")?;
Ok((report, sample))
})?;
assert!(
overlap.load(Ordering::Acquire),
"Must observe genuine query inference while a background call remains active"
);
assert_eq!(report.reused_sources, 2);
assert_eq!(report.encoded_sources, 1);
assert_eq!(
report.document_inference_calls,
calls.load(Ordering::Relaxed)
);
assert!(report.document_inference_calls > 1);
let (query_ms, admission_ms) = sample.unwrap();
eprintln!("Concurrent Google CPU fixture: total_budget=2 query_threads=1 indexing_threads=1 query_ms={query_ms:.3} admission_ms={admission_ms:.3} document_calls={}; not scale/p95/UI evidence", report.document_inference_calls);
Ok(())
}
+28 -1
View File
@@ -181,4 +181,31 @@ The actual CPU regression checks both a repeated cached query and an unseen
query after publication; the unseen input must record query_cache_hit zero.
See [RECOVERY-AND-HANDOFF.md](RECOVERY-AND-HANDOFF.md). Its single-document
request times do not establish representative-corpus p95, native first input
or foreground priority during indexing. Shared CPU admission remains open.
or foreground priority during indexing.
## Shared inference admission
The desktop now shares one CPU thread budget across its persistent query and
indexing workers. The default is two threads, clamped to available logical CPUs
and a maximum of eight. Indexing uses one thread; the query worker uses the
remaining threads (at least one). Pending searches have priority over new
background encodes, and document indexing yields between individual encodes.
Cached query vectors do not acquire an inference permit.
Settings version two stores the total budget. Version-one settings counted
threads per worker; migration adds one background thread, capped at eight.
An explicit version-two budget of one remains one. Resource changes are rejected
during active semantic work; a failed settings save preserves the warm session.
Debug traces record `semantic.inference.admission_wait` separately from native
inference. Admission waits are bounded at 30 seconds. Already executing native
inference cannot be preempted: a one-thread budget or single-core machine can
still delay a search during a long media encode. This budget covers admitted
inference worker threads, not extraction, decoding, ANN construction, rendering
or total operating-system CPU usage.
The real Google CPU regression `budget_google_cpu` confirmed overlapping query
and background inference with a two-thread budget: one query thread, one indexing
thread, six background document calls, an unseen query taking 103.407 ms including
0.034 ms admission in the final scoped-cleanup run. These are Windows VM debug measurements on a small fixture,
not representative-corpus p95, native UI latency, GPU or scale evidence.
+4 -3
View File
@@ -77,8 +77,9 @@ are not certified by those smoke tests.
## Remaining performance/release work
Background and query workers still receive independent thread budgets; shared
CPU admission and foreground priority are open. Publication no longer charges
Background and query workers now share inference admission and foreground
priority; see [PERFORMANCE.md](PERFORMANCE.md#shared-inference-admission).
Publication no longer charges
foreground graph deserialization, but eligibility refresh, indexing I/O and
CPU contention need representative measurements. Native compositor/input timing,
large-corpus p95, older CPUs, actual GPU/device-loss, power-loss durability,
@@ -97,4 +98,4 @@ installers/upgrades/signatures and macOS packaging remain open.
The prepared graph handle is process-local: foreign CLI/MCP index writers cannot
share its native Arc. Cold CLI initialization and cross-process reader refresh
remain separate costs. Representative concurrent-indexing/foreground measurements
and shared CPU thread admission remain the next performance milestone.
across decoder, extraction and ANN work remain the next performance milestone.
+5
View File
@@ -164,6 +164,11 @@ Audit and measured priorities: [EMBEDDINGGEMMA2-AUDIT.md](EMBEDDINGGEMMA2-AUDIT.
See [INCREMENTAL-SEMANTIC.md](INCREMENTAL-SEMANTIC.md).
- [ ] Benchmark genuine token-budgeted multi-sequence batching and isolation.
- [ ] Implement and measure shared foreground/indexer resource budgets/priority.
- [x] Shared inference admission, foreground priority between background
encodes, total-thread settings migration and retained warm sessions on save
failure; real Google CPU overlap regression passed on Windows.
- [ ] Measure representative mixed-media contention, single-thread latency,
extraction/decoder/ANN work and native UI p95 before closing this gate.
- [ ] Attribute startup cost; measure smaller query context and selective encoders.
- [ ] Buffered bounded media IPC and linear UTF-8 source-window offset processing.
- [ ] Evaluate 256d MRL and content-specific vision budgets on independent data.