From 25f00d1665e7e5bde39fecd18fbbd0f919ff106b Mon Sep 17 00:00:00 2001 From: Julia Lange Date: Tue, 3 Feb 2026 17:51:09 -0800 Subject: [PATCH] Score refactor, add scores to feed_channels --- .../20260115003047_initial_schema.sql | 2 + koucha/src/db/channel.rs | 2 +- koucha/src/db/feed_channel.rs | 59 +++++- koucha/src/score.rs | 181 +++++++++++------- 4 files changed, 166 insertions(+), 78 deletions(-) diff --git a/koucha/migrations/20260115003047_initial_schema.sql b/koucha/migrations/20260115003047_initial_schema.sql index ae2418e..b863eee 100644 --- a/koucha/migrations/20260115003047_initial_schema.sql +++ b/koucha/migrations/20260115003047_initial_schema.sql @@ -51,6 +51,8 @@ CREATE TABLE feed_items ( item_id INTEGER NOT NULL, feed_id INTEGER NOT NULL, score INTEGER NOT NULL, + last_updated TEXT NOT NULL, + boosted_at TEXT, archived BOOLEAN DEFAULT FALSE, PRIMARY KEY (item_id, feed_id), FOREIGN KEY (feed_id) REFERENCES feeds(id), diff --git a/koucha/src/db/channel.rs b/koucha/src/db/channel.rs index 0ea271b..6df59fb 100644 --- a/koucha/src/db/channel.rs +++ b/koucha/src/db/channel.rs @@ -115,7 +115,7 @@ impl Channel { ) -> Result> { let feeds: Result> = sqlx::query_as!( UnparsedFeedChannel, - "SELECT channel_id, feed_id + "SELECT channel_id, feed_id, initial_score, gravity, boost FROM feed_channels WHERE channel_id = ?", self.id.0 diff --git a/koucha/src/db/feed_channel.rs b/koucha/src/db/feed_channel.rs index 19656cb..0640555 100644 --- a/koucha/src/db/feed_channel.rs +++ b/koucha/src/db/feed_channel.rs @@ -8,17 +8,29 @@ use crate::{ FeedId, Item, }, + score::{ + Score, + Gravity, + Boost, + }, }; +use chrono::{Utc, DateTime}; pub struct UnparsedFeedChannel { pub channel_id: i64, pub feed_id: i64, + pub initial_score: Option, + pub gravity: Option, + pub boost: Option, } impl UnparsedFeedChannel { pub fn parse(self) -> Result { Ok(FeedChannel { channel_id: ChannelId(self.channel_id), - feed_id: FeedId(self.feed_id) + feed_id: FeedId(self.feed_id), + initial_score: Score::new(self.initial_score), + gravity: Gravity::new(self.gravity), + boost: Boost::new(self.boost), }) } } @@ -26,6 +38,9 @@ impl UnparsedFeedChannel { pub struct FeedChannel { channel_id: ChannelId, feed_id: FeedId, + initial_score: Score, + gravity: Gravity, + boost: Boost, } impl FeedChannel { @@ -38,17 +53,26 @@ impl FeedChannel { pub async fn add_item( &self, pool: &AdapterPool, item: &Item + ) -> Result<()> { + self.add_item_at(pool, item, Utc::now()).await + } + + async fn add_item_at( + &self, pool: &AdapterPool, item: &Item, add_at: DateTime ) -> Result<()> { let int_item_id = i64::from(item.id()); let int_feed_id = i64::from(self.feed_id); + let int_initial_score = i64::from(self.initial_score); + let string_last_updated = add_at.to_rfc2822(); sqlx::query!( - "INSERT OR IGNORE INTO feed_items (feed_id, item_id, score) - VALUES (?, ?, 5)", // TODO: Add in scoring featuress - int_feed_id, int_item_id + "INSERT OR IGNORE INTO feed_items (feed_id, item_id, score, last_updated) + VALUES (?, ?, ?, ?)", + int_feed_id, int_item_id, int_initial_score, string_last_updated ).execute(&pool.0).await?; Ok(()) } + } #[cfg(test)] @@ -62,23 +86,32 @@ mod tests { User }, test_utils::{ - FEED1, setup_adapter, + FEED1, setup_adapter, get_datetime }, }; #[test] fn parse() { const CID: i64 = 1; - const FID: i64 = 1; + const FID: i64 = 2; + const IS: i64 = 3; + const G: i64 = 4; + const B: i64 = 5; let ufc = UnparsedFeedChannel { channel_id: CID, feed_id: FID, + initial_score: Some(IS), + gravity: Some(G), + boost: Some(B), }; let fc = ufc.parse().unwrap(); assert_eq!(fc.channel_id.0, CID); assert_eq!(fc.feed_id.0, FID); + assert_eq!(i64::from(fc.initial_score), IS); + assert_eq!(i64::from(fc.gravity), G); + assert_eq!(i64::from(fc.boost), B); } // FeedChannel Tests @@ -93,6 +126,9 @@ mod tests { let fc = FeedChannel { channel_id: channel.id(), feed_id: FeedId(1), // Fake Feed + initial_score: Score::new(None), + gravity: Gravity::new(None), + boost: Boost::new(None), }; let channel_from_fc = fc.get_channel(pool).await.unwrap(); @@ -110,14 +146,18 @@ mod tests { let fc = FeedChannel { channel_id: ChannelId(1), // Fake Channel feed_id: feed.id(), + initial_score: Score::new(None), + gravity: Gravity::new(None), + boost: Boost::new(None), }; let feed_from_fc = fc.get_feed(pool).await.unwrap(); assert_eq!(feed_from_fc.id(), feed.id()); } - + #[tokio::test] pub async fn add_item() { + let dt = get_datetime(); let adapter = setup_adapter().await; let pool = adapter.get_pool(); @@ -128,10 +168,13 @@ mod tests { let fc = FeedChannel { channel_id: channel.id(), feed_id: feed.id(), + initial_score: Score::new(None), + gravity: Gravity::new(None), + boost: Boost::new(None), }; let item = Item::get_or_create(pool, channel.id(), "item-guid").await.unwrap(); - fc.add_item(pool, &item).await.unwrap(); + fc.add_item_at(pool, &item, dt).await.unwrap(); let items = feed.get_items(pool, 1, 0).await.unwrap(); assert_eq!(items[0].id(), item.id()); diff --git a/koucha/src/score.rs b/koucha/src/score.rs index 4cdac60..6d6cba2 100644 --- a/koucha/src/score.rs +++ b/koucha/src/score.rs @@ -1,5 +1,6 @@ use chrono::{DateTime, Utc, TimeDelta}; use crate::{Result}; +use std::ops::{Add, Sub}; mod default { use crate::score::SECONDS_IN_A_DAY; @@ -12,57 +13,91 @@ mod default { const SECONDS_IN_A_DAY: i64 = 60 * 60 * 24; macro_rules! rich_i64 { - ($name:ident, $default:expr) => { - #[derive(PartialEq, Debug, Copy, Clone)] - pub struct $name(i64); - impl From<$name> for i64 { fn from(id: $name) -> Self { id.0 } } - impl $name { - pub fn new(value: Option) -> Self { - Self(value.unwrap_or($default)) - } - } - }; + ($name:ident) => { + #[derive(PartialOrd, PartialEq, Debug, Copy, Clone)] + pub struct $name(i64); + impl From<$name> for i64 { fn from(id: $name) -> Self { id.0 } } + }; } -rich_i64!(Gravity, default::GRAVITY); -rich_i64!(Boost, default::BOOST); -pub struct UnparsedScore { +macro_rules! defaulting_i64 { + ($name:ident, $default:expr) => { + rich_i64!($name); + impl $name { + pub fn new(value: Option) -> Self { + Self(value.unwrap_or($default)) + } + } + }; +} + +macro_rules! addable_i64s { + ($lhs:ident, $rhs:ident) => { + impl Add<$rhs> for $lhs { + type Output = Self; + fn add(self, other: $rhs) -> Self::Output { Self(self.0 + other.0) } + } + } +} + +defaulting_i64!(Score, default::INITIAL_SCORE); +addable_i64s!(Score, Score); +addable_i64s!(Score, Boost); +impl Sub for Score { + type Output = Self; + fn sub(self, other: Boost) -> Self::Output { Self(self.0 - other.0) } +} +addable_i64s!(Score, GravityOverDuration); +defaulting_i64!(Boost, default::BOOST); +defaulting_i64!(Gravity, default::GRAVITY); +rich_i64!(GravityOverDuration); +impl Gravity { + fn over_duration( + &self, start: DateTime, end: DateTime + ) -> GravityOverDuration { + let elapsed_time = end.signed_duration_since(start); + GravityOverDuration( + self.0 * (elapsed_time.num_seconds() / SECONDS_IN_A_DAY) + ) + } +} +pub struct UnparsedTimedScore { pub value: i64, pub last_updated: DateTime, pub last_boosted: Option>, } -impl UnparsedScore { - pub fn parse(self) -> Score { +impl UnparsedTimedScore { + pub fn parse(self) -> TimedScore { match self.last_boosted { - None => Score::Decaying(DecayingScore { - value: self.value, + None => TimedScore::Decaying(DecayingScore { + value: Score(self.value), last_updated: self.last_updated, }), - Some(last_boosted) => Score::Boosted(BoostedScore { - value: self.value, + Some(last_boosted) => TimedScore::Boosted(BoostedScore { + value: Score(self.value), boosted_at: last_boosted, }), } } } -pub enum Score { +pub enum TimedScore { Decaying(DecayingScore), Boosted(BoostedScore), } -impl Score { +impl TimedScore { pub fn new() -> DecayingScore { - Self::new_with_initial(default::INITIAL_SCORE) + Self::new_with_initial(Score::new(None)) } - pub fn new_with_initial(initial_score: i64) -> DecayingScore { + pub fn new_with_initial(initial_score: Score) -> DecayingScore { Self::new_with_initial_and_time(initial_score, Utc::now()) } pub fn new_with_initial_and_time( - initial: i64, time: DateTime + initial: Score, time: DateTime ) -> DecayingScore { DecayingScore { value: initial, @@ -70,7 +105,7 @@ impl Score { } } - pub fn get_score(&self) -> i64 { + pub fn get_score(&self) -> Score { match self { Self::Decaying(s) => s.get_score(), Self::Boosted(b) => b.get_score(), @@ -83,16 +118,16 @@ impl Score { fn update_score_at_time(self, gravity: Gravity, time: DateTime) -> Self { match self { - Self::Decaying(d) => Score::Decaying( + Self::Decaying(d) => TimedScore::Decaying( d.apply_gravity_to_time(gravity, time) ), Self::Boosted(b) => { let try_unfrozen = b.try_unfreeze_at_time(time); match try_unfrozen { - Self::Decaying(s) => Score::Decaying( + Self::Decaying(s) => TimedScore::Decaying( s.apply_gravity_to_time(gravity, time) ), - Self::Boosted(b) => Score::Boosted(b), + Self::Boosted(b) => TimedScore::Boosted(b), } } } @@ -114,30 +149,26 @@ impl Score { } pub struct BoostedScore { - value: i64, + value: Score, boosted_at: DateTime, } pub struct DecayingScore { - value: i64, + value: Score, last_updated: DateTime, } impl DecayingScore { - fn get_score(&self) -> i64 { + fn get_score(&self) -> Score { self.value } fn apply_gravity_to_time( self, gravity: Gravity, update_time: DateTime ) -> Self { - let elapsed_time: TimeDelta = update_time.signed_duration_since(self.last_updated); - let new_value = if elapsed_time <= TimeDelta::zero() { self.value } else { - self.value + i64::from(gravity) * (elapsed_time.num_seconds() / SECONDS_IN_A_DAY) - }; Self { last_updated: update_time, - value: new_value, + value: self.value + gravity.over_duration(self.last_updated, update_time), } } @@ -147,33 +178,33 @@ impl DecayingScore { fn boost_at_time(self, boost: Boost, boost_time: DateTime) -> BoostedScore { BoostedScore { - value: self.value + i64::from(boost), + value: self.value + boost, boosted_at: boost_time, } } } impl BoostedScore { - fn get_score(&self) -> i64 { + fn get_score(&self) -> Score { self.value } pub fn unboost(self, boost: Boost) -> DecayingScore { DecayingScore { - value: self.value - i64::from(boost), + value: self.value - boost, last_updated: self.boosted_at, } } - fn try_unfreeze_at_time(self, update_time: DateTime) -> Score { + fn try_unfreeze_at_time(self, update_time: DateTime) -> TimedScore { let boost_end = self.boosted_at + TimeDelta::seconds(default::BOOST_FREEZE_IN_SECONDS); if boost_end < update_time { - Score::Decaying(DecayingScore { + TimedScore::Decaying(DecayingScore { value: self.value, last_updated: boost_end, }) } else { - Score::Boosted(self) + TimedScore::Boosted(self) } } } @@ -199,7 +230,7 @@ mod tests { #[test] fn parse_decaying() { - let ups = UnparsedScore { + let ups = UnparsedTimedScore { value: 10, last_updated: get_datetime(), last_boosted: None, @@ -211,7 +242,7 @@ mod tests { #[test] fn parse_boosted() { let dt = get_datetime(); - let ups = UnparsedScore { + let ups = UnparsedTimedScore { value: 10, last_updated: dt, last_boosted: Some(dt), @@ -222,22 +253,24 @@ mod tests { #[test] fn new() { - let score = Score::new(); - assert_eq!(score.value, default::INITIAL_SCORE); + let score = TimedScore::new(); + assert_eq!(score.value, Score(default::INITIAL_SCORE)); } #[test] fn new_with_values() { let dt = get_datetime(); - let score = Score::new_with_initial_and_time(10, dt); - assert_eq!(score.value, 10); + let score = TimedScore::new_with_initial_and_time(Score(10), dt); + assert_eq!(score.value, Score(10)); assert_eq!(score.last_updated, dt); } #[test] fn update_score_stays_decaying() { let dt = get_datetime(); - let score = Score::Decaying(Score::new_with_initial_and_time(10, dt)); + let score = TimedScore::Decaying( + TimedScore::new_with_initial_and_time(Score(10), dt) + ); let gravity = Gravity::new(None); let dt2 = dt + TimeDelta::seconds(SECONDS_IN_A_DAY); @@ -248,7 +281,9 @@ mod tests { #[test] fn update_score_stays_frozen() { let dt = get_datetime(); - let score = Score::Boosted(BoostedScore { value: 10, boosted_at: dt }); + let score = TimedScore::Boosted( + BoostedScore { value: Score(10), boosted_at: dt } + ); let gravity = Gravity::new(None); let dt2 = dt + TimeDelta::seconds(default::BOOST_FREEZE_IN_SECONDS); @@ -259,7 +294,9 @@ mod tests { #[test] fn update_score_thaws_and_decays() { let dt = get_datetime(); - let score = Score::Boosted(BoostedScore { value: 10, boosted_at: dt }); + let score = TimedScore::Boosted( + BoostedScore { value: Score(10), boosted_at: dt } + ); let gravity = Gravity::new(None); let dt2 = dt + TimeDelta::seconds( @@ -268,13 +305,15 @@ mod tests { let updated = score.update_score_at_time(gravity, dt2) .get_decaying().unwrap(); - assert!(updated.value < 10) + assert!(updated.value < Score(10)) } #[test] fn get_decaying_success() { let dt = get_datetime(); - let score = Score::Decaying(Score::new_with_initial_and_time(10, dt)); + let score = TimedScore::Decaying( + TimedScore::new_with_initial_and_time(Score(10), dt) + ); score.get_decaying().unwrap(); } @@ -283,7 +322,9 @@ mod tests { #[should_panic = "Attempted to get_boosted() of a decaying score"] fn get_boosted_failure() { let dt = get_datetime(); - let score = Score::Decaying(Score::new_with_initial_and_time(10, dt)); + let score = TimedScore::Decaying( + TimedScore::new_with_initial_and_time(Score(10), dt) + ); score.get_boosted().unwrap(); } @@ -293,8 +334,9 @@ mod tests { fn get_decaying_failure() { let dt = get_datetime(); let boost = Boost::new(None); - let score = Score::Boosted( - Score::new_with_initial_and_time(10, dt).boost_at_time(boost, dt) + let score = TimedScore::Boosted( + TimedScore::new_with_initial_and_time(Score(10), dt) + .boost_at_time(boost, dt) ); score.get_decaying().unwrap(); @@ -304,8 +346,9 @@ mod tests { fn get_boosted_success() { let dt = get_datetime(); let boost = Boost::new(None); - let score = Score::Boosted( - Score::new_with_initial_and_time(10, dt).boost_at_time(boost, dt) + let score = TimedScore::Boosted( + TimedScore::new_with_initial_and_time(Score(10), dt) + .boost_at_time(boost, dt) ); score.get_boosted().unwrap(); @@ -316,37 +359,37 @@ mod tests { #[test] fn apply_gravity_to_future() { let dt = get_datetime(); - let score = DecayingScore { value: 10, last_updated: dt }; + let score = DecayingScore { value: Score(10), last_updated: dt }; let future = dt + TimeDelta::seconds(SECONDS_IN_A_DAY); let gravity = Gravity::new(None); let updated = score.apply_gravity_to_time(gravity, future); - assert_eq!(updated.value, 10 + default::GRAVITY); + assert!(updated.value < Score(10)); assert_eq!(updated.last_updated, future); } - + #[test] fn apply_gravity_to_past() { let dt = get_datetime(); - let score = DecayingScore { value: 10, last_updated: dt }; + let score = DecayingScore { value: Score(10), last_updated: dt }; let past = dt - TimeDelta::seconds(SECONDS_IN_A_DAY); let gravity = Gravity::new(None); let updated = score.apply_gravity_to_time(gravity, past); - assert_eq!(updated.value, 10); + assert!(updated.value > Score(10)); assert_eq!(updated.last_updated, past); } #[test] fn boost() { let dt = get_datetime(); - let score = DecayingScore { value: 10, last_updated: dt }; + let score = DecayingScore { value: Score(10), last_updated: dt }; let boost = Boost::new(None); let boosted = score.boost_at_time(boost, dt); - assert_eq!(boosted.value, 10 + default::BOOST); + assert_eq!(boosted.value, Score(10) + Boost(default::BOOST)); assert_eq!(boosted.boosted_at, dt); } @@ -355,19 +398,19 @@ mod tests { #[test] fn unboost() { let dt = get_datetime(); - let score = DecayingScore { value: 10, last_updated: dt }; + let score = DecayingScore { value: Score(10), last_updated: dt }; let boost = Boost::new(None); let boosted = score.boost_at_time(boost, dt); let unboosted = boosted.unboost(boost); - assert_eq!(unboosted.value, 10); + assert_eq!(unboosted.value, Score(10)); assert_eq!(unboosted.last_updated, dt); } #[test] fn boosted_stays_frozen() { let dt = get_datetime(); - let score = BoostedScore { value: 10, boosted_at: dt }; + let score = BoostedScore { value: Score(10), boosted_at: dt }; let last_second = dt + TimeDelta::seconds(default::BOOST_FREEZE_IN_SECONDS); @@ -377,7 +420,7 @@ mod tests { #[test] fn boosted_thaws() { let dt = get_datetime(); - let score = BoostedScore { value: 10, boosted_at: dt }; + let score = BoostedScore { value: Score(10), boosted_at: dt }; let first_second = dt + TimeDelta::days(default::BOOST_FREEZE_IN_SECONDS+1);