Skip to main content

kime_core/
answer.rs

1//! Answers, built from a question's option logits and act logits the way Laya builds them.
2//!
3//! This is Laya's `Agent._decode_answers` (unchanged from 0.3.7 to 0.3.11), done in the same float
4//! types numpy uses so the rounded numbers match: the calibrated softmax and the entropy in f32,
5//! the expected score in f64, and sums in numpy's pairwise order. `exp` and `log` are numpy's own
6//! AVX2 routines rather than libm's, since they differ in the last bit often enough to move a
7//! rounded probability: 2 of the 400 parity responses changed in the fourth decimal with libm.
8
9use serde_json::{Map, Value, json};
10
11use crate::request::{Criteria, QType, Question};
12use crate::round::round_distribution;
13
14/// The `model` field of a compat response.
15pub const LAYA_MODEL: &str = "laya-rl-agent";
16
17/// The range Laya 0.3.9 and later confine a checkpoint's temperatures to. The English checkpoint
18/// ships 0.1006 for `choice:11+`, which would sharpen the logits tenfold.
19pub const TEMPERATURE_RANGE: (f64, f64) = (0.5, 5.0);
20
21/// A temperature Laya would use: `t` confined to [`TEMPERATURE_RANGE`], or 1 when it is not a
22/// finite number.
23#[must_use]
24pub fn clamp_temperature(t: f64) -> f64 {
25    if t.is_finite() { t.clamp(TEMPERATURE_RANGE.0, TEMPERATURE_RANGE.1) } else { 1.0 }
26}
27
28/// Laya's `temp_bucket`: the key of `temperature_by_options` for a type and an option count.
29#[must_use]
30pub fn temperature_bucket(t: QType, k: usize) -> String {
31    let size = match k {
32        0..=2 => "2",
33        3..=5 => "3-5",
34        6..=10 => "6-10",
35        _ => "11+",
36    };
37    format!("{}:{size}", t.as_str())
38}
39
40/// The calibration of a compat checkpoint, from its `rl_agent_config.json`, already clamped.
41#[derive(Debug, Clone, PartialEq)]
42pub struct Temperatures {
43    by_type: [f64; 3],
44    by_options: Vec<(String, f64)>,
45}
46
47impl Temperatures {
48    /// From the checkpoint's `temperature` (choice, score, noul) and `temperature_by_options`.
49    #[must_use]
50    pub fn new(by_type: [f64; 3], by_options: &[(String, f64)]) -> Self {
51        Self {
52            by_type: by_type.map(clamp_temperature),
53            by_options: by_options
54                .iter()
55                .map(|(k, t)| (k.clone(), clamp_temperature(*t)))
56                .collect(),
57        }
58    }
59
60    /// The temperature for a question of type `t` with `k` options.
61    #[must_use]
62    pub fn get(&self, t: QType, k: usize) -> f64 {
63        let bucket = temperature_bucket(t, k);
64        self.by_options
65            .iter()
66            .find(|(b, _)| *b == bucket)
67            .map_or(self.by_type[t.index()], |(_, t)| *t)
68    }
69}
70
71impl Default for Temperatures {
72    fn default() -> Self {
73        Self::new([1.0; 3], &[])
74    }
75}
76
77/// One question's answer.
78#[derive(Debug, Clone, PartialEq)]
79pub enum Answer {
80    /// A choice question.
81    Choice {
82        /// The most likely label, the first one on a tie.
83        choice: String,
84        /// Each label with its calibrated probability, in option order.
85        probabilities: Vec<(String, f64)>,
86        /// Normalized entropy confidence.
87        confidence: f64,
88        /// The act head's probability that acting on this answer is right.
89        act_probability: f64,
90    },
91    /// A score question.
92    Score {
93        /// The expected level, which can fall between levels.
94        score: f64,
95        /// The levels exactly as sent.
96        legend: Vec<Value>,
97        /// The probability of each level.
98        probabilities: Vec<f64>,
99        /// Normalized entropy confidence.
100        confidence: f64,
101        /// See [`Answer::Choice`].
102        act_probability: f64,
103    },
104    /// A noul question.
105    Noul {
106        /// The probability the statement is true.
107        noul: f64,
108        /// `max(noul, 1 - noul)`.
109        confidence: f64,
110        /// See [`Answer::Choice`].
111        act_probability: f64,
112    },
113}
114
115/// Rounds to `digits` places the way Python's `round` does: correctly, ties to even.
116#[must_use]
117pub fn py_round(x: f64, digits: usize) -> f64 {
118    format!("{x:.digits$}").parse().unwrap_or(x)
119}
120
121impl Answer {
122    /// The answer in Laya's JSON shape, with every number rounded to 4 places.
123    #[must_use]
124    pub fn to_json(&self) -> Value {
125        let r = |x: f64| py_round(x, 4);
126        match self {
127            Answer::Choice { choice, probabilities, confidence, act_probability } => json!({
128                "type": "choice",
129                "choice": choice,
130                "probabilities": probabilities.iter().map(|(k, p)| (k.clone(), json!(r(*p)))).collect::<Map<_, _>>(),
131                "confidence": r(*confidence),
132                "action": {"act_probability": r(*act_probability)},
133            }),
134            Answer::Score { score, legend, probabilities, confidence, act_probability } => json!({
135                "type": "score",
136                "score": r(*score),
137                "legend": legend.iter().enumerate().map(|(i, v)| (i.to_string(), v.clone())).collect::<Map<_, _>>(),
138                "probabilities": probabilities.iter().enumerate().map(|(i, p)| (i.to_string(), json!(r(*p)))).collect::<Map<_, _>>(),
139                "confidence": r(*confidence),
140                "action": {"act_probability": r(*act_probability)},
141            }),
142            Answer::Noul { noul, confidence, act_probability } => json!({
143                "type": "noul",
144                "noul": r(*noul),
145                "confidence": r(*confidence),
146                "action": {"act_probability": r(*act_probability)},
147            }),
148        }
149    }
150}
151
152impl Answer {
153    /// The answer in Jev's JSON shape, from spec/03-api.md: probabilities rounded to `precision`
154    /// places so that they still sum to exactly 1, scores and confidences rounded the same way,
155    /// no `action` block, and no confidence on a noul answer. `None` keeps the raw values.
156    /// `entropy` picks Laya's normalized entropy confidence over Jev's formula.
157    #[must_use]
158    pub fn to_jev_json(&self, precision: Option<u32>, entropy: bool) -> Value {
159        let r = |x: f64| match precision {
160            Some(d) => py_round(x, d as usize),
161            None => f64::from(x as f32),
162        };
163        let dist = |p: &[f64]| -> Vec<f64> {
164            match precision {
165                Some(d) => round_distribution(p, d),
166                None => p.iter().map(|&x| f64::from(x as f32)).collect(),
167            }
168        };
169        match self {
170            Answer::Choice { choice, probabilities, confidence, .. } => {
171                let raw: Vec<f64> = probabilities.iter().map(|p| p.1).collect();
172                let conf = if entropy { *confidence } else { crate::confidence::choice_jev(&raw) };
173                json!({
174                    "type": "choice",
175                    "choice": choice,
176                    "confidence": r(conf),
177                    "probabilities": probabilities.iter().zip(dist(&raw)).map(|((k, _), p)| (k.clone(), json!(p))).collect::<Map<_, _>>(),
178                })
179            }
180            Answer::Score { score, legend, probabilities, confidence, .. } => {
181                let conf =
182                    if entropy { *confidence } else { crate::confidence::score_jev(probabilities) };
183                json!({
184                    "type": "score",
185                    "score": r(*score),
186                    "confidence": r(conf),
187                    "legend": legend.iter().enumerate().map(|(i, v)| (i.to_string(), v.clone())).collect::<Map<_, _>>(),
188                    "probabilities": dist(probabilities).into_iter().enumerate().map(|(i, p)| (i.to_string(), json!(p))).collect::<Map<_, _>>(),
189                })
190            }
191            Answer::Noul { noul, .. } => json!({"type": "noul", "noul": r(*noul)}),
192        }
193    }
194}
195
196/// The answers to one request.
197#[derive(Debug, Clone, PartialEq)]
198pub struct Response {
199    /// The model that answered.
200    pub model: String,
201    /// The answers, in request order.
202    pub answers: Vec<(String, Answer)>,
203    /// Tokens read over all the request's sequences.
204    pub input_tokens: usize,
205}
206
207impl Response {
208    /// The answer to question `id`.
209    #[must_use]
210    pub fn get(&self, id: &str) -> Option<&Answer> {
211        self.answers.iter().find(|(q, _)| q == id).map(|(_, a)| a)
212    }
213
214    /// The response in Laya's JSON shape.
215    #[must_use]
216    pub fn to_json(&self) -> Value {
217        let answers: Map<_, _> =
218            self.answers.iter().map(|(id, a)| (id.clone(), a.to_json())).collect();
219        json!({
220            "model": self.model,
221            "answers": answers,
222            "usage": {"input_tokens": self.input_tokens, "output_tokens": 0},
223        })
224    }
225}
226
227/// numpy's pairwise sum, which is the order `ndarray.sum` adds contiguous values in.
228fn pairwise<T: Copy + Default + std::ops::Add<Output = T>>(x: &[T]) -> T {
229    let n = x.len();
230    if n < 8 {
231        return x.iter().fold(T::default(), |a, &b| a + b);
232    }
233    if n <= 128 {
234        let mut r = [x[0], x[1], x[2], x[3], x[4], x[5], x[6], x[7]];
235        let body = n - n % 8;
236        for c in x[8..body].as_chunks::<8>().0 {
237            for j in 0..8 {
238                r[j] = r[j] + c[j];
239            }
240        }
241        let mut s = ((r[0] + r[1]) + (r[2] + r[3])) + ((r[4] + r[5]) + (r[6] + r[7]));
242        for &v in &x[body..] {
243            s = s + v;
244        }
245        return s;
246    }
247    let half = n / 2 - (n / 2) % 8;
248    pairwise(&x[..half]) + pairwise(&x[half..])
249}
250
251/// numpy's float32 `exp` on x86 with AVX2 and FMA (`simd_exp_FLOAT` in
252/// `loops_exponent_log.dispatch.c.src`), one lane at a time: Cody-Waite reduction by ln 2, a 5/2
253/// rational approximation, then the power of two added straight into the exponent bits.
254// The constants are numpy's, digits and all, so they can be checked against its source.
255#[allow(clippy::excessive_precision)]
256fn np_exp(x: f32) -> f32 {
257    const XMAX: f32 = 88.722_84;
258    const XMIN: f32 = -103.972_08;
259    const MAGIC: f32 = 12_582_912.0;
260    const P: [f32; 6] = [
261        9.999_999_999_980_870_924_916e-1,
262        7.257_664_613_233_124_478_488e-1,
263        2.473_615_434_895_520_810_817e-1,
264        5.114_512_081_637_298_353_406e-2,
265        6.757_896_990_527_504_603_057e-3,
266        5.082_762_527_590_693_718_096e-4,
267    ];
268    const Q: [f32; 3] = [1.0, -2.742_335_390_411_667_452_936e-1, 2.159_509_375_685_829_852_307e-2];
269    if x.is_nan() {
270        return x;
271    }
272    if x >= XMAX {
273        return f32::INFINITY;
274    }
275    if x <= XMIN {
276        return 0.0;
277    }
278    let q = (x * std::f32::consts::LOG2_E + MAGIC) - MAGIC;
279    let r = q.mul_add(-1.428_606_77e-6, q.mul_add(-6.931_457_52e-1, x));
280    let r = q.mul_add(0.0, r);
281    let num =
282        P[5].mul_add(r, P[4]).mul_add(r, P[3]).mul_add(r, P[2]).mul_add(r, P[1]).mul_add(r, P[0]);
283    let den = Q[2].mul_add(r, Q[1]).mul_add(r, Q[0]);
284    let poly = num / den;
285    // q is a whole number here, and at most 128 in size.
286    let shift =
287        |poly: f32, q: f32| f32::from_bits(poly.to_bits().wrapping_add(((q as i32) << 23) as u32));
288    if q <= -125.0 {
289        let diff = (-(q + 125.0)) as u32;
290        shift(poly, -125.0) / (1u32 << diff) as f32
291    } else {
292        shift(poly, q)
293    }
294}
295
296/// numpy's float32 `log` on x86 with AVX2 and FMA (`simd_log_FLOAT`), for a positive finite `x`:
297/// the mantissa scaled into (sqrt(1/2), sqrt(2)], a 5/5 rational approximation of `log(1 + m)`,
298/// plus the exponent times ln 2.
299#[allow(clippy::excessive_precision)]
300fn np_log(x: f32) -> f32 {
301    const P: [f32; 6] = [
302        0.0,
303        9.999_999_999_999_998_702_752e-1,
304        2.112_677_543_073_053_063_722,
305        1.480_000_633_576_506_585_156,
306        3.808_837_741_388_407_920_751e-1,
307        2.589_979_117_907_922_693_523e-2,
308    ];
309    const Q: [f32; 6] = [
310        1.0,
311        2.612_677_543_073_109_236_779,
312        2.453_006_071_784_736_363_091,
313        9.864_942_958_519_418_960_339e-1,
314        1.546_476_374_983_906_719_538e-1,
315        5.875_095_403_124_574_342_950e-3,
316    ];
317    if x.is_nan() || x < 0.0 {
318        return f32::NAN;
319    }
320    if x == 0.0 {
321        return f32::NEG_INFINITY;
322    }
323    if x.is_infinite() {
324        return x;
325    }
326    let (bits, bias) = if x < f32::MIN_POSITIVE {
327        ((x * f32::from_bits(0x7180_0000)).to_bits(), 100.0)
328    } else {
329        (x.to_bits(), 0.0)
330    };
331    let mut exponent = ((bits >> 23) as i32 - 126) as f32 - bias;
332    let mut m = f32::from_bits((bits & 0x7f_ffff) | (126 << 23));
333    if m <= std::f32::consts::FRAC_1_SQRT_2 {
334        m += m;
335        exponent -= 1.0;
336    }
337    let m = m - 1.0;
338    let num =
339        P[5].mul_add(m, P[4]).mul_add(m, P[3]).mul_add(m, P[2]).mul_add(m, P[1]).mul_add(m, P[0]);
340    let den =
341        Q[5].mul_add(m, Q[4]).mul_add(m, Q[3]).mul_add(m, Q[2]).mul_add(m, Q[1]).mul_add(m, Q[0]);
342    exponent.mul_add(std::f32::consts::LN_2, num / den)
343}
344
345/// The calibrated distribution over `logits` at temperature `t`, in f32 as numpy computes it.
346fn softmax(logits: &[f32], t: f64) -> Vec<f32> {
347    let t = t as f32;
348    let z: Vec<f32> = logits.iter().map(|&l| l / t).collect();
349    let top = z.iter().copied().fold(f32::NEG_INFINITY, f32::max);
350    let e: Vec<f32> = z.iter().map(|&v| np_exp(v - top)).collect();
351    let s = pairwise(&e);
352    e.iter().map(|&v| v / s).collect()
353}
354
355/// Laya's `confidence_from_probs`, in f32.
356fn entropy_confidence(p: &[f32]) -> f64 {
357    let k = p.len();
358    if k < 2 {
359        return 1.0;
360    }
361    let terms: Vec<f32> = p.iter().map(|&v| v * np_log(v.clamp(1e-12, 1.0))).collect();
362    let ent = -pairwise(&terms);
363    f64::from((1.0 - ent / (k as f64).ln() as f32).clamp(0.0, 1.0))
364}
365
366/// The act head's first probability, as `torch.softmax` gives it.
367#[must_use]
368pub fn act_probability(act: [f32; 2]) -> f64 {
369    let top = act[0].max(act[1]);
370    let e = act.map(|v| (v - top).exp());
371    f64::from(e[0] * (1.0 / (e[0] + e[1])))
372}
373
374/// The answer to `q` from its option logits, in option order, and its two act logits.
375///
376/// # Panics
377///
378/// If `logits` does not have one value per option.
379#[must_use]
380pub fn laya_answer(q: &Question, logits: &[f32], act: [f32; 2], temps: &Temperatures) -> Answer {
381    let k = q.criteria.len();
382    assert_eq!(logits.len(), k, "one logit per option");
383    let p = softmax(logits, temps.get(q.qtype, k));
384    let act_probability = act_probability(act);
385    match &q.criteria {
386        Criteria::Choice(opts) => {
387            let mut best = 0;
388            for (i, &v) in p.iter().enumerate() {
389                if v > p[best] {
390                    best = i;
391                }
392            }
393            Answer::Choice {
394                choice: opts[best].label.clone(),
395                probabilities: opts
396                    .iter()
397                    .zip(&p)
398                    .map(|(o, &v)| (o.label.clone(), f64::from(v)))
399                    .collect(),
400                confidence: entropy_confidence(&p),
401                act_probability,
402            }
403        }
404        Criteria::Score(levels) => {
405            let weighted: Vec<f64> =
406                p.iter().enumerate().map(|(i, &v)| i as f64 * f64::from(v)).collect();
407            Answer::Score {
408                score: pairwise(&weighted),
409                legend: levels.clone(),
410                probabilities: p.iter().map(|&v| f64::from(v)).collect(),
411                confidence: entropy_confidence(&p),
412                act_probability,
413            }
414        }
415        Criteria::Noul { .. } => {
416            let yes = f64::from(p[1]);
417            Answer::Noul { noul: yes, confidence: yes.max(1.0 - yes), act_probability }
418        }
419    }
420}
421
422#[cfg(test)]
423// These values are exact, so comparing them exactly is the point.
424#[allow(clippy::float_cmp)]
425mod tests {
426    use super::*;
427
428    #[test]
429    fn pairwise_order() {
430        // Nine values: eight lanes plus one left over, which a left fold adds in another order.
431        let x = [1e8f32, 1.0, -1e8, 1.0, 0.5, 0.25, 3.0, 7.0, 1.0];
432        let lanes = ((1e8f32 + 1.0) + (-1e8 + 1.0)) + ((0.5 + 0.25) + (3.0 + 7.0)) + 1.0;
433        assert_eq!(pairwise(&x).to_bits(), lanes.to_bits());
434    }
435
436    #[test]
437    fn numpy_exp_log() {
438        // Values numpy 2.5 gives on an AVX2 machine, where libm's differ in the last bit.
439        for (x, want) in [(-0.0, 1.0f32), (-1.0, 0.367_879_43), (-20.0, 2.061_153_6e-9)] {
440            assert!((np_exp(x) - want).abs() <= want * 3e-7, "exp {x}");
441        }
442        assert_eq!(np_exp(0.0), 1.0);
443        assert_eq!(np_log(1.0), 0.0);
444        assert!((np_log(1e-12) - 1e-12f32.ln()).abs() < 1e-5);
445        assert!((np_log(0.3) - 0.3f32.ln()).abs() < 1e-6);
446    }
447
448    #[test]
449    fn rounding() {
450        assert_eq!(py_round(0.25, 1), 0.2);
451        assert_eq!(py_round(0.965_449_999, 4), 0.9654);
452        assert_eq!(py_round(1.0, 4), 1.0);
453    }
454
455    #[test]
456    fn buckets_and_clamp() {
457        let t = Temperatures::new([1.5, 1.2, 2.0], &[("choice:11+".into(), 0.1)]);
458        assert_eq!(t.get(QType::Choice, 12), 0.5);
459        assert_eq!(t.get(QType::Choice, 4), 1.5);
460        assert_eq!(temperature_bucket(QType::Score, 6), "score:6-10");
461        assert_eq!(clamp_temperature(f64::NAN), 1.0);
462    }
463
464    #[test]
465    fn jev_shape() {
466        let a = Answer::Choice {
467            choice: "b".into(),
468            probabilities: vec![("a".into(), 0.334), ("b".into(), 0.335), ("c".into(), 0.331)],
469            confidence: 0.1,
470            act_probability: 0.9,
471        };
472        let v = a.to_jev_json(Some(2), false);
473        let p = &v["probabilities"];
474        assert_eq!(
475            (p["a"].as_f64(), p["b"].as_f64(), p["c"].as_f64()),
476            (Some(0.33), Some(0.34), Some(0.33))
477        );
478        assert_eq!(v["choice"], "b");
479        assert!(v.get("action").is_none());
480        let s = Answer::Score {
481            score: 1.2345,
482            legend: vec![json!("lo"), json!("mid"), json!("hi")],
483            probabilities: vec![0.105, 0.555, 0.34],
484            confidence: 0.5,
485            act_probability: 1.0,
486        };
487        let v = s.to_jev_json(Some(1), false);
488        assert_eq!(v["score"], 1.2);
489        assert_eq!(v["legend"]["2"], "hi");
490        let n = Answer::Noul { noul: 0.876, confidence: 0.876, act_probability: 1.0 };
491        assert_eq!(n.to_jev_json(Some(2), false), json!({"type": "noul", "noul": 0.88}));
492        assert_eq!(n.to_jev_json(None, false)["noul"], f64::from(0.876f32));
493    }
494}