Skip to main content

gglib_core/domain/
sampling_discards.rs

1//! Which parameters a caller named and the ladder then passed over.
2//!
3//! # The gap this closes
4//!
5//! `presence_penalty`, `repeat_penalty` and `min_p` travel with whichever rung
6//! claims `temperature` — see [`InferenceConfig::resolve_layers_with_sources`].
7//! So `--profile chat --presence-penalty 1.2` with no `--temperature` silently
8//! discards the penalty: the profile claimed the temperature, and the coupled
9//! trio comes only from the rung that did.
10//!
11//! That is correct, and it is invisible. A resolved config cannot show it —
12//! a discarded value and a value nobody ever named look identical once the
13//! ladder has folded. Only the provenance distinguishes them.
14//!
15//! # Why the rung is a parameter
16//!
17//! There is more than one ladder. The stored ladder
18//! ([`InferenceConfig::resolve_with_profile`]) puts the request at rung 0; the
19//! request pipeline's puts `cli` at 0 and the *client* at 1. A helper that
20//! hardcoded 0 would be right on one and quietly wrong on the other, reporting
21//! the client's losses as the operator's.
22//!
23//! # Why `FloorCoupled` alone is not the test
24//!
25//! [`ParamSource::FloorCoupled`] says the coupling rule fired — not that *this*
26//! caller lost. When the claiming rung supplied its own value the field reports
27//! [`ParamSource::Layer`] instead, and the caller's value is just as gone. The
28//! test is therefore "did my rung win", not "which rule ran".
29
30use crate::domain::{FieldSources, InferenceConfig, ParamSource};
31
32/// The parameters `named` set that did **not** survive to the resolution.
33///
34/// Compares values, not just provenance: when the winning rung happens to name
35/// the same number, nothing was lost and there is nothing to report.
36///
37/// Returns `snake_case` field names, matching [`FieldSources::iter`] and
38/// [`InferenceConfig::to_openai_json_patch`].
39#[must_use]
40pub fn discarded_from_rung(
41    named: &InferenceConfig,
42    resolved: &InferenceConfig,
43    sources: &FieldSources,
44    rung: usize,
45) -> Vec<&'static str> {
46    let requested = named.to_openai_json_patch();
47    let survived = resolved.to_openai_json_patch();
48    let unsendable = non_finite_fields(named);
49
50    sources
51        .iter()
52        .filter(|(field, source)| {
53            // A non-finite value the caller named never reaches the wire at
54            // all, whichever rung "won" — JSON has no NaN or infinity, so
55            // `to_openai_json_patch` drops it. Reported unconditionally,
56            // because the alternative is the exact silence this whole helper
57            // exists to break, and because it is absent from `requested` and
58            // so would otherwise fall out of the membership test below.
59            if unsendable.contains(field) {
60                return true;
61            }
62            requested.contains_key(*field)
63                && !won(*source, rung)
64                && requested.get(*field) != survived.get(*field)
65        })
66        .map(|(field, _)| field)
67        .collect()
68}
69
70/// Float fields the caller set to a value JSON cannot carry.
71///
72/// Listed explicitly rather than derived, because the derivation would have to
73/// go through the very serialisation that loses them. The drift risk that
74/// creates is covered by `every_float_field_is_checked_for_non_finiteness`,
75/// which fails if a new float field is added and not named here.
76fn non_finite_fields(c: &InferenceConfig) -> Vec<&'static str> {
77    [
78        ("temperature", c.temperature),
79        ("top_p", c.top_p),
80        ("presence_penalty", c.presence_penalty),
81        ("repeat_penalty", c.repeat_penalty),
82        ("min_p", c.min_p),
83        ("frequency_penalty", c.frequency_penalty),
84        ("dynatemp_range", c.dynatemp_range),
85        ("dynatemp_exponent", c.dynatemp_exponent),
86        ("top_n_sigma", c.top_n_sigma),
87        ("dry_multiplier", c.dry_multiplier),
88        ("dry_base", c.dry_base),
89    ]
90    .into_iter()
91    .filter_map(|(field, value)| match value {
92        Some(v) if !v.is_finite() => Some(field),
93        _ => None,
94    })
95    .collect()
96}
97
98/// Whether the value at `rung` is the one that reached the resolution.
99///
100/// Exhaustive rather than a `matches!`, per the argument on
101/// [`ParamSource::is_deliberate_choice`] and because
102/// `scripts/check_param_source_exhaustive.sh` requires it: a new variant
103/// should force a decision here rather than default into "not ours".
104const fn won(source: ParamSource, rung: usize) -> bool {
105    match source {
106        // The only outcome in which this caller's value is what runs.
107        ParamSource::Layer(index) => index == rung,
108        // Everything else means this caller's value is not what runs:
109        // `FloorCoupled` — the coupling rule passed every lower rung over,
110        // this one included; `SuppressedByTemplate` — a later stage dropped it
111        // because the template never reads the field; `Floor`/`Unset` — nobody
112        // named it, so this caller did not either. Listed rather than
113        // wildcarded so a new variant forces a decision here.
114        ParamSource::FloorCoupled
115        | ParamSource::SuppressedByTemplate
116        | ParamSource::Floor
117        | ParamSource::Unset => false,
118    }
119}
120
121#[cfg(test)]
122#[path = "sampling_discards_tests.rs"]
123mod sampling_discards_tests;
124
125#[cfg(test)]
126#[path = "sampling_discards_non_finite_tests.rs"]
127mod sampling_discards_non_finite_tests;