Skip to content

Instantly share code, notes, and snippets.

@mizchi
Last active July 17, 2026 18:57
Show Gist options
  • Select an option

  • Save mizchi/d390dea93acd4261bf5a50be09d3b08a to your computer and use it in GitHub Desktop.

Select an option

Save mizchi/d390dea93acd4261bf5a50be09d3b08a to your computer and use it in GitHub Desktop.
句読点・形態素による認知リズム評価の試作

cognitive-rhythm-eval

「認知リズムを生むための日本語ライティング規範」を、意味の判定と混同せずに検証するための Rust 試作です。

評価対象の規範は、固定リビジョンの SKILL.md を参照します。形態素解析には vibrato 0.5.2 を使用します。

できること

vibrato の品詞列と文面の句読点から、次の観測可能な代理指標を JSON で出力します。

指標 定義 規範との対応
sentence_length_cv 句読点を除く文ごとの形態素数の変動係数 文の長短が一様でないか
short_long_short_patterns 短→長→短の 3 文パターン数 「立てる→流す→止める」の候補
length_contrast_transitions 平均文長の 35% 以上変化する隣接文数 拍の切替候補
comma_count, question_count を含む句読点の数 節内の流れ、問いの候補
content_token_ratio 名詞・動詞・形容詞・副詞・連体詞 ÷ 非記号トークン 内容語密度
hesitation_signal_sentences 逡巡・転回の表層語を含む文数 「かもしれない」「しかし」などの候補
document_signal_sentences 進行実況に多い表層語を含む文数 人手で話題テストすべき候補
pos_sequence.pos_counts 句読点を除く品詞の頻度 名詞・助詞・動詞などの偏り
pos_sequence.pos_bigram_counts 文内で隣接する品詞列の頻度 「名詞→助詞→動詞」などの並び
pos_sequence.pos_bigram_entropy 品詞 bigram 分布の Shannon entropy 品詞遷移の多様性の候補
pos_sequence.pos_bigram_repetition_rate 既出 POS bigram を再使用した遷移の割合 テンプレート反復の候補
bunsetsu_proxy 助詞・助動詞・接続詞で区切る文節候補 構文解析前の表層構造
frequency.sentence_length 文ごとの形態素数系列の DFT・最大 lag 自己相関・シャッフル p 値 文長リズムの診断候補(品質スコアには不使用)
frequency.lexical_topic_shift 隣接文の内容語集合の Jaccard 距離系列 語彙的な話題転換の周期候補

rhythm_score は上の変動・短長短・切替・逡巡・短い停止の重み付き和(0–100)です。document_update_risk は文書進行語を含む文の割合です。いずれも「文章の良さ」や「状況を更新した」という判定ではありません。

bunsetsu_proxy は係り受け解析ではありません。隣接する文節候補の組を依存辺として解釈したり、係り先・関係ラベルを出力したりはしません。真の係り受けを評価に入れるには、係り先と関係ラベルを返す専用パーサーを別の実装として接続し、文節候補との誤差も検証します。

周波数の単位は Hz ではなく「cycles per sentence」である。4文未満、または系列に変動がないときは支配周期を出力しない。8文以上では、最大 8 lag の正の自己相関を求め、同じ値を文順だけ 999 回シャッフルした帰無分布に対する片側 p 値も出す。これは周期が偶然より強いかを見る診断であり、p 値が高い DFT の山を改稿スコアには使わない。lexical_topic_shift は語の表層一致に基づく代理指標であり、同義語・比喩・照応を扱えないため、意味の周波数そのものではない。意味を測るには、文埋め込み間の距離列を同じ周波数分析器へ与える別バックエンドが必要になる。

特に「本章では」と書かれていても必要な導入であることがあり、逆に良い緩みは表層語だけでは捉えられます。このため document_update_risk は削除指示ではなく、規範の話題テストを人間が行うためのレビューキューです。

実行

--dict には、vibrato で生成された非圧縮system.dic を渡します。配布辞書は zstd 圧縮なので、上流の手順に従って展開してください。

just test
just build-dictionary lex.csv matrix.def char.def unk.def system.dic
just analyze /path/to/system.dic article.md
just corpus /path/to/system.dic examples/hypothesis.jsonl
just profile /path/to/system.dic references.jsonl candidate.md
just profile-corpus /path/to/system.dic references.jsonl candidates.jsonl
just revise /path/to/system.dic references.jsonl before.md after.md

examples/hypothesis.jsonl は良例・悪例の仮説ラベルを 3 件ずつ含むシードです。これは重みを作るための訓練データではなく、出力と研究手順を確認するための例です。

profile は、同じ目的・ジャンルで「快い」と人手で選んだ複数の参照文から、文長変動係数、語彙的話題変化の平均、POS bigram エントロピーの許容帯を作る。候補文には、各特徴を increasedecreasewithin_range のどれにすべきかと帯域外距離を返す。生の POS bigram 反復率は文章が長くなるほど増えやすいため、プロフィールには入れない。DFT の支配パワー比は文順シャッフルに対する有意性を別途検査する診断であり、プロフィールには入れない。参照 JSONL のラベルは使わないため、良例だけを別ファイルにして渡すこと。10本以上の参照では外れ値の影響を抑えるため各特徴の中央80%(10–90パーセンタイル)を使い、それ未満では min–max を使う。これは人間または生成器が改稿を繰り返すためのフィードバックであり、単一の品質スコアではない。参照が少ないと許容帯が過度に狭くなるので、少なくとも同ジャンル・同目的の複数著者・複数文書を使い、補正の採否は人間が確認する。

revise は改稿前後を同じプロフィールに照らし、profile_gap_reduction_score(初期の平均帯域外距離を何 % 解消したか)を返す。改稿前がすでに全帯域内ならこのスコアは未定義であり、それを無理に 100 点へ置換しない。これは rhythm_score の差分を置き換える改稿用の指標であり、一般的な文章品質の尺度ではない。内容を捨ててスコアだけを上げることを検知するため、名詞・動詞・形容詞・副詞・連体詞の表層集合に対する Jaccard 類似度を content_token_retention として別に記録する。二つを合成しないので、編集者は「参照への整合」と「内容保持」を別々に判断できる。実測例と読み方は examples/trpg-revision-ja.md を参照する。

build-dictionary は UTF-8 の MeCab 形式 *.csvmatrix.defchar.defunk.def から、vibrato が読む非圧縮 system.dic を作る。配布辞書のソースが EUC-JP なら、あらかじめ UTF-8 に変換して渡す。

外部ソースによる評価コーパス

corpus/sources.jsonl は、太宰治『走れメロス』、宮沢賢治『銀河鉄道の夜』、指定ブログの技術記事4件を記録した URL-only マニフェストです。認知リズム規範の固定リビジョンは corpus/rubrics.json に注釈規範として記録し、本文ラベルには使いません。

just prepare-corpus
just benchmark /path/to/system.dic corpus/generated/passages.jsonl
just validate-human /path/to/system.dic corpus/generated/passages.jsonl corpus/generated/human-annotations.jsonl

prepare-corpus は CSS 選択子で本文を抽出し、600–2,400文字の passage、ソース URL、文書分割キー、ドメイン、本文 SHA-256 をローカルに作ります。生成本文は corpus/generated/ 以下に置かれ、再配布しないため Git 管理外です。

benchmark は全 passage の AUC に加え、良例・悪例からそれぞれ1文書ずつ完全に外す leave-one-document-pair-out AUC を算出します。各 fold で「高い方が良い/低い方が良い」の向きも残りの文書だけから決めるため、同一文書の passage が学習側と評価側に混ざりません。さらに document_auc は passage を文書ごとに平均してから求めるため、passage 数の多い著者が有利になりません。document_permutation_p_value は良例・悪例の文書ラベルを全組合せで入れ替えた二側検定であり、文書数が多すぎる場合は計算量の上限により null になります。author_holdout_ready は両ラベルに2人以上の著者があるか、author_holdout_auc は条件を満たしたときに著者を完全に外した AUC を示す。条件を満たさない間は後者を null として、著者をまたいだ性能を主張しない。

現在のセットには両ラベルの technical_explanation があり、ドメインの完全交絡は解消している。一方で、良例は4著者、悪例は1著者のため author_holdout_ready: false である。良例の技術記事は2文書、悪例は4文書であり、媒体・題材・翻訳文体も交絡したままである。したがって高い AUC や小さい置換 p 値は、この固定コーパスでの探索結果に留める。同一題材を複数人が書いた人手注釈例と、未使用の複数著者・複数媒体の技術記事で再現したものだけを選抜候補として扱う。詳細は corpus/README.md を参照してください。

validate-human はブラインド人手評価の rhythm_1_to_5 を passage ごとに平均し、各表層特徴との符号付き Spearman 相関を返す。仮説ラベルはこの集計に使わない。入力は passage_idannotator_idrhythm_1_to_5 を持つ JSONL であり、範囲外の評点、未知 ID、同一評価者による重複評定を拒否する。評価票、無作為化、確認的テストの手順は corpus/annotation-protocol-ja.md を参照する。

定量評価の設計

この試作を有効性の評価器にするには、次のように進めます。

  1. 目的・分野・著者が偏らない 200–500 段落を集め、規範を読んだ独立した複数人が、リズム(1–5)、文書更新のみ(yes/no)、状況・判断の更新(yes/no)を別々に注釈する。
  2. 文書単位で分割して訓練・評価を分離する。著者が両方に入らない分割も別途行い、文体の記憶を防ぐ。
  3. 句読点のみ句読点 + 品詞句読点 + 品詞 + 文書進行語 の 3 条件をアブレーション比較する。各指標の向きは学習側だけで決め、文書単位でホールドアウトする。
  4. リズム評点には Spearman の順位相関とブートストラップ 95% CI、文書更新ラベルには ROC-AUC と PR-AUC、注釈者間には Krippendorff's alpha を報告する。
  5. 誤検出を、必要な導入・比喩・引用・箇条書き・短い技術文に層別して確認する。改善はラベルを見ない開発集合だけで行う。

この手順により、「リズムの代理指標として相関するか」と「文書更新のレビュー候補として役立つか」を、主観的な総合評価から切り離して反証可能にできます。

開発

just check

テストは文分割、形態素品詞比、短→長→短、文書進行語、そして in-memory MeCab 辞書を使う vibrato アダプタをカバーします。

ブラインド人手評価プロトコル

目的

仮説ラベル(good_hypothesis / bad_hypothesis)や著者名ではなく、読者が感じるリズムと表層特徴の対応を測る。評価者には出典 URL、著者、ラベル、既存の自動スコアを見せない。

最初の確認的評価は、文学と技術解説を混ぜず、目的が近い技術解説だけで行う。現行データは悪例仮説が1著者なので、これは収集手順のテンプレートであり、まだ実施可能な評価セットではない。

評価票

各評価者は、ランダムな passage ID と本文だけを受け取る。同じ原文書からは評価者あたり1 passageまでにする。各 passage について次を独立に採点する。

項目 判断基準
rhythm_1_to_5 1–5 読む速度・停止・密度の変化が、本文の目的に対して自然で追いやすいか。1 は単調または過度に断続的、3 は判断不能でない中間、5 は変化が目的に寄与する。
document_only yes/no/不明 本文が対象世界・論点を進めず、文書の進行だけを説明しているか。
situation_or_judgment_update yes/no/不明 読後に、対象世界または論点について新しい状態・判断が確定したか。

rhythm_1_to_5 を現在の validate-human コマンドの入力に使う。他の二項目は、後続の AUC / PR-AUC 評価用に別途保存する。評価者は「一般に良い文章か」「著者が好きか」を採点しない。

機械可読な入力

各評価者が同じ passage を二重に採点しないよう、1 行に1評価だけを記録する。本文や仮説ラベルは注釈ファイルへ含めない。

{"passage_id":"blind-017","annotator_id":"reader-03","rhythm_1_to_5":4}

passage_id は元の passage ID と対応させるが、評価票ではランダム化した ID を使う。集計前に対応表を適用する。範囲外の評点、未知の passage、同一評価者による同一 passage の重複はプログラムがエラーにする。

集計

少なくとも3人の独立評価者を用意し、各 passage の平均 rhythm_1_to_5 を作る。探索用には全ての表層特徴との符号付き Spearman 相関を出す。正の相関は「特徴値が高いほど評点が高い」、負の相関はその逆であり、仮説ラベルから向きを決めない。

just validate-human /path/to/system.dic \
  corpus/generated/passages.jsonl \
  corpus/generated/human-annotations.jsonl

確認的なテストでは、開発集合で選んだ特徴と向きを固定し、未使用の著者・媒体を含むテスト集合で相関とブートストラップ信頼区間を報告する。探索段階で多数の特徴を見た p 値を、文章品質の証拠にはしない。

採用しない条件

  • 片方のラベルが1著者だけで author_holdout_ready: false のまま。
  • 評価者が出典や仮説ラベルを知っている。
  • 文学と技術解説を混ぜた全体相関だけを報告する。
  • 確認用テストで事後に特徴・向き・閾値を変更する。

文章の「リズム」は測れるか:Vibrato で始めた仮説検証の記録

差は出た。だが、その差を「よい文章の証明」と呼ぶには早すぎた。まだ仮説だ。

文章には、意味を追う前に感じる読みやすさがある。文章は短く止まる文があり、少し長く流れる文があり、その切替が論点を運ぶ。反対に、文章で同じ説明の型が続くと、内容を理解していても息が詰まる。この文章の感覚を認知リズムと呼ぶなら、句読点と形態素からどこまで観測できるだろうか。

このリポジトリでは、固定したライティング規範を出発点に、Rust と vibrato で日本語の文章を計測した。目標は良文判定器を作ることではない。感覚的な規範を、反証できる代理指標へ分け、どこで壊れるかを確かめることである。この試作が測るのは文章の価値ではなく、文章を読み直す候補だ。

まず、文章を意味ではなく観測値へ分けた

文末の句読点で文を分け、記号を除いた形態素数から文長を出した。この試作が数えるのは、文長の変動係数、短→長→短の連なり、隣接文の長さの切替、読点、疑問符である。内容語については、名詞・動詞・形容詞・副詞・連体詞を取り出し、隣接文の集合がどれほど変わるかを Jaccard 距離で近似した。品詞 n-gram や句読点を文体特徴として扱う日本語の計量文体論も、この分解の出発点になった。Huang & Jin, 2020

内容語と品詞も頻度だけでは終わらせなかった。文内の POS bigram の反復率とエントロピーを出し、助詞・助動詞・接続詞で区切る文節候補も数えた。ただし、この文節候補は係り受けではない。係り先や意味関係を推定しているように見せないことを、実装の境界にした。

仮説コーパスには、確かに差があった

良例仮説には太宰治、宮沢賢治、技術記事、翻訳技術エッセイを置いた。悪例仮説には、読みの対照として指定された技術ブログの記事を置いた。後者を AI 生成と断定するためではない。ここでのラベルは、あくまで検証前の悪例仮説である。

生成した 115 passage を比べると、語彙的話題変化の平均は良例仮説で 0.907、悪例仮説で 0.836 だった。この話題変化と並んで、文長変動係数も 0.731 と 0.476 に分かれた。文書を等重みにしても、この二つの特徴は全5良例文書と全4悪例文書を分離した。一方、POS bigram の反復率は passage 単位では差が見えても、文書単位の置換検定では弱かった。

この結果を読むとき、AUC の高さだけを見てはいけない。同一文書の passage を学習側と評価側に混ぜない文書ホールドアウトを入れ、passage 数の多い著者が有利にならない文書 AUC も別に出した。差は残った。だが、ここで確認できたのは固定コーパス内の仮説の差だけである。

「心地よい周波数」は、いったん撤回した

出発点には、よい文章には音楽のコード進行のような周期があるのではないか、という勘があった。そこで文長列と内容語遷移列に DFT を掛け、強い周波数成分を探した。初期の TRPG 導入文では、この値をプロフィールに入れると、改稿が大きく改善したように見えた。

だが文順だけを 999 回シャッフルして比べると、TRPG 導入の文長自己相関は改稿前で p=0.417、改稿後で p=0.635 だった。この自己相関は、どちらも偶然より強い周期とは言えない。周期は、まだ品質の根拠にならない。DFT の山は診断として残したが、プロフィールと改稿スコアから外した。散文の文長列を自己相関と文順シャッフルで比べる考え方は Roberts, 1996 を参照した。測定値が存在することと、その測定値を品質の根拠にできることは別である。

指標でこの記事を直すときも、数値を目的にしない

この記事も初稿と改稿後で測った。この改稿の計測表は本文に置かず、改稿ログ に分けた。計測表の文数や品詞列が、評価対象の本文を変えてしまうからである。

初稿では、短い参照文だけから作ったプロフィールに対して、生の POS bigram 反復率が大きく帯域外に出た。

