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//! This module provides the core `InferenceConfig` type that is reused across:
8//! - Per-model defaults (`Model.inference_defaults`)
9//! - Global settings (`Settings.inference_defaults`)
10//! - Request-level overrides (flattened in `ChatProxyRequest`)
11//! - `gglib proxy` — per-request injection into OpenAI-format request bodies
12//! - `gglib chat` / `gglib q` — hierarchy resolution for the agentic loop
13//!
14//! All surfaces resolve inference parameters through
15//! [`InferenceConfig::resolve_with_profile`], which is the single source of
16//! truth for the hierarchy. [`InferenceConfig::resolve_with_defaults`] is the
17//! same resolution with no profile selected, for surfaces that have no notion
18//! of one.
19
20use serde::{Deserialize, Serialize};
21
22use crate::domain::sampling_provenance::{FieldSources, ParamSource};
23
24/// Inference parameters for LLM sampling.
25///
26/// All fields are optional to support partial configuration and fallback chains.
27/// Intended to be shared across model defaults, global settings, and request overrides.
28///
29/// # Hierarchy Resolution
30///
31/// When making an inference request, parameters are resolved in this order:
32/// 1. Request-level override (user specified for this request)
33/// 2. Selected profile (`Settings.inference_profiles`, chosen as
34///    `{model}:{profile}`; absent on surfaces without profiles)
35/// 3. Per-model defaults (stored in `Model.inference_defaults`)
36/// 4. Global settings (stored in `Settings.inference_defaults`)
37/// 5. Hardcoded fallback (e.g., temperature = 0.7)
38///
39/// # Examples
40///
41/// ```rust
42/// use gglib_core::domain::InferenceConfig;
43///
44/// // Conservative settings for code generation
45/// let code_gen = InferenceConfig {
46///     temperature: Some(0.2),
47///     top_p: Some(0.9),
48///     top_k: Some(40),
49///     max_tokens: Some(2048),
50///     repeat_penalty: Some(1.1),
51///     presence_penalty: None,
52///     min_p: None,
53/// };
54///
55/// // Creative writing settings
56/// let creative = InferenceConfig {
57///     temperature: Some(1.2),
58///     top_p: Some(0.95),
59///     ..Default::default()
60/// };
61/// ```
62#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
63#[serde(rename_all = "camelCase")]
64pub struct InferenceConfig {
65    /// Sampling temperature (0.0 - 2.0).
66    ///
67    /// Controls randomness in token selection:
68    /// - Lower values (0.1-0.5): More deterministic, focused
69    /// - Medium values (0.7-1.0): Balanced creativity
70    /// - Higher values (1.1-2.0): More random, creative
71    pub temperature: Option<f32>,
72
73    /// Nucleus sampling threshold (0.0 - 1.0).
74    ///
75    /// Considers only the top tokens whose cumulative probability exceeds this threshold.
76    /// Common values: 0.9 (default), 0.95 (more diverse)
77    pub top_p: Option<f32>,
78
79    /// Top-K sampling limit.
80    ///
81    /// Considers only the K most likely next tokens.
82    /// Common values: 40 (default), 10 (focused), 100 (diverse)
83    pub top_k: Option<i32>,
84
85    /// Maximum tokens to generate in response.
86    ///
87    /// Hard limit on response length. Does not include input tokens.
88    pub max_tokens: Option<u32>,
89
90    /// Repetition penalty (> 0.0, typically 1.0 - 1.3).
91    ///
92    /// Penalizes repeated tokens to reduce repetitive output.
93    /// - 1.0: No penalty (default)
94    /// - 1.1-1.3: Moderate penalty
95    /// - > 1.3: Strong penalty (may hurt coherence)
96    pub repeat_penalty: Option<f32>,
97
98    /// Presence penalty (0.0 - 2.0).
99    ///
100    /// Penalizes tokens that have already appeared in the output, encouraging
101    /// the model to cover new ground. Effective at preventing repetitive
102    /// reasoning loops in thinking models.
103    /// - 0.0: No penalty (default; disabled)
104    /// - 1.5: Recommended for reasoning/thinking models (e.g. `Qwen3.6`, `DeepSeek-R1`)
105    /// - > 2.0: Avoid; may degrade coherence
106    pub presence_penalty: Option<f32>,
107
108    /// Minimum-probability sampling threshold (0.0 - 1.0).
109    ///
110    /// Removes tokens whose probability is below `min_p × P(top token)`.
111    /// - 0.0: Disabled (explicit off; recommended by Qwen3.6)
112    /// - 0.05: llama.cpp built-in default when the flag is omitted
113    pub min_p: Option<f32>,
114}
115
116/// Whether a model's stored `inference_defaults` were set by the user or
117/// written automatically at import time.
118///
119/// `Model.inference_defaults` is populated two ways: a user explicitly
120/// tunes it (`gglib model update --presence-penalty …`, or the `WebUI` edit
121/// form), or [`crate::services`]'s import path auto-writes
122/// [`InferenceConfig::reasoning_profile`] onto any model carrying the
123/// `reasoning` tag — a reasonable guess, not a user decision. Both end up in
124/// the same column with nothing distinguishing them, which meant an
125/// auto-written guess silently outranked the user's own global settings in
126/// the resolution ladder ([`InferenceConfig::resolve_with_profile`]) exactly
127/// as if the user had tuned it themselves.
128///
129/// This type tracks which one actually happened, so resolution can rank
130/// [`AutoDetected`](Self::AutoDetected) below global settings while a real
131/// [`User`](Self::User) choice keeps outranking them.
132#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
133#[serde(rename_all = "snake_case")]
134pub enum DefaultsOrigin {
135    /// Set explicitly by the user — a CLI flag, a model-update request, or a
136    /// `WebUI` edit. Outranks global settings, same as before this type
137    /// existed.
138    User,
139    /// Written automatically at import time from the model's `reasoning`
140    /// tag, never reviewed by a person. Ranks below global settings.
141    AutoDetected,
142}
143
144impl std::fmt::Display for DefaultsOrigin {
145    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
146        match self {
147            Self::User => write!(f, "user"),
148            Self::AutoDetected => write!(f, "auto_detected"),
149        }
150    }
151}
152
153impl std::str::FromStr for DefaultsOrigin {
154    type Err = String;
155
156    fn from_str(s: &str) -> Result<Self, Self::Err> {
157        match s {
158            "user" => Ok(Self::User),
159            "auto_detected" => Ok(Self::AutoDetected),
160            other => Err(format!(
161                "unknown defaults origin '{other}'; expected user or auto_detected"
162            )),
163        }
164    }
165}
166
167/// Everything about the target model that changes how sampling resolves,
168/// independent of any specific request.
169///
170/// Bundled rather than passed as separate parameters because both
171/// [`InferenceConfig::resolve_with_profile`] and
172/// [`crate::request_pipeline::sampling::resolve_sampling`] need the same two
173/// facts about the same model, and the list has already grown once (see
174/// #685) — a named struct reads at call sites instead of two easily
175/// transposed booleans.
176#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
177pub struct ModelSamplingContext {
178    /// Whether the model carries gglib's `reasoning` capability tag. Selects
179    /// the coupled-trio floor — see [`InferenceConfig::reasoning_floor`].
180    pub is_reasoning: bool,
181    /// Whether the model's `inference_defaults` were user-set or
182    /// auto-detected. `None` when the model has no stored
183    /// `inference_defaults` at all, in which case it has no effect either
184    /// way. See [`DefaultsOrigin`].
185    pub defaults_origin: Option<DefaultsOrigin>,
186}
187
188/// Convert a camelCase string to `snake_case`.
189///
190/// Used internally to rename `InferenceConfig`'s serde camelCase output to the
191/// `OpenAI` wire format (`topP` → `top_p`, `maxTokens` → `max_tokens`, etc.).
192fn camel_to_snake(s: &str) -> String {
193    let mut out = String::with_capacity(s.len() + 4);
194    for ch in s.chars() {
195        if ch.is_uppercase() {
196            out.push('_');
197            out.push(ch.to_ascii_lowercase());
198        } else {
199            out.push(ch);
200        }
201    }
202    out
203}
204
205/// Convert a `snake_case` string to camelCase.
206///
207/// Inverse of [`camel_to_snake`]; used to normalise OpenAI-format body keys
208/// (`top_p`, `max_tokens`, etc.) into the camelCase form expected by
209/// `InferenceConfig`'s serde impl before deserialisation.
210fn snake_to_camel(s: &str) -> String {
211    let mut out = String::with_capacity(s.len());
212    let mut cap = false;
213    for ch in s.chars() {
214        if ch == '_' {
215            cap = true;
216        } else if cap {
217            out.push(ch.to_ascii_uppercase());
218            cap = false;
219        } else {
220            out.push(ch);
221        }
222    }
223    out
224}
225
226impl InferenceConfig {
227    /// Merge another config into this one, preferring values from `other`.
228    ///
229    /// For each field, if `other` has Some(value), use it; otherwise keep self's value.
230    /// This is useful for applying fallback chains.
231    ///
232    /// # Example
233    ///
234    /// ```rust
235    /// use gglib_core::domain::InferenceConfig;
236    ///
237    /// let mut request = InferenceConfig {
238    ///     temperature: Some(0.8),
239    ///     ..Default::default()
240    /// };
241    ///
242    /// let model_defaults = InferenceConfig {
243    ///     temperature: Some(0.5),
244    ///     top_p: Some(0.9),
245    ///     ..Default::default()
246    /// };
247    ///
248    /// request.merge_with(&model_defaults);
249    /// assert_eq!(request.temperature, Some(0.8)); // Request value wins
250    /// assert_eq!(request.top_p, Some(0.9));      // Fallback to model default
251    /// ```
252    pub const fn merge_with(&mut self, other: &Self) {
253        if self.temperature.is_none() {
254            self.temperature = other.temperature;
255        }
256        if self.top_p.is_none() {
257            self.top_p = other.top_p;
258        }
259        if self.top_k.is_none() {
260            self.top_k = other.top_k;
261        }
262        if self.max_tokens.is_none() {
263            self.max_tokens = other.max_tokens;
264        }
265        if self.repeat_penalty.is_none() {
266            self.repeat_penalty = other.repeat_penalty;
267        }
268        if self.presence_penalty.is_none() {
269            self.presence_penalty = other.presence_penalty;
270        }
271        if self.min_p.is_none() {
272            self.min_p = other.min_p;
273        }
274    }
275
276    /// Resolve an ordered list of sampling layers (highest priority first)
277    /// into a single fully-resolved config, then fill anything still unset
278    /// from `floor`.
279    ///
280    /// This is the one fold every multi-layer resolution surface goes
281    /// through: [`resolve_with_profile`] wraps it for the simple
282    /// request/profile/model/global shape, and
283    /// [`crate::request_pipeline::sampling`] builds its own five-layer
284    /// (cli/client/profile/model/global) array and calls it directly. There
285    /// is exactly one place that decides what "wins" means.
286    ///
287    /// # Uncoupled parameters
288    ///
289    /// `top_p`, `top_k`, and `max_tokens` gap-fill independently: each takes
290    /// the first `Some` value found scanning the layers top to bottom.
291    ///
292    /// # Coupled parameters
293    ///
294    /// `presence_penalty`, `repeat_penalty` and `min_p` are only meaningful
295    /// relative to how sharp the sampling distribution is, so they travel with
296    /// the `temperature` they were chosen for. [`reasoning_profile`] pairs
297    /// `temperature 1.0` with `presence_penalty 1.5` deliberately; a sparse
298    /// profile that sets `temperature 0.2` and leaves the penalty unset must
299    /// not inherit that `1.5` — that would run a recipe no layer ever
300    /// intended, a penalty tuned for a broad distribution applied to a
301    /// near-greedy one.
302    ///
303    /// So: `temperature` resolves to the first layer that sets one. If some
304    /// layer does, the coupled trio comes *only* from that same layer — never
305    /// a layer beneath it — falling to `floor` for anything that layer itself
306    /// left unset. If **no** layer sets a temperature at all, nothing has been
307    /// tuned against anything, so the coupled trio gap-fills normally, exactly
308    /// like the uncoupled parameters.
309    ///
310    /// [`resolve_with_profile`]: Self::resolve_with_profile
311    /// [`reasoning_profile`]: Self::reasoning_profile
312    #[must_use]
313    pub fn resolve_layers(layers: &[Option<&Self>], floor: &Self) -> Self {
314        Self::resolve_layers_with_sources(layers, floor).0
315    }
316
317    /// [`resolve_layers`] plus a record of which layer supplied each field.
318    ///
319    /// This is the implementation; [`resolve_layers`] delegates here and
320    /// discards the provenance. Values and provenance therefore come from one
321    /// pass over one ladder and cannot disagree — a second function that
322    /// re-derived the rules would eventually explain a decision the resolution
323    /// did not take, which is exactly what the `describe_provenance` helper
324    /// this replaced had already started doing.
325    ///
326    /// See [`FieldSources`] for how to read the result, and [`resolve_layers`]
327    /// for the coupling rule the sources reflect.
328    ///
329    /// [`resolve_layers`]: Self::resolve_layers
330    #[must_use]
331    pub fn resolve_layers_with_sources(
332        layers: &[Option<&Self>],
333        floor: &Self,
334    ) -> (Self, FieldSources) {
335        // Index into `layers` — not into the flattened iterator — so a caller
336        // can map it back to the name it gave that rung.
337        let first = |declares: &dyn Fn(&Self) -> bool| -> Option<usize> {
338            layers.iter().position(|l| l.is_some_and(declares))
339        };
340
341        let mut result = Self::default();
342
343        // Uncoupled: each takes the first layer that names it, independently.
344        let top_p = first(&|c| c.top_p.is_some());
345        let top_k = first(&|c| c.top_k.is_some());
346        let max_tokens = first(&|c| c.max_tokens.is_some());
347        let temperature = first(&|c| c.temperature.is_some());
348
349        result.top_p = top_p.and_then(|i| layers[i].and_then(|c| c.top_p));
350        result.top_k = top_k.and_then(|i| layers[i].and_then(|c| c.top_k));
351        result.max_tokens = max_tokens.and_then(|i| layers[i].and_then(|c| c.max_tokens));
352        result.temperature = temperature.and_then(|i| layers[i].and_then(|c| c.temperature));
353
354        // Coupled: the layer claiming `temperature` supplies the whole trio,
355        // including the fields it left unset — those drop to the floor rather
356        // than inheriting a value tuned for a temperature nobody chose.
357        let (repeat_penalty, presence_penalty, min_p) = if let Some(claim) = temperature {
358            let c = layers[claim].expect("index came from a Some layer");
359            result.repeat_penalty = c.repeat_penalty;
360            result.presence_penalty = c.presence_penalty;
361            result.min_p = c.min_p;
362            (
363                c.repeat_penalty.and(Some(claim)),
364                c.presence_penalty.and(Some(claim)),
365                c.min_p.and(Some(claim)),
366            )
367        } else {
368            // Nothing was tuned against anything, so the trio gap-fills like
369            // any uncoupled parameter.
370            let repeat_penalty = first(&|c| c.repeat_penalty.is_some());
371            let presence_penalty = first(&|c| c.presence_penalty.is_some());
372            let min_p = first(&|c| c.min_p.is_some());
373            result.repeat_penalty =
374                repeat_penalty.and_then(|i| layers[i].and_then(|c| c.repeat_penalty));
375            result.presence_penalty =
376                presence_penalty.and_then(|i| layers[i].and_then(|c| c.presence_penalty));
377            result.min_p = min_p.and_then(|i| layers[i].and_then(|c| c.min_p));
378            (repeat_penalty, presence_penalty, min_p)
379        };
380
381        result.merge_with(floor);
382
383        // A field no layer claimed came from the floor — or from nowhere, when
384        // the floor has none either (only `max_tokens`). When a layer claimed
385        // the temperature, the trio's fall-through is the coupling rule at
386        // work rather than a plain absence, and says so.
387        let coupled = temperature.is_some();
388        let source = |won: Option<usize>, has_floor: bool, is_coupled: bool| match won {
389            Some(i) => ParamSource::Layer(i),
390            None if !has_floor => ParamSource::Unset,
391            None if is_coupled => ParamSource::FloorCoupled,
392            None => ParamSource::Floor,
393        };
394
395        let sources = FieldSources {
396            temperature: source(temperature, floor.temperature.is_some(), false),
397            top_p: source(top_p, floor.top_p.is_some(), false),
398            top_k: source(top_k, floor.top_k.is_some(), false),
399            presence_penalty: source(presence_penalty, floor.presence_penalty.is_some(), coupled),
400            repeat_penalty: source(repeat_penalty, floor.repeat_penalty.is_some(), coupled),
401            min_p: source(min_p, floor.min_p.is_some(), coupled),
402            max_tokens: source(max_tokens, floor.max_tokens.is_some(), false),
403        };
404
405        (result, sources)
406    }
407
408    /// Create a new config with all fields set to sensible defaults.
409    ///
410    /// These are the hardcoded fallback values used when no other
411    /// defaults are configured.
412    ///
413    /// # `max_tokens` has no fallback
414    ///
415    /// It is deliberately `None`. Resolution force-writes every `Some` field
416    /// into the outgoing request, so a value here would cap *every* request
417    /// that did not name its own — silently truncating long answers. Left
418    /// unset, no `max_tokens` key is emitted and llama-server applies its own
419    /// `n_predict` default of `-1`, generating until a stop token or the
420    /// context limit.
421    ///
422    /// Omitting the key is exactly equivalent to sending `-1` (llama.cpp's
423    /// `has_budget()` treats `-1` as limitless) and is the better of the two:
424    /// `max_tokens: -1` is invalid under the `OpenAI` schema, which requires a
425    /// positive integer, so a strict client or intermediary proxy may reject
426    /// it. Omission keeps the forwarded body `OpenAI`-legal.
427    ///
428    /// Explicit per-request, per-profile, and per-model values are unaffected —
429    /// [`reasoning_profile`] still sets its own ceiling.
430    ///
431    /// [`reasoning_profile`]: Self::reasoning_profile
432    #[must_use]
433    pub const fn with_hardcoded_defaults() -> Self {
434        Self {
435            temperature: Some(0.7),
436            top_p: Some(0.95),
437            top_k: Some(40),
438            max_tokens: None,
439            repeat_penalty: Some(1.0),
440            presence_penalty: Some(0.0),
441            min_p: Some(0.0),
442        }
443    }
444
445    /// The coupled-trio floor for models tagged `reasoning`.
446    ///
447    /// [`resolve_layers`] falls back to a floor once it has decided which
448    /// layer (if any) claims the coupled trio and that layer left a field
449    /// unset. [`with_hardcoded_defaults`]'s neutral `presence_penalty: 0.0` is
450    /// the right floor for most models, but wrong for a `reasoning`-tagged
451    /// one: those degrade under greedy or near-greedy decoding into
452    /// repetitive reasoning loops (see [`reasoning_profile`], which pairs
453    /// `presence_penalty: 1.5` with `temperature: 1.0` specifically to
454    /// prevent this). `1.0` keeps a real guard in place at the floor without
455    /// asserting the full recipe tuned for a different temperature.
456    ///
457    /// [`resolve_layers`]: Self::resolve_layers
458    /// [`with_hardcoded_defaults`]: Self::with_hardcoded_defaults
459    /// [`reasoning_profile`]: Self::reasoning_profile
460    #[must_use]
461    pub const fn reasoning_floor() -> Self {
462        Self {
463            presence_penalty: Some(1.0),
464            ..Self::with_hardcoded_defaults()
465        }
466    }
467
468    /// Convert inference config to llama CLI arguments.
469    ///
470    /// Returns a vector of argument strings suitable for passing to llama-server.
471    /// Uses the same flag names as llama.cpp: `--temp`, `--top-p`, `--top-k`, `-n`, `--repeat-penalty`.
472    ///
473    /// This is the single source of truth for CLI flag conversion, reached by
474    /// every launch surface through `build_server_config` and
475    /// `ServerConfig.extra_args`.
476    ///
477    /// # Example
478    ///
479    /// ```rust
480    /// use gglib_core::domain::InferenceConfig;
481    ///
482    /// let config = InferenceConfig {
483    ///     temperature: Some(0.8),
484    ///     top_p: Some(0.9),
485    ///     top_k: None,
486    ///     max_tokens: Some(1024),
487    ///     repeat_penalty: None,
488    ///     presence_penalty: None,
489    ///     min_p: None,
490    /// };
491    ///
492    /// let args = config.to_cli_args();
493    /// assert_eq!(args, vec!["--temp", "0.8", "--top-p", "0.9", "-n", "1024"]);
494    /// ```
495    #[must_use]
496    pub fn to_cli_args(&self) -> Vec<String> {
497        let mut args = Vec::new();
498
499        if let Some(temp) = self.temperature {
500            args.push("--temp".to_string());
501            args.push(temp.to_string());
502        }
503        if let Some(top_p) = self.top_p {
504            args.push("--top-p".to_string());
505            args.push(top_p.to_string());
506        }
507        if let Some(top_k) = self.top_k {
508            args.push("--top-k".to_string());
509            args.push(top_k.to_string());
510        }
511        if let Some(max_tokens) = self.max_tokens {
512            args.push("-n".to_string());
513            args.push(max_tokens.to_string());
514        }
515        if let Some(repeat_penalty) = self.repeat_penalty {
516            args.push("--repeat-penalty".to_string());
517            args.push(repeat_penalty.to_string());
518        }
519        if let Some(presence_penalty) = self.presence_penalty {
520            args.push("--presence-penalty".to_string());
521            args.push(presence_penalty.to_string());
522        }
523        if let Some(min_p) = self.min_p {
524            args.push("--min-p".to_string());
525            args.push(min_p.to_string());
526        }
527
528        args
529    }
530
531    /// Return a recommended [`InferenceConfig`] profile for reasoning / thinking models.
532    ///
533    /// Applied automatically at import time when the `"reasoning"` capability tag is
534    /// detected (e.g. Qwen3.6, `DeepSeek-R1`, `QwQ`). Values follow the Qwen3.6 upstream
535    /// guidance for **thinking mode — general tasks** and are conservative enough to
536    /// work well across all thinking-capable models.
537    ///
538    /// | Parameter | Value | Rationale |
539    /// |-----------|-------|-----------|
540    /// | `temperature` | 1.0 | Recommended thinking-mode baseline |
541    /// | `top_p` | 0.95 | Broad nucleus; standard for reasoning |
542    /// | `top_k` | 20 | Tighter than the 40 fallback; suppresses low-quality tokens |
543    /// | `max_tokens` | 8192 | Safe out-of-the-box ceiling; increase for complex tasks |
544    /// | `repeat_penalty` | 1.0 | No penalty; `presence_penalty` handles anti-repetition |
545    /// | `presence_penalty` | 1.5 | Prevents repetitive reasoning loops |
546    /// | `min_p` | 0.0 | Explicitly disabled per Qwen3.6 spec |
547    ///
548    /// Users can override any parameter with `gglib model update <id> --<flag>` or
549    /// the equivalent UI control.
550    #[must_use]
551    pub const fn reasoning_profile() -> Self {
552        Self {
553            temperature: Some(1.0),
554            top_p: Some(0.95),
555            top_k: Some(20),
556            max_tokens: Some(8192),
557            repeat_penalty: Some(1.0),
558            presence_penalty: Some(1.5),
559            min_p: Some(0.0),
560        }
561    }
562
563    /// Resolve inference parameters using the 4-level hierarchy.
564    ///
565    /// Equivalent to [`resolve_with_profile`] with no profile selected — see
566    /// there for the merge order. This is the entry point for surfaces that
567    /// have no notion of a named profile (`gglib serve`, `gglib chat`,
568    /// `gglib q`, the Web UI chat API).
569    ///
570    /// `model_ctx` carries the two facts about the target model that change
571    /// how resolution behaves — see [`ModelSamplingContext`],
572    /// [`resolve_layers`] and [`reasoning_floor`].
573    ///
574    /// # Example
575    ///
576    /// ```rust
577    /// use gglib_core::domain::{InferenceConfig, ModelSamplingContext};
578    ///
579    /// let request = InferenceConfig { temperature: Some(0.9), ..Default::default() };
580    /// let model   = InferenceConfig { temperature: Some(0.5), top_p: Some(0.8), ..Default::default() };
581    ///
582    /// let resolved = request.resolve_with_defaults(Some(&model), None, ModelSamplingContext::default());
583    /// assert_eq!(resolved.temperature, Some(0.9)); // request wins
584    /// assert_eq!(resolved.top_p,       Some(0.8)); // model fills in
585    /// assert_eq!(resolved.top_k,       Some(40));  // hardcoded fallback
586    /// ```
587    ///
588    /// [`resolve_with_profile`]: Self::resolve_with_profile
589    /// [`resolve_layers`]: Self::resolve_layers
590    /// [`reasoning_floor`]: Self::reasoning_floor
591    #[must_use]
592    pub fn resolve_with_defaults(
593        self,
594        model: Option<&Self>,
595        global: Option<&Self>,
596        model_ctx: ModelSamplingContext,
597    ) -> Self {
598        self.resolve_with_profile(None, model, global, model_ctx)
599    }
600
601    /// Resolve inference parameters using the full 5-level hierarchy.
602    ///
603    /// Applies fallback layers in order, with each layer filling only `None`
604    /// fields from `self` — explicit values are never overwritten:
605    ///
606    /// 1. `self` — caller-supplied overrides (request params, CLI flags, etc.)
607    /// 2. `profile` — the named profile the request selected, if any
608    /// 3. `model` — per-model stored defaults, *if user-set*
609    /// 4. `global` — global settings defaults
610    /// 5. `model` again, *if auto-detected* — see below
611    /// 6. the model-class floor — [`reasoning_floor`] when
612    ///    `model_ctx.is_reasoning`, otherwise [`with_hardcoded_defaults`]
613    ///
614    /// This is the single source of truth for inference parameter resolution
615    /// across every gglib surface that does not need its own layer set;
616    /// [`resolve_with_defaults`] delegates here so there is exactly one merge
617    /// order to reason about and to test.
618    /// [`crate::request_pipeline::sampling`] needs a seventh layer (the
619    /// client's own request, sitting between `self` and `profile`) and calls
620    /// the underlying [`resolve_layers`] directly for that reason — the merge
621    /// semantics are identical either way.
622    ///
623    /// # Why the profile sits above the model
624    ///
625    /// Selecting `model:coding` is an explicit act by the caller, so it has to
626    /// beat the model's stored defaults or it would appear to do nothing on any
627    /// model that has them. Because profiles are *sparse* (see
628    /// [`crate::domain::inference_profile`]), outranking the model layer costs
629    /// nothing for parameters the profile does not set — those still resolve
630    /// from the model, which is what keeps one global profile safe to apply
631    /// across differing architectures.
632    ///
633    /// # Why `model` can rank below `global`
634    ///
635    /// `model` is only ever a stand-in for `Model.inference_defaults`, which
636    /// gets written two different ways (see [`DefaultsOrigin`]): a person
637    /// tuning it deliberately, or gglib's own import-time guess for any
638    /// model tagged `reasoning`. Those deserve different authority. A
639    /// deliberate per-model choice should keep outranking the operator's
640    /// global defaults — that is what "per-model" means. A guess nobody
641    /// reviewed should not: it silently shadowed the user's own configured
642    /// global settings, which is how #685 happened. `model_ctx.defaults_origin`
643    /// decides which rung `model` occupies for this call — never both at
644    /// once, since only one of rungs 3 and 5 is ever populated for a given
645    /// model.
646    ///
647    /// # Temperature-tuned parameters do not fall through
648    ///
649    /// See [`resolve_layers`] for the full rule. In short: once a layer
650    /// declares a `temperature`, lower layers may not contribute
651    /// `presence_penalty`, `repeat_penalty` or `min_p` — those resolve from
652    /// the claiming layer alone, falling to the class floor if it left them
653    /// unset.
654    ///
655    /// # Example
656    ///
657    /// ```rust
658    /// use gglib_core::domain::{InferenceConfig, ModelSamplingContext};
659    ///
660    /// // A sparse profile: sets temperature, says nothing about anything else.
661    /// let profile = InferenceConfig { temperature: Some(0.2), ..Default::default() };
662    /// // A thinking model's stored defaults: 1.5 is tuned for temperature 1.0.
663    /// let model = InferenceConfig {
664    ///     temperature: Some(1.0),
665    ///     presence_penalty: Some(1.5),
666    ///     top_k: Some(20),
667    ///     ..Default::default()
668    /// };
669    /// let model_ctx = ModelSamplingContext { is_reasoning: true, ..Default::default() };
670    ///
671    /// let resolved = InferenceConfig::default()
672    ///     .resolve_with_profile(Some(&profile), Some(&model), None, model_ctx);
673    ///
674    /// assert_eq!(resolved.temperature,      Some(0.2)); // profile beats model
675    /// assert_eq!(resolved.presence_penalty, Some(1.0)); // reasoning floor, NOT the model's 1.5
676    /// assert_eq!(resolved.top_k,            Some(20));  // untuned: still fills
677    /// ```
678    ///
679    /// [`resolve_layers`]: Self::resolve_layers
680    /// [`reasoning_floor`]: Self::reasoning_floor
681    /// [`with_hardcoded_defaults`]: Self::with_hardcoded_defaults
682    /// [`resolve_with_defaults`]: Self::resolve_with_defaults
683    #[must_use]
684    pub fn resolve_with_profile(
685        self,
686        profile: Option<&Self>,
687        model: Option<&Self>,
688        global: Option<&Self>,
689        model_ctx: ModelSamplingContext,
690    ) -> Self {
691        self.resolve_with_profile_explained(profile, model, global, model_ctx)
692            .0
693    }
694
695    /// [`resolve_with_profile`] plus a record of which rung supplied each
696    /// field.
697    ///
698    /// This is the implementation; [`resolve_with_profile`] delegates here and
699    /// discards the provenance, so the ladder — including the user/auto rung
700    /// split and the floor selection — is built exactly once.
701    ///
702    /// Map a [`ParamSource::Layer`] index back to a rung with
703    /// [`SamplingLayer::from_index`], which is kept beside this ladder for
704    /// that purpose.
705    ///
706    /// [`resolve_with_profile`]: Self::resolve_with_profile
707    /// [`SamplingLayer::from_index`]: crate::domain::SamplingLayer::from_index
708    /// [`ParamSource::Layer`]: crate::domain::ParamSource::Layer
709    #[must_use]
710    pub fn resolve_with_profile_explained(
711        self,
712        profile: Option<&Self>,
713        model: Option<&Self>,
714        global: Option<&Self>,
715        model_ctx: ModelSamplingContext,
716    ) -> (Self, FieldSources) {
717        let floor = if model_ctx.is_reasoning {
718            Self::reasoning_floor()
719        } else {
720            Self::with_hardcoded_defaults()
721        };
722        let (user_model, auto_model) = match model_ctx.defaults_origin {
723            Some(DefaultsOrigin::AutoDetected) => (None, model),
724            _ => (model, None),
725        };
726        Self::resolve_layers_with_sources(
727            &[Some(&self), profile, user_model, global, auto_model],
728            &floor,
729        )
730    }
731
732    /// Parse inference parameters from an OpenAI-format JSON body (`snake_case` keys).
733    ///
734    /// Converts wire-format `snake_case` field names (`top_p`, `max_tokens`,
735    /// `repeat_penalty`, etc.) to the internal camelCase representation via
736    /// [`snake_to_camel`], then deserialises using the existing `serde` impl.
737    /// Unknown or missing fields default to `None`.
738    ///
739    /// This is the inverse of [`to_openai_json_patch`].
740    ///
741    /// [`to_openai_json_patch`]: Self::to_openai_json_patch
742    #[must_use]
743    pub fn from_openai_json(value: &serde_json::Value) -> Self {
744        let Some(obj) = value.as_object() else {
745            return Self::default();
746        };
747        let camel: serde_json::Map<String, serde_json::Value> = obj
748            .iter()
749            .map(|(k, v)| (snake_to_camel(k), v.clone()))
750            .collect();
751        serde_json::from_value(serde_json::Value::Object(camel)).unwrap_or_default()
752    }
753
754    /// Serialise as an OpenAI-format JSON patch (`snake_case` keys, `Some` fields only).
755    ///
756    /// Uses `serde` to produce the camelCase form, then renames each key to
757    /// `snake_case` via [`camel_to_snake`]. Only `Some` fields are emitted — `None`
758    /// values are filtered out. The returned map can be merged directly into an
759    /// OpenAI-compatible request body with `body_obj.insert(k, v)`.
760    ///
761    /// This is the inverse of [`from_openai_json`].
762    ///
763    /// [`from_openai_json`]: Self::from_openai_json
764    #[must_use]
765    pub fn to_openai_json_patch(&self) -> serde_json::Map<String, serde_json::Value> {
766        let camel = serde_json::to_value(self).unwrap_or_default();
767        camel
768            .as_object()
769            .into_iter()
770            .flatten()
771            .filter(|(_, v)| !v.is_null())
772            .map(|(k, v)| (camel_to_snake(k), v.clone()))
773            .collect()
774    }
775}
776
777#[cfg(test)]
778mod tests {
779    use super::*;
780
781    #[test]
782    fn test_default_is_all_none() {
783        let config = InferenceConfig::default();
784        assert!(config.temperature.is_none());
785        assert!(config.top_p.is_none());
786        assert!(config.top_k.is_none());
787        assert!(config.max_tokens.is_none());
788        assert!(config.repeat_penalty.is_none());
789        assert!(config.presence_penalty.is_none());
790        assert!(config.min_p.is_none());
791    }
792
793    #[test]
794    fn test_merge_with_prefers_self() {
795        let mut request = InferenceConfig {
796            temperature: Some(0.8),
797            top_p: None,
798            ..Default::default()
799        };
800
801        let model_defaults = InferenceConfig {
802            temperature: Some(0.5),
803            top_p: Some(0.9),
804            top_k: Some(50),
805            ..Default::default()
806        };
807
808        request.merge_with(&model_defaults);
809
810        assert_eq!(request.temperature, Some(0.8)); // Request wins
811        assert_eq!(request.top_p, Some(0.9)); // Fallback to model
812        assert_eq!(request.top_k, Some(50)); // Fallback to model
813        assert!(request.max_tokens.is_none()); // Still None
814    }
815
816    #[test]
817    fn test_hardcoded_defaults() {
818        let config = InferenceConfig::with_hardcoded_defaults();
819        assert_eq!(config.temperature, Some(0.7));
820        assert_eq!(config.top_p, Some(0.95));
821        assert_eq!(config.top_k, Some(40));
822        // Deliberately absent: a fallback here would cap every request that
823        // did not name its own. See `with_hardcoded_defaults`.
824        assert_eq!(config.max_tokens, None);
825        assert_eq!(config.repeat_penalty, Some(1.0));
826        assert_eq!(config.presence_penalty, Some(0.0));
827        assert_eq!(config.min_p, Some(0.0));
828    }
829
830    /// The reasoning floor differs from the hardcoded floor in exactly one
831    /// field — a real anti-repetition guard where the neutral floor has none.
832    #[test]
833    fn test_reasoning_floor_differs_only_in_presence_penalty() {
834        let neutral = InferenceConfig::with_hardcoded_defaults();
835        let reasoning = InferenceConfig::reasoning_floor();
836
837        assert_eq!(reasoning.presence_penalty, Some(1.0));
838        assert_ne!(reasoning.presence_penalty, neutral.presence_penalty);
839
840        assert_eq!(reasoning.temperature, neutral.temperature);
841        assert_eq!(reasoning.top_p, neutral.top_p);
842        assert_eq!(reasoning.top_k, neutral.top_k);
843        assert_eq!(reasoning.max_tokens, neutral.max_tokens);
844        assert_eq!(reasoning.repeat_penalty, neutral.repeat_penalty);
845        assert_eq!(reasoning.min_p, neutral.min_p);
846    }
847
848    /// If nothing in the stack ever declares a temperature, nothing has been
849    /// "tuned" against anything — the coupled trio must gap-fill exactly like
850    /// any other parameter, from whichever layer sets it first, rather than
851    /// jump straight to the floor.
852    #[test]
853    fn test_coupled_trio_gap_fills_normally_when_no_layer_sets_temperature() {
854        let profile = InferenceConfig {
855            presence_penalty: Some(0.3),
856            ..Default::default()
857        };
858        let model = InferenceConfig {
859            presence_penalty: Some(0.5),
860            repeat_penalty: Some(1.2),
861            ..Default::default()
862        };
863
864        let resolved = InferenceConfig::default().resolve_with_profile(
865            Some(&profile),
866            Some(&model),
867            None,
868            ModelSamplingContext::default(),
869        );
870
871        assert_eq!(resolved.temperature, Some(0.7), "hardcoded fallback");
872        assert_eq!(
873            resolved.presence_penalty,
874            Some(0.3),
875            "profile's own value, not suppressed just because no layer set a temperature"
876        );
877        assert_eq!(
878            resolved.repeat_penalty,
879            Some(1.2),
880            "model fills in what the profile left unset"
881        );
882    }
883
884    /// The two ways an unset `max_tokens` could still reach llama-server and
885    /// cap generation: as a `max_tokens` key in the forwarded request body, or
886    /// as a `-n` flag on the launch command line. `-n` is the more dangerous of
887    /// the two — it sets `global_params.n_predict`, a server-wide ceiling that
888    /// overrides even a per-request `-1`.
889    #[test]
890    fn test_unset_max_tokens_reaches_llama_server_by_neither_route() {
891        let resolved = InferenceConfig::default().resolve_with_defaults(
892            None,
893            None,
894            ModelSamplingContext::default(),
895        );
896
897        assert!(
898            !resolved.to_openai_json_patch().contains_key("max_tokens"),
899            "an unset max_tokens must not be written into the request body"
900        );
901        assert!(
902            !resolved.to_cli_args().contains(&"-n".to_string()),
903            "an unset max_tokens must not become a server-wide -n ceiling"
904        );
905    }
906
907    /// An explicit value must still travel by both routes — this change removes
908    /// the *fallback*, not the parameter.
909    #[test]
910    fn test_explicit_max_tokens_is_still_forwarded() {
911        let resolved = InferenceConfig {
912            max_tokens: Some(512),
913            ..Default::default()
914        }
915        .resolve_with_defaults(None, None, ModelSamplingContext::default());
916
917        assert_eq!(resolved.max_tokens, Some(512));
918        assert_eq!(
919            resolved.to_openai_json_patch().get("max_tokens"),
920            Some(&serde_json::json!(512))
921        );
922        let args = resolved.to_cli_args();
923        let n_index = args.iter().position(|a| a == "-n").expect("-n emitted");
924        assert_eq!(args[n_index + 1], "512");
925    }
926
927    #[test]
928    fn test_reasoning_profile() {
929        let profile = InferenceConfig::reasoning_profile();
930        assert_eq!(profile.temperature, Some(1.0));
931        assert_eq!(profile.top_p, Some(0.95));
932        assert_eq!(profile.top_k, Some(20));
933        assert_eq!(profile.max_tokens, Some(8192));
934        assert_eq!(profile.repeat_penalty, Some(1.0));
935        assert_eq!(profile.presence_penalty, Some(1.5));
936        assert_eq!(profile.min_p, Some(0.0));
937    }
938
939    #[test]
940    fn test_serialization() {
941        let config = InferenceConfig {
942            temperature: Some(0.7),
943            top_p: Some(0.9),
944            top_k: None,
945            max_tokens: Some(1024),
946            repeat_penalty: None,
947            presence_penalty: None,
948            min_p: None,
949        };
950
951        let json = serde_json::to_string(&config).unwrap();
952        let deserialized: InferenceConfig = serde_json::from_str(&json).unwrap();
953
954        assert_eq!(config, deserialized);
955    }
956
957    #[test]
958    fn test_camel_to_snake() {
959        assert_eq!(camel_to_snake("temperature"), "temperature");
960        assert_eq!(camel_to_snake("topP"), "top_p");
961        assert_eq!(camel_to_snake("topK"), "top_k");
962        assert_eq!(camel_to_snake("maxTokens"), "max_tokens");
963        assert_eq!(camel_to_snake("repeatPenalty"), "repeat_penalty");
964        assert_eq!(camel_to_snake("presencePenalty"), "presence_penalty");
965        assert_eq!(camel_to_snake("minP"), "min_p");
966    }
967
968    #[test]
969    fn test_snake_to_camel() {
970        assert_eq!(snake_to_camel("temperature"), "temperature");
971        assert_eq!(snake_to_camel("top_p"), "topP");
972        assert_eq!(snake_to_camel("top_k"), "topK");
973        assert_eq!(snake_to_camel("max_tokens"), "maxTokens");
974        assert_eq!(snake_to_camel("repeat_penalty"), "repeatPenalty");
975        assert_eq!(snake_to_camel("presence_penalty"), "presencePenalty");
976        assert_eq!(snake_to_camel("min_p"), "minP");
977    }
978
979    #[test]
980    fn test_resolve_with_defaults_hierarchy() {
981        let request = InferenceConfig {
982            temperature: Some(0.9),
983            ..Default::default()
984        };
985        let model = InferenceConfig {
986            temperature: Some(0.5),
987            top_p: Some(0.8),
988            ..Default::default()
989        };
990        let global = InferenceConfig {
991            top_k: Some(10),
992            ..Default::default()
993        };
994
995        let resolved = request.resolve_with_defaults(
996            Some(&model),
997            Some(&global),
998            ModelSamplingContext::default(),
999        );
1000
1001        assert_eq!(resolved.temperature, Some(0.9)); // request wins
1002        assert_eq!(resolved.top_p, Some(0.8)); // model fills in
1003        assert_eq!(resolved.top_k, Some(10)); // global fills in
1004        assert_eq!(resolved.max_tokens, None); // no layer sets it; stays unset
1005        assert_eq!(resolved.repeat_penalty, Some(1.0)); // hardcoded fallback
1006    }
1007
1008    #[test]
1009    fn test_resolve_with_defaults_no_layers() {
1010        let base = InferenceConfig::default();
1011        let resolved = base.resolve_with_defaults(None, None, ModelSamplingContext::default());
1012        // Should equal hardcoded defaults
1013        assert_eq!(resolved, InferenceConfig::with_hardcoded_defaults());
1014    }
1015
1016    /// Every layer contributes exactly one distinguishable parameter, so a
1017    /// single assertion set pins the whole precedence ladder.
1018    #[test]
1019    fn test_resolve_with_profile_full_precedence_ladder() {
1020        let request = InferenceConfig {
1021            temperature: Some(0.9),
1022            ..Default::default()
1023        };
1024        let profile = InferenceConfig {
1025            temperature: Some(0.2),
1026            top_p: Some(0.85),
1027            ..Default::default()
1028        };
1029        let model = InferenceConfig {
1030            temperature: Some(0.5),
1031            top_p: Some(0.8),
1032            presence_penalty: Some(1.5),
1033            ..Default::default()
1034        };
1035        let global = InferenceConfig {
1036            top_k: Some(10),
1037            ..Default::default()
1038        };
1039
1040        let resolved = request.resolve_with_profile(
1041            Some(&profile),
1042            Some(&model),
1043            Some(&global),
1044            ModelSamplingContext::default(),
1045        );
1046
1047        assert_eq!(resolved.temperature, Some(0.9)); // request beats profile
1048        assert_eq!(resolved.top_p, Some(0.85)); // profile beats model
1049        assert_eq!(resolved.top_k, Some(10)); // global fills in
1050        // The request claimed the temperature, so the model's 1.5 — tuned for
1051        // its own 0.5 — must not fall through. Neutral hardcoded value instead.
1052        assert_eq!(resolved.presence_penalty, Some(0.0));
1053        assert_eq!(resolved.repeat_penalty, Some(1.0)); // hardcoded fallback
1054    }
1055
1056    /// The invariant that makes one global profile safe across differing
1057    /// architectures: parameters the profile leaves `None` still resolve from
1058    /// the model, so selecting a profile cannot erase per-model tuning.
1059    ///
1060    /// The exception is parameters tuned against temperature — see
1061    /// [`test_profile_temperature_does_not_inherit_model_penalties`].
1062    #[test]
1063    fn test_sparse_profile_does_not_erase_model_defaults() {
1064        let profile = InferenceConfig {
1065            temperature: Some(0.2),
1066            ..Default::default()
1067        };
1068        let model = InferenceConfig::reasoning_profile();
1069
1070        let resolved = InferenceConfig::default().resolve_with_profile(
1071            Some(&profile),
1072            Some(&model),
1073            None,
1074            ModelSamplingContext::default(),
1075        );
1076
1077        assert_eq!(resolved.temperature, Some(0.2)); // the profile's one opinion
1078        // Untuned parameters the profile stayed silent about still come from
1079        // the model — this is what keeps one profile safe across architectures.
1080        assert_eq!(resolved.top_k, model.top_k);
1081        assert_eq!(resolved.top_p, model.top_p);
1082        assert_eq!(resolved.max_tokens, model.max_tokens);
1083    }
1084
1085    /// Regression for #621: a sparse profile that lowers the temperature must
1086    /// not inherit penalties the model tuned for a much broader distribution.
1087    ///
1088    /// `reasoning_profile()` pairs `temperature 1.0` with `presence_penalty
1089    /// 1.5` deliberately. Applying that 1.5 to a near-greedy `temperature 0.2`
1090    /// request is a recipe no layer ever intended, and it reached production on
1091    /// every `:coding` request.
1092    ///
1093    /// The #621 fix originally floored `presence_penalty` to the universal
1094    /// neutral `0.0` here — correct in that it stopped the wrong transplant,
1095    /// but it also zeroed the model's only anti-repetition guard on a
1096    /// reasoning model, which is a second failure mode of its own (see the
1097    /// 2026-07-31 incident this floor was added for). `model_is_reasoning:
1098    /// true` selects [`InferenceConfig::reasoning_floor`] instead, which keeps
1099    /// a real, non-tuned-for-0.2 guard in place.
1100    #[test]
1101    fn test_profile_temperature_does_not_inherit_model_penalties() {
1102        let model = InferenceConfig::reasoning_profile();
1103        assert_eq!(model.temperature, Some(1.0), "guards the premise");
1104        assert_eq!(model.presence_penalty, Some(1.5), "guards the premise");
1105
1106        // Mirrors the shipped `coding` profile.
1107        let profile = InferenceConfig {
1108            temperature: Some(0.2),
1109            top_p: Some(0.95),
1110            top_k: Some(20),
1111            max_tokens: Some(8192),
1112            min_p: Some(0.05),
1113            ..Default::default()
1114        };
1115
1116        let resolved = InferenceConfig::default().resolve_with_profile(
1117            Some(&profile),
1118            Some(&model),
1119            None,
1120            ModelSamplingContext {
1121                is_reasoning: true,
1122                ..Default::default()
1123            },
1124        );
1125
1126        assert_eq!(resolved.temperature, Some(0.2));
1127        assert_eq!(
1128            resolved.presence_penalty,
1129            Some(1.0),
1130            "must not inherit 1.5, but must not go silently to zero either"
1131        );
1132        assert_eq!(
1133            resolved.repeat_penalty,
1134            Some(1.0),
1135            "neutral, not the model's"
1136        );
1137        assert_eq!(resolved.min_p, Some(0.05), "the profile's own value stands");
1138    }
1139
1140    /// The coupling is directional: a layer that supplies a temperature *and*
1141    /// its penalties still contributes them together, so a coherent recipe
1142    /// stored on a model is untouched when nothing above it sets a temperature.
1143    #[test]
1144    fn test_model_recipe_applies_intact_when_no_layer_sets_temperature() {
1145        let model = InferenceConfig::reasoning_profile();
1146        // A profile with opinions only about untuned parameters.
1147        let profile = InferenceConfig {
1148            top_k: Some(64),
1149            ..Default::default()
1150        };
1151
1152        let resolved = InferenceConfig::default().resolve_with_profile(
1153            Some(&profile),
1154            Some(&model),
1155            None,
1156            ModelSamplingContext {
1157                is_reasoning: true,
1158                ..Default::default()
1159            },
1160        );
1161
1162        assert_eq!(resolved.temperature, model.temperature);
1163        assert_eq!(resolved.presence_penalty, model.presence_penalty);
1164        assert_eq!(resolved.repeat_penalty, model.repeat_penalty);
1165        assert_eq!(resolved.top_k, Some(64)); // profile still wins where it spoke
1166    }
1167
1168    /// `resolve_with_defaults` delegates to `resolve_with_profile`, so the two
1169    /// must stay observably identical when no profile is selected.
1170    #[test]
1171    fn test_resolve_with_defaults_matches_profile_form_with_no_profile() {
1172        let request = InferenceConfig {
1173            temperature: Some(0.9),
1174            ..Default::default()
1175        };
1176        let model = InferenceConfig::reasoning_profile();
1177        let global = InferenceConfig {
1178            top_k: Some(10),
1179            ..Default::default()
1180        };
1181
1182        assert_eq!(
1183            request.clone().resolve_with_defaults(
1184                Some(&model),
1185                Some(&global),
1186                ModelSamplingContext::default()
1187            ),
1188            request.resolve_with_profile(
1189                None,
1190                Some(&model),
1191                Some(&global),
1192                ModelSamplingContext::default()
1193            ),
1194        );
1195    }
1196
1197    // ── Provenance agrees with the values ─────────────────────────────────
1198
1199    /// Assert, for every field, that the reported source actually accounts for
1200    /// the resolved value.
1201    ///
1202    /// This is the invariant that makes the two impossible to drift apart, and
1203    /// it is the check that would have caught the `describe_provenance`
1204    /// divergence this API replaced: a `Layer(i)` claim is only true if that
1205    /// layer really carries the resolved value.
1206    /// Field name, the value that resolved, and how to read that field off any
1207    /// layer — enough to check a reported source against reality.
1208    type FieldCheck = (
1209        &'static str,
1210        Option<f32>,
1211        fn(&InferenceConfig) -> Option<f32>,
1212    );
1213
1214    #[track_caller]
1215    fn assert_sources_explain_values(layers: &[Option<&InferenceConfig>], floor: &InferenceConfig) {
1216        let (resolved, sources) = InferenceConfig::resolve_layers_with_sources(layers, floor);
1217
1218        let checks: [FieldCheck; 5] = [
1219            ("temperature", resolved.temperature, |c| c.temperature),
1220            ("top_p", resolved.top_p, |c| c.top_p),
1221            ("presence_penalty", resolved.presence_penalty, |c| {
1222                c.presence_penalty
1223            }),
1224            ("repeat_penalty", resolved.repeat_penalty, |c| {
1225                c.repeat_penalty
1226            }),
1227            ("min_p", resolved.min_p, |c| c.min_p),
1228        ];
1229
1230        for (name, value, get) in checks {
1231            let source = sources
1232                .iter()
1233                .find(|(field, _)| *field == name)
1234                .expect("field is reported")
1235                .1;
1236            match source {
1237                ParamSource::Layer(i) => {
1238                    let layer = layers[i].expect("a named layer is populated");
1239                    assert_eq!(get(layer), value, "{name}: layer {i} must carry the value");
1240                }
1241                ParamSource::Floor | ParamSource::FloorCoupled => {
1242                    assert_eq!(get(floor), value, "{name}: must equal the floor");
1243                }
1244                ParamSource::Unset => assert_eq!(value, None, "{name}: must be absent"),
1245            }
1246        }
1247    }
1248
1249    /// Across the shapes the tests above exercise individually, plus the
1250    /// coupling-rule cases, provenance must account for every resolved value.
1251    #[test]
1252    fn test_sources_always_account_for_the_resolved_values() {
1253        let sparse_profile = InferenceConfig {
1254            temperature: Some(0.2),
1255            ..Default::default()
1256        };
1257        let recipe = InferenceConfig::reasoning_profile();
1258        let penalty_only = InferenceConfig {
1259            presence_penalty: Some(1.2),
1260            ..Default::default()
1261        };
1262        let global = InferenceConfig {
1263            top_k: Some(10),
1264            min_p: Some(0.05),
1265            ..Default::default()
1266        };
1267
1268        let ladders: [[Option<&InferenceConfig>; 4]; 6] = [
1269            // Nothing at all — everything falls to the floor.
1270            [None, None, None, None],
1271            // A sparse profile over a full recipe: the coupling rule fires.
1272            [None, Some(&sparse_profile), Some(&recipe), None],
1273            // The recipe alone, unclaimed from above.
1274            [None, None, Some(&recipe), None],
1275            // The drift case: a penalty above a temperature claim below it.
1276            [Some(&penalty_only), None, Some(&recipe), None],
1277            // No layer names a temperature — the trio gap-fills normally.
1278            [Some(&penalty_only), None, None, Some(&global)],
1279            // Every rung populated.
1280            [
1281                Some(&penalty_only),
1282                Some(&sparse_profile),
1283                Some(&recipe),
1284                Some(&global),
1285            ],
1286        ];
1287
1288        for floor in [
1289            InferenceConfig::with_hardcoded_defaults(),
1290            InferenceConfig::reasoning_floor(),
1291        ] {
1292            for ladder in &ladders {
1293                assert_sources_explain_values(ladder, &floor);
1294            }
1295        }
1296    }
1297
1298    /// `max_tokens` is the one parameter with no floor value, so an untouched
1299    /// ladder reports it as genuinely unset rather than as a floor default.
1300    #[test]
1301    fn test_max_tokens_reports_unset_rather_than_floor() {
1302        let (_, sources) = InferenceConfig::resolve_layers_with_sources(
1303            &[None],
1304            &InferenceConfig::with_hardcoded_defaults(),
1305        );
1306        assert_eq!(sources.max_tokens, ParamSource::Unset);
1307        // Every other field does have a floor to fall back on.
1308        assert_eq!(sources.temperature, ParamSource::Floor);
1309    }
1310
1311    /// The two floor variants are distinguishable: a trio suppressed by the
1312    /// coupling rule must not look the same as one nobody ever set.
1313    #[test]
1314    fn test_coupled_suppression_is_distinguishable_from_plain_absence() {
1315        let claim = InferenceConfig {
1316            temperature: Some(0.2),
1317            ..Default::default()
1318        };
1319        let floor = InferenceConfig::with_hardcoded_defaults();
1320
1321        let (_, claimed) = InferenceConfig::resolve_layers_with_sources(&[Some(&claim)], &floor);
1322        assert_eq!(claimed.presence_penalty, ParamSource::FloorCoupled);
1323
1324        let (_, untouched) = InferenceConfig::resolve_layers_with_sources(&[None], &floor);
1325        assert_eq!(untouched.presence_penalty, ParamSource::Floor);
1326    }
1327
1328    /// `resolve_with_profile` delegates to the explained form, so the two must
1329    /// agree on the value, and the ladder indices must match `SamplingLayer`.
1330    #[test]
1331    fn test_resolve_with_profile_explained_matches_the_plain_form() {
1332        let profile = InferenceConfig {
1333            temperature: Some(0.2),
1334            ..Default::default()
1335        };
1336        let model = InferenceConfig::reasoning_profile();
1337        let ctx = ModelSamplingContext {
1338            is_reasoning: true,
1339            defaults_origin: Some(DefaultsOrigin::User),
1340        };
1341
1342        let plain = InferenceConfig::default().resolve_with_profile(
1343            Some(&profile),
1344            Some(&model),
1345            None,
1346            ctx,
1347        );
1348        let (explained, sources) = InferenceConfig::default().resolve_with_profile_explained(
1349            Some(&profile),
1350            Some(&model),
1351            None,
1352            ctx,
1353        );
1354
1355        assert_eq!(plain, explained);
1356        // The profile sits at rung 1, and a user-set model at rung 2.
1357        assert_eq!(sources.temperature, ParamSource::Layer(1));
1358        assert_eq!(
1359            crate::domain::SamplingLayer::from_index(1),
1360            Some(crate::domain::SamplingLayer::Profile)
1361        );
1362        assert_eq!(sources.top_k, ParamSource::Layer(2));
1363        assert_eq!(
1364            crate::domain::SamplingLayer::from_index(2),
1365            Some(crate::domain::SamplingLayer::ModelUserSet)
1366        );
1367    }
1368
1369    /// An auto-detected recipe drops to rung 4, below global settings — the
1370    /// #685 ranking, now visible in the provenance rather than only in values.
1371    #[test]
1372    fn test_an_auto_detected_recipe_reports_the_lower_rung() {
1373        let model = InferenceConfig::reasoning_profile();
1374        let global = InferenceConfig {
1375            top_k: Some(10),
1376            ..Default::default()
1377        };
1378        let ctx = ModelSamplingContext {
1379            is_reasoning: true,
1380            defaults_origin: Some(DefaultsOrigin::AutoDetected),
1381        };
1382
1383        let (_, sources) = InferenceConfig::default().resolve_with_profile_explained(
1384            None,
1385            Some(&model),
1386            Some(&global),
1387            ctx,
1388        );
1389
1390        assert_eq!(sources.top_k, ParamSource::Layer(3), "global wins top_k");
1391        assert_eq!(
1392            sources.temperature,
1393            ParamSource::Layer(4),
1394            "the auto-detected recipe sits below global"
1395        );
1396    }
1397
1398    #[test]
1399    fn test_openai_json_roundtrip() {
1400        let config = InferenceConfig {
1401            temperature: Some(0.7),
1402            top_p: Some(0.9),
1403            repeat_penalty: Some(1.1),
1404            ..Default::default()
1405        };
1406        let patch = config.to_openai_json_patch();
1407
1408        // snake_case keys present for Some fields
1409        assert!(patch.contains_key("temperature"));
1410        assert!(patch.contains_key("top_p"));
1411        assert!(patch.contains_key("repeat_penalty"));
1412        // None fields absent
1413        assert!(!patch.contains_key("top_k"));
1414        assert!(!patch.contains_key("max_tokens"));
1415
1416        // Roundtrip via from_openai_json
1417        let val = serde_json::Value::Object(patch);
1418        let back = InferenceConfig::from_openai_json(&val);
1419        assert_eq!(back.temperature, Some(0.7));
1420        assert_eq!(back.top_p, Some(0.9));
1421        assert_eq!(back.repeat_penalty, Some(1.1));
1422        assert!(back.top_k.is_none());
1423    }
1424
1425    #[test]
1426    fn test_from_openai_json_unknown_fields_ignored() {
1427        let val = serde_json::json!({
1428            "temperature": 0.5,
1429            "model": "llama3",
1430            "messages": []
1431        });
1432        let config = InferenceConfig::from_openai_json(&val);
1433        assert_eq!(config.temperature, Some(0.5));
1434        assert!(config.top_p.is_none());
1435    }
1436}