From 37f46660c63d79450dc2e5838a213447bcb663a2 Mon Sep 17 00:00:00 2001 From: joelteply Date: Fri, 6 Nov 2026 18:02:49 -0600 Subject: [PATCH 1/4] 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 `` 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 0d7dda6ae (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 ``) 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) Claude-Session: https://claude.ai/code/session_01FRQWzgSfo79JtnwZywHKrE --- common/reasoning-budget.cpp | 13 ++++++ tests/test-reasoning-budget.cpp | 75 ++++++++++++++++++++++++++++++--- 2 files changed, 83 insertions(+), 5 deletions(-) diff --git a/common/reasoning-budget.cpp b/common/reasoning-budget.cpp index 4884299f3011..2b4ca880cd85 100644 --- a/common/reasoning-budget.cpp +++ b/common/reasoning-budget.cpp @@ -135,6 +135,19 @@ static void common_reasoning_budget_accept(struct llama_sampler * smpl, llama_to { // track the end sequence within forced_tokens so it is also reported on DONE const int32_t match = ctx->end_matcher.advance(token); + if (match >= 0 && (ctx->force_pos >= ctx->forced_tokens.size() || token != ctx->forced_tokens[ctx->force_pos])) { + // the reasoning already ended (a prefilled ``): nothing is left to force + ctx->state = REASONING_BUDGET_DONE; + ctx->end_match = match; + COM_TRC("%s", "deactivated (natural end while forcing)\n"); + break; + } + if (ctx->force_pos >= ctx->forced_tokens.size() || token != ctx->forced_tokens[ctx->force_pos]) { + // only the forced token advances the forced end: a prefilled token accepted in + // FORCING (budget 0 with the generation prompt's `\n`) is not a forced token, + // and consuming one reached DONE without ever sampling the end tag + break; + } ctx->force_pos++; if (ctx->force_pos >= ctx->forced_tokens.size()) { ctx->state = REASONING_BUDGET_DONE; diff --git a/tests/test-reasoning-budget.cpp b/tests/test-reasoning-budget.cpp index 3bcc77e1733c..b072585fb3e3 100644 --- a/tests/test-reasoning-budget.cpp +++ b/tests/test-reasoning-budget.cpp @@ -335,6 +335,68 @@ static void test_reasoning_budget_end_match() { // UTF-8 boundary detection unit test // Tests common_utf8_is_complete() from reasoning-budget.h +// what this catches: budget 0 with the generation prompt's `\n` prefilled. The prefilled start +// moves IDLE -> FORCING, then the prefilled newline was accepted IN FORCING and advanced the forced +// end, so the sampler reached DONE without ever sampling the end tag and the model thought to its +// cap (Qwen3.5-0.8B on 0d7dda6ae, 2026-11-06: reasoning_budget_tokens 0 thought 400 of 400 tokens, +// 1 was bounded). A prefilled token is not a forced token; a prefill that already closed the think +// leaves nothing to force. +static void test_reasoning_budget_prefill_while_forcing() { + const std::vector start = {100}; + const std::vector end = {101}; + + // budget 0, end tag alone forced: the prefilled newline must not consume it + { + auto * sampler = common_reasoning_budget_init(nullptr, {start}, {end}, {101}, 0, REASONING_BUDGET_IDLE); + llama_sampler_accept(sampler, 100); // prefilled : FORCING + llama_sampler_accept(sampler, 50); // prefilled newline + GGML_ASSERT(common_reasoning_budget_get_state(sampler) == REASONING_BUDGET_FORCING && "a prefilled token ended the forcing"); + GGML_ASSERT(get_forced_token(sampler, 101) == 101 && "the end tag is not forced after the prefill"); + llama_sampler_accept(sampler, 101); + GGML_ASSERT(common_reasoning_budget_get_state(sampler) == REASONING_BUDGET_DONE); + llama_sampler_free(sampler); + } + + // budget 0, a message then the end tag: both are forced in order after the prefill + { + auto * sampler = common_reasoning_budget_init(nullptr, {start}, {end}, {102, 101}, 0, REASONING_BUDGET_IDLE); + llama_sampler_accept(sampler, 100); + llama_sampler_accept(sampler, 50); + GGML_ASSERT(get_forced_token(sampler, 102) == 102 && "the forced message was skipped by the prefill"); + llama_sampler_accept(sampler, 102); + GGML_ASSERT(get_forced_token(sampler, 102) == 101); + llama_sampler_accept(sampler, 101); + GGML_ASSERT(common_reasoning_budget_get_state(sampler) == REASONING_BUDGET_DONE); + GGML_ASSERT(common_reasoning_budget_get_end_match(sampler) != nullptr && "the forced end tag is not reported"); + llama_sampler_free(sampler); + } + + // budget 0, the prefill already closed the think (`\n\n`): nothing is left to force + { + auto * sampler = common_reasoning_budget_init(nullptr, {start}, {end}, {102, 101}, 0, REASONING_BUDGET_IDLE); + llama_sampler_accept(sampler, 100); + llama_sampler_accept(sampler, 50); + llama_sampler_accept(sampler, 101); + GGML_ASSERT(common_reasoning_budget_get_state(sampler) == REASONING_BUDGET_DONE && "a closed think was forced again"); + GGML_ASSERT(common_reasoning_budget_get_end_match(sampler) != nullptr); + llama_sampler_free(sampler); + } + + // budget 1 and N: the prefilled newline is counted as before, then the end is forced + for (const int32_t budget : {1, 3}) { + auto * sampler = common_reasoning_budget_init(nullptr, {start}, {end}, {101}, budget, REASONING_BUDGET_IDLE); + llama_sampler_accept(sampler, 100); + for (int32_t i = 0; i < budget; i++) { + GGML_ASSERT(common_reasoning_budget_get_state(sampler) == REASONING_BUDGET_COUNTING); + llama_sampler_accept(sampler, 50 + i); + } + GGML_ASSERT(get_forced_token(sampler, 101) == 101 && "the budget did not force the end tag"); + llama_sampler_accept(sampler, 101); + GGML_ASSERT(common_reasoning_budget_get_state(sampler) == REASONING_BUDGET_DONE); + llama_sampler_free(sampler); + } +} + static void test_utf8_boundary_detection() { // Complete sequences GGML_ASSERT(common_utf8_is_complete("hello")); @@ -379,11 +441,12 @@ int main(void) { // Flow: i=0 apply()->passthrough, accept(100)->COUNTING; i=1 accept(50)->remaining=1 // i=2 accept(51)->remaining=0->FORCING; i=3 apply() forces token[0]; i=4 apply() forces token[1] // At i=4, accept() advances force_pos to 2 which equals forced_tokens.size(), so state becomes DONE + // (forced positions are sampled as the forced tokens, as the masked logits make them) { const std::vector start = {100}; const std::vector end = {101}; const std::vector forced = {102, 101}; // forced message + end - const std::vector sequence = {100, 50, 51, 52, 53}; // start + 4 tokens (budget=2) + const std::vector sequence = {100, 50, 51, 102, 101}; // start + 2 tokens (budget=2) + forced test_reasoning_budget("budget exhausted forcing", sequence, {start}, {end}, forced, 2, // budget of 2 tokens @@ -393,18 +456,19 @@ int main(void) { } // Test 3: Activate immediately with budget=0, forcing should start right away - // Flow: init promotes COUNTING+budget=0 to FORCING, so apply() sees FORCING at i=0 + // Flow: init promotes COUNTING+budget=0 to FORCING, so apply() sees FORCING at i=0; the start + // token accepted at i=0 is not a forced token and does not advance the forced end { const std::vector start = {100}; const std::vector end = {101}; const std::vector forced = {102, 101}; - const std::vector sequence = {100, 50, 51, 52}; // start token first, then 3 tokens + const std::vector sequence = {100, 102, 101, 52}; // start token, the forced end, one more test_reasoning_budget("activate immediately budget=0", sequence, {start}, {end}, forced, 0, // budget of 0 tokens REASONING_BUDGET_COUNTING, // starts counting, promoted to FORCING since budget=0 0, // forcing starts at i=0 (initialized in FORCING, apply forces immediately) - 1); // forcing continues through i=1 (accept at i=1 transitions to DONE) + 2); // forcing continues through i=2 (accept of the last forced token at i=2 transitions to DONE) } // Test 4: No start/end tokens configured - passthrough (no forcing) @@ -494,8 +558,9 @@ int main(void) { test_reasoning_budget_clone_mid_forcing(); test_reasoning_budget_force_manual(); test_reasoning_budget_end_match(); + test_reasoning_budget_prefill_while_forcing(); - printf("OK (12 tests passed)\n"); + printf("OK (13 tests passed)\n"); printf("Testing UTF-8 boundary detection... "); test_utf8_boundary_detection(); From 8e5091d79b0c6b569b1c075c557ea747f05594a4 Mon Sep 17 00:00:00 2001 From: joelteply Date: Fri, 6 Nov 2026 18:09:10 -0600 Subject: [PATCH 2/4] gguf: include for the errno the open-failure log reads gguf.cpp:996 logs strerror(errno) but never included ; 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) Claude-Session: https://claude.ai/code/session_01FRQWzgSfo79JtnwZywHKrE --- ggml/src/gguf.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/ggml/src/gguf.cpp b/ggml/src/gguf.cpp index 6c7b5817812b..63363ba784d8 100644 --- a/ggml/src/gguf.cpp +++ b/ggml/src/gguf.cpp @@ -3,6 +3,7 @@ #include "ggml-impl.h" #include "gguf.h" +#include #include #include #include From 45ae996818cf0d485d46c4fb2892f9355733245b Mon Sep 17 00:00:00 2001 From: joelteply Date: Fri, 6 Nov 2026 18:12:34 -0600 Subject: [PATCH 3/4] 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 ; llama-adapter.cpp ; common/jinja/value.cpp ; common/common.h and common/build-info.h . Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01FRQWzgSfo79JtnwZywHKrE --- common/build-info.h | 2 ++ common/common.h | 1 + common/jinja/value.cpp | 3 +++ src/llama-adapter.cpp | 2 ++ src/llama-graph.h | 1 + src/llama-mmap.cpp | 1 + src/llama-vocab.cpp | 1 + 7 files changed, 11 insertions(+) diff --git a/common/build-info.h b/common/build-info.h index 1e564591a612..a1e8796c5812 100644 --- a/common/build-info.h +++ b/common/build-info.h @@ -1,5 +1,7 @@ #pragma once +#include + int llama_build_number(void); const char * llama_commit(void); diff --git a/common/common.h b/common/common.h index 1fb343b93eca..8b6f12f3f802 100644 --- a/common/common.h +++ b/common/common.h @@ -8,6 +8,7 @@ #include "ggml.h" #include "llama.h" +#include #include #include #include diff --git a/common/jinja/value.cpp b/common/jinja/value.cpp index 6999ef7d6706..5c50ece6b6b7 100644 --- a/common/jinja/value.cpp +++ b/common/jinja/value.cpp @@ -5,6 +5,9 @@ // for converting from JSON to jinja values #include "json.h" +#include +#include +#include #include #include #include diff --git a/src/llama-adapter.cpp b/src/llama-adapter.cpp index a6ac5596353b..ed002c6abc55 100644 --- a/src/llama-adapter.cpp +++ b/src/llama-adapter.cpp @@ -4,6 +4,8 @@ #include "llama-mmap.h" #include "llama-model.h" +#include +#include #include #include #include diff --git a/src/llama-graph.h b/src/llama-graph.h index 4c7f60bd8137..05cc00f37d27 100644 --- a/src/llama-graph.h +++ b/src/llama-graph.h @@ -5,6 +5,7 @@ #include "llama-hparams.h" #include "llama-adapter.h" +#include #include #include #include diff --git a/src/llama-mmap.cpp b/src/llama-mmap.cpp index a8d26a420d91..7fee61ebf75c 100644 --- a/src/llama-mmap.cpp +++ b/src/llama-mmap.cpp @@ -4,6 +4,7 @@ #include "ggml.h" +#include #include #include #include diff --git a/src/llama-vocab.cpp b/src/llama-vocab.cpp index ff926ceecd17..52fa166b4928 100644 --- a/src/llama-vocab.cpp +++ b/src/llama-vocab.cpp @@ -7,6 +7,7 @@ #include "unicode.h" +#include #include #include #include From 9bd4d6386d39d5ae89e6d85cf62a20c4fefde332 Mon Sep 17 00:00:00 2001 From: joelteply Date: Fri, 6 Nov 2026 19:06:13 -0600 Subject: [PATCH 4/4] 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) Claude-Session: https://claude.ai/code/session_01FRQWzgSfo79JtnwZywHKrE --- .github/workflows/build-cpu.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/build-cpu.yml b/.github/workflows/build-cpu.yml index b62fe55d6c5f..690e371142ed 100644 --- a/.github/workflows/build-cpu.yml +++ b/.github/workflows/build-cpu.yml @@ -43,6 +43,9 @@ env: jobs: build-cmake-pkg: + # its runner is self-hosted ([self-hosted, Linux]) and exists only in ggml-org's repo; in a fork it + # queues for 24 h and is cancelled, which a merge gate reads as a failure + if: github.repository == 'ggml-org/llama.cpp' uses: ./.github/workflows/build-cmake-pkg.yml ubuntu: