Prepare text search during media setup and retain query sessions through promotion

This commit is contained in:
karl committed 2026-10-13 09:27:40 +02:00
1 parent af6ce29125
commit 9aae3f8216
15 files changed
+1235 -65

No files matched your search

+9 -2
View File
@@ -2,6 +2,13 @@
pub use searchi_assets::TOTAL_BYTES;
use std::{path::Path, sync::atomic::AtomicU64};
pub fn provision(directory: &Path, progress: &AtomicU64) -> Result<(), String> {
searchi_assets::provision(directory, progress).map_err(|error| format!("{error:#}"))
/// Notify only after a pinned asset is byte-verified; download continues while
/// desktop orchestration prepares a separately owned query session.
pub fn provision_with_verified_assets(
directory: &Path,
progress: &AtomicU64,
callback: impl FnMut(searchi_assets::VerifiedAsset),
) -> Result<(), String> {
searchi_assets::provision_with_verified_assets(directory, progress, callback)
.map_err(|error| format!("{error:#}"))
}
+228 -25
View File
@@ -2,7 +2,9 @@
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, InferenceBudget, InferenceRole};
use searchi_service::{
EmbeddingReport, HybridSearch, InferenceBudget, InferenceRole, SemanticCoverage,
};
use serde::Serialize;
use std::{
path::PathBuf,
@@ -17,6 +19,9 @@ pub struct SemanticState {
pub model_directory: PathBuf,
pub worker: PathBuf,
verified: AtomicBool,
text_verified: AtomicBool,
media_verified: AtomicBool,
media_error: Mutex<Option<String>>,
downloading: AtomicBool,
embedding: AtomicBool,
querying: AtomicBool,
@@ -47,6 +52,9 @@ impl SemanticState {
model_directory,
worker,
verified: AtomicBool::new(false),
text_verified: AtomicBool::new(false),
media_verified: AtomicBool::new(false),
media_error: Mutex::new(None),
downloading: AtomicBool::new(false),
embedding: AtomicBool::new(false),
querying: AtomicBool::new(false),
@@ -148,15 +156,95 @@ impl SemanticState {
let semantic = self.clone();
tauri::async_runtime::spawn_blocking(move || {
let _guard = WorkGuard(&semantic.downloading);
semantic.record_error(None);
let result =
model_download::provision(&semantic.model_directory, &semantic.downloaded_bytes);
semantic.verified.store(result.is_ok(), Ordering::Release);
let result = result.and_then(|_| semantic.prepare_query_session(database, threads));
semantic.record_error(result.err());
let _ = semantic.run_setup(database, threads);
});
}
fn run_setup(
self: &std::sync::Arc<Self>,
database: PathBuf,
threads: usize,
) -> Result<(), String> {
self.record_error(None);
self.verified.store(false, Ordering::Release);
self.text_verified.store(false, Ordering::Release);
self.media_verified.store(false, Ordering::Release);
if let Ok(mut error) = self.media_error.lock() {
*error = None;
}
let mut warm = None;
let provision = model_download::provision_with_verified_assets(
&self.model_directory,
&self.downloaded_bytes,
|asset| match asset {
searchi_assets::VerifiedAsset::TextBackbone => {
self.text_verified.store(true, Ordering::Release);
if !self.warmed.load(Ordering::Acquire) {
// Reserve warmup before publishing readiness to indexing admission.
self.warming.store(true, Ordering::Release);
let semantic = self.clone();
let database = database.clone();
warm = Some(
std::thread::Builder::new()
.name("searchi-query-prepare".into())
.spawn(move || {
let result = semantic.prepare_query_session(database, threads);
// Report immediately: the independent projector download can
// continue for minutes after a failed query warmup.
if let Err(error) = &result {
semantic.record_error(Some(error.clone()));
}
result
})
.map_err(|error| {
let error =
format!("Could not start query preparation: {error}");
self.record_error(Some(error.clone()));
self.warming.store(false, Ordering::Release);
error
}),
);
}
}
searchi_assets::VerifiedAsset::MediaProjector => {
self.media_verified.store(true, Ordering::Release);
}
},
);
self.verified.store(provision.is_ok(), Ordering::Release);
let prepared = warm
.map(|handle| {
handle.and_then(|handle| {
handle
.join()
.unwrap_or_else(|_| Err("Query preparation failed unexpectedly.".into()))
})
})
.unwrap_or(Ok(()));
self.warming.store(false, Ordering::Release);
self.finish_setup(provision, prepared)
}
fn finish_setup(
&self,
provision: Result<(), String>,
prepared: Result<(), String>,
) -> Result<(), String> {
if let Err(error) = &prepared {
self.record_error(Some(error.clone()));
}
if let Err(error) = &provision {
if self.text_verified.load(Ordering::Acquire) {
if let Ok(mut media_error) = self.media_error.lock() {
*media_error = Some(error.clone());
}
} else {
self.record_error(Some(error.clone()));
}
}
prepared.and(provision)
}
/// Linearize resource reset, persisted settings and setup admission. The
/// lifecycle lock never spans worker startup, inference or a service wait.
pub fn change_resources(
@@ -185,6 +273,23 @@ impl SemanticState {
Ok(())
}
fn desired_coverage(&self) -> SemanticCoverage {
if self.verified.load(Ordering::Acquire) && self.media_tools.is_some() {
SemanticCoverage::FullMedia
} else {
SemanticCoverage::TextOnly
}
}
fn can_index(&self, text_indexing_allowed: bool) -> bool {
self.text_verified.load(Ordering::Acquire)
&& self.worker.is_file()
&& self.warmed.load(Ordering::Acquire)
&& !self.warming.load(Ordering::Acquire)
&& !self.embedding.load(Ordering::Acquire)
&& (self.desired_coverage() == SemanticCoverage::FullMedia || text_indexing_allowed)
}
fn record_error(&self, value: Option<String>) {
if let Ok(mut error) = self.error.lock() {
*error = value;
@@ -196,6 +301,12 @@ impl SemanticState {
pub struct SemanticStatus {
model: &'static str,
ready: bool,
text_model_ready: bool,
media_model_ready: bool,
media_model_error: Option<String>,
indexing_ready: bool,
coverage: Option<SemanticCoverage>,
requested_coverage: SemanticCoverage,
model_directory: String,
worker_ready: bool,
downloading: bool,
@@ -226,11 +337,23 @@ impl Drop for WorkGuard<'_> {
pub fn semantic_status(state: State<'_, AppState>) -> Result<SemanticStatus, String> {
let semantic = &state.semantic;
let worker_ready = semantic.worker.is_file();
let index =
HybridSearch::embedding_status(&state.database).map_err(|error| error.to_string())?;
let desired = semantic.desired_coverage();
let index = HybridSearch::embedding_status_for_coverage(&state.database, desired)
.map_err(|error| error.to_string())?;
let indexing_ready = semantic.can_index(index.text_indexing_allowed);
Ok(SemanticStatus {
model: "Google EmbeddingGemma 2",
ready: worker_ready && semantic.warmed.load(Ordering::Acquire),
text_model_ready: semantic.text_verified.load(Ordering::Acquire),
media_model_ready: semantic.media_verified.load(Ordering::Acquire),
media_model_error: semantic
.media_error
.lock()
.ok()
.and_then(|error| error.clone()),
indexing_ready,
coverage: index.coverage,
requested_coverage: desired,
model_directory: semantic.model_directory.to_string_lossy().into_owned(),
worker_ready,
downloading: semantic.downloading.load(Ordering::Acquire),
@@ -289,13 +412,7 @@ pub async fn setup_model(state: State<'_, AppState>) -> Result<(), String> {
drop(admission);
tauri::async_runtime::spawn_blocking(move || {
let _guard = WorkGuard(&semantic.downloading);
semantic.record_error(None);
let result =
model_download::provision(&semantic.model_directory, &semantic.downloaded_bytes);
semantic.verified.store(result.is_ok(), Ordering::Release);
let result = result.and_then(|_| semantic.prepare_query_session(database, threads));
semantic.record_error(result.as_ref().err().cloned());
result
semantic.run_setup(database, threads)
})
.await
.map_err(|error| error.to_string())?
@@ -308,11 +425,13 @@ pub async fn index_embeddings(state: State<'_, AppState>) -> Result<EmbeddingRep
.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 coverage = semantic.desired_coverage();
let index = HybridSearch::embedding_status_for_coverage(&state.database, coverage)
.map_err(|error| error.to_string())?;
if !semantic.can_index(index.text_indexing_allowed) {
return Err(
"Semantic indexing is waiting for verified models and query preparation.".into(),
);
}
if semantic
.embedding
@@ -340,10 +459,14 @@ pub async fn index_embeddings(state: State<'_, AppState>) -> Result<EmbeddingRep
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, &semantic.media_limits)
match coverage {
SemanticCoverage::TextOnly => service.index_text_embeddings_preserving_media()
.and_then(|report| report.ok_or_else(|| anyhow::anyhow!("Full media index retained while media setup finishes."))),
SemanticCoverage::FullMedia => {
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, &semantic.media_limits)
}
}
})()
.map_err(|error| error.to_string());
// The publisher shares its prepared immutable ANN before activation.
@@ -517,6 +640,86 @@ pub async fn hybrid_search_measured(
mod tests {
use super::*;
#[test]
fn text_admission_requires_verified_warm_session_and_preserves_full_index() {
let runtime = tempfile::NamedTempFile::new().unwrap();
let state = SemanticState::new(
PathBuf::new(),
runtime.path().into(),
Err("no decoder".into()),
);
state.text_verified.store(true, Ordering::Release);
assert!(!state.can_index(true));
state.warmed.store(true, Ordering::Release);
state.downloading.store(true, Ordering::Release);
assert!(
state.can_index(true),
"projector download must not block verified text"
);
assert!(
!state.can_index(false),
"existing full generation must survive repair"
);
state.warming.store(true, Ordering::Release);
assert!(!state.can_index(true));
state.warming.store(false, Ordering::Release);
state.embedding.store(true, Ordering::Release);
assert!(!state.can_index(true));
state.embedding.store(false, Ordering::Release);
state.text_verified.store(false, Ordering::Release);
assert!(
!state.can_index(true),
"cached warm model cannot authorize unverified indexing"
);
}
#[test]
fn full_coverage_requires_pair_verification_and_decoder() {
let state = SemanticState::new(PathBuf::new(), PathBuf::new(), Err("no decoder".into()));
state.verified.store(true, Ordering::Release);
state.media_verified.store(true, Ordering::Release);
assert_eq!(state.desired_coverage(), SemanticCoverage::TextOnly);
}
#[test]
fn projector_error_preserves_warm_text_and_is_separate_from_query_error() {
let state = SemanticState::new(PathBuf::new(), PathBuf::new(), Err("no decoder".into()));
state.text_verified.store(true, Ordering::Release);
state.warmed.store(true, Ordering::Release);
assert!(state
.finish_setup(Err("projector download failed".into()), Ok(()))
.is_err());
assert!(state.warmed.load(Ordering::Acquire));
assert!(state.error.lock().unwrap().is_none());
assert_eq!(
state.media_error.lock().unwrap().as_deref(),
Some("projector download failed")
);
assert!(!state.verified.load(Ordering::Acquire));
}
#[test]
fn unverified_backbone_and_warmup_errors_remain_query_errors() {
let state = SemanticState::new(PathBuf::new(), PathBuf::new(), Err("no decoder".into()));
state
.finish_setup(Err("backbone invalid".into()), Ok(()))
.unwrap_err();
assert_eq!(
state.error.lock().unwrap().as_deref(),
Some("backbone invalid")
);
assert!(state.media_error.lock().unwrap().is_none());
state.text_verified.store(true, Ordering::Release);
state
.finish_setup(Ok(()), Err("worker startup failed".into()))
.unwrap_err();
assert_eq!(
state.error.lock().unwrap().as_deref(),
Some("worker startup failed")
);
assert!(!state.warmed.load(Ordering::Acquire));
}
#[test]
fn failed_resource_save_preserves_ready_session_and_admission() {
let state = std::sync::Arc::new(SemanticState::new(