Skip to main content

borg/
trust.rs

1//! Trust per engineer × area, earned from outcomes (PLAN.md "Assimilation", step 4).
2//!
3//! Corrections are claims, not facts: engineers reverse themselves (in one real case the same day),
4//! and weak engineers exist. So a lesson's pull on the conscience depends on how its authors' earlier
5//! claims in the same area held up. Everything here is deterministic, costs 0 tokens and reads only
6//! the routed collective's own store (`lessons.jsonl` and the `outcomes.jsonl` beside it): a private
7//! collective's evidence never moves trust in a work one, and an off repo has none.
8//!
9//! **Local and shared evidence**: `followed` and `corrected` come from this machine's `outcomes.jsonl`
10//! plus the teammates' results the mesh syncs as `outcomes-team.jsonl` (outcome.rs `read_team`: hashed
11//! sessions, each counted once), unless BORG_SHARE_OUTCOMES=off; `superseded` comes from the lessons and
12//! is shared. Teammates converge on the same trust once synced; results not yet synced, or kept local
13//! with BORG_SHARE_OUTCOMES=off, still make it differ, and `borg trust` says so.
14//!
15//! **Engineers** are canonical (collective.rs "Engineers"): emails compare case-insensitively and
16//! the emails an `engineers` entry lists are one engineer, mapped when lessons are read. The
17//! aliases are this machine's config, so a peer without them splits that engineer's trust again.
18//!
19//! **Area**: every lesson carries one of [`AREAS`], set by the same model call that generalises its
20//! episode (learn.rs); lessons from before areas existed, or with an unknown area, are "other".
21//!
22//! **Evidence** per (engineer, area): for each lesson, each of its engineers (git user.email
23//! recorded at capture), in the lesson's area, gets (an engineer who joined the lesson by a later
24//! merge only from sessions after they joined, so merging into a well-followed lesson inherits none
25//! of its earned trust)
26//! - `followed`, +1 per session: the conscience asked it and no correction of the same concern
27//!   followed (outcome.rs `asked_not_corrected`);
28//! - `corrected`, −1 per session: asked, and the engineer still corrected the same concern
29//!   (`asked_then_corrected`): the claim did not change what the drone did;
30//! - `superseded`, −2 once, when the supersession is settled: a later lesson replaced or contradicted
31//!   it (`supersedes`) and is the same engineer reversing himself, or a queen answer, or the
32//!   successor has been followed at least once. An unsettled contradiction by another engineer is a
33//!   claim against a claim and costs nobody trust yet.
34//!
35//! **Formula** (Laplace's rule of succession, a Beta(1, 1) prior):
36//!
37//! trust = (1 + followed) / (2 + followed + corrected + 2 · superseded)
38//!
39//! No evidence gives the neutral 0.5; the value stays strictly inside (0, 1), so one event can never
40//! make an engineer infallible or silence them. A superseded claim counts as two failures: being
41//! reversed is direct evidence the claim was wrong, while a correction after asking may also mean
42//! the question was too generic. One followed session gives 2/3, one corrected 1/3, one reversal 1/4.
43//!
44//! A lesson's trust is the mean of its authors' trust in its area (0.5 without known authors). The
45//! conscience multiplies BM25 relevance by trust / 0.5 on top of the outcome weight. A superseded
46//! lesson is hidden ([`successors`]) on a self-reversal, a queen answer, or when its successor is at
47//! least as trusted; the successor inherits its relevance. Otherwise both stay live and the
48//! conscience renders them as one disagreement with each side's trust.
49
50use std::collections::{BTreeMap, HashMap};
51use std::path::Path;
52
53use crate::conscience::{self, Challenge};
54use crate::outcome::{self, Outcome};
55use crate::{collective, learn};
56
57/// The kinds of concern a lesson can be about.
58pub const AREAS: [&str; 12] = [
59    "concurrency",
60    "spec-conformance",
61    "reuse-existing",
62    "architecture-boundary",
63    "verification",
64    "minimality",
65    "protocol-correctness",
66    "data-source",
67    "security",
68    "dependency-version",
69    "scope",
70    OTHER,
71];
72
73pub const OTHER: &str = "other";
74
75/// Baseline: trust without evidence, the middle of [0, 1].
76pub const NEUTRAL: f64 = 0.5;
77
78/// The area `raw` names (case, spaces and underscores ignored), "other" for anything else.
79pub fn area(raw: &str) -> &'static str {
80    let a = raw.trim().to_lowercase().replace(['_', ' '], "-");
81    AREAS.iter().find(|x| **x == a).copied().unwrap_or(OTHER)
82}
83
84/// What one engineer's claims in one area led to.
85#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
86pub struct Evidence {
87    pub followed: u32,
88    pub corrected: u32,
89    pub superseded: u32,
90}
91
92impl Evidence {
93    /// (1 + followed) / (2 + followed + corrected + 2 · superseded); see the module docs.
94    pub fn trust(&self) -> f64 {
95        let (f, c, s) = (self.followed as f64, self.corrected as f64, self.superseded as f64);
96        (1.0 + f) / (2.0 + f + c + 2.0 * s)
97    }
98
99    fn add(&mut self, o: Evidence) {
100        self.followed += o.followed;
101        self.corrected += o.corrected;
102        self.superseded += o.superseded;
103    }
104}
105
106/// Trust of every (engineer, area) that authored a lesson in one store.
107#[derive(Debug, Default)]
108pub struct Trust {
109    evidence: BTreeMap<(String, String), Evidence>,
110}
111
112impl Trust {
113    /// From a store's lessons (latest versions) and its outcomes.
114    pub fn compute(lessons: &[Challenge], outcomes: &[Outcome]) -> Trust {
115        let sessions = outcome::sessions(outcomes);
116        let followed = |id: &str| sessions.get(id).is_some_and(|ss| outcome::tally(ss.values(), i64::MIN).0 > 0);
117        let replaced: std::collections::HashSet<&str> =
118            edges(lessons).into_iter().filter(|(x, y)| decisive(x, y) || followed(&x.id)).map(|(_, y)| y.id.as_str()).collect();
119        let mut evidence: BTreeMap<(String, String), Evidence> = BTreeMap::new();
120        for c in lessons {
121            let superseded = u32::from(replaced.contains(c.id.as_str()));
122            let mut authors: Vec<&str> = c.engineers.iter().map(String::as_str).filter(|x| !x.is_empty()).collect();
123            authors.sort();
124            authors.dedup();
125            for a in authors {
126                // Joined by a later merge: only sessions after that count (legacy lessons: all).
127                let since = c.joined.get(a).copied().unwrap_or(i64::MIN);
128                let (followed, corrected) = sessions.get(c.id.as_str()).map_or((0, 0), |ss| outcome::tally(ss.values(), since));
129                let e = Evidence { followed, corrected, superseded };
130                evidence.entry((a.to_string(), area(&c.area).to_string())).or_default().add(e);
131            }
132        }
133        Trust { evidence }
134    }
135
136    /// `engineer`'s trust in `area`; neutral when they have no lesson there.
137    pub fn of(&self, engineer: &str, area_name: &str) -> f64 {
138        self.evidence.get(&(engineer.to_string(), area(area_name).to_string())).map_or(NEUTRAL, Evidence::trust)
139    }
140
141    /// Mean trust of a lesson's authors in its area; neutral without known authors.
142    pub fn lesson(&self, c: &Challenge) -> f64 {
143        let authors: Vec<&String> = c.engineers.iter().filter(|e| !e.is_empty()).collect();
144        match authors.len() {
145            0 => NEUTRAL,
146            n => authors.iter().map(|e| self.of(e, &c.area)).sum::<f64>() / n as f64,
147        }
148    }
149
150    /// Every (engineer, area) with its evidence, sorted by engineer then area.
151    pub fn rows(&self) -> impl Iterator<Item = (&str, &str, Evidence)> {
152        self.evidence.iter().map(|((e, a), x)| (e.as_str(), a.as_str(), *x))
153    }
154
155    /// Whether any (engineer, area) has evidence beyond the neutral prior.
156    pub fn has_evidence(&self) -> bool {
157        self.evidence.values().any(|e| *e != Evidence::default())
158    }
159}
160
161/// The trust of the store at `home` (its lessons and outcomes, nothing else), engineers under this
162/// machine's aliases.
163pub fn of_store(home: &Path) -> Trust {
164    of_store_with(home, collective::aliases())
165}
166
167/// `of_store` under explicit aliases.
168pub fn of_store_with(home: &Path, aliases: &collective::Aliases) -> Trust {
169    let lessons = home.join("lessons.jsonl");
170    Trust::compute(&conscience::load_unweighted_with(&lessons, None, aliases), &outcome::read_team(&lessons))
171}
172
173/// Valid supersession edges (successor, replaced). "X supersedes Y" counts only if Y does not itself
174/// (transitively) supersede X: in a cycle the collective disagrees with itself, so nobody's claim is
175/// hidden or penalised and the conscience renders the disagreement instead. Valid edges are acyclic.
176fn edges(lessons: &[Challenge]) -> Vec<(&Challenge, &Challenge)> {
177    if lessons.iter().all(|c| c.supersedes.is_empty()) {
178        return Vec::new();
179    }
180    let by: HashMap<&str, &Challenge> = lessons.iter().map(|c| (c.id.as_str(), c)).collect();
181    let declared: HashMap<&str, Vec<&str>> =
182        lessons.iter().map(|c| (c.id.as_str(), c.supersedes.iter().map(String::as_str).filter(|y| *y != c.id && by.contains_key(y)).collect())).collect();
183    let reaches = |from: &str, to: &str| -> bool {
184        let mut stack = vec![from];
185        let mut seen = std::collections::HashSet::new();
186        while let Some(x) = stack.pop() {
187            if x == to {
188                return true;
189            }
190            if seen.insert(x) {
191                stack.extend(declared.get(x).into_iter().flatten().copied());
192            }
193        }
194        false
195    };
196    lessons.iter().flat_map(|x| declared[x.id.as_str()].iter().filter(|y| !reaches(y, &x.id)).map(|y| (x, by[y])).collect::<Vec<_>>()).collect()
197}
198
199/// A supersession that settles the question by itself: an author reversing their own lesson, or a
200/// queen answer (the engineer asked decided).
201fn decisive(x: &Challenge, y: &Challenge) -> bool {
202    x.source == "queen" || x.engineers.iter().any(|e| !e.is_empty() && y.engineers.contains(e))
203}
204
205/// Every hidden lesson's live successor (the end of its chain of hiding supersessions): hidden
206/// lessons are not asked, the successor is asked in their place. X hides Y when the supersession is
207/// `decisive` or X's trust (`Challenge::trust`) is at least Y's; otherwise both stay live and the
208/// conscience renders them as a disagreement. Per hidden lesson the newest successor wins.
209pub fn successors(lessons: &[Challenge]) -> HashMap<String, String> {
210    let mut direct: HashMap<&str, &Challenge> = HashMap::new();
211    for (x, y) in edges(lessons) {
212        if !(decisive(x, y) || x.trust >= y.trust) {
213            continue;
214        }
215        let newer = |o: &&Challenge| (x.origin_ms, x.id.as_str()) > (o.origin_ms, o.id.as_str());
216        if direct.get(y.id.as_str()).is_none_or(newer) {
217            direct.insert(&y.id, x);
218        }
219    }
220    direct
221        .keys()
222        .map(|&y| {
223            let mut s = direct[y];
224            for _ in 0..lessons.len() {
225                match direct.get(s.id.as_str()) {
226                    Some(next) => s = next,
227                    None => break,
228                }
229            }
230            (y.to_string(), s.id.clone())
231        })
232        .collect()
233}
234
235/// One line for `borg status`.
236pub fn summary(home: &Path) -> String {
237    summary_with(home, collective::aliases())
238}
239
240/// `summary` under explicit aliases.
241pub fn summary_with(home: &Path, aliases: &collective::Aliases) -> String {
242    let t = of_store_with(home, aliases);
243    let mut rows: Vec<(&str, &str, Evidence)> = t.rows().filter(|(_, _, e)| *e != Evidence::default()).collect();
244    if rows.is_empty() {
245        let engineers: std::collections::BTreeSet<&str> = t.rows().map(|(e, _, _)| e).collect();
246        return format!("no evidence yet ({} engineers neutral at 0.50)", engineers.len());
247    }
248    rows.sort_by(|a, b| b.2.trust().total_cmp(&a.2.trust()).then(a.0.cmp(b.0)).then(a.1.cmp(b.1)));
249    let (top, low) = (rows[0], rows[rows.len() - 1]);
250    let mut s = format!("highest {} in {} {:.2}", top.0, top.1, top.2.trust());
251    if rows.len() > 1 {
252        s.push_str(&format!(", lowest {} in {} {:.2}", low.0, low.1, low.2.trust()));
253    }
254    format!("{s} ({} with evidence; borg trust)", rows.len())
255}
256
257/// The table `borg trust` prints for one store.
258pub fn table(t: &Trust) -> String {
259    let rows: Vec<(&str, &str, Evidence)> = t.rows().collect();
260    if rows.is_empty() {
261        return "no lessons with known engineers yet\n".into();
262    }
263    let w = rows.iter().map(|r| r.0.len()).max().unwrap_or(0).max("engineer".len());
264    let a = AREAS.iter().map(|x| x.len()).max().unwrap_or(0);
265    let mut s = format!("{:<w$}  {:<a$}  trust  followed  corrected  superseded\n", "engineer", "area");
266    for (e, ar, x) in rows {
267        s.push_str(&format!("{e:<w$}  {ar:<a$}  {:.2}   {:>8}  {:>9}  {:>10}\n", x.trust(), x.followed, x.corrected, x.superseded));
268    }
269    s
270}
271
272/// `borg trust [--dir DIR]`: trust per engineer × area of the collective DIR (default: the current
273/// directory) routes to. Off and invalid configs show nothing.
274pub fn main(args: &[&str]) -> i32 {
275    let mut cwd = std::env::current_dir().unwrap_or_default().display().to_string();
276    let mut it = args.iter();
277    while let Some(a) = it.next() {
278        match (*a, it.next()) {
279            ("--dir", Some(d)) => cwd = d.to_string(),
280            _ => {
281                eprintln!("usage: borg trust [--dir DIR]");
282                return 2;
283            }
284        }
285    }
286    match collective::load().map(|cfg| collective::route(cfg.as_ref(), &cwd, &learn::home())) {
287        Ok(collective::Route::Collective { name, home, .. }) => {
288            println!("collective {name} ({})", home.display());
289            println!("trust = (1 + followed) / (2 + followed + corrected + 2 x superseded); 0.50 = no evidence");
290            match crate::outcome::on() {
291                true => println!("followed/corrected: sessions on this machine and the team's synced outcomes; superseded: shared lessons\n"),
292                false => println!("followed/corrected: sessions on this machine only (BORG_SHARE_OUTCOMES=off); superseded: shared lessons\n"),
293            }
294            print!("{}", table(&of_store(&home)));
295            0
296        }
297        Ok(collective::Route::Off) => {
298            println!("off: {cwd} belongs to no collective; no trust is kept here");
299            0
300        }
301        Err(e) => {
302            eprintln!("borg trust: invalid collectives config ({e}); everything is off");
303            1
304        }
305    }
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311    use crate::outcome::{ASKED, CORRECTED, NOT_CORRECTED};
312
313    fn lesson(id: &str, ms: i64, area: &str, engineers: &[&str], supersedes: &[&str]) -> Challenge {
314        Challenge {
315            id: id.into(),
316            origin_ms: ms,
317            question: format!("question {id}"),
318            area: area.into(),
319            engineers: engineers.iter().map(|e| e.to_string()).collect(),
320            supersedes: supersedes.iter().map(|e| e.to_string()).collect(),
321            ..Default::default()
322        }
323    }
324
325    fn o(kind: &str, session: &str, id: &str) -> Outcome {
326        Outcome { kind: kind.into(), session: session.into(), lessons: vec![id.into()], ms: 1, repo: "r".into(), ..Default::default() }
327    }
328
329    #[test]
330    fn area_parsing_and_fallback() {
331        assert_eq!(area("concurrency"), "concurrency");
332        assert_eq!(area(" Spec_Conformance "), "spec-conformance");
333        assert_eq!(area("reuse existing"), "reuse-existing");
334        assert_eq!(area(""), OTHER);
335        assert_eq!(area("vibes"), OTHER);
336        // A stored lesson without an area reads as "other".
337        let c: Challenge = serde_json::from_str(r#"{"id":"a","origin_ms":1,"question":"q"}"#).unwrap();
338        assert_eq!(area(&c.area), OTHER);
339    }
340
341    #[test]
342    fn neutral_without_evidence_and_laplace_smoothing() {
343        assert_eq!(Evidence::default().trust(), NEUTRAL);
344        let t = Trust::compute(&[lesson("a", 1, "concurrency", &["ann@x"], &[])], &[]);
345        assert_eq!(t.of("ann@x", "concurrency"), NEUTRAL);
346        assert_eq!(t.of("nobody@x", "security"), NEUTRAL);
347        assert!(!t.has_evidence());
348        let e = |f, c, s| Evidence { followed: f, corrected: c, superseded: s }.trust();
349        assert!((e(1, 0, 0) - 2.0 / 3.0).abs() < 1e-12);
350        assert!((e(0, 1, 0) - 1.0 / 3.0).abs() < 1e-12);
351        assert_eq!(e(0, 0, 1), 0.25);
352        assert!(e(1000, 0, 0) < 1.0 && e(0, 1000, 1000) > 0.0, "strictly inside (0, 1)");
353    }
354
355    #[test]
356    fn followed_raises_trust_in_that_area_only() {
357        let ls = [lesson("a", 1, "concurrency", &["ann@x", "bob@x"], &[])];
358        let outs = [o(ASKED, "s1", "a"), o(NOT_CORRECTED, "s1", "a"), o(ASKED, "s2", "a"), o(NOT_CORRECTED, "s2", "a")];
359        let t = Trust::compute(&ls, &outs);
360        assert_eq!(t.of("ann@x", "concurrency"), 0.75);
361        assert_eq!(t.of("bob@x", "concurrency"), 0.75);
362        assert_eq!(t.of("ann@x", "security"), NEUTRAL);
363    }
364
365    /// A placebo's results (outcome.rs `PLACEBO`, judged blindly, never delivered) and unclear verdicts
366    /// are no claim followed or corrected: trust is the same without them.
367    #[test]
368    fn a_placebo_or_unclear_result_moves_no_trust() {
369        let ls = [lesson("a", 1, "concurrency", &["ann@x"], &[])];
370        let outs = [o(ASKED, "s1", "a"), o(NOT_CORRECTED, "s1", "a")];
371        let base = Trust::compute(&ls, &outs);
372        let placebo = |kind: &str| Outcome { channel: crate::outcome::PLACEBO.into(), ..o(kind, "s2", "a") };
373        let mut more = outs.to_vec();
374        more.extend([placebo(CORRECTED), placebo(NOT_CORRECTED), placebo(crate::outcome::CHANGED), o(crate::outcome::UNCLEAR, "s3", "a")]);
375        assert_eq!(Trust::compute(&ls, &more).of("ann@x", "concurrency"), base.of("ann@x", "concurrency"));
376        assert_eq!(base.of("ann@x", "concurrency"), 2.0 / 3.0);
377    }
378
379    #[test]
380    fn asked_and_still_corrected_lowers_trust() {
381        let ls = [lesson("a", 1, "scope", &["ann@x"], &[])];
382        // Corrected in s1 (counts once despite the later settle), followed in s2.
383        let outs = [o(ASKED, "s1", "a"), o(CORRECTED, "s1", "a"), o(NOT_CORRECTED, "s1", "a"), o(ASKED, "s2", "a"), o(NOT_CORRECTED, "s2", "a")];
384        let t = Trust::compute(&ls, &outs);
385        assert_eq!(t.rows().next().unwrap().2, Evidence { followed: 1, corrected: 1, superseded: 0 });
386        assert_eq!(t.of("ann@x", "scope"), 0.5);
387        let t = Trust::compute(&ls, &[o(CORRECTED, "s1", "a")]);
388        assert!((t.of("ann@x", "scope") - 1.0 / 3.0).abs() < 1e-12);
389    }
390
391    #[test]
392    fn superseded_lowers_its_authors_even_when_they_reverse_themselves() {
393        // bob's lesson is replaced by ann's; ann later reverses her own lesson in another area.
394        let ls = [
395            lesson("b1", 1, "concurrency", &["bob@x"], &[]),
396            lesson("a1", 2, "concurrency", &["ann@x"], &["b1"]),
397            lesson("a2", 3, "security", &["ann@x"], &[]),
398            lesson("a3", 4, "security", &["ann@x"], &["a2"]),
399        ];
400        let t = Trust::compute(&ls, &[]);
401        assert_eq!(t.of("bob@x", "concurrency"), NEUTRAL, "another engineer's contradiction is unsettled: no penalty yet");
402        assert_eq!(t.of("ann@x", "security"), 0.25, "a self-reversal counts against the reversed lesson's author at once");
403        // Once ann's successor is followed, the supersession is settled and bob pays for it.
404        let t = Trust::compute(&ls, &[o(NOT_CORRECTED, "s1", "a1")]);
405        assert_eq!(t.of("bob@x", "concurrency"), 0.25);
406        assert!((t.of("ann@x", "concurrency") - 2.0 / 3.0).abs() < 1e-12, "replacing a claim is no evidence for the replacer, being followed is");
407        assert_eq!(successors(&ls), HashMap::from([("b1".to_string(), "a1".to_string()), ("a2".to_string(), "a3".to_string())]));
408    }
409
410    #[test]
411    fn chains_resolve_and_cycles_hide_nobody() {
412        let chain = [lesson("a", 1, "", &["x@x"], &[]), lesson("b", 2, "", &["y@x"], &["a"]), lesson("c", 3, "", &["z@x"], &["b"])];
413        let s = successors(&chain);
414        assert_eq!((s["a"].as_str(), s["b"].as_str(), s.len()), ("c", "c", 2));
415        // Mutual supersession: unresolved disagreement, nobody is hidden or penalised.
416        let cycle = [lesson("a", 1, "", &["x@x"], &["b"]), lesson("b", 2, "", &["y@x"], &["a"]), lesson("c", 3, "", &[], &["missing", "c"])];
417        assert!(successors(&cycle).is_empty());
418        assert!(!Trust::compute(&cycle, &[]).has_evidence());
419    }
420
421    #[test]
422    fn a_later_coauthor_earns_only_from_sessions_after_joining() {
423        // ann's lesson was followed in 20 sessions; bob merged an agreeing correction in at ms 100.
424        let mut l = lesson("a", 1, "concurrency", &["ann@x", "bob@x"], &[]);
425        l.joined.insert("bob@x".into(), 100);
426        let mut outs: Vec<Outcome> = (0..20).map(|i| Outcome { ms: 10 + i, ..o(NOT_CORRECTED, &format!("s{i}"), "a") }).collect();
427        // His own correction marked the lesson corrected at the moment he joined: not his evidence either.
428        outs.push(Outcome { ms: 100, ..o(CORRECTED, "j", "a") });
429        let t = Trust::compute(std::slice::from_ref(&l), &outs);
430        assert!((t.of("ann@x", "concurrency") - 21.0 / 23.0).abs() < 1e-12);
431        assert_eq!(t.of("bob@x", "concurrency"), NEUTRAL, "no inherited trust");
432        outs.push(Outcome { ms: 200, ..o(NOT_CORRECTED, "later", "a") });
433        assert!((Trust::compute(&[l], &outs).of("bob@x", "concurrency") - 2.0 / 3.0).abs() < 1e-12);
434    }
435
436    #[test]
437    fn a_less_trusted_contradiction_hides_nothing_self_reversals_and_queen_answers_do() {
438        let mut ann = lesson("ann", 1, "concurrency", &["ann@x"], &[]);
439        let mut bob = lesson("bob", 2, "concurrency", &["bob@x"], &["ann"]);
440        (ann.trust, bob.trust) = (0.9, 0.2);
441        assert!(successors(&[ann.clone(), bob.clone()]).is_empty(), "both stay live");
442        bob.trust = 0.9;
443        assert_eq!(successors(&[ann.clone(), bob.clone()])["ann"], "bob", "at least as trusted: hides");
444        bob.trust = 0.2;
445        let mut queen = lesson("q", 3, "concurrency", &["lead@x"], &["ann"]);
446        (queen.source, queen.trust) = ("queen".into(), 0.1);
447        assert_eq!(successors(&[ann.clone(), queen.clone()])["ann"], "q");
448        let own = Challenge { trust: 0.1, ..lesson("own", 3, "concurrency", &["ann@x"], &["ann"]) };
449        assert_eq!(successors(&[ann.clone(), own])["ann"], "own");
450        // The queen answer settles the penalty without any outcome.
451        assert_eq!(Trust::compute(&[ann, queen], &[]).of("ann@x", "concurrency"), 0.25);
452    }
453
454    #[test]
455    fn lesson_trust_is_the_mean_of_its_authors_in_its_area() {
456        let ls = [lesson("a", 1, "concurrency", &["ann@x"], &[]), lesson("b", 2, "concurrency", &["bob@x"], &[])];
457        let t = Trust::compute(&ls, &[o(NOT_CORRECTED, "s", "a"), o(CORRECTED, "s", "b")]);
458        let both = lesson("c", 3, "concurrency", &["ann@x", "bob@x"], &[]);
459        assert!((t.lesson(&both) - (2.0 / 3.0 + 1.0 / 3.0) / 2.0).abs() < 1e-12);
460        assert_eq!(t.lesson(&lesson("d", 4, "concurrency", &[], &[])), NEUTRAL);
461        assert_eq!(t.lesson(&lesson("e", 4, "security", &["ann@x"], &[])), NEUTRAL);
462    }
463
464    fn store(name: &str, lessons: &[serde_json::Value], outcomes: &[Outcome]) -> std::path::PathBuf {
465        let dir = std::env::temp_dir().join(format!("borg-trust-{name}-{}", std::process::id()));
466        let _ = std::fs::remove_dir_all(&dir);
467        std::fs::create_dir_all(&dir).unwrap();
468        std::fs::write(dir.join("lessons.jsonl"), lessons.iter().map(|l| format!("{l}\n")).collect::<String>()).unwrap();
469        for x in outcomes {
470            outcome::append(&dir.join("outcomes.jsonl"), x).unwrap();
471        }
472        dir
473    }
474
475    fn dana() -> collective::Aliases {
476        let cfg: collective::Config =
477            serde_json::from_str(r#"{"engineers": [{"name": "dana", "emails": ["Dana.Example@corp.example", "da@home.example"]}]}"#).unwrap();
478        cfg.aliases()
479    }
480
481    #[test]
482    fn an_engineers_emails_share_one_trust() {
483        use serde_json::json;
484        let l = |id: &str, e: &[&str]| json!({"id": id, "origin_ms": 1, "question": "q", "area": "concurrency", "engineers": e});
485        let dir = store(
486            "aliases",
487            &[l("a", &["Dana.Example@corp.example"]), l("b", &["da@home.example"]), l("c", &["ann@x"])],
488            &[o(NOT_CORRECTED, "s1", "a"), o(NOT_CORRECTED, "s2", "b"), o(CORRECTED, "s3", "c")],
489        );
490        // Absent config: unchanged, two engineers with one followed session each.
491        let none = collective::Aliases::default();
492        let t = of_store_with(&dir, &none);
493        let rows: Vec<(&str, Evidence)> = t.rows().map(|(e, _, x)| (e, x)).collect();
494        assert_eq!(rows.len(), 3, "{rows:?}");
495        assert!((t.of("da@home.example", "concurrency") - 2.0 / 3.0).abs() < 1e-12);
496        assert_eq!(summary_with(&dir, &none), "highest da@home.example in concurrency 0.67, lowest ann@x in concurrency 0.33 (3 with evidence; borg trust)");
497        // Listed as one engineer: one row, both sessions.
498        let t = of_store_with(&dir, &dana());
499        assert_eq!(t.rows().map(|(e, _, x)| (e.to_string(), x.followed)).collect::<Vec<_>>(), [("ann@x".into(), 0), ("dana".to_string(), 2)]);
500        assert_eq!(t.of("dana", "concurrency"), 0.75);
501        assert!(table(&t).contains("dana") && !table(&t).contains("corp.example"));
502        let _ = std::fs::remove_dir_all(dir);
503    }
504
505    #[test]
506    fn status_counts_an_engineers_emails_once() {
507        use serde_json::json;
508        let l = |id: &str, e: &[&str]| json!({"id": id, "origin_ms": 1, "question": "q", "engineers": e});
509        let dir = store("status", &[l("a", &["Dana.Example@corp.example"]), l("b", &["da@home.example", "DA@home.example"])], &[]);
510        assert_eq!(summary_with(&dir, &collective::Aliases::default()), "no evidence yet (2 engineers neutral at 0.50)", "case alone never splits");
511        assert_eq!(summary_with(&dir, &dana()), "no evidence yet (1 engineers neutral at 0.50)");
512        let _ = std::fs::remove_dir_all(dir);
513    }
514
515    #[test]
516    fn aliases_settle_self_reversals_and_joins() {
517        use serde_json::json;
518        // dana reverses her own lesson from her other email: settled at once, like any self-reversal.
519        let dir = store(
520            "reversal",
521            &[
522                json!({"id": "a", "origin_ms": 1, "question": "q", "area": "security", "engineers": ["Dana.Example@corp.example"]}),
523                json!({"id": "b", "origin_ms": 2, "question": "q", "area": "security", "engineers": ["da@home.example"], "supersedes": ["a"]}),
524                // His other email merged into his own lesson later: still the first contributor.
525                json!({"id": "c", "origin_ms": 3, "question": "q", "area": "scope", "engineers": ["Dana.Example@corp.example", "da@home.example"],
526                    "joined": {"da@home.example": 100}}),
527            ],
528            &[Outcome { ms: 10, ..o(NOT_CORRECTED, "early", "c") }],
529        );
530        let t = of_store_with(&dir, &collective::Aliases::default());
531        assert_eq!(t.of("Dana.Example@corp.example".to_lowercase().as_str(), "security"), NEUTRAL, "two engineers: an unsettled contradiction");
532        assert_eq!(t.of("da@home.example", "scope"), NEUTRAL, "joined after the session");
533        let t = of_store_with(&dir, &dana());
534        assert_eq!(t.of("dana", "security"), 0.25, "one engineer reversing himself");
535        assert!((t.of("dana", "scope") - 2.0 / 3.0).abs() < 1e-12, "the session counts: he was there from the start");
536        let lessons = conscience::load_with(&dir.join("lessons.jsonl"), None, &dana());
537        assert_eq!(lessons[2].engineers, ["dana"]);
538        assert!(lessons[2].joined.is_empty());
539        let _ = std::fs::remove_dir_all(dir);
540    }
541
542    #[test]
543    fn table_and_summary_read_one_store() {
544        let dir = std::env::temp_dir().join(format!("borg-trust-table-{}", std::process::id()));
545        let _ = std::fs::remove_dir_all(&dir);
546        std::fs::create_dir_all(&dir).unwrap();
547        assert!(summary(&dir).starts_with("no evidence yet (0 engineers"));
548        let line =
549            |id: &str, area: &str, e: &str| format!("{}\n", serde_json::json!({"id": id, "origin_ms": 1, "question": "q", "area": area, "engineers": [e]}));
550        std::fs::write(dir.join("lessons.jsonl"), line("a", "concurrency", "ann@x") + &line("b", "scope", "bob@x")).unwrap();
551        for x in [o(NOT_CORRECTED, "s", "a"), o(CORRECTED, "s", "b")] {
552            outcome::append(&dir.join("outcomes.jsonl"), &x).unwrap();
553        }
554        let t = of_store(&dir);
555        let text = table(&t);
556        assert!(text.lines().nth(1).unwrap().starts_with("ann@x     concurrency"), "{text}");
557        assert!(text.contains("0.67") && text.contains("0.33"), "{text}");
558        assert_eq!(summary(&dir), "highest ann@x in concurrency 0.67, lowest bob@x in scope 0.33 (2 with evidence; borg trust)");
559        let _ = std::fs::remove_dir_all(dir);
560    }
561}