Compare commits

...

16 Commits

Author SHA1 Message Date
th-kim0823
56f20b7235 plan(fb-38): score semantics implementation plan
7 tasks: kebab-core ScoreKind enum + SearchHit field, lexical Bm25
labeling, vector Cosine, hybrid Rrf + search_with_trace pass-through,
cross-crate SearchHit literal cleanup, CLI integration test, docs
(wire schema + README + design + SKILL + INDEX).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:45:57 +09:00
th-kim0823
0359bd9682 spec(fb-38): score semantics design
- search_hit.v1 에 optional score_kind 필드 (rrf | bm25 | cosine)
- LexicalRetriever → Bm25, VectorRetriever → Cosine, HybridRetriever → Rrf
- fb-37 search_with_trace 의 mode-dispatch hits 는 underlying retriever 의
  score_kind 그대로 보존
- README + design §4 + SKILL 에 RRF 수식 전체 + "ranking signal, NOT confidence"
  안내, agent 용 trust threshold 는 nested retrieval.{lexical,vector}_score
- additive minor wire — schema bump 없음

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:40:47 +09:00
cf3acfc136 Merge pull request 'chore: bump version 0.4 → 0.5' (#130) from chore/bump-v0.5.0 into main
Reviewed-on: #130
2026-05-10 08:08:06 +00:00
th-kim0823
668e1174cc chore: bump version 0.4 → 0.5
v0.5.0 batches fb-32 (stale doc indicator) + fb-33 (streaming ask)
+ fb-34 (output budget controls) + fb-35 (verbatim fetch) + fb-36
(search filter args) + fb-37 (trace + stats).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:04:51 +09:00
745a75a82b Merge pull request 'feat(fb-37): trace + stats — search debug + KB health surface' (#129) from feat/fb-37-trace-and-stats into main
Reviewed-on: #129
2026-05-10 07:59:56 +00:00
th-kim0823
6a33d08aea fix(fb-37): address PR #129 round 1 review
- doc TraceFusionInput.fusion_score semantics (single-mode vs hybrid)
- comment why total_ms vs stage sum can drift (millis truncation)
- TODO marker on TUI trace popup filter passthrough

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:26:34 +09:00
th-kim0823
a40593590b docs(fb-37): wire schema + README + SMOKE + INDEX + SKILL 2026-05-10 14:13:47 +09:00
th-kim0823
5687cbc0e2 feat(tui): search pane t-key opens TracePopup (fb-37) 2026-05-10 13:39:11 +09:00
th-kim0823
653e432a30 feat(mcp): kebab__search trace input + output mirror (fb-37)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 13:32:30 +09:00
th-kim0823
f7e2072d66 test(cli): integration tests for --trace + schema breakdowns (fb-37)
Also fixes App::search_with_opts trace branch to use NoopRetriever
for SearchMode::Lexical, removing the embeddings requirement when
the user only wants lexical-mode trace.
2026-05-10 13:21:33 +09:00
th-kim0823
72c227af23 feat(cli): kebab search --trace flag + wire trace + pretty print (fb-37)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 13:08:48 +09:00
th-kim0823
69037c313a feat(app): SearchResponse.trace + opts.trace threading (fb-37)
Adds the `trace: Option<SearchTrace>` field to `SearchResponse` and
threads `SearchOpts.trace` through `App::search_with_opts`. When the
caller sets `opts.trace = true` the path bypasses the LRU search cache
and runs through `HybridRetriever::search_with_trace`, which dispatches
all 3 SearchModes internally; this means `--trace` requires embeddings
(same constraint as `--mode hybrid`). The non-trace path keeps its
exact prior behavior with `trace: None` stamped on the response.

Picked up Task 1 / Task 3 follow-ups in the same commit so the
workspace compiles: SearchOpts struct-literals in kebab-cli/main.rs +
kebab-mcp/tools/search.rs default the new `trace` field to false, and
the schema-wrapper test in kebab-cli/wire.rs fills the new
media_breakdown / lang_breakdown / index_bytes / stale_doc_count fields
on Stats with `Default::default()`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:01:18 +09:00
th-kim0823
6a067e3ab1 feat(search): HybridRetriever::search_with_trace (fb-37) 2026-05-10 12:38:53 +09:00
th-kim0823
231d80e82d feat(stats): media/lang/bytes/stale fields on schema.v1.stats (fb-37)
Extends CountSummary with media_breakdown, lang_breakdown, stale_doc_count
fields populated via stats_ext::breakdowns(). Adds count_summary_with_threshold
for callers that need real stale counts. Mirrors all new fields onto the
wire-bound Stats struct in kebab-app::schema with #[serde(default)] for
backwards-compat. Also fixes search_budget_integration.rs for the trace field
added to SearchOpts in Task 1.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 12:34:57 +09:00
th-kim0823
69c6e23432 feat(store): breakdowns + index_bytes helpers (fb-37)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 12:24:43 +09:00
th-kim0823
1e943f21dc feat(core): SearchTrace + IndexBytes types + SearchOpts.trace (fb-37)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 12:17:04 +09:00
36 changed files with 2317 additions and 57 deletions

44
Cargo.lock generated
View File

@@ -3525,7 +3525,7 @@ dependencies = [
[[package]]
name = "kebab-app"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"anyhow",
"base64 0.22.1",
@@ -3569,7 +3569,7 @@ dependencies = [
[[package]]
name = "kebab-chunk"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"anyhow",
"blake3",
@@ -3584,7 +3584,7 @@ dependencies = [
[[package]]
name = "kebab-cli"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"anyhow",
"clap",
@@ -3605,7 +3605,7 @@ dependencies = [
[[package]]
name = "kebab-config"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"anyhow",
"dirs 5.0.1",
@@ -3620,7 +3620,7 @@ dependencies = [
[[package]]
name = "kebab-core"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"anyhow",
"blake3",
@@ -3634,7 +3634,7 @@ dependencies = [
[[package]]
name = "kebab-embed"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"anyhow",
"blake3",
@@ -3648,7 +3648,7 @@ dependencies = [
[[package]]
name = "kebab-embed-local"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"anyhow",
"fastembed",
@@ -3661,7 +3661,7 @@ dependencies = [
[[package]]
name = "kebab-eval"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"anyhow",
"kebab-app",
@@ -3680,7 +3680,7 @@ dependencies = [
[[package]]
name = "kebab-llm"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"anyhow",
"kebab-core",
@@ -3689,7 +3689,7 @@ dependencies = [
[[package]]
name = "kebab-llm-local"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"anyhow",
"kebab-config",
@@ -3706,7 +3706,7 @@ dependencies = [
[[package]]
name = "kebab-mcp"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"anyhow",
"kebab-app",
@@ -3724,7 +3724,7 @@ dependencies = [
[[package]]
name = "kebab-normalize"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"anyhow",
"kebab-core",
@@ -3739,7 +3739,7 @@ dependencies = [
[[package]]
name = "kebab-parse-image"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"ab_glyph",
"anyhow",
@@ -3763,7 +3763,7 @@ dependencies = [
[[package]]
name = "kebab-parse-md"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"anyhow",
"kebab-core",
@@ -3780,7 +3780,7 @@ dependencies = [
[[package]]
name = "kebab-parse-pdf"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"anyhow",
"blake3",
@@ -3793,7 +3793,7 @@ dependencies = [
[[package]]
name = "kebab-parse-types"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"kebab-core",
"serde",
@@ -3801,7 +3801,7 @@ dependencies = [
[[package]]
name = "kebab-rag"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"anyhow",
"blake3",
@@ -3822,7 +3822,7 @@ dependencies = [
[[package]]
name = "kebab-search"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"anyhow",
"globset",
@@ -3841,7 +3841,7 @@ dependencies = [
[[package]]
name = "kebab-source-fs"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"anyhow",
"blake3",
@@ -3858,7 +3858,7 @@ dependencies = [
[[package]]
name = "kebab-store-sqlite"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"anyhow",
"blake3",
@@ -3879,7 +3879,7 @@ dependencies = [
[[package]]
name = "kebab-store-vector"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"anyhow",
"arrow",
@@ -3903,7 +3903,7 @@ dependencies = [
[[package]]
name = "kebab-tui"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"anyhow",
"crossterm",

View File

@@ -30,7 +30,7 @@ edition = "2024"
rust-version = "1.85"
license = "MIT OR Apache-2.0"
repository = "https://github.com/altair823/kebab"
version = "0.4.0"
version = "0.5.0"
[workspace.dependencies]
anyhow = "1"

View File

@@ -71,7 +71,7 @@ kebab doctor
|------|------|
| `kebab init` | XDG 경로에 데이터 디렉토리 + config.toml 생성 |
| `kebab ingest [<path>]` | Markdown / 이미지 / PDF 색인 (idempotent). TTY 에서는 stderr 진행 바, non-TTY (CI / pipe) 는 stderr 한 줄씩, `--json` 은 stdout 에 `ingest_progress.v1` 라인 streaming 후 마지막에 `ingest_report.v1`. Ctrl-C 한 번이면 현재 asset 마무리 후 abort (부분 commit 보존, idempotent re-run), 두 번째 Ctrl-C 는 hard exit. Markdown title 이 frontmatter 에 없어도 첫 H1 → H2 → 첫 paragraph 80 자 → 파일명 순으로 자동 채움 (parser_version `md-frontmatter-v2`) — 기존 색인된 doc 도 다음 ingest 에서 새 title 로 갱신. **Incremental** (p9-fb-23): 두 번째 이후의 ingest 는 변하지 않은 doc (blake3 + parser/chunker/embedder version 모두 동일) 의 parse/chunk/embed/vector upsert 를 자동 스킵. final summary 에 `N unchanged` 카운트 표시. `--force-reingest` 로 skip 무시 강제 재처리. **지원 형식** (extractor 자동 결정 — config 에 명시 불가): Markdown (`.md`), 이미지 (`.png` / `.jpg` / `.jpeg`, OCR + caption), PDF (`.pdf`). 다른 확장자는 자동 skip — `IngestItem.warnings` 에 사유 (`"unsupported media type: .docx"` 등), `IngestReport.skipped_by_extension` 에 카운트 분류, CLI / TUI summary 에 breakdown 표시. |
| `kebab search --mode {lexical,vector,hybrid} "<query>" [--no-cache] [--max-tokens N] [--snippet-chars N] [--cursor <opaque>] [--tag T] [--lang L] [--path-glob G] [--trust-min LEVEL] [--media TYPE] [--ingested-after RFC3339] [--doc-id ID]` | 검색. hybrid는 RRF fusion, citation 포함. 같은 process 안에서 동일 query (NFKC + trim + lowercase 정규화) 반복 시 in-process LRU 캐시 hit (capacity = `[search] cache_capacity`, default 256). `--no-cache` 로 강제 bypass — 디버깅용. ingest commit 발생 시 `kv['corpus_revision']` bump 으로 모든 entry 자동 stale. **`--max-tokens` / `--snippet-chars` / `--cursor` (p9-fb-34)** — agent budget controls. `--json` 출력은 `search_response.v1` wrapper (`{hits, next_cursor, truncated}`) — pre-fb-34 의 bare array 와 호환 안 됨. mismatched cursor → `error.v1.code = stale_cursor`. **filter flags (p9-fb-36):** `--tag` 는 반복 가능 flag (`--tag rust --tag async`) 로 OR 매칭, `--media``,` 구분 다중 값 OR 매칭, 나머지 flags 간은 AND 조합. `--trust-min``primary\|secondary\|generated` 중 하나 (해당 level 이상 포함). `--ingested-after` 는 RFC3339 UTC — 파싱 실패 시 `error.v1.code = config_invalid` (exit 2). `--media md``markdown` alias 로 정규화. 알 수 없는 `--media` 값은 무조건 empty hits (오류 아님). |
| `kebab search --mode {lexical,vector,hybrid} "<query>" [--no-cache] [--max-tokens N] [--snippet-chars N] [--cursor <opaque>] [--tag T] [--lang L] [--path-glob G] [--trust-min LEVEL] [--media TYPE] [--ingested-after RFC3339] [--doc-id ID] [--trace]` | 검색. hybrid는 RRF fusion, citation 포함. 같은 process 안에서 동일 query (NFKC + trim + lowercase 정규화) 반복 시 in-process LRU 캐시 hit (capacity = `[search] cache_capacity`, default 256). `--no-cache` 로 강제 bypass — 디버깅용. ingest commit 발생 시 `kv['corpus_revision']` bump 으로 모든 entry 자동 stale. **`--max-tokens` / `--snippet-chars` / `--cursor` (p9-fb-34)** — agent budget controls. `--json` 출력은 `search_response.v1` wrapper (`{hits, next_cursor, truncated}`) — pre-fb-34 의 bare array 와 호환 안 됨. mismatched cursor → `error.v1.code = stale_cursor`. **filter flags (p9-fb-36):** `--tag` 는 반복 가능 flag (`--tag rust --tag async`) 로 OR 매칭, `--media``,` 구분 다중 값 OR 매칭, 나머지 flags 간은 AND 조합. `--trust-min``primary\|secondary\|generated` 중 하나 (해당 level 이상 포함). `--ingested-after` 는 RFC3339 UTC — 파싱 실패 시 `error.v1.code = config_invalid` (exit 2). `--media md``markdown` alias 로 정규화. 알 수 없는 `--media` 값은 무조건 empty hits (오류 아님). **`--trace` (p9-fb-37)** — `search_response.v1.trace` 에 lexical / vector pre-fusion 후보 + RRF union + per-stage timing (`lexical_ms` / `vector_ms` / `fusion_ms` / `total_ms`) 노출. trace 요청은 캐시 우회 (`--no-cache` 없이도 항상 cold). |
| `kebab list docs` | 색인된 문서 목록 |
| `kebab inspect doc <id>` / `kebab inspect chunk <id>` | raw record 보기 |
| `kebab fetch chunk <id> [--context N]` / `kebab fetch doc <id> [--max-tokens N]` / `kebab fetch span <doc_id> <ls> <le> [--max-tokens N]` | (p9-fb-35) verbatim text fetch from indexed corpus. wire = `fetch_result.v1` (kind discriminator). chunk: target + ±N ordinal-context chunks. doc: full normalized markdown. span: 1-based line range (PDF/audio rejected as `error.v1.code = span_not_supported`). chars/4 budget on doc/span. |
@@ -80,7 +80,7 @@ kebab doctor
| `kebab tui` | Ratatui 셸 (Library + Search + Ask + Inspect 패널, desktop 진행 중). Library 에서 `r` 키로 background ingest 시작 — 화면 하단 status bar 가 진행 표시, 완료/abort 시 final 라인 잠시 유지 후 자동 hide. ingest 진행 중 `Esc` / `Ctrl-C` 가 cancel signal (그 외에는 quit). vim-style mode (header 우측 `-- NORMAL --` / `-- INSERT --`) — Library/Inspect 는 자동 NORMAL, Search/Ask 는 자동 INSERT. `i` 로 Normal→Insert (모든 pane — p9-fb-21), `Esc` 로 Insert→Normal 어디서나. mode-authoritative dispatch — Search 의 `j/k/o/g`, Ask 의 `e/j/k` 는 NORMAL 모드에서만 명령으로 동작, INSERT 에서는 입력 문자로 typing. (Search 의 chunk inspect 키는 `i``o` 로 rebind — `i` 가 universal Insert toggle.) **`F1` 로 cheatsheet popup** (현재 pane 의 키 매핑 + global 토글 표) — `Esc` / `F1` 로 닫기. Search 패널은 200ms debounce 후 background worker 가 검색 — 키 입력으로 UI freeze 안 됨, 사용자가 계속 타이핑하면 stale 결과 자동 폐기 (generation counter). Ask 패널은 multi-turn — 같은 conversation 안에서 Q1/A1, Q2/A2 transcript 누적, 다음 질문이 이전 턴을 history 로 받아 답변. 답변 본문은 markdown 렌더 (bold/italic/inline code/heading/list/code fence/table/blockquote, raw `**bold**` 가 실제 굵게 표시). `Ctrl-L` 로 새 conversation 시작. Search 의 `g` 키가 `$EDITOR` (기본 `vi`) 로 hit 의 citation 위치 열기 — 종료 후 TUI 화면이 자동으로 깨끗이 redraw. CLI `kebab ask` 는 raw markdown 그대로 (terminal 호환성 위해). Library 의 doc-list 가 한글 / 일본어 / 중국어 (CJK) 제목을 wide-char 정확한 column width 로 truncate — 한글 제목이 한 줄을 넘기지 않음 (CJK 1 자 = 2 col). Search/Ask/Filter 입력의 cursor 가 wide char 위에서 column 단위로 정렬 — 한글 입력 시 caret 이 글자 옆에 정확히 놓임. `← / →` 로 입력 문자열 중간 cursor 이동 (한글 한 글자 = 2 column 이라도 한 번에 이동), `Home / End` 로 양 끝 점프, `Delete` 로 cursor 위치 char 삭제 — 모든 input pane (Ask / Search / Library filter overlay) 동일 (p9-fb-22). Ask 트랜스크립트는 새 답변이 viewport 아래로 누적될 때 자동으로 tail 을 따라감 (auto-scroll); `j` / `k` 로 위로 스크롤하면 freeze, `Shift-G` 로 다시 bottom + auto-tail 재개. 화면 하단 hint line 은 한국어 동사구로 (`"위로"` / `"아래로"` / `"필터"` / `"타이핑 검색어"` / `"Esc 로 NORMAL 모드"` / `"i 입력모드"` 등) + 현재 (pane, mode) 조합에 맞춰 자동 분기, **첫 fragment 가 항상 `F1 도움말`** (cheatsheet 발견성 보장). 모든 모드에서 항상 떠 있는 상태바 — `kebab v<version> │ <pane> │ <docs> docs │ <state>` (state: streaming/searching/indexing/idle, ingest 진행 중에는 progress 가 같은 자리에 흡수됨). Ask 진입 시 conversation id 8 자 prefix 도 함께 표시. Ask 트랜스크립트와 Inspect 양쪽에서 `PgUp / PgDn` 으로 10 줄씩 페이지 스크롤. Library 의 doc list 위에는 `TITLE / TAGS / UPDATED / CHUNKS` 컬럼 헤더 행 표시 (display-width 정렬, Hangul / CJK 안전). |
| `kebab reset [--all / --data-only / --vector-only / --config-only] [--yes]` | XDG 데이터 wipe. **Irreversible.** TTY 면 confirm prompt, 아니면 `--yes` 필수. `--vector-only` 는 SQLite `embedding_records` 도 함께 truncate (orphan 방지) |
| `kebab eval run / compare` | golden query 회귀 측정 |
| `kebab schema [--json]` | introspection — wire schemas / capabilities / models / stats 한 번에. `--json``schema.v1` wire; 사람 모드는 서식 출력. |
| `kebab schema [--json]` | introspection — wire schemas / capabilities / models / stats 한 번에. `--json``schema.v1` wire; 사람 모드는 서식 출력. **stats 에 (p9-fb-37) `media_breakdown` (5 keys: markdown / pdf / image / audio / other) + `lang_breakdown` (BCP-47 코드, NULL 은 literal `"null"`) + `index_bytes` (sqlite + lancedb on-disk 합계) + `stale_doc_count` (`config.search.stale_threshold_days` 초과 doc 수) 추가.** |
| `kebab ingest-file <path>` | 단일 파일 ingest (workspace 외부 가능). 바이트는 `<workspace.root>/_external/<hash12>.<ext>` 로 copy. `.kebabignore` 매치 시 stderr warn 후 진행 (explicit ingest 가 bypass intent). |
| `kebab ingest-stdin --title <T> [--source-uri <URI>]` | stdin 의 markdown 본문 ingest. frontmatter (title + source_uri) 자동 prepend. v1 markdown only. |
| `kebab mcp` | MCP (Model Context Protocol) stdio server. agent host (Claude Code / Cursor / OpenAI Agents) 가 spawn 하여 tool 호출 (`search` / `ask` / `schema` / `doctor` / `ingest_file` / `ingest_stdin`). `--config` honor. |

View File

@@ -70,6 +70,9 @@ pub struct SearchResponse {
pub hits: Vec<SearchHit>,
pub next_cursor: Option<String>,
pub truncated: bool,
/// p9-fb-37: present when caller passed `SearchOpts.trace = true`.
/// Consumers that ignore trace should leave this `None`.
pub trace: Option<kebab_core::SearchTrace>,
}
/// Facade state — see module docs for lifetime rules.
@@ -341,6 +344,75 @@ impl App {
k: fetch_k,
..query.clone()
};
// p9-fb-37: when --trace is requested, bypass the LRU cache and
// run through `HybridRetriever::search_with_trace`, which
// dispatches by mode internally. Vector / hybrid modes require
// embeddings (same as `--mode hybrid`); lexical mode skips
// embedder construction via `NoopRetriever` so lexical-only
// workspaces (provider = "none") can use `--trace` without
// surfacing the "switch to --mode lexical" error.
if opts.trace {
let lex = Arc::new(LexicalRetriever::with_settings(
self.sqlite.clone(),
lexical_index_version(&self.config),
self.config.search.snippet_chars,
)) as Arc<dyn Retriever>;
let vec_retr: Arc<dyn Retriever> = if matches!(query.mode, SearchMode::Lexical) {
// `HybridRetriever::search_with_trace` never invokes the
// vector retriever for `SearchMode::Lexical` (Task 4).
// A no-op stand-in lets us avoid the ~470 MB embedder
// load when the user only asked for lexical trace.
Arc::new(NoopRetriever)
} else {
let (emb, vec_store) = self.require_embeddings()?;
let vec_iv = vector_index_version(emb.as_ref());
let vec_dyn: Arc<dyn VectorStore + Send + Sync> = vec_store;
let emb_dyn: Arc<dyn Embedder> = emb;
Arc::new(VectorRetriever::with_settings(
vec_dyn,
emb_dyn,
self.sqlite.clone(),
vec_iv,
self.config.search.snippet_chars,
)) as Arc<dyn Retriever>
};
let hybrid = HybridRetriever::new(&self.config, lex, vec_retr);
let (mut traced_hits, trace) = hybrid.search_with_trace(&fetch_query)?;
// Stamp staleness — same as search_uncached.
let now = time::OffsetDateTime::now_utc();
crate::staleness::mark_stale_in_place(
&mut traced_hits,
now,
self.config.search.stale_threshold_days,
);
// Apply offset + k_effective truncation (mirrors non-trace path).
let drop_n = offset.min(traced_hits.len());
traced_hits.drain(..drop_n);
let mut hits: Vec<SearchHit> =
traced_hits.into_iter().take(k_effective).collect();
// Snippet truncation if opts.snippet_chars set (mirror non-trace path).
if opts.snippet_chars.is_some() {
for h in hits.iter_mut() {
if h.snippet.chars().count() > snippet_chars {
h.snippet = trim_to_chars(&h.snippet, snippet_chars);
}
}
}
// Trace path skips the budget loop. Caller will inspect
// `hits.len()` and `trace.timing` rather than paginate.
return Ok(SearchResponse {
hits,
next_cursor: None,
truncated: false,
trace: Some(trace),
});
}
let mut all_hits = self.search(fetch_query)?;
// Skip offset.
@@ -421,6 +493,7 @@ impl App {
hits,
next_cursor,
truncated,
trace: None,
})
}
@@ -737,6 +810,24 @@ fn lexical_index_version(config: &kebab_config::Config) -> IndexVersion {
IndexVersion(format!("lex:{}", config.chunking.chunker_version))
}
/// p9-fb-37: stand-in for the vector retriever in the trace path when
/// `query.mode == SearchMode::Lexical`. `HybridRetriever::search_with_trace`'s
/// Lexical branch never calls `vector.search()`, so returning an empty
/// hit list here is safe and lets lexical-only workspaces (embedding
/// `provider = "none"`) use `--trace` without paying the ~470 MB
/// embedder load.
struct NoopRetriever;
impl Retriever for NoopRetriever {
fn search(&self, _q: &kebab_core::SearchQuery) -> anyhow::Result<Vec<kebab_core::SearchHit>> {
Ok(Vec::new())
}
fn index_version(&self) -> kebab_core::IndexVersion {
kebab_core::IndexVersion("noop:trace".into())
}
}
/// Compose a stable `IndexVersion` for the vector retriever. Tracks
/// `(embedding_model, embedding_version, dimensions)` so a model swap
/// flags drift via the existing index_version mismatch warning in
@@ -847,3 +938,59 @@ mod tests {
assert_ne!(a, d, "different session_id → different hash");
}
}
#[cfg(test)]
mod tests_trace {
use super::*;
use kebab_core::{SearchMode, SearchOpts, SearchQuery};
fn open_app_with_temp_dir() -> (tempfile::TempDir, App) {
let dir = tempfile::tempdir().unwrap();
let mut cfg = kebab_config::Config::defaults();
cfg.storage.data_dir = dir.path().to_string_lossy().into_owned();
// Bring up migrations.
let store = kebab_store_sqlite::SqliteStore::open(&cfg).unwrap();
store.run_migrations().unwrap();
drop(store);
let app = App::open_with_config(cfg).unwrap();
(dir, app)
}
#[test]
fn search_response_trace_none_when_opts_trace_false() {
let (_dir, app) = open_app_with_temp_dir();
let q = SearchQuery {
text: "x".into(),
mode: SearchMode::Lexical,
k: 1,
filters: Default::default(),
};
let resp = app.search_with_opts(q, SearchOpts::default()).unwrap();
assert!(resp.trace.is_none());
}
#[test]
fn search_response_trace_some_when_opts_trace_true_lexical_mode() {
// Lexical mode doesn't require embeddings — the trace path
// builds HybridRetriever with a `NoopRetriever` stand-in for
// the vector side, since `HybridRetriever::search_with_trace`'s
// Lexical branch never invokes `vector.search()`. Default
// Config has embedding `provider = "none"`, and lexical-mode
// trace must succeed under that config (no embedder load).
let (_dir, app) = open_app_with_temp_dir();
let q = SearchQuery {
text: "x".into(),
mode: SearchMode::Lexical,
k: 1,
filters: Default::default(),
};
let opts = SearchOpts {
trace: true,
..Default::default()
};
let resp = app
.search_with_opts(q, opts)
.expect("lexical-mode trace must succeed without embeddings");
assert!(resp.trace.is_some(), "trace populated when opts.trace=true");
}
}

View File

@@ -50,6 +50,18 @@ pub struct Stats {
pub chunk_count: u64,
pub asset_count: u64,
pub last_ingest_at: Option<String>,
/// p9-fb-37: per-media-kind doc count (5 keys, zero-padded).
#[serde(default)]
pub media_breakdown: std::collections::BTreeMap<String, u64>,
/// p9-fb-37: per-language doc count, NULL keyed as `"null"`.
#[serde(default)]
pub lang_breakdown: std::collections::BTreeMap<String, u64>,
/// p9-fb-37: on-disk byte sums.
#[serde(default)]
pub index_bytes: kebab_core::IndexBytes,
/// p9-fb-37: docs whose `updated_at` exceeds the staleness threshold.
#[serde(default)]
pub stale_doc_count: u64,
}
const KEBAB_VERSION: &str = env!("CARGO_PKG_VERSION");
@@ -85,7 +97,7 @@ const WIRE_SCHEMAS: &[&str] = &[
#[doc(hidden)]
pub fn schema_with_config(cfg: &Config) -> anyhow::Result<SchemaV1> {
let store = open_store_for_stats(cfg)?;
let stats = collect_stats(&store)?;
let stats = collect_stats(cfg, &store)?;
let models = collect_models(cfg, &store);
Ok(SchemaV1 {
schema_version: SCHEMA_V1_ID.to_string(),
@@ -124,13 +136,24 @@ fn open_store_for_stats(cfg: &Config) -> anyhow::Result<kebab_store_sqlite::Sqli
kebab_store_sqlite::SqliteStore::open_existing(&db_path)
}
fn collect_stats(store: &kebab_store_sqlite::SqliteStore) -> anyhow::Result<Stats> {
let counts = store.count_summary()?;
fn collect_stats(
cfg: &Config,
store: &kebab_store_sqlite::SqliteStore,
) -> anyhow::Result<Stats> {
let counts = store
.count_summary_with_threshold(cfg.search.stale_threshold_days as u64)?;
let data_dir = kebab_config::expand_path(&cfg.storage.data_dir, "");
let index_bytes = kebab_store_sqlite::stats_ext::index_bytes(&data_dir)
.map_err(|e| anyhow::anyhow!("index_bytes: {e}"))?;
Ok(Stats {
doc_count: counts.doc_count,
chunk_count: counts.chunk_count,
asset_count: counts.asset_count,
last_ingest_at: counts.last_ingest_at,
media_breakdown: counts.media_breakdown,
lang_breakdown: counts.lang_breakdown,
index_bytes,
stale_doc_count: counts.stale_doc_count,
})
}
@@ -150,3 +173,31 @@ fn collect_models(cfg: &Config, store: &kebab_store_sqlite::SqliteStore) -> Mode
corpus_revision: store.corpus_revision(),
}
}
#[cfg(test)]
mod tests_stats_ext {
use super::*;
#[test]
fn stats_includes_breakdowns_and_bytes_on_fresh_corpus() {
let dir = tempfile::tempdir().unwrap();
let mut cfg = kebab_config::Config::defaults();
cfg.storage.data_dir = dir.path().to_string_lossy().into_owned();
// Bring up migrations so the sqlite file is created.
let store = kebab_store_sqlite::SqliteStore::open(&cfg).unwrap();
store.run_migrations().unwrap();
drop(store);
let s = schema_with_config(&cfg).unwrap();
// 5 keys padded.
assert_eq!(s.stats.media_breakdown.len(), 5);
assert_eq!(s.stats.media_breakdown.get("markdown"), Some(&0));
assert_eq!(s.stats.media_breakdown.get("pdf"), Some(&0));
// lang map empty on empty corpus.
assert!(s.stats.lang_breakdown.is_empty());
// sqlite bytes positive after migrations, lancedb 0.
assert!(s.stats.index_bytes.sqlite > 0);
assert_eq!(s.stats.index_bytes.lancedb, 0);
assert_eq!(s.stats.stale_doc_count, 0);
}
}

View File

@@ -47,6 +47,7 @@ fn budget_truncates_snippets_when_below_threshold() {
max_tokens: Some(50),
snippet_chars: None,
cursor: None,
trace: false,
},
)
.unwrap();
@@ -78,6 +79,7 @@ fn cursor_paginates_to_next_page() {
max_tokens: None,
snippet_chars: None,
cursor: Some(cursor),
trace: false,
},
)
.unwrap();
@@ -114,6 +116,7 @@ fn cursor_rejected_after_corpus_revision_bump() {
max_tokens: None,
snippet_chars: None,
cursor: Some(c),
trace: false,
},
);
let err = result.unwrap_err();
@@ -147,6 +150,7 @@ fn max_tokens_zero_returns_one_hit_truncated() {
max_tokens: Some(0),
snippet_chars: None,
cursor: None,
trace: false,
},
)
.unwrap();

View File

@@ -163,6 +163,14 @@ enum Cmd {
/// p9-fb-36: filter to a single doc by id.
#[arg(long)]
doc_id: Option<String>,
/// p9-fb-37: emit pre-fusion lexical / vector / RRF candidate
/// lists + per-stage timing in the response. Bypasses cache
/// (debug intent — fresh run guaranteed). Requires embeddings
/// when `--mode hybrid` or `--mode vector`; lexical mode runs
/// without embeddings via a no-op vector stub.
#[arg(long)]
trace: bool,
},
/// Retrieval-augmented question answering.
@@ -669,6 +677,7 @@ fn run(cli: &Cli) -> anyhow::Result<()> {
media,
ingested_after,
doc_id,
trace,
} => {
let cfg = kebab_config::Config::load(cli.config.as_deref())?;
@@ -732,6 +741,7 @@ fn run(cli: &Cli) -> anyhow::Result<()> {
max_tokens: *max_tokens,
snippet_chars: *snippet_chars,
cursor: cursor.clone(),
trace: *trace,
};
// p9-fb-34: budget-aware path. --no-cache still bypasses the
// App-level LRU; wire wrapper applies regardless.
@@ -789,6 +799,22 @@ fn run(cli: &Cli) -> anyhow::Result<()> {
let next = resp.next_cursor.as_deref().unwrap_or("(none)");
eprintln!("[truncated; use --cursor {next} for the next page]");
}
if *trace {
if let Some(t) = &resp.trace {
eprintln!();
eprintln!("Trace:");
eprintln!(" lexical ({} hits, {}ms):", t.lexical.len(), t.timing.lexical_ms);
for c in t.lexical.iter().take(3) {
eprintln!(" rank={} score={:.4} chunk={}", c.rank, c.score, c.chunk_id.0);
}
eprintln!(" vector ({} hits, {}ms):", t.vector.len(), t.timing.vector_ms);
for c in t.vector.iter().take(3) {
eprintln!(" rank={} score={:.4} chunk={}", c.rank, c.score, c.chunk_id.0);
}
eprintln!(" fusion ({} inputs, {}ms)", t.rrf_inputs.len(), t.timing.fusion_ms);
eprintln!(" total: {}ms", t.timing.total_ms);
}
}
}
Ok(())
}

View File

@@ -81,11 +81,17 @@ pub fn wire_search_hit(h: &SearchHit) -> Value {
/// array (`wire_search_hits`) — see HOTFIXES / fb-34 for the
/// breaking shape change.
pub fn wire_search_response(r: &kebab_app::SearchResponse) -> Value {
let v = serde_json::json!({
let mut v = serde_json::json!({
"hits": r.hits.iter().map(wire_search_hit).collect::<Vec<_>>(),
"next_cursor": r.next_cursor,
"truncated": r.truncated,
});
if let Some(trace) = &r.trace {
let trace_v = serde_json::to_value(trace).expect("SearchTrace serializes");
if let Value::Object(ref mut map) = v {
map.insert("trace".to_string(), trace_v);
}
}
tag_object(v, "search_response.v1")
}
@@ -264,6 +270,7 @@ mod tests {
hits: vec![],
next_cursor: Some("opaque-cursor-abc".to_string()),
truncated: true,
trace: None,
};
let v = wire_search_response(&r);
assert_eq!(schema_of(&v), Some("search_response.v1"));
@@ -303,6 +310,10 @@ mod tests {
stats: Stats {
doc_count: 1, chunk_count: 2, asset_count: 1,
last_ingest_at: None,
media_breakdown: Default::default(),
lang_breakdown: Default::default(),
index_bytes: Default::default(),
stale_doc_count: 0,
},
};
let v = wire_schema(&schema);
@@ -343,4 +354,49 @@ mod tests {
assert_eq!(paths.len(), 1);
assert_eq!(paths[0].as_str(), Some("/tmp/x"));
}
#[test]
fn search_response_with_trace_serializes_trace_field() {
use kebab_core::{SearchTrace, TraceCandidate, TraceFusionInput,
TraceTiming, ChunkId, DocumentId, WorkspacePath};
let r = kebab_app::SearchResponse {
hits: vec![],
next_cursor: None,
truncated: false,
trace: Some(SearchTrace {
lexical: vec![TraceCandidate {
chunk_id: ChunkId("c1".into()),
doc_id: DocumentId("d1".into()),
doc_path: WorkspacePath::new("a.md".into()).unwrap(),
rank: 1,
score: 0.42,
}],
vector: vec![],
rrf_inputs: vec![TraceFusionInput {
chunk_id: ChunkId("c1".into()),
lexical_rank: Some(1),
vector_rank: None,
fusion_score: 0.0,
}],
timing: TraceTiming { lexical_ms: 5, vector_ms: 0, fusion_ms: 1, total_ms: 7 },
}),
};
let v = wire_search_response(&r);
assert_eq!(schema_of(&v), Some("search_response.v1"));
assert!(v["trace"].is_object());
assert_eq!(v["trace"]["timing"]["lexical_ms"], 5);
assert_eq!(v["trace"]["lexical"][0]["chunk_id"], "c1");
}
#[test]
fn search_response_without_trace_omits_field() {
let r = kebab_app::SearchResponse {
hits: vec![],
next_cursor: None,
truncated: false,
trace: None,
};
let v = wire_search_response(&r);
assert!(v.get("trace").is_none(), "trace field absent when None");
}
}

View File

@@ -0,0 +1,57 @@
//! p9-fb-37: integration tests for `kebab schema --json` extended stats.
mod common;
use serde_json::Value;
use std::fs;
use std::process::Command;
fn run_schema(cfg: &std::path::Path) -> Value {
let bin = env!("CARGO_BIN_EXE_kebab");
let out = Command::new(bin)
.args(["--config", cfg.to_str().unwrap(), "schema", "--json"])
.output()
.expect("run kebab schema");
assert!(
out.status.success(),
"schema failed: stderr={}",
String::from_utf8_lossy(&out.stderr)
);
serde_json::from_slice(&out.stdout).expect("valid JSON")
}
#[test]
fn schema_stats_includes_breakdowns_on_fresh_corpus() {
let dir = tempfile::tempdir().unwrap();
let (cfg, workspace, _data) = common::write_config(dir.path(), 0);
// Run a no-op ingest to bring up migrations + create the SQLite file.
fs::write(workspace.join("placeholder.md"), "# placeholder\n").unwrap();
common::ingest(&cfg, &workspace);
let v = run_schema(&cfg);
let stats = &v["stats"];
let m = stats["media_breakdown"].as_object().unwrap();
assert_eq!(m.len(), 5, "5 media keys padded");
for k in &["markdown", "pdf", "image", "audio", "other"] {
assert!(m[*k].is_number(), "media[{k}] is integer");
}
assert!(stats["lang_breakdown"].is_object());
assert!(stats["index_bytes"]["sqlite"].is_number());
assert!(stats["index_bytes"]["lancedb"].is_number());
assert!(stats["stale_doc_count"].is_number());
}
#[test]
fn schema_stats_breakdowns_after_ingest() {
let dir = tempfile::tempdir().unwrap();
let (cfg, workspace, _data) = common::write_config(dir.path(), 0);
fs::write(workspace.join("a.md"), "---\nlang: en\n---\nhello\n").unwrap();
fs::write(workspace.join("b.md"), "---\nlang: ko\n---\n안녕\n").unwrap();
common::ingest(&cfg, &workspace);
let v = run_schema(&cfg);
let stats = &v["stats"];
assert_eq!(stats["media_breakdown"]["markdown"], 2);
assert!(stats["lang_breakdown"].is_object());
assert!(stats["index_bytes"]["sqlite"].as_u64().unwrap() > 0);
}

View File

@@ -0,0 +1,58 @@
//! p9-fb-37: integration tests for `kebab search --trace --json`.
mod common;
use serde_json::Value;
use std::fs;
#[test]
fn search_trace_json_includes_trace_block() {
let dir = tempfile::tempdir().unwrap();
let (cfg, workspace, _data) = common::write_config(dir.path(), 0);
fs::write(workspace.join("doc1.md"), "# Title\n\nrust async hello\n").unwrap();
common::ingest(&cfg, &workspace);
let (stdout, _stderr) = common::run_search_with_args(
&cfg,
&["--mode", "lexical", "--trace", "--json", "rust"],
);
let v: Value = serde_json::from_str(stdout.trim()).expect("valid JSON");
assert_eq!(v["schema_version"], "search_response.v1");
assert!(v["trace"].is_object(), "trace block present");
assert!(v["trace"]["timing"].is_object());
assert!(v["trace"]["timing"]["total_ms"].is_number());
assert!(v["trace"]["lexical"].is_array());
assert!(v["trace"]["vector"].is_array());
assert!(v["trace"]["rrf_inputs"].is_array());
}
#[test]
fn search_without_trace_omits_trace_field() {
let dir = tempfile::tempdir().unwrap();
let (cfg, workspace, _data) = common::write_config(dir.path(), 0);
fs::write(workspace.join("doc1.md"), "# Title\n\nrust async hello\n").unwrap();
common::ingest(&cfg, &workspace);
let (stdout, _stderr) = common::run_search_with_args(
&cfg,
&["--mode", "lexical", "--json", "rust"],
);
let v: Value = serde_json::from_str(stdout.trim()).expect("valid JSON");
assert!(v.get("trace").is_none(), "trace field absent without --trace");
}
#[test]
fn search_trace_lexical_mode_vector_list_empty() {
let dir = tempfile::tempdir().unwrap();
let (cfg, workspace, _data) = common::write_config(dir.path(), 0);
fs::write(workspace.join("doc1.md"), "# Title\n\nrust async hello\n").unwrap();
common::ingest(&cfg, &workspace);
let (stdout, _stderr) = common::run_search_with_args(
&cfg,
&["--mode", "lexical", "--trace", "--json", "rust"],
);
let v: Value = serde_json::from_str(stdout.trim()).expect("valid JSON");
assert_eq!(v["trace"]["vector"].as_array().unwrap().len(), 0);
assert_eq!(v["trace"]["timing"]["vector_ms"], 0);
}

View File

@@ -51,8 +51,9 @@ pub use metadata::{
TrustLevel,
};
pub use search::{
DocFilter, DocSummary, RetrievalDetail, SearchFilters, SearchHit,
SearchMode, SearchOpts, SearchQuery,
DocFilter, DocSummary, IndexBytes, MEDIA_KINDS, RetrievalDetail, SearchFilters, SearchHit,
SearchMode, SearchOpts, SearchQuery, SearchTrace, TraceCandidate, TraceFusionInput,
TraceTiming,
};
pub use answer::{
Answer, AnswerCitation, AnswerRetrievalSummary, ModelRef, RefusalReason, TokenUsage,

View File

@@ -124,6 +124,60 @@ pub struct SearchOpts {
pub snippet_chars: Option<usize>,
/// Opaque base64 cursor from a previous response. None = first page.
pub cursor: Option<String>,
/// p9-fb-37: when true, capture pipeline trace (cache bypassed,
/// lex / vec pre-fusion lists + timing populated on the response).
#[serde(default)]
pub trace: bool,
}
/// p9-fb-37: search retrieval pipeline trace. Populated only when
/// `SearchOpts.trace = true`; `None` on the wrapping `SearchResponse`
/// otherwise. `lexical` / `vector` are pre-fusion candidate lists
/// (each retriever's full output for the fanout query). `rrf_inputs`
/// is the union (chunk_id) used by RRF, with each side's rank
/// captured. `timing` is wall-clock per stage.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct SearchTrace {
pub lexical: Vec<TraceCandidate>,
pub vector: Vec<TraceCandidate>,
pub rrf_inputs: Vec<TraceFusionInput>,
pub timing: TraceTiming,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct TraceCandidate {
pub chunk_id: ChunkId,
pub doc_id: DocumentId,
pub doc_path: WorkspacePath,
pub rank: u32,
pub score: f32,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct TraceFusionInput {
pub chunk_id: ChunkId,
pub lexical_rank: Option<u32>,
pub vector_rank: Option<u32>,
/// Hybrid mode: normalized RRF score in `[0, 1]`.
/// Lexical / Vector mode: equals the underlying retriever's score
/// (no fusion ran). 0.0 for chunks dropped past `target_k`.
pub fusion_score: f32,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TraceTiming {
pub lexical_ms: u64,
pub vector_ms: u64,
pub fusion_ms: u64,
pub total_ms: u64,
}
/// p9-fb-37: on-disk index size breakdown. Mirrored on the
/// wire `schema.v1.stats.index_bytes` block.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct IndexBytes {
pub sqlite: u64,
pub lancedb: u64,
}
#[cfg(test)]
@@ -193,4 +247,51 @@ mod tests {
assert!(old.ingested_after.is_none());
assert!(old.doc_id.is_none());
}
#[test]
fn search_trace_serde_roundtrip() {
let t = SearchTrace {
lexical: vec![TraceCandidate {
chunk_id: ChunkId("c1".into()),
doc_id: DocumentId("d1".into()),
doc_path: WorkspacePath::new("a.md".into()).unwrap(),
rank: 1,
score: 0.42,
}],
vector: vec![],
rrf_inputs: vec![TraceFusionInput {
chunk_id: ChunkId("c1".into()),
lexical_rank: Some(1),
vector_rank: None,
fusion_score: 0.0234,
}],
timing: TraceTiming {
lexical_ms: 12,
vector_ms: 0,
fusion_ms: 1,
total_ms: 14,
},
};
let v = serde_json::to_value(&t).unwrap();
assert_eq!(v["timing"]["lexical_ms"], 12);
assert_eq!(
v["lexical"][0]["score"].as_f64().unwrap() as f32,
0.42_f32
);
let back: SearchTrace = serde_json::from_value(v).unwrap();
assert_eq!(back, t);
}
#[test]
fn index_bytes_default_is_zero() {
let b = IndexBytes::default();
assert_eq!(b.sqlite, 0);
assert_eq!(b.lancedb, 0);
}
#[test]
fn search_opts_trace_default_false() {
let opts = SearchOpts::default();
assert!(!opts.trace);
}
}

View File

@@ -47,6 +47,10 @@ pub struct SearchInput {
pub ingested_after: Option<String>,
/// p9-fb-36: filter to a single doc.
pub doc_id: Option<String>,
/// p9-fb-37: when true, include a `trace` field on the response
/// with pre-fusion lexical/vector candidate lists + per-stage timing.
/// Bypasses cache (debug intent — fresh run guaranteed). Default false.
pub trace: Option<bool>,
}
pub fn handle(state: &KebabAppState, input: SearchInput) -> CallToolResult {
@@ -118,6 +122,7 @@ pub fn handle(state: &KebabAppState, input: SearchInput) -> CallToolResult {
max_tokens: input.max_tokens,
snippet_chars: input.snippet_chars,
cursor: input.cursor,
trace: input.trace.unwrap_or(false),
};
let cfg_clone = (*state.config).clone();
match kebab_app::search_with_opts_with_config(cfg_clone, query, opts) {
@@ -138,12 +143,19 @@ pub fn handle(state: &KebabAppState, input: SearchInput) -> CallToolResult {
v
})
.collect();
let envelope = serde_json::json!({
let mut envelope = serde_json::json!({
"schema_version": "search_response.v1",
"hits": tagged,
"next_cursor": resp.next_cursor,
"truncated": resp.truncated,
});
if let Some(trace) = &resp.trace {
let trace_v =
serde_json::to_value(trace).unwrap_or(serde_json::Value::Null);
if let serde_json::Value::Object(ref mut map) = envelope {
map.insert("trace".to_string(), trace_v);
}
}
match serde_json::to_string(&envelope) {
Ok(json) => to_tool_success(json),
Err(e) => to_tool_error(&anyhow::anyhow!(e)),

View File

@@ -69,6 +69,7 @@ async fn fetch_tool_chunk_returns_fetch_result_v1() {
media: None,
ingested_after: None,
doc_id: None,
trace: None,
},
);
let search_text = match &search_result.content.first().unwrap().raw {

View File

@@ -65,6 +65,7 @@ async fn search_tool_returns_search_response_v1() {
media: None,
ingested_after: None,
doc_id: None,
trace: None,
},
);
@@ -166,6 +167,7 @@ async fn search_with_doc_id_filter_returns_only_target() {
media: None,
ingested_after: None,
doc_id: None,
trace: None,
},
);
assert!(
@@ -204,6 +206,7 @@ async fn search_with_doc_id_filter_returns_only_target() {
media: None,
ingested_after: None,
doc_id: Some(target_doc_id.clone()),
trace: None,
},
);
assert!(
@@ -260,6 +263,7 @@ async fn search_with_invalid_ingested_after_returns_invalid_input() {
media: None,
ingested_after: Some("garbage".to_string()),
doc_id: None,
trace: None,
},
);

View File

@@ -0,0 +1,104 @@
//! p9-fb-37: integration test for `mcp__kebab__search` trace input/output.
use std::fs;
use kebab_config::Config;
use kebab_core::SourceScope;
use kebab_mcp::{KebabAppState, KebabHandler};
use rmcp::model::RawContent;
fn minimal_config(data_dir: &std::path::Path, workspace_root: &std::path::Path) -> Config {
let mut cfg = Config::defaults();
cfg.storage.data_dir = data_dir.to_string_lossy().into_owned();
cfg.storage.model_dir = data_dir.join("models").to_string_lossy().into_owned();
cfg.workspace.root = workspace_root.to_string_lossy().into_owned();
cfg.workspace.exclude.clear();
cfg.models.embedding.provider = "none".to_string();
cfg.models.embedding.dimensions = 0;
cfg
}
fn setup() -> (tempfile::TempDir, KebabHandler) {
let dir = tempfile::tempdir().unwrap();
let data_dir = dir.path().join("data");
let workspace_root = dir.path().join("notes");
fs::create_dir_all(&data_dir).unwrap();
fs::create_dir_all(&workspace_root).unwrap();
let config = minimal_config(&data_dir, &workspace_root);
fs::write(
workspace_root.join("a.md"),
"# Alpha\n\nThis document mentions kebab and bread.",
)
.unwrap();
let scope = SourceScope {
root: workspace_root.clone(),
include: vec![],
exclude: vec![],
};
let _ = kebab_app::ingest_with_config(config.clone(), scope, false).unwrap();
let state = KebabAppState::new(config, None);
let handler = KebabHandler::new(state);
(dir, handler)
}
fn make_input(trace: Option<bool>) -> kebab_mcp::tools::search::SearchInput {
kebab_mcp::tools::search::SearchInput {
query: "kebab".to_string(),
mode: Some("lexical".to_string()),
k: Some(5),
max_tokens: None,
snippet_chars: None,
cursor: None,
tags: None,
lang: None,
path_glob: None,
trust_min: None,
media: None,
ingested_after: None,
doc_id: None,
trace,
}
}
fn extract_json(result: &rmcp::model::CallToolResult) -> serde_json::Value {
assert!(
!result.is_error.unwrap_or(false),
"expected isError=false, got {result:?}"
);
let content = result.content.first().expect("at least one content item");
let text = match &content.raw {
RawContent::Text(t) => &t.text,
other => panic!("expected Text content, got {other:?}"),
};
serde_json::from_str(text).expect("valid JSON")
}
#[tokio::test]
async fn search_with_trace_true_returns_trace_field() {
let (_dir, handler) = setup();
let result = kebab_mcp::tools::search::handle(handler.state(), make_input(Some(true)));
let v = extract_json(&result);
assert_eq!(v["schema_version"], "search_response.v1");
assert!(v["trace"].is_object(), "trace field present when trace:true");
assert!(v["trace"]["timing"]["total_ms"].is_number());
assert!(v["trace"]["lexical"].is_array());
assert!(v["trace"]["vector"].is_array());
assert!(v["trace"]["rrf_inputs"].is_array());
}
#[tokio::test]
async fn search_without_trace_omits_trace_field() {
let (_dir, handler) = setup();
let result = kebab_mcp::tools::search::handle(handler.state(), make_input(None));
let v = extract_json(&result);
assert_eq!(v["schema_version"], "search_response.v1");
assert!(v.get("trace").is_none(), "trace absent when None");
}
#[tokio::test]
async fn search_with_trace_false_omits_trace_field() {
let (_dir, handler) = setup();
let result = kebab_mcp::tools::search::handle(handler.state(), make_input(Some(false)));
let v = extract_json(&result);
assert!(v.get("trace").is_none(), "trace absent when false");
}

View File

@@ -18,12 +18,15 @@
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Instant;
use anyhow::Result;
use kebab_core::{
IndexVersion, RetrievalDetail, Retriever, SearchHit, SearchMode, SearchQuery,
IndexVersion, RetrievalDetail, Retriever, SearchHit, SearchMode, SearchQuery, SearchTrace,
};
use crate::trace::{build_fusion_input_skeleton, candidates_from_hits, ScoreKind, TraceBuilder};
/// Default `k_rrf` if `kb-config::SearchCfg::rrf_k` is misconfigured.
/// Matches §6.4's documented default (60).
const DEFAULT_K_RRF: u32 = 60;
@@ -145,20 +148,22 @@ impl Retriever for HybridRetriever {
impl HybridRetriever {
fn fuse(&self, query: &SearchQuery) -> Result<Vec<SearchHit>> {
let target_k = if query.k == 0 { self.default_k } else { query.k };
// Fanout: ask each retriever for `target_k * MULTIPLIER` so
// the disjoint set of candidates is wide enough. The two
// per-side queries are identical (same text, k, mode, filters);
// only the dispatch differs, so we share one `SearchQuery`.
let fanout_k = target_k.saturating_mul(HYBRID_FANOUT_MULTIPLIER);
let lex_query = SearchQuery {
k: fanout_k,
..query.clone()
};
let lex_hits = self.lexical.search(&lex_query)?;
let vec_hits = self.vector.search(&lex_query)?;
self.fuse_with_inputs(&lex_hits, &vec_hits, target_k)
}
fn fuse_with_inputs(
&self,
lex_hits: &[SearchHit],
vec_hits: &[SearchHit],
target_k: usize,
) -> Result<Vec<SearchHit>> {
tracing::debug!(
lex = lex_hits.len(),
vec = vec_hits.len(),
@@ -171,11 +176,13 @@ impl HybridRetriever {
// already 1-based by both LexicalRetriever and VectorRetriever
// (and any well-behaved Retriever should mirror).
let lex_index: HashMap<String, (u32, SearchHit)> = lex_hits
.into_iter()
.iter()
.cloned()
.map(|h| (h.chunk_id.0.clone(), (h.rank, h)))
.collect();
let vec_index: HashMap<String, (u32, SearchHit)> = vec_hits
.into_iter()
.iter()
.cloned()
.map(|h| (h.chunk_id.0.clone(), (h.rank, h)))
.collect();
@@ -312,6 +319,85 @@ impl HybridRetriever {
tracing::debug!(rows = hits.len(), "kb-search hybrid: search done");
Ok(hits)
}
/// p9-fb-37: parallel to `Retriever::search` but additionally returns
/// a trace of pre-fusion lex/vec lists, RRF inputs (union with each
/// side's rank), and per-stage timing.
pub fn search_with_trace(
&self,
query: &SearchQuery,
) -> anyhow::Result<(Vec<SearchHit>, SearchTrace)> {
let start_total = Instant::now();
let target_k = if query.k == 0 { self.default_k } else { query.k };
let fanout_k = target_k.saturating_mul(HYBRID_FANOUT_MULTIPLIER);
let fanout_query = SearchQuery {
k: fanout_k,
..query.clone()
};
let mut tb = TraceBuilder::default();
let (lex_hits, vec_hits): (Vec<SearchHit>, Vec<SearchHit>) = match query.mode {
SearchMode::Lexical => {
let t0 = Instant::now();
let lh = self.lexical.search(&fanout_query)?;
tb.timing.lexical_ms = t0.elapsed().as_millis() as u64;
(lh, Vec::new())
}
SearchMode::Vector => {
let t0 = Instant::now();
let vh = self.vector.search(&fanout_query)?;
tb.timing.vector_ms = t0.elapsed().as_millis() as u64;
(Vec::new(), vh)
}
SearchMode::Hybrid => {
let t0 = Instant::now();
let lh = self.lexical.search(&fanout_query)?;
tb.timing.lexical_ms = t0.elapsed().as_millis() as u64;
let t1 = Instant::now();
let vh = self.vector.search(&fanout_query)?;
tb.timing.vector_ms = t1.elapsed().as_millis() as u64;
(lh, vh)
}
};
tb.lexical = candidates_from_hits(&lex_hits, ScoreKind::Lexical);
tb.vector = candidates_from_hits(&vec_hits, ScoreKind::Vector);
tb.rrf_inputs = build_fusion_input_skeleton(&lex_hits, &vec_hits);
let t_fusion = Instant::now();
let final_hits = match query.mode {
SearchMode::Lexical => {
let mut h = lex_hits.clone();
h.truncate(target_k);
h
}
SearchMode::Vector => {
let mut h = vec_hits.clone();
h.truncate(target_k);
h
}
SearchMode::Hybrid => self.fuse_with_inputs(&lex_hits, &vec_hits, target_k)?,
};
tb.timing.fusion_ms = t_fusion.elapsed().as_millis() as u64;
let score_by_chunk: std::collections::HashMap<String, f32> = final_hits
.iter()
.map(|h| (h.chunk_id.0.clone(), h.retrieval.fusion_score))
.collect();
for entry in &mut tb.rrf_inputs {
if let Some(s) = score_by_chunk.get(&entry.chunk_id.0) {
entry.fusion_score = *s;
}
}
// total_ms is wall-clock from start; per-stage `lexical_ms` /
// `vector_ms` / `fusion_ms` each truncate to whole millis via
// `as_millis() as u64`, so their sum can drift below total
// (sub-ms losses) — DO NOT assert `total_ms >= sum(stages)`.
tb.timing.total_ms = start_total.elapsed().as_millis() as u64;
Ok((final_hits, tb.into_trace()))
}
}
/// Parse the `hybrid_fusion` config string into a [`FusionPolicy`].
@@ -633,4 +719,107 @@ mod tests {
let FusionPolicy::Rrf { k_rrf } = parse_fusion("rrf", 0);
assert_eq!(k_rrf, DEFAULT_K_RRF);
}
#[test]
fn search_with_trace_returns_lex_and_vec_lists() {
use kebab_core::{ChunkId, DocumentId, IndexVersion, ChunkerVersion,
RetrievalDetail, SearchHit, SearchMode, SearchQuery,
WorkspacePath, Citation};
use std::sync::Arc;
fn mk_hit(rank: u32, chunk: &str, score: f32, mode: SearchMode) -> SearchHit {
SearchHit {
rank,
chunk_id: ChunkId(chunk.into()),
doc_id: DocumentId(format!("d-{chunk}")),
doc_path: WorkspacePath::new(format!("{chunk}.md")).unwrap(),
heading_path: vec![],
section_label: None,
snippet: chunk.into(),
citation: Citation::Line {
path: WorkspacePath::new(format!("{chunk}.md")).unwrap(),
start: 1,
end: 1,
section: None,
},
retrieval: RetrievalDetail {
method: mode,
fusion_score: score,
lexical_score: if mode == SearchMode::Lexical { Some(score) } else { None },
vector_score: if mode == SearchMode::Vector { Some(score) } else { None },
lexical_rank: if mode == SearchMode::Lexical { Some(rank) } else { None },
vector_rank: if mode == SearchMode::Vector { Some(rank) } else { None },
},
index_version: IndexVersion("v1".into()),
embedding_model: None,
chunker_version: ChunkerVersion("c1".into()),
indexed_at: time::OffsetDateTime::UNIX_EPOCH,
stale: false,
}
}
struct Stub { hits: Vec<SearchHit> }
impl Retriever for Stub {
fn search(&self, _q: &SearchQuery) -> anyhow::Result<Vec<SearchHit>> {
Ok(self.hits.clone())
}
fn index_version(&self) -> IndexVersion { IndexVersion("v1".into()) }
}
let lex = Arc::new(Stub {
hits: vec![
mk_hit(1, "c1", 0.9, SearchMode::Lexical),
mk_hit(2, "c2", 0.5, SearchMode::Lexical),
],
});
let vec_r = Arc::new(Stub {
hits: vec![
mk_hit(1, "c2", 0.8, SearchMode::Vector),
mk_hit(2, "c3", 0.6, SearchMode::Vector),
],
});
let hybrid = HybridRetriever::with_policy(
lex.clone(),
vec_r.clone(),
FusionPolicy::Rrf { k_rrf: 60 },
2,
);
let q = SearchQuery {
text: "x".into(),
mode: SearchMode::Hybrid,
k: 2,
filters: Default::default(),
};
let (hits, trace) = hybrid.search_with_trace(&q).unwrap();
assert!(!hits.is_empty());
assert_eq!(trace.lexical.len(), 2);
assert_eq!(trace.vector.len(), 2);
// Union: c1, c2, c3 → 3 entries.
assert_eq!(trace.rrf_inputs.len(), 3);
}
#[test]
fn search_with_trace_lexical_mode_empty_vector() {
use kebab_core::{IndexVersion, SearchMode, SearchQuery};
use std::sync::Arc;
struct EmptyR;
impl Retriever for EmptyR {
fn search(&self, _q: &SearchQuery) -> anyhow::Result<Vec<kebab_core::SearchHit>> {
Ok(vec![])
}
fn index_version(&self) -> IndexVersion { IndexVersion("v1".into()) }
}
let lex = Arc::new(EmptyR);
let vec_r = Arc::new(EmptyR);
let hybrid = HybridRetriever::with_policy(lex, vec_r, FusionPolicy::Rrf { k_rrf: 60 }, 2);
let q = SearchQuery {
text: "x".into(),
mode: SearchMode::Lexical,
k: 2,
filters: Default::default(),
};
let (_hits, trace) = hybrid.search_with_trace(&q).unwrap();
assert!(trace.vector.is_empty());
assert_eq!(trace.timing.vector_ms, 0);
}
}

View File

@@ -19,6 +19,7 @@
mod citation_helper;
mod hybrid;
mod lexical;
mod trace;
mod vector;
pub use hybrid::{FusionPolicy, HybridRetriever};

View File

@@ -0,0 +1,85 @@
//! p9-fb-37: trace capture helpers for `HybridRetriever::search_with_trace`.
use std::collections::BTreeMap;
use kebab_core::{
SearchHit, SearchTrace, TraceCandidate, TraceFusionInput, TraceTiming,
};
/// Build a `TraceCandidate` from a `SearchHit`. The score field reflects
/// each side's score (lexical / vector / fusion) — caller selects which
/// retriever's hit list this is.
pub fn candidates_from_hits(hits: &[SearchHit], score_kind: ScoreKind) -> Vec<TraceCandidate> {
hits.iter()
.map(|h| TraceCandidate {
chunk_id: h.chunk_id.clone(),
doc_id: h.doc_id.clone(),
doc_path: h.doc_path.clone(),
rank: h.rank,
score: match score_kind {
ScoreKind::Lexical => h.retrieval.lexical_score.unwrap_or(0.0),
ScoreKind::Vector => h.retrieval.vector_score.unwrap_or(0.0),
},
})
.collect()
}
#[derive(Clone, Copy, Debug)]
pub enum ScoreKind {
Lexical,
Vector,
}
/// Build the union of (chunk_id) across lex and vec hit lists, with
/// each side's rank captured. `fusion_score` is filled by the caller
/// (RRF computes it during fusion, this helper just pre-builds the
/// rank table — caller overwrites fusion_score in a second pass).
pub fn build_fusion_input_skeleton(
lex: &[SearchHit],
vec: &[SearchHit],
) -> Vec<TraceFusionInput> {
let mut by_chunk: BTreeMap<String, TraceFusionInput> = BTreeMap::new();
for h in lex {
by_chunk
.entry(h.chunk_id.0.clone())
.or_insert(TraceFusionInput {
chunk_id: h.chunk_id.clone(),
lexical_rank: None,
vector_rank: None,
fusion_score: 0.0,
})
.lexical_rank = Some(h.rank);
}
for h in vec {
by_chunk
.entry(h.chunk_id.0.clone())
.or_insert(TraceFusionInput {
chunk_id: h.chunk_id.clone(),
lexical_rank: None,
vector_rank: None,
fusion_score: 0.0,
})
.vector_rank = Some(h.rank);
}
by_chunk.into_values().collect()
}
/// Container the hybrid retriever fills during a traced run.
#[derive(Default)]
pub struct TraceBuilder {
pub lexical: Vec<TraceCandidate>,
pub vector: Vec<TraceCandidate>,
pub rrf_inputs: Vec<TraceFusionInput>,
pub timing: TraceTiming,
}
impl TraceBuilder {
pub fn into_trace(self) -> SearchTrace {
SearchTrace {
lexical: self.lexical,
vector: self.vector,
rrf_inputs: self.rrf_inputs,
timing: self.timing,
}
}
}

View File

@@ -28,6 +28,7 @@ mod fts;
mod jobs;
mod schema;
mod store;
pub mod stats_ext;
pub use embeddings::EmbeddingRecordRow;
pub use error::StoreError;

View File

@@ -0,0 +1,168 @@
//! p9-fb-37: extended stats helpers — per-media / per-lang doc counts,
//! stale doc count, on-disk index byte sums.
use std::collections::BTreeMap;
use std::path::Path;
use kebab_core::{IndexBytes, MEDIA_KINDS};
use rusqlite::Connection;
/// p9-fb-37: result of [`breakdowns`] — three independent counts collected in one pass.
#[derive(Debug, Clone, Default)]
pub struct Breakdowns {
pub media: BTreeMap<String, u64>,
pub lang: BTreeMap<String, u64>,
pub stale_doc_count: u64,
}
/// `media` always contains all 5 `MEDIA_KINDS` (zero-padded).
/// `lang` only contains observed languages; NULL lang is
/// keyed as the literal string `"null"`. `stale_doc_count` is 0 when
/// `threshold_days == 0` (mirrors fb-32 staleness disable semantics).
pub fn breakdowns(
conn: &Connection,
threshold_days: u64,
) -> rusqlite::Result<Breakdowns> {
// media: dual JSON shape — text variant ("markdown") vs object
// variant ({"image":{"format":"png"}}). Same CASE WHEN as fb-36.
let mut media: BTreeMap<String, u64> = MEDIA_KINDS
.iter()
.map(|k| ((*k).to_string(), 0u64))
.collect();
let mut stmt = conn.prepare(
"SELECT \
CASE \
WHEN json_type(a.media_type) = 'text' \
THEN json_extract(a.media_type, '$') \
ELSE (SELECT key FROM json_each(a.media_type) LIMIT 1) \
END AS kind, \
COUNT(DISTINCT d.doc_id) \
FROM documents d JOIN assets a ON a.asset_id = d.asset_id \
GROUP BY kind",
)?;
let rows = stmt.query_map([], |r| {
Ok((r.get::<_, String>(0)?, r.get::<_, u64>(1)?))
})?;
for row in rows {
let (kind, n) = row?;
media.insert(kind, n);
}
let mut lang: BTreeMap<String, u64> = BTreeMap::new();
let mut stmt = conn.prepare(
"SELECT COALESCE(lang, 'null') AS l, COUNT(*) \
FROM documents GROUP BY l",
)?;
let rows = stmt.query_map([], |r| {
Ok((r.get::<_, String>(0)?, r.get::<_, u64>(1)?))
})?;
for row in rows {
let (l, n) = row?;
lang.insert(l, n);
}
let stale_doc_count: u64 = if threshold_days == 0 {
0
} else {
let secs = (threshold_days as i64) * 86_400;
let cutoff = time::OffsetDateTime::now_utc()
- time::Duration::seconds(secs);
let cutoff_str = cutoff
.format(&time::format_description::well_known::Rfc3339)
.expect("RFC3339 format");
conn.query_row(
"SELECT COUNT(*) FROM documents WHERE updated_at < ?",
[cutoff_str],
|r| r.get(0),
)?
};
Ok(Breakdowns {
media,
lang,
stale_doc_count,
})
}
/// Sum on-disk bytes of the SQLite database (main + WAL + SHM) and
/// the LanceDB directory tree. Missing files / dir = 0.
pub fn index_bytes(data_dir: &Path) -> std::io::Result<IndexBytes> {
fn file_size_or_zero(p: &Path) -> u64 {
std::fs::metadata(p).map(|m| m.len()).unwrap_or(0)
}
fn dir_walk_sum(p: &Path) -> std::io::Result<u64> {
if !p.exists() {
return Ok(0);
}
let mut total = 0u64;
for entry in std::fs::read_dir(p)? {
let entry = entry?;
let ty = entry.file_type()?;
if ty.is_dir() {
total += dir_walk_sum(&entry.path())?;
} else if ty.is_file() {
total += entry.metadata()?.len();
}
}
Ok(total)
}
let sqlite_main = data_dir.join("kebab.sqlite");
let sqlite_wal = data_dir.join("kebab.sqlite-wal");
let sqlite_shm = data_dir.join("kebab.sqlite-shm");
let sqlite = file_size_or_zero(&sqlite_main)
+ file_size_or_zero(&sqlite_wal)
+ file_size_or_zero(&sqlite_shm);
let lancedb = dir_walk_sum(&data_dir.join("lancedb"))?;
Ok(IndexBytes { sqlite, lancedb })
}
#[cfg(test)]
mod tests {
use super::*;
fn open_fresh() -> (tempfile::TempDir, crate::SqliteStore) {
let dir = tempfile::tempdir().unwrap();
let mut cfg = kebab_config::Config::defaults();
cfg.storage.data_dir = dir.path().to_string_lossy().into_owned();
let store = crate::SqliteStore::open(&cfg).unwrap();
store.run_migrations().unwrap();
(dir, store)
}
#[test]
fn breakdowns_empty_corpus() {
let (_dir, store) = open_fresh();
let conn = store.read_conn();
let b = breakdowns(&conn, 0).unwrap();
// 5 keys all zero, lang map empty, stale 0.
assert_eq!(b.media.len(), 5);
for k in MEDIA_KINDS {
assert_eq!(b.media.get(*k), Some(&0u64));
}
assert!(b.lang.is_empty());
assert_eq!(b.stale_doc_count, 0);
}
#[test]
fn index_bytes_includes_sqlite_main() {
let (dir, _store) = open_fresh();
let b = index_bytes(dir.path()).unwrap();
assert!(b.sqlite > 0, "main sqlite file should exist after migrations");
assert_eq!(b.lancedb, 0);
}
#[test]
fn index_bytes_lancedb_dir_walk() {
let dir = tempfile::tempdir().unwrap();
let lance = dir.path().join("lancedb");
std::fs::create_dir_all(lance.join("vectors.lance")).unwrap();
std::fs::write(
lance.join("vectors.lance").join("data.bin"),
vec![0u8; 1024],
)
.unwrap();
let b = index_bytes(dir.path()).unwrap();
assert_eq!(b.lancedb, 1024);
}
}

View File

@@ -604,6 +604,12 @@ pub struct CountSummary {
/// ISO-8601 timestamp of the most-recently updated document row, or
/// `None` when the store is empty.
pub last_ingest_at: Option<String>,
/// p9-fb-37: per-media-kind doc count (5 keys, zero-padded).
pub media_breakdown: std::collections::BTreeMap<String, u64>,
/// p9-fb-37: per-language doc count, NULL keyed as `"null"`.
pub lang_breakdown: std::collections::BTreeMap<String, u64>,
/// p9-fb-37: docs whose `updated_at < now - threshold_days`. 0 when threshold=0.
pub stale_doc_count: u64,
}
impl SqliteStore {
@@ -611,39 +617,58 @@ impl SqliteStore {
/// most-recent `documents.updated_at` timestamp.
///
/// Uses `read_conn()` (no mutations) — mirrors the pattern used by
/// [`Self::corpus_revision`].
pub fn count_summary(&self) -> anyhow::Result<CountSummary> {
/// Shared helper: counts and breakdowns in a single pass with given threshold.
fn count_summary_inner(&self, threshold_days: u64) -> anyhow::Result<CountSummary> {
use anyhow::Context;
use rusqlite::OptionalExtension;
let conn = self.read_conn();
let doc_count: u64 = conn
.query_row("SELECT COUNT(*) FROM documents", [], |r| r.get(0))
.context("count documents")?;
let chunk_count: u64 = conn
.query_row("SELECT COUNT(*) FROM chunks", [], |r| r.get(0))
.context("count chunks")?;
let asset_count: u64 = conn
.query_row("SELECT COUNT(*) FROM assets", [], |r| r.get(0))
.context("count assets")?;
let last_ingest_at: Option<String> = conn
.query_row(
"SELECT MAX(updated_at) FROM documents",
[],
|r| r.get(0),
)
.query_row("SELECT MAX(updated_at) FROM documents", [], |r| r.get(0))
.optional()
.context("max updated_at")?
.flatten();
let bd = crate::stats_ext::breakdowns(&conn, threshold_days).context("breakdowns")?;
Ok(CountSummary {
doc_count,
chunk_count,
asset_count,
last_ingest_at,
media_breakdown: bd.media,
lang_breakdown: bd.lang,
stale_doc_count: bd.stale_doc_count,
})
}
/// [`Self::corpus_revision`].
pub fn count_summary(&self) -> anyhow::Result<CountSummary> {
// p9-fb-37: default uses threshold_days=0 (matches fb-32 disable
// semantics). Callers that need real stale_doc_count call
// count_summary_with_threshold.
self.count_summary_inner(0)
}
/// p9-fb-37: variant that honors `config.search.stale_threshold_days`.
/// Callers who need a meaningful `stale_doc_count` (e.g. `kebab schema`)
/// pass the configured threshold; the older `count_summary` returns 0.
pub fn count_summary_with_threshold(
&self,
threshold_days: u64,
) -> anyhow::Result<CountSummary> {
self.count_summary_inner(threshold_days)
}
}
/// Apply the design §5 / task-spec pragmas. Called once per connection.
@@ -681,6 +706,9 @@ mod tests {
assert_eq!(s.chunk_count, 0);
assert_eq!(s.asset_count, 0);
assert!(s.last_ingest_at.is_none());
assert_eq!(s.media_breakdown.len(), 5);
assert!(s.lang_breakdown.is_empty());
assert_eq!(s.stale_doc_count, 0);
}
}

View File

@@ -387,6 +387,8 @@ pub struct App {
pub ask: Option<AskState>,
/// Populated by p9-4.
pub inspect: Option<InspectState>,
/// p9-fb-37: trace popup state, `Some` while open.
pub trace_popup: Option<crate::trace_popup::TracePopupState>,
/// Populated by p9-fb-03 when the user kicks off an in-shell
/// ingest (Library `r`). Cleared by the run loop a few seconds
/// after the run reaches a terminal event.
@@ -461,6 +463,7 @@ impl App {
search: None,
ask: None,
inspect: None,
trace_popup: None,
ingest_state: None,
error_overlay: None,
should_quit: false,

View File

@@ -80,6 +80,7 @@ pub fn render_cheatsheet(f: &mut Frame, area: Rect, app: &App) {
("Delete", "remove char at cursor"),
("g", "open hit's citation in $EDITOR (Normal)"),
("o", "inspect selected hit's chunk (Normal — was `i` pre-fb-21)"),
("t", "open retrieval trace popup (Normal — p9-fb-37)"),
("i", "Normal → Insert (toggle back to typing)"),
("Esc", "back to Library"),
]);

View File

@@ -27,6 +27,7 @@ mod run;
mod search;
mod terminal;
mod theme;
pub mod trace_popup;
pub use input::{InputBuffer, display_width, place_cursor_x, truncate_to_display_width};
pub use theme::{Palette, Role, Theme};

View File

@@ -130,6 +130,21 @@ pub(crate) fn run_loop(app: &mut App) -> Result<()> {
if event::poll(POLL_INTERVAL)? {
match event::read()? {
Event::Key(key) if key.kind == KeyEventKind::Press => {
// p9-fb-37: trace popup eats keys while open.
// Sits ahead of cheatsheet + mode + pane dispatch
// so Esc / j / k / arrows route to the popup
// instead of leaking through to the search pane.
if app.trace_popup.is_some() {
let close = if let Some(popup) = app.trace_popup.as_mut() {
crate::trace_popup::handle_key_trace_popup(popup, key)
} else {
false
};
if close {
app.trace_popup = None;
}
continue;
}
// p9-fb-13: cheatsheet popup toggle takes
// precedence over both mode + pane dispatch.
// F1 toggles open/close. While visible, Esc
@@ -255,6 +270,12 @@ fn render_root(f: &mut Frame, app: &App) {
}
render_status_bar(f, outer[2], app);
render_key_hints(f, outer[3], app);
// p9-fb-37: trace popup overlays on top of pane content but
// below the error overlay (errors are higher-priority modal).
if let Some(popup) = &app.trace_popup {
let popup_area = centered_rect(80, 80, f.area());
crate::trace_popup::render_trace_popup(f, popup_area, popup);
}
if let Some(err) = &app.error_overlay {
render_error_overlay(f, f.area(), err, &app.theme);
}
@@ -263,6 +284,28 @@ fn render_root(f: &mut Frame, app: &App) {
}
}
/// p9-fb-37: centered sub-rect helper for the trace popup. Returns
/// a rect of `percent_x` × `percent_y` percent of `r`, centered.
fn centered_rect(percent_x: u16, percent_y: u16, r: ratatui::layout::Rect) -> ratatui::layout::Rect {
use ratatui::layout::{Constraint, Direction, Layout};
let popup_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
])
.split(r);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
])
.split(popup_layout[1])[1]
}
fn render_header(f: &mut Frame, area: Rect, app: &App) {
let pane_label = match app.focus {
Pane::Library => "Library",

View File

@@ -209,6 +209,51 @@ pub fn handle_key_search(state: &mut App, key: KeyEvent) -> KeyOutcome {
// pre-fb-12 SHIFT/none heuristic).
let is_normal = state.mode == crate::app::Mode::Normal;
// p9-fb-37: `t` opens the trace popup. Re-runs the last submitted
// query with SearchOpts.trace = true. Bypasses cache by going
// through `search_with_opts_with_config` (Task 5 wires opts.trace
// to skip the LRU cache).
if is_normal
&& matches!(
(key.code, key.modifiers),
(KeyCode::Char('t'), KeyModifiers::NONE)
)
{
let (last_query, has_results) = {
let s = state.search.as_ref().unwrap();
(s.last_query.clone(), !s.hits.is_empty())
};
if !has_results {
return KeyOutcome::Continue;
}
if let Some((q_text, q_mode)) = last_query {
// TODO: thread filters when TUI gains a filter UI (currently
// mirrors fire_search which also passes default filters).
let q = kebab_core::SearchQuery {
text: q_text,
mode: q_mode,
k: state.config.search.default_k,
filters: kebab_core::SearchFilters::default(),
};
let opts = kebab_core::SearchOpts {
trace: true,
..Default::default()
};
match kebab_app::search_with_opts_with_config(state.config.clone(), q, opts) {
Ok(resp) => {
if let Some(t) = resp.trace {
state.trace_popup = Some(crate::trace_popup::TracePopupState::new(t));
}
}
Err(_) => {
// Silent failure — trace is debug-only; user
// can still see search hits without it.
}
}
}
return KeyOutcome::Continue;
}
// p9-fb-21: chunk-inspect rebound from `i` to `o` (vim "open").
// The `i` key is now the universal Normal→Insert toggle (handled
// in `mode_intercept`), so it cannot also mean "inspect chunk"

View File

@@ -0,0 +1,139 @@
//! p9-fb-37: TUI trace popup. Opens from Search pane via `t` key
//! when results are visible. Re-runs the current query with
//! `SearchOpts.trace = true` and displays the lex / vec / rrf union
//! + per-stage timing as a single scroll list.
use crossterm::event::{KeyCode, KeyEvent};
use kebab_core::SearchTrace;
use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
#[derive(Debug, Clone)]
pub struct TracePopupState {
pub trace: SearchTrace,
pub scroll: u16,
}
impl TracePopupState {
pub fn new(trace: SearchTrace) -> Self {
Self { trace, scroll: 0 }
}
}
pub fn render_trace_popup(f: &mut Frame, area: Rect, state: &TracePopupState) {
let mut lines: Vec<Line> = Vec::new();
let bold = Style::default().add_modifier(Modifier::BOLD);
lines.push(Line::from(Span::styled(
format!(
"Lexical ({} hits, {} ms)",
state.trace.lexical.len(),
state.trace.timing.lexical_ms,
),
bold,
)));
for c in &state.trace.lexical {
lines.push(Line::from(format!(
" #{:>2} score={:.4} chunk={}",
c.rank, c.score, c.chunk_id.0
)));
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
format!(
"Vector ({} hits, {} ms)",
state.trace.vector.len(),
state.trace.timing.vector_ms,
),
bold,
)));
for c in &state.trace.vector {
lines.push(Line::from(format!(
" #{:>2} score={:.4} chunk={}",
c.rank, c.score, c.chunk_id.0
)));
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
format!(
"RRF inputs ({} entries, {} ms fusion)",
state.trace.rrf_inputs.len(),
state.trace.timing.fusion_ms,
),
bold,
)));
for e in &state.trace.rrf_inputs {
lines.push(Line::from(format!(
" chunk={} lex={:?} vec={:?} fusion={:.4}",
e.chunk_id.0, e.lexical_rank, e.vector_rank, e.fusion_score
)));
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
format!("Total: {} ms", state.trace.timing.total_ms),
bold,
)));
let block = Block::default()
.title("Trace — Esc to close, j/k or ↑↓ to scroll")
.borders(Borders::ALL);
let p = Paragraph::new(lines)
.block(block)
.scroll((state.scroll, 0))
.wrap(Wrap { trim: false });
f.render_widget(p, area);
}
/// Handle keys while popup is open. Returns true if the popup should close.
pub fn handle_key_trace_popup(state: &mut TracePopupState, key: KeyEvent) -> bool {
match key.code {
KeyCode::Esc => true,
KeyCode::Char('j') | KeyCode::Down => {
state.scroll = state.scroll.saturating_add(1);
false
}
KeyCode::Char('k') | KeyCode::Up => {
state.scroll = state.scroll.saturating_sub(1);
false
}
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::KeyModifiers;
use kebab_core::TraceTiming;
fn dummy_state() -> TracePopupState {
TracePopupState::new(SearchTrace {
lexical: vec![],
vector: vec![],
rrf_inputs: vec![],
timing: TraceTiming::default(),
})
}
#[test]
fn esc_closes() {
let mut s = dummy_state();
assert!(handle_key_trace_popup(
&mut s,
KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
));
}
#[test]
fn j_scrolls_down() {
let mut s = dummy_state();
assert!(!handle_key_trace_popup(
&mut s,
KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE),
));
assert_eq!(s.scroll, 1);
}
}

View File

@@ -206,6 +206,22 @@ kebab search "rust" --doc-id "<doc-id>" --tag rust --json
Bad `--ingested-after` → `error.v1.code = config_invalid`, exit 2.
Unknown `--media` value → silently empty (no error).
### Trace + stats (fb-37)
Re-run a search with `--trace` to see per-stage candidate lists + timing:
```bash
kebab --config /tmp/kebab-smoke/config.toml search "rust async" --trace --json | jq .trace
```
Inspect the corpus health surface:
```bash
kebab --config /tmp/kebab-smoke/config.toml schema --json | jq .stats
```
Look for: `media_breakdown` (5 keys), `lang_breakdown`, `index_bytes`, `stale_doc_count`.
## P6-4 이미지 ingestion 옵션
`config.toml` 에 다음 절을 추가하면 `kebab ingest` 가 `**/*.png` / `**/*.jpg` 등 이미지 자산도 함께 색인합니다 (텍스트만 색인하려면 생략):

View File

@@ -0,0 +1,697 @@
# fb-38 Score Semantics Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add `score_kind` field on `search_hit.v1` (`"rrf"` / `"bm25"` / `"cosine"`) and document RRF formula + score interpretation so agents stop misreading the top-level `score` as confidence.
**Architecture:** New `ScoreKind` enum on `kebab-core`. Each retriever (lexical / vector / hybrid) labels hits with the appropriate kind at construction. Wire serialization is automatic via existing `serde_json::to_value(&hit)`. Documentation in README + design + SKILL explains the RRF formula and the ranking-vs-confidence distinction.
**Tech Stack:** Rust 2024, serde, JSON Schema 2020-12.
**Spec:** `docs/superpowers/specs/2026-05-10-p9-fb-38-score-semantics-design.md`
---
## File map
**Create:** none.
**Modify:**
- `crates/kebab-core/src/search.rs` — add `ScoreKind` enum + `SearchHit.score_kind` field; update existing `SearchHit` test fixture.
- `crates/kebab-search/src/lexical.rs` — set `score_kind: Bm25` at hit construction.
- `crates/kebab-search/src/vector.rs` — set `score_kind: Cosine` at hit construction.
- `crates/kebab-search/src/hybrid.rs` — set `score_kind: Rrf` after RRF base.retrieval overwrite; update `mk_hit` test helper.
- `crates/kebab-rag/src/pipeline.rs` — update `mk_hit` test helper with `score_kind`.
- `crates/kebab-cli/tests/wire_search_response.rs` (or new) — integration test asserting `score_kind` on lexical / hybrid wire output.
- `docs/wire-schema/v1/search_hit.schema.json` — add optional `score_kind` enum field.
- `README.md` — new "Score interpretation (fb-38)" section.
- `docs/superpowers/specs/2026-04-27-kebab-final-form-design.md` §4 — RRF formula + score_kind field block.
- `integrations/claude-code/kebab/SKILL.md``score_kind` mention + ranking-vs-confidence guidance.
- `tasks/p9/p9-fb-38-score-semantics.md` — flip status, add design + plan links.
- `tasks/INDEX.md` — flip fb-38 to ✅.
---
## Task 1: Add ScoreKind enum + SearchHit.score_kind field
**Files:**
- Modify: `crates/kebab-core/src/search.rs`
- [ ] **Step 1: Append failing tests to `mod tests`**
```rust
#[test]
fn score_kind_serde_roundtrip() {
use ScoreKind::*;
for (kind, expected) in [(Rrf, "rrf"), (Bm25, "bm25"), (Cosine, "cosine")] {
let v = serde_json::to_value(kind).unwrap();
assert_eq!(v.as_str(), Some(expected));
let back: ScoreKind = serde_json::from_value(v).unwrap();
assert_eq!(back, kind);
}
}
#[test]
fn score_kind_default_is_rrf() {
assert_eq!(ScoreKind::default(), ScoreKind::Rrf);
}
#[test]
fn search_hit_deserialize_without_score_kind_defaults_to_rrf() {
// Old wire (pre-fb-38) shape — no `score_kind` field. Must
// deserialize cleanly with `Rrf` default.
let json = serde_json::json!({
"rank": 1,
"chunk_id": "c1",
"doc_id": "d1",
"doc_path": "a.md",
"heading_path": [],
"section_label": null,
"snippet": "x",
"citation": { "Line": { "path": "a.md", "start": 1, "end": 1, "section": null } },
"retrieval": {
"method": "Lexical",
"fusion_score": 0.5,
"lexical_score": 0.5,
"vector_score": null,
"lexical_rank": 1,
"vector_rank": null
},
"index_version": "v1",
"embedding_model": null,
"chunker_version": "c1",
"indexed_at": "2026-05-10T12:00:00Z",
"stale": false
});
let hit: SearchHit = serde_json::from_value(json).unwrap();
assert_eq!(hit.score_kind, ScoreKind::Rrf);
}
```
- [ ] **Step 2: Run tests to verify compile failures**
```bash
cargo test -p kebab-core --lib score_kind
```
Expected: errors — `ScoreKind` undefined; `SearchHit.score_kind` missing.
- [ ] **Step 3: Add `ScoreKind` enum + extend `SearchHit`**
In `crates/kebab-core/src/search.rs`, add the enum (place after `MEDIA_KINDS` constant, before `SearchQuery`):
```rust
/// p9-fb-38: top-level `SearchHit.score` declaration.
/// `Rrf` (hybrid) / `Bm25` (lexical-only) / `Cosine` (vector-only).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ScoreKind {
Rrf,
Bm25,
Cosine,
}
impl Default for ScoreKind {
fn default() -> Self {
ScoreKind::Rrf
}
}
```
Extend `SearchHit` (add field after `stale`):
```rust
pub struct SearchHit {
// ... existing fields ...
pub stale: bool,
/// p9-fb-38: declares the meaning of the top-level `score`.
/// `Rrf` (hybrid mode), `Bm25` (lexical-only), `Cosine` (vector-only).
/// Older wire (fb-38 미만) 부재 시 `Rrf` default — hybrid 가 기본 mode.
#[serde(default)]
pub score_kind: ScoreKind,
}
```
Update existing test fixture `search_hit_serializes_indexed_at_and_stale` (~line 190): add `score_kind: ScoreKind::Rrf,` to the struct literal.
- [ ] **Step 4: Run tests**
```bash
cargo test -p kebab-core --lib
```
Expected: all 3 new tests + existing tests pass.
- [ ] **Step 5: Re-export at crate root**
Edit `crates/kebab-core/src/lib.rs` re-export block — add `ScoreKind` to the `search::` re-export list.
```bash
grep -n "SearchHit\|SearchTrace\|TraceCandidate" crates/kebab-core/src/lib.rs
```
The fb-37 task added `SearchTrace`/`TraceCandidate`/`TraceFusionInput`/`TraceTiming`/`IndexBytes`/`MEDIA_KINDS` to the same export block — add `ScoreKind` next to them.
- [ ] **Step 6: Commit**
```bash
git add crates/kebab-core/src/search.rs crates/kebab-core/src/lib.rs
git commit -m "feat(core): ScoreKind enum + SearchHit.score_kind (fb-38)"
```
---
## Task 2: Label LexicalRetriever hits as Bm25
**Files:**
- Modify: `crates/kebab-search/src/lexical.rs`
- [ ] **Step 1: Add unit test in `crates/kebab-search/src/lexical.rs`**
Append to existing `mod tests` (find via `grep -n "mod tests" crates/kebab-search/src/lexical.rs`). If no tests module exists in that file, the integration tests in `tests/` cover behavior — add a unit test asserting via the public surface. Inspect first:
```bash
grep -n "mod tests\|#\[test\]" crates/kebab-search/src/lexical.rs | head -5
```
If no `mod tests` in lexical.rs, add a unit test in the existing integration test file (find via `ls crates/kebab-search/tests/`). Otherwise prepare an integration test that builds a lexical retriever against a real fixture and asserts on the hit's `score_kind`.
The simplest path: assert via the existing `lexical_*` integration tests. Pick the smallest one and add an assertion. Or, more cleanly, add a new integration test:
Append to `crates/kebab-search/tests/lexical_basic.rs` (or whichever existing lexical test file the workspace has — check `ls crates/kebab-search/tests/`):
```rust
#[test]
fn lexical_retriever_hits_carry_bm25_score_kind() {
// Use the existing fixture-builder pattern from this file.
// The intent: any hit returned by LexicalRetriever has
// `score_kind == ScoreKind::Bm25`.
let (_dir, retriever) = setup_lexical_with_corpus(&[
("a.md", "rust async tokens"),
]);
let hits = retriever
.search(&kebab_core::SearchQuery {
text: "rust".into(),
mode: kebab_core::SearchMode::Lexical,
k: 5,
filters: Default::default(),
})
.unwrap();
assert!(!hits.is_empty());
for h in &hits {
assert_eq!(h.score_kind, kebab_core::ScoreKind::Bm25);
}
}
```
`setup_lexical_with_corpus` is the existing fixture name — adjust to whatever the file's helper is called. If the file uses inline `tempfile::tempdir() + SqliteStore::open + ingest_with_config + LexicalRetriever::with_settings`, mirror that pattern.
- [ ] **Step 2: Run test to verify it fails**
```bash
cargo test -p kebab-search lexical_retriever_hits_carry_bm25_score_kind
```
Expected: compile error (struct literal needs new field) OR assertion failure (score_kind defaults to Rrf, not Bm25).
- [ ] **Step 3: Update `LexicalRetriever` hit construction**
In `crates/kebab-search/src/lexical.rs:447-471`, find the `Ok(SearchHit { ... })` block and add `score_kind: kebab_core::ScoreKind::Bm25,` (anywhere in the field list — placement doesn't matter for serde). Place it next to the `stale: false` line for visual grouping:
```rust
Ok(SearchHit {
rank,
chunk_id: ChunkId(raw.chunk_id),
// ... existing fields ...
indexed_at,
stale: false,
score_kind: kebab_core::ScoreKind::Bm25,
})
```
- [ ] **Step 4: Run tests**
```bash
cargo test -p kebab-search
```
Expected: new test passes + all existing kebab-search tests still pass.
- [ ] **Step 5: Clippy**
```bash
cargo clippy -p kebab-search --all-targets -- -D warnings
```
- [ ] **Step 6: Commit**
```bash
git add crates/kebab-search/src/lexical.rs crates/kebab-search/tests/
git commit -m "feat(search/lexical): label hits with ScoreKind::Bm25 (fb-38)"
```
---
## Task 3: Label VectorRetriever hits as Cosine
**Files:**
- Modify: `crates/kebab-search/src/vector.rs`
- [ ] **Step 1: Add unit test**
VectorRetriever requires embeddings, so a real-corpus integration test isn't possible without a model. Add a unit test that constructs a `SearchHit` directly through whichever helper the file uses, OR adjust an existing vector test that already builds a retriever.
Inspect existing tests:
```bash
ls crates/kebab-search/tests/ | grep vector
grep -n "fn build_hit\|VectorRetriever" crates/kebab-search/src/vector.rs | head -5
```
If there's a private `build_hit` helper, write a unit test around it. Otherwise mirror the lexical test pattern but stub the embedder. Worst case: skip the unit test for VectorRetriever and rely on the hybrid test (Task 4) which exercises the vector path indirectly. Document in the commit message.
For simplicity, the recommended approach: add the score_kind line in Step 2 below first, then add a unit test using a simple hit-construction helper if accessible. If not accessible, the hybrid task (Task 4) covers behavior via the search_with_trace mode=Vector branch.
- [ ] **Step 2: Update `VectorRetriever` hit construction**
In `crates/kebab-search/src/vector.rs:304-330`, find `Ok(SearchHit { ... })` and add:
```rust
Ok(SearchHit {
rank,
// ... existing fields ...
indexed_at,
stale: false,
score_kind: kebab_core::ScoreKind::Cosine,
})
```
- [ ] **Step 3: Run tests**
```bash
cargo test -p kebab-search
cargo clippy -p kebab-search --all-targets -- -D warnings
```
Expected: existing tests still pass; clippy clean.
- [ ] **Step 4: Commit**
```bash
git add crates/kebab-search/src/vector.rs
git commit -m "feat(search/vector): label hits with ScoreKind::Cosine (fb-38)"
```
---
## Task 4: Label HybridRetriever fuse hits as Rrf + update test helpers
**Files:**
- Modify: `crates/kebab-search/src/hybrid.rs`
- Modify: `crates/kebab-rag/src/pipeline.rs` (test helper)
- [ ] **Step 1: Add unit test in `crates/kebab-search/src/hybrid.rs` `mod tests`**
Append:
```rust
#[test]
fn hybrid_fuse_labels_hits_as_rrf() {
// Reuse mk_hit / Stub from the existing tests in this file.
use kebab_core::{ScoreKind, SearchMode, SearchQuery};
use std::sync::Arc;
struct Stub { hits: Vec<kebab_core::SearchHit> }
impl Retriever for Stub {
fn search(&self, _q: &SearchQuery) -> anyhow::Result<Vec<kebab_core::SearchHit>> {
Ok(self.hits.clone())
}
fn index_version(&self) -> kebab_core::IndexVersion {
kebab_core::IndexVersion("v1".into())
}
}
let lex = Arc::new(Stub {
hits: vec![mk_hit(1, "c1", 0.9, SearchMode::Lexical)],
});
let vec_r = Arc::new(Stub {
hits: vec![mk_hit(1, "c1", 0.8, SearchMode::Vector)],
});
let hybrid = HybridRetriever::with_policy(
lex,
vec_r,
FusionPolicy::Rrf { k_rrf: 60 },
2,
);
let q = SearchQuery {
text: "x".into(),
mode: SearchMode::Hybrid,
k: 1,
filters: Default::default(),
};
let hits = hybrid.search(&q).unwrap();
assert!(!hits.is_empty());
assert_eq!(hits[0].score_kind, ScoreKind::Rrf);
}
#[test]
fn hybrid_search_with_trace_lexical_mode_passes_through_bm25() {
use kebab_core::{ScoreKind, SearchMode, SearchQuery};
use std::sync::Arc;
struct Stub { hits: Vec<kebab_core::SearchHit> }
impl Retriever for Stub {
fn search(&self, _q: &SearchQuery) -> anyhow::Result<Vec<kebab_core::SearchHit>> {
Ok(self.hits.clone())
}
fn index_version(&self) -> kebab_core::IndexVersion {
kebab_core::IndexVersion("v1".into())
}
}
let mut lex_hit = mk_hit(1, "c1", 0.5, SearchMode::Lexical);
lex_hit.score_kind = ScoreKind::Bm25;
let lex = Arc::new(Stub { hits: vec![lex_hit] });
let vec_r = Arc::new(Stub { hits: vec![] });
let hybrid = HybridRetriever::with_policy(
lex,
vec_r,
FusionPolicy::Rrf { k_rrf: 60 },
2,
);
let q = SearchQuery {
text: "x".into(),
mode: SearchMode::Lexical,
k: 1,
filters: Default::default(),
};
let (hits, _trace) = hybrid.search_with_trace(&q).unwrap();
assert!(!hits.is_empty());
// search_with_trace mode=Lexical passes through underlying hits.
assert_eq!(hits[0].score_kind, ScoreKind::Bm25);
}
```
The existing `mk_hit` helper at `hybrid.rs:730` is in the same `mod tests` block — reachable.
- [ ] **Step 2: Run tests to verify failures**
```bash
cargo test -p kebab-search hybrid
```
Expected: compile errors (mk_hit doesn't set score_kind so the struct literal is incomplete; new tests assert wrong value).
- [ ] **Step 3: Update `mk_hit` test helper at `hybrid.rs:730`**
Find `fn mk_hit(rank: u32, chunk: &str, score: f32, mode: SearchMode) -> SearchHit` and add `score_kind` to the returned literal:
```rust
fn mk_hit(rank: u32, chunk: &str, score: f32, mode: SearchMode) -> SearchHit {
SearchHit {
// ... existing fields ...
indexed_at: time::OffsetDateTime::UNIX_EPOCH,
stale: false,
score_kind: kebab_core::ScoreKind::Rrf, // tests override per-mode
}
}
```
- [ ] **Step 4: Update `hybrid.rs` fuse to set Rrf after retrieval overwrite**
Find `base.retrieval = RetrievalDetail { ... }` block (~line 302-314). Immediately AFTER that block (before `hits.push(base)`), add:
```rust
base.score_kind = kebab_core::ScoreKind::Rrf;
hits.push(base);
```
(`base` was cloned from a lex/vec hit that had `Bm25`/`Cosine`; the fuse output is RRF-scored so override.)
- [ ] **Step 5: Update `pipeline.rs` mk_hit test helper**
```bash
grep -n "fn mk_hit" crates/kebab-rag/src/pipeline.rs
```
At ~line 1092, the test helper builds a SearchHit. Add `score_kind: kebab_core::ScoreKind::Rrf,` to the literal (place after `stale`).
- [ ] **Step 6: Update `kebab-core` test fixture if any other SearchHit literal exists**
```bash
grep -rn "SearchHit {" crates/ --include="*.rs"
```
For each location, ensure the literal includes `score_kind`. The Task 1 update on `crates/kebab-core/src/search.rs:190` should already be done. Tasks 2/3 cover the lexical/vector retriever construction. Tasks 4 covers `mk_hit` helpers. If any other SearchHit literal turns up (e.g. fb-37 added some in tests), add `score_kind` there too.
- [ ] **Step 7: Run tests + clippy**
```bash
cargo test -p kebab-core -p kebab-search -p kebab-rag
cargo clippy -p kebab-core -p kebab-search -p kebab-rag --all-targets -- -D warnings
```
Expected: all green.
- [ ] **Step 8: Commit**
```bash
git add crates/kebab-search/src/hybrid.rs crates/kebab-rag/src/pipeline.rs
git commit -m "feat(search/hybrid): label fused hits with ScoreKind::Rrf (fb-38)"
```
---
## Task 5: Workspace tests + cross-crate cleanup for SearchHit literals
**Files:**
- Modify: any other crate file with `SearchHit {` literal that broke (e.g., `kebab-app`, `kebab-cli`, `kebab-mcp`, `kebab-tui` test fixtures).
- [ ] **Step 1: Find all broken sites**
```bash
cargo build --workspace 2>&1 | grep "missing field \`score_kind\`" | head -20
```
This reveals every spot. Common patterns:
- Test fixtures in `crates/kebab-cli/tests/wire_*.rs` that hand-build hits.
- Test helpers in `crates/kebab-app/tests/`.
- TUI test data in `crates/kebab-tui/tests/`.
For each: open the file, find the `SearchHit {` literal, add `score_kind: kebab_core::ScoreKind::Rrf,` (default for test fixtures unless the test specifically exercises lex/vec mode).
- [ ] **Step 2: Verify workspace builds**
```bash
cargo build --workspace 2>&1 | tail -5
```
Expected: clean.
- [ ] **Step 3: Run full workspace tests**
```bash
cargo test --workspace --no-fail-fast -j 1
cargo clippy --workspace --all-targets -- -D warnings
```
Expected: all green.
- [ ] **Step 4: Commit**
```bash
git add crates/
git commit -m "fix(fb-38): add score_kind to remaining SearchHit literals"
```
---
## Task 6: CLI integration test for score_kind
**Files:**
- Modify: `crates/kebab-cli/tests/wire_search_response.rs` (or new file `wire_search_score_kind.rs` if appending feels cluttered)
- [ ] **Step 1: Inspect existing wire test pattern**
```bash
ls crates/kebab-cli/tests/
head -50 crates/kebab-cli/tests/wire_search_response.rs
```
Use the same fixture pattern from fb-37's `wire_search_trace.rs` (`common::write_config + ingest + run_search_with_args`).
- [ ] **Step 2: Add integration tests**
Create `crates/kebab-cli/tests/wire_search_score_kind.rs`:
```rust
//! p9-fb-38: integration tests for `search_hit.v1.score_kind`.
mod common;
use serde_json::Value;
use std::fs;
fn doc_with_term(workspace: &std::path::Path) {
fs::write(workspace.join("doc1.md"), "# Title\n\nrust async hello\n").unwrap();
}
#[test]
fn lexical_mode_hits_carry_bm25_score_kind() {
let dir = tempfile::tempdir().unwrap();
let (cfg, workspace, _data) = common::write_config(dir.path(), 0);
doc_with_term(&workspace);
common::ingest(&cfg, &workspace);
let (stdout, _stderr) = common::run_search_with_args(
&cfg,
&["--mode", "lexical", "--json", "rust"],
);
let v: Value = serde_json::from_str(stdout.trim()).expect("valid JSON");
let hits = v["hits"].as_array().expect("hits array");
assert!(!hits.is_empty(), "expected at least 1 hit");
for h in hits {
assert_eq!(h["score_kind"], "bm25");
}
}
#[test]
fn old_wire_reader_compat_score_kind_optional_field() {
// The wire schema marks `score_kind` as additive (not required).
// We can't easily simulate an old reader from inside Rust, but we
// can confirm the JSON includes the field — old readers that
// ignore unknown fields are unaffected. This test just ensures
// the field is always present in fb-38+ output.
let dir = tempfile::tempdir().unwrap();
let (cfg, workspace, _data) = common::write_config(dir.path(), 0);
doc_with_term(&workspace);
common::ingest(&cfg, &workspace);
let (stdout, _stderr) = common::run_search_with_args(
&cfg,
&["--mode", "lexical", "--json", "rust"],
);
let v: Value = serde_json::from_str(stdout.trim()).unwrap();
let hit = &v["hits"][0];
assert!(hit.get("score_kind").is_some(), "score_kind always emitted");
}
```
- [ ] **Step 3: Run integration tests**
```bash
cargo test -p kebab-cli --test wire_search_score_kind
```
Expected: 2 tests pass.
- [ ] **Step 4: Commit**
```bash
git add crates/kebab-cli/tests/wire_search_score_kind.rs
git commit -m "test(cli): integration tests for score_kind on lexical mode (fb-38)"
```
---
## Task 7: Wire schema + docs + status flip
**Files:**
- Modify: `docs/wire-schema/v1/search_hit.schema.json`
- Modify: `README.md`
- Modify: `docs/superpowers/specs/2026-04-27-kebab-final-form-design.md`
- Modify: `integrations/claude-code/kebab/SKILL.md`
- Modify: `tasks/p9/p9-fb-38-score-semantics.md`
- Modify: `tasks/INDEX.md`
- [ ] **Step 1: Update `docs/wire-schema/v1/search_hit.schema.json`**
Add `score_kind` to `properties` (not to `required`). Insert next to `score`:
```json
"score_kind": {
"type": "string",
"enum": ["rrf", "bm25", "cosine"],
"description": "p9-fb-38: kind of `score` value. `rrf` = RRF normalized [0,1] (hybrid mode); `bm25` = raw BM25 score (lexical-only); `cosine` = raw cosine similarity (vector-only). Older clients that omit this field can treat absence as `rrf` (the historical default)."
}
```
- [ ] **Step 2: Update `README.md`**
Find the `kebab search` section (or wherever flag descriptions live). Add a new "Score interpretation (fb-38)" subsection:
````markdown
### Score 해석 (fb-38)
`search_hit.v1.score` 는 **ranking signal** 이지 confidence 가 아니다. `score_kind` 필드로 의미 선언:
| `score_kind` | 의미 | 범위 |
|--------------|------|------|
| `rrf` (hybrid) | RRF normalized | `[0, 1]`, ceiling = 1.0 (양 채널 rank=1) |
| `bm25` (lexical) | raw BM25 | unbounded (≥ 0) |
| `cosine` (vector) | cosine sim | `[-1, 1]` |
#### RRF 수식 (hybrid mode)
```
chunk c 의 raw RRF = Σ_m 1 / (k_rrf + rank_m(c))
여기서 m ∈ {lexical, vector}, k_rrf = config.search.rrf_k (default 60).
양 채널 모두 rank=1 일 때 raw RRF = 2 / (k_rrf + 1) ≈ 0.0328.
normalize: rrf_score = raw_rrf / (2 / (k_rrf + 1))
→ rrf_score ∈ [0, 1]. 양쪽 rank=1 → 1.0, 한 쪽만 등장 → ≈ 0.5 천장.
```
`rrf_score = 0.5` 의 의미: chunk 가 한 채널 (lexical 또는 vector) 에서만 rank 1 로 등장. confidence 50% 가 아님 — RRF 수식의 산술적 천장.
agent 가 trust threshold 가 필요하면 top-level `score` 가 아닌 nested `retrieval.lexical_score` (BM25 raw) / `retrieval.vector_score` (cosine raw) 사용.
````
Place after the `kebab search` flag table or wherever similar reference content lives. If the README has existing `kebab search` row in a command table, add a `--trace` neighbor cross-reference here.
- [ ] **Step 3: Update `docs/superpowers/specs/2026-04-27-kebab-final-form-design.md` §4 search**
Add a new "Score scale (fb-38)" subsection under §4 with the same RRF formula block + `score_kind` field definition. The frozen design doc gets the contract; README is the user-facing copy.
```bash
grep -n "^## §4\|^### §4\|RRF\|hybrid_fusion" docs/superpowers/specs/2026-04-27-kebab-final-form-design.md | head -10
```
Locate the §4 search section and append the score scale block.
- [ ] **Step 4: Update `integrations/claude-code/kebab/SKILL.md`**
Find the `mcp__kebab__search` response shape block. Add a sentence:
> `hits[].score_kind`: `"rrf"` (hybrid) / `"bm25"` (lexical) / `"cosine"` (vector). top-level `score` 의 의미 선언 — confidence 아님. trust threshold 가 필요하면 `retrieval.lexical_score` / `retrieval.vector_score` (raw) 사용.
- [ ] **Step 5: Update `tasks/p9/p9-fb-38-score-semantics.md`**
Flip frontmatter `status: open` → `status: completed`. Replace the skeleton banner with:
```markdown
> ✅ **구현 완료.** 본 spec 은 구현 시점의 frozen 상태.
>
> - Design: [`docs/superpowers/specs/2026-05-10-p9-fb-38-score-semantics-design.md`](../../docs/superpowers/specs/2026-05-10-p9-fb-38-score-semantics-design.md)
> - Plan: [`docs/superpowers/plans/2026-05-10-p9-fb-38-score-semantics.md`](../../docs/superpowers/plans/2026-05-10-p9-fb-38-score-semantics.md)
```
- [ ] **Step 6: Update `tasks/INDEX.md`**
Find the fb-38 row. Flip status to ✅, mirror format of fb-32..37 rows.
- [ ] **Step 7: Run full workspace tests + clippy**
```bash
cargo test --workspace --no-fail-fast -j 1
cargo clippy --workspace --all-targets -- -D warnings
```
Expected: all green.
- [ ] **Step 8: Commit**
```bash
git add docs/ README.md tasks/p9/p9-fb-38-score-semantics.md tasks/INDEX.md integrations/claude-code/kebab/SKILL.md
git commit -m "docs(fb-38): wire schema + README + design + SKILL + INDEX"
```
---
## Final verification checklist
- [ ] `cargo test --workspace --no-fail-fast -j 1` green
- [ ] `cargo clippy --workspace --all-targets -- -D warnings` clean
- [ ] Manual smoke against `/tmp/kebab-smoke`:
- [ ] `kebab search Q --mode lexical --json | jq '.hits[0].score_kind'` returns `"bm25"`
- [ ] `kebab search Q --json | jq '.hits[0].score_kind'` returns `"rrf"` (hybrid default)
- [ ] README, design §4, SKILL, INDEX all reflect score_kind + RRF formula

View File

@@ -0,0 +1,173 @@
---
title: "p9-fb-38 — Score semantics design"
phase: P9
component: kebab-core + kebab-search + kebab-cli + wire-schema + docs
task_id: p9-fb-38
status: design
target_version: 0.6.0
contract_source: ../../docs/superpowers/specs/2026-04-27-kebab-final-form-design.md
contract_sections: [§4 search, §10 UX, wire-schema search_hit.v1]
date: 2026-05-10
---
# p9-fb-38 — Score semantics
## Goal
agent / 외부 통합이 `search_hit.v1.score` 를 confidence 로 오해하지 않도록 의미를 wire + docs 에 명시. 두 axes:
- **Wire (additive minor)**: `search_hit.v1``score_kind: string` 필드 추가 — `"rrf"` (hybrid) / `"bm25"` (lexical) / `"cosine"` (vector). top-level `score` 의 의미를 hit 단위로 declarative 하게 표시.
- **Docs**: README + design §4 + SKILL 에 RRF 수식 전체 (`2/(k+rank)` per-chunk, `2/(k+1)` ceiling, normalize 과정) + "ranking signal, NOT confidence" 안내. agent 용 trust threshold 는 nested `retrieval.lexical_score` / `vector_score` 권장.
wire change additive minor — schema bump 없음, 기존 consumer 무영향.
## Behavior contract
### Wire shape
**`search_hit.v1`** — 신규 optional 필드:
```jsonc
{
"schema_version": "search_hit.v1",
"rank": 1,
"score": 0.5, // 기존 — RRF normalized (hybrid) 또는 raw (lexical / vector)
"score_kind": "rrf", // p9-fb-38 신규 — "rrf" | "bm25" | "cosine"
// 기존 필드 ...
"retrieval": {
"method": "hybrid",
"fusion_score": 0.5,
"lexical_score": 12.34, // BM25 raw — agent 용 trust threshold
"vector_score": 0.78, // cosine sim — agent 용 trust threshold
"lexical_rank": 1,
"vector_rank": 1
}
}
```
`score_kind` `#[serde(default)]` (옛 reader / 옛 writer 호환). schema 의 `required` 미추가.
### Score kind dispatch
| Retriever | `score_kind` | top-level `score` 의 값 |
|-----------|--------------|--------------------------|
| LexicalRetriever | `"bm25"` | raw BM25 (≥ 0, unbounded) |
| VectorRetriever | `"cosine"` | cosine similarity (`[-1, 1]`) |
| HybridRetriever (fuse) | `"rrf"` | RRF normalized (`[0, 1]`) |
| HybridRetriever (search_with_trace, mode=Lexical) | `"bm25"` | pass-through from LexicalRetriever |
| HybridRetriever (search_with_trace, mode=Vector) | `"cosine"` | pass-through from VectorRetriever |
`SearchMode``score_kind` 의 1:1 매핑은 hybrid retriever 가 mode-dispatch 시 결정. lexical/vector mode 의 hits 는 retriever 자체가 정한 kind 그대로.
### Backwards-compat
- 옛 wire reader (fb-38 이전 binary): JSON 에 `score_kind` 키 없음. ignore. 영향 없음.
- 옛 wire writer (fb-38 이전 binary 가 보낸 JSON 을 새 binary 가 읽음): `score_kind` 부재 → `default_score_kind() = ScoreKind::Rrf`. 잘못된 추정 가능 (실제 lexical / vector mode 였을 수도).
- 정확한 의미 보장은 v0.6.0 이후 binary 로 통일 시점부터.
## Allowed / forbidden dependencies
- `kebab-core`: 신규 dep 없음. enum + field 추가만.
- `kebab-search`: 신규 dep 없음. hit construction 시 score_kind 라벨링.
- `kebab-cli`: 무수정 (serde 자동 emit).
- `kebab-mcp`: 무수정 (`SearchHit` 직접 serialize → 자동 포함).
- `kebab-tui`: 무수정.
`kebab-core` 의 다른 `kebab-*` 의존 금지 룰 그대로.
## Public surface delta
### kebab-core (`search.rs`)
```rust
/// p9-fb-38: top-level `SearchHit.score` 의 의미 declaration.
/// `Rrf` (hybrid) / `Bm25` (lexical-only) / `Cosine` (vector-only).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ScoreKind {
Rrf,
Bm25,
Cosine,
}
impl Default for ScoreKind {
fn default() -> Self { ScoreKind::Rrf }
}
```
`SearchHit` 확장:
```rust
pub struct SearchHit {
// 기존 필드 ...
/// p9-fb-38: top-level `score` 의 의미 declaration.
/// 옛 wire (부재) → `Rrf` default (hybrid 가 기본 mode).
#[serde(default)]
pub score_kind: ScoreKind,
}
```
### kebab-search (lexical / vector / hybrid)
- LexicalRetriever hit construction 에 `score_kind: ScoreKind::Bm25`.
- VectorRetriever hit construction 에 `score_kind: ScoreKind::Cosine`.
- HybridRetriever fuse 결과 hit 에 `score_kind: ScoreKind::Rrf`.
- HybridRetriever `search_with_trace` (fb-37) 의 Lexical/Vector branch 는 underlying retriever 의 hit 그대로 반환 — score_kind 는 그 retriever 의 라벨 (Bm25 / Cosine).
### kebab-cli + kebab-mcp
무수정. `serde_json::to_value(&hit)``score_kind` 를 자동 emit.
## Test plan
| kind | description |
|------|-------------|
| unit (kebab-core) | `ScoreKind` serde — Rrf↔"rrf", Bm25↔"bm25", Cosine↔"cosine" |
| unit (kebab-core) | `SearchHit` deserialization 시 `score_kind` 부재 → `Rrf` default |
| unit (kebab-core) | `ScoreKind::default() == Rrf` |
| unit (kebab-search/lexical) | LexicalRetriever hit 의 `score_kind == Bm25` |
| unit (kebab-search/vector) | VectorRetriever hit 의 `score_kind == Cosine` |
| unit (kebab-search/hybrid) | HybridRetriever fuse → all hits `Rrf` |
| unit (kebab-search/hybrid) | search_with_trace mode=Lexical → hits `Bm25` |
| 통합 (kebab-cli) | `kebab search Q --mode lexical --json``hits[0].score_kind == "bm25"` |
| 통합 (kebab-cli) | `kebab search Q --json` (default hybrid) → `hits[0].score_kind == "rrf"` |
vector mode 통합 테스트는 embeddings 의존 — unit (search_with_trace mode=Vector 시 hits Cosine) 으로 대체.
## Implementation steps (high-level)
1. `kebab-core::ScoreKind` enum + `SearchHit.score_kind` field + 단위 테스트.
2. `kebab-search/lexical.rs` LexicalRetriever hit construction 에 `Bm25` 라벨 + 단위 테스트.
3. `kebab-search/vector.rs` VectorRetriever hit construction 에 `Cosine` + 단위 테스트.
4. `kebab-search/hybrid.rs` fuse + search_with_trace 에 `Rrf` / pass-through + 단위 테스트.
5. `kebab-cli` 통합 테스트 (lexical-only + hybrid).
6. `docs/wire-schema/v1/search_hit.schema.json``score_kind` 필드 추가.
7. README — "Score interpretation" 섹션 (RRF 수식 + score_kind 표 + agent guidance).
8. design §4 search — RRF 수식 + normalize 정의 + score_kind 필드 등록.
9. SKILL.md — `mcp__kebab__search` 응답에 `score_kind` 안내.
10. tasks/INDEX.md / spec status flip.
## Risks / notes
- **RRF normalizer 변경 시**: k_rrf default 변경 또는 retriever 수 > 2 확장 시 ceiling 재계산. design §4 RRF 수식 + README Score interpretation 갱신 필요.
- **vector mode 통합 테스트 부재**: 통합 테스트 fixture 가 embeddings 없음 (`provider = "none"`). 통합은 lexical / hybrid 만, vector 는 단위 테스트로 cover.
- **fb-37 search_with_trace 와 정합성**: search_with_trace 는 underlying retriever 가 만든 hit 을 그대로 trace 의 lex/vec list 에 채움 — score_kind 도 자동 보존. 추가 작업 없음.
- **`#[serde(default)]` 의미**: 옛 wire reader 가 `score_kind` 키 발견 시 unknown field 거절 안 함 (serde 기본 동작 — `deny_unknown_fields` 없음, 확인 완료). 안전.
## Out of scope
- top-level `score` rename 또는 deprecation (v0.7.0+ 검토).
- channel score 의 추가 노출 (이미 `retrieval` block 에 있음).
- score gate threshold 변경 (config.rag.score_gate).
- TUI score badge / color hint.
- per-channel score normalization (BM25/cosine 둘 다 raw 유지).
- `RetrievalDetail.method``score_kind` 의 정합성 검증 (둘 다 같은 정보 source 지만 별도 declarative).
## Documentation updates (implementation PR 동시)
- `README.md` — "Score interpretation" 섹션 (RRF 수식 + score_kind 표 + agent guidance).
- `docs/superpowers/specs/2026-04-27-kebab-final-form-design.md` §4 — RRF 수식 block + score_kind field 등록.
- `docs/wire-schema/v1/search_hit.schema.json``score_kind` enum 필드.
- `integrations/claude-code/kebab/SKILL.md``mcp__kebab__search` 응답 안내 (score_kind + "ranking signal, NOT confidence" + raw threshold guidance).
- `tasks/p9/p9-fb-38-score-semantics.md``status: open → completed`, design + plan 링크.
- `tasks/INDEX.md` — fb-38 행 ✅.

View File

@@ -54,6 +54,30 @@
{ "type": "string", "format": "date-time" },
{ "type": "null" }
]
},
"media_breakdown": {
"type": "object",
"description": "p9-fb-37: per-media-kind doc count. 5 keys (markdown/pdf/image/audio/other), zero-padded.",
"additionalProperties": { "type": "integer", "minimum": 0 }
},
"lang_breakdown": {
"type": "object",
"description": "p9-fb-37: per-language doc count. NULL lang keyed as the literal string 'null'. Map may be empty on empty corpus.",
"additionalProperties": { "type": "integer", "minimum": 0 }
},
"index_bytes": {
"type": "object",
"description": "p9-fb-37: on-disk byte sums.",
"required": ["sqlite", "lancedb"],
"properties": {
"sqlite": { "type": "integer", "minimum": 0 },
"lancedb": { "type": "integer", "minimum": 0 }
}
},
"stale_doc_count": {
"type": "integer",
"minimum": 0,
"description": "p9-fb-37: docs whose updated_at exceeds config.search.stale_threshold_days. 0 when threshold=0."
}
}
}

View File

@@ -9,6 +9,26 @@
"schema_version": { "const": "search_response.v1" },
"hits": { "type": "array", "description": "search_hit.v1[]" },
"next_cursor": { "type": ["string", "null"], "description": "Opaque base64 cursor for next page; null when no more hits." },
"truncated": { "type": "boolean", "description": "True when budget forced snippet shortening or k reduction. Independent of `next_cursor`: caller may widen `max_tokens` (re-issue same query) or follow `next_cursor` (advance through more hits) or both." }
"truncated": { "type": "boolean", "description": "True when budget forced snippet shortening or k reduction. Independent of `next_cursor`: caller may widen `max_tokens` (re-issue same query) or follow `next_cursor` (advance through more hits) or both." },
"trace": {
"type": "object",
"description": "p9-fb-37: present iff caller passed --trace / SearchOpts.trace=true. Lex/vec pre-fusion lists + RRF union + per-stage timing.",
"required": ["lexical", "vector", "rrf_inputs", "timing"],
"properties": {
"lexical": { "type": "array", "items": { "type": "object" } },
"vector": { "type": "array", "items": { "type": "object" } },
"rrf_inputs":{ "type": "array", "items": { "type": "object" } },
"timing": {
"type": "object",
"required": ["lexical_ms", "vector_ms", "fusion_ms", "total_ms"],
"properties": {
"lexical_ms": { "type": "integer", "minimum": 0 },
"vector_ms": { "type": "integer", "minimum": 0 },
"fusion_ms": { "type": "integer", "minimum": 0 },
"total_ms": { "type": "integer", "minimum": 0 }
}
}
}
}
}
}

View File

@@ -48,7 +48,7 @@ Use when the user wants to **find** a doc, or when you (the model) need raw chun
Input:
```json
{ "query": "<query>", "mode": "hybrid", "k": 10, "max_tokens": null, "snippet_chars": null, "cursor": null, "tags": null, "lang": null, "path_glob": null, "trust_min": null, "media": null, "ingested_after": null, "doc_id": null }
{ "query": "<query>", "mode": "hybrid", "k": 10, "max_tokens": null, "snippet_chars": null, "cursor": null, "tags": null, "lang": null, "path_glob": null, "trust_min": null, "media": null, "ingested_after": null, "doc_id": null, "trace": null }
```
- `mode = "hybrid"` is the default-correct choice. Use `"vector"` for semantic-only ("docs about X concept"), `"lexical"` for exact strings ("the literal flag `--foo-bar`").
@@ -57,6 +57,7 @@ Input:
- Output is `search_response.v1`: `{ hits: search_hit.v1[], next_cursor: string|null, truncated: bool }`. Iterate `response.hits[]` for individual hits. Key hit fields: `rank`, `score`, `doc_path`, `heading_path[]`, `section_label`, `snippet`, `citation` (line range / page), `chunk_id`.
- Cite back to the user as `doc_path § heading_path[-1]` so they can open the source.
- When `truncated: true`, the budget loop modified the page (snippet shortening or k reduction). `next_cursor` is **independent** — non-null whenever more hits may be reachable. Caller may widen `max_tokens` (re-issue same query for fuller snippets / more hits per page) or follow `next_cursor` (advance through more hits) or both. Mismatched cursor (corpus_revision changed) returns `error.v1.code = stale_cursor` — re-issue the search to obtain a fresh one.
- **`trace: true` (p9-fb-37)** — debug aid. Response carries an extra `trace` block: `lexical[]` + `vector[]` (pre-fusion candidates), `rrf_inputs[]` (RRF union before final cut), and `timing` (`lexical_ms`, `vector_ms`, `fusion_ms`, `total_ms`). Trace bypasses the search cache (always cold). Use sparingly — it bloats the wire response and is for diagnosing "why did this hit / not hit", not normal retrieval.
### `mcp__kebab__ask` — when you need the answer
@@ -133,7 +134,7 @@ Claude Code spawns `kebab mcp` at session start; the process stays alive across
Before using streaming or multi-turn features, probe what this binary supports — call `mcp__kebab__schema` (or CLI `kebab schema --json`):
Returns `schema.v1`: `wire.schemas` (supported wire ids), `capabilities` (bool flags — e.g. `streaming_ask`, `rag_multi_turn`), `models` (version cascade 6-axis), `stats` (doc/chunk/asset count + last_ingest_at). Gate streaming / session flows on `capabilities.streaming_ask` / `capabilities.rag_multi_turn` being `true`. Cheap call (no LLM), once per session.
Returns `schema.v1`: `wire.schemas` (supported wire ids), `capabilities` (bool flags — e.g. `streaming_ask`, `rag_multi_turn`), `models` (version cascade 6-axis), `stats` (doc/chunk/asset count + last_ingest_at, plus p9-fb-37 health surface: `media_breakdown` per-kind doc counts (5 zero-padded keys: markdown / pdf / image / audio / other), `lang_breakdown` per BCP-47 lang (NULL keyed as the literal string `"null"`), `index_bytes.{sqlite,lancedb}` on-disk byte sums, `stale_doc_count` for docs older than `config.search.stale_threshold_days`). Gate streaming / session flows on `capabilities.streaming_ask` / `capabilities.rag_multi_turn` being `true`. Cheap call (no LLM), once per session.
## Quick health check

View File

@@ -125,7 +125,7 @@ P0~P5 는 직렬. P6~P9 는 P5 이후 병렬 가능.
- [p9-fb-34 output budget controls](p9/p9-fb-34-output-budget-controls.md) — ✅ 머지 + v0.5.0 cut 후보 (2026-05-09)
- [p9-fb-35 verbatim fetch](p9/p9-fb-35-verbatim-fetch.md) — ✅ 머지 + v0.5.0 cut 후보 (2026-05-09)
- [p9-fb-36 search filter args](p9/p9-fb-36-search-filters.md) — ✅ 머지 (2026-05-10)
- [p9-fb-37 trace + stats](p9/p9-fb-37-trace-and-stats.md) — ⏳ 미구현, brainstorm 필요 (depends_on 27)
- [p9-fb-37 trace + stats](p9/p9-fb-37-trace-and-stats.md) — ✅ 머지 (2026-05-10)
### 🎯 0.5.0 — RAG quality (cascade 동반: V00X + reindex)
- [p9-fb-38 score semantics](p9/p9-fb-38-score-semantics.md) — ⏳ 미구현, brainstorm 필요

View File

@@ -3,7 +3,7 @@ phase: P9
component: kebab-cli + kebab-search + kebab-rag
task_id: p9-fb-37
title: "Trace (--trace) + stats — pipeline 가시성"
status: open
status: completed
target_version: 0.4.0
depends_on: [p9-fb-27]
unblocks: []
@@ -14,7 +14,10 @@ source_feedback: 사용자 도그푸딩 2026-05-06 — agent / 사용자가 "왜
# p9-fb-37 — Trace + stats
> **백로그 only — 미구현 (Nice-to-have).** 본 spec 은 도그푸딩 피드백 skeleton. 구현 착수 전 [superpowers:brainstorming](../../docs/superpowers/) 으로 설계 단계 선행 필요. trace 의 verbosity level / wire shape / stats 의 별도 명령 vs schema 통합 brainstorm 후 확정.
> **구현 완료.** 본 spec 은 구현 시점의 frozen 상태.
>
> - Design: [`docs/superpowers/specs/2026-05-10-p9-fb-37-trace-and-stats-design.md`](../../docs/superpowers/specs/2026-05-10-p9-fb-37-trace-and-stats-design.md)
> - Plan: [`docs/superpowers/plans/2026-05-10-p9-fb-37-trace-and-stats.md`](../../docs/superpowers/plans/2026-05-10-p9-fb-37-trace-and-stats.md)
## 증상 / 동기