mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-10-17 13:19:18 +02:00
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.
141 lines
6.3 KiB
TypeScript
141 lines
6.3 KiB
TypeScript
import { test } from 'node:test'
|
|
import assert from 'node:assert/strict'
|
|
import {
|
|
CONVERSATION_OVERHEAD_TOKENS,
|
|
ESTIMATE_SAFETY_MARGIN,
|
|
MAX_CALIBRATION_RATIO,
|
|
MIN_CALIBRATION_RATIO,
|
|
PER_MESSAGE_OVERHEAD_TOKENS,
|
|
clampRatio,
|
|
estimateMessagesTokens,
|
|
estimateTokens,
|
|
estimateTokensConservative,
|
|
updateEwma,
|
|
} from '../../app/utils/token_estimate.js'
|
|
|
|
/**
|
|
* The accuracy claim these guard is not "the estimate is correct" — it can't be,
|
|
* without the model's own vocabulary. It is "the estimate tracks a real
|
|
* tokenizer closely enough, and consistently enough per model, that a single
|
|
* learned factor fixes the rest".
|
|
*
|
|
* REAL_COUNTS below are actual `prompt_eval_count` values recorded from
|
|
* llama3:8b via /api/chat, so the regression bounds mean something. Re-record
|
|
* them (single user message, num_predict:1) if the fixtures ever change.
|
|
*/
|
|
const FIXTURES = {
|
|
prose:
|
|
'Water purification in an emergency comes down to three reliable options. Boiling is the most dependable: bring water to a rolling boil for one full minute, or three minutes above 6,500 feet of elevation. Chemical treatment with unscented household bleach works when fuel is scarce; add eight drops per gallon, stir, and wait thirty minutes. Filtration removes protozoa and bacteria but not viruses, so pair it with one of the other two methods when the source is questionable.',
|
|
markdown_table:
|
|
'| Method | Time | Removes | Notes |\n|---|---|---|---|\n| Boiling | 1 min | Bacteria, viruses, protozoa | Needs fuel |\n| Bleach | 30 min | Bacteria, viruses | 8 drops/gal |\n| Filter (0.2um) | Instant | Bacteria, protozoa | Not viruses |\n| UV (SteriPEN) | 90 sec | All | Needs batteries, clear water |\n| Distillation | 1 hr | Everything incl. salts | Slow, fuel-heavy |',
|
|
json: '{"model":"llama3:8b","messages":[{"role":"system","content":"You are helpful."}],"options":{"num_ctx":8192,"num_predict":512,"temperature":0.7,"seed":42},"keep_alive":"10m","stream":true}',
|
|
list: '- Boil water for one minute\n- Store in food-grade containers\n- Rotate stock every six months\n- Label every container with a fill date\n- Keep two weeks minimum per person\n- One gallon per person per day\n- Add extra for pets and sanitation',
|
|
}
|
|
|
|
/** Measured prompt_eval_count from llama3:8b for a single user message. */
|
|
const REAL_COUNTS: Record<keyof typeof FIXTURES, number> = {
|
|
prose: 108,
|
|
markdown_table: 127,
|
|
json: 67,
|
|
list: 64,
|
|
}
|
|
|
|
test('estimate stays within 20% of real token counts on varied content', () => {
|
|
for (const [name, text] of Object.entries(FIXTURES)) {
|
|
const real = REAL_COUNTS[name as keyof typeof FIXTURES]
|
|
const estimated = estimateMessagesTokens([{ role: 'user', content: text }])
|
|
const error = Math.abs(estimated - real) / real
|
|
assert.ok(
|
|
error < 0.2,
|
|
`${name}: estimated ${estimated} vs real ${real} (${(error * 100).toFixed(1)}% error)`
|
|
)
|
|
}
|
|
})
|
|
|
|
test('beats a fixed chars-per-token divisor on average', () => {
|
|
// The specific failure of a fixed divisor is that it cannot be right for both
|
|
// prose (~4 chars/token) and structured text (~2.5). Tuning it for one breaks
|
|
// the other, and structured text is what overflows windows.
|
|
let segmenterError = 0
|
|
let divisorError = 0
|
|
for (const [name, text] of Object.entries(FIXTURES)) {
|
|
const real = REAL_COUNTS[name as keyof typeof FIXTURES]
|
|
segmenterError += Math.abs(estimateMessagesTokens([{ role: 'user', content: text }]) - real) / real
|
|
divisorError += Math.abs(Math.ceil(text.length / 3.5) - real) / real
|
|
}
|
|
assert.ok(
|
|
segmenterError < divisorError,
|
|
`segmenter total error ${segmenterError.toFixed(3)} should beat chars/3.5 ${divisorError.toFixed(3)}`
|
|
)
|
|
})
|
|
|
|
test('structured text costs more per character than prose', () => {
|
|
// The property that makes a fixed divisor wrong, asserted directly.
|
|
const prosePerChar = estimateTokens(FIXTURES.prose) / FIXTURES.prose.length
|
|
const jsonPerChar = estimateTokens(FIXTURES.json) / FIXTURES.json.length
|
|
assert.ok(jsonPerChar > prosePerChar, 'JSON should estimate more tokens per character than prose')
|
|
})
|
|
|
|
test('empty and whitespace input', () => {
|
|
assert.equal(estimateTokens(''), 0)
|
|
assert.equal(estimateMessagesTokens([]), 0)
|
|
assert.ok(estimateTokens(' ') >= 1)
|
|
})
|
|
|
|
test('newlines are counted, not swallowed', () => {
|
|
assert.ok(estimateTokens('a\nb\nc') > estimateTokens('a b c'))
|
|
})
|
|
|
|
test('long words are split into multiple tokens', () => {
|
|
// A 40-char identifier is several BPE pieces, not one.
|
|
assert.ok(estimateTokens('supercalifragilisticexpialidocious') > 3)
|
|
})
|
|
|
|
test('CJK characters cost roughly a token each', () => {
|
|
const text = '緊急時の水の浄化'
|
|
const estimated = estimateTokens(text)
|
|
assert.ok(estimated >= text.length, `expected >= ${text.length} tokens, got ${estimated}`)
|
|
})
|
|
|
|
test('message overhead scales with message count', () => {
|
|
const one = estimateMessagesTokens([{ role: 'user', content: 'hello' }])
|
|
const three = estimateMessagesTokens([
|
|
{ role: 'user', content: 'hello' },
|
|
{ role: 'assistant', content: 'hello' },
|
|
{ role: 'user', content: 'hello' },
|
|
])
|
|
assert.equal(three - one, 2 * (estimateTokens('hello') + PER_MESSAGE_OVERHEAD_TOKENS))
|
|
assert.ok(one > estimateTokens('hello') + CONVERSATION_OVERHEAD_TOKENS)
|
|
})
|
|
|
|
test('calibration ratio scales the estimate', () => {
|
|
const base = estimateTokens(FIXTURES.prose)
|
|
const scaled = estimateTokens(FIXTURES.prose, 1.25)
|
|
assert.equal(scaled, Math.ceil(base * 1.25))
|
|
})
|
|
|
|
test('conservative estimate never under-reports', () => {
|
|
const base = estimateTokens(FIXTURES.prose)
|
|
assert.ok(estimateTokensConservative(FIXTURES.prose) >= base)
|
|
assert.equal(estimateTokensConservative(FIXTURES.prose), Math.ceil(base * ESTIMATE_SAFETY_MARGIN))
|
|
})
|
|
|
|
test('EWMA seeds from the first observation then damps', () => {
|
|
assert.equal(updateEwma(null, 1.3), 1.3)
|
|
// A single outlier must nudge, not redefine.
|
|
const after = updateEwma(1.0, 2.0, 0.25)
|
|
assert.equal(after, 1.25)
|
|
assert.ok(after < 2.0)
|
|
})
|
|
|
|
test('ratio clamping rejects nonsense', () => {
|
|
assert.equal(clampRatio(Number.NaN), 1)
|
|
assert.equal(clampRatio(0), 1)
|
|
assert.equal(clampRatio(-3), 1)
|
|
assert.equal(clampRatio(99), MAX_CALIBRATION_RATIO)
|
|
assert.equal(clampRatio(0.01), MIN_CALIBRATION_RATIO)
|
|
// The real measured factors must survive clamping untouched.
|
|
assert.equal(clampRatio(1.26), 1.26)
|
|
assert.equal(clampRatio(1.02), 1.02)
|
|
})
|