Skip to main content

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}