use std::collections::HashMap;
use crate::conscience::{self, Challenge};
use crate::outcome::{self, Outcome};
pub const PRIOR_K: usize = 11;
pub const WINDOW: usize = 200;
pub const RAIL: usize = 20;
pub const MIN_ALPHA: f64 = 1e-3;
pub const EXPLORE: usize = 10;
#[derive(Debug, Clone, PartialEq)]
pub struct Labelled {
pub ms: i64,
pub grades: Vec<u8>,
pub needed: Vec<usize>,
}
pub fn labels(outcomes: &[Outcome]) -> Vec<Labelled> {
type Checks = Vec<(i64, usize, bool)>;
let mut checks: HashMap<(&str, &str), Checks> = HashMap::new();
for (i, o) in outcomes.iter().enumerate().filter(|(_, o)| o.kind == outcome::GRADED) {
for l in &o.lessons {
checks.entry((o.session.as_str(), l.as_str())).or_default().push((o.ms, i, o.chosen.contains(l)));
}
}
for v in checks.values_mut() {
v.sort_by_key(|c| c.0);
}
let mut needed: HashMap<usize, Vec<&str>> = HashMap::new();
for o in outcomes.iter().filter(|o| o.kind == outcome::CHANGED || o.kind == outcome::MISSED) {
for l in &o.lessons {
let Some(cs) = checks.get(&(o.session.as_str(), l.as_str())) else { continue };
let before = &cs[..cs.partition_point(|c| c.0 < o.ms)];
if let Some(&(_, i, _)) = before.iter().rev().find(|c| c.2 == (o.kind == outcome::CHANGED)) {
needed.entry(i).or_default().push(l);
}
}
}
let mut out: Vec<Labelled> = needed
.into_iter()
.filter_map(|(i, ls)| {
let o = &outcomes[i];
if !o.explore || o.grades.len() != o.lessons.len() || !o.grades.contains(&3) {
return None;
}
let rank = |i: usize| if o.ranks.len() == o.lessons.len() { o.ranks[i] as usize } else { i };
let needed = o.lessons.iter().enumerate().filter(|(_, l)| ls.contains(&l.as_str())).map(|(i, _)| rank(i)).collect();
Some(Labelled { ms: o.ms, grades: o.grades.clone(), needed })
})
.collect();
out.sort_by_key(|l| l.ms);
let drop = out.len().saturating_sub(WINDOW);
out.drain(..drop);
out
}
pub fn incumbent(grades: &[u8]) -> Vec<usize> {
if !grades.contains(&3) {
return Vec::new();
}
let mut ask: Vec<usize> = (0..grades.len()).filter(|&i| grades[i] >= 2).collect();
let room = conscience::CAP.saturating_sub(ask.len());
ask.extend((0..grades.len()).filter(|&i| grades[i] == 1).take(room));
ask
}
pub fn alpha(labels: &[Labelled]) -> f64 {
let missed = labels.iter().filter(|l| !incumbent(&l.grades).iter().any(|r| l.needed.contains(r))).count();
(missed as f64 / labels.len().max(1) as f64).max(MIN_ALPHA)
}
#[derive(Debug, Clone, PartialEq)]
pub struct Cut {
pub k: usize,
pub n: usize,
pub alpha: f64,
pub source: &'static str,
}
pub fn cut(labels: &[Labelled]) -> Cut {
let (n, a) = (labels.len(), alpha(labels));
if n + 1 < (1.0 / a - 1e-9).ceil() as usize {
return Cut { k: PRIOR_K, n, alpha: a, source: "prior" };
}
let widest = labels.iter().map(|l| l.grades.len()).max().unwrap_or(0);
let missed = |k: usize| labels.iter().filter(|l| !l.needed.iter().any(|r| *r < k)).count();
let k = (1..=widest).find(|&k| (missed(k) + 1) as f64 / (n + 1) as f64 <= a + 1e-12).unwrap_or(widest);
Cut { k, n, alpha: a, source: "crc" }
}
pub fn on() -> bool {
std::env::var("BORG_STOP_ATTENTION").as_deref() == Ok("on")
}
pub type Split<'a> = (Vec<&'a Challenge>, Vec<&'a Challenge>, Option<Cut>, Vec<f64>);
pub fn split<'a>(applied: &[(&'a Challenge, u8)], cut: &Cut) -> (Vec<&'a Challenge>, Vec<&'a Challenge>) {
if !applied.iter().any(|(_, g)| *g == 3) {
return conscience::graded_capped(applied, None);
}
let ask = cut.k.min(RAIL).min(applied.len());
let mut free: Vec<&Challenge> = applied[ask..].iter().map(|(c, _)| *c).collect();
free.truncate(conscience::CAP);
(applied[..ask].iter().map(|(c, _)| *c).collect(), free)
}
pub fn graded<'a>(applied: &[(&'a Challenge, u8)], home: &std::path::Path) -> Split<'a> {
if !on() || conscience::ask_cap().is_some() || !applied.iter().any(|(_, g)| *g == 3) {
let (ask, free) = conscience::graded(applied);
let p = fixed_p(applied, &ask);
return (ask, free, None, p);
}
let outcomes = outcome::read(&home.join("outcomes.jsonl"));
let base = cut(&labels(&outcomes));
let p = propensities(applied.len(), base.k);
let c = match explores(outcomes.iter().filter(|o| o.kind == outcome::GRADED).count()) {
true => Cut { k: RAIL, source: "explore", ..base },
false => base,
};
let (ask, free) = split(applied, &c);
(ask, free, Some(c), p)
}
pub fn fixed_p(applied: &[(&Challenge, u8)], ask: &[&Challenge]) -> Vec<f64> {
applied.iter().map(|(c, _)| if ask.iter().any(|a| a.id == c.id) { 1.0 } else { 0.0 }).collect()
}
pub fn propensities(n: usize, k: usize) -> Vec<f64> {
let q = 1.0 / EXPLORE as f64;
let (cut, rail) = (k.min(RAIL).min(n), RAIL.min(n));
(0..n).map(|r| (1.0 - q) * f64::from(u8::from(r < cut)) + q * f64::from(u8::from(r < rail))).map(crate::outcome::round_p).collect()
}
pub fn explores(graded: usize) -> bool {
(graded + 1).is_multiple_of(EXPLORE)
}
pub fn line(c: &Cut, applied: usize) -> String {
format!("#borg-attention k={} n={} alpha={:.4} source={} applied={applied}", c.k, c.n, c.alpha, c.source)
}
#[cfg(test)]
mod tests {
use super::*;
fn rec(kind: &str, session: &str, lessons: &[&str], ms: i64) -> Outcome {
Outcome { kind: kind.into(), session: session.into(), lessons: lessons.iter().map(|l| l.to_string()).collect(), ms, ..Default::default() }
}
fn graded_rec(session: &str, lessons: &[&str], grades: &[u8], ms: i64) -> Outcome {
explored(session, lessons, grades, &[], ms)
}
fn explored(session: &str, lessons: &[&str], grades: &[u8], unasked: &[&str], ms: i64) -> Outcome {
Outcome {
grades: grades.to_vec(),
ranks: (0..lessons.len() as u16).collect(),
chosen: lessons.iter().filter(|l| !unasked.contains(l)).map(|l| l.to_string()).collect(),
explore: true,
..rec(outcome::GRADED, session, lessons, ms)
}
}
fn lab(grades: &[u8], needed: &[usize]) -> Labelled {
Labelled { ms: 0, grades: grades.to_vec(), needed: needed.to_vec() }
}
#[test]
fn labels_are_needed_lessons_after_the_check_in_its_session() {
let log = vec![
rec(outcome::CHANGED, "s", &["C"], 5), explored("s", &["A", "B", "C", "D"], &[3, 2, 1, 1], &["D"], 10),
rec(outcome::CHANGED, "s", &["B"], 20),
rec(outcome::MISSED, "s", &["D"], 30),
rec(outcome::IGNORED, "s", &["A"], 30),
rec(outcome::CHANGED, "t", &["A"], 30), graded_rec("u", &["A"], &[3], 10), graded_rec("v", &["A"], &[2], 10), rec(outcome::CHANGED, "v", &["A"], 20),
];
assert_eq!(labels(&log), vec![Labelled { ms: 10, grades: vec![3, 2, 1, 1], needed: vec![1, 3] }]);
}
#[test]
fn only_explored_checks_label_the_cut() {
let cut = |explore: bool| Outcome { explore, ..explored("s", &["A", "B"], &[3, 3], &["B"], 10) };
let log = |explore: bool| vec![cut(explore), rec(outcome::CHANGED, "s", &["A"], 20)];
assert_eq!(labels(&log(false)), vec![]);
assert_eq!(labels(&log(true)), vec![Labelled { ms: 10, grades: vec![3, 3], needed: vec![0] }]);
}
#[test]
fn an_outcome_labels_only_the_last_check_that_asked_the_lesson() {
let log = vec![
graded_rec("s", &["A"], &[3], 10),
graded_rec("s", &["A"], &[3], 20),
explored("s", &["A"], &[3], &["A"], 25), rec(outcome::CHANGED, "s", &["A"], 35),
graded_rec("s", &["A"], &[3], 40), ];
assert_eq!(labels(&log), vec![Labelled { ms: 20, grades: vec![3], needed: vec![0] }]);
}
#[test]
fn labels_are_linear_in_the_store() {
let ls: Vec<String> = (0..10).map(|i| format!("L{i}")).collect();
let l: Vec<&str> = ls.iter().map(String::as_str).collect();
let log: Vec<Outcome> = (0..25_000i64)
.flat_map(|i| {
let s = format!("s{}", i % 500);
let mut v = vec![graded_rec(&s, &l, &[3; 10], 4 * i)];
v.extend((1..4).map(|j| rec(outcome::CHANGED, &s, &[l[(i as usize + j) % 10]], 4 * i + j as i64)));
v
})
.collect();
let t = std::time::Instant::now();
assert_eq!(labels(&log).len(), WINDOW);
assert!(t.elapsed() < std::time::Duration::from_secs(5), "{:?}", t.elapsed());
}
#[test]
fn labels_keep_the_newest_window() {
let log: Vec<Outcome> = (0..WINDOW as i64 + 5)
.flat_map(|i| [graded_rec(&format!("s{i}"), &["A"], &[3], 2 * i), rec(outcome::CHANGED, &format!("s{i}"), &["A"], 2 * i + 1)])
.collect();
let l = labels(&log);
assert_eq!(l.len(), WINDOW);
assert_eq!(l[0].ms, 10, "the five oldest dropped");
}
#[test]
fn incumbent_replays_the_fixed_rule() {
assert_eq!(incumbent(&[2, 1, 2]), Vec::<usize>::new());
assert_eq!(incumbent(&[1, 3, 2, 1]), [1, 2, 0, 3]);
let many: Vec<u8> = std::iter::once(3).chain(std::iter::repeat_n(1, 12)).collect();
assert_eq!(incumbent(&many), (0..conscience::CAP).collect::<Vec<_>>());
let l = [lab(&many, &[11]), lab(&many, &[0]), lab(&many, &[0]), lab(&many, &[3])];
assert_eq!(alpha(&l), 0.25);
assert_eq!(alpha(&l[1..]), MIN_ALPHA, "never below MIN_ALPHA");
}
#[test]
fn too_few_labels_keep_the_prior() {
assert_eq!(cut(&[]), Cut { k: PRIOR_K, n: 0, alpha: MIN_ALPHA, source: "prior" });
assert_eq!(cut(&[lab(&[3, 3, 3], &[0])]), Cut { k: PRIOR_K, n: 1, alpha: MIN_ALPHA, source: "prior" });
let g: Vec<u8> = std::iter::once(1).chain(std::iter::repeat_n(3, 10)).collect(); let l = [lab(&g, &[0]), lab(&[3, 3], &[1]), lab(&[3, 3], &[0])];
assert_eq!(cut(&l[1..]).source, "prior", "no miss in two");
assert_eq!((cut(&l).k, cut(&l).source), (2, "crc"), "{:?}", cut(&l));
}
#[test]
fn the_cut_is_the_smallest_k_that_meets_alpha() {
let skip: Vec<u8> = std::iter::once(1).chain(std::iter::repeat_n(3, 10)).collect();
let g: Vec<u8> = std::iter::once(3).chain(std::iter::repeat_n(1, 11)).collect();
let mut l: Vec<Labelled> = (0..10).map(|_| lab(&skip, &[0])).collect();
l.extend((0..30).map(|i| lab(&g, &[i % 3])));
assert_eq!(cut(&l), Cut { k: 3, n: 40, alpha: 0.25, source: "crc" });
let mut l: Vec<Labelled> = (0..25).map(|i| lab(&g, &[i % 4])).collect();
l.extend((0..15).map(|_| lab(&g, &[11])));
assert_eq!(cut(&l), Cut { k: 12, n: 40, alpha: 0.375, source: "crc" });
}
#[test]
fn propensities_mix_the_cut_and_the_explore_slice() {
let p = propensities(25, 3);
assert_eq!(&p[..3], &[1.0; 3]);
assert!(p[3..20].iter().all(|&x| x == 0.1), "{p:?}");
assert_eq!(&p[20..], &[0.0; 5]);
assert_eq!(propensities(2, 11), [1.0, 1.0], "the cut covers every apply");
assert_eq!(propensities(12, 11)[11], 0.1);
}
#[test]
fn fixed_rule_propensities_are_the_choice() {
let ls: Vec<Challenge> = (0..3).map(|i| Challenge { id: format!("L{i}"), ..Default::default() }).collect();
let blocking: Vec<(&Challenge, u8)> = ls.iter().zip([3, 2, 1]).collect();
let (ask, _) = conscience::graded_capped(&blocking, None);
assert_eq!(fixed_p(&blocking, &ask), [1.0, 1.0, 1.0]);
let soft: Vec<(&Challenge, u8)> = ls.iter().take(2).zip([2, 1]).collect();
let (ask, _) = conscience::graded_capped(&soft, None);
assert_eq!(fixed_p(&soft, &ask), [0.0, 0.0]);
}
#[test]
fn every_tenth_check_explores() {
assert_eq!((0..30).filter(|&g| explores(g)).collect::<Vec<_>>(), [9, 19, 29]);
}
#[test]
fn no_three_is_the_fixed_rule_and_the_rail_holds() {
let ls: Vec<Challenge> = (0..30).map(|i| Challenge { id: format!("L{i}"), ..Default::default() }).collect();
let ids = |v: &[&Challenge]| v.iter().map(|c| c.id.clone()).collect::<Vec<_>>();
let c = Cut { k: 2, n: 50, alpha: 0.1, source: "crc" };
let soft: Vec<(&Challenge, u8)> = ls.iter().take(12).map(|c| (c, 2)).collect();
let (a, f) = split(&soft, &c);
let (fa, ff) = conscience::graded_capped(&soft, None);
assert_eq!((ids(&a), ids(&f)), (ids(&fa), ids(&ff)));
let mixed: Vec<(&Challenge, u8)> = ls.iter().take(5).enumerate().map(|(i, c)| (c, if i == 3 { 3 } else { 1 })).collect();
let (a, f) = split(&mixed, &c);
assert_eq!((ids(&a), ids(&f)), (vec!["L0".to_string(), "L1".into()], vec!["L2".to_string(), "L3".into(), "L4".into()]));
let all: Vec<(&Challenge, u8)> = ls.iter().map(|c| (c, 3)).collect();
let (a, f) = split(&all, &Cut { k: 30, ..c.clone() });
assert_eq!((a.len(), f.len()), (RAIL, conscience::CAP.min(30 - RAIL)));
}
#[test]
fn the_line_names_the_cut() {
let c = Cut { k: 4, n: 40, alpha: 1.0 / 41.0, source: "crc" };
assert_eq!(line(&c, 12), "#borg-attention k=4 n=40 alpha=0.0244 source=crc applied=12");
}
}