Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ hasami は、MeCab などの外部の解析エンジンを使わずに Rust で
- **外部エンジンに依存しない**: MeCab や Sudachi を呼ばず、辞書の構築から解析までを hasami だけで行います
- **ラティスと Viterbi**: 辞書の語と未知語の候補をラティスに並べ、接続コストと単語コストの和が最小になる分け方を選びます
- **並列解析**: `hasami tokenize` は標準入力の行を CPU の数だけ並列に解析します。1 スレッドの API と Python バインディングの実測は [ベンチマーク](#ベンチマーク) にあります
- **読みと発音**: トークンごとに読みと発音を返します。文脈で読みが変わる語(「他」「数」など)、数詞と助数詞の音便(「三本」「一回」「三百」など)、1〜2 文字の英字の略語(AI・PC など)は、解析の後で読みを直します
- **読みと発音**: トークンごとに読みと発音を返します。数量の半角数字は位取りで読みます(10分 → ジュップン、1,000円 → センエン)。文脈で読みが変わる語(「他」「数」など)、数詞と助数詞の音便(「三本」「一回」「三百」など)、1〜2 文字の英字の略語(AI・PC など)も、解析の後で読みを直します
- **MeCab 形式の辞書から作る**: MeCab 形式の CSV と matrix.def・char.def・unk.def から辞書を作ります。配布辞書は IPAdic、IPAdic + NEologd、IPAdic + NEologd + SudachiDict の 3 つで、SudachiDict は IPAdic の品詞体系に写して足します。UniDic は手元でビルドできます
- **辞書のマージと修復**: 既存の辞書に MeCab 形式の CSV を足せます。`hasami repair` は、誤読や誤った品詞の元になるエントリを直すか取り除きます
- **すぐに読み込める辞書**: 辞書は mmap でそのまま参照する形式(.hsd v5)です。読み込むときはヘッダと小さな表だけを検査し、本体は解析で触れたページだけを読みます
Expand Down
23 changes: 21 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,27 @@ IPAdic で未知の普通名詞になる「ぷん」は、仮名から補完し
一日のツイタチは日付と期間を区別できないので補正せず、
二十日のハツカも現在のトークンに分配しない。小数点(点・`.`・`.`)以下には音便・和語読みを適用しない。
一語の「十分」の直後に「前」「後」が隣接する場合はジュップンに直すが、「十分に」「十分な」は変えない。
補正は読み・発音だけを変更し、表層形・品詞・原形・位置・コスト・辞書由来の情報を保つ。
複数桁の ASCII 数字を位取りで読む処理は、この数詞と助数詞の補正には含まれない。
数詞と助数詞の音便・和語読みの補正は読み・発音だけを変更し、表層形・品詞・原形・位置・コスト・辞書由来の情報を保つ。
ASCII 数字は、助数詞・年度・位の数詞(百・千・万・億・兆)や許可した英字単位に隣接する数量だけを位取りで読む。
`10分` はジュップン、`2031年度` はニセンサンジューイチネンド、`1,000円` はセンエン、`2.5倍` はニテンゴバイになる。
原入力から数や識別子の候補全体を調べてから、数の範囲だけを一つのトークンにする。カンマは整数部の正常な3桁区切りだけを
認め、小数点は1つで両側に数字が必要。整数部・小数部はそれぞれ16桁まで(総数32桁まで)で、整数は兆まで扱う。
小数部は1桁ずつ読み、小数の後の助数詞には音便・和語読みを適用しない。整数の一・八・十の後の兆は促音化する。
この位取り読みと兆の促音補正は、保護した識別子や不正な数字列には適用しない。従来の1桁数字と助数詞の音便は維持する。
兆の直前の数詞列は純ASCIIまたは純漢数字に限り、全角数字や混在した列(`18兆`・`1八兆`)の既存出力は保つ。

単独の数、先頭ゼロの数字列、符号付きの数、電話・郵便番号、IP・版番号、不正なカンマや複数の小数点は合成しない。
英字の識別子の後の数は空白・改行越しでも保護し、数字列の中に全角数字や漢数字を含む混在表記(`1234` など)も合成しない。
英字単位は既存の単位表で仮名の読みを持つ語との完全一致だけを認め、`10ms` を `10m` として部分認識しない。
数と単位が空白・改行で離れている場合も合成しない。一方、`10万2031円` のように和文の単位でつながる数量はそれぞれ読む。
Analyzer の前分割や文ごとの API でも元の入力を使って判定する。`LatticeWorkspace::tokenize_into` を直接呼ぶ場合は
渡した入力だけを文脈として使い、既存の出力は判定・変更の対象にしない。

合成トークンの表層形と原形はカンマ・小数点を含む原入力の部分文字列、位置はその全体のバイト範囲になる。
品詞は `名詞,数,*,*`、活用型・活用形は空、`is_known` は false。
`word_cost` は元トークンのコストを広い整数型で足し、最後に一度だけ i16 の範囲に飽和させる。最良パス決定後の情報であり、
経路の再探索には使わない。もともと数詞1トークンなら既存の素性を維持し、非空の既知の読みも尊重する。
`2026年度`・`2.5倍` のように数と単位を含む既知語も、分割や素性を変更しない。
対応表は [Open JTalk 1.11 の数詞規則](https://github.com/r9y9/open_jtalk/blob/1.11/src/njd_set_digit/njd_set_digit_rule_utf_8.h)
を参考に、トークンの読みを補正する独立した処理として実装している。

Expand Down
26 changes: 21 additions & 5 deletions src/analyzer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,8 @@ impl Analyzer {
/// テキストを形態素解析する。辞書に不正な参照を見つけたらエラーを返す
pub fn try_tokenize(&mut self, input: &str) -> Result<Vec<Token>, DictError> {
let mut tokens = Vec::with_capacity(input.len() / 3);
self.tokenize_chunks(input, 0, &mut tokens)?;
let plan = crate::numeric::AsciiPlan::new(input, 0);
self.tokenize_chunks(input, 0, &mut tokens, &plan, &mut 0)?;
Ok(tokens)
}

Expand All @@ -133,15 +134,22 @@ impl Analyzer {
input: &str,
offset: usize,
out: &mut Vec<Token>,
plan: &crate::numeric::AsciiPlan,
cursor: &mut usize,
) -> Result<(), DictError> {
let mut ends = std::mem::take(&mut self.chunk_ends);
self.splitter.chunk_ends_into(input, &mut ends);
let mut start = 0;
let mut result = Ok(());
for &end in &ends {
result =
self.workspace
.tokenize_into(&input[start..end], &self.dict, offset + start, out);
result = self.workspace.tokenize_into_prepared(
&input[start..end],
&self.dict,
offset + start,
out,
plan,
cursor,
);
if result.is_err() {
break;
}
Expand Down Expand Up @@ -173,11 +181,19 @@ impl Analyzer {
options: &SplitOptions<'_>,
) -> Result<Vec<(Sentence, Vec<Token>)>, DictError> {
let splitter = Splitter::new(options);
let plan = crate::numeric::AsciiPlan::new(text, 0);
let mut cursor = 0;
let mut out = Vec::new();
for sentence in splitter.split(text) {
let mut tokens = Vec::new();
let range = sentence.range.clone();
self.tokenize_chunks(&text[range.clone()], range.start, &mut tokens)?;
self.tokenize_chunks(
&text[range.clone()],
range.start,
&mut tokens,
&plan,
&mut cursor,
)?;
out.push((sentence, tokens));
}
Ok(out)
Expand Down
42 changes: 38 additions & 4 deletions src/lattice.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,9 @@ pub struct Token {
pub reading: Arc<str>,
/// 発音
pub pronunciation: Arc<str>,
/// 単語コスト
/// 単語コスト。合成した ASCII 数詞は元トークンのコストの総和(i16 の範囲に飽和)
pub word_cost: i16,
/// 辞書由来 (true=辞書, false=未知語)
/// 辞書由来 (true=辞書, false=未知語または合成した ASCII 数詞)
pub is_known: bool,
}

Expand Down Expand Up @@ -344,7 +344,15 @@ fn apply_decimal_counter_override(token: &mut Token, after_decimal_point: bool)
/// - アルファベット: 数字トークンと隙間なく隣接 → 単位読み(該当する場合)、なければアルファベット読み
/// - 繰り返し記号「々」: 直前のトークンの読み(そこだけ無音になるのを防ぐ)
/// - 仮名のみの語: 表層形をカタカナ化した読み(未知語で読みが空になるケースの救済)
#[cfg(all(test, feature = "build"))]
fn apply_contextual_readings(tokens: &mut [Token]) {
apply_contextual_readings_with_protection(tokens, &[]);
}

fn apply_contextual_readings_with_protection(
tokens: &mut [Token],
protected: &[std::ops::Range<usize>],
) {
for i in 0..tokens.len() {
if tokens[i].surface.is_empty() {
continue;
Expand Down Expand Up @@ -408,7 +416,11 @@ fn apply_contextual_readings(tokens: &mut [Token]) {
tokens[i].pronunciation = kana;
}
}
crate::numeric::apply_counter_readings(tokens);
if protected.is_empty() {
crate::numeric::apply_counter_readings(tokens);
} else {
crate::numeric::apply_counter_readings_with_protection(tokens, protected);
}
}

/// 仮名のみからなる表層形をカタカナの読みに変換する。
Expand Down Expand Up @@ -1030,6 +1042,19 @@ impl LatticeWorkspace {
dict: &Dictionary,
offset: usize,
out: &mut Vec<Token>,
) -> Result<(), DictError> {
let plan = crate::numeric::AsciiPlan::new(input, offset);
self.tokenize_into_prepared(input, dict, offset, out, &plan, &mut 0)
}

pub(crate) fn tokenize_into_prepared(
&mut self,
input: &str,
dict: &Dictionary,
offset: usize,
out: &mut Vec<Token>,
plan: &crate::numeric::AsciiPlan,
cursor: &mut usize,
) -> Result<(), DictError> {
if input.is_empty() {
return Ok(());
Expand Down Expand Up @@ -1241,7 +1266,16 @@ impl LatticeWorkspace {
}
});
}
apply_contextual_readings(&mut out[first..]);
let protected = crate::numeric::apply_ascii_quantities(
input,
offset,
out,
first,
plan,
cursor,
|surface| unit_reading(surface).is_some_and(is_katakana_str),
);
apply_contextual_readings_with_protection(&mut out[first..], &protected);
Ok(())
}
}
Expand Down
Loading
Loading