project-nomad/admin/commands/eval/generation.ts
jakeaturner 27be71b46e feat(AI): give chat a sampler instead of the backend's defaults
Chat sent num_ctx, num_predict and nothing else, so every turn decoded at
whatever the backend picked: temperature 0.8, top_p 0.9, no min_p. On a
heavily quantized 1-3B model that is the wandering, repetitive output people
read as "the local model is bad".

Adds `ai.responseStyle` with four profiles. min_p is the lever, not
temperature: it floors relative to the top token, so it truncates hard on a
confident distribution and barely at all on a flat one, which is where small
models start to wander. top_p stays at 1.0 rather than stacked on top of it.
repeat_penalty moves with the style, because a grounded answer legitimately
repeats a part number inside the 64-token window: `focused` relaxes it to
1.05, `creative` tightens it to 1.15.

`auto` is the only style that defers to the model. Plenty of library models
ship author-tuned samplers (qwen3 sets 0.6/0.95/20), so those win key by key
and NOMAD fills only the gap, which in practice is min_p. A style the user
picked by name overrides them, or it would silently do nothing on half
their models.

The compat transport drops min_p, so a profile carries a separate compatTopP.
Passing top_p 1.0 to a backend with no min_p behind it would sample the raw
distribution, which is worse than the 0.9 it replaced.

Resolved in the controller, so the grammar-constrained task calls keep their
temperature 0. An explicit temperature still outranks a profile's, which is
what keeps the eval harness deterministic.

`eval:generation --style` is new and load-bearing: temperature 0 makes min_p,
top_p and top_k inert, so a default run cannot see this change at all.
`--style=off` sends no samplers and no temperature, and is the control.

Verified against Ollama 0.32.9, min_p accepted. 22 unit tests.
2026-09-29 15:54:41 -07:00

295 lines
12 KiB
TypeScript

