tuitbot-core 0.1.47

Core library for Tuitbot autonomous X growth assistant
Documentation
//! Deterministic rule engine that generates actionable recommendations
//! from weekly metrics and (optionally) the previous week's report.

use super::metrics::TopicPerformance;
use crate::storage::strategy::StrategyReportRow;

/// An actionable recommendation generated by the rule engine.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Recommendation {
    /// Category: promote, kill, experiment, alert, celebrate.
    pub category: String,
    /// Priority: high, medium, low.
    pub priority: String,
    /// Short headline.
    pub title: String,
    /// Longer explanation with context.
    pub description: String,
}

/// Intermediate metrics struct passed to the rule engine.
#[derive(Debug, Clone)]
pub struct WeekMetrics {
    pub replies_sent: i64,
    pub tweets_posted: i64,
    pub threads_posted: i64,
    pub target_replies: i64,
    pub follower_delta: i64,
    pub avg_reply_score: f64,
    pub avg_tweet_score: f64,
    pub reply_acceptance_rate: f64,
    pub top_topics: Vec<TopicPerformance>,
    pub bottom_topics: Vec<TopicPerformance>,
    pub distinct_topic_count: i64,
    /// Configured capacity (max per day * 7).
    pub max_replies_per_week: i64,
    pub max_tweets_per_week: i64,
}

/// Generate recommendations from the current week's metrics and an optional previous report.
pub fn generate(
    metrics: &WeekMetrics,
    previous: Option<&StrategyReportRow>,
) -> Vec<Recommendation> {
    let mut recs = Vec::new();

    // Compute overall average score across reply and tweet scores
    let overall_avg = overall_average(metrics);

    // Rule 1: Promote Winners — topic avg > 1.5x overall, posts >= 3
    for topic in &metrics.top_topics {
        if topic.post_count >= 3 && overall_avg > 0.0 && topic.avg_score > overall_avg * 1.5 {
            recs.push(Recommendation {
                category: "promote".to_string(),
                priority: "high".to_string(),
                title: format!("Double down on \"{}\"", topic.topic),
                description: format!(
                    "This topic averages {:.1}{:.0}% above your overall average. \
                     Consider posting more about it.",
                    topic.avg_score,
                    (topic.avg_score / overall_avg - 1.0) * 100.0
                ),
            });
        }
    }

    // Rule 2: Kill Losers — topic avg < 0.5x overall, posts >= 3
    for topic in &metrics.bottom_topics {
        if topic.post_count >= 3 && overall_avg > 0.0 && topic.avg_score < overall_avg * 0.5 {
            recs.push(Recommendation {
                category: "kill".to_string(),
                priority: "medium".to_string(),
                title: format!("Reconsider \"{}\"", topic.topic),
                description: format!(
                    "This topic averages {:.1}{:.0}% below your overall average across {} posts. \
                     Consider dropping or reworking it.",
                    topic.avg_score,
                    (1.0 - topic.avg_score / overall_avg) * 100.0,
                    topic.post_count
                ),
            });
        }
    }

    // Rule 3: Low Volume — output < 50% of configured capacity
    let total_output = metrics.replies_sent + metrics.tweets_posted + metrics.threads_posted;
    let total_capacity = metrics.max_replies_per_week + metrics.max_tweets_per_week;
    if total_capacity > 0 && total_output > 0 && total_output < total_capacity / 2 {
        recs.push(Recommendation {
            category: "alert".to_string(),
            priority: "low".to_string(),
            title: "Low output volume".to_string(),
            description: format!(
                "You posted {total_output} items this week — under 50% of your configured \
                 capacity ({total_capacity}). Check if the engine is running or if discovery \
                 needs tuning."
            ),
        });
    }

    // Rule 4: Follower Stall — delta <= 0 despite output > 0
    if metrics.follower_delta <= 0 && total_output > 0 {
        recs.push(Recommendation {
            category: "alert".to_string(),
            priority: "high".to_string(),
            title: "Follower growth stalled".to_string(),
            description: format!(
                "You posted {total_output} items but followers {} by {}. \
                 Review content quality and targeting.",
                if metrics.follower_delta < 0 {
                    "dropped"
                } else {
                    "stayed flat"
                },
                metrics.follower_delta.unsigned_abs()
            ),
        });
    }

    // Rule 5: Reply Quality Low — acceptance rate < 10%
    if metrics.reply_acceptance_rate < 0.10 && metrics.replies_sent > 0 {
        recs.push(Recommendation {
            category: "experiment".to_string(),
            priority: "medium".to_string(),
            title: "Low reply acceptance rate".to_string(),
            description: format!(
                "Only {:.0}% of your replies received a response. Try different reply \
                 archetypes or target higher-engagement conversations.",
                metrics.reply_acceptance_rate * 100.0
            ),
        });
    }

    // Rule 6: Reply Quality High — acceptance rate > 30%
    if metrics.reply_acceptance_rate > 0.30 && metrics.replies_sent > 0 {
        recs.push(Recommendation {
            category: "celebrate".to_string(),
            priority: "low".to_string(),
            title: "Strong reply engagement".to_string(),
            description: format!(
                "{:.0}% of your replies received responses — well above average. \
                 Keep doing what you're doing.",
                metrics.reply_acceptance_rate * 100.0
            ),
        });
    }

    // Rule 7: W-o-W Regression — avg score dropped > 20% vs previous week
    if let Some(prev) = previous {
        let prev_avg = (prev.avg_reply_score + prev.avg_tweet_score) / 2.0;
        if prev_avg > 0.0 {
            let pct_change = (overall_avg - prev_avg) / prev_avg;
            if pct_change < -0.20 {
                recs.push(Recommendation {
                    category: "alert".to_string(),
                    priority: "high".to_string(),
                    title: "Engagement score regression".to_string(),
                    description: format!(
                        "Average score dropped {:.0}% vs last week ({:.1}{:.1}). \
                         Review recent content for quality issues.",
                        pct_change.abs() * 100.0,
                        prev_avg,
                        overall_avg
                    ),
                });
            }
        }
    }

    // Rule 8: Low Topic Diversity — only 1-2 distinct topics
    if metrics.distinct_topic_count <= 2 && metrics.tweets_posted > 0 {
        recs.push(Recommendation {
            category: "experiment".to_string(),
            priority: "low".to_string(),
            title: "Low topic diversity".to_string(),
            description: format!(
                "You only posted about {} distinct topic(s) this week. \
                 Try branching into adjacent topics to reach a wider audience.",
                metrics.distinct_topic_count
            ),
        });
    }

    // Sort by priority (high first)
    recs.sort_by(|a, b| priority_rank(&a.priority).cmp(&priority_rank(&b.priority)));

    recs
}

