metrale_sampling/
penalties.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-26: The host penalty stage: the LZ and DRY penalties and
4//! `apply_penalties_and_bias`, which applies every penalty and then the
5//! per-token logit bias. Re-exported at the crate root.
6//!
7//! Owner: metrale-sampling.
8//! Invariants:
9//! - Every step of `apply_penalties_and_bias` is gated on its own
10//!   `SamplingParams` field, so neutral params leave the logits unchanged.
11
12use super::SamplingParams;
13
14/// 2026-09-25: LZ penalty over the last `LZ_WINDOW` (256) tokens of `history`.
15///
16/// For each token in that window and each n-gram length 3, 4 and 5, count the
17/// earlier n-grams that equal the last n-1 tokens followed by that token, and
18/// subtract `penalty * (ngram_len - 2) * count` from its logit. Token ids at
19/// or above `logits.len()` are skipped.
20pub fn apply_lz_penalty(logits: &mut [f32], history: &[u32], penalty: f32) {
21    use std::collections::HashSet;
22    const LZ_WINDOW: usize = 256;
23    let history = if history.len() > LZ_WINDOW {
24        &history[history.len() - LZ_WINDOW..]
25    } else {
26        history
27    };
28    let n = logits.len();
29    // 2026-09-25: A token absent from the window cannot end a matching n-gram.
30    let token_set: HashSet<u32> = history.iter().copied().collect();
31    for &candidate in &token_set {
32        if (candidate as usize) >= n {
33            continue;
34        }
35        for ngram_len in 3..=5usize {
36            if history.len() < ngram_len {
37                continue;
38            }
39            // 2026-09-25: The n-gram that would form: the last ngram_len-1 tokens,
40            // then `candidate`.
41            let suffix = &history[history.len() - (ngram_len - 1)..];
42            let count = history
43                .windows(ngram_len)
44                .filter(|w| w[..ngram_len - 1] == *suffix && w[ngram_len - 1] == candidate)
45                .count();
46            if count > 0 {
47                logits[candidate as usize] -= penalty * (ngram_len as f32 - 2.0) * count as f32;
48            }
49        }
50    }
51}
52
53/// 2026-09-25: DRY ("don't repeat yourself") penalty. No-op for an empty
54/// history or `multiplier == 0.0`.
55///
56/// For each earlier position `i`, `match_lengths[i]` is the number of tokens
57/// that the history ending at `i` shares with the end of the whole history,
58/// counted backwards and stopping after a breaker (0 when `history[i]` is a
59/// breaker). When that length `len` exceeds `allowed_length`, the logit of
60/// `history[i + len]` is reduced by `multiplier * base^(len - allowed_length)`.
61pub fn apply_dry_penalty(
62    logits: &mut [f32],
63    history: &[u32],
64    multiplier: f32,
65    base: f32,
66    allowed_length: u32,
67    breakers: &[u32],
68) {
69    if history.is_empty() || multiplier == 0.0 {
70        return;
71    }
72    let n = logits.len();
73    let hist_len = history.len();
74    let allowed = allowed_length as usize;
75
76    let mut match_lengths = vec![0usize; hist_len];
77    for i in (0..hist_len.saturating_sub(1)).rev() {
78        if breakers.contains(&history[i]) {
79            match_lengths[i] = 0;
80            continue;
81        }
82        let mut len = 0;
83        let mut j = i;
84        let mut k = hist_len - 1;
85        while j < k && history[j] == history[k] {
86            len += 1;
87            if breakers.contains(&history[j]) {
88                break;
89            }
90            if j == 0 {
91                break;
92            }
93            j -= 1;
94            k -= 1;
95        }
96        match_lengths[i] = len;
97    }
98
99    #[allow(clippy::needless_range_loop)]
100    for i in 0..hist_len.saturating_sub(1) {
101        let len = match_lengths[i];
102        if len > allowed {
103            let extend_pos = i + len;
104            if extend_pos < hist_len {
105                let token = history[extend_pos] as usize;
106                if token < n {
107                    let penalty = multiplier * base.powi((len - allowed) as i32);
108                    logits[token] -= penalty;
109                }
110            }
111        }
112    }
113}
114
115/// 2026-09-25: Apply the repetition, presence, frequency, LZ and DRY penalties,
116/// then the per-token logit bias, to `logits` in place, using `token_history`.
117///
118/// Called by `sample_with_params_seeded` and by the server's
119/// `process_position_logits` (scheduler/logit_processors/mod.rs). It changes
120/// nothing when `repetition_penalty` is 1.0 (or <= 0.0), both additive
121/// penalties are 0.0, `lz_penalty` and `dry_multiplier` are <= 0.0, and
122/// `logit_bias` is empty: every step is gated on its own parameter.
123pub fn apply_penalties_and_bias(
124    logits: &mut [f32],
125    params: &SamplingParams,
126    token_history: &[u32],
127) {
128    let n = logits.len();
129
130    // 2026-09-25: Repetition penalty over the last `repetition_penalty_window`
131    // tokens (all of them when the window is 0 or covers the history). A value
132    // <= 0.0 is skipped: dividing by 0.0 would turn positive logits into inf.
133    let rep_penalty = params.repetition_penalty;
134    if rep_penalty != 1.0 && rep_penalty > 0.0 && !token_history.is_empty() {
135        let window = params.repetition_penalty_window as usize;
136        let effective = if window > 0 && window < token_history.len() {
137            &token_history[token_history.len() - window..]
138        } else {
139            token_history
140        };
141        for &tid in effective {
142            if (tid as usize) < n {
143                let logit = &mut logits[tid as usize];
144                if *logit > 0.0 {
145                    *logit /= rep_penalty;
146                } else {
147                    *logit *= rep_penalty;
148                }
149            }
150        }
151    }
152
153    // 2026-09-25: Additive penalties over the same window. For a token with
154    // count c in the window: z' = z - frequency_penalty * c - presence_penalty.
155    let freq_pen = params.frequency_penalty;
156    let pres_pen = params.presence_penalty;
157    if (freq_pen != 0.0 || pres_pen != 0.0) && !token_history.is_empty() {
158        let window = params.repetition_penalty_window as usize;
159        let effective = if window > 0 && window < token_history.len() {
160            &token_history[token_history.len() - window..]
161        } else {
162            token_history
163        };
164        let mut counts = std::collections::HashMap::<u32, u32>::new();
165        for &tid in effective {
166            *counts.entry(tid).or_insert(0) += 1;
167        }
168        for (&tid, &count) in &counts {
169            if (tid as usize) < n {
170                logits[tid as usize] -= freq_pen * count as f32 + pres_pen;
171            }
172        }
173    }
174
175    if params.lz_penalty > 0.0 && token_history.len() >= 4 {
176        apply_lz_penalty(logits, token_history, params.lz_penalty);
177    }
178
179    if params.dry_multiplier > 0.0 && token_history.len() >= 3 {
180        apply_dry_penalty(
181            logits,
182            token_history,
183            params.dry_multiplier,
184            params.dry_base,
185            params.dry_allowed_length,
186            &params.dry_sequence_breakers,
187        );
188    }
189
190    for &(tid, bias) in &params.logit_bias {
191        if (tid as usize) < n {
192            logits[tid as usize] += bias;
193        }
194    }
195}