Skip to main content

sokuto_server/guardrails/
compactor.rs

1//! # コンテキスト縮約・ノイズ除去モジュール (Anti-Context Rot)
2//!
3//! 連続する過剰な空白・改行、不可視のゼロ幅スペース、同一ログ行の極端な繰り返しを圧縮し、
4//! トークン長バジェット (最大系列長) を有効活用するためのテキスト正規化を提供する。
5
6use std::borrow::Cow;
7use std::sync::LazyLock;
8
9use regex::Regex;
10
11/// 3 回以上連続する改行を 2 回 (`\n\n`) に正規化する正規表現。
12static EXCESSIVE_NEWLINES_REGEX: LazyLock<Regex> =
13    LazyLock::new(|| Regex::new(r"\n{3,}").expect("連続改行正規表現のコンパイルに成功すること。"));
14
15/// 2 個以上連続する半角空白・タブを 1 つの空白に正規化する正規表現。
16static EXCESSIVE_WHITESPACE_REGEX: LazyLock<Regex> = LazyLock::new(|| {
17    Regex::new(r"[^\S\r\n]{2,}").expect("連続空白正規表現のコンパイルに成功すること。")
18});
19
20/// コンテキスト縮約設定構造体。
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct CompactorConfig {
23    /// ゼロ幅スペース (`\u{200B}` 等) および不可視制御文字の除去を有効にするか。
24    pub strip_invisible_chars: bool,
25    /// 連続する過剰な空白・改行の縮約を有効にするか。
26    pub collapse_whitespace: bool,
27    /// 同一文字の極端な連続の圧縮を有効にするか。
28    pub collapse_repeated_chars: bool,
29    /// ログ等の同一行の極端な繰り返しの圧縮閾値 (0 の場合は無効化、推奨: 5)。
30    pub max_identical_consecutive_lines: usize,
31}
32
33impl Default for CompactorConfig {
34    fn default() -> Self {
35        Self {
36            strip_invisible_chars: true,
37            collapse_whitespace: true,
38            collapse_repeated_chars: true,
39            max_identical_consecutive_lines: 5,
40        }
41    }
42}
43
44/// ゼロ幅文字や不可視制御文字を判定する。
45#[inline]
46fn is_invisible_or_control(c: char) -> bool {
47    matches!(
48        c,
49        '\u{200B}' | '\u{200C}' | '\u{200D}' | '\u{FEFF}' | '\u{00AD}'
50    ) || (c.is_control() && c != '\n' && c != '\t' && c != '\r')
51}
52
53/// 同一テキスト行の過剰な連続繰り返しを検知し圧縮する。
54fn collapse_consecutive_lines(text: &str, max_consecutive: usize) -> Option<String> {
55    if max_consecutive == 0 || !text.contains('\n') {
56        return None;
57    }
58
59    let mut result = String::with_capacity(text.len());
60    let mut current_line: Option<&str> = None;
61    let mut repeat_count = 0;
62    let mut modified = false;
63
64    let flush = |line: &str, count: usize, out: &mut String, modified: &mut bool| {
65        if count <= max_consecutive {
66            for _ in 0..count {
67                out.push_str(line);
68                out.push('\n');
69            }
70        } else {
71            *modified = true;
72            for _ in 0..max_consecutive {
73                out.push_str(line);
74                out.push('\n');
75            }
76            out.push_str(&format!(
77                "[... 同一行がさらに {} 回繰り返されたため省略 ...]\n",
78                count - max_consecutive
79            ));
80        }
81    };
82
83    for line in text.split('\n') {
84        if line.is_empty() {
85            if let Some(prev) = current_line.take() {
86                flush(prev, repeat_count, &mut result, &mut modified);
87                repeat_count = 0;
88            }
89            result.push('\n');
90            continue;
91        }
92
93        match current_line {
94            Some(prev) if prev == line => {
95                repeat_count += 1;
96            }
97            Some(prev) => {
98                flush(prev, repeat_count, &mut result, &mut modified);
99                current_line = Some(line);
100                repeat_count = 1;
101            }
102            None => {
103                current_line = Some(line);
104                repeat_count = 1;
105            }
106        }
107    }
108
109    if let Some(prev) = current_line {
110        flush(prev, repeat_count, &mut result, &mut modified);
111    }
112
113    // 末尾の不要な改行調整 (元のテキストの末尾に改行がなかった場合)
114    if !text.ends_with('\n') && result.ends_with('\n') {
115        result.pop();
116    }
117
118    if modified { Some(result) } else { None }
119}
120
121/// 同一文字の極端な連続 (20 回以上) を検知して圧縮する。
122fn collapse_repeated_chars_fast(text: &str) -> Option<String> {
123    let mut chars = text.chars().peekable();
124    let mut modified = false;
125    let mut result = String::with_capacity(text.len());
126
127    while let Some(c) = chars.next() {
128        let mut count = 1;
129        while let Some(&next_c) = chars.peek() {
130            if next_c == c {
131                count += 1;
132                chars.next();
133            } else {
134                break;
135            }
136        }
137
138        if count >= 20 {
139            modified = true;
140            for _ in 0..5 {
141                result.push(c);
142            }
143            result.push_str("[...省略...]");
144        } else {
145            for _ in 0..count {
146                result.push(c);
147            }
148        }
149    }
150
151    if modified { Some(result) } else { None }
152}
153
154/// テキストのコンテキスト縮約およびノイズ除去を実行する。
155///
156/// # 引数
157/// - `text`: 正規化対象文字列。
158/// - `config`: コンパクター設定。
159pub fn compact_text<'a>(text: &'a str, config: &CompactorConfig) -> Cow<'a, str> {
160    if text.is_empty() {
161        return Cow::Borrowed(text);
162    }
163
164    let mut current = Cow::Borrowed(text);
165
166    // 1. 不可視文字・ゼロ幅文字の除去
167    if config.strip_invisible_chars && current.chars().any(is_invisible_or_control) {
168        let cleaned: String = current
169            .chars()
170            .filter(|&c| !is_invisible_or_control(c))
171            .collect();
172        current = Cow::Owned(cleaned);
173    }
174
175    // 2. 同一行の繰り返し圧縮
176    if config.max_identical_consecutive_lines > 0
177        && let Some(collapsed) =
178            collapse_consecutive_lines(&current, config.max_identical_consecutive_lines)
179    {
180        current = Cow::Owned(collapsed);
181    }
182
183    // 3. 同一文字の極端な連続の短縮
184    if config.collapse_repeated_chars
185        && let Some(collapsed) = collapse_repeated_chars_fast(&current)
186    {
187        current = Cow::Owned(collapsed);
188    }
189
190    // 4. 過剰な空白・改行の縮約
191    if config.collapse_whitespace {
192        let has_excessive_newlines = EXCESSIVE_NEWLINES_REGEX.is_match(&current);
193        let has_excessive_whitespace = EXCESSIVE_WHITESPACE_REGEX.is_match(&current);
194
195        if has_excessive_newlines || has_excessive_whitespace {
196            let mut s = current.into_owned();
197            if has_excessive_newlines {
198                s = EXCESSIVE_NEWLINES_REGEX
199                    .replace_all(&s, "\n\n")
200                    .into_owned();
201            }
202            if has_excessive_whitespace {
203                s = EXCESSIVE_WHITESPACE_REGEX.replace_all(&s, " ").into_owned();
204            }
205            current = Cow::Owned(s);
206        }
207    }
208
209    current
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215
216    #[test]
217    fn test_strip_invisible_chars() {
218        let config = CompactorConfig {
219            collapse_whitespace: false,
220            collapse_repeated_chars: false,
221            max_identical_consecutive_lines: 0,
222            ..Default::default()
223        };
224
225        let raw = "こんにちは\u{200B}世界\u{FEFF}!";
226        let res = compact_text(raw, &config);
227        assert_eq!(res, "こんにちは世界!");
228    }
229
230    #[test]
231    fn test_collapse_whitespace_and_newlines() {
232        let config = CompactorConfig {
233            strip_invisible_chars: false,
234            collapse_repeated_chars: false,
235            max_identical_consecutive_lines: 0,
236            collapse_whitespace: true,
237        };
238
239        let raw = "行1\n\n\n\n\n行2   空白   テスト";
240        let res = compact_text(raw, &config);
241        assert_eq!(res, "行1\n\n行2 空白 テスト");
242    }
243
244    #[test]
245    fn test_collapse_consecutive_lines() {
246        let config = CompactorConfig {
247            collapse_whitespace: false,
248            strip_invisible_chars: false,
249            collapse_repeated_chars: false,
250            max_identical_consecutive_lines: 3,
251        };
252
253        let log = "INFO: healthcheck\nINFO: healthcheck\nINFO: healthcheck\nINFO: healthcheck\nINFO: healthcheck\nINFO: done";
254        let res = compact_text(log, &config);
255        assert!(res.contains("[... 同一行がさらに 2 回繰り返されたため省略 ...]"));
256        assert!(res.contains("INFO: done"));
257    }
258}