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