//! Publication faults exercised with the actual Google CPU encoder. use anyhow::Result; use rusqlite::Connection; use searchi_core::SearchIndex; use searchi_semantic::{GemmaEmbedder, TextWindow}; use searchi_service::{EmbeddingProvider, HybridSearch, SearchOptions}; use std::{ fs, path::PathBuf, sync::{Arc, Mutex}, }; #[derive(Default)] struct Control { calls: usize, mutation: Option<(PathBuf, String)>, } struct GoogleWithMutation { google: GemmaEmbedder, control: Arc>, } impl EmbeddingProvider for GoogleWithMutation { fn fingerprint(&self) -> &str { EmbeddingProvider::fingerprint(&self.google) } fn dimensions(&self) -> usize { EmbeddingProvider::dimensions(&self.google) } fn split_windows(&mut self, text: &str, title: &str) -> Result> { self.google.split_windows(text, title) } fn embed_documents(&mut self, texts: &[&str], title: &str) -> Result>> { let vectors = self.google.embed_documents(texts, title)?; let mut control = self.control.lock().unwrap(); control.calls += texts.len(); if let Some((path, replacement)) = control.mutation.take() { let metadata = fs::metadata(&path)?; assert_eq!(metadata.len(), replacement.len() as u64); fs::write(&path, replacement)?; fs::File::options() .write(true) .open(&path)? .set_times(fs::FileTimes::new().set_modified(metadata.modified()?))?; assert_eq!(fs::metadata(&path)?.modified()?, metadata.modified()?); } Ok(vectors) } fn embed_query(&mut self, text: &str) -> Result> { self.google.embed_query(text) } fn embed_code_query(&mut self, text: &str) -> Result> { self.google.embed_code_query(text) } } #[test] #[ignore = "Requires verified Google models and normal native worker; CPU only"] fn genuine_google_same_metadata_mutation_rejects_publication_and_preserves_ready_sources( ) -> Result<()> { assert_eq!(std::env::var("SEARCHI_FORCE_CPU")?, "1"); let google = GemmaEmbedder::load( std::env::var_os("SEARCHI_GEMMA_MODEL_DIR").expect("Verified Google assets required"), std::env::var_os("SEARCHI_GEMMA_WORKER").expect("Normal auto worker required"), 1, )?; assert_eq!(google.info.device, "cpu"); let temp = tempfile::tempdir()?; let root = temp.path().join("documents"); fs::create_dir(&root)?; let stable = root.join("garden.txt"); fs::write( &stable, "The garden irrigation waters tomato plants every morning.", )?; let changing = root.join("supplier.txt"); fs::write( &changing, "The supplier contract ends after sixty days written notice.", )?; let db = temp.path().join("index.db"); let mut lexical = SearchIndex::open(&db)?; lexical.index_folder(&root)?; let control = Arc::new(Mutex::new(Control::default())); let mut service = HybridSearch::with_provider( &db, Box::new(GoogleWithMutation { google, control: control.clone(), }), )?; service.index_embeddings()?; let connection = Connection::open(&db)?; let active = || { connection.query_row( "SELECT active_generation FROM semantic_meta WHERE singleton=1", [], |r| r.get::<_, i64>(0), ) }; let first = active()?; let stable_vector: Vec = connection.query_row( "SELECT vector FROM semantic_chunks WHERE generation=?1 AND path LIKE '%garden.txt'", [first], |r| r.get(0), )?; fs::write( &changing, "The supplier contract ends after ninety days written notice.", )?; lexical.index_folder(&root)?; control.lock().unwrap().mutation = Some(( changing.clone(), "The supplier contract ends after thirty days written notice.".into(), )); let error = service.index_embeddings().unwrap_err(); assert!( format!("{error:#}").contains("Source changed during semantic indexing"), "{error:#}" ); assert_eq!( active()?, first, "Changed bytes must never publish a stale generation" ); assert_eq!(control.lock().unwrap().calls, 3); let options = SearchOptions::default(); let results = service.search_semantic("tomato irrigation", 10, &options)?; assert!(results .iter() .any(|r| r.filename == "garden.txt" && r.source.is_some())); // A normal rescan must detect changed parser input even though the // mutation restored size and modification time; no metadata workaround. lexical.index_folder(&root)?; let report = service.index_embeddings()?; assert!(report.resumed); assert_eq!(report.reused_sources, 1); assert_eq!(report.encoded_sources, 1); assert_eq!(control.lock().unwrap().calls, 4); let latest = active()?; assert_ne!(latest, first); let latest_stable: Vec = connection.query_row( "SELECT vector FROM semantic_chunks WHERE generation=?1 AND path LIKE '%garden.txt'", [latest], |r| r.get(0), )?; assert_eq!(latest_stable, stable_vector); let results = service.search_semantic("supplier contract notice", 10, &options)?; let supplier = results .iter() .find(|r| r.filename == "supplier.txt") .expect("Current supplier source"); assert!(supplier.snippet.contains("thirty days")); assert!(supplier.source.is_some()); // Mutation before indexing must be caught against the persisted exact // parser-input hash, before spending document inference on stale passages. let metadata = fs::metadata(&changing)?; let replacement = "The supplier contract ends after eighty days written notice."; assert_eq!(replacement.len() as u64, metadata.len()); fs::write(&changing, replacement)?; fs::File::options() .write(true) .open(&changing)? .set_times(fs::FileTimes::new().set_modified(metadata.modified()?))?; assert_eq!(fs::metadata(&changing)?.modified()?, metadata.modified()?); let error = service.index_embeddings().unwrap_err(); assert!(format!("{error:#}").contains("reindex"), "{error:#}"); assert_eq!( control.lock().unwrap().calls, 4, "Stale extraction must fail before document inference" ); assert_eq!(active()?, latest); lexical.index_folder(&root)?; let report = service.index_embeddings()?; assert!(report.resumed); assert_eq!(report.reused_sources, 1); assert_eq!(report.encoded_sources, 1); assert_eq!(control.lock().unwrap().calls, 5); let results = service.search_semantic("supplier contract notice", 10, &options)?; let supplier = results .iter() .find(|r| r.filename == "supplier.txt") .expect("Re-extracted supplier source"); assert!(supplier.snippet.contains("eighty days")); assert!(supplier.source.is_some()); Ok(()) }