feat: initial text-readability-score v0.1.0 (text.readability_score@0.1.0)

Composite readability/complexity score: Flesch-DE + Verweistiefe
+ verb-distance. Reuse-lens: any project that needs to flag
"this text is hard to read" — government letters, AGB, scientific
abstracts.

Flesch-DE follows the Amstad adaptation:

  FRE_de = 180 − ASL − (58.5 × ASW)

ASL = avg sentence length (words); ASW = avg syllables per word.
A FRE_de around 30 reads as "schwer"; below 10 is technical-jargon
territory.

Verweistiefe uses an optional graph input (output of
graph.citation_extract). i.V.m.-edges count double because they
force the reader to chase a second norm.

composite_frust scales both to 0..10 and averages, so the output
is a single number the heatmap can plot against the §6 Studie
"Frust"-Achse.

Pure in-WASM, zero filesystem, zero network.

Reserved for next versions:

  - 0.2: noun-density and Schachtelsatz penalty
  - 0.3: per-paragraph breakdown so the UI can highlight the
         most-frustrating Absatz

Signed-off-by: flemming-it <sf@flemming.it>
This commit is contained in:
flemming-it 2026-06-18 11:25:13 +02:00
commit 2c3a6be513
6 changed files with 693 additions and 0 deletions

168
src/lib.rs Normal file
View file

@ -0,0 +1,168 @@
//! `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 {
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));
}
}
}
}