use std::collections::HashMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[allow(dead_code)]
pub enum SessionEventType {
View,
Click,
Play,
Skip,
Like,
Dislike,
Share,
Purchase,
}
impl SessionEventType {
#[must_use]
pub fn affinity_weight(self) -> f32 {
match self {
Self::Like => 1.0,
Self::Purchase => 0.9,
Self::Share => 0.8,
Self::Play => 0.7,
Self::Click => 0.4,
Self::View => 0.2,
Self::Skip => -0.3,
Self::Dislike => -0.8,
}
}
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct SessionEvent {
pub media_id: u64,
pub event_type: SessionEventType,
pub timestamp_ms: u64,
pub duration_ms: Option<u64>,
}
impl SessionEvent {
#[must_use]
pub fn new(
media_id: u64,
event_type: SessionEventType,
timestamp_ms: u64,
duration_ms: Option<u64>,
) -> Self {
Self {
media_id,
event_type,
timestamp_ms,
duration_ms,
}
}
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct Session {
pub id: String,
pub user_id: Option<u64>,
pub events: Vec<SessionEvent>,
pub created_ms: u64,
}
impl Session {
#[must_use]
pub fn new(id: impl Into<String>, user_id: Option<u64>, created_ms: u64) -> Self {
Self {
id: id.into(),
user_id,
events: Vec::new(),
created_ms,
}
}
pub fn add_event(&mut self, event: SessionEvent) {
self.events.push(event);
}
#[must_use]
pub fn completion_rate(&self, media_id: u64) -> f32 {
let mut total_play_ms: u64 = 0;
let mut total_available_ms: u64 = 0;
for event in &self.events {
if event.media_id != media_id || event.event_type != SessionEventType::Play {
continue;
}
if let Some(dur) = event.duration_ms {
total_play_ms += dur;
total_available_ms += dur;
}
}
if total_available_ms == 0 {
return 0.0;
}
(total_play_ms as f32 / total_available_ms as f32).min(1.0)
}
#[must_use]
pub fn affinity_score(&self, category: &str, categories: &HashMap<u64, Vec<String>>) -> f32 {
if self.events.is_empty() {
return 0.0;
}
let mut score = 0.0_f32;
let mut count = 0usize;
for event in &self.events {
if let Some(cats) = categories.get(&event.media_id) {
if cats.iter().any(|c| c == category) {
score += event.event_type.affinity_weight();
count += 1;
}
}
}
if count == 0 {
return 0.0;
}
(score / count as f32).clamp(0.0, 1.0)
}
#[must_use]
pub fn last_activity_ms(&self) -> u64 {
self.events
.iter()
.map(|e| e.timestamp_ms)
.max()
.unwrap_or(self.created_ms)
}
}
#[derive(Debug, Default)]
#[allow(dead_code)]
pub struct SessionStore {
sessions: HashMap<String, Session>,
next_id: u64,
}
impl SessionStore {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn create_session(&mut self) -> String {
self.create_session_for_user(None)
}
pub fn create_session_for_user(&mut self, user_id: Option<u64>) -> String {
self.next_id += 1;
let id = format!("session-{}", self.next_id);
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
let session = Session::new(id.clone(), user_id, now_ms);
self.sessions.insert(id.clone(), session);
id
}
pub fn add_event(&mut self, session_id: &str, event: SessionEvent) -> bool {
if let Some(session) = self.sessions.get_mut(session_id) {
session.add_event(event);
true
} else {
false
}
}
#[must_use]
pub fn get_session(&self, session_id: &str) -> Option<&Session> {
self.sessions.get(session_id)
}
#[must_use]
pub fn session_count(&self) -> usize {
self.sessions.len()
}
}
#[derive(Debug, Default)]
#[allow(dead_code)]
pub struct SessionBasedRecommender {
categories: HashMap<u64, Vec<String>>,
}
impl SessionBasedRecommender {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn register_categories(&mut self, media_id: u64, cats: Vec<String>) {
self.categories.insert(media_id, cats);
}
#[must_use]
pub fn recommend(&self, session: &Session, candidates: &[u64], top_k: usize) -> Vec<u64> {
if candidates.is_empty() || session.events.is_empty() {
return Vec::new();
}
let last_ts = session.last_activity_ms();
let mut scores: HashMap<u64, f32> = HashMap::new();
let interacted: std::collections::HashSet<u64> =
session.events.iter().map(|e| e.media_id).collect();
for event in &session.events {
let age_ms = last_ts.saturating_sub(event.timestamp_ms);
let recency = 1.0 / (1.0 + age_ms as f32 / 60_000.0);
let weight = event.event_type.affinity_weight() * recency;
let event_cats = match self.categories.get(&event.media_id) {
Some(c) => c,
None => continue,
};
for &cand in candidates {
if interacted.contains(&cand) {
continue;
}
if let Some(cand_cats) = self.categories.get(&cand) {
let shared = cand_cats.iter().any(|c| event_cats.contains(c));
if shared {
*scores.entry(cand).or_insert(0.0) += weight;
}
}
}
}
let mut ranked: Vec<(u64, f32)> = scores.into_iter().collect();
ranked.sort_by(|(_, a), (_, b)| b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal));
ranked.into_iter().take(top_k).map(|(id, _)| id).collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ts(offset_s: u64) -> u64 {
1_700_000_000_000 + offset_s * 1000
}
#[test]
fn test_session_event_creation() {
let e = SessionEvent::new(42, SessionEventType::Play, ts(0), Some(5000));
assert_eq!(e.media_id, 42);
assert_eq!(e.event_type, SessionEventType::Play);
assert_eq!(e.duration_ms, Some(5000));
}
#[test]
fn test_event_type_affinity_weights() {
assert!(
SessionEventType::Like.affinity_weight() > SessionEventType::Click.affinity_weight()
);
assert!(SessionEventType::Skip.affinity_weight() < 0.0);
assert!(
SessionEventType::Dislike.affinity_weight() < SessionEventType::Skip.affinity_weight()
);
}
#[test]
fn test_session_add_and_count() {
let mut session = Session::new("s1", Some(1), ts(0));
session.add_event(SessionEvent::new(
10,
SessionEventType::Play,
ts(1),
Some(3000),
));
assert_eq!(session.events.len(), 1);
}
#[test]
fn test_session_completion_rate_no_events() {
let session = Session::new("s1", None, ts(0));
assert_eq!(session.completion_rate(1), 0.0);
}
#[test]
fn test_session_completion_rate_with_play() {
let mut session = Session::new("s1", None, ts(0));
session.add_event(SessionEvent::new(
5,
SessionEventType::Play,
ts(1),
Some(6000),
));
assert!((session.completion_rate(5) - 1.0).abs() < 1e-5);
}
#[test]
fn test_session_affinity_score_no_match() {
let session = Session::new("s1", None, ts(0));
let cats: HashMap<u64, Vec<String>> = HashMap::new();
assert_eq!(session.affinity_score("drama", &cats), 0.0);
}
#[test]
fn test_session_affinity_score_positive() {
let mut session = Session::new("s1", None, ts(0));
session.add_event(SessionEvent::new(10, SessionEventType::Like, ts(1), None));
let mut cats: HashMap<u64, Vec<String>> = HashMap::new();
cats.insert(10, vec!["comedy".to_string()]);
let score = session.affinity_score("comedy", &cats);
assert!(score > 0.0);
}
#[test]
fn test_session_last_activity() {
let mut session = Session::new("s1", None, ts(0));
session.add_event(SessionEvent::new(1, SessionEventType::View, ts(5), None));
session.add_event(SessionEvent::new(2, SessionEventType::Click, ts(10), None));
assert_eq!(session.last_activity_ms(), ts(10));
}
#[test]
fn test_session_store_create() {
let mut store = SessionStore::new();
let id = store.create_session();
assert!(!id.is_empty());
assert_eq!(store.session_count(), 1);
}
#[test]
fn test_session_store_add_event() {
let mut store = SessionStore::new();
let id = store.create_session();
let event = SessionEvent::new(99, SessionEventType::Click, ts(0), None);
let ok = store.add_event(&id, event);
assert!(ok);
assert_eq!(
store
.get_session(&id)
.expect("should succeed in test")
.events
.len(),
1
);
}
#[test]
fn test_session_store_add_event_unknown() {
let mut store = SessionStore::new();
let event = SessionEvent::new(1, SessionEventType::View, ts(0), None);
assert!(!store.add_event("no-such-session", event));
}
#[test]
fn test_session_based_recommender() {
let mut rec = SessionBasedRecommender::new();
rec.register_categories(1, vec!["action".to_string()]);
rec.register_categories(2, vec!["action".to_string()]);
rec.register_categories(3, vec!["drama".to_string()]);
let mut session = Session::new("s1", None, ts(0));
session.add_event(SessionEvent::new(1, SessionEventType::Like, ts(1), None));
let results = rec.recommend(&session, &[2, 3], 2);
assert!(!results.is_empty());
assert_eq!(results[0], 2);
}
#[test]
fn test_session_based_recommender_excludes_interacted() {
let mut rec = SessionBasedRecommender::new();
rec.register_categories(1, vec!["sci-fi".to_string()]);
rec.register_categories(2, vec!["sci-fi".to_string()]);
let mut session = Session::new("s1", None, ts(0));
session.add_event(SessionEvent::new(
1,
SessionEventType::Play,
ts(1),
Some(5000),
));
let results = rec.recommend(&session, &[1, 2], 5);
assert!(!results.contains(&1)); }
}