Skip to main content

slop_guard/
lib.rs

1use std::collections::HashMap;
2
3use once_cell::sync::Lazy;
4use regex::Regex;
5use serde::{Deserialize, Serialize};
6
7// ---------------------------------------------------------------------------
8// Data structures
9// ---------------------------------------------------------------------------
10
11#[derive(Debug, Clone, Serialize)]
12pub struct Violation {
13    #[serde(rename = "type")]
14    pub violation_type: String,
15    pub rule: String,
16    #[serde(rename = "match")]
17    pub match_text: String,
18    pub context: String,
19    pub penalty: i32,
20}
21
22#[derive(Debug, Clone, Serialize)]
23pub struct AnalysisResult {
24    pub score: i32,
25    pub band: String,
26    pub word_count: usize,
27    pub violations: Vec<Violation>,
28    pub counts: HashMap<String, usize>,
29    pub total_penalty: i32,
30    pub weighted_sum: f64,
31    pub density: f64,
32    pub advice: Vec<String>,
33}
34
35// ---------------------------------------------------------------------------
36// Hyperparameters
37// ---------------------------------------------------------------------------
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct Hyperparameters {
41    pub concentration_alpha: f64,
42    pub decay_lambda: f64,
43    pub claude_categories: Vec<String>,
44    pub context_window_chars: usize,
45    pub short_text_word_count: usize,
46    pub repeated_ngram_min_n: usize,
47    pub repeated_ngram_max_n: usize,
48    pub repeated_ngram_min_count: usize,
49    pub slop_word_penalty: i32,
50    pub slop_phrase_penalty: i32,
51    pub structural_bold_header_min: usize,
52    pub structural_bold_header_penalty: i32,
53    pub structural_bullet_run_min: usize,
54    pub structural_bullet_run_penalty: i32,
55    pub triadic_record_cap: usize,
56    pub triadic_penalty: i32,
57    pub triadic_advice_min: usize,
58    pub tone_penalty: i32,
59    pub sentence_opener_penalty: i32,
60    pub weasel_penalty: i32,
61    pub ai_disclosure_penalty: i32,
62    pub placeholder_penalty: i32,
63    pub rhythm_min_sentences: usize,
64    pub rhythm_cv_threshold: f64,
65    pub rhythm_penalty: i32,
66    pub em_dash_words_basis: f64,
67    pub em_dash_density_threshold: f64,
68    pub em_dash_penalty: i32,
69    pub contrast_record_cap: usize,
70    pub contrast_penalty: i32,
71    pub contrast_advice_min: usize,
72    pub setup_resolution_record_cap: usize,
73    pub setup_resolution_penalty: i32,
74    pub colon_words_basis: f64,
75    pub colon_density_threshold: f64,
76    pub colon_density_penalty: i32,
77    pub pithy_max_sentence_words: usize,
78    pub pithy_record_cap: usize,
79    pub pithy_penalty: i32,
80    pub bullet_density_threshold: f64,
81    pub bullet_density_penalty: i32,
82    pub blockquote_min_lines: usize,
83    pub blockquote_free_lines: usize,
84    pub blockquote_cap: usize,
85    pub blockquote_penalty_step: i32,
86    pub bold_bullet_run_min: usize,
87    pub bold_bullet_run_penalty: i32,
88    pub horizontal_rule_min: usize,
89    pub horizontal_rule_penalty: i32,
90    pub phrase_reuse_record_cap: usize,
91    pub phrase_reuse_penalty: i32,
92    pub density_words_basis: f64,
93    pub score_min: i32,
94    pub score_max: i32,
95    pub band_clean_min: i32,
96    pub band_light_min: i32,
97    pub band_moderate_min: i32,
98    pub band_heavy_min: i32,
99}
100
101impl Default for Hyperparameters {
102    fn default() -> Self {
103        Self {
104            concentration_alpha: 2.5,
105            decay_lambda: 0.04,
106            claude_categories: vec![
107                "contrast_pairs".to_string(),
108                "pithy_fragment".to_string(),
109                "setup_resolution".to_string(),
110            ],
111            context_window_chars: 60,
112            short_text_word_count: 10,
113            repeated_ngram_min_n: 4,
114            repeated_ngram_max_n: 8,
115            repeated_ngram_min_count: 3,
116            slop_word_penalty: -2,
117            slop_phrase_penalty: -3,
118            structural_bold_header_min: 3,
119            structural_bold_header_penalty: -5,
120            structural_bullet_run_min: 6,
121            structural_bullet_run_penalty: -3,
122            triadic_record_cap: 5,
123            triadic_penalty: -1,
124            triadic_advice_min: 3,
125            tone_penalty: -3,
126            sentence_opener_penalty: -2,
127            weasel_penalty: -2,
128            ai_disclosure_penalty: -10,
129            placeholder_penalty: -5,
130            rhythm_min_sentences: 5,
131            rhythm_cv_threshold: 0.3,
132            rhythm_penalty: -5,
133            em_dash_words_basis: 150.0,
134            em_dash_density_threshold: 1.0,
135            em_dash_penalty: -3,
136            contrast_record_cap: 5,
137            contrast_penalty: -1,
138            contrast_advice_min: 2,
139            setup_resolution_record_cap: 5,
140            setup_resolution_penalty: -3,
141            colon_words_basis: 150.0,
142            colon_density_threshold: 1.5,
143            colon_density_penalty: -3,
144            pithy_max_sentence_words: 6,
145            pithy_record_cap: 3,
146            pithy_penalty: -2,
147            bullet_density_threshold: 0.40,
148            bullet_density_penalty: -8,
149            blockquote_min_lines: 3,
150            blockquote_free_lines: 2,
151            blockquote_cap: 4,
152            blockquote_penalty_step: -3,
153            bold_bullet_run_min: 3,
154            bold_bullet_run_penalty: -5,
155            horizontal_rule_min: 4,
156            horizontal_rule_penalty: -3,
157            phrase_reuse_record_cap: 5,
158            phrase_reuse_penalty: -1,
159            density_words_basis: 1000.0,
160            score_min: 0,
161            score_max: 100,
162            band_clean_min: 80,
163            band_light_min: 60,
164            band_moderate_min: 40,
165            band_heavy_min: 20,
166        }
167    }
168}
169
170/// Partial override — all fields optional. Only specified fields replace defaults.
171#[derive(Debug, Default, Deserialize)]
172pub struct HyperparametersOverride {
173    pub concentration_alpha: Option<f64>,
174    pub decay_lambda: Option<f64>,
175    pub claude_categories: Option<Vec<String>>,
176    pub context_window_chars: Option<usize>,
177    pub short_text_word_count: Option<usize>,
178    pub repeated_ngram_min_n: Option<usize>,
179    pub repeated_ngram_max_n: Option<usize>,
180    pub repeated_ngram_min_count: Option<usize>,
181    pub slop_word_penalty: Option<i32>,
182    pub slop_phrase_penalty: Option<i32>,
183    pub structural_bold_header_min: Option<usize>,
184    pub structural_bold_header_penalty: Option<i32>,
185    pub structural_bullet_run_min: Option<usize>,
186    pub structural_bullet_run_penalty: Option<i32>,
187    pub triadic_record_cap: Option<usize>,
188    pub triadic_penalty: Option<i32>,
189    pub triadic_advice_min: Option<usize>,
190    pub tone_penalty: Option<i32>,
191    pub sentence_opener_penalty: Option<i32>,
192    pub weasel_penalty: Option<i32>,
193    pub ai_disclosure_penalty: Option<i32>,
194    pub placeholder_penalty: Option<i32>,
195    pub rhythm_min_sentences: Option<usize>,
196    pub rhythm_cv_threshold: Option<f64>,
197    pub rhythm_penalty: Option<i32>,
198    pub em_dash_words_basis: Option<f64>,
199    pub em_dash_density_threshold: Option<f64>,
200    pub em_dash_penalty: Option<i32>,
201    pub contrast_record_cap: Option<usize>,
202    pub contrast_penalty: Option<i32>,
203    pub contrast_advice_min: Option<usize>,
204    pub setup_resolution_record_cap: Option<usize>,
205    pub setup_resolution_penalty: Option<i32>,
206    pub colon_words_basis: Option<f64>,
207    pub colon_density_threshold: Option<f64>,
208    pub colon_density_penalty: Option<i32>,
209    pub pithy_max_sentence_words: Option<usize>,
210    pub pithy_record_cap: Option<usize>,
211    pub pithy_penalty: Option<i32>,
212    pub bullet_density_threshold: Option<f64>,
213    pub bullet_density_penalty: Option<i32>,
214    pub blockquote_min_lines: Option<usize>,
215    pub blockquote_free_lines: Option<usize>,
216    pub blockquote_cap: Option<usize>,
217    pub blockquote_penalty_step: Option<i32>,
218    pub bold_bullet_run_min: Option<usize>,
219    pub bold_bullet_run_penalty: Option<i32>,
220    pub horizontal_rule_min: Option<usize>,
221    pub horizontal_rule_penalty: Option<i32>,
222    pub phrase_reuse_record_cap: Option<usize>,
223    pub phrase_reuse_penalty: Option<i32>,
224    pub density_words_basis: Option<f64>,
225    pub score_min: Option<i32>,
226    pub score_max: Option<i32>,
227    pub band_clean_min: Option<i32>,
228    pub band_light_min: Option<i32>,
229    pub band_moderate_min: Option<i32>,
230    pub band_heavy_min: Option<i32>,
231}
232
233impl Hyperparameters {
234    pub fn with_overrides(mut self, ov: &HyperparametersOverride) -> Self {
235        macro_rules! apply {
236            ($($field:ident),* $(,)?) => {
237                $(if let Some(ref v) = ov.$field { self.$field = v.clone(); })*
238            };
239        }
240        apply!(
241            concentration_alpha,
242            decay_lambda,
243            claude_categories,
244            context_window_chars,
245            short_text_word_count,
246            repeated_ngram_min_n,
247            repeated_ngram_max_n,
248            repeated_ngram_min_count,
249            slop_word_penalty,
250            slop_phrase_penalty,
251            structural_bold_header_min,
252            structural_bold_header_penalty,
253            structural_bullet_run_min,
254            structural_bullet_run_penalty,
255            triadic_record_cap,
256            triadic_penalty,
257            triadic_advice_min,
258            tone_penalty,
259            sentence_opener_penalty,
260            weasel_penalty,
261            ai_disclosure_penalty,
262            placeholder_penalty,
263            rhythm_min_sentences,
264            rhythm_cv_threshold,
265            rhythm_penalty,
266            em_dash_words_basis,
267            em_dash_density_threshold,
268            em_dash_penalty,
269            contrast_record_cap,
270            contrast_penalty,
271            contrast_advice_min,
272            setup_resolution_record_cap,
273            setup_resolution_penalty,
274            colon_words_basis,
275            colon_density_threshold,
276            colon_density_penalty,
277            pithy_max_sentence_words,
278            pithy_record_cap,
279            pithy_penalty,
280            bullet_density_threshold,
281            bullet_density_penalty,
282            blockquote_min_lines,
283            blockquote_free_lines,
284            blockquote_cap,
285            blockquote_penalty_step,
286            bold_bullet_run_min,
287            bold_bullet_run_penalty,
288            horizontal_rule_min,
289            horizontal_rule_penalty,
290            phrase_reuse_record_cap,
291            phrase_reuse_penalty,
292            density_words_basis,
293            score_min,
294            score_max,
295            band_clean_min,
296            band_light_min,
297            band_moderate_min,
298            band_heavy_min,
299        );
300        self
301    }
302}
303
304// ---------------------------------------------------------------------------
305// Rule names constant
306// ---------------------------------------------------------------------------
307
308pub const RULE_NAMES: &[&str] = &[
309    "slop_words",
310    "slop_phrases",
311    "structural",
312    "tone",
313    "weasel",
314    "ai_disclosure",
315    "placeholder",
316    "rhythm",
317    "em_dash",
318    "contrast_pairs",
319    "colon_density",
320    "pithy_fragment",
321    "setup_resolution",
322    "bullet_density",
323    "blockquote_density",
324    "bold_bullet_list",
325    "horizontal_rules",
326    "phrase_reuse",
327];
328
329// ---------------------------------------------------------------------------
330// Compiled patterns
331// ---------------------------------------------------------------------------
332
333static SLOP_WORD_RE: Lazy<Regex> = Lazy::new(|| {
334    let words = [
335        // Adjectives
336        "crucial",
337        "groundbreaking",
338        "pivotal",
339        "paramount",
340        "seamless",
341        "holistic",
342        "multifaceted",
343        "meticulous",
344        "profound",
345        "comprehensive",
346        "invaluable",
347        "notable",
348        "noteworthy",
349        "game-changing",
350        "revolutionary",
351        "pioneering",
352        "visionary",
353        "formidable",
354        "quintessential",
355        "unparalleled",
356        "stunning",
357        "breathtaking",
358        "captivating",
359        "nestled",
360        "robust",
361        "innovative",
362        "cutting-edge",
363        "impactful",
364        // Verbs
365        "delve",
366        "delves",
367        "delved",
368        "delving",
369        "embark",
370        "embrace",
371        "elevate",
372        "foster",
373        "harness",
374        "unleash",
375        "unlock",
376        "orchestrate",
377        "streamline",
378        "transcend",
379        "navigate",
380        "underscore",
381        "showcase",
382        "leverage",
383        "ensuring",
384        "highlighting",
385        "emphasizing",
386        "reflecting",
387        // Nouns
388        "landscape",
389        "tapestry",
390        "journey",
391        "paradigm",
392        "testament",
393        "trajectory",
394        "nexus",
395        "symphony",
396        "spectrum",
397        "odyssey",
398        "pinnacle",
399        "realm",
400        "intricacies",
401        // Hedging
402        "notably",
403        "importantly",
404        "furthermore",
405        "additionally",
406        "particularly",
407        "significantly",
408        "interestingly",
409        "remarkably",
410        "surprisingly",
411        "fascinatingly",
412        "moreover",
413        "however",
414        "overall",
415    ];
416    let alt = words
417        .iter()
418        .map(|w| regex::escape(w))
419        .collect::<Vec<_>>()
420        .join("|");
421    Regex::new(&format!("(?i)\\b({alt})\\b")).unwrap()
422});
423
424static SLOP_PHRASES: Lazy<Vec<Regex>> = Lazy::new(|| {
425    let phrases = [
426        "it's worth noting",
427        "it's important to note",
428        "this is where things get interesting",
429        "here's the thing",
430        "at the end of the day",
431        "in today's fast-paced",
432        "as technology continues to",
433        "something shifted",
434        "everything changed",
435        "the answer? it's simpler than you think",
436        "what makes this work is",
437        "this is exactly",
438        "let's break this down",
439        "let's dive in",
440        "in this post, we'll explore",
441        "in this article, we'll",
442        "let me know if",
443        "would you like me to",
444        "i hope this helps",
445        "as mentioned earlier",
446        "as i mentioned",
447        "without further ado",
448        "on the other hand",
449        "in addition",
450        "in summary",
451        "in conclusion",
452        "you might be wondering",
453        "the obvious question is",
454        "no discussion would be complete",
455        "great question",
456        "that's a great",
457        "if you want, i can",
458        "i can adapt this",
459        "i can make this",
460        "here are some options",
461        "here are a few options",
462        "would you prefer",
463        "shall i",
464        "if you'd like, i can",
465        "i can also",
466        "in other words",
467        "put differently",
468        "that is to say",
469        "to put it simply",
470        "to put it another way",
471        "what this means is",
472        "the takeaway is",
473        "the bottom line is",
474        "the key takeaway",
475        "the key insight",
476    ];
477    phrases
478        .iter()
479        .map(|p| Regex::new(&format!("(?i){}", regex::escape(p))).unwrap())
480        .collect()
481});
482
483static NOT_JUST_BUT_RE: Lazy<Regex> =
484    Lazy::new(|| Regex::new(r"(?i)not (just|only) .{1,40}, but (also )?").unwrap());
485
486static BOLD_HEADER_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"\*\*[^*]+[.:]\*\*\s+\S").unwrap());
487
488static BULLET_LINE_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"^(\s*[-*]\s|\s*\d+\.\s)").unwrap());
489
490static TRIADIC_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?i)\w+, \w+, and \w+").unwrap());
491
492static META_COMM_PATTERNS: Lazy<Vec<Regex>> = Lazy::new(|| {
493    vec![
494        Regex::new(r"(?i)would you like").unwrap(),
495        Regex::new(r"(?i)let me know if").unwrap(),
496        Regex::new(r"(?i)as mentioned").unwrap(),
497        Regex::new(r"(?i)i hope this").unwrap(),
498        Regex::new(r"(?i)feel free to").unwrap(),
499        Regex::new(r"(?i)don't hesitate to").unwrap(),
500    ]
501});
502
503static FALSE_NARRATIVITY_PATTERNS: Lazy<Vec<Regex>> = Lazy::new(|| {
504    vec![
505        Regex::new(r"(?i)then something interesting happened").unwrap(),
506        Regex::new(r"(?i)this is where things get interesting").unwrap(),
507        Regex::new(r"(?i)that's when everything changed").unwrap(),
508    ]
509});
510
511static SENTENCE_OPENER_PATTERNS: Lazy<Vec<Regex>> = Lazy::new(|| {
512    vec![
513        Regex::new(r"(?im)(?:^|[.!?]\s+)(certainly[,! ])").unwrap(),
514        Regex::new(r"(?im)(?:^|[.!?]\s+)(absolutely[,! ])").unwrap(),
515    ]
516});
517
518static WEASEL_PATTERNS: Lazy<Vec<Regex>> = Lazy::new(|| {
519    vec![
520        Regex::new(r"(?i)\bsome critics argue\b").unwrap(),
521        Regex::new(r"(?i)\bmany believe\b").unwrap(),
522        Regex::new(r"(?i)\bexperts suggest\b").unwrap(),
523        Regex::new(r"(?i)\bstudies show\b").unwrap(),
524        Regex::new(r"(?i)\bsome argue\b").unwrap(),
525        Regex::new(r"(?i)\bit is widely believed\b").unwrap(),
526        Regex::new(r"(?i)\bresearch suggests\b").unwrap(),
527    ]
528});
529
530static AI_DISCLOSURE_PATTERNS: Lazy<Vec<Regex>> = Lazy::new(|| {
531    vec![
532        Regex::new(r"(?i)\bas an ai\b").unwrap(),
533        Regex::new(r"(?i)\bas a language model\b").unwrap(),
534        Regex::new(r"(?i)\bi don't have personal\b").unwrap(),
535        Regex::new(r"(?i)\bi cannot browse\b").unwrap(),
536        Regex::new(r"(?i)\bup to my last training\b").unwrap(),
537        Regex::new(r"(?i)\bas of my (last |knowledge )?cutoff\b").unwrap(),
538        Regex::new(r"(?i)\bi'm just an? ai\b").unwrap(),
539    ]
540});
541
542static PLACEHOLDER_RE: Lazy<Regex> = Lazy::new(|| {
543    Regex::new(
544        r"(?i)\[insert [^\]]*\]|\[describe [^\]]*\]|\[url [^\]]*\]|\[your [^\]]*\]|\[todo[^\]]*\]",
545    )
546    .unwrap()
547});
548
549static SENTENCE_SPLIT_RE: Lazy<Regex> =
550    Lazy::new(|| Regex::new(r#"[.!?]["'\u{201D}\u{2019})\]]*(?:\s|$)"#).unwrap());
551
552static EM_DASH_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"\u{2014}| -- ").unwrap());
553
554static CONTRAST_PAIR_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"\b(\w+), not (\w+)\b").unwrap());
555
556static SETUP_RESOLUTION_A_RE: Lazy<Regex> = Lazy::new(|| {
557    Regex::new(concat!(
558        r"(?i)\b(this|that|these|those|it|they|we)\s+",
559        r"(isn't|aren't|wasn't|weren't|doesn't|don't|didn't|hasn't|haven't|won't|can't|couldn't|shouldn't",
560        r"|is\s+not|are\s+not|was\s+not|were\s+not|does\s+not|do\s+not|did\s+not",
561        r"|has\s+not|have\s+not|will\s+not|cannot|could\s+not|should\s+not)\b",
562        r".{0,80}[.;:,]\s*",
563        r"(it's|they're|that's|he's|she's|we're|it\s+is|they\s+are|that\s+is|this\s+is",
564        r"|these\s+are|those\s+are|he\s+is|she\s+is|we\s+are|what's|what\s+is",
565        r"|the\s+real|the\s+actual|instead|rather)",
566    ))
567    .unwrap()
568});
569
570static SETUP_RESOLUTION_B_RE: Lazy<Regex> = Lazy::new(|| {
571    Regex::new(concat!(
572        r"(?i)\b(it's|that's|this\s+is|they're|he's|she's|we're)\s+not\b",
573        r".{0,80}[.;:,]\s*",
574        r"(it's|they're|that's|he's|she's|we're|it\s+is|they\s+are|that\s+is|this\s+is",
575        r"|these\s+are|those\s+are|what's|what\s+is|the\s+real|the\s+actual|instead|rather)",
576    ))
577    .unwrap()
578});
579
580static ELABORATION_COLON_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r": [a-z]").unwrap());
581
582static FENCED_CODE_BLOCK_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?s)```.*?```").unwrap());
583
584static JSON_COLON_RE: Lazy<Regex> =
585    Lazy::new(|| Regex::new(r#": ["{\[\d]|: true|: false|: null"#).unwrap());
586
587static PITHY_PIVOT_RE: Lazy<Regex> =
588    Lazy::new(|| Regex::new(r"(?i),\s+(?:but|yet|and|not|or)\b").unwrap());
589
590static BULLET_DENSITY_RE: Lazy<Regex> =
591    Lazy::new(|| Regex::new(r"(?m)^\s*[-*]\s|^\s*\d+[.)]\s").unwrap());
592
593static BOLD_TERM_BULLET_RE: Lazy<Regex> =
594    Lazy::new(|| Regex::new(r"^\s*[-*]\s+\*\*|^\s*\d+[.)]\s+\*\*").unwrap());
595
596static HORIZONTAL_RULE_RE: Lazy<Regex> =
597    Lazy::new(|| Regex::new(r"(?m)^\s*(?:---+|\*\*\*+|___+)\s*$").unwrap());
598
599static PUNCT_STRIP_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[^\w]+|[^\w]+$").unwrap());
600
601// We need a per-line header check (non-multiline)
602static MD_HEADER_LINE_SINGLE_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"^\s*#").unwrap());
603
604// ---------------------------------------------------------------------------
605// Stopwords
606// ---------------------------------------------------------------------------
607
608static STOPWORDS: Lazy<std::collections::HashSet<&'static str>> = Lazy::new(|| {
609    [
610        "the", "a", "an", "and", "or", "but", "in", "on", "at", "to", "for", "of", "is", "it",
611        "that", "this", "with", "as", "by", "from", "was", "were", "are", "be", "been", "has",
612        "have", "had", "not", "no", "do", "does", "did", "will", "would", "could", "should", "can",
613        "may", "might", "if", "then", "than", "so", "up", "out", "about", "into", "over", "after",
614        "before", "between", "through", "just", "also", "very", "more", "most", "some", "any",
615        "each", "every", "all", "both", "few", "other", "such", "only", "own", "same", "too",
616        "how", "what", "which", "who", "when", "where", "why",
617    ]
618    .into_iter()
619    .collect()
620});
621
622// ---------------------------------------------------------------------------
623// Helpers
624// ---------------------------------------------------------------------------
625
626fn context_around(text: &str, start: usize, end: usize, width: usize) -> String {
627    let mid = (start + end) / 2;
628    let half = width / 2;
629    let ctx_start = mid.saturating_sub(half);
630    let ctx_end = std::cmp::min(text.len(), mid + half);
631
632    // Ensure we don't slice in the middle of a multi-byte char
633    let ctx_start = snap_to_char_boundary(text, ctx_start, false);
634    let ctx_end = snap_to_char_boundary(text, ctx_end, true);
635
636    let snippet = text[ctx_start..ctx_end].replace('\n', " ");
637    let prefix = if ctx_start > 0 { "..." } else { "" };
638    let suffix = if ctx_end < text.len() { "..." } else { "" };
639    format!("{prefix}{snippet}{suffix}")
640}
641
642/// Snap a byte offset to a valid char boundary.
643/// If `forward` is true, snap forward; otherwise snap backward.
644fn snap_to_char_boundary(text: &str, pos: usize, forward: bool) -> usize {
645    if pos >= text.len() {
646        return text.len();
647    }
648    if text.is_char_boundary(pos) {
649        return pos;
650    }
651    if forward {
652        let mut p = pos;
653        while p < text.len() && !text.is_char_boundary(p) {
654            p += 1;
655        }
656        p
657    } else {
658        let mut p = pos;
659        while p > 0 && !text.is_char_boundary(p) {
660            p -= 1;
661        }
662        p
663    }
664}
665
666fn word_count(text: &str) -> usize {
667    text.split_whitespace().count()
668}
669
670fn strip_code_blocks(text: &str) -> String {
671    FENCED_CODE_BLOCK_RE.replace_all(text, "").into_owned()
672}
673
674#[derive(Debug)]
675struct NgramResult {
676    phrase: String,
677    count: usize,
678    n: usize,
679}
680
681fn find_repeated_ngrams(text: &str, hp: &Hyperparameters) -> Vec<NgramResult> {
682    let min_n = hp.repeated_ngram_min_n;
683    let max_n = hp.repeated_ngram_max_n;
684    let min_count = hp.repeated_ngram_min_count;
685
686    // Tokenize
687    let raw_tokens: Vec<&str> = text.split_whitespace().collect();
688    let tokens: Vec<String> = raw_tokens
689        .iter()
690        .filter_map(|t| {
691            let stripped = PUNCT_STRIP_RE.replace_all(t, "").to_lowercase();
692            if stripped.is_empty() {
693                None
694            } else {
695                Some(stripped)
696            }
697        })
698        .collect();
699
700    if tokens.len() < min_n {
701        return vec![];
702    }
703
704    // Count n-grams
705    let mut ngram_counts: HashMap<Vec<String>, usize> = HashMap::new();
706    for n in min_n..=max_n {
707        if tokens.len() < n {
708            continue;
709        }
710        for i in 0..=tokens.len() - n {
711            let gram: Vec<String> = tokens[i..i + n].to_vec();
712            *ngram_counts.entry(gram).or_insert(0) += 1;
713        }
714    }
715
716    // Filter: count >= min_count AND not all stopwords
717    let repeated: HashMap<Vec<String>, usize> = ngram_counts
718        .into_iter()
719        .filter(|(gram, count)| {
720            *count >= min_count && !gram.iter().all(|w| STOPWORDS.contains(w.as_str()))
721        })
722        .collect();
723
724    if repeated.is_empty() {
725        return vec![];
726    }
727
728    // Suppress sub-n-grams
729    let mut sorted_grams: Vec<(&Vec<String>, &usize)> = repeated.iter().collect();
730    sorted_grams.sort_by(|a, b| b.0.len().cmp(&a.0.len()));
731
732    let mut to_remove: std::collections::HashSet<Vec<String>> = std::collections::HashSet::new();
733    for i in 0..sorted_grams.len() {
734        let longer = sorted_grams[i].0;
735        let longer_str = longer.join(" ");
736        let longer_count = *sorted_grams[i].1;
737        for &(shorter, &shorter_count) in sorted_grams.iter().skip(i + 1) {
738            if to_remove.contains(shorter) {
739                continue;
740            }
741            let shorter_str = shorter.join(" ");
742            if longer_str.contains(&shorter_str) && longer_count >= shorter_count {
743                to_remove.insert(shorter.clone());
744            }
745        }
746    }
747
748    // Sort by (-length, -count)
749    let mut result_grams: Vec<(&Vec<String>, &usize)> = repeated
750        .iter()
751        .filter(|(gram, _)| !to_remove.contains(*gram))
752        .collect();
753    result_grams.sort_by(|a, b| b.0.len().cmp(&a.0.len()).then_with(|| b.1.cmp(a.1)));
754
755    result_grams
756        .into_iter()
757        .map(|(gram, &count)| NgramResult {
758            phrase: gram.join(" "),
759            count,
760            n: gram.len(),
761        })
762        .collect()
763}
764
765pub(crate) fn split_sentences(text: &str) -> Vec<String> {
766    SENTENCE_SPLIT_RE
767        .split(text)
768        .map(|s| s.trim().to_string())
769        .filter(|s| !s.is_empty())
770        .collect()
771}
772
773#[cfg(test)]
774pub(crate) fn split_lines(text: &str) -> Vec<&str> {
775    text.split('\n').collect()
776}
777
778// ---------------------------------------------------------------------------
779// Initial counts
780// ---------------------------------------------------------------------------
781
782fn initial_counts() -> HashMap<String, usize> {
783    let mut m = HashMap::new();
784    for key in RULE_NAMES {
785        m.insert(key.to_string(), 0);
786    }
787    m
788}
789
790// ---------------------------------------------------------------------------
791// Rule implementations
792// ---------------------------------------------------------------------------
793
794#[derive(Debug)]
795pub(crate) struct RuleOutput {
796    pub violations: Vec<Violation>,
797    pub advice: Vec<String>,
798    pub count_deltas: HashMap<String, usize>,
799}
800
801impl RuleOutput {
802    fn new() -> Self {
803        Self {
804            violations: Vec::new(),
805            advice: Vec::new(),
806            count_deltas: HashMap::new(),
807        }
808    }
809
810    fn inc(&mut self, key: &str) {
811        *self.count_deltas.entry(key.to_string()).or_insert(0) += 1;
812    }
813}
814
815pub(crate) fn rule_slop_words(text: &str, hp: &Hyperparameters) -> RuleOutput {
816    let mut out = RuleOutput::new();
817    let width = hp.context_window_chars;
818    for m in SLOP_WORD_RE.find_iter(text) {
819        let word = m.as_str().to_lowercase();
820        out.violations.push(Violation {
821            violation_type: "Violation".to_string(),
822            rule: "slop_word".to_string(),
823            match_text: word.clone(),
824            context: context_around(text, m.start(), m.end(), width),
825            penalty: hp.slop_word_penalty,
826        });
827        out.advice.push(format!(
828            "Replace '{word}' \u{2014} what specifically do you mean?"
829        ));
830        out.inc("slop_words");
831    }
832    out
833}
834
835pub(crate) fn rule_slop_phrases(text: &str, hp: &Hyperparameters) -> RuleOutput {
836    let mut out = RuleOutput::new();
837    let width = hp.context_window_chars;
838
839    for pat in SLOP_PHRASES.iter() {
840        for m in pat.find_iter(text) {
841            let phrase = m.as_str().to_lowercase();
842            out.violations.push(Violation {
843                violation_type: "Violation".to_string(),
844                rule: "slop_phrase".to_string(),
845                match_text: phrase.clone(),
846                context: context_around(text, m.start(), m.end(), width),
847                penalty: hp.slop_phrase_penalty,
848            });
849            out.advice.push(format!(
850                "Cut '{phrase}' \u{2014} just state the point directly."
851            ));
852            out.inc("slop_phrases");
853        }
854    }
855
856    for m in NOT_JUST_BUT_RE.find_iter(text) {
857        let phrase = m.as_str().trim().to_lowercase();
858        out.violations.push(Violation {
859            violation_type: "Violation".to_string(),
860            rule: "slop_phrase".to_string(),
861            match_text: phrase.clone(),
862            context: context_around(text, m.start(), m.end(), width),
863            penalty: hp.slop_phrase_penalty,
864        });
865        out.advice.push(format!(
866            "Cut '{phrase}' \u{2014} just state the point directly."
867        ));
868        out.inc("slop_phrases");
869    }
870    out
871}
872
873pub(crate) fn rule_structural(text: &str, lines: &[&str], hp: &Hyperparameters) -> RuleOutput {
874    let mut out = RuleOutput::new();
875    let width = hp.context_window_chars;
876
877    // Bold headers
878    let bold_matches: Vec<_> = BOLD_HEADER_RE.find_iter(text).collect();
879    if bold_matches.len() >= hp.structural_bold_header_min {
880        out.violations.push(Violation {
881            violation_type: "Violation".to_string(),
882            rule: "structural".to_string(),
883            match_text: "bold_header_explanation".to_string(),
884            context: format!(
885                "Found {} instances of **Bold.** pattern",
886                bold_matches.len()
887            ),
888            penalty: hp.structural_bold_header_penalty,
889        });
890        out.advice.push(format!(
891            "Vary paragraph structure \u{2014} {} bold-header-explanation blocks in a row reads as LLM listicle.",
892            bold_matches.len()
893        ));
894        out.inc("structural");
895    }
896
897    // Bullet runs
898    let mut run_length: usize = 0;
899    for line in lines {
900        if BULLET_LINE_RE.is_match(line) {
901            run_length += 1;
902        } else {
903            if run_length >= hp.structural_bullet_run_min {
904                out.violations.push(Violation {
905                    violation_type: "Violation".to_string(),
906                    rule: "structural".to_string(),
907                    match_text: "excessive_bullets".to_string(),
908                    context: format!("Run of {run_length} consecutive bullet lines"),
909                    penalty: hp.structural_bullet_run_penalty,
910                });
911                out.advice.push(format!(
912                    "Consider prose instead of this {run_length}-item bullet list."
913                ));
914                out.inc("structural");
915            }
916            run_length = 0;
917        }
918    }
919    if run_length >= hp.structural_bullet_run_min {
920        out.violations.push(Violation {
921            violation_type: "Violation".to_string(),
922            rule: "structural".to_string(),
923            match_text: "excessive_bullets".to_string(),
924            context: format!("Run of {run_length} consecutive bullet lines"),
925            penalty: hp.structural_bullet_run_penalty,
926        });
927        out.advice.push(format!(
928            "Consider prose instead of this {run_length}-item bullet list."
929        ));
930        out.inc("structural");
931    }
932
933    // Triadic
934    let triadic_matches: Vec<_> = TRIADIC_RE.find_iter(text).collect();
935    let triadic_count = triadic_matches.len();
936    for m in triadic_matches.iter().take(hp.triadic_record_cap) {
937        out.violations.push(Violation {
938            violation_type: "Violation".to_string(),
939            rule: "structural".to_string(),
940            match_text: "triadic".to_string(),
941            context: context_around(text, m.start(), m.end(), width),
942            penalty: hp.triadic_penalty,
943        });
944        out.inc("structural");
945    }
946    if triadic_count >= hp.triadic_advice_min {
947        out.advice.push(format!(
948            "{triadic_count} triadic structures ('X, Y, and Z') \u{2014} vary your list cadence."
949        ));
950    }
951
952    out
953}
954
955pub(crate) fn rule_tone(text: &str, hp: &Hyperparameters) -> RuleOutput {
956    let mut out = RuleOutput::new();
957    let width = hp.context_window_chars;
958
959    for pat in META_COMM_PATTERNS.iter() {
960        for m in pat.find_iter(text) {
961            let phrase = m.as_str().to_lowercase();
962            out.violations.push(Violation {
963                violation_type: "Violation".to_string(),
964                rule: "tone".to_string(),
965                match_text: phrase.clone(),
966                context: context_around(text, m.start(), m.end(), width),
967                penalty: hp.tone_penalty,
968            });
969            out.advice.push(format!(
970                "Remove '{phrase}' \u{2014} this is a direct AI tell."
971            ));
972            out.inc("tone");
973        }
974    }
975
976    for pat in FALSE_NARRATIVITY_PATTERNS.iter() {
977        for m in pat.find_iter(text) {
978            let phrase = m.as_str().to_lowercase();
979            out.violations.push(Violation {
980                violation_type: "Violation".to_string(),
981                rule: "tone".to_string(),
982                match_text: phrase.clone(),
983                context: context_around(text, m.start(), m.end(), width),
984                penalty: hp.tone_penalty,
985            });
986            out.advice
987                .push(format!("Cut '{phrase}' \u{2014} announce less, show more."));
988            out.inc("tone");
989        }
990    }
991
992    for pat in SENTENCE_OPENER_PATTERNS.iter() {
993        for caps in pat.captures_iter(text) {
994            let full = caps.get(0).unwrap();
995            let word_match = caps.get(1).unwrap();
996            let word = word_match
997                .as_str()
998                .trim_matches(|c: char| c == ',' || c == '!' || c == ' ')
999                .to_lowercase();
1000            out.violations.push(Violation {
1001                violation_type: "Violation".to_string(),
1002                rule: "tone".to_string(),
1003                match_text: word.clone(),
1004                context: context_around(text, full.start(), full.end(), width),
1005                penalty: hp.sentence_opener_penalty,
1006            });
1007            out.advice.push(format!(
1008                "'{word}' as a sentence opener is an AI tell \u{2014} just make the point."
1009            ));
1010            out.inc("tone");
1011        }
1012    }
1013
1014    out
1015}
1016
1017pub(crate) fn rule_weasel(text: &str, hp: &Hyperparameters) -> RuleOutput {
1018    let mut out = RuleOutput::new();
1019    let width = hp.context_window_chars;
1020
1021    for pat in WEASEL_PATTERNS.iter() {
1022        for m in pat.find_iter(text) {
1023            let phrase = m.as_str().to_lowercase();
1024            out.violations.push(Violation {
1025                violation_type: "Violation".to_string(),
1026                rule: "weasel".to_string(),
1027                match_text: phrase.clone(),
1028                context: context_around(text, m.start(), m.end(), width),
1029                penalty: hp.weasel_penalty,
1030            });
1031            out.advice.push(format!(
1032                "Cut '{phrase}' \u{2014} either cite a source or own the claim."
1033            ));
1034            out.inc("weasel");
1035        }
1036    }
1037    out
1038}
1039
1040pub(crate) fn rule_ai_disclosure(text: &str, hp: &Hyperparameters) -> RuleOutput {
1041    let mut out = RuleOutput::new();
1042    let width = hp.context_window_chars;
1043
1044    for pat in AI_DISCLOSURE_PATTERNS.iter() {
1045        for m in pat.find_iter(text) {
1046            let phrase = m.as_str().to_lowercase();
1047            out.violations.push(Violation {
1048                violation_type: "Violation".to_string(),
1049                rule: "ai_disclosure".to_string(),
1050                match_text: phrase.clone(),
1051                context: context_around(text, m.start(), m.end(), width),
1052                penalty: hp.ai_disclosure_penalty,
1053            });
1054            out.advice.push(format!(
1055                "Remove '{phrase}' \u{2014} AI self-disclosure in authored prose is a critical tell."
1056            ));
1057            out.inc("ai_disclosure");
1058        }
1059    }
1060    out
1061}
1062
1063pub(crate) fn rule_placeholder(text: &str, hp: &Hyperparameters) -> RuleOutput {
1064    let mut out = RuleOutput::new();
1065    let width = hp.context_window_chars;
1066
1067    for m in PLACEHOLDER_RE.find_iter(text) {
1068        let match_text = m.as_str().to_lowercase();
1069        out.violations.push(Violation {
1070            violation_type: "Violation".to_string(),
1071            rule: "placeholder".to_string(),
1072            match_text: match_text.clone(),
1073            context: context_around(text, m.start(), m.end(), width),
1074            penalty: hp.placeholder_penalty,
1075        });
1076        out.advice.push(format!(
1077            "Remove placeholder '{match_text}' \u{2014} this is unfinished template text."
1078        ));
1079        out.inc("placeholder");
1080    }
1081    out
1082}
1083
1084pub(crate) fn rule_rhythm(sentences: &[String], hp: &Hyperparameters) -> RuleOutput {
1085    let mut out = RuleOutput::new();
1086
1087    if sentences.len() < hp.rhythm_min_sentences {
1088        return out;
1089    }
1090
1091    let lengths: Vec<f64> = sentences
1092        .iter()
1093        .map(|s| s.split_whitespace().count() as f64)
1094        .collect();
1095    let mean = lengths.iter().sum::<f64>() / lengths.len() as f64;
1096    if mean <= 0.0 {
1097        return out;
1098    }
1099
1100    let variance = lengths.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / lengths.len() as f64;
1101    let std = variance.sqrt();
1102    let cv = std / mean;
1103
1104    if cv < hp.rhythm_cv_threshold {
1105        out.violations.push(Violation {
1106            violation_type: "Violation".to_string(),
1107            rule: "rhythm".to_string(),
1108            match_text: "monotonous_rhythm".to_string(),
1109            context: format!(
1110                "CV={cv:.2} across {} sentences (mean {mean:.1} words)",
1111                sentences.len()
1112            ),
1113            penalty: hp.rhythm_penalty,
1114        });
1115        out.advice.push(format!(
1116            "Sentence lengths are too uniform (CV={cv:.2}) \u{2014} vary short and long."
1117        ));
1118        out.inc("rhythm");
1119    }
1120    out
1121}
1122
1123pub(crate) fn rule_em_dash_density(text: &str, wc: usize, hp: &Hyperparameters) -> RuleOutput {
1124    let mut out = RuleOutput::new();
1125    if wc == 0 {
1126        return out;
1127    }
1128
1129    let em_dash_count = EM_DASH_RE.find_iter(text).count();
1130    let ratio_per_150 = (em_dash_count as f64 / wc as f64) * hp.em_dash_words_basis;
1131    if ratio_per_150 > hp.em_dash_density_threshold {
1132        out.violations.push(Violation {
1133            violation_type: "Violation".to_string(),
1134            rule: "em_dash".to_string(),
1135            match_text: "em_dash_density".to_string(),
1136            context: format!(
1137                "{em_dash_count} em dashes in {wc} words ({ratio_per_150:.1} per 150 words)"
1138            ),
1139            penalty: hp.em_dash_penalty,
1140        });
1141        out.advice.push(format!(
1142            "Too many em dashes ({em_dash_count} in {wc} words) \u{2014} use other punctuation."
1143        ));
1144        out.inc("em_dash");
1145    }
1146    out
1147}
1148
1149pub(crate) fn rule_contrast_pairs(text: &str, hp: &Hyperparameters) -> RuleOutput {
1150    let mut out = RuleOutput::new();
1151    let width = hp.context_window_chars;
1152
1153    let matches: Vec<_> = CONTRAST_PAIR_RE.find_iter(text).collect();
1154    let count = matches.len();
1155
1156    for m in matches.iter().take(hp.contrast_record_cap) {
1157        let matched = m.as_str().to_string();
1158        out.violations.push(Violation {
1159            violation_type: "Violation".to_string(),
1160            rule: "contrast_pair".to_string(),
1161            match_text: matched.clone(),
1162            context: context_around(text, m.start(), m.end(), width),
1163            penalty: hp.contrast_penalty,
1164        });
1165        out.advice.push(format!(
1166            "'{matched}' \u{2014} 'X, not Y' contrast \u{2014} consider rephrasing to avoid the Claude pattern."
1167        ));
1168        out.inc("contrast_pairs");
1169    }
1170
1171    if count >= hp.contrast_advice_min {
1172        out.advice.push(format!(
1173            "{count} 'X, not Y' contrasts \u{2014} this is a Claude rhetorical tic. Vary your phrasing."
1174        ));
1175    }
1176    out
1177}
1178
1179pub(crate) fn rule_setup_resolution(text: &str, hp: &Hyperparameters) -> RuleOutput {
1180    let mut out = RuleOutput::new();
1181    let width = hp.context_window_chars;
1182
1183    let mut setup_res_recorded = 0usize;
1184    for pat in [&*SETUP_RESOLUTION_A_RE, &*SETUP_RESOLUTION_B_RE] {
1185        for m in pat.find_iter(text) {
1186            if setup_res_recorded < hp.setup_resolution_record_cap {
1187                let matched = m.as_str().to_string();
1188                out.violations.push(Violation {
1189                    violation_type: "Violation".to_string(),
1190                    rule: "setup_resolution".to_string(),
1191                    match_text: matched.clone(),
1192                    context: context_around(text, m.start(), m.end(), width),
1193                    penalty: hp.setup_resolution_penalty,
1194                });
1195                out.advice.push(format!(
1196                    "'{matched}' \u{2014} setup-and-resolution is a Claude rhetorical tic. Just state the point directly."
1197                ));
1198                setup_res_recorded += 1;
1199            }
1200            out.inc("setup_resolution");
1201        }
1202    }
1203    out
1204}
1205
1206pub(crate) fn rule_colon_density(text: &str, hp: &Hyperparameters) -> RuleOutput {
1207    let mut out = RuleOutput::new();
1208
1209    let stripped_text = strip_code_blocks(text);
1210    let mut colon_count = 0usize;
1211
1212    for line in stripped_text.split('\n') {
1213        if MD_HEADER_LINE_SINGLE_RE.is_match(line) {
1214            continue;
1215        }
1216        for cm in ELABORATION_COLON_RE.find_iter(line) {
1217            let col_pos = cm.start();
1218            let before = &line[..col_pos + 1];
1219            if before.ends_with("http:") || before.ends_with("https:") {
1220                continue;
1221            }
1222            let snippet_end = std::cmp::min(col_pos + 10, line.len());
1223            let snippet_end = snap_to_char_boundary(line, snippet_end, true);
1224            let snippet = &line[col_pos..snippet_end];
1225            if JSON_COLON_RE.is_match(snippet) {
1226                continue;
1227            }
1228            colon_count += 1;
1229        }
1230    }
1231
1232    let stripped_wc = word_count(&stripped_text);
1233    if stripped_wc == 0 {
1234        return out;
1235    }
1236
1237    let colon_ratio_per_150 = (colon_count as f64 / stripped_wc as f64) * hp.colon_words_basis;
1238    if colon_ratio_per_150 > hp.colon_density_threshold {
1239        out.violations.push(Violation {
1240            violation_type: "Violation".to_string(),
1241            rule: "colon_density".to_string(),
1242            match_text: "colon_density".to_string(),
1243            context: format!(
1244                "{colon_count} elaboration colons in {stripped_wc} words ({colon_ratio_per_150:.1} per 150 words)"
1245            ),
1246            penalty: hp.colon_density_penalty,
1247        });
1248        out.advice.push(format!(
1249            "Too many elaboration colons ({colon_count} in {stripped_wc} words) \u{2014} use periods or restructure sentences."
1250        ));
1251        out.inc("colon_density");
1252    }
1253    out
1254}
1255
1256pub(crate) fn rule_pithy_fragments(sentences: &[String], hp: &Hyperparameters) -> RuleOutput {
1257    let mut out = RuleOutput::new();
1258    let mut pithy_count = 0usize;
1259
1260    for sent in sentences {
1261        let s = sent.trim();
1262        if s.is_empty() {
1263            continue;
1264        }
1265        let sent_words: Vec<&str> = s.split_whitespace().collect();
1266        if sent_words.len() > hp.pithy_max_sentence_words {
1267            continue;
1268        }
1269        if PITHY_PIVOT_RE.is_match(s) {
1270            if pithy_count < hp.pithy_record_cap {
1271                out.violations.push(Violation {
1272                    violation_type: "Violation".to_string(),
1273                    rule: "pithy_fragment".to_string(),
1274                    match_text: s.to_string(),
1275                    context: s.to_string(),
1276                    penalty: hp.pithy_penalty,
1277                });
1278                out.advice.push(format!(
1279                    "'{s}' \u{2014} pithy evaluative fragments are a Claude tell. Expand or cut."
1280                ));
1281            }
1282            pithy_count += 1;
1283            out.inc("pithy_fragment");
1284        }
1285    }
1286    out
1287}
1288
1289pub(crate) fn rule_bullet_density(lines: &[&str], hp: &Hyperparameters) -> RuleOutput {
1290    let mut out = RuleOutput::new();
1291
1292    let non_empty: Vec<&&str> = lines.iter().filter(|l| !l.trim().is_empty()).collect();
1293    let total_non_empty = non_empty.len();
1294    if total_non_empty == 0 {
1295        return out;
1296    }
1297
1298    let bullet_count = non_empty
1299        .iter()
1300        .filter(|l| BULLET_DENSITY_RE.is_match(l))
1301        .count();
1302    let bullet_ratio = bullet_count as f64 / total_non_empty as f64;
1303    if bullet_ratio > hp.bullet_density_threshold {
1304        out.violations.push(Violation {
1305            violation_type: "Violation".to_string(),
1306            rule: "structural".to_string(),
1307            match_text: "bullet_density".to_string(),
1308            context: format!(
1309                "{bullet_count} of {total_non_empty} non-empty lines are bullets ({:.0}%)",
1310                bullet_ratio * 100.0
1311            ),
1312            penalty: hp.bullet_density_penalty,
1313        });
1314        out.advice.push(format!(
1315            "Over {:.0}% of lines are bullets \u{2014} write prose instead of lists.",
1316            bullet_ratio * 100.0
1317        ));
1318        out.inc("bullet_density");
1319    }
1320    out
1321}
1322
1323pub(crate) fn rule_blockquote_density(lines: &[&str], hp: &Hyperparameters) -> RuleOutput {
1324    let mut out = RuleOutput::new();
1325
1326    let mut in_code_block = false;
1327    let mut blockquote_count = 0usize;
1328    for line in lines {
1329        if line.trim().starts_with("```") {
1330            in_code_block = !in_code_block;
1331            continue;
1332        }
1333        if !in_code_block && line.starts_with('>') {
1334            blockquote_count += 1;
1335        }
1336    }
1337
1338    if blockquote_count >= hp.blockquote_min_lines {
1339        let excess = blockquote_count - hp.blockquote_free_lines;
1340        let capped = std::cmp::min(excess, hp.blockquote_cap);
1341        let bq_penalty = hp.blockquote_penalty_step * capped as i32;
1342        out.violations.push(Violation {
1343            violation_type: "Violation".to_string(),
1344            rule: "structural".to_string(),
1345            match_text: "blockquote_density".to_string(),
1346            context: format!(
1347                "{blockquote_count} blockquote lines \u{2014} Claude uses these as thesis statements"
1348            ),
1349            penalty: bq_penalty,
1350        });
1351        out.advice.push(format!(
1352            "{blockquote_count} blockquotes \u{2014} integrate key claims into prose instead of pulling them out as blockquotes."
1353        ));
1354        out.inc("blockquote_density");
1355    }
1356    out
1357}
1358
1359pub(crate) fn rule_bold_bullet_runs(lines: &[&str], hp: &Hyperparameters) -> RuleOutput {
1360    let mut out = RuleOutput::new();
1361
1362    let mut bold_bullet_run = 0usize;
1363    for line in lines {
1364        if BOLD_TERM_BULLET_RE.is_match(line) {
1365            bold_bullet_run += 1;
1366            continue;
1367        }
1368        if bold_bullet_run >= hp.bold_bullet_run_min {
1369            out.violations.push(Violation {
1370                violation_type: "Violation".to_string(),
1371                rule: "structural".to_string(),
1372                match_text: "bold_bullet_list".to_string(),
1373                context: format!("Run of {bold_bullet_run} bold-term bullets"),
1374                penalty: hp.bold_bullet_run_penalty,
1375            });
1376            out.advice.push(format!(
1377                "Run of {bold_bullet_run} bold-term bullets \u{2014} this is an LLM listicle pattern. Use varied paragraph structure."
1378            ));
1379            out.inc("bold_bullet_list");
1380        }
1381        bold_bullet_run = 0;
1382    }
1383    if bold_bullet_run >= hp.bold_bullet_run_min {
1384        out.violations.push(Violation {
1385            violation_type: "Violation".to_string(),
1386            rule: "structural".to_string(),
1387            match_text: "bold_bullet_list".to_string(),
1388            context: format!("Run of {bold_bullet_run} bold-term bullets"),
1389            penalty: hp.bold_bullet_run_penalty,
1390        });
1391        out.advice.push(format!(
1392            "Run of {bold_bullet_run} bold-term bullets \u{2014} this is an LLM listicle pattern. Use varied paragraph structure."
1393        ));
1394        out.inc("bold_bullet_list");
1395    }
1396    out
1397}
1398
1399pub(crate) fn rule_horizontal_rules(text: &str, hp: &Hyperparameters) -> RuleOutput {
1400    let mut out = RuleOutput::new();
1401
1402    let hr_count = HORIZONTAL_RULE_RE.find_iter(text).count();
1403    if hr_count >= hp.horizontal_rule_min {
1404        out.violations.push(Violation {
1405            violation_type: "Violation".to_string(),
1406            rule: "structural".to_string(),
1407            match_text: "horizontal_rules".to_string(),
1408            context: format!("{hr_count} horizontal rules \u{2014} excessive section dividers"),
1409            penalty: hp.horizontal_rule_penalty,
1410        });
1411        out.advice.push(format!(
1412            "{hr_count} horizontal rules \u{2014} section headers alone are sufficient, dividers are a crutch."
1413        ));
1414        out.inc("horizontal_rules");
1415    }
1416    out
1417}
1418
1419pub(crate) fn rule_phrase_reuse(text: &str, hp: &Hyperparameters) -> RuleOutput {
1420    let mut out = RuleOutput::new();
1421
1422    let repeated = find_repeated_ngrams(text, hp);
1423    for (recorded, ng) in repeated.iter().enumerate() {
1424        if recorded >= hp.phrase_reuse_record_cap {
1425            break;
1426        }
1427        out.violations.push(Violation {
1428            violation_type: "Violation".to_string(),
1429            rule: "phrase_reuse".to_string(),
1430            match_text: ng.phrase.clone(),
1431            context: format!(
1432                "'{}' ({}-word phrase) appears {} times",
1433                ng.phrase, ng.n, ng.count
1434            ),
1435            penalty: hp.phrase_reuse_penalty,
1436        });
1437        out.advice.push(format!(
1438            "'{}' appears {} times \u{2014} vary your phrasing to avoid repetition.",
1439            ng.phrase, ng.count
1440        ));
1441        out.inc("phrase_reuse");
1442    }
1443    out
1444}
1445
1446// ---------------------------------------------------------------------------
1447// Scoring
1448// ---------------------------------------------------------------------------
1449
1450fn compute_weighted_sum(
1451    violations: &[Violation],
1452    counts: &HashMap<String, usize>,
1453    hp: &Hyperparameters,
1454) -> f64 {
1455    let mut weighted_sum = 0.0f64;
1456    for v in violations {
1457        let penalty = v.penalty.unsigned_abs() as f64;
1458        let rule = &v.rule;
1459
1460        let is_claude_cat = hp.claude_categories.iter().any(|c| c == rule)
1461            || hp
1462                .claude_categories
1463                .iter()
1464                .any(|c| c == &format!("{rule}s"));
1465
1466        // Get the count for the rule
1467        let cat_count = counts
1468            .get(rule.as_str())
1469            .copied()
1470            .unwrap_or(0)
1471            .max(counts.get(&format!("{rule}s")).copied().unwrap_or(0));
1472
1473        if is_claude_cat && cat_count > 1 {
1474            let weight = penalty * (1.0 + hp.concentration_alpha * (cat_count as f64 - 1.0));
1475            weighted_sum += weight;
1476        } else {
1477            weighted_sum += penalty;
1478        }
1479    }
1480    weighted_sum
1481}
1482
1483fn band_for_score(score: i32, hp: &Hyperparameters) -> &'static str {
1484    if score >= hp.band_clean_min {
1485        "clean"
1486    } else if score >= hp.band_light_min {
1487        "light"
1488    } else if score >= hp.band_moderate_min {
1489        "moderate"
1490    } else if score >= hp.band_heavy_min {
1491        "heavy"
1492    } else {
1493        "saturated"
1494    }
1495}
1496
1497fn deduplicate_advice(advice: Vec<String>) -> Vec<String> {
1498    let mut seen = std::collections::HashSet::new();
1499    let mut unique = Vec::new();
1500    for item in advice {
1501        if seen.insert(item.clone()) {
1502            unique.push(item);
1503        }
1504    }
1505    unique
1506}
1507
1508// ---------------------------------------------------------------------------
1509// Merge helper
1510// ---------------------------------------------------------------------------
1511
1512fn merge_output(
1513    violations: &mut Vec<Violation>,
1514    advice: &mut Vec<String>,
1515    counts: &mut HashMap<String, usize>,
1516    out: RuleOutput,
1517) {
1518    violations.extend(out.violations);
1519    advice.extend(out.advice);
1520    for (key, delta) in out.count_deltas {
1521        if delta > 0 {
1522            *counts.entry(key).or_insert(0) += delta;
1523        }
1524    }
1525}
1526
1527// ---------------------------------------------------------------------------
1528// Public API
1529// ---------------------------------------------------------------------------
1530
1531pub fn analyze(text: &str) -> AnalysisResult {
1532    analyze_with_config(text, &Hyperparameters::default())
1533}
1534
1535pub fn analyze_with_config(text: &str, hp: &Hyperparameters) -> AnalysisResult {
1536    let wc = word_count(text);
1537    let counts_init = initial_counts();
1538
1539    if wc < hp.short_text_word_count {
1540        return AnalysisResult {
1541            score: hp.score_max,
1542            band: "clean".to_string(),
1543            word_count: wc,
1544            violations: vec![],
1545            counts: counts_init,
1546            total_penalty: 0,
1547            weighted_sum: 0.0,
1548            density: 0.0,
1549            advice: vec![],
1550        };
1551    }
1552
1553    let lines: Vec<&str> = text.split('\n').collect();
1554    let sentences: Vec<String> = split_sentences(text);
1555
1556    let mut violations: Vec<Violation> = Vec::new();
1557    let mut advice: Vec<String> = Vec::new();
1558    let mut counts = counts_init;
1559
1560    // 1. Slop words
1561    merge_output(
1562        &mut violations,
1563        &mut advice,
1564        &mut counts,
1565        rule_slop_words(text, hp),
1566    );
1567    // 2. Slop phrases
1568    merge_output(
1569        &mut violations,
1570        &mut advice,
1571        &mut counts,
1572        rule_slop_phrases(text, hp),
1573    );
1574    // 3. Structural
1575    merge_output(
1576        &mut violations,
1577        &mut advice,
1578        &mut counts,
1579        rule_structural(text, &lines, hp),
1580    );
1581    // 4. Tone
1582    merge_output(
1583        &mut violations,
1584        &mut advice,
1585        &mut counts,
1586        rule_tone(text, hp),
1587    );
1588    // 5. Weasel
1589    merge_output(
1590        &mut violations,
1591        &mut advice,
1592        &mut counts,
1593        rule_weasel(text, hp),
1594    );
1595    // 6. AI disclosure
1596    merge_output(
1597        &mut violations,
1598        &mut advice,
1599        &mut counts,
1600        rule_ai_disclosure(text, hp),
1601    );
1602    // 7. Placeholder
1603    merge_output(
1604        &mut violations,
1605        &mut advice,
1606        &mut counts,
1607        rule_placeholder(text, hp),
1608    );
1609    // 8. Rhythm
1610    merge_output(
1611        &mut violations,
1612        &mut advice,
1613        &mut counts,
1614        rule_rhythm(&sentences, hp),
1615    );
1616    // 9. Em dash density
1617    merge_output(
1618        &mut violations,
1619        &mut advice,
1620        &mut counts,
1621        rule_em_dash_density(text, wc, hp),
1622    );
1623    // 10. Contrast pairs
1624    merge_output(
1625        &mut violations,
1626        &mut advice,
1627        &mut counts,
1628        rule_contrast_pairs(text, hp),
1629    );
1630    // 11. Setup-resolution
1631    merge_output(
1632        &mut violations,
1633        &mut advice,
1634        &mut counts,
1635        rule_setup_resolution(text, hp),
1636    );
1637    // 12. Colon density
1638    merge_output(
1639        &mut violations,
1640        &mut advice,
1641        &mut counts,
1642        rule_colon_density(text, hp),
1643    );
1644    // 13. Pithy fragments
1645    merge_output(
1646        &mut violations,
1647        &mut advice,
1648        &mut counts,
1649        rule_pithy_fragments(&sentences, hp),
1650    );
1651    // 14. Bullet density
1652    merge_output(
1653        &mut violations,
1654        &mut advice,
1655        &mut counts,
1656        rule_bullet_density(&lines, hp),
1657    );
1658    // 15. Blockquote density
1659    merge_output(
1660        &mut violations,
1661        &mut advice,
1662        &mut counts,
1663        rule_blockquote_density(&lines, hp),
1664    );
1665    // 16. Bold bullet runs
1666    merge_output(
1667        &mut violations,
1668        &mut advice,
1669        &mut counts,
1670        rule_bold_bullet_runs(&lines, hp),
1671    );
1672    // 17. Horizontal rules
1673    merge_output(
1674        &mut violations,
1675        &mut advice,
1676        &mut counts,
1677        rule_horizontal_rules(text, hp),
1678    );
1679    // 18. Phrase reuse
1680    merge_output(
1681        &mut violations,
1682        &mut advice,
1683        &mut counts,
1684        rule_phrase_reuse(text, hp),
1685    );
1686
1687    let total_penalty: i32 = violations.iter().map(|v| v.penalty).sum();
1688    let weighted_sum = compute_weighted_sum(&violations, &counts, hp);
1689    let density = if wc > 0 {
1690        weighted_sum / (wc as f64 / hp.density_words_basis)
1691    } else {
1692        0.0
1693    };
1694    let raw_score = hp.score_max as f64 * (-hp.decay_lambda * density).exp();
1695    let score = (raw_score.round() as i32).clamp(hp.score_min, hp.score_max);
1696    let band = band_for_score(score, hp).to_string();
1697
1698    AnalysisResult {
1699        score,
1700        band,
1701        word_count: wc,
1702        violations,
1703        counts,
1704        total_penalty,
1705        weighted_sum: (weighted_sum * 100.0).round() / 100.0,
1706        density: (density * 100.0).round() / 100.0,
1707        advice: deduplicate_advice(advice),
1708    }
1709}
1710
1711#[cfg(test)]
1712mod rule_tests;