Files
dicklesworthstone__coding_a…/tests/search_frankensearch_integration.rs
Dicklesworthstone b316b35e81 fix(tests): frankensearch source contract pins the registry, not a git revision (gh#416)
cass_selects_only_the_explicit_tantivy_compatibility_surface still
demanded a git rev pin for frankensearch — stale since the registry flip.
The contract now asserts the exact `=<version>` registry pin (a caret req
could resolve a stale same-version twin), the ABSENCE of any git source,
the matching Cargo.lock registry resolution, build.rs expected_version,
and the README naming the pinned version. The cass-compat/lexical feature
assertions are unchanged. search_frankensearch_integration 73/73.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 02:15:31 -04:00

869 lines
32 KiB
Rust

//! Integration tests verifying the frankensearch search migration (bead s3ho2).
//!
//! Validates that:
//! 1. All search operations go through frankensearch (no direct tantivy imports remain)
//! 2. SemanticFilter directly implements frankensearch::core::filter::SearchFilter
//! 3. No duplicate FsSemanticFilterAdapter exists
//! 4. Vector search via frankensearch VectorIndex produces correct results
//! 5. RRF hybrid fusion uses frankensearch::rrf_fuse
//! 6. Query parsing and search pipeline work end-to-end through frankensearch
use coding_agent_search::search::query::{FieldMask, SearchClient, SearchFilters};
use coding_agent_search::search::tantivy::TantivyIndex;
use coding_agent_search::search::vector_index::{
SemanticFilter, VectorIndex, parse_semantic_doc_id,
};
use std::collections::HashSet;
use tempfile::TempDir;
mod util;
use util::e2e_log::PhaseTracker;
// =============================================================================
// ZERO TANTIVY IMPORTS AUDIT
// =============================================================================
/// Programmatic verification that no direct `use tantivy::` imports remain in src/.
/// This test reads the source files and ensures all tantivy usage goes through
/// frankensearch re-exports.
#[test]
fn no_direct_tantivy_imports_in_src() {
let src_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let mut violations = Vec::new();
fn scan_dir(dir: &std::path::Path, violations: &mut Vec<String>) {
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return,
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
scan_dir(&path, violations);
} else if path.extension().is_some_and(|ext| ext == "rs")
&& let Ok(content) = std::fs::read_to_string(&path)
{
for (line_num, line) in content.lines().enumerate() {
let trimmed = line.trim();
// Skip comments
if trimmed.starts_with("//") || trimmed.starts_with("/*") {
continue;
}
if trimmed.contains("use tantivy::") {
violations.push(format!(
"{}:{}: {}",
path.display(),
line_num + 1,
trimmed
));
}
}
}
}
}
scan_dir(&src_dir, &mut violations);
assert!(
violations.is_empty(),
"Found direct tantivy imports (should use frankensearch::lexical_tantivy instead):\n{}",
violations.join("\n")
);
}
/// Verify Cargo.toml has no direct tantivy dependency.
#[test]
fn no_direct_tantivy_in_cargo_toml() {
let cargo_toml = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml");
let content = std::fs::read_to_string(cargo_toml).expect("read Cargo.toml");
// Check [dependencies] section for a direct tantivy = line
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with("tantivy") && trimmed.contains('=') {
panic!(
"Found direct tantivy dependency in Cargo.toml: {trimmed}\n\
tantivy should only be used via frankensearch re-exports"
);
}
}
}
/// Freeze CASS's backend-selection contract independently of FrankenSearch's
/// generic `lexical` feature. CASS owns a foreign Tantivy schema and must keep
/// selecting the explicit compatibility lane when the facade default changes.
#[test]
fn cass_selects_only_the_explicit_tantivy_compatibility_surface() {
let manifest = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml"),
)
.expect("read Cargo.toml");
let dependency = manifest
.lines()
.find(|line| line.trim_start().starts_with("frankensearch = "))
.expect("frankensearch dependency declaration");
assert!(
dependency.contains("\"cass-compat\""),
"CASS must select the explicit foreign-index compatibility feature: {dependency}"
);
assert!(
!dependency.contains("\"lexical\""),
"CASS must not select the facade's swappable generic lexical feature: {dependency}"
);
// gh#416: frankensearch resolves from crates.io behind an EXACT version
// pin (the registry holds stale same-version twins from an older tree, so
// a caret req could resolve wrong source; `=` fences that off). The old
// form of this contract pinned a git revision — that shape is now itself
// a defect, checked below via the absence of any git source.
let pinned_version = dependency
.split_once("version = \"=")
.and_then(|(_, suffix)| suffix.split_once('"'))
.map(|(version, _)| version.to_owned())
.expect("frankensearch dependency must pin an exact registry version (= prefix)");
assert!(
!dependency.contains("git ="),
"frankensearch must not resolve from git; crates.io publishing forbids it: {dependency}"
);
let lockfile = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.lock"),
)
.expect("read Cargo.lock");
let expected_lock = format!(
"name = \"frankensearch\"\nversion = \"{pinned_version}\"\nsource = \"registry+https://github.com/rust-lang/crates.io-index\""
);
assert!(
lockfile.contains(&expected_lock),
"Cargo.lock must resolve frankensearch {pinned_version} from the crates.io registry"
);
let build_contract =
std::fs::read_to_string(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("build.rs"))
.expect("read build.rs");
assert!(
build_contract.contains(&format!("expected_version: \"{pinned_version}\"")),
"build.rs must enforce the same exact FrankenSearch version as Cargo.toml"
);
let readme =
std::fs::read_to_string(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("README.md"))
.expect("read README.md");
assert!(
readme.contains(&format!("={pinned_version}"))
&& readme.contains("cass-compat")
&& readme.contains("lexical-tantivy"),
"README dependency contract must name the pinned registry version and explicit compatibility lane"
);
assert!(
lockfile.contains("name = \"frankensearch-lexical\""),
"the CASS compatibility graph must contain the Tantivy-backed lexical crate"
);
// INVERTED BY THE CASS->QUILL FLIP, intent preserved.
//
// Before the flip Quill was not a cass dependency, so its ABSENCE proved
// that `cass-compat` was not implicitly dragging it in. Quill is now the
// lexical backend, so absence would mean the backend is missing entirely.
//
// The property worth guarding is unchanged — Quill must be acquired
// EXPLICITLY, never as a silent transitive effect of another feature — so
// the check is now: the crate is present AND the manifest names the `quill`
// feature itself. Deleting the assertion would have dropped the
// explicitness guarantee along with the stale expectation.
assert!(
lockfile.contains("name = \"frankensearch-quill\""),
"the lexical backend crate must be in the graph after the CASS->Quill flip"
);
let manifest_text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml"),
)
.expect("read Cargo.toml");
let frankensearch_dep_line = manifest_text
.lines()
.find(|line| line.starts_with("frankensearch = "))
.expect("frankensearch dependency line");
assert!(
frankensearch_dep_line.contains("\"quill\""),
"Quill must be selected explicitly in the frankensearch feature list, not acquired transitively"
);
let mut generic_imports = Vec::new();
for root in ["src", "tests", "benches"] {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(root);
let mut pending = vec![root];
while let Some(path) = pending.pop() {
for entry in std::fs::read_dir(&path).expect("scan Rust source tree") {
let entry = entry.expect("source directory entry");
let path = entry.path();
if path.is_dir() {
pending.push(path);
} else if path.extension().is_some_and(|extension| extension == "rs") {
let source = std::fs::read_to_string(&path).expect("read Rust source");
let generic_namespace = ["frankensearch", "lexical"].join("::");
if source.match_indices(&generic_namespace).any(|(offset, _)| {
source
.as_bytes()
.get(offset + generic_namespace.len())
.is_none_or(|next| *next != b'_')
}) {
generic_imports.push(path);
}
}
}
}
}
assert!(
generic_imports.is_empty(),
"Tantivy-native code must use frankensearch::lexical_tantivy explicitly: \
{generic_imports:#?}"
);
}
// =============================================================================
// SEARCHFILTER UNIFICATION
// =============================================================================
/// Verify SemanticFilter directly implements frankensearch::core::filter::SearchFilter.
/// This proves the adapter pattern (FsSemanticFilterAdapter) has been eliminated.
#[test]
fn semantic_filter_implements_search_filter_directly() {
use frankensearch::core::filter::SearchFilter;
let filter = SemanticFilter {
agents: Some(HashSet::from([3])),
workspaces: Some(HashSet::from([7])),
sources: Some(HashSet::from([11])),
roles: Some(HashSet::from([1])),
created_from: Some(1_700_000_000_000),
created_to: Some(1_700_000_000_100),
};
// Matching doc_id
assert!(
filter.matches("m|42|2|3|7|11|1|1700000000050", None),
"filter should match doc_id with correct agent/workspace/source/role/timestamp"
);
// Wrong agent
assert!(
!filter.matches("m|42|2|99|7|11|1|1700000000050", None),
"filter should reject wrong agent_id"
);
// Wrong workspace
assert!(
!filter.matches("m|42|2|3|99|11|1|1700000000050", None),
"filter should reject wrong workspace_id"
);
// Wrong source
assert!(
!filter.matches("m|42|2|3|7|99|1|1700000000050", None),
"filter should reject wrong source_id"
);
// Wrong role
assert!(
!filter.matches("m|42|2|3|7|11|9|1700000000050", None),
"filter should reject wrong role"
);
// Timestamp before range
assert!(
!filter.matches("m|42|2|3|7|11|1|1699999999999", None),
"filter should reject timestamp before created_from"
);
// Timestamp after range
assert!(
!filter.matches("m|42|2|3|7|11|1|1700000000200", None),
"filter should reject timestamp after created_to"
);
// Invalid doc_id
assert!(
!filter.matches("not-a-valid-doc-id", None),
"filter should reject invalid doc_id format"
);
}
/// Verify unrestricted filter (all None) matches everything.
#[test]
fn unrestricted_semantic_filter_matches_all() {
use frankensearch::core::filter::SearchFilter;
let filter = SemanticFilter::default();
assert!(filter.matches("m|1|0|5|10|20|0|1700000000000", None));
assert!(filter.matches("m|999|3|99|99|99|2|1800000000000", None));
}
// =============================================================================
// DOC_ID PARSING
// =============================================================================
/// Verify parse_semantic_doc_id is the single parser (no duplicates).
#[test]
fn parse_semantic_doc_id_roundtrip() {
let hash_hex = "aa".repeat(32);
let doc_id = format!("m|42|2|3|7|11|1|1700000000000|{hash_hex}");
let parsed = parse_semantic_doc_id(&doc_id).expect("should parse valid doc_id");
assert_eq!(parsed.message_id, 42);
assert_eq!(parsed.chunk_idx, 2);
assert_eq!(parsed.agent_id, 3);
assert_eq!(parsed.workspace_id, 7);
assert_eq!(parsed.source_id, 11);
assert_eq!(parsed.role, 1);
assert_eq!(parsed.created_at_ms, 1_700_000_000_000);
assert!(parsed.content_hash.is_some(), "should parse content hash");
}
/// Verify doc_id without content hash still parses.
#[test]
fn parse_semantic_doc_id_without_hash() {
let doc_id = "m|100|0|5|10|20|1|1700000000000";
let parsed = parse_semantic_doc_id(doc_id).expect("should parse doc_id without hash");
assert_eq!(parsed.message_id, 100);
assert_eq!(parsed.chunk_idx, 0);
assert!(parsed.content_hash.is_none(), "should have no content hash");
}
/// Invalid doc_id formats return None.
#[test]
fn parse_semantic_doc_id_rejects_invalid() {
assert!(parse_semantic_doc_id("").is_none());
assert!(parse_semantic_doc_id("not-a-doc-id").is_none());
assert!(parse_semantic_doc_id("x|1|2|3|4|5|6|7").is_none()); // wrong prefix
assert!(parse_semantic_doc_id("m|abc|2|3|4|5|6|7").is_none()); // non-numeric
assert!(parse_semantic_doc_id("m|1|2|3").is_none()); // too few fields
}
// =============================================================================
// FRANKENSEARCH VECTOR INDEX INTEGRATION
// =============================================================================
/// Verify frankensearch VectorIndex write + search roundtrip works correctly.
#[test]
fn frankensearch_vector_index_write_and_search() {
let dir = TempDir::new().unwrap();
let index_path = dir.path().join("vector_index").join("index-test.fsvi");
std::fs::create_dir_all(index_path.parent().unwrap()).unwrap();
let hash_a = "00".repeat(32);
let hash_b = "11".repeat(32);
let doc_a = format!("m|101|0|1|10|100|1|1700000000001|{hash_a}");
let doc_b = format!("m|202|0|2|20|200|1|1700000000002|{hash_b}");
// Write two vectors
let mut writer = VectorIndex::create_with_revision(
&index_path,
"test-embedder",
"rev-1",
2, // dimension
frankensearch::index::Quantization::F16,
)
.expect("create vector index");
writer
.write_record(&doc_a, &[1.0, 0.0])
.expect("write doc_a");
writer
.write_record(&doc_b, &[0.0, 1.0])
.expect("write doc_b");
writer.finish().expect("finish writing");
// Read and search
let index = VectorIndex::open(&index_path).expect("open vector index");
// Search for vector similar to doc_a
let results = index.search_top_k(&[1.0, 0.0], 5, None).expect("search");
assert!(!results.is_empty(), "should find at least one result");
let top = &results[0];
let parsed = parse_semantic_doc_id(&top.doc_id).expect("parse top result doc_id");
assert_eq!(parsed.message_id, 101, "top result should be doc_a");
}
/// Verify vector search with SemanticFilter integration.
#[test]
fn frankensearch_vector_search_with_semantic_filter() {
use frankensearch::core::filter::SearchFilter;
let dir = TempDir::new().unwrap();
let index_path = dir.path().join("vector_index").join("index-filtered.fsvi");
std::fs::create_dir_all(index_path.parent().unwrap()).unwrap();
let hash = "00".repeat(32);
let doc_agent1 = format!("m|101|0|1|10|100|1|1700000000001|{hash}");
let doc_agent2 = format!("m|202|0|2|20|200|1|1700000000002|{hash}");
let mut writer = VectorIndex::create_with_revision(
&index_path,
"test-embedder",
"rev-1",
2,
frankensearch::index::Quantization::F16,
)
.expect("create index");
// Both vectors point in same direction so both would match
writer
.write_record(&doc_agent1, &[1.0, 0.0])
.expect("write");
writer
.write_record(&doc_agent2, &[0.9, 0.1])
.expect("write");
writer.finish().expect("finish");
let index = VectorIndex::open(&index_path).expect("open");
// Filter to agent_id=1 only
let filter = SemanticFilter {
agents: Some(HashSet::from([1])),
..Default::default()
};
let results = index
.search_top_k(&[1.0, 0.0], 5, Some(&filter as &dyn SearchFilter))
.expect("filtered search");
assert_eq!(results.len(), 1, "should return only agent_id=1 result");
let parsed = parse_semantic_doc_id(&results[0].doc_id).expect("parse");
assert_eq!(parsed.agent_id, 1);
}
// =============================================================================
// LEXICAL SEARCH THROUGH FRANKENSEARCH
// =============================================================================
/// Verify that lexical search through SearchClient works end-to-end.
/// This validates the full pipeline: `frankensearch::lexical_tantivy` types → BM25 scoring.
#[test]
fn lexical_search_through_frankensearch_pipeline() {
let dir = TempDir::new().unwrap();
let mut index = TantivyIndex::open_or_create(dir.path()).unwrap();
let conv = util::ConversationFixtureBuilder::new("claude_code")
.title("frankensearch integration test")
.source_path(dir.path().join("session.jsonl"))
.base_ts(1_700_000_000_000)
.messages(3)
.with_content(0, "The authentication module handles OAuth2 flows")
.with_content(1, "Token refresh uses exponential backoff strategy")
.with_content(2, "Rate limiting prevents abuse of the API endpoint")
.build_normalized();
index.add_conversation(&conv).unwrap();
index.commit().unwrap();
let client = SearchClient::open(dir.path(), None)
.unwrap()
.expect("client");
let filters = SearchFilters::default();
// Exact term search
let hits = client
.search("authentication", filters.clone(), 10, 0, FieldMask::FULL)
.unwrap();
assert!(
!hits.is_empty(),
"should find 'authentication' via frankensearch BM25"
);
assert!(hits[0].content.contains("authentication"));
// Prefix wildcard search
let hits = client
.search("auth*", filters.clone(), 10, 0, FieldMask::FULL)
.unwrap();
assert!(!hits.is_empty(), "should match auth* prefix");
// Multi-term search
let hits = client
.search("token refresh", filters, 10, 0, FieldMask::FULL)
.unwrap();
assert!(!hits.is_empty(), "should find multi-term query");
}
/// No-mock schema-v8 compatibility receipt spanning the operations CASS needs
/// to survive the facade flip: create, ingest, commit, reopen, merge, query,
/// drop, and fresh reopen.
#[test]
// Merges two independently-built shard directories, which is the Tantivy-only
// N-directory merge. Quill's concat_merge joins segments within ONE manifest,
// so the staged-shard strategy that needed this is disabled for the Quill
// backend. Ignored rather than weakened; see the `staged_shard_plan` binding.
#[ignore = "Tantivy-only shard merge; unreachable on the Quill backend"]
fn cass_schema_v8_survives_create_merge_query_and_fresh_reopen() {
assert_eq!(
frankensearch::lexical_tantivy::CASS_SCHEMA_VERSION,
"v8",
"the compatibility receipt is specifically for CASS schema v8"
);
let tracker = PhaseTracker::new(
"search_frankensearch_integration",
"cass_schema_v8_survives_create_merge_query_and_fresh_reopen",
);
let root = TempDir::new().expect("compatibility fixture root");
let left_path = root.path().join("left");
let right_path = root.path().join("right");
let merged_path = root.path().join("merged");
tracker.phase(
"create_ingest_commit",
"build two real schema-v8 shards",
|| {
let mut left = TantivyIndex::open_or_create(&left_path).expect("create left shard");
let left_conversation = util::ConversationFixtureBuilder::new("claude_code")
.title("left compatibility shard")
.source_path(root.path().join("left.jsonl"))
.base_ts(1_700_000_000_000)
.messages(1)
.with_content(0, "quill facade migration keeps oauth lexical history")
.build_normalized();
left.add_conversation(&left_conversation)
.expect("ingest left shard");
left.commit().expect("commit left shard");
let mut right = TantivyIndex::open_or_create(&right_path).expect("create right shard");
let right_conversation = util::ConversationFixtureBuilder::new("codex")
.title("right compatibility shard")
.source_path(root.path().join("right.jsonl"))
.base_ts(1_700_000_001_000)
.messages(1)
.with_content(0, "tantivy schema eight survives restart and merge")
.build_normalized();
right
.add_conversation(&right_conversation)
.expect("ingest right shard");
right.commit().expect("commit right shard");
},
);
tracker.phase(
"reopen_merge",
"reopen both shards and merge real index files",
|| {
let left = TantivyIndex::open_or_create(&left_path).expect("reopen left shard");
let right = TantivyIndex::open_or_create(&right_path).expect("reopen right shard");
assert_eq!(
left.reader()
.expect("left reader")
.doc_count()
.expect("doc count"),
1
);
assert_eq!(
right
.reader()
.expect("right reader")
.doc_count()
.expect("doc count"),
1
);
drop((left, right));
let merged = TantivyIndex::merge_compatible_index_directories(
&merged_path,
&[left_path.as_path(), right_path.as_path()],
)
.expect("merge compatible schema-v8 shards");
assert_eq!(
merged
.reader()
.expect("merged reader")
.doc_count()
.expect("doc count"),
2
);
},
);
tracker.phase(
"query_restart",
"query, drop every owner, and query after fresh reopen",
|| {
for (query, expected_agent, expected_content) in [
(
"oauth",
"claude_code",
"quill facade migration keeps oauth lexical history",
),
(
"schema eight",
"codex",
"tantivy schema eight survives restart and merge",
),
] {
let client = SearchClient::open(&merged_path, None)
.expect("open merged search client")
.expect("merged search client exists");
let hits = client
.search(query, SearchFilters::default(), 10, 0, FieldMask::FULL)
.expect("query merged index");
assert_eq!(hits.len(), 1, "query {query:?} must find its source shard");
assert_eq!(hits[0].agent, expected_agent);
assert_eq!(hits[0].content, expected_content);
drop(client);
let restarted = SearchClient::open(&merged_path, None)
.expect("fresh reopen merged search client")
.expect("freshly reopened search client exists");
let restarted_hits = restarted
.search(query, SearchFilters::default(), 10, 0, FieldMask::FULL)
.expect("query after fresh reopen");
assert_eq!(
restarted_hits.len(),
1,
"query {query:?} must survive fresh reopen"
);
assert_eq!(restarted_hits[0].agent, expected_agent);
assert_eq!(restarted_hits[0].content, expected_content);
}
},
);
tracker.verbose(&format!(
"backend=tantivy namespace=lexical_tantivy schema_version={} documents=2",
frankensearch::lexical_tantivy::CASS_SCHEMA_VERSION
));
tracker.complete();
}
/// Verify agent filter works through the frankensearch pipeline.
#[test]
fn agent_filter_through_frankensearch_pipeline() {
let dir = TempDir::new().unwrap();
let mut index = TantivyIndex::open_or_create(dir.path()).unwrap();
let conv_claude = util::ConversationFixtureBuilder::new("claude_code")
.title("claude session")
.source_path(dir.path().join("claude.jsonl"))
.base_ts(1_700_000_000_000)
.messages(1)
.with_content(0, "debugging the database connection pool")
.build_normalized();
let conv_codex = util::ConversationFixtureBuilder::new("codex")
.title("codex session")
.source_path(dir.path().join("codex.jsonl"))
.base_ts(1_700_000_001_000)
.messages(1)
.with_content(0, "debugging the cache invalidation logic")
.build_normalized();
index.add_conversation(&conv_claude).unwrap();
index.add_conversation(&conv_codex).unwrap();
index.commit().unwrap();
let client = SearchClient::open(dir.path(), None)
.unwrap()
.expect("client");
// Search with agent filter
let mut filters = SearchFilters::default();
filters.agents.insert("claude_code".to_string());
let hits = client
.search("debugging", filters, 10, 0, FieldMask::FULL)
.unwrap();
// Should only find claude_code results
assert!(!hits.is_empty());
for hit in &hits {
assert_eq!(
hit.agent, "claude_code",
"agent filter should only return claude_code results"
);
}
}
// =============================================================================
// RRF FUSION VERIFICATION
// =============================================================================
/// Verify that frankensearch rrf_fuse is available and produces valid scores.
/// This tests the function signature and basic correctness, not the full hybrid
/// pipeline (which requires both lexical and semantic indexes).
#[test]
fn frankensearch_rrf_fuse_produces_valid_scores() {
use frankensearch::{RrfConfig, ScoreSource, ScoredResult, VectorHit, rrf_fuse};
let lexical_results = vec![
ScoredResult {
doc_id: "doc_a".into(),
score: 10.0,
source: ScoreSource::Lexical,
index: None,
fast_score: None,
quality_score: None,
lexical_score: Some(10.0),
rerank_score: None,
explanation: None,
metadata: None,
},
ScoredResult {
doc_id: "doc_b".into(),
score: 5.0,
source: ScoreSource::Lexical,
index: None,
fast_score: None,
quality_score: None,
lexical_score: Some(5.0),
rerank_score: None,
explanation: None,
metadata: None,
},
];
let semantic_results = vec![
VectorHit {
index: 0,
score: 0.95,
doc_id: "doc_b".into(),
},
VectorHit {
index: 1,
score: 0.8,
doc_id: "doc_c".into(),
},
];
let config = RrfConfig {
k: 60.0,
..RrfConfig::default()
};
let fused = rrf_fuse(&lexical_results, &semantic_results, 100, 0, &config);
assert!(!fused.is_empty(), "RRF fusion should produce results");
// doc_b appears in both lists, so should have highest RRF score
let top = &fused[0];
assert_eq!(
top.doc_id, "doc_b",
"doc_b should be ranked highest (appears in both lists)"
);
// Verify all scores are positive
for result in &fused {
assert!(result.rrf_score > 0.0, "RRF scores should be positive");
}
// Bead 7k7pl: pin the EXACT set of doc_ids, not just presence of
// each. The fusion contract says: given two input lists with doc_a
// + doc_b and doc_b + doc_c, RRF must produce EXACTLY {doc_a,
// doc_b, doc_c} — no extras (would indicate phantom docs leaking
// from another source), no missing ids (would indicate a dedup
// bug). Three separate `.contains()` probes accept a regression
// that also introduces extra doc_ids.
let doc_ids: HashSet<&str> = fused.iter().map(|r| r.doc_id.as_str()).collect();
let expected: HashSet<&str> = ["doc_a", "doc_b", "doc_c"].into_iter().collect();
assert_eq!(
doc_ids, expected,
"RRF fusion output must contain EXACTLY the union of the two input \
lists with no phantom ids; got {:?}",
doc_ids
);
}
// =============================================================================
// SEARCH RESULT CONSISTENCY
// =============================================================================
/// Verify that multiple searches with the same query produce identical results.
/// This tests determinism of the frankensearch pipeline.
#[test]
fn search_results_are_deterministic() {
let dir = TempDir::new().unwrap();
let mut index = TantivyIndex::open_or_create(dir.path()).unwrap();
let conv = util::ConversationFixtureBuilder::new("claude_code")
.title("determinism test")
.source_path(dir.path().join("session.jsonl"))
.base_ts(1_700_000_000_000)
.messages(5)
.with_content(0, "error handling in the authentication module")
.with_content(1, "authentication token validation logic")
.with_content(2, "error recovery from network failures")
.with_content(3, "database query optimization techniques")
.with_content(4, "authentication flow diagram and documentation")
.build_normalized();
index.add_conversation(&conv).unwrap();
index.commit().unwrap();
let client = SearchClient::open(dir.path(), None)
.unwrap()
.expect("client");
let filters = SearchFilters::default();
// Run same query 3 times
let hits1 = client
.search("authentication", filters.clone(), 10, 0, FieldMask::FULL)
.unwrap();
let hits2 = client
.search("authentication", filters.clone(), 10, 0, FieldMask::FULL)
.unwrap();
let hits3 = client
.search("authentication", filters, 10, 0, FieldMask::FULL)
.unwrap();
// Same number of results
assert_eq!(hits1.len(), hits2.len());
assert_eq!(hits2.len(), hits3.len());
// Same ordering (compare source_path + line_number as stable identifiers)
for i in 0..hits1.len() {
assert_eq!(
hits1[i].source_path, hits2[i].source_path,
"result {i} source_path should be deterministic"
);
assert_eq!(
hits1[i].line_number, hits2[i].line_number,
"result {i} line_number should be deterministic"
);
}
}
/// Verify SearchClient produces results with expected field population.
#[test]
fn search_results_have_expected_fields() {
let dir = TempDir::new().unwrap();
let mut index = TantivyIndex::open_or_create(dir.path()).unwrap();
let conv = util::ConversationFixtureBuilder::new("claude_code")
.title("field test session")
.source_path(dir.path().join("session.jsonl"))
.base_ts(1_700_000_000_000)
.messages(1)
.with_content(
0,
"testing that all search hit fields are populated correctly",
)
.build_normalized();
index.add_conversation(&conv).unwrap();
index.commit().unwrap();
let client = SearchClient::open(dir.path(), None)
.unwrap()
.expect("client");
let filters = SearchFilters::default();
let hits = client
.search("testing", filters, 10, 0, FieldMask::FULL)
.unwrap();
assert!(!hits.is_empty());
let hit = &hits[0];
assert!(!hit.content.is_empty(), "content should be populated");
assert!(
!hit.source_path.is_empty(),
"source_path should be populated"
);
assert!(!hit.agent.is_empty(), "agent should be populated");
assert_eq!(hit.agent, "claude_code");
assert!(hit.score > 0.0, "score should be positive");
}