1use serde_json::{Map, Value};
24use std::collections::{BTreeMap, BTreeSet};
25
26const 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
35const MIN_ITEMS: usize = 3;
37const MIN_DOMINANCE: f64 = 0.5;
40
41pub const KEEP_DATA_DIVISOR: usize = 2;
48
49#[derive(Debug, Clone)]
51pub struct CrushResult {
52 pub text: String,
54 pub lossless: bool,
57}
58
59#[derive(Debug, Clone)]
61pub struct CrushOpts {
62 pub drop_entropy: f64,
66 pub max_depth: usize,
68 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 pub fn lossless() -> Self {
85 Self::default()
86 }
87
88 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
98pub fn crush_lossless(value: &Value) -> Option<CrushResult> {
100 crush_with(value, &CrushOpts::lossless())
101}
102
103pub fn crush_lossy(value: &Value, opts: &CrushOpts) -> Option<CrushResult> {
105 crush_with(value, opts)
106}
107
108pub 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
116pub 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
129pub 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
148pub 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 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 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
317fn 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 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()); assert!(crush_lossless(&json!([1, 2, 3, 4])).is_none()); assert!(crush_lossless(&json!([])).is_none());
441 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 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 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 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 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 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 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 assert!(crush_text_lossy_if_beneficial(&raw, 1.0).is_none());
574 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}