Skip to main content

layover_core/learning/
mod.rs

1//! Agents proposing how to do better next time.
2//!
3//! An agent that discovers something durable — a gotcha, a reliable command, a convention — can
4//! write it down for the agents that come after it. That is the whole feature, and the hard part
5//! is not capturing them.
6//!
7//! # Why there is no approval queue
8//!
9//! The obvious design puts a human between a proposal and its use. A sibling project built
10//! exactly that, carefully: a proposal format, duplicate detection, impact ratings, a review
11//! endpoint and a dashboard queue. After 22 days of real operation it held **88 learnings, every
12//! one still pending, none ever approved** — and because only approved learnings were injected,
13//! not one had ever reached a run.
14//!
15//! That is not a discipline failure. Approving buys a diffuse future benefit, rejecting buys
16//! nothing, and ignoring costs nothing today, so the rational act is always "later". A gate whose
17//! default action is free will be defaulted forever.
18//!
19//! So a learning here applies immediately and **expires** instead. A wrong one decays rather than
20//! compounding, and the human reviews by exception — which is possible because the run history
21//! records which learnings were live for each run, so "what was it told when it did that?" is an
22//! answerable question.
23//!
24//! # Why re-proposal is the confirmation signal, and why echoes do not count
25//!
26//! A learning that is genuinely true gets rediscovered. One that was a fluke does not. Counting
27//! independent rediscoveries is therefore evidence, unlike an impact rating, which is the agent's
28//! own claim about its own work — the thing the architecture says not to trust.
29//!
30//! The subtlety is that a learning being *shown* to an agent contaminates the signal: re-proposing
31//! something you were just reminded of is an echo, not a rediscovery. So duplicates are suppressed
32//! while a learning is active — the same behaviour the sibling project needed, for the opposite
33//! reason — and only a proposal arriving while the learning is **lapsed** counts towards
34//! confirmation.
35
36use std::fmt;
37
38use jiff::Timestamp;
39use serde::{Deserialize, Serialize};
40use ulid::Ulid;
41
42use crate::agent::AgentName;
43
44mod ledger;
45pub mod screen;
46
47pub use ledger::{Learnings, Uptake};
48pub use screen::{Rejected, screen};
49
50/// How many independent rediscoveries make a learning permanent.
51///
52/// A rediscovery is an arrival *after* the learning has lapsed — the first proposal is a
53/// discovery, not a rediscovery, and does not count towards this. Reaching the bar therefore
54/// takes roughly `CONFIRM_AFTER * PROVISIONAL_RUNS` of the agent's runs, which is deliberate:
55/// permanence is the one state nothing expires out of, so it should be expensive.
56pub const CONFIRM_AFTER: u32 = 3;
57
58/// How many of an agent's runs a provisional learning survives.
59///
60/// Counted in runs rather than days deliberately: an hourly pipeline and a manual one should not
61/// share a clock. This is also the window in which a wrong learning can do damage, which is the
62/// number to lower if that ever stops feeling acceptable.
63pub const PROVISIONAL_RUNS: u32 = 20;
64
65/// Longest a learning may be, in characters.
66///
67/// Injected into every run of its agent, so length is a recurring cost. The limit also pushes
68/// towards one idea per learning, which is what makes them individually revocable.
69pub const MAX_TEXT: usize = 400;
70
71/// Identifier of a learning.
72#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
73#[serde(transparent)]
74pub struct LearningId(String);
75
76impl LearningId {
77    /// Mints a new identifier.
78    #[must_use]
79    pub fn generate() -> Self {
80        Self(format!("lrn_{}", Ulid::new()))
81    }
82
83    /// Returns the identifier as a string slice.
84    #[must_use]
85    pub fn as_str(&self) -> &str {
86        &self.0
87    }
88}
89
90/// Reads an identifier that came from outside — a URL path, say.
91///
92/// Deliberately not validated. This type names a learning; it does not certify that one exists,
93/// and the only thing done with an identifier matching nothing is to say so.
94impl From<&str> for LearningId {
95    fn from(value: &str) -> Self {
96        Self(value.to_owned())
97    }
98}
99
100impl fmt::Display for LearningId {
101    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
102        f.write_str(&self.0)
103    }
104}
105
106/// How much applying a learning would change a future run.
107///
108/// Self-assessed, and therefore used for *display and triage only* — never to decide whether a
109/// learning applies. An agent rating its own work is exactly the claim the architecture says not
110/// to trust with anything load-bearing.
111#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
112#[serde(rename_all = "snake_case")]
113pub enum Impact {
114    /// Minor convenience or polish.
115    Low,
116    /// Materially improves reliability or accuracy.
117    Medium,
118    /// Prevents a wrong result, a shipped defect, or a blocked run.
119    High,
120}
121
122impl Impact {
123    /// The identifier used in JSON.
124    #[must_use]
125    pub fn slug(self) -> &'static str {
126        match self {
127            Self::Low => "low",
128            Self::Medium => "medium",
129            Self::High => "high",
130        }
131    }
132
133    /// Parses a slug.
134    #[must_use]
135    pub fn from_slug(slug: &str) -> Option<Self> {
136        [Self::Low, Self::Medium, Self::High]
137            .into_iter()
138            .find(|impact| impact.slug() == slug)
139    }
140}
141
142impl fmt::Display for Impact {
143    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
144        f.write_str(self.slug())
145    }
146}
147
148/// What an agent submitted.
149#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
150pub struct Proposal {
151    /// Who proposed it.
152    pub agent: AgentName,
153    /// The insight, in one or two sentences.
154    pub text: String,
155    /// How much the agent thinks it matters.
156    pub impact: Impact,
157    /// When it was proposed.
158    pub at: Timestamp,
159}
160
161impl Proposal {
162    /// Records a proposal.
163    #[must_use]
164    pub fn new(agent: AgentName, text: impl Into<String>, impact: Impact, at: Timestamp) -> Self {
165        Self {
166            agent,
167            text: text.into(),
168            impact,
169            at,
170        }
171    }
172
173    /// Returns `true` when the text is usable.
174    ///
175    /// Only length and emptiness. Judging whether an insight is *good* is not something a
176    /// validator can do, and pretending otherwise would reject useful things.
177    #[must_use]
178    pub fn is_well_formed(&self) -> bool {
179        let trimmed = self.text.trim();
180        !trimmed.is_empty() && trimmed.chars().count() <= MAX_TEXT
181    }
182}
183
184/// Where a learning is in its life.
185#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
186#[serde(rename_all = "snake_case")]
187pub enum State {
188    /// Applies now, and will lapse unless it is rediscovered.
189    Provisional,
190    /// Rediscovered enough times to be treated as real. Applies indefinitely.
191    Confirmed,
192    /// Ran out of runs without being rediscovered. Not applied, but remembered, so that a later
193    /// rediscovery can be recognised as one.
194    Lapsed,
195    /// A human said no. Never applied and never counted again.
196    Rejected,
197}
198
199impl State {
200    /// Returns `true` when a learning in this state is given to runs.
201    #[must_use]
202    pub fn is_active(self) -> bool {
203        matches!(self, Self::Provisional | Self::Confirmed)
204    }
205
206    /// The identifier used in JSON.
207    #[must_use]
208    pub fn slug(self) -> &'static str {
209        match self {
210            Self::Provisional => "provisional",
211            Self::Confirmed => "confirmed",
212            Self::Lapsed => "lapsed",
213            Self::Rejected => "rejected",
214        }
215    }
216
217    /// Parses a slug.
218    #[must_use]
219    pub fn from_slug(slug: &str) -> Option<Self> {
220        [
221            Self::Provisional,
222            Self::Confirmed,
223            Self::Lapsed,
224            Self::Rejected,
225        ]
226        .into_iter()
227        .find(|state| state.slug() == slug)
228    }
229}
230
231impl fmt::Display for State {
232    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
233        f.write_str(self.slug())
234    }
235}
236
237/// A proposal that has been taken up, with its history.
238#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
239pub struct Learning {
240    /// Identifier.
241    pub id: LearningId,
242    /// The agent this applies to. Learnings do not cross agents.
243    pub agent: AgentName,
244    /// The insight.
245    pub text: String,
246    /// The agent's own rating, for triage.
247    pub impact: Impact,
248    /// Where it is in its life.
249    pub state: State,
250    /// How many times this insight has been proposed: one for the original discovery, plus one
251    /// for each independent rediscovery after a lapse. Echoes never count.
252    pub proposals: u32,
253    /// Runs left before it lapses. Meaningless unless [`State::Provisional`].
254    pub runs_left: u32,
255    /// When it was first proposed.
256    pub first_at: Timestamp,
257    /// When it was most recently proposed.
258    pub last_at: Timestamp,
259}
260
261impl Learning {
262    /// Takes up a proposal for the first time.
263    #[must_use]
264    pub fn from_proposal(proposal: &Proposal) -> Self {
265        Self {
266            id: LearningId::generate(),
267            agent: proposal.agent.clone(),
268            text: proposal.text.trim().to_owned(),
269            impact: proposal.impact,
270            state: State::Provisional,
271            proposals: 1,
272            runs_left: PROVISIONAL_RUNS,
273            first_at: proposal.at,
274            last_at: proposal.at,
275        }
276    }
277
278    /// Returns `true` when this learning is currently given to runs.
279    #[must_use]
280    pub fn is_active(&self) -> bool {
281        self.state.is_active()
282    }
283
284    /// Returns `true` when this says the same thing as `text`.
285    #[must_use]
286    pub fn matches(&self, text: &str) -> bool {
287        says_the_same_thing(&self.text, text)
288    }
289}
290
291/// Decides whether two pieces of prose say the same thing.
292///
293/// Overall word overlap turns out to be the wrong measure, because real learnings share sentence
294/// frames. "The workspace needs careful handling before publishing" and "the manifest needs
295/// careful handling before publishing" overlap by five words out of seven while being entirely
296/// different claims; the difference lives in the one word the frame does not supply.
297///
298/// So the test is **containment**, not overlap. A rediscovery phrased with an extra clause is a
299/// *superset* of the original — every word of the shorter appears in the longer. Two different
300/// insights each carry a word the other lacks, however much boilerplate they share.
301///
302/// Two guards keep that honest. The shorter text must carry enough words to mean something, or
303/// "use ripgrep" would match every sentence that happens to contain both words. And the longer
304/// must not be wildly longer, because a statement several times more specific is a different,
305/// narrower claim rather than the same one restated — which is exactly what a refinement is.
306///
307/// Near-identical rewordings are caught separately by a high overlap threshold, since those are
308/// the same length and differ only in punctuation or a synonym.
309///
310/// Getting this wrong in either direction has a cost worth stating. Too strict and a rediscovery
311/// is never recognised, so nothing is ever confirmed and every learning lapses forever — the
312/// mechanism fails silently while appearing to work. Too loose and two insights merge, and one is
313/// lost without trace.
314#[must_use]
315pub fn says_the_same_thing(left: &str, right: &str) -> bool {
316    // A negation mismatch is disqualifying rather than one more differing word. Adding `not` to
317    // an eight-word sentence still scores 0.83 similarity, comfortably over the rewording
318    // threshold — so treating it as an ordinary token left "X is safe" and "X is not safe" as one
319    // insight. Nothing about the shape of the two sentences can be allowed to outvote the fact
320    // that one asserts the opposite of the other.
321    if is_negated(left) != is_negated(right) {
322        return false;
323    }
324
325    let (left, right) = (significant_words(left), significant_words(right));
326    // Two texts with nothing substantive in them are not evidence of anything. Returning "equal"
327    // here made every pair of short scraps one insight — "use rg now" and "go to bed" matched.
328    if left.is_empty() || right.is_empty() {
329        return false;
330    }
331
332    let shared = left.iter().filter(|word| right.contains(*word)).count();
333    let (shorter, longer) = (left.len().min(right.len()), left.len().max(right.len()));
334
335    let union = longer + shorter - shared;
336    if union > 0 && precise(shared) / precise(union) >= REWORDING_THRESHOLD {
337        return true;
338    }
339
340    shared == shorter
341        && shorter >= MIN_WORDS_TO_CONTAIN
342        && precise(longer) <= precise(shorter) * MOST_ELABORATION
343}
344
345/// Overlap at which two texts are taken to be the same thing reworded.
346const REWORDING_THRESHOLD: f64 = 0.8;
347
348/// Fewest significant words a text must have for containment to mean anything.
349const MIN_WORDS_TO_CONTAIN: usize = 3;
350
351/// How much longer an elaboration may be before it counts as a different, narrower claim.
352const MOST_ELABORATION: f64 = 2.0;
353
354/// Widens a small count for a ratio.
355fn precise(count: usize) -> f64 {
356    u32::try_from(count).map_or(f64::from(u32::MAX), f64::from)
357}
358
359/// Returns `true` when a text reverses the sense of what it says.
360///
361/// Deliberately generous about what counts. A false positive here makes two genuinely equivalent
362/// learnings look different, which costs one spurious confirmation count. A false negative merges
363/// a claim with its own correction, which loses the correction and keeps the wrong claim — so the
364/// list includes words that only sometimes negate.
365fn is_negated(text: &str) -> bool {
366    text.split(|c: char| !c.is_alphanumeric())
367        .any(|word| NEGATIONS.contains(&word.to_lowercase().as_str()))
368}
369
370/// Words taken to reverse a claim, at any length.
371///
372/// `t` is here because splitting on non-alphanumerics turns `don't` and `isn't` into `don`/`isn`
373/// and `t`.
374const NEGATIONS: [&str; 10] = [
375    "not", "no", "nor", "never", "t", "cannot", "without", "unless", "neither", "none",
376];
377
378/// Reduces prose to the set of words worth comparing.
379///
380/// Tokens of three characters or fewer are dropped, because they are overwhelmingly articles and
381/// prepositions and counting them makes every sentence resemble every other one. Numbers survive
382/// that rule: `30` and `90` are under four characters, so "the token expires every 30 days" and
383/// "every 90 days" were the same learning, and numeric facts are most of what a learning is.
384fn significant_words(text: &str) -> Vec<String> {
385    let mut words: Vec<String> = text
386        .split(|c: char| !c.is_alphanumeric())
387        .map(str::to_lowercase)
388        .filter(|word| word.chars().count() > 3 || word.chars().any(|c| c.is_ascii_digit()))
389        .collect();
390    words.sort();
391    words.dedup();
392    words
393}
394
395#[cfg(test)]
396mod tests {
397    use super::*;
398
399    fn at(rfc3339: &str) -> Timestamp {
400        rfc3339.parse().expect("valid timestamp")
401    }
402
403    fn proposal(text: &str) -> Proposal {
404        Proposal::new(
405            "reviewer".into(),
406            text,
407            Impact::Medium,
408            at("2026-09-16T10:00:00Z"),
409        )
410    }
411
412    #[test]
413    fn a_new_learning_starts_provisional_with_a_full_life() {
414        let learning = Learning::from_proposal(&proposal("prefer ripgrep over findstr"));
415
416        assert_eq!(learning.state, State::Provisional);
417        assert!(learning.is_active(), "it applies from the first run");
418        assert_eq!(learning.proposals, 1);
419        assert_eq!(learning.runs_left, PROVISIONAL_RUNS);
420    }
421
422    #[test]
423    fn only_provisional_and_confirmed_learnings_reach_a_run() {
424        assert!(State::Provisional.is_active());
425        assert!(State::Confirmed.is_active());
426        assert!(!State::Lapsed.is_active());
427        assert!(!State::Rejected.is_active());
428    }
429
430    #[test]
431    fn rewording_and_punctuation_do_not_make_a_new_insight() {
432        assert!(says_the_same_thing(
433            "The ADO token expires every 30 days; refresh it before publishing.",
434            "the ADO token expires every 30 days, refresh it before publishing"
435        ));
436    }
437
438    #[test]
439    fn a_sentence_with_one_clause_added_is_still_the_same_insight() {
440        // The case that decides the threshold. If this does not match, a rediscovery worded even
441        // slightly better is never recognised as one, nothing is ever confirmed, and every
442        // learning lapses forever -- the mechanism fails silently and looks like it works.
443        assert!(says_the_same_thing(
444            "the token expires every thirty days",
445            "the token expires every thirty days, refresh before publishing"
446        ));
447    }
448
449    #[test]
450    fn a_claim_and_its_negation_are_not_the_same_claim() {
451        // The worst case this function can produce, and it was live. "not" is three characters,
452        // so both sides reduced to the same word set and matched at similarity 1.0. The effect
453        // in the ledger: an agent proposing the correction gets `Echo`, the correction is
454        // dropped, and the dangerous learning stays active -- while across lapse cycles the two
455        // contradictory phrasings count as independent rediscoveries of "the same insight" and
456        // drive it to permanent.
457        assert!(!says_the_same_thing(
458            "the migration is safe to run during business hours",
459            "the migration is not safe to run during business hours"
460        ));
461        assert!(!says_the_same_thing(
462            "delete the old worktree before starting the next itinerary",
463            "do not delete the old worktree before starting the next itinerary"
464        ));
465    }
466
467    #[test]
468    fn learnings_differing_only_in_a_number_are_different_learnings() {
469        // Numeric facts are most of what a learning is, and digits were being filtered out for
470        // being short.
471        assert!(!says_the_same_thing(
472            "the token expires every 30 days",
473            "the token expires every 90 days"
474        ));
475    }
476
477    #[test]
478    fn two_texts_with_nothing_substantive_in_them_do_not_match() {
479        // Returning "equal" for two empty word sets made every pair of short scraps one insight.
480        assert!(!says_the_same_thing("use rg now", "go to bed"));
481        assert!(!says_the_same_thing("", ""));
482    }
483
484    #[test]
485    fn two_claims_sharing_a_sentence_frame_are_not_the_same_claim() {
486        // The case that broke the first two attempts. These overlap by five words out of seven
487        // while being entirely different facts; the difference lives in the one word the frame
488        // does not supply.
489        assert!(!says_the_same_thing(
490            "the workspace needs careful handling before publishing",
491            "the manifest needs careful handling before publishing"
492        ));
493    }
494
495    #[test]
496    fn genuinely_different_insights_stay_separate() {
497        // A false match silently merges two insights and loses one, which is worse than an
498        // occasional missed duplicate.
499        assert!(!says_the_same_thing(
500            "the ADO token expires every thirty days",
501            "prefer ripgrep over findstr when searching the tree"
502        ));
503    }
504
505    #[test]
506    fn a_refinement_is_not_the_same_as_the_thing_it_refines() {
507        assert!(!says_the_same_thing(
508            "exclude the assistant bot from new activity",
509            "exclude the assistant bot and the build service account from new activity, but only \
510             when the commit tip is unchanged since the previous review cycle"
511        ));
512    }
513
514    #[test]
515    fn an_empty_or_overlong_proposal_is_not_well_formed() {
516        assert!(!proposal("   ").is_well_formed());
517        assert!(!proposal(&"x".repeat(MAX_TEXT + 1)).is_well_formed());
518        assert!(proposal("something short and useful").is_well_formed());
519    }
520
521    #[test]
522    fn impact_is_ordered_so_a_queue_can_be_triaged() {
523        assert!(Impact::High > Impact::Medium);
524        assert!(Impact::Medium > Impact::Low);
525        for impact in [Impact::Low, Impact::Medium, Impact::High] {
526            assert_eq!(Impact::from_slug(impact.slug()), Some(impact));
527        }
528    }
529
530    #[test]
531    fn states_round_trip() {
532        for state in [
533            State::Provisional,
534            State::Confirmed,
535            State::Lapsed,
536            State::Rejected,
537        ] {
538            assert_eq!(State::from_slug(state.slug()), Some(state));
539        }
540        assert_eq!(State::from_slug("approved"), None);
541    }
542
543    #[test]
544    fn a_learning_serialises_readably() {
545        let line = serde_json::to_string(&Learning::from_proposal(&proposal("use ripgrep")))
546            .expect("serialises");
547
548        assert!(line.contains(r#""state":"provisional""#), "{line}");
549        assert!(line.contains(r#""impact":"medium""#), "{line}");
550        assert!(
551            line.contains(r#""first_at":"2026-09-16T10:00:00Z""#),
552            "{line}"
553        );
554    }
555}