Skip to main content

lean_ctx/core/
json_crush.rs

1//! Deterministic JSON crusher — single source of truth for structural JSON
2//! compaction (#934, Headroom "Smart Crusher" port, GitLab #935).
3//!
4//! Real JSON payloads (API responses, `kubectl get -o json`, DB dumps, RAG
5//! chunks) are dominated by arrays of objects that repeat the same keys and
6//! values on every row. This module factors that redundancy out:
7//!
8//! - [`crush_lossless`] hoists every key that is present in *all* objects of an
9//!   array to its dominant value (a `_defaults` block); each item then keeps
10//!   only the fields that *deviate* from the default. A field absent from an
11//!   item means "equals the default", so the transform is **exactly**
12//!   reconstructible via [`reconstruct`].
13//! - [`crush_lossy`] additionally *drops* near-unique high-entropy columns
14//!   (timestamps, UUIDs — pure noise for an agent) recorded in `_dropped`. The
15//!   exact original is then recovered out-of-band via CCR, never from the text.
16//!
17//! Determinism (#498): the output is a pure function of the input `Value` — no
18//! timestamps, counters, randomness, or hash-map order leakage (candidate keys
19//! are walked through a [`BTreeSet`], value frequencies through a [`BTreeMap`]).
20//! The crusher never inflates: callers gate on `shorter_only`, and a no-op input
21//! returns [`None`].
22
23use serde_json::{Map, Value};
24use std::collections::{BTreeMap, BTreeSet};
25
26/// Marks an object that encodes a crushed array. Chosen to be vanishingly
27/// unlikely in real data; if the input already contains this key anywhere, the
28/// crusher bails (returns `None`) so reconstruction can never be ambiguous.
29const MARKER: &str = "_lc_crush";
30const MARKER_ARRAY: &str = "arr";
31const DEFAULTS_KEY: &str = "_defaults";
32const DROPPED_KEY: &str = "_dropped";
33const ITEMS_KEY: &str = "_items";
34
35/// Below this item count, factoring rarely beats its own structural overhead.
36const MIN_ITEMS: usize = 3;
37/// A key is only hoisted into `_defaults` when its dominant value covers at
38/// least this fraction of items (constants are the 100% case).
39const MIN_DOMINANCE: f64 = 0.5;
40
41/// Shared "the crush must at least halve the payload" threshold. The lossless
42/// crusher keeps every datum, so reshaping only pays when the array is redundant
43/// enough that the compact form is at most `1/KEEP_DATA_DIVISOR` of the input;
44/// heterogeneous/low-redundancy data falls through to each caller's own outline.
45/// Single source for the shell (`json_schema`, `curl`) and read (`structured_read`)
46/// paths so the gate can never drift (#936).
47pub const KEEP_DATA_DIVISOR: usize = 2;
48
49/// Result of a crush pass.
50#[derive(Debug, Clone)]
51pub struct CrushResult {
52    /// Compact JSON the agent sees.
53    pub text: String,
54    /// `true` when `reconstruct(text)` yields the exact original; `false` when a
55    /// lossy stage dropped columns and the original must come from CCR.
56    pub lossless: bool,
57}
58
59/// Tuning for a crush pass.
60#[derive(Debug, Clone)]
61pub struct CrushOpts {
62    /// Distinct-value ratio (`distinct / items`) at or above which an
63    /// all-present column is *dropped* (lossy). `1.0` disables dropping, making
64    /// the pass strictly lossless.
65    pub drop_entropy: f64,
66    /// Maximum recursion depth before a subtree is left untouched.
67    pub max_depth: usize,
68    /// Arrays larger than this are left untouched (pathology guard).
69    pub max_items: usize,
70}
71
72impl Default for CrushOpts {
73    fn default() -> Self {
74        Self {
75            drop_entropy: 1.0,
76            max_depth: 64,
77            max_items: 100_000,
78        }
79    }
80}
81
82impl CrushOpts {
83    /// Strictly lossless options (no column dropping).
84    pub fn lossless() -> Self {
85        Self::default()
86    }
87
88    /// Lossy options that drop columns whose distinct-value ratio is
89    /// `>= drop_entropy` (clamped to `[0, 1]`).
90    pub fn lossy(drop_entropy: f64) -> Self {
91        Self {
92            drop_entropy: drop_entropy.clamp(0.0, 1.0),
93            ..Self::default()
94        }
95    }
96}
97
98/// Lossless crush: returns `Some` only when something was actually factored.
99pub fn crush_lossless(value: &Value) -> Option<CrushResult> {
100    crush_with(value, &CrushOpts::lossless())
101}
102
103/// Lossy crush: lossless factoring plus high-entropy column dropping.
104pub fn crush_lossy(value: &Value, opts: &CrushOpts) -> Option<CrushResult> {
105    crush_with(value, opts)
106}
107
108/// Lossless crush of `value`, returning the compact text only when it at least
109/// halves `raw_len` ([`KEEP_DATA_DIVISOR`]). The shared gate for callers that
110/// already hold a parsed `Value` plus its source length (`json_schema`, `curl`).
111pub fn crush_value_if_beneficial(value: &Value, raw_len: usize) -> Option<String> {
112    let crushed = crush_lossless(value)?;
113    (crushed.text.len().saturating_mul(KEEP_DATA_DIVISOR) <= raw_len).then_some(crushed.text)
114}
115
116/// Parse `text` as JSON and losslessly crush it, returning the compact form only
117/// when it at least halves the input ([`KEEP_DATA_DIVISOR`]). The shared gate for
118/// callers that start from raw text (`structured_read`, `ctx_read` aggressive).
119/// `None` for non-JSON or low-redundancy input — the caller keeps its own path.
120pub fn crush_text_if_beneficial(text: &str) -> Option<String> {
121    let trimmed = text.trim();
122    if !trimmed.starts_with('{') && !trimmed.starts_with('[') {
123        return None;
124    }
125    let val: Value = serde_json::from_str(trimmed).ok()?;
126    crush_value_if_beneficial(&val, trimmed.len())
127}
128
129/// Lossy crush of `text`: drops near-unique high-entropy columns (timestamps,
130/// UUIDs — noise for an agent) whose distinct-value ratio is `>= drop_entropy`.
131/// Returns the [`CrushResult`] only when the pass **actually dropped** a column
132/// (`!lossless`, so a lossless pass would not already cover it) AND the compact
133/// form at least halves the input ([`KEEP_DATA_DIVISOR`]). Because data is lost,
134/// the caller MUST persist the verbatim original out-of-band (CCR) before
135/// emitting it — the dropped columns are never reconstructible from the text.
136/// `None` for non-JSON, low-redundancy, or all-lossless input.
137pub fn crush_text_lossy_if_beneficial(text: &str, drop_entropy: f64) -> Option<CrushResult> {
138    let trimmed = text.trim();
139    if !trimmed.starts_with('{') && !trimmed.starts_with('[') {
140        return None;
141    }
142    let val: Value = serde_json::from_str(trimmed).ok()?;
143    let res = crush_lossy(&val, &CrushOpts::lossy(drop_entropy))?;
144    (!res.lossless && res.text.len().saturating_mul(KEEP_DATA_DIVISOR) <= trimmed.len())
145        .then_some(res)
146}
147
148/// Rebuild a `Value` from crushed text. Exact for lossless forms; for lossy
149/// forms the `_dropped` columns are simply absent (recover them via CCR).
150pub fn reconstruct(crushed_text: &str) -> Option<Value> {
151    let v: Value = serde_json::from_str(crushed_text).ok()?;
152    Some(uncrush_node(&v))
153}
154
155fn crush_with(value: &Value, opts: &CrushOpts) -> Option<CrushResult> {
156    if contains_marker(value) {
157        return None;
158    }
159    let crushed = crush_node(value, opts, 0);
160    if !crushed.changed {
161        return None;
162    }
163    let text = serde_json::to_string(&crushed.value).ok()?;
164    Some(CrushResult {
165        text,
166        lossless: crushed.lossless,
167    })
168}
169
170struct Crushed {
171    value: Value,
172    changed: bool,
173    lossless: bool,
174}
175
176impl Crushed {
177    fn unchanged(value: Value) -> Self {
178        Self {
179            value,
180            changed: false,
181            lossless: true,
182        }
183    }
184}
185
186fn crush_node(value: &Value, opts: &CrushOpts, depth: usize) -> Crushed {
187    if depth > opts.max_depth {
188        return Crushed::unchanged(value.clone());
189    }
190    match value {
191        Value::Array(arr) => crush_array(arr, opts, depth),
192        Value::Object(map) => {
193            let mut out = Map::new();
194            let mut changed = false;
195            let mut lossless = true;
196            for (key, val) in map {
197                let child = crush_node(val, opts, depth + 1);
198                changed |= child.changed;
199                lossless &= child.lossless;
200                out.insert(key.clone(), child.value);
201            }
202            Crushed {
203                value: Value::Object(out),
204                changed,
205                lossless,
206            }
207        }
208        other => Crushed::unchanged(other.clone()),
209    }
210}
211
212fn crush_array(arr: &[Value], opts: &CrushOpts, depth: usize) -> Crushed {
213    // Crush each element first, so nested arrays inside items also compact and
214    // factoring compares already-normalized values.
215    let mut items: Vec<Value> = Vec::with_capacity(arr.len());
216    let mut child_changed = false;
217    let mut child_lossless = true;
218    for el in arr {
219        let child = crush_node(el, opts, depth + 1);
220        child_changed |= child.changed;
221        child_lossless &= child.lossless;
222        items.push(child.value);
223    }
224
225    let factorable = items.len() >= MIN_ITEMS
226        && items.len() <= opts.max_items
227        && items.iter().all(Value::is_object);
228    if !factorable {
229        return Crushed {
230            value: Value::Array(items),
231            changed: child_changed,
232            lossless: child_lossless,
233        };
234    }
235
236    // Keys present in EVERY item are the only factoring candidates: an omitted
237    // key would otherwise be indistinguishable from "equals default".
238    let mut candidates: BTreeSet<String> = items[0]
239        .as_object()
240        .map(|o| o.keys().cloned().collect())
241        .unwrap_or_default();
242    for item in &items[1..] {
243        if let Some(obj) = item.as_object() {
244            candidates.retain(|k| obj.contains_key(k));
245        }
246    }
247
248    let n = items.len();
249    let mut defaults = Map::new();
250    let mut dropped: BTreeSet<String> = BTreeSet::new();
251    for key in &candidates {
252        let values: Vec<&Value> = items.iter().filter_map(|it| it.get(key)).collect();
253        let (dominant, dominant_count, distinct) = dominant_value(&values);
254        let entropy = distinct as f64 / n as f64;
255        if opts.drop_entropy < 1.0 && entropy >= opts.drop_entropy {
256            dropped.insert(key.clone());
257            continue;
258        }
259        if dominant_count >= min_dominant_count(n) {
260            defaults.insert(key.clone(), dominant);
261        }
262    }
263
264    if defaults.is_empty() && dropped.is_empty() {
265        return Crushed {
266            value: Value::Array(items),
267            changed: child_changed,
268            lossless: child_lossless,
269        };
270    }
271
272    let new_items: Vec<Value> = items
273        .iter()
274        .map(|item| {
275            let mut slim = Map::new();
276            if let Some(obj) = item.as_object() {
277                for (key, val) in obj {
278                    if dropped.contains(key) {
279                        continue;
280                    }
281                    if defaults.get(key) == Some(val) {
282                        continue;
283                    }
284                    slim.insert(key.clone(), val.clone());
285                }
286            }
287            Value::Object(slim)
288        })
289        .collect();
290
291    let had_drops = !dropped.is_empty();
292
293    let mut crushed = Map::new();
294    crushed.insert(MARKER.to_string(), Value::String(MARKER_ARRAY.to_string()));
295    if !defaults.is_empty() {
296        crushed.insert(DEFAULTS_KEY.to_string(), Value::Object(defaults));
297    }
298    if had_drops {
299        crushed.insert(
300            DROPPED_KEY.to_string(),
301            Value::Array(dropped.into_iter().map(Value::String).collect()),
302        );
303    }
304    crushed.insert(ITEMS_KEY.to_string(), Value::Array(new_items));
305
306    Crushed {
307        value: Value::Object(crushed),
308        changed: true,
309        lossless: child_lossless && !had_drops,
310    }
311}
312
313fn min_dominant_count(n: usize) -> usize {
314    (((n as f64) * MIN_DOMINANCE).ceil() as usize).max(2)
315}
316
317/// Returns the most frequent value, its count, and the distinct-value count.
318/// Frequencies key on the canonical JSON serialization; ties break toward the
319/// lexicographically smallest serialization for determinism.
320fn dominant_value(values: &[&Value]) -> (Value, usize, usize) {
321    let mut freq: BTreeMap<String, (usize, Value)> = BTreeMap::new();
322    for v in values {
323        let key = serde_json::to_string(v).unwrap_or_default();
324        let entry = freq.entry(key).or_insert_with(|| (0, (*v).clone()));
325        entry.0 += 1;
326    }
327    let distinct = freq.len();
328    let mut best_count = 0usize;
329    let mut best_value = Value::Null;
330    for (count, value) in freq.values() {
331        if *count > best_count {
332            best_count = *count;
333            best_value = value.clone();
334        }
335    }
336    (best_value, best_count, distinct)
337}
338
339fn contains_marker(value: &Value) -> bool {
340    match value {
341        Value::Object(map) => map.contains_key(MARKER) || map.values().any(contains_marker),
342        Value::Array(arr) => arr.iter().any(contains_marker),
343        _ => false,
344    }
345}
346
347fn uncrush_node(value: &Value) -> Value {
348    match value {
349        Value::Object(map) => {
350            if map.get(MARKER) == Some(&Value::String(MARKER_ARRAY.to_string())) {
351                let defaults = map.get(DEFAULTS_KEY).and_then(Value::as_object);
352                let items = map.get(ITEMS_KEY).and_then(Value::as_array);
353                let mut out = Vec::new();
354                if let Some(items) = items {
355                    for item in items {
356                        let mut full = Map::new();
357                        if let Some(defaults) = defaults {
358                            for (key, val) in defaults {
359                                full.insert(key.clone(), uncrush_node(val));
360                            }
361                        }
362                        if let Some(obj) = item.as_object() {
363                            for (key, val) in obj {
364                                full.insert(key.clone(), uncrush_node(val));
365                            }
366                        }
367                        out.push(Value::Object(full));
368                    }
369                }
370                Value::Array(out)
371            } else {
372                let mut out = Map::new();
373                for (key, val) in map {
374                    out.insert(key.clone(), uncrush_node(val));
375                }
376                Value::Object(out)
377            }
378        }
379        Value::Array(arr) => Value::Array(arr.iter().map(uncrush_node).collect()),
380        other => other.clone(),
381    }
382}
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387    use serde_json::json;
388
389    fn homogeneous() -> Value {
390        json!([
391            {"status": "success", "region": "eu", "id": 1},
392            {"status": "success", "region": "eu", "id": 2},
393            {"status": "success", "region": "eu", "id": 3},
394            {"status": "success", "region": "eu", "id": 4}
395        ])
396    }
397
398    #[test]
399    fn lossless_factors_constant_columns() {
400        let v = homogeneous();
401        let crushed = crush_lossless(&v).expect("should crush");
402        assert!(crushed.lossless);
403        // Constants appear once in _defaults, not on every item.
404        assert!(crushed.text.contains("_defaults"));
405        assert_eq!(crushed.text.matches("success").count(), 1);
406    }
407
408    #[test]
409    fn lossless_roundtrips_exactly() {
410        let v = homogeneous();
411        let crushed = crush_lossless(&v).unwrap();
412        let restored = reconstruct(&crushed.text).unwrap();
413        assert_eq!(restored, v);
414    }
415
416    #[test]
417    fn output_is_byte_stable_across_calls() {
418        let v = homogeneous();
419        let run = || crush_lossless(&v).unwrap().text;
420        assert_eq!(run(), run(), "crush output must be deterministic (#498)");
421    }
422
423    #[test]
424    fn never_inflates_compressible_payload() {
425        let v = homogeneous();
426        let crushed = crush_lossless(&v).unwrap();
427        let compact = serde_json::to_string(&v).unwrap();
428        assert!(
429            crushed.text.len() < compact.len(),
430            "crushed {} should be shorter than {}",
431            crushed.text.len(),
432            compact.len()
433        );
434    }
435
436    #[test]
437    fn small_or_heterogeneous_arrays_are_skipped() {
438        assert!(crush_lossless(&json!([{"a": 1}, {"a": 2}])).is_none()); // < MIN_ITEMS
439        assert!(crush_lossless(&json!([1, 2, 3, 4])).is_none()); // not objects
440        assert!(crush_lossless(&json!([])).is_none());
441        // No shared identical column -> nothing to factor.
442        assert!(
443            crush_lossless(&json!([
444                {"a": 1}, {"b": 2}, {"c": 3}, {"d": 4}
445            ]))
446            .is_none()
447        );
448    }
449
450    #[test]
451    fn nested_arrays_crush_and_roundtrip() {
452        let v = json!({
453            "total": 2,
454            "data": [
455                {"kind": "node", "ready": true, "name": "a"},
456                {"kind": "node", "ready": true, "name": "b"},
457                {"kind": "node", "ready": true, "name": "c"}
458            ]
459        });
460        let crushed = crush_lossless(&v).unwrap();
461        assert!(crushed.lossless);
462        assert_eq!(reconstruct(&crushed.text).unwrap(), v);
463    }
464
465    #[test]
466    fn unicode_and_escapes_survive_roundtrip() {
467        let v = json!([
468            {"tag": "café", "note": "line\nbreak", "id": 1},
469            {"tag": "café", "note": "quote\"x", "id": 2},
470            {"tag": "café", "note": "tab\tend", "id": 3}
471        ]);
472        let crushed = crush_lossless(&v).unwrap();
473        assert_eq!(reconstruct(&crushed.text).unwrap(), v);
474    }
475
476    #[test]
477    fn dominant_value_factoring_keeps_deviations() {
478        let v = json!([
479            {"state": "ok", "code": 200},
480            {"state": "ok", "code": 200},
481            {"state": "ok", "code": 200},
482            {"state": "err", "code": 500}
483        ]);
484        let crushed = crush_lossless(&v).unwrap();
485        assert!(crushed.lossless);
486        // The minority row keeps its own state/code; majority omit them.
487        assert!(crushed.text.contains("err"));
488        assert_eq!(reconstruct(&crushed.text).unwrap(), v);
489    }
490
491    #[test]
492    fn lossy_drops_high_entropy_columns() {
493        let v = json!([
494            {"status": "ok", "uuid": "a1b2"},
495            {"status": "ok", "uuid": "c3d4"},
496            {"status": "ok", "uuid": "e5f6"},
497            {"status": "ok", "uuid": "g7h8"}
498        ]);
499        let crushed = crush_lossy(&v, &CrushOpts::lossy(0.9)).unwrap();
500        assert!(!crushed.lossless, "dropping a column is lossy");
501        assert!(crushed.text.contains("_dropped"));
502        assert!(!crushed.text.contains("a1b2"));
503        // The lossless columns still round-trip; dropped column is simply gone.
504        let restored = reconstruct(&crushed.text).unwrap();
505        let arr = restored.as_array().unwrap();
506        assert_eq!(arr.len(), 4);
507        assert_eq!(arr[0]["status"], json!("ok"));
508        assert!(arr[0].get("uuid").is_none());
509    }
510
511    #[test]
512    fn lossy_is_byte_stable() {
513        let v = json!([
514            {"status": "ok", "uuid": "a1b2"},
515            {"status": "ok", "uuid": "c3d4"},
516            {"status": "ok", "uuid": "e5f6"},
517            {"status": "ok", "uuid": "g7h8"}
518        ]);
519        let opts = CrushOpts::lossy(0.9);
520        let run = || crush_lossy(&v, &opts).unwrap().text;
521        assert_eq!(run(), run());
522    }
523
524    #[test]
525    fn beneficial_helpers_share_one_core_and_threshold() {
526        // Redundant array -> both gates return the compact, reconstructible form.
527        // Enough rows + constant columns that the compact form clearly halves it.
528        let items: Vec<Value> = (0..16)
529            .map(|i| {
530                json!({"status": "active", "region": "eu-central-1", "tier": "standard", "id": i})
531            })
532            .collect();
533        let v = Value::Array(items);
534        let raw = serde_json::to_string(&v).unwrap();
535        let by_value = crush_value_if_beneficial(&v, raw.len()).expect("value gate crushes");
536        let by_text = crush_text_if_beneficial(&raw).expect("text gate crushes");
537        assert_eq!(by_value, by_text, "both gates use one core + threshold");
538        assert!(by_text.len() * KEEP_DATA_DIVISOR <= raw.len());
539        assert_eq!(reconstruct(&by_text).unwrap(), v);
540
541        // Low-redundancy -> None (caller keeps its own outline/verbatim form).
542        let hetero = r#"[{"id":1,"k":"aaa"},{"id":2,"k":"bbb"},{"id":3,"k":"ccc"}]"#;
543        assert!(crush_text_if_beneficial(hetero).is_none());
544
545        // Non-JSON and bare scalars -> None.
546        assert!(crush_text_if_beneficial("not json").is_none());
547        assert!(crush_text_if_beneficial("\"just a string\"").is_none());
548        assert!(crush_text_if_beneficial("").is_none());
549    }
550
551    #[test]
552    fn lossy_gate_drops_high_entropy_columns_and_flags_lossy() {
553        // A near-unique high-entropy column (`ts`) alongside a constant one:
554        // lossless can factor the constant but the unique `ts` stays per-item, so
555        // lossless may not halve it. The lossy gate drops `ts` and reports lossy.
556        let items: Vec<Value> = (0..40)
557            .map(|i| json!({"status": "ok", "ts": format!("2026-06-22T10:00:{i:02}.{i:09}Z")}))
558            .collect();
559        let raw = serde_json::to_string(&Value::Array(items)).unwrap();
560
561        let res = crush_text_lossy_if_beneficial(&raw, 0.9).expect("lossy gate fires");
562        assert!(!res.lossless, "dropping a column must report lossy");
563        assert!(
564            res.text.contains(DROPPED_KEY),
565            "dropped columns are recorded"
566        );
567        assert!(
568            res.text.len() * KEEP_DATA_DIVISOR <= raw.len(),
569            "must at least halve"
570        );
571
572        // drop_entropy = 1.0 disables dropping -> nothing lossy -> None.
573        assert!(crush_text_lossy_if_beneficial(&raw, 1.0).is_none());
574        // Non-JSON -> None.
575        assert!(crush_text_lossy_if_beneficial("not json", 0.5).is_none());
576    }
577
578    #[test]
579    fn input_with_marker_key_is_left_alone() {
580        let v = json!([
581            {"_lc_crush": "x", "id": 1},
582            {"_lc_crush": "y", "id": 2},
583            {"_lc_crush": "z", "id": 3}
584        ]);
585        assert!(crush_lossless(&v).is_none());
586    }
587}