Skip to main content

sokuto_runtime/tokenizer/
batch.rs

1//! # 複数質問バッチトークナイズモジュール
2//!
3//! 単一の State に対する複数質問のプレフィックス共有エンコード、
4//! 可変長系列および可変長候補数の動的パディング、
5//! ならびに ONNX Runtime 入力テンソル平坦化バッファの生成を提供する。
6
7use indexmap::IndexMap;
8use sokuto_core::contract::model_spec::ModelInputDimensions;
9use sokuto_core::schema::Question;
10
11use crate::error::{Result, RuntimeError};
12use crate::tokenizer::JevTokenizer;
13
14/// 複数質問を一括パッキングしたバッチトークナイズ結果構造体。
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct BatchTokenizedQuestions {
17    /// 平坦化された入力トークン列テンソル用データ (`[batch_size * sequence_length]`)。
18    pub input_ids: Vec<i64>,
19    /// 平坦化されたアテンションマスクテンソル用データ (`[batch_size * sequence_length]`)。
20    pub attention_mask: Vec<i64>,
21    /// 平坦化された候補出現位置インデックステンソル用データ (`[batch_size * num_options]`)。
22    /// パディング領域には Gather 層の境界外アクセスを防ぐためダミー値 0 が設定される。
23    pub op_indices: Vec<i64>,
24    /// バッチテンソルの次元情報 (`batch_size`, `sequence_length`, `num_options`)。
25    pub dims: ModelInputDimensions,
26    /// バッチ内に含まれる質問識別子キーの整列リスト。
27    pub question_keys: Vec<String>,
28    /// 各質問の本来の有効候補数 $K_i$ のリスト。
29    pub candidate_counts: Vec<usize>,
30    /// 各質問のプロンプト出現順候補識別子キー列のリスト。
31    pub option_keys_per_q: Vec<Vec<String>>,
32}
33
34impl JevTokenizer {
35    /// 共通 State と複数質問群からプレフィックス共有バッチトークナイズ結果を生成する。
36    ///
37    /// 最大系列長にはインスタンスに設定されている `max_sequence_length` が適用される。
38    ///
39    /// # 引数
40    /// - `state`: 共通文脈テキスト。
41    /// - `questions`: 質問識別子と質問定義のマップ。
42    ///
43    /// # 戻り値
44    /// パディングおよび平坦化済みの `BatchTokenizedQuestions`。
45    ///
46    /// # エラー
47    /// - 質問群が空の場合は `InvalidQuestion`。
48    /// - 系列長超過またはマーカー不整合の場合は `RuntimeError`。
49    pub fn encode_batch_questions(
50        &self,
51        state: &str,
52        questions: &IndexMap<String, Question>,
53    ) -> Result<BatchTokenizedQuestions> {
54        self.encode_batch_questions_with_max_len(state, questions, self.max_sequence_length)
55    }
56
57    /// 系列長制約を明示指定して、共通 State と複数質問群を一括エンコードする。
58    ///
59    /// # 処理フロー
60    /// 1. `state` を `encode_state` により 1 度だけトークナイズし、トークン ID 列を共有する。
61    /// 2. 各質問に対して独立した State 優先トランケーションを適用し、個別トークナイズを実行する。
62    /// 3. バッチ内の最大系列長 $L_{\\max}$ および最大候補数 $K_{\\max}$ を算出する。
63    /// 4. パディングトークンおよび安全なダミー候補インデックス (0) を用いて平坦化バッファを構築する。
64    ///
65    /// # 引数
66    /// - `state`: 共通文脈テキスト。
67    /// - `questions`: 質問識別子と質問定義のマップ。
68    /// - `max_length`: 許容最大入力系列長。
69    ///
70    /// # エラー
71    /// - 質問群が空の場合は `InvalidQuestion`。
72    /// - テンソル形状が不正な場合は `ModelContractViolation`。
73    pub fn encode_batch_questions_with_max_len(
74        &self,
75        state: &str,
76        questions: &IndexMap<String, Question>,
77        max_length: usize,
78    ) -> Result<BatchTokenizedQuestions> {
79        let state_ids = self.encode_state(state)?;
80        self.encode_batch_with_pretokenized_state(&state_ids, questions, max_length)
81    }
82
83    /// 事前エンコード済み State トークン列を用いて複数質問群を一括バッチエンコードする。
84    ///
85    /// サーバー層でのマイクロバッチ分割(チャンキング)時に、同一 State の再トークナイズを完全に防ぐ。
86    ///
87    /// # 引数
88    /// - `state_ids`: 事前トークナイズ済み State トークン ID 列。
89    /// - `questions`: 質問識別子と質問定義のマップ。
90    /// - `max_length`: 許容最大入力系列長。
91    pub fn encode_batch_with_pretokenized_state(
92        &self,
93        state_ids: &[u32],
94        questions: &IndexMap<String, Question>,
95        max_length: usize,
96    ) -> Result<BatchTokenizedQuestions> {
97        let batch_size = questions.len();
98        if batch_size == 0 {
99            return Err(RuntimeError::InvalidQuestion(
100                "質問群が空のためバッチを構築できません。".to_string(),
101            ));
102        }
103
104        let mut question_keys = Vec::with_capacity(batch_size);
105        let mut tokenized_list = Vec::with_capacity(batch_size);
106        let mut candidate_counts = Vec::with_capacity(batch_size);
107        let mut option_keys_per_q = Vec::with_capacity(batch_size);
108
109        let mut max_seq_len = 0usize;
110        let mut max_num_options = 0usize;
111
112        for (q_key, question) in questions {
113            let tokenized = self.encode_with_pretokenized_state(state_ids, question, max_length)?;
114            let seq_len = tokenized.input_ids.len();
115            let num_options = tokenized.op_indices.len();
116
117            if seq_len > max_seq_len {
118                max_seq_len = seq_len;
119            }
120            if num_options > max_num_options {
121                max_num_options = num_options;
122            }
123
124            question_keys.push(q_key.clone());
125            candidate_counts.push(num_options);
126            option_keys_per_q.push(tokenized.option_keys.clone());
127            tokenized_list.push(tokenized);
128        }
129
130        let dims = ModelInputDimensions::new(batch_size, max_seq_len, max_num_options)
131            .map_err(|e| RuntimeError::ModelContractViolation(e.to_string()))?;
132
133        let pad_id = self.pad_token_id.unwrap_or(0) as i64;
134        let total_tokens = batch_size * max_seq_len;
135        let total_options = batch_size * max_num_options;
136
137        let mut input_ids = vec![pad_id; total_tokens];
138        let mut attention_mask = vec![0i64; total_tokens];
139        // Gather 層の範囲外アクセスを防ぐため、パディング候補スロットは 0 で埋める。
140        let mut op_indices = vec![0i64; total_options];
141
142        for (i, tokenized) in tokenized_list.into_iter().enumerate() {
143            let token_offset = i * max_seq_len;
144            let seq_len = tokenized.input_ids.len();
145            input_ids[token_offset..token_offset + seq_len].copy_from_slice(&tokenized.input_ids);
146            attention_mask[token_offset..token_offset + seq_len]
147                .copy_from_slice(&tokenized.attention_mask);
148
149            let op_offset = i * max_num_options;
150            let num_ops = tokenized.op_indices.len();
151            op_indices[op_offset..op_offset + num_ops].copy_from_slice(&tokenized.op_indices);
152        }
153
154        Ok(BatchTokenizedQuestions {
155            input_ids,
156            attention_mask,
157            op_indices,
158            dims,
159            question_keys,
160            candidate_counts,
161            option_keys_per_q,
162        })
163    }
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169    use std::path::PathBuf;
170
171    fn default_tokenizer_path() -> PathBuf {
172        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
173            .parent()
174            .and_then(|p| p.parent())
175            .unwrap()
176            .join("models")
177            .join("default")
178            .join("tokenizer.json")
179    }
180
181    #[test]
182    fn test_encode_batch_questions_empty_error() {
183        let tokenizer_path = default_tokenizer_path();
184        if !tokenizer_path.exists() {
185            return;
186        }
187        let tokenizer = JevTokenizer::from_file(&tokenizer_path).unwrap();
188        let empty_questions = IndexMap::new();
189
190        let result = tokenizer.encode_batch_questions("Context", &empty_questions);
191        assert!(result.is_err());
192        assert!(matches!(
193            result.unwrap_err(),
194            RuntimeError::InvalidQuestion(_)
195        ));
196    }
197
198    #[test]
199    fn test_encode_batch_questions_padding_and_alignment() {
200        let tokenizer_path = default_tokenizer_path();
201        if !tokenizer_path.exists() {
202            return;
203        }
204        let tokenizer = JevTokenizer::from_file(&tokenizer_path).unwrap();
205
206        let mut questions = IndexMap::new();
207        // 質問 1: 2 択
208        let mut map_a = IndexMap::new();
209        map_a.insert("opt1".to_string(), "選択肢 1".to_string());
210        map_a.insert("opt2".to_string(), "選択肢 2".to_string());
211        questions.insert(
212            "q1".to_string(),
213            Question::new_choice("質問 1 の指示文。", map_a),
214        );
215
216        // 質問 2: 4 択 (長い指示文)
217        let mut map_b = IndexMap::new();
218        map_b.insert("a".to_string(), "候補 A".to_string());
219        map_b.insert("b".to_string(), "候補 B".to_string());
220        map_b.insert("c".to_string(), "候補 C".to_string());
221        map_b.insert("d".to_string(), "候補 D".to_string());
222        questions.insert(
223            "q2".to_string(),
224            Question::new_choice(
225                "質問 2 の詳細かつ非常に長い指示文です。文脈に合わせて最適な項目を選択してください。",
226                map_b,
227            ),
228        );
229
230        let batch = tokenizer
231            .encode_batch_questions("共通のコンテキスト情報です。", &questions)
232            .expect("バッチエンコードに成功すること。");
233
234        assert_eq!(batch.dims.batch_size, 2);
235        assert_eq!(batch.dims.num_options, 4);
236        assert_eq!(batch.candidate_counts, vec![2, 4]);
237        assert_eq!(batch.question_keys, vec!["q1", "q2"]);
238
239        let l_max = batch.dims.sequence_length;
240        let k_max = batch.dims.num_options;
241
242        // 行 1 (q1) の検証: 2 択なので候補スロット 2, 3 はパディング (0)
243        assert_ne!(batch.op_indices[0], 0);
244        assert_ne!(batch.op_indices[1], 0);
245        assert_eq!(batch.op_indices[2], 0);
246        assert_eq!(batch.op_indices[3], 0);
247
248        // 系列長の末尾がパディングされていることの検証 (q1 は q2 より短い)
249        let q1_tokens = &batch.input_ids[0..l_max];
250        let q1_mask = &batch.attention_mask[0..l_max];
251        let valid_q1_len = q1_mask.iter().filter(|&&m| m == 1).count();
252        assert!(valid_q1_len < l_max);
253        // パディング領域のマスクが 0 であり、pad_token_id が埋められていること
254        let pad_id = tokenizer.pad_token_id().unwrap_or(0) as i64;
255        for &m in &q1_mask[valid_q1_len..] {
256            assert_eq!(m, 0);
257        }
258        for &token in &q1_tokens[valid_q1_len..] {
259            assert_eq!(token, pad_id);
260        }
261
262        // 行 2 (q2) の検証: 4 択すべて有効
263        for k in 0..4 {
264            assert_ne!(batch.op_indices[k_max + k], 0);
265        }
266    }
267}