gglib_core/domain/sampling_provenance.rs
1//! Which layer supplied each resolved sampling parameter.
2//!
3//! ## Why this exists
4//!
5//! [`InferenceConfig::resolve_layers_with_sources`](crate::domain::InferenceConfig::resolve_layers_with_sources)
6//! folds an ordered ladder of sampling layers into one config under two rules
7//! that are individually defensible and jointly opaque: the coupled trio
8//! (`presence_penalty`, `repeat_penalty`, `min_p`) travels with the
9//! `temperature` it was tuned against, and a model's stored defaults rank
10//! above or below global settings depending on whether a person set them.
11//!
12//! The resolved numbers alone cannot distinguish a value someone chose from
13//! one that fell out of a floor. `0.0` is a number; "`0.0`, from the floor,
14//! because the profile claimed the temperature" is an explanation, and only
15//! the second makes the behaviour auditable.
16//!
17//! ## One computation, not two
18//!
19//! [`FieldSources`] is produced by
20//! [`resolve_layers_with_sources`](crate::domain::InferenceConfig::resolve_layers_with_sources),
21//! the same pass that decides the values — never by a second function that
22//! re-derives the rules. That is deliberate: this provenance previously lived
23//! in a separate `describe_provenance` helper in the request pipeline, and the
24//! two implementations had already drifted. A ladder where `cli` supplied a
25//! `presence_penalty` and a lower layer claimed the `temperature` resolved the
26//! penalty from the claiming layer while the log named `cli`.
27//!
28//! The same `(value, source)` shape
29//! [`resolve_context_size_with_source`](crate::server_config::resolve_context_size_with_source)
30//! uses, and for the same reason.
31
32use serde::{Deserialize, Serialize};
33
34/// Which rung of a sampling ladder supplied one resolved parameter.
35///
36/// [`Layer`](Self::Layer) carries an index into the ladder that was resolved,
37/// rather than a name, because the ladders differ. [`SamplingLayer`] describes
38/// the five-rung ladder
39/// [`resolve_with_profile_explained`](crate::domain::InferenceConfig::resolve_with_profile_explained)
40/// builds; the request pipeline builds a **six**-rung one, adding `cli` and
41/// `client` above the rest. Callers map the index back to whatever names their
42/// own ladder used.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
44pub enum ParamSource {
45 /// The layer at this index in the resolved ladder named the value.
46 Layer(usize),
47 /// The class floor, because no layer named a value at all.
48 Floor,
49 /// A layer claimed `temperature` and this parameter is tuned against it,
50 /// so no layer beneath was eligible to supply one.
51 ///
52 /// Distinct from [`Floor`](Self::Floor) in the fact that matters: here a
53 /// lower layer may well have named a value and was **deliberately passed
54 /// over**. That is what the coupling rule does, and it is the one thing a
55 /// bare resolved number can never explain.
56 ///
57 /// # It does not imply a value was supplied
58 ///
59 /// The name predates [ADR 0003], when the floor filled all seven
60 /// parameters and being passed over always meant landing on a floor value.
61 /// Six of those are now deferred to llama.cpp, so a coupled parameter can
62 /// resolve to `None`: the rule fired, nothing beneath was eligible, and
63 /// the floor had nothing to offer either.
64 ///
65 /// The variant still reports the rule rather than degrading to
66 /// [`Unset`](Self::Unset), because "a value was discarded here" and
67 /// "nobody ever named one" are different explanations and only the first
68 /// tells a reader where to look. Check the resolved value for whether
69 /// anything was ultimately sent.
70 ///
71 /// [ADR 0003]: https://github.com/mmogr/gglib/blob/main/docs/adr/0003-defer-sampler-defaults-to-llama-cpp.md
72 FloorCoupled,
73 /// A layer named a value, and a later stage **suppressed** it because the
74 /// model's observed template capabilities say the template never reads the
75 /// field. Nothing was sent.
76 ///
77 /// Only `reasoning_effort` can carry this today — it is the one modelled
78 /// field delivered by a chat template reading a variable rather than by
79 /// the sampler ([ADR 0007] decision 3). The suppressed level and the rung
80 /// that supplied it are in the pipeline's own record; see
81 /// `request_pipeline::effort_gate::SuppressedEffort`.
82 ///
83 /// [ADR 0007]: https://github.com/mmogr/gglib/blob/main/docs/adr/0007-ask-the-server-for-template-capabilities.md
84 SuppressedByTemplate,
85 /// Nothing named it and the class floor carries none either, so no value
86 /// is sent and llama.cpp's own default applies. Which fields those are is
87 /// whatever
88 /// [`InferenceConfig::with_hardcoded_defaults`](crate::domain::InferenceConfig::with_hardcoded_defaults)
89 /// leaves unset — deliberately not restated here, because the last
90 /// restatement said "`max_tokens` is the only one" and stayed that way
91 /// through #741 adding three floorless DRY fields.
92 Unset,
93}
94
95impl ParamSource {
96 /// Whether a person actually chose this value.
97 ///
98 /// `auto_detected_rung` is the index of the auto-detected per-model rung
99 /// in the ladder being asked about — a recipe written at import time is a
100 /// guess, not a choice, which is why it already ranks below global
101 /// settings.
102 ///
103 /// # Why this is a method and not a `matches!` at the call site
104 ///
105 /// It was a `matches!` in `request_pipeline::sampling`, listing the
106 /// variants that count as *unchosen*. That shape fails open in the worst
107 /// direction: a new `ParamSource` variant is not in the list, so it reads
108 /// as "deliberately chosen", and the agentic temperature ceiling silently
109 /// stops firing for it. No compile error, no test failure, and the
110 /// symptom is a ceiling that quietly does nothing — which is exactly how
111 /// #741's floor and #744's ceiling both shipped inert.
112 ///
113 /// Here the `match` is exhaustive and the arms are the *positive* case,
114 /// so a new variant breaks the build at the one place that defines what
115 /// "deliberate" means, and whoever adds it has to decide.
116 #[must_use]
117 pub const fn is_deliberate_choice(self, auto_detected_rung: usize) -> bool {
118 match self {
119 // A rung someone configured — unless it is the auto-detected
120 // recipe, which nobody reviewed.
121 Self::Layer(i) => i != auto_detected_rung,
122 // Nothing named it, the coupling rule passed over whatever did, or
123 // a template gate threw away what a rung chose. None of the three
124 // leaves a chosen value standing on *this* parameter.
125 Self::Floor | Self::FloorCoupled | Self::Unset | Self::SuppressedByTemplate => false,
126 }
127 }
128}
129
130/// The five rungs of the ladder
131/// [`resolve_with_profile`](crate::domain::InferenceConfig::resolve_with_profile)
132/// builds, in priority order.
133///
134/// Only one of [`ModelUserSet`](Self::ModelUserSet) and
135/// [`ModelAutoDetected`](Self::ModelAutoDetected) is ever populated for a
136/// given model — both name `Model.inference_defaults`, and
137/// [`DefaultsOrigin`](crate::domain::DefaultsOrigin) decides which rung it
138/// occupies.
139#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
140pub enum SamplingLayer {
141 /// Caller-supplied overrides — request parameters or CLI flags.
142 Request,
143 /// The named profile the caller selected.
144 Profile,
145 /// Per-model defaults a person tuned deliberately.
146 ModelUserSet,
147 /// Global settings defaults.
148 Global,
149 /// Per-model defaults no person in this installation chose, so they rank
150 /// below global settings. Three origins share this rung by replacement —
151 /// the `reasoning`-tag guess, the model author's published recipe, and a
152 /// tune sweep's measured winner — and `DefaultsOrigin` says which one is
153 /// actually in it; the surfaces that name the rung read that, not this.
154 ModelAutoDetected,
155}
156
157impl SamplingLayer {
158 /// The rung at `index` in the ladder `resolve_with_profile` builds.
159 ///
160 /// The mapping lives here rather than at each call site so a change to the
161 /// ladder's order cannot silently mislabel a `ParamSource::Layer`.
162 #[must_use]
163 pub const fn from_index(index: usize) -> Option<Self> {
164 match index {
165 0 => Some(Self::Request),
166 1 => Some(Self::Profile),
167 2 => Some(Self::ModelUserSet),
168 3 => Some(Self::Global),
169 4 => Some(Self::ModelAutoDetected),
170 _ => None,
171 }
172 }
173
174 /// Short human-readable label, e.g. `per-model defaults (user-set)`.
175 #[must_use]
176 pub const fn label(self) -> &'static str {
177 match self {
178 Self::Request => "request parameters",
179 Self::Profile => "profile",
180 Self::ModelUserSet => "per-model defaults (user-set)",
181 Self::Global => "global settings",
182 Self::ModelAutoDetected => "per-model defaults (auto-detected)",
183 }
184 }
185}
186
187/// Per-field provenance for one resolved [`InferenceConfig`].
188///
189/// [`InferenceConfig`]: crate::domain::InferenceConfig
190#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
191pub struct FieldSources {
192 /// Where the resolved `temperature` came from.
193 pub temperature: ParamSource,
194 /// Where the resolved `top_p` came from.
195 pub top_p: ParamSource,
196 /// Where the resolved `top_k` came from.
197 pub top_k: ParamSource,
198 /// Where the resolved `presence_penalty` came from.
199 pub presence_penalty: ParamSource,
200 /// Where the resolved `repeat_penalty` came from.
201 pub repeat_penalty: ParamSource,
202 /// Where the resolved `min_p` came from.
203 pub min_p: ParamSource,
204 /// Where the resolved `frequency_penalty` came from.
205 pub frequency_penalty: ParamSource,
206 /// Where the resolved `dynatemp_range` came from.
207 pub dynatemp_range: ParamSource,
208 /// Where the resolved `dynatemp_exponent` came from.
209 pub dynatemp_exponent: ParamSource,
210 /// Where the resolved `top_n_sigma` came from.
211 pub top_n_sigma: ParamSource,
212 /// Where the resolved `dry_multiplier` came from.
213 pub dry_multiplier: ParamSource,
214 /// Where the resolved `dry_base` came from.
215 pub dry_base: ParamSource,
216 /// Where the resolved `dry_allowed_length` came from.
217 pub dry_allowed_length: ParamSource,
218 /// Where the resolved `dry_penalty_last_n` came from.
219 pub dry_penalty_last_n: ParamSource,
220 /// Where the resolved `max_tokens` came from.
221 pub max_tokens: ParamSource,
222 /// Where the resolved `reasoning_effort` came from.
223 ///
224 /// The only account there is. Neither reasoning control is observable at
225 /// the sampling boundary — no `/slots` or `/props` field echoes either one
226 /// ([ADR 0007] finding 7a) — so where a readback can eventually confirm
227 /// that `top_k` arrived, nothing will ever confirm this did. Provenance is
228 /// not a convenience for these two fields; it is the record.
229 ///
230 /// [ADR 0007]: https://github.com/mmogr/gglib/blob/main/docs/adr/0007-ask-the-server-for-template-capabilities.md
231 pub reasoning_effort: ParamSource,
232 /// Where the resolved `reasoning_budget_tokens` came from. Equally
233 /// unobservable — see [`reasoning_effort`](Self::reasoning_effort).
234 pub reasoning_budget_tokens: ParamSource,
235}
236
237impl FieldSources {
238 /// `(field_name, source)` pairs in display order.
239 ///
240 /// The single iteration order every consumer renders, so the CLI's table
241 /// and the pipeline's debug line cannot disagree about which parameter is
242 /// which. The coupled trio is kept adjacent because it is only
243 /// interpretable as a group.
244 pub fn iter(&self) -> impl Iterator<Item = (&'static str, ParamSource)> {
245 [
246 ("temperature", self.temperature),
247 ("top_p", self.top_p),
248 ("top_k", self.top_k),
249 ("presence_penalty", self.presence_penalty),
250 ("repeat_penalty", self.repeat_penalty),
251 ("min_p", self.min_p),
252 ("frequency_penalty", self.frequency_penalty),
253 ("dynatemp_range", self.dynatemp_range),
254 ("dynatemp_exponent", self.dynatemp_exponent),
255 ("top_n_sigma", self.top_n_sigma),
256 ("dry_multiplier", self.dry_multiplier),
257 ("dry_base", self.dry_base),
258 ("dry_allowed_length", self.dry_allowed_length),
259 ("dry_penalty_last_n", self.dry_penalty_last_n),
260 ("max_tokens", self.max_tokens),
261 // Last, and adjacent to each other rather than to the samplers:
262 // neither is one. `max_tokens` above them is the nearest relative
263 // — a budget — and `reasoning_budget_tokens` sits directly beneath
264 // it for that reason.
265 ("reasoning_effort", self.reasoning_effort),
266 ("reasoning_budget_tokens", self.reasoning_budget_tokens),
267 ]
268 .into_iter()
269 }
270
271 /// Render as `field=layer` pairs against the ladder's own layer names.
272 ///
273 /// `names` is indexed by [`ParamSource::Layer`]; an index past its end
274 /// renders as `?`, which can only happen if a caller passes names for a
275 /// different ladder than it resolved.
276 #[must_use]
277 pub fn describe(&self, names: &[&str]) -> String {
278 self.iter()
279 .map(|(field, source)| {
280 let label = match source {
281 ParamSource::Layer(i) => names.get(i).copied().unwrap_or("?"),
282 ParamSource::Floor | ParamSource::FloorCoupled => "floor",
283 ParamSource::Unset => "unset",
284 ParamSource::SuppressedByTemplate => "suppressed-by-template",
285 };
286 format!("{field}={label}")
287 })
288 .collect::<Vec<_>>()
289 .join(" ")
290 }
291}
292
293#[cfg(test)]
294#[path = "sampling_provenance_tests.rs"]
295mod sampling_provenance_tests;