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}