Skip to content

sampling: a reasoning budget of 0 bounds the think; a prefilled token cannot consume the forced end - #95

Merged
joelteply merged 3 commits into
masterfrom
8b479782/reasoning-budget-zero
Nov 7, 2026
Merged

joelteply merged 3 commits into
masterfrom
8b479782/reasoning-budget-zero

Conversation

@joelteply

Copy link
Copy Markdown

With budget 0, the generation prompt's prefilled <think> moves the budget sampler from
IDLE to FORCING, then the prefilled newline was accepted IN FORCING and advanced force_pos,
so the sampler reached DONE without ever sampling the end tag and the model thought to its
cap. Measured on 0d7dda6 (Qwen3.5-0.8B Q4_K_M, 2026-11-06): reasoning_budget_tokens 0
thought 400 of 400 tokens with empty content, with or without a json_schema; 1 was bounded.

FORCING now advances only on the forced token, and a natural end tag accepted while forcing
(a prefilled <think></think>) ends it, so a closed think is never forced a second time.

Tests: the realistic budget-0 case (start, then the forced end) and the budget-exhausted case
now feed the forced tokens as sampled; a new test covers budget 0 with the prefilled start and
newline (end tag alone; message then end tag), a prefill that already closed the think, and
budgets 1 and 3. Reverting the fix aborts the suite at 'activate immediately budget=0'.

Co-Authored-By: Claude Opus 5.5 (1M context) noreply@anthropic.com
Claude-Session: https://claude.ai/code/session_01FRQWzgSfo79JtnwZywHKrE

… cannot consume the forced end

With budget 0, the generation prompt's prefilled `<think>` moves the budget sampler from
IDLE to FORCING, then the prefilled newline was accepted IN FORCING and advanced force_pos,
so the sampler reached DONE without ever sampling the end tag and the model thought to its
cap. Measured on 0d7dda6 (Qwen3.5-0.8B Q4_K_M, 2026-11-06): reasoning_budget_tokens 0
thought 400 of 400 tokens with empty content, with or without a json_schema; 1 was bounded.

FORCING now advances only on the forced token, and a natural end tag accepted while forcing
(a prefilled `<think></think>`) ends it, so a closed think is never forced a second time.

Tests: the realistic budget-0 case (start, then the forced end) and the budget-exhausted case
now feed the forced tokens as sampled; a new test covers budget 0 with the prefilled start and
newline (end tag alone; message then end tag), a prefill that already closed the think, and
budgets 1 and 3. Reverting the fix aborts the suite at 'activate immediately budget=0'.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRQWzgSfo79JtnwZywHKrE
@joelteply

Copy link
Copy Markdown
Author

PASS on the logic at 6ad0dcb. Scope: common/reasoning-budget.cpp FORCING accept, and tests/test-reasoning-budget.cpp.

Verified by reading:

  • In FORCING, accept advances force_pos only when the accepted token equals forced_tokens[force_pos]. A prefilled token (budget 0 with <think>\n) no longer consumes the forced end.
  • In real sampling, apply masks the logits to the forced token, so sampled tokens always match. Only prefill can present a non-forced token in FORCING, which is exactly the case this fixes.
  • The natural-end branch requires match >= 0 AND a token that is not the expected forced one. A forced end tag completing the match still takes the normal advance-to-DONE path and keeps end_match reported. A prefilled closed think (<think>\n\n</think>) goes straight to DONE instead of forcing a second end. end_matcher.advance still runs on every token, as before.
  • The two existing tests change their sequences from non-forced tokens to the forced ones during FORCING. That is justified by the masking above, and the comment says why. It is not a weakening: the old sequences fed tokens real sampling can never produce.
  • The new test covers budget 0 (end alone; message then end), the closed-think prefill, and budgets 1 and 3.

Before merge, one receipt: please re-run your probe.py against a built llama-server from this branch, with the same Qwen3.5-0.8B and reasoning_budget_tokens 0 / 1 / 40, with and without json_schema. Report the think tokens and finish_reason, so the live symptom (0 thought to the cap) is shown fixed end to end, not only in the sampler unit. Then merge on green CI. The core picks it up at the next fork pin bump, and ggml-org#5787's ThinkBudget(0) becomes truly bounded.

@joelteply

Copy link
Copy Markdown
Author

Full PASS at 6ad0dcb, with the live receipt I asked for. Cormac's probe on the IntelMac (Qwen3.5-0.8B): on 0d7dda6, budget 0 thinks to the cap with empty content; on 1782249 plus the fix, budget 0 gives empty reasoning and 94-token schema JSON, finish=stop. Budgets 1 and 40 and no budget are unchanged. The unit suite passes 13/13, and reverting the fix makes it abort. Merge on green CI.

gguf.cpp:996 logs strerror(errno) but never included <cerrno>; it compiled only while another
header pulled it in transitively. The WASM WebGPU build (emscripten) no longer does: ubuntu-webgpu
failed on #95 with "use of undeclared identifier 'errno'" (it passed on #94, 2026-11-04).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRQWzgSfo79JtnwZywHKrE
@github-actions github-actions Bot added the ggml label Nov 7, 2026
@joelteply

Copy link
Copy Markdown
Author

