Files
kebab/crates/kebab-search/tests/common/mod.rs
altair823 7c85de065a chore: workspace-wide cleanup — clippy::pedantic baseline + auto-fix
cut PR v0.18.0 전 마지막 정리. 사용자 요청: "전체 코드베이스를 깔끔하고 알아보기 쉽게".

## Workspace lints

- `Cargo.toml` 의 `[workspace.lints.clippy]` 에 `pedantic = "warn"` (priority -1) + 의도적 allow-list 추가:
  - cast_possible_truncation / cast_possible_wrap / cast_sign_loss / cast_precision_loss — ONNX i64 / hash modular reduction 등 의도적 truncation.
  - doc_markdown / missing_errors_doc / missing_panics_doc — cosmetic doc style.
  - too_many_lines / module_name_repetitions / must_use_candidate / needless_pass_by_value / manual_let_else / items_after_statements / similar_names — informational only.
  - format_collect / match_wildcard_for_single_variants / trivially_copy_pass_by_ref / unnecessary_wraps — intentional patterns (exhaustive match, future Result variants 등).
  - default_trait_access — `Foo::default()` 가 idiomatic.
  - float_cmp — NLI / RRF score 의 explicit threshold 비교 의도.
  - struct_excessive_bools / case_sensitive_file_extension_comparisons / naive_bytecount / ignore_without_reason — domain-specific 의도.
  - format_push_string / return_self_not_must_use / match_same_arms — builder / wire-label / hot-path 패턴 보존.
  - needless_continue / used_underscore_binding / nonminimal_bool / unreadable_literal / many_single_char_names / doc_link_with_quotes / assigning_clones / collapsible_str_replace / trivial_regex / elidable_lifetime_names / range_plus_one / explicit_iter_loop / implicit_hasher / ref_option — remaining low-value style.
- 각 24 crate `Cargo.toml` 에 `[lints] workspace = true` 추가.

## Auto-fix

`cargo clippy --workspace --all-targets --fix` 적용 — 128 files changed, 552 insertions / 472 deletions. 주로:
- uninlined_format_args (~18): `format!("{}", x)` → `format!("{x}")`.
- redundant_closure_for_method_calls (~33): `.map(|x| x.foo())` → `.map(T::foo)`.
- 그 외 mechanical refactor.

## 검증

- `cargo clippy --workspace --all-targets -j 1 -- -D warnings` clean (pedantic + 모든 lint group).
- `cargo test --workspace --no-fail-fast -j 1` — **1293 tests pass + 1 pre-existing flaky fail** (`kebab-mcp::tools_call_ask_multi_hop::ask_tool_routes_multi_hop_true_to_decompose_first`, HOTFIX candidate, cleanup 무관). 회귀 0.

Wire 영향: 없음.
Behavior 영향: 없음 (mechanical refactor only).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 03:01:58 +00:00

304 lines
12 KiB
Rust

