Skip to main content

lean_ctx/core/
tabular_crush.rs

1//! Deterministic tabular (CSV/TSV) crusher — columnar redundancy factoring for
2//! delimited data (#982, Headroom tabular-compressor port, GitLab Epic #973).
3//!
4//! Real-world CSV/TSV dumps (DB exports, `psql -A -F,`, analytics extracts) are
5//! dominated by columns that repeat one value on every row (a `status`, `region`,
6//! `tenant` column) and by near-unique noise columns (UUIDs, timestamps). This
7//! module factors that out, mirroring [`crate::core::json_crush`] but for tables:
8//!
9//! - **Lossless**: every *constant* column (exactly one distinct value across all
10//!   data rows) is hoisted once into `_const`; the remaining columns are kept
11//!   positionally in `_rows`, so a value never repeats more than it must. The
12//!   transform is exactly reversible via [`reconstruct`].
13//! - **Lossy**: additionally *drops* near-unique high-entropy columns (recorded
14//!   in `_dropped`); the exact original is recovered out-of-band via CCR, never
15//!   from the text.
16//!
17//! The compact form is serialized with `serde_json` (robust quoting, escapes and
18//! Unicode — no hand-rolled format that could desync the reader), and the output
19//! is a pure function of the input (columns walked in header order, `_const`
20//! keyed deterministically), so identical input yields byte-identical output
21//! (#498). The crusher never inflates: callers gate on the shared
22//! [`crate::core::json_crush::KEEP_DATA_DIVISOR`] threshold and a no-op input returns [`None`].
23
24use std::collections::{BTreeMap, BTreeSet};
25
26use serde_json::{Map, Value};
27
28use crate::core::extractors::csv;
29use crate::core::json_crush::CrushResult;
30
31/// Marks a crushed-table document. Vanishingly unlikely as a real top-level CSV
32/// shape (the input is delimited text, never a JSON object), so [`reconstruct`]
33/// can identify our own output unambiguously.
34const MARKER: &str = "_lc_tbl";
35/// Full header, in original column order — the source of truth for reconstructing
36/// each row's column sequence.
37const ORDER_KEY: &str = "_order";
38/// Columns hoisted to their single repeated value (lossless).
39const CONST_KEY: &str = "_const";
40/// Columns dropped as high-entropy noise (lossy; recover via CCR).
41const DROPPED_KEY: &str = "_dropped";
42/// Per-row values for the *varying, kept* columns, positional in header order.
43const ROWS_KEY: &str = "_rows";
44
45/// Below this data-row count, columnar factoring rarely beats its own overhead.
46const MIN_ROWS: usize = 3;
47
48/// Lossless columnar crush of delimited `text`, returning the compact JSON form
49/// only when it clears the `beneficial` reduction gate. `None` for non-tabular,
50/// ragged, or low-redundancy input — the caller keeps its own path.
51pub fn crush_text_if_beneficial(text: &str, delimiter: char) -> Option<String> {
52    let res = crush(text, delimiter, 1.0)?;
53    (res.lossless && beneficial(&res.text, text)).then_some(res.text)
54}
55
56/// Lossy columnar crush of `text`: drops near-unique high-entropy columns whose
57/// distinct-value ratio is `>= drop_entropy`. Returns the [`CrushResult`] only
58/// when a column was **actually dropped** (`!lossless`) AND the compact form at
59/// least halves the input ([`crate::core::json_crush::KEEP_DATA_DIVISOR`]). Because data is then lost, the
60/// caller MUST persist the verbatim original out-of-band (CCR) before emitting —
61/// the dropped columns are never reconstructible from the text. `None` for
62/// non-tabular, low-redundancy, or all-lossless input.
63pub fn crush_text_lossy_if_beneficial(
64    text: &str,
65    delimiter: char,
66    drop_entropy: f64,
67) -> Option<CrushResult> {
68    let res = crush(text, delimiter, drop_entropy.clamp(0.0, 1.0))?;
69    (!res.lossless && beneficial(&res.text, text)).then_some(res)
70}
71
72/// Tabular reshaping into the columnar JSON form carries per-cell quoting
73/// overhead the JSON crusher's array-of-objects form does not, so the columnar
74/// win (eliminating constant columns + dropping noise) is gated on a 1/4 byte
75/// reduction rather than the JSON crusher's stricter halving — still a clear,
76/// never-inflating win whose exact data stays reconstructible via [`reconstruct`].
77const MIN_SAVE_RATIO_NUM: usize = 3;
78const MIN_SAVE_RATIO_DEN: usize = 4;
79
80fn beneficial(compact: &str, raw: &str) -> bool {
81    compact.len().saturating_mul(MIN_SAVE_RATIO_DEN) <= raw.len().saturating_mul(MIN_SAVE_RATIO_NUM)
82}
83
84/// Core crush. `drop_entropy < 1.0` enables lossy column dropping. Returns `None`
85/// unless the input is a well-formed table with at least one factorable column.
86fn crush(text: &str, delimiter: char, drop_entropy: f64) -> Option<CrushResult> {
87    // JSON is the JSON crusher's job; never treat it as a degenerate one-column
88    // table (and our own output starts with `{`, so this is also re-entry-safe).
89    let head = text.trim_start();
90    if head.starts_with('{') || head.starts_with('[') {
91        return None;
92    }
93
94    let rows = csv::parse(text, delimiter);
95    if rows.len() < MIN_ROWS + 1 {
96        return None; // need a header + >= MIN_ROWS data rows
97    }
98    let header = &rows[0];
99    let ncols = header.len();
100    if ncols < 2 {
101        return None; // a single column has nothing to factor across
102    }
103    let data = &rows[1..];
104
105    // Only rectangular tables: a ragged row makes positional reconstruction
106    // ambiguous, so fall through to the generic path.
107    if data.iter().any(|r| r.len() != ncols) {
108        return None;
109    }
110    // Distinct headers only: a duplicate name would alias in keyed `_const`.
111    let mut header_seen = BTreeSet::new();
112    if !header.iter().all(|h| header_seen.insert(h.as_str())) {
113        return None;
114    }
115
116    let n = data.len();
117    let mut const_map = Map::new();
118    let mut dropped: Vec<String> = Vec::new();
119    let mut kept: Vec<usize> = Vec::new();
120
121    for (c, name) in header.iter().enumerate() {
122        let distinct = data
123            .iter()
124            .map(|row| row[c].as_str())
125            .collect::<BTreeSet<_>>()
126            .len();
127        if drop_entropy < 1.0 && (distinct as f64 / n as f64) >= drop_entropy {
128            dropped.push(name.clone());
129            continue;
130        }
131        if distinct == 1 {
132            const_map.insert(name.clone(), Value::String(data[0][c].clone()));
133            continue;
134        }
135        kept.push(c);
136    }
137
138    let had_drops = !dropped.is_empty();
139    if const_map.is_empty() && !had_drops {
140        return None; // nothing factored — leave the table to the generic path
141    }
142
143    let rows_json: Vec<Value> = data
144        .iter()
145        .map(|row| {
146            Value::Array(
147                kept.iter()
148                    .map(|&c| Value::String(row[c].clone()))
149                    .collect(),
150            )
151        })
152        .collect();
153
154    let mut out = Map::new();
155    out.insert(MARKER.to_string(), Value::from(1u8));
156    out.insert(
157        ORDER_KEY.to_string(),
158        Value::Array(header.iter().cloned().map(Value::String).collect()),
159    );
160    if !const_map.is_empty() {
161        out.insert(CONST_KEY.to_string(), Value::Object(const_map));
162    }
163    if had_drops {
164        out.insert(
165            DROPPED_KEY.to_string(),
166            Value::Array(dropped.into_iter().map(Value::String).collect()),
167        );
168    }
169    out.insert(ROWS_KEY.to_string(), Value::Array(rows_json));
170
171    let text_out = serde_json::to_string(&Value::Object(out)).ok()?;
172    Some(CrushResult {
173        text: text_out,
174        lossless: !had_drops,
175    })
176}
177
178/// Rebuild the parsed rows (`[header, ..data]`) from crushed `text`. Exact for
179/// lossless forms; for lossy forms the `_dropped` columns are simply absent from
180/// the header and every row (recover them via CCR). `None` if `text` is not a
181/// tabular-crush document or is internally inconsistent.
182pub fn reconstruct(text: &str) -> Option<Vec<Vec<String>>> {
183    let v: Value = serde_json::from_str(text).ok()?;
184    let obj = v.as_object()?;
185    obj.get(MARKER)?;
186
187    let order: Vec<String> = obj
188        .get(ORDER_KEY)?
189        .as_array()?
190        .iter()
191        .map(|x| x.as_str().unwrap_or_default().to_string())
192        .collect();
193    let empty = Map::new();
194    let const_map = obj
195        .get(CONST_KEY)
196        .and_then(Value::as_object)
197        .unwrap_or(&empty);
198    let dropped: BTreeSet<&str> = obj
199        .get(DROPPED_KEY)
200        .and_then(Value::as_array)
201        .map(|a| a.iter().filter_map(Value::as_str).collect())
202        .unwrap_or_default();
203    let rows = obj.get(ROWS_KEY)?.as_array()?;
204
205    // Output columns keep original order minus dropped; the positional `_rows`
206    // columns are output columns minus the hoisted constants — exactly the
207    // `kept` order the crush emitted.
208    let out_cols: Vec<&str> = order
209        .iter()
210        .map(String::as_str)
211        .filter(|c| !dropped.contains(c))
212        .collect();
213    let varying: Vec<&str> = out_cols
214        .iter()
215        .copied()
216        .filter(|c| !const_map.contains_key(*c))
217        .collect();
218
219    let mut result: Vec<Vec<String>> = Vec::with_capacity(rows.len() + 1);
220    result.push(out_cols.iter().map(|c| (*c).to_string()).collect());
221
222    for row in rows {
223        let arr = row.as_array()?;
224        if arr.len() != varying.len() {
225            return None;
226        }
227        let vary_vals: BTreeMap<&str, &str> = varying
228            .iter()
229            .copied()
230            .zip(arr.iter().map(|x| x.as_str().unwrap_or_default()))
231            .collect();
232        let mut full = Vec::with_capacity(out_cols.len());
233        for col in &out_cols {
234            let cell = const_map
235                .get(*col)
236                .and_then(Value::as_str)
237                .or_else(|| vary_vals.get(col).copied())
238                .unwrap_or_default();
239            full.push(cell.to_string());
240        }
241        result.push(full);
242    }
243    Some(result)
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249
250    /// A redundant roster: two constant columns (`status`, `region`) and two
251    /// varying ones (`id`, `name`) over enough rows to clearly pay.
252    fn roster_csv() -> String {
253        let mut s = String::from("id,name,status,region\n");
254        for i in 0..16 {
255            s.push_str(&format!("{i},user{i},active,eu-central-1\n"));
256        }
257        s
258    }
259
260    #[test]
261    fn lossless_factors_constant_columns() {
262        let csv = roster_csv();
263        let crushed = crush_text_if_beneficial(&csv, ',').expect("should crush");
264        // Each constant value appears once, not on every row.
265        assert_eq!(crushed.matches("eu-central-1").count(), 1);
266        assert_eq!(crushed.matches("active").count(), 1);
267        assert!(crushed.contains("_const"));
268    }
269
270    #[test]
271    fn lossless_roundtrips_exactly() {
272        let csv = roster_csv();
273        let crushed = crush_text_if_beneficial(&csv, ',').unwrap();
274        let restored = reconstruct(&crushed).unwrap();
275        let original = csv::parse(&csv, ',');
276        assert_eq!(restored, original);
277    }
278
279    #[test]
280    fn output_is_byte_stable_across_calls() {
281        let csv = roster_csv();
282        let run = || crush_text_if_beneficial(&csv, ',').unwrap();
283        assert_eq!(run(), run(), "crush output must be deterministic (#498)");
284    }
285
286    #[test]
287    fn never_inflates_and_clears_the_gate() {
288        let csv = roster_csv();
289        let crushed = crush_text_if_beneficial(&csv, ',').unwrap();
290        assert!(crushed.len() < csv.len(), "must never inflate");
291        assert!(beneficial(&crushed, &csv), "must clear the reduction gate");
292    }
293
294    #[test]
295    fn tsv_delimiter_is_honoured() {
296        let mut s = String::from("id\tname\tstatus\tregion\n");
297        for i in 0..16 {
298            s.push_str(&format!("{i}\tuser{i}\tactive\teu-central-1\n"));
299        }
300        let crushed = crush_text_if_beneficial(&s, '\t').expect("tsv crushes");
301        assert_eq!(reconstruct(&crushed).unwrap(), csv::parse(&s, '\t'));
302    }
303
304    #[test]
305    fn quoted_fields_with_embedded_delimiters_roundtrip() {
306        // `note` varies and carries embedded commas + escaped quotes, so it stays
307        // a varying cell in `_rows`; the constant columns make the crush pay.
308        let mut s = String::from("id,note,status,region\n");
309        for i in 0..16 {
310            s.push_str(&format!("{i},\"a, {i} \"\"q\"\"\",active,eu-central-1\n"));
311        }
312        let crushed = crush_text_if_beneficial(&s, ',').unwrap();
313        let restored = reconstruct(&crushed).unwrap();
314        assert_eq!(restored, csv::parse(&s, ','));
315        // The embedded-delimiter, escaped-quote value survived verbatim.
316        assert_eq!(restored[1][1], "a, 0 \"q\"");
317    }
318
319    #[test]
320    fn skips_tables_with_nothing_to_factor() {
321        // Every column varies on every row -> no constant to hoist.
322        let mut s = String::from("a,b,c\n");
323        for i in 0..10 {
324            s.push_str(&format!("{i},{},{}\n", i + 100, i + 200));
325        }
326        assert!(crush_text_if_beneficial(&s, ',').is_none());
327    }
328
329    #[test]
330    fn skips_ragged_too_small_and_json_input() {
331        assert!(crush_text_if_beneficial("a,b\n1,ok\n2,ok", ',').is_none()); // < MIN_ROWS
332        assert!(crush_text_if_beneficial("a,b\n1,ok\n2\n3,ok\n4,ok", ',').is_none()); // ragged
333        assert!(crush_text_if_beneficial("col\n1\n2\n3\n4", ',').is_none()); // single column
334        assert!(crush_text_if_beneficial("[{\"a\":1}]", ',').is_none()); // JSON
335        assert!(crush_text_if_beneficial("", ',').is_none());
336    }
337
338    #[test]
339    fn lossy_drops_high_entropy_columns_and_flags_lossy() {
340        // A near-unique `uuid` column alongside a constant `status` column.
341        let mut s = String::from("status,uuid\n");
342        for i in 0..40 {
343            s.push_str(&format!("ok,uuid-{i:08}\n"));
344        }
345        let res = crush_text_lossy_if_beneficial(&s, ',', 0.9).expect("lossy gate fires");
346        assert!(!res.lossless, "dropping a column must report lossy");
347        assert!(res.text.contains("_dropped"));
348        assert!(
349            !res.text.contains("uuid-00000000"),
350            "dropped values are gone"
351        );
352        assert!(beneficial(&res.text, &s));
353
354        // The kept columns still reconstruct; the dropped one is simply absent.
355        let restored = reconstruct(&res.text).unwrap();
356        assert_eq!(restored[0], vec!["status"]);
357        assert_eq!(restored[1], vec!["ok"]);
358
359        // drop_entropy = 1.0 disables dropping -> nothing lossy -> None.
360        assert!(crush_text_lossy_if_beneficial(&s, ',', 1.0).is_none());
361    }
362
363    #[test]
364    fn lossy_is_byte_stable() {
365        let mut s = String::from("status,uuid\n");
366        for i in 0..40 {
367            s.push_str(&format!("ok,uuid-{i:08}\n"));
368        }
369        let run = || crush_text_lossy_if_beneficial(&s, ',', 0.9).unwrap().text;
370        assert_eq!(run(), run());
371    }
372}