fn overall_average(metrics: &WeekMetrics) -> f64 {
    let scores = [metrics.avg_reply_score, metrics.avg_tweet_score];
    let non_zero: Vec<f64> = scores.iter().copied().filter(|s| *s > 0.0).collect();
    if non_zero.is_empty() {
        return 0.0;
    }
    non_zero.iter().sum::<f64>() / non_zero.len() as f64
}

fn priority_rank(priority: &str) -> u8 {
    match priority {
        "high" => 0,
        "medium" => 1,
        "low" => 2,
        _ => 3,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn base_metrics() -> WeekMetrics {
        WeekMetrics {
            replies_sent: 40,
            tweets_posted: 15,
            threads_posted: 1,
            target_replies: 5,
            follower_delta: 50,
            avg_reply_score: 60.0,
            avg_tweet_score: 70.0,
            reply_acceptance_rate: 0.20,
            top_topics: vec![],
            bottom_topics: vec![],
            distinct_topic_count: 5,
            max_replies_per_week: 70,
            max_tweets_per_week: 21,
        }
    }

    #[test]
    fn no_recommendations_for_healthy_metrics() {
        let metrics = base_metrics();
        let recs = generate(&metrics, None);
        assert!(recs.is_empty(), "expected no recs, got: {recs:?}");
    }

    #[test]
    fn promote_winners() {
        let mut metrics = base_metrics();
        metrics.top_topics = vec![TopicPerformance {
            topic: "rust".to_string(),
            format: String::new(),
            avg_score: 120.0, // > 1.5 * 65 (overall avg)
            post_count: 5,
        }];
        let recs = generate(&metrics, None);
        assert!(recs.iter().any(|r| r.category == "promote"));
    }

    #[test]
    fn kill_losers() {
        let mut metrics = base_metrics();
        metrics.bottom_topics = vec![TopicPerformance {
            topic: "crypto".to_string(),
            format: String::new(),
            avg_score: 10.0, // < 0.5 * 65 (overall avg)
            post_count: 5,
        }];
        let recs = generate(&metrics, None);
        assert!(recs.iter().any(|r| r.category == "kill"));
    }

    #[test]
    fn follower_stall_alert() {
        let mut metrics = base_metrics();
        metrics.follower_delta = 0;
        let recs = generate(&metrics, None);
        assert!(recs
            .iter()
            .any(|r| r.category == "alert" && r.title.contains("stalled")));
    }

    #[test]
    fn low_reply_quality() {
        let mut metrics = base_metrics();
        metrics.reply_acceptance_rate = 0.05;
        let recs = generate(&metrics, None);
        assert!(recs.iter().any(|r| r.category == "experiment"));
    }

    #[test]
    fn high_reply_quality_celebrate() {
        let mut metrics = base_metrics();
        metrics.reply_acceptance_rate = 0.40;
        let recs = generate(&metrics, None);
        assert!(recs.iter().any(|r| r.category == "celebrate"));
    }

    #[test]
    fn wow_regression() {
        let metrics = base_metrics();
        let prev = StrategyReportRow {
            id: 1,
            week_start: "2026-02-17".to_string(),
            week_end: "2026-02-23".to_string(),
            replies_sent: 20,
            tweets_posted: 10,
            threads_posted: 1,
            target_replies: 5,
            follower_start: 950,
            follower_end: 1000,
            follower_delta: 50,
            avg_reply_score: 100.0,
            avg_tweet_score: 100.0, // prev avg = 100
            reply_acceptance_rate: 0.25,
            estimated_follow_conversion: 0.01,
            top_topics_json: "[]".to_string(),
            bottom_topics_json: "[]".to_string(),
            top_content_json: "[]".to_string(),
            recommendations_json: "[]".to_string(),
            created_at: String::new(),
        };
        // Current avg = 65, prev avg = 100, drop = 35% > 20%
        let recs = generate(&metrics, Some(&prev));
        assert!(recs
            .iter()
            .any(|r| r.category == "alert" && r.title.contains("regression")));
    }

    #[test]
    fn low_topic_diversity() {
        let mut metrics = base_metrics();
        metrics.distinct_topic_count = 1;
        let recs = generate(&metrics, None);
        assert!(recs
            .iter()
            .any(|r| r.category == "experiment" && r.title.contains("diversity")));
    }

    #[test]
    fn recommendations_sorted_by_priority() {
        let mut metrics = base_metrics();
        metrics.follower_delta = 0; // high priority alert
        metrics.reply_acceptance_rate = 0.05; // medium priority experiment
        metrics.distinct_topic_count = 1; // low priority experiment
        let recs = generate(&metrics, None);
        // First should be high priority
        assert_eq!(recs[0].priority, "high");
    }
}