BigMoeOnEdge/tests/chat_parse_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

161 lines
7 KiB
C++

// Regression test for the reasoning-parser wiring (issue #49).
//
// The bug: session.cpp built parse params with `common_chat_parser_params(cp)`, whose
// convenience constructor copies only format/generation_prompt and leaves the PEG grammar
// arena empty. `common_chat_parse` then throws on the first token, a catch swallows it, and
// the raw stream — reasoning markers and all — is shown verbatim. The fix copies the grammar
// arena across (`pp.parser = cp.parser`), extracted into bmoe::detail::build_parse_params.
//
// This exercises that exact function against a Qwen3-style template, with no model: the whole
// point is that the wiring is testable without a gguf. Assertions are explicit (not <cassert>)
// because the Release gates build with NDEBUG, which would compile assert() out.
#include "chat_parse.h"
#include "chat.h"
#include "common.h"
#include <cstdio>
#include <exception>
#include <fstream>
#include <sstream>
#include <string>
#include <vector>
// A real Qwen3 chat template, read from the vendored submodule at test time (the path is injected
// by CMake). It is detected as a tag-based reasoning format — the exact template upstream's own
// reasoning tests exercise (tests/test-chat.cpp, message_assist_thoughts). A hand-rolled stub does
// not reliably trip the differential analyzer, so the test uses the genuine file; if a submodule
// bump removes or moves it, the test fails loudly rather than rotting.
//
// This template PREFILLS "<think>\n" into the thinking-enabled generation prompt, so a model's
// stream begins with the reasoning text and closes it with "</think>" — hence the input below has
// no leading "<think>". common_chat_parse prepends the generation prompt before matching, so the
// prefilled opener is accounted for and the reasoning span is stripped from the shown content.
#ifndef BMOE_QWEN3_TEMPLATE_PATH
#error "BMOE_QWEN3_TEMPLATE_PATH must be defined by the build (path to the vendored Qwen3 .jinja)"
#endif
static int failures = 0;
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 void expect_eq(const char * name, const std::string & got, const std::string & want) {
if (got == want) {
std::printf("[PASS] %s\n", name);
} else {
std::printf("[FAIL] %s\n got: \"%s\"\n want: \"%s\"\n", name, got.c_str(), want.c_str());
++failures;
}
}
static std::string rstrip(std::string s) {
while (!s.empty() && (s.back() == '\n' || s.back() == '\r' || s.back() == ' ' || s.back() == '\t')) {
s.pop_back();
}
return s;
}
static void expect_true(const char * name, bool cond) {
if (cond) {
std::printf("[PASS] %s\n", name);
} else {
std::printf("[FAIL] %s\n", name);
++failures;
}
}
// Render a one-turn conversation and return the applied chat params (prompt + serialized grammar).
static common_chat_params apply_template(bool enable_thinking) {
static const std::string tmpl = read_file(BMOE_QWEN3_TEMPLATE_PATH);
auto tmpls = common_chat_templates_init(/*model=*/nullptr, tmpl);
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 = enable_thinking;
inputs.reasoning_format = COMMON_REASONING_FORMAT_AUTO;
return common_chat_templates_apply(tmpls.get(), inputs);
}
int main() {
// The reasoning stream: the template prefills the opening "<think>\n", so the model emits the
// reasoning text, closes it with "</think>", then the answer. Mirrors message_assist_thoughts
// in tests/test-chat.cpp.
const std::string raw = "I'm\nthinking\n</think>\n\nHello, world!\nWhat's up?";
const std::string want_content = "Hello, world!\nWhat's up?";
const std::string want_reasoning = "I'm\nthinking";
common_chat_params cp = apply_template(/*enable_thinking=*/true);
// The template must be detected as a tag-based reasoning format, or the test proves nothing.
expect_true("template yields a non-empty parser grammar", !cp.parser.empty());
// Positive: the production wiring copies the grammar and strips the reasoning block.
common_chat_parser_params good = bmoe::detail::build_parse_params(cp);
common_chat_msg parsed;
bool threw = false;
try {
parsed = common_chat_parse(common_chat_input(raw), /*is_partial=*/false, good);
} catch (const std::exception & e) {
threw = true;
std::printf(" build_parse_params path threw: %s\n", e.what());
}
expect_true("build_parse_params: parse does not throw", !threw);
expect_eq("build_parse_params: reasoning stripped from content", parsed.content, want_content);
// The parser preserves the reasoning span verbatim (a trailing newline before </think> may
// survive); the answer being clean is the contract, so compare the reasoning trimmed.
expect_eq("build_parse_params: reasoning captured", rstrip(parsed.reasoning_content), want_reasoning);
// Negative control: the pre-fix wiring (convenience ctor, no arena copied) must NOT correctly
// strip — whether it throws or returns the raw stream, it must not yield the clean answer.
// This pins bug #49 so a future submodule bump that silently changes the behaviour is noticed.
common_chat_parser_params unloaded(cp);
bool unloaded_stripped = false;
try {
unloaded_stripped =
common_chat_parse(common_chat_input(raw), /*is_partial=*/false, unloaded).content == want_content;
} catch (const std::exception &) {
unloaded_stripped = false;
}
expect_true("empty-arena ctor does not strip reasoning (the #49 bug)", !unloaded_stripped);
// Partial parse (the streaming path): mid-reasoning, before "</think>", the answer is not yet
// available and no reasoning must leak into the shown content — but the reasoning span itself
// MUST be exposed so a UI can stream it into a thinking block instead of sitting blank while
// the model reasons (issue #70). That is what session.cpp's shown_view surfaces per token.
bool partial_threw = false;
common_chat_msg partial;
try {
partial = common_chat_parse(common_chat_input("I'm\nthink"), /*is_partial=*/true, good);
} catch (const std::exception &) {
partial_threw = true;
}
expect_true("partial parse mid-reasoning does not throw", !partial_threw);
expect_true("partial parse leaks no reasoning into content", partial.content.empty());
expect_true("partial parse exposes the reasoning span for a live thinking block",
!partial.reasoning_content.empty());
if (failures == 0) {
std::printf("all chat-parse checks passed\n");
return 0;
}
std::printf("%d chat-parse check(s) failed\n", failures);
return 1;
}