Skip to content

fix: convert WASM byte offsets to code-unit offsets before slicing - #68

Open
technoligest wants to merge 1 commit into
feyninc:mainfrom
technoligest:fix/utf8-byte-offsets-in-chunkers
Open

fix: convert WASM byte offsets to code-unit offsets before slicing#68
technoligest wants to merge 1 commit into
feyninc:mainfrom
technoligest:fix/utf8-byte-offsets-in-chunkers

Conversation

@technoligest

@technoligest technoligest commented Jul 30, 2026

Copy link
Copy Markdown

The bug

split_offsets returns UTF-8 byte offsets β€” as its own docstring in chunk.d.ts says β€” but String.prototype.slice indexes UTF-16 code units. The two coincide only for ASCII, which is why this hasn't surfaced before. Any non-ASCII character desynchronizes them:

import { init, split_offsets } from '@chonkiejs/chunk';
await init();

split_offsets('ab cd', { delimiters: ' ', includeDelim: 'none', minChars: 0 });
// [[0,2],[3,5]] β†’ text.slice() β†’ ["ab", "cd"]   βœ…

split_offsets('Γ© cd', { delimiters: ' ', includeDelim: 'none', minChars: 0 });
// [[0,2],[3,5]] β†’ text.slice() β†’ ["Γ© ", "d"]    ❌ the 'c' is gone

split_offsets('πŸ”— cd', { delimiters: ' ', includeDelim: 'none', minChars: 0 });
// [[0,4],[5,7]] β†’ text.slice() β†’ ["πŸ”— c", ""]   ❌ drift of 2

Γ© and – drift the boundary by 1 byte, an emoji by 2. Three chunkers apply these offsets straight to a string:

  • recursive.ts β€” whitespace level and delimiter level
  • sentence.ts β€” the single-character-delimiter path (the multi-character defaults go through splitByPatternsOffsets, which is correct)
  • semantic.ts β€” sentence preparation

FastChunker already does it right β€” it slices the Uint8Array and decodes β€” so the fix here just brings the other three in line.

Why it's easy to miss

RecursiveChunker rejoins whitespace splits with ' ', so the corruption is length-preserving: characters are overwritten rather than dropped, and nothing downstream looks wrong. On a real 1246-page document I hit:

source : "Chapter 35.75, Streets – Bicycles – Paths\n\nπŸ”— <http://ap"
chunked: "Chapter 35.75, Streets – B cycles – Pat s\n\nπŸ”— <http //ap"

SentenceChunker's symptom is different β€” boundaries land mid-word, and endIndex can run past text.length (I saw endIndex 69 on a 64-character string).

The worst case is astral characters: a drift of 2 can land the boundary inside a surrogate pair. The resulting lone surrogate is unrepresentable in UTF-8, so JSON.stringify emits \udXXX, and downstream APIs reject the payload. That's how I found this β€” a reranking API returned 400 … ensure that your input is encoded in valid UTF-8 format on one chunk in 5,550.

The fix

Adds toCharOffsets(text, byteOffsets), which walks the string once to map UTF-8 byte offsets onto UTF-16 code-unit offsets, and returns ASCII input untouched so the common path stays free. The three chunkers now wrap their split_offsets calls with it.

No changes to the Rust/WASM side β€” its contract is documented and correct; only the JS consumers were mixing coordinate systems.

Verification

packages/core/tests/unicode-offsets.test.ts adds 19 tests. The 15 integration tests all fail on main and pass with this change; the 4 toCharOffsets unit tests cover the conversion directly.

The chunker tests pair each non-ASCII input with an ASCII input of identical code-unit length (cafΓ© vs cafe , πŸ”— link vs ab link ) and assert both produce identical chunk lengths and offsets β€” which isolates this bug from RecursiveChunker's existing whitespace-delimiter behaviour.

Against that 1246-page document (3.9 MB, 1,244 page segments, 182 emoji plus pervasive en-dashes), chunked at chunkSize: 1024:

before after
segments that don't round-trip 69 65
chunks containing a lone surrogate 1 0

The 65 remaining are not encoding-related: replacing every non-ASCII code unit with an ASCII one of the same length reproduces all 65 exactly, so they're the pre-existing whitespace-delimiter drop at the whitespace level (includeDelim: 'none' discards the delimiter, and the rejoin restores one space). Encoding-specific corruption goes to 0. I left that separate behaviour alone since it's orthogonal to this fix β€” happy to look at it separately if it's considered a bug.

