Skip to main content

layover_core/learning/
ledger.rs

1//! The collection of learnings, and the rules that move them between states.
2//!
3//! Three things happen here, and the order they happen in is the design:
4//!
5//! - a proposal arrives, and is either a new insight, an echo of a live one, or a rediscovery of
6//!   a lapsed one;
7//! - a run starts, which costs every provisional learning one of its remaining runs;
8//! - a human intervenes, which is the rare case rather than the load-bearing one.
9
10use jiff::Timestamp;
11
12use crate::agent::AgentName;
13
14#[cfg(test)]
15use super::Impact;
16
17use super::{CONFIRM_AFTER, Learning, LearningId, PROVISIONAL_RUNS, Proposal, State};
18use crate::learning::Rejected;
19
20/// What happened to a proposal.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum Uptake {
23    /// Nothing like it was known. It applies from now.
24    Taken,
25    /// It says the same thing as a learning that is currently being shown to the agent.
26    ///
27    /// Ignored, because an agent repeating advice it was just given is an echo, not evidence.
28    /// Counting it would let a single fluke confirm itself in three runs.
29    Echo,
30    /// It says the same thing as a learning that had lapsed. Genuine rediscovery, so it counts.
31    Rediscovered {
32        /// How many independent times it has now been proposed.
33        proposals: u32,
34    },
35    /// Rediscovered often enough to stop expiring.
36    Confirmed,
37    /// It says the same thing as something a human rejected. Ignored, permanently.
38    Refused,
39    /// The text was empty or too long.
40    Malformed,
41    /// The text is not something a learning may say.
42    ///
43    /// A learning outlives the run that wrote it and is read by every run after, so its text is
44    /// screened before it is ever stored rather than filtered on the way out. Storing it and
45    /// hiding it later would leave the thing an attacker wanted sitting in the factory's memory,
46    /// waiting for the filter to be relaxed.
47    Unacceptable(Rejected),
48}
49
50/// Every learning the factory holds, across all agents.
51#[derive(Debug, Clone, Default)]
52pub struct Learnings {
53    entries: Vec<Learning>,
54}
55
56impl Learnings {
57    /// Creates an empty collection.
58    #[must_use]
59    pub fn new() -> Self {
60        Self::default()
61    }
62
63    /// Rebuilds from stored learnings.
64    #[must_use]
65    pub fn from_entries(entries: Vec<Learning>) -> Self {
66        Self { entries }
67    }
68
69    /// Every learning, in the order they were first proposed.
70    pub fn all(&self) -> impl Iterator<Item = &Learning> {
71        self.entries.iter()
72    }
73
74    /// How many are held.
75    #[must_use]
76    pub fn len(&self) -> usize {
77        self.entries.len()
78    }
79
80    /// Returns `true` when nothing is held.
81    #[must_use]
82    pub fn is_empty(&self) -> bool {
83        self.entries.is_empty()
84    }
85
86    /// Finds one by identifier.
87    #[must_use]
88    pub fn get(&self, id: &LearningId) -> Option<&Learning> {
89        self.entries.iter().find(|learning| learning.id == *id)
90    }
91
92    /// The learnings an agent's next run should be given, oldest first.
93    ///
94    /// Confirmed ones lead, because they have earned their place, and a run that has to skim is
95    /// better off skimming the provisional tail.
96    #[must_use]
97    pub fn active_for(&self, agent: &AgentName) -> Vec<&Learning> {
98        let mut active: Vec<&Learning> = self
99            .entries
100            .iter()
101            .filter(|learning| learning.agent == *agent && learning.is_active())
102            .collect();
103
104        active.sort_by(|left, right| {
105            // Only the confirmed/provisional split and age matter: everything here is active by
106            // construction, so comparing on that would be a comparison that can never differ.
107            (right.state == State::Confirmed)
108                .cmp(&(left.state == State::Confirmed))
109                .then_with(|| left.first_at.cmp(&right.first_at))
110        });
111        active
112    }
113
114    /// Takes up a proposal, or explains why it was not taken up.
115    pub fn propose(&mut self, proposal: &Proposal) -> Uptake {
116        if !proposal.is_well_formed() {
117            return Uptake::Malformed;
118        }
119
120        // Screened before anything else looks at it, and before it can match an existing learning.
121        // A rediscovery of something that should never have been stored is still something that
122        // should never have been stored.
123        if let Err(reason) = crate::learning::screen(&proposal.text) {
124            return Uptake::Unacceptable(reason);
125        }
126
127        let text = proposal.text.trim();
128        let existing = self
129            .entries
130            .iter_mut()
131            .find(|learning| learning.agent == proposal.agent && learning.matches(text));
132
133        let Some(learning) = existing else {
134            self.entries.push(Learning::from_proposal(proposal));
135            return Uptake::Taken;
136        };
137
138        match learning.state {
139            // A human said no. Saying it again does not change that, and counting it would let an
140            // agent overturn a decision by repetition.
141            State::Rejected => Uptake::Refused,
142
143            // Being shown a learning and repeating it is not evidence of anything.
144            State::Provisional | State::Confirmed => Uptake::Echo,
145
146            State::Lapsed => {
147                learning.proposals = learning.proposals.saturating_add(1);
148                learning.last_at = proposal.at;
149                // The rediscovery may be better worded, or rated differently now that the agent
150                // has hit it twice. Take the newer text on the grounds that it was written with
151                // more experience of the problem.
152                learning.text.clear();
153                learning.text.push_str(text);
154                learning.impact = learning.impact.max(proposal.impact);
155
156                if learning.proposals.saturating_sub(1) >= CONFIRM_AFTER {
157                    learning.state = State::Confirmed;
158                    learning.runs_left = 0;
159                    Uptake::Confirmed
160                } else {
161                    learning.state = State::Provisional;
162                    learning.runs_left = PROVISIONAL_RUNS;
163                    Uptake::Rediscovered {
164                        proposals: learning.proposals,
165                    }
166                }
167            }
168        }
169    }
170
171    /// Charges one run against `agent`'s provisional learnings, lapsing any that run out.
172    ///
173    /// Returns the learnings that lapsed, so the caller can say so rather than have advice
174    /// disappear silently.
175    pub fn charge_run(&mut self, agent: &AgentName) -> Vec<LearningId> {
176        let mut lapsed = Vec::new();
177
178        for learning in &mut self.entries {
179            if learning.agent != *agent || learning.state != State::Provisional {
180                continue;
181            }
182
183            learning.runs_left = learning.runs_left.saturating_sub(1);
184            if learning.runs_left == 0 {
185                learning.state = State::Lapsed;
186                lapsed.push(learning.id.clone());
187            }
188        }
189
190        lapsed
191    }
192
193    /// Marks a learning permanent, as a human confirming what they have read.
194    ///
195    /// The ordinary path to [`State::Confirmed`] is rediscovery; this is the shortcut for an
196    /// operator who already knows the insight is right and does not want to wait for the factory
197    /// to work it out twice more.
198    pub fn confirm(&mut self, id: &LearningId, at: Timestamp) -> bool {
199        self.with(id, |learning| {
200            learning.state = State::Confirmed;
201            learning.runs_left = 0;
202            learning.last_at = at;
203        })
204    }
205
206    /// Refuses a learning for good.
207    ///
208    /// This is the revocation path, and the reason applying immediately is defensible: when a
209    /// learning turns out to be wrong there is one place to go and one thing to press.
210    pub fn reject(&mut self, id: &LearningId, at: Timestamp) -> bool {
211        self.with(id, |learning| {
212            learning.state = State::Rejected;
213            learning.runs_left = 0;
214            learning.last_at = at;
215        })
216    }
217
218    /// Applies a change to one learning, reporting whether it was there.
219    fn with(&mut self, id: &LearningId, change: impl FnOnce(&mut Learning)) -> bool {
220        match self.entries.iter_mut().find(|learning| learning.id == *id) {
221            Some(learning) => {
222                change(learning);
223                true
224            }
225            None => false,
226        }
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233
234    fn at(rfc3339: &str) -> Timestamp {
235        rfc3339.parse().expect("valid timestamp")
236    }
237
238    fn proposal(text: &str) -> Proposal {
239        Proposal::new(
240            "reviewer".into(),
241            text,
242            Impact::Medium,
243            at("2026-09-16T10:00:00Z"),
244        )
245    }
246
247    fn reviewer() -> AgentName {
248        "reviewer".into()
249    }
250
251    /// Runs an agent enough times to lapse everything provisional.
252    fn lapse(learnings: &mut Learnings) {
253        for _ in 0..PROVISIONAL_RUNS {
254            learnings.charge_run(&reviewer());
255        }
256    }
257
258    #[test]
259    fn a_new_learning_applies_from_the_next_run() {
260        // The whole point of not having an approval queue: value arrives immediately.
261        let mut learnings = Learnings::new();
262
263        assert_eq!(
264            learnings.propose(&proposal("use ripgrep here")),
265            Uptake::Taken
266        );
267        assert_eq!(learnings.active_for(&reviewer()).len(), 1);
268    }
269
270    #[test]
271    fn repeating_advice_you_were_just_given_is_not_evidence() {
272        // This is the subtlety that makes rediscovery meaningful. A learning being shown to an
273        // agent contaminates the signal: without this, one fluke confirms itself in three runs.
274        let mut learnings = Learnings::new();
275        learnings.propose(&proposal("use ripgrep here"));
276
277        assert_eq!(
278            learnings.propose(&proposal("use ripgrep here")),
279            Uptake::Echo
280        );
281        assert_eq!(
282            learnings.propose(&proposal("Use ripgrep here.")),
283            Uptake::Echo
284        );
285        assert_eq!(learnings.all().next().expect("one").proposals, 1);
286    }
287
288    #[test]
289    fn a_learning_lapses_after_its_allotted_runs() {
290        let mut learnings = Learnings::new();
291        learnings.propose(&proposal("use ripgrep here"));
292
293        for _ in 0..PROVISIONAL_RUNS - 1 {
294            assert!(learnings.charge_run(&reviewer()).is_empty());
295        }
296        let lapsed = learnings.charge_run(&reviewer());
297
298        assert_eq!(lapsed.len(), 1, "the last run should retire it");
299        assert!(learnings.active_for(&reviewer()).is_empty());
300        assert_eq!(learnings.all().next().expect("one").state, State::Lapsed);
301    }
302
303    #[test]
304    fn rediscovering_a_lapsed_learning_counts_and_revives_it() {
305        let mut learnings = Learnings::new();
306        learnings.propose(&proposal("use ripgrep here"));
307        lapse(&mut learnings);
308
309        assert_eq!(
310            learnings.propose(&proposal("use ripgrep here")),
311            Uptake::Rediscovered { proposals: 2 }
312        );
313        assert_eq!(learnings.active_for(&reviewer()).len(), 1);
314        assert_eq!(
315            learnings.all().next().expect("one").runs_left,
316            PROVISIONAL_RUNS,
317            "a rediscovery earns a full second life"
318        );
319    }
320
321    #[test]
322    fn three_independent_rediscoveries_make_a_learning_permanent() {
323        // Evidence rather than self-assessment: the agent keeps arriving at the same conclusion
324        // without being told it, which is the only signal here that is not the agent's own claim
325        // about its own work.
326        let mut learnings = Learnings::new();
327        learnings.propose(&proposal("the token expires every thirty days"));
328
329        lapse(&mut learnings);
330        assert_eq!(
331            learnings.propose(&proposal("the token expires every thirty days")),
332            Uptake::Rediscovered { proposals: 2 }
333        );
334
335        lapse(&mut learnings);
336        assert_eq!(
337            learnings.propose(&proposal("the token expires every thirty days")),
338            Uptake::Rediscovered { proposals: 3 }
339        );
340
341        lapse(&mut learnings);
342        assert_eq!(
343            learnings.propose(&proposal("the token expires every thirty days")),
344            Uptake::Confirmed
345        );
346
347        let learning = learnings.all().next().expect("one");
348        assert_eq!(learning.state, State::Confirmed);
349        assert_eq!(
350            learning.proposals,
351            CONFIRM_AFTER + 1,
352            "the original discovery plus three rediscoveries"
353        );
354    }
355
356    #[test]
357    fn a_confirmed_learning_never_lapses() {
358        let mut learnings = Learnings::new();
359        learnings.propose(&proposal("the token expires every thirty days"));
360        lapse(&mut learnings);
361        learnings.propose(&proposal("the token expires every thirty days"));
362        lapse(&mut learnings);
363        learnings.propose(&proposal("the token expires every thirty days"));
364        lapse(&mut learnings);
365        learnings.propose(&proposal("the token expires every thirty days"));
366
367        for _ in 0..PROVISIONAL_RUNS * 3 {
368            learnings.charge_run(&reviewer());
369        }
370
371        assert_eq!(learnings.active_for(&reviewer()).len(), 1);
372    }
373
374    #[test]
375    fn a_rejected_learning_cannot_be_reinstated_by_repetition() {
376        // Otherwise an agent overturns a human decision simply by being persistent, which is the
377        // one direction this system must not run in.
378        let mut learnings = Learnings::new();
379        learnings.propose(&proposal("skip the integration tests, they are flaky"));
380        let id = learnings.all().next().expect("one").id.clone();
381
382        assert!(learnings.reject(&id, at("2026-09-16T11:00:00Z")));
383
384        for _ in 0..5 {
385            assert_eq!(
386                learnings.propose(&proposal("skip the integration tests, they are flaky")),
387                Uptake::Refused
388            );
389        }
390        assert!(learnings.active_for(&reviewer()).is_empty());
391    }
392
393    #[test]
394    fn a_human_can_confirm_without_waiting_for_the_factory_to_agree() {
395        let mut learnings = Learnings::new();
396        learnings.propose(&proposal("use ripgrep here"));
397        let id = learnings.all().next().expect("one").id.clone();
398
399        assert!(learnings.confirm(&id, at("2026-09-16T11:00:00Z")));
400        lapse(&mut learnings);
401
402        assert_eq!(learnings.active_for(&reviewer()).len(), 1);
403    }
404
405    #[test]
406    fn learnings_do_not_cross_agents() {
407        let mut learnings = Learnings::new();
408        learnings.propose(&proposal("use ripgrep here"));
409        learnings.propose(&Proposal::new(
410            "developer".into(),
411            "use ripgrep here",
412            Impact::Low,
413            at("2026-09-16T10:00:00Z"),
414        ));
415
416        assert_eq!(learnings.active_for(&reviewer()).len(), 1);
417        assert_eq!(learnings.active_for(&"developer".into()).len(), 1);
418        assert_eq!(learnings.len(), 2, "same words, two separate insights");
419    }
420
421    #[test]
422    fn one_agents_runs_do_not_age_anothers_learnings() {
423        let mut learnings = Learnings::new();
424        learnings.propose(&proposal("use ripgrep here"));
425
426        for _ in 0..PROVISIONAL_RUNS * 2 {
427            learnings.charge_run(&"developer".into());
428        }
429
430        assert_eq!(
431            learnings.active_for(&reviewer()).len(),
432            1,
433            "a busy neighbour must not retire a quiet agent's advice"
434        );
435    }
436
437    #[test]
438    fn a_rediscovery_takes_the_newer_wording_and_the_higher_rating() {
439        // The second time round the agent has more experience of the problem, so its wording is
440        // likely better. Impact only ratchets up: a learning that turned out to matter more is
441        // more interesting than one that was downgraded.
442        let mut learnings = Learnings::new();
443        learnings.propose(&Proposal::new(
444            "reviewer".into(),
445            "the token expires every thirty days",
446            Impact::Low,
447            at("2026-09-16T10:00:00Z"),
448        ));
449        lapse(&mut learnings);
450
451        learnings.propose(&Proposal::new(
452            "reviewer".into(),
453            "the token expires every thirty days, refresh before publishing",
454            Impact::High,
455            at("2026-09-20T10:00:00Z"),
456        ));
457
458        let learning = learnings.all().next().expect("one");
459        assert!(learning.text.contains("refresh before publishing"));
460        assert_eq!(learning.impact, Impact::High);
461        assert_eq!(learning.last_at, at("2026-09-20T10:00:00Z"));
462    }
463
464    #[test]
465    fn a_malformed_proposal_is_refused_without_creating_anything() {
466        let mut learnings = Learnings::new();
467
468        assert_eq!(learnings.propose(&proposal("  ")), Uptake::Malformed);
469        assert_eq!(
470            learnings.propose(&proposal(&"x".repeat(super::super::MAX_TEXT + 1))),
471            Uptake::Malformed
472        );
473        assert!(learnings.is_empty());
474    }
475
476    #[test]
477    fn confirmed_learnings_are_offered_before_provisional_ones() {
478        let mut learnings = Learnings::new();
479        learnings.propose(&proposal("provisional advice about searching"));
480        learnings.propose(&proposal("the token expires every thirty days"));
481
482        let id = learnings
483            .all()
484            .find(|learning| learning.text.contains("token"))
485            .expect("second")
486            .id
487            .clone();
488        learnings.confirm(&id, at("2026-09-16T11:00:00Z"));
489
490        let active = learnings.active_for(&reviewer());
491        assert_eq!(active[0].state, State::Confirmed);
492    }
493
494    #[test]
495    fn acting_on_a_learning_that_is_not_there_reports_so() {
496        let mut learnings = Learnings::new();
497        let ghost = LearningId::generate();
498
499        assert!(!learnings.confirm(&ghost, at("2026-09-16T11:00:00Z")));
500        assert!(!learnings.reject(&ghost, at("2026-09-16T11:00:00Z")));
501    }
502}