tokenfold 0.3.1__tar.gz → 0.3.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. {tokenfold-0.3.1 → tokenfold-0.3.3}/Cargo.lock +10 -10
  2. {tokenfold-0.3.1 → tokenfold-0.3.3}/Cargo.toml +1 -1
  3. {tokenfold-0.3.1 → tokenfold-0.3.3}/PKG-INFO +22 -17
  4. {tokenfold-0.3.1 → tokenfold-0.3.3}/README.md +21 -16
  5. {tokenfold-0.3.1 → tokenfold-0.3.3}/crates/tokenfold-core/src/modes.rs +21 -0
  6. {tokenfold-0.3.1 → tokenfold-0.3.3}/crates/tokenfold-core/src/pipeline.rs +11 -0
  7. {tokenfold-0.3.1 → tokenfold-0.3.3}/crates/tokenfold-core/src/report.rs +52 -0
  8. tokenfold-0.3.3/crates/tokenfold-core/src/transforms/log_fold.rs +296 -0
  9. {tokenfold-0.3.1 → tokenfold-0.3.3}/crates/tokenfold-core/src/transforms/mod.rs +1 -0
  10. {tokenfold-0.3.1 → tokenfold-0.3.3}/crates/tokenfold-py/Cargo.toml +1 -1
  11. {tokenfold-0.3.1 → tokenfold-0.3.3}/LICENSE +0 -0
  12. {tokenfold-0.3.1 → tokenfold-0.3.3}/crates/tokenfold-core/Cargo.toml +0 -0
  13. {tokenfold-0.3.1 → tokenfold-0.3.3}/crates/tokenfold-core/benches/THRESHOLDS.toml +0 -0
  14. {tokenfold-0.3.1 → tokenfold-0.3.3}/crates/tokenfold-core/benches/compression_bench.rs +0 -0
  15. {tokenfold-0.3.1 → tokenfold-0.3.3}/crates/tokenfold-core/examples/quickstart.rs +0 -0
  16. {tokenfold-0.3.1 → tokenfold-0.3.3}/crates/tokenfold-core/src/budget.rs +0 -0
  17. {tokenfold-0.3.1 → tokenfold-0.3.3}/crates/tokenfold-core/src/errors.rs +0 -0
  18. {tokenfold-0.3.1 → tokenfold-0.3.3}/crates/tokenfold-core/src/filters.rs +0 -0
  19. {tokenfold-0.3.1 → tokenfold-0.3.3}/crates/tokenfold-core/src/input.rs +0 -0
  20. {tokenfold-0.3.1 → tokenfold-0.3.3}/crates/tokenfold-core/src/lib.rs +0 -0
  21. {tokenfold-0.3.1 → tokenfold-0.3.3}/crates/tokenfold-core/src/retrieval_store.rs +0 -0
  22. {tokenfold-0.3.1 → tokenfold-0.3.3}/crates/tokenfold-core/src/safety.rs +0 -0
  23. {tokenfold-0.3.1 → tokenfold-0.3.3}/crates/tokenfold-core/src/stats.rs +0 -0
  24. {tokenfold-0.3.1 → tokenfold-0.3.3}/crates/tokenfold-core/src/status.rs +0 -0
  25. {tokenfold-0.3.1 → tokenfold-0.3.3}/crates/tokenfold-core/src/token_estimator.rs +0 -0
  26. {tokenfold-0.3.1 → tokenfold-0.3.3}/crates/tokenfold-core/src/transforms/diff.rs +0 -0
  27. {tokenfold-0.3.1 → tokenfold-0.3.3}/crates/tokenfold-core/src/transforms/json.rs +0 -0
  28. {tokenfold-0.3.1 → tokenfold-0.3.3}/crates/tokenfold-core/src/transforms/json_dict.rs +0 -0
  29. {tokenfold-0.3.1 → tokenfold-0.3.3}/crates/tokenfold-core/src/transforms/json_fold.rs +0 -0
  30. {tokenfold-0.3.1 → tokenfold-0.3.3}/crates/tokenfold-core/src/transforms/logs.rs +0 -0
  31. {tokenfold-0.3.1 → tokenfold-0.3.3}/crates/tokenfold-core/src/transforms/redaction.rs +0 -0
  32. {tokenfold-0.3.1 → tokenfold-0.3.3}/crates/tokenfold-core/src/transforms/schema.rs +0 -0
  33. {tokenfold-0.3.1 → tokenfold-0.3.3}/crates/tokenfold-core/tests/golden.rs +0 -0
  34. {tokenfold-0.3.1 → tokenfold-0.3.3}/crates/tokenfold-core/tests/integration.rs +0 -0
  35. {tokenfold-0.3.1 → tokenfold-0.3.3}/crates/tokenfold-core/tests/property_tests.rs +0 -0
  36. {tokenfold-0.3.1 → tokenfold-0.3.3}/crates/tokenfold-py/src/lib.rs +0 -0
  37. {tokenfold-0.3.1 → tokenfold-0.3.3}/pyproject.toml +0 -0
