mirror of
https://git.pumpin.online/funkpower/catena.git
synced 2026-10-23 03:07:13 +00:00
236 lines
7.7 KiB
Rust
236 lines
7.7 KiB
Rust
//! What language a document is in.
|
|
//!
|
|
//! Two passes, because one does not do. The writing system settles most of
|
|
//! the world outright — a text in Hangul is Korean — and for the Latin and
|
|
//! Cyrillic alphabets it only narrows the field, so the commonest words
|
|
//! decide between what is left.
|
|
//!
|
|
//! No model, no download, no network. Function words make up a third of any
|
|
//! running text, and on a paragraph or more counting them is decisive. On a
|
|
//! single line it is not, which is why every answer carries a confidence and
|
|
//! why "could not tell" is one of the answers.
|
|
|
|
pub mod script;
|
|
pub mod words;
|
|
|
|
use schemars::JsonSchema;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
pub use script::Script;
|
|
|
|
/// How much text is worth looking at.
|
|
///
|
|
/// A book's language does not change after the first few pages, and reading
|
|
/// all of it to say so is waste.
|
|
pub const SAMPLE_CHARS: usize = 20_000;
|
|
|
|
/// What language a text turned out to be.
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
|
pub struct Verdict {
|
|
/// Record version.
|
|
#[serde(default = "version")]
|
|
pub v: u32,
|
|
/// What was read.
|
|
pub path: String,
|
|
/// ISO 639-1, the two-letter code. Absent when it could not be told.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub language: Option<String>,
|
|
/// ISO 639-2/B, which is what the `doc` record carries.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub bibliographic: Option<String>,
|
|
/// What the language is called in English.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub name: Option<String>,
|
|
/// The writing system, which is an answer even when the language is not.
|
|
pub script: String,
|
|
/// How far ahead the winner was, from zero to one. One means nothing else
|
|
/// scored at all.
|
|
#[serde(with = "corpus_core::finite")]
|
|
#[schemars(with = "f64")]
|
|
pub confidence: f64,
|
|
/// What was worth saying about the reading.
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub notes: Vec<String>,
|
|
}
|
|
|
|
fn version() -> u32 {
|
|
corpus_core::V
|
|
}
|
|
|
|
impl corpus_core::Record for Verdict {
|
|
fn version(&self) -> u32 {
|
|
self.v
|
|
}
|
|
}
|
|
|
|
/// Read a text's language.
|
|
pub fn detect(path: &str, text: &str) -> Verdict {
|
|
let sample: String = text.chars().take(SAMPLE_CHARS).collect();
|
|
let (script, share) = script::detect(&sample);
|
|
|
|
let mut verdict = Verdict {
|
|
v: corpus_core::V,
|
|
path: path.to_string(),
|
|
language: None,
|
|
bibliographic: None,
|
|
name: None,
|
|
script: name_of(script).to_string(),
|
|
confidence: 0.0,
|
|
notes: Vec::new(),
|
|
};
|
|
|
|
// A script only one language uses is the whole answer, and no amount of
|
|
// word counting would improve on it.
|
|
if let Some(sole) = sole_language(script) {
|
|
verdict.language = Some(sole.0.to_string());
|
|
verdict.bibliographic = Some(sole.1.to_string());
|
|
verdict.name = Some(sole.2.to_string());
|
|
verdict.confidence = share;
|
|
verdict
|
|
.notes
|
|
.push(format!("{} is written only in {}", sole.2, name_of(script)));
|
|
return verdict;
|
|
}
|
|
|
|
let scored = words::score(&sample, script);
|
|
let Some((best, best_score)) = scored.first() else {
|
|
verdict.notes.push(match script {
|
|
Script::Other => "no letters to go on".to_string(),
|
|
script => format!(
|
|
"nothing here knows the languages written in {}",
|
|
name_of(script)
|
|
),
|
|
});
|
|
return verdict;
|
|
};
|
|
|
|
if *best_score == 0.0 {
|
|
verdict
|
|
.notes
|
|
.push("none of the commonest words of any language it knows".to_string());
|
|
return verdict;
|
|
}
|
|
|
|
// How far ahead of the runner-up, so a close call can be refused.
|
|
let runner_up = scored.get(1).map_or(0.0, |(_, score)| *score);
|
|
let ratio = match runner_up > 0.0 {
|
|
true => best_score / runner_up,
|
|
false => f64::INFINITY,
|
|
};
|
|
if ratio < words::MARGIN {
|
|
verdict.notes.push(format!(
|
|
"{} and {} score too alike to choose between",
|
|
best.name, scored[1].0.name
|
|
));
|
|
return verdict;
|
|
}
|
|
|
|
verdict.language = Some(best.code.to_string());
|
|
verdict.bibliographic = Some(best.bibliographic.to_string());
|
|
verdict.name = Some(best.name.to_string());
|
|
verdict.confidence = match runner_up > 0.0 {
|
|
true => (1.0 - runner_up / best_score).clamp(0.0, 1.0),
|
|
false => 1.0,
|
|
};
|
|
verdict
|
|
}
|
|
|
|
/// The one language a script is used for, where there is only one.
|
|
fn sole_language(script: Script) -> Option<(&'static str, &'static str, &'static str)> {
|
|
Some(match script {
|
|
Script::Kana => ("ja", "jpn", "Japanese"),
|
|
Script::Han => ("zh", "chi", "Chinese"),
|
|
Script::Hangul => ("ko", "kor", "Korean"),
|
|
Script::Hebrew => ("he", "heb", "Hebrew"),
|
|
Script::Arabic => ("ar", "ara", "Arabic"),
|
|
Script::Devanagari => ("hi", "hin", "Hindi"),
|
|
_ => return None,
|
|
})
|
|
}
|
|
|
|
/// What a script is called.
|
|
pub fn name_of(script: Script) -> &'static str {
|
|
match script {
|
|
Script::Latin => "Latin",
|
|
Script::Cyrillic => "Cyrillic",
|
|
Script::Greek => "Greek",
|
|
Script::Arabic => "Arabic",
|
|
Script::Hebrew => "Hebrew",
|
|
Script::Han => "Han",
|
|
Script::Kana => "Kana",
|
|
Script::Hangul => "Hangul",
|
|
Script::Devanagari => "Devanagari",
|
|
Script::Other => "unknown",
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn a_paragraph_of_english_is_english() {
|
|
let found = detect(
|
|
"x.md",
|
|
"The obligation ran both ways, and contemporaries said so plainly whenever they were asked about it at all.",
|
|
);
|
|
assert_eq!(found.language.as_deref(), Some("en"));
|
|
assert_eq!(found.bibliographic.as_deref(), Some("eng"));
|
|
assert_eq!(found.script, "Latin");
|
|
assert!(found.confidence > 0.0);
|
|
}
|
|
|
|
#[test]
|
|
fn a_script_only_one_language_uses_needs_no_words() {
|
|
for (text, code) in [
|
|
("안녕하세요 세계 반갑습니다 오늘은", "ko"),
|
|
("これは日本語の文章です", "ja"),
|
|
("מרחבי שלום עולם וברוך הבא", "he"),
|
|
] {
|
|
assert_eq!(detect("x", text).language.as_deref(), Some(code), "{text}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn nothing_to_read_is_no_answer_rather_than_a_wrong_one() {
|
|
let found = detect("x", "12345 -- ...");
|
|
assert_eq!(found.language, None);
|
|
assert_eq!(found.script, "unknown");
|
|
assert_eq!(found.notes, ["no letters to go on"]);
|
|
}
|
|
|
|
#[test]
|
|
fn latin_words_no_language_claims_are_refused() {
|
|
let found = detect("x", "zzzz qqqq xxxx vvvv wwww kkkk jjjj");
|
|
assert_eq!(found.language, None);
|
|
assert!(found.notes[0].starts_with("none of the commonest words"));
|
|
}
|
|
|
|
#[test]
|
|
fn a_close_call_is_refused_rather_than_guessed() {
|
|
// "de" is among the commonest words of four of these languages at
|
|
// once, and on its own settles none of them.
|
|
let found = detect("x", "de de de");
|
|
assert_eq!(found.language, None, "{:?}", found.notes);
|
|
assert!(found.notes[0].contains("too alike"), "{:?}", found.notes);
|
|
}
|
|
|
|
#[test]
|
|
fn only_the_opening_is_read() {
|
|
let long = format!(
|
|
"{}{}",
|
|
"The obligation ran both ways. ".repeat(2000),
|
|
"xyzzy"
|
|
);
|
|
assert!(long.len() > SAMPLE_CHARS * 2);
|
|
assert_eq!(detect("x", &long).language.as_deref(), Some("en"));
|
|
}
|
|
|
|
#[test]
|
|
fn the_script_is_reported_even_when_the_language_is_not() {
|
|
let found = detect("x", "zzzz qqqq xxxx vvvv");
|
|
assert_eq!(found.script, "Latin");
|
|
assert_eq!(found.language, None);
|
|
}
|
|
}
|