しかし、これは長い記事ほど既出の品詞 bigram を再使用しやすいという、指標の長さ依存を露出した結果でもある。そこで POS 反復率を下げるためだけに文を奇抜にしたり、内容語を無関係に入れ替えたりはしなかった。生の反復率はプロフィールから外し、文書ホールドアウトでもより強く分離した POS bigram エントロピーへ置き換えた。改稿では、先に結論を置き、各節の結論と留保を隣接させ、短い文と説明文の役割を分けた。プロフィールへの距離と内容語保持率は記録するが、単独の点数を改稿の勝敗には使わない。

この判断は、指標を使わないという意味ではない。どの値が何に依存するかを確かめ、文章の目的と衝突する最適化を止めるために使う。数値は残す。数値に文章を従わせない。数値は編集者の代わりではなく、見落としを指す付箋である。

現在、残しているものと保留しているもの

現在プロフィールに残しているのは、文長変動係数、語彙的話題変化の平均、POS bigram エントロピーの三つだ。改稿前後では参照範囲への距離と内容語保持率を別に記録する。内容を削って数値だけを合わせる改稿は、改善ではないからである。

同時に、現在の良例仮説は4著者、悪例仮説は1著者なので、著者を完全に外した検証は実行できない。ここにも境界がある。ツールは author_holdout_ready: falseauthor_holdout_auc: null を返す。未知の著者へ一般化する性能を、まだ測れていないことを出力に残すためだ。

次は、ラベルを伏せて読んでもらう

次の実験では、仮説ラベルを伏せた人手評価を集める。評価者には出典、著者、自動スコアを見せず、本文の目的に対してリズムが自然かを 1–5 で評定してもらう。この評定の集計では仮説ラベルを使わず、平均評定と各特徴の符号付き Spearman 相関を出す。

この段階で初めて、「良例と悪例を分けた」ではなく、「読者の評定と対応した」と言える可能性が生まれる。文章の良さを一つの数に押し込めるのではない。どの観測がどの条件で崩れるかを、少しずつ確かめていく。

実装、再現手順、先行研究へのリンクはこの Gist に置いている。数値を文章の判決にしないことも、この試作の要件である。

ブログ記事の改稿ログ

対象は blog-article-ja.md である。本文の目的は、試作で何を測り、何を採用せず、次に何を検証するかを短く説明することだった。

参照と手順

blog-profile-references.jsonl の3本の技術的な観察→反証→判断という短い参照文から、文長変動係数、語彙的話題変化の平均、POS bigram エントロピーのプロフィールを作った。これは記事の品質を検証する正解集合ではなく、改稿でどの表層値が動いたかを確かめるための暫定参照である。

Gist には JSONL を置かず、参照文そのものをここへ残す。

  1. 最初に見えたのは、集計値の差だった。良例仮説の文は短い文と長い文を交互に置き、悪例仮説の文は似た長さへ寄っていた。しかし、その差だけでは採用できない。著者と媒体が違えば、数値は簡単に動く。
  2. 次に文順を崩して確かめた。周波数の山は残って見えたが、シャッフルした系列と比べると強い周期とは言えなかった。ここで指標を一つ外した。測れていることと、品質の根拠になることは別だった。
  3. 残したのは、観測結果と保留条件である。文長の散らばりと内容語の遷移には差が出た。一方で未知の著者にはまだ試せない。次の仕事は、ラベルを伏せた人手評定を集めて、この仮説を反証可能にすることだ。

初稿では、導入と各節の間に論点の受け渡しが少なかった。改稿では「文章」「この試作」「話題変化」「自己相関」「評定」といった、その節の論点を次の文へ引き継いだ。生の POS bigram 反復率は記事長に依存するため、プロフィールには使わなかった。

結果

観測値 初稿 改稿後 変化
プロフィール距離 0.122827 0.118306 -0.004520
profile_gap_reduction_score 3.68% 初期帯域外距離の解消率
rhythm_score 34.73 31.58 -3.15
文長変動係数 0.371 0.382 +0.011
語彙的話題変化の平均 0.944 0.940 -0.004
POS bigram エントロピー 3.605 3.595 -0.010
内容語表層集合の保持率 65.73% 名詞などの集合に対する Jaccard 類似度

プロフィール距離は小さくなった。内訳では、文長変動は両方とも参照帯内で、話題変化と POS bigram エントロピーの帯域外距離が少し縮んだ。

しかし rhythm_score は下がった。これは、短→長→短や強い文長切替に重みを置く旧来のヒューリスティックが、今回の論点接続を高く評価しないためである。二つの値を合成していないので、この改稿を「3.68% 良くなった」とは呼ばない。

さらに、内容語表層集合の保持率は 65.73% に留まる。これは意味保存の判定ではないが、言い換えが大きかったという警告にはなる。公開版では、数値だけではなく、結論を先に置いたか、各節の留保が直前の主張と接続しているかを人間が確認して採用した。

この改稿から得た設計上の修正

初稿を記事全体として測ると、生の POS bigram 反復率が非常に高く出た。長い文書ほど既出 bigram を再使用しやすいためである。この量を短い参照文の帯域へ無理に合わせると、文章を不自然に壊す。そこで生の反復率は診断として残し、プロフィールの特徴を POS bigram エントロピーへ置き換えた。

また、本文に詳細な計測表を置くと、表そのものの文数と品詞列が指標を変えてしまった。本文とこの改稿ログを分離したのは、評価対象と実験記録を混ぜないためである。

再現

just revise /path/to/system.dic \
  examples/blog-profile-references.jsonl \
  examples/blog-article-before-ja.md \
  examples/blog-article-ja.md

この記録は、同一の3本の自己作成参照文に対する結果であり、独立した読者評価ではない。次に必要なのは、長さと目的を揃えた複数著者の参照文と、ラベルを伏せた人手評定である。

[package]
name = "cognitive-rhythm-eval"
version = "0.1.0"
edition = "2024"
rust-version = "1.85"
description = "A measurable proxy evaluator for Japanese cognitive writing rhythm"
license = "MIT"
[dependencies]
encoding_rs = "0.8"
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] }
scraper = "0.24"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"
sha2 = "0.10"
vibrato = { version = "=0.5.2", default-features = false }

評価器併用ライティング規範の経験的評価ログ

対象は cognitive-rhythm-writing-with-eval-ja.md である。このログは、規範の著者自身による読み直しではなく、毎回新しい実行者に同じ規範を読ませて作成した。本文の数値評価ログと同じく、評価ログは規範本文に混ぜない。

評価の前提

Iteration 0: description / body 整合性

frontmatter は「日本語の説明文・エッセイ・導入文を、状況と判断の更新を中心に執筆・改稿し、評価器を補助に使う」と述べる。本文には、執筆の核、人手の話題・漏れ・緊張台帳、analyze、比較可能な場合だけの profile / revise、研究目的の benchmark / validate-human がある。記述と本文の対象範囲は一致していたため、この段階の変更はない。

固定した判定方法

  • 各シナリオに 5 項目のチェックリストを先に固定し、少なくとも 1 項目を [critical] にした。
  • 成功は、すべての [critical] 項目が の場合だけとした。
  • 正確度は ○ = 1partial = 0.5× = 0 の平均とした。
  • 実行者は、成果物、項目別達成、フェーズ別 trace、不明点、裁量、再試行を報告した。
  • 毎回、新しい実行者を使った。評価者に規範の改訂履歴は渡していない。

この協調環境はサブエージェント呼出しの duration_ms と厳密な tool_uses メタデータを公開しない。そのため、表の steps は実行者が報告したツール呼出し数であり、duration は 取得不可 と明示する。時間の収束だけは検証していない。

シナリオ

ID 種別 [critical] の中心
A 技術ブログの3段落改稿 3つの技術的事実を保持し、rhythm_score を品質点にしない
B 参照・辞書なしの TRPG 導入 数値最適化を開始せず、人手チェックへ切り替える
C 文学参照で技術仕様を評価したい依頼 異ジャンルのプロフィール比較を保留する
D document_update_risk が高い障害報告 時刻・状態・次回更新を機械的に削除しない
E 未提示の手順を補うよう求めるリリースノート 未提示の内部実装・操作を創作しない
F 未決定のロールバック条件を含む ADR(ホールドアウト) 未決定事項と確認責任を本文・事実台帳に残す

実行結果

「入力側の欠損」は、シナリオが意図的に未提示とした情報である。実行者がそれを創作せず、事実台帳へ移せた場合、規範の不明点には数えない。

ラウンド シナリオ 成功 正確度 steps duration 再試行 規範側の新しい不明点
初回 A 100% 1 取得不可 0 0
初回 B 100% 1 取得不可 0 0
初回 C 100% 1 取得不可 0 0
再評価 A 100% 1 取得不可 0 0
再評価 B 100% 1 取得不可 0 0
再評価 C 100% 1 取得不可 0 0
初期ホールドアウト D 100% 1 取得不可 0 0(ただし未提示操作の扱いを強化)
事実境界の追加後 D 100% 1 取得不可 0 0(入力側の操作欠損)
事実境界の追加後 E 100% 1 取得不可 0 0(入力側の実装・操作欠損)
事実台帳の追加後 D 100% 1 取得不可 0 0
事実台帳の追加後 E 100% 1 取得不可 0 0
最終再評価 A 100% 1 取得不可 0 0
最終再評価 C 100% 1 取得不可 0 0(1,200字版に必要な仕様は入力側で未提示)
最終ホールドアウト F 100% 1 取得不可 0 0

初回と再評価は、同じ固定チェックリストで別の実行者が 100% を再現した。事実台帳を追加した後も、2ラウンドで規範側の新しい不明点は 0、正確度差は 0 ポイント、報告 steps の差は 0% だった。最終ホールドアウト F の正確度も 100% であり、直近の平均から 15 ポイント以上は低下していない。

したがって、時間メタデータの確認を除き、収束条件を満たしたと暫定判断する。時間を含めた厳密な再現評価は、duration_ms と tool usage が取得できる実行環境で行う。

改訂した箇所

初期ホールドアウト D では、暫定回避策と無効化後の利用者操作が未提示だった。実行者は創作を避けたが、欠損の記録が成果物ごとに一貫しなかった。この問題は「数値評価」ではなく、入力事実の境界に関するものだった。

この修正が満たす固定判定の文言は、D の「未提示の操作・因果を創作せず、本文外ログの事実台帳に確認事項として明示する」と、E の「欠損情報を本文外ログの事実台帳に確認事項として記録する」である。

規範には次の二つを追加した。

  1. 守るべき境界: 入力にない事実・操作手順・数値を具体化せず、欠損は条件付き表現と本文外ログで扱う。
  2. 数値を見る前に読む: 話題・漏れ・緊張に加え、確定事実、未確認事実、確認すべき操作を分ける「事実台帳」を作る。

追加後の D と E は、未提示の操作・内部実装を創作せず、本文外の事実台帳に記録した。

失敗パターン台帳

  • パターン名: 未提示の運用情報を暗黙に補完する
    • 代表的な Issue: 「暫定回避策の具体的操作と、無効化後に利用者が行う操作が不明」
    • General Fix Rule: 操作を創作せず、確認事項として事実台帳に残し、根拠のある確認後に更新する。
    • Seen in: 初期ホールドアウト D、事実境界の追加後 D/E
    • 対応: 本文冒頭の境界規則に加え、人手チェックへ事実台帳を追加した。以後は本文外へ確認事項を分離できた。

この評価が示す範囲

これは「この規範がどんな文章でも良くする」ことの証明ではない。小さな、作為的なシナリオで、実行者が次を再現できたことを示すだけである。

  • 数値を一般品質スコアにせず、参照とのずれを診断として扱う。
  • 比較不能な参照、参照なし、辞書なしでは、プロフィール最適化を始めない。
  • document_update_risk を削除命令として扱わない。
  • 未提示の事実・操作を補完せず、本文外の事実台帳へ分離する。

実際の有効性を調べるには、未使用の実案件、複数の書き手、独立した人手評価、実行時間を取得できる評価基盤で再試験する必要がある。

name cognitive-rhythm-writing-with-eval
description 日本語の説明文・エッセイ・導入文を、状況と判断の更新を中心に執筆・改稿し、cognitive-rhythm-eval の形態素・句読点診断を補助的に用いる。数値の最適化ではなく、参照文とのずれを見つけて人間が改稿を判断したいときに使う。

認知リズムを生むための日本語ライティング規範(評価器併用版)

これは、認知の運びが読める日本語を書くための執筆・改稿スキルである。元の規範が重視するのは、文を短くすることでも、特定の語を入れることでもない。読者が観察し、少し保留し、判断が更新され、次の観察へ戻る運びを、内容そのものから作ることである。

この版では cognitive-rhythm-eval を併用する。評価器が扱うのは、句読点、形態素、品詞列、語彙集合から得た代理指標だけである。状況が変わったか、緊張が残っているか、比喩が効いているかは判定できない。したがって、数値は合否や品質点ではなく、読み直す場所を選ぶための診断として使う。

守るべき境界

  • 内容と状況の更新を、数値の改善より優先する。
  • rhythm_score、DFT、自己相関、POS bigram の生の反復率を、良し悪しの合否に使わない。
  • 参照プロフィールは、同じ目的・ジャンル・おおむね同じ長さの良例だけから作る。文学、技術解説、TRPG 導入を同じ帯域に混ぜない。
  • 数値に合わせるために、内容語、固有名詞、因果関係、必要な限定を捨てない。
  • 入力にない事実・操作手順・数値を具体化しない。欠けた運用手順は仮定で補わず、条件付きに表現し、確認すべき欠損として本文外のログに残す。
  • 評価結果・表・改稿ログを本文の中に埋め込んでから本文を再評価しない。表そのものが文長・品詞・句読点の指標を変えるため、ログは別ファイルに置く。
  • プロフィールに近づいても、人間の話題テスト・漏れテスト・緊張台帳を通らなければ採用しない。

執筆の核

1. 認知のモードを一方向に固定しない

文章は、次のモードを行き来する。

  1. 観察する。現象、手触り、制約、失敗、事実を置く。
  2. 保留する。意味づけを急がず、読者が引っかかれる余白を残す。
  3. 判断する。観察から導ける範囲だけを言う。
  4. 再観察する。判断で見え方が変わった対象へ戻る。

この順序は一文ごとのテンプレートではない。段落の中や節をまたいで、読者の理解が一段ずつ動くように設計する。

悪い例は、説明・断定・まとめが同じ密度で続き、認知の姿勢が変わらない文章である。良い例は、書き手が一度見て、確信しすぎず、発見し、見直す過程を、内容から追える。

2. 解決しきらない緊張を一つ残す

良い文章には、未処理の違和感がある。失敗の原因、概念のずれ、例外、うまく言えない感覚、次に検証すべき点を置き、直後に「つまり」「要するに」で回収しない。

次の観察や判断が、その緊張に少しだけ応答する。これが文と段落をつなぐ。

3. 進行の宣言ではなく、世界の変化を書く

「本節では」「次に」「ここまでで」は、文書の進行を更新する。一方、対象の状態、理解、選択肢、制約が変わる文は、状況を更新する。

必要な見出しや導入を禁止するわけではない。ただし、進行を示す一文が、読者の認識や対象の状態を何も変えないなら、削除・統合・状況への書き換えを検討する。

4. 説明しすぎず、抽象語を着地させる

「重要」「本質」「構造」「問題」といった抽象語だけで段落を閉じない。直後に、観測可能な差分、行為、制約、例を置く。逆に、場面だけを積み重ねて結論を曖昧にしすぎない。観察と判断を往復させる。

まず人間が行う執筆手順

A. 書き始める前

次を短く決める。

  • 読者は誰で、読み終えたとき何を見分けられるようになるか。
  • 文章の起点になる具体的な状況・失敗・疑問は何か。
  • 最後まで完全には回収しない緊張は何か。
  • 参照文を使うなら、候補と目的・ジャンル・長さが本当に近いか。

参照文がない場合、後述の profilerevise は使わない。通常の話題テストと analyze の診断だけで改稿する。異なる文体の数値へ寄せることは、比較ではなく模倣になる。

B. 初稿を書く

観察または具体的な状況から入る。結論を急ぐ必要はないが、雰囲気だけで引き延ばさない。一段落には、少なくとも一つの認識・状況・判断の差分を置く。

文の長さは内容に従わせる。短文を「止め」、長文を「流し」として機械的に交互にしない。短い文は、転回、観察の固定、言い切りに使う。長い文は、条件、因果、観察の連なりを運ぶ。どちらにも役割がなければ、長短の変化は演出ではなく癖になる。

C. 数値を見る前に読む

