Pantheon/tests/test_grammar_arm_window.c

366 lines
14 KiB
C

/* SPDX-License-Identifier: AGPL-3.0-or-later */
/* Copyright (C) 2026 David (Corey) Herrera */
/**
* @file test_grammar_arm_window.c
* @brief Gate for the full-logits ARMING WINDOW (PN_GRAMMAR_ARM_WINDOW).
*
* @status Proven red twice — see "PROOF THIS GATE CAN FAIL" below.
* @issues None
* @todo None
*
* WHAT IS BEING GATED
* ===================
* The serve arms the full-vocabulary logits row for the steps a grammar mask is
* live on, instead of for the whole turn. The invariant that has to survive:
*
* On every step whose sampler evaluates the grammar mask, the row it reads
* is the FULL vocabulary row.
*
* A missed mask is not a slow step. The mask can only choose among logits it
* can see, so a sparse top-k row holding none of the legal tokens has zero
* survivors and the walk fails open (AUTO) or force-terminates (FORCED) — the
* response_format defect, reintroduced.
*
* THE HAZARD IS EXACTLY ONE TOKEN, AND IT IS THE ONLY THING THIS TEST IS ABOUT
* ===========================================================================
* Within one decode step the real loops do, in order:
*
* refresh() -> mask + sample -> advance(chosen token) -> FORWARD
*
* `refresh()` reads the walk as it stood after the PREVIOUS token; the forward
* at the bottom fetches the row the NEXT step will sample. So the value that
* has to be known at forward time is the walk AFTER this step's advance —
* arming from the value `refresh()` produced (which is what `hook.active`
* holds) is stale by exactly one token, and the token it is stale on is the
* opener that engages the grammar.
*
* This file therefore models the loop ORDERING, not the sampler: it drives the
* real grammar walks over scripted token text and asks, per step, "was the row
* this step masked against armed?". Two policies are simulated:
*
* POLICY_AFTER_ADVANCE (shipped) armed[T+1] = mask_active(after token T)
* POLICY_STALE (the bug) armed[T+1] = mask_active(before token T)
*
* PROOF THIS GATE CAN FAIL
* ========================
* Every scenario asserts all three of:
* 1. the shipped policy misses ZERO masked steps;
* 2. the stale policy MISSES AT LEAST ONE — and the first miss is exactly the
* transition step (the first step whose mask is active). If the stale
* policy came out clean the script would not be exercising a transition at
* all, so the run exits VACUOUS (2) instead of passing;
* 3. the shipped policy leaves at least one step UNARMED — otherwise the
* window never narrows and the change is worth nothing on this script, so
* a "green" would again be measuring nothing.
*
* (2) is the anti-vacuity assertion the July sparse-sampler failure was missing:
* a bit-exactness gate that never runs the fast path passes 18/18 while testing
* the old path against itself.
*
* Pure C, libc-only — links the three grammar modules + the JSON reader. No
* engine, no model, no CUDA.
*/
#include "lib/test.h"
#include "../src/serve/tool_grammar.h"
#include "../src/serve/tool_grammar_json.h"
#include "../src/serve/response_format_grammar.h"
#include "../src/serve/json_reader.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* get_weather(city) — one tool, enough for both dialects. */
static const char *TOOLS_JSON =
"[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\","
"\"parameters\":{\"type\":\"object\",\"properties\":{"
"\"city\":{\"type\":\"string\"}},\"required\":[\"city\"]}}}]";
/* ── The walk under test, behind one vtable so one simulator drives all three
* grammars exactly as sg_tg_mask_active / sg_tg_step do. ── */
typedef struct {
void *st;
void (*advance)(void *st, const char *text);
int (*mask_active)(void *st);
void (*free_st)(void *st);
} walk;
static void w_adv_xml(void *st, const char *t) { tg_advance((tg_state *)st, t); }
static int w_act_xml(void *st) { return tg_mask_active((tg_state *)st) ? 1 : 0; }
static void w_free_xml(void *st) { tg_state_free((tg_state *)st); }
static void w_adv_json(void *st, const char *t) { tgj_advance((tgj_state *)st, t); }
static int w_act_json(void *st) { return tgj_mask_active((tgj_state *)st) ? 1 : 0; }
static void w_free_json(void *st) { tgj_state_free((tgj_state *)st); }
static void w_adv_rf(void *st, const char *t) { rfg_advance((rfg_state *)st, t); }
static int w_act_rf(void *st) { return rfg_mask_active((rfg_state *)st) ? 1 : 0; }
static void w_free_rf(void *st) { rfg_state_free((rfg_state *)st); }
#define MAX_STEPS 64
/* One simulated turn's per-step record. */
typedef struct {
int n;
int masked[MAX_STEPS]; /* the sampler evaluated the mask on this step */
int armed[MAX_STEPS]; /* the row this step read was the FULL row */
} run;
typedef enum { POLICY_AFTER_ADVANCE = 0, POLICY_STALE = 1 } arm_policy;
/**
* Replay `toks` through `w`, recording per step whether the mask was evaluated
* and whether the row that step read had been armed under `policy`.
*
* Mirrors the real decode loops step for step: the row a step samples was
* fetched by the PREVIOUS step's forward, so armed[T+1] is decided at the
* bottom of step T — after the advance under the shipped policy, before it
* under the stale one.
*
* @param w the grammar walk (consumed: this drives it to the end)
* @param toks NULL-terminated token TEXT stream
* @param policy which arming ordering to simulate
* @param out filled with the per-step record
*/
static void simulate(walk *w, const char *const *toks, arm_policy policy,
run *out)
{
memset(out, 0, sizeof(*out));
/* The turn's first row is armed before any token exists: both real paths
* arm it from the fresh walk. */
int armed_next = w->mask_active(w->st);
for (int t = 0; toks[t] && t < MAX_STEPS; t++) {
out->armed[t] = armed_next;
/* refresh() at the top of the step — what the sampler will gate on. */
int active_now = w->mask_active(w->st);
out->masked[t] = active_now;
out->n = t + 1;
w->advance(w->st, toks[t]);
/* Bottom of the step: arm the row the NEXT step reads. */
armed_next = (policy == POLICY_STALE) ? active_now
: w->mask_active(w->st);
}
}
/* Steps whose mask was evaluated against a row that was NOT armed. */
static int count_misses(const run *r)
{
int n = 0;
for (int i = 0; i < r->n; i++)
if (r->masked[i] && !r->armed[i]) n++;
return n;
}
static int first_miss(const run *r)
{
for (int i = 0; i < r->n; i++)
if (r->masked[i] && !r->armed[i]) return i;
return -1;
}
/* The transition step: the first step on which the mask is live. */
static int first_masked(const run *r)
{
for (int i = 0; i < r->n; i++)
if (r->masked[i]) return i;
return -1;
}
static int count_unarmed(const run *r)
{
int n = 0;
for (int i = 0; i < r->n; i++)
if (!r->armed[i]) n++;
return n;
}
/* Set by any scenario whose STALE arm came out clean — the script did not
* exercise a transition, so nothing was proved and main() exits VACUOUS. */
static int g_vacuous = 0;
static const char *g_vacuous_why = NULL;
/**
* Run one scenario under both policies and assert the three gate conditions.
*
* @param name scenario label, printed on failure
* @param mk builds a FRESH walk (called once per policy — the walk is
* consumed by the simulation)
* @param ud passed to `mk`
* @param toks the token text stream
*/
static void check_scenario(const char *name, walk (*mk)(void *ud), void *ud,
const char *const *toks)
{
run good, stale;
walk w = mk(ud);
simulate(&w, toks, POLICY_AFTER_ADVANCE, &good);
w.free_st(w.st);
w = mk(ud);
simulate(&w, toks, POLICY_STALE, &stale);
w.free_st(w.st);
/* 1. The invariant. */
if (count_misses(&good) != 0)
printf(" [%s] shipped policy MISSED the mask on step %d "
"(%d misses of %d steps)\n",
name, first_miss(&good), count_misses(&good), good.n);
ASSERT_INT_EQ(0, count_misses(&good));
/* 2. Anti-vacuity: the stale ordering must break, at the transition step. */
if (count_misses(&stale) == 0) {
g_vacuous = 1;
g_vacuous_why = name;
printf(" [%s] VACUOUS: the stale policy missed nothing — this "
"script never transitions, so it tests nothing\n", name);
}
ASSERT_TRUE(count_misses(&stale) > 0);
if (first_miss(&stale) != first_masked(&stale))
printf(" [%s] stale first miss %d != transition step %d\n",
name, first_miss(&stale), first_masked(&stale));
ASSERT_INT_EQ(first_masked(&stale), first_miss(&stale));
/* 3. The window must actually narrow, or "green" means nothing. */
if (count_unarmed(&good) == 0)
printf(" [%s] window never narrowed — every step armed\n", name);
ASSERT_TRUE(count_unarmed(&good) > 0);
printf(" [%s] %d steps, %d unarmed (the win), shipped misses 0, "
"stale misses %d first at step %d (transition %d)\n",
name, good.n, count_unarmed(&good), count_misses(&stale),
first_miss(&stale), first_masked(&stale));
}
/* ── walk factories ── */
static walk mk_xml_auto(void *ud) {
walk w = { tg_state_new((const tg_grammar *)ud), w_adv_xml, w_act_xml,
w_free_xml };
return w;
}
static walk mk_xml_forced(void *ud) {
walk w = { tg_state_new_forced((const tg_grammar *)ud, NULL), w_adv_xml,
w_act_xml, w_free_xml };
return w;
}
static walk mk_json_auto(void *ud) {
walk w = { tgj_state_new((const tgj_grammar *)ud), w_adv_json, w_act_json,
w_free_json };
return w;
}
static walk mk_rf(void *ud) {
(void)ud;
walk w = { rfg_state_new(RFG_FORMAT_JSON_OBJECT, true), w_adv_rf, w_act_rf,
w_free_rf };
return w;
}
/* ── Token streams. Multi-char pieces on purpose: real BPE tokens are, and the
* transition lands INSIDE a piece, which is precisely the case a
* character-granular argument would miss. ── */
/* AUTO + XML: the model reasons, closes, answers a little prose, then opens a
* call. This is the shape the client produces on essentially every request. */
static const char *const XML_AUTO[] = {
"Let", " me", " check", " the", " weather", ".", "</think>",
"Sure", ",", " one", " moment", ".",
"<tool_call>", "<function=", "get_weather", ">", "<parameter=", "city",
">", "Paris", "</parameter>", "</function>", "</tool_call>", NULL
};
/* FORCED + XML: the mask engages off reasoning-end, with no opener token at
* all — a different rising edge than in_call, and it must be caught too. */
static const char *const XML_FORCED[] = {
"Think", "ing", " about", " it", "</think>",
"<function=", "get_weather", ">", "<parameter=", "city", ">", "Paris",
"</parameter>", "</function>", NULL
};
/* AUTO + ornith JSON: the fenced dialect. */
static const char *const JSON_AUTO[] = {
"Check", "ing", ".", "</think>", "Here", " goes", ":", "\n",
"```json", "\n", "{\"function\":", "\"get_weather\"", ",",
"\"arguments\":", "{", "\"city\"", ":", "\"Paris\"", "}", "}", "\n",
"```", NULL
};
/* response_format json_object: the mask engages when reasoning ends, with the
* whole ANSWER constrained rather than a call. */
static const char *const RF_STREAM[] = {
"The", " user", " wants", " json", ".", "</think>",
"{", "\"a\"", ":", " 1", "}", NULL
};
static tg_grammar *g_xml;
static tgj_grammar *g_json;
static void test_xml_auto_transition(void) {
check_scenario("xml/auto", mk_xml_auto, g_xml, XML_AUTO);
}
static void test_xml_forced_transition(void) {
check_scenario("xml/forced", mk_xml_forced, g_xml, XML_FORCED);
}
static void test_json_auto_transition(void) {
check_scenario("json/auto", mk_json_auto, g_json, JSON_AUTO);
}
static void test_response_format_transition(void) {
check_scenario("response_format", mk_rf, NULL, RF_STREAM);
}
/* A turn that never calls a tool: the whole point of the window. No step may
* be armed, and neither policy can miss (nothing is ever masked). */
static void test_prose_turn_never_arms(void) {
static const char *const PROSE[] = {
"The", " capital", " of", " France", " is", " Paris", ".", "</think>",
"The", " capital", " of", " France", " is", " Paris", ".", NULL
};
run r;
walk w = mk_xml_auto(g_xml);
simulate(&w, PROSE, POLICY_AFTER_ADVANCE, &r);
w.free_st(w.st);
ASSERT_INT_EQ(0, count_misses(&r));
ASSERT_INT_EQ(r.n, count_unarmed(&r)); /* every step on the fast top-k path */
printf(" [xml/prose] %d steps, 0 armed — the whole turn stays sparse\n",
r.n);
}
/* The shipped policy's armed flag must equal what refresh() returns at the NEXT
* step, everywhere, not merely imply it. That equality is the mechanism's
* actual claim: it is the same read, moved to the other side of the advance. */
static void test_armed_equals_next_refresh(void) {
run r;
walk w = mk_xml_auto(g_xml);
simulate(&w, XML_AUTO, POLICY_AFTER_ADVANCE, &r);
w.free_st(w.st);
for (int t = 0; t < r.n; t++)
ASSERT_INT_EQ(r.masked[t], r.armed[t]);
printf(" [xml/auto] armed[T] == refresh()@T on all %d steps\n", r.n);
}
int main(void) {
jr_value *tools = jr_parse(TOOLS_JSON);
if (!tools) { fprintf(stderr, "tools schema failed to parse\n"); return 2; }
g_xml = tg_build(tools);
g_json = tgj_build(tools);
if (!g_xml || !g_json) {
fprintf(stderr, "grammar build failed\n");
return 2;
}
printf("\n=== grammar full-logits arming window ===\n");
RUN_TEST(xml_auto_transition);
RUN_TEST(xml_forced_transition);
RUN_TEST(json_auto_transition);
RUN_TEST(response_format_transition);
RUN_TEST(prose_turn_never_arms);
RUN_TEST(armed_equals_next_refresh);
tg_free(g_xml);
tgj_free(g_json);
jr_free(tools);
TEST_REPORT();
if (g_vacuous) {
fprintf(stderr, "\nVACUOUS: scenario '%s' could not break the stale "
"arming policy — the gate proved nothing.\n", g_vacuous_why);
return 2;
}
return __ft_failed == 0 ? 0 : 1;
}