project-nomad/admin/tests/unit/context_window.spec.ts
Jake Turner 9a95f7b2b8 feat(AI): budget the prompt and actually set the context window (#1253)
Chat was run through Ollama's OpenAI-compatible /v1 endpoint, which has no
context-size field, so the num_ctx we sent was silently discarded and the
entire NUM_CTX_LADDER was dead code. Every conversation ran at the backend
default and was truncated from the middle (where history and
retrieved context actually sit). Measured with identical ~7k-token prompts:
2,060 tokens processed against 7,049 on the native endpoint, and with the
needed fact in the discarded middle the old path hallucinated a plausible
wrong number.

Transport: add a native /api/chat path using the already-installed ollama
package, sending a real options object (num_ctx, num_predict, temperature,
seed) plus keep_alive. The OpenAI path is still used for LM Studio and llama.cpp,
minus the ineffective num_ctx and with include_usage so both paths return
prompt token counts. /api/show is now read for trained context, parameter
size, quantization and any modelfile num_ctx. The <think> stream normalizer
moves to app/utils/think_stream.ts so both transports share it; it gains a
flush() that fixes a dangling partial tag being dropped at end of stream.

Token accounting: replace chars/N with a structural segmenter scaled by a
per-model factor learned by EWMA from the prompt_eval_count every response
already returned and threw away. End-to-end estimator error is 4.6% against
18-20% for a fixed divisor — qwen2.5 converges to k=1.26 where llama3 sits
at 1.00, so no single global constant serves both.

Context window: resolved once per model from trained context, the exact KV
cost derived from GGUF metadata (head_count_kv, not head_count — GQA makes
them differ 4x), and available VRAM, then memoized. Memoization is
load-bearing: asking for a different num_ctx makes Ollama unload and reload
the model, stalling the turn and discarding the KV cache. The floor never
overrides the model's trained ceiling — tinyllama trains to 2048, and a
blind 4096 floor pushes RoPE past anything the weights saw.

Prompt budget: planPrompt allocates explicitly instead of delegating
overflow to silent backend truncation — response reserve, then fixed blocks
(system and current query, never dropped), then RAG chunks whole and
best-first, then history in whole user+assistant turns, evicted newest-first
in blocks with an elision marker. Block eviction keeps the prefix stable for
several turns rather than shifting on every one.

KV-cache ordering: the per-turn retrieved block moves behind history to the
tail, so the stable prefix stays byte-identical turn to turn. At turn 14 of
a long conversation, prefill is 231ms at the tail against 539ms at the front
(~1ms/turn growth against ~25ms). The win is conditional — with a large
retrieved block and short history the two measure the same — so both
orderings ship behind RAG_PLACEMENT. The per-turn query rewrite also moves
to the tasks model; on a single-slot Ollama it was evicting the chat model's
cached prefix on every single turn.

Also: OLLAMA_CONTEXT_LENGTH and OLLAMA_KV_CACHE_TYPE=q8_0 on the container
(q8_0 is treated as headroom, never as a budgeting assumption, since
unsupported architectures fall back to f16 silently), a Context Window
setting in Settings > Models, and budget fields on PipelineTrace.

Eval: golden suites (--suite) so the new long_context goldens cannot move
the aggregates the committed baselines were recorded against, plus budget
and estimator-error metrics through the report path. Retrieval is unchanged
and eval:compare reports no metric outside the tolerance band; the
long_context suite scores correctness 1.000 with historyElided 0.500 (the
two eviction cases, by design). Unit tests 337 -> 386 passing, same 7
pre-existing failures.

Ingest-side CHAR_TO_TOKEN_RATIO is deliberately untouched: changing it
re-chunks the corpus and invalidates retrieval baselines, which belongs in
its own measured change.
2026-09-29 15:54:41 -07:00

184 lines
6.7 KiB
TypeScript

import { test } from 'node:test'
import assert from 'node:assert/strict'
import {
MIN_CONTEXT,
UNKNOWN_BACKEND_CONTEXT,
computeKvBytesPerToken,
estimateKvBytesPerToken,
parseParameterBillions,
parseUserContextCap,
readContextLength,
readModelfileNumCtx,
resolveContextWindow,
snapToLadder,
} from '../../app/utils/context_window.js'
const GB = 2 ** 30
const KIB = 1024
/** Real /api/show model_info for llama3:8b, trimmed to the keys we read. */
const LLAMA3_8B_INFO = {
'general.architecture': 'llama',
'llama.attention.head_count': 32,
'llama.attention.head_count_kv': 8,
'llama.block_count': 32,
'llama.context_length': 8192,
'llama.embedding_length': 4096,
}
test('reads context length via the architecture prefix', () => {
assert.equal(readContextLength(LLAMA3_8B_INFO), 8192)
})
test('falls back to any *.context_length when architecture is missing', () => {
assert.equal(readContextLength({ 'qwen3.context_length': 32768 }), 32768)
})
test('readContextLength copes with a Map, and with nothing', () => {
assert.equal(readContextLength(new Map(Object.entries(LLAMA3_8B_INFO))), 8192)
assert.equal(readContextLength(undefined), undefined)
assert.equal(readContextLength({}), undefined)
assert.equal(readContextLength({ 'llama.block_count': 32 }), undefined)
})
test('reads num_ctx out of the modelfile parameters block', () => {
assert.equal(readModelfileNumCtx('num_keep 24\nnum_ctx 16384\nstop "<|eot_id|>"'), 16384)
assert.equal(readModelfileNumCtx('num_keep 24\nstop "<|eot_id|>"'), undefined)
assert.equal(readModelfileNumCtx(undefined), undefined)
})
test('computes exact KV bytes per token from GGUF metadata', () => {
// 2 (K+V) * 32 layers * 8 kv-heads * 128 head_dim * 2 bytes = 128 KiB/token,
// which puts an 8k window at exactly 1 GiB.
assert.equal(computeKvBytesPerToken(LLAMA3_8B_INFO), 128 * KIB)
assert.equal(computeKvBytesPerToken(LLAMA3_8B_INFO)! * 8192, 1 * GB)
})
test('uses grouped-query KV head count, not attention head count', () => {
// head_count 32 vs head_count_kv 8: using the wrong one overstates cost 4x and
// would hand every GQA model a quarter of the window it can afford.
const noGqa = { ...LLAMA3_8B_INFO, 'llama.attention.head_count_kv': 32 }
assert.equal(computeKvBytesPerToken(noGqa), 4 * computeKvBytesPerToken(LLAMA3_8B_INFO)!)
})
test('KV computation returns undefined on incomplete metadata', () => {
assert.equal(computeKvBytesPerToken({ 'general.architecture': 'llama' }), undefined)
assert.equal(computeKvBytesPerToken({}), undefined)
assert.equal(computeKvBytesPerToken(undefined), undefined)
})
test('KV estimate falls back by parameter size, monotonically', () => {
assert.ok(estimateKvBytesPerToken(1) < estimateKvBytesPerToken(8))
assert.ok(estimateKvBytesPerToken(8) < estimateKvBytesPerToken(70))
// Unknown size is treated as mid-range rather than free.
assert.equal(estimateKvBytesPerToken(undefined), estimateKvBytesPerToken(8))
})
test('parses parameter size from details, falling back to the tag name', () => {
assert.equal(parseParameterBillions('8.0B'), 8)
assert.equal(parseParameterBillions('1.5B'), 1.5)
assert.equal(parseParameterBillions(undefined, 'qwen2.5:0.5b'), 0.5)
assert.equal(parseParameterBillions(undefined, 'llama3.2:3b'), 3)
// The case the old name-regex got wrong: no size in the name at all. Better to
// say "unknown" than to silently assume 8B.
assert.equal(parseParameterBillions(undefined, 'phi3'), undefined)
})
test('snaps down to a ladder rung', () => {
assert.equal(snapToLadder(9000), 8192)
assert.equal(snapToLadder(8192), 8192)
assert.equal(snapToLadder(100), MIN_CONTEXT)
})
test('the model trained context is a hard ceiling', () => {
const d = resolveContextWindow({
modelMaxCtx: 8192,
kvBytesPerToken: 128 * KIB,
availableBytes: 80 * GB,
})
assert.equal(d.contextWindow, 8192)
assert.equal(d.limitedBy, 'model')
})
test('never exceeds trained context even to reach the floor', () => {
// tinyllama trains to 2048. Requesting 4096 pushes RoPE past anything the
// weights saw; a smaller-than-floor window is the correct answer here.
const d = resolveContextWindow({
modelMaxCtx: 2048,
kvBytesPerToken: 22 * KIB,
availableBytes: 8 * GB,
})
assert.equal(d.contextWindow, 2048)
assert.equal(d.limitedBy, 'model')
})
test('memory limits the window when the model would allow more', () => {
// gemma-class KV cost against a small card.
const d = resolveContextWindow({
modelMaxCtx: 262144,
kvBytesPerToken: 1536 * KIB,
availableBytes: 24 * GB,
modelBytes: 7.6 * GB,
})
assert.ok(d.contextWindow < 262144)
assert.ok(d.contextWindow >= MIN_CONTEXT)
})
test('a user cap only ever lowers the result', () => {
const capped = resolveContextWindow({
modelMaxCtx: 131072,
kvBytesPerToken: 112 * KIB,
availableBytes: 24 * GB,
userCap: 8192,
})
assert.equal(capped.contextWindow, 8192)
assert.equal(capped.limitedBy, 'user')
// A cap above what the model or memory allows must not raise anything.
const notRaised = resolveContextWindow({
modelMaxCtx: 8192,
kvBytesPerToken: 128 * KIB,
availableBytes: 24 * GB,
userCap: 131072,
})
assert.equal(notRaised.contextWindow, 8192)
})
test('weights are subtracted from the memory pool', () => {
const inputs = { modelMaxCtx: 131072, kvBytesPerToken: 128 * KIB, availableBytes: 8 * GB }
const withoutWeights = resolveContextWindow(inputs)
const withWeights = resolveContextWindow({ ...inputs, modelBytes: 5 * GB })
assert.ok(withWeights.contextWindow < withoutWeights.contextWindow)
})
test('falls back to a conservative default when nothing is known', () => {
const d = resolveContextWindow({ kvBytesPerToken: 128 * KIB })
assert.equal(d.contextWindow, UNKNOWN_BACKEND_CONTEXT)
assert.equal(d.limitedBy, 'default')
})
test('the resolver is deterministic — the same inputs give the same window', () => {
// Stability is the whole point: a window that varies between requests makes
// Ollama unload and reload the model, stalling the turn and dropping the KV cache.
const inputs = {
modelMaxCtx: 131072,
kvBytesPerToken: 128 * KIB,
availableBytes: 8 * GB,
modelBytes: 4.7 * GB,
}
const first = resolveContextWindow(inputs)
for (let i = 0; i < 5; i++) {
assert.deepEqual(resolveContextWindow(inputs), first)
}
})
test('parses the user context cap setting', () => {
assert.equal(parseUserContextCap('16384'), 16384)
assert.equal(parseUserContextCap('auto'), undefined)
assert.equal(parseUserContextCap(null), undefined)
assert.equal(parseUserContextCap(''), undefined)
assert.equal(parseUserContextCap('nonsense'), undefined)
// Below the floor is meaningless; treat it as unset rather than crippling chat.
assert.equal(parseUserContextCap('512'), undefined)
})