use std::collections::HashMap;
use rkyv::{Archive, Deserialize as RkyvDeserialize, Serialize as RkyvSerialize};
use serde::{Deserialize, Serialize};
use crate::LinderaResult;
use crate::dictionary::character_definition::{CategoryId, CharacterDefinition};
use crate::dictionary::schema::Schema;
use crate::dictionary::{Dictionary, UserDictionary};
use crate::error::LinderaErrorKind;
use crate::viterbi::{LexType, WordId};
pub const POS_FIELD_CANDIDATES: [&str; 2] = ["part_of_speech_tag", "part_of_speech"];
const COMMON_FIELD_COUNT: usize = 4;
#[derive(
Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Archive, RkyvSerialize, RkyvDeserialize,
)]
#[non_exhaustive]
pub struct SpacePenaltyRule {
pub pos: Vec<String>,
pub cost: i32,
}
impl SpacePenaltyRule {
pub fn new<S: Into<String>>(pos: impl IntoIterator<Item = S>, cost: i32) -> Self {
Self {
pos: pos.into_iter().map(Into::into).collect(),
cost,
}
}
}
#[derive(
Clone,
Debug,
Default,
PartialEq,
Eq,
Serialize,
Deserialize,
Archive,
RkyvSerialize,
RkyvDeserialize,
)]
#[non_exhaustive]
pub struct SpacePenaltyConfig {
pub rules: Vec<SpacePenaltyRule>,
}
impl SpacePenaltyConfig {
pub fn new(rules: Vec<SpacePenaltyRule>) -> Self {
Self { rules }
}
pub fn cost_for_tag(&self, pos_tag: &str) -> i32 {
let first = first_pos(pos_tag);
self.rules
.iter()
.find(|rule| rule.pos.iter().any(|p| p == first))
.map_or(0, |rule| rule.cost)
}
}
#[inline]
pub fn first_pos(pos_tag: &str) -> &str {
pos_tag.split('+').next().unwrap_or(pos_tag)
}
pub fn pos_detail_index(schema: &Schema) -> LinderaResult<usize> {
POS_FIELD_CANDIDATES
.iter()
.find_map(|name| schema.get_field_index(name))
.and_then(|index| index.checked_sub(COMMON_FIELD_COUNT))
.ok_or_else(|| {
LinderaErrorKind::Dictionary.with_error(anyhow::anyhow!(
"space penalty requires a part-of-speech field ({}) in the dictionary schema",
POS_FIELD_CANDIDATES.join(" or ")
))
})
}
#[derive(Clone, Debug)]
pub struct SpacePenaltyTable {
system: Vec<u8>,
user: Vec<u8>,
unknown: Vec<u8>,
costs: Vec<i32>,
space_ascii: [bool; 256],
space_category: Option<CategoryId>,
}
impl SpacePenaltyTable {
pub fn build(
config: &SpacePenaltyConfig,
dictionary: &Dictionary,
user_dictionary: Option<&UserDictionary>,
) -> LinderaResult<Self> {
if config.rules.len() > u8::MAX as usize {
return Err(LinderaErrorKind::Args.with_error(anyhow::anyhow!(
"space penalty supports at most {} rules, got {}",
u8::MAX,
config.rules.len()
)));
}
let pos_index = pos_detail_index(&dictionary.metadata.dictionary_schema)?;
let mut costs = Vec::with_capacity(config.rules.len() + 1);
costs.push(0);
costs.extend(config.rules.iter().map(|rule| rule.cost));
let mut rule_of_tag: HashMap<&str, u8> = HashMap::new();
for (i, rule) in config.rules.iter().enumerate().rev() {
for pos in &rule.pos {
rule_of_tag.insert(pos.as_str(), (i + 1) as u8);
}
}
let rule_for = |pos_tag: Option<&str>| -> u8 {
pos_tag
.and_then(|tag| rule_of_tag.get(first_pos(tag)))
.copied()
.unwrap_or(0)
};
let system = (0..dictionary.prefix_dictionary.word_count())
.map(|id| rule_for(dictionary.word_details_iter(id).nth(pos_index)))
.collect();
let user = user_dictionary.map_or_else(Vec::new, |ud| {
(0..ud.dict.word_count())
.map(|id| rule_for(ud.word_details_iter(id).nth(pos_index)))
.collect()
});
let unknown = (0..dictionary.unknown_dictionary.costs.len())
.map(|id| rule_for(dictionary.unknown_word_details_iter(id).nth(pos_index)))
.collect();
let char_definitions = &dictionary.character_definition;
let space_category = char_definitions.category_id_by_name("SPACE");
let mut space_ascii = [false; 256];
for (codepoint, is_space) in space_ascii.iter_mut().enumerate() {
if let Some(c) = char::from_u32(codepoint as u32) {
*is_space = match space_category {
Some(space_id) => char_definitions.lookup_categories(c).contains(&space_id),
None => c.is_whitespace(),
};
}
}
Ok(Self {
system,
user,
unknown,
costs,
space_ascii,
space_category,
})
}
#[inline]
pub fn is_space(&self, c: char, char_definitions: &CharacterDefinition) -> bool {
let codepoint = c as u32;
if codepoint < 256 {
self.space_ascii[codepoint as usize]
} else {
match self.space_category {
Some(space_id) => char_definitions.lookup_categories(c).contains(&space_id),
None => c.is_whitespace(),
}
}
}
#[inline]
pub fn cost(&self, word_id: WordId) -> i32 {
let table = match word_id.lex_type() {
LexType::System => &self.system,
LexType::User => &self.user,
LexType::Unknown => &self.unknown,
};
let rule = table.get(word_id.id() as usize).copied().unwrap_or(0);
self.costs[rule as usize]
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ko_dic_rules() -> SpacePenaltyConfig {
SpacePenaltyConfig::new(vec![
SpacePenaltyRule::new(
["EC", "EF", "EP", "ETM", "ETN", "VCP", "XSA", "XSN", "XSV"],
3000,
),
SpacePenaltyRule::new(
["JC", "JKB", "JKC", "JKG", "JKO", "JKQ", "JKS", "JKV", "JX"],
6000,
),
])
}
#[test]
fn first_pos_takes_the_part_before_the_first_plus() {
assert_eq!(first_pos("NNG"), "NNG");
assert_eq!(first_pos("VV+EC"), "VV");
assert_eq!(first_pos("XSV+ETM"), "XSV");
assert_eq!(first_pos(""), "");
}
#[test]
fn cost_for_tag_matches_on_the_first_pos() {
let config = ko_dic_rules();
assert_eq!(config.cost_for_tag("JKB"), 6000);
assert_eq!(config.cost_for_tag("EP"), 3000);
assert_eq!(config.cost_for_tag("EP+EC"), 3000);
assert_eq!(config.cost_for_tag("VCP+EP"), 3000);
assert_eq!(config.cost_for_tag("VV+EC"), 0);
assert_eq!(config.cost_for_tag("NNG"), 0);
}
#[test]
fn cost_for_tag_prefers_the_first_matching_rule() {
let config = SpacePenaltyConfig::new(vec![
SpacePenaltyRule::new(["JKS"], 100),
SpacePenaltyRule::new(["JKS", "JX"], 200),
]);
assert_eq!(config.cost_for_tag("JKS"), 100);
assert_eq!(config.cost_for_tag("JX"), 200);
}
#[test]
fn config_round_trips_through_json() {
let config = ko_dic_rules();
let json = serde_json::to_string(&config).unwrap();
let back: SpacePenaltyConfig = serde_json::from_str(&json).unwrap();
assert_eq!(back, config);
let parsed: SpacePenaltyConfig = serde_json::from_str(
r#"{"rules":[{"pos":["JKS"],"cost":6000},{"pos":["EP"],"cost":3000}]}"#,
)
.unwrap();
assert_eq!(parsed.rules.len(), 2);
assert_eq!(parsed.cost_for_tag("JKS"), 6000);
}
#[test]
fn constructors_set_every_field() {
let rule = SpacePenaltyRule::new(["JKS", "JX"], 6000);
assert_eq!(rule.pos, vec!["JKS".to_string(), "JX".to_string()]);
assert_eq!(rule.cost, 6000);
let config = SpacePenaltyConfig::new(vec![rule.clone()]);
assert_eq!(config.rules, vec![rule]);
let empty = SpacePenaltyConfig::default();
assert!(empty.rules.is_empty());
assert_eq!(empty.cost_for_tag("JKS"), 0);
}
#[test]
fn pos_detail_index_prefers_ko_dic_then_generic_name() {
let ko = Schema::new(
[
"surface",
"left_context_id",
"right_context_id",
"cost",
"part_of_speech_tag",
]
.iter()
.map(|s| s.to_string())
.collect(),
);
assert_eq!(pos_detail_index(&ko).unwrap(), 0);
let generic = Schema::new(
[
"surface",
"left_context_id",
"right_context_id",
"cost",
"display_surface",
"part_of_speech",
]
.iter()
.map(|s| s.to_string())
.collect(),
);
assert_eq!(pos_detail_index(&generic).unwrap(), 1);
let none = Schema::new(
[
"surface",
"left_context_id",
"right_context_id",
"cost",
"reading",
]
.iter()
.map(|s| s.to_string())
.collect(),
);
assert!(pos_detail_index(&none).is_err());
}
}