#![allow(dead_code)]
use std::collections::HashMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SignalKind {
TimeOfDay,
DayOfWeek,
DeviceType,
NetworkQuality,
Region,
Season,
Recency,
}
impl std::fmt::Display for SignalKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::TimeOfDay => write!(f, "TimeOfDay"),
Self::DayOfWeek => write!(f, "DayOfWeek"),
Self::DeviceType => write!(f, "DeviceType"),
Self::NetworkQuality => write!(f, "NetworkQuality"),
Self::Region => write!(f, "Region"),
Self::Season => write!(f, "Season"),
Self::Recency => write!(f, "Recency"),
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct SignalValue {
pub kind: SignalKind,
pub value: f64,
pub confidence: f64,
}
impl SignalValue {
#[must_use]
pub fn new(kind: SignalKind, value: f64, confidence: f64) -> Self {
Self {
kind,
value: value.clamp(0.0, 1.0),
confidence: confidence.clamp(0.0, 1.0),
}
}
#[must_use]
pub fn effective(&self) -> f64 {
self.value * self.confidence
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TimePeriod {
EarlyMorning,
Morning,
Afternoon,
Evening,
LateNight,
}
impl TimePeriod {
#[must_use]
pub fn from_hour(hour: u8) -> Self {
match hour {
5..=7 => Self::EarlyMorning,
8..=11 => Self::Morning,
12..=16 => Self::Afternoon,
17..=20 => Self::Evening,
_ => Self::LateNight,
}
}
#[must_use]
pub fn consumption_signal(self) -> f64 {
match self {
Self::EarlyMorning => 0.3,
Self::Morning => 0.5,
Self::Afternoon => 0.6,
Self::Evening => 0.9,
Self::LateNight => 0.8,
}
}
}
impl std::fmt::Display for TimePeriod {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::EarlyMorning => write!(f, "EarlyMorning"),
Self::Morning => write!(f, "Morning"),
Self::Afternoon => write!(f, "Afternoon"),
Self::Evening => write!(f, "Evening"),
Self::LateNight => write!(f, "LateNight"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeviceCategory {
Mobile,
Tablet,
Desktop,
Tv,
}
impl DeviceCategory {
#[must_use]
pub fn length_preference_signal(self) -> f64 {
match self {
Self::Mobile => 0.3,
Self::Tablet => 0.5,
Self::Desktop => 0.7,
Self::Tv => 0.9,
}
}
#[must_use]
pub fn quality_preference_signal(self) -> f64 {
match self {
Self::Mobile => 0.4,
Self::Tablet => 0.6,
Self::Desktop => 0.8,
Self::Tv => 1.0,
}
}
}
impl std::fmt::Display for DeviceCategory {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Mobile => write!(f, "Mobile"),
Self::Tablet => write!(f, "Tablet"),
Self::Desktop => write!(f, "Desktop"),
Self::Tv => write!(f, "TV"),
}
}
}
#[derive(Debug, Clone)]
pub struct SignalWeights {
pub weights: HashMap<SignalKind, f64>,
}
impl SignalWeights {
#[must_use]
pub fn equal() -> Self {
let mut weights = HashMap::new();
let kinds = [
SignalKind::TimeOfDay,
SignalKind::DayOfWeek,
SignalKind::DeviceType,
SignalKind::NetworkQuality,
SignalKind::Region,
SignalKind::Season,
SignalKind::Recency,
];
for kind in &kinds {
weights.insert(*kind, 1.0);
}
Self { weights }
}
pub fn set_weight(&mut self, kind: SignalKind, weight: f64) {
self.weights.insert(kind, weight.max(0.0));
}
#[must_use]
pub fn get_weight(&self, kind: SignalKind) -> f64 {
self.weights.get(&kind).copied().unwrap_or(0.0)
}
#[must_use]
pub fn total_weight(&self) -> f64 {
self.weights.values().sum()
}
}
impl Default for SignalWeights {
fn default() -> Self {
Self::equal()
}
}
#[derive(Debug, Clone)]
pub struct ContextSignals {
pub signals: Vec<SignalValue>,
}
impl ContextSignals {
#[must_use]
pub fn new() -> Self {
Self {
signals: Vec::new(),
}
}
pub fn add(&mut self, signal: SignalValue) {
self.signals.push(signal);
}
pub fn add_time_of_day(&mut self, hour: u8) {
let period = TimePeriod::from_hour(hour);
self.signals.push(SignalValue::new(
SignalKind::TimeOfDay,
period.consumption_signal(),
1.0,
));
}
pub fn add_device(&mut self, device: DeviceCategory) {
self.signals.push(SignalValue::new(
SignalKind::DeviceType,
device.length_preference_signal(),
1.0,
));
}
pub fn add_day_of_week(&mut self, is_weekend: bool) {
let value = if is_weekend { 0.9 } else { 0.5 };
self.signals
.push(SignalValue::new(SignalKind::DayOfWeek, value, 1.0));
}
#[allow(clippy::cast_precision_loss)]
pub fn add_recency(&mut self, days_old: u32, max_days: u32) {
let value = if max_days == 0 {
1.0
} else {
1.0 - (f64::from(days_old.min(max_days)) / f64::from(max_days))
};
self.signals
.push(SignalValue::new(SignalKind::Recency, value, 0.9));
}
#[must_use]
pub fn composite_score(&self, weights: &SignalWeights) -> f64 {
let total_weight = weights.total_weight();
if total_weight <= 0.0 || self.signals.is_empty() {
return 0.5; }
let weighted_sum: f64 = self
.signals
.iter()
.map(|s| s.effective() * weights.get_weight(s.kind))
.sum();
let used_weight: f64 = self
.signals
.iter()
.map(|s| weights.get_weight(s.kind))
.sum();
if used_weight <= 0.0 {
return 0.5;
}
(weighted_sum / used_weight).clamp(0.0, 1.0)
}
#[must_use]
pub fn len(&self) -> usize {
self.signals.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.signals.is_empty()
}
#[must_use]
pub fn get(&self, kind: SignalKind) -> Option<&SignalValue> {
self.signals.iter().find(|s| s.kind == kind)
}
}
impl Default for ContextSignals {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug)]
pub struct ContextModulator {
weights: SignalWeights,
strength: f64,
}
impl ContextModulator {
#[must_use]
pub fn new(weights: SignalWeights, strength: f64) -> Self {
Self {
weights,
strength: strength.clamp(0.0, 1.0),
}
}
#[must_use]
pub fn modulate(&self, base_score: f64, signals: &ContextSignals) -> f64 {
let ctx = signals.composite_score(&self.weights);
let factor = 1.0 - self.strength + self.strength * ctx;
(base_score * factor).clamp(0.0, 1.0)
}
#[must_use]
pub fn strength(&self) -> f64 {
self.strength
}
pub fn set_strength(&mut self, strength: f64) {
self.strength = strength.clamp(0.0, 1.0);
}
}
impl Default for ContextModulator {
fn default() -> Self {
Self::new(SignalWeights::default(), 0.3)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_signal_kind_display() {
assert_eq!(SignalKind::TimeOfDay.to_string(), "TimeOfDay");
assert_eq!(SignalKind::DeviceType.to_string(), "DeviceType");
assert_eq!(SignalKind::Region.to_string(), "Region");
}
#[test]
fn test_signal_value_clamp() {
let sv = SignalValue::new(SignalKind::Recency, 1.5, -0.2);
assert!((sv.value - 1.0).abs() < f64::EPSILON);
assert!((sv.confidence - 0.0).abs() < f64::EPSILON);
}
#[test]
fn test_signal_value_effective() {
let sv = SignalValue::new(SignalKind::TimeOfDay, 0.8, 0.5);
assert!((sv.effective() - 0.4).abs() < f64::EPSILON);
}
#[test]
fn test_time_period_from_hour() {
assert_eq!(TimePeriod::from_hour(6), TimePeriod::EarlyMorning);
assert_eq!(TimePeriod::from_hour(10), TimePeriod::Morning);
assert_eq!(TimePeriod::from_hour(14), TimePeriod::Afternoon);
assert_eq!(TimePeriod::from_hour(19), TimePeriod::Evening);
assert_eq!(TimePeriod::from_hour(23), TimePeriod::LateNight);
assert_eq!(TimePeriod::from_hour(3), TimePeriod::LateNight);
}
#[test]
fn test_time_period_consumption_signal() {
let evening = TimePeriod::Evening.consumption_signal();
let morning = TimePeriod::Morning.consumption_signal();
assert!(evening > morning);
}
#[test]
fn test_device_category_signals() {
let mobile_len = DeviceCategory::Mobile.length_preference_signal();
let tv_len = DeviceCategory::Tv.length_preference_signal();
assert!(tv_len > mobile_len);
let mobile_q = DeviceCategory::Mobile.quality_preference_signal();
let tv_q = DeviceCategory::Tv.quality_preference_signal();
assert!(tv_q > mobile_q);
}
#[test]
fn test_signal_weights_equal() {
let w = SignalWeights::equal();
assert!((w.get_weight(SignalKind::TimeOfDay) - 1.0).abs() < f64::EPSILON);
assert!((w.get_weight(SignalKind::Season) - 1.0).abs() < f64::EPSILON);
}
#[test]
fn test_signal_weights_set() {
let mut w = SignalWeights::equal();
w.set_weight(SignalKind::TimeOfDay, 2.0);
assert!((w.get_weight(SignalKind::TimeOfDay) - 2.0).abs() < f64::EPSILON);
}
#[test]
fn test_context_signals_add_time() {
let mut ctx = ContextSignals::new();
ctx.add_time_of_day(19); assert_eq!(ctx.len(), 1);
let sig = ctx
.get(SignalKind::TimeOfDay)
.expect("should succeed in test");
assert!((sig.value - 0.9).abs() < f64::EPSILON);
}
#[test]
fn test_context_signals_add_device() {
let mut ctx = ContextSignals::new();
ctx.add_device(DeviceCategory::Tv);
let sig = ctx
.get(SignalKind::DeviceType)
.expect("should succeed in test");
assert!((sig.value - 0.9).abs() < f64::EPSILON);
}
#[test]
fn test_context_signals_add_recency() {
let mut ctx = ContextSignals::new();
ctx.add_recency(0, 30); let sig = ctx
.get(SignalKind::Recency)
.expect("should succeed in test");
assert!((sig.value - 1.0).abs() < f64::EPSILON);
let mut ctx2 = ContextSignals::new();
ctx2.add_recency(30, 30); let sig2 = ctx2
.get(SignalKind::Recency)
.expect("should succeed in test");
assert!((sig2.value - 0.0).abs() < f64::EPSILON);
}
#[test]
fn test_composite_score_empty() {
let ctx = ContextSignals::new();
let w = SignalWeights::equal();
assert!((ctx.composite_score(&w) - 0.5).abs() < f64::EPSILON);
}
#[test]
fn test_composite_score_single_signal() {
let mut ctx = ContextSignals::new();
ctx.add(SignalValue::new(SignalKind::TimeOfDay, 0.8, 1.0));
let w = SignalWeights::equal();
let score = ctx.composite_score(&w);
assert!((score - 0.8).abs() < f64::EPSILON);
}
#[test]
fn test_context_modulator_no_effect() {
let modulator = ContextModulator::new(SignalWeights::equal(), 0.0);
let ctx = ContextSignals::new();
let result = modulator.modulate(0.7, &ctx);
assert!((result - 0.7).abs() < f64::EPSILON);
}
#[test]
fn test_context_modulator_full_effect() {
let modulator = ContextModulator::new(SignalWeights::equal(), 1.0);
let mut ctx = ContextSignals::new();
ctx.add(SignalValue::new(SignalKind::TimeOfDay, 1.0, 1.0));
let result = modulator.modulate(0.5, &ctx);
assert!((result - 0.5).abs() < f64::EPSILON);
}
#[test]
fn test_device_category_display() {
assert_eq!(DeviceCategory::Mobile.to_string(), "Mobile");
assert_eq!(DeviceCategory::Tv.to_string(), "TV");
}
}