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    /// Laya's `answer_confidence` (added in 0.3.20): the probability of the answer given, which
123    /// is what the temperatures are fitted to, unlike the entropy `confidence`.
124    #[must_use]
125    pub fn answer_confidence(&self) -> f64 {
126        match self {
127            Answer::Choice { probabilities, .. } => {
128                probabilities.iter().map(|p| p.1).fold(0.0, f64::max).clamp(0.0, 1.0)
129            }
130            Answer::Score { probabilities, .. } => {
131                probabilities.iter().copied().fold(0.0, f64::max).clamp(0.0, 1.0)
132            }
133            Answer::Noul { confidence, .. } => *confidence,
134        }
135    }
136
137    /// The answer in Laya's JSON shape, with every number rounded to 4 places.
138    #[must_use]
139    pub fn to_json(&self) -> Value {
140        let answer_confidence = py_round(self.answer_confidence(), 4);
141        let r = |x: f64| py_round(x, 4);
142        match self {
143            Answer::Choice { choice, probabilities, confidence, act_probability } => json!({
144                "type": "choice",
145                "choice": choice,
146                "probabilities": probabilities.iter().map(|(k, p)| (k.clone(), json!(r(*p)))).collect::<Map<_, _>>(),
147                "confidence": r(*confidence),
148                "answer_confidence": answer_confidence,
149                "action": {"act_probability": r(*act_probability)},
150            }),
151            Answer::Score { score, legend, probabilities, confidence, act_probability } => json!({
152                "type": "score",
153                "score": r(*score),
154                "legend": legend.iter().enumerate().map(|(i, v)| (i.to_string(), v.clone())).collect::<Map<_, _>>(),
155                "probabilities": probabilities.iter().enumerate().map(|(i, p)| (i.to_string(), json!(r(*p)))).collect::<Map<_, _>>(),
156                "confidence": r(*confidence),
157                "answer_confidence": answer_confidence,
158                "action": {"act_probability": r(*act_probability)},
159            }),
160            Answer::Noul { noul, confidence, act_probability } => json!({
161                "type": "noul",
162                "noul": r(*noul),
163                "confidence": r(*confidence),
164                "answer_confidence": answer_confidence,
165                "action": {"act_probability": r(*act_probability)},
166            }),
167        }
168    }
169}
170
171impl Answer {
172    /// The answer in Jev's JSON shape, from spec/03-api.md: probabilities rounded to `precision`
173    /// places so that they still sum to exactly 1, scores and confidences rounded the same way,
174    /// no `action` block, and no confidence on a noul answer. `None` keeps the raw values.
175    /// `entropy` picks Laya's normalized entropy confidence over Jev's formula.
176    #[must_use]
177    pub fn to_jev_json(&self, precision: Option<u32>, entropy: bool) -> Value {
178        let r = |x: f64| match precision {
179            Some(d) => py_round(x, d as usize),
180            None => f64::from(x as f32),
181        };
182        let dist = |p: &[f64]| -> Vec<f64> {
183            match precision {
184                Some(d) => round_distribution(p, d),
185                None => p.iter().map(|&x| f64::from(x as f32)).collect(),
186            }
187        };
188        match self {
189            Answer::Choice { choice, probabilities, confidence, .. } => {
190                let raw: Vec<f64> = probabilities.iter().map(|p| p.1).collect();
191                let conf = if entropy { *confidence } else { crate::confidence::choice_jev(&raw) };
192                json!({
193                    "type": "choice",
194                    "choice": choice,
195                    "confidence": r(conf),
196                    "probabilities": probabilities.iter().zip(dist(&raw)).map(|((k, _), p)| (k.clone(), json!(p))).collect::<Map<_, _>>(),
197                })
198            }
199            Answer::Score { score, legend, probabilities, confidence, .. } => {
200                let conf =
201                    if entropy { *confidence } else { crate::confidence::score_jev(probabilities) };
202                json!({
203                    "type": "score",
204                    "score": r(*score),
205                    "confidence": r(conf),
206                    "legend": legend.iter().enumerate().map(|(i, v)| (i.to_string(), v.clone())).collect::<Map<_, _>>(),
207                    "probabilities": dist(probabilities).into_iter().enumerate().map(|(i, p)| (i.to_string(), json!(p))).collect::<Map<_, _>>(),
208                })
209            }
210            Answer::Noul { noul, .. } => json!({"type": "noul", "noul": r(*noul)}),
211        }
212    }
213}
214
215/// The answers to one request.
216#[derive(Debug, Clone, PartialEq)]
217pub struct Response {
218    /// The model that answered.
219    pub model: String,
220    /// The answers, in request order.
221    pub answers: Vec<(String, Answer)>,
222    /// Tokens read over all the request's sequences.
223    pub input_tokens: usize,
224}
225
226impl Response {
227    /// The answer to question `id`.
228    #[must_use]
229    pub fn get(&self, id: &str) -> Option<&Answer> {
230        self.answers.iter().find(|(q, _)| q == id).map(|(_, a)| a)
231    }
232
233    /// The response in Laya's JSON shape.
234    #[must_use]
235    pub fn to_json(&self) -> Value {
236        let answers: Map<_, _> =
237            self.answers.iter().map(|(id, a)| (id.clone(), a.to_json())).collect();
238        json!({
239            "model": self.model,
240            "answers": answers,
241            "usage": {"input_tokens": self.input_tokens, "output_tokens": 0},
242        })
243    }
244}
245
246/// numpy's pairwise sum, which is the order `ndarray.sum` adds contiguous values in.
247fn pairwise<T: Copy + Default + std::ops::Add<Output = T>>(x: &[T]) -> T {
248    let n = x.len();
249    if n < 8 {
250        return x.iter().fold(T::default(), |a, &b| a + b);
251    }
252    if n <= 128 {
253        let mut r = [x[0], x[1], x[2], x[3], x[4], x[5], x[6], x[7]];
254        let body = n - n % 8;
255        for c in x[8..body].as_chunks::<8>().0 {
256            for j in 0..8 {
257                r[j] = r[j] + c[j];
258            }
259        }
260        let mut s = ((r[0] + r[1]) + (r[2] + r[3])) + ((r[4] + r[5]) + (r[6] + r[7]));
261        for &v in &x[body..] {
262            s = s + v;
263        }
264        return s;
265    }
266    let half = n / 2 - (n / 2) % 8;
267    pairwise(&x[..half]) + pairwise(&x[half..])
268}
269
270/// numpy's float32 `exp` on x86 with AVX2 and FMA (`simd_exp_FLOAT` in
271/// `loops_exponent_log.dispatch.c.src`), one lane at a time: Cody-Waite reduction by ln 2, a 5/2
272/// rational approximation, then the power of two added straight into the exponent bits.
273// The constants are numpy's, digits and all, so they can be checked against its source.
274#[allow(clippy::excessive_precision)]
275fn np_exp(x: f32) -> f32 {
276    const XMAX: f32 = 88.722_84;
277    const XMIN: f32 = -103.972_08;
278    const MAGIC: f32 = 12_582_912.0;
279    const P: [f32; 6] = [
280        9.999_999_999_980_870_924_916e-1,
281        7.257_664_613_233_124_478_488e-1,
282        2.473_615_434_895_520_810_817e-1,
283        5.114_512_081_637_298_353_406e-2,
284        6.757_896_990_527_504_603_057e-3,
285        5.082_762_527_590_693_718_096e-4,
286    ];
287    const Q: [f32; 3] = [1.0, -2.742_335_390_411_667_452_936e-1, 2.159_509_375_685_829_852_307e-2];
288    if x.is_nan() {
289        return x;
290    }
291    if x >= XMAX {
292        return f32::INFINITY;
293    }
294    if x <= XMIN {
295        return 0.0;
296    }
297    let q = (x * std::f32::consts::LOG2_E + MAGIC) - MAGIC;
298    let r = q.mul_add(-1.428_606_77e-6, q.mul_add(-6.931_457_52e-1, x));
299    let r = q.mul_add(0.0, r);
300    let num =
301        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]);
302    let den = Q[2].mul_add(r, Q[1]).mul_add(r, Q[0]);
303    let poly = num / den;
304    // q is a whole number here, and at most 128 in size.
305    let shift =
306        |poly: f32, q: f32| f32::from_bits(poly.to_bits().wrapping_add(((q as i32) << 23) as u32));
307    if q <= -125.0 {
308        let diff = (-(q + 125.0)) as u32;
309        shift(poly, -125.0) / (1u32 << diff) as f32
310    } else {
311        shift(poly, q)
312    }
313}
314
315/// numpy's float32 `log` on x86 with AVX2 and FMA (`simd_log_FLOAT`), for a positive finite `x`:
316/// the mantissa scaled into (sqrt(1/2), sqrt(2)], a 5/5 rational approximation of `log(1 + m)`,
317/// plus the exponent times ln 2.
318#[allow(clippy::excessive_precision)]
319fn np_log(x: f32) -> f32 {
320    const P: [f32; 6] = [
321        0.0,
322        9.999_999_999_999_998_702_752e-1,
323        2.112_677_543_073_053_063_722,
324        1.480_000_633_576_506_585_156,
325        3.808_837_741_388_407_920_751e-1,
326        2.589_979_117_907_922_693_523e-2,
327    ];
328    const Q: [f32; 6] = [
329        1.0,
330        2.612_677_543_073_109_236_779,
331        2.453_006_071_784_736_363_091,
332        9.864_942_958_519_418_960_339e-1,
333        1.546_476_374_983_906_719_538e-1,
334        5.875_095_403_124_574_342_950e-3,
335    ];
336    if x.is_nan() || x < 0.0 {
337        return f32::NAN;
338    }
339    if x == 0.0 {
340        return f32::NEG_INFINITY;
341    }
342    if x.is_infinite() {
343        return x;
344    }
345    let (bits, bias) = if x < f32::MIN_POSITIVE {
346        ((x * f32::from_bits(0x7180_0000)).to_bits(), 100.0)
347    } else {
348        (x.to_bits(), 0.0)
349    };
350    let mut exponent = ((bits >> 23) as i32 - 126) as f32 - bias;
351    let mut m = f32::from_bits((bits & 0x7f_ffff) | (126 << 23));
352    if m <= std::f32::consts::FRAC_1_SQRT_2 {
353        m += m;
354        exponent -= 1.0;
355    }
356    let m = m - 1.0;
357    let num =
358        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]);
359    let den =
360        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]);
361    exponent.mul_add(std::f32::consts::LN_2, num / den)
362}
363
364/// The calibrated distribution over `logits` at temperature `t`, in f32 as numpy computes it.
365fn softmax(logits: &[f32], t: f64) -> Vec<f32> {
366    let t = t as f32;
367    let z: Vec<f32> = logits.iter().map(|&l| l / t).collect();
368    let top = z.iter().copied().fold(f32::NEG_INFINITY, f32::max);
369    let e: Vec<f32> = z.iter().map(|&v| np_exp(v - top)).collect();
370    let s = pairwise(&e);
371    e.iter().map(|&v| v / s).collect()
372}
373
374/// Laya's `confidence_from_probs`, in f32.
375fn entropy_confidence(p: &[f32]) -> f64 {
376    let k = p.len();
377    if k < 2 {
378        return 1.0;
379    }
380    let terms: Vec<f32> = p.iter().map(|&v| v * np_log(v.clamp(1e-12, 1.0))).collect();
381    let ent = -pairwise(&terms);
382    f64::from((1.0 - ent / (k as f64).ln() as f32).clamp(0.0, 1.0))
383}
384
385/// The act head's first probability, as `torch.softmax` gives it.
386#[must_use]
387pub fn act_probability(act: [f32; 2]) -> f64 {
388    let top = act[0].max(act[1]);
389    let e = act.map(|v| (v - top).exp());
390    f64::from(e[0] * (1.0 / (e[0] + e[1])))
391}
392
393/// The answer to `q` from its option logits, in option order, and its two act logits.
394///
395/// # Panics
396///
397/// If `logits` does not have one value per option.
398#[must_use]
399pub fn laya_answer(q: &Question, logits: &[f32], act: [f32; 2], temps: &Temperatures) -> Answer {
400    let k = q.criteria.len();
401    assert_eq!(logits.len(), k, "one logit per option");
402    let p = softmax(logits, temps.get(q.qtype, k));
403    let act_probability = act_probability(act);
404    match &q.criteria {
405        Criteria::Choice(opts) => {
406            let mut best = 0;
407            for (i, &v) in p.iter().enumerate() {
408                if v > p[best] {
409                    best = i;
410                }
411            }
412            Answer::Choice {
413                choice: opts[best].label.clone(),
414                probabilities: opts
415                    .iter()
416                    .zip(&p)
417                    .map(|(o, &v)| (o.label.clone(), f64::from(v)))
418                    .collect(),
419                confidence: entropy_confidence(&p),
420                act_probability,
421            }
422        }
423        Criteria::Score(levels) => {
424            let weighted: Vec<f64> =
425                p.iter().enumerate().map(|(i, &v)| i as f64 * f64::from(v)).collect();
426            Answer::Score {
427                score: pairwise(&weighted),
428                legend: levels.clone(),
429                probabilities: p.iter().map(|&v| f64::from(v)).collect(),
430                confidence: entropy_confidence(&p),
431                act_probability,
432            }
433        }
434        Criteria::Noul { .. } => {
435            let yes = f64::from(p[1]);
436            Answer::Noul { noul: yes, confidence: yes.max(1.0 - yes), act_probability }
437        }
438    }
439}
440
441#[cfg(test)]
442// These values are exact, so comparing them exactly is the point.
443#[allow(clippy::float_cmp)]
444mod tests {
445    use super::*;
446
447    #[test]
448    fn pairwise_order() {
449        // Nine values: eight lanes plus one left over, which a left fold adds in another order.
450        let x = [1e8f32, 1.0, -1e8, 1.0, 0.5, 0.25, 3.0, 7.0, 1.0];
451        let lanes = ((1e8f32 + 1.0) + (-1e8 + 1.0)) + ((0.5 + 0.25) + (3.0 + 7.0)) + 1.0;
452        assert_eq!(pairwise(&x).to_bits(), lanes.to_bits());
453    }
454
455    #[test]
456    fn numpy_exp_log() {
457        // Values numpy 2.5 gives on an AVX2 machine, where libm's differ in the last bit.
458        for (x, want) in [(-0.0, 1.0f32), (-1.0, 0.367_879_43), (-20.0, 2.061_153_6e-9)] {
459            assert!((np_exp(x) - want).abs() <= want * 3e-7, "exp {x}");
460        }
461        assert_eq!(np_exp(0.0), 1.0);
462        assert_eq!(np_log(1.0), 0.0);
463        assert!((np_log(1e-12) - 1e-12f32.ln()).abs() < 1e-5);
464        assert!((np_log(0.3) - 0.3f32.ln()).abs() < 1e-6);
465    }
466
467    #[test]
468    fn rounding() {
469        assert_eq!(py_round(0.25, 1), 0.2);
470        assert_eq!(py_round(0.965_449_999, 4), 0.9654);
471        assert_eq!(py_round(1.0, 4), 1.0);
472    }
473
474    #[test]
475    fn buckets_and_clamp() {
476        let t = Temperatures::new([1.5, 1.2, 2.0], &[("choice:11+".into(), 0.1)]);
477        assert_eq!(t.get(QType::Choice, 12), 0.5);
478        assert_eq!(t.get(QType::Choice, 4), 1.5);
479        assert_eq!(temperature_bucket(QType::Score, 6), "score:6-10");
480        assert_eq!(clamp_temperature(f64::NAN), 1.0);
481    }
482
483    #[test]
484    fn jev_shape() {
485        let a = Answer::Choice {
486            choice: "b".into(),
487            probabilities: vec![("a".into(), 0.334), ("b".into(), 0.335), ("c".into(), 0.331)],
488            confidence: 0.1,
489            act_probability: 0.9,
490        };
491        let v = a.to_jev_json(Some(2), false);
492        let p = &v["probabilities"];
493        assert_eq!(
494            (p["a"].as_f64(), p["b"].as_f64(), p["c"].as_f64()),
495            (Some(0.33), Some(0.34), Some(0.33))
496        );
497        assert_eq!(v["choice"], "b");
498        assert!(v.get("action").is_none());
499        let s = Answer::Score {
500            score: 1.2345,
501            legend: vec![json!("lo"), json!("mid"), json!("hi")],
502            probabilities: vec![0.105, 0.555, 0.34],
503            confidence: 0.5,
504            act_probability: 1.0,
505        };
506        let v = s.to_jev_json(Some(1), false);
507        assert_eq!(v["score"], 1.2);
508        assert_eq!(v["legend"]["2"], "hi");
509        let n = Answer::Noul { noul: 0.876, confidence: 0.876, act_probability: 1.0 };
510        assert_eq!(n.to_jev_json(Some(2), false), json!({"type": "noul", "noul": 0.88}));
511        assert_eq!(n.to_jev_json(None, false)["noul"], f64::from(0.876f32));
512    }
513}