@@ -1013,7 +1013,7 @@ dependencies = [
1013
1013
 
1014
1014
  [[package]]
1015
1015
  name = "tokenfold-adapters"
1016
- version = "0.3.1"
1016
+ version = "0.3.3"
1017
1017
  dependencies = [
1018
1018
  "serde",
1019
1019
  "serde_json",
@@ -1022,7 +1022,7 @@ dependencies = [
1022
1022
 
1023
1023
  [[package]]
1024
1024
  name = "tokenfold-admin"
1025
- version = "0.3.1"
1025
+ version = "0.3.3"
1026
1026
  dependencies = [
1027
1027
  "ed25519-dalek",
1028
1028
  "serde",
@@ -1032,7 +1032,7 @@ dependencies = [
1032
1032
 
1033
1033
  [[package]]
1034
1034
  name = "tokenfold-cli"
1035
- version = "0.3.1"
1035
+ version = "0.3.3"
1036
1036
  dependencies = [
1037
1037
  "clap",
1038
1038
  "serde",
@@ -1045,7 +1045,7 @@ dependencies = [
1045
1045
 
1046
1046
  [[package]]
1047
1047
  name = "tokenfold-core"
1048
- version = "0.3.1"
1048
+ version = "0.3.3"
1049
1049
  dependencies = [
1050
1050
  "proptest",
1051
1051
  "regex",
@@ -1059,25 +1059,25 @@ dependencies = [
1059
1059
 
1060
1060
  [[package]]
1061
1061
  name = "tokenfold-image"
1062
- version = "0.3.1"
1062
+ version = "0.3.3"
1063
1063
 
1064
1064
  [[package]]
1065
1065
  name = "tokenfold-learn"
1066
- version = "0.3.1"
1066
+ version = "0.3.3"
1067
1067
  dependencies = [
1068
1068
  "tokenfold-core",
1069
1069
  ]
1070
1070
 
1071
1071
  [[package]]
1072
1072
  name = "tokenfold-output"
1073
- version = "0.3.1"
1073
+ version = "0.3.3"
1074
1074
  dependencies = [
1075
1075
  "tokenfold-core",
1076
1076
  ]
1077
1077
 
1078
1078
  [[package]]
1079
1079
  name = "tokenfold-proxy"
1080
- version = "0.3.1"
1080
+ version = "0.3.3"
1081
1081
  dependencies = [
1082
1082
  "clap",
1083
1083
  "serde",
@@ -1089,7 +1089,7 @@ dependencies = [
1089
1089
 
1090
1090
  [[package]]
1091
1091
  name = "tokenfold-py"
1092
- version = "0.3.1"
1092
+ version = "0.3.3"
1093
1093
  dependencies = [
1094
1094
  "pyo3",
1095
1095
  "serde_json",
@@ -1098,7 +1098,7 @@ dependencies = [
1098
1098
 
1099
1099
  [[package]]
1100
1100
  name = "tokenfold-rag"
1101
- version = "0.3.1"
1101
+ version = "0.3.3"
1102
1102
 
1103
1103
  [[package]]
1104
1104
  name = "toml"
@@ -3,7 +3,7 @@ resolver = "2"
3
3
  members = ["crates/tokenfold-core", "crates/tokenfold-py"]
4
4
 
5
5
  [workspace.package]
6
- version = "0.3.1"
6
+ version = "0.3.3"
7
7
  edition = "2024"
8
8
  rust-version = "1.97"
9
9
  license = "Apache-2.0"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tokenfold
3
- Version: 0.3.1
3
+ Version: 0.3.3
4
4
  Classifier: Programming Language :: Rust
5
5
  Classifier: Programming Language :: Python :: Implementation :: CPython
6
6
  Classifier: Programming Language :: Python :: 3 :: Only
@@ -10,7 +10,9 @@ Summary: Token-compression toolkit for LLM payloads (Python binding over the tok
10
10
  Requires-Python: >=3.9
11
11
  Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
12
12
 
13
- # tokenfold
13
+ <div align="center">
14
+
15
+ # TOKENFOLD
14
16
 
15
17
  **Send less noise. Fit more context. Pay for fewer input tokens.**
16
18
 
@@ -20,6 +22,8 @@ Local, provider-neutral compression for prompts, tool schemas, JSON, logs, and d
20
22
 
21
23
  [Quick start](#quick-start) · [Why tokenfold](#why-tokenfold) · [Integrations](#pick-your-integration) · [Benchmarks](#reproduce-the-numbers)
22
24
 
25
+ </div>
26
+
23
27
  ---
24
28
 
25
29
  ## Proven compression, not projections
@@ -45,8 +49,8 @@ cargo add tokenfold-core # Rust library
45
49
  cargo install tokenfold-cli # Rust CLI
46
50
  ```
47
51
 
48
- Or download the `v0.3.1` CLI for Linux, macOS, or Windows from
49
- [GitHub Releases](https://github.com/snchimata/tokenfold/releases/tag/v0.3.1),
52
+ Or download the CLI for Linux, macOS, or Windows from
53
+ [GitHub Releases](https://github.com/snchimata/tokenfold/releases/latest),
50
54
  then verify it with the adjacent `.sha256` file.
51
55
 
52
56
  Or install the Python package:
@@ -79,12 +83,21 @@ the canonical compression receipt:
79
83
  ```typescript
80
84
  import { compress } from "tokenfold";
81
85
 
86
+ const input = new TextEncoder().encode(JSON.stringify({
87
+ results: [
88
+ { id: 101, region: "us-east-1", plan: "pro" },
89
+ { id: 102, region: "us-east-1", plan: "pro" },
90
+ { id: 103, region: "us-east-1", plan: "pro" },
91
+ ],
92
+ }, null, 2));
93
+
82
94
  const { payload, report } = await compress(input, {
83
95
  format: "json",
84
96
  mode: "balanced",
85
97
  });
86
98
 
87
99
  console.log(`saved ${report.saved_tokens} tokens`);
100
+ console.log(new TextDecoder().decode(payload));
88
101
  ```
89
102
 
90
103
  Want to try the CLI from source? Inspect the bundled request without changing it:
@@ -96,10 +109,12 @@ cargo run --release --locked -p tokenfold-cli -- \
96
109
  inspect examples/openai_payload.json --format openai
97
110
  ```
98
111
 
112
+ Across 100 requests with the same payload shape, those savings add up to:
113
+
99
114
  ```text
100
- json_minify 346 → 229 saved 117
101
- schema_compaction 229 → 213 saved 16
102
- TOTAL 346 → 213 saved 133 (38.4% reduction, estimated)
115
+ json_minify 34,600 → 22,900 saved 11,700
116
+ schema_compaction 22,900 → 21,300 saved 1,600
117
+ TOTAL 34,600 → 21,300 saved 13,300 (38.4% reduction, estimated)
103
118
  ```
104
119
 
105
120
  ## Why tokenfold
@@ -229,16 +244,6 @@ cargo run --release --locked -p tokenfold-cli -- \
229
244
  The sample reports 382 → 206 estimated tokens, a **46.1% reduction**. Ragged
230
245
  or compact inputs may save little; Tokenfold reports that result honestly.
231
246
 
232
- ## Status
233
-
234
- Version **0.3.1** provides SHA-256-checksummed (not signed) CLI binaries for
235
- Linux, macOS, and Windows from [GitHub Releases](https://github.com/snchimata/tokenfold/releases/tag/v0.3.1),
236
- plus CycloneDX SBOMs. Registry packages are published to
237
- [PyPI](https://pypi.org/project/tokenfold/), [npm](https://www.npmjs.com/package/tokenfold),
238
- and [crates.io](https://crates.io/crates/tokenfold-core). npm installs the
239
- matching native binary package without a post-install download or local Rust
240
- build.
241
-
242
247
  ## Contributing
243
248
 
244
249
  Issues and pull requests are welcome. Run the core checks before opening a PR:
@@ -1,4 +1,6 @@
1
- # tokenfold
1
+ <div align="center">
2
+
3
+ # TOKENFOLD
2
4
 
3
5
  **Send less noise. Fit more context. Pay for fewer input tokens.**
4
6
 
@@ -8,6 +10,8 @@ Local, provider-neutral compression for prompts, tool schemas, JSON, logs, and d
8
10
 
9
11
  [Quick start](#quick-start) · [Why tokenfold](#why-tokenfold) · [Integrations](#pick-your-integration) · [Benchmarks](#reproduce-the-numbers)
10
12
 
13
+ </div>
14
+
11
15
  ---
12
16
 
13
17
  ## Proven compression, not projections
@@ -33,8 +37,8 @@ cargo add tokenfold-core # Rust library
33
37
  cargo install tokenfold-cli # Rust CLI
34
38
  ```
35
39
 
36
- Or download the `v0.3.1` CLI for Linux, macOS, or Windows from
37
- [GitHub Releases](https://github.com/snchimata/tokenfold/releases/tag/v0.3.1),
40
+ Or download the CLI for Linux, macOS, or Windows from
41
+ [GitHub Releases](https://github.com/snchimata/tokenfold/releases/latest),
38
42
  then verify it with the adjacent `.sha256` file.
39
43
 
40
44
  Or install the Python package:
@@ -67,12 +71,21 @@ the canonical compression receipt:
67
71
  ```typescript
68
72
  import { compress } from "tokenfold";
69
73
 
74
+ const input = new TextEncoder().encode(JSON.stringify({
75
+ results: [
76
+ { id: 101, region: "us-east-1", plan: "pro" },
77
+ { id: 102, region: "us-east-1", plan: "pro" },
78
+ { id: 103, region: "us-east-1", plan: "pro" },
79
+ ],
80
+ }, null, 2));
81
+
70
82
  const { payload, report } = await compress(input, {
71
83
  format: "json",
72
84
  mode: "balanced",
73
85
  });
74
86
 
75
87
  console.log(`saved ${report.saved_tokens} tokens`);
88
+ console.log(new TextDecoder().decode(payload));
76
89
  ```
77
90
 
78
91
  Want to try the CLI from source? Inspect the bundled request without changing it:
@@ -84,10 +97,12 @@ cargo run --release --locked -p tokenfold-cli -- \
84
97
  inspect examples/openai_payload.json --format openai
85
98
  ```
86
99
 
100
+ Across 100 requests with the same payload shape, those savings add up to:
101
+
87
102
  ```text
88
- json_minify 346 → 229 saved 117
89
- schema_compaction 229 → 213 saved 16
90
- TOTAL 346 → 213 saved 133 (38.4% reduction, estimated)
103
+ json_minify 34,600 → 22,900 saved 11,700
104
+ schema_compaction 22,900 → 21,300 saved 1,600
105
+ TOTAL 34,600 → 21,300 saved 13,300 (38.4% reduction, estimated)
91
106
  ```
92
107
 
93
108
  ## Why tokenfold
@@ -217,16 +232,6 @@ cargo run --release --locked -p tokenfold-cli -- \
217
232
  The sample reports 382 → 206 estimated tokens, a **46.1% reduction**. Ragged
218
233
  or compact inputs may save little; Tokenfold reports that result honestly.
219
234
 
220
- ## Status
221
-
222
- Version **0.3.1** provides SHA-256-checksummed (not signed) CLI binaries for
223
- Linux, macOS, and Windows from [GitHub Releases](https://github.com/snchimata/tokenfold/releases/tag/v0.3.1),
224
- plus CycloneDX SBOMs. Registry packages are published to
225
- [PyPI](https://pypi.org/project/tokenfold/), [npm](https://www.npmjs.com/package/tokenfold),
226
- and [crates.io](https://crates.io/crates/tokenfold-core). npm installs the
227
- matching native binary package without a post-install download or local Rust
228
- build.
229
-
230
235
  ## Contributing
231
236
 
232
237
  Issues and pull requests are welcome. Run the core checks before opening a PR:
@@ -15,6 +15,7 @@ pub enum TransformId {
15
15
  JsonFieldFold,
16
16
  JsonValueDict,
17
17
  SchemaCompaction,
18
+ LogFieldFold,
18
19
  LogCompaction,
19
20
  DiffCompaction,
20
21
  }
@@ -26,6 +27,7 @@ impl TransformId {
26
27
  TransformId::JsonFieldFold => "json_field_fold",
27
28
  TransformId::JsonValueDict => "json_value_dict",
28
29
  TransformId::SchemaCompaction => "schema_compaction",
30
+ TransformId::LogFieldFold => "log_field_fold",
29
31
  TransformId::LogCompaction => "log_compaction",
30
32
  TransformId::DiffCompaction => "diff_compaction",
31
33
  }
@@ -140,6 +142,25 @@ pub static ALL_ENTRIES: &[ModeEntry] = &[
140
142
  task_scopes: &[TaskScope::All],
141
143
  applicable_formats: &[InputFormat::OpenAiJson, InputFormat::AnthropicJson],
142
144
  },
145
+ // log_field_fold (v0.4): reversible columnar fold of TEMPLATED log lines — the log-line
146
+ // analogue of json_field_fold (emit each shared line skeleton once + per-line captured fields).
147
+ // Lossless (round-trip gated in the pipeline), so max_ratio is unrestricted (1.0), but like
148
+ // log_compaction it restructures what the model sees, so it stays out of Conservative and ships
149
+ // behind --experimental until its fidelity gate is green (the same path json_field_fold and
150
+ // log_compaction took). Runs before the lossy log_compaction (lossless-before-lossy ordering).
151
+ ModeEntry {
152
+ transform_id: TransformId::LogFieldFold,
153
+ version: "1.0.0",
154
+ conservative_enabled: false,
155
+ balanced_enabled: false,
156
+ aggressive_enabled: false,
157
+ experimental: true,
158
+ max_ratio_conservative: 0.0,
159
+ max_ratio_balanced: 1.0,
160
+ max_ratio_aggressive: 1.0,
161
+ task_scopes: &[TaskScope::All],
162
+ applicable_formats: &[InputFormat::PlainText, InputFormat::CommandOutput],
163
+ },
143
164
  ModeEntry {
144
165
  transform_id: TransformId::LogCompaction,
145
166
  version: "1.0.0",
@@ -366,6 +366,10 @@ fn apply_single_transform(
366
366
  // not that Phase 2 ship distinct values per mode).
367
367
  transforms::schema::compact_schema(bytes, 1).map_err(|e| e.to_string())
368
368
  }
369
+ TransformId::LogFieldFold => {
370
+ let text = std::str::from_utf8(bytes).map_err(|e| e.to_string())?;
371
+ Ok(transforms::log_fold::fold_log(text).into_bytes())
372
+ }
369
373
  TransformId::LogCompaction => {
370
374
  let text = std::str::from_utf8(bytes).map_err(|e| e.to_string())?;
371
375
  Ok(transforms::logs::compact(text, false).into_bytes())
@@ -423,6 +427,13 @@ fn validate_safety(
423
427
  }
424
428
  }
425
429
  }
430
+ // log_field_fold restructures templated log lines into a columnar form, so its safety
431
+ // invariant is exact reversibility: unfolding the output must reproduce the input bytes.
432
+ TransformId::LogFieldFold => {
433
+ if !transforms::log_fold::round_trips(before, after) {
434
+ return false;
435
+ }
436
+ }
426
437
  TransformId::LogCompaction | TransformId::DiffCompaction => {}
427
438
  }
428
439
  safety::protected_segments_present(protected, after)
@@ -16,6 +16,10 @@ pub struct CompressionReport {
16
16
  pub format: String,
17
17
  pub task_scope: String,
18
18
  pub request_id: Option<String>,
19
+ /// F-055: staged `raw -> RTK -> tokenfold` accounting. `None` for the common
20
+ /// single-stage path; populated only by RTK-composed `wrap --rtk` runs.
21
+ #[serde(default)]
22
+ pub pipeline: Option<PipelineReport>,
19
23
  pub quality: Option<QualityReport>,
20
24
  pub budget: Option<BudgetReport>,
21
25
  pub cache: Option<CacheReport>,
@@ -61,6 +65,7 @@ impl CompressionReport {
61
65
  format,
62
66
  task_scope,
63
67
  request_id: None,
68
+ pipeline: None,
64
69
  quality: None,
65
70
  budget: None,
66
71
  cache: None,
@@ -82,6 +87,53 @@ pub struct EstimatorInfo {
82
87
  pub is_exact: bool,
83
88
  }
84
89
 
90
+ /// F-055: separates savings and recoverability across composed stages (RTK then
91
+ /// tokenfold) so RTK's savings are never credited to tokenfold. The top-level
92
+ /// `original_tokens` keeps its v1 meaning — tokens *entering* `tokenfold_core`,
93
+ /// which is the post-RTK count when composed. See INTERFACES.md §1.9 / §2.
94
+ #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
95
+ pub struct PipelineReport {
96
+ /// Pre-RTK byte count. `Some` only when a complete raw capture was observed.
97
+ pub raw_input_bytes: Option<usize>,
98
+ /// Pre-RTK token count. `Some` only when raw capture is complete.
99
+ pub raw_input_tokens: Option<usize>,
100
+ pub final_output_bytes: usize,
101
+ /// Equals top-level `compressed_tokens`.
102
+ pub final_output_tokens: usize,
103
+ /// Populated only when raw and final counts use the same estimator.
104
+ pub total_saved_tokens: Option<usize>,
105
+ /// `"complete"`, `"partial"`, `"unavailable"`, or `"not_applicable"`.
106
+ pub raw_capture: String,
107
+ /// `"full"`, `"tokenfold_only"`, `"none"`, or `"not_applicable"`.
108
+ pub upstream_recoverability: String,
109
+ pub stages: Vec<PipelineStageReport>,
110
+ }
111
+
112
+ /// F-055: one composed stage. Count fields are nullable because an unavailable
113
+ /// stage or missing pre-stage capture cannot be measured honestly.
114
+ #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
115
+ pub struct PipelineStageReport {
116
+ /// `"rtk"` or `"tokenfold"`.
117
+ pub id: String,
118
+ pub version: Option<String>,
119
+ pub input_bytes: Option<usize>,
120
+ pub output_bytes: Option<usize>,
121
+ pub saved_bytes: Option<usize>,
122
+ pub input_tokens: Option<usize>,
123
+ pub output_tokens: Option<usize>,
124
+ pub saved_tokens: Option<usize>,
125
+ pub estimator: Option<EstimatorInfo>,
126
+ /// `"applied"`, `"passthrough"`, `"unavailable"`, `"incompatible"`, or `"failed"`.
127
+ pub status: String,
128
+ pub duration_ms: Option<f64>,
129
+ pub bypass_reason: Option<String>,
130
+ /// e.g. `"external:rtk@0.4.1"` or `"tokenfold_core"`.
131
+ pub provenance: String,
132
+ /// `"full"`, `"partial"`, `"none"`, or `"not_applicable"`.
133
+ pub recoverability: String,
134
+ pub evidence_ref: Option<String>,
135
+ }
136
+
85
137
  #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
86
138
  pub struct BudgetReport {
87
139
  pub target_tokens: Option<usize>,
@@ -0,0 +1,296 @@
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.
28
+ pub const TRANSFORM_ID: &str = "log_field_fold";
29
+
30
+ /// Semantic version of this transform's output behavior.
31
+ pub 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.
35
+ const HEADER: &str = "__tf_logfold1__";
36
+
37
+ /// Placeholder standing in for a captured variable token inside a template.
38
+ const 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.
43
+ const MIN_LINES: usize = 3;
44
+
45
+ use regex::Regex;
46
+ use 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`.
50
+ fn 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.
58
+ fn 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.
77
+ pub 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).
124
+ pub fn unfold_log(input: &str) -> String {
125
+ match try_unfold(input) {
126
+ Some(s) => s,
127
+ None => input.to_string(),
128
+ }
129
+ }
130
+
131
+ fn 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.
160
+ pub 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)]
169
+ mod 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
+ }
@@ -6,6 +6,7 @@ pub mod diff;
6
6
  pub mod json;
7
7
  pub mod json_dict;
8
8
  pub mod json_fold;
9
+ pub mod log_fold;
9
10
  pub mod logs;
10
11
  pub mod redaction;
11
12
  pub mod schema;
@@ -16,7 +16,7 @@ name = "tokenfold_py"
16
16
  crate-type = ["cdylib", "rlib"]
17
17
 
18
18
  [dependencies]
19
- tokenfold-core = { version = "0.3.1", path = "../tokenfold-core" }
19
+ tokenfold-core = { version = "0.3.3", path = "../tokenfold-core" }
20
20
  serde_json = { workspace = true }
21
21
  pyo3 = { version = "0.29.0", features = ["abi3-py39"] }
22
22
 
File without changes
File without changes