Skip to content

Commit 1555edb

Browse files
authored
Merge pull request #44 from mad-p/feature/stream-stats
漢直ストリーム統計を取る
2 parents 7495642 + 31e31cf commit 1555edb

15 files changed

Lines changed: 436 additions & 127 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,4 @@ scripts/data
2929
scripts/out
3030
/.ruby-version
3131
/out
32+
scripts/data-old

Claude.md

Lines changed: 37 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -109,52 +109,47 @@ log stream --predicate 'process == "MacTcode"'
109109
2. **BushuConfig**: 部首変換設定、自動部首変換学習設定
110110
3. **KeyBindingsConfig**: キーバインド、基本文字配列(40x40)
111111
4. **UIConfig**: 候補選択キー、記号セット
112-
5. **SystemConfig**: 除外アプリ、ログ、統計同期間隔、キャンセル期間
112+
5. **SystemConfig**: 除外アプリ、ログ、統計同期間隔、キャンセル期間、ストローク統計設定(`strokeStatsEnabled`, `streamStatsEnabled`, `streamThresholds`
113113

114114
設定変更は`UserConfigsDelegate`プロトコルで通知。
115115

116116
### 統計管理
117117

118-
`InputStats.i`(シングルトン):
119-
- スレッドセーフ(DispatchQueueで排他制御)
120-
- 基本文字、部首変換、交ぜ書き変換、機能実行をカウント
121-
- 定期的に`tc-record.txt`に出力(デフォルト1200秒間隔)
122-
123-
### このセッションで行った変更(要約・2026-02-21)
124-
125-
以下はこの作業セッションで実装/追加した内容の短いサマリです。将来の解析やデバッグ時に参照してください。
126-
127-
- InputStats の拡張
128-
- ストローク(T-Code基本キー)単位の頻度統計を追加しました。
129-
- 追加メソッド: `recordStroke(key:)`, `recordNonStrokeEvent()`, `resetStrokeStats()`, `writeStrokeStatsToFile()`, `loadStrokeStatsMaybe()`
130-
- 内部データ構造: `keyCount[40]`, `basicCharCount[1600]`, `bigram[1600]`, `panes`, `alternation`
131-
- 書き出しは既存の統計同期タイミング(`tc-record.txt` と同時)で行います。累積保存です(リセットしない)。
132-
133-
- 設定変更
134-
- 新しい設定フラグを追加: `system.strokeStatsEnabled`(デフォルト: `true`)。
135-
- このフラグが `false` の場合、ストローク統計の収集および `stroke-stats.json` の読み書きは行われません。
118+
`InputStats.i`(シングルトン)はスレッドセーフ(DispatchQueueで排他制御)で2種類の統計を管理する。
136119

137-
- 記録フックと連続性ルール
138-
- 記録は Option A(実際に basic と判定された箇所)を採用し、`TcodeMode``.text` 分岐で `Translator.strToKey` が 0..39 を返す文字を `recordStroke` で記録します。
139-
- 連続性を断つ(バイグラムを切る)イベント: 部首/交ぜ書き受容、機能実行、モード切替、候補確定、PendingKakutei の受容、非基本キー入力など。
140-
141-
- コードの主な変更箇所
142-
- `MacTcode/InputStats.swift` — ストローク統計実装
143-
- `MacTcode/UserConfigs.swift``strokeStatsEnabled` を追加(デフォルト true)
144-
- `MacTcode/Tcode/TcodeMode.swift``.text` 分岐で `recordStroke` 呼び出し
145-
- `MacTcode/Tcode/TcodeInputController.swift` — push/pop/deactivate/candidateSelected で `recordNonStrokeEvent` 呼び出し
146-
- `MacTcode/Mode/PendingKakuteiMode.swift` — accept() で連続性断ち
147-
148-
- テストとドキュメント
149-
- 単体テスト追加: `MacTcodeTests/StrokeStatsTests.swift`(ストローク記録、連続性切断、無効化挙動の検証)
150-
- ドキュメント追加/更新:
151-
- `STROKE_STATS.md``stroke-stats.json` のフォーマット仕様
152-
- `README.md`:統計セクションにストローク統計の説明追記
153-
- `ConfigParams.md``system.strokeStatsEnabled` の説明追記
154-
155-
重要: これら変更は既存のビルドとテストスイートで検証済み(Debug ビルド成功、ユニットテスト実行成功)。
156-
157-
必要に応じて、`STROKE_STATS.md` に記載した JSON を読み取る解析スクリプト(Python など)を追加できます。希望があれば実装します。
120+
#### 入力頻度統計
121+
- 基本文字、部首変換、交ぜ書き変換、機能実行をカウント
122+
- 定期的に`tc-record.txt`に追記(デフォルト1200秒間隔)
123+
- 設定変更・学習データ保存も同タイミングで実行
124+
125+
#### ストローク統計(`stroke-stats.json`に累積保存)
126+
- **keyCount[40]**: キーごとの打鍵数
127+
- **basicCharCount[1600]**: 基本文字(2打鍵)ごとの出現頻度(インデックス = key1 * 40 + key2)
128+
- **bigram[1600]**: 連続する2打鍵のバイグラム頻度
129+
- **panes**: 象限ペア("LL","LR","RL","RR")ごとの頻度
130+
- **alternation**: 交互打鍵("alternate"/"consecutive"/"first")の頻度
131+
- 設定: `system.strokeStatsEnabled`(デフォルト: true)
132+
133+
#### バイグラム連続性
134+
- `recordNonStrokeEvent()`: バイグラムの連続性を断つ(部首/交ぜ書き確定、機能実行、モード切替など)
135+
- `PendingKakuteiMode.accept()`: バイグラムのみ不連続(ストリームは継続)
136+
137+
#### ストリーム統計(`stroke-stats.json``streamCount`キーに保存)
138+
連続した漢直入力(ストリーム)の長さを複数のしきい値で計測する。
139+
140+
- **StreamCounterクラス**: しきい値ごとの独立したカウンタ
141+
- `currentLength`: 現在カウント中のストリーム長(文字数)
142+
- `histogram[51]`: ストリーム長のヒストグラム(インデックス50でキャップ)
143+
- **ストリームの定義**: 確定と確定の間隔がしきい値未満であれば同一ストリームとみなし文字数を累積。しきい値以上またはストリーム終了イベント発生時にヒストグラムを更新してリセット。
144+
- **文字数カウントのルール**:
145+
- `recordKakutei(charCount:subtract:)` で確定を記録
146+
- `subtract` はヨミ文字数の差し引き(Bushu: 2、Mazegaki: `hit.length`
147+
- `net = charCount - subtract` が正なら加算、負なら `max(0, currentLength + net)` で下限0を保証
148+
- **recordStreamEndEvent()**: ストリーム統計の不連続を記録(全StreamCounterを`endStream()`
149+
- 呼び出し箇所: IMEオフ(`deactivateServer`)、モード切替(`pushMode`/`popMode`)、機能実行(`incrementFunctionCount`
150+
- `candidateSelected`はバイグラムのみ(Mazegakiの`recordKakutei`がストリームを管理)
151+
- **永続化形式**: `streamCount: { "0.5": [0,0,...], "1.0": [0,0,...] }` — しきい値文字列をキー、51要素ヒストグラムを値
152+
- 設定: `system.streamStatsEnabled`(デフォルト: true)、`system.streamThresholds`(デフォルト: `["0.5", "1.0"]`
158153

159154
## 重要なパターン
160155

@@ -176,7 +171,7 @@ log stream --predicate 'process == "MacTcode"'
176171
| `Bushu.swift` | 部首変換アルゴリズム |
177172
| `MazegakiDict.swift` | 交ぜ書き辞書、MRU学習データ |
178173
| `PendingKakutei.swift` | 変換キャンセル機構 |
179-
| `InputStats.swift` | 統計管理 |
174+
| `InputStats.swift` | 統計管理(入力頻度・ストローク統計・ストリーム統計) |
180175
| `LineMode.swift` | 1行入力モード(バッファに蓄積して一気に送信) |
181176

182177
## テスト
@@ -196,7 +191,7 @@ make test # すべてのテストを実行
196191
- ✅ 自動部首変換機能(禁止設定含む)
197192
- ✅ 全角入力モード
198193
- ✅ 1行入力モード(LineMode)
199-
- ✅ 統計記録機能
194+
- ✅ 統計記録機能(入力頻度・ストローク統計・ストリーム統計)
200195
- ✅ SIGINTハンドリング(統計・学習データの同期)
201196

202197
詳細は`TODO.md`および`README.md`を参照。

ConfigParams.md

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,10 @@ IMEメニューから「設定ファイルフォルダを開く」を選ぶと
141141
"a", "o", "e", "u", "i", "d", "h", "t", "n", "s",
142142
";", "q", "j", "k", "x", "b", "m", "w", "v", "z"
143143
],
144-
"logEnabled": false
144+
"logEnabled": false,
145+
"strokeStatsEnabled": true,
146+
"streamStatsEnabled": true,
147+
"streamThresholds": ["0.5", "1.0"]
145148
}
146149
```
147150

@@ -169,6 +172,18 @@ IMEメニューから「設定ファイルフォルダを開く」を選ぶと
169172
- **`keyboardLayout`**: キーボードレイアウトの名前("dvorak", "qwerty"等)
170173
- **`keyboardLayoutMapping`**: 40個のキー配列マッピング(文字列配列)
171174
- **`logEnabled`**: デバッグログの出力有効/無効
175+
- **`strokeStatsEnabled`**: ストローク統計(キー使用率・バイグラム等)の記録有効/無効
176+
- デフォルト値: `true`
177+
- `false`に設定するとstroke-stats.jsonへの記録が無効になります
178+
- **`streamStatsEnabled`**: 漢直ストリーム統計の記録有効/無効
179+
- デフォルト値: `true`
180+
- 漢直で連続して入力しているストリームの長さ頻度を集計します
181+
- `strokeStatsEnabled``false`の場合、ストリーム統計も記録されません
182+
- **`streamThresholds`**: ストリーム連続判定のしきい値(秒)の文字列配列
183+
- デフォルト値: `["0.5", "1.0"]`
184+
- 前回の確定から指定した秒数以上経過した場合、ストリームが終了したと判断します
185+
- 複数のしきい値を指定すると、それぞれ独立したヒストグラムが記録されます
186+
- 例: `["0.5", "1.0", "2.0"]`
172187

173188
## 設定ファイルのセットアップ
174189

MacTcode.xcodeproj/project.pbxproj

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -746,7 +746,7 @@
746746
"@executable_path/../Frameworks",
747747
);
748748
MACOSX_DEPLOYMENT_TARGET = 11.0;
749-
MARKETING_VERSION = 0.17.0;
749+
MARKETING_VERSION = 0.18.0;
750750
ONLY_ACTIVE_ARCH = NO;
751751
PRODUCT_BUNDLE_IDENTIFIER = "jp.mad-p.inputmethod.MacTcode";
752752
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -783,7 +783,7 @@
783783
"@executable_path/../Frameworks",
784784
);
785785
MACOSX_DEPLOYMENT_TARGET = 11.0;
786-
MARKETING_VERSION = 0.17.0;
786+
MARKETING_VERSION = 0.18.0;
787787
PRODUCT_BUNDLE_IDENTIFIER = "jp.mad-p.inputmethod.MacTcode";
788788
PRODUCT_NAME = "$(TARGET_NAME)";
789789
SWIFT_EMIT_LOC_STRINGS = YES;

MacTcode/Bushu/Bushu.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,7 @@ final class Bushu {
296296
let backspaceCount = client.replaceYomi(result, length: 2, from: yomi)
297297
controller.setBackspaceIgnore(backspaceCount)
298298
InputStats.i.incrementBushuCount()
299+
InputStats.i.recordKakutei(charCount: 1, subtract: 2)
299300

300301
// 自動学習が有効な場合、PendingKakuteiを生成
301302
if UserConfigs.i.bushu.autoEnabled {

MacTcode/InputStats.swift

Lines changed: 133 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,43 @@
11
import Foundation
22

3+
/// しきい値ごとのストリーム統計を管理するクラス
4+
class StreamCounter {
5+
/// 現在カウント中のストリーム長(文字数)
6+
var currentLength: Int = 0
7+
/// ストリーム長さのヒストグラム(要素数51、インデックス0..50)
8+
var histogram: [Int] = Array(repeating: 0, count: 51)
9+
10+
/// ストリームに文字を追加する
11+
/// - Parameters:
12+
/// - count: 追加する文字数
13+
/// - subtract: 減算する文字数(ヨミ分の除外など)
14+
func addChars(_ count: Int, subtract: Int = 0) {
15+
let net = count - subtract
16+
if net > 0 {
17+
currentLength += net
18+
} else if net < 0 {
19+
// 理論的には発生しないが予防的に
20+
currentLength = max(0, currentLength + net)
21+
}
22+
// net == 0 は何もしない
23+
}
24+
25+
/// 現在のストリームを終了してヒストグラムを更新する
26+
func endStream() {
27+
guard currentLength > 0 else { return }
28+
let idx = min(currentLength, 50)
29+
// Log.i("★Stream ended with length \(currentLength), updating histogram at index \(idx)")
30+
histogram[idx] += 1
31+
currentLength = 0
32+
}
33+
34+
/// カウンタをリセットする
35+
func reset() {
36+
currentLength = 0
37+
histogram = Array(repeating: 0, count: 51)
38+
}
39+
}
40+
341
/// 入力統計情報を管理するシングルトンクラス
442
class InputStats {
543
static let i = InputStats()
@@ -28,37 +66,55 @@ class InputStats {
2866
private var lastStrokeKey: Int? = nil
2967
private var lastStrokePane: String? = nil // "L" or "R"
3068

69+
// ストリーム統計
70+
// 最後に確定した時刻(全しきい値で共有)
71+
private var lastKakuteiDate: Date? = nil
72+
// しきい値文字列 -> StreamCounter
73+
private var streamCounters: [String: StreamCounter] = [:]
74+
3175
private let queue = DispatchQueue(label: "jp.mad-p.inputmethods.MacTcode.inputstats", attributes: .concurrent)
3276

3377
private init() {
3478
// 初期化時に保存済みの stroke-stats.json を読み込む
3579
loadStrokeStatsMaybe()
80+
// ストリームカウンタを初期化
81+
initStreamCounters()
82+
}
83+
84+
/// 設定のしきい値に基づいてストリームカウンタを初期化する
85+
private func initStreamCounters() {
86+
let thresholds = UserConfigs.i.system.streamThresholds
87+
for key in thresholds {
88+
if streamCounters[key] == nil {
89+
streamCounters[key] = StreamCounter()
90+
}
91+
}
3692
}
3793

3894
/// 基本文字入力のカウントを増やす
3995
func incrementBasicCount() {
4096
queue.async(flags: .barrier) {
4197
self.basicCount += 1
4298
self.totalActionCount += 1
43-
Log.i("basicCount = \(self.basicCount)")
99+
// Log.i("basicCount = \(self.basicCount)")
44100
}
45101
}
46102

47103
/// 部首変換のカウントを増やす
48104
func incrementBushuCount() {
49105
queue.async(flags: .barrier) {
50106
self.bushuCount += 1
51-
// continuity break
52-
self.recordNonStrokeEventInternal()
107+
// バイグラム連続性を断つ(ストリームはrecordKakuteiで管理)
108+
self.recordNonStrokeEvent()
53109
}
54110
}
55111

56112
/// 交ぜ書き変換のカウントを増やす
57113
func incrementMazegakiCount() {
58114
queue.async(flags: .barrier) {
59115
self.mazegakiCount += 1
60-
// continuity break
61-
self.recordNonStrokeEventInternal()
116+
// バイグラム連続性を断つ(ストリームはrecordKakuteiで管理)
117+
self.recordNonStrokeEvent()
62118
}
63119
}
64120

@@ -67,8 +123,9 @@ class InputStats {
67123
queue.async(flags: .barrier) {
68124
self.functionCount += 1
69125
self.totalActionCount += 1
70-
// continuity break
71-
self.recordNonStrokeEventInternal()
126+
// バイグラム・ストリーム両方の連続性を断つ
127+
self.recordNonStrokeEvent()
128+
self.recordStreamEndEvent()
72129
}
73130
}
74131

@@ -124,20 +181,66 @@ class InputStats {
124181
}
125182
}
126183

127-
/// 非ストロークイベントを記録して連続性を断つ
184+
/// 非ストロークイベントを記録して連続性を断つ(バイグラム統計のみ)
128185
func recordNonStrokeEvent() {
186+
// Log.i("recordNonStrokeEvent called")
129187
guard UserConfigs.i.system.strokeStatsEnabled else { return }
130188
queue.async(flags: .barrier) {
131189
self.recordNonStrokeEventInternal()
132190
}
133191
}
134192

135-
// internal: assumes barrier queue
193+
// internal: assumes barrier queue(バイグラム統計のみ)
136194
private func recordNonStrokeEventInternal() {
137195
self.lastStrokeKey = nil
138196
self.lastStrokePane = nil
139197
}
140198

199+
/// ストリーム統計の不連続を記録する(ストリームを終了させる)
200+
func recordStreamEndEvent() {
201+
// Log.i("recordStreamEndEvent called")
202+
guard UserConfigs.i.system.streamStatsEnabled else { return }
203+
queue.async(flags: .barrier) {
204+
self.recordStreamEndEventInternal()
205+
}
206+
}
207+
208+
// internal: assumes barrier queue
209+
private func recordStreamEndEventInternal() {
210+
for counter in self.streamCounters.values {
211+
counter.endStream()
212+
}
213+
self.lastKakuteiDate = nil
214+
}
215+
216+
/// 漢直入力の確定を記録する(ストリーム統計用)
217+
/// - Parameters:
218+
/// - charCount: 確定された文字数
219+
/// - subtract: 減算する文字数(ヨミ分など。Bushuでは2、Mazegakiではhit.length)
220+
func recordKakutei(charCount: Int, subtract: Int = 0) {
221+
// Log.i("recordKakutei called: charCount = \(charCount), subtract = \(subtract)")
222+
guard UserConfigs.i.system.streamStatsEnabled else { return }
223+
queue.async(flags: .barrier) {
224+
let now = Date()
225+
if let last = self.lastKakuteiDate {
226+
let elapsed = now.timeIntervalSince(last)
227+
for (key, counter) in self.streamCounters {
228+
guard let threshold = Double(key) else { continue }
229+
if elapsed >= threshold {
230+
// Log.i("Stream threshold \(threshold) exceeded (elapsed = \(elapsed)), ending stream")
231+
counter.endStream()
232+
}
233+
counter.addChars(charCount, subtract: subtract)
234+
}
235+
} else {
236+
for counter in self.streamCounters.values {
237+
counter.addChars(charCount, subtract: subtract)
238+
}
239+
}
240+
self.lastKakuteiDate = now
241+
}
242+
}
243+
141244
/// ストローク統計をリセット(メモリ内)
142245
func resetStrokeStats() {
143246
queue.async(flags: .barrier) {
@@ -148,6 +251,9 @@ class InputStats {
148251
self.alternation = ["alternate": 0, "consecutive": 0, "first": 0]
149252
self.lastStrokeKey = nil
150253
self.lastStrokePane = nil
254+
// ストリーム統計もリセット
255+
for counter in self.streamCounters.values { counter.reset() }
256+
self.lastKakuteiDate = nil
151257
}
152258
}
153259

@@ -170,6 +276,16 @@ class InputStats {
170276
if let bg = obj["bigram"] as? [Int], bg.count == nKeys * nKeys { self.bigramCount = bg }
171277
if let p = obj["panes"] as? [String: Int] { self.panes = p }
172278
if let a = obj["alternation"] as? [String: Int] { self.alternation = a }
279+
// ストリーム統計を読み込む
280+
if let sc = obj["streamCount"] as? [String: [Int]] {
281+
for (key, hist) in sc {
282+
if hist.count == 51 {
283+
let counter = self.streamCounters[key] ?? StreamCounter()
284+
counter.histogram = hist
285+
self.streamCounters[key] = counter
286+
}
287+
}
288+
}
173289
}
174290
} catch {
175291
Log.i("Failed to load stroke-stats: \(error)")
@@ -188,6 +304,14 @@ class InputStats {
188304
obj["panes"] = self.panes
189305
obj["alternation"] = self.alternation
190306
obj["lastUpdated"] = ISO8601DateFormatter().string(from: Date())
307+
// ストリーム統計を書き出す(現在進行中のストリームは反映しない)
308+
if UserConfigs.i.system.streamStatsEnabled {
309+
var streamCount: [String: [Int]] = [:]
310+
for (key, counter) in self.streamCounters {
311+
streamCount[key] = counter.histogram
312+
}
313+
obj["streamCount"] = streamCount
314+
}
191315

192316
do {
193317
let data = try JSONSerialization.data(withJSONObject: obj, options: [.prettyPrinted, .sortedKeys])

0 commit comments

Comments
 (0)