1use std::collections::{HashMap, HashSet};
72
73use crate::conscience::Challenge;
74use crate::outcome::{self, Outcome};
75
76pub const D: f64 = 0.5;
79pub const REGRET: f64 = 1.0;
83pub const TESTED_W: f64 = 0.5;
86pub const VETOED_W: u32 = 3;
89
90#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize)]
92pub struct Weights {
93 pub vetoed: f64,
94 pub regret: f64,
95 pub tested: f64,
96}
97
98pub const WEIGHTS: Weights = Weights { vetoed: VETOED_W as f64, regret: REGRET, tested: TESTED_W };
100pub const D_CONSOLIDATED: f64 = 0.3;
102pub const CONSOLIDATE_SESSIONS: usize = 3;
105pub const DORMANT_BELOW: f64 = -2.0;
108
109#[derive(Debug, Default)]
115pub struct Clock {
116 first: Vec<(i64, String)>,
117 at: HashMap<String, u64>,
118 repos: Vec<HashSet<String>>,
120}
121
122impl Clock {
123 pub fn of(outcomes: &[Outcome]) -> Self {
124 let mut first: HashMap<&str, i64> = HashMap::new();
125 let mut repos: HashMap<&str, HashSet<String>> = HashMap::new();
126 for o in outcomes.iter().filter(|o| outcome::SHARED_KINDS.contains(&o.kind.as_str()) && o.kind != outcome::TESTED && outcome::is_result(o)) {
127 let m = first.entry(o.session.as_str()).or_insert(o.ms);
128 *m = (*m).min(o.ms);
129 if !o.repo.is_empty() {
130 repos.entry(o.session.as_str()).or_default().insert(o.repo.clone());
131 }
132 }
133 let mut first: Vec<(i64, String)> = first.into_iter().map(|(s, ms)| (ms, s.to_string())).collect();
134 first.sort();
135 let at = first.iter().enumerate().map(|(i, (_, s))| (s.clone(), i as u64)).collect();
136 let repos = first.iter().map(|(_, s)| repos.remove(s.as_str()).unwrap_or_default()).collect();
137 Clock { first, at, repos }
138 }
139
140 pub fn eligible(&self, c: &Challenge) -> Option<Vec<u64>> {
144 if c.reach != crate::reach::REPO || c.repo.is_empty() {
145 return None;
146 }
147 let tagged = outcome::tag(&c.repo);
148 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())
149 }
150
151 pub fn now(&self, session: Option<&str>) -> u64 {
154 session.and_then(|s| self.at.get(s).copied()).unwrap_or(self.first.len() as u64)
155 }
156
157 pub fn of_session(&self, session: &str) -> u64 {
159 self.at.get(session).copied().unwrap_or(self.first.len() as u64)
160 }
161
162 pub fn at_ms(&self, ms: i64) -> u64 {
164 self.first.partition_point(|(m, _)| *m < ms) as u64
165 }
166}
167
168pub fn activation(ages: impl IntoIterator<Item = u64>, d: f64) -> f64 {
170 ages.into_iter().map(|a| (a.max(1) as f64).powf(-d)).sum::<f64>().ln()
171}
172
173pub fn squash(a: f64) -> f64 {
175 2.0 / (1.0 + (-a / 2.0).exp())
176}
177
178#[derive(Debug, Clone, PartialEq)]
180pub struct Memory {
181 pub activation: f64,
182 pub changed: u32,
184 pub ignored: u32,
185 pub corrected: u32,
186 pub vetoed: u32,
187 pub regretted: f64,
189 pub tested: u32,
191 pub consolidated: bool,
192 pub dormant: bool,
193}
194
195impl Memory {
196 pub fn alpha_with(&self, w: &Weights) -> f64 {
198 1.0 + self.changed as f64 + w.tested * self.tested as f64
199 }
200
201 pub fn beta_with(&self, w: &Weights) -> f64 {
203 (1 + self.ignored + outcome::CORRECTED_W * self.corrected) as f64 + w.vetoed * self.vetoed as f64 + w.regret * self.regretted
204 }
205
206 pub fn alpha(&self) -> f64 {
207 self.alpha_with(&WEIGHTS)
208 }
209
210 pub fn beta(&self) -> f64 {
211 self.beta_with(&WEIGHTS)
212 }
213
214 pub fn mean(&self) -> f64 {
215 self.alpha() / (self.alpha() + self.beta())
216 }
217
218 pub fn weight(&self, id: &str, session: Option<&str>) -> f64 {
220 let x = match session {
221 Some(s) => draw(self.alpha(), self.beta(), seed(s, id)),
222 None => self.mean(),
223 };
224 squash(self.activation) * (0.5 + x)
225 }
226}
227
228pub fn derive(outcomes: &[Outcome], lessons: &[Challenge], session: Option<&str>) -> HashMap<String, Memory> {
230 let clock = Clock::of(outcomes);
231 let now = clock.now(session);
232 let mut result: HashMap<&str, HashMap<&str, u8>> = HashMap::new();
234 let mut uses: HashMap<&str, HashSet<&str>> = HashMap::new();
235 let mut repos: HashMap<&str, HashSet<&str>> = HashMap::new();
236 let mut regret: HashMap<(&str, &str), (f64, &str)> = HashMap::new();
239 let mut tested: HashMap<&str, HashSet<&str>> = HashMap::new();
241 let mut corrections: HashMap<&str, HashSet<i64>> = HashMap::new();
243 for o in outcomes.iter().filter(|o| outcome::is_result(o)) {
245 if is_correction(o) {
246 corrections.entry(o.session.as_str()).or_default().insert(o.ms);
247 }
248 if o.kind == outcome::TESTED {
249 for l in &o.lessons {
250 tested.entry(l.as_str()).or_default().insert(o.session.as_str());
251 }
252 continue;
253 }
254 if o.kind == outcome::REGRETTED {
255 for l in &o.lessons {
256 let e = regret.entry((l.as_str(), o.cause.as_str())).or_insert((0.0, o.session.as_str()));
257 e.0 = e.0.max(o.credit.clamp(0.0, 1.0));
258 }
259 continue;
260 }
261 let r = match o.kind.as_str() {
262 outcome::VETOED => 3,
263 outcome::CORRECTED => 2,
264 outcome::CHANGED => 1,
265 outcome::IGNORED => 0,
266 _ => continue,
270 };
271 for l in &o.lessons {
272 let e = result.entry(l.as_str()).or_default().entry(o.session.as_str()).or_insert(r);
273 *e = (*e).max(r);
274 if r == 1 {
275 uses.entry(l.as_str()).or_default().insert(o.session.as_str());
276 repos.entry(l.as_str()).or_default().insert(o.repo.as_str());
277 }
278 }
279 }
280 let mut blamed: HashMap<(&str, &str), f64> = HashMap::new();
284 for ((l, _), (credit, s)) in regret {
285 if result.get(l).and_then(|m| m.get(s)).is_none_or(|r| *r < 2) {
286 *blamed.entry((l, s)).or_default() += credit;
287 }
288 }
289 let mut regretted: HashMap<&str, f64> = HashMap::new();
290 for ((l, s), credit) in blamed {
291 *regretted.entry(l).or_default() += credit.min(regret_cap(corrections.get(s).map_or(0, HashSet::len)));
292 }
293 let mut eligible: HashMap<(&str, &str), Option<Vec<u64>>> = HashMap::new();
295 lessons
296 .iter()
297 .map(|c| {
298 let per = result.get(c.id.as_str());
299 let count = |k: u8| per.map_or(0, |m| m.values().filter(|r| **r == k).count() as u32);
300 let (changed, ignored, corrected, vetoed) = (count(1), count(0), count(2), count(3));
301 let used: Vec<u64> = uses.get(c.id.as_str()).map(|u| u.iter().map(|s| clock.of_session(s)).collect()).unwrap_or_default();
304 let changed_sessions = used.len();
305 let consolidated = changed_sessions >= CONSOLIDATE_SESSIONS && (repos.get(c.id.as_str()).is_some_and(|r| r.len() >= 2) || c.engineers.len() >= 2);
306 let d = if consolidated { D_CONSOLIDATED } else { D };
307 let born = clock.at_ms(c.origin_ms);
308 let eligible = eligible.entry((c.reach.as_str(), c.repo.as_str())).or_insert_with(|| clock.eligible(c));
310 let rank = |t: u64| eligible.as_ref().map_or(t, |e| e.partition_point(|x| *x < t) as u64);
311 let now = rank(now);
312 let ages = used.into_iter().chain([born]).map(|t| now.saturating_sub(rank(t)) + 1);
313 let a = activation(ages, d);
314 let regretted = regretted.get(c.id.as_str()).copied().unwrap_or(0.0);
315 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);
317 let m = Memory { activation: a, changed, ignored, corrected, vetoed, regretted, tested, consolidated, dormant: !consolidated && a < DORMANT_BELOW };
318 (c.id.clone(), m)
319 })
320 .collect()
321}
322
323pub fn regret_cap(corrections: usize) -> f64 {
329 corrections.max(1) as f64
330}
331
332fn is_correction(o: &Outcome) -> bool {
337 match o.kind.as_str() {
338 outcome::CORRECTED | outcome::VETOED => true,
339 outcome::MISSED => o.cause.is_empty(),
340 _ => false,
341 }
342}
343
344pub fn seed(session: &str, lesson: &str) -> u64 {
346 let mut h: u64 = 0xcbf29ce484222325;
347 for b in session.bytes().chain([0]).chain(lesson.bytes()) {
348 h ^= b as u64;
349 h = h.wrapping_mul(0x100000001b3);
350 }
351 h
352}
353
354pub struct Rng(u64);
356
357impl Rng {
358 pub fn new(seed: u64) -> Self {
359 Rng(seed)
360 }
361
362 pub fn next_u64(&mut self) -> u64 {
363 self.0 = self.0.wrapping_add(0x9e3779b97f4a7c15);
364 let mut z = self.0;
365 z = (z ^ (z >> 30)).wrapping_mul(0xbf58476d1ce4e5b9);
366 z = (z ^ (z >> 27)).wrapping_mul(0x94d049bb133111eb);
367 z ^ (z >> 31)
368 }
369
370 pub fn unit(&mut self) -> f64 {
372 ((self.next_u64() >> 11) as f64 + 0.5) / (1u64 << 53) as f64
373 }
374}
375
376pub fn round_seeded(x: f64, seed: u64) -> u32 {
379 let (whole, frac) = (x.floor(), x - x.floor());
380 match frac == 0.0 || Rng::new(seed).unit() >= frac {
381 true => whole as u32,
382 false => whole as u32 + 1,
383 }
384}
385
386pub fn draw(alpha: f64, beta: f64, seed: u64) -> f64 {
389 sample_beta(round_seeded(alpha, seed ^ 0x416c_7068), round_seeded(beta, seed ^ 0x5265_6772), seed)
390}
391
392pub fn sample_beta(a: u32, b: u32, seed: u64) -> f64 {
395 let scale = (a.max(b) as f64 / 1000.0).max(1.0);
396 let (a, b) = (((a as f64 / scale).round() as u32).max(1), ((b as f64 / scale).round() as u32).max(1));
397 let mut rng = Rng::new(seed);
398 let mut gamma = |k: u32| (0..k).map(|_| -rng.unit().ln()).sum::<f64>();
399 let x = gamma(a);
400 let y = gamma(b);
401 x / (x + y)
402}
403
404pub fn shuffle<T>(items: &mut [T], session: &str) {
407 let mut rng = Rng::new(seed(session, "#order"));
408 for i in (1..items.len()).rev() {
409 let j = (rng.next_u64() % (i as u64 + 1)) as usize;
410 items.swap(i, j);
411 }
412}
413
414fn distinctive(all: &[Challenge]) -> impl Fn(&str) -> HashSet<String> + '_ {
417 let mut df: HashMap<String, usize> = HashMap::new();
418 for c in all {
419 for t in crate::conscience::tokens(&c.question).into_iter().collect::<HashSet<_>>() {
420 *df.entry(t).or_default() += 1;
421 }
422 }
423 let n = all.len().max(1);
424 move |text: &str| crate::conscience::tokens(text).into_iter().filter(|t| df.get(t).is_some_and(|d| 3 * d <= n)).collect()
425}
426
427pub const NOVEL_WORDS: usize = 3;
430
431pub fn resolve(asked: &[&Challenge], all: &[Challenge], before: &str, after: &str, final_msg: &str, min_new: usize) -> (Vec<String>, Vec<String>) {
444 let words = distinctive(all);
445 let had: HashSet<String> = crate::conscience::tokens(before).into_iter().collect();
446 let new: HashSet<String> = words(&format!("{after}\n{final_msg}")).into_iter().filter(|w| !had.contains(w)).collect();
447 let lower = final_msg.to_lowercase();
448 let (mut changed, mut ignored) = (Vec::new(), Vec::new());
449 for c in asked {
450 let verdict = declared(&lower, &c.id.to_lowercase());
451 let applies = match verdict {
452 Some(v) => v,
453 None => words(&c.question).intersection(&new).count() >= min_new,
454 };
455 match applies {
456 true => changed.push(c.id.clone()),
457 false => ignored.push(c.id.clone()),
458 }
459 }
460 (changed, ignored)
461}
462
463pub fn declared(lower: &str, id: &str) -> Option<bool> {
466 let mut out = None;
467 let mut from = 0;
468 while let Some(i) = lower[from..].find(id) {
469 let at = from + i + id.len();
470 let rest: String = lower[at..].chars().take(16).collect();
471 let rest = rest.trim_start_matches(|c: char| c == ']' || c == '`' || c == '*' || c == ':' || c == '-' || c == '–' || c.is_whitespace());
472 if rest.starts_with("applies") || rest.starts_with("apply") {
473 out = Some(true);
474 } else if rest.starts_with("skip") {
475 out = Some(false);
476 }
477 from = at;
478 }
479 out
480}
481
482pub fn strip_declaration(text: &str, id: &str) -> String {
486 let (lower, id) = (text.to_ascii_lowercase(), id.to_ascii_lowercase());
488 if id.is_empty() {
489 return text.to_string();
490 }
491 let mut out = String::with_capacity(text.len());
492 let (mut from, mut kept) = (0, 0);
493 while let Some(i) = lower[from..].find(&id) {
494 let start = from + i;
495 let at = start + id.len();
496 let gap = lower[at..].len()
497 - lower[at..].trim_start_matches(|c: char| c == ']' || c == '`' || c == '*' || c == ':' || c == '-' || c == '–' || c.is_whitespace()).len();
498 let rest = &lower[at + gap..];
499 let word = ["applies", "apply", "skip"].iter().find(|w| rest.starts_with(**w)).map_or(0, |w| w.len());
500 let opens = start == 0 || !lower[..start].ends_with(|c: char| c.is_ascii_alphanumeric());
502 if word > 0 && opens && gap > 0 {
503 out.push_str(&text[kept..start]);
504 kept = at + gap + word;
505 }
506 from = at;
507 }
508 out.push_str(&text[kept..]);
509 out
510}
511
512#[cfg(test)]
513mod tests {
514 use super::*;
515 use crate::outcome::{CHANGED, CORRECTED, IGNORED, NOT_CORRECTED};
516
517 #[test]
518 fn a_stripped_declaration_is_gone_for_the_judge_and_the_rest_stays() {
519 for said in ["Mdrain: applies. Replaced the reset arc.", "- [Mdrain] - skip", "`Mdrain`: APPLY", "ok mdrain:skip then"] {
520 let s = strip_declaration(said, "Mdrain");
521 assert_eq!(declared(&s.to_lowercase(), "mdrain"), None, "{said} -> {s}");
522 assert!(declared(&said.to_lowercase(), "mdrain").is_some(), "{said}");
523 }
524 let both = "Mdrain: applies. New: skip. Mdrain is the budget lesson.";
525 let s = strip_declaration(both, "Mdrain");
526 assert_eq!(declared(&s.to_lowercase(), "new"), Some(false), "{s}");
527 assert!(s.contains("Replaced") || s.contains("Mdrain is the budget lesson"), "{s}");
528 assert_eq!(strip_declaration("no declaration here", "Mdrain"), "no declaration here");
529 assert_eq!(strip_declaration("XMdrain: applies", "Mdrain"), "XMdrain: applies", "another id ending in it");
530 }
531
532 fn o(kind: &str, session: &str, lesson: &str, ms: i64, repo: &str) -> Outcome {
533 Outcome { kind: kind.into(), session: session.into(), lessons: vec![lesson.into()], ms, repo: repo.into(), ..Default::default() }
534 }
535
536 fn lesson(id: &str, ms: i64) -> Challenge {
537 Challenge { id: id.into(), origin_ms: ms, question: format!("question of {id}"), ..Default::default() }
538 }
539
540 fn sessions(n: usize) -> Vec<Outcome> {
542 (0..n).map(|i| o(outcome::ASKED, &format!("s{i:03}"), "other", 100 * (i as i64 + 1), "r")).collect()
543 }
544
545 #[test]
546 fn a_new_lesson_is_neutral_and_activation_decays_with_the_session_clock_not_wall_time() {
547 let l = lesson("L", 0);
548 let m = &derive(&[], std::slice::from_ref(&l), None)["L"];
549 assert_eq!((m.activation, m.mean(), m.weight("L", None)), (0.0, 0.5, 1.0));
550 let m = &derive(&sessions(10), std::slice::from_ref(&l), None)["L"];
552 assert!((m.activation - (11f64).powf(-0.5).ln()).abs() < 1e-12);
553 assert!(m.weight("L", None) < 1.0 && m.weight("L", None) > 0.5);
554 let spread: Vec<Outcome> = sessions(10)
556 .into_iter()
557 .enumerate()
558 .map(|(i, mut x)| {
559 x.ms = if i < 5 { x.ms } else { x.ms + 365 * 86_400_000 };
560 x
561 })
562 .collect();
563 assert_eq!(derive(&spread, &[l], None)["L"].activation, m.activation);
564 }
565
566 #[test]
567 fn a_repo_convention_ages_only_with_the_sessions_of_its_repo_and_a_team_lesson_with_all() {
568 let conv = Challenge { reach: crate::reach::REPO.into(), repo: "a".into(), ..lesson("C", 0) };
569 let both = [lesson("T", 0), conv];
570 let in_repo = |log: Vec<Outcome>, repo: &dyn Fn(usize) -> String| -> Vec<Outcome> {
571 log.into_iter()
572 .enumerate()
573 .map(|(i, mut x)| {
574 x.repo = repo(i);
575 x
576 })
577 .collect()
578 };
579 let m = derive(&in_repo(sessions(60), &|_| "b".into()), &both, None);
581 assert!(m["T"].dormant);
582 assert_eq!((m["C"].activation, m["C"].dormant), (0.0, false));
583 for repo in ["a".to_string(), "A".to_string(), outcome::tag("a")] {
585 let m = derive(&in_repo(sessions(60), &|_| repo.clone()), &both, None);
586 assert_eq!(m["C"].activation, m["T"].activation, "{repo}");
587 assert!(m["C"].dormant, "{repo}");
588 }
589 let mut log = in_repo(sessions(40), &|i| if i % 4 == 0 { "a".into() } else { "b".into() });
592 log.push(o(CHANGED, "s004", "C", 501, "a"));
593 let m = derive(&log, &both, None);
594 let want = activation([11, 10], D);
595 assert!((m["C"].activation - want).abs() < 1e-12, "{} vs {want}", m["C"].activation);
596 }
597
598 #[test]
599 fn activation_is_monotone_in_uses_and_spaced_uses_outlast_massed_ones() {
600 let l = lesson("L", 0);
601 let mut log = sessions(30);
602 let a0 = derive(&log, std::slice::from_ref(&l), None)["L"].activation;
603 log.push(o(CHANGED, "s010", "L", 1101, "r"));
604 let a1 = derive(&log, std::slice::from_ref(&l), None)["L"].activation;
605 log.push(o(CHANGED, "s020", "L", 2101, "r"));
606 let a2 = derive(&log, std::slice::from_ref(&l), None)["L"].activation;
607 assert!(a0 < a1 && a1 < a2, "{a0} {a1} {a2}");
608 let at = |uses: &[usize]| {
611 let mut log = sessions(60);
612 log.extend(uses.iter().map(|i| o(CHANGED, &format!("s{i:03}"), "L", 100 * (*i as i64 + 1) + 1, "r")));
613 derive(&log, std::slice::from_ref(&l), None)["L"].activation
614 };
615 assert!(at(&[1, 20]) > at(&[1, 2]));
616 }
617
618 #[test]
619 fn belief_counts_each_session_once_corrected_over_changed_over_ignored() {
620 let l = lesson("L", 0);
621 let log = vec![
622 o(CHANGED, "a", "L", 1, "r"),
623 o(NOT_CORRECTED, "a", "L", 2, "r"), o(IGNORED, "b", "L", 3, "r"),
625 o(CHANGED, "c", "L", 4, "r"),
626 o(CORRECTED, "c", "L", 5, "r"), o(NOT_CORRECTED, "d", "L", 6, "r"), ];
629 let m = &derive(&log, &[l], None)["L"];
630 assert_eq!((m.changed, m.ignored, m.corrected), (1, 1, 1));
631 assert_eq!((m.alpha(), m.beta()), (2.0, 4.0));
632 }
633
634 #[test]
635 fn an_unjudged_settle_is_censored_and_a_later_verdict_counts_once() {
636 let l = lesson("L", 0);
637 let mem = |log: &[Outcome]| derive(log, std::slice::from_ref(&l), None)["L"].clone();
638 let asked = Outcome { check: "c1".into(), ..o(outcome::ASKED, "a", "L", 1, "r") };
639 let censored = Outcome { check: "c1".into(), reason: outcome::UNJUDGED_NO_CALL.into(), ..o(NOT_CORRECTED, "a", "L", 5, "r") };
640 let alone = mem(std::slice::from_ref(&asked));
641 assert_eq!(mem(&[asked.clone(), censored.clone()]), alone, "no belief effect: not an ignore");
642 for (kind, want) in [(CHANGED, (1, 0)), (IGNORED, (0, 1))] {
644 let later = Outcome { check: "c1".into(), ..o(kind, "a", "L", 9, "r") };
645 let m = mem(&[asked.clone(), censored.clone(), later]);
646 assert_eq!((m.changed, m.ignored), want, "{kind}");
647 }
648 }
649
650 #[test]
651 fn a_placebo_or_unclear_result_moves_no_belief() {
652 let l = lesson("L", 0);
653 let mem = |log: &[Outcome]| derive(log, std::slice::from_ref(&l), None)["L"].clone();
654 let asked = o(outcome::ASKED, "a", "L", 1, "r");
655 let alone = mem(std::slice::from_ref(&asked));
656 for kind in [CHANGED, IGNORED, CORRECTED, outcome::UNCLEAR] {
657 let placebo = Outcome { channel: outcome::PLACEBO.into(), ..o(kind, "b", "L", 5, "r") };
658 assert_eq!(mem(&[asked.clone(), placebo]), alone, "placebo {kind}");
659 }
660 assert_eq!(mem(&[asked.clone(), Outcome { channel: "stop".into(), ..o(outcome::UNCLEAR, "a", "L", 5, "r") }]), alone);
661 }
662
663 #[test]
664 fn one_sessions_regrets_blame_a_lesson_at_most_its_corrections() {
665 let l = lesson("L", 0);
666 let mem = |log: &[Outcome]| derive(log, std::slice::from_ref(&l), None)["L"].clone();
667 let asked = o(outcome::ASKED, "a", "L", 1, "r");
668 let mut log = vec![asked.clone()];
670 log.extend((0..5).map(|i| regret("a", "L", 10 + i, &format!("R{i}"), 0.5)));
671 assert_eq!(mem(&log).regretted, regret_cap(0));
672 assert_eq!(regret_cap(0), 1.0);
673 let mut twice = log.clone();
675 twice.extend([o(CORRECTED, "a", "X", 3, "r"), o(outcome::VETOED, "a", "Y", 4, "r"), o(CORRECTED, "a", "Z", 4, "r")]);
676 assert_eq!(mem(&twice).regretted, 2.0);
677 let mut other = log.clone();
679 other.extend([o(outcome::ASKED, "b", "L", 2, "r"), regret("b", "L", 20, "Rb", 0.5)]);
680 assert_eq!(mem(&other).regretted, 1.5);
681 }
682
683 #[test]
684 fn the_misses_a_regret_writes_leave_its_sessions_cap_unchanged() {
685 let l = lesson("L", 0);
686 let mem = |log: &[Outcome]| derive(log, std::slice::from_ref(&l), None)["L"].clone();
687 let mut log = vec![o(outcome::ASKED, "a", "L", 1, "r")];
691 for i in 0..5 {
692 log.push(regret("a", "L", 10 + i, &format!("R{i}"), 0.5));
693 log.push(Outcome { cause: format!("R{i}"), ..o(outcome::MISSED, "a", &format!("M{i}"), 10 + i, "r") });
694 }
695 assert_eq!(mem(&log).regretted, regret_cap(0), "the regrets' own misses are no corrections");
696 let tagged: Vec<Outcome> =
698 log.iter().map(|x| Outcome { cause: if x.cause.is_empty() { String::new() } else { outcome::tag(&x.cause) }, ..x.clone() }).collect();
699 assert_eq!(mem(&tagged).regretted, regret_cap(0));
700 let mut corrected = log.clone();
702 corrected.extend([o(outcome::MISSED, "a", "X", 3, "r"), o(outcome::MISSED, "a", "Y", 4, "r")]);
703 assert_eq!(mem(&corrected).regretted, 2.0);
704 }
705
706 #[test]
707 fn a_veto_outweighs_a_correction_and_a_change_in_its_session() {
708 let l = lesson("L", 0);
709 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")];
710 let m = &derive(&log, std::slice::from_ref(&l), None)["L"];
711 assert_eq!((m.changed, m.ignored, m.corrected, m.vetoed), (0, 0, 1, 1));
712 assert_eq!((m.alpha(), m.beta()), (1.0, (1 + 2 + 3) as f64));
713 let without = derive(&log[1..], std::slice::from_ref(&l), None)["L"].activation;
715 assert!(m.activation > without, "{} {without}", m.activation);
716 let only = |kind: &str| derive(&[o(kind, "a", "L", 1, "r")], std::slice::from_ref(&l), None)["L"].mean();
718 assert!(only(outcome::VETOED) < only(CORRECTED), "{} {}", only(outcome::VETOED), only(CORRECTED));
719 assert_eq!(only(outcome::VETOED), 1.0 / 5.0);
720 }
721
722 fn regret(session: &str, lesson: &str, ms: i64, cause: &str, credit: f64) -> Outcome {
723 Outcome { cause: cause.into(), credit, ..o(outcome::REGRETTED, session, lesson, ms, "r") }
724 }
725
726 #[test]
727 fn a_regret_lowers_belief_less_than_a_correction() {
728 let l = lesson("L", 0);
729 let mem = |log: &[Outcome]| derive(log, std::slice::from_ref(&l), None)["L"].clone();
730 let asked = o(outcome::ASKED, "a", "L", 1, "r");
731 let full = mem(&[asked.clone(), regret("a", "L", 9, "R1", 1.0)]);
732 assert_eq!((full.regretted, full.beta(), full.mean()), (1.0, 2.0, 1.0 / 3.0));
733 assert_eq!(mem(&[asked.clone(), o(CORRECTED, "a", "L", 9, "r")]).mean(), 1.0 / 4.0);
734 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)];
736 assert_eq!(mem(&log).regretted, 0.75);
737 let mut reversed = log.clone();
738 reversed.reverse();
739 assert_eq!(mem(&reversed), mem(&log));
740 let changed = mem(&[asked.clone(), o(CHANGED, "a", "L", 5, "r"), regret("a", "L", 9, "R1", 0.5)]);
742 assert_eq!((changed.changed, changed.regretted), (1, 0.5));
743 assert_eq!(mem(&[asked.clone(), regret("a", "L", 9, "R1", 1.0)]).activation, mem(std::slice::from_ref(&asked)).activation);
744 for kind in [CORRECTED, outcome::VETOED] {
746 let late = mem(&[asked.clone(), regret("a", "L", 9, "R1", 0.5), o(kind, "a", "L", 12, "r")]);
747 assert_eq!(late.regretted, 0.0, "{kind}");
748 assert_eq!(late.beta(), mem(&[asked.clone(), o(kind, "a", "L", 12, "r")]).beta(), "{kind}");
749 }
750 let other = mem(&[asked.clone(), o(CORRECTED, "b", "L", 3, "r"), regret("a", "L", 9, "R1", 0.5)]);
751 assert_eq!(other.regretted, 0.5, "another session's correction leaves the regret");
752 }
753
754 #[test]
755 fn an_older_stores_hive_verdicts_change_no_belief_and_no_tick() {
756 let l = lesson("L", 0);
757 let mem = |log: &[Outcome]| derive(log, std::slice::from_ref(&l), None)["L"].clone();
758 let asked = o(outcome::ASKED, "a", "L", 1, "r");
759 for kind in ["verified", "rejected", "hacked"] {
760 let old = Outcome { cause: "g/t1.v1".into(), ..o(kind, "a", "L", 9, "r") };
761 assert_eq!(mem(&[asked.clone(), old.clone()]), mem(std::slice::from_ref(&asked)), "{kind}");
762 assert_eq!(Clock::of(&[old]).now(None), 0, "{kind}");
763 }
764 }
765
766 #[test]
767 fn a_green_test_run_weighs_half_a_change_and_never_ticks() {
768 let l = lesson("L", 0);
769 let mem = |log: &[Outcome]| derive(log, std::slice::from_ref(&l), None)["L"].clone();
770 let asked = o(outcome::ASKED, "a", "L", 1, "r");
771 let t = mem(&[asked.clone(), o(outcome::TESTED, "a", "L", 9, "r"), o(outcome::TESTED, "a", "L", 10, "r")]);
772 assert_eq!((t.tested, t.alpha(), t.changed), (1, 1.0 + TESTED_W, 0), "once per session, never a use");
773 assert_eq!(t.activation, mem(std::slice::from_ref(&asked)).activation);
774 assert_eq!(Clock::of(&[o(outcome::TESTED, "z", "L", 9, "r")]).now(None), 0, "no tick");
775 for k in [outcome::CORRECTED, outcome::VETOED] {
777 let log = [o(outcome::TESTED, "a", "L", 9, "r"), asked.clone(), o(k, "a", "L", 5, "r")];
778 assert_eq!(mem(&log).tested, 0, "{k}");
779 }
780 assert_eq!(Weights { tested: 2.0, ..WEIGHTS }.tested, 2.0);
781 assert_eq!(t.alpha_with(&Weights { tested: 2.0, ..WEIGHTS }), 3.0);
782 }
783
784 #[test]
785 fn fractional_beta_sampling_is_unbiased_and_integer_beta_unchanged() {
786 let n = 20_000;
787 let mean = (0..n).map(|i| round_seeded(2.5, seed(&format!("s{i}"), "L")) as f64).sum::<f64>() / n as f64;
788 assert!((mean - 2.5).abs() < 0.02, "{mean}");
789 assert!((0..100).all(|i| round_seeded(3.0, i) == 3));
790 let m = Memory { activation: 0.0, changed: 2, ignored: 1, corrected: 1, vetoed: 0, regretted: 0.0, tested: 0, consolidated: false, dormant: false };
792 assert_eq!(m.weight("L", Some("s")), squash(0.0) * (0.5 + sample_beta(3, 4, seed("s", "L"))));
793 assert_eq!(m.weight("L", Some("s")), 1.0618369485259265);
794 }
795
796 #[test]
797 fn fractional_alpha_draws_unbiased_and_integer_unchanged() {
798 assert!((0..200).all(|i| draw(3.0, 4.0, i) == sample_beta(3, 4, i)));
800 assert!((0..200).all(|i| draw(2.0, 2.5, i) == sample_beta(2, round_seeded(2.5, i ^ 0x5265_6772), i)));
801 let n = 20_000;
803 let mean = |a: f64| (0..n).map(|i| draw(a, 1.0, seed(&format!("s{i}"), "L"))).sum::<f64>() / n as f64;
804 let (lo, mid, hi) = (mean(1.0), mean(1.5), mean(2.0));
805 assert!((mid - (lo + hi) / 2.0).abs() < 0.01, "{lo} {mid} {hi}");
806 let m = Memory { tested: 2, ..derive(&[], &[lesson("L", 0)], None)["L"].clone() };
807 let quarter = Weights { tested: 0.25, ..WEIGHTS };
808 assert_eq!((m.alpha_with(&quarter), m.alpha(), m.beta_with(&quarter)), (1.5, 2.0, 1.0));
809 }
810
811 #[test]
812 fn derived_memory_is_independent_of_log_order_so_merging_logs_merges_memory() {
813 let ls = [lesson("L", 150), lesson("M", 0), Challenge { engineers: vec!["a@x".into(), "b@x".into()], ..lesson("N", 0) }];
814 let mut log = sessions(12);
815 for (k, s) in ["s002", "s005", "s009"].iter().enumerate() {
816 log.push(o(CHANGED, s, "N", 300 * k as i64, "r"));
817 log.push(o(IGNORED, s, "M", 300 * k as i64, "r"));
818 }
819 log.push(o(CORRECTED, "s007", "L", 800, "r"));
820 let base = derive(&log, &ls, Some("s011"));
821 let mut rng = Rng::new(7);
822 for _ in 0..20 {
823 let mut shuffled = log.clone();
824 for i in (1..shuffled.len()).rev() {
825 shuffled.swap(i, (rng.next_u64() % (i as u64 + 1)) as usize);
826 }
827 assert_eq!(derive(&shuffled, &ls, Some("s011")), base);
828 }
829 let (a, b) = log.split_at(log.len() / 2);
831 assert_eq!(derive(&[b, a].concat(), &ls, Some("s011")), base);
832 assert!(base["N"].consolidated && !base["M"].consolidated);
833 }
834
835 #[test]
836 fn consolidation_needs_spread_uses_and_dormancy_waits_for_a_long_silence() {
837 let l = lesson("L", 0);
838 let mut log = sessions(80);
839 for i in [1, 2, 3] {
840 log.push(o(CHANGED, &format!("s{i:03}"), "L", 100 * (i + 1) + 1, "one-repo"));
841 }
842 let m = &derive(&log, std::slice::from_ref(&l), None)["L"];
843 assert!(!m.consolidated, "one repo, one engineer");
844 log.push(o(CHANGED, "s004", "L", 501, "other-repo"));
845 let m = &derive(&log, std::slice::from_ref(&l), None)["L"];
846 assert!(m.consolidated && !m.dormant);
847 let fresh = lesson("F", 0);
849 assert!(!derive(&sessions(40), std::slice::from_ref(&fresh), None)["F"].dormant);
850 let mut quiet = sessions(80);
851 assert!(derive(&quiet, std::slice::from_ref(&fresh), None)["F"].dormant);
852 quiet.push(o(CHANGED, "s079", "F", 8001, "r"));
853 assert!(!derive(&quiet, &[fresh], None)["F"].dormant, "one use reactivates");
854 }
855
856 #[test]
857 fn thompson_samples_are_stable_per_session_spread_when_uncertain_and_tight_when_proven() {
858 assert_eq!(sample_beta(3, 4, seed("s", "L")), sample_beta(3, 4, seed("s", "L")));
859 assert_ne!(sample_beta(3, 4, seed("s", "L")), sample_beta(3, 4, seed("t", "L")));
860 let draws = |a, b| (0..400).map(|i| sample_beta(a, b, seed(&format!("s{i}"), "L"))).collect::<Vec<f64>>();
861 let mean = |v: &[f64]| v.iter().sum::<f64>() / v.len() as f64;
862 let sd = |v: &[f64]| (v.iter().map(|x| (x - mean(v)).powi(2)).sum::<f64>() / v.len() as f64).sqrt();
863 let (new, proven, harmful) = (draws(1, 1), draws(21, 3), draws(1, 21));
864 assert!((mean(&new) - 0.5).abs() < 0.05 && sd(&new) > 0.25, "uniform: {} {}", mean(&new), sd(&new));
865 assert!(mean(&proven) > 0.8 && sd(&proven) < 0.1);
866 assert!(mean(&harmful) < 0.1);
867 assert!(new.iter().chain(&proven).all(|x| *x > 0.0 && *x < 1.0));
868 let m = Memory { activation: 0.0, changed: 20, ignored: 2, corrected: 0, vetoed: 0, regretted: 0.0, tested: 0, consolidated: false, dormant: false };
869 assert!(m.weight("L", Some("s")) > 1.2 && m.weight("L", Some("s")) < 1.5);
870 }
871
872 #[test]
873 fn resolve_reads_declarations_first_then_edits_on_the_lessons_point() {
874 let mk = |id: &str, q: &str| Challenge { id: id.into(), question: q.into(), ..Default::default() };
875 let all = vec![
876 mk("Idrain", "Is the per-turn hint place drained by an explicit consumer?"),
877 mk("Ijudge", "Is the replay judge exactly the named Vertex model?"),
878 mk("Ihost", "Does the replay run on live hosts?"),
879 mk("x1", "Is the plan complete?"),
880 mk("x2", "Is the plan tested?"),
881 mk("x3", "Is the plan small?"),
882 ];
883 let asked: Vec<&Challenge> = all[..3].iter().collect();
884 let fin = "Revised.\n`Ijudge`: applies — switched to Vertex.\n- Ihost: skip (offline task)\n";
885 let before = "Add a per-turn hint budget to TextNet; plan: reset arc on the starting transition";
886 let edits = "src/TextNet.java drain the hint place with an explicit consumer transition";
887 let (changed, ignored) = resolve(&asked, &all, before, edits, fin, NOVEL_WORDS);
888 assert_eq!((changed, ignored), (vec!["Idrain".to_string(), "Ijudge".to_string()], vec!["Ihost".to_string()]));
889 assert_eq!(resolve(&asked, &all, before, "", "Done.", NOVEL_WORDS).0, Vec::<String>::new());
891 assert!(resolve(&asked[..1], &all, before, "renamed the hint label, drained", "", NOVEL_WORDS).0.is_empty());
893 assert!(resolve(&asked[..1], &all, "the hint place is drained by an explicit consumer", edits, "", NOVEL_WORDS).0.is_empty());
894 }
895
896 #[test]
897 fn shuffle_is_a_stable_permutation_per_session() {
898 let mut a: Vec<u32> = (0..8).collect();
899 let mut b = a.clone();
900 shuffle(&mut a, "s1");
901 shuffle(&mut b, "s1");
902 assert_eq!(a, b);
903 let mut sorted = a.clone();
904 sorted.sort();
905 assert_eq!(sorted, (0..8).collect::<Vec<_>>());
906 let firsts: HashSet<u32> = (0..40)
907 .map(|i| {
908 let mut v: Vec<u32> = (0..8).collect();
909 shuffle(&mut v, &format!("s{i}"));
910 v[0]
911 })
912 .collect();
913 assert!(firsts.len() >= 5, "{firsts:?}");
914 }
915}