Boundary-marker pretokenization - #14
Open
sanderland wants to merge 1 commit into
Open
Conversation
sanderland
force-pushed
the
boundary-clean
branch
4 times, most recently
from
August 5, 2026 08:46
03ce92f to
06236a7
Compare
sanderland
force-pushed
the
boundary-clean
branch
from
August 5, 2026 09:40
06236a7 to
8514709
Compare
Adds `BoundaryScriptPretokenizer`: a SCRIPT-v3 pre-tokenization scheme that makes word boundaries explicit in the vocabulary instead of leaving them implicit in a space-prefixed duplicate of every word. A word span is delimited by a single atomic marker token on each side. Two markers that touch mean the space between the spans was elided, and a lone marker is a structural boundary, so `the` needs one vocabulary entry rather than the usual `the`/` the` pair. Punctuation and digit runs are delimited too, under `boundary_targets`. Case is optional and separate: `<^>` for title case, `<^^>` for all caps, placed outside the span's markers so `<|>the<|>` is a suffix of `<^><|>the<|>` and the two share their merges. `min_caps_length` and `single_char_shift` bound which spans a code may cover. The case test requires the span to actually be cased, which matters for scripts that have no case at all. The scheme reads SCRIPT v3's category folding to identify spans and replaces the base chunker, so it is a v3 scheme and raises rather than silently marking nothing under other configurations; the module docstring records what generalizing it would take. paper_utils/boundary/ reproduces the paper's tables. The default run reads the measurement caches committed under paper/generated/ and needs no GPU and no trained tokenizer; GRID=1 retrains the tokenizer grid and DOWNSTREAM=1 reruns the LM sweep. It produces the compression/MorphScore table, the bits-per-byte table, their appendix companions, a worked pre-tokenization example and a duplicate-vocabulary table, every number computed from an artifact rather than transcribed. Shared code: - script_bpe/corpus/registry.py: a sampled-text cache keyed on the sampling parameters, so a grid scans the source once rather than once per arm, plus create_streaming_quick_corpus, which reads until its character budget is full instead of reservoir-sampling the whole source. It is a separate corpus name, so it can never share a cache or a tokenizer path with a full-sample build. - script_bpe/utils.py, corpus/base.py, bpe/trainer.py: bounded worker shutdown. The forkserver can leave an exited worker unreaped and the parent then blocks forever in join() on a sentinel that never fires. Also fixes a Counter.__iadd__ rescan that made chunk-count merging quadratic. - eval/py-nanochat: bits-per-byte per true byte. nanochat divides summed loss by the summed byte length of the target tokens, so a scheme that elides a character between two tokens has no token to charge it to and its bpb is inflated; byte_factor measures the discrepancy and makes the figure comparable. - eval/py-nanochat: run_experiment(shuffle_data_order=...), off by default. The document iterator holds no RNG, so every seed of a configuration reads the same document sequence and a spread over seeds measures initialization alone. The permutation is derived from the seed and not the tokenizer, so arms stay paired. tests/test_boundary.py covers round-trip, the touching-marker/space invariant, no-triple-marker, cross-script spans, digit handling, non-ASCII numerics, hash distinctness across every axis that keys the corpus cache, and each case option gated independently. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W7F6Ac72HbPG15ExjkbFFu
sanderland
force-pushed
the
boundary-clean
branch
from
August 5, 2026 11:10
8514709 to
c7473af
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds the pretokenizer and reproduction directory for Explicit Boundary Markers for Subword Vocabularies.
The scheme
A single atomic token
<|>delimits spans, and the space between two delimited spans is elided at encode time and reconstructed at decode time from the resulting pair of touching markers:That removes the duplication the leading-space convention creates, where
' the'and'the'are separate vocabulary entries. Word spans are delimited unconditionally, so a word has one canonical form regardless of what precedes it; punctuation and digits are delimited only on a side whose space was elided, which is what keeps the touching-marker signal unambiguous.Optional case codes go a step further: a title-case span is emitted as
<^>plus its lowercased form, an all-caps span as<^^>, soThe/theandNASA/nasacan share an entry. The code sits outside the span's markers, which is what makes sharing possible —<|>the<|>occurs inside<^><|>the<|>as a suffix, so the trainer may cover both with one piece, and will spend a second entry only where the frequency justifies it.script_bpe/pretokenize's existing schemes are untouched; this is a newScriptPretokenizersubclass inpaper_utils/boundary/boundary_pretokenizer.py, configured by options:boundary_targets(),("punct",),("punct","digit")shift_code/caps_code<^>title case,<^^>all capsmin_caps_length<^^>may coversingle_char_shiftIt is a SCRIPT-v3 scheme and the module docstring says so: span identification reads v3's category folding, so V1/V2 would silently mark nothing, and the byte-based pretokenizers are out of reach. Making it universal is scoped out and described.
Reproducing the paper
regenerates every table from the measurement caches committed under
paper/generated/— no GPU, no trained tokenizer, about a second, because those caches are tracked precisely so the paper reproduces on any machine.GRID=1retrains the six-language tokenizer grid;DOWNSTREAM=1reruns the LM sweep and exits with a usable message where there is no CUDA.Produces the two main tables (compression + MorphScore; bits per byte), their appendix companions, a worked pre-tokenization example, and a table of duplicate vocabulary entries. Every number is computed from an artifact rather than transcribed, including the ones quoted in captions.
The downstream sweep is a plain sequential driver — one invocation of
run_downstream_eval.pyper arm × seed, skip-if-done — so scheduling it across nodes is left to whoever reproduces it rather than baked in.Shared code
script_bpe/corpus/registry.py: a sampled-text cache keyed on the sampling parameters, so a grid scans the source once instead of once per arm; andcreate_streaming_quick_corpus, which reads until its character budget is full instead of reservoir-sampling the whole source. Uniform sampling of a 5 GB slice needs ~45 GB read per language; the quick sampler is a deliberately separate corpus name so it can never share a cache or a tokenizer path with a full-sample build.script_bpe/utils.py,corpus/base.py,bpe/trainer.py: bounded worker shutdown. The forkserver can leave an exited worker unreaped, and the parent then blocks forever injoin()on a sentinel that never fires — twice costing 8.6 hours on a corpus build whose results the parent already held.join_workers/shutdown_poolbound the wait and escalate. Also fixes aCounter.__iadd__rescan that made chunk-count merging quadratic.eval/py-nanochat: bits-per-byte per true byte. nanochat divides summed loss by the summed byte length of the target tokens, taken from each token's own decoding; a scheme that elides a character between two tokens has no token to charge it to, so the denominator is short and bpb is inflated in proportion to how much the scheme elides.byte_factormeasures the discrepancy over the same text and makes the figure comparable across tokenizers.eval/py-nanochat:run_experiment(shuffle_data_order=...), off by default. nanochat's document iterator holds no RNG, so every seed of a configuration reads the identical document sequence and a spread over seeds measures weight initialization alone. The option lets the seed permute the training document order too; the permutation is derived from the seed and not the tokenizer, so arms of a comparison stay paired. It defaults off because it changes what a given seed means, and existing measurements would not reproduce with it on.Tests
tests/test_boundary.py(642 cases): round-trip, the touching-marker/space invariant, no-triple-marker, cross-script span merging, digit handling underSPLIT/RTL3, non-ASCII numerics, hash distinctness across every axis that keys the corpus cache, and each case option gated independently. Plustest_quick_sample.pyandtest_sample_cache.pyfor the sampler and its cache.🤖 Generated with Claude Code
https://claude.ai/code/session_01W7F6Ac72HbPG15ExjkbFFu