BigMoeOnEdge/tests/think_control_test.cpp
Raffaele 2b808d5215
build: llama.cpp to upstream master of 2026-10-18 (#278)
* build: llama.cpp to upstream master of 2026-10-18

The fork branch bmoe/expert-ready-hook-1810-dl is upstream master 5aeac37 plus the same single
expert-ready-hook commit, in the variant that also answers the CPU registry's get_proc_address,
so a build that loads the CPU backend as a module reaches it too. 523 upstream commits; the new
base loads architectures the old one did not (Kolibri-1 among them).

Upstream reworked common's chat and speculative APIs. The engine follows with the smallest change
that keeps its behaviour, each one the way upstream's own callers now do it:

- the rendered prompt arrives as parts; it is joined back with .str(), as upstream's server does,
  and tokenized exactly as before;
- the parser arena's JSON serialization is gone; build_parse_params copies the arena instead, as
  upstream's chat session does, and reasoning_format already lives on the template inputs;
- common_speculative_process reads a common_batch; the batch the engine decodes is mirrored into
  one at the three MTP call sites. The decode itself stays on the public llama_batch API.

Same backends and build options, so docs/backends.md holds. All 23 host gates pass, a tiny
Mixtral-style and a tiny OLMoE model pass all 90 byte-identity gates, and on a real model the
greedy output is unchanged, MTP drafts and accepts, and the Metal prefill still runs.

* test(gates): G18c compares decide and perplexity to within the kernels' batch-shape rounding

The two paths put the same choice tokens through different batch shapes, a
wide prefill and one row at a time, and the ARM CPU backend from the
2026-10 base accumulates them in another order: the three log-probabilities
differ by the same 1e-12 relative, the log-softmax normaliser one ulp apart.
Upstream never promised that comparison bitwise; the gates that prove
streamed == resident stay bit-exact. A bound of 1e-9 relative keeps the
check nine orders of magnitude below a wrong answer.
2026-10-18 13:28:31 +02:00

198 lines
10 KiB
C++

// Thinking-control tests (issue #82).
//
// The bug: turning Thinking off set enable_thinking=false and stopped there. That flag is handed
// to the model's jinja template, which is free to ignore it — LFM2.5's never reads it — so the
// prompt came out byte-identical either way and the model reasoned on, with nothing reporting that
// the setting had been dropped.
//
// Two things are asserted here, both against REAL templates from the vendored submodule (paths
// injected by CMake), with no model:
//
// 1. probe_think_control tells the regimes apart — a template that honours the flag; one that
// ignores it but whose reasoning is structural, so the turn can start past it; and one whose
// model owns its reasoning span and cannot be asked to skip it. Including the case that is
// easy to get wrong: a model whose FAMILY declares reasoning tags but which never reasons
// itself, and so must not be reported uncontrollable.
// 2. the prefill produces a prompt whose reasoning span is already CLOSED. This is the assertion
// that matters: an opened-and-not-closed span is the opposite request, and it is exactly what
// asking for an AUTO continuation would silently produce.
//
// This file does name `<think>`, in the LFM2.5 case only, because a test of a specific template is
// entitled to state what that template's span looks like. The ENGINE names no marker for any
// family: everything it emits comes from llama.cpp's handler for the loaded model, and every
// decision it makes reads model-supplied data (`thinking_start_tag`/`thinking_end_tag`). The
// gpt-oss expectations below take the harder route and are derived from that template's own
// thinking-on render, so a submodule bump that changes harmony's channel markers updates them with
// it instead of breaking the test.
//
// Assertions are explicit (not <cassert>) because the Release gates build with NDEBUG, which
// would compile assert() out.
#include "thinking_control.h"
#include "chat.h"
#include "common.h"
#include <cstdio>
#include <exception>
#include <fstream>
#include <sstream>
#include <string>
#if !defined(BMOE_TMPL_QWEN3) || !defined(BMOE_TMPL_LFM25) || !defined(BMOE_TMPL_GPTOSS) || \
!defined(BMOE_TMPL_LFM2) || !defined(BMOE_TMPL_LFM25_INSTRUCT) || !defined(BMOE_TMPL_GEMMA4)
#error "template paths must be defined by the build"
#endif
static int failures = 0;
static void expect(const char * name, bool ok, const std::string & detail = {}) {
if (ok) {
std::printf("[PASS] %s\n", name);
} else {
std::printf("[FAIL] %s%s%s\n", name, detail.empty() ? "" : "\n ", detail.c_str());
++failures;
}
}
static std::string read_file(const char * path) {
std::ifstream in(path, std::ios::binary);
if (!in) {
std::printf("[FAIL] cannot open template: %s\n", path);
++failures;
return {};
}
std::ostringstream ss;
ss << in.rdbuf();
return ss.str();
}
static common_chat_templates_ptr load(const char * path) {
return common_chat_templates_init(/*model*/ nullptr, read_file(path));
}
// Render one turn the way generate() does, optionally with the reasoning span closed up front.
static std::string render(const common_chat_templates * tmpls, bool think, bool prefill) {
common_chat_msg user;
user.role = "user";
user.content = "hi";
common_chat_templates_inputs inputs;
inputs.messages = {user};
inputs.add_generation_prompt = true;
inputs.use_jinja = true;
inputs.enable_thinking = think;
inputs.reasoning_format = COMMON_REASONING_FORMAT_AUTO;
if (prefill) bmoe::detail::add_no_think_prefill(inputs);
return common_chat_templates_apply(const_cast<common_chat_templates *>(tmpls), inputs).prompt.str();
}
// The check that the rejected logit-level attempt could not make: is the reasoning span CLOSED?
//
// The closing marker is not named here — it is taken from the same template's thinking-on render,
// which is where the model is left mid-reasoning. A prefilled prompt must contain that marker (the
// span was closed) and must end at or after it (nothing reopens afterwards).
static void expect_span_closed(const char * name, const common_chat_templates * tmpls, const char * close_marker) {
const std::string prefilled = render(tmpls, /*think*/ false, /*prefill*/ true);
const size_t at = prefilled.rfind(close_marker);
if (at == std::string::npos) {
expect(name, false, "prefilled prompt never closes the reasoning span:\n " + prefilled);
return;
}
// Only whitespace and the answer-channel header may follow: what must NOT follow is a fresh
// opening of the span, which would leave the model reasoning again.
const std::string tail = prefilled.substr(at + std::char_traits<char>::length(close_marker));
expect(name, tail.find("<think>") == std::string::npos, "span reopens after closing:\n " + prefilled);
}
int main() {
try {
// Qwen3 reads enable_thinking (it renders its own closed <think></think> when false), so the
// flag alone is the whole mechanism and the engine must add nothing.
{
auto t = load(BMOE_TMPL_QWEN3);
expect("qwen3: template honours enable_thinking",
bmoe::detail::probe_think_control(t.get()) == bmoe::ThinkControl::Template);
}
// LFM2.5 — the model from issue #82. Its template never mentions enable_thinking, so both
// renders are identical. The continuation hook does land (the span below really is closed),
// but LFM2.5 declares <think>/</think>, so the closed span is only a suggestion to a model
// that owns its own reasoning tags — and measured on-device it reasons straight past it,
// untagged, into the answer. Reported as uncontrollable rather than made worse.
{
auto t = load(BMOE_TMPL_LFM25);
expect("lfm2.5: flag is inert in the template", render(t.get(), /*think*/ true, /*prefill*/ false) ==
render(t.get(), /*think*/ false, /*prefill*/ false));
expect("lfm2.5: probed as none (declares its own reasoning tags)",
bmoe::detail::probe_think_control(t.get()) == bmoe::ThinkControl::None);
// The prefill itself is well-formed — this is why "none" is a statement about the
// MODEL, not about a mechanism that failed to render.
expect_span_closed("lfm2.5: the prefill would close the span correctly", t.get(), "</think>");
}
// gpt-oss / harmony. Its template ignores the flag too, and it declares thinking tags —
// but reasoning is a channel the format separates structurally: the prefilled render closes
// the analysis channel and opens the final one, so priming past it is not something the
// model can decline. This is the case the engine used to carry as two hardcoded harmony
// marker strings in the decode path.
{
auto t = load(BMOE_TMPL_GPTOSS);
expect("gpt-oss: probed as prefill",
bmoe::detail::probe_think_control(t.get()) == bmoe::ThinkControl::Prefill);
const std::string prefilled = render(t.get(), /*think*/ false, /*prefill*/ true);
const std::string thinking = render(t.get(), /*think*/ true, /*prefill*/ false);
// Derived, not asserted as a constant: whatever channel the plain render leaves the
// model in, the prefilled one must have moved past it to a different, later one.
expect("gpt-oss: prefill moves past the reasoning channel",
prefilled != thinking && prefilled.size() > thinking.size());
}
// The non-reasoning members of the same family. Their handler publishes <think>/</think> for
// the family as a whole, but these templates never emit it, so the models never reason.
// Reporting them uncontrollable would tell the user "this model always reasons" about a model
// that never does — and disable a control that was simply moot. They must come out as
// Template: the flag is passed, and there is nothing to suppress.
for (const char * p : {BMOE_TMPL_LFM2, BMOE_TMPL_LFM25_INSTRUCT}) {
auto t = load(p);
const std::string name = std::string("non-reasoning lfm variant is not reported uncontrollable: ") + p;
expect(name.c_str(), bmoe::detail::probe_think_control(t.get()) == bmoe::ThinkControl::Template);
}
// Gemma 4 reads enable_thinking, so it never reaches the tag test at all — pinned because it
// ships in the app catalog and declares a thinking tag of its own.
{
auto t = load(BMOE_TMPL_GEMMA4);
expect("gemma4: template honours enable_thinking",
bmoe::detail::probe_think_control(t.get()) == bmoe::ThinkControl::Template);
}
// A template with no reasoning of any kind. Nothing to suppress, so nothing to report: None
// is reserved for models that demonstrably DO reason and cannot be stopped.
{
auto t = common_chat_templates_init(
nullptr, "{% for m in messages %}{{ m.role }}: {{ m.content }}\n{% endfor %}assistant:");
expect("plain template: nothing to suppress, so not reported uncontrollable",
bmoe::detail::probe_think_control(t.get()) == bmoe::ThinkControl::Template);
}
// A probe that could not run is no evidence the flag is inert: fail open to the
// pre-existing behaviour rather than declaring the model uncontrollable.
expect("null templates: fails open to template",
bmoe::detail::probe_think_control(nullptr) == bmoe::ThinkControl::Template);
expect("names are stable",
std::string(bmoe::think_control_name(bmoe::ThinkControl::Prefill)) == "prefill" &&
std::string(bmoe::think_control_name(bmoe::ThinkControl::None)) == "none" &&
std::string(bmoe::think_control_name(bmoe::ThinkControl::Template)) == "template");
} catch (const std::exception & e) {
std::printf("[FAIL] unexpected exception: %s\n", e.what());
++failures;
}
std::printf(failures == 0 ? "\nthink control: all checks passed\n" : "\nthink control: %d check(s) failed\n",
failures);
return failures == 0 ? 0 : 1;
}