import { BaseCommand, flags } from '@adonisjs/core/ace'
import type { CommandOptions } from '@adonisjs/core/types/ace'
import type {
GenerationAggregate,
GenerationMode,
} from '../../app/services/eval_generation_service.js'
import type { ResponseStyle } from '../../types/ollama.js'
const MODES: GenerationMode[] = ['oracle', 'e2e', 'noretrieval']
const STYLES: ResponseStyle[] = ['auto', 'focused', 'creative', 'off']
/**
* Score generated answers against the golden set.
*
* The `--mode` flag is the whole point of this command:
*
* oracle perfect context injected by construction -> isolates the MODEL
* e2e real retrieval -> the real product
* noretrieval no context at all -> parametric baseline
*
* Run all three on the same model and the ambiguous complaint "the AI gave a
* bad answer" decomposes into a number that can be acted on.
*
* node ace eval:generation --model=llama3.2:latest --mode=oracle
* node ace eval:generation --model=llama3:8b --all-modes
* node ace eval:generation --model=mock # no Ollama needed
*/
export default class EvalGeneration extends BaseCommand {
static commandName = 'eval:generation'
static description = 'Score generated answers, with oracle/e2e/noretrieval ablation'
@flags.string({ description: 'Ollama model to evaluate, or "mock" for the extractive ceiling' })
declare model: string
@flags.string({ description: `One of: ${MODES.join(', ')} (default: e2e)` })
declare mode: string
@flags.boolean({ description: 'Run all three modes and print the decomposition' })
declare allModes: boolean
@flags.string({ description: 'Repeats per question, for stability (default: 3)' })
declare repeats: string
@flags.string({
description:
'Comma-separated golden suites to run, by filename without .jsonl (default: core). ' +
'Extra suites are opt-in so they cannot move the numbers a baseline was recorded against.',
})
declare suite: string
@flags.string({
description:
`Sample the way real chat does, under one of: ${STYLES.join(', ')}. ` +
'Omit for the deterministic temperature-0 default. Greedy decoding makes ' +
'min_p, top_p and top_k inert, so a Response Style is only visible with ' +
'this flag set, and "off" (no samplers, no temperature) is the control to ' +
'compare a style against.',
})
declare style: string
@flags.string({ description: 'Only run goldens carrying this tag' })
declare tag: string
@flags.string({ description: 'Limit to the first N goldens (for a quick smoke run)' })
declare limit: string
@flags.boolean({ description: 'Print every failing question with the answer the model gave' })
declare verbose: boolean
@flags.boolean({ description: 'Leave application debug logging on (very noisy)' })
declare debug: boolean
static options: CommandOptions = {
startApp: true,
}
async run() {
const { EvalCorpusService } = await import('#services/eval_corpus_service')
const { EvalGenerationService, MOCK_MODEL } = await import('#services/eval_generation_service')
const { quietLogging } = await import('../../app/utils/eval/quiet.js')
const corpusService = await this.app.container.make(EvalCorpusService)
const generationService = await this.app.container.make(EvalGenerationService)
const restoreLogging = quietLogging(this.debug)
try {
if (!this.model) {
this.logger.error('--model is required (use --model=mock to run without Ollama)')
this.exitCode = 1
return
}
const modes: GenerationMode[] = this.allModes
? MODES
: [(this.mode as GenerationMode) || 'e2e']
for (const mode of modes) {
if (!MODES.includes(mode)) {
this.logger.error(`Unknown mode "${mode}". Expected one of: ${MODES.join(', ')}`)
this.exitCode = 1
return
}
}
if (this.style && !STYLES.includes(this.style as ResponseStyle)) {
this.logger.error(`Unknown style "${this.style}". Expected one of: ${STYLES.join(', ')}`)
this.exitCode = 1
return
}
const samplerStyle = this.style ? (this.style as ResponseStyle) : undefined
let goldens = await corpusService.loadGoldens(this.suite ? this.suite.split(',').map((s) => s.trim()) : undefined)
if (this.tag) goldens = goldens.filter((g) => g.tags.includes(this.tag))
if (this.limit) goldens = goldens.slice(0, Number.parseInt(this.limit, 10))
if (goldens.length === 0) {
this.logger.error('No goldens matched the given filters')
this.exitCode = 1
return
}
// e2e and oracle both need the corpus present; noretrieval does not, but
// requiring it uniformly keeps the three modes comparable.
const chunks = await corpusService.count()
if (chunks === 0 && modes.some((m) => m !== 'noretrieval')) {
this.logger.error('The eval corpus is not ingested. Run: node ace eval:corpus --ingest')
this.exitCode = 1
return
}
const fingerprint = await corpusService.fingerprint()
const repeats = this.repeats ? Number.parseInt(this.repeats, 10) : 3
this.logger.info(
`Corpus ${fingerprint} · ${goldens.length} goldens · model=${this.model} · repeats=${repeats}` +
(samplerStyle ? ` · style=${samplerStyle}` : ' · style=deterministic (temp 0)')
)
if (samplerStyle) {
this.logger.info(
'Sampling is on: the seed is fixed but the run is only approximately reproducible. Watch the unstable count.'
)
}
if (this.model === MOCK_MODEL) {
this.logger.info(
'Mock model: answers are the injected context verbatim — this is the extractive ceiling for the current retrieval, not a real model.'
)
}
this.logger.info('')
const summaries: Array<{ mode: GenerationMode; agg: GenerationAggregate }> = []
for (const mode of modes) {
const result = await generationService.run(goldens, {
mode,
model: this.model,
repeats,
samplerStyle,
onProgress: (id, index, total) => {
if (this.verbose) this.logger.info(` [${index}/${total}] ${mode}: ${id}`)
},
})
summaries.push({ mode, agg: result.overall })
this.printAggregate(mode, result.overall, result.elapsedMs)
if (result.overall.errors > 0) {
this.logger.error(
`${result.overall.errors} question(s) errored talking to the model — the scores above are incomplete.`
)
const firstError = result.cases.find((c) => c.error)
if (firstError) this.logger.error(` first error (${firstError.id}): ${firstError.error}`)
this.exitCode = 1
}
if (this.verbose) this.printFailures(result.cases)
}
if (summaries.length > 1) this.printDecomposition(summaries)
} catch (error) {
this.logger.error(error instanceof Error ? error.message : String(error))
this.exitCode = 1
} finally {
restoreLogging()
}
}
private printAggregate(mode: GenerationMode, agg: GenerationAggregate, elapsedMs: number) {
this.logger.info(`=== ${mode.toUpperCase()} (${(elapsedMs / 1000).toFixed(1)}s) ===`)
this.logger.info(` correctness ${fmt(agg.correctness)}`)
this.logger.info(` refusal correct ${fmt(agg.refusalCorrectness)}`)
this.logger.info(` leakage rate ${fmt(agg.leakageRate)} (lower is better)`)
this.logger.info(` think-tag leak ${fmt(agg.thinkTagLeakRate)} (should be 0)`)
this.logger.info(` markdown formatted ${fmt(agg.markdownRate)}`)
this.logger.info(
` numeric grounding ${agg.groundedness ? `${agg.groundedness.mean.toFixed(3)} (n=${agg.groundedness.n})` : ' n/a'}`
)
this.logger.info(
` mean answer length ${agg.meanAnswerLength === null ? 'n/a' : Math.round(agg.meanAnswerLength)} chars`
)
// Context budgeting. These explain *why* a correctness number moved: an
// answer the model never had the material for is a different failure from
// an answer it got wrong, and only these two lines tell them apart.
if (agg.meanPromptTokens !== null) {
this.logger.info(` mean prompt tokens ${Math.round(agg.meanPromptTokens)}`)
}
if (agg.historyElidedRate !== null) {
this.logger.info(
` history elided ${fmt(agg.historyElidedRate)} (share of cases needing a trim)`
)
}
if (agg.chunksDroppedRate !== null) {
this.logger.info(
` context dropped ${fmt(agg.chunksDroppedRate)} (share of cases where a chunk didn't fit)`
)
}
if (agg.promptTokenError) {
// Every budget decision rests on the estimate; if this drifts, the trims
// above are being made against a number that is no longer true.
this.logger.info(
` token estimate err ${(agg.promptTokenError.mean * 100).toFixed(1)}% (vs the backend's real count)`
)
}
if (agg.unstable > 0) {
this.logger.warning(
` ${agg.unstable} question(s) flipped between repeats — excluded from gating, do not read them as a regression.`
)
}
this.logger.info('')
}
/**
* The reason this command exists: turn three scores into an attribution.
*/
private printDecomposition(summaries: Array<{ mode: GenerationMode; agg: GenerationAggregate }>) {
const get = (mode: GenerationMode) => summaries.find((s) => s.mode === mode)?.agg.correctness ?? null
const oracle = get('oracle')
const e2e = get('e2e')
const none = get('noretrieval')
this.logger.info('=== Decomposition ===')
if (oracle !== null && e2e !== null) {
const cost = oracle - e2e
this.logger.info(` retrieval cost ${cost.toFixed(3)} (oracle ${oracle.toFixed(3)} - e2e ${e2e.toFixed(3)})`)
this.logger.info(` model ceiling ${oracle.toFixed(3)} (what this model manages with perfect context)`)
}
if (e2e !== null && none !== null) {
this.logger.info(` RAG contribution ${(e2e - none).toFixed(3)} (e2e ${e2e.toFixed(3)} - noretrieval ${none.toFixed(3)})`)
}
if (oracle !== null && e2e !== null) {
// The actual triage rule, stated so nobody has to re-derive it.
const cost = oracle - e2e
if (oracle < 0.6) {
this.logger.warning(
' Oracle is low: this model struggles even with perfect context. Retrieval work will not fix it — try a larger model.'
)
} else if (cost > 0.15) {
this.logger.warning(
' Large retrieval cost: the model can use good context but is not being given it. Work on retrieval.'
)
} else {
this.logger.success(' Model and retrieval are both holding up on this corpus.')
}
}
if (none !== null && e2e !== null && e2e <= none) {
this.logger.warning(
' RAG is not adding anything over the bare model on this set — check that retrieval is actually reaching the prompt.'
)
}
}
private printFailures(cases: Array<any>) {
const failures = cases.filter((c) => c.correctness.passRate < 1 || c.refusalCorrectness.passRate < 1)
if (failures.length === 0) return
this.logger.info('--- failures ---')
for (const f of failures) {
this.logger.info(` ${f.id} correct=${f.correctness.passes}/${f.correctness.repeats} refusal=${f.refusalCorrectness.passes}/${f.refusalCorrectness.repeats}`)
const score = f.scores[0]
if (score?.missedRequired?.length) {
this.logger.info(` missed: ${score.missedRequired.join(' | ')}`)
}
if (score?.hitForbidden?.length) {
this.logger.info(` said forbidden: ${score.hitForbidden.join(' | ')}`)
}
if (score?.leakage?.length) {
this.logger.info(` narrated retrieval`)
}
if (f.answers[0]) {
const preview = f.answers[0].replace(/\s+/g, ' ').slice(0, 220)
this.logger.info(` said: ${preview}${f.answers[0].length > 220 ? '…' : ''}`)
}
}
this.logger.info('')
}
}
const fmt = (v: number | null) => (v === null ? ' n/a' : v.toFixed(3))