初稿を声に出すか、画面から離れて読み、次の三つをメモする。

  1. 話題テスト: 各段落の後、読者の知っている状況・選択肢・理解は何が変わったか。答えが「次の節に進んだ」だけの段落は要修正候補である。
  2. 漏れテスト: 「この文章では」「ここまでで」「重要なのは」など、書き手の進行実況が、対象の説明を代替していないか。
  3. 緊張台帳: 残した違和感は何か。それは後の観察・判断で少しずつ形を変えているか。途中で忘れられていないか。
  4. 事実台帳: 入力で確定している事実、未確認の事実、本文外で確認すべき操作を分ける。未確認の内容は具体化せず、本文では条件付きに留め、確認事項を改稿ログへ移す。

この段階で内容が破綻しているなら、数値を見る前に直す。評価器は意味的な破綻を救えない。

評価器を使う手順

以下の例では、辞書を system.dic、候補を draft.md とする。system.dic は vibrato が読む非圧縮辞書である。

1. 単体診断を取る

just analyze /path/to/system.dic draft.md

この出力では、次のように読み、数値だけで書き換えない。

出力 読み方 見直す問い
sentence_length_cv 文長の揺れの大きさ 全文が同じ息継ぎになっていないか。必要な説明まで短くしていないか。
short_long_short_patterns / length_contrast_transitions 文長の変化候補 変化が認識・論理・場面の切替と一致しているか。偶然の句読点操作ではないか。
content_token_ratio 内容語の表層密度 抽象的なつなぎ語や進行実況で、対象の描写が薄くなっていないか。専門語が多いだけでは高品質ではない。
document_update_risk 進行実況を含む文の候補 各候補を話題テストにかける。必要な道標なら残し、状況を変えないなら統合・削除・書換えを検討する。
pos_counts / pos_bigram_counts 品詞の偏り・反復候補 同じ言い切り、名詞止め、接続の型が続いていないかを原文で確認する。
pos_bigram_entropy 品詞遷移の多様性の代理 低い・高いこと自体を善悪としない。参照帯域から外れたときだけ、文型の固定を疑う。
frequency.* 文長・語彙的話題移動の周期診断 文章の周期性が偶然より強いかを見るだけ。意味の周波数や快さを測ったものではない。

document_update_risk は削除指示ではない。「本節では」が必要な仕様書もある。必ず元の文脈で判断する。

2. 比較可能な参照文があるときだけプロフィールを作る

参照 JSONL には、良例だけを入れる。ラベルは使用されない。少数の似た文書に過適合しないよう、同じ目的・ジャンルの複数文書・できれば複数著者を用いる。

just profile /path/to/system.dic references.jsonl draft.md

プロフィールは次の三特徴だけを使う。

  • sentence_length_cv: 文長の揺れ
  • lexical_topic_shift_mean: 隣接文の内容語集合の表層的な距離
  • pos_bigram_entropy: 品詞 bigram の分布の多様性

生の pos_bigram_repetition_rate は文章が長いほど増えやすいため、プロフィールには入れない。DFT のピークや自己相関も、文順のシャッフルに対する診断であり、目標値にしない。

出力が increase / decrease / within_range を示したら、対応する本文上の原因を一つ探す。たとえば文長の揺れが小さいからといって、一文を唐突に短くしてはならない。条件節を分ける、観察と判断を別の文にする、不要な要約を消す、といった意味のある変更だけを候補にする。

10本未満の参照では帯域が min–max になるため、結果は特に不安定である。帯域外であることを失敗と見なさない。

3. 改稿前後を別々に保存して比較する

just revise /path/to/system.dic references.jsonl draft-before.md draft-after.md

確認する値は二つであり、混ぜて一点にはしない。

  • profile_gap_reduction_score: 参照帯域から外れていた距離を、改稿でどれだけ縮めたか。改稿前から帯域内なら未定義であり、100 点扱いにしない。
  • content_token_retention: 内容語集合の Jaccard 類似度。内容を残せたかの粗い警報であり、低いから必ず失敗・高いから成功ではない。

採用の条件は次の順で判断する。

  1. 話題テスト、漏れテスト、緊張台帳が改稿前より悪化していない。
  2. 事実、限定、因果、固有名詞、読者への約束を失っていない。
  3. profile_gap_reduction_score が正なら、何を変えて近づいたかを本文の役割で説明できる。
  4. content_token_retention が大きく下がったなら、消えた内容語を確認する。意図的な言い換えでも、情報が落ちていないことを読む。

条件 1 または 2 を満たさない改稿は、数値が改善しても戻す。rhythm_score の増減は、採否の条件に加えない。

4. 改稿ログを本文から分離する

次のようなファイル配置にする。

article-before.md       # 初稿
article.md              # 採用した本文。ここだけを評価する
article-revision.md     # 改稿理由・コマンド・数値・未解決点
references.jsonl        # 同目的・同ジャンルの良例だけ

article-revision.md には、実行日、参照文の選定理由、数値、残した・戻した変更を Markdown で記録する。「スコアが上がったから良くなった」とは書かず、「どの代理指標が、どの本文上の選択を見直す契機になったか」を書く。

段落ごとのチェック

各段落について、次を順番に確認する。

  1. この段落は観察・保留・判断・再観察のどこにいるか。
  2. 前段落と比べ、状況または読者の理解は何が変わったか。
  3. 解決しきらない緊張は残っているか。残しすぎて放置していないか。
  4. 文の長短、接続、問い、列挙は、内容の転回に対応しているか。
  5. 文書進行を説明しているだけの文はないか。
  6. 数値の帯域に合わせるためだけの文・接続詞・言い換えはないか。

問いを使う場合、問いだけで段落を止めない。直後の観察・例・判断が問いの対象を具体化する。箇条書きの後には、一文だけでも列挙から何が変わったかを着地させる。

評価器を研究として使う場合

候補文の改稿補助と、指標そのものが有効かを調べることは別の仕事である。後者では次を守る。

just benchmark /path/to/system.dic corpus/generated/passages.jsonl
just validate-human /path/to/system.dic corpus/generated/passages.jsonl annotations.jsonl
  • benchmark の passage AUC は、同一文書の近い段落を見ている可能性がある。文書ホールドアウト、文書平均 AUC、可能なら著者ホールドアウトを優先して読む。
  • 仮説ラベルが付いたコーパスの差は、そのコーパスに限った探索結果である。媒体、題材、著者、翻訳文体の交絡を品質差と呼ばない。
  • validate-human はブラインドな人手の rhythm_1_to_5 と代理指標の符号付き Spearman 相関を出す。ラベルを使わないが、相関は因果でも普遍性でもない。
  • 指標の採用は、未使用の文書・著者・題材、人手評価、失敗例の読解で再現してから決める。

停止条件

次のいずれかなら、改稿ループを止める。

  • 本文が読者に渡す状況・判断が明確で、緊張の回収具合にも意図がある。
  • 同じ種類の変更を繰り返しても、話題テストと漏れテストが改善しない。
  • 参照帯域に近づけるほど、内容保持や文体の固有性が悪くなる。
  • 参照文の目的・ジャンル・長さが候補と異なり、比較の前提が失われている。

評価器の役目は、文章を平均的な数値へ矯正することではない。書き手がすでに感じた「同じ調子が続く」「進行を説明しているだけだ」「ここで何かが変わるはずだ」という違和感を、再現可能な観察と改稿ログへ変えることにある。

最終確認

  • 文章は、対象の状況・理解・判断を更新している。
  • 進行実況だけの文は、必要な道標として残したもの以外を処理した。
  • 文長や品詞の変化は、内容上の役割を持つ。
  • 数値に合わせるだけの編集をしていない。
  • プロフィールを使ったなら、参照文は目的・ジャンル・長さが近い良例だけである。
  • 改稿前後とログは別ファイルで保存し、本文にはログを混ぜていない。
  • 最終採否は、数値ではなく人間の読解と内容保持で決めた。

出典と変更点

この規範は、k16shikano/cognitive-rhythm-writing の認知リズム・状況更新・緊張・話題テストの考え方を基に、cognitive-rhythm-eval の現行コマンドと検証上の制約を加えた改作である。元の Gist は変更しない。

句読点・形態素による「認知リズム」評価の試作

対象は、認知モードの切替、未回収の緊張、短→長→短の拍、文書進行の実況を避けることを求める日本語ライティング規範である。

https://gist.githubusercontent.com/k16shikano/eb2929f13ed19c97188393d297be8432/raw/a3b1e26beced71d582e13314fb6f5b179b023c76/SKILL.md

実装

Rust の vibrato で形態素解析し、評価ロジックと解析器を分離した。入力は system.dic とテキスト、出力は JSON である。

  • 文長変動係数: 句読点を除く形態素数の標準偏差 / 平均
  • 短→長→短: 中央文が両端平均の 1.6 倍以上、かつ末尾文が中央文の 55% 以下の三連
  • 文長切替: 隣接文の長さの差が全体平均の 35% 以上
  • 内容語比: 名詞・動詞・形容詞・副詞・連体詞 / 非記号トークン
  • 逡巡・転回語: 「かもしれない」「だろう」「しかし」などの文数
  • 文書進行語: 「本章」「この節」「ここまで」「次に」「次節」「以下」「本稿」の文数

rhythm_score は上記の文長変動、短→長→短、切替、逡巡、短い停止を重み付きで 0–100 に正規化した代理指標である。document_update_risk は文書進行語を含む文の割合であり、削除の自動判定ではなく、人間の話題テストへ送るレビュー候補である。

改稿を評価するスコアへの改訂

単独の rhythm_score は、改稿の成功を表す値としては使わない。文長の強い短→長→短を多く含む原文が高得点でも、同目的の参照文が示すパターンから遠いことがあるためである。

そこで、選んだ参照文から各特徴の許容帯を作り、改稿前後の平均帯域外距離を比較する profile_gap_reduction_score を追加した。現在の特徴は文長変動係数、語彙的話題変化の平均、POS bigram エントロピーである。これは 100 * (改稿前距離 - 改稿後距離) / 改稿前距離 であり、100 は初期ギャップの全解消、負値は参照からの後退を表す。改稿前から帯域内なら定義しない。

内容を削ってプロフィールに合わせるだけの改変を区別するため、名詞・動詞・形容詞・副詞・連体詞の表層集合について、改稿前後の Jaccard 類似度を content_token_retention として併記する。保持率とプロフィール整合は合成しない。前者は内容保存のガード、後者は選んだ文体パターンへの整合であり、最終判断は人間が行う。

初期の TRPG 導入実測では、DFT 支配パワー比をプロフィールに含めたため、改稿前後で profile_gap_reduction_score = 98.70 と出た。しかし文長系列を文順シャッフル 999 回と比較した結果、改稿前 p=0.417、改稿後 p=0.635 であり、どちらも偶然より強い周期とはいえなかった。このため DFT 支配パワー比はプロフィールから外し、文長変動係数に差し替えた。その後、文章長に強く依存する生の POS bigram 反復率もプロフィールから外し、POS bigram エントロピーに置き換えた。旧プロフィールによる「改稿前が帯域内、改稿後は反復率だけが帯域外」という数値は現行定義では無効である。内容語保持率 88.66% は表層集合の比較として記録する。この反例は、人間評価なしにスペクトルや表層反復の形を「改善」と解釈してはならないことを示す。詳細な原文・改稿文・特徴ごとの差分は examples/trpg-revision-ja.md に記録する。

得られたこと

意図的に作った 3 段落の対照例では、状況・判断の変化を扱う例が rhythm_score = 55.83、進行実況の例が 22.53 となった。文書進行語リスクは順に 0% と 75% だった。

一方、内容語比は良例 0.535、悪例 0.629 であり、内容語密度が高いことは良文の根拠にならない。この区別には文長の波形と文書進行語が効いた。

また、初期版では「計測を見る」のような対象世界に関する 見る を文書進行と誤検出した。一般動詞を単独の規則から外し、文書を指す語句だけに絞る反例テストを追加した。この事例は、意味的な「状況を更新するか」は表層特徴だけで決められないことを示す。

検証方法

この試作は分類器の精度を主張しない。実証するには、独立した複数人が段落ごとに「リズム」(1–5)、「文書更新のみ」(yes/no)、「状況・判断の更新」(yes/no) を別々に注釈するコーパスが必要である。

評価では、句読点のみ、句読点 + 品詞、句読点 + 品詞 + 文書進行語をアブレーション比較する。リズム評点には Spearman 相関とブートストラップ信頼区間、二値ラベルには ROC-AUC と PR-AUC、注釈者間には Krippendorff's alpha を報告する。著者や文書が学習側と評価側をまたがない分割も必要である。

段落の密度波形は現在の集計値ではまだ扱っていない。次の実装では、段落ごとに文長・内容語比を算出し、密→疎の遷移と段落内の短→長→短を別の特徴として追加する。

