use std::fmt;
use jiff::Timestamp;
use serde::{Deserialize, Serialize};
use ulid::Ulid;
use crate::agent::AgentName;
mod ledger;
pub mod screen;
pub use ledger::{Learnings, Uptake};
pub use screen::{Rejected, screen};
pub const CONFIRM_AFTER: u32 = 3;
pub const PROVISIONAL_RUNS: u32 = 20;
pub const MAX_TEXT: usize = 400;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
#[serde(transparent)]
pub struct LearningId(String);
impl LearningId {
#[must_use]
pub fn generate() -> Self {
Self(format!("lrn_{}", Ulid::new()))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl From<&str> for LearningId {
fn from(value: &str) -> Self {
Self(value.to_owned())
}
}
impl fmt::Display for LearningId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Impact {
Low,
Medium,
High,
}
impl Impact {
#[must_use]
pub fn slug(self) -> &'static str {
match self {
Self::Low => "low",
Self::Medium => "medium",
Self::High => "high",
}
}
#[must_use]
pub fn from_slug(slug: &str) -> Option<Self> {
[Self::Low, Self::Medium, Self::High]
.into_iter()
.find(|impact| impact.slug() == slug)
}
}
impl fmt::Display for Impact {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.slug())
}
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct Proposal {
pub agent: AgentName,
pub text: String,
pub impact: Impact,
pub at: Timestamp,
}
impl Proposal {
#[must_use]
pub fn new(agent: AgentName, text: impl Into<String>, impact: Impact, at: Timestamp) -> Self {
Self {
agent,
text: text.into(),
impact,
at,
}
}
#[must_use]
pub fn is_well_formed(&self) -> bool {
let trimmed = self.text.trim();
!trimmed.is_empty() && trimmed.chars().count() <= MAX_TEXT
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum State {
Provisional,
Confirmed,
Lapsed,
Rejected,
}
impl State {
#[must_use]
pub fn is_active(self) -> bool {
matches!(self, Self::Provisional | Self::Confirmed)
}
#[must_use]
pub fn slug(self) -> &'static str {
match self {
Self::Provisional => "provisional",
Self::Confirmed => "confirmed",
Self::Lapsed => "lapsed",
Self::Rejected => "rejected",
}
}
#[must_use]
pub fn from_slug(slug: &str) -> Option<Self> {
[
Self::Provisional,
Self::Confirmed,
Self::Lapsed,
Self::Rejected,
]
.into_iter()
.find(|state| state.slug() == slug)
}
}
impl fmt::Display for State {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.slug())
}
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct Learning {
pub id: LearningId,
pub agent: AgentName,
pub text: String,
pub impact: Impact,
pub state: State,
pub proposals: u32,
pub runs_left: u32,
pub first_at: Timestamp,
pub last_at: Timestamp,
}
impl Learning {
#[must_use]
pub fn from_proposal(proposal: &Proposal) -> Self {
Self {
id: LearningId::generate(),
agent: proposal.agent.clone(),
text: proposal.text.trim().to_owned(),
impact: proposal.impact,
state: State::Provisional,
proposals: 1,
runs_left: PROVISIONAL_RUNS,
first_at: proposal.at,
last_at: proposal.at,
}
}
#[must_use]
pub fn is_active(&self) -> bool {
self.state.is_active()
}
#[must_use]
pub fn matches(&self, text: &str) -> bool {
says_the_same_thing(&self.text, text)
}
}
#[must_use]
pub fn says_the_same_thing(left: &str, right: &str) -> bool {
if is_negated(left) != is_negated(right) {
return false;
}
let (left, right) = (significant_words(left), significant_words(right));
if left.is_empty() || right.is_empty() {
return false;
}
let shared = left.iter().filter(|word| right.contains(*word)).count();
let (shorter, longer) = (left.len().min(right.len()), left.len().max(right.len()));
let union = longer + shorter - shared;
if union > 0 && precise(shared) / precise(union) >= REWORDING_THRESHOLD {
return true;
}
shared == shorter
&& shorter >= MIN_WORDS_TO_CONTAIN
&& precise(longer) <= precise(shorter) * MOST_ELABORATION
}
const REWORDING_THRESHOLD: f64 = 0.8;
const MIN_WORDS_TO_CONTAIN: usize = 3;
const MOST_ELABORATION: f64 = 2.0;
fn precise(count: usize) -> f64 {
u32::try_from(count).map_or(f64::from(u32::MAX), f64::from)
}
fn is_negated(text: &str) -> bool {
text.split(|c: char| !c.is_alphanumeric())
.any(|word| NEGATIONS.contains(&word.to_lowercase().as_str()))
}
const NEGATIONS: [&str; 10] = [
"not", "no", "nor", "never", "t", "cannot", "without", "unless", "neither", "none",
];
fn significant_words(text: &str) -> Vec<String> {
let mut words: Vec<String> = text
.split(|c: char| !c.is_alphanumeric())
.map(str::to_lowercase)
.filter(|word| word.chars().count() > 3 || word.chars().any(|c| c.is_ascii_digit()))
.collect();
words.sort();
words.dedup();
words
}
#[cfg(test)]
mod tests {
use super::*;
fn at(rfc3339: &str) -> Timestamp {
rfc3339.parse().expect("valid timestamp")
}
fn proposal(text: &str) -> Proposal {
Proposal::new(
"reviewer".into(),
text,
Impact::Medium,
at("2026-09-16T10:00:00Z"),
)
}
#[test]
fn a_new_learning_starts_provisional_with_a_full_life() {
let learning = Learning::from_proposal(&proposal("prefer ripgrep over findstr"));
assert_eq!(learning.state, State::Provisional);
assert!(learning.is_active(), "it applies from the first run");
assert_eq!(learning.proposals, 1);
assert_eq!(learning.runs_left, PROVISIONAL_RUNS);
}
#[test]
fn only_provisional_and_confirmed_learnings_reach_a_run() {
assert!(State::Provisional.is_active());
assert!(State::Confirmed.is_active());
assert!(!State::Lapsed.is_active());
assert!(!State::Rejected.is_active());
}
#[test]
fn rewording_and_punctuation_do_not_make_a_new_insight() {
assert!(says_the_same_thing(
"The ADO token expires every 30 days; refresh it before publishing.",
"the ADO token expires every 30 days, refresh it before publishing"
));
}
#[test]
fn a_sentence_with_one_clause_added_is_still_the_same_insight() {
assert!(says_the_same_thing(
"the token expires every thirty days",
"the token expires every thirty days, refresh before publishing"
));
}
#[test]
fn a_claim_and_its_negation_are_not_the_same_claim() {
assert!(!says_the_same_thing(
"the migration is safe to run during business hours",
"the migration is not safe to run during business hours"
));
assert!(!says_the_same_thing(
"delete the old worktree before starting the next itinerary",
"do not delete the old worktree before starting the next itinerary"
));
}
#[test]
fn learnings_differing_only_in_a_number_are_different_learnings() {
assert!(!says_the_same_thing(
"the token expires every 30 days",
"the token expires every 90 days"
));
}
#[test]
fn two_texts_with_nothing_substantive_in_them_do_not_match() {
assert!(!says_the_same_thing("use rg now", "go to bed"));
assert!(!says_the_same_thing("", ""));
}
#[test]
fn two_claims_sharing_a_sentence_frame_are_not_the_same_claim() {
assert!(!says_the_same_thing(
"the workspace needs careful handling before publishing",
"the manifest needs careful handling before publishing"
));
}
#[test]
fn genuinely_different_insights_stay_separate() {
assert!(!says_the_same_thing(
"the ADO token expires every thirty days",
"prefer ripgrep over findstr when searching the tree"
));
}
#[test]
fn a_refinement_is_not_the_same_as_the_thing_it_refines() {
assert!(!says_the_same_thing(
"exclude the assistant bot from new activity",
"exclude the assistant bot and the build service account from new activity, but only \
when the commit tip is unchanged since the previous review cycle"
));
}
#[test]
fn an_empty_or_overlong_proposal_is_not_well_formed() {
assert!(!proposal(" ").is_well_formed());
assert!(!proposal(&"x".repeat(MAX_TEXT + 1)).is_well_formed());
assert!(proposal("something short and useful").is_well_formed());
}
#[test]
fn impact_is_ordered_so_a_queue_can_be_triaged() {
assert!(Impact::High > Impact::Medium);
assert!(Impact::Medium > Impact::Low);
for impact in [Impact::Low, Impact::Medium, Impact::High] {
assert_eq!(Impact::from_slug(impact.slug()), Some(impact));
}
}
#[test]
fn states_round_trip() {
for state in [
State::Provisional,
State::Confirmed,
State::Lapsed,
State::Rejected,
] {
assert_eq!(State::from_slug(state.slug()), Some(state));
}
assert_eq!(State::from_slug("approved"), None);
}
#[test]
fn a_learning_serialises_readably() {
let line = serde_json::to_string(&Learning::from_proposal(&proposal("use ripgrep")))
.expect("serialises");
assert!(line.contains(r#""state":"provisional""#), "{line}");
assert!(line.contains(r#""impact":"medium""#), "{line}");
assert!(
line.contains(r#""first_at":"2026-09-16T10:00:00Z""#),
"{line}"
);
}
}