1use std::collections::{HashMap, HashSet};
67
68use crate::conscience::Challenge;
69use crate::outcome::{self, Outcome};
70
71pub const D: f64 = 0.5;
72pub const REGRET: f64 = 1.0;
76pub const TESTED_W: f64 = 0.5;
79pub const VETOED_W: u32 = 3;
82
83#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize)]
85pub struct Weights {
86 pub vetoed: f64,
87 pub regret: f64,
88 pub tested: f64,
89}
90
91pub const WEIGHTS: Weights = Weights { vetoed: VETOED_W as f64, regret: REGRET, tested: TESTED_W };
93pub const D_CONSOLIDATED: f64 = 0.3;
94pub const CONSOLIDATE_SESSIONS: usize = 3;
95pub const DORMANT_BELOW: f64 = -2.0;
96
97#[derive(Debug, Default)]
103pub struct Clock {
104 first: Vec<(i64, String)>,
105 at: HashMap<String, u64>,
106 repos: Vec<HashSet<String>>,
108}
109
110impl Clock {
111 pub fn of(outcomes: &[Outcome]) -> Self {
112 let mut first: HashMap<&str, i64> = HashMap::new();
113 let mut repos: HashMap<&str, HashSet<String>> = HashMap::new();
114 for o in outcomes.iter().filter(|o| outcome::SHARED_KINDS.contains(&o.kind.as_str()) && o.kind != outcome::TESTED) {
115 let m = first.entry(o.session.as_str()).or_insert(o.ms);
116 *m = (*m).min(o.ms);
117 if !o.repo.is_empty() {
118 repos.entry(o.session.as_str()).or_default().insert(o.repo.clone());
119 }
120 }
121 let mut first: Vec<(i64, String)> = first.into_iter().map(|(s, ms)| (ms, s.to_string())).collect();
122 first.sort();
123 let at = first.iter().enumerate().map(|(i, (_, s))| (s.clone(), i as u64)).collect();
124 let repos = first.iter().map(|(_, s)| repos.remove(s.as_str()).unwrap_or_default()).collect();
125 Clock { first, at, repos }
126 }
127
128 pub fn eligible(&self, c: &Challenge) -> Option<Vec<u64>> {
132 if c.reach != crate::reach::REPO || c.repo.is_empty() {
133 return None;
134 }
135 let tagged = outcome::tag(&c.repo);
136 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())
137 }
138
139 pub fn now(&self, session: Option<&str>) -> u64 {
142 session.and_then(|s| self.at.get(s).copied()).unwrap_or(self.first.len() as u64)
143 }
144
145 pub fn of_session(&self, session: &str) -> u64 {
147 self.at.get(session).copied().unwrap_or(self.first.len() as u64)
148 }
149
150 pub fn at_ms(&self, ms: i64) -> u64 {
152 self.first.partition_point(|(m, _)| *m < ms) as u64
153 }
154}
155
156pub fn activation(ages: impl IntoIterator<Item = u64>, d: f64) -> f64 {
158 ages.into_iter().map(|a| (a.max(1) as f64).powf(-d)).sum::<f64>().ln()
159}
160
161pub fn squash(a: f64) -> f64 {
163 2.0 / (1.0 + (-a / 2.0).exp())
164}
165
166#[derive(Debug, Clone, PartialEq)]
168pub struct Memory {
169 pub activation: f64,
170 pub changed: u32,
172 pub ignored: u32,
173 pub corrected: u32,
174 pub vetoed: u32,
175 pub regretted: f64,
177 pub tested: u32,
179 pub consolidated: bool,
180 pub dormant: bool,
181}
182
183impl Memory {
184 pub fn alpha_with(&self, w: &Weights) -> f64 {
186 1.0 + self.changed as f64 + w.tested * self.tested as f64
187 }
188
189 pub fn beta_with(&self, w: &Weights) -> f64 {
191 (1 + self.ignored + 2 * self.corrected) as f64 + w.vetoed * self.vetoed as f64 + w.regret * self.regretted
192 }
193
194 pub fn alpha(&self) -> f64 {
195 self.alpha_with(&WEIGHTS)
196 }
197
198 pub fn beta(&self) -> f64 {
199 self.beta_with(&WEIGHTS)
200 }
201
202 pub fn mean(&self) -> f64 {
203 self.alpha() / (self.alpha() + self.beta())
204 }
205
206 pub fn weight(&self, id: &str, session: Option<&str>) -> f64 {
208 let x = match session {
209 Some(s) => draw(self.alpha(), self.beta(), seed(s, id)),
210 None => self.mean(),
211 };
212 squash(self.activation) * (0.5 + x)
213 }
214}
215
216pub fn derive(outcomes: &[Outcome], lessons: &[Challenge], session: Option<&str>) -> HashMap<String, Memory> {
218 let clock = Clock::of(outcomes);
219 let now = clock.now(session);
220 let mut result: HashMap<&str, HashMap<&str, u8>> = HashMap::new();
222 let mut uses: HashMap<&str, HashSet<&str>> = HashMap::new();
223 let mut repos: HashMap<&str, HashSet<&str>> = HashMap::new();
224 let mut regret: HashMap<(&str, &str), (f64, &str)> = HashMap::new();
227 let mut tested: HashMap<&str, HashSet<&str>> = HashMap::new();
229 for o in outcomes {
230 if o.kind == outcome::TESTED {
231 for l in &o.lessons {
232 tested.entry(l.as_str()).or_default().insert(o.session.as_str());
233 }
234 continue;
235 }
236 if o.kind == outcome::REGRETTED {
237 for l in &o.lessons {
238 let e = regret.entry((l.as_str(), o.cause.as_str())).or_insert((0.0, o.session.as_str()));
239 e.0 = e.0.max(o.credit.clamp(0.0, 1.0));
240 }
241 continue;
242 }
243 let r = match o.kind.as_str() {
244 outcome::VETOED => 3,
245 outcome::CORRECTED => 2,
246 outcome::CHANGED => 1,
247 outcome::IGNORED | outcome::NOT_CORRECTED => 0,
248 _ => continue,
249 };
250 for l in &o.lessons {
251 let e = result.entry(l.as_str()).or_default().entry(o.session.as_str()).or_insert(r);
252 *e = (*e).max(r);
253 if r == 1 {
254 uses.entry(l.as_str()).or_default().insert(o.session.as_str());
255 repos.entry(l.as_str()).or_default().insert(o.repo.as_str());
256 }
257 }
258 }
259 let mut regretted: HashMap<&str, f64> = HashMap::new();
262 for ((l, _), (credit, s)) in regret {
263 if result.get(l).and_then(|m| m.get(s)).is_none_or(|r| *r < 2) {
264 *regretted.entry(l).or_default() += credit;
265 }
266 }
267 let mut eligible: HashMap<(&str, &str), Option<Vec<u64>>> = HashMap::new();
269 lessons
270 .iter()
271 .map(|c| {
272 let per = result.get(c.id.as_str());
273 let count = |k: u8| per.map_or(0, |m| m.values().filter(|r| **r == k).count() as u32);
274 let (changed, ignored, corrected, vetoed) = (count(1), count(0), count(2), count(3));
275 let used: Vec<u64> = uses.get(c.id.as_str()).map(|u| u.iter().map(|s| clock.of_session(s)).collect()).unwrap_or_default();
278 let changed_sessions = used.len();
279 let consolidated = changed_sessions >= CONSOLIDATE_SESSIONS && (repos.get(c.id.as_str()).is_some_and(|r| r.len() >= 2) || c.engineers.len() >= 2);
280 let d = if consolidated { D_CONSOLIDATED } else { D };
281 let born = clock.at_ms(c.origin_ms);
282 let eligible = eligible.entry((c.reach.as_str(), c.repo.as_str())).or_insert_with(|| clock.eligible(c));
284 let rank = |t: u64| eligible.as_ref().map_or(t, |e| e.partition_point(|x| *x < t) as u64);
285 let now = rank(now);
286 let ages = used.into_iter().chain([born]).map(|t| now.saturating_sub(rank(t)) + 1);
287 let a = activation(ages, d);
288 let regretted = regretted.get(c.id.as_str()).copied().unwrap_or(0.0);
289 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);
291 let m = Memory { activation: a, changed, ignored, corrected, vetoed, regretted, tested, consolidated, dormant: !consolidated && a < DORMANT_BELOW };
292 (c.id.clone(), m)
293 })
294 .collect()
295}
296
297pub fn seed(session: &str, lesson: &str) -> u64 {
299 let mut h: u64 = 0xcbf29ce484222325;
300 for b in session.bytes().chain([0]).chain(lesson.bytes()) {
301 h ^= b as u64;
302 h = h.wrapping_mul(0x100000001b3);
303 }
304 h
305}
306
307pub struct Rng(u64);
309
310impl Rng {
311 pub fn new(seed: u64) -> Self {
312 Rng(seed)
313 }
314
315 pub fn next_u64(&mut self) -> u64 {
316 self.0 = self.0.wrapping_add(0x9e3779b97f4a7c15);
317 let mut z = self.0;
318 z = (z ^ (z >> 30)).wrapping_mul(0xbf58476d1ce4e5b9);
319 z = (z ^ (z >> 27)).wrapping_mul(0x94d049bb133111eb);
320 z ^ (z >> 31)
321 }
322
323 pub fn unit(&mut self) -> f64 {
325 ((self.next_u64() >> 11) as f64 + 0.5) / (1u64 << 53) as f64
326 }
327}
328
329pub fn round_seeded(x: f64, seed: u64) -> u32 {
332 let (whole, frac) = (x.floor(), x - x.floor());
333 match frac == 0.0 || Rng::new(seed).unit() >= frac {
334 true => whole as u32,
335 false => whole as u32 + 1,
336 }
337}
338
339pub fn draw(alpha: f64, beta: f64, seed: u64) -> f64 {
342 sample_beta(round_seeded(alpha, seed ^ 0x416c_7068), round_seeded(beta, seed ^ 0x5265_6772), seed)
343}
344
345pub fn sample_beta(a: u32, b: u32, seed: u64) -> f64 {
348 let scale = (a.max(b) as f64 / 1000.0).max(1.0);
349 let (a, b) = (((a as f64 / scale).round() as u32).max(1), ((b as f64 / scale).round() as u32).max(1));
350 let mut rng = Rng::new(seed);
351 let mut gamma = |k: u32| (0..k).map(|_| -rng.unit().ln()).sum::<f64>();
352 let x = gamma(a);
353 let y = gamma(b);
354 x / (x + y)
355}
356
357pub fn shuffle<T>(items: &mut [T], session: &str) {
360 let mut rng = Rng::new(seed(session, "#order"));
361 for i in (1..items.len()).rev() {
362 let j = (rng.next_u64() % (i as u64 + 1)) as usize;
363 items.swap(i, j);
364 }
365}
366
367fn distinctive(all: &[Challenge]) -> impl Fn(&str) -> HashSet<String> + '_ {
370 let mut df: HashMap<String, usize> = HashMap::new();
371 for c in all {
372 for t in crate::conscience::tokens(&c.question).into_iter().collect::<HashSet<_>>() {
373 *df.entry(t).or_default() += 1;
374 }
375 }
376 let n = all.len().max(1);
377 move |text: &str| crate::conscience::tokens(text).into_iter().filter(|t| df.get(t).is_some_and(|d| 3 * d <= n)).collect()
378}
379
380pub const NOVEL_WORDS: usize = 3;
382
383pub fn resolve(asked: &[&Challenge], all: &[Challenge], before: &str, after: &str, final_msg: &str, min_new: usize) -> (Vec<String>, Vec<String>) {
396 let words = distinctive(all);
397 let had: HashSet<String> = crate::conscience::tokens(before).into_iter().collect();
398 let new: HashSet<String> = words(&format!("{after}\n{final_msg}")).into_iter().filter(|w| !had.contains(w)).collect();
399 let lower = final_msg.to_lowercase();
400 let (mut changed, mut ignored) = (Vec::new(), Vec::new());
401 for c in asked {
402 let verdict = declared(&lower, &c.id.to_lowercase());
403 let applies = match verdict {
404 Some(v) => v,
405 None => words(&c.question).intersection(&new).count() >= min_new,
406 };
407 match applies {
408 true => changed.push(c.id.clone()),
409 false => ignored.push(c.id.clone()),
410 }
411 }
412 (changed, ignored)
413}
414
415pub fn declared(lower: &str, id: &str) -> Option<bool> {
418 let mut out = None;
419 let mut from = 0;
420 while let Some(i) = lower[from..].find(id) {
421 let at = from + i + id.len();
422 let rest: String = lower[at..].chars().take(16).collect();
423 let rest = rest.trim_start_matches(|c: char| c == ']' || c == '`' || c == '*' || c == ':' || c == '-' || c == '–' || c.is_whitespace());
424 if rest.starts_with("applies") || rest.starts_with("apply") {
425 out = Some(true);
426 } else if rest.starts_with("skip") {
427 out = Some(false);
428 }
429 from = at;
430 }
431 out
432}
433
434#[cfg(test)]
435mod tests {
436 use super::*;
437 use crate::outcome::{CHANGED, CORRECTED, IGNORED, NOT_CORRECTED};
438
439 fn o(kind: &str, session: &str, lesson: &str, ms: i64, repo: &str) -> Outcome {
440 Outcome { kind: kind.into(), session: session.into(), lessons: vec![lesson.into()], ms, repo: repo.into(), ..Default::default() }
441 }
442
443 fn lesson(id: &str, ms: i64) -> Challenge {
444 Challenge { id: id.into(), origin_ms: ms, question: format!("question of {id}"), ..Default::default() }
445 }
446
447 fn sessions(n: usize) -> Vec<Outcome> {
449 (0..n).map(|i| o(outcome::ASKED, &format!("s{i:03}"), "other", 100 * (i as i64 + 1), "r")).collect()
450 }
451
452 #[test]
453 fn a_new_lesson_is_neutral_and_activation_decays_with_the_session_clock_not_wall_time() {
454 let l = lesson("L", 0);
455 let m = &derive(&[], std::slice::from_ref(&l), None)["L"];
456 assert_eq!((m.activation, m.mean(), m.weight("L", None)), (0.0, 0.5, 1.0));
457 let m = &derive(&sessions(10), std::slice::from_ref(&l), None)["L"];
459 assert!((m.activation - (11f64).powf(-0.5).ln()).abs() < 1e-12);
460 assert!(m.weight("L", None) < 1.0 && m.weight("L", None) > 0.5);
461 let spread: Vec<Outcome> = sessions(10)
463 .into_iter()
464 .enumerate()
465 .map(|(i, mut x)| {
466 x.ms = if i < 5 { x.ms } else { x.ms + 365 * 86_400_000 };
467 x
468 })
469 .collect();
470 assert_eq!(derive(&spread, &[l], None)["L"].activation, m.activation);
471 }
472
473 #[test]
474 fn a_repo_convention_ages_only_with_the_sessions_of_its_repo_and_a_team_lesson_with_all() {
475 let conv = Challenge { reach: crate::reach::REPO.into(), repo: "a".into(), ..lesson("C", 0) };
476 let both = [lesson("T", 0), conv];
477 let in_repo = |log: Vec<Outcome>, repo: &dyn Fn(usize) -> String| -> Vec<Outcome> {
478 log.into_iter()
479 .enumerate()
480 .map(|(i, mut x)| {
481 x.repo = repo(i);
482 x
483 })
484 .collect()
485 };
486 let m = derive(&in_repo(sessions(60), &|_| "b".into()), &both, None);
488 assert!(m["T"].dormant);
489 assert_eq!((m["C"].activation, m["C"].dormant), (0.0, false));
490 for repo in ["a".to_string(), "A".to_string(), outcome::tag("a")] {
492 let m = derive(&in_repo(sessions(60), &|_| repo.clone()), &both, None);
493 assert_eq!(m["C"].activation, m["T"].activation, "{repo}");
494 assert!(m["C"].dormant, "{repo}");
495 }
496 let mut log = in_repo(sessions(40), &|i| if i % 4 == 0 { "a".into() } else { "b".into() });
499 log.push(o(CHANGED, "s004", "C", 501, "a"));
500 let m = derive(&log, &both, None);
501 let want = activation([11, 10], D);
502 assert!((m["C"].activation - want).abs() < 1e-12, "{} vs {want}", m["C"].activation);
503 }
504
505 #[test]
506 fn activation_is_monotone_in_uses_and_spaced_uses_outlast_massed_ones() {
507 let l = lesson("L", 0);
508 let mut log = sessions(30);
509 let a0 = derive(&log, std::slice::from_ref(&l), None)["L"].activation;
510 log.push(o(CHANGED, "s010", "L", 1101, "r"));
511 let a1 = derive(&log, std::slice::from_ref(&l), None)["L"].activation;
512 log.push(o(CHANGED, "s020", "L", 2101, "r"));
513 let a2 = derive(&log, std::slice::from_ref(&l), None)["L"].activation;
514 assert!(a0 < a1 && a1 < a2, "{a0} {a1} {a2}");
515 let at = |uses: &[usize]| {
518 let mut log = sessions(60);
519 log.extend(uses.iter().map(|i| o(CHANGED, &format!("s{i:03}"), "L", 100 * (*i as i64 + 1) + 1, "r")));
520 derive(&log, std::slice::from_ref(&l), None)["L"].activation
521 };
522 assert!(at(&[1, 20]) > at(&[1, 2]));
523 }
524
525 #[test]
526 fn belief_counts_each_session_once_corrected_over_changed_over_ignored() {
527 let l = lesson("L", 0);
528 let log = vec![
529 o(CHANGED, "a", "L", 1, "r"),
530 o(NOT_CORRECTED, "a", "L", 2, "r"), o(IGNORED, "b", "L", 3, "r"),
532 o(CHANGED, "c", "L", 4, "r"),
533 o(CORRECTED, "c", "L", 5, "r"), o(NOT_CORRECTED, "d", "L", 6, "r"),
535 ];
536 let m = &derive(&log, &[l], None)["L"];
537 assert_eq!((m.changed, m.ignored, m.corrected), (1, 2, 1));
538 assert_eq!((m.alpha(), m.beta()), (2.0, 5.0));
539 }
540
541 #[test]
542 fn a_veto_outweighs_a_correction_and_a_change_in_its_session() {
543 let l = lesson("L", 0);
544 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")];
545 let m = &derive(&log, std::slice::from_ref(&l), None)["L"];
546 assert_eq!((m.changed, m.ignored, m.corrected, m.vetoed), (0, 0, 1, 1));
547 assert_eq!((m.alpha(), m.beta()), (1.0, (1 + 2 + 3) as f64));
548 let without = derive(&log[1..], std::slice::from_ref(&l), None)["L"].activation;
550 assert!(m.activation > without, "{} {without}", m.activation);
551 let only = |kind: &str| derive(&[o(kind, "a", "L", 1, "r")], std::slice::from_ref(&l), None)["L"].mean();
553 assert!(only(outcome::VETOED) < only(CORRECTED), "{} {}", only(outcome::VETOED), only(CORRECTED));
554 assert_eq!(only(outcome::VETOED), 1.0 / 5.0);
555 }
556
557 fn regret(session: &str, lesson: &str, ms: i64, cause: &str, credit: f64) -> Outcome {
558 Outcome { cause: cause.into(), credit, ..o(outcome::REGRETTED, session, lesson, ms, "r") }
559 }
560
561 #[test]
562 fn a_regret_lowers_belief_less_than_a_correction() {
563 let l = lesson("L", 0);
564 let mem = |log: &[Outcome]| derive(log, std::slice::from_ref(&l), None)["L"].clone();
565 let asked = o(outcome::ASKED, "a", "L", 1, "r");
566 let full = mem(&[asked.clone(), regret("a", "L", 9, "R1", 1.0)]);
567 assert_eq!((full.regretted, full.beta(), full.mean()), (1.0, 2.0, 1.0 / 3.0));
568 assert_eq!(mem(&[asked.clone(), o(CORRECTED, "a", "L", 9, "r")]).mean(), 1.0 / 4.0);
569 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)];
571 assert_eq!(mem(&log).regretted, 0.75);
572 let mut reversed = log.clone();
573 reversed.reverse();
574 assert_eq!(mem(&reversed), mem(&log));
575 let changed = mem(&[asked.clone(), o(CHANGED, "a", "L", 5, "r"), regret("a", "L", 9, "R1", 0.5)]);
577 assert_eq!((changed.changed, changed.regretted), (1, 0.5));
578 assert_eq!(mem(&[asked.clone(), regret("a", "L", 9, "R1", 1.0)]).activation, mem(std::slice::from_ref(&asked)).activation);
579 for kind in [CORRECTED, outcome::VETOED] {
581 let late = mem(&[asked.clone(), regret("a", "L", 9, "R1", 0.5), o(kind, "a", "L", 12, "r")]);
582 assert_eq!(late.regretted, 0.0, "{kind}");
583 assert_eq!(late.beta(), mem(&[asked.clone(), o(kind, "a", "L", 12, "r")]).beta(), "{kind}");
584 }
585 let other = mem(&[asked.clone(), o(CORRECTED, "b", "L", 3, "r"), regret("a", "L", 9, "R1", 0.5)]);
586 assert_eq!(other.regretted, 0.5, "another session's correction leaves the regret");
587 }
588
589 #[test]
590 fn an_older_stores_hive_verdicts_change_no_belief_and_no_tick() {
591 let l = lesson("L", 0);
592 let mem = |log: &[Outcome]| derive(log, std::slice::from_ref(&l), None)["L"].clone();
593 let asked = o(outcome::ASKED, "a", "L", 1, "r");
594 for kind in ["verified", "rejected", "hacked"] {
595 let old = Outcome { cause: "g/t1.v1".into(), ..o(kind, "a", "L", 9, "r") };
596 assert_eq!(mem(&[asked.clone(), old.clone()]), mem(std::slice::from_ref(&asked)), "{kind}");
597 assert_eq!(Clock::of(&[old]).now(None), 0, "{kind}");
598 }
599 }
600
601 #[test]
602 fn a_green_test_run_weighs_half_a_change_and_never_ticks() {
603 let l = lesson("L", 0);
604 let mem = |log: &[Outcome]| derive(log, std::slice::from_ref(&l), None)["L"].clone();
605 let asked = o(outcome::ASKED, "a", "L", 1, "r");
606 let t = mem(&[asked.clone(), o(outcome::TESTED, "a", "L", 9, "r"), o(outcome::TESTED, "a", "L", 10, "r")]);
607 assert_eq!((t.tested, t.alpha(), t.changed), (1, 1.0 + TESTED_W, 0), "once per session, never a use");
608 assert_eq!(t.activation, mem(std::slice::from_ref(&asked)).activation);
609 assert_eq!(Clock::of(&[o(outcome::TESTED, "z", "L", 9, "r")]).now(None), 0, "no tick");
610 for k in [outcome::CORRECTED, outcome::VETOED] {
612 let log = [o(outcome::TESTED, "a", "L", 9, "r"), asked.clone(), o(k, "a", "L", 5, "r")];
613 assert_eq!(mem(&log).tested, 0, "{k}");
614 }
615 assert_eq!(Weights { tested: 2.0, ..WEIGHTS }.tested, 2.0);
616 assert_eq!(t.alpha_with(&Weights { tested: 2.0, ..WEIGHTS }), 3.0);
617 }
618
619 #[test]
620 fn fractional_beta_sampling_is_unbiased_and_integer_beta_unchanged() {
621 let n = 20_000;
622 let mean = (0..n).map(|i| round_seeded(2.5, seed(&format!("s{i}"), "L")) as f64).sum::<f64>() / n as f64;
623 assert!((mean - 2.5).abs() < 0.02, "{mean}");
624 assert!((0..100).all(|i| round_seeded(3.0, i) == 3));
625 let m = Memory { activation: 0.0, changed: 2, ignored: 1, corrected: 1, vetoed: 0, regretted: 0.0, tested: 0, consolidated: false, dormant: false };
627 assert_eq!(m.weight("L", Some("s")), squash(0.0) * (0.5 + sample_beta(3, 4, seed("s", "L"))));
628 assert_eq!(m.weight("L", Some("s")), 1.0618369485259265);
629 }
630
631 #[test]
632 fn fractional_alpha_draws_unbiased_and_integer_unchanged() {
633 assert!((0..200).all(|i| draw(3.0, 4.0, i) == sample_beta(3, 4, i)));
635 assert!((0..200).all(|i| draw(2.0, 2.5, i) == sample_beta(2, round_seeded(2.5, i ^ 0x5265_6772), i)));
636 let n = 20_000;
638 let mean = |a: f64| (0..n).map(|i| draw(a, 1.0, seed(&format!("s{i}"), "L"))).sum::<f64>() / n as f64;
639 let (lo, mid, hi) = (mean(1.0), mean(1.5), mean(2.0));
640 assert!((mid - (lo + hi) / 2.0).abs() < 0.01, "{lo} {mid} {hi}");
641 let m = Memory { tested: 2, ..derive(&[], &[lesson("L", 0)], None)["L"].clone() };
642 let quarter = Weights { tested: 0.25, ..WEIGHTS };
643 assert_eq!((m.alpha_with(&quarter), m.alpha(), m.beta_with(&quarter)), (1.5, 2.0, 1.0));
644 }
645
646 #[test]
647 fn derived_memory_is_independent_of_log_order_so_merging_logs_merges_memory() {
648 let ls = [lesson("L", 150), lesson("M", 0), Challenge { engineers: vec!["a@x".into(), "b@x".into()], ..lesson("N", 0) }];
649 let mut log = sessions(12);
650 for (k, s) in ["s002", "s005", "s009"].iter().enumerate() {
651 log.push(o(CHANGED, s, "N", 300 * k as i64, "r"));
652 log.push(o(IGNORED, s, "M", 300 * k as i64, "r"));
653 }
654 log.push(o(CORRECTED, "s007", "L", 800, "r"));
655 let base = derive(&log, &ls, Some("s011"));
656 let mut rng = Rng::new(7);
657 for _ in 0..20 {
658 let mut shuffled = log.clone();
659 for i in (1..shuffled.len()).rev() {
660 shuffled.swap(i, (rng.next_u64() % (i as u64 + 1)) as usize);
661 }
662 assert_eq!(derive(&shuffled, &ls, Some("s011")), base);
663 }
664 let (a, b) = log.split_at(log.len() / 2);
666 assert_eq!(derive(&[b, a].concat(), &ls, Some("s011")), base);
667 assert!(base["N"].consolidated && !base["M"].consolidated);
668 }
669
670 #[test]
671 fn consolidation_needs_spread_uses_and_dormancy_waits_for_a_long_silence() {
672 let l = lesson("L", 0);
673 let mut log = sessions(80);
674 for i in [1, 2, 3] {
675 log.push(o(CHANGED, &format!("s{i:03}"), "L", 100 * (i + 1) + 1, "one-repo"));
676 }
677 let m = &derive(&log, std::slice::from_ref(&l), None)["L"];
678 assert!(!m.consolidated, "one repo, one engineer");
679 log.push(o(CHANGED, "s004", "L", 501, "other-repo"));
680 let m = &derive(&log, std::slice::from_ref(&l), None)["L"];
681 assert!(m.consolidated && !m.dormant);
682 let fresh = lesson("F", 0);
684 assert!(!derive(&sessions(40), std::slice::from_ref(&fresh), None)["F"].dormant);
685 let mut quiet = sessions(80);
686 assert!(derive(&quiet, std::slice::from_ref(&fresh), None)["F"].dormant);
687 quiet.push(o(CHANGED, "s079", "F", 8001, "r"));
688 assert!(!derive(&quiet, &[fresh], None)["F"].dormant, "one use reactivates");
689 }
690
691 #[test]
692 fn thompson_samples_are_stable_per_session_spread_when_uncertain_and_tight_when_proven() {
693 assert_eq!(sample_beta(3, 4, seed("s", "L")), sample_beta(3, 4, seed("s", "L")));
694 assert_ne!(sample_beta(3, 4, seed("s", "L")), sample_beta(3, 4, seed("t", "L")));
695 let draws = |a, b| (0..400).map(|i| sample_beta(a, b, seed(&format!("s{i}"), "L"))).collect::<Vec<f64>>();
696 let mean = |v: &[f64]| v.iter().sum::<f64>() / v.len() as f64;
697 let sd = |v: &[f64]| (v.iter().map(|x| (x - mean(v)).powi(2)).sum::<f64>() / v.len() as f64).sqrt();
698 let (new, proven, harmful) = (draws(1, 1), draws(21, 3), draws(1, 21));
699 assert!((mean(&new) - 0.5).abs() < 0.05 && sd(&new) > 0.25, "uniform: {} {}", mean(&new), sd(&new));
700 assert!(mean(&proven) > 0.8 && sd(&proven) < 0.1);
701 assert!(mean(&harmful) < 0.1);
702 assert!(new.iter().chain(&proven).all(|x| *x > 0.0 && *x < 1.0));
703 let m = Memory { activation: 0.0, changed: 20, ignored: 2, corrected: 0, vetoed: 0, regretted: 0.0, tested: 0, consolidated: false, dormant: false };
704 assert!(m.weight("L", Some("s")) > 1.2 && m.weight("L", Some("s")) < 1.5);
705 }
706
707 #[test]
708 fn resolve_reads_declarations_first_then_edits_on_the_lessons_point() {
709 let mk = |id: &str, q: &str| Challenge { id: id.into(), question: q.into(), ..Default::default() };
710 let all = vec![
711 mk("Idrain", "Is the per-turn hint place drained by an explicit consumer?"),
712 mk("Ijudge", "Is the replay judge exactly the named Vertex model?"),
713 mk("Ihost", "Does the replay run on live hosts?"),
714 mk("x1", "Is the plan complete?"),
715 mk("x2", "Is the plan tested?"),
716 mk("x3", "Is the plan small?"),
717 ];
718 let asked: Vec<&Challenge> = all[..3].iter().collect();
719 let fin = "Revised.\n`Ijudge`: applies — switched to Vertex.\n- Ihost: skip (offline task)\n";
720 let before = "Add a per-turn hint budget to TextNet; plan: reset arc on the starting transition";
721 let edits = "src/TextNet.java drain the hint place with an explicit consumer transition";
722 let (changed, ignored) = resolve(&asked, &all, before, edits, fin, NOVEL_WORDS);
723 assert_eq!((changed, ignored), (vec!["Idrain".to_string(), "Ijudge".to_string()], vec!["Ihost".to_string()]));
724 assert_eq!(resolve(&asked, &all, before, "", "Done.", NOVEL_WORDS).0, Vec::<String>::new());
726 assert!(resolve(&asked[..1], &all, before, "renamed the hint label, drained", "", NOVEL_WORDS).0.is_empty());
728 assert!(resolve(&asked[..1], &all, "the hint place is drained by an explicit consumer", edits, "", NOVEL_WORDS).0.is_empty());
729 }
730
731 #[test]
732 fn shuffle_is_a_stable_permutation_per_session() {
733 let mut a: Vec<u32> = (0..8).collect();
734 let mut b = a.clone();
735 shuffle(&mut a, "s1");
736 shuffle(&mut b, "s1");
737 assert_eq!(a, b);
738 let mut sorted = a.clone();
739 sorted.sort();
740 assert_eq!(sorted, (0..8).collect::<Vec<_>>());
741 let firsts: HashSet<u32> = (0..40)
742 .map(|i| {
743 let mut v: Vec<u32> = (0..8).collect();
744 shuffle(&mut v, &format!("s{i}"));
745 v[0]
746 })
747 .collect();
748 assert!(firsts.len() >= 5, "{firsts:?}");
749 }
750}