1use std::collections::{BTreeMap, HashMap};
35use std::path::Path;
36
37use serde::Serialize;
38
39use crate::conscience::{self, Challenge};
40use crate::memory::{self, Memory, Weights};
41use crate::outcome::{self, Outcome};
42use crate::{collective, learn};
43
44pub struct Knob {
47 pub name: &'static str,
48 pub current: f64,
49 pub kinds: &'static [&'static str],
50 pub set: fn(&mut Weights, f64),
51}
52
53pub const KNOBS: [Knob; 3] = [
56 Knob { name: "veto", current: memory::VETOED_W as f64, kinds: &[outcome::VETOED], set: |w, v| w.vetoed = v },
57 Knob { name: "regret", current: memory::REGRET, kinds: &[outcome::REGRETTED], set: |w, v| w.regret = v },
58 Knob { name: "tested", current: memory::TESTED_W, kinds: &[outcome::TESTED], set: |w, v| w.tested = v },
59];
60
61pub fn grid(current: f64) -> [f64; 3] {
64 [current / 2.0, current, current * 2.0]
65}
66
67pub const CURRENT: Weights = memory::WEIGHTS;
69
70#[derive(Debug, Clone, PartialEq)]
73pub struct Decision {
74 pub at: usize,
75 pub ms: i64,
76 pub session: String,
77 pub asked: Vec<(String, f64, f64)>,
78 pub pool: Vec<(String, f64, bool)>,
79}
80
81#[derive(Debug, Default, Clone, PartialEq, Serialize)]
84pub struct Skipped {
85 pub no_p: usize,
87 pub zero_p: usize,
89 pub no_pool: usize,
92 pub pending: usize,
94 pub graded: usize,
96}
97
98pub(crate) fn rank(kind: &str) -> Option<u8> {
102 match kind {
103 outcome::VETOED => Some(3),
104 outcome::CORRECTED => Some(2),
105 outcome::CHANGED => Some(1),
106 outcome::IGNORED => Some(0),
107 outcome::NOT_CORRECTED | outcome::NOT_ARISEN | outcome::UNCLEAR | outcome::REMAINS => None,
109 _ => None,
110 }
111}
112
113pub fn decisions(outcomes: &[Outcome]) -> (Vec<Decision>, Skipped) {
120 let mut results: HashMap<(&str, &str), Vec<(i64, u8)>> = HashMap::new();
121 for o in outcomes.iter().filter(|o| o.channel != outcome::PLACEBO) {
122 if let Some(r) = rank(&o.kind) {
123 for l in &o.lessons {
124 results.entry((o.session.as_str(), l.as_str())).or_default().push((o.ms, r));
125 }
126 }
127 }
128 let mut skipped = Skipped::default();
129 let mut out = Vec::new();
130 for (at, o) in outcomes.iter().enumerate() {
131 if o.kind == outcome::GRADED {
132 skipped.graded += 1;
133 continue;
134 }
135 if o.kind != outcome::ASKED || outcome::free(o) {
137 continue;
138 }
139 if o.p.is_empty() || o.p.len() != o.lessons.len() {
140 skipped.no_p += o.lessons.len();
141 continue;
142 }
143 let aligned = o.pool.len() == o.base.len() && o.pool.len() == o.scoped.len();
144 if o.pool.is_empty() || !aligned || !o.lessons.iter().all(|l| o.pool.contains(l)) {
145 skipped.no_pool += 1;
146 continue;
147 }
148 let mut asked = Vec::new();
149 for (l, &p) in o.lessons.iter().zip(&o.p) {
150 if p <= 0.0 {
151 skipped.zero_p += 1;
152 continue;
153 }
154 let after = results.get(&(o.session.as_str(), l.as_str())).and_then(|v| v.iter().filter(|(ms, _)| *ms >= o.ms).map(|(_, r)| *r).max());
155 match after {
156 Some(r) => asked.push((l.clone(), p, if r == 1 { 1.0 } else { 0.0 })),
157 None => skipped.pending += 1,
158 }
159 }
160 if !asked.is_empty() {
161 let pool = o.pool.iter().zip(&o.base).zip(&o.scoped).map(|((id, b), s)| (id.clone(), *b, *s)).collect();
162 out.push(Decision { at, ms: o.ms, session: o.session.clone(), asked, pool });
163 }
164 }
165 (out, skipped)
166}
167
168fn pool_lessons(d: &Decision) -> Vec<Challenge> {
170 d.pool.iter().map(|(id, _, _)| Challenge { id: id.clone(), ..Default::default() }).collect()
171}
172
173fn slot_p(mem: &HashMap<String, Memory>, d: &Decision, w: &Weights) -> HashMap<String, f64> {
176 let pool: Vec<Challenge> = d
177 .pool
178 .iter()
179 .map(|(id, _, scoped)| {
180 let m = &mem[id];
181 Challenge {
182 id: id.clone(),
183 scope: if *scoped { vec![conscience::ANY.into()] } else { Vec::new() },
184 belief: (m.alpha_with(w), m.beta_with(w)),
185 ..Default::default()
186 }
187 })
188 .collect();
189 let scored: Vec<(f64, &Challenge)> = pool
190 .iter()
191 .zip(&d.pool)
192 .map(|(c, (_, base, _))| {
193 let (a, b) = c.belief;
194 (base * (0.5 + a / (a + b)), c)
195 })
196 .collect();
197 conscience::propensity(&scored, &d.session)
198}
199
200pub fn replay(log: &[Outcome], d: &Decision, cand: &Weights) -> HashMap<String, f64> {
205 let prefix: Vec<Outcome> = log.iter().filter(|o| o.ms < d.ms).cloned().collect();
206 slot_p(&memory::derive(&prefix, &pool_lessons(d), None), d, cand)
207}
208
209#[derive(Debug, Clone, PartialEq, Serialize)]
211pub struct Estimate {
212 pub value: Option<f64>,
214 pub lo: f64,
216 pub hi: f64,
217 pub ess: f64,
219 pub n: usize,
221}
222
223pub const AGREE: f64 = 0.01;
226
227pub const RESAMPLES: usize = conscience::P_DRAWS;
229
230pub(crate) const SEED: u64 = 0x006f_7065;
232
233pub fn snips(units: &[(f64, f64, usize)], seed: u64) -> Estimate {
238 let ratio = |sw: f64, swr: f64| (sw > 0.0).then(|| swr / sw);
239 let sw: f64 = units.iter().map(|u| u.0).sum();
240 let sw2: f64 = units.iter().map(|u| u.0 * u.0).sum();
241 let value = ratio(sw, units.iter().map(|u| u.0 * u.1).sum());
242 let ess = if sw2 > 0.0 { sw * sw / sw2 } else { 0.0 };
243 let mut by: BTreeMap<usize, (f64, f64)> = BTreeMap::new();
245 for (w, r, s) in units {
246 let e = by.entry(*s).or_default();
247 e.0 += w;
248 e.1 += w * r;
249 }
250 let groups: Vec<(f64, f64)> = by.into_values().collect();
251 let mut rng = memory::Rng::new(seed);
252 let mut values: Vec<f64> = Vec::new();
253 if value.is_some() {
254 for _ in 0..RESAMPLES {
255 let (mut sw, mut swr) = (0.0, 0.0);
256 for _ in 0..groups.len() {
257 let g = groups[(rng.next_u64() % groups.len() as u64) as usize];
258 sw += g.0;
259 swr += g.1;
260 }
261 values.extend(ratio(sw, swr));
262 }
263 }
264 values.sort_by(f64::total_cmp);
265 let pct = |q: f64| values.get((q * (values.len().max(1) - 1) as f64).round() as usize).copied().unwrap_or(0.0);
266 Estimate { value, lo: pct(0.025), hi: pct(0.975), ess, n: units.len() }
267}
268
269#[derive(Debug, Clone, PartialEq, Serialize)]
271pub struct Row {
272 pub knob: &'static str,
273 pub value: f64,
274 pub est: Estimate,
275 pub evidence: usize,
278}
279
280#[derive(Debug, Clone, PartialEq, Serialize)]
283pub struct Pick {
284 pub knob: &'static str,
285 pub current: f64,
286 pub best: f64,
287 pub clears: bool,
288}
289
290#[derive(Debug, Clone, PartialEq, Serialize)]
291pub struct Report {
292 pub records: usize,
294 pub asked: usize,
295 pub decisions: usize,
296 pub units: usize,
298 pub skipped: Skipped,
299 pub drifted: usize,
302 pub agree: (usize, usize, f64),
304 pub current: Estimate,
305 pub rows: Vec<Row>,
306 pub picks: Vec<Pick>,
307}
308
309pub fn evaluate(outcomes: &[Outcome]) -> Report {
311 let (ds, skipped) = decisions(outcomes);
312 let mut sorted: Vec<&Outcome> = outcomes.iter().collect();
313 sorted.sort_by_key(|o| o.ms);
314 let mut naming: HashMap<&str, Vec<usize>> = HashMap::new();
317 for (i, o) in sorted.iter().enumerate() {
318 for l in &o.lessons {
319 naming.entry(l.as_str()).or_default().push(i);
320 }
321 }
322 let mut cands: Vec<Weights> = vec![CURRENT];
324 let mut grid_rows: Vec<(usize, f64, usize)> = Vec::new();
325 for (k, knob) in KNOBS.iter().enumerate() {
326 for v in grid(knob.current) {
327 let mut c = CURRENT;
328 (knob.set)(&mut c, v);
329 let u = cands.iter().position(|x| *x == c).unwrap_or_else(|| {
330 cands.push(c);
331 cands.len() - 1
332 });
333 grid_rows.push((k, v, u));
334 }
335 }
336 let mut sessions: HashMap<&str, usize> = HashMap::new();
337 let mut units: Vec<Vec<(f64, f64, usize)>> = vec![Vec::new(); cands.len()];
338 let mut agree = (0, 0, 0.0f64);
339 let (mut drifted, mut n_units) = (0, 0);
340 for d in &ds {
341 let cut = sorted.partition_point(|o| o.ms < d.ms);
342 let mut at: Vec<usize> = d.pool.iter().flat_map(|(id, _, _)| naming.get(id.as_str()).into_iter().flatten().copied().take_while(|i| *i < cut)).collect();
343 at.sort_unstable();
344 at.dedup();
345 let prefix: Vec<Outcome> = at.into_iter().map(|i| sorted[i].clone()).collect();
346 let mem = memory::derive(&prefix, &pool_lessons(d), None);
347 let s = sessions.len();
348 let s = *sessions.entry(d.session.as_str()).or_insert(s);
349 let ps: Vec<HashMap<String, f64>> = cands.iter().map(|c| slot_p(&mem, d, c)).collect();
350 let mut same = true;
352 for (id, logged, _) in &d.asked {
353 let delta = (ps[0].get(id).copied().unwrap_or(0.0) - logged).abs();
354 agree.0 += usize::from(delta <= AGREE);
355 agree.1 += 1;
356 agree.2 = agree.2.max(delta);
357 same &= delta <= AGREE;
358 }
359 if !same {
360 drifted += 1;
361 continue;
362 }
363 n_units += d.asked.len();
364 for (u, p) in ps.iter().enumerate() {
365 for (id, logged, reward) in &d.asked {
366 units[u].push((p.get(id).copied().unwrap_or(0.0) / logged, *reward, s));
367 }
368 }
369 }
370 let estimates: Vec<Estimate> = units.iter().map(|u| snips(u, SEED)).collect();
371 let last = ds.iter().map(|d| d.ms).max().unwrap_or(i64::MAX);
372 let evidence = |k: &Knob| outcomes.iter().filter(|o| o.ms < last && k.kinds.contains(&o.kind.as_str())).count();
373 let rows: Vec<Row> =
374 grid_rows.iter().map(|&(k, v, u)| Row { knob: KNOBS[k].name, value: v, est: estimates[u].clone(), evidence: evidence(&KNOBS[k]) }).collect();
375 let current = estimates[0].clone();
376 let picks = KNOBS
377 .iter()
378 .map(|k| {
379 let of = rows.iter().filter(|r| r.knob == k.name);
380 let mut best = of.clone().find(|r| r.value == k.current).expect("the grid holds the current value");
381 for r in of {
382 if r.est.value.unwrap_or(f64::NEG_INFINITY) > best.est.value.unwrap_or(f64::NEG_INFINITY) {
383 best = r;
384 }
385 }
386 let clears = best.value != k.current && current.value.is_some_and(|c| best.est.value.is_some() && best.est.lo > c);
387 Pick { knob: k.name, current: k.current, best: best.value, clears }
388 })
389 .collect();
390 Report {
391 records: outcomes.len(),
392 asked: outcomes.iter().filter(|o| o.kind == outcome::ASKED && !outcome::free(o)).count(),
393 decisions: ds.len(),
394 units: n_units,
395 skipped,
396 drifted,
397 agree,
398 current,
399 rows,
400 picks,
401 }
402}
403
404fn num(x: f64) -> String {
406 let s = format!("{x:.4}");
407 s.trim_end_matches('0').trim_end_matches('.').to_string()
408}
409
410pub fn render(r: &Report) -> String {
413 let s = &r.skipped;
414 let mut out = format!(
415 "records: {}, asked: {}\ndecisions: {} with a pool and p > 0 ({} asked lessons)\nskipped: {} lessons without p, {} with p = 0, {} without a result yet; {} asked records without a pool, {} graded records (p free of the weights)\n",
416 r.records, r.asked, r.decisions, r.units, s.no_p, s.zero_p, s.pending, s.no_pool, s.graded
417 );
418 if r.drifted > 0 {
419 out.push_str(&format!(
420 "left out: {} decision(s) whose replay under the current weights misses the logged p by more than {AGREE} (the live draw saw other records, e.g. a team record that arrived after the ask)\n",
421 r.drifted
422 ));
423 }
424 if r.units == 0 {
425 out.push_str(match r.drifted {
426 0 => "too thin: no logged decision carries a pool and a propensity above 0; constants unchanged\n",
427 _ => "too thin: no logged decision left to estimate from; constants unchanged\n",
428 });
429 out.push_str("(only the Thompson slot draw over a logged pool depends on these weights: graded and learned-cut propensities do not, and the shortlist the model grades is not randomised)\n");
430 return out;
431 }
432 out.push_str(&format!(
433 "agreement: {} of {} replayed p within {AGREE} of the logged p under the current weights (max |Δ| {:.4})\n",
434 r.agree.0, r.agree.1, r.agree.2
435 ));
436 out.push_str("reward: 1 when the session's strongest result after the ask is a change (asked_then_changed), 0 for an ignore, a correction or a veto\n");
437 out.push_str("estimate: self-normalised IPS of the per-ask change rate; 95% CI from a bootstrap over sessions; ESS = (Σw)²/Σw²\n");
438 out.push_str("grid: each weight ×0.5, ×1, ×2 around its current value, the others held\n");
439 for (k, pick) in KNOBS.iter().zip(&r.picks) {
440 let rows: Vec<&Row> = r.rows.iter().filter(|x| x.knob == k.name).collect();
441 let evidence = rows.first().map_or(0, |x| x.evidence);
442 out.push_str(&format!("\n{} (current {}, evidence {evidence} records)\n", k.name, num(k.current)));
443 out.push_str(&format!(" {:<8} {:>9} {:>17} {:>8}\n", "value", "estimate", "95% CI", "ESS"));
444 for x in rows {
445 let est = x.est.value.map_or("-".to_string(), |v| format!("{v:.3}"));
446 let ci = if x.est.value.is_some() { format!("[{:.3}, {:.3}]", x.est.lo, x.est.hi) } else { "-".into() };
447 let mark = if x.value == k.current { " (current)" } else { "" };
448 out.push_str(&format!(" {:<8} {est:>9} {ci:>17} {:>8.1}{mark}\n", num(x.value), x.est.ess));
449 }
450 let best = if pick.best == k.current { format!("{} (current)", num(pick.best)) } else { num(pick.best) };
451 out.push_str(&format!(" best: {best}; its CI lower bound clears the current estimate: {}\n", if pick.clears { "yes" } else { "no" }));
452 }
453 out.push_str("\nconstants unchanged\n");
454 out
455}
456
457fn store_view(lessons: &Path) -> Vec<Outcome> {
460 let (mut out, team) = outcome::team_parts(lessons, outcome::on());
462 out.extend(team);
463 out
464}
465
466pub fn main(args: &[&str]) -> i32 {
469 let usage = || {
470 eprintln!("usage: borg debug ope [--dir DIR | --file OUTCOMES.jsonl] [--json]");
471 2
472 };
473 let (mut dir, mut file, mut json): (Option<String>, Option<String>, bool) = (None, None, false);
474 let mut it = args.iter();
475 while let Some(a) = it.next() {
476 match (*a, dir.is_none() && file.is_none()) {
477 ("--json", _) => json = true,
478 ("--dir", true) => dir = Some(it.next().map(|d| d.to_string())).flatten(),
479 ("--file", true) => file = Some(it.next().map(|f| f.to_string())).flatten(),
480 _ => return usage(),
481 }
482 if matches!(*a, "--dir" | "--file") && dir.is_none() && file.is_none() {
483 return usage();
484 }
485 }
486 let (source, outcomes) = match file {
487 Some(f) => (format!("file {f}"), outcome::read(Path::new(&f))),
488 None => {
489 let cwd = dir.unwrap_or_else(|| std::env::current_dir().unwrap_or_default().display().to_string());
490 match collective::load().map(|cfg| collective::route(cfg.as_ref(), &cwd, &learn::home())) {
491 Ok(collective::Route::Collective { name, home, .. }) => {
492 (format!("collective {name} ({})", home.display()), store_view(&home.join("lessons.jsonl")))
493 }
494 Ok(collective::Route::Off) => {
495 println!("off: {cwd} belongs to no collective; no outcomes are kept here");
496 return 0;
497 }
498 Err(e) => {
499 eprintln!("borg debug ope: invalid collectives config ({e}); everything is off");
500 return 1;
501 }
502 }
503 }
504 };
505 let report = evaluate(&outcomes);
506 match json {
507 true => println!("{}", serde_json::json!({ "source": source, "report": report })),
508 false => print!("{source}\n{}", render(&report)),
509 }
510 0
511}
512
513#[cfg(test)]
514mod tests {
515 use super::*;
516 use crate::outcome::{ASKED, CHANGED, GRADED, IGNORED, VETOED};
517 use std::collections::HashSet;
518
519 fn o(kind: &str, session: &str, lessons: &[&str], ms: i64) -> Outcome {
520 Outcome {
521 kind: kind.into(),
522 session: session.into(),
523 lessons: lessons.iter().map(|l| l.to_string()).collect(),
524 ms,
525 repo: "r".into(),
526 ..Default::default()
527 }
528 }
529
530 fn ask(session: &str, asked: &[&str], p: &[f64], pool: &[&str], ms: i64) -> Outcome {
532 Outcome {
533 p: p.to_vec(),
534 pool: pool.iter().map(|l| l.to_string()).collect(),
535 base: vec![1.0; pool.len()],
536 scoped: vec![false; pool.len()],
537 ..o(ASKED, session, asked, ms)
538 }
539 }
540
541 fn tmp(name: &str) -> std::path::PathBuf {
542 let d = std::env::temp_dir().join(format!("borg-ope-{name}-{}", std::process::id()));
543 let _ = std::fs::remove_dir_all(&d);
544 std::fs::create_dir_all(&d).unwrap();
545 d
546 }
547
548 fn write(path: &Path, lines: &[String]) {
549 use std::io::Write;
550 let mut f = std::fs::OpenOptions::new().create(true).append(true).open(path).unwrap();
551 for l in lines {
552 writeln!(f, "{l}").unwrap();
553 }
554 }
555
556 fn json(x: &Outcome) -> String {
557 serde_json::to_string(x).unwrap()
558 }
559
560 #[test]
561 fn replaying_the_current_weights_reproduces_the_logged_p() {
562 let home = tmp("agree");
563 let lessons = home.join("lessons.jsonl");
564 let ids = ["U1", "U2", "U3", "U4", "S1", "S2", "S3", "S4"];
565 let ls: Vec<String> = ids
566 .iter()
567 .map(|id| {
568 let scope: Vec<&str> = if id.starts_with('S') { vec!["Beacon"] } else { vec![] };
569 serde_json::json!({"id": id, "origin_ms": 0, "question": format!("question of {id}"), "scope": scope}).to_string()
570 })
571 .collect();
572 write(&lessons, &ls);
573 let outcomes = home.join("outcomes.jsonl");
574 write(
576 &outcomes,
577 &[
578 o(ASKED, "h0", &["U1", "U2", "S1"], 100),
579 o(CHANGED, "h0", &["U1"], 110),
580 o(IGNORED, "h0", &["U2"], 120),
581 o(ASKED, "h1", &["U3", "S2"], 200),
582 o(VETOED, "h1", &["U3", "S2"], 210),
583 o(ASKED, "h2", &["U4"], 300),
584 Outcome { cause: "R1".into(), credit: 0.5, ..o(outcome::REGRETTED, "h2", &["U4"], 310) },
585 ]
586 .map(|x| json(&x)),
587 );
588 let mut logged = Vec::new();
590 for (k, s) in ["d0", "d1", "d2"].iter().enumerate() {
591 let ms = 1000 * (k as i64 + 1);
592 let cs = conscience::load_for(&lessons, None, Some(s));
593 let relevance = |i: usize, c: &Challenge| (1.0 + 0.07 * i as f64) / c.weight;
596 let mut ranked: Vec<(f64, &Challenge)> =
597 cs.iter().enumerate().map(|(i, c)| (relevance(i, c) * c.weight * c.trust / crate::trust::NEUTRAL, c)).collect();
598 ranked.sort_by(|a, b| b.0.total_cmp(&a.0));
599 let pool = conscience::budget_pool(&ranked, &HashSet::new());
600 assert!(pool.iter().filter(|(_, c)| c.scope.is_empty()).count() > conscience::GENERIC_CAP, "the generic slots overflow");
601 let p = conscience::propensity(&pool, s);
602 let asked = conscience::in_scope_first(conscience::explored(pool.iter().copied()));
603 let record = Outcome {
604 p: asked.iter().map(|c| outcome::round_p(p[&c.id])).collect(),
605 pool: pool.iter().map(|(_, c)| c.id.clone()).collect(),
606 base: pool.iter().map(|(sc, c)| conscience::pool_base(*sc, c)).collect(),
607 scoped: pool.iter().map(|(_, c)| !c.scope.is_empty()).collect(),
608 ..o(ASKED, s, &asked.iter().map(|c| c.id.as_str()).collect::<Vec<_>>(), ms)
609 };
610 logged.extend(record.p.iter().copied());
611 let mut lines = vec![json(&record)];
612 for (i, c) in asked.iter().enumerate() {
613 lines.push(json(&o(if i % 2 == 0 { CHANGED } else { IGNORED }, s, &[&c.id], ms + 10)));
614 }
615 write(&outcomes, &lines);
616 }
617 assert!(logged.iter().any(|p| *p > 0.0 && *p < 1.0), "{logged:?}");
618 let log = outcome::read_team(&lessons);
619 let r = evaluate(&log);
620 assert_eq!(r.decisions, 3);
621 assert_eq!((r.agree.0, r.agree.1), (logged.len(), logged.len()), "{:?}", r.agree);
622 assert!(r.agree.2 <= 1e-4, "within round_p: {:?}", r.agree);
623 let (ds, _) = decisions(&log);
625 for d in &ds {
626 let prefix: Vec<Outcome> = log.iter().filter(|x| x.ms < d.ms).cloned().collect();
627 let p = replay(&prefix, d, &CURRENT);
628 assert!(d.asked.iter().all(|(id, logged, _)| (p[id] - logged).abs() <= 1e-4), "{p:?} {:?}", d.asked);
629 }
630 let _ = std::fs::remove_dir_all(&home);
631 }
632
633 fn veto_world(sessions: usize, helpful_vetoed: bool) -> Vec<Outcome> {
637 let ids = ["V1", "V2", "V3", "G1", "G2", "G3"];
638 let ls: Vec<Challenge> = ids.iter().map(|id| Challenge { id: id.to_string(), ..Default::default() }).collect();
639 let mut log: Vec<Outcome> = ["V1", "V2", "V3"].iter().map(|v| o(VETOED, "pre", &[v], 0)).collect();
640 for i in 0..sessions {
641 let s = format!("s{i:03}");
642 let ms = 10 * (i as i64 + 1);
643 let mem = memory::derive(&log, &ls, Some(&s));
644 let cs: Vec<Challenge> = ls
645 .iter()
646 .map(|c| {
647 let m = &mem[&c.id];
648 Challenge { explore: m.weight(&c.id, Some(&s)) / m.weight(&c.id, None), belief: (m.alpha(), m.beta()), ..c.clone() }
649 })
650 .collect();
651 let pool: Vec<(f64, &Challenge)> = cs.iter().map(|c| (0.5 + c.belief.0 / (c.belief.0 + c.belief.1), c)).collect();
652 let p = conscience::propensity(&pool, &s);
653 let asked = conscience::in_scope_first(conscience::explored(pool.iter().copied()));
654 let names: Vec<&str> = asked.iter().map(|c| c.id.as_str()).collect();
655 log.push(ask(&s, &names, &names.iter().map(|id| outcome::round_p(p[*id])).collect::<Vec<_>>(), &ids, ms));
656 for id in names {
657 let helps = id.starts_with('V') == helpful_vetoed;
658 log.push(o(if helps { CHANGED } else { IGNORED }, &s, &[id], ms + 1));
659 }
660 }
661 log
662 }
663
664 fn rows<'a>(r: &'a Report, knob: &str) -> Vec<&'a Row> {
665 r.rows.iter().filter(|x| x.knob == knob).collect()
666 }
667
668 #[test]
669 fn a_planted_better_veto_weight_ranks_first() {
670 let r = evaluate(&veto_world(40, false));
671 assert_eq!(r.agree.0, r.agree.1, "{:?}", r.agree);
672 let veto: Vec<f64> = rows(&r, "veto").iter().map(|x| x.est.value.unwrap()).collect();
673 assert!(veto[2] > veto[1] && veto[1] > veto[0], "est(6) > est(3) > est(1.5): {veto:?}");
674 let pick = r.picks.iter().find(|p| p.knob == "veto").unwrap();
675 assert_eq!(pick.best, 6.0);
676 assert!(rows(&r, "veto").iter().all(|x| x.est.ess > 0.0 && x.evidence == 3));
677 let tested: Vec<&Estimate> = rows(&r, "tested").iter().map(|x| &x.est).collect();
679 assert!(tested.iter().all(|e| *e == tested[0]) && rows(&r, "tested")[0].evidence == 0);
680 assert_eq!(r.picks.iter().find(|p| p.knob == "tested").unwrap().best, memory::TESTED_W, "a tie keeps the current value");
681 let r = evaluate(&veto_world(40, true));
683 let veto: Vec<f64> = rows(&r, "veto").iter().map(|x| x.est.value.unwrap()).collect();
684 assert!(veto[0] > veto[1] && veto[1] > veto[2], "{veto:?}");
685 assert_eq!(r.picks.iter().find(|p| p.knob == "veto").unwrap().best, 1.5);
686 assert!(render(&r).contains("constants unchanged"));
687 }
688
689 #[test]
690 fn zero_and_missing_p_are_skipped_and_counted() {
691 let log = vec![
692 o(ASKED, "a", &["A"], 1),
693 ask("b", &["B"], &[0.0], &["B", "X"], 2),
694 Outcome { p: vec![0.5], ..o(ASKED, "c", &["C"], 3) },
695 o(GRADED, "c", &["C", "Y"], 3),
696 ask("d", &["D"], &[0.5], &["D", "Y"], 4),
697 Outcome { p: vec![1.0], channel: "queued".into(), ..o(ASKED, "e", &["E"], 4) },
698 Outcome { p: vec![0.5], channel: "early".into(), ..o(ASKED, "e", &["F"], 4) },
699 o(CHANGED, "a", &["A"], 5),
700 o(CHANGED, "b", &["B"], 5),
701 o(CHANGED, "c", &["C"], 5),
702 ];
703 let (ds, skipped) = decisions(&log);
704 assert!(ds.is_empty());
705 assert_eq!(skipped, Skipped { no_p: 1, zero_p: 1, no_pool: 1, pending: 1, graded: 1 }, "free deliveries count nowhere");
706 let r = evaluate(&log);
707 assert_eq!((r.units, r.current.value, r.asked), (0, None, 4));
708 assert!(render(&r).contains("too thin"), "{}", render(&r));
709 }
710
711 #[test]
712 fn a_placebo_unclear_or_censored_result_rewards_no_ask() {
713 let log = |result: Outcome| vec![ask("d", &["A"], &[0.5], &["A", "B"], 1), result];
714 let changed = o(CHANGED, "d", &["A"], 2);
715 let (ds, skipped) = decisions(&log(changed.clone()));
716 assert_eq!((ds.len(), skipped.pending), (1, 0), "a plain change rewards the ask");
717 let censored = Outcome { kind: outcome::NOT_CORRECTED.into(), ..changed.clone() };
718 for x in [Outcome { channel: outcome::PLACEBO.into(), ..changed.clone() }, Outcome { kind: outcome::UNCLEAR.into(), ..changed }, censored] {
719 let (ds, skipped) = decisions(&log(x.clone()));
720 assert_eq!((ds.len(), skipped.pending), (0, 1), "{x:?} leaves the ask without a result");
721 }
722 }
723
724 #[test]
725 fn snips_is_the_mean_under_equal_weights_and_ess_counts_them() {
726 let units: Vec<(f64, f64, usize)> = (0..40).map(|i| (1.0, if i % 4 == 0 { 1.0 } else { 0.0 }, i / 2)).collect();
727 let e = snips(&units, 7);
728 assert_eq!((e.value, e.ess, e.n), (Some(0.25), 40.0, 40));
729 assert!(e.lo <= 0.25 && 0.25 <= e.hi && e.lo < e.hi, "{e:?}");
730 assert_eq!(snips(&units, 7), e, "one seed, one interval");
731 assert_eq!(snips(&[], 7).value, None);
732 let e = snips(&[(3.0, 1.0, 0), (1.0, 0.0, 1)], 7);
734 assert_eq!((e.value, e.ess), (Some(0.75), 1.6));
735 }
736
737 #[test]
740 fn the_bootstrap_resamples_sessions_not_units() {
741 let units: Vec<(f64, f64, usize)> = (0..40).map(|i| (1.0, if i / 10 < 2 { 1.0 } else { 0.0 }, i / 10)).collect();
742 let e = snips(&units, 7);
743 assert_eq!(e.value, Some(0.5));
744 assert_eq!((e.lo, e.hi), (0.0, 1.0), "{e:?}");
747 let iid: Vec<(f64, f64, usize)> = units.iter().enumerate().map(|(i, u)| (u.0, u.1, i)).collect();
748 let narrow = snips(&iid, 7);
749 assert!(narrow.hi - narrow.lo < 0.5 * (e.hi - e.lo), "{narrow:?}");
750 }
751
752 #[test]
753 fn grid_halves_and_doubles() {
754 assert_eq!(grid(3.0), [1.5, 3.0, 6.0]);
755 let current: Vec<f64> = KNOBS.iter().map(|k| k.current).collect();
756 assert_eq!(current, [memory::VETOED_W as f64, memory::REGRET, memory::TESTED_W]);
757 for k in &KNOBS {
759 let mut c = CURRENT;
760 (k.set)(&mut c, 99.0);
761 assert_ne!(c, CURRENT, "{}", k.name);
762 }
763 }
764
765 fn leak_world(before: &[Outcome]) -> Vec<Outcome> {
768 let pool = ["A", "B", "C", "D"];
769 let probe = ask("d", &["A", "B", "C"], &[1.0, 1.0, 1.0], &pool, 100);
770 let (ds, _) = decisions(&[[before, &[probe.clone(), o(CHANGED, "d", &["A"], 110)]].concat()].concat());
771 let p = replay(before, &ds[0], &CURRENT);
772 let logged = Outcome { p: ["A", "B", "C"].iter().map(|l| outcome::round_p(p[*l])).collect(), ..probe };
773 [before, &[logged, o(CHANGED, "d", &["A"], 110)]].concat()
774 }
775
776 #[test]
777 fn replay_is_leak_free() {
778 let history = [o(CHANGED, "h", &["A"], 1), o(IGNORED, "h", &["B"], 2)];
779 let base = leak_world(&history);
780 let veto = |ms: i64| [base.clone(), vec![o(VETOED, "later", &["A", "B"], ms)]].concat();
781 let (ds, _) = decisions(&base);
782 let at = |log: &[Outcome]| replay(log, &ds[0], &CURRENT);
784 assert_eq!(at(&veto(100)), at(&base), "a veto at the decision's ms is not seen");
785 assert_eq!(at(&veto(500)), at(&base), "nor one after it");
786 assert_ne!(at(&veto(99)), at(&base), "one before it is");
787 let before = evaluate(&base);
789 assert_eq!((before.decisions, before.drifted, before.agree.0, before.agree.1), (1, 0, 1, 1), "{:?}", before.agree);
790 assert_eq!(evaluate(&veto(100)).rows, before.rows);
791 assert_eq!(evaluate(&veto(500)).rows, before.rows);
792 let seen = leak_world(&[history.to_vec(), vec![o(VETOED, "early", &["A", "B"], 99)]].concat());
794 let r = evaluate(&seen);
795 assert_eq!((r.drifted, r.units), (0, 1));
796 assert_ne!(r.rows, before.rows, "it moved the draw");
797 }
798
799 #[test]
802 fn a_record_the_live_draw_did_not_see_drops_its_decision() {
803 let history = [o(CHANGED, "h", &["A"], 1), o(IGNORED, "h", &["B"], 2)];
804 let late = [leak_world(&history), vec![o(VETOED, "peer", &["A", "B"], 50)]].concat();
805 let r = evaluate(&late);
806 assert_eq!((r.decisions, r.drifted, r.units), (1, 1, 0), "{:?}", r.agree);
807 assert!(r.agree.0 < r.agree.1 && r.current.value.is_none());
808 assert!(render(&r).contains("left out: 1 decision(s)"), "{}", render(&r));
809 }
810
811 #[test]
812 fn replay_scales_with_the_store() {
813 let mut log = Vec::new();
816 for i in 0..2000usize {
817 let (a, b) = (format!("L{}", i % 200), format!("L{}", (i * 7 + 1) % 200));
818 let s = format!("s{i}");
819 let ms = 10 * i as i64 + 5;
820 log.push(ask(&s, &[&a], &[1.0], &[&a, &b], ms));
821 log.push(o(if i % 3 == 0 { CHANGED } else { IGNORED }, &s, &[&a], ms + 1));
822 for k in 0..8 {
823 log.push(o(outcome::NOT_CORRECTED, &format!("x{i}"), &[&format!("L{}", (i + k) % 200)], ms + 2));
824 }
825 }
826 assert_eq!(log.len(), 20_000);
827 let started = std::time::Instant::now();
828 let r = evaluate(&log);
829 assert_eq!(r.decisions, 2000);
830 assert!(started.elapsed() < std::time::Duration::from_secs(5), "{:?}", started.elapsed());
831 }
832}