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