//! Reproducible source manifests, extraction, and metric-selection diagnostics.
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use std::fs;
use std::io::Write;
use std::path::Path;
use encoding_rs::SHIFT_JIS;
use reqwest::blocking::Client;
use scraper::{Html, Selector};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::{Evaluation, LabeledText, ScoredText};
/// A reproducible reference to a source document. The manifest stores metadata
/// and URLs, not third-party source text.
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct CorpusSource {
pub id: String,
pub url: String,
pub label: String,
pub author: String,
pub domain: String,
pub split_group: String,
pub selector: String,
pub encoding: String,
pub license: String,
}
#[derive(Debug)]
pub enum CorpusError {
Http(reqwest::Error),
Io(std::io::Error),
Json(serde_json::Error),
InvalidSelector { source_id: String, selector: String },
MissingContent { source_id: String, selector: String },
UnsupportedEncoding(String),
InvalidUtf8 { source_id: String },
}
impl fmt::Display for CorpusError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Http(error) => write!(formatter, "HTTP request failed: {error}"),
Self::Io(error) => write!(formatter, "I/O failed: {error}"),
Self::Json(error) => write!(formatter, "JSON parsing failed: {error}"),
Self::InvalidSelector {
source_id,
selector,
} => write!(formatter, "invalid selector {selector:?} for {source_id}"),
Self::MissingContent {
source_id,
selector,
} => write!(
formatter,
"selector {selector:?} found no content for {source_id}"
),
Self::UnsupportedEncoding(encoding) => {
write!(formatter, "unsupported source encoding: {encoding}")
}
Self::InvalidUtf8 { source_id } => write!(formatter, "invalid UTF-8 from {source_id}"),
}
}
}
impl std::error::Error for CorpusError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Http(error) => Some(error),
Self::Io(error) => Some(error),
Self::Json(error) => Some(error),
Self::InvalidSelector { .. }
| Self::MissingContent { .. }
| Self::UnsupportedEncoding(_)
| Self::InvalidUtf8 { .. } => None,
}
}
}
impl From<reqwest::Error> for CorpusError {
fn from(error: reqwest::Error) -> Self {
Self::Http(error)
}
}
impl From<std::io::Error> for CorpusError {
fn from(error: std::io::Error) -> Self {
Self::Io(error)
}
}
impl From<serde_json::Error> for CorpusError {
fn from(error: serde_json::Error) -> Self {
Self::Json(error)
}
}
/// Reads a version-controlled source manifest in JSON Lines format.
pub fn load_sources(path: impl AsRef<Path>) -> Result<Vec<CorpusSource>, CorpusError> {
fs::read_to_string(path)?
.lines()
.filter(|line| !line.trim().is_empty())
.map(serde_json::from_str)
.collect::<Result<Vec<_>, _>>()
.map_err(CorpusError::from)
}
/// Fetches all manifest sources and turns their selected article bodies into
/// passage records. Generated text should be stored only in ignored local data
/// directories unless redistribution is permitted by the source license.
pub fn fetch_passages(sources: &[CorpusSource]) -> Result<Vec<LabeledText>, CorpusError> {
let client = Client::builder()
.user_agent("cognitive-rhythm-eval/0.1 (research corpus builder)")
.build()?;
let mut passages = Vec::new();
for source in sources {
let response = client.get(&source.url).send()?.error_for_status()?;
let bytes = response.bytes()?;
let html = decode_html(source, &bytes)?;
passages.extend(extract_passages_from_html(source, &html, 600, 2_400)?);
}
Ok(passages)
}
/// Extracts article text from a cached HTML string, allowing selector behavior
/// to be tested without network access.
pub fn extract_passages_from_html(
source: &CorpusSource,
html: &str,
minimum_characters: usize,
maximum_characters: usize,
) -> Result<Vec<LabeledText>, CorpusError> {
let selector = Selector::parse(&source.selector).map_err(|_| CorpusError::InvalidSelector {
source_id: source.id.clone(),
selector: source.selector.clone(),
})?;
let document = Html::parse_document(html);
let root = document
.select(&selector)
.next()
.ok_or_else(|| CorpusError::MissingContent {
source_id: source.id.clone(),
selector: source.selector.clone(),
})?;
let paragraphs = html_fragment_to_paragraphs(&root.inner_html());
let windows = passage_windows(&paragraphs, minimum_characters, maximum_characters);
let source_hash = format!("{:x}", Sha256::digest(html.as_bytes()));
Ok(windows
.into_iter()
.enumerate()
.map(|(index, text)| LabeledText {
id: format!("{}-{index:03}", source.id),
label: source.label.clone(),
source_id: source.id.clone(),
author: source.author.clone(),
source_url: source.url.clone(),
split_group: source.split_group.clone(),
domain: source.domain.clone(),
source_hash: source_hash.clone(),
text,
})
.collect())
}
/// Writes generated passages as JSON Lines, creating its parent directory.
pub fn write_passages(path: impl AsRef<Path>, passages: &[LabeledText]) -> Result<(), CorpusError> {
let path = path.as_ref();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let file = fs::File::create(path)?;
let mut writer = std::io::BufWriter::new(file);
for passage in passages {
serde_json::to_writer(&mut writer, passage)?;
writer.write_all(b"\n")?;
}
Ok(())
}
/// A feature ranked by separation of `good_hypothesis` and `bad_hypothesis`.
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct MetricDiagnostic {
pub name: String,
pub direction: String,
/// Direction-adjusted pairwise ROC-AUC. 0.5 means no separation.
pub auc: f64,
/// Mean AUC on held-out good/bad document pairs. Feature direction is
/// selected from all other source documents in each fold.
pub document_holdout_auc: Option<f64>,
pub document_holdout_folds: usize,
/// Mean AUC on held-out good/bad author pairs. `None` when either label
/// does not yet contain at least two distinct authors.
pub author_holdout_auc: Option<f64>,
pub author_holdout_folds: usize,
/// Direction-adjusted AUC after averaging every passage in a source
/// document. Unlike `auc`, every source document has equal weight.
pub document_auc: Option<f64>,
/// Two-sided exact permutation p-value for the difference between the
/// good/bad document means. `None` means there are too many documents for
/// an exact enumeration.
pub document_permutation_p_value: Option<f64>,
pub good_mean: f64,
pub bad_mean: f64,
}
/// Separates apparent feature separation from whether the corpus can validate it.
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct MetricSelectionReport {
pub good_count: usize,
pub bad_count: usize,
pub document_holdout_ready: bool,
/// True only when both labels contain at least two distinct non-empty
/// authors. This is a prerequisite for an author-held-out validation.
pub author_holdout_ready: bool,
pub domain_confounded: bool,
pub shared_domains: Vec<String>,
pub metrics: Vec<MetricDiagnostic>,
}
/// One blind human rating for a corpus passage.
///
/// `rhythm_1_to_5` is intentionally independent of the hypothesis label: 1 is
/// least rhythmically effective for the assigned reading purpose, and 5 is
/// most effective.
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct HumanAnnotation {
pub passage_id: String,
pub annotator_id: String,
pub rhythm_1_to_5: f64,
}
impl HumanAnnotation {
#[must_use]
pub fn new(
passage_id: impl Into<String>,
annotator_id: impl Into<String>,
rhythm_1_to_5: f64,
) -> Self {
Self {
passage_id: passage_id.into(),
annotator_id: annotator_id.into(),
rhythm_1_to_5,
}
}
}
/// A raw, signed association between one proxy feature and mean human rhythm
/// ratings. Positive values mean the feature increases with the rating.
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct HumanMetricCorrelation {
pub name: String,
pub spearman_rho: Option<f64>,
pub sample_count: usize,
}
/// Aggregate of blinded human rhythm ratings and their feature associations.
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct HumanRhythmReport {
pub annotated_passage_count: usize,
pub rating_count: usize,
pub annotator_count: usize,
pub metrics: Vec<HumanMetricCorrelation>,
}
#[derive(Debug)]
pub enum MetricSelectionError {
MissingGoodExamples,
MissingBadExamples,
}
impl fmt::Display for MetricSelectionError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingGoodExamples => write!(formatter, "no good_hypothesis records found"),
Self::MissingBadExamples => write!(formatter, "no bad_hypothesis records found"),
}
}
}
impl std::error::Error for MetricSelectionError {}
#[derive(Debug)]
pub enum HumanRhythmError {
UnknownPassage {
passage_id: String,
},
DuplicateRating {
passage_id: String,
annotator_id: String,
},
InvalidRhythmRating {
passage_id: String,
value: f64,
},
}
impl fmt::Display for HumanRhythmError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::UnknownPassage { passage_id } => {
write!(
formatter,
"annotation refers to unknown passage {passage_id}"
)
}
Self::DuplicateRating {
passage_id,
annotator_id,
} => write!(
formatter,
"duplicate annotation from {annotator_id} for passage {passage_id}"
),
Self::InvalidRhythmRating { passage_id, value } => write!(
formatter,
"rhythm rating for {passage_id} must be finite and in 1..=5, got {value}"
),
}
}
}
impl std::error::Error for HumanRhythmError {}
/// Ranks surface metrics by direction-adjusted pairwise AUC.
///
/// The report does not train a model. A domain-confounded corpus cannot support
/// a general claim about writing quality even if the apparent AUC is high.
pub fn select_metrics(
records: &[ScoredText],
) -> Result<MetricSelectionReport, MetricSelectionError> {
let good: Vec<_> = records
.iter()
.filter(|record| record.label == "good_hypothesis")
.collect();
let bad: Vec<_> = records
.iter()
.filter(|record| record.label == "bad_hypothesis")
.collect();
if good.is_empty() {
return Err(MetricSelectionError::MissingGoodExamples);
}
if bad.is_empty() {
return Err(MetricSelectionError::MissingBadExamples);
}
let mut metrics = metric_names()
.into_iter()
.map(|name| metric_diagnostic(name, &good, &bad))
.collect::<Vec<_>>();
metrics.sort_by(|left, right| {
right
.auc
.total_cmp(&left.auc)
.then_with(|| left.name.cmp(&right.name))
});
let good_domains = good
.iter()
.map(|record| record.domain.clone())
.collect::<BTreeSet<_>>();
let bad_domains = bad
.iter()
.map(|record| record.domain.clone())
.collect::<BTreeSet<_>>();
let shared_domains = good_domains
.intersection(&bad_domains)
.cloned()
.collect::<Vec<_>>();
let document_holdout_ready =
distinct_split_groups(&good) >= 2 && distinct_split_groups(&bad) >= 2;
let author_holdout_ready = distinct_authors(&good) >= 2 && distinct_authors(&bad) >= 2;
Ok(MetricSelectionReport {
good_count: good.len(),
bad_count: bad.len(),
document_holdout_ready,
author_holdout_ready,
domain_confounded: shared_domains.is_empty(),
shared_domains,
metrics,
})
}
/// Correlates surface proxy metrics with the mean of independently collected,
/// blinded human rhythm ratings. Hypothesis labels are not consulted.
pub fn correlate_human_rhythm(
records: &[ScoredText],
annotations: &[HumanAnnotation],
) -> Result<HumanRhythmReport, HumanRhythmError> {
let records_by_id = records
.iter()
.map(|record| (record.id.as_str(), record))
.collect::<BTreeMap<_, _>>();
let mut ratings_by_passage = BTreeMap::<String, (f64, usize)>::new();
let mut seen_ratings = BTreeSet::<(String, String)>::new();
let mut annotators = BTreeSet::<String>::new();
for annotation in annotations {
if !(annotation.rhythm_1_to_5.is_finite()
&& (1.0..=5.0).contains(&annotation.rhythm_1_to_5))
{
return Err(HumanRhythmError::InvalidRhythmRating {
passage_id: annotation.passage_id.clone(),
value: annotation.rhythm_1_to_5,
});
}
if !records_by_id.contains_key(annotation.passage_id.as_str()) {
return Err(HumanRhythmError::UnknownPassage {
passage_id: annotation.passage_id.clone(),
});
}
let rating_key = (
annotation.passage_id.clone(),
annotation.annotator_id.clone(),
);
if !seen_ratings.insert(rating_key) {
return Err(HumanRhythmError::DuplicateRating {
passage_id: annotation.passage_id.clone(),
annotator_id: annotation.annotator_id.clone(),
});
}
let aggregate = ratings_by_passage
.entry(annotation.passage_id.clone())
.or_default();
aggregate.0 += annotation.rhythm_1_to_5;
aggregate.1 += 1;
annotators.insert(annotation.annotator_id.clone());
}
let metric_pairs = metric_names()
.into_iter()
.map(|name| {
let pairs = records
.iter()
.filter_map(|record| {
ratings_by_passage.get(&record.id).map(|(total, count)| {
(
metric_value(&record.evaluation, name),
total / *count as f64,
)
})
})
.collect::<Vec<_>>();
let (feature_values, human_values): (Vec<_>, Vec<_>) = pairs.into_iter().unzip();
HumanMetricCorrelation {
name: name.to_owned(),
spearman_rho: spearman_rho(&feature_values, &human_values),
sample_count: feature_values.len(),
}
})
.collect::<Vec<_>>();
let mut metrics = metric_pairs;
metrics.sort_by(|left, right| {
right
.spearman_rho
.map(f64::abs)
.unwrap_or_default()
.total_cmp(&left.spearman_rho.map(f64::abs).unwrap_or_default())
.then_with(|| left.name.cmp(&right.name))
});
Ok(HumanRhythmReport {
annotated_passage_count: ratings_by_passage.len(),
rating_count: annotations.len(),
annotator_count: annotators.len(),
metrics,
})
}
fn spearman_rho(left: &[f64], right: &[f64]) -> Option<f64> {
if left.len() != right.len() || left.len() < 2 {
return None;
}
let left_ranks = average_ranks(left);
let right_ranks = average_ranks(right);
let left_mean = mean(&left_ranks);
let right_mean = mean(&right_ranks);
let covariance = left_ranks
.iter()
.zip(&right_ranks)
.map(|(left, right)| (left - left_mean) * (right - right_mean))
.sum::<f64>();
let left_sum_of_squares = left_ranks
.iter()
.map(|value| (value - left_mean).powi(2))
.sum::<f64>();
let right_sum_of_squares = right_ranks
.iter()
.map(|value| (value - right_mean).powi(2))
.sum::<f64>();
let denominator = (left_sum_of_squares * right_sum_of_squares).sqrt();
(denominator > f64::EPSILON).then_some(covariance / denominator)
}
fn average_ranks(values: &[f64]) -> Vec<f64> {
let mut indexes = (0..values.len()).collect::<Vec<_>>();
indexes.sort_by(|left, right| values[*left].total_cmp(&values[*right]));
let mut ranks = vec![0.0; values.len()];
let mut start = 0;
while start < indexes.len() {
let mut end = start + 1;
while end < indexes.len()
&& values[indexes[start]].total_cmp(&values[indexes[end]]) == std::cmp::Ordering::Equal
{
end += 1;
}
let average_rank = (start + 1 + end) as f64 / 2.0;
for index in &indexes[start..end] {
ranks[*index] = average_rank;
}
start = end;
}
ranks
}
fn decode_html(source: &CorpusSource, bytes: &[u8]) -> Result<String, CorpusError> {
match source.encoding.to_ascii_lowercase().as_str() {
"utf-8" | "utf8" => {
String::from_utf8(bytes.to_vec()).map_err(|_| CorpusError::InvalidUtf8 {
source_id: source.id.clone(),
})
}
"shift_jis" | "shift-jis" | "sjis" => {
let (decoded, _, _) = SHIFT_JIS.decode(bytes);
Ok(decoded.into_owned())
}
encoding => Err(CorpusError::UnsupportedEncoding(encoding.to_owned())),
}
}
fn html_fragment_to_paragraphs(fragment: &str) -> Vec<String> {
let with_breaks = fragment
.replace("<br />", "\n")
.replace("<br/>", "\n")
.replace("<br>", "\n")
.replace("</p>", "\n\n")
.replace("</div>", "\n\n")
.replace("</h1>", "\n\n")
.replace("</h2>", "\n\n")
.replace("</h3>", "\n\n")
.replace("</h4>", "\n\n");
let fragment = Html::parse_fragment(&with_breaks);
let text = fragment.root_element().text().collect::<Vec<_>>().join("");
text.lines()
.map(normalize_whitespace)
.filter(|line| line.chars().count() >= 20)
.collect()
}
fn normalize_whitespace(text: &str) -> String {
text.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn passage_windows(paragraphs: &[String], minimum: usize, maximum: usize) -> Vec<String> {
let mut passages = Vec::new();
let mut current = String::new();
for paragraph in paragraphs {
let additional_length = if current.is_empty() { 0 } else { 2 } + paragraph.len();
if !current.is_empty() && current.len() + additional_length > maximum {
if current.chars().count() >= minimum {
passages.push(std::mem::take(&mut current));
} else {
current.clear();
}
}
if !current.is_empty() {
current.push_str("\n\n");
}
current.push_str(paragraph);
if paragraph.chars().count() > maximum && current.chars().count() >= minimum {
passages.push(std::mem::take(&mut current));
}
}
if current.chars().count() >= minimum {
passages.push(current);
}
passages
}
fn distinct_split_groups(records: &[&ScoredText]) -> usize {
group_keys(records, split_group_key).len()
}
fn distinct_authors(records: &[&ScoredText]) -> usize {
let records = records
.iter()
.copied()
.filter(|record| !author_key(record).is_empty())
.collect::<Vec<_>>();
group_keys(&records, author_key).len()
}
fn group_keys(records: &[&ScoredText], group_key: fn(&ScoredText) -> &str) -> BTreeSet<String> {
records
.iter()
.map(|record| group_key(record).to_owned())
.collect()
}
fn split_group_key(record: &ScoredText) -> &str {
if record.split_group.is_empty() {
&record.id
} else {
&record.split_group
}
}
fn author_key(record: &ScoredText) -> &str {
record.author.trim()
}
fn metric_names() -> Vec<&'static str> {
vec![
"rhythm_score",
"document_update_risk",
"sentence_length_cv",
"short_long_short_rate",
"length_contrast_rate",
"comma_per_sentence",
"question_per_sentence",
"content_token_ratio",
"hesitation_signal_rate",
"document_signal_rate",
"short_terminal_rate",
"pos_ratio:名詞",
"pos_ratio:動詞",
"pos_ratio:形容詞",
"pos_ratio:副詞",
"pos_ratio:連体詞",
"pos_ratio:助詞",
"pos_ratio:助動詞",
"pos_ratio:接続詞",
"pos_bigram_entropy",
"pos_bigram_repetition_rate",
"same_pos_transition_rate",
"mean_bunsetsu_candidate_tokens",
"bunsetsu_candidate_length_cv",
"conjunction_token_rate",
"sentence_length_dominant_power_ratio",
"lexical_topic_shift_mean",
"lexical_topic_shift_dominant_power_ratio",
]
}
fn metric_diagnostic(name: &str, good: &[&ScoredText], bad: &[&ScoredText]) -> MetricDiagnostic {
let good_values = good
.iter()
.map(|record| metric_value(&record.evaluation, name))
.collect::<Vec<_>>();
let bad_values = bad
.iter()
.map(|record| metric_value(&record.evaluation, name))
.collect::<Vec<_>>();
let higher_is_good_auc = pairwise_auc(&good_values, &bad_values);
let (direction, auc) = if higher_is_good_auc >= 0.5 {
("higher_is_good", higher_is_good_auc)
} else {
("lower_is_good", 1.0 - higher_is_good_auc)
};
let document_holdout = document_holdout_auc(name, good, bad);
let author_holdout = author_holdout_auc(name, good, bad);
let good_document_values = document_metric_values(name, good);
let bad_document_values = document_metric_values(name, bad);
let document_higher_is_good_auc = pairwise_auc(&good_document_values, &bad_document_values);
let document_auc = if document_higher_is_good_auc >= 0.5 {
document_higher_is_good_auc
} else {
1.0 - document_higher_is_good_auc
};
MetricDiagnostic {
name: name.to_owned(),
direction: direction.to_owned(),
auc,
document_holdout_auc: document_holdout.map(|(auc, _)| auc),
document_holdout_folds: document_holdout.map_or(0, |(_, folds)| folds),
author_holdout_auc: author_holdout.map(|(auc, _)| auc),
author_holdout_folds: author_holdout.map_or(0, |(_, folds)| folds),
document_auc: Some(document_auc),
document_permutation_p_value: exact_document_permutation_p_value(
&good_document_values,
&bad_document_values,
),
good_mean: mean(&good_values),
bad_mean: mean(&bad_values),
}
}
fn document_metric_values(name: &str, records: &[&ScoredText]) -> Vec<f64> {
let mut values_by_group = BTreeMap::<String, Vec<f64>>::new();
for record in records {
values_by_group
.entry(split_group_key(record).to_owned())
.or_default()
.push(metric_value(&record.evaluation, name));
}
values_by_group
.into_values()
.map(|values| mean(&values))
.collect()
}
/// Exact, two-sided label-permutation test for a difference in document means.
///
/// The cap keeps `benchmark` usable if a much larger corpus is supplied. The
/// current nine-document corpus has only 126 possible assignments.
fn exact_document_permutation_p_value(good: &[f64], bad: &[f64]) -> Option<f64> {
const MAX_EXACT_PERMUTATIONS: usize = 100_000;
let combinations = binomial_coefficient(good.len() + bad.len(), good.len())?;
if combinations > MAX_EXACT_PERMUTATIONS {
return None;
}
let observed_difference = (mean(good) - mean(bad)).abs();
let mut all_values = Vec::with_capacity(good.len() + bad.len());
all_values.extend_from_slice(good);
all_values.extend_from_slice(bad);
let total_sum = all_values.iter().sum::<f64>();
let mut extreme_count = 0;
let mut total_count = 0;
enumerate_label_assignments(
&all_values,
good.len(),
0,
0,
0.0,
total_sum,
observed_difference,
&mut extreme_count,
&mut total_count,
);
Some(extreme_count as f64 / total_count as f64)
}
#[allow(clippy::too_many_arguments)]
fn enumerate_label_assignments(
values: &[f64],
good_count: usize,
start: usize,
chosen: usize,
good_sum: f64,
total_sum: f64,
observed_difference: f64,
extreme_count: &mut usize,
total_count: &mut usize,
) {
if chosen == good_count {
let bad_count = values.len() - good_count;
let difference =
(good_sum / good_count as f64 - (total_sum - good_sum) / bad_count as f64).abs();
if difference + f64::EPSILON >= observed_difference {
*extreme_count += 1;
}
*total_count += 1;
return;
}
let remaining = good_count - chosen;
for index in start..=values.len() - remaining {
enumerate_label_assignments(
values,
good_count,
index + 1,
chosen + 1,
good_sum + values[index],
total_sum,
observed_difference,
extreme_count,
total_count,
);
}
}
fn binomial_coefficient(n: usize, k: usize) -> Option<usize> {
let k = k.min(n - k);
let mut result = 1usize;
for index in 1..=k {
result = result.checked_mul(n - k + index)? / index;
}
Some(result)
}
fn document_holdout_auc(
name: &str,
good: &[&ScoredText],
bad: &[&ScoredText],
) -> Option<(f64, usize)> {
grouped_holdout_auc(name, good, bad, split_group_key)
}
fn author_holdout_auc(
name: &str,
good: &[&ScoredText],
bad: &[&ScoredText],
) -> Option<(f64, usize)> {
let good = good
.iter()
.copied()
.filter(|record| !author_key(record).is_empty())
.collect::<Vec<_>>();
let bad = bad
.iter()
.copied()
.filter(|record| !author_key(record).is_empty())
.collect::<Vec<_>>();
grouped_holdout_auc(name, &good, &bad, author_key)
}
fn grouped_holdout_auc(
name: &str,
good: &[&ScoredText],
bad: &[&ScoredText],
group_key: fn(&ScoredText) -> &str,
) -> Option<(f64, usize)> {
let good_groups = group_keys(good, group_key);
let bad_groups = group_keys(bad, group_key);
if good_groups.len() < 2 || bad_groups.len() < 2 {
return None;
}
let mut fold_scores = Vec::new();
for held_good_group in &good_groups {
for held_bad_group in &bad_groups {
let train_good = good
.iter()
.copied()
.filter(|record| group_key(record) != held_good_group)
.collect::<Vec<_>>();
let train_bad = bad
.iter()
.copied()
.filter(|record| group_key(record) != held_bad_group)
.collect::<Vec<_>>();
let test_good = good
.iter()
.copied()
.filter(|record| group_key(record) == held_good_group)
.collect::<Vec<_>>();
let test_bad = bad
.iter()
.copied()
.filter(|record| group_key(record) == held_bad_group)
.collect::<Vec<_>>();
let train_auc = pairwise_auc(
&train_good
.iter()
.map(|record| metric_value(&record.evaluation, name))
.collect::<Vec<_>>(),
&train_bad
.iter()
.map(|record| metric_value(&record.evaluation, name))
.collect::<Vec<_>>(),
);
let test_auc = pairwise_auc(
&test_good
.iter()
.map(|record| metric_value(&record.evaluation, name))
.collect::<Vec<_>>(),
&test_bad
.iter()
.map(|record| metric_value(&record.evaluation, name))
.collect::<Vec<_>>(),
);
fold_scores.push(if train_auc >= 0.5 {
test_auc
} else {
1.0 - test_auc
});
}
}
Some((mean(&fold_scores), fold_scores.len()))
}
fn metric_value(evaluation: &Evaluation, name: &str) -> f64 {
let metrics = &evaluation.metrics;
let sentences = metrics.sentence_count.max(1) as f64;
let lexical_tokens = metrics.lexical_token_count.max(1) as f64;
if let Some(pos) = name.strip_prefix("pos_ratio:") {
return evaluation
.pos_sequence
.pos_counts
.get(pos)
.copied()
.unwrap_or_default() as f64
/ lexical_tokens;
}
match name {
"rhythm_score" => evaluation.rhythm_score,
"document_update_risk" => evaluation.document_update_risk,
"sentence_length_cv" => metrics.sentence_length_cv,
"short_long_short_rate" => {
metrics.short_long_short_patterns as f64
/ metrics.sentence_count.saturating_sub(2).max(1) as f64
}
"length_contrast_rate" => {
metrics.length_contrast_transitions as f64
/ metrics.sentence_count.saturating_sub(1).max(1) as f64
}
"comma_per_sentence" => metrics.comma_count as f64 / sentences,
"question_per_sentence" => metrics.question_count as f64 / sentences,
"content_token_ratio" => metrics.content_token_ratio,
"hesitation_signal_rate" => metrics.hesitation_signal_sentences as f64 / sentences,
"document_signal_rate" => metrics.document_signal_sentences as f64 / sentences,
"short_terminal_rate" => metrics.short_terminal_sentences as f64 / sentences,
"pos_bigram_entropy" => evaluation.pos_sequence.pos_bigram_entropy,
"pos_bigram_repetition_rate" => evaluation.pos_sequence.pos_bigram_repetition_rate,
"same_pos_transition_rate" => {
evaluation.pos_sequence.same_pos_transition_count as f64
/ evaluation.pos_sequence.pos_transition_count.max(1) as f64
}
"mean_bunsetsu_candidate_tokens" => evaluation.bunsetsu_proxy.mean_candidate_tokens,
"bunsetsu_candidate_length_cv" => evaluation.bunsetsu_proxy.candidate_length_cv,
"conjunction_token_rate" => {
evaluation.bunsetsu_proxy.conjunction_token_count as f64 / lexical_tokens
}
"sentence_length_dominant_power_ratio" => evaluation
.frequency
.sentence_length
.dominant_power_ratio
.unwrap_or(0.0),
"lexical_topic_shift_mean" => evaluation.frequency.lexical_topic_shift.mean,
"lexical_topic_shift_dominant_power_ratio" => evaluation
.frequency
.lexical_topic_shift
.dominant_power_ratio
.unwrap_or(0.0),
_ => unreachable!("metric names are fixed"),
}
}
fn pairwise_auc(good: &[f64], bad: &[f64]) -> f64 {
let mut wins = 0.0;
for good_value in good {
for bad_value in bad {
wins += match good_value.total_cmp(bad_value) {
std::cmp::Ordering::Greater => 1.0,
std::cmp::Ordering::Equal => 0.5,
std::cmp::Ordering::Less => 0.0,
};
}
}
wins / (good.len() * bad.len()) as f64
}
fn mean(values: &[f64]) -> f64 {
values.iter().sum::<f64>() / values.len() as f64
}
set shell := ["zsh", "-uc"]
default:
@just --list
test:
cargo test
fmt:
cargo fmt --check
lint:
cargo clippy --all-targets -- -D warnings
check: fmt lint test
analyze dictionary input:
cargo run -- analyze --dict {{ dictionary }} --file {{ input }}
corpus dictionary input:
cargo run -- corpus --dict {{ dictionary }} --file {{ input }}
prepare-corpus:
cargo run -- prepare --manifest corpus/sources.jsonl --output corpus/generated/passages.jsonl
build-dictionary lexicon matrix character unknown output:
cargo run -- build-dictionary --lexicon {{ lexicon }} --matrix {{ matrix }} --char {{ character }} --unknown {{ unknown }} --output {{ output }}
benchmark dictionary input:
cargo run -- benchmark --dict {{ dictionary }} --file {{ input }}
validate-human dictionary corpus annotations:
cargo run -- validate-human --dict {{ dictionary }} --corpus {{ corpus }} --annotations {{ annotations }}
profile dictionary references candidate:
cargo run -- profile --dict {{ dictionary }} --references {{ references }} --candidate {{ candidate }}
profile-corpus dictionary references candidates:
cargo run -- profile-corpus --dict {{ dictionary }} --references {{ references }} --candidates {{ candidates }}
revise dictionary references before after:
cargo run -- revise --dict {{ dictionary }} --references {{ references }} --before {{ before }} --after {{ after }}
//! Quantitative proxy metrics for Japanese cognitive writing rhythm.
//!
//! The metrics describe observable surface signals. They do not determine whether
//! a sentence updates the situation or is good writing; that remains a human
//! annotation task.
use std::collections::{BTreeMap, BTreeSet};
use std::io::Read;
use serde::{Deserialize, Serialize};
use vibrato::{Dictionary, SystemDictionaryBuilder, Tokenizer};
mod corpus;
pub use corpus::{
CorpusError, CorpusSource, HumanAnnotation, HumanMetricCorrelation, HumanRhythmError,
HumanRhythmReport, MetricDiagnostic, MetricSelectionError, MetricSelectionReport,
correlate_human_rhythm, extract_passages_from_html, fetch_passages, load_sources,
select_metrics, write_passages,
};
const DOCUMENT_SIGNALS: &[&str] = &[
"本章",
"本節",
"この章",
"この節",
"ここまで",
"次に",
"次は",
"次節",
"以下",
"本稿",
];
const HESITATION_SIGNALS: &[&str] = &[
"かもしれない",
"だろう",
"と思う",
"に違いない",
"とは思う",
"ただ",
"しかし",
"ところが",
];
const CONTENT_POS: &[&str] = &["名詞", "動詞", "形容詞", "副詞", "連体詞"];
const KNOWN_POS: &[&str] = &[
"名詞",
"動詞",
"形容詞",
"副詞",
"連体詞",
"助詞",
"助動詞",
"接続詞",
"感動詞",
"記号",
"補助記号",
"フィラー",
"空白",
];
/// One owned token yielded by a morphological analyzer.
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub struct MorphToken {
pub surface: String,
pub pos: String,
}
impl MorphToken {
#[must_use]
pub fn new(surface: impl Into<String>, pos: impl Into<String>) -> Self {
Self {
surface: surface.into(),
pos: pos.into(),
}
}
}
/// A sentence and its already-tokenized morphological units.
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub struct TokenizedSentence {
pub text: String,
pub tokens: Vec<MorphToken>,
}
impl TokenizedSentence {
#[must_use]
pub fn new(text: impl Into<String>, tokens: Vec<MorphToken>) -> Self {
Self {
text: text.into(),
tokens,
}
}
}
/// Boundary between the metric layer and a morphological analyzer.
pub trait MorphologicalAnalyzer {
fn tokenize(&self, text: &str) -> Vec<MorphToken>;
}
/// `vibrato`-backed implementation of [`MorphologicalAnalyzer`].
pub struct VibratoMorphAnalyzer {
tokenizer: Tokenizer,
}
impl VibratoMorphAnalyzer {
#[must_use]
pub const fn from_dictionary(dictionary: Dictionary) -> Self {
Self {
tokenizer: Tokenizer::new(dictionary),
}
}
/// Loads an uncompressed dictionary produced by `vibrato`.
///
/// The upstream distributed dictionaries use zstd compression and must be
/// decompressed before calling this method.
pub fn from_dictionary_reader<R: Read>(reader: R) -> vibrato::errors::Result<Self> {
Dictionary::read(reader).map(Self::from_dictionary)
}
/// Builds a dictionary from the four standard MeCab-format source files.
pub fn from_mecab_readers<S, C, P, U>(
system_lexicon: S,
matrix: C,
char_definition: P,
unknown_definition: U,
) -> vibrato::errors::Result<Self>
where
S: Read,
C: Read,
P: Read,
U: Read,
{
SystemDictionaryBuilder::from_readers(
system_lexicon,
matrix,
char_definition,
unknown_definition,
)
.map(Self::from_dictionary)
}
}
impl MorphologicalAnalyzer for VibratoMorphAnalyzer {
fn tokenize(&self, text: &str) -> Vec<MorphToken> {
let mut worker = self.tokenizer.new_worker();
worker.reset_sentence(text);
worker.tokenize();
worker
.token_iter()
.map(|token| MorphToken::new(token.surface(), pos_from_feature(token.feature())))
.collect()
}
}
/// Observable values used to make the score auditable.
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct RhythmMetrics {
pub sentence_count: usize,
pub token_count: usize,
pub lexical_token_count: usize,
pub content_token_count: usize,
pub content_token_ratio: f64,
pub mean_sentence_tokens: f64,
pub sentence_length_cv: f64,
pub length_contrast_transitions: usize,
pub short_long_short_patterns: usize,
pub comma_count: usize,
pub terminal_punctuation_count: usize,
pub question_count: usize,
pub hesitation_signal_sentences: usize,
pub document_signal_sentences: usize,
pub short_terminal_sentences: usize,
}
/// Part-of-speech frequencies and within-sentence transition statistics.
///
/// Punctuation is excluded and transitions never cross a sentence boundary.
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
pub struct PosSequenceMetrics {
pub pos_counts: BTreeMap<String, usize>,
pub pos_bigram_counts: BTreeMap<String, usize>,
pub pos_transition_count: usize,
pub same_pos_transition_count: usize,
/// Share of POS transitions that repeat an already-observed POS bigram.
pub pos_bigram_repetition_rate: f64,
/// Shannon entropy (base 2) of observed POS bigram frequencies.
pub pos_bigram_entropy: f64,
}
/// Surface-level bunsetsu candidates, not a dependency parse.
///
/// Candidates end at a particle, auxiliary, conjunction, or punctuation.
/// Adjacent candidate pairs are not dependency edges; an actual parser is
/// required to determine heads and relation labels.
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
pub struct BunsetsuProxyMetrics {
pub bunsetsu_candidate_count: usize,
pub mean_candidate_tokens: f64,
pub candidate_length_cv: f64,
pub adjacent_candidate_pairs: usize,
pub conjunction_token_count: usize,
}
/// Frequency-domain and serial-correlation summary of a sentence-indexed series.
///
/// Frequencies are cycles per sentence, not acoustic Hertz. Values are absent
/// for fewer than four samples or for a series without variation. The
/// autocorrelation fields are absent for fewer than eight samples.
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
pub struct FrequencyPattern {
pub sample_count: usize,
pub mean: f64,
pub dominant_frequency_cycles_per_sentence: Option<f64>,
pub dominant_period_sentences: Option<f64>,
pub dominant_power_ratio: Option<f64>,
/// Highest positive Pearson correlation between the series and a lagged copy.
pub max_lag_autocorrelation: Option<f64>,
/// Lag, in sentences, at which `max_lag_autocorrelation` occurs.
pub max_lag_autocorrelation_lag: Option<usize>,
/// One-sided permutation p-value for the maximum lag autocorrelation.
///
/// The null distribution is formed from 999 deterministic shuffles of the
/// same sentence-length values, each searched over the same lag range.
pub max_lag_autocorrelation_permutation_p_value: Option<f64>,
}
/// Frequency-domain proxies for prose rhythm and lexical topical movement.
///
/// `lexical_topic_shift` is the adjacent-sentence Jaccard distance over
/// surface content words. It is not a semantic embedding distance.
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
pub struct FrequencyMetrics {
pub sentence_length: FrequencyPattern,
pub lexical_topic_shift: FrequencyPattern,
}
/// One observed feature range in a reference writing profile.
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct PatternTarget {
pub feature: String,
pub lower_bound: f64,
pub upper_bound: f64,
pub reference_count: usize,
}
/// A target profile inferred from texts chosen as suitable references.
///
/// It describes a writing context, not a universal definition of good prose.
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct PatternTargetProfile {
pub reference_count: usize,
pub targets: Vec<PatternTarget>,
}
/// Direction of a candidate's adjustment relative to a target range.
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum PatternAdjustment {
Increase,
Decrease,
WithinRange,
}
/// One auditable profile comparison for a candidate text.
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct PatternDeviation {
pub feature: String,
pub value: f64,
pub lower_bound: f64,
pub upper_bound: f64,
pub distance: f64,
pub adjustment: PatternAdjustment,
}
/// Feedback that a generator or human editor can use for another revision.
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct PatternCorrection {
pub assessed_feature_count: usize,
/// Mean absolute distance outside reference ranges; lower is closer.
pub mean_distance: f64,
pub deviations: Vec<PatternDeviation>,
}
/// How one profile feature changed across a revision.
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct RevisionFeatureChange {
pub feature: String,
pub before_distance: f64,
pub after_distance: f64,
/// Positive values mean that the revision moved closer to the profile.
pub distance_reduction: f64,
pub before_adjustment: PatternAdjustment,
pub after_adjustment: PatternAdjustment,
}
/// Auditable before/after evaluation against one reference writing profile.
///
/// `profile_gap_reduction_score` is the percentage of the initial mean
/// profile distance removed by the revision. It is absent when the original
/// was already in range, and it is not a general writing-quality score.
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct RevisionEvaluation {
pub before: Evaluation,
pub after: Evaluation,
pub before_correction: PatternCorrection,
pub after_correction: PatternCorrection,
pub profile_distance_before: f64,
pub profile_distance_after: f64,
pub profile_distance_reduction: f64,
pub profile_gap_reduction_score: Option<f64>,
/// Jaccard similarity of content-word surface-form sets, when the source
/// text contains at least one content word.
pub content_token_retention: Option<f64>,
pub rhythm_score_change: f64,
pub resolved_feature_count: usize,
pub introduced_feature_count: usize,
pub feature_changes: Vec<RevisionFeatureChange>,
}
impl PatternTargetProfile {
/// Infers feature ranges from selected, genre-matched reference texts.
#[must_use]
pub fn from_references(references: &[Evaluation]) -> Self {
let targets = PATTERN_PROFILE_FEATURES
.iter()
.filter_map(|feature| {
let values = references
.iter()
.filter_map(|evaluation| pattern_profile_value(evaluation, feature))
.collect::<Vec<_>>();
let (lower_bound, upper_bound) = profile_interval(&values)?;
Some(PatternTarget {
feature: (*feature).to_owned(),
lower_bound,
upper_bound,
reference_count: values.len(),
})
})
.collect();
Self {
reference_count: references.len(),
targets,
}
}
/// Reports which observed features lie outside this profile's ranges.
#[must_use]
pub fn correction_for(&self, candidate: &Evaluation) -> PatternCorrection {
let deviations = self
.targets
.iter()
.filter_map(|target| {
let value = pattern_profile_value(candidate, &target.feature)?;
let (distance, adjustment) = if value < target.lower_bound {
(target.lower_bound - value, PatternAdjustment::Increase)
} else if value > target.upper_bound {
(value - target.upper_bound, PatternAdjustment::Decrease)
} else {
(0.0, PatternAdjustment::WithinRange)
};
Some(PatternDeviation {
feature: target.feature.clone(),
value,
lower_bound: target.lower_bound,
upper_bound: target.upper_bound,
distance,
adjustment,
})
})
.collect::<Vec<_>>();
let assessed_feature_count = deviations.len();
let mean_distance = if assessed_feature_count == 0 {
0.0
} else {
deviations.iter().map(|item| item.distance).sum::<f64>() / assessed_feature_count as f64
};
PatternCorrection {
assessed_feature_count,
mean_distance,
deviations,
}
}
}
/// Compares an already-evaluated before/after pair against a reference profile.
///
/// This deliberately keeps profile alignment and content preservation
/// separate: a rewrite must not receive a higher alignment score merely by
/// changing its subject matter.
#[must_use]
pub fn assess_revision(
profile: &PatternTargetProfile,
before: &Evaluation,
after: &Evaluation,
content_token_retention: Option<f64>,
) -> RevisionEvaluation {
let before_correction = profile.correction_for(before);
let after_correction = profile.correction_for(after);
let after_by_feature = after_correction
.deviations
.iter()
.map(|deviation| (deviation.feature.as_str(), deviation))
.collect::<BTreeMap<_, _>>();
let feature_changes = before_correction
.deviations
.iter()
.filter_map(|before_deviation| {
let after_deviation = after_by_feature.get(before_deviation.feature.as_str())?;
Some(RevisionFeatureChange {
feature: before_deviation.feature.clone(),
before_distance: before_deviation.distance,
after_distance: after_deviation.distance,
distance_reduction: before_deviation.distance - after_deviation.distance,
before_adjustment: before_deviation.adjustment.clone(),
after_adjustment: after_deviation.adjustment.clone(),
})
})
.collect::<Vec<_>>();
let resolved_feature_count = feature_changes
.iter()
.filter(|change| {
change.before_distance > f64::EPSILON && change.after_distance <= f64::EPSILON
})
.count();
let introduced_feature_count = feature_changes
.iter()
.filter(|change| {
change.before_distance <= f64::EPSILON && change.after_distance > f64::EPSILON
})
.count();
let profile_distance_before = before_correction.mean_distance;
let profile_distance_after = after_correction.mean_distance;
let profile_distance_reduction = profile_distance_before - profile_distance_after;
let profile_gap_reduction_score = (profile_distance_before > f64::EPSILON)
.then_some(profile_distance_reduction / profile_distance_before * 100.0);
RevisionEvaluation {
before: before.clone(),
after: after.clone(),
before_correction,
after_correction,
profile_distance_before,
profile_distance_after,
profile_distance_reduction,
profile_gap_reduction_score,
content_token_retention,
rhythm_score_change: after.rhythm_score - before.rhythm_score,
resolved_feature_count,
introduced_feature_count,
feature_changes,
}
}
/// A deliberately inspectable heuristic, not a writing-quality classifier.
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct Evaluation {
/// 0--100: surface proxy for variation and sentence cadence.
pub rhythm_score: f64,
/// 0--100: rate of document-progress signals that require human review.
pub document_update_risk: f64,
pub metrics: RhythmMetrics,
pub pos_sequence: PosSequenceMetrics,
pub bunsetsu_proxy: BunsetsuProxyMetrics,
pub frequency: FrequencyMetrics,
}
/// Human-labelled text used to calibrate or falsify the heuristic.
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct LabeledText {
pub id: String,
pub label: String,
#[serde(default)]
pub source_id: String,
#[serde(default)]
pub author: String,
#[serde(default)]
pub source_url: String,
#[serde(default)]
pub split_group: String,
#[serde(default)]
pub domain: String,
#[serde(default)]
pub source_hash: String,
pub text: String,
}
/// One labelled text with its proxy metrics.
#[derive(Clone, Debug, Serialize)]
pub struct ScoredText {
pub id: String,
pub label: String,
pub source_id: String,
pub author: String,
pub split_group: String,
pub domain: String,
pub evaluation: Evaluation,
}
/// Mean scores for a label. A corpus should contain independently annotated
/// documents before these values are interpreted.
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct LabelSummary {
pub count: usize,
pub mean_rhythm_score: f64,
pub mean_document_update_risk: f64,
}
#[derive(Clone, Debug, Serialize)]
pub struct CorpusEvaluation {
pub records: Vec<ScoredText>,
pub labels: BTreeMap<String, LabelSummary>,
}
const PATTERN_PROFILE_FEATURES: &[&str] = &[
"sentence_length_cv",
"lexical_topic_shift_mean",
"pos_bigram_entropy",
];
const ROBUST_PROFILE_MINIMUM_REFERENCES: usize = 10;
const PROFILE_LOWER_QUANTILE: f64 = 0.1;
const PROFILE_UPPER_QUANTILE: f64 = 0.9;
const AUTOCORRELATION_MINIMUM_SAMPLES: usize = 8;
const AUTOCORRELATION_MAXIMUM_LAG: usize = 8;
const AUTOCORRELATION_PERMUTATIONS: usize = 999;
/// Splits Japanese prose while retaining `。!?!?` on each sentence.
#[must_use]
pub fn split_sentences(text: &str) -> Vec<String> {
let mut sentences = Vec::new();
let mut current = String::new();
for character in text.chars() {
current.push(character);
if is_terminal_punctuation(character) {
push_trimmed_sentence(&mut sentences, &mut current);
}
}
push_trimmed_sentence(&mut sentences, &mut current);
sentences
}
/// Runs the surface evaluation on sentences whose tokens are already known.
#[must_use]
pub fn evaluate(sentences: &[TokenizedSentence]) -> Evaluation {
let sentence_lengths: Vec<usize> = sentences
.iter()
.map(|sentence| lexical_token_count(&sentence.tokens))
.collect();
let sentence_count = sentences.len();
let token_count = sentences.iter().map(|sentence| sentence.tokens.len()).sum();
let lexical_token_count = sentences
.iter()
.map(|sentence| lexical_token_count(&sentence.tokens))
.sum();
let content_token_count = sentences
.iter()
.flat_map(|sentence| &sentence.tokens)
.filter(|token| is_content_pos(&token.pos))
.count();
let mean_sentence_tokens = mean_usize(&sentence_lengths);
let sentence_length_cv = coefficient_of_variation(&sentence_lengths, mean_sentence_tokens);
let length_contrast_transitions = contrast_transitions(&sentence_lengths, mean_sentence_tokens);
let short_long_short_patterns = short_long_short_patterns(&sentence_lengths);
let comma_count = sentences
.iter()
.map(|sentence| count_characters(&sentence.text, &[',', '、', ',']))
.sum();
let terminal_punctuation_count = sentences
.iter()
.map(|sentence| count_terminal_punctuation(&sentence.text))
.sum();
let question_count = sentences
.iter()
.map(|sentence| count_characters(&sentence.text, &['?', '?']))
.sum();
let hesitation_signal_sentences = sentences
.iter()
.filter(|sentence| contains_any(&sentence.text, HESITATION_SIGNALS))
.count();
let document_signal_sentences = sentences
.iter()
.filter(|sentence| contains_any(&sentence.text, DOCUMENT_SIGNALS))
.count();
let short_terminal_sentences = sentences
.iter()
.zip(&sentence_lengths)
.filter(|(sentence, length)| {
**length > 0
&& (**length as f64) <= mean_sentence_tokens * 0.7
&& contains_terminal_punctuation(&sentence.text)
})
.count();
let content_token_ratio = if lexical_token_count == 0 {
0.0
} else {
content_token_count as f64 / lexical_token_count as f64
};
let pos_sequence = pos_sequence_metrics(sentences);
let bunsetsu_proxy = bunsetsu_proxy_metrics(sentences);
let frequency = frequency_metrics(sentences, &sentence_lengths);
let metrics = RhythmMetrics {
sentence_count,
token_count,
lexical_token_count,
content_token_count,
content_token_ratio,
mean_sentence_tokens,
sentence_length_cv,
length_contrast_transitions,
short_long_short_patterns,
comma_count,
terminal_punctuation_count,
question_count,
hesitation_signal_sentences,
document_signal_sentences,
short_terminal_sentences,
};
Evaluation {
rhythm_score: rhythm_score(&metrics),
document_update_risk: percentage(document_signal_sentences, sentence_count),
metrics,
pos_sequence,
bunsetsu_proxy,
frequency,
}
}
/// Tokenizes and evaluates a whole text using an injected analyzer.
#[must_use]
pub fn evaluate_text<A: MorphologicalAnalyzer>(analyzer: &A, text: &str) -> Evaluation {
let sentences: Vec<_> = split_sentences(text)
.into_iter()
.map(|sentence| {
let tokens = analyzer.tokenize(&sentence);
TokenizedSentence::new(sentence, tokens)
})
.collect();
evaluate(&sentences)
}
/// Evaluates a textual revision while retaining the surface content-word overlap.
#[must_use]
pub fn evaluate_revision<A: MorphologicalAnalyzer>(
analyzer: &A,
profile: &PatternTargetProfile,
before_text: &str,
after_text: &str,
) -> RevisionEvaluation {
let before = evaluate_text(analyzer, before_text);
let after = evaluate_text(analyzer, after_text);
let content_token_retention = content_token_retention(analyzer, before_text, after_text);
assess_revision(profile, &before, &after, content_token_retention)
}
/// Scores each labelled example and groups arithmetic means by label.
#[must_use]
pub fn evaluate_corpus<A: MorphologicalAnalyzer>(
analyzer: &A,
corpus: &[LabeledText],
) -> CorpusEvaluation {
let records: Vec<_> = corpus
.iter()
.map(|entry| ScoredText {
id: entry.id.clone(),
label: entry.label.clone(),
source_id: entry.source_id.clone(),
author: entry.author.clone(),
split_group: entry.split_group.clone(),
domain: entry.domain.clone(),
evaluation: evaluate_text(analyzer, &entry.text),
})
.collect();
let mut totals: BTreeMap<String, (usize, f64, f64)> = BTreeMap::new();
for record in &records {
let total = totals.entry(record.label.clone()).or_default();
total.0 += 1;
total.1 += record.evaluation.rhythm_score;
total.2 += record.evaluation.document_update_risk;
}
let labels = totals
.into_iter()
.map(|(label, (count, rhythm_total, risk_total))| {
(
label,
LabelSummary {
count,
mean_rhythm_score: rhythm_total / count as f64,
mean_document_update_risk: risk_total / count as f64,
},
)
})
.collect();
CorpusEvaluation { records, labels }
}
fn push_trimmed_sentence(sentences: &mut Vec<String>, current: &mut String) {
let trimmed = current.trim();
if !trimmed.is_empty() {
sentences.push(trimmed.to_owned());
}
current.clear();
}
fn pos_from_feature(feature: &str) -> String {
feature
.split(',')
.find(|item| KNOWN_POS.contains(item))
.unwrap_or("未知")
.to_owned()
}
fn is_terminal_punctuation(character: char) -> bool {
matches!(character, '。' | '!' | '?' | '!' | '?')
}
fn contains_terminal_punctuation(text: &str) -> bool {
text.chars().any(is_terminal_punctuation)
}
fn count_terminal_punctuation(text: &str) -> usize {
text.chars()
.filter(|character| is_terminal_punctuation(*character))
.count()
}
fn count_characters(text: &str, targets: &[char]) -> usize {
text.chars()
.filter(|character| targets.contains(character))
.count()
}
fn contains_any(text: &str, markers: &[&str]) -> bool {
markers.iter().any(|marker| text.contains(marker))
}
fn is_content_pos(pos: &str) -> bool {
CONTENT_POS.contains(&pos)
}
fn is_punctuation(token: &MorphToken) -> bool {
token.pos.contains("記号")
|| token.surface.chars().all(|character| {
character.is_ascii_punctuation() || "、。!?「」『』()[]【】".contains(character)
})
}
fn lexical_token_count(tokens: &[MorphToken]) -> usize {
tokens.iter().filter(|token| !is_punctuation(token)).count()
}
fn content_token_set<A: MorphologicalAnalyzer>(analyzer: &A, text: &str) -> BTreeSet<String> {
split_sentences(text)
.into_iter()
.flat_map(|sentence| analyzer.tokenize(&sentence))
.filter(|token| is_content_pos(&token.pos))
.map(|token| token.surface.to_lowercase())
.collect()
}
fn content_token_retention<A: MorphologicalAnalyzer>(
analyzer: &A,
before_text: &str,
after_text: &str,
) -> Option<f64> {
let before = content_token_set(analyzer, before_text);
if before.is_empty() {
return None;
}
let after = content_token_set(analyzer, after_text);
let union_count = before.union(&after).count();
Some(before.intersection(&after).count() as f64 / union_count as f64)
}
fn pos_sequence_metrics(sentences: &[TokenizedSentence]) -> PosSequenceMetrics {
let mut pos_counts = BTreeMap::new();
let mut pos_bigram_counts = BTreeMap::new();
let mut pos_transition_count = 0;
let mut same_pos_transition_count = 0;
for sentence in sentences {
let poses: Vec<_> = sentence
.tokens
.iter()
.filter(|token| !is_punctuation(token))
.map(|token| token.pos.as_str())
.collect();
for pos in &poses {
*pos_counts.entry((*pos).to_owned()).or_default() += 1;
}
for pair in poses.windows(2) {
pos_transition_count += 1;
if pair[0] == pair[1] {
same_pos_transition_count += 1;
}
*pos_bigram_counts
.entry(format!("{}→{}", pair[0], pair[1]))
.or_default() += 1;
}
}
PosSequenceMetrics {
pos_counts,
pos_bigram_entropy: shannon_entropy(&pos_bigram_counts),
pos_bigram_repetition_rate: if pos_transition_count == 0 {
0.0
} else {
(pos_transition_count - pos_bigram_counts.len()) as f64 / pos_transition_count as f64
},
pos_bigram_counts,
pos_transition_count,
same_pos_transition_count,
}
}
fn bunsetsu_proxy_metrics(sentences: &[TokenizedSentence]) -> BunsetsuProxyMetrics {
let mut candidate_lengths = Vec::new();
let mut adjacent_candidate_pairs = 0;
let mut conjunction_token_count = 0;
for sentence in sentences {
let mut candidate_count = 0;
let mut current_length = 0;
for token in &sentence.tokens {
if token.pos == "接続詞" {
conjunction_token_count += 1;
}
if is_punctuation(token) {
push_bunsetsu_candidate(
&mut candidate_lengths,
&mut current_length,
&mut candidate_count,
);
continue;
}
current_length += 1;
if ends_bunsetsu_candidate(token) {
push_bunsetsu_candidate(
&mut candidate_lengths,
&mut current_length,
&mut candidate_count,
);
}
}
push_bunsetsu_candidate(
&mut candidate_lengths,
&mut current_length,
&mut candidate_count,
);
adjacent_candidate_pairs += candidate_count.saturating_sub(1);
}
let mean_candidate_tokens = mean_usize(&candidate_lengths);
BunsetsuProxyMetrics {
bunsetsu_candidate_count: candidate_lengths.len(),
mean_candidate_tokens,
candidate_length_cv: coefficient_of_variation(&candidate_lengths, mean_candidate_tokens),
adjacent_candidate_pairs,
conjunction_token_count,
}
}
fn frequency_metrics(
sentences: &[TokenizedSentence],
sentence_lengths: &[usize],
) -> FrequencyMetrics {
FrequencyMetrics {
sentence_length: frequency_pattern(
&sentence_lengths
.iter()
.map(|length| *length as f64)
.collect::<Vec<_>>(),
),
lexical_topic_shift: frequency_pattern(&lexical_topic_shift_series(sentences)),
}
}
fn lexical_topic_shift_series(sentences: &[TokenizedSentence]) -> Vec<f64> {
let mut previous = None;
let mut shifts = Vec::with_capacity(sentences.len());
for sentence in sentences {
let current = sentence
.tokens
.iter()
.filter(|token| is_content_pos(&token.pos))
.map(|token| token.surface.to_lowercase())
.collect::<BTreeSet<_>>();
let shift = previous.as_ref().map_or(0.0, |prior: &BTreeSet<String>| {
let union_count = prior.union(&current).count();
if union_count == 0 {
0.0
} else {
1.0 - prior.intersection(&current).count() as f64 / union_count as f64
}
});
shifts.push(shift);
previous = Some(current);
}
shifts
}
fn frequency_pattern(values: &[f64]) -> FrequencyPattern {
let sample_count = values.len();
let mean = if sample_count == 0 {
0.0
} else {
values.iter().sum::<f64>() / sample_count as f64
};
let maximum_autocorrelation = max_positive_lag_autocorrelation(values);
let max_lag_autocorrelation_permutation_p_value =
maximum_autocorrelation.and_then(|(_, correlation)| {
max_lag_autocorrelation_permutation_p_value(values, correlation)
});
if sample_count < 4 {
return FrequencyPattern {
sample_count,
mean,
max_lag_autocorrelation: maximum_autocorrelation.map(|(_, correlation)| correlation),
max_lag_autocorrelation_lag: maximum_autocorrelation.map(|(lag, _)| lag),
max_lag_autocorrelation_permutation_p_value,
..FrequencyPattern::default()
};
}
let centered = values.iter().map(|value| value - mean).collect::<Vec<_>>();
let mut total_power = 0.0;
let mut dominant = None;
for frequency_index in 1..=sample_count / 2 {
let mut real = 0.0;
let mut imaginary = 0.0;
for (sample_index, value) in centered.iter().enumerate() {
let phase = std::f64::consts::TAU * frequency_index as f64 * sample_index as f64
/ sample_count as f64;
real += value * phase.cos();
imaginary -= value * phase.sin();
}
let power = real.mul_add(real, imaginary * imaginary);
total_power += power;
if dominant.is_none_or(|(_, dominant_power)| power > dominant_power) {
dominant = Some((frequency_index, power));
}
}
let Some((frequency_index, dominant_power)) = dominant else {
return FrequencyPattern {
sample_count,
mean,
max_lag_autocorrelation: maximum_autocorrelation.map(|(_, correlation)| correlation),
max_lag_autocorrelation_lag: maximum_autocorrelation.map(|(lag, _)| lag),
max_lag_autocorrelation_permutation_p_value,
..FrequencyPattern::default()
};
};
if total_power <= f64::EPSILON {
return FrequencyPattern {
sample_count,
mean,
max_lag_autocorrelation: maximum_autocorrelation.map(|(_, correlation)| correlation),
max_lag_autocorrelation_lag: maximum_autocorrelation.map(|(lag, _)| lag),
max_lag_autocorrelation_permutation_p_value,
..FrequencyPattern::default()
};
}
FrequencyPattern {
sample_count,
mean,
dominant_frequency_cycles_per_sentence: Some(frequency_index as f64 / sample_count as f64),
dominant_period_sentences: Some(sample_count as f64 / frequency_index as f64),
dominant_power_ratio: Some(dominant_power / total_power),
max_lag_autocorrelation: maximum_autocorrelation.map(|(_, correlation)| correlation),
max_lag_autocorrelation_lag: maximum_autocorrelation.map(|(lag, _)| lag),
max_lag_autocorrelation_permutation_p_value,
}
}
fn max_positive_lag_autocorrelation(values: &[f64]) -> Option<(usize, f64)> {
if values.len() < AUTOCORRELATION_MINIMUM_SAMPLES {
return None;
}
let maximum_lag = (values.len() / 2).min(AUTOCORRELATION_MAXIMUM_LAG);
let mut maximum = None;
for lag in 1..=maximum_lag {
let Some(correlation) = lag_pearson_correlation(values, lag) else {
continue;
};
if correlation <= 0.0 {
continue;
}
if maximum.is_none_or(|(_, best)| correlation > best) {
maximum = Some((lag, correlation));
}
}
maximum
}
fn max_lag_autocorrelation_permutation_p_value(
values: &[f64],
observed_correlation: f64,
) -> Option<f64> {
if values.len() < AUTOCORRELATION_MINIMUM_SAMPLES {
return None;
}
let mut state = autocorrelation_seed(values);
let mut at_least_as_large = 0_usize;
let mut shuffled = values.to_vec();
for _ in 0..AUTOCORRELATION_PERMUTATIONS {
shuffled.copy_from_slice(values);
deterministic_shuffle(&mut shuffled, &mut state);
if max_positive_lag_autocorrelation(&shuffled)
.is_some_and(|(_, correlation)| correlation >= observed_correlation)
{
at_least_as_large += 1;
}
}
Some((at_least_as_large + 1) as f64 / (AUTOCORRELATION_PERMUTATIONS + 1) as f64)
}
fn lag_pearson_correlation(values: &[f64], lag: usize) -> Option<f64> {
let paired_count = values.len().checked_sub(lag)?;
if paired_count < 4 {
return None;
}
let earlier = &values[..paired_count];
let later = &values[lag..];
let earlier_mean = earlier.iter().sum::<f64>() / paired_count as f64;
let later_mean = later.iter().sum::<f64>() / paired_count as f64;
let (covariance, earlier_sum_squares, later_sum_squares) = earlier.iter().zip(later).fold(
(0.0, 0.0, 0.0),
|(covariance, earlier_sum_squares, later_sum_squares), (earlier, later)| {
let earlier_delta = earlier - earlier_mean;
let later_delta = later - later_mean;
(
covariance + earlier_delta * later_delta,
earlier_sum_squares + earlier_delta * earlier_delta,
later_sum_squares + later_delta * later_delta,
)
},
);
let denominator = (earlier_sum_squares * later_sum_squares).sqrt();
(denominator > f64::EPSILON).then_some(covariance / denominator)
}
fn autocorrelation_seed(values: &[f64]) -> u64 {
values
.iter()
.enumerate()
.fold(0x9e37_79b9_7f4a_7c15, |seed, (index, value)| {
let rotated = value
.to_bits()
.rotate_left((index % u64::BITS as usize) as u32);
seed.rotate_left(7) ^ rotated.wrapping_mul(0xbf58_476d_1ce4_e5b9)
})
}
fn deterministic_shuffle(values: &mut [f64], state: &mut u64) {
for last_index in (1..values.len()).rev() {
*state = state
.wrapping_mul(6_364_136_223_846_793_005)
.wrapping_add(1_442_695_040_888_963_407);
let swap_index = (*state % (last_index + 1) as u64) as usize;
values.swap(last_index, swap_index);
}
}
fn pattern_profile_value(evaluation: &Evaluation, feature: &str) -> Option<f64> {
match feature {
"sentence_length_cv" => Some(evaluation.metrics.sentence_length_cv),
"lexical_topic_shift_mean" => Some(evaluation.frequency.lexical_topic_shift.mean),
"pos_bigram_entropy" => Some(evaluation.pos_sequence.pos_bigram_entropy),
_ => None,
}
}
fn profile_interval(values: &[f64]) -> Option<(f64, f64)> {
if values.len() < ROBUST_PROFILE_MINIMUM_REFERENCES {
return Some((
values.iter().copied().reduce(f64::min)?,
values.iter().copied().reduce(f64::max)?,
));
}
Some((
quantile(values, PROFILE_LOWER_QUANTILE)?,
quantile(values, PROFILE_UPPER_QUANTILE)?,
))
}
fn quantile(values: &[f64], quantile: f64) -> Option<f64> {
let mut sorted = values.to_vec();
sorted.sort_by(f64::total_cmp);
let last_index = sorted.len().checked_sub(1)?;
let position = quantile * last_index as f64;
let lower_index = position.floor() as usize;
let upper_index = position.ceil() as usize;
let interpolation = position - lower_index as f64;
Some(sorted[lower_index] * (1.0 - interpolation) + sorted[upper_index] * interpolation)
}
fn push_bunsetsu_candidate(
candidate_lengths: &mut Vec<usize>,
current_length: &mut usize,
candidate_count: &mut usize,
) {
if *current_length == 0 {
return;
}
candidate_lengths.push(*current_length);
*candidate_count += 1;
*current_length = 0;
}
fn ends_bunsetsu_candidate(token: &MorphToken) -> bool {
matches!(token.pos.as_str(), "助詞" | "助動詞" | "接続詞")
}
fn shannon_entropy(counts: &BTreeMap<String, usize>) -> f64 {
let total = counts.values().sum::<usize>();
if total == 0 {
return 0.0;
}
counts
.values()
.map(|count| {
let probability = *count as f64 / total as f64;
-probability * probability.log2()
})
.sum()
}
fn mean_usize(values: &[usize]) -> f64 {
if values.is_empty() {
0.0
} else {
values.iter().sum::<usize>() as f64 / values.len() as f64
}
}
fn coefficient_of_variation(values: &[usize], mean: f64) -> f64 {
if values.is_empty() || mean == 0.0 {
return 0.0;
}
let variance = values
.iter()
.map(|value| (*value as f64 - mean).powi(2))
.sum::<f64>()
/ values.len() as f64;
variance.sqrt() / mean
}
fn contrast_transitions(lengths: &[usize], mean: f64) -> usize {
lengths
.windows(2)
.filter(|pair| (pair[0] as f64 - pair[1] as f64).abs() >= mean * 0.35)
.count()
}
fn short_long_short_patterns(lengths: &[usize]) -> usize {
lengths
.windows(3)
.filter(|window| {
let first = window[0] as f64;
let middle = window[1] as f64;
let last = window[2] as f64;
let outer_mean = (first + last) / 2.0;
outer_mean > 0.0 && middle >= outer_mean * 1.6 && last <= middle * 0.55
})
.count()
}
fn percentage(numerator: usize, denominator: usize) -> f64 {
if denominator == 0 {
0.0
} else {
(numerator as f64 / denominator as f64) * 100.0
}
}
fn capped_ratio(numerator: usize, denominator: usize) -> f64 {
if denominator == 0 {
0.0
} else {
(numerator as f64 / denominator as f64).min(1.0)
}
}
fn rhythm_score(metrics: &RhythmMetrics) -> f64 {
let variation = (metrics.sentence_length_cv / 0.75).min(1.0);
let cadence = capped_ratio(
metrics.short_long_short_patterns,
metrics.sentence_count.saturating_sub(2),
);
let transitions = capped_ratio(
metrics.length_contrast_transitions,
metrics.sentence_count.saturating_sub(1),
);
let hesitation = capped_ratio(
metrics.hesitation_signal_sentences,
((metrics.sentence_count as f64) * 0.33).ceil() as usize,
);
let short_stops = capped_ratio(metrics.short_terminal_sentences, metrics.sentence_count);
100.0
* (0.30 * variation
+ 0.30 * cadence
+ 0.20 * transitions
+ 0.10 * hesitation
+ 0.10 * short_stops)
}
use std::env;
use std::error::Error;
use std::fs::{self, File};
use cognitive_rhythm_eval::{
HumanAnnotation, PatternCorrection, PatternTargetProfile, RevisionEvaluation,
VibratoMorphAnalyzer, correlate_human_rhythm, evaluate_corpus, evaluate_revision,
evaluate_text, fetch_passages, load_sources, select_metrics, write_passages,
};
use serde::Serialize;
use serde::de::DeserializeOwned;
use vibrato::SystemDictionaryBuilder;
#[derive(Serialize)]
struct BenchmarkOutput {
corpus: cognitive_rhythm_eval::CorpusEvaluation,
selection: cognitive_rhythm_eval::MetricSelectionReport,
}
#[derive(Serialize)]
struct ProfileOutput {
profile: PatternTargetProfile,
correction: PatternCorrection,
}
#[derive(Serialize)]
struct ProfileCorpusRecord {
id: String,
label: String,
source_id: String,
author: String,
split_group: String,
domain: String,
correction: PatternCorrection,
}
#[derive(Serialize)]
struct ProfileCorpusOutput {
profile: PatternTargetProfile,
records: Vec<ProfileCorpusRecord>,
}
#[derive(Serialize)]
struct RevisionOutput {
profile: PatternTargetProfile,
revision: RevisionEvaluation,
}
fn main() -> Result<(), Box<dyn Error>> {
let arguments: Vec<_> = env::args().skip(1).collect();
match arguments.as_slice() {
[
command,
manifest_flag,
manifest_path,
output_flag,
output_path,
] if command == "prepare" && manifest_flag == "--manifest" && output_flag == "--output" => {
let sources = load_sources(manifest_path)?;
let passages = fetch_passages(&sources)?;
write_passages(output_path, &passages)?;
println!("prepared {} passages at {output_path}", passages.len());
Ok(())
}
[
command,
lexicon_flag,
lexicon_path,
matrix_flag,
matrix_path,
char_flag,
char_path,
unknown_flag,
unknown_path,
output_flag,
output_path,
] if command == "build-dictionary"
&& lexicon_flag == "--lexicon"
&& matrix_flag == "--matrix"
&& char_flag == "--char"
&& unknown_flag == "--unknown"
&& output_flag == "--output" =>
{
let dictionary = SystemDictionaryBuilder::from_readers(
File::open(lexicon_path)?,
File::open(matrix_path)?,
File::open(char_path)?,
File::open(unknown_path)?,
)?;
dictionary.write(File::create(output_path)?)?;
println!("built Vibrato dictionary at {output_path}");
Ok(())
}
[
command,
dictionary_flag,
dictionary_path,
references_flag,
references_path,
candidate_flag,
candidate_path,
] if command == "profile"
&& dictionary_flag == "--dict"
&& references_flag == "--references"
&& candidate_flag == "--candidate" =>
{
let analyzer =
VibratoMorphAnalyzer::from_dictionary_reader(File::open(dictionary_path)?)?;
let profile = profile_from_references(&analyzer, references_path)?;
let candidate = evaluate_text(&analyzer, &fs::read_to_string(candidate_path)?);
let correction = profile.correction_for(&candidate);
print_json(&ProfileOutput {
profile,
correction,
})
}
[
command,
dictionary_flag,
dictionary_path,
references_flag,
references_path,
candidates_flag,
candidates_path,
] if command == "profile-corpus"
&& dictionary_flag == "--dict"
&& references_flag == "--references"
&& candidates_flag == "--candidates" =>
{
let analyzer =
VibratoMorphAnalyzer::from_dictionary_reader(File::open(dictionary_path)?)?;
let profile = profile_from_references(&analyzer, references_path)?;
let candidates = parse_jsonl(&fs::read_to_string(candidates_path)?)?;
let candidates = evaluate_corpus(&analyzer, &candidates);
let records = candidates
.records
.into_iter()
.map(|record| ProfileCorpusRecord {
id: record.id,
label: record.label,
source_id: record.source_id,
author: record.author,
split_group: record.split_group,
domain: record.domain,
correction: profile.correction_for(&record.evaluation),
})
.collect();
print_json(&ProfileCorpusOutput { profile, records })
}
[
command,
dictionary_flag,
dictionary_path,
references_flag,
references_path,
before_flag,
before_path,
after_flag,
after_path,
] if command == "revise"
&& dictionary_flag == "--dict"
&& references_flag == "--references"
&& before_flag == "--before"
&& after_flag == "--after" =>
{
let analyzer =
VibratoMorphAnalyzer::from_dictionary_reader(File::open(dictionary_path)?)?;
let profile = profile_from_references(&analyzer, references_path)?;
let before = fs::read_to_string(before_path)?;
let after = fs::read_to_string(after_path)?;
let revision = evaluate_revision(&analyzer, &profile, &before, &after);
print_json(&RevisionOutput { profile, revision })
}
[
command,
dictionary_flag,
dictionary_path,
corpus_flag,
corpus_path,
annotations_flag,
annotations_path,
] if command == "validate-human"
&& dictionary_flag == "--dict"
&& corpus_flag == "--corpus"
&& annotations_flag == "--annotations" =>
{
let analyzer =
VibratoMorphAnalyzer::from_dictionary_reader(File::open(dictionary_path)?)?;
let corpus = parse_jsonl(&fs::read_to_string(corpus_path)?)?;
let annotations: Vec<HumanAnnotation> =
parse_jsonl(&fs::read_to_string(annotations_path)?)?;
let corpus = evaluate_corpus(&analyzer, &corpus);
let human_rhythm = correlate_human_rhythm(&corpus.records, &annotations)?;
print_json(&human_rhythm)
}
[
command,
dictionary_flag,
dictionary_path,
file_flag,
input_path,
] if dictionary_flag == "--dict" && file_flag == "--file" => {
let analyzer =
VibratoMorphAnalyzer::from_dictionary_reader(File::open(dictionary_path)?)?;
let input = fs::read_to_string(input_path)?;
match command.as_str() {
"analyze" => print_json(&evaluate_text(&analyzer, &input)),
"corpus" => {
let corpus = parse_jsonl(&input)?;
print_json(&evaluate_corpus(&analyzer, &corpus))
}
"benchmark" => {
let corpus = parse_jsonl(&input)?;
let corpus = evaluate_corpus(&analyzer, &corpus);
let selection = select_metrics(&corpus.records)?;
print_json(&BenchmarkOutput { corpus, selection })
}
_ => Err(usage().into()),
}
}
_ => Err(usage().into()),
}
}
fn parse_jsonl<T: DeserializeOwned>(input: &str) -> Result<Vec<T>, serde_json::Error> {
input
.lines()
.filter(|line| !line.trim().is_empty())
.map(serde_json::from_str)
.collect()
}
fn profile_from_references(
analyzer: &VibratoMorphAnalyzer,
references_path: &str,
) -> Result<PatternTargetProfile, Box<dyn Error>> {
let references = parse_jsonl(&fs::read_to_string(references_path)?)?;
let references = evaluate_corpus(analyzer, &references);
Ok(PatternTargetProfile::from_references(
&references
.records
.iter()
.map(|record| record.evaluation.clone())
.collect::<Vec<_>>(),
))
}
fn print_json(value: &impl serde::Serialize) -> Result<(), Box<dyn Error>> {
println!("{}", serde_json::to_string_pretty(value)?);
Ok(())
}
fn usage() -> String {
"usage:\n cognitive-rhythm-eval prepare --manifest SOURCES.jsonl --output PASSAGES.jsonl\n cognitive-rhythm-eval build-dictionary --lexicon LEX.csv --matrix matrix.def --char char.def --unknown unk.def --output SYSTEM.dic\n cognitive-rhythm-eval analyze --dict SYSTEM.dic --file TEXT.md\n cognitive-rhythm-eval corpus --dict SYSTEM.dic --file LABELED.jsonl\n cognitive-rhythm-eval benchmark --dict SYSTEM.dic --file LABELED.jsonl\n cognitive-rhythm-eval validate-human --dict SYSTEM.dic --corpus PASSAGES.jsonl --annotations ANNOTATIONS.jsonl\n cognitive-rhythm-eval profile --dict SYSTEM.dic --references REFERENCE.jsonl --candidate CANDIDATE.md\n cognitive-rhythm-eval profile-corpus --dict SYSTEM.dic --references REFERENCE.jsonl --candidates CANDIDATES.jsonl\n cognitive-rhythm-eval revise --dict SYSTEM.dic --references REFERENCE.jsonl --before BEFORE.md --after AFTER.md".to_owned()
}

