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