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}