//! Shared scaffolding for kb-search hybrid integration tests.
//!
//! # Test policy
//!
//! Integration tests in `hybrid.rs` that touch `LanceVectorStore`
//! are marked `#[ignore]` AND call [`require_avx_or_panic`] inside
//! the test body so a `--ignored` invocation on a non-AVX host
//! fails loudly with a clear message rather than crashing later
//! inside Lance's f32 SIMD kernel with `SIGILL`.
//!
//! See `crates/kb-store-vector/tests/common/mod.rs` for the
//! original P3-3 rationale; this is a copy because that crate's
//! test commons are test-only and not part of its public surface.
#![allow(dead_code)]
use std::sync::Arc;
use kebab_config::Config;
use kebab_core::{
ChunkId, DocumentId, EmbeddingId, EmbeddingInput, EmbeddingKind,
EmbeddingModelId, EmbeddingVersion, IndexVersion, MediaType,
Retriever, SearchFilters, SearchHit, SearchMode, SearchQuery,
VectorRecord, VectorStore,
};
use kebab_embed::{Embedder, MockEmbedder};
use kebab_search::{LexicalRetriever, VectorRetriever};
use kebab_store_sqlite::SqliteStore;
use kebab_store_vector::LanceVectorStore;
use rusqlite::params;
use tempfile::TempDir;
/// Panic if the host CPU lacks AVX. Called from every `#[ignore]`-d
/// integration test body so that `cargo test -- --ignored` on a
/// non-AVX host fails loudly with a clear message instead of crashing
/// later inside a Lance SIMD kernel with `SIGILL`.
pub fn require_avx_or_panic() {
#[cfg(target_arch = "x86_64")]
{
assert!(std::is_x86_feature_detected!("avx"),
"kb-search hybrid integration test requires AVX-capable hardware; \
host CPU lacks AVX. Run on an AVX-capable machine."
);
}
}
/// Index version label used by hybrid integration tests so the
/// `index_version()` composite token is predictable in snapshots.
pub const TEST_LEX_INDEX_VERSION: &str = "v1.0-lex";
pub const TEST_VEC_INDEX_VERSION: &str = "v1.0-vec";
/// Embedding dimensions for tests. Kept small so MockEmbedder runs
/// fast and the Lance table stays compact on disk; production uses
/// 384 (multilingual-e5-small) but the retriever code is dim-agnostic.
pub const TEST_DIMENSIONS: usize = 16;
pub const TEST_MODEL_ID: &str = "mock-e5";
pub struct HybridEnv {
pub temp: TempDir,
pub config: Config,
pub sqlite: Arc<SqliteStore>,
pub vector_store: Arc<LanceVectorStore>,
pub embedder: Arc<MockEmbedder>,
}
impl HybridEnv {
pub fn new() -> Self {
let temp = tempfile::tempdir().expect("tempdir");
let mut config = Config::defaults();
config.storage.data_dir = temp.path().to_string_lossy().into_owned();
let sqlite = SqliteStore::open(&config).unwrap();
sqlite.run_migrations().unwrap();
let sqlite = Arc::new(sqlite);
let vector_store =
Arc::new(LanceVectorStore::new(&config, sqlite.clone()).unwrap());
let embedder = Arc::new(MockEmbedder::new(
EmbeddingModelId(TEST_MODEL_ID.to_string()),
EmbeddingVersion("v1".to_string()),
TEST_DIMENSIONS,
));
Self {
temp,
config,
sqlite,
vector_store,
embedder,
}
}
/// Build a `LexicalRetriever` over the shared SQLite store.
pub fn lexical_retriever(&self) -> LexicalRetriever {
LexicalRetriever::new(
Arc::clone(&self.sqlite),
IndexVersion(TEST_LEX_INDEX_VERSION.to_string()),
)
}
/// Build a `VectorRetriever` over the shared LanceVectorStore +
/// MockEmbedder + SQLite store.
pub fn vector_retriever(&self) -> VectorRetriever {
let store: Arc<dyn VectorStore + Send + Sync> =
Arc::clone(&self.vector_store) as Arc<dyn VectorStore + Send + Sync>;
let embed: Arc<dyn Embedder> =
Arc::clone(&self.embedder) as Arc<dyn Embedder>;
VectorRetriever::new(
store,
embed,
Arc::clone(&self.sqlite),
IndexVersion(TEST_VEC_INDEX_VERSION.to_string()),
)
}
/// Insert (asset, document, document_tags, chunk) rows directly.
/// We seed without going through `DocumentStore::put_document`
/// to keep this crate's test deps inside the Allowed list (no
/// `kb-parse-md` / `kb-normalize` / `kb-chunk`). The `chunks` row
/// also fires the V002 FTS5 triggers, so the lexical retriever
/// can find the row by `MATCH` without a manual rebuild.
pub fn seed_chunk(
&self,
chunk_id: &str,
doc_id: &str,
workspace_path: &str,
text: &str,
heading_path: &[&str],
tags: &[&str],
) {
let asset_id = format!("a{}", &doc_id[..31]);
let conn = self.sqlite.read_conn();
conn.execute(
"INSERT OR IGNORE INTO assets (
asset_id, source_uri, workspace_path, media_type, byte_len,
checksum, storage_kind, storage_path, discovered_at
) VALUES (?, ?, ?, '\"markdown\"', 0,
'deadbeefdeadbeefdeadbeefdeadbeef',
'reference', ?, '1970-01-01T00:00:00Z')",
params![
asset_id,
format!("file://{workspace_path}"),
workspace_path,
workspace_path,
],
)
.unwrap();
conn.execute(
"INSERT OR IGNORE INTO documents (
doc_id, asset_id, workspace_path, title, lang, source_type,
trust_level, parser_version, doc_version, schema_version,
metadata_json, provenance_json, created_at, updated_at
) VALUES (?, ?, ?, NULL, 'en', 'markdown', 'primary', 'v1', 1, 1,
'{}', '{}', '1970-01-01T00:00:00Z', '1970-01-01T00:00:00Z')",
params![doc_id, asset_id, workspace_path],
)
.unwrap();
for t in tags {
conn.execute(
"INSERT OR IGNORE INTO document_tags (doc_id, tag) VALUES (?, ?)",
params![doc_id, t],
)
.unwrap();
}
let heading_json = serde_json::to_string(heading_path).unwrap();
conn.execute(
"INSERT OR IGNORE INTO chunks (
chunk_id, doc_id, text, heading_path_json, section_label,
source_spans_json, token_estimate, chunker_version,
policy_hash, block_ids_json, created_at
) VALUES (?, ?, ?, ?, NULL,
'[{\"kind\":\"line\",\"start\":1,\"end\":3}]',
1, 'v1', 'h', '[]', '1970-01-01T00:00:00Z')",
params![chunk_id, doc_id, text, heading_json],
)
.unwrap();
}
/// High-level helper: seed a doc with the default media type
/// (Markdown) and embed its text. Returns the `DocumentId` so
/// callers can use it in `doc_id` filter tests.
pub fn insert_doc(&self, path: &str, text: &str) -> DocumentId {
self.insert_doc_with_media(path, text, MediaType::Markdown)
}
/// High-level helper: seed a doc with an explicit `MediaType`.
/// The `media_type` is serialized to JSON (mirrors how
/// `DocumentStore::put_document` writes it) and stored in `assets`.
pub fn insert_doc_with_media(
&self,
path: &str,
text: &str,
media: MediaType,
) -> DocumentId {
// Derive deterministic IDs from the path so repeated calls with
// the same path are idempotent (INSERT OR IGNORE).
let path_hash: String = {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut h = DefaultHasher::new();
path.hash(&mut h);
format!("{:032x}", h.finish())
};
let doc_id = format!("d{}", &path_hash[..31]);
let chunk_id = format!("c{}", &path_hash[..31]);
let asset_id = format!("a{}", &path_hash[..31]);
let media_json = serde_json::to_string(&media).expect("serialize MediaType");
let conn = self.sqlite.read_conn();
conn.execute(
"INSERT OR IGNORE INTO assets (
asset_id, source_uri, workspace_path, media_type, byte_len,
checksum, storage_kind, storage_path, discovered_at
) VALUES (?, ?, ?, ?, 0,
'deadbeefdeadbeefdeadbeefdeadbeef',
'reference', ?, '1970-01-01T00:00:00Z')",
params![
asset_id,
format!("file:///{path}"),
path,
media_json,
path,
],
)
.unwrap();
conn.execute(
"INSERT OR IGNORE INTO documents (
doc_id, asset_id, workspace_path, title, lang, source_type,
trust_level, parser_version, doc_version, schema_version,
metadata_json, provenance_json, created_at, updated_at
) VALUES (?, ?, ?, NULL, 'en', 'markdown', 'primary', 'v1', 1, 1,
'{}', '{}', '1970-01-01T00:00:00Z', '1970-01-01T00:00:00Z')",
params![doc_id, asset_id, path],
)
.unwrap();
let heading_json = "[]";
conn.execute(
"INSERT OR IGNORE INTO chunks (
chunk_id, doc_id, text, heading_path_json, section_label,
source_spans_json, token_estimate, chunker_version,
policy_hash, block_ids_json, created_at
) VALUES (?, ?, ?, ?, NULL,
'[{\"kind\":\"line\",\"start\":1,\"end\":1}]',
1, 'v1', 'h', '[]', '1970-01-01T00:00:00Z')",
params![chunk_id, doc_id, text, heading_json],
)
.unwrap();
drop(conn);
self.embed_and_upsert(&chunk_id, &doc_id, text, &[]);
DocumentId(doc_id)
}
/// Run a `SearchMode::Vector` query against the seeded corpus and
/// return the resulting `Vec<SearchHit>`.
pub fn run_vector_search(&self, query: &str, filters: &SearchFilters) -> Vec<SearchHit> {
let r = self.vector_retriever();
let q = SearchQuery {
text: query.to_string(),
mode: SearchMode::Vector,
k: 10,
filters: filters.clone(),
};
r.search(&q).expect("vector search")
}
/// Embed `text` as a Document and upsert it as the embedding for
/// `chunk_id`. Drives the same code path production uses:
/// MockEmbedder → VectorRecord → LanceVectorStore::upsert →
/// embedding_records committed.
pub fn embed_and_upsert(
&self,
chunk_id: &str,
doc_id: &str,
text: &str,
heading_path: &[&str],
) {
let inputs = [EmbeddingInput {
text,
kind: EmbeddingKind::Document,
}];
let mut vecs = self.embedder.embed(&inputs).unwrap();
let vector = vecs.remove(0);
let record = VectorRecord {
chunk_id: ChunkId(chunk_id.to_string()),
embedding_id: EmbeddingId(format!("e{}", &chunk_id[..31])),
vector,
doc_id: DocumentId(doc_id.to_string()),
text: text.to_string(),
heading_path: heading_path.iter().map(std::string::ToString::to_string).collect(),
model_id: EmbeddingModelId(TEST_MODEL_ID.to_string()),
model_version: EmbeddingVersion("v1".to_string()),
dimensions: TEST_DIMENSIONS,
};
self.vector_store.upsert(&[record]).unwrap();
}
}
/// Pad a short prefix to the 32-hex shape `kebab_core` newtypes expect.
pub fn id32(prefix: &str) -> String {
let mut s = prefix.to_string();
while s.len() < 32 {
s.push('0');
}
s.truncate(32);
s
}