Keep prepared search readers warm and verify abrupt indexing recovery

This commit is contained in:
karl committed 2026-10-13 01:36:34 +02:00
1 parent 423a1ea57d
commit 6b78c8bb7f
17 files changed
+1011 -41

No files matched your search

+3 -1
View File
@@ -42,7 +42,9 @@ readiness without completed release gates and recorded evidence.
Keep running appropriate local checks and recording actual platform evidence. Keep running appropriate local checks and recording actual platform evidence.
- Agents own assigned paths and must coordinate shared interface changes. Do not commit other agents' unfinished work. - Agents own assigned paths and must coordinate shared interface changes. Do not commit other agents' unfinished work.
- Prepare indexes and the real Google query session in the background. Retain - Prepare indexes and the real Google query session in the background. Retain
warmed readers and keep keyword search usable during setup. Measure startup, warmed readers and keep keyword search usable during setup. Register prepared
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. actual first input, and warm p95 separately.
- UI should belong to each OS: native typography/chrome/dialogs, themes, - UI should belong to each OS: native typography/chrome/dialogs, themes,
keyboard access, reduced motion and high contrast. Brief transitions must not keyboard access, reduced motion and high contrast. Brief transitions must not
Generated
+1
View File
@@ -2247,6 +2247,7 @@ dependencies = [
"clap", "clap",
"futures-util", "futures-util",
"rmcp", "rmcp",
"rusqlite",
"searchi-assets", "searchi-assets",
"searchi-core", "searchi-core",
"searchi-service", "searchi-service",
+11
View File
@@ -80,6 +80,17 @@ SEARCHI_TEST_FFMPEG and SEARCHI_TEST_FFPROBE. Run the actual Google integration:
cargo test -p searchi-service --test publication_google_cpu --test incremental_google_cpu --test google_cpu --locked -- --ignored --nocapture --test-threads=1 cargo test -p searchi-service --test publication_google_cpu --test incremental_google_cpu --test google_cpu --locked -- --ignored --nocapture --test-threads=1
``` ```
Abrupt process recovery uses the same verified asset/helper variables:
```sh
cargo test -p searchi-cli --test crash_recovery --locked -- --ignored --nocapture --test-threads=1
cargo test -p searchi-service --test prepared_google_cpu --locked -- --ignored --nocapture --test-threads=1
```
These tests use actual Google CPU inference. Crash tests terminate only their
owned CLI process tree. Do not equate process termination with power-loss proof,
or tiny prepared-reader timings with large-corpus/native UI performance.
Completed-group reuse must preserve actual vector bytes and remap current source Completed-group reuse must preserve actual vector bytes and remap current source
citations. Verify exact parser-input provenance and reject stale extraction before citations. Verify exact parser-input provenance and reject stale extraction before
encoding; legacy records require a normal rescan. Do not substitute simulated vectors or treat zero encoder calls as a encoding; legacy records require a normal rescan. Do not substitute simulated vectors or treat zero encoder calls as a
@@ -3,9 +3,25 @@
"release": "autobuild-2026-10-12-13-12", "release": "autobuild-2026-10-12-13-12",
"version": "9.0.2-27-g7e93014c5b", "version": "9.0.2-27-g7e93014c5b",
"platforms": { "platforms": {
"windows-x86_64": {"archive": "ffmpeg-n9.0.2-27-g7e93014c5b-win64-lgpl-9.0.zip", "bytes": 171476229, "sha256": "104a019fe34c0747b208bde4842a0bac8b70a307cc2bb33aa4648fc15ddb2e39"}, "windows-x86_64": {
"windows-aarch64": {"archive": "ffmpeg-n9.0.2-27-g7e93014c5b-winarm64-lgpl-9.0.zip", "bytes": 118006941, "sha256": "68d3bac585b80b6f17e459eb4a203ed1f01b7635d4c611f1685de1ae65ca0987"}, "archive": "ffmpeg-n9.0.2-27-g7e93014c5b-win64-lgpl-9.0.zip",
"linux-x86_64": {"archive": "ffmpeg-n9.0.2-27-g7e93014c5b-linux64-lgpl-9.0.tar.xz", "bytes": 137901812, "sha256": "12445b38a4700ba1ae7c9c7e57062f602c927dc4187555bfadc9f3e2f75f05b0"}, "bytes": 171476229,
"linux-aarch64": {"archive": "ffmpeg-n9.0.2-27-g7e93014c5b-linuxarm64-lgpl-9.0.tar.xz", "bytes": 117008524, "sha256": "c9160ae8440c17c016fb2f6b3fcce4bb9d633a15845e908b2303948a7724f227"} "sha256": "104a019fe34c0747b208bde4842a0bac8b70a307cc2bb33aa4648fc15ddb2e39"
},
"windows-aarch64": {
"archive": "ffmpeg-n9.0.2-27-g7e93014c5b-winarm64-lgpl-9.0.zip",
"bytes": 118006941,
"sha256": "68d3bac585b80b6f17e459eb4a203ed1f01b7635d4c611f1685de1ae65ca0987"
},
"linux-x86_64": {
"archive": "ffmpeg-n9.0.2-27-g7e93014c5b-linux64-lgpl-9.0.tar.xz",
"bytes": 137901812,
"sha256": "12445b38a4700ba1ae7c9c7e57062f602c927dc4187555bfadc9f3e2f75f05b0"
},
"linux-aarch64": {
"archive": "ffmpeg-n9.0.2-27-g7e93014c5b-linuxarm64-lgpl-9.0.tar.xz",
"bytes": 117008524,
"sha256": "c9160ae8440c17c016fb2f6b3fcce4bb9d633a15845e908b2303948a7724f227"
}
} }
} }
@@ -25,6 +25,7 @@ pub struct SemanticState {
downloaded_bytes: AtomicU64, downloaded_bytes: AtomicU64,
error: Mutex<Option<String>>, error: Mutex<Option<String>>,
service: Mutex<Option<HybridSearch>>, service: Mutex<Option<HybridSearch>>,
prepared_graphs: searchi_service::PreparedGraphs,
media_tools: Option<searchi_media::FfmpegTools>, media_tools: Option<searchi_media::FfmpegTools>,
media_setup_error: Option<String>, media_setup_error: Option<String>,
} }
@@ -51,6 +52,7 @@ impl SemanticState {
downloaded_bytes: AtomicU64::new(0), downloaded_bytes: AtomicU64::new(0),
error: Mutex::new(None), error: Mutex::new(None),
service: Mutex::new(None), service: Mutex::new(None),
prepared_graphs: searchi_service::PreparedGraphs::default(),
media_tools, media_tools,
media_setup_error, media_setup_error,
} }
@@ -76,6 +78,9 @@ impl SemanticState {
let mut service = let mut service =
HybridSearch::open(database, &self.model_directory, &self.worker, threads) HybridSearch::open(database, &self.model_directory, &self.worker, threads)
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
service
.share_prepared_graphs(&self.prepared_graphs)
.map_err(|error| error.to_string())?;
service.warmup().map_err(|error| error.to_string())?; service.warmup().map_err(|error| error.to_string())?;
*self *self
.service .service
@@ -242,36 +247,15 @@ pub async fn index_embeddings(state: State<'_, AppState>) -> Result<EmbeddingRep
threads, threads,
) )
.and_then(|mut service| { .and_then(|mut service| {
service.share_prepared_graphs(&semantic.prepared_graphs)?;
let tools = semantic.media_tools.as_ref().ok_or_else(|| { let tools = semantic.media_tools.as_ref().ok_or_else(|| {
anyhow::anyhow!("The media decoder is missing from this installation. Repair or reinstall searchi.") anyhow::anyhow!("The media decoder is missing from this installation. Repair or reinstall searchi.")
})?; })?;
service.index_embeddings_with_media(tools, &searchi_media::MediaLimits::default()) service.index_embeddings_with_media(tools, &searchi_media::MediaLimits::default())
}) })
.map_err(|error| error.to_string()); .map_err(|error| error.to_string());
// Prepare the newly activated graph before advertising semantic readiness. // The publisher shares its prepared immutable ANN before activation.
// Keyword search stays available while the cached query session catches up. // Keep the query model and its cached vectors warm throughout indexing.
if result.is_ok() {
semantic.warmed.store(false, Ordering::Release);
semantic.warming.store(true, Ordering::Release);
let _warming_guard = WorkGuard(&semantic.warming);
let preparation = (|| {
let mut cached = semantic
.service
.lock()
.map_err(|_| "Semantic session is unavailable.".to_string())?;
let service = cached
.as_mut()
.ok_or_else(|| "Semantic query session has not been prepared.".to_string())?;
service.warmup().map_err(|error| error.to_string())
})();
semantic.warmed.store(preparation.is_ok(), Ordering::Release);
if let Err(error) = preparation {
semantic.record_error(Some(format!(
"The index was saved, but search preparation failed: {error}"
)));
return result;
}
}
semantic.record_error(result.as_ref().err().cloned()); semantic.record_error(result.as_ref().err().cloned());
result result
}) })
+1
View File
@@ -25,3 +25,4 @@ uuid = { version = "1", features = ["v4"] }
[dev-dependencies] [dev-dependencies]
tempfile = "3" tempfile = "3"
rusqlite = { version = "0.32", features = ["bundled"] }
+297
View File
@@ -0,0 +1,297 @@
//! Abrupt termination of an owned real CLI process tree, not a returned error.
//! Requires verified local Google assets/decoders. This is not power-loss proof.
use rusqlite::Connection;
use serde_json::Value;
use std::{
fs::{self, File},
io::Write,
path::{Path, PathBuf},
process::{Child, Command, Stdio},
thread,
time::{Duration, Instant},
};
struct OwnedCli {
child: Child,
}
impl OwnedCli {
fn spawn(mut command: Command, stdout: &Path, stderr: &Path) -> Self {
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
// Every worker/decoder inherits this exclusive process group.
command.process_group(0);
}
command
.env("SEARCHI_FORCE_CPU", "1")
.stdin(Stdio::null())
.stdout(File::create(stdout).unwrap())
.stderr(File::create(stderr).unwrap());
Self {
child: command.spawn().unwrap(),
}
}
fn terminate(&mut self) -> std::io::Result<()> {
if self.child.try_wait()?.is_some() {
return Ok(());
}
#[cfg(windows)]
let status = Command::new("taskkill.exe")
.args(["/PID", &self.child.id().to_string(), "/T", "/F"])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()?;
#[cfg(unix)]
let status = Command::new("/bin/kill")
.args(["-KILL", "--", &format!("-{}", self.child.id())])
.status()?;
if !status.success() {
return Err(std::io::Error::other(
"Could not terminate owned CLI process tree",
));
}
self.child.wait()?;
Ok(())
}
fn finish(&mut self, timeout: Duration, stderr: &Path) {
let deadline = Instant::now() + timeout;
loop {
if let Some(status) = self.child.try_wait().unwrap() {
assert!(
status.success(),
"CLI failed: {}",
fs::read_to_string(stderr).unwrap()
);
return;
}
assert!(Instant::now() < deadline, "Owned CLI exceeded deadline");
thread::sleep(Duration::from_millis(20));
}
}
}
impl Drop for OwnedCli {
fn drop(&mut self) {
let _ = self.terminate();
}
}
fn cli(db: &Path, command: &str) -> Command {
let mut cmd = Command::new(env!("CARGO_BIN_EXE_searchi"));
cmd.arg(command).arg("--db").arg(db);
cmd
}
fn run(command: Command, directory: &Path, label: &str) -> Value {
let stdout = directory.join(format!("{label}.stdout"));
let stderr = directory.join(format!("{label}.stderr"));
let mut child = OwnedCli::spawn(command, &stdout, &stderr);
child.finish(Duration::from_secs(900), &stderr);
serde_json::from_slice(&fs::read(stdout).unwrap()).unwrap()
}
fn embed(db: &Path, model: &Path, worker: &Path, ffmpeg: &Path, ffprobe: &Path) -> Command {
let mut command = cli(db, "embed");
command
.arg("--model-dir")
.arg(model)
.arg("--embedding-worker")
.arg(worker)
.arg("--cpu-threads")
.arg("1")
.arg("--ffmpeg")
.arg(ffmpeg)
.arg("--ffprobe")
.arg(ffprobe);
command
}
fn index(db: &Path, root: &Path, directory: &Path) {
let stdout = directory.join("index.stdout");
let stderr = directory.join("index.stderr");
let mut command = cli(db, "index");
command.arg("--root").arg(root);
let mut child = OwnedCli::spawn(command, &stdout, &stderr);
child.finish(Duration::from_secs(60), &stderr);
}
fn required(name: &str) -> PathBuf {
PathBuf::from(std::env::var_os(name).unwrap_or_else(|| panic!("{name} required")))
}
fn active(db: &Connection) -> i64 {
db.query_row(
"SELECT active_generation FROM semantic_meta WHERE singleton=1",
[],
|r| r.get(0),
)
.unwrap()
}
type CommittedChunk = (String, i64, String, Vec<u8>);
fn completed(db: &Connection, generation: i64) -> Vec<CommittedChunk> {
let mut statement = db.prepare("SELECT c.task_key,c.ordinal,c.path,c.vector FROM semantic_chunks c JOIN semantic_tasks t ON t.generation=c.generation AND t.task_key=c.task_key WHERE c.generation=?1 AND t.state='done' ORDER BY c.task_key,c.ordinal").unwrap();
statement
.query_map([generation], |r| {
Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?))
})
.unwrap()
.collect::<rusqlite::Result<_>>()
.unwrap()
}
fn write_audio(path: &Path) {
// Standard PCM WAV: three overlapping audio windows under normal limits.
let samples = 16_000_u32 * 65;
let mut wav = File::create(path).unwrap();
for bytes in [
b"RIFF".as_slice(),
&(36 + samples * 2).to_le_bytes(),
b"WAVEfmt ",
&16_u32.to_le_bytes(),
&1_u16.to_le_bytes(),
&1_u16.to_le_bytes(),
&16_000_u32.to_le_bytes(),
&32_000_u32.to_le_bytes(),
&2_u16.to_le_bytes(),
&16_u16.to_le_bytes(),
b"data",
&(samples * 2).to_le_bytes(),
] {
wav.write_all(bytes).unwrap();
}
let mut pcm = Vec::with_capacity(samples as usize * 2);
for n in 0..samples {
let value = ((n as f64 * 440.0 * std::f64::consts::TAU / 16_000.0).sin() * 4096.0) as i16;
pcm.extend(value.to_le_bytes());
}
wav.write_all(&pcm).unwrap();
}
#[test]
#[ignore = "Requires verified Google assets/CPU auto worker and FFmpeg; kills only owned CLI tree"]
fn real_google_cli_kill_preserves_active_and_resumes_only_complete_groups() {
assert_eq!(std::env::var("SEARCHI_FORCE_CPU").unwrap(), "1");
let model = required("SEARCHI_GEMMA_MODEL_DIR");
let worker = required("SEARCHI_GEMMA_WORKER");
let ffmpeg = required("SEARCHI_TEST_FFMPEG");
let ffprobe = required("SEARCHI_TEST_FFPROBE");
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("sources");
fs::create_dir(&root).unwrap();
fs::write(
root.join("agreement.txt"),
"The supplier partnership can end with sixty days written notice.",
)
.unwrap();
let path = temp.path().join("index.db");
index(&path, &root, temp.path());
run(
embed(&path, &model, &worker, &ffmpeg, &ffprobe),
temp.path(),
"initial",
);
let db = Connection::open(&path).unwrap();
db.busy_timeout(Duration::from_secs(2)).unwrap();
let original = active(&db);
let old_vectors = completed(&db, original);
fs::write(
root.join("retry.rs"),
"pub fn exponential_backoff(attempt: u32) -> u64 { 100 * 2_u64.pow(attempt.min(8)) }\n",
)
.unwrap();
write_audio(&root.join("tone.wav"));
index(&path, &root, temp.path());
let stdout = temp.path().join("interrupted.stdout");
let stderr = temp.path().join("interrupted.stderr");
let mut child = OwnedCli::spawn(
embed(&path, &model, &worker, &ffmpeg, &ffprobe),
&stdout,
&stderr,
);
let deadline = Instant::now() + Duration::from_secs(300);
let generation = loop {
assert!(
child.child.try_wait().unwrap().is_none(),
"CLI finished before interruption boundary: {}",
fs::read_to_string(&stderr).unwrap()
);
let boundary: Option<i64> = db.query_row("SELECT g.id FROM semantic_generations g WHERE g.id!=?1 AND (SELECT count(*) FROM semantic_tasks t WHERE t.generation=g.id AND t.state='done')>=2 AND EXISTS(SELECT 1 FROM semantic_tasks t JOIN semantic_chunks c ON c.generation=t.generation AND c.path=t.path AND c.passage_id=t.passage_id WHERE t.generation=g.id AND t.state='running' AND t.source_type='media' AND c.task_key IS NULL AND c.source_type!='text') LIMIT 1", [original], |r| r.get(0)).ok();
if let Some(generation) = boundary {
break generation;
}
assert!(
Instant::now() < deadline,
"No committed partial media boundary within deadline"
);
thread::sleep(Duration::from_millis(20));
};
child.terminate().unwrap();
assert_eq!(
active(&db),
original,
"Abrupt kill must not publish staging"
);
assert_eq!(completed(&db, original), old_vectors);
let saved = completed(&db, generation);
assert_eq!(saved.len(), 2);
let partial: usize = db.query_row("SELECT count(*) FROM semantic_chunks c JOIN semantic_tasks t ON t.generation=c.generation AND t.path=c.path AND t.passage_id=c.passage_id WHERE c.generation=?1 AND t.state='running' AND t.source_type='media' AND c.task_key IS NULL AND c.source_type!='text'",[generation],|r|r.get(0)).unwrap();
assert!(
partial > 0,
"Need actual committed incomplete Google vectors"
);
let mut old_search = cli(&path, "search");
old_search
.arg("How can we stop working with the vendor?")
.arg("--json")
.arg("--model-dir")
.arg(&model)
.arg("--embedding-worker")
.arg(&worker)
.arg("--cpu-threads")
.arg("1");
let previous_results = run(old_search, temp.path(), "old-reader");
assert_eq!(previous_results[0]["filename"], "agreement.txt");
assert_eq!(
active(&db),
original,
"Searching must not activate unfinished work"
);
let report = run(
embed(&path, &model, &worker, &ffmpeg, &ffprobe),
temp.path(),
"resumed",
);
assert_eq!(report["resumed"], true);
assert_eq!(report["generation"], generation);
assert_eq!(report["document_inference_calls"], 0);
assert_eq!(report["reused_sources"], 2);
assert_eq!(report["encoded_sources"], 1);
assert_eq!(
report["audio_inference_calls"], 3,
"All incomplete media windows must be encoded afresh"
);
assert_eq!(report["media_failures"], 0);
assert_eq!(active(&db), generation);
let after = completed(&db, generation);
for chunk in saved {
assert!(
after.contains(&chunk),
"Committed real vector bytes/citations changed"
);
}
let (total,unique):(usize,usize)=db.query_row("SELECT count(*),count(DISTINCT task_key||':'||ordinal) FROM semantic_chunks WHERE generation=?1",[generation],|r|Ok((r.get(0)?,r.get(1)?))).unwrap();
assert_eq!(
(total, unique),
(5, 5),
"Partial media chunks must not leak or duplicate"
);
let mut search = cli(&path, "search");
search
.arg("retry failed requests with exponential delays")
.arg("--code-only")
.arg("--json")
.arg("--model-dir")
.arg(&model)
.arg("--embedding-worker")
.arg(&worker)
.arg("--cpu-threads")
.arg("1");
let results = run(search, temp.path(), "citation");
assert_eq!(results[0]["filename"], "retry.rs");
assert_eq!(results[0]["source"]["kind"], "line");
assert_eq!(results[0]["source"]["number"], 1);
eprintln!("Real CPU CLI crash recovery: generation={generation}, committed_sources=2, discarded_partial_chunks={partial}, resumed_audio_calls=3");
}
+7
View File
@@ -1,6 +1,8 @@
//! Shared offline hybrid search using real Google embeddings and persisted, //! Shared offline hybrid search using real Google embeddings and persisted,
//! filtered HNSW retrieval. Production-scale validation remains a release gate. //! filtered HNSW retrieval. Production-scale validation remains a release gate.
mod media; mod media;
mod prepared;
pub use prepared::PreparedGraphs;
mod pipeline; mod pipeline;
mod routing; mod routing;
pub use routing::SemanticOrigin; pub use routing::SemanticOrigin;
@@ -172,6 +174,11 @@ pub struct HybridSearch {
} }
impl HybridSearch { impl HybridSearch {
/// Connect background publication to an existing warmed reader.
pub fn share_prepared_graphs(&mut self, graphs: &PreparedGraphs) -> Result<()> {
self.store.share_prepared_graphs(graphs)
}
/// Actual model/runtime/device reported by the persistent native worker. /// Actual model/runtime/device reported by the persistent native worker.
pub fn worker_info(&self) -> Option<searchi_semantic::WorkerInfo> { pub fn worker_info(&self) -> Option<searchi_semantic::WorkerInfo> {
self.embedder.worker_info() self.embedder.worker_info()
+161
View File
@@ -0,0 +1,161 @@
//! Process-local handoff of immutable graphs prepared by background indexing.
use anyhow::{ensure, Result};
use std::{
collections::VecDeque,
path::{Path, PathBuf},
sync::{Arc, Mutex},
};
use usearch::Index;
/// Share between services for the same database. No model or query data is shared.
#[derive(Clone, Default)]
pub struct PreparedGraphs(Arc<Mutex<State>>);
#[derive(Default)]
struct State {
identity: Option<(PathBuf, usize)>,
entries: VecDeque<Entry>,
}
#[derive(Clone)]
struct Entry {
generation: i64,
fingerprint: String,
checksum: String,
graph: Arc<Index>,
}
impl PreparedGraphs {
pub(crate) fn bind(&self, database: &Path, dimensions: usize) -> Result<()> {
let identity = (database.canonicalize()?, dimensions);
let mut state = self
.0
.lock()
.map_err(|_| anyhow::anyhow!("Prepared graph registry unavailable"))?;
if let Some(existing) = &state.identity {
ensure!(
*existing == identity,
"Prepared graphs belong to another database or dimension"
);
} else {
state.identity = Some(identity);
}
Ok(())
}
pub(crate) fn get(
&self,
generation: i64,
fingerprint: &str,
checksum: &str,
) -> Result<Option<Arc<Index>>> {
let state = self
.0
.lock()
.map_err(|_| anyhow::anyhow!("Prepared graph registry unavailable"))?;
Ok(state
.entries
.iter()
.find(|entry| {
entry.generation == generation
&& entry.fingerprint == fingerprint
&& entry.checksum == checksum
})
.map(|entry| entry.graph.clone()))
}
pub(crate) fn publish(
&self,
generation: i64,
fingerprint: String,
checksum: String,
graph: Arc<Index>,
) -> Result<Publication> {
let mut state = self
.0
.lock()
.map_err(|_| anyhow::anyhow!("Prepared graph registry unavailable"))?;
state.entries.push_back(Entry {
generation,
fingerprint,
checksum,
graph: graph.clone(),
});
while state.entries.len() > 2 {
state.entries.pop_front();
}
Ok(Publication {
registry: self.clone(),
graph,
committed: false,
})
}
}
/// Failed SQL publication removes only this publisher's entry.
pub(crate) struct Publication {
registry: PreparedGraphs,
graph: Arc<Index>,
committed: bool,
}
impl Publication {
pub(crate) fn commit(mut self) {
self.committed = true;
}
}
impl Drop for Publication {
fn drop(&mut self) {
if !self.committed {
if let Ok(mut state) = self.registry.0.lock() {
state
.entries
.retain(|entry| !Arc::ptr_eq(&entry.graph, &self.graph));
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn graph() -> Arc<Index> {
Arc::new(
Index::new(&usearch::IndexOptions {
dimensions: 768,
..Default::default()
})
.unwrap(),
)
}
#[test]
fn identity_checksum_and_rollback_are_enforced() -> Result<()> {
let directory = tempfile::tempdir()?;
let database = directory.path().join("index.db");
std::fs::write(&database, [])?;
let other = directory.path().join("other.db");
std::fs::write(&other, [])?;
let registry = PreparedGraphs::default();
registry.bind(&database, 768)?;
assert!(registry.bind(&other, 768).is_err());
assert!(registry.bind(&database, 256).is_err());
let active = graph();
registry
.publish(1, "policy".into(), "one".into(), active.clone())?
.commit();
assert!(registry.get(1, "policy", "wrong")?.is_none());
assert!(registry.get(1, "wrong", "one")?.is_none());
{
let _pending = registry.publish(2, "policy".into(), "two".into(), graph())?;
assert!(registry.get(2, "policy", "two")?.is_some());
}
assert!(registry.get(2, "policy", "two")?.is_none());
assert!(Arc::ptr_eq(
&active,
&registry.get(1, "policy", "one")?.unwrap()
));
registry
.publish(2, "policy".into(), "two".into(), graph())?
.commit();
registry
.publish(3, "policy".into(), "three".into(), graph())?
.commit();
assert_eq!(registry.0.lock().unwrap().entries.len(), 2);
assert!(registry.get(1, "policy", "one")?.is_none());
assert_eq!(active.dimensions(), 768); // Eviction never invalidates a retained reader.
Ok(())
}
}
+133 -2
View File
@@ -45,7 +45,8 @@ pub(crate) struct Store {
connection: Connection, connection: Connection,
lock_path: PathBuf, lock_path: PathBuf,
dimensions: usize, dimensions: usize,
ann_cache: Option<(i64, Index)>, ann_cache: Option<(i64, Arc<Index>)>,
prepared_graphs: crate::PreparedGraphs,
eligibility_cache: Option<EligibilityCache>, eligibility_cache: Option<EligibilityCache>,
} }
@@ -68,6 +69,19 @@ thread_local! { static HASH_CALLS:std::cell::Cell<usize>=const {std::cell::Cell:
thread_local! { static MEDIA_STAT_CALLS:std::cell::Cell<usize>=const {std::cell::Cell::new(0)}; } thread_local! { static MEDIA_STAT_CALLS:std::cell::Cell<usize>=const {std::cell::Cell::new(0)}; }
impl Store { impl Store {
pub(crate) fn share_prepared_graphs(&mut self, graphs: &crate::PreparedGraphs) -> Result<()> {
graphs.bind(
Path::new(
self.connection
.path()
.context("Database path unavailable")?,
),
self.dimensions,
)?;
self.prepared_graphs = graphs.clone();
Ok(())
}
pub(crate) fn active_fingerprint(&self) -> Result<Option<String>> { pub(crate) fn active_fingerprint(&self) -> Result<Option<String>> {
Ok(self.connection.query_row("SELECT g.fingerprint FROM semantic_meta m JOIN semantic_generations g ON g.id=m.active_generation WHERE m.singleton=1",[],|r|r.get(0)).optional()?) Ok(self.connection.query_row("SELECT g.fingerprint FROM semantic_meta m JOIN semantic_generations g ON g.id=m.active_generation WHERE m.singleton=1",[],|r|r.get(0)).optional()?)
} }
@@ -170,6 +184,7 @@ impl Store {
lock_path, lock_path,
dimensions, dimensions,
ann_cache: None, ann_cache: None,
prepared_graphs: crate::PreparedGraphs::default(),
eligibility_cache: None, eligibility_cache: None,
}) })
} }
@@ -328,6 +343,16 @@ impl Store {
// before acquiring the write transaction. The revision check below // before acquiring the write transaction. The revision check below
// still closes races with indexed changes after this validation. // still closes races with indexed changes after this validation.
validate_sources()?; validate_sources()?;
let fingerprint: String = self.connection.query_row(
"SELECT fingerprint FROM semantic_generations WHERE id=?1",
[generation],
|row| row.get(0),
)?;
let ann = Arc::new(ann);
// Readers observing SQL publication already have a prepared immutable graph.
let publication =
self.prepared_graphs
.publish(generation, fingerprint, checksum.clone(), ann.clone())?;
let transaction = self let transaction = self
.connection .connection
.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?; .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
@@ -366,6 +391,7 @@ impl Store {
transaction.execute_batch("UPDATE semantic_meta SET dirty_floor=max(dirty_floor,corpus_revision-100000) WHERE singleton=1; transaction.execute_batch("UPDATE semantic_meta SET dirty_floor=max(dirty_floor,corpus_revision-100000) WHERE singleton=1;
DELETE FROM semantic_dirty_paths WHERE revision<(SELECT dirty_floor FROM semantic_meta WHERE singleton=1);")?; DELETE FROM semantic_dirty_paths WHERE revision<(SELECT dirty_floor FROM semantic_meta WHERE singleton=1);")?;
transaction.commit()?; transaction.commit()?;
publication.commit();
self.ann_cache = Some((generation, ann)); self.ann_cache = Some((generation, ann));
self.eligibility_cache = None; self.eligibility_cache = None;
Ok(()) Ok(())
@@ -505,6 +531,23 @@ impl Store {
return Ok(Vec::new()); return Ok(Vec::new());
}; };
trace.end(generation_span, Default::default()); trace.end(generation_span, Default::default());
if !self
.ann_cache
.as_ref()
.is_some_and(|(generation, _)| *generation == active)
{
let prepared = trace.timed("semantic.prepared_graph_lookup", parent, |_, _| {
let checksum: String = snapshot.query_row(
"SELECT ann_sha256 FROM semantic_generations WHERE id=?1",
[active],
|row| row.get(0),
)?;
self.prepared_graphs.get(active, fingerprint, &checksum)
})?;
if let Some(graph) = prepared {
self.ann_cache = Some((active, graph));
}
}
let ann_load = trace.begin("semantic.ann_load", parent); let ann_load = trace.begin("semantic.ann_load", parent);
trace.counter( trace.counter(
ann_load, ann_load,
@@ -553,7 +596,7 @@ impl Store {
ann.reserve_capacity_and_threads(ann.size().max(1), 1)?; ann.reserve_capacity_and_threads(ann.size().max(1), 1)?;
trace.counter(deserialize_span, "chunks", ann.size() as u64); trace.counter(deserialize_span, "chunks", ann.size() as u64);
trace.end(deserialize_span, Default::default()); trace.end(deserialize_span, Default::default());
self.ann_cache = Some((active, ann)); self.ann_cache = Some((active, Arc::new(ann)));
} }
trace.end(ann_load, Default::default()); trace.end(ann_load, Default::default());
let filter_span = trace.begin("semantic.filter_normalization", parent); let filter_span = trace.begin("semantic.filter_normalization", parent);
@@ -1417,6 +1460,94 @@ mod tests {
Ok(()) Ok(())
} }
#[test]
fn failed_sql_activation_removes_prepared_pending_and_preserves_live_reader() -> Result<()> {
// Storage publication invariant only; real Google coverage is a separate integration.
let directory = tempfile::tempdir()?;
let root = directory.path().join("docs");
std::fs::create_dir(&root)?;
std::fs::write(root.join("notes.txt"), "original relevant content")?;
let database = directory.path().join("index.db");
let mut lexical = SearchIndex::open(&database)?;
lexical.index_folder(&root)?;
let passage = lexical.passage_batch(0, 1)?.remove(0);
let windows = [TextWindow {
text: passage.text.clone(),
start_byte: 0,
end_byte: passage.text.len(),
}];
let mut vector = vec![0.0; DIMENSIONS];
vector[0] = 1.0;
let policy = "test-storage-publication";
let graphs = crate::PreparedGraphs::default();
let mut writer = Store::open(&database, DIMENSIONS)?;
writer.share_prepared_graphs(&graphs)?;
let active = writer.begin(policy)?;
writer.insert(active, &passage, 0, &windows, &[vector.clone()])?;
writer.activate(active)?;
let checksum: String = writer.connection.query_row(
"SELECT ann_sha256 FROM semantic_generations WHERE id=?1",
[active],
|r| r.get(0),
)?;
let mut reader = Store::open(&database, DIMENSIONS)?;
reader.share_prepared_graphs(&graphs)?;
assert_eq!(
reader
.search(&vector, policy, 10, &SearchOptions::default())?
.len(),
1
);
let retained = reader.ann_cache.as_ref().unwrap().1.clone();
let pending = writer.begin(policy)?;
writer.insert(pending, &passage, 0, &windows, &[vector.clone()])?;
let prepared = writer.build_ann(pending)?;
let mut serialized = vec![0; prepared.serialized_length()];
prepared.save_to_buffer(&mut serialized)?;
let pending_checksum = format!("{:x}", Sha256::digest(&serialized));
writer.connection.execute_batch("CREATE TRIGGER reject_activation BEFORE UPDATE OF active_generation ON semantic_meta BEGIN SELECT RAISE(ABORT,'activation rejected'); END")?;
assert!(writer
.activate(pending)
.unwrap_err()
.to_string()
.contains("activation rejected"));
assert_eq!(
writer.connection.query_row(
"SELECT active_generation FROM semantic_meta WHERE singleton=1",
[],
|r| r.get::<_, i64>(0)
)?,
active
);
assert!(graphs.get(pending, policy, &pending_checksum)?.is_none());
assert!(Arc::ptr_eq(
&retained,
&graphs.get(active, policy, &checksum)?.unwrap()
));
assert_eq!(
reader
.search(&vector, policy, 10, &SearchOptions::default())?
.len(),
1
);
assert!(Arc::ptr_eq(
&retained,
&reader.ann_cache.as_ref().unwrap().1
));
writer
.connection
.execute_batch("DROP TRIGGER reject_activation")?;
writer.activate(pending)?;
assert_eq!(
reader
.search(&vector, policy, 10, &SearchOptions::default())?
.len(),
1
);
assert_eq!(reader.ann_cache.as_ref().unwrap().0, pending);
Ok(())
}
#[test] #[test]
fn generation_persists_failed_staging_preserves_active_and_changes_hide_stale_vectors( fn generation_persists_failed_staging_preserves_active_and_changes_hide_stale_vectors(
) -> Result<()> { ) -> Result<()> {
@@ -0,0 +1,87 @@
//! Genuine Google CPU proof for background prepared-graph publication.
use anyhow::Result;
use searchi_core::SearchIndex;
use searchi_service::{HybridSearch, PreparedGraphs, SearchOptions};
use std::fs;
#[test]
#[ignore = "Requires verified Google models and normal worker; CPU only"]
fn prepared_publication_keeps_warmed_query_reader_hot() -> Result<()> {
assert_eq!(std::env::var("SEARCHI_FORCE_CPU")?, "1");
let model = std::env::var_os("SEARCHI_GEMMA_MODEL_DIR").expect("Verified models required");
let worker = std::env::var_os("SEARCHI_GEMMA_WORKER").expect("Normal worker required");
let temp = tempfile::tempdir()?;
let root = temp.path().join("documents");
fs::create_dir(&root)?;
let file = root.join("supplier.txt");
fs::write(
&file,
"The supplier agreement 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 indexer = HybridSearch::open(&db, &model, &worker, 1)?;
indexer.share_prepared_graphs(&graphs)?;
indexer.index_embeddings()?;
let mut reader = HybridSearch::open(&db, &model, &worker, 1)?;
reader.share_prepared_graphs(&graphs)?;
reader.warmup()?;
let query = "How can we end our supplier partnership?";
reader.search(query, 10, &SearchOptions::default())?;
fs::write(
&file,
"The supplier agreement ends with ninety days written notice.",
)?;
lexical.index_folder(&root)?;
indexer.index_embeddings()?;
drop(indexer); // The shared graph outlives the background model worker.
let response = reader.search_measured(query, 10, &SearchOptions::default(), true)?;
assert_eq!(response.results[0].filename, "supplier.txt");
assert!(response.results[0].snippet.contains("ninety days"));
let trace = response.metrics.trace.expect("Detailed trace required");
assert!(
trace
.iter()
.any(|span| span.counters.get("query_cache_hit") == Some(&1)),
"The retained query model must preserve its real-vector LRU across publication"
);
let loads: Vec<_> = trace
.iter()
.filter(|span| span.name == "semantic.ann_load")
.collect();
assert!(!loads.is_empty());
assert!(loads
.iter()
.all(|span| span.counters.get("cache_hit") == Some(&1)));
assert!(!trace.iter().any(|span| matches!(
span.name.as_str(),
"semantic.ann_blob_read" | "semantic.ann_checksum" | "semantic.ann_deserialize"
)));
let unseen = reader.search_measured(
"What notice is required for the contract to end?",
10,
&SearchOptions::default(),
true,
)?;
assert!(unseen.results[0].snippet.contains("ninety days"));
let unseen_trace = unseen.metrics.trace.expect("Unseen query trace required");
assert!(
unseen_trace
.iter()
.any(|span| span.counters.get("query_cache_hit") == Some(&0)),
"A distinct foreground input must execute genuine query inference"
);
assert!(unseen_trace
.iter()
.filter(|span| span.name == "semantic.ann_load")
.all(|span| span.counters.get("cache_hit") == Some(&1)));
assert!(!unseen_trace.iter().any(|span| matches!(
span.name.as_str(),
"semantic.ann_blob_read" | "semantic.ann_checksum" | "semantic.ann_deserialize"
)));
eprintln!("Prepared reader CPU fixture: retained_query_ms={:.3}, unseen_query_ms={:.3}; one document, not a scale benchmark",
response.metrics.total_ms, unseen.metrics.total_ms);
Ok(())
}
+10 -2
View File
@@ -201,10 +201,13 @@ signing/notarization requires Apple tooling and credentials. See the
### Windows archive for source installations ### Windows archive for source installations
After staging the CPU worker and media helpers, build an embedded-frontend Stage the normal hardware-detecting worker and media helpers, then build an embedded-frontend
desktop binary and collect its runtime files: desktop binary and collect its runtime files:
```powershell ```powershell
python tools/embeddinggemma2/build_runtime.py --backend auto --jobs 2
python tools/embeddinggemma2/stage_runtime.py --backend auto
python tools/media-runtime/provision.py --stage
Set-Location apps/desktop Set-Location apps/desktop
npm run tauri -- build --no-bundle npm run tauri -- build --no-bundle
Set-Location ../.. Set-Location ../..
@@ -223,6 +226,10 @@ using CPU and OS capabilities, and
the verified Vulkan loader DLL and its license; GPU availability is detected at the verified Vulkan loader DLL and its license; GPU availability is detected at
runtime, with CPU fallback. The SDK is used only to compile the app. runtime, with CPU fallback. The SDK is used only to compile the app.
All runtime DLLs are listed by size and SHA-256 in `runtime-libraries.json`. All runtime DLLs are listed by size and SHA-256 in `runtime-libraries.json`.
Packaging also verifies the original pinned decoder ZIP and compares staged
FFmpeg/FFprobe, notices and manifest against it. Keep that verified archive in
`.searchi/media-runtime` while preparing the package; changed staged helpers
or notices are rejected before replacing a previous archive.
Staging and packaging reject missing or changed listed dependencies before Staging and packaging reject missing or changed listed dependencies before
replacing output. CPU-only VM smoke tests can also omit optimized modules to replacing output. CPU-only VM smoke tests can also omit optimized modules to
exercise the packaged baseline; this does not certify older physical hardware. exercise the packaged baseline; this does not certify older physical hardware.
@@ -250,6 +257,7 @@ need both verified model files. See [semantic bounds and evidence](SEMANTIC.md)
for current corpus, localization, and performance limitations. Keep signing keys, for current corpus, localization, and performance limitations. Keep signing keys,
repository tokens, and model credentials outside source control. repository tokens, and model credentials outside source control.
FFmpeg provisioning supports manifest-pinned Windows/Linux x86-64 and ARM64 artifacts; FFmpeg provisioning supports manifest-pinned Windows/Linux x86-64 and ARM64 artifacts;
only Windows x86-64 has executed here. macOS decoder packaging remains a gate. Windows and Linux x86-64 startup and CPU decoding have executed here;
other architectures and macOS decoder packaging remain gates.
See [media runtime provenance](../tools/media-runtime/README.md) and See [media runtime provenance](../tools/media-runtime/README.md) and
[measured media limitations](MEDIA-QUALITY.md). [measured media limitations](MEDIA-QUALITY.md).
+16
View File
@@ -166,3 +166,19 @@ include the active VM-contention caveat: native photo inference and other work
may overlap. This is an absolute small-corpus comparison, not an old-baseline may overlap. This is an absolute small-corpus comparison, not an old-baseline
regression claim or proof of model/media tracing overhead. The extra cost regression claim or proof of model/media tracing overhead. The extra cost
supports keeping detailed mode optional rather than claiming it is free. supports keeping detailed mode optional rather than claiming it is free.
## Prepared-reader publication
The desktop shares a bounded process-local prepared ANN between indexing and
its retained query service. The graph is registered before SQL activation and
matched by canonical database, dimensions, generation, policy and checksum.
`semantic.prepared_graph_lookup` measures that handoff; a successful handoff
sets `semantic.ann_load` cache_hit to one, without blob read/checksum/deserialization.
The query model and real-vector LRU remain warm. Eligibility refresh and query
inference still have costs and are measured independently.
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.
+100
View File
@@ -0,0 +1,100 @@
# Process recovery and prepared-reader handoff
2026-10-13 development validation. Production release gates remain open.
## Abrupt process termination
`crates/searchi-cli/tests/crash_recovery.rs` invokes the real installed-layout
CLI adapter with verified Google EmbeddingGemma 2 assets, one CPU thread and
actual FFmpeg helpers. It creates two document/code source groups and a
65-second PCM WAV with three normal audio windows. It polls SQLite for both
completed document groups plus a committed, unowned chunk belonging to the
running media task, then terminates only its owned process tree.
Windows uses PID-scoped `taskkill /T /F`; Unix assigns a dedicated process group
and kills that group. Cleanup does not kill processes by name and does not panic
during test unwinding. No user app/settings/index is used.
Assertions verify that the previous active generation and completed vector
bytes remain intact. Restart resumes the same generation, makes zero document
encoder calls, re-encodes all three windows of the unfinished audio source,
and publishes five unique chunks without leaked partial vectors. A real
Google CodeRetrieval query checks the current source line citation.
Windows and native Linux actual runs passed in 157.86 s and 233.65 s. These are suite elapsed times with compiler
contention, not a search benchmark. Power loss, arbitrary batch boundaries and
all crash scenarios are not established by one process-termination case.
## Prepared graphs and warmed query model
The desktop already used separate background and query model workers, but
publication could send the next foreground request through blob reading,
SHA verification and ANN deserialization. It also withdrew readiness and
repeated query warmup while holding the retained session mutex.
The publisher now shares its immutable prepared `Arc<usearch::Index>` before
SQL activation through a process-local `PreparedGraphs` handle. That handle
binds to the canonical database and vector dimension; entries match generation,
full policy fingerprint and persisted ANN checksum. At most two registry entries
are retained. Failed SQL activation removes only its unpublished entry; readers
retain their own Arc when an old registry entry is evicted.
The desktop query worker keeps its model and query-vector LRU ready throughout
publication. Detailed traces include `semantic.prepared_graph_lookup`; the new
active graph is a cache hit instead of foreground blob loading. Existing source
eligibility, strict filtering and generation snapshot checks still apply.
Storage regressions cover identity/dimension/checksum mismatches, bounded
eviction, retained-reader lifetime and SQL activation rejection/retry. The real
Google CPU test drops the background worker, searches the updated document and
asserts no blob-read/checksum/deserialization spans. It verifies both a retained
query-vector hit and an unseen query with actual neural inference.
Windows one-document fixture: retained query 4.389 ms, unseen query 111.086 ms,
29.05 s suite elapsed. These measure the shared engine request in debug mode,
not native UI first input, application startup, a representative corpus or p95.
The same native Linux fixture passed: retained query 2.107 ms, unseen query
79.614 ms, suite 21.98 s. Different cache/contention conditions prevent treating
these numbers as Windows-versus-Linux comparative performance.
## Windows package media provenance
The packager now verifies the pinned original decoder ZIP size and checksum,
then compares the staged manifest, helpers and notices with bounded archive
members. Valid PE headers and a freshly recorded package checksum are not
accepted as decoder provenance. Fifteen packaging tests cover changed helpers,
notices, manifests, archive members and changes during ZIP writing; failure
preserves the previous output. Media staging was refreshed from the verified
cached archive before packaging.
Current embedded-frontend Windows release built successfully. Its 145,265,448-byte unsigned ZIP
contains 39 verified files; optimized and baseline-only packaged CPU inference
and decoder startup passed. Archive SHA-256:
`cf7953827be90255f182932fe53c16faf76701098d2636c8860b6ed4e0eb6ec4`.
Both worker smoke tests request auto with the GPU provider disabled only in the
child process. Actual GPU, native UI interaction and clean-machine installation
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
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,
installers/upgrades/signatures and macOS packaging remain open.
## Executed checks
- Windows and native Linux workspace tests: 106 passed each, 18 ignored.
- Windows native desktop unit suite: 17 passed.
- Strict Windows/Linux workspace and Windows desktop Clippy plus both formatting checks passed.
- Real Google CPU process-kill and prepared-reader regressions passed on both OSes.
- Windows packager tests: 15 passed. Current embedded-frontend release build,
archive integrity, packaged optimized/baseline CPU inference and decoder startup
passed; no accelerator was used.
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.
+9 -3
View File
@@ -30,7 +30,7 @@ Status: implementation started; no production release yet. Windows VM tests use
- [ ] Versioned embeddings, ANN index, exact-match priority and hybrid fusion. - [ ] Versioned embeddings, ANN index, exact-match priority and hybrid fusion.
- [ ] English/German relevance corpus and measured top-10 recall. - [ ] English/German relevance corpus and measured top-10 recall.
- [ ] Atomic model/index replacement and resumable persistent work queues. - [ ] Atomic model/index replacement and resumable persistent work queues.
- [ ] Completed-source manifests with title/model/window policy keys and - [x] Completed-source manifests with title/model/window policy keys and
citation remapping; reuse only verified complete document/media groups. citation remapping; reuse only verified complete document/media groups.
- [ ] Durable generation tasks, crash resume and final corpus reconciliation; - [ ] Durable generation tasks, crash resume and final corpus reconciliation;
see [incremental semantic design](INCREMENTAL-SEMANTIC.md). see [incremental semantic design](INCREMENTAL-SEMANTIC.md).
@@ -100,7 +100,7 @@ Status: implementation started; no production release yet. Windows VM tests use
Warm p95 alone does not satisfy the first-use requirement. Warm p95 alone does not satisfy the first-use requirement.
- [ ] Test migrations, interrupted work, invalid documents, inaccessible/offline roots and cloud placeholders. - [ ] Test migrations, interrupted work, invalid documents, inaccessible/offline roots and cloud placeholders.
- [ ] Automated native build matrix and documented supported cross-compilation paths. - [ ] Automated native build matrix and documented supported cross-compilation paths.
- [x] Native Linux headless workspace tests: 104 passed, 16 explicitly ignored. - [x] Native Linux headless workspace tests: 106 passed, 18 explicitly ignored.
- [x] Native Linux Google text/code/media reference parity and service - [x] Native Linux Google text/code/media reference parity and service
lifecycle/rebuild recovery: seven genuine CPU integration tests passed. lifecycle/rebuild recovery: seven genuine CPU integration tests passed.
- [x] Unsigned Linux x86-64 headless archive: verify extracted checksums/modes, - [x] Unsigned Linux x86-64 headless archive: verify extracted checksums/modes,
@@ -136,6 +136,9 @@ Audit and measured priorities: [EMBEDDINGGEMMA2-AUDIT.md](EMBEDDINGGEMMA2-AUDIT.
paired repeated-query/filter-change results with unchanged retrieval evidence. paired repeated-query/filter-change results with unchanged retrieval evidence.
- [x] Bounded provider-scoped actual-query vector LRU with accurate cache traces; - [x] Bounded provider-scoped actual-query vector LRU with accurate cache traces;
preserve filtering, source freshness, failures and actual warmup probes. preserve filtering, source freshness, failures and actual warmup probes.
- [x] Bounded shared prepared-ANN handoff before SQL activation; retain query
model/LRU and readiness across publication. Actual CPU first new query has
no foreground graph load; [recovery/handoff evidence](RECOVERY-AND-HANDOFF.md).
- [x] Reduce semantic settle delay to 120 ms and retain old ready generation - [x] Reduce semantic settle delay to 120 ms and retain old ready generation
during rebuilding; test initial-generation and activation readiness boundaries. during rebuilding; test initial-generation and activation readiness boundaries.
- [x] Bound whole IPC write/read deadlines and recover from a nonreading helper; - [x] Bound whole IPC write/read deadlines and recover from a nonreading helper;
@@ -153,7 +156,10 @@ Audit and measured priorities: [EMBEDDINGGEMMA2-AUDIT.md](EMBEDDINGGEMMA2-AUDIT.
on normal rescans; stale/missing agent excerpts fail closed. on normal rescans; stale/missing agent excerpts fail closed.
- [x] Actual Google CPU same-metadata text mutation before/during inference: - [x] Actual Google CPU same-metadata text mutation before/during inference:
reject publication, rescan without metadata workarounds and reuse unaffected vectors. reject publication, rescan without metadata workarounds and reuse unaffected vectors.
- [ ] Process-kill/concurrent deletion/media mutation, component-policy reuse and - [x] Abrupt owned CLI termination after a committed partial audio chunk;
resume completed documents and reencode unfinished media without duplicates.
Process termination does not prove power-loss durability.
- [ ] Concurrent deletion/media mutation, component-policy reuse and
indexing/foreground measurements. indexing/foreground measurements.
See [INCREMENTAL-SEMANTIC.md](INCREMENTAL-SEMANTIC.md). See [INCREMENTAL-SEMANTIC.md](INCREMENTAL-SEMANTIC.md).
- [ ] Benchmark genuine token-budgeted multi-sequence batching and isolation. - [ ] Benchmark genuine token-budgeted multi-sequence batching and isolation.
+76 -5
View File
@@ -10,11 +10,80 @@ import os
import re import re
from pathlib import Path from pathlib import Path
import struct import struct
import stat
import tempfile import tempfile
import zipfile import zipfile
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
CRT_LIBRARIES = {"concrt140.dll", "msvcp140.dll", "msvcp140_1.dll", "msvcp140_2.dll", "msvcp140_atomic_wait.dll", "msvcp140_codecvt_ids.dll", "vccorlib140.dll", "vcruntime140.dll", "vcruntime140_1.dll", "vcruntime140_threads.dll"} CRT_LIBRARIES = {"concrt140.dll", "msvcp140.dll", "msvcp140_1.dll", "msvcp140_2.dll", "msvcp140_atomic_wait.dll", "msvcp140_codecvt_ids.dll", "vccorlib140.dll", "vcruntime140.dll", "vcruntime140_1.dll", "vcruntime140_threads.dll"}
MAX_MEMBER = 256 * 1024 * 1024
MAX_MEDIA_TOTAL = 1024 * 1024 * 1024
def digest(path):
with path.open("rb") as stream:
return hashlib.file_digest(stream, "sha256").hexdigest()
def media_provenance(root, resources, architecture):
"""Compare staged helpers/notices with the checksum-pinned upstream ZIP."""
manifest_path = root / "tools/media-runtime/manifest.json"
if not manifest_path.is_file() or manifest_path.stat().st_size > 65536:
raise ValueError("Required pinned media manifest is missing or oversized")
manifest_bytes = manifest_path.read_bytes()
manifest = json.loads(manifest_bytes)
artifact = manifest["platforms"][f"windows-{architecture}"]
name = artifact.get("archive")
if not isinstance(name, str) or not re.fullmatch(r"[A-Za-z0-9_.-]+\.zip", name):
raise ValueError("Invalid media archive name")
archive = root / ".searchi/media-runtime" / name
if (not archive.is_file() or archive.is_symlink() or archive.absolute() != archive.resolve()
or archive.stat().st_size != artifact["bytes"] or digest(archive) != artifact["sha256"]):
raise ValueError("Media archive provenance mismatch")
staged_manifest = resources / "media-runtime/manifest.json"
if (not staged_manifest.is_file() or staged_manifest.stat().st_size != len(manifest_bytes)
or staged_manifest.read_bytes() != manifest_bytes):
raise ValueError("Staged media manifest differs from pinned manifest")
selected = {"media-runtime/manifest.json": {"bytes": len(manifest_bytes), "sha256": hashlib.sha256(manifest_bytes).hexdigest()}}
selected_keys = {name.casefold() for name in selected}
with zipfile.ZipFile(archive) as package:
members = package.infolist()
if len(members) > 10000:
raise ValueError("Media archive exceeds member ceiling")
total = 0
for member in members:
total += member.file_size
if member.file_size < 0 or member.file_size > MAX_MEMBER or total > MAX_MEDIA_TOTAL:
raise ValueError("Media archive exceeds size ceiling")
parts = member.filename.replace("\\", "/").split("/")
basename = parts[-1]
if basename in {"ffmpeg.exe", "ffprobe.exe"}:
target = "media-runtime/bin/" + basename
elif basename.lower().startswith(("license", "copying")) or basename.lower() in {"readme.txt", "readme.md"}:
if any(part in {"", ".", ".."} or ":" in part for part in parts):
raise ValueError("Unsafe media notice path")
target = "media-runtime/licenses/" + "/".join(parts)
else:
continue
mode = member.external_attr >> 16
if (any(part in {"", ".", ".."} or ":" in part for part in parts)
or member.is_dir() or stat.S_IFMT(mode) not in {0, stat.S_IFREG} or member.flag_bits & 1
or member.file_size <= 0 or target.casefold() in selected_keys):
raise ValueError("Invalid or duplicate media member")
path = resources / target
if (not path.is_file() or path.is_symlink() or path.absolute() != path.resolve()
or not path.resolve().is_relative_to(resources.resolve())):
raise ValueError(f"Staged media helper/notice missing or outside resources: {target}")
with package.open(member) as stream:
checksum = hashlib.file_digest(stream, "sha256").hexdigest()
if path.stat().st_size != member.file_size or digest(path) != checksum:
raise ValueError(f"Media helper/notice differs from pinned archive: {target}")
selected[target] = {"bytes": member.file_size, "sha256": checksum}
selected_keys.add(target.casefold())
if (not {"media-runtime/bin/ffmpeg.exe", "media-runtime/bin/ffprobe.exe"}.issubset(selected)
or not any(name.startswith("media-runtime/licenses/") for name in selected)):
raise ValueError("Media archive lacks required helpers or notices")
return selected
def pe_machine(path): def pe_machine(path):
@@ -34,6 +103,10 @@ def pe_machine(path):
def package(root, executable, output): def package(root, executable, output):
resources = root / "apps/desktop/src-tauri/resources" resources = root / "apps/desktop/src-tauri/resources"
machine = pe_machine(executable)
architecture = {0x8664: "x86_64", 0xAA64: "aarch64"}.get(machine)
if architecture is None:
raise ValueError("Unsupported Windows architecture")
inventory = resources / "runtime-libraries.json" inventory = resources / "runtime-libraries.json"
if not inventory.is_file() or inventory.stat().st_size > 65536: if not inventory.is_file() or inventory.stat().st_size > 65536:
raise ValueError("Required runtime dependency inventory is missing or oversized") raise ValueError("Required runtime dependency inventory is missing or oversized")
@@ -69,6 +142,8 @@ def package(root, executable, output):
raise ValueError(f"Runtime checksum mismatch: {name}") raise ValueError(f"Runtime checksum mismatch: {name}")
if "bytes" in record and path.stat().st_size != record["bytes"]: if "bytes" in record and path.stat().st_size != record["bytes"]:
raise ValueError(f"Runtime size mismatch: {name}") raise ValueError(f"Runtime size mismatch: {name}")
media = media_provenance(root, resources, architecture)
expected.update(media)
selected = [(executable, "searchi.exe"), (root / "LICENSE", "LICENSE.txt")] selected = [(executable, "searchi.exe"), (root / "LICENSE", "LICENSE.txt")]
for name in ["searchi-gemma-worker.exe", "LLAMA-LICENSE.txt", "MSVC-REDISTRIBUTION.txt", "vulkan-1.dll", "VULKAN-LICENSE.txt", "media-runtime/manifest.json", for name in ["searchi-gemma-worker.exe", "LLAMA-LICENSE.txt", "MSVC-REDISTRIBUTION.txt", "vulkan-1.dll", "VULKAN-LICENSE.txt", "media-runtime/manifest.json",
"media-runtime/bin/ffmpeg.exe", "media-runtime/bin/ffprobe.exe", "media-runtime/bin/ffmpeg.exe", "media-runtime/bin/ffprobe.exe",
@@ -77,17 +152,13 @@ def package(root, executable, output):
selected.append((inventory, inventory.name)) selected.append((inventory, inventory.name))
selected_names = {name for _, name in selected} selected_names = {name for _, name in selected}
selected.extend((resources / name, name) for name in expected if name not in selected_names) selected.extend((resources / name, name) for name in expected if name not in selected_names)
for directory in ["licenses", "media-runtime/licenses"]: for directory in ["licenses"]:
notices = list((resources / directory).rglob("*.txt")) notices = list((resources / directory).rglob("*.txt"))
if not notices: if not notices:
raise ValueError(f"Required notices are missing: {directory}") raise ValueError(f"Required notices are missing: {directory}")
selected_names = {name for _, name in selected} selected_names = {name for _, name in selected}
selected.extend((path, path.relative_to(resources).as_posix()) for path in notices selected.extend((path, path.relative_to(resources).as_posix()) for path in notices
if path.relative_to(resources).as_posix() not in selected_names) if path.relative_to(resources).as_posix() not in selected_names)
machine = pe_machine(executable)
architecture = {0x8664: "x86_64", 0xAA64: "aarch64"}.get(machine)
if architecture is None:
raise ValueError("Unsupported Windows architecture")
for path, name in selected: for path, name in selected:
if not path.is_file(): if not path.is_file():
raise ValueError(f"Required package file is missing: {name}") raise ValueError(f"Required package file is missing: {name}")
+71
View File
@@ -45,9 +45,80 @@ class PackageTests(unittest.TestCase):
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("notice") path.write_text("notice")
(resources / "settings.json").write_text("private settings") (resources / "settings.json").write_text("private settings")
media = root / ".searchi/media-runtime"
media.mkdir(parents=True)
upstream = media / "pinned-windows.zip"
with zipfile.ZipFile(upstream, "w") as archive:
for staged, member in [("bin/ffmpeg.exe", "bin/ffmpeg.exe"), ("bin/ffprobe.exe", "bin/ffprobe.exe"), ("licenses/LICENSE.txt", "LICENSE.txt")]:
archive.writestr(member, (resources / "media-runtime" / staged).read_bytes())
manifest = root / "tools/media-runtime/manifest.json"
manifest.parent.mkdir(parents=True)
manifest.write_text(json.dumps({"platforms": {"windows-x86_64": {
"archive": upstream.name, "bytes": upstream.stat().st_size,
"sha256": hashlib.sha256(upstream.read_bytes()).hexdigest()}}}))
(resources / "media-runtime/manifest.json").write_bytes(manifest.read_bytes())
inventory(resources) inventory(resources)
return root, resources, app, root / "archive.zip" return root, resources, app, root / "archive.zip"
def test_valid_pe_media_mutation_and_notice_corruption_preserve_previous_archive(self):
for name in ["bin/ffmpeg.exe", "bin/ffprobe.exe", "licenses/LICENSE.txt", "manifest.json"]:
with self.subTest(name=name), tempfile.TemporaryDirectory() as directory:
root, resources, app, output = self.fixture(directory)
output.write_bytes(b"previous package")
path = resources / "media-runtime" / name
path.write_bytes(path.read_bytes() + b"tampered")
with self.assertRaisesRegex(ValueError, "differs from pinned"):
package(root, app, output)
self.assertEqual(output.read_bytes(), b"previous package")
self.assertEqual(list(output.parent.glob("*.partial")), [])
def test_pinned_archive_corruption_preserves_previous_package(self):
with tempfile.TemporaryDirectory() as directory:
root, _, app, output = self.fixture(directory)
output.write_bytes(b"previous package")
source = root / ".searchi/media-runtime/pinned-windows.zip"
source.write_bytes(source.read_bytes() + b"changed")
with self.assertRaisesRegex(ValueError, "provenance mismatch"):
package(root, app, output)
self.assertEqual(output.read_bytes(), b"previous package")
def test_media_change_during_archive_write_preserves_previous_package(self):
with tempfile.TemporaryDirectory() as directory:
root, resources, app, output = self.fixture(directory)
output.write_bytes(b"previous package")
original = zipfile.ZipFile.open
def mutate(archive, name, mode="r", *args, **kwargs):
if name == "media-runtime/bin/ffmpeg.exe" and mode == "w":
path = resources / name
path.write_bytes(path.read_bytes() + b"changed")
return original(archive, name, mode, *args, **kwargs)
with mock.patch.object(zipfile.ZipFile, "open", mutate), self.assertRaisesRegex(ValueError, "changed while packaging"):
package(root, app, output)
self.assertEqual(output.read_bytes(), b"previous package")
def test_duplicate_helper_and_link_notice_are_rejected_before_output(self):
for kind in ["duplicate", "link"]:
with self.subTest(kind=kind), tempfile.TemporaryDirectory() as directory:
root, resources, app, output = self.fixture(directory)
output.write_bytes(b"previous package")
upstream = root / ".searchi/media-runtime/pinned-windows.zip"
with zipfile.ZipFile(upstream, "a") as archive:
if kind == "duplicate":
archive.writestr("other/ffmpeg.exe", (resources / "media-runtime/bin/ffmpeg.exe").read_bytes())
else:
entry = zipfile.ZipInfo("COPYING.txt")
entry.create_system = 3
entry.external_attr = 0o120777 << 16
archive.writestr(entry, "somewhere")
manifest = root / "tools/media-runtime/manifest.json"
data = json.loads(manifest.read_text())
data["platforms"]["windows-x86_64"].update(bytes=upstream.stat().st_size, sha256=hashlib.sha256(upstream.read_bytes()).hexdigest())
manifest.write_text(json.dumps(data))
(resources / "media-runtime/manifest.json").write_bytes(manifest.read_bytes())
with self.assertRaisesRegex(ValueError, "Invalid or duplicate"):
package(root, app, output)
self.assertEqual(output.read_bytes(), b"previous package")
def test_complete_archive_excludes_settings_and_verifies_recorded_bytes(self): def test_complete_archive_excludes_settings_and_verifies_recorded_bytes(self):
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
root, _, app, output = self.fixture(directory) root, _, app, output = self.fixture(directory)