1pub mod policy;
38pub mod replay;
39pub mod summary;
40
41use std::path::Path;
42
43use serde::{Deserialize, Serialize};
44
45use crate::attention;
46use crate::conscience::{self, Challenge};
47use crate::outcome;
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum Policy {
52 Fixed,
54 Cap(usize),
56 Attention,
58 Learned,
60}
61
62impl Policy {
63 #[must_use]
65 pub fn name(self) -> &'static str {
66 match self {
67 Policy::Fixed => "fixed",
68 Policy::Cap(_) => "cap",
69 Policy::Attention => "attention",
70 Policy::Learned => "learned",
71 }
72 }
73}
74
75#[must_use]
77pub fn policy() -> Policy {
78 precedence(std::env::var("BORG_SELECT").ok().as_deref(), conscience::ask_cap(), attention::on())
79}
80
81#[must_use]
89pub fn precedence(select: Option<&str>, cap: Option<usize>, attention: bool) -> Policy {
90 match (select, cap) {
91 (Some("fixed"), _) => Policy::Fixed,
92 (_, Some(n)) => Policy::Cap(n),
93 _ if attention => Policy::Attention,
94 _ => Policy::Learned,
95 }
96}
97
98#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
100#[serde(rename_all = "lowercase")]
101pub enum Route {
102 Ask,
104 Free,
106 Placebo,
108 #[default]
110 Not,
111}
112
113impl Route {
114 #[must_use]
115 pub fn name(self) -> &'static str {
116 match self {
117 Route::Ask => "ask",
118 Route::Free => "free",
119 Route::Placebo => "placebo",
120 Route::Not => "not",
121 }
122 }
123
124 #[must_use]
127 pub const fn arm(self) -> usize {
128 match self {
129 Route::Ask => 0,
130 Route::Free => 1,
131 Route::Placebo | Route::Not => 2,
132 }
133 }
134}
135
136pub const ARMS: usize = 3;
139
140#[derive(Debug, Clone, PartialEq)]
142pub struct Unit<'a> {
143 pub lesson: &'a str,
144 pub grade: u8,
146 pub rank: usize,
148}
149
150#[must_use]
152pub fn units<'a>(applied: &[(&'a Challenge, u8)]) -> Vec<Unit<'a>> {
153 applied.iter().enumerate().map(|(rank, (c, g))| Unit { lesson: c.id.as_str(), grade: *g, rank }).collect()
154}
155
156pub struct Ctx<'a> {
158 pub session: &'a str,
159 pub check: &'a str,
161 pub channel: &'static str,
163 pub lessons: &'a Path,
165 pub summary: &'a summary::Summary,
167 pub tokens: u32,
171 pub had: &'a [String],
179 pub alpha: f64,
184}
185
186#[must_use]
190pub fn shown_tokens(shown: &[&Challenge]) -> u32 {
191 let sum: usize = shown.iter().map(|c| crate::whole::cost(c)).sum();
192 sum.try_into().unwrap_or(u32::MAX)
193}
194
195#[must_use]
198pub fn nofree(applied: &[(&Challenge, u8)], had: &[String]) -> Vec<String> {
199 let mut out: Vec<String> = Vec::new();
200 for (c, _) in applied {
201 if had.contains(&c.id) && !out.contains(&c.id) {
202 out.push(c.id.clone());
203 }
204 }
205 out
206}
207
208#[must_use]
211pub fn logged_alpha(alpha: f64) -> Option<f64> {
212 (alpha != policy::ALPHA).then_some(alpha)
213}
214
215#[must_use]
218pub fn alpha_of(logged: Option<f64>) -> f64 {
219 logged.unwrap_or(policy::ALPHA)
220}
221
222#[derive(Debug, Clone, PartialEq, Default)]
224pub struct Log {
225 pub policy: &'static str,
227 pub source: &'static str,
234 pub xk: Option<u32>,
238 pub xs: Option<u32>,
239 pub alpha: Option<f64>,
243 pub fit: String,
245 pub gate: f64,
249 pub risk: f64,
252 pub attention: Option<String>,
255 pub psi: f64,
258}
259
260impl Log {
261 #[must_use]
264 pub fn line(&self, applied: usize, check: &str) -> String {
265 let fit = if self.fit.is_empty() { "-" } else { self.fit.as_str() };
266 format!("#borg-select policy={} source={} fit={fit} gate={:.4} applied={applied} @{check}", self.policy, self.source, self.gate)
267 }
268}
269
270#[derive(Debug, Clone)]
274pub struct Routed<'a> {
275 pub ask: Vec<&'a Challenge>,
276 pub free: Vec<&'a Challenge>,
277 pub placebo: Vec<&'a Challenge>,
278 pub p: Vec<f64>,
279 pub pf: Vec<f64>,
280 pub log: Log,
281}
282
283impl Routed<'_> {
284 #[must_use]
286 pub fn route(&self, id: &str) -> Route {
287 let has = |v: &[&Challenge]| v.iter().any(|c| c.id == id);
288 if has(&self.ask) {
289 Route::Ask
290 } else if has(&self.free) {
291 Route::Free
292 } else if has(&self.placebo) {
293 Route::Placebo
294 } else {
295 Route::Not
296 }
297 }
298}
299
300#[must_use]
311pub fn graded_under<'a>(policy: Policy, applied: &[(&'a Challenge, u8)], ctx: &Ctx) -> Routed<'a> {
312 let three = applied.iter().any(|(_, g)| *g == 3);
313 let fixed = |source: &'static str, placebos: bool, (ask, free): (Vec<&'a Challenge>, Vec<&'a Challenge>)| {
314 let (placebo, psi) = if placebos { draw_placebo(applied, &ask, &free, ctx) } else { (Vec::new(), 0.0) };
315 Routed {
316 p: attention::fixed_p(applied, &ask),
317 pf: attention::fixed_p(applied, &free),
318 ask,
319 free,
320 placebo,
321 log: Log { policy: policy.name(), source, fit: ctx.summary.fit.key.clone(), psi, ..Log::default() },
322 }
323 };
324 let mut r = match policy {
325 Policy::Attention if three => by_cut(applied, ctx),
326 Policy::Cap(n) => fixed("cap", false, conscience::graded_without(applied, Some(n), ctx.had)),
327 Policy::Learned => learned(applied, ctx),
328 Policy::Fixed => fixed("fixed", true, conscience::graded_without(applied, None, ctx.had)),
329 Policy::Attention => fixed("fixed", false, conscience::graded_without(applied, None, ctx.had)),
330 };
331 r.log.alpha = logged_alpha(ctx.alpha);
332 r
333}
334
335pub const PLACEBO_SLOTS: usize = 2;
338
339fn draw_placebo<'a>(applied: &[(&'a Challenge, u8)], ask: &[&Challenge], free: &[&Challenge], ctx: &Ctx) -> (Vec<&'a Challenge>, f64) {
346 let delivered = |id: &str| ask.iter().chain(free).any(|c| c.id == id);
347 let mut later: Vec<usize> = Vec::new();
348 for (j, (c, _)) in applied.iter().enumerate() {
349 if !delivered(&c.id) && !ctx.had.contains(&c.id) && !later.iter().any(|&i| applied[i].0.id == c.id) {
350 later.push(j);
351 }
352 }
353 let mut drawn = later.clone();
354 let mut rng = crate::memory::Rng::new(seed(ctx) ^ 0x706c_6163_6562_6f00);
356 for i in (1..drawn.len()).rev() {
357 drawn.swap(i, (rng.next_u64() % (i as u64 + 1)) as usize);
358 }
359 drawn.truncate(PLACEBO_SLOTS);
360 drawn.sort_unstable();
361 let psi = if later.is_empty() { 0.0 } else { outcome::round_p((PLACEBO_SLOTS as f64 / later.len() as f64).min(1.0)) };
362 (drawn.iter().map(|&j| applied[j].0).collect(), psi)
363}
364
365pub const REVERTED_SOURCE: &str = "reverted";
369
370fn learned<'a>(applied: &[(&'a Challenge, u8)], ctx: &Ctx) -> Routed<'a> {
376 let us = units(applied);
377 let s = ctx.summary;
378 let check = policy::Check { session: ctx.session, stop: ctx.channel == "stop", tokens: ctx.tokens };
379 let d = match s.monitor.reverted {
380 true => {
381 let base = policy::fixed_routes(&us, ctx.had);
382 let p = |r: Route| base.iter().map(|x| f64::from(u8::from(*x == r))).collect();
383 policy::Decision { p: p(Route::Ask), pf: p(Route::Free), routes: base, source: REVERTED_SOURCE, gate: 0.0, risk: 0.0, xk: None, xs: None }
384 }
385 false => policy::choose(&us, &check, &s.fit, &s.gate, ctx.alpha, seed(ctx), ctx.had),
386 };
387 let (ask, free) = ordered(applied, &d.routes);
388 let (placebo, psi) = draw_placebo(applied, &ask, &free, ctx);
389 Routed {
390 ask,
391 free,
392 placebo,
393 p: d.p,
394 pf: d.pf,
395 log: Log {
396 policy: Policy::Learned.name(),
397 source: d.source,
398 fit: s.fit.key.clone(),
399 gate: d.gate,
400 risk: d.risk,
401 xk: d.xk,
402 xs: d.xs,
403 alpha: None,
405 attention: None,
406 psi,
407 },
408 }
409}
410
411fn by_cut<'a>(applied: &[(&'a Challenge, u8)], ctx: &Ctx) -> Routed<'a> {
416 let outcomes = outcome::read(&outcome::path_beside(ctx.lessons));
417 let base = attention::cut(&attention::labels(&outcomes));
418 let (p, pf) = (attention::propensities(applied.len(), base.k), free_p(applied.len(), base.k));
419 let checks = outcomes.iter().filter(|o| o.kind == outcome::GRADED && !o.lessons.is_empty() && !o.failed).count();
422 let cut = match attention::explores(checks) {
423 true => attention::Cut { k: attention::RAIL, source: "explore", ..base },
424 false => base,
425 };
426 let (ask, free) = attention::split(applied, &cut);
427 let log = Log {
428 policy: Policy::Attention.name(),
429 source: cut.source,
430 fit: ctx.summary.fit.key.clone(),
431 attention: Some(attention::line(&cut, applied.len())),
432 ..Log::default()
433 };
434 Routed { ask, free, placebo: Vec::new(), p, pf, log }
435}
436
437fn free_p(n: usize, k: usize) -> Vec<f64> {
441 let q = 1.0 / attention::EXPLORE as f64;
442 let (cut, rail) = (k.min(attention::RAIL).min(n), attention::RAIL.min(n));
443 let within = |r: usize, from: usize| f64::from(u8::from(r >= from && r < from + conscience::CAP));
444 (0..n).map(|r| outcome::round_p((1.0 - q) * within(r, cut) + q * within(r, rail))).collect()
445}
446
447fn ordered<'a>(applied: &[(&'a Challenge, u8)], routes: &[Route]) -> (Vec<&'a Challenge>, Vec<&'a Challenge>) {
450 let mut order: Vec<usize> = (0..applied.len().min(routes.len())).collect();
451 order.sort_by_key(|&i| std::cmp::Reverse(applied[i].1));
452 let of = |r: Route| order.iter().filter(|&&i| routes[i] == r).map(|&i| applied[i].0).collect();
453 (of(Route::Ask), of(Route::Free))
454}
455
456#[must_use]
460pub fn seed(ctx: &Ctx) -> u64 {
461 let mut h = blake3::Hasher::new();
462 h.update(b"borg-select-seed-v1\0");
463 for part in [ctx.session, ctx.channel, ctx.check] {
464 h.update(part.as_bytes());
465 h.update(b"\0");
466 }
467 let bytes = h.finalize();
468 const WIDTH: usize = std::mem::size_of::<u64>();
470 let mut first = [0u8; WIDTH];
471 first.copy_from_slice(&bytes.as_bytes()[..WIDTH]);
472 u64::from_le_bytes(first)
473}
474
475#[must_use]
478pub fn pool_tokens(s: &summary::Summary, limit_ms: Option<u64>, session: &str) -> usize {
479 pool_tokens_under(policy(), s, limit_ms, session)
480}
481
482#[must_use]
488pub fn pool_tokens_under(policy: Policy, s: &summary::Summary, limit_ms: Option<u64>, session: &str) -> usize {
489 policy::pool_tokens(&s.fit, pool_active(policy, s), limit_ms, policy::session_seed(session))
490}
491
492fn pool_active(policy: Policy, s: &summary::Summary) -> bool {
498 policy == Policy::Learned && !s.monitor.reverted && (s.gate.allowance > 0.0 || s.fit.budget.is_some())
499}
500
501#[must_use]
505pub fn budget_of(lessons: &Path) -> Option<f64> {
506 budget_in(&crate::collective::load().ok().flatten()?, lessons)
507}
508
509fn budget_in(cfg: &crate::collective::Config, lessons: &Path) -> Option<f64> {
511 entry_in(cfg, lessons)?.budget()
512}
513
514#[must_use]
519pub fn risk_of(lessons: &Path) -> Option<f64> {
520 risk_in(&crate::collective::load().ok().flatten()?, lessons)
521}
522
523fn risk_in(cfg: &crate::collective::Config, lessons: &Path) -> Option<f64> {
525 entry_in(cfg, lessons)?.risk()
526}
527
528#[must_use]
532pub fn alpha(lessons: &Path) -> f64 {
533 risk_of(lessons).unwrap_or(policy::ALPHA)
534}
535
536fn entry_in<'a>(cfg: &'a crate::collective::Config, lessons: &Path) -> Option<&'a crate::collective::Entry> {
538 let store = lessons.parent()?;
539 let base = crate::collective::root_of(store);
540 cfg.collectives.iter().find(|c| crate::collective::store(&base, &c.name) == store)
541}
542
543pub const CHECK_HEX: usize = 12;
546
547#[must_use]
552pub fn check_id(session: &str, channel: &str, start_ms: i64, pid: u32) -> String {
553 let mut h = blake3::Hasher::new();
554 h.update(b"borg-check-v1\0");
555 h.update(format!("{session}\0{channel}\0{start_ms}\0{pid}").as_bytes());
556 h.finalize().to_hex()[..CHECK_HEX].to_string()
557}
558
559#[cfg(test)]
560mod tests {
561 use super::*;
562
563 fn lessons(n: usize) -> Vec<Challenge> {
564 (0..n).map(|i| Challenge { id: format!("L{i:02}"), ..Default::default() }).collect()
565 }
566
567 fn ids(v: &[&Challenge]) -> Vec<String> {
568 v.iter().map(|c| c.id.clone()).collect()
569 }
570
571 fn store(name: &str, outcomes: &[outcome::Outcome]) -> std::path::PathBuf {
573 let d = std::env::temp_dir().join(format!("borg-select-{name}-{}", std::process::id()));
574 let _ = std::fs::remove_dir_all(&d);
575 std::fs::create_dir_all(&d).unwrap();
576 let text: String = outcomes.iter().map(|o| format!("{}\n", serde_json::to_string(o).unwrap())).collect();
577 std::fs::write(d.join("outcomes.jsonl"), text).unwrap();
578 d.join("lessons.jsonl")
579 }
580
581 fn ctx<'a>(lessons: &'a Path, s: &'a summary::Summary) -> Ctx<'a> {
582 Ctx { session: "s", check: "0123456789ab", channel: "stop", lessons, summary: s, tokens: 0, had: &[], alpha: policy::ALPHA }
583 }
584
585 fn tables(n: usize) -> Vec<Vec<u8>> {
587 let mut out = vec![Vec::new()];
588 for len in 1..=n {
589 out.extend((0..3usize.pow(len as u32)).map(|mut x| {
590 (0..len)
591 .map(|_| {
592 let g = (x % 3) as u8 + 1;
593 x /= 3;
594 g
595 })
596 .collect()
597 }));
598 }
599 out
600 }
601
602 #[test]
603 fn the_switches_take_precedence_in_order() {
604 assert_eq!(precedence(None, None, false), Policy::Learned, "the default is the learned policy");
605 assert_eq!(precedence(Some("learned"), None, false), Policy::Learned, "naming it changes nothing");
606 assert_eq!(precedence(Some("fixed"), Some(2), true), Policy::Fixed, "fixed wins over both evaluation switches");
607 assert_eq!(precedence(Some("learned"), Some(2), true), Policy::Cap(2), "the cap wins over attention and learned");
608 assert_eq!(precedence(Some("learned"), None, true), Policy::Attention, "attention wins over learned");
609 assert_eq!(precedence(Some("nonsense"), None, false), Policy::Learned, "an unknown BORG_SELECT is the default, the learned policy");
610 assert_eq!(precedence(Some("Fixed"), None, false), Policy::Learned, "the value is exact");
611 assert_eq!(precedence(Some("fixed"), None, false), Policy::Fixed, "BORG_SELECT=fixed is the way back");
612 assert_eq!([Policy::Fixed, Policy::Cap(1), Policy::Attention, Policy::Learned].map(Policy::name), ["fixed", "cap", "attention", "learned"]);
613 }
614
615 fn candidates(applied: &[(&Challenge, u8)], r: &Routed, had: &[String]) -> Vec<String> {
618 let mut out: Vec<String> = Vec::new();
619 for (c, _) in applied {
620 if matches!(r.route(&c.id), Route::Not | Route::Placebo) && !had.contains(&c.id) && !out.contains(&c.id) {
621 out.push(c.id.clone());
622 }
623 }
624 out
625 }
626
627 #[test]
632 fn every_policy_without_a_cut_is_the_fixed_rule_on_every_grade_table() {
633 let ls = lessons(14);
634 let s = summary::Summary::default();
635 let none = std::path::PathBuf::from("/nonexistent/borg-select/lessons.jsonl");
636 let mut tables = tables(7);
637 tables.extend([vec![3; 12], [vec![1; 6], vec![3], vec![2; 7]].concat(), vec![2; 14], [vec![1; 11], vec![2; 3]].concat()]);
639 let some: Vec<String> = ["L00", "L03", "L11", "X"].map(String::from).to_vec();
642 for had in [&[][..], &some[..]] {
643 for grades in &tables {
644 let applied: Vec<(&Challenge, u8)> = ls.iter().zip(grades.iter().copied()).collect();
645 let at = Ctx { had, ..ctx(&none, &s) };
646 for (policy, cap) in [(Policy::Fixed, None), (Policy::Learned, None), (Policy::Cap(2), Some(2))] {
647 let r = graded_under(policy, &applied, &at);
648 let (ask, free) = conscience::graded_without(&applied, cap, had);
649 assert_eq!((ids(&r.ask), ids(&r.free)), (ids(&ask), ids(&free)), "{policy:?} {grades:?} {had:?}");
650 if had.is_empty() {
651 let (ask, free) = conscience::graded_capped(&applied, cap);
652 assert_eq!((ids(&r.ask), ids(&r.free)), (ids(&ask), ids(&free)), "no lesson had: the rule of every older record");
653 }
654 assert_eq!(r.p, attention::fixed_p(&applied, &ask), "{policy:?} {grades:?}");
655 assert_eq!(r.pf, attention::fixed_p(&applied, &free), "{policy:?} {grades:?}");
656 assert!(r.log.attention.is_none() && r.log.gate == 0.0);
657 assert_eq!(r.log.policy, policy.name());
658 let later = candidates(&applied, &r, had);
659 match policy {
660 Policy::Cap(_) => assert!(r.placebo.is_empty() && r.log.psi == 0.0),
661 _ => {
662 assert_eq!(r.placebo.len(), later.len().min(PLACEBO_SLOTS), "{policy:?} {grades:?}");
663 assert!(ids(&r.placebo).iter().all(|p| later.contains(p)), "{policy:?} {grades:?}");
664 let psi = if later.is_empty() { 0.0 } else { outcome::round_p((PLACEBO_SLOTS as f64 / later.len() as f64).min(1.0)) };
665 assert_eq!(r.log.psi, psi);
666 }
667 }
668 }
669 if !grades.contains(&3) {
670 let r = graded_under(Policy::Attention, &applied, &at);
671 let (ask, free) = conscience::graded_without(&applied, None, had);
672 assert_eq!((ids(&r.ask), ids(&r.free), r.log.source), (ids(&ask), ids(&free), "fixed"), "no 3: no cut");
673 assert!(r.placebo.is_empty() && r.log.psi == 0.0, "an evaluation switch draws no placebo");
674 }
675 }
676 }
677 }
678
679 #[test]
680 fn attention_with_a_three_is_the_cut_on_the_outcomes_beside_the_lessons() {
681 let ls = lessons(14);
683 let s = summary::Summary::default();
684 let grades: Vec<u8> = std::iter::once(3).chain(std::iter::repeat_n(1, 13)).collect();
685 let applied: Vec<(&Challenge, u8)> = ls.iter().zip(grades).collect();
686 let cold = store("attention-cold", &[]);
687 let r = graded_under(Policy::Attention, &applied, &ctx(&cold, &s));
688 assert_eq!((r.ask.len(), ids(&r.free)), (attention::PRIOR_K, ids(&applied[attention::PRIOR_K..].iter().map(|(c, _)| *c).collect::<Vec<_>>())));
689 assert_eq!(r.p, attention::propensities(14, attention::PRIOR_K));
690 assert_eq!(&r.pf[..attention::PRIOR_K], &[0.0; attention::PRIOR_K]);
691 assert_eq!(&r.pf[attention::PRIOR_K..], &[0.9; 3], "free unless the check explores, which asks them");
692 assert_eq!((r.log.policy, r.log.source), ("attention", "prior"));
693 assert_eq!(r.log.attention.as_deref(), Some("#borg-attention k=11 n=0 alpha=0.0010 source=prior applied=14"));
694 let graded = outcome::Outcome { kind: outcome::GRADED.into(), session: "x".into(), lessons: vec!["L0".into()], ..Default::default() };
696 let nine = store("attention-explore", &vec![graded.clone(); attention::EXPLORE - 1]);
697 let r = graded_under(Policy::Attention, &applied, &ctx(&nine, &s));
698 assert_eq!((r.ask.len(), r.free.len(), r.log.source), (14, 0, "explore"));
699 assert_eq!(r.p, attention::propensities(14, attention::PRIOR_K), "the propensities are the mixture's, not the override's");
700 let mut dirs = vec![cold, nine];
703 let (empty, failed) = (outcome::Outcome { lessons: Vec::new(), ..graded.clone() }, outcome::Outcome { failed: true, ..graded.clone() });
704 for (tag, ninth) in [("empty", empty), ("failed", failed)] {
705 let mut log = vec![graded.clone(); attention::EXPLORE - 2];
706 log.push(ninth);
707 let unmoved = store(&format!("attention-{tag}"), &log);
708 assert_eq!(graded_under(Policy::Attention, &applied, &ctx(&unmoved, &s)).log.source, "prior", "{tag}");
709 dirs.push(unmoved);
710 }
711 for d in dirs {
712 let _ = std::fs::remove_dir_all(d.parent().unwrap());
713 }
714 }
715
716 #[test]
717 fn free_propensities_mix_the_cut_and_the_explore_slice() {
718 let pf = free_p(35, 3);
720 assert_eq!(&pf[..3], &[0.0; 3]);
721 assert_eq!(&pf[3..13], &[0.9; 10]);
722 assert_eq!(&pf[13..20], &[0.0; 7]);
723 assert_eq!(&pf[20..30], &[0.1; 10]);
724 assert_eq!(&pf[30..], &[0.0; 5]);
725 assert_eq!(free_p(5, 11), [0.0; 5], "the cut and the rail ask all five");
726 let p = attention::propensities(35, 3);
728 assert!(p.iter().zip(&pf).all(|(a, f)| a + f <= 1.0 + 1e-9), "{p:?} {pf:?}");
729 }
730
731 #[test]
732 fn a_route_names_the_list_that_holds_the_lesson() {
733 let ls = lessons(3);
734 let applied: Vec<(&Challenge, u8)> = ls.iter().zip([3, 1, 2]).collect();
735 let none = std::path::PathBuf::from("/nonexistent/borg-select/lessons.jsonl");
736 let s = summary::Summary::default();
737 let mut r = graded_under(Policy::Fixed, &applied, &ctx(&none, &s));
738 assert_eq!(ids(&r.ask), ["L00", "L02", "L01"], "3s, then 2s, then 1s");
739 r.free = r.ask.split_off(2);
740 r.placebo = r.free.split_off(0);
741 assert_eq!(["L00", "L01", "L02", "L99"].map(|id| r.route(id)), [Route::Ask, Route::Placebo, Route::Ask, Route::Not]);
742 }
743
744 #[test]
745 fn the_select_line_carries_the_decision_and_its_check() {
746 let mut log = Log { policy: "learned", source: "fixed", ..Log::default() };
747 assert_eq!(log.line(4, "0123456789ab"), "#borg-select policy=learned source=fixed fit=- gate=0.0000 applied=4 @0123456789ab");
748 log.fit = "120-ab12".into();
749 log.gate = 0.25;
750 assert!(log.line(0, "ff").contains(" fit=120-ab12 gate=0.2500 applied=0 @ff"), "{}", log.line(0, "ff"));
751 assert_eq!(conscience::work_text(&format!("{}\nwork\n", log.line(1, "ab"))), "work\n");
753 }
754
755 fn deviating() -> summary::Summary {
757 let at = |name: &str| policy::NAMES.iter().position(|n| *n == name).unwrap();
758 let mut beta = vec![0.0; policy::P];
759 beta[at("intercept")] = -0.5;
760 beta[at("ask")] = -3.0;
761 beta[at("not")] = -1.0;
762 let mut chol = vec![0.0; policy::P * policy::P];
763 for k in 0..policy::P {
764 chol[k * policy::P + k] = 0.05;
765 }
766 let model = policy::Model { beta, chol, sd: vec![1.0; policy::P], spread: [0.1; 3], ..policy::Model::default() };
767 let rates = policy::Rates { free_stop: 1.0, free_live: 1.0, later: 0.0, wait: 0.0, ask_live: 1.0 };
768 let fit = policy::Fit { key: "9-abcdef01".into(), model: Some(model), rates, ..policy::Fit::default() };
769 let gate = summary::Gate { gain: vec![0.0; policy::DRAWS], allowance: 5.0, ..summary::Gate::default() };
770 summary::Summary { fit, gate, ..summary::Summary::default() }
771 }
772
773 #[test]
774 fn the_learned_policy_routes_by_its_fit_and_logs_it() {
775 let ls = lessons(4);
776 let applied: Vec<(&Challenge, u8)> = ls.iter().zip([3, 1, 2, 1]).collect();
777 let s = deviating();
778 let none = std::path::PathBuf::from("/nonexistent/borg-select/lessons.jsonl");
779 let live = Ctx { channel: "live", ..ctx(&none, &s) };
780 let r = graded_under(Policy::Learned, &applied, &live);
781 assert!(r.ask.is_empty(), "asking loses under this fit: {:?}", ids(&r.ask));
782 assert_eq!(ids(&r.free), ["L00", "L02", "L01", "L03"], "higher grade first, then the model's order");
783 assert_eq!((r.log.policy, r.log.source, r.log.fit.as_str()), ("learned", policy::EVIDENCE, "9-abcdef01"));
784 assert_eq!((r.log.xk, r.log.xs), (None, None), "on evidence the test does not run");
785 assert!(r.log.gate > 0.0);
786 assert!(r.pf.iter().all(|p| *p == 1.0) && r.p.iter().all(|p| *p == 0.0), "exact: {:?} {:?}", r.p, r.pf);
787 let r = graded_under(Policy::Learned, &applied, &ctx(&none, &s));
789 assert_eq!((ids(&r.ask), r.p[0]), (vec!["L00".to_string()], 1.0));
790 }
791
792 #[test]
797 fn a_live_check_under_the_test_asks_the_drawn_count_and_logs_it() {
798 let ls = lessons(4);
799 let applied: Vec<(&Challenge, u8)> = ls.iter().zip([2, 1, 2, 1]).collect();
800 let mut s = deviating();
801 if let Some(m) = s.fit.model.as_mut() {
802 m.beta = vec![0.0; policy::P];
803 m.beta[policy::NAMES.iter().position(|n| *n == "not").unwrap()] = -1.0;
804 }
805 let none = std::path::PathBuf::from("/nonexistent/borg-select/lessons.jsonl");
806 let order = ["L00", "L02", "L01", "L03"];
807 let mut seen = std::collections::BTreeSet::new();
808 for c in 0..40u32 {
809 let check = format!("{c:012x}");
810 let r = graded_under(Policy::Learned, &applied, &Ctx { channel: "live", check: &check, ..ctx(&none, &s) });
811 let (Some(k), Some(x)) = (r.log.xk, r.log.xs) else { panic!("check {c}: the test ran: {:?}", r.log) };
812 let x = x as usize;
813 assert_eq!(k, 4, "every lesson the fixed rule hands over is tested");
814 let named = |v: &[&str]| v.iter().map(|x| x.to_string()).collect::<Vec<_>>();
815 assert_eq!((ids(&r.ask), ids(&r.free)), (named(&order[..x]), named(&order[x..])), "check {c}");
816 assert_eq!(r.log.source, if x == 0 { policy::EXPERIMENT_CONTROL } else { policy::EXPERIMENT }, "check {c}");
817 assert_eq!(
818 (r.p.as_slice(), r.pf.as_slice()),
819 ([0.8, 0.4, 0.6, 0.2].as_slice(), [0.2, 0.6, 0.4, 0.8].as_slice()),
820 "each unit's share of the five counts"
821 );
822 seen.insert(x);
823 }
824 assert!(seen.len() > 2, "the count varies with the check: {seen:?}");
825 let r = graded_under(Policy::Learned, &applied, &ctx(&none, &s));
827 assert_eq!((r.log.source, r.log.xk, r.log.xs, r.ask.len()), (policy::EVIDENCE, None, None, 0));
828 }
829
830 #[test]
835 fn a_check_decides_and_logs_under_the_tolerance_in_its_ctx() {
836 let ls = lessons(4);
837 let applied: Vec<(&Challenge, u8)> = ls.iter().zip([2, 1, 2, 1]).collect();
838 let mut s = deviating();
839 if let Some(m) = s.fit.model.as_mut() {
840 m.beta = vec![0.0; policy::P];
841 m.beta[policy::NAMES.iter().position(|n| *n == "not").unwrap()] = -1.0;
842 }
843 let none = std::path::PathBuf::from("/nonexistent/borg-select/lessons.jsonl");
844 assert_eq!(alpha(&none), policy::ALPHA, "the config's α for this path");
845 let live = Ctx { channel: "live", ..ctx(&none, &s) };
846 let at = |alpha: f64| graded_under(Policy::Learned, &applied, &Ctx { alpha, ..live });
847 let default = at(policy::ALPHA);
848 assert_eq!((default.log.xk, default.log.alpha), (Some(4), None), "the test runs; the default is not logged");
849 let off = at(0.0);
850 assert_eq!((off.log.source, off.log.xk, off.log.xs, off.log.alpha), (policy::EVIDENCE, None, None, Some(0.0)), "{:?}", off.log);
851 assert_eq!((off.ask.len(), off.free.len()), (0, 4), "the fixed rule");
852 let wider = at(0.3);
853 assert_eq!((wider.log.xk, wider.log.alpha), (Some(4), Some(0.3)));
854 let fixed = graded_under(Policy::Fixed, &applied, &Ctx { alpha: 0.0, ..live });
855 assert_eq!(fixed.log.alpha, Some(0.0), "logged under every policy, for the cold start");
856 for a in [0.0, 0.3, policy::ALPHA] {
857 assert_eq!(alpha_of(logged_alpha(a)), a);
858 let replayed = at(alpha_of(at(a).log.alpha));
859 assert_eq!((replayed.log, replayed.p, replayed.pf), (at(a).log, at(a).p, at(a).pf), "α {a} replays as decided");
860 }
861 }
862
863 #[test]
865 fn a_reverted_store_keeps_the_fixed_rule() {
866 let ls = lessons(4);
867 let applied: Vec<(&Challenge, u8)> = ls.iter().zip([3, 1, 2, 1]).collect();
868 let mut s = deviating();
869 s.monitor.reverted = true;
870 let none = std::path::PathBuf::from("/nonexistent/borg-select/lessons.jsonl");
871 let r = graded_under(Policy::Learned, &applied, &Ctx { channel: "live", ..ctx(&none, &s) });
872 let (ask, free) = conscience::graded_capped(&applied, None);
873 assert_eq!((ids(&r.ask), ids(&r.free)), (ids(&ask), ids(&free)));
874 assert_eq!((r.log.source, r.log.gate), (REVERTED_SOURCE, 0.0));
875 assert_eq!(r.p, attention::fixed_p(&applied, &ask));
876 let had = ["L01".to_string()];
878 let r = graded_under(Policy::Learned, &applied, &Ctx { channel: "live", had: &had, ..ctx(&none, &s) });
879 assert_eq!(ids(&r.free), ids(&conscience::graded_without(&applied, None, &had).1));
880 assert_eq!((r.log.source, r.pf[1]), (REVERTED_SOURCE, 0.0));
881 }
882
883 #[test]
887 fn a_lesson_the_session_had_is_asked_but_never_handed_over_free() {
888 let ls = lessons(12);
889 let none = std::path::PathBuf::from("/nonexistent/borg-select/lessons.jsonl");
890 let s = summary::Summary::default();
891 let soft: Vec<(&Challenge, u8)> = ls.iter().zip(std::iter::once(2).chain(std::iter::repeat_n(1, 11))).collect();
893 let had = ["L00".to_string(), "X9".to_string()];
894 let free = |had: &[String]| ids(&conscience::graded_without(&soft, None, had).1);
895 assert_eq!(free(&[]), ids(&ls[..10].iter().collect::<Vec<_>>()));
896 assert_eq!(free(&had), ids(&ls[1..11].iter().collect::<Vec<_>>()), "a 2 the session had takes no slot; the next 1 does");
897 for policy in [Policy::Fixed, Policy::Learned] {
898 let r = graded_under(policy, &soft, &Ctx { had: &had, ..ctx(&none, &s) });
899 assert_eq!((ids(&r.free), r.pf[0]), (free(&had), 0.0), "{policy:?}");
900 assert!(!ids(&r.placebo).contains(&"L00".to_string()), "{policy:?}: a lesson the session had is no placebo");
901 }
902 let block: Vec<(&Challenge, u8)> = ls.iter().take(3).zip([3, 2, 1]).collect();
904 let ask = |had: &[String]| ids(&conscience::graded_without(&block, None, had).0);
905 assert_eq!(ask(&["L01".to_string()]), ask(&[]), "may still be asked");
906 let open = deviating();
909 let applied: Vec<(&Challenge, u8)> = ls.iter().take(4).zip([2, 1, 2, 1]).collect();
910 let live = ctx(&none, &open);
911 let all = graded_under(Policy::Learned, &applied, &live);
912 assert_eq!((all.log.source, all.route("L01")), (policy::EVIDENCE, Route::Free), "the scenario: without it, L01 goes free");
913 let had = ["L01".to_string()];
914 let r = graded_under(Policy::Learned, &applied, &Ctx { had: &had, ..live });
915 assert_eq!((r.route("L01"), r.pf[1]), (Route::Not, 0.0), "{:?}", r.pf);
916 assert_eq!(ids(&r.free), ["L00", "L02", "L03"]);
917 let twice: Vec<(&Challenge, u8)> = [&ls[3], &ls[1], &ls[3], &ls[2]].into_iter().map(|c| (c, 1)).collect();
919 assert_eq!(nofree(&twice, &["L02".to_string(), "L03".to_string(), "L99".to_string()]), ["L03", "L02"]);
920 assert!(nofree(&twice, &[]).is_empty());
921 }
922
923 #[test]
928 fn the_fixed_rule_draws_its_placebos_among_what_it_delivers_neither_way() {
929 let ls = lessons(14);
930 let none = std::path::PathBuf::from("/nonexistent/borg-select/lessons.jsonl");
931 let s = summary::Summary::default();
932 fn fixed<'a>(applied: &[(&'a Challenge, u8)], had: &[String], check: &str) -> Routed<'a> {
933 let (s, none) = (summary::Summary::default(), std::path::PathBuf::from("/nonexistent/borg-select/lessons.jsonl"));
934 graded_under(Policy::Fixed, applied, &Ctx { check, had, ..ctx(&none, &s) })
935 }
936 let block: Vec<(&Challenge, u8)> = ls.iter().take(12).zip(std::iter::once(3).chain(std::iter::repeat_n(1, 11))).collect();
938 let r = fixed(&block, &[], "0123456789ab");
939 assert_eq!((r.ask.len(), ids(&r.placebo), r.log.psi), (conscience::CAP, vec!["L10".to_string(), "L11".to_string()], 1.0));
940 let soft: Vec<(&Challenge, u8)> = ls.iter().map(|c| (c, 1)).collect();
942 let mut drawn = std::collections::BTreeMap::<String, u32>::new();
943 let checks = 200;
944 for c in 0..checks {
945 let check = format!("{c:012x}");
946 let r = fixed(&soft, &[], &check);
947 assert_eq!((r.free.len(), r.placebo.len(), r.log.psi), (conscience::CAP, PLACEBO_SLOTS, 0.5));
948 assert_eq!(ids(&r.placebo), ids(&fixed(&soft, &[], &check).placebo), "the check's seed decides");
949 assert!(r.placebo.windows(2).all(|w| w[0].id < w[1].id), "in the applies' order");
950 for p in &r.placebo {
951 *drawn.entry(p.id.clone()).or_default() += 1;
952 }
953 }
954 assert_eq!(drawn.keys().cloned().collect::<Vec<_>>(), ["L10", "L11", "L12", "L13"]);
955 assert!(drawn.values().all(|&n| (60..=140).contains(&n)), "each about half the checks: {drawn:?}");
956 let had = ["L12".to_string()];
958 for c in 0..20 {
959 let r = fixed(&soft, &had, &format!("{c:012x}"));
960 assert_eq!(r.log.psi, outcome::round_p(2.0 / 3.0));
961 assert!(!ids(&r.placebo).contains(&had[0]), "{:?}", ids(&r.placebo));
962 }
963 let named: Vec<(&Challenge, u8)> = ls.iter().take(10).chain([&ls[0], &ls[12], &ls[12]]).map(|c| (c, 1)).collect();
965 let r = fixed(&named, &[], "0123456789ab");
966 assert_eq!((ids(&r.placebo), r.log.psi), (vec!["L12".to_string()], 1.0));
967 for policy in [Policy::Cap(1), Policy::Attention] {
969 let r = graded_under(policy, &soft, &ctx(&none, &s));
970 assert!(r.placebo.is_empty() && r.log.psi == 0.0, "{policy:?}");
971 }
972 let cold = graded_under(Policy::Learned, &soft, &ctx(&none, &s));
973 assert_eq!((ids(&cold.placebo), cold.log.psi), (ids(&graded_under(Policy::Fixed, &soft, &ctx(&none, &s)).placebo), 0.5));
974 }
975
976 #[test]
980 fn the_pool_is_the_learned_policys_once_its_gate_opens_or_a_budget_binds() {
981 let pool = policy::PoolFit {
982 yields: vec![(50.0, 10.0); 8],
983 per_lesson: 300.0,
984 latency: (1000.0, 0.1, 500.0),
985 latency_n: policy::LATENCY_MIN as u32,
986 latency_reach: 24_000,
987 not: (-1.0, 0.1),
988 ..policy::PoolFit::default()
989 };
990 let base = crate::whole::TOKENS;
991 let limit = Some(60_000);
992 let unpriced = policy::Fit { pool: pool.clone(), ..policy::Fit::default() };
993 let grows = (0..20).map(|i| format!("s{i}")).find(|x| policy::pool_tokens(&unpriced, true, limit, policy::session_seed(x)) > base);
994 let session = grows.expect("the scenario: an active pool grows for some session");
995 let at = |policy: Policy, s: &summary::Summary| (pool_active(policy, s), pool_tokens_under(policy, s, limit, &session));
996 let mut s = summary::Summary { fit: unpriced, ..summary::Summary::default() };
997 assert_eq!(at(Policy::Learned, &s), (false, base), "gate closed, no budget");
998 s.gate.allowance = 1.0;
999 assert!(at(Policy::Learned, &s).0 && at(Policy::Learned, &s).1 > base, "gate open");
1000 for policy in [Policy::Fixed, Policy::Cap(2), Policy::Attention] {
1001 assert_eq!(at(policy, &s), (false, base), "{policy:?}");
1002 }
1003 s.monitor.reverted = true;
1004 assert_eq!(at(Policy::Learned, &s), (false, base), "reverted");
1005 let mut s = summary::Summary { fit: policy::Fit { pool, budget: Some(1.0), lambda: 1e-2, ..policy::Fit::default() }, ..summary::Summary::default() };
1007 let (active, tokens) = at(Policy::Learned, &s);
1008 assert!(active && tokens < base, "budget, gate closed: {tokens}");
1009 assert_eq!(at(Policy::Fixed, &s), (false, base), "the fixed rule keeps the baseline under a budget");
1010 s.monitor.reverted = true;
1011 assert_eq!(at(Policy::Learned, &s), (false, base), "reverted under a budget");
1012 }
1013
1014 #[test]
1021 fn the_gate_admits_at_the_pool_its_ledger_records() {
1022 let at = |name: &str| policy::NAMES.iter().position(|n| *n == name).unwrap();
1023 let mut beta = vec![0.0; policy::P];
1024 for (name, b) in [("intercept", -0.5), ("ask", -0.4), ("not", -0.3), ("ask_dilution", -0.1), ("free_dilution", -0.1), ("pool", -1.0)] {
1025 beta[at(name)] = b;
1026 }
1027 let mut chol = vec![0.0; policy::P * policy::P];
1030 for k in 0..policy::P {
1031 chol[k * policy::P + k] = if k == at("ask") {
1032 1.0
1033 } else if k == at("pool") {
1034 0.5
1035 } else {
1036 0.3
1037 };
1038 }
1039 let model = policy::Model { beta, chol, sd: vec![1.0; policy::P], spread: [0.3; 3], ..policy::Model::default() };
1040 let rates = policy::Rates { free_stop: 1.0, free_live: 1.0, later: 0.5, wait: 0.0, ask_live: 1.0 };
1041 let fit = policy::Fit { key: "9-pool0001".into(), model: Some(model), rates, ..policy::Fit::default() };
1042 let mut s = summary::Summary { fit, ..summary::Summary::default() };
1043 let tokens = u32::try_from(crate::whole::TOKENS / 4).unwrap();
1044 let (ls, none) = (lessons(10), std::path::PathBuf::from("/nonexistent/borg-select/lessons.jsonl"));
1045 let mut rng = crate::memory::Rng::new(77);
1046 let mut deviated = 0;
1047 for c in 0..400u64 {
1048 let (n, from) = (1 + (rng.next_u64() % 3) as usize, (rng.next_u64() % 10) as usize);
1049 let applied: Vec<(&Challenge, u8)> = (0..n).map(|i| (&ls[(from + i) % 10], 1 + (rng.next_u64() % 3) as u8)).collect();
1050 let (session, check, stop) = (format!("t{}", c / 10), format!("{c:012x}"), c % 5 == 0);
1051 let had: Vec<String> = if c % 2 == 0 { vec![ls[(rng.next_u64() % 10) as usize].id.clone()] } else { Vec::new() };
1053 let ctx = Ctx {
1054 session: &session,
1055 check: &check,
1056 channel: if stop { "stop" } else { "live" },
1057 lessons: &none,
1058 summary: &s,
1059 tokens,
1060 had: &had,
1061 alpha: policy::ALPHA,
1062 };
1063 let r = graded_under(Policy::Learned, &applied, &ctx);
1064 let us = units(&applied);
1065 let routes: Vec<Route> = applied.iter().map(|(l, _)| r.route(&l.id)).collect();
1066 assert!(applied.iter().zip(&r.pf).all(|((l, _), pf)| !had.contains(&l.id) || (r.route(&l.id) != Route::Free && *pf == 0.0)), "check {c}");
1067 let nofree = nofree(&applied, &had);
1068 deviated += usize::from(routes.iter().map(|x| if *x == Route::Placebo { Route::Not } else { *x }).ne(policy::fixed_routes(&us, &nofree)));
1069 let held = |g: &summary::Gate| policy::remaining(g) - g.allowance;
1070 let before = held(&s.gate);
1071 let ids = |v: &[&Challenge]| v.iter().map(|c| c.id.clone()).collect::<Vec<_>>();
1074 let o = crate::outcome::Outcome {
1075 kind: crate::outcome::GRADED.into(),
1076 session: session.clone(),
1077 check: check.clone(),
1078 channel: ctx.channel.into(),
1079 lessons: applied.iter().map(|(c, _)| c.id.clone()).collect(),
1080 grades: applied.iter().map(|(_, g)| *g).collect(),
1081 ranks: (0..n as u16).collect(),
1082 applied: n as u16,
1083 chosen: ids(&r.ask),
1084 free: ids(&r.free),
1085 placebo: ids(&r.placebo),
1086 p: r.p.clone(),
1087 pf: r.pf.clone(),
1088 psi: r.log.psi,
1089 nofree,
1090 policy: r.log.policy.into(),
1091 fit: r.log.fit.clone(),
1092 source: r.log.source.into(),
1093 tokens,
1094 ..Default::default()
1095 };
1096 s = summary::fold(&format!("{}\n", serde_json::to_string(&o).unwrap()), s);
1097 assert!(
1098 (r.log.risk - (before - held(&s.gate)).max(0.0)).abs() < 1e-9,
1099 "check {c}: risked {} but the ledger took {}",
1100 r.log.risk,
1101 before - held(&s.gate)
1102 );
1103 assert!(policy::remaining(&s.gate) >= -1e-9, "check {c}: the gate overspent, {}", policy::remaining(&s.gate));
1104 }
1105 assert!(deviated > 0, "the learned policy deviated, so the gate was put to the test");
1106 assert_eq!(policy::ledger(&s), s.gate, "the refit's ledger is the folds'");
1109 }
1110
1111 #[test]
1112 fn a_budget_is_read_for_the_collective_whose_store_it_is() {
1113 let base = Path::new("/home/x/.local/share/borg");
1114 let lessons = crate::collective::store(base, "team").join("lessons.jsonl");
1115 let cfg = |entry: &str| {
1116 serde_json::from_str::<crate::collective::Config>(&format!(r#"{{"collectives":[{{"name":"other","paths":[],"budget":5}},{entry}]}}"#)).unwrap()
1117 };
1118 assert_eq!(budget_in(&cfg(r#"{"name":"team","paths":[],"budget":400000}"#), &lessons), Some(400_000.0));
1119 assert_eq!(budget_in(&cfg(r#"{"name":"team","paths":[]}"#), &lessons), None, "off by default");
1120 assert_eq!(budget_in(&cfg(r#"{"name":"team","paths":[],"budget":0}"#), &lessons), None);
1121 assert_eq!(budget_in(&cfg(r#"{"name":"team","paths":[],"budget":"lots"}"#), &lessons), None);
1122 }
1123
1124 #[test]
1125 fn a_risk_is_read_for_the_collective_whose_store_it_is_and_only_as_a_share() {
1126 let base = Path::new("/home/x/.local/share/borg");
1127 let lessons = crate::collective::store(base, "team").join("lessons.jsonl");
1128 let cfg = |entry: &str| {
1129 serde_json::from_str::<crate::collective::Config>(&format!(r#"{{"collectives":[{{"name":"other","paths":[],"risk":0.5}},{entry}]}}"#)).unwrap()
1130 };
1131 assert_eq!(risk_in(&cfg(r#"{"name":"team","paths":[],"risk":0.2}"#), &lessons), Some(0.2));
1132 assert_eq!(risk_in(&cfg(r#"{"name":"team","paths":[],"risk":0}"#), &lessons), Some(0.0), "0 turns the test off");
1133 assert_eq!(risk_in(&cfg(r#"{"name":"team","paths":[],"risk":1}"#), &lessons), Some(1.0));
1134 assert_eq!(risk_in(&cfg(r#"{"name":"team","paths":[]}"#), &lessons), None, "unset: the policy's ALPHA");
1135 for bad in ["-0.1", "1.5", "\"lots\"", "null"] {
1136 assert_eq!(risk_in(&cfg(&format!(r#"{{"name":"team","paths":[],"risk":{bad}}}"#)), &lessons), None, "{bad}");
1137 }
1138 }
1139
1140 #[test]
1141 fn a_check_id_is_short_stable_hex_and_differs_per_check() {
1142 let id = check_id("s", "stop", 1_000, 42);
1143 assert_eq!(id.len(), CHECK_HEX);
1144 assert!(id.bytes().all(|b| b.is_ascii_hexdigit()), "{id}");
1145 assert_eq!(id, check_id("s", "stop", 1_000, 42), "stable");
1146 let others = [check_id("s", "live", 1_000, 42), check_id("s", "stop", 1_001, 42), check_id("s", "stop", 1_000, 43), check_id("t", "stop", 1_000, 42)];
1147 assert!(others.iter().all(|o| *o != id), "{others:?}");
1148 assert_ne!(check_id("ab", "c", 1, 1), check_id("a", "bc", 1, 1));
1150 }
1151
1152 #[test]
1153 fn a_seed_depends_on_session_channel_and_check() {
1154 let (s, none) = (summary::Summary::default(), std::path::PathBuf::new());
1155 let at = |session, channel, check| seed(&Ctx { session, check, channel, lessons: &none, summary: &s, tokens: 0, had: &[], alpha: policy::ALPHA });
1156 assert_eq!(at("s", "stop", "a1"), at("s", "stop", "a1"));
1157 assert_ne!(at("s", "stop", "a1"), at("s", "live", "a1"));
1158 assert_ne!(at("s", "stop", "a1"), at("s", "stop", "a2"));
1159 assert_ne!(at("s", "stop", "a1"), at("t", "stop", "a1"));
1160 }
1161}