From 7731682095acfa10e3789d95a6ccb1e416f3a3aa Mon Sep 17 00:00:00 2001 From: Yohei <20108042+owayo@users.noreply.github.com> Date: Tue, 13 Oct 2026 06:14:08 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=8D=8A=E8=A7=92=E3=81=AE=E6=95=B0?= =?UTF-8?q?=E9=87=8F=E3=82=92=E4=BD=8D=E5=8F=96=E3=82=8A=E3=81=A7=E8=AA=AD?= =?UTF-8?q?=E3=82=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 半角数字の数量が一桁ずつの読みになっていたため、元入力で数量と識別子を判定し、明確な数量の数詞範囲を最良パスの後でまとめる。 カンマ区切りと小数、百・千・万・億・兆を扱い、数詞と助数詞の補正につなげる。 登録済みの辞書語を優先し、番号・不正な表記・空白で離れた単位を保護する。合成した数詞は原入力の表層・位置を保持し、コストを合計する。 --- README.md | 2 +- docs/architecture.md | 23 +- src/analyzer.rs | 26 +- src/lattice.rs | 42 +- src/numeric.rs | 140 ++++++- src/numeric/ascii.rs | 907 +++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 1117 insertions(+), 23 deletions(-) create mode 100644 src/numeric/ascii.rs diff --git a/README.md b/README.md index d60bfcd..99b2720 100644 --- a/README.md +++ b/README.md @@ -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)です。読み込むときはヘッダと小さな表だけを検査し、本体は解析で触れたページだけを読みます diff --git a/docs/architecture.md b/docs/architecture.md index 4098eb1..147be23 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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) を参考に、トークンの読みを補正する独立した処理として実装している。 diff --git a/src/analyzer.rs b/src/analyzer.rs index efd863a..064e5f3 100644 --- a/src/analyzer.rs +++ b/src/analyzer.rs @@ -119,7 +119,8 @@ impl Analyzer { /// テキストを形態素解析する。辞書に不正な参照を見つけたらエラーを返す pub fn try_tokenize(&mut self, input: &str) -> Result, 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) } @@ -133,15 +134,22 @@ impl Analyzer { input: &str, offset: usize, out: &mut Vec, + 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; } @@ -173,11 +181,19 @@ impl Analyzer { options: &SplitOptions<'_>, ) -> Result)>, 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) diff --git a/src/lattice.rs b/src/lattice.rs index 2044886..0e810aa 100644 --- a/src/lattice.rs +++ b/src/lattice.rs @@ -56,9 +56,9 @@ pub struct Token { pub reading: Arc, /// 発音 pub pronunciation: Arc, - /// 単語コスト + /// 単語コスト。合成した ASCII 数詞は元トークンのコストの総和(i16 の範囲に飽和) pub word_cost: i16, - /// 辞書由来 (true=辞書, false=未知語) + /// 辞書由来 (true=辞書, false=未知語または合成した ASCII 数詞) pub is_known: bool, } @@ -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], +) { for i in 0..tokens.len() { if tokens[i].surface.is_empty() { continue; @@ -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); + } } /// 仮名のみからなる表層形をカタカナの読みに変換する。 @@ -1030,6 +1042,19 @@ impl LatticeWorkspace { dict: &Dictionary, offset: usize, out: &mut Vec, + ) -> 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, + plan: &crate::numeric::AsciiPlan, + cursor: &mut usize, ) -> Result<(), DictError> { if input.is_empty() { return Ok(()); @@ -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(()) } } diff --git a/src/numeric.rs b/src/numeric.rs index ff1bfbf..09b30c0 100644 --- a/src/numeric.rs +++ b/src/numeric.rs @@ -1,11 +1,15 @@ -//! 最良パスの数詞と助数詞の読み。トークンの境界・品詞・辞書由来の情報は変えない。 +//! 最良パスの数詞と助数詞の読み。ASCII の数量だけは数詞列を合成する。 //! -//! 音便は助数詞ごとの対応表で決める。人・日の和語読みだけは数詞列全体を見る。 +//! 音便は助数詞ごとの対応表で決め、読み以外の情報を保持する。人・日の和語読みは数詞列全体を見る。 //! 一日は日付と期間を区別できないので変更せず、二十日のハツカも分配しない。 use crate::lattice::Token; +use std::ops::Range; use std::sync::Arc; +mod ascii; +pub(crate) use ascii::{AsciiPlan, apply as apply_ascii_quantities}; + #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum Ending { One, @@ -37,6 +41,7 @@ enum Counter { Person, Day, Hour, + Trillion, } fn pos_has(pos: &str, prefix: &str) -> bool { @@ -77,10 +82,11 @@ fn place(c: char) -> Option { fn is_numeral(token: &Token) -> bool { pos_has(&token.pos, "名詞,数") && !token.surface.is_empty() - && token - .surface - .chars() - .all(|c| digit(c).is_some() || place(c).is_some()) + && (ascii::integer_value(&token.surface).is_some() + || token + .surface + .chars() + .all(|c| digit(c).is_some() || place(c).is_some())) } /// 位を含む数と、位を省いた数字列を読む。桁あふれ・位の逆転は曖昧な数として拒む。 @@ -122,7 +128,11 @@ fn numeral_value<'a>(surfaces: impl Iterator) -> Option { } fn ending(surface: &str) -> Option { - let value = numeral_value(std::iter::once(surface))?; + let value = if surface.contains(',') { + ascii::integer_value(surface)? + } else { + numeral_value(std::iter::once(surface))? + }; Some(match value % 10 { 1 => Ending::One, 2 => Ending::Two, @@ -153,6 +163,7 @@ fn counter(token: &Token) -> Option { Some(Counter::Hundred) } "千" if has_reading(token, &["セン", "ゼン"]) => Some(Counter::Thousand), + "兆" if has_reading(token, &["チョウ", "チョー"]) => Some(Counter::Trillion), _ => None, }; } @@ -271,15 +282,20 @@ fn numeral_glyph_readings( fn standard_number_readings(token: &Token, tails: &[&str]) -> bool { let mut chars = token.surface.chars().peekable(); - let Some(first) = chars.next() else { + let Some(_) = chars.next() else { return false; }; if chars.peek().is_none() { return has_reading(token, tails); } - // ASCII・全角の複数桁や混在表記は、数全体を読める処理を導入するまで辞書の読みを保つ。 - let mut chars = std::iter::once(first).chain(chars).peekable(); - let (mut reading, mut pronunciation) = (token.reading.as_ref(), token.pronunciation.as_ref()); + if token.surface.is_ascii() { + return ascii::standard_readings(&token.surface, &token.reading, &token.pronunciation); + } + standard_kanji_readings(&token.surface, &token.reading, &token.pronunciation) +} + +fn standard_kanji_readings(surface: &str, mut reading: &str, mut pronunciation: &str) -> bool { + let mut chars = surface.chars().peekable(); let mut previous = None; while let Some(c) = chars.next() { let allowed = numeral_glyph_readings(previous, c, chars.peek().copied()); @@ -412,16 +428,45 @@ fn ten_minutes(tokens: &mut [Token], i: usize) { /// 仮名などの読み補完の後に呼ぶ。数詞列をまとめず、読みと発音だけを変更する。 pub(crate) fn apply_counter_readings(tokens: &mut [Token]) { + apply_counter_readings_with_protection(tokens, &[]); +} + +pub(crate) fn apply_counter_readings_with_protection( + tokens: &mut [Token], + protected: &[Range], +) { let mut start = 0; + let mut protection = 0; + let (mut run_ascii, mut run_kanji, mut run_protected) = (true, true, false); for i in 0..tokens.len() { ten_minutes(tokens, i); if i == 0 || tokens[i - 1].end != tokens[i].start || !is_numeral(&tokens[i - 1]) { start = i; + (run_ascii, run_kanji, run_protected) = (true, true, false); continue; } + let previous = &tokens[i - 1]; + run_ascii &= previous.surface.is_ascii(); + run_kanji &= previous.surface.chars().all(|c| { + matches!( + c, + '〇' | '零' | '一' | '二' | '三' | '四' | '五' | '六' | '七' | '八' | '九' + ) || place(c).is_some() + }); + while protection < protected.len() && protected[protection].end <= previous.start { + protection += 1; + } + let previous_protected = protected + .get(protection) + .is_some_and(|range| range.start < previous.end && previous.start < range.end); + run_protected |= previous_protected; let Some(counter) = counter(&tokens[i]) else { continue; }; + // 兆の新しい促音化は、保護されていない純ASCIIまたは純漢数字の列だけに適用する。 + if matches!(counter, Counter::Trillion) && (run_protected || !(run_ascii || run_kanji)) { + continue; + } if after_decimal(tokens, start) { continue; } @@ -429,6 +474,11 @@ pub(crate) fn apply_counter_readings(tokens: &mut [Token]) { continue; }; let tails = number_tails(e); + // 既存の1桁数字の音便は保ち、新たな複数字の補正だけを保護する。 + if tokens[i - 1].surface.is_ascii() && tokens[i - 1].surface.len() > 1 && previous_protected + { + continue; + } if !standard_number_readings(&tokens[i - 1], tails) { continue; } @@ -501,6 +551,11 @@ pub(crate) fn apply_counter_readings(tokens: &mut [Token]) { } _ => set_readings(suffix, "セン", "セン"), }, + Counter::Trillion => { + if matches!(e, Ending::One | Ending::Eight | Ending::Ten) { + geminate(number, e); + } + } Counter::Person => { if person { let r = if e == Ending::One { "ヒト" } else { "フタ" }; @@ -679,6 +734,69 @@ mod tests { } } + #[test] + fn protected_ascii_numbers_skip_new_euphony_and_keep_existing_single_digit_rules() { + for (n, nr, c, cr, expected) in [ + ("1", "イチ", "兆", "チョウ", "イチチョウ"), + ("8", "ハチ", "兆", "チョウ", "ハチチョウ"), + ("10", "ジュウ", "兆", "チョウ", "ジュウチョウ"), + ("11", "ジュウイチ", "本", "ホン", "ジュウイチホン"), + ("1", "イチ", "本", "ホン", "イッポン"), + ("8", "ハチ", "分", "フン", "ハップン"), + ] { + let cpos = if c == "兆" { NUMBER } else { COUNTER }; + let mut out = tokens(&[(n, NUMBER, nr), (c, cpos, cr)]); + let fields = non_reading_fields(&out); + let protected = out[0].start..out[0].end; + apply_counter_readings_with_protection(&mut out, std::slice::from_ref(&protected)); + assert_eq!(readings(&out), (expected.into(), expected.into()), "{n}{c}"); + assert_eq!(non_reading_fields(&out), fields, "{n}{c}"); + } + } + + #[test] + fn trillion_euphony_requires_a_uniform_supported_number_run() { + for parts in [ + vec![("1", NUMBER, "イチ"), ("8", NUMBER, "ハチ")], + vec![("1", NUMBER, "イチ"), ("八", NUMBER, "ハチ")], + vec![("18", NUMBER, "イチハチ")], + vec![("1八", NUMBER, "イチハチ")], + vec![("8", NUMBER, "ハチ")], + vec![("18", NUMBER, "ジュウハチ")], + vec![("1", NUMBER, "イチ"), ("8", NUMBER, "ハチ")], + ] { + let mut parts = parts; + parts.push(("兆", NUMBER, "チョウ")); + let mut out = tokens(&parts); + let before = format!("{out:?}"); + apply_counter_readings(&mut out); + assert_eq!(format!("{out:?}"), before, "{parts:?}"); + } + for (n, reading, expected) in [ + ("1", "イチ", "イッチョウ"), + ("8", "ハチ", "ハッチョウ"), + ("10", "ジュウ", "ジュッチョウ"), + ("一", "イチ", "イッチョウ"), + ("八", "ハチ", "ハッチョウ"), + ("十", "ジュウ", "ジュッチョウ"), + ] { + let mut out = tokens(&[(n, NUMBER, reading), ("兆", NUMBER, "チョウ")]); + let fields = non_reading_fields(&out); + apply_counter_readings(&mut out); + assert_eq!(readings(&out), (expected.into(), expected.into()), "{n}兆"); + assert_eq!(non_reading_fields(&out), fields, "{n}兆"); + } + let mut out = tokens(&[ + ("1", NUMBER, "イチ"), + ("8", NUMBER, "ハチ"), + ("兆", NUMBER, "チョウ"), + ]); + let before = format!("{out:?}"); + let protected = out[0].start..out[0].end; + apply_counter_readings_with_protection(&mut out, std::slice::from_ref(&protected)); + assert_eq!(format!("{out:?}"), before); + } + #[test] fn compound_numerals_require_a_standard_reading_for_the_entire_word() { for (surface, reading, pronunciation, expected_r, expected_p) in [ diff --git a/src/numeric/ascii.rs b/src/numeric/ascii.rs new file mode 100644 index 0000000..d544790 --- /dev/null +++ b/src/numeric/ascii.rs @@ -0,0 +1,907 @@ +//! ASCII 数量の構文検査と位取り。識別子の候補全体を先に調べ、途中の数字だけを変換しない。 + +use crate::lattice::Token; +use std::ops::Range; +use std::sync::Arc; + +#[derive(Clone, Copy, Debug)] +struct Number { + integer: u64, + dot: Option, +} + +/// 整数16桁、小数部16桁。カンマは整数部の正しい3桁区切りだけを許す。 +fn parse(raw: &str) -> Option { + let (mut integer, mut integer_digits, mut fraction_digits) = (0u64, 0usize, 0usize); + let (mut group, mut comma, mut dot) = (0usize, false, None); + for (i, b) in raw.bytes().enumerate() { + match b { + b'0'..=b'9' if dot.is_none() => { + integer_digits += 1; + group += 1; + if integer_digits > 16 { + return None; + } + integer = integer * 10 + u64::from(b - b'0'); + } + b'0'..=b'9' => { + fraction_digits += 1; + if fraction_digits > 16 { + return None; + } + } + b',' if dot.is_none() => { + if group == 0 || (!comma && group > 3) || (comma && group != 3) { + return None; + } + comma = true; + group = 0; + } + b'.' if dot.is_none() && integer_digits > 0 && (!comma || group == 3) => dot = Some(i), + _ => return None, + } + } + if integer_digits == 0 + || (comma && group != 3) + || (dot.is_some() && fraction_digits == 0) + || (raw.starts_with('0') && integer_digits != 1) + { + return None; + } + Some(Number { integer, dot }) +} + +pub(super) fn integer_value(raw: &str) -> Option { + let parsed = parse(raw)?; + parsed.dot.is_none().then_some(parsed.integer) +} + +fn kanji(integer: u64) -> String { + if integer == 0 { + return "零".into(); + } + const DIGITS: &[char; 10] = &['〇', '一', '二', '三', '四', '五', '六', '七', '八', '九']; + let mut out = String::new(); + for (divisor, large) in [ + (1_000_000_000_000, "兆"), + (100_000_000, "億"), + (10_000, "万"), + (1, ""), + ] { + let mut group = integer / divisor % 10_000; + if group == 0 { + continue; + } + for (small, unit) in [(1_000, '千'), (100, '百'), (10, '十')] { + let n = group / small; + if n != 0 { + if n != 1 { + out.push(DIGITS[n as usize]); + } + out.push(unit); + } + group %= small; + } + if group != 0 { + out.push(DIGITS[group as usize]); + } + out.push_str(large); + } + out +} + +pub(super) fn standard_readings(raw: &str, reading: &str, pronunciation: &str) -> bool { + let Some(integer) = integer_value(raw) else { + return false; + }; + super::standard_kanji_readings(&kanji(integer), reading, pronunciation) +} + +fn readings(raw: &str, number: Number) -> (String, String) { + let spelling = kanji(number.integer); + let mut chars = spelling.chars().peekable(); + let (mut reading, mut pronunciation) = (String::new(), String::new()); + let mut previous = None; + while let Some(c) = chars.next() { + let kana = super::numeral_glyph_readings(previous, c, chars.peek().copied())[0]; + reading.push_str(kana); + pronunciation.push_str(match kana { + "ジュウ" => "ジュー", + "キュウ" => "キュー", + "チョウ" => "チョー", + _ => kana, + }); + previous = Some(c); + } + if let Some(dot) = number.dot { + reading.push_str("テン"); + pronunciation.push_str("テン"); + for b in raw[dot + 1..].bytes() { + let kana = [ + "ゼロ", + "イチ", + "ニ", + "サン", + "ヨン", + "ゴ", + "ロク", + "ナナ", + "ハチ", + "キュウ", + ][(b - b'0') as usize]; + reading.push_str(kana); + pronunciation.push_str(if kana == "キュウ" { + "キュー" + } else { + kana + }); + } + } + (reading, pronunciation) +} + +#[derive(Debug)] +struct Candidate { + number: Range, + envelope: Range, + parsed: Option, +} + +#[derive(Default, Debug)] +pub(crate) struct AsciiPlan { + candidates: Vec, +} + +fn envelope_byte(b: u8) -> bool { + b.is_ascii_alphanumeric() || b"_.,:+*/=-%^".contains(&b) +} + +impl AsciiPlan { + /// 位置は offset を加えた入力全体の位置。空白越しの英字・裸の数字も識別子の前置とみなす。 + pub(crate) fn new(input: &str, offset: usize) -> Self { + if !input.bytes().any(|b| b.is_ascii_digit()) { + return Self::default(); + } + let bytes = input.as_bytes(); + let (mut pos, mut prefix) = (0, false); + let mut plan = Self::default(); + while pos < bytes.len() { + if envelope_byte(bytes[pos]) { + let start = pos; + while pos < bytes.len() && envelope_byte(bytes[pos]) { + pos += 1; + } + let block = &input[start..pos]; + if let Some(first) = block.bytes().position(|b| b.is_ascii_digit()) { + let end = first + + block[first..] + .bytes() + .take_while(|b| b.is_ascii_digit() || matches!(b, b',' | b'.')) + .count(); + let suffix = &block[end..]; + let allowed_suffix = suffix.is_empty() + || suffix == "%" + || suffix.bytes().all(|b| b.is_ascii_alphabetic()); + let parsed = if first == 0 && !prefix && allowed_suffix { + parse(&block[..end]) + } else { + None + }; + plan.candidates.push(Candidate { + number: offset + start + first..offset + start + end, + envelope: offset + start..offset + pos, + parsed, + }); + } + prefix = block.bytes().any(|b| b.is_ascii_alphanumeric()); + } else { + let c = input[pos..].chars().next().unwrap(); + if matches!( + c, + '-' | '−' + | '‐' + | '‑' + | '‒' + | '–' + | '—' + | '―' + | '.' + | ',' + | '/' + | '∕' + | '÷' + | '×' + | '+' + ) { + prefix = true; + } else if !c.is_whitespace() && !(prefix && matches!(c, '(' | '[' | '(' | '[')) + { + prefix = matches!(c, 'A'..='Z' | 'a'..='z' | '0'..='9' | '〇' | '零' | '一' | '二' | '三' | '四' | '五' | '六' | '七' | '八' | '九'); + } + pos += c.len_utf8(); + } + } + plan + } +} + +fn number_pos(t: &Token) -> bool { + super::pos_has(&t.pos, "名詞,数") +} + +fn quantity(t: &Token, unit: impl Fn(&str) -> bool) -> bool { + super::pos_has(&t.pos, "名詞,接尾,助数詞") + || (t.surface.as_ref() == "年度" && super::pos_has(&t.pos, "名詞")) + || (number_pos(t) && matches!(t.surface.as_ref(), "百" | "千" | "万" | "億" | "兆")) + || unit(&t.surface) +} + +fn unit_boundary(input: &str, offset: usize, t: &Token) -> bool { + let Some(end) = t.end.checked_sub(offset) else { + return false; + }; + input.get(end..).is_some_and(|rest| rest.chars().next().is_none_or(|c| { + !c.is_ascii_alphanumeric() && !matches!(c, '_' | '^' | '/' | '*' | '+' | '-' | '=' | '²' | '³' | 'A'..='Z' | 'a'..='z' | '0'..='9') + })) +} + +/// 範囲 first.. だけを線形に圧縮する。保護した範囲は後段の数詞補正にも渡す。 +pub(crate) fn apply( + input: &str, + offset: usize, + out: &mut Vec, + first: usize, + plan: &AsciiPlan, + cursor: &mut usize, + unit: impl Fn(&str) -> bool, +) -> Vec> { + let mut protected = Vec::new(); + let (mut read, mut write) = (first, first); + let end = offset + input.len(); + while *cursor < plan.candidates.len() && plan.candidates[*cursor].envelope.end <= offset { + *cursor += 1; + } + while *cursor < plan.candidates.len() { + let candidate = &plan.candidates[*cursor]; + if candidate.envelope.start >= end { + break; + } + // 候補全体を含むチャンクまで進めず、各チャンクの後段にも同じ保護範囲を渡す。 + if candidate.envelope.end > end { + protected.push(candidate.envelope.clone()); + break; + } + *cursor += 1; + while read < out.len() && out[read].end <= candidate.number.start { + out.swap(write, read); + read += 1; + write += 1; + } + let start = read; + let mut limit = read; + while limit < out.len() && out[limit].end <= candidate.number.end { + limit += 1; + } + let eligible = candidate.parsed.is_some() + && start < limit + && out[start].start == candidate.number.start + && out[limit - 1].end == candidate.number.end + && out[start..limit] + .iter() + .all(|t| number_pos(t) || matches!(t.surface.as_ref(), "," | ".")) + && out[start..limit].windows(2).all(|p| p[0].end == p[1].start) + && out.get(limit).is_some_and(|t| { + t.start == candidate.number.end + && quantity(t, &unit) + && (candidate.envelope.end == candidate.number.end + || t.end == candidate.envelope.end) + && (!unit(&t.surface) || unit_boundary(input, offset, t)) + }); + if !eligible { + protected.push(candidate.envelope.clone()); + continue; + } + let raw = &input[candidate.number.start - offset..candidate.number.end - offset]; + if limit - start == 1 { + let token = &mut out[start]; + // 非空の既知の読みは尊重する。未知語と欠けたフィールドだけ位取りで補う。 + if !token.is_known || token.reading.is_empty() || token.pronunciation.is_empty() { + let (reading, pronunciation) = readings(raw, candidate.parsed.unwrap()); + if !token.is_known || token.reading.is_empty() { + token.reading = Arc::from(reading.as_str()); + } + if !token.is_known || token.pronunciation.is_empty() { + token.pronunciation = Arc::from(pronunciation.as_str()); + } + } + out.swap(write, start); + } else { + let (reading, pronunciation) = readings(raw, candidate.parsed.unwrap()); + let cost: i64 = out[start..limit] + .iter() + .map(|t| i64::from(t.word_cost)) + .sum(); + let surface: Arc = Arc::from(raw); + out[write] = Token { + base_form: Arc::clone(&surface), + surface, + start: candidate.number.start, + end: candidate.number.end, + pos: Arc::from("名詞,数,*,*"), + conj_type: Arc::from(""), + conj_form: Arc::from(""), + reading: Arc::from(reading), + pronunciation: Arc::from(pronunciation), + word_cost: cost.clamp(i64::from(i16::MIN), i64::from(i16::MAX)) as i16, + is_known: false, + }; + } + read = limit; + write += 1; + } + while read < out.len() { + out.swap(write, read); + read += 1; + write += 1; + } + out.truncate(write); + protected +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn quantity_grammar_limits_and_number_notation() { + for raw in [ + "0", + "10", + "2031", + "1,000", + "2.5", + "0.5", + "1234567890123456", + "1234567890123456.1234567890123456", + ] { + assert!(parse(raw).is_some(), "{raw}"); + } + for raw in [ + "", + "00", + "01", + "00.5", + ".5", + "5.", + "1,23", + "1,,000", + "1234,000", + "1.2.3", + "2.5,000", + "12345678901234567", + "0.12345678901234567", + ] { + assert!(parse(raw).is_none(), "{raw}"); + } + } + + #[test] + fn positional_readings_include_places_internal_zeroes_and_euphony() { + for (raw, r, p) in [ + ("10", "ジュウ", "ジュー"), + ("2031", "ニセンサンジュウイチ", "ニセンサンジューイチ"), + ("1,000", "セン", "セン"), + ("2.5", "ニテンゴ", "ニテンゴ"), + ("1010", "センジュウ", "センジュー"), + ("300", "サンビャク", "サンビャク"), + ("600", "ロッピャク", "ロッピャク"), + ("8000", "ハッセン", "ハッセン"), + ("10000", "イチマン", "イチマン"), + ("100000000", "イチオク", "イチオク"), + ("1000000000000", "イッチョウ", "イッチョー"), + ("8000000000000", "ハッチョウ", "ハッチョー"), + ("10000000000000", "ジュッチョウ", "ジュッチョー"), + ("0.09", "ゼロテンゼロキュウ", "ゼロテンゼロキュー"), + ] { + assert_eq!( + readings(raw, parse(raw).unwrap()), + (r.into(), p.into()), + "{raw}" + ); + } + } + + fn input_tokens(text: &str, offset: usize) -> Vec { + text.char_indices() + .map(|(i, c)| { + let surface = c.to_string(); + Token { + surface: surface.clone().into(), + start: offset + i, + end: offset + i + c.len_utf8(), + pos: if c.is_ascii_digit() { + "名詞,数,*,*" + } else if matches!(c, '円' | '分' | '倍') { + "名詞,接尾,助数詞,*" + } else { + "記号,一般,*,*" + } + .into(), + base_form: surface.into(), + conj_type: "".into(), + conj_form: "".into(), + reading: "".into(), + pronunciation: "".into(), + word_cost: 100, + is_known: true, + } + }) + .collect() + } + + #[test] + fn compression_covers_the_original_number_and_preserves_existing_output() { + let text = "1,000円2.5倍"; + let mut out = input_tokens("先", 0); + let previous = format!("{:?}", out[0]); + out.extend(input_tokens(text, 91)); + apply( + text, + 91, + &mut out, + 1, + &AsciiPlan::new(text, 91), + &mut 0, + |_| false, + ); + assert_eq!(format!("{:?}", out[0]), previous); + assert_eq!(out[1].surface.as_ref(), "1,000"); + assert_eq!(out[1].pronunciation.as_ref(), "セン"); + assert_eq!(out[1].word_cost, 500); + assert!(!out[1].is_known); + assert_eq!(out[3].surface.as_ref(), "2.5"); + assert_eq!(out[3].pronunciation.as_ref(), "ニテンゴ"); + for t in &out[1..] { + assert_eq!(&text[t.start - 91..t.end - 91], t.surface.as_ref()); + } + } + + #[test] + fn crossing_envelopes_remain_protected_in_every_intersecting_chunk() { + for parts in [&["ISO.", "8兆"][..], &["ISO.", ".", "8兆"][..]] { + let input = parts.concat(); + let offset = 91; + let plan = AsciiPlan::new(&input, offset); + let envelope = offset..offset + input.len() - "兆".len(); + let mut cursor = 0; + let mut position = offset; + let mut out = input_tokens("先", 0); + for (i, part) in parts.iter().enumerate() { + let first = out.len(); + out.extend(input_tokens(part, position)); + let before = format!("{out:?}"); + let protected = apply(part, position, &mut out, first, &plan, &mut cursor, |_| { + false + }); + assert_eq!(protected.as_slice(), std::slice::from_ref(&envelope)); + assert_eq!(format!("{out:?}"), before); + assert_eq!(cursor, usize::from(i + 1 == parts.len())); + position += part.len(); + } + } + } + + #[test] + fn identifiers_and_malformed_runs_are_preserved_as_a_whole() { + for text in [ + "03-1234-5678円", + "123-4567円", + "123-4567円", + "123−4567円", + "ISO9001円", + "ISO 9001円", + "ISO\n9001円", + "v1.2.3円", + "192.168.0.1円", + "1,23円", + "1.2.3円", + "1.23円", + "1,234円", + "0010分", + "1e3円", + "10m2", + "10ms", + "1234円", + "1234円", + "−10円", + ] { + let mut out = input_tokens(text, 0); + let before = format!("{out:?}"); + apply( + text, + 0, + &mut out, + 0, + &AsciiPlan::new(text, 0), + &mut 0, + |s| s == "m", + ); + assert_eq!(format!("{out:?}"), before, "{text}"); + } + let text = format!("ISO{}9001円", ".1".repeat(100_000)); + assert_eq!(AsciiPlan::new(&text, 0).candidates.len(), 1); + } + + #[test] + fn synthetic_cost_is_summed_widely_and_saturated_only_once() { + for (costs, expected) in [ + ([30000, 30000, -30000], 30000), + ([32000, 32000, 32000], i16::MAX), + ([-32000, -32000, -32000], i16::MIN), + ] { + let text = "123円"; + let mut out = input_tokens(text, 0); + for (t, cost) in out.iter_mut().zip(costs) { + t.word_cost = cost; + } + apply( + text, + 0, + &mut out, + 0, + &AsciiPlan::new(text, 0), + &mut 0, + |_| false, + ); + assert_eq!(out[0].word_cost, expected); + } + } +} + +#[cfg(all(test, feature = "build"))] +mod analyzer_tests { + use super::*; + use crate::dict::{DictBuilder, DictEntry}; + use crate::{Analyzer, Dictionary}; + + fn dictionary() -> Dictionary { + let mut builder = DictBuilder::new(); + for (n, r, p) in [ + ("0", "ゼロ", "ゼロ"), + ("1", "イチ", "イチ"), + ("2", "ニ", "ニ"), + ("3", "サン", "サン"), + ("4", "ヨン", "ヨン"), + ("5", "ゴ", "ゴ"), + ("6", "ロク", "ロク"), + ("7", "ナナ", "ナナ"), + ("8", "ハチ", "ハチ"), + ("9", "キュウ", "キュー"), + ("一", "イチ", "イチ"), + ("八", "ハチ", "ハチ"), + ("十", "ジュウ", "ジュー"), + ("1", "イチ", "イチ"), + ("8", "ハチ", "ハチ"), + ] { + builder.add_entry(DictEntry { + surface: n.into(), + pos: "名詞,数,*,*".into(), + reading: r.into(), + pronunciation: p.into(), + ..Default::default() + }); + } + for (surface, pos, r, p, cost) in [ + ("分", "名詞,接尾,助数詞,*", "フン", "フン", 0), + ("本", "名詞,接尾,助数詞,*", "ホン", "ホン", 0), + ("円", "名詞,接尾,助数詞,*", "エン", "エン", 0), + ("倍", "名詞,接尾,助数詞,*", "バイ", "バイ", 0), + ("人", "名詞,接尾,助数詞,*", "ニン", "ニン", 0), + ("日", "名詞,接尾,助数詞,*", "ニチ", "ニチ", 0), + ("年度", "名詞,一般,*,*", "ネンド", "ネンド", 0), + ("万", "名詞,数,*,*", "マン", "マン", 0), + ("兆", "名詞,数,*,*", "チョウ", "チョー", 0), + ("kg", "名詞,一般,*,*", "キログラム", "キログラム", 0), + ("12", "名詞,数,*,*", "ジュウニ", "ジューニ", -100), + ("11", "名詞,数,*,*", "テンイチ", "テンイチ", -100), + ( + "2026年度", + "名詞,固有名詞,一般,*", + "ニセンニジュウロクネンド", + "ニセンニジューロクネンド", + -100, + ), + ( + "2.5倍", + "名詞,固有名詞,一般,*", + "ニテンゴバイ", + "ニテンゴバイ", + -100, + ), + ] { + builder.add_entry(DictEntry { + surface: surface.into(), + pos: pos.into(), + reading: r.into(), + pronunciation: p.into(), + base_form: format!("base:{surface}").into(), + conj_type: "fixture-type".into(), + conj_form: "fixture-form".into(), + cost, + ..Default::default() + }); + } + builder.build().unwrap() + } + + fn pronunciation(tokens: &[Token]) -> String { + tokens.iter().map(|t| t.pronunciation.as_ref()).collect() + } + + #[test] + fn analyzer_quantity_readings_cover_integers_fractions_units_and_native_counters() { + let mut analyzer = Analyzer::from_dict(dictionary()); + for (text, expected) in [ + ("10分", "ジュップン"), + ("2031年度", "ニセンサンジューイチネンド"), + ("1,000円", "センエン"), + ("1.5倍", "イチテンゴバイ"), + ("0.5分", "ゼロテンゴフン"), + ("1.0分", "イチテンゼロフン"), + ("2.50分", "ニテンゴゼロフン"), + ("300本", "サンビャッポン"), + ("21本", "ニジューイッポン"), + ("14日", "ジューヨッカ"), + ("24日", "ニジューヨッカ"), + ("2人", "フタリ"), + ("12人", "ジューニニン"), + ("13日", "ジューサンニチ"), + ("10万", "ジューマン"), + ("1兆", "イッチョー"), + ("8兆", "ハッチョー"), + ("10兆", "ジュッチョー"), + ("一兆", "イッチョー"), + ("八兆", "ハッチョー"), + ("十兆", "ジュッチョー"), + ("1000kg", "センキログラム"), + ("1,000円2.5倍", "センエンニテンゴバイ"), + ("10万2031円", "ジューマンニセンサンジューイチエン"), + ("10円2031年度", "ジューエンニセンサンジューイチネンド"), + ] { + let out = analyzer.try_tokenize(text).unwrap(); + assert_eq!(pronunciation(&out), expected, "{text}"); + for t in &out { + assert_eq!(&text[t.start..t.end], t.surface.as_ref()); + } + } + let out = analyzer.try_tokenize("2031年度").unwrap(); + assert_eq!(out.len(), 2); + assert_eq!(out[0].surface.as_ref(), "2031"); + assert!(!out[0].is_known); + assert_eq!(out[0].base_form.as_ref(), "2031"); + assert_eq!(out[0].pos.as_ref(), "名詞,数,*,*"); + assert!(out[0].conj_type.is_empty() && out[0].conj_form.is_empty()); + assert!(out[0].mora_count() > 0); + } + + #[test] + fn known_single_number_and_known_mixed_word_keep_all_dictionary_fields() { + let mut analyzer = Analyzer::from_dict(dictionary()); + for (text, surface, expected) in [ + ("12円", "12", "ジューニ"), + ("11本", "11", "テンイチ"), + ("2026年度", "2026年度", "ニセンニジューロクネンド"), + ("2.5倍", "2.5倍", "ニテンゴバイ"), + ] { + let out = analyzer.try_tokenize(text).unwrap(); + let t = &out[0]; + assert_eq!(t.surface.as_ref(), surface); + assert_eq!(t.base_form.as_ref(), format!("base:{surface}")); + assert_eq!(t.conj_type.as_ref(), "fixture-type"); + assert_eq!(t.conj_form.as_ref(), "fixture-form"); + assert_eq!(t.word_cost, -100); + assert!(t.is_known); + assert_eq!(t.pronunciation.as_ref(), expected); + } + } + + #[test] + fn protected_single_digits_do_not_receive_new_trillion_euphony() { + let mut analyzer = Analyzer::from_dict(dictionary()); + for (text, surface, expected) in [ + ("ISO 1兆", "1", "イチ"), + ("ISO 8兆", "8", "ハチ"), + ("ISO\n8兆", "8", "ハチ"), + ("−1兆", "1", "イチ"), + ("01兆", "1", "イチ"), + ("1.2.1兆", "1", "イチ"), + ("1,01兆", "1", "イチ"), + ("18兆", "8", "ハチ"), + ("1八兆", "八", "ハチ"), + ("8兆", "8", "ハチ"), + ("18兆", "8", "ハチ"), + ] { + let out = analyzer.try_tokenize(text).unwrap(); + let t = out + .iter() + .rev() + .find(|t| t.surface.as_ref() == surface) + .unwrap(); + assert_eq!(t.reading.as_ref(), expected, "{text}"); + assert_eq!(t.pronunciation.as_ref(), expected, "{text}"); + } + } + + #[test] + fn original_input_protects_identifier_prefixes_across_analyzer_chunks_and_sentences() { + let mut analyzer = Analyzer::from_dict(dictionary()); + for separator in [" ", "\t", "\n", "\r\n"] { + let text = format!("ISO{separator}9001円"); + let out = analyzer.try_tokenize(&text).unwrap(); + assert_eq!( + out.iter() + .filter(|t| t.surface.chars().all(|c| c.is_ascii_digit())) + .count(), + 4, + "{text}" + ); + let sentences = analyzer + .try_tokenize_sentences(&text, &crate::sentence::SplitOptions::default()) + .unwrap(); + assert_eq!( + sentences + .iter() + .flat_map(|(_, t)| t) + .filter(|t| t.surface.chars().all(|c| c.is_ascii_digit())) + .count(), + 4 + ); + } + let out = analyzer.try_tokenize("価格。10分。2.50分").unwrap(); + assert!( + out.iter() + .any(|t| t.surface.as_ref() == "10" && t.pronunciation.as_ref() == "ジュッ") + ); + assert!(out.iter().any(|t| t.surface.as_ref() == "2.50" && t.pronunciation.as_ref() == "ニテンゴゼロ")); + } + + #[test] + fn prepared_chunks_keep_identifier_protection_until_the_envelope_ends() { + let dict = dictionary(); + let mut workspace = crate::lattice::LatticeWorkspace::new(); + for parts in [&["ISO.", "8兆"][..], &["ISO.", ".", "8兆"][..]] { + let input = parts.concat(); + for offset in [0, 91] { + let plan = AsciiPlan::new(&input, offset); + let mut cursor = 0; + let mut position = offset; + let mut out = Vec::new(); + let mut cursors = Vec::new(); + for part in parts { + let first = out.len(); + let previous = format!("{out:?}"); + workspace + .tokenize_into_prepared(part, &dict, position, &mut out, &plan, &mut cursor) + .unwrap(); + assert_eq!(format!("{:?}", &out[..first]), previous); + cursors.push(cursor); + position += part.len(); + } + let number = out.iter().find(|t| t.surface.as_ref() == "8").unwrap(); + assert_eq!(number.reading.as_ref(), "ハチ", "{parts:?} at {offset}"); + assert_eq!( + number.pronunciation.as_ref(), + "ハチ", + "{parts:?} at {offset}" + ); + assert!(cursors[..cursors.len() - 1].iter().all(|&c| c == 0)); + assert_eq!(cursor, plan.candidates.len()); + for t in &out { + assert_eq!(&input[t.start - offset..t.end - offset], t.surface.as_ref()); + } + } + } + let out = workspace.tokenize("8兆", &dict).unwrap(); + assert_eq!(pronunciation(&out), "ハッチョー"); + } + + #[test] + fn direct_workspace_quantity_phase_keeps_offset_and_prior_output_outside_its_context() { + let dict = dictionary(); + let mut workspace = crate::lattice::LatticeWorkspace::new(); + let mut out = workspace.tokenize("ISO", &dict).unwrap(); + let previous = format!("{out:?}"); + let first = out.len(); + workspace + .tokenize_into("1,000円", &dict, 91, &mut out) + .unwrap(); + assert_eq!(format!("{:?}", &out[..first]), previous); + assert_eq!(out[first].surface.as_ref(), "1,000"); + assert_eq!(out[first].pronunciation.as_ref(), "セン"); + assert_eq!((out[first].start, out[first].end), (91, 96)); + } +} + +#[cfg(test)] +mod distributed_tests { + #[test] + #[ignore = "配布辞書 3 個が必要(make dict-download)"] + fn distributed_ascii_quantity_readings() { + for name in ["ipadic", "ipadic-neologd", "ipadic-neologd-sudachi"] { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("dict") + .join(format!("{name}.hsd")); + let mut analyzer = crate::Analyzer::load(path).unwrap(); + for (text, expected) in [ + ("10分", "ジュップン"), + ("2031年度", "ニセンサンジューイチネンド"), + ("1,000円", "センエン"), + ("2.5倍", "ニテンゴバイ"), + ("0.5分", "ゼロテンゴフン"), + ("1.0分", "イチテンゼロフン"), + ("300本", "サンビャッポン"), + ("14日", "ジューヨッカ"), + ("10万", "ジューマン"), + ("1兆", "イッチョー"), + ("8兆", "ハッチョー"), + ("10兆", "ジュッチョー"), + ("一兆", "イッチョー"), + ("八兆", "ハッチョー"), + ("十兆", "ジュッチョー"), + ("1000kg", "センキログラム"), + ] { + let out = analyzer.try_tokenize(text).unwrap(); + let actual: String = out.iter().map(|t| t.pronunciation.as_ref()).collect(); + assert_eq!(actual, expected, "{name}: {text}"); + for t in &out { + assert_eq!(&text[t.start..t.end], t.surface.as_ref()); + } + } + for (text, surface, expected) in [ + ("ISO 1兆", "1", "イチ"), + ("ISO 8兆", "8", "ハチ"), + ("−1兆", "1", "イチ"), + ("18兆", "8", "ハチ"), + ("1八兆", "八", "ハチ"), + ("8兆", "8", "ハチ"), + ("18兆", "8", "ハチ"), + ] { + let out = analyzer.try_tokenize(text).unwrap(); + let t = out + .iter() + .rev() + .find(|t| t.surface.ends_with(surface)) + .unwrap_or_else(|| panic!("{name}: {text}: {out:?}")); + // NEologd は「−1」を既知語として持つので、その辞書読みも保つ。 + assert!(t.reading.ends_with(expected), "{name}: {text}: {t:?}"); + assert!(t.pronunciation.ends_with(expected), "{name}: {text}: {t:?}"); + } + for text in [ + "ISO\n9001円", + "ISO 9001円", + "03-1234-5678円", + "123-4567円", + "1.2.3円", + "1.23円", + "1,23円", + "0010円", + "10ms", + "2031", + "1000\n分", + ] { + let out = analyzer.try_tokenize(text).unwrap(); + assert!( + !out.iter().any(|t| !t.is_known + && t.surface.len() > 1 + && t.surface + .bytes() + .all(|b| b.is_ascii_digit() || matches!(b, b',' | b'.')) + && t.base_form == t.surface + && t.conj_type.is_empty() + && !t.reading.is_empty()), + "{name}: {text}" + ); + } + } + } +}