tokenfold 0.1.0__tar.gz → 0.2.0__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 (35) hide show
  1. {tokenfold-0.1.0 → tokenfold-0.2.0}/Cargo.lock +10 -10
  2. {tokenfold-0.1.0 → tokenfold-0.2.0}/Cargo.toml +1 -1
  3. {tokenfold-0.1.0 → tokenfold-0.2.0}/PKG-INFO +32 -9
  4. {tokenfold-0.1.0 → tokenfold-0.2.0}/README.md +31 -8
  5. {tokenfold-0.1.0 → tokenfold-0.2.0}/crates/tokenfold-core/examples/quickstart.rs +38 -16
  6. {tokenfold-0.1.0 → tokenfold-0.2.0}/crates/tokenfold-core/src/budget.rs +6 -1
  7. {tokenfold-0.1.0 → tokenfold-0.2.0}/crates/tokenfold-core/src/input.rs +12 -0
  8. {tokenfold-0.1.0 → tokenfold-0.2.0}/crates/tokenfold-core/src/modes.rs +43 -1
  9. {tokenfold-0.1.0 → tokenfold-0.2.0}/crates/tokenfold-core/src/pipeline.rs +73 -10
  10. tokenfold-0.2.0/crates/tokenfold-core/src/transforms/json_dict.rs +289 -0
  11. tokenfold-0.2.0/crates/tokenfold-core/src/transforms/json_fold.rs +308 -0
  12. {tokenfold-0.1.0 → tokenfold-0.2.0}/crates/tokenfold-core/src/transforms/mod.rs +2 -0
  13. {tokenfold-0.1.0 → tokenfold-0.2.0}/crates/tokenfold-core/tests/integration.rs +1 -0
  14. {tokenfold-0.1.0 → tokenfold-0.2.0}/crates/tokenfold-py/src/lib.rs +4 -0
  15. {tokenfold-0.1.0 → tokenfold-0.2.0}/crates/tokenfold-core/Cargo.toml +0 -0
  16. {tokenfold-0.1.0 → tokenfold-0.2.0}/crates/tokenfold-core/benches/THRESHOLDS.toml +0 -0
  17. {tokenfold-0.1.0 → tokenfold-0.2.0}/crates/tokenfold-core/benches/compression_bench.rs +0 -0
  18. {tokenfold-0.1.0 → tokenfold-0.2.0}/crates/tokenfold-core/src/errors.rs +0 -0
  19. {tokenfold-0.1.0 → tokenfold-0.2.0}/crates/tokenfold-core/src/filters.rs +0 -0
  20. {tokenfold-0.1.0 → tokenfold-0.2.0}/crates/tokenfold-core/src/lib.rs +0 -0
  21. {tokenfold-0.1.0 → tokenfold-0.2.0}/crates/tokenfold-core/src/report.rs +0 -0
  22. {tokenfold-0.1.0 → tokenfold-0.2.0}/crates/tokenfold-core/src/retrieval_store.rs +0 -0
  23. {tokenfold-0.1.0 → tokenfold-0.2.0}/crates/tokenfold-core/src/safety.rs +0 -0
  24. {tokenfold-0.1.0 → tokenfold-0.2.0}/crates/tokenfold-core/src/stats.rs +0 -0
  25. {tokenfold-0.1.0 → tokenfold-0.2.0}/crates/tokenfold-core/src/status.rs +0 -0
  26. {tokenfold-0.1.0 → tokenfold-0.2.0}/crates/tokenfold-core/src/token_estimator.rs +0 -0
  27. {tokenfold-0.1.0 → tokenfold-0.2.0}/crates/tokenfold-core/src/transforms/diff.rs +0 -0
  28. {tokenfold-0.1.0 → tokenfold-0.2.0}/crates/tokenfold-core/src/transforms/json.rs +0 -0
  29. {tokenfold-0.1.0 → tokenfold-0.2.0}/crates/tokenfold-core/src/transforms/logs.rs +0 -0
  30. {tokenfold-0.1.0 → tokenfold-0.2.0}/crates/tokenfold-core/src/transforms/redaction.rs +0 -0
  31. {tokenfold-0.1.0 → tokenfold-0.2.0}/crates/tokenfold-core/src/transforms/schema.rs +0 -0
  32. {tokenfold-0.1.0 → tokenfold-0.2.0}/crates/tokenfold-core/tests/golden.rs +0 -0
  33. {tokenfold-0.1.0 → tokenfold-0.2.0}/crates/tokenfold-core/tests/property_tests.rs +0 -0
  34. {tokenfold-0.1.0 → tokenfold-0.2.0}/crates/tokenfold-py/Cargo.toml +0 -0
  35. {tokenfold-0.1.0 → tokenfold-0.2.0}/pyproject.toml +0 -0