先行研究を受けた周波数指標の試行

採用した考え方

Roberts (1996) は散文の文長列について lag 1–5 の自己相関を取り、元の文長集合を保ったまま文順をシャッフルした対照と比較して、周期らしさが偶然に見つかる可能性を検査した。Rhythm in Prose and the Serial Correlation of Sentence Lengths

この試作では、8 文以上の系列について最大 8 lag の正の Pearson 自己相関を求め、同じ系列を決定的に 999 回シャッフルして、その最大値以上になる割合を片側 p 値として出す。p 値は「美しさ」ではなく、「この文順に周期らしい正相関がある」という狭い帰無仮説の検定である。

TRPG 導入での結果

最大自己相関 lag シャッフル p 値
改稿前 0.429 5 0.417
改稿後 0.292 5 0.635

11 文しかないため、どちらも有意な周期とはみなせない。初期実装が DFT 支配パワー比だけから与えた「98.70% のプロフィール改善」は撤回し、この二つの DFT 特徴をプロフィール距離から除外した。

文長を時系列として扱うこと自体には先行例があり、複数の長さ定義で自己相関の傾向が比較的頑健だという大規模分析もある。Robustness of sentence length measures in written texts ただし、その結果は英語中心の書籍コーパスであり、日本語の短い導入文に「心地よい周波数」があることは示さない。

