use crate::simulation::{D_MAX, D_MIN, S_MAX, S_MIN};
const MIN_PROB: f32 = 1e-7;
const MAX_PROB: f32 = 1.0 - 1e-7;
#[derive(Clone, Copy, Debug)]
struct CurveCache {
t: f32,
s: f32,
decay: f32,
factor: f32,
base: f32,
retrievability: f32,
}
#[derive(Clone, Copy, Debug)]
struct StepCache {
state_s: f32,
state_d: f32,
last_s: f32,
last_d: f32,
delta_t: f32,
rating: f32,
retrievability: f32,
failure_raw: f32,
failure_floor: f32,
failure_used_floor: bool,
short_raw: f32,
short_value: f32,
short_raw_active: bool,
use_short: bool,
use_failure: bool,
init_selected: bool,
padding: bool,
pre_clamp_s: f32,
mean_pre_clamp_d: f32,
init_rating: usize,
}
#[derive(Clone, Copy, Debug)]
struct State {
s: f32,
d: f32,
}
#[inline]
fn clamp(x: f32, min: f32, max: f32) -> f32 {
x.clamp(min, max)
}
#[inline]
fn clamp_grad(x: f32, min: f32, max: f32) -> f64 {
if x > min && x < max { 1.0 } else { 0.0 }
}
#[inline]
fn add(gw: &mut [f64], idx: usize, value: f64) {
if let Some(slot) = gw.get_mut(idx) {
*slot += value;
}
}
#[inline]
fn bce_loss_and_grad_r(r_raw: f32, label: f32, weight: f32) -> (f64, f64) {
if weight == 0.0 {
return (0.0, 0.0);
}
let r = clamp(r_raw, MIN_PROB, MAX_PROB);
let loss = -((label * r.ln()) + ((1.0 - label) * (1.0 - r).ln())) * weight;
let grad = -weight * (label / r - (1.0 - label) / (1.0 - r));
let grad = if r_raw > MIN_PROB && r_raw < MAX_PROB {
grad as f64
} else {
0.0
};
(loss as f64, grad)
}
#[inline]
fn curve_forward(w: &[f32], t: f32, s: f32) -> CurveCache {
let t = t.max(0.0);
let decay = -w[20];
let factor = ((0.9f32.ln()) / decay).exp() - 1.0;
let base = t / s * factor + 1.0;
let retrievability = base.powf(decay);
CurveCache {
t,
s,
decay,
factor,
base,
retrievability,
}
}
#[inline]
fn curve_backward(w: &[f32], cache: CurveCache, g_r: f64, gw: &mut [f64]) -> f64 {
if g_r == 0.0 {
return 0.0;
}
let r = cache.retrievability as f64;
let base = cache.base as f64;
let decay = cache.decay as f64;
let t = cache.t as f64;
let s = cache.s as f64;
let factor = cache.factor as f64;
let c = 0.9f64.ln();
let db_ds = -t * factor / (s * s);
let g_s = g_r * r * decay / base * db_ds;
let exp_term = (c / decay).exp();
let dfactor_ddecay = exp_term * (-c / (decay * decay));
let db_ddecay = t / s * dfactor_ddecay;
let dr_ddecay = r * (base.ln() + decay / base * db_ddecay);
add(gw, 20, -g_r * dr_ddecay);
let _ = w;
g_s
}
#[inline]
fn init_difficulty(w: &[f32], rating: usize) -> f32 {
let offset = rating.saturating_sub(1) as f32;
w[4] - (w[5] * offset).exp() + 1.0
}
#[inline]
fn step_forward(
w: &[f32],
state: State,
delta_t: f32,
rating: f32,
nth: usize,
) -> (State, StepCache) {
let last_s = clamp(state.s, S_MIN, S_MAX);
let last_d = clamp(state.d, D_MIN, D_MAX);
let curve = curve_forward(w, delta_t, last_s);
let r = curve.retrievability;
let hard_penalty = if rating == 2.0 { w[15] } else { 1.0 };
let easy_bonus = if rating == 4.0 { w[16] } else { 1.0 };
let success_inc = w[8].exp()
* (11.0 - last_d)
* last_s.powf(-w[9])
* (((1.0 - r) * w[10]).exp() - 1.0)
* hard_penalty
* easy_bonus;
let success = last_s * (success_inc + 1.0);
let failure_raw = w[11]
* last_d.powf(-w[12])
* ((last_s + 1.0).powf(w[13]) - 1.0)
* ((1.0 - r) * w[14]).exp();
let failure_floor = last_s / (w[17] * w[18]).exp();
let failure_used_floor = failure_floor < failure_raw;
let failure = if failure_used_floor {
failure_floor
} else {
failure_raw
};
let short_raw = (w[17] * (rating - 3.0 + w[18])).exp() * last_s.powf(-w[19]);
let short_raw_active = !(rating >= 2.0 && short_raw < 1.0);
let short_value = if rating >= 2.0 {
short_raw.max(1.0)
} else {
short_raw
};
let short = last_s * short_value;
let use_short = delta_t == 0.0;
let use_failure = rating == 1.0;
let mut new_s = if use_failure { failure } else { success };
if use_short {
new_s = short;
}
let delta_d = -w[6] * (rating - 3.0);
let next_d = last_d + (10.0 - last_d) * delta_d / 9.0;
let easy_d = init_difficulty(w, 4);
let mean_d = w[7] * (easy_d - next_d) + next_d;
let mut new_d = clamp(mean_d, D_MIN, D_MAX);
let init_selected = nth == 0 && state.s == 0.0;
let init_rating = clamp(rating, 1.0, 4.0) as usize;
if init_selected {
new_s = w[init_rating - 1];
new_d = clamp(init_difficulty(w, init_rating), D_MIN, D_MAX);
}
let padding = rating == 0.0;
if padding {
new_s = last_s;
new_d = last_d;
}
let pre_clamp_s = new_s;
let out = State {
s: clamp(new_s, S_MIN, S_MAX),
d: new_d,
};
let cache = StepCache {
state_s: state.s,
state_d: state.d,
last_s,
last_d,
delta_t,
rating,
retrievability: r,
failure_raw,
failure_floor,
failure_used_floor,
short_raw,
short_value,
short_raw_active,
use_short,
use_failure,
init_selected,
padding,
pre_clamp_s,
mean_pre_clamp_d: mean_d,
init_rating,
};
(out, cache)
}
fn backward_success(w: &[f32], c: &StepCache, g: f64, gw: &mut [f64]) -> (f64, f64, f64) {
if g == 0.0 {
return (0.0, 0.0, 0.0);
}
let s = c.last_s as f64;
let d = c.last_d as f64;
let r = c.retrievability as f64;
let rating = c.rating;
let a = (w[8] as f64).exp();
let b = 11.0 - d;
let c_s = s.powf(-(w[9] as f64));
let e = (((1.0 - r) * w[10] as f64).exp()) - 1.0;
let exp_e = e + 1.0;
let hp = if rating == 2.0 { w[15] as f64 } else { 1.0 };
let eb = if rating == 4.0 { w[16] as f64 } else { 1.0 };
let inc = a * b * c_s * e * hp * eb;
let g_inc = g * s;
let mut g_s = g * (inc + 1.0);
g_s += g_inc * inc * (-(w[9] as f64) / s);
let g_d = -(g_inc * a * c_s * e * hp * eb);
let g_r = g_inc * a * b * c_s * hp * eb * (-(w[10] as f64) * exp_e);
add(gw, 8, g_inc * inc);
add(gw, 9, g_inc * inc * -s.ln());
add(gw, 10, g_inc * a * b * c_s * hp * eb * ((1.0 - r) * exp_e));
if rating == 2.0 {
add(gw, 15, g_inc * a * b * c_s * e * eb);
}
if rating == 4.0 {
add(gw, 16, g_inc * a * b * c_s * e * hp);
}
(g_s, g_d, g_r)
}
fn backward_failure(w: &[f32], c: &StepCache, g: f64, gw: &mut [f64]) -> (f64, f64, f64) {
if g == 0.0 {
return (0.0, 0.0, 0.0);
}
let s = c.last_s as f64;
let d = c.last_d as f64;
let r = c.retrievability as f64;
if c.failure_used_floor {
let floor = c.failure_floor as f64;
let exp_floor = floor / s;
add(gw, 17, g * floor * (-(w[18] as f64)));
add(gw, 18, g * floor * (-(w[17] as f64)));
return (g * exp_floor, 0.0, 0.0);
}
let raw = c.failure_raw as f64;
let base = s + 1.0;
let p = base.powf(w[13] as f64);
let es = p - 1.0;
let last_d_pow = d.powf(-(w[12] as f64));
let er = ((1.0 - r) * w[14] as f64).exp();
add(gw, 11, g * raw / w[11] as f64);
add(gw, 12, g * raw * -d.ln());
add(gw, 13, g * (w[11] as f64) * last_d_pow * er * p * base.ln());
add(gw, 14, g * raw * (1.0 - r));
let g_s = g * (w[11] as f64) * last_d_pow * er * (w[13] as f64) * p / base;
let g_d = g * raw * (-(w[12] as f64) / d);
let g_r = g * raw * (-(w[14] as f64));
let _ = es;
(g_s, g_d, g_r)
}
fn backward_short(w: &[f32], c: &StepCache, g: f64, gw: &mut [f64]) -> f64 {
if g == 0.0 {
return 0.0;
}
let s = c.last_s as f64;
let mut g_s = g * c.short_value as f64;
if c.short_raw_active {
let g_raw = g * s;
let raw = c.short_raw as f64;
let q = (c.rating - 3.0 + w[18]) as f64;
add(gw, 17, g_raw * raw * q);
add(gw, 18, g_raw * raw * w[17] as f64);
add(gw, 19, g_raw * raw * -s.ln());
g_s += g_raw * raw * (-(w[19] as f64) / s);
}
g_s
}
fn backward_difficulty(w: &[f32], c: &StepCache, g_out: f64, gw: &mut [f64]) -> f64 {
if g_out == 0.0 {
return 0.0;
}
let g_mean = g_out * clamp_grad(c.mean_pre_clamp_d, D_MIN, D_MAX);
if g_mean == 0.0 {
return 0.0;
}
let rating_minus_3 = (c.rating - 3.0) as f64;
let last_d = c.last_d as f64;
let delta_d = -(w[6] as f64) * rating_minus_3;
let next_d = last_d + (10.0 - last_d) * delta_d / 9.0;
let easy_d = init_difficulty(w, 4) as f64;
add(gw, 7, g_mean * (easy_d - next_d));
add(gw, 4, g_mean * w[7] as f64);
add(
gw,
5,
g_mean * (w[7] as f64) * -3.0 * (3.0 * w[5] as f64).exp(),
);
let g_next = g_mean * (1.0 - w[7] as f64);
add(gw, 6, g_next * (10.0 - last_d) * (-(rating_minus_3)) / 9.0);
g_next * (1.0 - delta_d / 9.0)
}
fn backward_init(w: &[f32], rating: usize, g_s: f64, g_d: f64, gw: &mut [f64]) {
add(gw, rating - 1, g_s);
let raw_d = init_difficulty(w, rating);
let g_raw_d = g_d * clamp_grad(raw_d, D_MIN, D_MAX);
if g_raw_d != 0.0 {
let offset = (rating - 1) as f64;
add(gw, 4, g_raw_d);
add(gw, 5, g_raw_d * -offset * (offset * w[5] as f64).exp());
}
}
fn step_backward(
w: &[f32],
c: &StepCache,
g_out_s: f64,
g_out_d: f64,
g_r_extra: f64,
gw: &mut [f64],
) -> (f64, f64) {
let g_pre_s = g_out_s * clamp_grad(c.pre_clamp_s, S_MIN, S_MAX);
let g_pre_d = g_out_d;
let mut g_last_s = 0.0;
let mut g_last_d = 0.0;
let mut g_r = g_r_extra;
if c.padding {
g_last_s += g_pre_s;
g_last_d += g_pre_d;
} else if c.init_selected {
backward_init(w, c.init_rating, g_pre_s, g_pre_d, gw);
} else {
if c.use_short {
g_last_s += backward_short(w, c, g_pre_s, gw);
} else if c.use_failure {
let (gs, gd, gr) = backward_failure(w, c, g_pre_s, gw);
g_last_s += gs;
g_last_d += gd;
g_r += gr;
} else {
let (gs, gd, gr) = backward_success(w, c, g_pre_s, gw);
g_last_s += gs;
g_last_d += gd;
g_r += gr;
}
g_last_d += backward_difficulty(w, c, g_pre_d, gw);
}
let curve = CurveCache {
t: c.delta_t.max(0.0),
s: c.last_s,
decay: -w[20],
factor: ((0.9f32.ln()) / -w[20]).exp() - 1.0,
base: c.delta_t.max(0.0) / c.last_s * (((0.9f32.ln()) / -w[20]).exp() - 1.0) + 1.0,
retrievability: c.retrievability,
};
g_last_s += curve_backward(w, curve, g_r, gw);
let g_state_s = g_last_s * clamp_grad(c.state_s, S_MIN, S_MAX);
let g_state_d = g_last_d * clamp_grad(c.state_d, D_MIN, D_MAX);
(g_state_s, g_state_d)
}
#[allow(clippy::too_many_arguments)]
fn prefix_loss_and_grad(
w: &[f32],
t_hist: &[f32],
r_hist: &[f32],
seq_len: usize,
batch: usize,
column: usize,
delta_t: f32,
label: f32,
weight: f32,
gw: Option<&mut [f64]>,
) -> f64 {
let mut state = State { s: 0.0, d: 0.0 };
let need_grad = gw.is_some();
let mut caches = if need_grad {
Vec::with_capacity(seq_len)
} else {
Vec::new()
};
for t in 0..seq_len {
let idx = t * batch + column;
let (next, cache) = step_forward(w, state, t_hist[idx], r_hist[idx], t);
state = next;
if need_grad {
caches.push(cache);
}
}
let curve = curve_forward(w, delta_t, state.s);
let (loss, g_r) = bce_loss_and_grad_r(curve.retrievability, label, weight);
if let Some(gw) = gw {
let mut g_s = curve_backward(w, curve, g_r, gw);
let mut g_d = 0.0;
for cache in caches.iter().rev() {
let (prev_s, prev_d) = step_backward(w, cache, g_s, g_d, 0.0, gw);
g_s = prev_s;
g_d = prev_d;
}
}
loss
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn batch_loss_and_grad(
w: &[f32],
t_hist: &[f32],
r_hist: &[f32],
seq_len: usize,
batch: usize,
delta_ts: &[f32],
labels: &[f32],
weights: &[f32],
gw: &mut [f64],
) -> f64 {
let mut loss = 0.0;
for column in 0..batch {
loss += prefix_loss_and_grad(
w,
t_hist,
r_hist,
seq_len,
batch,
column,
delta_ts[column],
labels[column],
weights[column],
Some(gw),
);
}
loss
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn batch_loss(
w: &[f32],
t_hist: &[f32],
r_hist: &[f32],
seq_len: usize,
batch: usize,
delta_ts: &[f32],
labels: &[f32],
weights: &[f32],
) -> f64 {
let mut loss = 0.0;
for column in 0..batch {
loss += prefix_loss_and_grad(
w,
t_hist,
r_hist,
seq_len,
batch,
column,
delta_ts[column],
labels[column],
weights[column],
None,
);
}
loss
}
#[allow(clippy::too_many_arguments)]
fn card_column_loss_and_grad(
w: &[f32],
t_hist: &[f32],
r_hist: &[f32],
seq_len: usize,
batch: usize,
column: usize,
labels: &[f32],
weights: &[f32],
gw: Option<&mut [f64]>,
) -> f64 {
let mut state = State { s: 0.0, d: 0.0 };
let need_grad = gw.is_some();
let mut caches = if need_grad {
Vec::with_capacity(seq_len)
} else {
Vec::new()
};
let mut loss = 0.0;
let mut g_r_losses = if need_grad {
vec![0.0f64; seq_len]
} else {
Vec::new()
};
for t in 0..seq_len {
let idx = t * batch + column;
let (next, cache) = step_forward(w, state, t_hist[idx], r_hist[idx], t);
let (step_loss, g_r) = if t == 0 {
(0.0, 0.0)
} else {
bce_loss_and_grad_r(cache.retrievability, labels[idx], weights[idx])
};
loss += step_loss;
if need_grad {
g_r_losses[t] = g_r;
caches.push(cache);
}
state = next;
}
if let Some(gw) = gw {
let mut g_s = 0.0;
let mut g_d = 0.0;
for (t, cache) in caches.iter().enumerate().rev() {
let (prev_s, prev_d) = step_backward(w, cache, g_s, g_d, g_r_losses[t], gw);
g_s = prev_s;
g_d = prev_d;
}
}
loss
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn card_loss_and_grad(
w: &[f32],
t_hist: &[f32],
r_hist: &[f32],
seq_len: usize,
batch: usize,
labels: &[f32],
weights: &[f32],
gw: &mut [f64],
) -> f64 {
let mut loss = 0.0;
for column in 0..batch {
loss += card_column_loss_and_grad(
w,
t_hist,
r_hist,
seq_len,
batch,
column,
labels,
weights,
Some(gw),
);
}
loss
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn card_loss(
w: &[f32],
t_hist: &[f32],
r_hist: &[f32],
seq_len: usize,
batch: usize,
labels: &[f32],
weights: &[f32],
) -> f64 {
let mut loss = 0.0;
for column in 0..batch {
loss += card_column_loss_and_grad(
w, t_hist, r_hist, seq_len, batch, column, labels, weights, None,
);
}
loss
}
#[cfg(test)]
mod tests {
use super::*;
use crate::DEFAULT_PARAMETERS;
#[test]
fn finite_difference_matches_batch_gradient() {
let w = DEFAULT_PARAMETERS.to_vec();
let seq = 3;
let batch = 2;
let th = vec![0.0, 0.0, 3.0, 2.0, 5.0, 6.0];
let rh = vec![4.0, 1.0, 3.0, 2.0, 1.0, 3.0];
let dts = vec![7.0, 9.0];
let labels = vec![1.0, 0.0];
let weights = vec![1.0, 0.7];
let mut grad = [0.0; 21];
batch_loss_and_grad(&w, &th, &rh, seq, batch, &dts, &labels, &weights, &mut grad);
for i in 0..21 {
let eps = 1e-3f32.max(w[i].abs() * 1e-3);
let mut wp = w.clone();
let mut wm = w.clone();
wp[i] += eps;
wm[i] -= eps;
let lp = batch_loss(&wp, &th, &rh, seq, batch, &dts, &labels, &weights);
let lm = batch_loss(&wm, &th, &rh, seq, batch, &dts, &labels, &weights);
let numeric = (lp - lm) / (2.0 * eps as f64);
let diff = (numeric - grad[i]).abs();
let scale = numeric.abs().max(grad[i].abs()).max(1.0);
assert!(
diff / scale < 2e-2,
"param {i}: analytic={} numeric={numeric} diff={diff}",
grad[i]
);
}
}
#[test]
fn windowed_matches_sum_of_prefixes() {
let w = DEFAULT_PARAMETERS.to_vec();
let seq = 4;
let batch = 1;
let th_card = vec![0.0, 2.0, 5.0, 8.0];
let rh_card = vec![4.0, 3.0, 1.0, 3.0];
let mut labels_card = vec![0.0; seq];
let mut weights_card = vec![0.0; seq];
labels_card[1] = 1.0;
labels_card[2] = 0.0;
labels_card[3] = 1.0;
weights_card[1] = 0.5;
weights_card[2] = 0.7;
weights_card[3] = 1.1;
let mut g_card = [0.0; 21];
let l_card = card_loss_and_grad(
&w,
&th_card,
&rh_card,
seq,
batch,
&labels_card,
&weights_card,
&mut g_card,
);
let mut l_prefix = 0.0;
let mut g_prefix = [0.0; 21];
for prefix_len in 2..=4 {
let hist_len = prefix_len - 1;
let th = th_card[..hist_len].to_vec();
let rh = rh_card[..hist_len].to_vec();
let dts = vec![th_card[prefix_len - 1]];
let labels = vec![labels_card[prefix_len - 1]];
let weights = vec![weights_card[prefix_len - 1]];
l_prefix += batch_loss_and_grad(
&w,
&th,
&rh,
hist_len,
1,
&dts,
&labels,
&weights,
&mut g_prefix,
);
}
assert!((l_card - l_prefix).abs() < 1e-9);
for i in 0..21 {
assert!(
(g_card[i] - g_prefix[i]).abs() < 1e-9,
"param {i}: card={} prefix={}",
g_card[i],
g_prefix[i]
);
}
}
}