Skip to main content

lean_ctx_patterns/
json_crush.rs

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