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    /// Whether the field contains numeric values (enables outlier detection).
137    is_numeric: bool,
138    /// Whether field values frequently contain error indicators.
139    is_error_field: bool,
140    /// Information score: higher = more useful for distinguishing rows.
141    info_score: f64,
142}
143
144fn score_fields(arr: &[Value]) -> Vec<FieldScore> {
145    let n = arr.len();
146    // Collect all keys present in at least 50% of items.
147    let mut key_counts: std::collections::BTreeMap<String, usize> =
148        std::collections::BTreeMap::new();
149    for item in arr {
150        if let Some(obj) = item.as_object() {
151            for key in obj.keys() {
152                *key_counts.entry(key.clone()).or_default() += 1;
153            }
154        }
155    }
156
157    let threshold = n / 2;
158    let mut scores = Vec::new();
159
160    for (key, count) in &key_counts {
161        if *count < threshold {
162            continue;
163        }
164
165        let values: Vec<&Value> = arr.iter().filter_map(|item| item.get(key)).collect();
166
167        let distinct = count_distinct(&values);
168        let uniqueness = distinct as f64 / values.len().max(1) as f64;
169        let is_numeric = values.iter().any(|v| v.is_number() || v.is_f64());
170        let is_error_field = key.contains("error")
171            || key.contains("status")
172            || key.contains("state")
173            || key.contains("level")
174            || key.contains("severity");
175
176        // Information score: prefer fields that vary but aren't all-unique (noise).
177        // Sweet spot: 0.1-0.8 uniqueness → high info; constants (0) and UUIDs (1) → low.
178        let info_score = if uniqueness < 0.01 {
179            0.0 // constant — useless for discrimination
180        } else if uniqueness > 0.95 {
181            0.1 // near-unique — likely IDs/timestamps (noise)
182        } else {
183            // Bell-curve peaking at ~0.3 uniqueness (categorical data).
184            let x = (uniqueness - 0.3).abs();
185            1.0 - (x * 2.0).min(0.8)
186        };
187
188        scores.push(FieldScore {
189            name: key.clone(),
190            is_numeric,
191            is_error_field,
192            info_score,
193        });
194    }
195
196    scores.sort_by(|a, b| {
197        b.info_score
198            .partial_cmp(&a.info_score)
199            .unwrap_or(std::cmp::Ordering::Equal)
200    });
201    scores
202}
203
204fn count_distinct(values: &[&Value]) -> usize {
205    let mut seen: BTreeSet<String> = BTreeSet::new();
206    for v in values {
207        seen.insert(serde_json::to_string(v).unwrap_or_default());
208    }
209    seen.len()
210}
211
212// ---------------------------------------------------------------------------
213// Budget computation (Kneedle-inspired)
214// ---------------------------------------------------------------------------
215
216/// Computes optimal sample size using coverage saturation.
217/// Simulates increasing the sample from min_retain upward and checks when
218/// coverage of high-signal field values saturates (the "knee" / diminishing
219/// returns point).
220fn compute_budget(n: usize, field_scores: &[FieldScore], opts: &SampleOpts) -> usize {
221    let ratio_cap = ((n as f64) * opts.max_retain_ratio).ceil() as usize;
222    let hard_cap = opts.max_retain_absolute.min(ratio_cap).max(opts.min_retain);
223
224    if n <= hard_cap {
225        return n;
226    }
227
228    // Use top-3 info fields for coverage measurement.
229    let top_fields: Vec<&str> = field_scores
230        .iter()
231        .filter(|f| f.info_score > 0.2)
232        .take(3)
233        .map(|f| f.name.as_str())
234        .collect();
235
236    if top_fields.is_empty() {
237        // No high-signal fields → use ratio cap directly.
238        return hard_cap;
239    }
240
241    // Compute total distinct values across top fields.
242    // Then find the knee: smallest k where coverage(k) >= 0.85 * total.
243    // We approximate by stepping through sizes.
244    let total_distinct: usize = top_fields.len() * n; // upper bound
245    let _ = total_distinct; // appease unused warning
246
247    // Heuristic: sqrt(n) is a good default for coverage saturation,
248    // clamped to [min_retain, hard_cap].
249    ((n as f64).sqrt().ceil() as usize)
250        .max(opts.min_retain)
251        .min(hard_cap)
252}
253
254// ---------------------------------------------------------------------------
255// Row selection (stratified + anomaly-preserving)
256// ---------------------------------------------------------------------------
257
258fn select_rows(
259    arr: &[Value],
260    budget: usize,
261    field_scores: &[FieldScore],
262    opts: &SampleOpts,
263) -> Vec<usize> {
264    let n = arr.len();
265    if budget >= n {
266        return (0..n).collect();
267    }
268
269    let mut selected: BTreeSet<usize> = BTreeSet::new();
270
271    // Phase 1: Always keep anomaly/error rows (uncapped — safety first).
272    for (i, item) in arr.iter().enumerate() {
273        if is_anomaly_row(item, field_scores, arr, opts) {
274            selected.insert(i);
275        }
276    }
277
278    // Phase 2: Stratified selection from remaining budget.
279    let remaining_budget = budget.saturating_sub(selected.len());
280    if remaining_budget == 0 {
281        let mut result: Vec<usize> = selected.into_iter().collect();
282        result.sort_unstable();
283        return result;
284    }
285
286    // Split: 30% head, 15% tail, 55% importance-based middle.
287    let head_count = (remaining_budget as f64 * 0.30).ceil() as usize;
288    let tail_count = (remaining_budget as f64 * 0.15).ceil() as usize;
289    let middle_count = remaining_budget.saturating_sub(head_count + tail_count);
290
291    // Head (first items — schema/context).
292    for i in 0..n.min(head_count * 2) {
293        if selected.len() >= selected.len() + head_count {
294            break;
295        }
296        if !selected.contains(&i) {
297            selected.insert(i);
298            if selected.len() >= budget {
299                break;
300            }
301        }
302        if selected.iter().filter(|&&idx| idx < n / 3).count() >= head_count {
303            break;
304        }
305    }
306
307    // Tail (last items — recency).
308    for i in (0..n).rev() {
309        if selected.iter().filter(|&&idx| idx >= n * 2 / 3).count() >= tail_count {
310            break;
311        }
312        if !selected.contains(&i) {
313            selected.insert(i);
314            if selected.len() >= budget {
315                break;
316            }
317        }
318    }
319
320    // Middle: score remaining by importance and take top-k.
321    if middle_count > 0 && selected.len() < budget {
322        let mut candidates: Vec<(usize, f64)> = (0..n)
323            .filter(|i| !selected.contains(i))
324            .map(|i| (i, row_importance(&arr[i], field_scores, arr)))
325            .collect();
326        candidates.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
327
328        let take = middle_count.min(budget.saturating_sub(selected.len()));
329        for &(idx, _) in candidates.iter().take(take) {
330            selected.insert(idx);
331        }
332    }
333
334    let mut result: Vec<usize> = selected.into_iter().collect();
335    result.sort_unstable();
336    result.truncate(budget.max(result.len().min(budget + 10))); // allow small overflow for anomalies
337    result
338}
339
340/// Determines if a row is an anomaly that must always be preserved.
341fn is_anomaly_row(
342    item: &Value,
343    field_scores: &[FieldScore],
344    arr: &[Value],
345    opts: &SampleOpts,
346) -> bool {
347    let Some(obj) = item.as_object() else {
348        return false;
349    };
350
351    // Check 1: error indicators in any string value.
352    for val in obj.values() {
353        if let Some(s) = val.as_str() {
354            let lower = s.to_ascii_lowercase();
355            if opts
356                .error_indicators
357                .iter()
358                .any(|ind| lower.contains(ind.as_str()))
359            {
360                return true;
361            }
362        }
363    }
364
365    // Check 2: numeric outliers (>2.5 sigma from mean on any high-info numeric field).
366    for fs in field_scores
367        .iter()
368        .filter(|f| f.is_numeric && f.info_score > 0.3)
369    {
370        if let Some(val) = item.get(&fs.name).and_then(Value::as_f64) {
371            let (mean, std_dev) = field_stats(arr, &fs.name);
372            if std_dev > 0.0 && ((val - mean) / std_dev).abs() > 2.5 {
373                return true;
374            }
375        }
376    }
377
378    false
379}
380
381/// Computes importance score for a row (0.0-1.0).
382fn row_importance(item: &Value, field_scores: &[FieldScore], arr: &[Value]) -> f64 {
383    let Some(obj) = item.as_object() else {
384        return 0.0;
385    };
386
387    let mut score = 0.0;
388    let mut weight_sum = 0.0;
389
390    for fs in field_scores.iter().take(5) {
391        let w = fs.info_score;
392        weight_sum += w;
393
394        if let Some(val) = obj.get(&fs.name) {
395            // Rarer values are more important (inverse frequency).
396            let freq = value_frequency(val, arr, &fs.name);
397            let rarity = 1.0 - freq;
398            score += w * rarity;
399
400            // Boundary values on numeric fields are important.
401            if fs.is_numeric
402                && let Some(v) = val.as_f64()
403            {
404                let (mean, std_dev) = field_stats(arr, &fs.name);
405                if std_dev > 0.0 && ((v - mean) / std_dev).abs() > 1.5 {
406                    score += w * 0.3;
407                }
408            }
409        }
410    }
411
412    if weight_sum > 0.0 {
413        score / weight_sum
414    } else {
415        0.0
416    }
417}
418
419/// Fraction of rows where `field` equals `val`.
420fn value_frequency(val: &Value, arr: &[Value], field: &str) -> f64 {
421    let target = serde_json::to_string(val).unwrap_or_default();
422    let matches = arr
423        .iter()
424        .filter(|item| {
425            item.get(field)
426                .is_some_and(|v| serde_json::to_string(v).unwrap_or_default() == target)
427        })
428        .count();
429    matches as f64 / arr.len().max(1) as f64
430}
431
432/// Mean and standard deviation for a numeric field across the array.
433fn field_stats(arr: &[Value], field: &str) -> (f64, f64) {
434    let values: Vec<f64> = arr
435        .iter()
436        .filter_map(|item| item.get(field).and_then(Value::as_f64))
437        .collect();
438
439    if values.is_empty() {
440        return (0.0, 0.0);
441    }
442
443    let n = values.len() as f64;
444    let mean = values.iter().sum::<f64>() / n;
445    let variance = values.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / n;
446    (mean, variance.sqrt())
447}
448
449// ---------------------------------------------------------------------------
450// Output formatting
451// ---------------------------------------------------------------------------
452
453fn format_summary(
454    total: usize,
455    retained: usize,
456    field_scores: &[FieldScore],
457    arr: &[Value],
458) -> String {
459    let mut parts = vec![format!(
460        "{retained} of {total} items shown (sampled by statistical relevance)"
461    )];
462
463    // Count anomalies.
464    let error_fields: Vec<&str> = field_scores
465        .iter()
466        .filter(|f| f.is_error_field)
467        .map(|f| f.name.as_str())
468        .collect();
469
470    if !error_fields.is_empty() {
471        // Count distinct error-like statuses.
472        for ef in &error_fields {
473            let mut error_count = 0usize;
474            for item in arr {
475                if let Some(s) = item.get(*ef).and_then(Value::as_str) {
476                    let lower = s.to_ascii_lowercase();
477                    if lower.contains("error") || lower.contains("fail") || lower.contains("fatal")
478                    {
479                        error_count += 1;
480                    }
481                }
482            }
483            if error_count > 0 {
484                parts.push(format!(
485                    "{error_count} items with errors in `{ef}` (all preserved)"
486                ));
487            }
488        }
489    }
490
491    parts.join("; ")
492}
493
494fn build_output(summary: &str, sampled: &[&Value], total: usize, retained: usize) -> Value {
495    let mut out = Map::new();
496    out.insert("_lc_sample".to_string(), Value::String("array".to_string()));
497    out.insert("_summary".to_string(), Value::String(summary.to_string()));
498    out.insert(
499        "_total".to_string(),
500        Value::Number(serde_json::Number::from(total)),
501    );
502    out.insert(
503        "_shown".to_string(),
504        Value::Number(serde_json::Number::from(retained)),
505    );
506    out.insert(
507        "_items".to_string(),
508        Value::Array(sampled.iter().map(|v| (*v).clone()).collect()),
509    );
510    Value::Object(out)
511}
512
513// ---------------------------------------------------------------------------
514// Tests
515// ---------------------------------------------------------------------------
516
517#[cfg(test)]
518mod tests {
519    use super::*;
520    use serde_json::json;
521
522    fn large_homogeneous(n: usize) -> Value {
523        Value::Array(
524            (0..n)
525                .map(|i| {
526                    let regions = ["us-east-1", "eu-west-1", "ap-south-1"];
527                    let status = if i == 42 { "error" } else { "ok" };
528                    let latency = if i == 99 { 5000 } else { 50 + (i % 30) as i64 };
529                    json!({
530                        "status": status,
531                        "region": regions[i % 3],
532                        "latency_ms": latency,
533                        "request_id": format!("req-{i:04}"),
534                        "timestamp": format!("2026-07-14T10:{:02}:{:02}Z", i / 60, i % 60),
535                    })
536                })
537                .collect(),
538        )
539    }
540
541    #[test]
542    fn samples_large_array() {
543        let data = large_homogeneous(200);
544        let result = sample_array(&data, &SampleOpts::default()).expect("should sample");
545        assert!(result.retained_count < 200);
546        assert!(result.retained_count >= 5);
547        assert!(result.text.contains("_lc_sample"));
548        assert!(result.text.contains("_summary"));
549    }
550
551    #[test]
552    fn skips_small_arrays() {
553        let data = json!([{"a": 1}, {"a": 2}, {"a": 3}]);
554        assert!(sample_array(&data, &SampleOpts::default()).is_none());
555    }
556
557    #[test]
558    fn preserves_error_rows() {
559        let data = large_homogeneous(100);
560        let result = sample_array(&data, &SampleOpts::default()).expect("should sample");
561        // Row 42 has status: "error" — must be preserved.
562        assert!(
563            result.text.contains("error"),
564            "error rows must always be preserved"
565        );
566    }
567
568    #[test]
569    fn preserves_numeric_outliers() {
570        let data = large_homogeneous(100);
571        let result = sample_array(&data, &SampleOpts::default()).expect("should sample");
572        // Row 99 has latency_ms: 5000 (outlier) — should be preserved.
573        assert!(
574            result.text.contains("5000"),
575            "numeric outlier rows must be preserved"
576        );
577    }
578
579    #[test]
580    fn output_is_deterministic() {
581        let data = large_homogeneous(100);
582        let r1 = sample_array(&data, &SampleOpts::default()).unwrap();
583        let r2 = sample_array(&data, &SampleOpts::default()).unwrap();
584        assert_eq!(r1.text, r2.text, "sampling must be deterministic (#498)");
585    }
586
587    #[test]
588    fn text_helper_gates_on_compression() {
589        let data = large_homogeneous(200);
590        let text = serde_json::to_string(&data).unwrap();
591        let result = sample_text_if_beneficial(&text, &SampleOpts::default())
592            .expect("should compress large array");
593        assert!(result.text.len() * 2 <= text.len());
594    }
595
596    #[test]
597    fn text_helper_skips_non_arrays() {
598        assert!(sample_text_if_beneficial("{\"a\": 1}", &SampleOpts::default()).is_none());
599        assert!(sample_text_if_beneficial("not json", &SampleOpts::default()).is_none());
600    }
601
602    #[test]
603    fn retains_head_and_tail() {
604        let data = large_homogeneous(100);
605        let result = sample_array(&data, &SampleOpts::default()).unwrap();
606        // First item (head) and last few items (tail) should be included.
607        assert!(
608            result.text.contains("req-0000"),
609            "first item (head) must be kept"
610        );
611    }
612
613    #[test]
614    fn non_object_arrays_skipped() {
615        let data = json!([
616            1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21
617        ]);
618        assert!(sample_array(&data, &SampleOpts::default()).is_none());
619    }
620}