Skip to content

Fix #198: auto-prefix CJK tokens in literal-safe FTS path - #438

Merged
Widthdom merged 10 commits into
mainfrom
fix/issue-198-fts-cjk-tokenizer
Apr 18, 2026
Merged

Fix #198: auto-prefix CJK tokens in literal-safe FTS path#438
Widthdom merged 10 commits into
mainfrom
fix/issue-198-fts-cjk-tokenizer

Conversation

@Widthdom

Copy link
Copy Markdown
Owner

Summary

Fixes #198: cdidx search 計算 returned 0 matches against content containing 計算する because SQLite FTS5's default unicode61 tokenizer keeps adjacent CJK codepoints as a single token, so the literal-safe phrase "計算" never matched the longer indexed token.

  • CJK prefix promotion in the literal-safe FTS path — Tokens containing CJK script (Han / Hiragana / Katakana / Hangul / Halfwidth Katakana) are emitted as FTS5 prefix phrases ("計算"*) instead of bare phrases, so they match the longer CJK tokens unicode61 produces. Non-CJK tokens (Latin-diacritic, Greek, Cyrillic, emoji-mixed) keep bare-phrase behavior to avoid silently widening matches or leaking through emoji stripping. Strict equality on CJK substrings remains available via --exact / instr.
  • Extended coverage — Extensions H and I, Hangzhou numerals, iteration marks, Kana Extended-B, halfwidth Hangul, Bopomofo, Yi, Tangut / Tangut Components / Tangut Supplement, Khitan Small Script, Nüshu, and Ideographic Symbols are recognized as CJK for prefix promotion. CJK Radicals Supplement and Kangxi Radicals are intentionally excluded because unicode61 drops those symbol codepoints at tokenization time regardless. Emoji-only substring search still needs a tokenizer-level schema change (tracked as a FTS5 default tokenizer (unicode61) treats CJK runs as single tokens — search 計算 returns 0 matches against text containing 計算する, same for emoji and any non-whitespace-separated script #198 follow-up).
  • Per-block predicates with aligned regression testsDbSearchReader.IsCjkScript splits non-BMP CJK-adjacent blocks into per-block branches so each block can be independently verified, and regression tests exercise the previously-failing 計算 case plus each new block-envelope branch.
  • Rationale for OtherNotAssigned inclusion.NET 8's Unicode tables lag the Unicode Consortium's releases (Extension I reports as Cn despite being assigned in Unicode 15.1), and unicode61 ships its own older tables that empirically keep reserved block-tail codepoints as word characters. Allowing Cn through the block envelopes therefore preserves the search <cp>"<cp>abc" invariant. Truly unreachable codepoints still produce zero matches because FTS5 drops them on its own query-side tokenizer.
  • Docs syncedCHANGELOG.md, CLAUDE.md, and the MCP search tool description updated in English and Japanese.

Test plan

Closes #198

Co-Authored-By: Claude Opus 4.7 noreply@anthropic.com

Widthdom and others added 10 commits April 18, 2026 05:57
FTS5's default unicode61 tokenizer treats a run of adjacent CJK / Japanese /
Chinese / Korean codepoints as a single token, so `cdidx search 計算` quoted
the token as `"計算"` and returned 0 matches against any chunk containing
`計算する`. The literal-safe query path now routes each token through a new
`FormatFtsToken` helper that detects any codepoint above 0x7F and emits an
FTS5 prefix phrase (`"計算"*`) instead of a bare phrase, so CJK substring
queries hit without requiring `--fts` / `rawQuery`. Pure-ASCII tokens keep
their existing exact-phrase semantics unchanged, so the fix is additive and
non-breaking for Latin-script search.

Added DbReaderTests coverage for CJK substring matches through both
`Search` and `CountSearchResults`, plus an exact-full-token regression so
the prefix fallback cannot silently widen a literal search. Updated the
MCP `search` tool description and CLAUDE.md design-decisions section to
document the CJK prefix behavior and the known emoji limitation (emoji
substring search requires a schema-level tokenizer change — tracked as a
follow-up to #198).

Closes #198
The first pass of the #198 fix promoted any token containing a non-ASCII
codepoint (`foreach c in token: if c > 0x7F`) to an FTS5 prefix phrase.
That was too broad and was caught by codex review as an observable
false positive: `cdidx search 'foo🎉'` not only matched `src/emoji.py`
containing `foo🎉` but also leaked into `src/plain.py` which only
contained `foo` / `foobar`. The cause is that FTS5's default unicode61
tokenizer drops emoji (symbol category) codepoints entirely during
both indexing and query tokenization, so the sanitized query
`"foo🎉"*` is parsed by FTS5 as `"foo"*` — a prefix search on `foo`
that sweeps in every ASCII neighbor.

The same class of over-widening applied to Latin-diacritic tokens
(`naïve`, `café`), Greek, and Cyrillic, which unicode61 tokenizes
normally. Promoting them to prefix would silently widen an exact
literal search into a prefix search without the user asking for it.

`FormatFtsToken` now routes tokens through `ContainsCjk`, which uses
`Rune.EnumerateRunes()` to check explicit CJK Unicode blocks (Han /
CJK Unified Ideographs + Extensions A–G + Compatibility / Radicals,
Hiragana / Katakana + phonetic extensions, Hangul Syllables + Jamo +
Compatibility Jamo, halfwidth Katakana) and excludes symbol categories
(`OtherSymbol` / `MathSymbol` / `CurrencySymbol` / `ModifierSymbol`) up
front so an emoji that happens to fall inside a CJK-adjacent block
cannot trigger the fallback. The Halfwidth / Fullwidth Forms block is
narrowed to the halfwidth-Katakana sub-range so fullwidth Latin /
digits (`ABC`, `012`) also keep exact-phrase semantics.

Also added three negative regression tests:

- `Search_CjkPrefixDoesNotMatchUnrelatedCjkTokens` — pins that
  `search 計算` hits the `計算する` file but not an unrelated `検索する`
  file, so the prefix fallback can only widen *to* tokens that start
  with the query codepoints.
- `Search_EmojiMixedTokenDoesNotOverMatchAsciiPrefixNeighbors` —
  pins the exact bug codex caught: `foo🎉` must not drag in `foobar`.
- `Search_LatinDiacriticTokenDoesNotWidenToPrefixSearch` — pins that
  `naïve` stays as exact phrase semantics and does not widen to
  `naïvety`.

Updated CLAUDE.md (EN + JA), CHANGELOG.md (EN + JA), and the MCP
`search` tool description to say "CJK token" instead of "non-ASCII"
and call out the over-widening risk that motivates the narrowing.

Refs #198.
Codex round 2 review caught two correctness gaps in the previous
round's tests and docs, plus one dead-code issue:

1. `Search_EmojiMixedTokenDoesNotOverMatchAsciiPrefixNeighbors` only
   pinned that `foo🎉` did not widen to `foobar`, but CHANGELOG /
   CLAUDE.md / the MCP description all claimed emoji-mixed tokens keep
   "exact-phrase semantics". That claim is not achievable at the FTS
   layer: unicode61 strips emoji codepoints on BOTH the index side and
   the query side, so `foo🎉` is indexed and queried as the plain
   token `foo` — meaning `search foo🎉` also matches a file containing
   just `def foo():`. Callers who need strict emoji-bearing equality
   must route through the `--exact` / `instr` path.

2. `Search_CjkQueryStillRespectsExactFullToken` only asserted the
   positive case ("full-token CJK query finds full-token CJK content")
   but its name, comment, and the CHANGELOG entry implied it also
   pinned "CJK full token does not silently widen into a prefix
   search". The implementation cannot honor that — CJK tokens take
   the prefix path unconditionally, which is the whole point of the
   fix (otherwise `search 計算` would regress back to 0 hits against
   `計算する`). The trade-off needed to be written down, not pretended
   against.

3. CJK Radicals Supplement (U+2E80..U+2EFF) and Kangxi Radicals
   (U+2F00..U+2FDF) ranges in `IsCjkScript` were dead: every
   codepoint in those blocks is Unicode category `OtherSymbol`, which
   is rejected by the category guard earlier in the function.

Changes:

- `src/CodeIndex/Database/DbSearchReader.cs`: remove dead Radicals
  ranges, document why they are excluded (unicode61 drops them).
- `tests/CodeIndex.Tests/DbReaderTests.cs`:
  - Rename `Search_CjkQueryStillRespectsExactFullToken` →
    `Search_CjkFullTokenQueryStillFindsExactFullToken` with an
    explicit comment that this is a "does not regress" pin, not a
    "narrows to exact" pin.
  - Add `Search_CjkFullTokenQueryAlsoWidensToLongerTokens` to pin
    the intentional widening so it cannot be silently reverted into
    the #198 zero-hit repro.
  - Rename `Search_EmojiMixedTokenDoesNotOverMatchAsciiPrefixNeighbors`
    → `Search_EmojiMixedTokenDoesNotPrefixWidenToAsciiNeighbors` with
    a comment that this only guards against PREFIX widening, not
    against the deeper FTS-equivalence fallback.
  - Add `Search_EmojiMixedTokenFallsBackToAsciiToken_UseExactForStrict`
    that pins both halves of the limitation: `foo🎉` and `foo` are
    FTS-indistinguishable, and the `--exact` path is the only way to
    tell them apart.
- `CHANGELOG.md` (EN + JA), `CLAUDE.md` (EN + JA), and
  `src/CodeIndex/Mcp/McpToolDefinitions.cs` now document, rather than
  overclaim:
  - CJK prefix promotion is unconditional; strict equality requires
    `--exact`.
  - Emoji-mixed tokens are FTS-equivalent to their plain ASCII
    counterpart; strict equality requires `--exact`.
  - Radicals blocks are excluded because unicode61 drops them.

Full test suite still passes (1668 tests).

Refs #198.
)

- Add CJK Unified Ideographs Extension H (U+31350..U+323AF, Unicode 15.0)
  and Extension I (U+2EBF0..U+2EE5F, Unicode 15.1) to IsCjkScript. Without
  Extension H, `search '𱍐'` returned 0 hits against content containing
  `𱍐abc` — the exact #198 zero-hit shape reproduced on a newer Unicode
  block.
- Rewrite the FormatFtsToken summary comment: the previous wording claimed
  non-CJK tokens "intentionally keep exact-phrase semantics," which
  contradicts the rest of the documentation now that we acknowledge
  unicode61 drops emoji codepoints on both sides, making `foo🎉` and `foo`
  indistinguishable at the FTS layer. The new comment matches CHANGELOG,
  CLAUDE.md, and the MCP tool description.
- Tighten CountSearchResults_IncludesCjkSubstringMatches from `>= 1` to
  exact `Equal(1, ...)` with a miss-file fixture so drift that inflates
  the count is caught too.
- Add Search_FindsNonBmpCjkExtensionHSubstringInsideLongerToken using
  char.ConvertFromUtf32(0x31350) to pin surrogate-pair handling.
- CHANGELOG Japanese section: "拡張 A–G" → "拡張 A–I" for parity with the
  English "Extensions A–I" entry.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…/ halfwidth Hangul (#198)

Codex round 4 review found four remaining 0-hit repros even after Extensions
H/I were added: `search 々`, `search 〇`, `search ᄆ`, and `search 𚿰` all
returned zero against content like `々abc` / `〇abc` / `ᄆᄇᄈabc` / `𚿰abc`.
Under unicode61 those codepoints are treated as word characters but were
not in the CJK prefix fallback set, so the whole CJK run indexed as a single
token and the bare phrase query could never match a longer token.

Extend `IsCjkScript` in `src/CodeIndex/Database/DbSearchReader.cs`:

- Han-script codepoints outside the CJK Unified Ideographs blocks —
  ideographic iteration mark 々 (U+3005), closing mark 〆 (U+3006), number
  zero 〇 (U+3007), Hangzhou numerals (U+3021..U+3029), and Hangzhou 10/20/30
  + vertical iteration marks (U+3038..U+303B).
- Kana Extended-B (U+1AFF0..U+1AFFF, Unicode 15.0).
- Halfwidth range widened from U+FF65..U+FF9F (halfwidth Katakana) to
  U+FF65..U+FFDC (through halfwidth Hangul). U+FFDD..U+FFDF are unassigned
  and U+FFE0..U+FFEE are fullwidth currency/arrow symbols that unicode61
  drops during tokenization, so stopping at U+FFDC is correct.

Tests — `tests/CodeIndex.Tests/DbReaderTests.cs`:

- Added four new CJK regressions pinning `search 々`, `search 〇`,
  `search ᄆ`, and `search 𚿰` against content containing `<codepoint>abc`.
- Added a dedicated Extension I regression alongside the existing
  Extension H test so a future cleanup that drops either range would break
  its own dedicated test rather than silently regressing.
- Fixed the stale "non-ASCII prefix-match fallback" comment on the
  `Search_FindsCjkSubstringInsideLongerToken` test to say "CJK-only"
  consistent with the predicate's actual scope.

Docs — CHANGELOG (EN + JA), CLAUDE.md (EN + JA literal-safe search bullet),
and the MCP `search` tool description now enumerate the CJK set explicitly
(Kana + Kana Extended-B, CJK Unified Ideographs + Extensions A–I, Han-script
codepoints outside the unified blocks, Hangul, and halfwidth forms through
U+FFDC) so AI clients and humans can see at a glance what is / is not
auto-upgraded to a prefix phrase, and explain why the halfwidth range stops
at U+FFDC.

Verified end-to-end: the locally built binary now returns hits for all four
round-4 BLOCKING repro shapes (`search 々`, `search 〇`, `search ᄆ`,
`search 𚿰`) against files containing `々abc` / `〇abc` / `ᄆᄇᄈabc` / `𚿰abc`.
Full test suite: 1674 passed, 2 skipped (performance, as designed).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Codex round 5 review found two more 0-hit repro blocks left unaddressed by
round 4's expansion:

1. Vertical kana repeat marks U+3031..U+3035 (〱 〲 〳 〴 〵). Unicode category
   Lm; unicode61 keeps them as word characters. Adjacent to the U+3038..U+303B
   range but not covered by it.
2. Bopomofo (U+3100..U+312F) and Bopomofo Extended (U+31A0..U+31BF) — Mandarin
   zhuyin and minority-dialect zhuyin extensions, category Lo. unicode61 keeps
   them as word characters, so `search ㄅ` returned 0 against `ㄅabc`.

Extend `IsCjkScript` in `src/CodeIndex/Database/DbSearchReader.cs` to include
both classes. The Han-script branch now enumerates U+3031..U+3035 alongside
the existing individual 々 / 〆 / 〇 / Hangzhou / U+3038..U+303B lines — kept
explicit rather than widening to the full U+3000..U+303F block so we don't
sweep in ideographic space / brackets / dots that unicode61 already drops.

Tests — `tests/CodeIndex.Tests/DbReaderTests.cs`:

- Search_FindsVerticalKanaRepeatMarkInsideLongerToken (〱 U+3031)
- Search_FindsBopomofoInsideLongerToken (ㄅ U+3105)
- Search_FindsBopomofoExtendedInsideLongerToken (ㆠ U+31A0)

Each test indexes `<codepoint>abc` and queries `<codepoint>` through the
normal FTS path, so removing the corresponding range makes the test fail.

Docs — CHANGELOG (EN + JA), CLAUDE.md (EN + JA), MCP `search` tool description
now enumerate Kana Supplement / Kana Extended-A / Small Kana Extension
(U+1B000..U+1B16F, which the code already covered but the prose did not
mention — round 5 review flagged this as a non-blocking doc/code mismatch),
Bopomofo + Bopomofo Extended, and vertical kana repeat marks so AI clients
can see the full CJK scope at a glance.

Verified with the locally built binary: `search 〱`, `search 〵`, `search ㄅ`,
and `search ㆠ` all now return hits against files containing the respective
`<codepoint>abc` forms. Full test suite: 1677 passed, 2 skipped (performance).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Codex review round 6 identified six more historical East Asian
Unicode blocks whose codepoints are Lo / Lm (kept as word characters
by the default unicode61 tokenizer) and that were not covered by
`IsCjkScript`, reproducing the #198 zero-hit shape on `search <cp>`
against `<cp>abc` content:

  - Yi Syllables U+A000..U+A48F (Nuosu syllabary, BMP, Lo)
  - Tangut U+17000..U+187FF (Western Xia, non-BMP, Lo)
  - Tangut Components U+18800..U+18AFF (non-BMP, Lo)
  - Khitan Small Script U+18B00..U+18CFF (non-BMP, Lo)
  - Tangut Supplement U+18D00..U+18D8F (non-BMP, Lo)
  - Nüshu U+1B170..U+1B2FF (women's script, non-BMP, Lo)

`IsCjkScript` in `DbSearchReader` now covers those blocks so tokens
containing them take the FTS5 prefix-phrase path (`"<cp>"*`) and
`search ꀀ` / `search 𗀀` / `search 𘴀` / `search 𛅰` find content
containing `<cp>abc`. Yi Radicals (U+A490..U+A4CF) stay excluded —
every codepoint there is Unicode category `OtherSymbol` and is
dropped by unicode61, so prefix fallback cannot help them anyway.

Added four DbReader regression tests pinning Yi Syllables, Tangut
(lower bound of the merged U+17000..U+18D8F range), Tangut
Supplement (upper bound, doubling as a Khitan / Tangut Supplement
pin), and Nüshu. Updated CHANGELOG, CLAUDE.md, and the MCP `search`
tool description (EN + JA) to enumerate the new blocks and explain
why Yi Radicals is excluded.

Verified with the locally built binary against a temp fixture
containing `<cp>abc` files for each block: all four queries return
hits. All 1681 tests pass (+4 vs round 6).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Round 8 codex review follow-ups:

1. Split the merged 0x17000..0x18D8F range into four Unicode-block envelopes
   — Tangut (U+17000..U+187FF), Tangut Components (U+18800..U+18AFF),
   Khitan Small Script (U+18B00..U+18CFF), Tangut Supplement
   (U+18D00..U+18D8F) — so each block has its own predicate branch and
   its own regression test. A future narrowing of any single block no
   longer drags the others.

2. Add the individual iteration / annotation codepoints from the
   Ideographic Symbols and Punctuation block that unicode61 keeps as
   word characters — U+16FE0 (Tangut iteration mark, Lm), U+16FE1 (Nüshu
   iteration mark, Lm), U+16FE3 (Old Chinese iteration mark, Lm),
   U+16FE4 (Khitan Small Script filler, Mn), and U+16FF0..U+16FF1
   (Vietnamese Chu Nom alternate reading marks CA / NHAY, Mc). U+16FE2
   (Old Chinese hook mark, Po) is deliberately excluded because
   unicode61 drops it during tokenization. Without these, historical
   Han-based text containing iteration marks reproduces the #198 0-hit
   shape on a different codepoint class.

3. Document that `OtherNotAssigned` (Cn) is intentionally NOT added to
   the top-level category exclusion. .NET's Unicode tables lag the
   Unicode Consortium's releases, so codepoints assigned in Unicode 15.1
   — for example Extension I (U+2EBF0..U+2EE5F) — still report `Cn` on
   .NET 8. Excluding `Cn` would silently regress real CJK codepoints
   just because the runtime has not caught up. Small unassigned trailing
   holes inside block envelopes (e.g. Tangut U+187F8..U+187FF) are
   harmless because unicode61 drops unassigned codepoints from indexed
   content, so no prefix-promoted query can spuriously match them.

Added five new regression tests: Tangut Components (𘠀 U+18800), Khitan
Small Script (𘬀 U+18B00), Tangut Iteration Mark (𖿠 U+16FE0, Lm),
Khitan Small Script Filler (𖿤 U+16FE4, Mn), and Vietnamese Chu Nom
Alternate Reading Mark CA (𖿰 U+16FF0, Mc). The three Ideographic
Symbols and Punctuation tests span the Lm / Mn / Mc Unicode categories
so no single annotation-category regression can silently slip past, and
the four Tangut-family tests each exercise their own predicate branch
so a future block-merge refactor breaks its own dedicated test first.

Full suite: 1686 pass / 0 fail / 2 skip on net8.0.

Closes #198.
Round 8 code review flagged that the "unicode61 drops unassigned
codepoints from indexed content" rationale was empirically false:
searching for reserved codepoints like U+187F8, U+18CD6, U+18D09
returns content containing those codepoints through the locally
built binary, proving unicode61 keeps them as word characters.

The actual reason the block-envelope predicate works:
- .NET 8's Unicode tables lag the Unicode Consortium's releases,
  so Extension I (U+2EBF0..U+2EE5F, Unicode 15.1) is reported as
  OtherNotAssigned despite being assigned.
- unicode61 ships its own (older, different) Unicode tables and
  keeps reserved codepoints in CJK-like blocks as word characters.
- Therefore allowing Cn through the block envelopes produces
  correct search behavior.

Rewrote rationale comments/docs in DbSearchReader.cs,
CHANGELOG.md (EN+JA), CLAUDE.md (EN+JA), and McpToolDefinitions.cs
(EN+JA). Predicate behavior is unchanged.

Tests: 1686 pass / 0 fail / 2 skip on net8.0.

Refs #198

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@Widthdom
Widthdom merged commit 1007690 into main Apr 18, 2026
5 checks passed
@Widthdom
Widthdom deleted the fix/issue-198-fts-cjk-tokenizer branch April 18, 2026 11:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant