/* SPDX-License-Identifier: AGPL-3.0-or-later */ /* Copyright (C) 2026 David (Corey) Herrera */ /** * @file test_prefix_projection.c * @brief The prefix-cache agreement gate: what the serve PROJECTS for a turn * must be exactly what the client's next request TOKENIZES TO. * * This is the gate that was missing when the K80 test machine measured * `prefilled_tok == prompt_tok - sys_len` on every turn of a live agent-client * conversation — TTFT 19 → 38 → 59 * → 80 s over four turns, ~8 min at 16 k, because every rung of * bs_prefill_resume_sys's ladder missed and the whole history was re-prefilled. * * The property under test, stated as tokens: * * sg_project_client_rerender(reply) == the slice of the NEXT turn's * prompt that the client's re-render of that reply occupies. * * And the same property for the REASONING channel, which is the half a client * that sends `reasoning_content` back depends on: * * sg_encode_history_think_block(reasoning) == the slice that turn's * `...` occupies in the next prompt. * * Both sides are computed here for real — no model, no GPU: a synthetic * byte-level BPE tokenizer (one id per byte, plus the ChatML/think/tool-call * specials) stands in for the saber vocab, and the turn-2 prompt is built by the * production builder from the messages a faithful client would send. * * WHY THIS TEST CAN GO RED, and did. Every case carries a NEGATIVE CONTROL: the * projection the OLD code recorded — the token ids the model actually SAMPLED * for its reply. Those must NOT be a prefix of the next context, and they are * not, because "" has two legal tokenizations (one special id, or the * bytes) and the char-level tool grammar accepts either. That is the production * defect, reproduced here in tokens; the positive assertion is the fix. * * @status Complete. Runs on the Mac in the CPU `make test` suite. Covers both * recorded client conventions (reasoning dropped, reasoning resent) and * the THIRD one the projection rung does not record — a summarised * block, which matches neither and is why a summary costs a re-prefill. * @issues None * @todo None */ #include "lib/test.h" #include #include #include #include "../src/core/bpe_pretokenize.h" #include "../src/core/chat.h" #include "../src/core/engine.h" #include "../src/serve/json_reader.h" #include "../src/serve/serve_prompt_tokens.h" #include "../src/serve/tool_calling.h" #include "tokenizer/tokenizer.h" /* ============================================================ * A synthetic serve tokenizer * * One id per byte (GPT-2 codepoint order, no merges) plus the special tokens * the ChatML builder looks up. The contrast that matters is deliberate: the * text "" encodes to ELEVEN byte ids, while the special token is * ONE — the same two-tokenizations-of-one-string situation the saber vocab has * (248058 vs `< tool _call >`), scaled up so a divergence is unmissable. * ============================================================ */ static const char *const SPECIAL_TOKENS[] = { "<|im_start|>", "<|im_end|>", "", "", "", "", }; static ft_tokenizer *make_serve_tok(void) { ft_tokenizer *tok = ft_new(); ft_set_model_type(tok, 0); /* BPE — routes text through pn_bpe_encode */ for (int b = 0; b < 256; b++) { char raw[2] = { (char)b, 0 }; char *enc = gpt2_bytes_encode(raw); ft_add_token(tok, enc); free(enc); } for (size_t i = 0; i < sizeof(SPECIAL_TOKENS) / sizeof(SPECIAL_TOKENS[0]); i++) ft_add_token(tok, SPECIAL_TOKENS[i]); return tok; } /* The ids the MODEL produced for a span of its own output: plain text, because * that is what sampling emits when it walks the grammar's opener char by char. * This is what the old projection recorded, and it is the negative control. */ static uint32_t *sampled_ids(ft_tokenizer *tok, const char *text, int *n_out) { int n = 0; int *ids = pn_bpe_encode(tok, text, &n); uint32_t *out = malloc((size_t)(n > 0 ? n : 1) * sizeof(uint32_t)); for (int i = 0; i < n; i++) out[i] = (uint32_t)ids[i]; free(ids); *n_out = n; return out; } /* ============================================================ * The conversation under test * ============================================================ */ #define SYS_PROMPT "You are a careful orchestrator." #define USER_TURN_1 "What is item 1?" #define USER_TURN_2 "And item 2?" static const char TOOLS_JSON[] = "[{\"type\":\"function\",\"function\":{\"name\":\"attempt_completion\"," "\"parameters\":{\"type\":\"object\",\"properties\":" "{\"result\":{\"type\":\"string\"}}}}}]"; /* Everything one turn needs to be replayed as the client would send it back. */ typedef struct { const char *name; /* case label */ const char *content; /* the reply's visible content channel */ const char *tool_raw; /* the raw diverted tool block, or NULL */ } reply_case; /* Re-render a reply into the assistant `content` string the SERVE will build * from the client's next request — i.e. what combine_assistant_tool_calls does * to the tool_calls array the client echoes back. Owned; caller frees. */ static char *client_assistant_content(const reply_case *rc, const jr_value *tools) { if (!rc->tool_raw) return strdup(rc->content); tc_tool_call *calls = NULL; int n_calls = 0; if (tc_parse_tool_calls(rc->tool_raw, tools, TC_DIALECT_QWEN_XML, &calls, &n_calls) != 0 || n_calls <= 0) return NULL; /* Rebuild the OpenAI tool_calls array exactly as the reply carries it and * The client echoes it, then render it the way build_messages() will. */ json_writer jw; if (!jw_init(&jw)) { tc_tool_calls_free(calls, n_calls); return NULL; } jw_raw(&jw, "["); for (int i = 0; i < n_calls; i++) { if (i) jw_raw(&jw, ","); jw_raw(&jw, "{\"id\":"); jw_string(&jw, calls[i].id); jw_raw(&jw, ",\"type\":\"function\",\"function\":{\"name\":"); jw_string(&jw, calls[i].name); jw_raw(&jw, ",\"arguments\":"); jw_string(&jw, calls[i].arguments); jw_raw(&jw, "}}"); } jw_raw(&jw, "]"); jr_value *dom = jr_parse(jw.data); bool content_empty = !rc->content || !rc->content[0]; char *rendered = tc_render_assistant_tool_calls(dom, content_empty, TC_DIALECT_QWEN_XML); jr_free(dom); jw_free(&jw); tc_tool_calls_free(calls, n_calls); if (!rendered) return NULL; size_t clen = content_empty ? 0 : strlen(rc->content); char *joined = malloc(clen + strlen(rendered) + 1); if (joined) { if (clen) memcpy(joined, rc->content, clen); memcpy(joined + clen, rendered, strlen(rendered) + 1); } free(rendered); return joined; } /* Is a[0,a_n) a strict prefix of b[0,b_n)? */ static bool is_strict_prefix(const uint32_t *a, int a_n, const uint32_t *b, int b_n) { return a_n > 0 && a_n < b_n && memcmp(a, b, (size_t)a_n * sizeof(uint32_t)) == 0; } /* Run one reply shape end to end: project it, rebuild the next turn's prompt * from what the client sends back, and require the projection to be a strict * prefix of it. Then require the ids the model SAMPLED not to be. */ static void test_projection_case(pn_model *model, const jr_value *tools, const reply_case *rc) { printf("case: %s\n", rc->name); sg_message turn1[2] = { { "system", SYS_PROMPT, NULL }, { "user", USER_TURN_1, NULL }, }; int n1 = 0, sys_len = 0, think_prefill = 0; uint32_t *p1 = sg_build_prompt_tokens(model, turn1, 2, SG_THINKING_DEFAULT, &n1, &sys_len, &think_prefill); ASSERT_TRUE(p1 != NULL); ASSERT_TRUE(sys_len > 0 && sys_len < n1); ASSERT_INT_EQ(think_prefill, 1); /* the forced "" opener */ int head_n = n1 - think_prefill; int rerender_n = 0; uint32_t *rerender = sg_project_client_rerender(model, rc->content, rc->tool_raw, tools, TC_DIALECT_QWEN_XML, &rerender_n); ASSERT_TRUE(rerender != NULL); ASSERT_TRUE(rerender_n > 0); /* The projection the slot records: the client's head + the re-render. */ int vis_n = head_n + rerender_n; uint32_t *vis = malloc((size_t)vis_n * sizeof(uint32_t)); memcpy(vis, p1, (size_t)head_n * sizeof(uint32_t)); memcpy(vis + head_n, rerender, (size_t)rerender_n * sizeof(uint32_t)); /* Turn 2 — what a faithful client posts next. */ char *assistant_content = client_assistant_content(rc, tools); ASSERT_TRUE(assistant_content != NULL); sg_message turn2[4] = { { "system", SYS_PROMPT, NULL }, { "user", USER_TURN_1, NULL }, { "assistant", assistant_content, NULL }, { "user", USER_TURN_2, NULL }, }; int n2 = 0; uint32_t *p2 = sg_build_prompt_tokens(model, turn2, 4, SG_THINKING_DEFAULT, &n2, NULL, NULL); ASSERT_TRUE(p2 != NULL); ASSERT_TRUE(is_strict_prefix(vis, vis_n, p2, n2)); /* NEGATIVE CONTROL — the projection the old code recorded: the ids the * model actually sampled for its reply. This is the measured production * bug, and it must NOT match, or the assertion above proves nothing. */ const char *sampled_text = rc->tool_raw ? rc->tool_raw : rc->content; int sampled_n = 0; uint32_t *sampled = sampled_ids(model->tokenizer, sampled_text, &sampled_n); int old_n = head_n + sampled_n; uint32_t *old_vis = malloc((size_t)old_n * sizeof(uint32_t)); memcpy(old_vis, p1, (size_t)head_n * sizeof(uint32_t)); memcpy(old_vis + head_n, sampled, (size_t)sampled_n * sizeof(uint32_t)); if (rc->tool_raw) ASSERT_TRUE(!is_strict_prefix(old_vis, old_n, p2, n2)); free(old_vis); free(sampled); free(p2); free(assistant_content); free(vis); free(rerender); free(p1); } /* ============================================================ * Cases * ============================================================ */ /* The wrapped call the grammar compels — and the exact shape the K80 test machine saw * diverge, because the model spelled "" out in plain tokens. */ static const char TOOL_RAW_WRAPPED[] = "\n\n\n" "Item 1 is a widget.\n\n\n"; /* The bare form the Q3 model emits much of the time: no wrapper at all, so the * client's re-render ADDS one the model never generated. */ static const char TOOL_RAW_BARE[] = "\n\n" "Item 1 is a widget.\n\n"; static void test_tool_turn_wrapped(void) { ft_tokenizer *tok = make_serve_tok(); pn_model model = {0}; model.tokenizer = tok; model.chat_style = PN_CHAT_CHATML_THINK; jr_value *tools = jr_parse(TOOLS_JSON); reply_case rc = { "wrapped tool call (the observed case)", "", TOOL_RAW_WRAPPED }; test_projection_case(&model, tools, &rc); jr_free(tools); ft_free(tok); } static void test_tool_turn_bare(void) { ft_tokenizer *tok = make_serve_tok(); pn_model model = {0}; model.tokenizer = tok; model.chat_style = PN_CHAT_CHATML_THINK; jr_value *tools = jr_parse(TOOLS_JSON); reply_case rc = { "bare call the client re-wraps", "", TOOL_RAW_BARE }; test_projection_case(&model, tools, &rc); jr_free(tools); ft_free(tok); } static void test_tool_turn_with_prose(void) { ft_tokenizer *tok = make_serve_tok(); pn_model model = {0}; model.tokenizer = tok; model.chat_style = PN_CHAT_CHATML_THINK; jr_value *tools = jr_parse(TOOLS_JSON); reply_case rc = { "prose + a tool call (the \\n\\n separator)", "Let me look that up.", TOOL_RAW_WRAPPED }; test_projection_case(&model, tools, &rc); jr_free(tools); ft_free(tok); } static void test_prose_turn(void) { ft_tokenizer *tok = make_serve_tok(); pn_model model = {0}; model.tokenizer = tok; model.chat_style = PN_CHAT_CHATML_THINK; reply_case rc = { "plain prose answer", "Item 1 is a widget.", NULL }; test_projection_case(&model, NULL, &rc); ft_free(tok); } /* ============================================================ * The reasoning-bearing half of the same contract * * A client that resends `reasoning_content` is the one that keeps the model * reasoning at all (18/18 collapse without it, 0/18 with it — Fisher * p = 2.2e-10). But its history no * longer matches the think-ELIDED projection, so before this rung every such * turn re-prefilled the whole conversation. * * The property under test: * * head ++ sg_encode_history_think_block(reasoning) ++ rerender(reply) * == the prefix of the NEXT turn's prompt when that turn's assistant * message carries `reasoning_content`. * * The client never has to reproduce the ids the model SAMPLED inside its think * block — it echoes the string and the serve re-derives the ids through the * builder's own encoder. Both negative controls below are what makes that a * claim and not a tautology: each projection must MISS the other client's * history, which is exactly the production defect in each direction. * ============================================================ */ /* The reasoning the model streamed on this turn, echoed back verbatim by the * client. Contains a ""-adjacent character run on purpose: the block * markers must come from the SPECIAL ids the builder pushes, not from bytes * that happen to live in the reasoning text. */ #define TURN_1_REASONING \ "The user wants item 1. I should call the tool rather than guess.\n" \ "Note: do not write here." static void assert_reasoning_projection(pn_model *model, const jr_value *tools, const reply_case *rc) { printf("case: %s\n", rc->name); sg_message turn1[2] = { { "system", SYS_PROMPT, NULL }, { "user", USER_TURN_1, NULL }, }; int n1 = 0, sys_len = 0, think_prefill = 0; uint32_t *p1 = sg_build_prompt_tokens(model, turn1, 2, SG_THINKING_DEFAULT, &n1, &sys_len, &think_prefill); ASSERT_NOT_NULL(p1); int head_n = n1 - think_prefill; int rerender_n = 0; uint32_t *rerender = sg_project_client_rerender(model, rc->content, rc->tool_raw, tools, TC_DIALECT_QWEN_XML, &rerender_n); ASSERT_NOT_NULL(rerender); int think_n = 0; uint32_t *think = sg_encode_history_think_block(model, TURN_1_REASONING, &think_n); ASSERT_NOT_NULL(think); ASSERT_TRUE(think_n > 0); /* The two projections the slot records for this one turn. */ int elided_n = head_n + rerender_n; uint32_t *elided = malloc((size_t)elided_n * sizeof(uint32_t)); memcpy(elided, p1, (size_t)head_n * sizeof(uint32_t)); memcpy(elided + head_n, rerender, (size_t)rerender_n * sizeof(uint32_t)); int bearing_n = head_n + think_n + rerender_n; uint32_t *bearing = malloc((size_t)bearing_n * sizeof(uint32_t)); memcpy(bearing, p1, (size_t)head_n * sizeof(uint32_t)); memcpy(bearing + head_n, think, (size_t)think_n * sizeof(uint32_t)); memcpy(bearing + head_n + think_n, rerender, (size_t)rerender_n * sizeof(uint32_t)); /* Turn 2, both ways: the SAME conversation, differing only in whether the * assistant history turn carries the reasoning back. */ char *assistant_content = client_assistant_content(rc, tools); ASSERT_NOT_NULL(assistant_content); sg_message with_reasoning[4] = { { "system", SYS_PROMPT, NULL }, { "user", USER_TURN_1, NULL }, { "assistant", assistant_content, TURN_1_REASONING }, { "user", USER_TURN_2, NULL }, }; sg_message without_reasoning[4] = { { "system", SYS_PROMPT, NULL }, { "user", USER_TURN_1, NULL }, { "assistant", assistant_content, NULL }, { "user", USER_TURN_2, NULL }, }; int n_with = 0, n_without = 0; uint32_t *p_with = sg_build_prompt_tokens(model, with_reasoning, 4, SG_THINKING_DEFAULT, &n_with, NULL, NULL); uint32_t *p_without = sg_build_prompt_tokens(model, without_reasoning, 4, SG_THINKING_DEFAULT, &n_without, NULL, NULL); ASSERT_NOT_NULL(p_with); ASSERT_NOT_NULL(p_without); /* Sending the reasoning back really does change the prompt — otherwise the * two assertions below would be the same assertion twice. */ ASSERT_TRUE(n_with > n_without); /* THE PROPERTY. */ ASSERT_TRUE(is_strict_prefix(bearing, bearing_n, p_with, n_with)); /* NEGATIVE CONTROL 1 — the production defect this rung fixes: the elided * projection does NOT match a client that sends reasoning, so before the * reasoning-bearing candidate existed every such turn re-prefilled. */ ASSERT_TRUE(!is_strict_prefix(elided, elided_n, p_with, n_with)); /* NEGATIVE CONTROL 2 — and the new candidate must not cannibalize the old * one: it must MISS a client that drops reasoning, so the two are decided * by the request and never by luck. */ ASSERT_TRUE(!is_strict_prefix(bearing, bearing_n, p_without, n_without)); ASSERT_TRUE(is_strict_prefix(elided, elided_n, p_without, n_without)); free(p_without); free(p_with); free(assistant_content); free(bearing); free(elided); free(think); free(rerender); free(p1); } static void test_reasoning_projection_prose(void) { ft_tokenizer *tok = make_serve_tok(); pn_model model = {0}; model.tokenizer = tok; model.chat_style = PN_CHAT_CHATML_THINK; reply_case rc = { "reasoning resent + prose answer", "Item 1 is a widget.", NULL }; assert_reasoning_projection(&model, NULL, &rc); ft_free(tok); } static void test_reasoning_projection_tool_call(void) { ft_tokenizer *tok = make_serve_tok(); pn_model model = {0}; model.tokenizer = tok; model.chat_style = PN_CHAT_CHATML_THINK; jr_value *tools = jr_parse(TOOLS_JSON); reply_case rc = { "reasoning resent + a bare tool call the client re-wraps", "", TOOL_RAW_BARE }; assert_reasoning_projection(&model, tools, &rc); jr_free(tools); ft_free(tok); } /* The projection must be EMPTY exactly where the builder renders no block, or * the rung would predict a prompt that never arrives and the session would miss * every turn while looking like it had a candidate. */ static void test_think_block_projects_nothing_when_the_builder_renders_none(void) { printf("case: no think block to project -> no candidate\n"); ft_tokenizer *tok = make_serve_tok(); pn_model model = {0}; model.tokenizer = tok; model.chat_style = PN_CHAT_CHATML_THINK; int n = -1; ASSERT_TRUE(sg_encode_history_think_block(&model, NULL, &n) == NULL); ASSERT_INT_EQ(n, 0); ASSERT_TRUE(sg_encode_history_think_block(&model, "", &n) == NULL); ASSERT_INT_EQ(n, 0); /* A non-think chat style renders a history assistant turn WITHOUT a block * whatever `reasoning` says (sg_build_prompt_tokens gates on the style), so * projecting one here would be a guaranteed miss. */ model.chat_style = PN_CHAT_CHATML; ASSERT_TRUE(sg_encode_history_think_block(&model, "some reasoning", &n) == NULL); ASSERT_INT_EQ(n, 0); /* And it really is the style, not the text: the same call on a think style * produces a block. */ model.chat_style = PN_CHAT_CHATML_THINK; uint32_t *ids = sg_encode_history_think_block(&model, "some reasoning", &n); ASSERT_NOT_NULL(ids); ASSERT_TRUE(n > 0); /* The tags are the SPECIAL ids, not their bytes — a byte-encoded "" * would never reach the trained id and the model would read the block as * prose. */ int open_id = ft_token_to_id(tok, ""); int close_id = ft_token_to_id(tok, ""); ASSERT_INT_EQ((int)ids[0], open_id); bool closed = false; for (int i = 0; i < n; i++) if ((int)ids[i] == close_id) closed = true; ASSERT_TRUE(closed); free(ids); ft_free(tok); } /* ============================================================ * A THIRD convention: a SUMMARY of the reasoning * * The harness wants OFF / SUMMARY / FULL per turn. Two of those are recorded * candidates. The third is a block that is PRESENT but carries different text, * so it is a different token sequence and matches neither — asserted here * through the production builder rather than by assumption, because this is * where the per-turn idea either survives or dies. * * The same case carries the fix: the block the summary client sends is exactly * what sg_encode_history_think_block produces from the summary STRING. The * encoder is already general over any reasoning text; the only thing the serve * lacks at record time is which string the client will choose. So a third * recorded projection works — provided the summary comes from a source both * sides share. * ============================================================ */ #define TURN_1_SUMMARY "Decided to call the tool instead of guessing." static void test_summarised_reasoning_is_a_third_rendering(void) { printf("case: a summarised block is neither recorded candidate\n"); ft_tokenizer *tok = make_serve_tok(); pn_model model = {0}; model.tokenizer = tok; model.chat_style = PN_CHAT_CHATML_THINK; sg_message turn1[2] = { { "system", SYS_PROMPT, NULL }, { "user", USER_TURN_1, NULL }, }; int n1 = 0, sys_len = 0, think_prefill = 0; uint32_t *p1 = sg_build_prompt_tokens(&model, turn1, 2, SG_THINKING_DEFAULT, &n1, &sys_len, &think_prefill); ASSERT_NOT_NULL(p1); int head_n = n1 - think_prefill; const char *reply = "Item 1 is a widget."; int rerender_n = 0; uint32_t *rerender = sg_project_client_rerender(&model, reply, NULL, NULL, TC_DIALECT_QWEN_XML, &rerender_n); ASSERT_NOT_NULL(rerender); /* The two projections bs_slot_note_visible records for this turn. */ int full_n = 0; uint32_t *full = sg_encode_history_think_block(&model, TURN_1_REASONING, &full_n); ASSERT_NOT_NULL(full); int elided_n = head_n + rerender_n; uint32_t *elided = malloc((size_t)elided_n * sizeof(uint32_t)); memcpy(elided, p1, (size_t)head_n * sizeof(uint32_t)); memcpy(elided + head_n, rerender, (size_t)rerender_n * sizeof(uint32_t)); int bearing_n = head_n + full_n + rerender_n; uint32_t *bearing = malloc((size_t)bearing_n * sizeof(uint32_t)); memcpy(bearing, p1, (size_t)head_n * sizeof(uint32_t)); memcpy(bearing + head_n, full, (size_t)full_n * sizeof(uint32_t)); memcpy(bearing + head_n + full_n, rerender, (size_t)rerender_n * sizeof(uint32_t)); /* What a SUMMARY client posts next turn: the same conversation, the same * reply, a shorter think block. */ sg_message summarised[4] = { { "system", SYS_PROMPT, NULL }, { "user", USER_TURN_1, NULL }, { "assistant", reply, TURN_1_SUMMARY }, { "user", USER_TURN_2, NULL }, }; int n_summ = 0; uint32_t *p_summ = sg_build_prompt_tokens(&model, summarised, 4, SG_THINKING_DEFAULT, &n_summ, NULL, NULL); ASSERT_NOT_NULL(p_summ); /* THE FINDING. Neither candidate matches, and the divergence is INSIDE the * block: everything the client sent for earlier turns still agrees, so this * is the newest turn's rendering and nothing else. */ ASSERT_TRUE(!is_strict_prefix(bearing, bearing_n, p_summ, n_summ)); ASSERT_TRUE(!is_strict_prefix(elided, elided_n, p_summ, n_summ)); ASSERT_INT_EQ(memcmp(p_summ, p1, (size_t)head_n * sizeof(uint32_t)), 0); /* THE FIX, as far as the encoder is concerned: the same function that built * the reasoning-bearing candidate builds a matching one from the summary * string. Recording a third projection is therefore a bookkeeping change, * not a new rendering path — the missing input is the STRING. */ int summ_block_n = 0; uint32_t *summ_block = sg_encode_history_think_block(&model, TURN_1_SUMMARY, &summ_block_n); ASSERT_NOT_NULL(summ_block); ASSERT_TRUE(summ_block_n < full_n); /* it really is shorter */ int third_n = head_n + summ_block_n + rerender_n; uint32_t *third = malloc((size_t)third_n * sizeof(uint32_t)); memcpy(third, p1, (size_t)head_n * sizeof(uint32_t)); memcpy(third + head_n, summ_block, (size_t)summ_block_n * sizeof(uint32_t)); memcpy(third + head_n + summ_block_n, rerender, (size_t)rerender_n * sizeof(uint32_t)); ASSERT_TRUE(is_strict_prefix(third, third_n, p_summ, n_summ)); /* The degenerate summary — the empty string — is not a third convention at * all: the builder renders no block, so it IS the elided candidate and * costs nothing. "Summarise to nothing" and OFF are the same request. */ sg_message empty_summary[4] = { { "system", SYS_PROMPT, NULL }, { "user", USER_TURN_1, NULL }, { "assistant", reply, "" }, { "user", USER_TURN_2, NULL }, }; int n_empty = 0; uint32_t *p_empty = sg_build_prompt_tokens(&model, empty_summary, 4, SG_THINKING_DEFAULT, &n_empty, NULL, NULL); ASSERT_NOT_NULL(p_empty); ASSERT_TRUE(is_strict_prefix(elided, elided_n, p_empty, n_empty)); free(p_empty); free(third); free(summ_block); free(p_summ); free(bearing); free(elided); free(full); free(rerender); free(p1); ft_free(tok); } /* A turn that produced nothing reproducible (a pure-reasoning runaway) must * project NOTHING, so the caller leaves the rung disabled rather than record a * projection that would silently mismatch. */ static void test_unreproducible_turn_projects_nothing(void) { printf("case: unreproducible turn disables the rung\n"); ft_tokenizer *tok = make_serve_tok(); pn_model model = {0}; model.tokenizer = tok; model.chat_style = PN_CHAT_CHATML_THINK; int n = -1; uint32_t *ids = sg_project_client_rerender(&model, "", NULL, NULL, TC_DIALECT_QWEN_XML, &n); ASSERT_TRUE(ids == NULL); ASSERT_INT_EQ(n, 0); /* A diverted block that will not parse back into calls is the same case. */ ids = sg_project_client_rerender(&model, "", "" + "assistant\n". */ int header_n = 1; int hdr_ids_n = 0; int *hdr = pn_bpe_encode(tok, "assistant\n", &hdr_ids_n); free(hdr); header_n += hdr_ids_n; ASSERT_TRUE(n > header_n + direct_n); ASSERT_INT_EQ(memcmp(prompt + header_n, direct, (size_t)direct_n * sizeof(uint32_t)), 0); /* And the marker really did become ONE id, not its bytes — otherwise the * comparison above would pass on two identically-wrong encodings. */ int open_id = ft_token_to_id(tok, ""); ASSERT_TRUE(open_id >= 0); bool found = false; for (int i = 0; i < direct_n; i++) if ((int)direct[i] == open_id) found = true; ASSERT_TRUE(found); free(prompt); free(direct); ft_free(tok); } /* Does `enable_thinking` actually REACH THE MODEL? * * A test that only checks the field parses would pass while the feature does * nothing, so this asserts on the token stream the model is prefilled with — * the last thing before the forward pass. SG_THINKING_OFF must land the * template's enable_thinking=false serialization, "\n\n\n\n", * as SPECIAL ids (byte-encoded "" would never reach the trained id); * DEFAULT must not contain a "" at all, because the block is left OPEN * for the model to close itself. * * Asserting BOTH directions is the point: "OFF closes the block" alone would * still pass if the builder closed it unconditionally. */ static void assert_prefill_for_style(int chat_style, const char *label) { printf(" style: %s\n", label); ft_tokenizer *tok = make_serve_tok(); pn_model model = {0}; model.tokenizer = tok; model.chat_style = chat_style; int close_id = ft_token_to_id(tok, ""); int open_id = ft_token_to_id(tok, ""); ASSERT_TRUE(close_id >= 0 && open_id >= 0); sg_message msgs[1] = { { "user", "hi", NULL } }; int n_def = 0, prefill_def = 0; uint32_t *def = sg_build_prompt_tokens(&model, msgs, 1, SG_THINKING_DEFAULT, &n_def, NULL, &prefill_def); ASSERT_NOT_NULL(def); int n_off = 0, prefill_off = 0; uint32_t *off = sg_build_prompt_tokens(&model, msgs, 1, SG_THINKING_OFF, &n_off, NULL, &prefill_off); ASSERT_NOT_NULL(off); int n_on = 0, prefill_on = 0; uint32_t *on = sg_build_prompt_tokens(&model, msgs, 1, SG_THINKING_ON, &n_on, NULL, &prefill_on); ASSERT_NOT_NULL(on); bool def_closes = false, off_closes = false; for (int i = 0; i < n_def; i++) if ((int)def[i] == close_id) def_closes = true; for (int i = 0; i < n_off; i++) if ((int)off[i] == close_id) off_closes = true; /* The whole feature, in one pair of assertions. */ ASSERT_TRUE(off_closes); ASSERT_FALSE(def_closes); /* The closed block is the LAST thing in the prompt: the opener, then the * closer, then nothing but newlines. So the very next token the model emits * is already the answer — which is what makes this a dial rather than the * mid-sentence guillotine reasoning_budget is. * (In this synthetic vocab byte b has id b, so '\n' is id 10.) */ int i_open = -1, i_close = -1; for (int i = 0; i < n_off; i++) { if ((int)off[i] == open_id) i_open = i; if ((int)off[i] == close_id) i_close = i; } ASSERT_TRUE(i_open >= 0); ASSERT_TRUE(i_close > i_open); for (int i = i_close + 1; i < n_off; i++) ASSERT_INT_EQ((int)off[i], 10); /* The forced prefill spans exactly the opener-through-end run, and closing * the block costs strictly more forced tokens than leaving it open. */ ASSERT_INT_EQ(prefill_off, n_off - i_open); ASSERT_TRUE(prefill_off > prefill_def); /* ON must be byte-identical to DEFAULT here: PANTHEON_NO_THINK is unset in * the test env, so "explicitly on" and "unspecified" agree. */ ASSERT_INT_EQ(n_on, n_def); ASSERT_INT_EQ(prefill_on, prefill_def); ASSERT_INT_EQ(memcmp(on, def, (size_t)n_def * sizeof(uint32_t)), 0); free(def); free(off); free(on); ft_free(tok); } static void test_enable_thinking_reaches_the_prompt(void) { printf("case: enable_thinking changes the tokens the model is prefilled with\n"); assert_prefill_for_style(PN_CHAT_CHATML_THINK, "ChatML+think (nex-n2)"); assert_prefill_for_style(PN_CHAT_CHATML_THINK_NL, "ChatML+think\\n (saber)"); } /* A non-reasoning model has no think block to close, so the control must be * INERT there rather than injecting tags the model was never trained on. */ static void test_enable_thinking_is_inert_on_a_plain_model(void) { printf("case: enable_thinking is inert on a non-think chat style\n"); ft_tokenizer *tok = make_serve_tok(); pn_model model = {0}; model.tokenizer = tok; model.chat_style = PN_CHAT_CHATML; sg_message msgs[1] = { { "user", "hi", NULL } }; int n_def = 0, n_off = 0; uint32_t *def = sg_build_prompt_tokens(&model, msgs, 1, SG_THINKING_DEFAULT, &n_def, NULL, NULL); uint32_t *off = sg_build_prompt_tokens(&model, msgs, 1, SG_THINKING_OFF, &n_off, NULL, NULL); ASSERT_NOT_NULL(def); ASSERT_NOT_NULL(off); ASSERT_INT_EQ(n_off, n_def); ASSERT_INT_EQ(memcmp(off, def, (size_t)n_def * sizeof(uint32_t)), 0); free(def); free(off); ft_free(tok); } int main(void) { printf("\n=== prefix-cache projection agreement ===\n\n"); RUN_TEST(tool_turn_wrapped); RUN_TEST(tool_turn_bare); RUN_TEST(tool_turn_with_prose); RUN_TEST(prose_turn); RUN_TEST(unreproducible_turn_projects_nothing); RUN_TEST(one_encoder_for_both_sides); printf("\n=== reasoning-bearing projection (the client sends reasoning back) ===\n\n"); RUN_TEST(reasoning_projection_prose); RUN_TEST(reasoning_projection_tool_call); RUN_TEST(think_block_projects_nothing_when_the_builder_renders_none); RUN_TEST(summarised_reasoning_is_a_third_rendering); printf("\n=== per-request thinking control ===\n\n"); RUN_TEST(enable_thinking_reaches_the_prompt); RUN_TEST(enable_thinking_is_inert_on_a_plain_model); TEST_REPORT(); return __ft_failed != 0; }