@@ -184,6 +184,76 @@ static void test_reasoning_budget_clone_mid_forcing() {
184184 llama_sampler_free (sampler);
185185}
186186
187+ static void test_reasoning_budget_force_manual () {
188+ const std::vector<llama_token> start = {100 };
189+ const std::vector<llama_token> end = {101 };
190+ const std::vector<llama_token> forced = {102 , 101 };
191+
192+ // if COUNTING, force() succeeds and begins forcing the end sequence from the start
193+ {
194+ auto * sampler = common_reasoning_budget_init (nullptr , start, end, forced, 5 , REASONING_BUDGET_IDLE );
195+
196+ llama_sampler_accept (sampler, 100 ); // COUNTING, remaining=5
197+ llama_sampler_accept (sampler, 50 ); // COUNTING, remaining=4
198+ GGML_ASSERT (common_reasoning_budget_get_state (sampler) == REASONING_BUDGET_COUNTING );
199+
200+ GGML_ASSERT (common_reasoning_budget_force (sampler) && " force() should succeed from COUNTING" );
201+ GGML_ASSERT (common_reasoning_budget_get_state (sampler) == REASONING_BUDGET_FORCING );
202+
203+ // forces the configured sequence from force_pos=0, then transitions to DONE
204+ GGML_ASSERT (get_forced_token (sampler, 102 ) == 102 );
205+ llama_sampler_accept (sampler, 102 );
206+ GGML_ASSERT (get_forced_token (sampler, 102 ) == 101 );
207+ llama_sampler_accept (sampler, 101 );
208+ GGML_ASSERT (common_reasoning_budget_get_state (sampler) == REASONING_BUDGET_DONE );
209+
210+ llama_sampler_free (sampler);
211+ }
212+
213+ // if IDLE, force() is a no-op
214+ {
215+ auto * sampler = common_reasoning_budget_init (nullptr , start, end, forced, 5 , REASONING_BUDGET_IDLE );
216+
217+ GGML_ASSERT (!common_reasoning_budget_force (sampler) && " force() must not transition from IDLE" );
218+ GGML_ASSERT (common_reasoning_budget_get_state (sampler) == REASONING_BUDGET_IDLE );
219+
220+ llama_sampler_free (sampler);
221+ }
222+
223+ // if DONE, force() is a no-op
224+ {
225+ auto * sampler = common_reasoning_budget_init (nullptr , start, end, forced, 5 , REASONING_BUDGET_IDLE );
226+
227+ llama_sampler_accept (sampler, 100 ); // COUNTING
228+ llama_sampler_accept (sampler, 101 ); // natural end -> DONE
229+ GGML_ASSERT (common_reasoning_budget_get_state (sampler) == REASONING_BUDGET_DONE );
230+
231+ GGML_ASSERT (!common_reasoning_budget_force (sampler) && " force() must not transition from DONE" );
232+ GGML_ASSERT (common_reasoning_budget_get_state (sampler) == REASONING_BUDGET_DONE );
233+
234+ llama_sampler_free (sampler);
235+ }
236+
237+ // if FORCING, force() is a no-op and must not rewind the force position
238+ {
239+ auto * sampler = common_reasoning_budget_init (nullptr , start, end, forced, 0 , REASONING_BUDGET_FORCING );
240+
241+ GGML_ASSERT (get_forced_token (sampler, 102 ) == 102 );
242+ llama_sampler_accept (sampler, 102 ); // advance to the second forced token (force_pos=1)
243+
244+ GGML_ASSERT (!common_reasoning_budget_force (sampler) && " force() must not transition from FORCING" );
245+ GGML_ASSERT (common_reasoning_budget_get_state (sampler) == REASONING_BUDGET_FORCING );
246+ GGML_ASSERT (get_forced_token (sampler, 102 ) == 101 && " force() must not rewind the force position" );
247+
248+ llama_sampler_free (sampler);
249+ }
250+
251+ // a null sampler is safely ignored
252+ GGML_ASSERT (!common_reasoning_budget_force (nullptr ));
253+
254+ fprintf (stderr, " Test 'manual force transition' passed\n " );
255+ }
256+
187257// UTF-8 boundary detection unit test
188258// Tests common_utf8_is_complete() from reasoning-budget.h
189259static void test_utf8_boundary_detection () {
@@ -312,8 +382,9 @@ int main(void) {
312382
313383 test_reasoning_budget_clone_mid_counting ();
314384 test_reasoning_budget_clone_mid_forcing ();
385+ test_reasoning_budget_force_manual ();
315386
316- printf (" OK (8 tests passed)\n " );
387+ printf (" OK (9 tests passed)\n " );
317388
318389 printf (" Testing UTF-8 boundary detection... " );
319390 test_utf8_boundary_detection ();
0 commit comments