mbr_core/readability.rs
1//! Readability scoring utilities (Flesch Reading Ease and Flesch-Kincaid Grade Level).
2//!
3//! This module provides a small, dependency-free implementation of two widely
4//! used readability metrics that are computed server-side during markdown
5//! rendering:
6//!
7//! * **Flesch Reading Ease (FRE)** — higher values mean easier to read
8//! (0 = extremely difficult, 100 = extremely easy).
9//! * **Flesch-Kincaid Grade Level (FKGL)** — expressed as a US school grade.
10//!
11//! The heart of both formulas is the syllable count. We use a simple
12//! Aydin/sylco-style heuristic that is correct on the overwhelming majority of
13//! English words and degrades gracefully on edge cases. This matches the
14//! project's "zero run-time dependencies" principle — the formulas themselves
15//! are trivial arithmetic.
16//!
17//! # Example
18//!
19//! ```
20//! use mbr_core::readability::{ReadabilityCounts, scores};
21//!
22//! let counts = ReadabilityCounts {
23//! words: 10,
24//! sentences: 2,
25//! syllables: 14,
26//! };
27//! let s = scores(&counts);
28//! assert!(s.flesch_reading_ease.is_some());
29//! assert!(s.flesch_kincaid_grade.is_some());
30//! ```
31
32/// Accumulated counts needed to compute readability scores.
33///
34/// All fields are tallied during the single-pass markdown event loop so the
35/// computation piggybacks on work we are already doing.
36#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
37pub struct ReadabilityCounts {
38 /// Total words outside code/metadata blocks.
39 pub words: usize,
40 /// Total sentence-terminating events.
41 pub sentences: usize,
42 /// Total syllables across counted words.
43 pub syllables: usize,
44}
45
46/// Computed readability scores.
47///
48/// Each score is `None` when the input counts are insufficient to produce a
49/// meaningful value (for example, empty documents or code-only files).
50#[derive(Debug, Clone, Copy, Default, PartialEq)]
51pub struct ReadabilityScores {
52 /// Flesch Reading Ease. Typical range 0–100 (can exceed either bound
53 /// slightly for degenerate inputs).
54 pub flesch_reading_ease: Option<f32>,
55 /// Flesch-Kincaid Grade Level (US school grade).
56 pub flesch_kincaid_grade: Option<f32>,
57}
58
59/// Count syllables in a word using a simple heuristic.
60///
61/// The algorithm is a pared-down version of the well-known Aydin/sylco
62/// approach used by libraries like Wooorm's `syllable`:
63///
64/// 1. lowercase and keep only ASCII letters
65/// 2. count transitions from non-vowel to vowel (collapse vowel runs)
66/// 3. subtract 1 for a silent trailing `e` (but not `le` after a consonant,
67/// which is its own syllable in words like `table`, `simple`)
68/// 4. ensure a minimum of 1 syllable for any non-empty word
69///
70/// The heuristic treats `y` as a vowel for counting purposes, which is a
71/// reasonable approximation for English. Accuracy is roughly 80–90% on common
72/// English corpora — more than good enough for readability band estimation.
73///
74/// Returns `0` for inputs that contain no ASCII letters.
75///
76/// # Examples
77///
78/// ```
79/// use mbr_core::readability::count_syllables;
80/// assert_eq!(count_syllables("the"), 1);
81/// assert_eq!(count_syllables("cat"), 1);
82/// assert_eq!(count_syllables("apple"), 2);
83/// ```
84pub fn count_syllables(word: &str) -> usize {
85 // Normalize: keep only ASCII letters, lowercase them.
86 let normalized: Vec<u8> = word
87 .bytes()
88 .filter(|b| b.is_ascii_alphabetic())
89 .map(|b| b.to_ascii_lowercase())
90 .collect();
91
92 if normalized.is_empty() {
93 return 0;
94 }
95
96 let is_vowel = |b: u8| matches!(b, b'a' | b'e' | b'i' | b'o' | b'u' | b'y');
97
98 // Count vowel groups: each transition from non-vowel to vowel is +1.
99 let mut count: usize = 0;
100 let mut prev_was_vowel = false;
101 for &b in &normalized {
102 let v = is_vowel(b);
103 if v && !prev_was_vowel {
104 count += 1;
105 }
106 prev_was_vowel = v;
107 }
108
109 // Silent trailing `e`: subtract 1 unless the word ends in `le` preceded by
110 // a consonant (e.g. `table` — that `le` is its own syllable).
111 let len = normalized.len();
112 if len >= 2 && normalized[len - 1] == b'e' {
113 let ends_in_le_after_consonant =
114 len >= 3 && normalized[len - 2] == b'l' && !is_vowel(normalized[len - 3]);
115 if !ends_in_le_after_consonant {
116 count = count.saturating_sub(1);
117 }
118 }
119
120 // Non-empty words always get at least one syllable.
121 count.max(1)
122}
123
124/// Compute readability scores from accumulated counts.
125///
126/// Returns `None` for either score when `words == 0` or `sentences == 0`, which
127/// would otherwise produce a division by zero.
128///
129/// Formulas from Wikipedia
130/// (<https://en.wikipedia.org/wiki/Flesch%E2%80%93Kincaid_readability_tests>):
131///
132/// ```text
133/// FRE = 206.835 - 1.015 * (words / sentences) - 84.6 * (syllables / words)
134/// FKGL = 0.39 * (words / sentences) + 11.8 * (syllables / words) - 15.59
135/// ```
136#[must_use]
137pub fn scores(counts: &ReadabilityCounts) -> ReadabilityScores {
138 if counts.words == 0 || counts.sentences == 0 {
139 return ReadabilityScores::default();
140 }
141
142 let words = counts.words as f32;
143 let sentences = counts.sentences as f32;
144 let syllables = counts.syllables as f32;
145
146 let words_per_sentence = words / sentences;
147 let syllables_per_word = syllables / words;
148
149 let fre = 206.835 - 1.015 * words_per_sentence - 84.6 * syllables_per_word;
150 let fkgl = 0.39 * words_per_sentence + 11.8 * syllables_per_word - 15.59;
151
152 ReadabilityScores {
153 flesch_reading_ease: Some(fre),
154 flesch_kincaid_grade: Some(fkgl),
155 }
156}
157
158#[cfg(test)]
159mod tests {
160 use super::*;
161
162 /// Helper: assert a float is within `tolerance` of `expected`.
163 fn assert_close(actual: f32, expected: f32, tolerance: f32, label: &str) {
164 assert!(
165 (actual - expected).abs() < tolerance,
166 "{label}: expected {expected} ± {tolerance}, got {actual}"
167 );
168 }
169
170 #[test]
171 fn syllables_empty_word_is_zero() {
172 assert_eq!(count_syllables(""), 0);
173 assert_eq!(count_syllables(" "), 0);
174 assert_eq!(count_syllables("!!!"), 0);
175 }
176
177 #[test]
178 fn syllables_single_syllable_words() {
179 assert_eq!(count_syllables("the"), 1);
180 assert_eq!(count_syllables("cat"), 1);
181 assert_eq!(count_syllables("dog"), 1);
182 assert_eq!(count_syllables("run"), 1);
183 assert_eq!(count_syllables("eight"), 1);
184 }
185
186 #[test]
187 fn syllables_two_syllable_words() {
188 // Words where our heuristic agrees with natural syllable count.
189 assert_eq!(count_syllables("apple"), 2);
190 assert_eq!(count_syllables("table"), 2);
191 assert_eq!(count_syllables("simple"), 2);
192 }
193
194 #[test]
195 fn syllables_longer_words() {
196 // "readability" → read-a-bil-i-ty → 5
197 assert_eq!(count_syllables("readability"), 5);
198 }
199
200 #[test]
201 fn syllables_edge_cases_documented() {
202 // The Aydin heuristic is approximate — these are the values our
203 // implementation produces. Documented here so regressions are
204 // obvious and so the heuristic's behaviour is stable.
205 //
206 // "every" is pronounced as 2 (ev-ry) or 3 (ev-er-y). Our heuristic
207 // sees three vowel groups (e, e, y) and no trailing silent `e`,
208 // so it produces 3.
209 assert_eq!(count_syllables("every"), 3);
210 // "shoreline" has a silent medial `e` our heuristic cannot detect,
211 // so we count 3 syllables (sho-re-line) instead of the true 2.
212 assert_eq!(count_syllables("shoreline"), 3);
213 // "simile" (si-mi-le) — the final `e` in -ile is dropped by the
214 // silent-e rule because the preceding `l` is itself preceded by a
215 // vowel (i), so our heuristic produces 2 instead of 3.
216 assert_eq!(count_syllables("simile"), 2);
217 // "queue" has 2 vowel groups collapsed into 1 by the silent-e rule,
218 // giving 1. (True English count is 1.)
219 assert_eq!(count_syllables("queue"), 1);
220 }
221
222 #[test]
223 fn syllables_ignores_non_alpha_characters() {
224 assert_eq!(count_syllables("don't"), count_syllables("dont"));
225 assert_eq!(count_syllables("cat!"), 1);
226 assert_eq!(count_syllables(" hello "), count_syllables("hello"));
227 }
228
229 #[test]
230 fn syllables_handles_all_vowels_word() {
231 assert_eq!(count_syllables("aeiou"), 1); // single vowel run
232 }
233
234 #[test]
235 fn scores_returns_none_for_empty_input() {
236 let empty = ReadabilityCounts::default();
237 let s = scores(&empty);
238 assert!(s.flesch_reading_ease.is_none());
239 assert!(s.flesch_kincaid_grade.is_none());
240 }
241
242 #[test]
243 fn scores_returns_none_when_sentences_is_zero() {
244 let counts = ReadabilityCounts {
245 words: 10,
246 sentences: 0,
247 syllables: 15,
248 };
249 let s = scores(&counts);
250 assert!(s.flesch_reading_ease.is_none());
251 assert!(s.flesch_kincaid_grade.is_none());
252 }
253
254 #[test]
255 fn scores_returns_none_when_words_is_zero() {
256 let counts = ReadabilityCounts {
257 words: 0,
258 sentences: 1,
259 syllables: 0,
260 };
261 let s = scores(&counts);
262 assert!(s.flesch_reading_ease.is_none());
263 assert!(s.flesch_kincaid_grade.is_none());
264 }
265
266 #[test]
267 fn scores_known_simple_sentence() {
268 // "The cat sat on the mat." — 6 words, 1 sentence, all one-syllable.
269 // FRE = 206.835 - 1.015 * 6 - 84.6 * 1 = 116.145
270 // FKGL = 0.39 * 6 + 11.8 * 1 - 15.59 = -1.45
271 let counts = ReadabilityCounts {
272 words: 6,
273 sentences: 1,
274 syllables: 6,
275 };
276 let s = scores(&counts);
277 assert_close(s.flesch_reading_ease.unwrap(), 116.145, 0.01, "FRE");
278 assert_close(s.flesch_kincaid_grade.unwrap(), -1.45, 0.01, "FKGL");
279 }
280
281 #[test]
282 fn scores_typical_prose() {
283 // Roughly: average English ~15 words/sentence, ~1.5 syllables/word.
284 // FRE = 206.835 - 1.015 * 15 - 84.6 * 1.5 = 64.785 - 126.9 = 64.785
285 // = 206.835 - 15.225 - 126.9 = 64.71
286 // FKGL = 0.39 * 15 + 11.8 * 1.5 - 15.59 = 5.85 + 17.7 - 15.59 = 7.96
287 let counts = ReadabilityCounts {
288 words: 150,
289 sentences: 10,
290 syllables: 225,
291 };
292 let s = scores(&counts);
293 let fre = s.flesch_reading_ease.unwrap();
294 let fkgl = s.flesch_kincaid_grade.unwrap();
295 assert!(
296 (55.0..=75.0).contains(&fre),
297 "FRE for typical prose should be ~65, got {fre}"
298 );
299 assert!(
300 (6.0..=10.0).contains(&fkgl),
301 "FKGL for typical prose should be ~8, got {fkgl}"
302 );
303 }
304}