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}