///|
/// One step of the decision, as recorded by `explain`.
pub(all) enum Step {
/// franc's trigram distance ranked this language first.
Trigrams(Lang)
/// The shares of kana, Han and Hangul decided Chinese, Japanese or Korean.
ScriptShare(Lang)
/// In a short text, these common words moved the language to the top.
CommonWords(Lang, Array[String])
/// These letters and words settled the language among related ones.
RelatedLanguages(Lang, Array[String])
/// A near tie with the second language, broken for the language with
/// more speakers.
NearTie(Lang, Lang)
} derive(Eq, Debug)
///|
/// How one trigram of the text weighed between the detected language and
/// the runner-up in franc's distance.
pub(all) struct TrigramScore {
/// Three UTF-16 code units of the cleaned, lowercased text; spaces mark
/// word boundaries.
trigram : String
/// Occurrences in the text.
count : Int32
/// Rank in the detected language's profile (0 is the most frequent),
/// `None` if the profile lacks it.
rank : Int32?
/// Rank in the runner-up's profile.
runner_up_rank : Int32?
/// The runner-up's distance for this trigram minus the detected
/// language's: positive when it favours the detected language.
gain : Int32
} derive(Eq, Debug)
///|
/// Why `detect` decided as it did.
pub(all) struct Explanation {
/// What `detect` returns for the text.
detection : Detection?
/// franc's choice, then each pass of `Mode::Enhanced` that changed the
/// language at the top, in order.
steps : Array[Step]
/// The trigrams of the text that weigh differently for the detected
/// language and the runner-up, those that favour the detected language
/// most first. Empty when there is no runner-up or the languages were
/// not ranked by trigrams.
trigrams : Array[TrigramScore]
}
///|
/// The common words of `lang` among `words`, in order of first occurrence.
fn common_words_of(lang : Lang, words : Map[String, Int32]) -> Array[String] {
guard stop_words.search_by(entry => entry.0 == lang) is Some(i) else {
return []
}
let list = stop_words[i].1
words.keys().filter(word => list.contains(word)).collect()
}
///|
/// The letters and words among `words` that point to `lang` rather than
/// to `previous`, franc's choice before, in their group of related
/// languages.
fn group_clues(
lang : Lang,
previous : Lang,
words : Map[String, Int32],
script : Script,
) -> Array[String] {
guard group_of(previous, script) is Some(group) else { return [] }
let found = []
let marks = (target : Lang, letters : String) => {
group.markers.iter().any(m => m.0 == target && m.1 == letters)
}
for marker in group.markers {
let (target, letters, _) = marker
if target == lang &&
!marks(previous, letters) &&
words.keys().any(word => occurrences(word, letters) > 0) &&
!found.contains(letters) {
found.push(letters)
}
}
let list_of = (target : Lang) => {
match group.words.search_by(e => e.0 == target) {
Some(i) => group.words[i].1
None => []
}
}
let own = list_of(lang)
let other = list_of(previous)
for word in words.keys() {
if own.contains(word) && !other.contains(word) {
found.push(word)
}
}
found
}
///|
/// The rank of trigram `key` in the profile of the language at position
/// `i` of `index`.
fn rank_in(index : ScriptIndex, key : Int64, i : Int32) -> Int32? {
guard index.postings.get(key) is Some(list) else { return None }
for posting in list {
if posting.0 == i {
return Some(posting.1)
}
}
None
}
///|
/// franc's distance for one trigram that occurs `count` times.
fn trigram_distance(count : Int32, rank : Int32?) -> Int32 {
match rank {
Some(r) => (count - r - 1).abs()
None => max_difference
}
}
///|
/// The trigrams of `text` scored for `lang` against `runner_up` in the
/// profiles of `script`.
fn trigram_scores(
text : String,
script : Script,
lang : Lang,
runner_up : Lang,
) -> Array[TrigramScore] {
let mut found = None
for index in script_indexes {
if index.script == script {
found = Some(index)
}
}
guard found is Some(index) else { return [] }
let position = (target : Lang) => {
let mut at = None
for i, l in index.langs {
if l == target {
at = Some(i)
}
}
at
}
guard position(lang) is Some(a) && position(runner_up) is Some(b) else {
return []
}
let sample = if text.length() > max_sample_length {
text.view(end_offset=max_sample_length).to_owned()
} else {
text
}
let scores : Array[TrigramScore] = []
for gram in trigram_keys(sample) {
let (key, count) = gram
let rank = rank_in(index, key, a)
let runner_up_rank = rank_in(index, key, b)
let gain = trigram_distance(count, runner_up_rank) -
trigram_distance(count, rank)
if gain != 0 {
scores.push({ trigram: unpack(key), count, rank, runner_up_rank, gain, })
}
}
// Highest gain first; equal gains keep the order of the text's trigrams.
let order = scores.mapi((i, score) => (i, score))
order.sort_by((x, y) => {
if x.1.gain != y.1.gain {
y.1.gain - x.1.gain
} else {
x.0 - y.0
}
})
order.map(entry => entry.1)
}
///|
/// Why this detector identifies `text` as it does: the detection, the
/// steps that decided it and the trigrams that weigh for and against it.
pub fn Detector::explain(self : Detector, text : String) -> Explanation {
let detection = self.detect(text)
let prepared = prepared(text, self.options)
let steps = []
let ranked = ranking(
TextStats::of(prepared, with_words=self.options.mode == Enhanced),
self.options,
self.masks,
steps~,
)
let trigrams = match (detection, ranked) {
(Some({ lang, runner_up: Some(other), script, .. }), Some(_)) =>
trigram_scores(prepared, script, lang, other)
_ => []
}
{ detection, steps, trigrams, }
}
///|
/// Why `detect` identifies `text` as it does under `options`; see
/// `Detector::explain`.
pub fn explain(
text : String,
options~ : Options = default_options,
) -> Explanation {
detector_for(options).explain(text)
}