From 342f194c87dfb7fdc0357aabdf1869c088519141 Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 1 Jun 2026 17:29:58 +0900 Subject: [PATCH] fix: avoid projecting token measurements past latest usage --- crates/llm-worker/src/token_counter.rs | 59 +++++++++++-------------- crates/pod/src/compact/token_counter.rs | 4 +- 2 files changed, 27 insertions(+), 36 deletions(-) diff --git a/crates/llm-worker/src/token_counter.rs b/crates/llm-worker/src/token_counter.rs index f7d4482e..c79073be 100644 --- a/crates/llm-worker/src/token_counter.rs +++ b/crates/llm-worker/src/token_counter.rs @@ -6,8 +6,8 @@ //! # 方針 //! //! - ローカルトークナイザは持たない。実測値があればそれを採用し、 -//! measurement 間はバイト数で按分、最新 measurement より先は測定済みの増分 rate -//! または byte/4 fallback で外挿する +//! measurement 間はバイト数で按分、最新 measurement より先は byte/4 +//! fallback で外挿する //! - 推定の出どころは [`EstimateSource`] で呼び出し側に明示する。 //! 課金判断には使えないが、compact / prune / memory extract trigger 等の //! 閾値判定には十分な精度 @@ -22,7 +22,7 @@ pub enum EstimateSource { Measured, /// 連続する 2 つの measurement の間をバイト按分で計算 Interpolated, - /// 最後の measurement より新しい区間を最終 rate で外挿 + /// 最後の measurement より新しい区間を byte/4 fallback で外挿 Extrapolated, /// measurement が 1 件も無く、バイト数のみのフォールバック NoData, @@ -122,33 +122,8 @@ pub fn tokens_at( let at_bytes = prefix[index]; let delta_bytes = at_bytes.saturating_sub(lo_bytes); - let mut measured_span = None; - for pair in records.windows(2) { - let older = &pair[0]; - let newer = &pair[1]; - if newer.history_len > lo.history_len { - break; - } - - let older_bytes = prefix[older.history_len.min(cap)]; - let newer_bytes = prefix[newer.history_len.min(cap)]; - let span_bytes = newer_bytes.saturating_sub(older_bytes); - let span_tokens = newer - .input_total_tokens - .saturating_sub(older.input_total_tokens); - if span_bytes > 0 && span_tokens > 0 { - measured_span = Some((span_tokens, span_bytes)); - } - } - - let delta_tokens = if let Some((span_tokens, span_bytes)) = measured_span { - (delta_bytes as u128 * span_tokens as u128 / span_bytes as u128) as u64 - } else { - delta_bytes / 4 - }; - TokenEstimate { - tokens: lo.input_total_tokens.saturating_add(delta_tokens), + tokens: lo.input_total_tokens.saturating_add(delta_bytes / 4), source: EstimateSource::Extrapolated, } } @@ -255,7 +230,7 @@ mod tests { } #[test] - fn extrapolation_prefers_latest_measured_incremental_span_rate() { + fn extrapolation_after_multiple_measurements_uses_byte_fallback_for_unmeasured_delta() { let history = vec![ msg("first"), msg(&"measured increment ".repeat(20)), @@ -263,15 +238,31 @@ mod tests { ]; let records = vec![record(1, 10_000), record(2, 10_200)]; let prefix = prefix_bytes(&history); - let measured_bytes = prefix[2].saturating_sub(prefix[1]); let delta_bytes = prefix[3].saturating_sub(prefix[2]); - let expected_delta = (delta_bytes as u128 * 200_u128 / measured_bytes as u128) as u64; let est = total_tokens(&history, &records); assert_eq!(est.source, EstimateSource::Extrapolated); - assert_eq!(est.tokens, 10_200 + expected_delta); - assert_ne!(est.tokens, 10_200 + delta_bytes / 4); + assert_eq!(est.tokens, 10_200 + delta_bytes / 4); + } + + #[test] + fn extrapolation_does_not_reuse_measured_rate_after_context_projection() { + let compacted_span = msg("x"); + let projected = vec![ + msg("first"), + msg("summary only"), + compacted_span, + msg("new user input"), + ]; + let records = vec![record(1, 10_000), record(3, 30_000)]; + let prefix = prefix_bytes(&projected); + let delta_bytes = prefix[4].saturating_sub(prefix[3]); + + let est = total_tokens(&projected, &records); + + assert_eq!(est.source, EstimateSource::Extrapolated); + assert_eq!(est.tokens, 30_000 + delta_bytes / 4); } #[test] diff --git a/crates/pod/src/compact/token_counter.rs b/crates/pod/src/compact/token_counter.rs index 459922e9..aa843764 100644 --- a/crates/pod/src/compact/token_counter.rs +++ b/crates/pod/src/compact/token_counter.rs @@ -9,7 +9,7 @@ //! # 方針 //! //! - ローカルトークナイザは持たない。実測値があればそれを採用し、 -//! measurement 間はバイト数で按分、最新 measurement より先は最終 rate で外挿する +//! measurement 間はバイト数で按分、最新 measurement より先は byte/4 で外挿する //! - Compact の retained split では、request-time pruning / projection 後の //! `UsageRecord` を persisted history prefix の単調系列として扱わない。 //! 現在の prompt occupancy 推定を raw serialized bytes に配分し、末尾の @@ -245,7 +245,7 @@ pub(crate) fn savings_for_prune_impl( impl Pod { /// 現在の history 全体の推定トークン数。 /// - /// 最後の measurement と、その後に追加された未測定分のバイト按分/外挿。 + /// 最後の measurement と、その後に追加された未測定分の byte/4 外挿。 pub fn total_tokens(&self) -> TokenEstimate { let usage = self.usage_history(); llm_worker::token_counter::total_tokens(self.history(), &usage)