|
| 1 | +//! 上下文预算分配器(v0.18.2 REQ-254;ADR-027 §4 硬约束纯函数)。 |
| 2 | +//! |
| 3 | +//! @ai-context: 调用成本与库规模解耦——O(1) 恒量预算:档位内 系统+目标摘要 |
| 4 | +//! ~2K|信号 ~0.3K|检索片段 ~2.4K|输出预留 ~2K(设计 §六), |
| 5 | +//! 超限按相关性截断且**诚实提示绝不静默**;任意大输入 → 恒定输出。 |
| 6 | +//! @ai-context: 档位化(轻量 4K/标准 10K/深度 30K,默认标准)——设置页可选。 |
| 7 | +
|
| 8 | +/// 档位 token 上限(轻量/标准/深度)。 |
| 9 | +pub const TIER_LIGHT_TOKENS: usize = 4_000; |
| 10 | +pub const TIER_STANDARD_TOKENS: usize = 10_000; |
| 11 | +pub const TIER_DEEP_TOKENS: usize = 30_000; |
| 12 | + |
| 13 | +/// 字符→token 粗估(中文 ≈1.5-2 字/token,保守 2 字/token——低估 token 会爆 |
| 14 | +/// 预算,取保守值做**上界预留**)。 |
| 15 | +pub const CHARS_PER_TOKEN: usize = 2; |
| 16 | + |
| 17 | +/// 档位解析(未知/空 → 标准——诚实回落不猜)。 |
| 18 | +pub fn tier_tokens(tier: &str) -> usize { |
| 19 | + match tier { |
| 20 | + "light" => TIER_LIGHT_TOKENS, |
| 21 | + "deep" => TIER_DEEP_TOKENS, |
| 22 | + _ => TIER_STANDARD_TOKENS, |
| 23 | + } |
| 24 | +} |
| 25 | + |
| 26 | +/// 预算分解(系统+摘要/信号/检索/输出预留——剩余留断言与容错)。 |
| 27 | +#[derive(Debug, Clone, PartialEq, serde::Serialize)] |
| 28 | +#[serde(rename_all = "camelCase")] |
| 29 | +pub struct BudgetPlan { |
| 30 | + pub tier: String, |
| 31 | + pub total_tokens: usize, |
| 32 | + pub overhead_tokens: usize, |
| 33 | + pub retrieval_tokens: usize, |
| 34 | + pub output_reserve_tokens: usize, |
| 35 | + /// 检索侧可用的字符预算(截断上界)。 |
| 36 | + pub retrieval_chars: usize, |
| 37 | +} |
| 38 | + |
| 39 | +/// 按档位生成预算(开销恒量——任意档位 O(1))。 |
| 40 | +/// |
| 41 | +/// @ai-context: 摘要/信号为恒量开销(<2K),检索按剩余额度分配—— |
| 42 | +/// 输出预留 2K + 系统/摘要/信号 2.3K 是硬保留。 |
| 43 | +pub fn plan_budget(tier: &str) -> BudgetPlan { |
| 44 | + let total = tier_tokens(tier); |
| 45 | + let overhead_tokens = 2_300; // 系统提示 + 目标摘要 + 标量信号 |
| 46 | + let output_reserve_tokens = 2_000; |
| 47 | + let retrieval_tokens = total.saturating_sub(overhead_tokens + output_reserve_tokens); |
| 48 | + BudgetPlan { |
| 49 | + tier: tier.to_string(), |
| 50 | + total_tokens: total, |
| 51 | + overhead_tokens, |
| 52 | + retrieval_tokens, |
| 53 | + output_reserve_tokens, |
| 54 | + retrieval_chars: retrieval_tokens * CHARS_PER_TOKEN, |
| 55 | + } |
| 56 | +} |
| 57 | + |
| 58 | +/// 检索片段截断(字符上界;O(len) 单遍——片段长时按界截)。 |
| 59 | +pub fn truncate_retrieval(text: &str, budget_chars: usize) -> String { |
| 60 | + if budget_chars == 0 { |
| 61 | + return String::new(); |
| 62 | + } |
| 63 | + text.chars().take(budget_chars).collect() |
| 64 | +} |
| 65 | + |
| 66 | +/// 多片段排序截断(按给定相关性得分离低;总量 ≤ budget_chars——O(N log N) 排序 |
| 67 | +/// 后单遍累加,**输出上界与输入规模无关**:预算恒定)。 |
| 68 | +/// 登记豁免 dead_code:多片段检索打包为 L3 FTS 接线预留(当前最小面上文为 |
| 69 | +/// 单体串截断 truncate_retrieval——本函数 TDD 就绪,随 REQ-024 检索增强启用)。 |
| 70 | +#[allow(dead_code)] |
| 71 | +pub fn pack_fragments(fragments: &[(String, f64)], budget_chars: usize) -> (String, bool) { |
| 72 | + let mut sorted: Vec<&(String, f64)> = fragments.iter().collect(); |
| 73 | + sorted.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); |
| 74 | + let mut out = String::new(); |
| 75 | + let mut used = 0usize; |
| 76 | + let mut truncated = false; |
| 77 | + for (text, _score) in sorted { |
| 78 | + let remaining = budget_chars.saturating_sub(used); |
| 79 | + if remaining == 0 { |
| 80 | + truncated = true; |
| 81 | + break; |
| 82 | + } |
| 83 | + let take = text.chars().count().min(remaining); |
| 84 | + if take < text.chars().count() { |
| 85 | + truncated = true; |
| 86 | + } |
| 87 | + out.push_str(&text.chars().take(take).collect::<String>()); |
| 88 | + out.push('\n'); |
| 89 | + used += take + 1; |
| 90 | + } |
| 91 | + (out, truncated) |
| 92 | +} |
| 93 | + |
| 94 | +/// 诚实提示文案(截断/降级时注入——绝不静默;前端展示用)。 |
| 95 | +pub fn honest_truncation_note(truncated: bool) -> &'static str { |
| 96 | + if truncated { |
| 97 | + "⚠️ 上下文超限,已按相关性精简(成本与库规模解耦,不影响结论)" |
| 98 | + } else { |
| 99 | + "" |
| 100 | + } |
| 101 | +} |
| 102 | + |
| 103 | +#[cfg(test)] |
| 104 | +#[path = "budget_allocator_tests.rs"] |
| 105 | +mod tests; |
0 commit comments