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| outcome::SHARED_KINDS.contains(&o.kind.as_str()) && o.kind != outcome::TESTED && 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 let mut first: Vec<(i64, String)> = first.into_iter().map(|(s, ms)| (ms, s.to_string())).collect();
136 first.sort();
137 let at = first.iter().enumerate().map(|(i, (_, s))| (s.clone(), i as u64)).collect();
138 let repos = first.iter().map(|(_, s)| repos.remove(s.as_str()).unwrap_or_default()).collect();
139 Clock { first, at, repos }
140 }
141
142 pub fn eligible(&self, c: &Challenge) -> Option<Vec<u64>> {
146 if c.reach != crate::reach::REPO || c.repo.is_empty() {
147 return None;
148 }
149 let tagged = outcome::tag(&c.repo);
150 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())
151 }
152
153 pub fn now(&self, session: Option<&str>) -> u64 {
156 session.and_then(|s| self.at.get(s).copied()).unwrap_or(self.first.len() as u64)
157 }
158
159 pub fn of_session(&self, session: &str) -> u64 {
161 self.at.get(session).copied().unwrap_or(self.first.len() as u64)
162 }
163
164 pub fn at_ms(&self, ms: i64) -> u64 {
166 self.first.partition_point(|(m, _)| *m < ms) as u64
167 }
168}
169
170pub fn activation(ages: impl IntoIterator<Item = u64>, d: f64) -> f64 {
172 ages.into_iter().map(|a| (a.max(1) as f64).powf(-d)).sum::<f64>().ln()
173}
174
175pub fn squash(a: f64) -> f64 {
177 2.0 / (1.0 + (-a / 2.0).exp())
178}
179
180#[derive(Debug, Clone, PartialEq)]
182pub struct Memory {
183 pub activation: f64,
184 pub changed: u32,
186 pub ignored: u32,
187 pub corrected: u32,
188 pub vetoed: u32,
189 pub regretted: f64,
191 pub tested: u32,
193 pub consolidated: bool,
194 pub dormant: bool,
195}
196
197impl Memory {
198 pub fn alpha_with(&self, w: &Weights) -> f64 {
200 1.0 + self.changed as f64 + w.tested * self.tested as f64
201 }
202
203 pub fn beta_with(&self, w: &Weights) -> f64 {
205 (1 + self.ignored + outcome::CORRECTED_W * self.corrected) as f64 + w.vetoed * self.vetoed as f64 + w.regret * self.regretted
206 }
207
208 pub fn alpha(&self) -> f64 {
209 self.alpha_with(&WEIGHTS)
210 }
211
212 pub fn beta(&self) -> f64 {
213 self.beta_with(&WEIGHTS)
214 }
215
216 pub fn mean(&self) -> f64 {
217 self.alpha() / (self.alpha() + self.beta())
218 }
219
220 pub fn weight(&self, id: &str, session: Option<&str>) -> f64 {
222 let x = match session {
223 Some(s) => draw(self.alpha(), self.beta(), seed(s, id)),
224 None => self.mean(),
225 };
226 squash(self.activation) * (0.5 + x)
227 }
228}
229
230pub fn derive(outcomes: &[Outcome], lessons: &[Challenge], session: Option<&str>) -> HashMap<String, Memory> {
232 let clock = Clock::of(outcomes);
233 let now = clock.now(session);
234 let mut result: HashMap<&str, HashMap<&str, u8>> = HashMap::new();
236 let mut uses: HashMap<&str, HashSet<&str>> = HashMap::new();
237 let mut repos: HashMap<&str, HashSet<&str>> = HashMap::new();
238 let mut regret: HashMap<(&str, &str), (f64, &str)> = HashMap::new();
241 let mut tested: HashMap<&str, HashSet<&str>> = HashMap::new();
243 let mut corrections: HashMap<&str, HashSet<i64>> = HashMap::new();
245 for o in outcomes.iter().filter(|o| outcome::is_result(o)) {
247 if is_correction(o) {
248 corrections.entry(o.session.as_str()).or_default().insert(o.ms);
249 }
250 if o.kind == outcome::TESTED {
251 for l in &o.lessons {
252 tested.entry(l.as_str()).or_default().insert(o.session.as_str());
253 }
254 continue;
255 }
256 if o.kind == outcome::REGRETTED {
257 for l in &o.lessons {
258 let e = regret.entry((l.as_str(), o.cause.as_str())).or_insert((0.0, o.session.as_str()));
259 e.0 = e.0.max(o.credit.clamp(0.0, 1.0));
260 }
261 continue;
262 }
263 let r = match o.kind.as_str() {
264 outcome::VETOED => 3,
265 outcome::CORRECTED => 2,
266 outcome::CHANGED => 1,
267 outcome::IGNORED => 0,
268 outcome::NOT_CORRECTED => continue,
272 outcome::NOT_ARISEN | outcome::UNCLEAR | outcome::REMAINS => continue,
275 _ => continue,
276 };
277 for l in &o.lessons {
278 let e = result.entry(l.as_str()).or_default().entry(o.session.as_str()).or_insert(r);
279 *e = (*e).max(r);
280 if r == 1 {
281 uses.entry(l.as_str()).or_default().insert(o.session.as_str());
282 repos.entry(l.as_str()).or_default().insert(o.repo.as_str());
283 }
284 }
285 }
286 let mut blamed: HashMap<(&str, &str), f64> = HashMap::new();
290 for ((l, _), (credit, s)) in regret {
291 if result.get(l).and_then(|m| m.get(s)).is_none_or(|r| *r < 2) {
292 *blamed.entry((l, s)).or_default() += credit;
293 }
294 }
295 let mut regretted: HashMap<&str, f64> = HashMap::new();
296 for ((l, s), credit) in blamed {
297 *regretted.entry(l).or_default() += credit.min(regret_cap(corrections.get(s).map_or(0, HashSet::len)));
298 }
299 let mut eligible: HashMap<(&str, &str), Option<Vec<u64>>> = HashMap::new();
301 lessons
302 .iter()
303 .map(|c| {
304 let per = result.get(c.id.as_str());
305 let count = |k: u8| per.map_or(0, |m| m.values().filter(|r| **r == k).count() as u32);
306 let (changed, ignored, corrected, vetoed) = (count(1), count(0), count(2), count(3));
307 let used: Vec<u64> = uses.get(c.id.as_str()).map(|u| u.iter().map(|s| clock.of_session(s)).collect()).unwrap_or_default();
310 let changed_sessions = used.len();
311 let consolidated = changed_sessions >= CONSOLIDATE_SESSIONS && (repos.get(c.id.as_str()).is_some_and(|r| r.len() >= 2) || c.engineers.len() >= 2);
312 let d = if consolidated { D_CONSOLIDATED } else { D };
313 let born = clock.at_ms(c.origin_ms);
314 let eligible = eligible.entry((c.reach.as_str(), c.repo.as_str())).or_insert_with(|| clock.eligible(c));
316 let rank = |t: u64| eligible.as_ref().map_or(t, |e| e.partition_point(|x| *x < t) as u64);
317 let now = rank(now);
318 let ages = used.into_iter().chain([born]).map(|t| now.saturating_sub(rank(t)) + 1);
319 let a = activation(ages, d);
320 let regretted = regretted.get(c.id.as_str()).copied().unwrap_or(0.0);
321 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);
323 let m = Memory { activation: a, changed, ignored, corrected, vetoed, regretted, tested, consolidated, dormant: !consolidated && a < DORMANT_BELOW };
324 (c.id.clone(), m)
325 })
326 .collect()
327}
328
329pub fn regret_cap(corrections: usize) -> f64 {
335 corrections.max(1) as f64
336}
337
338fn is_correction(o: &Outcome) -> bool {
343 match o.kind.as_str() {
344 outcome::CORRECTED | outcome::VETOED => true,
345 outcome::MISSED => o.cause.is_empty(),
346 _ => false,
347 }
348}
349
350pub fn seed(session: &str, lesson: &str) -> u64 {
352 let mut h: u64 = 0xcbf29ce484222325;
353 for b in session.bytes().chain([0]).chain(lesson.bytes()) {
354 h ^= b as u64;
355 h = h.wrapping_mul(0x100000001b3);
356 }
357 h
358}
359
360pub struct Rng(u64);
362
363impl Rng {
364 pub fn new(seed: u64) -> Self {
365 Rng(seed)
366 }
367
368 pub fn next_u64(&mut self) -> u64 {
369 self.0 = self.0.wrapping_add(0x9e3779b97f4a7c15);
370 let mut z = self.0;
371 z = (z ^ (z >> 30)).wrapping_mul(0xbf58476d1ce4e5b9);
372 z = (z ^ (z >> 27)).wrapping_mul(0x94d049bb133111eb);
373 z ^ (z >> 31)
374 }
375
376 pub fn unit(&mut self) -> f64 {
378 ((self.next_u64() >> 11) as f64 + 0.5) / (1u64 << 53) as f64
379 }
380}
381
382pub fn round_seeded(x: f64, seed: u64) -> u32 {
385 let (whole, frac) = (x.floor(), x - x.floor());
386 match frac == 0.0 || Rng::new(seed).unit() >= frac {
387 true => whole as u32,
388 false => whole as u32 + 1,
389 }
390}
391
392pub fn draw(alpha: f64, beta: f64, seed: u64) -> f64 {
395 sample_beta(round_seeded(alpha, seed ^ 0x416c_7068), round_seeded(beta, seed ^ 0x5265_6772), seed)
396}
397
398pub fn sample_beta(a: u32, b: u32, seed: u64) -> f64 {
401 let scale = (a.max(b) as f64 / 1000.0).max(1.0);
402 let (a, b) = (((a as f64 / scale).round() as u32).max(1), ((b as f64 / scale).round() as u32).max(1));
403 let mut rng = Rng::new(seed);
404 let mut gamma = |k: u32| (0..k).map(|_| -rng.unit().ln()).sum::<f64>();
405 let x = gamma(a);
406 let y = gamma(b);
407 x / (x + y)
408}
409
410pub fn shuffle<T>(items: &mut [T], session: &str) {
413 let mut rng = Rng::new(seed(session, "#order"));
414 for i in (1..items.len()).rev() {
415 let j = (rng.next_u64() % (i as u64 + 1)) as usize;
416 items.swap(i, j);
417 }
418}
419
420fn distinctive(all: &[Challenge]) -> impl Fn(&str) -> HashSet<String> + '_ {
423 let mut df: HashMap<String, usize> = HashMap::new();
424 for c in all {
425 for t in crate::conscience::tokens(&c.question).into_iter().collect::<HashSet<_>>() {
426 *df.entry(t).or_default() += 1;
427 }
428 }
429 let n = all.len().max(1);
430 move |text: &str| crate::conscience::tokens(text).into_iter().filter(|t| df.get(t).is_some_and(|d| 3 * d <= n)).collect()
431}
432
433pub const NOVEL_WORDS: usize = 3;
436
437pub fn resolve(asked: &[&Challenge], all: &[Challenge], before: &str, after: &str, final_msg: &str, min_new: usize) -> (Vec<String>, Vec<String>) {
450 let words = distinctive(all);
451 let had: HashSet<String> = crate::conscience::tokens(before).into_iter().collect();
452 let new: HashSet<String> = words(&format!("{after}\n{final_msg}")).into_iter().filter(|w| !had.contains(w)).collect();
453 let lower = final_msg.to_lowercase();
454 let (mut changed, mut ignored) = (Vec::new(), Vec::new());
455 for c in asked {
456 let verdict = declared(&lower, &c.id.to_lowercase());
457 let applies = match verdict {
458 Some(v) => v,
459 None => words(&c.question).intersection(&new).count() >= min_new,
460 };
461 match applies {
462 true => changed.push(c.id.clone()),
463 false => ignored.push(c.id.clone()),
464 }
465 }
466 (changed, ignored)
467}
468
469pub fn declared(lower: &str, id: &str) -> Option<bool> {
472 let mut out = None;
473 let mut from = 0;
474 while let Some(i) = lower[from..].find(id) {
475 let at = from + i + id.len();
476 let rest: String = lower[at..].chars().take(16).collect();
477 let rest = rest.trim_start_matches(|c: char| c == ']' || c == '`' || c == '*' || c == ':' || c == '-' || c == '–' || c.is_whitespace());
478 if rest.starts_with("applies") || rest.starts_with("apply") {
479 out = Some(true);
480 } else if rest.starts_with("skip") {
481 out = Some(false);
482 }
483 from = at;
484 }
485 out
486}
487
488pub fn strip_declaration(text: &str, id: &str) -> String {
492 let (lower, id) = (text.to_ascii_lowercase(), id.to_ascii_lowercase());
494 if id.is_empty() {
495 return text.to_string();
496 }
497 let mut out = String::with_capacity(text.len());
498 let (mut from, mut kept) = (0, 0);
499 while let Some(i) = lower[from..].find(&id) {
500 let start = from + i;
501 let at = start + id.len();
502 let gap = lower[at..].len()
503 - lower[at..].trim_start_matches(|c: char| c == ']' || c == '`' || c == '*' || c == ':' || c == '-' || c == '–' || c.is_whitespace()).len();
504 let rest = &lower[at + gap..];
505 let word = ["applies", "apply", "skip"].iter().find(|w| rest.starts_with(**w)).map_or(0, |w| w.len());
506 let opens = start == 0 || !lower[..start].ends_with(|c: char| c.is_ascii_alphanumeric());
508 if word > 0 && opens && gap > 0 {
509 out.push_str(&text[kept..start]);
510 kept = at + gap + word;
511 }
512 from = at;
513 }
514 out.push_str(&text[kept..]);
515 out
516}
517
518#[cfg(test)]
519mod tests {
520 use super::*;
521 use crate::outcome::{CHANGED, CORRECTED, IGNORED, NOT_CORRECTED};
522
523 #[test]
524 fn a_stripped_declaration_is_gone_for_the_judge_and_the_rest_stays() {
525 for said in ["Mdrain: applies. Replaced the reset arc.", "- [Mdrain] - skip", "`Mdrain`: APPLY", "ok mdrain:skip then"] {
526 let s = strip_declaration(said, "Mdrain");
527 assert_eq!(declared(&s.to_lowercase(), "mdrain"), None, "{said} -> {s}");
528 assert!(declared(&said.to_lowercase(), "mdrain").is_some(), "{said}");
529 }
530 let both = "Mdrain: applies. New: skip. Mdrain is the budget lesson.";
531 let s = strip_declaration(both, "Mdrain");
532 assert_eq!(declared(&s.to_lowercase(), "new"), Some(false), "{s}");
533 assert!(s.contains("Replaced") || s.contains("Mdrain is the budget lesson"), "{s}");
534 assert_eq!(strip_declaration("no declaration here", "Mdrain"), "no declaration here");
535 assert_eq!(strip_declaration("XMdrain: applies", "Mdrain"), "XMdrain: applies", "another id ending in it");
536 }
537
538 fn o(kind: &str, session: &str, lesson: &str, ms: i64, repo: &str) -> Outcome {
539 Outcome { kind: kind.into(), session: session.into(), lessons: vec![lesson.into()], ms, repo: repo.into(), ..Default::default() }
540 }
541
542 fn lesson(id: &str, ms: i64) -> Challenge {
543 Challenge { id: id.into(), origin_ms: ms, question: format!("question of {id}"), ..Default::default() }
544 }
545
546 fn sessions(n: usize) -> Vec<Outcome> {
548 (0..n).map(|i| o(outcome::ASKED, &format!("s{i:03}"), "other", 100 * (i as i64 + 1), "r")).collect()
549 }
550
551 #[test]
552 fn a_new_lesson_is_neutral_and_activation_decays_with_the_session_clock_not_wall_time() {
553 let l = lesson("L", 0);
554 let m = &derive(&[], std::slice::from_ref(&l), None)["L"];
555 assert_eq!((m.activation, m.mean(), m.weight("L", None)), (0.0, 0.5, 1.0));
556 let m = &derive(&sessions(10), std::slice::from_ref(&l), None)["L"];
558 assert!((m.activation - (11f64).powf(-0.5).ln()).abs() < 1e-12);
559 assert!(m.weight("L", None) < 1.0 && m.weight("L", None) > 0.5);
560 let spread: Vec<Outcome> = sessions(10)
562 .into_iter()
563 .enumerate()
564 .map(|(i, mut x)| {
565 x.ms = if i < 5 { x.ms } else { x.ms + 365 * 86_400_000 };
566 x
567 })
568 .collect();
569 assert_eq!(derive(&spread, &[l], None)["L"].activation, m.activation);
570 }
571
572 #[test]
573 fn a_repo_convention_ages_only_with_the_sessions_of_its_repo_and_a_team_lesson_with_all() {
574 let conv = Challenge { reach: crate::reach::REPO.into(), repo: "a".into(), ..lesson("C", 0) };
575 let both = [lesson("T", 0), conv];
576 let in_repo = |log: Vec<Outcome>, repo: &dyn Fn(usize) -> String| -> Vec<Outcome> {
577 log.into_iter()
578 .enumerate()
579 .map(|(i, mut x)| {
580 x.repo = repo(i);
581 x
582 })
583 .collect()
584 };
585 let m = derive(&in_repo(sessions(60), &|_| "b".into()), &both, None);
587 assert!(m["T"].dormant);
588 assert_eq!((m["C"].activation, m["C"].dormant), (0.0, false));
589 for repo in ["a".to_string(), "A".to_string(), outcome::tag("a")] {
591 let m = derive(&in_repo(sessions(60), &|_| repo.clone()), &both, None);
592 assert_eq!(m["C"].activation, m["T"].activation, "{repo}");
593 assert!(m["C"].dormant, "{repo}");
594 }
595 let mut log = in_repo(sessions(40), &|i| if i % 4 == 0 { "a".into() } else { "b".into() });
598 log.push(o(CHANGED, "s004", "C", 501, "a"));
599 let m = derive(&log, &both, None);
600 let want = activation([11, 10], D);
601 assert!((m["C"].activation - want).abs() < 1e-12, "{} vs {want}", m["C"].activation);
602 }
603
604 #[test]
605 fn activation_is_monotone_in_uses_and_spaced_uses_outlast_massed_ones() {
606 let l = lesson("L", 0);
607 let mut log = sessions(30);
608 let a0 = derive(&log, std::slice::from_ref(&l), None)["L"].activation;
609 log.push(o(CHANGED, "s010", "L", 1101, "r"));
610 let a1 = derive(&log, std::slice::from_ref(&l), None)["L"].activation;
611 log.push(o(CHANGED, "s020", "L", 2101, "r"));
612 let a2 = derive(&log, std::slice::from_ref(&l), None)["L"].activation;
613 assert!(a0 < a1 && a1 < a2, "{a0} {a1} {a2}");
614 let at = |uses: &[usize]| {
617 let mut log = sessions(60);
618 log.extend(uses.iter().map(|i| o(CHANGED, &format!("s{i:03}"), "L", 100 * (*i as i64 + 1) + 1, "r")));
619 derive(&log, std::slice::from_ref(&l), None)["L"].activation
620 };
621 assert!(at(&[1, 20]) > at(&[1, 2]));
622 }
623
624 #[test]
625 fn belief_counts_each_session_once_corrected_over_changed_over_ignored() {
626 let l = lesson("L", 0);
627 let log = vec![
628 o(CHANGED, "a", "L", 1, "r"),
629 o(NOT_CORRECTED, "a", "L", 2, "r"), o(IGNORED, "b", "L", 3, "r"),
631 o(CHANGED, "c", "L", 4, "r"),
632 o(CORRECTED, "c", "L", 5, "r"), o(NOT_CORRECTED, "d", "L", 6, "r"), ];
635 let m = &derive(&log, &[l], None)["L"];
636 assert_eq!((m.changed, m.ignored, m.corrected), (1, 1, 1));
637 assert_eq!((m.alpha(), m.beta()), (2.0, 4.0));
638 }
639
640 #[test]
641 fn an_unjudged_settle_is_censored_and_a_later_verdict_counts_once() {
642 let l = lesson("L", 0);
643 let mem = |log: &[Outcome]| derive(log, std::slice::from_ref(&l), None)["L"].clone();
644 let asked = Outcome { check: "c1".into(), ..o(outcome::ASKED, "a", "L", 1, "r") };
645 let censored = Outcome { check: "c1".into(), reason: outcome::UNJUDGED_NO_CALL.into(), ..o(NOT_CORRECTED, "a", "L", 5, "r") };
646 let alone = mem(std::slice::from_ref(&asked));
647 assert_eq!(mem(&[asked.clone(), censored.clone()]), alone, "no belief effect: not an ignore");
648 for (kind, want) in [(CHANGED, (1, 0)), (IGNORED, (0, 1))] {
650 let later = Outcome { check: "c1".into(), ..o(kind, "a", "L", 9, "r") };
651 let m = mem(&[asked.clone(), censored.clone(), later]);
652 assert_eq!((m.changed, m.ignored), want, "{kind}");
653 }
654 }
655
656 #[test]
657 fn a_placebo_or_unclear_result_moves_no_belief() {
658 let l = lesson("L", 0);
659 let mem = |log: &[Outcome]| derive(log, std::slice::from_ref(&l), None)["L"].clone();
660 let asked = o(outcome::ASKED, "a", "L", 1, "r");
661 let alone = mem(std::slice::from_ref(&asked));
662 for kind in [CHANGED, IGNORED, CORRECTED, outcome::UNCLEAR] {
663 let placebo = Outcome { channel: outcome::PLACEBO.into(), ..o(kind, "b", "L", 5, "r") };
664 assert_eq!(mem(&[asked.clone(), placebo]), alone, "placebo {kind}");
665 }
666 assert_eq!(mem(&[asked.clone(), Outcome { channel: "stop".into(), ..o(outcome::UNCLEAR, "a", "L", 5, "r") }]), alone);
667 }
668
669 #[test]
674 fn a_not_arisen_or_remaining_verdict_moves_no_belief() {
675 let l = lesson("L", 0);
676 let mem = |log: &[Outcome]| derive(log, std::slice::from_ref(&l), None)["L"].clone();
677 let asked = Outcome { channel: "stop".into(), ..o(outcome::ASKED, "a", "L", 1, "r") };
678 let alone = mem(std::slice::from_ref(&asked));
679 for kind in [outcome::NOT_ARISEN, outcome::REMAINS] {
680 for channel in ["stop", "live", "queued", outcome::PLACEBO] {
681 let v = Outcome { channel: channel.into(), reasons: vec!["only docs changed".into()], ..o(kind, "a", "L", 5, "r") };
682 assert!(!outcome::is_result(&v), "{kind} {channel}");
683 assert_eq!(mem(&[asked.clone(), v]), alone, "{kind} {channel}");
684 }
685 }
686 let tests = "fail 2\npass 3\n";
688 let na = Outcome { channel: "stop".into(), ..o(outcome::NOT_ARISEN, "a", "L", 5, "r") };
689 assert_eq!(outcome::tested(std::slice::from_ref(&asked), "a", tests).len(), 1, "red then green: credited");
690 assert!(outcome::tested(&[asked.clone(), na.clone()], "a", tests).is_empty());
691 let changed = Outcome { channel: "stop".into(), ..o(CHANGED, "a", "L", 6, "r") };
692 assert_eq!(outcome::tested(&[asked.clone(), na, changed], "a", tests).len(), 1);
693 let late = Outcome { channel: "stop".into(), ..o(outcome::ASKED, "a", "L", 10, "r") };
696 let tests = "fail 11\npass 12\n";
697 for (channel, ms) in [(outcome::PLACEBO, 5), ("queued", 5), ("early", 5), ("stop", 5)] {
698 let before = Outcome { channel: channel.into(), ..o(outcome::NOT_ARISEN, "a", "L", ms, "r") };
699 assert_eq!(outcome::tested(&[before, late.clone()], "a", tests).len(), 1, "{channel} not arisen before the ask");
700 }
701 let after = Outcome { channel: outcome::PLACEBO.into(), ..o(outcome::NOT_ARISEN, "a", "L", 11, "r") };
702 assert_eq!(outcome::tested(&[late.clone(), after], "a", tests).len(), 1, "a placebo's verdict after the ask");
703 let on_it = Outcome { channel: "stop".into(), ..o(outcome::NOT_ARISEN, "a", "L", 11, "r") };
704 assert!(outcome::tested(&[asked, late, on_it], "a", tests).is_empty(), "the question's own verdict");
705 }
706
707 #[test]
708 fn one_sessions_regrets_blame_a_lesson_at_most_its_corrections() {
709 let l = lesson("L", 0);
710 let mem = |log: &[Outcome]| derive(log, std::slice::from_ref(&l), None)["L"].clone();
711 let asked = o(outcome::ASKED, "a", "L", 1, "r");
712 let mut log = vec![asked.clone()];
714 log.extend((0..5).map(|i| regret("a", "L", 10 + i, &format!("R{i}"), 0.5)));
715 assert_eq!(mem(&log).regretted, regret_cap(0));
716 assert_eq!(regret_cap(0), 1.0);
717 let mut twice = log.clone();
719 twice.extend([o(CORRECTED, "a", "X", 3, "r"), o(outcome::VETOED, "a", "Y", 4, "r"), o(CORRECTED, "a", "Z", 4, "r")]);
720 assert_eq!(mem(&twice).regretted, 2.0);
721 let mut other = log.clone();
723 other.extend([o(outcome::ASKED, "b", "L", 2, "r"), regret("b", "L", 20, "Rb", 0.5)]);
724 assert_eq!(mem(&other).regretted, 1.5);
725 }
726
727 #[test]
728 fn the_misses_a_regret_writes_leave_its_sessions_cap_unchanged() {
729 let l = lesson("L", 0);
730 let mem = |log: &[Outcome]| derive(log, std::slice::from_ref(&l), None)["L"].clone();
731 let mut log = vec![o(outcome::ASKED, "a", "L", 1, "r")];
735 for i in 0..5 {
736 log.push(regret("a", "L", 10 + i, &format!("R{i}"), 0.5));
737 log.push(Outcome { cause: format!("R{i}"), ..o(outcome::MISSED, "a", &format!("M{i}"), 10 + i, "r") });
738 }
739 assert_eq!(mem(&log).regretted, regret_cap(0), "the regrets' own misses are no corrections");
740 let tagged: Vec<Outcome> =
742 log.iter().map(|x| Outcome { cause: if x.cause.is_empty() { String::new() } else { outcome::tag(&x.cause) }, ..x.clone() }).collect();
743 assert_eq!(mem(&tagged).regretted, regret_cap(0));
744 let mut corrected = log.clone();
746 corrected.extend([o(outcome::MISSED, "a", "X", 3, "r"), o(outcome::MISSED, "a", "Y", 4, "r")]);
747 assert_eq!(mem(&corrected).regretted, 2.0);
748 }
749
750 #[test]
751 fn a_veto_outweighs_a_correction_and_a_change_in_its_session() {
752 let l = lesson("L", 0);
753 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")];
754 let m = &derive(&log, std::slice::from_ref(&l), None)["L"];
755 assert_eq!((m.changed, m.ignored, m.corrected, m.vetoed), (0, 0, 1, 1));
756 assert_eq!((m.alpha(), m.beta()), (1.0, (1 + 2 + 3) as f64));
757 let without = derive(&log[1..], std::slice::from_ref(&l), None)["L"].activation;
759 assert!(m.activation > without, "{} {without}", m.activation);
760 let only = |kind: &str| derive(&[o(kind, "a", "L", 1, "r")], std::slice::from_ref(&l), None)["L"].mean();
762 assert!(only(outcome::VETOED) < only(CORRECTED), "{} {}", only(outcome::VETOED), only(CORRECTED));
763 assert_eq!(only(outcome::VETOED), 1.0 / 5.0);
764 }
765
766 fn regret(session: &str, lesson: &str, ms: i64, cause: &str, credit: f64) -> Outcome {
767 Outcome { cause: cause.into(), credit, ..o(outcome::REGRETTED, session, lesson, ms, "r") }
768 }
769
770 #[test]
771 fn a_regret_lowers_belief_less_than_a_correction() {
772 let l = lesson("L", 0);
773 let mem = |log: &[Outcome]| derive(log, std::slice::from_ref(&l), None)["L"].clone();
774 let asked = o(outcome::ASKED, "a", "L", 1, "r");
775 let full = mem(&[asked.clone(), regret("a", "L", 9, "R1", 1.0)]);
776 assert_eq!((full.regretted, full.beta(), full.mean()), (1.0, 2.0, 1.0 / 3.0));
777 assert_eq!(mem(&[asked.clone(), o(CORRECTED, "a", "L", 9, "r")]).mean(), 1.0 / 4.0);
778 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)];
780 assert_eq!(mem(&log).regretted, 0.75);
781 let mut reversed = log.clone();
782 reversed.reverse();
783 assert_eq!(mem(&reversed), mem(&log));
784 let changed = mem(&[asked.clone(), o(CHANGED, "a", "L", 5, "r"), regret("a", "L", 9, "R1", 0.5)]);
786 assert_eq!((changed.changed, changed.regretted), (1, 0.5));
787 assert_eq!(mem(&[asked.clone(), regret("a", "L", 9, "R1", 1.0)]).activation, mem(std::slice::from_ref(&asked)).activation);
788 for kind in [CORRECTED, outcome::VETOED] {
790 let late = mem(&[asked.clone(), regret("a", "L", 9, "R1", 0.5), o(kind, "a", "L", 12, "r")]);
791 assert_eq!(late.regretted, 0.0, "{kind}");
792 assert_eq!(late.beta(), mem(&[asked.clone(), o(kind, "a", "L", 12, "r")]).beta(), "{kind}");
793 }
794 let other = mem(&[asked.clone(), o(CORRECTED, "b", "L", 3, "r"), regret("a", "L", 9, "R1", 0.5)]);
795 assert_eq!(other.regretted, 0.5, "another session's correction leaves the regret");
796 }
797
798 #[test]
799 fn an_older_stores_hive_verdicts_change_no_belief_and_no_tick() {
800 let l = lesson("L", 0);
801 let mem = |log: &[Outcome]| derive(log, std::slice::from_ref(&l), None)["L"].clone();
802 let asked = o(outcome::ASKED, "a", "L", 1, "r");
803 for kind in ["verified", "rejected", "hacked"] {
804 let old = Outcome { cause: "g/t1.v1".into(), ..o(kind, "a", "L", 9, "r") };
805 assert_eq!(mem(&[asked.clone(), old.clone()]), mem(std::slice::from_ref(&asked)), "{kind}");
806 assert_eq!(Clock::of(&[old]).now(None), 0, "{kind}");
807 }
808 }
809
810 #[test]
811 fn a_green_test_run_weighs_half_a_change_and_never_ticks() {
812 let l = lesson("L", 0);
813 let mem = |log: &[Outcome]| derive(log, std::slice::from_ref(&l), None)["L"].clone();
814 let asked = o(outcome::ASKED, "a", "L", 1, "r");
815 let t = mem(&[asked.clone(), o(outcome::TESTED, "a", "L", 9, "r"), o(outcome::TESTED, "a", "L", 10, "r")]);
816 assert_eq!((t.tested, t.alpha(), t.changed), (1, 1.0 + TESTED_W, 0), "once per session, never a use");
817 assert_eq!(t.activation, mem(std::slice::from_ref(&asked)).activation);
818 assert_eq!(Clock::of(&[o(outcome::TESTED, "z", "L", 9, "r")]).now(None), 0, "no tick");
819 for k in [outcome::CORRECTED, outcome::VETOED] {
821 let log = [o(outcome::TESTED, "a", "L", 9, "r"), asked.clone(), o(k, "a", "L", 5, "r")];
822 assert_eq!(mem(&log).tested, 0, "{k}");
823 }
824 assert_eq!(Weights { tested: 2.0, ..WEIGHTS }.tested, 2.0);
825 assert_eq!(t.alpha_with(&Weights { tested: 2.0, ..WEIGHTS }), 3.0);
826 }
827
828 #[test]
829 fn fractional_beta_sampling_is_unbiased_and_integer_beta_unchanged() {
830 let n = 20_000;
831 let mean = (0..n).map(|i| round_seeded(2.5, seed(&format!("s{i}"), "L")) as f64).sum::<f64>() / n as f64;
832 assert!((mean - 2.5).abs() < 0.02, "{mean}");
833 assert!((0..100).all(|i| round_seeded(3.0, i) == 3));
834 let m = Memory { activation: 0.0, changed: 2, ignored: 1, corrected: 1, vetoed: 0, regretted: 0.0, tested: 0, consolidated: false, dormant: false };
836 assert_eq!(m.weight("L", Some("s")), squash(0.0) * (0.5 + sample_beta(3, 4, seed("s", "L"))));
837 assert_eq!(m.weight("L", Some("s")), 1.0618369485259265);
838 }
839
840 #[test]
841 fn fractional_alpha_draws_unbiased_and_integer_unchanged() {
842 assert!((0..200).all(|i| draw(3.0, 4.0, i) == sample_beta(3, 4, i)));
844 assert!((0..200).all(|i| draw(2.0, 2.5, i) == sample_beta(2, round_seeded(2.5, i ^ 0x5265_6772), i)));
845 let n = 20_000;
847 let mean = |a: f64| (0..n).map(|i| draw(a, 1.0, seed(&format!("s{i}"), "L"))).sum::<f64>() / n as f64;
848 let (lo, mid, hi) = (mean(1.0), mean(1.5), mean(2.0));
849 assert!((mid - (lo + hi) / 2.0).abs() < 0.01, "{lo} {mid} {hi}");
850 let m = Memory { tested: 2, ..derive(&[], &[lesson("L", 0)], None)["L"].clone() };
851 let quarter = Weights { tested: 0.25, ..WEIGHTS };
852 assert_eq!((m.alpha_with(&quarter), m.alpha(), m.beta_with(&quarter)), (1.5, 2.0, 1.0));
853 }
854
855 #[test]
856 fn derived_memory_is_independent_of_log_order_so_merging_logs_merges_memory() {
857 let ls = [lesson("L", 150), lesson("M", 0), Challenge { engineers: vec!["a@x".into(), "b@x".into()], ..lesson("N", 0) }];
858 let mut log = sessions(12);
859 for (k, s) in ["s002", "s005", "s009"].iter().enumerate() {
860 log.push(o(CHANGED, s, "N", 300 * k as i64, "r"));
861 log.push(o(IGNORED, s, "M", 300 * k as i64, "r"));
862 }
863 log.push(o(CORRECTED, "s007", "L", 800, "r"));
864 let base = derive(&log, &ls, Some("s011"));
865 let mut rng = Rng::new(7);
866 for _ in 0..20 {
867 let mut shuffled = log.clone();
868 for i in (1..shuffled.len()).rev() {
869 shuffled.swap(i, (rng.next_u64() % (i as u64 + 1)) as usize);
870 }
871 assert_eq!(derive(&shuffled, &ls, Some("s011")), base);
872 }
873 let (a, b) = log.split_at(log.len() / 2);
875 assert_eq!(derive(&[b, a].concat(), &ls, Some("s011")), base);
876 assert!(base["N"].consolidated && !base["M"].consolidated);
877 }
878
879 #[test]
880 fn consolidation_needs_spread_uses_and_dormancy_waits_for_a_long_silence() {
881 let l = lesson("L", 0);
882 let mut log = sessions(80);
883 for i in [1, 2, 3] {
884 log.push(o(CHANGED, &format!("s{i:03}"), "L", 100 * (i + 1) + 1, "one-repo"));
885 }
886 let m = &derive(&log, std::slice::from_ref(&l), None)["L"];
887 assert!(!m.consolidated, "one repo, one engineer");
888 log.push(o(CHANGED, "s004", "L", 501, "other-repo"));
889 let m = &derive(&log, std::slice::from_ref(&l), None)["L"];
890 assert!(m.consolidated && !m.dormant);
891 let fresh = lesson("F", 0);
893 assert!(!derive(&sessions(40), std::slice::from_ref(&fresh), None)["F"].dormant);
894 let mut quiet = sessions(80);
895 assert!(derive(&quiet, std::slice::from_ref(&fresh), None)["F"].dormant);
896 quiet.push(o(CHANGED, "s079", "F", 8001, "r"));
897 assert!(!derive(&quiet, &[fresh], None)["F"].dormant, "one use reactivates");
898 }
899
900 #[test]
901 fn thompson_samples_are_stable_per_session_spread_when_uncertain_and_tight_when_proven() {
902 assert_eq!(sample_beta(3, 4, seed("s", "L")), sample_beta(3, 4, seed("s", "L")));
903 assert_ne!(sample_beta(3, 4, seed("s", "L")), sample_beta(3, 4, seed("t", "L")));
904 let draws = |a, b| (0..400).map(|i| sample_beta(a, b, seed(&format!("s{i}"), "L"))).collect::<Vec<f64>>();
905 let mean = |v: &[f64]| v.iter().sum::<f64>() / v.len() as f64;
906 let sd = |v: &[f64]| (v.iter().map(|x| (x - mean(v)).powi(2)).sum::<f64>() / v.len() as f64).sqrt();
907 let (new, proven, harmful) = (draws(1, 1), draws(21, 3), draws(1, 21));
908 assert!((mean(&new) - 0.5).abs() < 0.05 && sd(&new) > 0.25, "uniform: {} {}", mean(&new), sd(&new));
909 assert!(mean(&proven) > 0.8 && sd(&proven) < 0.1);
910 assert!(mean(&harmful) < 0.1);
911 assert!(new.iter().chain(&proven).all(|x| *x > 0.0 && *x < 1.0));
912 let m = Memory { activation: 0.0, changed: 20, ignored: 2, corrected: 0, vetoed: 0, regretted: 0.0, tested: 0, consolidated: false, dormant: false };
913 assert!(m.weight("L", Some("s")) > 1.2 && m.weight("L", Some("s")) < 1.5);
914 }
915
916 #[test]
917 fn resolve_reads_declarations_first_then_edits_on_the_lessons_point() {
918 let mk = |id: &str, q: &str| Challenge { id: id.into(), question: q.into(), ..Default::default() };
919 let all = vec![
920 mk("Idrain", "Is the per-turn hint place drained by an explicit consumer?"),
921 mk("Ijudge", "Is the replay judge exactly the named Vertex model?"),
922 mk("Ihost", "Does the replay run on live hosts?"),
923 mk("x1", "Is the plan complete?"),
924 mk("x2", "Is the plan tested?"),
925 mk("x3", "Is the plan small?"),
926 ];
927 let asked: Vec<&Challenge> = all[..3].iter().collect();
928 let fin = "Revised.\n`Ijudge`: applies — switched to Vertex.\n- Ihost: skip (offline task)\n";
929 let before = "Add a per-turn hint budget to TextNet; plan: reset arc on the starting transition";
930 let edits = "src/TextNet.java drain the hint place with an explicit consumer transition";
931 let (changed, ignored) = resolve(&asked, &all, before, edits, fin, NOVEL_WORDS);
932 assert_eq!((changed, ignored), (vec!["Idrain".to_string(), "Ijudge".to_string()], vec!["Ihost".to_string()]));
933 assert_eq!(resolve(&asked, &all, before, "", "Done.", NOVEL_WORDS).0, Vec::<String>::new());
935 assert!(resolve(&asked[..1], &all, before, "renamed the hint label, drained", "", NOVEL_WORDS).0.is_empty());
937 assert!(resolve(&asked[..1], &all, "the hint place is drained by an explicit consumer", edits, "", NOVEL_WORDS).0.is_empty());
938 }
939
940 #[test]
941 fn shuffle_is_a_stable_permutation_per_session() {
942 let mut a: Vec<u32> = (0..8).collect();
943 let mut b = a.clone();
944 shuffle(&mut a, "s1");
945 shuffle(&mut b, "s1");
946 assert_eq!(a, b);
947 let mut sorted = a.clone();
948 sorted.sort();
949 assert_eq!(sorted, (0..8).collect::<Vec<_>>());
950 let firsts: HashSet<u32> = (0..40)
951 .map(|i| {
952 let mut v: Vec<u32> = (0..8).collect();
953 shuffle(&mut v, &format!("s{i}"));
954 v[0]
955 })
956 .collect();
957 assert!(firsts.len() >= 5, "{firsts:?}");
958 }
959}