Skip to main content

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;