1use serde_json::{Map, Value};
26use std::collections::{BTreeMap, BTreeSet};
27
28const 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
37const MIN_ITEMS: usize = 3;
39const MIN_DOMINANCE: f64 = 0.5;
42
43pub const KEEP_DATA_DIVISOR: usize = 2;
50
51#[derive(Debug, Clone)]
53pub struct CrushResult {
54 pub text: String,
56 pub lossless: bool,
59}
60
61#[derive(Debug, Clone)]
63pub struct CrushOpts {
64 pub drop_entropy: f64,
68 pub max_depth: usize,
70 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 pub fn lossless() -> Self {
87 Self::default()
88 }
89
90 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
100pub fn crush_lossless(value: &Value) -> Option<CrushResult> {
102 crush_with(value, &CrushOpts::lossless())
103}
104
105pub fn crush_lossy(value: &Value, opts: &CrushOpts) -> Option<CrushResult> {
107 crush_with(value, opts)
108}
109
110pub 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
118pub 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
131pub 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
150pub 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 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 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
319fn 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 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()); assert!(crush_lossless(&json!([1, 2, 3, 4])).is_none()); assert!(crush_lossless(&json!([])).is_none());
443 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 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 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 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 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 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 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 assert!(crush_text_lossy_if_beneficial(&raw, 1.0).is_none());
576 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}