1use serde_json::{Map, Value};
26use std::collections::BTreeSet;
27
28#[derive(Debug, Clone)]
30pub struct SampleResult {
31 pub text: String,
33 pub original_count: usize,
35 pub retained_count: usize,
37 pub summary: String,
39}
40
41#[derive(Debug, Clone)]
43pub struct SampleOpts {
44 pub min_items: usize,
46 pub max_retain_ratio: f64,
49 pub max_retain_absolute: usize,
51 pub min_retain: usize,
53 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
80pub 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; }
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
114pub 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 if result.text.len() * 2 > trimmed.len() {
124 return None;
125 }
126 Some(result)
127}
128
129#[derive(Debug, Clone)]
134struct FieldScore {
135 name: String,
136 is_numeric: bool,
138 is_error_field: bool,
140 info_score: f64,
142}
143
144fn score_fields(arr: &[Value]) -> Vec<FieldScore> {
145 let n = arr.len();
146 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 let info_score = if uniqueness < 0.01 {
179 0.0 } else if uniqueness > 0.95 {
181 0.1 } else {
183 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
212fn 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 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 return hard_cap;
239 }
240
241 let total_distinct: usize = top_fields.len() * n; let _ = total_distinct; ((n as f64).sqrt().ceil() as usize)
250 .max(opts.min_retain)
251 .min(hard_cap)
252}
253
254fn 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 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 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 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 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 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 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))); result
338}
339
340fn 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 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 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
381fn 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 let freq = value_frequency(val, arr, &fs.name);
397 let rarity = 1.0 - freq;
398 score += w * rarity;
399
400 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
419fn 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
432fn 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
449fn 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 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 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#[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 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 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 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}