Skip to main content

borg/
facts.rs

1//! The belief facts: what memory.rs `derive` and trust.rs `Trust::compute` read from a store's outcome
2//! history, folded incrementally and kept beside the logs, so a hook reads only what was appended since
3//! the last one instead of the whole history (conscience.rs `load_as`, every hook's lessons).
4//!
5//! **The fold.** Everything those two read is a max, a min or a union per key (memory.rs `Folded`,
6//! outcome.rs `sessions`), so folding a prefix of a log and then the rest equals folding all of it, in
7//! any order and with lines repeated. The facts keep the two logs apart: this machine's
8//! `outcomes.jsonl` (`local`, raw strings, each distinct session and repo `tag`ged once) and the team's
9//! `outcomes-team.jsonl` (`team`, by session tag). What outcome.rs `team_view` does to them is applied
10//! when they are read (`Facts::view`), so the facts serve the view with sharing on and off: this
11//! machine's own published copies dropped (`mine`), a peer's `tested` line counted only for lessons its
12//! session was given, a regret's session the first line's (this machine's log first, then by offset).
13//! Lines parse exactly as outcome.rs `read` and `team_parts` parse them. `team_parts` keeps the first of
14//! duplicate team lines; the fold keeps their union, which is the same for the lines `publish` writes
15//! (deterministic per record) and needs no dedup set.
16//!
17//! **Regrets.** Most lines of a long history are regrets (12,577 of 17,293 on one store), each its own
18//! (lesson, cause) key, while `derive` needs only their sum per (lesson, deciding session) (memory.rs
19//! `blame`: 76 pairs there). So the facts keep both: the sums per view (`Facts::blame`, in the snapshot,
20//! all a hook reads) and the regrets behind them (`Regrets`, by lesson and a fingerprint of the cause as
21//! each view names it, in `REGRETS_FILE`), read only by a fold that meets a regret line or a team session
22//! that turned out to be this machine's, which then sums them again (`settle`).
23//!
24//! **The snapshot.** `<store>/beliefs.bin` (`FILE`): a fixed little-endian layout (magic, the fold's
25//! `rules`, the regrets' generation, string table, tags, the two parts, the sums), ending in the blake3
26//! of the body; `<store>/beliefs-regrets.bin` (`REGRETS_FILE`) likewise, carrying the generation it was
27//! written as. Each part carries its log's checkpoint: device, inode, the offset folded up to (always a
28//! line end), and the blake3 of the log's first 4 KiB and of the 4 KiB before the offset. A hook
29//! verifies it, folds the complete lines after it and saves the snapshot again when it moved. A mismatch
30//! (the log replaced by a rename, as borg-mesh's quarantine does, rewritten in place, or shorter) folds
31//! that log again from the start; a rewrite of this machine's log folds the team's again too, for `mine`
32//! may have shrunk. An unterminated last line counts for the hook that reads it, as `read` counts it, and
33//! is never saved. An edit in the middle of a log that leaves both windows intact goes unnoticed: nothing
34//! in borg writes one, and `borg learn`, which reads the logs whole anyway, folds them afresh
35//! (`rebuild`). Lessons are never part of the fold (they and `BORG_BEFORE` apply at read), so a lessons
36//! edit changes nothing here.
37//!
38//! **Versions.** What a line leaves in the facts depends on this binary's rules (outcome.rs
39//! `SHARED_KINDS`, `is_result`, `tag`; memory.rs `ticks`, `result_of`, `is_correction_of`), and a
40//! checkpoint of an append-only log still matches after an upgrade. So both files carry `rules`, the
41//! hash of borg's version: a snapshot of another version fails `decode` and is folded afresh, once.
42//! Every change to those rules ships with a new version.
43//!
44//! **Concurrency.** Hooks run as many short processes at once. None takes a lock: each writes a
45//! `.<pid>.tmp` file beside the snapshot and renames it over it, the last writer winning, the regrets
46//! file first.
47//! Every saved snapshot is the fold of a prefix of each log, so an older one only costs the next hook a
48//! longer tail. One whose regrets file was since replaced by another generation (two hooks folded at
49//! once) is void as soon as a fold needs its regrets: that hook folds afresh. A file that is truncated,
50//! corrupt or of another version fails to decode and is rebuilt. They are read whole, never mapped (a
51//! mapped file truncated under a reader faults).
52//!
53//! **Place.** Local to the store, like the selection cache (select/summary.rs): borg-mesh syncs an
54//! allowlist (lines.rs `SYNCED`) that names neither.
55
56use std::collections::{HashMap, HashSet};
57use std::hash::{BuildHasherDefault, Hasher};
58use std::io::{Read, Seek, SeekFrom};
59use std::os::unix::fs::MetadataExt;
60use std::path::{Path, PathBuf};
61
62use crate::memory::{self, Folded};
63use crate::outcome::{self, Outcome, Shared};
64
65/// The snapshot beside the logs (module doc).
66pub const FILE: &str = "beliefs.bin";
67/// The regrets behind the snapshot's sums (module doc).
68pub const REGRETS_FILE: &str = "beliefs-regrets.bin";
69const MAGIC: &[u8; 8] = b"BORGFCT2";
70const REGRETS_MAGIC: &[u8; 8] = b"BORGRGT2";
71/// The bytes hashed at the start of a log and before its checkpoint.
72const WINDOW: u64 = 4096;
73
74/// An interned string.
75type Id = u32;
76/// A cause as a view names it: the first 16 bytes of its blake3 (a regret's cause is only compared).
77type Fp = u128;
78
79fn fp(s: &str) -> Fp {
80    u128::from_le_bytes(blake3::hash(s.as_bytes()).as_bytes()[..16].try_into().expect("16 bytes"))
81}
82
83/// The fold's rules (module doc "Versions"): the hash of borg's version.
84fn rules() -> [u8; 32] {
85    *blake3::hash(concat!("borg-facts\0", env!("CARGO_PKG_VERSION")).as_bytes()).as_bytes()
86}
87
88/// The fold's hash: rustc's FxHash (a rotate, a xor and a multiply per word), for keys that are ids
89/// assigned in fold order, tuples of them and blake3 fingerprints. Never for a string of a log: a peer's
90/// lines (borg-mesh syncs `outcomes-team.jsonl`) could choose strings that collide under FxHash, so every
91/// map keyed by strings (`Facts::index`, memory.rs `Folded`, `View::trust`) keeps std's keyed SipHash.
92#[derive(Default, Clone, Copy)]
93pub struct Fx(u64);
94
95impl Fx {
96    fn add(&mut self, word: u64) {
97        self.0 = (self.0.rotate_left(5) ^ word).wrapping_mul(0x51_7c_c1_b7_27_22_0a_95);
98    }
99}
100
101impl Hasher for Fx {
102    fn write(&mut self, bytes: &[u8]) {
103        let (words, rest) = bytes.as_chunks::<8>();
104        for w in words {
105            self.add(u64::from_le_bytes(*w));
106        }
107        if !rest.is_empty() {
108            let mut w = [0u8; 8];
109            w[..rest.len()].copy_from_slice(rest);
110            self.add(u64::from_le_bytes(w) ^ (rest.len() as u64) << 56);
111        }
112    }
113    fn write_u8(&mut self, x: u8) {
114        self.add(u64::from(x));
115    }
116    fn write_u32(&mut self, x: u32) {
117        self.add(u64::from(x));
118    }
119    fn write_u64(&mut self, x: u64) {
120        self.add(x);
121    }
122    fn write_u128(&mut self, x: u128) {
123        self.add(x as u64);
124        self.add((x >> 64) as u64);
125    }
126    fn write_usize(&mut self, x: usize) {
127        self.add(x as u64);
128    }
129    fn finish(&self) -> u64 {
130        self.0
131    }
132}
133
134/// A map and a set under `Fx`.
135pub type FxMap<K, V> = HashMap<K, V, BuildHasherDefault<Fx>>;
136pub type FxSet<K> = HashSet<K, BuildHasherDefault<Fx>>;
137
138/// One session of a log.
139#[derive(Debug, Default, Clone, PartialEq)]
140struct Sess {
141    /// The first ms of a line that ticks the activity clock (memory.rs `ticks`), None for none.
142    tick: Option<i64>,
143    /// The non-empty repos of its ticking lines.
144    repos: Vec<Id>,
145    /// The ms of its corrections (memory.rs `is_correction_of`), distinct.
146    corrections: Vec<i64>,
147}
148
149/// One (lesson, session) of a log.
150#[derive(Debug, Clone, Copy, PartialEq)]
151struct Cell {
152    /// The largest memory.rs `result_of`, -1 for none.
153    r: i8,
154    /// A change seen (whatever a later correction made `r`).
155    used: bool,
156    /// A green run of this machine's (a team line's waits for the gate, `Part::tested`).
157    tested: bool,
158    /// outcome.rs `sessions`: 0 none, 1 not corrected, 2 corrected; and the earliest ms of those.
159    trust: u8,
160    trust_ms: i64,
161}
162
163impl Default for Cell {
164    fn default() -> Self {
165        Cell { r: -1, used: false, tested: false, trust: 0, trust_ms: 0 }
166    }
167}
168
169/// How far a log is folded, and what it looked like there (module doc).
170#[derive(Debug, Default, Clone, PartialEq)]
171struct Check {
172    dev: u64,
173    ino: u64,
174    at: u64,
175    head: [u8; 32],
176    tail: [u8; 32],
177}
178
179/// The fold of one log, but its regrets (`Regrets`).
180#[derive(Debug, Default, Clone, PartialEq)]
181struct Part {
182    check: Check,
183    sessions: FxMap<Id, Sess>,
184    cells: FxMap<(Id, Id), Cell>,
185    /// (lesson, session, repo) of each change.
186    use_repos: FxSet<(Id, Id, Id)>,
187    /// The team's only: each `tested` line (session, lessons), gated when read; and the (session,
188    /// lesson) pairs its other lines name (outcome.rs `team_parts`).
189    tested: FxSet<(Id, Vec<Id>)>,
190    given: FxSet<(Id, Id)>,
191}
192
193impl Part {
194    fn cell(&mut self, l: Id, s: Id) -> &mut Cell {
195        self.cells.entry((l, s)).or_default()
196    }
197}
198
199/// A regret as a view merges its lines: the largest credit, the offset of the first line, its session.
200type Merged = (f64, u64, Id);
201/// `Merged` across both logs: the place of the first line is (from the team's log, offset).
202type Placed = (f64, (bool, u64), Id);
203
204/// The regrets of both logs (module doc "Regrets").
205#[derive(Debug, Default, Clone, PartialEq)]
206struct Regrets {
207    /// This machine's, by (lesson, cause) as the view off names the cause (raw) and as the view on
208    /// does (its `tag`).
209    off: FxMap<(Id, Fp), Merged>,
210    on: FxMap<(Id, Fp), Merged>,
211    /// The team's by (lesson, cause, session): the largest credit and the offset of the first line.
212    /// Merged when summed, without the sessions that turned out to be this machine's (`mine` grows).
213    team: FxMap<(Id, Fp, Id), (f64, u64)>,
214}
215
216impl Regrets {
217    fn is_empty(&self) -> bool {
218        self.off.is_empty() && self.on.is_empty() && self.team.is_empty()
219    }
220}
221
222/// Merges a line of a regret into `m` (memory.rs `Folded::of`: the largest credit, the first session).
223fn merge(m: &mut FxMap<(Id, Fp), Merged>, key: (Id, Fp), credit: f64, off: u64, s: Id) {
224    let e = m.entry(key).or_insert((0.0, off, s));
225    e.0 = e.0.max(credit);
226    if off < e.1 {
227        (e.1, e.2) = (off, s);
228    }
229}
230
231/// The folded logs of one store (module doc).
232#[derive(Debug, Default, Clone)]
233pub struct Facts {
234    strings: Vec<String>,
235    /// Each string's id, built when a fold first needs one (a hook that folds nothing hashes no string).
236    index: HashMap<String, Id>,
237    /// Each raw session and repo of this machine's log to its outcome.rs `tag` ("" stays "").
238    tags: FxMap<Id, Id>,
239    local: Part,
240    team: Part,
241    /// The tags of this machine's sessions: the team lines `team_parts` drops. Not saved (from `local`).
242    mine: FxSet<Id>,
243    /// Per view (off, on): memory.rs `blame` of its regrets, (lesson, session, credit) by ids.
244    blame: [Vec<(Id, Id, f64)>; 2],
245    /// The generation of the regrets file the sums are of; 0 for no regrets.
246    generation: u128,
247    /// The regrets, None until a fold needs them (read from `regrets_file` at `generation`).
248    regrets: Option<Regrets>,
249    regrets_file: Option<PathBuf>,
250    /// The regrets changed since they were read: written as a new generation at the next save.
251    changed: bool,
252    /// `blame` is to be summed again from `regrets` (`settle`).
253    stale: bool,
254    /// The regrets were needed and their file is not of `generation`: these facts are void.
255    lost: bool,
256}
257
258/// The saved facts are equal (what neither file holds, nor the regrets, which `blame` sums).
259impl PartialEq for Facts {
260    fn eq(&self, o: &Self) -> bool {
261        (&self.strings, &self.tags, &self.local, &self.team, &self.blame) == (&o.strings, &o.tags, &o.local, &o.team, &o.blame)
262    }
263}
264
265/// One record as the fold reads it, from either log.
266struct Rec<'a> {
267    kind: &'a str,
268    session: &'a str,
269    lessons: &'a [String],
270    ms: i64,
271    repo: &'a str,
272    channel: &'a str,
273    cause: &'a str,
274    credit: f64,
275}
276
277#[cfg(test)]
278thread_local! {
279    /// Lines a fold parsed and regrets files read, on this thread (the tests' proof of a warm load).
280    static PARSED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
281    static REGRET_READS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
282}
283
284impl Facts {
285    fn id(&mut self, s: &str) -> Id {
286        if self.index.len() != self.strings.len() {
287            self.index = self.strings.iter().enumerate().map(|(i, s)| (s.clone(), i as Id)).collect();
288        }
289        if let Some(i) = self.index.get(s) {
290            return *i;
291        }
292        let i = self.strings.len() as Id;
293        self.strings.push(s.to_string());
294        self.index.insert(s.to_string(), i);
295        i
296    }
297
298    /// The `tag` of a raw string of this machine's log, hashed once per string.
299    fn tagged(&mut self, raw: Id) -> Id {
300        if let Some(t) = self.tags.get(&raw) {
301            return *t;
302        }
303        let s = &self.strings[raw as usize];
304        let t = if s.is_empty() { String::new() } else { outcome::tag(s) };
305        let t = self.id(&t);
306        self.tags.insert(raw, t);
307        t
308    }
309
310    /// The regrets, read on first need; None (and the facts void) when their file is not of this
311    /// generation.
312    fn regrets(&mut self) -> Option<&mut Regrets> {
313        if self.regrets.is_none() {
314            let read = match self.generation {
315                0 => Some(Regrets::default()),
316                generation => {
317                    #[cfg(test)]
318                    REGRET_READS.with(|c| c.set(c.get() + 1));
319                    let bytes = self.regrets_file.as_deref().and_then(|p| std::fs::read(p).ok());
320                    bytes.and_then(|b| decode_regrets(&b, generation, self.strings.len()))
321                }
322            };
323            match read {
324                Some(r) => self.regrets = Some(r),
325                None => {
326                    self.lost = true;
327                    return None;
328                }
329            }
330        }
331        self.regrets.as_mut()
332    }
333
334    /// Drops the regrets of a log folded again from the start (this machine's: all of them, the team's
335    /// is folded again too).
336    fn forget(&mut self, team: bool) {
337        if team {
338            match self.regrets() {
339                Some(r) if !r.team.is_empty() => r.team.clear(),
340                _ => return,
341            }
342        } else {
343            self.regrets = Some(Regrets::default());
344        }
345        (self.changed, self.stale) = (true, true);
346    }
347
348    /// Folds the lines of `bytes`, which start at offset `base` of the log.
349    fn fold(&mut self, team: bool, bytes: &[u8], base: u64) {
350        let mut pos = 0;
351        for line in bytes.split(|b| *b == b'\n') {
352            let off = base + pos as u64;
353            pos += line.len() + 1;
354            #[cfg(test)]
355            PARSED.with(|c| c.set(c.get() + 1));
356            // As `str::lines` reads a line.
357            let line = line.strip_suffix(b"\r").unwrap_or(line);
358            let Ok(text) = std::str::from_utf8(line) else { continue };
359            if team {
360                let Ok(x) = serde_json::from_str::<Shared>(text) else { continue };
361                if outcome::SHARED_KINDS.contains(&x.kind.as_str()) {
362                    let rec =
363                        Rec { kind: &x.kind, session: &x.s, lessons: &x.lessons, ms: x.ms, repo: &x.r, channel: &x.channel, cause: &x.c, credit: x.credit };
364                    self.add(true, &rec, off);
365                }
366            } else if let Ok(o) = serde_json::from_str::<Outcome>(text) {
367                let rec = Rec {
368                    kind: &o.kind,
369                    session: &o.session,
370                    lessons: &o.lessons,
371                    ms: o.ms,
372                    repo: &o.repo,
373                    channel: &o.channel,
374                    cause: &o.cause,
375                    credit: o.credit,
376                };
377                self.add(false, &rec, off);
378            }
379        }
380    }
381
382    /// Folds one record at offset `off`: memory.rs `Folded::of` and outcome.rs `sessions` per key.
383    fn add(&mut self, team: bool, o: &Rec<'_>, off: u64) {
384        let s = self.id(o.session);
385        if !team {
386            let t = self.tagged(s);
387            // A team session folded before it was this machine's: its regrets leave the view on.
388            if self.mine.insert(t) && self.team.sessions.contains_key(&t) && self.regrets().is_some() {
389                self.stale = true;
390            }
391            self.local.sessions.entry(s).or_default();
392        } else if self.mine.contains(&s) {
393            // A copy of this machine's own record, which `team_parts` drops. `mine` only grows between
394            // two folds of the team's log (a rewrite of the local one folds it again), and `view` and
395            // `settle` drop what was folded before a session became this machine's.
396            return;
397        }
398        let lessons: Vec<Id> = o.lessons.iter().map(|l| self.id(l)).collect();
399        let repo = self.id(o.repo);
400        if !team {
401            self.tagged(repo);
402        }
403        if team && o.kind != outcome::TESTED {
404            self.team.given.extend(lessons.iter().map(|l| (s, *l)));
405        }
406        // outcome.rs `is_result`: no evidence for any reader of the fold.
407        if o.channel == outcome::PLACEBO || matches!(o.kind, outcome::UNCLEAR | outcome::REMAINS | outcome::NOT_ARISEN) {
408            return;
409        }
410        let part = if team { &mut self.team } else { &mut self.local };
411        let sess = part.sessions.entry(s).or_default();
412        if memory::ticks(o.kind) {
413            sess.tick = Some(sess.tick.map_or(o.ms, |t| t.min(o.ms)));
414            if !o.repo.is_empty() && !sess.repos.contains(&repo) {
415                sess.repos.push(repo);
416            }
417        }
418        if memory::is_correction_of(o.kind, o.cause) && !sess.corrections.contains(&o.ms) {
419            sess.corrections.push(o.ms);
420        }
421        if o.kind == outcome::TESTED {
422            if team {
423                part.tested.insert((s, lessons));
424            } else {
425                lessons.iter().for_each(|l| part.cell(*l, s).tested = true);
426            }
427            return;
428        }
429        if o.kind == outcome::REGRETTED {
430            let credit = o.credit.clamp(0.0, 1.0);
431            // The cause as each view names it: the team's as written (a tag), this machine's raw (off)
432            // and tagged (on, outcome.rs `team_view`).
433            let named = if o.cause.is_empty() { String::new() } else { outcome::tag(o.cause) };
434            let (raw, named) = (fp(o.cause), fp(&named));
435            let Some(r) = self.regrets() else { return };
436            for l in lessons {
437                if team {
438                    let e = r.team.entry((l, raw, s)).or_insert((0.0, off));
439                    e.0 = e.0.max(credit);
440                    e.1 = e.1.min(off);
441                } else {
442                    merge(&mut r.off, (l, raw), credit, off, s);
443                    merge(&mut r.on, (l, named), credit, off, s);
444                }
445            }
446            (self.changed, self.stale) = (true, true);
447            return;
448        }
449        let trust = match o.kind {
450            outcome::CORRECTED | outcome::VETOED => 2,
451            outcome::NOT_CORRECTED => 1,
452            _ => 0,
453        };
454        let r = memory::result_of(o.kind);
455        if r.is_none() && trust == 0 {
456            return;
457        }
458        for l in lessons {
459            let c = part.cell(l, s);
460            if let Some(r) = r {
461                c.r = c.r.max(r as i8);
462                c.used |= r == 1;
463            }
464            if trust > 0 {
465                c.trust_ms = if c.trust == 0 { o.ms } else { c.trust_ms.min(o.ms) };
466                c.trust = c.trust.max(trust);
467            }
468            if r == Some(1) {
469                part.use_repos.insert((l, s, repo));
470            }
471        }
472    }
473
474    /// Sums the regrets per view again when they changed (module doc "Regrets"): as `view` would merge
475    /// them, this machine's lines before the team's, then by offset.
476    fn settle(&mut self) {
477        if !self.stale {
478            return;
479        }
480        let Some(r) = self.regrets.as_ref() else { return };
481        let off = memory::blame(r.off.iter().map(|((l, _), (credit, _, s))| (*l, *s, *credit)));
482        let mut on: FxMap<(Id, Fp), Placed> = FxMap::default();
483        on.reserve(r.on.len() + r.team.len());
484        on.extend(r.on.iter().map(|(k, (credit, off, s))| (*k, (*credit, (false, *off), *s))));
485        for ((l, c, s), (credit, off)) in r.team.iter().filter(|((_, _, s), _)| !self.mine.contains(s)) {
486            let order = (true, *off);
487            let e = on.entry((*l, *c)).or_insert((0.0, order, *s));
488            e.0 = e.0.max(*credit);
489            if order < e.1 {
490                (e.1, e.2) = (order, *s);
491            }
492        }
493        let on = memory::blame(on.into_iter().map(|((l, _), (credit, _, s))| (l, s, credit)));
494        self.blame = [off, on];
495        self.stale = false;
496    }
497}
498
499/// What `derive` and `Trust` read, as outcome.rs `team_view` would show them.
500pub struct View<'a> {
501    pub folded: Folded<'a>,
502    /// outcome.rs `sessions`.
503    pub trust: HashMap<&'a str, HashMap<&'a str, (bool, i64)>>,
504}
505
506impl Facts {
507    /// The view with the team's outcomes (`on`, outcome.rs `on`) or without: O(facts), no log read.
508    pub fn view(&self, on: bool) -> View<'_> {
509        let st = |i: Id| self.strings[i as usize].as_str();
510        // A string of this machine's log as the view names it: its tag with the team's (`team_view`).
511        let local = |i: Id| if on { self.tags.get(&i).copied().unwrap_or(i) } else { i };
512        let named = |team: bool, i: Id| if team { i } else { local(i) };
513        let parts: &[(&Part, bool)] = if on { &[(&self.local, false), (&self.team, true)] } else { &[(&self.local, false)] };
514        let keep = |team: bool, s: Id| !team || !self.mine.contains(&s);
515        let mut f = Folded::default();
516        let mut trust: HashMap<&str, HashMap<&str, (bool, i64)>> = HashMap::new();
517        let mut ticks: FxMap<Id, (i64, FxSet<Id>)> = FxMap::default();
518        for (p, team) in parts.iter().copied() {
519            for (s, x) in p.sessions.iter().filter(|(s, _)| keep(team, **s)) {
520                if !x.corrections.is_empty() {
521                    f.corrections.entry(st(*s)).or_default().extend(x.corrections.iter().copied());
522                }
523                if let Some(tick) = x.tick {
524                    let e = ticks.entry(*s).or_insert((tick, FxSet::default()));
525                    e.0 = e.0.min(tick);
526                    e.1.extend(x.repos.iter().map(|r| named(team, *r)));
527                }
528            }
529            for ((l, s), c) in p.cells.iter().filter(|((_, s), _)| keep(team, *s)) {
530                let (l, s) = (st(*l), st(*s));
531                if c.r >= 0 {
532                    let e = f.result.entry(l).or_default().entry(s).or_insert(c.r as u8);
533                    *e = (*e).max(c.r as u8);
534                }
535                if c.used {
536                    f.uses.entry(l).or_default().insert(s);
537                }
538                if c.tested {
539                    f.tested.entry(l).or_default().insert(s);
540                }
541                if c.trust > 0 {
542                    let e = trust.entry(l).or_default().entry(s).or_insert((c.trust == 2, c.trust_ms));
543                    e.0 |= c.trust == 2;
544                    e.1 = e.1.min(c.trust_ms);
545                }
546            }
547            for (l, _, r) in p.use_repos.iter().filter(|(_, s, _)| keep(team, *s)) {
548                f.repos.entry(st(*l)).or_default().insert(st(named(team, *r)));
549            }
550            for (s, ls) in p.tested.iter().filter(|(s, _)| keep(team, *s)) {
551                if ls.iter().all(|l| p.given.contains(&(*s, *l))) {
552                    for l in ls {
553                        f.tested.entry(st(*l)).or_default().insert(st(*s));
554                    }
555                }
556            }
557        }
558        let clock = ticks.into_iter().map(|(s, (ms, repos))| (st(s).to_string(), ms, repos.into_iter().map(|r| st(r).to_string()).collect()));
559        f.clock = memory::Clock::from_sessions(clock);
560        f.regret = self.blame[usize::from(on)].iter().map(|(l, s, credit)| (st(*l), st(*s), *credit)).collect();
561        View { folded: f, trust }
562    }
563}
564
565/// The blake3 of `f`'s bytes [from, to); all ones when they cannot be read (never a log's hash).
566fn window(f: &mut std::fs::File, from: u64, to: u64) -> [u8; 32] {
567    let mut buf = vec![0u8; (to - from) as usize];
568    match f.seek(SeekFrom::Start(from)).and_then(|_| f.read_exact(&mut buf)) {
569        Ok(()) => *blake3::hash(&buf).as_bytes(),
570        Err(_) => [0xff; 32],
571    }
572}
573
574/// Where `catch_up` left a log.
575struct Caught {
576    /// The part differs from the saved one.
577    moved: bool,
578    /// It was folded again from the start.
579    rebuilt: bool,
580    /// The unterminated last line, and its offset.
581    rest: Vec<u8>,
582    at: u64,
583}
584
585impl Facts {
586    fn part(&mut self, team: bool) -> &mut Part {
587        if team { &mut self.team } else { &mut self.local }
588    }
589
590    /// Brings the part of `log` up to date: verifies its checkpoint (a mismatch folds the log from the
591    /// start), then folds the complete lines after it.
592    fn catch_up(&mut self, team: bool, log: &Path) -> Caught {
593        let Ok(mut f) = std::fs::File::open(log) else {
594            let gone = *self.part(team) != Part::default();
595            if gone {
596                *self.part(team) = Part::default();
597                self.forget(team);
598            }
599            return Caught { moved: gone, rebuilt: gone, rest: Vec::new(), at: 0 };
600        };
601        let Ok(meta) = f.metadata() else { return Caught { moved: false, rebuilt: false, rest: Vec::new(), at: 0 } };
602        let len = meta.len();
603        let c = self.part(team).check.clone();
604        let same = c.at == 0
605            || (c.dev == meta.dev()
606                && c.ino == meta.ino()
607                && len >= c.at
608                && window(&mut f, 0, c.at.min(WINDOW)) == c.head
609                && window(&mut f, c.at.saturating_sub(WINDOW), c.at) == c.tail);
610        let rebuilt = !same;
611        if rebuilt {
612            *self.part(team) = Part::default();
613            self.forget(team);
614        }
615        let at = self.part(team).check.at;
616        let mut rest = Vec::new();
617        if f.seek(SeekFrom::Start(at)).and_then(|_| f.read_to_end(&mut rest)).is_err() {
618            return Caught { moved: rebuilt, rebuilt, rest: Vec::new(), at };
619        }
620        let done = rest.iter().rposition(|b| *b == b'\n').map_or(0, |i| i + 1);
621        let tail = rest.split_off(done);
622        if done > 0 {
623            self.fold(team, &rest[..done - 1], at);
624            let to = at + done as u64;
625            let (head, end) = (window(&mut f, 0, to.min(WINDOW)), window(&mut f, to.saturating_sub(WINDOW), to));
626            self.part(team).check = Check { dev: meta.dev(), ino: meta.ino(), at: to, head, tail: end };
627        }
628        Caught { moved: rebuilt || done > 0, rebuilt, rest: tail, at: at + done as u64 }
629    }
630
631    /// Both logs beside `lessons` folded on: returns whether the facts moved and each log's unterminated
632    /// last line (local, team) with its offset.
633    fn catch_up_store(&mut self, lessons: &Path) -> (bool, [(Vec<u8>, u64); 2]) {
634        let local = self.catch_up(false, &outcome::path_beside(lessons));
635        if local.rebuilt {
636            // `mine` may have shrunk: the team's lines it dropped may count now (`forget` dropped the
637            // team's regrets with this machine's).
638            self.team = Part::default();
639            self.mine = self.local.sessions.keys().filter_map(|s| self.tags.get(s).copied()).collect();
640        }
641        let team = self.catch_up(true, &lessons.with_file_name(outcome::TEAM_FILE));
642        (local.moved || team.moved, [(local.rest, local.at), (team.rest, team.at)])
643    }
644
645    /// The unterminated last lines, as `read` counts them (never saved).
646    fn fold_rests(&mut self, rests: [(Vec<u8>, u64); 2]) {
647        for (team, (rest, at)) in [false, true].into_iter().zip(rests) {
648            if !rest.is_empty() {
649                self.fold(team, &rest, at);
650            }
651        }
652    }
653
654    /// Folds what was appended since these facts (and saves them when they moved and `save`); None when
655    /// they turned out void (`lost`).
656    fn update(mut self, lessons: &Path, save: bool) -> Option<Facts> {
657        self.regrets_file = Some(regrets_beside(lessons));
658        let (moved, rests) = self.catch_up_store(lessons);
659        if self.lost {
660            return None;
661        }
662        self.settle();
663        if moved && save {
664            store(lessons, &mut self);
665        }
666        self.fold_rests(rests);
667        self.settle();
668        (!self.lost).then_some(self)
669    }
670}
671
672/// The snapshot beside the lessons file `lessons`.
673pub fn path_beside(lessons: &Path) -> PathBuf {
674    lessons.with_file_name(FILE)
675}
676
677/// The regrets file beside the lessons file `lessons`.
678pub fn regrets_beside(lessons: &Path) -> PathBuf {
679    lessons.with_file_name(REGRETS_FILE)
680}
681
682/// The facts of the logs beside `lessons`, up to date: the snapshot plus what was appended since (all of
683/// it when the snapshot is missing, does not match or turns out void), saved again when they moved.
684pub fn load(lessons: &Path) -> Facts {
685    let saved = std::fs::read(path_beside(lessons)).ok().and_then(|b| decode(&b)).unwrap_or_default();
686    // A fold from nothing never reads a regrets file, so it is never void.
687    saved.update(lessons, true).or_else(|| Facts::default().update(lessons, true)).unwrap_or_default()
688}
689
690/// The facts of the logs beside `lessons` folded from the start, ignoring the snapshot.
691#[cfg(test)]
692fn fresh(lessons: &Path) -> Facts {
693    Facts::default().update(lessons, false).expect("a fold from nothing reads no regrets file")
694}
695
696/// Folds the logs beside `lessons` from the start and replaces the snapshot (`borg learn`, which read
697/// them whole: module doc). Nothing when there is no log to fold.
698pub fn rebuild(lessons: &Path) {
699    let mut facts = Facts { regrets_file: Some(regrets_beside(lessons)), ..Facts::default() };
700    let (moved, _) = facts.catch_up_store(lessons);
701    facts.settle();
702    if moved {
703        store(lessons, &mut facts);
704    }
705}
706
707/// Writes `bytes` to `path`: a temp file, then a rename (module doc).
708fn write_atomic(path: &Path, bytes: &[u8]) -> bool {
709    let tmp = path.with_extension(format!("{}.tmp", std::process::id()));
710    let ok = std::fs::write(&tmp, bytes).is_ok() && std::fs::rename(&tmp, path).is_ok();
711    if !ok {
712        let _ = std::fs::remove_file(&tmp);
713    }
714    ok
715}
716
717/// A new generation of the regrets file: unique per write (process, time, a counter), never 0.
718fn new_generation() -> u128 {
719    static N: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
720    let mut h = blake3::Hasher::new();
721    h.update(&std::process::id().to_le_bytes());
722    h.update(&std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_nanos()).to_le_bytes());
723    h.update(&N.fetch_add(1, std::sync::atomic::Ordering::Relaxed).to_le_bytes());
724    u128::from_le_bytes(h.finalize().as_bytes()[..16].try_into().expect("16 bytes")).max(1)
725}
726
727/// Saves the facts beside `lessons`: changed regrets first, as a new generation, then the snapshot that
728/// names it. A failure costs the next hook a fold.
729fn store(lessons: &Path, facts: &mut Facts) {
730    if facts.changed {
731        let Some(r) = facts.regrets.as_ref() else { return };
732        let generation = match r.is_empty() {
733            true => 0,
734            false => new_generation(),
735        };
736        if generation != 0 && !write_atomic(&regrets_beside(lessons), &encode_regrets(r, generation)) {
737            return;
738        }
739        (facts.generation, facts.changed) = (generation, false);
740    }
741    write_atomic(&path_beside(lessons), &encode(facts));
742}
743
744/// Little-endian writer of the files.
745struct W(Vec<u8>);
746
747impl W {
748    fn u8(&mut self, x: u8) {
749        self.0.push(x);
750    }
751    fn u32(&mut self, x: u32) {
752        self.0.extend_from_slice(&x.to_le_bytes());
753    }
754    fn u64(&mut self, x: u64) {
755        self.0.extend_from_slice(&x.to_le_bytes());
756    }
757    fn u128(&mut self, x: u128) {
758        self.0.extend_from_slice(&x.to_le_bytes());
759    }
760    fn len(&mut self, n: usize) {
761        self.u32(n as u32);
762    }
763    /// The body's blake3, appended.
764    fn seal(mut self) -> Vec<u8> {
765        let sum = blake3::hash(&self.0);
766        self.0.extend_from_slice(sum.as_bytes());
767        self.0
768    }
769}
770
771/// Little-endian reader of the files: None past their end.
772struct R<'a>(&'a [u8]);
773
774impl<'a> R<'a> {
775    /// The body of a sealed file of `magic` and these `rules`, after them; None for anything else.
776    fn open(b: &'a [u8], magic: &[u8; 8]) -> Option<Self> {
777        let (body, sum) = b.split_at_checked(b.len().checked_sub(32)?)?;
778        if blake3::hash(body).as_bytes() != sum {
779            return None;
780        }
781        let mut r = R(body);
782        (r.take(magic.len())? == magic && r.hash()? == rules()).then_some(r)
783    }
784    fn take(&mut self, n: usize) -> Option<&'a [u8]> {
785        let (a, b) = self.0.split_at_checked(n)?;
786        self.0 = b;
787        Some(a)
788    }
789    fn u8(&mut self) -> Option<u8> {
790        Some(self.take(1)?[0])
791    }
792    fn u32(&mut self) -> Option<u32> {
793        Some(u32::from_le_bytes(self.take(4)?.try_into().ok()?))
794    }
795    fn u64(&mut self) -> Option<u64> {
796        Some(u64::from_le_bytes(self.take(8)?.try_into().ok()?))
797    }
798    fn u128(&mut self) -> Option<u128> {
799        Some(u128::from_le_bytes(self.take(16)?.try_into().ok()?))
800    }
801    fn i64(&mut self) -> Option<i64> {
802        self.u64().map(|x| x as i64)
803    }
804    fn f64(&mut self) -> Option<f64> {
805        self.u64().map(f64::from_bits)
806    }
807    fn hash(&mut self) -> Option<[u8; 32]> {
808        self.take(32)?.try_into().ok()
809    }
810    /// A count, at most what the rest could hold at `each` bytes per item (a corrupt count never
811    /// reserves gigabytes).
812    fn len(&mut self, each: usize) -> Option<usize> {
813        let n = self.u32()? as usize;
814        (n.checked_mul(each)? <= self.0.len()).then_some(n)
815    }
816    /// An id below `n`.
817    fn id(&mut self, n: usize) -> Option<Id> {
818        self.u32().filter(|i| (*i as usize) < n)
819    }
820}
821
822fn enc_part(w: &mut W, p: &Part) {
823    let c = &p.check;
824    w.u64(c.dev);
825    w.u64(c.ino);
826    w.u64(c.at);
827    w.0.extend_from_slice(&c.head);
828    w.0.extend_from_slice(&c.tail);
829    w.len(p.sessions.len());
830    for (s, x) in &p.sessions {
831        w.u32(*s);
832        w.u8(u8::from(x.tick.is_some()));
833        w.u64(x.tick.unwrap_or(0) as u64);
834        w.len(x.repos.len());
835        x.repos.iter().for_each(|r| w.u32(*r));
836        w.len(x.corrections.len());
837        x.corrections.iter().for_each(|m| w.u64(*m as u64));
838    }
839    w.len(p.cells.len());
840    for ((l, s), c) in &p.cells {
841        w.u32(*l);
842        w.u32(*s);
843        w.u8(c.r as u8);
844        w.u8(u8::from(c.used) | u8::from(c.tested) << 1);
845        w.u8(c.trust);
846        w.u64(c.trust_ms as u64);
847    }
848    w.len(p.use_repos.len());
849    for (l, s, r) in &p.use_repos {
850        [*l, *s, *r].into_iter().for_each(|x| w.u32(x));
851    }
852    w.len(p.tested.len());
853    for (s, ls) in &p.tested {
854        w.u32(*s);
855        w.len(ls.len());
856        ls.iter().for_each(|l| w.u32(*l));
857    }
858    w.len(p.given.len());
859    for (s, l) in &p.given {
860        w.u32(*s);
861        w.u32(*l);
862    }
863}
864
865/// One part, every id checked against the `n` strings.
866fn dec_part(r: &mut R<'_>, n: usize) -> Option<Part> {
867    let mut p = Part { check: Check { dev: r.u64()?, ino: r.u64()?, at: r.u64()?, head: r.hash()?, tail: r.hash()? }, ..Part::default() };
868    let k = r.len(21)?;
869    p.sessions.reserve(k);
870    for _ in 0..k {
871        let s = r.id(n)?;
872        let tick = match (r.u8()?, r.i64()?) {
873            (0, _) => None,
874            (1, ms) => Some(ms),
875            _ => return None,
876        };
877        let repos = (0..r.len(4)?).map(|_| r.id(n)).collect::<Option<_>>()?;
878        let corrections = (0..r.len(8)?).map(|_| r.i64()).collect::<Option<_>>()?;
879        p.sessions.insert(s, Sess { tick, repos, corrections });
880    }
881    let k = r.len(19)?;
882    p.cells.reserve(k);
883    for _ in 0..k {
884        let key = (r.id(n)?, r.id(n)?);
885        let (rv, flags, trust, trust_ms) = (r.u8()? as i8, r.u8()?, r.u8()?, r.i64()?);
886        p.cells.insert(key, Cell { r: rv, used: flags & 1 != 0, tested: flags & 2 != 0, trust, trust_ms });
887    }
888    for _ in 0..r.len(12)? {
889        p.use_repos.insert((r.id(n)?, r.id(n)?, r.id(n)?));
890    }
891    for _ in 0..r.len(8)? {
892        let s = r.id(n)?;
893        let ls = (0..r.len(4)?).map(|_| r.id(n)).collect::<Option<_>>()?;
894        p.tested.insert((s, ls));
895    }
896    for _ in 0..r.len(8)? {
897        p.given.insert((r.id(n)?, r.id(n)?));
898    }
899    Some(p)
900}
901
902/// The snapshot's bytes (module doc).
903pub fn encode(f: &Facts) -> Vec<u8> {
904    let mut w = W(Vec::with_capacity(1 << 16));
905    w.0.extend_from_slice(MAGIC);
906    w.0.extend_from_slice(&rules());
907    w.u128(f.generation);
908    w.len(f.strings.len());
909    for s in &f.strings {
910        w.len(s.len());
911        w.0.extend_from_slice(s.as_bytes());
912    }
913    w.len(f.tags.len());
914    for (a, b) in &f.tags {
915        w.u32(*a);
916        w.u32(*b);
917    }
918    enc_part(&mut w, &f.local);
919    enc_part(&mut w, &f.team);
920    for blame in &f.blame {
921        w.len(blame.len());
922        for (l, s, credit) in blame {
923            w.u32(*l);
924            w.u32(*s);
925            w.u64(credit.to_bits());
926        }
927    }
928    w.seal()
929}
930
931/// The facts of a whole, valid snapshot of these `rules`; None for anything else (the caller folds
932/// afresh). Their regrets stay unread (`Facts::regrets`).
933pub fn decode(b: &[u8]) -> Option<Facts> {
934    let mut r = R::open(b, MAGIC)?;
935    let mut f = Facts { generation: r.u128()?, ..Facts::default() };
936    let n = r.len(4)?;
937    f.strings.reserve(n);
938    for _ in 0..n {
939        let len = r.len(1)?;
940        f.strings.push(std::str::from_utf8(r.take(len)?).ok()?.to_string());
941    }
942    let k = r.len(8)?;
943    f.tags.reserve(k);
944    for _ in 0..k {
945        f.tags.insert(r.id(n)?, r.id(n)?);
946    }
947    f.local = dec_part(&mut r, n)?;
948    f.team = dec_part(&mut r, n)?;
949    for blame in &mut f.blame {
950        *blame = (0..r.len(16)?).map(|_| Some((r.id(n)?, r.id(n)?, r.f64()?))).collect::<Option<_>>()?;
951    }
952    f.mine = f.local.sessions.keys().map(|s| f.tags.get(s).copied()).collect::<Option<_>>()?;
953    r.0.is_empty().then_some(f)
954}
955
956/// The regrets file's bytes: its generation, then the three maps (module doc).
957fn encode_regrets(x: &Regrets, generation: u128) -> Vec<u8> {
958    let mut w = W(Vec::with_capacity(40 * (x.off.len() + x.on.len() + x.team.len()) + 128));
959    w.0.extend_from_slice(REGRETS_MAGIC);
960    w.0.extend_from_slice(&rules());
961    w.u128(generation);
962    for m in [&x.off, &x.on] {
963        w.len(m.len());
964        for ((l, c), (credit, off, s)) in m {
965            w.u32(*l);
966            w.u128(*c);
967            w.u64(credit.to_bits());
968            w.u64(*off);
969            w.u32(*s);
970        }
971    }
972    w.len(x.team.len());
973    for ((l, c, s), (credit, off)) in &x.team {
974        w.u32(*l);
975        w.u128(*c);
976        w.u32(*s);
977        w.u64(credit.to_bits());
978        w.u64(*off);
979    }
980    w.seal()
981}
982
983/// The regrets of a whole, valid regrets file of `generation`, every id below `n`; None for anything
984/// else (the facts that name it are void).
985fn decode_regrets(b: &[u8], generation: u128, n: usize) -> Option<Regrets> {
986    let mut r = R::open(b, REGRETS_MAGIC)?;
987    (r.u128()? == generation).then_some(())?;
988    let mut x = Regrets::default();
989    for m in [&mut x.off, &mut x.on] {
990        let k = r.len(40)?;
991        m.reserve(k);
992        for _ in 0..k {
993            let key = (r.id(n)?, r.u128()?);
994            m.insert(key, (r.f64()?, r.u64()?, r.id(n)?));
995        }
996    }
997    let k = r.len(40)?;
998    x.team.reserve(k);
999    for _ in 0..k {
1000        let key = (r.id(n)?, r.u128()?, r.id(n)?);
1001        x.team.insert(key, (r.f64()?, r.u64()?));
1002    }
1003    r.0.is_empty().then_some(x)
1004}
1005
1006#[cfg(test)]
1007mod tests {
1008    use super::*;
1009    use crate::conscience::Challenge;
1010    use crate::memory::Rng;
1011
1012    fn dir(tag: &str) -> PathBuf {
1013        let d = std::env::temp_dir().join(format!("borg-facts-{tag}-{}", std::process::id()));
1014        let _ = std::fs::remove_dir_all(&d);
1015        std::fs::create_dir_all(&d).unwrap();
1016        d
1017    }
1018
1019    fn pick<'a>(r: &mut Rng, xs: &[&'a str]) -> &'a str {
1020        xs[(r.next_u64() % xs.len() as u64) as usize]
1021    }
1022
1023    /// A random store's two logs (local, team) and lessons: every kind, placebos, regrets with odd
1024    /// credits, this machine's own published copies, peers' lines (a `tested` one given or not), exact
1025    /// duplicates, unknown kinds and lines that are no record.
1026    fn logs(seed: u64) -> (String, String, Vec<Challenge>) {
1027        let mut r = Rng::new(seed);
1028        let kinds = [
1029            outcome::ASKED,
1030            outcome::CHANGED,
1031            outcome::IGNORED,
1032            outcome::CORRECTED,
1033            outcome::NOT_CORRECTED,
1034            outcome::MISSED,
1035            outcome::VETOED,
1036            outcome::REGRETTED,
1037            outcome::TESTED,
1038            outcome::UNCLEAR,
1039            outcome::REMAINS,
1040            outcome::NOT_ARISEN,
1041            outcome::GRADED,
1042            outcome::ENDED,
1043        ];
1044        let lessons = ["L0", "L1", "L2", "L3", "L4", "L5"];
1045        let repos = ["repoA", "RepoA", "repoB", ""];
1046        let causes = ["", "", "R1", "R2", "V3"];
1047        let mut ms = 0;
1048        let mut record = |r: &mut Rng, session: String| {
1049            ms += 1 + (r.next_u64() % 3) as i64;
1050            let kind = pick(r, &kinds);
1051            outcome::Outcome {
1052                kind: kind.into(),
1053                session,
1054                lessons: (0..r.next_u64() % 3 + u64::from(kind != outcome::ENDED)).map(|_| pick(r, &lessons).to_string()).collect(),
1055                // Equal times across sessions (the clock orders them by id), and the extremes a peer's
1056                // line may carry.
1057                ms: match r.next_u64() % 24 {
1058                    0 => i64::MAX,
1059                    1 => i64::MIN,
1060                    2..=7 => 7,
1061                    _ => ms,
1062                },
1063                repo: pick(r, &repos).into(),
1064                channel: if r.next_u64().is_multiple_of(6) { outcome::PLACEBO.into() } else { pick(r, &["", "early", "stop"]).into() },
1065                cause: if kind == outcome::REGRETTED || kind == outcome::MISSED { pick(r, &causes).into() } else { String::new() },
1066                credit: if kind == outcome::REGRETTED { (r.next_u64() % 16) as f64 / 10.0 - 0.2 } else { 0.0 },
1067                ..Default::default()
1068            }
1069        };
1070        let (mut local, mut team): (Vec<String>, Vec<String>) = (Vec::new(), Vec::new());
1071        for _ in 0..r.next_u64() % 40 {
1072            let line = match r.next_u64() % 12 {
1073                0 => "{\"kind\":\"asked\",\"session\":".to_string(),
1074                1 if !local.is_empty() => local[(r.next_u64() % local.len() as u64) as usize].clone(),
1075                _ => {
1076                    let session = format!("s{}", r.next_u64() % 5);
1077                    let o = record(&mut r, session);
1078                    if let Some(x) = outcome::share(&o, "this-node").filter(|_| r.next_u64().is_multiple_of(2)) {
1079                        team.push(serde_json::to_string(&x).unwrap());
1080                    }
1081                    serde_json::to_string(&o).unwrap()
1082                }
1083            };
1084            local.push(line);
1085        }
1086        for _ in 0..r.next_u64() % 40 {
1087            let line = match r.next_u64() % 12 {
1088                0 => "not a record".to_string(),
1089                1 if !team.is_empty() => team[(r.next_u64() % team.len() as u64) as usize].clone(),
1090                _ => {
1091                    let mut o = record(&mut r, String::new());
1092                    o.channel = String::new();
1093                    let peer = format!("{:032x}", r.next_u64() % 4);
1094                    let mut x = outcome::share(&o, "peer").unwrap_or_else(|| outcome::Shared { kind: o.kind.clone(), ms: o.ms, ..Default::default() });
1095                    (x.s, x.lessons) = (peer, o.lessons.clone());
1096                    serde_json::to_string(&x).unwrap()
1097                }
1098            };
1099            team.push(line);
1100        }
1101        let challenge = |k: usize| Challenge {
1102            id: if k < 6 { lessons[k].to_string() } else { format!("N{k}") },
1103            origin_ms: (r.next_u64() % 60) as i64,
1104            reach: if k.is_multiple_of(3) { crate::reach::REPO.into() } else { String::new() },
1105            repo: if k.is_multiple_of(3) { pick(&mut r, &repos).into() } else { String::new() },
1106            engineers: (0..1 + k % 2).map(|e| format!("e{e}")).collect(),
1107            ..Default::default()
1108        };
1109        let cs = (0..8).map(challenge).collect();
1110        let lines = |v: Vec<String>| v.into_iter().map(|l| l + "\n").collect::<String>();
1111        (lines(local), lines(team), cs)
1112    }
1113
1114    /// outcome.rs `sessions` as sorted rows, whatever the maps' hasher.
1115    fn norm<A, B>(m: &HashMap<&str, HashMap<&str, (bool, i64), B>, A>) -> Vec<(String, String, bool, i64)> {
1116        let mut v: Vec<(String, String, bool, i64)> = m.iter().flat_map(|(l, ss)| ss.iter().map(|(s, x)| (l.to_string(), s.to_string(), x.0, x.1))).collect();
1117        v.sort();
1118        v
1119    }
1120
1121    /// The facts' memory and trust equal a fresh `derive` and `sessions` of the logs beside `lessons`,
1122    /// with the team's outcomes on and off, for every session in play and a new one.
1123    fn same(lessons: &Path, cs: &[Challenge], facts: &Facts, what: &str) {
1124        for on in [true, false] {
1125            let outcomes = outcome::team_view(lessons, on);
1126            let view = facts.view(on);
1127            assert_eq!(norm(&outcome::sessions(&outcomes)), norm(&view.trust), "{what}: trust, on={on}");
1128            let mut sessions: Vec<Option<&str>> = outcomes.iter().map(|o| Some(o.session.as_str())).collect();
1129            sessions.sort();
1130            sessions.dedup();
1131            sessions.extend([None, Some("a-new-session")]);
1132            for session in sessions {
1133                let want = memory::derive(&outcomes, cs, session);
1134                let got = memory::derive_folded(facts.view(on).folded, cs, session);
1135                assert_eq!(want.len(), got.len());
1136                for (id, a) in &want {
1137                    let b = &got[id];
1138                    let close = |x: f64, y: f64| x == y || (x - y).abs() <= 1e-9 * x.abs().max(1.0);
1139                    assert!(
1140                        (a.changed, a.ignored, a.corrected, a.vetoed, a.tested, a.consolidated, a.dormant)
1141                            == (b.changed, b.ignored, b.corrected, b.vetoed, b.tested, b.consolidated, b.dormant)
1142                            && close(a.activation, b.activation)
1143                            && close(a.regretted, b.regretted),
1144                        "{what}: on={on} session={session:?} {id}: {a:?} != {b:?}"
1145                    );
1146                }
1147            }
1148        }
1149    }
1150
1151    /// Folded whole, folded in random pieces cut mid-line (each load resuming the last's snapshot), and
1152    /// round-tripped through the snapshot: always a fresh `derive`.
1153    #[test]
1154    fn the_facts_equal_a_fresh_derive_however_the_logs_grew() {
1155        let d = dir("grow");
1156        let lessons = d.join("lessons.jsonl");
1157        let (pl, pt) = (outcome::path_beside(&lessons), d.join(outcome::TEAM_FILE));
1158        for seed in 0..200 {
1159            let (local, team, cs) = logs(seed);
1160            let _ = std::fs::remove_file(path_beside(&lessons));
1161            std::fs::write(&pl, "").unwrap();
1162            std::fs::write(&pt, "").unwrap();
1163            let mut r = Rng::new(seed ^ 0xfac7);
1164            let (mut l, mut t) = (0, 0);
1165            while l < local.len() || t < team.len() {
1166                l = (l + (r.next_u64() % 1200) as usize).min(local.len());
1167                t = (t + (r.next_u64() % 1200) as usize).min(team.len());
1168                use std::io::Write;
1169                let have = |p: &Path| std::fs::metadata(p).unwrap().len() as usize;
1170                std::fs::OpenOptions::new().append(true).open(&pl).unwrap().write_all(&local.as_bytes()[have(&pl)..l]).unwrap();
1171                std::fs::OpenOptions::new().append(true).open(&pt).unwrap().write_all(&team.as_bytes()[have(&pt)..t]).unwrap();
1172                let facts = load(&lessons);
1173                same(&lessons, &cs, &facts, &format!("seed {seed} at {l}/{t}"));
1174            }
1175            let saved = decode(&std::fs::read(path_beside(&lessons)).unwrap_or_default());
1176            if let Some(mut saved) = saved {
1177                assert_eq!(decode(&encode(&saved)).as_ref(), Some(&saved), "seed {seed}: round trip");
1178                saved.regrets_file = Some(regrets_beside(&lessons));
1179                let n = saved.strings.len();
1180                let regrets = saved.regrets().cloned().expect("the regrets of the snapshot's generation");
1181                assert_eq!(decode_regrets(&encode_regrets(&regrets, 9), 9, n).as_ref(), Some(&regrets), "seed {seed}: regrets round trip");
1182            }
1183            same(&lessons, &cs, &fresh(&lessons), &format!("seed {seed}: fresh"));
1184            same(&lessons, &cs, &load(&lessons), &format!("seed {seed}: warm"));
1185        }
1186        let _ = std::fs::remove_dir_all(d);
1187    }
1188
1189    /// Every way the logs or the snapshot change under the facts is noticed: a rename (quarantine), an
1190    /// in-place rewrite shorter, longer and of the same length, a removed log, a corrupt, truncated or
1191    /// foreign snapshot. `rebuild` replaces a snapshot that missed an edit no checkpoint can see.
1192    #[test]
1193    fn a_rewritten_log_or_a_bad_snapshot_folds_afresh() {
1194        let d = dir("rewrite");
1195        let lessons = d.join("lessons.jsonl");
1196        let (pl, pt) = (outcome::path_beside(&lessons), d.join(outcome::TEAM_FILE));
1197        let snap = path_beside(&lessons);
1198        for seed in 0..60 {
1199            let (local, team, cs) = logs(seed);
1200            let _ = std::fs::remove_file(&snap);
1201            std::fs::write(&pl, &local).unwrap();
1202            std::fs::write(&pt, &team).unwrap();
1203            same(&lessons, &cs, &load(&lessons), "cold");
1204            let every_other = |text: &str, k: usize| text.lines().enumerate().filter(|(i, _)| i % 3 != k).map(|(_, l)| format!("{l}\n")).collect::<String>();
1205            // Quarantine: a copy without some lines renamed over the team's log.
1206            let tmp = d.join("q.tmp");
1207            std::fs::write(&tmp, every_other(&team, 1)).unwrap();
1208            std::fs::rename(&tmp, &pt).unwrap();
1209            same(&lessons, &cs, &load(&lessons), "renamed");
1210            // In place: shorter, then longer than the checkpoint, then the same length.
1211            std::fs::write(&pl, every_other(&local, 0)).unwrap();
1212            same(&lessons, &cs, &load(&lessons), "local shorter");
1213            std::fs::write(&pt, every_other(&team, 2) + &team).unwrap();
1214            same(&lessons, &cs, &load(&lessons), "team longer");
1215            let lines: Vec<&str> = local.lines().collect();
1216            if lines.len() > 2 {
1217                let mut body: String = lines.iter().enumerate().filter(|(i, _)| *i != 1).map(|(_, l)| format!("{l}\n")).collect();
1218                body.push_str(&" ".repeat(lines[1].len()));
1219                body.push('\n');
1220                std::fs::write(&pl, &body).unwrap();
1221                same(&lessons, &cs, &load(&lessons), "local same length");
1222            }
1223            // A bad snapshot.
1224            if let Ok(mut b) = std::fs::read(&snap) {
1225                let n = b.len() / 2;
1226                b[n] ^= 0x55;
1227                assert!(decode(&b).is_none());
1228                std::fs::write(&snap, &b).unwrap();
1229                same(&lessons, &cs, &load(&lessons), "corrupt");
1230                let b = std::fs::read(&snap).unwrap();
1231                std::fs::write(&snap, &b[..b.len() - 1]).unwrap();
1232                same(&lessons, &cs, &load(&lessons), "truncated");
1233            }
1234            std::fs::write(&snap, b"BORGFCT0 an older fold").unwrap();
1235            same(&lessons, &cs, &load(&lessons), "foreign");
1236            // Removed logs.
1237            std::fs::remove_file(&pt).unwrap();
1238            same(&lessons, &cs, &load(&lessons), "team removed");
1239            std::fs::remove_file(&pl).unwrap();
1240            same(&lessons, &cs, &load(&lessons), "local removed");
1241            std::fs::write(&pl, &local).unwrap();
1242            std::fs::write(&pt, &team).unwrap();
1243            same(&lessons, &cs, &load(&lessons), "back");
1244        }
1245        // An edit inside a log both windows miss: a warm load keeps the snapshot's facts (it read only the
1246        // tail), until `rebuild`. The edited line is the only change of lesson LX.
1247        let (local, team): (String, String) = (0..40).map(logs).map(|(l, t, _)| (l, t)).unzip();
1248        let cs = logs(0).2;
1249        let mid = local[..local.len() / 2].rfind('\n').unwrap() + 1;
1250        let sentinel =
1251            serde_json::to_string(&Outcome { kind: outcome::CHANGED.into(), session: "sx".into(), lessons: vec!["LX".into()], ms: 5, ..Default::default() })
1252                .unwrap();
1253        let local = format!("{}{sentinel}\n{}", &local[..mid], &local[mid..]);
1254        assert!(mid > 2 * WINDOW as usize && local.len() - mid > 2 * WINDOW as usize);
1255        let _ = std::fs::remove_file(&snap);
1256        std::fs::write(&pl, &local).unwrap();
1257        std::fs::write(&pt, &team).unwrap();
1258        let big = load(&lessons);
1259        same(&lessons, &cs, &big, "big");
1260        let uses_lx = |f: &Facts| f.view(false).folded.uses.contains_key("LX");
1261        assert!(uses_lx(&big));
1262        let mut edited = local.clone();
1263        edited.replace_range(mid..mid + sentinel.len(), &" ".repeat(sentinel.len()));
1264        std::fs::write(&pl, &edited).unwrap();
1265        assert!(load(&lessons) == big, "the warm load folds only what follows its checkpoint");
1266        assert!(!uses_lx(&fresh(&lessons)), "the edit changed a fact");
1267        rebuild(&lessons);
1268        assert!(!uses_lx(&load(&lessons)));
1269        same(&lessons, &cs, &load(&lessons), "rebuilt");
1270        let _ = std::fs::remove_dir_all(d);
1271    }
1272
1273    /// A warm load parses only the lines appended since the snapshot, and reads the regrets file only
1274    /// when a regret line (or a team session that turns out to be this machine's) comes up.
1275    #[test]
1276    fn a_warm_load_parses_only_the_appended_lines() {
1277        let d = dir("warm");
1278        let lessons = d.join("lessons.jsonl");
1279        let (pl, pt) = (outcome::path_beside(&lessons), d.join(outcome::TEAM_FILE));
1280        let (local, team, cs) = (0..40).map(logs).find(|(l, t, _)| l.contains("\"regretted\"") && t.contains("\"regretted\"")).expect("a store with regrets");
1281        std::fs::write(&pl, &local).unwrap();
1282        std::fs::write(&pt, &team).unwrap();
1283        let count = || (PARSED.with(|c| c.replace(0)), REGRET_READS.with(|c| c.replace(0)));
1284        count();
1285        let cold = load(&lessons);
1286        assert_eq!(count(), (local.lines().count() + team.lines().count(), 0), "cold: every line, no regrets file");
1287        assert!(cold.generation != 0 && regrets_beside(&lessons).exists());
1288        assert!(load(&lessons) == cold);
1289        assert_eq!(count(), (0, 0), "warm: nothing parsed, no regrets file read");
1290        let line = |kind: &str, session: &str| {
1291            let o = Outcome {
1292                kind: kind.into(),
1293                session: session.into(),
1294                lessons: vec!["L1".into()],
1295                ms: 99,
1296                cause: "R9".into(),
1297                credit: 0.5,
1298                ..Default::default()
1299            };
1300            serde_json::to_string(&o).unwrap() + "\n"
1301        };
1302        use std::io::Write;
1303        let append = |text: &str| std::fs::OpenOptions::new().append(true).open(&pl).unwrap().write_all(text.as_bytes()).unwrap();
1304        append(&line(outcome::CHANGED, "s1"));
1305        same(&lessons, &cs, &load(&lessons), "a change appended");
1306        assert_eq!(count(), (1, 0), "a change: its line, no regrets file");
1307        append(&line(outcome::REGRETTED, "s1"));
1308        same(&lessons, &cs, &load(&lessons), "a regret appended");
1309        assert_eq!(count(), (1, 1), "a regret: its line and the regrets file");
1310        same(&lessons, &cs, &load(&lessons), "warm again");
1311        assert_eq!(count(), (0, 0));
1312        let _ = std::fs::remove_dir_all(d);
1313    }
1314
1315    /// A team session folded before this machine's log names it (its own published copy came first):
1316    /// its regrets and results leave the view on, as `team_parts` drops them.
1317    #[test]
1318    fn a_team_session_that_turns_out_to_be_mine_leaves_the_view() {
1319        let d = dir("mine");
1320        let lessons = d.join("lessons.jsonl");
1321        let (pl, pt) = (outcome::path_beside(&lessons), d.join(outcome::TEAM_FILE));
1322        let cs = logs(0).2;
1323        let o = |kind: &str, session: &str, ms: i64| Outcome {
1324            kind: kind.into(),
1325            session: session.into(),
1326            lessons: vec!["L1".into()],
1327            ms,
1328            repo: "repoA".into(),
1329            cause: if kind == outcome::REGRETTED { "R1".into() } else { String::new() },
1330            credit: 0.75,
1331            ..Default::default()
1332        };
1333        let shared = [o(outcome::CHANGED, "me", 3), o(outcome::REGRETTED, "me", 4)];
1334        let team: String = shared.iter().map(|x| serde_json::to_string(&outcome::share(x, "this-node").unwrap()).unwrap() + "\n").collect();
1335        std::fs::write(&pt, &team).unwrap();
1336        std::fs::write(&pl, serde_json::to_string(&o(outcome::ASKED, "other", 1)).unwrap() + "\n").unwrap();
1337        let before = load(&lessons);
1338        same(&lessons, &cs, &before, "a peer's");
1339        assert!(!before.view(true).folded.regret.is_empty());
1340        let mine: String = shared.iter().map(|x| serde_json::to_string(x).unwrap() + "\n").collect();
1341        use std::io::Write;
1342        std::fs::OpenOptions::new().append(true).open(&pl).unwrap().write_all(mine.as_bytes()).unwrap();
1343        let after = load(&lessons);
1344        same(&lessons, &cs, &after, "mine");
1345        assert!(after.view(true).folded.regret.iter().all(|(_, s, _)| *s == "me"), "only this machine's own regret");
1346        let _ = std::fs::remove_dir_all(d);
1347    }
1348
1349    /// A line whose ms is i64::MAX ticks the clock like any other: no value means "no tick".
1350    #[test]
1351    fn a_tick_at_the_largest_ms_is_a_tick() {
1352        let d = dir("max");
1353        let lessons = d.join("lessons.jsonl");
1354        let o = |kind: &str, session: &str, lesson: &str, ms: i64| Outcome {
1355            kind: kind.into(),
1356            session: session.into(),
1357            lessons: vec![lesson.into()],
1358            ms,
1359            ..Default::default()
1360        };
1361        let log = [o(outcome::CHANGED, "s1", "L1", 10), o(outcome::ASKED, "s2", "L2", i64::MAX), o(outcome::CHANGED, "s3", "L1", 20)];
1362        std::fs::write(outcome::path_beside(&lessons), log.iter().map(|x| serde_json::to_string(x).unwrap() + "\n").collect::<String>()).unwrap();
1363        let cs: Vec<Challenge> = ["L1", "L2"].iter().map(|id| Challenge { id: id.to_string(), origin_ms: 1, ..Default::default() }).collect();
1364        same(&lessons, &cs, &load(&lessons), "i64::MAX");
1365        assert_eq!(load(&lessons).view(false).folded.clock.now(None), 3, "three sessions tick");
1366        let _ = std::fs::remove_dir_all(d);
1367    }
1368
1369    /// A snapshot or regrets file of another version's rules, or a regrets file of another generation
1370    /// (a concurrent hook's), is folded afresh.
1371    #[test]
1372    fn another_version_or_generation_folds_afresh() {
1373        let d = dir("version");
1374        let lessons = d.join("lessons.jsonl");
1375        let (pl, pt) = (outcome::path_beside(&lessons), d.join(outcome::TEAM_FILE));
1376        let (local, team, cs) = (0..20).map(logs).find(|(l, _, _)| l.contains("\"regretted\"")).expect("a store with regrets");
1377        std::fs::write(&pl, &local).unwrap();
1378        std::fs::write(&pt, &team).unwrap();
1379        let cold = load(&lessons);
1380        // The same snapshot, sealed under other rules.
1381        let other = |b: &[u8]| {
1382            let mut body = b[..b.len() - 32].to_vec();
1383            body[8] ^= 1;
1384            W(body).seal()
1385        };
1386        let snap = std::fs::read(path_beside(&lessons)).unwrap();
1387        assert!(decode(&snap).is_some() && decode(&other(&snap)).is_none());
1388        let regrets = std::fs::read(regrets_beside(&lessons)).unwrap();
1389        let n = cold.strings.len();
1390        assert!(decode_regrets(&regrets, cold.generation, n).is_some() && decode_regrets(&other(&regrets), cold.generation, n).is_none());
1391        std::fs::write(path_beside(&lessons), other(&snap)).unwrap();
1392        PARSED.with(|c| c.set(0));
1393        same(&lessons, &cs, &load(&lessons), "other rules");
1394        assert_eq!(PARSED.with(|c| c.replace(0)), local.lines().count() + team.lines().count(), "folded afresh");
1395        // A regrets file of another generation, met by a regret line: void, folded afresh.
1396        let saved = decode(&std::fs::read(path_beside(&lessons)).unwrap()).unwrap();
1397        let mut theirs = saved.clone();
1398        theirs.regrets_file = Some(regrets_beside(&lessons));
1399        let r = theirs.regrets().cloned().unwrap();
1400        std::fs::write(regrets_beside(&lessons), encode_regrets(&r, saved.generation ^ 1)).unwrap();
1401        let regret = local.lines().find(|l| l.contains("\"regretted\"")).unwrap();
1402        use std::io::Write;
1403        std::fs::OpenOptions::new().append(true).open(&pl).unwrap().write_all(format!("{regret}\n").as_bytes()).unwrap();
1404        same(&lessons, &cs, &load(&lessons), "other generation");
1405        assert_eq!(PARSED.with(|c| c.replace(0)), 1 + local.lines().count() + 1 + team.lines().count(), "its line, then all afresh");
1406        same(&lessons, &cs, &load(&lessons), "warm after");
1407        let _ = std::fs::remove_dir_all(d);
1408    }
1409
1410    /// Nothing written beside a lessons file without an outcome log (an eval's challenge file).
1411    #[test]
1412    fn no_log_no_snapshot() {
1413        let d = dir("none");
1414        let lessons = d.join("lessons.jsonl");
1415        assert_eq!(load(&lessons), Facts::default());
1416        rebuild(&lessons);
1417        assert!(!path_beside(&lessons).exists());
1418        let _ = std::fs::remove_dir_all(d);
1419    }
1420
1421    /// On a real store (an ignored test, it reads files outside the repo): BORG_FACTS_STORE, a copy of a
1422    /// store dir (it writes the snapshot there), and BORG_FACTS_SESSIONS, a comma-separated list of
1423    /// sessions. Equal cold, warm and round-tripped, with the team's outcomes on and off.
1424    #[test]
1425    #[ignore]
1426    fn the_facts_equal_a_fresh_derive_on_a_real_store() {
1427        let dir = PathBuf::from(std::env::var_os("BORG_FACTS_STORE").expect("set BORG_FACTS_STORE"));
1428        let lessons = dir.join("lessons.jsonl");
1429        let cs = crate::conscience::load_store_unweighted(&lessons, None);
1430        let sessions = std::env::var("BORG_FACTS_SESSIONS").unwrap_or_default();
1431        let t = std::time::Instant::now();
1432        let cold = load(&lessons);
1433        eprintln!("cold {:?}", t.elapsed());
1434        let t = std::time::Instant::now();
1435        let warm = load(&lessons);
1436        eprintln!("warm {:?}, {} bytes", t.elapsed(), encode(&warm).len());
1437        assert_eq!(decode(&encode(&warm)).as_ref(), Some(&warm));
1438        for on in [true, false] {
1439            let outcomes = outcome::team_view(&lessons, on);
1440            for session in sessions.split(',').filter(|s| !s.is_empty()).map(Some).chain([None, Some("a-new-session")]) {
1441                for facts in [&cold, &warm] {
1442                    let want = memory::derive(&outcomes, &cs, session);
1443                    let got = memory::derive_folded(facts.view(on).folded, &cs, session);
1444                    assert_eq!(want.len(), got.len());
1445                    for (id, a) in &want {
1446                        let b = &got[id];
1447                        assert_eq!(
1448                            (a.changed, a.ignored, a.corrected, a.vetoed, a.tested, a.consolidated, a.dormant),
1449                            (b.changed, b.ignored, b.corrected, b.vetoed, b.tested, b.consolidated, b.dormant),
1450                            "{id}"
1451                        );
1452                        assert!((a.activation - b.activation).abs() < 1e-9 && (a.regretted - b.regretted).abs() < 1e-9, "{id}: {a:?} != {b:?}");
1453                    }
1454                }
1455                eprintln!("on={on} {session:?}: {} lessons equal", cs.len());
1456            }
1457        }
1458        same(&lessons, &cs, &warm, "real store");
1459    }
1460}