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 #[allow(dead_code)]
138 uniqueness: f64,
139 is_numeric: bool,
141 is_error_field: bool,
143 info_score: f64,
145}
146
147fn score_fields(arr: &[Value]) -> Vec<FieldScore> {
148 let n = arr.len();
149 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 let info_score = if uniqueness < 0.01 {
182 0.0 } else if uniqueness > 0.95 {
184 0.1 } else {
186 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
216fn 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 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 return hard_cap;
243 }
244
245 let total_distinct: usize = top_fields.len() * n; let _ = total_distinct; ((n as f64).sqrt().ceil() as usize)
254 .max(opts.min_retain)
255 .min(hard_cap)
256}
257
258fn 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 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 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 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 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 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 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))); result
342}
343
344fn 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 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 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
385fn 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 let freq = value_frequency(val, arr, &fs.name);
401 let rarity = 1.0 - freq;
402 score += w * rarity;
403
404 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
423fn 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
436fn 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
453fn 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 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 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#[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 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 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 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}