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