1use std::collections::HashMap;
67use std::path::Path;
68
69use serde::{Deserialize, Serialize};
70
71pub const ASKED: &str = "asked";
72pub const CORRECTED: &str = "asked_then_corrected";
73pub const NOT_CORRECTED: &str = "asked_not_corrected";
74pub const CHANGED: &str = "asked_then_changed";
75pub const IGNORED: &str = "asked_ignored";
76pub const GRADED: &str = "graded";
80pub const MISSED: &str = "applied_then_corrected";
83pub const VETOED: &str = "vetoed";
86pub const REGRETTED: &str = "regretted";
90
91pub const TESTED: &str = "tested";
94
95pub const IDLE_MS: i64 = 24 * 60 * 60 * 1000;
97
98#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
99pub struct Outcome {
100 pub kind: String,
101 pub session: String,
102 pub lessons: Vec<String>,
103 pub ms: i64,
104 pub repo: String,
105 #[serde(default, skip_serializing_if = "Vec::is_empty")]
107 pub groups: Vec<Vec<String>>,
108 #[serde(default, skip_serializing_if = "String::is_empty")]
112 pub channel: String,
113 #[serde(default, skip_serializing_if = "Vec::is_empty")]
118 pub grades: Vec<u8>,
119 #[serde(default, skip_serializing_if = "Vec::is_empty")]
120 pub ranks: Vec<u16>,
121 #[serde(default, skip_serializing_if = "is_zero")]
122 pub applied: u16,
123 #[serde(default, skip_serializing_if = "Vec::is_empty")]
125 pub chosen: Vec<String>,
126 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
129 pub explore: bool,
130 #[serde(default, skip_serializing_if = "String::is_empty")]
132 pub cause: String,
133 #[serde(default, skip_serializing_if = "is_zero_f")]
135 pub credit: f64,
136 #[serde(default, skip_serializing_if = "Vec::is_empty")]
144 pub p: Vec<f64>,
145 #[serde(default, skip_serializing_if = "Vec::is_empty")]
151 pub pool: Vec<String>,
152 #[serde(default, skip_serializing_if = "Vec::is_empty")]
153 pub base: Vec<f64>,
154 #[serde(default, skip_serializing_if = "Vec::is_empty")]
155 pub scoped: Vec<bool>,
156}
157
158pub fn round_p(p: f64) -> f64 {
160 (p * 1e4).round() / 1e4
161}
162
163fn is_zero(v: &u16) -> bool {
164 *v == 0
165}
166
167fn is_zero_f(v: &f64) -> bool {
168 *v == 0.0
169}
170
171pub fn path_beside(lessons: &Path) -> std::path::PathBuf {
173 lessons.with_file_name("outcomes.jsonl")
174}
175
176pub const TEAM_FILE: &str = "outcomes-team.jsonl";
178pub const PUBLISHED: &str = "outcomes-team.published";
180
181pub const SHARED_KINDS: [&str; 9] = [ASKED, CHANGED, IGNORED, CORRECTED, NOT_CORRECTED, MISSED, VETOED, REGRETTED, TESTED];
203
204pub fn tag(s: &str) -> String {
208 let mut h = blake3::Hasher::new();
209 h.update(b"borg-outcome-v1\0");
210 h.update(s.as_bytes());
211 h.finalize().to_hex()[..32].to_string()
212}
213
214fn tag_repo(repo: &str) -> String {
216 if repo.is_empty() { String::new() } else { tag(repo) }
217}
218
219#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
222pub struct Shared {
223 pub kind: String,
224 pub s: String,
226 pub lessons: Vec<String>,
227 pub ms: i64,
228 pub node: String,
230 #[serde(default, skip_serializing_if = "Vec::is_empty")]
231 pub p: Vec<f64>,
232 #[serde(default, skip_serializing_if = "String::is_empty")]
233 pub channel: String,
234 #[serde(default, skip_serializing_if = "String::is_empty")]
236 pub r: String,
237 #[serde(default, skip_serializing_if = "String::is_empty")]
239 pub c: String,
240 #[serde(default, skip_serializing_if = "is_zero_f")]
242 pub credit: f64,
243}
244
245pub fn share(o: &Outcome, node: &str) -> Option<Shared> {
247 SHARED_KINDS.contains(&o.kind.as_str()).then(|| Shared {
248 kind: o.kind.clone(),
249 s: tag(&o.session),
250 lessons: o.lessons.clone(),
251 ms: o.ms,
252 node: node.into(),
253 p: o.p.clone(),
254 channel: o.channel.clone(),
255 r: tag_repo(&o.repo),
256 c: tag_repo(&o.cause),
257 credit: o.credit,
258 })
259}
260
261pub fn on() -> bool {
263 std::env::var("BORG_SHARE_OUTCOMES").as_deref() != Ok("off")
264}
265
266pub fn publish(home: &Path) -> usize {
272 let local = home.join("outcomes.jsonl");
273 let team = home.join(TEAM_FILE);
274 let mark = home.join(PUBLISHED);
275 let len = std::fs::metadata(&local).map_or(0, |m| m.len());
276 if !on() || len == 0 {
277 return 0;
278 }
279 let published = std::fs::read_to_string(&mark).ok().and_then(|t| t.trim().parse::<u64>().ok());
280 if team.exists() && published == Some(len) {
281 return 0;
282 }
283 let mine = std::fs::read_to_string(&local).unwrap_or_default();
284 let node = crate::stats::node(home);
285 let text = std::fs::read_to_string(&team).unwrap_or_default();
286 let mut have: std::collections::HashSet<String> = text.lines().map(str::to_string).collect();
287 let mut out = String::new();
288 let records = mine.lines().filter_map(|l| serde_json::from_str::<Outcome>(l).ok());
289 for line in records.filter_map(|o| share(&o, &node)).filter_map(|x| serde_json::to_string(&x).ok()) {
290 if have.insert(line.clone()) {
291 out.push_str(&line);
292 out.push('\n');
293 }
294 }
295 if out.is_empty() {
296 let _ = std::fs::write(&mark, mine.len().to_string());
297 return 0;
298 }
299 use std::io::Write;
300 let open = text.as_bytes().last().is_some_and(|c| *c != b'\n');
301 let written = std::fs::OpenOptions::new()
302 .create(true)
303 .append(true)
304 .open(&team)
305 .and_then(|mut f| f.write_all(format!("{}{out}", if open { "\n" } else { "" }).as_bytes()));
306 if written.is_err() {
307 return 0;
308 }
309 let _ = std::fs::write(&mark, mine.len().to_string());
310 out.lines().count()
311}
312
313pub fn read_team(lessons: &Path) -> Vec<Outcome> {
316 team_view(lessons, on())
317}
318
319pub fn team_view(lessons: &Path, on: bool) -> Vec<Outcome> {
325 let (mut out, team) = team_parts(lessons, on);
326 if on {
327 for o in &mut out {
328 o.repo = tag_repo(&o.repo);
329 o.cause = tag_repo(&o.cause);
330 }
331 }
332 out.extend(team);
333 out
334}
335
336pub fn team_parts(lessons: &Path, on: bool) -> (Vec<Outcome>, Vec<Outcome>) {
343 let local = read(&path_beside(lessons));
344 if !on {
345 return (local, Vec::new());
346 }
347 let mine: std::collections::HashSet<String> = local.iter().map(|o| tag(&o.session)).collect();
348 let text = std::fs::read_to_string(lessons.with_file_name(TEAM_FILE)).unwrap_or_default();
349 let lines: Vec<Shared> = text.lines().filter_map(|l| serde_json::from_str::<Shared>(l).ok()).collect();
350 let mut given: std::collections::HashMap<&str, std::collections::HashSet<&str>> = std::collections::HashMap::new();
352 for x in lines.iter().filter(|x| SHARED_KINDS.contains(&x.kind.as_str()) && x.kind != TESTED) {
353 given.entry(x.s.as_str()).or_default().extend(x.lessons.iter().map(String::as_str));
354 }
355 let mut seen: std::collections::HashSet<(String, String, Vec<String>, i64, String)> = std::collections::HashSet::new();
356 let mut team = Vec::new();
357 for x in lines.iter() {
358 if !SHARED_KINDS.contains(&x.kind.as_str()) || mine.contains(&x.s) || !seen.insert((x.kind.clone(), x.s.clone(), x.lessons.clone(), x.ms, x.c.clone()))
359 {
360 continue;
361 }
362 if x.kind == TESTED && !x.lessons.iter().all(|l| given.get(x.s.as_str()).is_some_and(|g| g.contains(l.as_str()))) {
363 continue;
364 }
365 team.push(Outcome {
366 kind: x.kind.clone(),
367 session: x.s.clone(),
368 lessons: x.lessons.clone(),
369 ms: x.ms,
370 repo: x.r.clone(),
371 p: x.p.clone(),
372 channel: x.channel.clone(),
373 cause: x.c.clone(),
374 credit: x.credit,
375 ..Default::default()
376 });
377 }
378 (local, team)
379}
380
381pub fn read(path: &Path) -> Vec<Outcome> {
382 std::fs::read_to_string(path).unwrap_or_default().lines().filter_map(|l| serde_json::from_str(l).ok()).collect()
383}
384
385pub fn append(path: &Path, o: &Outcome) -> std::io::Result<()> {
387 crate::learn::append_jsonl(path, o)?;
388 let kind = match o.kind.as_str() {
391 CHANGED => crate::stats::CHANGED,
392 IGNORED => crate::stats::IGNORED,
393 CORRECTED => crate::stats::CORRECTED,
394 NOT_CORRECTED => crate::stats::NOT_CORRECTED,
395 REGRETTED => crate::stats::REGRETTED,
396 TESTED => crate::stats::TESTED,
397 _ => return Ok(()),
398 };
399 if let Some(home) = path.parent() {
400 crate::stats::count(home, kind, &o.session, |e| {
401 e.repo = o.repo.clone();
402 e.lessons = o.lessons.clone();
403 e.channel = o.channel.clone();
404 });
405 }
406 Ok(())
407}
408
409pub fn asked_before(outcomes: &[Outcome], session: &str, lesson: &str, ms: i64) -> bool {
411 outcomes.iter().any(|o| o.kind == ASKED && o.session == session && o.ms < ms && o.lessons.iter().any(|l| l == lesson))
412}
413
414pub fn has(outcomes: &[Outcome], kind: &str, session: &str, lesson: &str) -> bool {
416 outcomes.iter().any(|o| o.kind == kind && o.session == session && o.lessons.iter().any(|l| l == lesson))
417}
418
419pub fn contradicted(outcomes: &[Outcome], session: &str, ms: i64, target: Option<&str>, supersedes: &[String]) -> Vec<String> {
425 let mut out: Vec<String> = supersedes.to_vec();
426 if let Some(t) = target {
427 let sides: Vec<String> = outcomes
428 .iter()
429 .filter(|o| o.kind == ASKED && o.session == session && o.ms < ms)
430 .flat_map(|o| &o.groups)
431 .filter(|g| g.iter().any(|x| x == t))
432 .flatten()
433 .filter(|x| *x != t)
434 .cloned()
435 .collect();
436 match sides.is_empty() {
437 true => out.push(t.to_string()),
438 false => out.extend(sides),
439 }
440 }
441 out.retain(|l| asked_before(outcomes, session, l, ms) && !has(outcomes, CORRECTED, session, l) && !has(outcomes, VETOED, session, l));
442 out.sort();
443 out.dedup();
444 out
445}
446
447pub fn free(o: &Outcome) -> bool {
449 o.channel == "early" || o.channel == "queued"
450}
451
452pub fn missed(outcomes: &[Outcome], session: &str, ms: i64, target: Option<&str>, supersedes: &[String]) -> Vec<String> {
458 let mut out: Vec<String> = supersedes.iter().cloned().chain(target.map(str::to_string)).collect();
459 let applied_unchosen = |l: &str| {
460 outcomes.iter().any(|o| o.kind == GRADED && o.session == session && o.ms < ms && o.lessons.iter().any(|x| x == l) && !o.chosen.iter().any(|x| x == l))
461 };
462 let questioned = |l: &str| outcomes.iter().any(|o| o.kind == ASKED && !free(o) && o.session == session && o.ms < ms && o.lessons.iter().any(|x| x == l));
463 out.retain(|l| applied_unchosen(l) && !questioned(l) && !has(outcomes, MISSED, session, l));
464 out.sort();
465 out.dedup();
466 out
467}
468
469fn credited(outcomes: &[Outcome], cause: &str, lesson: &str) -> bool {
471 outcomes.iter().any(|o| o.kind == REGRETTED && o.cause == cause && o.lessons.iter().any(|l| l == lesson))
472}
473
474#[allow(clippy::too_many_arguments)]
485pub fn regretted(outcomes: &[Outcome], session: &str, before_ms: i64, cause: &str, target: &[String], strength: f64, ms: i64, repo: &str) -> Vec<Outcome> {
486 if session.is_empty() || before_ms <= 0 || strength <= 0.0 {
487 return Vec::new();
488 }
489 let mut asked: Vec<&str> =
490 outcomes.iter().filter(|o| o.kind == ASKED && o.session == session && o.ms < before_ms).flat_map(|o| o.lessons.iter().map(String::as_str)).collect();
491 asked.sort_unstable();
492 asked.dedup();
493 if asked.iter().any(|l| target.iter().any(|t| t == l)) {
494 asked.retain(|l| target.iter().any(|t| t == l));
495 }
496 asked.retain(|l| !has(outcomes, CORRECTED, session, l) && !has(outcomes, VETOED, session, l));
497 let credit = (strength / asked.len().max(1) as f64).min(1.0);
498 asked
499 .into_iter()
500 .filter(|l| !credited(outcomes, cause, l))
501 .map(|l| Outcome {
502 kind: REGRETTED.into(),
503 session: session.into(),
504 lessons: vec![l.into()],
505 ms,
506 repo: repo.into(),
507 cause: cause.into(),
508 credit,
509 ..Default::default()
510 })
511 .collect()
512}
513
514pub fn tested(outcomes: &[Outcome], session: &str, tests: &str) -> Vec<Outcome> {
524 let mut runs: Vec<(i64, bool)> = tests
525 .lines()
526 .filter_map(|l| l.split_once(' '))
527 .filter_map(|(w, m)| Some((m.trim().parse::<i64>().ok()?, matches!(w, "pass" | "fail").then_some(w == "pass")?)))
528 .collect();
529 runs.sort_unstable();
530 let mut first: HashMap<&str, (i64, &str)> = HashMap::new();
532 for o in outcomes.iter().filter(|o| o.kind == ASKED && !free(o) && o.session == session) {
533 for l in &o.lessons {
534 let e = first.entry(l.as_str()).or_insert((o.ms, o.repo.as_str()));
535 if o.ms < e.0 {
536 *e = (o.ms, o.repo.as_str());
537 }
538 }
539 }
540 let mut out: Vec<Outcome> = first
541 .into_iter()
542 .filter(|(l, _)| ![TESTED, CORRECTED, VETOED, IGNORED].iter().any(|k| has(outcomes, k, session, l)))
543 .filter_map(|(l, (at, repo))| {
544 let after = &runs[runs.partition_point(|r| r.0 <= at)..];
545 let &(ms, pass) = after.last()?;
546 (pass && (after.iter().any(|r| !r.1) || has(outcomes, CHANGED, session, l))).then(|| Outcome {
547 kind: TESTED.into(),
548 session: session.into(),
549 lessons: vec![l.into()],
550 ms,
551 repo: repo.into(),
552 ..Default::default()
553 })
554 })
555 .collect();
556 out.sort_by(|a, b| a.lessons.cmp(&b.lessons));
557 out
558}
559
560pub fn settle(outcomes: &[Outcome], session: &str, ms: i64) -> Vec<Outcome> {
565 let mine = outcomes.iter().filter(|o| o.session == session);
566 let resolved: std::collections::HashSet<&str> = mine.clone().filter(|o| resolves(&o.kind)).flat_map(|o| o.lessons.iter().map(String::as_str)).collect();
567 let mut asked: Vec<(&str, &str)> = mine
568 .filter(|o| o.kind == ASKED)
569 .flat_map(|o| o.lessons.iter().map(move |l| (l.as_str(), o.repo.as_str())))
570 .filter(|(l, _)| !resolved.contains(l))
571 .collect();
572 asked.sort();
573 asked.dedup_by_key(|(l, _)| *l);
574 asked
575 .into_iter()
576 .map(|(l, repo)| Outcome { kind: NOT_CORRECTED.into(), session: session.into(), lessons: vec![l.into()], ms, repo: repo.into(), ..Default::default() })
577 .collect()
578}
579
580fn resolves(kind: &str) -> bool {
582 matches!(kind, CORRECTED | NOT_CORRECTED | VETOED)
583}
584
585pub fn idle_sessions<'a>(outcomes: &'a [Outcome], last_episode: &HashMap<String, i64>, now: i64) -> Vec<&'a str> {
590 let mut newest: HashMap<&str, i64> = HashMap::new();
591 let (mut asked, mut resolved) = (std::collections::HashSet::new(), std::collections::HashSet::new());
592 for o in outcomes {
593 let into = match o.kind.as_str() {
594 ASKED => &mut asked,
595 k if resolves(k) => &mut resolved,
596 _ => continue,
597 };
598 into.extend(o.lessons.iter().map(|l| (o.session.as_str(), l.as_str())));
599 if o.kind == ASKED {
600 let m = newest.entry(o.session.as_str()).or_insert(o.ms);
601 *m = (*m).max(o.ms);
602 }
603 }
604 let open: std::collections::HashSet<&str> = asked.difference(&resolved).map(|(s, _)| *s).collect();
605 let mut idle: Vec<&str> = newest
606 .into_iter()
607 .filter(|&(s, ms)| open.contains(s) && ms.max(last_episode.get(s).copied().unwrap_or(i64::MIN)) <= now - IDLE_MS)
608 .map(|(s, _)| s)
609 .collect();
610 idle.sort();
611 idle
612}
613
614pub fn weight(not_corrected: u32, corrected: u32) -> f64 {
625 let (n, c) = (not_corrected as f64, corrected as f64);
626 (1.0 + n) / (1.0 + n + 2.0 * c)
627}
628
629pub fn weights(outcomes: &[Outcome]) -> HashMap<String, f64> {
631 counts(outcomes).into_iter().map(|(l, (n, c))| (l, weight(n, c))).collect()
632}
633
634pub fn counts(outcomes: &[Outcome]) -> HashMap<String, (u32, u32)> {
637 sessions(outcomes).into_iter().map(|(l, ss)| (l.to_string(), tally(ss.values(), i64::MIN))).collect()
638}
639
640pub fn sessions(outcomes: &[Outcome]) -> HashMap<&str, HashMap<&str, (bool, i64)>> {
645 let mut out: HashMap<&str, HashMap<&str, (bool, i64)>> = HashMap::new();
646 for o in outcomes {
647 let corrected = match o.kind.as_str() {
648 CORRECTED | VETOED => true,
649 NOT_CORRECTED => false,
650 _ => continue,
651 };
652 for l in &o.lessons {
653 let r = out.entry(l.as_str()).or_default().entry(o.session.as_str()).or_insert((corrected, o.ms));
654 r.0 |= corrected;
655 r.1 = r.1.min(o.ms);
656 }
657 }
658 out
659}
660
661pub fn tally<'a>(sessions: impl IntoIterator<Item = &'a (bool, i64)>, since: i64) -> (u32, u32) {
664 sessions.into_iter().filter(|(_, ms)| *ms > since).fold((0, 0), |(n, c), (corrected, _)| match corrected {
665 true => (n, c + 1),
666 false => (n + 1, c),
667 })
668}
669
670#[cfg(test)]
671mod tests {
672 use super::*;
673
674 fn o(kind: &str, session: &str, lessons: &[&str], ms: i64) -> Outcome {
675 Outcome {
676 kind: kind.into(),
677 session: session.into(),
678 lessons: lessons.iter().map(|l| l.to_string()).collect(),
679 ms,
680 repo: "r".into(),
681 ..Default::default()
682 }
683 }
684
685 #[test]
686 fn missed_is_an_applied_unasked_lesson_a_later_correction_lands_on() {
687 let graded = |ms: i64, chosen: &[&str]| Outcome {
688 kind: GRADED.into(),
689 session: "s".into(),
690 lessons: vec!["A".into(), "B".into()],
691 ms,
692 chosen: chosen.iter().map(|x| x.to_string()).collect(),
693 ..Default::default()
694 };
695 let none: Vec<String> = Vec::new();
696 let log = vec![graded(10, &["B"])];
697 assert_eq!(missed(&log, "s", 20, Some("A"), &none), ["A"]);
698 assert_eq!(missed(&log, "s", 20, None, &["A".into(), "B".into()]), ["A"], "B was chosen");
699 assert!(missed(&log, "s", 5, Some("A"), &none).is_empty(), "a correction before the check");
700 assert!(missed(&log, "t", 20, Some("A"), &none).is_empty(), "another session");
701 assert!(missed(&log, "s", 20, Some("C"), &none).is_empty(), "never applied");
702 let mut asked = log.clone();
703 asked.push(o(ASKED, "s", &["A"], 15));
704 assert!(missed(&asked, "s", 20, Some("A"), &none).is_empty(), "asked later in the session: not a miss");
705 let mut free = log.clone();
706 free.push(Outcome { channel: "queued".into(), ..o(ASKED, "s", &["A"], 15) });
707 free.push(Outcome { channel: "early".into(), ..o(ASKED, "s", &["A"], 16) });
708 assert_eq!(missed(&free, "s", 20, Some("A"), &none), ["A"], "handed over free, never asked: still a miss");
709 let mut done = log.clone();
710 done.push(o(MISSED, "s", &["A"], 18));
711 assert!(missed(&done, "s", 20, Some("A"), &none).is_empty(), "recorded once");
712 }
713
714 #[test]
715 fn weight_formula() {
716 assert_eq!(weight(0, 0), 1.0);
717 assert_eq!(weight(5, 0), 1.0);
718 assert!((weight(0, 1) - 1.0 / 3.0).abs() < 1e-12);
719 assert_eq!(weight(1, 1), 0.5);
720 assert!(weight(0, 3) < weight(0, 1));
721 }
722
723 #[test]
724 fn weights_count_each_session_once_and_corrected_wins() {
725 let all = vec![
726 o(ASKED, "s1", &["A", "B"], 10),
727 o(CORRECTED, "s1", &["A"], 20),
728 o(CORRECTED, "s1", &["A"], 30), o(NOT_CORRECTED, "s1", &["A"], 40), o(NOT_CORRECTED, "s1", &["B"], 40),
731 o(NOT_CORRECTED, "s2", &["A"], 50),
732 ];
733 let w = weights(&all);
734 assert_eq!(w["A"], weight(1, 1));
735 assert_eq!(w["B"], 1.0);
736 assert!(!w.contains_key("C"));
737 }
738
739 #[test]
740 fn a_session_whose_tests_go_green_after_an_ask_credits_it_once() {
741 let mut log = vec![o(ASKED, "s", &["A", "B"], 10), o(ASKED, "t", &["A"], 10)];
742 let got = tested(&log, "s", "fail 20\npass 30\n");
744 assert_eq!(got.iter().map(|x| (x.kind.as_str(), x.lessons[0].as_str(), x.ms)).collect::<Vec<_>>(), [(TESTED, "A", 30), (TESTED, "B", 30)]);
745 log.extend(got);
747 assert!(tested(&log, "s", "fail 20\npass 30\n").is_empty());
748 assert!(tested(&log, "t", "").is_empty(), "no tests file: nothing");
750 }
751
752 #[test]
753 fn a_green_run_credits_only_after_the_ask_and_with_a_reason() {
754 let log = vec![o(ASKED, "s", &["A"], 50)];
755 assert!(tested(&log, "s", "fail 20\npass 30\n").is_empty(), "red and green before the ask");
756 assert!(tested(&log, "s", "fail 20\npass 60\n").is_empty(), "the failure before the ask: green proves nothing new");
757 assert!(tested(&log, "s", "fail 60\npass 70\nfail 80\n").is_empty(), "last run red");
758 assert!(tested(&log, "s", "pass 60\n").is_empty(), "green alone, nothing changed");
759 assert!(tested(&log, "s", "pass 60\npass 70 \ngarbage\n").is_empty());
760 let early = [Outcome { channel: "early".into(), ..o(ASKED, "e", &["A"], 10) }];
761 assert!(tested(&early, "e", "fail 20\npass 30\n").is_empty(), "a free delivery asked nothing");
762 let changed = [log.clone(), vec![o(CHANGED, "s", &["A"], 55)]].concat();
764 assert_eq!(tested(&changed, "s", "pass 60\n").len(), 1);
765 for k in [CORRECTED, VETOED, IGNORED] {
768 let worse = [log.clone(), vec![o(k, "s", &["A"], 55)]].concat();
769 assert!(tested(&worse, "s", "fail 60\npass 70\n").is_empty(), "{k}");
770 }
771 let twice = [log.clone(), vec![o(ASKED, "s", &["A"], 65)]].concat();
773 assert_eq!(tested(&twice, "s", "pass 70\nfail 60\n")[0].ms, 70);
774 }
775
776 #[test]
777 fn settle_marks_only_unresolved_asks_of_this_session() {
778 let all = vec![o(ASKED, "s1", &["B", "A"], 10), o(ASKED, "s1", &["A", "C"], 15), o(CORRECTED, "s1", &["A"], 20), o(ASKED, "s2", &["D"], 10)];
779 let settled = settle(&all, "s1", 99);
780 assert_eq!(settled, vec![o(NOT_CORRECTED, "s1", &["B"], 99), o(NOT_CORRECTED, "s1", &["C"], 99)]);
781 assert!(asked_before(&all, "s1", "A", 11) && !asked_before(&all, "s1", "C", 15) && !asked_before(&all, "s2", "A", 99));
782 }
783
784 #[test]
785 fn contradicted_marks_superseded_asks_and_the_other_sides_of_a_disagreement() {
786 let mut ask = o(ASKED, "s1", &["A", "B", "C", "D"], 10);
787 ask.groups = vec![vec!["A".into(), "B".into()]];
788 let all = vec![ask, o(ASKED, "s1", &["E"], 30), o(CORRECTED, "s1", &["D"], 15)];
789 let ids = |v: &[&str]| v.iter().map(|x| x.to_string()).collect::<Vec<_>>();
790 assert_eq!(contradicted(&all, "s1", 20, Some("C"), &[]), ["C"]);
792 assert_eq!(contradicted(&all, "s1", 20, Some("A"), &[]), ["B"]);
794 assert_eq!(contradicted(&all, "s1", 20, None, &ids(&["C", "E", "D", "Z"])), ["C"]);
796 assert!(contradicted(&all, "s2", 20, Some("C"), &ids(&["A"])).is_empty(), "another session asked nothing");
797 let mut vetoed = all.clone();
798 vetoed.push(o(VETOED, "s1", &["C"], 12));
799 assert!(contradicted(&vetoed, "s1", 20, Some("C"), &[]).is_empty(), "a vetoed lesson already has the stronger result");
800 }
801
802 #[test]
803 fn a_vetoed_lesson_is_not_settled_and_counts_as_corrected() {
804 let all = vec![o(ASKED, "s1", &["A", "B"], 10), o(VETOED, "s1", &["A"], 20)];
805 assert_eq!(settle(&all, "s1", 99), vec![o(NOT_CORRECTED, "s1", &["B"], 99)]);
806 assert_eq!(sessions(&all)["A"]["s1"], (true, 20));
807 assert_eq!(weights(&all)["A"], weight(0, 1));
808 }
809
810 #[test]
811 fn sessions_keep_the_earliest_result_and_tally_filters_by_it() {
812 let all =
813 vec![o(NOT_CORRECTED, "s1", &["A"], 10), o(CORRECTED, "s2", &["A"], 20), o(NOT_CORRECTED, "s2", &["A"], 25), o(NOT_CORRECTED, "s3", &["A"], 30)];
814 let s = sessions(&all);
815 assert_eq!(s["A"]["s2"], (true, 20));
816 assert_eq!(tally(s["A"].values(), i64::MIN), (2, 1));
817 assert_eq!(tally(s["A"].values(), 20), (1, 0));
818 }
819
820 #[test]
821 fn idle_sessions_need_old_asks_and_old_episodes() {
822 let all = vec![o(ASKED, "old", &["A"], 0), o(ASKED, "busy", &["A"], 0), o(ASKED, "fresh", &["A"], 50), o(CORRECTED, "x", &["A"], 0)];
823 let episodes = HashMap::from([("busy".to_string(), 60)]);
824 assert_eq!(idle_sessions(&all, &episodes, IDLE_MS + 10), ["old"]);
825 assert_eq!(idle_sessions(&all, &episodes, IDLE_MS + 60), ["busy", "fresh", "old"]);
826 }
827
828 #[test]
829 fn idle_sessions_skip_settled_ones() {
830 let all = vec![
831 o(ASKED, "done", &["A", "B"], 0),
832 o(NOT_CORRECTED, "done", &["A"], 1),
833 o(CORRECTED, "done", &["B"], 1),
834 o(ASKED, "vetoed", &["A"], 0),
835 o(VETOED, "vetoed", &["A"], 1),
836 o(ASKED, "half", &["A", "B"], 0),
837 o(NOT_CORRECTED, "half", &["A"], 1),
838 ];
839 assert_eq!(idle_sessions(&all, &HashMap::new(), IDLE_MS + 10), ["half"], "every ask of the others has its result");
840 }
841
842 #[test]
843 fn regretted_blames_asks_before_the_decision_shared_or_by_target() {
844 let mut graded = o(GRADED, "s", &["G"], 5);
845 graded.chosen = vec![];
846 let log = vec![graded, o(ASKED, "s", &["B", "A"], 10), o(ASKED, "s", &["C"], 30), o(ASKED, "t", &["D"], 10)];
847 let ids = |v: &[&str]| v.iter().map(|x| x.to_string()).collect::<Vec<_>>();
848 let blame = |log: &[Outcome], target: &[String]| regretted(log, "s", 20, "R1", target, 0.5, 99, "r");
849 let got = blame(&log, &[]);
850 let want = |l: &str, credit: f64| Outcome { cause: "R1".into(), credit, ..o(REGRETTED, "s", &[l], 99) };
851 assert_eq!(got, vec![want("A", 0.25), want("B", 0.25)], "shared; C was asked after the decision, G never shown");
852 assert_eq!(blame(&log, &ids(&["B", "Z"])), vec![want("B", 0.5)], "the merge target takes it all");
853 assert_eq!(blame(&log, &ids(&["Z"])), got, "a target not asked: shared");
854 let mut corrected = log.clone();
855 corrected.push(o(CORRECTED, "s", &["B"], 15));
856 assert_eq!(blame(&corrected, &[]), vec![want("A", 0.5)], "corrected outranks regretted");
857 let mut vetoed = log.clone();
858 vetoed.push(o(VETOED, "s", &["B"], 15));
859 assert_eq!(blame(&vetoed, &[]), vec![want("A", 0.5)], "vetoed outranks regretted");
860 assert!(blame(&corrected, &ids(&["B"])).is_empty(), "the target took it as a correction: nobody else is blamed");
861 assert!(blame(&vetoed, &ids(&["B", "Z"])).is_empty(), "the target took it as a veto: nobody else is blamed");
862 assert_eq!(blame(&corrected, &ids(&["A", "B"])), vec![want("A", 0.5)], "the uncorrected target keeps its share");
863 let mut again = log.clone();
864 again.extend(got.clone());
865 assert!(blame(&again, &[]).is_empty(), "credited once per cause");
866 let mut half = log.clone();
867 half.push(got[0].clone());
868 assert_eq!(blame(&half, &[]), vec![want("B", 0.25)], "a partial run completes with the same share");
869 assert_eq!(regretted(&again, "s", 20, "R2", &[], 0.5, 99, "r").len(), 2, "another regret blames again");
870 assert!(regretted(&log, "u", 20, "R1", &[], 0.5, 99, "r").is_empty(), "another session asked nothing");
871 assert!(regretted(&log, "", 20, "R1", &[], 0.5, 99, "r").is_empty());
872 assert!(regretted(&log, "s", 0, "R1", &[], 0.5, 99, "r").is_empty(), "no decision time");
873 assert!(regretted(&log, "s", 20, "R1", &[], 0.0, 99, "r").is_empty(), "not strong");
874 let old: Outcome = serde_json::from_str(r#"{"kind":"asked","session":"s","lessons":["A"],"ms":1,"repo":"r"}"#).unwrap();
876 assert_eq!(old, o(ASKED, "s", &["A"], 1));
877 assert!(!serde_json::to_string(&old).unwrap().contains("credit"));
878 let line = serde_json::to_string(&got[0]).unwrap();
879 assert!(line.contains(r#""cause":"R1","credit":0.25"#), "{line}");
880 assert_eq!(serde_json::from_str::<Outcome>(&line).unwrap(), got[0]);
881 }
882
883 #[test]
884 fn a_line_from_before_propensities_reads_and_writes_back_unchanged() {
885 let line = r#"{"kind":"asked","session":"s","lessons":["A","B"],"ms":1,"repo":"r","channel":"stop","grades":[3,1],"ranks":[0,1],"applied":2}"#;
886 let o: Outcome = serde_json::from_str(line).unwrap();
887 assert!(o.p.is_empty());
888 assert_eq!(serde_json::to_string(&o).unwrap(), line);
889 let with = Outcome { p: vec![round_p(1.0), round_p(1.0 / 3.0)], ..o };
890 assert!(serde_json::to_string(&with).unwrap().ends_with(r#""applied":2,"p":[1.0,0.3333]}"#));
891 }
892
893 fn tmp(name: &str) -> std::path::PathBuf {
894 let d = std::env::temp_dir().join(format!("borg-outcome-{name}-{}", std::process::id()));
895 let _ = std::fs::remove_dir_all(&d);
896 std::fs::create_dir_all(&d).unwrap();
897 d
898 }
899
900 fn lines(path: &Path, v: &[String]) {
901 std::fs::write(path, v.iter().map(|l| format!("{l}\n")).collect::<String>()).unwrap();
902 }
903
904 fn changed(team: &[Outcome], lesson: &str) -> u32 {
905 let ls = [crate::conscience::Challenge { id: lesson.into(), ..Default::default() }];
906 crate::memory::derive(team, &ls, None)[lesson].changed
907 }
908
909 #[test]
910 fn share_redacts() {
911 let mut a = o(ASKED, "session-raw", &["A"], 5);
912 a.repo = "secret-repo".into();
913 a.groups = vec![vec!["A".into(), "B".into()]];
914 a.grades = vec![3];
915 a.ranks = vec![0];
916 a.p = vec![0.5];
917 let x = share(&a, "n1").unwrap();
918 let line = serde_json::to_string(&x).unwrap();
919 for raw in ["session-raw", "secret-repo", "groups", "grades", "ranks", "\"id\"", "version"] {
920 assert!(!line.contains(raw), "{raw} in {line}");
921 }
922 assert_eq!((x.s, x.r, x.p, x.node), (tag("session-raw"), tag("secret-repo"), vec![0.5], "n1".to_string()));
923 assert_eq!(tag("s").len(), 32);
924 for kind in [GRADED, "veto"] {
925 assert!(share(&o(kind, "s", &["A"], 1), "n1").is_none(), "{kind} is not shared");
926 }
927 let regret = share(&Outcome { cause: "episode-raw".into(), credit: 0.25, ..o(REGRETTED, "s", &["A"], 1) }, "n1").unwrap();
928 let line = serde_json::to_string(®ret).unwrap();
929 assert!(!line.contains("episode-raw") && line.contains(&tag("episode-raw")) && line.contains(r#""credit":0.25"#), "{line}");
930 }
931
932 #[test]
933 fn pool_fields_stay_local() {
934 let a = Outcome { pool: vec!["A".into(), "B".into()], base: vec![1.25, 0.5], scoped: vec![true, false], p: vec![0.5], ..o(ASKED, "s", &["A"], 5) };
935 let line = serde_json::to_string(&a).unwrap();
936 assert_eq!(serde_json::from_str::<Outcome>(&line).unwrap(), a, "round trip");
937 let shared = serde_json::to_string(&share(&a, "n1").unwrap()).unwrap();
938 for field in ["pool", "base", "scoped"] {
939 assert!(line.contains(field) && !shared.contains(field), "{field}: {shared}");
940 }
941 assert!(!serde_json::to_string(&o(ASKED, "s", &["A"], 5)).unwrap().contains("pool"), "empty: not written");
942 }
943
944 #[test]
945 fn publish_is_idempotent() {
946 let home = tmp("publish");
947 assert_eq!(publish(&home), 0, "no outcomes, nothing to publish");
948 let mut asked = o(ASKED, "s1", &["A"], 1);
949 asked.p = vec![1.0];
950 lines(&home.join("outcomes.jsonl"), &[asked, o(GRADED, "s1", &["A"], 1), o(CHANGED, "s1", &["A"], 2)].map(|x| serde_json::to_string(&x).unwrap()));
951 assert_eq!(publish(&home), 2, "graded stays local");
952 let first = std::fs::read_to_string(home.join(TEAM_FILE)).unwrap();
953 assert_eq!(publish(&home), 0);
954 assert_eq!(std::fs::read_to_string(home.join(TEAM_FILE)).unwrap(), first);
955 assert!(first.contains(r#""p":[1.0]"#) && !first.contains("\"s1\""), "{first}");
956 assert_eq!(std::fs::read_to_string(home.join(PUBLISHED)).unwrap(), std::fs::metadata(home.join("outcomes.jsonl")).unwrap().len().to_string());
958 std::fs::OpenOptions::new()
959 .append(true)
960 .open(home.join("outcomes.jsonl"))
961 .and_then(|mut f| std::io::Write::write_all(&mut f, format!("{}\n", serde_json::to_string(&o(IGNORED, "s2", &["A"], 3)).unwrap()).as_bytes()))
962 .unwrap();
963 assert_eq!(publish(&home), 1);
964 assert_eq!(publish(&home), 0);
965 std::fs::remove_file(home.join(TEAM_FILE)).unwrap();
967 assert_eq!(publish(&home), 3);
968 let _ = std::fs::remove_dir_all(home);
969 }
970
971 fn team_store(name: &str, shuffle: bool) -> std::path::PathBuf {
975 let home = tmp(name);
976 let mut local = o(CHANGED, "s1", &["L1"], 10);
977 local.repo = "app".into();
978 lines(&home.join("outcomes.jsonl"), &[serde_json::to_string(&local).unwrap()]);
979 let remote = |kind: &str, s: &str, node: &str| {
980 serde_json::to_string(&Shared {
981 kind: kind.into(),
982 s: s.into(),
983 lessons: vec!["L1".into()],
984 ms: 20,
985 node: node.into(),
986 r: tag("web"),
987 ..Default::default()
988 })
989 .unwrap()
990 };
991 let mut team = vec![
992 serde_json::to_string(&share(&local, "me").unwrap()).unwrap(),
993 remote(CHANGED, "x", "n2"),
994 remote(CHANGED, "x", "n3"),
995 remote("hacked", "y", "n2"),
996 remote("veto", "z", "n2"),
997 ];
998 if shuffle {
999 team.reverse();
1000 team.swap(0, 2);
1001 }
1002 lines(&home.join(TEAM_FILE), &team);
1003 home
1004 }
1005
1006 #[test]
1007 fn read_team_drops_own_copies_and_keeps_remote() {
1008 let home = team_store("team", false);
1009 let team = team_view(&home.join("lessons.jsonl"), true);
1010 assert_eq!(changed(&team, "L1"), 2, "{team:?}");
1011 assert_eq!(team.iter().filter(|o| o.session == "x").count(), 1, "one remote line per (kind, session, lessons, ms)");
1012 assert!(team.iter().all(|o| o.kind != "hacked" && o.kind != "veto"), "unshared kinds, an older peer's hive verdict among them, are ignored");
1013 assert!(team.iter().all(|o| o.repo != "app"), "local repos compare as tags");
1014 let _ = std::fs::remove_dir_all(home);
1015 }
1016
1017 #[test]
1018 fn read_team_is_order_independent() {
1019 let (a, b) = (team_store("order-a", false), team_store("order-b", true));
1020 let ls = [crate::conscience::Challenge { id: "L1".into(), ..Default::default() }];
1021 let m = |h: &Path| crate::memory::derive(&team_view(&h.join("lessons.jsonl"), true), &ls, None);
1022 assert_eq!(m(&a), m(&b));
1023 let _ = std::fs::remove_dir_all(a);
1024 let _ = std::fs::remove_dir_all(b);
1025 }
1026
1027 #[test]
1031 fn a_peer_derives_the_same_memory_from_the_team_file() {
1032 let (a, b) = (tmp("fold-a"), tmp("fold-b"));
1033 let regret = Outcome { cause: "episode-1".into(), credit: 0.5, ..o(REGRETTED, "s2", &["L1"], 40) };
1034 let log = [
1035 o(GRADED, "s0", &["L1"], 5),
1036 o(ASKED, "s1", &["L1"], 10),
1037 o(CHANGED, "s1", &["L1"], 20),
1038 o(VETOED, "s1", &["L1"], 30),
1039 o(ASKED, "s2", &["L1"], 35),
1040 regret,
1041 ];
1042 lines(&a.join("outcomes.jsonl"), &log.map(|x| serde_json::to_string(&x).unwrap()));
1043 publish(&a);
1044 std::fs::copy(a.join(TEAM_FILE), b.join(TEAM_FILE)).unwrap();
1045 let ls = [crate::conscience::Challenge { id: "L1".into(), origin_ms: 1, ..Default::default() }];
1046 let m = |h: &Path| crate::memory::derive(&team_view(&h.join("lessons.jsonl"), true), &ls, None);
1047 let (here, there) = (m(&a), m(&b));
1048 assert_eq!((here["L1"].vetoed, here["L1"].changed, here["L1"].regretted), (1, 0, 0.5), "{here:?}");
1049 assert_eq!(here, there);
1050 let _ = std::fs::remove_dir_all(a);
1051 let _ = std::fs::remove_dir_all(b);
1052 }
1053
1054 #[test]
1058 fn a_remote_line_under_a_local_tag_counts_on_peers_only() {
1059 let (victim, peer) = (tmp("forged-victim"), tmp("forged-peer"));
1060 let own = o(CHANGED, "s1", &["L1"], 10);
1061 let forged = Shared { kind: CORRECTED.into(), s: tag("s1"), lessons: vec!["L1".into()], ms: 11, node: "n2".into(), ..Default::default() };
1062 let team = [serde_json::to_string(&share(&own, "me").unwrap()).unwrap(), serde_json::to_string(&forged).unwrap()];
1063 lines(&victim.join("outcomes.jsonl"), &[serde_json::to_string(&own).unwrap()]);
1064 lines(&victim.join(TEAM_FILE), &team);
1065 lines(&peer.join(TEAM_FILE), &team);
1066 let ls = [crate::conscience::Challenge { id: "L1".into(), ..Default::default() }];
1067 let m = |h: &Path| crate::memory::derive(&team_view(&h.join("lessons.jsonl"), true), &ls, None).remove("L1").unwrap();
1068 assert_eq!((m(&victim).changed, m(&victim).corrected), (1, 0), "the victim never sees the override");
1069 assert_eq!((m(&peer).changed, m(&peer).corrected), (0, 1), "a peer counts the forged correction");
1070 let _ = std::fs::remove_dir_all(victim);
1071 let _ = std::fs::remove_dir_all(peer);
1072 }
1073
1074 #[test]
1076 fn a_peer_green_run_counts_only_for_lessons_its_session_was_given() {
1077 let home = tmp("tested-team");
1078 let asked = share(&o(ASKED, "p1", &["L1"], 10), "peer").unwrap();
1079 let ok = share(&o(TESTED, "p1", &["L1"], 20), "peer").unwrap();
1080 let stray = share(&o(TESTED, "p1", &["L2"], 20), "peer").unwrap();
1081 let alone = share(&o(TESTED, "p2", &["L1"], 20), "peer").unwrap();
1082 let verdict = Shared { kind: "verified".into(), ..share(&o(ASKED, "p3", &["L1"], 10), "peer").unwrap() };
1084 let unbacked = share(&o(TESTED, "p3", &["L1"], 20), "peer").unwrap();
1085 let all = [&asked, &ok, &stray, &alone, &verdict, &unbacked];
1086 lines(&home.join(TEAM_FILE), &all.map(|x| serde_json::to_string(x).unwrap()));
1087 let ls = ["L1", "L2"].map(|id| crate::conscience::Challenge { id: id.into(), ..Default::default() });
1088 let m = crate::memory::derive(&team_view(&home.join("lessons.jsonl"), true), &ls, None);
1089 assert_eq!((m["L1"].tested, m["L2"].tested), (1, 0), "{m:?}");
1090 let _ = std::fs::remove_dir_all(home);
1091 }
1092
1093 #[test]
1094 fn off_keeps_beliefs_local() {
1095 let home = team_store("off", false);
1096 let local = team_view(&home.join("lessons.jsonl"), false);
1097 assert_eq!(local, read(&home.join("outcomes.jsonl")));
1098 assert_eq!(changed(&local, "L1"), 1);
1099 let _ = std::fs::remove_dir_all(home);
1100 }
1101
1102 #[test]
1105 fn concurrent_appends_keep_every_record() {
1106 let home = tmp("concurrent");
1107 let p = home.join("outcomes.jsonl");
1108 let (writers, each) = (8, 2000);
1109 std::thread::scope(|scope| {
1110 for w in 0..writers {
1111 let p = &p;
1112 scope.spawn(move || (0..each).for_each(|i| append(p, &o(ASKED, &format!("s{w}"), &["A"], i)).unwrap()));
1113 }
1114 });
1115 assert_eq!(read(&p).len(), writers * each as usize);
1116 let _ = std::fs::remove_dir_all(home);
1117 }
1118
1119 #[test]
1120 fn append_and_read_round_trip() {
1121 let dir = std::env::temp_dir().join(format!("borg-outcome-{}", std::process::id()));
1122 let p = dir.join("outcomes.jsonl");
1123 let _ = std::fs::remove_file(&p);
1124 append(&p, &o(ASKED, "s", &["A"], 1)).unwrap();
1125 append(&p, &o(CORRECTED, "s", &["A"], 2)).unwrap();
1126 assert_eq!(read(&p), vec![o(ASKED, "s", &["A"], 1), o(CORRECTED, "s", &["A"], 2)]);
1127 let _ = std::fs::remove_dir_all(dir);
1128 }
1129}