mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-10-17 05:14:19 +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.
265 lines
8.8 KiB
TypeScript
265 lines
8.8 KiB
TypeScript
/**
|
|
* Report shape, baseline diffing, and the gate decision.
|
|
*
|
|
* Pure functions over plain objects — the service layer does the file I/O. The
|
|
* comparison logic in particular has to be testable without a filesystem,
|
|
* because it is the piece that decides whether a pull request is blocked.
|
|
*/
|
|
|
|
export type ReportKind = 'retrieval' | 'generation'
|
|
|
|
/**
|
|
* Provenance. Every field here exists to answer "is this report comparable to
|
|
* that one?", and `corpusFingerprint` is the one that can veto the comparison
|
|
* outright.
|
|
*/
|
|
export type ReportMeta = {
|
|
kind: ReportKind
|
|
createdAt: string
|
|
corpusFingerprint: string
|
|
gitSha: string | null
|
|
gitBranch: string | null
|
|
gitDirty: boolean
|
|
nomadVersion: string | null
|
|
/** Host platform. Recorded for context; never used to justify a score. */
|
|
platform: {
|
|
cpuArchitecture: string | null
|
|
osName: string | null
|
|
nodeVersion: string
|
|
}
|
|
params: Record<string, unknown>
|
|
}
|
|
|
|
/**
|
|
* A report is metadata plus a flat bag of named metrics plus the per-case
|
|
* detail. Flattening the metrics is deliberate: it means the diff logic does
|
|
* not need to know anything about retrieval versus generation, so adding a
|
|
* metric later requires no change to the comparison or the gate.
|
|
*/
|
|
export type EvalReport = {
|
|
meta: ReportMeta
|
|
/** metric name -> value. null means "not measurable in this run". */
|
|
metrics: Record<string, number | null>
|
|
/** tag -> metric name -> value. */
|
|
byTag: Record<string, Record<string, number | null>>
|
|
/** Per-question detail, for the "what did it actually say" question. */
|
|
cases: unknown[]
|
|
}
|
|
|
|
/**
|
|
* Whether a metric going up is good.
|
|
*
|
|
* Without this the diff cannot tell an improvement from a regression, and a
|
|
* leakage rate falling to zero would be reported as a failure.
|
|
*/
|
|
export type MetricDirection = 'higher-is-better' | 'lower-is-better'
|
|
|
|
export const METRIC_DIRECTIONS: Record<string, MetricDirection> = {
|
|
// retrieval
|
|
'recall@1': 'higher-is-better',
|
|
'recall@3': 'higher-is-better',
|
|
'recall@5': 'higher-is-better',
|
|
'recall@10': 'higher-is-better',
|
|
'hitRate@1': 'higher-is-better',
|
|
'hitRate@3': 'higher-is-better',
|
|
'hitRate@5': 'higher-is-better',
|
|
'hitRate@10': 'higher-is-better',
|
|
'precision@1': 'higher-is-better',
|
|
'precision@3': 'higher-is-better',
|
|
'precision@5': 'higher-is-better',
|
|
'precision@10': 'higher-is-better',
|
|
'ndcg@1': 'higher-is-better',
|
|
'ndcg@3': 'higher-is-better',
|
|
'ndcg@5': 'higher-is-better',
|
|
'ndcg@10': 'higher-is-better',
|
|
mrr: 'higher-is-better',
|
|
emptyRateOnAnswerable: 'lower-is-better',
|
|
nonEmptyRateOnRefusal: 'lower-is-better',
|
|
// generation
|
|
correctness: 'higher-is-better',
|
|
refusalCorrectness: 'higher-is-better',
|
|
leakageRate: 'lower-is-better',
|
|
thinkTagLeakRate: 'lower-is-better',
|
|
markdownRate: 'higher-is-better',
|
|
groundedness: 'higher-is-better',
|
|
// context budgeting
|
|
//
|
|
// These are diagnostics as much as gates. A rise in historyElidedRate means
|
|
// conversations are being trimmed more often, which explains a correctness
|
|
// drop that would otherwise look like the model getting worse. promptTokenError
|
|
// gates the token estimator itself: every budget decision rests on it, so a
|
|
// regression there quietly degrades everything downstream.
|
|
historyElidedRate: 'lower-is-better',
|
|
chunksDroppedRate: 'lower-is-better',
|
|
promptTokenError: 'lower-is-better',
|
|
}
|
|
|
|
export type MetricDelta = {
|
|
metric: string
|
|
baseline: number | null
|
|
current: number | null
|
|
delta: number | null
|
|
direction: MetricDirection
|
|
/** Signed so that positive always means "better", whatever the direction. */
|
|
improvement: number | null
|
|
regressed: boolean
|
|
/** Present in one report but not the other. */
|
|
onlyIn?: 'baseline' | 'current'
|
|
}
|
|
|
|
export type ComparisonResult = {
|
|
comparable: boolean
|
|
/** Why the comparison was refused, when it was. */
|
|
incomparableReason?: string
|
|
tolerance: number
|
|
deltas: MetricDelta[]
|
|
regressions: MetricDelta[]
|
|
improvements: MetricDelta[]
|
|
}
|
|
|
|
/**
|
|
* Default tolerance band.
|
|
*
|
|
* Not zero, on purpose. Even at temperature 0 the generation tier moves a
|
|
* little between runs, and a gate that fires on 0.001 gets switched off within
|
|
* a week. 0.02 is roughly "one question in fifty" on a 99-question set — small
|
|
* enough to catch a real regression, large enough to ignore a coin flip.
|
|
*/
|
|
export const DEFAULT_TOLERANCE = 0.02
|
|
|
|
/**
|
|
* Slack for floating-point representation error at the tolerance boundary.
|
|
*
|
|
* Without it, `0.9 - 0.02` evaluates to a delta of -0.020000000000000018, which
|
|
* is "greater than the tolerance" by 1.8e-17 and would block a pull request. A
|
|
* gate that fires on the last bit of a double is a gate people learn to ignore.
|
|
*/
|
|
const BOUNDARY_EPSILON = 1e-9
|
|
|
|
export function compareReports(
|
|
baseline: EvalReport,
|
|
current: EvalReport,
|
|
tolerance: number = DEFAULT_TOLERANCE
|
|
): ComparisonResult {
|
|
if (baseline.meta.kind !== current.meta.kind) {
|
|
return incomparable(
|
|
`cannot compare a ${baseline.meta.kind} report against a ${current.meta.kind} report`,
|
|
tolerance
|
|
)
|
|
}
|
|
|
|
// The hard veto. Different corpora, chunk sizes, or embedding models mean the
|
|
// two numbers were produced by different experiments, and diffing them would
|
|
// manufacture a regression (or hide one) out of nothing.
|
|
if (baseline.meta.corpusFingerprint !== current.meta.corpusFingerprint) {
|
|
return incomparable(
|
|
`corpus fingerprint changed (${baseline.meta.corpusFingerprint} -> ${current.meta.corpusFingerprint}). ` +
|
|
'The corpus, chunk size, or embedding model differs, so these runs are not measuring the same thing. Re-baseline instead.',
|
|
tolerance
|
|
)
|
|
}
|
|
|
|
const names = [...new Set([...Object.keys(baseline.metrics), ...Object.keys(current.metrics)])].sort()
|
|
const deltas: MetricDelta[] = names.map((metric) => {
|
|
const b = baseline.metrics[metric] ?? null
|
|
const c = current.metrics[metric] ?? null
|
|
const direction = METRIC_DIRECTIONS[metric] ?? 'higher-is-better'
|
|
const onlyIn =
|
|
!(metric in baseline.metrics) ? ('current' as const)
|
|
: !(metric in current.metrics) ? ('baseline' as const)
|
|
: undefined
|
|
|
|
if (b === null || c === null) {
|
|
return { metric, baseline: b, current: c, delta: null, direction, improvement: null, regressed: false, onlyIn }
|
|
}
|
|
|
|
const delta = c - b
|
|
const improvement = direction === 'higher-is-better' ? delta : -delta
|
|
return {
|
|
metric,
|
|
baseline: b,
|
|
current: c,
|
|
delta,
|
|
direction,
|
|
improvement,
|
|
regressed: improvement < -(tolerance + BOUNDARY_EPSILON),
|
|
onlyIn,
|
|
}
|
|
})
|
|
|
|
return {
|
|
comparable: true,
|
|
tolerance,
|
|
deltas,
|
|
regressions: deltas.filter((d) => d.regressed),
|
|
improvements: deltas.filter(
|
|
(d) => d.improvement !== null && d.improvement > tolerance + BOUNDARY_EPSILON
|
|
),
|
|
}
|
|
}
|
|
|
|
function incomparable(reason: string, tolerance: number): ComparisonResult {
|
|
return {
|
|
comparable: false,
|
|
incomparableReason: reason,
|
|
tolerance,
|
|
deltas: [],
|
|
regressions: [],
|
|
improvements: [],
|
|
}
|
|
}
|
|
|
|
/** Flatten a `Record<number, number|null>` into `name@k` keys. */
|
|
export function flattenByK(
|
|
prefix: string,
|
|
values: Record<number, number | null>
|
|
): Record<string, number | null> {
|
|
return Object.fromEntries(Object.entries(values).map(([k, v]) => [`${prefix}@${k}`, v]))
|
|
}
|
|
|
|
const pct = (v: number | null) => (v === null ? 'n/a' : v.toFixed(4))
|
|
|
|
/** Human-readable summary, worst regressions first. */
|
|
export function renderComparisonMarkdown(
|
|
baselinePath: string,
|
|
currentPath: string,
|
|
result: ComparisonResult
|
|
): string {
|
|
const lines: string[] = ['# Eval comparison', '']
|
|
lines.push(`- baseline: \`${baselinePath}\``)
|
|
lines.push(`- current: \`${currentPath}\``)
|
|
lines.push(`- tolerance: ${result.tolerance}`)
|
|
lines.push('')
|
|
|
|
if (!result.comparable) {
|
|
lines.push('## Not comparable', '', result.incomparableReason ?? 'unknown reason', '')
|
|
return lines.join('\n')
|
|
}
|
|
|
|
lines.push(
|
|
result.regressions.length > 0
|
|
? `## ${result.regressions.length} regression(s)`
|
|
: '## No regressions'
|
|
)
|
|
lines.push('')
|
|
lines.push('| metric | baseline | current | delta | verdict |')
|
|
lines.push('|---|---:|---:|---:|---|')
|
|
|
|
// Worst first — the thing a developer needs is at the top, not sorted
|
|
// alphabetically halfway down a wall of unchanged rows.
|
|
const ordered = [...result.deltas].sort((a, b) => (a.improvement ?? 0) - (b.improvement ?? 0))
|
|
for (const d of ordered) {
|
|
const verdict =
|
|
d.onlyIn === 'current' ? 'new'
|
|
: d.onlyIn === 'baseline' ? 'removed'
|
|
: d.delta === null ? '—'
|
|
: d.regressed ? '**REGRESSED**'
|
|
: (d.improvement ?? 0) > result.tolerance ? 'improved'
|
|
: 'within tolerance'
|
|
const arrow = d.delta === null ? 'n/a' : `${d.delta >= 0 ? '+' : ''}${d.delta.toFixed(4)}`
|
|
lines.push(`| ${d.metric} | ${pct(d.baseline)} | ${pct(d.current)} | ${arrow} | ${verdict} |`)
|
|
}
|
|
lines.push('')
|
|
return lines.join('\n')
|
|
}
|