1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum Uptake {
23 Taken,
25 Echo,
30 Rediscovered {
32 proposals: u32,
34 },
35 Confirmed,
37 Refused,
39 Malformed,
41 Unacceptable(Rejected),
48}
49
50#[derive(Debug, Clone, Default)]
52pub struct Learnings {
53 entries: Vec<Learning>,
54}
55
56impl Learnings {
57 #[must_use]
59 pub fn new() -> Self {
60 Self::default()
61 }
62
63 #[must_use]
65 pub fn from_entries(entries: Vec<Learning>) -> Self {
66 Self { entries }
67 }
68
69 pub fn all(&self) -> impl Iterator<Item = &Learning> {
71 self.entries.iter()
72 }
73
74 #[must_use]
76 pub fn len(&self) -> usize {
77 self.entries.len()
78 }
79
80 #[must_use]
82 pub fn is_empty(&self) -> bool {
83 self.entries.is_empty()
84 }
85
86 #[must_use]
88 pub fn get(&self, id: &LearningId) -> Option<&Learning> {
89 self.entries.iter().find(|learning| learning.id == *id)
90 }
91
92 #[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 (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 pub fn propose(&mut self, proposal: &Proposal) -> Uptake {
116 if !proposal.is_well_formed() {
117 return Uptake::Malformed;
118 }
119
120 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 State::Rejected => Uptake::Refused,
142
143 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 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 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 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 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 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 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 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 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 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 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 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}