@@ -1013,7 +1013,7 @@ dependencies = [
1013
1013
 
1014
1014
  [[package]]
1015
1015
  name = "tokenfold-adapters"
1016
- version = "0.1.0"
1016
+ version = "0.2.0"
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.1.0"
1025
+ version = "0.2.0"
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.1.0"
1035
+ version = "0.2.0"
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.1.0"
1048
+ version = "0.2.0"
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.1.0"
1062
+ version = "0.2.0"
1063
1063
 
1064
1064
  [[package]]
1065
1065
  name = "tokenfold-learn"
1066
- version = "0.1.0"
1066
+ version = "0.2.0"
1067
1067
  dependencies = [
1068
1068
  "tokenfold-core",
1069
1069
  ]
1070
1070
 
1071
1071
  [[package]]
1072
1072
  name = "tokenfold-output"
1073
- version = "0.1.0"
1073
+ version = "0.2.0"
1074
1074
  dependencies = [
1075
1075
  "tokenfold-core",
1076
1076
  ]
1077
1077
 
1078
1078
  [[package]]
1079
1079
  name = "tokenfold-proxy"
1080
- version = "0.1.0"
1080
+ version = "0.2.0"
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.1.0"
1092
+ version = "0.2.0"
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.1.0"
1101
+ version = "0.2.0"
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.1.0"
6
+ version = "0.2.0"
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.1.0
3
+ Version: 0.2.0
4
4
  Classifier: Programming Language :: Rust
5
5
  Classifier: Programming Language :: Python :: Implementation :: CPython
6
6
  Classifier: Programming Language :: Python :: 3 :: Only
@@ -48,7 +48,8 @@ exactly what changed. Preview every transformation before applying it.
48
48
 
49
49
  ## Quick start
50
50
 
51
- tokenfold is currently source-only. Build the CLI with the pinned Rust toolchain:
51
+ The library and Python package are published `cargo add tokenfold-core` and
52
+ `pip install tokenfold`. To use the CLI, build it from source with the pinned Rust toolchain:
52
53
 
53
54
  ```bash
54
55
  git clone https://github.com/snchimata/tokenfold.git
@@ -81,6 +82,15 @@ target/release/tokenfold compress examples/openai_payload.json \
81
82
  --target-tokens 250 > compressed.json
82
83
  ```
83
84
 
85
+ Compress generic JSON **data** (API responses, records, logs — not just message payloads).
86
+ tokenfold folds arrays of same-shape objects into columnar form and dictionaries repeated
87
+ values, all losslessly:
88
+
89
+ ```bash
90
+ target/release/tokenfold inspect examples/api_response.json --format json
91
+ # larger, more repetitive data compresses further — a 50-record blob folds 0% → ~68%
92
+ ```
93
+
84
94
  Or trim noisy output from any command:
85
95
 
86
96
  ```bash
@@ -114,8 +124,14 @@ target/release/tokenfold wrap -- git diff
114
124
 
115
125
  ### Python
116
126
 
117
- The Python package is a native `pyo3` binding for CPython 3.9+. Until wheels are published,
118
- install it locally with [maturin](https://www.maturin.rs/):
127
+ The Python package is a native `pyo3` binding for CPython 3.9+:
128
+
129
+ ```bash
130
+ pip install tokenfold
131
+ ```
132
+
133
+ A Windows wheel is published; on Linux/macOS pip builds the sdist from source (needs a Rust
134
+ toolchain). To build from a checkout instead, use [maturin](https://www.maturin.rs/):
119
135
 
120
136
  ```bash
121
137
  python -m venv .venv
@@ -177,18 +193,25 @@ input ──▶ redact secrets ──▶ compact ──▶ recount ──
177
193
  | `secret_redaction` | Always on | Redacts secret-shaped values before reporting, logging, or persistence |
178
194
  | `json_minify` | Default | Removes insignificant JSON whitespace |
179
195
  | `schema_compaction` | Default | Shortens examples while preserving descriptions and required fields |
196
+ | `json_field_fold` | Balanced / aggressive | Folds arrays of same-shape objects into columnar `{cols, rows}` — each key once, not once per row (generic JSON data) |
197
+ | `json_value_dict` | Balanced / aggressive | Replaces repeated large values with dictionary references (generic JSON data) |
180
198
  | `log_compaction` | Balanced / aggressive | Collapses repeated, low-signal log lines |
181
199
  | `diff_compaction` | Experimental | Reduces low-signal diff context |
182
200
 
183
- Lossy transforms must clear a downstream fidelity gate before promotion. Originals can be
184
- stored by SHA-256 hash when reversibility is requested; secret-shaped content is never
185
- persisted.
201
+ `json_field_fold` and `json_value_dict` are **lossless and reversible**: each is applied only
202
+ if it passes an exact round-trip check (`unfold(fold(x)) == x`) *and* lowers the exact token
203
+ count, so a payload can never come out larger or altered. Lossy transforms must clear a
204
+ downstream fidelity gate before promotion. Originals can also be stored by SHA-256 hash when
205
+ reversibility is requested; secret-shaped content is never persisted.
186
206
 
187
207
  ## Measured, not marketed
188
208
 
189
209
  The structured-JSON benchmark measures **45.63% exact token savings** in balanced mode on a
190
- deterministic, pretty-printed OpenAI tool-schema fixture. It is a reproducible benchmark,
191
- not a promise for every workload.
210
+ deterministic, pretty-printed OpenAI tool-schema fixture. On generic JSON **data**, the v0.2
211
+ columnar fold + value dictionary reach **~61–68% exact savings** on repetitive records
212
+ (30–50 rows), losslessly. All numbers are quoted against exact `o200k_base` counts and are
213
+ reproducible benchmarks, not a promise for every workload — ragged or already-compact JSON
214
+ correctly reports single-digit or no savings rather than pretending.
192
215
 
193
216
  ```bash
194
217
  cargo bench -p tokenfold-core
@@ -36,7 +36,8 @@ exactly what changed. Preview every transformation before applying it.
36
36
 
37
37
  ## Quick start
38
38
 
39
- tokenfold is currently source-only. Build the CLI with the pinned Rust toolchain:
39
+ The library and Python package are published `cargo add tokenfold-core` and
40
+ `pip install tokenfold`. To use the CLI, build it from source with the pinned Rust toolchain:
40
41
 
41
42
  ```bash
42
43
  git clone https://github.com/snchimata/tokenfold.git
@@ -69,6 +70,15 @@ target/release/tokenfold compress examples/openai_payload.json \
69
70
  --target-tokens 250 > compressed.json
70
71
  ```
71
72
 
73
+ Compress generic JSON **data** (API responses, records, logs — not just message payloads).
74
+ tokenfold folds arrays of same-shape objects into columnar form and dictionaries repeated
75
+ values, all losslessly:
76
+
77
+ ```bash
78
+ target/release/tokenfold inspect examples/api_response.json --format json
79
+ # larger, more repetitive data compresses further — a 50-record blob folds 0% → ~68%
80
+ ```
81
+
72
82
  Or trim noisy output from any command:
73
83
 
74
84
  ```bash
@@ -102,8 +112,14 @@ target/release/tokenfold wrap -- git diff
102
112
 
103
113
  ### Python
104
114
 
105
- The Python package is a native `pyo3` binding for CPython 3.9+. Until wheels are published,
106
- install it locally with [maturin](https://www.maturin.rs/):
115
+ The Python package is a native `pyo3` binding for CPython 3.9+:
116
+
117
+ ```bash
118
+ pip install tokenfold
119
+ ```
120
+
121
+ A Windows wheel is published; on Linux/macOS pip builds the sdist from source (needs a Rust
122
+ toolchain). To build from a checkout instead, use [maturin](https://www.maturin.rs/):
107
123
 
108
124
  ```bash
109
125
  python -m venv .venv
@@ -165,18 +181,25 @@ input ──▶ redact secrets ──▶ compact ──▶ recount ──
165
181
  | `secret_redaction` | Always on | Redacts secret-shaped values before reporting, logging, or persistence |
166
182
  | `json_minify` | Default | Removes insignificant JSON whitespace |
167
183
  | `schema_compaction` | Default | Shortens examples while preserving descriptions and required fields |
184
+ | `json_field_fold` | Balanced / aggressive | Folds arrays of same-shape objects into columnar `{cols, rows}` — each key once, not once per row (generic JSON data) |
185
+ | `json_value_dict` | Balanced / aggressive | Replaces repeated large values with dictionary references (generic JSON data) |
168
186
  | `log_compaction` | Balanced / aggressive | Collapses repeated, low-signal log lines |
169
187
  | `diff_compaction` | Experimental | Reduces low-signal diff context |
170
188
 
171
- Lossy transforms must clear a downstream fidelity gate before promotion. Originals can be
172
- stored by SHA-256 hash when reversibility is requested; secret-shaped content is never
173
- persisted.
189
+ `json_field_fold` and `json_value_dict` are **lossless and reversible**: each is applied only
190
+ if it passes an exact round-trip check (`unfold(fold(x)) == x`) *and* lowers the exact token
191
+ count, so a payload can never come out larger or altered. Lossy transforms must clear a
192
+ downstream fidelity gate before promotion. Originals can also be stored by SHA-256 hash when
193
+ reversibility is requested; secret-shaped content is never persisted.
174
194
 
175
195
  ## Measured, not marketed
176
196
 
177
197
  The structured-JSON benchmark measures **45.63% exact token savings** in balanced mode on a
178
- deterministic, pretty-printed OpenAI tool-schema fixture. It is a reproducible benchmark,
179
- not a promise for every workload.
198
+ deterministic, pretty-printed OpenAI tool-schema fixture. On generic JSON **data**, the v0.2
199
+ columnar fold + value dictionary reach **~61–68% exact savings** on repetitive records
200
+ (30–50 rows), losslessly. All numbers are quoted against exact `o200k_base` counts and are
201
+ reproducible benchmarks, not a promise for every workload — ragged or already-compact JSON
202
+ correctly reports single-digit or no savings rather than pretending.
180
203
 
181
204
  ```bash
182
205
  cargo bench -p tokenfold-core
@@ -12,12 +12,21 @@
12
12
 
13
13
  use tokenfold_core::{CompressionInput, CompressionMode, CompressionPolicy, compress};
14
14
 
15
- // The payload ships with the repo (examples/openai_payload.json). Embedding it with
16
- // include_str! keeps this example runnable from any working directory.
15
+ // The payloads ship with the repo. Embedding them with include_str! keeps this example
16
+ // runnable from any working directory.
17
17
  const PAYLOAD: &str = include_str!("../../../examples/openai_payload.json");
18
+ const API_RESPONSE: &str = include_str!("../../../examples/api_response.json");
18
19
 
19
20
  fn main() -> Result<(), Box<dyn std::error::Error>> {
20
- // 1. Wrap the raw request bytes, telling tokenfold it's an OpenAI-style JSON body.
21
+ compress_openai_body()?;
22
+ println!("\n{}\n", "-".repeat(72));
23
+ compress_json_data()?;
24
+ Ok(())
25
+ }
26
+
27
+ // --- 1. An LLM request body (chat messages + a verbose tool schema) -----------------------
28
+ fn compress_openai_body() -> Result<(), Box<dyn std::error::Error>> {
29
+ // Wrap the raw request bytes, telling tokenfold it's an OpenAI-style JSON body.
21
30
  let input = CompressionInput::openai_json(PAYLOAD.as_bytes().to_vec());
22
31
 
23
32
  // 2. Pick a policy. Balanced is the safe default; JSON minification and schema
@@ -30,6 +39,30 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
30
39
  let out = compress(input, &policy)?;
31
40
  let report = &out.report;
32
41
 
42
+ println!("== OpenAI request body ==");
43
+ print_report(report);
44
+ Ok(())
45
+ }
46
+
47
+ // --- 2. Generic JSON data (API response, records, logs) -----------------------------------
48
+ // This is the v0.2 path: data-JSON that isn't an LLM message payload. tokenfold minifies it,
49
+ // folds arrays of same-shape objects into columnar form (each key once, not once per row),
50
+ // and dictionaries repeated values — all losslessly (every stage is round-trip gated).
51
+ fn compress_json_data() -> Result<(), Box<dyn std::error::Error>> {
52
+ let input = CompressionInput::json(API_RESPONSE.as_bytes().to_vec());
53
+ let policy = CompressionPolicy::builder()
54
+ .mode(CompressionMode::Balanced)
55
+ .build()?;
56
+ let out = compress(input, &policy)?;
57
+
58
+ println!("== Generic JSON data ==");
59
+ print_report(&out.report);
60
+ println!("\ncompressed payload ({} bytes):", out.bytes.len());
61
+ println!("{}", String::from_utf8_lossy(&out.bytes));
62
+ Ok(())
63
+ }
64
+
65
+ fn print_report(report: &tokenfold_core::report::CompressionReport) {
33
66
  println!("status: {:?}", report.status);
34
67
  println!(
35
68
  "tokens: {} -> {} ({} saved, {:.1}%)",
@@ -39,23 +72,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
39
72
  "estimator: {} (exact: {})",
40
73
  report.estimator.backend, report.estimator.is_exact,
41
74
  );
42
-
43
- // 4. The report itemizes what each transform did — receipts, not guesses.
44
- println!("\ntransforms:");
75
+ // The report itemizes what each transform did — receipts, not guesses.
76
+ println!("transforms:");
45
77
  for t in &report.transforms {
46
78
  println!(
47
79
  " - {:<24} {:<10?} (saved {} tokens)",
48
80
  t.id, t.status, t.saved_tokens,
49
81
  );
50
82
  }
51
-
52
- for w in &report.warnings {
53
- println!(" warning: {}", w.message);
54
- }
55
-
56
- // 5. out.bytes is the compressed payload, ready to send to any provider.
57
- println!("\ncompressed payload ({} bytes):", out.bytes.len());
58
- println!("{}", String::from_utf8_lossy(&out.bytes));
59
-
60
- Ok(())
61
83
  }
@@ -220,7 +220,12 @@ pub fn protected_segments(input: &CompressionInput, policy: &CompressionPolicy)
220
220
  InputFormat::GitDiff => extract_diff_protected(&input.bytes),
221
221
  // ponytail: no transform touches plain text/command output structure yet beyond
222
222
  // log/diff compaction (task-scope gated), so nothing is unconditionally protected.
223
- InputFormat::PlainText | InputFormat::CommandOutput | InputFormat::Auto => Vec::new(),
223
+ // Generic Json has no "protected" sub-segment either — json_field_fold's own
224
+ // round-trip safety gate is what guarantees its data is preserved.
225
+ InputFormat::PlainText
226
+ | InputFormat::CommandOutput
227
+ | InputFormat::Json
228
+ | InputFormat::Auto => Vec::new(),
224
229
  }
225
230
  }
226
231
 
@@ -8,6 +8,9 @@ pub enum InputFormat {
8
8
  Auto,
9
9
  OpenAiJson,
10
10
  AnthropicJson,
11
+ /// Generic JSON data (API responses, records, logs) that isn't an LLM message payload.
12
+ /// Unlocks the JSON-data transforms (`json_minify`, `json_field_fold`).
13
+ Json,
11
14
  PlainText,
12
15
  CommandOutput,
13
16
  GitDiff,
@@ -34,6 +37,15 @@ impl CompressionInput {
34
37
  }
35
38
  }
36
39
 
40
+ /// Generic JSON data (not an LLM message payload). Enables `json_minify`,
41
+ /// `json_field_fold`, and `json_value_dict`.
42
+ pub fn json(bytes: impl Into<Vec<u8>>) -> Self {
43
+ Self {
44
+ format: InputFormat::Json,
45
+ bytes: bytes.into(),
46
+ }
47
+ }
48
+
37
49
  pub fn plain_text(bytes: impl Into<Vec<u8>>) -> Self {
38
50
  Self {
39
51
  format: InputFormat::PlainText,
@@ -12,6 +12,8 @@ use crate::input::InputFormat;
12
12
  #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
13
13
  pub enum TransformId {
14
14
  JsonMinify,
15
+ JsonFieldFold,
16
+ JsonValueDict,
15
17
  SchemaCompaction,
16
18
  LogCompaction,
17
19
  DiffCompaction,
@@ -21,6 +23,8 @@ impl TransformId {
21
23
  pub fn as_str(&self) -> &'static str {
22
24
  match self {
23
25
  TransformId::JsonMinify => "json_minify",
26
+ TransformId::JsonFieldFold => "json_field_fold",
27
+ TransformId::JsonValueDict => "json_value_dict",
24
28
  TransformId::SchemaCompaction => "schema_compaction",
25
29
  TransformId::LogCompaction => "log_compaction",
26
30
  TransformId::DiffCompaction => "diff_compaction",
@@ -83,7 +87,45 @@ pub static ALL_ENTRIES: &[ModeEntry] = &[
83
87
  max_ratio_balanced: 1.0,
84
88
  max_ratio_aggressive: 1.0,
85
89
  task_scopes: &[TaskScope::All],
86
- applicable_formats: &[InputFormat::OpenAiJson, InputFormat::AnthropicJson],
90
+ applicable_formats: &[
91
+ InputFormat::OpenAiJson,
92
+ InputFormat::AnthropicJson,
93
+ InputFormat::Json,
94
+ ],
95
+ },
96
+ // json_field_fold (v0.2): reversible columnar fold of arrays of homogeneous objects.
97
+ // Lossless (round-trip gated in the pipeline), so max_ratio is unrestricted (1.0), but
98
+ // it restructures what the model sees, so it stays out of Conservative (same convention
99
+ // as log_compaction) and only runs on generic Json data, never on OpenAI/Anthropic
100
+ // message bodies (whose API shape must not change).
101
+ ModeEntry {
102
+ transform_id: TransformId::JsonFieldFold,
103
+ version: "1.0.0",
104
+ conservative_enabled: false,
105
+ balanced_enabled: true,
106
+ aggressive_enabled: true,
107
+ experimental: false,
108
+ max_ratio_conservative: 0.0,
109
+ max_ratio_balanced: 1.0,
110
+ max_ratio_aggressive: 1.0,
111
+ task_scopes: &[TaskScope::All],
112
+ applicable_formats: &[InputFormat::Json],
113
+ },
114
+ // json_value_dict (v0.2): reversible value deduplication. Runs AFTER json_field_fold so it
115
+ // also collapses the repeated nested values folding surfaces across rows. Lossless
116
+ // (round-trip gated), unrestricted ratio, out of Conservative, generic Json only.
117
+ ModeEntry {
118
+ transform_id: TransformId::JsonValueDict,
119
+ version: "1.0.0",
120
+ conservative_enabled: false,
121
+ balanced_enabled: true,
122
+ aggressive_enabled: true,
123
+ experimental: false,
124
+ max_ratio_conservative: 0.0,
125
+ max_ratio_balanced: 1.0,
126
+ max_ratio_aggressive: 1.0,
127
+ task_scopes: &[TaskScope::All],
128
+ applicable_formats: &[InputFormat::Json],
87
129
  },
88
130
  ModeEntry {
89
131
  transform_id: TransformId::SchemaCompaction,
@@ -354,6 +354,12 @@ fn apply_single_transform(
354
354
  ) -> Result<Vec<u8>, String> {
355
355
  match transform_id {
356
356
  TransformId::JsonMinify => transforms::json::minify_json(bytes).map_err(|e| e.to_string()),
357
+ TransformId::JsonFieldFold => {
358
+ transforms::json_fold::fold_json(bytes).map_err(|e| e.to_string())
359
+ }
360
+ TransformId::JsonValueDict => {
361
+ transforms::json_dict::dict_json(bytes).map_err(|e| e.to_string())
362
+ }
357
363
  TransformId::SchemaCompaction => {
358
364
  // ponytail: a fixed example cap for now; per-mode example counts are a future
359
365
  // config knob (F-011 acceptance criteria only requires the count be configurable,
@@ -379,18 +385,45 @@ fn validate_safety(
379
385
  after: &[u8],
380
386
  protected: &[Vec<u8>],
381
387
  ) -> bool {
382
- let is_json_format = matches!(format, InputFormat::OpenAiJson | InputFormat::AnthropicJson);
383
- let is_json_transform = matches!(
384
- transform_id,
385
- TransformId::JsonMinify | TransformId::SchemaCompaction
386
- );
387
- if is_json_format && is_json_transform {
388
- if !safety::json_still_valid(after) {
389
- return false;
388
+ match transform_id {
389
+ // json_field_fold intentionally restructures JSON (arrays of objects -> columnar
390
+ // form), so key-order preservation does NOT apply. Its safety invariant is instead
391
+ // exact reversibility: unfolding the output must reproduce the input's data.
392
+ TransformId::JsonFieldFold => {
393
+ if !safety::json_still_valid(after) {
394
+ return false;
395
+ }
396
+ if !transforms::json_fold::round_trips(before, after) {
397
+ return false;
398
+ }
399
+ }
400
+ // json_value_dict replaces repeated values with dictionary references — also a
401
+ // reversible restructure, gated on exact round-trip reconstruction.
402
+ TransformId::JsonValueDict => {
403
+ if !safety::json_still_valid(after) {
404
+ return false;
405
+ }
406
+ if !transforms::json_dict::round_trips(before, after) {
407
+ return false;
408
+ }
390
409
  }
391
- if !safety::json_key_order_preserved(before, after) {
392
- return false;
410
+ // json_minify / schema_compaction on any JSON-family format: output must stay valid
411
+ // JSON with byte-for-byte key order preserved.
412
+ TransformId::JsonMinify | TransformId::SchemaCompaction => {
413
+ let is_json_format = matches!(
414
+ format,
415
+ InputFormat::OpenAiJson | InputFormat::AnthropicJson | InputFormat::Json
416
+ );
417
+ if is_json_format {
418
+ if !safety::json_still_valid(after) {
419
+ return false;
420
+ }
421
+ if !safety::json_key_order_preserved(before, after) {
422
+ return false;
423
+ }
424
+ }
393
425
  }
426
+ TransformId::LogCompaction | TransformId::DiffCompaction => {}
394
427
  }
395
428
  safety::protected_segments_present(protected, after)
396
429
  }
@@ -470,6 +503,7 @@ fn format_label(format: InputFormat) -> &'static str {
470
503
  InputFormat::Auto => "auto",
471
504
  InputFormat::OpenAiJson => "openai_json",
472
505
  InputFormat::AnthropicJson => "anthropic_json",
506
+ InputFormat::Json => "json",
473
507
  InputFormat::PlainText => "plain_text",
474
508
  InputFormat::CommandOutput => "command_output",
475
509
  InputFormat::GitDiff => "git_diff",
@@ -588,6 +622,35 @@ mod tests {
588
622
  assert!(serde_json::from_slice::<serde_json::Value>(&output.bytes).is_ok());
589
623
  }
590
624
 
625
+ #[test]
626
+ fn json_data_transforms_never_regress_token_count() {
627
+ // The "exact-token chooser" property: each JSON-data stage (minify -> fold -> dict) is
628
+ // adopted only if it lowers the exact token count, so no input can come out larger —
629
+ // including the shapes that sink naive TOON/CSV-ization: ragged, scalar, already-compact.
630
+ let cases: &[&[u8]] = &[
631
+ br#"[{"a":1,"b":2},{"a":3,"b":4},{"a":5,"b":6}]"#, // foldable
632
+ br#"[{"a":1},{"b":2},{"c":3}]"#, // ragged, heterogeneous
633
+ br#"{"x":[1,2,3],"y":"already compact scalar"}"#, // no repeated structure
634
+ br#"[1,2,3,4,5,6,7,8,9,10]"#, // scalars only
635
+ br#"{}"#, // trivial
636
+ ];
637
+ for case in cases {
638
+ let input = CompressionInput::json(case.to_vec());
639
+ let policy = CompressionPolicy::builder().build().unwrap();
640
+ let output = compress_with_estimator(input, &policy, &ByteHeuristicEstimator).unwrap();
641
+ assert!(
642
+ output.report.compressed_tokens <= output.report.original_tokens,
643
+ "regressed on {}: {} -> {}",
644
+ String::from_utf8_lossy(case),
645
+ output.report.original_tokens,
646
+ output.report.compressed_tokens,
647
+ );
648
+ // and every adopted transform round-trips is guaranteed by the pipeline safety gate;
649
+ // here we just assert the output is still valid JSON.
650
+ assert!(serde_json::from_slice::<serde_json::Value>(&output.bytes).is_ok());
651
+ }
652
+ }
653
+
591
654
  #[test]
592
655
  fn secret_redaction_warning_always_present_when_redaction_runs() {
593
656
  let input = CompressionInput::plain_text(b"nothing secret here".to_vec());
@@ -0,0 +1,289 @@
1
+ //! `json_value_dict` transform (canonical id `"json_value_dict"`, v1.0.0).
2
+ //!
3
+ //! Content-aware, **losslessly reversible** value deduplication for generic JSON data.
4
+ //! Where `json_field_fold` factors out repeated *keys*, this factors out repeated *values*:
5
+ //! the same large object/array/string appearing many times (a constant nested record, a
6
+ //! repeated timestamp, an identical config blob) is stored once in a dictionary and every
7
+ //! occurrence is replaced by a compact reference.
8
+ //!
9
+ //! ```text
10
+ //! {"a":{"x":1,"y":2,"z":3},"b":{"x":1,"y":2,"z":3}}
11
+ //! -> {"__tf_dict__":[{"x":1,"y":2,"z":3}],
12
+ //! "__tf_data__":{"a":{"__tf_ref__":0},"b":{"__tf_ref__":0}}}
13
+ //! ```
14
+ //!
15
+ //! It runs *after* `json_field_fold` in the pipeline, so it also collapses the repeated
16
+ //! nested values that folding surfaces across rows. Only values whose serialized form is
17
+ //! large enough that a reference is cheaper are dictionaried (see `MIN_VALUE_BYTES`), and the
18
+ //! pipeline's exact-token gate drops the whole transform if it fails to net a saving — so it
19
+ //! can never make a payload worse. Reversibility is guaranteed by `round_trips` (the pipeline
20
+ //! safety gate): a fold that wouldn't restore exactly is rolled back.
21
+
22
+ use std::collections::HashMap;
23
+
24
+ use serde_json::{Map, Value};
25
+
26
+ /// Canonical transform id, as registered with the pipeline.
27
+ pub const TRANSFORM_ID: &str = "json_value_dict";
28
+
29
+ /// Semantic version of this transform's output behavior.
30
+ pub const TRANSFORM_VERSION: &str = "1.0.0";
31
+
32
+ const DICT: &str = "__tf_dict__";
33
+ const DATA: &str = "__tf_data__";
34
+ const REF: &str = "__tf_ref__";
35
+
36
+ /// Minimum serialized byte length for a value to be worth dictionarying. A reference is
37
+ /// `{"__tf_ref__":N}` (~16 bytes), so shorter values would only grow; the exact-token gate is
38
+ /// the final arbiter, this is just a cheap pre-filter that keeps the transform net-positive.
39
+ const MIN_VALUE_BYTES: usize = 24;
40
+
41
+ /// A value must repeat at least this many times to be dictionaried.
42
+ const MIN_COUNT: usize = 2;
43
+
44
+ #[derive(Debug, thiserror::Error)]
45
+ pub enum JsonDictError {
46
+ #[error("invalid json: {0}")]
47
+ Invalid(#[from] serde_json::Error),
48
+ }
49
+
50
+ /// Replaces repeated large values in `input` with dictionary references. No-op-safe: empty
51
+ /// input returns empty; input with nothing worth dictionarying returns unchanged.
52
+ pub fn dict_json(input: &[u8]) -> Result<Vec<u8>, JsonDictError> {
53
+ if input.is_empty() {
54
+ return Ok(Vec::new());
55
+ }
56
+ let value: Value = serde_json::from_slice(input)?;
57
+
58
+ let mut counts: HashMap<String, usize> = HashMap::new();
59
+ count_dictable(&value, &mut counts);
60
+
61
+ let mut index: Vec<String> = Vec::new();
62
+ let mut index_of: HashMap<String, usize> = HashMap::new();
63
+ let data = replace(&value, &counts, &mut index, &mut index_of);
64
+
65
+ if index.is_empty() {
66
+ return Ok(input.to_vec());
67
+ }
68
+ let dict: Vec<Value> = index
69
+ .iter()
70
+ .map(|s| serde_json::from_str(s).expect("canonical string from our own to_string"))
71
+ .collect();
72
+ let mut wrapper = Map::new();
73
+ wrapper.insert(DICT.to_string(), Value::Array(dict));
74
+ wrapper.insert(DATA.to_string(), data);
75
+ Ok(serde_json::to_vec(&Value::Object(wrapper))?)
76
+ }
77
+
78
+ /// Inverse of [`dict_json`]: expands every `{__tf_ref__: i}` back to `dict[i]`.
79
+ pub fn undict_json(input: &[u8]) -> Result<Vec<u8>, JsonDictError> {
80
+ if input.is_empty() {
81
+ return Ok(Vec::new());
82
+ }
83
+ let value: Value = serde_json::from_slice(input)?;
84
+ let restored = restore(&value);
85
+ Ok(serde_json::to_vec(&restored)?)
86
+ }
87
+
88
+ /// True iff expanding `after` reproduces `before` exactly (as JSON values) — the pipeline's
89
+ /// safety gate for this transform.
90
+ pub fn round_trips(before: &[u8], after: &[u8]) -> bool {
91
+ let (Ok(before_v), Ok(after_v)) = (
92
+ serde_json::from_slice::<Value>(before),
93
+ serde_json::from_slice::<Value>(after),
94
+ ) else {
95
+ return false;
96
+ };
97
+ restore(&after_v) == before_v
98
+ }
99
+
100
+ /// Canonical dictionary key for a value, or `None` if it isn't a dictionary candidate
101
+ /// (too small, or a scalar that a reference couldn't beat).
102
+ fn dict_key(v: &Value) -> Option<String> {
103
+ if !matches!(v, Value::Object(_) | Value::Array(_) | Value::String(_)) {
104
+ return None;
105
+ }
106
+ let key = serde_json::to_string(v).ok()?;
107
+ (key.len() >= MIN_VALUE_BYTES).then_some(key)
108
+ }
109
+
110
+ fn count_dictable(v: &Value, counts: &mut HashMap<String, usize>) {
111
+ if let Some(k) = dict_key(v) {
112
+ *counts.entry(k).or_insert(0) += 1;
113
+ }
114
+ match v {
115
+ Value::Object(m) => m.values().for_each(|val| count_dictable(val, counts)),
116
+ Value::Array(a) => a.iter().for_each(|item| count_dictable(item, counts)),
117
+ _ => {}
118
+ }
119
+ }
120
+
121
+ /// Top-down replacement: the outermost repeated value at any position becomes a reference and
122
+ /// is not descended into (so a repeated parent object swallows its children rather than
123
+ /// leaving dead dictionary entries). Indices are assigned lazily, so only referenced values
124
+ /// end up in the dictionary.
125
+ fn replace(
126
+ v: &Value,
127
+ counts: &HashMap<String, usize>,
128
+ index: &mut Vec<String>,
129
+ index_of: &mut HashMap<String, usize>,
130
+ ) -> Value {
131
+ if let Some(k) = dict_key(v)
132
+ && counts.get(&k).copied().unwrap_or(0) >= MIN_COUNT
133
+ {
134
+ let idx = *index_of.entry(k.clone()).or_insert_with(|| {
135
+ index.push(k);
136
+ index.len() - 1
137
+ });
138
+ let mut r = Map::new();
139
+ r.insert(REF.to_string(), Value::from(idx as u64));
140
+ return Value::Object(r);
141
+ }
142
+ match v {
143
+ Value::Object(m) => {
144
+ let mut o = Map::new();
145
+ for (kk, val) in m {
146
+ o.insert(kk.clone(), replace(val, counts, index, index_of));
147
+ }
148
+ Value::Object(o)
149
+ }
150
+ Value::Array(a) => Value::Array(a.iter().map(|it| replace(it, counts, index, index_of)).collect()),
151
+ _ => v.clone(),
152
+ }
153
+ }
154
+
155
+ /// Expands a whole document: if it's our `{__tf_dict__, __tf_data__}` wrapper, resolve every
156
+ /// reference in the data against the dictionary; otherwise return it unchanged.
157
+ fn restore(v: &Value) -> Value {
158
+ let Some((dict, data)) = as_wrapper(v) else {
159
+ return v.clone();
160
+ };
161
+ expand(data, dict)
162
+ }
163
+
164
+ fn as_wrapper(v: &Value) -> Option<(&Vec<Value>, &Value)> {
165
+ let m = v.as_object()?;
166
+ if m.len() != 2 {
167
+ return None;
168
+ }
169
+ let dict = m.get(DICT)?.as_array()?;
170
+ let data = m.get(DATA)?;
171
+ Some((dict, data))
172
+ }
173
+
174
+ fn expand(v: &Value, dict: &[Value]) -> Value {
175
+ if let Some(idx) = as_ref(v) {
176
+ // Dictionary entries are stored ref-free, so this resolves in one step; out-of-range
177
+ // indices are left as-is (the round-trip gate then rejects the fold).
178
+ return dict.get(idx).cloned().unwrap_or_else(|| v.clone());
179
+ }
180
+ match v {
181
+ Value::Object(m) => {
182
+ let mut o = Map::new();
183
+ for (k, val) in m {
184
+ o.insert(k.clone(), expand(val, dict));
185
+ }
186
+ Value::Object(o)
187
+ }
188
+ Value::Array(a) => Value::Array(a.iter().map(|it| expand(it, dict)).collect()),
189
+ _ => v.clone(),
190
+ }
191
+ }
192
+
193
+ fn as_ref(v: &Value) -> Option<usize> {
194
+ let m = v.as_object()?;
195
+ if m.len() != 1 {
196
+ return None;
197
+ }
198
+ Some(m.get(REF)?.as_u64()? as usize)
199
+ }
200
+
201
+ #[cfg(test)]
202
+ mod tests {
203
+ use super::*;
204
+
205
+ fn parse(b: &[u8]) -> Value {
206
+ serde_json::from_slice(b).unwrap()
207
+ }
208
+
209
+ #[test]
210
+ fn dedups_a_repeated_large_object() {
211
+ let obj = r#"{"kind":"widget","color":"blue","size":"large"}"#;
212
+ let input = format!(r#"{{"a":{obj},"b":{obj},"c":{obj}}}"#);
213
+ let out = dict_json(input.as_bytes()).unwrap();
214
+ let s = String::from_utf8(out.clone()).unwrap();
215
+ assert!(s.contains("__tf_dict__"), "expected dict form, got {s}");
216
+ // the object literal appears once (in the dict), not three times
217
+ assert_eq!(s.matches("widget").count(), 1);
218
+ assert!(round_trips(input.as_bytes(), &out));
219
+ }
220
+
221
+ #[test]
222
+ fn dict_then_undict_reproduces_original() {
223
+ let obj = r#"{"kind":"widget","color":"blue","size":"large"}"#;
224
+ let input = format!(r#"[{obj},{obj},{obj},{obj}]"#);
225
+ let folded = dict_json(input.as_bytes()).unwrap();
226
+ let back = undict_json(&folded).unwrap();
227
+ assert_eq!(parse(&back), parse(input.as_bytes()));
228
+ }
229
+
230
+ #[test]
231
+ fn short_repeated_values_are_left_alone() {
232
+ // "member" is short; a reference would be longer, so it must NOT be dictionaried.
233
+ let input = br#"{"a":"member","b":"member","c":"member"}"#;
234
+ let out = dict_json(input).unwrap();
235
+ assert_eq!(parse(&out), parse(input));
236
+ assert!(!String::from_utf8(out).unwrap().contains("__tf_dict__"));
237
+ }
238
+
239
+ #[test]
240
+ fn single_occurrence_is_not_dictionaried() {
241
+ let input = br#"{"only":{"a":"quite a long value here indeed yes"}}"#;
242
+ let out = dict_json(input).unwrap();
243
+ assert!(!String::from_utf8(out).unwrap().contains("__tf_dict__"));
244
+ }
245
+
246
+ #[test]
247
+ fn nested_repeats_do_not_create_dead_entries() {
248
+ let inner = r#"{"deeply":"nested repeated value that is long"}"#;
249
+ let parent = format!(r#"{{"p":{inner},"q":"x"}}"#);
250
+ let input = format!(r#"[{parent},{parent}]"#);
251
+ let out = dict_json(input.as_bytes()).unwrap();
252
+ assert!(round_trips(input.as_bytes(), &out));
253
+ }
254
+
255
+ #[test]
256
+ fn empty_and_invalid_inputs() {
257
+ assert!(dict_json(b"").unwrap().is_empty());
258
+ assert!(dict_json(b"{bad").is_err());
259
+ }
260
+
261
+ use proptest::prelude::*;
262
+
263
+ fn arb_json() -> impl Strategy<Value = Value> {
264
+ let leaf = prop_oneof![
265
+ Just(Value::Null),
266
+ any::<bool>().prop_map(Value::Bool),
267
+ any::<i64>().prop_map(|n| Value::Number(n.into())),
268
+ "[^\"\\\\]{0,20}".prop_map(Value::String),
269
+ ];
270
+ leaf.prop_recursive(4, 48, 6, |inner| {
271
+ prop_oneof![
272
+ prop::collection::vec(inner.clone(), 0..6).prop_map(Value::Array),
273
+ prop::collection::hash_map("[a-z]{1,6}", inner, 0..6)
274
+ .prop_map(|m| Value::Object(m.into_iter().collect())),
275
+ ]
276
+ })
277
+ }
278
+
279
+ proptest! {
280
+ #[test]
281
+ fn dict_then_undict_is_the_identity_on_arbitrary_json(v in arb_json()) {
282
+ let bytes = serde_json::to_vec(&v).unwrap();
283
+ let dicted = dict_json(&bytes).unwrap();
284
+ prop_assert!(round_trips(&bytes, &dicted));
285
+ let back: Value = serde_json::from_slice(&undict_json(&dicted).unwrap()).unwrap();
286
+ prop_assert_eq!(back, v);
287
+ }
288
+ }
289
+ }
@@ -0,0 +1,308 @@
1
+ //! `json_field_fold` transform (canonical id `"json_field_fold"`, v1.0.0).
2
+ //!
3
+ //! Content-aware, **losslessly reversible** structural compression for generic JSON data
4
+ //! (`InputFormat::Json`). Where `json_minify` only removes whitespace, this transform removes
5
+ //! the dominant source of token waste in data-JSON: **repeated object keys**. An array of N
6
+ //! objects that all share the same keys emits each key N times; folding rewrites it to a
7
+ //! columnar form that emits each key exactly once.
8
+ //!
9
+ //! ```text
10
+ //! [ {"id":1,"role":"member"}, {"id":2,"role":"member"} ]
11
+ //! -> {"__tf_cols__":["id","role"],"__tf_rows__":[[1,"member"],[2,"member"]]}
12
+ //! ```
13
+ //!
14
+ //! The fold is applied recursively (values are folded before their enclosing array is), so
15
+ //! nested arrays-of-objects fold too. It is a pure structural rewrite — every value is
16
+ //! preserved — so `unfold_json` reconstructs the original data exactly (as a `serde_json`
17
+ //! value; number *spelling* like `1e10` may be renormalized by re-serialization, but the
18
+ //! numeric value is unchanged). The pipeline gates adoption on that round-trip
19
+ //! (`round_trips`), so a fold that would ever lose data is rolled back rather than emitted.
20
+
21
+ use serde_json::{Map, Value};
22
+
23
+ /// Canonical transform id, as registered with the pipeline.
24
+ pub const TRANSFORM_ID: &str = "json_field_fold";
25
+
26
+ /// Semantic version of this transform's output behavior.
27
+ pub const TRANSFORM_VERSION: &str = "1.0.0";
28
+
29
+ /// Reserved keys marking a folded array. Chosen to be collision-unlikely in real data; any
30
+ /// actual collision is caught by the pipeline's round-trip safety gate, never corrupts data.
31
+ const COLS: &str = "__tf_cols__";
32
+ const ROWS: &str = "__tf_rows__";
33
+
34
+ /// Minimum array length worth folding. Below this the `{cols,rows}` framing overhead can
35
+ /// exceed the saved keys; the pipeline also rolls back any net regression, so this is only a
36
+ /// cheap early-out, not the correctness boundary.
37
+ const MIN_ROWS: usize = 2;
38
+
39
+ #[derive(Debug, thiserror::Error)]
40
+ pub enum JsonFoldError {
41
+ #[error("invalid json: {0}")]
42
+ Invalid(#[from] serde_json::Error),
43
+ }
44
+
45
+ /// Folds arrays of homogeneous objects in `input` into columnar `{__tf_cols__, __tf_rows__}`
46
+ /// form. No-op-safe: empty input returns empty; input with no foldable arrays returns a
47
+ /// (compact) re-serialization with the same data.
48
+ pub fn fold_json(input: &[u8]) -> Result<Vec<u8>, JsonFoldError> {
49
+ if input.is_empty() {
50
+ return Ok(Vec::new());
51
+ }
52
+ let value: Value = serde_json::from_slice(input)?;
53
+ let folded = fold_value(&value);
54
+ Ok(serde_json::to_vec(&folded)?)
55
+ }
56
+
57
+ /// Inverse of [`fold_json`]: expands every `{__tf_cols__, __tf_rows__}` node back into an
58
+ /// array of objects. This is the reversible half that makes the transform safe.
59
+ pub fn unfold_json(input: &[u8]) -> Result<Vec<u8>, JsonFoldError> {
60
+ if input.is_empty() {
61
+ return Ok(Vec::new());
62
+ }
63
+ let value: Value = serde_json::from_slice(input)?;
64
+ let unfolded = unfold_value(&value);
65
+ Ok(serde_json::to_vec(&unfolded)?)
66
+ }
67
+
68
+ /// True iff unfolding `after` reproduces `before` exactly (as JSON values). The pipeline's
69
+ /// safety gate for this transform — folding is only adopted when this holds.
70
+ pub fn round_trips(before: &[u8], after: &[u8]) -> bool {
71
+ let (Ok(before_v), Ok(after_v)) = (
72
+ serde_json::from_slice::<Value>(before),
73
+ serde_json::from_slice::<Value>(after),
74
+ ) else {
75
+ return false;
76
+ };
77
+ unfold_value(&after_v) == before_v
78
+ }
79
+
80
+ fn fold_value(v: &Value) -> Value {
81
+ match v {
82
+ Value::Array(items) => {
83
+ let folded: Vec<Value> = items.iter().map(fold_value).collect();
84
+ try_fold_array(&folded).unwrap_or(Value::Array(folded))
85
+ }
86
+ Value::Object(map) => {
87
+ let mut out = Map::new();
88
+ for (k, val) in map {
89
+ out.insert(k.clone(), fold_value(val));
90
+ }
91
+ Value::Object(out)
92
+ }
93
+ _ => v.clone(),
94
+ }
95
+ }
96
+
97
+ /// Returns the columnar node if `items` is a foldable array (>= MIN_ROWS objects sharing the
98
+ /// same key set, none empty, none already using the reserved markers), else `None`.
99
+ fn try_fold_array(items: &[Value]) -> Option<Value> {
100
+ if items.len() < MIN_ROWS {
101
+ return None;
102
+ }
103
+ let first = items[0].as_object()?;
104
+ if first.is_empty() || first.contains_key(COLS) || first.contains_key(ROWS) {
105
+ return None;
106
+ }
107
+ // Column order is the first object's key order; membership is a set check so objects that
108
+ // carry the same keys in a different order still fold (values are placed by key, so this
109
+ // stays lossless).
110
+ let cols: Vec<String> = first.keys().cloned().collect();
111
+ for item in items {
112
+ let obj = item.as_object()?;
113
+ if obj.len() != cols.len() || !cols.iter().all(|k| obj.contains_key(k)) {
114
+ return None;
115
+ }
116
+ }
117
+ let rows: Vec<Value> = items
118
+ .iter()
119
+ .map(|item| {
120
+ let obj = item.as_object().expect("checked above");
121
+ Value::Array(cols.iter().map(|k| obj[k].clone()).collect())
122
+ })
123
+ .collect();
124
+ let mut folded = Map::new();
125
+ folded.insert(
126
+ COLS.to_string(),
127
+ Value::Array(cols.into_iter().map(Value::String).collect()),
128
+ );
129
+ folded.insert(ROWS.to_string(), Value::Array(rows));
130
+ Some(Value::Object(folded))
131
+ }
132
+
133
+ fn unfold_value(v: &Value) -> Value {
134
+ match v {
135
+ Value::Object(map) => {
136
+ if let Some(arr) = try_unfold_node(map) {
137
+ arr
138
+ } else {
139
+ let mut out = Map::new();
140
+ for (k, val) in map {
141
+ out.insert(k.clone(), unfold_value(val));
142
+ }
143
+ Value::Object(out)
144
+ }
145
+ }
146
+ Value::Array(items) => Value::Array(items.iter().map(unfold_value).collect()),
147
+ _ => v.clone(),
148
+ }
149
+ }
150
+
151
+ /// Expands one `{__tf_cols__, __tf_rows__}` node back to an array of objects, or `None` if
152
+ /// `map` isn't a well-formed folded node (wrong keys, wrong types, or a row whose width
153
+ /// doesn't match the column count — all of which mean it's real data, not our framing).
154
+ fn try_unfold_node(map: &Map<String, Value>) -> Option<Value> {
155
+ if map.len() != 2 {
156
+ return None;
157
+ }
158
+ let cols = map.get(COLS)?.as_array()?;
159
+ let rows = map.get(ROWS)?.as_array()?;
160
+ let col_names: Vec<&str> = cols.iter().map(|c| c.as_str()).collect::<Option<_>>()?;
161
+ let mut out = Vec::with_capacity(rows.len());
162
+ for row in rows {
163
+ let vals = row.as_array()?;
164
+ if vals.len() != col_names.len() {
165
+ return None;
166
+ }
167
+ let mut obj = Map::new();
168
+ for (name, val) in col_names.iter().zip(vals) {
169
+ obj.insert((*name).to_string(), unfold_value(val));
170
+ }
171
+ out.push(Value::Object(obj));
172
+ }
173
+ Some(Value::Array(out))
174
+ }
175
+
176
+ #[cfg(test)]
177
+ mod tests {
178
+ use super::*;
179
+
180
+ fn parse(b: &[u8]) -> Value {
181
+ serde_json::from_slice(b).unwrap()
182
+ }
183
+
184
+ #[test]
185
+ fn folds_homogeneous_array_and_removes_repeated_keys() {
186
+ let input = br#"[{"id":1,"role":"member"},{"id":2,"role":"member"}]"#;
187
+ let out = fold_json(input).unwrap();
188
+ let s = String::from_utf8(out.clone()).unwrap();
189
+ assert!(s.contains("__tf_cols__"), "expected folded form, got {s}");
190
+ // "role" appears once as a column, not once per row.
191
+ assert_eq!(s.matches("role").count(), 1);
192
+ // and it round-trips
193
+ assert!(round_trips(input, &out));
194
+ }
195
+
196
+ #[test]
197
+ fn fold_then_unfold_reproduces_original_value() {
198
+ let input = br#"{"count":2,"results":[{"a":1,"b":2},{"a":3,"b":4}]}"#;
199
+ let folded = fold_json(input).unwrap();
200
+ let unfolded = unfold_json(&folded).unwrap();
201
+ assert_eq!(parse(&unfolded), parse(input));
202
+ }
203
+
204
+ #[test]
205
+ fn nested_arrays_of_objects_fold_too() {
206
+ let input = br#"{"groups":[{"users":[{"id":1},{"id":2}]},{"users":[{"id":3},{"id":4}]}]}"#;
207
+ let out = fold_json(input).unwrap();
208
+ assert!(round_trips(input, &out));
209
+ // both the outer "groups" array and each inner "users" array are homogeneous → folded.
210
+ let s = String::from_utf8(out).unwrap();
211
+ assert!(s.contains("__tf_cols__"));
212
+ }
213
+
214
+ #[test]
215
+ fn heterogeneous_array_is_left_alone() {
216
+ // different key sets → not foldable, must stay a plain array (still round-trips).
217
+ let input = br#"[{"a":1},{"b":2}]"#;
218
+ let out = fold_json(input).unwrap();
219
+ assert_eq!(parse(&out), parse(input));
220
+ assert!(!String::from_utf8(out).unwrap().contains("__tf_cols__"));
221
+ }
222
+
223
+ #[test]
224
+ fn array_of_non_objects_is_left_alone() {
225
+ let input = br#"[1,2,3,4]"#;
226
+ let out = fold_json(input).unwrap();
227
+ assert_eq!(parse(&out), parse(input));
228
+ }
229
+
230
+ #[test]
231
+ fn single_object_array_is_not_folded() {
232
+ let input = br#"[{"a":1,"b":2}]"#;
233
+ let out = fold_json(input).unwrap();
234
+ assert!(!String::from_utf8(out).unwrap().contains("__tf_cols__"));
235
+ }
236
+
237
+ #[test]
238
+ fn same_keys_different_order_still_folds_losslessly() {
239
+ let input = br#"[{"a":1,"b":2},{"b":4,"a":3}]"#;
240
+ let out = fold_json(input).unwrap();
241
+ assert!(round_trips(input, &out));
242
+ }
243
+
244
+ #[test]
245
+ fn source_data_using_reserved_markers_does_not_round_trip_falsely() {
246
+ // If real data already looks like our framing, folding must not silently corrupt it.
247
+ // Here the array isn't foldable anyway; the point is unfold doesn't misread real data
248
+ // that happens to be a genuine 2-key object with other names.
249
+ let input = br#"{"__tf_cols__":["x"],"__tf_rows__":[[1]]}"#;
250
+ // This *is* a valid folded node shape; unfold would expand it. round_trips guards the
251
+ // pipeline: folding this input produces identical bytes (nothing foldable), and
252
+ // unfold(after) != before, so the pipeline would not adopt a fold here.
253
+ let folded = fold_json(input).unwrap();
254
+ assert!(!round_trips(input, &folded));
255
+ }
256
+
257
+ #[test]
258
+ fn empty_input_is_a_noop() {
259
+ assert!(fold_json(b"").unwrap().is_empty());
260
+ assert!(unfold_json(b"").unwrap().is_empty());
261
+ }
262
+
263
+ #[test]
264
+ fn invalid_json_is_rejected() {
265
+ assert!(fold_json(b"{not json").is_err());
266
+ }
267
+
268
+ #[test]
269
+ fn numbers_and_nulls_survive_the_round_trip() {
270
+ let input = br#"[{"n":1.5,"z":null,"b":true},{"n":2.5,"z":null,"b":false}]"#;
271
+ let out = fold_json(input).unwrap();
272
+ assert!(round_trips(input, &out));
273
+ }
274
+
275
+ use proptest::prelude::*;
276
+
277
+ // Arbitrary JSON (integer numbers only, to keep value-equality exact — float spelling is
278
+ // the one thing re-serialization may renormalize, and it's covered by the unit test above).
279
+ fn arb_json() -> impl Strategy<Value = Value> {
280
+ let leaf = prop_oneof![
281
+ Just(Value::Null),
282
+ any::<bool>().prop_map(Value::Bool),
283
+ any::<i64>().prop_map(|n| Value::Number(n.into())),
284
+ "[^\"\\\\]{0,12}".prop_map(Value::String),
285
+ ];
286
+ leaf.prop_recursive(4, 48, 6, |inner| {
287
+ prop_oneof![
288
+ prop::collection::vec(inner.clone(), 0..6).prop_map(Value::Array),
289
+ prop::collection::hash_map("[a-z]{1,6}", inner, 0..6)
290
+ .prop_map(|m| Value::Object(m.into_iter().collect())),
291
+ ]
292
+ })
293
+ }
294
+
295
+ proptest! {
296
+ // The core safety guarantee: folding never loses data. For ANY JSON value, unfolding
297
+ // the folded form reproduces it exactly, and round_trips() (the pipeline's gate) agrees.
298
+ #[test]
299
+ fn fold_then_unfold_is_the_identity_on_arbitrary_json(v in arb_json()) {
300
+ let bytes = serde_json::to_vec(&v).unwrap();
301
+ let folded = fold_json(&bytes).unwrap();
302
+ prop_assert!(round_trips(&bytes, &folded), "round_trips() rejected a valid fold");
303
+ let unfolded = unfold_json(&folded).unwrap();
304
+ let back: Value = serde_json::from_slice(&unfolded).unwrap();
305
+ prop_assert_eq!(back, v);
306
+ }
307
+ }
308
+ }
@@ -4,6 +4,8 @@
4
4
 
5
5
  pub mod diff;
6
6
  pub mod json;
7
+ pub mod json_dict;
8
+ pub mod json_fold;
7
9
  pub mod logs;
8
10
  pub mod redaction;
9
11
  pub mod schema;
@@ -258,6 +258,7 @@ fn mode_matrix_fixture_mirrors_the_rust_source_of_truth() {
258
258
  let format_from_str = |s: &str| match s {
259
259
  "openai_json" => InputFormat::OpenAiJson,
260
260
  "anthropic_json" => InputFormat::AnthropicJson,
261
+ "json" => InputFormat::Json,
261
262
  "plain_text" => InputFormat::PlainText,
262
263
  "command_output" => InputFormat::CommandOutput,
263
264
  "git_diff" => InputFormat::GitDiff,
@@ -111,6 +111,8 @@ pub enum PyInputFormat {
111
111
  OpenaiJson,
112
112
  #[pyo3(name = "ANTHROPIC_JSON")]
113
113
  AnthropicJson,
114
+ #[pyo3(name = "JSON")]
115
+ Json,
114
116
  #[pyo3(name = "PLAIN_TEXT")]
115
117
  PlainText,
116
118
  #[pyo3(name = "COMMAND_OUTPUT")]
@@ -125,6 +127,7 @@ impl From<PyInputFormat> for CoreFormat {
125
127
  PyInputFormat::Auto => CoreFormat::Auto,
126
128
  PyInputFormat::OpenaiJson => CoreFormat::OpenAiJson,
127
129
  PyInputFormat::AnthropicJson => CoreFormat::AnthropicJson,
130
+ PyInputFormat::Json => CoreFormat::Json,
128
131
  PyInputFormat::PlainText => CoreFormat::PlainText,
129
132
  PyInputFormat::CommandOutput => CoreFormat::CommandOutput,
130
133
  PyInputFormat::GitDiff => CoreFormat::GitDiff,
@@ -137,6 +140,7 @@ fn parse_format_str(s: &str) -> PyResult<CoreFormat> {
137
140
  "AUTO" => Ok(CoreFormat::Auto),
138
141
  "OPENAI_JSON" => Ok(CoreFormat::OpenAiJson),
139
142
  "ANTHROPIC_JSON" => Ok(CoreFormat::AnthropicJson),
143
+ "JSON" => Ok(CoreFormat::Json),
140
144
  "PLAIN_TEXT" => Ok(CoreFormat::PlainText),
141
145
  "COMMAND_OUTPUT" => Ok(CoreFormat::CommandOutput),
142
146
  "GIT_DIFF" => Ok(CoreFormat::GitDiff),
File without changes