Skip to main content

sokuto_server/service/
inference.rs

1//! # 共通推論サービスモジュール
2//!
3//! 単一リクエストおよび一括バッチリクエスト双方から利用される
4//! コア推論ディスパッチ、粗密 2 段階ルーティング、キャッシュ制御、
5//! ガードレール適用、およびメトリクス収集を集約する。
6
7use std::sync::Arc;
8use std::time::Instant;
9
10use axum::http::{HeaderMap, HeaderValue};
11use indexmap::IndexMap;
12use serde_json::Value;
13use sokuto_core::gating::{SystemRoutingSummary, evaluate_response_routing};
14use sokuto_core::schema::{Answer, QuestionType, SystemOneRequest, Usage};
15use sokuto_runtime::engine::StateCacheManager;
16use sokuto_runtime::hierarchical::CoarseToFineRouter;
17
18use crate::error::ServerError;
19use crate::metrics::{
20    record_confidence, record_gating_route, record_hierarchical_early_exit,
21    record_hierarchical_soft_beam, record_inference_metrics, record_question_type,
22};
23use crate::state::AppState;
24
25/// コア推論パイプラインの実行結果サマリー。
26#[derive(Debug, Clone)]
27pub struct InferenceOutput {
28    /// 各質問に対応する判定結果マップ。
29    pub answers: IndexMap<String, Answer>,
30    /// トークン消費統計。
31    pub usage: Usage,
32    /// 確信度ゲーティングサマリー (有効時のみ)。
33    pub routing_summary: Option<SystemRoutingSummary>,
34    /// State キャッシュステータス文字列 ("HIT", "MISS", "BYPASS")。
35    pub cache_status: &'static str,
36    /// キャッシュがヒットしたかどうかのフラグ。
37    pub cache_hit: bool,
38    /// 対象 State のキャッシュキー (任意)。
39    pub state_key: Option<u64>,
40}
41
42impl InferenceOutput {
43    /// 共通レスポンスヘッダー群 (キャッシュ状態および State ハッシュ) を生成する。
44    pub fn to_headers(&self) -> HeaderMap {
45        let mut headers = HeaderMap::new();
46        if let Some(key) = self.state_key
47            && let Ok(hv) = HeaderValue::from_str(&format!("{:016x}", key))
48        {
49            headers.insert("X-State-Hash", hv);
50        }
51        let cache_status_val = HeaderValue::from_static(self.cache_status);
52        headers.insert("X-Cache-Status", cache_status_val.clone());
53        headers.insert("X-State-Cache", cache_status_val.clone());
54        headers.insert("X-Cache", cache_status_val);
55        headers
56    }
57}
58
59/// 共通推論ディスパッチおよび実行制御サービス。
60#[derive(Clone)]
61pub struct InferenceService {
62    state: Arc<AppState>,
63}
64
65impl InferenceService {
66    /// 新規推論サービスを生成する。
67    pub fn new(state: Arc<AppState>) -> Self {
68        Self { state }
69    }
70
71    /// 推論リクエストを実行し、結果およびキャッシュ・ゲーティングメタデータを返却する。
72    ///
73    /// # 処理フロー
74    /// 1. ガードレールパイプラインによる前処理 (無毒化、特殊トークン除去等)。
75    /// 2. 質問プリミティブ種別ごとのメトリクス集計。
76    /// 3. State テキスト展開とキャッシュキー (ハッシュ) の検証および解決。
77    /// 4. `tokio::task::spawn_blocking` による非同期ワーカースレッド分離。
78    /// 5. デュアルエンジン中間特徴量キャッシュまたは通常粗密エンジンの透過的ディスパッチ。
79    /// 6. 後処理ガードレール (二峰性分布補正、キー復元等) の適用。
80    /// 7. 確信度ゲーティング判定と集約サマリーの算出。
81    /// 8. 推論遅延、確信度分布、ゲーティングルーティングメトリクスの一括記録。
82    pub async fn execute(
83        &self,
84        mut req: SystemOneRequest,
85        client_hash_str: Option<String>,
86    ) -> Result<InferenceOutput, ServerError> {
87        let original_questions = req.questions.clone();
88
89        // 1. ガードレール前処理
90        self.state.guardrail_pipeline.process(&mut req, None)?;
91
92        let question_count = req.questions.len();
93
94        // 2. 質問タイプ別メトリクスの記録
95        for question in req.questions.values() {
96            match question.question_type {
97                QuestionType::Choice => record_question_type("choice"),
98                QuestionType::Score => record_question_type("score"),
99                QuestionType::Noul => record_question_type("noul"),
100            }
101        }
102
103        // 3. State 本文の展開とキャッシュ検証
104        let state_text = match &req.state {
105            Value::String(s) => s.clone(),
106            Value::Null => String::new(),
107            other => other.to_string(),
108        };
109
110        let is_state_empty = state_text.trim().is_empty();
111
112        let (state_key, _state_cache_hit_initial) = if is_state_empty {
113            if let Some(ref h_str) = client_hash_str {
114                let key = u64::from_str_radix(h_str.trim().trim_start_matches("0x"), 16).map_err(
115                    |e| ServerError::BadRequest(format!("無効な State ハッシュです: {e}。")),
116                )?;
117                let has_cache = self
118                    .state
119                    .cache_manager
120                    .as_ref()
121                    .map(|cm| cm.contains_key(key))
122                    .unwrap_or(false);
123                if !has_cache {
124                    return Err(ServerError::StateCacheMiss(
125                        "指定された State ハッシュのキャッシュが見つかりません。State 本文を含めて再送してください。".to_string(),
126                    ));
127                }
128                (Some(key), true)
129            } else {
130                return Err(ServerError::BadRequest(
131                    "State 本文が空で、有効な State ハッシュも指定されていません。".to_string(),
132                ));
133            }
134        } else {
135            let key = StateCacheManager::compute_key(&state_text);
136            let has_cache = self
137                .state
138                .cache_manager
139                .as_ref()
140                .map(|cm| cm.contains_key(key))
141                .unwrap_or(false);
142            (Some(key), has_cache)
143        };
144
145        let engine = Arc::clone(&self.state.engine);
146        let dual_engine = self.state.dual_engine.clone();
147        let cache_manager = self.state.cache_manager.clone();
148        let tokenizer = Arc::clone(&self.state.tokenizer);
149        let calib_config = Arc::clone(&self.state.calib_config);
150        let coarse_config = self.state.coarse_config.clone();
151        let guardrail_pipeline = self.state.guardrail_pipeline.clone();
152        let chunk_size = self.state.chunk_size;
153        let questions = req.questions;
154        let gating_config = req
155            .gating
156            .unwrap_or_else(|| self.state.gating_config.clone());
157        let auto_hierarchical = req
158            .auto_hierarchical
159            .unwrap_or(self.state.auto_hierarchical);
160        let hierarchical_mapping = self.state.hierarchical_mapping.clone();
161        let hierarchical_config = self.state.hierarchical_config.clone();
162
163        let inference_start = Instant::now();
164
165        // 4. ブロッキング推論タスクの隔離実行
166        let (answers, prompt_tokens, routing_summary, route_records, cache_hit, cache_status_str) =
167            tokio::task::spawn_blocking(move || {
168                let state_tokens = if !is_state_empty {
169                    tokenizer
170                        .encode_state(&state_text)
171                        .map(|toks| toks.len())
172                        .unwrap_or(0)
173                } else {
174                    0
175                };
176                let questions_tokens: usize = questions
177                    .values()
178                    .map(|q| {
179                        sokuto_runtime::tokenizer::prompt::format_prompt(q)
180                            .and_then(|f| {
181                                tokenizer
182                                    .inner()
183                                    .encode(f.suffix.as_str(), false)
184                                    .map_err(|e| {
185                                        sokuto_runtime::error::RuntimeError::TokenizerEncodeError(
186                                            e.to_string(),
187                                        )
188                                    })
189                            })
190                            .map(|toks| toks.len())
191                            .unwrap_or(0)
192                    })
193                    .sum();
194                let total_prompt_tokens = state_tokens + questions_tokens;
195
196                // 透過的粗密ルーティング対象質問の有無を判定 (マッピング適合性および候補数閾値ガード)
197                let has_hierarchical_target = if let Some(ref mapping) = hierarchical_mapping {
198                    questions.values().any(|q| {
199                        q.question_type == QuestionType::Choice
200                            && q.criteria
201                                .as_ref()
202                                .and_then(|c| c.as_map())
203                                .map(|m| {
204                                    let k = m.len();
205                                    (auto_hierarchical && k > 8 || k > 16)
206                                        && m.keys()
207                                            .any(|key| mapping.fine_to_coarse.contains_key(key))
208                                })
209                                .unwrap_or(false)
210                    })
211                } else {
212                    false
213                };
214
215                let (mut answers, cache_hit, status_str) = if let (Some(de), Some(cm), Some(key)) =
216                    (&dual_engine, &cache_manager, state_key)
217                {
218                    let (cached_state, hit, status) = if let Some(cached) = cm.get(key) {
219                        (cached, true, "HIT")
220                    } else {
221                        let (s_ids, s_mask) = tokenizer.encode_state_tensor(&state_text)?;
222                        let cached = de.encode_and_cache_state(
223                            &state_text,
224                            &s_ids,
225                            &s_mask,
226                            [1, s_ids.len()],
227                        )?;
228                        (cached, false, "MISS")
229                    };
230
231                    if has_hierarchical_target {
232                        let mapping = hierarchical_mapping
233                            .as_ref()
234                            .expect("マッピングが存在すること。");
235                        let router = CoarseToFineRouter::new_dual(
236                            de,
237                            &cached_state,
238                            &tokenizer,
239                            mapping.as_ref(),
240                            &calib_config,
241                            hierarchical_config,
242                        )?;
243
244                        let mut combined_answers =
245                            indexmap::IndexMap::with_capacity(questions.len());
246                        let mut fallback_questions = indexmap::IndexMap::new();
247
248                        let effective_state = if is_state_empty {
249                            cached_state.state_text.as_deref().unwrap_or(&state_text)
250                        } else {
251                            &state_text
252                        };
253
254                        for (qid, question) in &questions {
255                            let is_hierarchical = question.question_type == QuestionType::Choice
256                                && question
257                                    .criteria
258                                    .as_ref()
259                                    .and_then(|c| c.as_map())
260                                    .map(|m| {
261                                        let k = m.len();
262                                        (auto_hierarchical && k > 8 || k > 16)
263                                            && m.keys()
264                                                .any(|key| mapping.fine_to_coarse.contains_key(key))
265                                    })
266                                    .unwrap_or(false);
267
268                            if is_hierarchical {
269                                let res = router.route(
270                                    effective_state,
271                                    &question.instructions,
272                                    Some(&gating_config),
273                                )?;
274                                if res.trace().soft_beam_triggered {
275                                    record_hierarchical_soft_beam();
276                                }
277                                if res.trace().early_exit_triggered {
278                                    record_hierarchical_early_exit();
279                                }
280                                combined_answers.insert(qid.clone(), res.into_answer());
281                            } else {
282                                fallback_questions.insert(qid.clone(), question.clone());
283                            }
284                        }
285
286                        if !fallback_questions.is_empty() {
287                            let fallback_answers = de.evaluate_batch_questions_with_cache(
288                                &tokenizer,
289                                &cached_state,
290                                if is_state_empty {
291                                    None
292                                } else {
293                                    Some(&state_text)
294                                },
295                                &fallback_questions,
296                                &calib_config,
297                                Some(&gating_config),
298                                chunk_size,
299                            )?;
300                            for (qid, answer) in fallback_answers {
301                                combined_answers.insert(qid, answer);
302                            }
303                        }
304
305                        (combined_answers, hit, status)
306                    } else {
307                        let ans = de.evaluate_batch_questions_with_cache(
308                            &tokenizer,
309                            &cached_state,
310                            if is_state_empty {
311                                None
312                            } else {
313                                Some(&state_text)
314                            },
315                            &questions,
316                            &calib_config,
317                            Some(&gating_config),
318                            chunk_size,
319                        )?;
320
321                        (ans, hit, status)
322                    }
323                } else if has_hierarchical_target {
324                    let mapping = hierarchical_mapping
325                        .as_ref()
326                        .expect("マッピングが存在すること。");
327                    let router = CoarseToFineRouter::new(
328                        &engine,
329                        &tokenizer,
330                        mapping.as_ref(),
331                        &calib_config,
332                        hierarchical_config,
333                    )?;
334
335                    let mut combined_answers = indexmap::IndexMap::with_capacity(questions.len());
336                    let mut fallback_questions = indexmap::IndexMap::new();
337
338                    for (qid, question) in &questions {
339                        let is_hierarchical = question.question_type == QuestionType::Choice
340                            && question
341                                .criteria
342                                .as_ref()
343                                .and_then(|c| c.as_map())
344                                .map(|m| {
345                                    let k = m.len();
346                                    (auto_hierarchical && k > 8 || k > 16)
347                                        && m.keys()
348                                            .any(|key| mapping.fine_to_coarse.contains_key(key))
349                                })
350                                .unwrap_or(false);
351
352                        if is_hierarchical {
353                            let res = router.route(
354                                &state_text,
355                                &question.instructions,
356                                Some(&gating_config),
357                            )?;
358                            if res.trace().soft_beam_triggered {
359                                record_hierarchical_soft_beam();
360                            }
361                            if res.trace().early_exit_triggered {
362                                record_hierarchical_early_exit();
363                            }
364                            combined_answers.insert(qid.clone(), res.into_answer());
365                        } else {
366                            fallback_questions.insert(qid.clone(), question.clone());
367                        }
368                    }
369
370                    if !fallback_questions.is_empty() {
371                        let fallback_answers = engine
372                            .evaluate_batch_questions_coarse_to_fine_chunked_with_gating(
373                                &tokenizer,
374                                &state_text,
375                                &fallback_questions,
376                                &calib_config,
377                                &coarse_config,
378                                chunk_size,
379                                Some(&gating_config),
380                            )?;
381                        for (qid, answer) in fallback_answers {
382                            combined_answers.insert(qid, answer);
383                        }
384                    }
385
386                    (combined_answers, false, "BYPASS")
387                } else {
388                    let ans = engine.evaluate_batch_questions_coarse_to_fine_chunked_with_gating(
389                        &tokenizer,
390                        &state_text,
391                        &questions,
392                        &calib_config,
393                        &coarse_config,
394                        chunk_size,
395                        Some(&gating_config),
396                    )?;
397                    (ans, false, "BYPASS")
398                };
399
400                // 6. 後処理ガードレール
401                guardrail_pipeline.post_process(&mut answers, &original_questions);
402
403                // 7. 確信度ゲーティングサマリーおよびルーティングレコードの収集
404                let (routing_summary, route_records) = if gating_config.enabled {
405                    let mut records = Vec::with_capacity(answers.len());
406                    for (qid, answer) in answers.iter() {
407                        let q_def = questions.get(qid);
408                        let q_type_str = q_def
409                            .map(|q| match q.question_type {
410                                QuestionType::Choice => "choice",
411                                QuestionType::Score => "score",
412                                QuestionType::Noul => "noul",
413                            })
414                            .unwrap_or("unknown");
415
416                        if let Some(ref meta) = answer.gating {
417                            records.push((
418                                meta.route.as_str(),
419                                q_type_str,
420                                qid.clone(),
421                                meta.route,
422                                meta.confidence,
423                                meta.reason.clone(),
424                            ));
425                        }
426                    }
427                    let summary = evaluate_response_routing(&answers);
428                    (Some(summary), records)
429                } else {
430                    (None, Vec::new())
431                };
432
433                Ok::<_, ServerError>((
434                    answers,
435                    total_prompt_tokens,
436                    routing_summary,
437                    route_records,
438                    cache_hit,
439                    status_str,
440                ))
441            })
442            .await
443            .map_err(|join_err| {
444                ServerError::Internal(format!(
445                    "ブロッキング推論タスクが異常終了しました: {join_err}。"
446                ))
447            })??;
448
449        let inference_duration = inference_start.elapsed().as_secs_f64();
450
451        // 8. メトリクスの一括記録
452        record_inference_metrics(inference_duration, question_count);
453
454        for answer in answers.values() {
455            if let Some(conf) = answer.effective_confidence() {
456                record_confidence(conf);
457            }
458        }
459
460        for (route_str, q_type_str, qid, route, confidence, reason) in route_records {
461            record_gating_route(route_str, q_type_str);
462            tracing::info!(
463                question_id = %qid,
464                route = %route,
465                confidence = confidence,
466                reason = %reason,
467                "確信度ゲーティング判定を適用しました。"
468            );
469        }
470
471        let usage = Usage::new(prompt_tokens);
472
473        Ok(InferenceOutput {
474            answers,
475            usage,
476            routing_summary,
477            cache_status: cache_status_str,
478            cache_hit,
479            state_key,
480        })
481    }
482}