tidaldb/tidal/tests/text_index.rs
jx12n 3bcfb3c576 feat: Bazel build, crate docs/ai-lookup, docker images, and engine hardening
- Add BUILD.bazel across tidal, tidal-net, tidal-server, tidalctl for bzlmod build
- Add tidal/ crate docs (README, CHANGELOG, CONTRIBUTING, AGENTS, CLAUDE, API, ARCHITECTURE) and ai-lookup reference
- Add docker standalone/cluster/deploy images, compose, and prometheus config
- Harden WAL (batch format, writer, dedup, diagnostics), text syncer/collectors, and vector registry
- Expand tidalctl CLI and tests; restructure WAL/visibility integration test suites
- Refine tidal-net transport/client/server and tidal-server cluster/scatter-gather
2026-06-07 18:29:38 -06:00

180 lines
5.8 KiB
Rust

#![allow(clippy::unwrap_used)]
//! m5p1 Text Index end-to-end integration test.
//!
//! Validates the full BM25 pipeline: schema declaration → index → write →
//! commit → query parse → search → score. Uses an ephemeral in-RAM index so
//! no disk I/O is required.
use std::collections::HashMap;
use tidaldb::{
schema::{EntityId, TextFieldDef, TextFieldType},
text::{AllScoresCollector, TextIndex},
};
fn make_fields() -> Vec<TextFieldDef> {
vec![
TextFieldDef {
key: "title".into(),
field_type: TextFieldType::Text,
},
TextFieldDef {
key: "description".into(),
field_type: TextFieldType::Text,
},
TextFieldDef {
key: "category".into(),
field_type: TextFieldType::Keyword,
},
]
}
/// Validates the full m5p1 text index pipeline:
/// index → write → commit → search → score
#[test]
fn text_index_end_to_end() {
let fields = make_fields();
let idx = TextIndex::ephemeral(&fields).unwrap();
// Write 100 items.
let mut w = idx.writer_guard().unwrap();
for i in 0..100u64 {
let mut meta = HashMap::new();
meta.insert("title".into(), format!("Rust tutorial {i}"));
meta.insert("description".into(), "Learn Rust programming".into());
meta.insert("category".into(), "programming".into());
w.index_item(EntityId::new(i), &meta).unwrap();
}
w.commit(100).unwrap();
drop(w);
idx.reload_reader().unwrap();
let searcher = idx.searcher();
let parser = idx.query_parser();
let collector = AllScoresCollector {
entity_id_field: idx.fields().entity_id,
};
// Test 1: bare terms (AND conjunction) — "Rust tutorial" matches all 100.
let q = parser.parse("Rust tutorial").unwrap();
let results = searcher.search(q.as_ref(), &collector).unwrap();
assert!(!results.is_empty(), "bare terms should return results");
// Test 2: exact phrase — "Rust programming" is in every description.
let q = parser.parse("\"Rust programming\"").unwrap();
let results = searcher.search(q.as_ref(), &collector).unwrap();
assert!(!results.is_empty(), "exact phrase should match description");
// Test 3: field-scoped keyword — category:programming matches all 100.
let q = parser.parse("category:programming").unwrap();
let results = searcher.search(q.as_ref(), &collector).unwrap();
assert_eq!(
results.len(),
100,
"keyword field-scoped query should match all 100"
);
// Test 4: exclusion — "Rust -foobarxyz" should match (exclusion term not in corpus).
// MUST_NOT excludes at the document level; "foobarxyz" appears nowhere, so nothing excluded.
let q = parser.parse("Rust -foobarxyz").unwrap();
let results = searcher.search(q.as_ref(), &collector).unwrap();
assert!(
!results.is_empty(),
"exclusion of absent term should still return matching documents"
);
// Test 5: BM25 latency < 10ms at 100 docs (trivial at this scale).
let start = std::time::Instant::now();
let q = parser.parse("Rust").unwrap();
let _ = searcher.search(q.as_ref(), &collector).unwrap();
assert!(
start.elapsed().as_millis() < 10,
"BM25 query should complete in < 10ms at 100 docs"
);
}
/// Boolean OR returns more results than AND for the same terms.
#[test]
fn boolean_or_returns_superset_of_and() {
let fields = vec![TextFieldDef {
key: "title".into(),
field_type: TextFieldType::Text,
}];
let idx = TextIndex::ephemeral(&fields).unwrap();
let mut w = idx.writer_guard().unwrap();
for (i, title) in [
(1u64, "jazz piano"),
(2u64, "rock guitar"),
(3u64, "jazz violin"),
] {
let mut m = HashMap::new();
m.insert("title".into(), title.into());
w.index_item(EntityId::new(i), &m).unwrap();
}
w.commit(3).unwrap();
drop(w);
idx.reload_reader().unwrap();
let searcher = idx.searcher();
let parser = idx.query_parser();
let collector = AllScoresCollector {
entity_id_field: idx.fields().entity_id,
};
// AND: "jazz piano" requires both terms — only entity 1.
let q_and = parser.parse("jazz piano").unwrap();
let and_results = searcher.search(q_and.as_ref(), &collector).unwrap();
// OR: "jazz OR piano" — entities 1 and 3.
let q_or = parser.parse("jazz OR piano").unwrap();
let or_results = searcher.search(q_or.as_ref(), &collector).unwrap();
assert!(
or_results.len() >= and_results.len(),
"OR should return at least as many results as AND"
);
assert_eq!(
and_results.len(),
1,
"AND requires both 'jazz' and 'piano' — only entity 1"
);
assert_eq!(or_results.len(), 2, "OR jazz or piano — entities 1 and 3");
}
/// Deleting an item removes it from search results after next commit.
#[test]
fn delete_removes_from_results() {
let fields = vec![TextFieldDef {
key: "title".into(),
field_type: TextFieldType::Text,
}];
let idx = TextIndex::ephemeral(&fields).unwrap();
let mut w = idx.writer_guard().unwrap();
let mut m = HashMap::new();
m.insert("title".into(), "jazz piano".into());
w.index_item(EntityId::new(1), &m).unwrap();
w.commit(1).unwrap();
// Delete and commit.
w.delete_item(EntityId::new(1));
w.commit(2).unwrap();
drop(w);
idx.reload_reader().unwrap();
let searcher = idx.searcher();
let parser = idx.query_parser();
let collector = AllScoresCollector {
entity_id_field: idx.fields().entity_id,
};
let q = parser.parse("jazz").unwrap();
let results = searcher.search(q.as_ref(), &collector).unwrap();
assert!(
results.is_empty(),
"deleted item should not appear in results"
);
}