Skip to main content

tokenfold_core/transforms/
log_fold.rs

1//! `log_field_fold` transform (canonical id `"log_field_fold"`, v1.0.0).
2//!
3//! Content-aware, **losslessly reversible** structural compression for line-oriented text
4//! (`InputFormat::PlainText` / `CommandOutput`). It is the log-line analogue of
5//! [`json_field_fold`](super::json_fold): where that folds arrays of homogeneous *objects* by
6//! emitting each repeated key once, this folds runs of *templated* log lines by emitting each
7//! repeated line **template** once.
8//!
9//! Most log lines share a fixed skeleton and vary only in a few fields (timestamps, ids, counts):
10//!
11//! ```text
12//! 2026-07-01T10:05:11Z req=req-0311 status=200 ms=41
13//! 2026-07-01T10:05:12Z req=req-0312 status=200 ms=44
14//! ```
15//!
16//! Replacing each variable token (a run of digits, or a `0x…` hex literal) with a placeholder
17//! yields one shared template `2026-\x00-\x00T\x00:\x00:\x00Z req=req-\x00 status=\x00 ms=\x00`
18//! plus a per-line tuple of the captured values. The fold emits every distinct template once and
19//! a compact row per original line (`template_id` + its captured values), so the skeleton text is
20//! paid for once instead of per line — a large win on repetitive logs, unlike
21//! [`log_compaction`](super::logs) which only collapses *identical adjacent* lines.
22//!
23//! It is a pure structural rewrite: `unfold_log` reconstructs the original bytes exactly, and the
24//! pipeline gates adoption on that round-trip ([`round_trips`]) — a fold that would ever lose data
25//! (or a genuine input that happens to look like our framing) is rolled back rather than emitted.
26
27/// Canonical transform id, as registered with the pipeline.
28pub const TRANSFORM_ID: &str = "log_field_fold";
29
30/// Semantic version of this transform's output behavior.
31pub const TRANSFORM_VERSION: &str = "1.0.0";
32
33/// First line of the folded blob. Collision-unlikely in real logs; any actual collision is caught
34/// by the pipeline's round-trip safety gate, so it never corrupts data.
35const HEADER: &str = "__tf_logfold1__";
36
37/// Placeholder standing in for a captured variable token inside a template.
38const PH: char = '\u{0}';
39
40/// Minimum number of lines worth folding, and the maximum distinct-template fraction below which a
41/// fold can pay off. Both are cheap early-outs; the pipeline also rolls back any net token
42/// regression, so these are heuristics, not the correctness boundary.
43const MIN_LINES: usize = 3;
44
45use regex::Regex;
46use std::sync::OnceLock;
47
48/// Matches a maximal variable token: a `0x…` hex literal or a run of decimal digits. Linear-time
49/// (no backtracking); see `deny.toml`.
50fn var_pattern() -> &'static Regex {
51    static RE: OnceLock<Regex> = OnceLock::new();
52    RE.get_or_init(|| Regex::new(r"0x[0-9a-fA-F]+|[0-9]+").expect("var_pattern is a valid literal"))
53}
54
55/// Replaces each variable token in `segment` with [`PH`], returning the template and the captured
56/// tokens in left-to-right order. `caps` never contain [`PH`], `\n`, or spaces (the pattern only
57/// matches `[0-9a-fA-F]`/`x`), which keeps the row serialization below unambiguous.
58fn templatize(segment: &str) -> (String, Vec<&str>) {
59    let re = var_pattern();
60    let mut template = String::with_capacity(segment.len());
61    let mut caps = Vec::new();
62    let mut last = 0;
63    for m in re.find_iter(segment) {
64        template.push_str(&segment[last..m.start()]);
65        template.push(PH);
66        caps.push(m.as_str());
67        last = m.end();
68    }
69    template.push_str(&segment[last..]);
70    (template, caps)
71}
72
73/// Folds runs of templated lines in `input` into a header + a JSON array of distinct templates +
74/// one compact row (`template_id` then captured values) per original line. Returns `input`
75/// unchanged when folding cannot help (too few lines, no shared templates, or a line contains the
76/// placeholder char). Never panics.
77pub fn fold_log(input: &str) -> String {
78    if input.is_empty() || input.contains(PH) {
79        return input.to_string();
80    }
81    // split_inclusive keeps each line's trailing '\n' as part of the segment, so reassembly is
82    // byte-exact (CRLF, blank lines, and a missing final newline are all preserved).
83    let segments: Vec<&str> = input.split_inclusive('\n').collect();
84    if segments.len() < MIN_LINES {
85        return input.to_string();
86    }
87
88    let mut templates: Vec<String> = Vec::new();
89    let mut ids: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
90    let mut rows: Vec<(usize, Vec<&str>)> = Vec::with_capacity(segments.len());
91    for seg in &segments {
92        let (template, caps) = templatize(seg);
93        let id = *ids.entry(template.clone()).or_insert_with(|| {
94            templates.push(template);
95            templates.len() - 1
96        });
97        rows.push((id, caps));
98    }
99
100    // Only worth it if templates are actually shared (skeleton text amortizes). If every line is
101    // its own template there is nothing to save; let the pipeline keep the original.
102    if templates.len() >= segments.len() {
103        return input.to_string();
104    }
105
106    let mut out = String::with_capacity(input.len() / 2);
107    out.push_str(HEADER);
108    out.push('\n');
109    out.push_str(&serde_json::to_string(&templates).expect("Vec<String> always serializes"));
110    for (id, caps) in &rows {
111        out.push('\n');
112        out.push_str(&id.to_string());
113        for cap in caps {
114            out.push(' ');
115            out.push_str(cap);
116        }
117    }
118    out
119}
120
121/// Inverse of [`fold_log`]: expands a folded blob back to the original text. Returns `input`
122/// unchanged if it is not a well-formed folded blob (so a genuine log that merely starts with the
123/// header is not mangled — the pipeline's [`round_trips`] gate makes that safe either way).
124pub fn unfold_log(input: &str) -> String {
125    match try_unfold(input) {
126        Some(s) => s,
127        None => input.to_string(),
128    }
129}
130
131fn try_unfold(input: &str) -> Option<String> {
132    let mut lines = input.split('\n');
133    if lines.next()? != HEADER {
134        return None;
135    }
136    let templates: Vec<String> = serde_json::from_str(lines.next()?).ok()?;
137    let mut out = String::with_capacity(input.len() * 2);
138    for row in lines {
139        let mut fields = row.split(' ');
140        let id: usize = fields.next()?.parse().ok()?;
141        let template = templates.get(id)?;
142        let mut caps = fields;
143        // Interleave the template's constant segments with the captured values. The number of
144        // placeholders must equal the number of captures, else this isn't our framing.
145        let mut parts = template.split(PH);
146        out.push_str(parts.next()?);
147        for part in parts {
148            out.push_str(caps.next()?);
149            out.push_str(part);
150        }
151        if caps.next().is_some() {
152            return None; // more captures than placeholders → malformed
153        }
154    }
155    Some(out)
156}
157
158/// True iff unfolding `after` reproduces `before` exactly. The pipeline's safety gate for this
159/// transform — folding is only adopted when this holds.
160pub fn round_trips(before: &[u8], after: &[u8]) -> bool {
161    let (Ok(before_s), Ok(after_s)) = (std::str::from_utf8(before), std::str::from_utf8(after))
162    else {
163        return false;
164    };
165    unfold_log(after_s) == before_s
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    fn assert_lossless(input: &str) {
173        let folded = fold_log(input);
174        assert!(
175            round_trips(input.as_bytes(), folded.as_bytes()),
176            "round_trips() rejected the fold of {input:?}"
177        );
178        assert_eq!(
179            unfold_log(&folded),
180            input,
181            "unfold != original for {input:?}"
182        );
183    }
184
185    #[test]
186    fn folds_templated_log_and_emits_skeleton_once() {
187        // A realistically-sized run of one skeleton, so the shared template amortizes: the header +
188        // template JSON is paid once, the per-line skeleton text is not.
189        let input: String = (0..40)
190            .map(|i| format!("req=req-{i:04} status=200 ms={}\n", 30 + i % 20))
191            .collect();
192        let folded = fold_log(&input);
193        assert!(
194            folded.starts_with(HEADER),
195            "expected folded form, got {folded:?}"
196        );
197        // the shared skeleton word "status" is emitted once (in the single template), not per line.
198        assert_eq!(folded.matches("status").count(), 1);
199        assert!(
200            folded.len() < input.len(),
201            "fold ({}) not smaller than input ({})",
202            folded.len(),
203            input.len()
204        );
205        assert_lossless(&input);
206    }
207
208    #[test]
209    fn preserves_crlf_blank_lines_and_missing_final_newline() {
210        assert_lossless("a=1\r\na=2\r\na=3\r\n");
211        assert_lossless("x=1\n\nx=2\n\nx=3\n");
212        assert_lossless("x=1\nx=2\nx=3"); // no trailing newline
213    }
214
215    #[test]
216    fn no_shared_templates_is_left_unchanged() {
217        // every line a distinct skeleton → nothing to fold.
218        let input = "alpha\nbeta gamma\ndelta epsilon zeta\n";
219        assert_eq!(fold_log(input), input);
220    }
221
222    #[test]
223    fn fewer_than_min_lines_is_left_unchanged() {
224        let input = "req=1 ok\nreq=2 ok\n";
225        assert_eq!(fold_log(input), input);
226    }
227
228    #[test]
229    fn empty_input_is_a_noop() {
230        assert_eq!(fold_log(""), "");
231        assert_eq!(unfold_log(""), "");
232    }
233
234    #[test]
235    fn lines_with_no_variable_tokens_still_fold_when_identical() {
236        // three identical constant lines share one (capture-less) template.
237        let input = "heartbeat ok\nheartbeat ok\nheartbeat ok\n";
238        assert_lossless(input);
239    }
240
241    #[test]
242    fn hex_and_decimal_tokens_both_captured() {
243        let input = "addr=0x1f val=10\naddr=0x2a val=20\naddr=0x3b val=30\n";
244        assert_lossless(input);
245        // the "addr=" / "val=" skeleton is shared → one template.
246        assert!(fold_log(input).starts_with(HEADER));
247    }
248
249    #[test]
250    fn input_containing_the_placeholder_char_is_not_folded() {
251        let input = "a\u{0}b\na\u{0}c\na\u{0}d\n";
252        assert_eq!(fold_log(input), input);
253    }
254
255    #[test]
256    fn genuine_input_shaped_like_the_header_round_trips_safely() {
257        // If real text starts with our header, unfold must not silently corrupt it: fold_log of a
258        // 2-line input is a no-op (< MIN_LINES), and round_trips gates the pipeline regardless.
259        let input = "__tf_logfold1__\n[\"x\"]\n0 boom";
260        let folded = fold_log(input);
261        // Either it wasn't folded (identity) or, if it were, the pipeline would only adopt it when
262        // round_trips holds — so the original is always recoverable either way.
263        assert_eq!(unfold_log(&folded), input);
264    }
265
266    use proptest::prelude::*;
267
268    // Lines built from a small alphabet of skeletons + digit/hex fields, so templates recur (the
269    // regime the fold targets) while still exercising CRLF, blanks, and missing final newlines.
270    fn arb_log() -> impl Strategy<Value = String> {
271        let line = prop_oneof![
272            (0u32..999u32).prop_map(|n| format!("req={n} status=200")),
273            (0u32..999u32).prop_map(|n| format!("addr=0x{n:x} ok")),
274            Just("heartbeat".to_string()),
275            "[a-z ]{0,8}".prop_map(|s| s),
276        ];
277        (prop::collection::vec(line, 0..40), any::<bool>()).prop_map(|(lines, trailing)| {
278            let mut s = lines.join("\n");
279            if trailing && !s.is_empty() {
280                s.push('\n');
281            }
282            s
283        })
284    }
285
286    proptest! {
287        // Core safety guarantee: folding never loses data. For ANY log-ish input, unfolding the
288        // folded form reproduces it exactly, and round_trips() (the pipeline's gate) agrees.
289        #[test]
290        fn fold_then_unfold_is_the_identity(input in arb_log()) {
291            let folded = fold_log(&input);
292            prop_assert!(round_trips(input.as_bytes(), folded.as_bytes()));
293            prop_assert_eq!(unfold_log(&folded), input);
294        }
295    }
296}