Skip to main content

tokenfold_core/transforms/
json_fold.rs

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
21use serde_json::{Map, Value};
22
23/// Canonical transform id, as registered with the pipeline.
24pub const TRANSFORM_ID: &str = "json_field_fold";
25
26/// Semantic version of this transform's output behavior.
27pub 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.
31const COLS: &str = "__tf_cols__";
32const 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.
37const MIN_ROWS: usize = 2;
38
39#[derive(Debug, thiserror::Error)]
40pub 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.
48pub 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.
59pub 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.
70pub 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
80fn 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`.
99fn 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
133fn 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).
154fn 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)]
177mod 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}