1use rand::seq::IndexedRandom;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum ReplyArchetype {
18 AgreeAndExpand,
20 RespectfulDisagree,
22 AddData,
24 AskQuestion,
26 ShareExperience,
28}
29
30impl ReplyArchetype {
31 pub fn select(rng: &mut impl rand::Rng) -> Self {
33 let choices: &[(Self, u32)] = &[
36 (Self::AgreeAndExpand, 30),
37 (Self::AskQuestion, 25),
38 (Self::ShareExperience, 20),
39 (Self::AddData, 15),
40 (Self::RespectfulDisagree, 10),
41 ];
42
43 let total: u32 = choices.iter().map(|(_, w)| w).sum();
44 let mut roll = rng.random_range(0..total);
45 for (archetype, weight) in choices {
46 if roll < *weight {
47 return *archetype;
48 }
49 roll -= weight;
50 }
51 Self::AgreeAndExpand
52 }
53
54 pub fn prompt_fragment(self) -> &'static str {
56 match self {
57 Self::AgreeAndExpand => {
58 "Approach: Agree with the author's point and extend it with \
59 an additional insight or implication they didn't mention."
60 }
61 Self::RespectfulDisagree => {
62 "Approach: Respectfully offer an alternative take. Start with \
63 what you agree with, then pivot to where you see it differently. \
64 Keep it constructive — never confrontational."
65 }
66 Self::AddData => {
67 "Approach: Add a concrete data point, stat, example, or case study \
68 that supports or contextualizes the topic. Cite specifics when possible."
69 }
70 Self::AskQuestion => {
71 "Approach: Ask a thoughtful follow-up question that shows you've engaged \
72 deeply with the tweet. The question should invite the author to elaborate."
73 }
74 Self::ShareExperience => {
75 "Approach: Share a brief personal experience or observation related to the \
76 topic. Use 'I' language and keep it genuine and specific."
77 }
78 }
79 }
80}
81
82impl std::fmt::Display for ReplyArchetype {
83 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84 match self {
85 Self::AgreeAndExpand => write!(f, "agree_and_expand"),
86 Self::RespectfulDisagree => write!(f, "respectful_disagree"),
87 Self::AddData => write!(f, "add_data"),
88 Self::AskQuestion => write!(f, "ask_question"),
89 Self::ShareExperience => write!(f, "share_experience"),
90 }
91 }
92}
93
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100pub enum TweetFormat {
101 List,
103 ContrarianTake,
105 MostPeopleThinkX,
107 Storytelling,
109 BeforeAfter,
111 Question,
113 Tip,
115}
116
117impl TweetFormat {
118 const ALL: &'static [Self] = &[
120 Self::List,
121 Self::ContrarianTake,
122 Self::MostPeopleThinkX,
123 Self::Storytelling,
124 Self::BeforeAfter,
125 Self::Question,
126 Self::Tip,
127 ];
128
129 pub fn select(recent: &[Self], rng: &mut impl rand::Rng) -> Self {
131 let available: Vec<Self> = Self::ALL
132 .iter()
133 .copied()
134 .filter(|f| !recent.contains(f))
135 .collect();
136
137 if available.is_empty() {
138 *Self::ALL.choose(rng).expect("ALL is non-empty")
139 } else {
140 *available.choose(rng).expect("available is non-empty")
141 }
142 }
143
144 pub fn prompt_fragment(self) -> &'static str {
146 match self {
147 Self::List => {
148 "Format: Write a numbered list of 3-5 quick tips or insights. \
149 Keep each item to one line."
150 }
151 Self::ContrarianTake => {
152 "Format: Start with a common belief, then challenge it with an \
153 unexpected truth. Structure: 'Everyone says X. But actually, Y.'"
154 }
155 Self::MostPeopleThinkX => {
156 "Format: 'Most people think [common assumption]. The reality: [insight].'"
157 }
158 Self::Storytelling => {
159 "Format: Tell a very brief story or anecdote (2-3 sentences) that \
160 illustrates the topic. End with the lesson."
161 }
162 Self::BeforeAfter => {
163 "Format: Show a transformation. 'Before: [old way]. After: [new way]. \
164 [Brief insight on why the change matters].'"
165 }
166 Self::Question => {
167 "Format: Pose a thought-provoking question to the audience that invites \
168 engagement. Optionally share your own answer in 1-2 sentences."
169 }
170 Self::Tip => {
171 "Format: Share one specific, actionable tip. Be concrete — include the \
172 exact steps or command, not vague advice."
173 }
174 }
175 }
176}
177
178impl std::fmt::Display for TweetFormat {
179 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
180 match self {
181 Self::List => write!(f, "list"),
182 Self::ContrarianTake => write!(f, "contrarian_take"),
183 Self::MostPeopleThinkX => write!(f, "most_people_think_x"),
184 Self::Storytelling => write!(f, "storytelling"),
185 Self::BeforeAfter => write!(f, "before_after"),
186 Self::Question => write!(f, "question"),
187 Self::Tip => write!(f, "tip"),
188 }
189 }
190}
191
192#[derive(Debug, Clone, Copy, PartialEq, Eq)]
198pub enum ThreadStructure {
199 Transformation,
201 Framework,
203 Mistakes,
205 Analysis,
207}
208
209impl ThreadStructure {
210 const ALL: &'static [Self] = &[
212 Self::Transformation,
213 Self::Framework,
214 Self::Mistakes,
215 Self::Analysis,
216 ];
217
218 pub fn select(rng: &mut impl rand::Rng) -> Self {
220 *Self::ALL.choose(rng).expect("ALL is non-empty")
221 }
222
223 pub fn prompt_fragment(self) -> &'static str {
225 match self {
226 Self::Transformation => {
227 "Structure: Tell a transformation story. Start with the 'before' state, \
228 walk through the key turning points, and end with the 'after' state \
229 and lessons learned."
230 }
231 Self::Framework => {
232 "Structure: Present a step-by-step framework. Tweet 1 hooks with the \
233 problem, subsequent tweets present each step, and the last tweet \
234 summarizes the framework."
235 }
236 Self::Mistakes => {
237 "Structure: Share mistakes and lessons. Tweet 1 hooks with 'N mistakes \
238 I made doing X', each subsequent tweet is one mistake with what you \
239 learned, and the last tweet is the key takeaway."
240 }
241 Self::Analysis => {
242 "Structure: Deep-dive analysis. Tweet 1 states the thesis, subsequent \
243 tweets provide evidence or arguments, and the last tweet draws a conclusion."
244 }
245 }
246 }
247}
248
249impl std::fmt::Display for ThreadStructure {
250 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
251 match self {
252 Self::Transformation => write!(f, "transformation"),
253 Self::Framework => write!(f, "framework"),
254 Self::Mistakes => write!(f, "mistakes"),
255 Self::Analysis => write!(f, "analysis"),
256 }
257 }
258}
259
260#[cfg(test)]
261mod tests {
262 use super::*;
263
264 #[test]
265 fn reply_archetype_select_returns_valid() {
266 let mut rng = rand::rng();
267 for _ in 0..100 {
268 let _ = ReplyArchetype::select(&mut rng);
269 }
270 }
271
272 #[test]
273 fn reply_archetype_select_distribution() {
274 let mut rng = rand::rng();
275 let mut counts = [0u32; 5];
276 for _ in 0..1000 {
277 let archetype = ReplyArchetype::select(&mut rng);
278 match archetype {
279 ReplyArchetype::AgreeAndExpand => counts[0] += 1,
280 ReplyArchetype::RespectfulDisagree => counts[1] += 1,
281 ReplyArchetype::AddData => counts[2] += 1,
282 ReplyArchetype::AskQuestion => counts[3] += 1,
283 ReplyArchetype::ShareExperience => counts[4] += 1,
284 }
285 }
286 for (i, count) in counts.iter().enumerate() {
288 assert!(
289 *count > 0,
290 "archetype index {i} never selected in 1000 samples"
291 );
292 }
293 assert!(
295 counts[0] > counts[1],
296 "AgreeAndExpand should be more frequent"
297 );
298 }
299
300 #[test]
301 fn reply_archetype_prompt_fragments_non_empty() {
302 let archetypes = [
303 ReplyArchetype::AgreeAndExpand,
304 ReplyArchetype::RespectfulDisagree,
305 ReplyArchetype::AddData,
306 ReplyArchetype::AskQuestion,
307 ReplyArchetype::ShareExperience,
308 ];
309 for a in archetypes {
310 assert!(!a.prompt_fragment().is_empty());
311 }
312 }
313
314 #[test]
315 fn reply_archetype_display() {
316 assert_eq!(
317 ReplyArchetype::AgreeAndExpand.to_string(),
318 "agree_and_expand"
319 );
320 assert_eq!(ReplyArchetype::AskQuestion.to_string(), "ask_question");
321 }
322
323 #[test]
324 fn tweet_format_select_avoids_recent() {
325 let mut rng = rand::rng();
326 let recent = vec![TweetFormat::List, TweetFormat::Tip, TweetFormat::Question];
327
328 for _ in 0..50 {
329 let format = TweetFormat::select(&recent, &mut rng);
330 assert!(!recent.contains(&format));
331 }
332 }
333
334 #[test]
335 fn tweet_format_select_clears_when_all_recent() {
336 let mut rng = rand::rng();
337 let recent: Vec<TweetFormat> = TweetFormat::ALL.to_vec();
338 let format = TweetFormat::select(&recent, &mut rng);
340 assert!(TweetFormat::ALL.contains(&format));
341 }
342
343 #[test]
344 fn tweet_format_prompt_fragments_non_empty() {
345 for f in TweetFormat::ALL {
346 assert!(!f.prompt_fragment().is_empty());
347 }
348 }
349
350 #[test]
351 fn tweet_format_display() {
352 assert_eq!(TweetFormat::List.to_string(), "list");
353 assert_eq!(TweetFormat::ContrarianTake.to_string(), "contrarian_take");
354 assert_eq!(TweetFormat::BeforeAfter.to_string(), "before_after");
355 }
356
357 #[test]
358 fn thread_structure_select_returns_valid() {
359 let mut rng = rand::rng();
360 for _ in 0..50 {
361 let structure = ThreadStructure::select(&mut rng);
362 assert!(ThreadStructure::ALL.contains(&structure));
363 }
364 }
365
366 #[test]
367 fn thread_structure_prompt_fragments_non_empty() {
368 for s in ThreadStructure::ALL {
369 assert!(!s.prompt_fragment().is_empty());
370 }
371 }
372
373 #[test]
374 fn thread_structure_display() {
375 assert_eq!(
376 ThreadStructure::Transformation.to_string(),
377 "transformation"
378 );
379 assert_eq!(ThreadStructure::Framework.to_string(), "framework");
380 assert_eq!(ThreadStructure::Mistakes.to_string(), "mistakes");
381 assert_eq!(ThreadStructure::Analysis.to_string(), "analysis");
382 }
383
384 #[test]
385 fn reply_archetype_all_variants_reachable() {
386 use std::collections::HashSet;
387 let mut rng = rand::rng();
388 let mut seen = HashSet::new();
389 for _ in 0..10_000 {
390 seen.insert(ReplyArchetype::select(&mut rng).to_string());
391 }
392 assert_eq!(
393 seen.len(),
394 5,
395 "expected all 5 reply archetypes, got {seen:?}"
396 );
397 }
398
399 #[test]
400 fn tweet_format_all_variants_reachable() {
401 use std::collections::HashSet;
402 let mut rng = rand::rng();
403 let mut seen = HashSet::new();
404 let recent: Vec<TweetFormat> = vec![];
405 for _ in 0..10_000 {
406 seen.insert(TweetFormat::select(&recent, &mut rng).to_string());
407 }
408 assert_eq!(seen.len(), 7, "expected all 7 tweet formats, got {seen:?}");
409 }
410
411 #[test]
412 fn thread_structure_all_variants_reachable() {
413 use std::collections::HashSet;
414 let mut rng = rand::rng();
415 let mut seen = HashSet::new();
416 for _ in 0..10_000 {
417 seen.insert(ThreadStructure::select(&mut rng).to_string());
418 }
419 assert_eq!(
420 seen.len(),
421 4,
422 "expected all 4 thread structures, got {seen:?}"
423 );
424 }
425
426 #[test]
427 fn tweet_format_display_all_variants() {
428 assert_eq!(TweetFormat::List.to_string(), "list");
429 assert_eq!(TweetFormat::ContrarianTake.to_string(), "contrarian_take");
430 assert_eq!(
431 TweetFormat::MostPeopleThinkX.to_string(),
432 "most_people_think_x"
433 );
434 assert_eq!(TweetFormat::Storytelling.to_string(), "storytelling");
435 assert_eq!(TweetFormat::BeforeAfter.to_string(), "before_after");
436 assert_eq!(TweetFormat::Question.to_string(), "question");
437 assert_eq!(TweetFormat::Tip.to_string(), "tip");
438 }
439
440 #[test]
441 fn reply_archetype_display_all_variants() {
442 assert_eq!(
443 ReplyArchetype::AgreeAndExpand.to_string(),
444 "agree_and_expand"
445 );
446 assert_eq!(
447 ReplyArchetype::RespectfulDisagree.to_string(),
448 "respectful_disagree"
449 );
450 assert_eq!(ReplyArchetype::AddData.to_string(), "add_data");
451 assert_eq!(ReplyArchetype::AskQuestion.to_string(), "ask_question");
452 assert_eq!(
453 ReplyArchetype::ShareExperience.to_string(),
454 "share_experience"
455 );
456 }
457
458 #[test]
459 fn tweet_format_select_single_available() {
460 let mut rng = rand::rng();
461 let recent = vec![
463 TweetFormat::List,
464 TweetFormat::ContrarianTake,
465 TweetFormat::MostPeopleThinkX,
466 TweetFormat::BeforeAfter,
467 TweetFormat::Question,
468 TweetFormat::Tip,
469 ];
470 for _ in 0..50 {
471 let picked = TweetFormat::select(&recent, &mut rng);
472 assert_eq!(
473 picked,
474 TweetFormat::Storytelling,
475 "only Storytelling should be available"
476 );
477 }
478 }
479
480 #[test]
485 fn reply_archetype_prompt_fragment_content() {
486 let frag = ReplyArchetype::AgreeAndExpand.prompt_fragment();
488 assert!(frag.contains("Agree"));
489 let frag = ReplyArchetype::RespectfulDisagree.prompt_fragment();
490 assert!(frag.contains("alternative"));
491 let frag = ReplyArchetype::AddData.prompt_fragment();
492 assert!(frag.contains("data"));
493 let frag = ReplyArchetype::AskQuestion.prompt_fragment();
494 assert!(frag.contains("question"));
495 let frag = ReplyArchetype::ShareExperience.prompt_fragment();
496 assert!(frag.contains("experience"));
497 }
498
499 #[test]
500 fn tweet_format_prompt_fragment_content() {
501 let frag = TweetFormat::List.prompt_fragment();
502 assert!(frag.contains("list"));
503 let frag = TweetFormat::ContrarianTake.prompt_fragment();
504 assert!(frag.contains("challenge"));
505 let frag = TweetFormat::Storytelling.prompt_fragment();
506 assert!(frag.contains("story"));
507 let frag = TweetFormat::BeforeAfter.prompt_fragment();
508 assert!(frag.contains("Before"));
509 let frag = TweetFormat::Question.prompt_fragment();
510 assert!(frag.contains("question"));
511 let frag = TweetFormat::Tip.prompt_fragment();
512 assert!(frag.contains("tip"));
513 }
514
515 #[test]
516 fn thread_structure_prompt_fragment_content() {
517 let frag = ThreadStructure::Transformation.prompt_fragment();
518 assert!(frag.contains("transformation"));
519 let frag = ThreadStructure::Framework.prompt_fragment();
520 assert!(frag.contains("framework"));
521 let frag = ThreadStructure::Mistakes.prompt_fragment();
522 assert!(frag.contains("mistakes"));
523 let frag = ThreadStructure::Analysis.prompt_fragment();
524 assert!(frag.contains("analysis"));
525 }
526
527 #[test]
528 fn tweet_format_all_count() {
529 assert_eq!(TweetFormat::ALL.len(), 7);
530 }
531
532 #[test]
533 fn thread_structure_all_count() {
534 assert_eq!(ThreadStructure::ALL.len(), 4);
535 }
536
537 #[test]
538 fn reply_archetype_equality() {
539 assert_eq!(ReplyArchetype::AddData, ReplyArchetype::AddData);
540 assert_ne!(ReplyArchetype::AddData, ReplyArchetype::AskQuestion);
541 }
542
543 #[test]
544 fn tweet_format_equality() {
545 assert_eq!(TweetFormat::Tip, TweetFormat::Tip);
546 assert_ne!(TweetFormat::Tip, TweetFormat::List);
547 }
548
549 #[test]
550 fn thread_structure_equality() {
551 assert_eq!(ThreadStructure::Analysis, ThreadStructure::Analysis);
552 assert_ne!(ThreadStructure::Analysis, ThreadStructure::Framework);
553 }
554
555 #[test]
556 fn tweet_format_empty_recent() {
557 let mut rng = rand::rng();
558 let format = TweetFormat::select(&[], &mut rng);
559 assert!(TweetFormat::ALL.contains(&format));
560 }
561
562 #[test]
563 fn thread_structure_debug() {
564 let debug = format!("{:?}", ThreadStructure::Transformation);
565 assert!(debug.contains("Transformation"));
566 }
567
568 #[test]
569 fn tweet_format_debug() {
570 let debug = format!("{:?}", TweetFormat::List);
571 assert!(debug.contains("List"));
572 }
573
574 #[test]
575 fn reply_archetype_debug() {
576 let debug = format!("{:?}", ReplyArchetype::AddData);
577 assert!(debug.contains("AddData"));
578 }
579
580 #[test]
581 fn tweet_format_most_people_think_x_prompt() {
582 let frag = TweetFormat::MostPeopleThinkX.prompt_fragment();
583 assert!(frag.contains("Most people"));
584 }
585}