Skip to main content

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;