Prepare text search during media setup and retain query sessions through promotion
This commit is contained in:
1 parent
af6ce29125
commit
9aae3f8216
15 files changed
+1235
-65
No files matched your search
@@ -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:#}"))
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
+41
-11
@@ -16,6 +16,13 @@ import {
|
||||
SEMANTIC_SETTLE_MS,
|
||||
} from "./search-state";
|
||||
import type { SemanticPhase, SearchFilters } from "./search-state";
|
||||
import {
|
||||
semanticIndexReady,
|
||||
semanticIndexKey,
|
||||
semanticRetryVisible,
|
||||
semanticMediaReady,
|
||||
} from "./semantic-setup-state";
|
||||
import type { SemanticCoverage } from "./semantic-setup-state";
|
||||
import { fileIcon, icon } from "./icons";
|
||||
import {
|
||||
SearchMeasurement,
|
||||
@@ -102,6 +109,12 @@ interface SemanticStatus {
|
||||
stale: boolean;
|
||||
pending: number;
|
||||
warming: boolean;
|
||||
text_model_ready?: boolean;
|
||||
media_model_ready?: boolean;
|
||||
media_model_error?: string | null;
|
||||
indexing_ready?: boolean;
|
||||
requested_coverage?: SemanticCoverage;
|
||||
coverage?: SemanticCoverage | null;
|
||||
media_tools_ready?: boolean;
|
||||
media_setup_error?: string | null;
|
||||
media_failures?: { path: string; message: string }[];
|
||||
@@ -160,6 +173,7 @@ let latestSearch: SearchRequest | null = null;
|
||||
let shownLane: "lexical" | "hybrid" = "lexical";
|
||||
let folderRevision = 0;
|
||||
let localSemanticError: string | null = null;
|
||||
let localModelSetupError: string | null = null;
|
||||
let querySemanticFailed = false;
|
||||
let previewPath: string | null = null;
|
||||
const previewRevision = new PreviewRevision();
|
||||
@@ -297,6 +311,7 @@ function queueSemanticRefresh(delay = 1000) {
|
||||
|
||||
async function buildSemanticIndex() {
|
||||
if (semanticJobActive || indexingActive) return;
|
||||
if (semanticStatus && !semanticIndexReady(semanticStatus)) return;
|
||||
semanticJobActive = true;
|
||||
localSemanticError = null;
|
||||
element("semantic-status").textContent =
|
||||
@@ -330,7 +345,9 @@ async function refreshSemanticStatus() {
|
||||
const status = await invoke<SemanticStatus>("semantic_status");
|
||||
semanticStatus = status;
|
||||
refreshEmptySearchState();
|
||||
const error = localSemanticError || status.error;
|
||||
const setupError =
|
||||
status.ready && status.media_model_error ? null : localModelSetupError;
|
||||
const error = localSemanticError || status.error || setupError;
|
||||
if (error && error !== expandedError) {
|
||||
element<HTMLDetailsElement>("semantic-panel").open = true;
|
||||
expandedError = error;
|
||||
@@ -363,13 +380,15 @@ async function refreshSemanticStatus() {
|
||||
message = "Preparing Google semantic search on your device…";
|
||||
else if (status.downloading)
|
||||
message =
|
||||
status.downloaded_bytes >= status.total_bytes
|
||||
? "Verifying the downloaded Google model…"
|
||||
: `Downloading Google model · ${Math.round(status.downloaded_bytes / 1_000_000)} of ${Math.round(status.total_bytes / 1_000_000)} MB`;
|
||||
status.ready && status.text_model_ready
|
||||
? `${semanticAvailable(status) ? "Text search ready" : "Text model ready"} · Preparing media model · ${Math.round(status.downloaded_bytes / 1_000_000)} of ${Math.round(status.total_bytes / 1_000_000)} MB`
|
||||
: status.downloaded_bytes >= status.total_bytes
|
||||
? "Verifying the downloaded Google model…"
|
||||
: `Downloading Google model · ${Math.round(status.downloaded_bytes / 1_000_000)} of ${Math.round(status.total_bytes / 1_000_000)} MB`;
|
||||
else if (status.embedding || semanticJobActive)
|
||||
message =
|
||||
"Building your semantic index… Keyword search remains available.";
|
||||
else if (status.ready && status.chunks > 0)
|
||||
else if (semanticAvailable(status) && status.chunks > 0)
|
||||
message = `Semantic search ready · ${status.chunks.toLocaleString()} content segments${status.stale ? " · Updates pending" : ""}${status.media_failures?.length ? ` · ${status.media_failures.length} files could not be indexed` : ""}`;
|
||||
else if (status.ready)
|
||||
message = "Google model ready. Index a folder to search by meaning.";
|
||||
@@ -385,20 +404,29 @@ async function refreshSemanticStatus() {
|
||||
? `Media decoder setup needs attention: ${status.media_setup_error}. Text search remains available.`
|
||||
: status.worker_info && !status.worker_info.media_supported
|
||||
? "The installed worker needs a media-capable runtime update. Text search remains available."
|
||||
: status.media_tools_ready && status.worker_info?.media_supported
|
||||
: semanticMediaReady(status) &&
|
||||
status.media_tools_ready &&
|
||||
status.worker_info?.media_supported
|
||||
? "Image, audio, and video encoders are available locally."
|
||||
: "Preparing local image, audio, and video support.";
|
||||
element("semantic-detail").textContent += ` ${mediaDetail}`;
|
||||
if (status.media_model_error)
|
||||
element("semantic-detail").textContent +=
|
||||
` Media model setup needs attention: ${status.media_model_error}. Text search remains available.`;
|
||||
if (status.media_failures?.length) {
|
||||
element("semantic-detail").textContent +=
|
||||
` ${status.media_failures.length} files could not be indexed. The first issue: ${status.media_failures[0].path}: ${status.media_failures[0].message}. Filename search remains available; rebuild after fixing the file.`;
|
||||
}
|
||||
const retry = element<HTMLButtonElement>("retry-model");
|
||||
retry.hidden = !error || status.ready;
|
||||
retry.hidden = !semanticRetryVisible({ ...status, error });
|
||||
retry.disabled = working || isCaptureSurface();
|
||||
const rebuild = element<HTMLButtonElement>("rebuild-semantic");
|
||||
rebuild.hidden = !status.ready || (!status.chunks && !status.pending);
|
||||
rebuild.disabled = working || indexingActive || isCaptureSurface();
|
||||
rebuild.disabled =
|
||||
!semanticIndexReady(status) ||
|
||||
semanticJobActive ||
|
||||
indexingActive ||
|
||||
isCaptureSurface();
|
||||
element<HTMLSelectElement>("cpu-threads").disabled =
|
||||
working || indexingActive || isCaptureSurface();
|
||||
if (
|
||||
@@ -406,10 +434,11 @@ async function refreshSemanticStatus() {
|
||||
status.ready &&
|
||||
status.stale &&
|
||||
status.pending > 0 &&
|
||||
!working &&
|
||||
semanticIndexReady(status) &&
|
||||
!semanticJobActive &&
|
||||
!indexingActive
|
||||
) {
|
||||
const key = `${status.generation}:${status.pending}:${folderRevision}`;
|
||||
const key = semanticIndexKey(status, folderRevision);
|
||||
if (automaticIndex.claim(key)) {
|
||||
void buildSemanticIndex();
|
||||
return;
|
||||
@@ -1276,12 +1305,13 @@ element("retry-model").addEventListener("click", async () => {
|
||||
const button = element<HTMLButtonElement>("retry-model");
|
||||
button.disabled = true;
|
||||
localSemanticError = null;
|
||||
localModelSetupError = null;
|
||||
element("semantic-status").textContent = "Retrying Google model setup…";
|
||||
queueSemanticRefresh();
|
||||
try {
|
||||
await invoke("setup_model");
|
||||
} catch (error) {
|
||||
localSemanticError = String(error);
|
||||
localModelSetupError = String(error);
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
queueSemanticRefresh(0);
|
||||
|
||||
@@ -17,9 +17,15 @@ export const SEMANTIC_SETTLE_MS = 120;
|
||||
/** A rebuild retains the previous committed generation. Backend readiness is
|
||||
* withdrawn only while a replacement query graph is being prepared. */
|
||||
export function semanticAvailable(
|
||||
status: { ready: boolean; generation: number | null } | null,
|
||||
status: {
|
||||
ready: boolean;
|
||||
generation: number | null;
|
||||
coverage?: string | null;
|
||||
} | null,
|
||||
) {
|
||||
return Boolean(status?.ready && status.generation !== null);
|
||||
return Boolean(
|
||||
status?.ready && status.generation !== null && status.coverage !== null,
|
||||
);
|
||||
}
|
||||
|
||||
/** One active request per lane; intermediate keystrokes collapse to the latest.
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/** Setup and search readiness are independent: media downloads need not block text. */
|
||||
export type SemanticCoverage = "text_only" | "full_media";
|
||||
|
||||
export interface SemanticSetupStatus {
|
||||
ready: boolean;
|
||||
generation: number | null;
|
||||
pending: number;
|
||||
downloading: boolean;
|
||||
warming: boolean;
|
||||
embedding: boolean;
|
||||
error: string | null;
|
||||
indexing_ready?: boolean;
|
||||
text_model_ready?: boolean;
|
||||
media_model_ready?: boolean;
|
||||
media_model_error?: string | null;
|
||||
requested_coverage?: SemanticCoverage;
|
||||
}
|
||||
|
||||
export function semanticIndexReady(status: SemanticSetupStatus) {
|
||||
return Boolean(
|
||||
status.ready &&
|
||||
!status.warming &&
|
||||
!status.embedding &&
|
||||
(status.indexing_ready ?? !status.downloading),
|
||||
);
|
||||
}
|
||||
|
||||
/** Promotion must receive its own automatic attempt even with unchanged counts. */
|
||||
export function semanticIndexKey(
|
||||
status: SemanticSetupStatus,
|
||||
folderRevision: number,
|
||||
) {
|
||||
return `${status.generation}:${status.pending}:${folderRevision}:${status.requested_coverage ?? "full_media"}`;
|
||||
}
|
||||
|
||||
export function semanticRetryVisible(status: SemanticSetupStatus) {
|
||||
return Boolean(status.media_model_error || (status.error && !status.ready));
|
||||
}
|
||||
|
||||
export function semanticMediaReady(status: SemanticSetupStatus) {
|
||||
// Legacy capture fixtures and older full-pair status did not expose this field.
|
||||
return status.media_model_ready ?? (status.ready && !status.downloading);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { semanticIndexReady, semanticIndexKey, semanticRetryVisible, semanticMediaReady } from '../src/semantic-setup-state.ts';
|
||||
import { AutomaticIndexGate, semanticAvailable } from '../src/search-state.ts';
|
||||
|
||||
const text = () => ({ ready: true, generation: 1, pending: 2, downloading: true,
|
||||
warming: false, embedding: false, error: null, indexing_ready: true,
|
||||
text_model_ready: true, media_model_ready: false, requested_coverage: 'text_only' });
|
||||
|
||||
test('warm verified text can index during media download but not concurrent warmup or indexing', () => {
|
||||
assert.equal(semanticIndexReady(text()), true);
|
||||
for (const patch of [{ready:false}, {warming:true}, {embedding:true}, {indexing_ready:false}])
|
||||
assert.equal(semanticIndexReady({...text(), ...patch}), false);
|
||||
});
|
||||
|
||||
test('media repair cannot authorize a text downgrade of an existing full index', () => {
|
||||
const status = {...text(), requested_coverage:'full_media', indexing_ready:false};
|
||||
assert.equal(semanticAvailable(status), true);
|
||||
assert.equal(semanticIndexReady(status), false);
|
||||
});
|
||||
|
||||
test('an incompatible or unprepared generation never claims semantic availability', () => {
|
||||
assert.equal(semanticAvailable({...text(), coverage:null}), false);
|
||||
assert.equal(semanticAvailable({...text(), coverage:'text_only'}), true);
|
||||
assert.equal(semanticAvailable({...text(), coverage:'full_media'}), true);
|
||||
});
|
||||
|
||||
test('coverage promotion gets one new automatic attempt without a repeated polling loop', () => {
|
||||
const gate = new AutomaticIndexGate();
|
||||
const early = semanticIndexKey(text(), 4);
|
||||
const promoted = semanticIndexKey({...text(), requested_coverage:'full_media'}, 4);
|
||||
assert.equal(gate.claim(early), true);
|
||||
assert.equal(gate.claim(early), false);
|
||||
assert.equal(gate.claim(promoted), true);
|
||||
assert.equal(gate.claim(promoted), false);
|
||||
});
|
||||
|
||||
test('failed media setup leaves text search available and retry visible', () => {
|
||||
const failed = {...text(), downloading:false, media_model_error:'Download failed'};
|
||||
assert.equal(semanticAvailable(failed), true);
|
||||
assert.equal(semanticRetryVisible(failed), true);
|
||||
assert.equal(semanticMediaReady(failed), false);
|
||||
assert.equal(semanticRetryVisible({...text(), error:'Query failed'}), false);
|
||||
assert.equal(semanticRetryVisible({...text(), ready:false, error:'Setup failed'}), true);
|
||||
});
|
||||
|
||||
test('legacy full-pair status remains blocked during download and ready after setup', () => {
|
||||
const legacy = {ready:true,generation:1,pending:2,downloading:true,warming:false,embedding:false,error:null};
|
||||
assert.equal(semanticIndexReady(legacy), false);
|
||||
assert.equal(semanticMediaReady(legacy), false);
|
||||
assert.equal(semanticIndexReady({...legacy,downloading:false}), true);
|
||||
assert.equal(semanticMediaReady({...legacy,downloading:false}), true);
|
||||
});
|
||||
Reference in new issue
Block a user