kebab-embed/kebab-llm 은 "새 type 없음"을 자처한 순수 re-export 셸이었다 (trait 은 이미 kebab-core 소유, mock + test helper 만 보유). "kebab-core 재구성 시 안정 surface" 라는 명분은 1인 RAG 엔 speculative YAGNI. 흡수: - kebab-core 에 default-OFF `mock` feature + src/mock.rs (MockEmbedder, MockLanguageModel, assert_vector_shape/assert_unit_norm/assert_finish_chunk 을 kebab_core:: → crate:: import 만 바꿔 verbatim 이동). - production import 2곳(kebab-embed-local, kebab-llm-local) + test import 다수(search/rag/parse-image/embed-local) 를 kebab_core 로 repoint. mock 쓰는 crate 는 dev-dep 에 features=["mock"] (default 빌드 무영향). - shim 자체 테스트: mock 동작 테스트는 kebab-core/tests/ 로 이동, reexports.rs(셸 재수출 테스트)는 폐기. - crates/kebab-embed, crates/kebab-llm 삭제 + workspace member/deps 정리. - ARCHITECTURE/HANDOFF/component README 의 crate 그래프·표·rationale 갱신 (22 → 20 crates). llm-local 의 broken intra-doc link 2건도 정리. trait surface·동작 불변 (test-only + import-rename). workspace build 는 mock default-OFF 라 mock 코드 미컴파일. 적대적 검증 3렌즈(build-test-integrity + behavior-identity[mock byte-identical] + dead-crate-completeness) 통과, clippy --workspace -D warnings 클린. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Mc6W1fgsrbFKTsqA6P8La
180 lines
5.8 KiB
Rust
180 lines
5.8 KiB
Rust
//! Integration tests for `MockEmbedder`. Gated behind the `mock` feature.
|
||
//!
|
||
//! Canonical invocation: `cargo test -p kebab-core --features mock`.
|
||
//! (Without `--features mock` this file compiles to nothing — the `cfg` gate
|
||
//! below short-circuits, since the mock lives in `kebab-core`'s own optional
|
||
//! `mock` module and cannot be enabled via a self dev-dependency.)
|
||
|
||
#![cfg(feature = "mock")]
|
||
|
||
use kebab_core::{
|
||
Embedder, EmbeddingInput, EmbeddingKind, EmbeddingModelId, EmbeddingVersion, MockEmbedder,
|
||
assert_unit_norm, assert_vector_shape,
|
||
};
|
||
use proptest::prelude::*;
|
||
|
||
fn mk(dims: usize) -> MockEmbedder {
|
||
MockEmbedder::new(
|
||
EmbeddingModelId("mock-test".into()),
|
||
EmbeddingVersion("0".into()),
|
||
dims,
|
||
)
|
||
}
|
||
|
||
#[test]
|
||
fn dyn_dispatch_through_box() {
|
||
let e: Box<dyn Embedder> = Box::new(mk(8));
|
||
assert_eq!(e.dimensions(), 8);
|
||
assert_eq!(e.model_id(), EmbeddingModelId("mock-test".into()));
|
||
assert_eq!(e.model_version(), EmbeddingVersion("0".into()));
|
||
|
||
let inputs = [EmbeddingInput {
|
||
text: "a fox",
|
||
kind: EmbeddingKind::Document,
|
||
}];
|
||
let v = e.embed(&inputs).expect("embed via box");
|
||
assert_eq!(v.len(), 1);
|
||
assert_vector_shape(&v, 8);
|
||
}
|
||
|
||
#[test]
|
||
fn identical_input_yields_byte_identical_vector() {
|
||
let e = mk(16);
|
||
let a = e
|
||
.embed(&[EmbeddingInput {
|
||
text: "the quick brown fox",
|
||
kind: EmbeddingKind::Document,
|
||
}])
|
||
.unwrap();
|
||
let b = e
|
||
.embed(&[EmbeddingInput {
|
||
text: "the quick brown fox",
|
||
kind: EmbeddingKind::Document,
|
||
}])
|
||
.unwrap();
|
||
// Vec<Vec<f32>> equality is byte-equal because we did not mutate
|
||
// either side and the hash + normalization path is pure.
|
||
assert_eq!(a, b);
|
||
}
|
||
|
||
#[test]
|
||
fn document_and_query_kinds_differ_for_same_text() {
|
||
let e = mk(32);
|
||
let inputs = [
|
||
EmbeddingInput {
|
||
text: "needle in haystack",
|
||
kind: EmbeddingKind::Document,
|
||
},
|
||
EmbeddingInput {
|
||
text: "needle in haystack",
|
||
kind: EmbeddingKind::Query,
|
||
},
|
||
];
|
||
let v = e.embed(&inputs).unwrap();
|
||
assert_eq!(v.len(), 2);
|
||
assert_vector_shape(&v, 32);
|
||
assert_ne!(
|
||
v[0], v[1],
|
||
"Document and Query kinds must produce different vectors for identical text"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn dimensions_match_construction() {
|
||
for dims in [1usize, 4, 64, 384, 768, 1024] {
|
||
let e = mk(dims);
|
||
assert_eq!(e.dimensions(), dims);
|
||
let v = e
|
||
.embed(&[EmbeddingInput {
|
||
text: "x",
|
||
kind: EmbeddingKind::Document,
|
||
}])
|
||
.unwrap();
|
||
assert_vector_shape(&v, dims);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn different_seeds_produce_different_vectors() {
|
||
let a = MockEmbedder::with_seed(
|
||
EmbeddingModelId("m".into()),
|
||
EmbeddingVersion("0".into()),
|
||
16,
|
||
0,
|
||
);
|
||
let b = MockEmbedder::with_seed(
|
||
EmbeddingModelId("m".into()),
|
||
EmbeddingVersion("0".into()),
|
||
16,
|
||
1,
|
||
);
|
||
let inputs = [EmbeddingInput {
|
||
text: "same input",
|
||
kind: EmbeddingKind::Document,
|
||
}];
|
||
assert_ne!(a.embed(&inputs).unwrap(), b.embed(&inputs).unwrap());
|
||
}
|
||
|
||
proptest! {
|
||
#![proptest_config(ProptestConfig {
|
||
cases: 100,
|
||
..ProptestConfig::default()
|
||
})]
|
||
|
||
/// 100 random `(text, kind)` pairs: every output vector must have
|
||
/// `len == dimensions`, contain only finite floats, contain no NaNs,
|
||
/// be L2 unit-norm within tolerance, be re-deterministic across calls,
|
||
/// differ between Document/Query kinds, and differ between distinct texts.
|
||
#[test]
|
||
fn random_inputs_yield_well_formed_vectors(
|
||
text in ".{0,256}",
|
||
text2 in ".{0,256}",
|
||
is_query in any::<bool>(),
|
||
// dims ≥ 2: a 1-dim unit-norm vector has only two possible values
|
||
// (`[1.0]` or `[-1.0]`), which makes the kind/text differential
|
||
// assertions degenerate. Pick a floor of 2 so the differentials
|
||
// exercise non-degenerate vector space.
|
||
dims in 2usize..=128,
|
||
) {
|
||
// Skip degenerate case where the two random texts collide; the
|
||
// "distinct text → distinct vector" assertion below requires them to
|
||
// differ.
|
||
prop_assume!(text != text2);
|
||
|
||
let e = mk(dims);
|
||
let kind = if is_query { EmbeddingKind::Query } else { EmbeddingKind::Document };
|
||
let v = e.embed(&[EmbeddingInput { text: &text, kind }]).unwrap();
|
||
prop_assert_eq!(v.len(), 1);
|
||
prop_assert_eq!(v[0].len(), dims);
|
||
for x in &v[0] {
|
||
prop_assert!(x.is_finite(), "component {x} not finite");
|
||
prop_assert!(!x.is_nan(), "component {x} is NaN");
|
||
}
|
||
|
||
// L2 unit-norm within tolerance. `5e-4` is a safe upper bound up to
|
||
// dims = 128 here (would-be floor: f32::EPSILON × √dims).
|
||
assert_unit_norm(&v, 5e-4);
|
||
|
||
// Re-determinism: embedding `text` as Document twice → byte-equal.
|
||
let doc_a = e
|
||
.embed(&[EmbeddingInput { text: &text, kind: EmbeddingKind::Document }])
|
||
.unwrap();
|
||
let doc_b = e
|
||
.embed(&[EmbeddingInput { text: &text, kind: EmbeddingKind::Document }])
|
||
.unwrap();
|
||
prop_assert_eq!(&doc_a, &doc_b, "Doc(text) must be byte-equal across calls");
|
||
|
||
// Kind differential: Doc(text) != Query(text).
|
||
let q = e
|
||
.embed(&[EmbeddingInput { text: &text, kind: EmbeddingKind::Query }])
|
||
.unwrap();
|
||
prop_assert_ne!(&doc_a, &q, "Doc(text) must differ from Query(text)");
|
||
|
||
// Text differential: Doc(text) != Doc(text2) when text != text2.
|
||
let doc_other = e
|
||
.embed(&[EmbeddingInput { text: &text2, kind: EmbeddingKind::Document }])
|
||
.unwrap();
|
||
prop_assert_ne!(&doc_a, &doc_other, "distinct texts must yield distinct Doc vectors");
|
||
}
|
||
}
|