Skip to main content

borg/
outcome.rs

1//! What happened after the conscience asked a lesson: the signal for down-weighting lessons
2//! that are generic or do not help, and for trust per engineer × area (trust.rs).
3//!
4//! `$BORG_HOME/outcomes.jsonl` is append-only and deterministic, one record per line:
5//! - `asked`: the conscience asked these lessons at a stop or live, or delivered them as context (early,
6//!   or a graded check's 2s and 1s handed over free; hook, 0 tokens);
7//! - `asked_then_corrected`: after lesson X was asked, the engineer still made a correction in the
8//!   same session that `borg learn` merged into X (same concern): asking did not prevent it;
9//! - `asked_not_corrected`: the session ended, or has been idle for `IDLE_MS`, and no later
10//!   correction was merged into X. The idle rule keeps both kinds coming from every `borg learn`
11//!   run: without it, a session whose `end` hook never ran could only ever add corrected counts.
12//!
13//! A correction that contradicts a lesson asked earlier in the session (`supersedes`) also marks that
14//! lesson `asked_then_corrected`; when the conscience rendered asked lessons as a disagreement (the
15//! `asked` record's `groups`), a correction siding with one of them marks the other sides, not the
16//! one it agreed with (`contradicted`).
17//!
18//! - `asked_then_changed` (hook, no extra call): at a later Stop of the session, the consequence model's
19//!   call (predict.rs) also judged, by meaning, that the drone changed its work on the lesson's point
20//!   and that the change avoids the consequence: a use, the deposit that keeps a lesson active
21//!   (memory.rs). The drone's `<id>: applies|skip` is evidence in that prompt, never a label alone;
22//! - `asked_ignored` (hook): the same judge found the drone changed nothing on that point.
23//! - `vetoed` (`borg no`, veto.rs, 0 tokens): the engineer said no to the session's work; every lesson
24//!   shown in the session before it is credited, the strongest negative result.
25//!
26//! - `regretted` (`borg learn`, 0 tokens): a regret episode (a later fix of a decision, or a review finding;
27//!   learn/regret.rs) generalised into a lesson blames the lessons asked in the deciding session before the
28//!   decision (`regretted`): asking did not prevent it. `credit` carries each lesson's share of the blame;
29//!   memory.rs weighs it below a correction. Deliberately not read by trust.rs (`sessions`), by `weight`
30//!   (the legacy single-number view) or by attention.rs: a regret is about a lesson, not about an
31//!   engineer following it.
32//!
33//! - `tested` (`borg learn`, 0 tokens): the session's own test runs (hook.rs `capture_test`) ended green
34//!   after a lesson was asked as a question (a free delivery is none): a failed run after the ask, then a
35//!   pass, or a pass after the drone changed its work on the lesson's point (`asked_then_changed`); never
36//!   for a question the drone ignored (`asked_then_ignored`). A
37//!   weak credit (memory.rs `TESTED_W`), once per (session, lesson) (`tested`). It is about the work,
38//!   not about an engineer following a lesson: never a use, and not read by trust.rs, `weight` or
39//!   attention.rs.
40//!
41//! An unclear verdict, or a session whose later turn ends never reach the model (the prefilter skipped
42//! them, or BORG_MATCH=bm25), leaves the lesson pending: `settle` turns it into `asked_not_corrected`.
43//!
44//! `asked` and `graded` records carry `p`, each lesson's probability of being asked under the policy in
45//! force at that check (its propensity), for off-policy evaluation of another policy from this log. An
46//! `asked` record from a Thompson slot draw (the bm25 and non-graded paths) also carries that draw's
47//! pool (`pool`, `base`, `scoped`): every competitor with its score before the usefulness factor, so
48//! `borg debug ope` (ope.rs) can recompute the propensity under other belief weights. Local only: the team
49//! projection drops them.
50//!
51//! **Team outcomes.** The raw log stays on this machine (it holds session ids and repos). `publish`
52//! appends a redacted, deterministic projection of the results the team may see (`SHARED_KINDS`) to
53//! `outcomes-team.jsonl` (`TEAM_FILE`), which the mesh syncs (borg-mesh lines.rs `SYNCED`): kind,
54//! lesson ids, time, propensity, channel, the hashed session and repo (`tag`), a regret's hashed cause
55//! and credit, and the node, never raw sessions, repos, causes, groups, grades or text. Only the belief readers opt in to the team view
56//! (`read_team`): memory and trust in the conscience (conscience.rs `load_as`), `borg trust`, `borg
57//! memory` and borg-web's lessons. learn.rs (it would settle remote sessions), attention.rs (labels and
58//! the explore counter are this node's policy), stats.rs (rollups sync their own counts), install.rs and
59//! the hook's pending/judge read this machine's log only. BORG_SHARE_OUTCOMES=off publishes and reads
60//! nothing of the team's.
61//!
62//! A session counts once per lesson. If a resumed session gets both results, corrected wins.
63//! memory.rs derives activation and the usefulness belief from these results; `weight` below is the
64//! earlier, single-number view (kept for reporting and for comparison in the R1 replay).
65
66use std::collections::HashMap;
67use std::path::Path;
68
69use serde::{Deserialize, Serialize};
70
71pub const ASKED: &str = "asked";
72pub const CORRECTED: &str = "asked_then_corrected";
73pub const NOT_CORRECTED: &str = "asked_not_corrected";
74pub const CHANGED: &str = "asked_then_changed";
75pub const IGNORED: &str = "asked_ignored";
76/// One graded check: every lesson the model graded as applying (`lessons`, in its order, with
77/// `grades` and `ranks`), and which of them the check chose to ask (`chosen`). The denominator for
78/// learning how many to ask: a lesson that applied but was not chosen can still turn out needed.
79pub const GRADED: &str = "graded";
80/// A lesson a graded check of the session found applying but did not ask, which a later correction
81/// in that session then merged into or superseded: a question that was needed and not asked (`missed`).
82pub const MISSED: &str = "applied_then_corrected";
83/// The engineer vetoed the session's work (`borg no`, veto.rs): every lesson shown in the session
84/// before it is credited. The strongest negative result.
85pub const VETOED: &str = "vetoed";
86/// A lesson asked in a session before a decision that a later fix (or a review finding) showed was
87/// wrong (learn.rs `credit_regrets`): asking did not prevent it. `cause` names the regret episode and
88/// `credit` carries the share of the blame, (0, 1]. Weaker than a correction (memory.rs `REGRET`).
89pub const REGRETTED: &str = "regretted";
90
91/// The session's own tests went green after the lesson was asked (`tested`): red then green, or green
92/// after a change on the lesson's point. Once per (session, lesson), dated at that pass.
93pub const TESTED: &str = "tested";
94
95/// A session without asks or episodes for this long is treated as ended (24h).
96pub const IDLE_MS: i64 = 24 * 60 * 60 * 1000;
97
98#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
99pub struct Outcome {
100    pub kind: String,
101    pub session: String,
102    pub lessons: Vec<String>,
103    pub ms: i64,
104    pub repo: String,
105    /// `asked` only: the asked lessons rendered as one disagreement (conscience.rs `disagreements`).
106    #[serde(default, skip_serializing_if = "Vec::is_empty")]
107    pub groups: Vec<Vec<String>>,
108    /// How the lessons were asked (`asked` and the judge's results): `stop` (a turn-end block), `live`
109    /// (a live check during the turn, live.rs), `early` (early delivery, conscience.rs `early`) or
110    /// `queued` (a graded check's 2s and 1s, handed over free). Empty in records from before delivery modes.
111    #[serde(default, skip_serializing_if = "String::is_empty")]
112    pub channel: String,
113    /// `graded` and `asked`: each lesson's grade (3 certain, 2 likely, 1 worth a thought) and its rank in
114    /// the model's applies (0 first), aligned with `lessons`, and how many lessons the check graded as
115    /// applying; on `asked`, 0 where no check of the session graded the lesson. For learning how many to
116    /// ask (attention.rs reads them from `graded`).
117    #[serde(default, skip_serializing_if = "Vec::is_empty")]
118    pub grades: Vec<u8>,
119    #[serde(default, skip_serializing_if = "Vec::is_empty")]
120    pub ranks: Vec<u16>,
121    #[serde(default, skip_serializing_if = "is_zero")]
122    pub applied: u16,
123    /// `graded` only: the lessons the check chose to ask.
124    #[serde(default, skip_serializing_if = "Vec::is_empty")]
125    pub chosen: Vec<String>,
126    /// `graded` only: the check explored, asking every apply up to `attention::RAIL` whatever the cut,
127    /// so its outcomes label the cut (attention.rs).
128    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
129    pub explore: bool,
130    /// `regretted`: the regret episode (`R…`/`V…` id). The idempotence key with the lesson.
131    #[serde(default, skip_serializing_if = "String::is_empty")]
132    pub cause: String,
133    /// `regretted` only: the lesson's share of the regret, (0, 1].
134    #[serde(default, skip_serializing_if = "is_zero_f")]
135    pub credit: f64,
136    /// `asked` and `graded`: per lesson (aligned with `lessons`), the probability that the policy in force
137    /// at that check asked it: 1 or 0 under the fixed graded rule, the explore mixture under the learned
138    /// cut (attention.rs `propensities`), the share of Thompson draws that give it a slot in
139    /// `budget`/`widen` (conscience.rs `propensity`), the share that delivers it early (conscience.rs
140    /// `early_propensity`), 1 for a free hand-over of a graded check's 2s and 1s. Rounded to 4 decimals
141    /// (`round_p`). Empty in records from before propensities and when unknown (BORG_ONLY, a state file
142    /// from an older binary). For off-policy evaluation (ope.rs).
143    #[serde(default, skip_serializing_if = "Vec::is_empty")]
144    pub p: Vec<f64>,
145    /// `asked` from a Thompson slot draw only (conscience.rs `budget_pool`/`widen_pool`, hook.rs
146    /// `#borg-pool`): the pool the check drew from, in ranking order, the asked lessons included; each
147    /// one's score without its usefulness factor (conscience.rs `pool_base`, unrounded so a replay is
148    /// exact); and whether it is scoped. Aligned with each other, not with `lessons`. Empty for graded
149    /// and live asks and free deliveries. Local: never shared (`share`). For `borg debug ope` (ope.rs).
150    #[serde(default, skip_serializing_if = "Vec::is_empty")]
151    pub pool: Vec<String>,
152    #[serde(default, skip_serializing_if = "Vec::is_empty")]
153    pub base: Vec<f64>,
154    #[serde(default, skip_serializing_if = "Vec::is_empty")]
155    pub scoped: Vec<bool>,
156}
157
158/// A propensity rounded to 4 decimals, so lines stay deterministic and short.
159pub fn round_p(p: f64) -> f64 {
160    (p * 1e4).round() / 1e4
161}
162
163fn is_zero(v: &u16) -> bool {
164    *v == 0
165}
166
167fn is_zero_f(v: &f64) -> bool {
168    *v == 0.0
169}
170
171/// The outcomes store next to a lessons file.
172pub fn path_beside(lessons: &Path) -> std::path::PathBuf {
173    lessons.with_file_name("outcomes.jsonl")
174}
175
176/// The redacted outcomes the team sees, at the store root beside `outcomes.jsonl` (module doc).
177pub const TEAM_FILE: &str = "outcomes-team.jsonl";
178/// Beside `TEAM_FILE`, never synced: the length of `outcomes.jsonl` at the last `publish`.
179pub const PUBLISHED: &str = "outcomes-team.published";
180
181/// The kinds `publish` shares. A whitelist: `graded` and any later kind stay local until deliberately
182/// added here.
183///
184/// Closed under memory.rs `derive`: every kind that changes a lesson's result for a session, or its
185/// belief, travels, so a peer folds the same memory. `vetoed` outranks the session's `changed` (shared
186/// alone, a lesson vetoed here would gain a use and spread there); `regretted` carries its `credit` and
187/// its hashed `cause` (`Shared::c`), the key derive dedupes regrets by. `graded` is the asking policy's
188/// denominator, not a result, and never moves the activity clock (`memory::Clock` counts shared kinds).
189///
190/// Unguarded: any member can write a line under any session tag (tags are public in this file), so a
191/// forged `asked_then_corrected` or `vetoed` overrides a teammate's session on every node but that
192/// teammate's, and a few forged `asked_then_changed` with fresh tags and two repo tags consolidate a
193/// lesson. `node` is self-declared. The mesh admits only members' signed lines (borg-mesh origin.rs),
194/// so this is the trusted-member model; the deferred guards (a session tag bound to its first signer,
195/// consolidation from at least two signers) would close it.
196///
197/// `tested` is shared like `asked_then_changed`, the result it is weaker than: memory folds it, so a peer
198/// without it would hold another belief. Both are this node's own reading of its session (the judge's,
199/// the test runner's output), so the trusted-member model above is the same;
200/// `team_view` counts a peer's `tested` line only for lessons the team's records show its session was
201/// given. It never moves the activity clock either.
202pub const SHARED_KINDS: [&str; 9] = [ASKED, CHANGED, IGNORED, CORRECTED, NOT_CORRECTED, MISSED, VETOED, REGRETTED, TESTED];
203
204/// A session or repo as the team sees it: the first 32 hex digits of blake3("borg-outcome-v1\0" + s).
205/// Unsalted on purpose: the same session hashes to the same tag on every node, so a node can drop its
206/// own published copies (`read_team`).
207pub fn tag(s: &str) -> String {
208    let mut h = blake3::Hasher::new();
209    h.update(b"borg-outcome-v1\0");
210    h.update(s.as_bytes());
211    h.finalize().to_hex()[..32].to_string()
212}
213
214/// `tag`, keeping an empty repo empty.
215fn tag_repo(repo: &str) -> String {
216    if repo.is_empty() { String::new() } else { tag(repo) }
217}
218
219/// One line of `TEAM_FILE`. Never carries `id` and `version` together (the mesh would settle it as a
220/// versioned record).
221#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
222pub struct Shared {
223    pub kind: String,
224    /// `tag` of the session.
225    pub s: String,
226    pub lessons: Vec<String>,
227    pub ms: i64,
228    /// The publishing node (stats.rs `node`), self-declared: dedup does not depend on it.
229    pub node: String,
230    #[serde(default, skip_serializing_if = "Vec::is_empty")]
231    pub p: Vec<f64>,
232    #[serde(default, skip_serializing_if = "String::is_empty")]
233    pub channel: String,
234    /// `tag` of the repo: memory.rs counts distinct repos to consolidate a lesson.
235    #[serde(default, skip_serializing_if = "String::is_empty")]
236    pub r: String,
237    /// `tag` of a `regretted` record's cause (the regret episode): memory.rs counts each regret once.
238    #[serde(default, skip_serializing_if = "String::is_empty")]
239    pub c: String,
240    /// A `regretted` record's share of the blame.
241    #[serde(default, skip_serializing_if = "is_zero_f")]
242    pub credit: f64,
243}
244
245/// `o` as the team sees it, or None for a kind not in `SHARED_KINDS`.
246pub fn share(o: &Outcome, node: &str) -> Option<Shared> {
247    SHARED_KINDS.contains(&o.kind.as_str()).then(|| Shared {
248        kind: o.kind.clone(),
249        s: tag(&o.session),
250        lessons: o.lessons.clone(),
251        ms: o.ms,
252        node: node.into(),
253        p: o.p.clone(),
254        channel: o.channel.clone(),
255        r: tag_repo(&o.repo),
256        c: tag_repo(&o.cause),
257        credit: o.credit,
258    })
259}
260
261/// Team outcomes on: `BORG_SHARE_OUTCOMES` is not `off`.
262pub fn on() -> bool {
263    std::env::var("BORG_SHARE_OUTCOMES").as_deref() != Ok("off")
264}
265
266/// Appends to `TEAM_FILE` in `home` the shared lines of `home`'s `outcomes.jsonl` it lacks; returns
267/// how many. The lines are deterministic, so a re-run appends nothing; two runs at once may append the
268/// same line twice, which the mesh's union and the per-session count make harmless. Nothing when off.
269/// A local log no longer than at the last publish (`PUBLISHED`, its length then) is not read again: the
270/// log only grows, so nothing new can be in it.
271pub fn publish(home: &Path) -> usize {
272    let local = home.join("outcomes.jsonl");
273    let team = home.join(TEAM_FILE);
274    let mark = home.join(PUBLISHED);
275    let len = std::fs::metadata(&local).map_or(0, |m| m.len());
276    if !on() || len == 0 {
277        return 0;
278    }
279    let published = std::fs::read_to_string(&mark).ok().and_then(|t| t.trim().parse::<u64>().ok());
280    if team.exists() && published == Some(len) {
281        return 0;
282    }
283    let mine = std::fs::read_to_string(&local).unwrap_or_default();
284    let node = crate::stats::node(home);
285    let text = std::fs::read_to_string(&team).unwrap_or_default();
286    let mut have: std::collections::HashSet<String> = text.lines().map(str::to_string).collect();
287    let mut out = String::new();
288    let records = mine.lines().filter_map(|l| serde_json::from_str::<Outcome>(l).ok());
289    for line in records.filter_map(|o| share(&o, &node)).filter_map(|x| serde_json::to_string(&x).ok()) {
290        if have.insert(line.clone()) {
291            out.push_str(&line);
292            out.push('\n');
293        }
294    }
295    if out.is_empty() {
296        let _ = std::fs::write(&mark, mine.len().to_string());
297        return 0;
298    }
299    use std::io::Write;
300    let open = text.as_bytes().last().is_some_and(|c| *c != b'\n');
301    let written = std::fs::OpenOptions::new()
302        .create(true)
303        .append(true)
304        .open(&team)
305        .and_then(|mut f| f.write_all(format!("{}{out}", if open { "\n" } else { "" }).as_bytes()));
306    if written.is_err() {
307        return 0;
308    }
309    let _ = std::fs::write(&mark, mine.len().to_string());
310    out.lines().count()
311}
312
313/// The belief view of the outcomes beside `lessons`: this machine's log plus, unless
314/// BORG_SHARE_OUTCOMES=off, the team's (`team_view`).
315pub fn read_team(lessons: &Path) -> Vec<Outcome> {
316    team_view(lessons, on())
317}
318
319/// Off: this machine's log as is. On: its records with each repo and cause as its `tag` (memory.rs only
320/// compares them for distinctness, so beliefs are the same and comparable with the team's), then the
321/// team's lines that count (`team_parts`). A remote session then counts once per lesson like a local
322/// one (memory.rs `derive`, `sessions`). learn.rs keys on raw causes: it reads this
323/// machine's log (`read`).
324pub fn team_view(lessons: &Path, on: bool) -> Vec<Outcome> {
325    let (mut out, team) = team_parts(lessons, on);
326    if on {
327        for o in &mut out {
328            o.repo = tag_repo(&o.repo);
329            o.cause = tag_repo(&o.cause);
330        }
331    }
332    out.extend(team);
333    out
334}
335
336/// This machine's records as written, and (on) every `TEAM_FILE` line of a shared kind whose session
337/// is not one of this machine's (that drops this node's own published copies, whatever its node id),
338/// once per (kind, session, lessons, ms, cause): one read of each file, so the two never disagree on
339/// where the local records end (ope.rs reads both). A kind this binary does not share (a newer node's,
340/// or an older one's hive verdict) is ignored, and so is a `tested` line naming a lesson the team's
341/// records do not show its session was given.
342pub fn team_parts(lessons: &Path, on: bool) -> (Vec<Outcome>, Vec<Outcome>) {
343    let local = read(&path_beside(lessons));
344    if !on {
345        return (local, Vec::new());
346    }
347    let mine: std::collections::HashSet<String> = local.iter().map(|o| tag(&o.session)).collect();
348    let text = std::fs::read_to_string(lessons.with_file_name(TEAM_FILE)).unwrap_or_default();
349    let lines: Vec<Shared> = text.lines().filter_map(|l| serde_json::from_str::<Shared>(l).ok()).collect();
350    // The lessons each team session's shared records name: a green run credits only lessons it was given.
351    let mut given: std::collections::HashMap<&str, std::collections::HashSet<&str>> = std::collections::HashMap::new();
352    for x in lines.iter().filter(|x| SHARED_KINDS.contains(&x.kind.as_str()) && x.kind != TESTED) {
353        given.entry(x.s.as_str()).or_default().extend(x.lessons.iter().map(String::as_str));
354    }
355    let mut seen: std::collections::HashSet<(String, String, Vec<String>, i64, String)> = std::collections::HashSet::new();
356    let mut team = Vec::new();
357    for x in lines.iter() {
358        if !SHARED_KINDS.contains(&x.kind.as_str()) || mine.contains(&x.s) || !seen.insert((x.kind.clone(), x.s.clone(), x.lessons.clone(), x.ms, x.c.clone()))
359        {
360            continue;
361        }
362        if x.kind == TESTED && !x.lessons.iter().all(|l| given.get(x.s.as_str()).is_some_and(|g| g.contains(l.as_str()))) {
363            continue;
364        }
365        team.push(Outcome {
366            kind: x.kind.clone(),
367            session: x.s.clone(),
368            lessons: x.lessons.clone(),
369            ms: x.ms,
370            repo: x.r.clone(),
371            p: x.p.clone(),
372            channel: x.channel.clone(),
373            cause: x.c.clone(),
374            credit: x.credit,
375            ..Default::default()
376        });
377    }
378    (local, team)
379}
380
381pub fn read(path: &Path) -> Vec<Outcome> {
382    std::fs::read_to_string(path).unwrap_or_default().lines().filter_map(|l| serde_json::from_str(l).ok()).collect()
383}
384
385/// Appends `o` in one write (learn.rs `append_line`: concurrent writers never tear a record).
386pub fn append(path: &Path, o: &Outcome) -> std::io::Result<()> {
387    crate::learn::append_jsonl(path, o)?;
388    // The results count in the usage statistics; `asked` is already counted as the Stop's block, a
389    // `vetoed` result as the `veto` that credited it (veto.rs).
390    let kind = match o.kind.as_str() {
391        CHANGED => crate::stats::CHANGED,
392        IGNORED => crate::stats::IGNORED,
393        CORRECTED => crate::stats::CORRECTED,
394        NOT_CORRECTED => crate::stats::NOT_CORRECTED,
395        REGRETTED => crate::stats::REGRETTED,
396        TESTED => crate::stats::TESTED,
397        _ => return Ok(()),
398    };
399    if let Some(home) = path.parent() {
400        crate::stats::count(home, kind, &o.session, |e| {
401            e.repo = o.repo.clone();
402            e.lessons = o.lessons.clone();
403            e.channel = o.channel.clone();
404        });
405    }
406    Ok(())
407}
408
409/// Did the conscience ask `lesson` in `session` before `ms`?
410pub fn asked_before(outcomes: &[Outcome], session: &str, lesson: &str, ms: i64) -> bool {
411    outcomes.iter().any(|o| o.kind == ASKED && o.session == session && o.ms < ms && o.lessons.iter().any(|l| l == lesson))
412}
413
414/// Is a result (`kind`) already recorded for `lesson` in `session`? Keeps `borg learn` idempotent.
415pub fn has(outcomes: &[Outcome], kind: &str, session: &str, lesson: &str) -> bool {
416    outcomes.iter().any(|o| o.kind == kind && o.session == session && o.lessons.iter().any(|l| l == lesson))
417}
418
419/// The lessons a correction at `ms` in `session` shows were asked in vain, each asked in `session`
420/// before `ms` and not yet recorded as corrected or vetoed there (a veto is the stronger result): the lessons the correction `supersedes`
421/// (it contradicts them), and `target`, the lesson it was merged into (the same concern came up
422/// again). When `target` was asked as one side of a rendered disagreement, the engineer sided with
423/// it: the other sides of that disagreement were corrected, not `target`. Sorted, unique.
424pub fn contradicted(outcomes: &[Outcome], session: &str, ms: i64, target: Option<&str>, supersedes: &[String]) -> Vec<String> {
425    let mut out: Vec<String> = supersedes.to_vec();
426    if let Some(t) = target {
427        let sides: Vec<String> = outcomes
428            .iter()
429            .filter(|o| o.kind == ASKED && o.session == session && o.ms < ms)
430            .flat_map(|o| &o.groups)
431            .filter(|g| g.iter().any(|x| x == t))
432            .flatten()
433            .filter(|x| *x != t)
434            .cloned()
435            .collect();
436        match sides.is_empty() {
437            true => out.push(t.to_string()),
438            false => out.extend(sides),
439        }
440    }
441    out.retain(|l| asked_before(outcomes, session, l, ms) && !has(outcomes, CORRECTED, session, l) && !has(outcomes, VETOED, session, l));
442    out.sort();
443    out.dedup();
444    out
445}
446
447/// Was `o` a free delivery (channel `early` or `queued`): lessons handed over as context, no question?
448pub fn free(o: &Outcome) -> bool {
449    o.channel == "early" || o.channel == "queued"
450}
451
452/// The lessons a correction at `ms` in `session` shows were needed and not asked: of the lessons it
453/// `supersedes` and `target` (the lesson it was merged into), those a graded check of the session found
454/// applying before `ms` without choosing them, never asked as a question in the session before `ms` (a
455/// free delivery asks nothing: a lesson only handed over as context stays a miss for the cut,
456/// attention.rs), and not yet recorded as missed there. Sorted, unique.
457pub fn missed(outcomes: &[Outcome], session: &str, ms: i64, target: Option<&str>, supersedes: &[String]) -> Vec<String> {
458    let mut out: Vec<String> = supersedes.iter().cloned().chain(target.map(str::to_string)).collect();
459    let applied_unchosen = |l: &str| {
460        outcomes.iter().any(|o| o.kind == GRADED && o.session == session && o.ms < ms && o.lessons.iter().any(|x| x == l) && !o.chosen.iter().any(|x| x == l))
461    };
462    let questioned = |l: &str| outcomes.iter().any(|o| o.kind == ASKED && !free(o) && o.session == session && o.ms < ms && o.lessons.iter().any(|x| x == l));
463    out.retain(|l| applied_unchosen(l) && !questioned(l) && !has(outcomes, MISSED, session, l));
464    out.sort();
465    out.dedup();
466    out
467}
468
469/// Has `lesson` been credited for the regret `cause` already (in any session)?
470fn credited(outcomes: &[Outcome], cause: &str, lesson: &str) -> bool {
471    outcomes.iter().any(|o| o.kind == REGRETTED && o.cause == cause && o.lessons.iter().any(|l| l == lesson))
472}
473
474/// The lessons a regret blames: those asked in `session` (the deciding one) before `before_ms`, without
475/// a correction or a veto there already (both outrank regretted, as in memory.rs) and not yet credited
476/// for `cause`. When some of `target` (the lesson the regret was folded into, and the lessons that lesson
477/// supersedes) were asked, they share the blame alone: the same concern came up again, and when all of
478/// them already took a correction or a veto for it, nobody else is blamed. Otherwise every asked lesson
479/// shares it equally (the Shapley value of a symmetric game). A lesson a graded check found applying but
480/// did not ask was never shown, so it is not blamed. One record per lesson, sorted, at `ms`,
481/// with `credit = strength / n`; n counts the blamed lessons before the already-credited ones are left
482/// out, so a re-run writes nothing and the shares never change. Every record's session has an earlier
483/// `asked` record, so it never moves memory.rs's `Clock`.
484#[allow(clippy::too_many_arguments)]
485pub fn regretted(outcomes: &[Outcome], session: &str, before_ms: i64, cause: &str, target: &[String], strength: f64, ms: i64, repo: &str) -> Vec<Outcome> {
486    if session.is_empty() || before_ms <= 0 || strength <= 0.0 {
487        return Vec::new();
488    }
489    let mut asked: Vec<&str> =
490        outcomes.iter().filter(|o| o.kind == ASKED && o.session == session && o.ms < before_ms).flat_map(|o| o.lessons.iter().map(String::as_str)).collect();
491    asked.sort_unstable();
492    asked.dedup();
493    if asked.iter().any(|l| target.iter().any(|t| t == l)) {
494        asked.retain(|l| target.iter().any(|t| t == l));
495    }
496    asked.retain(|l| !has(outcomes, CORRECTED, session, l) && !has(outcomes, VETOED, session, l));
497    let credit = (strength / asked.len().max(1) as f64).min(1.0);
498    asked
499        .into_iter()
500        .filter(|l| !credited(outcomes, cause, l))
501        .map(|l| Outcome {
502            kind: REGRETTED.into(),
503            session: session.into(),
504            lessons: vec![l.into()],
505            ms,
506            repo: repo.into(),
507            cause: cause.into(),
508            credit,
509            ..Default::default()
510        })
511        .collect()
512}
513
514/// The `tested` records a session's own test runs earn (`tests`: its `<state>/<session>.tests`, `fail <ms>`
515/// and `pass <ms>` lines, hook.rs `capture_test`): each lesson asked as a question in `session` (a free
516/// delivery asks nothing, `free`: handed over at every prompt of its scope, it would gain from every
517/// test-first session) whose last test result after its first ask passed, after a failed run since that ask (red, then green) or a change the drone
518/// made on the lesson's point (`asked_then_changed`), and that has no `tested` record there yet, nor a
519/// correction or a veto (the engineer's word outranks a green run), nor `asked_then_ignored` (red, then
520/// green is also how a feature is built: it does not make an ignored question useful). A pass before the ask says nothing
521/// of the lesson. One record per lesson, sorted, at that last pass. Every record's session has an
522/// earlier `asked` record, so it never moves memory.rs's `Clock`.
523pub fn tested(outcomes: &[Outcome], session: &str, tests: &str) -> Vec<Outcome> {
524    let mut runs: Vec<(i64, bool)> = tests
525        .lines()
526        .filter_map(|l| l.split_once(' '))
527        .filter_map(|(w, m)| Some((m.trim().parse::<i64>().ok()?, matches!(w, "pass" | "fail").then_some(w == "pass")?)))
528        .collect();
529    runs.sort_unstable();
530    // Each lesson's first ask in the session, and its repo.
531    let mut first: HashMap<&str, (i64, &str)> = HashMap::new();
532    for o in outcomes.iter().filter(|o| o.kind == ASKED && !free(o) && o.session == session) {
533        for l in &o.lessons {
534            let e = first.entry(l.as_str()).or_insert((o.ms, o.repo.as_str()));
535            if o.ms < e.0 {
536                *e = (o.ms, o.repo.as_str());
537            }
538        }
539    }
540    let mut out: Vec<Outcome> = first
541        .into_iter()
542        .filter(|(l, _)| ![TESTED, CORRECTED, VETOED, IGNORED].iter().any(|k| has(outcomes, k, session, l)))
543        .filter_map(|(l, (at, repo))| {
544            let after = &runs[runs.partition_point(|r| r.0 <= at)..];
545            let &(ms, pass) = after.last()?;
546            (pass && (after.iter().any(|r| !r.1) || has(outcomes, CHANGED, session, l))).then(|| Outcome {
547                kind: TESTED.into(),
548                session: session.into(),
549                lessons: vec![l.into()],
550                ms,
551                repo: repo.into(),
552                ..Default::default()
553            })
554        })
555        .collect();
556    out.sort_by(|a, b| a.lessons.cmp(&b.lessons));
557    out
558}
559
560/// At session end: every lesson asked in `session` that has no result yet was not corrected. A
561/// vetoed lesson already has its result.
562/// Sorted by lesson id so the appended lines are deterministic.
563/// One pass over the log, whatever the number of asks.
564pub fn settle(outcomes: &[Outcome], session: &str, ms: i64) -> Vec<Outcome> {
565    let mine = outcomes.iter().filter(|o| o.session == session);
566    let resolved: std::collections::HashSet<&str> = mine.clone().filter(|o| resolves(&o.kind)).flat_map(|o| o.lessons.iter().map(String::as_str)).collect();
567    let mut asked: Vec<(&str, &str)> = mine
568        .filter(|o| o.kind == ASKED)
569        .flat_map(|o| o.lessons.iter().map(move |l| (l.as_str(), o.repo.as_str())))
570        .filter(|(l, _)| !resolved.contains(l))
571        .collect();
572    asked.sort();
573    asked.dedup_by_key(|(l, _)| *l);
574    asked
575        .into_iter()
576        .map(|(l, repo)| Outcome { kind: NOT_CORRECTED.into(), session: session.into(), lessons: vec![l.into()], ms, repo: repo.into(), ..Default::default() })
577        .collect()
578}
579
580/// Does a `kind` record end an ask's wait for its result (`settle`)?
581fn resolves(kind: &str) -> bool {
582    matches!(kind, CORRECTED | NOT_CORRECTED | VETOED)
583}
584
585/// Sessions with asks that are idle at `now`: their newest ask and newest episode
586/// (`last_episode`, newest episode ms per session) are at least `IDLE_MS` old. Sorted. Only sessions
587/// with an ask still without its result, so `borg learn` (under the store lock) settles what is new,
588/// not every session it ever settled: one pass over the log.
589pub fn idle_sessions<'a>(outcomes: &'a [Outcome], last_episode: &HashMap<String, i64>, now: i64) -> Vec<&'a str> {
590    let mut newest: HashMap<&str, i64> = HashMap::new();
591    let (mut asked, mut resolved) = (std::collections::HashSet::new(), std::collections::HashSet::new());
592    for o in outcomes {
593        let into = match o.kind.as_str() {
594            ASKED => &mut asked,
595            k if resolves(k) => &mut resolved,
596            _ => continue,
597        };
598        into.extend(o.lessons.iter().map(|l| (o.session.as_str(), l.as_str())));
599        if o.kind == ASKED {
600            let m = newest.entry(o.session.as_str()).or_insert(o.ms);
601            *m = (*m).max(o.ms);
602        }
603    }
604    let open: std::collections::HashSet<&str> = asked.difference(&resolved).map(|(s, _)| *s).collect();
605    let mut idle: Vec<&str> = newest
606        .into_iter()
607        .filter(|&(s, ms)| open.contains(s) && ms.max(last_episode.get(s).copied().unwrap_or(i64::MIN)) <= now - IDLE_MS)
608        .map(|(s, _)| s)
609        .collect();
610    idle.sort();
611    idle
612}
613
614/// Usefulness of a lesson from its asked outcomes:
615///
616/// w = (1 + not_corrected) / (1 + not_corrected + 2 * corrected)
617///
618/// The +1 is a neutral prior, so a lesson without outcomes keeps w = 1 and a single bad session
619/// cannot zero it. A correction of the same concern right after asking is strong evidence the
620/// question did not help (too generic, or it does not change what the drone does), while a
621/// session without correction is weak evidence (the engineer may simply not have looked), so a
622/// correction counts twice. w stays in (0, 1]: outcomes only demote, BM25 relevance still decides
623/// what is on topic. One corrected session gives 1/3; one corrected and one not gives 1/2.
624pub fn weight(not_corrected: u32, corrected: u32) -> f64 {
625    let (n, c) = (not_corrected as f64, corrected as f64);
626    (1.0 + n) / (1.0 + n + 2.0 * c)
627}
628
629/// The weight of every lesson that has outcomes. Each (session, lesson) counts once; corrected wins.
630pub fn weights(outcomes: &[Outcome]) -> HashMap<String, f64> {
631    counts(outcomes).into_iter().map(|(l, (n, c))| (l, weight(n, c))).collect()
632}
633
634/// Per lesson with results: (sessions not corrected, sessions corrected). Each (session, lesson)
635/// counts once; corrected wins. Used by `weights`.
636pub fn counts(outcomes: &[Outcome]) -> HashMap<String, (u32, u32)> {
637    sessions(outcomes).into_iter().map(|(l, ss)| (l.to_string(), tally(ss.values(), i64::MIN))).collect()
638}
639
640/// Per lesson, per session with a result: (corrected, ms of the session's earliest result for the
641/// lesson). Corrected wins over a later (resumed) settle. trust.rs filters these by when an engineer
642/// joined the lesson. A veto (`vetoed`) counts as a correction here, so trust and `weight` see it too
643/// (as heavy as a correction; only memory.rs weighs it above one).
644pub fn sessions(outcomes: &[Outcome]) -> HashMap<&str, HashMap<&str, (bool, i64)>> {
645    let mut out: HashMap<&str, HashMap<&str, (bool, i64)>> = HashMap::new();
646    for o in outcomes {
647        let corrected = match o.kind.as_str() {
648            CORRECTED | VETOED => true,
649            NOT_CORRECTED => false,
650            _ => continue,
651        };
652        for l in &o.lessons {
653            let r = out.entry(l.as_str()).or_default().entry(o.session.as_str()).or_insert((corrected, o.ms));
654            r.0 |= corrected;
655            r.1 = r.1.min(o.ms);
656        }
657    }
658    out
659}
660
661/// (sessions not corrected, sessions corrected) among `sessions` results whose earliest result is
662/// after `since`.
663pub fn tally<'a>(sessions: impl IntoIterator<Item = &'a (bool, i64)>, since: i64) -> (u32, u32) {
664    sessions.into_iter().filter(|(_, ms)| *ms > since).fold((0, 0), |(n, c), (corrected, _)| match corrected {
665        true => (n, c + 1),
666        false => (n + 1, c),
667    })
668}
669
670#[cfg(test)]
671mod tests {
672    use super::*;
673
674    fn o(kind: &str, session: &str, lessons: &[&str], ms: i64) -> Outcome {
675        Outcome {
676            kind: kind.into(),
677            session: session.into(),
678            lessons: lessons.iter().map(|l| l.to_string()).collect(),
679            ms,
680            repo: "r".into(),
681            ..Default::default()
682        }
683    }
684
685    #[test]
686    fn missed_is_an_applied_unasked_lesson_a_later_correction_lands_on() {
687        let graded = |ms: i64, chosen: &[&str]| Outcome {
688            kind: GRADED.into(),
689            session: "s".into(),
690            lessons: vec!["A".into(), "B".into()],
691            ms,
692            chosen: chosen.iter().map(|x| x.to_string()).collect(),
693            ..Default::default()
694        };
695        let none: Vec<String> = Vec::new();
696        let log = vec![graded(10, &["B"])];
697        assert_eq!(missed(&log, "s", 20, Some("A"), &none), ["A"]);
698        assert_eq!(missed(&log, "s", 20, None, &["A".into(), "B".into()]), ["A"], "B was chosen");
699        assert!(missed(&log, "s", 5, Some("A"), &none).is_empty(), "a correction before the check");
700        assert!(missed(&log, "t", 20, Some("A"), &none).is_empty(), "another session");
701        assert!(missed(&log, "s", 20, Some("C"), &none).is_empty(), "never applied");
702        let mut asked = log.clone();
703        asked.push(o(ASKED, "s", &["A"], 15));
704        assert!(missed(&asked, "s", 20, Some("A"), &none).is_empty(), "asked later in the session: not a miss");
705        let mut free = log.clone();
706        free.push(Outcome { channel: "queued".into(), ..o(ASKED, "s", &["A"], 15) });
707        free.push(Outcome { channel: "early".into(), ..o(ASKED, "s", &["A"], 16) });
708        assert_eq!(missed(&free, "s", 20, Some("A"), &none), ["A"], "handed over free, never asked: still a miss");
709        let mut done = log.clone();
710        done.push(o(MISSED, "s", &["A"], 18));
711        assert!(missed(&done, "s", 20, Some("A"), &none).is_empty(), "recorded once");
712    }
713
714    #[test]
715    fn weight_formula() {
716        assert_eq!(weight(0, 0), 1.0);
717        assert_eq!(weight(5, 0), 1.0);
718        assert!((weight(0, 1) - 1.0 / 3.0).abs() < 1e-12);
719        assert_eq!(weight(1, 1), 0.5);
720        assert!(weight(0, 3) < weight(0, 1));
721    }
722
723    #[test]
724    fn weights_count_each_session_once_and_corrected_wins() {
725        let all = vec![
726            o(ASKED, "s1", &["A", "B"], 10),
727            o(CORRECTED, "s1", &["A"], 20),
728            o(CORRECTED, "s1", &["A"], 30),     // second correction in the same session: still one
729            o(NOT_CORRECTED, "s1", &["A"], 40), // resumed session settled later: corrected wins
730            o(NOT_CORRECTED, "s1", &["B"], 40),
731            o(NOT_CORRECTED, "s2", &["A"], 50),
732        ];
733        let w = weights(&all);
734        assert_eq!(w["A"], weight(1, 1));
735        assert_eq!(w["B"], 1.0);
736        assert!(!w.contains_key("C"));
737    }
738
739    #[test]
740    fn a_session_whose_tests_go_green_after_an_ask_credits_it_once() {
741        let mut log = vec![o(ASKED, "s", &["A", "B"], 10), o(ASKED, "t", &["A"], 10)];
742        // Red after the ask, then green: one credit per lesson asked, dated at the pass.
743        let got = tested(&log, "s", "fail 20\npass 30\n");
744        assert_eq!(got.iter().map(|x| (x.kind.as_str(), x.lessons[0].as_str(), x.ms)).collect::<Vec<_>>(), [(TESTED, "A", 30), (TESTED, "B", 30)]);
745        // Idempotent: a re-run with the records written writes nothing.
746        log.extend(got);
747        assert!(tested(&log, "s", "fail 20\npass 30\n").is_empty());
748        // Only the session's own asks: t ran no tests.
749        assert!(tested(&log, "t", "").is_empty(), "no tests file: nothing");
750    }
751
752    #[test]
753    fn a_green_run_credits_only_after_the_ask_and_with_a_reason() {
754        let log = vec![o(ASKED, "s", &["A"], 50)];
755        assert!(tested(&log, "s", "fail 20\npass 30\n").is_empty(), "red and green before the ask");
756        assert!(tested(&log, "s", "fail 20\npass 60\n").is_empty(), "the failure before the ask: green proves nothing new");
757        assert!(tested(&log, "s", "fail 60\npass 70\nfail 80\n").is_empty(), "last run red");
758        assert!(tested(&log, "s", "pass 60\n").is_empty(), "green alone, nothing changed");
759        assert!(tested(&log, "s", "pass 60\npass 70 \ngarbage\n").is_empty());
760        let early = [Outcome { channel: "early".into(), ..o(ASKED, "e", &["A"], 10) }];
761        assert!(tested(&early, "e", "fail 20\npass 30\n").is_empty(), "a free delivery asked nothing");
762        // Green after the drone changed its work on the lesson: a credit.
763        let changed = [log.clone(), vec![o(CHANGED, "s", &["A"], 55)]].concat();
764        assert_eq!(tested(&changed, "s", "pass 60\n").len(), 1);
765        // A correction or a veto of the lesson in the session outranks a green run; an ignored question
766        // gains nothing from the drone fixing its own failing test.
767        for k in [CORRECTED, VETOED, IGNORED] {
768            let worse = [log.clone(), vec![o(k, "s", &["A"], 55)]].concat();
769            assert!(tested(&worse, "s", "fail 60\npass 70\n").is_empty(), "{k}");
770        }
771        // Lines out of order still find the last run; the first ask decides what is after it.
772        let twice = [log.clone(), vec![o(ASKED, "s", &["A"], 65)]].concat();
773        assert_eq!(tested(&twice, "s", "pass 70\nfail 60\n")[0].ms, 70);
774    }
775
776    #[test]
777    fn settle_marks_only_unresolved_asks_of_this_session() {
778        let all = vec![o(ASKED, "s1", &["B", "A"], 10), o(ASKED, "s1", &["A", "C"], 15), o(CORRECTED, "s1", &["A"], 20), o(ASKED, "s2", &["D"], 10)];
779        let settled = settle(&all, "s1", 99);
780        assert_eq!(settled, vec![o(NOT_CORRECTED, "s1", &["B"], 99), o(NOT_CORRECTED, "s1", &["C"], 99)]);
781        assert!(asked_before(&all, "s1", "A", 11) && !asked_before(&all, "s1", "C", 15) && !asked_before(&all, "s2", "A", 99));
782    }
783
784    #[test]
785    fn contradicted_marks_superseded_asks_and_the_other_sides_of_a_disagreement() {
786        let mut ask = o(ASKED, "s1", &["A", "B", "C", "D"], 10);
787        ask.groups = vec![vec!["A".into(), "B".into()]];
788        let all = vec![ask, o(ASKED, "s1", &["E"], 30), o(CORRECTED, "s1", &["D"], 15)];
789        let ids = |v: &[&str]| v.iter().map(|x| x.to_string()).collect::<Vec<_>>();
790        // A plain merge into C: C was asked in vain.
791        assert_eq!(contradicted(&all, "s1", 20, Some("C"), &[]), ["C"]);
792        // A correction that sides with A in the rendered A/B disagreement: B was wrong, A was right.
793        assert_eq!(contradicted(&all, "s1", 20, Some("A"), &[]), ["B"]);
794        // A new lesson superseding C and E: only C was asked before it; D is already corrected.
795        assert_eq!(contradicted(&all, "s1", 20, None, &ids(&["C", "E", "D", "Z"])), ["C"]);
796        assert!(contradicted(&all, "s2", 20, Some("C"), &ids(&["A"])).is_empty(), "another session asked nothing");
797        let mut vetoed = all.clone();
798        vetoed.push(o(VETOED, "s1", &["C"], 12));
799        assert!(contradicted(&vetoed, "s1", 20, Some("C"), &[]).is_empty(), "a vetoed lesson already has the stronger result");
800    }
801
802    #[test]
803    fn a_vetoed_lesson_is_not_settled_and_counts_as_corrected() {
804        let all = vec![o(ASKED, "s1", &["A", "B"], 10), o(VETOED, "s1", &["A"], 20)];
805        assert_eq!(settle(&all, "s1", 99), vec![o(NOT_CORRECTED, "s1", &["B"], 99)]);
806        assert_eq!(sessions(&all)["A"]["s1"], (true, 20));
807        assert_eq!(weights(&all)["A"], weight(0, 1));
808    }
809
810    #[test]
811    fn sessions_keep_the_earliest_result_and_tally_filters_by_it() {
812        let all =
813            vec![o(NOT_CORRECTED, "s1", &["A"], 10), o(CORRECTED, "s2", &["A"], 20), o(NOT_CORRECTED, "s2", &["A"], 25), o(NOT_CORRECTED, "s3", &["A"], 30)];
814        let s = sessions(&all);
815        assert_eq!(s["A"]["s2"], (true, 20));
816        assert_eq!(tally(s["A"].values(), i64::MIN), (2, 1));
817        assert_eq!(tally(s["A"].values(), 20), (1, 0));
818    }
819
820    #[test]
821    fn idle_sessions_need_old_asks_and_old_episodes() {
822        let all = vec![o(ASKED, "old", &["A"], 0), o(ASKED, "busy", &["A"], 0), o(ASKED, "fresh", &["A"], 50), o(CORRECTED, "x", &["A"], 0)];
823        let episodes = HashMap::from([("busy".to_string(), 60)]);
824        assert_eq!(idle_sessions(&all, &episodes, IDLE_MS + 10), ["old"]);
825        assert_eq!(idle_sessions(&all, &episodes, IDLE_MS + 60), ["busy", "fresh", "old"]);
826    }
827
828    #[test]
829    fn idle_sessions_skip_settled_ones() {
830        let all = vec![
831            o(ASKED, "done", &["A", "B"], 0),
832            o(NOT_CORRECTED, "done", &["A"], 1),
833            o(CORRECTED, "done", &["B"], 1),
834            o(ASKED, "vetoed", &["A"], 0),
835            o(VETOED, "vetoed", &["A"], 1),
836            o(ASKED, "half", &["A", "B"], 0),
837            o(NOT_CORRECTED, "half", &["A"], 1),
838        ];
839        assert_eq!(idle_sessions(&all, &HashMap::new(), IDLE_MS + 10), ["half"], "every ask of the others has its result");
840    }
841
842    #[test]
843    fn regretted_blames_asks_before_the_decision_shared_or_by_target() {
844        let mut graded = o(GRADED, "s", &["G"], 5);
845        graded.chosen = vec![];
846        let log = vec![graded, o(ASKED, "s", &["B", "A"], 10), o(ASKED, "s", &["C"], 30), o(ASKED, "t", &["D"], 10)];
847        let ids = |v: &[&str]| v.iter().map(|x| x.to_string()).collect::<Vec<_>>();
848        let blame = |log: &[Outcome], target: &[String]| regretted(log, "s", 20, "R1", target, 0.5, 99, "r");
849        let got = blame(&log, &[]);
850        let want = |l: &str, credit: f64| Outcome { cause: "R1".into(), credit, ..o(REGRETTED, "s", &[l], 99) };
851        assert_eq!(got, vec![want("A", 0.25), want("B", 0.25)], "shared; C was asked after the decision, G never shown");
852        assert_eq!(blame(&log, &ids(&["B", "Z"])), vec![want("B", 0.5)], "the merge target takes it all");
853        assert_eq!(blame(&log, &ids(&["Z"])), got, "a target not asked: shared");
854        let mut corrected = log.clone();
855        corrected.push(o(CORRECTED, "s", &["B"], 15));
856        assert_eq!(blame(&corrected, &[]), vec![want("A", 0.5)], "corrected outranks regretted");
857        let mut vetoed = log.clone();
858        vetoed.push(o(VETOED, "s", &["B"], 15));
859        assert_eq!(blame(&vetoed, &[]), vec![want("A", 0.5)], "vetoed outranks regretted");
860        assert!(blame(&corrected, &ids(&["B"])).is_empty(), "the target took it as a correction: nobody else is blamed");
861        assert!(blame(&vetoed, &ids(&["B", "Z"])).is_empty(), "the target took it as a veto: nobody else is blamed");
862        assert_eq!(blame(&corrected, &ids(&["A", "B"])), vec![want("A", 0.5)], "the uncorrected target keeps its share");
863        let mut again = log.clone();
864        again.extend(got.clone());
865        assert!(blame(&again, &[]).is_empty(), "credited once per cause");
866        let mut half = log.clone();
867        half.push(got[0].clone());
868        assert_eq!(blame(&half, &[]), vec![want("B", 0.25)], "a partial run completes with the same share");
869        assert_eq!(regretted(&again, "s", 20, "R2", &[], 0.5, 99, "r").len(), 2, "another regret blames again");
870        assert!(regretted(&log, "u", 20, "R1", &[], 0.5, 99, "r").is_empty(), "another session asked nothing");
871        assert!(regretted(&log, "", 20, "R1", &[], 0.5, 99, "r").is_empty());
872        assert!(regretted(&log, "s", 0, "R1", &[], 0.5, 99, "r").is_empty(), "no decision time");
873        assert!(regretted(&log, "s", 20, "R1", &[], 0.0, 99, "r").is_empty(), "not strong");
874        // An old line without cause and credit still reads; a regretted line round-trips.
875        let old: Outcome = serde_json::from_str(r#"{"kind":"asked","session":"s","lessons":["A"],"ms":1,"repo":"r"}"#).unwrap();
876        assert_eq!(old, o(ASKED, "s", &["A"], 1));
877        assert!(!serde_json::to_string(&old).unwrap().contains("credit"));
878        let line = serde_json::to_string(&got[0]).unwrap();
879        assert!(line.contains(r#""cause":"R1","credit":0.25"#), "{line}");
880        assert_eq!(serde_json::from_str::<Outcome>(&line).unwrap(), got[0]);
881    }
882
883    #[test]
884    fn a_line_from_before_propensities_reads_and_writes_back_unchanged() {
885        let line = r#"{"kind":"asked","session":"s","lessons":["A","B"],"ms":1,"repo":"r","channel":"stop","grades":[3,1],"ranks":[0,1],"applied":2}"#;
886        let o: Outcome = serde_json::from_str(line).unwrap();
887        assert!(o.p.is_empty());
888        assert_eq!(serde_json::to_string(&o).unwrap(), line);
889        let with = Outcome { p: vec![round_p(1.0), round_p(1.0 / 3.0)], ..o };
890        assert!(serde_json::to_string(&with).unwrap().ends_with(r#""applied":2,"p":[1.0,0.3333]}"#));
891    }
892
893    fn tmp(name: &str) -> std::path::PathBuf {
894        let d = std::env::temp_dir().join(format!("borg-outcome-{name}-{}", std::process::id()));
895        let _ = std::fs::remove_dir_all(&d);
896        std::fs::create_dir_all(&d).unwrap();
897        d
898    }
899
900    fn lines(path: &Path, v: &[String]) {
901        std::fs::write(path, v.iter().map(|l| format!("{l}\n")).collect::<String>()).unwrap();
902    }
903
904    fn changed(team: &[Outcome], lesson: &str) -> u32 {
905        let ls = [crate::conscience::Challenge { id: lesson.into(), ..Default::default() }];
906        crate::memory::derive(team, &ls, None)[lesson].changed
907    }
908
909    #[test]
910    fn share_redacts() {
911        let mut a = o(ASKED, "session-raw", &["A"], 5);
912        a.repo = "secret-repo".into();
913        a.groups = vec![vec!["A".into(), "B".into()]];
914        a.grades = vec![3];
915        a.ranks = vec![0];
916        a.p = vec![0.5];
917        let x = share(&a, "n1").unwrap();
918        let line = serde_json::to_string(&x).unwrap();
919        for raw in ["session-raw", "secret-repo", "groups", "grades", "ranks", "\"id\"", "version"] {
920            assert!(!line.contains(raw), "{raw} in {line}");
921        }
922        assert_eq!((x.s, x.r, x.p, x.node), (tag("session-raw"), tag("secret-repo"), vec![0.5], "n1".to_string()));
923        assert_eq!(tag("s").len(), 32);
924        for kind in [GRADED, "veto"] {
925            assert!(share(&o(kind, "s", &["A"], 1), "n1").is_none(), "{kind} is not shared");
926        }
927        let regret = share(&Outcome { cause: "episode-raw".into(), credit: 0.25, ..o(REGRETTED, "s", &["A"], 1) }, "n1").unwrap();
928        let line = serde_json::to_string(&regret).unwrap();
929        assert!(!line.contains("episode-raw") && line.contains(&tag("episode-raw")) && line.contains(r#""credit":0.25"#), "{line}");
930    }
931
932    #[test]
933    fn pool_fields_stay_local() {
934        let a = Outcome { pool: vec!["A".into(), "B".into()], base: vec![1.25, 0.5], scoped: vec![true, false], p: vec![0.5], ..o(ASKED, "s", &["A"], 5) };
935        let line = serde_json::to_string(&a).unwrap();
936        assert_eq!(serde_json::from_str::<Outcome>(&line).unwrap(), a, "round trip");
937        let shared = serde_json::to_string(&share(&a, "n1").unwrap()).unwrap();
938        for field in ["pool", "base", "scoped"] {
939            assert!(line.contains(field) && !shared.contains(field), "{field}: {shared}");
940        }
941        assert!(!serde_json::to_string(&o(ASKED, "s", &["A"], 5)).unwrap().contains("pool"), "empty: not written");
942    }
943
944    #[test]
945    fn publish_is_idempotent() {
946        let home = tmp("publish");
947        assert_eq!(publish(&home), 0, "no outcomes, nothing to publish");
948        let mut asked = o(ASKED, "s1", &["A"], 1);
949        asked.p = vec![1.0];
950        lines(&home.join("outcomes.jsonl"), &[asked, o(GRADED, "s1", &["A"], 1), o(CHANGED, "s1", &["A"], 2)].map(|x| serde_json::to_string(&x).unwrap()));
951        assert_eq!(publish(&home), 2, "graded stays local");
952        let first = std::fs::read_to_string(home.join(TEAM_FILE)).unwrap();
953        assert_eq!(publish(&home), 0);
954        assert_eq!(std::fs::read_to_string(home.join(TEAM_FILE)).unwrap(), first);
955        assert!(first.contains(r#""p":[1.0]"#) && !first.contains("\"s1\""), "{first}");
956        // Unchanged since the last publish: the log is not read again; grown: only the new line goes.
957        assert_eq!(std::fs::read_to_string(home.join(PUBLISHED)).unwrap(), std::fs::metadata(home.join("outcomes.jsonl")).unwrap().len().to_string());
958        std::fs::OpenOptions::new()
959            .append(true)
960            .open(home.join("outcomes.jsonl"))
961            .and_then(|mut f| std::io::Write::write_all(&mut f, format!("{}\n", serde_json::to_string(&o(IGNORED, "s2", &["A"], 3)).unwrap()).as_bytes()))
962            .unwrap();
963        assert_eq!(publish(&home), 1);
964        assert_eq!(publish(&home), 0);
965        // A team file gone (a fresh mesh) is published again in full.
966        std::fs::remove_file(home.join(TEAM_FILE)).unwrap();
967        assert_eq!(publish(&home), 3);
968        let _ = std::fs::remove_dir_all(home);
969    }
970
971    /// A store whose local log has s1 changed L1, and whose team file holds that result as published
972    /// by this node, a remote session's change of L1, the same remote line from another node's copy,
973    /// and a kind this binary does not share.
974    fn team_store(name: &str, shuffle: bool) -> std::path::PathBuf {
975        let home = tmp(name);
976        let mut local = o(CHANGED, "s1", &["L1"], 10);
977        local.repo = "app".into();
978        lines(&home.join("outcomes.jsonl"), &[serde_json::to_string(&local).unwrap()]);
979        let remote = |kind: &str, s: &str, node: &str| {
980            serde_json::to_string(&Shared {
981                kind: kind.into(),
982                s: s.into(),
983                lessons: vec!["L1".into()],
984                ms: 20,
985                node: node.into(),
986                r: tag("web"),
987                ..Default::default()
988            })
989            .unwrap()
990        };
991        let mut team = vec![
992            serde_json::to_string(&share(&local, "me").unwrap()).unwrap(),
993            remote(CHANGED, "x", "n2"),
994            remote(CHANGED, "x", "n3"),
995            remote("hacked", "y", "n2"),
996            remote("veto", "z", "n2"),
997        ];
998        if shuffle {
999            team.reverse();
1000            team.swap(0, 2);
1001        }
1002        lines(&home.join(TEAM_FILE), &team);
1003        home
1004    }
1005
1006    #[test]
1007    fn read_team_drops_own_copies_and_keeps_remote() {
1008        let home = team_store("team", false);
1009        let team = team_view(&home.join("lessons.jsonl"), true);
1010        assert_eq!(changed(&team, "L1"), 2, "{team:?}");
1011        assert_eq!(team.iter().filter(|o| o.session == "x").count(), 1, "one remote line per (kind, session, lessons, ms)");
1012        assert!(team.iter().all(|o| o.kind != "hacked" && o.kind != "veto"), "unshared kinds, an older peer's hive verdict among them, are ignored");
1013        assert!(team.iter().all(|o| o.repo != "app"), "local repos compare as tags");
1014        let _ = std::fs::remove_dir_all(home);
1015    }
1016
1017    #[test]
1018    fn read_team_is_order_independent() {
1019        let (a, b) = (team_store("order-a", false), team_store("order-b", true));
1020        let ls = [crate::conscience::Challenge { id: "L1".into(), ..Default::default() }];
1021        let m = |h: &Path| crate::memory::derive(&team_view(&h.join("lessons.jsonl"), true), &ls, None);
1022        assert_eq!(m(&a), m(&b));
1023        let _ = std::fs::remove_dir_all(a);
1024        let _ = std::fs::remove_dir_all(b);
1025    }
1026
1027    /// A node's whole fold reaches its peers: a veto that outranks the session's change, a regret's
1028    /// credit, and a session that only graded (no tick on either node). A peer holding only the team file
1029    /// derives the same memory as the node that recorded it.
1030    #[test]
1031    fn a_peer_derives_the_same_memory_from_the_team_file() {
1032        let (a, b) = (tmp("fold-a"), tmp("fold-b"));
1033        let regret = Outcome { cause: "episode-1".into(), credit: 0.5, ..o(REGRETTED, "s2", &["L1"], 40) };
1034        let log = [
1035            o(GRADED, "s0", &["L1"], 5),
1036            o(ASKED, "s1", &["L1"], 10),
1037            o(CHANGED, "s1", &["L1"], 20),
1038            o(VETOED, "s1", &["L1"], 30),
1039            o(ASKED, "s2", &["L1"], 35),
1040            regret,
1041        ];
1042        lines(&a.join("outcomes.jsonl"), &log.map(|x| serde_json::to_string(&x).unwrap()));
1043        publish(&a);
1044        std::fs::copy(a.join(TEAM_FILE), b.join(TEAM_FILE)).unwrap();
1045        let ls = [crate::conscience::Challenge { id: "L1".into(), origin_ms: 1, ..Default::default() }];
1046        let m = |h: &Path| crate::memory::derive(&team_view(&h.join("lessons.jsonl"), true), &ls, None);
1047        let (here, there) = (m(&a), m(&b));
1048        assert_eq!((here["L1"].vetoed, here["L1"].changed, here["L1"].regretted), (1, 0, 0.5), "{here:?}");
1049        assert_eq!(here, there);
1050        let _ = std::fs::remove_dir_all(a);
1051        let _ = std::fs::remove_dir_all(b);
1052    }
1053
1054    /// The unguarded surface `SHARED_KINDS` documents, pinned until the deferred guards land: a remote
1055    /// line under one of this node's session tags is dropped here, whatever its kind, while a peer
1056    /// without that session takes it, and the stronger result wins there.
1057    #[test]
1058    fn a_remote_line_under_a_local_tag_counts_on_peers_only() {
1059        let (victim, peer) = (tmp("forged-victim"), tmp("forged-peer"));
1060        let own = o(CHANGED, "s1", &["L1"], 10);
1061        let forged = Shared { kind: CORRECTED.into(), s: tag("s1"), lessons: vec!["L1".into()], ms: 11, node: "n2".into(), ..Default::default() };
1062        let team = [serde_json::to_string(&share(&own, "me").unwrap()).unwrap(), serde_json::to_string(&forged).unwrap()];
1063        lines(&victim.join("outcomes.jsonl"), &[serde_json::to_string(&own).unwrap()]);
1064        lines(&victim.join(TEAM_FILE), &team);
1065        lines(&peer.join(TEAM_FILE), &team);
1066        let ls = [crate::conscience::Challenge { id: "L1".into(), ..Default::default() }];
1067        let m = |h: &Path| crate::memory::derive(&team_view(&h.join("lessons.jsonl"), true), &ls, None).remove("L1").unwrap();
1068        assert_eq!((m(&victim).changed, m(&victim).corrected), (1, 0), "the victim never sees the override");
1069        assert_eq!((m(&peer).changed, m(&peer).corrected), (0, 1), "a peer counts the forged correction");
1070        let _ = std::fs::remove_dir_all(victim);
1071        let _ = std::fs::remove_dir_all(peer);
1072    }
1073
1074    /// A peer's `tested` line counts for a lesson its session's shared records name, and for no other.
1075    #[test]
1076    fn a_peer_green_run_counts_only_for_lessons_its_session_was_given() {
1077        let home = tmp("tested-team");
1078        let asked = share(&o(ASKED, "p1", &["L1"], 10), "peer").unwrap();
1079        let ok = share(&o(TESTED, "p1", &["L1"], 20), "peer").unwrap();
1080        let stray = share(&o(TESTED, "p1", &["L2"], 20), "peer").unwrap();
1081        let alone = share(&o(TESTED, "p2", &["L1"], 20), "peer").unwrap();
1082        // A 0.3.0 peer's hive verdict is not shared any more, so it gives its session nothing.
1083        let verdict = Shared { kind: "verified".into(), ..share(&o(ASKED, "p3", &["L1"], 10), "peer").unwrap() };
1084        let unbacked = share(&o(TESTED, "p3", &["L1"], 20), "peer").unwrap();
1085        let all = [&asked, &ok, &stray, &alone, &verdict, &unbacked];
1086        lines(&home.join(TEAM_FILE), &all.map(|x| serde_json::to_string(x).unwrap()));
1087        let ls = ["L1", "L2"].map(|id| crate::conscience::Challenge { id: id.into(), ..Default::default() });
1088        let m = crate::memory::derive(&team_view(&home.join("lessons.jsonl"), true), &ls, None);
1089        assert_eq!((m["L1"].tested, m["L2"].tested), (1, 0), "{m:?}");
1090        let _ = std::fs::remove_dir_all(home);
1091    }
1092
1093    #[test]
1094    fn off_keeps_beliefs_local() {
1095        let home = team_store("off", false);
1096        let local = team_view(&home.join("lessons.jsonl"), false);
1097        assert_eq!(local, read(&home.join("outcomes.jsonl")));
1098        assert_eq!(changed(&local, "L1"), 1);
1099        let _ = std::fs::remove_dir_all(home);
1100    }
1101
1102    /// Several writers appending to one log at once (several drones on one store): every record
1103    /// reads back, none torn into `{A}{B}\n\n`.
1104    #[test]
1105    fn concurrent_appends_keep_every_record() {
1106        let home = tmp("concurrent");
1107        let p = home.join("outcomes.jsonl");
1108        let (writers, each) = (8, 2000);
1109        std::thread::scope(|scope| {
1110            for w in 0..writers {
1111                let p = &p;
1112                scope.spawn(move || (0..each).for_each(|i| append(p, &o(ASKED, &format!("s{w}"), &["A"], i)).unwrap()));
1113            }
1114        });
1115        assert_eq!(read(&p).len(), writers * each as usize);
1116        let _ = std::fs::remove_dir_all(home);
1117    }
1118
1119    #[test]
1120    fn append_and_read_round_trip() {
1121        let dir = std::env::temp_dir().join(format!("borg-outcome-{}", std::process::id()));
1122        let p = dir.join("outcomes.jsonl");
1123        let _ = std::fs::remove_file(&p);
1124        append(&p, &o(ASKED, "s", &["A"], 1)).unwrap();
1125        append(&p, &o(CORRECTED, "s", &["A"], 2)).unwrap();
1126        assert_eq!(read(&p), vec![o(ASKED, "s", &["A"], 1), o(CORRECTED, "s", &["A"], 2)]);
1127        let _ = std::fs::remove_dir_all(dir);
1128    }
1129}