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:
commit
2c3a6be513
6 changed files with 693 additions and 0 deletions
168
src/lib.rs
Normal file
168
src/lib.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue