Skip to main content

tuitbot_core/strategy/
recommendations.rs

1//! Deterministic rule engine that generates actionable recommendations
2//! from weekly metrics and (optionally) the previous week's report.
3
4use super::metrics::TopicPerformance;
5use crate::storage::strategy::StrategyReportRow;
6
7/// An actionable recommendation generated by the rule engine.
8#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
9pub struct Recommendation {
10    /// Category: promote, kill, experiment, alert, celebrate.
11    pub category: String,
12    /// Priority: high, medium, low.
13    pub priority: String,
14    /// Short headline.
15    pub title: String,
16    /// Longer explanation with context.
17    pub description: String,
18}
19
20/// Intermediate metrics struct passed to the rule engine.
21#[derive(Debug, Clone)]
22pub struct WeekMetrics {
23    pub replies_sent: i64,
24    pub tweets_posted: i64,
25    pub threads_posted: i64,
26    pub target_replies: i64,
27    pub follower_delta: i64,
28    pub avg_reply_score: f64,
29    pub avg_tweet_score: f64,
30    pub reply_acceptance_rate: f64,
31    pub top_topics: Vec<TopicPerformance>,
32    pub bottom_topics: Vec<TopicPerformance>,
33    pub distinct_topic_count: i64,
34    /// Configured capacity (max per day * 7).
35    pub max_replies_per_week: i64,
36    pub max_tweets_per_week: i64,
37}
38
39/// Generate recommendations from the current week's metrics and an optional previous report.
40pub fn generate(
41    metrics: &WeekMetrics,
42    previous: Option<&StrategyReportRow>,
43) -> Vec<Recommendation> {
44    let mut recs = Vec::new();
45
46    // Compute overall average score across reply and tweet scores
47    let overall_avg = overall_average(metrics);
48
49    // Rule 1: Promote Winners — topic avg > 1.5x overall, posts >= 3
50    for topic in &metrics.top_topics {
51        if topic.post_count >= 3 && overall_avg > 0.0 && topic.avg_score > overall_avg * 1.5 {
52            recs.push(Recommendation {
53                category: "promote".to_string(),
54                priority: "high".to_string(),
55                title: format!("Double down on \"{}\"", topic.topic),
56                description: format!(
57                    "This topic averages {:.1} — {:.0}% above your overall average. \
58                     Consider posting more about it.",
59                    topic.avg_score,
60                    (topic.avg_score / overall_avg - 1.0) * 100.0
61                ),
62            });
63        }
64    }
65
66    // Rule 2: Kill Losers — topic avg < 0.5x overall, posts >= 3
67    for topic in &metrics.bottom_topics {
68        if topic.post_count >= 3 && overall_avg > 0.0 && topic.avg_score < overall_avg * 0.5 {
69            recs.push(Recommendation {
70                category: "kill".to_string(),
71                priority: "medium".to_string(),
72                title: format!("Reconsider \"{}\"", topic.topic),
73                description: format!(
74                    "This topic averages {:.1} — {:.0}% below your overall average across {} posts. \
75                     Consider dropping or reworking it.",
76                    topic.avg_score,
77                    (1.0 - topic.avg_score / overall_avg) * 100.0,
78                    topic.post_count
79                ),
80            });
81        }
82    }
83
84    // Rule 3: Low Volume — output < 50% of configured capacity
85    let total_output = metrics.replies_sent + metrics.tweets_posted + metrics.threads_posted;
86    let total_capacity = metrics.max_replies_per_week + metrics.max_tweets_per_week;
87    if total_capacity > 0 && total_output > 0 && total_output < total_capacity / 2 {
88        recs.push(Recommendation {
89            category: "alert".to_string(),
90            priority: "low".to_string(),
91            title: "Low output volume".to_string(),
92            description: format!(
93                "You posted {total_output} items this week — under 50% of your configured \
94                 capacity ({total_capacity}). Check if the engine is running or if discovery \
95                 needs tuning."
96            ),
97        });
98    }
99
100    // Rule 4: Follower Stall — delta <= 0 despite output > 0
101    if metrics.follower_delta <= 0 && total_output > 0 {
102        recs.push(Recommendation {
103            category: "alert".to_string(),
104            priority: "high".to_string(),
105            title: "Follower growth stalled".to_string(),
106            description: format!(
107                "You posted {total_output} items but followers {} by {}. \
108                 Review content quality and targeting.",
109                if metrics.follower_delta < 0 {
110                    "dropped"
111                } else {
112                    "stayed flat"
113                },
114                metrics.follower_delta.unsigned_abs()
115            ),
116        });
117    }
118
119    // Rule 5: Reply Quality Low — acceptance rate < 10%
120    if metrics.reply_acceptance_rate < 0.10 && metrics.replies_sent > 0 {
121        recs.push(Recommendation {
122            category: "experiment".to_string(),
123            priority: "medium".to_string(),
124            title: "Low reply acceptance rate".to_string(),
125            description: format!(
126                "Only {:.0}% of your replies received a response. Try different reply \
127                 archetypes or target higher-engagement conversations.",
128                metrics.reply_acceptance_rate * 100.0
129            ),
130        });
131    }
132
133    // Rule 6: Reply Quality High — acceptance rate > 30%
134    if metrics.reply_acceptance_rate > 0.30 && metrics.replies_sent > 0 {
135        recs.push(Recommendation {
136            category: "celebrate".to_string(),
137            priority: "low".to_string(),
138            title: "Strong reply engagement".to_string(),
139            description: format!(
140                "{:.0}% of your replies received responses — well above average. \
141                 Keep doing what you're doing.",
142                metrics.reply_acceptance_rate * 100.0
143            ),
144        });
145    }
146
147    // Rule 7: W-o-W Regression — avg score dropped > 20% vs previous week
148    if let Some(prev) = previous {
149        let prev_avg = (prev.avg_reply_score + prev.avg_tweet_score) / 2.0;
150        if prev_avg > 0.0 {
151            let pct_change = (overall_avg - prev_avg) / prev_avg;
152            if pct_change < -0.20 {
153                recs.push(Recommendation {
154                    category: "alert".to_string(),
155                    priority: "high".to_string(),
156                    title: "Engagement score regression".to_string(),
157                    description: format!(
158                        "Average score dropped {:.0}% vs last week ({:.1} → {:.1}). \
159                         Review recent content for quality issues.",
160                        pct_change.abs() * 100.0,
161                        prev_avg,
162                        overall_avg
163                    ),
164                });
165            }
166        }
167    }
168
169    // Rule 8: Low Topic Diversity — only 1-2 distinct topics
170    if metrics.distinct_topic_count <= 2 && metrics.tweets_posted > 0 {
171        recs.push(Recommendation {
172            category: "experiment".to_string(),
173            priority: "low".to_string(),
174            title: "Low topic diversity".to_string(),
175            description: format!(
176                "You only posted about {} distinct topic(s) this week. \
177                 Try branching into adjacent topics to reach a wider audience.",
178                metrics.distinct_topic_count
179            ),
180        });
181    }
182
183    // Sort by priority (high first)
184    recs.sort_by(|a, b| priority_rank(&a.priority).cmp(&priority_rank(&b.priority)));
185
186    recs
187}
188
189fn overall_average(metrics: &WeekMetrics) -> f64 {
190    let scores = [metrics.avg_reply_score, metrics.avg_tweet_score];
191    let non_zero: Vec<f64> = scores.iter().copied().filter(|s| *s > 0.0).collect();
192    if non_zero.is_empty() {
193        return 0.0;
194    }
195    non_zero.iter().sum::<f64>() / non_zero.len() as f64
196}
197
198fn priority_rank(priority: &str) -> u8 {
199    match priority {
200        "high" => 0,
201        "medium" => 1,
202        "low" => 2,
203        _ => 3,
204    }
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    fn base_metrics() -> WeekMetrics {
212        WeekMetrics {
213            replies_sent: 40,
214            tweets_posted: 15,
215            threads_posted: 1,
216            target_replies: 5,
217            follower_delta: 50,
218            avg_reply_score: 60.0,
219            avg_tweet_score: 70.0,
220            reply_acceptance_rate: 0.20,
221            top_topics: vec![],
222            bottom_topics: vec![],
223            distinct_topic_count: 5,
224            max_replies_per_week: 70,
225            max_tweets_per_week: 21,
226        }
227    }
228
229    #[test]
230    fn no_recommendations_for_healthy_metrics() {
231        let metrics = base_metrics();
232        let recs = generate(&metrics, None);
233        assert!(recs.is_empty(), "expected no recs, got: {recs:?}");
234    }
235
236    #[test]
237    fn promote_winners() {
238        let mut metrics = base_metrics();
239        metrics.top_topics = vec![TopicPerformance {
240            topic: "rust".to_string(),
241            format: String::new(),
242            avg_score: 120.0, // > 1.5 * 65 (overall avg)
243            post_count: 5,
244        }];
245        let recs = generate(&metrics, None);
246        assert!(recs.iter().any(|r| r.category == "promote"));
247    }
248
249    #[test]
250    fn kill_losers() {
251        let mut metrics = base_metrics();
252        metrics.bottom_topics = vec![TopicPerformance {
253            topic: "crypto".to_string(),
254            format: String::new(),
255            avg_score: 10.0, // < 0.5 * 65 (overall avg)
256            post_count: 5,
257        }];
258        let recs = generate(&metrics, None);
259        assert!(recs.iter().any(|r| r.category == "kill"));
260    }
261
262    #[test]
263    fn follower_stall_alert() {
264        let mut metrics = base_metrics();
265        metrics.follower_delta = 0;
266        let recs = generate(&metrics, None);
267        assert!(recs
268            .iter()
269            .any(|r| r.category == "alert" && r.title.contains("stalled")));
270    }
271
272    #[test]
273    fn low_reply_quality() {
274        let mut metrics = base_metrics();
275        metrics.reply_acceptance_rate = 0.05;
276        let recs = generate(&metrics, None);
277        assert!(recs.iter().any(|r| r.category == "experiment"));
278    }
279
280    #[test]
281    fn high_reply_quality_celebrate() {
282        let mut metrics = base_metrics();
283        metrics.reply_acceptance_rate = 0.40;
284        let recs = generate(&metrics, None);
285        assert!(recs.iter().any(|r| r.category == "celebrate"));
286    }
287
288    #[test]
289    fn wow_regression() {
290        let metrics = base_metrics();
291        let prev = StrategyReportRow {
292            id: 1,
293            week_start: "2026-02-17".to_string(),
294            week_end: "2026-02-23".to_string(),
295            replies_sent: 20,
296            tweets_posted: 10,
297            threads_posted: 1,
298            target_replies: 5,
299            follower_start: 950,
300            follower_end: 1000,
301            follower_delta: 50,
302            avg_reply_score: 100.0,
303            avg_tweet_score: 100.0, // prev avg = 100
304            reply_acceptance_rate: 0.25,
305            estimated_follow_conversion: 0.01,
306            top_topics_json: "[]".to_string(),
307            bottom_topics_json: "[]".to_string(),
308            top_content_json: "[]".to_string(),
309            recommendations_json: "[]".to_string(),
310            created_at: String::new(),
311        };
312        // Current avg = 65, prev avg = 100, drop = 35% > 20%
313        let recs = generate(&metrics, Some(&prev));
314        assert!(recs
315            .iter()
316            .any(|r| r.category == "alert" && r.title.contains("regression")));
317    }
318
319    #[test]
320    fn low_topic_diversity() {
321        let mut metrics = base_metrics();
322        metrics.distinct_topic_count = 1;
323        let recs = generate(&metrics, None);
324        assert!(recs
325            .iter()
326            .any(|r| r.category == "experiment" && r.title.contains("diversity")));
327    }
328
329    #[test]
330    fn recommendations_sorted_by_priority() {
331        let mut metrics = base_metrics();
332        metrics.follower_delta = 0; // high priority alert
333        metrics.reply_acceptance_rate = 0.05; // medium priority experiment
334        metrics.distinct_topic_count = 1; // low priority experiment
335        let recs = generate(&metrics, None);
336        // First should be high priority
337        assert_eq!(recs[0].priority, "high");
338    }
339}