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.
- {tokenfold-0.1.0 → tokenfold-0.2.0}/Cargo.lock +10 -10
- {tokenfold-0.1.0 → tokenfold-0.2.0}/Cargo.toml +1 -1
- {tokenfold-0.1.0 → tokenfold-0.2.0}/PKG-INFO +32 -9
- {tokenfold-0.1.0 → tokenfold-0.2.0}/README.md +31 -8
- {tokenfold-0.1.0 → tokenfold-0.2.0}/crates/tokenfold-core/examples/quickstart.rs +38 -16
- {tokenfold-0.1.0 → tokenfold-0.2.0}/crates/tokenfold-core/src/budget.rs +6 -1
- {tokenfold-0.1.0 → tokenfold-0.2.0}/crates/tokenfold-core/src/input.rs +12 -0
- {tokenfold-0.1.0 → tokenfold-0.2.0}/crates/tokenfold-core/src/modes.rs +43 -1
- {tokenfold-0.1.0 → tokenfold-0.2.0}/crates/tokenfold-core/src/pipeline.rs +73 -10
- tokenfold-0.2.0/crates/tokenfold-core/src/transforms/json_dict.rs +289 -0
- tokenfold-0.2.0/crates/tokenfold-core/src/transforms/json_fold.rs +308 -0
- {tokenfold-0.1.0 → tokenfold-0.2.0}/crates/tokenfold-core/src/transforms/mod.rs +2 -0
- {tokenfold-0.1.0 → tokenfold-0.2.0}/crates/tokenfold-core/tests/integration.rs +1 -0
- {tokenfold-0.1.0 → tokenfold-0.2.0}/crates/tokenfold-py/src/lib.rs +4 -0
- {tokenfold-0.1.0 → tokenfold-0.2.0}/crates/tokenfold-core/Cargo.toml +0 -0
- {tokenfold-0.1.0 → tokenfold-0.2.0}/crates/tokenfold-core/benches/THRESHOLDS.toml +0 -0
- {tokenfold-0.1.0 → tokenfold-0.2.0}/crates/tokenfold-core/benches/compression_bench.rs +0 -0
- {tokenfold-0.1.0 → tokenfold-0.2.0}/crates/tokenfold-core/src/errors.rs +0 -0
- {tokenfold-0.1.0 → tokenfold-0.2.0}/crates/tokenfold-core/src/filters.rs +0 -0
- {tokenfold-0.1.0 → tokenfold-0.2.0}/crates/tokenfold-core/src/lib.rs +0 -0
- {tokenfold-0.1.0 → tokenfold-0.2.0}/crates/tokenfold-core/src/report.rs +0 -0
- {tokenfold-0.1.0 → tokenfold-0.2.0}/crates/tokenfold-core/src/retrieval_store.rs +0 -0
- {tokenfold-0.1.0 → tokenfold-0.2.0}/crates/tokenfold-core/src/safety.rs +0 -0
- {tokenfold-0.1.0 → tokenfold-0.2.0}/crates/tokenfold-core/src/stats.rs +0 -0
- {tokenfold-0.1.0 → tokenfold-0.2.0}/crates/tokenfold-core/src/status.rs +0 -0
- {tokenfold-0.1.0 → tokenfold-0.2.0}/crates/tokenfold-core/src/token_estimator.rs +0 -0
- {tokenfold-0.1.0 → tokenfold-0.2.0}/crates/tokenfold-core/src/transforms/diff.rs +0 -0
- {tokenfold-0.1.0 → tokenfold-0.2.0}/crates/tokenfold-core/src/transforms/json.rs +0 -0
- {tokenfold-0.1.0 → tokenfold-0.2.0}/crates/tokenfold-core/src/transforms/logs.rs +0 -0
- {tokenfold-0.1.0 → tokenfold-0.2.0}/crates/tokenfold-core/src/transforms/redaction.rs +0 -0
- {tokenfold-0.1.0 → tokenfold-0.2.0}/crates/tokenfold-core/src/transforms/schema.rs +0 -0
- {tokenfold-0.1.0 → tokenfold-0.2.0}/crates/tokenfold-core/tests/golden.rs +0 -0
- {tokenfold-0.1.0 → tokenfold-0.2.0}/crates/tokenfold-core/tests/property_tests.rs +0 -0
- {tokenfold-0.1.0 → tokenfold-0.2.0}/crates/tokenfold-py/Cargo.toml +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
1062
|
+
version = "0.2.0"
|
|
1063
1063
|
|
|
1064
1064
|
[[package]]
|
|
1065
1065
|
name = "tokenfold-learn"
|
|
1066
|
-
version = "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.
|
|
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.
|
|
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.
|
|
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.
|
|
1101
|
+
version = "0.2.0"
|
|
1102
1102
|
|
|
1103
1103
|
[[package]]
|
|
1104
1104
|
name = "toml"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tokenfold
|
|
3
|
-
Version: 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
|
-
|
|
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
|
|
118
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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.
|
|
191
|
-
|
|
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
|
-
|
|
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
|
|
106
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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.
|
|
179
|
-
|
|
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
|
|
16
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: &[
|
|
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
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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
|
-
|
|
392
|
-
|
|
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
|
+
}
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|