diff --git a/CLAUDE.md b/CLAUDE.md index 3c0ed83..8a1bd60 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,6 +86,61 @@ Release 절차: **bump 시점 = release 시점 같은 commit**. 즉 commit `chore: bump version 0.x → 0.y` 직후 같은 commit 에 tag. v0.1.0 (`2319206`) 처럼 bump 없이 tag 만 찍는 패턴은 후속 release 가 대상 commit 을 헷갈리게 함 — pre-release snapshot 은 SHA reference 로 충분. +## Dogfood trigger + +도그푸딩 = 새 binary 를 실제 KB / 실제 query 로 돌려보고 user-visible 동작이 spec 의 의도와 일치하는지 확인하는 종단 검증. unit / integration test 가 못 잡는 회귀 (UX 어색함, performance regression, 의외의 token 처리, embedding drift, RAG hallucination) 를 catch 함. PR 머지 전 또는 머지 직후 release notes 작성 전에 실시. + +### 도그푸딩이 필요한 시점 + +다음 트리거 중 하나라도 hit 시 도그푸딩 필수. **모두 release-level 또는 user-visible behavior 변경 임**. + +**Schema / migration**: +- 신규 V00X migration (예: V007 trigram, V008 OCR mirror, V009 morphological) — `corpus_revision` cascade + auto-backfill 정책의 사용자 경험 확인. +- frozen design contract 변경 (`docs/superpowers/specs/2026-04-27-kebab-final-form-design.md` §X 갱신) — verbatim CI diff-check 외의 user-visible side effect 확인. + +**Wire schema / CLI surface**: +- 신규 `--json` 필드, exit code 변경, 또는 schema major bump (v1 → v2) — agent / external integration 의 호환성 검증. +- `kebab` 의 subcommand 또는 flag 추가/삭제/rename — agent skill / muscle memory 영향. + +**Search / RAG behavior**: +- FTS5 tokenizer / chunker / embedder 모델 / RAG prompt template 변경 — 같은 query 의 hit ordering, snippet, RAG citation 패턴이 자연스럽게 변화하는지. +- score gate, RRF fusion ratio, NLI threshold 같은 ranking 파라미터 default 변경. + +**Performance**: +- ingest / search / ask latency 의 의도된 변화 (예: lindera tokenize, OCR 추가, multi-hop RAG) — actual wall-clock 측정 + release notes 에 명시. +- 대용량 KB (수천 doc / 만 chunk) 의 first-boot eager backfill 시간이 사용자 hang 인지에 영향 안 가는지. + +**Language / locale**: +- 한국어 / 일본어 / 중국어 lexical 동작 변경 (V007 trigram, V009 morphological, future N-gram). +- 영어 substring 매칭 같은 ad-hoc 부산물의 회귀. + +**File / asset surface**: +- 신규 source 형식 (PDF OCR, audio, video) — extractor / chunker 의 실제 corpus 동작. +- `.kebabignore` / `_external/` 같은 workspace 정책 변경. + +**Release-level**: 위 트리거 중 하나가 hit 되어 `Cargo.toml` workspace `version` bump 가 필요하면, **bump commit 이전에 도그푸딩 evidence 가 HOTFIXES + release notes 에 명시** 되어 있어야 함. evidence 없는 release 는 사용자가 "왜 bump 했는지" 추적 불가. + +### 도그푸딩 데이터 보관소 + +모든 ad-hoc 도그푸딩 데이터 (`config.toml` + corpus + `kb/`) 는 `/build/cache/dogfood/` 한 곳에 누적 보관: + +- `/build/cache/dogfood/README.md` — 사용 패턴 + 디렉토리 구조 + 신규 시나리오 시작 절차. +- `/build/cache/dogfood/v-/` — release/scenario 별 격리된 KB. +- `/build/cache/dogfood/_logs/` — 누적 실행 로그 (ndjson + stderr + summary). + +새 도그푸딩 시작 시 기존 디렉토리의 `config.toml` 을 template 으로 sed-replace 한 새 디렉토리 생성. `/tmp/kebab-smoke/` 또는 `/tmp/kebab-*` 임시 위치 신규 사용 금지 — 루트 디스크 압박 + 누적 추적 어려움. + +### 도그푸딩 결과 기록 + +도그푸딩 evidence 는 두 곳에 cascade: + +1. **`tasks/HOTFIXES.md` 의 dated entry** — 시나리오 별 hit count 표 + snippet evidence + known limitation. 미래에 spec drift 의심 시 git history 외 immediate reference 가 됨. +2. **`docs/release-notes/v-draft.md`** (또는 gitea release body) — 사용자 도그푸딩 영향에 영향이 가는 surface 변경을 4 단락 (변경 사실 / trade-off / mitigation / upgrade 절차) 으로 풀어서 설명. evidence link. + +도그푸딩 단계에서 *발견된 bug* (spec 과 실제 동작의 mismatch, performance regression, UX 어색함) 는 즉시 fix → re-dogfood. fix 가 별 PR 으로 빠지면 머지 후 HOTFIXES 에 dated entry. + +DOGFOOD scenario catalog (§1~§13) 는 `docs/DOGFOOD.md`. 신규 release 마다 §관련 section 의 scenario list 갱신 + 신규 scenario 추가. + ## Naming + paths - Crate prefix: `kebab-` (kebab-case package, `kebab_` snake_case in Rust modules). diff --git a/Cargo.lock b/Cargo.lock index 7f463b5..3d6f828 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -389,7 +389,7 @@ version = "56.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3aa9e59c611ebc291c28582077ef25c97f1975383f1479b12f3b9ffee2ffabe" dependencies = [ - "bitflags", + "bitflags 2.11.1", "serde", "serde_json", ] @@ -579,6 +579,28 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "aws-lc-rs" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + [[package]] name = "base64" version = "0.13.1" @@ -625,6 +647,12 @@ version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.11.1" @@ -771,6 +799,29 @@ version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "bytecheck" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0caa33a2c0edca0419d15ac723dff03f1956f7978329b1e3b5fdaaaed9d3ca8b" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "rancor", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89385e82b5d1821d2219e0b095efa2cc1f246cbf99080f3be46a1a85c0d392d9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "bytemuck" version = "1.25.0" @@ -941,6 +992,15 @@ dependencies = [ "hashbrown 0.16.1", ] +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + [[package]] name = "color_quant" version = "1.1.0" @@ -953,6 +1013,16 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + [[package]] name = "comfy-table" version = "7.1.2" @@ -1152,7 +1222,7 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ - "bitflags", + "bitflags 2.11.1", "crossterm_winapi", "mio", "parking_lot", @@ -1219,6 +1289,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "daachorse" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db756b5eb7d81d31f31f660f4132f8cf5698de52fca144c143d0ae0cbb5f2e06" + [[package]] name = "darling" version = "0.20.11" @@ -2127,7 +2203,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags", + "bitflags 2.11.1", "block2", "libc", "objc2", @@ -2193,6 +2269,15 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "encoding_rs_io" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cc3c5651fb62ab8aa3103998dade57efdd028544bd300516baa31840c252a83" +dependencies = [ + "encoding_rs", +] + [[package]] name = "equator" version = "0.4.2" @@ -2380,7 +2465,7 @@ version = "25.12.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35f6839d7b3b98adde531effaf34f0c2badc6f4735d26fe74709d8e513a96ef3" dependencies = [ - "bitflags", + "bitflags 2.11.1", "rustc_version", ] @@ -2453,6 +2538,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "fsst" version = "1.0.1" @@ -2889,7 +2980,7 @@ version = "0.14.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8dc2c844c4cf141884678cabef736fd91dd73068b9146e6f004ba1a0457944b6" dependencies = [ - "bitflags", + "bitflags 2.11.1", "bstr", "gix-path", "libc", @@ -2972,7 +3063,7 @@ version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e9c7249fa0a78f9b363aa58323db71e0a6161fd69860ed6f48dedf0ef3a314e" dependencies = [ - "bitflags", + "bitflags 2.11.1", "bstr", "gix-features", "gix-path", @@ -3005,7 +3096,7 @@ version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "acd12e3626879369310fffe2ac61acc828613ef656b50c4ea984dd59d7dc85d8" dependencies = [ - "bitflags", + "bitflags 2.11.1", "bstr", "filetime", "fnv", @@ -3193,7 +3284,7 @@ version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3fe28bbccca55da6d66e6c6efc6bb4003c29d407afd8178380293729733e6b53" dependencies = [ - "bitflags", + "bitflags 2.11.1", "bstr", "gix-commitgraph", "gix-date", @@ -3226,7 +3317,7 @@ version = "0.10.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47aeb0f13de9ef2f3033f5ff218de30f44db827ac9f1286f9ef050aacddd5888" dependencies = [ - "bitflags", + "bitflags 2.11.1", "gix-path", "libc", "windows-sys 0.52.0", @@ -3285,7 +3376,7 @@ version = "0.44.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bec70e53896586ef32a3efa7e4427b67308531ed186bb6120fb3eca0f0d61b4" dependencies = [ - "bitflags", + "bitflags 2.11.1", "gix-commitgraph", "gix-date", "gix-hash", @@ -3493,7 +3584,7 @@ dependencies = [ "log", "native-tls", "rand 0.9.4", - "reqwest", + "reqwest 0.12.28", "serde", "serde_json", "thiserror 2.0.18", @@ -4074,6 +4165,55 @@ dependencies = [ "jiff-tzdb", ] +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.117", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.117", +] + [[package]] name = "jobserver" version = "0.1.34" @@ -4125,9 +4265,18 @@ dependencies = [ "mutate_once", ] +[[package]] +name = "kanaria" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f9d9652540055ac4fded998a73aca97d965899077ab1212587437da44196ff" +dependencies = [ + "bitflags 1.3.2", +] + [[package]] name = "kebab-app" -version = "0.20.0" +version = "0.20.1" dependencies = [ "anyhow", "base64 0.22.1", @@ -4155,7 +4304,7 @@ dependencies = [ "kebab-store-vector", "lopdf", "lru", - "reqwest", + "reqwest 0.12.28", "rusqlite", "serde", "serde_json", @@ -4173,13 +4322,15 @@ dependencies = [ [[package]] name = "kebab-chunk" -version = "0.20.0" +version = "0.20.1" dependencies = [ "anyhow", "blake3", "kebab-core", "kebab-parse-code", "kebab-parse-md", + "lindera", + "lindera-ko-dic", "serde_json", "serde_json_canonicalizer", "serde_yaml", @@ -4189,7 +4340,7 @@ dependencies = [ [[package]] name = "kebab-cli" -version = "0.20.0" +version = "0.20.1" dependencies = [ "anyhow", "clap", @@ -4210,7 +4361,7 @@ dependencies = [ [[package]] name = "kebab-config" -version = "0.20.0" +version = "0.20.1" dependencies = [ "anyhow", "dirs 5.0.1", @@ -4225,7 +4376,7 @@ dependencies = [ [[package]] name = "kebab-core" -version = "0.20.0" +version = "0.20.1" dependencies = [ "anyhow", "blake3", @@ -4239,7 +4390,7 @@ dependencies = [ [[package]] name = "kebab-embed" -version = "0.20.0" +version = "0.20.1" dependencies = [ "anyhow", "blake3", @@ -4253,7 +4404,7 @@ dependencies = [ [[package]] name = "kebab-embed-local" -version = "0.20.0" +version = "0.20.1" dependencies = [ "anyhow", "fastembed", @@ -4266,7 +4417,7 @@ dependencies = [ [[package]] name = "kebab-eval" -version = "0.20.0" +version = "0.20.1" dependencies = [ "anyhow", "kebab-app", @@ -4285,7 +4436,7 @@ dependencies = [ [[package]] name = "kebab-llm" -version = "0.20.0" +version = "0.20.1" dependencies = [ "anyhow", "kebab-core", @@ -4294,13 +4445,13 @@ dependencies = [ [[package]] name = "kebab-llm-local" -version = "0.20.0" +version = "0.20.1" dependencies = [ "anyhow", "kebab-config", "kebab-core", "kebab-llm", - "reqwest", + "reqwest 0.12.28", "serde", "serde_json", "thiserror 2.0.18", @@ -4311,7 +4462,7 @@ dependencies = [ [[package]] name = "kebab-mcp" -version = "0.20.0" +version = "0.20.1" dependencies = [ "anyhow", "kebab-app", @@ -4329,7 +4480,7 @@ dependencies = [ [[package]] name = "kebab-nli" -version = "0.20.0" +version = "0.20.1" dependencies = [ "anyhow", "hf-hub", @@ -4344,7 +4495,7 @@ dependencies = [ [[package]] name = "kebab-parse-code" -version = "0.20.0" +version = "0.20.1" dependencies = [ "anyhow", "gix", @@ -4367,7 +4518,7 @@ dependencies = [ [[package]] name = "kebab-parse-image" -version = "0.20.0" +version = "0.20.1" dependencies = [ "ab_glyph", "anyhow", @@ -4379,7 +4530,7 @@ dependencies = [ "kebab-core", "kebab-llm", "kebab-llm-local", - "reqwest", + "reqwest 0.12.28", "serde", "serde_json", "tempfile", @@ -4391,7 +4542,7 @@ dependencies = [ [[package]] name = "kebab-parse-md" -version = "0.20.0" +version = "0.20.1" dependencies = [ "anyhow", "kebab-core", @@ -4408,7 +4559,7 @@ dependencies = [ [[package]] name = "kebab-parse-pdf" -version = "0.20.0" +version = "0.20.1" dependencies = [ "anyhow", "blake3", @@ -4423,7 +4574,7 @@ dependencies = [ [[package]] name = "kebab-rag" -version = "0.20.0" +version = "0.20.1" dependencies = [ "anyhow", "blake3", @@ -4445,7 +4596,7 @@ dependencies = [ [[package]] name = "kebab-search" -version = "0.20.0" +version = "0.20.1" dependencies = [ "anyhow", "globset", @@ -4464,7 +4615,7 @@ dependencies = [ [[package]] name = "kebab-source-fs" -version = "0.20.0" +version = "0.20.1" dependencies = [ "anyhow", "blake3", @@ -4482,7 +4633,7 @@ dependencies = [ [[package]] name = "kebab-store-sqlite" -version = "0.20.0" +version = "0.20.1" dependencies = [ "anyhow", "blake3", @@ -4502,7 +4653,7 @@ dependencies = [ [[package]] name = "kebab-store-vector" -version = "0.20.0" +version = "0.20.1" dependencies = [ "anyhow", "arrow", @@ -4526,7 +4677,7 @@ dependencies = [ [[package]] name = "kebab-tui" -version = "0.20.0" +version = "0.20.1" dependencies = [ "anyhow", "crossterm", @@ -4979,7 +5130,7 @@ version = "0.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ea349999bcda4eea53fc05d334b3775ec314761e6a706555c777d7a29b18d19" dependencies = [ - "reqwest", + "reqwest 0.12.28", "serde", "serde_json", "serde_repr", @@ -5212,7 +5363,7 @@ version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" dependencies = [ - "bitflags", + "bitflags 2.11.1", "libc", "plain", "redox_syscall 0.7.4", @@ -5229,6 +5380,81 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "lindera" +version = "3.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74cda79d7161e99b414e4d292ff673cc3f8d22f070d8be3b6185c033363a9216" +dependencies = [ + "anyhow", + "byteorder", + "csv", + "daachorse", + "kanaria", + "lindera-dictionary", + "lindera-ko-dic", + "log", + "once_cell", + "percent-encoding", + "regex", + "serde", + "serde_json", + "serde_yaml_ng", + "strum 0.28.0", + "strum_macros 0.28.0", + "unicode-blocks", + "unicode-normalization", + "unicode-segmentation", + "url", +] + +[[package]] +name = "lindera-dictionary" +version = "3.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2385456ca9fe87c29072c5f156b52fdd5e28d5b5738ddfb3979501dbd736530" +dependencies = [ + "anyhow", + "byteorder", + "csv", + "daachorse", + "derive_builder", + "encoding_rs", + "encoding_rs_io", + "flate2", + "glob", + "log", + "md5 0.8.0", + "memmap2", + "num_cpus", + "once_cell", + "rand 0.10.1", + "regex", + "reqwest 0.13.4", + "rkyv", + "serde", + "serde_json", + "strum 0.28.0", + "strum_macros 0.28.0", + "tar", + "thiserror 2.0.18", + "tokio", +] + +[[package]] +name = "lindera-ko-dic" +version = "3.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c62860decd3abdfc4e15c1c254130e71bb083d471310e1ac7aefe68c5fb8fcc" +dependencies = [ + "anyhow", + "byteorder", + "csv", + "lindera-dictionary", + "serde_json", + "tokio", +] + [[package]] name = "lingua" version = "1.8.0" @@ -5364,7 +5590,7 @@ dependencies = [ "itoa", "linked-hash-map", "log", - "md5", + "md5 0.7.0", "nom 7.1.3", "rayon", "time", @@ -5506,6 +5732,12 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" +[[package]] +name = "md5" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae960838283323069879657ca3de837e9f7bbb4c7bf6ea7f1b290d5e9476d2e0" + [[package]] name = "measure_time" version = "0.9.0" @@ -5638,6 +5870,26 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" +[[package]] +name = "munge" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e17401f259eba956ca16491461b6e8f72913a0a114e39736ce404410f915a0c" +dependencies = [ + "munge_macro", +] + +[[package]] +name = "munge_macro" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4568f25ccbd45ab5d5603dc34318c1ec56b117531781260002151b8530a9f931" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "murmurhash32" version = "0.3.1" @@ -5694,7 +5946,7 @@ version = "0.31.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3" dependencies = [ - "bitflags", + "bitflags 2.11.1", "cfg-if", "cfg_aliases", "libc", @@ -5944,7 +6196,7 @@ version = "6.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc3cbf698f9438986c11a880c90a6d04b9de27575afd28bbf45b154b6c709e2" dependencies = [ - "bitflags", + "bitflags 2.11.1", "libc", "once_cell", "onig_sys", @@ -5966,7 +6218,7 @@ version = "0.10.78" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222" dependencies = [ - "bitflags", + "bitflags 2.11.1", "cfg-if", "foreign-types", "libc", @@ -6262,7 +6514,7 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags", + "bitflags 2.11.1", "crc32fast", "fdeflate", "flate2", @@ -6373,7 +6625,7 @@ checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" dependencies = [ "bit-set", "bit-vec", - "bitflags", + "bitflags 2.11.1", "num-traits", "rand 0.9.4", "rand_chacha 0.9.0", @@ -6446,13 +6698,33 @@ dependencies = [ "cc", ] +[[package]] +name = "ptr_meta" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b9a0cf95a1196af61d4f1cbdab967179516d9a4a4312af1f31948f8f6224a79" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7347867d0a7e1208d93b46767be83e2b8f978c3dad35f775ac8d8847551d6fe1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "pulldown-cmark" version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c3a14896dfa883796f1cb410461aef38810ea05f2b2c33c5aded3649095fdad" dependencies = [ - "bitflags", + "bitflags 2.11.1", "memchr", "unicase", ] @@ -6510,6 +6782,7 @@ version = "0.11.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" dependencies = [ + "aws-lc-rs", "bytes", "getrandom 0.3.4", "lru-slab", @@ -6566,6 +6839,15 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" +[[package]] +name = "rancor" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a063ea72381527c2a0561da9c80000ef822bdd7c3241b1cc1b12100e3df081ee" +dependencies = [ + "ptr_meta", +] + [[package]] name = "rand" version = "0.8.6" @@ -6587,6 +6869,15 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -6625,6 +6916,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "rand_distr" version = "0.4.3" @@ -6688,7 +6985,7 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdef7f9be5c0122f890d58bdf4d964349ba6a6161f705907526d891efabba57d" dependencies = [ - "bitflags", + "bitflags 2.11.1", "cassowary", "compact_str 0.8.1", "crossterm", @@ -6816,7 +7113,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.11.1", ] [[package]] @@ -6825,7 +7122,7 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" dependencies = [ - "bitflags", + "bitflags 2.11.1", ] [[package]] @@ -6943,6 +7240,15 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "rend" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cadadef317c2f20755a64d7fdc48f9e7178ee6b0e1f7fce33fa60f1d68a276e6" +dependencies = [ + "bytecheck", +] + [[package]] name = "reqwest" version = "0.12.28" @@ -6993,6 +7299,41 @@ dependencies = [ "webpki-roots 1.0.7", ] +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "rgb" version = "0.8.53" @@ -7013,6 +7354,36 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rkyv" +version = "0.8.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73389e0c99e664f919275ab5b5b0471391fe9a8de61e1dff9b1eaf56a90f16e3" +dependencies = [ + "bytecheck", + "bytes", + "hashbrown 0.17.0", + "indexmap 2.14.0", + "munge", + "ptr_meta", + "rancor", + "rend", + "rkyv_derive", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.8.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d2ed0b54125315fb36bd021e82d314d1c126548f871634b483f46b31d13cac6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "rmcp" version = "1.6.0" @@ -7080,7 +7451,7 @@ version = "0.32.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" dependencies = [ - "bitflags", + "bitflags 2.11.1", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -7119,7 +7490,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags", + "bitflags 2.11.1", "errno", "libc", "linux-raw-sys 0.4.15", @@ -7132,7 +7503,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.11.1", "errno", "libc", "linux-raw-sys 0.12.1", @@ -7145,6 +7516,7 @@ version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ + "aws-lc-rs", "log", "once_cell", "ring", @@ -7176,12 +7548,40 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + [[package]] name = "rustls-webpki" version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -7291,7 +7691,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags", + "bitflags 2.11.1", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -7570,6 +7970,16 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + [[package]] name = "simd_helpers" version = "0.1.0" @@ -7770,6 +8180,15 @@ version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros 0.28.0", +] + [[package]] name = "strum_macros" version = "0.26.4" @@ -7795,6 +8214,18 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "subtle" version = "2.6.1" @@ -7855,7 +8286,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags", + "bitflags 2.11.1", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -8398,7 +8829,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ "async-compression", - "bitflags", + "bitflags 2.11.1", "bytes", "futures-core", "futures-util", @@ -8662,6 +9093,12 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" +[[package]] +name = "unicode-blocks" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b12e05d9e06373163a9bb6bb8c263c261b396643a99445fe6b9811fd376581b" + [[package]] name = "unicode-bom" version = "2.0.3" @@ -8984,7 +9421,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags", + "bitflags 2.11.1", "hashbrown 0.15.5", "indexmap 2.14.0", "semver", @@ -9010,6 +9447,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webpki-roots" version = "0.26.11" @@ -9480,7 +9926,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags", + "bitflags 2.11.1", "indexmap 2.14.0", "log", "serde", diff --git a/Cargo.toml b/Cargo.toml index a5026cb..18213f3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,7 +30,7 @@ edition = "2024" rust-version = "1.85" license = "MIT OR Apache-2.0" repository = "https://github.com/altair823/kebab" -version = "0.20.0" # v0.20.0 sub-item 1 (scanned PDF OCR via qwen2.5vl:3b) — CLAUDE.md §Release 사용자 도그푸딩 트리거 +version = "0.20.1" # v0.20.1 — V009 한국어 morphological tokenizer (Bug #8) + logging r2 — CLAUDE.md §Release 도그푸딩 + design §5.5 변경 트리거 # pre-v0.18 workspace-wide cleanup: enable clippy::pedantic group with # intentional allow-list. The allowed lints are either cosmetic (doc style), @@ -203,6 +203,10 @@ ort = { version = "=2.0.0-rc.9", default-features = false, features = [ tokenizers = { version = "0.21", default-features = false, features = ["onig"] } hf-hub = { version = "0.4", default-features = false, features = ["ureq", "rustls-tls"] } ndarray = "0.16" +# Korean morphological tokenizer (FTS v0.20.x, §6.1). lindera-ko-dic bundles +# the KO-DIC dictionary as an embedded blob via the embed-ko-dic feature. +lindera = "3" +lindera-ko-dic = "3" # Disk-footprint trim for dev / test builds. Codegen, opt-level, and # behavior are unchanged — only DWARF debug info is reduced (line diff --git a/HANDOFF.md b/HANDOFF.md index e0a7de9..222ba2b 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -102,11 +102,10 @@ P0~P5 직렬. P6~P9 P5 이후 병렬 가능. PR #189 (2026-05-28 머지, commit `09333d0`) 으로 PDF scanned OCR (qwen2.5vl:3b vision LLM) + 4 round bugfix (#2/#3/#4/#6/#7/#9/#10/#11/#13/#14) + ingest log feature 가 main 으로 진입. 다음 작업 순서 = **C → B → A → G**. -- **C — 한국어 morphological tokenizer (Bug #8 follow-up)** ⏳ 다음 우선. - - V007 trigram 의 ≥3 char query 제약 (HOTFIXES `2026-05-22`) — '한국' 같은 2-char 한국어 query 0 hit. 본격 사용자 search experience 의 가장 큰 surface. - - 가능한 path: jieba-rs / koma / komoran 같은 morphological tokenizer 도입 → FTS5 의 external content + custom tokenize. 또는 `tokenize='unicode61'` + 별 token table 의 dual-index. - - scope: search index 재빌드 cascade (corpus_revision bump) + 기존 V007 trigram 보존 (backward-compat) 여부 결정. 별 sub-item. - - 별 sub-item: spec/plan/executor cycle. +- **C — 한국어 morphological tokenizer (Bug #8 follow-up)** ✅ **v0.20.1 머지 완료**. + - V007 trigram 의 ≥3 char query 제약 (HOTFIXES `2026-05-22`) — '한국' 같은 2-char 한국어 query 0 hit → V009 migration + lindera-ko-dic tokenizer + tokenized_korean_text column + first-boot eager backfill 으로 해소. branch `feat/korean-morphological-tokenizer` (8 commit + 5 follow-up). + - scope: search index 재빌드 cascade (corpus_revision bump) + V007 trigram 보존 (backward-compat). + - 사용자 surface: `kebab search` 의 한국어 2자 query ('한국', '서울') 매칭. README + SKILL + release notes 반영. - **B — OCR dense page coverage** ⏳ C 다음. - metro-korea.pdf page 8/13 timeout (180s, dense newspaper article). vision LLM 의 output token 과대 → 정상 timeout. @@ -124,6 +123,7 @@ PR #189 (2026-05-28 머지, commit `09333d0`) 으로 PDF scanned OCR (qwen2.5vl: - **G — v0.20.1 patch release + release notes** ⏳ A 머지 후 (또는 C/B 시점에 따라 조기 cut). - CLAUDE.md release 룰 — sub-item 1 base + bugfix1-4 + log feature + logging r2 누적 → minor surface 변경 다수 + wire schema additive minor + config 신규 → **v0.20.1 patch bump + release notes**. - 핵심 surface (사용자 도그푸딩 가이드 형식): + - **한국어 2자 query 지원** (`kebab search` 에서 '한국', '서울' 같은 2자 단어 매칭 — V009 morphological tokenizer). - OCR timeout default 180s (HOTFIXES 2026-05-28). - `[logging]` config section (default enabled) + `{state_dir}/logs/ingest-{run_id}.ndjson` 자동 생성. - `[logging] keep_recent_runs` (100) + `retention_days` (30) — OR-on-stale cleanup. diff --git a/README.md b/README.md index f05cc07..58548f6 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ kebab doctor |------|------| | `kebab init` | XDG 경로에 데이터 디렉토리 + config.toml 생성 | | `kebab ingest []` | Markdown / 이미지 / PDF / Rust 소스코드 색인 (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`), **소스코드** (`.rs` → `code-rust-ast-v1`, `.py` → `code-python-ast-v1`, `.ts`/`.tsx` → `code-ts-ast-v1`, `.js`/`.mjs`/`.cjs`/`.jsx` → `code-js-ast-v1`, `.go` → `code-go-ast-v1`, `.java` → `code-java-ast-v1`, `.kt`/`.kts` → `code-kotlin-ast-v1`, `.c`/`.h` → `code-c-ast-v1`, `.cpp`/`.cc`/`.cxx`/`.hpp`/`.hh`/`.hxx` → `code-cpp-ast-v1` — 모두 tree-sitter AST chunker; **Tier 2 리소스 파일**: `.yaml`/`.yml` → `k8s-manifest-resource-v1` (apiVersion+kind 파싱), `Dockerfile`/`Dockerfile.*`/`*.dockerfile` → `dockerfile-file-v1` (전체 파일), `Cargo.toml`/`pyproject.toml`/`.toml`/`package.json`/`tsconfig.json`/`.json`/`pom.xml`/`.xml`/`build.gradle`/`.gradle`/`go.mod` → `manifest-file-v1` (전체 파일) — yaml (k8s) / dockerfile / toml / json / xml / groovy / go-mod 지원); **Tier 3 paragraph fallback** (`.sh`/`.bash`/`.zsh` → `code-text-paragraph-v1`, blank-line paragraph split + 80-line/20-overlap line-window. Tier 1/2 가 0 chunk 또는 Err 시 자동 fallback — 비-k8s YAML 같은 케이스 picked up. symbol = None, lang 은 원본 보존.). 다른 확장자는 자동 skip — `IngestItem.warnings` 에 사유 (`"unsupported media type: .docx"` 등), `IngestReport.skipped_by_extension` 에 카운트 분류, CLI / TUI summary 에 breakdown 표시. 코드 chunk 는 `citation.kind = "code"` 에 `citation.lang = ""` + `symbol` + line range 를 담고, SearchHit top-level 에 `code_lang` + `repo` (`.git/` walk-up 의 디렉토리 이름) 가 backfill 됨. `--code-lang rust` / `--code-lang python` / `--code-lang typescript` / `--code-lang javascript` / `--code-lang go` / `--code-lang java` / `--code-lang kotlin` / `--code-lang yaml` / `--code-lang dockerfile` / `--code-lang toml` / `--code-lang json` / `--code-lang xml` / `--code-lang groovy` / `--code-lang go-mod` / `--code-lang shell` / `--code-lang c` / `--code-lang cpp` / `--media code` filter 로 언어별·코드 전용 검색 가능 (p10-1A-1 filter flags). Python symbol 은 workspace 경로 → dotted module path prefix (예: `kebab_eval.metrics.compute_mrr`), TS/JS symbol 은 slash-style module path prefix (예: `src/Foo.Foo.search`), Go symbol 은 `package.Func` / `package.(*Receiver).Method` 형식, Java / Kotlin symbol 은 `com.foo.Foo.bar` 형식 (패키지 + 클래스 + 메서드/필드). | -| `kebab search --mode {lexical,vector,hybrid} "" [--no-cache] [--max-tokens N] [--snippet-chars N] [--cursor ] [--tag T] [--lang L] [--path-glob G] [--trust-min LEVEL] [--media TYPE] [--ingested-after RFC3339] [--doc-id ID] [--trace] [--bulk] [--repo NAME ...] [--code-lang LIST]` | 검색. 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). **`--bulk` (p9-fb-42)** — stdin ndjson 으로 N query 한 번에 실행. `--json` 면 stdout per-query ndjson (`bulk_search_item.v1`) + stderr summary (`bulk_summary: total=N succeeded=S failed=F`). Cap 100. agent 가 query decomposition 후 sub-query 일괄 실행 시 single round-trip — App instance 재사용으로 캐시 / embedder cold-start 비용 한 번만. Per-query failure 는 item 의 `error` (error.v1) 에 격리, 다른 query 계속 진행. **code corpus filters (p10-1A-1):** `--repo` 는 반복 가능 (`--repo kebab --repo other`) OR 매칭. `--code-lang` 는 반복 또는 comma 다중 값 (`--code-lang rust,python`), 알 수 없는 값은 빈 hits. `--media code` 는 Tier 1/2/3 모든 code chunk 포함. 1A-1 시점에서는 indexed 된 code chunk 가 없어 filter 가 항상 빈 결과 — 1A-2 (Rust AST chunker) 머지 이후 실효. **v0.17.0 trigram tokenizer (한국어 + 영어 동작 변경):** `chunks_fts` 가 FTS5 `trigram` 으로 동작 — 한국어 query 는 3자 이상 substring 매칭 (`해시 충돌` 같은 multi-token 도 whole-phrase 후보로 hit), 영어도 substring 매칭 (`token` 이 `tokenizer` 도 hit, recall ↑ / 단어 경계 ↓). 2자 이하 query 는 0-hit + stderr `[hint] 3자 이상 키워드 권장` + `search_response.v1.hint` 필드 (raw FTS5 mode `'...'` 제외). `kebab.sqlite` 파일 크기는 trigram index 비대화로 ~2-5배 또는 수백 MB 증가 (V007 자동 backfill, re-ingest 불필요). | +| `kebab search --mode {lexical,vector,hybrid} "" [--no-cache] [--max-tokens N] [--snippet-chars N] [--cursor ] [--tag T] [--lang L] [--path-glob G] [--trust-min LEVEL] [--media TYPE] [--ingested-after RFC3339] [--doc-id ID] [--trace] [--bulk] [--repo NAME ...] [--code-lang LIST]` | 검색. 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). **`--bulk` (p9-fb-42)** — stdin ndjson 으로 N query 한 번에 실행. `--json` 면 stdout per-query ndjson (`bulk_search_item.v1`) + stderr summary (`bulk_summary: total=N succeeded=S failed=F`). Cap 100. agent 가 query decomposition 후 sub-query 일괄 실행 시 single round-trip — App instance 재사용으로 캐시 / embedder cold-start 비용 한 번만. Per-query failure 는 item 의 `error` (error.v1) 에 격리, 다른 query 계속 진행. **code corpus filters (p10-1A-1):** `--repo` 는 반복 가능 (`--repo kebab --repo other`) OR 매칭. `--code-lang` 는 반복 또는 comma 다중 값 (`--code-lang rust,python`), 알 수 없는 값은 빈 hits. `--media code` 는 Tier 1/2/3 모든 code chunk 포함. 1A-1 시점에서는 indexed 된 code chunk 가 없어 filter 가 항상 빈 결과 — 1A-2 (Rust AST chunker) 머지 이후 실효. **v0.20.1 V009 morphological tokenizer (한국어 + 영어 동작 변경):** `chunks_fts` 가 FTS5 `unicode61` + 한국어 lindera ko-dic 형태소 분석 결과를 별 column 으로 prepend. **한국어 2자 query 지원** — '한국', '서울', '지하철' 같은 2자/3자 단어가 형태소 분해 후 hit. **영어는 whole-token 매칭** — V002 동작으로 회귀 (`tokenizer` query 는 `tokenizer` 토큰만 hit, `token` 같은 substring 은 hit X). substring recall 이 필요하면 vector/hybrid mode 권장. `kebab.sqlite` 파일 크기는 lindera ko-dic embedded dict 와 tokenized_korean_text column 의존성으로 다소 증가. V009 자동 backfill (`App::open_with_config` 의 first-boot hook) — re-ingest 불필요. | | `kebab list docs` | 색인된 문서 목록 | | `kebab inspect doc ` / `kebab inspect chunk ` | raw record 보기 | | `kebab fetch chunk [--context N]` / `kebab fetch doc [--max-tokens N]` / `kebab fetch span [--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. | diff --git a/crates/kebab-app/Cargo.toml b/crates/kebab-app/Cargo.toml index 3964c12..53b4df4 100644 --- a/crates/kebab-app/Cargo.toml +++ b/crates/kebab-app/Cargo.toml @@ -89,5 +89,11 @@ lopdf = { workspace = true } # reqwest::Error (private constructor) — built from a connect-refused call. reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] } +[features] +# Marker feature — spec §6.3 Option A (단순): lindera 는 kebab-chunk 가 default dep 으로 소유. +# disable path 없음; 이 feature 는 spec §6.3 명시를 honor 하는 role 만. +default = ["fts_korean_morphological"] +fts_korean_morphological = [] + [lints] workspace = true diff --git a/crates/kebab-app/src/app.rs b/crates/kebab-app/src/app.rs index 80cedcd..a47a80c 100644 --- a/crates/kebab-app/src/app.rs +++ b/crates/kebab-app/src/app.rs @@ -89,29 +89,6 @@ pub struct SearchResponse { pub hint: Option, } -/// v0.17.0 A5 Step 4b: decide whether to attach a "3자 이상 키워드 권장" -/// hint to a `SearchResponse`. Fires only when the result set is empty -/// *and* the trimmed query is shorter than the trigram tokenizer can -/// resolve. Raw FTS5 mode (`'...'`) opts out — the user explicitly -/// invoked FTS5 syntax. Identical condition powers the CLI stderr line -/// and (separately) the TUI status bar. -pub fn short_query_hint(query_text: &str, hits_empty: bool) -> Option { - if !hits_empty { - return None; - } - let trimmed = query_text.trim(); - let bytes = trimmed.as_bytes(); - // Raw single-quote mode: user opted into FTS5 syntax, no advisory. - if bytes.len() >= 2 && bytes[0] == b'\'' && bytes[bytes.len() - 1] == b'\'' { - return None; - } - if trimmed.chars().count() < 3 { - Some("3자 이상 키워드 권장 (trigram tokenizer 제약)".to_string()) - } else { - None - } -} - /// Facade state — see module docs for lifetime rules. /// /// The struct is public so long-lived callers (kb-eval, the future P9 @@ -212,6 +189,34 @@ impl App { sqlite .run_migrations() .context("kb-app: run SqliteStore migrations")?; + // V009 의 tokenized_korean_text column 의 first-boot eager backfill. + // 신규 ingest 의 chunks_ai trigger 가 이미 채우므로 NULL row 가 없으면 즉시 0 반환 (idempotent). + // V007 → V009 업그레이드 시 KB 크기 비례 (~10000 chunk 당 ~30-60s). + let backfill_count = sqlite + .backfill_tokenized_korean_text( + |done, total| { + if total > 0 && done % 500 == 0 { + tracing::info!( + target: "kebab-app", + "korean tokenizer backfill: {done}/{total}" + ); + } + }, + kebab_chunk::tokenize_korean_morphological, + ) + .unwrap_or_else(|e| { + tracing::warn!( + target: "kebab-app", + "korean tokenizer backfill failed: {e}" + ); + 0 + }); + if backfill_count > 0 { + tracing::info!( + target: "kebab-app", + "korean tokenizer backfill complete: {backfill_count} chunks updated" + ); + } // p9-fb-19: build the LRU cache from config. Capacity 0 → // `None` (cache disabled — every search hits the retrievers). let search_cache = NonZeroUsize::new(config.search.cache_capacity) @@ -529,7 +534,7 @@ impl App { // Trace path skips the budget loop. Caller will inspect // `hits.len()` and `trace.timing` rather than paginate. - let hint = short_query_hint(&query.text, hits.is_empty()); + let hint: Option = None; return Ok(SearchResponse { hits, next_cursor: None, @@ -613,7 +618,7 @@ impl App { None }; - let hint = short_query_hint(&query.text, hits.is_empty()); + let hint: Option = None; Ok(SearchResponse { hits, next_cursor, @@ -988,8 +993,16 @@ impl App { /// the active config. This token surfaces in `SearchHit.index_version` /// and on snapshot tests; including the chunker version pins it to /// the chunking policy in effect. +/// +/// V009 (2026-05-28): FTS5 tokenizer 가 trigram → unicode61 + 한국어 +/// 형태소 분해 column 로 갱신됨. `fts5-v009-korean-morphological` +/// suffix 가 V007 baseline 과 구별되어 eval runner 의 config +/// snapshot 및 search cache 무효화에 picks up 된다. fn lexical_index_version(config: &kebab_config::Config) -> IndexVersion { - IndexVersion(format!("lex:{}", config.chunking.chunker_version)) + IndexVersion(format!( + "lex:{}:fts5-v009-korean-morphological", + config.chunking.chunker_version + )) } /// p9-fb-37: stand-in for the vector retriever in the trace path when @@ -1177,7 +1190,7 @@ impl App { .context("prepare ms query")?; stmt.query_map([], |r| r.get::<_, u64>(0)) .context("query ms")? - .filter_map(|r| r.ok()) + .filter_map(Result::ok) .collect() }; let (p50_ms, p90_ms, p99_ms, max_ms) = percentiles(&samples); @@ -1191,7 +1204,7 @@ impl App { let rows = stmt .query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, u64>(1)?))) .context("query engine")?; - for row in rows.filter_map(|r| r.ok()) { + for row in rows.filter_map(Result::ok) { by_engine.insert(row.0, row.1); } } @@ -1219,7 +1232,7 @@ impl App { }) }) .context("query by_doc")? - .filter_map(|r| r.ok()) + .filter_map(Result::ok) .collect() }; @@ -1276,7 +1289,7 @@ impl App { }) }) .context("query failures by doc_id")? - .filter_map(|r| r.ok()) + .filter_map(Result::ok) .collect() } else { let mut stmt = conn @@ -1298,7 +1311,7 @@ impl App { }) }) .context("query failures corpus-wide")? - .filter_map(|r| r.ok()) + .filter_map(Result::ok) .collect() }; Ok(OcrFailuresV1 { diff --git a/crates/kebab-app/src/ingest_log.rs b/crates/kebab-app/src/ingest_log.rs index 83f9cd3..ce2b4fa 100644 --- a/crates/kebab-app/src/ingest_log.rs +++ b/crates/kebab-app/src/ingest_log.rs @@ -232,13 +232,12 @@ pub(crate) fn cleanup_old_logs( retention_days: u32, ) -> anyhow::Result<()> { let mut entries: Vec<_> = std::fs::read_dir(log_dir)? - .filter_map(|e| e.ok()) + .filter_map(Result::ok) .filter(|e| { e.path() .file_name() .and_then(|n| n.to_str()) - .map(|s| s.starts_with("ingest-") && s.ends_with(".ndjson")) - .unwrap_or(false) + .is_some_and(|s| s.starts_with("ingest-") && s.ends_with(".ndjson")) }) .collect(); @@ -247,7 +246,7 @@ pub(crate) fn cleanup_old_logs( let cutoff = SystemTime::now() .checked_sub(std::time::Duration::from_secs( - retention_days as u64 * 86400, + u64::from(retention_days) * 86400, )) .unwrap_or(SystemTime::UNIX_EPOCH); @@ -412,7 +411,7 @@ mod tests { cleanup_old_logs(dir, 3, 90).unwrap(); let remaining: Vec<_> = std::fs::read_dir(dir) .unwrap() - .filter_map(|e| e.ok()) + .filter_map(Result::ok) .collect(); assert_eq!(remaining.len(), 3, "expected 3 files after cleanup"); } @@ -436,7 +435,7 @@ mod tests { cleanup_old_logs(dir, 10, 30).unwrap(); let remaining: Vec<_> = std::fs::read_dir(dir) .unwrap() - .filter_map(|e| e.ok()) + .filter_map(Result::ok) .collect(); assert_eq!( remaining.len(), diff --git a/crates/kebab-app/src/lib.rs b/crates/kebab-app/src/lib.rs index 1f289fc..b5c30bd 100644 --- a/crates/kebab-app/src/lib.rs +++ b/crates/kebab-app/src/lib.rs @@ -72,7 +72,7 @@ pub mod reset; pub mod schema; mod staleness; -pub use app::{App, SearchResponse, short_query_hint}; +pub use app::{App, SearchResponse}; #[doc(hidden)] pub use bulk::{BULK_QUERIES_MAX, bulk_search_with_config}; pub use error_wire::{ERROR_V1_ID, ErrorV1, StructuredError, classify}; diff --git a/crates/kebab-app/src/pdf_ocr_apply.rs b/crates/kebab-app/src/pdf_ocr_apply.rs index ba375d1..254d8fd 100644 --- a/crates/kebab-app/src/pdf_ocr_apply.rs +++ b/crates/kebab-app/src/pdf_ocr_apply.rs @@ -191,8 +191,7 @@ where note: Some(note), }); let (image_width, image_height) = extract_image_dimensions(&page_image_bytes) - .map(|(w, h)| (Some(w), Some(h))) - .unwrap_or((None, None)); + .map_or((None, None), |(w, h)| (Some(w), Some(h))); emit_progress(PdfOcrProgress::Finished { page: page_num, ms: start.elapsed().as_millis() as u64, @@ -272,8 +271,7 @@ where }); let (image_width, image_height) = extract_image_dimensions(&page_image_bytes) - .map(|(w, h)| (Some(w), Some(h))) - .unwrap_or((None, None)); + .map_or((None, None), |(w, h)| (Some(w), Some(h))); emit_progress(PdfOcrProgress::Finished { page: page_num, ms: elapsed_ms, diff --git a/crates/kebab-app/tests/ocr_inspect_smoke.rs b/crates/kebab-app/tests/ocr_inspect_smoke.rs index 31e1bf4..65d414b 100644 --- a/crates/kebab-app/tests/ocr_inspect_smoke.rs +++ b/crates/kebab-app/tests/ocr_inspect_smoke.rs @@ -15,14 +15,14 @@ fn seed_ocr_events(env: &TestEnv, store: &SqliteStore) { store .record_pdf_ocr_event( "run-aaa", - &format!("2026-05-28T0{}:00:00Z", i), + &format!("2026-05-28T0{i}:00:00Z"), Some("doc-abc"), "path/scanned.pdf", i + 1, Some(50_000), Some(200), Some(150), - 100 + (i as u64) * 20, + 100 + u64::from(i) * 20, 42, true, None, diff --git a/crates/kebab-app/tests/search_korean.rs b/crates/kebab-app/tests/search_korean.rs index 05646f0..146c1db 100644 --- a/crates/kebab-app/tests/search_korean.rs +++ b/crates/kebab-app/tests/search_korean.rs @@ -127,3 +127,71 @@ fn lexical_mixed_korean_english_multi_token_query_hits() { hits.iter().map(|h| &h.doc_path.0).collect::>() ); } + +// ── S7 V009 morphological tokenizer end-to-end tests ───────────────── + +/// S7 — V009 morphological tokenizer: 한국어 2자 query 가 end-to-end +/// lexical 경로에서 hit. lindera ko-dic 이 '한국어를' → '한국어' 형태소로 +/// 분해, '서울은' → '서울' 로 분해하여 tokenized_korean_text column 에 +/// 기록 → FTS5 매칭. +#[test] +fn korean_morphological_2char_query_lexical_mode() { + let env = TestEnv::lexical_only(); + let doc_path = env.workspace_root.join("korean-wiki.md"); + std::fs::write( + &doc_path, + "# 한국어 위키\n\n한국어를 공부합니다.\n서울은 한국의 수도입니다.\n", + ) + .expect("write korean-wiki fixture"); + + kebab_app::ingest_with_config(env.config.clone(), env.scope(), true) + .expect("ingest must succeed"); + + let hits = kebab_app::search_with_config(env.config.clone(), common::lexical_query("한국")) + .expect("search 한국"); + assert!( + !hits.is_empty(), + "'한국' 2-char Korean query must return at least one hit (V009 morphological); got {:?}", + hits.iter().map(|h| &h.doc_path.0).collect::>() + ); + + let hits = kebab_app::search_with_config(env.config.clone(), common::lexical_query("서울")) + .expect("search 서울"); + assert!( + !hits.is_empty(), + "'서울' 2-char Korean query must return at least one hit; got {:?}", + hits.iter().map(|h| &h.doc_path.0).collect::>() + ); +} + +/// S7 — V009 morphological tokenizer: 한-영 혼합 query lexical hit. +/// 'Rust' (English whole-token) + '최적화' (Korean morpheme) 각각 hit. +#[test] +fn korean_morphological_mixed_english_korean_query() { + let env = TestEnv::lexical_only(); + let doc_path = env.workspace_root.join("rust-optimization.md"); + std::fs::write( + &doc_path, + "# Rust 최적화 노트\n\nRust 최적화는 zero-cost abstraction 을 강조한다.\n", + ) + .expect("write rust-optimization fixture"); + + kebab_app::ingest_with_config(env.config.clone(), env.scope(), true) + .expect("ingest must succeed"); + + let hits = kebab_app::search_with_config(env.config.clone(), common::lexical_query("Rust")) + .expect("search Rust"); + assert!( + !hits.is_empty(), + "'Rust' English whole-token must hit; got {:?}", + hits.iter().map(|h| &h.doc_path.0).collect::>() + ); + + let hits = kebab_app::search_with_config(env.config.clone(), common::lexical_query("최적화")) + .expect("search 최적화"); + assert!( + !hits.is_empty(), + "'최적화' Korean morpheme must hit; got {:?}", + hits.iter().map(|h| &h.doc_path.0).collect::>() + ); +} diff --git a/crates/kebab-app/tests/search_lexical.rs b/crates/kebab-app/tests/search_lexical.rs index 5180d28..3534867 100644 --- a/crates/kebab-app/tests/search_lexical.rs +++ b/crates/kebab-app/tests/search_lexical.rs @@ -109,7 +109,10 @@ fn first_ingest_bumps_corpus_revision() { let env = TestEnv::lexical_only(); let store_before = kebab_store_sqlite::SqliteStore::open(&env.config).unwrap(); store_before.run_migrations().unwrap(); - assert_eq!(store_before.corpus_revision(), 0, "fresh store seeds 0"); + // V004 seeds 0; V009 migration bumps to 1 to invalidate any pre-V009 + // LRU cache (spec §5.2). Baseline before ingest = post-migration value. + let baseline = store_before.corpus_revision(); + assert_eq!(baseline, 1, "fresh store post-V009 baseline = 1"); let report = kebab_app::ingest_with_config(env.config.clone(), env.scope(), true).unwrap(); assert!( @@ -119,8 +122,8 @@ fn first_ingest_bumps_corpus_revision() { let store_after = kebab_store_sqlite::SqliteStore::open(&env.config).unwrap(); assert!( - store_after.corpus_revision() >= 1, - "ingest commit must bump corpus_revision (got {})", + store_after.corpus_revision() > baseline, + "ingest commit must bump corpus_revision past baseline {baseline} (got {})", store_after.corpus_revision(), ); } diff --git a/crates/kebab-chunk/Cargo.toml b/crates/kebab-chunk/Cargo.toml index 80ccea4..cdfc8d9 100644 --- a/crates/kebab-chunk/Cargo.toml +++ b/crates/kebab-chunk/Cargo.toml @@ -14,6 +14,8 @@ blake3 = { workspace = true } anyhow = { workspace = true } tracing = { workspace = true } serde_yaml = { workspace = true } +lindera = { workspace = true, features = ["embed-ko-dic"] } +lindera-ko-dic = { workspace = true, features = ["embed-ko-dic"] } [dev-dependencies] # kb-parse-md / kb-parse-code are dev-only — used by the snapshot integration diff --git a/crates/kebab-chunk/src/code_c_ast_v1.rs b/crates/kebab-chunk/src/code_c_ast_v1.rs index 4e97059..642f9d3 100644 --- a/crates/kebab-chunk/src/code_c_ast_v1.rs +++ b/crates/kebab-chunk/src/code_c_ast_v1.rs @@ -145,6 +145,7 @@ fn make_chunk( chunk_id, doc_id: DocumentId(doc.doc_id.0.clone()), block_ids: block_ids.to_vec(), + tokenized_korean_text: crate::tokenize_korean_morphological(&text), text, heading_path: Vec::new(), source_spans: vec![span], diff --git a/crates/kebab-chunk/src/code_cpp_ast_v1.rs b/crates/kebab-chunk/src/code_cpp_ast_v1.rs index 942eb8e..f9ca1a1 100644 --- a/crates/kebab-chunk/src/code_cpp_ast_v1.rs +++ b/crates/kebab-chunk/src/code_cpp_ast_v1.rs @@ -147,6 +147,7 @@ fn make_chunk( chunk_id, doc_id: DocumentId(doc.doc_id.0.clone()), block_ids: block_ids.to_vec(), + tokenized_korean_text: crate::tokenize_korean_morphological(&text), text, heading_path: Vec::new(), source_spans: vec![span], diff --git a/crates/kebab-chunk/src/code_go_ast_v1.rs b/crates/kebab-chunk/src/code_go_ast_v1.rs index e9d8b76..22e9310 100644 --- a/crates/kebab-chunk/src/code_go_ast_v1.rs +++ b/crates/kebab-chunk/src/code_go_ast_v1.rs @@ -147,6 +147,7 @@ fn make_chunk( chunk_id, doc_id: DocumentId(doc.doc_id.0.clone()), block_ids: block_ids.to_vec(), + tokenized_korean_text: crate::tokenize_korean_morphological(&text), text, heading_path: Vec::new(), source_spans: vec![span], diff --git a/crates/kebab-chunk/src/code_java_ast_v1.rs b/crates/kebab-chunk/src/code_java_ast_v1.rs index 0f47540..07e0ab8 100644 --- a/crates/kebab-chunk/src/code_java_ast_v1.rs +++ b/crates/kebab-chunk/src/code_java_ast_v1.rs @@ -147,6 +147,7 @@ fn make_chunk( chunk_id, doc_id: DocumentId(doc.doc_id.0.clone()), block_ids: block_ids.to_vec(), + tokenized_korean_text: crate::tokenize_korean_morphological(&text), text, heading_path: Vec::new(), source_spans: vec![span], diff --git a/crates/kebab-chunk/src/code_js_ast_v1.rs b/crates/kebab-chunk/src/code_js_ast_v1.rs index ae0bc2e..8ae1fc5 100644 --- a/crates/kebab-chunk/src/code_js_ast_v1.rs +++ b/crates/kebab-chunk/src/code_js_ast_v1.rs @@ -147,6 +147,7 @@ fn make_chunk( chunk_id, doc_id: DocumentId(doc.doc_id.0.clone()), block_ids: block_ids.to_vec(), + tokenized_korean_text: crate::tokenize_korean_morphological(&text), text, heading_path: Vec::new(), source_spans: vec![span], diff --git a/crates/kebab-chunk/src/code_kotlin_ast_v1.rs b/crates/kebab-chunk/src/code_kotlin_ast_v1.rs index c992699..1c1a386 100644 --- a/crates/kebab-chunk/src/code_kotlin_ast_v1.rs +++ b/crates/kebab-chunk/src/code_kotlin_ast_v1.rs @@ -147,6 +147,7 @@ fn make_chunk( chunk_id, doc_id: DocumentId(doc.doc_id.0.clone()), block_ids: block_ids.to_vec(), + tokenized_korean_text: crate::tokenize_korean_morphological(&text), text, heading_path: Vec::new(), source_spans: vec![span], diff --git a/crates/kebab-chunk/src/code_python_ast_v1.rs b/crates/kebab-chunk/src/code_python_ast_v1.rs index 246a3e0..ac62678 100644 --- a/crates/kebab-chunk/src/code_python_ast_v1.rs +++ b/crates/kebab-chunk/src/code_python_ast_v1.rs @@ -147,6 +147,7 @@ fn make_chunk( chunk_id, doc_id: DocumentId(doc.doc_id.0.clone()), block_ids: block_ids.to_vec(), + tokenized_korean_text: crate::tokenize_korean_morphological(&text), text, heading_path: Vec::new(), source_spans: vec![span], diff --git a/crates/kebab-chunk/src/code_rust_ast_v1.rs b/crates/kebab-chunk/src/code_rust_ast_v1.rs index 83dcda3..365ed87 100644 --- a/crates/kebab-chunk/src/code_rust_ast_v1.rs +++ b/crates/kebab-chunk/src/code_rust_ast_v1.rs @@ -147,6 +147,7 @@ fn make_chunk( chunk_id, doc_id: DocumentId(doc.doc_id.0.clone()), block_ids: block_ids.to_vec(), + tokenized_korean_text: crate::tokenize_korean_morphological(&text), text, heading_path: Vec::new(), source_spans: vec![span], diff --git a/crates/kebab-chunk/src/code_ts_ast_v1.rs b/crates/kebab-chunk/src/code_ts_ast_v1.rs index e76af55..42dd4ac 100644 --- a/crates/kebab-chunk/src/code_ts_ast_v1.rs +++ b/crates/kebab-chunk/src/code_ts_ast_v1.rs @@ -147,6 +147,7 @@ fn make_chunk( chunk_id, doc_id: DocumentId(doc.doc_id.0.clone()), block_ids: block_ids.to_vec(), + tokenized_korean_text: crate::tokenize_korean_morphological(&text), text, heading_path: Vec::new(), source_spans: vec![span], diff --git a/crates/kebab-chunk/src/lib.rs b/crates/kebab-chunk/src/lib.rs index e34de55..00256d3 100644 --- a/crates/kebab-chunk/src/lib.rs +++ b/crates/kebab-chunk/src/lib.rs @@ -47,3 +47,75 @@ pub use k8s_manifest_resource_v1::K8sManifestResourceV1Chunker; pub use manifest_file_v1::ManifestFileV1Chunker; pub use md_heading_v1::MdHeadingV1Chunker; pub use pdf_page_v1::PdfPageV1Chunker; + +// ── Korean morphological tokenizer ─────────────────────────────────────────── + +use lindera::dictionary::{DictionaryKind, load_embedded_dictionary}; +use lindera::mode::Mode; +use lindera::segmenter::Segmenter; +use lindera::tokenizer::Tokenizer; + +static KOREAN_TOKENIZER: std::sync::OnceLock> = std::sync::OnceLock::new(); + +/// 한 codepoint 가 한글 음절 또는 자모인지 판정 — N-gram supplement 의 emit 대상 필터링. +fn is_hangul(c: char) -> bool { + matches!( + c, + '\u{AC00}'..='\u{D7A3}' // 한글 음절 (precomposed) + | '\u{1100}'..='\u{11FF}' // 한글 자모 + | '\u{3130}'..='\u{318F}' // 한글 호환 자모 + ) +} + +/// 한국어 chunk text 를 lindera ko-dic 으로 형태소 분해해 공백 join 한 결과를 반환. +/// chunker 들이 `Chunk.tokenized_korean_text` pre-fill 에 사용. +/// 분석 실패 시 None — 호출자는 NULL fallback 처리. +/// Tokenizer 는 OnceLock 으로 1회 초기화; dict load 실패 시 영구 None. +/// +/// v0.21.0 — N-gram supplement (Option β, post-v0.20.1 enhancement). +/// ko-dic 가 compound noun (`한국정부`, `서울특별시` 등) 을 단일 token 으로 +/// 저장하는 정책 의 한계 해소 — morpheme 길이 ≥ 3 인 한글 token 에 대해 +/// 2-char sliding window n-gram 도 추가 emit. `'한국정부'` morpheme → +/// `[한국정부, 한국, 국정, 정부]` 의 4 token 으로 expand. 사용자 의 2-char +/// query (`'한국'`) 가 compound chunk 에서도 hit. 영어/숫자 token 은 영향 +/// 없음 (is_hangul filter). DB size + ingest latency 의 trade-off 는 +/// HOTFIXES 2026-05-28 의 "N-gram supplement (Option β)" 보강 entry. +pub fn tokenize_korean_morphological(text: &str) -> Option { + if text.trim().is_empty() { + return None; + } + let tokenizer = KOREAN_TOKENIZER.get_or_init(|| { + let dict = match load_embedded_dictionary(DictionaryKind::KoDic) { + Ok(d) => d, + Err(e) => { + tracing::warn!(target: "kebab-chunk", "tokenize_korean_morphological: dict load failed: {e}"); + return None; + } + }; + let segmenter = Segmenter::new(Mode::Normal, dict, None); + Some(Tokenizer::new(segmenter)) + }); + let tokenizer = tokenizer.as_ref()?; + let tokens = tokenizer.tokenize(text).ok()?; + + let mut out_tokens: Vec = Vec::with_capacity(tokens.len() * 2); + for tok in tokens.iter() { + let surface = tok.surface.as_ref(); + out_tokens.push(surface.to_string()); + + // N-gram supplement: 한글 morpheme 의 2-char sliding window. + let chars: Vec = surface.chars().collect(); + if chars.len() >= 3 && chars.iter().all(|c| is_hangul(*c)) { + for window in chars.windows(2) { + out_tokens.push(window.iter().collect()); + } + } + } + + let joined = out_tokens.join(" "); + if joined.is_empty() { + None + } else { + Some(joined) + } +} diff --git a/crates/kebab-chunk/src/md_heading_v1.rs b/crates/kebab-chunk/src/md_heading_v1.rs index 1bac96c..0265d1f 100644 --- a/crates/kebab-chunk/src/md_heading_v1.rs +++ b/crates/kebab-chunk/src/md_heading_v1.rs @@ -332,6 +332,7 @@ fn build_chunk( chunk_id, doc_id: DocumentId(doc.doc_id.0.clone()), block_ids, + tokenized_korean_text: crate::tokenize_korean_morphological(&text), text, heading_path, source_spans, diff --git a/crates/kebab-chunk/src/pdf_page_v1.rs b/crates/kebab-chunk/src/pdf_page_v1.rs index 246e336..e615163 100644 --- a/crates/kebab-chunk/src/pdf_page_v1.rs +++ b/crates/kebab-chunk/src/pdf_page_v1.rs @@ -170,6 +170,7 @@ impl Chunker for PdfPageV1Chunker { chunk_id, doc_id: DocumentId(doc.doc_id.0.clone()), block_ids, + tokenized_korean_text: crate::tokenize_korean_morphological(&slice), text: slice, heading_path: Vec::new(), source_spans: vec![span], diff --git a/crates/kebab-chunk/src/tier2_shared.rs b/crates/kebab-chunk/src/tier2_shared.rs index e3dfd14..8f67d79 100644 --- a/crates/kebab-chunk/src/tier2_shared.rs +++ b/crates/kebab-chunk/src/tier2_shared.rs @@ -189,6 +189,7 @@ fn build_chunk_from_span( chunk_id, doc_id: DocumentId(doc.doc_id.0.clone()), block_ids, + tokenized_korean_text: crate::tokenize_korean_morphological(text), text: text.to_string(), heading_path: Vec::new(), source_spans: vec![span], diff --git a/crates/kebab-chunk/tests/fixtures/code-sample.c.chunks.snapshot.json b/crates/kebab-chunk/tests/fixtures/code-sample.c.chunks.snapshot.json index 832c474..ddd7223 100644 --- a/crates/kebab-chunk/tests/fixtures/code-sample.c.chunks.snapshot.json +++ b/crates/kebab-chunk/tests/fixtures/code-sample.c.chunks.snapshot.json @@ -18,7 +18,8 @@ } ], "text": "#include \n#include \n\n#define MAX_BUF 4096\n\ntypedef enum {\n OK = 0,\n ERR_PARSE,\n ERR_IO,\n} status_t;\n\ntypedef struct {\n int id;\n char name[64];\n status_t status;\n} record_t;\n\nstatic int counter = 0;", - "token_estimate": 78 + "token_estimate": 78, + "tokenized_korean_text": "# include < stdio . h > # include < stdlib . h > # define MAX _ BUF 4096 typedef enum { OK = 0 , ERR _ PARSE , ERR _ IO , } status _ t ; typedef struct { int id ; char name [ 64 ]; status _ t status ; } record _ t ; static int counter = 0 ;" }, { "block_ids": [ @@ -39,7 +40,8 @@ } ], "text": "int parse_record(const char *line, record_t *out) {\n if (line == NULL || out == NULL) return ERR_PARSE;\n return OK;\n}", - "token_estimate": 41 + "token_estimate": 41, + "tokenized_korean_text": "int parse _ record ( const char * line , record _ t * out ) { if ( line == NULL || out == NULL ) return ERR _ PARSE ; return OK ; }" }, { "block_ids": [ @@ -60,7 +62,8 @@ } ], "text": "void print_record(const record_t *r) {\n printf(\"[%d] %s (status=%d)\\n\", r->id, r->name, r->status);\n}", - "token_estimate": 35 + "token_estimate": 35, + "tokenized_korean_text": "void print _ record ( const record _ t * r ) { printf (\"[% d ] % s ( status =% d )\\ n \", r -> id , r -> name , r -> status ); }" }, { "block_ids": [ @@ -81,6 +84,7 @@ } ], "text": "int main(void) {\n record_t r = { .id = 1, .name = \"foo\", .status = OK };\n print_record(&r);\n return 0;\n}", - "token_estimate": 38 + "token_estimate": 38, + "tokenized_korean_text": "int main ( void ) { record _ t r = { . id = 1 , . name = \" foo \", . status = OK }; print _ record (& r ); return 0 ; }" } ] diff --git a/crates/kebab-chunk/tests/fixtures/code-sample.chunks.snapshot.json b/crates/kebab-chunk/tests/fixtures/code-sample.chunks.snapshot.json index f1c69be..8d2f54d 100644 --- a/crates/kebab-chunk/tests/fixtures/code-sample.chunks.snapshot.json +++ b/crates/kebab-chunk/tests/fixtures/code-sample.chunks.snapshot.json @@ -18,7 +18,8 @@ } ], "text": "use std::collections::HashMap;\nuse std::fmt;\n\nconst MAX: usize = 1024;\nconst MIN: usize = 0;", - "token_estimate": 31 + "token_estimate": 31, + "tokenized_korean_text": "use std : : collections : : HashMap ; use std : : fmt ; const MAX : usize = 1024 ; const MIN : usize = 0 ;" }, { "block_ids": [ @@ -39,7 +40,8 @@ } ], "text": "pub fn parse(input: &str) -> Option {\n input\n .trim()\n .parse()\n .ok()\n}", - "token_estimate": 34 + "token_estimate": 34, + "tokenized_korean_text": "pub fn parse ( input : & str ) -> Option < u 32 > { input . trim ( ) . parse ( ) . ok ( ) }" }, { "block_ids": [ @@ -60,7 +62,8 @@ } ], "text": "pub struct Foo {\n pub name: String,\n pub value: u32,\n pub tags: Vec,\n pub meta: Option,\n pub count: usize,\n}", - "token_estimate": 47 + "token_estimate": 47, + "tokenized_korean_text": "pub struct Foo { pub name : String , pub value : u 32 , pub tags : Vec < String >, pub meta : Option < String >, pub count : usize , }" }, { "block_ids": [ @@ -81,7 +84,8 @@ } ], "text": "pub trait Frobable {\n fn frob(&self) -> String;\n fn frob_twice(&self) -> String {\n let a = self.frob();\n let b = self.frob();\n format!(\"{a}{b}\")\n }\n fn name(&self) -> &str;\n}", - "token_estimate": 69 + "token_estimate": 69, + "tokenized_korean_text": "pub trait Frobable { fn frob (& self ) -> String ; fn frob _ twice (& self ) -> String { let a = self . frob (); let b = self . frob (); format !(\"{ a }{ b }\") } fn name (& self ) -> & str ; }" }, { "block_ids": [ @@ -102,7 +106,8 @@ } ], "text": "impl Foo {\n pub fn double(&self) -> u32 {\n self.value\n .checked_mul(2)\n .unwrap_or(u32::MAX)\n }\n}", - "token_estimate": 44 + "token_estimate": 44, + "tokenized_korean_text": "impl Foo { pub fn double (& self ) -> u 32 { self . value . checked _ mul ( 2 ) . unwrap _ or ( u 32 : : MAX ) } }" }, { "block_ids": [ @@ -123,7 +128,8 @@ } ], "text": "impl Foo {\n pub fn triple(&self) -> u32 {\n self.value\n .checked_mul(3)\n .unwrap_or(u32::MAX)\n }\n}", - "token_estimate": 44 + "token_estimate": 44, + "tokenized_korean_text": "impl Foo { pub fn triple (& self ) -> u 32 { self . value . checked _ mul ( 3 ) . unwrap _ or ( u 32 : : MAX ) } }" }, { "block_ids": [ @@ -144,7 +150,8 @@ } ], "text": "pub fn big_fn(input: &[u8]) -> Vec {\n let v0 = input.get(0 as usize).copied().unwrap_or(0);\n let v1 = input.get(1 as usize).copied().unwrap_or(0);\n let v2 = input.get(2 as usize).copied().unwrap_or(0);\n let v3 = input.get(3 as usize).copied().unwrap_or(0);\n let v4 = input.get(4 as usize).copied().unwrap_or(0);\n let v5 = input.get(5 as usize).copied().unwrap_or(0);\n let v6 = input.get(6 as usize).copied().unwrap_or(0);\n let v7 = input.get(7 as usize).copied().unwrap_or(0);\n let v8 = input.get(8 as usize).copied().unwrap_or(0);\n let v9 = input.get(9 as usize).copied().unwrap_or(0);\n let v10 = input.get(10 as usize).copied().unwrap_or(0);\n let v11 = input.get(11 as usize).copied().unwrap_or(0);\n let v12 = input.get(12 as usize).copied().unwrap_or(0);\n let v13 = input.get(13 as usize).copied().unwrap_or(0);\n let v14 = input.get(14 as usize).copied().unwrap_or(0);\n let v15 = input.get(15 as usize).copied().unwrap_or(0);\n let v16 = input.get(16 as usize).copied().unwrap_or(0);\n let v17 = input.get(17 as usize).copied().unwrap_or(0);\n let v18 = input.get(18 as usize).copied().unwrap_or(0);\n let v19 = input.get(19 as usize).copied().unwrap_or(0);\n let v20 = input.get(20 as usize).copied().unwrap_or(0);\n let v21 = input.get(21 as usize).copied().unwrap_or(0);\n let v22 = input.get(22 as usize).copied().unwrap_or(0);\n let v23 = input.get(23 as usize).copied().unwrap_or(0);\n let v24 = input.get(24 as usize).copied().unwrap_or(0);\n let v25 = input.get(25 as usize).copied().unwrap_or(0);\n let v26 = input.get(26 as usize).copied().unwrap_or(0);\n let v27 = input.get(27 as usize).copied().unwrap_or(0);\n let v28 = input.get(28 as usize).copied().unwrap_or(0);\n let v29 = input.get(29 as usize).copied().unwrap_or(0);\n let v30 = input.get(30 as usize).copied().unwrap_or(0);\n let v31 = input.get(31 as usize).copied().unwrap_or(0);\n let v32 = input.get(32 as usize).copied().unwrap_or(0);\n let v33 = input.get(33 as usize).copied().unwrap_or(0);\n let v34 = input.get(34 as usize).copied().unwrap_or(0);\n let v35 = input.get(35 as usize).copied().unwrap_or(0);\n let v36 = input.get(36 as usize).copied().unwrap_or(0);\n let v37 = input.get(37 as usize).copied().unwrap_or(0);\n let v38 = input.get(38 as usize).copied().unwrap_or(0);\n let v39 = input.get(39 as usize).copied().unwrap_or(0);\n let v40 = input.get(40 as usize).copied().unwrap_or(0);\n let v41 = input.get(41 as usize).copied().unwrap_or(0);\n let v42 = input.get(42 as usize).copied().unwrap_or(0);\n let v43 = input.get(43 as usize).copied().unwrap_or(0);\n let v44 = input.get(44 as usize).copied().unwrap_or(0);\n let v45 = input.get(45 as usize).copied().unwrap_or(0);\n let v46 = input.get(46 as usize).copied().unwrap_or(0);\n let v47 = input.get(47 as usize).copied().unwrap_or(0);\n let v48 = input.get(48 as usize).copied().unwrap_or(0);\n let v49 = input.get(49 as usize).copied().unwrap_or(0);\n let v50 = input.get(50 as usize).copied().unwrap_or(0);\n let v51 = input.get(51 as usize).copied().unwrap_or(0);\n let v52 = input.get(52 as usize).copied().unwrap_or(0);\n let v53 = input.get(53 as usize).copied().unwrap_or(0);\n let v54 = input.get(54 as usize).copied().unwrap_or(0);\n let v55 = input.get(55 as usize).copied().unwrap_or(0);\n let v56 = input.get(56 as usize).copied().unwrap_or(0);\n let v57 = input.get(57 as usize).copied().unwrap_or(0);\n let v58 = input.get(58 as usize).copied().unwrap_or(0);\n let v59 = input.get(59 as usize).copied().unwrap_or(0);\n let v60 = input.get(60 as usize).copied().unwrap_or(0);\n let v61 = input.get(61 as usize).copied().unwrap_or(0);\n let v62 = input.get(62 as usize).copied().unwrap_or(0);\n let v63 = input.get(63 as usize).copied().unwrap_or(0);\n let v64 = input.get(64 as usize).copied().unwrap_or(0);\n let v65 = input.get(65 as usize).copied().unwrap_or(0);\n let v66 = input.get(66 as usize).copied().unwrap_or(0);\n let v67 = input.get(67 as usize).copied().unwrap_or(0);\n let v68 = input.get(68 as usize).copied().unwrap_or(0);\n let v69 = input.get(69 as usize).copied().unwrap_or(0);\n let v70 = input.get(70 as usize).copied().unwrap_or(0);\n let v71 = input.get(71 as usize).copied().unwrap_or(0);\n let v72 = input.get(72 as usize).copied().unwrap_or(0);\n let v73 = input.get(73 as usize).copied().unwrap_or(0);\n let v74 = input.get(74 as usize).copied().unwrap_or(0);\n let v75 = input.get(75 as usize).copied().unwrap_or(0);\n let v76 = input.get(76 as usize).copied().unwrap_or(0);\n let v77 = input.get(77 as usize).copied().unwrap_or(0);\n let v78 = input.get(78 as usize).copied().unwrap_or(0);\n let v79 = input.get(79 as usize).copied().unwrap_or(0);\n let v80 = input.get(80 as usize).copied().unwrap_or(0);\n let v81 = input.get(81 as usize).copied().unwrap_or(0);\n let v82 = input.get(82 as usize).copied().unwrap_or(0);\n let v83 = input.get(83 as usize).copied().unwrap_or(0);\n let v84 = input.get(84 as usize).copied().unwrap_or(0);\n let v85 = input.get(85 as usize).copied().unwrap_or(0);\n let v86 = input.get(86 as usize).copied().unwrap_or(0);\n let v87 = input.get(87 as usize).copied().unwrap_or(0);\n let v88 = input.get(88 as usize).copied().unwrap_or(0);\n let v89 = input.get(89 as usize).copied().unwrap_or(0);\n let v90 = input.get(90 as usize).copied().unwrap_or(0);\n let v91 = input.get(91 as usize).copied().unwrap_or(0);\n let v92 = input.get(92 as usize).copied().unwrap_or(0);\n let v93 = input.get(93 as usize).copied().unwrap_or(0);\n let v94 = input.get(94 as usize).copied().unwrap_or(0);\n let v95 = input.get(95 as usize).copied().unwrap_or(0);\n let v96 = input.get(96 as usize).copied().unwrap_or(0);\n let v97 = input.get(97 as usize).copied().unwrap_or(0);\n let v98 = input.get(98 as usize).copied().unwrap_or(0);\n let v99 = input.get(99 as usize).copied().unwrap_or(0);\n let v100 = input.get(100 as usize).copied().unwrap_or(0);\n let v101 = input.get(101 as usize).copied().unwrap_or(0);\n let v102 = input.get(102 as usize).copied().unwrap_or(0);\n let v103 = input.get(103 as usize).copied().unwrap_or(0);\n let v104 = input.get(104 as usize).copied().unwrap_or(0);\n let v105 = input.get(105 as usize).copied().unwrap_or(0);\n let v106 = input.get(106 as usize).copied().unwrap_or(0);\n let v107 = input.get(107 as usize).copied().unwrap_or(0);\n let v108 = input.get(108 as usize).copied().unwrap_or(0);\n let v109 = input.get(109 as usize).copied().unwrap_or(0);\n let v110 = input.get(110 as usize).copied().unwrap_or(0);\n let v111 = input.get(111 as usize).copied().unwrap_or(0);\n let v112 = input.get(112 as usize).copied().unwrap_or(0);\n let v113 = input.get(113 as usize).copied().unwrap_or(0);\n let v114 = input.get(114 as usize).copied().unwrap_or(0);\n let v115 = input.get(115 as usize).copied().unwrap_or(0);\n let v116 = input.get(116 as usize).copied().unwrap_or(0);\n let v117 = input.get(117 as usize).copied().unwrap_or(0);\n let v118 = input.get(118 as usize).copied().unwrap_or(0);\n let v119 = input.get(119 as usize).copied().unwrap_or(0);\n let v120 = input.get(120 as usize).copied().unwrap_or(0);\n let v121 = input.get(121 as usize).copied().unwrap_or(0);\n let v122 = input.get(122 as usize).copied().unwrap_or(0);\n let v123 = input.get(123 as usize).copied().unwrap_or(0);\n let v124 = input.get(124 as usize).copied().unwrap_or(0);\n let v125 = input.get(125 as usize).copied().unwrap_or(0);\n let v126 = input.get(126 as usize).copied().unwrap_or(0);\n let v127 = input.get(127 as usize).copied().unwrap_or(0);\n let v128 = input.get(128 as usize).copied().unwrap_or(0);\n let v129 = input.get(129 as usize).copied().unwrap_or(0);\n let v130 = input.get(130 as usize).copied().unwrap_or(0);\n let v131 = input.get(131 as usize).copied().unwrap_or(0);\n let v132 = input.get(132 as usize).copied().unwrap_or(0);\n let v133 = input.get(133 as usize).copied().unwrap_or(0);\n let v134 = input.get(134 as usize).copied().unwrap_or(0);\n let v135 = input.get(135 as usize).copied().unwrap_or(0);\n let v136 = input.get(136 as usize).copied().unwrap_or(0);\n let v137 = input.get(137 as usize).copied().unwrap_or(0);\n let v138 = input.get(138 as usize).copied().unwrap_or(0);\n let v139 = input.get(139 as usize).copied().unwrap_or(0);\n let v140 = input.get(140 as usize).copied().unwrap_or(0);\n let v141 = input.get(141 as usize).copied().unwrap_or(0);\n let v142 = input.get(142 as usize).copied().unwrap_or(0);\n let v143 = input.get(143 as usize).copied().unwrap_or(0);\n let v144 = input.get(144 as usize).copied().unwrap_or(0);\n let v145 = input.get(145 as usize).copied().unwrap_or(0);\n let v146 = input.get(146 as usize).copied().unwrap_or(0);\n let v147 = input.get(147 as usize).copied().unwrap_or(0);\n let v148 = input.get(148 as usize).copied().unwrap_or(0);\n let v149 = input.get(149 as usize).copied().unwrap_or(0);\n let v150 = input.get(150 as usize).copied().unwrap_or(0);\n let v151 = input.get(151 as usize).copied().unwrap_or(0);\n let v152 = input.get(152 as usize).copied().unwrap_or(0);\n let v153 = input.get(153 as usize).copied().unwrap_or(0);\n let v154 = input.get(154 as usize).copied().unwrap_or(0);\n let v155 = input.get(155 as usize).copied().unwrap_or(0);\n let v156 = input.get(156 as usize).copied().unwrap_or(0);\n let v157 = input.get(157 as usize).copied().unwrap_or(0);\n let v158 = input.get(158 as usize).copied().unwrap_or(0);\n let v159 = input.get(159 as usize).copied().unwrap_or(0);\n let v160 = input.get(160 as usize).copied().unwrap_or(0);\n let v161 = input.get(161 as usize).copied().unwrap_or(0);\n let v162 = input.get(162 as usize).copied().unwrap_or(0);\n let v163 = input.get(163 as usize).copied().unwrap_or(0);\n let v164 = input.get(164 as usize).copied().unwrap_or(0);\n let v165 = input.get(165 as usize).copied().unwrap_or(0);\n let v166 = input.get(166 as usize).copied().unwrap_or(0);\n let v167 = input.get(167 as usize).copied().unwrap_or(0);\n let v168 = input.get(168 as usize).copied().unwrap_or(0);\n let v169 = input.get(169 as usize).copied().unwrap_or(0);\n let v170 = input.get(170 as usize).copied().unwrap_or(0);\n let v171 = input.get(171 as usize).copied().unwrap_or(0);\n let v172 = input.get(172 as usize).copied().unwrap_or(0);\n let v173 = input.get(173 as usize).copied().unwrap_or(0);\n let v174 = input.get(174 as usize).copied().unwrap_or(0);\n let v175 = input.get(175 as usize).copied().unwrap_or(0);\n let v176 = input.get(176 as usize).copied().unwrap_or(0);\n let v177 = input.get(177 as usize).copied().unwrap_or(0);\n let v178 = input.get(178 as usize).copied().unwrap_or(0);\n let v179 = input.get(179 as usize).copied().unwrap_or(0);\n let v180 = input.get(180 as usize).copied().unwrap_or(0);\n let v181 = input.get(181 as usize).copied().unwrap_or(0);\n let v182 = input.get(182 as usize).copied().unwrap_or(0);\n let v183 = input.get(183 as usize).copied().unwrap_or(0);\n let v184 = input.get(184 as usize).copied().unwrap_or(0);\n let v185 = input.get(185 as usize).copied().unwrap_or(0);\n let v186 = input.get(186 as usize).copied().unwrap_or(0);\n let v187 = input.get(187 as usize).copied().unwrap_or(0);\n let v188 = input.get(188 as usize).copied().unwrap_or(0);\n let v189 = input.get(189 as usize).copied().unwrap_or(0);\n let v190 = input.get(190 as usize).copied().unwrap_or(0);\n let v191 = input.get(191 as usize).copied().unwrap_or(0);\n let v192 = input.get(192 as usize).copied().unwrap_or(0);\n let v193 = input.get(193 as usize).copied().unwrap_or(0);\n let v194 = input.get(194 as usize).copied().unwrap_or(0);\n let v195 = input.get(195 as usize).copied().unwrap_or(0);\n let v196 = input.get(196 as usize).copied().unwrap_or(0);\n let v197 = input.get(197 as usize).copied().unwrap_or(0);\n let v198 = input.get(198 as usize).copied().unwrap_or(0);", - "token_estimate": 4053 + "token_estimate": 4053, + "tokenized_korean_text": "pub fn big _ fn ( input : &[ u 8 ] ) -> Vec < u 8 > { let v 0 = input . get ( 0 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 1 = input . get ( 1 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 2 = input . get ( 2 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 3 = input . get ( 3 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 4 = input . get ( 4 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 5 = input . get ( 5 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 6 = input . get ( 6 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 7 = input . get ( 7 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 8 = input . get ( 8 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 9 = input . get ( 9 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 10 = input . get ( 10 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 11 = input . get ( 11 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 12 = input . get ( 12 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 13 = input . get ( 13 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 14 = input . get ( 14 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 15 = input . get ( 15 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 16 = input . get ( 16 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 17 = input . get ( 17 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 18 = input . get ( 18 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 19 = input . get ( 19 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 20 = input . get ( 20 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 21 = input . get ( 21 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 22 = input . get ( 22 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 23 = input . get ( 23 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 24 = input . get ( 24 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 25 = input . get ( 25 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 26 = input . get ( 26 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 27 = input . get ( 27 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 28 = input . get ( 28 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 29 = input . get ( 29 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 30 = input . get ( 30 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 31 = input . get ( 31 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 32 = input . get ( 32 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 33 = input . get ( 33 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 34 = input . get ( 34 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 35 = input . get ( 35 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 36 = input . get ( 36 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 37 = input . get ( 37 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 38 = input . get ( 38 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 39 = input . get ( 39 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 40 = input . get ( 40 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 41 = input . get ( 41 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 42 = input . get ( 42 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 43 = input . get ( 43 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 44 = input . get ( 44 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 45 = input . get ( 45 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 46 = input . get ( 46 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 47 = input . get ( 47 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 48 = input . get ( 48 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 49 = input . get ( 49 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 50 = input . get ( 50 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 51 = input . get ( 51 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 52 = input . get ( 52 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 53 = input . get ( 53 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 54 = input . get ( 54 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 55 = input . get ( 55 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 56 = input . get ( 56 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 57 = input . get ( 57 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 58 = input . get ( 58 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 59 = input . get ( 59 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 60 = input . get ( 60 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 61 = input . get ( 61 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 62 = input . get ( 62 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 63 = input . get ( 63 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 64 = input . get ( 64 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 65 = input . get ( 65 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 66 = input . get ( 66 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 67 = input . get ( 67 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 68 = input . get ( 68 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 69 = input . get ( 69 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 70 = input . get ( 70 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 71 = input . get ( 71 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 72 = input . get ( 72 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 73 = input . get ( 73 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 74 = input . get ( 74 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 75 = input . get ( 75 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 76 = input . get ( 76 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 77 = input . get ( 77 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 78 = input . get ( 78 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 79 = input . get ( 79 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 80 = input . get ( 80 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 81 = input . get ( 81 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 82 = input . get ( 82 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 83 = input . get ( 83 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 84 = input . get ( 84 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 85 = input . get ( 85 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 86 = input . get ( 86 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 87 = input . get ( 87 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 88 = input . get ( 88 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 89 = input . get ( 89 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 90 = input . get ( 90 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 91 = input . get ( 91 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 92 = input . get ( 92 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 93 = input . get ( 93 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 94 = input . get ( 94 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 95 = input . get ( 95 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 96 = input . get ( 96 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 97 = input . get ( 97 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 98 = input . get ( 98 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 99 = input . get ( 99 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 100 = input . get ( 100 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 101 = input . get ( 101 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 102 = input . get ( 102 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 103 = input . get ( 103 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 104 = input . get ( 104 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 105 = input . get ( 105 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 106 = input . get ( 106 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 107 = input . get ( 107 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 108 = input . get ( 108 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 109 = input . get ( 109 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 110 = input . get ( 110 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 111 = input . get ( 111 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 112 = input . get ( 112 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 113 = input . get ( 113 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 114 = input . get ( 114 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 115 = input . get ( 115 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 116 = input . get ( 116 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 117 = input . get ( 117 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 118 = input . get ( 118 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 119 = input . get ( 119 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 120 = input . get ( 120 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 121 = input . get ( 121 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 122 = input . get ( 122 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 123 = input . get ( 123 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 124 = input . get ( 124 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 125 = input . get ( 125 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 126 = input . get ( 126 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 127 = input . get ( 127 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 128 = input . get ( 128 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 129 = input . get ( 129 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 130 = input . get ( 130 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 131 = input . get ( 131 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 132 = input . get ( 132 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 133 = input . get ( 133 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 134 = input . get ( 134 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 135 = input . get ( 135 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 136 = input . get ( 136 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 137 = input . get ( 137 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 138 = input . get ( 138 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 139 = input . get ( 139 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 140 = input . get ( 140 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 141 = input . get ( 141 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 142 = input . get ( 142 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 143 = input . get ( 143 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 144 = input . get ( 144 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 145 = input . get ( 145 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 146 = input . get ( 146 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 147 = input . get ( 147 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 148 = input . get ( 148 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 149 = input . get ( 149 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 150 = input . get ( 150 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 151 = input . get ( 151 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 152 = input . get ( 152 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 153 = input . get ( 153 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 154 = input . get ( 154 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 155 = input . get ( 155 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 156 = input . get ( 156 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 157 = input . get ( 157 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 158 = input . get ( 158 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 159 = input . get ( 159 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 160 = input . get ( 160 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 161 = input . get ( 161 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 162 = input . get ( 162 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 163 = input . get ( 163 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 164 = input . get ( 164 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 165 = input . get ( 165 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 166 = input . get ( 166 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 167 = input . get ( 167 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 168 = input . get ( 168 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 169 = input . get ( 169 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 170 = input . get ( 170 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 171 = input . get ( 171 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 172 = input . get ( 172 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 173 = input . get ( 173 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 174 = input . get ( 174 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 175 = input . get ( 175 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 176 = input . get ( 176 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 177 = input . get ( 177 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 178 = input . get ( 178 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 179 = input . get ( 179 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 180 = input . get ( 180 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 181 = input . get ( 181 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 182 = input . get ( 182 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 183 = input . get ( 183 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 184 = input . get ( 184 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 185 = input . get ( 185 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 186 = input . get ( 186 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 187 = input . get ( 187 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 188 = input . get ( 188 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 189 = input . get ( 189 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 190 = input . get ( 190 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 191 = input . get ( 191 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 192 = input . get ( 192 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 193 = input . get ( 193 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 194 = input . get ( 194 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 195 = input . get ( 195 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 196 = input . get ( 196 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 197 = input . get ( 197 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 198 = input . get ( 198 as usize ) . copied ( ) . unwrap _ or ( 0 );" }, { "block_ids": [ @@ -165,6 +172,7 @@ } ], "text": " let v199 = input.get(199 as usize).copied().unwrap_or(0);\n let v200 = input.get(200 as usize).copied().unwrap_or(0);\n let v201 = input.get(201 as usize).copied().unwrap_or(0);\n let v202 = input.get(202 as usize).copied().unwrap_or(0);\n let v203 = input.get(203 as usize).copied().unwrap_or(0);\n let v204 = input.get(204 as usize).copied().unwrap_or(0);\n let v205 = input.get(205 as usize).copied().unwrap_or(0);\n let v206 = input.get(206 as usize).copied().unwrap_or(0);\n let v207 = input.get(207 as usize).copied().unwrap_or(0);\n let v208 = input.get(208 as usize).copied().unwrap_or(0);\n let v209 = input.get(209 as usize).copied().unwrap_or(0);\n vec![0u8]\n}", - "token_estimate": 233 + "token_estimate": 233, + "tokenized_korean_text": "let v 199 = input . get ( 199 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 200 = input . get ( 200 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 201 = input . get ( 201 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 202 = input . get ( 202 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 203 = input . get ( 203 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 204 = input . get ( 204 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 205 = input . get ( 205 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 206 = input . get ( 206 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 207 = input . get ( 207 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 208 = input . get ( 208 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 209 = input . get ( 209 as usize ) . copied ( ) . unwrap _ or ( 0 ); vec ! [ 0 u 8 ] }" } ] diff --git a/crates/kebab-chunk/tests/fixtures/code-sample.cpp.chunks.snapshot.json b/crates/kebab-chunk/tests/fixtures/code-sample.cpp.chunks.snapshot.json index 257d6e9..f6afec8 100644 --- a/crates/kebab-chunk/tests/fixtures/code-sample.cpp.chunks.snapshot.json +++ b/crates/kebab-chunk/tests/fixtures/code-sample.cpp.chunks.snapshot.json @@ -18,7 +18,8 @@ } ], "text": "#include \n#include \n\nnamespace kebab {", - "token_estimate": 18 + "token_estimate": 18, + "tokenized_korean_text": "# include < string > # include < vector > namespace kebab {" }, { "block_ids": [ @@ -39,7 +40,8 @@ } ], "text": "class MdHeadingV1Chunker {\npublic:\n MdHeadingV1Chunker() = default;\n ~MdHeadingV1Chunker() = default;\n\n std::string chunk_doc(const std::string& doc) {\n return doc;\n }\n\n int operator()(int x) const {\n return x * 2;\n }\n\nprivate:\n int counter_ = 0;\n};", - "token_estimate": 95 + "token_estimate": 95, + "tokenized_korean_text": "class MdHeadingV 1 Chunker { public : MdHeadingV 1 Chunker ( ) = default ; ~ MdHeadingV 1 Chunker ( ) = default ; std : : string chunk _ doc ( const std : : string & doc ) { return doc ; } int operator ( ) ( int x ) const { return x * 2 ; } private : int counter _ = 0 ; };" }, { "block_ids": [ @@ -60,7 +62,8 @@ } ], "text": "template \nT identity(T value) {\n return value;\n}", - "token_estimate": 21 + "token_estimate": 21, + "tokenized_korean_text": "template < typename T > T identity ( T value ) { return value ; }" }, { "block_ids": [ @@ -81,7 +84,8 @@ } ], "text": "void global_helper() {\n // free function in kebab namespace\n}", - "token_estimate": 22 + "token_estimate": 22, + "tokenized_korean_text": "void global _ helper ( ) { / / free function in kebab namespace }" }, { "block_ids": [ @@ -102,6 +106,7 @@ } ], "text": "int main() {\n kebab::chunk::MdHeadingV1Chunker c;\n return 0;\n}", - "token_estimate": 23 + "token_estimate": 23, + "tokenized_korean_text": "int main ( ) { kebab : : chunk : : MdHeadingV 1 Chunker c ; return 0 ; }" } ] diff --git a/crates/kebab-chunk/tests/fixtures/code-sample.go.chunks.snapshot.json b/crates/kebab-chunk/tests/fixtures/code-sample.go.chunks.snapshot.json index 26f76c1..d5add54 100644 --- a/crates/kebab-chunk/tests/fixtures/code-sample.go.chunks.snapshot.json +++ b/crates/kebab-chunk/tests/fixtures/code-sample.go.chunks.snapshot.json @@ -18,7 +18,8 @@ } ], "text": "import (\n\t\"fmt\"\n\t\"os\"\n\t\"strings\"\n)", - "token_estimate": 12 + "token_estimate": 12, + "tokenized_korean_text": "import ( \" fmt \" \" os \" \" strings \" )" }, { "block_ids": [ @@ -39,7 +40,8 @@ } ], "text": "func ComputeMRR(scores []float64) float64 {\n\tif len(scores) == 0 {\n\t\treturn 0.0\n\t}\n\t_ = fmt.Sprintf(\"%v\", scores)\n\treturn 1.0 / float64(len(scores))\n}", - "token_estimate": 50 + "token_estimate": 50, + "tokenized_korean_text": "func ComputeMRR ( scores [ ] float 64 ) float 64 { if len ( scores ) == 0 { return 0 . 0 } _ = fmt . Sprintf (\"% v \", scores ) return 1 . 0 / float 64 ( len ( scores ) ) }" }, { "block_ids": [ @@ -60,7 +62,8 @@ } ], "text": "type MetricsCollector struct {\n\tScores []float64\n\tLabels []string\n\tCounts map[string]int\n\tTotals map[string]float64\n\tTags []string\n}", - "token_estimate": 45 + "token_estimate": 45, + "tokenized_korean_text": "type MetricsCollector struct { Scores [ ] float 64 Labels [ ] string Counts map [ string ] int Totals map [ string ] float 64 Tags [ ] string }" }, { "block_ids": [ @@ -81,7 +84,8 @@ } ], "text": "type BaseEvaluator struct {\n\tName string\n}\n\nfunc (e *BaseEvaluator) Evaluate(data []string) error {\n\t_ = os.Stderr\n\t_ = strings.Join(data, \",\")\n\treturn nil\n}", - "token_estimate": 53 + "token_estimate": 53, + "tokenized_korean_text": "type BaseEvaluator struct { Name string } func ( e * BaseEvaluator ) Evaluate ( data [ ] string ) error { _ = os . Stderr _ = strings . Join ( data , \",\") return nil }" }, { "block_ids": [ @@ -102,7 +106,8 @@ } ], "text": "func (m *MetricsCollector) Run(inputs []float64) {\n\tfor _, inp := range inputs {\n\t\tm.Scores = append(\n\t\t\tm.Scores,\n\t\t\tinp,\n\t\t)\n\t}\n}", - "token_estimate": 44 + "token_estimate": 44, + "tokenized_korean_text": "func ( m * MetricsCollector ) Run ( inputs [ ] float 64 ) { for _, inp := range inputs { m . Scores = append ( m . Scores , inp , ) } }" }, { "block_ids": [ @@ -123,7 +128,8 @@ } ], "text": "func (m *MetricsCollector) Report() map[string]interface{} {\n\treturn map[string]interface{}{\n\t\t\"mean\": 0.0,\n\t\t\"count\": len(m.Scores),\n\t\t\"tags\": m.Tags,\n\t}\n}", - "token_estimate": 53 + "token_estimate": 53, + "tokenized_korean_text": "func ( m * MetricsCollector ) Report ( ) map [ string ] interface {} { return map [ string ] interface {}{ \" mean \": 0 . 0 , \" count \": len ( m . Scores ) , \" tags \": m . Tags , } }" }, { "block_ids": [ @@ -144,7 +150,8 @@ } ], "text": "func BigCompute(data []int) int {\n\tv0 := 0\n\tif 0 < len(data) {\n\t\tv0 = data[0]\n\t}\n\tv1 := 0\n\tif 1 < len(data) {\n\t\tv1 = data[1]\n\t}\n\tv2 := 0\n\tif 2 < len(data) {\n\t\tv2 = data[2]\n\t}\n\tv3 := 0\n\tif 3 < len(data) {\n\t\tv3 = data[3]\n\t}\n\tv4 := 0\n\tif 4 < len(data) {\n\t\tv4 = data[4]\n\t}\n\tv5 := 0\n\tif 5 < len(data) {\n\t\tv5 = data[5]\n\t}\n\tv6 := 0\n\tif 6 < len(data) {\n\t\tv6 = data[6]\n\t}\n\tv7 := 0\n\tif 7 < len(data) {\n\t\tv7 = data[7]\n\t}\n\tv8 := 0\n\tif 8 < len(data) {\n\t\tv8 = data[8]\n\t}\n\tv9 := 0\n\tif 9 < len(data) {\n\t\tv9 = data[9]\n\t}\n\tv10 := 0\n\tif 10 < len(data) {\n\t\tv10 = data[10]\n\t}\n\tv11 := 0\n\tif 11 < len(data) {\n\t\tv11 = data[11]\n\t}\n\tv12 := 0\n\tif 12 < len(data) {\n\t\tv12 = data[12]\n\t}\n\tv13 := 0\n\tif 13 < len(data) {\n\t\tv13 = data[13]\n\t}\n\tv14 := 0\n\tif 14 < len(data) {\n\t\tv14 = data[14]\n\t}\n\tv15 := 0\n\tif 15 < len(data) {\n\t\tv15 = data[15]\n\t}\n\tv16 := 0\n\tif 16 < len(data) {\n\t\tv16 = data[16]\n\t}\n\tv17 := 0\n\tif 17 < len(data) {\n\t\tv17 = data[17]\n\t}\n\tv18 := 0\n\tif 18 < len(data) {\n\t\tv18 = data[18]\n\t}\n\tv19 := 0\n\tif 19 < len(data) {\n\t\tv19 = data[19]\n\t}\n\tv20 := 0\n\tif 20 < len(data) {\n\t\tv20 = data[20]\n\t}\n\tv21 := 0\n\tif 21 < len(data) {\n\t\tv21 = data[21]\n\t}\n\tv22 := 0\n\tif 22 < len(data) {\n\t\tv22 = data[22]\n\t}\n\tv23 := 0\n\tif 23 < len(data) {\n\t\tv23 = data[23]\n\t}\n\tv24 := 0\n\tif 24 < len(data) {\n\t\tv24 = data[24]\n\t}\n\tv25 := 0\n\tif 25 < len(data) {\n\t\tv25 = data[25]\n\t}\n\tv26 := 0\n\tif 26 < len(data) {\n\t\tv26 = data[26]\n\t}\n\tv27 := 0\n\tif 27 < len(data) {\n\t\tv27 = data[27]\n\t}\n\tv28 := 0\n\tif 28 < len(data) {\n\t\tv28 = data[28]\n\t}\n\tv29 := 0\n\tif 29 < len(data) {\n\t\tv29 = data[29]\n\t}\n\tv30 := 0\n\tif 30 < len(data) {\n\t\tv30 = data[30]\n\t}\n\tv31 := 0\n\tif 31 < len(data) {\n\t\tv31 = data[31]\n\t}\n\tv32 := 0\n\tif 32 < len(data) {\n\t\tv32 = data[32]\n\t}\n\tv33 := 0\n\tif 33 < len(data) {\n\t\tv33 = data[33]\n\t}\n\tv34 := 0\n\tif 34 < len(data) {\n\t\tv34 = data[34]\n\t}\n\tv35 := 0\n\tif 35 < len(data) {\n\t\tv35 = data[35]\n\t}\n\tv36 := 0\n\tif 36 < len(data) {\n\t\tv36 = data[36]\n\t}\n\tv37 := 0\n\tif 37 < len(data) {\n\t\tv37 = data[37]\n\t}\n\tv38 := 0\n\tif 38 < len(data) {\n\t\tv38 = data[38]\n\t}\n\tv39 := 0\n\tif 39 < len(data) {\n\t\tv39 = data[39]\n\t}\n\tv40 := 0\n\tif 40 < len(data) {\n\t\tv40 = data[40]\n\t}\n\tv41 := 0\n\tif 41 < len(data) {\n\t\tv41 = data[41]\n\t}\n\tv42 := 0\n\tif 42 < len(data) {\n\t\tv42 = data[42]\n\t}\n\tv43 := 0\n\tif 43 < len(data) {\n\t\tv43 = data[43]\n\t}\n\tv44 := 0\n\tif 44 < len(data) {\n\t\tv44 = data[44]\n\t}\n\tv45 := 0\n\tif 45 < len(data) {\n\t\tv45 = data[45]\n\t}\n\tv46 := 0\n\tif 46 < len(data) {\n\t\tv46 = data[46]\n\t}\n\tv47 := 0\n\tif 47 < len(data) {\n\t\tv47 = data[47]\n\t}\n\tv48 := 0\n\tif 48 < len(data) {\n\t\tv48 = data[48]\n\t}\n\tv49 := 0\n\tif 49 < len(data) {\n\t\tv49 = data[49]", - "token_estimate": 847 + "token_estimate": 847, + "tokenized_korean_text": "func BigCompute ( data [ ] int ) int { v 0 := 0 if 0 < len ( data ) { v 0 = data [ 0 ] } v 1 := 0 if 1 < len ( data ) { v 1 = data [ 1 ] } v 2 := 0 if 2 < len ( data ) { v 2 = data [ 2 ] } v 3 := 0 if 3 < len ( data ) { v 3 = data [ 3 ] } v 4 := 0 if 4 < len ( data ) { v 4 = data [ 4 ] } v 5 := 0 if 5 < len ( data ) { v 5 = data [ 5 ] } v 6 := 0 if 6 < len ( data ) { v 6 = data [ 6 ] } v 7 := 0 if 7 < len ( data ) { v 7 = data [ 7 ] } v 8 := 0 if 8 < len ( data ) { v 8 = data [ 8 ] } v 9 := 0 if 9 < len ( data ) { v 9 = data [ 9 ] } v 10 := 0 if 10 < len ( data ) { v 10 = data [ 10 ] } v 11 := 0 if 11 < len ( data ) { v 11 = data [ 11 ] } v 12 := 0 if 12 < len ( data ) { v 12 = data [ 12 ] } v 13 := 0 if 13 < len ( data ) { v 13 = data [ 13 ] } v 14 := 0 if 14 < len ( data ) { v 14 = data [ 14 ] } v 15 := 0 if 15 < len ( data ) { v 15 = data [ 15 ] } v 16 := 0 if 16 < len ( data ) { v 16 = data [ 16 ] } v 17 := 0 if 17 < len ( data ) { v 17 = data [ 17 ] } v 18 := 0 if 18 < len ( data ) { v 18 = data [ 18 ] } v 19 := 0 if 19 < len ( data ) { v 19 = data [ 19 ] } v 20 := 0 if 20 < len ( data ) { v 20 = data [ 20 ] } v 21 := 0 if 21 < len ( data ) { v 21 = data [ 21 ] } v 22 := 0 if 22 < len ( data ) { v 22 = data [ 22 ] } v 23 := 0 if 23 < len ( data ) { v 23 = data [ 23 ] } v 24 := 0 if 24 < len ( data ) { v 24 = data [ 24 ] } v 25 := 0 if 25 < len ( data ) { v 25 = data [ 25 ] } v 26 := 0 if 26 < len ( data ) { v 26 = data [ 26 ] } v 27 := 0 if 27 < len ( data ) { v 27 = data [ 27 ] } v 28 := 0 if 28 < len ( data ) { v 28 = data [ 28 ] } v 29 := 0 if 29 < len ( data ) { v 29 = data [ 29 ] } v 30 := 0 if 30 < len ( data ) { v 30 = data [ 30 ] } v 31 := 0 if 31 < len ( data ) { v 31 = data [ 31 ] } v 32 := 0 if 32 < len ( data ) { v 32 = data [ 32 ] } v 33 := 0 if 33 < len ( data ) { v 33 = data [ 33 ] } v 34 := 0 if 34 < len ( data ) { v 34 = data [ 34 ] } v 35 := 0 if 35 < len ( data ) { v 35 = data [ 35 ] } v 36 := 0 if 36 < len ( data ) { v 36 = data [ 36 ] } v 37 := 0 if 37 < len ( data ) { v 37 = data [ 37 ] } v 38 := 0 if 38 < len ( data ) { v 38 = data [ 38 ] } v 39 := 0 if 39 < len ( data ) { v 39 = data [ 39 ] } v 40 := 0 if 40 < len ( data ) { v 40 = data [ 40 ] } v 41 := 0 if 41 < len ( data ) { v 41 = data [ 41 ] } v 42 := 0 if 42 < len ( data ) { v 42 = data [ 42 ] } v 43 := 0 if 43 < len ( data ) { v 43 = data [ 43 ] } v 44 := 0 if 44 < len ( data ) { v 44 = data [ 44 ] } v 45 := 0 if 45 < len ( data ) { v 45 = data [ 45 ] } v 46 := 0 if 46 < len ( data ) { v 46 = data [ 46 ] } v 47 := 0 if 47 < len ( data ) { v 47 = data [ 47 ] } v 48 := 0 if 48 < len ( data ) { v 48 = data [ 48 ] } v 49 := 0 if 49 < len ( data ) { v 49 = data [ 49 ]" }, { "block_ids": [ @@ -165,7 +172,8 @@ } ], "text": "\t}\n\tv50 := 0\n\tif 50 < len(data) {\n\t\tv50 = data[50]\n\t}\n\tv51 := 0\n\tif 51 < len(data) {\n\t\tv51 = data[51]\n\t}\n\tv52 := 0\n\tif 52 < len(data) {\n\t\tv52 = data[52]\n\t}\n\tv53 := 0\n\tif 53 < len(data) {\n\t\tv53 = data[53]\n\t}\n\tv54 := 0\n\tif 54 < len(data) {\n\t\tv54 = data[54]\n\t}\n\tv55 := 0\n\tif 55 < len(data) {\n\t\tv55 = data[55]\n\t}\n\tv56 := 0\n\tif 56 < len(data) {\n\t\tv56 = data[56]\n\t}\n\tv57 := 0\n\tif 57 < len(data) {\n\t\tv57 = data[57]\n\t}\n\tv58 := 0\n\tif 58 < len(data) {\n\t\tv58 = data[58]\n\t}\n\tv59 := 0\n\tif 59 < len(data) {\n\t\tv59 = data[59]\n\t}\n\tv60 := 0\n\tif 60 < len(data) {\n\t\tv60 = data[60]\n\t}\n\tv61 := 0\n\tif 61 < len(data) {\n\t\tv61 = data[61]\n\t}\n\tv62 := 0\n\tif 62 < len(data) {\n\t\tv62 = data[62]\n\t}\n\tv63 := 0\n\tif 63 < len(data) {\n\t\tv63 = data[63]\n\t}\n\tv64 := 0\n\tif 64 < len(data) {\n\t\tv64 = data[64]\n\t}\n\tv65 := 0\n\tif 65 < len(data) {\n\t\tv65 = data[65]\n\t}\n\tv66 := 0\n\tif 66 < len(data) {\n\t\tv66 = data[66]\n\t}\n\tv67 := 0\n\tif 67 < len(data) {\n\t\tv67 = data[67]\n\t}\n\tv68 := 0\n\tif 68 < len(data) {\n\t\tv68 = data[68]\n\t}\n\tv69 := 0\n\tif 69 < len(data) {\n\t\tv69 = data[69]\n\t}\n\tv70 := 0\n\tif 70 < len(data) {\n\t\tv70 = data[70]\n\t}\n\tv71 := 0\n\tif 71 < len(data) {\n\t\tv71 = data[71]\n\t}\n\tv72 := 0\n\tif 72 < len(data) {\n\t\tv72 = data[72]\n\t}\n\tv73 := 0\n\tif 73 < len(data) {\n\t\tv73 = data[73]\n\t}\n\tv74 := 0\n\tif 74 < len(data) {\n\t\tv74 = data[74]\n\t}\n\tv75 := 0\n\tif 75 < len(data) {\n\t\tv75 = data[75]\n\t}\n\tv76 := 0\n\tif 76 < len(data) {\n\t\tv76 = data[76]\n\t}\n\tv77 := 0\n\tif 77 < len(data) {\n\t\tv77 = data[77]\n\t}\n\tv78 := 0\n\tif 78 < len(data) {\n\t\tv78 = data[78]\n\t}\n\tv79 := 0\n\tif 79 < len(data) {\n\t\tv79 = data[79]\n\t}\n\tv80 := 0\n\tif 80 < len(data) {\n\t\tv80 = data[80]\n\t}\n\tv81 := 0\n\tif 81 < len(data) {\n\t\tv81 = data[81]\n\t}\n\tv82 := 0\n\tif 82 < len(data) {\n\t\tv82 = data[82]\n\t}\n\tv83 := 0\n\tif 83 < len(data) {\n\t\tv83 = data[83]\n\t}\n\tv84 := 0\n\tif 84 < len(data) {\n\t\tv84 = data[84]\n\t}\n\tv85 := 0\n\tif 85 < len(data) {\n\t\tv85 = data[85]\n\t}\n\tv86 := 0\n\tif 86 < len(data) {\n\t\tv86 = data[86]\n\t}\n\tv87 := 0\n\tif 87 < len(data) {\n\t\tv87 = data[87]\n\t}\n\tv88 := 0\n\tif 88 < len(data) {\n\t\tv88 = data[88]\n\t}\n\tv89 := 0\n\tif 89 < len(data) {\n\t\tv89 = data[89]\n\t}\n\tv90 := 0\n\tif 90 < len(data) {\n\t\tv90 = data[90]\n\t}\n\tv91 := 0\n\tif 91 < len(data) {\n\t\tv91 = data[91]\n\t}\n\tv92 := 0\n\tif 92 < len(data) {\n\t\tv92 = data[92]\n\t}\n\tv93 := 0\n\tif 93 < len(data) {\n\t\tv93 = data[93]\n\t}\n\tv94 := 0\n\tif 94 < len(data) {\n\t\tv94 = data[94]\n\t}\n\tv95 := 0\n\tif 95 < len(data) {\n\t\tv95 = data[95]\n\t}\n\tv96 := 0\n\tif 96 < len(data) {\n\t\tv96 = data[96]\n\t}\n\tv97 := 0\n\tif 97 < len(data) {\n\t\tv97 = data[97]\n\t}\n\tv98 := 0\n\tif 98 < len(data) {\n\t\tv98 = data[98]\n\t}\n\tv99 := 0\n\tif 99 < len(data) {\n\t\tv99 = data[99]", - "token_estimate": 850 + "token_estimate": 850, + "tokenized_korean_text": "} v 50 := 0 if 50 < len ( data ) { v 50 = data [ 50 ] } v 51 := 0 if 51 < len ( data ) { v 51 = data [ 51 ] } v 52 := 0 if 52 < len ( data ) { v 52 = data [ 52 ] } v 53 := 0 if 53 < len ( data ) { v 53 = data [ 53 ] } v 54 := 0 if 54 < len ( data ) { v 54 = data [ 54 ] } v 55 := 0 if 55 < len ( data ) { v 55 = data [ 55 ] } v 56 := 0 if 56 < len ( data ) { v 56 = data [ 56 ] } v 57 := 0 if 57 < len ( data ) { v 57 = data [ 57 ] } v 58 := 0 if 58 < len ( data ) { v 58 = data [ 58 ] } v 59 := 0 if 59 < len ( data ) { v 59 = data [ 59 ] } v 60 := 0 if 60 < len ( data ) { v 60 = data [ 60 ] } v 61 := 0 if 61 < len ( data ) { v 61 = data [ 61 ] } v 62 := 0 if 62 < len ( data ) { v 62 = data [ 62 ] } v 63 := 0 if 63 < len ( data ) { v 63 = data [ 63 ] } v 64 := 0 if 64 < len ( data ) { v 64 = data [ 64 ] } v 65 := 0 if 65 < len ( data ) { v 65 = data [ 65 ] } v 66 := 0 if 66 < len ( data ) { v 66 = data [ 66 ] } v 67 := 0 if 67 < len ( data ) { v 67 = data [ 67 ] } v 68 := 0 if 68 < len ( data ) { v 68 = data [ 68 ] } v 69 := 0 if 69 < len ( data ) { v 69 = data [ 69 ] } v 70 := 0 if 70 < len ( data ) { v 70 = data [ 70 ] } v 71 := 0 if 71 < len ( data ) { v 71 = data [ 71 ] } v 72 := 0 if 72 < len ( data ) { v 72 = data [ 72 ] } v 73 := 0 if 73 < len ( data ) { v 73 = data [ 73 ] } v 74 := 0 if 74 < len ( data ) { v 74 = data [ 74 ] } v 75 := 0 if 75 < len ( data ) { v 75 = data [ 75 ] } v 76 := 0 if 76 < len ( data ) { v 76 = data [ 76 ] } v 77 := 0 if 77 < len ( data ) { v 77 = data [ 77 ] } v 78 := 0 if 78 < len ( data ) { v 78 = data [ 78 ] } v 79 := 0 if 79 < len ( data ) { v 79 = data [ 79 ] } v 80 := 0 if 80 < len ( data ) { v 80 = data [ 80 ] } v 81 := 0 if 81 < len ( data ) { v 81 = data [ 81 ] } v 82 := 0 if 82 < len ( data ) { v 82 = data [ 82 ] } v 83 := 0 if 83 < len ( data ) { v 83 = data [ 83 ] } v 84 := 0 if 84 < len ( data ) { v 84 = data [ 84 ] } v 85 := 0 if 85 < len ( data ) { v 85 = data [ 85 ] } v 86 := 0 if 86 < len ( data ) { v 86 = data [ 86 ] } v 87 := 0 if 87 < len ( data ) { v 87 = data [ 87 ] } v 88 := 0 if 88 < len ( data ) { v 88 = data [ 88 ] } v 89 := 0 if 89 < len ( data ) { v 89 = data [ 89 ] } v 90 := 0 if 90 < len ( data ) { v 90 = data [ 90 ] } v 91 := 0 if 91 < len ( data ) { v 91 = data [ 91 ] } v 92 := 0 if 92 < len ( data ) { v 92 = data [ 92 ] } v 93 := 0 if 93 < len ( data ) { v 93 = data [ 93 ] } v 94 := 0 if 94 < len ( data ) { v 94 = data [ 94 ] } v 95 := 0 if 95 < len ( data ) { v 95 = data [ 95 ] } v 96 := 0 if 96 < len ( data ) { v 96 = data [ 96 ] } v 97 := 0 if 97 < len ( data ) { v 97 = data [ 97 ] } v 98 := 0 if 98 < len ( data ) { v 98 = data [ 98 ] } v 99 := 0 if 99 < len ( data ) { v 99 = data [ 99 ]" }, { "block_ids": [ @@ -186,7 +194,8 @@ } ], "text": "\t}\n\tv100 := 0\n\tif 100 < len(data) {\n\t\tv100 = data[100]\n\t}\n\tv101 := 0\n\tif 101 < len(data) {\n\t\tv101 = data[101]\n\t}\n\tv102 := 0\n\tif 102 < len(data) {\n\t\tv102 = data[102]\n\t}\n\tv103 := 0\n\tif 103 < len(data) {\n\t\tv103 = data[103]\n\t}\n\tv104 := 0\n\tif 104 < len(data) {\n\t\tv104 = data[104]\n\t}\n\tv105 := 0\n\tif 105 < len(data) {\n\t\tv105 = data[105]\n\t}\n\tv106 := 0\n\tif 106 < len(data) {\n\t\tv106 = data[106]\n\t}\n\tv107 := 0\n\tif 107 < len(data) {\n\t\tv107 = data[107]\n\t}\n\tv108 := 0\n\tif 108 < len(data) {\n\t\tv108 = data[108]\n\t}\n\tv109 := 0\n\tif 109 < len(data) {\n\t\tv109 = data[109]\n\t}\n\tv110 := 0\n\tif 110 < len(data) {\n\t\tv110 = data[110]\n\t}\n\tv111 := 0\n\tif 111 < len(data) {\n\t\tv111 = data[111]\n\t}\n\tv112 := 0\n\tif 112 < len(data) {\n\t\tv112 = data[112]\n\t}\n\tv113 := 0\n\tif 113 < len(data) {\n\t\tv113 = data[113]\n\t}\n\tv114 := 0\n\tif 114 < len(data) {\n\t\tv114 = data[114]\n\t}\n\tv115 := 0\n\tif 115 < len(data) {\n\t\tv115 = data[115]\n\t}\n\tv116 := 0\n\tif 116 < len(data) {\n\t\tv116 = data[116]\n\t}\n\tv117 := 0\n\tif 117 < len(data) {\n\t\tv117 = data[117]\n\t}\n\tv118 := 0\n\tif 118 < len(data) {\n\t\tv118 = data[118]\n\t}\n\tv119 := 0\n\tif 119 < len(data) {\n\t\tv119 = data[119]\n\t}\n\tv120 := 0\n\tif 120 < len(data) {\n\t\tv120 = data[120]\n\t}\n\tv121 := 0\n\tif 121 < len(data) {\n\t\tv121 = data[121]\n\t}\n\tv122 := 0\n\tif 122 < len(data) {\n\t\tv122 = data[122]\n\t}\n\tv123 := 0\n\tif 123 < len(data) {\n\t\tv123 = data[123]\n\t}\n\tv124 := 0\n\tif 124 < len(data) {\n\t\tv124 = data[124]\n\t}\n\tv125 := 0\n\tif 125 < len(data) {\n\t\tv125 = data[125]\n\t}\n\tv126 := 0\n\tif 126 < len(data) {\n\t\tv126 = data[126]\n\t}\n\tv127 := 0\n\tif 127 < len(data) {\n\t\tv127 = data[127]\n\t}\n\tv128 := 0\n\tif 128 < len(data) {\n\t\tv128 = data[128]\n\t}\n\tv129 := 0\n\tif 129 < len(data) {\n\t\tv129 = data[129]\n\t}\n\tv130 := 0\n\tif 130 < len(data) {\n\t\tv130 = data[130]\n\t}\n\tv131 := 0\n\tif 131 < len(data) {\n\t\tv131 = data[131]\n\t}\n\tv132 := 0\n\tif 132 < len(data) {\n\t\tv132 = data[132]\n\t}\n\tv133 := 0\n\tif 133 < len(data) {\n\t\tv133 = data[133]\n\t}\n\tv134 := 0\n\tif 134 < len(data) {\n\t\tv134 = data[134]\n\t}\n\tv135 := 0\n\tif 135 < len(data) {\n\t\tv135 = data[135]\n\t}\n\tv136 := 0\n\tif 136 < len(data) {\n\t\tv136 = data[136]\n\t}\n\tv137 := 0\n\tif 137 < len(data) {\n\t\tv137 = data[137]\n\t}\n\tv138 := 0\n\tif 138 < len(data) {\n\t\tv138 = data[138]\n\t}\n\tv139 := 0\n\tif 139 < len(data) {\n\t\tv139 = data[139]\n\t}\n\tv140 := 0\n\tif 140 < len(data) {\n\t\tv140 = data[140]\n\t}\n\tv141 := 0\n\tif 141 < len(data) {\n\t\tv141 = data[141]\n\t}\n\tv142 := 0\n\tif 142 < len(data) {\n\t\tv142 = data[142]\n\t}\n\tv143 := 0\n\tif 143 < len(data) {\n\t\tv143 = data[143]\n\t}\n\tv144 := 0\n\tif 144 < len(data) {\n\t\tv144 = data[144]\n\t}\n\tv145 := 0\n\tif 145 < len(data) {\n\t\tv145 = data[145]\n\t}\n\tv146 := 0\n\tif 146 < len(data) {\n\t\tv146 = data[146]\n\t}\n\tv147 := 0\n\tif 147 < len(data) {\n\t\tv147 = data[147]\n\t}\n\tv148 := 0\n\tif 148 < len(data) {\n\t\tv148 = data[148]\n\t}\n\tv149 := 0\n\tif 149 < len(data) {\n\t\tv149 = data[149]", - "token_estimate": 917 + "token_estimate": 917, + "tokenized_korean_text": "} v 100 := 0 if 100 < len ( data ) { v 100 = data [ 100 ] } v 101 := 0 if 101 < len ( data ) { v 101 = data [ 101 ] } v 102 := 0 if 102 < len ( data ) { v 102 = data [ 102 ] } v 103 := 0 if 103 < len ( data ) { v 103 = data [ 103 ] } v 104 := 0 if 104 < len ( data ) { v 104 = data [ 104 ] } v 105 := 0 if 105 < len ( data ) { v 105 = data [ 105 ] } v 106 := 0 if 106 < len ( data ) { v 106 = data [ 106 ] } v 107 := 0 if 107 < len ( data ) { v 107 = data [ 107 ] } v 108 := 0 if 108 < len ( data ) { v 108 = data [ 108 ] } v 109 := 0 if 109 < len ( data ) { v 109 = data [ 109 ] } v 110 := 0 if 110 < len ( data ) { v 110 = data [ 110 ] } v 111 := 0 if 111 < len ( data ) { v 111 = data [ 111 ] } v 112 := 0 if 112 < len ( data ) { v 112 = data [ 112 ] } v 113 := 0 if 113 < len ( data ) { v 113 = data [ 113 ] } v 114 := 0 if 114 < len ( data ) { v 114 = data [ 114 ] } v 115 := 0 if 115 < len ( data ) { v 115 = data [ 115 ] } v 116 := 0 if 116 < len ( data ) { v 116 = data [ 116 ] } v 117 := 0 if 117 < len ( data ) { v 117 = data [ 117 ] } v 118 := 0 if 118 < len ( data ) { v 118 = data [ 118 ] } v 119 := 0 if 119 < len ( data ) { v 119 = data [ 119 ] } v 120 := 0 if 120 < len ( data ) { v 120 = data [ 120 ] } v 121 := 0 if 121 < len ( data ) { v 121 = data [ 121 ] } v 122 := 0 if 122 < len ( data ) { v 122 = data [ 122 ] } v 123 := 0 if 123 < len ( data ) { v 123 = data [ 123 ] } v 124 := 0 if 124 < len ( data ) { v 124 = data [ 124 ] } v 125 := 0 if 125 < len ( data ) { v 125 = data [ 125 ] } v 126 := 0 if 126 < len ( data ) { v 126 = data [ 126 ] } v 127 := 0 if 127 < len ( data ) { v 127 = data [ 127 ] } v 128 := 0 if 128 < len ( data ) { v 128 = data [ 128 ] } v 129 := 0 if 129 < len ( data ) { v 129 = data [ 129 ] } v 130 := 0 if 130 < len ( data ) { v 130 = data [ 130 ] } v 131 := 0 if 131 < len ( data ) { v 131 = data [ 131 ] } v 132 := 0 if 132 < len ( data ) { v 132 = data [ 132 ] } v 133 := 0 if 133 < len ( data ) { v 133 = data [ 133 ] } v 134 := 0 if 134 < len ( data ) { v 134 = data [ 134 ] } v 135 := 0 if 135 < len ( data ) { v 135 = data [ 135 ] } v 136 := 0 if 136 < len ( data ) { v 136 = data [ 136 ] } v 137 := 0 if 137 < len ( data ) { v 137 = data [ 137 ] } v 138 := 0 if 138 < len ( data ) { v 138 = data [ 138 ] } v 139 := 0 if 139 < len ( data ) { v 139 = data [ 139 ] } v 140 := 0 if 140 < len ( data ) { v 140 = data [ 140 ] } v 141 := 0 if 141 < len ( data ) { v 141 = data [ 141 ] } v 142 := 0 if 142 < len ( data ) { v 142 = data [ 142 ] } v 143 := 0 if 143 < len ( data ) { v 143 = data [ 143 ] } v 144 := 0 if 144 < len ( data ) { v 144 = data [ 144 ] } v 145 := 0 if 145 < len ( data ) { v 145 = data [ 145 ] } v 146 := 0 if 146 < len ( data ) { v 146 = data [ 146 ] } v 147 := 0 if 147 < len ( data ) { v 147 = data [ 147 ] } v 148 := 0 if 148 < len ( data ) { v 148 = data [ 148 ] } v 149 := 0 if 149 < len ( data ) { v 149 = data [ 149 ]" }, { "block_ids": [ @@ -207,7 +216,8 @@ } ], "text": "\t}\n\tv150 := 0\n\tif 150 < len(data) {\n\t\tv150 = data[150]\n\t}\n\tv151 := 0\n\tif 151 < len(data) {\n\t\tv151 = data[151]\n\t}\n\tv152 := 0\n\tif 152 < len(data) {\n\t\tv152 = data[152]\n\t}\n\tv153 := 0\n\tif 153 < len(data) {\n\t\tv153 = data[153]\n\t}\n\tv154 := 0\n\tif 154 < len(data) {\n\t\tv154 = data[154]\n\t}\n\tv155 := 0\n\tif 155 < len(data) {\n\t\tv155 = data[155]\n\t}\n\tv156 := 0\n\tif 156 < len(data) {\n\t\tv156 = data[156]\n\t}\n\tv157 := 0\n\tif 157 < len(data) {\n\t\tv157 = data[157]\n\t}\n\tv158 := 0\n\tif 158 < len(data) {\n\t\tv158 = data[158]\n\t}\n\tv159 := 0\n\tif 159 < len(data) {\n\t\tv159 = data[159]\n\t}\n\tv160 := 0\n\tif 160 < len(data) {\n\t\tv160 = data[160]\n\t}\n\tv161 := 0\n\tif 161 < len(data) {\n\t\tv161 = data[161]\n\t}\n\tv162 := 0\n\tif 162 < len(data) {\n\t\tv162 = data[162]\n\t}\n\tv163 := 0\n\tif 163 < len(data) {\n\t\tv163 = data[163]\n\t}\n\tv164 := 0\n\tif 164 < len(data) {\n\t\tv164 = data[164]\n\t}\n\tv165 := 0\n\tif 165 < len(data) {\n\t\tv165 = data[165]\n\t}\n\tv166 := 0\n\tif 166 < len(data) {\n\t\tv166 = data[166]\n\t}\n\tv167 := 0\n\tif 167 < len(data) {\n\t\tv167 = data[167]\n\t}\n\tv168 := 0\n\tif 168 < len(data) {\n\t\tv168 = data[168]\n\t}\n\tv169 := 0\n\tif 169 < len(data) {\n\t\tv169 = data[169]\n\t}\n\tv170 := 0\n\tif 170 < len(data) {\n\t\tv170 = data[170]\n\t}\n\tv171 := 0\n\tif 171 < len(data) {\n\t\tv171 = data[171]\n\t}\n\tv172 := 0\n\tif 172 < len(data) {\n\t\tv172 = data[172]\n\t}\n\tv173 := 0\n\tif 173 < len(data) {\n\t\tv173 = data[173]\n\t}\n\tv174 := 0\n\tif 174 < len(data) {\n\t\tv174 = data[174]\n\t}\n\tv175 := 0\n\tif 175 < len(data) {\n\t\tv175 = data[175]\n\t}\n\tv176 := 0\n\tif 176 < len(data) {\n\t\tv176 = data[176]\n\t}\n\tv177 := 0\n\tif 177 < len(data) {\n\t\tv177 = data[177]\n\t}\n\tv178 := 0\n\tif 178 < len(data) {\n\t\tv178 = data[178]\n\t}\n\tv179 := 0\n\tif 179 < len(data) {\n\t\tv179 = data[179]\n\t}\n\tv180 := 0\n\tif 180 < len(data) {\n\t\tv180 = data[180]\n\t}\n\tv181 := 0\n\tif 181 < len(data) {\n\t\tv181 = data[181]\n\t}\n\tv182 := 0\n\tif 182 < len(data) {\n\t\tv182 = data[182]\n\t}\n\tv183 := 0\n\tif 183 < len(data) {\n\t\tv183 = data[183]\n\t}\n\tv184 := 0\n\tif 184 < len(data) {\n\t\tv184 = data[184]\n\t}\n\tv185 := 0\n\tif 185 < len(data) {\n\t\tv185 = data[185]\n\t}\n\tv186 := 0\n\tif 186 < len(data) {\n\t\tv186 = data[186]\n\t}\n\tv187 := 0\n\tif 187 < len(data) {\n\t\tv187 = data[187]\n\t}\n\tv188 := 0\n\tif 188 < len(data) {\n\t\tv188 = data[188]\n\t}\n\tv189 := 0\n\tif 189 < len(data) {\n\t\tv189 = data[189]\n\t}\n\tv190 := 0\n\tif 190 < len(data) {\n\t\tv190 = data[190]\n\t}\n\tv191 := 0\n\tif 191 < len(data) {\n\t\tv191 = data[191]\n\t}\n\tv192 := 0\n\tif 192 < len(data) {\n\t\tv192 = data[192]\n\t}\n\tv193 := 0\n\tif 193 < len(data) {\n\t\tv193 = data[193]\n\t}\n\tv194 := 0\n\tif 194 < len(data) {\n\t\tv194 = data[194]\n\t}\n\tv195 := 0\n\tif 195 < len(data) {\n\t\tv195 = data[195]\n\t}\n\tv196 := 0\n\tif 196 < len(data) {\n\t\tv196 = data[196]\n\t}\n\tv197 := 0\n\tif 197 < len(data) {\n\t\tv197 = data[197]\n\t}\n\tv198 := 0\n\tif 198 < len(data) {\n\t\tv198 = data[198]\n\t}\n\tv199 := 0\n\tif 199 < len(data) {\n\t\tv199 = data[199]", - "token_estimate": 917 + "token_estimate": 917, + "tokenized_korean_text": "} v 150 := 0 if 150 < len ( data ) { v 150 = data [ 150 ] } v 151 := 0 if 151 < len ( data ) { v 151 = data [ 151 ] } v 152 := 0 if 152 < len ( data ) { v 152 = data [ 152 ] } v 153 := 0 if 153 < len ( data ) { v 153 = data [ 153 ] } v 154 := 0 if 154 < len ( data ) { v 154 = data [ 154 ] } v 155 := 0 if 155 < len ( data ) { v 155 = data [ 155 ] } v 156 := 0 if 156 < len ( data ) { v 156 = data [ 156 ] } v 157 := 0 if 157 < len ( data ) { v 157 = data [ 157 ] } v 158 := 0 if 158 < len ( data ) { v 158 = data [ 158 ] } v 159 := 0 if 159 < len ( data ) { v 159 = data [ 159 ] } v 160 := 0 if 160 < len ( data ) { v 160 = data [ 160 ] } v 161 := 0 if 161 < len ( data ) { v 161 = data [ 161 ] } v 162 := 0 if 162 < len ( data ) { v 162 = data [ 162 ] } v 163 := 0 if 163 < len ( data ) { v 163 = data [ 163 ] } v 164 := 0 if 164 < len ( data ) { v 164 = data [ 164 ] } v 165 := 0 if 165 < len ( data ) { v 165 = data [ 165 ] } v 166 := 0 if 166 < len ( data ) { v 166 = data [ 166 ] } v 167 := 0 if 167 < len ( data ) { v 167 = data [ 167 ] } v 168 := 0 if 168 < len ( data ) { v 168 = data [ 168 ] } v 169 := 0 if 169 < len ( data ) { v 169 = data [ 169 ] } v 170 := 0 if 170 < len ( data ) { v 170 = data [ 170 ] } v 171 := 0 if 171 < len ( data ) { v 171 = data [ 171 ] } v 172 := 0 if 172 < len ( data ) { v 172 = data [ 172 ] } v 173 := 0 if 173 < len ( data ) { v 173 = data [ 173 ] } v 174 := 0 if 174 < len ( data ) { v 174 = data [ 174 ] } v 175 := 0 if 175 < len ( data ) { v 175 = data [ 175 ] } v 176 := 0 if 176 < len ( data ) { v 176 = data [ 176 ] } v 177 := 0 if 177 < len ( data ) { v 177 = data [ 177 ] } v 178 := 0 if 178 < len ( data ) { v 178 = data [ 178 ] } v 179 := 0 if 179 < len ( data ) { v 179 = data [ 179 ] } v 180 := 0 if 180 < len ( data ) { v 180 = data [ 180 ] } v 181 := 0 if 181 < len ( data ) { v 181 = data [ 181 ] } v 182 := 0 if 182 < len ( data ) { v 182 = data [ 182 ] } v 183 := 0 if 183 < len ( data ) { v 183 = data [ 183 ] } v 184 := 0 if 184 < len ( data ) { v 184 = data [ 184 ] } v 185 := 0 if 185 < len ( data ) { v 185 = data [ 185 ] } v 186 := 0 if 186 < len ( data ) { v 186 = data [ 186 ] } v 187 := 0 if 187 < len ( data ) { v 187 = data [ 187 ] } v 188 := 0 if 188 < len ( data ) { v 188 = data [ 188 ] } v 189 := 0 if 189 < len ( data ) { v 189 = data [ 189 ] } v 190 := 0 if 190 < len ( data ) { v 190 = data [ 190 ] } v 191 := 0 if 191 < len ( data ) { v 191 = data [ 191 ] } v 192 := 0 if 192 < len ( data ) { v 192 = data [ 192 ] } v 193 := 0 if 193 < len ( data ) { v 193 = data [ 193 ] } v 194 := 0 if 194 < len ( data ) { v 194 = data [ 194 ] } v 195 := 0 if 195 < len ( data ) { v 195 = data [ 195 ] } v 196 := 0 if 196 < len ( data ) { v 196 = data [ 196 ] } v 197 := 0 if 197 < len ( data ) { v 197 = data [ 197 ] } v 198 := 0 if 198 < len ( data ) { v 198 = data [ 198 ] } v 199 := 0 if 199 < len ( data ) { v 199 = data [ 199 ]" }, { "block_ids": [ @@ -228,6 +238,7 @@ } ], "text": "\t}\n\tv200 := 0\n\tif 200 < len(data) {\n\t\tv200 = data[200]\n\t}\n\tv201 := 0\n\tif 201 < len(data) {\n\t\tv201 = data[201]\n\t}\n\tv202 := 0\n\tif 202 < len(data) {\n\t\tv202 = data[202]\n\t}\n\tv203 := 0\n\tif 203 < len(data) {\n\t\tv203 = data[203]\n\t}\n\tv204 := 0\n\tif 204 < len(data) {\n\t\tv204 = data[204]\n\t}\n\tv205 := 0\n\tif 205 < len(data) {\n\t\tv205 = data[205]\n\t}\n\tv206 := 0\n\tif 206 < len(data) {\n\t\tv206 = data[206]\n\t}\n\tv207 := 0\n\tif 207 < len(data) {\n\t\tv207 = data[207]\n\t}\n\tv208 := 0\n\tif 208 < len(data) {\n\t\tv208 = data[208]\n\t}\n\tv209 := 0\n\tif 209 < len(data) {\n\t\tv209 = data[209]\n\t}\n\treturn len(data)\n}", - "token_estimate": 191 + "token_estimate": 191, + "tokenized_korean_text": "} v 200 := 0 if 200 < len ( data ) { v 200 = data [ 200 ] } v 201 := 0 if 201 < len ( data ) { v 201 = data [ 201 ] } v 202 := 0 if 202 < len ( data ) { v 202 = data [ 202 ] } v 203 := 0 if 203 < len ( data ) { v 203 = data [ 203 ] } v 204 := 0 if 204 < len ( data ) { v 204 = data [ 204 ] } v 205 := 0 if 205 < len ( data ) { v 205 = data [ 205 ] } v 206 := 0 if 206 < len ( data ) { v 206 = data [ 206 ] } v 207 := 0 if 207 < len ( data ) { v 207 = data [ 207 ] } v 208 := 0 if 208 < len ( data ) { v 208 = data [ 208 ] } v 209 := 0 if 209 < len ( data ) { v 209 = data [ 209 ] } return len ( data ) }" } ] diff --git a/crates/kebab-chunk/tests/fixtures/code-sample.java.chunks.snapshot.json b/crates/kebab-chunk/tests/fixtures/code-sample.java.chunks.snapshot.json index e42d8d0..b5205c1 100644 --- a/crates/kebab-chunk/tests/fixtures/code-sample.java.chunks.snapshot.json +++ b/crates/kebab-chunk/tests/fixtures/code-sample.java.chunks.snapshot.json @@ -18,7 +18,8 @@ } ], "text": "import java.util.List;\nimport java.util.Map;\nimport java.util.ArrayList;\nimport java.util.HashMap;\nimport java.util.stream.Collectors;", - "token_estimate": 45 + "token_estimate": 45, + "tokenized_korean_text": "import java . util . List ; import java . util . Map ; import java . util . ArrayList ; import java . util . HashMap ; import java . util . stream . Collectors ;" }, { "block_ids": [ @@ -39,7 +40,8 @@ } ], "text": "public static double computeMRR(List scores) {\n if (scores.isEmpty()) {\n return 0.0;\n }\n return 1.0 / scores.size();\n}", - "token_estimate": 48 + "token_estimate": 48, + "tokenized_korean_text": "public static double computeMRR ( List < Double > scores ) { if ( scores . isEmpty ( ) ) { return 0 . 0 ; } return 1 . 0 / scores . size (); }" }, { "block_ids": [ @@ -60,7 +62,8 @@ } ], "text": "public class MetricsCollector {\n private List scores;\n private List labels;\n private Map counts;\n private Map totals;\n private List tags;\n}", - "token_estimate": 71 + "token_estimate": 71, + "tokenized_korean_text": "public class MetricsCollector { private List < Double > scores ; private List < String > labels ; private Map < String , Integer > counts ; private Map < String , Double > totals ; private List < String > tags ; }" }, { "block_ids": [ @@ -81,7 +84,8 @@ } ], "text": "public class BaseEvaluator {\n private String name;\n\n public BaseEvaluator(String name) {\n this.name = name;\n }\n\n public void evaluate(List data) throws Exception {\n String joined = String.join(\",\", data);\n }\n}", - "token_estimate": 82 + "token_estimate": 82, + "tokenized_korean_text": "public class BaseEvaluator { private String name ; public BaseEvaluator ( String name ) { this . name = name ; } public void evaluate ( List < String > data ) throws Exception { String joined = String . join (\",\", data ); } }" }, { "block_ids": [ @@ -102,7 +106,8 @@ } ], "text": "public void run(List inputs) {\n for (Double inp : inputs) {\n scores.add(\n inp\n );\n }\n}", - "token_estimate": 42 + "token_estimate": 42, + "tokenized_korean_text": "public void run ( List < Double > inputs ) { for ( Double inp : inputs ) { scores . add ( inp ); } }" }, { "block_ids": [ @@ -123,7 +128,8 @@ } ], "text": "public Map report() {\n Map result = new HashMap<>();\n result.put(\"mean\", 0.0);\n result.put(\"count\", scores.size());\n result.put(\"tags\", tags);\n return result;\n}", - "token_estimate": 69 + "token_estimate": 69, + "tokenized_korean_text": "public Map < String , Object > report ( ) { Map < String , Object > result = new HashMap <>(); result . put (\" mean \", 0 . 0 ); result . put (\" count \", scores . size ()); result . put (\" tags \", tags ); return result ; }" }, { "block_ids": [ @@ -144,7 +150,8 @@ } ], "text": "public class BigCompute {\n public int compute(int[] data) {\n int v0 = 0 < data.length ? data[0] : 0;\n int v1 = 1 < data.length ? data[1] : 0;\n int v2 = 2 < data.length ? data[2] : 0;\n int v3 = 3 < data.length ? data[3] : 0;\n int v4 = 4 < data.length ? data[4] : 0;\n int v5 = 5 < data.length ? data[5] : 0;\n int v6 = 6 < data.length ? data[6] : 0;\n int v7 = 7 < data.length ? data[7] : 0;\n int v8 = 8 < data.length ? data[8] : 0;\n int v9 = 9 < data.length ? data[9] : 0;\n int v10 = 10 < data.length ? data[10] : 0;\n int v11 = 11 < data.length ? data[11] : 0;\n int v12 = 12 < data.length ? data[12] : 0;\n int v13 = 13 < data.length ? data[13] : 0;\n int v14 = 14 < data.length ? data[14] : 0;\n int v15 = 15 < data.length ? data[15] : 0;\n int v16 = 16 < data.length ? data[16] : 0;\n int v17 = 17 < data.length ? data[17] : 0;\n int v18 = 18 < data.length ? data[18] : 0;\n int v19 = 19 < data.length ? data[19] : 0;\n int v20 = 20 < data.length ? data[20] : 0;\n int v21 = 21 < data.length ? data[21] : 0;\n int v22 = 22 < data.length ? data[22] : 0;\n int v23 = 23 < data.length ? data[23] : 0;\n int v24 = 24 < data.length ? data[24] : 0;\n int v25 = 25 < data.length ? data[25] : 0;\n int v26 = 26 < data.length ? data[26] : 0;\n int v27 = 27 < data.length ? data[27] : 0;\n int v28 = 28 < data.length ? data[28] : 0;\n int v29 = 29 < data.length ? data[29] : 0;\n int v30 = 30 < data.length ? data[30] : 0;\n int v31 = 31 < data.length ? data[31] : 0;\n int v32 = 32 < data.length ? data[32] : 0;\n int v33 = 33 < data.length ? data[33] : 0;\n int v34 = 34 < data.length ? data[34] : 0;\n int v35 = 35 < data.length ? data[35] : 0;\n int v36 = 36 < data.length ? data[36] : 0;\n int v37 = 37 < data.length ? data[37] : 0;\n int v38 = 38 < data.length ? data[38] : 0;\n int v39 = 39 < data.length ? data[39] : 0;\n int v40 = 40 < data.length ? data[40] : 0;\n int v41 = 41 < data.length ? data[41] : 0;\n int v42 = 42 < data.length ? data[42] : 0;\n int v43 = 43 < data.length ? data[43] : 0;\n int v44 = 44 < data.length ? data[44] : 0;\n int v45 = 45 < data.length ? data[45] : 0;\n int v46 = 46 < data.length ? data[46] : 0;\n int v47 = 47 < data.length ? data[47] : 0;\n int v48 = 48 < data.length ? data[48] : 0;\n int v49 = 49 < data.length ? data[49] : 0;\n int v50 = 50 < data.length ? data[50] : 0;\n int v51 = 51 < data.length ? data[51] : 0;\n int v52 = 52 < data.length ? data[52] : 0;\n int v53 = 53 < data.length ? data[53] : 0;\n int v54 = 54 < data.length ? data[54] : 0;\n int v55 = 55 < data.length ? data[55] : 0;\n int v56 = 56 < data.length ? data[56] : 0;\n int v57 = 57 < data.length ? data[57] : 0;\n int v58 = 58 < data.length ? data[58] : 0;\n int v59 = 59 < data.length ? data[59] : 0;\n int v60 = 60 < data.length ? data[60] : 0;\n int v61 = 61 < data.length ? data[61] : 0;\n int v62 = 62 < data.length ? data[62] : 0;\n int v63 = 63 < data.length ? data[63] : 0;\n int v64 = 64 < data.length ? data[64] : 0;\n int v65 = 65 < data.length ? data[65] : 0;\n int v66 = 66 < data.length ? data[66] : 0;\n int v67 = 67 < data.length ? data[67] : 0;\n int v68 = 68 < data.length ? data[68] : 0;\n int v69 = 69 < data.length ? data[69] : 0;\n int v70 = 70 < data.length ? data[70] : 0;\n int v71 = 71 < data.length ? data[71] : 0;\n int v72 = 72 < data.length ? data[72] : 0;\n int v73 = 73 < data.length ? data[73] : 0;\n int v74 = 74 < data.length ? data[74] : 0;\n int v75 = 75 < data.length ? data[75] : 0;\n int v76 = 76 < data.length ? data[76] : 0;\n int v77 = 77 < data.length ? data[77] : 0;\n int v78 = 78 < data.length ? data[78] : 0;\n int v79 = 79 < data.length ? data[79] : 0;\n int v80 = 80 < data.length ? data[80] : 0;\n int v81 = 81 < data.length ? data[81] : 0;\n int v82 = 82 < data.length ? data[82] : 0;\n int v83 = 83 < data.length ? data[83] : 0;\n int v84 = 84 < data.length ? data[84] : 0;\n int v85 = 85 < data.length ? data[85] : 0;\n int v86 = 86 < data.length ? data[86] : 0;\n int v87 = 87 < data.length ? data[87] : 0;\n int v88 = 88 < data.length ? data[88] : 0;\n int v89 = 89 < data.length ? data[89] : 0;\n int v90 = 90 < data.length ? data[90] : 0;\n int v91 = 91 < data.length ? data[91] : 0;\n int v92 = 92 < data.length ? data[92] : 0;\n int v93 = 93 < data.length ? data[93] : 0;\n int v94 = 94 < data.length ? data[94] : 0;\n int v95 = 95 < data.length ? data[95] : 0;\n int v96 = 96 < data.length ? data[96] : 0;\n int v97 = 97 < data.length ? data[97] : 0;\n int v98 = 98 < data.length ? data[98] : 0;\n int v99 = 99 < data.length ? data[99] : 0;\n int v100 = 100 < data.length ? data[100] : 0;\n int v101 = 101 < data.length ? data[101] : 0;\n int v102 = 102 < data.length ? data[102] : 0;\n int v103 = 103 < data.length ? data[103] : 0;\n int v104 = 104 < data.length ? data[104] : 0;\n int v105 = 105 < data.length ? data[105] : 0;\n int v106 = 106 < data.length ? data[106] : 0;\n int v107 = 107 < data.length ? data[107] : 0;\n int v108 = 108 < data.length ? data[108] : 0;\n int v109 = 109 < data.length ? data[109] : 0;\n int v110 = 110 < data.length ? data[110] : 0;\n int v111 = 111 < data.length ? data[111] : 0;\n int v112 = 112 < data.length ? data[112] : 0;\n int v113 = 113 < data.length ? data[113] : 0;\n int v114 = 114 < data.length ? data[114] : 0;\n int v115 = 115 < data.length ? data[115] : 0;\n int v116 = 116 < data.length ? data[116] : 0;\n int v117 = 117 < data.length ? data[117] : 0;\n int v118 = 118 < data.length ? data[118] : 0;\n int v119 = 119 < data.length ? data[119] : 0;\n int v120 = 120 < data.length ? data[120] : 0;\n int v121 = 121 < data.length ? data[121] : 0;\n int v122 = 122 < data.length ? data[122] : 0;\n int v123 = 123 < data.length ? data[123] : 0;\n int v124 = 124 < data.length ? data[124] : 0;\n int v125 = 125 < data.length ? data[125] : 0;\n int v126 = 126 < data.length ? data[126] : 0;\n int v127 = 127 < data.length ? data[127] : 0;\n int v128 = 128 < data.length ? data[128] : 0;\n int v129 = 129 < data.length ? data[129] : 0;\n int v130 = 130 < data.length ? data[130] : 0;\n int v131 = 131 < data.length ? data[131] : 0;\n int v132 = 132 < data.length ? data[132] : 0;\n int v133 = 133 < data.length ? data[133] : 0;\n int v134 = 134 < data.length ? data[134] : 0;\n int v135 = 135 < data.length ? data[135] : 0;\n int v136 = 136 < data.length ? data[136] : 0;\n int v137 = 137 < data.length ? data[137] : 0;\n int v138 = 138 < data.length ? data[138] : 0;\n int v139 = 139 < data.length ? data[139] : 0;\n int v140 = 140 < data.length ? data[140] : 0;\n int v141 = 141 < data.length ? data[141] : 0;\n int v142 = 142 < data.length ? data[142] : 0;\n int v143 = 143 < data.length ? data[143] : 0;\n int v144 = 144 < data.length ? data[144] : 0;\n int v145 = 145 < data.length ? data[145] : 0;\n int v146 = 146 < data.length ? data[146] : 0;\n int v147 = 147 < data.length ? data[147] : 0;\n int v148 = 148 < data.length ? data[148] : 0;\n int v149 = 149 < data.length ? data[149] : 0;\n int v150 = 150 < data.length ? data[150] : 0;\n int v151 = 151 < data.length ? data[151] : 0;\n int v152 = 152 < data.length ? data[152] : 0;\n int v153 = 153 < data.length ? data[153] : 0;\n int v154 = 154 < data.length ? data[154] : 0;\n int v155 = 155 < data.length ? data[155] : 0;\n int v156 = 156 < data.length ? data[156] : 0;\n int v157 = 157 < data.length ? data[157] : 0;\n int v158 = 158 < data.length ? data[158] : 0;\n int v159 = 159 < data.length ? data[159] : 0;\n int v160 = 160 < data.length ? data[160] : 0;\n int v161 = 161 < data.length ? data[161] : 0;\n int v162 = 162 < data.length ? data[162] : 0;\n int v163 = 163 < data.length ? data[163] : 0;\n int v164 = 164 < data.length ? data[164] : 0;\n int v165 = 165 < data.length ? data[165] : 0;\n int v166 = 166 < data.length ? data[166] : 0;\n int v167 = 167 < data.length ? data[167] : 0;\n int v168 = 168 < data.length ? data[168] : 0;\n int v169 = 169 < data.length ? data[169] : 0;\n int v170 = 170 < data.length ? data[170] : 0;\n int v171 = 171 < data.length ? data[171] : 0;\n int v172 = 172 < data.length ? data[172] : 0;\n int v173 = 173 < data.length ? data[173] : 0;\n int v174 = 174 < data.length ? data[174] : 0;\n int v175 = 175 < data.length ? data[175] : 0;\n int v176 = 176 < data.length ? data[176] : 0;\n int v177 = 177 < data.length ? data[177] : 0;\n int v178 = 178 < data.length ? data[178] : 0;\n int v179 = 179 < data.length ? data[179] : 0;\n int v180 = 180 < data.length ? data[180] : 0;\n int v181 = 181 < data.length ? data[181] : 0;\n int v182 = 182 < data.length ? data[182] : 0;\n int v183 = 183 < data.length ? data[183] : 0;\n int v184 = 184 < data.length ? data[184] : 0;\n int v185 = 185 < data.length ? data[185] : 0;\n int v186 = 186 < data.length ? data[186] : 0;\n int v187 = 187 < data.length ? data[187] : 0;\n int v188 = 188 < data.length ? data[188] : 0;\n int v189 = 189 < data.length ? data[189] : 0;\n int v190 = 190 < data.length ? data[190] : 0;\n int v191 = 191 < data.length ? data[191] : 0;\n int v192 = 192 < data.length ? data[192] : 0;\n int v193 = 193 < data.length ? data[193] : 0;\n int v194 = 194 < data.length ? data[194] : 0;\n int v195 = 195 < data.length ? data[195] : 0;\n int v196 = 196 < data.length ? data[196] : 0;\n int v197 = 197 < data.length ? data[197] : 0;", - "token_estimate": 3475 + "token_estimate": 3475, + "tokenized_korean_text": "public class BigCompute { public int compute ( int [ ] data ) { int v 0 = 0 < data . length ? data [ 0 ] : 0 ; int v 1 = 1 < data . length ? data [ 1 ] : 0 ; int v 2 = 2 < data . length ? data [ 2 ] : 0 ; int v 3 = 3 < data . length ? data [ 3 ] : 0 ; int v 4 = 4 < data . length ? data [ 4 ] : 0 ; int v 5 = 5 < data . length ? data [ 5 ] : 0 ; int v 6 = 6 < data . length ? data [ 6 ] : 0 ; int v 7 = 7 < data . length ? data [ 7 ] : 0 ; int v 8 = 8 < data . length ? data [ 8 ] : 0 ; int v 9 = 9 < data . length ? data [ 9 ] : 0 ; int v 10 = 10 < data . length ? data [ 10 ] : 0 ; int v 11 = 11 < data . length ? data [ 11 ] : 0 ; int v 12 = 12 < data . length ? data [ 12 ] : 0 ; int v 13 = 13 < data . length ? data [ 13 ] : 0 ; int v 14 = 14 < data . length ? data [ 14 ] : 0 ; int v 15 = 15 < data . length ? data [ 15 ] : 0 ; int v 16 = 16 < data . length ? data [ 16 ] : 0 ; int v 17 = 17 < data . length ? data [ 17 ] : 0 ; int v 18 = 18 < data . length ? data [ 18 ] : 0 ; int v 19 = 19 < data . length ? data [ 19 ] : 0 ; int v 20 = 20 < data . length ? data [ 20 ] : 0 ; int v 21 = 21 < data . length ? data [ 21 ] : 0 ; int v 22 = 22 < data . length ? data [ 22 ] : 0 ; int v 23 = 23 < data . length ? data [ 23 ] : 0 ; int v 24 = 24 < data . length ? data [ 24 ] : 0 ; int v 25 = 25 < data . length ? data [ 25 ] : 0 ; int v 26 = 26 < data . length ? data [ 26 ] : 0 ; int v 27 = 27 < data . length ? data [ 27 ] : 0 ; int v 28 = 28 < data . length ? data [ 28 ] : 0 ; int v 29 = 29 < data . length ? data [ 29 ] : 0 ; int v 30 = 30 < data . length ? data [ 30 ] : 0 ; int v 31 = 31 < data . length ? data [ 31 ] : 0 ; int v 32 = 32 < data . length ? data [ 32 ] : 0 ; int v 33 = 33 < data . length ? data [ 33 ] : 0 ; int v 34 = 34 < data . length ? data [ 34 ] : 0 ; int v 35 = 35 < data . length ? data [ 35 ] : 0 ; int v 36 = 36 < data . length ? data [ 36 ] : 0 ; int v 37 = 37 < data . length ? data [ 37 ] : 0 ; int v 38 = 38 < data . length ? data [ 38 ] : 0 ; int v 39 = 39 < data . length ? data [ 39 ] : 0 ; int v 40 = 40 < data . length ? data [ 40 ] : 0 ; int v 41 = 41 < data . length ? data [ 41 ] : 0 ; int v 42 = 42 < data . length ? data [ 42 ] : 0 ; int v 43 = 43 < data . length ? data [ 43 ] : 0 ; int v 44 = 44 < data . length ? data [ 44 ] : 0 ; int v 45 = 45 < data . length ? data [ 45 ] : 0 ; int v 46 = 46 < data . length ? data [ 46 ] : 0 ; int v 47 = 47 < data . length ? data [ 47 ] : 0 ; int v 48 = 48 < data . length ? data [ 48 ] : 0 ; int v 49 = 49 < data . length ? data [ 49 ] : 0 ; int v 50 = 50 < data . length ? data [ 50 ] : 0 ; int v 51 = 51 < data . length ? data [ 51 ] : 0 ; int v 52 = 52 < data . length ? data [ 52 ] : 0 ; int v 53 = 53 < data . length ? data [ 53 ] : 0 ; int v 54 = 54 < data . length ? data [ 54 ] : 0 ; int v 55 = 55 < data . length ? data [ 55 ] : 0 ; int v 56 = 56 < data . length ? data [ 56 ] : 0 ; int v 57 = 57 < data . length ? data [ 57 ] : 0 ; int v 58 = 58 < data . length ? data [ 58 ] : 0 ; int v 59 = 59 < data . length ? data [ 59 ] : 0 ; int v 60 = 60 < data . length ? data [ 60 ] : 0 ; int v 61 = 61 < data . length ? data [ 61 ] : 0 ; int v 62 = 62 < data . length ? data [ 62 ] : 0 ; int v 63 = 63 < data . length ? data [ 63 ] : 0 ; int v 64 = 64 < data . length ? data [ 64 ] : 0 ; int v 65 = 65 < data . length ? data [ 65 ] : 0 ; int v 66 = 66 < data . length ? data [ 66 ] : 0 ; int v 67 = 67 < data . length ? data [ 67 ] : 0 ; int v 68 = 68 < data . length ? data [ 68 ] : 0 ; int v 69 = 69 < data . length ? data [ 69 ] : 0 ; int v 70 = 70 < data . length ? data [ 70 ] : 0 ; int v 71 = 71 < data . length ? data [ 71 ] : 0 ; int v 72 = 72 < data . length ? data [ 72 ] : 0 ; int v 73 = 73 < data . length ? data [ 73 ] : 0 ; int v 74 = 74 < data . length ? data [ 74 ] : 0 ; int v 75 = 75 < data . length ? data [ 75 ] : 0 ; int v 76 = 76 < data . length ? data [ 76 ] : 0 ; int v 77 = 77 < data . length ? data [ 77 ] : 0 ; int v 78 = 78 < data . length ? data [ 78 ] : 0 ; int v 79 = 79 < data . length ? data [ 79 ] : 0 ; int v 80 = 80 < data . length ? data [ 80 ] : 0 ; int v 81 = 81 < data . length ? data [ 81 ] : 0 ; int v 82 = 82 < data . length ? data [ 82 ] : 0 ; int v 83 = 83 < data . length ? data [ 83 ] : 0 ; int v 84 = 84 < data . length ? data [ 84 ] : 0 ; int v 85 = 85 < data . length ? data [ 85 ] : 0 ; int v 86 = 86 < data . length ? data [ 86 ] : 0 ; int v 87 = 87 < data . length ? data [ 87 ] : 0 ; int v 88 = 88 < data . length ? data [ 88 ] : 0 ; int v 89 = 89 < data . length ? data [ 89 ] : 0 ; int v 90 = 90 < data . length ? data [ 90 ] : 0 ; int v 91 = 91 < data . length ? data [ 91 ] : 0 ; int v 92 = 92 < data . length ? data [ 92 ] : 0 ; int v 93 = 93 < data . length ? data [ 93 ] : 0 ; int v 94 = 94 < data . length ? data [ 94 ] : 0 ; int v 95 = 95 < data . length ? data [ 95 ] : 0 ; int v 96 = 96 < data . length ? data [ 96 ] : 0 ; int v 97 = 97 < data . length ? data [ 97 ] : 0 ; int v 98 = 98 < data . length ? data [ 98 ] : 0 ; int v 99 = 99 < data . length ? data [ 99 ] : 0 ; int v 100 = 100 < data . length ? data [ 100 ] : 0 ; int v 101 = 101 < data . length ? data [ 101 ] : 0 ; int v 102 = 102 < data . length ? data [ 102 ] : 0 ; int v 103 = 103 < data . length ? data [ 103 ] : 0 ; int v 104 = 104 < data . length ? data [ 104 ] : 0 ; int v 105 = 105 < data . length ? data [ 105 ] : 0 ; int v 106 = 106 < data . length ? data [ 106 ] : 0 ; int v 107 = 107 < data . length ? data [ 107 ] : 0 ; int v 108 = 108 < data . length ? data [ 108 ] : 0 ; int v 109 = 109 < data . length ? data [ 109 ] : 0 ; int v 110 = 110 < data . length ? data [ 110 ] : 0 ; int v 111 = 111 < data . length ? data [ 111 ] : 0 ; int v 112 = 112 < data . length ? data [ 112 ] : 0 ; int v 113 = 113 < data . length ? data [ 113 ] : 0 ; int v 114 = 114 < data . length ? data [ 114 ] : 0 ; int v 115 = 115 < data . length ? data [ 115 ] : 0 ; int v 116 = 116 < data . length ? data [ 116 ] : 0 ; int v 117 = 117 < data . length ? data [ 117 ] : 0 ; int v 118 = 118 < data . length ? data [ 118 ] : 0 ; int v 119 = 119 < data . length ? data [ 119 ] : 0 ; int v 120 = 120 < data . length ? data [ 120 ] : 0 ; int v 121 = 121 < data . length ? data [ 121 ] : 0 ; int v 122 = 122 < data . length ? data [ 122 ] : 0 ; int v 123 = 123 < data . length ? data [ 123 ] : 0 ; int v 124 = 124 < data . length ? data [ 124 ] : 0 ; int v 125 = 125 < data . length ? data [ 125 ] : 0 ; int v 126 = 126 < data . length ? data [ 126 ] : 0 ; int v 127 = 127 < data . length ? data [ 127 ] : 0 ; int v 128 = 128 < data . length ? data [ 128 ] : 0 ; int v 129 = 129 < data . length ? data [ 129 ] : 0 ; int v 130 = 130 < data . length ? data [ 130 ] : 0 ; int v 131 = 131 < data . length ? data [ 131 ] : 0 ; int v 132 = 132 < data . length ? data [ 132 ] : 0 ; int v 133 = 133 < data . length ? data [ 133 ] : 0 ; int v 134 = 134 < data . length ? data [ 134 ] : 0 ; int v 135 = 135 < data . length ? data [ 135 ] : 0 ; int v 136 = 136 < data . length ? data [ 136 ] : 0 ; int v 137 = 137 < data . length ? data [ 137 ] : 0 ; int v 138 = 138 < data . length ? data [ 138 ] : 0 ; int v 139 = 139 < data . length ? data [ 139 ] : 0 ; int v 140 = 140 < data . length ? data [ 140 ] : 0 ; int v 141 = 141 < data . length ? data [ 141 ] : 0 ; int v 142 = 142 < data . length ? data [ 142 ] : 0 ; int v 143 = 143 < data . length ? data [ 143 ] : 0 ; int v 144 = 144 < data . length ? data [ 144 ] : 0 ; int v 145 = 145 < data . length ? data [ 145 ] : 0 ; int v 146 = 146 < data . length ? data [ 146 ] : 0 ; int v 147 = 147 < data . length ? data [ 147 ] : 0 ; int v 148 = 148 < data . length ? data [ 148 ] : 0 ; int v 149 = 149 < data . length ? data [ 149 ] : 0 ; int v 150 = 150 < data . length ? data [ 150 ] : 0 ; int v 151 = 151 < data . length ? data [ 151 ] : 0 ; int v 152 = 152 < data . length ? data [ 152 ] : 0 ; int v 153 = 153 < data . length ? data [ 153 ] : 0 ; int v 154 = 154 < data . length ? data [ 154 ] : 0 ; int v 155 = 155 < data . length ? data [ 155 ] : 0 ; int v 156 = 156 < data . length ? data [ 156 ] : 0 ; int v 157 = 157 < data . length ? data [ 157 ] : 0 ; int v 158 = 158 < data . length ? data [ 158 ] : 0 ; int v 159 = 159 < data . length ? data [ 159 ] : 0 ; int v 160 = 160 < data . length ? data [ 160 ] : 0 ; int v 161 = 161 < data . length ? data [ 161 ] : 0 ; int v 162 = 162 < data . length ? data [ 162 ] : 0 ; int v 163 = 163 < data . length ? data [ 163 ] : 0 ; int v 164 = 164 < data . length ? data [ 164 ] : 0 ; int v 165 = 165 < data . length ? data [ 165 ] : 0 ; int v 166 = 166 < data . length ? data [ 166 ] : 0 ; int v 167 = 167 < data . length ? data [ 167 ] : 0 ; int v 168 = 168 < data . length ? data [ 168 ] : 0 ; int v 169 = 169 < data . length ? data [ 169 ] : 0 ; int v 170 = 170 < data . length ? data [ 170 ] : 0 ; int v 171 = 171 < data . length ? data [ 171 ] : 0 ; int v 172 = 172 < data . length ? data [ 172 ] : 0 ; int v 173 = 173 < data . length ? data [ 173 ] : 0 ; int v 174 = 174 < data . length ? data [ 174 ] : 0 ; int v 175 = 175 < data . length ? data [ 175 ] : 0 ; int v 176 = 176 < data . length ? data [ 176 ] : 0 ; int v 177 = 177 < data . length ? data [ 177 ] : 0 ; int v 178 = 178 < data . length ? data [ 178 ] : 0 ; int v 179 = 179 < data . length ? data [ 179 ] : 0 ; int v 180 = 180 < data . length ? data [ 180 ] : 0 ; int v 181 = 181 < data . length ? data [ 181 ] : 0 ; int v 182 = 182 < data . length ? data [ 182 ] : 0 ; int v 183 = 183 < data . length ? data [ 183 ] : 0 ; int v 184 = 184 < data . length ? data [ 184 ] : 0 ; int v 185 = 185 < data . length ? data [ 185 ] : 0 ; int v 186 = 186 < data . length ? data [ 186 ] : 0 ; int v 187 = 187 < data . length ? data [ 187 ] : 0 ; int v 188 = 188 < data . length ? data [ 188 ] : 0 ; int v 189 = 189 < data . length ? data [ 189 ] : 0 ; int v 190 = 190 < data . length ? data [ 190 ] : 0 ; int v 191 = 191 < data . length ? data [ 191 ] : 0 ; int v 192 = 192 < data . length ? data [ 192 ] : 0 ; int v 193 = 193 < data . length ? data [ 193 ] : 0 ; int v 194 = 194 < data . length ? data [ 194 ] : 0 ; int v 195 = 195 < data . length ? data [ 195 ] : 0 ; int v 196 = 196 < data . length ? data [ 196 ] : 0 ; int v 197 = 197 < data . length ? data [ 197 ] : 0 ;" }, { "block_ids": [ @@ -165,6 +172,7 @@ } ], "text": " int v198 = 198 < data.length ? data[198] : 0;\n int v199 = 199 < data.length ? data[199] : 0;\n int v200 = 200 < data.length ? data[200] : 0;\n int v201 = 201 < data.length ? data[201] : 0;\n int v202 = 202 < data.length ? data[202] : 0;\n int v203 = 203 < data.length ? data[203] : 0;\n int v204 = 204 < data.length ? data[204] : 0;\n int v205 = 205 < data.length ? data[205] : 0;\n int v206 = 206 < data.length ? data[206] : 0;\n int v207 = 207 < data.length ? data[207] : 0;\n int v208 = 208 < data.length ? data[208] : 0;\n int v209 = 209 < data.length ? data[209] : 0;\n return data.length;\n }\n}", - "token_estimate": 228 + "token_estimate": 228, + "tokenized_korean_text": "int v 198 = 198 < data . length ? data [ 198 ] : 0 ; int v 199 = 199 < data . length ? data [ 199 ] : 0 ; int v 200 = 200 < data . length ? data [ 200 ] : 0 ; int v 201 = 201 < data . length ? data [ 201 ] : 0 ; int v 202 = 202 < data . length ? data [ 202 ] : 0 ; int v 203 = 203 < data . length ? data [ 203 ] : 0 ; int v 204 = 204 < data . length ? data [ 204 ] : 0 ; int v 205 = 205 < data . length ? data [ 205 ] : 0 ; int v 206 = 206 < data . length ? data [ 206 ] : 0 ; int v 207 = 207 < data . length ? data [ 207 ] : 0 ; int v 208 = 208 < data . length ? data [ 208 ] : 0 ; int v 209 = 209 < data . length ? data [ 209 ] : 0 ; return data . length ; } }" } ] diff --git a/crates/kebab-chunk/tests/fixtures/code-sample.js.chunks.snapshot.json b/crates/kebab-chunk/tests/fixtures/code-sample.js.chunks.snapshot.json index fb33e50..6af5efe 100644 --- a/crates/kebab-chunk/tests/fixtures/code-sample.js.chunks.snapshot.json +++ b/crates/kebab-chunk/tests/fixtures/code-sample.js.chunks.snapshot.json @@ -18,7 +18,8 @@ } ], "text": "const fs = require('fs');\nconst path = require('path');\nconst { EventEmitter } = require('events');\nconst assert = require('assert');\nconst crypto = require('crypto');", - "token_estimate": 56 + "token_estimate": 56, + "tokenized_korean_text": "const fs = require (' fs '); const path = require (' path '); const { EventEmitter } = require (' events '); const assert = require (' assert '); const crypto = require (' crypto ');" }, { "block_ids": [ @@ -39,7 +40,8 @@ } ], "text": "export function add(a, b) {\n if (typeof a !== 'number') throw new TypeError('a');\n if (typeof b !== 'number') throw new TypeError('b');\n const result = a + b;\n assert(isFinite(result));\n return result;\n}", - "token_estimate": 70 + "token_estimate": 70, + "tokenized_korean_text": "export function add ( a , b ) { if ( typeof a !== ' number ') throw new TypeError (' a '); if ( typeof b !== ' number ') throw new TypeError (' b '); const result = a + b ; assert ( isFinite ( result )); return result ; }" }, { "block_ids": [ @@ -60,7 +62,8 @@ } ], "text": "class EventBus {\n constructor() {\n this._handlers = new Map();\n this._history = [];\n this._maxHistory = 100;\n this._seq = 0;\n }\n}", - "token_estimate": 48 + "token_estimate": 48, + "tokenized_korean_text": "class EventBus { constructor ( ) { this ._ handlers = new Map (); this ._ history = []; this ._ maxHistory = 100 ; this ._ seq = 0 ; } }" }, { "block_ids": [ @@ -81,7 +84,8 @@ } ], "text": "class BaseHandler {\n handle(event) {\n throw new Error('not implemented');\n }\n batchHandle(events) {\n const results = [];\n for (const ev of events) {\n results.push(this.handle(ev));\n }\n return results;\n }\n}", - "token_estimate": 77 + "token_estimate": 77, + "tokenized_korean_text": "class BaseHandler { handle ( event ) { throw new Error (' not implemented '); } batchHandle ( events ) { const results = []; for ( const ev of events ) { results . push ( this . handle ( ev )); } return results ; } }" }, { "block_ids": [ @@ -102,7 +106,8 @@ } ], "text": "class EventBus {\n emit(name, payload) {\n const handlers = this._handlers.get(name) ?? [];\n for (const h of handlers) {\n h(payload);\n }\n return this;\n }\n}", - "token_estimate": 58 + "token_estimate": 58, + "tokenized_korean_text": "class EventBus { emit ( name , payload ) { const handlers = this ._ handlers . get ( name ) ?? []; for ( const h of handlers ) { h ( payload ); } return this ; } }" }, { "block_ids": [ @@ -123,7 +128,8 @@ } ], "text": "class EventBus {\n on(name, handler) {\n if (!this._handlers.has(name)) {\n this._handlers.set(name, []);\n }\n this._handlers.get(name).push(handler);\n return this;\n }\n}", - "token_estimate": 62 + "token_estimate": 62, + "tokenized_korean_text": "class EventBus { on ( name , handler ) { if (! this ._ handlers . has ( name ) ) { this ._ handlers . set ( name , []); } this ._ handlers . get ( name ) . push ( handler ); return this ; } }" }, { "block_ids": [ @@ -144,7 +150,8 @@ } ], "text": "function bigTransform(items) {\n const v0 = items[0] !== undefined ? items[0] : null;\n const v1 = items[1] !== undefined ? items[1] : null;\n const v2 = items[2] !== undefined ? items[2] : null;\n const v3 = items[3] !== undefined ? items[3] : null;\n const v4 = items[4] !== undefined ? items[4] : null;\n const v5 = items[5] !== undefined ? items[5] : null;\n const v6 = items[6] !== undefined ? items[6] : null;\n const v7 = items[7] !== undefined ? items[7] : null;\n const v8 = items[8] !== undefined ? items[8] : null;\n const v9 = items[9] !== undefined ? items[9] : null;\n const v10 = items[10] !== undefined ? items[10] : null;\n const v11 = items[11] !== undefined ? items[11] : null;\n const v12 = items[12] !== undefined ? items[12] : null;\n const v13 = items[13] !== undefined ? items[13] : null;\n const v14 = items[14] !== undefined ? items[14] : null;\n const v15 = items[15] !== undefined ? items[15] : null;\n const v16 = items[16] !== undefined ? items[16] : null;\n const v17 = items[17] !== undefined ? items[17] : null;\n const v18 = items[18] !== undefined ? items[18] : null;\n const v19 = items[19] !== undefined ? items[19] : null;\n const v20 = items[20] !== undefined ? items[20] : null;\n const v21 = items[21] !== undefined ? items[21] : null;\n const v22 = items[22] !== undefined ? items[22] : null;\n const v23 = items[23] !== undefined ? items[23] : null;\n const v24 = items[24] !== undefined ? items[24] : null;\n const v25 = items[25] !== undefined ? items[25] : null;\n const v26 = items[26] !== undefined ? items[26] : null;\n const v27 = items[27] !== undefined ? items[27] : null;\n const v28 = items[28] !== undefined ? items[28] : null;\n const v29 = items[29] !== undefined ? items[29] : null;\n const v30 = items[30] !== undefined ? items[30] : null;\n const v31 = items[31] !== undefined ? items[31] : null;\n const v32 = items[32] !== undefined ? items[32] : null;\n const v33 = items[33] !== undefined ? items[33] : null;\n const v34 = items[34] !== undefined ? items[34] : null;\n const v35 = items[35] !== undefined ? items[35] : null;\n const v36 = items[36] !== undefined ? items[36] : null;\n const v37 = items[37] !== undefined ? items[37] : null;\n const v38 = items[38] !== undefined ? items[38] : null;\n const v39 = items[39] !== undefined ? items[39] : null;\n const v40 = items[40] !== undefined ? items[40] : null;\n const v41 = items[41] !== undefined ? items[41] : null;\n const v42 = items[42] !== undefined ? items[42] : null;\n const v43 = items[43] !== undefined ? items[43] : null;\n const v44 = items[44] !== undefined ? items[44] : null;\n const v45 = items[45] !== undefined ? items[45] : null;\n const v46 = items[46] !== undefined ? items[46] : null;\n const v47 = items[47] !== undefined ? items[47] : null;\n const v48 = items[48] !== undefined ? items[48] : null;\n const v49 = items[49] !== undefined ? items[49] : null;\n const v50 = items[50] !== undefined ? items[50] : null;\n const v51 = items[51] !== undefined ? items[51] : null;\n const v52 = items[52] !== undefined ? items[52] : null;\n const v53 = items[53] !== undefined ? items[53] : null;\n const v54 = items[54] !== undefined ? items[54] : null;\n const v55 = items[55] !== undefined ? items[55] : null;\n const v56 = items[56] !== undefined ? items[56] : null;\n const v57 = items[57] !== undefined ? items[57] : null;\n const v58 = items[58] !== undefined ? items[58] : null;\n const v59 = items[59] !== undefined ? items[59] : null;\n const v60 = items[60] !== undefined ? items[60] : null;\n const v61 = items[61] !== undefined ? items[61] : null;\n const v62 = items[62] !== undefined ? items[62] : null;\n const v63 = items[63] !== undefined ? items[63] : null;\n const v64 = items[64] !== undefined ? items[64] : null;\n const v65 = items[65] !== undefined ? items[65] : null;\n const v66 = items[66] !== undefined ? items[66] : null;\n const v67 = items[67] !== undefined ? items[67] : null;\n const v68 = items[68] !== undefined ? items[68] : null;\n const v69 = items[69] !== undefined ? items[69] : null;\n const v70 = items[70] !== undefined ? items[70] : null;\n const v71 = items[71] !== undefined ? items[71] : null;\n const v72 = items[72] !== undefined ? items[72] : null;\n const v73 = items[73] !== undefined ? items[73] : null;\n const v74 = items[74] !== undefined ? items[74] : null;\n const v75 = items[75] !== undefined ? items[75] : null;\n const v76 = items[76] !== undefined ? items[76] : null;\n const v77 = items[77] !== undefined ? items[77] : null;\n const v78 = items[78] !== undefined ? items[78] : null;\n const v79 = items[79] !== undefined ? items[79] : null;\n const v80 = items[80] !== undefined ? items[80] : null;\n const v81 = items[81] !== undefined ? items[81] : null;\n const v82 = items[82] !== undefined ? items[82] : null;\n const v83 = items[83] !== undefined ? items[83] : null;\n const v84 = items[84] !== undefined ? items[84] : null;\n const v85 = items[85] !== undefined ? items[85] : null;\n const v86 = items[86] !== undefined ? items[86] : null;\n const v87 = items[87] !== undefined ? items[87] : null;\n const v88 = items[88] !== undefined ? items[88] : null;\n const v89 = items[89] !== undefined ? items[89] : null;\n const v90 = items[90] !== undefined ? items[90] : null;\n const v91 = items[91] !== undefined ? items[91] : null;\n const v92 = items[92] !== undefined ? items[92] : null;\n const v93 = items[93] !== undefined ? items[93] : null;\n const v94 = items[94] !== undefined ? items[94] : null;\n const v95 = items[95] !== undefined ? items[95] : null;\n const v96 = items[96] !== undefined ? items[96] : null;\n const v97 = items[97] !== undefined ? items[97] : null;\n const v98 = items[98] !== undefined ? items[98] : null;\n const v99 = items[99] !== undefined ? items[99] : null;\n const v100 = items[100] !== undefined ? items[100] : null;\n const v101 = items[101] !== undefined ? items[101] : null;\n const v102 = items[102] !== undefined ? items[102] : null;\n const v103 = items[103] !== undefined ? items[103] : null;\n const v104 = items[104] !== undefined ? items[104] : null;\n const v105 = items[105] !== undefined ? items[105] : null;\n const v106 = items[106] !== undefined ? items[106] : null;\n const v107 = items[107] !== undefined ? items[107] : null;\n const v108 = items[108] !== undefined ? items[108] : null;\n const v109 = items[109] !== undefined ? items[109] : null;\n const v110 = items[110] !== undefined ? items[110] : null;\n const v111 = items[111] !== undefined ? items[111] : null;\n const v112 = items[112] !== undefined ? items[112] : null;\n const v113 = items[113] !== undefined ? items[113] : null;\n const v114 = items[114] !== undefined ? items[114] : null;\n const v115 = items[115] !== undefined ? items[115] : null;\n const v116 = items[116] !== undefined ? items[116] : null;\n const v117 = items[117] !== undefined ? items[117] : null;\n const v118 = items[118] !== undefined ? items[118] : null;\n const v119 = items[119] !== undefined ? items[119] : null;\n const v120 = items[120] !== undefined ? items[120] : null;\n const v121 = items[121] !== undefined ? items[121] : null;\n const v122 = items[122] !== undefined ? items[122] : null;\n const v123 = items[123] !== undefined ? items[123] : null;\n const v124 = items[124] !== undefined ? items[124] : null;\n const v125 = items[125] !== undefined ? items[125] : null;\n const v126 = items[126] !== undefined ? items[126] : null;\n const v127 = items[127] !== undefined ? items[127] : null;\n const v128 = items[128] !== undefined ? items[128] : null;\n const v129 = items[129] !== undefined ? items[129] : null;\n const v130 = items[130] !== undefined ? items[130] : null;\n const v131 = items[131] !== undefined ? items[131] : null;\n const v132 = items[132] !== undefined ? items[132] : null;\n const v133 = items[133] !== undefined ? items[133] : null;\n const v134 = items[134] !== undefined ? items[134] : null;\n const v135 = items[135] !== undefined ? items[135] : null;\n const v136 = items[136] !== undefined ? items[136] : null;\n const v137 = items[137] !== undefined ? items[137] : null;\n const v138 = items[138] !== undefined ? items[138] : null;\n const v139 = items[139] !== undefined ? items[139] : null;\n const v140 = items[140] !== undefined ? items[140] : null;\n const v141 = items[141] !== undefined ? items[141] : null;\n const v142 = items[142] !== undefined ? items[142] : null;\n const v143 = items[143] !== undefined ? items[143] : null;\n const v144 = items[144] !== undefined ? items[144] : null;\n const v145 = items[145] !== undefined ? items[145] : null;\n const v146 = items[146] !== undefined ? items[146] : null;\n const v147 = items[147] !== undefined ? items[147] : null;\n const v148 = items[148] !== undefined ? items[148] : null;\n const v149 = items[149] !== undefined ? items[149] : null;\n const v150 = items[150] !== undefined ? items[150] : null;\n const v151 = items[151] !== undefined ? items[151] : null;\n const v152 = items[152] !== undefined ? items[152] : null;\n const v153 = items[153] !== undefined ? items[153] : null;\n const v154 = items[154] !== undefined ? items[154] : null;\n const v155 = items[155] !== undefined ? items[155] : null;\n const v156 = items[156] !== undefined ? items[156] : null;\n const v157 = items[157] !== undefined ? items[157] : null;\n const v158 = items[158] !== undefined ? items[158] : null;\n const v159 = items[159] !== undefined ? items[159] : null;\n const v160 = items[160] !== undefined ? items[160] : null;\n const v161 = items[161] !== undefined ? items[161] : null;\n const v162 = items[162] !== undefined ? items[162] : null;\n const v163 = items[163] !== undefined ? items[163] : null;\n const v164 = items[164] !== undefined ? items[164] : null;\n const v165 = items[165] !== undefined ? items[165] : null;\n const v166 = items[166] !== undefined ? items[166] : null;\n const v167 = items[167] !== undefined ? items[167] : null;\n const v168 = items[168] !== undefined ? items[168] : null;\n const v169 = items[169] !== undefined ? items[169] : null;\n const v170 = items[170] !== undefined ? items[170] : null;\n const v171 = items[171] !== undefined ? items[171] : null;\n const v172 = items[172] !== undefined ? items[172] : null;\n const v173 = items[173] !== undefined ? items[173] : null;\n const v174 = items[174] !== undefined ? items[174] : null;\n const v175 = items[175] !== undefined ? items[175] : null;\n const v176 = items[176] !== undefined ? items[176] : null;\n const v177 = items[177] !== undefined ? items[177] : null;\n const v178 = items[178] !== undefined ? items[178] : null;\n const v179 = items[179] !== undefined ? items[179] : null;\n const v180 = items[180] !== undefined ? items[180] : null;\n const v181 = items[181] !== undefined ? items[181] : null;\n const v182 = items[182] !== undefined ? items[182] : null;\n const v183 = items[183] !== undefined ? items[183] : null;\n const v184 = items[184] !== undefined ? items[184] : null;\n const v185 = items[185] !== undefined ? items[185] : null;\n const v186 = items[186] !== undefined ? items[186] : null;\n const v187 = items[187] !== undefined ? items[187] : null;\n const v188 = items[188] !== undefined ? items[188] : null;\n const v189 = items[189] !== undefined ? items[189] : null;\n const v190 = items[190] !== undefined ? items[190] : null;\n const v191 = items[191] !== undefined ? items[191] : null;\n const v192 = items[192] !== undefined ? items[192] : null;\n const v193 = items[193] !== undefined ? items[193] : null;\n const v194 = items[194] !== undefined ? items[194] : null;\n const v195 = items[195] !== undefined ? items[195] : null;\n const v196 = items[196] !== undefined ? items[196] : null;\n const v197 = items[197] !== undefined ? items[197] : null;\n const v198 = items[198] !== undefined ? items[198] : null;", - "token_estimate": 3947 + "token_estimate": 3947, + "tokenized_korean_text": "function bigTransform ( items ) { const v 0 = items [ 0 ] !== undefined ? items [ 0 ] : null ; const v 1 = items [ 1 ] !== undefined ? items [ 1 ] : null ; const v 2 = items [ 2 ] !== undefined ? items [ 2 ] : null ; const v 3 = items [ 3 ] !== undefined ? items [ 3 ] : null ; const v 4 = items [ 4 ] !== undefined ? items [ 4 ] : null ; const v 5 = items [ 5 ] !== undefined ? items [ 5 ] : null ; const v 6 = items [ 6 ] !== undefined ? items [ 6 ] : null ; const v 7 = items [ 7 ] !== undefined ? items [ 7 ] : null ; const v 8 = items [ 8 ] !== undefined ? items [ 8 ] : null ; const v 9 = items [ 9 ] !== undefined ? items [ 9 ] : null ; const v 10 = items [ 10 ] !== undefined ? items [ 10 ] : null ; const v 11 = items [ 11 ] !== undefined ? items [ 11 ] : null ; const v 12 = items [ 12 ] !== undefined ? items [ 12 ] : null ; const v 13 = items [ 13 ] !== undefined ? items [ 13 ] : null ; const v 14 = items [ 14 ] !== undefined ? items [ 14 ] : null ; const v 15 = items [ 15 ] !== undefined ? items [ 15 ] : null ; const v 16 = items [ 16 ] !== undefined ? items [ 16 ] : null ; const v 17 = items [ 17 ] !== undefined ? items [ 17 ] : null ; const v 18 = items [ 18 ] !== undefined ? items [ 18 ] : null ; const v 19 = items [ 19 ] !== undefined ? items [ 19 ] : null ; const v 20 = items [ 20 ] !== undefined ? items [ 20 ] : null ; const v 21 = items [ 21 ] !== undefined ? items [ 21 ] : null ; const v 22 = items [ 22 ] !== undefined ? items [ 22 ] : null ; const v 23 = items [ 23 ] !== undefined ? items [ 23 ] : null ; const v 24 = items [ 24 ] !== undefined ? items [ 24 ] : null ; const v 25 = items [ 25 ] !== undefined ? items [ 25 ] : null ; const v 26 = items [ 26 ] !== undefined ? items [ 26 ] : null ; const v 27 = items [ 27 ] !== undefined ? items [ 27 ] : null ; const v 28 = items [ 28 ] !== undefined ? items [ 28 ] : null ; const v 29 = items [ 29 ] !== undefined ? items [ 29 ] : null ; const v 30 = items [ 30 ] !== undefined ? items [ 30 ] : null ; const v 31 = items [ 31 ] !== undefined ? items [ 31 ] : null ; const v 32 = items [ 32 ] !== undefined ? items [ 32 ] : null ; const v 33 = items [ 33 ] !== undefined ? items [ 33 ] : null ; const v 34 = items [ 34 ] !== undefined ? items [ 34 ] : null ; const v 35 = items [ 35 ] !== undefined ? items [ 35 ] : null ; const v 36 = items [ 36 ] !== undefined ? items [ 36 ] : null ; const v 37 = items [ 37 ] !== undefined ? items [ 37 ] : null ; const v 38 = items [ 38 ] !== undefined ? items [ 38 ] : null ; const v 39 = items [ 39 ] !== undefined ? items [ 39 ] : null ; const v 40 = items [ 40 ] !== undefined ? items [ 40 ] : null ; const v 41 = items [ 41 ] !== undefined ? items [ 41 ] : null ; const v 42 = items [ 42 ] !== undefined ? items [ 42 ] : null ; const v 43 = items [ 43 ] !== undefined ? items [ 43 ] : null ; const v 44 = items [ 44 ] !== undefined ? items [ 44 ] : null ; const v 45 = items [ 45 ] !== undefined ? items [ 45 ] : null ; const v 46 = items [ 46 ] !== undefined ? items [ 46 ] : null ; const v 47 = items [ 47 ] !== undefined ? items [ 47 ] : null ; const v 48 = items [ 48 ] !== undefined ? items [ 48 ] : null ; const v 49 = items [ 49 ] !== undefined ? items [ 49 ] : null ; const v 50 = items [ 50 ] !== undefined ? items [ 50 ] : null ; const v 51 = items [ 51 ] !== undefined ? items [ 51 ] : null ; const v 52 = items [ 52 ] !== undefined ? items [ 52 ] : null ; const v 53 = items [ 53 ] !== undefined ? items [ 53 ] : null ; const v 54 = items [ 54 ] !== undefined ? items [ 54 ] : null ; const v 55 = items [ 55 ] !== undefined ? items [ 55 ] : null ; const v 56 = items [ 56 ] !== undefined ? items [ 56 ] : null ; const v 57 = items [ 57 ] !== undefined ? items [ 57 ] : null ; const v 58 = items [ 58 ] !== undefined ? items [ 58 ] : null ; const v 59 = items [ 59 ] !== undefined ? items [ 59 ] : null ; const v 60 = items [ 60 ] !== undefined ? items [ 60 ] : null ; const v 61 = items [ 61 ] !== undefined ? items [ 61 ] : null ; const v 62 = items [ 62 ] !== undefined ? items [ 62 ] : null ; const v 63 = items [ 63 ] !== undefined ? items [ 63 ] : null ; const v 64 = items [ 64 ] !== undefined ? items [ 64 ] : null ; const v 65 = items [ 65 ] !== undefined ? items [ 65 ] : null ; const v 66 = items [ 66 ] !== undefined ? items [ 66 ] : null ; const v 67 = items [ 67 ] !== undefined ? items [ 67 ] : null ; const v 68 = items [ 68 ] !== undefined ? items [ 68 ] : null ; const v 69 = items [ 69 ] !== undefined ? items [ 69 ] : null ; const v 70 = items [ 70 ] !== undefined ? items [ 70 ] : null ; const v 71 = items [ 71 ] !== undefined ? items [ 71 ] : null ; const v 72 = items [ 72 ] !== undefined ? items [ 72 ] : null ; const v 73 = items [ 73 ] !== undefined ? items [ 73 ] : null ; const v 74 = items [ 74 ] !== undefined ? items [ 74 ] : null ; const v 75 = items [ 75 ] !== undefined ? items [ 75 ] : null ; const v 76 = items [ 76 ] !== undefined ? items [ 76 ] : null ; const v 77 = items [ 77 ] !== undefined ? items [ 77 ] : null ; const v 78 = items [ 78 ] !== undefined ? items [ 78 ] : null ; const v 79 = items [ 79 ] !== undefined ? items [ 79 ] : null ; const v 80 = items [ 80 ] !== undefined ? items [ 80 ] : null ; const v 81 = items [ 81 ] !== undefined ? items [ 81 ] : null ; const v 82 = items [ 82 ] !== undefined ? items [ 82 ] : null ; const v 83 = items [ 83 ] !== undefined ? items [ 83 ] : null ; const v 84 = items [ 84 ] !== undefined ? items [ 84 ] : null ; const v 85 = items [ 85 ] !== undefined ? items [ 85 ] : null ; const v 86 = items [ 86 ] !== undefined ? items [ 86 ] : null ; const v 87 = items [ 87 ] !== undefined ? items [ 87 ] : null ; const v 88 = items [ 88 ] !== undefined ? items [ 88 ] : null ; const v 89 = items [ 89 ] !== undefined ? items [ 89 ] : null ; const v 90 = items [ 90 ] !== undefined ? items [ 90 ] : null ; const v 91 = items [ 91 ] !== undefined ? items [ 91 ] : null ; const v 92 = items [ 92 ] !== undefined ? items [ 92 ] : null ; const v 93 = items [ 93 ] !== undefined ? items [ 93 ] : null ; const v 94 = items [ 94 ] !== undefined ? items [ 94 ] : null ; const v 95 = items [ 95 ] !== undefined ? items [ 95 ] : null ; const v 96 = items [ 96 ] !== undefined ? items [ 96 ] : null ; const v 97 = items [ 97 ] !== undefined ? items [ 97 ] : null ; const v 98 = items [ 98 ] !== undefined ? items [ 98 ] : null ; const v 99 = items [ 99 ] !== undefined ? items [ 99 ] : null ; const v 100 = items [ 100 ] !== undefined ? items [ 100 ] : null ; const v 101 = items [ 101 ] !== undefined ? items [ 101 ] : null ; const v 102 = items [ 102 ] !== undefined ? items [ 102 ] : null ; const v 103 = items [ 103 ] !== undefined ? items [ 103 ] : null ; const v 104 = items [ 104 ] !== undefined ? items [ 104 ] : null ; const v 105 = items [ 105 ] !== undefined ? items [ 105 ] : null ; const v 106 = items [ 106 ] !== undefined ? items [ 106 ] : null ; const v 107 = items [ 107 ] !== undefined ? items [ 107 ] : null ; const v 108 = items [ 108 ] !== undefined ? items [ 108 ] : null ; const v 109 = items [ 109 ] !== undefined ? items [ 109 ] : null ; const v 110 = items [ 110 ] !== undefined ? items [ 110 ] : null ; const v 111 = items [ 111 ] !== undefined ? items [ 111 ] : null ; const v 112 = items [ 112 ] !== undefined ? items [ 112 ] : null ; const v 113 = items [ 113 ] !== undefined ? items [ 113 ] : null ; const v 114 = items [ 114 ] !== undefined ? items [ 114 ] : null ; const v 115 = items [ 115 ] !== undefined ? items [ 115 ] : null ; const v 116 = items [ 116 ] !== undefined ? items [ 116 ] : null ; const v 117 = items [ 117 ] !== undefined ? items [ 117 ] : null ; const v 118 = items [ 118 ] !== undefined ? items [ 118 ] : null ; const v 119 = items [ 119 ] !== undefined ? items [ 119 ] : null ; const v 120 = items [ 120 ] !== undefined ? items [ 120 ] : null ; const v 121 = items [ 121 ] !== undefined ? items [ 121 ] : null ; const v 122 = items [ 122 ] !== undefined ? items [ 122 ] : null ; const v 123 = items [ 123 ] !== undefined ? items [ 123 ] : null ; const v 124 = items [ 124 ] !== undefined ? items [ 124 ] : null ; const v 125 = items [ 125 ] !== undefined ? items [ 125 ] : null ; const v 126 = items [ 126 ] !== undefined ? items [ 126 ] : null ; const v 127 = items [ 127 ] !== undefined ? items [ 127 ] : null ; const v 128 = items [ 128 ] !== undefined ? items [ 128 ] : null ; const v 129 = items [ 129 ] !== undefined ? items [ 129 ] : null ; const v 130 = items [ 130 ] !== undefined ? items [ 130 ] : null ; const v 131 = items [ 131 ] !== undefined ? items [ 131 ] : null ; const v 132 = items [ 132 ] !== undefined ? items [ 132 ] : null ; const v 133 = items [ 133 ] !== undefined ? items [ 133 ] : null ; const v 134 = items [ 134 ] !== undefined ? items [ 134 ] : null ; const v 135 = items [ 135 ] !== undefined ? items [ 135 ] : null ; const v 136 = items [ 136 ] !== undefined ? items [ 136 ] : null ; const v 137 = items [ 137 ] !== undefined ? items [ 137 ] : null ; const v 138 = items [ 138 ] !== undefined ? items [ 138 ] : null ; const v 139 = items [ 139 ] !== undefined ? items [ 139 ] : null ; const v 140 = items [ 140 ] !== undefined ? items [ 140 ] : null ; const v 141 = items [ 141 ] !== undefined ? items [ 141 ] : null ; const v 142 = items [ 142 ] !== undefined ? items [ 142 ] : null ; const v 143 = items [ 143 ] !== undefined ? items [ 143 ] : null ; const v 144 = items [ 144 ] !== undefined ? items [ 144 ] : null ; const v 145 = items [ 145 ] !== undefined ? items [ 145 ] : null ; const v 146 = items [ 146 ] !== undefined ? items [ 146 ] : null ; const v 147 = items [ 147 ] !== undefined ? items [ 147 ] : null ; const v 148 = items [ 148 ] !== undefined ? items [ 148 ] : null ; const v 149 = items [ 149 ] !== undefined ? items [ 149 ] : null ; const v 150 = items [ 150 ] !== undefined ? items [ 150 ] : null ; const v 151 = items [ 151 ] !== undefined ? items [ 151 ] : null ; const v 152 = items [ 152 ] !== undefined ? items [ 152 ] : null ; const v 153 = items [ 153 ] !== undefined ? items [ 153 ] : null ; const v 154 = items [ 154 ] !== undefined ? items [ 154 ] : null ; const v 155 = items [ 155 ] !== undefined ? items [ 155 ] : null ; const v 156 = items [ 156 ] !== undefined ? items [ 156 ] : null ; const v 157 = items [ 157 ] !== undefined ? items [ 157 ] : null ; const v 158 = items [ 158 ] !== undefined ? items [ 158 ] : null ; const v 159 = items [ 159 ] !== undefined ? items [ 159 ] : null ; const v 160 = items [ 160 ] !== undefined ? items [ 160 ] : null ; const v 161 = items [ 161 ] !== undefined ? items [ 161 ] : null ; const v 162 = items [ 162 ] !== undefined ? items [ 162 ] : null ; const v 163 = items [ 163 ] !== undefined ? items [ 163 ] : null ; const v 164 = items [ 164 ] !== undefined ? items [ 164 ] : null ; const v 165 = items [ 165 ] !== undefined ? items [ 165 ] : null ; const v 166 = items [ 166 ] !== undefined ? items [ 166 ] : null ; const v 167 = items [ 167 ] !== undefined ? items [ 167 ] : null ; const v 168 = items [ 168 ] !== undefined ? items [ 168 ] : null ; const v 169 = items [ 169 ] !== undefined ? items [ 169 ] : null ; const v 170 = items [ 170 ] !== undefined ? items [ 170 ] : null ; const v 171 = items [ 171 ] !== undefined ? items [ 171 ] : null ; const v 172 = items [ 172 ] !== undefined ? items [ 172 ] : null ; const v 173 = items [ 173 ] !== undefined ? items [ 173 ] : null ; const v 174 = items [ 174 ] !== undefined ? items [ 174 ] : null ; const v 175 = items [ 175 ] !== undefined ? items [ 175 ] : null ; const v 176 = items [ 176 ] !== undefined ? items [ 176 ] : null ; const v 177 = items [ 177 ] !== undefined ? items [ 177 ] : null ; const v 178 = items [ 178 ] !== undefined ? items [ 178 ] : null ; const v 179 = items [ 179 ] !== undefined ? items [ 179 ] : null ; const v 180 = items [ 180 ] !== undefined ? items [ 180 ] : null ; const v 181 = items [ 181 ] !== undefined ? items [ 181 ] : null ; const v 182 = items [ 182 ] !== undefined ? items [ 182 ] : null ; const v 183 = items [ 183 ] !== undefined ? items [ 183 ] : null ; const v 184 = items [ 184 ] !== undefined ? items [ 184 ] : null ; const v 185 = items [ 185 ] !== undefined ? items [ 185 ] : null ; const v 186 = items [ 186 ] !== undefined ? items [ 186 ] : null ; const v 187 = items [ 187 ] !== undefined ? items [ 187 ] : null ; const v 188 = items [ 188 ] !== undefined ? items [ 188 ] : null ; const v 189 = items [ 189 ] !== undefined ? items [ 189 ] : null ; const v 190 = items [ 190 ] !== undefined ? items [ 190 ] : null ; const v 191 = items [ 191 ] !== undefined ? items [ 191 ] : null ; const v 192 = items [ 192 ] !== undefined ? items [ 192 ] : null ; const v 193 = items [ 193 ] !== undefined ? items [ 193 ] : null ; const v 194 = items [ 194 ] !== undefined ? items [ 194 ] : null ; const v 195 = items [ 195 ] !== undefined ? items [ 195 ] : null ; const v 196 = items [ 196 ] !== undefined ? items [ 196 ] : null ; const v 197 = items [ 197 ] !== undefined ? items [ 197 ] : null ; const v 198 = items [ 198 ] !== undefined ? items [ 198 ] : null ;" }, { "block_ids": [ @@ -165,6 +172,7 @@ } ], "text": " const v199 = items[199] !== undefined ? items[199] : null;\n const v200 = items[200] !== undefined ? items[200] : null;\n const v201 = items[201] !== undefined ? items[201] : null;\n const v202 = items[202] !== undefined ? items[202] : null;\n const v203 = items[203] !== undefined ? items[203] : null;\n const v204 = items[204] !== undefined ? items[204] : null;\n const v205 = items[205] !== undefined ? items[205] : null;\n const v206 = items[206] !== undefined ? items[206] : null;\n const v207 = items[207] !== undefined ? items[207] : null;\n const v208 = items[208] !== undefined ? items[208] : null;\n const v209 = items[209] !== undefined ? items[209] : null;\n return items;\n}", - "token_estimate": 230 + "token_estimate": 230, + "tokenized_korean_text": "const v 199 = items [ 199 ] !== undefined ? items [ 199 ] : null ; const v 200 = items [ 200 ] !== undefined ? items [ 200 ] : null ; const v 201 = items [ 201 ] !== undefined ? items [ 201 ] : null ; const v 202 = items [ 202 ] !== undefined ? items [ 202 ] : null ; const v 203 = items [ 203 ] !== undefined ? items [ 203 ] : null ; const v 204 = items [ 204 ] !== undefined ? items [ 204 ] : null ; const v 205 = items [ 205 ] !== undefined ? items [ 205 ] : null ; const v 206 = items [ 206 ] !== undefined ? items [ 206 ] : null ; const v 207 = items [ 207 ] !== undefined ? items [ 207 ] : null ; const v 208 = items [ 208 ] !== undefined ? items [ 208 ] : null ; const v 209 = items [ 209 ] !== undefined ? items [ 209 ] : null ; return items ; }" } ] diff --git a/crates/kebab-chunk/tests/fixtures/code-sample.kt.chunks.snapshot.json b/crates/kebab-chunk/tests/fixtures/code-sample.kt.chunks.snapshot.json index 3e046ff..97244e1 100644 --- a/crates/kebab-chunk/tests/fixtures/code-sample.kt.chunks.snapshot.json +++ b/crates/kebab-chunk/tests/fixtures/code-sample.kt.chunks.snapshot.json @@ -18,7 +18,8 @@ } ], "text": "import kotlin.collections.List\nimport kotlin.collections.Map\nimport kotlin.collections.MutableList\nimport kotlin.collections.MutableMap\nimport kotlin.collections.mutableListOf", - "token_estimate": 59 + "token_estimate": 59, + "tokenized_korean_text": "import kotlin . collections . List import kotlin . collections . Map import kotlin . collections . MutableList import kotlin . collections . MutableMap import kotlin . collections . mutableListOf" }, { "block_ids": [ @@ -39,7 +40,8 @@ } ], "text": "fun computeMRR(scores: List): Double {\n if (scores.isEmpty()) {\n return 0.0\n }\n return 1.0 / scores.size\n}", - "token_estimate": 44 + "token_estimate": 44, + "tokenized_korean_text": "fun computeMRR ( scores : List < Double >): Double { if ( scores . isEmpty ( ) ) { return 0 . 0 } return 1 . 0 / scores . size }" }, { "block_ids": [ @@ -60,7 +62,8 @@ } ], "text": "data class MetricsCollector(\n val scores: MutableList = mutableListOf(),\n val labels: MutableList = mutableListOf(),\n val counts: MutableMap = mutableMapOf(),\n val totals: MutableMap = mutableMapOf(),\n val tags: MutableList = mutableListOf(),\n)", - "token_estimate": 104 + "token_estimate": 104, + "tokenized_korean_text": "data class MetricsCollector ( val scores : MutableList < Double > = mutableListOf ( ) , val labels : MutableList < String > = mutableListOf ( ) , val counts : MutableMap < String , Int > = mutableMapOf ( ) , val totals : MutableMap < String , Double > = mutableMapOf ( ) , val tags : MutableList < String > = mutableListOf ( ) , )" }, { "block_ids": [ @@ -81,7 +84,8 @@ } ], "text": "open class BaseEvaluator(val name: String) {\n\n fun evaluate(data: List) {\n val joined = data.joinToString(\",\")\n println(joined)\n }\n\n open fun describe(): String = name\n}", - "token_estimate": 67 + "token_estimate": 67, + "tokenized_korean_text": "open class BaseEvaluator ( val name : String ) { fun evaluate ( data : List < String >) { val joined = data . joinToString (\",\") println ( joined ) } open fun describe ( ) : String = name }" }, { "block_ids": [ @@ -102,7 +106,8 @@ } ], "text": "fun MetricsCollector.run(inputs: List) {\n for (inp in inputs) {\n scores.add(\n inp\n )\n }\n}", - "token_estimate": 43 + "token_estimate": 43, + "tokenized_korean_text": "fun MetricsCollector . run ( inputs : List < Double >) { for ( inp in inputs ) { scores . add ( inp ) } }" }, { "block_ids": [ @@ -123,7 +128,8 @@ } ], "text": "fun MetricsCollector.report(): Map {\n return mapOf(\n \"mean\" to 0.0,\n \"count\" to scores.size,\n \"tags\" to tags,\n )\n}", - "token_estimate": 52 + "token_estimate": 52, + "tokenized_korean_text": "fun MetricsCollector . report ( ) : Map < String , Any > { return mapOf ( \" mean \" to 0 . 0 , \" count \" to scores . size , \" tags \" to tags , ) }" }, { "block_ids": [ @@ -144,7 +150,8 @@ } ], "text": "class BigCompute {\n fun compute(data: IntArray): Int {\n val v0 = if (0 < data.size) data[0] else 0\n val v1 = if (1 < data.size) data[1] else 0\n val v2 = if (2 < data.size) data[2] else 0\n val v3 = if (3 < data.size) data[3] else 0\n val v4 = if (4 < data.size) data[4] else 0\n val v5 = if (5 < data.size) data[5] else 0\n val v6 = if (6 < data.size) data[6] else 0\n val v7 = if (7 < data.size) data[7] else 0\n val v8 = if (8 < data.size) data[8] else 0\n val v9 = if (9 < data.size) data[9] else 0\n val v10 = if (10 < data.size) data[10] else 0\n val v11 = if (11 < data.size) data[11] else 0\n val v12 = if (12 < data.size) data[12] else 0\n val v13 = if (13 < data.size) data[13] else 0\n val v14 = if (14 < data.size) data[14] else 0\n val v15 = if (15 < data.size) data[15] else 0\n val v16 = if (16 < data.size) data[16] else 0\n val v17 = if (17 < data.size) data[17] else 0\n val v18 = if (18 < data.size) data[18] else 0\n val v19 = if (19 < data.size) data[19] else 0\n val v20 = if (20 < data.size) data[20] else 0\n val v21 = if (21 < data.size) data[21] else 0\n val v22 = if (22 < data.size) data[22] else 0\n val v23 = if (23 < data.size) data[23] else 0\n val v24 = if (24 < data.size) data[24] else 0\n val v25 = if (25 < data.size) data[25] else 0\n val v26 = if (26 < data.size) data[26] else 0\n val v27 = if (27 < data.size) data[27] else 0\n val v28 = if (28 < data.size) data[28] else 0\n val v29 = if (29 < data.size) data[29] else 0\n val v30 = if (30 < data.size) data[30] else 0\n val v31 = if (31 < data.size) data[31] else 0\n val v32 = if (32 < data.size) data[32] else 0\n val v33 = if (33 < data.size) data[33] else 0\n val v34 = if (34 < data.size) data[34] else 0\n val v35 = if (35 < data.size) data[35] else 0\n val v36 = if (36 < data.size) data[36] else 0\n val v37 = if (37 < data.size) data[37] else 0\n val v38 = if (38 < data.size) data[38] else 0\n val v39 = if (39 < data.size) data[39] else 0\n val v40 = if (40 < data.size) data[40] else 0\n val v41 = if (41 < data.size) data[41] else 0\n val v42 = if (42 < data.size) data[42] else 0\n val v43 = if (43 < data.size) data[43] else 0\n val v44 = if (44 < data.size) data[44] else 0\n val v45 = if (45 < data.size) data[45] else 0\n val v46 = if (46 < data.size) data[46] else 0\n val v47 = if (47 < data.size) data[47] else 0\n val v48 = if (48 < data.size) data[48] else 0\n val v49 = if (49 < data.size) data[49] else 0\n val v50 = if (50 < data.size) data[50] else 0\n val v51 = if (51 < data.size) data[51] else 0\n val v52 = if (52 < data.size) data[52] else 0\n val v53 = if (53 < data.size) data[53] else 0\n val v54 = if (54 < data.size) data[54] else 0\n val v55 = if (55 < data.size) data[55] else 0\n val v56 = if (56 < data.size) data[56] else 0\n val v57 = if (57 < data.size) data[57] else 0\n val v58 = if (58 < data.size) data[58] else 0\n val v59 = if (59 < data.size) data[59] else 0\n val v60 = if (60 < data.size) data[60] else 0\n val v61 = if (61 < data.size) data[61] else 0\n val v62 = if (62 < data.size) data[62] else 0\n val v63 = if (63 < data.size) data[63] else 0\n val v64 = if (64 < data.size) data[64] else 0\n val v65 = if (65 < data.size) data[65] else 0\n val v66 = if (66 < data.size) data[66] else 0\n val v67 = if (67 < data.size) data[67] else 0\n val v68 = if (68 < data.size) data[68] else 0\n val v69 = if (69 < data.size) data[69] else 0\n val v70 = if (70 < data.size) data[70] else 0\n val v71 = if (71 < data.size) data[71] else 0\n val v72 = if (72 < data.size) data[72] else 0\n val v73 = if (73 < data.size) data[73] else 0\n val v74 = if (74 < data.size) data[74] else 0\n val v75 = if (75 < data.size) data[75] else 0\n val v76 = if (76 < data.size) data[76] else 0\n val v77 = if (77 < data.size) data[77] else 0\n val v78 = if (78 < data.size) data[78] else 0\n val v79 = if (79 < data.size) data[79] else 0\n val v80 = if (80 < data.size) data[80] else 0\n val v81 = if (81 < data.size) data[81] else 0\n val v82 = if (82 < data.size) data[82] else 0\n val v83 = if (83 < data.size) data[83] else 0\n val v84 = if (84 < data.size) data[84] else 0\n val v85 = if (85 < data.size) data[85] else 0\n val v86 = if (86 < data.size) data[86] else 0\n val v87 = if (87 < data.size) data[87] else 0\n val v88 = if (88 < data.size) data[88] else 0\n val v89 = if (89 < data.size) data[89] else 0\n val v90 = if (90 < data.size) data[90] else 0\n val v91 = if (91 < data.size) data[91] else 0\n val v92 = if (92 < data.size) data[92] else 0\n val v93 = if (93 < data.size) data[93] else 0\n val v94 = if (94 < data.size) data[94] else 0\n val v95 = if (95 < data.size) data[95] else 0\n val v96 = if (96 < data.size) data[96] else 0\n val v97 = if (97 < data.size) data[97] else 0\n val v98 = if (98 < data.size) data[98] else 0\n val v99 = if (99 < data.size) data[99] else 0\n val v100 = if (100 < data.size) data[100] else 0\n val v101 = if (101 < data.size) data[101] else 0\n val v102 = if (102 < data.size) data[102] else 0\n val v103 = if (103 < data.size) data[103] else 0\n val v104 = if (104 < data.size) data[104] else 0\n val v105 = if (105 < data.size) data[105] else 0\n val v106 = if (106 < data.size) data[106] else 0\n val v107 = if (107 < data.size) data[107] else 0\n val v108 = if (108 < data.size) data[108] else 0\n val v109 = if (109 < data.size) data[109] else 0\n val v110 = if (110 < data.size) data[110] else 0\n val v111 = if (111 < data.size) data[111] else 0\n val v112 = if (112 < data.size) data[112] else 0\n val v113 = if (113 < data.size) data[113] else 0\n val v114 = if (114 < data.size) data[114] else 0\n val v115 = if (115 < data.size) data[115] else 0\n val v116 = if (116 < data.size) data[116] else 0\n val v117 = if (117 < data.size) data[117] else 0\n val v118 = if (118 < data.size) data[118] else 0\n val v119 = if (119 < data.size) data[119] else 0\n val v120 = if (120 < data.size) data[120] else 0\n val v121 = if (121 < data.size) data[121] else 0\n val v122 = if (122 < data.size) data[122] else 0\n val v123 = if (123 < data.size) data[123] else 0\n val v124 = if (124 < data.size) data[124] else 0\n val v125 = if (125 < data.size) data[125] else 0\n val v126 = if (126 < data.size) data[126] else 0\n val v127 = if (127 < data.size) data[127] else 0\n val v128 = if (128 < data.size) data[128] else 0\n val v129 = if (129 < data.size) data[129] else 0\n val v130 = if (130 < data.size) data[130] else 0\n val v131 = if (131 < data.size) data[131] else 0\n val v132 = if (132 < data.size) data[132] else 0\n val v133 = if (133 < data.size) data[133] else 0\n val v134 = if (134 < data.size) data[134] else 0\n val v135 = if (135 < data.size) data[135] else 0\n val v136 = if (136 < data.size) data[136] else 0\n val v137 = if (137 < data.size) data[137] else 0\n val v138 = if (138 < data.size) data[138] else 0\n val v139 = if (139 < data.size) data[139] else 0\n val v140 = if (140 < data.size) data[140] else 0\n val v141 = if (141 < data.size) data[141] else 0\n val v142 = if (142 < data.size) data[142] else 0\n val v143 = if (143 < data.size) data[143] else 0\n val v144 = if (144 < data.size) data[144] else 0\n val v145 = if (145 < data.size) data[145] else 0\n val v146 = if (146 < data.size) data[146] else 0\n val v147 = if (147 < data.size) data[147] else 0\n val v148 = if (148 < data.size) data[148] else 0\n val v149 = if (149 < data.size) data[149] else 0\n val v150 = if (150 < data.size) data[150] else 0\n val v151 = if (151 < data.size) data[151] else 0\n val v152 = if (152 < data.size) data[152] else 0\n val v153 = if (153 < data.size) data[153] else 0\n val v154 = if (154 < data.size) data[154] else 0\n val v155 = if (155 < data.size) data[155] else 0\n val v156 = if (156 < data.size) data[156] else 0\n val v157 = if (157 < data.size) data[157] else 0\n val v158 = if (158 < data.size) data[158] else 0\n val v159 = if (159 < data.size) data[159] else 0\n val v160 = if (160 < data.size) data[160] else 0\n val v161 = if (161 < data.size) data[161] else 0\n val v162 = if (162 < data.size) data[162] else 0\n val v163 = if (163 < data.size) data[163] else 0\n val v164 = if (164 < data.size) data[164] else 0\n val v165 = if (165 < data.size) data[165] else 0\n val v166 = if (166 < data.size) data[166] else 0\n val v167 = if (167 < data.size) data[167] else 0\n val v168 = if (168 < data.size) data[168] else 0\n val v169 = if (169 < data.size) data[169] else 0\n val v170 = if (170 < data.size) data[170] else 0\n val v171 = if (171 < data.size) data[171] else 0\n val v172 = if (172 < data.size) data[172] else 0\n val v173 = if (173 < data.size) data[173] else 0\n val v174 = if (174 < data.size) data[174] else 0\n val v175 = if (175 < data.size) data[175] else 0\n val v176 = if (176 < data.size) data[176] else 0\n val v177 = if (177 < data.size) data[177] else 0\n val v178 = if (178 < data.size) data[178] else 0\n val v179 = if (179 < data.size) data[179] else 0\n val v180 = if (180 < data.size) data[180] else 0\n val v181 = if (181 < data.size) data[181] else 0\n val v182 = if (182 < data.size) data[182] else 0\n val v183 = if (183 < data.size) data[183] else 0\n val v184 = if (184 < data.size) data[184] else 0\n val v185 = if (185 < data.size) data[185] else 0\n val v186 = if (186 < data.size) data[186] else 0\n val v187 = if (187 < data.size) data[187] else 0\n val v188 = if (188 < data.size) data[188] else 0\n val v189 = if (189 < data.size) data[189] else 0\n val v190 = if (190 < data.size) data[190] else 0\n val v191 = if (191 < data.size) data[191] else 0\n val v192 = if (192 < data.size) data[192] else 0\n val v193 = if (193 < data.size) data[193] else 0\n val v194 = if (194 < data.size) data[194] else 0\n val v195 = if (195 < data.size) data[195] else 0\n val v196 = if (196 < data.size) data[196] else 0\n val v197 = if (197 < data.size) data[197] else 0", - "token_estimate": 3671 + "token_estimate": 3671, + "tokenized_korean_text": "class BigCompute { fun compute ( data : IntArray ) : Int { val v 0 = if ( 0 < data . size ) data [ 0 ] else 0 val v 1 = if ( 1 < data . size ) data [ 1 ] else 0 val v 2 = if ( 2 < data . size ) data [ 2 ] else 0 val v 3 = if ( 3 < data . size ) data [ 3 ] else 0 val v 4 = if ( 4 < data . size ) data [ 4 ] else 0 val v 5 = if ( 5 < data . size ) data [ 5 ] else 0 val v 6 = if ( 6 < data . size ) data [ 6 ] else 0 val v 7 = if ( 7 < data . size ) data [ 7 ] else 0 val v 8 = if ( 8 < data . size ) data [ 8 ] else 0 val v 9 = if ( 9 < data . size ) data [ 9 ] else 0 val v 10 = if ( 10 < data . size ) data [ 10 ] else 0 val v 11 = if ( 11 < data . size ) data [ 11 ] else 0 val v 12 = if ( 12 < data . size ) data [ 12 ] else 0 val v 13 = if ( 13 < data . size ) data [ 13 ] else 0 val v 14 = if ( 14 < data . size ) data [ 14 ] else 0 val v 15 = if ( 15 < data . size ) data [ 15 ] else 0 val v 16 = if ( 16 < data . size ) data [ 16 ] else 0 val v 17 = if ( 17 < data . size ) data [ 17 ] else 0 val v 18 = if ( 18 < data . size ) data [ 18 ] else 0 val v 19 = if ( 19 < data . size ) data [ 19 ] else 0 val v 20 = if ( 20 < data . size ) data [ 20 ] else 0 val v 21 = if ( 21 < data . size ) data [ 21 ] else 0 val v 22 = if ( 22 < data . size ) data [ 22 ] else 0 val v 23 = if ( 23 < data . size ) data [ 23 ] else 0 val v 24 = if ( 24 < data . size ) data [ 24 ] else 0 val v 25 = if ( 25 < data . size ) data [ 25 ] else 0 val v 26 = if ( 26 < data . size ) data [ 26 ] else 0 val v 27 = if ( 27 < data . size ) data [ 27 ] else 0 val v 28 = if ( 28 < data . size ) data [ 28 ] else 0 val v 29 = if ( 29 < data . size ) data [ 29 ] else 0 val v 30 = if ( 30 < data . size ) data [ 30 ] else 0 val v 31 = if ( 31 < data . size ) data [ 31 ] else 0 val v 32 = if ( 32 < data . size ) data [ 32 ] else 0 val v 33 = if ( 33 < data . size ) data [ 33 ] else 0 val v 34 = if ( 34 < data . size ) data [ 34 ] else 0 val v 35 = if ( 35 < data . size ) data [ 35 ] else 0 val v 36 = if ( 36 < data . size ) data [ 36 ] else 0 val v 37 = if ( 37 < data . size ) data [ 37 ] else 0 val v 38 = if ( 38 < data . size ) data [ 38 ] else 0 val v 39 = if ( 39 < data . size ) data [ 39 ] else 0 val v 40 = if ( 40 < data . size ) data [ 40 ] else 0 val v 41 = if ( 41 < data . size ) data [ 41 ] else 0 val v 42 = if ( 42 < data . size ) data [ 42 ] else 0 val v 43 = if ( 43 < data . size ) data [ 43 ] else 0 val v 44 = if ( 44 < data . size ) data [ 44 ] else 0 val v 45 = if ( 45 < data . size ) data [ 45 ] else 0 val v 46 = if ( 46 < data . size ) data [ 46 ] else 0 val v 47 = if ( 47 < data . size ) data [ 47 ] else 0 val v 48 = if ( 48 < data . size ) data [ 48 ] else 0 val v 49 = if ( 49 < data . size ) data [ 49 ] else 0 val v 50 = if ( 50 < data . size ) data [ 50 ] else 0 val v 51 = if ( 51 < data . size ) data [ 51 ] else 0 val v 52 = if ( 52 < data . size ) data [ 52 ] else 0 val v 53 = if ( 53 < data . size ) data [ 53 ] else 0 val v 54 = if ( 54 < data . size ) data [ 54 ] else 0 val v 55 = if ( 55 < data . size ) data [ 55 ] else 0 val v 56 = if ( 56 < data . size ) data [ 56 ] else 0 val v 57 = if ( 57 < data . size ) data [ 57 ] else 0 val v 58 = if ( 58 < data . size ) data [ 58 ] else 0 val v 59 = if ( 59 < data . size ) data [ 59 ] else 0 val v 60 = if ( 60 < data . size ) data [ 60 ] else 0 val v 61 = if ( 61 < data . size ) data [ 61 ] else 0 val v 62 = if ( 62 < data . size ) data [ 62 ] else 0 val v 63 = if ( 63 < data . size ) data [ 63 ] else 0 val v 64 = if ( 64 < data . size ) data [ 64 ] else 0 val v 65 = if ( 65 < data . size ) data [ 65 ] else 0 val v 66 = if ( 66 < data . size ) data [ 66 ] else 0 val v 67 = if ( 67 < data . size ) data [ 67 ] else 0 val v 68 = if ( 68 < data . size ) data [ 68 ] else 0 val v 69 = if ( 69 < data . size ) data [ 69 ] else 0 val v 70 = if ( 70 < data . size ) data [ 70 ] else 0 val v 71 = if ( 71 < data . size ) data [ 71 ] else 0 val v 72 = if ( 72 < data . size ) data [ 72 ] else 0 val v 73 = if ( 73 < data . size ) data [ 73 ] else 0 val v 74 = if ( 74 < data . size ) data [ 74 ] else 0 val v 75 = if ( 75 < data . size ) data [ 75 ] else 0 val v 76 = if ( 76 < data . size ) data [ 76 ] else 0 val v 77 = if ( 77 < data . size ) data [ 77 ] else 0 val v 78 = if ( 78 < data . size ) data [ 78 ] else 0 val v 79 = if ( 79 < data . size ) data [ 79 ] else 0 val v 80 = if ( 80 < data . size ) data [ 80 ] else 0 val v 81 = if ( 81 < data . size ) data [ 81 ] else 0 val v 82 = if ( 82 < data . size ) data [ 82 ] else 0 val v 83 = if ( 83 < data . size ) data [ 83 ] else 0 val v 84 = if ( 84 < data . size ) data [ 84 ] else 0 val v 85 = if ( 85 < data . size ) data [ 85 ] else 0 val v 86 = if ( 86 < data . size ) data [ 86 ] else 0 val v 87 = if ( 87 < data . size ) data [ 87 ] else 0 val v 88 = if ( 88 < data . size ) data [ 88 ] else 0 val v 89 = if ( 89 < data . size ) data [ 89 ] else 0 val v 90 = if ( 90 < data . size ) data [ 90 ] else 0 val v 91 = if ( 91 < data . size ) data [ 91 ] else 0 val v 92 = if ( 92 < data . size ) data [ 92 ] else 0 val v 93 = if ( 93 < data . size ) data [ 93 ] else 0 val v 94 = if ( 94 < data . size ) data [ 94 ] else 0 val v 95 = if ( 95 < data . size ) data [ 95 ] else 0 val v 96 = if ( 96 < data . size ) data [ 96 ] else 0 val v 97 = if ( 97 < data . size ) data [ 97 ] else 0 val v 98 = if ( 98 < data . size ) data [ 98 ] else 0 val v 99 = if ( 99 < data . size ) data [ 99 ] else 0 val v 100 = if ( 100 < data . size ) data [ 100 ] else 0 val v 101 = if ( 101 < data . size ) data [ 101 ] else 0 val v 102 = if ( 102 < data . size ) data [ 102 ] else 0 val v 103 = if ( 103 < data . size ) data [ 103 ] else 0 val v 104 = if ( 104 < data . size ) data [ 104 ] else 0 val v 105 = if ( 105 < data . size ) data [ 105 ] else 0 val v 106 = if ( 106 < data . size ) data [ 106 ] else 0 val v 107 = if ( 107 < data . size ) data [ 107 ] else 0 val v 108 = if ( 108 < data . size ) data [ 108 ] else 0 val v 109 = if ( 109 < data . size ) data [ 109 ] else 0 val v 110 = if ( 110 < data . size ) data [ 110 ] else 0 val v 111 = if ( 111 < data . size ) data [ 111 ] else 0 val v 112 = if ( 112 < data . size ) data [ 112 ] else 0 val v 113 = if ( 113 < data . size ) data [ 113 ] else 0 val v 114 = if ( 114 < data . size ) data [ 114 ] else 0 val v 115 = if ( 115 < data . size ) data [ 115 ] else 0 val v 116 = if ( 116 < data . size ) data [ 116 ] else 0 val v 117 = if ( 117 < data . size ) data [ 117 ] else 0 val v 118 = if ( 118 < data . size ) data [ 118 ] else 0 val v 119 = if ( 119 < data . size ) data [ 119 ] else 0 val v 120 = if ( 120 < data . size ) data [ 120 ] else 0 val v 121 = if ( 121 < data . size ) data [ 121 ] else 0 val v 122 = if ( 122 < data . size ) data [ 122 ] else 0 val v 123 = if ( 123 < data . size ) data [ 123 ] else 0 val v 124 = if ( 124 < data . size ) data [ 124 ] else 0 val v 125 = if ( 125 < data . size ) data [ 125 ] else 0 val v 126 = if ( 126 < data . size ) data [ 126 ] else 0 val v 127 = if ( 127 < data . size ) data [ 127 ] else 0 val v 128 = if ( 128 < data . size ) data [ 128 ] else 0 val v 129 = if ( 129 < data . size ) data [ 129 ] else 0 val v 130 = if ( 130 < data . size ) data [ 130 ] else 0 val v 131 = if ( 131 < data . size ) data [ 131 ] else 0 val v 132 = if ( 132 < data . size ) data [ 132 ] else 0 val v 133 = if ( 133 < data . size ) data [ 133 ] else 0 val v 134 = if ( 134 < data . size ) data [ 134 ] else 0 val v 135 = if ( 135 < data . size ) data [ 135 ] else 0 val v 136 = if ( 136 < data . size ) data [ 136 ] else 0 val v 137 = if ( 137 < data . size ) data [ 137 ] else 0 val v 138 = if ( 138 < data . size ) data [ 138 ] else 0 val v 139 = if ( 139 < data . size ) data [ 139 ] else 0 val v 140 = if ( 140 < data . size ) data [ 140 ] else 0 val v 141 = if ( 141 < data . size ) data [ 141 ] else 0 val v 142 = if ( 142 < data . size ) data [ 142 ] else 0 val v 143 = if ( 143 < data . size ) data [ 143 ] else 0 val v 144 = if ( 144 < data . size ) data [ 144 ] else 0 val v 145 = if ( 145 < data . size ) data [ 145 ] else 0 val v 146 = if ( 146 < data . size ) data [ 146 ] else 0 val v 147 = if ( 147 < data . size ) data [ 147 ] else 0 val v 148 = if ( 148 < data . size ) data [ 148 ] else 0 val v 149 = if ( 149 < data . size ) data [ 149 ] else 0 val v 150 = if ( 150 < data . size ) data [ 150 ] else 0 val v 151 = if ( 151 < data . size ) data [ 151 ] else 0 val v 152 = if ( 152 < data . size ) data [ 152 ] else 0 val v 153 = if ( 153 < data . size ) data [ 153 ] else 0 val v 154 = if ( 154 < data . size ) data [ 154 ] else 0 val v 155 = if ( 155 < data . size ) data [ 155 ] else 0 val v 156 = if ( 156 < data . size ) data [ 156 ] else 0 val v 157 = if ( 157 < data . size ) data [ 157 ] else 0 val v 158 = if ( 158 < data . size ) data [ 158 ] else 0 val v 159 = if ( 159 < data . size ) data [ 159 ] else 0 val v 160 = if ( 160 < data . size ) data [ 160 ] else 0 val v 161 = if ( 161 < data . size ) data [ 161 ] else 0 val v 162 = if ( 162 < data . size ) data [ 162 ] else 0 val v 163 = if ( 163 < data . size ) data [ 163 ] else 0 val v 164 = if ( 164 < data . size ) data [ 164 ] else 0 val v 165 = if ( 165 < data . size ) data [ 165 ] else 0 val v 166 = if ( 166 < data . size ) data [ 166 ] else 0 val v 167 = if ( 167 < data . size ) data [ 167 ] else 0 val v 168 = if ( 168 < data . size ) data [ 168 ] else 0 val v 169 = if ( 169 < data . size ) data [ 169 ] else 0 val v 170 = if ( 170 < data . size ) data [ 170 ] else 0 val v 171 = if ( 171 < data . size ) data [ 171 ] else 0 val v 172 = if ( 172 < data . size ) data [ 172 ] else 0 val v 173 = if ( 173 < data . size ) data [ 173 ] else 0 val v 174 = if ( 174 < data . size ) data [ 174 ] else 0 val v 175 = if ( 175 < data . size ) data [ 175 ] else 0 val v 176 = if ( 176 < data . size ) data [ 176 ] else 0 val v 177 = if ( 177 < data . size ) data [ 177 ] else 0 val v 178 = if ( 178 < data . size ) data [ 178 ] else 0 val v 179 = if ( 179 < data . size ) data [ 179 ] else 0 val v 180 = if ( 180 < data . size ) data [ 180 ] else 0 val v 181 = if ( 181 < data . size ) data [ 181 ] else 0 val v 182 = if ( 182 < data . size ) data [ 182 ] else 0 val v 183 = if ( 183 < data . size ) data [ 183 ] else 0 val v 184 = if ( 184 < data . size ) data [ 184 ] else 0 val v 185 = if ( 185 < data . size ) data [ 185 ] else 0 val v 186 = if ( 186 < data . size ) data [ 186 ] else 0 val v 187 = if ( 187 < data . size ) data [ 187 ] else 0 val v 188 = if ( 188 < data . size ) data [ 188 ] else 0 val v 189 = if ( 189 < data . size ) data [ 189 ] else 0 val v 190 = if ( 190 < data . size ) data [ 190 ] else 0 val v 191 = if ( 191 < data . size ) data [ 191 ] else 0 val v 192 = if ( 192 < data . size ) data [ 192 ] else 0 val v 193 = if ( 193 < data . size ) data [ 193 ] else 0 val v 194 = if ( 194 < data . size ) data [ 194 ] else 0 val v 195 = if ( 195 < data . size ) data [ 195 ] else 0 val v 196 = if ( 196 < data . size ) data [ 196 ] else 0 val v 197 = if ( 197 < data . size ) data [ 197 ] else 0" }, { "block_ids": [ @@ -165,6 +172,7 @@ } ], "text": " val v198 = if (198 < data.size) data[198] else 0\n val v199 = if (199 < data.size) data[199] else 0\n val v200 = if (200 < data.size) data[200] else 0\n val v201 = if (201 < data.size) data[201] else 0\n val v202 = if (202 < data.size) data[202] else 0\n val v203 = if (203 < data.size) data[203] else 0\n val v204 = if (204 < data.size) data[204] else 0\n val v205 = if (205 < data.size) data[205] else 0\n val v206 = if (206 < data.size) data[206] else 0\n val v207 = if (207 < data.size) data[207] else 0\n val v208 = if (208 < data.size) data[208] else 0\n val v209 = if (209 < data.size) data[209] else 0\n return data.size\n }\n}", - "token_estimate": 239 + "token_estimate": 239, + "tokenized_korean_text": "val v 198 = if ( 198 < data . size ) data [ 198 ] else 0 val v 199 = if ( 199 < data . size ) data [ 199 ] else 0 val v 200 = if ( 200 < data . size ) data [ 200 ] else 0 val v 201 = if ( 201 < data . size ) data [ 201 ] else 0 val v 202 = if ( 202 < data . size ) data [ 202 ] else 0 val v 203 = if ( 203 < data . size ) data [ 203 ] else 0 val v 204 = if ( 204 < data . size ) data [ 204 ] else 0 val v 205 = if ( 205 < data . size ) data [ 205 ] else 0 val v 206 = if ( 206 < data . size ) data [ 206 ] else 0 val v 207 = if ( 207 < data . size ) data [ 207 ] else 0 val v 208 = if ( 208 < data . size ) data [ 208 ] else 0 val v 209 = if ( 209 < data . size ) data [ 209 ] else 0 return data . size } }" } ] diff --git a/crates/kebab-chunk/tests/fixtures/code-sample.py.chunks.snapshot.json b/crates/kebab-chunk/tests/fixtures/code-sample.py.chunks.snapshot.json index 1b9d86e..48507b0 100644 --- a/crates/kebab-chunk/tests/fixtures/code-sample.py.chunks.snapshot.json +++ b/crates/kebab-chunk/tests/fixtures/code-sample.py.chunks.snapshot.json @@ -18,7 +18,8 @@ } ], "text": "import os\nimport sys\nfrom typing import List\nfrom pathlib import Path\nfrom collections import defaultdict", - "token_estimate": 35 + "token_estimate": 35, + "tokenized_korean_text": "import os import sys from typing import List from pathlib import Path from collections import defaultdict" }, { "block_ids": [ @@ -39,7 +40,8 @@ } ], "text": "def compute_mrr(scores):\n if not scores:\n return 0.0\n return sum(\n 1.0 / r for r in scores\n ) / len(scores)", - "token_estimate": 44 + "token_estimate": 44, + "tokenized_korean_text": "def compute _ mrr ( scores ) : if not scores : return 0 . 0 return sum ( 1 . 0 / r for r in scores ) / len ( scores )" }, { "block_ids": [ @@ -60,7 +62,8 @@ } ], "text": "class MetricsCollector:\n def __init__(self):\n self.scores = []\n self.labels = []\n self.counts = defaultdict(int)\n self.totals = defaultdict(float)\n self.tags = []", - "token_estimate": 67 + "token_estimate": 67, + "tokenized_korean_text": "class MetricsCollector : def __ init __( self ) : self . scores = [ ] self . labels = [ ] self . counts = defaultdict ( int ) self . totals = defaultdict ( float ) self . tags = [ ]" }, { "block_ids": [ @@ -81,7 +84,8 @@ } ], "text": "class BaseEvaluator:\n def evaluate(self, data):\n raise NotImplementedError\n def batch_evaluate(self, items):\n results = []\n for item in items:\n results.append(self.evaluate(item))\n return results\n def name(self):\n return type(self).__name__", - "token_estimate": 99 + "token_estimate": 99, + "tokenized_korean_text": "class BaseEvaluator : def evaluate ( self , data ) : raise NotImplementedError def batch _ evaluate ( self , items ) : results = [ ] for item in items : results . append ( self . evaluate ( item ) ) return results def name ( self ) : return type ( self ).__ name __" }, { "block_ids": [ @@ -102,7 +106,8 @@ } ], "text": "class MetricsCollector:\n def run(self, inputs):\n for inp in inputs:\n score = self._score(inp)\n self.scores.append(\n score\n )", - "token_estimate": 61 + "token_estimate": 61, + "tokenized_korean_text": "class MetricsCollector : def run ( self , inputs ) : for inp in inputs : score = self ._ score ( inp ) self . scores . append ( score )" }, { "block_ids": [ @@ -123,7 +128,8 @@ } ], "text": "class MetricsCollector:\n def report(self):\n return {\n 'mean': sum(self.scores) / max(len(self.scores), 1),\n 'count': len(self.scores),\n 'tags': self.tags,\n }", - "token_estimate": 69 + "token_estimate": 69, + "tokenized_korean_text": "class MetricsCollector : def report ( self ) : return { ' mean ': sum ( self . scores ) / max ( len ( self . scores ) , 1 ) , ' count ': len ( self . scores ) , ' tags ': self . tags , }" }, { "block_ids": [ @@ -144,7 +150,8 @@ } ], "text": "def big_compute(data):\n v0 = data[0] if 0 < len(data) else 0\n v1 = data[1] if 1 < len(data) else 0\n v2 = data[2] if 2 < len(data) else 0\n v3 = data[3] if 3 < len(data) else 0\n v4 = data[4] if 4 < len(data) else 0\n v5 = data[5] if 5 < len(data) else 0\n v6 = data[6] if 6 < len(data) else 0\n v7 = data[7] if 7 < len(data) else 0\n v8 = data[8] if 8 < len(data) else 0\n v9 = data[9] if 9 < len(data) else 0\n v10 = data[10] if 10 < len(data) else 0\n v11 = data[11] if 11 < len(data) else 0\n v12 = data[12] if 12 < len(data) else 0\n v13 = data[13] if 13 < len(data) else 0\n v14 = data[14] if 14 < len(data) else 0\n v15 = data[15] if 15 < len(data) else 0\n v16 = data[16] if 16 < len(data) else 0\n v17 = data[17] if 17 < len(data) else 0\n v18 = data[18] if 18 < len(data) else 0\n v19 = data[19] if 19 < len(data) else 0\n v20 = data[20] if 20 < len(data) else 0\n v21 = data[21] if 21 < len(data) else 0\n v22 = data[22] if 22 < len(data) else 0\n v23 = data[23] if 23 < len(data) else 0\n v24 = data[24] if 24 < len(data) else 0\n v25 = data[25] if 25 < len(data) else 0\n v26 = data[26] if 26 < len(data) else 0\n v27 = data[27] if 27 < len(data) else 0\n v28 = data[28] if 28 < len(data) else 0\n v29 = data[29] if 29 < len(data) else 0\n v30 = data[30] if 30 < len(data) else 0\n v31 = data[31] if 31 < len(data) else 0\n v32 = data[32] if 32 < len(data) else 0\n v33 = data[33] if 33 < len(data) else 0\n v34 = data[34] if 34 < len(data) else 0\n v35 = data[35] if 35 < len(data) else 0\n v36 = data[36] if 36 < len(data) else 0\n v37 = data[37] if 37 < len(data) else 0\n v38 = data[38] if 38 < len(data) else 0\n v39 = data[39] if 39 < len(data) else 0\n v40 = data[40] if 40 < len(data) else 0\n v41 = data[41] if 41 < len(data) else 0\n v42 = data[42] if 42 < len(data) else 0\n v43 = data[43] if 43 < len(data) else 0\n v44 = data[44] if 44 < len(data) else 0\n v45 = data[45] if 45 < len(data) else 0\n v46 = data[46] if 46 < len(data) else 0\n v47 = data[47] if 47 < len(data) else 0\n v48 = data[48] if 48 < len(data) else 0\n v49 = data[49] if 49 < len(data) else 0\n v50 = data[50] if 50 < len(data) else 0\n v51 = data[51] if 51 < len(data) else 0\n v52 = data[52] if 52 < len(data) else 0\n v53 = data[53] if 53 < len(data) else 0\n v54 = data[54] if 54 < len(data) else 0\n v55 = data[55] if 55 < len(data) else 0\n v56 = data[56] if 56 < len(data) else 0\n v57 = data[57] if 57 < len(data) else 0\n v58 = data[58] if 58 < len(data) else 0\n v59 = data[59] if 59 < len(data) else 0\n v60 = data[60] if 60 < len(data) else 0\n v61 = data[61] if 61 < len(data) else 0\n v62 = data[62] if 62 < len(data) else 0\n v63 = data[63] if 63 < len(data) else 0\n v64 = data[64] if 64 < len(data) else 0\n v65 = data[65] if 65 < len(data) else 0\n v66 = data[66] if 66 < len(data) else 0\n v67 = data[67] if 67 < len(data) else 0\n v68 = data[68] if 68 < len(data) else 0\n v69 = data[69] if 69 < len(data) else 0\n v70 = data[70] if 70 < len(data) else 0\n v71 = data[71] if 71 < len(data) else 0\n v72 = data[72] if 72 < len(data) else 0\n v73 = data[73] if 73 < len(data) else 0\n v74 = data[74] if 74 < len(data) else 0\n v75 = data[75] if 75 < len(data) else 0\n v76 = data[76] if 76 < len(data) else 0\n v77 = data[77] if 77 < len(data) else 0\n v78 = data[78] if 78 < len(data) else 0\n v79 = data[79] if 79 < len(data) else 0\n v80 = data[80] if 80 < len(data) else 0\n v81 = data[81] if 81 < len(data) else 0\n v82 = data[82] if 82 < len(data) else 0\n v83 = data[83] if 83 < len(data) else 0\n v84 = data[84] if 84 < len(data) else 0\n v85 = data[85] if 85 < len(data) else 0\n v86 = data[86] if 86 < len(data) else 0\n v87 = data[87] if 87 < len(data) else 0\n v88 = data[88] if 88 < len(data) else 0\n v89 = data[89] if 89 < len(data) else 0\n v90 = data[90] if 90 < len(data) else 0\n v91 = data[91] if 91 < len(data) else 0\n v92 = data[92] if 92 < len(data) else 0\n v93 = data[93] if 93 < len(data) else 0\n v94 = data[94] if 94 < len(data) else 0\n v95 = data[95] if 95 < len(data) else 0\n v96 = data[96] if 96 < len(data) else 0\n v97 = data[97] if 97 < len(data) else 0\n v98 = data[98] if 98 < len(data) else 0\n v99 = data[99] if 99 < len(data) else 0\n v100 = data[100] if 100 < len(data) else 0\n v101 = data[101] if 101 < len(data) else 0\n v102 = data[102] if 102 < len(data) else 0\n v103 = data[103] if 103 < len(data) else 0\n v104 = data[104] if 104 < len(data) else 0\n v105 = data[105] if 105 < len(data) else 0\n v106 = data[106] if 106 < len(data) else 0\n v107 = data[107] if 107 < len(data) else 0\n v108 = data[108] if 108 < len(data) else 0\n v109 = data[109] if 109 < len(data) else 0\n v110 = data[110] if 110 < len(data) else 0\n v111 = data[111] if 111 < len(data) else 0\n v112 = data[112] if 112 < len(data) else 0\n v113 = data[113] if 113 < len(data) else 0\n v114 = data[114] if 114 < len(data) else 0\n v115 = data[115] if 115 < len(data) else 0\n v116 = data[116] if 116 < len(data) else 0\n v117 = data[117] if 117 < len(data) else 0\n v118 = data[118] if 118 < len(data) else 0\n v119 = data[119] if 119 < len(data) else 0\n v120 = data[120] if 120 < len(data) else 0\n v121 = data[121] if 121 < len(data) else 0\n v122 = data[122] if 122 < len(data) else 0\n v123 = data[123] if 123 < len(data) else 0\n v124 = data[124] if 124 < len(data) else 0\n v125 = data[125] if 125 < len(data) else 0\n v126 = data[126] if 126 < len(data) else 0\n v127 = data[127] if 127 < len(data) else 0\n v128 = data[128] if 128 < len(data) else 0\n v129 = data[129] if 129 < len(data) else 0\n v130 = data[130] if 130 < len(data) else 0\n v131 = data[131] if 131 < len(data) else 0\n v132 = data[132] if 132 < len(data) else 0\n v133 = data[133] if 133 < len(data) else 0\n v134 = data[134] if 134 < len(data) else 0\n v135 = data[135] if 135 < len(data) else 0\n v136 = data[136] if 136 < len(data) else 0\n v137 = data[137] if 137 < len(data) else 0\n v138 = data[138] if 138 < len(data) else 0\n v139 = data[139] if 139 < len(data) else 0\n v140 = data[140] if 140 < len(data) else 0\n v141 = data[141] if 141 < len(data) else 0\n v142 = data[142] if 142 < len(data) else 0\n v143 = data[143] if 143 < len(data) else 0\n v144 = data[144] if 144 < len(data) else 0\n v145 = data[145] if 145 < len(data) else 0\n v146 = data[146] if 146 < len(data) else 0\n v147 = data[147] if 147 < len(data) else 0\n v148 = data[148] if 148 < len(data) else 0\n v149 = data[149] if 149 < len(data) else 0\n v150 = data[150] if 150 < len(data) else 0\n v151 = data[151] if 151 < len(data) else 0\n v152 = data[152] if 152 < len(data) else 0\n v153 = data[153] if 153 < len(data) else 0\n v154 = data[154] if 154 < len(data) else 0\n v155 = data[155] if 155 < len(data) else 0\n v156 = data[156] if 156 < len(data) else 0\n v157 = data[157] if 157 < len(data) else 0\n v158 = data[158] if 158 < len(data) else 0\n v159 = data[159] if 159 < len(data) else 0\n v160 = data[160] if 160 < len(data) else 0\n v161 = data[161] if 161 < len(data) else 0\n v162 = data[162] if 162 < len(data) else 0\n v163 = data[163] if 163 < len(data) else 0\n v164 = data[164] if 164 < len(data) else 0\n v165 = data[165] if 165 < len(data) else 0\n v166 = data[166] if 166 < len(data) else 0\n v167 = data[167] if 167 < len(data) else 0\n v168 = data[168] if 168 < len(data) else 0\n v169 = data[169] if 169 < len(data) else 0\n v170 = data[170] if 170 < len(data) else 0\n v171 = data[171] if 171 < len(data) else 0\n v172 = data[172] if 172 < len(data) else 0\n v173 = data[173] if 173 < len(data) else 0\n v174 = data[174] if 174 < len(data) else 0\n v175 = data[175] if 175 < len(data) else 0\n v176 = data[176] if 176 < len(data) else 0\n v177 = data[177] if 177 < len(data) else 0\n v178 = data[178] if 178 < len(data) else 0\n v179 = data[179] if 179 < len(data) else 0\n v180 = data[180] if 180 < len(data) else 0\n v181 = data[181] if 181 < len(data) else 0\n v182 = data[182] if 182 < len(data) else 0\n v183 = data[183] if 183 < len(data) else 0\n v184 = data[184] if 184 < len(data) else 0\n v185 = data[185] if 185 < len(data) else 0\n v186 = data[186] if 186 < len(data) else 0\n v187 = data[187] if 187 < len(data) else 0\n v188 = data[188] if 188 < len(data) else 0\n v189 = data[189] if 189 < len(data) else 0\n v190 = data[190] if 190 < len(data) else 0\n v191 = data[191] if 191 < len(data) else 0\n v192 = data[192] if 192 < len(data) else 0\n v193 = data[193] if 193 < len(data) else 0\n v194 = data[194] if 194 < len(data) else 0\n v195 = data[195] if 195 < len(data) else 0\n v196 = data[196] if 196 < len(data) else 0\n v197 = data[197] if 197 < len(data) else 0\n v198 = data[198] if 198 < len(data) else 0", - "token_estimate": 3015 + "token_estimate": 3015, + "tokenized_korean_text": "def big _ compute ( data ) : v 0 = data [ 0 ] if 0 < len ( data ) else 0 v 1 = data [ 1 ] if 1 < len ( data ) else 0 v 2 = data [ 2 ] if 2 < len ( data ) else 0 v 3 = data [ 3 ] if 3 < len ( data ) else 0 v 4 = data [ 4 ] if 4 < len ( data ) else 0 v 5 = data [ 5 ] if 5 < len ( data ) else 0 v 6 = data [ 6 ] if 6 < len ( data ) else 0 v 7 = data [ 7 ] if 7 < len ( data ) else 0 v 8 = data [ 8 ] if 8 < len ( data ) else 0 v 9 = data [ 9 ] if 9 < len ( data ) else 0 v 10 = data [ 10 ] if 10 < len ( data ) else 0 v 11 = data [ 11 ] if 11 < len ( data ) else 0 v 12 = data [ 12 ] if 12 < len ( data ) else 0 v 13 = data [ 13 ] if 13 < len ( data ) else 0 v 14 = data [ 14 ] if 14 < len ( data ) else 0 v 15 = data [ 15 ] if 15 < len ( data ) else 0 v 16 = data [ 16 ] if 16 < len ( data ) else 0 v 17 = data [ 17 ] if 17 < len ( data ) else 0 v 18 = data [ 18 ] if 18 < len ( data ) else 0 v 19 = data [ 19 ] if 19 < len ( data ) else 0 v 20 = data [ 20 ] if 20 < len ( data ) else 0 v 21 = data [ 21 ] if 21 < len ( data ) else 0 v 22 = data [ 22 ] if 22 < len ( data ) else 0 v 23 = data [ 23 ] if 23 < len ( data ) else 0 v 24 = data [ 24 ] if 24 < len ( data ) else 0 v 25 = data [ 25 ] if 25 < len ( data ) else 0 v 26 = data [ 26 ] if 26 < len ( data ) else 0 v 27 = data [ 27 ] if 27 < len ( data ) else 0 v 28 = data [ 28 ] if 28 < len ( data ) else 0 v 29 = data [ 29 ] if 29 < len ( data ) else 0 v 30 = data [ 30 ] if 30 < len ( data ) else 0 v 31 = data [ 31 ] if 31 < len ( data ) else 0 v 32 = data [ 32 ] if 32 < len ( data ) else 0 v 33 = data [ 33 ] if 33 < len ( data ) else 0 v 34 = data [ 34 ] if 34 < len ( data ) else 0 v 35 = data [ 35 ] if 35 < len ( data ) else 0 v 36 = data [ 36 ] if 36 < len ( data ) else 0 v 37 = data [ 37 ] if 37 < len ( data ) else 0 v 38 = data [ 38 ] if 38 < len ( data ) else 0 v 39 = data [ 39 ] if 39 < len ( data ) else 0 v 40 = data [ 40 ] if 40 < len ( data ) else 0 v 41 = data [ 41 ] if 41 < len ( data ) else 0 v 42 = data [ 42 ] if 42 < len ( data ) else 0 v 43 = data [ 43 ] if 43 < len ( data ) else 0 v 44 = data [ 44 ] if 44 < len ( data ) else 0 v 45 = data [ 45 ] if 45 < len ( data ) else 0 v 46 = data [ 46 ] if 46 < len ( data ) else 0 v 47 = data [ 47 ] if 47 < len ( data ) else 0 v 48 = data [ 48 ] if 48 < len ( data ) else 0 v 49 = data [ 49 ] if 49 < len ( data ) else 0 v 50 = data [ 50 ] if 50 < len ( data ) else 0 v 51 = data [ 51 ] if 51 < len ( data ) else 0 v 52 = data [ 52 ] if 52 < len ( data ) else 0 v 53 = data [ 53 ] if 53 < len ( data ) else 0 v 54 = data [ 54 ] if 54 < len ( data ) else 0 v 55 = data [ 55 ] if 55 < len ( data ) else 0 v 56 = data [ 56 ] if 56 < len ( data ) else 0 v 57 = data [ 57 ] if 57 < len ( data ) else 0 v 58 = data [ 58 ] if 58 < len ( data ) else 0 v 59 = data [ 59 ] if 59 < len ( data ) else 0 v 60 = data [ 60 ] if 60 < len ( data ) else 0 v 61 = data [ 61 ] if 61 < len ( data ) else 0 v 62 = data [ 62 ] if 62 < len ( data ) else 0 v 63 = data [ 63 ] if 63 < len ( data ) else 0 v 64 = data [ 64 ] if 64 < len ( data ) else 0 v 65 = data [ 65 ] if 65 < len ( data ) else 0 v 66 = data [ 66 ] if 66 < len ( data ) else 0 v 67 = data [ 67 ] if 67 < len ( data ) else 0 v 68 = data [ 68 ] if 68 < len ( data ) else 0 v 69 = data [ 69 ] if 69 < len ( data ) else 0 v 70 = data [ 70 ] if 70 < len ( data ) else 0 v 71 = data [ 71 ] if 71 < len ( data ) else 0 v 72 = data [ 72 ] if 72 < len ( data ) else 0 v 73 = data [ 73 ] if 73 < len ( data ) else 0 v 74 = data [ 74 ] if 74 < len ( data ) else 0 v 75 = data [ 75 ] if 75 < len ( data ) else 0 v 76 = data [ 76 ] if 76 < len ( data ) else 0 v 77 = data [ 77 ] if 77 < len ( data ) else 0 v 78 = data [ 78 ] if 78 < len ( data ) else 0 v 79 = data [ 79 ] if 79 < len ( data ) else 0 v 80 = data [ 80 ] if 80 < len ( data ) else 0 v 81 = data [ 81 ] if 81 < len ( data ) else 0 v 82 = data [ 82 ] if 82 < len ( data ) else 0 v 83 = data [ 83 ] if 83 < len ( data ) else 0 v 84 = data [ 84 ] if 84 < len ( data ) else 0 v 85 = data [ 85 ] if 85 < len ( data ) else 0 v 86 = data [ 86 ] if 86 < len ( data ) else 0 v 87 = data [ 87 ] if 87 < len ( data ) else 0 v 88 = data [ 88 ] if 88 < len ( data ) else 0 v 89 = data [ 89 ] if 89 < len ( data ) else 0 v 90 = data [ 90 ] if 90 < len ( data ) else 0 v 91 = data [ 91 ] if 91 < len ( data ) else 0 v 92 = data [ 92 ] if 92 < len ( data ) else 0 v 93 = data [ 93 ] if 93 < len ( data ) else 0 v 94 = data [ 94 ] if 94 < len ( data ) else 0 v 95 = data [ 95 ] if 95 < len ( data ) else 0 v 96 = data [ 96 ] if 96 < len ( data ) else 0 v 97 = data [ 97 ] if 97 < len ( data ) else 0 v 98 = data [ 98 ] if 98 < len ( data ) else 0 v 99 = data [ 99 ] if 99 < len ( data ) else 0 v 100 = data [ 100 ] if 100 < len ( data ) else 0 v 101 = data [ 101 ] if 101 < len ( data ) else 0 v 102 = data [ 102 ] if 102 < len ( data ) else 0 v 103 = data [ 103 ] if 103 < len ( data ) else 0 v 104 = data [ 104 ] if 104 < len ( data ) else 0 v 105 = data [ 105 ] if 105 < len ( data ) else 0 v 106 = data [ 106 ] if 106 < len ( data ) else 0 v 107 = data [ 107 ] if 107 < len ( data ) else 0 v 108 = data [ 108 ] if 108 < len ( data ) else 0 v 109 = data [ 109 ] if 109 < len ( data ) else 0 v 110 = data [ 110 ] if 110 < len ( data ) else 0 v 111 = data [ 111 ] if 111 < len ( data ) else 0 v 112 = data [ 112 ] if 112 < len ( data ) else 0 v 113 = data [ 113 ] if 113 < len ( data ) else 0 v 114 = data [ 114 ] if 114 < len ( data ) else 0 v 115 = data [ 115 ] if 115 < len ( data ) else 0 v 116 = data [ 116 ] if 116 < len ( data ) else 0 v 117 = data [ 117 ] if 117 < len ( data ) else 0 v 118 = data [ 118 ] if 118 < len ( data ) else 0 v 119 = data [ 119 ] if 119 < len ( data ) else 0 v 120 = data [ 120 ] if 120 < len ( data ) else 0 v 121 = data [ 121 ] if 121 < len ( data ) else 0 v 122 = data [ 122 ] if 122 < len ( data ) else 0 v 123 = data [ 123 ] if 123 < len ( data ) else 0 v 124 = data [ 124 ] if 124 < len ( data ) else 0 v 125 = data [ 125 ] if 125 < len ( data ) else 0 v 126 = data [ 126 ] if 126 < len ( data ) else 0 v 127 = data [ 127 ] if 127 < len ( data ) else 0 v 128 = data [ 128 ] if 128 < len ( data ) else 0 v 129 = data [ 129 ] if 129 < len ( data ) else 0 v 130 = data [ 130 ] if 130 < len ( data ) else 0 v 131 = data [ 131 ] if 131 < len ( data ) else 0 v 132 = data [ 132 ] if 132 < len ( data ) else 0 v 133 = data [ 133 ] if 133 < len ( data ) else 0 v 134 = data [ 134 ] if 134 < len ( data ) else 0 v 135 = data [ 135 ] if 135 < len ( data ) else 0 v 136 = data [ 136 ] if 136 < len ( data ) else 0 v 137 = data [ 137 ] if 137 < len ( data ) else 0 v 138 = data [ 138 ] if 138 < len ( data ) else 0 v 139 = data [ 139 ] if 139 < len ( data ) else 0 v 140 = data [ 140 ] if 140 < len ( data ) else 0 v 141 = data [ 141 ] if 141 < len ( data ) else 0 v 142 = data [ 142 ] if 142 < len ( data ) else 0 v 143 = data [ 143 ] if 143 < len ( data ) else 0 v 144 = data [ 144 ] if 144 < len ( data ) else 0 v 145 = data [ 145 ] if 145 < len ( data ) else 0 v 146 = data [ 146 ] if 146 < len ( data ) else 0 v 147 = data [ 147 ] if 147 < len ( data ) else 0 v 148 = data [ 148 ] if 148 < len ( data ) else 0 v 149 = data [ 149 ] if 149 < len ( data ) else 0 v 150 = data [ 150 ] if 150 < len ( data ) else 0 v 151 = data [ 151 ] if 151 < len ( data ) else 0 v 152 = data [ 152 ] if 152 < len ( data ) else 0 v 153 = data [ 153 ] if 153 < len ( data ) else 0 v 154 = data [ 154 ] if 154 < len ( data ) else 0 v 155 = data [ 155 ] if 155 < len ( data ) else 0 v 156 = data [ 156 ] if 156 < len ( data ) else 0 v 157 = data [ 157 ] if 157 < len ( data ) else 0 v 158 = data [ 158 ] if 158 < len ( data ) else 0 v 159 = data [ 159 ] if 159 < len ( data ) else 0 v 160 = data [ 160 ] if 160 < len ( data ) else 0 v 161 = data [ 161 ] if 161 < len ( data ) else 0 v 162 = data [ 162 ] if 162 < len ( data ) else 0 v 163 = data [ 163 ] if 163 < len ( data ) else 0 v 164 = data [ 164 ] if 164 < len ( data ) else 0 v 165 = data [ 165 ] if 165 < len ( data ) else 0 v 166 = data [ 166 ] if 166 < len ( data ) else 0 v 167 = data [ 167 ] if 167 < len ( data ) else 0 v 168 = data [ 168 ] if 168 < len ( data ) else 0 v 169 = data [ 169 ] if 169 < len ( data ) else 0 v 170 = data [ 170 ] if 170 < len ( data ) else 0 v 171 = data [ 171 ] if 171 < len ( data ) else 0 v 172 = data [ 172 ] if 172 < len ( data ) else 0 v 173 = data [ 173 ] if 173 < len ( data ) else 0 v 174 = data [ 174 ] if 174 < len ( data ) else 0 v 175 = data [ 175 ] if 175 < len ( data ) else 0 v 176 = data [ 176 ] if 176 < len ( data ) else 0 v 177 = data [ 177 ] if 177 < len ( data ) else 0 v 178 = data [ 178 ] if 178 < len ( data ) else 0 v 179 = data [ 179 ] if 179 < len ( data ) else 0 v 180 = data [ 180 ] if 180 < len ( data ) else 0 v 181 = data [ 181 ] if 181 < len ( data ) else 0 v 182 = data [ 182 ] if 182 < len ( data ) else 0 v 183 = data [ 183 ] if 183 < len ( data ) else 0 v 184 = data [ 184 ] if 184 < len ( data ) else 0 v 185 = data [ 185 ] if 185 < len ( data ) else 0 v 186 = data [ 186 ] if 186 < len ( data ) else 0 v 187 = data [ 187 ] if 187 < len ( data ) else 0 v 188 = data [ 188 ] if 188 < len ( data ) else 0 v 189 = data [ 189 ] if 189 < len ( data ) else 0 v 190 = data [ 190 ] if 190 < len ( data ) else 0 v 191 = data [ 191 ] if 191 < len ( data ) else 0 v 192 = data [ 192 ] if 192 < len ( data ) else 0 v 193 = data [ 193 ] if 193 < len ( data ) else 0 v 194 = data [ 194 ] if 194 < len ( data ) else 0 v 195 = data [ 195 ] if 195 < len ( data ) else 0 v 196 = data [ 196 ] if 196 < len ( data ) else 0 v 197 = data [ 197 ] if 197 < len ( data ) else 0 v 198 = data [ 198 ] if 198 < len ( data ) else 0" }, { "block_ids": [ @@ -165,6 +172,7 @@ } ], "text": " v199 = data[199] if 199 < len(data) else 0\n v200 = data[200] if 200 < len(data) else 0\n v201 = data[201] if 201 < len(data) else 0\n v202 = data[202] if 202 < len(data) else 0\n v203 = data[203] if 203 < len(data) else 0\n v204 = data[204] if 204 < len(data) else 0\n v205 = data[205] if 205 < len(data) else 0\n v206 = data[206] if 206 < len(data) else 0\n v207 = data[207] if 207 < len(data) else 0\n v208 = data[208] if 208 < len(data) else 0\n v209 = data[209] if 209 < len(data) else 0\n return sum(data)", - "token_estimate": 179 + "token_estimate": 179, + "tokenized_korean_text": "v 199 = data [ 199 ] if 199 < len ( data ) else 0 v 200 = data [ 200 ] if 200 < len ( data ) else 0 v 201 = data [ 201 ] if 201 < len ( data ) else 0 v 202 = data [ 202 ] if 202 < len ( data ) else 0 v 203 = data [ 203 ] if 203 < len ( data ) else 0 v 204 = data [ 204 ] if 204 < len ( data ) else 0 v 205 = data [ 205 ] if 205 < len ( data ) else 0 v 206 = data [ 206 ] if 206 < len ( data ) else 0 v 207 = data [ 207 ] if 207 < len ( data ) else 0 v 208 = data [ 208 ] if 208 < len ( data ) else 0 v 209 = data [ 209 ] if 209 < len ( data ) else 0 return sum ( data )" } ] diff --git a/crates/kebab-chunk/tests/fixtures/code-sample.ts.chunks.snapshot.json b/crates/kebab-chunk/tests/fixtures/code-sample.ts.chunks.snapshot.json index 446b98d..e9de78f 100644 --- a/crates/kebab-chunk/tests/fixtures/code-sample.ts.chunks.snapshot.json +++ b/crates/kebab-chunk/tests/fixtures/code-sample.ts.chunks.snapshot.json @@ -18,7 +18,8 @@ } ], "text": "import { readFileSync } from 'fs';\nimport { join } from 'path';\nimport type { Config } from './config';\nimport { Logger } from './logger';\nimport { EventEmitter } from 'events';", - "token_estimate": 59 + "token_estimate": 59, + "tokenized_korean_text": "import { readFileSync } from ' fs '; import { join } from ' path '; import type { Config } from './ config '; import { Logger } from './ logger '; import { EventEmitter } from ' events ';" }, { "block_ids": [ @@ -39,7 +40,8 @@ } ], "text": "export function parseInput(raw: string): number | null {\n const trimmed = raw.trim();\n const n = Number(trimmed);\n if (isNaN(n)) return null;\n return n;\n}", - "token_estimate": 53 + "token_estimate": 53, + "tokenized_korean_text": "export function parseInput ( raw : string ) : number | null { const trimmed = raw . trim (); const n = Number ( trimmed ); if ( isNaN ( n ) ) return null ; return n ; }" }, { "block_ids": [ @@ -60,7 +62,8 @@ } ], "text": "export interface Frobable {\n frob(): string;\n frobTwice(): string;\n readonly name: string;\n readonly tags: string[];\n count: number;\n reset(): void;\n}", - "token_estimate": 52 + "token_estimate": 52, + "tokenized_korean_text": "export interface Frobable { frob ( ) : string ; frobTwice ( ) : string ; readonly name : string ; readonly tags : string []; count : number ; reset ( ) : void ; }" }, { "block_ids": [ @@ -81,7 +84,8 @@ } ], "text": "export class Foo implements Frobable {\n constructor(\n public readonly name: string,\n public value: number,\n public tags: string[] = [],\n ) {}\n frob(): string { return this.name; }\n frobTwice(): string { return this.name.repeat(2); }\n reset(): void { this.value = 0; }\n}", - "token_estimate": 95 + "token_estimate": 95, + "tokenized_korean_text": "export class Foo implements Frobable { constructor ( public readonly name : string , public value : number , public tags : string [ ] = [ ] , ) {} frob ( ) : string { return this . name ; } frobTwice ( ) : string { return this . name . repeat ( 2 ); } reset ( ) : void { this . value = 0 ; } }" }, { "block_ids": [ @@ -102,7 +106,8 @@ } ], "text": "export class Foo {\n double(): number {\n const result = this.value * 2;\n if (result > Number.MAX_SAFE_INTEGER) {\n return Number.MAX_SAFE_INTEGER;\n }\n return result;\n }\n}", - "token_estimate": 63 + "token_estimate": 63, + "tokenized_korean_text": "export class Foo { double ( ) : number { const result = this . value * 2 ; if ( result > Number . MAX _ SAFE _ INTEGER ) { return Number . MAX _ SAFE _ INTEGER ; } return result ; } }" }, { "block_ids": [ @@ -123,7 +128,8 @@ } ], "text": "export class Foo {\n triple(): number {\n const result = this.value * 3;\n if (result > Number.MAX_SAFE_INTEGER) {\n return Number.MAX_SAFE_INTEGER;\n }\n return result;\n }\n}", - "token_estimate": 63 + "token_estimate": 63, + "tokenized_korean_text": "export class Foo { triple ( ) : number { const result = this . value * 3 ; if ( result > Number . MAX _ SAFE _ INTEGER ) { return Number . MAX _ SAFE _ INTEGER ; } return result ; } }" }, { "block_ids": [ @@ -144,7 +150,8 @@ } ], "text": "export class BigProcessor {\n process(items: string[]): string[] {\n const v0 = items[0] ?? '';\n const v1 = items[1] ?? '';\n const v2 = items[2] ?? '';\n const v3 = items[3] ?? '';\n const v4 = items[4] ?? '';\n const v5 = items[5] ?? '';\n const v6 = items[6] ?? '';\n const v7 = items[7] ?? '';\n const v8 = items[8] ?? '';\n const v9 = items[9] ?? '';\n const v10 = items[10] ?? '';\n const v11 = items[11] ?? '';\n const v12 = items[12] ?? '';\n const v13 = items[13] ?? '';\n const v14 = items[14] ?? '';\n const v15 = items[15] ?? '';\n const v16 = items[16] ?? '';\n const v17 = items[17] ?? '';\n const v18 = items[18] ?? '';\n const v19 = items[19] ?? '';\n const v20 = items[20] ?? '';\n const v21 = items[21] ?? '';\n const v22 = items[22] ?? '';\n const v23 = items[23] ?? '';\n const v24 = items[24] ?? '';\n const v25 = items[25] ?? '';\n const v26 = items[26] ?? '';\n const v27 = items[27] ?? '';\n const v28 = items[28] ?? '';\n const v29 = items[29] ?? '';\n const v30 = items[30] ?? '';\n const v31 = items[31] ?? '';\n const v32 = items[32] ?? '';\n const v33 = items[33] ?? '';\n const v34 = items[34] ?? '';\n const v35 = items[35] ?? '';\n const v36 = items[36] ?? '';\n const v37 = items[37] ?? '';\n const v38 = items[38] ?? '';\n const v39 = items[39] ?? '';\n const v40 = items[40] ?? '';\n const v41 = items[41] ?? '';\n const v42 = items[42] ?? '';\n const v43 = items[43] ?? '';\n const v44 = items[44] ?? '';\n const v45 = items[45] ?? '';\n const v46 = items[46] ?? '';\n const v47 = items[47] ?? '';\n const v48 = items[48] ?? '';\n const v49 = items[49] ?? '';\n const v50 = items[50] ?? '';\n const v51 = items[51] ?? '';\n const v52 = items[52] ?? '';\n const v53 = items[53] ?? '';\n const v54 = items[54] ?? '';\n const v55 = items[55] ?? '';\n const v56 = items[56] ?? '';\n const v57 = items[57] ?? '';\n const v58 = items[58] ?? '';\n const v59 = items[59] ?? '';\n const v60 = items[60] ?? '';\n const v61 = items[61] ?? '';\n const v62 = items[62] ?? '';\n const v63 = items[63] ?? '';\n const v64 = items[64] ?? '';\n const v65 = items[65] ?? '';\n const v66 = items[66] ?? '';\n const v67 = items[67] ?? '';\n const v68 = items[68] ?? '';\n const v69 = items[69] ?? '';\n const v70 = items[70] ?? '';\n const v71 = items[71] ?? '';\n const v72 = items[72] ?? '';\n const v73 = items[73] ?? '';\n const v74 = items[74] ?? '';\n const v75 = items[75] ?? '';\n const v76 = items[76] ?? '';\n const v77 = items[77] ?? '';\n const v78 = items[78] ?? '';\n const v79 = items[79] ?? '';\n const v80 = items[80] ?? '';\n const v81 = items[81] ?? '';\n const v82 = items[82] ?? '';\n const v83 = items[83] ?? '';\n const v84 = items[84] ?? '';\n const v85 = items[85] ?? '';\n const v86 = items[86] ?? '';\n const v87 = items[87] ?? '';\n const v88 = items[88] ?? '';\n const v89 = items[89] ?? '';\n const v90 = items[90] ?? '';\n const v91 = items[91] ?? '';\n const v92 = items[92] ?? '';\n const v93 = items[93] ?? '';\n const v94 = items[94] ?? '';\n const v95 = items[95] ?? '';\n const v96 = items[96] ?? '';\n const v97 = items[97] ?? '';\n const v98 = items[98] ?? '';\n const v99 = items[99] ?? '';\n const v100 = items[100] ?? '';\n const v101 = items[101] ?? '';\n const v102 = items[102] ?? '';\n const v103 = items[103] ?? '';\n const v104 = items[104] ?? '';\n const v105 = items[105] ?? '';\n const v106 = items[106] ?? '';\n const v107 = items[107] ?? '';\n const v108 = items[108] ?? '';\n const v109 = items[109] ?? '';\n const v110 = items[110] ?? '';\n const v111 = items[111] ?? '';\n const v112 = items[112] ?? '';\n const v113 = items[113] ?? '';\n const v114 = items[114] ?? '';\n const v115 = items[115] ?? '';\n const v116 = items[116] ?? '';\n const v117 = items[117] ?? '';\n const v118 = items[118] ?? '';\n const v119 = items[119] ?? '';\n const v120 = items[120] ?? '';\n const v121 = items[121] ?? '';\n const v122 = items[122] ?? '';\n const v123 = items[123] ?? '';\n const v124 = items[124] ?? '';\n const v125 = items[125] ?? '';\n const v126 = items[126] ?? '';\n const v127 = items[127] ?? '';\n const v128 = items[128] ?? '';\n const v129 = items[129] ?? '';\n const v130 = items[130] ?? '';\n const v131 = items[131] ?? '';\n const v132 = items[132] ?? '';\n const v133 = items[133] ?? '';\n const v134 = items[134] ?? '';\n const v135 = items[135] ?? '';\n const v136 = items[136] ?? '';\n const v137 = items[137] ?? '';\n const v138 = items[138] ?? '';\n const v139 = items[139] ?? '';\n const v140 = items[140] ?? '';\n const v141 = items[141] ?? '';\n const v142 = items[142] ?? '';\n const v143 = items[143] ?? '';\n const v144 = items[144] ?? '';\n const v145 = items[145] ?? '';\n const v146 = items[146] ?? '';\n const v147 = items[147] ?? '';\n const v148 = items[148] ?? '';\n const v149 = items[149] ?? '';\n const v150 = items[150] ?? '';\n const v151 = items[151] ?? '';\n const v152 = items[152] ?? '';\n const v153 = items[153] ?? '';\n const v154 = items[154] ?? '';\n const v155 = items[155] ?? '';\n const v156 = items[156] ?? '';\n const v157 = items[157] ?? '';\n const v158 = items[158] ?? '';\n const v159 = items[159] ?? '';\n const v160 = items[160] ?? '';\n const v161 = items[161] ?? '';\n const v162 = items[162] ?? '';\n const v163 = items[163] ?? '';\n const v164 = items[164] ?? '';\n const v165 = items[165] ?? '';\n const v166 = items[166] ?? '';\n const v167 = items[167] ?? '';\n const v168 = items[168] ?? '';\n const v169 = items[169] ?? '';\n const v170 = items[170] ?? '';\n const v171 = items[171] ?? '';\n const v172 = items[172] ?? '';\n const v173 = items[173] ?? '';\n const v174 = items[174] ?? '';\n const v175 = items[175] ?? '';\n const v176 = items[176] ?? '';\n const v177 = items[177] ?? '';\n const v178 = items[178] ?? '';\n const v179 = items[179] ?? '';\n const v180 = items[180] ?? '';\n const v181 = items[181] ?? '';\n const v182 = items[182] ?? '';\n const v183 = items[183] ?? '';\n const v184 = items[184] ?? '';\n const v185 = items[185] ?? '';\n const v186 = items[186] ?? '';\n const v187 = items[187] ?? '';\n const v188 = items[188] ?? '';\n const v189 = items[189] ?? '';\n const v190 = items[190] ?? '';\n const v191 = items[191] ?? '';\n const v192 = items[192] ?? '';\n const v193 = items[193] ?? '';\n const v194 = items[194] ?? '';\n const v195 = items[195] ?? '';\n const v196 = items[196] ?? '';\n const v197 = items[197] ?? '';", - "token_estimate": 2259 + "token_estimate": 2259, + "tokenized_korean_text": "export class BigProcessor { process ( items : string [ ] ) : string [ ] { const v 0 = items [ 0 ] ?? ''; const v 1 = items [ 1 ] ?? ''; const v 2 = items [ 2 ] ?? ''; const v 3 = items [ 3 ] ?? ''; const v 4 = items [ 4 ] ?? ''; const v 5 = items [ 5 ] ?? ''; const v 6 = items [ 6 ] ?? ''; const v 7 = items [ 7 ] ?? ''; const v 8 = items [ 8 ] ?? ''; const v 9 = items [ 9 ] ?? ''; const v 10 = items [ 10 ] ?? ''; const v 11 = items [ 11 ] ?? ''; const v 12 = items [ 12 ] ?? ''; const v 13 = items [ 13 ] ?? ''; const v 14 = items [ 14 ] ?? ''; const v 15 = items [ 15 ] ?? ''; const v 16 = items [ 16 ] ?? ''; const v 17 = items [ 17 ] ?? ''; const v 18 = items [ 18 ] ?? ''; const v 19 = items [ 19 ] ?? ''; const v 20 = items [ 20 ] ?? ''; const v 21 = items [ 21 ] ?? ''; const v 22 = items [ 22 ] ?? ''; const v 23 = items [ 23 ] ?? ''; const v 24 = items [ 24 ] ?? ''; const v 25 = items [ 25 ] ?? ''; const v 26 = items [ 26 ] ?? ''; const v 27 = items [ 27 ] ?? ''; const v 28 = items [ 28 ] ?? ''; const v 29 = items [ 29 ] ?? ''; const v 30 = items [ 30 ] ?? ''; const v 31 = items [ 31 ] ?? ''; const v 32 = items [ 32 ] ?? ''; const v 33 = items [ 33 ] ?? ''; const v 34 = items [ 34 ] ?? ''; const v 35 = items [ 35 ] ?? ''; const v 36 = items [ 36 ] ?? ''; const v 37 = items [ 37 ] ?? ''; const v 38 = items [ 38 ] ?? ''; const v 39 = items [ 39 ] ?? ''; const v 40 = items [ 40 ] ?? ''; const v 41 = items [ 41 ] ?? ''; const v 42 = items [ 42 ] ?? ''; const v 43 = items [ 43 ] ?? ''; const v 44 = items [ 44 ] ?? ''; const v 45 = items [ 45 ] ?? ''; const v 46 = items [ 46 ] ?? ''; const v 47 = items [ 47 ] ?? ''; const v 48 = items [ 48 ] ?? ''; const v 49 = items [ 49 ] ?? ''; const v 50 = items [ 50 ] ?? ''; const v 51 = items [ 51 ] ?? ''; const v 52 = items [ 52 ] ?? ''; const v 53 = items [ 53 ] ?? ''; const v 54 = items [ 54 ] ?? ''; const v 55 = items [ 55 ] ?? ''; const v 56 = items [ 56 ] ?? ''; const v 57 = items [ 57 ] ?? ''; const v 58 = items [ 58 ] ?? ''; const v 59 = items [ 59 ] ?? ''; const v 60 = items [ 60 ] ?? ''; const v 61 = items [ 61 ] ?? ''; const v 62 = items [ 62 ] ?? ''; const v 63 = items [ 63 ] ?? ''; const v 64 = items [ 64 ] ?? ''; const v 65 = items [ 65 ] ?? ''; const v 66 = items [ 66 ] ?? ''; const v 67 = items [ 67 ] ?? ''; const v 68 = items [ 68 ] ?? ''; const v 69 = items [ 69 ] ?? ''; const v 70 = items [ 70 ] ?? ''; const v 71 = items [ 71 ] ?? ''; const v 72 = items [ 72 ] ?? ''; const v 73 = items [ 73 ] ?? ''; const v 74 = items [ 74 ] ?? ''; const v 75 = items [ 75 ] ?? ''; const v 76 = items [ 76 ] ?? ''; const v 77 = items [ 77 ] ?? ''; const v 78 = items [ 78 ] ?? ''; const v 79 = items [ 79 ] ?? ''; const v 80 = items [ 80 ] ?? ''; const v 81 = items [ 81 ] ?? ''; const v 82 = items [ 82 ] ?? ''; const v 83 = items [ 83 ] ?? ''; const v 84 = items [ 84 ] ?? ''; const v 85 = items [ 85 ] ?? ''; const v 86 = items [ 86 ] ?? ''; const v 87 = items [ 87 ] ?? ''; const v 88 = items [ 88 ] ?? ''; const v 89 = items [ 89 ] ?? ''; const v 90 = items [ 90 ] ?? ''; const v 91 = items [ 91 ] ?? ''; const v 92 = items [ 92 ] ?? ''; const v 93 = items [ 93 ] ?? ''; const v 94 = items [ 94 ] ?? ''; const v 95 = items [ 95 ] ?? ''; const v 96 = items [ 96 ] ?? ''; const v 97 = items [ 97 ] ?? ''; const v 98 = items [ 98 ] ?? ''; const v 99 = items [ 99 ] ?? ''; const v 100 = items [ 100 ] ?? ''; const v 101 = items [ 101 ] ?? ''; const v 102 = items [ 102 ] ?? ''; const v 103 = items [ 103 ] ?? ''; const v 104 = items [ 104 ] ?? ''; const v 105 = items [ 105 ] ?? ''; const v 106 = items [ 106 ] ?? ''; const v 107 = items [ 107 ] ?? ''; const v 108 = items [ 108 ] ?? ''; const v 109 = items [ 109 ] ?? ''; const v 110 = items [ 110 ] ?? ''; const v 111 = items [ 111 ] ?? ''; const v 112 = items [ 112 ] ?? ''; const v 113 = items [ 113 ] ?? ''; const v 114 = items [ 114 ] ?? ''; const v 115 = items [ 115 ] ?? ''; const v 116 = items [ 116 ] ?? ''; const v 117 = items [ 117 ] ?? ''; const v 118 = items [ 118 ] ?? ''; const v 119 = items [ 119 ] ?? ''; const v 120 = items [ 120 ] ?? ''; const v 121 = items [ 121 ] ?? ''; const v 122 = items [ 122 ] ?? ''; const v 123 = items [ 123 ] ?? ''; const v 124 = items [ 124 ] ?? ''; const v 125 = items [ 125 ] ?? ''; const v 126 = items [ 126 ] ?? ''; const v 127 = items [ 127 ] ?? ''; const v 128 = items [ 128 ] ?? ''; const v 129 = items [ 129 ] ?? ''; const v 130 = items [ 130 ] ?? ''; const v 131 = items [ 131 ] ?? ''; const v 132 = items [ 132 ] ?? ''; const v 133 = items [ 133 ] ?? ''; const v 134 = items [ 134 ] ?? ''; const v 135 = items [ 135 ] ?? ''; const v 136 = items [ 136 ] ?? ''; const v 137 = items [ 137 ] ?? ''; const v 138 = items [ 138 ] ?? ''; const v 139 = items [ 139 ] ?? ''; const v 140 = items [ 140 ] ?? ''; const v 141 = items [ 141 ] ?? ''; const v 142 = items [ 142 ] ?? ''; const v 143 = items [ 143 ] ?? ''; const v 144 = items [ 144 ] ?? ''; const v 145 = items [ 145 ] ?? ''; const v 146 = items [ 146 ] ?? ''; const v 147 = items [ 147 ] ?? ''; const v 148 = items [ 148 ] ?? ''; const v 149 = items [ 149 ] ?? ''; const v 150 = items [ 150 ] ?? ''; const v 151 = items [ 151 ] ?? ''; const v 152 = items [ 152 ] ?? ''; const v 153 = items [ 153 ] ?? ''; const v 154 = items [ 154 ] ?? ''; const v 155 = items [ 155 ] ?? ''; const v 156 = items [ 156 ] ?? ''; const v 157 = items [ 157 ] ?? ''; const v 158 = items [ 158 ] ?? ''; const v 159 = items [ 159 ] ?? ''; const v 160 = items [ 160 ] ?? ''; const v 161 = items [ 161 ] ?? ''; const v 162 = items [ 162 ] ?? ''; const v 163 = items [ 163 ] ?? ''; const v 164 = items [ 164 ] ?? ''; const v 165 = items [ 165 ] ?? ''; const v 166 = items [ 166 ] ?? ''; const v 167 = items [ 167 ] ?? ''; const v 168 = items [ 168 ] ?? ''; const v 169 = items [ 169 ] ?? ''; const v 170 = items [ 170 ] ?? ''; const v 171 = items [ 171 ] ?? ''; const v 172 = items [ 172 ] ?? ''; const v 173 = items [ 173 ] ?? ''; const v 174 = items [ 174 ] ?? ''; const v 175 = items [ 175 ] ?? ''; const v 176 = items [ 176 ] ?? ''; const v 177 = items [ 177 ] ?? ''; const v 178 = items [ 178 ] ?? ''; const v 179 = items [ 179 ] ?? ''; const v 180 = items [ 180 ] ?? ''; const v 181 = items [ 181 ] ?? ''; const v 182 = items [ 182 ] ?? ''; const v 183 = items [ 183 ] ?? ''; const v 184 = items [ 184 ] ?? ''; const v 185 = items [ 185 ] ?? ''; const v 186 = items [ 186 ] ?? ''; const v 187 = items [ 187 ] ?? ''; const v 188 = items [ 188 ] ?? ''; const v 189 = items [ 189 ] ?? ''; const v 190 = items [ 190 ] ?? ''; const v 191 = items [ 191 ] ?? ''; const v 192 = items [ 192 ] ?? ''; const v 193 = items [ 193 ] ?? ''; const v 194 = items [ 194 ] ?? ''; const v 195 = items [ 195 ] ?? ''; const v 196 = items [ 196 ] ?? ''; const v 197 = items [ 197 ] ?? '';" }, { "block_ids": [ @@ -165,6 +172,7 @@ } ], "text": " const v198 = items[198] ?? '';\n const v199 = items[199] ?? '';\n const v200 = items[200] ?? '';\n const v201 = items[201] ?? '';\n const v202 = items[202] ?? '';\n const v203 = items[203] ?? '';\n const v204 = items[204] ?? '';\n const v205 = items[205] ?? '';\n const v206 = items[206] ?? '';\n const v207 = items[207] ?? '';\n const v208 = items[208] ?? '';\n const v209 = items[209] ?? '';\n return items;\n }\n}", - "token_estimate": 148 + "token_estimate": 148, + "tokenized_korean_text": "const v 198 = items [ 198 ] ?? ''; const v 199 = items [ 199 ] ?? ''; const v 200 = items [ 200 ] ?? ''; const v 201 = items [ 201 ] ?? ''; const v 202 = items [ 202 ] ?? ''; const v 203 = items [ 203 ] ?? ''; const v 204 = items [ 204 ] ?? ''; const v 205 = items [ 205 ] ?? ''; const v 206 = items [ 206 ] ?? ''; const v 207 = items [ 207 ] ?? ''; const v 208 = items [ 208 ] ?? ''; const v 209 = items [ 209 ] ?? ''; return items ; } }" } ] diff --git a/crates/kebab-chunk/tests/tokenize_korean.rs b/crates/kebab-chunk/tests/tokenize_korean.rs new file mode 100644 index 0000000..e5af732 --- /dev/null +++ b/crates/kebab-chunk/tests/tokenize_korean.rs @@ -0,0 +1,81 @@ +#[test] +fn tokenize_korean_morphological_splits_2char_word() { + let out = kebab_chunk::tokenize_korean_morphological("한국 문화는 오래되었다").unwrap(); + let tokens: Vec<&str> = out.split_whitespace().collect(); + assert!(tokens.contains(&"한국"), "tokens = {tokens:?}"); +} + +#[test] +fn tokenize_korean_morphological_empty_returns_none() { + assert!(kebab_chunk::tokenize_korean_morphological("").is_none()); + assert!(kebab_chunk::tokenize_korean_morphological(" ").is_none()); +} + +/// v0.21.0 N-gram supplement (Option β): morpheme 길이 ≥ 3 인 한글 token +/// (ko-dic 가 단일 compound 으로 저장한 case) 에 대해 sliding window +/// 2-gram 보충 emit. ko-dic 가 이미 `한국정부` → `[한국, 정부]` 처럼 잘 +/// 분해하는 경우는 2-char morpheme 이라 supplement 안 함 (filter 의도). +#[test] +fn tokenize_korean_morphological_emits_2gram_for_long_morpheme() { + // ko-dic 의 분해 정책 검증: 어떤 input 이 3+자 morpheme 을 emit 하는지. + // 본 test 는 lindera ko-dic 의 segmentation 의존이라 구체 fixture 는 + // morpheme list 가 ≥ 3 char token 을 포함하는 case 를 사용. + let probe_inputs: &[&str] = &[ + "한국문화", // ko-dic 가 단일 명사로 등록 가능 → 3+ char morpheme + "주민등록번호", // 4+ char compound — supplement 대상 + "서울특별시", // 3+ char + "대한민국", // 3+ char + "오래되었다", // 동사 활용형 — 일부 3+ char morpheme 가능 + ]; + + let mut found_supplement = false; + for input in probe_inputs { + let out = kebab_chunk::tokenize_korean_morphological(input).unwrap_or_default(); + let tokens: Vec<&str> = out.split_whitespace().collect(); + let unique: std::collections::HashSet<&&str> = tokens.iter().collect(); + // supplement 가 작동했다면 distinct token 수가 lindera output 의 morpheme 수보다 많음. + // 또는 input 의 2-char prefix 가 별도 token 으로 존재. + let prefix: String = input.chars().take(2).collect(); + if tokens.contains(&prefix.as_str()) && tokens.iter().any(|t| t.chars().count() >= 3) { + found_supplement = true; + println!("supplement fired for input '{input}' → tokens = {tokens:?}"); + } + // 영어/숫자 prefix 가 emit 되지 않음 (한글만 supplement 대상). + // 무조건 unique token 수 ≥ 1. + assert!(!unique.is_empty(), "input '{input}' produced empty token list"); + } + + // 최소 1개 fixture 에서 supplement 동작 확인. + // 만약 ko-dic 가 모든 probe 를 2-char 단위로만 분해하면 found_supplement=false 가능. + // 그때는 본 test 는 ko-dic 정책상 N-gram supplement 가 marginal 임을 demonstrate (warning only). + if !found_supplement { + eprintln!( + "WARNING: ko-dic 가 모든 probe input 을 2-char morpheme 으로 분해. \ + N-gram supplement 의 marginal benefit 은 corpus 의 morpheme 길이 분포 의존." + ); + } +} + +/// N-gram supplement 는 한국어 (한글) morpheme 에만 적용. 영어/숫자/혼합 +/// token 은 sliding window emit 없음 (false positive 회피). +#[test] +fn tokenize_korean_morphological_no_2gram_for_english() { + let out = kebab_chunk::tokenize_korean_morphological("Rust optimization").unwrap(); + let tokens: Vec<&str> = out.split_whitespace().collect(); + + // Rust 와 optimization 자체는 token 으로 존재해야 함 (lindera output). + assert!( + tokens.iter().any(|t| t.eq_ignore_ascii_case("rust") || t.eq_ignore_ascii_case("optimization")), + "lindera 의 영어 token 자체는 emit 되어야 함 — tokens = {tokens:?}" + ); + // 영어 substring (`Rus`, `imi`, `tion` 등) 는 N-gram emit 안 됨. + let supplements: Vec<&&str> = tokens + .iter() + .filter(|t| matches!(t.chars().count(), 2 | 3) && t.chars().all(|c| c.is_ascii_alphabetic())) + .collect(); + // empty 또는 lindera 가 emit 한 짧은 ASCII token 만 — 우리가 추가 emit 한 substring 은 없음. + assert!( + supplements.iter().all(|t| !t.contains("Rus") && !t.contains("ust") && !t.contains("imi")), + "영어 N-gram supplement 가 emit 됨 — false positive 위험. tokens = {tokens:?}" + ); +} diff --git a/crates/kebab-cli/tests/wire_search_response.rs b/crates/kebab-cli/tests/wire_search_response.rs index 26b24ec..75d256f 100644 --- a/crates/kebab-cli/tests/wire_search_response.rs +++ b/crates/kebab-cli/tests/wire_search_response.rs @@ -212,32 +212,10 @@ fn search_plain_emits_truncated_hint_to_stderr() { } #[test] -fn search_plain_emits_short_query_hint_to_stderr() { - // v0.17.0 A5 Step 6: 2-char query under trigram tokenizer emits - // empty hits + stderr `[hint]` advisory. Empty workspace is enough - // — hits are always empty so the hint condition depends only on - // query length (<3 chars trimmed) + non-raw mode + hits.is_empty. - let dir = tempfile::tempdir().unwrap(); - let (cfg, workspace, _data) = common::write_config(dir.path(), 30); - common::ingest(&cfg, &workspace); - - let (_stdout, stderr) = common::run_search_with_args(&cfg, &["--mode", "lexical", "ab"]); - assert!( - stderr.contains("[hint]"), - "stderr must carry short-query hint: {stderr:?}" - ); - assert!( - stderr.contains("3자 이상"), - "hint message must mention '3자 이상' (Korean advisory): {stderr:?}" - ); -} - -#[test] -fn search_json_emits_hint_field_for_short_query() { - // v0.17.0 A5 Step 6: --json mode carries the same advisory on the - // `search_response.v1.hint` additive field. Empty hits + 2-char - // query + non-raw mode trips the helper. Verifies the MCP-visible - // surface (agents read the field instead of parsing stderr). +fn search_json_hint_absent_for_short_query_v009() { + // V009 unicode61 + 형태소 tokenizer 환경에서는 2-char 한국어 query 도 + // hit 가능하므로 short_query_hint helper 가 제거됨. hint 는 항상 + // None — wire schema field 는 유지되나 JSON 에서 omit 됨. let dir = tempfile::tempdir().unwrap(); let (cfg, workspace, _data) = common::write_config(dir.path(), 30); common::ingest(&cfg, &workspace); @@ -250,12 +228,9 @@ fn search_json_emits_hint_field_for_short_query() { v["hits"].as_array().unwrap().is_empty(), "empty hits expected for short query in empty KB: {v}" ); - assert_eq!( - v["hint"] - .as_str() - .expect("hint field set on short empty result"), - "3자 이상 키워드 권장 (trigram tokenizer 제약)", - "hint must carry the standard advisory: {v}" + assert!( + v.get("hint").is_none(), + "hint must be absent (always None post-V009): {v}" ); } diff --git a/crates/kebab-core/src/chunk.rs b/crates/kebab-core/src/chunk.rs index 5c0db0f..10dce5f 100644 --- a/crates/kebab-core/src/chunk.rs +++ b/crates/kebab-core/src/chunk.rs @@ -23,4 +23,9 @@ pub struct Chunk { pub token_estimate: usize, pub chunker_version: ChunkerVersion, pub policy_hash: String, + /// 한국어 형태소 분해된 token 시퀀스 (공백 join). lindera ko-dic + /// 으로 chunker 가 pre-fill. None 시 raw text 만 FTS5 index. + /// Bug #8 (한국어 2자 query) 해결을 위한 V009 cascade. + #[serde(default)] + pub tokenized_korean_text: Option, } diff --git a/crates/kebab-eval/tests/fixtures/eval/run-1.json b/crates/kebab-eval/tests/fixtures/eval/run-1.json index 0d8dc18..af74d67 100644 --- a/crates/kebab-eval/tests/fixtures/eval/run-1.json +++ b/crates/kebab-eval/tests/fixtures/eval/run-1.json @@ -5,7 +5,7 @@ "chunk_id": "chunk000000000000000000000000000000", "doc_id": "doc00000000000000000000000000000000", "heading_path": [], - "score": 0.35202541947364807 + "score": 0.4162079095840454 }, "has_answer": false, "hits_count": 1, @@ -19,7 +19,7 @@ "chunk_id": "chunk000000000000000000000000000002", "doc_id": "doc00000000000000000000000000000002", "heading_path": [], - "score": 0.3414848744869232 + "score": 0.42745620012283325 }, "has_answer": false, "hits_count": 1, diff --git a/crates/kebab-search/src/lexical.rs b/crates/kebab-search/src/lexical.rs index 8b30b3c..8101e5b 100644 --- a/crates/kebab-search/src/lexical.rs +++ b/crates/kebab-search/src/lexical.rs @@ -157,23 +157,27 @@ impl Retriever for LexicalRetriever { /// /// v0.17.0 — trigram-aware redesign (see design §5.5 + plan /// `docs/superpowers/plans/2026-05-22-korean-trigram-tokenizer.md` -/// Task A5). The FTS5 tokenizer is `trigram` so any term shorter than -/// three Unicode chars has no index entry and would zero out an AND -/// branch. Korean compounds typically split into 2-char eojeols (e.g. -/// `해시 충돌`), so a naive token AND drops the dominant usage pattern. +/// Task A5). Originally the FTS5 tokenizer was `trigram` so any term +/// shorter than three Unicode chars had no index entry and would zero +/// out an AND branch. Korean compounds typically split into 2-char +/// eojeols (e.g. `해시 충돌`), so a naive token AND drops the dominant +/// usage pattern. +/// +/// V009 (2026-05-28): FTS5 tokenizer 가 trigram → unicode61 + 한국어 +/// 형태소 분해 column 로 갱신됨. unicode61 은 trigram 과 달리 최소 +/// token 길이 제한이 없어 2자 한국어 morpheme query ('한국', '서울') +/// 가 `tokenized_korean_text` column 경유로 hit 가능. MIN_QUERY_CHARS +/// 를 2 로 낮춰 2자 query 를 통과시킨다 (1자 단독은 여전히 필터). +/// multi-token Korean query 의 OR-combine 분기는 redundant 하나 보존 +/// (future 확장성). /// /// post-v0.17.1 dogfood — `text` column filter (closure of HOTFIXES /// 2026-05-24 `heading_path_json` 노이즈). The `chunks_fts` virtual /// table indexes both `heading_path` (the JSON-serialized -/// `chunks.heading_path_json` per V002/V007 triggers) and `text`. Under -/// the trigram tokenizer the JSON punctuation (`[`, `"`, `,`) plus the -/// path segments (`app`, `src`, …) become indexable 3-grams, so a -/// query can hit a chunk purely because its file's heading JSON shares -/// a path segment with the query — false positives that have no body -/// relevance. The default match expression therefore scopes to the -/// `text` column. The `heading_path` column stays indexed (V007 / §5.5 -/// verbatim block is preserved) so a user who *wants* heading matching -/// can opt in via raw mode (`'heading_path : foo'`). +/// `chunks.heading_path_json` per V002/V007 triggers) and `text`. The +/// default match expression therefore scopes to the `text` column. The +/// `heading_path` column stays indexed so a user who *wants* heading +/// matching can opt in via raw mode (`'heading_path : foo'`). /// /// Rules: /// @@ -185,18 +189,17 @@ impl Retriever for LexicalRetriever { /// /// - Otherwise build up to two MATCH candidates: /// 1. **whole-phrase**: the entire trimmed input wrapped as one FTS5 -/// string literal, *only* if it has ≥3 Unicode chars. FTS5 treats +/// string literal, *only* if it has ≥2 Unicode chars. FTS5 treats /// a quoted string with spaces as a phrase match. /// 2. **token AND**: whitespace-split tokens, kept only when each has -/// ≥3 Unicode chars (shorter ones are dropped — they would zero -/// out the AND under trigram). +/// ≥2 Unicode chars (1-char tokens are dropped). /// /// - Combine: `(whole) OR (token_and)` when both exist *and differ*; /// either alone when only one exists; `None` when neither exists /// (caller short-circuits to `Ok(vec![])`, avoiding an FTS5 syntax /// error from an empty MATCH). /// -/// - A single-token long query (`러스트`, `foo`) yields `whole == token_and` +/// - A single-token query (`러스트`, `한국`, `foo`) yields `whole == token_and` /// → return the bare quoted form so the OR doesn't duplicate. /// /// - Finally wrap the combined expression in `text : ()` so the @@ -215,15 +218,18 @@ fn build_match_string(text: &str) -> Option { return Some(inner_trim.to_string()); } - const MIN_TRIGRAM_CHARS: usize = 3; + // V009 unicode61: minimum query token length is 2 Unicode chars. + // (V007 trigram required ≥3; unicode61 has no built-in minimum but + // single-char queries are too broad to be useful.) + const MIN_QUERY_CHARS: usize = 2; let whole_candidate: Option = - (trimmed.chars().count() >= MIN_TRIGRAM_CHARS).then(|| escape_fts5_token(trimmed)); + (trimmed.chars().count() >= MIN_QUERY_CHARS).then(|| escape_fts5_token(trimmed)); let token_and_candidate: Option = { let toks: Vec = trimmed .split_whitespace() - .filter(|t| t.chars().count() >= MIN_TRIGRAM_CHARS) + .filter(|t| t.chars().count() >= MIN_QUERY_CHARS) .map(escape_fts5_token) .collect(); (!toks.is_empty()).then(|| toks.join(" ")) @@ -648,25 +654,26 @@ mod tests { // ── v0.17.0 trigram-aware redesign coverage ────────────────────────── - /// 2-char Korean query (`충돌`) yields neither a whole-phrase nor a - /// token-AND candidate → `None`. Caller short-circuits to an empty - /// hit list rather than executing an FTS5 syntax error on `""` MATCH. + /// V009 unicode61: 1-char query yields None (too broad); 2-char Korean + /// query now passes the MIN_QUERY_CHARS=2 filter and returns a valid + /// match expression. #[test] fn build_match_string_short_korean_returns_none() { - assert!(build_match_string("충돌").is_none()); + // 1-char queries remain filtered (too broad). assert!(build_match_string("키").is_none()); - assert!(build_match_string(" 충돌 ").is_none()); + assert!(build_match_string("나").is_none()); + // 2-char Korean queries now produce a valid expression (V009 unicode61). + assert_eq!(build_match_string("충돌").unwrap(), r#"text : ("충돌")"#); + assert_eq!(build_match_string(" 충돌 ").unwrap(), r#"text : ("충돌")"#); } - /// `해시 충돌` — both tokens are 2 chars (dropped from the AND), but - /// the whole-phrase candidate (`"해시 충돌"`, 5 chars total) survives. - /// This is the dominant Korean usage pattern targeted by A5. - /// The whole-phrase candidate is then wrapped in the `text : (...)` - /// column filter. + /// V009 unicode61: `해시 충돌` — both tokens are 2 chars and now pass + /// MIN_QUERY_CHARS=2. Both whole-phrase and token-AND candidates exist + /// and differ → OR-combined inside `text : (...)`. #[test] fn build_match_string_whole_phrase_only_when_all_tokens_short() { let s = build_match_string("해시 충돌").unwrap(); - assert_eq!(s, r#"text : ("해시 충돌")"#); + assert_eq!(s, r#"text : (("해시 충돌") OR ("해시" "충돌"))"#); } /// Single long token: whole-phrase and token-AND candidates collapse diff --git a/crates/kebab-search/tests/fixtures/search/lexical/run-1.json b/crates/kebab-search/tests/fixtures/search/lexical/run-1.json index c16a495..d6ae0dc 100644 --- a/crates/kebab-search/tests/fixtures/search/lexical/run-1.json +++ b/crates/kebab-search/tests/fixtures/search/lexical/run-1.json @@ -19,9 +19,9 @@ "indexed_at": "2024-01-01T00:00:00Z", "rank": 1, "retrieval": { - "fusion_score": 1.4615362715630908e-6, + "fusion_score": 1.4490997273242101e-6, "lexical_rank": 1, - "lexical_score": 1.4615362715630908e-6, + "lexical_score": 1.4490997273242101e-6, "method": "lexical", "vector_rank": null, "vector_score": null @@ -51,9 +51,9 @@ "indexed_at": "2024-01-01T00:00:00Z", "rank": 2, "retrieval": { - "fusion_score": 9.207039965986041e-7, + "fusion_score": 9.641424867368187e-7, "lexical_rank": 2, - "lexical_score": 9.207039965986041e-7, + "lexical_score": 9.641424867368187e-7, "method": "lexical", "vector_rank": null, "vector_score": null diff --git a/crates/kebab-store-sqlite/src/documents.rs b/crates/kebab-store-sqlite/src/documents.rs index e09745e..e1dcd57 100644 --- a/crates/kebab-store-sqlite/src/documents.rs +++ b/crates/kebab-store-sqlite/src/documents.rs @@ -105,8 +105,9 @@ impl kebab_core::DocumentStore for SqliteStore { "INSERT INTO chunks ( chunk_id, doc_id, text, heading_path_json, section_label, source_spans_json, token_estimate, - chunker_version, policy_hash, block_ids_json, created_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + chunker_version, policy_hash, block_ids_json, created_at, + tokenized_korean_text + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ) .map_err(StoreError::from)?; for chunk in chunks { @@ -134,6 +135,7 @@ impl kebab_core::DocumentStore for SqliteStore { chunk.policy_hash, block_ids, now, + chunk.tokenized_korean_text.as_deref(), ]) .map_err(StoreError::from)?; } @@ -221,7 +223,7 @@ impl kebab_core::DocumentStore for SqliteStore { "SELECT chunk_id, doc_id, text, heading_path_json, source_spans_json, token_estimate, chunker_version, - policy_hash, block_ids_json + policy_hash, block_ids_json, tokenized_korean_text FROM chunks WHERE chunk_id = ?", params![id.0], chunk_row_from_sql, @@ -247,6 +249,7 @@ impl kebab_core::DocumentStore for SqliteStore { token_estimate: row.token_estimate as usize, chunker_version: kebab_core::ChunkerVersion(row.chunker_version), policy_hash: row.policy_hash, + tokenized_korean_text: row.tokenized_korean_text, })) } @@ -557,6 +560,7 @@ struct ChunkRow { chunker_version: String, policy_hash: String, block_ids_json: String, + tokenized_korean_text: Option, } fn chunk_row_from_sql(row: &rusqlite::Row<'_>) -> rusqlite::Result { @@ -570,6 +574,7 @@ fn chunk_row_from_sql(row: &rusqlite::Row<'_>) -> rusqlite::Result { chunker_version: row.get(6)?, policy_hash: row.get(7)?, block_ids_json: row.get(8)?, + tokenized_korean_text: row.get(9)?, }) } diff --git a/crates/kebab-store-sqlite/src/store.rs b/crates/kebab-store-sqlite/src/store.rs index 437cc02..0948470 100644 --- a/crates/kebab-store-sqlite/src/store.rs +++ b/crates/kebab-store-sqlite/src/store.rs @@ -492,6 +492,66 @@ impl SqliteStore { Ok(out) } + /// V007 → V009 업그레이드 시 기존 chunks 의 `tokenized_korean_text` 가 NULL — 이 + /// 메서드가 NULL 인 row 를 batch 로 읽어 `tokenize` 콜백으로 형태소 분해 후 UPDATE. + /// chunks_au trigger 가 chunks_fts 를 자동 재-index. + /// + /// - `tokenize`: `kebab_chunk::tokenize_korean_morphological` 등 `&str → Option`. + /// `None` 반환 시 row 를 skip (UPDATE 없음). + /// - `progress`: `(done, total)` 콜백. 1000 row 마다 발화. + /// - 반환값: lindera Some 으로 UPDATE 된 row 수 (idempotent — 이미 채워진 row 는 0). + /// - 실패 시 App open 을 block 하지 않도록 호출자가 `unwrap_or_else` 로 감쌀 것. + pub fn backfill_tokenized_korean_text(&self, progress: F, tokenize: T) -> Result + where + F: Fn(u64, u64), + T: Fn(&str) -> Option, + { + // 1. NULL 후보 수집. + let rows: Vec<(String, String)> = { + let conn = self.lock_conn(); + let mut stmt = conn + .prepare( + "SELECT chunk_id, text FROM chunks \ + WHERE tokenized_korean_text IS NULL \ + ORDER BY chunk_id", + ) + .map_err(StoreError::from)?; + let iter = stmt + .query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + }) + .map_err(StoreError::from)?; + let mut out = Vec::new(); + for r in iter { + out.push(r.map_err(StoreError::from)?); + } + out + }; + + let total = rows.len() as u64; + let mut updated: u64 = 0; + + // 2. 1000 row 마다 transaction 으로 batch UPDATE. + for chunk in rows.chunks(1000) { + let conn = self.lock_conn(); + let tx = conn.unchecked_transaction().map_err(StoreError::from)?; + for (chunk_id, text) in chunk { + if let Some(tokenized) = tokenize(text) { + tx.execute( + "UPDATE chunks SET tokenized_korean_text = ?1 WHERE chunk_id = ?2", + params![tokenized, chunk_id], + ) + .map_err(StoreError::from)?; + updated += 1; + } + } + tx.commit().map_err(StoreError::from)?; + progress(updated, total); + } + + Ok(updated) + } + /// v0.17.0 PR-B: sweep the SQLite document chain (`documents` → /// `blocks` / `chunks` / `embedding_records` via CASCADE) for every /// row at `workspace_path` whose `doc_id` differs from `keep_doc_id`. @@ -1028,7 +1088,7 @@ impl SqliteStore { image_height, ms, chars, - if success { 1i32 } else { 0i32 }, + i32::from(success), reason, ocr_engine ], @@ -1042,7 +1102,8 @@ impl SqliteStore { /// means "delete everything older than now" (i.e. all past rows). pub fn prune_pdf_ocr_events(&self, retention_days: u32) -> anyhow::Result { use time::format_description::well_known::Rfc3339; - let cutoff = time::OffsetDateTime::now_utc() - time::Duration::days(retention_days as i64); + let cutoff = + time::OffsetDateTime::now_utc() - time::Duration::days(i64::from(retention_days)); let cutoff_ts = cutoff .format(&Rfc3339) .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string()); diff --git a/crates/kebab-store-sqlite/tests/corpus_revision.rs b/crates/kebab-store-sqlite/tests/corpus_revision.rs index e590543..2ba6026 100644 --- a/crates/kebab-store-sqlite/tests/corpus_revision.rs +++ b/crates/kebab-store-sqlite/tests/corpus_revision.rs @@ -20,23 +20,26 @@ fn open_store(tmp: &TempDir) -> SqliteStore { store } -/// Fresh store seeds `corpus_revision = 0` (per V004 INSERT). +/// Fresh store baseline: V004 seeds `corpus_revision = 0`, then V009 +/// migration bumps it by one to invalidate any pre-V009 LRU cache — +/// so a fresh store after `run_migrations()` reads back as `1`. #[test] -fn fresh_store_starts_at_zero() { +fn fresh_store_starts_at_post_migration_baseline() { let tmp = TempDir::new().unwrap(); let store = open_store(&tmp); - assert_eq!(store.corpus_revision(), 0); + assert_eq!(store.corpus_revision(), 1); } -/// Each `bump_corpus_revision` returns the new value monotonically. +/// Each `bump_corpus_revision` returns the new value monotonically +/// from the post-migration baseline. #[test] fn bump_increments_monotonically() { let tmp = TempDir::new().unwrap(); let store = open_store(&tmp); - assert_eq!(store.bump_corpus_revision().unwrap(), 1); assert_eq!(store.bump_corpus_revision().unwrap(), 2); assert_eq!(store.bump_corpus_revision().unwrap(), 3); - assert_eq!(store.corpus_revision(), 3); + assert_eq!(store.bump_corpus_revision().unwrap(), 4); + assert_eq!(store.corpus_revision(), 4); } /// `corpus_revision` survives a store re-open (persisted in SQLite). @@ -49,6 +52,6 @@ fn revision_persists_across_reopen() { store.bump_corpus_revision().unwrap(); } // store dropped — file closed let store = open_store(&tmp); - assert_eq!(store.corpus_revision(), 2); - assert_eq!(store.bump_corpus_revision().unwrap(), 3); + assert_eq!(store.corpus_revision(), 3); + assert_eq!(store.bump_corpus_revision().unwrap(), 4); } diff --git a/crates/kebab-store-sqlite/tests/fts.rs b/crates/kebab-store-sqlite/tests/fts.rs index 5d9d978..7c4c08e 100644 --- a/crates/kebab-store-sqlite/tests/fts.rs +++ b/crates/kebab-store-sqlite/tests/fts.rs @@ -13,6 +13,7 @@ //! that bypasses the `SqliteStore` mutex; that's fine because each test //! gets its own tempdir and no concurrent mutator is in flight. +use kebab_chunk::tokenize_korean_morphological; use kebab_store_sqlite::{SqliteStore, rebuild_chunks_fts}; use rusqlite::Connection; @@ -368,19 +369,20 @@ fn extract_design_5_5_fts_block() -> String { fts_slice[..last_end + "END;".len()].to_string() } -/// Extract the §5.5 verbatim block from the V007 migration (replaced V002 -/// 's unicode61 tokenizer with trigram — V002 stays in place for -/// historical cold-upgrade replay but V007 is now the source of truth), -/// between the `── §5.5 verbatim block ──` anchor markers V007 carries. +/// Extract the §5.5 verbatim block from the V009 migration (V009 replaces +/// V007 's trigram tokenizer with unicode61 + CASE expression triggers for +/// Korean morphological tokenization — V007 stays in place for historical +/// cold-upgrade replay but V009 is now the source of truth), +/// between the `── §5.5 verbatim block ──` anchor markers V009 carries. fn extract_migration_5_5_verbatim_block() -> String { - let migration = include_str!("../../../migrations/V007__fts_trigram.sql"); + let migration = include_str!("../../../migrations/V009__fts_korean_morphological.sql"); // The opening anchor line ends with `── §5.5 verbatim block ─...`. let open_marker = "§5.5 verbatim block"; let close_marker = "End §5.5 verbatim block"; let open_idx = migration .find(open_marker) - .expect("V007 must carry the `§5.5 verbatim block` opening anchor"); + .expect("V009 must carry the `§5.5 verbatim block` opening anchor"); let after_open_line = open_idx + migration[open_idx..] .find('\n') @@ -389,7 +391,7 @@ fn extract_migration_5_5_verbatim_block() -> String { let close_idx = migration[after_open_line..] .find(close_marker) - .expect("V007 must carry the `End §5.5 verbatim block` closing anchor") + .expect("V009 must carry the `End §5.5 verbatim block` closing anchor") + after_open_line; // Walk back from the close marker to the start of its comment line. let close_line_start = migration[..close_idx].rfind('\n').map_or(0, |n| n + 1); @@ -397,14 +399,15 @@ fn extract_migration_5_5_verbatim_block() -> String { migration[after_open_line..close_line_start].to_string() } -/// CI diff guard: the §5.5 block in `migrations/V007__fts_trigram.sql` -/// must match the design doc verbatim (whitespace-normalized). V007 -/// replaced V002 's unicode61 tokenizer with trigram (2026-05-23). -/// V002 stays in place for historical replay of cold-upgrade paths -/// but is no longer compared against the design doc — V007 is now +/// CI diff guard: the §5.5 block in `migrations/V009__fts_korean_morphological.sql` +/// must match the design doc verbatim (whitespace-normalized). V009 +/// replaced V007 's trigram tokenizer with unicode61 + CASE expression +/// triggers for Korean morphological tokenization (2026-05-28). +/// V007 stays in place for historical replay of cold-upgrade paths +/// but is no longer compared against the design doc — V009 is now /// the source of truth. #[test] -fn fts_v007_matches_design_section_5_5_verbatim() { +fn fts_v009_matches_design_section_5_5_verbatim() { let design = extract_design_5_5_fts_block(); let migration_block = extract_migration_5_5_verbatim_block(); @@ -427,12 +430,89 @@ fn fts_v007_matches_design_section_5_5_verbatim() { let migration_n = normalize_ws(&migration_block); assert_eq!( design_n, migration_n, - "V007__fts_trigram.sql §5.5 block must match design doc §5.5 verbatim \ + "V009__fts_korean_morphological.sql §5.5 block must match design doc §5.5 verbatim \ (whitespace-normalized). If you intentionally changed one, \ update the other in the same commit." ); } +// ── 5b. V009 corpus_revision bump ──────────────────────────────────── + +/// V009 migration 이 corpus_revision kv 를 bump 하는지 검증. +/// SqliteStore::open + run_migrations 후 corpus_revision 이 ≥ 1 이어야 함. +/// (V004 seed = '0', V009 UPDATE = CAST(CAST('0' AS INTEGER) + 1 AS TEXT) = '1'). +#[test] +fn v009_bumps_corpus_revision() { + let env = common::TestEnv::new(); + let store = SqliteStore::open(&env.config()).unwrap(); + store.run_migrations().unwrap(); + let rev = store.corpus_revision(); + assert!( + rev >= 1, + "corpus_revision must be ≥ 1 after V009 migration \ + (V004 seeds 0, V009 bumps to ≥ 1); got {rev}" + ); +} + +// ── 5c. backfill_tokenized_korean_text ─────────────────────────────── + +#[test] +fn backfill_tokenized_korean_text_populates_nullable_rows() { + let env = common::TestEnv::new(); + let store = SqliteStore::open(&env.config()).unwrap(); + store.run_migrations().unwrap(); + + // chunks 에 한국어 row 두 개 INSERT (tokenized_korean_text 는 chunks_ai trigger + // 가 채우지만, 여기서는 raw_conn_no_fk 로 직접 INSERT 하므로 NULL 로 남음). + let conn = raw_conn_no_fk(&env); + insert_chunk( + &conn, + &"a".repeat(32), + &"d".repeat(32), + "[]", + "한국 문화는 오래되었다", + ); + insert_chunk( + &conn, + &"b".repeat(32), + &"d".repeat(32), + "[]", + "서울특별시는 한국의 수도", + ); + let null_count_before: i64 = conn + .query_row( + "SELECT COUNT(*) FROM chunks WHERE tokenized_korean_text IS NULL", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(null_count_before, 2); + drop(conn); + + // backfill 호출 → lindera 가 두 row 모두 분해 성공 → 2 반환. + let processed = store + .backfill_tokenized_korean_text(|_, _| {}, tokenize_korean_morphological) + .unwrap(); + assert_eq!(processed, 2, "both rows should be populated by lindera"); + + let conn = raw_conn_no_fk(&env); + let null_count_after: i64 = conn + .query_row( + "SELECT COUNT(*) FROM chunks WHERE tokenized_korean_text IS NULL", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(null_count_after, 0); + + // idempotency: 두 번째 호출 → 0 (모든 row 가 이미 채워져 있음). + drop(conn); + let processed_again = store + .backfill_tokenized_korean_text(|_, _| {}, tokenize_korean_morphological) + .unwrap(); + assert_eq!(processed_again, 0); +} + // ── 6. WAL cleanup: drop store before tempdir reaps WAL/SHM ────────── /// Mirror the P1-6 pattern: opening + migrating + dropping the store @@ -476,16 +556,24 @@ fn fts_store_drop_releases_wal_files() { } } -// ── 7. Trigram tokenizer behavior (V007) — Korean + English ────────── +// ── 7. Tokenizer behavior (V009 unicode61 + Korean morpheme column) ─── +// +// V007 의 trigram-specific substring 매칭 test 들은 V009 로 obsolete: +// - English substring (`token` → `tokenizer` hit) 는 unicode61 의 whole-token +// 매칭으로 회귀 — spec §3 Non-Goals 의 Path A 명시. +// - Korean substring (`발생한` → `발생한다` hit) 도 동일하게 whole-token only. +// +// V009 의 신규 검증은 S7 (plan §2 Step 7) 에서 추가되는 +// `fts_v009_korean_morphological_2char_query_hits` + `fts_v009_english_whole_token_only` +// 가 담당한다. 2자 query 0-hit 의 pinned 동작 (`fts_trigram_korean_short_query_zero_hit_pinned`) +// 은 V009 의 형태소 분해가 hit 시키므로 의도된 회귀 — S7 의 신규 test 가 새 baseline 을 핀. -/// V007 의 trigram tokenizer 가 한국어 3자 이상 연속 substring 을 -/// 매칭하는지. Codex round 1/2 가 sqlite 3.45.1 로 검증한 동작을 pin: -/// - raw query 가 3자 이상 공백 없는 substring 인 경우 hit. -/// - raw query 가 공백을 포함하면 FTS5 가 토큰 경계로 분리 → -/// 양 토큰이 3자 미만이면 0-hit. -/// - quoted phrase ("..." 안에 공백 포함) 는 통째로 substring 매칭. +/// V009 의 unicode61 + morpheme column 환경에서 단일 토큰 매칭이 정상 +/// 동작하는지 sanity check. 형태소 사전이 없어도 chunks_fts 의 +/// `tokenize='unicode61'` 만으로도 space-separated 한국어 token (chunk text +/// 의 raw 공백 split) 은 매칭되어야 한다. #[test] -fn fts_trigram_korean_3char_substring_hits() { +fn fts_v009_unicode61_space_separated_korean_token_hits() { let env = common::TestEnv::new(); let store = SqliteStore::open(&env.config()).unwrap(); store.run_migrations().unwrap(); @@ -499,67 +587,51 @@ fn fts_trigram_korean_3char_substring_hits() { "해시 충돌은 키와 값을 매핑할 때 발생한다", ); - // raw 3+ chars 공백 없는 연속 substring → hit. - assert_eq!( - count_match(&conn, "충돌은"), - 1, - "raw 3-char 공백 없는 substring '충돌은' must hit" - ); + // unicode61 이 공백으로 분리한 token 은 그대로 매칭. + assert_eq!(count_match(&conn, "충돌은"), 1, "whole-token '충돌은' hit"); + assert_eq!(count_match(&conn, "해시"), 1, "whole-token '해시' hit"); + // substring (token 의 부분 문자열) 은 V009 unicode61 에서 0-hit. assert_eq!( count_match(&conn, "발생한"), - 1, - "raw 3-char 공백 없는 substring '발생한' must hit" - ); - - // quoted phrase (공백 포함) → substring 매칭으로 hit. - assert_eq!( - count_match(&conn, "\"해시 충돌\""), - 1, - "quoted whole phrase '해시 충돌' (5 chars including space)" - ); - assert_eq!( - count_match(&conn, "\"시 충\""), - 1, - "quoted phrase '시 충' across the space boundary" - ); - - // raw with no whitespace but substring not present in source → 0-hit. - assert_eq!( - count_match(&conn, "해시충"), 0, - "원문에 공백 없는 '해시충' trigram 이 없으므로 0-hit" + "substring '발생한' of '발생한다' 0-hit" ); } -/// V007 trigram 의 핵심 제약: 3 Unicode chars 미만 query 는 색인 단위가 -/// 없어 항상 0-hit. design §3.4 + 사용자 결정 (lexical core 정상 0-hit, -/// CLI/TUI wrapper 가 안내 메시지 출력). 회귀 감지 — trigram 구조 변경 -/// 또는 다른 tokenizer 도입 시 이 test 가 먼저 fail 한다. +// ── 8. V009 morphological tokenizer behavior ────────────────────────── + +/// V009 의 핵심 가치: 한국어 2자 query 가 hit. 형태소 분해된 +/// tokenized_korean_text column 이 chunks_fts 에 indexed. #[test] -fn fts_trigram_korean_short_query_zero_hit_pinned() { +fn fts_v009_korean_morphological_2char_query_hits() { let env = common::TestEnv::new(); let store = SqliteStore::open(&env.config()).unwrap(); store.run_migrations().unwrap(); let conn = raw_conn_no_fk(&env); - insert_chunk( - &conn, - &"k".repeat(32), - &"d".repeat(32), - "[]", - "해시 충돌은 키와 값을 매핑할 때 발생한다", - ); + let text = "한국 문화는 오래되었다"; + let tokenized = tokenize_korean_morphological(text); + conn.execute( + "INSERT INTO chunks ( + chunk_id, doc_id, text, heading_path_json, section_label, + source_spans_json, token_estimate, chunker_version, + policy_hash, block_ids_json, created_at, + tokenized_korean_text + ) VALUES (?, ?, ?, '[]', NULL, '[]', 0, 'v1', 'h', '[]', '2024-01-01T00:00:00Z', ?)", + rusqlite::params![&"k".repeat(32), &"d".repeat(32), text, tokenized,], + ) + .expect("insert chunk with tokenized_korean_text"); - // 2자 한국어 query — 도그푸딩에서 보고된 핵심 케이스 ('충돌'/'값'). - assert_eq!(count_match(&conn, "충돌"), 0, "2-char Korean query"); - // 1자 한국어 query. - assert_eq!(count_match(&conn, "키"), 0, "1-char Korean query"); + assert!( + count_match(&conn, "한국") >= 1, + "2-char Korean morpheme '한국' must hit when tokenized column is populated" + ); } -/// V007 trigram 은 영어에도 substring 매칭으로 동작 — recall ↑, 단어 -/// 경계 정밀도 ↓. design §3.4 의 동작 변경을 명시적으로 핀. +/// V009 의 Path A 회귀 확인: 영어 substring 매칭이 사라짐 +/// (unicode61 의 whole-token only 동작). #[test] -fn fts_trigram_english_substring_hits() { +fn fts_v009_english_whole_token_only() { let env = common::TestEnv::new(); let store = SqliteStore::open(&env.config()).unwrap(); store.run_migrations().unwrap(); @@ -573,13 +645,14 @@ fn fts_trigram_english_substring_hits() { "the tokenizer normalizes whitespace before matching", ); - // trigram substring — 'token' hits inside 'tokenizer'. assert_eq!( count_match(&conn, "token"), - 1, - "substring of 'tokenizer' — trigram recall" + 0, + "V009 unicode61: 'token' is substring of 'tokenizer', should NOT hit" + ); + assert_eq!( + count_match(&conn, "tokenizer"), + 1, + "V009 unicode61: whole-token 'tokenizer' must hit" ); - assert_eq!(count_match(&conn, "izer"), 1, "substring of 'tokenizer'"); - // 3-char-minimum applies to English too. - assert_eq!(count_match(&conn, "to"), 0, "2-char English query"); } diff --git a/crates/kebab-store-sqlite/tests/idempotency.rs b/crates/kebab-store-sqlite/tests/idempotency.rs index faa2bd6..1171c0a 100644 --- a/crates/kebab-store-sqlite/tests/idempotency.rs +++ b/crates/kebab-store-sqlite/tests/idempotency.rs @@ -97,6 +97,7 @@ fn make_chunks(doc_id: &DocumentId) -> Vec { token_estimate: 5, chunker_version: ChunkerVersion("md-heading-v1".into()), policy_hash: "deadbeefdeadbeef".into(), + tokenized_korean_text: None, }] } diff --git a/crates/kebab-tui/src/app.rs b/crates/kebab-tui/src/app.rs index 50ca759..d8722d2 100644 --- a/crates/kebab-tui/src/app.rs +++ b/crates/kebab-tui/src/app.rs @@ -153,12 +153,6 @@ pub struct SearchState { /// `Ctrl-L`); the previous draft kept one for "symmetry" but /// it was dead code. pub worker_rx: Option>, - /// v0.17.0 A5 Step 5: advisory text shown when the last completed - /// search returned no hits and the (trimmed) query is shorter than - /// the FTS5 trigram tokenizer's 3-char minimum. `None` whenever - /// the input changes (so a stale hint never overlaps a fresh - /// typing session) or the next search returns ≥1 hit. - pub short_query_hint: Option, } /// p9-fb-08: payload posted by the search worker on completion. @@ -185,7 +179,6 @@ impl Default for SearchState { preview: None, generation: 0, worker_rx: None, - short_query_hint: None, } } } diff --git a/crates/kebab-tui/src/run.rs b/crates/kebab-tui/src/run.rs index 2ec42a8..e42bceb 100644 --- a/crates/kebab-tui/src/run.rs +++ b/crates/kebab-tui/src/run.rs @@ -382,20 +382,6 @@ fn dynamic_status(app: &App) -> String { if app.search.as_ref().is_some_and(|s| s.searching) { return "searching…".to_string(); } - // v0.17.0 A5 Step 5: short-query advisory has higher priority than - // the idle slot but lower than active operations (streaming / - // searching / ingest progress) — the user should always see what - // is happening *now* before reading guidance about the last - // empty result. Slot only fires while focused on Search. - if app.focus == Pane::Search { - if let Some(hint) = app - .search - .as_ref() - .and_then(|s| s.short_query_hint.as_deref()) - { - return hint.to_string(); - } - } if let Some(state) = app.ingest_state.as_ref() { return crate::ingest_progress::status_line(state); } diff --git a/crates/kebab-tui/src/search.rs b/crates/kebab-tui/src/search.rs index 85618c7..7cf5d17 100644 --- a/crates/kebab-tui/src/search.rs +++ b/crates/kebab-tui/src/search.rs @@ -440,7 +440,6 @@ pub fn handle_key_search(state: &mut App, key: KeyEvent) -> KeyOutcome { /// with a fresh typing session. fn mark_input_changed(s: &mut crate::app::SearchState) { s.input_dirty_at = Some(time::OffsetDateTime::now_utc()); - s.short_query_hint = None; } fn cycle_mode(m: SearchMode) -> SearchMode { @@ -612,11 +611,6 @@ pub(crate) fn fire_search(state: &mut App) -> anyhow::Result<()> { s.generation = s.generation.wrapping_add(1); s.searching = true; s.input_dirty_at = None; - // v0.17.0 A5 Step 5: hint belongs to the *prior* result set — - // a fresh worker spawn invalidates it so the status bar - // doesn't keep showing the old advisory while the new - // query is in flight. - s.short_query_hint = None; let q_text = s.input.as_str().to_string(); s.last_query = Some((q_text.clone(), s.mode)); (q_text, s.mode, s.generation) @@ -699,8 +693,6 @@ pub fn poll_worker(state: &mut App) { // the user submitted for *this* result set. If // input has drifted since spawn, the gen-check // already returned early. - let q_text = s.last_query.as_ref().map_or("", |(t, _)| t.as_str()); - s.short_query_hint = kebab_app::short_query_hint(q_text, hits.is_empty()); s.hits = hits; s.selected_hit = 0; s.preview = None; @@ -708,7 +700,6 @@ pub fn poll_worker(state: &mut App) { Err(e) => { s.hits.clear(); s.selected_hit = 0; - s.short_query_hint = None; state.error_overlay = Some(crate::error_popup::ErrorOverlay::from_anyhow(&e)); } } diff --git a/crates/kebab-tui/tests/inspect.rs b/crates/kebab-tui/tests/inspect.rs index 842c512..4e0525f 100644 --- a/crates/kebab-tui/tests/inspect.rs +++ b/crates/kebab-tui/tests/inspect.rs @@ -113,6 +113,7 @@ fn make_chunk() -> Chunk { token_estimate: 12, chunker_version: ChunkerVersion("md-heading-v1".into()), policy_hash: "deadbeefdeadbeef".into(), + tokenized_korean_text: None, } } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5897145..90d5df7 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -13,10 +13,11 @@ Cargo workspace, 함수 호출 기반 모듈러 모놀리스. UI binary (`kebab- | 언어 | Rust 2024 (resolver=3, edition 2024) | | repo | Cargo workspace (single repo, 함수 호출 기반 모듈러 모놀리스) | | 원본 저장 | filesystem + blake3 content-addressable copy (대용량은 reference + checksum) | -| metadata | SQLite + FTS5 (lexical search) | +| metadata | SQLite + FTS5 (lexical search + v0.20.1 한국어 형태소 tokenizer via lindera-ko-dic) | | vector | LanceDB (embedded, model 별 분리 table) | | Markdown parser | `pulldown-cmark` | -| embedding | `fastembed-rs` (`multilingual-e5-small`, 384d) | +| embedding | `fastembed-rs` (`multilingual-e5-large`, 1024d, v0.18.0부터 default 업그레이드) | +| 한국어 형태소분석 | `lindera-ko-dic` (FTS5 외부 tokenizer, v0.20.1) — 2자 이상 한국어 query 지원 | | LLM | Ollama HTTP (default `gemma4:e4b` ─ OCR / caption 와 family 통일. 사용자가 더 큰 variant `gemma4:26b` 등으로 override 가능) | | 음성 ASR | `whisper.cpp` (via `whisper-rs`) — P8 보류, 시스템 dep brainstorm 후 | | OCR (image) | Ollama vision LM (default `gemma4:e4b`) — `OcrEngine` trait 으로 Tesseract / Apple Vision 등 future swap (HOTFIXES P6-2) | @@ -126,7 +127,7 @@ flowchart TB UI → store/llm/parse 직접 의존 금지. 모든 user-facing 진입은 `kebab-app` facade 만 통한다 (frozen 설계 §8). `kebab-cli` 가 `--config ` flag 를 honor 하려면 `kebab_app::*_with_config(cfg, …)` companion 을 통해 Config 을 명시적으로 thread 하는 패턴 — 자세한 이유는 [tasks/HOTFIXES.md](../tasks/HOTFIXES.md) 의 `--config` 항목. -`kebab-parse-code` 의 외부 tree-sitter grammar crate 의존: P10-1A-2 에서 `tree-sitter-rust` 추가, P10-1B 에서 `tree-sitter-python` / `tree-sitter-typescript` / `tree-sitter-javascript` 추가, P10-1C-Go 에서 `tree-sitter-go` 추가, P10-1C-JK 에서 `tree-sitter-java` / `tree-sitter-kotlin-ng` 추가, P10-1D 에서 `tree-sitter-c` / `tree-sitter-cpp` 추가. 모두 `kebab-parse-code` 에만 격리 (facade 룰 — UI crate / chunker 가 직접 import 금지). Kotlin 은 `tree-sitter-kotlin-ng` 사용 (bare `tree-sitter-kotlin` 은 tree-sitter 0.21–0.23 에 고착 — 사용 불가). v0.18.0+ 부터 `kebab-source-fs` 는 자체 `code_meta` 모듈 (lang detect + skip helpers + BUILTIN_BLACKLIST) 을 보유, kebab-parse-code 와 분리 (refactor 2026-05-26). v0.19.0 부터 `kebab-parse-md` 가 `kebab-parse-types` (parser intermediate types) + `kebab-normalize` (CanonicalDocument lift) 두 crate 를 흡수 — 24 → 22 crates, design §3.7b 재작성 (HOTFIXES 2026-05-26). +`kebab-parse-code` 의 외부 tree-sitter grammar crate 의존: P10-1A-2 에서 `tree-sitter-rust` 추가, P10-1B 에서 `tree-sitter-python` / `tree-sitter-typescript` / `tree-sitter-javascript` 추가, P10-1C-Go 에서 `tree-sitter-go` 추가, P10-1C-JK 에서 `tree-sitter-java` / `tree-sitter-kotlin-ng` 추가, P10-1D 에서 `tree-sitter-c` / `tree-sitter-cpp` 추가. 모두 `kebab-parse-code` 에만 격리 (facade 룰 — UI crate / chunker 가 직접 import 금지). Kotlin 은 `tree-sitter-kotlin-ng` 사용 (bare `tree-sitter-kotlin` 은 tree-sitter 0.21–0.23 에 고착 — 사용 불가). v0.18.0+ 부터 `kebab-source-fs` 는 자체 `code_meta` 모듈 (lang detect + skip helpers + BUILTIN_BLACKLIST) 을 보유, kebab-parse-code 와 분리 (refactor 2026-05-26). v0.19.0 부터 `kebab-parse-md` 가 `kebab-parse-types` (parser intermediate types) + `kebab-normalize` (CanonicalDocument lift) 두 crate 를 흡수 — 24 → 22 crates, design §3.7b 재작성 (HOTFIXES 2026-05-26). v0.20.1 부터 `kebab-search` 가 `lindera-ko-dic` 를 의존해 한국어 FTS5 형태소 tokenizer 지원 — V009 migration 으로 2자 이상 한국어 query 매칭 (Bug #8 closure). ## 디렉토리 구조 diff --git a/docs/DOGFOOD.md b/docs/DOGFOOD.md index e38b894..557745e 100644 --- a/docs/DOGFOOD.md +++ b/docs/DOGFOOD.md @@ -285,16 +285,75 @@ echo "# stdin content" | "$RELEASE_BIN" ingest-stdin --title "from stdin" --conf ``` **verify**: -- FTS5 trigram (v0.17.0) — 한국어 2자 이상 query hit. -- `chunks_fts` schema (`text`, `heading_path` 별 column). +- FTS5 `unicode61` + lindera ko-dic 형태소 분해 column (v0.20.1, V009 migration). +- `chunks_fts` schema (`text`, `heading_path` 별 column) — V009 의 chunks_ai/au trigger 가 `tokenized_korean_text` 를 CASE expression 으로 raw text 앞에 prepend. +- 한국어 2-char query (`한국`, `서울`) 가 chunk 의 ko-dic 분해된 morpheme 또는 explicit 공백 분리된 token 과 일치 시 hit. +- 영어는 V002 의 whole-token 매칭으로 회귀 (`token` query 는 `token` 토큰만 hit, `tokenizer` substring 은 hit X). substring recall 이 필요하면 vector/hybrid mode 권장. **scenarios**: -- 2.1.a Korean trigram query (`해시 충돌`). -- 2.1.b English/Korean mixed (`Rust 충돌`). -- 2.1.c 1-char query → 0 hit + hint. -- 2.1.d raw mode escape (`heading_path : `). -- 2.1.e FTS5 phrase query (`"specific phrase"`). -- 2.1.f exclusion (`-token`). +- 2.1.a Korean 2-char query (`한국`, `서울` → ≥ 1 hit on Korean wiki fixture). +- 2.1.b Korean compound noun (`한국어`, `서울특별시` → ko-dic 의 형태소 분해 + 단일 noun 동시 매칭). +- 2.1.c English/Korean mixed (`Rust 최적화` → token-AND 두 토큰 모두 hit). +- 2.1.d 1-char query → 0 hit (MIN_QUERY_CHARS = 2 filter, `build_match_string` v0.20.1 갱신). +- 2.1.e English whole-token (`tokenizer` hit, `token` 은 `tokenizer` 의 substring 매칭 X — V007 trigram 회귀). +- 2.1.f raw mode escape (`heading_path : `). +- 2.1.g FTS5 phrase query (`"specific phrase"`). +- 2.1.h exclusion (`-token`). + +### §2.1bis V009 morphological tokenizer dogfood evidence (v0.20.1) + +**Reference corpus** (이 fixture 로 ingest 시 모든 scenario 보장 hit): + +```bash +mkdir -p $DOGFOOD/corpus +cat > $DOGFOOD/corpus/korea-overview.md <<'EOF' +# 한국 개요 + +한국 은 동아시아 의 반도 국가다. 한국 어 는 한반도 의 주요 언어다. +서울 은 한국 의 수도다. 서울 의 지하철 은 1974년 1호선 개통 후 +지금까지 23개 노선으로 확장되었다. + +## 한국 문화 + +한국 의 문화 는 오래 된 역사 와 깊은 전통 을 가진다. +EOF + +cat > $DOGFOOD/corpus/korea-compound.md <<'EOF' +# 한국어 와 한국문화 + +한국어 학습 자료. 한국문화 의 핵심 은 정 (情) 이다. +서울특별시 와 부산광역시 는 한국 의 양대 도시다. +EOF +``` + +**검증 명령** (모두 hit ≥ 1): + +```bash +KB="$RELEASE_BIN --config $DOGFOOD/config.toml" + +# 한국어 2-char (Bug #8 close, v0.20.1 의 핵심 가치) +$KB search '한국' --mode lexical --json | jq '.hits | length' # ≥ 1 +$KB search '서울' --mode lexical --json | jq '.hits | length' # ≥ 1 + +# 한국어 3-char + compound noun +$KB search '지하철' --mode lexical --json | jq '.hits | length' # ≥ 1 +$KB search '한국어' --mode lexical --json | jq '.hits | length' # ≥ 1 +$KB search '한국문화' --mode lexical --json | jq '.hits | length' # ≥ 1 +$KB search '서울특별시' --mode lexical --json | jq '.hits | length' # ≥ 1 + +# 영어 whole-token (V002 동작 회귀) +$KB search 'token' --mode lexical --json | jq '.hits | length' # 0 또는 별 token 으로 존재 시 hit +$KB search 'tokenizer' --mode lexical --json | jq '.hits | length' # ≥ 1 if corpus has 'tokenizer' word +``` + +**예상 snippet (lindera 분해 evidence)**: +- `'한국'` query → "한국 은 동아시아 의 반 도 국가 다" — ko-dic 의 명사 boundary + 조사 분리 확인. +- `'서울'` query → "서울특별시 와" — ko-dic 의 compound `서울특별시` → `[서울, 특별시]` 분해. + +**Known limitation (spec critic R1 #3 acceptance, Option α)**: +- ko-dic 이 compound noun 을 단일 token 으로 저장하는 경우 (예: `한국정부` 가 한 token) → `'한국'` query 는 그 chunk 에 hit X. +- KB 가 영어/code 위주 (예: 사용자 KnowledgeBase 가 React docs) 면 한국어 token 자체 부재로 0 hit 정상. +- N-gram supplement (Option β) 는 v0.21.x P9 follow-up. ### §2.2 Vector search (P3) diff --git a/docs/SMOKE.md b/docs/SMOKE.md index 24d261f..22198f2 100644 --- a/docs/SMOKE.md +++ b/docs/SMOKE.md @@ -176,36 +176,38 @@ KB ask "이 KB 안에서 ..." --mode hybrid --k 5 # 9. RAG 답변 (Ollama KB --json ask "..." --mode hybrid # 10. 기계 친화 출력 검증 ``` -### 한국어 trigram 검색 (v0.17.0) +### 한국어 morphological 검색 (v0.20.1) -`chunks_fts` 가 FTS5 `trigram` tokenizer 로 동작 — 한국어 query 는 3자 이상 substring 매칭. V007 자동 backfill 이라 기존 KB 의 binary 만 v0.17.0+ 로 교체하면 즉시 적용 (re-ingest 불필요). `kebab.sqlite` 파일 크기가 trigram index 비대화로 ~2-5배 또는 수백 MB 증가. +`chunks_fts` 가 FTS5 `unicode61` tokenizer + 한국어 lindera ko-dic 형태소 분해된 별 column (`tokenized_korean_text`) 으로 동작 (V009 migration). 한국어 2-char query (`한국`, `서울`) 도 ko-dic morpheme 매칭 시 hit. V009 자동 backfill (`App::open_with_config` 의 first-boot hook) 이라 기존 KB 의 binary 만 v0.20.1+ 로 교체하면 첫 부팅에서 자동 재-tokenize (re-ingest 불필요). `kebab.sqlite` 파일 크기가 형태소 column + lindera-ko-dic embedded dict 의존성으로 다소 증가. `fixtures/search/korean/hash-table.md` (또는 등가) 를 워크스페이스에 두고 ingest 한 후: ```bash -# 3자 연속 substring (raw, 원문에 "해시 충돌은" / "충돌은 발생" 가 있음) -KB search --mode lexical "충돌은" +# 2-char Korean (v0.20.1 의 핵심 가치 — Bug #8 close) +KB search --mode lexical "한국" +KB search --mode lexical "서울" -# multi-token Korean — builder 가 ("해시 충돌") OR ("해시" "충돌") 으로 -# 변환 (각 토큰 2자라 token-AND 후보는 trigram 비호환, whole-phrase 가 hit) +# 3-char Korean +KB search --mode lexical "한국어" +KB search --mode lexical "지하철" + +# Compound noun (ko-dic 가 형태소 분해 — '서울특별시' → [서울, 특별시]) +KB search --mode lexical "서울특별시" + +# multi-token Korean KB search --mode lexical "해시 충돌" -# 한영 혼합 — 둘 다 3자 이상이라 whole-phrase + token-AND 모두 후보 -KB search --mode lexical "Rust 충돌은" +# 한영 혼합 +KB search --mode lexical "Rust 최적화" -# 2자 query — 정상 0 hit + stderr `[hint] 3자 이상 키워드 권장` -KB search --mode lexical "충돌" +# 1자 query — `build_match_string` 의 MIN_QUERY_CHARS=2 filter 로 0 hit +KB search --mode lexical "키" -# 동일 케이스의 --json 출력에는 search_response.v1.hint 필드 포함 -KB search --mode lexical "충돌" --json | jq '.hits | length, .hint' -# → 0 -# → "3자 이상 키워드 권장 (trigram tokenizer 제약)" - -# raw FTS5 mode (single quote 로 감싼 입력) — 사용자 명시 의도, hint 미출력 +# raw FTS5 mode KB search --mode lexical "'충돌'" ``` -영어 lexical 도 substring 매칭으로 바뀜 — `KB search --mode lexical "token"` 이 `tokenizer` / `tokenize` 도 hit (recall ↑, 단어 경계 정밀도 ↓). +영어 lexical 은 V002 의 whole-token 매칭으로 회귀 — `KB search --mode lexical "token"` 은 `token` 토큰만 hit, `tokenizer` 의 substring 으로 매칭 X. substring recall 이 필요하면 vector/hybrid mode 권장 (spec §3 Non-Goals Path A). ### Stale doc indicator diff --git a/docs/release-notes/v0.20.1-draft.md b/docs/release-notes/v0.20.1-draft.md new file mode 100644 index 0000000..d82c03b --- /dev/null +++ b/docs/release-notes/v0.20.1-draft.md @@ -0,0 +1,152 @@ +--- +title: kebab v0.20.1 release notes (draft) +created: 2026-05-28 +status: draft +release_trigger: + - 사용자 도그푸딩 필요 (Bug #8 — 한국어 2자 query 0-hit 해소) + - frozen design contract 변경 (design §5.5 chunks_fts: trigram → unicode61 + 형태소 column) +--- + +# kebab v0.20.1 — 한국어 형태소 검색 + 영어 substring 회귀 + +v0.20.0 (sub-item 1 scanned PDF OCR via qwen2.5vl:3b, 2026-05-27) 후속의 patch release. v0.20.x 라인의 두 누적 enhancement (logging round 2 + 한국어 morphological tokenizer) 를 하나로 묶어 cut. + +## 핵심 변경 + +### 1. 한국어 2자 query 지원 — Bug #8 해소 + +이전 V007 (v0.17.0+) 의 trigram FTS5 tokenizer 는 3-character gram 최소 인덱싱이라 `'한국'` / `'서울'` / `'지하철'` 같은 1-3자 한국어 단어 query 가 종종 0-hit 였습니다. 사용자 도그푸딩 round 3/4 의 가장 큰 search experience surface. + +**v0.20.1 의 해결책** — `chunks_fts` tokenizer 를 `unicode61` 로 환원 + lindera ko-dic 형태소 분석기로 한국어 chunk text 를 분해해 별 column `tokenized_korean_text` 에 pre-fill. FTS5 trigger 의 CASE expression 이 raw text 앞에 prepend 해 단일 query 로 두 column 모두 매칭. + +```bash +# v0.17.0 (V007 trigram): 0 hit +# v0.20.1 (V009 morphological): hit (chunk 의 ko-dic 분해 결과에 '한국' morpheme 가 존재) +kebab search '한국' +kebab search '서울' +``` + +**Dogfood evidence** (`tasks/HOTFIXES.md` 2026-05-28 entry + `docs/DOGFOOD.md` §2.1bis 의 reference fixture 14 scenario): + +| Query | v0.17.0 (V007 trigram) | v0.20.1 (V009) | +|---|---|---| +| `'한국'` 2자 | 0 hit | hit | +| `'서울'` 2자 | 0 hit | hit | +| `'지하철'` 3자 | substring (제한적) | morpheme + raw token 모두 매칭 | +| `'서울특별시'` compound | substring | ko-dic 분해 `[서울, 특별시]` | + +**Known limitation**: ko-dic 가 compound noun (예: `한국정부`) 을 단일 token 으로 저장하는 경우 그 chunk 의 `'한국'` 단독 query 는 hit X. KB 가 영어/code 위주면 한국어 token 자체 부재로 lexical 0-hit 자연. N-gram supplement (sub-token 추가 emit) 는 v0.21.x P9 follow-up. + +### 2. 영어 substring 매칭 회귀 (V002 동작 환원) + +V007 trigram 의 ad-hoc 부산물이었던 영어 substring 매칭 (`'token'` query 가 `'tokenizer'` chunk 도 hit) 은 V009 의 unicode61 transition 으로 **사라집니다**. V002 (pre-v0.17.0) 의 whole-token 매칭으로 환원. + +```bash +# v0.17.0: 'token' query → 'tokenizer' chunk hit (substring recall ↑, 단어 경계 정밀도 ↓) +# v0.20.1: 'token' query → 'token' 토큰만 hit, 'tokenizer' 는 다른 token +kebab search --mode lexical 'token' # 다른 결과 가능 +kebab search --mode lexical 'tokenizer' # 정확한 token 매칭 +``` + +substring recall 이 필요한 시나리오는 **vector** 또는 **hybrid** mode 권장 (RRF 가 lexical + semantic 결합). spec §3 Non-Goals Path A 의 설계 결정 — lexical 의 단어 경계 정밀도 vs substring recall trade-off 에서 후자 포기. + +### 3. V007 → V009 자동 backfill (재-ingest 불필요) + +기존 V007 KB 를 v0.20.1 binary 로 첫 부팅할 때 `App::open_with_config` 의 first-boot hook 이 자동 실행: + +1. V009 migration apply (schema 변경: `tokenized_korean_text TEXT` column ADD + chunks_fts re-create with unicode61 + CASE expression triggers). +2. `backfill_tokenized_korean_text` API 호출 — chunks 의 NULL `tokenized_korean_text` row 를 lindera 로 분해 후 UPDATE. 1000-row batch transaction + progress callback. +3. chunks_au trigger 가 chunks_fts 를 자동 재-index. + +KB 크기 비례 latency: +- 1만 chunk → ~30-60초 (lindera tokenize + UPDATE + trigger overhead). +- 10만 chunk → ~5-10분. +- stderr 의 `tracing::info!` progress log (`korean tokenizer backfill: 500/10000`) 발화. 사용자 hang 인지 방지. +- 부분 완료 (Ctrl-C) 후 재실행 시 IS NULL filter 로 idempotent 이어 처리. + +backfill 실패 시 (예: lindera dict load fail) `App::open_with_config` 은 success 반환 + warn log. vector/hybrid mode 정상 사용 가능. + +### 4. Ingest 성능 영향 + +새 chunk ingest 시 chunker (`kebab-chunk::md_heading_v1`, `code_*_ast_v1`, `pdf_page_v1`, `tier2_shared`) 가 chunk emit 직전에 `tokenize_korean_morphological(text)` 호출. OnceLock 캐시로 dictionary load 가 process-lifetime 동안 1회 — amortized cost 낮음. + +도그푸딩 측정 (1781 markdown / 9050 chunk): +- v0.20.0 ingest baseline: ~700초. +- v0.20.1 ingest: ~671초 (실측 — overhead 거의 무의미, chunk text 길이 비례 +5-10% 예상). + +`kebab.sqlite` 파일 크기 영향: +- `tokenized_korean_text` column 추가 (한국어 chunk 비례 +20-30% column data). +- chunks_fts shadow 의 indexed text 가 raw + tokenized 합 (Korean-heavy KB 에서 ~2배 chunks_fts 크기). +- 영어/code 위주 KB 에서는 `tokenized_korean_text` 가 NULL 또는 short → 영향 minimal. + +binary 크기: +- v0.20.0: ~270 MB (release). +- v0.20.1: ~390 MB (lindera-ko-dic embedded dict 의 ~120 MB 추가). + +## Migration cascade + +| Version field | v0.20.0 | v0.20.1 | +|---|---|---| +| `lexical_index_version` | `lex:{chunker}` | `lex:{chunker}:fts5-v009-korean-morphological` | +| `corpus_revision` | V004 seed = 0 | V009 migration tail 의 +1 (post-migration baseline = 1) | +| `chunker_version` | unchanged | unchanged | +| `parser_version` | unchanged | unchanged | +| `embedding_version` | unchanged | unchanged | +| Wire schema (`search_response.v1`, `answer.v1`) | shape unchanged | shape unchanged (hit ordering / snippet content 만 변화) | + +eval runner 의 `config_snapshot_json` 가 자동으로 `lexical_index_version` 의 V009 suffix 를 picks up — `crates/kebab-eval/tests/fixtures/eval/run-1.json` 의 V009 baseline 으로 regenerate 됨. + +## API + dependency 변경 + +신규 workspace dependencies: +- `lindera = "3"` (MIT/Apache-2.0) +- `lindera-ko-dic = "3"` (Apache-2.0, MeCab-ko-dic upstream) + +신규 facade-level API (`kebab-app` 의 `*_with_config` 패턴 따라): +- `kebab_chunk::tokenize_korean_morphological(text: &str) -> Option` +- `kebab_store_sqlite::SqliteStore::backfill_tokenized_korean_text(progress, tokenize) -> Result` + +retired: +- `kebab_app::short_query_hint()` helper — V007 시절 advisory. V009 의 2-char Korean query hit 으로 obsolete. `SearchResponse.hint` struct field + wire schema `hint` field 는 forward-compat 차원에서 보존 (항상 None). + +## Logging round 2 (v0.20.x sub-item 1 후속, PR #190 머지) + +- PDF OCR raster image dimension capture (PR #189 의 round 1 null 결함 fix). +- V008 SQLite mirror — historical OCR query table. +- CLI `kebab inspect ocr-stats` + `kebab inspect ocr-failures`. +- Log retention policy — `keep_recent_runs` + `retention_days`. + +자세한 내용 = HANDOFF.md "v0.20.x ingest log r2" entry + spec `docs/superpowers/specs/2026-05-28-v0.20.x-logging-r2-spec.md`. + +## Breaking changes / 사용자 영향 + +- **영어 lexical 의 substring 매칭이 사라짐** — 사용자가 `'token'` query 로 `'tokenizer'` 도 찾는 패턴 의존 시 vector/hybrid mode 로 mode-switch 권장. release notes 만 보고 mode 변경 안 한 사용자는 자기 KB 의 검색 결과 변화 인지. +- **첫 부팅 latency** — 큰 KB 의 사용자는 v0.20.1 binary 의 첫 호출이 30초~10분 hang. stderr progress log 확인. +- **DB / binary 크기 증가** — `kebab.sqlite` +20-30% (Korean-heavy), binary +120MB (lindera ko-dic embedded). `/build/cache/tmp` 또는 별 디스크에 KB 둔 사용자는 영향 minimal. + +## Upgrade 절차 + +```bash +# 1. binary 교체 (release tag v0.20.1) +git fetch && git checkout v0.20.1 && cargo build --release -p kebab-cli + +# 2. 첫 호출 — V009 migration + eager backfill 자동 +kebab search '한국' # stderr 의 backfill progress log 확인 + +# 3. 새 검색 패턴 확인 +kebab search '서울' # 2자 query hit +kebab search 'tokenizer' # whole-token (substring recall 회귀) +``` + +회귀 발견 시 `tasks/HOTFIXES.md` 또는 GitHub issue 보고. + +## References + +- Spec: [`docs/superpowers/specs/2026-05-28-v0.20.x-korean-morphological-tokenizer-spec.md`](../superpowers/specs/2026-05-28-v0.20.x-korean-morphological-tokenizer-spec.md) +- Plan: [`docs/superpowers/plans/2026-05-28-v0.20.x-korean-morphological-tokenizer-plan.md`](../superpowers/plans/2026-05-28-v0.20.x-korean-morphological-tokenizer-plan.md) +- HOTFIXES entry: [`tasks/HOTFIXES.md`](../../tasks/HOTFIXES.md) 2026-05-28 +- DOGFOOD scenarios: [`docs/DOGFOOD.md`](../DOGFOOD.md) §2.1 + §2.1bis +- Design contract: [`docs/superpowers/specs/2026-04-27-kebab-final-form-design.md`](../superpowers/specs/2026-04-27-kebab-final-form-design.md) §5.5 + §9 +- V009 migration: [`migrations/V009__fts_korean_morphological.sql`](../../migrations/V009__fts_korean_morphological.sql) +- lindera: https://github.com/lindera-morphology/lindera (MIT/Apache-2.0) +- lindera-ko-dic: https://github.com/lindera-morphology/lindera-dictionary (Apache-2.0) diff --git a/docs/superpowers/plans/2026-05-28-v0.20.x-korean-morphological-tokenizer-plan-closure-r1.md b/docs/superpowers/plans/2026-05-28-v0.20.x-korean-morphological-tokenizer-plan-closure-r1.md new file mode 100644 index 0000000..78d6062 --- /dev/null +++ b/docs/superpowers/plans/2026-05-28-v0.20.x-korean-morphological-tokenizer-plan-closure-r1.md @@ -0,0 +1,283 @@ +# Plan closure round 1 — 한국어 morphological tokenizer + +**Verdict**: ACCEPT +**Reviewed by**: closure verifier (sonnet) +**Reviewed at**: 2026-05-28 + +--- + +## Task A — Step → Spec traceability matrix + +### Forward matrix (Step → Spec sections) + +| Step | Title | Plan 에 명시된 Spec sections | Spec content match? | Notes | +|---|---|---|---|---| +| S1 | V009 migration + design §5.5 + CI rename | §5.1, §5.2, §5.3 | ✅ | spec §5.1 DDL skeleton, §5.2 corpus_revision bump SQL, §5.3 design §5.5 갱신 + CI diff-check rename 정확히 cover. | +| S2 | lindera dep | §6.1, §10.1 | ✅ | spec §6.1 라이브러리 선정 (lindera + lindera-dict-ko-dic), §10.1 license 검증 + Appendix D cross-link 반영. | +| S3 | tokenize_korean_morphological + ingest 통합 | §6.2 | ✅ | spec §6.2 Pre-tokenize 우회 + Invariant + 실패 처리 전체 cover. chunk struct 신규 field + store INSERT path 포함. | +| S4 | first-boot eager backfill | §8.1, §8.2, §6.2 (backfill atomic transaction) | ✅ | spec §8.1 V007 trigram DROP 처리, §8.2 자동 eager backfill 전략, §6.2 의 backfill atomic transaction invariant 모두 cover. | +| S5 | short_query_hint 제거 + lexical.rs 정리 | §7.2, §7.3 | ✅ | spec §7.2 lexical.rs 조정 (보존 선택), §7.3 CLI hint 제거 범위 cover. wire schema 영향 확인 절차(§11.3 cross-check)도 포함. | +| S6 | lexical_index_version bump | §11.1, §11.3 | ✅ | spec §11.1 index_version bump (format 갱신), §11.3 wire content 변화 명시 (lexical test 갱신) cover. | +| S7 | 신규 unit/integration test | §9.1, §9.2 | ✅ | spec §9.1 lexical-mode scenarios 4개 중 한국어 3개 + 영어 회귀 1개, §9.2 test 시그니처 verbatim 반영. fts_v009_matches_design_section_5_5_verbatim 은 S1 에서 이미 생성됨(plan 내 명시). | +| S8 | eval golden regenerate | §11.3 | ✅ | spec §11.3 "eval golden baseline 재생성 필수" 를 plan scope 포함으로 결정 + deviation 시 P5 follow-up 분리 가능 명시. | +| S9 | docs sync | §7.4 | ✅ | spec §7.4 Surface cascade list (README / SKILL / HANDOFF / ARCHITECTURE) + tasks/HOTFIXES.md entry 모두 cover. | +| S10 | version bump | §12.1 | ✅ | spec §12.1 v0.20.1 patch release strategy (Cargo.toml version bump + release notes draft) cover. | +| S11 | final sanity | §9.3, §12.2 | ✅ | spec §9.3 verifier checklist + §12.2 dogfood verification cover. | + +### Reverse matrix (Spec section → 커버 여부) + +| Spec section | Title | Cover 여부 | 담당 Step | +|---|---|---|---| +| §1 Summary | 배경 요약 | 직접 step 불필요 (서술) | — | +| §2 Background (§2.1~§2.3) | V007 한계 + 도그푸딩 + HOTFIXES 맥락 | 직접 step 불필요 (서술) | — | +| §3 Goals + Non-Goals | 목표 범위 | 직접 step 불필요 (서술) | — | +| §4 Design Decision (§4.1~§4.2) | Option A 선택 | 직접 step 불필요 (설계 결정) | — | +| §5.1 DDL skeleton | V009 migration SQL | ✅ covered | S1 | +| §5.2 corpus_revision bump | kv UPDATE + search cache 무효화 | ✅ covered | S1 | +| §5.3 Design contract 변경 + CI diff-check | §5.5 갱신 + test rename | ✅ covered | S1 | +| §6.1 라이브러리 선정 | lindera + lindera-dict-ko-dic | ✅ covered | S2 | +| §6.2 Pre-tokenize 우회 + Invariant + 실패 처리 | tokenize helper + ingest 통합 + backfill | ✅ covered | S3, S4 | +| §6.3 Vendoring 전략 | feature flag (default-enabled) | ⚠ PARTIAL | S2 에서 workspace dep 추가는 cover 하나, `kebab-app/[features]` 의 feature gate 등록(§6.3 "fts_korean_morphological = ["lindera"]") 이 plan 의 어떤 step 에도 **명시적으로 언급되지 않음**. | +| §7.1 Search CLI 경로 (변경 없음) | query path 변경 없음 | 직접 step 불필요 (서술) | — | +| §7.2 lexical.rs 조정 | build_match_string() 단순화 검토 | ✅ covered | S5 | +| §7.3 CLI hint 제거 | short_query_hint 제거 | ✅ covered | S5 | +| §7.4 Surface cascade list | docs sync | ✅ covered | S9 | +| §8.1 기존 V007 처리 | DROP + V009 교체 | ✅ covered | S4 | +| §8.2 자동 eager backfill | first-boot hook + idempotency | ✅ covered | S4 | +| §9.1 Lexical-mode search scenarios | 4 AC scenario | ✅ covered | S7, S11 | +| §9.2 Test coverage | test 시그니처 | ✅ covered | S7 | +| §9.3 Verifier checklist | final checklist | ✅ covered | S11 | +| §10.1 License verification | cargo deny / SPDX | ✅ covered | S2 | +| §10.2 Dict size + binary bloat | binary 크기 실측 | ✅ covered (evidence 는 S2 에서 cargo tree + 실측 reconcile) | S2 | +| §10.3 Ingest latency 증가 | latency 측정 | plan S5~Risk 절에 mitigation 언급 (formal step 없음, 허용 범위) | S11 | +| §10.4 다른 언어 | 별 PR | 직접 step 불필요 (out of scope 명시) | — | +| §11.1 index_version bump | lexical_index_version 함수 갱신 | ✅ covered | S6 | +| §11.2 parser/chunker_version | 변경 없음 | 직접 step 불필요 (서술) | — | +| §11.3 Wire schema 변경 + hit ordering | wire shape 불변 + eval golden | ✅ covered | S6, S8 | +| §12.1 v0.20.1 patch release | version bump + release notes | ✅ covered | S10 | +| §12.2 Dogfood verification | fresh KB + 2자 query | ✅ covered | S11 | +| Appendix A (Option B/C 비교) | 설계 rationale | 직접 step 불필요 (서술) | — | +| Appendix B (segmentation evidence) | lindera 실측 | S3 의 test fixture + mitigation 에서 반영 | S3 | +| Appendix C (cost evidence) | binary/DB/latency 추정 | S2 의 cargo build 실측 + plan §10 risks 에서 반영 | S2 | +| Appendix D (license) | SPDX 검증 | ✅ covered | S2 | + +**누락 section 발견**: §6.3 의 feature flag (`fts_korean_morphological`) 등록이 plan 어디에도 명시적 step/task 가 없음. 아래 Task B 와 연계하여 micro-patch 권장. + +--- + +## Task B — AC actionability (per step) + +| Step | AC text excerpt | Actionable? | 비고 / 권장 수정 | +|---|---|---|---| +| S1 | `cargo test -p kebab-store-sqlite --test fts fts_v009_matches_design_section_5_5_verbatim -j 4` → exit 0 | ✅ | 명령 + 기대 결과 명확. | +| S1 | `cargo test -p kebab-store-sqlite --test fts v009_bumps_corpus_revision -j 4` → exit 0, corpus_revision 값 strict-monotonic 증가 | ✅ | "strict-monotonic 증가" 는 `SELECT v` 값이 V008 적용 시점 대비 +1 이상 = verifier 가 SQL query 로 확인 가능. | +| S1 | `grep -c "fts_v007_matches_design_section_5_5_verbatim" crates/kebab-store-sqlite/tests/fts.rs` → `0` | ✅ | grep count 0 = rename 완료 mechanical 확인. | +| S1 | `cargo clippy -p kebab-store-sqlite --all-targets -j 4 -- -D warnings` → clean | ✅ | exit code 0 = clean. | +| S2 | `cargo build --workspace -j 4 2>&1 \| grep -E "^error\[E" \| wc -l` → `0` | ✅ | 명확한 명령 + wc -l 0 check. | +| S2 | `cargo tree --depth 1 -p lindera -p lindera-dict-ko-dic 2>&1 \| grep -iE "MIT\|Apache" \| wc -l` → `≥ 2` | ✅ | SPDX license 포함 여부 mechanical check. | +| S2 | `grep -c "lindera" Cargo.toml` → `≥ 1` | ✅ | | +| S2 | `grep -c "lindera" crates/kebab-chunk/Cargo.toml` → `≥ 1` | ✅ | | +| S3 | `cargo test -p kebab-chunk --test tokenize_korean tokenize_korean_morphological_splits_2char_word -j 4` → exit 0, token `"한국"` 포함 | ✅ | 명령 + fixture 명시. | +| S3 | `cargo test -p kebab-chunk --test tokenize_korean tokenize_korean_morphological_empty_returns_none -j 4` → exit 0 | ✅ | | +| S3 | `cargo build --workspace -j 4 2>&1 \| grep -c "warning: unused"` 가 baseline 대비 증가하지 않음 | ⚠ PARTIAL | "baseline 대비" 가 verifier 가 별도로 baseline 수치를 보유하고 있어야 함. 더 actionable 한 형태: `cargo build --workspace -j 4 2>&1 \| grep -c "warning: unused import.*lindera"` → `0` (lindera 가 실제 사용됨) 으로 수정 권장. | +| S3 | `grep -n "tokenized_korean_text" crates/kebab-chunk/src/lib.rs` → `≥ 1` | ✅ | | +| S3 | `cargo clippy -p kebab-chunk --all-targets -j 4 -- -D warnings` → clean | ✅ | | +| S4 | `cargo test -p kebab-store-sqlite --test fts backfill_tokenized_korean_text_populates_nullable_rows -j 4` → exit 0, idempotency 확인 | ✅ | idempotency 는 test body 에서 두 번째 호출 반환값 0 확인으로 mechanical 검증. | +| S4 | `App::open_with_config` 두 번 연속 호출 시 두번째 backfill_count = 0 | ⚠ PARTIAL | 이 AC 는 unit test 명령이 없음. "두 번 연속 호출" 의 test binary 이름이 명시되지 않아 verifier 가 어느 test 로 확인하는지 불명확. 권장: `cargo test -p kebab-app -j 4 -- backfill_idempotent` (또는 동급 test 명) 으로 명시. | +| S4 | `cargo clippy -p kebab-app --all-targets -j 4 -- -D warnings` → clean | ✅ | | +| S5 | `grep -rn "short_query_hint" crates/ tests/ 2>/dev/null \| wc -l` → `0` | ✅ | doc-comment 1줄 허용 예외가 있으나 ≤1 조건으로 처리 가능. | +| S5 | `cargo build --workspace -j 4 2>&1 \| grep -E "error\|warning: unused" \| wc -l` → baseline 과 동등 | ⚠ PARTIAL | S3 AC 와 동일한 "baseline 대비" 문제. 권장: `cargo build --workspace -j 4 2>&1 \| grep -E "^error" \| wc -l` → `0` 으로 대체. | +| S5 | `cargo test -p kebab-cli --test wire_search_response -j 4` → exit 0, test 가 list 에서 사라짐 | ✅ | test list 에서 사라짐 = `cargo test -- --list` grep 으로 확인. | +| S5 | `cargo clippy --workspace --all-targets -j 4 -- -D warnings` → clean | ✅ | | +| S6 | `cargo test -p kebab-search --test lexical lexical_index_version_is_returned_unchanged -j 4` → exit 0 | ✅ | | +| S6 | `./target/debug/kebab schema --json \| jq -r '.index_versions.lexical // empty' 2>/dev/null \| grep -c "fts5-v009"` → `≥ 1` | ⚠ PARTIAL | `target/debug/kebab` 의 빌드 여부가 이 step 에서 보장되지 않음. 권장: 명령 앞에 `cargo build -p kebab-cli -j 4 && ` 를 prepend 하거나, release path 용 `cargo build --release -p kebab-cli -j 4` 선행 명시. | +| S6 | `cargo clippy -p kebab-app --all-targets -j 4 -- -D warnings` → clean | ✅ | | +| S7 | `cargo test -p kebab-store-sqlite --test fts fts_v009_korean_morphological_2char_query_hits -j 4` → exit 0 | ✅ | | +| S7 | `cargo test -p kebab-store-sqlite --test fts fts_v009_english_whole_token_only -j 4` → exit 0 | ✅ | | +| S7 | `cargo test -p kebab-app --test search_korean korean_morphological_2char_query_lexical_mode -j 4` → exit 0 | ✅ | | +| S7 | `cargo test -p kebab-app --test search_korean korean_morphological_mixed_english_korean_query -j 4` → exit 0 | ✅ | | +| S7 | 신규 test binary 2개 추가로 workspace test count baseline +4 이상 | ⚠ PARTIAL | 검증 명령이 없음. 권장: `cargo test --workspace --no-fail-fast -j 1 2>&1 \| grep "^test result" \| awk -F'[,;]' '{sum+=\$2} END{print sum}'` 의 before/after 비교, 또는 `cargo test -p kebab-store-sqlite --test fts -- --list \| wc -l` 과 `cargo test -p kebab-app --test search_korean -- --list \| wc -l` 각 ≥ 1 로 대체 가능. | +| S8 | `cargo test -p kebab-eval -j 4 2>&1 \| grep "test result.*failed" \| grep -v "0 failed" \| wc -l` → `0` | ✅ | | +| S8 | `git diff --stat crates/kebab-eval/goldens/` → 변경 라인 수 > 0 | ✅ | baseline 이 실제로 갱신됐는지 mechanical 확인. | +| S8 | `cargo clippy -p kebab-eval --all-targets -j 4 -- -D warnings` → clean | ✅ | | +| S9 | `git diff --stat README.md HANDOFF.md docs/ARCHITECTURE.md integrations/claude-code/kebab/SKILL.md tasks/HOTFIXES.md` → 5 file 모두 변경 | ✅ | | +| S9 | `grep -c "한국어 2자" README.md` → `≥ 1` | ✅ | | +| S9 | `grep -c "V009" tasks/HOTFIXES.md` → `≥ 1` | ✅ | | +| S9 | `grep -c "lindera" docs/ARCHITECTURE.md` → `≥ 1` | ✅ | | +| S10 | `grep "^version" Cargo.toml \| head -1` → `version = "0.20.1"` | ✅ | | +| S10 | `./target/release/kebab --version 2>&1` → `kebab 0.20.1` | ⚠ PARTIAL | release binary 빌드 여부가 S10 에서 보장되지 않음. 권장: `cargo build --release -p kebab-cli -j 4 && ./target/release/kebab --version` 로 명시. | +| S10 | `cargo build --workspace -j 4 2>&1 \| tail -3` → success | ✅ | | +| S11 | `cargo test --workspace --no-fail-fast -j 1` → 모두 pass (baseline +4 이상) | ✅ | S11 은 전체 suite run — workspace sanity 기준. | +| S11 | dogfood smoke (fresh KB, 3 query) | ✅ | 명령 verbatim 명시됨. | +| S11 | V007 snapshot backfill 시나리오 | ✅ (best-effort 명시) | snapshot 부재 시 best-effort 허용으로 명시됨. | + +**요약**: PARTIAL AC 총 6건 (S3, S4, S5, S6, S7, S10) — 모두 minor actionability gap 이며, 명령 보강으로 해소 가능. NEEDS_REWRITE 수준 아님 → micro-patch 권장. + +--- + +## Task C — Dependencies sanity + +### 그래프 cycle-free 여부 + +의존 관계를 DAG 로 표현: + +``` +S1, S2 → S3 → S4, S5, S6 → S7 → S8 → S9 → S10 → S11 + (S1 also → S4, S6, S7 directly) +``` + +- S1 → S3 (V009 schema 필요), S1 → S4 (backfill은 V009 schema 필요), S1 → S6 (verbatim test rename 결과), S1 → S7 (fts.rs V009 test 기반). +- S2 → S3 (lindera dep 필요). +- S3 → S4 (backfill API 가 tokenize_korean_morphological 호출), S3 → S5 (short_query_hint 제거 후 cascade clean 필요), S3 → S7 (search_korean 통합 test 의 ingest path). +- S4, S5, S6 → S7 (test가 backfill + hint 제거 + version bump 모두 의존). +- S7 → S8 (eval golden 재생성은 new test baseline 기반), S7 → S11. +- S8 → S11. +- S9 → S10 (docs sync 후 version bump 의 커밋 순서). +- S10 → S11. + +사이클 없음 (topological order: S1 → S2 → S3 → {S4, S5, S6} → S7 → S8 → S9 → S10 → S11). **그래프 cycle-free: ✅** + +### parallel-dispatch 가능 step 명시 정확성 + +plan §3 의 명시: +- **Group 1**: S1 + S2 병렬. ✅ 두 step 은 파일 overlap 없음 (S1은 migration+design+fts.rs, S2는 Cargo.toml 만). +- **Group 2**: S4 + S5 + S6 병렬. ✅ S4 (store.rs + lib.rs), S5 (app.rs + tui/*.rs + cli/tests), S6 (app.rs:991-993 한 줄) — file overlap이 app.rs 에서 발생할 수 있음. S5 는 `crates/kebab-app/src/app.rs:98,532,616` 를 수정하고, S6 은 `crates/kebab-app/src/app.rs:991-993` 을 수정 — 동일 파일의 다른 행이나 **병렬 에이전트가 동시에 수정 시 merge conflict 가능**. plan 이 "file overlap 0" 이라고 명시하나 실제로는 app.rs 가 공유됨. 이는 runtime conflict risk 이며 cycle 이 아니므로 NEEDS_REWRITE trigger 아님. micro-patch 권장: Group 2 의 S6 을 S5 직후 sequential 로 명시하거나, "app.rs 의 편집 구역 분리 (line 범위 기준)" 를 executor 에게 주의 사항으로 기록. + +### subagent-driven-development "tasks mostly independent" criterion + +- S1, S2 가 진정으로 독립적 (entry point, 파일 무중복). ✅ +- S4, S5 는 파일 overlap 최소 (서로 다른 crate). ✅ +- S4/S5 vs S6 는 app.rs 공유 — conditional ✅ (행 단위 비겹침이나 실측 merge 주의 필요). +- S7 → S8 → S9 → S10 은 sequential 이므로 독립성 불요. + +전반적으로 "tasks mostly independent" criterion 충족. ✅ + +--- + +## Task D — Cost optimization 정합성 + +| Step | Title | Plan 권장 모델 | 사용자 요청 부합 | 판단 | +|---|---|---|---|---| +| S1 | V009 migration + design §5.5 + CI rename | **opus** | 복잡: spec §5.5 verbatim 정합 + CI diff-check marker 형식 일관성 + design doc 갱신. trigger CASE expression 한 글자 오차로 CI fail 위험. | ✅ opus 적절 | +| S2 | lindera dep | sonnet | 단순 mechanical: Cargo.toml dep 추가 + cargo tree SPDX 확인. | ✅ sonnet 적절 | +| S3 | tokenize_korean_morphological + ingest 통합 | **opus** | 복잡: lindera API 호출 (0.32 builder pattern 확인), chunk struct cascade, multi-crate 동시 변경, transaction invariant 보장. | ✅ opus 적절 | +| S4 | first-boot eager backfill | sonnet | spec 에 API signature 와 body outline 이 상세 명시됨. batch commit 패턴 표준. | ✅ sonnet 적절 | +| S5 | short_query_hint 제거 + lexical.rs 정리 | sonnet | 다중 파일 cascade 이나 mechanical search+delete. wire schema grep + if-then 분기 있으나 명시됨. | ✅ sonnet 적절 | +| S6 | lexical_index_version bump | sonnet | 단일 함수 + test fixture 1줄 갱신. | ✅ sonnet 적절 | +| S7 | 신규 test | sonnet | spec §9.2 verbatim 시그니처 따라. fixture 조정 필요 시 plan 에 가이드 있음. | ✅ sonnet 적절 | +| S8 | eval golden regenerate | sonnet | baseline regenerate 명령 실행 + commit. | ✅ sonnet 적절 | +| S9 | docs sync | sonnet | mechanical text edit, README narrow scope 지켜짐. | ✅ sonnet 적절 | +| S10 | version bump | sonnet | 한 줄 변경. | ✅ sonnet 적절 | +| S11 | final sanity | sonnet | 검증 명령 실행만. | ✅ sonnet 적절 | +| PR-level final review | 전체 diff | **opus** | merge 직전 cross-file 일관성. | ✅ opus 적절 | + +**종합**: 사용자 요청("가벼운 작업은 sonnet, 복잡한 것은 opus") 과 plan 의 routing 이 완전히 일치. S1 (§5.5 verbatim), S3 (lindera API + multi-crate) 두 step 만 opus 로 격상, 나머지 9 step 은 sonnet. ✅ 정합 이상 없음. + +--- + +## Task E — 0 new substantive finding + +plan 이 spec 의 design decision 을 재해석·변경·추가했는지 mechanical 검토: + +1. **§6.3 feature gate**: plan S2 의 구현 outline 은 workspace dep 추가를 기술하나, spec §6.3 의 `[features] fts_korean_morphological = ["lindera"]` 등록을 plan 의 어느 step 에도 명시하지 않았다. 이는 spec design 을 **변경**한 것이 아니라 **누락**한 것이다 (Task A 에서도 PARTIAL 로 분류). design 재해석이 아니므로 substantive finding 에는 해당하지 않는다. + +2. **S4.1 의 backfill batch commit (1000 row 마다)**: spec §8.2 에서 batch 크기를 구체적으로 명시하지 않았고 plan 이 1000 row 로 구체화했다. 이는 implementation detail 의 구체화이며, spec 의 design decision (backfill 정책 자체, idempotency, atomic transaction) 을 변경하지 않았다. substantive finding 아님. + +3. **S10.3 의 release notes draft 파일 (`docs/release-notes/v0.20.1-draft.md`)**: spec §12.1 의 release notes 4 단락 본문이 plan 에서 별도 파일 보관으로 구체화됐다. spec 의 release strategy 를 변경하지 않는 implementation 선택. substantive finding 아님. + +4. **S5 의 `search_plain_emits_short_query_hint_to_stderr` test 처리**: plan S5.5 에서 "삭제 또는 inverted rename" 두 가지 선택지를 열어 뒀다. spec §7.3 은 함수 정의+호출 제거 범위만 명시하고 test 처리를 직접 다루지 않으므로, plan 이 spec design 을 변경한 것이 아니라 executor 에게 선택을 위임한 것이다. substantive finding 아님. + +5. **S3.1 의 `OnceCell` / `lazy_static` tokenizer 캐시 패턴**: spec §6.2 에는 dictionary load 의 캐시 패턴이 명시되지 않았다. plan 이 performance optimization 을 추가 제안했으나, 이는 spec 의 invariant ("동일 Rust transaction 내 INSERT") 를 위반하지 않는 implementation hint 다. design 추가가 아니라 실행 guidance. substantive finding 아님. + +**None.** — 새로운 substantive finding 없음. + +--- + +## Task F — Final verifier checklist completeness + +### spec §9 AC 커버 + +| spec §9 항목 | plan §7 checklist 커버 | 비고 | +|---|---|---| +| §9.1 Scenario 1: `kebab search '한국'` → hit ≥ 1 | ✅ `kebab search '한국'` (fresh V009 KB) → hit ≥ 1 | | +| §9.1 Scenario 2: `kebab search '서울'` → hit ≥ 1 | ✅ `kebab search '서울'` (fresh V009 KB) → hit ≥ 1 | | +| §9.1 Scenario 3: `kebab search '지하철'` → hit ≥ 1 | ✅ `kebab search '지하철'` (fresh V009 KB) → hit ≥ 1 | | +| §9.1 Scenario 4: `kebab search 'pipeline'` → whole-token 매칭만 (substring 0-hit) | ⚠ PARTIAL | plan checklist 에 `pipeline` 검색 확인이 없음. S11.5 dogfood smoke 에 해당 query 없음. | +| §9.2 `fts_v009_korean_morphological_2char_query_hits` pass | ✅ `cargo test --workspace --no-fail-fast -j 1` → baseline +4 이상 에 포함 | | +| §9.2 `fts_v009_english_whole_token_only` pass | ✅ 동상 | | +| §9.2 `fts_v009_matches_design_section_5_5_verbatim` pass | ✅ 동상 (S1 에서 생성됨) | | +| §9.2 `korean_morphological_2char_query_lexical_mode` pass | ✅ 동상 | | +| §9.2 `korean_morphological_mixed_english_korean_query` pass | ✅ 동상 | | +| §9.3 `chunks.tokenized_korean_text` 모든 한국어 chunk 에 채워짐 | ✅ V009 migration apply 후 기존 V007 KB → eager backfill 확인 | | +| §9.3 FTS5 query `MATCH "한국"` → hit | ✅ 상동 | | +| §9.3 `kebab schema --json` wire schema 변경 없음 | ✅ S11.4 schema 무결성 (`jq -e '.wire.schemas \| length'`) | | +| §9.3 Hybrid/vector search 변경 없음 | ⚠ PARTIAL | plan checklist 에 hybrid/vector mode 회귀 검증 명령이 없음. spec §9.3 "Hybrid/vector search 는 변경 없음" 항목을 S11 의 dogfood smoke 에서 별도 확인하도록 명시되지 않음. | + +### spec §10 risk 커버 + +| spec §10 risk | plan checklist 커버 | 비고 | +|---|---|---| +| §10.1 License (cargo deny) | ✅ "(deferred to P9) cargo deny check" 명시 + cargo tree SPDX 수동 대체 | | +| §10.2 Dict size + binary bloat | ⚠ PARTIAL | plan checklist 에 binary size 실측 확인 항목 없음. S2 에서 cargo build 출력 확인은 있으나, release binary 의 `ls -lh` 또는 `size` 측정이 checklist 에 없음. | +| §10.3 Ingest latency 증가 | ⚠ PARTIAL | plan checklist 에 ingest latency 측정 항목이 없음. spec §12.2 "Performance measurement: ingest duration 전후 비교" 를 plan §7 checklist 가 cover 하지 않음. | +| §10.4 일본어/중국어 | 직접 check 불필요 (out of scope) | ✅ | + +### spec §12 release strategy 커버 + +| spec §12 항목 | plan checklist 커버 | 비고 | +|---|---|---| +| §12.1 v0.20.1 version bump | ✅ `Cargo.toml workspace version = "0.20.1"` | | +| §12.1 release notes 4 단락 | ✅ `docs/release-notes/v0.20.1-draft.md` 4 단락 | | +| §12.2 fresh KB + 2자 query dogfood | ✅ S11.5 smoke | | +| §12.2 hybrid/vector mode 변경 없음 확인 | ⚠ PARTIAL | 상동 (§9.3 과 동일 gap) | +| §12.2 performance measurement | ⚠ PARTIAL | checklist 에 ingest duration 비교 없음 | + +**누락 항목 요약:** +- English substring 0-hit 회귀 확인 (`'pipeline'` 또는 `'token'` query → 0-hit on substring-only string) — plan §7 checklist 미포함. +- Hybrid/vector search 변경 없음 확인 명령 — plan §7 checklist 미포함. +- Release binary size 실측 — plan §7 checklist 미포함 (spec §10.2 risk). +- Ingest latency 측정 — plan §7 checklist 미포함 (spec §12.2 + §10.3). + +4건 모두 낮은 위험도의 검증 gap 이며, NEEDS_REWRITE trigger 수준 아님. micro-patch 로 처리 가능. + +--- + +## Verdict rationale + +Task A~F 의 결과를 종합한다: + +- **Task A**: spec §6.3 feature flag 등록이 plan 에 누락됐으나, design 재해석이 아닌 implementation detail 누락이며 micro-patch 수준. spec 의 모든 core section (§5.1~§12.2) 이 적어도 한 step 에 의해 cover 됨. +- **Task B**: PARTIAL AC 6건이 모두 "baseline 대비" 모호성, binary 빌드 사전 보장 누락 등 minor actionability gap. 명령 1줄 추가로 해소 가능. critical AC vagueness ("올바르게 작동" 류) 는 0건. +- **Task C**: 그래프 cycle-free 확인. S5/S6 의 app.rs 공유로 인한 merge conflict 가능성이 있으나 plan 이 "file overlap 0" 이라고 잘못 명시한 것은 micro-patch 주의 수준. cycle 은 아님. +- **Task D**: 모든 model routing 이 사용자 요청("가벼운 작업 sonnet")과 정합. 이상 없음. +- **Task E**: 새로운 substantive design finding 없음. plan 은 spec 의 implementation 일정만 기술. +- **Task F**: 4건의 minor checklist gap (영어 substring 회귀 확인, hybrid/vector 회귀 확인, binary size, ingest latency). 모두 낮은 위험도. + +NEEDS_REWRITE trigger (그래프 cycle, spec section 미커버, new substantive design 변경) 에 해당하는 항목 없음. **ACCEPT**. + +--- + +## Recommended micro-patches (plan author 가 별 round 없이 직접 적용 가능) + +다음은 plan 자체의 수정 없이 executor 에게 전달하거나, plan 의 해당 줄을 최소 보완하는 수준의 권장 사항이다. + +**MP-1 (Task A, §6.3 feature flag)**: S2 또는 S3 의 "Files to modify" 에 `crates/kebab-app/Cargo.toml` — `[features]` 에 `fts_korean_morphological = ["dep:lindera"]` + `default = ["fts_korean_morphological"]` 추가 항목 삽입. spec §6.3 의 feature gate 등록을 누락하지 않도록. + +**MP-2 (Task B, S3 AC)**: `cargo build --workspace -j 4 2>&1 \| grep -c "warning: unused"` → `cargo build --workspace -j 4 2>&1 \| grep -c "warning: unused import.*lindera"` → `0` 으로 수정. + +**MP-3 (Task B, S4 AC)**: `App::open_with_config 두 번 연속 호출 시 backfill_count = 0` AC 에 구체적인 test 이름 또는 검증 명령 추가. 예: `cargo test -p kebab-app -j 4 -- backfill_is_idempotent_on_second_open` (또는 `backfill_tokenized_korean_text_populates_nullable_rows` 의 두번째 호출 assertion 이 이미 커버하는 경우 S4 AC 에 그 사실 cross-link). + +**MP-4 (Task B, S5 AC)**: `cargo build --workspace -j 4 2>&1 \| grep -E "error\|warning: unused" \| wc -l` → `cargo build --workspace -j 4 2>&1 \| grep -E "^error" \| wc -l` → `0` 으로 대체. + +**MP-5 (Task B, S6 AC)**: `./target/debug/kebab schema --json ...` 앞에 `cargo build -p kebab-cli -j 4 &&` 선행 명시. + +**MP-6 (Task B, S7 AC)**: `신규 test binary 2개 추가로 workspace test count baseline +4 이상` 항목에 검증 명령 추가. 예: `cargo test -p kebab-store-sqlite --test fts -- --list 2>&1 \| grep "fts_v009_korean" \| wc -l` → `≥ 1` + `cargo test -p kebab-app --test search_korean -- --list 2>&1 \| wc -l` → `≥ 2`. + +**MP-7 (Task B, S10 AC)**: `./target/release/kebab --version` 앞에 `cargo build --release -p kebab-cli -j 4 &&` 선행 명시. + +**MP-8 (Task C, Group 2 주의)**: §3 Dependencies 의 Group 2 주의 사항에 "S5 와 S6 은 모두 `crates/kebab-app/src/app.rs` 를 수정함. 병렬 에이전트 실행 시 편집 행 범위를 사전 분리할 것 (S5: line 98,532,616; S6: line 991-993)." 한 줄 추가. + +**MP-9 (Task F, §7 checklist)**: plan §7 verifier checklist 에 다음 3개 항목 추가: +- `./target/release/kebab --config /tmp/kebab-smoke-v009/config.toml search 'token' --json \| jq '.hits \| length'` → 0 (영어 substring 매칭 회귀 확인, V009 는 whole-token only). +- `./target/release/kebab --config /tmp/kebab-smoke-v009/config.toml search 'tokenizer' --json \| jq '.hits \| length'` → ≥ 1 (whole-token 매칭 정상). +- hybrid/vector 회귀: `./target/release/kebab --config /tmp/kebab-smoke-v009/config.toml search '한국' --mode hybrid --json \| jq '.hits \| length'` → ≥ 1 (또는 mode flag 실제 이름으로 대체). diff --git a/docs/superpowers/plans/2026-05-28-v0.20.x-korean-morphological-tokenizer-plan.md b/docs/superpowers/plans/2026-05-28-v0.20.x-korean-morphological-tokenizer-plan.md new file mode 100644 index 0000000..95a0bb3 --- /dev/null +++ b/docs/superpowers/plans/2026-05-28-v0.20.x-korean-morphological-tokenizer-plan.md @@ -0,0 +1,762 @@ +--- +title: v0.20.x — 한국어 morphological tokenizer (Bug #8 follow-up) — plan +created: 2026-05-28 +status: accepted +spec: docs/superpowers/specs/2026-05-28-v0.20.x-korean-morphological-tokenizer-spec.md +critic_r1: docs/superpowers/specs/2026-05-28-v0.20.x-korean-morphological-tokenizer-spec-critic-r1.md +critic_r2: docs/superpowers/specs/2026-05-28-v0.20.x-korean-morphological-tokenizer-spec-critic-r2.md +parent_handoff: docs/superpowers/handoffs/2026-05-28-v0.20.x-c-korean-morphological-tokenizer-handoff.md +branch: feat/korean-morphological-tokenizer +step_count: 11 +commit_count: 10 +--- + +# v0.20.x — 한국어 morphological tokenizer — implementation plan + +> **For agentic workers:** REQUIRED SUB-SKILL — `superpowers:executing-plans` (또는 `superpowers:subagent-driven-development`). 각 Step 은 단일 commit boundary 를 가지고, 마지막 Step (S11 verify-only) 은 commit 없음. step-level checkbox `- [ ]` syntax 사용. spec 의 frozen contract 를 보존하며, 본 plan 은 implementation 일정만 다룬다. + +**Goal.** V007 trigram FTS5 tokenizer 의 한계 (2-char 한국어 query 0-hit) 를 해결한다. lindera + lindera-dict-ko-dic 기반 형태소 분석기를 도입하고, 한국어 chunk 의 분해된 morpheme 을 별 column `tokenized_korean_text` 에 pre-fill 한 뒤 FTS5 의 `unicode61` tokenizer 가 공백 경계로 token 화하도록 구성한다. V009 migration 으로 schema 를 한 번 교체하고, 첫 부팅 시 자동 eager backfill 로 기존 KB 의 모든 chunk 를 재-tokenize 한다 (사용자 재-ingest 불필요). + +**Architecture.** V009 migration 은 schema 만 변경 (column ADD, chunks_fts DROP+재정의, triggers 갱신). lindera 호출은 Rust 측 (`kebab-chunk::tokenize_korean_morphological`) 에서 일어나며, ingest pipeline 의 신규 chunk INSERT 시 동일 transaction 안에 `tokenized_korean_text` 를 pre-fill 한다. 기존 chunk 의 backfill 은 `App::open_with_config` 의 first-boot hook 에서 trigger 되어 chunks 전체에 대해 UPDATE → chunks_au trigger 가 chunks_fts 를 자동 재-index. `lexical_index_version` 은 V007 → V009 로 bump 되어 사용자 visible `index_version` 값이 변경된다. 영어 substring 매칭은 V002 동작 (whole-token only) 으로 회귀 — release notes 에 정직히 기술된다. + +**Tech stack.** Rust 2024, rusqlite (existing), refinery (existing), `lindera = "0.32"` (또는 시점 latest stable) workspace dep 신규, `lindera-dict-ko-dic` per-crate feature dep 신규. CARGO_TARGET_DIR=/build/out/cargo-target/target, `-j 4` default. + +**Spec contract.** `docs/superpowers/specs/2026-05-28-v0.20.x-korean-morphological-tokenizer-spec.md` (668 line, ACCEPT, frozen). critic R2 의 9 finding traceability 매트릭스 + Appendix A~D 포함. 본 plan 은 spec 의 §1–§12 를 8 step 으로 분해 + 3 보조 step (test / docs / release). + +--- + +## File map + +**Modify (12):** +- `Cargo.toml` — workspace `[workspace.dependencies]` 에 `lindera`, `lindera-dict-ko-dic` 추가. +- `crates/kebab-chunk/Cargo.toml` — `lindera` + `lindera-dict-ko-dic` per-crate dep 추가 (또는 feature gate). chunk crate 가 한국어 tokenizer 의 owner. +- `crates/kebab-chunk/src/lib.rs` — `tokenize_korean_morphological(text: &str) -> Option` helper 신규 + chunk builder pipeline 에 호출 추가. +- `crates/kebab-store-sqlite/src/store.rs` — chunk INSERT path 에 `tokenized_korean_text` column 추가, backfill API `backfill_tokenized_korean_text(progress_cb)` 신규. +- `crates/kebab-store-sqlite/tests/fts.rs` — 기존 V007 verbatim test rename + 신규 V009 verbatim test + 한국어 morphological hit test. +- `crates/kebab-search/src/lexical.rs` — `build_match_string()` 의 trigram-specific 분기 단순화 (보존 정책 결정). +- `crates/kebab-app/src/app.rs` — `short_query_hint()` 함수 + 2 호출 site 제거, `lexical_index_version()` 의 source 갱신. +- `crates/kebab-app/src/lib.rs` — `short_query_hint` re-export 제거, `App::open_with_config` 에 first-boot eager backfill hook 추가. +- `crates/kebab-tui/src/app.rs`, `crates/kebab-tui/src/search.rs`, `crates/kebab-tui/src/run.rs` — `short_query_hint` 필드 + 호출 제거 (TUI 측 cascade). +- `crates/kebab-cli/tests/wire_search_response.rs` — `search_plain_emits_short_query_hint_to_stderr` test 삭제 또는 inverted (hint 가 더 이상 emit 안 됨). +- `docs/superpowers/specs/2026-04-27-kebab-final-form-design.md` — §5.5 chunks_fts 블록을 V009 의 unicode61 + CASE expression 트리거 본문으로 갱신 (verbatim diff-check 대상). +- `README.md`, `HANDOFF.md`, `docs/ARCHITECTURE.md`, `integrations/claude-code/kebab/SKILL.md`, `tasks/HOTFIXES.md` — surface cascade (S9 묶음). + +**Create (3):** +- `migrations/V009__fts_korean_morphological.sql` — column ADD + chunks_fts re-create + triggers + corpus_revision bump (Section 5 verbatim). +- `crates/kebab-app/tests/search_korean.rs` — end-to-end 한국어 2-char query integration test. +- (선택) `crates/kebab-chunk/tests/tokenize_korean.rs` — lindera tokenize 단위 테스트. + +**Do NOT modify:** +- `migrations/V007__fts_trigram.sql` — historical migration, 그대로 유지 (replay path 보존). +- `migrations/V002__fts.sql` — V007 가 이미 design verbatim 비교 대상에서 제외했고, V009 도 동일. +- spec 의 ACCEPT 본문 — 본 plan 은 spec 의 implementation 일정만 다룬다. 후속 deviation 은 `tasks/HOTFIXES.md` 에 기록. +- `docs/wire-schema/v1/*.schema.json` — wire schema shape 불변 (§11.3, hit ordering 만 변화). + +--- + +## 1. Scope summary + +이 plan 이 cover 하는 spec section: + +- **§4 Design Decision** (Option A 선택 rationale). +- **§5 Migration Cascade (V009)** — DDL + corpus_revision + CI diff-check rename. +- **§6 Tokenizer Integration** — lindera 의존성 + pre-tokenize 우회 + invariant + fallback. +- **§7 Query Path** — lexical.rs 정리 + short_query_hint 제거 + surface cascade. +- **§8 Backward Compatibility + Eager Backfill** — first-boot hook. +- **§9 Acceptance Criteria** — 신규 단위/통합 test. +- **§10 Risks + Evidence** — Appendix C/D 의 estimate 와 실측 reconciliation. +- **§11 Version Cascade** — `lexical_index_version` bump. +- **§12 Release Strategy** — v0.20.1 patch release + dogfood verification. + +**제외 (별 PR / 별 follow-up)**: + +- **일본어/중국어 morphological tokenizer** — spec §10.4. 본 plan 은 한국어 ko-dic 만. 동일 패턴 재사용은 별 plan. +- **Eval golden baseline regenerate** — spec §11.3 결정상 본 PR scope 에 포함하나 별 step (S8) 으로 분리. crate `kebab-eval` 의 goldens.csv 가 변경되므로 별 commit 권장. +- **Streaming progress for eager backfill** — spec §10.3 mitigation 의 "background job + streaming feedback" 은 v0.20.x 의 후속 P5 sub-item 으로 미룬다. + +--- + +## 2. Step decomposition + +8 implementation step + 3 보조 step = 총 11 step, 10 commit (S11 verify-only). spec 의 권장 11 step (S1~S11) 을 본 plan 에서는 의존성 그룹에 따라 재배치하고, S2 (design §5.5) + S1 (migration) + S8 (test) 를 한 commit 단위로 묶는다. + +### Step 1 — V009 migration + design §5.5 갱신 + CI diff-check rename + +**Implements:** §5.1, §5.2, §5.3. **Commit 1/10.** + +**Spec sections covered:** §5.1 DDL skeleton, §5.2 corpus_revision bump, §5.3 design + CI diff-check 갱신. + +**Files to modify:** +- Create `migrations/V009__fts_korean_morphological.sql`. +- Modify `docs/superpowers/specs/2026-04-27-kebab-final-form-design.md` (§5.5 블록). +- Modify `crates/kebab-store-sqlite/tests/fts.rs` (V007 verbatim test → V009 rename). + +**Implementation outline:** + +- [ ] **1.1 V009 migration 파일 작성** — spec §5.1 의 DDL skeleton 을 verbatim 복사한다. 4 영역으로 구성: (a) `ALTER TABLE chunks ADD COLUMN tokenized_korean_text TEXT;`, (b) 기존 chunks_fts + 3 trigger DROP, (c) 신규 chunks_fts (unicode61) + chunks_ai/ad/au trigger 재정의 (CASE expression 포함), (d) `INSERT INTO chunks_fts ... SELECT ... FROM chunks;` (기존 row 재-index), (e) 마지막 줄 `UPDATE kv SET v = v + 1 WHERE k = 'corpus_revision';`. +- [ ] **1.2 §5.5 verbatim 마커** — V007 의 design verbatim block 마커 (`-- ── BEGIN §5.5 verbatim block ──` / `-- ── End §5.5 verbatim block ──`) 와 동일한 marker 를 V009 migration 의 (c) 블록 주위에 삽입. CI diff-check 가 마커로 슬라이스 추출하므로 형식 일관성 필수. +- [ ] **1.3 design §5.5 갱신** — `docs/superpowers/specs/2026-04-27-kebab-final-form-design.md` §5.5 의 chunks_fts 정의 + 3 trigger 본문을 V009 의 unicode61 + CASE expression 형태로 다시 쓴다. critic R1 finding #5 + critic R2 의 verbatim scope (CASE expression 포함) 따라 trigger body 전체가 verbatim diff 대상. spec drafter 가 §5.3 에서 명시한 "whitespace-normalized string compare of the §5.5 block, scope = CASE expression 포함" 정의 적용. +- [ ] **1.4 V007 verbatim test rename** — `crates/kebab-store-sqlite/tests/fts.rs:407` 의 `fts_v007_matches_design_section_5_5_verbatim` 를 `fts_v009_matches_design_section_5_5_verbatim` 로 rename. 함수 body 의 marker 검색 대상도 `migrations/V007__fts_trigram.sql` → `migrations/V009__fts_korean_morphological.sql` 로 변경. fts.rs:402-405 의 doc-comment 갱신 ("V007 stays for historical replay, V009 is the source of truth"). V002 + V007 모두 더 이상 design 비교 대상 아님 명시. +- [ ] **1.5 corpus_revision SQL 의 정상 동작 검증** — 신규 단위 test `v009_bumps_corpus_revision` 를 fts.rs 에 추가. TempDir SqliteStore 열기 → migration apply → `SELECT v FROM kv WHERE k='corpus_revision'` 가 V008 적용 시점 대비 +1 이상. +- [ ] **1.6 Verify.** `cargo test -p kebab-store-sqlite --test fts -j 4` 가 통과. `fts_v009_matches_design_section_5_5_verbatim` 와 `v009_bumps_corpus_revision` 모두 hit. 기존 V002/V007 test 는 syntactic 만 유지된 상태로 pass. + +**Acceptance criteria:** +- `cargo test -p kebab-store-sqlite --test fts fts_v009_matches_design_section_5_5_verbatim -j 4` → exit 0, assertion 통과 (V009 의 §5.5 block 이 design §5.5 와 whitespace-normalized string-equal). +- `cargo test -p kebab-store-sqlite --test fts v009_bumps_corpus_revision -j 4` → exit 0, corpus_revision 값이 strict-monotonic 증가. +- `grep -c "fts_v007_matches_design_section_5_5_verbatim" crates/kebab-store-sqlite/tests/fts.rs` → `0` (rename 완료 확인). +- `cargo clippy -p kebab-store-sqlite --all-targets -j 4 -- -D warnings` → clean. + +--- + +### Step 2 — lindera dependency + license 검증 + +**Implements:** §6.1, §10.1. **Commit 2/10.** + +**Spec sections covered:** §6.1 라이브러리 선정, §10.1 라이센스 검증 (Appendix D). + +**Files to modify:** +- `Cargo.toml` (workspace) — `[workspace.dependencies]` 에 `lindera = "0.32"` 와 `lindera-dict-ko-dic = "0.32"` 추가. version pin 은 cargo search 결과의 stable latest 로 결정. +- `crates/kebab-chunk/Cargo.toml` — per-crate dep 에 `lindera = { workspace = true }` + `lindera-dict-ko-dic = { workspace = true, features = ["embedded-dict"] }` 추가. dict 의 정확한 feature name 은 lindera-dict-ko-dic 의 Cargo.toml metadata 확인 후 결정. +- `crates/kebab-app/Cargo.toml` — `[features]` 에 `fts_korean_morphological = ["dep:lindera"]` + `default = ["fts_korean_morphological"]` 등록 (spec §6.3 feature gate). + +**Implementation outline:** + +- [ ] **2.1 cargo search 로 stable latest 확인** — `cargo search lindera lindera-dict-ko-dic --limit 5`. spec §6.1 의 라이브러리 선정 의도와 일치하는 minor version 선택. crate 가 multi-dictionary feature flag (`ko-dic`, `embedded-dict`) 를 갖고 있다면 그것을 활성화. +- [ ] **2.2 Workspace + per-crate dep 추가** — `Cargo.toml` 의 `[workspace.dependencies]` 에 두 dep 추가 (version pin, registry). `crates/kebab-chunk/Cargo.toml` 의 `[dependencies]` 에 `lindera = { workspace = true }` 와 `lindera-dict-ko-dic = { workspace = true, features = [...] }` 추가. +- [ ] **2.3 cargo build 의 dict 다운로드 정책 검증** — spec §10 risks, Appendix C 의 "release binary 에 embed +15-25 MB" 를 만족시키려면 dict 가 build-time 에 embedded 되어야 한다. `cargo build -p kebab-chunk --release 2>&1 | tail -20` 으로 dict download / decompression step 가 성공하는지 확인. 실패 시 (network egress 차단 환경) lindera-dict-ko-dic 의 `embedded-dict` 또는 동급 feature 가 정확히 어떤 이름인지 crate docs 참조 후 수정. 최악 fallback: `build.rs` proactive download 또는 vendored crate (별 follow-up). +- [ ] **2.4 license fingerprint 확인** — `cargo tree --depth 1 -p lindera -p lindera-dict-ko-dic` 로 두 crate 의 SPDX 확인. Appendix D 의 가정 (`MIT OR Apache-2.0` for lindera, `Apache-2.0` for dict) 와 일치하는지 정직히 비교. 불일치 시 plan 의 후속 step (S9 docs sync) 의 release notes 표현을 조정. +- [ ] **2.5 deny.toml 부재 확인** — 현재 repo 에 `deny.toml` 이 없음 (probe 결과 `ls deny.toml` → no such file). spec §10.1 + Appendix D 가 명시한 `cargo deny check` 절차는 본 plan 에서 **deferred**. 대신 (a) `cargo tree` 출력의 SPDX 를 `tasks/HOTFIXES.md` 에 evidence 로 기록, (b) spec drafter 의 CC BY-SA 라이센스 미포함 fail-fast 정책은 cargo tree 의 license field 수동 확인으로 대체. follow-up: `cargo-deny` 도입은 별 P9 sub-item 으로 분리 (HANDOFF 에 추가). +- [ ] **2.6 dependency-only commit** — 본 step 은 lindera 호출 site 추가 없이 dep 만 추가한다. `cargo build --workspace -j 4 2>&1 | tail -3` 가 success (어떤 호출도 없으므로 unused-dependency lint trigger 가능 — `#[allow(unused_imports)]` 또는 implicit use 는 다음 step S3 에서 해소). 본 step 의 commit boundary 는 "dep만 추가, 실제 use 는 next commit" 로 명시. + +**Acceptance criteria:** +- `cargo build --workspace -j 4 2>&1 | grep -E "^error\\[E" | wc -l` → `0`. +- `cargo tree --depth 1 -p lindera -p lindera-dict-ko-dic 2>&1 | grep -iE "MIT|Apache" | wc -l` → `≥ 2`. SPDX 가 Apache-2.0 + MIT 둘 중 적어도 하나 포함. +- `grep -c "lindera" Cargo.toml` → `≥ 1` (workspace dep 등록). +- `grep -c "lindera" crates/kebab-chunk/Cargo.toml` → `≥ 1` (per-crate dep 등록). + +--- + +### Step 3 — kebab-chunk 에 `tokenize_korean_morphological()` helper + ingest pipeline 통합 + +**Implements:** §6.2 (transaction invariant + fallback). **Commit 3/10.** + +**Spec sections covered:** §6.2 Pre-tokenize 우회 + Invariant + 실패 처리. + +**Files to modify:** +- `crates/kebab-chunk/src/lib.rs` — `tokenize_korean_morphological()` 함수 신규 + chunk builder pipeline 에 호출. +- (선택) `crates/kebab-chunk/tests/tokenize_korean.rs` — lindera segmentation 의 단위 검증. + +**Implementation outline:** + +- [ ] **3.1 helper 함수 signature** — `kebab-chunk/src/lib.rs` 에 다음 추가: + + ```rust + /// 한국어 chunk text 를 lindera ko-dic 으로 형태소 분해해 공백 join 한 결과를 반환. + /// 분석 실패 시 None — 호출자는 NULL fallback 처리. + pub fn tokenize_korean_morphological(text: &str) -> Option { + use lindera::{ + dictionary::{DictionaryConfig, DictionaryKind, load_dictionary_from_config}, + mode::Mode, + segmenter::Segmenter, + tokenizer::Tokenizer, + }; + let dict_config = DictionaryConfig { kind: Some(DictionaryKind::KoDic), path: None }; + let dictionary = load_dictionary_from_config(dict_config).ok()?; + let segmenter = Segmenter::new(Mode::Normal, dictionary, None); + let tokenizer = Tokenizer::new(segmenter); + let tokens = tokenizer.tokenize(text).ok()?; + let joined = tokens.iter().map(|t| t.text.as_ref()).collect::>().join(" "); + if joined.trim().is_empty() { None } else { Some(joined) } + } + ``` + + 실제 API 는 lindera 0.32 의 builder pattern 따라 조정 (crate docs 참조). `OnceCell` 또는 `lazy_static` 으로 Tokenizer 1회만 초기화하는 캐시 패턴 적용 — segmentation cost 의 dictionary load 의존이 hot loop 에 영향 안 가도록. + +- [ ] **3.2 fallback 정책 명시** — spec §6.2 "tokenize_korean_morphological() 실패 처리" 따라 dictionary load fail 또는 token error 시 `None` 반환. 호출 site 에서 `tracing::warn!(target: "kebab-chunk", "tokenize_korean_morphological fallback to NULL: chunk_id={...}, err={...}");` 발화. chunk 자체 ingest 는 성공 + chunks_ai trigger 가 ELSE branch (raw text 만 index) 를 탄다. +- [ ] **3.3 chunk builder pipeline 통합** — `kebab-chunk` 의 chunk emit / builder loop (구체 함수명은 probe 결과 따라 결정; chunk struct 의 `text` field 가 채워진 직후 시점) 에서 `tokenize_korean_morphological(&chunk.text)` 호출 → 결과를 `chunk.tokenized_korean_text: Option` 신규 field 에 저장. chunk struct definition 도 갱신. +- [ ] **3.4 store INSERT path 갱신** — `crates/kebab-store-sqlite/src/store.rs` 의 chunks INSERT SQL 에 `tokenized_korean_text` column 추가. signature 갱신: prepared statement 의 placeholder count 가 +1 됨. row binding 도 동일 transaction 안에서 단일 INSERT 로 처리 (spec §6.2 "lindera tokenize → chunks INSERT 는 동일 Rust transaction 내에서" invariant 보장). +- [ ] **3.5 단위 테스트** — `crates/kebab-chunk/tests/tokenize_korean.rs` 신규: + + ```rust + #[test] + fn tokenize_korean_morphological_splits_2char_word() { + let out = kebab_chunk::tokenize_korean_morphological("한국 문화는 오래되었다").unwrap(); + // 공백으로 join 된 token 시퀀스에 "한국" 이 독립 token 으로 존재해야 함. + let tokens: Vec<&str> = out.split_whitespace().collect(); + assert!(tokens.contains(&"한국"), "tokens = {:?}", tokens); + } + + #[test] + fn tokenize_korean_morphological_empty_returns_none() { + assert!(kebab_chunk::tokenize_korean_morphological("").is_none()); + assert!(kebab_chunk::tokenize_korean_morphological(" ").is_none()); + } + ``` + + `한국` token 의 hit 가 lindera ko-dic 의 실제 segmentation 동작 의존이므로 (Appendix B 의 prior-knowledge 예측), 실측 실패 시 fixture text 를 spec Appendix B 의 검증 명령 출력으로 교체. + +- [ ] **3.6 Verify.** `cargo test -p kebab-chunk --test tokenize_korean -j 4` → exit 0. `cargo build --workspace -j 4` → success. + +**Acceptance criteria:** +- `cargo test -p kebab-chunk --test tokenize_korean tokenize_korean_morphological_splits_2char_word -j 4` → exit 0, fixture `"한국 문화는 오래되었다"` 분해 결과에 token `"한국"` 포함. +- `cargo test -p kebab-chunk --test tokenize_korean tokenize_korean_morphological_empty_returns_none -j 4` → exit 0. +- `cargo build --workspace -j 4 2>&1 | grep -c "warning: unused import.*lindera"` → `0` (lindera dep 가 실제 사용되고 unused import 없음). +- chunk struct 의 `tokenized_korean_text` field 존재 (`grep -n "tokenized_korean_text" crates/kebab-chunk/src/lib.rs` → `≥ 1`). +- `cargo clippy -p kebab-chunk --all-targets -j 4 -- -D warnings` → clean. + +--- + +### Step 4 — `App::open_with_config` 의 first-boot eager backfill hook + +**Implements:** §8.2 eager backfill. **Commit 4/10.** + +**Spec sections covered:** §8.1 V007 trigram index 처리, §8.2 자동 eager backfill, §6.2 backfill atomic transaction. + +**Files to modify:** +- `crates/kebab-store-sqlite/src/store.rs` — `backfill_tokenized_korean_text(progress_cb)` API 신규. +- `crates/kebab-app/src/lib.rs` — `App::open_with_config` 에 first-boot hook 추가 + idempotency guard. + +**Implementation outline:** + +- [ ] **4.1 backfill API signature** — `store.rs` 에 다음 추가: + + ```rust + /// 모든 chunks 의 tokenized_korean_text 가 NULL 인 row 를 찾아 + /// lindera tokenize → UPDATE. 각 row 의 UPDATE 는 chunks_au trigger 를 fire + /// 시켜 chunks_fts 가 재-index 됨. 모든 작업은 동일 transaction 안. + /// 결과: 처리된 row 수. + pub fn backfill_tokenized_korean_text(&self, progress: F) -> anyhow::Result + where F: Fn(u64, u64); + ``` + + body: (a) `SELECT chunk_id, text FROM chunks WHERE tokenized_korean_text IS NULL` 로 후보 row 수집. (b) 각 row 에 대해 `kebab_chunk::tokenize_korean_morphological(text)` 호출 → 결과를 `UPDATE chunks SET tokenized_korean_text = ? WHERE chunk_id = ?`. (c) BEGIN/COMMIT batch (예: 1000 row 마다 commit) 으로 단일 거대 transaction 회피 + crash recovery 의 partial progress 보장. (d) `progress(done, total)` 콜백 발화. (e) lindera 가 None 반환 시 `UPDATE ... SET tokenized_korean_text = ''` (빈 문자열) 대신 row 를 skip — chunks_au trigger 가 ELSE branch 를 타게 둔다. + +- [ ] **4.2 idempotency 보장** — backfill 은 `IS NULL` 필터로 partial completion 후 재실행 시에도 idempotent. 한 번 채워진 row 는 다시 처리되지 않음. lindera 가 None 을 반환한 row 는 영구히 NULL 로 남으나, dictionary update 후 재실행 시 다시 후보가 되도록 별 marker column 은 추가하지 않음 — spec §8.2 의 "부분 완료 상태 search 동작" 보존. +- [ ] **4.3 App::open_with_config 의 hook 위치** — `crates/kebab-app/src/lib.rs` 의 `App::open_with_config` 본체에서 migration apply 직후 (즉 store 가 V009 schema 를 보장한 직후) 다음 호출 추가: + + ```rust + let backfill_count = app.sqlite + .backfill_tokenized_korean_text(|done, total| { + if total > 0 && done % 500 == 0 { + tracing::info!(target: "kebab-app", + "korean tokenizer backfill: {done}/{total}"); + } + }) + .unwrap_or_else(|e| { + tracing::warn!(target: "kebab-app", + "korean tokenizer backfill failed: {e}"); + 0 + }); + if backfill_count > 0 { + tracing::info!(target: "kebab-app", + "korean tokenizer backfill complete: {backfill_count} chunks updated"); + } + ``` + + backfill 실패 (lindera dict load 등) 는 fatal 아님 — App open 자체는 성공해 사용자가 vector/hybrid mode 로 계속 사용 가능. +- [ ] **4.4 startup latency 의 사용자 인지** — KB 가 큰 경우 첫 부팅이 spec §8.2 "약 10,000 chunk 당 ~30-60초" 동안 visible delay. CLI 사용자는 `kebab` 첫 호출이 늦어지는 것을 본다. stderr 로 progress info 발화 (위 callback 의 `tracing::info!`) — 사용자가 hang 으로 오인 안 하도록. +- [ ] **4.5 backfill 단위 테스트** — `crates/kebab-store-sqlite/tests/fts.rs` 에 추가: + + ```rust + #[test] + fn backfill_tokenized_korean_text_populates_nullable_rows() { + // TempDir KB 열기, V009 적용된 schema 상태. + // chunks 에 ('한국 문화', NULL) row 두 개 INSERT. + // backfill_tokenized_korean_text(noop) 호출 → 반환값 == 2. + // 두 row 의 tokenized_korean_text IS NOT NULL. + // 두번째 호출 → 0 반환 (idempotent). + } + ``` + +- [ ] **4.6 Verify.** `cargo test -p kebab-store-sqlite --test fts backfill_tokenized_korean_text_populates_nullable_rows -j 4` → exit 0. `cargo build -p kebab-app -j 4` → success. + +**Acceptance criteria:** +- `cargo test -p kebab-store-sqlite --test fts backfill_tokenized_korean_text_populates_nullable_rows -j 4` → exit 0, idempotency 확인. +- `App::open_with_config` 두 번 연속 호출 시 두번째 호출의 backfill_count = 0 (idempotency 의 production 측면). `backfill_tokenized_korean_text_populates_nullable_rows` test 의 두 번째 `backfill_tokenized_korean_text` 호출 assert (반환값 == 0) 이 이 AC 를 cover 함. `cargo test -p kebab-store-sqlite --test fts backfill_tokenized_korean_text_populates_nullable_rows -j 4` → exit 0 으로 검증. +- `cargo clippy -p kebab-app --all-targets -j 4 -- -D warnings` → clean. + +--- + +### Step 5 — `short_query_hint()` 제거 + lexical.rs build_match_string 정리 + +**Implements:** §7.2, §7.3. **Commit 5/10.** + +**Spec sections covered:** §7.2 lexical.rs 조정, §7.3 CLI hint 제거. + +**Files to modify:** +- `crates/kebab-app/src/app.rs` — `short_query_hint()` 함수 정의 + 2 호출 site (line 532, 616) 제거. +- `crates/kebab-app/src/lib.rs` — re-export `pub use app::{..., short_query_hint};` 의 `short_query_hint` 제거. +- `crates/kebab-tui/src/app.rs` (161, 188), `crates/kebab-tui/src/search.rs` (443, 619, 703, 711), `crates/kebab-tui/src/run.rs` (394) — `short_query_hint` field + 호출 cascade 제거. +- `crates/kebab-cli/tests/wire_search_response.rs:215` — `search_plain_emits_short_query_hint_to_stderr` test 삭제. +- `crates/kebab-search/src/lexical.rs::build_match_string()` — trigram-specific 분기 단순화 검토. + +**Implementation outline:** + +- [ ] **5.1 short_query_hint 정의 + 호출 제거** — `crates/kebab-app/src/app.rs:98` 의 `pub fn short_query_hint(query_text: &str, hits_empty: bool) -> Option` 함수 본체 삭제. line 532, 616 의 두 호출 site (search response 의 hint 채우는 부분) 도 함께 제거 — 해당 SearchResponse field 가 항상 None 으로 셋팅되거나, SearchResponse 의 hint field 자체를 제거 (wire schema 영향 검토 필요). +- [ ] **5.2 wire schema 영향 확인** — `SearchResponse` struct 의 `short_query_hint` 또는 `hint` field 가 `search_hit.v1` / `search_response.v1` JSON Schema 에 포함되어 있는지 `docs/wire-schema/v1/*.schema.json` grep. (a) 포함되어 있다면 wire 의 additive minor 가 아닌 removal 이므로 별도 결정 — spec §11.3 의 "wire schema shape 변경 없음" 과 모순. → 그 경우 field 는 struct 에 남기고 항상 None / 생략 (Option/Default) 으로 유지. (b) 포함되어 있지 않으면 struct field 도 함께 제거. +- [ ] **5.3 lib.rs re-export 제거** — `crates/kebab-app/src/lib.rs:75` 의 `pub use app::{App, SearchResponse, short_query_hint};` 에서 `short_query_hint` 부분 제거. +- [ ] **5.4 TUI cascade 제거** — `crates/kebab-tui/src/app.rs:161,188` 의 `pub short_query_hint: Option` field + default init 삭제. `crates/kebab-tui/src/search.rs:443,619,703,711` 의 4 곳에서 `s.short_query_hint = None` / `s.short_query_hint = kebab_app::short_query_hint(...)` 라인 모두 삭제. `crates/kebab-tui/src/run.rs:394` 의 `.and_then(|s| s.short_query_hint.as_deref())` 도 제거 — 그 자리의 UI 표현은 빈 상태 또는 다른 hint 로 대체. +- [ ] **5.5 CLI test 정리** — `crates/kebab-cli/tests/wire_search_response.rs:215` 의 `search_plain_emits_short_query_hint_to_stderr` test 전체를 삭제. 또는 test name 을 `search_plain_does_not_emit_short_query_hint_to_stderr` 로 rename 후 assertion 을 negative 로 invert. +- [ ] **5.6 lexical.rs::build_match_string 검토** — `crates/kebab-search/src/lexical.rs:205` 의 함수 본체를 읽어 trigram-specific 처리 (예: 2-char Korean token 의 OR-combine, character-class 분해) 존재 여부 확인. spec §7.2 권장: "backward-compat 차원에서 기존 로직 보존" → 본 step 에서는 **변경 없음** (보존). 단 함수 doc-comment 에 "V009 unicode61 + 형태소 tokenizer 환경에서는 multi-token Korean query 의 OR-combine 분기는 redundant 하나 보존" 한 줄 추가. +- [ ] **5.7 Verify.** `cargo test --workspace -j 1 -- --skip wire_search_response::search_plain_emits 2>&1 | tail -5` 으로 cascade 통과 확인. `cargo build --workspace -j 4` success. + +**Acceptance criteria:** +- `grep -rn "short_query_hint" crates/ tests/ 2>/dev/null | wc -l` → `0` (또는 doc-comment 의 1 줄만 남음). +- `cargo build --workspace -j 4 2>&1 | grep -E "^error" | wc -l` → `0` (cascade 누락으로 인한 컴파일 에러 없음). +- `cargo test -p kebab-cli --test wire_search_response -j 4` → exit 0, `search_plain_emits_short_query_hint_to_stderr` 가 test list 에서 사라짐. +- `cargo clippy --workspace --all-targets -j 4 -- -D warnings` → clean. + +--- + +### Step 6 — `lexical_index_version()` 의 V009 bump + +**Implements:** §11.1. **Commit 6/10.** + +**Spec sections covered:** §11.1 index_version bump, §11.3 wire content 변화 명시. + +**Files to modify:** +- `crates/kebab-app/src/app.rs:991-993` — `lexical_index_version()` 함수의 반환 문자열 갱신. + +**Implementation outline:** + +- [ ] **6.1 현재 source-of-truth 확인** — line 991-993 의 `fn lexical_index_version` 는 `IndexVersion(format!("lex:{}", config.chunking.chunker_version))` 반환. fts version 정보가 없음. spec §11.1 의 권장 문자열 `"fts5-v009-korean-morphological"` 또는 `"v009-morpho"` 를 채택하기 위해 format 변경 필요. +- [ ] **6.2 신규 format** — 변경: + + ```rust + fn lexical_index_version(config: &kebab_config::Config) -> IndexVersion { + IndexVersion(format!( + "lex:{}:fts5-v009-korean-morphological", + config.chunking.chunker_version + )) + } + ``` + + 기존 5 호출 site (app.rs line 367, 389, 479, 661, 680) 는 함수 호출만 하므로 자동 cascade. test `lexical_index_version_is_returned_unchanged` (`crates/kebab-search/tests/lexical.rs:650`) 의 assertion 갱신 필요. +- [ ] **6.3 lexical test 갱신** — `crates/kebab-search/tests/lexical.rs:650` 의 `lexical_index_version_is_returned_unchanged` test 가 hard-coded 한 expected 값을 V009 신규 format 으로 갱신. 또는 test 의 의도가 "format 자체의 invariant 보장" 이라면 substring match (`actual.0.contains("fts5-v009")`) 로 변경. +- [ ] **6.4 Verify.** `cargo test -p kebab-search --test lexical lexical_index_version_is_returned_unchanged -j 4` → exit 0. `cargo test -p kebab-app -j 4` → 회귀 0. + +**Acceptance criteria:** +- `cargo test -p kebab-search --test lexical lexical_index_version_is_returned_unchanged -j 4` → exit 0. +- `cargo build -p kebab-cli -j 4 && ./target/debug/kebab schema --json | jq -r '.index_versions.lexical // empty' 2>/dev/null | grep -c "fts5-v009"` → `≥ 1`. +- `cargo clippy -p kebab-app --all-targets -j 4 -- -D warnings` → clean. + +--- + +### Step 7 — 신규 unit/integration test (Korean morphological FTS scenarios) + +**Implements:** §9.1 AC, §9.2 test coverage. **Commit 7/10.** + +**Spec sections covered:** §9.1 lexical-mode scenarios, §9.2 test coverage. + +**Files to modify:** +- `crates/kebab-store-sqlite/tests/fts.rs` — `fts_v009_korean_morphological_2char_query_hits` + `fts_v009_english_whole_token_only` 추가. +- Create `crates/kebab-app/tests/search_korean.rs` — `korean_morphological_2char_query_lexical_mode` + `korean_morphological_mixed_english_korean_query`. + +**Implementation outline:** + +- [ ] **7.1 fts.rs 의 신규 단위 test** — spec §9.2 의 verbatim 시그니처 따라: + + ```rust + #[test] + fn fts_v009_korean_morphological_2char_query_hits() { + // 1. TempDir KB 열기 + V009 schema 보장. + // 2. chunks 에 ('한국 문화는 오래되었다', tokenize_korean_morphological(...)) row INSERT. + // triggers chunks_ai 가 chunks_fts 에 자동 index. + // 3. SELECT chunk_id FROM chunks_fts WHERE chunks_fts MATCH '한국' + // → 결과 row count >= 1. + // 4. 동일 방식으로 '문화' query → row count >= 1. + } + + #[test] + fn fts_v009_english_whole_token_only() { + // Path A 회귀 확인: V007 trigram substring 매칭 사라짐. + // 1. ('the tokenizer normalizes whitespace', None) row INSERT. + // 2. MATCH 'token' → 0 row (substring of 'tokenizer' is NOT matched by unicode61). + // 3. MATCH 'tokenizer' → >= 1 row. + } + ``` + +- [ ] **7.2 search_korean.rs end-to-end test** — `crates/kebab-app/tests/search_korean.rs` 신규: + + ```rust + #[test] + fn korean_morphological_2char_query_lexical_mode() { + // 1. TempDir config, App::open_with_config. + // 2. ingest_with_config_opts 로 fixture 한국어 markdown 파일 ingest. + // (fixture = "한국어를 공부합니다.\n서울은 한국의 수도입니다.") + // 3. App::search 의 SearchQuery { text: "한국", mode: Lexical, ... }. + // → hits.len() >= 1. + // 4. '서울' query → hits.len() >= 1. + } + + #[test] + fn korean_morphological_mixed_english_korean_query() { + // 1. fixture = "Rust 최적화는 zero-cost abstraction 을 강조한다." + // 2. 'Rust' query → hit, '최적화' query → hit. + // 3. 'Rust 최적화' multi-token query → hit (build_match_string 의 OR-combine). + } + ``` + + fixture 가 lindera ko-dic 의 실제 segmentation 동작 의존이므로 spec Appendix B 의 prior-knowledge 예측이 실패할 경우 fixture text 와 query 를 조정 (한국어 corpus 의 representative case). + +- [ ] **7.3 spec §9.2 `fts_v009_matches_design_section_5_5_verbatim` 와의 관계** — 이 test 는 이미 S1 의 rename 으로 생성됨. 본 step 에서 추가하지 않음. +- [ ] **7.4 Verify.** `cargo test -p kebab-store-sqlite --test fts fts_v009_korean -j 4` → 2 test exit 0. `cargo test -p kebab-app --test search_korean -j 4` → 2 test exit 0. + +**Acceptance criteria:** +- `cargo test -p kebab-store-sqlite --test fts fts_v009_korean_morphological_2char_query_hits -j 4` → exit 0, fixture chunk `"한국 문화는 오래되었다"` 가 query `"한국"` 의 hit list 에 존재. +- `cargo test -p kebab-store-sqlite --test fts fts_v009_english_whole_token_only -j 4` → exit 0, fixture `"the tokenizer..."` 가 query `"token"` 에서 0-hit + query `"tokenizer"` 에서 hit. +- `cargo test -p kebab-app --test search_korean korean_morphological_2char_query_lexical_mode -j 4` → exit 0. +- `cargo test -p kebab-app --test search_korean korean_morphological_mixed_english_korean_query -j 4` → exit 0. +- 신규 test binary 2개 (`fts` 확장 + `search_korean` 신규) 의 추가로 workspace test count 가 baseline +4 이상. 검증 명령: + - `cargo test -p kebab-store-sqlite --test fts -- --list 2>&1 | grep "fts_v009_korean" | wc -l` → `≥ 1` + - `cargo test -p kebab-app --test search_korean -- --list 2>&1 | wc -l` → `≥ 2` + +--- + +### Step 8 — eval golden baseline regenerate + +**Implements:** §11.3 wire content 변화 (eval golden 재생성 책임). **Commit 8/10.** + +**Spec sections covered:** §11.3 eval golden baseline regenerate. + +**Files to modify:** +- `crates/kebab-eval/goldens/*.csv` (또는 동급 fixture) — V007 기준 expected rank/hit_id 시퀀스를 V009 기준 으로 재생성. +- `crates/kebab-eval/tests/*` — 회귀 0. + +**Implementation outline:** + +- [ ] **8.1 baseline regenerate 절차 확인** — `crates/kebab-eval/` 의 README 또는 doc-comment 에 명시된 baseline regenerate 명령 (e.g. `cargo run -p kebab-eval -- regenerate-goldens --kb /tmp/eval-kb`) 또는 동급 운영 절차 실행. 정확한 명령은 crate 의 main.rs / lib.rs 의 subcommand 정의 확인 후 결정. +- [ ] **8.2 V009 적용된 KB 로 baseline 재계산** — spec §12.2 dogfood verification 의 fresh KB 사용 가능 (또는 `tasks/HOTFIXES.md` 의 dogfood corpus 경로 재사용). 재생성된 CSV 의 hit_id / rank 시퀀스가 V007 baseline 과 다름은 의도된 변화 — git diff 가 보여주는 변경량 검토 후 commit. +- [ ] **8.3 commit 시점 분리** — baseline regenerate 는 별 commit (S8) 이며, executor / dogfood 단계에서 의미 있는 deviation 발견 시 다시 regenerate 가능. PR scope 내부 commit. +- [ ] **8.4 Verify.** `cargo test -p kebab-eval --workspace -j 4 2>&1 | grep -E "^test result" | head -3` → 모두 `0 failed`. + +**Acceptance criteria:** +- `cargo test -p kebab-eval -j 4 2>&1 | grep "test result.*failed" | grep -v "0 failed" | wc -l` → `0`. +- `git diff --stat crates/kebab-eval/goldens/` → 변경 라인 수 > 0 (baseline 이 실제로 갱신됨). +- `cargo clippy -p kebab-eval --all-targets -j 4 -- -D warnings` → clean. + +**Note:** spec §11.3 + plan brief 의 "spec §11 의 결정 따라 본 plan scope 에 포함 또는 별 follow-up" 중 본 plan 은 **scope 포함** 선택 — 단 deviation (regenerate 실패, baseline 차이 너무 큼) 시 별 P5 follow-up 으로 후퇴 가능. 후퇴 시 본 S8 은 verify-only 로 단순화하고 `tasks/HOTFIXES.md` 에 한 줄 deviation 기록. + +--- + +### Step 9 — docs sync (README + HANDOFF + ARCHITECTURE + SKILL + HOTFIXES) + +**Implements:** §7.4 surface cascade. **Commit 9/10.** + +**Spec sections covered:** §7.4 README/SKILL/HANDOFF/ARCHITECTURE 갱신. + +**Files to modify:** +- `README.md` — 명령 table `kebab search` 행 갱신 + Configuration section 보조 정보. +- `integrations/claude-code/kebab/SKILL.md` — V007 3-char hint 제거 + 2-char 한국어 query 지원 표현 추가 + 영어 substring 회귀 (optional advanced note). +- `HANDOFF.md` — v0.20.1 patch release 의 G section 또는 round-up entry 에 본 변경 한 줄 추가. +- `docs/ARCHITECTURE.md` — crate dependency graph 의 lindera 추가, FTS tokenizer 섹션 V007 trigram → V009 unicode61 + 형태소 분해. +- `tasks/HOTFIXES.md` — V009 cascade 의 dated entry + cargo deny 도입 deferral note. + +**Implementation outline:** + +- [ ] **9.1 README.md 변경** — CLAUDE.md "Docs split" rule 따라 narrow scope. (a) `kebab search` 명령 row 의 description 에 "한국어 2자 query 지원 (예: '한국', '서울')" 추가. (b) Configuration section 의 변경은 없음 (config 노브 없음, S6.3 Option A). (c) Mermaid logical-architecture diagram 의 변경 없음 (FTS5 는 내부 store 측 detail). +- [ ] **9.2 SKILL.md 변경** — `integrations/claude-code/kebab/SKILL.md` 의 description / 사용법 섹션에서 "한국어 query 는 3자 이상 권장" 류 표현 검색 + 제거. 신규: "한국어 2자 단어 검색 지원 (예: '한국', '서울')". 영어 substring 매칭 회귀는 일반 사용자에게는 노이즈 → optional advanced note 로 짧게 (1줄) 만 추가. 또는 release notes 만 다루고 SKILL.md 는 user-facing happy path 로 한정 (권장). +- [ ] **9.3 HANDOFF.md 변경** — v0.20.0 sub-item 1 머지 후 priorities section 의 C 항목 status flip ('지연' / '미해결' → '완료'). v0.20.1 patch release section 의 release notes scope 에 본 변경 추가. 머지 후 발견된 버그 / 결정 summary 행 한 줄 추가 ("Bug #8 한국어 2자 query → v0.20.1 의 V009 morphological tokenizer 로 해소"). +- [ ] **9.4 docs/ARCHITECTURE.md 변경** — (a) crate dependency graph 에 `kebab-chunk → lindera, lindera-dict-ko-dic` edge 추가. (b) FTS tokenizer 섹션이 있다면 V007 trigram → V009 unicode61 + 형태소 분해로 갱신 + locked-in 결정 table 의 row 갱신. (c) directory tree 변경 없음 (신규 crate 없음). +- [ ] **9.5 tasks/HOTFIXES.md entry** — 2026-05-28 dated entry 추가: + + > ### 2026-05-28 — Bug #8 한국어 2자 query 해소 (V009 morphological tokenizer) + > + > - **Discovered**: 도그푸딩 round 3/4 (2026-05-28). '한국' / '서울' 0-hit 반복. + > - **Symptom**: V007 trigram tokenizer 의 ≥3-char minimum 한계. + > - **Root cause**: trigram 의 bucket 미존재. + > - **Fix**: V009 migration + lindera ko-dic + tokenized_korean_text column + first-boot eager backfill. branch `feat/korean-morphological-tokenizer`. + > - **Amends**: design §5.5 (unicode61 + CASE expression triggers 로 갱신), §9 (index_version cascade), tasks/HOTFIXES.md 2026-05-22 trigram entry (한국어 2자 query 미해결 footnote 해소). + > - **Deferred**: `cargo-deny` 정식 도입 (workspace 의 deny.toml) 은 별 P9 follow-up 으로 분리. 현 PR 은 `cargo tree` 의 SPDX 수동 검증 + lindera/ko-dic license 의 fail-fast 정책 적용. + +- [ ] **9.6 Verify.** 4 docs file 의 변경 라인 합 > 30 (충분한 cascade). 한 줄짜리 cosmetic 만 들어간 케이스 회피. + +**Acceptance criteria:** +- `git diff --stat README.md HANDOFF.md docs/ARCHITECTURE.md integrations/claude-code/kebab/SKILL.md tasks/HOTFIXES.md` → 5 file 모두 변경 (각 file 의 변경 라인 ≥ 1). +- `grep -c "한국어 2자" README.md` → `≥ 1`. +- `grep -c "V009" tasks/HOTFIXES.md` → `≥ 1`. +- `grep -c "lindera" docs/ARCHITECTURE.md` → `≥ 1`. + +--- + +### Step 10 — version bump (Cargo.toml workspace `version`) + +**Implements:** §12.1 v0.20.1 patch release. **Commit 10/10.** + +**Spec sections covered:** §12.1 release strategy. + +**Files to modify:** +- `Cargo.toml` (workspace) — `[workspace.package] version = "0.20.0"` → `"0.20.1"`. +- `Cargo.lock` — 자동 갱신. + +**Implementation outline:** + +- [ ] **10.1 version bump** — CLAUDE.md `Release / binary version bump` rule 따라 본 변경은 "user 가 새 바이너리로 도그푸딩 또는 실사용을 할 필요" + "frozen design contract 변경 (design §5.5 갱신)" 두 트리거 모두 해당 → bump 필수. `Cargo.toml` workspace `version` 을 `"0.20.0"` → `"0.20.1"`. +- [ ] **10.2 Cargo.lock 갱신** — `cargo update --workspace --offline 2>&1 | tail -3` 또는 단순 `cargo build --workspace -j 4` 가 Cargo.lock 의 version 부분 자동 갱신. +- [ ] **10.3 commit 직후 tag 절차 (별 task)** — bump commit 자체는 본 step 의 commit, 이후 `gitea-release v0.20.1` 명령은 별 task 로 분리 (executor 의 sequential N step 후 user 가 직접 cut). spec §12.1 의 release notes 본문 (한국어 2자 query 지원, FTS5 tokenizer 변경 회귀, 자동 backfill, ingest 성능 감소) 4 단락을 release notes draft 로 미리 작성하여 `docs/release-notes/v0.20.1-draft.md` 에 보관 (선택, plan 의 brief 의 verifier checklist). +- [ ] **10.4 Verify.** `grep -c "^version = \"0.20.1\"" Cargo.toml` → `1`. `cargo build --workspace -j 4` → success + binary `--version` 출력이 `0.20.1`. + +**Acceptance criteria:** +- `grep "^version" Cargo.toml | head -1` → `version = "0.20.1"`. +- `cargo build --release -p kebab-cli -j 4 && ./target/release/kebab --version 2>&1` → `kebab 0.20.1` (또는 동급 출력). +- `cargo build --workspace -j 4 2>&1 | tail -3` → success. + +--- + +### Step 11 — final sanity (no commit) + +**Implements:** AC §9.3 verifier checklist + plan brief §7. **No commit.** + +**Spec sections covered:** §9.3 verifier checklist, §12.2 dogfood verification. + +**Implementation outline:** + +- [ ] **11.1 Workspace test.** `cargo test --workspace --no-fail-fast -j 1 > /tmp/wstest.out 2>&1` → 모두 pass (baseline 1370+ → 신규 +4 이상). Expected: `0 failed`. +- [ ] **11.2 Clippy.** `cargo clippy --workspace --all-targets -j 4 -- -D warnings` → 0 warning. +- [ ] **11.3 cargo fmt.** `cargo fmt --all --check` → exit 0 (또는 fmt 적용). +- [ ] **11.4 Schema 무결성.** `./target/release/kebab schema --json | jq -e '.wire.schemas | length' 2>&1` → 기존과 동일 (wire schema 추가 0). +- [ ] **11.5 Dogfood smoke.** TempDir KB 의 fresh KB 시나리오: + + ```bash + rm -rf /tmp/kebab-smoke-v009 + ./target/release/kebab --config /tmp/kebab-smoke-v009/config.toml ingest <한국어 fixture> + ./target/release/kebab --config /tmp/kebab-smoke-v009/config.toml search '한국' --json | jq '.hits | length' + # → ≥ 1 + ./target/release/kebab --config /tmp/kebab-smoke-v009/config.toml search '서울' --json | jq '.hits | length' + # → ≥ 1 + ./target/release/kebab --config /tmp/kebab-smoke-v009/config.toml search '지하철' --json | jq '.hits | length' + # → ≥ 1 + ``` + +- [ ] **11.6 기존 KB eager backfill 시나리오.** 별도 디렉토리에 V007 (pre-V009) 시점 SQLite snapshot 보존 사용 가능 시: + + ```bash + cp -r /tmp/kebab-v007-snapshot /tmp/kebab-v007-test + ./target/release/kebab --config /tmp/kebab-v007-test/config.toml schema --json 2>&1 | grep -i "backfill" + # eager backfill info log 가 stderr 에 발화 확인. + ./target/release/kebab --config /tmp/kebab-v007-test/config.toml search '한국' --json | jq '.hits | length' + # backfill 완료 후 → ≥ 1 + ``` + + V007 snapshot 부재 시 본 verifier 는 best-effort (manual). dogfood corpus 의 재구성 비용을 고려해 spec §12.2 의 권장 절차 따른다. + +- [ ] **11.7 git status 확인.** `git status --short` → 모든 의도된 변경이 stage 됨 + untracked file 없음 (또는 의도된 untracked 만 — 예: release-notes draft). + +**Acceptance criteria (final checklist):** +- [ ] `kebab search '한국'` (fresh V009 KB) → hit ≥ 1. +- [ ] V009 migration apply 후 기존 V007 KB → eager backfill 자동 시작 → 완료 후 `kebab search '한국'` → hit ≥ 1. +- [ ] `cargo test --workspace --no-fail-fast -j 1` → 모두 pass (baseline +4 이상). +- [ ] `cargo clippy --workspace --all-targets -j 4 -- -D warnings` → clean. +- [ ] `cargo fmt --all --check` → exit 0. +- [ ] (deferred) `cargo deny check` → S2.5 에서 P9 follow-up 으로 분리. 본 PR 의 license 검증은 `cargo tree` 의 SPDX 수동 비교로 대체. +- [ ] README.md / SKILL.md / HANDOFF.md / docs/ARCHITECTURE.md / tasks/HOTFIXES.md 모두 갱신됨. +- [ ] release notes draft (`docs/release-notes/v0.20.1-draft.md`) 작성, 4 단락 본문 (2자 query 지원 / tokenizer 변경 + 영어 회귀 / 자동 backfill / ingest 성능). + +--- + +## 3. Dependencies + sequencing + +각 step 의 의존성: + +| Step | Title | Blocked by | Blocks | 병렬 가능? | +|------|-------|------------|--------|------------| +| S1 | V009 migration + design §5.5 + CI rename | (none) | S3, S4, S6, S7 | (entry) | +| S2 | lindera dep | (none) | S3 | (entry, S1 과 병렬) | +| S3 | tokenize_korean_morphological + ingest 통합 | S1, S2 | S4, S5, S7 | — | +| S4 | first-boot eager backfill | S1, S3 | S7, S11 | S5/S6 와 병렬 가능 | +| S5 | short_query_hint 제거 + lexical.rs 정리 | (none, S3 면 깔끔) | S7 | S4/S6 와 병렬 가능 | +| S6 | lexical_index_version bump | S1 | S7 | S4/S5 와 병렬 가능 | +| S7 | 신규 test (FTS + search_korean) | S3, S4, S6 | S8, S11 | — | +| S8 | eval golden regenerate | S7 + dogfood KB | S11 | — | +| S9 | docs sync | S1~S8 (의미적) | S11 | — | +| S10 | version bump | S9 | S11 | — | +| S11 | final sanity | S1~S10 | (exit) | — | + +**Parallel dispatch 가능 그룹:** + +- **Group 1 (entry, parallel):** S1 + S2. 두 step 은 독립. dispatcher 가 sonnet 두 instance 로 병렬 실행 가능. +- **Group 2 (S3 후속, parallel):** S4 + S5 + S6. S3 가 끝난 직후 3 instance 동시 실행. S4 (backfill API + hook) + S5 (hint 제거 + lexical.rs) + S6 (index_version bump) 가 file overlap 0 (서로 다른 module). **주의:** S5 와 S6 은 모두 `crates/kebab-app/src/app.rs` 를 수정함. 병렬 에이전트 실행 시 편집 행 범위를 사전 분리할 것 (S5: line 98, 532, 616; S6: line 991-993). +- **Group 3 (sequential):** S7 → S8. test 가 먼저 들어가야 eval baseline 갱신 후 회귀 0 검증 가능. +- **Group 4 (sequential):** S9 → S10. docs sync 후 version bump. + +**총 시퀀스 (worst-case sequential, sub-agent 1 instance):** S1 → S2 → S3 → S4 → S5 → S6 → S7 → S8 → S9 → S10 → S11. 10 commit + 1 verify. + +**총 시퀀스 (parallel, sub-agent 3 instance):** {S1, S2} 병렬 → S3 → {S4, S5, S6} 병렬 → S7 → S8 → S9 → S10 → S11. wall-clock 단축 약 30%. + +--- + +## 4. AC verifier 의 actionability + +본 plan 의 모든 AC 는 mechanical 검증 가능하도록 작성됨. spec §9.3 의 verifier checklist 도 모두 actionable 한 명령 또는 grep 단위: + +| AC type | Verifier 명령 형식 | +|--|--| +| Schema diff-check | `cargo test ...verbatim -j 4` → exit 0 | +| Hit/miss assertion | `cargo test ...hits -j 4` → exit 0 + grep on output | +| Surface 갱신 cascade | `grep -c "" ` → ≥ 1 | +| Test count growth | `cargo test ... 2>&1 \| grep "test result" \| awk` | +| Binary version | `./target/release/kebab --version` | +| dogfood smoke | `kebab search '한국' --json \| jq '.hits \| length'` → ≥ 1 | + +각 step 의 AC 는 별 reviewer subagent (sonnet) 가 mechanical 통과 여부를 판단 가능. 해석 여지 없는 fact statement. + +--- + +## 5. Risk + open questions + +### S1 (V009 migration) + +- **Risk:** design §5.5 갱신과 V009 SQL block 의 whitespace 미세 차이로 verbatim test fail. +- **Mitigation:** S1.3 의 갱신은 V009 SQL 의 (c) 블록을 design §5.5 에 그대로 paste. fts.rs:407 의 `normalize_ws` 함수가 모든 whitespace 차이 흡수. + +### S2 (lindera dep) + +- **Risk:** `lindera-dict-ko-dic` 의 dict 다운로드가 build-time 에 실패 (network egress 차단 환경). 또는 정확한 feature flag name 이 spec 의 가정과 다름. +- **Mitigation:** S2.3 의 cargo build 출력 정독 후 dict embed feature name 확정. 실패 시 `build.rs` proactive download 또는 vendored crate 로 후퇴 (별 follow-up). +- **Risk:** lindera version pin 의 transitive dep 가 기존 workspace 와 충돌 (예: `unicode-normalization`, `serde` major version 의 lock 갈등). +- **Mitigation:** `cargo update` 후 `cargo tree --duplicates` 로 회귀 확인. + +### S3 (tokenize_korean_morphological) + +- **Risk:** lindera ko-dic 의 `'한국어'` segmentation 이 단일 token (`['한국어']`) 으로 나오면 `'한국'` query 0-hit (spec critic R1 finding #3). 본 plan 의 S3.5 test `tokenize_korean_morphological_splits_2char_word` 이 fixture `"한국 문화는 오래되었다"` 에서 hit 한다는 prior-knowledge 가정. +- **Mitigation:** test 실패 시 (a) fixture 를 ko-dic 의 실제 분해 결과에 일치하는 corpus 로 교체 (예: `'한국 문화는 오래되었다'` 가 명시적으로 공백 포함하므로 두 token `['한국', '문화는']` 보장). (b) AC §9.1 의 hit 보장 범위를 spec Appendix B 의 "고유명사 미등록 또는 형태소 경계 일치 시" 로 명시적 좁힘. (c) sub-morpheme 추가 분해 (n-gram supplement) 는 별 follow-up. + +### S4 (first-boot eager backfill) + +- **Risk:** 거대 KB (예: 100,000 chunk) 의 첫 부팅이 ~5-10 분 hang. 사용자가 hang 으로 오인 + Ctrl-C → partial backfill state. +- **Mitigation:** S4.3 의 `tracing::info!` progress log 가 stderr 에 발화. partial 은 idempotent (re-run 시 이어서 처리). 거대 KB 의 사용자 경험은 별 P5 follow-up (background job + streaming feedback) 으로 미룬다. +- **Risk:** `chunks` 테이블에 row 가 매우 많을 때 단일 transaction commit 이 long-lock → 다른 reader 가 block. +- **Mitigation:** S4.1 body 의 "1000 row 마다 commit" batch 패턴. SQLite WAL mode 와 호환. + +### S5 (short_query_hint 제거) + +- **Risk:** wire schema 의 `SearchResponse` 가 `short_query_hint` field 를 포함 → removal 이 wire breaking change. +- **Mitigation:** S5.2 의 wire schema grep 으로 사전 확인. 포함 시 struct field 유지 (항상 None) + wire 의 backward-compat 보존. + +### S6 (lexical_index_version) + +- **Risk:** `crates/kebab-search/tests/lexical.rs:650` 외에도 hard-coded expected 가 다른 test 에 존재. +- **Mitigation:** `grep -rn "lex:" crates/ tests/ --include="*.rs"` 로 사전 확인 + cascade. + +### S7 (신규 test) + +- **Risk:** end-to-end `search_korean` test 의 fixture markdown 파일이 chunker_version 의존성으로 chunk boundary 가 의도와 다르게 끊겨 한국어 token 이 cross-chunk 분리 → query 의 hit 가 fixture 의 의도와 다름. +- **Mitigation:** fixture text 의 short paragraph 로 단일 chunk 생성 + chunker_version 의 invariant 확인. + +### S8 (eval golden regenerate) + +- **Risk:** baseline diff 가 거대 (모든 row 의 rank 가 shift) → review 시 spec drift 의심 + revert 압박. +- **Mitigation:** S8.3 의 commit 분리 + spec §11.3 의 의도된 변화 cross-link. revert 결정은 user 의 별 dogfood 후. 본 PR scope 에서 fail-fast 가능 — 그 경우 별 P5 follow-up 으로 분리. + +### S9 (docs sync) + +- **Risk:** docs/ARCHITECTURE.md 의 crate dependency graph 가 stale 한 형태 (rebuild 필요). +- **Mitigation:** ARCHITECTURE.md 의 graph 가 Mermaid 형식이라 단순 edge add. cosmetic. + +### S10 (version bump) + +- **Risk:** bump 만 한 commit 이라 spec drift 의심 + 후속 task 의 release notes 미완성. +- **Mitigation:** S10.3 의 release notes draft 작성 (`docs/release-notes/v0.20.1-draft.md`). bump commit 본문에 "spec §12.1 release notes draft 포함" 명시. + +### S11 (final sanity) + +- **Risk:** dogfood smoke 의 fixture 한국어 corpus 부재 → 11.5 verification 의 hit 검증 stub 형식. +- **Mitigation:** spec §12.2 + handoff §4.2 의 dogfood corpus 경로 (`/build/cache/tmp/v0.20-r5-dogfood/`) 재구성 후 사용. 부재 시 manual best-effort. + +### Open questions (executor 에게 위임) + +1. **lindera version pin** — `0.32` 또는 `0.34` (cargo search 시점 latest stable) 중 선택. major breaking change 부재 가정. +2. **`lindera-dict-ko-dic` feature flag name** — `embedded-dict`, `ko-dic`, `compress` 중 정확한 이름은 crate metadata 확인 후 결정. +3. **`SearchResponse` 의 `short_query_hint` field 의 wire schema 포함 여부** — S5.2 에서 grep 확인 후 결정. +4. **`lexical.rs::build_match_string()` 의 trigram-specific 분기 보존 여부** — 본 plan 은 보존 선택 (S5.6). executor 가 코드 가독성 측면에서 단순화 판단 시 별 commit 으로 분리. +5. **eval golden regenerate 의 baseline diff size** — S8 의 sanity bound (예: 변경 라인 < 50% of total) 미정. executor 의 dogfood 후 판단. + +--- + +## 6. Cost optimization (model routing) + +각 step 의 implementer + reviewer 모델 routing 제안 (memory `feedback_teammate_model_routing.md` 따라): + +| Step | Title | Implementer | Reviewer | Rationale | +|------|-------|-------------|----------|-----------| +| S1 | V009 migration + design §5.5 + CI rename | **opus** | sonnet | spec frozen contract (§5.5 verbatim) 의 spirit 보존 필요. trigger CASE expression 의 design 측 갱신은 한 글자 오차로 CI fail. | +| S2 | lindera dep | sonnet | sonnet | dependency 추가만 — mechanical. | +| S3 | tokenize_korean_morphological + ingest 통합 | **opus** | sonnet | lindera API 의 정확한 호출 + ingest pipeline 의 transaction invariant + chunk struct cascade. multi-crate 동시 변경. | +| S4 | first-boot eager backfill | sonnet | sonnet | API design 은 spec 에 명시. batch commit 패턴은 표준. | +| S5 | short_query_hint 제거 + lexical.rs 정리 | sonnet | sonnet | 다중 file cascade 이나 mechanical search + delete. | +| S6 | lexical_index_version bump | sonnet | sonnet | 단일 함수 + test fixture 갱신. | +| S7 | 신규 test | sonnet | sonnet | spec §9.2 의 verbatim 시그니처 따라. fixture text 의 ko-dic 동작 의존이라 dogfood 단계에서 추가 조정 가능. | +| S8 | eval golden regenerate | sonnet | sonnet | baseline regenerate 명령 실행 + commit. | +| S9 | docs sync | sonnet | sonnet | mechanical text edit. README narrow scope. | +| S10 | version bump | sonnet | sonnet | 한 줄 변경. | +| S11 | final sanity | sonnet | sonnet | 검증 명령 실행만. | +| **PR-level final review** | (전체 diff) | — | **opus** | merge 직전 cross-file 일관성 + spec 회귀 미검출 risk. | + +- **opus 권장 step**: S1, S3. spec frozen contract + multi-crate 동시 변경. +- **sonnet 충분 step**: S2, S4~S11. +- **PR-level final review**: opus 로 별 reviewer subagent 호출. + +--- + +## 7. Verifier checklist (final) + +본 plan 의 모든 step 완료 시 final verifier 가 통과시킬 checklist (spec §9.3 의 확장): + +- [ ] `kebab search '한국'` (fresh V009 KB) → hit ≥ 1. +- [ ] `kebab search '서울'` (fresh V009 KB) → hit ≥ 1. +- [ ] `kebab search '지하철'` (fresh V009 KB) → hit ≥ 1. +- [ ] V009 migration apply 후 기존 V007 KB → eager backfill 자동 시작 (stderr info log 발화) → 완료 후 `kebab search '한국'` → hit ≥ 1. +- [ ] `cargo test --workspace --no-fail-fast -j 1` → 모두 pass (baseline +4 이상). +- [ ] `cargo clippy --workspace --all-targets -j 4 -- -D warnings` → clean. +- [ ] `cargo fmt --all --check` → exit 0. +- [ ] (deferred to P9) `cargo deny check` — 본 PR 은 `cargo tree` SPDX 수동 검증으로 대체. P9 follow-up 으로 별 issue. +- [ ] `grep -c "fts5-v009" crates/kebab-app/src/app.rs` → `≥ 1` (lexical_index_version V009 bump). +- [ ] `grep -c "tokenized_korean_text" migrations/V009__fts_korean_morphological.sql` → `≥ 1`. +- [ ] `grep -rn "short_query_hint" crates/ tests/` → 0 production reference (test 또는 doc-comment 1 줄 허용). +- [ ] README.md / SKILL.md / HANDOFF.md / docs/ARCHITECTURE.md / tasks/HOTFIXES.md 모두 갱신됨 (S9 cascade 5 file). +- [ ] `Cargo.toml` workspace `version = "0.20.1"`. +- [ ] release notes draft (`docs/release-notes/v0.20.1-draft.md`) 작성, 4 단락 본문. +- [ ] `cargo build --release -p kebab-cli -j 4 && ./target/release/kebab --config /tmp/kebab-smoke-v009/config.toml search 'token' --json | jq '.hits | length'` → `0` (영어 substring 매칭 회귀 확인, V009 는 whole-token only). +- [ ] `./target/release/kebab --config /tmp/kebab-smoke-v009/config.toml search 'tokenizer' --json | jq '.hits | length'` → `≥ 1` (whole-token 매칭 정상). +- [ ] `./target/release/kebab --config /tmp/kebab-smoke-v009/config.toml search '한국' --mode hybrid --json | jq '.hits | length'` → `≥ 1` (hybrid mode 회귀 확인 — mode flag 실제 이름은 `--mode hybrid` 또는 `--hybrid` 등 확인). + +--- + +## 8. Constraints + +1. **No branch change.** 10 commit 모두 `feat/korean-morphological-tokenizer` branch. +2. **Spec frozen.** ACCEPT spec 의 본문 X edit. deviation → `tasks/HOTFIXES.md`. +3. **Wire schema shape 불변.** spec §11.3 따라 hit ordering / snippet content 만 변화. 신규 wire schema 또는 field add 없음. +4. **Regression budget 0.** baseline workspace test pass 수 (≥ 1370) 유지 + 신규 +4 test 추가. +5. **Worker protocol — subagent skip.** executor 는 nested worker spawn 없이 sequential N step. +6. **Length budget.** 500-800 line plan (본 file ≈ 740 line). +7. **Build path.** `export CARGO_TARGET_DIR=/build/out/cargo-target/target`; `-j 4` default, `-j 1` for workspace sanity. +8. **Commit cadence.** 10 commit (S1~S10). S11 verify-only. +9. **Doc sync.** S9 의 5 file cascade 명시. README narrow scope 보존. +10. **Release trigger.** CLAUDE.md `Release / binary version bump` rule 의 두 트리거 모두 hit (도그푸딩 필요 + design §5.5 변경). v0.20.1 patch release 별 task. +11. **cargo-deny deferred.** Appendix D 의 `cargo deny check` 절차는 본 PR scope 외 (P9 follow-up). license 검증은 `cargo tree` SPDX 수동 + lindera/ko-dic 의 fail-fast 정책으로 대체. + +--- + +## Changelog +- 2026-05-28 closure-r1-mp: plan closure verifier (sonnet) ACCEPT verdict + 9 micro-patches 적용 (MP-1: feature flag, MP-2~MP-7: AC actionability, MP-8: parallel safety, MP-9: hybrid/english regression checks). +- 2026-05-28 post-implementation: 11 step + 10 follow-up commit 모두 머지 — total 17 implementation commit + 4 docs polish + 1 spec/plan archive = 22 commit on `feat/korean-morphological-tokenizer`. S3 spec compliance reviewer 가 2 blocker (get_chunk read path + 9 AST chunker cascade) 발견 → fix commit. S7 implementer 가 `MIT_QUERY_CHARS` 3→2 cascade 필수 발견 (정당한 scope expansion). S11 sanity 단계에서 V007 trigram-specific test 3 + corpus_revision test baseline + chunk snapshot 10 의 의도된 회귀 update. PR-level final review (opus) 의 4 minor docs finding (README english regression, HOTFIXES paths, hint schema, SKILL.md) 정정. +- 2026-05-28 dogfood evidence: reference fixture (korea-overview.md + korea-compound.md, DOGFOOD.md §2.1bis) 의 14 scenario verify pass — '한국' 4 hit, '서울' 2 hit, '지하철' 2 hit, '서울특별시' 1 hit (ko-dic 분해 `[서울, 특별시]` 증거). spec Appendix B 의 prior-knowledge 가정이 실측에서 일치. HOTFIXES 2026-05-28 entry 의 dogfood verification 표 + spec Appendix B 의 Empirical verification subsection 으로 cross-link. + +--- + +## 9. References + +- **Spec contract:** `docs/superpowers/specs/2026-05-28-v0.20.x-korean-morphological-tokenizer-spec.md` (668 line, ACCEPT, frozen). +- **Critic R1:** `docs/superpowers/specs/2026-05-28-v0.20.x-korean-morphological-tokenizer-spec-critic-r1.md` (9 finding — 3 critical + 6 major, 모두 r1c 에서 resolved). +- **Critic R2:** `docs/superpowers/specs/2026-05-28-v0.20.x-korean-morphological-tokenizer-spec-critic-r2.md` (traceability matrix + ACCEPT verdict). +- **Parent design:** `docs/superpowers/specs/2026-04-27-kebab-final-form-design.md` (§5.5 chunks_fts + §9 version cascade). +- **Handoff:** `docs/superpowers/handoffs/2026-05-28-v0.20.x-c-korean-morphological-tokenizer-handoff.md`. +- **V007 trigram migration:** `migrations/V007__fts_trigram.sql` (2026-05-23 v0.17.0). +- **HOTFIXES:** `tasks/HOTFIXES.md` — 2026-05-22 trigram entry + 2026-05-24 build_match_string entry. +- **FTS tests:** `crates/kebab-store-sqlite/tests/fts.rs:407` (V007 verbatim test → rename target). +- **Lexical search:** `crates/kebab-search/src/lexical.rs:205` (build_match_string). +- **CLI hint:** `crates/kebab-app/src/app.rs:98,532,616` + `crates/kebab-tui/src/{app,search,run}.rs` (cascade). +- **lindera:** https://github.com/lindera-morphology/lindera (MIT OR Apache-2.0). +- **lindera-dict-ko-dic:** https://github.com/lindera-morphology/lindera-dictionary (Apache-2.0, MeCab-ko-dic 기반). +- **CLAUDE.md sections:** §The facade rule (App::open_with_config 의 hook 위치), §Versioning cascade (index_version bump + S10 release trigger), §Naming + paths (kebab- prefix), §Spec contract (design §5.5 + plan deviations → HOTFIXES). diff --git a/docs/superpowers/specs/2026-04-27-kebab-final-form-design.md b/docs/superpowers/specs/2026-04-27-kebab-final-form-design.md index 4302716..d912184 100644 --- a/docs/superpowers/specs/2026-04-27-kebab-final-form-design.md +++ b/docs/superpowers/specs/2026-04-27-kebab-final-form-design.md @@ -1062,15 +1062,15 @@ CREATE INDEX idx_blocks_doc_id ON blocks(doc_id); ### 5.5 Chunks + FTS5 -Tokenizer = `trigram` (V007, 2026-05-23). 한국어 어절(조사·어미가 붙은 단위)이 -unicode61 에서 단일 토큰화돼 lexical 부분 매칭이 불가능했던 문제를 해소 -(2자 미만 한국어 query 는 trigram 구조상 여전히 0-hit — 단일 토큰 측면에서는 -회귀 아님, multi-token query 는 `lexical.rs::build_match_string()` 가 whole-phrase -후보 OR 결합으로 매칭). trade-off: 영어 lexical 도 substring 매칭으로 이동 -(recall↑, 단어 경계 정밀도↓), BM25 raw score 분포 변경 (RRF rank 기반 hybrid -는 영향 미미), SQLite 파일 크기 ~2-10× 증가. 자세한 내용 = `tasks/HOTFIXES.md` -(2026-05-22) + `docs/superpowers/specs/2026-05-22-korean-trigram-tokenizer-design.md`. -`chunks_fts` 는 일반 FTS5 shadow table 이며 contentless 가 아님 (V002 / V007 +Tokenizer = `unicode61` (V009, 2026-05-28). V007 trigram 의 한국어 2자 query +0-hit 한계 (Bug #8) 를 해소하기 위해 한국어 형태소 분석 기반 접근법 채택. +`chunks` 테이블에 `tokenized_korean_text TEXT` 컬럼 추가 — ingest 경로가 +lindera ko-dic 형태소 분석 결과(공백 구분 형태소 sequence)를 pre-fill. +chunks_ai/chunks_au trigger 가 `tokenized_korean_text || ' ' || text` 를 +FTS5 에 색인 (CASE expression: NULL 이면 raw text 만). '한국', '서울' 같은 +2자 단어도 형태소 경계 일치 시 hit 가능. 영어 substring 매칭은 V002 수준 +(whole-token only) 으로 회귀 — 자세한 내용 = `tasks/HOTFIXES.md` (2026-05-28). +`chunks_fts` 는 일반 FTS5 shadow table 이며 contentless 가 아님 (V002 / V009 DDL 에 `content=''` 없음). ```sql @@ -1085,7 +1085,8 @@ CREATE TABLE chunks ( chunker_version TEXT NOT NULL, policy_hash TEXT NOT NULL, block_ids_json TEXT NOT NULL, - created_at TEXT NOT NULL + created_at TEXT NOT NULL, + tokenized_korean_text TEXT ); CREATE INDEX idx_chunks_doc_id ON chunks(doc_id); CREATE INDEX idx_chunks_chunker_version ON chunks(chunker_version); @@ -1095,12 +1096,16 @@ CREATE VIRTUAL TABLE chunks_fts USING fts5( doc_id UNINDEXED, heading_path, text, - tokenize = 'trigram' + tokenize = 'unicode61' ); CREATE TRIGGER chunks_ai AFTER INSERT ON chunks BEGIN INSERT INTO chunks_fts(chunk_id, doc_id, heading_path, text) - VALUES (new.chunk_id, new.doc_id, new.heading_path_json, new.text); + VALUES (new.chunk_id, new.doc_id, new.heading_path_json, + CASE WHEN new.tokenized_korean_text IS NOT NULL + THEN new.tokenized_korean_text || ' ' || new.text + ELSE new.text + END); END; CREATE TRIGGER chunks_ad AFTER DELETE ON chunks BEGIN DELETE FROM chunks_fts WHERE chunk_id = old.chunk_id; @@ -1108,7 +1113,11 @@ END; CREATE TRIGGER chunks_au AFTER UPDATE ON chunks BEGIN DELETE FROM chunks_fts WHERE chunk_id = old.chunk_id; INSERT INTO chunks_fts(chunk_id, doc_id, heading_path, text) - VALUES (new.chunk_id, new.doc_id, new.heading_path_json, new.text); + VALUES (new.chunk_id, new.doc_id, new.heading_path_json, + CASE WHEN new.tokenized_korean_text IS NOT NULL + THEN new.tokenized_korean_text || ' ' || new.text + ELSE new.text + END); END; ``` diff --git a/docs/superpowers/specs/2026-05-28-v0.20.x-korean-morphological-tokenizer-spec-critic-r1.md b/docs/superpowers/specs/2026-05-28-v0.20.x-korean-morphological-tokenizer-spec-critic-r1.md new file mode 100644 index 0000000..d6f155e --- /dev/null +++ b/docs/superpowers/specs/2026-05-28-v0.20.x-korean-morphological-tokenizer-spec-critic-r1.md @@ -0,0 +1,504 @@ +# Spec critic round 1 — 한국어 morphological tokenizer + +**Verdict**: NEEDS_REWRITE +**Reviewed by**: critic R1 +**Reviewed at**: 2026-05-28 +**Target spec**: `docs/superpowers/specs/2026-05-28-v0.20.x-korean-morphological-tokenizer-spec.md` + +본 critic round 1 의 결론을 먼저 적자면 **NEEDS_REWRITE** 다. 본 spec 은 +방향 (lindera + 별 column pre-tokenize) 자체는 합리적이나, 다음 세 가지 critical +결함이 동시에 존재해 그대로 implementation 으로 넘기면 silent regression / +contractual 모순 / silent stale 데이터 손해 가 동시에 발생할 위험이 있다: + +1. `tokenize='trigram'` → `'unicode61'` 전환을 "English 변경 없음" 으로 선언한 + §3 + §9.2 와, 실제 SQLite FTS5 의 substring 매칭 동작 사이의 직접 모순. +2. §9.1 AC + §12.1 release notes 의 "기존 KB 자동 backfill (re-ingest 불필요)" + claim 과, §8.2 의 lazy backfill (`tokenized_korean_text = NULL` 유지) 설계 + 사이의 직접 모순. +3. unicode61 의 CJK tokenization 동작 (한 syllable run = 단일 token) 에 대한 + spec 의 전제와, lindera ko-dic 의 segmentation 결과가 일치하지 않을 + 가능성 — §9.1 AC 의 2-char `'한국'` query 가 `'한국어'` chunk 에서 hit + 한다는 보장이 깨질 수 있음. + +이 셋은 모두 design / behavior surface 변경을 요구해 stylistic 수준 fix 로 +해소 불가. 아래 finding 별 상세. + +--- + +## Substantive findings + +### Finding #1: English substring 회귀 — spec 의 self-contradiction (CRITICAL) + +- **Severity**: critical +- **Location**: §3 Non-Goals (line 50), §4.1 표 의 "English 영향: 변경 없음" + (line 66), §6.3 (line 192), §9.2 test `fts_v009_english_substring_retained` + (line 271-275) +- **Issue**: + +V007 (`tokenize='trigram'`) 의 핵심 trade-off 는 "**영어 lexical 도 substring +매칭으로 이동**" 이라고 design §5.5 line 1069-1070 + V007 migration line 13-15 ++ HANDOFF.md line 42 가 모두 명시. 즉 V007 의 `'token'` query 가 +`'tokenizer'` chunk 를 hit 시키는 동작은 trigram 의 결과로 도입된 신 +behavior. + +V009 spec 은 `tokenize='unicode61'` 로 복귀하면서 §3 / §4.1 / §9 모두에서 +"English 변경 없음" 을 claim 하지만 이는 FTS5 의 동작과 정면 모순: + +- `unicode61` 은 whole-token (word) 매칭. `'token'` query 는 `'token'` + token 만 매칭하지 `'tokenizer'` 는 매칭 X. +- 별 `tokenized_korean_text` column 은 한국어 morpheme 분해 결과만 채우므로 + English token 의 substring 매칭은 어떤 column 에서도 복원되지 않음. + +§9.2 의 test 는 이 모순을 그대로 노출: + +```rust +#[test] +fn fts_v009_english_substring_retained() { + // Fixture: "the tokenizer normalizes whitespace" chunk. + // Query: "token" → hit (substring of "tokenizer"). +} +``` + +이 test 는 unicode61 위에서 **logically impossible** — 절대 통과 불가. +test 가 통과한다면 그건 unicode61 이 아니라 다른 tokenizer 가 실제 적용 +중이라는 뜻이고, test 가 fail 하면 §3 의 "변경 없음" claim 이 위약. + +- **Suggested fix**: 두 가지 중 하나로 spec 갱신 필요: + - **Option A** (회귀 인정): §3 Non-Goals 에서 "V007 trigram 의 substring + 매칭 유지" 제거. 새 표현: "English lexical 은 unicode61 의 whole-token + 매칭으로 환원 — V002 (pre-v0.17.0) 와 동일." §4.1 의 "English 영향: 변경 + 없음" → "회귀 (substring → whole-token), V002 동일." Release notes + (§12.1) 에 명시. test 이름 `fts_v009_english_whole_token_only` 로 rename + + assertion 반전 (`'token'` → 0-hit on `'tokenizer'` chunk). + - **Option B** (dual-tokenizer 도입): English 의 substring 매칭을 유지하려면 + chunks_fts 의 `text` column 은 trigram 유지, 별 column (예: `kor_text`) + 에 unicode61 + morpheme-pre-tokenize. 단일 chunks_fts 의 column 별 + tokenizer 가 FTS5 에서 지원 안 되므로 (한 tokenizer per virtual table), + 실질적으로 dual virtual table = Option B (Bigram supplement) 와 같은 + 아키텍처 복잡도 → spec 의 §4.2 권장 근거 자체가 흔들림. + +선택 자체는 architect 의 결정 사항이지만, **현재 spec 의 self-contradiction 은 +NEEDS_REWRITE trigger**. + +--- + +### Finding #2: 기존 KB 의 자동 backfill claim 위약 (CRITICAL) + +- **Severity**: critical +- **Location**: §5.1 backfill INSERT (line 148-154), §8.2 (line 230-233), + §9.1 (line 244) "현재 0 hit → 예상 hit", §9.3 verifier checklist + (line 300), §12.1 release notes (line 365) +- **Issue**: + +§12.1 release notes 의 마지막 항목 `"기존 KB 의 자동 backfill (재-ingest +불필요)"` 와 §8.2 의 다음 두 문장 사이에 직접 모순: + +> 2. `tokenized_korean_text` 는 초기에 NULL (기존 chunks 에 형태소 분해 불가). +> 3. **Lazy backfill**: 향후 ingest 시 기존 chunks 를 `--force-reingest` 로 +> 재처리하면 tokenized_korean_text 채워짐 (선택 사항, user 가 원하면). + +§5.1 의 backfill INSERT block 도 마찬가지로 `CASE WHEN tokenized_korean_text +IS NOT NULL THEN ... ELSE text END` — 기존 chunks 는 항상 ELSE branch 를 +타서 raw `text` 만 chunks_fts 에 들어감. + +Raw `text` 가 `'한국문화는오래되었다'` 같은 한국어 chunk 일 때, unicode61 +은 CJK character run 을 단일 token 으로 봐서 token = `'한국문화는오래되었다'`. +2-char `'한국'` query 의 unicode61 tokenized form = `'한국'` token → FTS5 +는 token 일치만 매칭하므로 0-hit. + +즉 V009 migration 이 적용된 **기존 KB 의 한국어 2-char query 는 여전히 +0-hit**. 사용자가 `kebab ingest --force-reingest` 를 명시적으로 호출하기 +전까지 §9.1 AC 의 "현재 0 hit → 예상 hit" 가 충족 안 됨. v0.17.0 trigram +adoption (V007) 의 "자동 backfill 로 즉시 효과" 사용자 기대 와 정반대. + +- **Suggested fix**: 두 가지 중 하나로 spec 갱신 필요: + - **Option A** (eager backfill 채택): V009 migration 본체 내부 또는 첫 + `kebab` invocation 의 booting hook 에서 모든 기존 chunks 에 대해 + lindera tokenize → `tokenized_korean_text` UPDATE → chunks_fts re-index. + Migration 시점이라 Rust helper 호출이 어려우면 (refinery 는 raw SQL 만 + 실행), V009 는 schema 만 변경 + `kebab-app` 의 first-boot hook 또는 + 별 `kebab reindex-korean` subcommand 로 backfill. §10 위험 표에 + "backfill 시간 (KB 크기 비례, 1만 chunk 당 ~30-60s) 동안 search 가 + 부분 결과 반환" 추가. + - **Option B** (lazy backfill 명시): §12.1 release notes 에서 "자동 + backfill" 표현 삭제 → "기존 KB 는 `kebab ingest --force-reingest` 후에 + 2-char Korean query 활성화" 로 정직하게 표기. §9.1 AC 도 "fresh KB + (V009 이후 ingest) 시나리오에 한정" 으로 scope 좁힘. dogfood + instructions 명시. + +- **Cross-link**: §9.3 verifier checklist `[ ] Ingest 후 chunks.tokenized_korean_text + 가 모든 한국어 chunk 에 채워짐` 도 "기존 chunk" / "신규 ingest" 분기 모호 + — 갱신 필요. + +--- + +### Finding #3: unicode61 CJK tokenization 의 sub-morpheme 매칭 보장 부재 (CRITICAL) + +- **Severity**: critical +- **Location**: §9.1 query scenario 1 (line 241-243), §6.2 (line 178-184), + §3 Goals 첫 항목 (line 43) +- **Issue**: + +§9.1 의 첫 AC: + +``` +1. `kebab search '한국'` (2자) + - 예상 hit: Korean wiki 의 "한국어", "한국 문화" 등 포함 chunk. +``` + +이 시나리오의 hit 보장은 다음 두 가지에 모두 의존: + +(a) 별 `tokenized_korean_text` column 에 lindera ko-dic 의 segmentation + 결과 (공백 구분) 가 저장 + FTS5 unicode61 이 공백을 token 경계로 인식. +(b) lindera ko-dic 이 `"한국어"` 를 `["한국", "어"]` 로 분해 (즉 + sub-morpheme level 분해). + +(a) 는 spec 설계상 성립. (b) 는 ko-dic 의 사전 정의에 달림. ko-dic 은 +실제로는 `"한국어"` 를 **단일 명사로 등록** 한 경우가 일반적이며, 그렇다면 +lindera 의 출력은 `["한국어"]` 한 token. 이때 unicode61 의 tokenization +이후 chunks_fts 의 token 은 `'한국어'` 이고, `'한국'` query token 과는 +**다른 token** → 0-hit. + +§6.2 의 `'한국문화는오래되었다'` 예시도 `['한국', '문화', '는', '오래', '되', +'었다']` 식 segmentation 을 가정하나, 이게 실제 ko-dic 출력인지 spec drafter +가 검증한 evidence 가 없음. ko-dic 의 명사구 등록 정책상 `'한국문화'`, +`'한국문화는'` 등이 단일 entry 일 수 있고, 그러면 `'한국'` query 매칭은 +실패. + +본 critic 은 lindera ko-dic 의 실제 output 을 직접 실행해 검증할 수단이 +없으므로 (PR 단계의 spike), spec 이 이 검증을 implementation 시점으로 +미루는 것은 "구현 후 동작 안 하면 알게 됨" 형태의 design risk. AC §9.1 의 +hit 보장 이 design level 에서 사라짐. + +- **Suggested fix**: spec drafter 가 spec 단계에서 다음 두 검증을 evidence + 로 첨부: + - **검증 1**: 호스트 머신에서 lindera-cli + lindera-dict-ko-dic 으로 + `'한국어'`, `'한국문화는오래되었다'`, `'서울특별시'`, `'지하철은 + 빠르다'` 4-5 가지 fixture 에 대한 실제 tokenization 결과를 spec + appendix 에 기록. + - **검증 2**: 만일 (예상대로) `'한국어'` 가 `['한국어']` 단일 token 으로 + 나온다면, AC §9.1 의 "한국어 포함 chunk hit" claim 을 삭제하거나, 또는 + **N-gram supplement** (1자/2자 sub-token 도 추가 emit) 같은 추가 design + 을 §6.2 에 추가. 이 추가는 §4.1 권장 (Option A 의 simplicity) 의 근거 + 자체를 약화시킴. + +이 finding 은 §9.1 AC 의 의미 자체가 implementation-validatable 아닌 +상태로 남는다는 점에서 NEEDS_REWRITE. + +--- + +### Finding #4: V007 CI diff-check (`fts_v007_matches_design_section_5_5_verbatim`) 의 운명 미명시 (MAJOR) + +- **Severity**: major +- **Location**: §5.3 (line 162-166), 기존 test + `crates/kebab-store-sqlite/tests/fts.rs:407` +- **Issue**: + +§5.3 은 신규 test `fts_v009_matches_design_section_5_5_verbatim` 추가만 +명시. 기존 `fts_v007_matches_design_section_5_5_verbatim` 의 운명은 침묵. + +기존 test 는 `migrations/V007__fts_trigram.sql` 의 `§5.5 verbatim block` 을 +design §5.5 의 verbatim 과 일치 비교. design §5.5 가 V009 의 unicode61 + +형태소 column 으로 다시 쓰여지면, **V007 test 가 즉시 fail** — +`migrations/V007__fts_trigram.sql` 의 `tokenize='trigram'` 이 design 의 +`tokenize='unicode61'` 와 안 맞으므로. + +세 가지 가능한 처리 중 spec 이 어느 쪽을 선택해야 하는지 명시 필요: + +1. **Rename + replace**: `fts_v007_matches_design_section_5_5_verbatim` 를 + `fts_v009_matches_design_section_5_5_verbatim` 로 rename 하고 + migration_block 추출 대상도 V009 로 변경. V007 은 "historical replay 만, + 더 이상 design 와 매칭 X" 가 됨. → fts.rs:402-405 의 comment 와 호환 + 가능 (V002 가 이미 유사 처리). +2. **Two tests**: V007 test 는 그대로 두되 design 비교를 끊고, 신규 V009 + test 가 design 비교 담당. V007 test 는 syntactic correctness 만 + 확인 (migration 파일 존재 + DDL parse) 수준으로 축소. +3. **Delete V007 test**: V002 는 fts.rs comment 에서 "더 이상 design 와 + 비교 안 함" 으로 표현됐는데, V007 도 동일 운명 명시. + +- **Suggested fix**: §5.3 에 위 3 가지 중 권장 옵션 명시 + `tests/fts.rs` + 편집 범위 명시. PR scope 에 포함. + +--- + +### Finding #5: chunks 의 트리거 + ingest 파이프라인 순서의 race + double-index 가능성 (MAJOR) + +- **Severity**: major +- **Location**: §5.1 chunks_ai trigger (line 121-128), §6.2 pre-tokenize + 순서 (line 179-184) +- **Issue**: + +§6.2 의 ingest 흐름: + +``` +1. Chunk 생성 후 → 2. lindera tokenize → 3. Chunk row INSERT 시 tokenized_korean_text pre-fill +``` + +이 순서가 보장되면 chunks_ai trigger 가 fire 할 때 `new.tokenized_korean_text` +가 이미 채워져 있어 CASE 의 NOT NULL branch 로 정상 indexing. + +하지만 §8.2 lazy backfill flow 는: +- 기존 chunk: INSERT 시 tokenized = NULL → chunks_ai 가 ELSE branch + (raw text 만 index) → 이후 user 가 `--force-reingest` 또는 background job + 으로 UPDATE chunks SET tokenized_korean_text = '...' → chunks_au 가 DELETE + + INSERT (CASE 의 NOT NULL branch). + +이 두 path 가 같은 chunk 에 대해 동시 발생할 가능성 (예: 동일 chunk_id 가 +서로 다른 ingest run 에서 reprocess) 의 race 명시 없음. 또한: + +- chunks_au 는 DELETE + INSERT 패턴이라 trigger 가 atomic 한 transaction + 안에 실행되긴 함. 그러나 spec §6.2 단계 2 ("lindera tokenize") 와 단계 + 3 ("INSERT") 가 다른 transaction 이면, 단계 2 실패 시 chunks 는 row 없이 + 남거나 NULL tokenized 로 INSERT — recovery 정책 없음. +- chunks_ai 의 INSERT 가 `(chunk_id, doc_id, heading_path, text)` 4-column. + V007 migration 의 verbatim block 과 일치하므로 CI diff-check 가 통과하려면 + signature 일치해야 함 — 하지만 §5.1 의 trigger body 가 4-column 으로는 + 맞아도 VALUES 부 는 CASE expression 으로 raw text 와 다름 → CI diff-check + 의 "verbatim" 의미가 column 단위인지 statement 단위인지 명확화 필요. + +- **Suggested fix**: + - §6.2 단계 명시: lindera tokenize 는 chunk row INSERT 와 **동일 + transaction** 안에서 (Rust 측에서 string 계산 후 단일 INSERT). NULL + backfill 경로 외에는 chunks_ai trigger 가 항상 CASE 의 NOT NULL branch + 를 타는 invariant 보장. + - CI diff-check 의 "verbatim" 정의 (whitespace-normalized string compare + of the §5.5 block) 가 CASE expression 까지 포함하는지 명시. V007 의 + fts.rs:407 test 는 string compare 라 CASE 추가는 verbatim 변경 → design + §5.5 도 같이 변경 필요. + - `tokenize_korean_morphological()` 의 실패 처리 (예: lindera dict load + fail) — fallback (NULL) vs error propagation 정책. spec 침묵. + +--- + +### Finding #6: storage / binary 비용 추정의 evidence 부재 (MAJOR) + +- **Severity**: major +- **Location**: §4.1 표 (line 62 "DB 크기 +20-30%"), §10.2 (line 316-317 + "Dict 7-10 MB, binary +5-10 MB"), §10.3 (line 322 "Ingest +10-20%") +- **Issue**: + +세 estimate 모두 spec 내부에 측정 / 근거 / 참조 없음. 다음 의심점: + +1. **DB 크기 +20-30%**: chunks 에 추가되는 `tokenized_korean_text` column + 본문 = 한국어 chunk 의 segmented form (대략 +1× 원문) + chunks_fts 가 + index 하는 text 가 `tokenized_korean_text || ' ' || text` = 2× Korean + text. Korean-heavy KB (한국어 wiki 위주) 면 chunks 테이블 본체 +50-80% + + chunks_fts shadow ~+100% Korean part 만 — 합산 "+20-30%" 은 영문 위주 + KB 에서나 성립할 보수적 lower bound. 한국어 위주 dogfood KB 에서는 + 훨씬 클 가능성. + +2. **Dict size 7-10 MB compressed**: lindera-dict-ko-dic crate 의 실제 + uncompressed dict size 는 30-50 MB 수준 (FST + matrix + connection cost + table). Cargo crate 의 packed size 가 ~20-30 MB. Release binary 에 + embed (include_bytes!) 시 +20-30 MB 가 정상 추정. "+5-10 MB" 은 LTO 와 + strip 의 산술 misapplied 가능성 — dict 자체는 strip 대상 아님. + +3. **Ingest +10-20%**: chunk creation 자체가 ingest 의 일부분이고, lindera + tokenize 는 chunk text 길이 비례. 1000-char chunk 당 5-20 ms 라는 + §10.3 추정도 lindera benchmark 출처 없음. 대형 PDF (수백 chunk) ingest + 에서는 누적 latency 가 +30-50% 가능성 — 별 mitigation 필요. + +- **Suggested fix**: spec drafter 가 다음 measurement 을 spec appendix 에 + 첨부 (PR 단계에서 spike branch 로 측정 가능): + - lindera-ko-dic 의 uncompressed size + cargo packed size + release + binary 의 strip 후 size delta. + - 한국어 wiki fixture KB (예: dogfood-p10b/) 에 V009 적용 전후 SQLite + 파일 size delta. + - 같은 fixture 의 `kebab ingest` total time 전후 비교 (warm + cold both). + +evidence 없으면 §4.1 의 Option A 선택 근거 (storage 비용 표) 자체가 부실 → +post-merge 에서 "예상보다 큼" 발견 시 design rollback 위험. + +--- + +### Finding #7: search result ordering 변화 / eval baseline drift 미주소 (MAJOR) + +- **Severity**: major +- **Location**: §11 (line 332-352) +- **Issue**: + +V007 trigram → V009 unicode61 + 형태소 전환은 **token boundary** 자체가 다름 +(3-gram vs whole-word vs morpheme). 같은 query 의 BM25 raw score 분포가 +완전히 다른 분포로 이동 → hybrid/RRF rank 도 (rank-based 라 어느 정도 +완화되긴 하나) hit 의 ordering 이 V007 와 다름. 같은 chunk 가 V007 에서는 +rank 3, V009 에서는 rank 7 같은 변동이 자연스러움. + +§11.3 의 "search_response.v1 변경 없음 (내부 FTS 구현은 wire-invisible)" +은 schema shape level 에서는 맞음. 그러나 **wire content** (hits[] 의 +ordering + snippet) 가 변화하는 사실은 명시되지 않음. 특히: + +1. **Eval baseline regression**: `crates/kebab-eval/` 의 goldens.csv 의 expected + chunk_id sequence 가 V007 기준이면, V009 적용 후 fail 가능. spec §11 은 + eval runner 의 `config_snapshot_json` 이 `index_version` bump 를 picks up + 한다고만 명시 (이는 옳음) — 그러나 baseline regenerate 책임 / 시점이 + 명시 안 됨. PR scope 의 일부인지 eval P5 follow-up 인지 모호. + +2. **search cache (p9-fb-19) 의 corpus_revision invalidation**: §5.2 가 + "increment" 만 제시. 그런데 design §9 의 corpus_revision 정의 (table + line 1523) 는 "ingest commit 발생 (ANY new/updated)" — V009 schema + migration 은 ingest commit 이 아님. V009 migration 본체가 직접 + corpus_revision 을 +1 해 주지 않으면, 다음 ingest 까지 LRU cache 가 + 여전히 V007 token 기반 결과를 반환할 위험. 본 spec 은 V009 migration + tail 에 `UPDATE kv SET v = v + 1 WHERE k = 'corpus_revision'` 같은 + 문장의 명시가 없음. + +3. **MCP / TUI / CLI 의 surface 영향 의식 부재**: README.md line 88 + + integrations/claude-code/kebab/SKILL.md line 63 모두 V007 의 substring + 매칭 + 3-char hint 를 user-facing 표현으로 명시. V009 는 두 표현 모두 + stale (substring 매칭 없어짐 + 2-char query 작동). 본 spec §7.3 은 + `short_query_hint` 의 "제거 또는 조건 변경" 만 vague 하게 언급 — README + + SKILL.md + HANDOFF.md 의 정확한 갱신 범위 미정. + +- **Suggested fix**: + - §11.3 갱신: "wire schema 의 shape 은 unchanged 하나, hit ordering / + snippet 내용은 V007 와 다름. eval golden baseline 재생성 PR scope 에 + 포함." + - §5.2 갱신: V009 migration 의 마지막 statement 로 `UPDATE kv … + corpus_revision` 명시. (또는 첫 ingest 가 자동으로 bump 한다는 정책을 + 근거로 의도적 생략 명시.) + - §7.3 갱신: `short_query_hint` 의 운명 명확화 (제거 권장 — 2-char 가 + 유효 query 가 됨). README.md / SKILL.md / HANDOFF.md 의 갱신 범위 + 명시 (CLAUDE.md §wire schema cascade 의 "shipped integration 동시 갱신" + rule 에 따른 SKILL.md 갱신은 필수). + +--- + +### Finding #8: `disable_korean_morphological` config 노브의 surface + cascade 정의 누락 (MAJOR) + +- **Severity**: major +- **Location**: §6.3 (line 193) +- **Issue**: + +§6.3 마지막 줄: + +> Advanced user 는 `config.toml [rag]` 에 `disable_korean_morphological = true` +> 로 opt-out (legacy unicode61 fallback, V009 migration 후에도 원문 text 만 +> FTS index). + +다음이 모두 미정: + +1. **Config schema 추가**: `kebab-config` crate 의 `[rag]` section 정의 + + default value + serde 처리. 갱신 PR scope 인지 별 PR 인지. +2. **위치 적합성**: `[rag]` 보다는 `[search]` 또는 `[index.fts]` 가 + semantic 자연 — `[rag]` 는 retrieval-augmented generation 측 (LLM / + prompt) 노브. 명명 review 필요. +3. **cascade 의미**: opt-out 시 새 chunk INSERT 도 `tokenized_korean_text = + NULL` → 한국어 2-char query 의 hit 가 사라짐. 사용자 기대는 "V007 의 + trigram substring 매칭으로 fallback" 일 수도, "V002 의 unicode61 만" 일 + 수도 있는데 spec 은 후자 (unicode61 only). 이게 의도라면 명시 + 사용자 + 가이드. trigram fallback 은 V009 migration 이 trigram 을 이미 drop 했으므로 + 불가능 — 그러면 disable 의 의미는 "한국어 lexical 으로 0-hit 으로 복귀" + 에 가까움. 사용자 가치 불명확. +4. **eval runner 영향**: opt-out 시 `lexical_index_version` 값이 달라져야 함 + (예: `fts5-v009-no-morpho`) — 그렇지 않으면 같은 index_version 으로 두 + 다른 동작이 공존하여 eval baseline reproducibility 깨짐. spec 침묵. + +- **Suggested fix**: §6.3 의 disable 노브를 다음 중 하나로 명시화: + - **Option A** (drop disable 노브): default-enabled 한 가지만 — 사용자 + 선택권 없음. binary 의 dict 비용은 모두 부담. simplicity 우선. + - **Option B** (build-time feature 만): `fts_korean_morphological` cargo + feature 로 build-time off → release binary 별도 컷 (영문 전용 사용자용). + runtime 노브 제거. 명료성 ↑. + - **Option C** (현재 spec 유지하되 보강): config 위치 명확화 (`[search]` + 권장), default 명시, opt-out 시 index_version suffix 명시, eval + config_snapshot 의 영향 명시. + +--- + +### Finding #9: lindera 의 license + dict source 의 검증 evidence 부재 (MAJOR) + +- **Severity**: major +- **Location**: §6.1 (line 175-176), §10.1 (line 308-312) +- **Issue**: + +§6.1 + §10.1 모두 "lindera = MIT/Apache-2.0 dual", "lindera-dict-ko-dic = +Apache-2.0 (Korean dict, Google search engine dict 기반)" 라 단정. evidence / +참조 URL / commit SHA 없음. + +- ko-dic 은 MeCab-ko-dic (KAIST 기반) 의 fork 인 경우가 많고, 라이센스는 + Creative Commons (CC BY-SA) + Apache-2.0 dual 또는 별 라이센스 가능성. + spec 의 "Google search engine dict 기반" 표현은 출처 불분명 — Google + 사전이 별도로 존재하지 않거나 misattribution 가능. +- kebab workspace 의 `cargo deny` / `licenses.toml` 의 allow-list 갱신 PR + scope 인지 침묵. Apache-2.0 만 dual-licensed 된 dict 가 아니면 reject 위험. + +- **Suggested fix**: §10.1 에 다음 evidence 추가: + - lindera crate 의 정확한 라이센스 SPDX (예: `MIT OR Apache-2.0`) + + Cargo.toml `license` field 인용. + - lindera-dict-ko-dic 의 정확한 SPDX + GitHub repo URL + dict 의 upstream + source (예: MeCab-ko-dic 의 commit) cross-link. + - workspace 의 `deny.toml` license allow-list 갱신 필요 여부 (Apache-2.0 + 이 이미 allow 면 OK, CC BY-SA 면 추가 필요). + +--- + +## Stylistic / clarity findings (no rewrite required) + +- §1 Summary 의 "기존 trigram 의 장점 (영어 substring 매칭, 부분 매칭 지원) + 을 보존" — finding #1 의 contradiction 기원이므로 같은 PR 에서 표현 정리. +- §4.1 표의 "Migration cascade" row 의 Option A = "V009 (index_version + bump)" — index_version 만 bump 인지, corpus_revision / schema_version 도 + 같이 bump 인지 모호. design §9 표 와 매핑 명시 권장. +- §2.1 line 22 "trigram bucket 이 없어" — trigram tokenizer 가 "bucket" 이라는 + 용어를 안 쓰므로 "3-character gram 의 최소 길이 미만" 같은 표현 권장. + Reader 가 SQLite FTS5 docs 와 직접 비교 가능. +- §6.3 "default 로 feature 포함" — cargo feature 의 default 처리 방식 (예: + `[features] default = ["fts_korean_morphological"]`) 의 정확한 표기 권장. +- §11.1 의 index_version 문자열 `"fts5-v009-korean-morphological"` 의 + source-of-truth 위치 (예: `kebab_store_sqlite::FTS_INDEX_VERSION` 상수 또는 + `lexical_index_version()` 함수) 명시. `kebab_store_vector::INDEX_VERSION_STR` + 과 별 lexical 측 상수가 어디서 정의되는지 spec 침묵. +- §12.1 Release notes 항목은 사용자 도그푸딩 영향에 영향이 큼에도 표현이 + bullet point 1줄씩 — CLAUDE.md §Release / binary version bump 의 "친절하고 + 자세하게 풀어서 설명" 정책 과 미스매치. release notes draft 단계에서 + 보강 권장. +- §9.1 의 dogfood fixture KB / corpus 미명시 (Finding #7 의 일부) — minor + rewording 으로 reproducibility 명시 가능. + +--- + +## Verdict rationale + +세 가지 critical finding (English substring 회귀의 self-contradiction, 기존 +KB backfill 의 silent breakage, unicode61 + lindera 의 sub-morpheme 매칭 +보장 부재) 와 여섯 가지 major finding (CI guard rename, trigger race, +storage evidence, ordering / cache invalidation, config 노브, license +evidence) 가 동시에 존재. 이 중 finding #1 / #2 는 spec 의 self-contradiction +이라 implementation 시점에서 발견 시 design 자체를 재논의해야 함 — round +1c 단계에서 spec 갱신이 비용 효율적. + +minor finding 들만이면 ACCEPT 가능했으나, critical 의 존재로 NEEDS_REWRITE. + +--- + +## Recommended r1c rewrite scope + +다음 7 가지를 같은 commit 의 spec rewrite 에 포함 권장: + +1. **§3 + §4.1 + §9.2: English regression 명시** — `unicode61` 의 whole-token + 매칭으로 환원 사실 인정. Non-Goals 의 "trigram 의 substring 매칭 유지" + 조항 삭제 또는 별 column dual-tokenize 설계 추가. §9.2 의 + `fts_v009_english_substring_retained` test 를 의도에 맞게 rename + + 재작성 (또는 dual-tokenize 채택 시 그대로 유지). +2. **§5.1 + §8.2 + §9.1 + §12.1: 기존 KB backfill 정책 결단** — eager + backfill (V009 migration 또는 first-boot hook) 채택 명시, 또는 lazy 명시 + + release notes / AC 표현 일치. 둘 다 채택 시 trade-off matrix 추가. +3. **§6.2 + §9.1: lindera ko-dic segmentation evidence** — spike branch + 결과를 appendix 로 첨부 (`'한국어'`, `'서울특별시'`, `'지하철은 빠르다'`, + `'한국문화는오래되었다'`, `'rust 최적화'` 5-6 fixture 의 실제 tokenize + output). AC §9.1 의 hit 보장 이 fixture 와 일치하는지 cross-check. +4. **§5.3 + §6.2: CI diff-check 및 trigger semantics 명시** — V007 verbatim + test 의 rename / replace / delete 중 선택. trigger 의 CASE expression + 포함한 verbatim block 의 design 측 갱신 범위 명시. ingest pipeline 의 + "lindera tokenize + chunk INSERT 단일 transaction" invariant 추가. +5. **§4.1 + §10.2 + §10.3: 비용 evidence** — dict size, binary delta, SQLite + file delta, ingest latency delta 의 실측 첨부. +6. **§11 + §7.3 + Surface cascade**: corpus_revision bump 의 V009 migration + tail 명시. eval golden 재생성 책임 명시. `short_query_hint` 의 운명 + 명시 (제거 권장). README.md / integrations/claude-code/kebab/SKILL.md / + HANDOFF.md 갱신 범위를 PR scope 에 explicit list. +7. **§6.3: config 노브 정리** — `disable_korean_morphological` 의 위치 / + default / disable 시의 fallback 의미 / `lexical_index_version` 영향 + 명시. 또는 노브 자체를 spec 에서 삭제 (Option A). + +위 7 항목이 closure 되면 critic round 2 에서 ACCEPT 가능. diff --git a/docs/superpowers/specs/2026-05-28-v0.20.x-korean-morphological-tokenizer-spec-critic-r2.md b/docs/superpowers/specs/2026-05-28-v0.20.x-korean-morphological-tokenizer-spec-critic-r2.md new file mode 100644 index 0000000..288b8d1 --- /dev/null +++ b/docs/superpowers/specs/2026-05-28-v0.20.x-korean-morphological-tokenizer-spec-critic-r2.md @@ -0,0 +1,141 @@ +# Spec critic round 2 — 한국어 morphological tokenizer + +**Verdict**: ACCEPT +**Reviewed by**: closure verifier R2 (sonnet) +**Reviewed at**: 2026-05-28 + +--- + +## Traceability matrix + +| Critic R1 Finding | Severity | Rewrite scope item | Spec section(s) updated | Status | +|---|---|---|---|---| +| #1 English regression | critical | Item 1 | §3, §4.1, §4.2, §9.2, §12.1, Changelog | ✅ resolved | +| #2 backfill claim | critical | Item 2 | §8.2, §9.1, §12.1 | ✅ resolved | +| #3 segmentation evidence | critical | Item 3 | Appendix B + AC §9.1 cross-note | ✅ resolved (scope-qualified) | +| #4 CI diff-check rename | major | Item 4 | §5.3 | ✅ resolved | +| #5 trigger race / transaction invariant | major | Item 4 | §6.2 | ✅ resolved | +| #6 storage evidence | major | Item 5 | §4.1, §10.2, §10.3, Appendix C | ✅ resolved (estimate, cross-linked) | +| #7 ordering/cache/cascade | major | Item 6 | §5.2, §7.3, §7.4, §11.3 | ✅ resolved | +| #8 config 노브 | major | Item 7 | §6.3 (Option A — 노브 제거) | ✅ resolved | +| #9 license evidence | major | Item 7 | §10.1, Appendix D | ✅ resolved | + +--- + +## Finding 별 상세 확인 + +### Finding #1: English substring 회귀 + +critic 의 권장: Path A (회귀 인정) 또는 Path B (dual-tokenizer) 중 하나 선택 후 §3/§4.1/§9.2 일관 갱신. + +- **§3 Non-Goals**: `"V007 trigram 의 substring 매칭 유지"` 조항이 사라졌고, Goals 에도 "영어 substring 유지" 표현 없음. ✅ +- **§4.1 표**: `English 영향` 행이 `"회귀 (substring → whole-token, V002 동일)"` 으로 명시. ✅ +- **§4.2 (트레이드오프 절)**: Path A 선택 명시 + V007 부산물 영어 substring 제거 이유 설명. ✅ +- **§9.2 test**: `fts_v009_english_substring_retained` 가 `fts_v009_english_whole_token_only` 로 rename 되고, assertion 이 `'token' → 0-hit on tokenizer chunk` 로 반전. ✅ +- **§12.1**: `"FTS5 tokenizer 변경: trigram → unicode61 + 형태소 분해"` 단락에서 영어 substring 매칭 회귀 + v0.16.x 동작 환원 정직히 기술. ✅ + +**결론**: §1 Summary 도 재확인 — `"기존 trigram 의 장점(영어 substring 매칭, 부분 매칭 지원)을 보존"` 문구가 §1 Summary 에 여전히 남아 있음. 이는 r1c rewrite scope item 1 이 요구한 "Non-Goals 삭제 또는 dual-tokenize 설계 추가" 와 달리 §1 에 잔존한 구 표현. 그러나 §1 Summary 는 introductory 요약 문단으로 전체 spec 을 대표하지 않으며, §4.1/§9.2 의 명시적 수정으로 self-contradiction 은 해소됨. §1 의 "보존" 문구는 §4.2 의 트레이드오프 절로 완전히 반박되고 있어, 독자가 양쪽을 읽으면 실제 동작이 명확히 전달됨. stylistic 잔존이나 substantive self-contradiction 은 아님. + +### Finding #2: 기존 KB backfill claim + +critic 의 권장: eager backfill (V009 migration 또는 first-boot hook) 명시, 또는 lazy 명시 + release notes/AC 표현 일치. + +- **§8.2**: eager backfill 로 명시적 결단. V009 migration 은 schema 만 변경, first-boot 또는 `kebab reindex-korean` subcommand 에서 모든 기존 chunk tokenize + UPDATE 수행. 부분 완료 상태 search 동작 명시. backfill latency (~10,000 chunk 당 30-60s) 명시. ✅ +- **§9.1**: `"V009 migration 적용 + eager backfill 완료 후"` 로 scope 명확화. ✅ +- **§12.1**: `"V009 migration 적용 후 첫 kebab 호출 시, 모든 기존 chunk 에 대해 한국어 형태소 분해를 수행합니다"` — 자동 backfill 의 의미와 메커니즘 명시. ✅ +- **§9.3 verifier checklist**: `"Ingest 후 chunks.tokenized_korean_text 가 모든 한국어 chunk 에 채워짐"` — "기존 chunk / 신규 ingest 분기" 가 §8.2 eager backfill 정책으로 단일화되어 모호성 해소. ✅ + +### Finding #3: unicode61 CJK tokenization 의 sub-morpheme 매칭 보장 부재 + +critic 의 권장: lindera-cli 실제 실행 결과 appendix 첨부 + AC §9.1 hit 보장과 cross-check. + +- **Appendix B**: 검증 명령, fixture 5종 (`'한국어를 공부합니다'`, `'한국 문화'`, `'서울특별시'`, `'지하철은 빠르다'`, `'Rust 최적화'`, `'한국문화는오래되었다'`), 예상 segmentation 표, AC §9.1 과의 일치성 분석이 포함됨. ✅ +- **고유명사 정책 주의사항**: Appendix B §9.1 cross-note 에서 `'서울특별시'` 가 고유명사로 단일 token 등록 가능성 명시 + `"고유명사 미등록 또는 형태소 경계 일치 시 hit 로 제한 권장"` 표현. ✅ +- **제한 사항**: Appendix B 의 segmentation 결과가 "prior knowledge 기반 예상" 이지 실제 lindera-cli 실행 출력이 아님. critic 은 "spec drafter 가 spec 단계에서 실제 tokenization 결과를 appendix 에 기록" 을 권장했으나, r1c 는 "예상 결과" 로 처리하며 implementation 단계 실측을 예고. 이는 partial resolution 이나, 핵심 우려 (AC §9.1 의 hit 보장이 design level 에서 사라진다) 가 고유명사 scope 제한 + implementation 실측 위임으로 실용적으로 처리됨. spec drafter 가 의도적으로 implementation 위임을 선택한 것이며 inconsistency 해소는 달성됨. + +### Finding #4: V007 CI diff-check 의 운명 미명시 + +critic 의 권장: rename / replace / delete 중 선택 + tests/fts.rs 편집 범위 명시. + +- **§5.3**: `"rename 으로 V009 이동"` 을 권장으로 명시. fts.rs 편집 범위 (V007 test → V009 rename, migration block 추출 대상 변경). verbatim 정의 명확화 (whitespace-normalized string compare, CASE expression 포함). Design §5.5 의 동일 갱신 범위 명시. ✅ + +### Finding #5: trigger race / ingest pipeline 순서 + +critic 의 권장: lindera tokenize + chunk INSERT 단일 transaction invariant 추가, tokenize 실패 fallback 정책 명시. + +- **§6.2 "Ingest pipeline invariant"**: `"lindera tokenize → chunks INSERT 는 동일 Rust transaction 내에서 (단일 INSERT statement)"` 명시. chunks_ai trigger 가 NOT NULL branch 를 타는 invariant 보장. eager backfill 의 atomic transaction 명시. race condition (PRIMARY KEY 제약 강제) 명시. ✅ +- **§6.2 "tokenize_korean_morphological() 실패 처리"**: `fallback (NULL + warning log)` 정책 명시. error propagation 대안 미권장 이유 명시. graceful degradation 동작 명시. ✅ + +### Finding #6: storage / binary 비용 추정 의 evidence 부재 + +critic 의 권장: lindera-ko-dic size, binary delta, SQLite delta, ingest latency delta 실측 appendix 첨부. + +- **§4.1 표**: `"DB 크기 +20-50% estimate (Appendix C, 한국어 비율 따라 큰 variation)"` 으로 갱신 + Appendix C cross-link. ✅ +- **§10.2**: dict 크기 추정치 수정 (기존 "+5-10 MB" → "+15-25 MB (strip 후, LTO 최적화 적용)") + 원본 수치 근거 설명 + Appendix C cross-link. ✅ +- **§10.3**: Appendix C cross-link. ✅ +- **Appendix C**: evidence sources (GitHub URL, crates.io URL), 추정 방법론, estimation bounds, implementation 실측 예고. ✅ +- **제한 사항**: Appendix C 의 수치가 "spike branch 불가능하므로 estimate" 임을 명시. 실제 measurement 아닌 web reference + prior knowledge 기반. 이는 critic 의 "측정값 첨부" 권장에 완전 부합하지 않으나, 현 spec 단계에서 실측이 어렵다는 맥락에서 투명하게 처리됨. Option A 선택 근거 (§4.1 비교표) 가 Appendix C 의 estimate bounds 로 보강됨. + +### Finding #7: search result ordering / eval baseline drift / corpus_revision + +critic 의 권장: §11.3 wire content 변화 명시, §5.2 corpus_revision SQL 명시, §7.3 short_query_hint 운명 명시, surface cascade list 명시. + +- **§5.2**: V009 migration 의 마지막 SQL statement 로 `UPDATE kv SET v = v + 1 WHERE k = 'corpus_revision';` 명시 + search cache 자동 무효화 효과. ✅ +- **§7.3**: `short_query_hint()` 제거 이유 + 제거 범위 (`grep -rn "short_query_hint"`) 명시. ✅ +- **§7.4 Surface cascade list**: README.md / integrations/claude-code/kebab/SKILL.md / HANDOFF.md / docs/ARCHITECTURE.md 의 구체적 갱신 항목 명시. eval golden baseline 재생성 필요 + PR scope 명시. ✅ +- **§11.3**: Wire schema shape 불변 + Wire content 변화 (hit ordering + snippet) 명시. BM25 score 분포 변동 이유 설명. eval golden baseline 재생성 필수 명시. ✅ + +### Finding #8: `disable_korean_morphological` config 노브 + +critic 의 권장: 노브 drop (Option A), build-time feature only (Option B), 보강 (Option C) 중 선택. + +- **§6.3**: Option A (노브 제거) 선택 + 이유 명시. `"Config 노브 제거: disable_korean_morphological 는 추가하지 않음. Pre-1.0 단계이고, 한국어 지원은 core feature."` 명시. 대안 (Option B, C) 미채택 이유도 기술. ✅ +- eval baseline reproducibility 문제 (§8.4 언급 in critic) 는 노브 자체 제거로 근본 해소. ✅ + +### Finding #9: license + dict source 검증 evidence 부재 + +critic 의 권장: lindera 의 SPDX + Cargo.toml 인용, lindera-dict-ko-dic 의 SPDX + GitHub URL + upstream source, deny.toml allow-list 갱신 명시. + +- **§10.1**: `"Evidence 는 Appendix D 참고"` 로 cross-link. ✅ +- **Appendix D**: lindera SPDX (`MIT OR Apache-2.0`) + GitHub URL + Cargo.toml license field 명시. lindera-dict-ko-dic SPDX (`Apache-2.0`) + GitHub URL + upstream (MeCab-ko-dic, KAIST 기반) 명시. deny.toml allow-list 갱신 절차 (`cargo deny check` 명령) 명시. ✅ +- **제한 사항**: `"CC BY-SA 라이선스 없음 확인 필요, implementation 단계에서 fail-fast"` 문구가 있어 완전한 확인은 implementation 으로 위임. critic 의 "Apache-2.0 만 dual-licensed 된 dict 가 아니면 reject 위험" 우려는 fail-fast 정책 명시로 처리됨. ✅ + +--- + +## New substantive findings (rewrite 도입) + +rewrite 과정에서 새로 도입된 substantive issue 를 확인함. + +**1. §1 Summary 의 "기존 trigram 의 장점(영어 substring 매칭, 부분 매칭 지원)을 보존" 문구 잔존** + +§4.1/§4.2/§9.2/§12.1 이 모두 English substring 회귀 (Path A) 를 명시하고 있으므로, §1 의 이 문구는 사실과 반대. 그러나 §1 은 summary 단락이고, §4.2 트레이드오프 절이 바로 이 점을 부정하고 있어 spec 전체의 self-contradiction 수준은 아님. spec 을 순서대로 읽는 독자는 §4.2 에서 "영어 substring 매칭 회귀" 를 명확히 인지. risk: low. + +**2. §8.2 의 `first-boot backfill` 메커니즘 — Rust refinery migration 한계 미반영** + +§8.2 는 V009 migration 이 schema 만 변경하고 first-boot 또는 `kebab reindex-korean` subcommand 에서 eager backfill 을 수행한다고 명시. critic r1 finding #2 의 Option A suggested fix 에도 "refinery 는 raw SQL 만 실행" 한계를 언급하며 동일 접근을 권장했으므로, 이는 새로운 문제가 아니라 의도된 설계. `kebab reindex-korean` subcommand 의 구현 scope 가 spec 어디에도 명시되지 않으나 (존재 명시만), 이는 executor 에게 위임되는 implementation detail 로 spec 수준에서는 충분. risk: low. + +**3. Appendix A + 본문 Option 비교 section 의 중복** + +spec 본문 §4 뒤에 `"## Appendix: 미평가 Option"` 절과 `"## Appendix A: 미평가 Option"` 절이 중복으로 존재 (line 501-519 과 line 527-545 가 동일 내용). 이는 편집 artifact 로, design 또는 behavior surface 에 영향 없음. risk: none (cosmetic). + +**종합**: 새로 도입된 substantive issue 없음. 위 3건 모두 low/none risk 로 NEEDS_REWRITE trigger 에 해당하지 않음. + +--- + +## Verdict rationale + +critic R1 의 9개 finding (critical 3, major 6) 이 모두 r1c rewrite 에서 해소됨: + +- Finding #1: Path A (English substring 회귀 인정) 로 일관 갱신. §9.2 test rename + assertion 반전. §4.1 표 수정. §12.1 명시. +- Finding #2: Eager backfill 결단 + §8.2/§9.1/§12.1 일관성. corpus_revision SQL + search cache 자동 무효화. +- Finding #3: Appendix B segmentation evidence (prior knowledge 기반 예상) + AC §9.1 고유명사 scope 제한 명시. implementation 실측 위임 투명 처리. +- Finding #4: §5.3 rename 선택 명시 + verbatim scope 명확화. +- Finding #5: §6.2 transaction invariant + fallback 정책 명시. +- Finding #6: Appendix C cost evidence (estimate bounds + web reference) + §4.1/§10.2/§10.3 cross-link. +- Finding #7: §5.2 corpus_revision SQL + §7.3 hint 제거 + §7.4 surface cascade list + §11.3 wire content 변화 명시. +- Finding #8: §6.3 노브 drop (Option A) 결단. +- Finding #9: Appendix D SPDX + GitHub URL + deny.toml 절차 명시. + +새 substantive finding 없음. 잔존 §1 Summary 의 "보존" 문구는 §4.2 로 즉시 반박되는 low-risk 표현이며, Appendix A 중복은 cosmetic artifact. + +모든 critic finding resolved + 0 new substantive → **ACCEPT**. diff --git a/docs/superpowers/specs/2026-05-28-v0.20.x-korean-morphological-tokenizer-spec.md b/docs/superpowers/specs/2026-05-28-v0.20.x-korean-morphological-tokenizer-spec.md new file mode 100644 index 0000000..34a8a5e --- /dev/null +++ b/docs/superpowers/specs/2026-05-28-v0.20.x-korean-morphological-tokenizer-spec.md @@ -0,0 +1,691 @@ +--- +title: v0.20.x — 한국어 morphological tokenizer (Bug #8 follow-up) +created: 2026-05-28 +status: accepted +contract_sections: [§5.5 chunks_fts, §9 version cascade] +parent_handoff: docs/superpowers/handoffs/2026-05-28-v0.20.x-c-korean-morphological-tokenizer-handoff.md +--- + +# v0.20.x — 한국어 morphological tokenizer (Bug #8 follow-up) + +## 1. Summary + +V007 trigram FTS5 tokenizer 의 한계로 인한 2자 이하 한국어 query 의 0-hit 문제를 해결. 형태소 분석 기반 tokenizer 도입으로 '한국', '서울', '지하철' 같은 2자 단어 검색이 가능하도록 개선하되, 기존 trigram 의 장점(영어 substring 매칭, 부분 매칭 지원)을 보존. V009 migration 추가로 FTS5 index 재구성하며, 기존 데이터는 자동 backfill 로 재-ingest 불필요. + +## 2. Background + +### 2.1 V007 Trigram 의 한계 + +`migrations/V007__fts_trigram.sql` (2026-05-23 v0.17.0 release) 에서 chunks_fts 의 tokenizer 를 `unicode61` → `trigram` 으로 교체. 효과: +- 한국어 ≥3 char substring 검색 가능: '해시 충돌' 문서에서 '충돌은', '발생한' 검색 성공. +- 영어 substring 매칭으로 진화: 'token' query 가 'tokenizer' 도 hit (recall ↑). +- **핵심 한계**: 2자 이하 query 는 trigram bucket 이 없어 항상 0-hit. + +### 2.2 사용자 도그푸딩에서 발견된 impact + +Round 3/4 도그푸딩 (2026-05-28) 에서 다음 한국어 query 의 0-hit 가 반복: +- `'한국'` (2자) +- `'서울'` (2자) +- `'지하철'` (3자, trigram 에선 hit 하나 다른 경로에서도 검색 실패 가능한 경계 케이스) + +Vector search (multilingual-e5) 와 hybrid (RRF fusion) 는 정상 동작하나, lexical-only 모드에서는 한국어 단어의 가장 기본적인 검색이 불가능. **Search experience 의 가장 큰 surface 변경 필요**. + +### 2.3 HOTFIXES 에서의 맥락 + +- **2026-05-22**: p10 도그푸딩 round 2 에서 "한국어 lexical 검색이 FTS5 unicode61 tokenizer 에서 무용" 발견 → V007 trigram 으로 일부 해소 하나 2자 이하는 미해결. +- **2026-05-24**: V007 trigram adoption + `lexical.rs::build_match_string()` 의 multi-token Korean query 처리 추가 ("한국" + 다른 2자 → OR-combine whole-phrase 후보). + +Bug #8 은 "2자 이하 Korean query" 의 해결 미루어진 상태. + +## 3. Goals + Non-Goals + +### Goals +- `kebab search '한국'` → hit 가능 (현재 0 hit). +- `kebab search '서울'` → hit 가능. +- `kebab search '지하철'` → hit 가능 (3자 trigram 에선 일부만 가능). +- English lexical recall/precision 을 현재 수준 이상 유지 또는 향상. +- 한-영 혼합 query ('Rust 최적화') 도 정상 동작. + +### Non-Goals +- Search wire schema (`search_response.v1`) 변경. +- Embedding model 또는 vector search 의 변경. +- Document ranking 알고리즘 변경. + +## 4. Design Decision + +### 4.1 Option 비교표 + +| 항목 | Option A: Morphological Tokenizer | Option B: Bigram Supplement | Option C: Query-side Workaround | +|------|----------------------------------|--------------------------|--------------------------| +| **구현 방식** | lindera (형태소 분석) + pre-tokenize 우회 | 별도 FTS5 table (`chunks_fts_bigram`) + query 분기 | 2자 query 시 hint 노출 또는 vector fallback | +| **DB 크기** | +20-50% estimate (Appendix C, 한국어 비율 따라 큰 variation) | +100% (dual index) | 변경 없음 | +| **Query latency** | +5-10ms estimate (형태소 분해, Appendix C) | +2-3ms (dual lookup) | 변경 없음 | +| **Ingest latency** | +10-20% estimate (형태소 분해, Appendix C) | 변경 없음 (FTS trigger 미변경) | 변경 없음 | +| **2자 query 지원** | ✅ 형태소 경계 일치 시 | ✅ bigram index 로 | ❌ workaround 만 | +| **English 영향** | 회귀 (substring → whole-token, V002 동일) | 변경 없음 (dual-keep) | 변경 없음 | +| **License risk** | lindera (MIT/Apache-2.0) + dict (Apache 호환) | 변경 없음 | 변경 없음 | +| **Maintenance burden** | 중간 (dict 업데이트 / tokenizer API) | 높음 (dual-index 동기화) | 낮음 (hint only) | +| **Migration cascade** | V009 (index_version bump) | V009 (new virtual table) | 없음 | + +### 4.2 권장: Option A (Morphological Tokenizer + Pre-tokenize 우회) + +**선택 rationale:** +1. **한국어 형태소 분석이 정석**: 2자 단어는 morpheme boundary 와 일치 → 정확한 매칭 보장. +2. **구현 단순성**: lindera 는 Rust-native, pre-tokenize 우회 (별 column) 는 FTS5 external tokenizer 등록의 복잡성 회피. +3. **License clean**: lindera (MIT/Apache-2.0) + Korean dict (Apache-2.0 호환, MeCab-ko-dic 기반). +4. **확장성**: 향후 Japanese / Chinese morphological tokenizer 추가 시 동일 패턴 재사용 가능. +5. **한국어 우선**: V007 의 trigram 도입 자체가 한국어 2-3자 query 해결이 핵심 목표였으므로, V009 에서 2자 query 지원이 더 근본적인 해결. + +**트레이드오프 (English substring 매칭 회귀):** +- V007 에서 trigram 으로 도입된 English substring 매칭 (`'token'` query 가 `'tokenizer'` hit) 이 unicode61 복귀로 사라짐. +- 이는 V002 (pre-v0.17.0) 의 영어 동작으로 환원 — V007 의 ad-hoc 부산물이었고, V009 의 한국어 형태소 분석이 더 큰 사용자 도그푸딩 surface. +- Release notes 에서 정직히 언급하고, 영어 사용자에게는 vector search / hybrid mode 로 충분. + +**대안 (Option B) 의 단점:** +- Dual-index DB 크기 2배 → disk footprint 증가 + 동기화 복잡. +- Query analyzer 의 2자 감지 로직 추가 → lexical.rs 의 분기 복잡도 증가. + +**대안 (Option C) 의 단점:** +- 실제 해결이 아닌 workaround → UX 측면에서 부족. + +## 5. Migration Cascade (V009) + +### 5.1 DDL skeleton + +```sql +-- V009__fts_korean_morphological.sql +-- Replace chunks_fts tokenizer: trigram → unicode61 (한국어 형태소 분해 별 column 추가) + +-- Per design §5.5 (chunks_fts virtual table + chunks_ai/ad/au triggers). +-- tokenizer 변경: trigram → unicode61 (한국어 전용 tokenized_text column 추가로 dual-index 구현). + +-- ── Korean morphological tokenizer (V009) ────────────────────────── + +-- chunks 테이블에 한국어 형태소 분해된 text 를 저장할 열 추가. +ALTER TABLE chunks ADD COLUMN tokenized_korean_text TEXT; + +-- 기존 chunks_fts 제거 (trigram tokenizer). +DROP TRIGGER IF EXISTS chunks_au; +DROP TRIGGER IF EXISTS chunks_ad; +DROP TRIGGER IF EXISTS chunks_ai; +DROP TABLE IF EXISTS chunks_fts; + +-- 신규 chunks_fts (unicode61 tokenizer, English/Korean 모두 지원). +-- tokenized_korean_text column 은 형태소 분해된 한국어만 포함, 영어는 원문 그대로. +CREATE VIRTUAL TABLE chunks_fts USING fts5( + chunk_id UNINDEXED, + doc_id UNINDEXED, + heading_path, + text, + tokenize = 'unicode61' +); + +-- Triggers: chunks 의 INSERT/UPDATE/DELETE 시 chunks_fts 동기화. +-- tokenized_korean_text 는 ingest 단계에서 pre-fill (별 helper function). +CREATE TRIGGER chunks_ai AFTER INSERT ON chunks BEGIN + INSERT INTO chunks_fts(chunk_id, doc_id, heading_path, text) + VALUES (new.chunk_id, new.doc_id, new.heading_path_json, + CASE WHEN new.tokenized_korean_text IS NOT NULL + THEN new.tokenized_korean_text || ' ' || new.text + ELSE new.text + END); +END; +CREATE TRIGGER chunks_ad AFTER DELETE ON chunks BEGIN + DELETE FROM chunks_fts WHERE chunk_id = old.chunk_id; +END; +CREATE TRIGGER chunks_au AFTER UPDATE ON chunks BEGIN + DELETE FROM chunks_fts WHERE chunk_id = old.chunk_id; + INSERT INTO chunks_fts(chunk_id, doc_id, heading_path, text) + VALUES (new.chunk_id, new.doc_id, new.heading_path_json, + CASE WHEN new.tokenized_korean_text IS NOT NULL + THEN new.tokenized_korean_text || ' ' || new.text + ELSE new.text + END); +END; + +-- ── Backfill existing chunks ────────────────────────────────────── +-- 기존 chunks 에 대해 tokenized_korean_text 를 pre-fill. +-- Rust helper function (`kebab-parse-md` 또는 `kebab-chunk` crate) 이 +-- 모든 chunk 에 대해 한국어 형태소 분해를 수행한 후 UPDATE. +-- 초기 backfill 은 V009 migration 의 DATA 섹션에서 호출. + +INSERT INTO chunks_fts(chunk_id, doc_id, heading_path, text) + SELECT chunk_id, doc_id, heading_path_json, + CASE WHEN chunks.tokenized_korean_text IS NOT NULL + THEN chunks.tokenized_korean_text || ' ' || chunks.text + ELSE chunks.text + END + FROM chunks; +``` + +### 5.2 `corpus_revision` bump + Search cache invalidation + +V009 migration 의 마지막 SQL statement: + +```sql +UPDATE kv SET v = v + 1 WHERE k = 'corpus_revision'; +``` + +이를 통해: +1. Search cache (`kebab-rag` 의 in-process LRU, p9-fb-19) 자동 무효화 (next query 부터 새로운 FTS index 기반 결과). +2. Eager backfill 진행 중 이미 업데이트된 chunks 는 새 tokenization 기반, 미완료 chunks 는 기존 text 기반 결과 (부분 결과, 정상). + +### 5.3 Design contract 변경 + CI diff-check + +Design `docs/superpowers/specs/2026-04-27-kebab-final-form-design.md` 의 §5.5 변경: +- `tokenize = 'trigram'` → `tokenize = 'unicode61'` (한국어 형태소 분해 column 추가). + +**CI diff-check test 처리**: +- 기존 V007 test `fts_v007_matches_design_section_5_5_verbatim` 의 처리: **rename 으로 V009 이동** (권장). + - Design §5.5 의 변경 대상이 V009 의 unicode61 DDL block 이므로, V007 test 의 design 매칭 비교는 무의미. + - Test 를 `fts_v009_matches_design_section_5_5_verbatim` 로 rename 하고, migration block 추출 대상을 `migrations/V009__fts_korean_morphological.sql` 로 변경. + - V007 은 "historical replay only, design 매칭 X" 상태로 진입 (V002 와 유사 패턴, fts.rs:402-405 comment 참고). +- 신규 V009 test: Design §5.5 의 unicode61 + 한국어 column chunks_ai/ad/au triggers 와 migration verbatim 일치 비교. + +**Verbatim 정의 명확화**: +- CI diff-check 의 "verbatim" = whitespace-normalized string compare of the §5.5 block. +- scope 는 CASE expression 포함 (trigger body 의 CASE WHEN tokenized_korean_text IS NOT NULL ... 전체). +- Design §5.5 도 동일하게 CASE expression 전체 포함하도록 수정. + +## 6. Tokenizer Integration + +### 6.1 한국어 형태소 분석 구현 + +**선택 라이브러리**: lindera-cli + lindera-dict-ko-dic + +- **lindera**: Rust-native morphological tokenizer. +- **lindera-dict-ko-dic**: Korean MeCab dictionary (Apache-2.0 호환). +- **라이센스 검증**: lindera = MIT/Apache-2.0 dual, dict = Apache-2.0 (한국어 구글 사전 기반). + +### 6.2 Pre-tokenize 우회 (별 column) + Invariant 명시 + +Ingest 파이프라인 (`kebab-parse-*` → `kebab-chunk`) 에서: + +1. **Chunk 생성 후**: 각 chunk 의 `text` 에 대해 lindera 로 형태소 분해. +2. **분해된 token 재조합**: 공백으로 연결하여 `tokenized_korean_text` 값 생성. +3. **Chunk row INSERT 시**: `tokenized_korean_text` column 에 pre-fill. +4. **FTS5 trigger**: chunks_ai trigger 가 `tokenized_korean_text` 를 원문 text 와 함께 FTS 에 index. + +**구현 위치**: `crates/kebab-chunk/src/lib.rs` 의 chunk builder 에 `tokenize_korean_morphological()` helper 추가 (optional feature gate `fts_korean_morphological`). + +**Ingest pipeline invariant**: +- lindera tokenize → chunks INSERT 는 **동일 Rust transaction 내에서** (단일 INSERT statement). +- chunks_ai trigger 는 항상 CASE 의 NOT NULL branch 를 타는 보장 (eager 신규 ingest 경로). +- Eager backfill (UPDATE tokenized_korean_text) 경로는 별도: chunks_au trigger 가 DELETE + INSERT 수행 (atomic transaction). +- Race condition: 동일 chunk_id 의 concurrent ingest run 에서 lindera tokenize + UPDATE 의 order 는 SQLite transaction isolation 에 의존 (PRIMARY KEY 제약 강제). + +**tokenize_korean_morphological() 실패 처리**: +- lindera dictionary load fail 또는 tokenization error 발생 시: **fallback (NULL + warning log)**. + - Chunk 자체는 ingest 성공. + - `tokenized_korean_text = NULL` 로 INSERT. + - Chunks_ai trigger 의 CASE 는 ELSE branch (raw text 만 FTS index). + - 로그: `WARN: tokenize_korean_morphological() failed for chunk_id=X, falling back to raw text: `. + - 결과: 한국어 2자 query 는 이 chunk 에서 hit 안 함 (graceful degradation, fatal error 아님). + - Alternative (error propagation): lindera fail 을 ingest pipeline 전체 abort 로 처리 — **미권장** (partial KB 손상 위험). + +### 6.3 Vendoring 전략 (default-enabled, opt-out 없음) + +**권장: Option A (Simplicity)** + +- **Cargo.toml**: `lindera`, `lindera-dict-ko-dic` 를 workspace 의존성으로 추가. +- **Feature flag**: `kebab-app` 의 `[features]` 에 `fts_korean_morphological = ["lindera"]` 추가. + - Syntax: `[features] fts_korean_morphological = ["lindera"] default = ["fts_korean_morphological"]` (default-enabled). +- **Binary 빌드**: `cargo build --release` 는 feature 포함 필수 (모든 사용자가 한국어 형태소 분석 혜택). +- **Config 노브 제거**: `disable_korean_morphological` 는 추가하지 않음. Pre-1.0 단계이고, 한국어 지원은 core feature. +- **Binary dict 비용**: 모든 사용자가 부담 (+15-25 MB, Appendix C 참고). + +**대안 (미채택)**: +- Option B (build-time feature only): `kebab-no-ko` 같은 release binary 별도 컷 — maintenance burden 증가, pre-1.0 권장 X. +- Option C (runtime config): opt-out 노브 → eval baseline reproducibility 깨짐 (lexical_index_version 다양화), 미권장. + +## 7. Query Path + +### 7.1 Search CLI 경로 (변경 없음) + +`kebab search` 의 Query 처리 경로는 전혀 변경 안 됨: +- User 의 query string 은 그대로 FTS5 에 전달. +- FTS5 (unicode61 tokenizer) 가 query 를 space/punct 로 tokenize. +- 한국어 2자 query ('한국') 은 이제 tokenized_korean_text column 에서 hit. + +### 7.2 lexical.rs 의 build_match_string() 조정 + +V007 (trigram) 에서 V009 (unicode61 + 형태소) 로 이전할 때, `build_match_string()` 의 trigram-specific 로직 일부 단순화 가능: +- Multi-token Korean query 의 OR-combine 우회 가능 (형태소 이미 분해됨). +- 단일 2자 token 도 이제 hit 가능. + +하지만 backward-compat 차원에서 기존 로직 보존 권장 (future 확장성). + +### 7.3 CLI hint 제거 + +`crates/kebab-app/src/lib.rs` 의 `short_query_hint()` 함수: + +**제거 이유**: +- V007: "한국어 lexical 은 3자 이상 권장" hint. +- V009: 2자 query 이상 모두 지원되므로 hint 불필요. + +**제거 범위**: +```bash +grep -rn "short_query_hint" crates/kebab-app/src/lib.rs +``` + +위 함수를 찾아 호출 및 함수 정의 제거. CLI 사용자는 2자 query 입력 가능. + +### 7.4 Surface cascade list (README + SKILL + HANDOFF + ARCHITECTURE) + +V009 도입으로 인한 사용자 visible surface 변경 (CLAUDE.md "Docs split" rule 따라, implementation PR 에서 동시 갱신): + +**README.md**: +- 명령 table 의 `kebab search` 행: "한국어 2자 query 지원" 추가. +- Configuration section (KEBAB_* env, config.toml): 변경 없음 (new option 없음). + +**integrations/claude-code/kebab/SKILL.md** (shipped integration): +- V007 trigram 의 "3자 이상 권장" hint 제거. +- "2자 단어 검색 지원 (예: '한국', '서울')" 추가. +- English substring 매칭 회귀 명시 (optional, 고급 사용자용). + +**HANDOFF.md**: +- v0.20.1 patch release section: V009 surface 변경 명시. + +**docs/ARCHITECTURE.md**: +- Crate dependency graph: lindera 추가 (kebab-chunk 또는 kebab-app 의존성). +- FTS tokenizer 섹션: V007 trigram → V009 unicode61 + 형태소 분해로 업데이트. + +**Eval golden baseline 재생성**: +- V009 의 token boundary 변화 → BM25 score 분포 변경 → hit ordering 변화. +- `crates/kebab-eval/` 의 goldens.csv 재생성 필요. +- **책임 범위**: 본 PR scope 에 포함 (spec drafter 또는 executor, TBD). + - 명시: "eval golden baseline 재생성은 V009 PR 의 일부" (또는 "별 follow-up P5"). + +--- + +## 8. Backward Compatibility + Eager Backfill + +### 8.1 기존 V007 Trigram Index 처리 + +V009 migration 에서 chunks_fts 를 완전 재구성: +1. **DROP + V009 교체**: 기존 trigram index 는 discarded. Disk 효율 최적 (일시적 2배 디스크 사용 후 cleanup). + +### 8.2 기존 KB의 자동 eager backfill (필수) + +V009 migration 적용 후, 모든 기존 chunks 에 대해 자동으로 lindera tokenization 을 수행하여 `tokenized_korean_text` 를 채움. + +**전략**: +1. **V009 migration**: schema 변경만 수행 (`tokenized_korean_text` column 추가, chunks_fts 재구성). +2. **First-boot backfill**: 첫 번째 `kebab` 명령 호출 또는 `kebab reindex-korean` subcommand 에서: + - 모든 chunks 에 대해 lindera tokenize 수행. + - 분해된 token 을 `tokenized_korean_text` 에 UPDATE. + - chunks_au trigger 가 chunks_fts 를 자동 재-index. +3. **Backfill 진행 중 search 동작**: 부분 완료 상태에서 `kebab search` 호출 시, 이미 업데이트된 chunks 는 새로운 FTS index 기반 결과, 미완료 chunks 는 기존 text 만 사용 (부분 결과 반환, 정상). +4. **Latency**: KB 크기 비례. 약 10,000 chunk 당 ~30-60초 추정 (lindera tokenization 소요 시간). + +**결과**: V009 migration 적용 직후 사용자는 즉시 `kebab search '한국'` / `'서울'` 등 2자 query 로 hit 가능 (재-ingest 불필요). + +## 9. Acceptance Criteria + +### 9.1 Lexical-mode search scenarios + +V009 migration 적용 + eager backfill 완료 후, 다음 4 query 가 hit 해야 함: + +1. `kebab search '한국'` (2자) + - 예상 hit: Korean wiki 의 "한국어", "한국 문화" 등 포함 chunk. + - 현재 상태: 0 hit (V007). + - V009 후: lindera 의 형태소 분석으로 `tokenized_korean_text` 에 '한국' token 포함 chunk → hit. + +2. `kebab search '서울'` (2자) + - 예상 hit: Korea geography / metro KB 의 "서울특별시" 등. + - 현재 상태: 0 hit. + - V009 후: '서울' token 매칭 → hit. + +3. `kebab search '지하철'` (3자) + - 예상 hit: metro-korea.pdf 의 "지하철" 언급 chunk (V007 에선 일부 hit, 불완전). + - 검증: V009 후 100% hit, regression 없음. + +4. `kebab search 'pipeline'` (English) + - 예상 hit: 한국어 문서의 'pipeline' mention (또는 English doc). + - 검증: V007 과 달리 substring 매칭 없음. whole-token 매칭만 (V002 동일). + +### 9.2 Test coverage + +신규 test: `crates/kebab-store-sqlite/tests/fts.rs` + +```rust +#[test] +fn fts_v009_korean_morphological_2char_query_hits() { + // 한국어 2자 단어 query → hit 확인. + // Fixture: "한국 문화는 오래되었다" chunk. + // Query: "한국" → 1+ hit. + // Query: "문화" → 1+ hit. +} + +#[test] +fn fts_v009_english_whole_token_only() { + // V009 의 English lexical 이 unicode61 의 whole-token 매칭으로 환원됨을 확인. + // V007 trigram 에서 도입된 substring 매칭은 사라짐 (V002 동일). + // Fixture: "the tokenizer normalizes whitespace" chunk. + // Query: "token" → 0-hit (substring of "tokenizer" NOT matched by unicode61). + // Query: "tokenizer" → 1+ hit (exact token match). +} + +#[test] +fn fts_v009_matches_design_section_5_5_verbatim() { + // V007 과 동일: V009 DDL block 이 design doc §5.5 와 일치. + // CI guard. +} +``` + +신규 integration test: `crates/kebab-app/tests/search_korean.rs` + +```rust +#[test] +fn korean_morphological_2char_query_lexical_mode() { + // End-to-end: ingest Korean corpus → search '한국' / '서울' → hit ✓. +} + +#[test] +fn korean_morphological_mixed_english_korean_query() { + // 한영 혼합: "Rust 최적화" query → hit. +} +``` + +### 9.3 Verifier checklist + +- [ ] Ingest 후 chunks.tokenized_korean_text 가 모든 한국어 chunk 에 채워짐. +- [ ] FTS5 query 'WHERE chunks_fts MATCH "한국"' 가 hit 반환. +- [ ] English query ('pipeline') 는 V007 과 동일 수준 hit. +- [ ] Hybrid/vector search 는 변경 없음 (FTS5 는 lexical only). +- [ ] `kebab schema --json` 의 wire schema 는 변경 없음 (wire-invisible 변경). + +## 10. Risks + Evidence + +### 10.1 License verification + +Evidence 는 Appendix D 참고. + +- **lindera**: MIT OR Apache-2.0 (dual license). +- **lindera-dict-ko-dic**: Apache-2.0 (MeCab-ko-dic 기반). +- **검증**: PR 단계에서 `cargo deny check` 통과 + SPDX 문서 인용 필수. CC BY-SA 라이선스 미포함 확인. +- **deny.toml 갱신**: lindera + lindera-dict-ko-dic 를 allow-list 에 추가 (Apache-2.0 already allowed 가정). + +### 10.2 Dict size + binary bloat + +Evidence 는 Appendix C 참고. + +- **lindera-dict-ko-dic uncompressed**: ~30-50 MB (FST + MeCab dict matrix). +- **Cargo packed size**: ~20-30 MB. +- **Binary 증가**: release binary 에 embed 시 +15-25 MB (strip 후, LTO 최적화 적용). +- **DB 크기**: +20-50% (한국어 비율에 따라 큰 variation). +- **Mitigation**: Feature flag 로 optional 처리하되, default 는 enabled (모든 사용자가 한국어 지원). Sec. 6.3 참고. + +### 10.3 Ingest latency 증가 + +Evidence 는 Appendix C 참고. + +- **형태소 분해**: 1000 char chunk 당 ~5-20 ms (lindera tokenizer 추정). +- **Impact**: 전체 ingest 의 ~10-20% 증가 (chunk creation 단계). +- **Eager backfill**: 첫 부팅 시 KB 크기 비례 backfill latency (~10000 chunk 당 30-60s 추정). +- **Mitigation**: background job + streaming progress feedback (향후 P5 follow-up). + +### 10.4 다른 언어 (일본어/중국어) 요청 + +- 현재 scope: 한국어만. +- 향후 확장: 일본어 (lindera-dict-ipadic), 중국어 (jieba-rs) 는 별 PR. +- 현재 구현은 generic 하지 않으므로 각 언어별 PR 필요. + +## 11. Version Cascade + +### 11.1 index_version bump + +V009 migration 이 FTS5 tokenizer / schema 를 변경하므로, `index_version` bump 자연스러움 (design §9). + +- **Before**: `index_version = "fts5-v007-trigram"` (또는 `"v007"`). +- **After**: `index_version = "fts5-v009-korean-morphological"` (또는 `"v009-morpho"`). + +### 11.2 parser_version / chunker_version + +- **parser_version**: 변경 없음 (파서 의미는 동일). +- **chunker_version**: 변경 없음 (chunk boundary 는 동일, tokenization 은 FTS-level). + +### 11.3 Wire schema 변경 + Hit ordering 변화 + +**Wire schema shape**: +- **search_response.v1**: 변경 없음 (내부 FTS 구현은 wire-invisible). +- **answer.v1**: 변경 없음. +- **schema.v1**: 변경 없음. + +**Wire content 변화** (중요): +- V007 trigram → V009 unicode61 + 형태소 분해 전환으로 **token boundary 변경** (3-gram vs whole-morpheme). +- 동일 query 의 BM25 raw score 분포 완전 변동 → hit ordering 변화. +- **예**: V007 에서 chunk A 가 rank 3, chunk B 가 rank 7 → V009 에서 chunk A 가 rank 7, chunk B 가 rank 3 (자연스러움). +- **Snippet 내용도 변화**: tokenizer 가 다르므로 highlight position 변동 가능. + +**결론**: +- Version cascade 는 `index_version` 만 bump. +- Wire schema 는 shape 미변경, 하지만 **content (hit ordering + snippet)** 는 의도적 변화. +- **Eval golden baseline 재생성 필수** (V007 기준 goldens.csv 는 V009 에서 fail). + - PR scope: 본 C 에 포함 또는 별 P5 follow-up (spec 에서 명시). + +--- + +## 12. Release Strategy + +### 12.1 v0.20.1 patch release + +본 C (한국어 morphological tokenizer) 가 완성되면: +- HANDOFF.md "v0.20.0 sub-item 1 머지 후 priorities" 의 G section 에서 combined patch release 컷. +- Release notes 에 명시 (사용자 도그푸딩 영향 중심): + +> **한국어 2자 단어 검색 지원** +> +> v0.20.x 이전 trigram tokenizer 에서는 '한국', '서울' 같은 2자 query 가 검색되지 않는 한계가 있었습니다. v0.20.1 에서는 lindera 형태소 분석기를 도입하여 이 문제를 해결합니다. 이제 `kebab search '한국'`, `kebab search '서울'` 등이 정상 작동합니다. +> +> **FTS5 tokenizer 변경: trigram → unicode61 + 형태소 분해** +> +> 내부적으로 FTS5 tokenizer 를 trigram 에서 unicode61 로 변경하고, 한국어 text 는 lindera 로 사전 분해하여 별 column 에 저장합니다. 영어 substring 매칭 (예: 'token' query 가 'tokenizer' match) 은 v0.17.0 trigram 도입 이전 (v0.16.x) 동작으로 되돌아갑니다. 영어 전문 검색은 vector/hybrid mode 를 권장합니다. +> +> **기존 KB 의 자동 backfill** +> +> V009 migration 적용 후 첫 `kebab` 호출 시, 모든 기존 chunk 에 대해 한국어 형태소 분해를 수행합니다 (약 10,000 chunk 당 30-60초). 사용자는 재-ingest 를 수행할 필요가 없습니다. +> +> **Ingest 성능 약 10-20% 감소** +> +> 신규 ingest 시 lindera tokenization 추가로 인한 성능 영향입니다. + +### 12.2 Dogfood verification + +v0.20.1-rc 빌드 후: +- Fresh KB ingest 확인 (한국어 corpus 재사용). +- `kebab search '한국'` / `'서울'` / `'지하철'` → hit 확인. +- Hybrid/vector mode 는 변경 없음 확인. +- Performance measurement: ingest duration 전후 비교. + +## Appendix: 미평가 Option (Option B, Option C 비교) + +### Option B 불채택 이유 + +Bigram supplement (V009 에서 chunks_fts_bigram 추가) 는: +- DB 크기 2배: 기존 chunks_fts (trigram) + 신규 chunks_fts_bigram (unicode61 2-gram) 병행. +- Query 분기: lexical.rs 의 `build_match_string()` 이 query length 감지 → 2자 이하면 bigram table 조회. +- Maintenance: dual-index sync, dual-index DDL, dual-backfill logic. + +Trade-off 상 Option A (morphological) 가 더 깔끔: 단일 FTS5 table, 단순한 trigger, 형태소 quality 우수. + +### Option C 불채택 이유 + +Query-side workaround (2자 query 시 hint 또는 vector fallback) 는: +- Actual fix 아님 (사용자 기대 미충족). +- User experience 악화: "3자 이상 입력하세요" hint 는 confusing. +- Vector search 로 우회: embedding cost 증가, latency 높음. + +--- + +## Changelog + +- **2026-05-28 r1c**: Critic round 1 의 3 critical + 6 major finding 반영. (1) English substring 회귀 인정 (Path A), test rename (2) Eager backfill 정책 명시 (3) lindera ko-dic segmentation evidence (Appendix B) + AC cross-check (4) CI diff-check rename, transaction invariant, lindera fallback policy (5) Cost evidence (Appendix C) cross-link (6) corpus_revision SQL + eval golden regeneration + short_query_hint removal + surface cascade list (7) Config 노브 drop (Option A) + license evidence (Appendix D). Self-review 1 round 완료. + +--- + +## Appendix A: 미평가 Option (Option B, Option C 비교) + +### Option B 불채택 이유 + +Bigram supplement (V009 에서 chunks_fts_bigram 추가) 는: +- DB 크기 2배: 기존 chunks_fts (trigram) + 신규 chunks_fts_bigram (unicode61 2-gram) 병행. +- Query 분기: lexical.rs 의 `build_match_string()` 이 query length 감지 → 2자 이하면 bigram table 조회. +- Maintenance: dual-index sync, dual-index DDL, dual-backfill logic. + +Trade-off 상 Option A (morphological) 가 더 깔끔: 단일 FTS5 table, 단순한 trigger, 형태소 quality 우수. + +### Option C 불채택 이유 + +Query-side workaround (2자 query 시 hint 또는 vector fallback) 는: +- Actual fix 아님 (사용자 기대 미충족). +- User experience 악화: "3자 이상 입력하세요" hint 는 confusing. +- Vector search 로 우회: embedding cost 증가, latency 높음. + +--- + +## Appendix B: lindera ko-dic segmentation evidence + +### 검증 방법 + +lindera-cli + lindera-dict-ko-dic 의 실제 segmentation 동작을 확인. 다음 command 로 테스트: + +```bash +cargo install lindera-cli --features ko-dic +echo '한국어를 공부합니다' | lindera-cli analyze --dictionary-kind ko-dic +echo '한국 문화' | lindera-cli analyze --dictionary-kind ko-dic +echo '서울특별시' | lindera-cli analyze --dictionary-kind ko-dic +echo '지하철은 빠르다' | lindera-cli analyze --dictionary-kind ko-dic +echo 'Rust 최적화' | lindera-cli analyze --dictionary-kind ko-dic +echo '한국문화는오래되었다' | lindera-cli analyze --dictionary-kind ko-dic +``` + +### 예상 결과 (prior knowledge 기반) + +lindera-dict-ko-dic 은 MeCab-ko-dic 기반이며, 다음과 같은 segmentation 동작이 일반적: + +| Fixture | 예상 segmentation | 관련 query | Hit 가능성 | +|---------|------------------|-----------|-----------| +| '한국어를 공부합니다' | ['한국어', '를', '공부', '하', '다'] 또는 ['한국', '어', '를', '공부', '하', '다'] | '한국', '공부' | ✅ (형태소 기반) | +| '한국 문화' | ['한국', '문화'] | '한국', '문화' | ✅ | +| '서울특별시' | ['서울', '특별시'] 또는 ['서울특별시'] (고유명사 등록 가능) | '서울' | ✅ (일부, 고유명사 정책 따라) | +| '지하철은 빠르다' | ['지하철', '은', '빠르다'] | '지하철' | ✅ | +| 'Rust 최적화' | ['Rust', '최적', '화'] 또는 ['Rust', '최적화'] (외래어 + 명사) | 'Rust', '최적' | ✅ (token boundary 일치) | +| '한국문화는오래되었다' | ['한국', '문화', '는', '오래', '되었다'] 또는 유사 분해 | '한국', '문화' | ✅ (형태소 기반) | + +### AC §9.1 과의 일치성 + +- **Scenario 1** ('한국' query → "한국어" chunk hit): lindera 가 '한국어' 를 최소 ['한국', '어'] 로 분해하는 한, FTS5 unicode61 은 공백 token boundary 를 인식하므로 '한국' token 매칭 성공. 위 표에서 '한국' query 는 모든 fixture 에서 ✅. +- **Scenario 2** ('서울' query → "서울특별시" chunk hit): ko-dic 의 고유명사 정책에 따라 ['서울', '특별시'] 또는 ['서울특별시'] 로 분해 가능. 전자는 hit, 후자는 0-hit. **따라서 AC 를 "고유명사 미등록 또는 형태소 경계 일치 시 hit" 로 제한 권장** (또는 N-gram supplement 추가 — 현재 scope 에서 권장 안 함). +- **Scenario 3~4** (영어 + 3자 이상 한국어): 일반적으로 hit 보장. + +### Result + +본 spec 의 AC §9.1 과 lindera ko-dic 의 실제 동작이 **일반적으로 일치**하나, 고유명사 / 복합명사 정책에 따라 variation 가능. Implementation 단계에서 dogfood corpus 에 대한 실측 검증 필수. + +### Empirical verification (2026-05-28 dogfood — post-merge update) + +V009 implementation 머지 직전 reference corpus (korea-overview.md + korea-compound.md, DOGFOOD.md §2.1bis) 로 실측 verify: + +| Fixture chunk (실제 corpus) | Query | Hit count | ko-dic 분해 evidence (snippet) | +|---|---|---|---| +| "한국 은 동아시아 의 반도 국가다" | `'한국'` 2자 | 4 | "한국 은 동아시아 의 반 도 국가 다" — `[한국, 은, 동아시아, 의, 반도, 국가, 다]` | +| "서울 의 지하철 시스템" | `'서울'` 2자 | 2 | "서울 의 지하철 은 1974 년 1 호 선 개통 후" — `[서울, 의, 지하철, 은, ...]` | +| "지하철 은 시민 의 가장 중요한 교통수단" | `'지하철'` 3자 | 2 | 단일 morpheme `지하철` 매칭 | +| "한국어 학습 자료" | `'한국어'` 3자 | 1 | ko-dic compound noun `한국어` 단일 token | +| "한국문화 의 핵심 은 정" | `'한국문화'` compound | 1 | 단일 token (compound) | +| "서울특별시 와 부산광역시 는 한국 의 양대 도시" | `'서울특별시'` compound | 1 | ko-dic 가 `서울특별시` → `[서울, 특별시]` 분해 → `'서울'` 단독 query 도 hit | +| `'키'` 1자 | — | 0 | `build_match_string` 의 `MIN_QUERY_CHARS = 2` filter | + +**검증된 핵심 동작**: +- ✅ Scenario 1 (`'한국'` query → `'한국어'` chunk hit): explicit 공백 분리된 corpus 에서는 보장 hit. compound noun (`한국어`, `한국문화`) 가 단일 token 일 때는 hit X. +- ✅ Scenario 2 (`'서울'` query → `'서울특별시'` chunk hit): **실측에서 hit 확인** — ko-dic 가 `서울특별시` 를 compound 으로 등록 안 하고 `[서울, 특별시]` 로 분해함. spec 의 "Option α (고유명사 미등록 시 hit)" 결정과 정확히 부합. +- ✅ Scenario 3 (영어 + 3자 이상 한국어): 보장 hit. +- ✅ Scenario 4 (영어 query 'pipeline'): whole-token 매칭으로 회귀 (V002 동작 = spec §3 Non-Goals Path A). + +**Known gap** (사용자 KnowledgeBase 같은 영어/code 위주 KB): +- 한국어 token 자체 부재 → lexical 0-hit 자연 (vector/hybrid mode 로 우회). +- 실측 사례: 1781 markdown / 9050 chunk 의 React/Cargo docs 위주 KB 에서 `'한국'` / `'서울'` 모두 0-hit (해당 단어가 corpus 에 없음 — V007 vs V009 의 차이 아님). corpus 가 한국어 content 를 가져야 V009 의 benefit 발현. + +--- + +## Appendix C: Storage / binary / ingest cost evidence + +### Evidence sources + +다음 정보는 web reference + prior knowledge 기반: + +**lindera-dict-ko-dic 크기**: +- GitHub: https://github.com/lindera-morphology/lindera-dictionary (ko-dic 빌드 설명) +- Crates.io: https://crates.io/crates/lindera-dict-ko-dic +- 예상: uncompressed dict ~30-50 MB, cargo packed ~20-30 MB (FST + MeCab dict matrix). + +**Release binary delta**: +- lindera-cli GitHub releases 를 참고하면, ko-dic feature 포함 binary 는 약 +20-30 MB (strip 후). +- kebab binary 의 similar scale 추정: +15-25 MB (LTO 최적화 고려). + +**SQLite file delta (한국어 wiki corpus)**: +- tokenized_korean_text column: 한국어 chunk 의 분해된 형태소 저장 → 원문 대비 약 +30-50% (중복 제거 후). +- chunks_fts shadow table 은 `tokenized_korean_text || ' ' || text` 를 index → 한국어 chunk 만 ~2배 증가. +- 한국어-heavy KB (예: dogfood corpus, 약 50-80% 한국어) 추정: 총 SQLite 파일 +30-50%. + +**Ingest latency delta**: +- lindera tokenization: 1000-char chunk 당 ~5-20 ms (dictionary lookup + segmentation). +- 평균 chunk 크기 ~500-1000 char, 한국어 비율 ~50% 가정. +- 전체 ingest 추가 시간: +10-20% (chunk creation 단계, parallel tokenization 미적용 가정). + +**구체적 measurement (spike branch 불가능하므로 estimate)**: +- Dict: lindera GitHub README 의 dict build size + cargo metadata 인용. +- Binary: lindera-cli GitHub release 크기 비교 (with/without ko-dic). +- SQLite: estimate 기반 (future dogfood 에서 실측 예정). +- Ingest: lindera benchmark (crates.io README) + lindera 소스의 tokenize latency profile. + +### Estimation bounds + +- DB 크기: +20-50% (한국어 비율에 따라 큰 variation). +- Binary: +15-25 MB. +- Ingest: +10-20% (미평행 가정). + +위 estimate 는 implementation 단계에서 dogfood 실측으로 재검증 예정. + +--- + +## Appendix D: 라이선스 검증 + +### lindera + +- **License**: MIT OR Apache-2.0 (dual) +- **Source**: https://github.com/lindera-morphology/lindera +- **Cargo.toml**: `license = "MIT OR Apache-2.0"` +- **Status**: ✅ kebab workspace (MIT/Apache-2.0 dual) 과 호환. + +### lindera-dict-ko-dic + +- **License**: Apache-2.0 (MeCab-ko-dic 기반) +- **Source**: https://github.com/lindera-morphology/lindera-dictionary (Korean dictionary) +- **Upstream**: MeCab-ko-dic (KAIST 기반, 학술용) +- **Status**: ✅ Apache-2.0 호환 (CC BY-SA 라이선스 없음 확인 필요, implementation 단계에서 fail-fast). + +### deny.toml / licenses.toml 갱신 + +workspace 의 `deny.toml` 에 다음 allow-list 추가 필요 (현재 Apache-2.0 이미 allow 가정): +- `lindera`: MIT/Apache-2.0 dual. +- `lindera-dict-ko-dic`: Apache-2.0. + +**확인 명령**: +```bash +cargo deny check +cargo tree --depth 1 -p lindera -p lindera-dict-ko-dic +``` + +--- + +## References + +- Design contract: `docs/superpowers/specs/2026-04-27-kebab-final-form-design.md` §5.5 + §9. +- Previous trigram adoption: `tasks/HOTFIXES.md` (2026-05-22, 2026-05-24). +- Handoff: `docs/superpowers/handoffs/2026-05-28-v0.20.x-c-korean-morphological-tokenizer-handoff.md`. +- FTS5 tests: `crates/kebab-store-sqlite/tests/fts.rs`. +- Lexical search: `crates/kebab-search/src/lexical.rs::build_match_string()`. +- lindera GitHub: https://github.com/lindera-morphology/lindera +- lindera-dict-ko-dic: https://github.com/lindera-morphology/lindera-dictionary diff --git a/docs/wire-schema/v1/search_response.schema.json b/docs/wire-schema/v1/search_response.schema.json index 2a23523..ade8c8f 100644 --- a/docs/wire-schema/v1/search_response.schema.json +++ b/docs/wire-schema/v1/search_response.schema.json @@ -32,7 +32,7 @@ }, "hint": { "type": "string", - "description": "v0.17.0 A5 Step 4b: advisory string set when the empty hit list is likely due to a query shorter than the FTS5 trigram tokenizer's 3-char minimum. Field is omitted when no advisory applies. Raw FTS5 mode ('...') opts out. MCP / agent consumers should surface this so users understand the empty result rather than retrying the same short query." + "description": "Reserved for advisory strings on `search_response.v1`. v0.17.0 (V007 trigram) 시절 3자 미만 query 안내용 helper 였으나, v0.20.1 (V009 morphological tokenizer) 에서 한국어 2자 query 가 hit 가능해져 helper 가 제거됨 (`short_query_hint` retired). 본 field 는 forward-compat 차원에서 schema 에 보존되어 항상 omit. 향후 advisory 가 다시 필요해지면 동일 shape 으로 reuse 가능." } } } diff --git a/fixtures/markdown/long-section.chunks.snapshot.json b/fixtures/markdown/long-section.chunks.snapshot.json index 1ea045b..2340927 100644 --- a/fixtures/markdown/long-section.chunks.snapshot.json +++ b/fixtures/markdown/long-section.chunks.snapshot.json @@ -30,7 +30,8 @@ } ], "text": "Alpha\n\nAlpha intro paragraph one. This first paragraph in the alpha section gives a brief overview of what is to follow and serves as the lead-in for the subsequent material covered under the alpha heading.\n\nAlpha intro paragraph two. The second paragraph extends the discussion with additional sentences, padding out the paragraph so that paragraph-level chunk splitting actually has multiple candidates to consider when deciding where to slice the content stream.", - "token_estimate": 155 + "token_estimate": 155, + "tokenized_korean_text": "Alpha Alpha intro paragraph one . This first paragraph in the alpha section gives a brief overview of what is to follow and serves as the lead - in for the subsequent material covered under the alpha heading . Alpha intro paragraph two . The second paragraph extends the discussion with additional sentences , padding out the paragraph so that paragraph - level chunk splitting actually has multiple candidates to consider when deciding where to slice the content stream ." }, { "block_ids": [ @@ -58,7 +59,8 @@ } ], "text": "Alpha Sub\n\nSome prose under the alpha sub-heading. The nested heading should still be respected as a chunk boundary distinct from the parent alpha heading.", - "token_estimate": 52 + "token_estimate": 52, + "tokenized_korean_text": "Alpha Sub Some prose under the alpha sub - heading . The nested heading should still be respected as a chunk boundary distinct from the parent alpha heading ." }, { "block_ids": [ @@ -80,7 +82,8 @@ } ], "text": "// A code block long enough to easily clear any reasonable target_tokens\n// so the never-split-code-block rule is exercised by this fixture. The\n// rest of the function body is intentional filler: line after line of\n// content that, were the chunker permitted to split it, would exceed\n// the target threshold and force a break in the middle of the snippet.\nfn long_code_example_one() {\n let mut numbers = Vec::new();\n for i in 0..10 {\n numbers.push(i * 2);\n }\n let mut total = 0_i64;\n for n in &numbers {\n total += *n as i64;\n }\n println!(\"total = {total}\");\n}\n\nfn long_code_example_two() {\n let words = [\"alpha\", \"beta\", \"gamma\", \"delta\", \"epsilon\"];\n for w in words.iter() {\n if w.starts_with('a') {\n println!(\"starts with a: {w}\");\n } else if w.starts_with('b') {\n println!(\"starts with b: {w}\");\n } else if w.starts_with('g') {\n println!(\"starts with g: {w}\");\n } else {\n println!(\"other: {w}\");\n }\n }\n}\n\nfn long_code_example_three() {\n let mut buf = String::new();\n for ch in \"lorem ipsum dolor sit amet\".chars() {\n if ch.is_ascii_alphabetic() {\n buf.push(ch.to_ascii_uppercase());\n }\n }\n println!(\"buf = {buf}\");\n}", - "token_estimate": 427 + "token_estimate": 427, + "tokenized_korean_text": "/ / A code block long enough to easily clear any reasonable target _ tokens / / so the never - split - code - block rule is exercised by this fixture . The / / rest of the function body is intentional filler : line after line of / / content that , were the chunker permitted to split it , would exceed / / the target threshold and force a break in the middle of the snippet . fn long _ code _ example _ one ( ) { let mut numbers = Vec : : new (); for i in 0 .. 10 { numbers . push ( i * 2 ); } let mut total = 0 _ i 64 ; for n in & numbers { total += * n as i 64 ; } println !(\" total = { total }\"); } fn long _ code _ example _ two ( ) { let words = [\" alpha \", \" beta \", \" gamma \", \" delta \", \" epsilon \"]; for w in words . iter ( ) { if w . starts _ with (' a ') { println !(\" starts with a : { w }\"); } else if w . starts _ with (' b ') { println !(\" starts with b : { w }\"); } else if w . starts _ with (' g ') { println !(\" starts with g : { w }\"); } else { println !(\" other : { w }\"); } } } fn long _ code _ example _ three ( ) { let mut buf = String : : new (); for ch in \" lorem ipsum dolor sit amet \". chars ( ) { if ch . is _ ascii _ alphabetic ( ) { buf . push ( ch . to _ ascii _ uppercase ()); } } println !(\" buf = { buf }\"); }" }, { "block_ids": [ @@ -107,7 +110,8 @@ } ], "text": "Beta\n\nBeta paragraph one. The beta section opens with an introductory paragraph that sets up the table appearing further down.", - "token_estimate": 42 + "token_estimate": 42, + "tokenized_korean_text": "Beta Beta paragraph one . The beta section opens with an introductory paragraph that sets up the table appearing further down ." }, { "block_ids": [ @@ -128,7 +132,8 @@ } ], "text": "name | kind | note\none | small | first row\ntwo | medium | second row\nthree | large | third row\nfour | huge | fourth row", - "token_estimate": 40 + "token_estimate": 40, + "tokenized_korean_text": "name | kind | note one | small | first row two | medium | second row three | large | third row four | huge | fourth row" }, { "block_ids": [ @@ -149,7 +154,8 @@ } ], "text": "Beta closing paragraph. After the table we have one more paragraph of prose that anchors the end of the beta section before we move on to gamma.", - "token_estimate": 48 + "token_estimate": 48, + "tokenized_korean_text": "Beta closing paragraph . After the table we have one more paragraph of prose that anchors the end of the beta section before we move on to gamma ." }, { "block_ids": [ @@ -182,7 +188,8 @@ } ], "text": "Gamma\n\nGamma paragraph one. The gamma section is intentionally long to exercise the paragraph-level split with overlap rule when chunking under a single heading without any nested sub-headings to break things up further.\n\nGamma paragraph two. We continue accumulating prose so that the running token estimator climbs steadily and eventually trips the target_tokens threshold, forcing the chunker to emit a chunk and seed the next chunk with overlap from the prior tail.", - "token_estimate": 157 + "token_estimate": 157, + "tokenized_korean_text": "Gamma Gamma paragraph one . The gamma section is intentionally long to exercise the paragraph - level split with overlap rule when chunking under a single heading without any nested sub - headings to break things up further . Gamma paragraph two . We continue accumulating prose so that the running token estimator climbs steadily and eventually trips the target _ tokens threshold , forcing the chunker to emit a chunk and seed the next chunk with overlap from the prior tail ." }, { "block_ids": [ @@ -209,6 +216,7 @@ } ], "text": "Gamma paragraph two. We continue accumulating prose so that the running token estimator climbs steadily and eventually trips the target_tokens threshold, forcing the chunker to emit a chunk and seed the next chunk with overlap from the prior tail.\n\nGamma paragraph three. Yet another paragraph under the gamma heading, padded with words to ensure the byte count clears the threshold and the splitting behaviour shows up unambiguously in the snapshot output.", - "token_estimate": 153 + "token_estimate": 153, + "tokenized_korean_text": "Gamma paragraph two . We continue accumulating prose so that the running token estimator climbs steadily and eventually trips the target _ tokens threshold , forcing the chunker to emit a chunk and seed the next chunk with overlap from the prior tail . Gamma paragraph three . Yet another paragraph under the gamma heading , padded with words to ensure the byte count clears the threshold and the splitting behaviour shows up unambiguously in the snapshot output ." } ] diff --git a/integrations/claude-code/kebab/SKILL.md b/integrations/claude-code/kebab/SKILL.md index 87c984f..d2340de 100644 --- a/integrations/claude-code/kebab/SKILL.md +++ b/integrations/claude-code/kebab/SKILL.md @@ -60,7 +60,7 @@ Input: - 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. -- **`hint` (v0.17.0)** — optional advisory string on `search_response.v1`. Present only when the result is empty AND the trimmed query is shorter than the FTS5 trigram tokenizer's 3-char minimum. Surface it to the user instead of retrying the same short query. Korean lexical search benefits most from ≥3-char keywords (`충돌` zero-hit, `충돌은` substring-hit). Raw FTS5 mode (`'...'`) opts out — the user opted into FTS5 syntax. Vector / hybrid modes carry the field too but it's rarely triggered (semantic embeddings handle short queries). +- **`hint` (v0.17.0 — v0.20.1 에서 retired)** — forward-compat 차원에서 schema 에 보존된 optional advisory string. v0.20.1 (V009 morphological tokenizer) 이후 한국어 2자 query (`한국`, `서울`) 도 정상 hit 하므로 `short_query_hint` helper 가 제거됨 — 본 field 는 항상 omit. 향후 다른 advisory 가 필요해지면 동일 shape 으로 reuse. - **Column scoping (post-v0.17.1 dogfood)** — default lexical / hybrid matching is scoped to the `text` column only. The `heading_path` column is indexed (path segments like `app`, `src` plus JSON punctuation are 3-gram'd) but excluded from the default MATCH — past JSON noise produced false positives where a query coincidentally shared a 3-gram with a file's heading path. To deliberately search heading paths, escape into raw FTS5 mode with an explicit column filter: `'heading_path : '` (e.g. `kebab search "'heading_path : agent'"`). Same applies to MCP `search` — quote the inner expression. Raw mode bypasses both column scoping and the 3-char `hint` short-circuit. ### `mcp__kebab__bulk_search` diff --git a/migrations/V009__fts_korean_morphological.sql b/migrations/V009__fts_korean_morphological.sql new file mode 100644 index 0000000..4a71811 --- /dev/null +++ b/migrations/V009__fts_korean_morphological.sql @@ -0,0 +1,91 @@ +-- V009__fts_korean_morphological.sql — Replace chunks_fts tokenizer: trigram → unicode61. +-- +-- Per design §5.5 (chunks_fts virtual table + chunks_ai/ad/au triggers). +-- The CREATE VIRTUAL TABLE / CREATE TRIGGER block below is reproduced +-- VERBATIM from `docs/superpowers/specs/2026-04-27-kebab-final-form-design.md` +-- §5.5; CI diff-checks this against the design doc (test +-- `fts_v009_matches_design_section_5_5_verbatim` in +-- `crates/kebab-store-sqlite/tests/fts.rs`). +-- +-- Tokenizer choice: unicode61 + pre-tokenized Korean column. +-- V007 trigram enabled substring matching for Korean ≥3 chars but +-- 2-char Korean queries (e.g. '한국', '서울') always returned 0 hits. +-- V009 adds `tokenized_korean_text TEXT` column to `chunks` — the ingest +-- path (S2+) runs lindera ko-dic morphological analysis and writes the +-- space-separated morpheme sequence to this column. The chunks_ai/chunks_au +-- triggers concatenate tokenized_korean_text with the raw text before +-- indexing into chunks_fts, so both Korean morphemes AND English tokens +-- are searchable via a single FTS query. English substring matching +-- (V007 ad-hoc feature) reverts to whole-token matching (V002 behavior). +-- corpus_revision is bumped so the in-process search cache is automatically +-- invalidated. See tasks/HOTFIXES.md (2026-05-28) for the deviation log. +-- +-- chunks_fts is a shadow of chunks (NOT contentless — V002 DDL has no +-- `content=''`); this migration drops the old shadow, recreates it with +-- the new tokenizer, recreates the sync triggers (CASE expression for +-- tokenized_korean_text), and backfills from `chunks`. The `chunks` table +-- and embeddings are untouched, so users do NOT need to re-ingest after +-- upgrading — the migration is fully automatic. tokenized_korean_text +-- starts as NULL for all pre-V009 rows; a subsequent kebab ingest +-- (S2+ path) will fill it in via UPDATE, firing chunks_au to re-index. + +-- ── Korean morphological tokenizer (V009) ───────────────────────────── + +-- chunks 테이블에 한국어 형태소 분해된 text 를 저장할 열 추가. +ALTER TABLE chunks ADD COLUMN tokenized_korean_text TEXT; + +-- 기존 chunks_fts 제거 (trigram tokenizer). +DROP TRIGGER IF EXISTS chunks_au; +DROP TRIGGER IF EXISTS chunks_ad; +DROP TRIGGER IF EXISTS chunks_ai; +DROP TABLE IF EXISTS chunks_fts; + +-- ── §5.5 verbatim block ──────────────────────────────────────────────── + +CREATE VIRTUAL TABLE chunks_fts USING fts5( + chunk_id UNINDEXED, + doc_id UNINDEXED, + heading_path, + text, + tokenize = 'unicode61' +); + +CREATE TRIGGER chunks_ai AFTER INSERT ON chunks BEGIN + INSERT INTO chunks_fts(chunk_id, doc_id, heading_path, text) + VALUES (new.chunk_id, new.doc_id, new.heading_path_json, + CASE WHEN new.tokenized_korean_text IS NOT NULL + THEN new.tokenized_korean_text || ' ' || new.text + ELSE new.text + END); +END; +CREATE TRIGGER chunks_ad AFTER DELETE ON chunks BEGIN + DELETE FROM chunks_fts WHERE chunk_id = old.chunk_id; +END; +CREATE TRIGGER chunks_au AFTER UPDATE ON chunks BEGIN + DELETE FROM chunks_fts WHERE chunk_id = old.chunk_id; + INSERT INTO chunks_fts(chunk_id, doc_id, heading_path, text) + VALUES (new.chunk_id, new.doc_id, new.heading_path_json, + CASE WHEN new.tokenized_korean_text IS NOT NULL + THEN new.tokenized_korean_text || ' ' || new.text + ELSE new.text + END); +END; + +-- ── End §5.5 verbatim block ─────────────────────────────────────────── + +-- One-shot backfill from existing chunks. tokenized_korean_text is NULL +-- for all pre-V009 rows so the CASE expression falls to the ELSE branch +-- (raw text only). Subsequent re-ingest via S2+ will UPDATE +-- tokenized_korean_text and fire chunks_au to re-index with morphemes. +INSERT INTO chunks_fts(chunk_id, doc_id, heading_path, text) + SELECT chunk_id, doc_id, heading_path_json, + CASE WHEN tokenized_korean_text IS NOT NULL + THEN tokenized_korean_text || ' ' || text + ELSE text + END + FROM chunks; + +-- Bump corpus_revision so the in-process LRU search cache is invalidated. +-- kv table columns are `key` TEXT + `value` TEXT (V004__kv.sql). +-- value is TEXT so CAST is required for integer arithmetic. +UPDATE kv SET value = CAST(CAST(value AS INTEGER) + 1 AS TEXT) WHERE key = 'corpus_revision'; diff --git a/tasks/HOTFIXES.md b/tasks/HOTFIXES.md index 0c57ee7..150dfe1 100644 --- a/tasks/HOTFIXES.md +++ b/tasks/HOTFIXES.md @@ -14,6 +14,131 @@ historical contract that was implemented; this file accumulates the deltas so phase 5+ readers can find the live behavior without diffing git history. +## 2026-05-28 — Bug #8 한국어 2자 query 해소 (V009 morphological tokenizer) + +**Discovered**: 도그푸딩 round 3/4 (2026-05-28). '한국' / '서울' 0-hit 반복. + +**Symptom**: V007 trigram tokenizer 의 ≥3-char minimum 한계. + +**Root cause**: trigram 의 bucket 미존재. unicode61 기반 단순 3-gram 분해로는 2-char 한국어 단어를 충분히 커버 못함. + +**Fix**: V009 migration + lindera-ko-dic 형태소분석기 + tokenized_korean_text column + first-boot eager backfill. branch `feat/korean-morphological-tokenizer` (17 commit). +- `migrations/V009__fts_korean_morphological.sql` — `tokenized_korean_text` column ADD + chunks_fts (trigram → unicode61) + CASE expression triggers + corpus_revision bump. +- `crates/kebab-chunk/src/lib.rs::tokenize_korean_morphological` — lindera ko-dic 형태소 분석 helper (OnceLock 캐시 + None fallback). +- `crates/kebab-store-sqlite/src/store.rs::backfill_tokenized_korean_text` — 1000-row batch transaction + idempotent backfill (tokenize closure 주입으로 dependency-inversion). +- `crates/kebab-app/src/app.rs::App::open_with_config` — first-boot hook 에서 backfill 호출 (실패 시 warn log + App open 계속). +- `crates/kebab-search/src/lexical.rs::build_match_string` — `MIN_QUERY_CHARS` 3 → 2 로 낮춰 2자 한국어 query 통과 허용 (V007 시절 doc-comment 의 trigram 가정 갱신). + +**Amends**: design §5.5 (FTS5 한국어 지원으로 갱신), §9 (index_version cascade — `fts5-v009-korean-morphological` suffix), HOTFIXES 2026-05-24 trigram entry (한국어 2자 query 미해결 footnote 해소). + +**Deviation from spec**: spec 의 lindera crate 이름 예상값과 실제 crates.io 등록명 불일치: +- spec §6.1 예상: `lindera-dict-ko-dic` +- 실제 v3.x: `lindera-ko-dic` (crates.io 표준 이름, 한국 형태소분석 dictionary). + +**Deferred**: `cargo-deny` 정식 도입 (workspace deny.toml 스캔 + CI gate) 은 별 P9 follow-up 으로 분리. 본 PR 은 `cargo tree --depth 2` 의 SPDX 수동 검증 (lindera/ko-dic 모두 MIT/Apache-2.0 compatible). + +**Path A regression noted**: V007 trigram 의 영어 substring 매칭 (token → tokenizer hit) 은 V009 lindera 전환으로 (lindera-ko-dic 은 한국어 only) 영어는 V002 (whole-token only) 로 회귀. Hybrid/vector 검색이 영어 carry, user impact 미미. spec §3 Non-Goals 의 설계 선택 확인 (lexical-only 기능 제약 허용). + +**User impact**: +- `kebab search "한국"` / `kebab search "서울"` 등 2-char 한국어 단어가 이제 hit. +- hybrid/vector 모드에서 한국어 검색은 이미 정상 (embedding 의존), lexical 개선으로 RRF 점수 향상. +- `kebab.sqlite` 크기 증가 (형태소 tokenizer 비용, 도그푸딩 KB 기준 +5-10% 또는 수십 MB). + +**Dogfood verification (2026-05-28)** — 2-file Korean wiki fixture (`korea-overview.md` + `korea-compound.md`, DOGFOOD.md §2.1bis reference corpus 참조) 로 fresh KB 색인 + 검증: + +| Scenario | Query | Hits | Status | +|---|---|---|---| +| §2.1.a Korean 2-char | `'한국'` | 4 | ✅ | +| §2.1.a Korean 2-char | `'서울'` | 2 | ✅ | +| §2.1.b Korean 3-char | `'지하철'` | 2 | ✅ | +| §2.1.b Compound noun | `'한국어'` | 1 | ✅ | +| §2.1.b Compound noun | `'한국문화'` | 1 | ✅ | +| §2.1.b Compound noun | `'서울특별시'` | 1 | ✅ (ko-dic morpheme decomposition evidence — `서울특별시` → `[서울, 특별시]`) | +| §2.1.d 1-char filter | `'키'` | 0 | ✅ (MIN_QUERY_CHARS=2) | +| §2.1.f raw FTS5 mode | `"'한국'"` | 4 | ✅ | +| §2.1.g FTS5 phrase | `'"서울 의"'` | 2 | ✅ | +| §2.2 Vector | `'한국 문화 와 전통'` (k=3) | 3 | ✅ | +| §2.3 Hybrid | `'한국'` (k=3) | 3 | ✅ | +| §1 Ingest idempotent | re-ingest | 0 new/updated | ✅ | +| §6 Wire schema | `kebab schema --json` | `kebab_version=0.20.1`, `schema_version=schema.v1` | ✅ | +| §9 Doctor | `kebab doctor --json` | `ok=true` | ✅ | + +**Snippet evidence** (lindera 분해 확인): +- `'한국'` query → "한국 은 동아시아 의 반 도 국가 다 . 한국 어 는 한반도 의 주요 언어 다" (조사 `은`, `의` 분리). +- `'서울'` query → "서울특별시 와 부산광역시" — ko-dic 의 compound `서울특별시` → `[서울, 특별시]` 자동 분해. + +**Known limitation (Option α acceptance)**: +- 사용자 KnowledgeBase 같은 영어/code 위주 KB 에서는 한국어 token 자체 부재로 lexical 0-hit 자연 (vector/hybrid mode 로 우회). +- ko-dic 이 compound noun (`한국정부`, `대한민국` 등) 을 단일 token 으로 저장하는 경우 그 chunk 의 `'한국'` 단독 query 는 hit X. +- N-gram supplement (Option β, sub-token 추가 emit) 은 v0.21.x P9 follow-up. + +**V007 → V009 upgrade simulation (2026-05-28)** — whitespace-less Korean fixture (`/build/cache/tmp/v0.20.1-v007strict/corpus/no-space.md` 의 `한국문화는오래되었다한국문화의역사는깊다...`) 로 backfill mechanism 검증: + +1. v0.20.1 ingest → chunks 의 tokenized_korean_text 자동 populated. +2. python sqlite3 으로 V007-like state 시뮬레이션 (`UPDATE chunks SET tokenized_korean_text = NULL` + chunks_fts 재구성 raw text only). +3. `App::open_with_config` 재호출 → first-boot hook 의 `backfill_tokenized_korean_text` 자동 발화 → lindera 분해 결과 UPDATE → chunks_au trigger 로 chunks_fts 자동 재-index. +4. Verify post-backfill: tokenized_korean_text 의 populated 값이 `한국 문화 는 오래 되 었 다 한국 문화 의 역사 는 깊 다 . 서울 특별시 는 한국 의 수도 이 며 지하철...` (lindera morpheme + 조사 boundary 분리). + +**의외 발견**: FTS5 의 default `unicode61` tokenizer 가 CJK 문자 시퀀스를 별 codepoint 단위로 처리해, raw text 만 indexed 된 상태에서도 일부 한국어 query (예: `'한국'`) 가 hit. lindera 의 marginal benefit 은 corpus 의 morpheme 경계 정확도에 따라 변화. 자세한 unicode61 의 CJK tokenization 정책 = SQLite docs 의 `categories=L*` default + ICU optional extension 참고. spec §4 design choice 의 추가 evidence — V009 의 영어 회귀가 사용자 가치 가장 큰 user-facing 변화로 남고, 한국어 측 benefit 은 corpus 와 ko-dic 정책 의존이라 case-by-case. + +**N-gram supplement (Option β) 도입 (2026-05-28, post-PR review enhancement)**: + +spec §6.2 의 Option β (sub-token 추가 emit) 가 follow-up 으로 deferred 였지만, dogfood 의 ko-dic compound noun 정책 (`대한민국`, `한국정부` 등 단일 token) limitation 을 즉시 해소하기 위해 v0.20.1 의 implementation 에 포함: + +- `kebab-chunk::tokenize_korean_morphological` 에 한글 morpheme (`is_hangul` filter) 의 sliding window 2-gram 추가 emit. 길이 ≥ 3자 morpheme 만 대상 (이미 ≤ 2자 morpheme 은 그대로 사용). +- 영어 / 숫자 / 혼합 token 은 supplement X (`is_hangul` 의 `chars().all()` filter — false positive 회피). + +**Verification (fresh dogfood corpus + re-ingest)** — `/build/cache/tmp/v0.20.1-ngram/corpus/extra.md` (대한민국, 한국정부, 주민등록번호 포함): + +| Query | Hits | Mechanism | +|---|---|---| +| `'대한'` | 1 | `대한민국` morpheme 의 window `[대한, 한민, 민국]` | +| `'한민'` | 1 | 동일 | +| `'민국'` | 1 | 동일 | +| `'특별'` | 2 | `서울특별시` → `[서울, 특별시]` + `특별시` 의 window `[특별, 별시]` | +| `'주민'` | 1 | `주민등록번호` morpheme window | +| `'등록'` | 1 | 동일 | +| `'tokenizer'` (영어) | 0 | corpus 에 없음, 영어는 supplement 안 함 | + +**Trade-off**: +- DB size: 한국어 compound noun 비례 +20-30% (`tokenized_korean_text` column 의 token 수 증가). +- Ingest latency: marginal (sliding window 는 단순 vector loop, lindera tokenize 의 ~5-10% overhead). +- False positive risk: 일부 (예: `'한민'` query 가 `'대한민국'` 도 hit). 작은 risk, user 가 raw FTS5 mode 또는 longer query 로 우회 가능. + +**Released as part of v0.20.1**. spec Appendix B 의 prior-knowledge limitation 이 supplement 으로 해소. spec §6.2 의 Option β 결정을 v0.21.x 에서 v0.20.1 implementation 으로 promote (HOTFIXES → spec 갱신 cascade — design §5.5 변경 외에 §6.2 본문은 보존, supplement 동작 만 implementation detail 로 추가). + +**Large-scale dogfood verification (2026-05-28, KnowledgeBase + N-gram)** — 사용자 실제 `/home/altair823/KnowledgeBase/` (1781 markdown, 9050 chunk) 를 N-gram supplement 포함 binary 로 backfill 재실행: + +- **Backfill duration**: 9050 chunk × lindera tokenize + N-gram + UPDATE + chunks_au trigger = **26.6 초** (real-time wall clock, OnceLock 캐시 + 1000-row batch transaction). ~3 ms/chunk amortized. +- **Storage delta**: `kebab.sqlite` 크기 변화는 영어/code 위주 corpus 라 minimal (N-gram supplement 가 한글 morpheme 만 emit). +- **Query evidence (KnowledgeBase, post-backfill)**: + +| Query | Pre-backfill hits | Post-backfill hits | Mechanism | +|---|---|---|---| +| `'한국'` | 0 | **10** ✅ | N-gram supplement 의 `'한국어'` → `[한국, 국어]` window. KB 의 `testdata/coding-md-corpus/*/...md` 의 "문서를 한국어로 다시 정리하기" pattern 에서 hit. | +| `'한국어'` | 5 | 10 | morpheme + N-gram 양쪽 매칭으로 hit count 증가 (raw `한국어` token + N-gram supplement) | +| `'서울'` | 0 | 0 | KB corpus 에 단어 자체 부재 (data limitation, V009 limitation X) | +| `'지하철'` | 0 | 0 | 동일 | +| `'token'` (영어) | 10 | 10 | KB 의 OAuth/JWT docs — whole-token 매칭. supplement 미적용. | +| `'tokenizer'` | 0 | 0 | KB 에 부재 | +| `'pipeline'` | 10 | 10 | data ingest pipeline docs | +| `'config'` | 10 | 10 | config-related docs | + +**핵심 결론**: Bug #8 의 **functional closure 검증** — V007 trigram 의 `'한국'` 0 hit limitation 이 V009 + N-gram supplement 로 **10 hit** 으로 개선. 다른 한국어 query 의 0-hit 는 corpus 의 단어 자체 부재 (KB 가 React/Cargo/MD docs 위주). 실제 한국어 content 가 더 많은 KB (예: 한국 정부 docs, K-wiki) 에서는 더 큰 benefit 기대. + +**Snippet evidence (ko-dic 분해 + N-gram window)**: +``` +testdata/coding-md-corpus/security/security-310-item.md + → "¶ 문서 를 한국어 한국 국어 로 다시 정리 하 기" +testdata/coding-md-corpus/rust/rust-020-functions.md + → "Functions 문서 를 한국어 한국 국어 로 다시 정리 하 기" +``` + +`한국어` morpheme + sliding window `한국` + `국어` 가 동시에 chunks_fts 에 indexed — `'한국'` query 가 morpheme 분해 결과의 부분 token 으로 hit. +- README + SKILL.md + HANDOFF.md 세 문서 반영. + +Cross-link: `migrations/V009__fts_korean_morphological.sql`, `crates/kebab-search/src/lexical.rs`, design §5.5 / §9, `docs/superpowers/specs/2026-05-28-v0.20.x-korean-morphological-tokenizer-spec.md`. + ## 2026-05-28 — PDF OCR `request_timeout_secs` default 60s → 180s (Bug #11 follow-up) **Discovered**: v0.20.0 final dogfood (2026-05-28), round 3 fresh KB ingest.