use serde::{Deserialize, Serialize};
use std::collections::HashMap;
pub const FEATURE_COUNT: usize = 10;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[repr(usize)]
pub enum FeatureIndex {
CircadianReceptivity = 0,
ActivityLevel = 1,
InteractionFrequency = 2,
DismissalRate = 3,
IdleDuration = 4,
SessionFatigue = 5,
DayOfWeekReceptivity = 6,
EmotionalValence = 7,
BudgetUtilization = 8,
NotificationMode = 9,
}
impl FeatureIndex {
pub const ALL: [FeatureIndex; FEATURE_COUNT] = [
Self::CircadianReceptivity,
Self::ActivityLevel,
Self::InteractionFrequency,
Self::DismissalRate,
Self::IdleDuration,
Self::SessionFatigue,
Self::DayOfWeekReceptivity,
Self::EmotionalValence,
Self::BudgetUtilization,
Self::NotificationMode,
];
pub fn name(self) -> &'static str {
match self {
Self::CircadianReceptivity => "circadian_receptivity",
Self::ActivityLevel => "activity_level",
Self::InteractionFrequency => "interaction_frequency",
Self::DismissalRate => "dismissal_rate",
Self::IdleDuration => "idle_duration",
Self::SessionFatigue => "session_fatigue",
Self::DayOfWeekReceptivity => "day_of_week_receptivity",
Self::EmotionalValence => "emotional_valence",
Self::BudgetUtilization => "budget_utilization",
Self::NotificationMode => "notification_mode",
}
}
}
pub type FeatureVector = [f64; FEATURE_COUNT];
pub fn default_features() -> FeatureVector {
[
0.5, 0.0, 0.0, 0.0, 0.5, 0.0, 0.5, 0.5, 0.0, 0.0, ]
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ActivityState {
Idle,
JustReturned,
Browsing,
Communicating,
TaskSwitching,
FocusedWork,
DeepFocus,
}
impl ActivityState {
pub fn interruption_cost(self) -> f64 {
match self {
Self::Idle => 0.05,
Self::JustReturned => 0.10,
Self::TaskSwitching => 0.15,
Self::Browsing => 0.30,
Self::Communicating => 0.55,
Self::FocusedWork => 0.75,
Self::DeepFocus => 0.95,
}
}
pub fn activity_level(self) -> f64 {
self.interruption_cost()
}
pub fn as_str(self) -> &'static str {
match self {
Self::Idle => "idle",
Self::JustReturned => "just_returned",
Self::Browsing => "browsing",
Self::Communicating => "communicating",
Self::TaskSwitching => "task_switching",
Self::FocusedWork => "focused_work",
Self::DeepFocus => "deep_focus",
}
}
pub fn from_str(s: &str) -> Self {
match s {
"idle" => Self::Idle,
"just_returned" => Self::JustReturned,
"browsing" => Self::Browsing,
"communicating" => Self::Communicating,
"task_switching" => Self::TaskSwitching,
"focused_work" => Self::FocusedWork,
"deep_focus" => Self::DeepFocus,
_ => Self::Idle,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum NotificationMode {
All,
ImportantOnly,
DoNotDisturb,
}
impl NotificationMode {
pub fn feature_value(self) -> f64 {
match self {
Self::All => 0.0,
Self::ImportantOnly => 0.5,
Self::DoNotDisturb => 1.0,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContextSnapshot {
pub now: f64,
pub activity: ActivityState,
pub recent_interactions_15min: u32,
pub recent_outcomes: (u32, u32, u32),
pub secs_since_last_interaction: f64,
pub session_duration_secs: f64,
pub emotional_valence: f64,
pub session_suggestions_accepted: u32,
pub session_suggestion_budget: u32,
pub notification_mode: NotificationMode,
}
impl ContextSnapshot {
pub fn to_features(&self, model: &ReceptivityModel) -> FeatureVector {
let mut features = default_features();
let hour = super::temporal::hour_of_day_utc(self.now);
features[FeatureIndex::CircadianReceptivity as usize] = model.circadian_receptivity[hour];
features[FeatureIndex::ActivityLevel as usize] = self.activity.activity_level();
features[FeatureIndex::InteractionFrequency as usize] =
(self.recent_interactions_15min as f64 / 20.0).clamp(0.0, 1.0);
let total_outcomes =
self.recent_outcomes.0 + self.recent_outcomes.1 + self.recent_outcomes.2;
features[FeatureIndex::DismissalRate as usize] = if total_outcomes > 0 {
self.recent_outcomes.1 as f64 / total_outcomes as f64
} else {
0.0
};
features[FeatureIndex::IdleDuration as usize] =
1.0 / (1.0 + (-0.005 * (self.secs_since_last_interaction - 300.0)).exp());
features[FeatureIndex::SessionFatigue as usize] =
1.0 / (1.0 + (-0.0003 * (self.session_duration_secs - 7200.0)).exp());
let dow = super::temporal::day_of_week_utc(self.now);
features[FeatureIndex::DayOfWeekReceptivity as usize] = model.dow_receptivity[dow];
features[FeatureIndex::EmotionalValence as usize] = (self.emotional_valence + 1.0) / 2.0;
features[FeatureIndex::BudgetUtilization as usize] = if self.session_suggestion_budget > 0 {
self.session_suggestions_accepted as f64 / self.session_suggestion_budget as f64
} else {
0.0
};
features[FeatureIndex::NotificationMode as usize] = self.notification_mode.feature_value();
features
}
}
impl Default for ContextSnapshot {
fn default() -> Self {
Self {
now: 0.0,
activity: ActivityState::Idle,
recent_interactions_15min: 0,
recent_outcomes: (0, 0, 0),
secs_since_last_interaction: 0.0,
session_duration_secs: 0.0,
emotional_valence: 0.0,
session_suggestions_accepted: 0,
session_suggestion_budget: 20,
notification_mode: NotificationMode::All,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReceptivityModel {
pub weights: [f64; FEATURE_COUNT],
pub bias: f64,
pub learning_rate: f64,
pub l2_lambda: f64,
pub training_count: u64,
pub circadian_receptivity: [f64; 24],
pub dow_receptivity: [f64; 7],
pub quiet_hours: QuietHoursConfig,
pub attention_budget: AttentionBudgetConfig,
}
impl ReceptivityModel {
pub fn new() -> Self {
let mut weights = [0.0; FEATURE_COUNT];
weights[FeatureIndex::CircadianReceptivity as usize] = 1.5; weights[FeatureIndex::ActivityLevel as usize] = -3.0; weights[FeatureIndex::InteractionFrequency as usize] = 2.0; weights[FeatureIndex::DismissalRate as usize] = -4.0; weights[FeatureIndex::IdleDuration as usize] = -1.0; weights[FeatureIndex::SessionFatigue as usize] = -1.5; weights[FeatureIndex::DayOfWeekReceptivity as usize] = 1.0; weights[FeatureIndex::EmotionalValence as usize] = 1.5; weights[FeatureIndex::BudgetUtilization as usize] = -2.5; weights[FeatureIndex::NotificationMode as usize] = -5.0;
Self {
weights,
bias: 0.5, learning_rate: 0.05,
l2_lambda: 0.001,
training_count: 0,
circadian_receptivity: [0.5; 24], dow_receptivity: [0.5; 7], quiet_hours: QuietHoursConfig::default(),
attention_budget: AttentionBudgetConfig::default(),
}
}
#[inline]
fn sigmoid(z: f64) -> f64 {
1.0 / (1.0 + (-z).exp())
}
pub fn predict(&self, features: &FeatureVector) -> f64 {
let z: f64 = self
.weights
.iter()
.zip(features.iter())
.map(|(&w, &x)| w * x)
.sum::<f64>()
+ self.bias;
Self::sigmoid(z)
}
pub fn estimate(&self, context: &ContextSnapshot) -> ReceptivityEstimate {
if self.quiet_hours.is_quiet(context.now) {
return ReceptivityEstimate {
score: 0.0,
factors: vec![ReceptivityFactor {
name: "quiet_hours".to_string(),
value: 1.0,
contribution: -1.0,
description: "Quiet hours active — all interruptions blocked".to_string(),
}],
is_quiet_hours: true,
budget_remaining: 0,
};
}
if context.notification_mode == NotificationMode::DoNotDisturb {
return ReceptivityEstimate {
score: 0.0,
factors: vec![ReceptivityFactor {
name: "do_not_disturb".to_string(),
value: 1.0,
contribution: -1.0,
description: "Do Not Disturb mode active".to_string(),
}],
is_quiet_hours: false,
budget_remaining: 0,
};
}
let features = context.to_features(self);
let score = self.predict(&features);
let factors: Vec<ReceptivityFactor> = FeatureIndex::ALL
.iter()
.map(|&idx| {
let i = idx as usize;
let contribution = self.weights[i] * features[i];
ReceptivityFactor {
name: idx.name().to_string(),
value: features[i],
contribution,
description: factor_description(idx, features[i]),
}
})
.collect();
let budget_remaining = self
.attention_budget
.remaining(context.session_suggestions_accepted);
ReceptivityEstimate {
score,
factors,
is_quiet_hours: false,
budget_remaining,
}
}
pub fn learn(&mut self, features: &FeatureVector, outcome: SuggestionOutcome) {
let target = match outcome {
SuggestionOutcome::Accepted => 1.0,
SuggestionOutcome::Dismissed => 0.0,
SuggestionOutcome::Ignored => 0.3, };
let prediction = self.predict(features);
let error = target - prediction;
for i in 0..FEATURE_COUNT {
let gradient = error * features[i] - self.l2_lambda * self.weights[i];
self.weights[i] += self.learning_rate * gradient;
}
self.bias += self.learning_rate * error;
self.training_count += 1;
if self.training_count % 100 == 0 {
self.learning_rate = (self.learning_rate * 0.95).max(0.001);
}
}
pub fn learn_temporal_pattern(&mut self, timestamp: f64, outcome: SuggestionOutcome) {
let hour = super::temporal::hour_of_day_utc(timestamp);
let dow = super::temporal::day_of_week_utc(timestamp);
let lr = 0.05;
let target = match outcome {
SuggestionOutcome::Accepted => 0.8,
SuggestionOutcome::Dismissed => 0.2,
SuggestionOutcome::Ignored => 0.4,
};
self.circadian_receptivity[hour] += lr * (target - self.circadian_receptivity[hour]);
self.dow_receptivity[dow] += lr * (target - self.dow_receptivity[dow]);
}
pub fn observe_outcome(&mut self, context: &ContextSnapshot, outcome: SuggestionOutcome) {
let features = context.to_features(self);
self.learn(&features, outcome);
self.learn_temporal_pattern(context.now, outcome);
}
}
impl Default for ReceptivityModel {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SuggestionOutcome {
Accepted,
Dismissed,
Ignored,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReceptivityEstimate {
pub score: f64,
pub factors: Vec<ReceptivityFactor>,
pub is_quiet_hours: bool,
pub budget_remaining: u32,
}
impl ReceptivityEstimate {
pub fn is_receptive(&self, threshold: f64) -> bool {
self.score >= threshold && !self.is_quiet_hours && self.budget_remaining > 0
}
pub fn top_blocker(&self) -> Option<&ReceptivityFactor> {
self.factors.iter().min_by(|a, b| {
a.contribution
.partial_cmp(&b.contribution)
.unwrap_or(std::cmp::Ordering::Equal)
})
}
pub fn top_enabler(&self) -> Option<&ReceptivityFactor> {
self.factors.iter().max_by(|a, b| {
a.contribution
.partial_cmp(&b.contribution)
.unwrap_or(std::cmp::Ordering::Equal)
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReceptivityFactor {
pub name: String,
pub value: f64,
pub contribution: f64,
pub description: String,
}
fn factor_description(idx: FeatureIndex, value: f64) -> String {
match idx {
FeatureIndex::CircadianReceptivity => {
if value > 0.7 {
"Peak receptivity time of day".into()
} else if value < 0.3 {
"Low receptivity time of day".into()
} else {
"Average time of day".into()
}
}
FeatureIndex::ActivityLevel => {
if value > 0.7 {
"User in deep focus — avoid interrupting".into()
} else if value > 0.4 {
"User moderately active".into()
} else {
"User idle or lightly active".into()
}
}
FeatureIndex::InteractionFrequency => {
if value > 0.5 {
"User actively engaged".into()
} else if value > 0.1 {
"Some recent interaction".into()
} else {
"No recent interaction".into()
}
}
FeatureIndex::DismissalRate => {
if value > 0.5 {
"High dismissal rate — user rejecting suggestions".into()
} else if value > 0.2 {
"Moderate dismissal rate".into()
} else {
"Low dismissal rate — user open to suggestions".into()
}
}
FeatureIndex::IdleDuration => {
if value > 0.7 {
"User has been idle for a while — may have left".into()
} else if value > 0.3 {
"User recently active".into()
} else {
"User just interacted".into()
}
}
FeatureIndex::SessionFatigue => {
if value > 0.7 {
"Extended session — user may be fatigued".into()
} else if value > 0.3 {
"Moderate session length".into()
} else {
"Fresh session".into()
}
}
FeatureIndex::DayOfWeekReceptivity => {
if value > 0.7 {
"Historically receptive day".into()
} else if value < 0.3 {
"Historically unreceptive day".into()
} else {
"Average day".into()
}
}
FeatureIndex::EmotionalValence => {
if value > 0.7 {
"Positive emotional state".into()
} else if value < 0.3 {
"Negative emotional state".into()
} else {
"Neutral emotional state".into()
}
}
FeatureIndex::BudgetUtilization => {
if value > 0.8 {
"Attention budget nearly depleted".into()
} else if value > 0.5 {
"Attention budget half used".into()
} else {
"Attention budget available".into()
}
}
FeatureIndex::NotificationMode => {
if value > 0.7 {
"Do Not Disturb mode".into()
} else if value > 0.3 {
"Important notifications only".into()
} else {
"All notifications allowed".into()
}
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QuietHoursConfig {
pub enabled: bool,
pub start_hour: u8,
pub end_hour: u8,
pub weekday_mask: u8,
pub allow_critical: bool,
}
impl Default for QuietHoursConfig {
fn default() -> Self {
Self {
enabled: true,
start_hour: 22,
end_hour: 7,
weekday_mask: 0x7F, allow_critical: true,
}
}
}
impl QuietHoursConfig {
pub fn is_quiet(&self, timestamp: f64) -> bool {
if !self.enabled {
return false;
}
let hour = super::temporal::hour_of_day_utc(timestamp) as u8;
let dow = super::temporal::day_of_week_utc(timestamp);
if self.weekday_mask & (1 << dow) == 0 {
return false; }
if self.start_hour > self.end_hour {
hour >= self.start_hour || hour < self.end_hour
} else if self.start_hour < self.end_hour {
hour >= self.start_hour && hour < self.end_hour
} else {
false }
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AttentionBudgetConfig {
pub max_per_session: u32,
pub min_interval_secs: f64,
pub recovery_rate_per_hour: f64,
}
impl Default for AttentionBudgetConfig {
fn default() -> Self {
Self {
max_per_session: 20,
min_interval_secs: 120.0, recovery_rate_per_hour: 5.0,
}
}
}
impl AttentionBudgetConfig {
pub fn remaining(&self, used: u32) -> u32 {
self.max_per_session.saturating_sub(used)
}
pub fn interval_ok(&self, secs_since_last_suggestion: f64) -> bool {
secs_since_last_suggestion >= self.min_interval_secs
}
}
pub fn estimate_interruption_cost(
activity: ActivityState,
action_urgency: f64,
action_importance: f64,
) -> f64 {
let base_cost = activity.interruption_cost();
let urgency_discount = 1.0 - 0.5 * action_urgency;
let importance_discount = 1.0 - 0.3 * action_importance;
let adjusted = base_cost * urgency_discount * importance_discount;
adjusted.clamp(0.0, 1.0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_features_bounds() {
let features = default_features();
for (i, &f) in features.iter().enumerate() {
assert!(f >= 0.0 && f <= 1.0, "Feature {i} out of bounds: {f}");
}
}
#[test]
fn test_feature_index_count() {
assert_eq!(FeatureIndex::ALL.len(), FEATURE_COUNT);
}
#[test]
fn test_activity_cost_ordering() {
let states = [
ActivityState::Idle,
ActivityState::JustReturned,
ActivityState::TaskSwitching,
ActivityState::Browsing,
ActivityState::Communicating,
ActivityState::FocusedWork,
ActivityState::DeepFocus,
];
for w in states.windows(2) {
assert!(
w[0].interruption_cost() <= w[1].interruption_cost(),
"{:?} ({}) should cost <= {:?} ({})",
w[0],
w[0].interruption_cost(),
w[1],
w[1].interruption_cost()
);
}
}
#[test]
fn test_model_idle_user_receptive() {
let model = ReceptivityModel::new();
let context = ContextSnapshot {
now: 50000.0, activity: ActivityState::Idle,
recent_interactions_15min: 5,
recent_outcomes: (3, 0, 0), secs_since_last_interaction: 30.0,
session_duration_secs: 600.0,
emotional_valence: 0.3,
session_suggestions_accepted: 2,
session_suggestion_budget: 20,
notification_mode: NotificationMode::All,
};
let estimate = model.estimate(&context);
assert!(
estimate.score > 0.5,
"Idle user should be receptive: {}",
estimate.score
);
assert!(!estimate.is_quiet_hours);
}
#[test]
fn test_model_focused_user_not_receptive() {
let model = ReceptivityModel::new();
let context = ContextSnapshot {
now: 50000.0,
activity: ActivityState::DeepFocus,
recent_interactions_15min: 15,
recent_outcomes: (1, 5, 2), secs_since_last_interaction: 10.0,
session_duration_secs: 14400.0, emotional_valence: -0.3,
session_suggestions_accepted: 15,
session_suggestion_budget: 20,
notification_mode: NotificationMode::ImportantOnly,
};
let estimate = model.estimate(&context);
assert!(
estimate.score < 0.3,
"Focused user should not be receptive: {}",
estimate.score
);
}
#[test]
fn test_model_dnd_blocks() {
let model = ReceptivityModel::new();
let context = ContextSnapshot {
now: 50000.0,
activity: ActivityState::Idle,
notification_mode: NotificationMode::DoNotDisturb,
..Default::default()
};
let estimate = model.estimate(&context);
assert_eq!(estimate.score, 0.0, "DND should block all suggestions");
}
#[test]
fn test_prediction_bounds() {
let model = ReceptivityModel::new();
let low_features = [0.0; FEATURE_COUNT];
let high_features = [1.0; FEATURE_COUNT];
let p_low = model.predict(&low_features);
let p_high = model.predict(&high_features);
assert!(p_low >= 0.0 && p_low <= 1.0);
assert!(p_high >= 0.0 && p_high <= 1.0);
}
#[test]
fn test_learning_moves_prediction() {
let mut model = ReceptivityModel::new();
let receptive_features: FeatureVector = [0.5, 0.1, 0.8, 0.0, 0.2, 0.1, 0.5, 0.7, 0.1, 0.0];
let pred_before = model.predict(&receptive_features);
for _ in 0..20 {
model.learn(&receptive_features, SuggestionOutcome::Accepted);
}
let pred_after = model.predict(&receptive_features);
assert!(
pred_after > pred_before,
"Training on accepts should increase prediction: {} -> {}",
pred_before,
pred_after
);
}
#[test]
fn test_learning_dismissals_decrease() {
let mut model = ReceptivityModel::new();
let features: FeatureVector = [0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.0];
let pred_before = model.predict(&features);
for _ in 0..20 {
model.learn(&features, SuggestionOutcome::Dismissed);
}
let pred_after = model.predict(&features);
assert!(
pred_after < pred_before,
"Training on dismissals should decrease prediction: {} -> {}",
pred_before,
pred_after
);
}
#[test]
fn test_temporal_pattern_learning() {
let mut model = ReceptivityModel::new();
let nine_am = 86400.0 + 9.0 * 3600.0; let three_am = 86400.0 + 3.0 * 3600.0;
for _ in 0..20 {
model.learn_temporal_pattern(nine_am, SuggestionOutcome::Accepted);
model.learn_temporal_pattern(three_am, SuggestionOutcome::Dismissed);
}
let morning_receptivity = model.circadian_receptivity[9];
let night_receptivity = model.circadian_receptivity[3];
assert!(
morning_receptivity > night_receptivity,
"Morning should be more receptive than 3am: {} vs {}",
morning_receptivity,
night_receptivity
);
}
#[test]
fn test_quiet_hours_wrap_around() {
let config = QuietHoursConfig {
enabled: true,
start_hour: 22,
end_hour: 7,
weekday_mask: 0x7F,
allow_critical: true,
};
let eleven_pm = 86400.0 + 23.0 * 3600.0;
assert!(config.is_quiet(eleven_pm));
let three_am = 86400.0 + 3.0 * 3600.0;
assert!(config.is_quiet(three_am));
let noon = 86400.0 + 12.0 * 3600.0;
assert!(!config.is_quiet(noon));
}
#[test]
fn test_quiet_hours_disabled() {
let config = QuietHoursConfig {
enabled: false,
..Default::default()
};
assert!(!config.is_quiet(86400.0 + 23.0 * 3600.0));
}
#[test]
fn test_quiet_hours_weekday_mask() {
let config = QuietHoursConfig {
enabled: true,
start_hour: 22,
end_hour: 7,
weekday_mask: 0x1F, allow_critical: true,
};
let saturday_night = 1704499200.0 + 23.0 * 3600.0;
let dow = super::super::temporal::day_of_week_utc(saturday_night);
if dow >= 5 {
assert!(!config.is_quiet(saturday_night));
}
}
#[test]
fn test_budget_remaining() {
let config = AttentionBudgetConfig::default();
assert_eq!(config.remaining(0), 20);
assert_eq!(config.remaining(15), 5);
assert_eq!(config.remaining(25), 0); }
#[test]
fn test_budget_interval() {
let config = AttentionBudgetConfig::default();
assert!(!config.interval_ok(60.0)); assert!(config.interval_ok(120.0)); assert!(config.interval_ok(300.0)); }
#[test]
fn test_interruption_cost_urgency_discount() {
let base = estimate_interruption_cost(ActivityState::FocusedWork, 0.0, 0.0);
let urgent = estimate_interruption_cost(ActivityState::FocusedWork, 1.0, 0.0);
assert!(
urgent < base,
"Urgent actions should have lower cost: {} vs {}",
urgent,
base
);
}
#[test]
fn test_interruption_cost_importance_discount() {
let base = estimate_interruption_cost(ActivityState::Browsing, 0.0, 0.0);
let important = estimate_interruption_cost(ActivityState::Browsing, 0.0, 1.0);
assert!(
important < base,
"Important actions should have lower cost: {} vs {}",
important,
base
);
}
#[test]
fn test_interruption_cost_bounds() {
for activity in [
ActivityState::Idle,
ActivityState::DeepFocus,
ActivityState::Communicating,
] {
for urgency in [0.0, 0.5, 1.0] {
for importance in [0.0, 0.5, 1.0] {
let cost = estimate_interruption_cost(activity, urgency, importance);
assert!(
cost >= 0.0 && cost <= 1.0,
"Cost out of bounds: {} for {:?}, u={}, i={}",
cost,
activity,
urgency,
importance
);
}
}
}
}
#[test]
fn test_estimate_top_blocker_enabler() {
let model = ReceptivityModel::new();
let context = ContextSnapshot {
now: 50000.0,
activity: ActivityState::FocusedWork,
recent_interactions_15min: 10,
recent_outcomes: (5, 2, 1),
secs_since_last_interaction: 30.0,
session_duration_secs: 3600.0,
emotional_valence: 0.0,
session_suggestions_accepted: 5,
session_suggestion_budget: 20,
notification_mode: NotificationMode::All,
};
let estimate = model.estimate(&context);
assert!(estimate.top_blocker().is_some());
assert!(estimate.top_enabler().is_some());
assert!(
estimate.top_blocker().unwrap().contribution
<= estimate.top_enabler().unwrap().contribution
);
}
#[test]
fn test_is_receptive_threshold() {
let estimate = ReceptivityEstimate {
score: 0.6,
factors: vec![],
is_quiet_hours: false,
budget_remaining: 10,
};
assert!(estimate.is_receptive(0.5));
assert!(!estimate.is_receptive(0.7));
let quiet_estimate = ReceptivityEstimate {
score: 0.9,
factors: vec![],
is_quiet_hours: true,
budget_remaining: 10,
};
assert!(!quiet_estimate.is_receptive(0.5));
}
}