現在の判断

  • DFT と自己相関は出力して可視化するが、プロフィールの品質・改稿スコアには入れない。
  • プロフィールには、文長変動係数、語彙的話題変化の平均、POS bigram エントロピーだけを使う。生の POS bigram 反復率は系列が長くなるほど既出遷移を再使用しやすく、記事全体と短い参照文を比較するプロフィールには不適切だった。
  • 人手の改稿前後比較でリズム評定との相関が確認できた場合だけ、自己相関・スペクトル特徴を候補として再導入する。
  • 意味の周期は内容語 Jaccard では測れない。次段階は entity grid、係り受け、文埋め込み距離を別系列として比較する。

仮説コーパスでの文書単位比較

現在の生成コーパスは、良例仮説が 5 文書・91 passage、悪例仮説が 4 文書・24 passage である。benchmark に passage AUC だけでなく、各文書内の passage を先に平均する document_auc と、9 文書のラベルを全 126 通りで置換する二側検定を加えた。結果は次の通りである。

特徴 passage AUC 文書 holdout AUC 文書 AUC 置換 p 値
語彙的話題変化の平均(高) 0.957 0.946 1.000 0.00794
文長変動係数(高) 0.826 0.808 1.000 0.01587
POS bigram エントロピー(高) 0.941 0.880 0.900 0.03175
POS bigram 反復率(低) 0.851 0.726 0.850 0.13492
既存 rhythm_score(高) 0.896 0.875 1.000 0.00794

