text-readability-score/src/lib.rs
flemming-it 255d7e2615 fix(clippy): bring source under -D warnings ahead of CI
Prep for the Forgejo CI gate. Adjustments per module are small
and local:

  - test modules get inner `#![allow(clippy::unwrap_used,
    expect_used, panic)]` so the existing assert.expect()
    test idiom keeps working without rewriting every fixture
  - the dead_code field that downstream consumers may still
    want serialised gets an explicit #[allow(dead_code)]
  - manual char/range comparisons fold to the idiomatic forms
    (`['…']`, `(2..=5).contains(&n)`)
  - one snake_case rename in text-readability-score

Also re-bakes module.wasm so the committed artefact matches
the post-fmt source byte-for-byte.

No behaviour change, no test change. cargo fmt --all -- --check
and cargo clippy --all-targets -- -D warnings now both pass.

Signed-off-by: flemming-it <sf@flemming.it>
2026-06-18 12:13:11 +02:00

172 lines
4.7 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! `text.readability_score` — Flesch-DE + Verweistiefe + verb-
//! distance composite.
//!
//! The Flesch formula adapted by Amstad for German:
//! FRE_de = 180 ASL (58.5 × ASW)
//! where ASL = avg sentence length (words), ASW = avg syllables
//! per word. A FRE_de of ~30 reads like "schwer".
//!
//! depth_index is mean inbound citation distance from the
//! citation graph (if supplied). Composite frust scales both
//! to 0..10 and averages.
#![allow(clippy::result_large_err)]
use chain_module_sdk::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize)]
struct InputNorm {
paragraphs: Vec<InputParagraph>,
}
#[derive(Debug, Deserialize)]
struct InputParagraph {
text: String,
}
#[derive(Debug, Deserialize)]
struct InputGraph {
citations: Vec<InputCitation>,
}
#[derive(Debug, Deserialize)]
struct InputCitation {
#[serde(rename = "type")]
kind: String,
}
#[derive(Debug, Serialize)]
struct Output {
flesch_de: f32,
depth_index: f32,
composite_frust: f32,
}
#[fai_module]
pub fn invoke(_ctx: Context, inputs: Inputs) -> Result<Outputs, ModuleError> {
let norm: InputNorm = inputs
.require_json("norm")
.map_err(|e| ModuleError::invalid_input(format!("norm shape: {e}")))?;
let graph_ivm: Option<InputGraph> = inputs.get("graph").and_then(|p| match p {
Payload::Json(s) => serde_json::from_str(s).ok(),
_ => None,
});
let full_text: String = norm
.paragraphs
.iter()
.map(|p| p.text.as_str())
.collect::<Vec<_>>()
.join(" ");
let flesch = flesch_de(&full_text);
let depth = depth_index(graph_ivm.as_ref());
let composite = composite_frust(flesch, depth);
let out = Output {
flesch_de: flesch,
depth_index: depth,
composite_frust: composite,
};
Outputs::new().with_json("score", &out)
}
fn flesch_de(text: &str) -> f32 {
let sentences = count_sentences(text);
let words: Vec<&str> = text
.split(|c: char| c.is_whitespace() || c == ',' || c == ';')
.filter(|w| !w.is_empty())
.collect();
if sentences == 0 || words.is_empty() {
return 0.0;
}
let asl = words.len() as f32 / sentences as f32;
let total_sylls: usize = words.iter().map(|w| count_syllables(w)).sum();
let asw = total_sylls as f32 / words.len() as f32;
(180.0 - asl - 58.5 * asw).clamp(0.0, 100.0)
}
fn count_sentences(text: &str) -> usize {
text.chars()
.filter(|c| matches!(c, '.' | '!' | '?'))
.count()
.max(1)
}
fn count_syllables(word: &str) -> usize {
let mut count = 0;
let mut in_vowel = false;
for c in word.chars() {
let v = matches!(
c.to_ascii_lowercase(),
'a' | 'e' | 'i' | 'o' | 'u' | 'y' | 'ä' | 'ö' | 'ü'
);
if v && !in_vowel {
count += 1;
}
in_vowel = v;
}
count.max(1)
}
fn depth_index(graph: Option<&InputGraph>) -> f32 {
let Some(g) = graph else { return 0.0 };
let n = g.citations.len() as f32;
if n == 0.0 {
0.0
} else {
// crude proxy: each i.V.m.-link counts double — that's where
// the reader has to chase a second norm.
let weighted: f32 = g
.citations
.iter()
.map(|c| if c.kind == "verweist_iVm" { 2.0 } else { 1.0 })
.sum();
(weighted / n).clamp(0.0, 10.0)
}
}
/// Combine 0..100 FRE (high = easy) and 0..10 depth (high = hard).
/// Map to a single 0..10 frust score (high = frustrating).
fn composite_frust(flesch: f32, depth: f32) -> f32 {
let frust_from_flesch = (100.0 - flesch) / 10.0;
((frust_from_flesch + depth) / 2.0).clamp(0.0, 10.0)
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
#[test]
fn easy_german_scores_high_flesch() {
let f = flesch_de("Das ist ein Test. Er ist kurz.");
assert!(f > 60.0, "expected easy text to score > 60, got {f}");
}
#[test]
fn long_compound_german_scores_low_flesch() {
let f = flesch_de(
"Die Datenschutzgrundverordnungsanpassungsdurchführungsverordnung \
betrifft sämtliche Verantwortliche im Geltungsbereich der Verordnung.",
);
assert!(f < 30.0, "expected dense text to score < 30, got {f}");
}
#[test]
fn depth_zero_without_graph() {
assert_eq!(depth_index(None), 0.0);
}
#[test]
fn composite_bounded() {
for f in [0.0_f32, 50.0, 100.0] {
for d in [0.0_f32, 5.0, 10.0] {
let c = composite_frust(f, d);
assert!((0.0..=10.0).contains(&c));
}
}
}
}