1use std::collections::{HashMap, HashSet};
73
74use crate::conscience::Challenge;
75use crate::outcome::{self, Outcome};
76
77pub const D: f64 = 0.5;
80pub const REGRET: f64 = 1.0;
84pub const TESTED_W: f64 = 0.5;
87pub const VETOED_W: u32 = 3;
91
92#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize)]
94pub struct Weights {
95 pub vetoed: f64,
96 pub regret: f64,
97 pub tested: f64,
98}
99
100pub const WEIGHTS: Weights = Weights { vetoed: VETOED_W as f64, regret: REGRET, tested: TESTED_W };
102pub const D_CONSOLIDATED: f64 = 0.3;
104pub const CONSOLIDATE_SESSIONS: usize = 3;
107pub const DORMANT_BELOW: f64 = -2.0;
110
111#[derive(Debug, Default)]
117pub struct Clock {
118 first: Vec<(i64, String)>,
119 at: HashMap<String, u64>,
120 repos: Vec<HashSet<String>>,
122}
123
124impl Clock {
125 pub fn of(outcomes: &[Outcome]) -> Self {
126 let mut first: HashMap<&str, i64> = HashMap::new();
127 let mut repos: HashMap<&str, HashSet<String>> = HashMap::new();
128 for o in outcomes.iter().filter(|o| ticks(&o.kind) && outcome::is_result(o)) {
129 let m = first.entry(o.session.as_str()).or_insert(o.ms);
130 *m = (*m).min(o.ms);
131 if !o.repo.is_empty() {
132 repos.entry(o.session.as_str()).or_default().insert(o.repo.clone());
133 }
134 }
135 Self::from_sessions(first.into_iter().map(|(s, ms)| (s.to_string(), ms, repos.remove(s).unwrap_or_default())))
136 }
137
138 pub fn from_sessions(sessions: impl IntoIterator<Item = (String, i64, HashSet<String>)>) -> Self {
140 let mut rows: Vec<((i64, String), HashSet<String>)> = sessions.into_iter().map(|(s, ms, r)| ((ms, s), r)).collect();
141 rows.sort_by(|a, b| a.0.cmp(&b.0));
142 let at = rows.iter().enumerate().map(|(i, ((_, s), _))| (s.clone(), i as u64)).collect();
143 let (first, repos) = rows.into_iter().unzip();
144 Clock { first, at, repos }
145 }
146
147 pub fn eligible(&self, c: &Challenge) -> Option<Vec<u64>> {
151 if c.reach != crate::reach::REPO || c.repo.is_empty() {
152 return None;
153 }
154 let tagged = outcome::tag(&c.repo);
155 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())
156 }
157
158 pub fn now(&self, session: Option<&str>) -> u64 {
161 session.and_then(|s| self.at.get(s).copied()).unwrap_or(self.first.len() as u64)
162 }
163
164 pub fn of_session(&self, session: &str) -> u64 {
166 self.at.get(session).copied().unwrap_or(self.first.len() as u64)
167 }
168
169 pub fn at_ms(&self, ms: i64) -> u64 {
171 self.first.partition_point(|(m, _)| *m < ms) as u64
172 }
173}
174
175pub fn activation(ages: impl IntoIterator<Item = u64>, d: f64) -> f64 {
177 ages.into_iter().map(|a| (a.max(1) as f64).powf(-d)).sum::<f64>().ln()
178}
179
180pub fn squash(a: f64) -> f64 {
182 2.0 / (1.0 + (-a / 2.0).exp())
183}
184
185#[derive(Debug, Clone, PartialEq)]
187pub struct Memory {
188 pub activation: f64,
189 pub changed: u32,
191 pub ignored: u32,
192 pub corrected: u32,
193 pub vetoed: u32,
194 pub regretted: f64,
196 pub tested: u32,
198 pub consolidated: bool,
199 pub dormant: bool,
200}
201
202impl Memory {
203 pub fn alpha_with(&self, w: &Weights) -> f64 {
205 1.0 + self.changed as f64 + w.tested * self.tested as f64
206 }
207
208 pub fn beta_with(&self, w: &Weights) -> f64 {
210 (1 + self.ignored + outcome::CORRECTED_W * self.corrected) as f64 + w.vetoed * self.vetoed as f64 + w.regret * self.regretted
211 }
212
213 pub fn alpha(&self) -> f64 {
214 self.alpha_with(&WEIGHTS)
215 }
216
217 pub fn beta(&self) -> f64 {
218 self.beta_with(&WEIGHTS)
219 }
220
221 pub fn mean(&self) -> f64 {
222 self.alpha() / (self.alpha() + self.beta())
223 }
224
225 pub fn weight(&self, id: &str, session: Option<&str>) -> f64 {
227 let x = match session {
228 Some(s) => draw(self.alpha(), self.beta(), seed(s, id)),
229 None => self.mean(),
230 };
231 squash(self.activation) * (0.5 + x)
232 }
233}
234
235pub fn ticks(kind: &str) -> bool {
237 outcome::SHARED_KINDS.contains(&kind) && kind != outcome::TESTED
238}
239
240pub fn result_of(kind: &str) -> Option<u8> {
246 match kind {
247 outcome::VETOED => Some(3),
248 outcome::CORRECTED => Some(2),
249 outcome::CHANGED => Some(1),
250 outcome::IGNORED => Some(0),
251 _ => None,
252 }
253}
254
255#[derive(Debug, Default)]
259pub struct Folded<'a> {
260 pub clock: Clock,
261 pub result: HashMap<&'a str, HashMap<&'a str, u8>>,
263 pub uses: HashMap<&'a str, HashSet<&'a str>>,
266 pub repos: HashMap<&'a str, HashSet<&'a str>>,
267 pub regret: Vec<(&'a str, &'a str, f64)>,
271 pub tested: HashMap<&'a str, HashSet<&'a str>>,
273 pub corrections: HashMap<&'a str, HashSet<i64>>,
275}
276
277impl<'a> Folded<'a> {
278 pub fn of(outcomes: &'a [Outcome]) -> Self {
279 let mut f = Folded { clock: Clock::of(outcomes), ..Folded::default() };
280 let mut regret: HashMap<(&str, &str), (f64, &str)> = HashMap::new();
281 for o in outcomes.iter().filter(|o| outcome::is_result(o)) {
283 if is_correction(o) {
284 f.corrections.entry(o.session.as_str()).or_default().insert(o.ms);
285 }
286 if o.kind == outcome::TESTED {
287 for l in &o.lessons {
288 f.tested.entry(l.as_str()).or_default().insert(o.session.as_str());
289 }
290 continue;
291 }
292 if o.kind == outcome::REGRETTED {
293 for l in &o.lessons {
294 let e = regret.entry((l.as_str(), o.cause.as_str())).or_insert((0.0, o.session.as_str()));
295 e.0 = e.0.max(o.credit.clamp(0.0, 1.0));
296 }
297 continue;
298 }
299 let Some(r) = result_of(&o.kind) else { continue };
300 for l in &o.lessons {
301 let e = f.result.entry(l.as_str()).or_default().entry(o.session.as_str()).or_insert(r);
302 *e = (*e).max(r);
303 if r == 1 {
304 f.uses.entry(l.as_str()).or_default().insert(o.session.as_str());
305 f.repos.entry(l.as_str()).or_default().insert(o.repo.as_str());
306 }
307 }
308 }
309 f.regret = blame(regret.into_iter().map(|((l, _), (credit, s))| (l, s, credit)));
310 f
311 }
312}
313
314pub fn blame<L: Copy + Ord, S: Copy + Ord>(regrets: impl IntoIterator<Item = (L, S, f64)>) -> Vec<(L, S, f64)> {
317 let mut v: Vec<(L, S, f64)> = regrets.into_iter().collect();
318 v.sort_unstable_by(|a, b| (a.0, a.1).cmp(&(b.0, b.1)).then(a.2.total_cmp(&b.2)));
319 let mut out: Vec<(L, S, f64)> = Vec::new();
320 for (l, s, credit) in v {
321 match out.last_mut() {
322 Some(last) if (last.0, last.1) == (l, s) => last.2 += credit,
323 _ => out.push((l, s, credit)),
324 }
325 }
326 out
327}
328
329pub fn derive(outcomes: &[Outcome], lessons: &[Challenge], session: Option<&str>) -> HashMap<String, Memory> {
331 derive_folded(Folded::of(outcomes), lessons, session)
332}
333
334pub fn derive_folded(f: Folded<'_>, lessons: &[Challenge], session: Option<&str>) -> HashMap<String, Memory> {
336 let Folded { clock, result, uses, repos, regret, tested, corrections } = f;
337 let now = clock.now(session);
338 let mut capped: Vec<(&str, f64)> = regret
343 .into_iter()
344 .filter(|(l, s, _)| result.get(l).and_then(|m| m.get(s)).is_none_or(|r| *r < 2))
345 .map(|(l, s, credit)| (l, credit.min(regret_cap(corrections.get(s).map_or(0, HashSet::len)))))
346 .collect();
347 capped.sort_unstable_by(|a, b| a.0.cmp(b.0).then(a.1.total_cmp(&b.1)));
348 let mut regretted: HashMap<&str, f64> = HashMap::new();
349 for (l, credit) in capped {
350 *regretted.entry(l).or_default() += credit;
351 }
352 let mut eligible: HashMap<(&str, &str), Option<Vec<u64>>> = HashMap::new();
354 lessons
355 .iter()
356 .map(|c| {
357 let per = result.get(c.id.as_str());
358 let count = |k: u8| per.map_or(0, |m| m.values().filter(|r| **r == k).count() as u32);
359 let (changed, ignored, corrected, vetoed) = (count(1), count(0), count(2), count(3));
360 let used: Vec<u64> = uses.get(c.id.as_str()).map(|u| u.iter().map(|s| clock.of_session(s)).collect()).unwrap_or_default();
363 let changed_sessions = used.len();
364 let consolidated = changed_sessions >= CONSOLIDATE_SESSIONS && (repos.get(c.id.as_str()).is_some_and(|r| r.len() >= 2) || c.engineers.len() >= 2);
365 let d = if consolidated { D_CONSOLIDATED } else { D };
366 let born = clock.at_ms(c.origin_ms);
367 let eligible = eligible.entry((c.reach.as_str(), c.repo.as_str())).or_insert_with(|| clock.eligible(c));
369 let rank = |t: u64| eligible.as_ref().map_or(t, |e| e.partition_point(|x| *x < t) as u64);
370 let now = rank(now);
371 let ages = used.into_iter().chain([born]).map(|t| now.saturating_sub(rank(t)) + 1);
372 let a = activation(ages, d);
373 let regretted = regretted.get(c.id.as_str()).copied().unwrap_or(0.0);
374 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);
376 let m = Memory { activation: a, changed, ignored, corrected, vetoed, regretted, tested, consolidated, dormant: !consolidated && a < DORMANT_BELOW };
377 (c.id.clone(), m)
378 })
379 .collect()
380}
381
382pub fn regret_cap(corrections: usize) -> f64 {
388 corrections.max(1) as f64
389}
390
391fn is_correction(o: &Outcome) -> bool {
396 is_correction_of(&o.kind, &o.cause)
397}
398
399pub fn is_correction_of(kind: &str, cause: &str) -> bool {
401 match kind {
402 outcome::CORRECTED | outcome::VETOED => true,
403 outcome::MISSED => cause.is_empty(),
404 _ => false,
405 }
406}
407
408pub fn seed(session: &str, lesson: &str) -> u64 {
410 let mut h: u64 = 0xcbf29ce484222325;
411 for b in session.bytes().chain([0]).chain(lesson.bytes()) {
412 h ^= b as u64;
413 h = h.wrapping_mul(0x100000001b3);
414 }
415 h
416}
417
418pub struct Rng(u64);
420
421impl Rng {
422 pub fn new(seed: u64) -> Self {
423 Rng(seed)
424 }
425
426 pub fn next_u64(&mut self) -> u64 {
427 self.0 = self.0.wrapping_add(0x9e3779b97f4a7c15);
428 let mut z = self.0;
429 z = (z ^ (z >> 30)).wrapping_mul(0xbf58476d1ce4e5b9);
430 z = (z ^ (z >> 27)).wrapping_mul(0x94d049bb133111eb);
431 z ^ (z >> 31)
432 }
433
434 pub fn unit(&mut self) -> f64 {
436 ((self.next_u64() >> 11) as f64 + 0.5) / (1u64 << 53) as f64
437 }
438}
439
440pub fn round_seeded(x: f64, seed: u64) -> u32 {
443 let (whole, frac) = (x.floor(), x - x.floor());
444 match frac == 0.0 || Rng::new(seed).unit() >= frac {
445 true => whole as u32,
446 false => whole as u32 + 1,
447 }
448}
449
450pub fn draw(alpha: f64, beta: f64, seed: u64) -> f64 {
453 sample_beta(round_seeded(alpha, seed ^ 0x416c_7068), round_seeded(beta, seed ^ 0x5265_6772), seed)
454}
455
456pub fn sample_beta(a: u32, b: u32, seed: u64) -> f64 {
459 let scale = (a.max(b) as f64 / 1000.0).max(1.0);
460 let (a, b) = (((a as f64 / scale).round() as u32).max(1), ((b as f64 / scale).round() as u32).max(1));
461 let mut rng = Rng::new(seed);
462 let mut gamma = |k: u32| (0..k).map(|_| -rng.unit().ln()).sum::<f64>();
463 let x = gamma(a);
464 let y = gamma(b);
465 x / (x + y)
466}
467
468pub fn shuffle<T>(items: &mut [T], session: &str) {
471 let mut rng = Rng::new(seed(session, "#order"));
472 for i in (1..items.len()).rev() {
473 let j = (rng.next_u64() % (i as u64 + 1)) as usize;
474 items.swap(i, j);
475 }
476}
477
478pub struct Distinctive {
481 df: HashMap<String, usize>,
482 n: usize,
483}
484
485impl Distinctive {
486 pub fn new(all: &[Challenge]) -> Self {
487 let mut df: HashMap<String, usize> = HashMap::new();
488 for c in all {
489 for t in crate::conscience::tokens(&c.question).into_iter().collect::<HashSet<_>>() {
490 *df.entry(t).or_default() += 1;
491 }
492 }
493 Distinctive { df, n: all.len().max(1) }
494 }
495
496 pub fn is(&self, t: &str) -> bool {
498 self.df.get(t).is_some_and(|d| 3 * d <= self.n)
499 }
500
501 pub fn words(&self, text: &str) -> HashSet<String> {
503 crate::conscience::tokens(text).into_iter().filter(|t| self.is(t)).collect()
504 }
505}
506
507pub const NOVEL_WORDS: usize = 3;
510
511pub fn resolve(asked: &[&Challenge], all: &[Challenge], before: &str, after: &str, final_msg: &str, min_new: usize) -> (Vec<String>, Vec<String>) {
524 let words = Distinctive::new(all);
525 let had: HashSet<String> = crate::conscience::tokens(before).into_iter().collect();
526 let new: HashSet<String> = words.words(&format!("{after}\n{final_msg}")).into_iter().filter(|w| !had.contains(w)).collect();
527 let lower = final_msg.to_lowercase();
528 let (mut changed, mut ignored) = (Vec::new(), Vec::new());
529 for c in asked {
530 let verdict = declared(&lower, &c.id.to_lowercase());
531 let applies = match verdict {
532 Some(v) => v,
533 None => words.words(&c.question).intersection(&new).count() >= min_new,
534 };
535 match applies {
536 true => changed.push(c.id.clone()),
537 false => ignored.push(c.id.clone()),
538 }
539 }
540 (changed, ignored)
541}
542
543pub fn declared(lower: &str, id: &str) -> Option<bool> {
546 let mut out = None;
547 let mut from = 0;
548 while let Some(i) = lower[from..].find(id) {
549 let at = from + i + id.len();
550 let rest: String = lower[at..].chars().take(16).collect();
551 let rest = rest.trim_start_matches(|c: char| c == ']' || c == '`' || c == '*' || c == ':' || c == '-' || c == '–' || c.is_whitespace());
552 if rest.starts_with("applies") || rest.starts_with("apply") {
553 out = Some(true);
554 } else if rest.starts_with("skip") {
555 out = Some(false);
556 }
557 from = at;
558 }
559 out
560}
561
562pub fn strip_declaration(text: &str, id: &str) -> String {
566 let (lower, id) = (text.to_ascii_lowercase(), id.to_ascii_lowercase());
568 if id.is_empty() {
569 return text.to_string();
570 }
571 let mut out = String::with_capacity(text.len());
572 let (mut from, mut kept) = (0, 0);
573 while let Some(i) = lower[from..].find(&id) {
574 let start = from + i;
575 let at = start + id.len();
576 let gap = lower[at..].len()
577 - lower[at..].trim_start_matches(|c: char| c == ']' || c == '`' || c == '*' || c == ':' || c == '-' || c == '–' || c.is_whitespace()).len();
578 let rest = &lower[at + gap..];
579 let word = ["applies", "apply", "skip"].iter().find(|w| rest.starts_with(**w)).map_or(0, |w| w.len());
580 let opens = start == 0 || !lower[..start].ends_with(|c: char| c.is_ascii_alphanumeric());
582 if word > 0 && opens && gap > 0 {
583 out.push_str(&text[kept..start]);
584 kept = at + gap + word;
585 }
586 from = at;
587 }
588 out.push_str(&text[kept..]);
589 out
590}
591
592#[cfg(test)]
593mod tests {
594 use super::*;
595 use crate::outcome::{CHANGED, CORRECTED, IGNORED, NOT_CORRECTED};
596
597 #[test]
598 fn a_stripped_declaration_is_gone_for_the_judge_and_the_rest_stays() {
599 for said in ["Mdrain: applies. Replaced the reset arc.", "- [Mdrain] - skip", "`Mdrain`: APPLY", "ok mdrain:skip then"] {
600 let s = strip_declaration(said, "Mdrain");
601 assert_eq!(declared(&s.to_lowercase(), "mdrain"), None, "{said} -> {s}");
602 assert!(declared(&said.to_lowercase(), "mdrain").is_some(), "{said}");
603 }
604 let both = "Mdrain: applies. New: skip. Mdrain is the budget lesson.";
605 let s = strip_declaration(both, "Mdrain");
606 assert_eq!(declared(&s.to_lowercase(), "new"), Some(false), "{s}");
607 assert!(s.contains("Replaced") || s.contains("Mdrain is the budget lesson"), "{s}");
608 assert_eq!(strip_declaration("no declaration here", "Mdrain"), "no declaration here");
609 assert_eq!(strip_declaration("XMdrain: applies", "Mdrain"), "XMdrain: applies", "another id ending in it");
610 }
611
612 fn o(kind: &str, session: &str, lesson: &str, ms: i64, repo: &str) -> Outcome {
613 Outcome { kind: kind.into(), session: session.into(), lessons: vec![lesson.into()], ms, repo: repo.into(), ..Default::default() }
614 }
615
616 fn lesson(id: &str, ms: i64) -> Challenge {
617 Challenge { id: id.into(), origin_ms: ms, question: format!("question of {id}"), ..Default::default() }
618 }
619
620 fn sessions(n: usize) -> Vec<Outcome> {
622 (0..n).map(|i| o(outcome::ASKED, &format!("s{i:03}"), "other", 100 * (i as i64 + 1), "r")).collect()
623 }
624
625 #[test]
626 fn a_new_lesson_is_neutral_and_activation_decays_with_the_session_clock_not_wall_time() {
627 let l = lesson("L", 0);
628 let m = &derive(&[], std::slice::from_ref(&l), None)["L"];
629 assert_eq!((m.activation, m.mean(), m.weight("L", None)), (0.0, 0.5, 1.0));
630 let m = &derive(&sessions(10), std::slice::from_ref(&l), None)["L"];
632 assert!((m.activation - (11f64).powf(-0.5).ln()).abs() < 1e-12);
633 assert!(m.weight("L", None) < 1.0 && m.weight("L", None) > 0.5);
634 let spread: Vec<Outcome> = sessions(10)
636 .into_iter()
637 .enumerate()
638 .map(|(i, mut x)| {
639 x.ms = if i < 5 { x.ms } else { x.ms + 365 * 86_400_000 };
640 x
641 })
642 .collect();
643 assert_eq!(derive(&spread, &[l], None)["L"].activation, m.activation);
644 }
645
646 #[test]
647 fn a_repo_convention_ages_only_with_the_sessions_of_its_repo_and_a_team_lesson_with_all() {
648 let conv = Challenge { reach: crate::reach::REPO.into(), repo: "a".into(), ..lesson("C", 0) };
649 let both = [lesson("T", 0), conv];
650 let in_repo = |log: Vec<Outcome>, repo: &dyn Fn(usize) -> String| -> Vec<Outcome> {
651 log.into_iter()
652 .enumerate()
653 .map(|(i, mut x)| {
654 x.repo = repo(i);
655 x
656 })
657 .collect()
658 };
659 let m = derive(&in_repo(sessions(60), &|_| "b".into()), &both, None);
661 assert!(m["T"].dormant);
662 assert_eq!((m["C"].activation, m["C"].dormant), (0.0, false));
663 for repo in ["a".to_string(), "A".to_string(), outcome::tag("a")] {
665 let m = derive(&in_repo(sessions(60), &|_| repo.clone()), &both, None);
666 assert_eq!(m["C"].activation, m["T"].activation, "{repo}");
667 assert!(m["C"].dormant, "{repo}");
668 }
669 let mut log = in_repo(sessions(40), &|i| if i % 4 == 0 { "a".into() } else { "b".into() });
672 log.push(o(CHANGED, "s004", "C", 501, "a"));
673 let m = derive(&log, &both, None);
674 let want = activation([11, 10], D);
675 assert!((m["C"].activation - want).abs() < 1e-12, "{} vs {want}", m["C"].activation);
676 }
677
678 #[test]
679 fn activation_is_monotone_in_uses_and_spaced_uses_outlast_massed_ones() {
680 let l = lesson("L", 0);
681 let mut log = sessions(30);
682 let a0 = derive(&log, std::slice::from_ref(&l), None)["L"].activation;
683 log.push(o(CHANGED, "s010", "L", 1101, "r"));
684 let a1 = derive(&log, std::slice::from_ref(&l), None)["L"].activation;
685 log.push(o(CHANGED, "s020", "L", 2101, "r"));
686 let a2 = derive(&log, std::slice::from_ref(&l), None)["L"].activation;
687 assert!(a0 < a1 && a1 < a2, "{a0} {a1} {a2}");
688 let at = |uses: &[usize]| {
691 let mut log = sessions(60);
692 log.extend(uses.iter().map(|i| o(CHANGED, &format!("s{i:03}"), "L", 100 * (*i as i64 + 1) + 1, "r")));
693 derive(&log, std::slice::from_ref(&l), None)["L"].activation
694 };
695 assert!(at(&[1, 20]) > at(&[1, 2]));
696 }
697
698 #[test]
699 fn belief_counts_each_session_once_corrected_over_changed_over_ignored() {
700 let l = lesson("L", 0);
701 let log = vec![
702 o(CHANGED, "a", "L", 1, "r"),
703 o(NOT_CORRECTED, "a", "L", 2, "r"), o(IGNORED, "b", "L", 3, "r"),
705 o(CHANGED, "c", "L", 4, "r"),
706 o(CORRECTED, "c", "L", 5, "r"), o(NOT_CORRECTED, "d", "L", 6, "r"), ];
709 let m = &derive(&log, &[l], None)["L"];
710 assert_eq!((m.changed, m.ignored, m.corrected), (1, 1, 1));
711 assert_eq!((m.alpha(), m.beta()), (2.0, 4.0));
712 }
713
714 #[test]
715 fn an_unjudged_settle_is_censored_and_a_later_verdict_counts_once() {
716 let l = lesson("L", 0);
717 let mem = |log: &[Outcome]| derive(log, std::slice::from_ref(&l), None)["L"].clone();
718 let asked = Outcome { check: "c1".into(), ..o(outcome::ASKED, "a", "L", 1, "r") };
719 let censored = Outcome { check: "c1".into(), reason: outcome::UNJUDGED_NO_CALL.into(), ..o(NOT_CORRECTED, "a", "L", 5, "r") };
720 let alone = mem(std::slice::from_ref(&asked));
721 assert_eq!(mem(&[asked.clone(), censored.clone()]), alone, "no belief effect: not an ignore");
722 for (kind, want) in [(CHANGED, (1, 0)), (IGNORED, (0, 1))] {
724 let later = Outcome { check: "c1".into(), ..o(kind, "a", "L", 9, "r") };
725 let m = mem(&[asked.clone(), censored.clone(), later]);
726 assert_eq!((m.changed, m.ignored), want, "{kind}");
727 }
728 }
729
730 #[test]
731 fn a_placebo_or_unclear_result_moves_no_belief() {
732 let l = lesson("L", 0);
733 let mem = |log: &[Outcome]| derive(log, std::slice::from_ref(&l), None)["L"].clone();
734 let asked = o(outcome::ASKED, "a", "L", 1, "r");
735 let alone = mem(std::slice::from_ref(&asked));
736 for kind in [CHANGED, IGNORED, CORRECTED, outcome::UNCLEAR] {
737 let placebo = Outcome { channel: outcome::PLACEBO.into(), ..o(kind, "b", "L", 5, "r") };
738 assert_eq!(mem(&[asked.clone(), placebo]), alone, "placebo {kind}");
739 }
740 assert_eq!(mem(&[asked.clone(), Outcome { channel: "stop".into(), ..o(outcome::UNCLEAR, "a", "L", 5, "r") }]), alone);
741 }
742
743 #[test]
748 fn a_not_arisen_or_remaining_verdict_moves_no_belief() {
749 let l = lesson("L", 0);
750 let mem = |log: &[Outcome]| derive(log, std::slice::from_ref(&l), None)["L"].clone();
751 let asked = Outcome { channel: "stop".into(), ..o(outcome::ASKED, "a", "L", 1, "r") };
752 let alone = mem(std::slice::from_ref(&asked));
753 for kind in [outcome::NOT_ARISEN, outcome::REMAINS] {
754 for channel in ["stop", "live", "queued", outcome::PLACEBO] {
755 let v = Outcome { channel: channel.into(), reasons: vec!["only docs changed".into()], ..o(kind, "a", "L", 5, "r") };
756 assert!(!outcome::is_result(&v), "{kind} {channel}");
757 assert_eq!(mem(&[asked.clone(), v]), alone, "{kind} {channel}");
758 }
759 }
760 let tests = "fail 2\npass 3\n";
762 let na = Outcome { channel: "stop".into(), ..o(outcome::NOT_ARISEN, "a", "L", 5, "r") };
763 assert_eq!(outcome::tested(std::slice::from_ref(&asked), "a", tests).len(), 1, "red then green: credited");
764 assert!(outcome::tested(&[asked.clone(), na.clone()], "a", tests).is_empty());
765 let changed = Outcome { channel: "stop".into(), ..o(CHANGED, "a", "L", 6, "r") };
766 assert_eq!(outcome::tested(&[asked.clone(), na, changed], "a", tests).len(), 1);
767 let late = Outcome { channel: "stop".into(), ..o(outcome::ASKED, "a", "L", 10, "r") };
770 let tests = "fail 11\npass 12\n";
771 for (channel, ms) in [(outcome::PLACEBO, 5), ("queued", 5), ("early", 5), ("stop", 5)] {
772 let before = Outcome { channel: channel.into(), ..o(outcome::NOT_ARISEN, "a", "L", ms, "r") };
773 assert_eq!(outcome::tested(&[before, late.clone()], "a", tests).len(), 1, "{channel} not arisen before the ask");
774 }
775 let after = Outcome { channel: outcome::PLACEBO.into(), ..o(outcome::NOT_ARISEN, "a", "L", 11, "r") };
776 assert_eq!(outcome::tested(&[late.clone(), after], "a", tests).len(), 1, "a placebo's verdict after the ask");
777 let on_it = Outcome { channel: "stop".into(), ..o(outcome::NOT_ARISEN, "a", "L", 11, "r") };
778 assert!(outcome::tested(&[asked, late, on_it], "a", tests).is_empty(), "the question's own verdict");
779 }
780
781 #[test]
782 fn one_sessions_regrets_blame_a_lesson_at_most_its_corrections() {
783 let l = lesson("L", 0);
784 let mem = |log: &[Outcome]| derive(log, std::slice::from_ref(&l), None)["L"].clone();
785 let asked = o(outcome::ASKED, "a", "L", 1, "r");
786 let mut log = vec![asked.clone()];
788 log.extend((0..5).map(|i| regret("a", "L", 10 + i, &format!("R{i}"), 0.5)));
789 assert_eq!(mem(&log).regretted, regret_cap(0));
790 assert_eq!(regret_cap(0), 1.0);
791 let mut twice = log.clone();
793 twice.extend([o(CORRECTED, "a", "X", 3, "r"), o(outcome::VETOED, "a", "Y", 4, "r"), o(CORRECTED, "a", "Z", 4, "r")]);
794 assert_eq!(mem(&twice).regretted, 2.0);
795 let mut other = log.clone();
797 other.extend([o(outcome::ASKED, "b", "L", 2, "r"), regret("b", "L", 20, "Rb", 0.5)]);
798 assert_eq!(mem(&other).regretted, 1.5);
799 }
800
801 #[test]
802 fn the_misses_a_regret_writes_leave_its_sessions_cap_unchanged() {
803 let l = lesson("L", 0);
804 let mem = |log: &[Outcome]| derive(log, std::slice::from_ref(&l), None)["L"].clone();
805 let mut log = vec![o(outcome::ASKED, "a", "L", 1, "r")];
809 for i in 0..5 {
810 log.push(regret("a", "L", 10 + i, &format!("R{i}"), 0.5));
811 log.push(Outcome { cause: format!("R{i}"), ..o(outcome::MISSED, "a", &format!("M{i}"), 10 + i, "r") });
812 }
813 assert_eq!(mem(&log).regretted, regret_cap(0), "the regrets' own misses are no corrections");
814 let tagged: Vec<Outcome> =
816 log.iter().map(|x| Outcome { cause: if x.cause.is_empty() { String::new() } else { outcome::tag(&x.cause) }, ..x.clone() }).collect();
817 assert_eq!(mem(&tagged).regretted, regret_cap(0));
818 let mut corrected = log.clone();
820 corrected.extend([o(outcome::MISSED, "a", "X", 3, "r"), o(outcome::MISSED, "a", "Y", 4, "r")]);
821 assert_eq!(mem(&corrected).regretted, 2.0);
822 }
823
824 #[test]
825 fn a_veto_outweighs_a_correction_and_a_change_in_its_session() {
826 let l = lesson("L", 0);
827 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")];
828 let m = &derive(&log, std::slice::from_ref(&l), None)["L"];
829 assert_eq!((m.changed, m.ignored, m.corrected, m.vetoed), (0, 0, 1, 1));
830 assert_eq!((m.alpha(), m.beta()), (1.0, (1 + 2 + 3) as f64));
831 let without = derive(&log[1..], std::slice::from_ref(&l), None)["L"].activation;
833 assert!(m.activation > without, "{} {without}", m.activation);
834 let only = |kind: &str| derive(&[o(kind, "a", "L", 1, "r")], std::slice::from_ref(&l), None)["L"].mean();
836 assert!(only(outcome::VETOED) < only(CORRECTED), "{} {}", only(outcome::VETOED), only(CORRECTED));
837 assert_eq!(only(outcome::VETOED), 1.0 / 5.0);
838 }
839
840 fn regret(session: &str, lesson: &str, ms: i64, cause: &str, credit: f64) -> Outcome {
841 Outcome { cause: cause.into(), credit, ..o(outcome::REGRETTED, session, lesson, ms, "r") }
842 }
843
844 #[test]
845 fn a_regret_lowers_belief_less_than_a_correction() {
846 let l = lesson("L", 0);
847 let mem = |log: &[Outcome]| derive(log, std::slice::from_ref(&l), None)["L"].clone();
848 let asked = o(outcome::ASKED, "a", "L", 1, "r");
849 let full = mem(&[asked.clone(), regret("a", "L", 9, "R1", 1.0)]);
850 assert_eq!((full.regretted, full.beta(), full.mean()), (1.0, 2.0, 1.0 / 3.0));
851 assert_eq!(mem(&[asked.clone(), o(CORRECTED, "a", "L", 9, "r")]).mean(), 1.0 / 4.0);
852 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)];
854 assert_eq!(mem(&log).regretted, 0.75);
855 let mut reversed = log.clone();
856 reversed.reverse();
857 assert_eq!(mem(&reversed), mem(&log));
858 let changed = mem(&[asked.clone(), o(CHANGED, "a", "L", 5, "r"), regret("a", "L", 9, "R1", 0.5)]);
860 assert_eq!((changed.changed, changed.regretted), (1, 0.5));
861 assert_eq!(mem(&[asked.clone(), regret("a", "L", 9, "R1", 1.0)]).activation, mem(std::slice::from_ref(&asked)).activation);
862 for kind in [CORRECTED, outcome::VETOED] {
864 let late = mem(&[asked.clone(), regret("a", "L", 9, "R1", 0.5), o(kind, "a", "L", 12, "r")]);
865 assert_eq!(late.regretted, 0.0, "{kind}");
866 assert_eq!(late.beta(), mem(&[asked.clone(), o(kind, "a", "L", 12, "r")]).beta(), "{kind}");
867 }
868 let other = mem(&[asked.clone(), o(CORRECTED, "b", "L", 3, "r"), regret("a", "L", 9, "R1", 0.5)]);
869 assert_eq!(other.regretted, 0.5, "another session's correction leaves the regret");
870 }
871
872 #[test]
873 fn an_older_stores_hive_verdicts_change_no_belief_and_no_tick() {
874 let l = lesson("L", 0);
875 let mem = |log: &[Outcome]| derive(log, std::slice::from_ref(&l), None)["L"].clone();
876 let asked = o(outcome::ASKED, "a", "L", 1, "r");
877 for kind in ["verified", "rejected", "hacked"] {
878 let old = Outcome { cause: "g/t1.v1".into(), ..o(kind, "a", "L", 9, "r") };
879 assert_eq!(mem(&[asked.clone(), old.clone()]), mem(std::slice::from_ref(&asked)), "{kind}");
880 assert_eq!(Clock::of(&[old]).now(None), 0, "{kind}");
881 }
882 }
883
884 #[test]
885 fn a_green_test_run_weighs_half_a_change_and_never_ticks() {
886 let l = lesson("L", 0);
887 let mem = |log: &[Outcome]| derive(log, std::slice::from_ref(&l), None)["L"].clone();
888 let asked = o(outcome::ASKED, "a", "L", 1, "r");
889 let t = mem(&[asked.clone(), o(outcome::TESTED, "a", "L", 9, "r"), o(outcome::TESTED, "a", "L", 10, "r")]);
890 assert_eq!((t.tested, t.alpha(), t.changed), (1, 1.0 + TESTED_W, 0), "once per session, never a use");
891 assert_eq!(t.activation, mem(std::slice::from_ref(&asked)).activation);
892 assert_eq!(Clock::of(&[o(outcome::TESTED, "z", "L", 9, "r")]).now(None), 0, "no tick");
893 for k in [outcome::CORRECTED, outcome::VETOED] {
895 let log = [o(outcome::TESTED, "a", "L", 9, "r"), asked.clone(), o(k, "a", "L", 5, "r")];
896 assert_eq!(mem(&log).tested, 0, "{k}");
897 }
898 assert_eq!(Weights { tested: 2.0, ..WEIGHTS }.tested, 2.0);
899 assert_eq!(t.alpha_with(&Weights { tested: 2.0, ..WEIGHTS }), 3.0);
900 }
901
902 #[test]
903 fn fractional_beta_sampling_is_unbiased_and_integer_beta_unchanged() {
904 let n = 20_000;
905 let mean = (0..n).map(|i| round_seeded(2.5, seed(&format!("s{i}"), "L")) as f64).sum::<f64>() / n as f64;
906 assert!((mean - 2.5).abs() < 0.02, "{mean}");
907 assert!((0..100).all(|i| round_seeded(3.0, i) == 3));
908 let m = Memory { activation: 0.0, changed: 2, ignored: 1, corrected: 1, vetoed: 0, regretted: 0.0, tested: 0, consolidated: false, dormant: false };
910 assert_eq!(m.weight("L", Some("s")), squash(0.0) * (0.5 + sample_beta(3, 4, seed("s", "L"))));
911 assert_eq!(m.weight("L", Some("s")), 1.0618369485259265);
912 }
913
914 #[test]
915 fn fractional_alpha_draws_unbiased_and_integer_unchanged() {
916 assert!((0..200).all(|i| draw(3.0, 4.0, i) == sample_beta(3, 4, i)));
918 assert!((0..200).all(|i| draw(2.0, 2.5, i) == sample_beta(2, round_seeded(2.5, i ^ 0x5265_6772), i)));
919 let n = 20_000;
921 let mean = |a: f64| (0..n).map(|i| draw(a, 1.0, seed(&format!("s{i}"), "L"))).sum::<f64>() / n as f64;
922 let (lo, mid, hi) = (mean(1.0), mean(1.5), mean(2.0));
923 assert!((mid - (lo + hi) / 2.0).abs() < 0.01, "{lo} {mid} {hi}");
924 let m = Memory { tested: 2, ..derive(&[], &[lesson("L", 0)], None)["L"].clone() };
925 let quarter = Weights { tested: 0.25, ..WEIGHTS };
926 assert_eq!((m.alpha_with(&quarter), m.alpha(), m.beta_with(&quarter)), (1.5, 2.0, 1.0));
927 }
928
929 #[test]
930 fn derived_memory_is_independent_of_log_order_so_merging_logs_merges_memory() {
931 let ls = [lesson("L", 150), lesson("M", 0), Challenge { engineers: vec!["a@x".into(), "b@x".into()], ..lesson("N", 0) }];
932 let mut log = sessions(12);
933 for (k, s) in ["s002", "s005", "s009"].iter().enumerate() {
934 log.push(o(CHANGED, s, "N", 300 * k as i64, "r"));
935 log.push(o(IGNORED, s, "M", 300 * k as i64, "r"));
936 }
937 log.push(o(CORRECTED, "s007", "L", 800, "r"));
938 let base = derive(&log, &ls, Some("s011"));
939 let mut rng = Rng::new(7);
940 for _ in 0..20 {
941 let mut shuffled = log.clone();
942 for i in (1..shuffled.len()).rev() {
943 shuffled.swap(i, (rng.next_u64() % (i as u64 + 1)) as usize);
944 }
945 assert_eq!(derive(&shuffled, &ls, Some("s011")), base);
946 }
947 let (a, b) = log.split_at(log.len() / 2);
949 assert_eq!(derive(&[b, a].concat(), &ls, Some("s011")), base);
950 assert!(base["N"].consolidated && !base["M"].consolidated);
951 }
952
953 #[test]
954 fn consolidation_needs_spread_uses_and_dormancy_waits_for_a_long_silence() {
955 let l = lesson("L", 0);
956 let mut log = sessions(80);
957 for i in [1, 2, 3] {
958 log.push(o(CHANGED, &format!("s{i:03}"), "L", 100 * (i + 1) + 1, "one-repo"));
959 }
960 let m = &derive(&log, std::slice::from_ref(&l), None)["L"];
961 assert!(!m.consolidated, "one repo, one engineer");
962 log.push(o(CHANGED, "s004", "L", 501, "other-repo"));
963 let m = &derive(&log, std::slice::from_ref(&l), None)["L"];
964 assert!(m.consolidated && !m.dormant);
965 let fresh = lesson("F", 0);
967 assert!(!derive(&sessions(40), std::slice::from_ref(&fresh), None)["F"].dormant);
968 let mut quiet = sessions(80);
969 assert!(derive(&quiet, std::slice::from_ref(&fresh), None)["F"].dormant);
970 quiet.push(o(CHANGED, "s079", "F", 8001, "r"));
971 assert!(!derive(&quiet, &[fresh], None)["F"].dormant, "one use reactivates");
972 }
973
974 #[test]
975 fn thompson_samples_are_stable_per_session_spread_when_uncertain_and_tight_when_proven() {
976 assert_eq!(sample_beta(3, 4, seed("s", "L")), sample_beta(3, 4, seed("s", "L")));
977 assert_ne!(sample_beta(3, 4, seed("s", "L")), sample_beta(3, 4, seed("t", "L")));
978 let draws = |a, b| (0..400).map(|i| sample_beta(a, b, seed(&format!("s{i}"), "L"))).collect::<Vec<f64>>();
979 let mean = |v: &[f64]| v.iter().sum::<f64>() / v.len() as f64;
980 let sd = |v: &[f64]| (v.iter().map(|x| (x - mean(v)).powi(2)).sum::<f64>() / v.len() as f64).sqrt();
981 let (new, proven, harmful) = (draws(1, 1), draws(21, 3), draws(1, 21));
982 assert!((mean(&new) - 0.5).abs() < 0.05 && sd(&new) > 0.25, "uniform: {} {}", mean(&new), sd(&new));
983 assert!(mean(&proven) > 0.8 && sd(&proven) < 0.1);
984 assert!(mean(&harmful) < 0.1);
985 assert!(new.iter().chain(&proven).all(|x| *x > 0.0 && *x < 1.0));
986 let m = Memory { activation: 0.0, changed: 20, ignored: 2, corrected: 0, vetoed: 0, regretted: 0.0, tested: 0, consolidated: false, dormant: false };
987 assert!(m.weight("L", Some("s")) > 1.2 && m.weight("L", Some("s")) < 1.5);
988 }
989
990 #[test]
991 fn resolve_reads_declarations_first_then_edits_on_the_lessons_point() {
992 let mk = |id: &str, q: &str| Challenge { id: id.into(), question: q.into(), ..Default::default() };
993 let all = vec![
994 mk("Idrain", "Is the per-turn hint place drained by an explicit consumer?"),
995 mk("Ijudge", "Is the replay judge exactly the named Vertex model?"),
996 mk("Ihost", "Does the replay run on live hosts?"),
997 mk("x1", "Is the plan complete?"),
998 mk("x2", "Is the plan tested?"),
999 mk("x3", "Is the plan small?"),
1000 ];
1001 let asked: Vec<&Challenge> = all[..3].iter().collect();
1002 let fin = "Revised.\n`Ijudge`: applies — switched to Vertex.\n- Ihost: skip (offline task)\n";
1003 let before = "Add a per-turn hint budget to TextNet; plan: reset arc on the starting transition";
1004 let edits = "src/TextNet.java drain the hint place with an explicit consumer transition";
1005 let (changed, ignored) = resolve(&asked, &all, before, edits, fin, NOVEL_WORDS);
1006 assert_eq!((changed, ignored), (vec!["Idrain".to_string(), "Ijudge".to_string()], vec!["Ihost".to_string()]));
1007 assert_eq!(resolve(&asked, &all, before, "", "Done.", NOVEL_WORDS).0, Vec::<String>::new());
1009 assert!(resolve(&asked[..1], &all, before, "renamed the hint label, drained", "", NOVEL_WORDS).0.is_empty());
1011 assert!(resolve(&asked[..1], &all, "the hint place is drained by an explicit consumer", edits, "", NOVEL_WORDS).0.is_empty());
1012 }
1013
1014 #[test]
1015 fn shuffle_is_a_stable_permutation_per_session() {
1016 let mut a: Vec<u32> = (0..8).collect();
1017 let mut b = a.clone();
1018 shuffle(&mut a, "s1");
1019 shuffle(&mut b, "s1");
1020 assert_eq!(a, b);
1021 let mut sorted = a.clone();
1022 sorted.sort();
1023 assert_eq!(sorted, (0..8).collect::<Vec<_>>());
1024 let firsts: HashSet<u32> = (0..40)
1025 .map(|i| {
1026 let mut v: Vec<u32> = (0..8).collect();
1027 shuffle(&mut v, &format!("s{i}"));
1028 v[0]
1029 })
1030 .collect();
1031 assert!(firsts.len() >= 5, "{firsts:?}");
1032 }
1033}