この固定セットでは、良例の全5文書が悪例の全4文書より高い話題変化平均を示し、文長変動も同じ方向で分離した。POS bigram エントロピーも文書ホールドアウトで比較的強く分離した。反対に、生の POS bigram 反復率は passage レベルでは差が出るが、文書単位の置換検定では弱く、文章長にも依存する。よって当面のプロフィール候補は「隣接文で内容語がどの程度変わるか」「文長の散らばり」「品詞遷移のエントロピー」とする。

一方、文長 DFT 支配パワー比も文書 AUC 1.000、置換 p=0.00794 で分離するが、これは「心地よい周期」の実証ではない。個別 passage の自己相関シャッフル検定では良例優位の方向が出ず、TRPG 導入の反例もあるため、周波数特徴は引き続きプロフィール・改稿スコアから除外する。

この p 値は、9文書という小標本で、複数の候補指標を見た後に算出した探索的な値である。多重比較補正も、著者・媒体・題材・翻訳文体の交絡の解消もしていない。独立した技術記事コーパスで特徴と向きを固定して再現するまでは、一般的な「良文」指標とは呼ばない。

著者監査のため、取得マニフェストの author を生成 passage にも保存し、author_holdout_readyauthor_holdout_auc を追加した。今回の良例仮説は4著者(太宰治、宮沢賢治、mizchi、翻訳記事)、悪例仮説は error-daizenn の1著者だけであり、出力は前者が false、後者が null になる。したがって文書ホールドアウト AUC は計算できても、未知の著者へ一般化する性能はまだ一切測れていない。次に追加すべきデータは、悪例仮説の別著者ではなく、同一基準で独立に人手評価された複数著者の技術記事である。

