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