Skip to main content

gglib_core/domain/
model_sampling.rs

1//! The sampler defaults a model carries in its own GGUF metadata.
2//!
3//! Inputs come from the raw GGUF key/value map that `gglib-gguf` copies
4//! verbatim into [`crate::domain::Model::metadata`] — the same pattern as
5//! [`crate::domain::estimate_kv_elems_per_token`] and
6//! [`crate::domain::kv_memory_is_partial`], and this module sits beside them
7//! for that reason.
8//!
9//! # Why gglib has to know about these
10//!
11//! llama.cpp PR #17120 (merged 2025-11-25, and in the pinned build) added
12//! `common_init_sampler_from_model`, which overwrites `params.sampling` from
13//! the model's own `general.sampling.*` keys **for every field no CLI flag
14//! set** — and `GET /props` renders `default_generation_settings` from that
15//! same struct.
16//!
17//! So `/props` answers *"what will this server with this model default to"*,
18//! not *"what does this build default to"*. Since [ADR 0003] gglib passes no
19//! sampler flags at all, model metadata always wins where it is present.
20//!
21//! `gglib_proxy::props`'s baseline check compares `/props` against a table
22//! measured for the pinned build. Without this module it reports a model's own
23//! recommendation as *drift* — "this build's default has moved, ADR 0003's
24//! deferral is re-opened" — which is a false alarm on the one instrument whose
25//! whole value is being worth believing when it fires.
26//!
27//! # Five keys of twelve
28//!
29//! llama.cpp reads twelve `general.sampling.*` keys. Only five name a
30//! parameter gglib has a floor opinion about, and those are the only ones
31//! modelled here — the same rule `SlotParams` states about `/props`'s 42
32//! fields: naming the rest would invent an obligation to keep up with them.
33//!
34//! ```text
35//!   gglib field        GGUF key
36//!   temperature        general.sampling.temp
37//!   top_p              general.sampling.top_p
38//!   top_k              general.sampling.top_k
39//!   min_p              general.sampling.min_p
40//!   repeat_penalty     general.sampling.penalty_repeat
41//!
42//!   presence_penalty   (none)
43//!   dry_multiplier     (none)
44//! ```
45//!
46//! The asymmetry at the bottom is worth stating rather than leaving to be
47//! rediscovered: `presence_penalty` and `dry_multiplier` have no GGUF key at
48//! all, so they stay attributable to the build whatever a model ships. A
49//! baseline check therefore cannot go fully blind on a model's account.
50//!
51//! The other seven keys — `sequence`, `xtc_probability`, `xtc_threshold`,
52//! `penalty_last_n`, `mirostat`, `mirostat_tau`, `mirostat_eta` — move
53//! sampling with nothing in gglib watching, because gglib has no floor for
54//! them to contradict.
55//!
56//! # Not architecture-prefixed
57//!
58//! Unlike its two siblings, which look up `{arch}.{suffix}` and fall back to
59//! the bare suffix. `general.sampling.*` is a `general.*` key like
60//! `general.architecture`: there is one spelling and a prefixed fallback would
61//! match keys that do not exist.
62//!
63//! [ADR 0003]: https://github.com/mmogr/gglib/blob/main/docs/adr/0003-defer-sampler-defaults-to-llama-cpp.md
64
65use std::collections::HashMap;
66use std::hash::BuildHasher;
67
68/// What one model's GGUF says about one sampler field.
69///
70/// Three states rather than `Option<f64>`, because the two ways of having no
71/// number license opposite conclusions. A model that names nothing leaves the
72/// build's own default observable in `/props`; a model that names something
73/// gglib cannot read leaves nothing observable, because llama.cpp's `strtof`
74/// and Rust's `f64::from_str` need not agree on every string and gglib cannot
75/// tell from here which of them took the value.
76#[derive(Debug, Clone, Copy, Default, PartialEq)]
77pub enum ModelSamplingDefault {
78    /// The GGUF names no value, so llama.cpp's build default applies and
79    /// `/props` is a clean read of it.
80    #[default]
81    Absent,
82    /// The GGUF names it. llama.cpp overwrites its own default with this for
83    /// every request that does not set the field.
84    Declared(f64),
85    /// The GGUF names it and gglib could not read the value as a number.
86    Unreadable,
87}
88
89/// gglib's wire name for a sampler field, paired with the GGUF key that can
90/// move it.
91///
92/// The single mapping table. `gglib_proxy::props::UPSTREAM_DEFAULTS` names the
93/// fields the baseline check compares; this names which of them a model can
94/// reach, and a test over there asserts the two agree in both directions.
95pub const MODEL_SAMPLING_KEYS: [(&str, &str); 5] = [
96    ("temperature", "general.sampling.temp"),
97    ("top_p", "general.sampling.top_p"),
98    ("top_k", "general.sampling.top_k"),
99    ("min_p", "general.sampling.min_p"),
100    ("repeat_penalty", "general.sampling.penalty_repeat"),
101];
102
103/// The sampler defaults one model declares.
104///
105/// `Copy`, and deliberately so: it rides [`ModelLaunchSpec`] into the resident
106/// set and out again on every `current_model()` call, so it is cloned far more
107/// often than it is built. Adding a `String` here would put an allocation on
108/// the admission fast path and cost `with_model_sampling` its `const`.
109///
110/// [`ModelLaunchSpec`]: crate::ports::ModelLaunchSpec
111#[derive(Debug, Clone, Copy, Default, PartialEq)]
112pub struct ModelSamplingDefaults {
113    /// `general.sampling.temp`.
114    pub temperature: ModelSamplingDefault,
115    /// `general.sampling.top_p`.
116    pub top_p: ModelSamplingDefault,
117    /// `general.sampling.top_k`.
118    pub top_k: ModelSamplingDefault,
119    /// `general.sampling.min_p`.
120    pub min_p: ModelSamplingDefault,
121    /// `general.sampling.penalty_repeat`.
122    pub repeat_penalty: ModelSamplingDefault,
123}
124
125impl ModelSamplingDefaults {
126    /// Read what a model declares out of its stored GGUF metadata.
127    ///
128    /// Values arrive stringified by `GgufValue::to_string()`, so a `FLOAT32`
129    /// `0.7` is the string `"0.7"` — Rust's `Display` prints the shortest form
130    /// that round-trips. Parsing it back yields `0.7`, while `/props` reports
131    /// the same value widened from `f32` (`0.699999988079071`); the ~1.2e-8 gap
132    /// is well inside the epsilon the comparison uses.
133    #[must_use]
134    pub fn from_metadata<S: BuildHasher>(metadata: &HashMap<String, String, S>) -> Self {
135        let read = |key: &str| {
136            metadata
137                .get(key)
138                .map_or(ModelSamplingDefault::Absent, |raw| {
139                    raw.trim()
140                        .parse::<f64>()
141                        .map_or(ModelSamplingDefault::Unreadable, |v| {
142                            ModelSamplingDefault::Declared(v)
143                        })
144                })
145        };
146        Self {
147            temperature: read("general.sampling.temp"),
148            top_p: read("general.sampling.top_p"),
149            top_k: read("general.sampling.top_k"),
150            min_p: read("general.sampling.min_p"),
151            repeat_penalty: read("general.sampling.penalty_repeat"),
152        }
153    }
154
155    /// Look one field up by its gglib wire name.
156    ///
157    /// Mirrors `SlotParams::get` so the baseline check reads both sides the
158    /// same way. A name with no GGUF key — `presence_penalty`,
159    /// `dry_multiplier`, or anything unknown — is [`Absent`] **by
160    /// construction**, not because this model happened not to set it.
161    ///
162    /// [`Absent`]: ModelSamplingDefault::Absent
163    #[must_use]
164    pub fn get(&self, field: &str) -> ModelSamplingDefault {
165        match field {
166            "temperature" => self.temperature,
167            "top_p" => self.top_p,
168            "top_k" => self.top_k,
169            "min_p" => self.min_p,
170            "repeat_penalty" => self.repeat_penalty,
171            _ => ModelSamplingDefault::Absent,
172        }
173    }
174
175    /// The GGUF key that can move `field`, if any.
176    #[must_use]
177    pub fn gguf_key(field: &str) -> Option<&'static str> {
178        MODEL_SAMPLING_KEYS
179            .iter()
180            .find(|(name, _)| *name == field)
181            .map(|(_, key)| *key)
182    }
183}
184
185// =============================================================================
186// What gglib does with what the model published
187// =============================================================================
188
189/// Tolerance for comparing a published value against a resolved one.
190///
191/// Same value and reason as `gglib_proxy::props`'s. A GGUF `FLOAT32` `0.7`
192/// stringifies to `"0.7"` and parses back to `f64` `0.7`, while gglib's own
193/// resolved `f32` `0.7` widens to `0.699999988079071`. The ~1.2e-8 gap is an
194/// artefact of the round trip, not a disagreement, and must not render as one.
195const FLOAT_EPSILON: f64 = 1e-6;
196
197/// What gglib is doing with one field's published recommendation.
198///
199/// # Why this is a configuration question, not an observation
200///
201/// [`gglib_proxy::props`]'s baseline check asks *"has this build's default
202/// table moved?"* and answers it from `/props`. This asks a different question
203/// with a different failure mode: *"is gglib sending something other than what
204/// the model author published?"* — which is decidable from stored
205/// configuration alone, with no server running and no request in flight.
206///
207/// Keeping them apart matters. The baseline check must abstain wherever
208/// attribution fails, because a wrong verdict there re-opens or falsely
209/// satisfies [ADR 0003]'s deletion criterion. This comparison has no such
210/// hazard: both sides are known exactly, so every field reaches a verdict and
211/// none of them is `Indeterminate`.
212///
213/// # The wire rule this encodes
214///
215/// A sampling value gglib resolves is sent in the request body, and the body
216/// wins over `default_generation_settings`. A value gglib leaves unresolved is
217/// sent as nothing at all, and llama.cpp then applies the model's own
218/// `general.sampling.*` key ([ADR 0004] finding 7). So the question "does the
219/// model's published value survive to the sampler?" is answered entirely by
220/// **whether gglib names the field**, not by which rung named it — which is
221/// why this keys on `Option<f64>` rather than on [`ParamSource`].
222///
223/// [`gglib_proxy::props`]: https://github.com/mmogr/gglib/blob/main/crates/gglib-proxy/src/props.rs
224/// [`ParamSource`]: crate::domain::ParamSource
225/// [ADR 0003]: https://github.com/mmogr/gglib/blob/main/docs/adr/0003-defer-sampler-defaults-to-llama-cpp.md
226/// [ADR 0004]: https://github.com/mmogr/gglib/blob/main/docs/adr/0004-observe-the-sampling-boundary.md
227#[derive(Debug, Clone, PartialEq)]
228pub enum SamplingOverride {
229    /// The model published nothing gglib could act against — either it names
230    /// no value for this field, or the field has no GGUF key at all.
231    ///
232    /// The two are collapsed deliberately: both mean *there is no published
233    /// recommendation to override*, which is the only thing a surface needs in
234    /// order to stay quiet. [`ModelSamplingDefaults::gguf_key`] tells the two
235    /// apart where it matters.
236    NotPublished,
237    /// The model published a value and gglib names nothing, so llama.cpp
238    /// applies the model author's number.
239    ///
240    /// This is the state [ADR 0003]'s deferral was aiming at, and the one a
241    /// bare `—` in an explain table renders indistinguishably from a gap.
242    ///
243    /// [ADR 0003]: https://github.com/mmogr/gglib/blob/main/docs/adr/0003-defer-sampler-defaults-to-llama-cpp.md
244    Deferred {
245        /// The GGUF key carrying it.
246        key: &'static str,
247        /// What the model author published.
248        published: f64,
249    },
250    /// The model published a value and gglib sends the same number.
251    ///
252    /// Not an override in effect, and reporting it as one would cry wolf. Kept
253    /// distinct from [`Self::Deferred`] anyway, because gglib *asserting* a
254    /// value it happens to agree with is exactly the redundant restatement
255    /// [ADR 0003] argues against — it silently overrides whatever the model
256    /// author chooses next.
257    ///
258    /// [ADR 0003]: https://github.com/mmogr/gglib/blob/main/docs/adr/0003-defer-sampler-defaults-to-llama-cpp.md
259    Restated {
260        /// The GGUF key carrying it.
261        key: &'static str,
262        /// The value both sides name.
263        published: f64,
264    },
265    /// The model published a value and gglib sends a different one.
266    ///
267    /// The state this whole comparison exists to surface.
268    Overridden {
269        /// The GGUF key carrying it.
270        key: &'static str,
271        /// What the model author published.
272        published: f64,
273        /// What gglib puts on the wire instead.
274        sending: f64,
275    },
276    /// The model names the key and gglib could not read its value.
277    ///
278    /// Carried through rather than folded into [`Self::NotPublished`] for the
279    /// reason [`ModelSamplingDefault::Unreadable`] exists: llama.cpp's `strtof`
280    /// and Rust's `f64::from_str` need not agree on every string, so gglib
281    /// cannot say whether a recommendation was applied here or not.
282    Unreadable {
283        /// The GGUF key whose value could not be read.
284        key: &'static str,
285        /// What gglib sends regardless, if anything. Its own value still
286        /// reaches the sampler; what is unknown is what it displaced.
287        sending: Option<f64>,
288    },
289}
290
291impl SamplingOverride {
292    /// Whether gglib is putting a different number on the wire than the model
293    /// author published.
294    ///
295    /// The one predicate a surface should branch on to decide whether to warn.
296    /// [`Self::Unreadable`] is deliberately **not** included: gglib cannot tell
297    /// whether it is overriding anything there, and a warning that might be
298    /// about nothing is the [`Indeterminate`]-rendered-as-`Differs` mistake
299    /// [ADR 0004] decision 3 forbids one layer up.
300    ///
301    /// [`Indeterminate`]: https://github.com/mmogr/gglib/blob/main/crates/gglib-proxy/src/props.rs
302    /// [ADR 0004]: https://github.com/mmogr/gglib/blob/main/docs/adr/0004-observe-the-sampling-boundary.md
303    #[must_use]
304    pub const fn is_override(&self) -> bool {
305        matches!(self, Self::Overridden { .. })
306    }
307
308    /// Whether the model published anything at all for this field.
309    #[must_use]
310    pub const fn model_published(&self) -> bool {
311        !matches!(self, Self::NotPublished)
312    }
313}
314
315impl ModelSamplingDefaults {
316    /// Compare one field's published value against what gglib will send.
317    ///
318    /// `sending` is what the ladder resolved: `None` means gglib names the
319    /// field nowhere and llama.cpp is left to apply the model's own value.
320    #[must_use]
321    pub fn compare_field(&self, field: &str, sending: Option<f64>) -> SamplingOverride {
322        let Some(key) = Self::gguf_key(field) else {
323            // No GGUF key exists, so no model can have published one. This is
324            // the `presence_penalty` / `dry_multiplier` arm, and it is a fact
325            // about the format rather than about this model.
326            return SamplingOverride::NotPublished;
327        };
328        match self.get(field) {
329            ModelSamplingDefault::Absent => SamplingOverride::NotPublished,
330            ModelSamplingDefault::Unreadable => SamplingOverride::Unreadable { key, sending },
331            ModelSamplingDefault::Declared(published) => match sending {
332                None => SamplingOverride::Deferred { key, published },
333                Some(sending) if (sending - published).abs() <= FLOAT_EPSILON => {
334                    SamplingOverride::Restated { key, published }
335                }
336                Some(sending) => SamplingOverride::Overridden {
337                    key,
338                    published,
339                    sending,
340                },
341            },
342        }
343    }
344
345    /// Compare every field a model can publish, in [`MODEL_SAMPLING_KEYS`]
346    /// order.
347    ///
348    /// `resolved` looks each field up by its gglib wire name and returns what
349    /// the ladder decided to send, so callers hand in whichever config they are
350    /// explaining rather than this module learning about [`InferenceConfig`].
351    ///
352    /// [`InferenceConfig`]: crate::domain::InferenceConfig
353    #[must_use]
354    pub fn compare_all(
355        &self,
356        resolved: impl Fn(&str) -> Option<f64>,
357    ) -> Vec<(&'static str, SamplingOverride)> {
358        MODEL_SAMPLING_KEYS
359            .iter()
360            .map(|(field, _)| (*field, self.compare_field(field, resolved(field))))
361            .collect()
362    }
363}
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368
369    fn meta(pairs: &[(&str, &str)]) -> HashMap<String, String> {
370        pairs
371            .iter()
372            .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
373            .collect()
374    }
375
376    /// The ordinary case, and the one that keeps today's behaviour: a model
377    /// with no opinion leaves the build's own defaults observable.
378    #[test]
379    fn a_gguf_that_names_no_sampler_defaults_declares_nothing() {
380        let d = ModelSamplingDefaults::from_metadata(&meta(&[
381            ("general.architecture", "qwen3"),
382            ("qwen3.block_count", "36"),
383        ]));
384
385        assert_eq!(d, ModelSamplingDefaults::default());
386        assert_eq!(d.get("temperature"), ModelSamplingDefault::Absent);
387    }
388
389    #[test]
390    fn a_model_embedded_temperature_is_read_from_general_sampling_temp() {
391        let d = ModelSamplingDefaults::from_metadata(&meta(&[("general.sampling.temp", "0.33")]));
392
393        assert_eq!(d.temperature, ModelSamplingDefault::Declared(0.33));
394        assert_eq!(d.get("temperature"), ModelSamplingDefault::Declared(0.33));
395        assert_eq!(d.top_p, ModelSamplingDefault::Absent, "others untouched");
396    }
397
398    /// The one key whose GGUF name differs from gglib's wire name. A typo here
399    /// would be silent: the field would simply always read as `Absent`.
400    #[test]
401    fn repeat_penalty_is_read_from_general_sampling_penalty_repeat() {
402        let d = ModelSamplingDefaults::from_metadata(&meta(&[(
403            "general.sampling.penalty_repeat",
404            "1.07",
405        )]));
406
407        assert_eq!(
408            d.get("repeat_penalty"),
409            ModelSamplingDefault::Declared(1.07)
410        );
411        assert_eq!(
412            ModelSamplingDefaults::gguf_key("repeat_penalty"),
413            Some("general.sampling.penalty_repeat")
414        );
415    }
416
417    /// "Named but unreadable" is not "not named". The first means llama.cpp
418    /// may have applied something gglib cannot see; the second means the build
419    /// default stands and `/props` can be trusted for that field.
420    #[test]
421    fn a_value_that_is_not_a_number_is_unreadable_rather_than_absent() {
422        let d = ModelSamplingDefaults::from_metadata(&meta(&[("general.sampling.temp", "warm")]));
423
424        assert_eq!(d.temperature, ModelSamplingDefault::Unreadable);
425        assert_ne!(d.temperature, ModelSamplingDefault::Absent);
426    }
427
428    /// Unlike `kv_estimate` and `kv_memory`, whose keys are `{arch}.{suffix}`.
429    /// A prefixed lookup here would match keys that do not exist and miss the
430    /// one that does.
431    #[test]
432    fn the_sampling_keys_are_not_architecture_prefixed() {
433        let d = ModelSamplingDefaults::from_metadata(&meta(&[
434            ("general.architecture", "qwen3"),
435            ("qwen3.sampling.temp", "0.33"),
436            ("sampling.temp", "0.44"),
437        ]));
438
439        assert_eq!(
440            d.temperature,
441            ModelSamplingDefault::Absent,
442            "only general.sampling.temp counts"
443        );
444    }
445
446    /// **The asymmetry that makes the baseline check still worth running.**
447    /// These two have no GGUF key, so no model can move them and the build
448    /// stays observable through them however much else it declares.
449    #[test]
450    fn presence_penalty_and_dry_multiplier_have_no_gguf_key() {
451        // Invented keys a model author might plausibly try.
452        let d = ModelSamplingDefaults::from_metadata(&meta(&[
453            ("general.sampling.presence_penalty", "1.0"),
454            ("general.sampling.dry_multiplier", "0.8"),
455        ]));
456
457        for field in ["presence_penalty", "dry_multiplier"] {
458            assert_eq!(
459                ModelSamplingDefaults::gguf_key(field),
460                None,
461                "{field} must have no GGUF key"
462            );
463            assert_eq!(
464                d.get(field),
465                ModelSamplingDefault::Absent,
466                "{field} must be unreachable by a model, not merely unset"
467            );
468        }
469    }
470
471    #[test]
472    fn whitespace_around_a_declared_value_is_tolerated() {
473        let d =
474            ModelSamplingDefaults::from_metadata(&meta(&[("general.sampling.top_p", " 0.71 ")]));
475        assert_eq!(d.top_p, ModelSamplingDefault::Declared(0.71));
476    }
477
478    /// `top_k` is an integer in the GGUF and stringifies without a decimal
479    /// point; reading everything as `f64` keeps one comparison path.
480    #[test]
481    fn an_integer_valued_key_reads_as_a_float() {
482        let d = ModelSamplingDefaults::from_metadata(&meta(&[("general.sampling.top_k", "17")]));
483        assert_eq!(d.top_k, ModelSamplingDefault::Declared(17.0));
484    }
485
486    #[test]
487    fn an_unknown_field_name_is_absent_rather_than_a_panic() {
488        let d = ModelSamplingDefaults::default();
489        assert_eq!(d.get("mirostat"), ModelSamplingDefault::Absent);
490        assert_eq!(ModelSamplingDefaults::gguf_key("mirostat"), None);
491    }
492
493    // =========================================================================
494    // The override comparison
495    // =========================================================================
496
497    /// The state the whole comparison exists to surface: a model author
498    /// published a number and gglib puts a different one on the wire.
499    #[test]
500    fn a_resolved_value_that_differs_from_the_published_one_is_an_override() {
501        let d = ModelSamplingDefaults::from_metadata(&meta(&[("general.sampling.temp", "0.33")]));
502
503        let verdict = d.compare_field("temperature", Some(1.0));
504
505        assert_eq!(
506            verdict,
507            SamplingOverride::Overridden {
508                key: "general.sampling.temp",
509                published: 0.33,
510                sending: 1.0,
511            }
512        );
513        assert!(verdict.is_override());
514    }
515
516    /// gglib naming nothing is what lets the model's own value through. This
517    /// must never read as an override, and it is the state a bare `—` in an
518    /// explain table cannot distinguish from a gap.
519    #[test]
520    fn naming_nothing_defers_to_the_published_value() {
521        let d = ModelSamplingDefaults::from_metadata(&meta(&[("general.sampling.top_p", "0.71")]));
522
523        let verdict = d.compare_field("top_p", None);
524
525        assert_eq!(
526            verdict,
527            SamplingOverride::Deferred {
528                key: "general.sampling.top_p",
529                published: 0.71,
530            }
531        );
532        assert!(!verdict.is_override());
533        assert!(verdict.model_published());
534    }
535
536    /// Sending the same number is not an override in effect, and warning about
537    /// it would cry wolf — but it is not deferral either, so it keeps its own
538    /// arm.
539    #[test]
540    fn sending_the_published_value_is_restated_rather_than_overridden() {
541        let d = ModelSamplingDefaults::from_metadata(&meta(&[("general.sampling.min_p", "0.05")]));
542
543        let verdict = d.compare_field("min_p", Some(0.05));
544
545        assert_eq!(
546            verdict,
547            SamplingOverride::Restated {
548                key: "general.sampling.min_p",
549                published: 0.05,
550            }
551        );
552        assert!(!verdict.is_override());
553    }
554
555    /// **The round-trip guard.** A GGUF `FLOAT32` `0.7` reaches this module as
556    /// the string `"0.7"` and parses to `f64` `0.7`, while gglib's own resolved
557    /// `f32` `0.7` widens to `0.699999988079071`. Comparing those exactly would
558    /// report an override on every model that publishes a value gglib agrees
559    /// with — the loudest possible false alarm.
560    #[test]
561    fn an_f32_round_trip_does_not_read_as_an_override() {
562        let d = ModelSamplingDefaults::from_metadata(&meta(&[("general.sampling.temp", "0.7")]));
563
564        let widened = f64::from(0.7_f32);
565        assert!(
566            (widened - 0.7).abs() > f64::EPSILON,
567            "guards the premise: the gap is real, not an artefact of this assertion"
568        );
569
570        assert!(matches!(
571            d.compare_field("temperature", Some(widened)),
572            SamplingOverride::Restated { .. }
573        ));
574    }
575
576    /// A difference larger than the epsilon still has to register, or the
577    /// tolerance above would have silenced the check rather than calibrated it.
578    #[test]
579    fn a_difference_above_the_epsilon_still_registers() {
580        let d = ModelSamplingDefaults::from_metadata(&meta(&[("general.sampling.temp", "0.7")]));
581
582        assert!(d.compare_field("temperature", Some(0.7001)).is_override());
583    }
584
585    /// gglib cannot tell whether it displaced anything here, so this must not
586    /// claim an override — the same rule ADR 0004 decision 3 applies to
587    /// `Indeterminate` one layer up.
588    #[test]
589    fn an_unreadable_published_value_is_not_reported_as_an_override() {
590        let d = ModelSamplingDefaults::from_metadata(&meta(&[("general.sampling.temp", "warm")]));
591
592        let verdict = d.compare_field("temperature", Some(1.0));
593
594        assert_eq!(
595            verdict,
596            SamplingOverride::Unreadable {
597                key: "general.sampling.temp",
598                sending: Some(1.0),
599            }
600        );
601        assert!(!verdict.is_override(), "cannot claim what it cannot know");
602        assert!(verdict.model_published());
603    }
604
605    /// A model with no opinion leaves gglib free, and the surfaces silent.
606    #[test]
607    fn a_field_the_model_never_named_is_not_published() {
608        let d = ModelSamplingDefaults::default();
609
610        let verdict = d.compare_field("temperature", Some(1.0));
611
612        assert_eq!(verdict, SamplingOverride::NotPublished);
613        assert!(!verdict.is_override());
614        assert!(!verdict.model_published());
615    }
616
617    /// **The asymmetry, restated at the comparison layer.** These two have no
618    /// GGUF key, so gglib naming them can never be overriding a model author —
619    /// and a surface must not imply otherwise however loudly the model declares
620    /// keys with those names.
621    #[test]
622    fn a_field_with_no_gguf_key_can_never_be_an_override() {
623        let d = ModelSamplingDefaults::from_metadata(&meta(&[
624            ("general.sampling.presence_penalty", "1.0"),
625            ("general.sampling.dry_multiplier", "0.8"),
626        ]));
627
628        for field in ["presence_penalty", "dry_multiplier"] {
629            assert_eq!(
630                d.compare_field(field, Some(1.5)),
631                SamplingOverride::NotPublished,
632                "{field} is unreachable by a model"
633            );
634        }
635    }
636
637    /// `compare_all` covers exactly the reachable set, in the mapping table's
638    /// order, so a surface iterating it cannot silently miss a field.
639    #[test]
640    fn compare_all_covers_every_reachable_field_in_table_order() {
641        let d = ModelSamplingDefaults::from_metadata(&meta(&[
642            ("general.sampling.temp", "0.33"),
643            ("general.sampling.penalty_repeat", "1.07"),
644        ]));
645
646        let all = d.compare_all(|field| match field {
647            "temperature" => Some(1.0),
648            "repeat_penalty" => Some(1.07),
649            _ => None,
650        });
651
652        let fields: Vec<&str> = all.iter().map(|(f, _)| *f).collect();
653        assert_eq!(
654            fields,
655            MODEL_SAMPLING_KEYS
656                .iter()
657                .map(|(f, _)| *f)
658                .collect::<Vec<_>>()
659        );
660
661        let by_field = |name: &str| {
662            all.iter()
663                .find(|(f, _)| *f == name)
664                .map(|(_, v)| v.clone())
665                .expect("field present")
666        };
667        assert!(by_field("temperature").is_override());
668        assert!(matches!(
669            by_field("repeat_penalty"),
670            SamplingOverride::Restated { .. }
671        ));
672        assert_eq!(by_field("top_p"), SamplingOverride::NotPublished);
673    }
674}