1use std::collections::HashMap;
92use std::path::Path;
93
94use serde::{Deserialize, Serialize};
95
96pub const ASKED: &str = "asked";
97pub const CORRECTED: &str = "asked_then_corrected";
98pub const NOT_CORRECTED: &str = "asked_not_corrected";
99pub const CHANGED: &str = "asked_then_changed";
100pub const IGNORED: &str = "asked_ignored";
101pub const GRADED: &str = "graded";
105pub const MISSED: &str = "applied_then_corrected";
108pub const VETOED: &str = "vetoed";
111pub const REGRETTED: &str = "regretted";
115
116pub const TESTED: &str = "tested";
119
120pub const UNCLEAR: &str = "asked_unclear";
125
126pub const NOT_ARISEN: &str = "asked_not_arisen";
132
133pub const REMAINS: &str = "asked_consequence_remains";
137
138pub const AT_END: &str = "end";
142
143#[must_use]
146pub fn settles(kind: &str) -> bool {
147 matches!(kind, CHANGED | IGNORED | NOT_ARISEN)
148}
149
150pub const DELIVERY: &str = "delivery";
156
157pub const ENDED: &str = "ended";
162
163pub const PLACEBO: &str = "placebo";
169
170#[must_use]
175pub fn is_result(o: &Outcome) -> bool {
176 o.channel != PLACEBO && !matches!(o.kind.as_str(), UNCLEAR | REMAINS | NOT_ARISEN)
177}
178
179pub const UNJUDGED_NONE: &str = "judged";
186pub const UNJUDGED_TRIES: &str = "tries";
187pub const UNJUDGED_SLOTS: &str = "slots";
188pub const UNJUDGED_FAILED: &str = "failed";
189pub const UNJUDGED_NO_CALL: &str = "no_call";
190pub const UNJUDGED_LATER: &str = "later";
191
192pub const DECIDED_BUSY: &str = "lock_busy";
200pub const DECIDED_NO_LOCK: &str = "lock_failed";
201pub const DECIDED_NO_RECORD: &str = "unrecorded";
202
203pub const CORRECTED_W: u32 = 2;
210
211pub const IDLE_MS: i64 = crate::clock::DAY_MS;
214
215#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
216pub struct Outcome {
217 pub kind: String,
218 pub session: String,
219 pub lessons: Vec<String>,
220 pub ms: i64,
221 pub repo: String,
222 #[serde(default, skip_serializing_if = "Vec::is_empty")]
224 pub groups: Vec<Vec<String>>,
225 #[serde(default, skip_serializing_if = "String::is_empty")]
230 pub channel: String,
231 #[serde(default, skip_serializing_if = "Vec::is_empty")]
236 pub grades: Vec<u8>,
237 #[serde(default, skip_serializing_if = "Vec::is_empty")]
238 pub ranks: Vec<u16>,
239 #[serde(default, skip_serializing_if = "is_zero")]
240 pub applied: u16,
241 #[serde(default, skip_serializing_if = "Vec::is_empty")]
243 pub chosen: Vec<String>,
244 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
247 pub explore: bool,
248 #[serde(default, skip_serializing_if = "String::is_empty")]
250 pub cause: String,
251 #[serde(default, skip_serializing_if = "is_zero_f")]
253 pub credit: f64,
254 #[serde(default, skip_serializing_if = "Vec::is_empty")]
262 pub p: Vec<f64>,
263 #[serde(default, skip_serializing_if = "Vec::is_empty")]
269 pub pool: Vec<String>,
270 #[serde(default, skip_serializing_if = "Vec::is_empty")]
271 pub base: Vec<f64>,
272 #[serde(default, skip_serializing_if = "Vec::is_empty")]
273 pub scoped: Vec<bool>,
274 #[serde(default, skip_serializing_if = "String::is_empty")]
281 pub check: String,
282 #[serde(default, skip_serializing_if = "String::is_empty")]
285 pub policy: String,
286 #[serde(default, skip_serializing_if = "String::is_empty")]
288 pub fit: String,
289 #[serde(default, skip_serializing_if = "String::is_empty")]
299 pub seen: String,
300 #[serde(default, skip_serializing_if = "String::is_empty")]
301 pub source: String,
302 #[serde(default, skip_serializing_if = "Vec::is_empty")]
304 pub free: Vec<String>,
305 #[serde(default, skip_serializing_if = "Vec::is_empty")]
309 pub pf: Vec<f64>,
310 #[serde(default, skip_serializing_if = "Vec::is_empty")]
313 pub placebo: Vec<String>,
314 #[serde(default, skip_serializing_if = "is_default")]
315 pub psi: f64,
316 #[serde(default, skip_serializing_if = "Vec::is_empty")]
322 pub nofree: Vec<String>,
323 #[serde(default, skip_serializing_if = "Option::is_none")]
329 pub xk: Option<u32>,
330 #[serde(default, skip_serializing_if = "Option::is_none")]
331 pub xs: Option<u32>,
332 #[serde(default, skip_serializing_if = "Option::is_none")]
336 pub alpha: Option<f64>,
337 #[serde(default, skip_serializing_if = "Vec::is_empty")]
344 pub tail: Vec<String>,
345 #[serde(default, skip_serializing_if = "is_default")]
346 pub pt: f64,
347 #[serde(default, skip_serializing_if = "Vec::is_empty")]
352 pub rel: Vec<f64>,
353 #[serde(default, skip_serializing_if = "String::is_empty")]
358 pub route: String,
359 #[serde(default, skip_serializing_if = "String::is_empty")]
360 pub reason: String,
361 #[serde(default, skip_serializing_if = "is_default")]
370 pub eligible: u32,
371 #[serde(default, skip_serializing_if = "is_default")]
372 pub shown: u32,
373 #[serde(default, skip_serializing_if = "is_default")]
374 pub tokens: u32,
375 #[serde(default, skip_serializing_if = "is_default")]
379 pub budget: u32,
380 #[serde(default, skip_serializing_if = "Vec::is_empty")]
381 pub fill: Vec<u16>,
382 #[serde(default, skip_serializing_if = "String::is_empty")]
386 pub model: String,
387 #[serde(default, skip_serializing_if = "String::is_empty")]
388 pub prompt_hash: String,
389 #[serde(default, skip_serializing_if = "String::is_empty")]
390 pub harness: String,
391 #[serde(default, skip_serializing_if = "String::is_empty")]
392 pub version: String,
393 #[serde(default, skip_serializing_if = "is_default")]
394 pub dur_ms: u64,
395 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
396 pub failed: bool,
397 #[serde(default, skip_serializing_if = "is_default")]
400 pub usd: f64,
401 #[serde(default, skip_serializing_if = "is_default")]
405 pub judge: u8,
406 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
407 pub stripped: bool,
408 #[serde(default, skip_serializing_if = "Vec::is_empty")]
412 pub reasons: Vec<String>,
413 #[serde(default, skip_serializing_if = "String::is_empty")]
416 pub at: String,
417 #[serde(default, skip_serializing_if = "Vec::is_empty")]
421 pub moves: Vec<String>,
422 #[serde(default, skip_serializing_if = "Vec::is_empty")]
423 pub whys: Vec<String>,
424 #[serde(default, skip_serializing_if = "is_default")]
428 pub xtokens: u32,
429 #[serde(default, skip_serializing_if = "String::is_empty")]
432 pub line: String,
433 #[serde(default, skip_serializing_if = "String::is_empty")]
436 pub run: String,
437}
438
439pub const ROUTE_ASKED: &str = "asked";
446pub const ROUTE_FREE: &str = "free";
447pub const ROUTE_PLACEBO: &str = "placebo";
448pub const ROUTE_DROPPED_DONE: &str = "dropped_done";
449pub const ROUTE_DROPPED_CHARS: &str = "dropped_chars";
450pub const ROUTE_NEVER: &str = "never_delivered";
451pub const ROUTE_SUPERSEDED: &str = "superseded";
452pub const ROUTES: [&str; 7] = [ROUTE_ASKED, ROUTE_FREE, ROUTE_PLACEBO, ROUTE_DROPPED_DONE, ROUTE_DROPPED_CHARS, ROUTE_NEVER, ROUTE_SUPERSEDED];
453
454#[must_use]
461pub fn policy_of(o: &Outcome) -> &str {
462 match o.policy.as_str() {
463 "" if o.explore || o.p.iter().any(|p| *p > 0.0 && *p < 1.0) => "attention",
464 "" => "fixed",
465 p => p,
466 }
467}
468
469#[must_use]
474pub fn free_of(o: &Outcome) -> Vec<String> {
475 use crate::conscience::{self, Challenge};
476 if !o.policy.is_empty() || o.grades.len() != o.lessons.len() {
477 return o.free.clone();
478 }
479 if o.grades.contains(&3) {
480 return match policy_of(o) {
481 "attention" => o.lessons.iter().filter(|l| !o.chosen.contains(l)).take(conscience::CAP).cloned().collect(),
482 _ => Vec::new(),
483 };
484 }
485 let ls: Vec<Challenge> = o.lessons.iter().map(|id| Challenge { id: id.clone(), ..Default::default() }).collect();
486 let applied: Vec<(&Challenge, u8)> = ls.iter().zip(o.grades.iter().copied()).collect();
487 conscience::graded_capped(&applied, None).1.into_iter().map(|c| c.id.clone()).collect()
488}
489
490fn is_default<T: Default + PartialEq>(v: &T) -> bool {
491 *v == T::default()
492}
493
494pub fn round_p(p: f64) -> f64 {
496 (p * 1e4).round() / 1e4
497}
498
499fn is_zero(v: &u16) -> bool {
500 *v == 0
501}
502
503fn is_zero_f(v: &f64) -> bool {
504 *v == 0.0
505}
506
507pub fn path_beside(lessons: &Path) -> std::path::PathBuf {
509 lessons.with_file_name("outcomes.jsonl")
510}
511
512pub const TEAM_FILE: &str = "outcomes-team.jsonl";
514pub const PUBLISHED: &str = "outcomes-team.published";
516
517pub const SHARED_KINDS: [&str; 9] = [ASKED, CHANGED, IGNORED, CORRECTED, NOT_CORRECTED, MISSED, VETOED, REGRETTED, TESTED];
539
540pub fn tag(s: &str) -> String {
544 let mut h = blake3::Hasher::new();
545 h.update(b"borg-outcome-v1\0");
546 h.update(s.as_bytes());
547 h.finalize().to_hex()[..32].to_string()
548}
549
550fn tag_repo(repo: &str) -> String {
552 if repo.is_empty() { String::new() } else { tag(repo) }
553}
554
555#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
558pub struct Shared {
559 pub kind: String,
560 pub s: String,
562 pub lessons: Vec<String>,
563 pub ms: i64,
564 pub node: String,
566 #[serde(default, skip_serializing_if = "Vec::is_empty")]
567 pub p: Vec<f64>,
568 #[serde(default, skip_serializing_if = "String::is_empty")]
569 pub channel: String,
570 #[serde(default, skip_serializing_if = "String::is_empty")]
572 pub r: String,
573 #[serde(default, skip_serializing_if = "String::is_empty")]
575 pub c: String,
576 #[serde(default, skip_serializing_if = "is_zero_f")]
578 pub credit: f64,
579}
580
581pub fn share(o: &Outcome, node: &str) -> Option<Shared> {
584 (SHARED_KINDS.contains(&o.kind.as_str()) && is_result(o)).then(|| Shared {
585 kind: o.kind.clone(),
586 s: tag(&o.session),
587 lessons: o.lessons.clone(),
588 ms: o.ms,
589 node: node.into(),
590 p: o.p.clone(),
591 channel: o.channel.clone(),
592 r: tag_repo(&o.repo),
593 c: tag_repo(&o.cause),
594 credit: o.credit,
595 })
596}
597
598pub fn on() -> bool {
600 std::env::var("BORG_SHARE_OUTCOMES").as_deref() != Ok("off")
601}
602
603pub fn publish(home: &Path) -> usize {
609 let local = home.join("outcomes.jsonl");
610 let team = home.join(TEAM_FILE);
611 let mark = home.join(PUBLISHED);
612 let len = std::fs::metadata(&local).map_or(0, |m| m.len());
613 if !on() || len == 0 {
614 return 0;
615 }
616 let published = std::fs::read_to_string(&mark).ok().and_then(|t| t.trim().parse::<u64>().ok());
617 if team.exists() && published == Some(len) {
618 return 0;
619 }
620 let mine = std::fs::read_to_string(&local).unwrap_or_default();
621 let node = crate::stats::node(home);
622 let text = std::fs::read_to_string(&team).unwrap_or_default();
623 let mut have: std::collections::HashSet<String> = text.lines().map(str::to_string).collect();
624 let mut out = String::new();
625 let personal = crate::reach::personal_ids(home);
627 let records = mine.lines().filter_map(|l| serde_json::from_str::<Outcome>(l).ok()).filter_map(|mut o| {
628 let named = !o.lessons.is_empty();
629 o.lessons.retain(|id| !personal.contains(id));
630 (!named || !o.lessons.is_empty()).then_some(o)
631 });
632 for line in records.filter_map(|o| share(&o, &node)).filter_map(|x| serde_json::to_string(&x).ok()) {
633 if have.insert(line.clone()) {
634 out.push_str(&line);
635 out.push('\n');
636 }
637 }
638 if out.is_empty() {
639 let _ = std::fs::write(&mark, mine.len().to_string());
640 return 0;
641 }
642 use std::io::Write;
643 let open = text.as_bytes().last().is_some_and(|c| *c != b'\n');
644 let written = std::fs::OpenOptions::new()
645 .create(true)
646 .append(true)
647 .open(&team)
648 .and_then(|mut f| f.write_all(format!("{}{out}", if open { "\n" } else { "" }).as_bytes()));
649 if written.is_err() {
650 return 0;
651 }
652 let _ = std::fs::write(&mark, mine.len().to_string());
653 out.lines().count()
654}
655
656pub fn read_team(lessons: &Path) -> Vec<Outcome> {
659 team_view(lessons, on())
660}
661
662pub fn team_view(lessons: &Path, on: bool) -> Vec<Outcome> {
668 let (mut out, team) = team_parts(lessons, on);
669 if on {
670 for o in &mut out {
671 o.repo = tag_repo(&o.repo);
672 o.cause = tag_repo(&o.cause);
673 }
674 }
675 out.extend(team);
676 out
677}
678
679pub fn team_parts(lessons: &Path, on: bool) -> (Vec<Outcome>, Vec<Outcome>) {
686 let local = read(&path_beside(lessons));
687 if !on {
688 return (local, Vec::new());
689 }
690 let mine: std::collections::HashSet<String> = local.iter().map(|o| tag(&o.session)).collect();
691 let text = std::fs::read_to_string(lessons.with_file_name(TEAM_FILE)).unwrap_or_default();
692 let lines: Vec<Shared> = text.lines().filter_map(|l| serde_json::from_str::<Shared>(l).ok()).collect();
693 let mut given: std::collections::HashMap<&str, std::collections::HashSet<&str>> = std::collections::HashMap::new();
695 for x in lines.iter().filter(|x| SHARED_KINDS.contains(&x.kind.as_str()) && x.kind != TESTED) {
696 given.entry(x.s.as_str()).or_default().extend(x.lessons.iter().map(String::as_str));
697 }
698 let mut seen: std::collections::HashSet<(String, String, Vec<String>, i64, String)> = std::collections::HashSet::new();
699 let mut team = Vec::new();
700 for x in lines.iter() {
701 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()))
702 {
703 continue;
704 }
705 if x.kind == TESTED && !x.lessons.iter().all(|l| given.get(x.s.as_str()).is_some_and(|g| g.contains(l.as_str()))) {
706 continue;
707 }
708 team.push(Outcome {
709 kind: x.kind.clone(),
710 session: x.s.clone(),
711 lessons: x.lessons.clone(),
712 ms: x.ms,
713 repo: x.r.clone(),
714 p: x.p.clone(),
715 channel: x.channel.clone(),
716 cause: x.c.clone(),
717 credit: x.credit,
718 ..Default::default()
719 });
720 }
721 (local, team)
722}
723
724pub fn read(path: &Path) -> Vec<Outcome> {
725 std::fs::read_to_string(path).unwrap_or_default().lines().filter_map(|l| serde_json::from_str(l).ok()).collect()
726}
727
728pub fn append(path: &Path, o: &Outcome) -> std::io::Result<()> {
731 crate::learn::append_jsonl(path, o)?;
732 if let Some(home) = path.parent() {
733 count_result(home, o);
734 }
735 Ok(())
736}
737
738pub fn count_result(home: &Path, o: &Outcome) {
744 if !is_result(o) {
745 return;
746 }
747 let kind = match o.kind.as_str() {
748 CHANGED => crate::stats::CHANGED,
749 IGNORED => crate::stats::IGNORED,
750 CORRECTED => crate::stats::CORRECTED,
751 NOT_CORRECTED => crate::stats::NOT_CORRECTED,
752 REGRETTED => crate::stats::REGRETTED,
753 TESTED => crate::stats::TESTED,
754 UNCLEAR | REMAINS | NOT_ARISEN => return,
756 _ => return,
757 };
758 crate::stats::count(home, kind, &o.session, |e| {
759 e.repo = o.repo.clone();
760 e.lessons = o.lessons.clone();
761 e.channel = o.channel.clone();
762 });
763}
764
765pub fn asked_before(outcomes: &[Outcome], session: &str, lesson: &str, ms: i64) -> bool {
767 outcomes.iter().any(|o| o.kind == ASKED && o.session == session && o.ms < ms && o.lessons.iter().any(|l| l == lesson))
768}
769
770pub fn has(outcomes: &[Outcome], kind: &str, session: &str, lesson: &str) -> bool {
772 outcomes.iter().any(|o| o.kind == kind && o.session == session && o.lessons.iter().any(|l| l == lesson))
773}
774
775pub fn contradicted(outcomes: &[Outcome], session: &str, ms: i64, target: Option<&str>, supersedes: &[String]) -> Vec<String> {
781 let mut out: Vec<String> = supersedes.to_vec();
782 if let Some(t) = target {
783 let sides: Vec<String> = outcomes
784 .iter()
785 .filter(|o| o.kind == ASKED && o.session == session && o.ms < ms)
786 .flat_map(|o| &o.groups)
787 .filter(|g| g.iter().any(|x| x == t))
788 .flatten()
789 .filter(|x| *x != t)
790 .cloned()
791 .collect();
792 match sides.is_empty() {
793 true => out.push(t.to_string()),
794 false => out.extend(sides),
795 }
796 }
797 out.retain(|l| asked_before(outcomes, session, l, ms) && !has(outcomes, CORRECTED, session, l) && !has(outcomes, VETOED, session, l));
798 out.sort();
799 out.dedup();
800 out
801}
802
803pub fn free(o: &Outcome) -> bool {
805 o.channel == "early" || o.channel == "queued"
806}
807
808pub fn missed(outcomes: &[Outcome], session: &str, ms: i64, target: Option<&str>, supersedes: &[String]) -> Vec<String> {
814 let mut out: Vec<String> = supersedes.iter().cloned().chain(target.map(str::to_string)).collect();
815 let applied_unchosen = |l: &str| {
816 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))
817 };
818 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));
819 out.retain(|l| applied_unchosen(l) && !questioned(l) && !has(outcomes, MISSED, session, l));
820 out.sort();
821 out.dedup();
822 out
823}
824
825fn credited(outcomes: &[Outcome], cause: &str, lesson: &str) -> bool {
827 outcomes.iter().any(|o| o.kind == REGRETTED && o.cause == cause && o.lessons.iter().any(|l| l == lesson))
828}
829
830#[allow(clippy::too_many_arguments)]
841pub fn regretted(outcomes: &[Outcome], session: &str, before_ms: i64, cause: &str, target: &[String], strength: f64, ms: i64, repo: &str) -> Vec<Outcome> {
842 if session.is_empty() || before_ms <= 0 || strength <= 0.0 {
843 return Vec::new();
844 }
845 let mut asked: Vec<&str> =
846 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();
847 asked.sort_unstable();
848 asked.dedup();
849 if asked.iter().any(|l| target.iter().any(|t| t == l)) {
850 asked.retain(|l| target.iter().any(|t| t == l));
851 }
852 asked.retain(|l| !has(outcomes, CORRECTED, session, l) && !has(outcomes, VETOED, session, l));
853 let credit = (strength / asked.len().max(1) as f64).min(1.0);
854 asked
855 .into_iter()
856 .filter(|l| !credited(outcomes, cause, l))
857 .map(|l| Outcome {
858 kind: REGRETTED.into(),
859 session: session.into(),
860 lessons: vec![l.into()],
861 ms,
862 repo: repo.into(),
863 cause: cause.into(),
864 credit,
865 ..Default::default()
866 })
867 .collect()
868}
869
870pub fn tested(outcomes: &[Outcome], session: &str, tests: &str) -> Vec<Outcome> {
882 let mut runs: Vec<(i64, bool)> = tests
883 .lines()
884 .filter_map(|l| l.split_once(' '))
885 .filter_map(|(w, m)| Some((m.trim().parse::<i64>().ok()?, matches!(w, "pass" | "fail").then_some(w == "pass")?)))
886 .collect();
887 runs.sort_unstable();
888 let mut first: HashMap<&str, (i64, &str)> = HashMap::new();
890 for o in outcomes.iter().filter(|o| o.kind == ASKED && !free(o) && o.session == session) {
891 for l in &o.lessons {
892 let e = first.entry(l.as_str()).or_insert((o.ms, o.repo.as_str()));
893 if o.ms < e.0 {
894 *e = (o.ms, o.repo.as_str());
895 }
896 }
897 }
898 let not_arisen_on_ask = |l: &str, at: i64| {
901 outcomes
902 .iter()
903 .any(|o| o.kind == NOT_ARISEN && o.session == session && o.channel != PLACEBO && !free(o) && o.ms >= at && o.lessons.iter().any(|x| x == l))
904 };
905 let mut out: Vec<Outcome> = first
906 .into_iter()
907 .filter(|(l, _)| ![TESTED, CORRECTED, VETOED, IGNORED].iter().any(|k| has(outcomes, k, session, l)))
908 .filter(|&(l, (at, _))| !not_arisen_on_ask(l, at) || has(outcomes, CHANGED, session, l))
909 .filter_map(|(l, (at, repo))| {
910 let after = &runs[runs.partition_point(|r| r.0 <= at)..];
911 let &(ms, pass) = after.last()?;
912 (pass && (after.iter().any(|r| !r.1) || has(outcomes, CHANGED, session, l))).then(|| Outcome {
913 kind: TESTED.into(),
914 session: session.into(),
915 lessons: vec![l.into()],
916 ms,
917 repo: repo.into(),
918 ..Default::default()
919 })
920 })
921 .collect();
922 out.sort_by(|a, b| a.lessons.cmp(&b.lessons));
923 out
924}
925
926pub fn settle(outcomes: &[Outcome], session: &str, ms: i64) -> Vec<Outcome> {
930 settle_with(outcomes, session, ms, &|_| UNJUDGED_NO_CALL)
931}
932
933pub fn settle_with(outcomes: &[Outcome], session: &str, ms: i64, unjudged: &dyn Fn(&str) -> &'static str) -> Vec<Outcome> {
955 let mine: Vec<&Outcome> = outcomes.iter().filter(|o| o.session == session).collect();
956 let resolved: std::collections::HashSet<&str> = mine.iter().filter(|o| resolves(o)).flat_map(|o| o.lessons.iter().map(String::as_str)).collect();
957 let mut verdicts: HashMap<&str, Vec<(i64, &str, bool)>> = HashMap::new();
959 for o in mine.iter().filter(|o| settles(&o.kind) && o.channel != PLACEBO) {
960 for l in &o.lessons {
961 verdicts.entry(l.as_str()).or_default().push((o.ms, o.check.as_str(), free(o)));
962 }
963 }
964 let mut asked: std::collections::BTreeMap<&str, (&str, i64, &str, bool)> = std::collections::BTreeMap::new();
967 let mut latest: HashMap<&str, i64> = HashMap::new();
970 for o in mine.iter().filter(|o| o.kind == ASKED || is_placebo(o)) {
971 for l in o.lessons.iter().map(String::as_str) {
972 let t = latest.entry(l).or_insert(o.ms);
973 *t = (*t).max(o.ms);
974 if o.kind != ASKED || resolved.contains(l) {
975 continue;
976 }
977 let e = asked.entry(l).or_insert((o.repo.as_str(), o.ms, o.check.as_str(), free(o)));
978 e.0 = e.0.min(o.repo.as_str());
979 if o.ms >= e.1 {
980 (e.1, e.2, e.3) = (o.ms, o.check.as_str(), free(o));
981 }
982 }
983 }
984 let judged = |l: &str, at: i64, check: &str, as_free: bool| {
990 verdicts.get(l).is_some_and(|v| v.iter().any(|&(t, c, f)| f == as_free && (t > at || (!check.is_empty() && c == check))))
991 };
992 let mut out: Vec<Outcome> = asked
993 .into_iter()
994 .map(|(l, (repo, at, check, as_free))| Outcome {
995 kind: NOT_CORRECTED.into(),
996 session: session.into(),
997 lessons: vec![l.into()],
998 ms,
999 repo: repo.into(),
1000 check: check.into(),
1001 reason: if judged(l, at, check, as_free) { UNJUDGED_NONE } else { unjudged(l) }.into(),
1002 version: env!("CARGO_PKG_VERSION").into(),
1005 ..Default::default()
1006 })
1007 .collect();
1008 let mut placebos: Vec<Outcome> = open_placebos(&mine)
1009 .into_iter()
1010 .map(|(l, d)| Outcome {
1011 kind: NOT_CORRECTED.into(),
1012 session: session.into(),
1013 lessons: vec![l.into()],
1014 ms,
1015 repo: d.repo.clone(),
1016 channel: PLACEBO.into(),
1017 check: d.check.clone(),
1018 reason: if latest.get(l).is_some_and(|&t| t > d.ms) { UNJUDGED_LATER } else { unjudged(l) }.into(),
1020 ..Default::default()
1021 })
1022 .collect();
1023 placebos.sort_by(|a, b| (&a.lessons, &a.check).cmp(&(&b.lessons, &b.check)));
1024 out.extend(placebos);
1025 out
1026}
1027
1028fn resolves(o: &Outcome) -> bool {
1031 matches!(o.kind.as_str(), CORRECTED | NOT_CORRECTED | VETOED) && o.channel != PLACEBO
1032}
1033
1034fn is_placebo(o: &Outcome) -> bool {
1037 o.kind == DELIVERY && o.route == ROUTE_PLACEBO
1038}
1039
1040fn closes(r: &Outcome, d: &Outcome) -> bool {
1047 match r.kind.as_str() {
1048 NOT_CORRECTED => r.check == d.check,
1049 k if settles(k) && (r.check.is_empty() || d.check.is_empty()) => r.ms > d.ms,
1050 k if settles(k) => r.check == d.check,
1051 _ => false,
1052 }
1053}
1054
1055fn open_placebos<'a>(mine: &[&'a Outcome]) -> Vec<(&'a str, &'a Outcome)> {
1058 let mut closers: HashMap<&str, Vec<&Outcome>> = HashMap::new();
1059 for &o in mine.iter().filter(|o| o.channel == PLACEBO && !is_placebo(o)) {
1060 for l in &o.lessons {
1061 closers.entry(l.as_str()).or_default().push(o);
1062 }
1063 }
1064 mine.iter()
1065 .copied()
1066 .filter(|o| is_placebo(o))
1067 .flat_map(|d| d.lessons.iter().map(move |l| (l.as_str(), d)))
1068 .filter(|&(l, d)| !closers.get(l).is_some_and(|rs| rs.iter().any(|r| closes(r, d))))
1069 .collect()
1070}
1071
1072pub fn idle_sessions<'a>(outcomes: &'a [Outcome], last_episode: &HashMap<String, i64>, now: i64) -> Vec<&'a str> {
1078 let mut newest: HashMap<&str, i64> = HashMap::new();
1079 let (mut asked, mut resolved) = (std::collections::HashSet::new(), std::collections::HashSet::new());
1080 let mut placebos: HashMap<&str, Vec<&Outcome>> = HashMap::new();
1082 for o in outcomes {
1083 if o.channel == PLACEBO || is_placebo(o) {
1084 placebos.entry(o.session.as_str()).or_default().push(o);
1085 }
1086 if o.kind == ASKED || is_placebo(o) {
1087 let m = newest.entry(o.session.as_str()).or_insert(o.ms);
1088 *m = (*m).max(o.ms);
1089 }
1090 let into = match o.kind.as_str() {
1091 ASKED => &mut asked,
1092 _ if resolves(o) => &mut resolved,
1093 _ => continue,
1094 };
1095 into.extend(o.lessons.iter().map(|l| (o.session.as_str(), l.as_str())));
1096 }
1097 let mut open: std::collections::HashSet<&str> = asked.difference(&resolved).map(|(s, _)| *s).collect();
1098 open.extend(placebos.iter().filter(|(_, rs)| !open_placebos(rs).is_empty()).map(|(s, _)| *s));
1099 let mut idle: Vec<&str> = newest
1100 .into_iter()
1101 .filter(|&(s, ms)| open.contains(s) && ms.max(last_episode.get(s).copied().unwrap_or(i64::MIN)) <= now - IDLE_MS)
1102 .map(|(s, _)| s)
1103 .collect();
1104 idle.sort();
1105 idle
1106}
1107
1108pub fn weight(not_corrected: u32, corrected: u32) -> f64 {
1119 let (n, c) = (not_corrected as f64, corrected as f64);
1120 (1.0 + n) / (1.0 + n + f64::from(CORRECTED_W) * c)
1121}
1122
1123pub fn weights(outcomes: &[Outcome]) -> HashMap<String, f64> {
1125 counts(outcomes).into_iter().map(|(l, (n, c))| (l, weight(n, c))).collect()
1126}
1127
1128pub fn counts(outcomes: &[Outcome]) -> HashMap<String, (u32, u32)> {
1131 sessions(outcomes).into_iter().map(|(l, ss)| (l.to_string(), tally(ss.values(), i64::MIN))).collect()
1132}
1133
1134pub fn sessions(outcomes: &[Outcome]) -> HashMap<&str, HashMap<&str, (bool, i64)>> {
1139 let mut out: HashMap<&str, HashMap<&str, (bool, i64)>> = HashMap::new();
1140 for o in outcomes.iter().filter(|o| is_result(o)) {
1142 let corrected = match o.kind.as_str() {
1143 CORRECTED | VETOED => true,
1144 NOT_CORRECTED => false,
1145 _ => continue,
1146 };
1147 for l in &o.lessons {
1148 let r = out.entry(l.as_str()).or_default().entry(o.session.as_str()).or_insert((corrected, o.ms));
1149 r.0 |= corrected;
1150 r.1 = r.1.min(o.ms);
1151 }
1152 }
1153 out
1154}
1155
1156pub fn tally<'a>(sessions: impl IntoIterator<Item = &'a (bool, i64)>, since: i64) -> (u32, u32) {
1159 sessions.into_iter().filter(|(_, ms)| *ms > since).fold((0, 0), |(n, c), (corrected, _)| match corrected {
1160 true => (n, c + 1),
1161 false => (n + 1, c),
1162 })
1163}
1164
1165#[cfg(test)]
1166mod tests {
1167 use super::*;
1168
1169 fn o(kind: &str, session: &str, lessons: &[&str], ms: i64) -> Outcome {
1170 Outcome {
1171 kind: kind.into(),
1172 session: session.into(),
1173 lessons: lessons.iter().map(|l| l.to_string()).collect(),
1174 ms,
1175 repo: "r".into(),
1176 ..Default::default()
1177 }
1178 }
1179
1180 #[test]
1181 fn missed_is_an_applied_unasked_lesson_a_later_correction_lands_on() {
1182 let graded = |ms: i64, chosen: &[&str]| Outcome {
1183 kind: GRADED.into(),
1184 session: "s".into(),
1185 lessons: vec!["A".into(), "B".into()],
1186 ms,
1187 chosen: chosen.iter().map(|x| x.to_string()).collect(),
1188 ..Default::default()
1189 };
1190 let none: Vec<String> = Vec::new();
1191 let log = vec![graded(10, &["B"])];
1192 assert_eq!(missed(&log, "s", 20, Some("A"), &none), ["A"]);
1193 assert_eq!(missed(&log, "s", 20, None, &["A".into(), "B".into()]), ["A"], "B was chosen");
1194 assert!(missed(&log, "s", 5, Some("A"), &none).is_empty(), "a correction before the check");
1195 assert!(missed(&log, "t", 20, Some("A"), &none).is_empty(), "another session");
1196 assert!(missed(&log, "s", 20, Some("C"), &none).is_empty(), "never applied");
1197 let mut asked = log.clone();
1198 asked.push(o(ASKED, "s", &["A"], 15));
1199 assert!(missed(&asked, "s", 20, Some("A"), &none).is_empty(), "asked later in the session: not a miss");
1200 let mut free = log.clone();
1201 free.push(Outcome { channel: "queued".into(), ..o(ASKED, "s", &["A"], 15) });
1202 free.push(Outcome { channel: "early".into(), ..o(ASKED, "s", &["A"], 16) });
1203 assert_eq!(missed(&free, "s", 20, Some("A"), &none), ["A"], "handed over free, never asked: still a miss");
1204 let mut done = log.clone();
1205 done.push(o(MISSED, "s", &["A"], 18));
1206 assert!(missed(&done, "s", 20, Some("A"), &none).is_empty(), "recorded once");
1207 }
1208
1209 #[test]
1210 fn weight_formula() {
1211 assert_eq!(weight(0, 0), 1.0);
1212 assert_eq!(weight(5, 0), 1.0);
1213 assert!((weight(0, 1) - 1.0 / 3.0).abs() < 1e-12);
1214 assert_eq!(weight(1, 1), 0.5);
1215 assert!(weight(0, 3) < weight(0, 1));
1216 }
1217
1218 #[test]
1219 fn weights_count_each_session_once_and_corrected_wins() {
1220 let all = vec![
1221 o(ASKED, "s1", &["A", "B"], 10),
1222 o(CORRECTED, "s1", &["A"], 20),
1223 o(CORRECTED, "s1", &["A"], 30), o(NOT_CORRECTED, "s1", &["A"], 40), o(NOT_CORRECTED, "s1", &["B"], 40),
1226 o(NOT_CORRECTED, "s2", &["A"], 50),
1227 ];
1228 let w = weights(&all);
1229 assert_eq!(w["A"], weight(1, 1));
1230 assert_eq!(w["B"], 1.0);
1231 assert!(!w.contains_key("C"));
1232 }
1233
1234 #[test]
1235 fn a_session_whose_tests_go_green_after_an_ask_credits_it_once() {
1236 let mut log = vec![o(ASKED, "s", &["A", "B"], 10), o(ASKED, "t", &["A"], 10)];
1237 let got = tested(&log, "s", "fail 20\npass 30\n");
1239 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)]);
1240 log.extend(got);
1242 assert!(tested(&log, "s", "fail 20\npass 30\n").is_empty());
1243 assert!(tested(&log, "t", "").is_empty(), "no tests file: nothing");
1245 }
1246
1247 #[test]
1248 fn a_green_run_credits_only_after_the_ask_and_with_a_reason() {
1249 let log = vec![o(ASKED, "s", &["A"], 50)];
1250 assert!(tested(&log, "s", "fail 20\npass 30\n").is_empty(), "red and green before the ask");
1251 assert!(tested(&log, "s", "fail 20\npass 60\n").is_empty(), "the failure before the ask: green proves nothing new");
1252 assert!(tested(&log, "s", "fail 60\npass 70\nfail 80\n").is_empty(), "last run red");
1253 assert!(tested(&log, "s", "pass 60\n").is_empty(), "green alone, nothing changed");
1254 assert!(tested(&log, "s", "pass 60\npass 70 \ngarbage\n").is_empty());
1255 let early = [Outcome { channel: "early".into(), ..o(ASKED, "e", &["A"], 10) }];
1256 assert!(tested(&early, "e", "fail 20\npass 30\n").is_empty(), "a free delivery asked nothing");
1257 let changed = [log.clone(), vec![o(CHANGED, "s", &["A"], 55)]].concat();
1259 assert_eq!(tested(&changed, "s", "pass 60\n").len(), 1);
1260 for k in [CORRECTED, VETOED, IGNORED] {
1263 let worse = [log.clone(), vec![o(k, "s", &["A"], 55)]].concat();
1264 assert!(tested(&worse, "s", "fail 60\npass 70\n").is_empty(), "{k}");
1265 }
1266 let twice = [log.clone(), vec![o(ASKED, "s", &["A"], 65)]].concat();
1268 assert_eq!(tested(&twice, "s", "pass 70\nfail 60\n")[0].ms, 70);
1269 }
1270
1271 #[test]
1272 fn settle_marks_only_unresolved_asks_of_this_session() {
1273 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)];
1274 let settled = settle(&all, "s1", 99);
1275 assert_eq!(settled, vec![unjudged("s1", "B", UNJUDGED_NO_CALL), unjudged("s1", "C", UNJUDGED_NO_CALL)]);
1276 assert!(asked_before(&all, "s1", "A", 11) && !asked_before(&all, "s1", "C", 15) && !asked_before(&all, "s2", "A", 99));
1277 }
1278
1279 fn unjudged(session: &str, lesson: &str, reason: &str) -> Outcome {
1281 Outcome { reason: reason.into(), version: env!("CARGO_PKG_VERSION").into(), ..o(NOT_CORRECTED, session, &[lesson], 99) }
1282 }
1283
1284 #[test]
1285 fn a_settled_lesson_says_why_it_had_no_verdict_and_names_its_latest_check() {
1286 let ask = |lessons: &[&str], ms: i64, check: &str| Outcome { check: check.into(), ..o(ASKED, "s", lessons, ms) };
1287 let placebo = Outcome { channel: PLACEBO.into(), ..o(CHANGED, "s", &["C"], 30) };
1288 let all = vec![ask(&["A", "B", "C"], 10, "c1"), ask(&["B"], 20, "c2"), o(IGNORED, "s", &["A"], 25), placebo];
1289 let why = |l: &str| if l == "B" { UNJUDGED_TRIES } else { UNJUDGED_SLOTS };
1290 let got = settle_with(&all, "s", 99, &why);
1291 let want = |l: &str, reason: &str, check: &str| Outcome { check: check.into(), ..unjudged("s", l, reason) };
1292 assert_eq!(got, vec![want("A", UNJUDGED_NONE, "c1"), want("B", UNJUDGED_TRIES, "c2"), want("C", UNJUDGED_SLOTS, "c1")], "a placebo's verdict is none");
1293 }
1294
1295 #[test]
1299 fn a_verdict_on_an_earlier_delivery_leaves_the_later_question_unjudged() {
1300 let ask = |channel: &str, ms: i64, check: &str| Outcome { channel: channel.into(), check: check.into(), ..o(ASKED, "s", &["L"], ms) };
1301 let verdict = |channel: &str, ms: i64, check: &str| Outcome { channel: channel.into(), check: check.into(), ..o(IGNORED, "s", &["L"], ms) };
1302 let why = |log: &[Outcome]| settle_with(log, "s", 99, &|_| UNJUDGED_NO_CALL).into_iter().map(|x| (x.reason, x.check)).collect::<Vec<_>>();
1303 let said = |reason: &str, check: &str| vec![(reason.to_string(), check.to_string())];
1304 let free_then_asked = vec![ask("queued", 10, "cF"), verdict("queued", 20, "cF"), ask("stop", 20, "cS")];
1306 assert_eq!(why(&free_then_asked), said(UNJUDGED_NO_CALL, "cS"));
1307 let answered = [free_then_asked, vec![verdict("stop", 30, "cS")]].concat();
1308 assert_eq!(why(&answered), said(UNJUDGED_NONE, "cS"));
1309 assert_eq!(why(&[ask("stop", 20, "cS"), verdict("stop", 30, "cL")]), said(UNJUDGED_NONE, "cS"));
1311 assert_eq!(why(&[ask("stop", 20, "cS"), verdict("stop", 15, "cS")]), said(UNJUDGED_NONE, "cS"));
1313 }
1314
1315 #[test]
1321 fn a_free_deliverys_verdict_after_a_later_question_leaves_the_question_unjudged() {
1322 let ask = |channel: &str, ms: i64, check: &str| Outcome { channel: channel.into(), check: check.into(), ..o(ASKED, "s", &["L"], ms) };
1323 let verdict = |channel: &str, ms: i64, check: &str| Outcome { channel: channel.into(), check: check.into(), ..o(CHANGED, "s", &["L"], ms) };
1324 let why = |log: &[Outcome]| settle_with(log, "s", 99, &|_| UNJUDGED_TRIES).into_iter().map(|x| (x.reason, x.check)).collect::<Vec<_>>();
1325 let said = |reason: &str, check: &str| vec![(reason.to_string(), check.to_string())];
1326 let stuck = vec![ask("queued", 10, "cA"), ask("live", 20, "cB"), verdict("queued", 30, "cA")];
1327 assert_eq!(why(&stuck), said(UNJUDGED_TRIES, "cB"));
1328 let early = vec![ask("early", 10, "cA"), ask("stop", 20, "cB"), verdict("early", 30, "cA")];
1329 assert_eq!(why(&early), said(UNJUDGED_TRIES, "cB"));
1330 assert_eq!(why(&[stuck.clone(), vec![verdict("live", 40, "cB")]].concat()), said(UNJUDGED_NONE, "cB"), "the question's own verdict");
1331 assert_eq!(why(&[ask("queued", 10, "cA"), verdict("queued", 30, "cA")]), said(UNJUDGED_NONE, "cA"), "a free delivery's own");
1332
1333 use crate::select::{
1335 Route,
1336 summary::{Label, Summary, fold},
1337 };
1338 let graded = |check: &str, ms: i64, channel: &str| Outcome {
1339 grades: vec![3],
1340 ranks: vec![0],
1341 applied: 1,
1342 channel: channel.into(),
1343 check: check.into(),
1344 ..o(GRADED, "s", &["L"], ms)
1345 };
1346 let mut log = vec![Outcome { free: vec!["L".into()], ..graded("cA", 5, "live") }, Outcome { chosen: vec!["L".into()], ..graded("cB", 15, "live") }];
1347 log.extend(stuck);
1348 log.extend(settle_with(&log, "s", 99, &|_| UNJUDGED_TRIES));
1349 let text: String = log.iter().map(|x| format!("{}\n", serde_json::to_string(x).unwrap())).collect();
1350 let s = fold(&text, Summary::default());
1351 let label = |route: Route| s.applies.iter().find(|a| a.route == route).map(|a| a.label);
1352 assert_eq!(label(Route::Ask), Some(Label::Censored), "{:?}", s.applies);
1353 }
1354
1355 #[test]
1360 fn a_settle_leaves_no_delivery_of_its_lesson_open() {
1361 use crate::select::{
1362 Route,
1363 summary::{Label, Summary, fold},
1364 };
1365 let graded = |check: &str, ms: i64, ask: bool| Outcome {
1366 grades: vec![2],
1367 ranks: vec![0],
1368 applied: 1,
1369 channel: "live".into(),
1370 check: check.into(),
1371 chosen: if ask { vec!["L".into()] } else { Vec::new() },
1372 free: if ask { Vec::new() } else { vec!["L".into()] },
1373 ..o(GRADED, "s", &["L"], ms)
1374 };
1375 let asked = |channel: &str, ms: i64, check: &str| Outcome { channel: channel.into(), check: check.into(), ..o(ASKED, "s", &["L"], ms) };
1376 let verdict = |channel: &str, check: &str| Outcome { channel: channel.into(), check: check.into(), ..o(CHANGED, "s", &["L"], 40) };
1377 for (first_ask, channels) in [(false, ["queued", "live"]), (true, ["live", "queued"])] {
1378 let mut log = vec![graded("cA", 5, first_ask), asked(channels[0], 10, "cA"), graded("cB", 15, !first_ask), asked(channels[1], 20, "cB")];
1379 log.push(verdict(channels[1], "cB"));
1380 let settled = settle_with(&log, "s", 99, &|_| UNJUDGED_TRIES);
1381 assert_eq!(settled.iter().map(|x| (x.reason.as_str(), x.check.as_str())).collect::<Vec<_>>(), [(UNJUDGED_NONE, "cB")]);
1382 log.extend(settled);
1383 let text: String = log.iter().map(|x| format!("{}\n", serde_json::to_string(x).unwrap())).collect();
1384 let s = fold(&text, Summary::default());
1385 let label = |route: Route| s.applies.iter().find(|a| a.route == route).map(|a| a.label);
1386 let (a, b) = if first_ask { (Route::Ask, Route::Free) } else { (Route::Free, Route::Ask) };
1387 assert_eq!((label(a), label(b)), (Some(Label::Censored), Some(Label::Changed)), "first asked {first_ask}: {:?}", s.applies);
1388 }
1389 let mut log = vec![graded("cA", 5, false), asked("queued", 10, "cA"), graded("cB", 15, true), asked("live", 20, "cB"), verdict("queued", "cA")];
1393 log.push(Outcome { reason: UNJUDGED_NONE.into(), check: "cB".into(), ..o(NOT_CORRECTED, "s", &["L"], 99) });
1394 let text: String = log.iter().map(|x| format!("{}\n", serde_json::to_string(x).unwrap())).collect();
1395 let s = fold(&text, Summary::default());
1396 let labels: Vec<(Route, Label)> = s.applies.iter().map(|a| (a.route, a.label)).collect();
1397 assert_eq!(labels, [(Route::Free, Label::Changed), (Route::Ask, Label::Open)]);
1398 }
1399
1400 fn placebo(lessons: &[&str], check: &str, ms: i64) -> Outcome {
1401 Outcome { channel: PLACEBO.into(), check: check.into(), route: ROUTE_PLACEBO.into(), ..o(DELIVERY, "s", lessons, ms) }
1402 }
1403
1404 #[test]
1408 fn an_unjudged_placebo_settles_on_its_own_channel() {
1409 let on = |kind: &str, lesson: &str, check: &str, ms: i64| Outcome { channel: PLACEBO.into(), check: check.into(), ..o(kind, "s", &[lesson], ms) };
1410 let log = vec![
1411 placebo(&["A", "B"], "p1", 10),
1412 on(IGNORED, "A", "p1", 15),
1413 on(UNCLEAR, "B", "p1", 15),
1414 placebo(&["C"], "p2", 20),
1415 Outcome { channel: "queued".into(), check: "c3".into(), ..o(ASKED, "s", &["C"], 30) },
1416 Outcome { route: ROUTE_DROPPED_DONE.into(), ..placebo(&["D"], "p4", 40) },
1417 ];
1418 let why = |l: &str| if l == "B" { UNJUDGED_TRIES } else { UNJUDGED_NO_CALL };
1419 let got = settle_with(&log, "s", 99, &why);
1420 let closed =
1421 |l: &str, check: &str, reason: &str| Outcome { channel: PLACEBO.into(), check: check.into(), version: String::new(), ..unjudged("s", l, reason) };
1422 let asked = Outcome { check: "c3".into(), ..unjudged("s", "C", UNJUDGED_NO_CALL) };
1423 let want = vec![asked, closed("B", "p1", UNJUDGED_TRIES), closed("C", "p2", UNJUDGED_LATER)];
1424 assert_eq!(got, want, "A has its verdict; D never reached the judge's queue");
1425 assert!(got[1..].iter().all(|x| !is_result(x) && share(x, "n1").is_none()), "no result, never shared");
1426 let mut resumed = [log, got].concat();
1428 assert!(settle_with(&resumed, "s", 120, &why).is_empty());
1429 resumed.extend([Outcome { channel: "stop".into(), check: "c5".into(), ..o(ASKED, "s", &["B"], 110) }, placebo(&["E"], "p6", 111)]);
1431 let again: Vec<(String, String, String)> =
1432 settle_with(&resumed, "s", 120, &why).into_iter().map(|x| (x.lessons.join(","), x.channel, x.check)).collect();
1433 assert_eq!(again, [("B".to_string(), String::new(), "c5".to_string()), ("E".to_string(), PLACEBO.to_string(), "p6".to_string())]);
1434 }
1435
1436 #[test]
1441 fn not_arisen_settles_its_delivery_and_a_remaining_consequence_leaves_it_pending() {
1442 use crate::select::{
1443 Route,
1444 summary::{Label, Summary, fold},
1445 };
1446 let ask = |l: &str, check: &str| Outcome { channel: "stop".into(), check: check.into(), ..o(ASKED, "s", &[l], 10) };
1447 let on = |kind: &str, l: &str, check: &str, channel: &str| Outcome {
1448 channel: channel.into(),
1449 check: check.into(),
1450 judge: 3,
1451 reasons: vec!["only the docs changed".into()],
1452 ..o(kind, "s", &[l], 20)
1453 };
1454 let log = vec![
1455 ask("A", "cA"),
1456 on(NOT_ARISEN, "A", "cA", "stop"),
1457 ask("B", "cB"),
1458 on(REMAINS, "B", "cB", "stop"),
1459 placebo(&["P"], "pP", 10),
1460 on(NOT_ARISEN, "P", "pP", PLACEBO),
1461 placebo(&["Q"], "pQ", 10),
1462 on(REMAINS, "Q", "pQ", PLACEBO),
1463 ];
1464 assert!(log.iter().filter(|x| x.kind == NOT_ARISEN || x.kind == REMAINS).all(|x| !is_result(x) && share(x, "n1").is_none()));
1465 assert!(settles(NOT_ARISEN) && settles(CHANGED) && settles(IGNORED) && !settles(REMAINS) && !settles(UNCLEAR));
1466 let got: Vec<(String, String, String)> =
1467 settle_with(&log, "s", 99, &|_| UNJUDGED_TRIES).into_iter().map(|x| (x.lessons.join(","), x.channel, x.reason)).collect();
1468 let row = |l: &str, channel: &str, reason: &str| (l.to_string(), channel.to_string(), reason.to_string());
1469 assert_eq!(got, [row("A", "", UNJUDGED_NONE), row("B", "", UNJUDGED_TRIES), row("Q", PLACEBO, UNJUDGED_TRIES)], "P closed by its verdict");
1470 let line = serde_json::to_string(&log[1]).unwrap();
1472 assert!(line.contains(r#""reasons":["only the docs changed"]"#), "{line}");
1473 assert_eq!(serde_json::from_str::<Outcome>(&line).unwrap(), log[1]);
1474 let old: Outcome = serde_json::from_str(r#"{"kind":"asked_unclear","session":"s","lessons":["A"],"ms":1,"repo":"r","channel":"stop"}"#).unwrap();
1475 assert!(old.reasons.is_empty() && !is_result(&old) && !settles(&old.kind));
1476
1477 let graded = |l: &str, check: &str, ask: bool, placebo: bool| Outcome {
1479 grades: vec![3],
1480 ranks: vec![0],
1481 applied: 1,
1482 channel: "stop".into(),
1483 check: check.into(),
1484 chosen: if ask { vec![l.into()] } else { Vec::new() },
1485 placebo: if placebo { vec![l.into()] } else { Vec::new() },
1486 psi: if placebo { 0.5 } else { 0.0 },
1487 ..o(GRADED, "s", &[l], 5)
1488 };
1489 let mut all = vec![graded("A", "cA", true, false), graded("B", "cB", true, false), graded("C", "cC", true, false)];
1490 all.push(graded("P", "pP", false, true));
1491 all.extend(log);
1492 all.extend([ask("C", "cC"), on(UNCLEAR, "C", "cC", "stop")]);
1493 all.extend([on(UNCLEAR, "A", "cA", "stop"), on(CHANGED, "B", "cB", "stop")]);
1495 let text: String = all.iter().map(|x| format!("{}\n", serde_json::to_string(x).unwrap())).collect();
1496 let s = fold(&text, Summary::default());
1497 let label = |l: &str| {
1498 let i = s.lessons.iter().position(|x| x == l).unwrap() as u32;
1499 s.applies.iter().find(|a| a.lesson == i).map(|a| (a.route, a.label, a.judge.is_some()))
1500 };
1501 assert_eq!(label("A"), Some((Route::Ask, Label::NotArisen, true)), "{:?}", s.applies);
1502 assert_eq!(label("B"), Some((Route::Ask, Label::Changed, true)));
1503 assert_eq!(label("C"), Some((Route::Ask, Label::Unclear, false)), "an older kind keeps its label");
1504 assert_eq!(label("P"), Some((Route::Placebo, Label::NotArisen, true)));
1505 let first: String = all[..all.len() - 1].iter().map(|x| format!("{}\n", serde_json::to_string(x).unwrap())).collect();
1507 let s = fold(&first, Summary::default());
1508 let b = s.lessons.iter().position(|x| x == "B").unwrap() as u32;
1509 let a = s.applies.iter().find(|a| a.lesson == b).unwrap();
1510 assert_eq!((a.label, a.judge, a.jv), (Label::Remains, None, 3), "a pending label keeps its judge's version");
1511 assert!(a.label.pending() && !a.label.verdict() && Label::NotArisen.verdict() && !Label::NotArisen.pending());
1512 let end = [&all[..], &[graded("E", "cE", true, false), ask("E", "cE"), Outcome { at: AT_END.into(), ..on(CHANGED, "E", "cE", "stop") }]].concat();
1514 let text: String = end.iter().map(|x| format!("{}\n", serde_json::to_string(x).unwrap())).collect();
1515 let s = fold(&text, Summary::default());
1516 assert!(s.judges.iter().any(|j| j.starts_with("v3|")) && s.judges.iter().any(|j| j.starts_with("v3@end|")), "{:?}", s.judges);
1517 }
1518
1519 #[test]
1522 fn idle_sessions_wait_for_open_placebos() {
1523 let only = vec![placebo(&["A"], "p1", 0)];
1524 assert_eq!(idle_sessions(&only, &HashMap::new(), IDLE_MS + 10), ["s"]);
1525 assert!(idle_sessions(&only, &HashMap::new(), IDLE_MS - 10).is_empty(), "the placebo is the session's newest activity");
1526 let settled = [only.clone(), settle(&only, "s", 5)].concat();
1527 assert!(idle_sessions(&settled, &HashMap::new(), IDLE_MS + 10).is_empty());
1528 }
1529
1530 #[test]
1534 fn the_selection_summary_censors_a_settled_placebo() {
1535 use crate::select::summary::{Label, Summary, fold};
1536 let graded = Outcome {
1537 grades: vec![1, 1],
1538 ranks: vec![0, 1],
1539 applied: 2,
1540 channel: "stop".into(),
1541 check: "p1".into(),
1542 policy: "learned".into(),
1543 placebo: vec!["A".into(), "B".into()],
1544 ..o(GRADED, "s", &["A", "B"], 5)
1545 };
1546 let mut log = vec![graded, placebo(&["A", "B"], "p1", 10), Outcome { channel: PLACEBO.into(), check: "p1".into(), ..o(IGNORED, "s", &["A"], 15) }];
1547 log.extend(settle(&log, "s", 99));
1548 let text: String = log.iter().map(|x| format!("{}\n", serde_json::to_string(x).unwrap())).collect();
1549 let s = fold(&text, Summary::default());
1550 let label = |l: &str| s.applies.iter().find(|a| s.lessons[a.lesson as usize] == l).map(|a| a.label);
1551 assert_eq!((label("A"), label("B")), (Some(Label::Ignored), Some(Label::Censored)));
1552 }
1553
1554 #[test]
1555 fn contradicted_marks_superseded_asks_and_the_other_sides_of_a_disagreement() {
1556 let mut ask = o(ASKED, "s1", &["A", "B", "C", "D"], 10);
1557 ask.groups = vec![vec!["A".into(), "B".into()]];
1558 let all = vec![ask, o(ASKED, "s1", &["E"], 30), o(CORRECTED, "s1", &["D"], 15)];
1559 let ids = |v: &[&str]| v.iter().map(|x| x.to_string()).collect::<Vec<_>>();
1560 assert_eq!(contradicted(&all, "s1", 20, Some("C"), &[]), ["C"]);
1562 assert_eq!(contradicted(&all, "s1", 20, Some("A"), &[]), ["B"]);
1564 assert_eq!(contradicted(&all, "s1", 20, None, &ids(&["C", "E", "D", "Z"])), ["C"]);
1566 assert!(contradicted(&all, "s2", 20, Some("C"), &ids(&["A"])).is_empty(), "another session asked nothing");
1567 let mut vetoed = all.clone();
1568 vetoed.push(o(VETOED, "s1", &["C"], 12));
1569 assert!(contradicted(&vetoed, "s1", 20, Some("C"), &[]).is_empty(), "a vetoed lesson already has the stronger result");
1570 }
1571
1572 #[test]
1573 fn a_placebo_or_unclear_result_is_no_result_for_any_reader() {
1574 let placebo = |kind: &str| Outcome { channel: PLACEBO.into(), ..o(kind, "s", &["A"], 5) };
1575 let unclear = Outcome { channel: "stop".into(), ..o(UNCLEAR, "s", &["A"], 5) };
1576 for x in [placebo(CHANGED), placebo(IGNORED), placebo(UNCLEAR), unclear.clone()] {
1577 assert!(!is_result(&x), "{x:?}");
1578 assert!(share(&x, "n1").is_none(), "never shared: {x:?}");
1579 }
1580 assert!(is_result(&Outcome { channel: "queued".into(), ..o(CHANGED, "s", &["A"], 5) }));
1581 assert!(!SHARED_KINDS.contains(&UNCLEAR) && !SHARED_KINDS.contains(&DELIVERY));
1582 let all = [placebo(CORRECTED), placebo(NOT_CORRECTED), o(NOT_CORRECTED, "t", &["A"], 6)];
1584 assert_eq!(tally(sessions(&all)["A"].values(), i64::MIN), (1, 0));
1585 let d = tmp("placebo-stats");
1587 append(&d.join("outcomes.jsonl"), &placebo(CHANGED)).unwrap();
1588 append(&d.join("outcomes.jsonl"), &unclear).unwrap();
1589 append(&d.join("outcomes.jsonl"), &Outcome { channel: "stop".into(), ..o(CHANGED, "s", &["B"], 7) }).unwrap();
1590 let kinds: Vec<String> = crate::stats::events(&d).into_iter().map(|e| e.kind).collect();
1591 assert_eq!(kinds, [crate::stats::CHANGED], "{kinds:?}");
1592 let _ = std::fs::remove_dir_all(d);
1593 }
1594
1595 #[test]
1596 fn a_vetoed_lesson_is_not_settled_and_counts_as_corrected() {
1597 let all = vec![o(ASKED, "s1", &["A", "B"], 10), o(VETOED, "s1", &["A"], 20)];
1598 assert_eq!(settle(&all, "s1", 99), vec![unjudged("s1", "B", UNJUDGED_NO_CALL)]);
1599 assert_eq!(sessions(&all)["A"]["s1"], (true, 20));
1600 assert_eq!(weights(&all)["A"], weight(0, 1));
1601 }
1602
1603 #[test]
1604 fn sessions_keep_the_earliest_result_and_tally_filters_by_it() {
1605 let all =
1606 vec![o(NOT_CORRECTED, "s1", &["A"], 10), o(CORRECTED, "s2", &["A"], 20), o(NOT_CORRECTED, "s2", &["A"], 25), o(NOT_CORRECTED, "s3", &["A"], 30)];
1607 let s = sessions(&all);
1608 assert_eq!(s["A"]["s2"], (true, 20));
1609 assert_eq!(tally(s["A"].values(), i64::MIN), (2, 1));
1610 assert_eq!(tally(s["A"].values(), 20), (1, 0));
1611 }
1612
1613 #[test]
1614 fn idle_sessions_need_old_asks_and_old_episodes() {
1615 let all = vec![o(ASKED, "old", &["A"], 0), o(ASKED, "busy", &["A"], 0), o(ASKED, "fresh", &["A"], 50), o(CORRECTED, "x", &["A"], 0)];
1616 let episodes = HashMap::from([("busy".to_string(), 60)]);
1617 assert_eq!(idle_sessions(&all, &episodes, IDLE_MS + 10), ["old"]);
1618 assert_eq!(idle_sessions(&all, &episodes, IDLE_MS + 60), ["busy", "fresh", "old"]);
1619 }
1620
1621 #[test]
1622 fn idle_sessions_skip_settled_ones() {
1623 let all = vec![
1624 o(ASKED, "done", &["A", "B"], 0),
1625 o(NOT_CORRECTED, "done", &["A"], 1),
1626 o(CORRECTED, "done", &["B"], 1),
1627 o(ASKED, "vetoed", &["A"], 0),
1628 o(VETOED, "vetoed", &["A"], 1),
1629 o(ASKED, "half", &["A", "B"], 0),
1630 o(NOT_CORRECTED, "half", &["A"], 1),
1631 ];
1632 assert_eq!(idle_sessions(&all, &HashMap::new(), IDLE_MS + 10), ["half"], "every ask of the others has its result");
1633 }
1634
1635 #[test]
1636 fn regretted_blames_asks_before_the_decision_shared_or_by_target() {
1637 let mut graded = o(GRADED, "s", &["G"], 5);
1638 graded.chosen = vec![];
1639 let log = vec![graded, o(ASKED, "s", &["B", "A"], 10), o(ASKED, "s", &["C"], 30), o(ASKED, "t", &["D"], 10)];
1640 let ids = |v: &[&str]| v.iter().map(|x| x.to_string()).collect::<Vec<_>>();
1641 let blame = |log: &[Outcome], target: &[String]| regretted(log, "s", 20, "R1", target, 0.5, 99, "r");
1642 let got = blame(&log, &[]);
1643 let want = |l: &str, credit: f64| Outcome { cause: "R1".into(), credit, ..o(REGRETTED, "s", &[l], 99) };
1644 assert_eq!(got, vec![want("A", 0.25), want("B", 0.25)], "shared; C was asked after the decision, G never shown");
1645 assert_eq!(blame(&log, &ids(&["B", "Z"])), vec![want("B", 0.5)], "the merge target takes it all");
1646 assert_eq!(blame(&log, &ids(&["Z"])), got, "a target not asked: shared");
1647 let mut corrected = log.clone();
1648 corrected.push(o(CORRECTED, "s", &["B"], 15));
1649 assert_eq!(blame(&corrected, &[]), vec![want("A", 0.5)], "corrected outranks regretted");
1650 let mut vetoed = log.clone();
1651 vetoed.push(o(VETOED, "s", &["B"], 15));
1652 assert_eq!(blame(&vetoed, &[]), vec![want("A", 0.5)], "vetoed outranks regretted");
1653 assert!(blame(&corrected, &ids(&["B"])).is_empty(), "the target took it as a correction: nobody else is blamed");
1654 assert!(blame(&vetoed, &ids(&["B", "Z"])).is_empty(), "the target took it as a veto: nobody else is blamed");
1655 assert_eq!(blame(&corrected, &ids(&["A", "B"])), vec![want("A", 0.5)], "the uncorrected target keeps its share");
1656 let mut again = log.clone();
1657 again.extend(got.clone());
1658 assert!(blame(&again, &[]).is_empty(), "credited once per cause");
1659 let mut half = log.clone();
1660 half.push(got[0].clone());
1661 assert_eq!(blame(&half, &[]), vec![want("B", 0.25)], "a partial run completes with the same share");
1662 assert_eq!(regretted(&again, "s", 20, "R2", &[], 0.5, 99, "r").len(), 2, "another regret blames again");
1663 assert!(regretted(&log, "u", 20, "R1", &[], 0.5, 99, "r").is_empty(), "another session asked nothing");
1664 assert!(regretted(&log, "", 20, "R1", &[], 0.5, 99, "r").is_empty());
1665 assert!(regretted(&log, "s", 0, "R1", &[], 0.5, 99, "r").is_empty(), "no decision time");
1666 assert!(regretted(&log, "s", 20, "R1", &[], 0.0, 99, "r").is_empty(), "not strong");
1667 let old: Outcome = serde_json::from_str(r#"{"kind":"asked","session":"s","lessons":["A"],"ms":1,"repo":"r"}"#).unwrap();
1669 assert_eq!(old, o(ASKED, "s", &["A"], 1));
1670 assert!(!serde_json::to_string(&old).unwrap().contains("credit"));
1671 let line = serde_json::to_string(&got[0]).unwrap();
1672 assert!(line.contains(r#""cause":"R1","credit":0.25"#), "{line}");
1673 assert_eq!(serde_json::from_str::<Outcome>(&line).unwrap(), got[0]);
1674 }
1675
1676 #[test]
1677 fn a_line_from_before_propensities_reads_and_writes_back_unchanged() {
1678 let line = r#"{"kind":"asked","session":"s","lessons":["A","B"],"ms":1,"repo":"r","channel":"stop","grades":[3,1],"ranks":[0,1],"applied":2}"#;
1679 let o: Outcome = serde_json::from_str(line).unwrap();
1680 assert!(o.p.is_empty());
1681 assert_eq!(serde_json::to_string(&o).unwrap(), line);
1682 let with = Outcome { p: vec![round_p(1.0), round_p(1.0 / 3.0)], ..o };
1683 assert!(serde_json::to_string(&with).unwrap().ends_with(r#""applied":2,"p":[1.0,0.3333]}"#));
1684 }
1685
1686 #[test]
1687 fn a_graded_line_from_before_select_reads_and_writes_back_unchanged() {
1688 let line = r#"{"kind":"graded","session":"s","lessons":["A","B"],"ms":1,"repo":"r","channel":"live","grades":[3,1],"ranks":[0,1],"applied":2,"chosen":["A","B"],"explore":true,"p":[1.0,0.1]}"#;
1689 let o: Outcome = serde_json::from_str(line).unwrap();
1690 assert!(o.check.is_empty() && o.policy.is_empty() && o.free.is_empty() && o.pf.is_empty() && o.judge == 0 && !o.failed);
1691 assert!(o.nofree.is_empty() && o.tail.is_empty() && o.pt == 0.0 && o.rel.is_empty());
1692 assert_eq!(serde_json::to_string(&o).unwrap(), line);
1693 for line in [
1695 r#"{"kind":"graded","session":"s","lessons":["A","B","C"],"ms":1,"repo":"r","channel":"stop","grades":[2,1,1],"ranks":[0,1,2],"applied":3,"p":[0.0,0.0,0.0],"check":"0123456789ab","policy":"learned","fit":"120-ab12","seen":"9-abcdef01","source":"fixed","free":["A","B"],"pf":[1.0,1.0,0.0],"placebo":["C"],"psi":1.0,"eligible":40,"shown":30,"tokens":12000,"fill":[0,4,7],"model":"m","version":"0.6.0","dur_ms":9000,"usd":0.01}"#,
1696 r#"{"kind":"asked","session":"s","lessons":["A","B"],"ms":2,"repo":"r","channel":"early","p":[1.0,0.5],"route":"asked"}"#,
1697 ] {
1698 let o: Outcome = serde_json::from_str(line).unwrap();
1699 assert!(o.nofree.is_empty() && o.tail.is_empty() && o.pt == 0.0 && o.rel.is_empty(), "{line}");
1700 assert_eq!((o.xk, o.xs), (None, None), "{line}");
1701 assert_eq!(serde_json::to_string(&o).unwrap(), line);
1702 }
1703 }
1704
1705 #[test]
1706 fn the_selection_fields_round_trip_and_stay_local() {
1707 let g = Outcome {
1708 check: "0123456789ab".into(),
1709 policy: "learned".into(),
1710 fit: "120-ab12".into(),
1711 free: vec!["B".into()],
1712 pf: vec![0.0, 1.0, 0.0],
1713 placebo: vec!["C".into()],
1714 psi: 0.5,
1715 nofree: vec!["N9".into()],
1716 xk: Some(2),
1717 xs: Some(0),
1718 tail: vec!["T9".into()],
1719 pt: 0.125,
1720 eligible: 40,
1721 shown: 30,
1722 tokens: 12_000,
1723 fill: vec![0, 4, 7],
1724 model: "m".into(),
1725 prompt_hash: "ph".into(),
1726 harness: "claude".into(),
1727 version: "0.6.0".into(),
1728 dur_ms: 9_000,
1729 failed: true,
1730 ..o(GRADED, "s", &["A", "B", "C"], 5)
1731 };
1732 let delivered = Outcome { check: "0123456789ab".into(), route: ROUTE_DROPPED_CHARS.into(), reason: "budget".into(), ..o(ASKED, "s", &["B"], 6) };
1733 let judged = Outcome { check: "0123456789ab".into(), judge: 2, stripped: true, ..o(CHANGED, "s", &["A"], 7) };
1734 let early = Outcome { channel: "early".into(), rel: vec![0.75], ..o(ASKED, "s", &["D"], 8) };
1735 let carried = Outcome { nofree: g.nofree.clone(), xk: g.xk, xs: g.xs, tail: g.tail.clone(), pt: g.pt, rel: vec![0.75], ..judged.clone() };
1737 for x in [&g, &delivered, &judged, &early, &carried] {
1738 let line = serde_json::to_string(x).unwrap();
1739 assert_eq!(&serde_json::from_str::<Outcome>(&line).unwrap(), x, "round trip");
1740 }
1741 assert!(serde_json::to_string(&g).unwrap().contains(r#""xk":2,"xs":0"#));
1743 assert!(share(&g, "n1").is_none(), "graded stays local");
1744 for x in [&delivered, &judged, &early, &carried] {
1745 let shared = serde_json::to_string(&share(x, "n1").unwrap()).unwrap();
1746 for field in
1747 ["check", "0123456789ab", "route", "reason", "judge", "stripped", "nofree", "N9", "xk", "xs", "tail", "T9", "pt", "0.125", "rel", "0.75"]
1748 {
1749 assert!(!shared.contains(field), "{field}: {shared}");
1750 }
1751 }
1752 assert!(ROUTES.iter().all(|r| !r.is_empty() && !r.contains(' ')), "one word each");
1753 }
1754
1755 #[test]
1756 fn an_older_graded_record_is_the_fixed_rule_unless_it_shows_the_cut() {
1757 let graded = |grades: &[u8], chosen: &[&str], p: &[f64]| Outcome {
1758 lessons: (0..grades.len()).map(|i| format!("L{i:02}")).collect(),
1759 grades: grades.to_vec(),
1760 chosen: chosen.iter().map(|c| c.to_string()).collect(),
1761 p: p.to_vec(),
1762 ..o(GRADED, "s", &[], 1)
1763 };
1764 let soft: Vec<u8> = [vec![1; 6], vec![2; 6]].concat();
1766 let old = graded(&soft, &[], &[0.0; 12]);
1767 assert_eq!(policy_of(&old), "fixed");
1768 let want: Vec<String> = (6..12).chain(0..4).map(|i| format!("L{i:02}")).collect();
1769 assert_eq!(free_of(&old), want);
1770 let block = graded(&[3, 1], &["L00", "L01"], &[1.0, 1.0]);
1772 assert_eq!((policy_of(&block), free_of(&block)), ("fixed", Vec::<String>::new()));
1773 let cut = graded(&[3, 1, 1, 1], &["L00", "L01"], &[1.0, 1.0, 0.1, 0.1]);
1775 assert_eq!((policy_of(&cut), free_of(&cut)), ("attention", vec!["L02".to_string(), "L03".into()]));
1776 let explored = Outcome { explore: true, ..graded(&[3, 1, 1], &["L00", "L01", "L02"], &[1.0, 1.0, 1.0]) };
1777 assert_eq!((policy_of(&explored), free_of(&explored)), ("attention", Vec::<String>::new()));
1778 let named = Outcome { policy: "learned".into(), free: vec!["L01".into()], ..graded(&[3, 1], &["L00"], &[1.0, 0.0]) };
1780 assert_eq!((policy_of(&named), free_of(&named)), ("learned", vec!["L01".to_string()]));
1781 }
1782
1783 fn tmp(name: &str) -> std::path::PathBuf {
1784 let d = std::env::temp_dir().join(format!("borg-outcome-{name}-{}", std::process::id()));
1785 let _ = std::fs::remove_dir_all(&d);
1786 std::fs::create_dir_all(&d).unwrap();
1787 d
1788 }
1789
1790 fn lines(path: &Path, v: &[String]) {
1791 std::fs::write(path, v.iter().map(|l| format!("{l}\n")).collect::<String>()).unwrap();
1792 }
1793
1794 fn changed(team: &[Outcome], lesson: &str) -> u32 {
1795 let ls = [crate::conscience::Challenge { id: lesson.into(), ..Default::default() }];
1796 crate::memory::derive(team, &ls, None)[lesson].changed
1797 }
1798
1799 #[test]
1800 fn share_redacts() {
1801 let mut a = o(ASKED, "session-raw", &["A"], 5);
1802 a.repo = "secret-repo".into();
1803 a.groups = vec![vec!["A".into(), "B".into()]];
1804 a.grades = vec![3];
1805 a.ranks = vec![0];
1806 a.p = vec![0.5];
1807 let x = share(&a, "n1").unwrap();
1808 let line = serde_json::to_string(&x).unwrap();
1809 for raw in ["session-raw", "secret-repo", "groups", "grades", "ranks", "\"id\"", "version"] {
1810 assert!(!line.contains(raw), "{raw} in {line}");
1811 }
1812 assert_eq!((x.s, x.r, x.p, x.node), (tag("session-raw"), tag("secret-repo"), vec![0.5], "n1".to_string()));
1813 assert_eq!(tag("s").len(), 32);
1814 for kind in [GRADED, "veto"] {
1815 assert!(share(&o(kind, "s", &["A"], 1), "n1").is_none(), "{kind} is not shared");
1816 }
1817 let regret = share(&Outcome { cause: "episode-raw".into(), credit: 0.25, ..o(REGRETTED, "s", &["A"], 1) }, "n1").unwrap();
1818 let line = serde_json::to_string(®ret).unwrap();
1819 assert!(!line.contains("episode-raw") && line.contains(&tag("episode-raw")) && line.contains(r#""credit":0.25"#), "{line}");
1820 }
1821
1822 #[test]
1823 fn pool_fields_stay_local() {
1824 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) };
1825 let line = serde_json::to_string(&a).unwrap();
1826 assert_eq!(serde_json::from_str::<Outcome>(&line).unwrap(), a, "round trip");
1827 let shared = serde_json::to_string(&share(&a, "n1").unwrap()).unwrap();
1828 for field in ["pool", "base", "scoped"] {
1829 assert!(line.contains(field) && !shared.contains(field), "{field}: {shared}");
1830 }
1831 assert!(!serde_json::to_string(&o(ASKED, "s", &["A"], 5)).unwrap().contains("pool"), "empty: not written");
1832 }
1833
1834 #[test]
1835 fn new_graded_fields_stay_local() {
1836 let g = Outcome {
1837 moves: vec!["hold a lock across an await".into()],
1838 whys: vec!["the send awaits under the lock".into()],
1839 xtokens: 120,
1840 line: "a2".into(),
1841 run: "async".into(),
1842 ..o(GRADED, "s", &["A"], 5)
1843 };
1844 let line = serde_json::to_string(&g).unwrap();
1845 assert_eq!(serde_json::from_str::<Outcome>(&line).unwrap(), g, "round trip");
1846 for field in ["moves", "whys", "xtokens", "\"line\"", "\"run\""] {
1847 assert!(line.contains(field), "{field}: {line}");
1848 }
1849 assert!(share(&g, "n1").is_none(), "graded stays local");
1850 let plain = serde_json::to_string(&o(GRADED, "s", &["A"], 5)).unwrap();
1851 for field in ["moves", "whys", "xtokens", "\"line\"", "\"run\""] {
1852 assert!(!plain.contains(field), "empty: not written, older lines write back unchanged: {plain}");
1853 }
1854 }
1855
1856 #[test]
1857 fn publish_is_idempotent() {
1858 let home = tmp("publish");
1859 assert_eq!(publish(&home), 0, "no outcomes, nothing to publish");
1860 let mut asked = o(ASKED, "s1", &["A"], 1);
1861 asked.p = vec![1.0];
1862 lines(&home.join("outcomes.jsonl"), &[asked, o(GRADED, "s1", &["A"], 1), o(CHANGED, "s1", &["A"], 2)].map(|x| serde_json::to_string(&x).unwrap()));
1863 assert_eq!(publish(&home), 2, "graded stays local");
1864 let first = std::fs::read_to_string(home.join(TEAM_FILE)).unwrap();
1865 assert_eq!(publish(&home), 0);
1866 assert_eq!(std::fs::read_to_string(home.join(TEAM_FILE)).unwrap(), first);
1867 assert!(first.contains(r#""p":[1.0]"#) && !first.contains("\"s1\""), "{first}");
1868 assert_eq!(std::fs::read_to_string(home.join(PUBLISHED)).unwrap(), std::fs::metadata(home.join("outcomes.jsonl")).unwrap().len().to_string());
1870 std::fs::OpenOptions::new()
1871 .append(true)
1872 .open(home.join("outcomes.jsonl"))
1873 .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()))
1874 .unwrap();
1875 assert_eq!(publish(&home), 1);
1876 assert_eq!(publish(&home), 0);
1877 std::fs::remove_file(home.join(TEAM_FILE)).unwrap();
1879 assert_eq!(publish(&home), 3);
1880 let _ = std::fs::remove_dir_all(home);
1881 }
1882
1883 fn team_store(name: &str, shuffle: bool) -> std::path::PathBuf {
1887 let home = tmp(name);
1888 let mut local = o(CHANGED, "s1", &["L1"], 10);
1889 local.repo = "app".into();
1890 lines(&home.join("outcomes.jsonl"), &[serde_json::to_string(&local).unwrap()]);
1891 let remote = |kind: &str, s: &str, node: &str| {
1892 serde_json::to_string(&Shared {
1893 kind: kind.into(),
1894 s: s.into(),
1895 lessons: vec!["L1".into()],
1896 ms: 20,
1897 node: node.into(),
1898 r: tag("web"),
1899 ..Default::default()
1900 })
1901 .unwrap()
1902 };
1903 let mut team = vec![
1904 serde_json::to_string(&share(&local, "me").unwrap()).unwrap(),
1905 remote(CHANGED, "x", "n2"),
1906 remote(CHANGED, "x", "n3"),
1907 remote("hacked", "y", "n2"),
1908 remote("veto", "z", "n2"),
1909 ];
1910 if shuffle {
1911 team.reverse();
1912 team.swap(0, 2);
1913 }
1914 lines(&home.join(TEAM_FILE), &team);
1915 home
1916 }
1917
1918 #[test]
1919 fn read_team_drops_own_copies_and_keeps_remote() {
1920 let home = team_store("team", false);
1921 let team = team_view(&home.join("lessons.jsonl"), true);
1922 assert_eq!(changed(&team, "L1"), 2, "{team:?}");
1923 assert_eq!(team.iter().filter(|o| o.session == "x").count(), 1, "one remote line per (kind, session, lessons, ms)");
1924 assert!(team.iter().all(|o| o.kind != "hacked" && o.kind != "veto"), "unshared kinds, an older peer's hive verdict among them, are ignored");
1925 assert!(team.iter().all(|o| o.repo != "app"), "local repos compare as tags");
1926 let _ = std::fs::remove_dir_all(home);
1927 }
1928
1929 #[test]
1930 fn read_team_is_order_independent() {
1931 let (a, b) = (team_store("order-a", false), team_store("order-b", true));
1932 let ls = [crate::conscience::Challenge { id: "L1".into(), ..Default::default() }];
1933 let m = |h: &Path| crate::memory::derive(&team_view(&h.join("lessons.jsonl"), true), &ls, None);
1934 assert_eq!(m(&a), m(&b));
1935 let _ = std::fs::remove_dir_all(a);
1936 let _ = std::fs::remove_dir_all(b);
1937 }
1938
1939 #[test]
1943 fn a_peer_derives_the_same_memory_from_the_team_file() {
1944 let (a, b) = (tmp("fold-a"), tmp("fold-b"));
1945 let regret = Outcome { cause: "episode-1".into(), credit: 0.5, ..o(REGRETTED, "s2", &["L1"], 40) };
1946 let log = [
1947 o(GRADED, "s0", &["L1"], 5),
1948 o(ASKED, "s1", &["L1"], 10),
1949 o(CHANGED, "s1", &["L1"], 20),
1950 o(VETOED, "s1", &["L1"], 30),
1951 o(ASKED, "s2", &["L1"], 35),
1952 regret,
1953 ];
1954 lines(&a.join("outcomes.jsonl"), &log.map(|x| serde_json::to_string(&x).unwrap()));
1955 publish(&a);
1956 std::fs::copy(a.join(TEAM_FILE), b.join(TEAM_FILE)).unwrap();
1957 let ls = [crate::conscience::Challenge { id: "L1".into(), origin_ms: 1, ..Default::default() }];
1958 let m = |h: &Path| crate::memory::derive(&team_view(&h.join("lessons.jsonl"), true), &ls, None);
1959 let (here, there) = (m(&a), m(&b));
1960 assert_eq!((here["L1"].vetoed, here["L1"].changed, here["L1"].regretted), (1, 0, 0.5), "{here:?}");
1961 assert_eq!(here, there);
1962 let _ = std::fs::remove_dir_all(a);
1963 let _ = std::fs::remove_dir_all(b);
1964 }
1965
1966 #[test]
1970 fn a_remote_line_under_a_local_tag_counts_on_peers_only() {
1971 let (victim, peer) = (tmp("forged-victim"), tmp("forged-peer"));
1972 let own = o(CHANGED, "s1", &["L1"], 10);
1973 let forged = Shared { kind: CORRECTED.into(), s: tag("s1"), lessons: vec!["L1".into()], ms: 11, node: "n2".into(), ..Default::default() };
1974 let team = [serde_json::to_string(&share(&own, "me").unwrap()).unwrap(), serde_json::to_string(&forged).unwrap()];
1975 lines(&victim.join("outcomes.jsonl"), &[serde_json::to_string(&own).unwrap()]);
1976 lines(&victim.join(TEAM_FILE), &team);
1977 lines(&peer.join(TEAM_FILE), &team);
1978 let ls = [crate::conscience::Challenge { id: "L1".into(), ..Default::default() }];
1979 let m = |h: &Path| crate::memory::derive(&team_view(&h.join("lessons.jsonl"), true), &ls, None).remove("L1").unwrap();
1980 assert_eq!((m(&victim).changed, m(&victim).corrected), (1, 0), "the victim never sees the override");
1981 assert_eq!((m(&peer).changed, m(&peer).corrected), (0, 1), "a peer counts the forged correction");
1982 let _ = std::fs::remove_dir_all(victim);
1983 let _ = std::fs::remove_dir_all(peer);
1984 }
1985
1986 #[test]
1988 fn a_peer_green_run_counts_only_for_lessons_its_session_was_given() {
1989 let home = tmp("tested-team");
1990 let asked = share(&o(ASKED, "p1", &["L1"], 10), "peer").unwrap();
1991 let ok = share(&o(TESTED, "p1", &["L1"], 20), "peer").unwrap();
1992 let stray = share(&o(TESTED, "p1", &["L2"], 20), "peer").unwrap();
1993 let alone = share(&o(TESTED, "p2", &["L1"], 20), "peer").unwrap();
1994 let verdict = Shared { kind: "verified".into(), ..share(&o(ASKED, "p3", &["L1"], 10), "peer").unwrap() };
1996 let unbacked = share(&o(TESTED, "p3", &["L1"], 20), "peer").unwrap();
1997 let all = [&asked, &ok, &stray, &alone, &verdict, &unbacked];
1998 lines(&home.join(TEAM_FILE), &all.map(|x| serde_json::to_string(x).unwrap()));
1999 let ls = ["L1", "L2"].map(|id| crate::conscience::Challenge { id: id.into(), ..Default::default() });
2000 let m = crate::memory::derive(&team_view(&home.join("lessons.jsonl"), true), &ls, None);
2001 assert_eq!((m["L1"].tested, m["L2"].tested), (1, 0), "{m:?}");
2002 let _ = std::fs::remove_dir_all(home);
2003 }
2004
2005 #[test]
2006 fn off_keeps_beliefs_local() {
2007 let home = team_store("off", false);
2008 let local = team_view(&home.join("lessons.jsonl"), false);
2009 assert_eq!(local, read(&home.join("outcomes.jsonl")));
2010 assert_eq!(changed(&local, "L1"), 1);
2011 let _ = std::fs::remove_dir_all(home);
2012 }
2013
2014 #[test]
2017 fn concurrent_appends_keep_every_record() {
2018 let home = tmp("concurrent");
2019 let p = home.join("outcomes.jsonl");
2020 let (writers, each) = (8, 2000);
2021 std::thread::scope(|scope| {
2022 for w in 0..writers {
2023 let p = &p;
2024 scope.spawn(move || (0..each).for_each(|i| append(p, &o(ASKED, &format!("s{w}"), &["A"], i)).unwrap()));
2025 }
2026 });
2027 assert_eq!(read(&p).len(), writers * each as usize);
2028 let _ = std::fs::remove_dir_all(home);
2029 }
2030
2031 #[test]
2032 fn append_and_read_round_trip() {
2033 let dir = std::env::temp_dir().join(format!("borg-outcome-{}", std::process::id()));
2034 let p = dir.join("outcomes.jsonl");
2035 let _ = std::fs::remove_file(&p);
2036 append(&p, &o(ASKED, "s", &["A"], 1)).unwrap();
2037 append(&p, &o(CORRECTED, "s", &["A"], 2)).unwrap();
2038 assert_eq!(read(&p), vec![o(ASKED, "s", &["A"], 1), o(CORRECTED, "s", &["A"], 2)]);
2039 let _ = std::fs::remove_dir_all(dir);
2040 }
2041}