1use std::collections::{BTreeMap, HashMap};
75use std::io::{Read, Seek};
76use std::path::{Path, PathBuf};
77
78use serde::{Deserialize, Serialize};
79
80use super::Route;
81use super::policy::{self, Fit, Monitor};
82use crate::outcome::{self, Outcome};
83
84pub const SV: u32 = 10;
93pub const CACHE: &str = "select-summary.json";
95pub const REVERTED: &str = "select-reverted";
99pub const MAX_APPLIES: usize = 50_000;
103pub const MAX_SESSIONS: usize = 4_096;
107const _: () = assert!(MAX_SESSIONS - MAX_SESSIONS / 4 >= policy::FIT_SESSIONS);
109pub const MAX_FRESH: usize = 1_024;
114pub const LATENCY_KEEP: usize = 2_000;
117
118#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
120#[serde(rename_all = "lowercase")]
121pub enum Delivered {
122 #[default]
124 Unknown,
125 Yes,
126 Dropped,
128 Superseded,
131}
132
133impl Delivered {
134 #[must_use]
136 pub fn never(self) -> bool {
137 matches!(self, Delivered::Dropped | Delivered::Superseded)
138 }
139}
140
141#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
143#[serde(rename_all = "lowercase")]
144pub enum Label {
145 #[default]
147 Open,
148 Changed,
150 Ignored,
152 Unclear,
154 Censored,
156 #[serde(rename = "not_arisen")]
160 NotArisen,
161 Remains,
164}
165
166impl Label {
167 fn yields_to(self, next: Label) -> bool {
171 match next {
172 Label::Changed | Label::Ignored | Label::NotArisen => true,
173 Label::Unclear | Label::Remains => matches!(self, Label::Open | Label::Censored | Label::Unclear | Label::Remains),
174 Label::Censored => self == Label::Open,
175 Label::Open => false,
176 }
177 }
178
179 #[must_use]
182 pub fn pending(self) -> bool {
183 matches!(self, Label::Unclear | Label::Remains)
184 }
185
186 #[must_use]
188 pub fn verdict(self) -> bool {
189 matches!(self, Label::Changed | Label::Ignored | Label::NotArisen)
190 }
191}
192
193fn is_default<T: Default + PartialEq>(v: &T) -> bool {
194 *v == T::default()
195}
196
197#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
200pub struct Apply {
201 pub check: u32,
203 pub lesson: u32,
204 pub grade: u8,
205 #[serde(default, skip_serializing_if = "is_default")]
206 pub rank: u16,
207 pub route: Route,
208 #[serde(default, skip_serializing_if = "is_default")]
209 pub p: f64,
210 #[serde(default, skip_serializing_if = "is_default")]
211 pub pf: f64,
212 #[serde(default, skip_serializing_if = "is_default")]
213 pub psi: f64,
214 #[serde(default, skip_serializing_if = "is_default")]
215 pub delivered: Delivered,
216 #[serde(default, skip_serializing_if = "is_default")]
217 pub label: Label,
218 #[serde(default, skip_serializing_if = "Option::is_none")]
220 pub judge: Option<u32>,
221 #[serde(default, skip_serializing_if = "is_default")]
225 pub jv: u8,
226 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
228 pub stripped: bool,
229 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
231 pub later: bool,
232 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
234 pub waited: bool,
235 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
239 pub had: bool,
240 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
245 pub nofree: bool,
246 #[serde(default, skip_serializing_if = "is_default")]
248 pub pending: u16,
249}
250
251#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
253pub struct Check {
254 #[serde(default, skip_serializing_if = "String::is_empty")]
256 pub id: String,
257 pub tick: u32,
259 pub ms: i64,
260 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
262 pub stop: bool,
263 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
265 pub learned: bool,
266 #[serde(default, skip_serializing_if = "is_default")]
268 pub tokens: u32,
269 #[serde(default, skip_serializing_if = "is_default")]
270 pub model: u32,
271 pub first: u32,
273 pub n: u16,
274 #[serde(default, skip_serializing_if = "is_default")]
275 pub m_q: u16,
276 #[serde(default, skip_serializing_if = "is_default")]
277 pub m_f: u16,
278 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
280 pub tallied: bool,
281 #[serde(default, skip_serializing_if = "Option::is_none")]
285 pub xk: Option<u32>,
286 #[serde(default, skip_serializing_if = "Option::is_none")]
287 pub xs: Option<u32>,
288}
289
290pub const FREE_STOP: usize = 0;
295pub const FREE_LIVE: usize = 1;
296pub const LATER: usize = 2;
297pub const WAIT: usize = 3;
298pub const ASK_LIVE: usize = 4;
299pub const TALLIES: usize = 5;
302const _: () = assert!(ASK_LIVE + 1 == TALLIES);
303
304#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
306pub struct Session {
307 pub id: String,
308 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
310 pub ended: bool,
311 #[serde(default, skip_serializing_if = "is_default")]
313 pub checks: u32,
314 #[serde(default, skip_serializing_if = "is_default")]
316 pub tally: [(u32, u32); TALLIES],
317 #[serde(default, skip_serializing_if = "Vec::is_empty")]
319 pub had: Vec<u32>,
320 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
322 pub tested: bool,
323}
324
325#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
329pub struct Fresh {
330 pub seq: u64,
331 pub had: Vec<u32>,
332}
333
334#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
341pub struct Pool {
342 #[serde(default, skip_serializing_if = "Vec::is_empty")]
343 pub bins: Vec<(u64, u64)>,
344 #[serde(default, skip_serializing_if = "is_default")]
345 pub tokens: u64,
346 #[serde(default, skip_serializing_if = "is_default")]
347 pub shown: u64,
348 #[serde(default, skip_serializing_if = "Vec::is_empty")]
349 pub latency: Vec<(u32, u32)>,
350 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
352 pub stops: BTreeMap<String, Vec<(u32, bool)>>,
353 #[serde(default, skip_serializing_if = "is_default")]
356 pub tail: (u64, u64),
357}
358
359#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
362pub struct Early {
363 #[serde(default, skip_serializing_if = "is_default")]
364 pub judged: u32,
365 #[serde(default, skip_serializing_if = "is_default")]
366 pub changed: u32,
367}
368
369#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
373pub struct Spent {
374 #[serde(default, skip_serializing_if = "is_default")]
375 pub calls: u64,
376 #[serde(default, skip_serializing_if = "is_default")]
377 pub rounds: u64,
378 #[serde(default, skip_serializing_if = "is_default")]
379 pub usd: f64,
380}
381
382pub const STOP: usize = 0;
384pub const LIVE: usize = 1;
385
386pub const FELL_BACK: [&str; 3] = [outcome::DECIDED_BUSY, outcome::DECIDED_NO_LOCK, outcome::DECIDED_NO_RECORD];
390
391#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
397pub struct Experiment {
398 #[serde(default, skip_serializing_if = "Vec::is_empty")]
399 pub ran: Vec<(u32, u32, u64)>,
400 #[serde(default, skip_serializing_if = "is_default")]
401 pub control: u64,
402 #[serde(default, skip_serializing_if = "Vec::is_empty")]
403 pub refused: Vec<(u32, u64)>,
404 #[serde(default, skip_serializing_if = "is_default")]
405 pub sessions: u32,
406}
407
408impl Experiment {
409 #[must_use]
411 pub fn totals(&self) -> (u64, u64) {
412 (self.ran.iter().map(|c| c.2).sum(), self.refused.iter().map(|c| c.1).sum())
413 }
414}
415
416#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
419pub struct Signals {
420 #[serde(default, skip_serializing_if = "is_default")]
421 pub sessions: u32,
422 #[serde(default, skip_serializing_if = "is_default")]
423 pub corrections: u32,
424 #[serde(default, skip_serializing_if = "is_default")]
425 pub vetoes: u32,
426 #[serde(default, skip_serializing_if = "is_default")]
427 pub regrets: u32,
428 #[serde(default, skip_serializing_if = "is_default")]
429 pub tested: u32,
430}
431
432#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
438pub struct Gate {
439 #[serde(default, skip_serializing_if = "Vec::is_empty")]
440 pub gain: Vec<f64>,
441 #[serde(default, skip_serializing_if = "is_default")]
442 pub allowance: f64,
443 #[serde(default, skip_serializing_if = "is_default")]
444 pub checks: u32,
445 #[serde(default, skip_serializing_if = "is_default")]
446 pub deviated: u32,
447}
448
449#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
454pub struct Past {
455 #[serde(default, skip_serializing_if = "is_default")]
456 pub sessions: u32,
457 #[serde(default, skip_serializing_if = "is_default")]
458 pub checks: u64,
459 #[serde(default, skip_serializing_if = "Vec::is_empty")]
460 pub tally: Vec<[(f64, f64); TALLIES]>,
461}
462
463impl Past {
464 fn add(&mut self, gone: &[Session]) {
466 if gone.is_empty() {
467 return;
468 }
469 let n = gone.len() as u32;
470 self.tally.resize(policy::HALF_LIVES.len(), [(0.0, 0.0); TALLIES]);
471 for (sums, &h) in self.tally.iter_mut().zip(&policy::HALF_LIVES) {
472 let aged = policy::decay(n, h);
473 for e in sums.iter_mut() {
474 *e = (e.0 * aged, e.1 * aged);
475 }
476 for (t, x) in gone.iter().enumerate() {
477 weigh(sums, &x.tally, policy::decay(n - t as u32, h));
478 }
479 }
480 for x in gone.iter().filter(|x| x.checks > 0) {
481 self.sessions += 1;
482 self.checks += u64::from(x.checks);
483 }
484 }
485}
486
487fn weigh(sums: &mut [(f64, f64); TALLIES], tally: &[(u32, u32); TALLIES], w: f64) {
489 for (s, &(n, k)) in sums.iter_mut().zip(tally) {
490 s.0 += w * f64::from(k);
491 s.1 += w * f64::from(n.saturating_sub(k));
492 }
493}
494
495#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
497pub struct Summary {
498 pub sv: u32,
500 pub at: u64,
502 #[serde(default)]
504 pub fit: Fit,
505 #[serde(default, skip_serializing_if = "String::is_empty")]
506 pub hash: String,
507 #[serde(default, skip_serializing_if = "Vec::is_empty")]
508 pub sessions: Vec<Session>,
509 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
511 pub fresh: BTreeMap<String, Fresh>,
512 #[serde(default, skip_serializing_if = "Vec::is_empty")]
514 pub lessons: Vec<String>,
515 #[serde(default, skip_serializing_if = "Vec::is_empty")]
516 pub models: Vec<String>,
517 #[serde(default, skip_serializing_if = "Vec::is_empty")]
518 pub judges: Vec<String>,
519 #[serde(default, skip_serializing_if = "Vec::is_empty")]
520 pub checks: Vec<Check>,
521 #[serde(default, skip_serializing_if = "Vec::is_empty")]
522 pub applies: Vec<Apply>,
523 #[serde(default, skip_serializing_if = "is_default")]
524 pub pool: Pool,
525 #[serde(default, skip_serializing_if = "is_default")]
526 pub early: Early,
527 #[serde(default, skip_serializing_if = "is_default")]
528 pub signals: [Signals; 2],
529 #[serde(default, skip_serializing_if = "is_default")]
530 pub spend: [Spent; 2],
531 #[serde(default, skip_serializing_if = "is_default")]
534 pub fell_back: [u64; FELL_BACK.len()],
535 #[serde(default, skip_serializing_if = "is_default")]
537 pub experiment: Experiment,
538 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
540 pub era: bool,
541 #[serde(default, skip_serializing_if = "is_default")]
542 pub gate: Gate,
543 #[serde(default, skip_serializing_if = "is_default")]
544 pub monitor: Monitor,
545 #[serde(default, skip_serializing_if = "is_default")]
547 pub dropped: u64,
548 #[serde(default, skip_serializing_if = "is_default")]
549 pub past: Past,
550}
551
552impl Default for Summary {
553 fn default() -> Self {
555 Summary {
556 sv: SV,
557 at: 0,
558 fit: Fit::default(),
559 hash: String::new(),
560 sessions: Vec::new(),
561 fresh: BTreeMap::new(),
562 lessons: Vec::new(),
563 models: Vec::new(),
564 judges: Vec::new(),
565 checks: Vec::new(),
566 applies: Vec::new(),
567 pool: Pool::default(),
568 early: Early::default(),
569 signals: Default::default(),
570 spend: Default::default(),
571 fell_back: Default::default(),
572 experiment: Experiment::default(),
573 era: false,
574 gate: Gate::default(),
575 monitor: Monitor::default(),
576 dropped: 0,
577 past: Past::default(),
578 }
579 }
580}
581
582impl Summary {
583 #[must_use]
586 pub fn key(&self) -> String {
587 format!("{}-{}", self.at, self.hash.get(..8).unwrap_or(&self.hash))
588 }
589
590 fn consistent(&self) -> bool {
593 let mut next = 0u32;
594 let checks = self.checks.iter().all(|c| {
595 let ok = c.first == next && (c.tick as usize) < self.sessions.len() && (c.model as usize) < self.models.len().max(1);
596 next = c.first + u32::from(c.n);
597 ok
598 });
599 let applies = self.applies.iter().enumerate().all(|(i, a)| {
600 let inside = self.checks.get(a.check as usize).is_some_and(|c| (c.first as usize..c.first as usize + usize::from(c.n)).contains(&i));
601 inside && (a.lesson as usize) < self.lessons.len() && a.judge.is_none_or(|j| (j as usize) < self.judges.len())
602 });
603 let gate = self.gate.gain.is_empty() || self.gate.gain.len() == policy::DRAWS;
604 let past = self.past.tally.is_empty() || self.past.tally.len() == policy::HALF_LIVES.len();
605 let had = self.sessions.iter().flat_map(|x| &x.had).chain(self.fresh.values().flat_map(|f| &f.had)).all(|l| (*l as usize) < self.lessons.len());
606 checks && next as usize == self.applies.len() && applies && gate && past && had
607 }
608
609 #[must_use]
613 pub fn tallies(&self, h: Option<u32>) -> [(f64, f64); TALLIES] {
614 let now = self.sessions.len() as u32;
615 let mut sums = [(0.0, 0.0); TALLIES];
616 if let Some(past) = policy::HALF_LIVES.iter().position(|x| *x == h).and_then(|i| self.past.tally.get(i)) {
617 let w = policy::decay(now, h);
618 for (s, p) in sums.iter_mut().zip(past) {
619 *s = (w * p.0, w * p.1);
620 }
621 }
622 for (t, x) in self.sessions.iter().enumerate() {
623 weigh(&mut sums, &x.tally, policy::decay(now.saturating_sub(t as u32), h));
624 }
625 sums
626 }
627
628 #[must_use]
631 pub fn checked(&self) -> (u64, u64) {
632 let kept = self.sessions.iter().filter(|x| x.checks > 0);
633 (u64::from(self.past.sessions) + kept.clone().count() as u64, self.past.checks + kept.map(|x| u64::from(x.checks)).sum::<u64>())
634 }
635}
636
637#[derive(Default)]
639struct Index {
640 sessions: HashMap<String, u32>,
641 lessons: HashMap<String, u32>,
642 models: HashMap<String, u32>,
643 judges: HashMap<String, u32>,
644 checks: HashMap<String, u32>,
645 by_lesson: HashMap<(u32, u32), Vec<u32>>,
647 untallied: HashMap<u32, Vec<u32>>,
649 open: HashMap<u32, u32>,
651}
652
653impl Index {
654 fn of(s: &Summary) -> Index {
655 let table = |v: &[String]| v.iter().enumerate().map(|(i, x)| (x.clone(), i as u32)).collect::<HashMap<_, _>>();
656 let mut ix = Index {
657 sessions: s.sessions.iter().enumerate().map(|(i, x)| (x.id.clone(), i as u32)).collect(),
658 lessons: table(&s.lessons),
659 models: table(&s.models),
660 judges: table(&s.judges),
661 ..Index::default()
662 };
663 for (ci, c) in s.checks.iter().enumerate() {
664 if !c.id.is_empty() {
665 ix.checks.insert(c.id.clone(), ci as u32);
666 }
667 if !c.tallied {
668 ix.untallied.entry(c.tick).or_default().push(ci as u32);
669 }
670 for i in c.first..c.first + u32::from(c.n) {
671 let Some(a) = s.applies.get(i as usize) else { continue };
672 ix.by_lesson.entry((c.tick, a.lesson)).or_default().push(i);
673 if waiting(a) {
674 *ix.open.entry(c.tick).or_default() += 1;
675 }
676 }
677 }
678 ix
679 }
680}
681
682fn waiting(a: &Apply) -> bool {
684 a.label == Label::Open && a.route != Route::Not && !a.delivered.never()
685}
686
687fn intern(table: &mut Vec<String>, ix: &mut HashMap<String, u32>, name: &str) -> u32 {
689 if let Some(&i) = ix.get(name) {
690 return i;
691 }
692 table.push(name.to_string());
693 ix.insert(name.to_string(), table.len() as u32 - 1);
694 table.len() as u32 - 1
695}
696
697const USED: [&str; 13] = [
699 outcome::GRADED,
700 outcome::ASKED,
701 outcome::DELIVERY,
702 outcome::ENDED,
703 outcome::CHANGED,
704 outcome::IGNORED,
705 outcome::UNCLEAR,
706 outcome::NOT_ARISEN,
707 outcome::REMAINS,
708 outcome::NOT_CORRECTED,
709 outcome::CORRECTED,
710 outcome::MISSED,
711 outcome::VETOED,
712];
713
714fn kind_of(line: &str) -> Option<&str> {
717 let rest = line.strip_prefix("{\"kind\":\"")?;
718 rest.split_once('"').map(|(k, _)| k)
719}
720
721#[must_use]
724pub fn fold(text: &str, from: Summary) -> Summary {
725 let mut s = from;
726 let mut ix = Index::of(&s);
727 for raw in text.split_inclusive('\n') {
728 if !raw.ends_with('\n') {
729 break;
730 }
731 s.at += raw.len() as u64;
732 let mut h = blake3::Hasher::new();
733 h.update(s.hash.as_bytes());
734 h.update(raw.as_bytes());
735 s.hash = h.finalize().to_hex()[..16].to_string();
737 s.line(raw.trim_end(), &mut ix);
738 }
739 s
740}
741
742impl Summary {
743 fn line(&mut self, line: &str, ix: &mut Index) {
744 let era = usize::from(self.era);
745 match kind_of(line) {
746 Some(outcome::REGRETTED) => {
747 self.signals[era].regrets += 1;
748 return;
749 }
750 Some(outcome::TESTED) => {
751 self.signals[era].tested += 1;
752 return;
753 }
754 Some(k) if !USED.contains(&k) => return,
755 _ => {}
756 }
757 let Ok(o) = serde_json::from_str::<Outcome>(line) else { return };
758 match o.kind.as_str() {
759 outcome::GRADED => self.graded(&o, ix),
760 outcome::CHANGED => self.verdict(&o, Label::Changed, ix),
761 outcome::IGNORED => self.verdict(&o, Label::Ignored, ix),
762 outcome::UNCLEAR => self.verdict(&o, Label::Unclear, ix),
763 outcome::NOT_ARISEN => self.verdict(&o, Label::NotArisen, ix),
764 outcome::REMAINS => self.verdict(&o, Label::Remains, ix),
765 outcome::NOT_CORRECTED => self.settled(&o, ix),
766 outcome::ASKED | outcome::DELIVERY => self.delivered(&o, ix),
767 outcome::ENDED => {
769 if let Some(&tick) = ix.sessions.get(&o.session) {
770 self.end(tick, ix);
771 }
772 }
773 outcome::CORRECTED => self.signals[era].corrections += 1,
774 outcome::MISSED if o.cause.is_empty() => self.signals[era].corrections += 1,
777 outcome::VETOED => self.signals[era].vetoes += 1,
778 outcome::REGRETTED => self.signals[era].regrets += 1,
779 outcome::TESTED => self.signals[era].tested += 1,
780 _ => {}
781 }
782 }
783
784 fn tick(&mut self, session: &str, ix: &mut Index) -> u32 {
787 if let Some(&t) = ix.sessions.get(session) {
788 return t;
789 }
790 if self.sessions.len() >= MAX_SESSIONS {
791 self.compact(ix, 0, MAX_SESSIONS / 4);
792 }
793 let had = self.fresh.remove(session).map(|f| f.had).unwrap_or_default();
794 self.sessions.push(Session { id: session.to_string(), had, ..Session::default() });
795 let t = self.sessions.len() as u32 - 1;
796 ix.sessions.insert(session.to_string(), t);
797 self.signals[usize::from(self.era)].sessions += 1;
798 t
799 }
800
801 fn graded(&mut self, o: &Outcome, ix: &mut Index) {
802 let tick = self.tick(&o.session, ix);
803 self.sessions[tick as usize].checks += 1;
804 let stop = o.channel != "live";
805 let spent = &mut self.spend[if stop { STOP } else { LIVE }];
806 spent.calls += 1;
807 spent.rounds += u64::from(stop && !o.chosen.is_empty());
808 spent.usd += o.usd;
809 if let Some(i) = FELL_BACK.iter().position(|r| *r == o.reason) {
812 self.fell_back[i] += 1;
813 }
814 if o.shown > 0 && !o.failed {
816 self.pool.shown += u64::from(o.shown);
817 self.pool.tokens += u64::from(o.tokens);
818 let bin = |k: u64| (u64::BITS - (k + 1).leading_zeros() - 1) as usize;
819 let last = bin(u64::from(o.shown) - 1);
820 if self.pool.bins.len() <= last {
821 self.pool.bins.resize(last + 1, (0, 0));
822 }
823 for (b, e) in self.pool.bins.iter_mut().enumerate().take(last + 1) {
824 let (lo, hi) = ((1u64 << b) - 1, (1u64 << (b + 1)) - 1);
825 e.0 += u64::from(o.shown).min(hi) - lo;
826 }
827 for &f in o.fill.iter().take(o.lessons.len()).filter(|f| u32::from(**f) < o.shown) {
828 if let Some(e) = self.pool.bins.get_mut(bin(u64::from(f))) {
829 e.1 += 1;
830 }
831 }
832 for (l, f) in tail_of(o) {
834 let b = bin(u64::from(f));
835 if self.pool.bins.len() <= b {
836 self.pool.bins.resize(b + 1, (0, 0));
837 }
838 let applied = u64::from(o.lessons.iter().any(|x| x == l));
839 let e = &mut self.pool.bins[b];
840 e.0 += 1;
841 e.1 += applied;
842 self.pool.tail.0 += 1;
843 self.pool.tail.1 += applied;
844 }
845 }
846 if o.dur_ms > 0 && o.tokens > 0 {
847 if self.pool.latency.len() >= LATENCY_KEEP {
848 self.pool.latency.remove(0);
849 }
850 self.pool.latency.push((o.tokens, u32::try_from(o.dur_ms).unwrap_or(u32::MAX)));
853 }
854 if stop && o.dur_ms > 0 && crate::harness::ALL.iter().any(|h| h.name() == o.harness) {
857 let calls = self.pool.stops.entry(o.harness.clone()).or_default();
858 if calls.len() >= LATENCY_KEEP {
859 calls.remove(0);
860 }
861 calls.push((u32::try_from(o.dur_ms).unwrap_or(u32::MAX), o.failed));
862 }
863 let learned = o.policy == "learned";
864 self.era |= learned;
865 let Ok(n) = u16::try_from(o.lessons.len()) else { return };
867 if o.failed || n == 0 || o.grades.len() != o.lessons.len() {
868 return;
869 }
870 let free = outcome::free_of(o);
871 let model = format!("{}|{}", o.model, o.prompt_hash);
872 let model = intern(&mut self.models, &mut ix.models, &model);
873 let ci = self.checks.len() as u32;
874 let first = self.applies.len() as u32;
875 let pending = u16::try_from(ix.open.get(&tick).copied().unwrap_or(0)).unwrap_or(u16::MAX);
876 let mut routes = Vec::with_capacity(o.lessons.len());
877 for (i, l) in o.lessons.iter().enumerate() {
878 let route = if o.chosen.contains(l) {
879 Route::Ask
880 } else if free.contains(l) {
881 Route::Free
882 } else if o.placebo.contains(l) {
883 Route::Placebo
884 } else {
885 Route::Not
886 };
887 routes.push(route);
888 let pf = o.pf.get(i).copied().unwrap_or(f64::from(u8::from(route == Route::Free)));
890 let lesson = intern(&mut self.lessons, &mut ix.lessons, l);
891 let rank = o.ranks.get(i).copied().unwrap_or(i as u16);
892 let psi = if matches!(route, Route::Placebo | Route::Not) { o.psi } else { 0.0 };
893 let nofree = o.nofree.contains(l);
896 let had = nofree || self.sessions[tick as usize].had.binary_search(&lesson).is_ok();
897 let p = o.p.get(i).copied().unwrap_or(0.0);
898 let a = Apply { check: ci, lesson, grade: o.grades[i], rank, route, p, pf, psi, had, nofree, pending, ..Apply::default() };
899 if waiting(&a) {
900 *ix.open.entry(tick).or_default() += 1;
901 }
902 self.applies.push(a);
903 ix.by_lesson.entry((tick, lesson)).or_default().push(first + i as u32);
904 }
905 let count = |r: Route| u16::try_from(routes.iter().filter(|x| **x == r).count()).unwrap_or(u16::MAX);
906 self.checks.push(Check {
907 id: o.check.clone(),
908 tick,
909 ms: o.ms,
910 stop: o.channel == "stop",
911 learned,
912 tokens: o.tokens,
913 model,
914 first,
915 n,
916 m_q: count(Route::Ask),
917 m_f: count(Route::Free),
918 tallied: false,
919 xk: None,
920 xs: None,
921 });
922 if learned {
923 self.count_test(o, ci as usize, tick);
924 }
925 if !o.check.is_empty() {
926 ix.checks.insert(o.check.clone(), ci);
927 }
928 ix.untallied.entry(tick).or_default().push(ci);
929 if learned && self.fit.model.is_some() {
930 let units: Vec<super::Unit> =
931 o.lessons.iter().zip(&o.grades).enumerate().map(|(r, (l, g))| super::Unit { lesson: l, grade: *g, rank: r }).collect();
932 let check = policy::Check { session: &o.session, stop: o.channel == "stop", tokens: o.tokens };
933 policy::ledger_add_without(&self.fit, &mut self.gate, &check, &units, &routes, &o.nofree);
935 }
936 if self.applies.len() > MAX_APPLIES {
937 self.compact(ix, MAX_APPLIES / 4, 0);
938 }
939 }
940
941 fn count_test(&mut self, o: &Outcome, ci: usize, tick: u32) {
944 let Some(k) = o.xk.filter(|k| *k as usize <= o.lessons.len()) else { return };
945 let source = o.source.as_str();
946 if ![policy::EXPERIMENT, policy::EXPERIMENT_CONTROL, policy::GATE].contains(&source) {
947 return;
948 }
949 let Some(s) = o.xs.filter(|s| *s <= k) else { return };
952 let e = &mut self.experiment;
953 if source == policy::GATE {
954 match e.refused.binary_search_by_key(&k, |c| c.0) {
955 Ok(i) => e.refused[i].1 += 1,
956 Err(i) => e.refused.insert(i, (k, 1)),
957 }
958 }
959 match e.ran.binary_search_by_key(&(k, s), |c| (c.0, c.1)) {
960 Ok(i) => e.ran[i].2 += 1,
961 Err(i) => e.ran.insert(i, (k, s, 1)),
962 }
963 e.control += u64::from(source == policy::EXPERIMENT_CONTROL);
964 (self.checks[ci].xk, self.checks[ci].xs) = (Some(k), Some(s));
965 let x = &mut self.sessions[tick as usize];
966 e.sessions += u32::from(!x.tested);
967 x.tested = true;
968 }
969
970 fn apply_of(&self, o: &Outcome, lesson: &str, routes: &[Route], ix: &Index) -> Option<usize> {
973 let l = *ix.lessons.get(lesson)?;
974 if !o.check.is_empty() {
975 let c = &self.checks[*ix.checks.get(&o.check)? as usize];
976 return (c.first..c.first + u32::from(c.n)).map(|i| i as usize).find(|&i| self.applies[i].lesson == l && routes.contains(&self.applies[i].route));
977 }
978 let tick = *ix.sessions.get(&o.session)?;
979 ix.by_lesson.get(&(tick, l))?.iter().rev().map(|&i| i as usize).find(|&i| {
980 let a = &self.applies[i];
981 routes.contains(&a.route) && self.checks[a.check as usize].ms < o.ms
982 })
983 }
984
985 fn stop_routes(&self, o: &Outcome, ix: &Index) -> &'static [Route] {
991 let live = ix.checks.get(&o.check).is_some_and(|&ci| self.checks.get(ci as usize).is_some_and(|c| !c.stop));
992 if live { &[Route::Ask, Route::Free] } else { &[Route::Ask] }
993 }
994
995 fn three(&self, ci: usize) -> bool {
998 let Some(c) = self.checks.get(ci) else { return false };
999 self.applies.get(c.first as usize..c.first as usize + usize::from(c.n)).is_some_and(|a| a.iter().any(|a| a.grade == 3))
1000 }
1001
1002 fn label(&mut self, i: usize, next: Label, ix: &mut Index) {
1004 let a = &self.applies[i];
1005 if !a.label.yields_to(next) {
1006 return;
1007 }
1008 let tick = self.checks[a.check as usize].tick;
1009 let before = waiting(a);
1010 self.applies[i].label = next;
1011 if before
1012 && !waiting(&self.applies[i])
1013 && let Some(n) = ix.open.get_mut(&tick)
1014 {
1015 *n = n.saturating_sub(1);
1016 }
1017 }
1018
1019 fn verdict(&mut self, o: &Outcome, label: Label, ix: &mut Index) {
1020 if o.channel == "early" {
1021 if !label.pending() {
1022 self.early.judged += 1;
1023 self.early.changed += u32::from(label == Label::Changed);
1024 }
1025 return;
1026 }
1027 let routes: &[Route] = match o.channel.as_str() {
1028 outcome::PLACEBO => &[Route::Placebo],
1029 "stop" => self.stop_routes(o, ix),
1030 "live" => &[Route::Ask],
1031 "queued" => &[Route::Free],
1032 _ => &[Route::Ask, Route::Free],
1033 };
1034 for l in &o.lessons {
1035 let Some(i) = self.apply_of(o, l, routes, ix) else { continue };
1036 self.label(i, label, ix);
1037 if self.applies[i].label == label {
1038 self.applies[i].jv = o.judge;
1039 }
1040 if !label.pending() {
1041 let model = self.models.get(self.checks[self.applies[i].check as usize].model as usize).cloned().unwrap_or_default();
1042 let at = if o.at.is_empty() { String::new() } else { format!("@{}", o.at) };
1046 let key = format!("v{}{at}|{model}", o.judge);
1047 self.applies[i].judge = Some(intern(&mut self.judges, &mut ix.judges, &key));
1048 self.applies[i].stripped = o.stripped;
1049 }
1050 }
1051 }
1052
1053 fn settled(&mut self, o: &Outcome, ix: &mut Index) {
1063 let tick = self.tick(&o.session, ix);
1064 self.end(tick, ix);
1065 for l in &o.lessons {
1066 if o.reason != outcome::UNJUDGED_NONE
1067 && let Some(i) = self.apply_of(o, l, &[Route::Ask, Route::Free, Route::Placebo], ix)
1068 {
1069 self.label(i, Label::Censored, ix);
1070 }
1071 if o.version.is_empty() {
1072 continue;
1073 }
1074 let Some(&lesson) = ix.lessons.get(l) else { continue };
1075 let open: Vec<usize> = ix
1076 .by_lesson
1077 .get(&(tick, lesson))
1078 .into_iter()
1079 .flatten()
1080 .map(|&i| i as usize)
1081 .filter(|&i| self.applies[i].delivered == Delivered::Yes && waiting(&self.applies[i]))
1082 .collect();
1083 for i in open {
1084 self.label(i, Label::Censored, ix);
1085 }
1086 }
1087 }
1088
1089 fn end(&mut self, tick: u32, ix: &mut Index) {
1091 self.sessions[tick as usize].ended = true;
1092 for ci in ix.untallied.remove(&tick).unwrap_or_default() {
1093 self.tally(ci as usize);
1094 }
1095 }
1096
1097 fn tally(&mut self, ci: usize) {
1099 let three = self.three(ci);
1100 let c = &mut self.checks[ci];
1101 c.tallied = true;
1102 let (tick, stop, range) = (c.tick as usize, c.stop, c.first as usize..c.first as usize + usize::from(c.n));
1103 for i in range {
1104 let Some(a) = self.applies.get(i) else { continue };
1105 for (which, hit) in counted(a, stop, three).into_iter().flatten() {
1106 let t = &mut self.sessions[tick].tally[which];
1107 t.0 += 1;
1108 t.1 += u32::from(hit);
1109 }
1110 }
1111 }
1112
1113 fn update(&mut self, i: usize, set: impl FnOnce(&mut Apply)) {
1116 let Some(ci) = self.applies.get(i).map(|a| a.check as usize) else { return };
1117 let Some(c) = self.checks.get(ci) else { return };
1118 let (tallied, stop, tick, three) = (c.tallied, c.stop, c.tick as usize, self.three(ci));
1119 let before = counted(&self.applies[i], stop, three);
1120 set(&mut self.applies[i]);
1121 let after = counted(&self.applies[i], stop, three);
1122 let Some(x) = self.sessions.get_mut(tick).filter(|_| tallied) else { return };
1123 for (b, a) in before.into_iter().zip(after) {
1124 match (b, a) {
1125 (Some((which, was)), Some((_, is))) if was != is => {
1126 let hits = &mut x.tally[which].1;
1127 *hits = if is { *hits + 1 } else { hits.saturating_sub(1) };
1128 }
1129 (Some((which, was)), None) => {
1131 let t = &mut x.tally[which];
1132 t.0 = t.0.saturating_sub(1);
1133 t.1 = t.1.saturating_sub(u32::from(was));
1134 }
1135 _ => {}
1136 }
1137 }
1138 }
1139
1140 fn show(&mut self, tick: Option<u32>, session: &str, lesson: &str, ix: &mut Index) -> u32 {
1143 let l = intern(&mut self.lessons, &mut ix.lessons, lesson);
1144 let had = match tick {
1145 Some(t) => &mut self.sessions[t as usize].had,
1146 None => {
1147 if self.fresh.len() >= MAX_FRESH && !self.fresh.contains_key(session) {
1148 let mut seqs: Vec<u64> = self.fresh.values().map(|f| f.seq).collect();
1149 seqs.sort_unstable();
1150 let keep = seqs[MAX_FRESH / 4];
1151 self.fresh.retain(|_, f| f.seq >= keep);
1152 }
1153 let seq = self.at;
1154 &mut self.fresh.entry(session.to_string()).or_insert_with(|| Fresh { seq, had: Vec::new() }).had
1155 }
1156 };
1157 if let Err(at) = had.binary_search(&l) {
1158 had.insert(at, l);
1159 }
1160 l
1161 }
1162
1163 fn delivered(&mut self, o: &Outcome, ix: &mut Index) {
1168 let (fate, routes): (Delivered, &[Route]) = match (o.kind.as_str(), o.route.as_str()) {
1169 (outcome::DELIVERY, outcome::ROUTE_ASKED) if o.channel == "stop" => (Delivered::Yes, self.stop_routes(o, ix)),
1170 (outcome::DELIVERY, outcome::ROUTE_ASKED) => (Delivered::Yes, &[Route::Ask]),
1171 (outcome::DELIVERY, outcome::ROUTE_FREE) => (Delivered::Yes, &[Route::Free]),
1172 (outcome::DELIVERY, outcome::ROUTE_PLACEBO) => (Delivered::Yes, &[Route::Placebo]),
1173 (outcome::DELIVERY, outcome::ROUTE_SUPERSEDED) => (Delivered::Superseded, &[Route::Ask]),
1174 (outcome::DELIVERY, _) => (Delivered::Dropped, &[Route::Ask, Route::Free, Route::Placebo]),
1175 (_, _) if o.channel == "queued" => (Delivered::Yes, &[Route::Free]),
1176 (_, _) if o.channel == "stop" => (Delivered::Yes, self.stop_routes(o, ix)),
1177 (_, _) if o.channel == "live" => (Delivered::Yes, &[Route::Ask]),
1178 _ => (Delivered::Yes, &[]),
1179 };
1180 let shown = fate == Delivered::Yes && o.route != outcome::ROUTE_PLACEBO;
1182 let Some(&tick) = ix.sessions.get(&o.session) else {
1183 for l in o.lessons.iter().filter(|_| shown) {
1185 self.show(None, &o.session, l, ix);
1186 }
1187 return;
1188 };
1189 for l in &o.lessons {
1190 let own = if routes.is_empty() { None } else { self.apply_of(o, l, routes, ix) };
1191 if let Some(i) = own {
1192 let before = waiting(&self.applies[i]);
1193 let c = &self.checks[self.applies[i].check as usize];
1194 let waited = fate == Delivered::Yes && o.channel == "stop" && !c.stop && self.applies[i].route == Route::Ask;
1195 self.update(i, |a| {
1196 a.delivered = fate;
1197 a.waited |= waited;
1198 });
1199 let after = waiting(&self.applies[i]);
1200 let open = ix.open.entry(tick).or_default();
1201 match (before, after) {
1202 (true, false) => *open = open.saturating_sub(1),
1203 (false, true) => *open += 1,
1204 _ => {}
1205 }
1206 }
1207 if !shown {
1208 continue;
1209 }
1210 let lesson = self.show(Some(tick), &o.session, l, ix);
1211 let own_check = own.map(|i| self.applies[i].check);
1212 for &i in ix.by_lesson.get(&(tick, lesson)).map_or(&[][..], Vec::as_slice) {
1213 let a = &self.applies[i as usize];
1214 if a.route == Route::Not && Some(a.check) != own_check && self.checks[a.check as usize].ms < o.ms {
1215 self.update(i as usize, |a| a.later = true);
1216 }
1217 }
1218 }
1219 if o.kind == outcome::DELIVERY && o.route == outcome::ROUTE_NEVER {
1221 self.end(tick, ix);
1222 }
1223 }
1224
1225 fn compact(&mut self, ix: &mut Index, applies: usize, sessions: usize) {
1231 let cut = self.checks.iter().position(|c| c.first as usize >= applies).unwrap_or(self.checks.len());
1232 let len = self.sessions.len();
1233 let oldest = self.checks[cut..].iter().map(|c| c.tick as usize).min().unwrap_or(len);
1234 let gone = oldest.max(sessions).min(len.saturating_sub(policy::FIT_SESSIONS));
1235 let gone = gone - gone % 2;
1236 self.past.add(&self.sessions[..gone]);
1237 self.sessions.drain(..gone);
1238 let (checks, all) = (std::mem::take(&mut self.checks), std::mem::take(&mut self.applies));
1239 for (ci, mut c) in checks.into_iter().enumerate() {
1240 if ci < cut || (c.tick as usize) < gone {
1241 continue;
1242 }
1243 let of = all.get(c.first as usize..c.first as usize + usize::from(c.n)).unwrap_or_default();
1244 let check = self.checks.len() as u32;
1245 (c.first, c.tick) = (self.applies.len() as u32, c.tick - gone as u32);
1246 self.applies.extend(of.iter().map(|a| Apply { check, ..a.clone() }));
1247 self.checks.push(c);
1248 }
1249 self.dropped += (all.len() - self.applies.len()) as u64;
1250 let had = self.sessions.iter().flat_map(|x| &x.had).chain(self.fresh.values().flat_map(|f| &f.had)).copied();
1251 let lessons = prune(&mut self.lessons, self.applies.iter().map(|a| a.lesson).chain(had));
1252 let judges = prune(&mut self.judges, self.applies.iter().filter_map(|a| a.judge));
1253 let models = prune(&mut self.models, self.checks.iter().map(|c| c.model));
1254 let new = |map: &[u32], i: u32| map.get(i as usize).copied().unwrap_or(i);
1255 for a in &mut self.applies {
1256 (a.lesson, a.judge) = (new(&lessons, a.lesson), a.judge.map(|j| new(&judges, j)));
1257 }
1258 for had in self.sessions.iter_mut().map(|x| &mut x.had).chain(self.fresh.values_mut().map(|f| &mut f.had)) {
1260 for l in had.iter_mut() {
1261 *l = new(&lessons, *l);
1262 }
1263 }
1264 for c in &mut self.checks {
1265 c.model = new(&models, c.model);
1266 }
1267 *ix = Index::of(self);
1268 }
1269}
1270
1271fn counted(a: &Apply, stop: bool, three: bool) -> [Option<(usize, bool)>; 2] {
1283 if a.had || a.delivered == Delivered::Superseded {
1284 return [None; 2];
1285 }
1286 match a.route {
1287 Route::Free => [Some((if stop { FREE_STOP } else { FREE_LIVE }, a.delivered == Delivered::Yes)), None],
1288 Route::Not if !stop => [Some((LATER, a.later)), None],
1289 Route::Ask if !stop && three => [Some((ASK_LIVE, a.delivered == Delivered::Yes)), Some((WAIT, a.waited))],
1290 _ => [None; 2],
1291 }
1292}
1293
1294fn tail_of(o: &Outcome) -> Vec<(&str, u16)> {
1298 let at = o.fill.get(o.lessons.len()..).unwrap_or_default();
1299 let past = at.iter().all(|f| u32::from(*f) >= o.shown && *f != u16::MAX);
1300 if o.tail.is_empty() || at.len() != o.tail.len() || !past || !(o.pt > 0.0 && o.pt <= 1.0) {
1301 return Vec::new();
1302 }
1303 o.tail.iter().map(String::as_str).zip(at.iter().copied()).collect()
1304}
1305
1306fn prune(table: &mut Vec<String>, used: impl Iterator<Item = u32>) -> Vec<u32> {
1308 let mut keep = vec![false; table.len()];
1309 for i in used {
1310 if let Some(k) = keep.get_mut(i as usize) {
1311 *k = true;
1312 }
1313 }
1314 let mut map = Vec::with_capacity(keep.len());
1315 for (name, k) in std::mem::take(table).into_iter().zip(keep) {
1316 map.push(table.len() as u32);
1317 if k {
1318 table.push(name);
1319 }
1320 }
1321 map
1322}
1323
1324#[must_use]
1326pub fn cache_path(lessons: &Path) -> PathBuf {
1327 lessons.with_file_name(CACHE)
1328}
1329
1330#[must_use]
1332pub fn reverted_path(lessons: &Path) -> PathBuf {
1333 lessons.with_file_name(REVERTED)
1334}
1335
1336#[must_use]
1339pub fn load(lessons: &Path) -> Summary {
1340 read(lessons, true)
1341}
1342
1343#[must_use]
1346pub fn peek(lessons: &Path) -> Summary {
1347 read(lessons, false)
1348}
1349
1350pub fn refresh(lessons: &Path) -> Summary {
1353 let (s, cached) = read_at(lessons, true);
1354 if cached != Some(s.at) {
1355 let _ = save(lessons, &s);
1356 }
1357 s
1358}
1359
1360fn read(lessons: &Path, rebuild_ok: bool) -> Summary {
1361 read_at(lessons, rebuild_ok).0
1362}
1363
1364fn read_at(lessons: &Path, rebuild_ok: bool) -> (Summary, Option<u64>) {
1366 let log = outcome::path_beside(lessons);
1367 let len = std::fs::metadata(&log).map_or(0, |m| m.len());
1368 let cached = std::fs::read(cache_path(lessons))
1369 .ok()
1370 .and_then(|b| serde_json::from_slice::<Summary>(&b).ok())
1371 .filter(|s| s.sv == SV && s.at <= len && s.consistent());
1372 let reverted = reverted_path(lessons).exists();
1373 let mut resumed = cached.as_ref().map(|s| s.at);
1374 let Some(mut s) = cached.or_else(|| rebuild_ok.then(|| rebuild(lessons))) else {
1375 return (Summary { monitor: Monitor { reverted, ..Monitor::default() }, ..Summary::default() }, None);
1376 };
1377 if s.at < len {
1378 match tail(&log, s.at) {
1379 Some(text) => s = fold(&text, s),
1380 None if rebuild_ok => (s, resumed) = (rebuild(lessons), None),
1381 None => (s, resumed) = (Summary::default(), None),
1382 }
1383 }
1384 s.monitor.reverted = reverted;
1386 (s, resumed)
1387}
1388
1389fn tail(log: &Path, at: u64) -> Option<String> {
1391 let mut f = std::fs::File::open(log).ok()?;
1392 f.seek(std::io::SeekFrom::Start(at)).ok()?;
1393 let mut bytes = Vec::new();
1394 f.read_to_end(&mut bytes).ok()?;
1395 String::from_utf8(bytes).ok()
1396}
1397
1398pub fn save(lessons: &Path, s: &Summary) -> std::io::Result<()> {
1400 let target = cache_path(lessons);
1401 let tmp = target.with_extension(format!("{}.tmp", std::process::id()));
1402 std::fs::write(&tmp, serde_json::to_vec(s).map_err(std::io::Error::other)?)?;
1403 std::fs::rename(&tmp, &target).inspect_err(|_| {
1404 let _ = std::fs::remove_file(&tmp);
1405 })
1406}
1407
1408#[must_use]
1411pub fn rebuild(lessons: &Path) -> Summary {
1412 let text = std::fs::read_to_string(outcome::path_beside(lessons)).unwrap_or_default();
1413 fold(&text, Summary::default())
1414}
1415
1416#[must_use]
1419pub fn with_fit(mut s: Summary, budget: Option<f64>) -> Summary {
1420 s.fit = policy::fit(&s, budget);
1421 s.gate = policy::ledger(&s);
1422 let sticky = s.monitor.reverted;
1423 s.monitor = policy::monitor(&s);
1424 s.monitor.reverted |= sticky;
1425 s
1426}
1427
1428pub fn refit(lessons: &Path) -> Summary {
1432 let s = with_fit(load(lessons), super::budget_of(lessons));
1433 if s.monitor.reverted {
1434 let _ = std::fs::write(reverted_path(lessons), format!("{}\n", serde_json::to_string(&s.monitor).unwrap_or_default()));
1435 }
1436 let _ = save(lessons, &s);
1437 s
1438}
1439
1440#[cfg(test)]
1441mod tests {
1442 use super::*;
1443
1444 fn rec(kind: &str, session: &str, lessons: &[&str], ms: i64) -> Outcome {
1445 Outcome { kind: kind.into(), session: session.into(), lessons: lessons.iter().map(|l| l.to_string()).collect(), ms, ..Default::default() }
1446 }
1447
1448 fn graded(session: &str, check: &str, lessons: &[&str], grades: &[u8], ask: &[&str], free: &[&str], ms: i64) -> Outcome {
1450 let owned = |v: &[&str]| v.iter().map(|l| l.to_string()).collect::<Vec<_>>();
1451 Outcome {
1452 grades: grades.to_vec(),
1453 ranks: (0..lessons.len() as u16).collect(),
1454 applied: lessons.len() as u16,
1455 chosen: owned(ask),
1456 free: owned(free),
1457 channel: "live".into(),
1458 check: check.into(),
1459 policy: "learned".into(),
1460 p: lessons.iter().map(|l| f64::from(u8::from(ask.contains(l)))).collect(),
1461 pf: lessons.iter().map(|l| f64::from(u8::from(free.contains(l)))).collect(),
1462 shown: 20,
1463 tokens: 6000,
1464 fill: (0..lessons.len() as u16).map(|i| 3 * i).collect(),
1465 dur_ms: 9000,
1466 ..rec(outcome::GRADED, session, lessons, ms)
1467 }
1468 }
1469
1470 fn verdict(kind: &str, session: &str, lesson: &str, check: &str, channel: &str, ms: i64) -> Outcome {
1471 Outcome { check: check.into(), channel: channel.into(), judge: 2, ..rec(kind, session, &[lesson], ms) }
1472 }
1473
1474 fn delivery(session: &str, lesson: &str, check: &str, route: &str, ms: i64) -> Outcome {
1475 Outcome { check: check.into(), route: route.into(), ..rec(outcome::DELIVERY, session, &[lesson], ms) }
1476 }
1477
1478 fn stop(session: &str, check: &str, lessons: &[&str], grades: &[u8], ask: &[&str], free: &[&str], ms: i64) -> Outcome {
1480 Outcome { channel: "stop".into(), ..graded(session, check, lessons, grades, ask, free, ms) }
1481 }
1482
1483 fn never(session: &str, check: &str, lessons: &[&str], ms: i64) -> Outcome {
1485 Outcome { check: check.into(), route: outcome::ROUTE_NEVER.into(), channel: "queued".into(), ..rec(outcome::DELIVERY, session, lessons, ms) }
1486 }
1487
1488 fn lines(log: &[Outcome]) -> String {
1489 log.iter().map(|o| format!("{}\n", serde_json::to_string(o).unwrap())).collect()
1490 }
1491
1492 fn log() -> Vec<Outcome> {
1494 let legacy = Outcome {
1495 check: String::new(),
1496 policy: String::new(),
1497 pf: Vec::new(),
1498 free: Vec::new(),
1499 ..graded("old", "", &["A", "B", "C"], &[2, 1, 1], &[], &[], 1)
1500 };
1501 let mut placebo = graded("s", "c3", &["A", "B", "C"], &[1, 1, 2], &[], &["C"], 40);
1502 placebo.placebo = vec!["A".into()];
1503 placebo.psi = 0.5;
1504 vec![
1505 legacy,
1506 Outcome { channel: "queued".into(), ..rec(outcome::ASKED, "old", &["A"], 2) },
1507 Outcome { channel: "queued".into(), ..rec(outcome::CHANGED, "old", &["A"], 3) },
1508 Outcome { channel: "queued".into(), ..rec(outcome::IGNORED, "old", &["B"], 4) },
1509 rec(outcome::NOT_CORRECTED, "old", &["C"], 5),
1510 graded("s", "c1", &["A", "B"], &[3, 2], &["A", "B"], &[], 10),
1511 delivery("s", "A", "c1", outcome::ROUTE_ASKED, 11),
1512 delivery("s", "B", "c1", outcome::ROUTE_DROPPED_DONE, 11),
1513 rec(outcome::REGRETTED, "s", &["A"], 12),
1514 Outcome { cause: "R1".into(), credit: 0.5, ..rec(outcome::REGRETTED, "s", &["B"], 12) },
1515 verdict(outcome::UNCLEAR, "s", "A", "c1", "live", 13),
1516 verdict(outcome::CHANGED, "s", "A", "c1", "live", 14),
1517 graded("s", "c2", &["D", "E"], &[1, 2], &[], &["E"], 20),
1518 Outcome { channel: "early".into(), ..rec(outcome::CHANGED, "s", &["Q"], 21) },
1519 delivery("s", "E", "c2", outcome::ROUTE_FREE, 22),
1520 Outcome { check: "c9".into(), channel: "live".into(), ..rec(outcome::ASKED, "s", &["D"], 30) },
1521 placebo,
1522 delivery("s", "A", "c3", outcome::ROUTE_PLACEBO, 41),
1523 verdict(outcome::IGNORED, "s", "A", "c3", outcome::PLACEBO, 42),
1524 verdict(outcome::CHANGED, "s", "C", "c3", "queued", 43),
1525 rec(outcome::VETOED, "s", &["C"], 44),
1526 Outcome { reason: outcome::UNJUDGED_TRIES.into(), check: "c2".into(), ..rec(outcome::NOT_CORRECTED, "s", &["E"], 50) },
1527 rec(outcome::TESTED, "s", &["A"], 51),
1528 Outcome { kind: "graded".into(), failed: true, tokens: 6000, dur_ms: 30_000, ..graded("s", "c4", &[], &[], &[], &[], 52) },
1529 ]
1530 }
1531
1532 fn label_of(s: &Summary, check: &str, lesson: &str) -> (Route, Label, Delivered) {
1533 let c = s.checks.iter().find(|c| c.id == check).unwrap();
1534 let a = s.applies[c.first as usize..(c.first + u32::from(c.n)) as usize].iter().find(|a| s.lessons[a.lesson as usize] == lesson).unwrap();
1535 (a.route, a.label, a.delivered)
1536 }
1537
1538 #[test]
1539 fn results_join_their_check_by_id_and_older_ones_by_the_last_check_before() {
1540 let s = fold(&lines(&log()), Summary::default());
1541 assert_eq!(s.checks.len(), 4, "the failed check has no applies");
1542 let old = &s.applies[..3];
1545 assert_eq!(
1547 old.iter().map(|a| (a.route, a.label)).collect::<Vec<_>>(),
1548 [(Route::Free, Label::Changed), (Route::Free, Label::Ignored), (Route::Free, Label::Censored)]
1549 );
1550 assert_eq!(old[0].delivered, Delivered::Yes);
1551 assert_eq!(old[0].pf, 1.0, "an older record's free lesson had pf 1");
1552 assert_eq!(label_of(&s, "c1", "A"), (Route::Ask, Label::Changed, Delivered::Yes));
1554 assert_eq!(label_of(&s, "c1", "B"), (Route::Ask, Label::Open, Delivered::Dropped));
1555 assert_eq!(label_of(&s, "c3", "A"), (Route::Placebo, Label::Ignored, Delivered::Yes));
1557 assert_eq!(label_of(&s, "c3", "C"), (Route::Free, Label::Changed, Delivered::Unknown));
1558 assert_eq!(label_of(&s, "c2", "E").1, Label::Censored);
1560 let d = s.applies.iter().find(|a| s.lessons[a.lesson as usize] == "D").unwrap();
1561 assert!(d.later && d.route == Route::Not);
1562 let mut more = log();
1564 more.insert(16, graded("s", "c2b", &["A"], &[1], &[], &[], 35));
1565 let s2 = fold(&lines(&more), Summary::default());
1566 assert!(!s2.applies[s2.checks.iter().find(|c| c.id == "c2b").unwrap().first as usize].later, "a placebo is no delivery");
1567 assert_eq!(s.applies.iter().find(|a| a.route == Route::Placebo).map(|a| a.psi), Some(0.5));
1568 assert_eq!((s.early.judged, s.early.changed), (1, 1));
1569 assert!(s.applies.iter().filter(|a| a.label == Label::Changed).all(|a| a.judge.is_some()));
1570 assert_eq!(s.judges, ["v0||", "v2||"], "the older verdicts' judge (version 0) and the new one's, each with its check's model");
1571 assert_eq!((s.signals[1].regrets, s.signals[1].vetoes, s.signals[1].tested), (2, 1, 1));
1573 assert_eq!(s.sessions.iter().map(|x| x.id.as_str()).collect::<Vec<_>>(), ["old", "s"]);
1574 assert!(s.sessions.iter().all(|x| x.ended));
1575 assert_eq!(s.sessions[1].tally[FREE_LIVE], (2, 1), "E delivered, C not");
1576 assert_eq!(s.sessions[1].tally[LATER], (2, 1), "c2's D delivered later, c3's B not");
1577 assert_eq!(s.pool.latency.len(), 5, "every timed call, the failed one too");
1578 assert_eq!(s.pool.bins[0], (4, 4), "position 0 shown by the four answered calls, applied in each");
1579 assert_eq!(s.pool.bins[2], (16, 6), "positions 3 to 6: fills 3 and 6 of the three-lesson checks, 3 of the two-lesson ones");
1580 }
1581
1582 fn early(session: &str, lessons: &[&str], ms: i64) -> Outcome {
1584 Outcome { channel: "early".into(), ..rec(outcome::ASKED, session, lessons, ms) }
1585 }
1586
1587 fn more() -> Vec<Outcome> {
1590 let tailed = Outcome {
1591 nofree: vec!["N1".into()],
1592 tail: vec!["T1".into(), "T2".into()],
1593 pt: 0.5,
1594 fill: vec![0, 3, 25, 25, 40],
1595 ..graded("n", "n1", &["A", "N1", "T1"], &[3, 1, 2], &["A"], &["T1"], 61)
1596 };
1597 vec![
1598 early("n", &["N1"], 60),
1599 tailed,
1600 delivery("n", "A", "n1", outcome::ROUTE_ASKED, 62),
1601 delivery("n", "T1", "n1", outcome::ROUTE_FREE, 62),
1602 rec(outcome::ENDED, "n", &[], 63),
1603 ]
1604 }
1605
1606 #[test]
1607 fn a_fold_of_the_whole_equals_its_folds_at_every_line_boundary() {
1608 let text = lines(&[log(), more()].concat());
1609 let mut prior = Summary::default();
1611 let fitted = with_fit(fold(&text, Summary::default()), None);
1612 prior.fit = fitted.fit.clone();
1613 assert!(prior.fit.model.is_some(), "the log has labels");
1614 let whole = fold(&text, prior.clone());
1615 assert!(whole.gate.checks > 0, "learned checks reached the ledger");
1616 let mut at = 0;
1617 let mut one = prior.clone();
1618 for line in text.split_inclusive('\n') {
1619 let split = fold(&text[at + line.len()..], fold(&text[..at + line.len()], prior.clone()));
1620 assert_eq!(split, whole, "split after byte {}", at + line.len());
1621 one = fold(line, one);
1622 at += line.len();
1623 }
1624 assert_eq!(one, whole, "line by line");
1625 assert_eq!(whole.at, text.len() as u64);
1626 let refit = with_fit(whole.clone(), None);
1628 assert_eq!(refit.fit.key, whole.key());
1629 }
1630
1631 #[test]
1632 fn a_line_being_written_waits_for_its_newline() {
1633 let text = lines(&log());
1634 let cut = text.len() - 10;
1635 let s = fold(&text[..cut], Summary::default());
1636 let last_newline = text[..cut].rfind('\n').unwrap() + 1;
1637 assert_eq!(s.at, last_newline as u64);
1638 assert_eq!(fold(&text[s.at as usize..], s), fold(&text, Summary::default()));
1639 }
1640
1641 #[test]
1642 fn kinds_it_does_not_use_cost_a_substring() {
1643 let text =
1645 [r#"{"kind":"regretted",broken"#, r#"{"kind":"something_new","session":"z"}"#, r#"{"kind":"graded","session":"s","lessons":[],"ms":1,"repo":""}"#]
1646 .map(|l| format!("{l}\n"))
1647 .concat();
1648 let text = text.as_str();
1649 let s = fold(text, Summary::default());
1650 assert_eq!(s.signals[0].regrets, 1);
1651 assert_eq!(s.sessions.len(), 1, "only the graded line registered a session");
1652 assert_eq!(kind_of("{\"kind\":\"asked\",\"x\":1}"), Some("asked"));
1653 assert_eq!(kind_of(" {\"kind\":\"asked\"}"), None);
1654 }
1655
1656 fn store(name: &str, text: &str) -> PathBuf {
1658 let d = std::env::temp_dir().join(format!("borg-summary-{name}-{}", std::process::id()));
1659 let _ = std::fs::remove_dir_all(&d);
1660 std::fs::create_dir_all(&d).unwrap();
1661 std::fs::write(d.join("outcomes.jsonl"), text).unwrap();
1662 d.join("lessons.jsonl")
1663 }
1664
1665 #[test]
1666 fn load_resumes_the_cache_and_rebuilds_what_it_cannot() {
1667 let text = lines(&log());
1668 let half = text[..text.len() / 2].rfind('\n').unwrap() + 1;
1669 let lessons = store("load", &text[..half]);
1670 let whole = fold(&text, Summary::default());
1671 save(&lessons, &load(&lessons)).unwrap();
1673 std::fs::write(outcome::path_beside(&lessons), &text).unwrap();
1674 assert_eq!(load(&lessons), whole);
1675 assert_eq!(peek(&lessons), whole);
1676 std::fs::write(cache_path(&lessons), "{\"sv\":1,\"at\":").unwrap();
1678 assert_eq!(load(&lessons), whole);
1679 assert_eq!(peek(&lessons), Summary::default());
1680 let mut other = whole.clone();
1682 other.sv = SV + 1;
1683 save(&lessons, &other).unwrap();
1684 assert_eq!(load(&lessons), whole);
1685 let mut ahead = whole.clone();
1686 ahead.at += 1_000;
1687 save(&lessons, &ahead).unwrap();
1688 assert_eq!(load(&lessons), whole);
1689 let mut broken = whole.clone();
1691 broken.checks[1].first += 1;
1692 save(&lessons, &broken).unwrap();
1693 assert_eq!((load(&lessons), peek(&lessons)), (whole.clone(), Summary::default()));
1694 let mut broken = whole.clone();
1695 broken.applies[0].lesson = 99;
1696 save(&lessons, &broken).unwrap();
1697 assert_eq!(load(&lessons), whole);
1698 let mut broken = whole.clone();
1699 broken.sessions[1].had.push(99);
1700 save(&lessons, &broken).unwrap();
1701 assert_eq!(load(&lessons), whole, "what a session had points outside the lessons");
1702 assert!(whole.consistent() && Summary::default().consistent());
1703 let fitted = with_fit(whole.clone(), None);
1706 assert!(fitted.fit.model.is_some() && fitted.monitor.ucb.is_none());
1707 save(&lessons, &fitted).unwrap();
1708 let parsed: Summary = serde_json::from_slice(&serde_json::to_vec(&fitted).unwrap()).unwrap();
1709 assert_eq!(load(&lessons), parsed);
1710 std::fs::write(reverted_path(&lessons), "{}\n").unwrap();
1712 assert!(load(&lessons).monitor.reverted && peek(&lessons).monitor.reverted);
1713 save(&lessons, &Summary { monitor: Monitor { reverted: true, ..fitted.monitor.clone() }, ..fitted.clone() }).unwrap();
1716 assert!(load(&lessons).monitor.reverted);
1717 std::fs::remove_file(reverted_path(&lessons)).unwrap();
1718 assert!(!load(&lessons).monitor.reverted && !peek(&lessons).monitor.reverted);
1719 std::fs::write(outcome::path_beside(&lessons), &text[..half]).unwrap();
1721 std::fs::remove_file(cache_path(&lessons)).unwrap();
1722 assert_eq!(refresh(&lessons).at, half as u64, "no cache: rebuilt and saved");
1723 let pretty = serde_json::to_vec_pretty(&peek(&lessons)).unwrap();
1725 std::fs::write(cache_path(&lessons), &pretty).unwrap();
1726 assert_eq!(refresh(&lessons).at, half as u64);
1727 assert_eq!(std::fs::read(cache_path(&lessons)).unwrap(), pretty, "nothing new: not rewritten");
1728 std::fs::write(outcome::path_beside(&lessons), &text).unwrap();
1729 assert_eq!(refresh(&lessons), whole);
1730 assert_eq!(peek(&lessons), whole, "the tail it folded is saved");
1731 assert_eq!(load(Path::new("/nonexistent/borg-summary/lessons.jsonl")), Summary::default());
1733 let _ = std::fs::remove_dir_all(lessons.parent().unwrap());
1734 }
1735
1736 #[test]
1737 fn compaction_keeps_the_newest_checks_and_their_joins() {
1738 let text = lines(&log());
1739 let mut s = fold(&text, Summary::default());
1740 let kept = s.checks.len() - 1;
1741 let mut ix = Index::of(&s);
1742 let first = s.checks[1].first as u64;
1743 s.compact(&mut ix, 1, 0);
1744 assert_eq!((s.checks.len(), s.dropped), (kept, first));
1745 assert_eq!(s.checks[0].first, 0);
1746 assert!(s.applies.iter().all(|a| (a.check as usize) < s.checks.len()));
1747 let more = lines(&[graded("s", "c5", &["Z"], &[2], &[], &["Z"], 60), verdict(outcome::CHANGED, "s", "Z", "c5", "queued", 61)]);
1749 let s = fold(&more, s);
1750 assert_eq!(label_of(&s, "c5", "Z").1, Label::Changed);
1751 }
1752
1753 #[test]
1754 fn every_ended_session_counts_its_deliveries_and_a_resumed_one_moves_them() {
1755 let ended = |session: &str, ms| rec(outcome::ENDED, session, &[], ms);
1756 let log = [
1757 stop("a", "a1", &["A", "B"], &[2, 1], &[], &["A", "B"], 1),
1759 never("a", "a1", &["A", "B"], 2),
1760 ended("a", 3),
1761 graded("b", "b1", &["C", "D"], &[3, 1], &["C"], &["D"], 10),
1763 delivery("b", "C", "b1", outcome::ROUTE_ASKED, 11),
1764 delivery("b", "D", "b1", outcome::ROUTE_FREE, 11),
1765 rec(outcome::CORRECTED, "b", &["C"], 12),
1766 ended("b", 13),
1767 stop("c", "c1", &["E"], &[2], &[], &["E"], 20),
1769 never("c", "c1", &["E"], 21),
1770 stop("d", "d1", &["F"], &[2], &[], &["F"], 30),
1772 ended("e", 31),
1774 ];
1775 let s = fold(&lines(&log), Summary::default());
1776 let of = |s: &Summary, id: &str| s.sessions.iter().find(|x| x.id == id).cloned().unwrap();
1777 assert_eq!(of(&s, "a").tally[FREE_STOP], (2, 0), "no next prompt came");
1778 assert_eq!((of(&s, "b").tally[FREE_LIVE], of(&s, "b").tally[WAIT]), ((1, 1), (1, 0)), "D delivered; C asked during the turn");
1779 assert_eq!(of(&s, "c").tally[FREE_STOP], (1, 0));
1780 assert_eq!(of(&s, "d").tally, [(0, 0); TALLIES]);
1781 assert_eq!(s.sessions.iter().map(|x| (x.id.as_str(), x.ended)).collect::<Vec<_>>(), [("a", true), ("b", true), ("c", true), ("d", false)]);
1782 let resumed = fold(&lines(&[delivery("a", "A", "a1", outcome::ROUTE_FREE, 40)]), s);
1784 assert_eq!(of(&resumed, "a").tally[FREE_STOP], (2, 1));
1785 }
1786
1787 #[test]
1788 fn left_for_later_is_a_live_checks_rate() {
1789 let log = [
1790 stop("s", "k1", &["L", "M"], &[1, 1], &[], &[], 1),
1793 graded("s", "k2", &["L"], &[3], &["L"], &[], 5),
1794 delivery("s", "L", "k2", outcome::ROUTE_ASKED, 6),
1795 graded("s", "k3", &["N"], &[1], &[], &[], 7),
1797 stop("s", "k4", &["N"], &[3], &["N"], &[], 8),
1798 Outcome { channel: "stop".into(), ..delivery("s", "N", "k4", outcome::ROUTE_ASKED, 9) },
1799 rec(outcome::ENDED, "s", &[], 10),
1800 ];
1801 let s = fold(&lines(&log), Summary::default());
1802 assert_eq!(s.sessions[0].tally[LATER], (1, 1), "k3's N alone");
1803 let k1 = s.checks[0].first as usize;
1805 assert!(s.applies[k1].later && !s.applies[k1 + 1].later, "L arrived later, M never");
1806 }
1807
1808 #[test]
1809 fn a_lesson_its_session_had_counts_in_no_delivery_tally() {
1810 let ended = |session: &str, ms| rec(outcome::ENDED, session, &[], ms);
1811 let log = [
1812 early("a", &["E"], 1),
1815 stop("a", "a1", &["E", "F"], &[2, 1], &[], &["E", "F"], 2),
1816 Outcome { channel: "queued".into(), ..rec(outcome::ASKED, "a", &["F"], 3) },
1817 ended("a", 4),
1818 graded("b", "b1", &["G"], &[2], &[], &["G"], 10),
1820 delivery("b", "G", "b1", outcome::ROUTE_FREE, 11),
1821 graded("b", "b2", &["G", "H"], &[2, 1], &[], &["G", "H"], 12),
1822 Outcome { reason: "given before".into(), ..delivery("b", "G", "b2", outcome::ROUTE_DROPPED_DONE, 13) },
1823 delivery("b", "H", "b2", outcome::ROUTE_FREE, 13),
1824 ended("b", 14),
1825 Outcome { nofree: vec!["K".into()], ..graded("c", "c1", &["K", "M"], &[2, 1], &[], &[], 20) },
1827 ended("c", 21),
1828 ];
1829 let s = fold(&lines(&log), Summary::default());
1830 let of = |id: &str| s.sessions.iter().find(|x| x.id == id).cloned().unwrap();
1831 assert_eq!(of("a").tally[FREE_STOP], (1, 1), "F alone: the session had E");
1832 assert_eq!(of("b").tally[FREE_LIVE], (2, 2), "b1's G and b2's H: at b2 the session had G");
1833 assert_eq!(of("c").tally[LATER], (1, 0), "M alone");
1834 let a1 = s.checks.iter().find(|c| c.id == "a1").unwrap().first as usize;
1835 assert!(s.applies[a1].had && !s.applies[a1 + 1].had);
1836 assert!(s.fresh.is_empty());
1838 let names = |x: &Session| x.had.iter().map(|&l| s.lessons[l as usize].clone()).collect::<Vec<_>>();
1839 assert_eq!((names(&of("a")), names(&of("b")), names(&of("c"))), (vec!["E".into(), "F".into()], vec!["G".into(), "H".into()], vec![]));
1840 }
1841
1842 #[test]
1843 fn a_live_question_is_delivered_when_it_reaches_the_drone() {
1844 let at_stop = |o: Outcome| Outcome { channel: "stop".into(), ..o };
1845 let log = [
1846 early("q", &["Q4", "Q6"], 1),
1850 graded("q", "q1", &["Q1", "Q2", "Q3", "Q4", "Q6"], &[3, 3, 3, 3, 3], &["Q1", "Q2", "Q3", "Q4", "Q6"], &[], 2),
1851 delivery("q", "Q1", "q1", outcome::ROUTE_ASKED, 3),
1852 delivery("q", "Q6", "q1", outcome::ROUTE_ASKED, 3),
1853 verdict(outcome::CHANGED, "q", "Q6", "q1", "live", 4),
1854 at_stop(delivery("q", "Q2", "q1", outcome::ROUTE_ASKED, 5)),
1856 Outcome { reason: "not deliverable".into(), ..delivery("q", "Q3", "q1", outcome::ROUTE_DROPPED_DONE, 5) },
1857 Outcome { reason: "done at the stop".into(), ..delivery("q", "Q4", "q1", outcome::ROUTE_DROPPED_DONE, 5) },
1858 stop("q", "q2", &["Q5"], &[3], &["Q5"], &[], 6),
1860 at_stop(delivery("q", "Q5", "q2", outcome::ROUTE_ASKED, 7)),
1861 rec(outcome::ENDED, "q", &[], 8),
1862 ];
1863 let s = fold(&lines(&log), Summary::default());
1864 assert_eq!((s.sessions[0].tally[ASK_LIVE], s.sessions[0].tally[WAIT]), ((3, 2), (3, 1)));
1865 let q6 = s.checks[0].first as usize + 4;
1866 assert_eq!((label_of(&s, "q1", "Q6"), s.applies[q6].had), ((Route::Ask, Label::Changed, Delivered::Yes), true));
1867 assert!((policy::fit(&s, None).rates.ask_live - 0.6).abs() < 1e-12);
1869 assert_eq!((policy::fit(&Summary::default(), None).rates.ask_live, policy::prior().rates.ask_live), (1.0, 1.0));
1871 let s = fold(&lines(&[at_stop(delivery("q", "Q3", "q1", outcome::ROUTE_ASKED, 9))]), s);
1873 assert_eq!((s.sessions[0].tally[ASK_LIVE], s.sessions[0].tally[WAIT]), ((3, 3), (3, 2)));
1874 }
1875
1876 #[test]
1877 fn a_superseded_ask_counts_nowhere_in_the_live_question_tally() {
1878 let log = [
1881 graded("a", "a1", &["A"], &[3], &["A"], &[], 1),
1882 graded("a", "a2", &["A"], &[3], &["A"], &[], 2),
1883 Outcome { channel: "live".into(), reason: "superseded".into(), ..delivery("a", "A", "a1", outcome::ROUTE_SUPERSEDED, 3) },
1884 Outcome { channel: "live".into(), ..delivery("a", "A", "a2", outcome::ROUTE_ASKED, 4) },
1885 rec(outcome::ENDED, "a", &[], 5),
1886 ];
1887 let s = fold(&lines(&log), Summary::default());
1888 assert_eq!(s.sessions[0].tally[ASK_LIVE], (1, 1), "the superseded ask is not an undelivered question");
1889 assert_eq!(label_of(&s, "a1", "A"), (Route::Ask, Label::Open, Delivered::Superseded));
1890 assert!(!waiting(&s.applies[s.checks[0].first as usize]), "no verdict waits on it");
1891 let ended = fold(&lines(&[graded("r", "r1", &["A"], &[3], &["A"], &[], 1), rec(outcome::ENDED, "r", &[], 2)]), Summary::default());
1893 assert_eq!(ended.sessions[0].tally[ASK_LIVE], (1, 0));
1894 let resumed = fold(&lines(&[Outcome { channel: "live".into(), ..delivery("r", "A", "r1", outcome::ROUTE_SUPERSEDED, 3) }]), ended);
1895 assert_eq!(resumed.sessions[0].tally[ASK_LIVE], (0, 0));
1896 let log = [
1898 graded("b", "b1", &["A"], &[3], &["A"], &[], 1),
1899 Outcome { channel: "live".into(), reason: "settled by later work".into(), ..delivery("b", "A", "b1", outcome::ROUTE_DROPPED_DONE, 3) },
1900 rec(outcome::ENDED, "b", &[], 5),
1901 ];
1902 assert_eq!(fold(&lines(&log), Summary::default()).sessions[0].tally[ASK_LIVE], (1, 0));
1903 }
1904
1905 #[test]
1906 fn a_hand_over_the_stop_asked_in_place_is_its_free_applys_delivery_and_verdict() {
1907 let at_stop = |o: Outcome| Outcome { channel: "stop".into(), ..o };
1911 let log = [
1912 graded("s", "k1", &["A", "H"], &[3, 2], &["A"], &["H"], 1),
1913 stop("s", "k2", &[], &[], &[], &[], 2),
1914 at_stop(Outcome { check: "k1".into(), ..rec(outcome::ASKED, "s", &["A"], 3) }),
1915 Outcome { reason: "at the stop".into(), ..at_stop(delivery("s", "A", "k1", outcome::ROUTE_ASKED, 3)) },
1916 at_stop(Outcome { check: "k1".into(), ..rec(outcome::ASKED, "s", &["H"], 3) }),
1917 Outcome { reason: "in place of a hand-over".into(), ..at_stop(delivery("s", "H", "k1", outcome::ROUTE_ASKED, 3)) },
1918 verdict(outcome::CHANGED, "s", "H", "k1", "stop", 4),
1920 verdict(outcome::IGNORED, "s", "A", "k1", "stop", 4),
1921 Outcome { reason: outcome::UNJUDGED_NONE.into(), check: "k1".into(), ..rec(outcome::NOT_CORRECTED, "s", &["A", "H"], 5) },
1922 rec(outcome::ENDED, "s", &[], 6),
1923 ];
1924 let s = fold(&lines(&log), Summary::default());
1925 assert_eq!(label_of(&s, "k1", "H"), (Route::Free, Label::Changed, Delivered::Yes), "the draw's free apply, delivered and judged");
1926 assert_eq!(label_of(&s, "k1", "A"), (Route::Ask, Label::Ignored, Delivered::Yes));
1927 assert_eq!(s.sessions[0].tally[FREE_LIVE], (1, 1), "H arrived");
1928 assert_eq!((s.sessions[0].tally[ASK_LIVE], s.sessions[0].tally[WAIT]), ((1, 1), (1, 1)));
1929 let log = [stop("t", "t1", &["F", "G"], &[3, 2], &["F"], &["G"], 1), verdict(outcome::CHANGED, "t", "G", "t1", "stop", 2)];
1931 assert_eq!(label_of(&fold(&lines(&log), Summary::default()), "t1", "G").1, Label::Open, "a stop's free lesson is judged on channel queued");
1932 }
1933
1934 #[test]
1935 fn a_live_question_of_a_check_without_a_3_is_no_question_that_waits_for_the_stop() {
1936 let log = [
1937 graded("t", "t1", &["A"], &[3], &["A"], &[], 1),
1939 Outcome { channel: "stop".into(), ..delivery("t", "A", "t1", outcome::ROUTE_ASKED, 2) },
1940 graded("t", "t2", &["B", "C"], &[2, 1], &["B"], &[], 3),
1942 Outcome { channel: "queued".into(), ..never("t", "t2", &["B"], 4) },
1943 rec(outcome::ENDED, "t", &[], 5),
1944 ];
1945 let s = fold(&lines(&log), Summary::default());
1946 assert_eq!((s.sessions[0].tally[ASK_LIVE], s.sessions[0].tally[WAIT]), ((1, 1), (1, 1)), "t1's A alone");
1947 assert_eq!(label_of(&s, "t2", "B"), (Route::Ask, Label::Open, Delivered::Dropped), "its apply still says what came of it");
1948 let s = fold(&lines(&[delivery("t", "B", "t2", outcome::ROUTE_ASKED, 6)]), s);
1950 assert_eq!((s.sessions[0].tally[ASK_LIVE], s.sessions[0].tally[WAIT]), ((1, 1), (1, 1)));
1951 }
1952
1953 #[test]
1954 fn a_tail_sample_teaches_the_yield_past_the_pool() {
1955 let tailed = |pt: f64, at: &[u16]| Outcome {
1958 shown: 4,
1959 tail: vec!["T1".into(), "T2".into()],
1960 pt,
1961 fill: [&[0, 9][..], at].concat(),
1962 ..graded("s", "t1", &["A", "T1"], &[2, 1], &[], &["A", "T1"], 1)
1963 };
1964 let s = fold(&lines(&[tailed(0.25, &[9, 20])]), Summary::default());
1965 assert_eq!(s.pool.bins, [(1, 1), (2, 0), (1, 0), (1, 1), (1, 0)]);
1967 assert_eq!((s.pool.shown, s.pool.tokens), (4, 6000), "the pool's own: the tail is extra");
1968 assert_eq!(s.pool.tail, (2, 1), "the tail's lessons shown and applied, as borg top counts them");
1969 for o in [tailed(0.0, &[9, 20]), tailed(1.5, &[9, 20]), tailed(0.25, &[9]), tailed(0.25, &[2, 20])] {
1971 let s = fold(&lines(&[o]), Summary::default());
1972 assert_eq!((s.pool.bins, s.pool.tail), (vec![(1, 1), (2, 0), (1, 0)], (0, 0)));
1973 }
1974 }
1975
1976 #[test]
1977 fn sessions_shown_lessons_before_a_check_stay_bounded() {
1978 let n = MAX_FRESH + 10;
1979 let text = lines(&(0..n).map(|t| early(&format!("e{t}"), &["L"], t as i64)).collect::<Vec<_>>());
1980 let s = fold(&text, Summary::default());
1981 assert!(s.fresh.len() <= MAX_FRESH, "{} kept", s.fresh.len());
1983 let newest = format!("e{}", n - 1);
1984 assert!(!s.fresh.contains_key("e0") && s.fresh.contains_key(&newest));
1985 assert!(s.sessions.is_empty() && s.consistent());
1986 for at in [text.len() / 2, text.len() - 100] {
1987 let at = text[..at].rfind('\n').unwrap() + 1;
1988 assert_eq!(fold(&text[at..], fold(&text[..at], Summary::default())), s, "split after byte {at}");
1989 }
1990 let s = fold(&lines(&[stop(&newest, "x1", &["L"], &[2], &[], &["L"], 9_999), rec(outcome::ENDED, &newest, &[], 10_000)]), s);
1992 assert_eq!(s.sessions[0].tally[FREE_STOP], (0, 0));
1993 }
1994
1995 #[test]
1996 fn a_regrets_missed_lesson_is_no_correction() {
1997 let log = [
1998 rec(outcome::MISSED, "s", &["A"], 1),
1999 Outcome { cause: "R1".into(), ..rec(outcome::MISSED, "s", &["B"], 2) },
2000 rec(outcome::CORRECTED, "s", &["C"], 3),
2001 ];
2002 assert_eq!(fold(&lines(&log), Summary::default()).signals[0].corrections, 2, "A's and C's: B's is the regret R1 again");
2003 }
2004
2005 #[test]
2006 fn the_monitors_weights_persist_through_save_and_fold() {
2007 let text = lines(&log());
2008 let half = text[..text.len() / 2].rfind('\n').unwrap() + 1;
2009 let lessons = store("monitor", &text[..half]);
2010 let s = Summary { monitor: Monitor { ess: 12.5, max_weight: 3.25, labelled: 7, ..Monitor::default() }, ..load(&lessons) };
2011 save(&lessons, &s).unwrap();
2012 std::fs::write(outcome::path_beside(&lessons), &text).unwrap();
2013 let folded = refresh(&lessons);
2014 assert_eq!(folded.at, text.len() as u64, "the tail folded");
2015 for x in [&folded, &peek(&lessons)] {
2016 assert_eq!((x.monitor.ess, x.monitor.max_weight, x.monitor.labelled), (12.5, 3.25, 7));
2017 }
2018 let _ = std::fs::remove_dir_all(lessons.parent().unwrap());
2019 }
2020
2021 fn many(n: usize) -> String {
2024 let mut log = Vec::new();
2025 for t in 0..n {
2026 let (id, check, lesson, ms) = (format!("s{t}"), format!("c{t}"), format!("L{t}"), 10 * t as i64);
2027 log.push(early(&id, &[&format!("X{t}")], ms));
2028 log.push(graded(&id, &check, &[&lesson], &[2], &[], &[&lesson], ms));
2029 if t % 2 == 0 {
2030 log.push(delivery(&id, &lesson, &check, outcome::ROUTE_FREE, ms + 1));
2031 }
2032 log.push(rec(outcome::ENDED, &id, &[], ms + 2));
2033 }
2034 lines(&log)
2035 }
2036
2037 #[test]
2038 fn the_session_table_stays_bounded_and_keeps_what_its_readers_need() {
2039 let n = MAX_SESSIONS + 2;
2040 let text = many(n);
2041 let s = fold(&text, Summary::default());
2042 assert!(s.sessions.len() <= MAX_SESSIONS, "{} sessions kept", s.sessions.len());
2044 assert_eq!((n - s.sessions.len()) % 2, 0, "the monitor's halves keep their sessions");
2045 assert_eq!(s.sessions.last().map(|x| x.id.clone()), Some(format!("s{}", n - 1)));
2046 assert!(s.consistent());
2047 assert_eq!((s.checks.len(), s.dropped), (s.sessions.len(), (n - s.sessions.len()) as u64));
2050 assert_eq!(s.lessons.len(), 2 * s.applies.len());
2051 let newest = s.sessions.last().unwrap();
2052 let shown: Vec<&str> = newest.had.iter().map(|&l| s.lessons[l as usize].as_str()).collect();
2053 assert_eq!(shown, [format!("X{}", n - 1)], "an odd session: its free lesson never came");
2054 let even = &s.sessions[s.sessions.len() - 2];
2055 let shown: Vec<&str> = even.had.iter().map(|&l| s.lessons[l as usize].as_str()).collect();
2056 assert_eq!(shown, [format!("X{}", n - 2), format!("L{}", n - 2)]);
2057 for h in policy::HALF_LIVES {
2060 let want = (0..n).fold((0.0, 0.0), |(k, miss), t| {
2061 let w = policy::decay((n - t) as u32, h);
2062 if t % 2 == 0 { (k + w, miss) } else { (k, miss + w) }
2063 });
2064 let got = s.tallies(h)[FREE_LIVE];
2065 let close = |a: f64, b: f64| (a - b).abs() <= 1e-9 * b.max(1.0);
2066 assert!(close(got.0, want.0) && close(got.1, want.1), "{h:?}: {got:?} against {want:?}");
2067 }
2068 assert_eq!(s.checked(), (n as u64, n as u64));
2069 for at in [text.len() / 3, text.len() - 200] {
2071 let at = text[..at].rfind('\n').unwrap() + 1;
2072 assert_eq!(fold(&text[at..], fold(&text[..at], Summary::default())), s, "split after byte {at}");
2073 }
2074 }
2075
2076 #[test]
2082 fn the_question_count_tests_checks_count_by_k_and_s_and_mark_their_check() {
2083 let x = |session: &str, check: &str, source: &str, k: Option<u32>, s: Option<u32>, ms| Outcome {
2084 source: source.into(),
2085 xk: k,
2086 xs: s,
2087 ..graded(session, check, &["A", "B", "C"], &[2, 2, 1], &["A"], &["B", "C"], ms)
2088 };
2089 let text = lines(&[
2090 x("x", "e1", policy::EXPERIMENT, Some(3), Some(1), 10),
2091 x("x", "e2", policy::EXPERIMENT, Some(3), Some(1), 20),
2092 x("x", "e3", policy::EXPERIMENT_CONTROL, Some(3), Some(0), 30),
2093 x("x", "g1", policy::GATE, Some(3), None, 40),
2095 x("x", "g2", policy::GATE, Some(3), Some(2), 45),
2097 x("x", "bad", policy::EXPERIMENT, Some(3), Some(4), 50),
2099 x("x", "big", policy::EXPERIMENT, Some(9), Some(1), 60),
2100 x("x", "ev", policy::EVIDENCE, None, None, 70),
2101 Outcome { policy: "fixed".into(), ..x("x", "f1", policy::EXPERIMENT, Some(3), Some(2), 80) },
2102 x("y", "y1", policy::EXPERIMENT, Some(2), Some(2), 90),
2103 ]);
2104 let s = fold(&text, Summary::default());
2105 let e = &s.experiment;
2106 assert_eq!(
2107 (e.ran.as_slice(), e.control, e.refused.as_slice(), e.sessions),
2108 (&[(2, 2, 1), (3, 0, 1), (3, 1, 2), (3, 2, 1)][..], 1, &[(3, 1)][..], 2),
2109 "{e:?}"
2110 );
2111 assert_eq!(e.totals(), (5, 1));
2112 let marked: Vec<(&str, Option<u32>, Option<u32>)> = s.checks.iter().map(|c| (c.id.as_str(), c.xk, c.xs)).filter(|c| c.1.is_some()).collect();
2113 assert_eq!(marked, [("e1", Some(3), Some(1)), ("e2", Some(3), Some(1)), ("e3", Some(3), Some(0)), ("g2", Some(3), Some(2)), ("y1", Some(2), Some(2))]);
2114 assert!(s.sessions.iter().all(|x| x.tested));
2115 let at = text[..text.len() / 2].rfind('\n').unwrap() + 1;
2117 assert_eq!(fold(&text[at..], fold(&text[..at], Summary::default())), s);
2118 let mut compacted = s.clone();
2120 let mut ix = Index::of(&compacted);
2121 compacted.compact(&mut ix, s.applies.len(), 0);
2122 assert_eq!((compacted.checks.len(), &compacted.experiment), (0, &s.experiment));
2123 }
2124
2125 #[test]
2126 fn a_learned_decision_the_fixed_rule_took_over_counts_by_its_reason() {
2127 let fell = |check: &str, reason: &str, applied: &[&str], ms| Outcome {
2128 policy: "fixed".into(),
2129 reason: reason.into(),
2130 ..graded("f", check, applied, &vec![1; applied.len()], &[], applied, ms)
2131 };
2132 let text = lines(&[
2133 fell("f1", outcome::DECIDED_BUSY, &["A"], 1),
2134 fell("f2", outcome::DECIDED_BUSY, &["B"], 2),
2135 fell("f3", outcome::DECIDED_NO_LOCK, &[], 3),
2137 fell("f4", outcome::DECIDED_NO_RECORD, &["A"], 4),
2138 graded("f", "f5", &["A"], &[1], &[], &["A"], 5),
2140 fell("f6", "something_new", &["A"], 6),
2141 ]);
2142 let s = fold(&text, Summary::default());
2143 assert_eq!(s.fell_back, [2, 1, 1], "lock_busy, lock_failed, unrecorded");
2144 assert_eq!(s.checked().1, 6, "the graded checks a fallback is one of");
2145 let at = text[..text.len() / 2].rfind('\n').unwrap() + 1;
2147 assert_eq!(fold(&text[at..], fold(&text[..at], Summary::default())), s);
2148 let lessons = store("fell-back", &text);
2151 save(&lessons, &s).unwrap();
2152 assert_eq!(peek(&lessons).fell_back, s.fell_back);
2153 save(&lessons, &Summary { sv: 5, fell_back: Default::default(), ..s.clone() }).unwrap();
2154 assert_eq!(load(&lessons), s);
2155 let _ = std::fs::remove_dir_all(lessons.parent().unwrap());
2156 }
2157
2158 #[test]
2159 fn the_prior_writes_the_short_line() {
2160 let prior = Summary::default();
2161 assert_eq!((prior.sv, prior.at, &prior.fit), (SV, 0, &Fit::default()));
2162 let line = serde_json::to_string(&prior).unwrap();
2163 assert_eq!(line, format!(r#"{{"sv":{SV},"at":0,"fit":{{}}}}"#));
2164 assert_eq!(serde_json::from_str::<Summary>(&line).unwrap(), prior);
2165 let none = Path::new("/nonexistent/borg-summary/lessons.jsonl");
2166 assert_eq!(load(none), prior);
2167 assert_eq!(rebuild(none), prior);
2168 }
2169}