use std::collections::{BTreeMap, BTreeSet};
use serde_json::{Map, Value};
use crate::csv;
use lean_ctx_patterns::json_crush::CrushResult;
const MARKER: &str = "_lc_tbl";
const ORDER_KEY: &str = "_order";
const CONST_KEY: &str = "_const";
const DROPPED_KEY: &str = "_dropped";
const ROWS_KEY: &str = "_rows";
const MIN_ROWS: usize = 3;
pub fn crush_text_if_beneficial(text: &str, delimiter: char) -> Option<String> {
let res = crush(text, delimiter, 1.0)?;
(res.lossless && beneficial(&res.text, text)).then_some(res.text)
}
pub fn crush_text_lossy_if_beneficial(
text: &str,
delimiter: char,
drop_entropy: f64,
) -> Option<CrushResult> {
let res = crush(text, delimiter, drop_entropy.clamp(0.0, 1.0))?;
(!res.lossless && beneficial(&res.text, text)).then_some(res)
}
const MIN_SAVE_RATIO_NUM: usize = 3;
const MIN_SAVE_RATIO_DEN: usize = 4;
fn beneficial(compact: &str, raw: &str) -> bool {
compact.len().saturating_mul(MIN_SAVE_RATIO_DEN) <= raw.len().saturating_mul(MIN_SAVE_RATIO_NUM)
}
fn crush(text: &str, delimiter: char, drop_entropy: f64) -> Option<CrushResult> {
let head = text.trim_start();
if head.starts_with('{') || head.starts_with('[') {
return None;
}
let rows = csv::parse(text, delimiter);
if rows.len() < MIN_ROWS + 1 {
return None; }
let header = &rows[0];
let ncols = header.len();
if ncols < 2 {
return None; }
let data = &rows[1..];
if data.iter().any(|r| r.len() != ncols) {
return None;
}
let mut header_seen = BTreeSet::new();
if !header.iter().all(|h| header_seen.insert(h.as_str())) {
return None;
}
let n = data.len();
let mut const_map = Map::new();
let mut dropped: Vec<String> = Vec::new();
let mut kept: Vec<usize> = Vec::new();
for (c, name) in header.iter().enumerate() {
let distinct = data
.iter()
.map(|row| row[c].as_str())
.collect::<BTreeSet<_>>()
.len();
if drop_entropy < 1.0 && (distinct as f64 / n as f64) >= drop_entropy {
dropped.push(name.clone());
continue;
}
if distinct == 1 {
const_map.insert(name.clone(), Value::String(data[0][c].clone()));
continue;
}
kept.push(c);
}
let had_drops = !dropped.is_empty();
if const_map.is_empty() && !had_drops {
return None; }
let rows_json: Vec<Value> = data
.iter()
.map(|row| {
Value::Array(
kept.iter()
.map(|&c| Value::String(row[c].clone()))
.collect(),
)
})
.collect();
let mut out = Map::new();
out.insert(MARKER.to_string(), Value::from(1u8));
out.insert(
ORDER_KEY.to_string(),
Value::Array(header.iter().cloned().map(Value::String).collect()),
);
if !const_map.is_empty() {
out.insert(CONST_KEY.to_string(), Value::Object(const_map));
}
if had_drops {
out.insert(
DROPPED_KEY.to_string(),
Value::Array(dropped.into_iter().map(Value::String).collect()),
);
}
out.insert(ROWS_KEY.to_string(), Value::Array(rows_json));
let text_out = serde_json::to_string(&Value::Object(out)).ok()?;
Some(CrushResult {
text: text_out,
lossless: !had_drops,
})
}
pub fn reconstruct(text: &str) -> Option<Vec<Vec<String>>> {
let v: Value = serde_json::from_str(text).ok()?;
let obj = v.as_object()?;
obj.get(MARKER)?;
let order: Vec<String> = obj
.get(ORDER_KEY)?
.as_array()?
.iter()
.map(|x| x.as_str().unwrap_or_default().to_string())
.collect();
let empty = Map::new();
let const_map = obj
.get(CONST_KEY)
.and_then(Value::as_object)
.unwrap_or(&empty);
let dropped: BTreeSet<&str> = obj
.get(DROPPED_KEY)
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(Value::as_str).collect())
.unwrap_or_default();
let rows = obj.get(ROWS_KEY)?.as_array()?;
let out_cols: Vec<&str> = order
.iter()
.map(String::as_str)
.filter(|c| !dropped.contains(c))
.collect();
let varying: Vec<&str> = out_cols
.iter()
.copied()
.filter(|c| !const_map.contains_key(*c))
.collect();
let mut result: Vec<Vec<String>> = Vec::with_capacity(rows.len() + 1);
result.push(out_cols.iter().map(|c| (*c).to_string()).collect());
for row in rows {
let arr = row.as_array()?;
if arr.len() != varying.len() {
return None;
}
let vary_vals: BTreeMap<&str, &str> = varying
.iter()
.copied()
.zip(arr.iter().map(|x| x.as_str().unwrap_or_default()))
.collect();
let mut full = Vec::with_capacity(out_cols.len());
for col in &out_cols {
let cell = const_map
.get(*col)
.and_then(Value::as_str)
.or_else(|| vary_vals.get(col).copied())
.unwrap_or_default();
full.push(cell.to_string());
}
result.push(full);
}
Some(result)
}
#[cfg(test)]
pub mod tests {
use super::*;
fn roster_csv() -> String {
let mut s = String::from("id,name,status,region\n");
for i in 0..16 {
s.push_str(&format!("{i},user{i},active,eu-central-1\n"));
}
s
}
#[test]
fn lossless_factors_constant_columns() {
let csv = roster_csv();
let crushed = crush_text_if_beneficial(&csv, ',').expect("should crush");
assert_eq!(crushed.matches("eu-central-1").count(), 1);
assert_eq!(crushed.matches("active").count(), 1);
assert!(crushed.contains("_const"));
}
#[test]
fn lossless_roundtrips_exactly() {
let csv = roster_csv();
let crushed = crush_text_if_beneficial(&csv, ',').unwrap();
let restored = reconstruct(&crushed).unwrap();
let original = csv::parse(&csv, ',');
assert_eq!(restored, original);
}
#[test]
fn output_is_byte_stable_across_calls() {
let csv = roster_csv();
let run = || crush_text_if_beneficial(&csv, ',').unwrap();
assert_eq!(run(), run(), "crush output must be deterministic (#498)");
}
#[test]
fn never_inflates_and_clears_the_gate() {
let csv = roster_csv();
let crushed = crush_text_if_beneficial(&csv, ',').unwrap();
assert!(crushed.len() < csv.len(), "must never inflate");
assert!(beneficial(&crushed, &csv), "must clear the reduction gate");
}
#[test]
fn tsv_delimiter_is_honoured() {
let mut s = String::from("id\tname\tstatus\tregion\n");
for i in 0..16 {
s.push_str(&format!("{i}\tuser{i}\tactive\teu-central-1\n"));
}
let crushed = crush_text_if_beneficial(&s, '\t').expect("tsv crushes");
assert_eq!(reconstruct(&crushed).unwrap(), csv::parse(&s, '\t'));
}
#[test]
fn quoted_fields_with_embedded_delimiters_roundtrip() {
let mut s = String::from("id,note,status,region\n");
for i in 0..16 {
s.push_str(&format!("{i},\"a, {i} \"\"q\"\"\",active,eu-central-1\n"));
}
let crushed = crush_text_if_beneficial(&s, ',').unwrap();
let restored = reconstruct(&crushed).unwrap();
assert_eq!(restored, csv::parse(&s, ','));
assert_eq!(restored[1][1], "a, 0 \"q\"");
}
#[test]
fn skips_tables_with_nothing_to_factor() {
let mut s = String::from("a,b,c\n");
for i in 0..10 {
s.push_str(&format!("{i},{},{}\n", i + 100, i + 200));
}
assert!(crush_text_if_beneficial(&s, ',').is_none());
}
#[test]
fn skips_ragged_too_small_and_json_input() {
assert!(crush_text_if_beneficial("a,b\n1,ok\n2,ok", ',').is_none()); assert!(crush_text_if_beneficial("a,b\n1,ok\n2\n3,ok\n4,ok", ',').is_none()); assert!(crush_text_if_beneficial("col\n1\n2\n3\n4", ',').is_none()); assert!(crush_text_if_beneficial("[{\"a\":1}]", ',').is_none()); assert!(crush_text_if_beneficial("", ',').is_none());
}
#[test]
fn lossy_drops_high_entropy_columns_and_flags_lossy() {
let mut s = String::from("status,uuid\n");
for i in 0..40 {
s.push_str(&format!("ok,uuid-{i:08}\n"));
}
let res = crush_text_lossy_if_beneficial(&s, ',', 0.9).expect("lossy gate fires");
assert!(!res.lossless, "dropping a column must report lossy");
assert!(res.text.contains("_dropped"));
assert!(
!res.text.contains("uuid-00000000"),
"dropped values are gone"
);
assert!(beneficial(&res.text, &s));
let restored = reconstruct(&res.text).unwrap();
assert_eq!(restored[0], vec!["status"]);
assert_eq!(restored[1], vec!["ok"]);
assert!(crush_text_lossy_if_beneficial(&s, ',', 1.0).is_none());
}
#[test]
fn lossy_is_byte_stable() {
let mut s = String::from("status,uuid\n");
for i in 0..40 {
s.push_str(&format!("ok,uuid-{i:08}\n"));
}
let run = || crush_text_lossy_if_beneficial(&s, ',', 0.9).unwrap().text;
assert_eq!(run(), run());
}
}