Pantheon/tests/test_prefix_cache_sys.c

1936 lines
87 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* SPDX-License-Identifier: AGPL-3.0-or-later */
/* Copyright (C) 2026 David (Corey) Herrera */
/**
* @file test_prefix_cache_sys.c
* @brief SYS1 unit test — system-prefix caching in pn_prefix_cache plus the
* underlying qwen35moe state snapshot/restore, WITHOUT a model: the
* test provides its own pn_state_new/reset/free stubs over hand-built
* pn_qwen35moe_state structs (small dims), so it links only
* prefix_cache.c + qwen35moe_state_snapshot.c + batch_sched.c and runs
* on the Mac.
*
* Covers: snapshot/restore round-trip (fp32 + q4 incl. sink/window
* shadows), dim-mismatch rejection, the acquire_sys decision ladder
* (full-history append resume preferred; sys-prefix resume on divergent
* history; changed system prompt -> full reset + re-capture), the
* capture protocol (boundary pos guard), LRU eviction of snapshots, and
* the SYS1-BATCHED per-slot twin (bs_prefill_resume_sys /
* bs_prefill_capture_sys over a hand-assembled scheduler): the batched
* resume ladder, per-slot capture/restore, cross-slot isolation, slot
* takeover by another session, and the snapshot-affinity acquire pass.
* (Batched DECODE itself needs CUDA — that is the K80 hardware gate.)
*
* Also covers the projection rung in BOTH client conventions: reasoning
* elided (test_think_elided_projection) and reasoning RESENT
* (test_reasoning_bearing_resume), including the fallback-to-full-prefill
* that a divergent visible prefix must produce — and what it costs to
* CHANGE convention mid-session (test_reasoning_convention_switch):
* forward switches are free, a summarised block costs the turn it is
* introduced on, and retroactively dropping a block already sent costs
* every turn.
*
* And two conversations under ONE session id (opencode's title request
* + its real turns): each resumes from its own slot's cache
* (test_two_convos_*, bs_slot_reroute).
*
* @status Complete
* @issues None
* @todo None
*/
#include <stdio.h>
#include <limits.h>
#include <stdlib.h>
#include <string.h>
#include "../src/core/prefix_cache.h"
/* Phase 10b stub: the content-generation counter lives in
* qwen35moe_forward.c, which this test intentionally does not link (it
* stubs the pn_state lifecycle). snapshot-restore bumps it. */
unsigned pn_qwen35moe_kv_next_content_gen(void)
{
static unsigned counter;
return ++counter;
}
#include "../src/models/qwen35moe.h"
#include "../src/models/qwen35moe_internal.h"
#include "../src/models/qwen35moe_kv_q4.h"
#include "../src/serve/batch_sched.h"
static int failures = 0;
#define CHECK(cond, msg) do { \
if (cond) { printf(" PASS: %s\n", msg); } \
else { printf(" FAIL: %s (%s:%d)\n", msg, __FILE__, __LINE__); failures++; } \
} while (0)
/* ============================================================
* Hand-built dual state (small dims) + pn_state lifecycle stubs
* ============================================================ */
enum {
T_N_SSM = 2,
T_N_ATTN = 1,
T_CONV_M1 = 3,
T_CONV_DIM = 8,
T_STATE = 4,
T_VHEADS = 2,
T_HEADS_KV = 2,
T_HEAD_DIM = 32, /* multiple of 32 so q4 rows pack cleanly */
T_MAX_SEQ = 64,
};
static int g_make_q4 = 0; /* KV mode for the next make_state() */
static pn_qwen35moe_state *make_state(void)
{
pn_qwen35moe_state *s = calloc(1, sizeof(*s));
s->n_ssm = T_N_SSM;
s->n_attn = T_N_ATTN;
s->ssm = calloc(T_N_SSM, sizeof(*s->ssm));
s->kv = calloc(T_N_ATTN, sizeof(*s->kv));
for (int i = 0; i < T_N_SSM; i++) {
pn_qwen35moe_ssm_state *st = &s->ssm[i];
st->conv_dim = T_CONV_DIM;
st->conv_minus_1 = T_CONV_M1;
st->state_size = T_STATE;
st->n_v_heads = T_VHEADS;
st->conv_state = calloc((size_t)T_CONV_M1 * T_CONV_DIM, sizeof(float));
st->ssm_state = calloc((size_t)T_STATE * T_STATE * T_VHEADS,
sizeof(float));
}
for (int i = 0; i < T_N_ATTN; i++) {
pn_qwen35moe_kv *kv = &s->kv[i];
kv->n_head_kv = T_HEADS_KV;
kv->head_dim = T_HEAD_DIM;
kv->max_seq = T_MAX_SEQ;
kv->q4 = g_make_q4;
if (kv->q4) {
size_t bytes = (size_t)T_HEADS_KV * T_MAX_SEQ *
(size_t)pn_kvq4_row_bytes(T_HEAD_DIM);
kv->kq = calloc(bytes, 1);
kv->vq = calloc(bytes, 1);
size_t sink_n = (size_t)T_HEADS_KV * PN_KVQ4_SINKMAX * T_HEAD_DIM;
size_t win_n = (size_t)T_HEADS_KV * PN_KVQ4_WMAX * T_HEAD_DIM;
kv->k_sink = calloc(sink_n, sizeof(uint16_t));
kv->v_sink = calloc(sink_n, sizeof(uint16_t));
kv->k_win = calloc(win_n, sizeof(uint16_t));
kv->v_win = calloc(win_n, sizeof(uint16_t));
} else {
size_t n = (size_t)T_HEADS_KV * T_MAX_SEQ * T_HEAD_DIM;
kv->k = calloc(n, sizeof(float));
kv->v = calloc(n, sizeof(float));
}
}
return s;
}
static void free_state(pn_qwen35moe_state *s)
{
if (!s) return;
for (int i = 0; i < s->n_ssm; i++) {
free(s->ssm[i].conv_state);
free(s->ssm[i].ssm_state);
}
for (int i = 0; i < s->n_attn; i++) {
free(s->kv[i].k); free(s->kv[i].v);
free(s->kv[i].kq); free(s->kv[i].vq);
free(s->kv[i].k_sink); free(s->kv[i].v_sink);
free(s->kv[i].k_win); free(s->kv[i].v_win);
}
free(s->ssm);
free(s->kv);
free(s);
}
/* ---- pn_state lifecycle stubs (replace engine.c for this test) ---- */
/* Phase 11: bs_think_elided_resume refuses itself above the session context cap.
* engine.c is not linked here, so mirror its shape -- the model's own trained
* context when set, otherwise unbounded -- which leaves the rung ENABLED for
* these hand-built states and so keeps the think-elision paths under test. */
int pn_session_ctx_cap(const pn_model *model)
{
if (model && model->config.n_ctx > 0)
return model->config.n_ctx;
return INT_MAX;
}
/* The PN_REQPROF divergence dumps decode token ids to text so a miss can be read
* straight out of the serve log. Neither the tokenizer nor engine.c is linked
* here, and the dumps are diagnostic-only (unreachable with PN_REQPROF unset),
* so stub them rather than pull a tokenizer into a host-only unit test. */
char *ft_decode(ft_tokenizer *tok, int *ids, int length)
{
(void)tok; (void)ids; (void)length;
return NULL;
}
int ft_get_model_type(ft_tokenizer *tok)
{
(void)tok;
return -1;
}
char *gpt2_bytes_decode(const char *input)
{
(void)input;
return NULL;
}
pn_state *pn_state_new(pn_model *model)
{
pn_state *st = calloc(1, sizeof(*st));
st->config = &model->config;
st->kv_cache = make_state();
return st;
}
void pn_state_free(pn_state *state)
{
if (!state) return;
free_state((pn_qwen35moe_state *)state->kv_cache);
free(state);
}
void pn_state_reset(pn_state *state)
{
pn_qwen35moe_state *s = (pn_qwen35moe_state *)state->kv_cache;
state->pos = 0;
for (int i = 0; i < s->n_ssm; i++) {
memset(s->ssm[i].conv_state, 0,
(size_t)T_CONV_M1 * T_CONV_DIM * sizeof(float));
memset(s->ssm[i].ssm_state, 0,
(size_t)T_STATE * T_STATE * T_VHEADS * sizeof(float));
}
for (int i = 0; i < s->n_attn; i++) {
pn_qwen35moe_kv *kv = &s->kv[i];
if (kv->q4) {
size_t bytes = (size_t)T_HEADS_KV * T_MAX_SEQ *
(size_t)pn_kvq4_row_bytes(T_HEAD_DIM);
memset(kv->kq, 0, bytes);
memset(kv->vq, 0, bytes);
} else {
size_t n = (size_t)T_HEADS_KV * T_MAX_SEQ * T_HEAD_DIM;
memset(kv->k, 0, n * sizeof(float));
memset(kv->v, 0, n * sizeof(float));
}
kv->kv_mirror_stale = 0;
}
}
/* ============================================================
* Pattern fill / verify helpers ("simulated prefill")
* ============================================================ */
static void fill_ssm(pn_qwen35moe_state *s, float seed)
{
for (int i = 0; i < s->n_ssm; i++) {
for (int j = 0; j < T_CONV_M1 * T_CONV_DIM; j++)
s->ssm[i].conv_state[j] = seed + (float)(i * 100 + j);
for (int j = 0; j < T_STATE * T_STATE * T_VHEADS; j++)
s->ssm[i].ssm_state[j] = seed * 2.0f + (float)(i * 100 + j);
}
}
static int check_ssm(const pn_qwen35moe_state *s, float seed)
{
for (int i = 0; i < s->n_ssm; i++) {
for (int j = 0; j < T_CONV_M1 * T_CONV_DIM; j++)
if (s->ssm[i].conv_state[j] != seed + (float)(i * 100 + j))
return 0;
for (int j = 0; j < T_STATE * T_STATE * T_VHEADS; j++)
if (s->ssm[i].ssm_state[j] != seed * 2.0f + (float)(i * 100 + j))
return 0;
}
return 1;
}
/* Fill fp32 KV rows [from, to) with a per-(head,row,dim) pattern. */
static void fill_kv_rows(pn_qwen35moe_state *s, int from, int to, float seed)
{
for (int i = 0; i < s->n_attn; i++) {
pn_qwen35moe_kv *kv = &s->kv[i];
for (int h = 0; h < kv->n_head_kv; h++)
for (int r = from; r < to; r++)
for (int d = 0; d < kv->head_dim; d++) {
size_t at = ((size_t)h * kv->max_seq + r) * kv->head_dim + d;
kv->k[at] = seed + (float)(h * 10000 + r * 100 + d);
kv->v[at] = -seed - (float)(h * 10000 + r * 100 + d);
}
}
}
static int check_kv_rows(const pn_qwen35moe_state *s, int from, int to,
float seed)
{
for (int i = 0; i < s->n_attn; i++) {
const pn_qwen35moe_kv *kv = &s->kv[i];
for (int h = 0; h < kv->n_head_kv; h++)
for (int r = from; r < to; r++)
for (int d = 0; d < kv->head_dim; d++) {
size_t at = ((size_t)h * kv->max_seq + r) * kv->head_dim + d;
if (kv->k[at] != seed + (float)(h * 10000 + r * 100 + d))
return 0;
if (kv->v[at] != -seed - (float)(h * 10000 + r * 100 + d))
return 0;
}
}
return 1;
}
/* Byte-pattern fill for q4 buffers + shadows. */
static void fill_q4(pn_qwen35moe_state *s, unsigned seed)
{
pn_qwen35moe_kv *kv = &s->kv[0];
size_t bytes = (size_t)kv->n_head_kv * kv->max_seq *
(size_t)pn_kvq4_row_bytes(kv->head_dim);
for (size_t j = 0; j < bytes; j++) {
kv->kq[j] = (unsigned char)((seed + j) & 0xFF);
kv->vq[j] = (unsigned char)((seed + j + 7) & 0xFF);
}
size_t sink_n = (size_t)kv->n_head_kv * PN_KVQ4_SINKMAX * kv->head_dim;
size_t win_n = (size_t)kv->n_head_kv * PN_KVQ4_WMAX * kv->head_dim;
for (size_t j = 0; j < sink_n; j++) {
kv->k_sink[j] = (uint16_t)(seed + j);
kv->v_sink[j] = (uint16_t)(seed + j + 3);
}
for (size_t j = 0; j < win_n; j++) {
kv->k_win[j] = (uint16_t)(seed + j + 11);
kv->v_win[j] = (uint16_t)(seed + j + 13);
}
}
/* ============================================================
* Test 1 — snapshot/restore round-trip (fp32)
* ============================================================ */
static void test_snapshot_roundtrip_fp32(void)
{
printf("test: snapshot/restore round-trip (fp32)\n");
g_make_q4 = 0;
pn_qwen35moe_state *s = make_state();
fill_ssm(s, 1.0f);
fill_kv_rows(s, 0, 8, 1.0f);
s->kv[0].kv_mirror_stale = 1;
void *snap = pn_qwen35moe_state_snapshot(s, 8);
CHECK(snap != NULL, "snapshot at pos 8 succeeds");
CHECK(pn_qwen35moe_state_snapshot_pos(snap) == 8, "snapshot pos recorded");
/* Scribble everything (a later turn), then restore the boundary. */
fill_ssm(s, 9.0f);
fill_kv_rows(s, 0, 24, 9.0f);
CHECK(pn_qwen35moe_state_restore(s, snap) == 0, "restore succeeds");
CHECK(check_ssm(s, 1.0f), "SSM state byte-restored");
CHECK(check_kv_rows(s, 0, 8, 1.0f), "KV rows [0,8) byte-restored");
CHECK(check_kv_rows(s, 8, 24, 9.0f), "KV rows past the boundary untouched");
CHECK(s->kv[0].kv_mirror_stale == 0, "restore marks host mirror current");
CHECK(pn_qwen35moe_state_snapshot(s, 0) == NULL, "pos 0 snapshot rejected");
CHECK(pn_qwen35moe_state_snapshot(s, T_MAX_SEQ + 1) == NULL,
"pos > max_seq snapshot rejected");
pn_qwen35moe_state_snapshot_free(snap);
free_state(s);
}
/* ============================================================
* Test 2 — snapshot/restore round-trip (q4 + fp16 shadows)
* ============================================================ */
static void test_snapshot_roundtrip_q4(void)
{
printf("test: snapshot/restore round-trip (q4 + shadows)\n");
g_make_q4 = 1;
pn_qwen35moe_state *s = make_state();
fill_ssm(s, 3.0f);
fill_q4(s, 100);
void *snap = pn_qwen35moe_state_snapshot(s, 8);
CHECK(snap != NULL, "q4 snapshot succeeds");
/* Keep a reference copy of the captured region, scribble, restore. */
pn_qwen35moe_kv *kv = &s->kv[0];
size_t row_b = (size_t)pn_kvq4_row_bytes(kv->head_dim);
size_t bytes = (size_t)kv->n_head_kv * kv->max_seq * row_b;
unsigned char *kq_ref = malloc(bytes);
memcpy(kq_ref, kv->kq, bytes);
size_t win_n = (size_t)kv->n_head_kv * PN_KVQ4_WMAX * kv->head_dim;
uint16_t *win_ref = malloc(win_n * sizeof(uint16_t));
memcpy(win_ref, kv->k_win, win_n * sizeof(uint16_t));
fill_ssm(s, 8.0f);
fill_q4(s, 200);
CHECK(pn_qwen35moe_state_restore(s, snap) == 0, "q4 restore succeeds");
CHECK(check_ssm(s, 3.0f), "q4: SSM state byte-restored");
int rows_ok = 1;
for (int h = 0; h < kv->n_head_kv && rows_ok; h++)
if (memcmp(kv->kq + (size_t)h * kv->max_seq * row_b,
kq_ref + (size_t)h * kv->max_seq * row_b, 8 * row_b) != 0)
rows_ok = 0;
CHECK(rows_ok, "q4: KV rows [0,8) byte-restored");
CHECK(memcmp(kv->k_win, win_ref, win_n * sizeof(uint16_t)) == 0,
"q4: fp16 window ring byte-restored");
/* Mode mismatch: a q4 snapshot cannot restore into an fp32 state. */
g_make_q4 = 0;
pn_qwen35moe_state *s32 = make_state();
CHECK(pn_qwen35moe_state_restore(s32, snap) == -1,
"q4 snapshot rejected by fp32 state (dim guard)");
free_state(s32);
free(kq_ref);
free(win_ref);
pn_qwen35moe_state_snapshot_free(snap);
free_state(s);
}
/* ============================================================
* Test 2b — serialize/deserialize blob round-trip (Phase 7)
* The snap→bytes→snap round-trip MUST be byte-identical (it is pure
* packing/unpacking). Verified two ways: (1) re-serializing the deserialized
* snap yields byte-identical blob bytes; (2) restoring the deserialized snap
* into a fresh state reproduces the original state buffers exactly.
* ============================================================ */
static void test_snap_serialize_roundtrip(int q4)
{
printf("test: snap serialize/deserialize round-trip (%s)\n",
q4 ? "q4" : "fp32");
g_make_q4 = q4;
const uint64_t WV = 0xABCDEF0123456789ull;
pn_qwen35moe_state *s = make_state();
fill_ssm(s, 4.0f);
if (q4) fill_q4(s, 300); else fill_kv_rows(s, 0, 16, 4.0f);
void *snap = pn_qwen35moe_state_snapshot(s, 16);
CHECK(snap != NULL, "snapshot succeeds");
/* Serialize. */
unsigned char *b1 = NULL; size_t l1 = 0;
CHECK(pn_qwen35moe_snap_serialize(snap, WV, &b1, &l1) == 0 && b1 && l1 > 0,
"serialize succeeds");
/* Deserialize → re-serialize → byte-identical blob. */
uint64_t wv_out = 0;
void *snap2 = pn_qwen35moe_snap_deserialize(b1, l1, &wv_out);
CHECK(snap2 != NULL, "deserialize succeeds");
CHECK(wv_out == WV, "weights_version round-trips through the header");
CHECK(pn_qwen35moe_state_snapshot_pos(snap2) == 16, "pos round-trips");
unsigned char *b2 = NULL; size_t l2 = 0;
CHECK(pn_qwen35moe_snap_serialize(snap2, WV, &b2, &l2) == 0, "re-serialize");
CHECK(l1 == l2 && b1 && b2 && memcmp(b1, b2, l1) == 0,
"snap->bytes->snap->bytes is BYTE-IDENTICAL");
/* Restore the deserialized snap into a fresh state → equals the original. */
pn_qwen35moe_state *s2 = make_state();
CHECK(pn_qwen35moe_state_restore(s2, snap2) == 0,
"restore deserialized snap into fresh state");
CHECK(check_ssm(s2, 4.0f), "restored SSM equals original");
if (!q4) {
CHECK(check_kv_rows(s2, 0, 16, 4.0f), "restored fp32 KV equals original");
} else {
pn_qwen35moe_kv *a = &s->kv[0], *r = &s2->kv[0];
size_t rb = (size_t)pn_kvq4_row_bytes(a->head_dim);
int ok = 1;
for (int h = 0; h < a->n_head_kv && ok; h++)
if (memcmp(a->kq + (size_t)h * a->max_seq * rb,
r->kq + (size_t)h * r->max_seq * rb, 16 * rb) != 0)
ok = 0;
size_t win_n = (size_t)a->n_head_kv * PN_KVQ4_WMAX * a->head_dim;
CHECK(ok && memcmp(a->k_win, r->k_win, win_n * sizeof(uint16_t)) == 0,
"restored q4 rows + window shadow equal original");
}
/* Tamper / robustness: bad magic, truncation, and a NULL blob all reject. */
unsigned char save0 = b1[0];
b1[0] ^= 0xFF;
CHECK(pn_qwen35moe_snap_deserialize(b1, l1, NULL) == NULL,
"bad magic rejected");
b1[0] = save0;
CHECK(pn_qwen35moe_snap_deserialize(b1, l1 - 1, NULL) == NULL,
"truncated blob rejected");
CHECK(pn_qwen35moe_snap_deserialize(NULL, 0, NULL) == NULL,
"NULL blob rejected");
/* Hostile row_b: a blob keeping the dims snap_matches_state validates but
* inflating the KV layer's row_b would drive an OOB write on restore. The
* deserializer must reject any row_b inconsistent with (q4, head_dim).
* Blob layout for this fixed test model: header (28 B: magic4 + fmt4 + wv8 +
* pos4 + nssm4 + nattn4) + T_N_SSM SSM layers, each = 16 B (2×u64 counts) +
* conv_n*4 + ssm_n*4, then the attention layer's 4×i32 (16 B), then the u64
* row_b. conv_n = T_CONV_M1*T_CONV_DIM, ssm_n = T_STATE*T_STATE*T_VHEADS
* (identical in both KV modes — SSM is fp32). */
{
size_t conv_n = (size_t)T_CONV_M1 * T_CONV_DIM;
size_t ssm_n = (size_t)T_STATE * T_STATE * T_VHEADS;
size_t per_ssm = 16 + conv_n * sizeof(float) + ssm_n * sizeof(float);
size_t rb_off = 28 + (size_t)T_N_SSM * per_ssm + 16;
unsigned char save_rb[8];
memcpy(save_rb, b1 + rb_off, 8);
b1[rb_off] = (unsigned char)(save_rb[0] + 4); /* inflate row_b by 4 */
CHECK(pn_qwen35moe_snap_deserialize(b1, l1, NULL) == NULL,
"hostile row_b (inconsistent with head_dim) rejected");
memcpy(b1 + rb_off, save_rb, 8);
CHECK(pn_qwen35moe_snap_deserialize(b1, l1, NULL) != NULL,
"restored row_b deserializes again (tamper was reversible)");
}
/* Hostile pos: pos strides the live KV buffer on restore (pos*row_b bytes
* per head into a max_seq*row_b span). pos is an attacker-controlled header
* field the deserializer only checks pos>0, so a blob with pos>max_seq — all
* other dims matching the live model — drives an OOB write. Reproduce it by
* shrinking the blob's per-layer max_seq below its pos and restoring into a
* matching (smaller-max_seq) live state: snap_matches_state must reject on
* the pos>max_seq guard even though every OTHER field agrees. The attn
* layer's max_seq i32 sits after the SSM block + q4/n_head_kv/head_dim (28 B
* header + T_N_SSM SSM layers + 3×i32). */
{
size_t conv_n = (size_t)T_CONV_M1 * T_CONV_DIM;
size_t ssm_n = (size_t)T_STATE * T_STATE * T_VHEADS;
size_t per_ssm = 16 + conv_n * sizeof(float) + ssm_n * sizeof(float);
size_t ms_off = 28 + (size_t)T_N_SSM * per_ssm + 12;
unsigned char save_ms[4];
memcpy(save_ms, b1 + ms_off, 4);
int32_t small_ms = 8; /* < blob pos (16) — makes pos > max_seq */
memcpy(b1 + ms_off, &small_ms, 4);
void *hs = pn_qwen35moe_snap_deserialize(b1, l1, NULL);
CHECK(hs != NULL, "pos-hostile blob still deserializes (pos strides ok)");
pn_qwen35moe_state *sm = make_state();
sm->kv[0].max_seq = small_ms; /* match the tampered max_seq field */
CHECK(pn_qwen35moe_state_restore(sm, hs) != 0,
"restore rejects pos > live max_seq (OOB-write hardening)");
pn_qwen35moe_state_snapshot_free(hs);
free_state(sm);
memcpy(b1 + ms_off, save_ms, 4);
}
free(b1); free(b2);
pn_qwen35moe_state_snapshot_free(snap);
pn_qwen35moe_state_snapshot_free(snap2);
free_state(s);
free_state(s2);
}
/* ============================================================
* Test 3 — the acquire_sys decision ladder + capture protocol
* ============================================================ */
static const uint32_t SYS_A[8] = { 1, 2, 3, 4, 5, 6, 7, 8 };
static const uint32_t SYS_B[8] = { 1, 2, 3, 4, 5, 6, 7, 99 };
/* Compose sys prefix + a filler conversation [base, base+n). */
static void mk_prompt(uint32_t *out, const uint32_t *sys, int conv_base,
int n_total)
{
memcpy(out, sys, 8 * sizeof(uint32_t));
for (int i = 8; i < n_total; i++)
out[i] = (uint32_t)(conv_base + i);
}
static void test_acquire_sys_flow(void)
{
printf("test: acquire_sys decision ladder\n");
g_make_q4 = 0;
pn_model model = {0};
model.config.arch = PN_ARCH_QWEN35MOE;
pn_prefix_cache *c = pn_prefix_cache_new(&model, 2);
CHECK(c != NULL, "cache created");
/* Turn 1: fresh session -> full prefill + capture request. */
uint32_t p1[20];
mk_prompt(p1, SYS_A, 1000, 20);
int delta = -1, cap = -1;
pn_state *st = pn_prefix_cache_acquire_sys(c, "s1", p1, 20, 8, &delta, &cap);
CHECK(st != NULL, "turn 1 acquire returns a state");
CHECK(delta == 0 && cap == 1, "turn 1: full prefill + capture requested");
/* Simulate prefilling the system prefix, then capture the boundary. */
pn_qwen35moe_state *qs = (pn_qwen35moe_state *)st->kv_cache;
fill_ssm(qs, 1.0f);
fill_kv_rows(qs, 0, 8, 1.0f);
st->pos = 8;
CHECK(pn_prefix_cache_capture_sys(c, "s1", p1, 8) == 0,
"boundary capture succeeds at pos == sys_len");
CHECK(pn_prefix_cache_capture_sys(c, "s1", p1, 5) == -1,
"capture at wrong pos rejected");
/* Simulate the rest of the turn (conversation + 4 generated tokens). */
fill_ssm(qs, 5.0f);
fill_kv_rows(qs, 8, 24, 5.0f);
st->pos = 24;
uint32_t hist1[24];
memcpy(hist1, p1, 20 * sizeof(uint32_t));
for (int i = 20; i < 24; i++) hist1[i] = (uint32_t)(9000 + i);
CHECK(pn_prefix_cache_commit(c, "s1", hist1, 24) == 0, "turn 1 commit");
/* Turn 2: same system prefix, DIVERGENT conversation (the reasoning-model
* case) -> sys-prefix resume at 8, boundary state restored. */
uint32_t p2[30];
mk_prompt(p2, SYS_A, 4000, 30);
st = pn_prefix_cache_acquire_sys(c, "s1", p2, 30, 8, &delta, &cap);
CHECK(st != NULL && delta == 8 && cap == 0,
"turn 2: divergent history resumes at sys boundary");
CHECK(st->pos == 8, "turn 2: state rewound to sys_len");
qs = (pn_qwen35moe_state *)st->kv_cache;
CHECK(check_ssm(qs, 1.0f) && check_kv_rows(qs, 0, 8, 1.0f),
"turn 2: boundary state byte-restored");
/* Finish turn 2 and commit; turn 3 is a pure APPEND of the committed
* history -> the full-history resume must win over the sys resume. */
fill_kv_rows(qs, 8, 34, 6.0f);
st->pos = 34;
uint32_t hist2[34];
memcpy(hist2, p2, 30 * sizeof(uint32_t));
for (int i = 30; i < 34; i++) hist2[i] = (uint32_t)(9100 + i);
CHECK(pn_prefix_cache_commit(c, "s1", hist2, 34) == 0, "turn 2 commit");
uint32_t p3[36];
memcpy(p3, hist2, 34 * sizeof(uint32_t));
p3[34] = 7001; p3[35] = 7002;
st = pn_prefix_cache_acquire_sys(c, "s1", p3, 36, 8, &delta, &cap);
CHECK(st != NULL && delta == 34 && cap == 0,
"turn 3: append history -> full-history resume preferred");
/* Turn 4: CHANGED system prompt -> no reuse, full reset + re-capture. */
uint32_t p4[20];
mk_prompt(p4, SYS_B, 2000, 20);
st = pn_prefix_cache_acquire_sys(c, "s1", p4, 20, 8, &delta, &cap);
CHECK(st != NULL && delta == 0 && cap == 1,
"turn 4: changed system prompt -> full reset + capture");
CHECK(st->pos == 0, "turn 4: state was reset");
/* No system prefix (sys_len 0) -> legacy behavior via the old API. */
st = pn_prefix_cache_acquire(c, "s2", p1, 20, &delta);
CHECK(st != NULL && delta == 0, "no sys prefix: legacy full prefill");
/* Eviction: cap == 2, a third session evicts the LRU ("s1") and its
* snapshot; re-acquiring "s1" is a fresh session (capture again). */
st = pn_prefix_cache_acquire_sys(c, "s3", p1, 20, 8, &delta, &cap);
CHECK(st != NULL, "third session (evicts LRU)");
st = pn_prefix_cache_acquire_sys(c, "s1", p1, 20, 8, &delta, &cap);
CHECK(st != NULL && delta == 0 && cap == 1,
"evicted session returns fresh (snapshot gone, capture again)");
pn_prefix_cache_free(c);
}
/* ============================================================
* Test 4 — unsupported architecture never engages SYS1
* ============================================================ */
static void test_unsupported_arch(void)
{
printf("test: unsupported arch falls back cleanly\n");
g_make_q4 = 0;
pn_model model = {0};
model.config.arch = PN_ARCH_LLAMA; /* stubs still build a qwen state,
* but SYS1 must refuse the arch */
pn_prefix_cache *c = pn_prefix_cache_new(&model, 2);
uint32_t p1[20];
mk_prompt(p1, SYS_A, 1000, 20);
int delta = -1, cap = -1;
pn_state *st = pn_prefix_cache_acquire_sys(c, "u1", p1, 20, 8, &delta, &cap);
CHECK(st != NULL && delta == 0 && cap == 0,
"unsupported arch: full prefill, no capture requested");
st->pos = 8;
CHECK(pn_prefix_cache_capture_sys(c, "u1", p1, 8) == -1,
"unsupported arch: capture refused");
pn_prefix_cache_free(c);
}
/* ============================================================
* Test 4b — LCP rewind on plain-KV (llama-family) arches
*
* No snapshots: a divergent history resumes at the longest common
* prefix by truncating pos (KV rows [0,l) are position-invariant for
* pure causal attention). Covers: full prefill on the fresh session,
* the rewind on divergence, exact-append still preferred, pos-clamp,
* and full reset when nothing is shared.
* ============================================================ */
static void test_lcp_rewind(void)
{
printf("test: llama-family LCP rewind resume\n");
g_make_q4 = 0;
pn_model model = {0};
model.config.arch = PN_ARCH_LLAMA;
pn_prefix_cache *c = pn_prefix_cache_new(&model, 2);
CHECK(c != NULL, "cache created");
/* Turn 1: fresh session -> full prefill (and no SYS1 capture). */
uint32_t p1[20];
mk_prompt(p1, SYS_A, 1000, 20);
int delta = -1, cap = -1;
pn_state *st = pn_prefix_cache_acquire_sys(c, "r1", p1, 20, 8, &delta, &cap);
CHECK(st != NULL && delta == 0 && cap == 0,
"turn 1: fresh session full prefill, no capture");
st->pos = 24; /* prompt + 4 generated */
uint32_t hist1[24];
memcpy(hist1, p1, 20 * sizeof(uint32_t));
for (int i = 20; i < 24; i++) hist1[i] = (uint32_t)(9000 + i);
CHECK(pn_prefix_cache_commit(c, "r1", hist1, 24) == 0, "turn 1 commit");
/* Turn 2: DIVERGENT conversation, same 8-token system prefix ->
* rewind to the LCP (8) instead of a full reset. */
uint32_t p2[30];
mk_prompt(p2, SYS_A, 4000, 30);
st = pn_prefix_cache_acquire_sys(c, "r1", p2, 30, 8, &delta, &cap);
CHECK(st != NULL && delta == 8 && cap == 0,
"turn 2: divergent history rewinds to the LCP");
CHECK(st->pos == 8, "turn 2: pos truncated to the LCP");
/* Turn 3: pure append of the committed history -> exact append resume
* still wins (no rewind, no truncation). */
st->pos = 34;
uint32_t hist2[34];
memcpy(hist2, p2, 30 * sizeof(uint32_t));
for (int i = 30; i < 34; i++) hist2[i] = (uint32_t)(9100 + i);
CHECK(pn_prefix_cache_commit(c, "r1", hist2, 34) == 0, "turn 2 commit");
uint32_t p3[36];
memcpy(p3, hist2, 34 * sizeof(uint32_t));
p3[34] = 7001; p3[35] = 7002;
st = pn_prefix_cache_acquire_sys(c, "r1", p3, 36, 8, &delta, &cap);
CHECK(st != NULL && delta == 34, "turn 3: exact append still preferred");
/* Rewind never past the state's own position: the committed history says
* 36 tokens but the state only sits at 10 (fell behind) -> the LCP (36)
* clamps to pos, resuming at 10. */
CHECK(pn_prefix_cache_commit(c, "r1", p3, 36) == 0, "turn 3 commit");
st->pos = 10; /* simulate a state that fell behind its history */
st = pn_prefix_cache_acquire_sys(c, "r1", p3, 36, 8, &delta, &cap);
CHECK(st != NULL && delta == 10 && st->pos == 10,
"rewind clamped to the state's own pos");
/* Nothing shared at all (different first token) -> full reset. */
uint32_t p5[20];
mk_prompt(p5, SYS_A, 1000, 20);
p5[0] = 42;
st = pn_prefix_cache_acquire_sys(c, "r1", p5, 20, 8, &delta, &cap);
CHECK(st != NULL && delta == 0 && st->pos == 0,
"no shared prefix: full reset");
pn_prefix_cache_free(c);
}
/* ============================================================
* Test 5 — SYS1-BATCHED: per-slot resume/capture ladder + isolation
*
* Drives bs_prefill_resume_sys / bs_prefill_capture_sys / the affinity
* acquires over a hand-assembled pn_batch_sched (no bs_create — that is
* CUDA-gated). Only the pieces these paths touch are initialized: the
* mutex + condvars, cap, model, and per-slot pn_states (the same stubs the
* prefix-cache tests use). The driver thread never runs (driver_running
* false), matching the non-CUDA reality these host paths are tested in.
* bs_destroy is used for teardown, so it also exercises the sys-snapshot
* frees. Every resume takes the GPU turn; bs_prefill_abort releases turn +
* slot, standing in for the CUDA-only bind.
* ============================================================ */
static pn_batch_sched *mk_sched(pn_model *model, int cap)
{
pn_batch_sched *s = calloc(1, sizeof(*s));
s->model = model;
s->cap = cap;
pthread_mutex_init(&s->mu, NULL);
pthread_cond_init(&s->cv_gpu, NULL);
pthread_cond_init(&s->cv_work, NULL);
pthread_cond_init(&s->cv_idle, NULL);
for (int i = 0; i < cap; i++) {
bs_slot *sl = &s->slots[i];
sl->slot_idx = i;
sl->status = BS_SLOT_IDLE;
sl->gpu_idx = -1;
pthread_cond_init(&sl->cv, NULL);
sl->state = pn_state_new(model);
}
return s;
}
/* Simulate finalize's warm-history publication (the real bs_slot_finalize
* needs a live GPU decode slot): mark the slot's state as holding session
* `sid`'s committed history `hist[0..n)` at pos == n. */
static void publish_warm(bs_slot *sl, const char *sid, const uint32_t *hist,
int n)
{
snprintf(sl->sid, sizeof(sl->sid), "%s", sid);
if (sl->hist_cap < n) {
sl->hist = realloc(sl->hist, (size_t)n * sizeof(uint32_t));
sl->hist_cap = n;
}
memcpy(sl->hist, hist, (size_t)n * sizeof(uint32_t));
sl->hist_n = n;
sl->state->pos = n;
}
static void test_batched_sys_flow(void)
{
printf("test: SYS1-batched per-slot resume/capture ladder\n");
g_make_q4 = 0;
pn_model model = {0};
model.config.arch = PN_ARCH_QWEN35MOE;
pn_batch_sched *bs = mk_sched(&model, 2);
uint32_t pa1[20], pb1[20];
mk_prompt(pa1, SYS_A, 1000, 20); /* session A, sys prefix SYS_A */
mk_prompt(pb1, SYS_B, 3000, 20); /* session B, sys prefix SYS_B */
int delta = -1, cap = -1;
/* ── A turn 1 (slot 0): cold -> full prefill + capture request. ── */
int sa = bs_slot_acquire_biased(bs, "A");
CHECK(sa == 0, "A turn 1 claims slot 0");
bs_prefill_resume_sys(bs, sa, "A", pa1, 20, 8, &delta, &cap);
CHECK(delta == 0 && cap == 1, "A turn 1: full prefill + capture requested");
pn_qwen35moe_state *qa = (pn_qwen35moe_state *)bs->slots[sa].state->kv_cache;
fill_ssm(qa, 1.0f); /* "prefill" the system prefix */
fill_kv_rows(qa, 0, 8, 1.0f);
bs->slots[sa].state->pos = 8;
CHECK(bs_prefill_capture_sys(bs, sa, "A", pa1, 5) == -1,
"capture at wrong pos rejected");
CHECK(bs_prefill_capture_sys(bs, sa, "A", pa1, 8) == 0,
"A boundary capture succeeds at pos == sys_len");
fill_ssm(qa, 5.0f); /* rest of the turn */
fill_kv_rows(qa, 8, 24, 5.0f);
uint32_t ha[24];
memcpy(ha, pa1, 20 * sizeof(uint32_t));
for (int i = 20; i < 24; i++) ha[i] = (uint32_t)(9000 + i);
publish_warm(&bs->slots[sa], "A", ha, 24);
bs_prefill_abort(bs, sa); /* release turn + slot (stands in for bind) */
/* ── B turn 1 (slot 1): concurrent session, own snapshot. Pretend A is
* mid-decode on slot 0 (the concurrent case this test single-threads) so
* B's acquire takes slot 1. ── */
bs->slots[0].status = BS_SLOT_DECODE;
int sb = bs_slot_acquire_biased(bs, "B");
CHECK(sb == 1, "B turn 1 claims slot 1 (A's slot busy)");
bs->slots[0].status = BS_SLOT_IDLE;
bs_prefill_resume_sys(bs, sb, "B", pb1, 20, 8, &delta, &cap);
CHECK(delta == 0 && cap == 1, "B turn 1: full prefill + capture requested");
pn_qwen35moe_state *qb = (pn_qwen35moe_state *)bs->slots[sb].state->kv_cache;
fill_ssm(qb, 2.0f);
fill_kv_rows(qb, 0, 8, 2.0f);
bs->slots[sb].state->pos = 8;
CHECK(bs_prefill_capture_sys(bs, sb, "B", pb1, 8) == 0,
"B boundary capture succeeds");
fill_ssm(qb, 6.0f);
fill_kv_rows(qb, 8, 24, 6.0f);
uint32_t hb[24];
memcpy(hb, pb1, 20 * sizeof(uint32_t));
for (int i = 20; i < 24; i++) hb[i] = (uint32_t)(9500 + i);
publish_warm(&bs->slots[sb], "B", hb, 24);
bs_prefill_abort(bs, sb);
/* ── A turn 2: APPEND of the committed history -> warm resume wins. ── */
uint32_t pa2[26];
memcpy(pa2, ha, 24 * sizeof(uint32_t));
pa2[24] = 7001; pa2[25] = 7002;
sa = bs_slot_acquire_biased(bs, "A");
CHECK(sa == 0, "A routed back to its warm slot 0");
bs_prefill_resume_sys(bs, sa, "A", pa2, 26, 8, &delta, &cap);
CHECK(delta == 24 && cap == 0,
"A turn 2: append history -> warm-history resume preferred");
publish_warm(&bs->slots[sa], "A", ha, 24); /* keep prior warm state */
bs_prefill_abort(bs, sa);
/* ── A turn 3: DIVERGENT history, same system prefix -> sys resume; and
* the restore must not touch B's slot-1 state (isolation). ── */
uint32_t pa3[30];
mk_prompt(pa3, SYS_A, 4000, 30);
sa = bs_slot_acquire_biased(bs, "A");
CHECK(sa == 0, "A turn 3 routed to slot 0 again");
bs_prefill_resume_sys(bs, sa, "A", pa3, 30, 8, &delta, &cap);
CHECK(delta == 8 && cap == 0,
"A turn 3: divergent history resumes at sys boundary");
CHECK(bs->slots[sa].state->pos == 8, "A turn 3: state rewound to sys_len");
CHECK(check_ssm(qa, 1.0f) && check_kv_rows(qa, 0, 8, 1.0f),
"A turn 3: boundary state byte-restored");
CHECK(bs->slots[sa].sid[0] == '\0' && bs->slots[sa].hist_n == 0,
"A turn 3: stale committed history dropped");
CHECK(check_ssm(qb, 6.0f) && check_kv_rows(qb, 0, 8, 2.0f) &&
check_kv_rows(qb, 8, 24, 6.0f),
"isolation: B's slot-1 state untouched by A's restore");
bs_prefill_abort(bs, sa);
/* ── Snapshot-affinity pass: A's warm sid is gone (dropped above) but its
* snapshot remains -> acquire must still route A to slot 0. Plain
* release (no resume ran, so this slot never took the GPU turn — abort
* would release an unowned turn and desync the turnstile). ── */
sa = bs_slot_acquire_biased(bs, "A");
CHECK(sa == 0, "snapshot affinity routes A back to slot 0 (sid empty)");
bs_slot_release(bs, sa);
/* ── B turn 2 (divergent): B's own snapshot on slot 1 still restores. ── */
uint32_t pb2[30];
mk_prompt(pb2, SYS_B, 5000, 30);
sb = bs_slot_acquire_biased(bs, "B");
CHECK(sb == 1, "B routed back to its slot 1");
bs_prefill_resume_sys(bs, sb, "B", pb2, 30, 8, &delta, &cap);
CHECK(delta == 8 && cap == 0 && check_ssm(qb, 2.0f) &&
check_kv_rows(qb, 0, 8, 2.0f),
"B turn 2: own boundary snapshot restored (not A's)");
bs_prefill_abort(bs, sb);
/* ── Changed system prompt for A -> no reuse, full reset + re-capture,
* and the slot REPORTS the substitution (same sid, second conversation). ── */
uint32_t pa4[20];
mk_prompt(pa4, SYS_B, 6000, 20); /* A now arrives with SYS_B */
sa = bs_slot_acquire_biased(bs, "A");
bs_prefill_resume_sys(bs, sa, "A", pa4, 20, 8, &delta, &cap);
CHECK(delta == 0 && cap == 1 && bs->slots[sa].state->pos == 0,
"changed system prompt: full reset + capture requested");
CHECK(bs_slot_session_conflict(bs, sa),
"changed system prompt under a live sid is REPORTED as a conflict");
bs_prefill_abort(bs, sa);
/* ── Slot takeover: session C claims A's slot (both free, C matches
* neither) — append-miss + sid-guarded sys-miss -> full prefill; C's
* capture then replaces the resident snapshot. ── */
uint32_t pc1[20];
mk_prompt(pc1, SYS_A, 7000, 20); /* same SYS_A bytes as A's snapshot! */
int sc = bs_slot_acquire_biased(bs, "C");
bs_prefill_resume_sys(bs, sc, "C", pc1, 20, 8, &delta, &cap);
CHECK(delta == 0 && cap == 1,
"takeover: C never reuses A's snapshot (sid guard) even on "
"identical prefix bytes");
pn_qwen35moe_state *qc = (pn_qwen35moe_state *)bs->slots[sc].state->kv_cache;
fill_ssm(qc, 3.0f);
fill_kv_rows(qc, 0, 8, 3.0f);
bs->slots[sc].state->pos = 8;
CHECK(bs_prefill_capture_sys(bs, sc, "C", pc1, 8) == 0,
"takeover: C's capture replaces the slot's snapshot");
CHECK(strcmp(bs->slots[sc].sys_sid, "C") == 0,
"takeover: snapshot ownership moved to C");
bs_prefill_abort(bs, sc);
/* ── No session id / no sys prefix -> byte-identical legacy behavior. ── */
int sn = bs_slot_acquire(bs);
bs_prefill_resume_sys(bs, sn, NULL, pc1, 20, 8, &delta, &cap);
CHECK(delta == 0 && cap == 0, "NULL sid: full prefill, no capture");
bs_prefill_abort(bs, sn);
sn = bs_slot_acquire(bs);
bs_prefill_resume(bs, sn, "C", pc1, 20, &delta);
CHECK(delta == 0, "bs_prefill_resume wrapper: sys disabled (sys_len 0)");
bs_prefill_abort(bs, sn);
bs_destroy(bs); /* also frees the per-slot snapshots (leak gate) */
}
/* ============================================================
* Test 6b — THINK-ELISION: the projection is what the CLIENT will resend
*
* bs_slot_note_visible builds the projection; bs_slot_finalize publishes it
* (GPU-only, so publish_vis stands in); bs_prefill_resume_sys consumes it.
* The cases that matter are the two the K80 test machine found: a plain turn, and a TOOL
* turn where the model emitted a BARE call but the client re-renders it inside
* a "<tool_call>" wrapper it never generated.
* ============================================================ */
/* bs_slot_finalize needs a GPU state, so the test drives its publish STEP
* directly — the production function, not a copy of it: the two projections'
* asymmetric length rule is exactly the kind of thing a stand-in gets wrong. */
static void publish_vis(bs_slot *sl)
{
bs_slot_publish_projections(sl, sl->ct_n);
}
/* Seed the slot's committed-token log as the driver would: the prompt followed
* by `gen` generated tokens. */
static void seed_committed_log(bs_slot *sl, const uint32_t *prompt, int n_prompt,
const uint32_t *gen, int n_gen)
{
int n = n_prompt + n_gen;
if (sl->ct_cap < n) {
sl->ct = realloc(sl->ct, (size_t)n * sizeof(uint32_t));
sl->ct_cap = n;
}
memcpy(sl->ct, prompt, (size_t)n_prompt * sizeof(uint32_t));
memcpy(sl->ct + n_prompt, gen, (size_t)n_gen * sizeof(uint32_t));
sl->ct_n = n;
}
/* ============================================================
* Test 6c — SESSION SUBSTITUTION: one session id, two conversations
*
* The defect this guards: an agent's reflect pass POSTed a second conversation
* (its own system prompt) under the main chat's session id. Every rung missed,
* the slot's boundary snapshot was replaced by the intruder's, and BOTH
* conversations re-prefilled in full from then on — with nothing said.
*
* The rule under test: report iff the SYSTEM BLOCK changed under a session id
* this slot already holds a record for. The three legitimate ways a history
* changes (append, think-elided re-render, client-side compaction of the body)
* all keep that block byte-identical and must NOT be reported.
* ============================================================ */
static void test_session_substitution(void)
{
printf("test: session substitution (one sid, two conversations)\n");
g_make_q4 = 0;
pn_model model = {0};
model.config.arch = PN_ARCH_QWEN35MOE;
pn_batch_sched *bs = mk_sched(&model, 2);
int delta = -1, cap = -1;
/* Conversation A, turn 1: cold, captures its boundary. */
uint32_t a1[20];
mk_prompt(a1, SYS_A, 1000, 20);
int s = bs_slot_acquire_biased(bs, "S");
bs_prefill_resume_sys(bs, s, "S", a1, 20, 8, &delta, &cap);
CHECK(!bs_slot_session_conflict(bs, s),
"a brand-new session id is never a conflict");
pn_qwen35moe_state *q = (pn_qwen35moe_state *)bs->slots[s].state->kv_cache;
fill_ssm(q, 1.0f);
fill_kv_rows(q, 0, 8, 1.0f);
bs->slots[s].state->pos = 8;
CHECK(bs_prefill_capture_sys(bs, s, "S", a1, 8) == 0, "A captures at 8");
uint32_t ha[24];
memcpy(ha, a1, 20 * sizeof(uint32_t));
for (int i = 20; i < 24; i++) ha[i] = (uint32_t)(9000 + i);
publish_warm(&bs->slots[s], "S", ha, 24);
bs_prefill_abort(bs, s);
/* A turn 2 — a plain APPEND. Same system block: not a conflict. */
uint32_t a2[26];
memcpy(a2, ha, 24 * sizeof(uint32_t));
a2[24] = 7001; a2[25] = 7002;
s = bs_slot_acquire_biased(bs, "S");
bs_prefill_resume_sys(bs, s, "S", a2, 26, 8, &delta, &cap);
CHECK(delta == 24 && !bs_slot_session_conflict(bs, s),
"an appended history is a continuation, not a conflict");
publish_warm(&bs->slots[s], "S", ha, 24);
bs_prefill_abort(bs, s);
/* A turn 3 — the history BODY is rewritten (what a client-side compaction
* does) but the system block is untouched. This is the case a naive
* reject-on-divergence would break, so it must stay silent. */
uint32_t a3[30];
mk_prompt(a3, SYS_A, 4000, 30);
s = bs_slot_acquire_biased(bs, "S");
bs_prefill_resume_sys(bs, s, "S", a3, 30, 8, &delta, &cap);
CHECK(delta == 8, "rewritten body resumes at the system boundary");
CHECK(!bs_slot_session_conflict(bs, s),
"a rewritten history body (compaction) is NOT a conflict");
bs_prefill_abort(bs, s);
/* THE DEFECT — a SECOND conversation arrives under the same session id,
* carrying a different system prompt. */
uint32_t b1[20];
mk_prompt(b1, SYS_B, 5000, 20);
s = bs_slot_acquire_biased(bs, "S");
bs_prefill_resume_sys(bs, s, "S", b1, 20, 8, &delta, &cap);
CHECK(delta == 0 && cap == 1, "the substituted conversation full-prefills");
CHECK(bs_slot_session_conflict(bs, s),
"a different system prompt under a live sid IS a conflict");
fill_ssm(q, 2.0f);
fill_kv_rows(q, 0, 8, 2.0f);
bs->slots[s].state->pos = 8;
CHECK(bs_prefill_capture_sys(bs, s, "S", b1, 8) == 0,
"and it replaces the displaced conversation's snapshot");
bs_prefill_abort(bs, s);
/* Conversation A comes back: flagged again, and this is the turn that
* actually pays — a full re-prefill of the whole conversation. */
s = bs_slot_acquire_biased(bs, "S");
bs_prefill_resume_sys(bs, s, "S", a3, 30, 8, &delta, &cap);
CHECK(delta == 0, "the displaced conversation re-prefills in full");
CHECK(bs_slot_session_conflict(bs, s),
"the displaced conversation's return is flagged too");
publish_warm(&bs->slots[s], "S", a3, 30);
bs_prefill_abort(bs, s);
/* The flag is PER TURN, not per session: A's next turn is an ordinary
* append, so it must come back clean. A latched flag would keep telling a
* client to renumber a session that is already behaving, and — always true
* after one incident — would stop carrying information. */
uint32_t a4[32];
memcpy(a4, a3, 30 * sizeof(uint32_t));
a4[30] = 7101; a4[31] = 7102;
s = bs_slot_acquire_biased(bs, "S");
bs_prefill_resume_sys(bs, s, "S", a4, 32, 8, &delta, &cap);
CHECK(delta == 30, "the recovered conversation resumes warm");
CHECK(!bs_slot_session_conflict(bs, s),
"the conflict flag does not latch: the next clean turn is clean");
bs_prefill_abort(bs, s);
/* A request with NO system block carries no signal — never flagged. */
s = bs_slot_acquire_biased(bs, "S");
bs_prefill_resume_sys(bs, s, "S", b1, 20, 0, &delta, &cap);
CHECK(!bs_slot_session_conflict(bs, s),
"sys_len 0: no system block, so nothing is claimed");
bs_prefill_abort(bs, s);
/* A DIFFERENT session id on a slot warm for someone else is an ordinary
* takeover, not a substitution. */
int t = bs_slot_acquire_biased(bs, "OTHER");
bs_prefill_resume_sys(bs, t, "OTHER", a1, 20, 8, &delta, &cap);
CHECK(!bs_slot_session_conflict(bs, t),
"a different session id claiming a slot is not a conflict");
bs_prefill_abort(bs, t);
/* No session id at all: the uncached path is untouched. */
int u = bs_slot_acquire(bs);
bs_prefill_resume_sys(bs, u, NULL, b1, 20, 8, &delta, &cap);
CHECK(!bs_slot_session_conflict(bs, u), "NULL sid: never a conflict");
bs_prefill_abort(bs, u);
bs_destroy(bs);
}
/* ============================================================
* Test 6d — TWO CONVERSATIONS, ONE SESSION ID, each keeps its own cache
*
* opencode sends its session id on every request: a title-generation call
* (its own short system prompt) and the real turns (a long one). The acquire
* runs before tokenization and took the FIRST slot of the id — the title's —
* so the real turn 2 landed on the wrong slot and re-prefilled everything
* (measured on the K80 fleet: 14,081 tokens, 170 s, for a 274-token delta).
* bs_slot_reroute moves the turn to the slot its prompt continues.
* ============================================================ */
/* One turn as the serve runs it: acquire, reroute on the known prompt, resume
* (capturing the boundary when asked). Returns the slot; *delta = resume. */
static int convo_turn(pn_batch_sched *bs, const char *sid,
const uint32_t *prompt, int n, int *delta)
{
int s = bs_slot_acquire_biased(bs, sid);
s = bs_slot_reroute(bs, s, sid, prompt, n, 8);
int cap = 0;
bs_prefill_resume_sys(bs, s, sid, prompt, n, 8, delta, &cap);
if (cap) {
bs->slots[s].state->pos = 8;
bs_prefill_capture_sys(bs, s, sid, prompt, 8);
}
return s;
}
/* End the turn: publish prompt + 4 generated tokens as the warm history. */
static int convo_commit(pn_batch_sched *bs, int s, const char *sid,
const uint32_t *prompt, int n, uint32_t *hist)
{
memcpy(hist, prompt, (size_t)n * sizeof(uint32_t));
for (int i = n; i < n + 4; i++)
hist[i] = (uint32_t)(9000 + 10 * s + i);
publish_warm(&bs->slots[s], sid, hist, n + 4);
bs_prefill_abort(bs, s);
return n + 4;
}
/* The next turn's prompt: the committed history plus two new tokens. */
static int convo_append(uint32_t *out, const uint32_t *hist, int n, int tag)
{
memcpy(out, hist, (size_t)n * sizeof(uint32_t));
out[n] = (uint32_t)(7000 + tag);
out[n + 1] = (uint32_t)(7100 + tag);
return n + 2;
}
/* The measured order: the title request is still decoding when turn 1 arrives,
* so turn 1 lands on slot 1; turn 2 must come back to slot 1. */
static void test_two_convos_concurrent_title(void)
{
printf("test: one sid, two conversations (title still running at turn 1)\n");
g_make_q4 = 0;
pn_model model = {0};
model.config.arch = PN_ARCH_QWEN35MOE;
pn_batch_sched *bs = mk_sched(&model, 2);
uint32_t t1[16], ht[20], m1[24], hm[28], m2[30];
int delta = -1;
mk_prompt(t1, SYS_B, 5000, 16); /* title conversation */
int st = convo_turn(bs, "S", t1, 16, &delta);
convo_commit(bs, st, "S", t1, 16, ht);
bs->slots[st].status = BS_SLOT_DECODE; /* still in flight */
mk_prompt(m1, SYS_A, 1000, 24); /* real turn 1 */
int sm = convo_turn(bs, "S", m1, 24, &delta);
CHECK(sm != st && delta == 0, "turn 1 full-prefills on the other slot");
int hm_n = convo_commit(bs, sm, "S", m1, 24, hm);
bs->slots[st].status = BS_SLOT_IDLE; /* title finished */
int n2 = convo_append(m2, hm, hm_n, 2);
int s2 = convo_turn(bs, "S", m2, n2, &delta);
CHECK(s2 == sm, "turn 2 routed to turn 1's slot, not the title's");
CHECK(delta == hm_n, "turn 2 prefills only its delta");
CHECK(!bs_slot_session_conflict(bs, s2), "turn 2 is not a conflict");
bs_prefill_abort(bs, s2);
bs_destroy(bs);
}
/* Strictly sequential, interleaved turns: neither conversation evicts the
* other, and each resumes from its own warm history / snapshot. */
static void test_two_convos_interleaved(void)
{
printf("test: one sid, two conversations, interleaved turns\n");
g_make_q4 = 0;
pn_model model = {0};
model.config.arch = PN_ARCH_QWEN35MOE;
pn_batch_sched *bs = mk_sched(&model, 2);
uint32_t t1[16], ht[20], t2[22], ht2[26];
uint32_t m1[24], hm[28], m2[30], hm2[34], m3[40];
int delta = -1;
mk_prompt(t1, SYS_B, 5000, 16);
int st = convo_turn(bs, "S", t1, 16, &delta);
int ht_n = convo_commit(bs, st, "S", t1, 16, ht);
mk_prompt(m1, SYS_A, 1000, 24);
int sm = convo_turn(bs, "S", m1, 24, &delta);
CHECK(sm != st, "a new conversation takes the cold slot, not the title's");
CHECK(!bs_slot_session_conflict(bs, sm), "...and is not reported");
int hm_n = convo_commit(bs, sm, "S", m1, 24, hm);
CHECK(strcmp(bs->slots[st].sid, "S") == 0 && bs->slots[st].hist_n == ht_n,
"the title conversation's warm history survived");
int n = convo_append(t2, ht, ht_n, 1);
int s = convo_turn(bs, "S", t2, n, &delta);
CHECK(s == st && delta == ht_n, "title turn 2 resumes warm on its slot");
convo_commit(bs, s, "S", t2, n, ht2);
n = convo_append(m2, hm, hm_n, 2);
s = convo_turn(bs, "S", m2, n, &delta);
CHECK(s == sm && delta == hm_n, "main turn 2 resumes warm on its slot");
convo_commit(bs, s, "S", m2, n, hm2);
mk_prompt(m3, SYS_A, 4000, 40); /* body rewritten, same system block */
s = convo_turn(bs, "S", m3, 40, &delta);
CHECK(s == sm && delta == 8,
"main body rewrite resumes from its own system-prefix snapshot");
bs_prefill_abort(bs, s);
CHECK(bs_slot_reroute(bs, 0, NULL, m3, 40, 8) == 0, "NULL sid: no reroute");
bs_destroy(bs);
}
static void test_think_elided_projection(void)
{
printf("test: think-elision projection (plain turn + bare tool call)\n");
g_make_q4 = 0;
pn_model model = {0};
model.config.arch = PN_ARCH_QWEN35MOE;
pn_batch_sched *bs = mk_sched(&model, 2);
/* Turn 1: prompt = 10 client tokens + a 1-token forced "<think>" prefill.
* Generation = 3 invisible tokens (the think block) then 4 visible ones. */
uint32_t prompt[11];
for (int i = 0; i < 11; i++) prompt[i] = (uint32_t)(100 + i);
uint32_t gen[7] = { 900, 901, 902, 500, 501, 502, 503 };
int delta = -1, cap_req = -1;
int slot = bs_slot_acquire_biased(bs, "A");
CHECK(slot == 0, "cold session claims slot 0");
bs_prefill_resume_sys(bs, slot, "A", prompt, 11, 4, &delta, &cap_req);
CHECK(delta == 0, "turn 1 is a cold full prefill");
seed_committed_log(&bs->slots[slot], prompt, 11, gen, 7);
/* The serve's re-render of the reply: the 4 visible tokens. The 3 think
* tokens are never in it — the client was never shown them. */
uint32_t rerender[4] = { 500, 501, 502, 503 };
bs_slot_note_visible(bs, slot, prompt, 11, 1, NULL, 0, rerender, 4);
CHECK(bs->slots[slot].pend_vis_n == 10 + 4,
"projection = client head (10) + re-rendered reply (4)");
publish_vis(&bs->slots[slot]);
publish_warm(&bs->slots[slot], "A", bs->slots[slot].ct, 18);
bs_prefill_abort(bs, slot);
/* Turn 2: the client resends exactly the projection + a new user turn. */
uint32_t next[18];
memcpy(next, bs->slots[0].vis, 14 * sizeof(uint32_t));
for (int i = 14; i < 18; i++) next[i] = (uint32_t)(700 + i);
slot = bs_slot_acquire_biased(bs, "A");
bs_prefill_resume_sys(bs, slot, "A", next, 18, 4, &delta, &cap_req);
CHECK(delta == 14, "think-elided resume: prefill only the new tail");
CHECK(!bs_slot_session_conflict(bs, slot),
"a think-elided re-render is a continuation, not a conflict");
CHECK(bs->slots[slot].ct_keep == 18 && bs->slots[slot].ct_prompt_from == 14,
"committed-log seeding records the elided KV length");
bs_prefill_abort(bs, slot);
/* A BARE tool call: the model generated no wrapper, the client's re-render
* adds "<tool_call>\n" (two ids). Without them the projection is short by
* the wrapper and every such turn misses. */
bs->slots[0].sid[0] = '\0';
bs->slots[0].hist_n = 0;
bs->slots[0].vis_n = 0;
uint32_t wrapped_rerender[6] = { 248058, 198, 500, 501, 502, 503 };
slot = bs_slot_acquire_biased(bs, "A");
bs_prefill_resume_sys(bs, slot, "A", prompt, 11, 4, &delta, &cap_req);
seed_committed_log(&bs->slots[slot], prompt, 11, gen, 7);
bs_slot_note_visible(bs, slot, prompt, 11, 1, NULL, 0,
wrapped_rerender, 6);
CHECK(bs->slots[slot].pend_vis_n == 10 + 6,
"tool projection = head + the WRAPPED re-render of the bare call");
publish_vis(&bs->slots[slot]);
CHECK(bs->slots[slot].vis[10] == 248058 && bs->slots[slot].vis[11] == 198,
"the wrapper sits between the head and the generated call");
publish_warm(&bs->slots[slot], "A", bs->slots[slot].ct, 18);
bs_prefill_abort(bs, slot);
uint32_t next2[20];
memcpy(next2, bs->slots[0].vis, 16 * sizeof(uint32_t));
for (int i = 16; i < 20; i++) next2[i] = (uint32_t)(800 + i);
slot = bs_slot_acquire_biased(bs, "A");
bs_prefill_resume_sys(bs, slot, "A", next2, 20, 4, &delta, &cap_req);
CHECK(delta == 16, "a wrapped re-render of a bare call still resumes");
bs_prefill_abort(bs, slot);
/* A cold, unrelated session must NOT evict slot 0's warm state while an
* idle slot with nothing in it exists. */
int other = bs_slot_acquire_biased(bs, "B");
CHECK(other == 1, "a cold session takes the empty slot, not the warm one");
CHECK(bs->slots[0].sid[0] != '\0', "slot 0 keeps its warm session");
bs_prefill_resume_sys(bs, other, "B", prompt, 11, 4, &delta, &cap_req);
bs_prefill_abort(bs, other);
bs_destroy(bs);
}
/* ============================================================
* Test 6b2 — THE REASONING-BEARING RUNG: a client that SENDS reasoning back
*
* A client must resend `reasoning_content` or the model stops reasoning (18/18
* collapse without it, 0/18 with it).
* But that history no longer matches the think-ELIDED projection, so before this
* rung a reasoning-bearing client re-prefilled its whole conversation every
* turn — minutes per turn at ~22 ms/token, which is what made the correct client
* behaviour unusable.
*
* What must hold, and each is asserted below:
* 1. A reasoning-bearing request RESUMES (delta == the projection length), so
* the reasoning span costs nothing.
* 2. A request whose visible prefix DIVERGES falls back to a FULL prefill —
* the non-negotiable half. Reusing KV that does not correspond to the
* request's prefix is silently wrong output, worse than a slow turn.
* 3. The elided rung still fires for a client that drops reasoning, off the
* SAME committed turn: the two candidates are decided by the request.
* 4. The first turn of a session is a cold full prefill (nothing to resume).
* ============================================================ */
/* Commit one turn on slot 0 for session "A" with BOTH projections recorded: a
* 10-token client head, a 3-token think block, a 4-token reply. The KV holds 18
* tokens (prompt 11 incl. a 1-token forced "<think>", plus 7 generated). */
static void commit_turn_with_reasoning(pn_batch_sched *bs,
const uint32_t *prompt,
const uint32_t *think_block,
int think_block_n,
const uint32_t *rerender)
{
uint32_t gen[7] = { 900, 901, 902, 500, 501, 502, 503 };
int delta = -1, cap_req = -1;
int slot = bs_slot_acquire_biased(bs, "A");
bs_prefill_resume_sys(bs, slot, "A", prompt, 11, 4, &delta, &cap_req);
CHECK(delta == 0, "first turn of a session is a cold full prefill");
seed_committed_log(&bs->slots[slot], prompt, 11, gen, 7);
bs_slot_note_visible(bs, slot, prompt, 11, 1, think_block, think_block_n,
rerender, 4);
CHECK(bs->slots[slot].pend_vis_n == 10 + 4 &&
bs->slots[slot].pend_vis_think_n == 10 + think_block_n + 4,
"both projections recorded off one turn");
publish_vis(&bs->slots[slot]);
publish_warm(&bs->slots[slot], "A", bs->slots[slot].ct, 18);
bs_prefill_abort(bs, slot);
}
static void test_reasoning_bearing_resume(void)
{
printf("test: reasoning-bearing resume (the client sends reasoning back)\n");
g_make_q4 = 0;
pn_model model = {0};
model.config.arch = PN_ARCH_QWEN35MOE;
pn_batch_sched *bs = mk_sched(&model, 2);
int delta = -1, cap_req = -1;
uint32_t prompt[11];
for (int i = 0; i < 11; i++) prompt[i] = (uint32_t)(100 + i);
/* The think block as the BUILDER re-renders it from the reasoning TEXT —
* deliberately NOT the ids the model sampled (900,901,902, which are what
* the KV holds). That is the whole design: the client echoes the string,
* the serve re-derives the ids, and the KV stays authoritative for the
* span. A rung that required the ids to agree would never fire. */
uint32_t think_block[3] = { 248068, 310, 248069 };
uint32_t rerender[4] = { 500, 501, 502, 503 };
uint32_t sampled[3] = { 900, 901, 902 };
commit_turn_with_reasoning(bs, prompt, think_block, 3, rerender);
CHECK(bs->slots[0].vis_n == 14 && bs->slots[0].vis_think_n == 17,
"finalize publishes both projections against the 18-token KV");
CHECK(bs->slots[0].vis_think_n == 17 &&
memcmp(bs->slots[0].vis_think + 10, think_block,
3 * sizeof(uint32_t)) == 0 &&
memcmp(bs->slots[0].hist + 11, sampled, 3 * sizeof(uint32_t)) == 0,
"the projection holds the RE-RENDERED block, the KV the sampled one");
/* 1. Turn 2, reasoning resent: the client posts the reasoning-bearing
* projection plus a new user turn. It must RESUME. */
uint32_t with_reasoning[21];
memcpy(with_reasoning, prompt, 10 * sizeof(uint32_t)); /* head */
memcpy(with_reasoning + 10, think_block, 3 * sizeof(uint32_t));
memcpy(with_reasoning + 13, rerender, 4 * sizeof(uint32_t));
for (int i = 17; i < 21; i++) with_reasoning[i] = (uint32_t)(700 + i);
int slot = bs_slot_acquire_biased(bs, "A");
bs_prefill_resume_sys(bs, slot, "A", with_reasoning, 21, 4, &delta, &cap_req);
CHECK(delta == 17,
"a reasoning-bearing history RESUMES: prefill only the new tail");
CHECK(bs->slots[slot].ct_keep == 18 && bs->slots[slot].ct_prompt_from == 17,
"the committed log is seeded from the KV, not the client's rendering");
CHECK(!bs_slot_session_conflict(bs, slot),
"resending reasoning is a continuation, not a session substitution");
CHECK(pn_prefix_cached_tokens(delta, 21) == 17,
"and the wire reports the reuse it actually performed");
bs_prefill_abort(bs, slot);
/* 2. THE TRAP. Same length, same shape, one token different INSIDE the
* reasoning span — a client that re-rendered the reasoning differently.
* The KV does not correspond to this prefix, so the only correct answer
* is to throw it away and prefill from scratch. */
uint32_t diverged[21];
memcpy(diverged, with_reasoning, 21 * sizeof(uint32_t));
diverged[11] = 999; /* inside the re-rendered think block */
slot = bs_slot_acquire_biased(bs, "A");
bs_prefill_resume_sys(bs, slot, "A", diverged, 21, 4, &delta, &cap_req);
CHECK(delta == 0,
"a divergence inside the reasoning span FALLS BACK to a full prefill");
bs_prefill_abort(bs, slot);
/* A divergence in the client HEAD — the span both projections share — must
* fall back too, and it is the case where a wrong resume would splice one
* conversation onto another's KV. */
memcpy(diverged, with_reasoning, 21 * sizeof(uint32_t));
diverged[9] = 999;
bs->slots[0].sid[0] = '\0';
bs->slots[0].hist_n = 0;
commit_turn_with_reasoning(bs, prompt, think_block, 3, rerender);
slot = bs_slot_acquire_biased(bs, "A");
bs_prefill_resume_sys(bs, slot, "A", diverged, 21, 4, &delta, &cap_req);
CHECK(delta == 0, "a divergence in the client head falls back too");
bs_prefill_abort(bs, slot);
/* 3. The SAME committed turn, asked by a client that DROPS reasoning: the
* elided rung must still fire, unchanged. Adding a candidate must not
* take one away. */
bs->slots[0].sid[0] = '\0';
bs->slots[0].hist_n = 0;
commit_turn_with_reasoning(bs, prompt, think_block, 3, rerender);
uint32_t without_reasoning[18];
memcpy(without_reasoning, prompt, 10 * sizeof(uint32_t)); /* head */
memcpy(without_reasoning + 10, rerender, 4 * sizeof(uint32_t));
for (int i = 14; i < 18; i++) without_reasoning[i] = (uint32_t)(700 + i);
slot = bs_slot_acquire_biased(bs, "A");
bs_prefill_resume_sys(bs, slot, "A", without_reasoning, 18, 4, &delta,
&cap_req);
CHECK(delta == 14,
"the same turn still resumes for a client that OMITS reasoning");
CHECK(bs->slots[slot].ct_keep == 18 && bs->slots[slot].ct_prompt_from == 14,
"the elided rung's committed-log seeding is unchanged");
bs_prefill_abort(bs, slot);
/* 4. COLD MUST STILL BE CORRECT, and the sharp version of cold is a NEW
* session landing on the slot an evicted one left warm — its tokens then
* byte-MATCH a projection that describes somebody else's KV. Force it:
* reserve the spare slot so "NEW" has to take slot 0. */
int busy = bs_slot_acquire(bs);
CHECK(busy == 1, "the spare slot is reserved so the cold session must take 0");
int cold = bs_slot_acquire_biased(bs, "NEW");
CHECK(cold == 0 && bs->slots[0].vis_think_n == 17,
"the cold session takes the slot still holding A's projection");
bs_prefill_resume_sys(bs, cold, "NEW", with_reasoning, 21, 4, &delta,
&cap_req);
CHECK(delta == 0,
"a first turn carrying reasoning is a cold full prefill even when it "
"byte-matches another session's projection");
CHECK(cap_req == 1, "and it captures a system boundary for next time");
bs_prefill_abort(bs, cold);
/* `busy` was only RESERVED — it never took a GPU turn, so releasing the slot
* is the whole undo. bs_prefill_abort would also release a turn nobody
* holds and desync the turnstile. */
bs_slot_release(bs, busy);
/* 5. A reasoning-bearing projection LONGER than the committed KV must still
* resume. This is the normal case, not a corner: the client's rendering
* carries the template's own "<think>\n" / "</think>\n\n" separators where
* the KV carries whatever the model sampled around the same text, and the
* emitter trimmed the leading newline out of the reasoning string — so
* the two lengths land within a token or two of each other and the
* projection routinely comes out the longer one. A "must not exceed
* hist_n" tripwire (which the think-ELIDED projection does carry, and
* rightly) would silently disable this rung on a coin flip. Here the KV
* is 18 and a 5-token block puts the projection at 19. */
bs->slots[0].sid[0] = '\0';
bs->slots[0].hist_n = 0;
uint32_t long_block[5] = { 248068, 310, 311, 312, 248069 };
commit_turn_with_reasoning(bs, prompt, long_block, 5, rerender);
CHECK(bs->slots[0].hist_n == 18 && bs->slots[0].vis_think_n == 19,
"the reasoning-bearing projection (19) outruns the KV it maps (18)");
uint32_t long_req[23];
memcpy(long_req, prompt, 10 * sizeof(uint32_t));
memcpy(long_req + 10, long_block, 5 * sizeof(uint32_t));
memcpy(long_req + 15, rerender, 4 * sizeof(uint32_t));
for (int i = 19; i < 23; i++) long_req[i] = (uint32_t)(700 + i);
slot = bs_slot_acquire_biased(bs, "A");
bs_prefill_resume_sys(bs, slot, "A", long_req, 23, 4, &delta, &cap_req);
CHECK(delta == 19,
"and it resumes anyway: the gate is the memcmp, not the length");
CHECK(bs->slots[slot].ct_keep == 18 && bs->slots[slot].ct_prompt_from == 19,
"the committed log still starts from the KV the state actually holds");
bs_prefill_abort(bs, slot);
/* 6. The other side of that asymmetry: the think-ELIDED projection strictly
* DROPS tokens from the committed conversation, so one longer than the
* history it maps is nonsense and must be refused. Here a 9-token reply
* re-render puts it at 19 against an 18-token KV. */
bs->slots[0].sid[0] = '\0';
bs->slots[0].hist_n = 0;
uint32_t gen[7] = { 900, 901, 902, 500, 501, 502, 503 };
uint32_t fat_rerender[9] = { 500, 501, 502, 503, 504, 505, 506, 507, 508 };
slot = bs_slot_acquire_biased(bs, "A");
bs_prefill_resume_sys(bs, slot, "A", prompt, 11, 4, &delta, &cap_req);
seed_committed_log(&bs->slots[slot], prompt, 11, gen, 7);
bs_slot_note_visible(bs, slot, prompt, 11, 1, think_block, 3,
fat_rerender, 9);
bs_slot_publish_projections(&bs->slots[slot], bs->slots[slot].ct_n);
CHECK(bs->slots[slot].vis_n == 0,
"an elided projection longer than its own KV (19 > 18) is refused");
CHECK(bs->slots[slot].vis_think_n == 22,
"and refusing it does not take the reasoning-bearing one down too");
bs_prefill_abort(bs, slot);
bs_destroy(bs);
}
/* ============================================================
* Test 6b3 — CHANGING THE REASONING CONVENTION MID-SESSION
*
* The wanted capability is a harness that picks OFF / SUMMARY / FULL per turn
* from the situation (circling -> SUMMARY, hard diagnosis -> FULL, context
* tight -> step down). That is only worth building if a switch is free, so
* this test drives the real ladder across switches and asserts what each one
* costs. The three questions it answers, and the answers it asserts:
*
* 1. WHAT IS `head`? The tokens the client ACTUALLY SENT for this turn,
* minus the forced generation prefix (bs_slot_note_visible takes
* prompt[0, n_prompt - think_prefill_len)) — never anything reconstructed
* from the KV. So every earlier turn is frozen at whatever rendering the
* client used when it sent it, and the two candidates decide ONLY the
* newest assistant turn's think block.
* 2. A FORWARD SWITCH IS FREE. Turn 2 renders turn 1 with its reasoning
* (think-included hits); turn 3 renders turn 2 WITHOUT (think-elided hits
* off the very same committed turn). Neither costs a token.
* 3. REWRITING THE PAST IS NOT. A client that drops a block it already sent
* — the "reasoning on the last turn only" sliding window — changes `head`
* and defeats both candidates on every turn it slides.
* 4. A THIRD CONVENTION IS NOT FREE EITHER, ONCE. A SUMMARY is neither
* candidate: the block is present with different text, so it misses on the
* turn it is introduced. It costs exactly that turn — by the next one the
* summary is part of `head` and the ladder resumes again.
* ============================================================ */
static int cat_tokens(uint32_t *out, int at, const uint32_t *src, int n)
{
memcpy(out + at, src, (size_t)n * sizeof(uint32_t));
return at + n;
}
/* The conversation's pieces. A think block is 3 tokens whichever convention
* renders it, so LENGTH is never what tells the candidates apart — only the
* reasoning token in the middle differs (310/311 = the model's own reasoning,
* 321 = a summary of it). A rung that discriminated by length would pass the
* forward-switch cases and silently accept the summary. */
static const uint32_t CS_SYS[4] = { 100, 101, 102, 103 }; /* sys_len = 4 */
static const uint32_t CS_U1[3] = { 110, 111, 112 };
static const uint32_t CS_U2[3] = { 120, 121, 122 };
static const uint32_t CS_U3[3] = { 130, 131, 132 };
static const uint32_t CS_U4[3] = { 140, 141, 142 };
static const uint32_t CS_TP[1] = { 248068 }; /* forced <think> */
static const uint32_t CS_B1[3] = { 248068, 310, 248069 }; /* turn 1, FULL */
static const uint32_t CS_B2[3] = { 248068, 311, 248069 }; /* turn 2, FULL */
static const uint32_t CS_S2[3] = { 248068, 321, 248069 }; /* turn 2, SUMMARY*/
static const uint32_t CS_B3[3] = { 248068, 312, 248069 }; /* turn 3, FULL */
static const uint32_t CS_R1[4] = { 500, 501, 502, 503 }; /* re-rendered reply */
static const uint32_t CS_R2[4] = { 510, 511, 512, 513 };
static const uint32_t CS_R3[4] = { 520, 521, 522, 523 };
/* Generated: 3 sampled think ids (NOT the client's rendering) + the reply. */
static const uint32_t CS_GEN1[7] = { 900, 901, 902, 500, 501, 502, 503 };
static const uint32_t CS_GEN2[7] = { 910, 911, 912, 510, 511, 512, 513 };
static const uint32_t CS_GEN3[7] = { 920, 921, 922, 520, 521, 522, 523 };
enum { CS_SYS_LEN = 4 };
/* Run one turn through the production entry points and commit it.
*
* The committed log is seeded by bs_slot_bind's own rule (batch_sched.c,
* ct_keep / ct_prompt_from): hist[0, ct_keep) ++ req[ct_prompt_from, n_req) ++
* the generated tokens. Copying that rule rather than "prompt ++ gen" is what
* makes the chain faithful across a projection resume, where the KV keeps the
* ids the model SAMPLED inside its think block and the client's rendering only
* covers the tail. Returns the ladder's delta. */
static int commit_convention_turn(pn_batch_sched *bs, const char *sid,
const uint32_t *req, int n_req,
const uint32_t *gen, int n_gen,
const uint32_t *block, int block_n,
const uint32_t *rerender, int rerender_n)
{
int delta = -1, cap_req = -1;
int slot = bs_slot_acquire_biased(bs, sid);
bs_prefill_resume_sys(bs, slot, sid, req, n_req, CS_SYS_LEN, &delta,
&cap_req);
bs_slot *sl = &bs->slots[slot];
if (cap_req) {
/* The ladder asked for a boundary snapshot: take it mid-prefill exactly
* as the caller does, so a later MISS falls to the sys-prefix rung and
* the test measures the cost the fleet would actually pay. */
pn_qwen35moe_state *q = (pn_qwen35moe_state *)sl->state->kv_cache;
fill_ssm(q, 1.0f);
fill_kv_rows(q, 0, CS_SYS_LEN, 1.0f);
sl->state->pos = CS_SYS_LEN;
bs_prefill_capture_sys(bs, slot, sid, req, CS_SYS_LEN);
}
int keep = sl->ct_keep, from = sl->ct_prompt_from;
int n = keep + (n_req - from) + n_gen;
if (sl->ct_cap < n) {
sl->ct = realloc(sl->ct, (size_t)n * sizeof(uint32_t));
sl->ct_cap = n;
}
if (keep > 0)
memcpy(sl->ct, sl->hist, (size_t)keep * sizeof(uint32_t));
memcpy(sl->ct + keep, req + from, (size_t)(n_req - from) * sizeof(uint32_t));
memcpy(sl->ct + keep + (n_req - from), gen, (size_t)n_gen * sizeof(uint32_t));
sl->ct_n = n;
bs_slot_note_visible(bs, slot, req, n_req, 1, block, block_n,
rerender, rerender_n);
publish_vis(sl);
publish_warm(sl, sid, sl->ct, n);
bs_prefill_abort(bs, slot);
return delta;
}
/* Ask the ladder what a request would cost WITHOUT committing it. A hit leaves
* the slot untouched; a miss burns the warm state, which is the point of
* re-seeding before each miss probe. */
static int probe_turn(pn_batch_sched *bs, const char *sid,
const uint32_t *req, int n_req)
{
int delta = -1, cap_req = -1;
int slot = bs_slot_acquire_biased(bs, sid);
bs_prefill_resume_sys(bs, slot, sid, req, n_req, CS_SYS_LEN, &delta,
&cap_req);
bs_prefill_abort(bs, slot);
return delta;
}
/* Turns 1 and 2 of the conversation, committed. Turn 1 is cold and captures
* the system boundary; turn 2 is the client's FIRST convention choice — it
* renders turn 1 WITH the reasoning, which is the OFF->FULL forward switch. */
static void seed_two_turns(pn_batch_sched *bs, uint32_t *req2, int *n2_out)
{
uint32_t req1[8];
int n1 = cat_tokens(req1, 0, CS_SYS, 4);
n1 = cat_tokens(req1, n1, CS_U1, 3);
n1 = cat_tokens(req1, n1, CS_TP, 1);
CHECK(commit_convention_turn(bs, "A", req1, n1, CS_GEN1, 7, CS_B1, 3,
CS_R1, 4) == 0,
"turn 1 is a cold full prefill and captures the system boundary");
CHECK(bs->slots[0].vis_n == 11 && bs->slots[0].vis_think_n == 14,
"turn 1 records both conventions: elided 11, reasoning-bearing 14");
int n2 = cat_tokens(req2, 0, CS_SYS, 4);
n2 = cat_tokens(req2, n2, CS_U1, 3);
n2 = cat_tokens(req2, n2, CS_B1, 3); /* turn 1 rendered WITH reasoning */
n2 = cat_tokens(req2, n2, CS_R1, 4);
n2 = cat_tokens(req2, n2, CS_U2, 3);
n2 = cat_tokens(req2, n2, CS_TP, 1);
*n2_out = n2;
CHECK(commit_convention_turn(bs, "A", req2, n2, CS_GEN2, 7, CS_B2, 3,
CS_R2, 4) == 14,
"turn 2 sends reasoning for the first time and RESUMES (delta 14)");
}
static void test_reasoning_convention_switch(void)
{
printf("test: switching the reasoning convention mid-session\n");
g_make_q4 = 0;
pn_model model = {0};
model.config.arch = PN_ARCH_QWEN35MOE;
/* ONE slot: affinity cannot quietly hand a turn a cold slot and make a
* resume assertion pass for the wrong reason. */
pn_batch_sched *bs = mk_sched(&model, 1);
uint32_t req2[18];
int n2 = 0;
seed_two_turns(bs, req2, &n2);
CHECK(n2 == 18 && bs->slots[0].vis_n == 21 && bs->slots[0].vis_think_n == 24,
"turn 2's projections are built on the head the client SENT (17), "
"which already carries turn 1's block");
/* 1. FORWARD SWITCH, FULL -> OFF. Turn 3 keeps turn 1 exactly as it sent
* it and drops the block for turn 2 only. The reasoning-bearing
* candidate is tried FIRST and must decline; the elided one hits. */
uint32_t off[25];
int n_off = cat_tokens(off, 0, req2, 17); /* the head, byte-for-byte */
n_off = cat_tokens(off, n_off, CS_R2, 4);
n_off = cat_tokens(off, n_off, CS_U3, 3);
n_off = cat_tokens(off, n_off, CS_TP, 1);
CHECK(n_off == 25 && probe_turn(bs, "A", off, n_off) == 21,
"FULL -> OFF on the newest turn RESUMES (delta 21): a forward switch "
"costs nothing");
/* 2. The other direction off the SAME committed turn: stay FULL. */
uint32_t full[28];
int n_full = cat_tokens(full, 0, req2, 17);
n_full = cat_tokens(full, n_full, CS_B2, 3);
n_full = cat_tokens(full, n_full, CS_R2, 4);
n_full = cat_tokens(full, n_full, CS_U3, 3);
n_full = cat_tokens(full, n_full, CS_TP, 1);
CHECK(n_full == 28 && probe_turn(bs, "A", full, n_full) == 24,
"staying FULL resumes off the same turn (delta 24): the request "
"picks the candidate, the commit never did");
/* 3. THE THIRD CONVENTION. A SUMMARY renders the block with different text,
* so it is neither recorded candidate and the ladder drops to the
* system-prefix rung: 24 of 28 tokens re-prefill. This is where the
* per-turn OFF/SUMMARY/FULL idea costs something. */
uint32_t summ[28];
int n_summ = cat_tokens(summ, 0, req2, 17);
n_summ = cat_tokens(summ, n_summ, CS_S2, 3); /* same length as CS_B2 */
n_summ = cat_tokens(summ, n_summ, CS_R2, 4);
n_summ = cat_tokens(summ, n_summ, CS_U3, 3);
n_summ = cat_tokens(summ, n_summ, CS_TP, 1);
CHECK(n_summ == n_full,
"the summary is the same LENGTH as the reasoning it replaces, so only "
"its content can be what the ladder rejects");
CHECK(commit_convention_turn(bs, "A", summ, n_summ, CS_GEN3, 7, CS_B3, 3,
CS_R3, 4) == CS_SYS_LEN,
"introducing a SUMMARY MISSES both candidates: it falls to the "
"system-prefix rung and re-prefills the conversation");
/* 4. ...but only once. The summary is now part of the head the client
* sent, so it is frozen into turn 3's projections like any other text
* and the next turn resumes again. */
uint32_t next[35];
int n_next = cat_tokens(next, 0, summ, 27); /* head as sent, incl. S2 */
n_next = cat_tokens(next, n_next, CS_R3, 4);
n_next = cat_tokens(next, n_next, CS_U4, 3);
n_next = cat_tokens(next, n_next, CS_TP, 1);
CHECK(n_next == 35 && probe_turn(bs, "A", next, n_next) == 31,
"the turn AFTER a summary resumes (delta 31): the cost of a new "
"convention is ONE turn, paid where it is introduced");
/* 5. REWRITING THE PAST. "Reasoning on the newest turn only" is the
* obvious harness policy and it is the one that cannot work: at turn 3
* it drops the block it already sent for turn 1, which changes the head
* both candidates were built on. Nothing about this turn is new — same
* conversation, same tokens for turn 2 — and it still misses. */
bs_destroy(bs);
bs = mk_sched(&model, 1);
seed_two_turns(bs, req2, &n2);
uint32_t slide[25];
int n_slide = cat_tokens(slide, 0, CS_SYS, 4);
n_slide = cat_tokens(slide, n_slide, CS_U1, 3);
n_slide = cat_tokens(slide, n_slide, CS_R1, 4); /* turn 1's block DROPPED */
n_slide = cat_tokens(slide, n_slide, CS_U2, 3);
n_slide = cat_tokens(slide, n_slide, CS_B2, 3);
n_slide = cat_tokens(slide, n_slide, CS_R2, 4);
n_slide = cat_tokens(slide, n_slide, CS_U3, 3);
n_slide = cat_tokens(slide, n_slide, CS_TP, 1);
CHECK(n_slide == 25 && probe_turn(bs, "A", slide, n_slide) == CS_SYS_LEN,
"retroactively DROPPING a block the client already sent misses both "
"candidates: a sliding reasoning window re-prefills every turn");
bs_destroy(bs);
}
/* ============================================================
* Test 6d — CACHED-TOKENS REPORTING: pn_prefix_cached_tokens must equal the
* reuse each resume rung ACTUALLY performed.
*
* The number the serve puts on the wire as
* usage.prompt_tokens_details.cached_tokens is derived from the cache's own
* chosen resume boundary — never recomputed from the prompt, which would
* instrument the cache with a second implementation of itself and agree with it
* for the wrong reason. So this test drives the REAL ladder (both the
* single-user acquire_sys and the batched bs_prefill_resume_sys) and asserts
* the reported count against what each rung reused: 0 on a cold turn, the
* history length on an append, the projection length on a think-elided append,
* sys_len on a boundary restore, the LCP on a llama-family rewind.
* ============================================================ */
/* Report the cached count for a rung and check it against BOTH the cache's own
* delta (the derivation) and the length that rung was supposed to reuse (so a
* ladder that silently stopped reusing cannot pass by reporting its own zero). */
static void check_cached(int delta, int n_ctx, int want, const char *msg)
{
int cached = pn_prefix_cached_tokens(delta, n_ctx);
CHECK(cached == want && cached == delta && cached <= n_ctx, msg);
}
static void test_cached_tokens_tracks_resume_rungs(void)
{
printf("test: cached_tokens tracks every resume rung\n");
g_make_q4 = 0;
int delta = -1, cap = -1;
/* ── Single-user cache, qwen35 (snapshot family). ── */
pn_model model = {0};
model.config.arch = PN_ARCH_QWEN35MOE;
pn_prefix_cache *c = pn_prefix_cache_new(&model, 2);
uint32_t p1[20];
mk_prompt(p1, SYS_A, 1000, 20);
pn_state *st = pn_prefix_cache_acquire_sys(c, "s1", p1, 20, 8, &delta, &cap);
check_cached(delta, 20, 0, "cold turn reports 0 cached tokens");
pn_qwen35moe_state *qs = (pn_qwen35moe_state *)st->kv_cache;
fill_ssm(qs, 1.0f);
fill_kv_rows(qs, 0, 8, 1.0f);
st->pos = 8;
CHECK(pn_prefix_cache_capture_sys(c, "s1", p1, 8) == 0, "boundary captured");
fill_kv_rows(qs, 8, 24, 5.0f);
st->pos = 24;
uint32_t hist1[24];
memcpy(hist1, p1, 20 * sizeof(uint32_t));
for (int i = 20; i < 24; i++) hist1[i] = (uint32_t)(9000 + i);
CHECK(pn_prefix_cache_commit(c, "s1", hist1, 24) == 0, "turn 1 commit");
/* Append rung: the whole committed history is reused. */
uint32_t p2[26];
memcpy(p2, hist1, 24 * sizeof(uint32_t));
p2[24] = 7001; p2[25] = 7002;
pn_prefix_cache_acquire_sys(c, "s1", p2, 26, 8, &delta, &cap);
check_cached(delta, 26, 24, "append resume reports the reused history");
/* Sys-prefix restore rung: only the system prefix is reused. */
uint32_t p3[30];
mk_prompt(p3, SYS_A, 4000, 30);
pn_prefix_cache_acquire_sys(c, "s1", p3, 30, 8, &delta, &cap);
check_cached(delta, 30, 8, "sys-prefix restore reports sys_len cached");
pn_prefix_cache_free(c);
/* ── LCP-rewind rung (llama family — no snapshots). ── */
pn_model lmodel = {0};
lmodel.config.arch = PN_ARCH_LLAMA;
pn_prefix_cache *lc = pn_prefix_cache_new(&lmodel, 2);
st = pn_prefix_cache_acquire_sys(lc, "r1", p1, 20, 8, &delta, &cap);
check_cached(delta, 20, 0, "llama cold turn reports 0 cached tokens");
st->pos = 24;
CHECK(pn_prefix_cache_commit(lc, "r1", hist1, 24) == 0, "llama turn 1 commit");
pn_prefix_cache_acquire_sys(lc, "r1", p3, 30, 8, &delta, &cap);
check_cached(delta, 30, 8, "lcp rewind reports the common prefix cached");
pn_prefix_cache_free(lc);
/* ── Batched twin: the path the fleet actually runs. ── */
pn_batch_sched *bs = mk_sched(&model, 2);
uint32_t prompt[11];
for (int i = 0; i < 11; i++) prompt[i] = (uint32_t)(100 + i);
uint32_t gen[7] = { 900, 901, 902, 500, 501, 502, 503 };
int slot = bs_slot_acquire_biased(bs, "A");
bs_prefill_resume_sys(bs, slot, "A", prompt, 11, 4, &delta, &cap);
check_cached(delta, 11, 0, "batched cold turn reports 0 cached tokens");
/* Think-elided append: the client resends its own projection, so the reuse
* is the PROJECTION length (14), not the slot's longer elided KV (18). */
seed_committed_log(&bs->slots[slot], prompt, 11, gen, 7);
uint32_t rerender[4] = { 500, 501, 502, 503 };
bs_slot_note_visible(bs, slot, prompt, 11, 1, NULL, 0, rerender, 4);
publish_vis(&bs->slots[slot]);
publish_warm(&bs->slots[slot], "A", bs->slots[slot].ct, 18);
bs_prefill_abort(bs, slot);
uint32_t next[18];
memcpy(next, bs->slots[0].vis, 14 * sizeof(uint32_t));
for (int i = 14; i < 18; i++) next[i] = (uint32_t)(700 + i);
slot = bs_slot_acquire_biased(bs, "A");
bs_prefill_resume_sys(bs, slot, "A", next, 18, 4, &delta, &cap);
check_cached(delta, 18, 14,
"think-elided append reports the projection, not the KV length");
bs_prefill_abort(bs, slot);
bs_destroy(bs);
/* Degenerate boundary: a resume position at or past the prompt still reports
* a count a client can divide by (computed = prompt - cached >= 0). */
CHECK(pn_prefix_cached_tokens(40, 30) == 30, "cached clamps to prompt length");
CHECK(pn_prefix_cached_tokens(-1, 30) == 0, "negative boundary reports 0");
}
/* ============================================================
* Test 7 — Phase 5 session admission: REFUSE at capacity + idle TTL
* ============================================================ */
static void test_session_admission(void)
{
printf("test: session admission (refuse at capacity + idle TTL)\n");
g_make_q4 = 0;
pn_model model = {0};
model.config.arch = PN_ARCH_QWEN35MOE;
pn_prefix_cache *c = pn_prefix_cache_new(&model, 2); /* cap = 2 */
CHECK(c != NULL, "cache created (cap 2)");
int rt = -1;
/* Empty cache: any new sid is admitted (a free slot exists). */
CHECK(pn_prefix_cache_admit(c, "a", 1000, 0, &rt) == PN_SESSION_ADMIT_OK &&
rt == 0, "empty cache admits new sid 'a' (restart_tokens 0)");
/* Fill both slots with two live sessions via acquire. */
uint32_t p[20];
mk_prompt(p, SYS_A, 1000, 20);
int delta = -1;
pn_state *sa = pn_prefix_cache_acquire(c, "a", p, 20, &delta);
pn_state *sb = pn_prefix_cache_acquire(c, "b", p, 20, &delta);
CHECK(sa && sb, "two sessions 'a','b' acquired (both slots live)");
pn_prefix_cache_touch(c, "a", 1000);
pn_prefix_cache_touch(c, "b", 1000);
/* Give 'a' a committed history so its restart estimate is non-zero. */
CHECK(pn_prefix_cache_commit(c, "a", p, 20) == 0, "commit 'a' history (20 tok)");
/* Existing sid always resumes, and reports its committed length. */
rt = -1;
CHECK(pn_prefix_cache_admit(c, "a", 2000, 0, &rt) == PN_SESSION_ADMIT_OK &&
rt == 20, "existing sid 'a' admitted; restart_tokens = 20");
/* Genuinely-new sid over the cap with NO idle TTL -> REFUSE. */
rt = 99;
CHECK(pn_prefix_cache_admit(c, "c", 2000, 0, &rt) ==
PN_SESSION_ADMIT_AT_CAPACITY && rt == 0,
"new sid 'c' over cap refused (no reaping; restart_tokens 0)");
/* Idle TTL frees a slot: 'b' last active at t=1000; at t=2_000_000 with a
* 60_000 ms TTL it is idle (idle 1_999_000 ms >= 60_000) and reaped, so 'c'
* gets in. 'a' was touched more recently (t=... we touch it now to keep it
* live) — verify only ONE slot is freed and the active one survives. */
pn_prefix_cache_touch(c, "a", 1999000); /* keep 'a' fresh */
rt = -1;
CHECK(pn_prefix_cache_admit(c, "c", 2000000, 60000, &rt) ==
PN_SESSION_ADMIT_OK,
"idle 'b' reaped past TTL -> new sid 'c' admitted");
/* 'a' was kept fresh, so it must still resume (was not the reaped one). */
CHECK(pn_prefix_cache_admit(c, "a", 2000000, 60000, NULL) ==
PN_SESSION_ADMIT_OK, "fresh 'a' still resident after reaping 'b'");
/* NULL cache / empty sid -> OK (uncached path, no-op). */
CHECK(pn_prefix_cache_admit(NULL, "x", 0, 0, NULL) == PN_SESSION_ADMIT_OK,
"NULL cache -> OK");
CHECK(pn_prefix_cache_admit(c, "", 0, 0, NULL) == PN_SESSION_ADMIT_OK,
"empty sid -> OK");
pn_prefix_cache_free(c);
}
int main(void)
{
printf("=== test_prefix_cache_sys (SYS1) ===\n");
test_snapshot_roundtrip_fp32();
test_snapshot_roundtrip_q4();
test_snap_serialize_roundtrip(0);
test_snap_serialize_roundtrip(1);
test_acquire_sys_flow();
test_unsupported_arch();
test_lcp_rewind();
test_batched_sys_flow();
test_session_substitution();
test_two_convos_concurrent_title();
test_two_convos_interleaved();
test_think_elided_projection();
test_reasoning_bearing_resume();
test_reasoning_convention_switch();
test_cached_tokens_tracks_resume_rungs();
test_session_admission();
if (failures) {
printf("=== %d FAILURE(S) ===\n", failures);
return 1;
}
printf("=== all tests passed ===\n");
return 0;
}