Skip to main content

borg/
ope.rs

1//! `borg debug ope`: off-policy estimates of the usefulness belief's hand-set weights, from the
2//! outcome log alone. Reports, never applies: no constant changes.
3//!
4//! **What is estimated.** The per-ask change rate under a candidate policy: of the lessons a check
5//! asks, the share the drone then changed its work on. The candidates differ from the policy in force
6//! in one weight of the belief (memory.rs `Weights`), the others held.
7//!
8//! **Which records count.** The weights reach a logged propensity on one path only: the Thompson slot
9//! draw over a pool (conscience.rs `budget_pool`/`widen_pool` and `propensity`, the bm25 and the
10//! non-graded paths), whose `asked` records carry the pool (outcome.rs `pool`, `base`, `scoped`). On the
11//! default graded path p comes from the model's grades (0 or 1) or the learned cut (attention.rs), both
12//! free of these weights: a candidate's p equals the logged p there, so those records say nothing about
13//! the weights. The weights do order the shortlist the model grades, but nothing there is randomised or
14//! logged. So a decision is an `asked` record with p and a pool; the rest is skipped and counted
15//! (`Skipped`): no p, p = 0, no pool, no result yet, and `graded` records.
16//!
17//! **Leak-free replay.** For each decision the beliefs come from memory.rs `derive` over the outcomes
18//! before its ms (sorted once, stably), under the candidate's weights, then the same `propensity` the
19//! live path ran, on the logged pool with score = base × (0.5 + the candidate's mean) and the
20//! decision's session. A lesson's counts depend only on the records that name it, so `evaluate`
21//! derives over those records alone (an index per lesson), which keeps a run linear in the log. The
22//! logged pool is held fixed: other weights could move a lesson across the LOW/BLOCK bars or change
23//! the widen path's forced picks, which the replay does not model. "Before its ms" is what the live
24//! draw saw only when every record arrived by its ms: a team record arrives a sync round after its ms,
25//! so the draw may not have seen it. A decision whose replay under the current weights misses its
26//! logged p (by more than `AGREE`) was drawn from other records than the replay's, and its weights
27//! would be biased: it is counted (`Report::drifted`) and left out of every estimate.
28//!
29//! **The estimate.** Self-normalised inverse propensity scoring over the asked lessons, w = p under the
30//! candidate / logged p, reward 1 when the session's result after the ask is a change; the effective
31//! sample size (Σw)²/Σw²; a 95% interval from a bootstrap over whole sessions (a session's rewards are
32//! not independent), seeded, so a run is deterministic.
33
34use 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
44/// One hand-set weight: its name, current value, the outcome kinds it weighs (its evidence), and how a
45/// candidate sets it.
46pub 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
53/// The three hand-set weights: the veto's (`VETOED_W`), a regret's (`REGRET`) and a green test run's
54/// (`TESTED_W`).
55pub 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
61/// Candidates around a weight: half, itself, double. Derived from the current value, never hand-picked;
62/// one knob moves at a time, the others stay current.
63pub fn grid(current: f64) -> [f64; 3] {
64    [current / 2.0, current, current * 2.0]
65}
66
67/// The policy in force.
68pub const CURRENT: Weights = memory::WEIGHTS;
69
70/// One logged Thompson check: its `asked` record's index in the log, ms and session, the asked lessons
71/// with their logged p and reward, and the pool (id, base, scoped).
72#[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/// What `decisions` left out, and why. `no_p`, `zero_p` and `pending` count asked lessons; `no_pool`
82/// and `graded` count records.
83#[derive(Debug, Default, Clone, PartialEq, Serialize)]
84pub struct Skipped {
85    /// An `asked` record without p (or with p not aligned with its lessons): older logs, BORG_ONLY.
86    pub no_p: usize,
87    /// A lesson asked with p = 0: no inverse weight exists.
88    pub zero_p: usize,
89    /// An `asked` record with p but no pool: the graded rule, the learned cut, a live ask, a team record,
90    /// a log from before pools. The weights do not move them.
91    pub no_pool: usize,
92    /// An asked lesson with no result in its session at or after the ask yet.
93    pub pending: usize,
94    /// A `graded` record: its p comes from the model's grades or the learned cut, free of the weights.
95    pub graded: usize,
96}
97
98/// A result's rank, as memory.rs folds a session: vetoed > corrected > changed > ignored. A lesson
99/// settled without a verdict (`asked_not_corrected`) has none: censored, as memory.rs reads it (the
100/// judge never said whether the drone acted on it), so its ask waits as pending.
101pub(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        _ => None,
108    }
109}
110
111/// The logged Thompson checks of `outcomes`, in log order, and what was skipped. A lesson's reward is
112/// 1 when the strongest result of its session at or after the ask is a change, else 0: a correction or
113/// a veto after the ask means asking did not help. A placebo's result (outcome.rs `PLACEBO`: judged
114/// where a free delivery would be, never shown) is no result about the lesson, and an unclear verdict
115/// (`UNCLEAR`) has no rank: neither rewards anything.
116pub fn decisions(outcomes: &[Outcome]) -> (Vec<Decision>, Skipped) {
117    let mut results: HashMap<(&str, &str), Vec<(i64, u8)>> = HashMap::new();
118    for o in outcomes.iter().filter(|o| o.channel != outcome::PLACEBO) {
119        if let Some(r) = rank(&o.kind) {
120            for l in &o.lessons {
121                results.entry((o.session.as_str(), l.as_str())).or_default().push((o.ms, r));
122            }
123        }
124    }
125    let mut skipped = Skipped::default();
126    let mut out = Vec::new();
127    for (at, o) in outcomes.iter().enumerate() {
128        if o.kind == outcome::GRADED {
129            skipped.graded += 1;
130            continue;
131        }
132        // A free delivery (early, queued) asks nothing and draws from no pool the weights move.
133        if o.kind != outcome::ASKED || outcome::free(o) {
134            continue;
135        }
136        if o.p.is_empty() || o.p.len() != o.lessons.len() {
137            skipped.no_p += o.lessons.len();
138            continue;
139        }
140        let aligned = o.pool.len() == o.base.len() && o.pool.len() == o.scoped.len();
141        if o.pool.is_empty() || !aligned || !o.lessons.iter().all(|l| o.pool.contains(l)) {
142            skipped.no_pool += 1;
143            continue;
144        }
145        let mut asked = Vec::new();
146        for (l, &p) in o.lessons.iter().zip(&o.p) {
147            if p <= 0.0 {
148                skipped.zero_p += 1;
149                continue;
150            }
151            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());
152            match after {
153                Some(r) => asked.push((l.clone(), p, if r == 1 { 1.0 } else { 0.0 })),
154                None => skipped.pending += 1,
155            }
156        }
157        if !asked.is_empty() {
158            let pool = o.pool.iter().zip(&o.base).zip(&o.scoped).map(|((id, b), s)| (id.clone(), *b, *s)).collect();
159            out.push(Decision { at, ms: o.ms, session: o.session.clone(), asked, pool });
160        }
161    }
162    (out, skipped)
163}
164
165/// The pool's lessons as `derive` needs them: their ids.
166fn pool_lessons(d: &Decision) -> Vec<Challenge> {
167    d.pool.iter().map(|(id, _, _)| Challenge { id: id.clone(), ..Default::default() }).collect()
168}
169
170/// Each pooled lesson's chance of a slot under weights `w`, given its counts `mem`: the logged pool
171/// with each belief and score under `w`, through the live `propensity`.
172fn slot_p(mem: &HashMap<String, Memory>, d: &Decision, w: &Weights) -> HashMap<String, f64> {
173    let pool: Vec<Challenge> = d
174        .pool
175        .iter()
176        .map(|(id, _, scoped)| {
177            let m = &mem[id];
178            Challenge {
179                id: id.clone(),
180                scope: if *scoped { vec![conscience::ANY.into()] } else { Vec::new() },
181                belief: (m.alpha_with(w), m.beta_with(w)),
182                ..Default::default()
183            }
184        })
185        .collect();
186    let scored: Vec<(f64, &Challenge)> = pool
187        .iter()
188        .zip(&d.pool)
189        .map(|(c, (_, base, _))| {
190            let (a, b) = c.belief;
191            (base * (0.5 + a / (a + b)), c)
192        })
193        .collect();
194    conscience::propensity(&scored, &d.session)
195}
196
197/// Each pooled lesson's chance of a slot under `cand`, as of the decision: beliefs from memory::derive
198/// over the records of `log` before `d.ms` (the cut is made here: nothing at or after the decision
199/// reaches it) with the candidate weights, then conscience::propensity on the logged pool with s =
200/// base × (0.5 + mean) and the decision's session.
201pub fn replay(log: &[Outcome], d: &Decision, cand: &Weights) -> HashMap<String, f64> {
202    let prefix: Vec<Outcome> = log.iter().filter(|o| o.ms < d.ms).cloned().collect();
203    slot_p(&memory::derive(&prefix, &pool_lessons(d), None), d, cand)
204}
205
206/// One estimate of a policy's per-ask change rate.
207#[derive(Debug, Clone, PartialEq, Serialize)]
208pub struct Estimate {
209    /// SNIPS: Σwr/Σw; None without weight.
210    pub value: Option<f64>,
211    /// The 95% bootstrap interval (0, 0 without a value).
212    pub lo: f64,
213    pub hi: f64,
214    /// Effective sample size (Σw)²/Σw².
215    pub ess: f64,
216    /// Units (asked lessons) behind it.
217    pub n: usize,
218}
219
220/// Method: how far a replayed p may lie from the logged one and still count as the same draw: the logged p is
221/// rounded (outcome.rs `round_p`), and the draw's estimate is Monte Carlo (conscience::P_DRAWS).
222pub const AGREE: f64 = 0.01;
223
224/// Method: the resamples behind a CI: precision, not a knob (as conscience::P_DRAWS).
225pub const RESAMPLES: usize = conscience::P_DRAWS;
226
227/// Method: the bootstrap's seed, fixed so a run is deterministic (also select/replay.rs's).
228pub(crate) const SEED: u64 = 0x006f_7065;
229
230/// Self-normalised IPS over units (w = p_cand / p_logged, reward, session): Σwr/Σw; None when Σw = 0.
231/// ESS = (Σw)²/Σw². CI: the 2.5th and 97.5th percentiles of `RESAMPLES` bootstrap resamples of whole
232/// sessions (the session's rewards are not independent), drawn with memory::Rng from `seed`:
233/// deterministic.
234pub fn snips(units: &[(f64, f64, usize)], seed: u64) -> Estimate {
235    let ratio = |sw: f64, swr: f64| (sw > 0.0).then(|| swr / sw);
236    let sw: f64 = units.iter().map(|u| u.0).sum();
237    let sw2: f64 = units.iter().map(|u| u.0 * u.0).sum();
238    let value = ratio(sw, units.iter().map(|u| u.0 * u.1).sum());
239    let ess = if sw2 > 0.0 { sw * sw / sw2 } else { 0.0 };
240    // Per session (in session order, so the resamples do not depend on the units' order): Σw, Σwr.
241    let mut by: BTreeMap<usize, (f64, f64)> = BTreeMap::new();
242    for (w, r, s) in units {
243        let e = by.entry(*s).or_default();
244        e.0 += w;
245        e.1 += w * r;
246    }
247    let groups: Vec<(f64, f64)> = by.into_values().collect();
248    let mut rng = memory::Rng::new(seed);
249    let mut values: Vec<f64> = Vec::new();
250    if value.is_some() {
251        for _ in 0..RESAMPLES {
252            let (mut sw, mut swr) = (0.0, 0.0);
253            for _ in 0..groups.len() {
254                let g = groups[(rng.next_u64() % groups.len() as u64) as usize];
255                sw += g.0;
256                swr += g.1;
257            }
258            values.extend(ratio(sw, swr));
259        }
260    }
261    values.sort_by(f64::total_cmp);
262    let pct = |q: f64| values.get((q * (values.len().max(1) - 1) as f64).round() as usize).copied().unwrap_or(0.0);
263    Estimate { value, lo: pct(0.025), hi: pct(0.975), ess, n: units.len() }
264}
265
266/// One candidate value of one knob.
267#[derive(Debug, Clone, PartialEq, Serialize)]
268pub struct Row {
269    pub knob: &'static str,
270    pub value: f64,
271    pub est: Estimate,
272    /// Records of the knob's kinds before the last decision (in the whole log without a decision):
273    /// without any, the candidates cannot differ.
274    pub evidence: usize,
275}
276
277/// A knob's best candidate (the highest estimate; ties go to the current value) and whether its CI
278/// lower bound clears the current value's estimate. Never applied.
279#[derive(Debug, Clone, PartialEq, Serialize)]
280pub struct Pick {
281    pub knob: &'static str,
282    pub current: f64,
283    pub best: f64,
284    pub clears: bool,
285}
286
287#[derive(Debug, Clone, PartialEq, Serialize)]
288pub struct Report {
289    /// Records read, `asked` records among them (free deliveries left out: they ask nothing).
290    pub records: usize,
291    pub asked: usize,
292    pub decisions: usize,
293    /// Asked lessons behind the estimates (those of drifted decisions left out).
294    pub units: usize,
295    pub skipped: Skipped,
296    /// Decisions whose replay under the current weights misses the logged p by more than `AGREE`: the
297    /// live draw saw other records (module doc), so they are left out of every estimate.
298    pub drifted: usize,
299    /// The current weights' replay against the logged p: units within 0.01, of all, the largest |Δ|.
300    pub agree: (usize, usize, f64),
301    pub current: Estimate,
302    pub rows: Vec<Row>,
303    pub picks: Vec<Pick>,
304}
305
306/// Evaluates every knob's grid on `outcomes` (`decisions`, `replay`, `snips`).
307pub fn evaluate(outcomes: &[Outcome]) -> Report {
308    let (ds, skipped) = decisions(outcomes);
309    let mut sorted: Vec<&Outcome> = outcomes.iter().collect();
310    sorted.sort_by_key(|o| o.ms);
311    // Per lesson, the positions (in `sorted`) of the records naming it: a lesson's counts depend on
312    // those alone.
313    let mut naming: HashMap<&str, Vec<usize>> = HashMap::new();
314    for (i, o) in sorted.iter().enumerate() {
315        for l in &o.lessons {
316            naming.entry(l.as_str()).or_default().push(i);
317        }
318    }
319    // Every (knob, value), and the distinct candidates behind them (the current one once).
320    let mut cands: Vec<Weights> = vec![CURRENT];
321    let mut grid_rows: Vec<(usize, f64, usize)> = Vec::new();
322    for (k, knob) in KNOBS.iter().enumerate() {
323        for v in grid(knob.current) {
324            let mut c = CURRENT;
325            (knob.set)(&mut c, v);
326            let u = cands.iter().position(|x| *x == c).unwrap_or_else(|| {
327                cands.push(c);
328                cands.len() - 1
329            });
330            grid_rows.push((k, v, u));
331        }
332    }
333    let mut sessions: HashMap<&str, usize> = HashMap::new();
334    let mut units: Vec<Vec<(f64, f64, usize)>> = vec![Vec::new(); cands.len()];
335    let mut agree = (0, 0, 0.0f64);
336    let (mut drifted, mut n_units) = (0, 0);
337    for d in &ds {
338        let cut = sorted.partition_point(|o| o.ms < d.ms);
339        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();
340        at.sort_unstable();
341        at.dedup();
342        let prefix: Vec<Outcome> = at.into_iter().map(|i| sorted[i].clone()).collect();
343        let mem = memory::derive(&prefix, &pool_lessons(d), None);
344        let s = sessions.len();
345        let s = *sessions.entry(d.session.as_str()).or_insert(s);
346        let ps: Vec<HashMap<String, f64>> = cands.iter().map(|c| slot_p(&mem, d, c)).collect();
347        // The current weights (candidate 0) must reproduce the logged draw.
348        let mut same = true;
349        for (id, logged, _) in &d.asked {
350            let delta = (ps[0].get(id).copied().unwrap_or(0.0) - logged).abs();
351            agree.0 += usize::from(delta <= AGREE);
352            agree.1 += 1;
353            agree.2 = agree.2.max(delta);
354            same &= delta <= AGREE;
355        }
356        if !same {
357            drifted += 1;
358            continue;
359        }
360        n_units += d.asked.len();
361        for (u, p) in ps.iter().enumerate() {
362            for (id, logged, reward) in &d.asked {
363                units[u].push((p.get(id).copied().unwrap_or(0.0) / logged, *reward, s));
364            }
365        }
366    }
367    let estimates: Vec<Estimate> = units.iter().map(|u| snips(u, SEED)).collect();
368    let last = ds.iter().map(|d| d.ms).max().unwrap_or(i64::MAX);
369    let evidence = |k: &Knob| outcomes.iter().filter(|o| o.ms < last && k.kinds.contains(&o.kind.as_str())).count();
370    let rows: Vec<Row> =
371        grid_rows.iter().map(|&(k, v, u)| Row { knob: KNOBS[k].name, value: v, est: estimates[u].clone(), evidence: evidence(&KNOBS[k]) }).collect();
372    let current = estimates[0].clone();
373    let picks = KNOBS
374        .iter()
375        .map(|k| {
376            let of = rows.iter().filter(|r| r.knob == k.name);
377            let mut best = of.clone().find(|r| r.value == k.current).expect("the grid holds the current value");
378            for r in of {
379                if r.est.value.unwrap_or(f64::NEG_INFINITY) > best.est.value.unwrap_or(f64::NEG_INFINITY) {
380                    best = r;
381                }
382            }
383            let clears = best.value != k.current && current.value.is_some_and(|c| best.est.value.is_some() && best.est.lo > c);
384            Pick { knob: k.name, current: k.current, best: best.value, clears }
385        })
386        .collect();
387    Report {
388        records: outcomes.len(),
389        asked: outcomes.iter().filter(|o| o.kind == outcome::ASKED && !outcome::free(o)).count(),
390        decisions: ds.len(),
391        units: n_units,
392        skipped,
393        drifted,
394        agree,
395        current,
396        rows,
397        picks,
398    }
399}
400
401/// `x` without trailing zeros: 3, 1.5, 0.25.
402fn num(x: f64) -> String {
403    let s = format!("{x:.4}");
404    s.trim_end_matches('0').trim_end_matches('.').to_string()
405}
406
407/// The report as text: counts, the agreement of the replay with the log, the reward, the grid, then a
408/// table per knob with its best candidate.
409pub fn render(r: &Report) -> String {
410    let s = &r.skipped;
411    let mut out = format!(
412        "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",
413        r.records, r.asked, r.decisions, r.units, s.no_p, s.zero_p, s.pending, s.no_pool, s.graded
414    );
415    if r.drifted > 0 {
416        out.push_str(&format!(
417            "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",
418            r.drifted
419        ));
420    }
421    if r.units == 0 {
422        out.push_str(match r.drifted {
423            0 => "too thin: no logged decision carries a pool and a propensity above 0; constants unchanged\n",
424            _ => "too thin: no logged decision left to estimate from; constants unchanged\n",
425        });
426        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");
427        return out;
428    }
429    out.push_str(&format!(
430        "agreement: {} of {} replayed p within {AGREE} of the logged p under the current weights (max |Δ| {:.4})\n",
431        r.agree.0, r.agree.1, r.agree.2
432    ));
433    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");
434    out.push_str("estimate: self-normalised IPS of the per-ask change rate; 95% CI from a bootstrap over sessions; ESS = (Σw)²/Σw²\n");
435    out.push_str("grid: each weight ×0.5, ×1, ×2 around its current value, the others held\n");
436    for (k, pick) in KNOBS.iter().zip(&r.picks) {
437        let rows: Vec<&Row> = r.rows.iter().filter(|x| x.knob == k.name).collect();
438        let evidence = rows.first().map_or(0, |x| x.evidence);
439        out.push_str(&format!("\n{} (current {}, evidence {evidence} records)\n", k.name, num(k.current)));
440        out.push_str(&format!("  {:<8} {:>9} {:>17} {:>8}\n", "value", "estimate", "95% CI", "ESS"));
441        for x in rows {
442            let est = x.est.value.map_or("-".to_string(), |v| format!("{v:.3}"));
443            let ci = if x.est.value.is_some() { format!("[{:.3}, {:.3}]", x.est.lo, x.est.hi) } else { "-".into() };
444            let mark = if x.value == k.current { " (current)" } else { "" };
445            out.push_str(&format!("  {:<8} {est:>9} {ci:>17} {:>8.1}{mark}\n", num(x.value), x.est.ess));
446        }
447        let best = if pick.best == k.current { format!("{} (current)", num(pick.best)) } else { num(pick.best) };
448        out.push_str(&format!("  best: {best}; its CI lower bound clears the current estimate: {}\n", if pick.clears { "yes" } else { "no" }));
449    }
450    out.push_str("\nconstants unchanged\n");
451    out
452}
453
454/// The outcomes beliefs were derived from live (outcome.rs `read_team`), with this machine's records
455/// as written.
456fn store_view(lessons: &Path) -> Vec<Outcome> {
457    // One read of each file: the local records and the team's beside them never overlap.
458    let (mut out, team) = outcome::team_parts(lessons, outcome::on());
459    out.extend(team);
460    out
461}
462
463/// `borg debug ope [--dir DIR | --file OUTCOMES.jsonl] [--json]`: the store of the collective DIR routes to
464/// (the working directory by default), or one outcomes file alone.
465pub fn main(args: &[&str]) -> i32 {
466    let usage = || {
467        eprintln!("usage: borg debug ope [--dir DIR | --file OUTCOMES.jsonl] [--json]");
468        2
469    };
470    let (mut dir, mut file, mut json): (Option<String>, Option<String>, bool) = (None, None, false);
471    let mut it = args.iter();
472    while let Some(a) = it.next() {
473        match (*a, dir.is_none() && file.is_none()) {
474            ("--json", _) => json = true,
475            ("--dir", true) => dir = Some(it.next().map(|d| d.to_string())).flatten(),
476            ("--file", true) => file = Some(it.next().map(|f| f.to_string())).flatten(),
477            _ => return usage(),
478        }
479        if matches!(*a, "--dir" | "--file") && dir.is_none() && file.is_none() {
480            return usage();
481        }
482    }
483    let (source, outcomes) = match file {
484        Some(f) => (format!("file {f}"), outcome::read(Path::new(&f))),
485        None => {
486            let cwd = dir.unwrap_or_else(|| std::env::current_dir().unwrap_or_default().display().to_string());
487            match collective::load().map(|cfg| collective::route(cfg.as_ref(), &cwd, &learn::home())) {
488                Ok(collective::Route::Collective { name, home, .. }) => {
489                    (format!("collective {name} ({})", home.display()), store_view(&home.join("lessons.jsonl")))
490                }
491                Ok(collective::Route::Off) => {
492                    println!("off: {cwd} belongs to no collective; no outcomes are kept here");
493                    return 0;
494                }
495                Err(e) => {
496                    eprintln!("borg debug ope: invalid collectives config ({e}); everything is off");
497                    return 1;
498                }
499            }
500        }
501    };
502    let report = evaluate(&outcomes);
503    match json {
504        true => println!("{}", serde_json::json!({ "source": source, "report": report })),
505        false => print!("{source}\n{}", render(&report)),
506    }
507    0
508}
509
510#[cfg(test)]
511mod tests {
512    use super::*;
513    use crate::outcome::{ASKED, CHANGED, GRADED, IGNORED, VETOED};
514    use std::collections::HashSet;
515
516    fn o(kind: &str, session: &str, lessons: &[&str], ms: i64) -> Outcome {
517        Outcome {
518            kind: kind.into(),
519            session: session.into(),
520            lessons: lessons.iter().map(|l| l.to_string()).collect(),
521            ms,
522            repo: "r".into(),
523            ..Default::default()
524        }
525    }
526
527    /// An `asked` record of `asked` from `pool` (ids with base 1, unscoped) with logged `p`.
528    fn ask(session: &str, asked: &[&str], p: &[f64], pool: &[&str], ms: i64) -> Outcome {
529        Outcome {
530            p: p.to_vec(),
531            pool: pool.iter().map(|l| l.to_string()).collect(),
532            base: vec![1.0; pool.len()],
533            scoped: vec![false; pool.len()],
534            ..o(ASKED, session, asked, ms)
535        }
536    }
537
538    fn tmp(name: &str) -> std::path::PathBuf {
539        let d = std::env::temp_dir().join(format!("borg-ope-{name}-{}", std::process::id()));
540        let _ = std::fs::remove_dir_all(&d);
541        std::fs::create_dir_all(&d).unwrap();
542        d
543    }
544
545    fn write(path: &Path, lines: &[String]) {
546        use std::io::Write;
547        let mut f = std::fs::OpenOptions::new().create(true).append(true).open(path).unwrap();
548        for l in lines {
549            writeln!(f, "{l}").unwrap();
550        }
551    }
552
553    fn json(x: &Outcome) -> String {
554        serde_json::to_string(x).unwrap()
555    }
556
557    #[test]
558    fn replaying_the_current_weights_reproduces_the_logged_p() {
559        let home = tmp("agree");
560        let lessons = home.join("lessons.jsonl");
561        let ids = ["U1", "U2", "U3", "U4", "S1", "S2", "S3", "S4"];
562        let ls: Vec<String> = ids
563            .iter()
564            .map(|id| {
565                let scope: Vec<&str> = if id.starts_with('S') { vec!["Beacon"] } else { vec![] };
566                serde_json::json!({"id": id, "origin_ms": 0, "question": format!("question of {id}"), "scope": scope}).to_string()
567            })
568            .collect();
569        write(&lessons, &ls);
570        let outcomes = home.join("outcomes.jsonl");
571        // History: changes, ignores, a veto and a regret.
572        write(
573            &outcomes,
574            &[
575                o(ASKED, "h0", &["U1", "U2", "S1"], 100),
576                o(CHANGED, "h0", &["U1"], 110),
577                o(IGNORED, "h0", &["U2"], 120),
578                o(ASKED, "h1", &["U3", "S2"], 200),
579                o(VETOED, "h1", &["U3", "S2"], 210),
580                o(ASKED, "h2", &["U4"], 300),
581                Outcome { cause: "R1".into(), credit: 0.5, ..o(outcome::REGRETTED, "h2", &["U4"], 310) },
582            ]
583            .map(|x| json(&x)),
584        );
585        // Three checks through the live chain: load as the session, rank, pool, slots, propensity.
586        let mut logged = Vec::new();
587        for (k, s) in ["d0", "d1", "d2"].iter().enumerate() {
588            let ms = 1000 * (k as i64 + 1);
589            let cs = conscience::load_for(&lessons, None, Some(s));
590            // `rank`'s score, relevance × weight × trust / NEUTRAL, with a relevance that keeps all eight
591            // over the low bar.
592            let relevance = |i: usize, c: &Challenge| (1.0 + 0.07 * i as f64) / c.weight;
593            let mut ranked: Vec<(f64, &Challenge)> =
594                cs.iter().enumerate().map(|(i, c)| (relevance(i, c) * c.weight * c.trust / crate::trust::NEUTRAL, c)).collect();
595            ranked.sort_by(|a, b| b.0.total_cmp(&a.0));
596            let pool = conscience::budget_pool(&ranked, &HashSet::new());
597            assert!(pool.iter().filter(|(_, c)| c.scope.is_empty()).count() > conscience::GENERIC_CAP, "the generic slots overflow");
598            let p = conscience::propensity(&pool, s);
599            let asked = conscience::in_scope_first(conscience::explored(pool.iter().copied()));
600            let record = Outcome {
601                p: asked.iter().map(|c| outcome::round_p(p[&c.id])).collect(),
602                pool: pool.iter().map(|(_, c)| c.id.clone()).collect(),
603                base: pool.iter().map(|(sc, c)| conscience::pool_base(*sc, c)).collect(),
604                scoped: pool.iter().map(|(_, c)| !c.scope.is_empty()).collect(),
605                ..o(ASKED, s, &asked.iter().map(|c| c.id.as_str()).collect::<Vec<_>>(), ms)
606            };
607            logged.extend(record.p.iter().copied());
608            let mut lines = vec![json(&record)];
609            for (i, c) in asked.iter().enumerate() {
610                lines.push(json(&o(if i % 2 == 0 { CHANGED } else { IGNORED }, s, &[&c.id], ms + 10)));
611            }
612            write(&outcomes, &lines);
613        }
614        assert!(logged.iter().any(|p| *p > 0.0 && *p < 1.0), "{logged:?}");
615        let log = outcome::read_team(&lessons);
616        let r = evaluate(&log);
617        assert_eq!(r.decisions, 3);
618        assert_eq!((r.agree.0, r.agree.1), (logged.len(), logged.len()), "{:?}", r.agree);
619        assert!(r.agree.2 <= 1e-4, "within round_p: {:?}", r.agree);
620        // The public replay over the whole prefix agrees too (evaluate derives over the pool's records).
621        let (ds, _) = decisions(&log);
622        for d in &ds {
623            let prefix: Vec<Outcome> = log.iter().filter(|x| x.ms < d.ms).cloned().collect();
624            let p = replay(&prefix, d, &CURRENT);
625            assert!(d.asked.iter().all(|(id, logged, _)| (p[id] - logged).abs() <= 1e-4), "{p:?} {:?}", d.asked);
626        }
627        let _ = std::fs::remove_dir_all(&home);
628    }
629
630    /// A world of `sessions` checks over six unscoped lessons of equal base, three slots: V1-V3 carry
631    /// one early veto each, G1-G3 none. Logged under the current weights as the live path does, beliefs
632    /// derived from the log so far; `helpful_vetoed` says which three the drone changes its work on.
633    fn veto_world(sessions: usize, helpful_vetoed: bool) -> Vec<Outcome> {
634        let ids = ["V1", "V2", "V3", "G1", "G2", "G3"];
635        let ls: Vec<Challenge> = ids.iter().map(|id| Challenge { id: id.to_string(), ..Default::default() }).collect();
636        let mut log: Vec<Outcome> = ["V1", "V2", "V3"].iter().map(|v| o(VETOED, "pre", &[v], 0)).collect();
637        for i in 0..sessions {
638            let s = format!("s{i:03}");
639            let ms = 10 * (i as i64 + 1);
640            let mem = memory::derive(&log, &ls, Some(&s));
641            let cs: Vec<Challenge> = ls
642                .iter()
643                .map(|c| {
644                    let m = &mem[&c.id];
645                    Challenge { explore: m.weight(&c.id, Some(&s)) / m.weight(&c.id, None), belief: (m.alpha(), m.beta()), ..c.clone() }
646                })
647                .collect();
648            let pool: Vec<(f64, &Challenge)> = cs.iter().map(|c| (0.5 + c.belief.0 / (c.belief.0 + c.belief.1), c)).collect();
649            let p = conscience::propensity(&pool, &s);
650            let asked = conscience::in_scope_first(conscience::explored(pool.iter().copied()));
651            let names: Vec<&str> = asked.iter().map(|c| c.id.as_str()).collect();
652            log.push(ask(&s, &names, &names.iter().map(|id| outcome::round_p(p[*id])).collect::<Vec<_>>(), &ids, ms));
653            for id in names {
654                let helps = id.starts_with('V') == helpful_vetoed;
655                log.push(o(if helps { CHANGED } else { IGNORED }, &s, &[id], ms + 1));
656            }
657        }
658        log
659    }
660
661    fn rows<'a>(r: &'a Report, knob: &str) -> Vec<&'a Row> {
662        r.rows.iter().filter(|x| x.knob == knob).collect()
663    }
664
665    #[test]
666    fn a_planted_better_veto_weight_ranks_first() {
667        let r = evaluate(&veto_world(40, false));
668        assert_eq!(r.agree.0, r.agree.1, "{:?}", r.agree);
669        let veto: Vec<f64> = rows(&r, "veto").iter().map(|x| x.est.value.unwrap()).collect();
670        assert!(veto[2] > veto[1] && veto[1] > veto[0], "est(6) > est(3) > est(1.5): {veto:?}");
671        let pick = r.picks.iter().find(|p| p.knob == "veto").unwrap();
672        assert_eq!(pick.best, 6.0);
673        assert!(rows(&r, "veto").iter().all(|x| x.est.ess > 0.0 && x.evidence == 3));
674        // A knob without evidence cannot tell its candidates apart.
675        let tested: Vec<&Estimate> = rows(&r, "tested").iter().map(|x| &x.est).collect();
676        assert!(tested.iter().all(|e| *e == tested[0]) && rows(&r, "tested")[0].evidence == 0);
677        assert_eq!(r.picks.iter().find(|p| p.knob == "tested").unwrap().best, memory::TESTED_W, "a tie keeps the current value");
678        // The mirror world, where the vetoed lessons are the helpful ones: a lighter veto ranks first.
679        let r = evaluate(&veto_world(40, true));
680        let veto: Vec<f64> = rows(&r, "veto").iter().map(|x| x.est.value.unwrap()).collect();
681        assert!(veto[0] > veto[1] && veto[1] > veto[2], "{veto:?}");
682        assert_eq!(r.picks.iter().find(|p| p.knob == "veto").unwrap().best, 1.5);
683        assert!(render(&r).contains("constants unchanged"));
684    }
685
686    #[test]
687    fn zero_and_missing_p_are_skipped_and_counted() {
688        let log = vec![
689            o(ASKED, "a", &["A"], 1),
690            ask("b", &["B"], &[0.0], &["B", "X"], 2),
691            Outcome { p: vec![0.5], ..o(ASKED, "c", &["C"], 3) },
692            o(GRADED, "c", &["C", "Y"], 3),
693            ask("d", &["D"], &[0.5], &["D", "Y"], 4),
694            Outcome { p: vec![1.0], channel: "queued".into(), ..o(ASKED, "e", &["E"], 4) },
695            Outcome { p: vec![0.5], channel: "early".into(), ..o(ASKED, "e", &["F"], 4) },
696            o(CHANGED, "a", &["A"], 5),
697            o(CHANGED, "b", &["B"], 5),
698            o(CHANGED, "c", &["C"], 5),
699        ];
700        let (ds, skipped) = decisions(&log);
701        assert!(ds.is_empty());
702        assert_eq!(skipped, Skipped { no_p: 1, zero_p: 1, no_pool: 1, pending: 1, graded: 1 }, "free deliveries count nowhere");
703        let r = evaluate(&log);
704        assert_eq!((r.units, r.current.value, r.asked), (0, None, 4));
705        assert!(render(&r).contains("too thin"), "{}", render(&r));
706    }
707
708    #[test]
709    fn a_placebo_unclear_or_censored_result_rewards_no_ask() {
710        let log = |result: Outcome| vec![ask("d", &["A"], &[0.5], &["A", "B"], 1), result];
711        let changed = o(CHANGED, "d", &["A"], 2);
712        let (ds, skipped) = decisions(&log(changed.clone()));
713        assert_eq!((ds.len(), skipped.pending), (1, 0), "a plain change rewards the ask");
714        let censored = Outcome { kind: outcome::NOT_CORRECTED.into(), ..changed.clone() };
715        for x in [Outcome { channel: outcome::PLACEBO.into(), ..changed.clone() }, Outcome { kind: outcome::UNCLEAR.into(), ..changed }, censored] {
716            let (ds, skipped) = decisions(&log(x.clone()));
717            assert_eq!((ds.len(), skipped.pending), (0, 1), "{x:?} leaves the ask without a result");
718        }
719    }
720
721    #[test]
722    fn snips_is_the_mean_under_equal_weights_and_ess_counts_them() {
723        let units: Vec<(f64, f64, usize)> = (0..40).map(|i| (1.0, if i % 4 == 0 { 1.0 } else { 0.0 }, i / 2)).collect();
724        let e = snips(&units, 7);
725        assert_eq!((e.value, e.ess, e.n), (Some(0.25), 40.0, 40));
726        assert!(e.lo <= 0.25 && 0.25 <= e.hi && e.lo < e.hi, "{e:?}");
727        assert_eq!(snips(&units, 7), e, "one seed, one interval");
728        assert_eq!(snips(&[], 7).value, None);
729        // Weights tilt the estimate and shrink the effective sample.
730        let e = snips(&[(3.0, 1.0, 0), (1.0, 0.0, 1)], 7);
731        assert_eq!((e.value, e.ess), (Some(0.75), 1.6));
732    }
733
734    /// The bootstrap resamples whole sessions: four sessions of ten identical rewards (two all 1, two
735    /// all 0) are four independent results, not forty, and the interval is as wide as four allow.
736    #[test]
737    fn the_bootstrap_resamples_sessions_not_units() {
738        let units: Vec<(f64, f64, usize)> = (0..40).map(|i| (1.0, if i / 10 < 2 { 1.0 } else { 0.0 }, i / 10)).collect();
739        let e = snips(&units, 7);
740        assert_eq!(e.value, Some(0.5));
741        // Four sessions: all four drawn from one side has chance 2 × (1/2)^4 = 1/8 > 5%, so the 95%
742        // interval reaches both ends. Forty independent units would give about 0.5 ± 0.16.
743        assert_eq!((e.lo, e.hi), (0.0, 1.0), "{e:?}");
744        let iid: Vec<(f64, f64, usize)> = units.iter().enumerate().map(|(i, u)| (u.0, u.1, i)).collect();
745        let narrow = snips(&iid, 7);
746        assert!(narrow.hi - narrow.lo < 0.5 * (e.hi - e.lo), "{narrow:?}");
747    }
748
749    #[test]
750    fn grid_halves_and_doubles() {
751        assert_eq!(grid(3.0), [1.5, 3.0, 6.0]);
752        let current: Vec<f64> = KNOBS.iter().map(|k| k.current).collect();
753        assert_eq!(current, [memory::VETOED_W as f64, memory::REGRET, memory::TESTED_W]);
754        // Each knob sets its own weight.
755        for k in &KNOBS {
756            let mut c = CURRENT;
757            (k.set)(&mut c, 99.0);
758            assert_ne!(c, CURRENT, "{}", k.name);
759        }
760    }
761
762    /// Four unscoped lessons over three slots (p depends on the beliefs), history `before` the check at
763    /// ms 100, and the check logged with the p the current weights give on that history: the live draw.
764    fn leak_world(before: &[Outcome]) -> Vec<Outcome> {
765        let pool = ["A", "B", "C", "D"];
766        let probe = ask("d", &["A", "B", "C"], &[1.0, 1.0, 1.0], &pool, 100);
767        let (ds, _) = decisions(&[[before, &[probe.clone(), o(CHANGED, "d", &["A"], 110)]].concat()].concat());
768        let p = replay(before, &ds[0], &CURRENT);
769        let logged = Outcome { p: ["A", "B", "C"].iter().map(|l| outcome::round_p(p[*l])).collect(), ..probe };
770        [before, &[logged, o(CHANGED, "d", &["A"], 110)]].concat()
771    }
772
773    #[test]
774    fn replay_is_leak_free() {
775        let history = [o(CHANGED, "h", &["A"], 1), o(IGNORED, "h", &["B"], 2)];
776        let base = leak_world(&history);
777        let veto = |ms: i64| [base.clone(), vec![o(VETOED, "later", &["A", "B"], ms)]].concat();
778        let (ds, _) = decisions(&base);
779        // The public replay takes the whole log and cuts it at the decision itself.
780        let at = |log: &[Outcome]| replay(log, &ds[0], &CURRENT);
781        assert_eq!(at(&veto(100)), at(&base), "a veto at the decision's ms is not seen");
782        assert_eq!(at(&veto(500)), at(&base), "nor one after it");
783        assert_ne!(at(&veto(99)), at(&base), "one before it is");
784        // `evaluate` alike: what comes at or after a decision changes no estimate.
785        let before = evaluate(&base);
786        assert_eq!((before.decisions, before.drifted, before.agree.0, before.agree.1), (1, 0, 1, 1), "{:?}", before.agree);
787        assert_eq!(evaluate(&veto(100)).rows, before.rows);
788        assert_eq!(evaluate(&veto(500)).rows, before.rows);
789        // A veto logged before the check that the live draw saw: replayed as seen.
790        let seen = leak_world(&[history.to_vec(), vec![o(VETOED, "early", &["A", "B"], 99)]].concat());
791        let r = evaluate(&seen);
792        assert_eq!((r.drifted, r.units), (0, 1));
793        assert_ne!(r.rows, before.rows, "it moved the draw");
794    }
795
796    /// A team record stamped before a check but synced after it: the live draw never saw it, the replay
797    /// would. The current weights then miss the logged p, and the decision is left out, not estimated.
798    #[test]
799    fn a_record_the_live_draw_did_not_see_drops_its_decision() {
800        let history = [o(CHANGED, "h", &["A"], 1), o(IGNORED, "h", &["B"], 2)];
801        let late = [leak_world(&history), vec![o(VETOED, "peer", &["A", "B"], 50)]].concat();
802        let r = evaluate(&late);
803        assert_eq!((r.decisions, r.drifted, r.units), (1, 1, 0), "{:?}", r.agree);
804        assert!(r.agree.0 < r.agree.1 && r.current.value.is_none());
805        assert!(render(&r).contains("left out: 1 decision(s)"), "{}", render(&r));
806    }
807
808    #[test]
809    fn replay_scales_with_the_store() {
810        // 2k decisions over 20k records: pools of two unscoped lessons (p = 1, no draw), so this times the
811        // replay's derives, not the propensity.
812        let mut log = Vec::new();
813        for i in 0..2000usize {
814            let (a, b) = (format!("L{}", i % 200), format!("L{}", (i * 7 + 1) % 200));
815            let s = format!("s{i}");
816            let ms = 10 * i as i64 + 5;
817            log.push(ask(&s, &[&a], &[1.0], &[&a, &b], ms));
818            log.push(o(if i % 3 == 0 { CHANGED } else { IGNORED }, &s, &[&a], ms + 1));
819            for k in 0..8 {
820                log.push(o(outcome::NOT_CORRECTED, &format!("x{i}"), &[&format!("L{}", (i + k) % 200)], ms + 2));
821            }
822        }
823        assert_eq!(log.len(), 20_000);
824        let started = std::time::Instant::now();
825        let r = evaluate(&log);
826        assert_eq!(r.decisions, 2000);
827        assert!(started.elapsed() < std::time::Duration::from_secs(5), "{:?}", started.elapsed());
828    }
829}