use crate::features::{Features, TaskKind};
const N_TASK_KINDS: usize = 7;
const N_RUNGS: usize = 8;
pub const FEATURE_DIM: usize = 1 + N_TASK_KINDS + 5 + N_RUNGS;
const BIAS: usize = 0;
fn task_kind_slot(k: TaskKind) -> usize {
let i = match k {
TaskKind::CodeEdit => 0,
TaskKind::TestGen => 1,
TaskKind::Explore => 2,
TaskKind::Review => 3,
TaskKind::Extract => 4,
TaskKind::Chat => 5,
TaskKind::Other => 6,
};
1 + i
}
fn sigmoid(z: f64) -> f64 {
let z = z.clamp(-30.0, 30.0);
1.0 / (1.0 + (-z).exp())
}
#[must_use]
pub fn encode(features: &Features, rung: u32) -> [f64; FEATURE_DIM] {
let mut x = [0.0_f64; FEATURE_DIM];
x[BIAS] = 1.0;
x[task_kind_slot(features.task_kind)] = 1.0;
let base = 1 + N_TASK_KINDS;
x[base] = f64::from(features.prompt_token_bucket).min(16.0) / 16.0;
x[base + 1] = f64::from(u8::from(features.tool_count > 0));
x[base + 2] = f64::from(features.tool_count).min(10.0) / 10.0;
x[base + 3] = f64::from(u8::from(features.has_images));
x[base + 4] = f64::from(features.session_failure_count).min(5.0) / 5.0;
let rung_slot = 1 + N_TASK_KINDS + 5 + (rung as usize).min(N_RUNGS - 1);
x[rung_slot] = 1.0;
x
}
#[derive(Debug, Clone)]
pub struct PassPredictor {
weights: [f64; FEATURE_DIM],
lr: f64,
l2: f64,
}
impl PassPredictor {
#[must_use]
pub fn new(lr: f64, l2: f64) -> Self {
Self {
weights: [0.0; FEATURE_DIM],
lr,
l2,
}
}
#[must_use]
pub fn predict(&self, features: &Features, rung: u32) -> f64 {
let x = encode(features, rung);
let z: f64 = self
.weights
.iter()
.zip(x.iter())
.map(|(w, xi)| w * xi)
.sum();
sigmoid(z)
}
pub fn update(&mut self, features: &Features, rung: u32, passed: bool) {
let x = encode(features, rung);
let p = {
let z: f64 = self
.weights
.iter()
.zip(x.iter())
.map(|(w, xi)| w * xi)
.sum();
sigmoid(z)
};
let err = p - f64::from(u8::from(passed));
for (i, w) in self.weights.iter_mut().enumerate() {
let reg = if i == BIAS { 0.0 } else { self.l2 * *w };
*w -= self.lr * (err * x[i] + reg);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn feats(kind: TaskKind) -> Features {
Features::new(kind)
}
#[test]
fn encode_layout_is_fixed_and_onehot_correct() {
let x = encode(&feats(TaskKind::CodeEdit), 0);
assert_eq!(x.len(), FEATURE_DIM);
assert_eq!(x[BIAS], 1.0);
assert_eq!(x[task_kind_slot(TaskKind::CodeEdit)], 1.0);
let tk_set: usize = (1..1 + N_TASK_KINDS).filter(|&i| x[i] == 1.0).count();
assert_eq!(tk_set, 1);
let rbase = 1 + N_TASK_KINDS + 5;
let r_set: usize = (rbase..rbase + N_RUNGS).filter(|&i| x[i] == 1.0).count();
assert_eq!(r_set, 1);
let xr = encode(&feats(TaskKind::Other), 99);
assert_eq!(xr[rbase + N_RUNGS - 1], 1.0);
}
#[test]
fn predict_stays_in_unit_interval() {
let mut p = PassPredictor::new(0.5, 0.0);
for _ in 0..1000 {
p.update(&feats(TaskKind::Chat), 0, true);
}
let v = p.predict(&feats(TaskKind::Chat), 0);
assert!(v > 0.0 && v < 1.0, "prediction must stay in (0,1): {v}");
}
#[test]
fn converges_to_separate_easy_from_hard_by_rung() {
let mut p = PassPredictor::new(0.2, 1e-4);
let f = feats(TaskKind::CodeEdit);
for _ in 0..2000 {
p.update(&f, 0, false);
p.update(&f, 2, true);
}
let low = p.predict(&f, 0);
let high = p.predict(&f, 2);
assert!(
low < 0.2,
"hopeless rung should predict low pass, got {low}"
);
assert!(
high > 0.8,
"reliable rung should predict high pass, got {high}"
);
assert!(high - low > 0.6, "predictor must separate the two rungs");
}
#[test]
fn l2_keeps_weights_bounded() {
let mut p = PassPredictor::new(0.5, 0.05);
let f = feats(TaskKind::Review);
for i in 0..5000 {
p.update(&f, 1, i % 2 == 0);
}
let maxw = p
.weights
.iter()
.cloned()
.fold(0.0_f64, |a, w| a.max(w.abs()));
assert!(
maxw.is_finite() && maxw < 50.0,
"L2 must bound weights: {maxw}"
);
let v = p.predict(&f, 1);
assert!((v - 0.5).abs() < 0.15, "50/50 labels → ~0.5, got {v}");
}
}