use std::collections::{HashMap, HashSet};
use crate::conscience::Challenge;
use crate::outcome::{self, Outcome};
pub const D: f64 = 0.5;
pub const REGRET: f64 = 1.0;
pub const TESTED_W: f64 = 0.5;
pub const VETOED_W: u32 = 3;
#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize)]
pub struct Weights {
pub vetoed: f64,
pub regret: f64,
pub tested: f64,
}
pub const WEIGHTS: Weights = Weights { vetoed: VETOED_W as f64, regret: REGRET, tested: TESTED_W };
pub const D_CONSOLIDATED: f64 = 0.3;
pub const CONSOLIDATE_SESSIONS: usize = 3;
pub const DORMANT_BELOW: f64 = -2.0;
#[derive(Debug, Default)]
pub struct Clock {
first: Vec<(i64, String)>,
at: HashMap<String, u64>,
repos: Vec<HashSet<String>>,
}
impl Clock {
pub fn of(outcomes: &[Outcome]) -> Self {
let mut first: HashMap<&str, i64> = HashMap::new();
let mut repos: HashMap<&str, HashSet<String>> = HashMap::new();
for o in outcomes.iter().filter(|o| outcome::SHARED_KINDS.contains(&o.kind.as_str()) && o.kind != outcome::TESTED && outcome::is_result(o)) {
let m = first.entry(o.session.as_str()).or_insert(o.ms);
*m = (*m).min(o.ms);
if !o.repo.is_empty() {
repos.entry(o.session.as_str()).or_default().insert(o.repo.clone());
}
}
let mut first: Vec<(i64, String)> = first.into_iter().map(|(s, ms)| (ms, s.to_string())).collect();
first.sort();
let at = first.iter().enumerate().map(|(i, (_, s))| (s.clone(), i as u64)).collect();
let repos = first.iter().map(|(_, s)| repos.remove(s.as_str()).unwrap_or_default()).collect();
Clock { first, at, repos }
}
pub fn eligible(&self, c: &Challenge) -> Option<Vec<u64>> {
if c.reach != crate::reach::REPO || c.repo.is_empty() {
return None;
}
let tagged = outcome::tag(&c.repo);
Some((0..self.repos.len() as u64).filter(|t| self.repos[*t as usize].iter().any(|r| r.eq_ignore_ascii_case(&c.repo) || *r == tagged)).collect())
}
pub fn now(&self, session: Option<&str>) -> u64 {
session.and_then(|s| self.at.get(s).copied()).unwrap_or(self.first.len() as u64)
}
pub fn of_session(&self, session: &str) -> u64 {
self.at.get(session).copied().unwrap_or(self.first.len() as u64)
}
pub fn at_ms(&self, ms: i64) -> u64 {
self.first.partition_point(|(m, _)| *m < ms) as u64
}
}
pub fn activation(ages: impl IntoIterator<Item = u64>, d: f64) -> f64 {
ages.into_iter().map(|a| (a.max(1) as f64).powf(-d)).sum::<f64>().ln()
}
pub fn squash(a: f64) -> f64 {
2.0 / (1.0 + (-a / 2.0).exp())
}
#[derive(Debug, Clone, PartialEq)]
pub struct Memory {
pub activation: f64,
pub changed: u32,
pub ignored: u32,
pub corrected: u32,
pub vetoed: u32,
pub regretted: f64,
pub tested: u32,
pub consolidated: bool,
pub dormant: bool,
}
impl Memory {
pub fn alpha_with(&self, w: &Weights) -> f64 {
1.0 + self.changed as f64 + w.tested * self.tested as f64
}
pub fn beta_with(&self, w: &Weights) -> f64 {
(1 + self.ignored + outcome::CORRECTED_W * self.corrected) as f64 + w.vetoed * self.vetoed as f64 + w.regret * self.regretted
}
pub fn alpha(&self) -> f64 {
self.alpha_with(&WEIGHTS)
}
pub fn beta(&self) -> f64 {
self.beta_with(&WEIGHTS)
}
pub fn mean(&self) -> f64 {
self.alpha() / (self.alpha() + self.beta())
}
pub fn weight(&self, id: &str, session: Option<&str>) -> f64 {
let x = match session {
Some(s) => draw(self.alpha(), self.beta(), seed(s, id)),
None => self.mean(),
};
squash(self.activation) * (0.5 + x)
}
}
pub fn derive(outcomes: &[Outcome], lessons: &[Challenge], session: Option<&str>) -> HashMap<String, Memory> {
let clock = Clock::of(outcomes);
let now = clock.now(session);
let mut result: HashMap<&str, HashMap<&str, u8>> = HashMap::new();
let mut uses: HashMap<&str, HashSet<&str>> = HashMap::new();
let mut repos: HashMap<&str, HashSet<&str>> = HashMap::new();
let mut regret: HashMap<(&str, &str), (f64, &str)> = HashMap::new();
let mut tested: HashMap<&str, HashSet<&str>> = HashMap::new();
let mut corrections: HashMap<&str, HashSet<i64>> = HashMap::new();
for o in outcomes.iter().filter(|o| outcome::is_result(o)) {
if is_correction(o) {
corrections.entry(o.session.as_str()).or_default().insert(o.ms);
}
if o.kind == outcome::TESTED {
for l in &o.lessons {
tested.entry(l.as_str()).or_default().insert(o.session.as_str());
}
continue;
}
if o.kind == outcome::REGRETTED {
for l in &o.lessons {
let e = regret.entry((l.as_str(), o.cause.as_str())).or_insert((0.0, o.session.as_str()));
e.0 = e.0.max(o.credit.clamp(0.0, 1.0));
}
continue;
}
let r = match o.kind.as_str() {
outcome::VETOED => 3,
outcome::CORRECTED => 2,
outcome::CHANGED => 1,
outcome::IGNORED => 0,
_ => continue,
};
for l in &o.lessons {
let e = result.entry(l.as_str()).or_default().entry(o.session.as_str()).or_insert(r);
*e = (*e).max(r);
if r == 1 {
uses.entry(l.as_str()).or_default().insert(o.session.as_str());
repos.entry(l.as_str()).or_default().insert(o.repo.as_str());
}
}
}
let mut blamed: HashMap<(&str, &str), f64> = HashMap::new();
for ((l, _), (credit, s)) in regret {
if result.get(l).and_then(|m| m.get(s)).is_none_or(|r| *r < 2) {
*blamed.entry((l, s)).or_default() += credit;
}
}
let mut regretted: HashMap<&str, f64> = HashMap::new();
for ((l, s), credit) in blamed {
*regretted.entry(l).or_default() += credit.min(regret_cap(corrections.get(s).map_or(0, HashSet::len)));
}
let mut eligible: HashMap<(&str, &str), Option<Vec<u64>>> = HashMap::new();
lessons
.iter()
.map(|c| {
let per = result.get(c.id.as_str());
let count = |k: u8| per.map_or(0, |m| m.values().filter(|r| **r == k).count() as u32);
let (changed, ignored, corrected, vetoed) = (count(1), count(0), count(2), count(3));
let used: Vec<u64> = uses.get(c.id.as_str()).map(|u| u.iter().map(|s| clock.of_session(s)).collect()).unwrap_or_default();
let changed_sessions = used.len();
let consolidated = changed_sessions >= CONSOLIDATE_SESSIONS && (repos.get(c.id.as_str()).is_some_and(|r| r.len() >= 2) || c.engineers.len() >= 2);
let d = if consolidated { D_CONSOLIDATED } else { D };
let born = clock.at_ms(c.origin_ms);
let eligible = eligible.entry((c.reach.as_str(), c.repo.as_str())).or_insert_with(|| clock.eligible(c));
let rank = |t: u64| eligible.as_ref().map_or(t, |e| e.partition_point(|x| *x < t) as u64);
let now = rank(now);
let ages = used.into_iter().chain([born]).map(|t| now.saturating_sub(rank(t)) + 1);
let a = activation(ages, d);
let regretted = regretted.get(c.id.as_str()).copied().unwrap_or(0.0);
let tested = tested.get(c.id.as_str()).map_or(0, |t| t.iter().filter(|s| per.and_then(|m| m.get(**s)).is_none_or(|r| *r < 2)).count() as u32);
let m = Memory { activation: a, changed, ignored, corrected, vetoed, regretted, tested, consolidated, dormant: !consolidated && a < DORMANT_BELOW };
(c.id.clone(), m)
})
.collect()
}
pub fn regret_cap(corrections: usize) -> f64 {
corrections.max(1) as f64
}
fn is_correction(o: &Outcome) -> bool {
match o.kind.as_str() {
outcome::CORRECTED | outcome::VETOED => true,
outcome::MISSED => o.cause.is_empty(),
_ => false,
}
}
pub fn seed(session: &str, lesson: &str) -> u64 {
let mut h: u64 = 0xcbf29ce484222325;
for b in session.bytes().chain([0]).chain(lesson.bytes()) {
h ^= b as u64;
h = h.wrapping_mul(0x100000001b3);
}
h
}
pub struct Rng(u64);
impl Rng {
pub fn new(seed: u64) -> Self {
Rng(seed)
}
pub fn next_u64(&mut self) -> u64 {
self.0 = self.0.wrapping_add(0x9e3779b97f4a7c15);
let mut z = self.0;
z = (z ^ (z >> 30)).wrapping_mul(0xbf58476d1ce4e5b9);
z = (z ^ (z >> 27)).wrapping_mul(0x94d049bb133111eb);
z ^ (z >> 31)
}
pub fn unit(&mut self) -> f64 {
((self.next_u64() >> 11) as f64 + 0.5) / (1u64 << 53) as f64
}
}
pub fn round_seeded(x: f64, seed: u64) -> u32 {
let (whole, frac) = (x.floor(), x - x.floor());
match frac == 0.0 || Rng::new(seed).unit() >= frac {
true => whole as u32,
false => whole as u32 + 1,
}
}
pub fn draw(alpha: f64, beta: f64, seed: u64) -> f64 {
sample_beta(round_seeded(alpha, seed ^ 0x416c_7068), round_seeded(beta, seed ^ 0x5265_6772), seed)
}
pub fn sample_beta(a: u32, b: u32, seed: u64) -> f64 {
let scale = (a.max(b) as f64 / 1000.0).max(1.0);
let (a, b) = (((a as f64 / scale).round() as u32).max(1), ((b as f64 / scale).round() as u32).max(1));
let mut rng = Rng::new(seed);
let mut gamma = |k: u32| (0..k).map(|_| -rng.unit().ln()).sum::<f64>();
let x = gamma(a);
let y = gamma(b);
x / (x + y)
}
pub fn shuffle<T>(items: &mut [T], session: &str) {
let mut rng = Rng::new(seed(session, "#order"));
for i in (1..items.len()).rev() {
let j = (rng.next_u64() % (i as u64 + 1)) as usize;
items.swap(i, j);
}
}
fn distinctive(all: &[Challenge]) -> impl Fn(&str) -> HashSet<String> + '_ {
let mut df: HashMap<String, usize> = HashMap::new();
for c in all {
for t in crate::conscience::tokens(&c.question).into_iter().collect::<HashSet<_>>() {
*df.entry(t).or_default() += 1;
}
}
let n = all.len().max(1);
move |text: &str| crate::conscience::tokens(text).into_iter().filter(|t| df.get(t).is_some_and(|d| 3 * d <= n)).collect()
}
pub const NOVEL_WORDS: usize = 3;
pub fn resolve(asked: &[&Challenge], all: &[Challenge], before: &str, after: &str, final_msg: &str, min_new: usize) -> (Vec<String>, Vec<String>) {
let words = distinctive(all);
let had: HashSet<String> = crate::conscience::tokens(before).into_iter().collect();
let new: HashSet<String> = words(&format!("{after}\n{final_msg}")).into_iter().filter(|w| !had.contains(w)).collect();
let lower = final_msg.to_lowercase();
let (mut changed, mut ignored) = (Vec::new(), Vec::new());
for c in asked {
let verdict = declared(&lower, &c.id.to_lowercase());
let applies = match verdict {
Some(v) => v,
None => words(&c.question).intersection(&new).count() >= min_new,
};
match applies {
true => changed.push(c.id.clone()),
false => ignored.push(c.id.clone()),
}
}
(changed, ignored)
}
pub fn declared(lower: &str, id: &str) -> Option<bool> {
let mut out = None;
let mut from = 0;
while let Some(i) = lower[from..].find(id) {
let at = from + i + id.len();
let rest: String = lower[at..].chars().take(16).collect();
let rest = rest.trim_start_matches(|c: char| c == ']' || c == '`' || c == '*' || c == ':' || c == '-' || c == '–' || c.is_whitespace());
if rest.starts_with("applies") || rest.starts_with("apply") {
out = Some(true);
} else if rest.starts_with("skip") {
out = Some(false);
}
from = at;
}
out
}
pub fn strip_declaration(text: &str, id: &str) -> String {
let (lower, id) = (text.to_ascii_lowercase(), id.to_ascii_lowercase());
if id.is_empty() {
return text.to_string();
}
let mut out = String::with_capacity(text.len());
let (mut from, mut kept) = (0, 0);
while let Some(i) = lower[from..].find(&id) {
let start = from + i;
let at = start + id.len();
let gap = lower[at..].len()
- lower[at..].trim_start_matches(|c: char| c == ']' || c == '`' || c == '*' || c == ':' || c == '-' || c == '–' || c.is_whitespace()).len();
let rest = &lower[at + gap..];
let word = ["applies", "apply", "skip"].iter().find(|w| rest.starts_with(**w)).map_or(0, |w| w.len());
let opens = start == 0 || !lower[..start].ends_with(|c: char| c.is_ascii_alphanumeric());
if word > 0 && opens && gap > 0 {
out.push_str(&text[kept..start]);
kept = at + gap + word;
}
from = at;
}
out.push_str(&text[kept..]);
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::outcome::{CHANGED, CORRECTED, IGNORED, NOT_CORRECTED};
#[test]
fn a_stripped_declaration_is_gone_for_the_judge_and_the_rest_stays() {
for said in ["Mdrain: applies. Replaced the reset arc.", "- [Mdrain] - skip", "`Mdrain`: APPLY", "ok mdrain:skip then"] {
let s = strip_declaration(said, "Mdrain");
assert_eq!(declared(&s.to_lowercase(), "mdrain"), None, "{said} -> {s}");
assert!(declared(&said.to_lowercase(), "mdrain").is_some(), "{said}");
}
let both = "Mdrain: applies. New: skip. Mdrain is the budget lesson.";
let s = strip_declaration(both, "Mdrain");
assert_eq!(declared(&s.to_lowercase(), "new"), Some(false), "{s}");
assert!(s.contains("Replaced") || s.contains("Mdrain is the budget lesson"), "{s}");
assert_eq!(strip_declaration("no declaration here", "Mdrain"), "no declaration here");
assert_eq!(strip_declaration("XMdrain: applies", "Mdrain"), "XMdrain: applies", "another id ending in it");
}
fn o(kind: &str, session: &str, lesson: &str, ms: i64, repo: &str) -> Outcome {
Outcome { kind: kind.into(), session: session.into(), lessons: vec![lesson.into()], ms, repo: repo.into(), ..Default::default() }
}
fn lesson(id: &str, ms: i64) -> Challenge {
Challenge { id: id.into(), origin_ms: ms, question: format!("question of {id}"), ..Default::default() }
}
fn sessions(n: usize) -> Vec<Outcome> {
(0..n).map(|i| o(outcome::ASKED, &format!("s{i:03}"), "other", 100 * (i as i64 + 1), "r")).collect()
}
#[test]
fn a_new_lesson_is_neutral_and_activation_decays_with_the_session_clock_not_wall_time() {
let l = lesson("L", 0);
let m = &derive(&[], std::slice::from_ref(&l), None)["L"];
assert_eq!((m.activation, m.mean(), m.weight("L", None)), (0.0, 0.5, 1.0));
let m = &derive(&sessions(10), std::slice::from_ref(&l), None)["L"];
assert!((m.activation - (11f64).powf(-0.5).ln()).abs() < 1e-12);
assert!(m.weight("L", None) < 1.0 && m.weight("L", None) > 0.5);
let spread: Vec<Outcome> = sessions(10)
.into_iter()
.enumerate()
.map(|(i, mut x)| {
x.ms = if i < 5 { x.ms } else { x.ms + 365 * 86_400_000 };
x
})
.collect();
assert_eq!(derive(&spread, &[l], None)["L"].activation, m.activation);
}
#[test]
fn a_repo_convention_ages_only_with_the_sessions_of_its_repo_and_a_team_lesson_with_all() {
let conv = Challenge { reach: crate::reach::REPO.into(), repo: "a".into(), ..lesson("C", 0) };
let both = [lesson("T", 0), conv];
let in_repo = |log: Vec<Outcome>, repo: &dyn Fn(usize) -> String| -> Vec<Outcome> {
log.into_iter()
.enumerate()
.map(|(i, mut x)| {
x.repo = repo(i);
x
})
.collect()
};
let m = derive(&in_repo(sessions(60), &|_| "b".into()), &both, None);
assert!(m["T"].dormant);
assert_eq!((m["C"].activation, m["C"].dormant), (0.0, false));
for repo in ["a".to_string(), "A".to_string(), outcome::tag("a")] {
let m = derive(&in_repo(sessions(60), &|_| repo.clone()), &both, None);
assert_eq!(m["C"].activation, m["T"].activation, "{repo}");
assert!(m["C"].dormant, "{repo}");
}
let mut log = in_repo(sessions(40), &|i| if i % 4 == 0 { "a".into() } else { "b".into() });
log.push(o(CHANGED, "s004", "C", 501, "a"));
let m = derive(&log, &both, None);
let want = activation([11, 10], D);
assert!((m["C"].activation - want).abs() < 1e-12, "{} vs {want}", m["C"].activation);
}
#[test]
fn activation_is_monotone_in_uses_and_spaced_uses_outlast_massed_ones() {
let l = lesson("L", 0);
let mut log = sessions(30);
let a0 = derive(&log, std::slice::from_ref(&l), None)["L"].activation;
log.push(o(CHANGED, "s010", "L", 1101, "r"));
let a1 = derive(&log, std::slice::from_ref(&l), None)["L"].activation;
log.push(o(CHANGED, "s020", "L", 2101, "r"));
let a2 = derive(&log, std::slice::from_ref(&l), None)["L"].activation;
assert!(a0 < a1 && a1 < a2, "{a0} {a1} {a2}");
let at = |uses: &[usize]| {
let mut log = sessions(60);
log.extend(uses.iter().map(|i| o(CHANGED, &format!("s{i:03}"), "L", 100 * (*i as i64 + 1) + 1, "r")));
derive(&log, std::slice::from_ref(&l), None)["L"].activation
};
assert!(at(&[1, 20]) > at(&[1, 2]));
}
#[test]
fn belief_counts_each_session_once_corrected_over_changed_over_ignored() {
let l = lesson("L", 0);
let log = vec![
o(CHANGED, "a", "L", 1, "r"),
o(NOT_CORRECTED, "a", "L", 2, "r"), o(IGNORED, "b", "L", 3, "r"),
o(CHANGED, "c", "L", 4, "r"),
o(CORRECTED, "c", "L", 5, "r"), o(NOT_CORRECTED, "d", "L", 6, "r"), ];
let m = &derive(&log, &[l], None)["L"];
assert_eq!((m.changed, m.ignored, m.corrected), (1, 1, 1));
assert_eq!((m.alpha(), m.beta()), (2.0, 4.0));
}
#[test]
fn an_unjudged_settle_is_censored_and_a_later_verdict_counts_once() {
let l = lesson("L", 0);
let mem = |log: &[Outcome]| derive(log, std::slice::from_ref(&l), None)["L"].clone();
let asked = Outcome { check: "c1".into(), ..o(outcome::ASKED, "a", "L", 1, "r") };
let censored = Outcome { check: "c1".into(), reason: outcome::UNJUDGED_NO_CALL.into(), ..o(NOT_CORRECTED, "a", "L", 5, "r") };
let alone = mem(std::slice::from_ref(&asked));
assert_eq!(mem(&[asked.clone(), censored.clone()]), alone, "no belief effect: not an ignore");
for (kind, want) in [(CHANGED, (1, 0)), (IGNORED, (0, 1))] {
let later = Outcome { check: "c1".into(), ..o(kind, "a", "L", 9, "r") };
let m = mem(&[asked.clone(), censored.clone(), later]);
assert_eq!((m.changed, m.ignored), want, "{kind}");
}
}
#[test]
fn a_placebo_or_unclear_result_moves_no_belief() {
let l = lesson("L", 0);
let mem = |log: &[Outcome]| derive(log, std::slice::from_ref(&l), None)["L"].clone();
let asked = o(outcome::ASKED, "a", "L", 1, "r");
let alone = mem(std::slice::from_ref(&asked));
for kind in [CHANGED, IGNORED, CORRECTED, outcome::UNCLEAR] {
let placebo = Outcome { channel: outcome::PLACEBO.into(), ..o(kind, "b", "L", 5, "r") };
assert_eq!(mem(&[asked.clone(), placebo]), alone, "placebo {kind}");
}
assert_eq!(mem(&[asked.clone(), Outcome { channel: "stop".into(), ..o(outcome::UNCLEAR, "a", "L", 5, "r") }]), alone);
}
#[test]
fn one_sessions_regrets_blame_a_lesson_at_most_its_corrections() {
let l = lesson("L", 0);
let mem = |log: &[Outcome]| derive(log, std::slice::from_ref(&l), None)["L"].clone();
let asked = o(outcome::ASKED, "a", "L", 1, "r");
let mut log = vec![asked.clone()];
log.extend((0..5).map(|i| regret("a", "L", 10 + i, &format!("R{i}"), 0.5)));
assert_eq!(mem(&log).regretted, regret_cap(0));
assert_eq!(regret_cap(0), 1.0);
let mut twice = log.clone();
twice.extend([o(CORRECTED, "a", "X", 3, "r"), o(outcome::VETOED, "a", "Y", 4, "r"), o(CORRECTED, "a", "Z", 4, "r")]);
assert_eq!(mem(&twice).regretted, 2.0);
let mut other = log.clone();
other.extend([o(outcome::ASKED, "b", "L", 2, "r"), regret("b", "L", 20, "Rb", 0.5)]);
assert_eq!(mem(&other).regretted, 1.5);
}
#[test]
fn the_misses_a_regret_writes_leave_its_sessions_cap_unchanged() {
let l = lesson("L", 0);
let mem = |log: &[Outcome]| derive(log, std::slice::from_ref(&l), None)["L"].clone();
let mut log = vec![o(outcome::ASKED, "a", "L", 1, "r")];
for i in 0..5 {
log.push(regret("a", "L", 10 + i, &format!("R{i}"), 0.5));
log.push(Outcome { cause: format!("R{i}"), ..o(outcome::MISSED, "a", &format!("M{i}"), 10 + i, "r") });
}
assert_eq!(mem(&log).regretted, regret_cap(0), "the regrets' own misses are no corrections");
let tagged: Vec<Outcome> =
log.iter().map(|x| Outcome { cause: if x.cause.is_empty() { String::new() } else { outcome::tag(&x.cause) }, ..x.clone() }).collect();
assert_eq!(mem(&tagged).regretted, regret_cap(0));
let mut corrected = log.clone();
corrected.extend([o(outcome::MISSED, "a", "X", 3, "r"), o(outcome::MISSED, "a", "Y", 4, "r")]);
assert_eq!(mem(&corrected).regretted, 2.0);
}
#[test]
fn a_veto_outweighs_a_correction_and_a_change_in_its_session() {
let l = lesson("L", 0);
let log = vec![o(CHANGED, "a", "L", 1, "r"), o(CORRECTED, "a", "L", 2, "r"), o(outcome::VETOED, "a", "L", 3, "r"), o(CORRECTED, "b", "L", 4, "r")];
let m = &derive(&log, std::slice::from_ref(&l), None)["L"];
assert_eq!((m.changed, m.ignored, m.corrected, m.vetoed), (0, 0, 1, 1));
assert_eq!((m.alpha(), m.beta()), (1.0, (1 + 2 + 3) as f64));
let without = derive(&log[1..], std::slice::from_ref(&l), None)["L"].activation;
assert!(m.activation > without, "{} {without}", m.activation);
let only = |kind: &str| derive(&[o(kind, "a", "L", 1, "r")], std::slice::from_ref(&l), None)["L"].mean();
assert!(only(outcome::VETOED) < only(CORRECTED), "{} {}", only(outcome::VETOED), only(CORRECTED));
assert_eq!(only(outcome::VETOED), 1.0 / 5.0);
}
fn regret(session: &str, lesson: &str, ms: i64, cause: &str, credit: f64) -> Outcome {
Outcome { cause: cause.into(), credit, ..o(outcome::REGRETTED, session, lesson, ms, "r") }
}
#[test]
fn a_regret_lowers_belief_less_than_a_correction() {
let l = lesson("L", 0);
let mem = |log: &[Outcome]| derive(log, std::slice::from_ref(&l), None)["L"].clone();
let asked = o(outcome::ASKED, "a", "L", 1, "r");
let full = mem(&[asked.clone(), regret("a", "L", 9, "R1", 1.0)]);
assert_eq!((full.regretted, full.beta(), full.mean()), (1.0, 2.0, 1.0 / 3.0));
assert_eq!(mem(&[asked.clone(), o(CORRECTED, "a", "L", 9, "r")]).mean(), 1.0 / 4.0);
let log = vec![asked.clone(), regret("a", "L", 9, "R1", 0.25), regret("a", "L", 9, "R1", 0.25), regret("a", "L", 12, "R2", 0.5)];
assert_eq!(mem(&log).regretted, 0.75);
let mut reversed = log.clone();
reversed.reverse();
assert_eq!(mem(&reversed), mem(&log));
let changed = mem(&[asked.clone(), o(CHANGED, "a", "L", 5, "r"), regret("a", "L", 9, "R1", 0.5)]);
assert_eq!((changed.changed, changed.regretted), (1, 0.5));
assert_eq!(mem(&[asked.clone(), regret("a", "L", 9, "R1", 1.0)]).activation, mem(std::slice::from_ref(&asked)).activation);
for kind in [CORRECTED, outcome::VETOED] {
let late = mem(&[asked.clone(), regret("a", "L", 9, "R1", 0.5), o(kind, "a", "L", 12, "r")]);
assert_eq!(late.regretted, 0.0, "{kind}");
assert_eq!(late.beta(), mem(&[asked.clone(), o(kind, "a", "L", 12, "r")]).beta(), "{kind}");
}
let other = mem(&[asked.clone(), o(CORRECTED, "b", "L", 3, "r"), regret("a", "L", 9, "R1", 0.5)]);
assert_eq!(other.regretted, 0.5, "another session's correction leaves the regret");
}
#[test]
fn an_older_stores_hive_verdicts_change_no_belief_and_no_tick() {
let l = lesson("L", 0);
let mem = |log: &[Outcome]| derive(log, std::slice::from_ref(&l), None)["L"].clone();
let asked = o(outcome::ASKED, "a", "L", 1, "r");
for kind in ["verified", "rejected", "hacked"] {
let old = Outcome { cause: "g/t1.v1".into(), ..o(kind, "a", "L", 9, "r") };
assert_eq!(mem(&[asked.clone(), old.clone()]), mem(std::slice::from_ref(&asked)), "{kind}");
assert_eq!(Clock::of(&[old]).now(None), 0, "{kind}");
}
}
#[test]
fn a_green_test_run_weighs_half_a_change_and_never_ticks() {
let l = lesson("L", 0);
let mem = |log: &[Outcome]| derive(log, std::slice::from_ref(&l), None)["L"].clone();
let asked = o(outcome::ASKED, "a", "L", 1, "r");
let t = mem(&[asked.clone(), o(outcome::TESTED, "a", "L", 9, "r"), o(outcome::TESTED, "a", "L", 10, "r")]);
assert_eq!((t.tested, t.alpha(), t.changed), (1, 1.0 + TESTED_W, 0), "once per session, never a use");
assert_eq!(t.activation, mem(std::slice::from_ref(&asked)).activation);
assert_eq!(Clock::of(&[o(outcome::TESTED, "z", "L", 9, "r")]).now(None), 0, "no tick");
for k in [outcome::CORRECTED, outcome::VETOED] {
let log = [o(outcome::TESTED, "a", "L", 9, "r"), asked.clone(), o(k, "a", "L", 5, "r")];
assert_eq!(mem(&log).tested, 0, "{k}");
}
assert_eq!(Weights { tested: 2.0, ..WEIGHTS }.tested, 2.0);
assert_eq!(t.alpha_with(&Weights { tested: 2.0, ..WEIGHTS }), 3.0);
}
#[test]
fn fractional_beta_sampling_is_unbiased_and_integer_beta_unchanged() {
let n = 20_000;
let mean = (0..n).map(|i| round_seeded(2.5, seed(&format!("s{i}"), "L")) as f64).sum::<f64>() / n as f64;
assert!((mean - 2.5).abs() < 0.02, "{mean}");
assert!((0..100).all(|i| round_seeded(3.0, i) == 3));
let m = Memory { activation: 0.0, changed: 2, ignored: 1, corrected: 1, vetoed: 0, regretted: 0.0, tested: 0, consolidated: false, dormant: false };
assert_eq!(m.weight("L", Some("s")), squash(0.0) * (0.5 + sample_beta(3, 4, seed("s", "L"))));
assert_eq!(m.weight("L", Some("s")), 1.0618369485259265);
}
#[test]
fn fractional_alpha_draws_unbiased_and_integer_unchanged() {
assert!((0..200).all(|i| draw(3.0, 4.0, i) == sample_beta(3, 4, i)));
assert!((0..200).all(|i| draw(2.0, 2.5, i) == sample_beta(2, round_seeded(2.5, i ^ 0x5265_6772), i)));
let n = 20_000;
let mean = |a: f64| (0..n).map(|i| draw(a, 1.0, seed(&format!("s{i}"), "L"))).sum::<f64>() / n as f64;
let (lo, mid, hi) = (mean(1.0), mean(1.5), mean(2.0));
assert!((mid - (lo + hi) / 2.0).abs() < 0.01, "{lo} {mid} {hi}");
let m = Memory { tested: 2, ..derive(&[], &[lesson("L", 0)], None)["L"].clone() };
let quarter = Weights { tested: 0.25, ..WEIGHTS };
assert_eq!((m.alpha_with(&quarter), m.alpha(), m.beta_with(&quarter)), (1.5, 2.0, 1.0));
}
#[test]
fn derived_memory_is_independent_of_log_order_so_merging_logs_merges_memory() {
let ls = [lesson("L", 150), lesson("M", 0), Challenge { engineers: vec!["a@x".into(), "b@x".into()], ..lesson("N", 0) }];
let mut log = sessions(12);
for (k, s) in ["s002", "s005", "s009"].iter().enumerate() {
log.push(o(CHANGED, s, "N", 300 * k as i64, "r"));
log.push(o(IGNORED, s, "M", 300 * k as i64, "r"));
}
log.push(o(CORRECTED, "s007", "L", 800, "r"));
let base = derive(&log, &ls, Some("s011"));
let mut rng = Rng::new(7);
for _ in 0..20 {
let mut shuffled = log.clone();
for i in (1..shuffled.len()).rev() {
shuffled.swap(i, (rng.next_u64() % (i as u64 + 1)) as usize);
}
assert_eq!(derive(&shuffled, &ls, Some("s011")), base);
}
let (a, b) = log.split_at(log.len() / 2);
assert_eq!(derive(&[b, a].concat(), &ls, Some("s011")), base);
assert!(base["N"].consolidated && !base["M"].consolidated);
}
#[test]
fn consolidation_needs_spread_uses_and_dormancy_waits_for_a_long_silence() {
let l = lesson("L", 0);
let mut log = sessions(80);
for i in [1, 2, 3] {
log.push(o(CHANGED, &format!("s{i:03}"), "L", 100 * (i + 1) + 1, "one-repo"));
}
let m = &derive(&log, std::slice::from_ref(&l), None)["L"];
assert!(!m.consolidated, "one repo, one engineer");
log.push(o(CHANGED, "s004", "L", 501, "other-repo"));
let m = &derive(&log, std::slice::from_ref(&l), None)["L"];
assert!(m.consolidated && !m.dormant);
let fresh = lesson("F", 0);
assert!(!derive(&sessions(40), std::slice::from_ref(&fresh), None)["F"].dormant);
let mut quiet = sessions(80);
assert!(derive(&quiet, std::slice::from_ref(&fresh), None)["F"].dormant);
quiet.push(o(CHANGED, "s079", "F", 8001, "r"));
assert!(!derive(&quiet, &[fresh], None)["F"].dormant, "one use reactivates");
}
#[test]
fn thompson_samples_are_stable_per_session_spread_when_uncertain_and_tight_when_proven() {
assert_eq!(sample_beta(3, 4, seed("s", "L")), sample_beta(3, 4, seed("s", "L")));
assert_ne!(sample_beta(3, 4, seed("s", "L")), sample_beta(3, 4, seed("t", "L")));
let draws = |a, b| (0..400).map(|i| sample_beta(a, b, seed(&format!("s{i}"), "L"))).collect::<Vec<f64>>();
let mean = |v: &[f64]| v.iter().sum::<f64>() / v.len() as f64;
let sd = |v: &[f64]| (v.iter().map(|x| (x - mean(v)).powi(2)).sum::<f64>() / v.len() as f64).sqrt();
let (new, proven, harmful) = (draws(1, 1), draws(21, 3), draws(1, 21));
assert!((mean(&new) - 0.5).abs() < 0.05 && sd(&new) > 0.25, "uniform: {} {}", mean(&new), sd(&new));
assert!(mean(&proven) > 0.8 && sd(&proven) < 0.1);
assert!(mean(&harmful) < 0.1);
assert!(new.iter().chain(&proven).all(|x| *x > 0.0 && *x < 1.0));
let m = Memory { activation: 0.0, changed: 20, ignored: 2, corrected: 0, vetoed: 0, regretted: 0.0, tested: 0, consolidated: false, dormant: false };
assert!(m.weight("L", Some("s")) > 1.2 && m.weight("L", Some("s")) < 1.5);
}
#[test]
fn resolve_reads_declarations_first_then_edits_on_the_lessons_point() {
let mk = |id: &str, q: &str| Challenge { id: id.into(), question: q.into(), ..Default::default() };
let all = vec![
mk("Idrain", "Is the per-turn hint place drained by an explicit consumer?"),
mk("Ijudge", "Is the replay judge exactly the named Vertex model?"),
mk("Ihost", "Does the replay run on live hosts?"),
mk("x1", "Is the plan complete?"),
mk("x2", "Is the plan tested?"),
mk("x3", "Is the plan small?"),
];
let asked: Vec<&Challenge> = all[..3].iter().collect();
let fin = "Revised.\n`Ijudge`: applies — switched to Vertex.\n- Ihost: skip (offline task)\n";
let before = "Add a per-turn hint budget to TextNet; plan: reset arc on the starting transition";
let edits = "src/TextNet.java drain the hint place with an explicit consumer transition";
let (changed, ignored) = resolve(&asked, &all, before, edits, fin, NOVEL_WORDS);
assert_eq!((changed, ignored), (vec!["Idrain".to_string(), "Ijudge".to_string()], vec!["Ihost".to_string()]));
assert_eq!(resolve(&asked, &all, before, "", "Done.", NOVEL_WORDS).0, Vec::<String>::new());
assert!(resolve(&asked[..1], &all, before, "renamed the hint label, drained", "", NOVEL_WORDS).0.is_empty());
assert!(resolve(&asked[..1], &all, "the hint place is drained by an explicit consumer", edits, "", NOVEL_WORDS).0.is_empty());
}
#[test]
fn shuffle_is_a_stable_permutation_per_session() {
let mut a: Vec<u32> = (0..8).collect();
let mut b = a.clone();
shuffle(&mut a, "s1");
shuffle(&mut b, "s1");
assert_eq!(a, b);
let mut sorted = a.clone();
sorted.sort();
assert_eq!(sorted, (0..8).collect::<Vec<_>>());
let firsts: HashSet<u32> = (0..40)
.map(|i| {
let mut v: Vec<u32> = (0..8).collect();
shuffle(&mut v, &format!("s{i}"));
v[0]
})
.collect();
assert!(firsts.len() >= 5, "{firsts:?}");
}
}