#![allow(dead_code)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReadingLevel {
Children,
Standard,
Fast,
Unlimited,
}
impl ReadingLevel {
#[must_use]
pub fn cps_limit(self) -> Option<f64> {
match self {
Self::Children => Some(14.0),
Self::Standard => Some(17.0),
Self::Fast => Some(22.0),
Self::Unlimited => None,
}
}
#[must_use]
pub fn label(self) -> &'static str {
match self {
Self::Children => "children",
Self::Standard => "standard",
Self::Fast => "fast",
Self::Unlimited => "unlimited",
}
}
}
#[derive(Debug, Clone)]
pub struct ReadingSpeedCheck {
pub cue_index: usize,
pub start_ms: i64,
pub end_ms: i64,
pub actual_cps: f64,
pub limit_cps: Option<f64>,
pub violation: bool,
}
impl ReadingSpeedCheck {
#[must_use]
pub fn is_too_fast(&self) -> bool {
self.violation
}
#[must_use]
pub fn excess_cps(&self) -> f64 {
match self.limit_cps {
Some(limit) if self.actual_cps > limit => self.actual_cps - limit,
_ => 0.0,
}
}
}
#[derive(Debug, Clone)]
pub struct SpeedCue {
pub index: usize,
pub start_ms: i64,
pub end_ms: i64,
pub text: String,
}
impl SpeedCue {
#[must_use]
pub fn new(index: usize, start_ms: i64, end_ms: i64, text: impl Into<String>) -> Self {
Self {
index,
start_ms,
end_ms,
text: text.into(),
}
}
#[allow(clippy::cast_precision_loss)]
#[must_use]
pub fn duration_secs(&self) -> f64 {
let dur_ms = (self.end_ms - self.start_ms).max(0);
dur_ms as f64 / 1000.0
}
#[must_use]
pub fn char_count(&self) -> usize {
self.text.chars().filter(|c| !c.is_whitespace()).count()
}
#[allow(clippy::cast_precision_loss)]
#[must_use]
pub fn cps(&self) -> f64 {
let dur = self.duration_secs();
if dur <= 0.0 {
return 0.0;
}
self.char_count() as f64 / dur
}
}
#[derive(Debug, Clone)]
pub struct ReadingSpeedAnalyzer {
pub level: ReadingLevel,
}
impl ReadingSpeedAnalyzer {
#[must_use]
pub fn new(level: ReadingLevel) -> Self {
Self { level }
}
#[must_use]
pub fn analyze(&self, cues: &[SpeedCue]) -> Vec<ReadingSpeedCheck> {
let limit = self.level.cps_limit();
cues.iter()
.map(|cue| {
let actual_cps = cue.cps();
let violation = limit.is_some_and(|l| actual_cps > l);
ReadingSpeedCheck {
cue_index: cue.index,
start_ms: cue.start_ms,
end_ms: cue.end_ms,
actual_cps,
limit_cps: limit,
violation,
}
})
.collect()
}
#[must_use]
pub fn violations(&self, cues: &[SpeedCue]) -> Vec<ReadingSpeedCheck> {
self.analyze(cues)
.into_iter()
.filter(|c| c.violation)
.collect()
}
}
#[derive(Debug, Clone)]
pub struct ReadingSpeedReport {
pub level: ReadingLevel,
pub total_cues: usize,
pub violation_count: usize,
pub max_cps: f64,
pub avg_cps: f64,
}
impl ReadingSpeedReport {
#[must_use]
pub fn from_checks(level: ReadingLevel, checks: &[ReadingSpeedCheck]) -> Self {
let total_cues = checks.len();
let violation_count = checks.iter().filter(|c| c.violation).count();
let max_cps = checks.iter().map(|c| c.actual_cps).fold(0.0f64, f64::max);
let avg_cps = if total_cues == 0 {
0.0
} else {
checks.iter().map(|c| c.actual_cps).sum::<f64>() / total_cues as f64
};
Self {
level,
total_cues,
violation_count,
max_cps,
avg_cps,
}
}
#[allow(clippy::cast_precision_loss)]
#[must_use]
pub fn compliance_pct(&self) -> f64 {
if self.total_cues == 0 {
return 100.0;
}
let compliant = self.total_cues.saturating_sub(self.violation_count);
compliant as f64 / self.total_cues as f64 * 100.0
}
#[must_use]
pub fn is_fully_compliant(&self) -> bool {
self.violation_count == 0
}
}
#[derive(Debug, Clone)]
pub struct WordComplexityConfig {
pub base_char_weight: f64,
pub syllable_penalty: f64,
pub long_word_penalty: f64,
pub long_word_threshold: usize,
pub digit_penalty: f64,
pub punctuation_penalty: f64,
}
impl Default for WordComplexityConfig {
fn default() -> Self {
Self {
base_char_weight: 1.0,
syllable_penalty: 0.3,
long_word_penalty: 0.5,
long_word_threshold: 8,
digit_penalty: 0.2,
punctuation_penalty: 0.1,
}
}
}
#[must_use]
pub fn estimate_syllables(word: &str) -> usize {
if word.is_empty() {
return 0;
}
let lower: Vec<char> = word.chars().map(|c| c.to_ascii_lowercase()).collect();
let vowels = ['a', 'e', 'i', 'o', 'u', 'y'];
let mut count = 0usize;
let mut prev_vowel = false;
for &c in &lower {
let is_v = vowels.contains(&c);
if is_v && !prev_vowel {
count += 1;
}
prev_vowel = is_v;
}
if lower.len() > 2 && lower.last() == Some(&'e') && !vowels.contains(&lower[lower.len() - 2]) {
count = count.saturating_sub(1);
}
count.max(1)
}
#[must_use]
pub fn word_complexity_score(text: &str, config: &WordComplexityConfig) -> f64 {
let mut score = 0.0;
for word in text.split_whitespace() {
let char_count = word.chars().filter(|c| c.is_alphanumeric()).count();
score += char_count as f64 * config.base_char_weight;
let syllables = estimate_syllables(word);
if syllables > 1 {
score += (syllables - 1) as f64 * config.syllable_penalty;
}
if char_count > config.long_word_threshold {
score += config.long_word_penalty;
}
let digit_count = word.chars().filter(|c| c.is_ascii_digit()).count();
score += digit_count as f64 * config.digit_penalty;
let punct_count = word.chars().filter(|c| c.is_ascii_punctuation()).count();
score += punct_count as f64 * config.punctuation_penalty;
}
score
}
#[derive(Debug, Clone)]
pub struct ComplexitySpeedCheck {
pub cue_index: usize,
pub start_ms: i64,
pub end_ms: i64,
pub complexity_score: f64,
pub effective_cps: f64,
pub simple_cps: f64,
pub limit_cps: Option<f64>,
pub violation: bool,
}
#[derive(Debug, Clone)]
pub struct ComplexityReadingSpeedAnalyzer {
pub level: ReadingLevel,
pub config: WordComplexityConfig,
}
impl ComplexityReadingSpeedAnalyzer {
#[must_use]
pub fn new(level: ReadingLevel) -> Self {
Self {
level,
config: WordComplexityConfig::default(),
}
}
#[must_use]
pub fn with_config(level: ReadingLevel, config: WordComplexityConfig) -> Self {
Self { level, config }
}
#[must_use]
pub fn analyze(&self, cues: &[SpeedCue]) -> Vec<ComplexitySpeedCheck> {
let limit = self.level.cps_limit();
cues.iter()
.map(|cue| {
let dur = cue.duration_secs();
let complexity_score = word_complexity_score(&cue.text, &self.config);
let effective_cps = if dur > 0.0 {
complexity_score / dur
} else {
0.0
};
let simple_cps = cue.cps();
let violation = limit.is_some_and(|l| effective_cps > l);
ComplexitySpeedCheck {
cue_index: cue.index,
start_ms: cue.start_ms,
end_ms: cue.end_ms,
complexity_score,
effective_cps,
simple_cps,
limit_cps: limit,
violation,
}
})
.collect()
}
#[must_use]
pub fn violations(&self, cues: &[SpeedCue]) -> Vec<ComplexitySpeedCheck> {
self.analyze(cues)
.into_iter()
.filter(|c| c.violation)
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_cue(index: usize, start_ms: i64, end_ms: i64, text: &str) -> SpeedCue {
SpeedCue::new(index, start_ms, end_ms, text)
}
#[test]
fn test_reading_level_cps_limit_children() {
assert_eq!(ReadingLevel::Children.cps_limit(), Some(14.0));
}
#[test]
fn test_reading_level_cps_limit_unlimited() {
assert_eq!(ReadingLevel::Unlimited.cps_limit(), None);
}
#[test]
fn test_reading_level_labels() {
assert_eq!(ReadingLevel::Standard.label(), "standard");
assert_eq!(ReadingLevel::Fast.label(), "fast");
}
#[test]
fn test_speed_cue_duration_secs() {
let cue = make_cue(0, 0, 2000, "hello");
assert!((cue.duration_secs() - 2.0).abs() < 0.001);
}
#[test]
fn test_speed_cue_char_count_excludes_spaces() {
let cue = make_cue(0, 0, 1000, "hello world");
assert_eq!(cue.char_count(), 10);
}
#[test]
fn test_speed_cue_cps() {
let cue = make_cue(0, 0, 2000, "hello world");
assert!((cue.cps() - 5.0).abs() < 0.01);
}
#[test]
fn test_speed_cue_zero_duration_cps() {
let cue = make_cue(0, 1000, 1000, "hello");
assert_eq!(cue.cps(), 0.0);
}
#[test]
fn test_reading_speed_check_is_too_fast() {
let check = ReadingSpeedCheck {
cue_index: 0,
start_ms: 0,
end_ms: 1000,
actual_cps: 20.0,
limit_cps: Some(17.0),
violation: true,
};
assert!(check.is_too_fast());
assert!((check.excess_cps() - 3.0).abs() < 0.01);
}
#[test]
fn test_reading_speed_check_not_too_fast() {
let check = ReadingSpeedCheck {
cue_index: 0,
start_ms: 0,
end_ms: 1000,
actual_cps: 10.0,
limit_cps: Some(17.0),
violation: false,
};
assert!(!check.is_too_fast());
assert_eq!(check.excess_cps(), 0.0);
}
#[test]
fn test_analyzer_no_violations_slow_text() {
let cues = vec![make_cue(0, 0, 5000, "Hello")];
let analyzer = ReadingSpeedAnalyzer::new(ReadingLevel::Standard);
let violations = analyzer.violations(&cues);
assert!(violations.is_empty());
}
#[test]
fn test_analyzer_detects_violation() {
let cues = vec![make_cue(0, 0, 1000, "abcdefghijklmnopqrstuvwxyz")];
let analyzer = ReadingSpeedAnalyzer::new(ReadingLevel::Standard);
let violations = analyzer.violations(&cues);
assert_eq!(violations.len(), 1);
}
#[test]
fn test_report_compliance_pct_full() {
let cues = vec![
make_cue(0, 0, 5000, "Short"),
make_cue(1, 5000, 10000, "Also short"),
];
let analyzer = ReadingSpeedAnalyzer::new(ReadingLevel::Standard);
let checks = analyzer.analyze(&cues);
let report = ReadingSpeedReport::from_checks(ReadingLevel::Standard, &checks);
assert!((report.compliance_pct() - 100.0).abs() < 0.01);
assert!(report.is_fully_compliant());
}
#[test]
fn test_report_compliance_pct_partial() {
let cues = vec![
make_cue(0, 0, 1000, "abcdefghijklmnopqrstuvwxyz"), make_cue(1, 1000, 6000, "fine"), ];
let analyzer = ReadingSpeedAnalyzer::new(ReadingLevel::Standard);
let checks = analyzer.analyze(&cues);
let report = ReadingSpeedReport::from_checks(ReadingLevel::Standard, &checks);
assert!((report.compliance_pct() - 50.0).abs() < 0.01);
assert!(!report.is_fully_compliant());
}
#[test]
fn test_report_empty_cues() {
let report = ReadingSpeedReport::from_checks(ReadingLevel::Standard, &[]);
assert!((report.compliance_pct() - 100.0).abs() < 0.01);
assert_eq!(report.total_cues, 0);
}
#[test]
fn test_estimate_syllables_one() {
assert_eq!(estimate_syllables("cat"), 1);
assert_eq!(estimate_syllables("dog"), 1);
}
#[test]
fn test_estimate_syllables_two() {
assert_eq!(estimate_syllables("hello"), 2);
assert_eq!(estimate_syllables("water"), 2);
}
#[test]
fn test_estimate_syllables_polysyllabic() {
let s = estimate_syllables("international");
assert!(s >= 4, "got {s} for 'international'");
}
#[test]
fn test_estimate_syllables_empty() {
assert_eq!(estimate_syllables(""), 0);
}
#[test]
fn test_estimate_syllables_single_char() {
assert_eq!(estimate_syllables("a"), 1);
assert_eq!(estimate_syllables("x"), 1);
}
#[test]
fn test_word_complexity_score_simple_text() {
let config = WordComplexityConfig::default();
let score_simple = word_complexity_score("Hello world", &config);
assert!(score_simple > 10.0, "score={score_simple}");
}
#[test]
fn test_word_complexity_score_complex_higher() {
let config = WordComplexityConfig::default();
let simple = word_complexity_score("Hi there", &config);
let complex = word_complexity_score("Internationally unprecedented", &config);
let simple_per_char = simple / 7.0; let complex_per_char = complex / 29.0; assert!(
complex_per_char > simple_per_char,
"complex_pc={complex_per_char}, simple_pc={simple_per_char}"
);
}
#[test]
fn test_word_complexity_score_digits_penalty() {
let config = WordComplexityConfig::default();
let no_digits = word_complexity_score("Hello world", &config);
let with_digits = word_complexity_score("Hello 12345", &config);
assert!(with_digits > no_digits, "digits should add penalty");
}
#[test]
fn test_word_complexity_score_punctuation_penalty() {
let config = WordComplexityConfig::default();
let plain = word_complexity_score("Hello world", &config);
let with_punct = word_complexity_score("Hello, world!!!", &config);
assert!(with_punct > plain, "punctuation should add penalty");
}
#[test]
fn test_complexity_analyzer_no_violation_slow() {
let cues = vec![make_cue(0, 0, 10000, "Hello")];
let analyzer = ComplexityReadingSpeedAnalyzer::new(ReadingLevel::Standard);
let violations = analyzer.violations(&cues);
assert!(violations.is_empty());
}
#[test]
fn test_complexity_analyzer_detects_fast_complex_text() {
let cues = vec![make_cue(
0,
0,
1000,
"Internationally unprecedented characterization",
)];
let analyzer = ComplexityReadingSpeedAnalyzer::new(ReadingLevel::Standard);
let violations = analyzer.violations(&cues);
assert_eq!(violations.len(), 1);
assert!(violations[0].effective_cps > violations[0].simple_cps);
}
#[test]
fn test_complexity_check_fields() {
let cues = vec![make_cue(0, 0, 2000, "Hello world")];
let analyzer = ComplexityReadingSpeedAnalyzer::new(ReadingLevel::Standard);
let checks = analyzer.analyze(&cues);
assert_eq!(checks.len(), 1);
assert!(checks[0].complexity_score > 0.0);
assert!(checks[0].effective_cps > 0.0);
assert_eq!(checks[0].limit_cps, Some(17.0));
assert!(!checks[0].violation);
}
#[test]
fn test_word_complexity_config_custom() {
let config = WordComplexityConfig {
base_char_weight: 2.0,
syllable_penalty: 1.0,
long_word_penalty: 2.0,
long_word_threshold: 4,
digit_penalty: 0.5,
punctuation_penalty: 0.5,
};
let score = word_complexity_score("Hello", &config);
assert!((score - 13.0).abs() < 0.01, "score={score}");
}
#[test]
fn test_word_complexity_empty_text() {
let config = WordComplexityConfig::default();
let score = word_complexity_score("", &config);
assert!((score - 0.0).abs() < 0.01);
}
}