日本語の計量文体論では、品詞 n-gram、助詞、句読点、機能表現が著者・文体の特徴として実証されている。Huang & Jin, 2020 日本語の局所的一貫性については、接続関係・照応・語彙連鎖を組み込む entity grid が提案されている。横野・奥村, 2010

TRPG 導入文の改稿評価

結論

rhythm_score や周波数スペクトルの山を、改稿の成功としては採点しない。現行のプロフィール距離は、同目的の参照文から得た文長変動・語彙的話題変化・POS bigram エントロピーだけで計算する。文長系列の周波数・自己相関は、改稿の理由を読むための診断として別に出力する。

観測値 改稿前 改稿後 変化
プロフィール距離 0.000000 0.000668 +0.000668
profile_gap_reduction_score 改稿前がすでに全帯域内のため未定義
内容語保持率 88.66% 内容語の表層集合の Jaccard 類似度
rhythm_score 56.86 43.55 -13.31
文長自己相関のシャッフル p 値 0.417 0.635 どちらも有意とはいえない
帯域内へ解消した特徴 0
新たに帯域外になった特徴 1

この実測では、表層指標からは「改稿前より良くなった」とは判定できない。これは失敗ではなく、先行研究に照らして不十分な周波数指標をスコアから外した結果である。内容を削除・置換するだけで整合度を上げることを避けるため、内容語保持率をスコアへ合成せず、独立したガードとして常に記録する。

改稿スコアの定義

各特徴について、参照プロフィールの許容帯から外れた絶対距離を出し、その平均を profile_distance とする。改稿前の距離が正のとき、改稿スコアを次で定義する。

profile_gap_reduction_score
  = 100 * (profile_distance_before - profile_distance_after)
        / profile_distance_before

100 は初期ギャップをすべて解消したこと、0 は変化なし、負値はプロフィールから遠ざかったことを表す。改稿前から全特徴が帯域内なら分母がゼロになるため、このスコアは出力しない。

今回のプロフィールは、good_hypothesis として選んだ 91 passage から、各特徴の 10–90 パーセンタイルで作った。これは仮説ラベルの参照集合であり、独立注釈済みの正解データではない。文学・技術・翻訳や著者の偏りが残るため、この数値を「良文らしさの検証済み尺度」とは解釈しない。

旧プロフィールでの記録と現行定義

次の数値は、DFT を外した直後に生の POS bigram 反復率を使って計算した旧プロフィールの記録である。反復率は文章が長いほど増えやすいため、現行プロフィールから除外した。したがって、表のプロフィール距離と反復率の判定を現行スコアとして読んではならない。改稿前後の原文・周波数診断・内容語保持率を残す目的で掲載する。

旧プロフィールで何が改善し、何が悪化したか

特徴 参照帯 改稿前 改稿後 判定
文長変動係数 0.49607–0.98088 0.67864 0.55199 帯域内を維持
語彙的話題変化の平均 0.86368–0.94524 0.90496 0.89273 帯域内を維持
POS bigram 反復率 0.87435–0.92453 0.87574 0.87234 わずかに帯域外(上げる)へ

文長の DFT 支配パワー比は 0.58265 から 0.41032 に変化したが、自己相関の最大値を文順シャッフル 999 回の帰無分布と比較すると、p 値はそれぞれ 0.4170.635 だった。11 文のこの導入では、周期らしさが偶然より強いとは示せない。Roberts の散文研究が用いた「自己相関 + 文順シャッフル」の考え方に従い、DFT 比はプロフィールから外した。Roberts, 1996

初期試作で報告した 98.70 は、DFT 支配パワー比をプロフィールに入れた旧定義による値であり、現行スコアでは撤回する。ここで記した反復率の帯域外判定も、生の反復率を使う旧プロフィールの値である。人間の読者評価で改稿後が良いと判断されるなら、この対照例は「当時の表層特徴だけでは改善を捉えられない」反例としてコーパスに残す。

rhythm_score が下がったのは、短→長→短や強い文長切替を重く加点する従来のヒューリスティックが、今回の改稿後の穏やかな変化を低く見るためである。この差はバグではなく、単一のリズム総合点を改稿の成功判定に使わない根拠になる。

原文と改稿

改稿前

霧が鳴った。午前零時、探索者たちのもとへ届いた無言電話の向こうで、港の浮標が、一定の間隔を置いて二度ずつ、金属を擦るような声を上げていた。海は凪いでいる。

通話が切れる直前、受話器を握る女の息だけが残った。風だろう、と誰かが言った。だが窓の外では、停泊した船の綱も、倉庫の看板も、ひとつとして揺れていない。そのあと聞こえたのは、遠ざかる足音ではなく、濡れた裸足が板張りの床をこちらへ近づいてくる音だった。

港の監視小屋は施錠されていた。扉の内側には、海水で描かれた円があり、その中心に、あなたたちの宿の部屋番号が書かれている。まだ乾いていない。そして円の外側から、小さな爪が三度、扉を叩いた。

改稿後

霧が鳴った。午前零時、探索者たちのもとへ届いた無言電話の向こうで、港の浮標が、一定の間隔を置いて二度ずつ、金属を擦るような声を上げていた。それでも港の水面は、不気味なほど凪いでいる。

通話が切れる直前、受話器を握る女の息だけが残った。風だろう、と誰かが言った。だが窓の外では、停泊した船の綱も、倉庫の看板も、ひとつとして揺れていない。そのあと聞こえたのは、遠ざかる足音ではなく、濡れた裸足が板張りの床をこちらへ近づいてくる音だった。

港の監視小屋は施錠されていた。扉の内側には、海水で描かれた円があり、その中心に、あなたたちの宿の部屋番号が書かれている。円の輪郭はまだ乾かず、足元の埃に細い筋を残していた。そして円の外側から、小さな爪が三度、扉を叩いた。

再現

cargo run -- revise \
  --dict /path/to/system.dic \
  --references good-references.jsonl \
  --before examples/trpg-intro-quantified-before.md \
  --after examples/trpg-intro-quantified.md

機械可読な詳細はコマンドの JSON 出力で得るが、公開する結果・考察はこのような Markdown に転記する。スコアの意味、参照集合、改善点と副作用を同時に読めるようにするためである。

@mizchi

mizchi commented Jul 17, 2026

Copy link
Copy Markdown
Author

ブラインド人手評価の入口\n\nvalidate-human を追加しました。passage_idannotator_idrhythm_1_to_5 の評定を passage ごとに平均し、仮説ラベルを使わず全特徴との符号付き Spearman 相関を返します。未知 ID、1–5 範囲外、同一評価者の二重評定はエラーにします。\n\nannotation-protocol-ja.md に、出典を隠す評価票、同一原文書の重複を避ける方法、確認的テストでの制約を記録しました。現時点では人手データが未収集なので、相関値はまだ主張しません。

@mizchi

mizchi commented Jul 17, 2026

Copy link
Copy Markdown
Author

日本語ブログと改稿ログを追加\n\nblog-article-ja.md は、この試作で何を測り、コーパスでどの差が出て、周波数仮説をなぜ撤回したかをまとめた本文です。blog-revision-ja.md には初稿→改稿の数値を記録しました。プロフィール距離は 3.68% 解消した一方、rhythm_score は低下したため、数値を合成して「改善」とは呼んでいません。\n\n改稿中に、生の POS bigram 反復率が記事長に依存することも確認しました。プロフィールからは外し、POS bigram エントロピーへ置換しています。計測表自身が本文の文数・品詞列を変えるため、本文と改稿ログを別 Markdown に分けました。

@mizchi

mizchi commented Jul 17, 2026

Copy link
Copy Markdown
Author

評価器併用版のライティング規範を追加

cognitive-rhythm-writing-with-eval-ja.md を追加した。元の認知リズム規範を、cognitive-rhythm-evalanalyze / profile / revise を使う執筆・改稿手順へ書き直している。

数値は合否ではなくレビューの手がかりとして扱う。参照文の同質性、内容保持、話題テスト、改稿ログの本文からの分離、停止条件を明記した。生の POS bigram 反復率・DFT・自己相関・rhythm_score は品質最適化の目標にしない。

@mizchi

mizchi commented Jul 17, 2026

Copy link
Copy Markdown
Author

empirical-prompt-tuning による評価ループ

cognitive-rhythm-writing-with-eval-ja.md を、毎回新しい実行者を使う固定チェックリストで評価した。通常・参照なし・異ジャンル参照・document_update_risk・未提示手順・ADR の6種類を扱い、最終ホールドアウトを含めて全 critical 項目を通過した。

実質的な改訂は、未提示の事実・操作・数値を補完せず、確定/未確認/確認責任を本文外の「事実台帳」に分ける規則である。詳細なシナリオ、成功条件、失敗パターン、取得できなかった duration メタデータは cognitive-rhythm-writing-eval-ja.md に記録した。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment