refactor: reasoning markers that are text, not one token #113
Loading…
Reference in a new issue
No description provided.
Delete branch "refactor/reasoning-marker"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
The reasoning split and the thinking budgets assumed a chat template's closing marker is one added token in the vocabulary. That holds for Granite 4 and Nemotron 3, but not for OLMo 3 (
allenai/Olmo-3-7B-Think), whose</think>is ordinary BPE text. Such a model could be neither split nor budgeted.This change puts the marker behind a small interface owned by the tokenizer, and adds a text implementation beside the existing id one. Behavior for templates whose marker is an added token is unchanged.
Why text, and not a token sequence
A text marker has no fixed token sequence. OLMo 3's tokenizer spells it according to the surrounding text:
x\n</think>\n\nAnswerx\n</think>\n\nAnswerx\n</think>\nAnswerx\n</think>\nAnswerx</think>Answerx</think>Answerdone.</think>\n\ndone.</think>\n\nx </think> yx</think>yMatching any one id sequence would miss most of the closes the model actually writes, so the marker is matched in the decoded text.
What changes
tokenizer.ReasoningEndis an interface with the three things the engine needs from a marker:Find(id, text, prev): did the token just generated complete the marker, and where do reasoning and answer split?Pending(text): how many trailing bytes a stream must hold back because they may yet become the marker.Close(lead, trail): the tokens that write"\n</think>\n", with either newline optional.ReasoningTokenizer.ReasoningEnd()returns one of these, or nil for no reasoning convention. It replacesReasoningEndToken() int32.TokenMarker(added token) matches only its id, so the split stays unforgeable from prose. ItsPendingis always 0, andClosesplices the id between newline ids.TextMarker(ordinary text) finds the marker in the decoded output, starting far enough back to catch a marker split across tokens. The marker may end inside the token that completed it; the rest of that token belongs to the answer.Pendingis the longest tail that is a prefix of the marker.Closeencodes the whole string, so a forced close is the tokenizer's own spelling (>\nas one token).withChatTemplatepicks the marker kind. It usesTokenMarkerwhen the template's marker is an added token andTextMarkerotherwise. Before, a missing token disabled reasoning for that template.The engine has one path.
Findafter each decode, holds backPendingwhile streaming, and forcesClosefor a budget cut.ReasonTokensis counted back over the tokens that cover the marker; for an added token that is the same count as before.Close.AGENTS.md and README describe both marker kinds.
Not covered: under a grammar, bytes that share a token with the end of a text marker were sampled unconstrained and are not fed to the grammar. For whitespace, which is what a model writes there, this is harmless.
Verification
go build ./...,go vet ./...,go test ./...pass; gofmt is clean.TokenMarker.reasoning_test.go, on a toy tokenizer that spells</think>the way OLMo 3 does):TestTextMarkerFind: matches across token boundaries and inside a token, but not a marker earlier than the newest token could have completed.TestTextMarkerPending: the holdback rule.TestMarkerClose: the merged>\nspelling for text, the id between newlines for a token, and no newline from a tokenizer that cannot round-trip one.TestTokenMarkerFind: matches on the id only, never on text.TestHFReasoningEndText: a template marker absent from the added tokens yields aTextMarkeron a real Granite tokenizer.TestHFReasoningEndTokenand the Nemotron goldens confirm both shipped templates still getTokenMarkerwith their ids.TestGenerateChatReasoningSplitText: a text marker spanning two tokens splits an identical token stream exactly, and the streamed deltas reassemble both regions.TestGenerateChatMaxThinkingTokensHardText: a hard budget forces\n</think>\n, the reasoning is the thought plus its newline, andreasoning_tokensstays within the budget.Pendingstubbed to 0, the hard-budget test failed 28 of 30 runs on streamed reasoning. It depends on when the stream goroutine flushes, so it is not deterministic;TestTextMarkerPendingpins the rule itself.No GPU run: the change is in tokenization and step-loop bookkeeping, covered by the CPU tests above.
🤖 Generated with Claude Code
The reasoning split assumed the template's closing marker is a single added token. OLMo 3 writes "</think>" as ordinary BPE tokens ("</", "think", ">", with ">" merging into a following newline), so that model could not be split or budgeted. tokenizer.ReasoningTokenizer now returns a ReasoningEnd (the marker's text, plus its id when it is an added token) in place of ReasoningEndToken. The engine keeps the id path unchanged for added tokens and, when the marker is plain text, finds it in the decoded output, holds back a possible partial marker while streaming, and forces the budget close by encoding "\n</think>\n" as one string. Granite 4 and Nemotron 3 still resolve to the id path. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>Automated review by pr-reviewer v0.54.0 | Safety Check | Nemotron 3 Nano | tracking id
r-c1dde0-5131faThis is an AI-generated review and may contain mistakes.
Status: ❌ Failed
Review failed. Tracking id
r-c1dde0-5131fa— see logs for details.Comment
@pr-reviewer-bot retryto try again.refactor(engine): reasoning markers that are text, not one tokento refactor: reasoning markers that are text, not one token