gglib_core/domain/inference.rs
1//! Inference configuration types.
2//!
3//! Defines shared types for configuring LLM inference parameters
4//! (temperature, `top_p`, `top_k`, `max_tokens`, `repeat_penalty`,
5//! `presence_penalty`, `min_p`).
6//!
7//! **Tier B — Policy** ([ADR 0001]) for the hierarchy: the ordered fold,
8//! profiles, the user-set versus auto-detected split and the class floors are
9//! decisions llama-server is structurally not in a position to make, so
10//! nothing here gates on [`RuntimeCapabilities`].
11//!
12//! [`with_hardcoded_defaults`](InferenceConfig::with_hardcoded_defaults) is
13//! the exception, and it is the same shape as ADR 0001's `truncation` caveat:
14//! the *policy* of having a floor is gglib's, but a floor *value* that equals
15//! llama.cpp's own default is a redundant assertion rather than a decision.
16//! Six of the seven were measured to be exactly that. [ADR 0003] decides they
17//! are deferred, leaving `temperature` — the one genuine divergence, 0.7
18//! against upstream's 0.8 — plus
19//! [`reasoning_floor`](InferenceConfig::reasoning_floor)'s class-aware
20//! overrides.
21//!
22//! [ADR 0001]: https://github.com/mmogr/gglib/blob/main/docs/adr/0001-runtime-capability-tiers.md
23//! [ADR 0003]: https://github.com/mmogr/gglib/blob/main/docs/adr/0003-defer-sampler-defaults-to-llama-cpp.md
24//! [`RuntimeCapabilities`]: crate::domain::RuntimeCapabilities
25//!
26//! This module provides the core `InferenceConfig` type that is reused across:
27//! - Per-model defaults (`Model.inference_defaults`)
28//! - Global settings (`Settings.inference_defaults`)
29//! - Request-level overrides (flattened in `ChatProxyRequest`)
30//! - `gglib proxy` — per-request injection into OpenAI-format request bodies
31//! - `gglib chat` / `gglib q` — hierarchy resolution for the agentic loop
32//!
33//! All surfaces resolve inference parameters through
34//! [`InferenceConfig::resolve_with_profile`], which is the single source of
35//! truth for the hierarchy. [`InferenceConfig::resolve_with_defaults`] is the
36//! same resolution with no profile selected, for surfaces that have no notion
37//! of one.
38
39use std::fmt;
40
41use serde::{Deserialize, Serialize};
42
43use crate::domain::reasoning_effort::ReasoningEffort;
44use crate::domain::sampling_provenance::{FieldSources, ParamSource};
45
46/// Inference parameters for LLM sampling.
47///
48/// All fields are optional to support partial configuration and fallback chains.
49/// Intended to be shared across model defaults, global settings, and request overrides.
50///
51/// # Hierarchy Resolution
52///
53/// When making an inference request, parameters are resolved in this order:
54/// 1. Request-level override (user specified for this request)
55/// 2. Selected profile (`Settings.inference_profiles`, chosen as
56/// `{model}:{profile}`; absent on surfaces without profiles)
57/// 3. Per-model defaults (stored in `Model.inference_defaults`)
58/// 4. Global settings (stored in `Settings.inference_defaults`)
59/// 5. Hardcoded fallback (e.g., temperature = 0.7)
60///
61/// # Examples
62///
63/// ```rust
64/// use gglib_core::domain::InferenceConfig;
65///
66/// // Conservative settings for code generation
67/// let code_gen = InferenceConfig {
68/// temperature: Some(0.2),
69/// top_p: Some(0.9),
70/// top_k: Some(40),
71/// max_tokens: Some(2048),
72/// repeat_penalty: Some(1.1),
73/// ..Default::default()
74/// };
75///
76/// // Creative writing settings
77/// let creative = InferenceConfig {
78/// temperature: Some(1.2),
79/// top_p: Some(0.95),
80/// ..Default::default()
81/// };
82/// ```
83#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
84#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS), ts(export))]
85#[serde(rename_all = "camelCase")]
86pub struct InferenceConfig {
87 /// Sampling temperature (0.0 - 2.0).
88 ///
89 /// Controls randomness in token selection:
90 /// - Lower values (0.1-0.5): More deterministic, focused
91 /// - Medium values (0.7-1.0): Balanced creativity
92 /// - Higher values (1.1-2.0): More random, creative
93 pub temperature: Option<f32>,
94
95 /// Nucleus sampling threshold (0.0 - 1.0).
96 ///
97 /// Considers only the top tokens whose cumulative probability exceeds this threshold.
98 /// Common values: 0.9 (default), 0.95 (more diverse)
99 pub top_p: Option<f32>,
100
101 /// Top-K sampling limit.
102 ///
103 /// Considers only the K most likely next tokens.
104 /// Common values: 40 (default), 10 (focused), 100 (diverse)
105 pub top_k: Option<i32>,
106
107 /// Maximum tokens to generate in response.
108 ///
109 /// Hard limit on response length. Does not include input tokens.
110 pub max_tokens: Option<u32>,
111
112 /// Repetition penalty (> 0.0, typically 1.0 - 1.3).
113 ///
114 /// Penalizes repeated tokens to reduce repetitive output.
115 /// - 1.0: No penalty (default)
116 /// - 1.1-1.3: Moderate penalty
117 /// - > 1.3: Strong penalty (may hurt coherence)
118 pub repeat_penalty: Option<f32>,
119
120 /// Presence penalty (0.0 - 2.0).
121 ///
122 /// Penalizes tokens that have already appeared in the output, encouraging
123 /// the model to cover new ground. Effective at preventing repetitive
124 /// reasoning loops in thinking models.
125 /// - 0.0: No penalty (default; disabled)
126 /// - 1.5: Recommended for reasoning/thinking models (e.g. `Qwen3.6`, `DeepSeek-R1`)
127 /// - > 2.0: Avoid; may degrade coherence
128 pub presence_penalty: Option<f32>,
129
130 /// Frequency penalty (-2.0 - 2.0), `presence_penalty`'s twin.
131 ///
132 /// Penalizes tokens in proportion to how *often* they have already
133 /// appeared, where `presence_penalty` is a flat once-seen offset. An
134 /// OpenAI-standard field that llama.cpp supports; until it was modelled
135 /// here it passed through the proxy ungoverned, so an untrusted client
136 /// could steer sampling with it while every modelled twin was gated
137 /// (ADR 0003's `frequency_penalty` follow-up).
138 /// - 0.0: No penalty (llama.cpp's default)
139 /// - Negative values *encourage* reuse; valid upstream, rarely wanted
140 ///
141 /// Unset defers to llama.cpp's own default (0.0, disabled).
142 ///
143 /// Deliberately **not** part of the temperature-coupled trio, although
144 /// the trio's rationale (a flat logit offset competing with temperature's
145 /// sharpening) applies to it literally. The cost that evicted DRY from
146 /// the coupled set (#746) applies literally too: coupling would make a
147 /// layer naming only a `frequency_penalty` lose it to any lower layer
148 /// naming a `temperature` — the default state of every `reasoning`-tagged
149 /// model — and no shipped profile pairs a frequency penalty with a
150 /// temperature, so coupling would protect nothing in exchange. The trio
151 /// stays a closed set; joining it needs sweep data, not symmetry.
152 pub frequency_penalty: Option<f32>,
153
154 /// Minimum-probability sampling threshold (0.0 - 1.0).
155 ///
156 /// Removes tokens whose probability is below `min_p × P(top token)`.
157 /// - 0.0: Disabled (explicit off; recommended by Qwen3.6, and the floor
158 /// for `reasoning`-tagged models — see [`reasoning_floor`])
159 /// - 0.05: llama.cpp's own default, and the neutral floor here — see
160 /// [`with_hardcoded_defaults`]
161 ///
162 /// [`reasoning_floor`]: Self::reasoning_floor
163 /// [`with_hardcoded_defaults`]: Self::with_hardcoded_defaults
164 pub min_p: Option<f32>,
165
166 /// Dynamic-temperature half-range — entropy-adaptive temperature.
167 ///
168 /// llama.cpp scales the effective temperature within
169 /// `[temperature − range, temperature + range]` by the entropy of each
170 /// step's token distribution: confident (low-entropy) steps decode
171 /// cooler, uncertain (high-entropy) steps decode hotter. On a completion
172 /// that mixes free-form reasoning with structured tool-call tokens this
173 /// is a per-token soft version of phase-aware sampling, with no phase
174 /// detection required.
175 /// - 0.0: Disabled (llama.cpp's default)
176 /// - 0.3–0.75: Exploratory range around a 0.6–1.0 base temperature
177 ///
178 /// Unset defers to llama.cpp's own default (0.0, disabled).
179 ///
180 /// Deliberately **not** part of the temperature-coupled trio, for the
181 /// reason DRY is not (#746): coupling would make a layer naming only a
182 /// `dynatemp_range` lose it to any lower layer naming a `temperature` —
183 /// the default state of every `reasoning`-tagged model — which costs the
184 /// most natural way of switching it on. Unlike the trio, its meaning is
185 /// anchored to whatever base temperature actually resolves, so an
186 /// orphaned range stays coherent.
187 pub dynatemp_range: Option<f32>,
188
189 /// Dynamic-temperature exponent, shaping how sharply the effective
190 /// temperature responds to entropy. Inert while
191 /// [`dynatemp_range`](Self::dynatemp_range) is unset or zero.
192 /// Unset defers to llama.cpp's own default (1.0).
193 pub dynatemp_exponent: Option<f32>,
194
195 /// Top-n-sigma: keep only tokens whose *pre-softmax* logit is within
196 /// `n × σ` of the maximum (arXiv 2411.07641). Because it truncates the
197 /// unscaled logits, the candidate set does not widen as temperature
198 /// rises — the property that makes it a candidate for keeping tool-call
199 /// tokens stable while a reasoning model decodes hot.
200 /// - ≤ 0.0: Disabled (llama.cpp's default is −1.0)
201 /// - 1.0–2.0: The paper's evaluated range
202 ///
203 /// Unset defers to llama.cpp's own default (−1.0, disabled).
204 pub top_n_sigma: Option<f32>,
205
206 /// DRY (Don't Repeat Yourself) penalty strength.
207 ///
208 /// Penalises tokens that would extend a sequence already present in the
209 /// context, which catches the multi-token degenerate loops that
210 /// `repeat_penalty` — a flat per-token penalty — cannot see.
211 /// - 0.0: Disabled (llama.cpp's default, and the floor here)
212 /// - 0.8: A common starting point for long agentic sessions
213 ///
214 /// Left alone on agentic turns, deliberately. An earlier version forced
215 /// this to `0` whenever a request carried tools, reasoning that structured
216 /// output legitimately repeats tokens. Both halves were wrong: llama.cpp's
217 /// sequence breakers already default to `\n`, `:`, `"`, `*` — two of which
218 /// are pervasive in JSON — and agentic clients send `tools` on *every*
219 /// request, so the pin would have disabled DRY for whole sessions, which
220 /// is the workload it exists for.
221 ///
222 /// llama.cpp's fifth DRY parameter, `--dry-sequence-breaker`, is not
223 /// modelled: it is a list of strings, and every layer of this hierarchy —
224 /// merge, coupling, the CLI flags, the settings mirror — is built for
225 /// scalars. It is also the right lever if DRY is ever seen mangling a tool
226 /// call, so modelling it is the follow-up, not switching DRY off.
227 pub dry_multiplier: Option<f32>,
228
229 /// DRY penalty base, the exponent applied per token of matched sequence
230 /// length. Higher grows the penalty faster on longer repeats.
231 /// Unset defers to llama.cpp's own default (1.75).
232 pub dry_base: Option<f32>,
233
234 /// Sequence length, in tokens, that DRY tolerates before penalising.
235 /// Unset defers to llama.cpp's own default (2).
236 pub dry_allowed_length: Option<i32>,
237
238 /// How far back DRY scans for repeats, in tokens. `0` disables the
239 /// penalty; llama.cpp resolves negative values against the context size.
240 /// Unset defers to llama.cpp's own default (64).
241 pub dry_penalty_last_n: Option<i32>,
242
243 /// RNG seed for the sampler.
244 ///
245 /// Unset means llama.cpp draws a fresh random seed per request, which is
246 /// what it reports as `4294967295` (`u32::MAX`) in `/slots`.
247 ///
248 /// # Request-scoped by design, like `max_tokens`
249 ///
250 /// This is the second field in this struct that is **not a sampling
251 /// policy**. Every other one answers "how should this model sample?" and is
252 /// worth storing per model; a seed answers "make *this run* reproducible",
253 /// and a seed stored per model would pin every response that model ever
254 /// produces to the same text. So:
255 ///
256 /// - no floor names it, on either class floor;
257 /// - nothing gglib writes at import names it;
258 /// - the CLI, profile and settings surfaces do not expose it.
259 ///
260 /// It lives here anyway, rather than beside the hierarchy, for the reason
261 /// `max_tokens` does: this struct is the single thing that becomes the
262 /// request body ([`Self::to_openai_json_patch`]), and a value that reaches
263 /// the wire any other way is a value the ladder cannot explain and the
264 /// [readback] cannot check. llama.cpp reports the applied seed in
265 /// `/slots`, so routing it through here is what makes "did my seed actually
266 /// land?" an answerable question rather than an assumption — which is the
267 /// whole point of seeding a benchmark.
268 ///
269 /// [readback]: https://github.com/mmogr/gglib/blob/main/crates/gglib-proxy/src/sampling_audit.rs
270 pub seed: Option<u32>,
271
272 /// How hard the model is *asked* to think — a **prompt-shaping template
273 /// control**, not a sampler.
274 ///
275 /// Every other field in this struct configures llama.cpp's sampler chain.
276 /// This one does not touch it. It is parsed off the top-level `OpenAI`
277 /// body, stored as a Jinja kwarg and handed to the chat template, which
278 /// may render it into the prompt, may branch on it, or — on most models —
279 /// may never read the variable at all. It changes what the model is shown,
280 /// not how its logits are cut.
281 ///
282 /// # The wire, measured
283 ///
284 /// Against the pinned build ([ADR 0007] finding 7c):
285 ///
286 /// | sent | llama-server | why it matters here |
287 /// |---|---|---|
288 /// | `"high"` | 200, kwarg set | the ordinary case |
289 /// | `"banana"` | 200, **rendered into the prompt verbatim** | nothing upstream validates this field |
290 /// | `42` | 200, kwarg dropped, template default applies | a wrong type degrades silently |
291 /// | `""` | 200, ignored | already means "no opinion" |
292 /// | `"none"` | 200, kwarg **erased** | on `gpt-oss` the template's own `medium` fallback then fires |
293 ///
294 /// # An enum makes gglib stricter than upstream, deliberately
295 ///
296 /// [`extract_client_sampling`]'s doctrine is to "accept what upstream
297 /// accepts and reject what upstream rejects, so gglib never becomes the
298 /// stricter of the two". A closed [`ReasoningEffort`] plainly violates it:
299 /// llama-server takes `"banana"` and gglib will not.
300 ///
301 /// The doctrine's premise is that upstream is the authority on what a
302 /// field means, so disagreeing with it can only cost a value that would
303 /// have worked. That premise is false for exactly this field. ADR 0007
304 /// finding 7c measured the asymmetry: upstream **governs the budget** —
305 /// `reasoning_budget_tokens: -2` is a clean HTTP 400 naming the range —
306 /// and **does not govern effort at all**. There is no allowlist, no type
307 /// check, and no check that the loaded template reads the variable. Where
308 /// upstream has no opinion, gglib's governance is not the *stricter* of
309 /// two; it is the only one there is.
310 ///
311 /// And the failure mode is not a rejected-but-valid value. `"banana"` does
312 /// not fail, it renders: the user's own prompt gains a line reading
313 /// `Reasoning: banana`, and the model answers as if a person had typed it.
314 /// Passing that through is not accepting what upstream accepts, it is
315 /// forwarding a typo into a prompt. A rejected field costs the client its
316 /// `reasoning_effort` and is reported by name
317 /// ([`FieldIssue::Rejected`]); a forwarded typo costs the answer and is
318 /// reported nowhere.
319 ///
320 /// That trade only exists if the rejection also *removes the key*, and it
321 /// is this field that forces the point. Every other reader here rejects
322 /// only what upstream would have rejected too, so a forwarded reject came
323 /// back as an HTTP 400 and the damage was visible; and floored fields are
324 /// overwritten by the resolved patch anyway. This field is neither — no
325 /// layer or floor ever names it (`no_floor_names_a_reasoning_control`) and
326 /// upstream validates nothing — so a rejection that stopped at the layer
327 /// would be a report with no effect, and `"banana"` would reach the
328 /// prompt with gglib's own `client_fields_rejected` recording that it had
329 /// been stopped. See the body cleanup in `request_pipeline::sampling`, and
330 /// `a_rejected_effort_level_never_reaches_the_wire` for the pin.
331 ///
332 /// The narrower rule the doctrine actually encodes still holds and is
333 /// obeyed by this field's twin: see
334 /// [`reasoning_budget_tokens`](Self::reasoning_budget_tokens), which
335 /// accepts upstream's full `-1..=i32::MAX` and rejects precisely what
336 /// upstream 400s on.
337 ///
338 /// # Permanently Blind
339 ///
340 /// Neither this nor the budget appears in `/slots` or `/props`;
341 /// `task_params::to_json` exports no reasoning field in either branch. [ADR
342 /// 0004]'s readback can confirm a `top_k` arrived and can never confirm
343 /// this did. The provenance record is the whole account — which is why the
344 /// field is modelled here rather than left to ride the body ungoverned
345 /// (ADR 0007 finding 6, the #779 shape under a new name).
346 ///
347 /// # No floor names one
348 ///
349 /// See [`with_hardcoded_defaults`]. Several templates default themselves
350 /// (`gpt-oss` to `medium`); a floor here would override each template's own
351 /// choice with a value nobody made.
352 ///
353 /// [ADR 0004]: https://github.com/mmogr/gglib/blob/main/docs/adr/0004-observe-the-sampling-boundary.md
354 /// [ADR 0007]: https://github.com/mmogr/gglib/blob/main/docs/adr/0007-ask-the-server-for-template-capabilities.md
355 /// [`extract_client_sampling`]: Self::extract_client_sampling
356 /// [`with_hardcoded_defaults`]: Self::with_hardcoded_defaults
357 pub reasoning_effort: Option<ReasoningEffort>,
358
359 /// How many tokens of thinking the model is allowed before it is cut off —
360 /// a **budget**, not a sampler and not a taste.
361 ///
362 /// It says what this request *is* (a turn that may spend at most `n`
363 /// tokens reasoning), the same category `max_tokens` occupies, and it is
364 /// enforced by llama.cpp itself (`common/reasoning-budget.{h,cpp}`) rather
365 /// than by a template that may or may not read a variable. That is what
366 /// puts it on the client-authoritative side of the trust gate while its
367 /// twin [`reasoning_effort`](Self::reasoning_effort) is gated — see
368 /// [`crate::request_pipeline::sampling`], which states the split where the
369 /// carve-out is coded.
370 ///
371 /// # The wire, measured — and the range is upstream's, not gglib's
372 ///
373 /// Range-validated on the pinned build: `-1 <= v <= 2147483647`, and `-2`
374 /// comes back as a clean HTTP 400 naming the range ([ADR 0007]
375 /// finding 7c). So the full `-1..=i32::MAX` is accepted here and only
376 /// values below `-1` are rejected — gglib matches upstream's own 400
377 /// exactly and adds nothing. This is the doctrine at
378 /// [`extract_client_sampling`] applied straight, and it is the reason the
379 /// departure argued on `reasoning_effort` is a departure about *that*
380 /// field rather than about reasoning controls in general.
381 ///
382 /// - `-1` — defer to the launch-time `--reasoning-budget` default.
383 /// - `0` — valid, and the honest spelling of "stop thinking immediately".
384 /// It is what gglib offers instead of `reasoning_effort: "none"`, which
385 /// yields *medium* thinking on `gpt-oss` (ADR 0007 decision 4).
386 ///
387 /// A value gglib refuses (below `-1`) is dropped from the layer and
388 /// **left in the forwarded body**, unlike its twin: upstream's own 400
389 /// names this field and its range, which is a better answer to the client
390 /// than a silent substitution and keeps gglib no stricter than upstream.
391 /// See [`FieldIssue::Rejected`].
392 ///
393 /// # The alias
394 ///
395 /// Upstream also accepts `thinking_budget_tokens`
396 /// ([`THINKING_BUDGET_TOKENS_KEY`]) as a second spelling of this
397 /// parameter. gglib reads it — a name it did not read was a name the trust
398 /// gate could not govern — and emits the canonical one only, erasing the
399 /// alias from every forwarded body so the two spellings cannot disagree
400 /// about the resolved value. With both sent, the canonical key wins. See
401 /// [`read_reasoning_budget_tokens`].
402 ///
403 /// # Permanently Blind, like its twin
404 ///
405 /// It is parsed into `params.sampling`, but `task_params::to_json`
406 /// serialises no `reasoning_budget_*` field, so nothing echoes it in
407 /// `/slots` or `/props` either. ADR 0007's finding 7a is a correction to
408 /// that ADR's own earlier claim; do not re-derive an echo from the request
409 /// *parse* table.
410 ///
411 /// # No floor names one
412 ///
413 /// See [`with_hardcoded_defaults`]. A fleet-wide thinking budget is a
414 /// tuning decision with measurement behind it, and `-1` — the "defer"
415 /// sentinel — is exactly what omitting the key already means.
416 ///
417 /// [ADR 0007]: https://github.com/mmogr/gglib/blob/main/docs/adr/0007-ask-the-server-for-template-capabilities.md
418 /// [`extract_client_sampling`]: Self::extract_client_sampling
419 /// [`with_hardcoded_defaults`]: Self::with_hardcoded_defaults
420 pub reasoning_budget_tokens: Option<i32>,
421}
422
423/// Whether a model's stored `inference_defaults` were set by the user or
424/// written automatically at import time.
425///
426/// `Model.inference_defaults` is populated two ways: a user explicitly
427/// tunes it (`gglib model update --presence-penalty …`, or the `WebUI` edit
428/// form), or [`crate::services`]'s import path auto-writes
429/// [`InferenceConfig::reasoning_profile`] onto any model carrying the
430/// `reasoning` tag — a reasonable guess, not a user decision. Both end up in
431/// the same column with nothing distinguishing them, which meant an
432/// auto-written guess silently outranked the user's own global settings in
433/// the resolution ladder ([`InferenceConfig::resolve_with_profile`]) exactly
434/// as if the user had tuned it themselves.
435///
436/// This type tracks which one actually happened, so resolution can rank
437/// [`AutoDetected`](Self::AutoDetected) below global settings while a real
438/// [`User`](Self::User) choice keeps outranking them.
439#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
440#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS), ts(export))]
441#[serde(rename_all = "snake_case")]
442pub enum DefaultsOrigin {
443 /// Set explicitly by the user — a CLI flag, a model-update request, or a
444 /// `WebUI` edit. Outranks global settings, same as before this type
445 /// existed.
446 User,
447 /// Written automatically at import time from the model's `reasoning`
448 /// tag, never reviewed by a person. Ranks below global settings.
449 AutoDetected,
450 /// Read at import time from the model author's own
451 /// `generation_config.json` on `HuggingFace`.
452 ///
453 /// Ranks **exactly where [`Self::AutoDetected`] does** — below global
454 /// settings — and is a distinct variant because of what it is, not where
455 /// it sits. Both are written without a person reviewing them, so neither
456 /// may outrank a setting somebody chose.
457 ///
458 /// It never *coexists* with `AutoDetected`: the import prefers this when
459 /// it can fetch one, because a published recipe is evidence about this
460 /// model where [`InferenceConfig::reasoning_profile`] is a generic guess
461 /// keyed off a tag. So "above generic tag guesses" holds by replacement
462 /// rather than by rank, and no new ladder rung is needed to express it.
463 ///
464 /// Kept apart from `User` for the reason that distinction exists at all:
465 /// this is not a value anybody in this installation decided, so the
466 /// agentic-turn ceiling may still cap it and global settings still win.
467 Published,
468 /// Written by a tune sweep: the winner of a measured comparison on this
469 /// model, this quant, this hardware — not a person's choice, and not a
470 /// guess either.
471 ///
472 /// Ranks **exactly where [`Self::AutoDetected`] does** — below global
473 /// settings. The principle that puts it there is the ladder's oldest one:
474 /// nothing a person chose may be outranked by anything a person did not,
475 /// and an automated apply is not a person. Like [`Self::Published`], it
476 /// never coexists with the other below-global origins: applying a winner
477 /// overwrites `inference_defaults`, so "above the guesses" holds by
478 /// replacement rather than by rank.
479 ///
480 /// Unlike both of them, the **agentic-turn ceiling never caps it**. The
481 /// sweep resolves its candidates against this model's real context
482 /// (#748) precisely so the winner transfers to production; a ceiling
483 /// capping the stored winner would un-measure it on exactly the turns it
484 /// was measured for. The ceiling exists to bound values nobody examined —
485 /// a measured temperature is the most examined value in the system.
486 Measured,
487}
488
489impl std::fmt::Display for DefaultsOrigin {
490 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
491 match self {
492 Self::User => write!(f, "user"),
493 Self::AutoDetected => write!(f, "auto_detected"),
494 Self::Published => write!(f, "published"),
495 Self::Measured => write!(f, "measured"),
496 }
497 }
498}
499
500impl std::str::FromStr for DefaultsOrigin {
501 type Err = String;
502
503 fn from_str(s: &str) -> Result<Self, Self::Err> {
504 match s {
505 "user" => Ok(Self::User),
506 "auto_detected" => Ok(Self::AutoDetected),
507 "published" => Ok(Self::Published),
508 "measured" => Ok(Self::Measured),
509 other => Err(format!(
510 "unknown defaults origin '{other}'; expected user, auto_detected, published or measured"
511 )),
512 }
513 }
514}
515
516/// Everything about the target model that changes how sampling resolves,
517/// independent of any specific request.
518///
519/// Bundled rather than passed as separate parameters because both
520/// [`InferenceConfig::resolve_with_profile`] and
521/// [`crate::request_pipeline::sampling::resolve_sampling`] need the same two
522/// facts about the same model, and the list has already grown once (see
523/// #685) — a named struct reads at call sites instead of two easily
524/// transposed booleans.
525#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
526pub struct ModelSamplingContext {
527 /// Whether the model carries gglib's `reasoning` capability tag. Selects
528 /// the coupled-trio floor — see [`InferenceConfig::reasoning_floor`].
529 pub is_reasoning: bool,
530 /// Whether the model's `inference_defaults` were user-set or
531 /// auto-detected. `None` when the model has no stored
532 /// `inference_defaults` at all, in which case it has no effect either
533 /// way. See [`DefaultsOrigin`].
534 pub defaults_origin: Option<DefaultsOrigin>,
535}
536
537impl ModelSamplingContext {
538 /// Read both facts off a catalog row.
539 ///
540 /// Four call sites across three crates built this struct field-by-field
541 /// from a [`Model`](crate::domain::Model), each re-deriving `is_reasoning`
542 /// from the tag list inline. Two fields is exactly the size at which
543 /// hand-construction looks harmless and stops being so: the pair travels
544 /// together, both are read by the same fold, and a call site that filled
545 /// one and defaulted the other would resolve against the wrong floor or
546 /// mis-rank the model's own defaults against global settings — silently,
547 /// in both cases.
548 #[must_use]
549 pub fn for_model(model: &crate::domain::Model) -> Self {
550 Self {
551 is_reasoning: crate::domain::capability_tags::is_reasoning(&model.tags),
552 defaults_origin: model.defaults_origin,
553 }
554 }
555}
556
557/// Convert a camelCase string to `snake_case`.
558///
559/// Used internally to rename `InferenceConfig`'s serde camelCase output to the
560/// `OpenAI` wire format (`topP` → `top_p`, `maxTokens` → `max_tokens`, etc.).
561fn camel_to_snake(s: &str) -> String {
562 let mut out = String::with_capacity(s.len() + 4);
563 for ch in s.chars() {
564 if ch.is_uppercase() {
565 out.push('_');
566 out.push(ch.to_ascii_lowercase());
567 } else {
568 out.push(ch);
569 }
570 }
571 out
572}
573
574/// What reading one client-supplied sampling field did, when it was not
575/// simply "read it".
576///
577/// Carried out of [`InferenceConfig::extract_client_sampling`] so the caller
578/// can log or count it. A value gglib declines to use is a fact about a
579/// client worth surfacing — before this existed, the entire client sampling
580/// layer could vanish over one key with nothing recording that it had.
581///
582/// # An issue is a report, not by itself an instruction to the body
583///
584/// Recording that gglib could not use a value says nothing about whether the
585/// client's own spelling should still be forwarded, and the answer is *usually
586/// yes*: llama-server rejects what these readers reject, so a forwarded bad
587/// value earns the client an honest HTTP 400 from the system that owns the
588/// field. One field escapes that rule — [`REASONING_EFFORT_KEY`], which
589/// upstream does not validate at all — and it is the only one
590/// `request_pipeline::sampling` erases on the strength of an issue. See
591/// [`Rejected`](Self::Rejected) and that module's `erase_unadopted_client_keys`.
592#[derive(Debug, Clone, PartialEq, Eq)]
593pub enum FieldIssue {
594 /// Recognised, but not in the form this field takes. A documented
595 /// equivalent was substituted and the value is in use.
596 ///
597 /// The client's own spelling stays in the forwarded body. Where the
598 /// substitute is a value (`top_k: 5.0` → `5`) the resolved patch overwrites
599 /// it; where it is "no opinion" (`max_tokens: -1`, `seed: -1`) the ladder
600 /// emits nothing and the client's sentinel rides through to llama-server,
601 /// which reads it as the same absence this type does.
602 Normalised {
603 /// Wire key, as the client spelled it.
604 field: &'static str,
605 /// What arrived, rendered for a log line.
606 from: String,
607 /// What it was taken to mean.
608 to: &'static str,
609 },
610 /// Not readable as this field's type. **This field alone** is dropped;
611 /// every other field the client sent is unaffected.
612 ///
613 /// Dropped from the client's sampling layer always. Whether the client's
614 /// own text is also removed from the forwarded body is one field's
615 /// exception, not the rule:
616 ///
617 /// - [`REASONING_EFFORT_KEY`] is **deleted**. Upstream validates it not at
618 /// all, so a refused-but-forwarded `"banana"` is not answered with a 400
619 /// — it is rendered into the user's prompt (ADR 0007 finding 7c). gglib's
620 /// refusal has to bite here because no other system's will.
621 /// - Every other field, including [`REASONING_BUDGET_TOKENS_KEY`], is
622 /// **forwarded as sent**. These readers reject what llama-server rejects,
623 /// so the client gets upstream's own 400 naming the field and its range —
624 /// a better answer than a silent substitution, and it keeps gglib exactly
625 /// as strict as upstream rather than stricter.
626 Rejected {
627 /// Wire key, as the client spelled it.
628 field: &'static str,
629 /// What arrived, rendered for a log line.
630 value: String,
631 /// What the field accepts.
632 expected: &'static str,
633 },
634}
635
636impl FieldIssue {
637 /// The wire key this issue is about, exactly as it appears in the request
638 /// body.
639 ///
640 /// Every reader passes the literal key it read, including the one field
641 /// with two accepted spellings: a budget sent as
642 /// [`THINKING_BUDGET_TOKENS_KEY`] is reported under that name, not under
643 /// the canonical one the client never used. So this is directly usable as
644 /// a `serde_json::Map` key by the body cleanup in
645 /// `request_pipeline::sampling`.
646 #[must_use]
647 pub const fn field(&self) -> &'static str {
648 match self {
649 Self::Normalised { field, .. } | Self::Rejected { field, .. } => field,
650 }
651 }
652}
653
654impl fmt::Display for FieldIssue {
655 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
656 match self {
657 Self::Normalised { field, from, to } => {
658 write!(f, "{field}={from} read as {to}")
659 }
660 Self::Rejected {
661 field,
662 value,
663 expected,
664 } => write!(f, "{field}={value} dropped (expected {expected})"),
665 }
666 }
667}
668
669/// Render a JSON value compactly enough for a log line.
670///
671/// The budget is bytes, and the cut is taken at a character boundary — this
672/// renders a value the *client* sent, so it is arbitrary UTF-8 and `&s[..40]`
673/// would panic on the first request whose 40th byte fell inside a character.
674/// See [`crate::utils::text`].
675fn brief(v: &serde_json::Value) -> String {
676 crate::utils::text::truncate_with_ellipsis(&v.to_string(), 40).into_owned()
677}
678
679/// Narrow a JSON number to the `f32` every sampling field stores.
680///
681/// Named rather than inline because an `#[allow]` cannot sit on an
682/// expression, and the truncation is deliberate: the wire carries `f64` and
683/// `InferenceConfig` has always been `f32`.
684#[allow(clippy::cast_possible_truncation)]
685const fn narrow(n: f64) -> f32 {
686 n as f32
687}
688
689/// Read one float field. Absent and `null` are both "no opinion".
690fn read_f32(
691 obj: &serde_json::Map<String, serde_json::Value>,
692 key: &'static str,
693 issues: &mut Vec<FieldIssue>,
694) -> Option<f32> {
695 let v = obj.get(key)?;
696 if v.is_null() {
697 return None;
698 }
699 v.as_f64().map_or_else(
700 || {
701 issues.push(FieldIssue::Rejected {
702 field: key,
703 value: brief(v),
704 expected: "a number",
705 });
706 None
707 },
708 |n| Some(narrow(n)),
709 )
710}
711
712/// Read one integer field.
713///
714/// A float with no fractional part is accepted, because llama.cpp accepts
715/// `top_k: 40.0` and several clients emit every number as a float. A float
716/// that would lose information is not.
717fn read_i32(
718 obj: &serde_json::Map<String, serde_json::Value>,
719 key: &'static str,
720 issues: &mut Vec<FieldIssue>,
721) -> Option<i32> {
722 let v = obj.get(key)?;
723 if v.is_null() {
724 return None;
725 }
726 if let Some(n) = v.as_i64() {
727 return i32::try_from(n).map_or_else(
728 |_| {
729 issues.push(FieldIssue::Rejected {
730 field: key,
731 value: brief(v),
732 expected: "a 32-bit integer",
733 });
734 None
735 },
736 Some,
737 );
738 }
739 if let Some(f) = v.as_f64()
740 && f.fract() == 0.0
741 && f >= f64::from(i32::MIN)
742 && f <= f64::from(i32::MAX)
743 {
744 #[allow(clippy::cast_possible_truncation)]
745 let n = f as i32;
746 issues.push(FieldIssue::Normalised {
747 field: key,
748 from: brief(v),
749 to: "an integer",
750 });
751 return Some(n);
752 }
753 issues.push(FieldIssue::Rejected {
754 field: key,
755 value: brief(v),
756 expected: "an integer",
757 });
758 None
759}
760
761/// Read `max_tokens`, which is `u32` internally and `-1` on the wire.
762///
763/// `-1` is llama.cpp's own idiom for "no limit", and omitting the key means
764/// exactly that here — see
765/// [`with_hardcoded_defaults`](InferenceConfig::with_hardcoded_defaults),
766/// which deliberately leaves `max_tokens` unset. So `-1` is not an error to
767/// be reported, it is a spelling of a value this type already has. Any other
768/// negative is a client bug.
769fn read_max_tokens(
770 obj: &serde_json::Map<String, serde_json::Value>,
771 issues: &mut Vec<FieldIssue>,
772) -> Option<u32> {
773 let v = obj.get("max_tokens")?;
774 if v.is_null() {
775 return None;
776 }
777 // Not `read_i32_raw(v)?` — `?` would return before recording anything,
778 // which is the same silent-drop shape this whole function exists to end.
779 let Some(n) = read_i32_raw(v) else {
780 issues.push(FieldIssue::Rejected {
781 field: "max_tokens",
782 value: brief(v),
783 expected: "a non-negative integer, or -1 for no limit",
784 });
785 return None;
786 };
787 if n == -1 {
788 issues.push(FieldIssue::Normalised {
789 field: "max_tokens",
790 from: "-1".to_string(),
791 to: "no limit",
792 });
793 return None;
794 }
795 u32::try_from(n).map_or_else(
796 |_| {
797 issues.push(FieldIssue::Rejected {
798 field: "max_tokens",
799 value: brief(v),
800 expected: "a non-negative integer, or -1 for no limit",
801 });
802 None
803 },
804 Some,
805 )
806}
807
808/// Read the `seed` field.
809///
810/// llama.cpp's own spelling for "pick one at random" is `-1`, and its `/slots`
811/// reports a random seed as `4294967295` (`u32::MAX`). Both are normalised to
812/// `None`, which is how this type spells the same thing — omission already
813/// means random, so carrying a sentinel would give the same state two
814/// representations and make `seed.is_some()` stop meaning "reproducible".
815fn read_seed(
816 obj: &serde_json::Map<String, serde_json::Value>,
817 issues: &mut Vec<FieldIssue>,
818) -> Option<u32> {
819 let v = obj.get("seed")?;
820 if v.is_null() {
821 return None;
822 }
823 let Some(n) = v.as_i64() else {
824 issues.push(FieldIssue::Rejected {
825 field: "seed",
826 value: brief(v),
827 expected: "a non-negative integer, or -1 for a random seed",
828 });
829 return None;
830 };
831 if n == -1 || n == i64::from(u32::MAX) {
832 issues.push(FieldIssue::Normalised {
833 field: "seed",
834 from: brief(v),
835 to: "a random seed",
836 });
837 return None;
838 }
839 u32::try_from(n).map_or_else(
840 |_| {
841 issues.push(FieldIssue::Rejected {
842 field: "seed",
843 value: brief(v),
844 expected: "a non-negative integer, or -1 for a random seed",
845 });
846 None
847 },
848 Some,
849 )
850}
851
852/// Wire key for [`InferenceConfig::reasoning_effort`].
853///
854/// Named rather than spelled twice because the body cleanup in
855/// `request_pipeline::sampling` deletes this exact key when the reader refuses
856/// a level, and a typo there would be a silent no-op — nothing else in the
857/// system would notice a `reasoning_effort` that was never removed.
858pub(crate) const REASONING_EFFORT_KEY: &str = "reasoning_effort";
859
860/// Wire key for [`InferenceConfig::reasoning_budget_tokens`], and the only
861/// spelling gglib ever *emits*.
862pub(crate) const REASONING_BUDGET_TOKENS_KEY: &str = "reasoning_budget_tokens";
863
864/// Upstream's accepted alias for [`REASONING_BUDGET_TOKENS_KEY`].
865///
866/// llama-server reads either name into the same parameter, so a client may
867/// legitimately send this one and mean the budget. gglib therefore *reads* it
868/// (see [`read_reasoning_budget_tokens`]) and never emits it: the resolved
869/// value is force-inserted under the canonical key alone, and this key is
870/// erased from every forwarded body by `request_pipeline::sampling` whatever
871/// the trust setting says.
872///
873/// Both halves are load-bearing. Not reading it left an untrusted client's
874/// `thinking_budget_tokens: 100000` riding the body past a gate that governs
875/// the canonical spelling — the #779 shape, ungoverned and unrecorded. Not
876/// erasing it would leave two keys upstream reads as one, with gglib's own
877/// resolved value in only the first: llama-server's own parse order, not
878/// gglib's ladder, would decide the budget.
879pub(crate) const THINKING_BUDGET_TOKENS_KEY: &str = "thinking_budget_tokens";
880
881/// Read the `reasoning_effort` field.
882///
883/// The one reader in this module that is stricter than llama-server, and the
884/// only one that has to be: upstream validates this field not at all, so an
885/// unrecognised level is not caught anywhere downstream — it is *rendered into
886/// the prompt*. See [`InferenceConfig::reasoning_effort`] for the argument.
887///
888/// - a level, in any case → that level
889/// - `""` → [`Normalised`](FieldIssue::Normalised) to no opinion; llama-server
890/// ignores an empty string, so it already means "unset", and reporting it
891/// keeps a client that sends `""` on every request visible.
892/// - `"none"` → [`Rejected`](FieldIssue::Rejected) with a pointer at the field
893/// that actually stops thinking. Not silently mapped to anything: it is the
894/// one wrong value a client is *likely* to send on purpose, and mapping it
895/// would guess at an intent (`0` budget? the template default?) that only
896/// the client knows.
897/// - anything else, including a non-string → `Rejected`.
898fn read_reasoning_effort(
899 obj: &serde_json::Map<String, serde_json::Value>,
900 issues: &mut Vec<FieldIssue>,
901) -> Option<ReasoningEffort> {
902 const FIELD: &str = REASONING_EFFORT_KEY;
903
904 let v = obj.get(FIELD)?;
905 if v.is_null() {
906 return None;
907 }
908 let Some(s) = v.as_str() else {
909 issues.push(FieldIssue::Rejected {
910 field: FIELD,
911 value: brief(v),
912 // Deliberately not "a string": a non-string does not fail
913 // upstream, it degrades to the template's own default, so naming
914 // only the type would leave a client thinking any string works.
915 expected: "one of: minimal, low, medium, high, xhigh, max",
916 });
917 return None;
918 };
919 if s.is_empty() {
920 issues.push(FieldIssue::Normalised {
921 field: FIELD,
922 from: "\"\"".to_string(),
923 to: "no reasoning-effort preference",
924 });
925 return None;
926 }
927 if let Some(level) = ReasoningEffort::from_wire(s) {
928 return Some(level);
929 }
930 issues.push(FieldIssue::Rejected {
931 field: FIELD,
932 value: brief(v),
933 expected: if s.eq_ignore_ascii_case("none") {
934 // ADR 0007 finding 4: `\"none\"` erases the kwarg, and on gpt-oss
935 // the template's own fallback then yields *medium* thinking.
936 "one of: minimal, low, medium, high, xhigh, max \
937 (\"none\" is not off — use reasoning_budget_tokens: 0)"
938 } else {
939 "one of: minimal, low, medium, high, xhigh, max"
940 },
941 });
942 None
943}
944
945/// Read the `reasoning_budget_tokens` field, under either name upstream
946/// accepts for it.
947///
948/// Exactly upstream's range and nothing narrower: `-1 <= v <= i32::MAX`, with
949/// `-1` meaning "defer to the launch `--reasoning-budget`" and `0` meaning
950/// "stop thinking immediately". llama-server answers `-2` with an HTTP 400
951/// naming that range, so rejecting below `-1` here reproduces upstream's own
952/// verdict rather than adding a gglib opinion — the difference from
953/// [`read_reasoning_effort`], which has no upstream verdict to reproduce.
954///
955/// # The alias is read, and the canonical key wins
956///
957/// llama-server accepts [`THINKING_BUDGET_TOKENS_KEY`] as a second spelling of
958/// the same parameter ([ADR 0007] finding 7c). A reader that knew only the
959/// canonical name left the alias ungoverned: it entered no layer, appeared in
960/// no discard record, was overwritten by no force-insert, and so an untrusted
961/// client's `thinking_budget_tokens` outranked the operator's resolved budget
962/// silently — the #779 shape this arc exists to close.
963///
964/// Whichever name arrives, the value becomes
965/// [`InferenceConfig::reasoning_budget_tokens`] and is governed like any other
966/// client-authoritative budget. With both present the canonical key wins,
967/// because that is the name gglib itself emits and the one every other surface
968/// (provenance, the audit, `gglib model explain`) reports. An explicit `null`
969/// counts as absent under either name, as it does for every other reader here.
970///
971/// Issues are reported against the key the client actually sent, so a refusal
972/// names the text that was in the request rather than a name the client never
973/// used.
974///
975/// [ADR 0007]: https://github.com/mmogr/gglib/blob/main/docs/adr/0007-ask-the-server-for-template-capabilities.md
976fn read_reasoning_budget_tokens(
977 obj: &serde_json::Map<String, serde_json::Value>,
978 issues: &mut Vec<FieldIssue>,
979) -> Option<i32> {
980 let field = [REASONING_BUDGET_TOKENS_KEY, THINKING_BUDGET_TOKENS_KEY]
981 .into_iter()
982 .find(|key| obj.get(*key).is_some_and(|v| !v.is_null()))?;
983
984 // `read_i32` has already reported anything unreadable as the key the
985 // client sent, so only the range check is left.
986 let n = read_i32(obj, field, issues)?;
987 if n < -1 {
988 issues.push(FieldIssue::Rejected {
989 field,
990 value: n.to_string(),
991 expected: "an integer >= -1 (-1 defers to the launch default, 0 stops thinking)",
992 });
993 return None;
994 }
995 Some(n)
996}
997
998/// The integer read behind [`read_max_tokens`], without issue reporting —
999/// its caller reports in terms of `max_tokens`' own accepted range.
1000fn read_i32_raw(v: &serde_json::Value) -> Option<i32> {
1001 if let Some(n) = v.as_i64() {
1002 return i32::try_from(n).ok();
1003 }
1004 let f = v.as_f64()?;
1005 if f.fract() != 0.0 || f < f64::from(i32::MIN) || f > f64::from(i32::MAX) {
1006 return None;
1007 }
1008 #[allow(clippy::cast_possible_truncation)]
1009 Some(f as i32)
1010}
1011
1012/// Which ladder rung supplied each member of the temperature-coupled set.
1013///
1014/// Purely an intermediate inside [`InferenceConfig::resolve_layers_with_sources`]:
1015/// the two arms of the coupling rule each produce one of these, and the
1016/// provenance record is built from it. Named fields rather than a tuple
1017/// because both arms and the provenance construction read it positionally
1018/// otherwise, and the three are easy to transpose.
1019#[derive(Debug, Clone, Copy)]
1020struct CoupledLayers {
1021 repeat_penalty: Option<usize>,
1022 presence_penalty: Option<usize>,
1023 min_p: Option<usize>,
1024}
1025
1026impl InferenceConfig {
1027 /// Merge another config into this one, preferring values from `other`.
1028 ///
1029 /// For each field, if `other` has Some(value), use it; otherwise keep self's value.
1030 /// This is useful for applying fallback chains.
1031 ///
1032 /// # Example
1033 ///
1034 /// ```rust
1035 /// use gglib_core::domain::InferenceConfig;
1036 ///
1037 /// let mut request = InferenceConfig {
1038 /// temperature: Some(0.8),
1039 /// ..Default::default()
1040 /// };
1041 ///
1042 /// let model_defaults = InferenceConfig {
1043 /// temperature: Some(0.5),
1044 /// top_p: Some(0.9),
1045 /// ..Default::default()
1046 /// };
1047 ///
1048 /// request.merge_with(&model_defaults);
1049 /// assert_eq!(request.temperature, Some(0.8)); // Request value wins
1050 /// assert_eq!(request.top_p, Some(0.9)); // Fallback to model default
1051 /// ```
1052 pub const fn merge_with(&mut self, other: &Self) {
1053 if self.temperature.is_none() {
1054 self.temperature = other.temperature;
1055 }
1056 if self.top_p.is_none() {
1057 self.top_p = other.top_p;
1058 }
1059 if self.top_k.is_none() {
1060 self.top_k = other.top_k;
1061 }
1062 if self.max_tokens.is_none() {
1063 self.max_tokens = other.max_tokens;
1064 }
1065 if self.repeat_penalty.is_none() {
1066 self.repeat_penalty = other.repeat_penalty;
1067 }
1068 if self.presence_penalty.is_none() {
1069 self.presence_penalty = other.presence_penalty;
1070 }
1071 if self.frequency_penalty.is_none() {
1072 self.frequency_penalty = other.frequency_penalty;
1073 }
1074 if self.min_p.is_none() {
1075 self.min_p = other.min_p;
1076 }
1077 if self.dynatemp_range.is_none() {
1078 self.dynatemp_range = other.dynatemp_range;
1079 }
1080 if self.dynatemp_exponent.is_none() {
1081 self.dynatemp_exponent = other.dynatemp_exponent;
1082 }
1083 if self.top_n_sigma.is_none() {
1084 self.top_n_sigma = other.top_n_sigma;
1085 }
1086 if self.dry_multiplier.is_none() {
1087 self.dry_multiplier = other.dry_multiplier;
1088 }
1089 if self.dry_base.is_none() {
1090 self.dry_base = other.dry_base;
1091 }
1092 if self.dry_allowed_length.is_none() {
1093 self.dry_allowed_length = other.dry_allowed_length;
1094 }
1095 if self.dry_penalty_last_n.is_none() {
1096 self.dry_penalty_last_n = other.dry_penalty_last_n;
1097 }
1098 // Still `const`: `ReasoningEffort` is a fieldless `Copy` enum and the
1099 // budget is an `i32`, so both moves are plain copies. A non-`Copy`
1100 // field here (a `String` level, say) would have forced the keyword off
1101 // this function and quietly turned every merge into a clone.
1102 if self.reasoning_effort.is_none() {
1103 self.reasoning_effort = other.reasoning_effort;
1104 }
1105 if self.reasoning_budget_tokens.is_none() {
1106 self.reasoning_budget_tokens = other.reasoning_budget_tokens;
1107 }
1108 }
1109
1110 /// Write the temperature-coupled set into `result` and report which rung
1111 /// supplied each member.
1112 ///
1113 /// Split out of [`resolve_layers_with_sources`] only for length; that is
1114 /// also where the rule it implements is documented. `temperature` is the
1115 /// rung that claimed the temperature, if any — the whole coupling rule
1116 /// hangs off whether that is `Some`.
1117 ///
1118 /// [`resolve_layers_with_sources`]: Self::resolve_layers_with_sources
1119 fn resolve_coupled(
1120 layers: &[Option<&Self>],
1121 temperature: Option<usize>,
1122 result: &mut Self,
1123 ) -> CoupledLayers {
1124 let first = |declares: &dyn Fn(&Self) -> bool| -> Option<usize> {
1125 layers.iter().position(|l| l.is_some_and(declares))
1126 };
1127
1128 // The layer claiming `temperature` supplies the whole set, including
1129 // the fields it left unset — those drop to the floor rather than
1130 // inheriting a value tuned for a temperature nobody chose.
1131 if let Some(claim) = temperature {
1132 let c = layers[claim].expect("index came from a Some layer");
1133 result.repeat_penalty = c.repeat_penalty;
1134 result.presence_penalty = c.presence_penalty;
1135 result.min_p = c.min_p;
1136 return CoupledLayers {
1137 repeat_penalty: c.repeat_penalty.and(Some(claim)),
1138 presence_penalty: c.presence_penalty.and(Some(claim)),
1139 min_p: c.min_p.and(Some(claim)),
1140 };
1141 }
1142
1143 // Nothing was tuned against anything, so the set gap-fills like any
1144 // uncoupled parameter.
1145 let found = CoupledLayers {
1146 repeat_penalty: first(&|c| c.repeat_penalty.is_some()),
1147 presence_penalty: first(&|c| c.presence_penalty.is_some()),
1148 min_p: first(&|c| c.min_p.is_some()),
1149 };
1150 result.repeat_penalty = found
1151 .repeat_penalty
1152 .and_then(|i| layers[i].and_then(|c| c.repeat_penalty));
1153 result.presence_penalty = found
1154 .presence_penalty
1155 .and_then(|i| layers[i].and_then(|c| c.presence_penalty));
1156 result.min_p = found.min_p.and_then(|i| layers[i].and_then(|c| c.min_p));
1157 found
1158 }
1159
1160 /// Resolve an ordered list of sampling layers (highest priority first)
1161 /// into a single fully-resolved config, filling anything still unset from
1162 /// `floor`, and report which layer supplied each field.
1163 ///
1164 /// This is the one fold every multi-layer resolution surface goes
1165 /// through: [`resolve_with_profile`] wraps it for the simple
1166 /// request/profile/model/global shape, and
1167 /// [`crate::request_pipeline::sampling`] builds its own **six**-layer
1168 /// (`cli`, `client`, `profile`, `model`, `global`, `model auto-detected`)
1169 /// array and calls it directly. There is exactly one place that decides
1170 /// what "wins" means.
1171 ///
1172 /// Values and provenance come from one pass over one ladder and so cannot
1173 /// disagree — a second function that re-derived the rules would eventually
1174 /// explain a decision the resolution did not take, which is exactly what
1175 /// the `describe_provenance` helper this replaced had already started
1176 /// doing. See [`FieldSources`] for how to read the second half of the
1177 /// return; callers wanting only the values take `.0`.
1178 ///
1179 /// # Uncoupled parameters
1180 ///
1181 /// `top_p`, `top_k`, `max_tokens` and the four DRY parameters gap-fill
1182 /// independently: each takes the first `Some` value found scanning the
1183 /// layers top to bottom.
1184 ///
1185 /// # Coupled parameters
1186 ///
1187 /// `presence_penalty`, `repeat_penalty` and `min_p` are only meaningful
1188 /// relative to how sharp the sampling distribution is, so they travel with
1189 /// the `temperature` they were chosen for. [`reasoning_profile`] pairs
1190 /// `temperature 1.0` with `presence_penalty 1.5` deliberately; a sparse
1191 /// profile that sets `temperature 0.2` and leaves the penalty unset must
1192 /// not inherit that `1.5` — that would run a recipe no layer ever
1193 /// intended, a penalty tuned for a broad distribution applied to a
1194 /// near-greedy one.
1195 ///
1196 /// So: `temperature` resolves to the first layer that sets one. If some
1197 /// layer does, the coupled trio comes *only* from that same layer — never
1198 /// a layer beneath it — falling to `floor` for anything that layer itself
1199 /// left unset. If **no** layer sets a temperature at all, nothing has been
1200 /// tuned against anything, so the trio gap-fills normally, exactly like
1201 /// the uncoupled parameters.
1202 ///
1203 /// # Why DRY is *not* coupled
1204 ///
1205 /// It was, briefly, on the symmetry argument that a repetition penalty is
1206 /// a repetition penalty. Verification showed the symmetry is false and the
1207 /// cost is real. `presence_penalty` and `repeat_penalty` are flat logit
1208 /// offsets competing directly with temperature's sharpening; DRY's
1209 /// strength is governed by its own `dry_base` and `dry_allowed_length`,
1210 /// and it targets verbatim sequence repetition — a failure mode that is
1211 /// *worse* at low temperature, not milder.
1212 ///
1213 /// Coupling it meant a layer naming a DRY value but no temperature lost
1214 /// that value silently whenever any lower layer named one, which is the
1215 /// default state of every `reasoning`-tagged model. Since no shipped
1216 /// profile and not [`reasoning_profile`] itself pairs a temperature with
1217 /// DRY values, the coupling protected nothing and cost the most natural
1218 /// way to switch DRY on. See #745.
1219 ///
1220 /// [`resolve_with_profile`]: Self::resolve_with_profile
1221 /// [`reasoning_profile`]: Self::reasoning_profile
1222 #[must_use]
1223 pub fn resolve_layers_with_sources(
1224 layers: &[Option<&Self>],
1225 floor: &Self,
1226 ) -> (Self, FieldSources) {
1227 // Index into `layers` — not into the flattened iterator — so a caller
1228 // can map it back to the name it gave that rung.
1229 let first = |declares: &dyn Fn(&Self) -> bool| -> Option<usize> {
1230 layers.iter().position(|l| l.is_some_and(declares))
1231 };
1232
1233 let mut result = Self::default();
1234
1235 // Uncoupled: each takes the first layer that names it, independently.
1236 let top_p = first(&|c| c.top_p.is_some());
1237 let top_k = first(&|c| c.top_k.is_some());
1238 let max_tokens = first(&|c| c.max_tokens.is_some());
1239 let temperature = first(&|c| c.temperature.is_some());
1240 let dynatemp_range = first(&|c| c.dynatemp_range.is_some());
1241 let dynatemp_exponent = first(&|c| c.dynatemp_exponent.is_some());
1242 let top_n_sigma = first(&|c| c.top_n_sigma.is_some());
1243 let frequency_penalty = first(&|c| c.frequency_penalty.is_some());
1244 let dry_multiplier = first(&|c| c.dry_multiplier.is_some());
1245 let dry_base = first(&|c| c.dry_base.is_some());
1246 let dry_allowed_length = first(&|c| c.dry_allowed_length.is_some());
1247 let dry_penalty_last_n = first(&|c| c.dry_penalty_last_n.is_some());
1248 let seed = first(&|c| c.seed.is_some());
1249 let reasoning_effort = first(&|c| c.reasoning_effort.is_some());
1250 let reasoning_budget_tokens = first(&|c| c.reasoning_budget_tokens.is_some());
1251
1252 result.top_p = top_p.and_then(|i| layers[i].and_then(|c| c.top_p));
1253 result.top_k = top_k.and_then(|i| layers[i].and_then(|c| c.top_k));
1254 result.max_tokens = max_tokens.and_then(|i| layers[i].and_then(|c| c.max_tokens));
1255 result.temperature = temperature.and_then(|i| layers[i].and_then(|c| c.temperature));
1256 result.dynatemp_range =
1257 dynatemp_range.and_then(|i| layers[i].and_then(|c| c.dynatemp_range));
1258 result.dynatemp_exponent =
1259 dynatemp_exponent.and_then(|i| layers[i].and_then(|c| c.dynatemp_exponent));
1260 result.top_n_sigma = top_n_sigma.and_then(|i| layers[i].and_then(|c| c.top_n_sigma));
1261 result.frequency_penalty =
1262 frequency_penalty.and_then(|i| layers[i].and_then(|c| c.frequency_penalty));
1263 result.dry_multiplier =
1264 dry_multiplier.and_then(|i| layers[i].and_then(|c| c.dry_multiplier));
1265 result.dry_base = dry_base.and_then(|i| layers[i].and_then(|c| c.dry_base));
1266 result.dry_allowed_length =
1267 dry_allowed_length.and_then(|i| layers[i].and_then(|c| c.dry_allowed_length));
1268 result.dry_penalty_last_n =
1269 dry_penalty_last_n.and_then(|i| layers[i].and_then(|c| c.dry_penalty_last_n));
1270 // Uncoupled, and never coupled: a seed says nothing about how sharp the
1271 // distribution is, so pairing it with a temperature would be
1272 // meaningless. See the field docs for why no floor names it either.
1273 result.seed = seed.and_then(|i| layers[i].and_then(|c| c.seed));
1274 // Uncoupled, and never coupled — for a stronger reason than the seed's.
1275 // The coupled trio travels with `temperature` because all four shape
1276 // one probability distribution. Neither reasoning control touches the
1277 // sampler chain's distribution at all: effort is a template kwarg
1278 // consumed at render time, and the budget is a token count. Joining
1279 // them to the trio would mean a profile naming only an effort level
1280 // stripped a model's tuned `presence_penalty` — a recipe nobody wrote,
1281 // built out of a field that cannot interact with it.
1282 result.reasoning_effort =
1283 reasoning_effort.and_then(|i| layers[i].and_then(|c| c.reasoning_effort));
1284 result.reasoning_budget_tokens =
1285 reasoning_budget_tokens.and_then(|i| layers[i].and_then(|c| c.reasoning_budget_tokens));
1286
1287 let coupled_layers = Self::resolve_coupled(layers, temperature, &mut result);
1288
1289 result.merge_with(floor);
1290
1291 // A field no layer claimed came from the floor — or from nowhere, when
1292 // the floor has none either, which is whatever `with_hardcoded_defaults`
1293 // leaves unset rather than a list worth restating here.
1294 //
1295 // The coupling rule is checked **before** the floor's emptiness, and
1296 // the order is load-bearing. `Unset` means "nobody named this"; when a
1297 // layer named it and the coupling rule passed it over, that is a
1298 // different and more interesting fact, and it stays true whether or
1299 // not the floor then had a value to offer. Testing `!has_floor` first
1300 // was harmless while the floor filled all seven, and became a silent
1301 // loss of provenance the moment ADR 0003 emptied six of them — the
1302 // coupled trio would have reported as a plain absence.
1303 let coupled = temperature.is_some();
1304 let source = |won: Option<usize>, has_floor: bool, is_coupled: bool| match won {
1305 Some(i) => ParamSource::Layer(i),
1306 None if is_coupled => ParamSource::FloorCoupled,
1307 None if !has_floor => ParamSource::Unset,
1308 None => ParamSource::Floor,
1309 };
1310
1311 let sources = FieldSources {
1312 temperature: source(temperature, floor.temperature.is_some(), false),
1313 top_p: source(top_p, floor.top_p.is_some(), false),
1314 top_k: source(top_k, floor.top_k.is_some(), false),
1315 presence_penalty: source(
1316 coupled_layers.presence_penalty,
1317 floor.presence_penalty.is_some(),
1318 coupled,
1319 ),
1320 repeat_penalty: source(
1321 coupled_layers.repeat_penalty,
1322 floor.repeat_penalty.is_some(),
1323 coupled,
1324 ),
1325 min_p: source(coupled_layers.min_p, floor.min_p.is_some(), coupled),
1326 dynatemp_range: source(dynatemp_range, floor.dynatemp_range.is_some(), false),
1327 dynatemp_exponent: source(dynatemp_exponent, floor.dynatemp_exponent.is_some(), false),
1328 top_n_sigma: source(top_n_sigma, floor.top_n_sigma.is_some(), false),
1329 frequency_penalty: source(frequency_penalty, floor.frequency_penalty.is_some(), false),
1330 dry_multiplier: source(dry_multiplier, floor.dry_multiplier.is_some(), false),
1331 dry_base: source(dry_base, floor.dry_base.is_some(), false),
1332 dry_allowed_length: source(
1333 dry_allowed_length,
1334 floor.dry_allowed_length.is_some(),
1335 false,
1336 ),
1337 dry_penalty_last_n: source(
1338 dry_penalty_last_n,
1339 floor.dry_penalty_last_n.is_some(),
1340 false,
1341 ),
1342 max_tokens: source(max_tokens, floor.max_tokens.is_some(), false),
1343 reasoning_effort: source(reasoning_effort, floor.reasoning_effort.is_some(), false),
1344 reasoning_budget_tokens: source(
1345 reasoning_budget_tokens,
1346 floor.reasoning_budget_tokens.is_some(),
1347 false,
1348 ),
1349 };
1350
1351 (result, sources)
1352 }
1353
1354 /// The floor beneath every sampling ladder: what gglib asserts when no
1355 /// layer named a value.
1356 ///
1357 /// # It asserts one parameter, not seven
1358 ///
1359 /// [ADR 0003] measured this floor against a bare `llama-server` on the
1360 /// pinned build and found six of its seven values were *exactly* the
1361 /// upstream default:
1362 ///
1363 /// ```text
1364 /// parameter gglib floor upstream verdict
1365 /// temperature 0.7 0.8 DIVERGES -> policy
1366 /// top_p 0.95 0.95 EQUALS -> deleted
1367 /// top_k 40 40 EQUALS -> deleted
1368 /// repeat_penalty 1.0 1.0 EQUALS -> deleted
1369 /// presence_penalty 0.0 0.0 EQUALS -> deleted
1370 /// min_p 0.05 0.05 EQUALS -> deleted
1371 /// dry_multiplier 0.0 0.0 EQUALS -> deleted
1372 /// ```
1373 ///
1374 /// Restating a value that is already the answer is not a decision, it is
1375 /// a redundant assertion — and a costly one, because it silently overrides
1376 /// whatever upstream chooses next. #739 was exactly that failure: a floor
1377 /// of `min_p: 0.0` disabled the tail cut on every untuned request, and
1378 /// nothing in the system was positioned to notice. Six such overrides are
1379 /// now impossible.
1380 ///
1381 /// The six are **deferred**, not disabled. Nothing is emitted for them, so
1382 /// llama.cpp applies its own default — which on this build is the same
1383 /// number that used to be written here. Provenance reports them as
1384 /// [`ParamSource::Unset`], which is
1385 /// precisely what deferral is: gglib names no value.
1386 ///
1387 /// # `temperature: 0.7` stays, and upstream's is 0.8
1388 ///
1389 /// The one genuine policy choice in the set, and stated here because an
1390 /// undocumented divergence is how the other six became invisible in the
1391 /// first place. gglib decodes slightly more conservatively than
1392 /// llama.cpp's default for agentic work.
1393 ///
1394 /// # The floor is no longer uniform
1395 ///
1396 /// [`reasoning_floor`] still asserts `presence_penalty: 1.0` and
1397 /// `min_p: 0.0` for `reasoning`-tagged models, which are class-aware
1398 /// policy llama.cpp has no notion of. So after this change `min_p` is
1399 /// asserted for reasoning models and deferred for every other model.
1400 /// That is the correct shape and it needs saying out loud, because it is
1401 /// the first time the floor has differed by model class in what it
1402 /// *names* rather than only in what it names it as.
1403 ///
1404 /// # Deferral is safe only while the build is pinned
1405 ///
1406 /// [ADR 0002] pins the llama.cpp build; [ADR 0004]'s `/props` baseline
1407 /// check reads the default table back and flags any field that moves.
1408 /// That pairing is what makes deleting a value behaviour-preserving
1409 /// rather than hopeful.
1410 ///
1411 /// [ADR 0002]: https://github.com/mmogr/gglib/blob/main/docs/adr/0002-defer-tool-call-constraint-to-llama-cpp.md
1412 /// [ADR 0003]: https://github.com/mmogr/gglib/blob/main/docs/adr/0003-defer-sampler-defaults-to-llama-cpp.md
1413 /// [ADR 0004]: https://github.com/mmogr/gglib/blob/main/docs/adr/0004-observe-the-sampling-boundary.md
1414 /// [`reasoning_floor`]: Self::reasoning_floor
1415 ///
1416 /// # `max_tokens` has no fallback
1417 ///
1418 /// It is deliberately `None`. Resolution force-writes every `Some` field
1419 /// into the outgoing request, so a value here would cap *every* request
1420 /// that did not name its own — silently truncating long answers. Left
1421 /// unset, no `max_tokens` key is emitted and llama-server applies its own
1422 /// `n_predict` default of `-1`, generating until a stop token or the
1423 /// context limit.
1424 ///
1425 /// Omitting the key is exactly equivalent to sending `-1` (llama.cpp's
1426 /// `has_budget()` treats `-1` as limitless) and is the better of the two:
1427 /// `max_tokens: -1` is invalid under the `OpenAI` schema, which requires a
1428 /// positive integer, so a strict client or intermediary proxy may reject
1429 /// it. Omission keeps the forwarded body `OpenAI`-legal.
1430 ///
1431 /// Explicit per-request, per-profile, and per-model values are unaffected —
1432 /// [`reasoning_profile`] still sets its own ceiling.
1433 ///
1434 /// # A note on `min_p`, because it moved twice
1435 ///
1436 /// #739 changed it from `0.0` to `0.05`, correctly: `0.0` reads like an
1437 /// absence but was not one — [`to_openai_json_patch`] drops only `None`,
1438 /// so the floor was *explicitly disabling* the tail cut on every untuned
1439 /// request. The fix was right, and the mechanism it used was the problem.
1440 /// #739 restated upstream's value to keep it "visible as `min_p=floor` in
1441 /// sampling provenance instead of reporting as unset", which bought
1442 /// visibility at the price of a permanent silent override. Deferral is the
1443 /// better answer to the same objection: it reports as unset *because it is
1444 /// unset*, and [ADR 0004]'s readback names llama.cpp's own number instead
1445 /// of gglib restating it.
1446 ///
1447 /// [`reasoning_profile`]: Self::reasoning_profile
1448 /// [`to_openai_json_patch`]: Self::to_openai_json_patch
1449 #[must_use]
1450 pub const fn with_hardcoded_defaults() -> Self {
1451 Self {
1452 // No floor names a seed. See the field docs: a seed is not a
1453 // sampling policy, and a floor that pinned one would make every
1454 // untuned request in the installation decode identically.
1455 seed: None,
1456 // The one value gglib asserts. Upstream's is 0.8; see above.
1457 temperature: Some(0.7),
1458 // Everything below is deferred to llama.cpp, which is a decision
1459 // and not an omission — ADR 0003 finding 1 measured each of them
1460 // equal to the upstream default on the pinned build. Setting any
1461 // of these again means overriding whatever upstream chooses next,
1462 // so do it only with a measurement saying upstream is wrong.
1463 top_p: None,
1464 top_k: None,
1465 repeat_penalty: None,
1466 presence_penalty: None,
1467 min_p: None,
1468 // Never floored: modelled after ADR 0003, under its rule. llama.cpp
1469 // defaults it to 0.0 (off) and no measurement says otherwise; it is
1470 // governed here so the untrusted-client gate covers it, not so a
1471 // floor can assert it.
1472 frequency_penalty: None,
1473 // Never floored: introduced after ADR 0003, under its rule — the
1474 // floor asserts only measured divergences from upstream, and no
1475 // measurement says llama.cpp's own defaults (range 0.0 / exponent
1476 // 1.0 / sigma −1.0, all "off") are wrong as a fleet-wide floor.
1477 // Switching either mechanism on is a per-model or per-profile
1478 // tuning decision with sweep data behind it.
1479 dynatemp_range: None,
1480 dynatemp_exponent: None,
1481 top_n_sigma: None,
1482 // DRY stays off, and now says so by silence rather than by
1483 // asserting the zero llama.cpp already defaults to. Enabling it
1484 // fleet-wide is a tuning decision for a per-model or per-profile
1485 // layer with sweep data behind it, not for the floor every untuned
1486 // model lands on.
1487 dry_multiplier: None,
1488 // Never had a floor: with the multiplier off they have no effect,
1489 // and asserting values would claim a recipe nobody has measured.
1490 dry_base: None,
1491 dry_allowed_length: None,
1492 dry_penalty_last_n: None,
1493 // No fallback by design — see above.
1494 max_tokens: None,
1495 // Neither reasoning control is floored, and neither ever should
1496 // be. A floored `reasoning_effort` would override each template's
1497 // *own* internal default with a value nobody chose — `gpt-oss`
1498 // sets itself to `medium` when no kwarg arrives, and other
1499 // templates have other defaults or none. That is the #739 shape
1500 // (a floor silently displacing the value the thing beneath already
1501 // had) applied to a control that is not even observable
1502 // afterwards. A floored budget is the same mistake with a number:
1503 // `-1` already means "defer to the launch default", which is what
1504 // emitting no key does for free.
1505 reasoning_effort: None,
1506 reasoning_budget_tokens: None,
1507 }
1508 }
1509
1510 /// The coupled-trio floor for models tagged `reasoning`.
1511 ///
1512 /// [`resolve_layers_with_sources`] falls back to a floor once it has decided which
1513 /// layer (if any) claims the coupled set and that layer left a field
1514 /// unset. [`with_hardcoded_defaults`]'s neutral `presence_penalty: 0.0` is
1515 /// the right floor for most models, but wrong for a `reasoning`-tagged
1516 /// one: those degrade under greedy or near-greedy decoding into
1517 /// repetitive reasoning loops (see [`reasoning_profile`], which pairs
1518 /// `presence_penalty: 1.5` with `temperature: 1.0` specifically to
1519 /// prevent this). `1.0` keeps a real guard in place at the floor without
1520 /// asserting the full recipe tuned for a different temperature.
1521 ///
1522 /// `min_p` is pinned to `0.0` for the same class-specific reason: Qwen3.6's
1523 /// published guidance is to disable min-p on these models, which
1524 /// [`reasoning_profile`] already encodes.
1525 ///
1526 /// # These two are now the only class-specific *assertions*
1527 ///
1528 /// The neutral floor used to name `min_p: 0.05` and `presence_penalty:
1529 /// 0.0`, so this function read as "the same seven values, two of them
1530 /// different". [ADR 0003] deferred both of those to llama.cpp, so it now
1531 /// reads as "two values the neutral floor does not name at all".
1532 ///
1533 /// The consequence is worth stating because it makes the floor non-uniform
1534 /// in a way it never was: **`min_p` is asserted for reasoning models and
1535 /// deferred for everything else.** A reasoning model gets `min_p: 0.0` on
1536 /// the wire; every other model gets no `min_p` key and llama.cpp's own
1537 /// 0.05. That asymmetry is deliberate — one is a measured divergence from
1538 /// upstream, the other is agreement with it — but it will look like a bug
1539 /// to anyone diffing two requests without this paragraph.
1540 ///
1541 /// `presence_penalty: 1.0` is the same shape: asserted here, deferred
1542 /// elsewhere.
1543 ///
1544 /// [ADR 0003]: https://github.com/mmogr/gglib/blob/main/docs/adr/0003-defer-sampler-defaults-to-llama-cpp.md
1545 ///
1546 /// [`resolve_layers_with_sources`]: Self::resolve_layers_with_sources
1547 /// [`with_hardcoded_defaults`]: Self::with_hardcoded_defaults
1548 /// [`reasoning_profile`]: Self::reasoning_profile
1549 #[must_use]
1550 pub const fn reasoning_floor() -> Self {
1551 Self {
1552 presence_penalty: Some(1.0),
1553 min_p: Some(0.0),
1554 ..Self::with_hardcoded_defaults()
1555 }
1556 }
1557
1558 /// The highest temperature an agentic turn should decode at, when the
1559 /// model's class has one.
1560 ///
1561 /// A turn that carries tools may emit structured output. This is the
1562 /// ceiling that caps its temperature — applied by
1563 /// [`crate::request_pipeline::sampling`] *after* resolution, and only over
1564 /// a value nobody deliberately chose. It never raises a temperature.
1565 ///
1566 /// # Reasoning models have no ceiling — measured, not argued
1567 ///
1568 /// A `reasoning` model does not decode its tool call in isolation: the
1569 /// `<think>` block and the call are one completion under one sampler
1570 /// configuration, so a cap imposed for the sake of structured output lands
1571 /// on the reasoning phase too. This shipped as a `0.6` cap (inside the
1572 /// Qwen3 / DeepSeek-R1 recommended band), and [ADR 0004]'s addendum named
1573 /// the evidence that would justify changing it. That experiment ran on
1574 /// 2026-08-10 (tune runs #12–#32, `Qwen3.5-4B` `Q8_0`, 20 paired runs of
1575 /// the full agentic suite per arm):
1576 ///
1577 /// - Recipe temperature `1.0` uncapped beat the `0.6` cap on the paired
1578 /// composite 11W–4L–5T, mean +0.067, Wilcoxon one-sided p = 0.0099,
1579 /// bootstrap 95% CI [+0.017, +0.116].
1580 /// - The cost the cap existed to prevent never materialised: tool-call
1581 /// formatting tasks passed 100% at `1.0` versus 98.6% at `0.6`.
1582 /// - The failure the cap was risking did: loop/stagnation triggers were
1583 /// *more* frequent under the cap (29/126 vs 22/117) — cooling a
1584 /// thinking model manufactures the repetition its own vendors warn
1585 /// about, which the proxy's loop guard then acts on.
1586 ///
1587 /// So a reasoning model's resolved temperature stands on agentic turns,
1588 /// which in the shipped default means its auto-detected recipe's `1.0`.
1589 ///
1590 /// # `0.3` for everything else — unmeasured, unchanged
1591 ///
1592 /// The non-reasoning cap predates that experiment and no non-reasoning
1593 /// model has been measured against it. It keeps its old rationale (steady
1594 /// structured output without being greedy) and its old value until it
1595 /// earns the same treatment: evidence, not argument.
1596 ///
1597 /// # Why a ceiling and not a floor
1598 ///
1599 /// The floor this replaced could never fire on the models that most needed
1600 /// it. A `reasoning`-tagged model carries an auto-detected recipe naming
1601 /// `temperature: 1.0`, and any layer outranks a floor — so the adjustment
1602 /// was inert on precisely the models used for agentic coding. A ceiling
1603 /// gated on provenance fires there and stays out of the way everywhere a
1604 /// person actually made a choice.
1605 ///
1606 /// [ADR 0004]: https://github.com/mmogr/gglib/blob/main/docs/adr/0004-observe-the-sampling-boundary.md
1607 #[must_use]
1608 pub const fn agentic_temperature_ceiling(is_reasoning: bool) -> Option<f32> {
1609 if is_reasoning { None } else { Some(0.3) }
1610 }
1611
1612 /// Return a recommended [`InferenceConfig`] profile for reasoning / thinking models.
1613 ///
1614 /// Applied automatically at import time when the `"reasoning"` capability tag is
1615 /// detected (e.g. Qwen3.6, `DeepSeek-R1`, `QwQ`). Values follow the Qwen3.6 upstream
1616 /// guidance for **thinking mode — general tasks** and are conservative enough to
1617 /// work well across all thinking-capable models.
1618 ///
1619 /// | Parameter | Value | Rationale |
1620 /// |-----------|-------|-----------|
1621 /// | `temperature` | 1.0 | Recommended thinking-mode baseline |
1622 /// | `top_p` | 0.95 | Broad nucleus; standard for reasoning |
1623 /// | `top_k` | 20 | Tighter than the 40 fallback; suppresses low-quality tokens |
1624 /// | `max_tokens` | 8192 | Safe out-of-the-box ceiling; increase for complex tasks |
1625 /// | `repeat_penalty` | 1.0 | No penalty; `presence_penalty` handles anti-repetition |
1626 /// | `presence_penalty` | 1.5 | Prevents repetitive reasoning loops |
1627 /// | `min_p` | 0.0 | Explicitly disabled per Qwen3.6 spec |
1628 ///
1629 /// Users can override any parameter with `gglib model update <id> --<flag>` or
1630 /// the equivalent UI control.
1631 #[must_use]
1632 pub const fn reasoning_profile() -> Self {
1633 Self {
1634 // Never seeded: this recipe is stored per model, and a stored seed
1635 // would pin every response that model ever produces.
1636 seed: None,
1637 temperature: Some(1.0),
1638 top_p: Some(0.95),
1639 top_k: Some(20),
1640 max_tokens: Some(8192),
1641 repeat_penalty: Some(1.0),
1642 presence_penalty: Some(1.5),
1643 min_p: Some(0.0),
1644 // Deliberately unset, and this must stay that way: legacy rows are
1645 // classified as auto-detected by comparing their stored defaults
1646 // against this recipe verbatim (`resolve_defaults_origin`). Rows
1647 // written before DRY existed deserialize these as `None`, so any
1648 // value here would make every one of them compare unequal and
1649 // silently reclassify as user-set, moving them up a resolution
1650 // rung. The same holds for every field added since — dynatemp,
1651 // top-n-sigma and frequency_penalty included.
1652 dry_multiplier: None,
1653 dry_base: None,
1654 dry_allowed_length: None,
1655 dry_penalty_last_n: None,
1656 dynatemp_range: None,
1657 dynatemp_exponent: None,
1658 top_n_sigma: None,
1659 frequency_penalty: None,
1660 // Deliberately unset for the same legacy-row reason as the block
1661 // above — and independently, because this recipe is keyed off the
1662 // `reasoning` *tag*, which says a model thinks, not how hard it
1663 // should be asked to. Nothing observes whether the level landed,
1664 // so a guess written here would be an unfalsifiable one.
1665 reasoning_effort: None,
1666 reasoning_budget_tokens: None,
1667 }
1668 }
1669
1670 /// Resolve inference parameters using the 4-level hierarchy.
1671 ///
1672 /// Equivalent to [`resolve_with_profile`] with no profile selected — see
1673 /// there for the merge order. This is the entry point for surfaces that
1674 /// have no notion of a named profile (`gglib serve`, `gglib chat`,
1675 /// `gglib q`, the Web UI chat API).
1676 ///
1677 /// `model_ctx` carries the two facts about the target model that change
1678 /// how resolution behaves — see [`ModelSamplingContext`],
1679 /// [`resolve_layers_with_sources`] and [`reasoning_floor`].
1680 ///
1681 /// # Example
1682 ///
1683 /// ```rust
1684 /// use gglib_core::domain::{InferenceConfig, ModelSamplingContext};
1685 ///
1686 /// let request = InferenceConfig { temperature: Some(0.9), ..Default::default() };
1687 /// let model = InferenceConfig { temperature: Some(0.5), top_p: Some(0.8), ..Default::default() };
1688 ///
1689 /// let resolved = request.resolve_with_defaults(Some(&model), None, ModelSamplingContext::default());
1690 /// assert_eq!(resolved.temperature, Some(0.9)); // request wins
1691 /// assert_eq!(resolved.top_p, Some(0.8)); // model fills in
1692 /// assert_eq!(resolved.top_k, None); // no layer named it, and
1693 /// // the floor defers top_k
1694 /// // to llama.cpp (ADR 0003)
1695 /// ```
1696 ///
1697 /// [`resolve_with_profile`]: Self::resolve_with_profile
1698 /// [`resolve_layers_with_sources`]: Self::resolve_layers_with_sources
1699 /// [`reasoning_floor`]: Self::reasoning_floor
1700 #[must_use]
1701 pub fn resolve_with_defaults(
1702 self,
1703 model: Option<&Self>,
1704 global: Option<&Self>,
1705 model_ctx: ModelSamplingContext,
1706 ) -> Self {
1707 self.resolve_with_profile(None, model, global, model_ctx)
1708 }
1709
1710 /// Resolve inference parameters using the full 5-level hierarchy.
1711 ///
1712 /// Applies fallback layers in order, with each layer filling only `None`
1713 /// fields from `self` — explicit values are never overwritten:
1714 ///
1715 /// 1. `self` — caller-supplied overrides (request params, CLI flags, etc.)
1716 /// 2. `profile` — the named profile the request selected, if any
1717 /// 3. `model` — per-model stored defaults, *if user-set*
1718 /// 4. `global` — global settings defaults
1719 /// 5. `model` again, *if auto-detected* — see below
1720 /// 6. the model-class floor — [`reasoning_floor`] when
1721 /// `model_ctx.is_reasoning`, otherwise [`with_hardcoded_defaults`]
1722 ///
1723 /// This is the single source of truth for inference parameter resolution
1724 /// across every gglib surface that does not need its own layer set;
1725 /// [`resolve_with_defaults`] delegates here so there is exactly one merge
1726 /// order to reason about and to test.
1727 /// [`crate::request_pipeline::sampling`] needs a sixth rung (the client's
1728 /// own request, sitting *below* the CLI override rather than between
1729 /// `self` and `profile`) and calls the underlying [`resolve_layers_with_sources`]
1730 /// directly for that reason — the merge semantics are identical either
1731 /// way.
1732 ///
1733 /// # Why the profile sits above the model
1734 ///
1735 /// Selecting `model:coding` is an explicit act by the caller, so it has to
1736 /// beat the model's stored defaults or it would appear to do nothing on any
1737 /// model that has them. Because profiles are *sparse* (see
1738 /// [`crate::domain::inference_profile`]), outranking the model layer costs
1739 /// nothing for parameters the profile does not set — those still resolve
1740 /// from the model, which is what keeps one global profile safe to apply
1741 /// across differing architectures.
1742 ///
1743 /// # Why `model` can rank below `global`
1744 ///
1745 /// `model` is only ever a stand-in for `Model.inference_defaults`, which
1746 /// gets written two different ways (see [`DefaultsOrigin`]): a person
1747 /// tuning it deliberately, or gglib's own import-time guess for any
1748 /// model tagged `reasoning`. Those deserve different authority. A
1749 /// deliberate per-model choice should keep outranking the operator's
1750 /// global defaults — that is what "per-model" means. A guess nobody
1751 /// reviewed should not: it silently shadowed the user's own configured
1752 /// global settings, which is how #685 happened. `model_ctx.defaults_origin`
1753 /// decides which rung `model` occupies for this call — never both at
1754 /// once, since only one of rungs 3 and 5 is ever populated for a given
1755 /// model.
1756 ///
1757 /// # Temperature-tuned parameters do not fall through
1758 ///
1759 /// See [`resolve_layers_with_sources`] for the full rule. In short: once a layer
1760 /// declares a `temperature`, lower layers may not contribute
1761 /// `presence_penalty`, `repeat_penalty` or `min_p` — those resolve from
1762 /// the claiming layer alone, falling to the class floor if it left them
1763 /// unset.
1764 ///
1765 /// # Example
1766 ///
1767 /// ```rust
1768 /// use gglib_core::domain::{InferenceConfig, ModelSamplingContext};
1769 ///
1770 /// // A sparse profile: sets temperature, says nothing about anything else.
1771 /// let profile = InferenceConfig { temperature: Some(0.2), ..Default::default() };
1772 /// // A thinking model's stored defaults: 1.5 is tuned for temperature 1.0.
1773 /// let model = InferenceConfig {
1774 /// temperature: Some(1.0),
1775 /// presence_penalty: Some(1.5),
1776 /// top_k: Some(20),
1777 /// ..Default::default()
1778 /// };
1779 /// let model_ctx = ModelSamplingContext { is_reasoning: true, ..Default::default() };
1780 ///
1781 /// let resolved = InferenceConfig::default()
1782 /// .resolve_with_profile(Some(&profile), Some(&model), None, model_ctx);
1783 ///
1784 /// assert_eq!(resolved.temperature, Some(0.2)); // profile beats model
1785 /// assert_eq!(resolved.presence_penalty, Some(1.0)); // reasoning floor, NOT the model's 1.5
1786 /// assert_eq!(resolved.top_k, Some(20)); // untuned: still fills
1787 /// ```
1788 ///
1789 /// [`resolve_layers_with_sources`]: Self::resolve_layers_with_sources
1790 /// [`reasoning_floor`]: Self::reasoning_floor
1791 /// [`with_hardcoded_defaults`]: Self::with_hardcoded_defaults
1792 /// [`resolve_with_defaults`]: Self::resolve_with_defaults
1793 #[must_use]
1794 pub fn resolve_with_profile(
1795 self,
1796 profile: Option<&Self>,
1797 model: Option<&Self>,
1798 global: Option<&Self>,
1799 model_ctx: ModelSamplingContext,
1800 ) -> Self {
1801 self.resolve_with_profile_explained(profile, model, global, model_ctx)
1802 .0
1803 }
1804
1805 /// [`resolve_with_profile`] plus a record of which rung supplied each
1806 /// field.
1807 ///
1808 /// This is the implementation; [`resolve_with_profile`] delegates here and
1809 /// discards the provenance, so the ladder — including the user/auto rung
1810 /// split and the floor selection — is built exactly once.
1811 ///
1812 /// Map a [`ParamSource::Layer`] index back to a rung with
1813 /// [`SamplingLayer::from_index`], which is kept beside this ladder for
1814 /// that purpose.
1815 ///
1816 /// [`resolve_with_profile`]: Self::resolve_with_profile
1817 /// [`SamplingLayer::from_index`]: crate::domain::SamplingLayer::from_index
1818 /// [`ParamSource::Layer`]: crate::domain::ParamSource::Layer
1819 #[must_use]
1820 pub fn resolve_with_profile_explained(
1821 self,
1822 profile: Option<&Self>,
1823 model: Option<&Self>,
1824 global: Option<&Self>,
1825 model_ctx: ModelSamplingContext,
1826 ) -> (Self, FieldSources) {
1827 let floor = if model_ctx.is_reasoning {
1828 Self::reasoning_floor()
1829 } else {
1830 Self::with_hardcoded_defaults()
1831 };
1832 // Exhaustive on purpose. The catch-all this replaced sent every
1833 // not-`AutoDetected` origin to the user-set rung, so adding
1834 // `Published` would silently have ranked a fetched recipe *above*
1835 // global settings — the one thing an unreviewed origin must never do.
1836 let (user_model, auto_model) = match model_ctx.defaults_origin {
1837 Some(
1838 DefaultsOrigin::AutoDetected | DefaultsOrigin::Published | DefaultsOrigin::Measured,
1839 ) => (None, model),
1840 Some(DefaultsOrigin::User) | None => (model, None),
1841 };
1842 Self::resolve_layers_with_sources(
1843 &[Some(&self), profile, user_model, global, auto_model],
1844 &floor,
1845 )
1846 }
1847
1848 /// Parse inference parameters from an OpenAI-format JSON body
1849 /// (`snake_case` keys), plus what the read had to reject or normalise.
1850 ///
1851 /// Missing keys, explicit `null`s and keys this type does not model all
1852 /// yield `None` for that field and leave the rest untouched. This is the
1853 /// inverse of [`Self::to_openai_json_patch`]; a caller with nothing to
1854 /// report on the rejections takes `.0`.
1855 ///
1856 /// # One bad field must not cost the other ten
1857 ///
1858 /// This read used to camel-case the whole body and hand it to
1859 /// `serde_json::from_value(..).unwrap_or_default()`. Serde parses an
1860 /// object as a unit, so a single wrongly-typed key failed the whole
1861 /// deserialise and `unwrap_or_default()` returned an all-`None` config —
1862 /// silently discarding every sampling value the client sent, with no log
1863 /// and no test covering the failure path.
1864 ///
1865 /// Reading field by field means a bad `max_tokens` costs `max_tokens` and
1866 /// nothing else.
1867 ///
1868 /// # The coercion policy is upstream's, not ours
1869 ///
1870 /// [ADR 0003] finding 6 measured what llama.cpp itself accepts on the
1871 /// pinned build, so this does not have to invent a policy:
1872 ///
1873 /// | sent | llama.cpp | here |
1874 /// |---|---|---|
1875 /// | `max_tokens: -1` | 200 | [`Normalised`] to `None` — omission already means "no limit" |
1876 /// | `top_k: 40.0` | 200 | accepted as `40`; a *fractional* float is rejected |
1877 /// | `temperature: "0.7"` | 400 | [`Rejected`] — a numeric string is a client bug, and quietly parsing it teaches nobody |
1878 ///
1879 /// The principle is to accept what upstream accepts and reject what
1880 /// upstream rejects, so gglib never becomes the stricter of the two on a
1881 /// value that would have worked. Before this change it was: llama.cpp
1882 /// takes `max_tokens: -1` and gglib threw away the entire layer over it.
1883 ///
1884 /// # One field departs from it, and says so
1885 ///
1886 /// [`reasoning_effort`](Self::reasoning_effort) is read against a closed
1887 /// enum, which llama-server is not: it validates that field not at all and
1888 /// renders `"banana"` into the prompt. The departure is argued on the
1889 /// field itself rather than here, because the argument is about that
1890 /// field's measured wire behaviour and not about coercion in general —
1891 /// its twin [`reasoning_budget_tokens`](Self::reasoning_budget_tokens)
1892 /// follows the principle exactly, reproducing upstream's own 400 boundary
1893 /// and nothing narrower.
1894 ///
1895 /// [ADR 0003]: https://github.com/mmogr/gglib/blob/main/docs/adr/0003-defer-sampler-defaults-to-llama-cpp.md
1896 /// [`Normalised`]: FieldIssue::Normalised
1897 /// [`Rejected`]: FieldIssue::Rejected
1898 #[must_use]
1899 pub fn extract_client_sampling(value: &serde_json::Value) -> (Self, Vec<FieldIssue>) {
1900 let Some(obj) = value.as_object() else {
1901 return (Self::default(), Vec::new());
1902 };
1903 let mut issues = Vec::new();
1904
1905 let cfg = Self {
1906 temperature: read_f32(obj, "temperature", &mut issues),
1907 top_p: read_f32(obj, "top_p", &mut issues),
1908 top_k: read_i32(obj, "top_k", &mut issues),
1909 max_tokens: read_max_tokens(obj, &mut issues),
1910 repeat_penalty: read_f32(obj, "repeat_penalty", &mut issues),
1911 presence_penalty: read_f32(obj, "presence_penalty", &mut issues),
1912 frequency_penalty: read_f32(obj, "frequency_penalty", &mut issues),
1913 min_p: read_f32(obj, "min_p", &mut issues),
1914 dynatemp_range: read_f32(obj, "dynatemp_range", &mut issues),
1915 dynatemp_exponent: read_f32(obj, "dynatemp_exponent", &mut issues),
1916 top_n_sigma: read_f32(obj, "top_n_sigma", &mut issues),
1917 dry_multiplier: read_f32(obj, "dry_multiplier", &mut issues),
1918 dry_base: read_f32(obj, "dry_base", &mut issues),
1919 seed: read_seed(obj, &mut issues),
1920 dry_allowed_length: read_i32(obj, "dry_allowed_length", &mut issues),
1921 dry_penalty_last_n: read_i32(obj, "dry_penalty_last_n", &mut issues),
1922 reasoning_effort: read_reasoning_effort(obj, &mut issues),
1923 reasoning_budget_tokens: read_reasoning_budget_tokens(obj, &mut issues),
1924 };
1925
1926 (cfg, issues)
1927 }
1928
1929 /// Serialise as an OpenAI-format JSON patch (`snake_case` keys, `Some` fields only).
1930 ///
1931 /// Uses `serde` to produce the camelCase form, then renames each key to
1932 /// `snake_case` via [`camel_to_snake`]. Only `Some` fields are emitted — `None`
1933 /// values are filtered out. The returned map can be merged directly into an
1934 /// OpenAI-compatible request body with `body_obj.insert(k, v)`.
1935 ///
1936 /// This is the inverse of [`extract_client_sampling`].
1937 ///
1938 /// [`extract_client_sampling`]: Self::extract_client_sampling
1939 #[must_use]
1940 pub fn to_openai_json_patch(&self) -> serde_json::Map<String, serde_json::Value> {
1941 let camel = serde_json::to_value(self).unwrap_or_default();
1942 camel
1943 .as_object()
1944 .into_iter()
1945 .flatten()
1946 .filter(|(_, v)| !v.is_null())
1947 .map(|(k, v)| (camel_to_snake(k), v.clone()))
1948 .collect()
1949 }
1950}
1951
1952#[cfg(test)]
1953#[path = "inference_tests.rs"]
1954mod inference_tests;