329 lines
9.8 KiB
C
329 lines
9.8 KiB
C
/* SPDX-License-Identifier: AGPL-3.0-or-later */
|
|
/* Copyright (C) 2026 David (Corey) Herrera */
|
|
/**
|
|
* @file test_response_format_grammar.c
|
|
* @brief Unit tests for the `response_format` decoding grammar
|
|
* (src/serve/response_format_grammar.c).
|
|
*
|
|
* Drives the FSM exactly the way the constrained sampler does — ask
|
|
* rfg_token_allowed() for a candidate, and only rfg_advance() when it said yes —
|
|
* so a test that passes here is a statement about what the sampler can emit, not
|
|
* about a parser run after the fact.
|
|
*
|
|
* @status Covers json_object: acceptance of valid JSON, rejection of prose and
|
|
* of every malformed shape a model actually produces, the reasoning
|
|
* grace, the mask gating, and multi-char token boundaries.
|
|
* @issues None
|
|
* @todo None
|
|
*/
|
|
|
|
#include <stdio.h>
|
|
#include <string.h>
|
|
|
|
#include "lib/test.h"
|
|
#include "../src/serve/response_format_grammar.h"
|
|
|
|
/* Offer `text` as one candidate token: allowed → advance (what the sampler
|
|
* does), rejected → the live state is untouched (what the mask does). */
|
|
static bool feed_token(rfg_state *st, const char *text)
|
|
{
|
|
if (!rfg_token_allowed(st, text)) return false;
|
|
rfg_advance(st, text);
|
|
return true;
|
|
}
|
|
|
|
/* Feed a whole string in `chunk`-char tokens. Returns false at the first chunk
|
|
* the grammar refuses (so a test can assert exactly where a shape dies). */
|
|
static bool feed_chunks(rfg_state *st, const char *text, int chunk)
|
|
{
|
|
size_t n = strlen(text);
|
|
char buf[64];
|
|
for (size_t i = 0; i < n; i += (size_t)chunk) {
|
|
size_t len = n - i < (size_t)chunk ? n - i : (size_t)chunk;
|
|
if (len >= sizeof(buf)) len = sizeof(buf) - 1;
|
|
memcpy(buf, text + i, len);
|
|
buf[len] = '\0';
|
|
if (!feed_token(st, buf)) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/* A state with no reasoning grace (a non-think model): constrained from token 0. */
|
|
static rfg_state *fresh(void)
|
|
{
|
|
return rfg_state_new(RFG_FORMAT_JSON_OBJECT, false);
|
|
}
|
|
|
|
/* Accept a valid document at several token boundaries — the grammar has to hold
|
|
* for ANY tokenization, and a real vocab emits multi-char pieces like `{"`. */
|
|
static void accepts(const char *json)
|
|
{
|
|
for (int chunk = 1; chunk <= 5; chunk++) {
|
|
rfg_state *st = fresh();
|
|
ASSERT_NOT_NULL(st);
|
|
ASSERT_TRUE(feed_chunks(st, json, chunk));
|
|
ASSERT_TRUE(rfg_complete(st));
|
|
rfg_state_free(st);
|
|
}
|
|
}
|
|
|
|
/* Reject a malformed document: at SOME point the grammar must refuse, and it
|
|
* must not report completion. */
|
|
static void rejects(const char *json)
|
|
{
|
|
rfg_state *st = fresh();
|
|
ASSERT_NOT_NULL(st);
|
|
bool all_allowed = feed_chunks(st, json, 1);
|
|
ASSERT_FALSE(all_allowed && rfg_complete(st));
|
|
rfg_state_free(st);
|
|
}
|
|
|
|
/* ── acceptance ── */
|
|
|
|
static void test_accepts_flat_object(void)
|
|
{
|
|
accepts("{\"a\":1}");
|
|
accepts("{\"name\":\"pantheon\",\"ok\":true}");
|
|
}
|
|
|
|
static void test_accepts_empty_object(void)
|
|
{
|
|
accepts("{}");
|
|
}
|
|
|
|
static void test_accepts_nested_and_arrays(void)
|
|
{
|
|
accepts("{\"a\":{\"b\":[1,2,{\"c\":null}]},\"d\":[]}");
|
|
}
|
|
|
|
static void test_accepts_all_scalar_types(void)
|
|
{
|
|
accepts("{\"s\":\"x\",\"i\":-12,\"f\":3.5e-2,\"t\":true,\"f2\":false,\"n\":null}");
|
|
}
|
|
|
|
static void test_accepts_whitespace(void)
|
|
{
|
|
accepts(" {\n \"a\" : 1 ,\n \"b\" : [ 2 ]\n}");
|
|
}
|
|
|
|
static void test_accepts_string_escapes(void)
|
|
{
|
|
accepts("{\"a\":\"line\\nbreak \\\"quoted\\\" \\u00e9\"}");
|
|
}
|
|
|
|
/* ── rejection: the shapes a model actually produces ── */
|
|
|
|
static void test_rejects_prose(void)
|
|
{
|
|
rfg_state *st = fresh();
|
|
/* The whole point: a model that would have answered in prose cannot. */
|
|
ASSERT_FALSE(feed_token(st, "Sure"));
|
|
ASSERT_FALSE(feed_token(st, "Here"));
|
|
ASSERT_FALSE(rfg_complete(st));
|
|
/* But the JSON opener is available, so the mask always has a survivor. */
|
|
ASSERT_TRUE(feed_token(st, "{"));
|
|
rfg_state_free(st);
|
|
}
|
|
|
|
static void test_rejects_markdown_fence(void)
|
|
{
|
|
rfg_state *st = fresh();
|
|
ASSERT_FALSE(feed_token(st, "```json\n{"));
|
|
rfg_state_free(st);
|
|
}
|
|
|
|
static void test_rejects_top_level_array(void)
|
|
{
|
|
/* json_object means an OBJECT — a bare array is not it. */
|
|
rfg_state *st = fresh();
|
|
ASSERT_FALSE(feed_token(st, "["));
|
|
rfg_state_free(st);
|
|
}
|
|
|
|
static void test_rejects_trailing_comma(void)
|
|
{
|
|
rejects("{\"a\":1,}");
|
|
rejects("{\"a\":[1,]}");
|
|
}
|
|
|
|
static void test_rejects_unquoted_key(void)
|
|
{
|
|
rejects("{a:1}");
|
|
}
|
|
|
|
static void test_rejects_missing_colon(void)
|
|
{
|
|
rejects("{\"a\" 1}");
|
|
}
|
|
|
|
static void test_rejects_incomplete_number(void)
|
|
{
|
|
rejects("{\"a\":1e}");
|
|
rejects("{\"a\":-}");
|
|
rejects("{\"a\":1.}");
|
|
}
|
|
|
|
static void test_rejects_bad_literal(void)
|
|
{
|
|
rejects("{\"a\":tru}");
|
|
rejects("{\"a\":TRUE}");
|
|
}
|
|
|
|
static void test_rejects_bad_escape(void)
|
|
{
|
|
rejects("{\"a\":\"\\q\"}");
|
|
rejects("{\"a\":\"\\u00zz\"}");
|
|
}
|
|
|
|
static void test_rejects_raw_control_char_in_string(void)
|
|
{
|
|
rejects("{\"a\":\"x\ny\"}");
|
|
}
|
|
|
|
static void test_rejects_unbalanced_close(void)
|
|
{
|
|
rejects("{\"a\":[1}");
|
|
rejects("{\"a\":{]}");
|
|
}
|
|
|
|
/* ── completion + mask gating ── */
|
|
|
|
static void test_complete_stops_the_walk(void)
|
|
{
|
|
rfg_state *st = fresh();
|
|
ASSERT_TRUE(feed_chunks(st, "{\"a\":1}", 1));
|
|
ASSERT_TRUE(rfg_complete(st));
|
|
/* Once complete nothing further is a valid continuation — the serve stops
|
|
* decoding here, and a stray token could only invalidate the content. */
|
|
ASSERT_FALSE(rfg_token_allowed(st, "x"));
|
|
/* …and the mask goes inactive, so EOS becomes sampleable again. */
|
|
ASSERT_FALSE(rfg_mask_active(st));
|
|
rfg_state_free(st);
|
|
}
|
|
|
|
static void test_mask_active_while_open(void)
|
|
{
|
|
rfg_state *st = fresh();
|
|
ASSERT_TRUE(rfg_mask_active(st)); /* before the object opens */
|
|
ASSERT_TRUE(feed_token(st, "{"));
|
|
ASSERT_TRUE(rfg_mask_active(st)); /* inside the object */
|
|
ASSERT_TRUE(rfg_in_json(st));
|
|
ASSERT_TRUE(feed_token(st, "}"));
|
|
ASSERT_FALSE(rfg_mask_active(st)); /* done */
|
|
ASSERT_FALSE(rfg_in_json(st));
|
|
rfg_state_free(st);
|
|
}
|
|
|
|
/* ── the reasoning grace (think-style models) ── */
|
|
|
|
static void test_reasoning_grace_is_unconstrained(void)
|
|
{
|
|
rfg_state *st = rfg_state_new(RFG_FORMAT_JSON_OBJECT, true);
|
|
ASSERT_NOT_NULL(st);
|
|
ASSERT_FALSE(rfg_mask_active(st)); /* the think block is free */
|
|
ASSERT_FALSE(rfg_reasoning_ended(st));
|
|
ASSERT_TRUE(feed_token(st, "The user wants "));
|
|
ASSERT_TRUE(feed_token(st, "a JSON object. Let me think... "));
|
|
ASSERT_FALSE(rfg_mask_active(st));
|
|
|
|
/* "</think>" is self-detected in the raw stream — the serve emitter's think
|
|
* flag is not reliable on every model, which is why this is here. */
|
|
ASSERT_TRUE(feed_token(st, "</think>"));
|
|
ASSERT_TRUE(rfg_reasoning_ended(st));
|
|
ASSERT_TRUE(rfg_mask_active(st));
|
|
|
|
/* From here the answer is constrained. */
|
|
ASSERT_FALSE(feed_token(st, "Sure!"));
|
|
ASSERT_TRUE(feed_chunks(st, "{\"a\":1}", 2));
|
|
ASSERT_TRUE(rfg_complete(st));
|
|
rfg_state_free(st);
|
|
}
|
|
|
|
static void test_reasoning_close_split_across_tokens(void)
|
|
{
|
|
rfg_state *st = rfg_state_new(RFG_FORMAT_JSON_OBJECT, true);
|
|
ASSERT_TRUE(feed_token(st, "thinking</th"));
|
|
ASSERT_FALSE(rfg_reasoning_ended(st));
|
|
ASSERT_TRUE(feed_token(st, "ink>"));
|
|
ASSERT_TRUE(rfg_reasoning_ended(st));
|
|
ASSERT_TRUE(rfg_mask_active(st));
|
|
rfg_state_free(st);
|
|
}
|
|
|
|
static void test_explicit_reasoning_end(void)
|
|
{
|
|
/* The reasoning-budget force-close path calls this directly. */
|
|
rfg_state *st = rfg_state_new(RFG_FORMAT_JSON_OBJECT, true);
|
|
ASSERT_FALSE(rfg_mask_active(st));
|
|
rfg_reasoning_end(st);
|
|
ASSERT_TRUE(rfg_reasoning_ended(st));
|
|
ASSERT_TRUE(rfg_mask_active(st));
|
|
ASSERT_FALSE(feed_token(st, "prose"));
|
|
rfg_state_free(st);
|
|
}
|
|
|
|
static void test_text_format_builds_no_state(void)
|
|
{
|
|
/* {"type":"text"} is the default and must cost nothing. */
|
|
ASSERT_NULL(rfg_state_new(RFG_FORMAT_TEXT, false));
|
|
}
|
|
|
|
static void test_depth_cap_is_not_a_crash(void)
|
|
{
|
|
rfg_state *st = fresh();
|
|
ASSERT_TRUE(feed_token(st, "{"));
|
|
/* Open key/value pairs until the cap refuses another container. */
|
|
bool refused = false;
|
|
for (int i = 0; i < RFG_MAX_DEPTH + 8; i++) {
|
|
if (!feed_token(st, "\"a\":")) { refused = true; break; }
|
|
if (!feed_token(st, "{")) { refused = true; break; }
|
|
}
|
|
ASSERT_TRUE(refused);
|
|
ASSERT_FALSE(rfg_complete(st));
|
|
rfg_state_free(st);
|
|
}
|
|
|
|
static void test_null_state_is_safe(void)
|
|
{
|
|
/* The serve calls these unconditionally when the feature is off. */
|
|
ASSERT_TRUE(rfg_token_allowed(NULL, "x"));
|
|
ASSERT_FALSE(rfg_complete(NULL));
|
|
ASSERT_FALSE(rfg_mask_active(NULL));
|
|
ASSERT_FALSE(rfg_in_json(NULL));
|
|
rfg_advance(NULL, "x");
|
|
rfg_reasoning_end(NULL);
|
|
rfg_state_free(NULL);
|
|
}
|
|
|
|
int main(void)
|
|
{
|
|
printf("\n=== response_format grammar (json_object constrained decoding) ===\n\n");
|
|
RUN_TEST(accepts_flat_object);
|
|
RUN_TEST(accepts_empty_object);
|
|
RUN_TEST(accepts_nested_and_arrays);
|
|
RUN_TEST(accepts_all_scalar_types);
|
|
RUN_TEST(accepts_whitespace);
|
|
RUN_TEST(accepts_string_escapes);
|
|
RUN_TEST(rejects_prose);
|
|
RUN_TEST(rejects_markdown_fence);
|
|
RUN_TEST(rejects_top_level_array);
|
|
RUN_TEST(rejects_trailing_comma);
|
|
RUN_TEST(rejects_unquoted_key);
|
|
RUN_TEST(rejects_missing_colon);
|
|
RUN_TEST(rejects_incomplete_number);
|
|
RUN_TEST(rejects_bad_literal);
|
|
RUN_TEST(rejects_bad_escape);
|
|
RUN_TEST(rejects_raw_control_char_in_string);
|
|
RUN_TEST(rejects_unbalanced_close);
|
|
RUN_TEST(complete_stops_the_walk);
|
|
RUN_TEST(mask_active_while_open);
|
|
RUN_TEST(reasoning_grace_is_unconstrained);
|
|
RUN_TEST(reasoning_close_split_across_tokens);
|
|
RUN_TEST(explicit_reasoning_end);
|
|
RUN_TEST(text_format_builds_no_state);
|
|
RUN_TEST(depth_cap_is_not_a_crash);
|
|
RUN_TEST(null_state_is_safe);
|
|
TEST_REPORT();
|
|
return __ft_failed == 0 ? 0 : 1;
|
|
}
|