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.
159 lines
5.4 KiB
TypeScript
159 lines
5.4 KiB
TypeScript
import { inject } from '@adonisjs/core'
|
|
import { execFile } from 'node:child_process'
|
|
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
import { dirname, join, resolve } from 'node:path'
|
|
import { promisify } from 'node:util'
|
|
import type { GenerationRunResult } from './eval_generation_service.js'
|
|
import type { RetrievalRunResult } from './eval_retrieval_service.js'
|
|
import { flattenByK, type EvalReport, type ReportMeta } from '../utils/eval/report.js'
|
|
|
|
const run = promisify(execFile)
|
|
|
|
export const EVAL_REPORTS_DIR = 'tests/eval/reports'
|
|
export const EVAL_BASELINES_DIR = 'tests/eval/baselines'
|
|
|
|
/**
|
|
* Turns run results into durable, comparable report files.
|
|
*
|
|
* Two formats, on purpose: JSON is what `eval:compare` diffs, and Markdown is
|
|
* what a human actually reads when a number moved and they need to know which
|
|
* question broke and what the model said.
|
|
*/
|
|
@inject()
|
|
export class EvalReportService {
|
|
async buildMeta(
|
|
kind: 'retrieval' | 'generation',
|
|
corpusFingerprint: string,
|
|
params: Record<string, unknown>
|
|
): Promise<ReportMeta> {
|
|
const [git, nomadVersion, platform] = await Promise.all([
|
|
readGitState(),
|
|
readNomadVersion(),
|
|
readPlatform(),
|
|
])
|
|
return {
|
|
kind,
|
|
createdAt: new Date().toISOString(),
|
|
corpusFingerprint,
|
|
gitSha: git.sha,
|
|
gitBranch: git.branch,
|
|
gitDirty: git.dirty,
|
|
nomadVersion,
|
|
platform,
|
|
params,
|
|
}
|
|
}
|
|
|
|
fromRetrieval(meta: ReportMeta, result: RetrievalRunResult): EvalReport {
|
|
const flatten = (agg: RetrievalRunResult['overall']) => ({
|
|
...flattenByK('recall', agg.recall),
|
|
...flattenByK('hitRate', agg.hitRate),
|
|
...flattenByK('precision', agg.precision),
|
|
...flattenByK('ndcg', agg.ndcg),
|
|
mrr: agg.mrr,
|
|
emptyRateOnAnswerable: agg.emptyRateOnAnswerable,
|
|
nonEmptyRateOnRefusal: agg.nonEmptyRateOnRefusal,
|
|
})
|
|
return {
|
|
meta,
|
|
metrics: flatten(result.overall),
|
|
byTag: Object.fromEntries(Object.entries(result.byTag).map(([tag, agg]) => [tag, flatten(agg)])),
|
|
cases: result.cases,
|
|
}
|
|
}
|
|
|
|
fromGeneration(meta: ReportMeta, result: GenerationRunResult): EvalReport {
|
|
const flatten = (agg: GenerationRunResult['overall']) => ({
|
|
correctness: agg.correctness,
|
|
refusalCorrectness: agg.refusalCorrectness,
|
|
leakageRate: agg.leakageRate,
|
|
thinkTagLeakRate: agg.thinkTagLeakRate,
|
|
markdownRate: agg.markdownRate,
|
|
groundedness: agg.groundedness?.mean ?? null,
|
|
historyElidedRate: agg.historyElidedRate,
|
|
chunksDroppedRate: agg.chunksDroppedRate,
|
|
promptTokenError: agg.promptTokenError?.mean ?? null,
|
|
meanPromptTokens: agg.meanPromptTokens,
|
|
})
|
|
return {
|
|
meta,
|
|
metrics: flatten(result.overall),
|
|
byTag: Object.fromEntries(Object.entries(result.byTag).map(([tag, agg]) => [tag, flatten(agg)])),
|
|
// Answers are kept in full. When a score moves, the only question anyone
|
|
// actually asks is "what did it say?", and a truncated answer cannot
|
|
// answer it.
|
|
cases: result.cases,
|
|
}
|
|
}
|
|
|
|
/** Write `<slug>.json` and `<slug>.md`; returns the JSON path. */
|
|
async write(report: EvalReport, slug: string, markdown: string): Promise<string> {
|
|
const dir = resolve(join(process.cwd(), EVAL_REPORTS_DIR))
|
|
await mkdir(dir, { recursive: true })
|
|
const jsonPath = join(dir, `${slug}.json`)
|
|
await writeFile(jsonPath, JSON.stringify(report, null, 2))
|
|
await writeFile(join(dir, `${slug}.md`), markdown)
|
|
return jsonPath
|
|
}
|
|
|
|
async read(path: string): Promise<EvalReport> {
|
|
const parsed = JSON.parse(await readFile(resolve(path), 'utf8'))
|
|
if (!parsed?.meta?.kind || !parsed?.metrics) {
|
|
throw new Error(`${path} is not an eval report`)
|
|
}
|
|
return parsed as EvalReport
|
|
}
|
|
|
|
/**
|
|
* Baselines are filed under their corpus fingerprint. That is not cosmetic:
|
|
* it makes it structurally impossible to overwrite the baseline for one
|
|
* corpus with a run against another.
|
|
*/
|
|
baselinePath(report: EvalReport, name: string): string {
|
|
return resolve(
|
|
join(process.cwd(), EVAL_BASELINES_DIR, report.meta.corpusFingerprint, `${name}.json`)
|
|
)
|
|
}
|
|
|
|
async promoteToBaseline(report: EvalReport, name: string): Promise<string> {
|
|
const path = this.baselinePath(report, name)
|
|
await mkdir(dirname(path), { recursive: true })
|
|
await writeFile(path, JSON.stringify(report, null, 2))
|
|
return path
|
|
}
|
|
}
|
|
|
|
async function readGitState() {
|
|
const git = async (args: string[]) => (await run('git', args)).stdout.trim()
|
|
try {
|
|
const [sha, branch, status] = await Promise.all([
|
|
git(['rev-parse', 'HEAD']),
|
|
git(['rev-parse', '--abbrev-ref', 'HEAD']),
|
|
git(['status', '--porcelain']),
|
|
])
|
|
// A dirty tree matters: it means the report does not correspond to any
|
|
// commit, so a later "this regressed at sha X" claim would be unfounded.
|
|
return { sha, branch, dirty: status.length > 0 }
|
|
} catch {
|
|
return { sha: null, branch: null, dirty: false }
|
|
}
|
|
}
|
|
|
|
async function readNomadVersion(): Promise<string | null> {
|
|
try {
|
|
const pkg = JSON.parse(await readFile(resolve(join(process.cwd(), '..', 'package.json')), 'utf8'))
|
|
return pkg.version ?? null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
async function readPlatform() {
|
|
const os = await import('node:os')
|
|
return {
|
|
cpuArchitecture: os.arch(),
|
|
osName: `${os.type()} ${os.release()}`,
|
|
nodeVersion: process.version,
|
|
}
|
|
}
|