refactor(core): 빈 re-export shim crate kebab-embed/kebab-llm → kebab-core 흡수

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
This commit is contained in:
2026-06-27 01:06:14 +00:00
parent 9dc7e14434
commit 2d68827cf5
39 changed files with 409 additions and 688 deletions

View File

@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project
Single-user local-first knowledge base + RAG. Rust 2024 workspace, 22 crates, single binary (`kebab`). All inference is local (Ollama + fastembed + whisper.cpp).
Single-user local-first knowledge base + RAG. Rust 2024 workspace, 20 crates, single binary (`kebab`). All inference is local (Ollama + fastembed + whisper.cpp).
The repo's documentation is split by audience — don't duplicate across them:

32
Cargo.lock generated
View File

@@ -4248,10 +4248,8 @@ dependencies = [
"kebab-chunk",
"kebab-config",
"kebab-core",
"kebab-embed",
"kebab-embed-local",
"kebab-embed-ollama",
"kebab-llm",
"kebab-llm-local",
"kebab-nli",
"kebab-parse-code",
@@ -4340,6 +4338,7 @@ version = "0.31.0"
dependencies = [
"anyhow",
"blake3",
"proptest",
"serde",
"serde_json",
"serde_json_canonicalizer",
@@ -4348,20 +4347,6 @@ dependencies = [
"unicode-normalization",
]
[[package]]
name = "kebab-embed"
version = "0.31.0"
dependencies = [
"anyhow",
"blake3",
"kebab-config",
"kebab-core",
"proptest",
"serde",
"thiserror 2.0.18",
"tracing",
]
[[package]]
name = "kebab-embed-local"
version = "0.31.0"
@@ -4369,7 +4354,7 @@ dependencies = [
"anyhow",
"fastembed",
"kebab-config",
"kebab-embed",
"kebab-core",
"serde_json",
"tempfile",
"tracing",
@@ -4409,15 +4394,6 @@ dependencies = [
"uuid",
]
[[package]]
name = "kebab-llm"
version = "0.31.0"
dependencies = [
"anyhow",
"kebab-core",
"proptest",
]
[[package]]
name = "kebab-llm-local"
version = "0.31.0"
@@ -4425,7 +4401,6 @@ dependencies = [
"anyhow",
"kebab-config",
"kebab-core",
"kebab-llm",
"reqwest 0.12.28",
"serde",
"serde_json",
@@ -4504,7 +4479,6 @@ dependencies = [
"kamadak-exif",
"kebab-config",
"kebab-core",
"kebab-llm",
"kebab-llm-local",
"ndarray",
"ort",
@@ -4559,7 +4533,6 @@ dependencies = [
"blake3",
"kebab-config",
"kebab-core",
"kebab-llm",
"kebab-nli",
"kebab-search",
"kebab-store-sqlite",
@@ -4581,7 +4554,6 @@ dependencies = [
"globset",
"kebab-config",
"kebab-core",
"kebab-embed",
"kebab-store-sqlite",
"kebab-store-vector",
"rusqlite",

View File

@@ -9,10 +9,8 @@ members = [
"crates/kebab-store-sqlite",
"crates/kebab-store-vector",
"crates/kebab-search",
"crates/kebab-embed",
"crates/kebab-embed-local",
"crates/kebab-embed-ollama",
"crates/kebab-llm",
"crates/kebab-llm-local",
"crates/kebab-rag",
"crates/kebab-app",

View File

@@ -35,6 +35,7 @@ P0~P5 직렬. P6~P9 P5 이후 병렬 가능.
머지 후 발견된 모든 deviation / hotfix 의 dated 로그는 [tasks/HOTFIXES.md](tasks/HOTFIXES.md). 본 요약은 \"누군가가 인수받을 때 알아두면 시간을 많이 절약하는\" 항목만:
- **2026-06-27 ponytail-audit 정리 arc (over-engineering 제거)** — 1인 RAG 가 과복잡해진 표면·구조를 감사 후 정리(능력 불변). (1) #219 죽은 search-cache scaffold 제거 — #214 spine 에서 LRU 캐시를 없앤 뒤 남은 `App::search_uncached`/`search_uncached_with_config` facade/`search --no-cache`·`--explain` 플래그/`explain_default` config/관련 주석. `search()` 는 byte-identical(본문 verbatim 이동), `search_cache: false` wire capability 만 유지. (2) #220 9개 동일 code AST chunker(`code_*_ast_v1.rs`) → 단일 `CodeAstV1Chunker { version_label }` + `for_lang(lang)` 통합 (**3030줄**). chunker 는 tree-sitter 미사용·lang 은 SourceSpan 데이터 → struct 차이는 VERSION_LABEL 문자열뿐. 라벨 verbatim 유지 → chunk_id byte-identical → **재인덱싱 0**; 9개 골든 스냅샷이 expected 무수정 통과로 증명. (3) pure re-export shim 이던 `kebab-embed`/`kebab-llm`(trait 은 이미 kebab-core 소유, mock+test helper 만 보유)를 kebab-core 의 default-OFF `mock` feature 로 흡수 — **22 → 20 crates**, trait surface·동작 불변(test-only + import-rename churn, dev-dep `features=["mock"]`). 후속(작은 tail): FusionPolicy 1-arm enum→inline, NliVerifier default-0 shim 제거, dual-YAML 통일. 자세한 내용: 각 PR(#219/#220/…) + `docs/ARCHITECTURE.md`.
- **2026-06-24 md-heading-v2: 예산 초과 청크 일반 분할** — v0.30.0. markdown 청커가 v1 의 "블록 미분할" 한계를 일반화 — 거대 list/code/table/paragraph 가 한 청크로 임베더 ctx 를 초과하던 문제를, `token_estimate > max_chunk_tokens`(신규 config, byte/3, default 4000)인 청크만 줄(→UTF-8 char) 경계로 분할해 해소. 미분할 청크는 v1 과 byte-identical. 분할 조각 chunk_id 는 `#seg{i}` 접미사로 충돌 회피, `max_chunk_tokens` 는 v2 policy_hash 에 fold(공유 ChunkPolicy 미변경). `chunker_version` v1→v2 라 다음 plain ingest 에서 markdown 1회 자동 재청크(코드/PDF 무영향). **동기**: strict 임베더(AMD Lemonade `/api/embed`)는 oversize 입력을 truncate 아닌 거부(`500 too large`) — ollama 가 조용히 truncate 하던 걸 청커가 애초에 안 만들도록. **known limitation**: 분할 조각 citation 은 블록 단위(sub-line 정밀 아님). 도그푸딩(실험 KB, arctic@Lemonade): v2 전 620 중 2 doc 임베드 실패 → v2 후 620/620·7114 청크 전부 ≤4000, "WiredTiger excessive memory" 질의에 거대 doc SERVER-22906 가 1위(0.977). 자세한 내용: `tasks/HOTFIXES.md` (2026-06-24), 설계 `docs/superpowers/plans/2026-06-24-md-heading-v2-oversize-split.md`.
- **2026-06-21 provenance 출처 필터: `[[workspace.sources]]` 멀티소스 + `--source`/`--source-type`** — v0.29.0. 혼합 출처 KB(위키+jira 등)에서 색인은 전부 하되 질의 시 출처로 좁히는 레버. config `[[workspace.sources]]`(각 id/root/trust_level/source_type) + `documents.source_id` 컬럼(V014, additive, 재색인 0) + config v3→v4 migration(`step_3_to_4`, 단일 root→implicit `default` source, 멱등) + 검색 `--source <id>` / `--source-type <type>`(lexical+vector 두 site, OR). trust precedence = frontmatter > per-source 기본값 > Primary. **설계 근거**: 전역 trust 곱셈가중(weighted-RRF)은 A/B 에서 반증(θ=0.85 만으로 incident MRR 0.918→0.340 절벽) — 필터가 see-saw 없는 올바른 레버. 도그푸딩(620 doc, jira400+wiki220): `--source wiki` concept 0.780→0.810, `--source jira` incident 0.918→0.975. **follow-up**: MCP search 필터 미노출 · `kebab list` source_id 미표시 · RAG provenance 라벨 미구현. 자세한 내용: `tasks/HOTFIXES.md` (2026-06-21).
- **2026-06-04 PP-OCRv5 ONNX Rust 네이티브 OCR** — v0.27.0. `[image.ocr] engine = "paddle-onnx"` 로 PP-OCRv5(검출+인식) ONNX 를 in-process(`ort` =2.0.0-rc.9) 실행 — Python 런타임/원격 호출 없이 큰 페이지 CPU <4초(Ollama vision ~50초 대비). default 는 여전히 `"ollama-vision"`. 후처리(min-area rect/unclip)는 pure-Rust. **함정**: unclip 은 corner 를 centroid 에서 방사 확장하면 안 되고 edge 별 polygon offset 이어야 함(방사 확장 시 wide/short 텍스트 박스 높이가 안 커져 글자 윗부분 잘림 → ㄷ→ㄴ, e2e CER 0.26). 수정 후 CER 0.005. 모델 ONNX 는 `crates/kebab-parse-image/assets/paddleocr-onnx/`(LFS). 자세한 내용: `tasks/HOTFIXES.md` (2026-06-04 PP-OCRv5 ONNX), spec/plan `docs/superpowers/{specs,plans}/2026-06-04-rust-native-ocr-*.md`.

View File

@@ -16,10 +16,8 @@ kebab-chunk = { path = "../kebab-chunk" }
kebab-store-sqlite = { path = "../kebab-store-sqlite" }
kebab-store-vector = { path = "../kebab-store-vector" }
kebab-search = { path = "../kebab-search" }
kebab-embed = { path = "../kebab-embed" }
kebab-embed-local = { path = "../kebab-embed-local" }
kebab-embed-ollama = { path = "../kebab-embed-ollama" }
kebab-llm = { path = "../kebab-llm" }
kebab-llm-local = { path = "../kebab-llm-local" }
kebab-rag = { path = "../kebab-rag" }
# p9-fb-41 PR-9c-2: facade construction of OnnxNliVerifier when
@@ -67,10 +65,6 @@ rusqlite = { workspace = true }
[dev-dependencies]
kebab-config = { path = "../kebab-config" }
# doc-side expansion (Phase 2) Task 4: ExpansionGenerator unit tests build
# MockLanguageModel (gated behind kebab-llm's `mock` feature, default OFF in
# [dependencies]). Enabling it here turns it on for the test build only.
kebab-llm = { path = "../kebab-llm", features = ["mock"] }
rusqlite = { workspace = true }
filetime = "0.2"
tempfile = { workspace = true }

View File

@@ -17,5 +17,18 @@ blake3 = { workspace = true }
serde_json_canonicalizer = "0.3"
unicode-normalization = "0.1"
[features]
default = []
# Opt-in `MockEmbedder` / `MockLanguageModel` + test helpers (src/mock.rs).
# Default OFF so release builds (no `--features mock`) compile the symbols out
# entirely (verifiable via `nm`/`cargo bloat`). Moved here from the deleted
# `kebab-embed` / `kebab-llm` re-export shim crates.
mock = []
[dev-dependencies]
# For the mock tests (tests/mock_embedder.rs, tests/mock_language_model.rs),
# which run only under `--features mock`.
proptest = { workspace = true }
[lints]
workspace = true

View File

@@ -20,6 +20,8 @@ pub mod ingest;
pub mod jobs;
pub mod media;
pub mod metadata;
#[cfg(feature = "mock")]
mod mock;
pub mod normalize;
pub mod search;
pub mod traits;
@@ -53,6 +55,10 @@ pub use ingest::{IngestItem, IngestItemKind, IngestReport, SkipExamples};
pub use jobs::{JobFilter, JobId, JobKind, JobRow, JobStatus};
pub use media::{AudioType, Checksum, ImageType, Lang, MediaType};
pub use metadata::{Metadata, Provenance, ProvenanceEvent, ProvenanceKind, SourceType, TrustLevel};
#[cfg(feature = "mock")]
pub use mock::{
MockEmbedder, MockLanguageModel, assert_finish_chunk, assert_unit_norm, assert_vector_shape,
};
pub use normalize::{nfc, to_posix};
pub use search::{
BulkSearchItem, BulkSearchResponse, BulkSearchSummary, DocFilter, DocSummary, IndexBytes,

View File

@@ -0,0 +1,319 @@
//! Deterministic mock `Embedder` / `LanguageModel` + test helpers.
//!
//! Compiled only when the `mock` feature is enabled. Default builds
//! (`cargo build`, no `--features mock`) MUST NOT contain the `MockEmbedder` /
//! `MockLanguageModel` symbols — verifiable by symbol scan (`nm`/`cargo bloat`).
//!
//! Moved here verbatim from the former `kebab-embed` / `kebab-llm` re-export
//! shim crates (folded into `kebab-core`); those crates defined no new types.
//!
//! # `MockEmbedder` determinism contract
//!
//! For every call to [`MockEmbedder::embed`], component `i` of the output
//! vector for input `(text, kind)` is computed as:
//!
//! ```text
//! h = blake3(seed_le8 || kind_byte || text_len_le8 || text_utf8 || i_le8)
//! raw_i64 = i64::from_le_bytes(h[0..8])
//! comp = (raw_i64 as f64 / i64::MAX as f64) as f32 // ∈ [-1.0, 1.0]
//! ```
//!
//! `kind_byte` is `0u8` for [`EmbeddingKind::Document`] and `1u8` for
//! [`EmbeddingKind::Query`] — mirrors the e5-style prefix behavior (the same
//! text in different roles produces different vectors). `text_len_le8` is the
//! length of `text_utf8` (in bytes) as a little-endian `u64`; it provides
//! domain separation so the boundary between `text` and the trailing `i_le8`
//! cannot be ambiguous (without it, e.g. `("ABCDEFGH", 0)` and
//! `("", u64::from_le_bytes(*b"ABCDEFGH"))` would hash identically).
//!
//! After the per-component pass each vector is **L2-normalized to unit
//! length** so downstream cosine-similarity tests can rely on a unit-norm
//! input (‖v‖ ≈ 1.0 within f32 epsilon × √dims — the per-component f32
//! truncation is bounded by `f32::EPSILON`, summed in quadrature gives
//! roughly `√dims · EPSILON` in the L2 norm). If a vector ends up all-zeros
//! (vanishingly unlikely from BLAKE3), it is left untouched rather than
//! dividing by zero.
//!
//! Invariants the contract guarantees:
//!
//! * Identical `(seed, kind, text, dimensions)` → byte-identical output.
//! * Different `kind` for the same text → different output (kind_byte differs).
//! * Different `text` → different output with overwhelming probability.
//! * All output components are finite (`is_finite()`).
//!
//! # `MockLanguageModel` streaming contract
//!
//! For every call to [`MockLanguageModel::generate_stream`]:
//!
//! 1. The configured `canned_response` is examined for any of `req.stop`. If
//! one or more stop strings are substrings of the response, the response
//! is truncated at the **earliest byte position** of any match (i.e., the
//! first stop string to land — ties broken by the order entries appear in
//! `req.stop`, since `Iterator::min` returns the first equal element on
//! ties, breaking by `req.stop` declaration order).
//! 2. The (possibly truncated) string is iterated by Unicode scalar
//! (`str::chars()`) and each character is yielded as
//! [`TokenChunk::Token`]`(c.to_string())`. This makes streaming UTF-8 safe
//! by construction (no character is split across chunks). Emits one
//! `TokenChunk` per Unicode scalar value (`char`), not per grapheme
//! cluster — Hangul jamo, emoji ZWJ sequences, and combining marks split
//! into multiple chunks. Acceptable for trait-shape testing; real adapters
//! MAY combine.
//! 3. After all tokens, a single terminal [`TokenChunk::Done`] is yielded
//! with:
//! * `finish_reason = FinishReason::Stop` if a stop string truncated the
//! canned text — mirroring real LLM behavior, which reports Stop on
//! stop-sequence termination regardless of the configured finish.
//! * `finish_reason = canned_finish.clone()` otherwise.
//! * `usage = canned_usage.clone()` always.
//!
//! No network. No filesystem. No async runtime. No tokenizer — `usage` fields
//! are whatever the constructor was given.
use crate::{
Embedder, EmbeddingInput, EmbeddingKind, EmbeddingModelId, EmbeddingVersion, FinishReason,
GenerateRequest, LanguageModel, ModelRef, TokenChunk, TokenUsage,
};
// ── Embed test helpers ────────────────────────────────────────────────────
/// Assert every vector has length `expected_dims` and contains only finite
/// floats. Intended for downstream test crates so they don't each rewrite the
/// shape check.
///
/// Panics on mismatch (test-only helper — callers are tests).
pub fn assert_vector_shape(vecs: &[Vec<f32>], expected_dims: usize) {
for (i, v) in vecs.iter().enumerate() {
assert_eq!(
v.len(),
expected_dims,
"vector {i}: dims {} != expected {expected_dims}",
v.len(),
);
for (j, x) in v.iter().enumerate() {
assert!(x.is_finite(), "vector {i}[{j}] = {x} is not finite");
}
}
}
/// Assert every vector has L2 norm within `tolerance` of `1.0`.
///
/// L2 norm is computed in `f64` (per-component square accumulation in `f64`
/// then `sqrt`) before truncating back to `f32`, so the comparison is not
/// dominated by accumulation error in the check itself — only the f32
/// truncation of the input vector's components contributes.
///
/// Tolerance guidance: callers pass their own. For `dims = 384` and
/// f32-truncated unit vectors, `5e-4` is a safe upper bound under quadratic
/// accumulation of per-component f32 truncation (`f32::EPSILON × √dims`).
/// Smaller dims tolerate tighter bounds; larger dims need looser ones.
///
/// Panics on mismatch (test-only helper — callers are tests).
pub fn assert_unit_norm(vecs: &[Vec<f32>], tolerance: f32) {
for (i, v) in vecs.iter().enumerate() {
let norm_sq: f64 = v.iter().map(|&x| f64::from(x) * f64::from(x)).sum();
let norm = norm_sq.sqrt() as f32;
assert!(
(norm - 1.0).abs() <= tolerance,
"vector {i}: ‖v‖ = {norm} (off from 1.0 by {})",
(norm - 1.0).abs(),
);
}
}
// ── LLM test helper ───────────────────────────────────────────────────────
/// Assert the streamed `TokenChunk` sequence ends with a [`TokenChunk::Done`]
/// frame. Per spec §7.2 / §0 Q5 every stream — even an erroring one — must
/// terminate with a `Done` chunk; this helper centralizes that contract check
/// so downstream test crates don't each rewrite it.
///
/// Panics on mismatch (test-only helper — callers are tests).
pub fn assert_finish_chunk(chunks: &[TokenChunk]) {
assert!(
matches!(chunks.last(), Some(TokenChunk::Done { .. })),
"stream must end with TokenChunk::Done; got {:?}",
chunks.last(),
);
}
// ── MockEmbedder ──────────────────────────────────────────────────────────
/// Deterministic test double. See module docs for the hashing recipe.
pub struct MockEmbedder {
model_id: EmbeddingModelId,
version: EmbeddingVersion,
dimensions: usize,
seed: u64,
}
impl MockEmbedder {
/// Construct with `seed = 0`. Use [`Self::with_seed`] to pick a different
/// seed (e.g., to verify two embedders with the same identity but
/// different seeds yield different vectors).
pub fn new(model_id: EmbeddingModelId, version: EmbeddingVersion, dimensions: usize) -> Self {
Self {
model_id,
version,
dimensions,
seed: 0,
}
}
/// Construct with an explicit seed. Useful for differential tests.
pub fn with_seed(
model_id: EmbeddingModelId,
version: EmbeddingVersion,
dimensions: usize,
seed: u64,
) -> Self {
Self {
model_id,
version,
dimensions,
seed,
}
}
fn kind_byte(kind: EmbeddingKind) -> u8 {
match kind {
EmbeddingKind::Document => 0,
EmbeddingKind::Query => 1,
}
}
fn component(&self, kind: EmbeddingKind, text: &str, i: usize) -> f32 {
let mut hasher = blake3::Hasher::new();
hasher.update(&self.seed.to_le_bytes());
hasher.update(&[Self::kind_byte(kind)]);
// Length-prefix `text` (LE u64) so the boundary between `text` and the
// trailing `i` field is unambiguous — without this, `("ABCDEFGH", 0)`
// and `("", u64::from_le_bytes(*b"ABCDEFGH"))` would feed identical
// bytes into the hasher.
hasher.update(&(text.len() as u64).to_le_bytes());
hasher.update(text.as_bytes());
hasher.update(&(i as u64).to_le_bytes());
let digest = hasher.finalize();
let bytes = digest.as_bytes();
let mut head = [0u8; 8];
head.copy_from_slice(&bytes[..8]);
let raw = i64::from_le_bytes(head);
// Map to [-1.0, 1.0]. `i64::MAX` is finite in f64 so the ratio is
// always finite. Casting back to f32 cannot produce a NaN/Inf for
// values in this range.
// Note: i64::MIN/i64::MAX gives -1.0000000000000002 → f32 cast rounds to -1.0; range [-1, 1] holds in f32 even with this asymmetry.
((raw as f64) / (i64::MAX as f64)) as f32
}
}
impl Embedder for MockEmbedder {
fn model_id(&self) -> EmbeddingModelId {
self.model_id.clone()
}
fn model_version(&self) -> EmbeddingVersion {
self.version.clone()
}
fn dimensions(&self) -> usize {
self.dimensions
}
fn embed(&self, inputs: &[EmbeddingInput<'_>]) -> anyhow::Result<Vec<Vec<f32>>> {
let mut out = Vec::with_capacity(inputs.len());
for input in inputs {
let mut v: Vec<f32> = (0..self.dimensions)
.map(|i| self.component(input.kind, input.text, i))
.collect();
// L2-normalize. Skip the rare all-zero case to avoid 0/0 = NaN.
let norm_sq: f64 = v.iter().map(|&x| f64::from(x) * f64::from(x)).sum();
if norm_sq > 0.0 {
let inv = (1.0 / norm_sq.sqrt()) as f32;
for x in &mut v {
*x *= inv;
}
}
out.push(v);
}
Ok(out)
}
}
// ── MockLanguageModel ─────────────────────────────────────────────────────
/// Deterministic test double. See module docs for the streaming recipe.
pub struct MockLanguageModel {
pub model_id: String,
pub provider: String,
pub context_tokens: usize,
pub canned_response: String,
pub canned_finish: FinishReason,
pub canned_usage: TokenUsage,
}
impl MockLanguageModel {
/// Apply `req.stop` to `canned_response`. Returns `(truncated_text,
/// stop_hit)` where `stop_hit` is true iff any stop string was found.
fn apply_stop<'a>(canned: &'a str, stop: &[String]) -> (&'a str, bool) {
// Earliest byte position wins. Ties break by first occurrence in
// `stop` (Iterator::min returns the first equal element, and we
// iterate `stop` in its declared order). Empty stop strings are
// ignored — they would otherwise match at position 0 and silently
// eat the entire response.
let earliest = stop
.iter()
.filter(|s| !s.is_empty())
.filter_map(|s| canned.find(s.as_str()))
.min();
match earliest {
// `str::find` returns a UTF-8 char boundary by contract, so direct byte-slice is sound.
Some(idx) => (&canned[..idx], true),
None => (canned, false),
}
}
}
impl LanguageModel for MockLanguageModel {
fn model_ref(&self) -> ModelRef {
ModelRef {
id: self.model_id.clone(),
provider: self.provider.clone(),
// Per §3.8: `dimensions` carries the embedder's output dim and is
// intentionally None for chat models.
dimensions: None,
}
}
fn context_tokens(&self) -> usize {
self.context_tokens
}
fn generate_stream(
&self,
req: GenerateRequest,
) -> anyhow::Result<Box<dyn Iterator<Item = anyhow::Result<TokenChunk>> + Send>> {
let (truncated, stop_hit) = Self::apply_stop(&self.canned_response, &req.stop);
// Pre-materialize the full chunk sequence into an owned Vec. This
// sidesteps lifetime juggling around `&self.canned_response` inside
// a `'static` iterator and trivially gives `Send` (Vec<TokenChunk>
// is Send because TokenChunk is Send).
let mut chunks: Vec<TokenChunk> = truncated
.chars()
.map(|c| TokenChunk::Token(c.to_string()))
.collect();
let finish_reason = if stop_hit {
FinishReason::Stop
} else {
self.canned_finish.clone()
};
chunks.push(TokenChunk::Done {
finish_reason,
usage: self.canned_usage.clone(),
});
Ok(Box::new(chunks.into_iter().map(Ok)))
}
}

View File

@@ -1,10 +1,13 @@
//! Integration tests for `MockEmbedder`. Gated behind the `mock` feature.
//!
//! Canonical invocation: `cargo test -p kb-embed --features mock`.
//! 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_embed::{
use kebab_core::{
Embedder, EmbeddingInput, EmbeddingKind, EmbeddingModelId, EmbeddingVersion, MockEmbedder,
assert_unit_norm, assert_vector_shape,
};

View File

@@ -1,10 +1,13 @@
//! Integration tests for `MockLanguageModel`. Gated behind the `mock` feature.
//!
//! Canonical invocation: `cargo test -p kb-llm --features mock`.
//! 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_llm::{
use kebab_core::{
FinishReason, GenerateRequest, LanguageModel, MockLanguageModel, TokenChunk, TokenUsage,
assert_finish_chunk,
};

View File

@@ -8,8 +8,8 @@ repository = { workspace = true }
description = "Local fastembed-rs adapter implementing kb_core::Embedder (multilingual-e5-large default, e5-small backwards-compat)"
[dependencies]
kebab-core = { path = "../kebab-core" }
kebab-config = { path = "../kebab-config" }
kebab-embed = { path = "../kebab-embed" }
# Default features bring `ort-download-binaries` (bundled ONNX runtime)
# and `hf-hub-native-tls` (first-run model download). No extra features
# needed for the multilingual-e5-{small,large} paths.
@@ -18,6 +18,10 @@ tracing = { workspace = true }
anyhow = { workspace = true }
[dev-dependencies]
# `tests/embed_model.rs` uses `kebab_core::assert_unit_norm` /
# `assert_vector_shape` (the `mock` module's test helpers, gated OFF by
# default). Enable the feature for the test build only.
kebab-core = { path = "../kebab-core", features = ["mock"] }
tempfile = { workspace = true }
serde_json = { workspace = true }

View File

@@ -1,5 +1,5 @@
//! `kb-embed-local` — `FastembedEmbedder`, a local ONNX-backed
//! [`Embedder`](kebab_embed::Embedder) implementation.
//! [`Embedder`](kebab_core::Embedder) implementation.
//!
//! Wraps [`fastembed::TextEmbedding`]. Default is `multilingual-e5-large`
//! (1024-dim, p9-fb-39b); `multilingual-e5-small` (384-dim) is also supported
@@ -29,7 +29,7 @@ use std::sync::Mutex;
use anyhow::{Context, Result};
use fastembed::{EmbeddingModel, InitOptions, TextEmbedding};
use kebab_config::EmbeddingModelCfg;
use kebab_embed::{Embedder, EmbeddingInput, EmbeddingKind, EmbeddingModelId, EmbeddingVersion};
use kebab_core::{Embedder, EmbeddingInput, EmbeddingKind, EmbeddingModelId, EmbeddingVersion};
/// Subdirectory under `config.storage.model_dir` where the fastembed
/// adapter writes / reads ONNX + tokenizer files. Hard-coded per task
@@ -224,7 +224,7 @@ pub(crate) fn check_dim(model_dim: usize, cfg_dim: usize) -> Result<()> {
#[cfg(test)]
mod tests {
use super::*;
use kebab_embed::EmbeddingInput;
use kebab_core::EmbeddingInput;
// ── check_dim ────────────────────────────────────────────────────
//

View File

@@ -23,7 +23,7 @@ use std::hash::{Hash, Hasher};
use std::sync::OnceLock;
use std::time::Instant;
use kebab_embed::{Embedder, EmbeddingInput, EmbeddingKind};
use kebab_core::{Embedder, EmbeddingInput, EmbeddingKind};
use kebab_embed_local::{FASTEMBED_CACHE_SUBDIR, FastembedEmbedder};
/// Resolve the fastembed cache dir from a `Config`'s storage paths,
@@ -149,12 +149,12 @@ fn output_vectors_are_l2_normalized() {
},
];
let out = emb.embed(&inputs).expect("embed");
// Per `kebab_embed::assert_unit_norm` docs: `5e-4` is the safe bound at
// Per `kebab_core::assert_unit_norm` docs: `5e-4` is the safe bound at
// 1024 dims (f32::EPSILON × √1024 ≈ 2.3e-6, but ONNX kernels add
// their own per-component noise; 1e-3 is very generous and matches
// the spec's `± 1e-3`).
kebab_embed::assert_unit_norm(&out, 1e-3);
kebab_embed::assert_vector_shape(&out, 1024);
kebab_core::assert_unit_norm(&out, 1e-3);
kebab_core::assert_vector_shape(&out, 1024);
}
// ─── determinism ──────────────────────────────────────────────────────

View File

@@ -1,33 +0,0 @@
[package]
name = "kebab-embed"
version = { workspace = true }
edition = { workspace = true }
rust-version = { workspace = true }
license = { workspace = true }
repository = { workspace = true }
description = "Embedder trait re-exports + opt-in deterministic MockEmbedder for downstream tests"
[dependencies]
kebab-core = { path = "../kebab-core" }
kebab-config = { path = "../kebab-config" }
serde = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
anyhow = { workspace = true }
# Used only by `MockEmbedder` (feature = "mock") for deterministic per-component
# hashing. Kept as an unconditional dep because `blake3` is already in the
# workspace lockfile (transitively via kb-core); pulling it in here adds zero
# build cost and keeps Cargo.toml simple.
blake3 = { workspace = true }
[features]
default = []
# Opt-in `MockEmbedder`. Default OFF so release builds (no `--features mock`)
# compile the symbol out entirely (verifiable via `nm`/`cargo bloat`).
mock = []
[dev-dependencies]
proptest = { workspace = true }
[lints]
workspace = true

View File

@@ -1,76 +0,0 @@
//! `kb-embed` — thin re-export crate for the [`Embedder`] trait surface.
//!
//! This crate exists so downstream code (`kb-store-vector`, `kb-search`,
//! adapters in p3-2) can `use kebab_embed::Embedder` and stay stable across
//! kb-core reorganizations. It defines **no new types**; everything is a
//! re-export of [`kebab_core`].
//!
//! ## Mock implementation
//!
//! [`MockEmbedder`] (gated behind the `mock` feature, default **OFF**) is a
//! deterministic test double. Real adapters (fastembed, candle, ollama-embed)
//! live in p3-2 and MUST NOT be implemented here.
//!
//! See `docs/superpowers/specs/2026-04-27-kebab-final-form-design.md` §7.1, §7.2,
//! §11 for the contract.
// ── Trait re-exports ──────────────────────────────────────────────────────
//
// Per spec §7.2 — these are the only public-surface types this crate offers.
// Adding new types is forbidden by the task contract.
pub use kebab_core::{Embedder, EmbeddingInput, EmbeddingKind, EmbeddingModelId, EmbeddingVersion};
// ── Test helper ───────────────────────────────────────────────────────────
/// Assert every vector has length `expected_dims` and contains only finite
/// floats. Intended for downstream test crates so they don't each rewrite the
/// shape check.
///
/// Panics on mismatch (test-only helper — callers are tests).
pub fn assert_vector_shape(vecs: &[Vec<f32>], expected_dims: usize) {
for (i, v) in vecs.iter().enumerate() {
assert_eq!(
v.len(),
expected_dims,
"vector {i}: dims {} != expected {expected_dims}",
v.len(),
);
for (j, x) in v.iter().enumerate() {
assert!(x.is_finite(), "vector {i}[{j}] = {x} is not finite");
}
}
}
/// Assert every vector has L2 norm within `tolerance` of `1.0`.
///
/// L2 norm is computed in `f64` (per-component square accumulation in `f64`
/// then `sqrt`) before truncating back to `f32`, so the comparison is not
/// dominated by accumulation error in the check itself — only the f32
/// truncation of the input vector's components contributes.
///
/// Tolerance guidance: callers pass their own. For `dims = 384` and
/// f32-truncated unit vectors, `5e-4` is a safe upper bound under quadratic
/// accumulation of per-component f32 truncation (`f32::EPSILON × √dims`).
/// Smaller dims tolerate tighter bounds; larger dims need looser ones.
///
/// Panics on mismatch (test-only helper — callers are tests).
pub fn assert_unit_norm(vecs: &[Vec<f32>], tolerance: f32) {
for (i, v) in vecs.iter().enumerate() {
let norm_sq: f64 = v.iter().map(|&x| f64::from(x) * f64::from(x)).sum();
let norm = norm_sq.sqrt() as f32;
assert!(
(norm - 1.0).abs() <= tolerance,
"vector {i}: ‖v‖ = {norm} (off from 1.0 by {})",
(norm - 1.0).abs(),
);
}
}
// ── MockEmbedder (feature = "mock") ───────────────────────────────────────
#[cfg(feature = "mock")]
mod mock;
#[cfg(feature = "mock")]
pub use mock::MockEmbedder;

View File

@@ -1,142 +0,0 @@
//! Deterministic mock embedder for downstream tests.
//!
//! Compiled only when the `mock` feature is enabled. Default builds
//! (`cargo build --release -p kb-embed`) MUST NOT contain the `MockEmbedder`
//! symbol — verifiable by symbol scan (`nm`, `cargo bloat`).
//!
//! ## Determinism contract
//!
//! For every call to [`MockEmbedder::embed`], component `i` of the output
//! vector for input `(text, kind)` is computed as:
//!
//! ```text
//! h = blake3(seed_le8 || kind_byte || text_len_le8 || text_utf8 || i_le8)
//! raw_i64 = i64::from_le_bytes(h[0..8])
//! comp = (raw_i64 as f64 / i64::MAX as f64) as f32 // ∈ [-1.0, 1.0]
//! ```
//!
//! `kind_byte` is `0u8` for [`EmbeddingKind::Document`] and `1u8` for
//! [`EmbeddingKind::Query`] — mirrors the e5-style prefix behavior (the same
//! text in different roles produces different vectors). `text_len_le8` is the
//! length of `text_utf8` (in bytes) as a little-endian `u64`; it provides
//! domain separation so the boundary between `text` and the trailing `i_le8`
//! cannot be ambiguous (without it, e.g. `("ABCDEFGH", 0)` and
//! `("", u64::from_le_bytes(*b"ABCDEFGH"))` would hash identically).
//!
//! After the per-component pass each vector is **L2-normalized to unit
//! length** so downstream cosine-similarity tests can rely on a unit-norm
//! input (‖v‖ ≈ 1.0 within f32 epsilon × √dims — the per-component f32
//! truncation is bounded by `f32::EPSILON`, summed in quadrature gives
//! roughly `√dims · EPSILON` in the L2 norm). If a vector ends up all-zeros
//! (vanishingly unlikely from BLAKE3), it is left untouched rather than
//! dividing by zero.
//!
//! Invariants the contract guarantees:
//!
//! * Identical `(seed, kind, text, dimensions)` → byte-identical output.
//! * Different `kind` for the same text → different output (kind_byte differs).
//! * Different `text` → different output with overwhelming probability.
//! * All output components are finite (`is_finite()`).
use kebab_core::{Embedder, EmbeddingInput, EmbeddingKind, EmbeddingModelId, EmbeddingVersion};
/// Deterministic test double. See module docs for the hashing recipe.
pub struct MockEmbedder {
model_id: EmbeddingModelId,
version: EmbeddingVersion,
dimensions: usize,
seed: u64,
}
impl MockEmbedder {
/// Construct with `seed = 0`. Use [`Self::with_seed`] to pick a different
/// seed (e.g., to verify two embedders with the same identity but
/// different seeds yield different vectors).
pub fn new(model_id: EmbeddingModelId, version: EmbeddingVersion, dimensions: usize) -> Self {
Self {
model_id,
version,
dimensions,
seed: 0,
}
}
/// Construct with an explicit seed. Useful for differential tests.
pub fn with_seed(
model_id: EmbeddingModelId,
version: EmbeddingVersion,
dimensions: usize,
seed: u64,
) -> Self {
Self {
model_id,
version,
dimensions,
seed,
}
}
fn kind_byte(kind: EmbeddingKind) -> u8 {
match kind {
EmbeddingKind::Document => 0,
EmbeddingKind::Query => 1,
}
}
fn component(&self, kind: EmbeddingKind, text: &str, i: usize) -> f32 {
let mut hasher = blake3::Hasher::new();
hasher.update(&self.seed.to_le_bytes());
hasher.update(&[Self::kind_byte(kind)]);
// Length-prefix `text` (LE u64) so the boundary between `text` and the
// trailing `i` field is unambiguous — without this, `("ABCDEFGH", 0)`
// and `("", u64::from_le_bytes(*b"ABCDEFGH"))` would feed identical
// bytes into the hasher.
hasher.update(&(text.len() as u64).to_le_bytes());
hasher.update(text.as_bytes());
hasher.update(&(i as u64).to_le_bytes());
let digest = hasher.finalize();
let bytes = digest.as_bytes();
let mut head = [0u8; 8];
head.copy_from_slice(&bytes[..8]);
let raw = i64::from_le_bytes(head);
// Map to [-1.0, 1.0]. `i64::MAX` is finite in f64 so the ratio is
// always finite. Casting back to f32 cannot produce a NaN/Inf for
// values in this range.
// Note: i64::MIN/i64::MAX gives -1.0000000000000002 → f32 cast rounds to -1.0; range [-1, 1] holds in f32 even with this asymmetry.
((raw as f64) / (i64::MAX as f64)) as f32
}
}
impl Embedder for MockEmbedder {
fn model_id(&self) -> EmbeddingModelId {
self.model_id.clone()
}
fn model_version(&self) -> EmbeddingVersion {
self.version.clone()
}
fn dimensions(&self) -> usize {
self.dimensions
}
fn embed(&self, inputs: &[EmbeddingInput<'_>]) -> anyhow::Result<Vec<Vec<f32>>> {
let mut out = Vec::with_capacity(inputs.len());
for input in inputs {
let mut v: Vec<f32> = (0..self.dimensions)
.map(|i| self.component(input.kind, input.text, i))
.collect();
// L2-normalize. Skip the rare all-zero case to avoid 0/0 = NaN.
let norm_sq: f64 = v.iter().map(|&x| f64::from(x) * f64::from(x)).sum();
if norm_sq > 0.0 {
let inv = (1.0 / norm_sq.sqrt()) as f32;
for x in &mut v {
*x *= inv;
}
}
out.push(v);
}
Ok(out)
}
}

View File

@@ -1,61 +0,0 @@
//! Compile-only test: verifies the crate's public surface (trait re-exports
//! and the `assert_vector_shape` helper) is reachable without the `mock`
//! feature.
//!
//! Runs under both `cargo test -p kb-embed` and
//! `cargo test -p kb-embed --features mock`.
use kebab_embed::{
Embedder, EmbeddingInput, EmbeddingKind, EmbeddingModelId, EmbeddingVersion,
assert_vector_shape,
};
/// A trivial in-test impl that does NOT rely on the `mock` feature — proves
/// the trait surface alone is enough to write an `Embedder`.
struct ZeroEmbedder {
dims: usize,
}
impl Embedder for ZeroEmbedder {
fn model_id(&self) -> EmbeddingModelId {
EmbeddingModelId("zero".into())
}
fn model_version(&self) -> EmbeddingVersion {
EmbeddingVersion("0".into())
}
fn dimensions(&self) -> usize {
self.dims
}
fn embed(&self, inputs: &[EmbeddingInput<'_>]) -> anyhow::Result<Vec<Vec<f32>>> {
Ok(inputs.iter().map(|_| vec![0.0; self.dims]).collect())
}
}
#[test]
fn reexports_compile_without_mock_feature() {
let e: Box<dyn Embedder> = Box::new(ZeroEmbedder { dims: 4 });
let inputs = [
EmbeddingInput {
text: "hello",
kind: EmbeddingKind::Document,
},
EmbeddingInput {
text: "world",
kind: EmbeddingKind::Query,
},
];
let v = e.embed(&inputs).expect("zero embed");
assert_eq!(v.len(), 2);
assert_vector_shape(&v, 4);
}
/// Sanity: when built WITHOUT `--features mock`, the `MockEmbedder` symbol
/// is absent. We can't usefully test `nm` from inside a unit test, but we
/// can at least confirm the cfg gate parses both ways. See PR notes for the
/// CI-side `nm`/`cargo bloat` symbol scan.
#[cfg(not(feature = "mock"))]
#[test]
fn mock_feature_off_compiles() {
// No-op — the test's existence proves the `not(feature = "mock")` gate
// compiles and the crate is usable without `MockEmbedder`.
}

View File

@@ -10,7 +10,6 @@ description = "Ollama HTTP adapter implementing kb_core::LanguageModel via req
[dependencies]
kebab-core = { path = "../kebab-core" }
kebab-config = { path = "../kebab-config" }
kebab-llm = { path = "../kebab-llm" }
# `default-features = false` drops the `default-tls` (native-tls / openssl)
# feature so we don't pull in a system OpenSSL; we explicitly pin rustls.
# Note: `default-features = false` does NOT drop tokio — reqwest 0.12's

View File

@@ -3,12 +3,12 @@
//!
//! ## Why a separate crate
//!
//! `kb-llm` re-exports the trait + [`MockLanguageModel`] for downstream tests.
//! Real adapters (Ollama, llama.cpp, candle) live outside `kb-llm` so swapping
//! providers stays config-only and so the trait crate has no heavy
//! dependencies. p4-2 ("first real LM") is the home of [`OllamaLanguageModel`]
//! and the [`LlmError`] enum the rest of the workspace will pattern-match
//! against.
//! `kebab-core` exposes the [`LanguageModel`] trait + a feature-gated
//! `MockLanguageModel` for downstream tests. Real adapters (Ollama, llama.cpp,
//! candle) live outside `kebab-core` so swapping providers stays config-only
//! and so the core crate stays free of heavy adapter dependencies. p4-2
//! ("first real LM") is the home of [`OllamaLanguageModel`] and the
//! [`LlmError`] enum the rest of the workspace will pattern-match against.
//!
//! ## Runtime contract
//!
@@ -40,10 +40,9 @@ pub use error::LlmError;
pub use ollama::OllamaLanguageModel;
// Re-export the trait surface so adapter consumers can `use kebab_llm_local::*`
// without also depending on `kb-llm` directly. These are the same symbols
// `kb-llm` re-exports from `kb-core`; this crate adds **no new types** to
// the trait surface (`LlmError` and `OllamaLanguageModel` are
// without also depending on `kb-core` directly. This crate adds **no new
// types** to the trait surface (`LlmError` and `OllamaLanguageModel` are
// implementation-side only).
pub use kebab_llm::{
pub use kebab_core::{
FinishReason, GenerateRequest, LanguageModel, ModelRef, TokenChunk, TokenUsage,
};

View File

@@ -60,7 +60,7 @@ use crate::error::LlmError;
/// `reqwest::blocking` adapter implementing [`LanguageModel`] over Ollama's
/// local HTTP API. Construction is cheap and offline; the first network
/// call happens inside [`generate_stream`].
/// call happens inside [`LanguageModel::generate_stream`].
pub struct OllamaLanguageModel {
client: reqwest::blocking::Client,
/// Already-validated endpoint URL string (e.g. `"http://127.0.0.1:11434"`).

View File

@@ -1,24 +0,0 @@
[package]
name = "kebab-llm"
version = { workspace = true }
edition = { workspace = true }
rust-version = { workspace = true }
license = { workspace = true }
repository = { workspace = true }
description = "LanguageModel trait re-export + feature-gated MockLanguageModel for downstream tests"
[dependencies]
kebab-core = { path = "../kebab-core" }
anyhow = { workspace = true }
[features]
default = []
# Opt-in `MockLanguageModel`. Default OFF so release builds (no `--features mock`)
# compile the symbol out entirely (verifiable via `nm`/`cargo bloat`).
mock = []
[dev-dependencies]
proptest = { workspace = true }
[lints]
workspace = true

View File

@@ -1,49 +0,0 @@
//! `kb-llm` — thin re-export crate for the [`LanguageModel`] trait surface.
//!
//! This crate exists so downstream code (`kb-rag`, adapters in p4-2) can
//! `use kebab_llm::LanguageModel` and stay stable across kb-core reorganizations.
//! It defines **no new types**; everything is a re-export of [`kebab_core`].
//!
//! ## Mock implementation
//!
//! [`MockLanguageModel`] (gated behind the `mock` feature, default **OFF**) is
//! a deterministic test double. Real adapters (Ollama, llama.cpp, candle) live
//! in p4-2 and MUST NOT be implemented here. Real adapters MAY return `Err`
//! from `generate_stream` itself (e.g., connection refused) before any chunk
//! is yielded; the mock never does.
//!
//! See `docs/superpowers/specs/2026-04-27-kebab-final-form-design.md` §7.1, §7.2,
//! §0 Q5 (streaming), §3.8 (`ModelRef`) for the contract.
// ── Trait re-exports ──────────────────────────────────────────────────────
//
// Per spec §7.2 — these are the only public-surface types this crate offers.
// Adding new types is forbidden by the task contract.
pub use kebab_core::{
FinishReason, GenerateRequest, LanguageModel, ModelRef, TokenChunk, TokenUsage,
};
// ── Test helper ───────────────────────────────────────────────────────────
/// Assert the streamed `TokenChunk` sequence ends with a [`TokenChunk::Done`]
/// frame. Per spec §7.2 / §0 Q5 every stream — even an erroring one — must
/// terminate with a `Done` chunk; this helper centralizes that contract check
/// so downstream test crates don't each rewrite it.
///
/// Panics on mismatch (test-only helper — callers are tests).
pub fn assert_finish_chunk(chunks: &[TokenChunk]) {
assert!(
matches!(chunks.last(), Some(TokenChunk::Done { .. })),
"stream must end with TokenChunk::Done; got {:?}",
chunks.last(),
);
}
// ── MockLanguageModel (feature = "mock") ──────────────────────────────────
#[cfg(feature = "mock")]
mod mock;
#[cfg(feature = "mock")]
pub use mock::MockLanguageModel;

View File

@@ -1,115 +0,0 @@
//! Deterministic mock language model for downstream tests.
//!
//! Compiled only when the `mock` feature is enabled. Default builds
//! (`cargo build --release -p kb-llm`) MUST NOT contain the `MockLanguageModel`
//! symbol — verifiable by symbol scan (`nm`/`cargo bloat`).
//!
//! ## Streaming contract
//!
//! For every call to [`MockLanguageModel::generate_stream`]:
//!
//! 1. The configured `canned_response` is examined for any of `req.stop`. If
//! one or more stop strings are substrings of the response, the response
//! is truncated at the **earliest byte position** of any match (i.e., the
//! first stop string to land — ties broken by the order entries appear in
//! `req.stop`, since `Iterator::min` returns the first equal element on
//! ties, breaking by `req.stop` declaration order).
//! 2. The (possibly truncated) string is iterated by Unicode scalar
//! (`str::chars()`) and each character is yielded as
//! [`TokenChunk::Token`]`(c.to_string())`. This makes streaming UTF-8 safe
//! by construction (no character is split across chunks). Emits one
//! `TokenChunk` per Unicode scalar value (`char`), not per grapheme
//! cluster — Hangul jamo, emoji ZWJ sequences, and combining marks split
//! into multiple chunks. Acceptable for trait-shape testing; real adapters
//! MAY combine.
//! 3. After all tokens, a single terminal [`TokenChunk::Done`] is yielded
//! with:
//! * `finish_reason = FinishReason::Stop` if a stop string truncated the
//! canned text — mirroring real LLM behavior, which reports Stop on
//! stop-sequence termination regardless of the configured finish.
//! * `finish_reason = canned_finish.clone()` otherwise.
//! * `usage = canned_usage.clone()` always.
//!
//! ## Non-effects
//!
//! - No network. No filesystem. No async runtime.
//! - No tokenizer. `usage.prompt_tokens` / `completion_tokens` are whatever
//! the constructor was given — the mock does not count.
use kebab_core::{FinishReason, GenerateRequest, LanguageModel, ModelRef, TokenChunk, TokenUsage};
/// Deterministic test double. See module docs for the streaming recipe.
pub struct MockLanguageModel {
pub model_id: String,
pub provider: String,
pub context_tokens: usize,
pub canned_response: String,
pub canned_finish: FinishReason,
pub canned_usage: TokenUsage,
}
impl MockLanguageModel {
/// Apply `req.stop` to `canned_response`. Returns `(truncated_text,
/// stop_hit)` where `stop_hit` is true iff any stop string was found.
fn apply_stop<'a>(canned: &'a str, stop: &[String]) -> (&'a str, bool) {
// Earliest byte position wins. Ties break by first occurrence in
// `stop` (Iterator::min returns the first equal element, and we
// iterate `stop` in its declared order). Empty stop strings are
// ignored — they would otherwise match at position 0 and silently
// eat the entire response.
let earliest = stop
.iter()
.filter(|s| !s.is_empty())
.filter_map(|s| canned.find(s.as_str()))
.min();
match earliest {
// `str::find` returns a UTF-8 char boundary by contract, so direct byte-slice is sound.
Some(idx) => (&canned[..idx], true),
None => (canned, false),
}
}
}
impl LanguageModel for MockLanguageModel {
fn model_ref(&self) -> ModelRef {
ModelRef {
id: self.model_id.clone(),
provider: self.provider.clone(),
// Per §3.8: `dimensions` carries the embedder's output dim and is
// intentionally None for chat models.
dimensions: None,
}
}
fn context_tokens(&self) -> usize {
self.context_tokens
}
fn generate_stream(
&self,
req: GenerateRequest,
) -> anyhow::Result<Box<dyn Iterator<Item = anyhow::Result<TokenChunk>> + Send>> {
let (truncated, stop_hit) = Self::apply_stop(&self.canned_response, &req.stop);
// Pre-materialize the full chunk sequence into an owned Vec. This
// sidesteps lifetime juggling around `&self.canned_response` inside
// a `'static` iterator and trivially gives `Send` (Vec<TokenChunk>
// is Send because TokenChunk is Send).
let mut chunks: Vec<TokenChunk> = truncated
.chars()
.map(|c| TokenChunk::Token(c.to_string()))
.collect();
let finish_reason = if stop_hit {
FinishReason::Stop
} else {
self.canned_finish.clone()
};
chunks.push(TokenChunk::Done {
finish_reason,
usage: self.canned_usage.clone(),
});
Ok(Box::new(chunks.into_iter().map(Ok)))
}
}

View File

@@ -1,75 +0,0 @@
//! Compile-only test: verifies the crate's public surface (trait re-exports
//! and the `assert_finish_chunk` helper) is reachable without the `mock`
//! feature.
//!
//! Runs under both `cargo test -p kb-llm` and
//! `cargo test -p kb-llm --features mock`.
use kebab_llm::{
FinishReason, GenerateRequest, LanguageModel, ModelRef, TokenChunk, TokenUsage,
assert_finish_chunk,
};
/// A trivial in-test impl that does NOT rely on the `mock` feature — proves
/// the trait surface alone is enough to write a `LanguageModel`. It returns a
/// stream that terminates immediately with `Done`.
struct ZeroLanguageModel;
impl LanguageModel for ZeroLanguageModel {
fn model_ref(&self) -> ModelRef {
ModelRef {
id: "zero".into(),
provider: "zero".into(),
dimensions: None,
}
}
fn context_tokens(&self) -> usize {
0
}
fn generate_stream(
&self,
_req: GenerateRequest,
) -> anyhow::Result<Box<dyn Iterator<Item = anyhow::Result<TokenChunk>> + Send>> {
let chunks = vec![TokenChunk::Done {
finish_reason: FinishReason::Stop,
usage: TokenUsage {
prompt_tokens: 0,
completion_tokens: 0,
latency_ms: 0,
},
}];
Ok(Box::new(chunks.into_iter().map(Ok)))
}
}
#[test]
fn dyn_dispatch_via_box_works() {
let m: Box<dyn LanguageModel> = Box::new(ZeroLanguageModel);
assert_eq!(m.model_ref().id, "zero");
assert_eq!(m.context_tokens(), 0);
let req = GenerateRequest {
system: "sys".into(),
user: "usr".into(),
stop: vec![],
max_tokens: 16,
temperature: 0.0,
seed: None,
images: Vec::new(),
};
let stream = m.generate_stream(req).expect("stream");
let chunks: Vec<TokenChunk> = stream.map(|r| r.expect("ok chunk")).collect();
assert_eq!(chunks.len(), 1);
assert_finish_chunk(&chunks);
}
/// Sanity: when built WITHOUT `--features mock`, the `MockLanguageModel`
/// symbol is absent. We can't usefully test `nm` from inside a unit test, but
/// we can at least confirm the cfg gate parses both ways. See PR notes for
/// the CI-side `nm`/`cargo bloat` symbol scan.
#[cfg(not(feature = "mock"))]
#[test]
fn mock_feature_off_compiles() {
// No-op — the test's existence proves the `not(feature = "mock")` gate
// compiles and the crate is usable without `MockLanguageModel`.
}

View File

@@ -10,12 +10,9 @@ description = "Image extractor + EXIF + OCR (Ollama-vision) for the kebab pipe
[dependencies]
kebab-core = { path = "../kebab-core" }
kebab-config = { path = "../kebab-config" }
# `kebab-llm` re-exports the trait crate (`kebab-core::LanguageModel`)
# under a stable surface; the caption adapter consumes any
# `dyn LanguageModel`. We do NOT depend on `kebab-llm-local` (forbidden
# by p6-3 design §8) — the trait abstraction is exactly what spec
# requires.
kebab-llm = { path = "../kebab-llm" }
# The caption adapter consumes any `dyn LanguageModel` (the trait lives in
# `kebab-core`). We do NOT depend on `kebab-llm-local` (forbidden by p6-3
# design §8) — the trait abstraction is exactly what spec requires.
anyhow = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
@@ -66,12 +63,12 @@ tokio = { workspace = true, features = ["rt-multi-thread"] }
# font rendering.
ab_glyph = "0.2"
base64 = { workspace = true }
# `kebab-llm/mock` exposes `MockLanguageModel` for hermetic caption
# tests. Real adapters (Ollama) live in `kebab-llm-local`, which is
# `kebab-core`'s `mock` feature exposes `MockLanguageModel` for hermetic
# caption tests. Real adapters (Ollama) live in `kebab-llm-local`, which is
# only allowed at the dev-dep level here — the runtime crate stays
# trait-only, so the §8 forbidden-deps rule (no `kebab-llm-local`
# at runtime) is preserved.
kebab-llm = { path = "../kebab-llm", features = ["mock"] }
kebab-core = { path = "../kebab-core", features = ["mock"] }
kebab-llm-local = { path = "../kebab-llm-local" }
[lints]

View File

@@ -20,7 +20,7 @@
//! OFF at compile time). We collapse this into a single runtime gate
//! (`config.ingest.image.caption.enabled = false`, default OFF). Reasoning:
//! the captioning module's only extra deps are `base64` + `image` +
//! `kebab-llm` trait — all already pulled in by the rest of the
//! the `kebab-core` `LanguageModel` trait — all already pulled in by the rest of the
//! crate. A cargo feature would only complicate the build matrix
//! without saving meaningful binary weight. See `tasks/HOTFIXES.md`
//! (2026-05-02) for the deviation log.

View File

@@ -1,6 +1,6 @@
//! Integration tests for the caption adapter (P6-3).
//!
//! All hermetic tests use `MockLanguageModel` from `kebab-llm/mock`
//! All hermetic tests use `MockLanguageModel` from `kebab-core` (`mock` feature)
//! which captures `req.images` indirectly via the canned response. A
//! single opt-in test (`#[ignore]`) wires the real
//! `kebab-llm-local::OllamaLanguageModel` against the workspace's
@@ -15,7 +15,7 @@ use kebab_core::{
AssetId, BlockId, CommonBlock, FinishReason, GenerateRequest, ImageRefBlock, Lang,
LanguageModel, ModelRef, ProvenanceEvent, ProvenanceKind, SourceSpan, TokenChunk, TokenUsage,
};
use kebab_llm::MockLanguageModel;
use kebab_core::MockLanguageModel;
use kebab_parse_image::{apply_caption, caption_image};
use crate::common::red_100x50_png;

View File

@@ -11,7 +11,6 @@ description = "RAG pipeline: retrieve → gate → pack → generate → cite-
kebab-core = { path = "../kebab-core" }
kebab-config = { path = "../kebab-config" }
kebab-search = { path = "../kebab-search" }
kebab-llm = { path = "../kebab-llm" }
kebab-nli = { path = "../kebab-nli" }
kebab-store-sqlite = { path = "../kebab-store-sqlite" }
serde = { workspace = true }
@@ -24,7 +23,7 @@ anyhow = { workspace = true }
blake3 = { workspace = true }
[dev-dependencies]
kebab-llm = { path = "../kebab-llm", features = ["mock"] }
kebab-core = { path = "../kebab-core", features = ["mock"] }
tempfile = { workspace = true }
rusqlite = { workspace = true }
serde_json = { workspace = true }

View File

@@ -11,7 +11,7 @@ use std::sync::atomic::Ordering;
use common::{MockRetriever, RagEnv, id32, mk_hit, mk_hit_with_indexed_at};
use kebab_core::{FinishReason, LanguageModel, Retriever, SearchMode, TokenChunk, TokenUsage};
use kebab_llm::MockLanguageModel;
use kebab_core::MockLanguageModel;
use kebab_rag::{AskOpts, RagPipeline, RefusalReason, StreamEvent};
/// LM ID used everywhere — kept short so snapshots stay stable.

View File

@@ -12,7 +12,7 @@ use common::{MockRetriever, RagEnv, id32, mk_hit};
use kebab_core::{
FinishReason, LanguageModel, Retriever, SearchMode, TokenChunk, TokenUsage, TrustLevel,
};
use kebab_llm::MockLanguageModel;
use kebab_core::MockLanguageModel;
use kebab_rag::{AskOpts, RagPipeline};
const TEST_LM_ID: &str = "mock-lm";

View File

@@ -11,7 +11,7 @@ use common::{MockRetriever, RagEnv, id32, mk_hit};
use kebab_core::{
FinishReason, LanguageModel, RefusalReason, Retriever, SearchMode, TokenChunk, TokenUsage,
};
use kebab_llm::MockLanguageModel;
use kebab_core::MockLanguageModel;
use kebab_rag::{AskOpts, RagPipeline, StreamEvent};
const TEST_LM_ID: &str = "mock-lm";

View File

@@ -18,7 +18,6 @@ kebab-store-sqlite = { path = "../kebab-store-sqlite" }
# adapter — the concrete adapter (`kb-embed-local`) stays out of this
# crate per the spec's Forbidden deps list.
kebab-store-vector = { path = "../kebab-store-vector" }
kebab-embed = { path = "../kebab-embed" }
rusqlite = { workspace = true }
globset = { workspace = true }
serde_json = { workspace = true }
@@ -31,11 +30,11 @@ time = { workspace = true }
[dev-dependencies]
tempfile = { workspace = true }
# Hybrid integration tests inject a `MockEmbedder` (kb-embed `mock`
# Hybrid integration tests inject a `MockEmbedder` (kebab-core `mock`
# feature) and stand up a real `LanceVectorStore` on a tmp directory.
# The mock-retriever unit tests (the bulk of the hybrid suite) do not
# need either, but the integration / snapshot lane does.
kebab-embed = { path = "../kebab-embed", features = ["mock"] }
kebab-core = { path = "../kebab-core", features = ["mock"] }
[lints]
workspace = true

View File

@@ -22,7 +22,7 @@ use kebab_core::{
EmbeddingVersion, IndexVersion, MediaType, Retriever, SearchFilters, SearchHit, SearchMode,
SearchQuery, VectorRecord, VectorStore,
};
use kebab_embed::{Embedder, MockEmbedder};
use kebab_core::{Embedder, MockEmbedder};
use kebab_search::{LexicalRetriever, VectorRetriever};
use kebab_store_sqlite::SqliteStore;
use kebab_store_vector::LanceVectorStore;

View File

@@ -64,10 +64,8 @@ flowchart TB
vector["kebab-store-vector"]
end
subgraph Adapters ["traits + adapters"]
embed["kebab-embed<br/>(trait)"]
embedlocal["kebab-embed-local<br/>(fastembed, default)"]
embedollama["kebab-embed-ollama<br/>(Ollama /api/embed, opt-in)"]
llm["kebab-llm<br/>(trait)"]
llmlocal["kebab-llm-local<br/>(Ollama)"]
search["kebab-search"]
rag["kebab-rag"]
@@ -103,24 +101,20 @@ flowchart TB
pimg --> core
paud --> core
pcode --> core
embedlocal --> embed
embedlocal --> core
embedollama --> core
embedollama --> config
llmlocal --> llm
llmlocal --> core
rag --> search
rag --> llm
rag --> sqlite
rag --> nli
app --> nli
nli --> config
search --> sqlite
search --> vector
search --> embed
eval --> app
config --> core
embed --> core
llm --> core
sqlite --> core
vector --> core
chunk --> core
@@ -132,7 +126,7 @@ flowchart TB
UI → store/llm/parse 직접 의존 금지. 모든 user-facing 진입은 `kebab-app` facade 만 통한다 (frozen 설계 §8). `kebab-cli``--config <path>` flag 를 honor 하려면 `kebab_app::*_with_config(cfg, …)` companion 을 통해 Config 을 명시적으로 thread 하는 패턴 — 자세한 이유는 [tasks/HOTFIXES.md](../tasks/HOTFIXES.md) 의 `--config` 항목.
`kebab-parse-code` 의 외부 tree-sitter grammar crate 의존: P10-1A-2 에서 `tree-sitter-rust` 추가, P10-1B 에서 `tree-sitter-python` / `tree-sitter-typescript` / `tree-sitter-javascript` 추가, P10-1C-Go 에서 `tree-sitter-go` 추가, P10-1C-JK 에서 `tree-sitter-java` / `tree-sitter-kotlin-ng` 추가, P10-1D 에서 `tree-sitter-c` / `tree-sitter-cpp` 추가. 모두 `kebab-parse-code` 에만 격리 (facade 룰 — UI crate / chunker 가 직접 import 금지). Kotlin 은 `tree-sitter-kotlin-ng` 사용 (bare `tree-sitter-kotlin` 은 tree-sitter 0.210.23 에 고착 — 사용 불가). v0.18.0+ 부터 `kebab-source-fs` 는 자체 `code_meta` 모듈 (lang detect + skip helpers + BUILTIN_BLACKLIST) 을 보유, kebab-parse-code 와 분리 (refactor 2026-05-26). v0.19.0 부터 `kebab-parse-md``kebab-parse-types` (parser intermediate types) + `kebab-normalize` (CanonicalDocument lift) 두 crate 를 흡수 — 24 → 22 crates, design §3.7b 재작성 (HOTFIXES 2026-05-26). v0.20.1 부터 `kebab-search``lindera-ko-dic` 를 의존해 한국어 FTS5 형태소 tokenizer 지원 — V009 migration 으로 2자 이상 한국어 query 매칭 (Bug #8 closure).
`kebab-parse-code` 의 외부 tree-sitter grammar crate 의존: P10-1A-2 에서 `tree-sitter-rust` 추가, P10-1B 에서 `tree-sitter-python` / `tree-sitter-typescript` / `tree-sitter-javascript` 추가, P10-1C-Go 에서 `tree-sitter-go` 추가, P10-1C-JK 에서 `tree-sitter-java` / `tree-sitter-kotlin-ng` 추가, P10-1D 에서 `tree-sitter-c` / `tree-sitter-cpp` 추가. 모두 `kebab-parse-code` 에만 격리 (facade 룰 — UI crate / chunker 가 직접 import 금지). Kotlin 은 `tree-sitter-kotlin-ng` 사용 (bare `tree-sitter-kotlin` 은 tree-sitter 0.210.23 에 고착 — 사용 불가). v0.18.0+ 부터 `kebab-source-fs` 는 자체 `code_meta` 모듈 (lang detect + skip helpers + BUILTIN_BLACKLIST) 을 보유, kebab-parse-code 와 분리 (refactor 2026-05-26). v0.19.0 부터 `kebab-parse-md``kebab-parse-types` (parser intermediate types) + `kebab-normalize` (CanonicalDocument lift) 두 crate 를 흡수 — 24 → 22 crates, design §3.7b 재작성 (HOTFIXES 2026-05-26). v0.20.1 부터 `kebab-search``lindera-ko-dic` 를 의존해 한국어 FTS5 형태소 tokenizer 지원 — V009 migration 으로 2자 이상 한국어 query 매칭 (Bug #8 closure). pure re-export shim 이던 `kebab-embed` / `kebab-llm` (trait 은 이미 `kebab-core` 소유, mock + test helper 만 보유) 를 `kebab-core` 의 default-OFF `mock` feature 로 흡수 — 22 → 20 crates, trait surface · 동작 불변 (test-only + import-rename churn).
### 임베딩 백엔드 결정표 (v0.26.0)
@@ -195,10 +189,10 @@ kebab/
│ │ └── tier2_shared.rs # Tier 2 (p10-2): shared oversize fallback + Chunk builder helpers
│ ├── kebab-store-sqlite/ # SQLite + FTS5 (V001/V002/V003) (P1-6, P2-1, P3-3). src/derivation_cache.rs = derivation_cache 테이블 저장소 (V012, v0.21.0)
│ ├── kebab-search/ # Lexical + Vector + Hybrid retriever (P2-2, P3-4)
│ ├── kebab-embed/ kebab-embed-local/ # Embedder trait + fastembed adapter (P3-1, P3-2)
│ ├── kebab-embed-local/ # fastembed Embedder adapter (P3-2; trait lives in kebab-core)
│ ├── kebab-embed-ollama/ # Ollama /api/embed Embedder, opt-in provider=ollama (arctic 경로, v0.26.0)
│ ├── kebab-store-vector/ # LanceDB VectorStore (P3-3, P7-3 follow-up)
│ ├── kebab-llm/ kebab-llm-local/ # LanguageModel trait + Ollama adapter (P4-1, P4-2)
│ ├── kebab-llm-local/ # Ollama LanguageModel adapter (P4-2; trait lives in kebab-core)
│ ├── kebab-rag/ # RAG pipeline (P4-3)
│ ├── kebab-nli/ # NLI verifier (mDeBERTa-v3 XNLI, fb-41 PR-9a/9b/9c-1)
│ ├── kebab-eval/ # golden query runner + metrics (P5-1, P5-2)

View File

@@ -95,7 +95,7 @@ flowchart TB
- **새 미디어 타입 추가** (예: epub) — Parse → Normalize+Chunk → Store (chunker_version) → App facade (라우팅).
- **새 retrieval 모드** — Search → App facade (mode dispatch) → UI (--mode flag).
- **새 LLM 어댑터** — LLM (trait crate, 새 type 금지) + 새 `kebab-llm-<provider>` crate → App facade (config provider switch).
- **새 LLM 어댑터** — `kebab-core``LanguageModel` trait 구현 + 새 `kebab-llm-<provider>` crate → App facade (config provider switch).
- **TUI 신규 pane** — UI 만. Mode + Theme + InputBuffer 재사용.
## 다이어그램 제약

View File

@@ -6,7 +6,6 @@
| Crate | 역할 |
|-------|------|
| `kebab-embed` | `Embedder` trait re-export + 테스트 도구 (`assert_vector_shape`, `assert_unit_norm`) + optional `MockEmbedder` (feature gated). 새 type 추가 **금지** — 순수 facade. |
| `kebab-embed-local` | `FastembedEmbedder` — fastembed-rs 위 ONNX-backed local 임베더. default `multilingual-e5-small` 384d. |
## 구조
@@ -64,7 +63,7 @@ flowchart LR
## 주요 type / trait / 함수
**Trait** (`kebab-core`, re-export `kebab-embed`):
**Trait** (`kebab-core`):
- `Embedder::embed(&self, inputs: &[EmbeddingInput<'_>]) -> Result<Vec<Vec<f32>>>` — 출력 shape `inputs.len()` × `dimensions()`. 결과 벡터 모두 L2 = 1 + finite.
- `EmbeddingInput { text: &str, kind: EmbeddingKind }` — kind = `Document` / `Query` (E5 prefix 분기).
- `EmbeddingModelId(String)`, `EmbeddingVersion(String)``model_id × version × dim` 으로 vector store 테이블 분리.
@@ -75,22 +74,21 @@ flowchart LR
- E5 prefix 자동 적용: `Document``"passage: "`, `Query``"query: "` (§11.3).
- L2 정규화 = fastembed 내장 (`transformer_with_precedence`). 별도 정규화 안 함, 단 `assert_unit_norm` 테스트로 invariant pin.
**테스트 도구** (`kebab-embed`):
**테스트 도구** (`kebab-core`, feature `mock`):
- `assert_vector_shape(&[Vec<f32>], expected_dims)` — 길이 + finite 검증.
- `assert_unit_norm(&[Vec<f32>], tolerance)` — L2 norm 이 `1.0 ± tolerance`. f32 384d 권장 tol = `5e-4`.
- `MockEmbedder` (feature `mock`, default OFF) — 테스트용 deterministic double. 실 어댑터는 `kebab-embed-local` 또는 future P+ adapter 가 담당.
## 외부 의존
- `kebab-embed``kebab-core` 만 (re-export crate).
- `kebab-embed-local``kebab-embed` + `kebab-config`, `fastembed`, `anyhow`.
- `kebab-embed-local``kebab-core` + `kebab-config`, `fastembed`, `anyhow`.
- 외부 lib: `fastembed-rs` (ONNX wrapper, Hugging Face 모델 다운로드 포함). 로컬 ORT runtime.
- 외부 서비스: 첫 호출 시 모델 다운로드 (Hugging Face). 그 후 오프라인.
## 핵심 결정
- **`kebab-embed` = trait re-export only, **새 type 금지****.
**왜**: `kebab-store-vector`, `kebab-search` 등 downstream `use kebab_embed::Embedder` 안정 surface 의존. `kebab-core` 재구성 시 trait 이동해도 downstream 안 깨짐. spec 가 명시 — 어댑터 코드는 `kebab-embed-local` 또는 future `kebab-embed-<provider>` 로.
- **`Embedder` trait + 테스트 도구가 `kebab-core` 에 직접 거주 (`mock` feature)**.
**왜**: `kebab-store-vector`, `kebab-search` 등 downstream `use kebab_core::Embedder` 로 의존 — 별도 re-export shim 불필요. `MockEmbedder` / `assert_vector_shape` / `assert_unit_norm` 은 default-OFF `mock` feature 뒤에 둠. 과거의 순수 facade `kebab-embed``kebab-core` 로 fold-in 되어 삭제됨 (crate 그래프는 [`docs/ARCHITECTURE.md`](../../ARCHITECTURE.md) 참조). 어댑터 코드는 `kebab-embed-local` 또는 future `kebab-embed-<provider>` 로.
- **`multilingual-e5-small` 384d default**.
**왜**: 한국어 + 영어 동시 강함, ONNX 작음 (~120MB), 384d 가 retrieval 정확도/저장 비용 균형 좋음. e5 prefix 컨벤션 (`"passage: "` / `"query: "`) 으로 같은 모델이 doc + query 두 모드 cover.

View File

@@ -6,7 +6,6 @@
| Crate | 역할 |
|-------|------|
| `kebab-llm` | `LanguageModel` trait re-export + `MockLanguageModel` (feature `mock`, default OFF). 새 type 추가 **금지** — 순수 facade. |
| `kebab-llm-local` | `OllamaLanguageModel``reqwest::blocking` 기반 Ollama `POST /api/generate` 어댑터. line-delimited JSON streaming 디코드. |
## 구조
@@ -86,7 +85,7 @@ flowchart LR
## 주요 type / trait / 함수
**Trait** (`kebab-core`, re-export `kebab-llm`):
**Trait** (`kebab-core`):
- `LanguageModel::model_ref() -> ModelRef` — provider/model/version 식별. `Answer.model_ref` 으로 흘려서 wire payload 가 자가 식별.
- `LanguageModel::context_tokens() -> usize` — 모델 별 max prompt+completion 합. RAG 가 budget 계산에 사용.
- `LanguageModel::generate_stream(req: GenerateRequest) -> Result<Box<dyn Iterator<Item = Result<TokenChunk>> + Send>>` — async 안 됨, 매 next() 가 blocking. 모든 stream 이 마지막에 `TokenChunk::Done` 으로 끝남 (error 케이스 포함, §0 Q5).
@@ -106,20 +105,19 @@ flowchart LR
**`LlmError`** (`kebab-llm-local::error`):
- ConnectionRefused / HttpStatus(code) / Decode(json error) / Timeout / Aborted / 그 외 — `Err` 로 first chunk 전 surface 가능.
**테스트 도구** (`kebab-llm`):
**테스트 도구** (`kebab-core`, feature `mock`):
- `assert_finish_chunk(chunks: &[TokenChunk])` — 마지막이 `Done` 이어야 — 모든 stream contract pin.
- `MockLanguageModel` (feature `mock`, default OFF) — deterministic test double. 실 adapter 만 `Err` 가능, mock 은 항상 stream 시작 후 yield.
## 외부 의존
- `kebab-llm``kebab-core` 만 (re-export crate).
- `kebab-llm-local``kebab-llm` + `kebab-config`, `reqwest` (`blocking` feature, JSON), `serde` + `serde_json`, `thiserror`, `anyhow`.
- `kebab-llm-local``kebab-core` + `kebab-config`, `reqwest` (`blocking` feature, JSON), `serde` + `serde_json`, `thiserror`, `anyhow`.
- 외부 서비스: **Ollama HTTP** (default `http://127.0.0.1:11434`). default 모델 `gemma4:e4b` (OCR / caption / RAG 모두 같은 family — 단일 모델 다운로드면 전 시스템 동작).
## 핵심 결정
- **`kebab-llm` = trait re-export only, **새 type 금지****.
**왜**: `kebab-rag` 등 downstream `use kebab_llm::LanguageModel` 안정 surface 의존. 어댑터 (Ollama/llama.cpp/candle) 는 별 crate. swap config-only.
- **`LanguageModel` trait + `MockLanguageModel``kebab-core` 에 직접 거주 (`mock` feature)**.
**왜**: `kebab-rag` 등 downstream `use kebab_core::LanguageModel` 로 의존 — 별도 re-export shim 불필요. 어댑터 (Ollama/llama.cpp/candle) 는 여전히 별 crate swap config-only. 과거의 순수 facade `kebab-llm``kebab-core` 로 fold-in 되어 삭제됨 (crate 그래프는 [`docs/ARCHITECTURE.md`](../../ARCHITECTURE.md) 참조).
- **synchronous + blocking + stream iterator**.
**왜**: §0 Q5 가 streaming 명시. `async` 가 trait object 와 잘 안 맞음 (Rust async-in-trait 안정성 + Send bound 복잡). `reqwest::blocking` + line-delimited frame 의 `Iterator` 가 caller 코드 단순. RAG 가 동기 소비 + UI thread 가 별도 worker 로 spawn.

View File

@@ -106,7 +106,7 @@ flowchart LR
## 외부 의존
- crate dep: `kebab-core` + `kebab-config` + `kebab-search` (`Retriever` trait 만) + `kebab-llm` (trait 만) + `kebab-store-sqlite` (`DocumentStore` + `put_answer` helper).
- crate dep: `kebab-core` (`LanguageModel` trait 포함) + `kebab-config` + `kebab-search` (`Retriever` trait 만) + `kebab-store-sqlite` (`DocumentStore` + `put_answer` helper).
- 외부 lib: `serde`/`serde_json`, `regex` (citation marker `[N]` 매칭), `time` (timestamps), `blake3` (`TraceId` 채굴), `thiserror`, `anyhow`.
- 외부 서비스: 없음 (concrete adapter 가 가져옴).

View File

@@ -101,7 +101,7 @@ flowchart LR
## 외부 의존
- crate dep: `kebab-core` + `kebab-config` + `kebab-store-sqlite` + `kebab-store-vector` + `kebab-embed` (trait re-export). `kebab-embed-local` 은 caller 가 inject (forbidden direct dep).
- crate dep: `kebab-core` (`Embedder` trait 포함) + `kebab-config` + `kebab-store-sqlite` + `kebab-store-vector`. `kebab-embed-local` 은 caller 가 inject (forbidden direct dep).
- 외부 lib: `rusqlite` (FTS5 쿼리), `globset` (filter 매칭), `serde_json`, `tracing`.
- 외부 서비스: 없음.
@@ -125,7 +125,7 @@ flowchart LR
- **두 측 `index_version` mismatch = warn (not error)**.
**왜**: lexical 이 v2, vector 가 v1 (re-embed 안 했음) 같은 stale state 가 운영 시 일어남. 즉시 fail = ingest 끝나기 전 search 막힘. warning 만 띄우고 계속 동작 = 사용자가 인지하고 re-index 결정.
- **`kebab-embed` (trait crate) 만 의존, `kebab-embed-local` (concrete) **금지****.
- **`Embedder` trait (`kebab-core`) 만 의존, `kebab-embed-local` (concrete) **금지****.
**왜**: future MVP 의 swap 가능성 (candle, ollama-embed 등). `kebab-search` 가 concrete 어댑터 import 하면 `kebab-embed-local` 의 fastembed dep (큰 ONNX runtime) 이 search 에 강제 → unrelated build 비용. caller 가 runtime inject.
## 관련 spec / HOTFIXES