Skip to main content

gglib_core/domain/
generation_config.rs

1//! The sampling recipe a model author publishes in `generation_config.json`.
2//!
3//! # Why gglib goes looking for this
4//!
5//! [`crate::domain::model_sampling`] reads `general.sampling.*` out of a GGUF,
6//! which llama.cpp applies directly. That is the ideal source and it is almost
7//! never present: the keys landed upstream in PR #17120 (2025-11-25) and most
8//! converters predate them or simply drop what they do not recognise. The
9//! author's numbers exist — they are in `generation_config.json` in the base
10//! repo, which is what every `transformers` user gets by default — they just do
11//! not survive the trip into a quantised GGUF.
12//!
13//! So gglib fetches them at import instead, and ranks them where an unreviewed
14//! recipe belongs. Same argument [ADR 0004]'s follow-up makes:
15//!
16//! > gglib currently writes its own `reasoning_profile()` recipe for
17//! > `reasoning`-tagged models; a model author's published recommendation is
18//! > better evidence than gglib's guess.
19//!
20//! # This is not `ModelSamplingDefaults`, and must not become it
21//!
22//! The obvious place to put these values is [`ModelSamplingDefaults`], and it
23//! is the wrong one. That type means *"what this GGUF declares, which
24//! llama.cpp has already applied to `default_generation_settings`"*, and
25//! `gglib_proxy::props` reads it to decide whether a `/props` value is
26//! attributable to the model rather than to the build.
27//!
28//! A value fetched from `HuggingFace` has been applied by nobody. Writing it
29//! there would make the baseline check report `ModelSupplied` for a number
30//! llama-server never saw — an instrument reporting an attribution that cannot
31//! be wrong because it was invented, which is [ADR 0004] finding 1's trap
32//! wearing a new hat. These stay in the *inference hierarchy*, where a value
33//! only takes effect because gglib sends it.
34//!
35//! [`ModelSamplingDefaults`]: crate::domain::ModelSamplingDefaults
36//! [ADR 0004]: https://github.com/mmogr/gglib/blob/main/docs/adr/0004-observe-the-sampling-boundary.md
37
38use serde_json::Value;
39
40use super::InferenceConfig;
41
42/// Where to look for a `generation_config.json`, best candidate first.
43///
44/// The file lives in the *base* repo — `Qwen/Qwen3-4B` — while the GGUF a
45/// person downloads almost always comes from a quant repo such as
46/// `unsloth/Qwen3-4B-GGUF`, which carries the weights and little else. Asking
47/// only the repo gglib downloaded from would find nothing on the overwhelming
48/// majority of imports.
49///
50/// Three sources, in descending order of how much they are actually knowledge:
51///
52/// 1. **`base_model:` tags.** `HuggingFace` generates these on a quant repo
53///    from its model card, in the forms `base_model:Qwen/Qwen3-4B` and
54///    `base_model:quantized:Qwen/Qwen3-4B`. This is the publisher stating the
55///    relationship, so it goes first.
56/// 2. **The download repo itself.** Some publishers do ship the file beside
57///    the GGUFs, and a repo that is *not* a quant repo is its own base.
58/// 3. **The name with a quant suffix stripped**, under the same owner. A
59///    guess, and last for that reason — `unsloth/Qwen3-4B-GGUF` implies
60///    `unsloth/Qwen3-4B`, which frequently does not exist. Harmless because a
61///    miss is a 404 the caller already has to handle.
62///
63/// Deduplicated, order-preserving, so a repo that is its own base is not asked
64/// twice.
65#[must_use]
66pub fn generation_config_candidates(repo_id: &str, tags: &[String]) -> Vec<String> {
67    let mut out: Vec<String> = Vec::new();
68    let mut push = |candidate: String| {
69        if !candidate.is_empty() && !out.contains(&candidate) {
70            out.push(candidate);
71        }
72    };
73
74    for tag in tags {
75        if let Some(rest) = tag.strip_prefix("base_model:") {
76            // `base_model:quantized:Owner/Name`, `base_model:finetune:...` and
77            // the bare `base_model:Owner/Name` all end with the repo id, so
78            // take the last two path segments rather than enumerating the
79            // relationship words — which HuggingFace adds to over time.
80            let segments: Vec<&str> = rest.split('/').collect();
81            if segments.len() >= 2 {
82                let owner = segments[segments.len() - 2]
83                    .rsplit(':')
84                    .next()
85                    .unwrap_or_default();
86                push(format!("{owner}/{}", segments[segments.len() - 1]));
87            }
88        }
89    }
90
91    push(repo_id.to_owned());
92
93    if let Some((owner, name)) = repo_id.split_once('/') {
94        let trimmed = strip_quant_suffix(name);
95        if trimmed != name {
96            push(format!("{owner}/{trimmed}"));
97        }
98    }
99
100    out
101}
102
103/// Strip a trailing quantisation marker from a repo name.
104fn strip_quant_suffix(name: &str) -> &str {
105    for suffix in ["-GGUF", "-gguf", ".GGUF", ".gguf", "-GGML", "-ggml"] {
106        if let Some(base) = name.strip_suffix(suffix) {
107            return base;
108        }
109    }
110    name
111}
112
113/// The `transformers` spelling of each field gglib models, paired with its own.
114///
115/// Only the fields where the two mean the same thing. The omissions below are
116/// the interesting part of this module.
117const FIELD_MAP: [(&str, &str); 6] = [
118    ("temperature", "temperature"),
119    ("top_p", "top_p"),
120    ("top_k", "top_k"),
121    // `transformers` calls it `repetition_penalty`; llama.cpp and gglib call
122    // it `repeat_penalty`. Same flat multiplicative penalty, same semantics.
123    ("repetition_penalty", "repeat_penalty"),
124    ("min_p", "min_p"),
125    // Rare in practice, but unambiguous where present.
126    ("presence_penalty", "presence_penalty"),
127];
128
129/// Accepted range for each field, matching `docs/sampling.md`.
130///
131/// A published value outside its range is dropped rather than clamped. Clamping
132/// would invent a number the author did not choose and attribute it to them;
133/// dropping falls through to the next rung, which is a source gglib can name.
134fn in_range(field: &str, value: f64) -> bool {
135    match field {
136        // `temperature` and `presence_penalty` share a range by coincidence
137        // rather than by kind; they are one arm because clippy objects to two
138        // identical ones, not because the bound has a common origin.
139        "temperature" | "presence_penalty" => (0.0..=2.0).contains(&value),
140        "top_p" | "min_p" => (0.0..=1.0).contains(&value),
141        // llama.cpp treats 0 as "disabled"; negatives are meaningless.
142        "top_k" => value >= 0.0 && value <= f64::from(i32::MAX),
143        "repeat_penalty" => value > 0.0,
144        _ => false,
145    }
146}
147
148/// What one `generation_config.json` yielded.
149#[derive(Debug, Clone, PartialEq)]
150pub struct PublishedGenerationConfig {
151    /// The sampling values gglib could use, ready to occupy a ladder rung.
152    pub config: InferenceConfig,
153    /// Fields that were present but unusable — out of range, or not a number.
154    ///
155    /// Kept rather than dropped silently so an import can say *why* an author's
156    /// published value is not being honoured. A number gglib ignored without
157    /// saying so is indistinguishable from one it never saw.
158    pub rejected: Vec<String>,
159    /// Whether the file asked for greedy decoding via `do_sample: false`.
160    ///
161    /// Reported rather than acted on. gglib has no greedy mode, and the nearest
162    /// equivalent — forcing `temperature: 0` — is exactly the near-greedy
163    /// setting [ADR 0004]'s addendum bans for reasoning models. So this is
164    /// surfaced for a person to decide about, never applied.
165    ///
166    /// [ADR 0004]: https://github.com/mmogr/gglib/blob/main/docs/adr/0004-observe-the-sampling-boundary.md
167    pub requests_greedy: bool,
168}
169
170impl PublishedGenerationConfig {
171    /// Whether the file yielded any usable sampling value at all.
172    ///
173    /// A `generation_config.json` carrying only token ids is the common case,
174    /// and it must not be stored as a recipe — an all-`None` config on the
175    /// ladder is indistinguishable from no rung, but it would displace the
176    /// `reasoning` recipe that *does* carry values.
177    #[must_use]
178    pub fn is_empty(&self) -> bool {
179        self.config == InferenceConfig::default()
180    }
181}
182
183/// Read a model author's `generation_config.json`.
184///
185/// Returns `None` only when the body is not a JSON object. Anything else — an
186/// empty file, one carrying nothing but token ids, values of the wrong type —
187/// yields a [`PublishedGenerationConfig`] that reports what it found, because
188/// "the author published nothing usable" and "gglib could not read the file"
189/// are different answers and only the second is a fault.
190///
191/// # What is deliberately not read
192///
193/// - **`max_new_tokens` / `max_length`.** `max_tokens` is unset by design in
194///   gglib (see [`InferenceConfig`]), so that a client's own limit is the only
195///   thing that bounds a response. Importing an author's default would quietly
196///   cap every request that named none, which is a much larger decision than
197///   this module is making.
198/// - **`do_sample: false`.** Reported via
199///   [`PublishedGenerationConfig::requests_greedy`], never applied.
200/// - **Everything else in the file** — `bos_token_id`, `eos_token_id`,
201///   `pad_token_id`, `transformers_version` and friends. Naming only the
202///   fields gglib models keeps this from becoming an obligation to track the
203///   whole `transformers` generation schema, the same rule `SlotParams` states
204///   about `/props`'s 42 fields.
205#[must_use]
206pub fn parse_generation_config(body: &str) -> Option<PublishedGenerationConfig> {
207    let json: Value = serde_json::from_str(body).ok()?;
208    let object = json.as_object()?;
209
210    let mut config = InferenceConfig::default();
211    let mut rejected = Vec::new();
212
213    for (their_name, our_name) in FIELD_MAP {
214        let Some(raw) = object.get(their_name) else {
215            continue;
216        };
217        // An explicit `null` is how `transformers` spells "no opinion". It is
218        // an absence, not a malformed value, so it is not a rejection.
219        if raw.is_null() {
220            continue;
221        }
222        let Some(value) = raw.as_f64() else {
223            rejected.push(format!("{their_name} is not a number"));
224            continue;
225        };
226        if !value.is_finite() || !in_range(our_name, value) {
227            rejected.push(format!("{their_name} = {value} is out of range"));
228            continue;
229        }
230        apply(&mut config, our_name, value);
231    }
232
233    Some(PublishedGenerationConfig {
234        config,
235        rejected,
236        requests_greedy: object.get("do_sample") == Some(&Value::Bool(false)),
237    })
238}
239
240/// Write one validated value onto the config by gglib's field name.
241#[allow(clippy::cast_possible_truncation)]
242fn apply(config: &mut InferenceConfig, field: &str, value: f64) {
243    match field {
244        "temperature" => config.temperature = Some(value as f32),
245        "top_p" => config.top_p = Some(value as f32),
246        // Already bounded to `0..=i32::MAX` by `in_range`.
247        "top_k" => config.top_k = Some(value as i32),
248        "repeat_penalty" => config.repeat_penalty = Some(value as f32),
249        "min_p" => config.min_p = Some(value as f32),
250        "presence_penalty" => config.presence_penalty = Some(value as f32),
251        _ => unreachable!("apply is only called with a FIELD_MAP target"),
252    }
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258
259    /// Qwen3's published thinking-mode recipe, verbatim in shape.
260    const QWEN3: &str = r#"{
261        "bos_token_id": 151643,
262        "do_sample": true,
263        "eos_token_id": [151645, 151643],
264        "pad_token_id": 151643,
265        "repetition_penalty": 1.05,
266        "temperature": 0.6,
267        "top_k": 20,
268        "top_p": 0.95,
269        "transformers_version": "4.51.0"
270    }"#;
271
272    #[test]
273    fn a_published_recipe_is_read_into_the_fields_gglib_models() {
274        let parsed = parse_generation_config(QWEN3).expect("parses");
275
276        assert_eq!(parsed.config.temperature, Some(0.6));
277        assert_eq!(parsed.config.top_p, Some(0.95));
278        assert_eq!(parsed.config.top_k, Some(20));
279        assert!(parsed.rejected.is_empty(), "{:?}", parsed.rejected);
280        assert!(!parsed.is_empty());
281    }
282
283    /// The one field whose name differs between `transformers` and gglib. A
284    /// typo here would be silent — the value would simply never be read.
285    #[test]
286    fn repetition_penalty_maps_onto_repeat_penalty() {
287        let parsed = parse_generation_config(QWEN3).expect("parses");
288        assert_eq!(parsed.config.repeat_penalty, Some(1.05));
289    }
290
291    /// **The common case.** Most `generation_config.json` files carry nothing
292    /// but token ids, and storing that as a recipe would displace the
293    /// `reasoning` recipe that does carry values.
294    #[test]
295    fn a_file_with_only_token_ids_yields_an_empty_recipe() {
296        let parsed = parse_generation_config(
297            r#"{"bos_token_id": 1, "eos_token_id": 2, "transformers_version": "4.51.0"}"#,
298        )
299        .expect("parses");
300
301        assert!(parsed.is_empty());
302        assert!(parsed.rejected.is_empty(), "absence is not a rejection");
303    }
304
305    /// `max_new_tokens` must not become `max_tokens`: gglib leaves that unset
306    /// by design so nothing but the client bounds a response.
307    #[test]
308    fn a_published_token_limit_is_not_imported() {
309        let parsed = parse_generation_config(r#"{"max_new_tokens": 512, "max_length": 4096}"#)
310            .expect("parses");
311
312        assert_eq!(parsed.config.max_tokens, None);
313        assert!(parsed.is_empty());
314    }
315
316    /// Greedy is reported, never applied — forcing `temperature: 0` is the
317    /// near-greedy setting ADR 0004's addendum bans for reasoning models.
318    #[test]
319    fn a_request_for_greedy_decoding_is_reported_rather_than_applied() {
320        let parsed =
321            parse_generation_config(r#"{"do_sample": false, "temperature": 0.7}"#).expect("parses");
322
323        assert!(parsed.requests_greedy);
324        assert_eq!(
325            parsed.config.temperature,
326            Some(0.7),
327            "the published temperature still stands on its own"
328        );
329    }
330
331    #[test]
332    fn do_sample_true_is_not_a_greedy_request() {
333        let parsed = parse_generation_config(r#"{"do_sample": true}"#).expect("parses");
334        assert!(!parsed.requests_greedy);
335    }
336
337    /// Out-of-range values are dropped rather than clamped: clamping invents a
338    /// number the author did not choose and attributes it to them.
339    #[test]
340    fn an_out_of_range_value_is_dropped_and_reported() {
341        let parsed =
342            parse_generation_config(r#"{"temperature": 7.5, "top_p": 0.9}"#).expect("parses");
343
344        assert_eq!(parsed.config.temperature, None, "not clamped to 2.0");
345        assert_eq!(parsed.config.top_p, Some(0.9), "the good value still lands");
346        assert_eq!(parsed.rejected.len(), 1);
347        assert!(
348            parsed.rejected[0].contains("temperature"),
349            "{:?}",
350            parsed.rejected
351        );
352    }
353
354    #[test]
355    fn a_non_numeric_value_is_reported_rather_than_silently_ignored() {
356        let parsed = parse_generation_config(r#"{"temperature": "warm"}"#).expect("parses");
357
358        assert_eq!(parsed.config.temperature, None);
359        assert_eq!(parsed.rejected.len(), 1);
360    }
361
362    /// `null` is how `transformers` spells "no opinion". Reporting it as a
363    /// rejection would put a warning on a file that is behaving normally.
364    #[test]
365    fn an_explicit_null_is_an_absence_not_a_rejection() {
366        let parsed =
367            parse_generation_config(r#"{"temperature": null, "top_k": null}"#).expect("parses");
368
369        assert!(parsed.is_empty());
370        assert!(parsed.rejected.is_empty());
371    }
372
373    /// `top_k: 0` means "disabled" in both `transformers` and llama.cpp, so it
374    /// is a real value rather than an out-of-range one.
375    #[test]
376    fn top_k_zero_is_a_value_not_a_rejection() {
377        let parsed = parse_generation_config(r#"{"top_k": 0}"#).expect("parses");
378
379        assert_eq!(parsed.config.top_k, Some(0));
380        assert!(parsed.rejected.is_empty());
381    }
382
383    /// A body that is not a JSON object is the one case gglib cannot read at
384    /// all — distinct from a file that published nothing usable.
385    #[test]
386    fn an_unreadable_body_is_none_rather_than_an_empty_recipe() {
387        assert!(parse_generation_config("not json").is_none());
388        assert!(parse_generation_config("[1, 2, 3]").is_none());
389        assert!(parse_generation_config("").is_none());
390
391        assert!(
392            parse_generation_config("{}").is_some(),
393            "an empty object is a readable file that published nothing"
394        );
395    }
396
397    // =========================================================================
398    // Where to look
399    // =========================================================================
400
401    fn tags(items: &[&str]) -> Vec<String> {
402        items.iter().map(|s| (*s).to_string()).collect()
403    }
404
405    /// **The case this exists for.** The GGUF comes from a quant repo that
406    /// carries weights and little else; the author's recipe is in the base
407    /// repo, and the publisher already stated which one that is.
408    #[test]
409    fn a_base_model_tag_is_preferred_over_the_download_repo() {
410        let candidates = generation_config_candidates(
411            "unsloth/Qwen3-4B-GGUF",
412            &tags(&["base_model:Qwen/Qwen3-4B", "text-generation"]),
413        );
414
415        assert_eq!(candidates[0], "Qwen/Qwen3-4B");
416        assert!(candidates.contains(&"unsloth/Qwen3-4B-GGUF".to_string()));
417    }
418
419    /// `HuggingFace` inserts a relationship word, and adds new ones over time.
420    /// Reading the last two segments survives words this code has never seen.
421    #[test]
422    fn a_relationship_qualified_base_model_tag_is_read() {
423        for tag in [
424            "base_model:quantized:Qwen/Qwen3-4B",
425            "base_model:finetune:Qwen/Qwen3-4B",
426            "base_model:some-future-word:Qwen/Qwen3-4B",
427        ] {
428            let candidates = generation_config_candidates("unsloth/Qwen3-4B-GGUF", &tags(&[tag]));
429            assert_eq!(candidates[0], "Qwen/Qwen3-4B", "{tag}");
430        }
431    }
432
433    /// The guess, and last on purpose: `unsloth/Qwen3-4B` frequently does not
434    /// exist. Harmless, because a miss is a 404 the caller already handles.
435    #[test]
436    fn a_quant_suffix_is_stripped_as_a_last_resort() {
437        let candidates = generation_config_candidates("unsloth/Qwen3-4B-GGUF", &[]);
438
439        assert_eq!(candidates, ["unsloth/Qwen3-4B-GGUF", "unsloth/Qwen3-4B"]);
440    }
441
442    /// A repo that is its own base must not be asked twice.
443    #[test]
444    fn candidates_are_deduplicated_in_order() {
445        let candidates =
446            generation_config_candidates("Qwen/Qwen3-4B", &tags(&["base_model:Qwen/Qwen3-4B"]));
447
448        assert_eq!(candidates, ["Qwen/Qwen3-4B"]);
449    }
450
451    #[test]
452    fn a_repo_with_no_quant_suffix_yields_only_itself() {
453        assert_eq!(
454            generation_config_candidates("Qwen/Qwen3-4B", &[]),
455            ["Qwen/Qwen3-4B"]
456        );
457    }
458
459    /// A malformed tag must not produce a candidate that cannot be a repo id.
460    #[test]
461    fn a_base_model_tag_without_an_owner_is_skipped() {
462        let candidates = generation_config_candidates("owner/Thing", &tags(&["base_model:Thing"]));
463        assert_eq!(candidates, ["owner/Thing"]);
464    }
465
466    /// The list is what bounds the import's network work, so it must stay
467    /// short whatever a repo tags itself with.
468    #[test]
469    fn the_candidate_list_stays_within_the_lookup_budget() {
470        let noisy = tags(&[
471            "base_model:Qwen/Qwen3-4B",
472            "base_model:quantized:Qwen/Qwen3-4B",
473            "text-generation",
474            "conversational",
475        ]);
476        let candidates = generation_config_candidates("unsloth/Qwen3-4B-GGUF", &noisy);
477
478        assert!(
479            candidates.len() <= crate::services::MAX_GENERATION_CONFIG_LOOKUPS,
480            "{candidates:?}"
481        );
482    }
483
484    /// Fields gglib does not model are ignored rather than tracked, the same
485    /// rule `SlotParams` states about `/props`.
486    #[test]
487    fn unmodelled_fields_are_ignored_without_complaint() {
488        let parsed = parse_generation_config(
489            r#"{"num_beams": 4, "typical_p": 0.9, "epsilon_cutoff": 0.1, "temperature": 0.6}"#,
490        )
491        .expect("parses");
492
493        assert_eq!(parsed.config.temperature, Some(0.6));
494        assert!(parsed.rejected.is_empty());
495    }
496}