///|
/// Distance added for a trigram missing from a profile.
let max_difference : Int32 = 300
///|
/// The trigram profiles of one script, inverted: for each trigram, the
/// languages whose profile has it and its rank there.
priv struct ScriptIndex {
script : Script
/// The script's languages in franc-min's order.
langs : FixedArray[Lang]
/// Trigram key (see `pack`) -> (position in `langs`, rank) pairs.
postings : Map[Int64, Array[(Int32, Int32)]]
}
///|
/// One index per trigram script, built once. A text trigram is looked up
/// once for all languages, instead of once per language.
let script_indexes : FixedArray[ScriptIndex] = trigram_profiles.map(entry => {
let postings : Map[Int64, Array[(Int32, Int32)]] = Map([])
for i, profile in entry.1 {
for rank, gram in profile.1.split("|").collect() {
let key = key_of(gram)
match postings.get(key) {
Some(list) => list.push((i, rank))
None => postings[key] = [(i, rank)]
}
}
}
{ script: entry.0, langs: entry.1.map(profile => profile.0), postings, }
})
///|
fn allowed(lang : Lang, allow : Array[Lang], deny : Array[Lang]) -> Bool {
(allow.is_empty() || allow.contains(lang)) && !deny.contains(lang)
}
///|
/// Which languages of each script index `allow` and `deny` leave, in the
/// order of `script_indexes`.
fn language_masks(
allow : Array[Lang],
deny : Array[Lang],
) -> FixedArray[FixedArray[Bool]] {
script_indexes.map(index => {
index.langs.map(lang => allowed(lang, allow, deny))
})
}
///|
/// franc's trigram distance from the text's trigrams to every language of
/// `index`: for each trigram of the text, the difference between its count
/// and its rank in the profile, or `max_difference` when the profile lacks
/// it, summed.
fn distances(
grams : Map[Int64, Int32],
index : ScriptIndex,
) -> FixedArray[Int32] {
let totals = FixedArray::make(
index.langs.length(),
grams.length() * max_difference,
)
for key, count in grams {
guard index.postings.get(key) is Some(list) else { continue }
for posting in list {
let (i, rank) = posting
let diff = count - rank - 1
totals[i] += (if diff < 0 { -diff } else { diff }) - max_difference
}
}
totals
}
///|
/// Result of franc's `francAll`, without the `und` entry: the script that
/// decided the outcome and the languages with normalised scores, best
/// first, for the text that `stats` describe. `None` where franc returns
/// `und`. `masks` are the languages that `allow` and `deny` leave, per
/// script index (see `language_masks`).
fn franc_masked(
stats : TextStats,
allow : Array[Lang],
deny : Array[Lang],
masks : FixedArray[FixedArray[Bool]],
min_length : Int32,
) -> (Script, Array[(Lang, Double)])? {
if stats.length == 0 || stats.length < min_length {
return None
}
let top = stats.top_script()
if top < 0 {
return None
}
let script = all_scripts[top]
if script.sole_language() is Some(lang) {
return if allowed(lang, allow, deny) {
Some((script, [(lang, 1.0)]))
} else {
None
}
}
let found : Array[(Lang, Int32, Int32)] = []
for k, index in script_indexes {
if index.script != script {
continue
}
let mask = masks[k]
let totals = distances(stats.grams, index)
for i, lang in index.langs {
if mask[i] {
found.push((lang, totals[i], found.length()))
}
}
}
if found.is_empty() {
return None
}
found.sort_by((a, b) => if a.1 != b.1 { a.1 - b.1 } else { a.2 - b.2 })
let min = found[0].1
let max = stats.sample_length * max_difference - min
let ranked = found.map(d => {
let score = 1.0 - (d.1 - min).to_double() / max.to_double()
(d.0, if score.is_nan() { 0.0 } else { score })
})
Some((script, ranked))
}