Skip to main content

borg/
memory.rs

1//! Usefulness as the memory dynamics of a collective brain (internal evaluation "phase-r", "R1 design", not published).
2//!
3//! Everything here is derived from the append-only outcome log (outcome.rs), so merging two logs merges
4//! memory, whatever the line order. The belief readers derive it from the team view (outcome.rs
5//! `read_team`): this machine's log plus the redacted results the mesh syncs from teammates, each
6//! remote session under its hashed id and counted once per lesson, so teammates converge on one
7//! memory. The activity clock then counts the collective's sessions on every node, not this machine's
8//! alone. BORG_SHARE_OUTCOMES=off keeps it this machine's view. Per lesson:
9//!
10//! - **Uses.** A use is an `asked_then_changed` record: after the Stop block asked the lesson, the
11//!   drone changed its work on that point and the change avoids the consequence, as judged by the
12//!   consequence model at a later Stop call of the session (predict.rs `Judged`, main.rs `pending`;
13//!   no extra call). The lesson's creation counts as one use too, so
14//!   a new lesson is not invisible.
15//! - **Activation** (ACT-R base-level learning; equally a pheromone trail of evaporating deposits):
16//!   `A = ln(Σ_j Δt_j^(−d))` over the uses j, `d = 0.5` (`D_CONSOLIDATED` = 0.3 once consolidated).
17//!   Δt counts sessions of the collective since the use (the activity clock, `Clock`), plus one, never
18//!   wall time: a holiday erases nothing. Uses spread over time keep a lesson active longer than the
19//!   same number massed early; with a fixed d that comes from the later last use (a decay that depends
20//!   on the activation at each use, Pavlik and Anderson's spacing model, is a possible refinement).
21//! - **Usefulness belief** `Beta(1 + changed + ½·tested, 1 + ignored + 2·corrected + 3·vetoed +
22//!   regretted)` (the veto's 3 is `VETOED_W`), one result
23//!   per (session, lesson), vetoed > corrected > changed > ignored. `asked_not_corrected` (settled by `borg
24//!   learn`) without a change is ignored. A correction counts twice, as in outcome.rs; a veto (`borg no`,
25//!   veto.rs) three times, above a correction as the design asks (no eval behind the 3 yet). Regretted is
26//!   the weighted share of later fixes of decisions the lesson was asked before (outcome.rs `regretted`),
27//!   each regret once, at most `REGRET` × 0.875: a separate, later event, added on top of the session's
28//!   result unless that result is a correction or a veto (the stronger result already counts it). A fractional β is sampled by randomised rounding (`round_seeded`).
29//!   `tested` (outcome.rs) is the session's own tests going green after the ask, one per (lesson,
30//!   session), weighed `TESTED_W`: additive to the session's result (it is about the work, a session
31//!   result about the engineer's reaction), never a use, no tick, and nothing for a session whose
32//!   result is a correction or a veto.
33//!   A fractional α (a candidate weight) is rounded the same way
34//!   (`draw`). `Weights` holds the hand-set weights so that `borg debug ope` (ope.rs)
35//!   can replay the belief under other values offline; the counts `derive` returns are constant-free.
36//! - **Weight** = `squash(A) × (0.5 + x)`, where `x` is a Thompson sample from the belief, seeded by
37//!   (session, lesson) so a session sees one stable ranking, or the belief's mean without a session.
38//!   `squash(A) = 2 / (1 + e^(−A/2))` lies in (0, 2) and is 1 at A = 0, a lesson created this session;
39//!   `0.5 + x` lies in (0.5, 1.5) with mean 1 for a lesson without outcomes. So a new lesson ranks as
40//!   BM25 ranks it, in expectation; uncertain lessons get tried (their samples spread), proven ones win,
41//!   harmful ones fade. The sample is tempered (±50 %) so that a lesson with no evidence cannot drop a
42//!   strong BM25 match to nothing. In ranking (conscience.rs), the relative bar uses the mean weight and
43//!   the session's draw orders the lessons over the bar for the limited slots: exploration never pushes
44//!   a strong match under the bar, it decides who gets asked when more qualify than fit.
45//! - **Consolidated** (long-term): changed in at least `CONSOLIDATE_SESSIONS` distinct sessions, across
46//!   at least two repos or authored by at least two engineers. Consolidated lessons decay slower and
47//!   may be delivered early even without a scope (main.rs `early`).
48//! - **Dormant**: not consolidated and `A < DORMANT_BELOW` (about 54 sessions of the collective
49//!   without a use). Never deleted: a dormant lesson is recalled only on a strong match (conscience.rs
50//!   `rank`), and one use reactivates it.
51//! - **Whose sessions count.** Disuse is measured where the lesson could have been used, by its reach,
52//!   on whichever node the sessions ran. ACT-R's decay stands for
53//!   the opportunities a memory had and missed; a session that could never have recalled the lesson is
54//!   no such opportunity. So a team lesson ages with every session of the collective, a teammate's
55//!   included: they all see it, and a lesson none of their drones acts on for ~54 sessions has stopped
56//!   mattering to the people it reaches, even when its own author's node is quiet. A repo convention ages
57//!   only with the sessions in its repo (`Clock::eligible`, by the repo of each session's lines), so
58//!   neither a teammate's busy week elsewhere nor an engineer's own work in another repo lets it fade.
59//!   This is coarser than reach.rs `deliverable`: a session's repo is its working directory's
60//!   (hook.rs `repo_of`), not every repo it touched, so a session started in one repo that edits
61//!   another is no tick for the other's conventions (a use there still counts: it ranks at the next
62//!   eligible tick). The system gate (systems.rs `gate`) is not applied, so a lesson scoped to other
63//!   systems ages with every session its reach allows. In the team view each repo is a case-sensitive
64//!   tag, so a clone whose directory name differs in case from the lesson's repo does not age it.
65
66use std::collections::{HashMap, HashSet};
67
68use crate::conscience::Challenge;
69use crate::outcome::{self, Outcome};
70
71pub const D: f64 = 0.5;
72/// What one regret at full strength adds to a lesson's β: half a correction (a correction is
73/// same-session, same-concern evidence; a regret is delayed and shared among the lessons asked). A veto
74/// weighs more than a correction, so regret < corrected < vetoed.
75pub const REGRET: f64 = 1.0;
76/// What one `tested` result (outcome.rs) adds to α: half a change (design-chosen, `borg debug ope`
77/// estimates others): the session's own runner said the work passes.
78pub const TESTED_W: f64 = 0.5;
79/// What one veto (`borg no`, veto.rs) adds to a lesson's β: above a correction's 2, as the design asks
80/// (design-chosen, no eval behind it yet).
81pub const VETOED_W: u32 = 3;
82
83/// The belief's hand-set weights (design-chosen; `borg debug ope` estimates others offline, never applies them).
84#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize)]
85pub struct Weights {
86    pub vetoed: f64,
87    pub regret: f64,
88    pub tested: f64,
89}
90
91/// The weights in force.
92pub const WEIGHTS: Weights = Weights { vetoed: VETOED_W as f64, regret: REGRET, tested: TESTED_W };
93pub const D_CONSOLIDATED: f64 = 0.3;
94pub const CONSOLIDATE_SESSIONS: usize = 3;
95pub const DORMANT_BELOW: f64 = -2.0;
96
97/// The collective's activity clock: its sessions in the order they first appear in the outcome log
98/// (by first ms, then id: independent of line order). Only the kinds the team shares
99/// (outcome.rs `SHARED_KINDS`) tick it, so every node counts the same sessions from the same first
100/// ms: a session that only graded never moves it, and neither does a `tested` result (about the
101/// session's work).
102#[derive(Debug, Default)]
103pub struct Clock {
104    first: Vec<(i64, String)>,
105    at: HashMap<String, u64>,
106    /// Per tick, the repos its session's ticking lines name (raw here, `tag`ged in the team view).
107    repos: Vec<HashSet<String>>,
108}
109
110impl Clock {
111    pub fn of(outcomes: &[Outcome]) -> Self {
112        let mut first: HashMap<&str, i64> = HashMap::new();
113        let mut repos: HashMap<&str, HashSet<String>> = HashMap::new();
114        for o in outcomes.iter().filter(|o| outcome::SHARED_KINDS.contains(&o.kind.as_str()) && o.kind != outcome::TESTED) {
115            let m = first.entry(o.session.as_str()).or_insert(o.ms);
116            *m = (*m).min(o.ms);
117            if !o.repo.is_empty() {
118                repos.entry(o.session.as_str()).or_default().insert(o.repo.clone());
119            }
120        }
121        let mut first: Vec<(i64, String)> = first.into_iter().map(|(s, ms)| (ms, s.to_string())).collect();
122        first.sort();
123        let at = first.iter().enumerate().map(|(i, (_, s))| (s.clone(), i as u64)).collect();
124        let repos = first.iter().map(|(_, s)| repos.remove(s.as_str()).unwrap_or_default()).collect();
125        Clock { first, at, repos }
126    }
127
128    /// The ticks of the sessions `c` counts its disuse in, ascending, or None for every tick: a repo
129    /// convention's are the sessions with a line in its repo, matched ignoring case as reach.rs
130    /// `deliverable` does or, in the team view, by its exact `tag`; any other reach's are all of them.
131    pub fn eligible(&self, c: &Challenge) -> Option<Vec<u64>> {
132        if c.reach != crate::reach::REPO || c.repo.is_empty() {
133            return None;
134        }
135        let tagged = outcome::tag(&c.repo);
136        Some((0..self.repos.len() as u64).filter(|t| self.repos[*t as usize].iter().any(|r| r.eq_ignore_ascii_case(&c.repo) || *r == tagged)).collect())
137    }
138
139    /// The tick of `session`: its place on the clock, or the next tick for a session not in the log
140    /// (or none given).
141    pub fn now(&self, session: Option<&str>) -> u64 {
142        session.and_then(|s| self.at.get(s).copied()).unwrap_or(self.first.len() as u64)
143    }
144
145    /// The tick of a session.
146    pub fn of_session(&self, session: &str) -> u64 {
147        self.at.get(session).copied().unwrap_or(self.first.len() as u64)
148    }
149
150    /// The tick at wall time `ms`: how many sessions had started before it.
151    pub fn at_ms(&self, ms: i64) -> u64 {
152        self.first.partition_point(|(m, _)| *m < ms) as u64
153    }
154}
155
156/// `ln(Σ age^(−d))` over ages in ticks (each at least 1); −∞ without any.
157pub fn activation(ages: impl IntoIterator<Item = u64>, d: f64) -> f64 {
158    ages.into_iter().map(|a| (a.max(1) as f64).powf(-d)).sum::<f64>().ln()
159}
160
161/// `2 / (1 + e^(−A/2))`: (0, 2), 1 at A = 0.
162pub fn squash(a: f64) -> f64 {
163    2.0 / (1.0 + (-a / 2.0).exp())
164}
165
166/// One lesson's memory.
167#[derive(Debug, Clone, PartialEq)]
168pub struct Memory {
169    pub activation: f64,
170    /// Sessions whose result for this lesson is a change, an ignore, a correction, a veto.
171    pub changed: u32,
172    pub ignored: u32,
173    pub corrected: u32,
174    pub vetoed: u32,
175    /// Σ credit of the regrets that blame it (outcome.rs `regretted`), each regret once.
176    pub regretted: f64,
177    /// Sessions whose own tests went green after it was asked (outcome.rs `tested`).
178    pub tested: u32,
179    pub consolidated: bool,
180    pub dormant: bool,
181}
182
183impl Memory {
184    /// α under weights `w`: the counts are constant-free, so one `derive` serves every candidate.
185    pub fn alpha_with(&self, w: &Weights) -> f64 {
186        1.0 + self.changed as f64 + w.tested * self.tested as f64
187    }
188
189    /// β under weights `w`.
190    pub fn beta_with(&self, w: &Weights) -> f64 {
191        (1 + self.ignored + 2 * self.corrected) as f64 + w.vetoed * self.vetoed as f64 + w.regret * self.regretted
192    }
193
194    pub fn alpha(&self) -> f64 {
195        self.alpha_with(&WEIGHTS)
196    }
197
198    pub fn beta(&self) -> f64 {
199        self.beta_with(&WEIGHTS)
200    }
201
202    pub fn mean(&self) -> f64 {
203        self.alpha() / (self.alpha() + self.beta())
204    }
205
206    /// `squash(A) × (0.5 + x)`: x the Thompson sample for (`session`, `id`), or the mean without a session.
207    pub fn weight(&self, id: &str, session: Option<&str>) -> f64 {
208        let x = match session {
209            Some(s) => draw(self.alpha(), self.beta(), seed(s, id)),
210            None => self.mean(),
211        };
212        squash(self.activation) * (0.5 + x)
213    }
214}
215
216/// Each lesson's memory as of `session` (its tick; the next tick when absent or None).
217pub fn derive(outcomes: &[Outcome], lessons: &[Challenge], session: Option<&str>) -> HashMap<String, Memory> {
218    let clock = Clock::of(outcomes);
219    let now = clock.now(session);
220    // Per (lesson, session): 3 vetoed > 2 corrected > 1 changed > 0 ignored; and the repos of the changes.
221    let mut result: HashMap<&str, HashMap<&str, u8>> = HashMap::new();
222    let mut uses: HashMap<&str, HashSet<&str>> = HashMap::new();
223    let mut repos: HashMap<&str, HashSet<&str>> = HashMap::new();
224    // Per (lesson, regret): its credit, the largest of duplicate lines (merging two logs merges memory),
225    // and the deciding session it blames.
226    let mut regret: HashMap<(&str, &str), (f64, &str)> = HashMap::new();
227    // Per lesson: the sessions whose own tests went green after it was asked.
228    let mut tested: HashMap<&str, HashSet<&str>> = HashMap::new();
229    for o in outcomes {
230        if o.kind == outcome::TESTED {
231            for l in &o.lessons {
232                tested.entry(l.as_str()).or_default().insert(o.session.as_str());
233            }
234            continue;
235        }
236        if o.kind == outcome::REGRETTED {
237            for l in &o.lessons {
238                let e = regret.entry((l.as_str(), o.cause.as_str())).or_insert((0.0, o.session.as_str()));
239                e.0 = e.0.max(o.credit.clamp(0.0, 1.0));
240            }
241            continue;
242        }
243        let r = match o.kind.as_str() {
244            outcome::VETOED => 3,
245            outcome::CORRECTED => 2,
246            outcome::CHANGED => 1,
247            outcome::IGNORED | outcome::NOT_CORRECTED => 0,
248            _ => continue,
249        };
250        for l in &o.lessons {
251            let e = result.entry(l.as_str()).or_default().entry(o.session.as_str()).or_insert(r);
252            *e = (*e).max(r);
253            if r == 1 {
254                uses.entry(l.as_str()).or_default().insert(o.session.as_str());
255                repos.entry(l.as_str()).or_default().insert(o.repo.as_str());
256            }
257        }
258    }
259    // A regret of a session whose result for the lesson is a correction or a veto adds nothing: the
260    // stronger result already counts that session's failure, whichever was recorded first.
261    let mut regretted: HashMap<&str, f64> = HashMap::new();
262    for ((l, _), (credit, s)) in regret {
263        if result.get(l).and_then(|m| m.get(s)).is_none_or(|r| *r < 2) {
264            *regretted.entry(l).or_default() += credit;
265        }
266    }
267    // Per (reach, repo): the ticks its lessons were eligible in (`Clock::eligible`).
268    let mut eligible: HashMap<(&str, &str), Option<Vec<u64>>> = HashMap::new();
269    lessons
270        .iter()
271        .map(|c| {
272            let per = result.get(c.id.as_str());
273            let count = |k: u8| per.map_or(0, |m| m.values().filter(|r| **r == k).count() as u32);
274            let (changed, ignored, corrected, vetoed) = (count(1), count(0), count(2), count(3));
275            // Uses: every session whose result is a change (a later correction in that session still
276            // deposited the change: the drone did act on it), plus the lesson's creation.
277            let used: Vec<u64> = uses.get(c.id.as_str()).map(|u| u.iter().map(|s| clock.of_session(s)).collect()).unwrap_or_default();
278            let changed_sessions = used.len();
279            let consolidated = changed_sessions >= CONSOLIDATE_SESSIONS && (repos.get(c.id.as_str()).is_some_and(|r| r.len() >= 2) || c.engineers.len() >= 2);
280            let d = if consolidated { D_CONSOLIDATED } else { D };
281            let born = clock.at_ms(c.origin_ms);
282            // Δt in the sessions the lesson was eligible in: a tick's place among them (itself excluded).
283            let eligible = eligible.entry((c.reach.as_str(), c.repo.as_str())).or_insert_with(|| clock.eligible(c));
284            let rank = |t: u64| eligible.as_ref().map_or(t, |e| e.partition_point(|x| *x < t) as u64);
285            let now = rank(now);
286            let ages = used.into_iter().chain([born]).map(|t| now.saturating_sub(rank(t)) + 1);
287            let a = activation(ages, d);
288            let regretted = regretted.get(c.id.as_str()).copied().unwrap_or(0.0);
289            // A green run of a session the engineer corrected or vetoed on the lesson adds nothing.
290            let tested = tested.get(c.id.as_str()).map_or(0, |t| t.iter().filter(|s| per.and_then(|m| m.get(**s)).is_none_or(|r| *r < 2)).count() as u32);
291            let m = Memory { activation: a, changed, ignored, corrected, vetoed, regretted, tested, consolidated, dormant: !consolidated && a < DORMANT_BELOW };
292            (c.id.clone(), m)
293        })
294        .collect()
295}
296
297/// FNV-1a over `session \0 lesson`: a stable seed (std's hasher may change between Rust versions).
298pub fn seed(session: &str, lesson: &str) -> u64 {
299    let mut h: u64 = 0xcbf29ce484222325;
300    for b in session.bytes().chain([0]).chain(lesson.bytes()) {
301        h ^= b as u64;
302        h = h.wrapping_mul(0x100000001b3);
303    }
304    h
305}
306
307/// SplitMix64.
308pub struct Rng(u64);
309
310impl Rng {
311    pub fn new(seed: u64) -> Self {
312        Rng(seed)
313    }
314
315    pub fn next_u64(&mut self) -> u64 {
316        self.0 = self.0.wrapping_add(0x9e3779b97f4a7c15);
317        let mut z = self.0;
318        z = (z ^ (z >> 30)).wrapping_mul(0xbf58476d1ce4e5b9);
319        z = (z ^ (z >> 27)).wrapping_mul(0x94d049bb133111eb);
320        z ^ (z >> 31)
321    }
322
323    /// Uniform in (0, 1).
324    pub fn unit(&mut self) -> f64 {
325        ((self.next_u64() >> 11) as f64 + 0.5) / (1u64 << 53) as f64
326    }
327}
328
329/// `x` rounded to an integer at random, unbiased: ⌊x⌋ + 1 with probability frac(x), drawn from `seed`.
330/// An integer `x` is returned as is without a draw, so a lesson without regrets samples as before.
331pub fn round_seeded(x: f64, seed: u64) -> u32 {
332    let (whole, frac) = (x.floor(), x - x.floor());
333    match frac == 0.0 || Rng::new(seed).unit() >= frac {
334        true => whole as u32,
335        false => whole as u32 + 1,
336    }
337}
338
339/// A Thompson draw from Beta(alpha, beta), both randomised-rounded (`round_seeded`); integer
340/// parameters draw exactly as before.
341pub fn draw(alpha: f64, beta: f64, seed: u64) -> f64 {
342    sample_beta(round_seeded(alpha, seed ^ 0x416c_7068), round_seeded(beta, seed ^ 0x5265_6772), seed)
343}
344
345/// A Beta(a, b) draw for integer a, b ≥ 1: X / (X + Y) with X ~ Gamma(a), Y ~ Gamma(b), each a sum of
346/// exponentials. Counts above 1000 are scaled down to keep the cost bounded (the shape barely moves).
347pub fn sample_beta(a: u32, b: u32, seed: u64) -> f64 {
348    let scale = (a.max(b) as f64 / 1000.0).max(1.0);
349    let (a, b) = (((a as f64 / scale).round() as u32).max(1), ((b as f64 / scale).round() as u32).max(1));
350    let mut rng = Rng::new(seed);
351    let mut gamma = |k: u32| (0..k).map(|_| -rng.unit().ln()).sum::<f64>();
352    let x = gamma(a);
353    let y = gamma(b);
354    x / (x + y)
355}
356
357/// A deterministic shuffle of `items` for `session` (Fisher-Yates on `Rng`): the order questions are
358/// shown in, so that no lesson always sits first (position bias).
359pub fn shuffle<T>(items: &mut [T], session: &str) {
360    let mut rng = Rng::new(seed(session, "#order"));
361    for i in (1..items.len()).rev() {
362        let j = (rng.next_u64() % (i as u64 + 1)) as usize;
363        items.swap(i, j);
364    }
365}
366
367/// Words of `text` that discriminate in this store: tokens (conscience.rs `tokens`) that occur in at
368/// most a third of the lessons' questions.
369fn distinctive(all: &[Challenge]) -> impl Fn(&str) -> HashSet<String> + '_ {
370    let mut df: HashMap<String, usize> = HashMap::new();
371    for c in all {
372        for t in crate::conscience::tokens(&c.question).into_iter().collect::<HashSet<_>>() {
373            *df.entry(t).or_default() += 1;
374        }
375    }
376    let n = all.len().max(1);
377    move |text: &str| crate::conscience::tokens(text).into_iter().filter(|t| df.get(t).is_some_and(|d| 3 * d <= n)).collect()
378}
379
380/// Undeclared, a change needs this many of the lesson's distinctive words new in the drone's work.
381pub const NOVEL_WORDS: usize = 3;
382
383/// The word rule: what the drone did with each asked lesson after it was delivered: (changed, ignored).
384/// Never an outcome: its labels measured shared vocabulary (55% "changed" against 38% on a mismatched
385/// control, internal evaluation "phase-r" R1, not published). It only decides whether an early-delivered lesson counts as done
386/// (main.rs `early_acted`) and serves the offline replay (`borg debug resolve`); asked lessons are labelled by
387/// the consequence model (main.rs `pending`).
388/// - Declared, in its final message: `<id>: applies` is a change, `<id>: skip` an ignore (the block
389///   asks for one such line per question).
390/// - Undeclared: a change when its work after the block (`after`: the edits since, and the final
391///   message) brings in at least `min_new` of the lesson's distinctive words (`distinctive`, over `all`)
392///   that its work before the block (`before`: prompt, touches, earlier edits, the blocked message) did
393///   not contain. Shared words alone are not enough: a drone working on the lesson's topic already used
394///   them (the R1 replay's control matched other cases' edits as often as a run's own).
395pub fn resolve(asked: &[&Challenge], all: &[Challenge], before: &str, after: &str, final_msg: &str, min_new: usize) -> (Vec<String>, Vec<String>) {
396    let words = distinctive(all);
397    let had: HashSet<String> = crate::conscience::tokens(before).into_iter().collect();
398    let new: HashSet<String> = words(&format!("{after}\n{final_msg}")).into_iter().filter(|w| !had.contains(w)).collect();
399    let lower = final_msg.to_lowercase();
400    let (mut changed, mut ignored) = (Vec::new(), Vec::new());
401    for c in asked {
402        let verdict = declared(&lower, &c.id.to_lowercase());
403        let applies = match verdict {
404            Some(v) => v,
405            None => words(&c.question).intersection(&new).count() >= min_new,
406        };
407        match applies {
408            true => changed.push(c.id.clone()),
409            false => ignored.push(c.id.clone()),
410        }
411    }
412    (changed, ignored)
413}
414
415/// `<id>: applies` (Some(true)) or `<id>: skip` (Some(false)) in a lowercased message; brackets,
416/// backticks, a dash or a colon between, the last such line winning.
417pub fn declared(lower: &str, id: &str) -> Option<bool> {
418    let mut out = None;
419    let mut from = 0;
420    while let Some(i) = lower[from..].find(id) {
421        let at = from + i + id.len();
422        let rest: String = lower[at..].chars().take(16).collect();
423        let rest = rest.trim_start_matches(|c: char| c == ']' || c == '`' || c == '*' || c == ':' || c == '-' || c == '–' || c.is_whitespace());
424        if rest.starts_with("applies") || rest.starts_with("apply") {
425            out = Some(true);
426        } else if rest.starts_with("skip") {
427            out = Some(false);
428        }
429        from = at;
430    }
431    out
432}
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437    use crate::outcome::{CHANGED, CORRECTED, IGNORED, NOT_CORRECTED};
438
439    fn o(kind: &str, session: &str, lesson: &str, ms: i64, repo: &str) -> Outcome {
440        Outcome { kind: kind.into(), session: session.into(), lessons: vec![lesson.into()], ms, repo: repo.into(), ..Default::default() }
441    }
442
443    fn lesson(id: &str, ms: i64) -> Challenge {
444        Challenge { id: id.into(), origin_ms: ms, question: format!("question of {id}"), ..Default::default() }
445    }
446
447    /// `n` sessions s0..s{n-1} that ask some other lesson, at ms 100, 200, ...
448    fn sessions(n: usize) -> Vec<Outcome> {
449        (0..n).map(|i| o(outcome::ASKED, &format!("s{i:03}"), "other", 100 * (i as i64 + 1), "r")).collect()
450    }
451
452    #[test]
453    fn a_new_lesson_is_neutral_and_activation_decays_with_the_session_clock_not_wall_time() {
454        let l = lesson("L", 0);
455        let m = &derive(&[], std::slice::from_ref(&l), None)["L"];
456        assert_eq!((m.activation, m.mean(), m.weight("L", None)), (0.0, 0.5, 1.0));
457        // Ten sessions later without a use: A = ln(11^-0.5) < 0; weight below 1 but well above 0.
458        let m = &derive(&sessions(10), std::slice::from_ref(&l), None)["L"];
459        assert!((m.activation - (11f64).powf(-0.5).ln()).abs() < 1e-12);
460        assert!(m.weight("L", None) < 1.0 && m.weight("L", None) > 0.5);
461        // The same ten sessions spread over a year (a holiday in between): the same activation.
462        let spread: Vec<Outcome> = sessions(10)
463            .into_iter()
464            .enumerate()
465            .map(|(i, mut x)| {
466                x.ms = if i < 5 { x.ms } else { x.ms + 365 * 86_400_000 };
467                x
468            })
469            .collect();
470        assert_eq!(derive(&spread, &[l], None)["L"].activation, m.activation);
471    }
472
473    #[test]
474    fn a_repo_convention_ages_only_with_the_sessions_of_its_repo_and_a_team_lesson_with_all() {
475        let conv = Challenge { reach: crate::reach::REPO.into(), repo: "a".into(), ..lesson("C", 0) };
476        let both = [lesson("T", 0), conv];
477        let in_repo = |log: Vec<Outcome>, repo: &dyn Fn(usize) -> String| -> Vec<Outcome> {
478            log.into_iter()
479                .enumerate()
480                .map(|(i, mut x)| {
481                    x.repo = repo(i);
482                    x
483                })
484                .collect()
485        };
486        // Sixty sessions elsewhere: the team lesson goes dormant, the convention had no chance to be used.
487        let m = derive(&in_repo(sessions(60), &|_| "b".into()), &both, None);
488        assert!(m["T"].dormant);
489        assert_eq!((m["C"].activation, m["C"].dormant), (0.0, false));
490        // Sixty in its repo, raw, in another case or as the team view's tag: it fades like the team lesson.
491        for repo in ["a".to_string(), "A".to_string(), outcome::tag("a")] {
492            let m = derive(&in_repo(sessions(60), &|_| repo.clone()), &both, None);
493            assert_eq!(m["C"].activation, m["T"].activation, "{repo}");
494            assert!(m["C"].dormant, "{repo}");
495        }
496        // Interleaved, every fourth session in its repo (ten of forty): born before the first (age 10 + 1),
497        // used in the second (nine sessions from it on, + 1).
498        let mut log = in_repo(sessions(40), &|i| if i % 4 == 0 { "a".into() } else { "b".into() });
499        log.push(o(CHANGED, "s004", "C", 501, "a"));
500        let m = derive(&log, &both, None);
501        let want = activation([11, 10], D);
502        assert!((m["C"].activation - want).abs() < 1e-12, "{} vs {want}", m["C"].activation);
503    }
504
505    #[test]
506    fn activation_is_monotone_in_uses_and_spaced_uses_outlast_massed_ones() {
507        let l = lesson("L", 0);
508        let mut log = sessions(30);
509        let a0 = derive(&log, std::slice::from_ref(&l), None)["L"].activation;
510        log.push(o(CHANGED, "s010", "L", 1101, "r"));
511        let a1 = derive(&log, std::slice::from_ref(&l), None)["L"].activation;
512        log.push(o(CHANGED, "s020", "L", 2101, "r"));
513        let a2 = derive(&log, std::slice::from_ref(&l), None)["L"].activation;
514        assert!(a0 < a1 && a1 < a2, "{a0} {a1} {a2}");
515        // Two uses massed early (sessions 1, 2) vs spaced (1, 20): measured long after, spaced is higher.
516        // (Plain base-level learning: the advantage comes from the later last use.)
517        let at = |uses: &[usize]| {
518            let mut log = sessions(60);
519            log.extend(uses.iter().map(|i| o(CHANGED, &format!("s{i:03}"), "L", 100 * (*i as i64 + 1) + 1, "r")));
520            derive(&log, std::slice::from_ref(&l), None)["L"].activation
521        };
522        assert!(at(&[1, 20]) > at(&[1, 2]));
523    }
524
525    #[test]
526    fn belief_counts_each_session_once_corrected_over_changed_over_ignored() {
527        let l = lesson("L", 0);
528        let log = vec![
529            o(CHANGED, "a", "L", 1, "r"),
530            o(NOT_CORRECTED, "a", "L", 2, "r"), // settled later: the change stands
531            o(IGNORED, "b", "L", 3, "r"),
532            o(CHANGED, "c", "L", 4, "r"),
533            o(CORRECTED, "c", "L", 5, "r"), // corrected anyway: corrected wins
534            o(NOT_CORRECTED, "d", "L", 6, "r"),
535        ];
536        let m = &derive(&log, &[l], None)["L"];
537        assert_eq!((m.changed, m.ignored, m.corrected), (1, 2, 1));
538        assert_eq!((m.alpha(), m.beta()), (2.0, 5.0));
539    }
540
541    #[test]
542    fn a_veto_outweighs_a_correction_and_a_change_in_its_session() {
543        let l = lesson("L", 0);
544        let log = vec![o(CHANGED, "a", "L", 1, "r"), o(CORRECTED, "a", "L", 2, "r"), o(outcome::VETOED, "a", "L", 3, "r"), o(CORRECTED, "b", "L", 4, "r")];
545        let m = &derive(&log, std::slice::from_ref(&l), None)["L"];
546        assert_eq!((m.changed, m.ignored, m.corrected, m.vetoed), (0, 0, 1, 1));
547        assert_eq!((m.alpha(), m.beta()), (1.0, (1 + 2 + 3) as f64));
548        // The veto's change still deposited a use: activation counts it.
549        let without = derive(&log[1..], std::slice::from_ref(&l), None)["L"].activation;
550        assert!(m.activation > without, "{} {without}", m.activation);
551        // With equal counts, a vetoed lesson's belief is below a corrected one's.
552        let only = |kind: &str| derive(&[o(kind, "a", "L", 1, "r")], std::slice::from_ref(&l), None)["L"].mean();
553        assert!(only(outcome::VETOED) < only(CORRECTED), "{} {}", only(outcome::VETOED), only(CORRECTED));
554        assert_eq!(only(outcome::VETOED), 1.0 / 5.0);
555    }
556
557    fn regret(session: &str, lesson: &str, ms: i64, cause: &str, credit: f64) -> Outcome {
558        Outcome { cause: cause.into(), credit, ..o(outcome::REGRETTED, session, lesson, ms, "r") }
559    }
560
561    #[test]
562    fn a_regret_lowers_belief_less_than_a_correction() {
563        let l = lesson("L", 0);
564        let mem = |log: &[Outcome]| derive(log, std::slice::from_ref(&l), None)["L"].clone();
565        let asked = o(outcome::ASKED, "a", "L", 1, "r");
566        let full = mem(&[asked.clone(), regret("a", "L", 9, "R1", 1.0)]);
567        assert_eq!((full.regretted, full.beta(), full.mean()), (1.0, 2.0, 1.0 / 3.0));
568        assert_eq!(mem(&[asked.clone(), o(CORRECTED, "a", "L", 9, "r")]).mean(), 1.0 / 4.0);
569        // Duplicate lines of one regret count once; two regrets add up; line order does not matter.
570        let log = vec![asked.clone(), regret("a", "L", 9, "R1", 0.25), regret("a", "L", 9, "R1", 0.25), regret("a", "L", 12, "R2", 0.5)];
571        assert_eq!(mem(&log).regretted, 0.75);
572        let mut reversed = log.clone();
573        reversed.reverse();
574        assert_eq!(mem(&reversed), mem(&log));
575        // On top of the session's result, never a use: activation is unchanged.
576        let changed = mem(&[asked.clone(), o(CHANGED, "a", "L", 5, "r"), regret("a", "L", 9, "R1", 0.5)]);
577        assert_eq!((changed.changed, changed.regretted), (1, 0.5));
578        assert_eq!(mem(&[asked.clone(), regret("a", "L", 9, "R1", 1.0)]).activation, mem(std::slice::from_ref(&asked)).activation);
579        // A correction or a veto of the deciding session, recorded before or after the regret, takes it.
580        for kind in [CORRECTED, outcome::VETOED] {
581            let late = mem(&[asked.clone(), regret("a", "L", 9, "R1", 0.5), o(kind, "a", "L", 12, "r")]);
582            assert_eq!(late.regretted, 0.0, "{kind}");
583            assert_eq!(late.beta(), mem(&[asked.clone(), o(kind, "a", "L", 12, "r")]).beta(), "{kind}");
584        }
585        let other = mem(&[asked.clone(), o(CORRECTED, "b", "L", 3, "r"), regret("a", "L", 9, "R1", 0.5)]);
586        assert_eq!(other.regretted, 0.5, "another session's correction leaves the regret");
587    }
588
589    #[test]
590    fn an_older_stores_hive_verdicts_change_no_belief_and_no_tick() {
591        let l = lesson("L", 0);
592        let mem = |log: &[Outcome]| derive(log, std::slice::from_ref(&l), None)["L"].clone();
593        let asked = o(outcome::ASKED, "a", "L", 1, "r");
594        for kind in ["verified", "rejected", "hacked"] {
595            let old = Outcome { cause: "g/t1.v1".into(), ..o(kind, "a", "L", 9, "r") };
596            assert_eq!(mem(&[asked.clone(), old.clone()]), mem(std::slice::from_ref(&asked)), "{kind}");
597            assert_eq!(Clock::of(&[old]).now(None), 0, "{kind}");
598        }
599    }
600
601    #[test]
602    fn a_green_test_run_weighs_half_a_change_and_never_ticks() {
603        let l = lesson("L", 0);
604        let mem = |log: &[Outcome]| derive(log, std::slice::from_ref(&l), None)["L"].clone();
605        let asked = o(outcome::ASKED, "a", "L", 1, "r");
606        let t = mem(&[asked.clone(), o(outcome::TESTED, "a", "L", 9, "r"), o(outcome::TESTED, "a", "L", 10, "r")]);
607        assert_eq!((t.tested, t.alpha(), t.changed), (1, 1.0 + TESTED_W, 0), "once per session, never a use");
608        assert_eq!(t.activation, mem(std::slice::from_ref(&asked)).activation);
609        assert_eq!(Clock::of(&[o(outcome::TESTED, "z", "L", 9, "r")]).now(None), 0, "no tick");
610        // The session's correction or veto of the lesson outranks it, in any line order.
611        for k in [outcome::CORRECTED, outcome::VETOED] {
612            let log = [o(outcome::TESTED, "a", "L", 9, "r"), asked.clone(), o(k, "a", "L", 5, "r")];
613            assert_eq!(mem(&log).tested, 0, "{k}");
614        }
615        assert_eq!(Weights { tested: 2.0, ..WEIGHTS }.tested, 2.0);
616        assert_eq!(t.alpha_with(&Weights { tested: 2.0, ..WEIGHTS }), 3.0);
617    }
618
619    #[test]
620    fn fractional_beta_sampling_is_unbiased_and_integer_beta_unchanged() {
621        let n = 20_000;
622        let mean = (0..n).map(|i| round_seeded(2.5, seed(&format!("s{i}"), "L")) as f64).sum::<f64>() / n as f64;
623        assert!((mean - 2.5).abs() < 0.02, "{mean}");
624        assert!((0..100).all(|i| round_seeded(3.0, i) == 3));
625        // A lesson without regrets draws exactly what it drew before fractional β (pinned).
626        let m = Memory { activation: 0.0, changed: 2, ignored: 1, corrected: 1, vetoed: 0, regretted: 0.0, tested: 0, consolidated: false, dormant: false };
627        assert_eq!(m.weight("L", Some("s")), squash(0.0) * (0.5 + sample_beta(3, 4, seed("s", "L"))));
628        assert_eq!(m.weight("L", Some("s")), 1.0618369485259265);
629    }
630
631    #[test]
632    fn fractional_alpha_draws_unbiased_and_integer_unchanged() {
633        // Integer parameters: `draw` is the draw `weight` made before fractional α existed.
634        assert!((0..200).all(|i| draw(3.0, 4.0, i) == sample_beta(3, 4, i)));
635        assert!((0..200).all(|i| draw(2.0, 2.5, i) == sample_beta(2, round_seeded(2.5, i ^ 0x5265_6772), i)));
636        // A fractional α rounds unbiased: Beta(1.5, 1) draws average between Beta(1, 1)'s and Beta(2, 1)'s.
637        let n = 20_000;
638        let mean = |a: f64| (0..n).map(|i| draw(a, 1.0, seed(&format!("s{i}"), "L"))).sum::<f64>() / n as f64;
639        let (lo, mid, hi) = (mean(1.0), mean(1.5), mean(2.0));
640        assert!((mid - (lo + hi) / 2.0).abs() < 0.01, "{lo} {mid} {hi}");
641        let m = Memory { tested: 2, ..derive(&[], &[lesson("L", 0)], None)["L"].clone() };
642        let quarter = Weights { tested: 0.25, ..WEIGHTS };
643        assert_eq!((m.alpha_with(&quarter), m.alpha(), m.beta_with(&quarter)), (1.5, 2.0, 1.0));
644    }
645
646    #[test]
647    fn derived_memory_is_independent_of_log_order_so_merging_logs_merges_memory() {
648        let ls = [lesson("L", 150), lesson("M", 0), Challenge { engineers: vec!["a@x".into(), "b@x".into()], ..lesson("N", 0) }];
649        let mut log = sessions(12);
650        for (k, s) in ["s002", "s005", "s009"].iter().enumerate() {
651            log.push(o(CHANGED, s, "N", 300 * k as i64, "r"));
652            log.push(o(IGNORED, s, "M", 300 * k as i64, "r"));
653        }
654        log.push(o(CORRECTED, "s007", "L", 800, "r"));
655        let base = derive(&log, &ls, Some("s011"));
656        let mut rng = Rng::new(7);
657        for _ in 0..20 {
658            let mut shuffled = log.clone();
659            for i in (1..shuffled.len()).rev() {
660                shuffled.swap(i, (rng.next_u64() % (i as u64 + 1)) as usize);
661            }
662            assert_eq!(derive(&shuffled, &ls, Some("s011")), base);
663        }
664        // Two halves merged in either order: the same memory.
665        let (a, b) = log.split_at(log.len() / 2);
666        assert_eq!(derive(&[b, a].concat(), &ls, Some("s011")), base);
667        assert!(base["N"].consolidated && !base["M"].consolidated);
668    }
669
670    #[test]
671    fn consolidation_needs_spread_uses_and_dormancy_waits_for_a_long_silence() {
672        let l = lesson("L", 0);
673        let mut log = sessions(80);
674        for i in [1, 2, 3] {
675            log.push(o(CHANGED, &format!("s{i:03}"), "L", 100 * (i + 1) + 1, "one-repo"));
676        }
677        let m = &derive(&log, std::slice::from_ref(&l), None)["L"];
678        assert!(!m.consolidated, "one repo, one engineer");
679        log.push(o(CHANGED, "s004", "L", 501, "other-repo"));
680        let m = &derive(&log, std::slice::from_ref(&l), None)["L"];
681        assert!(m.consolidated && !m.dormant);
682        // An unused lesson: active at first, dormant after a long silence, active again after one use.
683        let fresh = lesson("F", 0);
684        assert!(!derive(&sessions(40), std::slice::from_ref(&fresh), None)["F"].dormant);
685        let mut quiet = sessions(80);
686        assert!(derive(&quiet, std::slice::from_ref(&fresh), None)["F"].dormant);
687        quiet.push(o(CHANGED, "s079", "F", 8001, "r"));
688        assert!(!derive(&quiet, &[fresh], None)["F"].dormant, "one use reactivates");
689    }
690
691    #[test]
692    fn thompson_samples_are_stable_per_session_spread_when_uncertain_and_tight_when_proven() {
693        assert_eq!(sample_beta(3, 4, seed("s", "L")), sample_beta(3, 4, seed("s", "L")));
694        assert_ne!(sample_beta(3, 4, seed("s", "L")), sample_beta(3, 4, seed("t", "L")));
695        let draws = |a, b| (0..400).map(|i| sample_beta(a, b, seed(&format!("s{i}"), "L"))).collect::<Vec<f64>>();
696        let mean = |v: &[f64]| v.iter().sum::<f64>() / v.len() as f64;
697        let sd = |v: &[f64]| (v.iter().map(|x| (x - mean(v)).powi(2)).sum::<f64>() / v.len() as f64).sqrt();
698        let (new, proven, harmful) = (draws(1, 1), draws(21, 3), draws(1, 21));
699        assert!((mean(&new) - 0.5).abs() < 0.05 && sd(&new) > 0.25, "uniform: {} {}", mean(&new), sd(&new));
700        assert!(mean(&proven) > 0.8 && sd(&proven) < 0.1);
701        assert!(mean(&harmful) < 0.1);
702        assert!(new.iter().chain(&proven).all(|x| *x > 0.0 && *x < 1.0));
703        let m = Memory { activation: 0.0, changed: 20, ignored: 2, corrected: 0, vetoed: 0, regretted: 0.0, tested: 0, consolidated: false, dormant: false };
704        assert!(m.weight("L", Some("s")) > 1.2 && m.weight("L", Some("s")) < 1.5);
705    }
706
707    #[test]
708    fn resolve_reads_declarations_first_then_edits_on_the_lessons_point() {
709        let mk = |id: &str, q: &str| Challenge { id: id.into(), question: q.into(), ..Default::default() };
710        let all = vec![
711            mk("Idrain", "Is the per-turn hint place drained by an explicit consumer?"),
712            mk("Ijudge", "Is the replay judge exactly the named Vertex model?"),
713            mk("Ihost", "Does the replay run on live hosts?"),
714            mk("x1", "Is the plan complete?"),
715            mk("x2", "Is the plan tested?"),
716            mk("x3", "Is the plan small?"),
717        ];
718        let asked: Vec<&Challenge> = all[..3].iter().collect();
719        let fin = "Revised.\n`Ijudge`: applies — switched to Vertex.\n- Ihost: skip (offline task)\n";
720        let before = "Add a per-turn hint budget to TextNet; plan: reset arc on the starting transition";
721        let edits = "src/TextNet.java drain the hint place with an explicit consumer transition";
722        let (changed, ignored) = resolve(&asked, &all, before, edits, fin, NOVEL_WORDS);
723        assert_eq!((changed, ignored), (vec!["Idrain".to_string(), "Ijudge".to_string()], vec!["Ihost".to_string()]));
724        // No change and nothing declared: ignored.
725        assert_eq!(resolve(&asked, &all, before, "", "Done.", NOVEL_WORDS).0, Vec::<String>::new());
726        // One new word is not enough; words the work already had do not count.
727        assert!(resolve(&asked[..1], &all, before, "renamed the hint label, drained", "", NOVEL_WORDS).0.is_empty());
728        assert!(resolve(&asked[..1], &all, "the hint place is drained by an explicit consumer", edits, "", NOVEL_WORDS).0.is_empty());
729    }
730
731    #[test]
732    fn shuffle_is_a_stable_permutation_per_session() {
733        let mut a: Vec<u32> = (0..8).collect();
734        let mut b = a.clone();
735        shuffle(&mut a, "s1");
736        shuffle(&mut b, "s1");
737        assert_eq!(a, b);
738        let mut sorted = a.clone();
739        sorted.sort();
740        assert_eq!(sorted, (0..8).collect::<Vec<_>>());
741        let firsts: HashSet<u32> = (0..40)
742            .map(|i| {
743                let mut v: Vec<u32> = (0..8).collect();
744                shuffle(&mut v, &format!("s{i}"));
745                v[0]
746            })
747            .collect();
748        assert!(firsts.len() >= 5, "{firsts:?}");
749    }
750}