Skip to main content

borg/
pools.rs

1//! The pool's shadow log (0.7.7): what each detached check (check_async.rs) showed its model, and how
2//! other orders would have ranked the same lessons, one line per check in `pools.jsonl` beside the
3//! store's outcomes.
4//!
5//! The stop pool fills its budget in one fixed order (whole.rs `select_filled`: the shortlist, then BM25 ×
6//! weight × trust, then newest first). Whether another order finds more of what the judge applies can
7//! only be measured on lessons the model was shown, so each check logs, per eligible lesson in its fill
8//! order (`Pooled::ids`), its role (prefix, boundary draw, tail sample, not shown), its exact chance to
9//! be shown (`pi`: 1 in the prefix, pt + (1 − pt)·π_b past it, whole.rs `explore`) and the scores of the
10//! candidate orders the pre-registered switch test compares (internal evaluation "learn-retrieval", not
11//! published):
12//!
13//! - `bm25q`: today's word match (conscience.rs `raw`), before weight and trust; `w`, `trust`, `act`:
14//!   the memory weight, the authors' trust, the activation behind the weight (memory.rs).
15//! - `bm25f`: BM25 of question, situations and consequence against the whole work, idf over the store's
16//!   lessons at the stop (E3-fb).
17//! - `cue`: the best BM25 of the lesson's retrieval cues (cues.rs) against the work's tail, idf over every
18//!   cue of the store (E5); null without cues.
19//! - `mv`: BM25 of situations and cues against the design moves of the session's latest check that
20//!   finished before this stop (predict.rs `moves`; E6); null without such moves.
21//! - `q`: the lesson's online value from its earlier verdicts, Q ← Q + 0.2(r − Q), changed +1, ignored
22//!   −0.5, prior 0, placebos skipped (E2).
23//! - `cos`: the cosine of the lesson's vector to the work's tail (vectors.rs `scored`); null without
24//!   vectors.
25//! - `rrf`: reciprocal rank fusion of bm25f, cue, mv (bm25f where mv is null) and q over the rows the
26//!   model was shown (E7): Σ 1/(60 + rank), ranks 1-based, ties at their mean, a missing score last.
27//!
28//! **Logged only.** None of these reaches the pool, its order or the prompt (the shadow-purity test
29//! pins it); the order changes only when the switch test passes. Local: never synced (borg-mesh
30//! `SYNCED`), never shared, kept whole until that test reads it (its size is in `borg status`). Nothing
31//! with learning off. The miss causes in `borg status` and `borg web` fold from it (`misses`).
32
33use std::collections::{HashMap, HashSet};
34use std::io::{BufRead, Read, Seek};
35use std::path::{Path, PathBuf};
36
37use serde::{Deserialize, Serialize};
38
39use crate::conscience::{self, Challenge};
40
41/// Protocol: the version of a `pools.jsonl` line's format (`Pooled::v`).
42pub const VERSION: u32 = 1;
43
44/// The log beside a store's outcomes.
45#[must_use]
46pub fn path(home: &Path) -> PathBuf {
47    home.join("pools.jsonl")
48}
49
50/// RRF's rank offset. Method: the standard constant (Cormack, Clarke and Büttcher, "Reciprocal Rank
51/// Fusion outperforms Condorcet and individual Rank Learning Methods", 2009), as the offline scorer.
52pub const RRF_K: f64 = 60.0;
53
54/// The online value's step and rewards (`q`). Method: the offline scorer's E2, fixed before the switch
55/// test was registered: a verdict moves Q a fifth of the way; a change is worth +1, an ignored question
56/// −0.5; unclear and not arisen leave it.
57pub const Q_STEP: f64 = 0.2;
58pub const Q_CHANGED: f64 = 1.0;
59pub const Q_IGNORED: f64 = -0.5;
60
61/// `Pooled::flags` bits: in the shortlist (BM25's top `vectors::SHORTLIST` plus vector hits, computed
62/// even when the pool fits), added by the vectors, in scope for where the session works, delivered in
63/// the session already (hook.rs `had_in`), pending a verdict this check judges, in play (applied by an
64/// earlier check of the session, or delivered).
65pub const SHORTLIST: u8 = 1;
66pub const VECTOR: u8 = 2;
67pub const IN_SCOPE: u8 = 4;
68pub const DELIVERED: u8 = 8;
69pub const PENDING: u8 = 16;
70pub const IN_PLAY: u8 = 32;
71
72/// `Pooled::role` values.
73pub const NOT_SHOWN: u8 = 0;
74pub const PREFIX: u8 = 1;
75pub const BOUNDARY: u8 = 2;
76pub const TAIL: u8 = 3;
77
78/// One check's line of `pools.jsonl`: per-check fields, then columns aligned with `ids` (every eligible
79/// lesson in fill order: the prefix's order, then the lessons it dropped as the fill met them), floats
80/// at 4 decimals, null where a score does not apply.
81#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
82pub struct Pooled {
83    pub v: u32,
84    pub check: String,
85    pub session: String,
86    pub ms: i64,
87    /// The check's index among the session's checks that called the model (check_async.rs `index`).
88    pub idx: u64,
89    /// The lesson line format (whole.rs `Line`), the model and its prompt template (predict.rs
90    /// `template_hash`); whether the call failed (no applies then).
91    pub line: String,
92    pub model: String,
93    pub prompt_hash: String,
94    pub failed: bool,
95    /// The pool's budget B in tokens, the prefix's lessons, the exploration share s, τ, the tail's k of
96    /// the n the prefix left, and pt = k / n (whole.rs `Explored`).
97    pub budget: usize,
98    pub prefix: usize,
99    pub s: f64,
100    pub tau: usize,
101    pub k: usize,
102    pub n_rem: usize,
103    pub pt: f64,
104    /// The check whose moves `mv` read; None without.
105    pub moves_from: Option<String>,
106    pub ids: Vec<String>,
107    /// `id@v<version>#<key>`: which wording was shown (cues.rs `key`).
108    pub ver: Vec<String>,
109    pub role: Vec<u8>,
110    pub pb: Vec<Option<f64>>,
111    pub pi: Vec<f64>,
112    pub bm25q: Vec<f64>,
113    pub bm25f: Vec<Option<f64>>,
114    pub cos: Vec<Option<f64>>,
115    pub w: Vec<f64>,
116    pub trust: Vec<f64>,
117    pub act: Vec<f64>,
118    pub q: Vec<f64>,
119    pub cue: Vec<Option<f64>>,
120    pub mv: Vec<Option<f64>>,
121    pub rrf: Vec<Option<f64>>,
122    pub flags: Vec<u8>,
123    /// The grade the model gave each lesson it applied, 0 for the rest: the miss causes' positives.
124    pub grade: Vec<u8>,
125}
126
127/// What the pool step knows of its rows (hook.rs `pool`): the eligible lessons in fill order, their
128/// roles and chances, today's word match, and the exploration's sizes.
129#[derive(Debug, Clone)]
130pub struct Rows<'a> {
131    pub rows: Vec<&'a Challenge>,
132    pub prefix: usize,
133    pub role: Vec<u8>,
134    pub pb: Vec<Option<f64>>,
135    pub pi: Vec<f64>,
136    pub bm25q: Vec<f64>,
137    pub budget: usize,
138    pub s: f64,
139    pub tau: usize,
140    pub k: usize,
141    pub n_rem: usize,
142    pub pt: f64,
143}
144
145impl<'a> Rows<'a> {
146    /// The rows of the pool `e` filled from `challenges`, whose word matches are `raw` (conscience.rs
147    /// `raw`, aligned with `challenges`), within `budget` tokens.
148    #[must_use]
149    pub fn of(e: &crate::whole::Explored<'a>, challenges: &[Challenge], raw: &[f64], budget: usize) -> Rows<'a> {
150        let prefix = e.filled.order.len();
151        let rows: Vec<&'a Challenge> = e.filled.order.iter().chain(&e.filled.dropped).copied().collect();
152        let mut role = vec![NOT_SHOWN; rows.len()];
153        role[..prefix].fill(PREFIX);
154        for (at, _) in &e.boundary {
155            role[*at] = BOUNDARY;
156        }
157        for (at, _) in &e.tail {
158            role[*at] = TAIL;
159        }
160        let (pb_r, pi_r) = e.chances();
161        let pb = std::iter::repeat_n(None, prefix).chain(pb_r).collect();
162        let pi = std::iter::repeat_n(1.0, prefix).chain(pi_r).collect();
163        let by: HashMap<&str, f64> = challenges.iter().zip(raw).map(|(c, r)| (c.id.as_str(), *r)).collect();
164        let bm25q = rows.iter().map(|c| by.get(c.id.as_str()).copied().unwrap_or(0.0)).collect();
165        Rows { rows, prefix, role, pb, pi, bm25q, budget, s: e.s, tau: e.tau, k: e.k, n_rem: e.n, pt: e.pt }
166    }
167}
168
169/// What the check adds after its call (hook.rs `settle`).
170pub struct Check<'c> {
171    pub session: &'c str,
172    pub check: &'c str,
173    pub idx: u64,
174    /// When its stop ran: the store, the verdicts and the moves it reads are those before it.
175    pub stop_ms: i64,
176    pub line: &'c str,
177    pub model: &'c str,
178    pub prompt_hash: &'c str,
179    pub failed: bool,
180    /// The applies with their grades; the lessons whose verdict it judged.
181    pub applied: &'c [(&'c Challenge, u8)],
182    pub pending: &'c [String],
183    /// The work the pool read (conscience.rs `work_text`), where the session works, the lessons the
184    /// check could ask.
185    pub text: &'c str,
186    pub scope_text: &'c str,
187    pub challenges: &'c [Challenge],
188    /// The lessons delivered in the session, and those in play (delivered or applied by an earlier check).
189    pub delivered: &'c HashSet<&'c str>,
190    pub in_play: &'c HashSet<&'c str>,
191    /// The store's lessons file, and the outcomes the verdicts and the moves are read from.
192    pub lessons: &'c Path,
193    pub outcomes: &'c Path,
194}
195
196/// Appends the check's line (`pooled`) to `pools.jsonl` in `home`. Nothing with learning off; fails
197/// quietly (a log, never in the way of the check).
198pub fn log(home: &Path, rows: &Rows, c: &Check) {
199    if !crate::learn::learning() {
200        return;
201    }
202    if let Ok(line) = serde_json::to_string(&pooled(rows, c)) {
203        let _ = crate::learn::append_line(&path(home), &line);
204    }
205}
206
207/// Rounded to 4 decimals, as every float of the log (never `-0`).
208fn r4(x: f64) -> f64 {
209    (x * 1e4).round() / 1e4 + 0.0
210}
211
212/// The check's line: `rows` scored as the module doc says.
213#[must_use]
214pub fn pooled(rows: &Rows, c: &Check) -> Pooled {
215    // The store as the session's lessons came from it: the team's and this engineer's own (hook.rs
216    // `loaded`), and the cues written before the stop.
217    let corpus = conscience::load_store_unweighted(c.lessons, Some(c.stop_ms));
218    let cues = crate::cues::load_before(c.lessons, Some(c.stop_ms));
219    let cues_of = |l: &Challenge| cues.get(&crate::cues::key(&l.question, &l.situations, &l.consequence));
220    let (q, moves) = read_outcomes(c.outcomes, c.session, c.stop_ms, &rows.rows.iter().map(|l| l.id.as_str()).collect());
221    let at: HashMap<&str, usize> = corpus.iter().enumerate().map(|(i, l)| (l.id.as_str(), i)).collect();
222    let in_corpus = |scores: &[f64]| -> Vec<Option<f64>> { rows.rows.iter().map(|l| at.get(l.id.as_str()).map(|&i| scores[i])).collect() };
223    // E3-fb: one document per lesson of the store.
224    let fb_docs: Vec<Vec<String>> =
225        corpus.iter().map(|l| conscience::tokens(&format!("{}\n{}\n{}", l.question, l.situations.join("\n"), l.consequence))).collect();
226    let bm25f = in_corpus(&conscience::bm25(&fb_docs, c.text));
227    // E5: each cue its own document; a lesson scores its best cue.
228    let tail = crate::vectors::query_tail(c.text);
229    let (mut cue_docs, mut owner) = (Vec::new(), Vec::new());
230    for (i, l) in corpus.iter().enumerate() {
231        for cue in cues_of(l).into_iter().flatten() {
232            cue_docs.push(conscience::tokens(cue));
233            owner.push(i);
234        }
235    }
236    let mut best: Vec<Option<f64>> = vec![None; corpus.len()];
237    for (s, i) in conscience::bm25(&cue_docs, &tail).into_iter().zip(owner) {
238        best[i] = Some(best[i].map_or(s, |b: f64| b.max(s)));
239    }
240    let cue: Vec<Option<f64>> = rows.rows.iter().map(|l| at.get(l.id.as_str()).and_then(|&i| best[i])).collect();
241    // E6: situations and cues against the latest earlier check's moves.
242    let mv: Vec<Option<f64>> = match &moves {
243        Some((_, said)) => {
244            let docs: Vec<Vec<String>> = corpus
245                .iter()
246                .map(|l| conscience::tokens(&l.situations.iter().chain(cues_of(l).into_iter().flatten()).cloned().collect::<Vec<_>>().join("\n")))
247                .collect();
248            in_corpus(&conscience::bm25(&docs, &said.join("\n")))
249        }
250        None => vec![None; rows.rows.len()],
251    };
252    let qs: Vec<f64> = rows.rows.iter().map(|l| q.get(l.id.as_str()).copied().unwrap_or(0.0)).collect();
253    // Vectors: every lesson the index holds, so each eligible row has its cosine.
254    let n = corpus.len() + c.challenges.len();
255    let hits = crate::vectors::scored(c.lessons, &tail, n);
256    let cos: Vec<Option<f64>> = match &hits {
257        Some(h) => {
258            let by: HashMap<&str, f32> = h.iter().map(|(id, s)| (id.as_str(), *s)).collect();
259            rows.rows.iter().map(|l| by.get(l.id.as_str()).map(|s| f64::from(*s))).collect()
260        }
261        None => vec![None; rows.rows.len()],
262    };
263    // The shortlist as the fill would take it (vectors.rs `shortlist`), from the same hits.
264    let ranked: Vec<String> =
265        hits.iter().flatten().take(crate::vectors::k() + crate::vectors::SHORTLIST + crate::vectors::OVER_FETCH).map(|(id, _)| id.clone()).collect();
266    let (list, added) = crate::vectors::shortlist_from(c.challenges, c.text, c.scope_text, crate::vectors::SHORTLIST, &ranked);
267    let listed: HashSet<&str> = list.iter().map(|l| l.id.as_str()).collect();
268    let vector: HashSet<&str> = list[list.len() - added..].iter().map(|l| l.id.as_str()).collect();
269    let have = conscience::Names::of(c.scope_text);
270    let pending: HashSet<&str> = c.pending.iter().map(String::as_str).collect();
271    let flags = rows
272        .rows
273        .iter()
274        .map(|l| {
275            let id = l.id.as_str();
276            [
277                (listed.contains(id), SHORTLIST),
278                (vector.contains(id), VECTOR),
279                (conscience::in_scope(l, &have), IN_SCOPE),
280                (c.delivered.contains(id), DELIVERED),
281                (pending.contains(id), PENDING),
282                (c.in_play.contains(id), IN_PLAY),
283            ]
284            .iter()
285            .filter(|(on, _)| *on)
286            .fold(0, |f, (_, bit)| f | bit)
287        })
288        .collect();
289    // E7 over the rows the model was shown.
290    let shown: Vec<usize> = (0..rows.rows.len()).filter(|&i| rows.role[i] != NOT_SHOWN).collect();
291    let pick = |v: &[Option<f64>]| shown.iter().map(|&i| v[i]).collect::<Vec<_>>();
292    let mv_or_fb: Vec<Option<f64>> = if moves.is_some() { mv.clone() } else { bm25f.clone() };
293    let lists = [pick(&bm25f), pick(&cue), pick(&mv_or_fb), shown.iter().map(|&i| Some(qs[i])).collect()];
294    let mut rrf = vec![None; rows.rows.len()];
295    let fused = lists.iter().map(|l| ranks(l)).fold(vec![0.0; shown.len()], |acc, r| acc.iter().zip(&r).map(|(a, r)| a + 1.0 / (RRF_K + r)).collect());
296    for (&i, f) in shown.iter().zip(fused) {
297        rrf[i] = Some(r4(f));
298    }
299    let grade = rows.rows.iter().map(|l| c.applied.iter().find(|(a, _)| a.id == l.id).map_or(0, |(_, g)| *g)).collect();
300    let opt = |v: Vec<Option<f64>>| v.into_iter().map(|x| x.map(r4)).collect::<Vec<_>>();
301    Pooled {
302        v: VERSION,
303        check: c.check.into(),
304        session: c.session.into(),
305        ms: crate::learn::now_ms(),
306        idx: c.idx,
307        line: c.line.into(),
308        model: c.model.into(),
309        prompt_hash: c.prompt_hash.into(),
310        failed: c.failed,
311        budget: rows.budget,
312        prefix: rows.prefix,
313        s: rows.s,
314        tau: rows.tau,
315        k: rows.k,
316        n_rem: rows.n_rem,
317        pt: rows.pt,
318        moves_from: moves.map(|(check, _)| check),
319        ids: rows.rows.iter().map(|l| l.id.clone()).collect(),
320        ver: rows.rows.iter().map(|l| format!("{}@v{}#{}", l.id, l.version, crate::cues::key(&l.question, &l.situations, &l.consequence))).collect(),
321        role: rows.role.clone(),
322        pb: opt(rows.pb.clone()),
323        pi: rows.pi.iter().map(|x| r4(*x)).collect(),
324        bm25q: rows.bm25q.iter().map(|x| r4(*x)).collect(),
325        bm25f: opt(bm25f),
326        cos: opt(cos),
327        w: rows.rows.iter().map(|l| r4(l.weight)).collect(),
328        trust: rows.rows.iter().map(|l| r4(l.trust)).collect(),
329        act: rows.rows.iter().map(|l| r4(l.activation)).collect(),
330        q: qs.into_iter().map(r4).collect(),
331        cue: opt(cue),
332        mv: opt(mv),
333        rrf,
334        flags,
335        grade,
336    }
337}
338
339/// 1-based ranks of `v`, best (largest) first, ties at their mean rank, a missing value below every
340/// present one (tied with the other missing ones), as the offline scorer ranks.
341#[must_use]
342pub fn ranks(v: &[Option<f64>]) -> Vec<f64> {
343    let key = |x: &Option<f64>| x.unwrap_or(f64::NEG_INFINITY);
344    let mut sorted: Vec<f64> = v.iter().map(key).collect();
345    sorted.sort_by(|a, b| b.total_cmp(a));
346    v.iter()
347        .map(|x| {
348            let x = key(x);
349            let lo = sorted.partition_point(|s| *s > x);
350            let hi = sorted.partition_point(|s| *s >= x);
351            (lo + 1 + hi) as f64 / 2.0
352        })
353        .collect()
354}
355
356/// A `graded` record's check and its design moves (predict.rs `moves`).
357type Moves = (String, Vec<String>);
358
359/// From the outcomes at `log`, read line by line: each of `rows`' Q before `stop_ms` (strictly, in time
360/// order: changed +1, ignored −0.5, placebos skipped), and the moves of `session`'s latest `graded`
361/// record with moves that finished before `stop_ms`, with its check.
362fn read_outcomes(log: &Path, session: &str, stop_ms: i64, rows: &HashSet<&str>) -> (HashMap<String, f64>, Option<Moves>) {
363    #[derive(Deserialize)]
364    struct Line {
365        kind: String,
366        #[serde(default)]
367        session: String,
368        #[serde(default)]
369        ms: i64,
370        #[serde(default)]
371        lessons: Vec<String>,
372        #[serde(default)]
373        channel: String,
374        #[serde(default)]
375        check: String,
376        #[serde(default)]
377        moves: Vec<String>,
378    }
379    let mut verdicts: Vec<(i64, f64, Vec<String>)> = Vec::new();
380    let mut moves: Option<(i64, String, Vec<String>)> = None;
381    if let Ok(f) = std::fs::File::open(log) {
382        for line in std::io::BufReader::new(f).lines().map_while(Result::ok) {
383            let verdict = line.contains(crate::outcome::CHANGED) || line.contains(crate::outcome::IGNORED);
384            if !verdict && !line.contains("\"moves\"") {
385                continue;
386            }
387            let Ok(o) = serde_json::from_str::<Line>(&line) else { continue };
388            if o.ms >= stop_ms {
389                continue;
390            }
391            let r = match o.kind.as_str() {
392                crate::outcome::CHANGED => Q_CHANGED,
393                crate::outcome::IGNORED => Q_IGNORED,
394                crate::outcome::GRADED if o.session == session && !o.moves.is_empty() => {
395                    if moves.as_ref().is_none_or(|(ms, _, _)| o.ms >= *ms) {
396                        moves = Some((o.ms, o.check, o.moves));
397                    }
398                    continue;
399                }
400                _ => continue,
401            };
402            if o.channel == crate::outcome::PLACEBO {
403                continue;
404            }
405            let mine: Vec<String> = o.lessons.into_iter().filter(|l| rows.contains(l.as_str())).collect();
406            if !mine.is_empty() {
407                verdicts.push((o.ms, r, mine));
408            }
409        }
410    }
411    verdicts.sort_by_key(|(ms, _, _)| *ms);
412    let mut q: HashMap<String, f64> = HashMap::new();
413    for (_, r, ls) in verdicts {
414        for l in ls {
415            let v = q.entry(l).or_insert(0.0);
416            *v += Q_STEP * (r - *v);
417        }
418    }
419    (q, moves.map(|(_, check, said)| (check, said)))
420}
421
422/// The miss causes (`borg status`, `borg web`): of the first applies at grade ≥ 2 the checks logged
423/// (not pending, not in play), those the fill order ranked low, at a fill position past half the
424/// prefix or shown only by a draw (boundary or tail), each with its mechanical cause (`cause`).
425#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
426pub struct Misses {
427    /// The bytes of `pools.jsonl` folded (the cache's offset) and its size.
428    pub at: u64,
429    /// The checks folded, the positives among their rows, and the low ones (`causes` counts them).
430    pub checks: u64,
431    pub positives: u64,
432    pub low: u64,
433    /// Per cause (`CAUSES`), the low positives it explains.
434    pub causes: Vec<(String, u64)>,
435    /// Of the low positives, those shown by a draw past the prefix.
436    pub drawn: u64,
437}
438
439/// The mechanical causes, in the order `cause` tries them: out of scope for where the session works;
440/// no word of the work matched it but the vectors found it; no word matched; its words matched in the
441/// pool's top half but weight and trust put it in the bottom half (demoted); its words matched less
442/// than half the pool's (weak words); otherwise (the shortlist, ties, newest first).
443pub const CAUSES: [&str; 6] = ["scope", "vector_only", "no_word_match", "demoted", "weak_words", "other"];
444
445/// The cache of `misses` beside the log.
446fn cache(home: &Path) -> PathBuf {
447    home.join("misses.json")
448}
449
450/// The miss causes of the store `home` (`Misses`): the cache, then the log's lines past its offset folded
451/// in and the cache saved again; a log shorter than the cache's offset (replaced) is folded anew.
452#[must_use]
453pub fn misses(home: &Path) -> Misses {
454    let log = path(home);
455    let len = std::fs::metadata(&log).map(|m| m.len()).unwrap_or(0);
456    let mut m: Misses = std::fs::read_to_string(cache(home)).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default();
457    if m.at > len || m.causes.len() != CAUSES.len() {
458        m = Misses { causes: CAUSES.iter().map(|c| (c.to_string(), 0)).collect(), ..Misses::default() };
459    }
460    if m.at == len {
461        return m;
462    }
463    let Ok(mut f) = std::fs::File::open(&log) else { return m };
464    if f.seek(std::io::SeekFrom::Start(m.at)).is_err() {
465        return m;
466    }
467    let mut r = std::io::BufReader::new(f.by_ref());
468    let mut buf = String::new();
469    while r.read_line(&mut buf).is_ok_and(|n| n > 0) {
470        // A line still being written is folded at the next read.
471        if !buf.ends_with('\n') {
472            break;
473        }
474        m.at += buf.len() as u64;
475        if let Ok(p) = serde_json::from_str::<Pooled>(&buf) {
476            fold(&mut m, &p);
477        }
478        buf.clear();
479    }
480    if let Ok(text) = serde_json::to_string(&m) {
481        let tmp = cache(home).with_extension(format!("json.{}", std::process::id()));
482        if std::fs::write(&tmp, text).and_then(|()| std::fs::rename(&tmp, cache(home))).is_err() {
483            let _ = std::fs::remove_file(&tmp);
484        }
485    }
486    m
487}
488
489/// One check's line folded into `m`.
490fn fold(m: &mut Misses, p: &Pooled) {
491    m.checks += 1;
492    for i in 0..p.ids.len() {
493        let (g, f) = (p.grade.get(i).copied().unwrap_or(0), p.flags.get(i).copied().unwrap_or(0));
494        if g < 2 || f & (PENDING | IN_PLAY) != 0 {
495            continue;
496        }
497        m.positives += 1;
498        let role = p.role.get(i).copied().unwrap_or(NOT_SHOWN);
499        if !(role == BOUNDARY || role == TAIL || (role == PREFIX && 2 * i >= p.prefix)) {
500            continue;
501        }
502        m.low += 1;
503        m.drawn += u64::from(role != PREFIX);
504        let why = cause(p, i);
505        if let Some(c) = m.causes.iter_mut().find(|(c, _)| c == why) {
506            c.1 += 1;
507        }
508    }
509}
510
511/// Why row `i` of `p` sat low in the fill order (`CAUSES`), from its logged scores; the rank shares are
512/// within the check's rows, 1-based ranks over their number, ties at their mean (the offline miss report).
513#[must_use]
514pub fn cause(p: &Pooled, i: usize) -> &'static str {
515    let f = p.flags.get(i).copied().unwrap_or(0);
516    let bm25 = p.bm25q.get(i).copied().unwrap_or(0.0);
517    if f & IN_SCOPE == 0 {
518        return "scope";
519    }
520    if bm25 <= 0.0 {
521        return if f & VECTOR != 0 { "vector_only" } else { "no_word_match" };
522    }
523    // Every column read by row, missing cells as 0 (a line shorter in one column never panics a reader).
524    let n = p.ids.len().max(i + 1);
525    let share = |v: Vec<Option<f64>>| ranks(&v)[i] / n as f64;
526    let q = |j: usize| p.bm25q.get(j).copied().unwrap_or(0.0);
527    let words = share((0..n).map(|j| Some(q(j))).collect());
528    let score = share((0..n).map(|j| Some(q(j) * p.w.get(j).unwrap_or(&1.0) * p.trust.get(j).unwrap_or(&0.5) / crate::trust::NEUTRAL)).collect());
529    if words <= 0.5 && score > 0.5 {
530        "demoted"
531    } else if words > 0.5 {
532        "weak_words"
533    } else {
534        "other"
535    }
536}
537
538#[cfg(test)]
539mod tests {
540    use super::*;
541
542    /// A line whose columns are shorter than its ids (an edited file, a later format) gives a cause, never
543    /// a panic in `borg status` or the web's selection view.
544    #[test]
545    fn a_short_column_never_panics_the_cause() {
546        let p = Pooled { ids: vec!["A".into(), "B".into(), "C".into()], bm25q: vec![1.0], flags: vec![IN_SCOPE; 3], ..Default::default() };
547        assert_eq!(cause(&p, 2), "no_word_match");
548        let p = Pooled { bm25q: vec![1.0, 2.0], ..p };
549        assert!(CAUSES.contains(&cause(&p, 1)));
550    }
551
552    #[test]
553    fn ranks_are_one_based_descending_with_mean_ties_and_missing_last() {
554        assert_eq!(ranks(&[Some(3.0), Some(1.0), Some(3.0), None, Some(2.0), None]), [1.5, 4.0, 1.5, 5.5, 3.0, 5.5]);
555        assert!(ranks(&[]).is_empty());
556    }
557
558    /// Q from the verdicts before the stop, in time order, placebos and other lessons skipped; the
559    /// moves of the session's latest check that finished before it.
560    #[test]
561    fn q_and_moves_are_read_as_of_the_stop() {
562        let d = std::env::temp_dir().join(format!("borg-pools-outcomes-{}", std::process::id()));
563        let _ = std::fs::remove_dir_all(&d);
564        std::fs::create_dir_all(&d).unwrap();
565        let log = d.join("outcomes.jsonl");
566        let lines = [
567            r#"{"kind":"asked_ignored","session":"x","ms":30,"lessons":["A"]}"#,
568            r#"{"kind":"asked_then_changed","session":"x","ms":10,"lessons":["A","Z"]}"#,
569            r#"{"kind":"asked_then_changed","session":"x","ms":20,"lessons":["A"],"channel":"placebo"}"#,
570            r#"{"kind":"asked_then_changed","session":"x","ms":99,"lessons":["A"]}"#,
571            r#"{"kind":"graded","session":"s","ms":40,"check":"c2","moves":["later"]}"#,
572            r#"{"kind":"graded","session":"s","ms":35,"check":"c1","moves":["earlier"]}"#,
573            r#"{"kind":"graded","session":"t","ms":45,"check":"c3","moves":["another session"]}"#,
574            r#"{"kind":"graded","session":"s","ms":60,"check":"c4","moves":["after the stop"]}"#,
575        ];
576        for l in lines {
577            crate::learn::append_line(&log, l).unwrap();
578        }
579        let rows: HashSet<&str> = ["A", "B"].into();
580        let (q, moves) = read_outcomes(&log, "s", 50, &rows);
581        // A: changed at 10 (0 → 0.2), ignored at 30 (0.2 + 0.2·(−0.5 − 0.2) = 0.06); the placebo and the
582        // verdict after the stop count for nothing.
583        assert!((q["A"] - 0.06).abs() < 1e-12, "{q:?}");
584        assert!(!q.contains_key("B") && !q.contains_key("Z"));
585        assert_eq!(moves, Some(("c2".to_string(), vec!["later".to_string()])));
586        assert_eq!(read_outcomes(&log, "s", 36, &rows).1.map(|m| m.0), Some("c1".into()));
587        assert_eq!(read_outcomes(&log, "s", 35, &rows).1, None, "strictly before the stop");
588        let _ = std::fs::remove_dir_all(&d);
589    }
590
591    fn row(p: &mut Pooled, id: &str, role: u8, bm25: f64, w: f64, flags: u8, grade: u8) {
592        p.ids.push(id.into());
593        p.role.push(role);
594        p.bm25q.push(bm25);
595        p.w.push(w);
596        p.trust.push(0.5);
597        p.flags.push(flags);
598        p.grade.push(grade);
599    }
600
601    /// A check's low first applies get their causes; pending and in-play applies and those high in
602    /// the fill are no misses; the fold resumes at its offset.
603    #[test]
604    fn misses_fold_the_low_first_applies_by_cause() {
605        let mut p = Pooled { prefix: 6, ..Pooled::default() };
606        row(&mut p, "top", PREFIX, 9.0, 1.0, IN_SCOPE, 3); // high in the fill: no miss
607        row(&mut p, "a", PREFIX, 8.0, 1.0, IN_SCOPE | PENDING, 3); // pending
608        row(&mut p, "b", PREFIX, 7.0, 1.0, IN_SCOPE | IN_PLAY, 2); // in play
609        row(&mut p, "dem", PREFIX, 6.0, 0.01, IN_SCOPE, 2); // words in the top half, weight sinks it
610        row(&mut p, "x", PREFIX, 5.0, 1.0, IN_SCOPE, 0);
611        row(&mut p, "vec", PREFIX, 0.0, 1.0, IN_SCOPE | VECTOR, 2);
612        row(&mut p, "weak", TAIL, 0.5, 1.0, IN_SCOPE, 2);
613        row(&mut p, "none", BOUNDARY, 0.0, 1.0, IN_SCOPE, 3);
614        row(&mut p, "low1", NOT_SHOWN, 0.1, 1.0, IN_SCOPE, 0);
615        let home = std::env::temp_dir().join(format!("borg-pools-misses-{}", std::process::id()));
616        let _ = std::fs::remove_dir_all(&home);
617        std::fs::create_dir_all(&home).unwrap();
618        let line = serde_json::to_string(&p).unwrap();
619        crate::learn::append_line(&path(&home), &line).unwrap();
620        let m = misses(&home);
621        let by = |c: &str| m.causes.iter().find(|(x, _)| x == c).unwrap().1;
622        assert_eq!((m.checks, m.positives, m.low, m.drawn), (1, 5, 4, 2), "{m:?}");
623        assert_eq!((by("demoted"), by("vector_only"), by("weak_words"), by("no_word_match"), by("other"), by("scope")), (1, 1, 1, 1, 0, 0));
624        // The next read folds only what was appended.
625        crate::learn::append_line(&path(&home), &line).unwrap();
626        let m2 = misses(&home);
627        assert_eq!((m2.checks, m2.low, m2.at), (2, 8, std::fs::metadata(path(&home)).unwrap().len()));
628        let _ = std::fs::remove_dir_all(&home);
629    }
630}