feat(p4-1): llm-trait — kb-llm 크레이트 + MockLanguageModel #21

Merged
altair823 merged 1 commits from feat/p4-1-llm-trait into main 2026-05-01 13:45:19 +00:00
Owner

변경 요약

P4-1 llm-trait 작업입니다. 새 크레이트 kb-llm을 추가해서 P4 후속 작업 (p4-2 Ollama, 향후 llama.cpp / candle 등) 이 의존할 안정적인 trait 표면을 마련하고, downstream 테스트 (특히 p4-3 RAG 파이프라인)에서 사용할 결정적 mock을 feature flag로 노출합니다. P3-1 (kb-embed) 패턴을 그대로 따릅니다.

무엇을 했는가

트레이트 재노출

kb_core::{LanguageModel, GenerateRequest, TokenChunk, FinishReason, TokenUsage, ModelRef}을 그대로 pub use. 이 크레이트 자체에는 새로운 타입을 추가하지 않습니다 — kb-core가 재구성되더라도 downstream 호출 사이트가 kb_llm::LanguageModel 통해 안정 import.

MockLanguageModel (#[cfg(feature = "mock")], default OFF)

p4-3 RAG 파이프라인 테스트가 Ollama 의존성 없이 trait 객체를 받을 수 있게 만든 결정적 mock입니다. 핵심 동작:

  • canned_response을 Unicode scalar value 단위 (char)로 stream해서 TokenChunk::Token(c.to_string()) 발행. grapheme cluster 단위 아님 — Hangul jamo, emoji ZWJ sequence, combining mark은 여러 chunk로 split됩니다. trait-shape 검증용으로는 충분하고 실제 adapter는 combine 가능. 모듈 doc-comment에 명시.
  • req.stop 처리: 비어있지 않은 stop string마다 byte position을 찾고 Iterator::min으로 가장 이른 위치 채택 (동률은 req.stop 선언 순서가 이김). str::find는 UTF-8 char boundary를 반환하므로 직접 byte-slice가 안전.
  • stop이 매치되면 finish_reasonStop으로 override (OpenAI / Ollama real-world contract와 일치). 아니면 canned_finish을 verbatim 통과.
  • canned_response이 비어있어도 TokenChunk::Done { finish_reason, usage }은 항상 발행됨. 반환 iterator는 Vec<TokenChunk>::into_iter().map(Ok)을 box한 형태라 trivially Send.
  • I/O 무: 네트워크도 파일시스템도 건드리지 않습니다. trait 표면 검증이 목적.

헬퍼

  • assert_finish_chunk(chunks) — chunk 배열의 마지막이 Done임을 assert. proptest나 trait contract 검증 시 useful. P3-1의 assert_vector_shape와 같은 시리즈.

테스트

  • default 라인 (2건): dyn_dispatch_via_box_works (ZeroLanguageModel을 in-test 정의해서 trait 표면이 mock 없이도 사용 가능함을 증명) + mock_feature_off_compiles.
  • --features mock 라인 (9건): streams_then_done, honors_stop_strings, honors_first_stop_match, dyn_dispatch_via_box, concat_equals_canned, model_ref_has_no_dimensions, finish_reason_passes_through_when_no_stop_match, 그리고 100-case proptest. proptest는 random Unicode 문자열에 대해 (a) 마지막 chunk가 Done, (b) Token chunk 개수 == canned_response.chars().count(), (c) Token concat == canned (stop으로 truncated된 경우 truncated body)을 검증.
  • 워크스페이스 271 passed / 24 ignored / 0 failed. cargo clippy --workspace --all-targets [--features kb-llm/mock] -- -D warnings 두 feature 조합 모두 clean.

Symbol gating 검증

  • cargo build --release -p kb-llm (default): nm target/release/libkb_llm.rlib | grep MockLanguageModel0건.
  • cargo build --release -p kb-llm --features mock: trait impl symbols 3건 (generate_stream, model_ref, apply_stop).

spec Risks/notes의 "릴리스 빌드 (no --features mock)는 MockLanguageModel 심볼을 포함해서는 안 된다"가 심볼 레벨에서 보장됩니다.

의존성

Allowed deps 명세에는 kb-core, kb-config, serde, thiserror, tracing이 있는데 — 본 PR에서는 kb-core + anyhow만 직접 의존으로 선언했습니다. 리뷰의 MUST-FIX 사항이었습니다: 나머지 4개는 spec에 "Allowed"로 적혀있지만 이 skeleton 크레이트에서 실제로 imports되지 않아서, 종속성 그래프를 슬림하게 유지하기 위해 제거. p4-2/p4-3에서 필요해지면 그때 own dep site에 다시 추가.

anyhow는 trait 반환 타입 (anyhow::Result)이 강제하므로 forced.

Forbidden deps (reqwest, ureq, tokio, whisper-rs, kb-source-fs, kb-parse-md, kb-normalize, kb-chunk, kb-store-*, kb-embed*, kb-search, kb-rag, kb-tui, kb-desktop) 어느 것도 cargo tree -p kb-llm에 없습니다.

변경 파일

  • crates/kb-llm/Cargo.toml (신규)
  • crates/kb-llm/src/lib.rs (신규 — 재노출 + helper)
  • crates/kb-llm/src/mock.rs (신규)
  • crates/kb-llm/tests/mock.rs (신규)
  • crates/kb-llm/tests/reexports.rs (신규)
  • Cargo.toml (workspace member 등록)
  • Cargo.lock

Out of scope

  • 실제 adapter (kb-llm-local Ollama = p4-2).
  • 실제 tokenizer 기반 token 카운팅 (adapter의 usage.prompt_tokens best-effort).
  • Server-side cancellation / abort signal (P+).

design §7.1, §7.2, §0 Q5, §3.8 / report §11.2 참고.

## 변경 요약 P4-1 llm-trait 작업입니다. 새 크레이트 `kb-llm`을 추가해서 P4 후속 작업 (p4-2 Ollama, 향후 llama.cpp / candle 등) 이 의존할 안정적인 trait 표면을 마련하고, downstream 테스트 (특히 p4-3 RAG 파이프라인)에서 사용할 결정적 mock을 feature flag로 노출합니다. P3-1 (kb-embed) 패턴을 그대로 따릅니다. ## 무엇을 했는가 ### 트레이트 재노출 `kb_core::{LanguageModel, GenerateRequest, TokenChunk, FinishReason, TokenUsage, ModelRef}`을 그대로 `pub use`. 이 크레이트 자체에는 새로운 타입을 추가하지 않습니다 — kb-core가 재구성되더라도 downstream 호출 사이트가 `kb_llm::LanguageModel` 통해 안정 import. ### MockLanguageModel (`#[cfg(feature = "mock")]`, default OFF) p4-3 RAG 파이프라인 테스트가 Ollama 의존성 없이 trait 객체를 받을 수 있게 만든 결정적 mock입니다. 핵심 동작: - `canned_response`을 Unicode scalar value 단위 (`char`)로 stream해서 `TokenChunk::Token(c.to_string())` 발행. **grapheme cluster 단위 아님** — Hangul jamo, emoji ZWJ sequence, combining mark은 여러 chunk로 split됩니다. trait-shape 검증용으로는 충분하고 실제 adapter는 combine 가능. 모듈 doc-comment에 명시. - `req.stop` 처리: 비어있지 않은 stop string마다 byte position을 찾고 `Iterator::min`으로 가장 이른 위치 채택 (동률은 `req.stop` 선언 순서가 이김). `str::find`는 UTF-8 char boundary를 반환하므로 직접 byte-slice가 안전. - stop이 매치되면 `finish_reason`은 `Stop`으로 override (OpenAI / Ollama real-world contract와 일치). 아니면 `canned_finish`을 verbatim 통과. - `canned_response`이 비어있어도 `TokenChunk::Done { finish_reason, usage }`은 항상 발행됨. 반환 iterator는 `Vec<TokenChunk>::into_iter().map(Ok)`을 box한 형태라 trivially `Send`. - I/O 무: 네트워크도 파일시스템도 건드리지 않습니다. trait 표면 검증이 목적. ### 헬퍼 - `assert_finish_chunk(chunks)` — chunk 배열의 마지막이 `Done`임을 assert. proptest나 trait contract 검증 시 useful. P3-1의 `assert_vector_shape`와 같은 시리즈. ## 테스트 - **default 라인 (2건)**: `dyn_dispatch_via_box_works` (`ZeroLanguageModel`을 in-test 정의해서 trait 표면이 mock 없이도 사용 가능함을 증명) + `mock_feature_off_compiles`. - **`--features mock` 라인 (9건)**: streams_then_done, honors_stop_strings, honors_first_stop_match, dyn_dispatch_via_box, concat_equals_canned, model_ref_has_no_dimensions, finish_reason_passes_through_when_no_stop_match, 그리고 100-case proptest. proptest는 random Unicode 문자열에 대해 (a) 마지막 chunk가 Done, (b) Token chunk 개수 == `canned_response.chars().count()`, (c) Token concat == canned (stop으로 truncated된 경우 truncated body)을 검증. - 워크스페이스 271 passed / 24 ignored / 0 failed. `cargo clippy --workspace --all-targets [--features kb-llm/mock] -- -D warnings` 두 feature 조합 모두 clean. ## Symbol gating 검증 - `cargo build --release -p kb-llm` (default): `nm target/release/libkb_llm.rlib | grep MockLanguageModel` → **0건**. - `cargo build --release -p kb-llm --features mock`: trait impl symbols 3건 (`generate_stream`, `model_ref`, `apply_stop`). spec Risks/notes의 \"릴리스 빌드 (no `--features mock`)는 MockLanguageModel 심볼을 포함해서는 안 된다\"가 심볼 레벨에서 보장됩니다. ## 의존성 Allowed deps 명세에는 `kb-core`, `kb-config`, `serde`, `thiserror`, `tracing`이 있는데 — 본 PR에서는 **kb-core + anyhow만** 직접 의존으로 선언했습니다. 리뷰의 MUST-FIX 사항이었습니다: 나머지 4개는 spec에 \"Allowed\"로 적혀있지만 이 skeleton 크레이트에서 실제로 imports되지 않아서, 종속성 그래프를 슬림하게 유지하기 위해 제거. p4-2/p4-3에서 필요해지면 그때 own dep site에 다시 추가. `anyhow`는 trait 반환 타입 (`anyhow::Result`)이 강제하므로 forced. Forbidden deps (`reqwest`, `ureq`, `tokio`, `whisper-rs`, `kb-source-fs`, `kb-parse-md`, `kb-normalize`, `kb-chunk`, `kb-store-*`, `kb-embed*`, `kb-search`, `kb-rag`, `kb-tui`, `kb-desktop`) 어느 것도 `cargo tree -p kb-llm`에 없습니다. ## 변경 파일 - `crates/kb-llm/Cargo.toml` (신규) - `crates/kb-llm/src/lib.rs` (신규 — 재노출 + helper) - `crates/kb-llm/src/mock.rs` (신규) - `crates/kb-llm/tests/mock.rs` (신규) - `crates/kb-llm/tests/reexports.rs` (신규) - `Cargo.toml` (workspace member 등록) - `Cargo.lock` ## Out of scope - 실제 adapter (`kb-llm-local` Ollama = p4-2). - 실제 tokenizer 기반 token 카운팅 (adapter의 `usage.prompt_tokens` best-effort). - Server-side cancellation / abort signal (P+). design §7.1, §7.2, §0 Q5, §3.8 / report §11.2 참고.
altair823 added 1 commit 2026-05-01 13:39:38 +00:00
Establishes the kb-llm trait crate so concrete LLM adapters (p4-2
Ollama, future llama.cpp / candle) target a stable surface. Pure re-
export of kb_core::{LanguageModel, GenerateRequest, TokenChunk,
FinishReason, TokenUsage, ModelRef} plus a feature-gated deterministic
mock for downstream RAG tests (p4-3) that need an LLM trait object
without an Ollama dependency.

MockLanguageModel (cfg(feature = "mock"), default OFF):
- Holds canned_response + canned_finish + canned_usage + (model_id,
  provider, context_tokens). Pure in-memory; no I/O.
- generate_stream() honors GenerateRequest.stop: scans every non-empty
  stop string against the canned response, takes the earliest byte
  position (Iterator::min returns the first equal element on ties so
  declaration order in req.stop wins), truncates with a direct byte-
  slice (str::find returns a UTF-8 char boundary by contract).
- When a stop matches, finish_reason is overridden to Stop (matches
  OpenAI / Ollama real-world behaviour); otherwise the caller's
  canned_finish passes through verbatim.
- Emits one TokenChunk::Token per Unicode scalar value (char), NOT per
  grapheme cluster — Hangul jamo, emoji ZWJ sequences, combining
  marks split. Acceptable for trait-shape testing; real adapters MAY
  combine. Documented in module docs.
- Always terminates with TokenChunk::Done { finish_reason, usage } even
  if the canned response is empty. The returned iterator is a boxed
  Vec<TokenChunk>::into_iter().map(Ok), trivially Send.
- Real adapters MAY return Err from generate_stream itself (e.g.
  connection refused) before any chunk is yielded; the mock never does.
  Documented for the trait re-exporter consumer audience.

Helpers:
- assert_finish_chunk(chunks) — asserts the last chunk is a Done.
  Useful for proptests asserting trait contract over random inputs.

Tests:
- cargo test -p kb-llm (no features): 2 reexport / dyn-dispatch tests.
- cargo test -p kb-llm --features mock: 9 tests including 100-case
  proptest over random Unicode strings asserting Done terminator,
  char-count == streamed Token chunks, concat == canned (truncated by
  stop), plus explicit cases for stop-string truncation, first-stop-
  match precedence, model_ref dimensions=None invariant, finish reason
  pass-through.
- All 271 workspace tests pass; clippy clean for both default and
  mock-on feature configurations.

Symbol gating verified:
- cargo build --release -p kb-llm (default): nm shows zero
  MockLanguageModel symbols.
- cargo build --release -p kb-llm --features mock: three trait-impl
  symbols present. Spec invariant "release builds MUST NOT include
  MockLanguageModel" enforced at the symbol level.

Allowed deps respected: only kb-core (path) and anyhow (workspace,
forced by trait return type). Dropped kb-config / serde / thiserror /
tracing from the spec's allowed list — they are listed as Allowed but
nothing in this skeleton crate references them, and dropping them
keeps the dependency graph slim for downstream consumers. p4-2/p4-3
will add what they need at their own dep sites.

Forbidden deps (reqwest, ureq, tokio, whisper-rs, kb-source-fs,
kb-parse-md, kb-normalize, kb-chunk, kb-store-*, kb-embed*, kb-search,
kb-rag, kb-tui, kb-desktop) all absent from cargo tree -p kb-llm.

Out of scope: real adapter (p4-2 Ollama), token counting against the
real tokenizer, server-side cancellation / abort signals (P+).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
claude-reviewer-01 reviewed 2026-05-01 13:40:27 +00:00
claude-reviewer-01 left a comment
Member

P4-1 코드 리뷰 — 셀프 머지 게이트로 인해 COMMENT only.

spec compliance + code quality 양쪽 리뷰 결과 BLOCKER 0건. spec compliance는 PASS with NIT, code quality는 1 MUST-FIX (unused deps) + 8 NIT. MUST-FIX + 4개 NIT (byte-boundary 코멘트, min vs min_by_key doc 텍스트, grapheme cluster 경계 명문화, real adapter error 가능성 doc note)을 PR에 반영했습니다.

핵심 포인트:

  • MockLanguageModel은 P3-1 (kb-embed) 패턴을 그대로 따라서 #[cfg(feature = \"mock\")] + default OFF, 심볼 레벨에서 nm 검증 (default 0건, --features mock 3건).
  • stop string truncation이 Iterator::min의 stdlib stable-ordering 보장 + UTF-8 char boundary str::find contract를 활용해 명시적 tie-break / boundary check 없이도 정확.
  • 100-case proptest이 random Unicode 문자열에 대해 streaming + stop + Done 종결 invariant을 한 번에 cluster 검증.
  • spec Allowed deps에서 실 미사용 4개 (kb-config, serde, thiserror, tracing) 제거 — cargo metadata이 실 사용 의존성만 반영.

워크스페이스 default 271 passed / 24 ignored / 0 failed. clippy clean (default + --features mock).

inline 코멘트는 모두 잘 만든 결정에 대한 노트입니다. 머지 진행해도 됩니다. 다음은 P4-2 ollama-adapter.

P4-1 코드 리뷰 — 셀프 머지 게이트로 인해 COMMENT only. spec compliance + code quality 양쪽 리뷰 결과 BLOCKER 0건. spec compliance는 PASS with NIT, code quality는 1 MUST-FIX (unused deps) + 8 NIT. MUST-FIX + 4개 NIT (byte-boundary 코멘트, min vs min_by_key doc 텍스트, grapheme cluster 경계 명문화, real adapter error 가능성 doc note)을 PR에 반영했습니다. 핵심 포인트: - MockLanguageModel은 P3-1 (kb-embed) 패턴을 그대로 따라서 `#[cfg(feature = \"mock\")]` + default OFF, 심볼 레벨에서 `nm` 검증 (default 0건, --features mock 3건). - stop string truncation이 `Iterator::min`의 stdlib stable-ordering 보장 + UTF-8 char boundary `str::find` contract를 활용해 명시적 tie-break / boundary check 없이도 정확. - 100-case proptest이 random Unicode 문자열에 대해 streaming + stop + Done 종결 invariant을 한 번에 cluster 검증. - spec Allowed deps에서 실 미사용 4개 (kb-config, serde, thiserror, tracing) 제거 — `cargo metadata`이 실 사용 의존성만 반영. 워크스페이스 default 271 passed / 24 ignored / 0 failed. clippy clean (default + --features mock). inline 코멘트는 모두 잘 만든 결정에 대한 노트입니다. 머지 진행해도 됩니다. 다음은 P4-2 ollama-adapter.
@@ -0,0 +11,4 @@
kb-core = { path = "../kb-core" }
anyhow = { workspace = true }
[features]

spec Allowed deps 목록에서 실제로 imports되지 않는 kb-config, serde, thiserror, tracing을 제거. spec compliance를 좁게 해석하기보다 cargo metadata이 정확하게 "실 사용 deps"를 반영하도록 — clippy unused-crate-dependencies가 잡았을 cruft를 PR 단계에서 정리. p4-2/p4-3에서 필요해지면 그때 own dep site에서 추가.

spec Allowed deps 목록에서 실제로 imports되지 않는 `kb-config`, `serde`, `thiserror`, `tracing`을 제거. spec compliance를 좁게 해석하기보다 `cargo metadata`이 정확하게 "실 사용 deps"를 반영하도록 — clippy `unused-crate-dependencies`가 잡았을 cruft를 PR 단계에서 정리. p4-2/p4-3에서 필요해지면 그때 own dep site에서 추가.
@@ -0,0 +19,4 @@
//! [`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

module doc에 "Unicode scalar value (char)" vs "grapheme cluster" 차이를 명시 — 미래 누군가가 emoji ZWJ sequence가 분할되는 걸 보고 "버그다"라며 grapheme 단위로 "고치는" 회귀를 차단합니다. trait-shape 검증이 mock의 책임이고 grapheme combine은 real adapter의 책임이라는 경계를 명문화.

module doc에 "Unicode scalar value (`char`)" vs "grapheme cluster" 차이를 명시 — 미래 누군가가 emoji ZWJ sequence가 분할되는 걸 보고 "버그다"라며 grapheme 단위로 "고치는" 회귀를 차단합니다. trait-shape 검증이 mock의 책임이고 grapheme combine은 real adapter의 책임이라는 경계를 명문화.
@@ -0,0 +57,4 @@
// 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

stop string truncation 로직: Iterator::min이 동률에서 첫 element를 반환한다는 stdlib 보장에 의존해서 req.stop 선언 순서를 자연스럽게 tie-breaker로 활용. min_by_key을 안 쓴 이유 + UTF-8 char boundary 안전성 (str::find 반환값 직접 slice)을 코멘트로 박아두어 미래 reader가 "왜 더 명시적인 코드가 아닌가"의 답을 즉시 얻습니다.

stop string truncation 로직: `Iterator::min`이 동률에서 첫 element를 반환한다는 stdlib 보장에 의존해서 `req.stop` 선언 순서를 자연스럽게 tie-breaker로 활용. `min_by_key`을 안 쓴 이유 + UTF-8 char boundary 안전성 (`str::find` 반환값 직접 slice)을 코멘트로 박아두어 미래 reader가 "왜 더 명시적인 코드가 아닌가"의 답을 즉시 얻습니다.
@@ -0,0 +97,4 @@
// 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

stop이 매치되면 finish_reasonStop으로 override, 아니면 canned_finish verbatim 통과. spec line 73의 "truncate before emitting"이 finish_reason까지 명시 안 했지만 OpenAI / Ollama real-world contract와 일치하는 자연스러운 선택입니다. finish_reason_passes_through_when_no_stop_match 테스트로 두 경로 모두 pin.

stop이 매치되면 `finish_reason`을 `Stop`으로 override, 아니면 `canned_finish` verbatim 통과. spec line 73의 "truncate before emitting"이 finish_reason까지 명시 안 했지만 OpenAI / Ollama real-world contract와 일치하는 자연스러운 선택입니다. `finish_reason_passes_through_when_no_stop_match` 테스트로 두 경로 모두 pin.
@@ -0,0 +197,4 @@
.count();
prop_assert_eq!(token_count, canned.chars().count());
// Concatenation == canned (byte-equal).

100-case proptest이 random Unicode 문자열로 stop 처리 + char-by-char streaming + Done 종결 invariant을 한 번에 검증. assert_finish_chunk helper로 contract 위반을 즉시 명시적으로 fail. 향후 streaming logic을 손댈 때 모두 fail해야 정상이라는 의미라 회귀 안전망이 단단해졌습니다.

100-case proptest이 random Unicode 문자열로 stop 처리 + char-by-char streaming + Done 종결 invariant을 한 번에 검증. `assert_finish_chunk` helper로 contract 위반을 즉시 명시적으로 fail. 향후 streaming logic을 손댈 때 모두 fail해야 정상이라는 의미라 회귀 안전망이 단단해졌습니다.
@@ -0,0 +29,4 @@
fn generate_stream(
&self,
_req: GenerateRequest,
) -> anyhow::Result<Box<dyn Iterator<Item = anyhow::Result<TokenChunk>> + Send>> {

테스트 안에서 ZeroLanguageModel을 직접 정의해서 trait 표면 자체만으로 dyn dispatch가 동작함을 보인 게 의도가 좋습니다 — re-export 크레이트의 진짜 책임 ("upstream trait를 호환되게 노출하기")을 mock에 의존하지 않고 검증. mock feature가 꺼진 상태에서도 의미 있는 테스트가 남는 형태.

테스트 안에서 `ZeroLanguageModel`을 직접 정의해서 trait 표면 자체만으로 dyn dispatch가 동작함을 보인 게 의도가 좋습니다 — re-export 크레이트의 진짜 책임 ("upstream trait를 호환되게 노출하기")을 mock에 의존하지 않고 검증. mock feature가 꺼진 상태에서도 의미 있는 테스트가 남는 형태.
altair823 merged commit 9ceabebf38 into main 2026-05-01 13:45:19 +00:00
altair823 deleted branch feat/p4-1-llm-trait 2026-05-01 13:45:47 +00:00
Sign in to join this conversation.
No Reviewers
No Label
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: altair823-org/kebab#21