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;