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}