use serde::Serialize;
use crate::types::{Category, Diagnostic, Severity};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub struct Score {
pub value: u32,
pub max: u32,
}
impl Score {
#[must_use]
pub const fn new(value: u32, max: u32) -> Self {
Self {
value: if value > max { max } else { value },
max,
}
}
#[must_use]
pub const fn perfect(max: u32) -> Self {
Self { value: max, max }
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub struct CategoryScore {
pub category: Category,
pub score: Score,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub struct Scorecard {
pub global: Score,
pub per_category: [CategoryScore; 5],
}
#[derive(Debug, Clone)]
pub struct ScoringConfig {
pub category_max: u32,
pub category_cap: u32,
pub weight_overrides: std::collections::BTreeMap<String, u32>,
}
impl Default for ScoringConfig {
fn default() -> Self {
Self {
category_max: DEFAULT_CATEGORY_MAX,
category_cap: DEFAULT_CATEGORY_CAP,
weight_overrides: std::collections::BTreeMap::new(),
}
}
}
pub const DEFAULT_CATEGORY_MAX: u32 = 20;
pub const DEFAULT_CATEGORY_CAP: u32 = 15;
pub const DENSITY_FLOOR_WORDS: u32 = 200;
#[must_use]
pub const fn severity_multiplier(severity: Severity) -> u32 {
match severity {
Severity::Info => 1,
Severity::Warning => 3,
Severity::Error => 5,
}
}
pub const WEIGHTED_RULE_IDS: &[&str] = &[
"readability.score",
"structure.sentence-too-long",
"structure.paragraph-too-long",
"structure.deep-subordination",
"syntax.passive-voice",
"syntax.unclear-antecedent",
"structure.heading-jump",
"structure.deeply-nested-lists",
"structure.excessive-commas",
"structure.long-enumeration",
"rhythm.consecutive-long-sentences",
"rhythm.repetitive-connectors",
"lexicon.low-lexical-diversity",
"lexicon.excessive-nominalization",
"lexicon.unexplained-abbreviation",
"lexicon.weasel-words",
"lexicon.jargon-undefined",
"syntax.nested-negation",
"syntax.conditional-stacking",
"lexicon.all-caps-shouting",
"structure.line-length-wide",
"structure.mixed-numeric-format",
"lexicon.redundant-intensifier",
"syntax.dense-punctuation-burst",
"lexicon.consonant-cluster",
"structure.italic-span-long",
];
#[must_use]
pub fn default_weight_for(rule_id: &str) -> u32 {
match rule_id {
"readability.score" => 5,
"structure.sentence-too-long"
| "structure.paragraph-too-long"
| "structure.deep-subordination"
| "syntax.passive-voice"
| "syntax.unclear-antecedent"
| "syntax.nested-negation"
| "syntax.conditional-stacking" => 2,
_ => 1,
}
}
#[must_use]
pub fn compute(diagnostics: &[Diagnostic], word_count: u32, config: &ScoringConfig) -> Scorecard {
let density_words = word_count.max(DENSITY_FLOOR_WORDS);
let density_divisor = f64::from(density_words) / 1000.0;
let mut costs: [f64; 5] = [0.0; 5];
for diag in diagnostics {
let weight = config
.weight_overrides
.get(&diag.rule_id)
.copied()
.unwrap_or(diag.weight);
let hit_cost = f64::from(weight * severity_multiplier(diag.severity));
let idx = category_index(diag.category());
costs[idx] += hit_cost;
}
let cap = f64::from(config.category_cap);
let max = config.category_max;
let per_category: [CategoryScore; 5] = core::array::from_fn(|i| {
let normalized = (costs[i] / density_divisor).min(cap).max(0.0);
debug_assert!(
normalized.is_finite() && (0.0..=cap).contains(&normalized),
"scoring invariant violated: normalized={normalized} outside [0, {cap}]",
);
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let cost_u32 = normalized.round() as u32;
let score_value = max.saturating_sub(cost_u32);
CategoryScore {
category: Category::ALL[i],
score: Score::new(score_value, max),
}
});
let global_value: u32 = per_category.iter().map(|c| c.score.value).sum();
let global_max = max * 5;
Scorecard {
global: Score::new(global_value, global_max),
per_category,
}
}
const fn category_index(c: Category) -> usize {
match c {
Category::Structure => 0,
Category::Rhythm => 1,
Category::Lexicon => 2,
Category::Syntax => 3,
Category::Readability => 4,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{Location, Severity, SourceFile};
fn diag(rule: &str, severity: Severity) -> Diagnostic {
Diagnostic::new(
rule,
severity,
Location::new(SourceFile::Anonymous, 1, 1, 1),
"m",
)
}
#[test]
fn zero_diagnostics_scores_perfect() {
let config = ScoringConfig::default();
let card = compute(&[], 1000, &config);
assert_eq!(card.global.value, card.global.max);
assert_eq!(card.global.max, DEFAULT_CATEGORY_MAX * 5);
for cs in &card.per_category {
assert_eq!(cs.score.value, cs.score.max);
}
}
#[test]
fn empty_document_does_not_panic() {
let config = ScoringConfig::default();
let card = compute(&[], 0, &config);
assert_eq!(card.global.value, card.global.max);
}
#[test]
fn single_warning_costs_weight_times_severity_normalized() {
let config = ScoringConfig::default();
let diags = [diag("structure.sentence-too-long", Severity::Warning)];
let card = compute(&diags, 1000, &config);
let structure = card
.per_category
.iter()
.find(|c| c.category == Category::Structure)
.unwrap();
assert_eq!(structure.score.value, 14);
for cs in &card.per_category {
if cs.category != Category::Structure {
assert_eq!(cs.score.value, DEFAULT_CATEGORY_MAX);
}
}
}
#[test]
fn category_cap_limits_runaway_rule() {
let config = ScoringConfig::default();
let diags: Vec<_> = (0..20)
.map(|_| diag("structure.sentence-too-long", Severity::Warning))
.collect();
let card = compute(&diags, 1000, &config);
let structure = card
.per_category
.iter()
.find(|c| c.category == Category::Structure)
.unwrap();
assert_eq!(
structure.score.value,
DEFAULT_CATEGORY_MAX - DEFAULT_CATEGORY_CAP
);
}
#[test]
fn density_floor_protects_short_documents() {
let config = ScoringConfig::default();
let diags = [diag("lexicon.weasel-words", Severity::Warning)];
let card = compute(&diags, 10, &config);
let lex = card
.per_category
.iter()
.find(|c| c.category == Category::Lexicon)
.unwrap();
assert_eq!(lex.score.value, 5);
}
#[test]
fn weight_override_takes_effect() {
let mut config = ScoringConfig::default();
config
.weight_overrides
.insert("lexicon.weasel-words".into(), 10);
let diags = [diag("lexicon.weasel-words", Severity::Info)];
let card = compute(&diags, 1000, &config);
let lex = card
.per_category
.iter()
.find(|c| c.category == Category::Lexicon)
.unwrap();
assert_eq!(lex.score.value, 10);
}
#[test]
fn default_weight_for_prioritises_costly_rules() {
assert_eq!(default_weight_for("readability.score"), 5);
assert_eq!(default_weight_for("structure.sentence-too-long"), 2);
assert_eq!(default_weight_for("lexicon.weasel-words"), 1);
assert_eq!(default_weight_for("unknown"), 1);
}
#[test]
fn severity_multiplier_is_monotonic() {
assert!(severity_multiplier(Severity::Info) < severity_multiplier(Severity::Warning));
assert!(severity_multiplier(Severity::Warning) < severity_multiplier(Severity::Error));
}
}