firstpass_core/
predictor.rs1use crate::features::{Features, TaskKind};
15
16const N_TASK_KINDS: usize = 7;
18const N_RUNGS: usize = 8;
20pub const FEATURE_DIM: usize = 1 + N_TASK_KINDS + 5 + N_RUNGS;
24
25const BIAS: usize = 0;
27
28fn task_kind_slot(k: TaskKind) -> usize {
30 let i = match k {
31 TaskKind::CodeEdit => 0,
32 TaskKind::TestGen => 1,
33 TaskKind::Explore => 2,
34 TaskKind::Review => 3,
35 TaskKind::Extract => 4,
36 TaskKind::Chat => 5,
37 TaskKind::Other => 6,
38 };
39 1 + i
40}
41
42fn sigmoid(z: f64) -> f64 {
44 let z = z.clamp(-30.0, 30.0);
45 1.0 / (1.0 + (-z).exp())
46}
47
48#[must_use]
53pub fn encode(features: &Features, rung: u32) -> [f64; FEATURE_DIM] {
54 let mut x = [0.0_f64; FEATURE_DIM];
55 x[BIAS] = 1.0;
56 x[task_kind_slot(features.task_kind)] = 1.0;
57 let base = 1 + N_TASK_KINDS;
58 x[base] = f64::from(features.prompt_token_bucket).min(16.0) / 16.0;
60 x[base + 1] = f64::from(u8::from(features.tool_count > 0));
61 x[base + 2] = f64::from(features.tool_count).min(10.0) / 10.0;
62 x[base + 3] = f64::from(u8::from(features.has_images));
63 x[base + 4] = f64::from(features.session_failure_count).min(5.0) / 5.0;
64 let rung_slot = 1 + N_TASK_KINDS + 5 + (rung as usize).min(N_RUNGS - 1);
65 x[rung_slot] = 1.0;
66 x
67}
68
69#[derive(Debug, Clone)]
75pub struct PassPredictor {
76 weights: [f64; FEATURE_DIM],
77 lr: f64,
78 l2: f64,
79}
80
81impl PassPredictor {
82 #[must_use]
84 pub fn new(lr: f64, l2: f64) -> Self {
85 Self {
86 weights: [0.0; FEATURE_DIM],
87 lr,
88 l2,
89 }
90 }
91
92 #[must_use]
94 pub fn predict(&self, features: &Features, rung: u32) -> f64 {
95 let x = encode(features, rung);
96 let z: f64 = self
97 .weights
98 .iter()
99 .zip(x.iter())
100 .map(|(w, xi)| w * xi)
101 .sum();
102 sigmoid(z)
103 }
104
105 pub fn update(&mut self, features: &Features, rung: u32, passed: bool) {
110 let x = encode(features, rung);
111 let p = {
112 let z: f64 = self
113 .weights
114 .iter()
115 .zip(x.iter())
116 .map(|(w, xi)| w * xi)
117 .sum();
118 sigmoid(z)
119 };
120 let err = p - f64::from(u8::from(passed));
121 for (i, w) in self.weights.iter_mut().enumerate() {
122 let reg = if i == BIAS { 0.0 } else { self.l2 * *w };
123 *w -= self.lr * (err * x[i] + reg);
124 }
125 }
126}
127
128#[cfg(test)]
129mod tests {
130 use super::*;
131
132 fn feats(kind: TaskKind) -> Features {
133 Features::new(kind)
134 }
135
136 #[test]
137 fn encode_layout_is_fixed_and_onehot_correct() {
138 let x = encode(&feats(TaskKind::CodeEdit), 0);
139 assert_eq!(x.len(), FEATURE_DIM);
140 assert_eq!(x[BIAS], 1.0);
141 assert_eq!(x[task_kind_slot(TaskKind::CodeEdit)], 1.0);
142 let tk_set: usize = (1..1 + N_TASK_KINDS).filter(|&i| x[i] == 1.0).count();
144 assert_eq!(tk_set, 1);
145 let rbase = 1 + N_TASK_KINDS + 5;
147 let r_set: usize = (rbase..rbase + N_RUNGS).filter(|&i| x[i] == 1.0).count();
148 assert_eq!(r_set, 1);
149 let xr = encode(&feats(TaskKind::Other), 99);
151 assert_eq!(xr[rbase + N_RUNGS - 1], 1.0);
152 }
153
154 #[test]
155 fn predict_stays_in_unit_interval() {
156 let mut p = PassPredictor::new(0.5, 0.0);
157 for _ in 0..1000 {
158 p.update(&feats(TaskKind::Chat), 0, true);
159 }
160 let v = p.predict(&feats(TaskKind::Chat), 0);
161 assert!(v > 0.0 && v < 1.0, "prediction must stay in (0,1): {v}");
162 }
163
164 #[test]
165 fn converges_to_separate_easy_from_hard_by_rung() {
166 let mut p = PassPredictor::new(0.2, 1e-4);
168 let f = feats(TaskKind::CodeEdit);
169 for _ in 0..2000 {
170 p.update(&f, 0, false);
171 p.update(&f, 2, true);
172 }
173 let low = p.predict(&f, 0);
174 let high = p.predict(&f, 2);
175 assert!(
176 low < 0.2,
177 "hopeless rung should predict low pass, got {low}"
178 );
179 assert!(
180 high > 0.8,
181 "reliable rung should predict high pass, got {high}"
182 );
183 assert!(high - low > 0.6, "predictor must separate the two rungs");
184 }
185
186 #[test]
187 fn l2_keeps_weights_bounded() {
188 let mut p = PassPredictor::new(0.5, 0.05);
190 let f = feats(TaskKind::Review);
191 for i in 0..5000 {
192 p.update(&f, 1, i % 2 == 0);
193 }
194 let maxw = p
195 .weights
196 .iter()
197 .cloned()
198 .fold(0.0_f64, |a, w| a.max(w.abs()));
199 assert!(
200 maxw.is_finite() && maxw < 50.0,
201 "L2 must bound weights: {maxw}"
202 );
203 let v = p.predict(&f, 1);
205 assert!((v - 0.5).abs() < 0.15, "50/50 labels → ~0.5, got {v}");
206 }
207}