project-nomad/admin/app/utils/sampler.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

134 lines
5.7 KiB
TypeScript

/**
* Pure helpers for deciding which sampler settings a chat request carries.
*
* Kept out of OllamaService for the same reason `context_window.ts` is: every
* interesting case here is a bad stored value or a model that declares its own
* parameters, and none of them need Ollama, Docker or MySQL to exercise. The
* service supplies the facts (what the stored setting says, what /api/show
* returned); everything here is a function of its arguments.
*/
import { DEFAULT_RESPONSE_STYLE, SAMPLER_PRESETS } from '../../constants/ollama.js'
import type { ResponseStyle, SamplerProfile } from '../../types/ollama.js'
const RESPONSE_STYLES: ResponseStyle[] = ['auto', 'focused', 'creative', 'off']
/**
* Interpret a stored `ai.responseStyle` value.
*
* Unset, empty and unrecognised all mean the default. A corrupt row must not
* silently drop chat back to unsampled backend defaults, because that is the
* failure this setting exists to fix and it would be invisible from the UI.
* `off` is only ever reached by someone choosing it.
*/
export function parseResponseStyle(raw: string | null | undefined): ResponseStyle {
if (raw === null || raw === undefined) return DEFAULT_RESPONSE_STYLE
const trimmed = String(raw).trim().toLowerCase()
if (!trimmed) return DEFAULT_RESPONSE_STYLE
return (RESPONSE_STYLES as string[]).includes(trimmed)
? (trimmed as ResponseStyle)
: DEFAULT_RESPONSE_STYLE
}
/**
* Pull sampler values out of the `parameters` string /api/show returns (the
* modelfile PARAMETER lines, e.g. `temperature 0.6`).
*
* Sibling of `readModelfileNumCtx`, and there for a sharper reason than it: a
* good number of Ollama library models ship author-tuned samplers (qwen3 sets
* 0.6/0.95/20, deepseek-r1 sets 0.6), and stamping a generic house profile over
* those would make exactly the models someone tuned carefully worse. Under the
* `auto` style these values win, key by key.
*
* Repeated PARAMETER lines are legal in a modelfile and the last one wins in
* Ollama, so the scan does not stop at the first match.
*/
export function readModelfileSamplers(parameters: unknown): SamplerProfile {
const profile: SamplerProfile = {}
if (typeof parameters !== 'string') return profile
const keys: [RegExp, keyof SamplerProfile][] = [
[/^\s*temperature\s+(-?[\d.]+)\s*$/gm, 'temperature'],
[/^\s*top_p\s+(-?[\d.]+)\s*$/gm, 'topP'],
[/^\s*top_k\s+(-?[\d.]+)\s*$/gm, 'topK'],
[/^\s*min_p\s+(-?[\d.]+)\s*$/gm, 'minP'],
[/^\s*repeat_penalty\s+(-?[\d.]+)\s*$/gm, 'repeatPenalty'],
]
for (const [pattern, field] of keys) {
let match: RegExpExecArray | null
while ((match = pattern.exec(parameters)) !== null) {
const value = Number.parseFloat(match[1])
if (Number.isFinite(value) && value >= 0) profile[field] = value
}
}
return profile
}
/**
* The sampler settings to send for one request.
*
* `off` sends nothing. `auto` is NOMAD's baseline with the model author's own
* values layered over it, which in practice means the model keeps its tuned
* temperature and top_p and gains the min_p it was missing. `focused` and
* `creative` are explicit user choices and replace the model's values outright,
* because a style the user picked by name that quietly did nothing on half
* their models would be worse than not offering it.
*
* A model that declares its own `top_p` under `auto` also gets it on the compat
* transport: an author-set nucleus is a better substitute for the missing min_p
* than the preset's generic fallback.
*/
export function resolveSamplerProfile(
style: ResponseStyle,
modelfileSamplers?: SamplerProfile
): SamplerProfile | undefined {
if (style === 'off') return undefined
const preset = SAMPLER_PRESETS[style]
if (style !== 'auto') return { ...preset }
const declared = modelfileSamplers ?? {}
const merged: SamplerProfile = { ...preset, ...declared }
if (declared.topP !== undefined) merged.compatTopP = declared.topP
return merged
}
/**
* Map a profile onto Ollama's native `options` keys.
*
* `temperature` is deliberately absent: it is carried on ChatInput itself, so an
* explicit caller value (eval runs and the grammar-constrained task calls, both
* of which pin it to 0) keeps beating whatever style the user has chosen.
*/
export function nativeSamplerOptions(profile: SamplerProfile | undefined): Record<string, number> {
if (!profile) return {}
const options: Record<string, number> = {}
if (profile.topP !== undefined) options.top_p = profile.topP
if (profile.topK !== undefined) options.top_k = profile.topK
if (profile.minP !== undefined) options.min_p = profile.minP
if (profile.repeatPenalty !== undefined) options.repeat_penalty = profile.repeatPenalty
return options
}
/**
* Map a profile onto the OpenAI-compatible endpoint's params.
*
* Only `top_p` survives the trip. min_p, top_k and repeat_penalty have no
* equivalent there, and presence/frequency_penalty are not stand-ins for
* repeat_penalty — they accumulate over the whole completion rather than a
* 64-token window, so borrowing one to fake the other would punish a grounded
* answer for citing the same identifier twice.
*
* `compatTopP` rather than `topP` is the value sent, for the reason documented
* on the type: the presets set `topP: 1.0` and truncate with min_p, and passing
* that through unchanged to a backend that drops min_p would leave the raw
* distribution untruncated, which is worse than the default it replaced.
*/
export function compatSamplerParams(profile: SamplerProfile | undefined): Record<string, number> {
if (!profile) return {}
const params: Record<string, number> = {}
const topP = profile.compatTopP ?? profile.topP
if (topP !== undefined) params.top_p = topP
return params
}