Full suite: no regressions. tests/code.test.ts (9) and one init-all case fail identically before and after on my machine β€” they need an optional tree-sitter language pack.

Summary by CodeRabbit

  • Bug Fixes

    • Fixed text chunking for Unicode content, including multi-byte characters and emoji.
    • Prevented surrogate pairs from being split and ensured chunk boundaries correctly map back to the original text.
    • Improved sentence and delimiter-based chunking to avoid truncated or out-of-range results.
  • Tests

    • Added coverage for Unicode offsets, ASCII compatibility, sentence boundaries, and chunk slicing accuracy.

split_offsets returns UTF-8 byte offsets, but String.prototype.slice
indexes UTF-16 code units. The two coincide only for ASCII, so any
non-ASCII character desynchronizes them:

  split_offsets('Γ© cd', { delimiters: ' ', includeDelim: 'none' })
  // [[0,2],[3,5]] -> text.slice() yields ["Γ© ", "d"], losing the 'c'

RecursiveChunker, SentenceChunker and SemanticChunker all applied these
offsets directly to strings. Since RecursiveChunker rejoins whitespace
splits with a space, the damage was length-preserving and therefore
silent. An astral character drifts the boundary by 2 bytes, which can
land inside a surrogate pair and emit a lone surrogate - unrepresentable
in UTF-8, so downstream JSON APIs reject the chunk outright.

FastChunker was already correct: it slices the byte array and decodes,
rather than mixing coordinate systems.

Adds toCharOffsets, which walks the string once to map byte offsets to
code-unit offsets and returns ASCII input untouched.
@vercel

vercel Bot commented Jul 30, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
chonkiejs-docs Skipped Skipped Jul 30, 2026 5:56pm

Request Review

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

πŸ“ Walkthrough

Walkthrough

Adds UTF-8 byte to UTF-16 offset conversion and applies it to recursive, semantic, and sentence chunkers. New tests cover ASCII, accented characters, punctuation, emoji, surrogate pairs, chunk boundaries, and source slicing.

Changes

Unicode offset conversion

Layer / File(s) Summary
Offset conversion utility
packages/core/src/offsets.ts, packages/core/tests/unicode-offsets.test.ts
Adds toCharOffsets with ASCII passthrough, Unicode mapping, and end-of-text handling.
Chunker offset integration
packages/core/src/recursive.ts, packages/core/src/semantic.ts, packages/core/src/sentence.ts
Converts WASM offsets before text slicing and sentence-boundary processing.
Unicode chunking validation
packages/core/tests/unicode-offsets.test.ts
Validates chunk parity, delimiter boundaries, source slicing, and surrogate-pair preservation.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Chunker
  participant WASM_split_offsets
  participant toCharOffsets
  participant JavaScript_slice
  Chunker->>WASM_split_offsets: request UTF-8 byte offsets
  WASM_split_offsets-->>toCharOffsets: return offset pairs
  toCharOffsets-->>Chunker: return UTF-16 code-unit pairs
  Chunker->>JavaScript_slice: slice source text
Loading

Poem

A rabbit found bytes in a Unicode heap,
And mapped them where JavaScript sleeps.
Emoji stayed whole, accents stayed bright,
Chunks met their boundaries just right.
β€œHop!” said the bunny, β€œthe offsets align!”

πŸš₯ Pre-merge checks | βœ… 5
βœ… Passed checks (5 passed)
Check name Status Explanation
Description Check βœ… Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check βœ… Passed The title accurately summarizes the main change: converting WASM byte offsets to JavaScript code-unit offsets before slicing.
Docstring Coverage βœ… Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check βœ… Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check βœ… Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
πŸ§ͺ Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❀️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