PASS carries to 46c6ee2. I verified the delta via compare 6ad0dcb97...46c6ee211: one file, ggml/src/gguf.cpp +1/-0, adding #include <cerrno> for the strerror(errno) at :996 that the webgpu (emscripten) build no longer gets transitively. No logic change.

…ger provides them transitively

ubuntu-webgpu (emscripten) failed on #95 at gguf.cpp (errno), then at llama-graph.h:103
(getenv/atoi undeclared): the same build passed on #94 (2026-11-04), so the toolchain stopped
pulling these headers in through others. Upstream ggml-org/llama.cpp master already includes
them; this adds exactly the ones upstream has and the fork lacked (diffed per file over src/,
common/, ggml/src, ggml/include): llama-graph.h, llama-mmap.cpp, llama-vocab.cpp <cstdlib>;
llama-adapter.cpp <cerrno> <cstring>; common/jinja/value.cpp <cstdio> <cstdlib> <cstring>;
common/common.h and common/build-info.h <cstdio>.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRQWzgSfo79JtnwZywHKrE
@joelteply

Copy link
Copy Markdown
Author

PASS carries to d273091. I verified the delta via compare 46c6ee211...d273091f6: 7 files, additions only. It adds 10 C standard-library #include lines (<cerrno> ×1, <cstdio> ×3, <cstdlib> ×4, <cstring> ×2) plus one blank line, in common/build-info.h, common/common.h, common/jinja/value.cpp, src/llama-adapter.cpp, src/llama-graph.h, src/llama-mmap.cpp and src/llama-vocab.cpp. No logic change. Diffing the includes against upstream in one pass, instead of one per CI round, was the right call.

@joelteply
joelteply merged commit 25c5ec1 into master Nov 7, 2026
10 of 27 checks passed
joelteply added a commit that referenced this pull request Nov 7, 2026
…ed runner exists

Its runner is [self-hosted, Linux], which exists only upstream. In this fork it sat queued for
24 h on every PR and was cancelled, and a merge gate reads a cancelled check as a failure (#94,
#95, #96). The fork's four self-hosted workflows (CI, Server, UI, UI Build) are disabled in
the repo settings for the same reason; this job is called from the CPU workflow, so it gets
a job condition instead.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRQWzgSfo79JtnwZywHKrE
joelteply added a commit that referenced this pull request Nov 7, 2026
…n line (#95 merged to the stale master) (#96)

* sampling: a reasoning budget of 0 bounds the think; a prefilled token cannot consume the forced end

With budget 0, the generation prompt's prefilled `<think>` moves the budget sampler from
IDLE to FORCING, then the prefilled newline was accepted IN FORCING and advanced force_pos,
so the sampler reached DONE without ever sampling the end tag and the model thought to its
cap. Measured on 0d7dda6 (Qwen3.5-0.8B Q4_K_M, 2026-11-06): reasoning_budget_tokens 0
thought 400 of 400 tokens with empty content, with or without a json_schema; 1 was bounded.

FORCING now advances only on the forced token, and a natural end tag accepted while forcing
(a prefilled `<think></think>`) ends it, so a closed think is never forced a second time.

Tests: the realistic budget-0 case (start, then the forced end) and the budget-exhausted case
now feed the forced tokens as sampled; a new test covers budget 0 with the prefilled start and
newline (end tag alone; message then end tag), a prefill that already closed the think, and
budgets 1 and 3. Reverting the fix aborts the suite at 'activate immediately budget=0'.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRQWzgSfo79JtnwZywHKrE

* gguf: include <cerrno> for the errno the open-failure log reads

gguf.cpp:996 logs strerror(errno) but never included <cerrno>; it compiled only while another
header pulled it in transitively. The WASM WebGPU build (emscripten) no longer does: ubuntu-webgpu
failed on #95 with "use of undeclared identifier 'errno'" (it passed on #94, 2026-11-04).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRQWzgSfo79JtnwZywHKrE

* include the libc headers upstream includes: the WASM toolchain no longer provides them transitively

ubuntu-webgpu (emscripten) failed on #95 at gguf.cpp (errno), then at llama-graph.h:103
(getenv/atoi undeclared): the same build passed on #94 (2026-11-04), so the toolchain stopped
pulling these headers in through others. Upstream ggml-org/llama.cpp master already includes
them; this adds exactly the ones upstream has and the fork lacked (diffed per file over src/,
common/, ggml/src, ggml/include): llama-graph.h, llama-mmap.cpp, llama-vocab.cpp <cstdlib>;
llama-adapter.cpp <cerrno> <cstring>; common/jinja/value.cpp <cstdio> <cstdlib> <cstring>;
common/common.h and common/build-info.h <cstdio>.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRQWzgSfo79JtnwZywHKrE

* ci: build-cmake-pkg runs only in ggml-org's repo, where its self-hosted runner exists

Its runner is [self-hosted, Linux], which exists only upstream. In this fork it sat queued for
24 h on every PR and was cancelled, and a merge gate reads a cancelled check as a failure (#94,
#95, #96). The fork's four self-hosted workflows (CI, Server, UI, UI Build) are disabled in
the repo settings for the same reason; this job is called from the CPU workflow, so it gets
a job condition instead.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRQWzgSfo79JtnwZywHKrE

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant