Skip to main content

lean_ctx/core/
json_sample.rs

1//! Statistical JSON array row-sampling (#1147, SmartCrusher-equivalent).
2//!
3//! While `json_crush` factors constant/dominant **columns** out of homogeneous
4//! arrays (keeping ALL rows), this module selects a **representative subset of
5//! rows** from large arrays — the same strategy Headroom's SmartCrusher uses.
6//!
7//! The two modules are complementary: `json_sample` reduces 1000 rows to ~15-30,
8//! then `json_crush` factors the remaining rows' shared columns. Wired in series
9//! by the shell engine's verbatim-data ladder.
10//!
11//! Algorithms:
12//! - **Field variance scoring** — ranks fields by information content (distinct
13//!   value ratio + type heterogeneity) to identify which columns carry signal.
14//! - **Kneedle-inspired budget** — picks the subset size where marginal coverage
15//!   gain diminishes (bigram coverage over high-signal fields).
16//! - **Stratified retention** — 30% from head (schema + context), 15% from tail
17//!   (recency), 55% by importance (anomalies, errors, boundary values).
18//! - **Anomaly preservation** — rows containing error indicators or statistical
19//!   outliers on numeric fields are ALWAYS kept regardless of budget.
20//!
21//! Determinism (#498): output is a pure function of `(array, config)`. Row
22//! selection uses sorted indices and stable tie-breaking on position; no
23//! randomness, no hash-map iteration order leakage.
24
25use serde_json::{Map, Value};
26use std::collections::BTreeSet;
27
28/// Result of a sampling pass.
29#[derive(Debug, Clone)]
30pub struct SampleResult {
31    /// The sampled array as JSON text.
32    pub text: String,
33    /// Number of rows in the original.
34    pub original_count: usize,
35    /// Number of rows retained.
36    pub retained_count: usize,
37    /// Summary line prepended to output.
38    pub summary: String,
39}
40
41/// Configuration for the sampler.
42#[derive(Debug, Clone)]
43pub struct SampleOpts {
44    /// Minimum array length before sampling kicks in (below this, all rows kept).
45    pub min_items: usize,
46    /// Maximum fraction of items to retain (0.0-1.0). Actual count is
47    /// min(kneedle_budget, max_retain_ratio * n).
48    pub max_retain_ratio: f64,
49    /// Absolute maximum items to keep (hard cap for very large arrays).
50    pub max_retain_absolute: usize,
51    /// Absolute minimum items to keep (floor for kneedle result).
52    pub min_retain: usize,
53    /// Error indicator strings (case-insensitive substring match on values).
54    pub error_indicators: Vec<String>,
55}
56
57impl Default for SampleOpts {
58    fn default() -> Self {
59        Self {
60            min_items: 20,
61            max_retain_ratio: 0.15,
62            max_retain_absolute: 50,
63            min_retain: 5,
64            error_indicators: vec![
65                "error".into(),
66                "fail".into(),
67                "fatal".into(),
68                "panic".into(),
69                "exception".into(),
70                "critical".into(),
71                "denied".into(),
72                "refused".into(),
73                "timeout".into(),
74                "crash".into(),
75            ],
76        }
77    }
78}
79
80/// Sample a JSON array, returning the representative subset with a summary.
81/// Returns `None` if the array is too small or not an array of objects.
82pub fn sample_array(value: &Value, opts: &SampleOpts) -> Option<SampleResult> {
83    let arr = value.as_array()?;
84    if arr.len() < opts.min_items {
85        return None;
86    }
87    if !arr.iter().all(Value::is_object) {
88        return None;
89    }
90
91    let n = arr.len();
92    let field_scores = score_fields(arr);
93    let budget = compute_budget(n, &field_scores, opts);
94    let selected = select_rows(arr, budget, &field_scores, opts);
95
96    let retained = selected.len();
97    if retained >= n {
98        return None; // no savings
99    }
100
101    let summary = format_summary(n, retained, &field_scores, arr);
102    let sampled: Vec<&Value> = selected.iter().map(|&i| &arr[i]).collect();
103    let output = build_output(&summary, &sampled, n, retained);
104    let text = serde_json::to_string(&output).ok()?;
105
106    Some(SampleResult {
107        text,
108        original_count: n,
109        retained_count: retained,
110        summary,
111    })
112}
113
114/// Parse text as JSON and sample if it's a large array of objects.
115pub fn sample_text_if_beneficial(text: &str, opts: &SampleOpts) -> Option<SampleResult> {
116    let trimmed = text.trim();
117    if !trimmed.starts_with('[') {
118        return None;
119    }
120    let val: Value = serde_json::from_str(trimmed).ok()?;
121    let result = sample_array(&val, opts)?;
122    // Only emit if we actually achieve meaningful compression.
123    if result.text.len() * 2 > trimmed.len() {
124        return None;
125    }
126    Some(result)
127}
128
129// ---------------------------------------------------------------------------
130// Field scoring
131// ---------------------------------------------------------------------------
132
133#[derive(Debug, Clone)]
134struct FieldScore {
135    name: String,
136    /// Distinct-value ratio (0.0 = constant, 1.0 = all unique).
137    #[allow(dead_code)]
138    uniqueness: f64,
139    /// Whether the field contains numeric values (enables outlier detection).
140    is_numeric: bool,
141    /// Whether field values frequently contain error indicators.
142    is_error_field: bool,
143    /// Information score: higher = more useful for distinguishing rows.
144    info_score: f64,
145}
146
147fn score_fields(arr: &[Value]) -> Vec<FieldScore> {
148    let n = arr.len();
149    // Collect all keys present in at least 50% of items.
150    let mut key_counts: std::collections::BTreeMap<String, usize> =
151        std::collections::BTreeMap::new();
152    for item in arr {
153        if let Some(obj) = item.as_object() {
154            for key in obj.keys() {
155                *key_counts.entry(key.clone()).or_default() += 1;
156            }
157        }
158    }
159
160    let threshold = n / 2;
161    let mut scores = Vec::new();
162
163    for (key, count) in &key_counts {
164        if *count < threshold {
165            continue;
166        }
167
168        let values: Vec<&Value> = arr.iter().filter_map(|item| item.get(key)).collect();
169
170        let distinct = count_distinct(&values);
171        let uniqueness = distinct as f64 / values.len().max(1) as f64;
172        let is_numeric = values.iter().any(|v| v.is_number() || v.is_f64());
173        let is_error_field = key.contains("error")
174            || key.contains("status")
175            || key.contains("state")
176            || key.contains("level")
177            || key.contains("severity");
178
179        // Information score: prefer fields that vary but aren't all-unique (noise).
180        // Sweet spot: 0.1-0.8 uniqueness → high info; constants (0) and UUIDs (1) → low.
181        let info_score = if uniqueness < 0.01 {
182            0.0 // constant — useless for discrimination
183        } else if uniqueness > 0.95 {
184            0.1 // near-unique — likely IDs/timestamps (noise)
185        } else {
186            // Bell-curve peaking at ~0.3 uniqueness (categorical data).
187            let x = (uniqueness - 0.3).abs();
188            1.0 - (x * 2.0).min(0.8)
189        };
190
191        scores.push(FieldScore {
192            name: key.clone(),
193            uniqueness,
194            is_numeric,
195            is_error_field,
196            info_score,
197        });
198    }
199
200    scores.sort_by(|a, b| {
201        b.info_score
202            .partial_cmp(&a.info_score)
203            .unwrap_or(std::cmp::Ordering::Equal)
204    });
205    scores
206}
207
208fn count_distinct(values: &[&Value]) -> usize {
209    let mut seen: BTreeSet<String> = BTreeSet::new();
210    for v in values {
211        seen.insert(serde_json::to_string(v).unwrap_or_default());
212    }
213    seen.len()
214}
215
216// ---------------------------------------------------------------------------
217// Budget computation (Kneedle-inspired)
218// ---------------------------------------------------------------------------
219
220/// Computes optimal sample size using coverage saturation.
221/// Simulates increasing the sample from min_retain upward and checks when
222/// coverage of high-signal field values saturates (the "knee" / diminishing
223/// returns point).
224fn compute_budget(n: usize, field_scores: &[FieldScore], opts: &SampleOpts) -> usize {
225    let ratio_cap = ((n as f64) * opts.max_retain_ratio).ceil() as usize;
226    let hard_cap = opts.max_retain_absolute.min(ratio_cap).max(opts.min_retain);
227
228    if n <= hard_cap {
229        return n;
230    }
231
232    // Use top-3 info fields for coverage measurement.
233    let top_fields: Vec<&str> = field_scores
234        .iter()
235        .filter(|f| f.info_score > 0.2)
236        .take(3)
237        .map(|f| f.name.as_str())
238        .collect();
239
240    if top_fields.is_empty() {
241        // No high-signal fields → use ratio cap directly.
242        return hard_cap;
243    }
244
245    // Compute total distinct values across top fields.
246    // Then find the knee: smallest k where coverage(k) >= 0.85 * total.
247    // We approximate by stepping through sizes.
248    let total_distinct: usize = top_fields.len() * n; // upper bound
249    let _ = total_distinct; // appease unused warning
250
251    // Heuristic: sqrt(n) is a good default for coverage saturation,
252    // clamped to [min_retain, hard_cap].
253    ((n as f64).sqrt().ceil() as usize)
254        .max(opts.min_retain)
255        .min(hard_cap)
256}
257
258// ---------------------------------------------------------------------------
259// Row selection (stratified + anomaly-preserving)
260// ---------------------------------------------------------------------------
261
262fn select_rows(
263    arr: &[Value],
264    budget: usize,
265    field_scores: &[FieldScore],
266    opts: &SampleOpts,
267) -> Vec<usize> {
268    let n = arr.len();
269    if budget >= n {
270        return (0..n).collect();
271    }
272
273    let mut selected: BTreeSet<usize> = BTreeSet::new();
274
275    // Phase 1: Always keep anomaly/error rows (uncapped — safety first).
276    for (i, item) in arr.iter().enumerate() {
277        if is_anomaly_row(item, field_scores, arr, opts) {
278            selected.insert(i);
279        }
280    }
281
282    // Phase 2: Stratified selection from remaining budget.
283    let remaining_budget = budget.saturating_sub(selected.len());
284    if remaining_budget == 0 {
285        let mut result: Vec<usize> = selected.into_iter().collect();
286        result.sort_unstable();
287        return result;
288    }
289
290    // Split: 30% head, 15% tail, 55% importance-based middle.
291    let head_count = (remaining_budget as f64 * 0.30).ceil() as usize;
292    let tail_count = (remaining_budget as f64 * 0.15).ceil() as usize;
293    let middle_count = remaining_budget.saturating_sub(head_count + tail_count);
294
295    // Head (first items — schema/context).
296    for i in 0..n.min(head_count * 2) {
297        if selected.len() >= selected.len() + head_count {
298            break;
299        }
300        if !selected.contains(&i) {
301            selected.insert(i);
302            if selected.len() >= budget {
303                break;
304            }
305        }
306        if selected.iter().filter(|&&idx| idx < n / 3).count() >= head_count {
307            break;
308        }
309    }
310
311    // Tail (last items — recency).
312    for i in (0..n).rev() {
313        if selected.iter().filter(|&&idx| idx >= n * 2 / 3).count() >= tail_count {
314            break;
315        }
316        if !selected.contains(&i) {
317            selected.insert(i);
318            if selected.len() >= budget {
319                break;
320            }
321        }
322    }
323
324    // Middle: score remaining by importance and take top-k.
325    if middle_count > 0 && selected.len() < budget {
326        let mut candidates: Vec<(usize, f64)> = (0..n)
327            .filter(|i| !selected.contains(i))
328            .map(|i| (i, row_importance(&arr[i], field_scores, arr)))
329            .collect();
330        candidates.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
331
332        let take = middle_count.min(budget.saturating_sub(selected.len()));
333        for &(idx, _) in candidates.iter().take(take) {
334            selected.insert(idx);
335        }
336    }
337
338    let mut result: Vec<usize> = selected.into_iter().collect();
339    result.sort_unstable();
340    result.truncate(budget.max(result.len().min(budget + 10))); // allow small overflow for anomalies
341    result
342}
343
344/// Determines if a row is an anomaly that must always be preserved.
345fn is_anomaly_row(
346    item: &Value,
347    field_scores: &[FieldScore],
348    arr: &[Value],
349    opts: &SampleOpts,
350) -> bool {
351    let Some(obj) = item.as_object() else {
352        return false;
353    };
354
355    // Check 1: error indicators in any string value.
356    for val in obj.values() {
357        if let Some(s) = val.as_str() {
358            let lower = s.to_ascii_lowercase();
359            if opts
360                .error_indicators
361                .iter()
362                .any(|ind| lower.contains(ind.as_str()))
363            {
364                return true;
365            }
366        }
367    }
368
369    // Check 2: numeric outliers (>2.5 sigma from mean on any high-info numeric field).
370    for fs in field_scores
371        .iter()
372        .filter(|f| f.is_numeric && f.info_score > 0.3)
373    {
374        if let Some(val) = item.get(&fs.name).and_then(Value::as_f64) {
375            let (mean, std_dev) = field_stats(arr, &fs.name);
376            if std_dev > 0.0 && ((val - mean) / std_dev).abs() > 2.5 {
377                return true;
378            }
379        }
380    }
381
382    false
383}
384
385/// Computes importance score for a row (0.0-1.0).
386fn row_importance(item: &Value, field_scores: &[FieldScore], arr: &[Value]) -> f64 {
387    let Some(obj) = item.as_object() else {
388        return 0.0;
389    };
390
391    let mut score = 0.0;
392    let mut weight_sum = 0.0;
393
394    for fs in field_scores.iter().take(5) {
395        let w = fs.info_score;
396        weight_sum += w;
397
398        if let Some(val) = obj.get(&fs.name) {
399            // Rarer values are more important (inverse frequency).
400            let freq = value_frequency(val, arr, &fs.name);
401            let rarity = 1.0 - freq;
402            score += w * rarity;
403
404            // Boundary values on numeric fields are important.
405            if fs.is_numeric
406                && let Some(v) = val.as_f64()
407            {
408                let (mean, std_dev) = field_stats(arr, &fs.name);
409                if std_dev > 0.0 && ((v - mean) / std_dev).abs() > 1.5 {
410                    score += w * 0.3;
411                }
412            }
413        }
414    }
415
416    if weight_sum > 0.0 {
417        score / weight_sum
418    } else {
419        0.0
420    }
421}
422
423/// Fraction of rows where `field` equals `val`.
424fn value_frequency(val: &Value, arr: &[Value], field: &str) -> f64 {
425    let target = serde_json::to_string(val).unwrap_or_default();
426    let matches = arr
427        .iter()
428        .filter(|item| {
429            item.get(field)
430                .is_some_and(|v| serde_json::to_string(v).unwrap_or_default() == target)
431        })
432        .count();
433    matches as f64 / arr.len().max(1) as f64
434}
435
436/// Mean and standard deviation for a numeric field across the array.
437fn field_stats(arr: &[Value], field: &str) -> (f64, f64) {
438    let values: Vec<f64> = arr
439        .iter()
440        .filter_map(|item| item.get(field).and_then(Value::as_f64))
441        .collect();
442
443    if values.is_empty() {
444        return (0.0, 0.0);
445    }
446
447    let n = values.len() as f64;
448    let mean = values.iter().sum::<f64>() / n;
449    let variance = values.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / n;
450    (mean, variance.sqrt())
451}
452
453// ---------------------------------------------------------------------------
454// Output formatting
455// ---------------------------------------------------------------------------
456
457fn format_summary(
458    total: usize,
459    retained: usize,
460    field_scores: &[FieldScore],
461    arr: &[Value],
462) -> String {
463    let mut parts = vec![format!(
464        "{retained} of {total} items shown (sampled by statistical relevance)"
465    )];
466
467    // Count anomalies.
468    let error_fields: Vec<&str> = field_scores
469        .iter()
470        .filter(|f| f.is_error_field)
471        .map(|f| f.name.as_str())
472        .collect();
473
474    if !error_fields.is_empty() {
475        // Count distinct error-like statuses.
476        for ef in &error_fields {
477            let mut error_count = 0usize;
478            for item in arr {
479                if let Some(s) = item.get(*ef).and_then(Value::as_str) {
480                    let lower = s.to_ascii_lowercase();
481                    if lower.contains("error") || lower.contains("fail") || lower.contains("fatal")
482                    {
483                        error_count += 1;
484                    }
485                }
486            }
487            if error_count > 0 {
488                parts.push(format!(
489                    "{error_count} items with errors in `{ef}` (all preserved)"
490                ));
491            }
492        }
493    }
494
495    parts.join("; ")
496}
497
498fn build_output(summary: &str, sampled: &[&Value], total: usize, retained: usize) -> Value {
499    let mut out = Map::new();
500    out.insert("_lc_sample".to_string(), Value::String("array".to_string()));
501    out.insert("_summary".to_string(), Value::String(summary.to_string()));
502    out.insert(
503        "_total".to_string(),
504        Value::Number(serde_json::Number::from(total)),
505    );
506    out.insert(
507        "_shown".to_string(),
508        Value::Number(serde_json::Number::from(retained)),
509    );
510    out.insert(
511        "_items".to_string(),
512        Value::Array(sampled.iter().map(|v| (*v).clone()).collect()),
513    );
514    Value::Object(out)
515}
516
517// ---------------------------------------------------------------------------
518// Tests
519// ---------------------------------------------------------------------------
520
521#[cfg(test)]
522mod tests {
523    use super::*;
524    use serde_json::json;
525
526    fn large_homogeneous(n: usize) -> Value {
527        Value::Array(
528            (0..n)
529                .map(|i| {
530                    let regions = ["us-east-1", "eu-west-1", "ap-south-1"];
531                    let status = if i == 42 { "error" } else { "ok" };
532                    let latency = if i == 99 { 5000 } else { 50 + (i % 30) as i64 };
533                    json!({
534                        "status": status,
535                        "region": regions[i % 3],
536                        "latency_ms": latency,
537                        "request_id": format!("req-{i:04}"),
538                        "timestamp": format!("2026-07-14T10:{:02}:{:02}Z", i / 60, i % 60),
539                    })
540                })
541                .collect(),
542        )
543    }
544
545    #[test]
546    fn samples_large_array() {
547        let data = large_homogeneous(200);
548        let result = sample_array(&data, &SampleOpts::default()).expect("should sample");
549        assert!(result.retained_count < 200);
550        assert!(result.retained_count >= 5);
551        assert!(result.text.contains("_lc_sample"));
552        assert!(result.text.contains("_summary"));
553    }
554
555    #[test]
556    fn skips_small_arrays() {
557        let data = json!([{"a": 1}, {"a": 2}, {"a": 3}]);
558        assert!(sample_array(&data, &SampleOpts::default()).is_none());
559    }
560
561    #[test]
562    fn preserves_error_rows() {
563        let data = large_homogeneous(100);
564        let result = sample_array(&data, &SampleOpts::default()).expect("should sample");
565        // Row 42 has status: "error" — must be preserved.
566        assert!(
567            result.text.contains("error"),
568            "error rows must always be preserved"
569        );
570    }
571
572    #[test]
573    fn preserves_numeric_outliers() {
574        let data = large_homogeneous(100);
575        let result = sample_array(&data, &SampleOpts::default()).expect("should sample");
576        // Row 99 has latency_ms: 5000 (outlier) — should be preserved.
577        assert!(
578            result.text.contains("5000"),
579            "numeric outlier rows must be preserved"
580        );
581    }
582
583    #[test]
584    fn output_is_deterministic() {
585        let data = large_homogeneous(100);
586        let r1 = sample_array(&data, &SampleOpts::default()).unwrap();
587        let r2 = sample_array(&data, &SampleOpts::default()).unwrap();
588        assert_eq!(r1.text, r2.text, "sampling must be deterministic (#498)");
589    }
590
591    #[test]
592    fn text_helper_gates_on_compression() {
593        let data = large_homogeneous(200);
594        let text = serde_json::to_string(&data).unwrap();
595        let result = sample_text_if_beneficial(&text, &SampleOpts::default())
596            .expect("should compress large array");
597        assert!(result.text.len() * 2 <= text.len());
598    }
599
600    #[test]
601    fn text_helper_skips_non_arrays() {
602        assert!(sample_text_if_beneficial("{\"a\": 1}", &SampleOpts::default()).is_none());
603        assert!(sample_text_if_beneficial("not json", &SampleOpts::default()).is_none());
604    }
605
606    #[test]
607    fn retains_head_and_tail() {
608        let data = large_homogeneous(100);
609        let result = sample_array(&data, &SampleOpts::default()).unwrap();
610        // First item (head) and last few items (tail) should be included.
611        assert!(
612            result.text.contains("req-0000"),
613            "first item (head) must be kept"
614        );
615    }
616
617    #[test]
618    fn non_object_arrays_skipped() {
619        let data = json!([
620            1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21
621        ]);
622        assert!(sample_array(&data, &SampleOpts::default()).is_none());
623    }
624}