πŸ€– Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/core/tests/unicode-offsets.test.ts`:
- Line 82: Raise the repository and workspace Node engine lower bounds from 18
to 20, including the relevant declarations in package configuration and
pnpm-workspace.yaml. Update packages/core/tsconfig.json to use a library target
that defines String.prototype.isWellFormed(), then retain the isWellFormed()
test assertion in unicode-offsets.test.ts.
πŸͺ„ Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
βš™οΈ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 61bca28c-b952-437c-998b-1269a86c2e2b

πŸ“₯ Commits

Reviewing files that changed from the base of the PR and between 6a98fd9 and ec9b511.

πŸ“’ Files selected for processing (5)
  • packages/core/src/offsets.ts
  • packages/core/src/recursive.ts
  • packages/core/src/semantic.ts
  • packages/core/src/sentence.ts
  • packages/core/tests/unicode-offsets.test.ts

const chunks = await chunker.chunk(PARITY_CASES.emoji.wide);

for (const chunk of chunks) {
expect(chunk.text.isWellFormed()).toBe(true);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟑 Minor | ⚑ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check declared TS lib/target and Node engine requirements
fd tsconfig.json | xargs -I{} sh -c 'echo "== {} =="; cat {}'
fd package.json -d 2 | xargs -I{} sh -c 'echo "== {} =="; jq ".engines" {} 2>/dev/null'
fd '.nvmrc|.tool-versions' -H

Repository: feyninc/chonkiejs

Length of output: 2641


🏁 Script executed:

#!/bin/bash
set -e
echo "== package.json files with engines/scripts =="
git ls-files '*package.json' | while read -r f; do
  echo "== $f =="
  jq '{name, type, engines, scripts, dependencies, devDependencies}' "$f"
done

echo
echo "== workflow node-version declarations =="
if [ -d .github/workflows ]; then
  fd -a . .github/workflows -t f | while read -r f; do echo "== $f =="; rg -n "node-version|nodejs|setup-node|vitest|pnpm|npm|yarn|bun" "$f" || true; done
else
  echo "no .github/workflows directory"
fi

echo
echo "== unicode-offsets test relevant lines =="
fd unicode-offsets.test.ts -t f | while read -r f; do
  echo "== $f =="
  sed -n '1,180p' "$f"
done

echo
echo "== TypeScript declarations for isWellFormed in built-in libs =="
fd 'lib.es2023.*.d.ts|lib.es2024.*.d.ts|lib.esnext.*.d.ts' /var 2>/dev/null || true

Repository: feyninc/chonkiejs

Length of output: 9926


🏁 Script executed:

#!/bin/bash -
# Inspect packages/core test config inheritance path and any lockfile engine pins.
set -e
echo "== packages/core/package.json =="
jq '{name, type, engines, scripts, dependencies, devDependencies}' packages/core/package.json

echo
echo "== packages/core/tests/tsconfig.json =="
cat packages/core/tests/tsconfig.json

echo
echo "== packages/core/tsconfig.json =="
cat packages/core/tsconfig.json

echo
echo "== package manager files =="
git ls-files | rg '(^|/)(pnpm-lock.yaml|package-lock.json|yarn.lock|bun.lockb|bun.lock)$' || true

echo
echo "== lockfile engines/node declarations (first 120 matches) =="
for f in $(git ls-files | rg '(^|/)(pnpm-lock.yaml|package-lock.json|yarn.lock|bun.lockb|bun.lock)$' || true); do
  echo "== $f =="
  rg -n "engines:|node:|node-version|`@chonkiejs/core`|typescript|vitest" "$f" | head -120 || true
done

echo
echo "== TypeScript installed availability =="
node - <<'JS'
try {
  const fs = require('fs');
  const path = require('path');
  for (const root of ['node_modules', process.cwd() + '/node_modules']) {
    const file = path.join(root, 'typescript/lib/lib.es2024.string.d.ts');
    if (fs.existsSync(file)) {
      console.log('types file exists:', file);
      const text = fs.readFileSync(file, 'utf8');
      console.log(text.includes('isWellFormed') ? 'isWellFormed declaration present' : 'isWellFormed declaration absent');
    }
  }
  console.log('node', process.version);
} catch (e) {
  console.log('type check unavailable:', e.message);
}
JS

Repository: feyninc/chonkiejs

Length of output: 10387


Raise the test/CI Node lower bound before using String.isWellFormed().

packages/core/tests inherits packages/core/tsconfig.json, which still uses lib: ["ES2021"], so these calls will fail type-checking unless that lib is updated. Add Node 20 to the repo and pnpm-workspace.yaml declarations too; the current workspace/engine config only requires Node 18, but String.prototype.isWellFormed() needs Node 20.

πŸ€– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/tests/unicode-offsets.test.ts` at line 82, Raise the repository
and workspace Node engine lower bounds from 18 to 20, including the relevant
declarations in package configuration and pnpm-workspace.yaml. Update
packages/core/tsconfig.json to use a library target that defines
String.prototype.isWellFormed(), then retain the isWellFormed() test assertion
in unicode-offsets.test.ts.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant