suiko 0.3.10

Deterministic diagnostics for natural and readable Japanese writing
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
//! 読解負荷レーン。自然度スコアとは分離した推敲用の指さしを返す。

use std::collections::BTreeMap;
use std::sync::LazyLock;

use regex::Regex;

use crate::Error;
use crate::morphology::Morphology;
use crate::text::{mask_markdown_structure, sentences_with_raw};

use super::morph::{
    buried_list, buried_question_list, long_attributive_span, punctuation_between, tokenize,
};
use super::{Finding, ReadingLoadReport, ReadingLoadStats, ReadingLoadThresholds};

// Issue #34 で著者が読みづらいと判断した文は句点を除いて49〜70字(2節・55字を含む)。
// 30字まで下げても実文書99件で10件しか出ず、45字で3文をすべて残す(eval/calibration.md)。
// 節の下限は「はいか、」のような選択肢を外し、「〜へ戻るか」(11字)を残す。
// 並びの下限は、Issue #34 の3文の並び(38〜42字)より少し短く取る。書籍原稿の点検で、
// 20〜34字の並びは述語がすぐ続き、節ごとに切ると冗長になった(eval/calibration.md)。
const QUESTION_LIST_SENTENCE_MIN: usize = 45;
const QUESTION_CLAUSE_MIN: usize = 6;
const QUESTION_LIST_MIN: usize = 35;
// 名詞の列挙は、最後の項目より前に並んだ区画が20字以上か、6項目以上のときだけ指す。
// 書籍原稿2冊と評価コーパスの点検で、20字未満は「仕事、睡眠、食事」のような一語の
// 並びだった(eval/calibration.md)。
const BURIED_LIST_HELD_MIN: usize = 20;
const BURIED_LIST_MANY_ITEMS: usize = 6;

// 文ごとに呼ばれるため、正規表現は一度だけコンパイルする。
static WHITESPACE_RUN: LazyLock<Regex> =
    LazyLock::new(|| Regex::new(r"\s{2,}").expect("valid whitespace regex"));

fn reading_length(text: &str) -> usize {
    WHITESPACE_RUN.replace_all(text, " ").trim().chars().count()
}

fn first_negation_modifies_noun(
    tokens: &[crate::morphology::Morpheme],
    first: usize,
    second: usize,
) -> bool {
    let Some(_) = tokens.get(first + 1).filter(|token| {
        matches!(token.pos(0), "名詞" | "代名詞")
            && !matches!(token.surface.as_str(), "こと" | "わけ" | "はず")
    }) else {
        return false;
    };
    let Some(particle) = tokens.get(first + 2) else {
        return false;
    };
    // 「必要のないデータはない」のような存在の否定は候補に残す。
    // 別の修飾句にある「ある」まで、この例外へ含めない。
    if matches!(particle.surface.as_str(), "は" | "が" | "も")
        && tokens[first + 1..second]
            .iter()
            .rev()
            .find(|token| token.pos(0) == "動詞")
            .is_some_and(|token| token.dictionary_form() == "ある")
    {
        return false;
    }
    first + 2 < second
        && particle.pos(0) == "助詞"
        && matches!(
            particle.surface.as_str(),
            "は" | "が" | "を" | "も" | "や" | "に" | "へ"
        )
}

/// 形の上では否定が二つ並ぶが、別々の述語や語彙化した表現で、読み手が
/// 符号の反転を計算しない並び。二つの否定が同じ命題に掛かる
/// 「ないわけではない」「なくはない」「ずにはいられない」は候補に残す。
fn negations_are_independent(
    tokens: &[crate::morphology::Morpheme],
    first: usize,
    second: usize,
) -> bool {
    // 「〜ないかもしれない(しれません)」の「しれない」は推量の定型で、命題を否定しない。
    // 「〜ていないかは分からない」「〜なくなるかを試せない」のように、最初の否定が
    // 疑問の「か」の節の中にあるなら、二つの否定は同じ命題に掛からない。
    if tokens[first + 1..second].iter().any(|token| {
        matches!(token.dictionary_form(), "しれる" | "知れる")
            || (token.surface == "か"
                && token.pos(0) == "助詞"
                && matches!(token.pos(1), "副助詞" | "終助詞"))
    }) {
        return true;
    }
    // 「際限なく」「例外なく」のように、助詞を挟まず名詞に付く「なく」は副詞。
    if tokens[first].surface == "なく" && first > 0 && tokens[first - 1].pos(0) == "名詞" {
        return true;
    }
    // 「〜ないから進まない」のように、理由・逆接の接続助詞で節が切れている。
    if tokens[first + 1..second].iter().any(|token| {
        token.pos(1) == "接続助詞"
            && matches!(
                token.surface.as_str(),
                "から" | "ので" | "のに" | "けど" | "けれど" | "けれども" | "が" | "し"
            )
    }) {
        return true;
    }
    // 「聞こえず話せない」「足りず区別できない」「確かめずに推測していない」は別の述語。
    if tokens[first].surface == "ず" {
        let next =
            first + 1 + usize::from(tokens.get(first + 1).is_some_and(|t| t.surface == "に"));
        return tokens.get(next).is_some_and(|token| {
            matches!(token.pos(0), "動詞" | "名詞") && token.dictionary_form() != "いる"
        });
    }
    false
}

/// 括弧の外にある「=」を式の印とみなす。「(=ほぼ個人)」のような括弧内の
/// 言い換えは散文の一部として扱う。
fn equals_outside_parentheses(text: &str) -> bool {
    let mut depth = 0_usize;
    text.chars().any(|ch| {
        match ch {
            '(' | '(' => depth += 1,
            ')' | ')' => depth = depth.saturating_sub(1),
            _ => {}
        }
        depth == 0 && matches!(ch, '=' | '=')
    })
}

pub fn analyze_reading_load(
    raw: &str,
    morphology: &Morphology,
    genre: Option<&str>,
) -> Result<ReadingLoadReport, Error> {
    analyze_reading_load_with_thresholds(raw, morphology, genre, ReadingLoadThresholds::default())
}

pub fn analyze_reading_load_with_thresholds(
    raw: &str,
    morphology: &Morphology,
    genre: Option<&str>,
    thresholds: ReadingLoadThresholds,
) -> Result<ReadingLoadReport, Error> {
    let masked = mask_markdown_structure(raw);
    let raw_lines = raw.split('\n').collect::<Vec<_>>();
    let split = sentences_with_raw(&masked, raw);
    let tokenized = tokenize(&split, morphology)?;
    let sentence_max = thresholds
        .sentence_max
        .unwrap_or(if genre == Some("essay") { 110 } else { 90 });
    // 校正時、原稿で目視修正した文の修飾節は31〜76字に分布し、35字では31〜34字の
    // 文を取り逃した。随筆(青空文庫)でも30字で文の1.5%程度に収まる(eval/calibration.md)。
    let attributive_span_min = thresholds.attributive_span_min.unwrap_or(30);
    let kanji = Regex::new(r"[一-龿々]{7,}").expect("valid kanji-run regex");
    let conditional_negative =
        Regex::new(r"^(ない|なけれ|なく)(と|ば|ければ)").expect("valid conditional-negation regex");
    let mut findings = Vec::new();

    for sentence in &tokenized {
        let length = reading_length(&sentence.text);
        let excerpt = sentence.raw_text.chars().take(40).collect::<String>();
        // 読点ゼロの長文(カタログ B4の下位事例)。読点密度の検出はNO-GO済みだが、
        // 60字以上で読点が1つもない文は統語的な切れ目が示されず、実測でも
        // 人間文書の誤検知ゼロだった(eval/calibration.md)。日本語の読点診断なので、
        // URL・引用文献・コード断片のようなLatin優勢の行は対象にしない。
        let japanese_chars = sentence
            .text
            .chars()
            .filter(|c| matches!(c, 'ぁ'..='ん' | 'ァ'..='ヶ' | 'ー' | '一'..='鿿' | '々'))
            .count();
        // 「A → B → C」のループの図式や「X = A AND B」の式は散文ではない。
        // 読点の打ち方や修飾節の長さを問わない。
        let arrow_chain = sentence.text.matches(['→', '⇒']).count() >= 2;
        let formula = equals_outside_parentheses(&sentence.text);
        if length >= 60
            && japanese_chars * 2 >= length
            && !sentence.text.contains(['、', ',', ','])
            && !arrow_chain
            && !formula
        {
            let mut finding = Finding::new(
                sentence.line,
                "no_comma_sentence",
                excerpt.clone(),
                "info",
                format!(
                    "一文{length}字に読点がない(目安60字)。カタログ B4。統語的な切れ目に読点を打つか、文を分ける"
                ),
            );
            finding.span = sentence.span(&raw_lines, 0, sentence.text.len());
            findings.push(finding);
        }
        if length > sentence_max {
            let mut finding = Finding::new(
                sentence.line,
                "sentence_too_long",
                excerpt,
                "info",
                format!(
                    "一文が{length}字(目安{sentence_max}字)。カタログ B1。一文一義になっているか確認する(分割の結果、字数が増えるのは正しい)"
                ),
            );
            finding.span = sentence.span(&raw_lines, 0, sentence.text.len());
            findings.push(finding);
        }

        for found in kanji.find_iter(&sentence.text) {
            let includes_proper_noun = sentence.tokens.iter().any(|token| {
                token.byte_end > found.start()
                    && token.byte_start < found.end()
                    && token.pos(1) == "固有名詞"
            });
            if includes_proper_noun {
                continue;
            }
            let mut finding = Finding::new(
                sentence.line,
                "kanji_run",
                found.as_str(),
                "info",
                format!(
                    "漢字が{}字連続(目安6字)。カタログ C1。語の切れ目が読み取れるか確認する",
                    found.as_str().chars().count()
                ),
            );
            finding.span = sentence.span(&raw_lines, found.start(), found.end());
            findings.push(finding);
        }

        if let Some((start, end, items, held)) = buried_list(&sentence.tokens) {
            let min_chars = if items <= 3 { 80 } else { 50 };
            // 「観察、仮説、介入」のような一語ずつの短い並びは、箇条書きに開くと一語だけの
            // 項目が並んで読みにくくなる。書誌の「(著)、(訳)」も本文の列挙ではない。
            let bibliographic = ["(著)", "(訳)", "(監訳)", "(監修)", "(編)"]
                .iter()
                .any(|role| sentence.text.contains(role));
            if length >= min_chars
                && (held >= BURIED_LIST_HELD_MIN || items >= BURIED_LIST_MANY_ITEMS)
                && !bibliographic
            {
                let phrase = sentence.tokens[start..end]
                    .iter()
                    .map(|token| token.surface.as_str())
                    .collect::<String>();
                let mut finding = Finding::new(
                    sentence.line,
                    "buried_list",
                    phrase.chars().take(40).collect::<String>(),
                    "info",
                    format!(
                        "同格の名詞句が読点で{items}個並んでいる(並び{held}字・一文{length}字)。カタログ F1。箇条書きに開くと並列関係を読み手が再構成せずに済む"
                    ),
                );
                finding.span = sentence.span(
                    &raw_lines,
                    sentence.tokens[start].byte_start,
                    sentence.tokens[end - 1].byte_end,
                );
                findings.push(finding);
            }
        }

        // 疑問節「〜か、」を読点で並べ、末尾の「〜かを」で一つの述語へ係らせる文
        // (カタログ F1の節版)。短い選択肢の列挙は節の字数と述語の有無で外す。
        if length >= QUESTION_LIST_SENTENCE_MIN
            && let Some(list) =
                buried_question_list(&sentence.tokens, QUESTION_CLAUSE_MIN, QUESTION_LIST_MIN)
        {
            let phrase = sentence.tokens[list.start..list.end]
                .iter()
                .map(|token| token.surface.as_str())
                .collect::<String>();
            let particle = &sentence.tokens[list.end - 1].surface;
            let mut finding = Finding::new(
                sentence.line,
                "buried_question_list",
                phrase.chars().take(40).collect::<String>(),
                "info",
                format!(
                    "疑問節「〜か」が読点で{}個並び、末尾の「か{particle}」で一つの述語に係っている(並び{}字・一文{length}字)。カタログ F1。係り先の述語を先に出すか、節ごとに文を切る",
                    list.clauses, list.chars
                ),
            );
            finding.span = sentence.span(
                &raw_lines,
                sentence.tokens[list.start].byte_start,
                sentence.tokens[list.end - 1].byte_end,
            );
            findings.push(finding);
        }

        // 一つの実質名詞に、述語を2つ以上含む長い修飾節が前置され、その名詞句が主節の
        // 項になる文(カタログ B2/B5)。読み手は名詞が出るまで修飾節全体を保留する。
        // 削除済みの nested_attributive は連体形の個数で全発火したため、ここでは
        // 個数ではなく一つの名詞が背負う修飾節の長さと述語数を測る。
        if !arrow_chain
            && let Some(span) =
                long_attributive_span(&sentence.text, &sentence.tokens, attributive_span_min)
        {
            let surface = |range: std::ops::Range<usize>| {
                sentence.tokens[range]
                    .iter()
                    .map(|token| token.surface.as_str())
                    .collect::<String>()
            };
            let head = surface(span.head..span.head_end);
            let phrase = surface(span.start..span.head_end);
            let phrase_chars = phrase.chars().count();
            let excerpt = if phrase_chars > 40 {
                format!(
                    "…{}",
                    phrase.chars().skip(phrase_chars - 39).collect::<String>()
                )
            } else {
                phrase
            };
            let mut finding = Finding::new(
                sentence.line,
                "long_attributive_span",
                excerpt,
                "info",
                format!(
                    "名詞「{head}」に{}字・述語{}個の修飾節が前置されている(目安{attributive_span_min}字)。カタログ B2/B5。被修飾名詞か述語を先に出すか、修飾節を独立した文に分ける",
                    span.chars, span.predicates
                ),
            );
            finding.span = sentence.span(
                &raw_lines,
                sentence.tokens[span.start].byte_start,
                sentence.tokens[span.head_end - 1].byte_end,
            );
            findings.push(finding);
        }

        let negative_indices = sentence
            .tokens
            .iter()
            .enumerate()
            .filter(|(_, token)| {
                matches!(token.pos(0), "助動詞" | "形容詞")
                    && matches!(
                        token.dictionary_form(),
                        "ない" | "無い" | "ぬ" | "ず" | "ん"
                    )
            })
            .map(|(index, _)| index)
            .collect::<Vec<_>>();
        for pair in negative_indices.windows(2) {
            let first = pair[0];
            let second = pair[1];
            let phrase = sentence.tokens[first..=second]
                .iter()
                .map(|token| token.surface.as_str())
                .collect::<String>();
            let obligation = [
                "といけ",
                "とだめ",
                "とダメ",
                "ばならな",
                "ばなりま",
                "ばいけな",
                "てはならな",
                "てはなりま",
                "てはいけな",
                "ざるを得",
                "ざるをえ",
            ]
            .iter()
            .any(|pattern| phrase.contains(pattern));
            if second - first <= 6
                && !punctuation_between(&sentence.tokens, first, second)
                && !obligation
                && !conditional_negative.is_match(&phrase)
                && !first_negation_modifies_noun(&sentence.tokens, first, second)
                && !negations_are_independent(&sentence.tokens, first, second)
            {
                let mut finding = Finding::new(
                    sentence.line,
                    "double_negative",
                    phrase,
                    "info",
                    "否定が二重に掛かっている可能性。カタログ A1/A2。肯定に畳むなら真偽が反転していないか必ず確認する。控えめな肯定が本質的な箇所は触らない",
                );
                finding.span = sentence.span(
                    &raw_lines,
                    sentence.tokens[first].byte_start,
                    sentence.tokens[second].byte_end,
                );
                findings.push(finding);
                break;
            }
        }

        let no_indices = sentence
            .tokens
            .iter()
            .enumerate()
            // 「〜するのは」「〜なの」の準体助詞は名詞句の連鎖ではないので数えない。
            .filter(|(_, token)| {
                token.surface == "の" && token.pos(0) == "助詞" && token.pos(1) == "格助詞"
            })
            .map(|(index, _)| index)
            .collect::<Vec<_>>();
        for window in no_indices.windows(3) {
            if window.windows(2).all(|pair| pair[1] - pair[0] <= 3)
                && !punctuation_between(&sentence.tokens, window[0], window[2])
            {
                let phrase = sentence.tokens[window[0]..=window[2]]
                    .iter()
                    .map(|token| token.surface.as_str())
                    .collect::<String>();
                let mut finding = Finding::new(
                    sentence.line,
                    "no_chain",
                    phrase,
                    "info",
                    "格助詞「の」が3連以上。カタログ C2。どこかを動詞・連用に開く",
                );
                finding.span = sentence.span(
                    &raw_lines,
                    sentence.tokens[window[0]].byte_start,
                    sentence.tokens[window[2]].byte_end,
                );
                findings.push(finding);
                break;
            }
        }
    }
    findings.sort_by_key(|finding| finding.line);
    let mut by_category = BTreeMap::new();
    for finding in &findings {
        *by_category.entry(finding.category.clone()).or_default() += 1;
    }
    Ok(ReadingLoadReport {
        stats: ReadingLoadStats {
            total: findings.len(),
            sentences: tokenized.len(),
            genre: genre.map(str::to_owned),
            by_category,
        },
        findings,
    })
}