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
@@ -0,0 +1,260 @@
|
||||
//! Coverage/publication-control tests use empty ANN graphs, never model vectors.
|
||||
//! Actual semantic retrieval is separately covered by early_text_google_cpu.
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
struct RejectAllInference;
|
||||
impl EmbeddingProvider for RejectAllInference {
|
||||
fn fingerprint(&self) -> &str {
|
||||
GEMMA_FINGERPRINT
|
||||
}
|
||||
fn dimensions(&self) -> usize {
|
||||
GEMMA_DIMENSIONS
|
||||
}
|
||||
fn split_windows(&mut self, _: &str, _: &str) -> Result<Vec<TextWindow>> {
|
||||
anyhow::bail!("Unexpected tokenization")
|
||||
}
|
||||
fn embed_documents(&mut self, _: &[&str], _: &str) -> Result<Vec<Vec<f32>>> {
|
||||
anyhow::bail!("Unexpected document inference")
|
||||
}
|
||||
fn embed_query(&mut self, _: &str) -> Result<Vec<f32>> {
|
||||
anyhow::bail!("No fabricated query vectors")
|
||||
}
|
||||
}
|
||||
|
||||
fn fixture() -> Result<(tempfile::TempDir, std::path::PathBuf, SearchIndex, Store)> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let root = temp.path().join("corpus");
|
||||
fs::create_dir(&root)?;
|
||||
fs::write(
|
||||
root.join("supplier.txt"),
|
||||
"Supplier cancellation requires written notice.",
|
||||
)?;
|
||||
// Filename/coverage control only, not an image inference fixture.
|
||||
fs::write(root.join("photo.bmp"), b"not an image")?;
|
||||
let db = temp.path().join("index.db");
|
||||
let mut lexical = SearchIndex::open(&db)?;
|
||||
lexical.index_folder(&root)?;
|
||||
let store = Store::open(&db, GEMMA_DIMENSIONS)?;
|
||||
Ok((temp, db, lexical, store))
|
||||
}
|
||||
|
||||
fn empty_generation(store: &mut Store, policy: &str) -> Result<i64> {
|
||||
let _lock = store.build_lock()?;
|
||||
let generation = store.begin(policy)?;
|
||||
store.activate(generation)?;
|
||||
Ok(generation)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coverage_status_distinguishes_media_pending_and_survives_mutation() -> Result<()> {
|
||||
let (temp, db, mut lexical, mut store) = fixture()?;
|
||||
let before = HybridSearch::embedding_status_for_coverage(&db, SemanticCoverage::TextOnly)?;
|
||||
assert_eq!(before.pending, 1);
|
||||
assert_eq!(before.coverage, None);
|
||||
assert_eq!(HybridSearch::embedding_status(&db)?.pending, 2);
|
||||
let policy = format!("{}:text-only", generation_fingerprint(GEMMA_FINGERPRINT));
|
||||
empty_generation(&mut store, &policy)?;
|
||||
let text = HybridSearch::embedding_status_for_coverage(&db, SemanticCoverage::TextOnly)?;
|
||||
assert_eq!(text.coverage, Some(SemanticCoverage::TextOnly));
|
||||
assert!(!text.stale);
|
||||
assert_eq!(
|
||||
text.pending, 0,
|
||||
"Media must not trigger endless text-only rebuilds"
|
||||
);
|
||||
assert!(text.text_indexing_allowed);
|
||||
let full = HybridSearch::embedding_status(&db)?;
|
||||
assert!(full.stale);
|
||||
assert_eq!(full.pending, 2);
|
||||
fs::write(
|
||||
temp.path().join("corpus/supplier.txt"),
|
||||
"A revised supplier cancellation policy.",
|
||||
)?;
|
||||
lexical.index_folder(temp.path().join("corpus"))?;
|
||||
let stale = HybridSearch::embedding_status_for_coverage(&db, SemanticCoverage::TextOnly)?;
|
||||
assert!(stale.stale);
|
||||
assert_eq!(stale.pending, 1);
|
||||
fs::remove_file(temp.path().join("corpus/supplier.txt"))?;
|
||||
lexical.index_folder(temp.path().join("corpus"))?;
|
||||
assert_eq!(
|
||||
HybridSearch::embedding_status_for_coverage(&db, SemanticCoverage::TextOnly)?.pending,
|
||||
0
|
||||
);
|
||||
assert_eq!(HybridSearch::embedding_status(&db)?.pending, 1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guarded_text_setup_preserves_full_publication_even_after_stale_ui_snapshot() -> Result<()> {
|
||||
let (_temp, db, _lexical, mut publisher) = fixture()?;
|
||||
let mut early = HybridSearch::with_provider(&db, Box::new(RejectAllInference))?;
|
||||
assert!(
|
||||
HybridSearch::embedding_status_for_coverage(&db, SemanticCoverage::TextOnly)?
|
||||
.text_indexing_allowed
|
||||
);
|
||||
let lock = publisher.build_lock()?;
|
||||
// A competing full publisher owns the same lock. Early setup must not
|
||||
// perform source discovery, inference or stage deletion while it is held.
|
||||
assert!(early
|
||||
.index_text_embeddings_preserving_media()
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("Another semantic rebuild"));
|
||||
let generation = publisher.begin(&generation_fingerprint(GEMMA_FINGERPRINT))?;
|
||||
publisher.activate(generation)?;
|
||||
drop(lock);
|
||||
assert!(early.index_text_embeddings_preserving_media()?.is_none());
|
||||
let status = HybridSearch::embedding_status_for_coverage(&db, SemanticCoverage::TextOnly)?;
|
||||
assert_eq!(status.generation, Some(generation));
|
||||
assert_eq!(status.coverage, Some(SemanticCoverage::FullMedia));
|
||||
assert!(!status.text_indexing_allowed);
|
||||
assert!(!status.stale);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_full_policy_remains_protected_while_default_status_is_stale() -> Result<()> {
|
||||
let (_temp, db, _lexical, mut store) = fixture()?;
|
||||
let policy = format!(
|
||||
"{}:custom-240-2000-120-10000-2000-1800-14400000-17179869184",
|
||||
generation_fingerprint(GEMMA_FINGERPRINT)
|
||||
);
|
||||
let generation = empty_generation(&mut store, &policy)?;
|
||||
let mut early = HybridSearch::with_provider(&db, Box::new(RejectAllInference))?;
|
||||
assert!(early.index_text_embeddings_preserving_media()?.is_none());
|
||||
let text = HybridSearch::embedding_status_for_coverage(&db, SemanticCoverage::TextOnly)?;
|
||||
assert_eq!(text.coverage, Some(SemanticCoverage::FullMedia));
|
||||
assert_eq!(text.generation, Some(generation));
|
||||
assert!(!text.stale);
|
||||
assert!(!text.text_indexing_allowed);
|
||||
assert!(
|
||||
HybridSearch::embedding_status(&db)?.stale,
|
||||
"Default full status keeps its exact-policy contract"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_policy_transition_rejects_other_models_windows_and_dimensions() -> Result<()> {
|
||||
let (_temp, db, _lexical, mut publisher) = fixture()?;
|
||||
let graphs = PreparedGraphs::default();
|
||||
publisher.share_prepared_graphs(&graphs)?;
|
||||
let expected = generation_fingerprint(GEMMA_FINGERPRINT);
|
||||
let generation = empty_generation(&mut publisher, &format!("{expected}:text-only"))?;
|
||||
let mut reader = Store::open(&db, GEMMA_DIMENSIONS)?;
|
||||
reader.share_prepared_graphs(&graphs)?;
|
||||
// Empty graph controls policy acceptance and prepared-graph handoff without
|
||||
// inventing vectors or claiming search-quality evidence.
|
||||
let mut trace = TraceCollector::new(MetricsMode::Debug);
|
||||
reader.search_origin_traced(
|
||||
&vec![0.0; GEMMA_DIMENSIONS],
|
||||
&expected,
|
||||
5,
|
||||
&SearchOptions::default(),
|
||||
None,
|
||||
&mut trace,
|
||||
None,
|
||||
)?;
|
||||
let spans = trace.finish(0).trace.unwrap();
|
||||
assert!(spans
|
||||
.iter()
|
||||
.any(|s| s.name == "semantic.ann_load" && s.counters.get("cache_hit") == Some(&1)));
|
||||
let connection = rusqlite::Connection::open(&db)?;
|
||||
for policy in [
|
||||
"different-model",
|
||||
&expected.replace("byte64k-overlap512", "byte64k-overlap1024"),
|
||||
] {
|
||||
connection.execute(
|
||||
"UPDATE semantic_generations SET fingerprint=?1 WHERE id=?2",
|
||||
rusqlite::params![policy, generation],
|
||||
)?;
|
||||
let mut trace = TraceCollector::new(MetricsMode::Debug);
|
||||
reader.search_origin_traced(
|
||||
&vec![0.0; GEMMA_DIMENSIONS],
|
||||
&expected,
|
||||
5,
|
||||
&SearchOptions::default(),
|
||||
None,
|
||||
&mut trace,
|
||||
None,
|
||||
)?;
|
||||
assert!(!trace
|
||||
.finish(0)
|
||||
.trace
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|s| s.name == "semantic.ann_load"));
|
||||
assert_eq!(
|
||||
HybridSearch::embedding_status_for_coverage(&db, SemanticCoverage::TextOnly)?.coverage,
|
||||
None
|
||||
);
|
||||
}
|
||||
connection.execute(
|
||||
"UPDATE semantic_generations SET fingerprint=?1,dimensions=512 WHERE id=?2",
|
||||
rusqlite::params![expected, generation],
|
||||
)?;
|
||||
let mut trace = TraceCollector::new(MetricsMode::Debug);
|
||||
reader.search_origin_traced(
|
||||
&vec![0.0; GEMMA_DIMENSIONS],
|
||||
&expected,
|
||||
5,
|
||||
&SearchOptions::default(),
|
||||
None,
|
||||
&mut trace,
|
||||
None,
|
||||
)?;
|
||||
assert!(!trace
|
||||
.finish(0)
|
||||
.trace
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|s| s.name == "semantic.ann_load"));
|
||||
let incompatible =
|
||||
HybridSearch::embedding_status_for_coverage(&db, SemanticCoverage::TextOnly)?;
|
||||
assert_eq!(incompatible.coverage, None);
|
||||
assert!(
|
||||
incompatible.text_indexing_allowed,
|
||||
"Wrong dimensions must not claim compatible full coverage"
|
||||
);
|
||||
connection.execute(
|
||||
"UPDATE semantic_generations SET dimensions=768,ann_blob=NULL WHERE id=?1",
|
||||
[generation],
|
||||
)?;
|
||||
let unprepared = HybridSearch::embedding_status_for_coverage(&db, SemanticCoverage::TextOnly)?;
|
||||
assert_eq!(unprepared.coverage, None);
|
||||
assert!(
|
||||
!unprepared.text_indexing_allowed,
|
||||
"Missing ANN must not permit dropping otherwise compatible full media sources"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_task_component_policy_preserves_model_and_window_identity() {
|
||||
let base = generation_fingerprint(GEMMA_FINGERPRINT);
|
||||
assert_eq!(policy_base(&format!("{base}:text-only")), base);
|
||||
let custom = format!("{base}:custom-240-2000-120-10000-2000-1800-14400000-17179869184");
|
||||
assert_eq!(policy_base(&custom), base);
|
||||
assert!(compatible_query_policy(
|
||||
&format!("{base}:text-only"),
|
||||
&custom
|
||||
));
|
||||
assert!(compatible_query_policy(&custom, &base));
|
||||
for suffix in [":text-only-v2", ":custom-unknown", ":custom-1-2-3-4"] {
|
||||
let unknown = format!("{base}{suffix}");
|
||||
assert_eq!(policy_base(&unknown), unknown);
|
||||
assert!(!compatible_query_policy(&base, &unknown));
|
||||
}
|
||||
assert!(!compatible_query_policy(
|
||||
&base,
|
||||
&base.replace("byte64k-overlap512", "byte64k-overlap1024")
|
||||
));
|
||||
assert_eq!(
|
||||
serde_json::to_string(&SemanticCoverage::TextOnly).unwrap(),
|
||||
"\"text_only\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&SemanticCoverage::FullMedia).unwrap(),
|
||||
"\"full_media\""
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user