-
Notifications
You must be signed in to change notification settings - Fork 0
0023 byte level decode substitutes
Status: accepted · Date: 2026-08-14
BpeTokenizer.Decode, for a byte-level model, assembled the raw bytes each
token symbol spells and handed them to JsonArtifact.Utf8NoBom.GetString,
the same strict UTF8Encoding the persistence layer uses. A byte sequence
that is not well-formed UTF-8 made that call throw
DecoderFallbackException. Issue #149 was filed about one way that shape
arises — an added token whose content is not byte-level decodable end to end
— and #121's own added-token corpus already carried three cases that threw
for exactly that reason.
Decode now substitutes U+FFFD for a byte sequence that is not well-formed
UTF-8, at the one call site that converted bytes to a string, instead of
throwing DecoderFallbackException. Encode's own strict byte conversion
is untouched, and so is JsonArtifact.Utf8NoBom itself — the substitution
is local to Decode's output path, via a second UTF8Encoding constructed
with throwOnInvalidBytes: false.
Issue #149's own investigation went past the added-token shape it was filed
about and measured plain streaming decode — no added token involved,
against stock GPT-2 through tokenizers 0.23.1, one id at a time, the way a
language model is normally consumed. U+FFFD is what the reference itself
returns for a multi-byte character split across token boundaries:
| Text | Ids producing U+FFFD |
|---|---|
東京 👋 |
6 of 6 |
日本語のテキスト |
6 of 10 |
🇫🇷 emoji |
6 of 7 |
déjà vu |
0 of 6 |
DataNet threw on every one of those before this branch — Decode was
unusable one id at a time for any text outside Latin-1, which is not an
edge case the issue happened to also cover; it is the shape of ordinary CJK
and emoji text under streaming decode. tests/oracles/bytelevel_decode_stream.json
carries the frozen corpus.
Encode still throws on a lone surrogate. A lone surrogate is not
well-formed UTF-16, so there is no byte sequence for it to be lossless
about — nothing for a substitution to produce in its place. The asymmetry
between a forgiving Decode and a strict Encode is not invented here; it
is the reference's own. tokenizers decodes lossily and encodes strictly,
and this decision reproduces exactly that shape rather than picking a
uniform policy tokenizers does not have.
Before this change, a byte sequence that could not be decoded raised
DecoderFallbackException — a loud signal that usually meant the caller had
truncated a stream mid-character or hand-built an id list incorrectly.
After this change, the same input returns a string silently, containing
U+FFFD in place of the bytes that did not decode. That string is
indistinguishable, at the type level, from one that decoded cleanly. A
caller who wants to know now has to test the result for U+FFFD themselves;
nothing forces them to. This is a real loss, not a cosmetic one — it trades
an exception a caller could not ignore for a value a caller can, and it is
the cost this decision accepts in exchange for one-id-at-a-time decoding
being usable at all.
A complete, valid byte sequence — anything Encode itself produced and
handed back whole — never reaches the fallback path: Utf8Lossy and the
strict encoding agree on every well-formed input, so the byte-level round
trip holds exactly where it held before this change. Only a byte sequence
that was already malformed changes behaviour, from throwing to
substituting.
Refusing such a model at load. TokenizerJsonLoader.LoadBpe could
refuse a tokenizer.json whose added tokens are not byte-level decodable,
the way it already refuses byte_fallback and other shapes. That addresses
only the added-token case #149 was filed about — it does nothing for the
streaming case in Decision 2, where the split arises from ordinary
multi-byte text and no added token is involved. A load-time refusal would
leave the more common shape unfixed while adding a new way to reject a
model.
An opt-in parameter. Decode could take a flag choosing between
throwing and substituting, leaving today's strict behaviour as the default.
tokenizers offers no such choice — every caller gets the same lossy
decode — so a parameter here would double a public method's contract for a
choice the reference does not offer its own callers, without bringing
DataNet closer to what it is measured against.
-
docs/equivalence.md'sdecoderow no longer claims an unqualified byte-exact round trip; it states what happens to a byte sequence that is not well-formed instead. -
docs/equivalence.md'sLoadBperow drops the clause #121 added naming this issue as an open divergence — it is closed. -
docs/guides/embeddings.mdstates whatDecodedoes with an added token that is not byte-level decodable, instead of naming an exception that no longer happens, and says that decoding a token at a time now works.
- 0001-target-framework
- 0002-unicode-comparison-unit
- 0003-provenance-and-licensing
- 0004-levenshtein-myers-backlog
- 0005-hamming-jellyfish-divergence
- 0006-ratcliff-autojunk
- 0007-metaphone-scope
- 0008-italian-enza-nltk-divergence
- 0009-sample-consumes-a-local-feed
- 0010-stop-word-list-provenance
- 0011-persistence-format
- 0012-per-package-versioning
- 0013-sentencepiece-parity-scope
- 0014-precompiled-normalizer
- 0015-sonar-rules-in-the-build
- 0016-metrics-package-placement
- 0017-bpe-parity-scope
- 0018-multiclass-roc-auc-parallelism-is-opt-in
- 0019-the-net-analysers-run-in-the-build-too
- 0020-normalize-is-a-projection-not-a-parameter
- 0021-multioutput-is-a-method-not-an-enum
- 0022-added-token-matching-flags
- 0023-byte-level-decode-substitutes
- 0024-weighted-median-averages-within-scikit-learns-epsilon
- 0025-quickselect-replaces-a-full-sort-for-the-median
- 0026-r2-and-explainedvariance-split-their-undefined-cases-differently
- 0027-r2-and-explainedvariance-vectorize-only-a-single-output
- 0028-log1p-is-kahans-identity-not-math-log-1-plus-x
- 0029-balanced-accuracy-adjusted-is-left-to-ieee-754-at-the-edge
- 0030-cohen-kappa-keeps-scikit-learns-expected-matrix-orientation
- 0031-nosamplecorrect-mirrors-numpys-float64-upcast
- 0032-fbeta-substitutes-tp-predicted-and-support-algebraically
- 0033-compensated-sum-is-neumaiers-variant
- 0034-dropout-is-refused-for-want-of-a-user
- 0035-a-null-pre-split-is-removed-with-invert-not-isolated
- 0036-a-member-may-ship-without-an-oracle-if-it-says-so
- 0037-the-guards-run-before-the-commit
- 0038-the-gate-confronts-an-exception-tag-with-the-page-that-documents-it
- 0039-mutual-information-returns-zero-on-an-empty-input
- 0040-a-curve-is-a-sealed-class-per-curve
- 0041-one-sample-file-per-public-class
- 0042-phonetic-encoders-refuse-a-null-word
- 0043-the-equality-table-is-sized-to-the-pattern
- 0044-compression-belongs-to-the-caller
- 0045-a-console-call-carries-its-reason-on-the-line
- 0046-check-adr-immutable-runs-in-ci-only
- 0047-one-gate-per-kernel-not-one-per-alphabet
- 0048-the-gate-depends-on-the-kernel-and-the-alphabet
- 0049-two-gates-per-kernel-tested-where-the-width-is-known
- 0050-the-sentencepiece-bpe-lineage-stays-a-bpe-model
- benchmark_latest
- decisions
- equivalence
- matplotlib
- migration
- nightly_run
- numpy
- pandas
- performance
- pytorch
- seaborn
- sklearn
- statsmodels