Skip to content

Boundary-marker pretokenization - #14

Open
sanderland wants to merge 1 commit into
mainfrom
boundary-clean
Open

Boundary-marker pretokenization#14
sanderland wants to merge 1 commit into
mainfrom
boundary-clean

Conversation

@sanderland

@sanderland sanderland commented Aug 5, 2026

Copy link
Copy Markdown
Owner

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:

two <|> touching  ->  exactly one space
a lone <|>        ->  nothing (structural boundary only)

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 <^^>, so The/the and NASA/nasa can 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 new ScriptPretokenizer subclass in paper_utils/boundary/boundary_pretokenizer.py, configured by options:

option
boundary_targets what is delimited besides word spans, which always are: (), ("punct",), ("punct","digit")
shift_code / caps_code <^> title case, <^^> all caps
min_caps_length shortest span <^^> may cover
single_char_shift whether a one-character span is shift-coded

It 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

./paper_utils/boundary/run_all_experiments.sh

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=1 retrains the six-language tokenizer grid; DOWNSTREAM=1 reruns 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.py per 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; and create_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 in join() on a sentinel that never fires — twice costing 8.6 hours on a corpus build whose results the parent already held. join_workers/shutdown_pool bound the wait and escalate. 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, 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_factor measures 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 under SPLIT/RTL3, non-ASCII numerics, hash distinctness across every axis that keys the corpus cache, and each case option gated independently. Plus test_quick_sample.py and test_sample_cache.py for the sampler and its cache.

🤖 Generated with Claude Code

https://claude.ai/code/session_01W7F6Ac72HbPG15ExjkbFFu

@sanderland
sanderland force-pushed the boundary-clean branch 4 times, most recently from 03ce92f to 06236a7 Compare August 5, 2026 08:46
@sanderland sanderland changed the title Boundary-marker pretokenization: paper_utils/boundary Boundary-marker pretokenization Aug 5, 2026
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
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