1use std::collections::HashMap;
2
3use once_cell::sync::Lazy;
4use regex::Regex;
5use serde::{Deserialize, Serialize};
6
7#[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#[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#[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
304pub 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
329static SLOP_WORD_RE: Lazy<Regex> = Lazy::new(|| {
334 let words = [
335 "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 "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 "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 "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
601static MD_HEADER_LINE_SINGLE_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"^\s*#").unwrap());
603
604static 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
622fn 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 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
642fn 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 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 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 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 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 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
778fn 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#[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 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 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 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
1446fn 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 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
1508fn 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
1527pub 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 merge_output(
1562 &mut violations,
1563 &mut advice,
1564 &mut counts,
1565 rule_slop_words(text, hp),
1566 );
1567 merge_output(
1569 &mut violations,
1570 &mut advice,
1571 &mut counts,
1572 rule_slop_phrases(text, hp),
1573 );
1574 merge_output(
1576 &mut violations,
1577 &mut advice,
1578 &mut counts,
1579 rule_structural(text, &lines, hp),
1580 );
1581 merge_output(
1583 &mut violations,
1584 &mut advice,
1585 &mut counts,
1586 rule_tone(text, hp),
1587 );
1588 merge_output(
1590 &mut violations,
1591 &mut advice,
1592 &mut counts,
1593 rule_weasel(text, hp),
1594 );
1595 merge_output(
1597 &mut violations,
1598 &mut advice,
1599 &mut counts,
1600 rule_ai_disclosure(text, hp),
1601 );
1602 merge_output(
1604 &mut violations,
1605 &mut advice,
1606 &mut counts,
1607 rule_placeholder(text, hp),
1608 );
1609 merge_output(
1611 &mut violations,
1612 &mut advice,
1613 &mut counts,
1614 rule_rhythm(&sentences, hp),
1615 );
1616 merge_output(
1618 &mut violations,
1619 &mut advice,
1620 &mut counts,
1621 rule_em_dash_density(text, wc, hp),
1622 );
1623 merge_output(
1625 &mut violations,
1626 &mut advice,
1627 &mut counts,
1628 rule_contrast_pairs(text, hp),
1629 );
1630 merge_output(
1632 &mut violations,
1633 &mut advice,
1634 &mut counts,
1635 rule_setup_resolution(text, hp),
1636 );
1637 merge_output(
1639 &mut violations,
1640 &mut advice,
1641 &mut counts,
1642 rule_colon_density(text, hp),
1643 );
1644 merge_output(
1646 &mut violations,
1647 &mut advice,
1648 &mut counts,
1649 rule_pithy_fragments(&sentences, hp),
1650 );
1651 merge_output(
1653 &mut violations,
1654 &mut advice,
1655 &mut counts,
1656 rule_bullet_density(&lines, hp),
1657 );
1658 merge_output(
1660 &mut violations,
1661 &mut advice,
1662 &mut counts,
1663 rule_blockquote_density(&lines, hp),
1664 );
1665 merge_output(
1667 &mut violations,
1668 &mut advice,
1669 &mut counts,
1670 rule_bold_bullet_runs(&lines, hp),
1671 );
1672 merge_output(
1674 &mut violations,
1675 &mut advice,
1676 &mut counts,
1677 rule_horizontal_rules(text, hp),
1678 );
1679 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;