use serde_json::{Map, Value};
use std::collections::BTreeSet;
#[derive(Debug, Clone)]
pub(crate) struct SampleResult {
pub text: String,
pub original_count: usize,
pub retained_count: usize,
pub summary: String,
}
#[derive(Debug, Clone)]
pub(crate) struct SampleOpts {
pub min_items: usize,
pub max_retain_ratio: f64,
pub max_retain_absolute: usize,
pub min_retain: usize,
pub error_indicators: Vec<String>,
}
impl Default for SampleOpts {
fn default() -> Self {
Self {
min_items: 20,
max_retain_ratio: 0.15,
max_retain_absolute: 50,
min_retain: 5,
error_indicators: vec![
"error".into(),
"fail".into(),
"fatal".into(),
"panic".into(),
"exception".into(),
"critical".into(),
"denied".into(),
"refused".into(),
"timeout".into(),
"crash".into(),
],
}
}
}
pub(crate) fn sample_array(value: &Value, opts: &SampleOpts) -> Option<SampleResult> {
let arr = value.as_array()?;
if arr.len() < opts.min_items {
return None;
}
if !arr.iter().all(Value::is_object) {
return None;
}
let n = arr.len();
let field_scores = score_fields(arr);
let budget = compute_budget(n, &field_scores, opts);
let selected = select_rows(arr, budget, &field_scores, opts);
let retained = selected.len();
if retained >= n {
return None; }
let summary = format_summary(n, retained, &field_scores, arr);
let sampled: Vec<&Value> = selected.iter().map(|&i| &arr[i]).collect();
let output = build_output(&summary, &sampled, n, retained);
let text = serde_json::to_string(&output).ok()?;
Some(SampleResult {
text,
original_count: n,
retained_count: retained,
summary,
})
}
pub(crate) fn sample_text_if_beneficial(text: &str, opts: &SampleOpts) -> Option<SampleResult> {
let trimmed = text.trim();
if !trimmed.starts_with('[') {
return None;
}
let val: Value = serde_json::from_str(trimmed).ok()?;
let result = sample_array(&val, opts)?;
if result.text.len() * 2 > trimmed.len() {
return None;
}
Some(result)
}
#[derive(Debug, Clone)]
struct FieldScore {
name: String,
is_numeric: bool,
is_error_field: bool,
info_score: f64,
}
fn score_fields(arr: &[Value]) -> Vec<FieldScore> {
let n = arr.len();
let mut key_counts: std::collections::BTreeMap<String, usize> =
std::collections::BTreeMap::new();
for item in arr {
if let Some(obj) = item.as_object() {
for key in obj.keys() {
*key_counts.entry(key.clone()).or_default() += 1;
}
}
}
let threshold = n / 2;
let mut scores = Vec::new();
for (key, count) in &key_counts {
if *count < threshold {
continue;
}
let values: Vec<&Value> = arr.iter().filter_map(|item| item.get(key)).collect();
let distinct = count_distinct(&values);
let uniqueness = distinct as f64 / values.len().max(1) as f64;
let is_numeric = values.iter().any(|v| v.is_number() || v.is_f64());
let is_error_field = key.contains("error")
|| key.contains("status")
|| key.contains("state")
|| key.contains("level")
|| key.contains("severity");
let info_score = if uniqueness < 0.01 {
0.0 } else if uniqueness > 0.95 {
0.1 } else {
let x = (uniqueness - 0.3).abs();
1.0 - (x * 2.0).min(0.8)
};
scores.push(FieldScore {
name: key.clone(),
is_numeric,
is_error_field,
info_score,
});
}
scores.sort_by(|a, b| {
b.info_score
.partial_cmp(&a.info_score)
.unwrap_or(std::cmp::Ordering::Equal)
});
scores
}
fn count_distinct(values: &[&Value]) -> usize {
let mut seen: BTreeSet<String> = BTreeSet::new();
for v in values {
seen.insert(serde_json::to_string(v).unwrap_or_default());
}
seen.len()
}
fn compute_budget(n: usize, field_scores: &[FieldScore], opts: &SampleOpts) -> usize {
let ratio_cap = ((n as f64) * opts.max_retain_ratio).ceil() as usize;
let hard_cap = opts.max_retain_absolute.min(ratio_cap).max(opts.min_retain);
if n <= hard_cap {
return n;
}
let top_fields: Vec<&str> = field_scores
.iter()
.filter(|f| f.info_score > 0.2)
.take(3)
.map(|f| f.name.as_str())
.collect();
if top_fields.is_empty() {
return hard_cap;
}
let total_distinct: usize = top_fields.len() * n; let _ = total_distinct;
((n as f64).sqrt().ceil() as usize)
.max(opts.min_retain)
.min(hard_cap)
}
fn select_rows(
arr: &[Value],
budget: usize,
field_scores: &[FieldScore],
opts: &SampleOpts,
) -> Vec<usize> {
let n = arr.len();
if budget >= n {
return (0..n).collect();
}
let mut selected: BTreeSet<usize> = BTreeSet::new();
for (i, item) in arr.iter().enumerate() {
if is_anomaly_row(item, field_scores, arr, opts) {
selected.insert(i);
}
}
let remaining_budget = budget.saturating_sub(selected.len());
if remaining_budget == 0 {
let mut result: Vec<usize> = selected.into_iter().collect();
result.sort_unstable();
return result;
}
let head_count = (remaining_budget as f64 * 0.30).ceil() as usize;
let tail_count = (remaining_budget as f64 * 0.15).ceil() as usize;
let middle_count = remaining_budget.saturating_sub(head_count + tail_count);
for i in 0..n.min(head_count * 2) {
if selected.len() >= selected.len() + head_count {
break;
}
if !selected.contains(&i) {
selected.insert(i);
if selected.len() >= budget {
break;
}
}
if selected.iter().filter(|&&idx| idx < n / 3).count() >= head_count {
break;
}
}
for i in (0..n).rev() {
if selected.iter().filter(|&&idx| idx >= n * 2 / 3).count() >= tail_count {
break;
}
if !selected.contains(&i) {
selected.insert(i);
if selected.len() >= budget {
break;
}
}
}
if middle_count > 0 && selected.len() < budget {
let mut candidates: Vec<(usize, f64)> = (0..n)
.filter(|i| !selected.contains(i))
.map(|i| (i, row_importance(&arr[i], field_scores, arr)))
.collect();
candidates.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
let take = middle_count.min(budget.saturating_sub(selected.len()));
for &(idx, _) in candidates.iter().take(take) {
selected.insert(idx);
}
}
let mut result: Vec<usize> = selected.into_iter().collect();
result.sort_unstable();
result.truncate(budget.max(result.len().min(budget + 10))); result
}
fn is_anomaly_row(
item: &Value,
field_scores: &[FieldScore],
arr: &[Value],
opts: &SampleOpts,
) -> bool {
let Some(obj) = item.as_object() else {
return false;
};
for val in obj.values() {
if let Some(s) = val.as_str() {
let lower = s.to_ascii_lowercase();
if opts
.error_indicators
.iter()
.any(|ind| lower.contains(ind.as_str()))
{
return true;
}
}
}
for fs in field_scores
.iter()
.filter(|f| f.is_numeric && f.info_score > 0.3)
{
if let Some(val) = item.get(&fs.name).and_then(Value::as_f64) {
let (mean, std_dev) = field_stats(arr, &fs.name);
if std_dev > 0.0 && ((val - mean) / std_dev).abs() > 2.5 {
return true;
}
}
}
false
}
fn row_importance(item: &Value, field_scores: &[FieldScore], arr: &[Value]) -> f64 {
let Some(obj) = item.as_object() else {
return 0.0;
};
let mut score = 0.0;
let mut weight_sum = 0.0;
for fs in field_scores.iter().take(5) {
let w = fs.info_score;
weight_sum += w;
if let Some(val) = obj.get(&fs.name) {
let freq = value_frequency(val, arr, &fs.name);
let rarity = 1.0 - freq;
score += w * rarity;
if fs.is_numeric
&& let Some(v) = val.as_f64()
{
let (mean, std_dev) = field_stats(arr, &fs.name);
if std_dev > 0.0 && ((v - mean) / std_dev).abs() > 1.5 {
score += w * 0.3;
}
}
}
}
if weight_sum > 0.0 {
score / weight_sum
} else {
0.0
}
}
fn value_frequency(val: &Value, arr: &[Value], field: &str) -> f64 {
let target = serde_json::to_string(val).unwrap_or_default();
let matches = arr
.iter()
.filter(|item| {
item.get(field)
.is_some_and(|v| serde_json::to_string(v).unwrap_or_default() == target)
})
.count();
matches as f64 / arr.len().max(1) as f64
}
fn field_stats(arr: &[Value], field: &str) -> (f64, f64) {
let values: Vec<f64> = arr
.iter()
.filter_map(|item| item.get(field).and_then(Value::as_f64))
.collect();
if values.is_empty() {
return (0.0, 0.0);
}
let n = values.len() as f64;
let mean = values.iter().sum::<f64>() / n;
let variance = values.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / n;
(mean, variance.sqrt())
}
fn format_summary(
total: usize,
retained: usize,
field_scores: &[FieldScore],
arr: &[Value],
) -> String {
let mut parts = vec![format!(
"{retained} of {total} items shown (sampled by statistical relevance)"
)];
let error_fields: Vec<&str> = field_scores
.iter()
.filter(|f| f.is_error_field)
.map(|f| f.name.as_str())
.collect();
if !error_fields.is_empty() {
for ef in &error_fields {
let mut error_count = 0usize;
for item in arr {
if let Some(s) = item.get(*ef).and_then(Value::as_str) {
let lower = s.to_ascii_lowercase();
if lower.contains("error") || lower.contains("fail") || lower.contains("fatal")
{
error_count += 1;
}
}
}
if error_count > 0 {
parts.push(format!(
"{error_count} items with errors in `{ef}` (all preserved)"
));
}
}
}
parts.join("; ")
}
fn build_output(summary: &str, sampled: &[&Value], total: usize, retained: usize) -> Value {
let mut out = Map::new();
out.insert("_lc_sample".to_string(), Value::String("array".to_string()));
out.insert("_summary".to_string(), Value::String(summary.to_string()));
out.insert(
"_total".to_string(),
Value::Number(serde_json::Number::from(total)),
);
out.insert(
"_shown".to_string(),
Value::Number(serde_json::Number::from(retained)),
);
out.insert(
"_items".to_string(),
Value::Array(sampled.iter().map(|v| (*v).clone()).collect()),
);
Value::Object(out)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn large_homogeneous(n: usize) -> Value {
Value::Array(
(0..n)
.map(|i| {
let regions = ["us-east-1", "eu-west-1", "ap-south-1"];
let status = if i == 42 { "error" } else { "ok" };
let latency = if i == 99 { 5000 } else { 50 + (i % 30) as i64 };
json!({
"status": status,
"region": regions[i % 3],
"latency_ms": latency,
"request_id": format!("req-{i:04}"),
"timestamp": format!("2026-07-14T10:{:02}:{:02}Z", i / 60, i % 60),
})
})
.collect(),
)
}
#[test]
fn samples_large_array() {
let data = large_homogeneous(200);
let result = sample_array(&data, &SampleOpts::default()).expect("should sample");
assert!(result.retained_count < 200);
assert!(result.retained_count >= 5);
assert!(result.text.contains("_lc_sample"));
assert!(result.text.contains("_summary"));
}
#[test]
fn skips_small_arrays() {
let data = json!([{"a": 1}, {"a": 2}, {"a": 3}]);
assert!(sample_array(&data, &SampleOpts::default()).is_none());
}
#[test]
fn preserves_error_rows() {
let data = large_homogeneous(100);
let result = sample_array(&data, &SampleOpts::default()).expect("should sample");
assert!(
result.text.contains("error"),
"error rows must always be preserved"
);
}
#[test]
fn preserves_numeric_outliers() {
let data = large_homogeneous(100);
let result = sample_array(&data, &SampleOpts::default()).expect("should sample");
assert!(
result.text.contains("5000"),
"numeric outlier rows must be preserved"
);
}
#[test]
fn output_is_deterministic() {
let data = large_homogeneous(100);
let r1 = sample_array(&data, &SampleOpts::default()).unwrap();
let r2 = sample_array(&data, &SampleOpts::default()).unwrap();
assert_eq!(r1.text, r2.text, "sampling must be deterministic (#498)");
}
#[test]
fn text_helper_gates_on_compression() {
let data = large_homogeneous(200);
let text = serde_json::to_string(&data).unwrap();
let result = sample_text_if_beneficial(&text, &SampleOpts::default())
.expect("should compress large array");
assert!(result.text.len() * 2 <= text.len());
}
#[test]
fn text_helper_skips_non_arrays() {
assert!(sample_text_if_beneficial("{\"a\": 1}", &SampleOpts::default()).is_none());
assert!(sample_text_if_beneficial("not json", &SampleOpts::default()).is_none());
}
#[test]
fn retains_head_and_tail() {
let data = large_homogeneous(100);
let result = sample_array(&data, &SampleOpts::default()).unwrap();
assert!(
result.text.contains("req-0000"),
"first item (head) must be kept"
);
}
#[test]
fn non_object_arrays_skipped() {
let data = json!([
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21
]);
assert!(sample_array(&data, &SampleOpts::default()).is_none());
}
}