1use std::cmp::Ordering;
35use std::collections::BTreeMap;
36
37use serde_json::{Map, Value};
38
39use crate::retrieval_store::hex_sha256;
40use crate::token_estimator::TokenEstimator;
41
42pub const TRANSFORM_ID: &str = "json_prune";
43pub const TRANSFORM_VERSION: &str = "1.0.0";
44
45const MIN_ARRAY_LEN: usize = 2;
48
49const SELF_FIELD: &str = "$self";
52
53const DISCRETE_OUTLIER_SCORE: f64 = 1_000.0;
58
59const MARGIN_FLOOR: i64 = 32;
62const MARGIN_PER_ITEM: f64 = 0.5;
63
64#[derive(Debug, thiserror::Error)]
65pub enum JsonPruneError {
66 #[error("invalid json: {0}")]
67 Invalid(#[from] serde_json::Error),
68}
69
70#[derive(Debug, Clone)]
71pub struct LossyOptions {
72 pub preserve_paths: Vec<String>,
75 pub ratio: f64,
82 pub namespace: String,
85}
86
87#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct DroppedItem {
93 pub hash: String,
94 pub bytes: Vec<u8>,
95}
96
97#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
98pub struct PruneReport {
99 pub eligible_arrays: usize,
100 pub total_candidates: usize,
101 pub preserved_candidates: usize,
102 pub kept_candidates: usize,
103 pub dropped_candidates: usize,
104}
105
106#[derive(Debug, Clone)]
107pub struct PruneOutcome {
108 pub json: Value,
112 pub dropped: Vec<DroppedItem>,
113 pub report: PruneReport,
114}
115
116pub fn prune(
120 input: &[u8],
121 options: &LossyOptions,
122 estimator: &dyn TokenEstimator,
123) -> Result<Option<PruneOutcome>, JsonPruneError> {
124 if input.is_empty() || options.ratio >= 1.0 {
125 return Ok(None);
126 }
127 let value: Value = serde_json::from_slice(input)?;
128
129 let mut arrays = Vec::new();
130 collect_eligible_arrays(&value, String::new(), &mut arrays);
131 if arrays.is_empty() {
132 return Ok(None);
133 }
134
135 let preserved_array: Vec<bool> = arrays
136 .iter()
137 .map(|a| is_preserved(&a.path, &options.preserve_paths))
138 .collect();
139 let field_stats: Vec<BTreeMap<String, FieldStats>> = arrays
140 .iter()
141 .map(|a| numeric_field_stats(&a.items))
142 .collect();
143 let markers: Vec<Vec<Value>> = arrays
144 .iter()
145 .map(|a| {
146 a.items
147 .iter()
148 .map(|item| {
149 let bytes = serde_json::to_vec(item).unwrap_or_default();
150 marker_json(&hex_sha256(&bytes), &options.namespace)
151 })
152 .collect()
153 })
154 .collect();
155 let marker_bytes_cache: Vec<Vec<Vec<u8>>> = markers
156 .iter()
157 .map(|per_array| {
158 per_array
159 .iter()
160 .map(|m| serde_json::to_vec(m).unwrap_or_default())
161 .collect()
162 })
163 .collect();
164
165 let total_candidates: usize = arrays.iter().map(|a| a.items.len()).sum();
166 let preserved_candidates: usize = arrays
167 .iter()
168 .zip(&preserved_array)
169 .filter(|&(_, &p)| p)
170 .map(|(a, _)| a.items.len())
171 .sum();
172
173 let mut candidates = Vec::new();
174 for (array_idx, array) in arrays.iter().enumerate() {
175 if preserved_array[array_idx] {
176 continue;
177 }
178 let stats = &field_stats[array_idx];
179 let n = array.items.len();
180 for (item_idx, item) in array.items.iter().enumerate() {
181 candidates.push(Candidate {
182 array_idx,
183 item_idx,
184 bytes: serde_json::to_vec(item).unwrap_or_default(),
185 rank: RankKey {
186 failure_signal: has_structural_failure_signal(item),
187 outlier_score: numeric_outlier_score(item, stats),
188 edge_bonus: item_idx == 0 || item_idx + 1 == n,
189 },
190 fingerprint: structural_fingerprint(item),
191 });
192 }
193 }
194
195 if candidates.is_empty() {
196 return Ok(None);
197 }
198
199 let marker_tokens_cache: Vec<Vec<i64>> = marker_bytes_cache
207 .iter()
208 .map(|per_array| {
209 per_array
210 .iter()
211 .map(|m| estimator.count_bytes(m) as i64)
212 .collect()
213 })
214 .collect();
215 let real_tokens_cache: Vec<i64> = candidates
216 .iter()
217 .map(|c| estimator.count_bytes(&c.bytes) as i64)
218 .collect();
219
220 let mut kept_mask: Vec<Vec<bool>> = arrays.iter().map(|a| vec![false; a.items.len()]).collect();
221
222 let mut droppable: Vec<usize> = Vec::new();
235 for (idx, c) in candidates.iter().enumerate() {
236 let marker_tok = marker_tokens_cache[c.array_idx][c.item_idx];
237 if real_tokens_cache[idx] > marker_tok {
238 droppable.push(idx);
239 } else {
240 kept_mask[c.array_idx][c.item_idx] = true;
241 }
242 }
243
244 let walk_order = diversity_walk_order(&candidates, &droppable);
245
246 let mut pool_tokens: i64 = droppable
247 .iter()
248 .map(|&idx| marker_tokens_cache[candidates[idx].array_idx][candidates[idx].item_idx])
249 .sum();
250 let mut pool_bytes: i64 = droppable
261 .iter()
262 .map(|&idx| {
263 marker_bytes_cache[candidates[idx].array_idx][candidates[idx].item_idx].len() as i64
264 })
265 .sum();
266 let real_total_tokens: i64 = droppable.iter().map(|&idx| real_tokens_cache[idx]).sum();
267 let budget_tokens = pool_tokens
268 + ((real_total_tokens - pool_tokens) as f64 * options.ratio.clamp(0.0, 1.0)).round() as i64;
269 let mut accepted = 0usize;
270 const MAX_EXACT_TIER_CALLS_PER_ARRAY: usize = 16;
280 let mut exact_calls_used = vec![0usize; arrays.len()];
281
282 for &idx in &walk_order {
283 let c = &candidates[idx];
284 let marker_len = marker_bytes_cache[c.array_idx][c.item_idx].len() as i64;
285 let marker_tok = marker_tokens_cache[c.array_idx][c.item_idx];
286 let real_len = c.bytes.len() as i64;
287 let real_tok = real_tokens_cache[idx];
288
289 let trial_bytes = pool_bytes - marker_len + real_len;
290 let trial_tokens = pool_tokens - marker_tok + real_tok;
291 let margin = MARGIN_FLOOR + (MARGIN_PER_ITEM * accepted as f64) as i64;
292 let mut exact_delta: Option<i64> = None;
299
300 let fits = trial_bytes <= budget_tokens
303 || trial_tokens + margin <= budget_tokens
307 || (exact_calls_used[c.array_idx] < MAX_EXACT_TIER_CALLS_PER_ARRAY && {
313 exact_calls_used[c.array_idx] += 1;
314 kept_mask[c.array_idx][c.item_idx] = true;
315 let with = estimator.count_bytes(&assemble(&arrays[c.array_idx], &kept_mask[c.array_idx], &markers[c.array_idx]));
316 kept_mask[c.array_idx][c.item_idx] = false;
317 let without = estimator.count_bytes(&assemble(&arrays[c.array_idx], &kept_mask[c.array_idx], &markers[c.array_idx]));
318 let delta = with as i64 - without as i64;
319 exact_delta = Some(delta);
320 pool_tokens + delta <= budget_tokens
321 });
322
323 if fits {
324 kept_mask[c.array_idx][c.item_idx] = true;
325 pool_bytes = trial_bytes;
326 pool_tokens = match exact_delta {
327 Some(delta) => pool_tokens + delta,
328 None => trial_tokens,
329 };
330 accepted += 1;
331 }
332 }
333
334 let mut cursor = 0usize;
335 let pruned = rewrite_tree(&value, &mut cursor, &preserved_array, &kept_mask, &markers);
336
337 let mut dropped = Vec::new();
338 for (array_idx, mask) in kept_mask.iter().enumerate() {
339 if preserved_array[array_idx] {
345 continue;
346 }
347 for (item_idx, &kept) in mask.iter().enumerate() {
348 if !kept {
349 dropped.push(DroppedItem {
350 hash: hex_sha256(
351 &serde_json::to_vec(&arrays[array_idx].items[item_idx]).unwrap_or_default(),
352 ),
353 bytes: serde_json::to_vec(&arrays[array_idx].items[item_idx])
354 .unwrap_or_default(),
355 });
356 }
357 }
358 }
359
360 if dropped.is_empty() {
361 return Ok(None);
362 }
363
364 let dropped_candidates = dropped.len();
365 Ok(Some(PruneOutcome {
366 json: pruned,
367 dropped,
368 report: PruneReport {
369 eligible_arrays: arrays.len(),
370 total_candidates,
371 preserved_candidates,
372 kept_candidates: total_candidates - preserved_candidates - dropped_candidates,
373 dropped_candidates,
374 },
375 }))
376}
377
378struct RankKey {
379 failure_signal: bool,
380 outlier_score: f64,
381 edge_bonus: bool,
382}
383
384impl RankKey {
385 fn cmp(&self, other: &RankKey) -> Ordering {
389 self.failure_signal
390 .cmp(&other.failure_signal)
391 .then_with(|| {
392 self.outlier_score
393 .partial_cmp(&other.outlier_score)
394 .unwrap_or(Ordering::Equal)
395 })
396 .then_with(|| self.edge_bonus.cmp(&other.edge_bonus))
397 }
398}
399
400struct Candidate {
401 array_idx: usize,
402 item_idx: usize,
403 bytes: Vec<u8>,
404 rank: RankKey,
405 fingerprint: String,
406}
407
408fn diversity_walk_order(candidates: &[Candidate], eligible: &[usize]) -> Vec<usize> {
419 let mut groups: BTreeMap<String, Vec<usize>> = BTreeMap::new();
420 for &idx in eligible {
421 let c = &candidates[idx];
422 groups.entry(c.fingerprint.clone()).or_default().push(idx);
423 }
424 for members in groups.values_mut() {
425 members.sort_by(|&a, &b| candidates[b].rank.cmp(&candidates[a].rank));
426 }
427 let mut group_order: Vec<String> = groups.keys().cloned().collect();
428 group_order.sort_by(|a, b| {
429 let ra = &candidates[groups[a][0]].rank;
430 let rb = &candidates[groups[b][0]].rank;
431 rb.cmp(ra)
432 });
433
434 let mut cursors: BTreeMap<String, usize> =
435 group_order.iter().map(|k| (k.clone(), 0usize)).collect();
436 let mut order = Vec::with_capacity(eligible.len());
437 let mut active: Vec<String> = group_order;
444 while !active.is_empty() {
445 for key in &active {
446 let members = &groups[key];
447 let cursor = cursors.get_mut(key).expect("seeded above");
448 order.push(members[*cursor]);
449 *cursor += 1;
450 }
451 active.retain(|key| cursors[key] < groups[key].len());
452 }
453 order
454}
455
456fn assemble(array: &EligibleArray, kept_mask: &[bool], markers: &[Value]) -> Vec<u8> {
457 let items: Vec<Value> = array
458 .items
459 .iter()
460 .zip(kept_mask)
461 .zip(markers)
462 .map(|((item, &kept), marker)| if kept { item.clone() } else { marker.clone() })
463 .collect();
464 serde_json::to_vec(&Value::Array(items)).unwrap_or_default()
465}
466
467fn marker_json(hash: &str, namespace: &str) -> Value {
468 let mut inner = Map::new();
469 inner.insert("hash".to_string(), Value::String(hash.to_string()));
470 inner.insert("alg".to_string(), Value::String("sha256".to_string()));
471 inner.insert(
472 "namespace".to_string(),
473 Value::String(namespace.to_string()),
474 );
475 let mut outer = Map::new();
476 outer.insert("$tf_ref".to_string(), Value::Object(inner));
477 Value::Object(outer)
478}
479
480struct EligibleArray {
481 path: String,
482 items: Vec<Value>,
483}
484
485fn collect_eligible_arrays(value: &Value, path: String, out: &mut Vec<EligibleArray>) {
495 match value {
496 Value::Array(items) if items.len() >= MIN_ARRAY_LEN => {
497 out.push(EligibleArray {
498 path,
499 items: items.clone(),
500 });
501 }
502 Value::Array(items) => {
503 for item in items {
504 collect_eligible_arrays(item, path.clone(), out);
505 }
506 }
507 Value::Object(map) => {
508 for (k, v) in map {
509 let child_path = if path.is_empty() {
510 k.clone()
511 } else {
512 format!("{path}.{k}")
513 };
514 collect_eligible_arrays(v, child_path, out);
515 }
516 }
517 _ => {}
518 }
519}
520
521fn rewrite_tree(
525 value: &Value,
526 cursor: &mut usize,
527 preserved_array: &[bool],
528 kept_mask: &[Vec<bool>],
529 markers: &[Vec<Value>],
530) -> Value {
531 match value {
532 Value::Array(items) if items.len() >= MIN_ARRAY_LEN => {
533 let idx = *cursor;
534 *cursor += 1;
535 if preserved_array[idx] {
536 return value.clone();
537 }
538 let rebuilt: Vec<Value> = items
539 .iter()
540 .enumerate()
541 .map(|(i, item)| {
542 if kept_mask[idx][i] {
543 item.clone()
544 } else {
545 markers[idx][i].clone()
546 }
547 })
548 .collect();
549 Value::Array(rebuilt)
550 }
551 Value::Array(items) => Value::Array(
552 items
553 .iter()
554 .map(|item| rewrite_tree(item, cursor, preserved_array, kept_mask, markers))
555 .collect(),
556 ),
557 Value::Object(map) => {
558 let mut out = Map::new();
559 for (k, v) in map {
560 out.insert(
561 k.clone(),
562 rewrite_tree(v, cursor, preserved_array, kept_mask, markers),
563 );
564 }
565 Value::Object(out)
566 }
567 _ => value.clone(),
568 }
569}
570
571pub fn revert_markers(json: &Value, restore: &std::collections::HashMap<String, Value>) -> Value {
577 if let Value::Object(map) = json
578 && map.len() == 1
579 && let Some(Value::Object(inner)) = map.get("$tf_ref")
580 && let Some(Value::String(hash)) = inner.get("hash")
581 && let Some(original) = restore.get(hash)
582 {
583 return original.clone();
584 }
585 match json {
586 Value::Array(items) => {
587 Value::Array(items.iter().map(|v| revert_markers(v, restore)).collect())
588 }
589 Value::Object(map) => {
590 let mut out = Map::new();
591 for (k, v) in map {
592 out.insert(k.clone(), revert_markers(v, restore));
593 }
594 Value::Object(out)
595 }
596 _ => json.clone(),
597 }
598}
599
600fn is_preserved(array_path: &str, preserve_paths: &[String]) -> bool {
623 let prefix = if array_path.is_empty() {
624 String::new()
625 } else {
626 format!("{array_path}.")
627 };
628 preserve_paths
629 .iter()
630 .any(|p| p == array_path || (p.len() > prefix.len() && p.starts_with(&prefix)))
631}
632
633#[derive(Debug, Clone, Copy)]
634struct FieldStats {
635 median: f64,
636 mad: f64,
637}
638
639fn numeric_field_stats(items: &[Value]) -> BTreeMap<String, FieldStats> {
645 let mut values: BTreeMap<String, Vec<f64>> = BTreeMap::new();
646 for item in items {
647 match item {
648 Value::Number(n) => {
649 if let Some(f) = n.as_f64() {
650 values.entry(SELF_FIELD.to_string()).or_default().push(f);
651 }
652 }
653 Value::Object(map) => {
654 for (k, v) in map {
655 if let Value::Number(n) = v
656 && let Some(f) = n.as_f64()
657 {
658 values.entry(k.clone()).or_default().push(f);
659 }
660 }
661 }
662 _ => {}
663 }
664 }
665 values
666 .into_iter()
667 .filter(|(_, v)| v.len() >= MIN_ARRAY_LEN)
668 .map(|(k, mut v)| {
669 let median = exact_median(&mut v);
670 let mut deviations: Vec<f64> = v.iter().map(|x| (x - median).abs()).collect();
671 let mad = exact_median(&mut deviations);
672 (k, FieldStats { median, mad })
673 })
674 .collect()
675}
676
677fn exact_median(values: &mut [f64]) -> f64 {
678 values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
679 let n = values.len();
680 if n == 0 {
681 return 0.0;
682 }
683 if n % 2 == 1 {
684 values[n / 2]
685 } else {
686 (values[n / 2 - 1] + values[n / 2]) / 2.0
687 }
688}
689
690fn modified_z_score(value: f64, stats: &FieldStats) -> f64 {
694 if stats.mad > 0.0 {
695 (value - stats.median).abs() / (1.4826 * stats.mad)
696 } else if value == stats.median {
697 0.0
698 } else {
699 DISCRETE_OUTLIER_SCORE
700 }
701}
702
703fn numeric_outlier_score(item: &Value, stats: &BTreeMap<String, FieldStats>) -> f64 {
704 match item {
705 Value::Number(n) => n
706 .as_f64()
707 .and_then(|f| stats.get(SELF_FIELD).map(|s| modified_z_score(f, s)))
708 .unwrap_or(0.0),
709 Value::Object(map) => map
710 .iter()
711 .filter_map(|(k, v)| {
712 let Value::Number(n) = v else { return None };
713 let f = n.as_f64()?;
714 let s = stats.get(k)?;
715 Some(modified_z_score(f, s))
716 })
717 .fold(0.0, f64::max),
718 _ => 0.0,
719 }
720}
721
722const STATUS_FALSE_KEYS: &[&str] = &["success", "ok", "healthy", "passed", "valid"];
723const ERROR_COUNT_KEYS: &[&str] = &[
724 "error_count",
725 "errors",
726 "failures",
727 "failure_count",
728 "retries",
729 "retry_count",
730];
731const STATUS_CODE_KEYS: &[&str] = &["status", "status_code", "http_status", "code"];
732
733fn has_structural_failure_signal(item: &Value) -> bool {
737 let Value::Object(map) = item else {
738 return false;
739 };
740 for (k, v) in map {
741 let lower = k.to_ascii_lowercase();
742 if STATUS_FALSE_KEYS.contains(&lower.as_str()) && v == &Value::Bool(false) {
743 return true;
744 }
745 if ERROR_COUNT_KEYS.contains(&lower.as_str()) && v.as_f64().is_some_and(|f| f > 0.0) {
746 return true;
747 }
748 if STATUS_CODE_KEYS.contains(&lower.as_str())
749 && v.as_i64().is_some_and(|n| (400..=599).contains(&n))
750 {
751 return true;
752 }
753 }
754 false
755}
756
757fn structural_fingerprint(item: &Value) -> String {
760 match item {
761 Value::Object(map) => {
762 let mut keys: Vec<&str> = map.keys().map(|k| k.as_str()).collect();
763 keys.sort_unstable();
764 keys.join(",")
765 }
766 Value::Array(_) => "array".to_string(),
767 Value::Number(_) => "number".to_string(),
768 Value::String(_) => "string".to_string(),
769 Value::Bool(_) => "bool".to_string(),
770 Value::Null => "null".to_string(),
771 }
772}
773
774#[cfg(test)]
775mod tests {
776 use super::*;
777 use crate::token_estimator::ByteHeuristicEstimator;
778
779 fn opts(ratio: f64) -> LossyOptions {
780 LossyOptions {
781 preserve_paths: Vec::new(),
782 ratio,
783 namespace: "default".to_string(),
784 }
785 }
786
787 fn padding() -> String {
792 "x".repeat(150)
793 }
794
795 #[test]
796 fn ratio_at_or_above_one_is_a_clean_noop() {
797 let input = br#"{"items":[{"a":1},{"a":2},{"a":3}]}"#;
798 assert!(
799 prune(input, &opts(1.0), &ByteHeuristicEstimator)
800 .unwrap()
801 .is_none()
802 );
803 }
804
805 #[test]
806 fn no_eligible_arrays_is_a_clean_noop() {
807 let input = br#"{"a":1,"items":[1]}"#; assert!(
809 prune(input, &opts(0.1), &ByteHeuristicEstimator)
810 .unwrap()
811 .is_none()
812 );
813 }
814
815 #[test]
816 fn preserved_array_is_never_touched_even_at_zero_ratio() {
817 let input = serde_json::json!({"items": [{"a":1},{"a":2},{"a":3},{"a":4}]});
818 let mut o = opts(0.0);
819 o.preserve_paths = vec!["items".to_string()];
820 let bytes = serde_json::to_vec(&input).unwrap();
821 assert!(
822 prune(&bytes, &o, &ByteHeuristicEstimator)
823 .unwrap()
824 .is_none()
825 );
826 }
827
828 #[test]
829 fn preserved_array_alongside_a_prunable_one_never_leaks_into_dropped_or_the_report() {
830 let p = padding();
838 let keep_items: Vec<Value> = (0..3)
842 .map(|i| serde_json::json!({"a": i, "guard": "KEEP_ME", "pad": p}))
843 .collect();
844 let prune_items: Vec<Value> = (0..4)
845 .map(|i| serde_json::json!({"a": i, "pad": p}))
846 .collect();
847 let input = serde_json::json!({"keep_me": keep_items, "prune_me": prune_items});
848 let bytes = serde_json::to_vec(&input).unwrap();
849 let mut o = opts(0.1);
850 o.preserve_paths = vec!["keep_me".to_string()];
851
852 let outcome = prune(&bytes, &o, &ByteHeuristicEstimator).unwrap().unwrap();
853
854 let keep_me_bytes: Vec<Vec<u8>> = keep_items
856 .iter()
857 .map(|v| serde_json::to_vec(v).unwrap())
858 .collect();
859 for d in &outcome.dropped {
860 assert!(
861 !keep_me_bytes.contains(&d.bytes),
862 "a preserved item leaked into outcome.dropped: {:?}",
863 String::from_utf8_lossy(&d.bytes)
864 );
865 }
866 assert_eq!(
869 outcome.report.total_candidates,
870 outcome.report.preserved_candidates
871 + outcome.report.kept_candidates
872 + outcome.report.dropped_candidates
873 );
874 assert_eq!(outcome.report.preserved_candidates, 3);
875 let out_keep_me = outcome.json["keep_me"].as_array().unwrap();
877 assert_eq!(out_keep_me, &keep_items);
878 }
879
880 #[test]
881 fn large_uniform_array_prunes_without_quadratic_blowup() {
882 let p = "x".repeat(150);
890 let items: Vec<Value> = (0..3000)
891 .map(|i| serde_json::json!({"n": i, "pad": p}))
892 .collect();
893 let input = serde_json::json!({"items": items});
894 let bytes = serde_json::to_vec(&input).unwrap();
895
896 let start = std::time::Instant::now();
897 let outcome = prune(&bytes, &opts(0.3), &ByteHeuristicEstimator).unwrap();
898 let elapsed = start.elapsed();
899
900 assert!(
901 elapsed < std::time::Duration::from_secs(5),
902 "prune() on 3000 uniform items took {elapsed:?} -- likely a quadratic regression"
903 );
904 assert!(outcome.is_some());
906 }
907
908 #[test]
909 fn zero_ratio_drops_low_priority_items_from_an_unpreserved_array() {
910 let p = padding();
911 let input = serde_json::json!({"items": (0..6).map(|i| serde_json::json!({"a": i, "pad": p})).collect::<Vec<_>>()});
912 let bytes = serde_json::to_vec(&input).unwrap();
913 let outcome = prune(&bytes, &opts(0.0), &ByteHeuristicEstimator)
914 .unwrap()
915 .expect("some items should drop at ratio 0.0");
916 assert!(outcome.report.dropped_candidates > 0);
917 assert_eq!(
918 outcome.report.dropped_candidates + outcome.report.kept_candidates,
919 outcome.report.total_candidates
920 );
921 let s = serde_json::to_string(&outcome.json).unwrap();
923 assert_eq!(s.matches("$tf_ref").count(), outcome.dropped.len());
924 }
925
926 #[test]
927 fn mad_zero_and_equal_to_median_has_no_outlier_signal() {
928 let stats = FieldStats {
929 median: 0.0,
930 mad: 0.0,
931 };
932 assert_eq!(modified_z_score(0.0, &stats), 0.0);
933 }
934
935 #[test]
936 fn mad_zero_and_different_from_median_is_a_strong_discrete_outlier() {
937 let stats = FieldStats {
940 median: 0.0,
941 mad: 0.0,
942 };
943 assert_eq!(modified_z_score(1.0, &stats), DISCRETE_OUTLIER_SCORE);
944 }
945
946 #[test]
947 fn mad_positive_uses_the_standard_modified_z_score_formula() {
948 let stats = FieldStats {
949 median: 10.0,
950 mad: 2.0,
951 };
952 let expected = (15.0_f64 - 10.0).abs() / (1.4826 * 2.0);
953 assert!((modified_z_score(15.0, &stats) - expected).abs() < 1e-9);
954 }
955
956 #[test]
957 fn structural_failure_signal_is_value_aware_not_substring_matched() {
958 assert!(!has_structural_failure_signal(
960 &serde_json::json!({"error_count": 0})
961 ));
962 assert!(!has_structural_failure_signal(
963 &serde_json::json!({"failed": false})
964 ));
965 assert!(has_structural_failure_signal(
967 &serde_json::json!({"success": false})
968 ));
969 assert!(has_structural_failure_signal(
970 &serde_json::json!({"error_count": 3})
971 ));
972 assert!(has_structural_failure_signal(
973 &serde_json::json!({"status_code": 503})
974 ));
975 assert!(!has_structural_failure_signal(
976 &serde_json::json!({"status_code": 200})
977 ));
978 }
979
980 #[test]
981 fn structural_failure_signal_survives_the_full_pipeline_at_low_ratio() {
982 let p = padding();
986 let mut items: Vec<Value> = (0..20)
987 .map(|i| serde_json::json!({"id": i, "success": true, "pad": p}))
988 .collect();
989 items[10] = serde_json::json!({"id": 10, "success": false, "pad": p});
990 let input = serde_json::json!({"items": items});
991 let bytes = serde_json::to_vec(&input).unwrap();
992 let outcome = prune(&bytes, &opts(0.1), &ByteHeuristicEstimator)
993 .unwrap()
994 .unwrap();
995 let arr = outcome.json["items"].as_array().unwrap();
996 assert_eq!(arr[10]["success"], serde_json::json!(false));
997 }
998
999 #[test]
1000 fn is_deterministic_across_repeated_runs() {
1001 let p = padding();
1002 let input = serde_json::json!({"items": (0..15).map(|i| serde_json::json!({"n": i, "pad": p})).collect::<Vec<_>>()});
1003 let bytes = serde_json::to_vec(&input).unwrap();
1004 let a = prune(&bytes, &opts(0.3), &ByteHeuristicEstimator)
1005 .unwrap()
1006 .unwrap();
1007 let b = prune(&bytes, &opts(0.3), &ByteHeuristicEstimator)
1008 .unwrap()
1009 .unwrap();
1010 assert_eq!(a.json, b.json);
1011 assert_eq!(
1012 a.dropped.iter().map(|d| &d.hash).collect::<Vec<_>>(),
1013 b.dropped.iter().map(|d| &d.hash).collect::<Vec<_>>()
1014 );
1015 }
1016
1017 #[test]
1018 fn diversity_walk_spreads_across_fingerprint_groups_before_draining_one() {
1019 let p = padding();
1023 let mut items = Vec::new();
1024 for i in 0..6 {
1025 items.push(serde_json::json!({"kind_a": i, "pad": p}));
1026 }
1027 for i in 0..6 {
1028 items.push(serde_json::json!({"kind_b": i, "pad": p}));
1029 }
1030 let input = serde_json::json!({"items": items});
1031 let bytes = serde_json::to_vec(&input).unwrap();
1032 let outcome = prune(&bytes, &opts(0.4), &ByteHeuristicEstimator)
1033 .unwrap()
1034 .unwrap();
1035 let arr = outcome.json["items"].as_array().unwrap();
1036 let kind_a_kept = arr[0..6]
1037 .iter()
1038 .filter(|v| v.get("kind_a").is_some())
1039 .count();
1040 let kind_b_kept = arr[6..12]
1041 .iter()
1042 .filter(|v| v.get("kind_b").is_some())
1043 .count();
1044 assert!(
1045 kind_a_kept > 0,
1046 "round-robin should keep at least one kind_a item"
1047 );
1048 assert!(
1049 kind_b_kept > 0,
1050 "round-robin should keep at least one kind_b item"
1051 );
1052 }
1053
1054 #[test]
1055 fn pruned_output_is_always_valid_json() {
1056 let p = padding();
1057 let input = serde_json::json!({"items": (0..10).map(|i| serde_json::json!({"n": i, "pad": p})).collect::<Vec<_>>()});
1058 let bytes = serde_json::to_vec(&input).unwrap();
1059 let outcome = prune(&bytes, &opts(0.5), &ByteHeuristicEstimator)
1060 .unwrap()
1061 .unwrap();
1062 let round_trip = serde_json::to_vec(&outcome.json).unwrap();
1063 assert!(serde_json::from_slice::<Value>(&round_trip).is_ok());
1064 }
1065
1066 #[test]
1067 fn dropped_item_hashes_match_their_own_bytes() {
1068 let p = padding();
1069 let input = serde_json::json!({"items": (0..8).map(|i| serde_json::json!({"n": i, "pad": p})).collect::<Vec<_>>()});
1070 let bytes = serde_json::to_vec(&input).unwrap();
1071 let outcome = prune(&bytes, &opts(0.1), &ByteHeuristicEstimator)
1072 .unwrap()
1073 .unwrap();
1074 for d in &outcome.dropped {
1075 assert_eq!(hex_sha256(&d.bytes), d.hash);
1076 }
1077 }
1078
1079 #[test]
1080 fn nested_arrays_are_found_but_not_recursed_into_when_the_parent_is_eligible() {
1081 let input = serde_json::json!({
1084 "groups": [
1085 {"users": [1,2,3]},
1086 {"users": [4,5,6]},
1087 ]
1088 });
1089 let bytes = serde_json::to_vec(&input).unwrap();
1090 let mut arrays = Vec::new();
1091 let value: Value = serde_json::from_slice(&bytes).unwrap();
1092 collect_eligible_arrays(&value, String::new(), &mut arrays);
1093 assert_eq!(
1094 arrays.len(),
1095 1,
1096 "only the outer array is eligible, not the nested ones"
1097 );
1098 assert_eq!(arrays[0].path, "groups");
1099 }
1100
1101 #[test]
1102 fn revert_markers_restores_only_the_named_hash_and_leaves_everything_else_alone() {
1103 let p = padding();
1104 let input = serde_json::json!({"items": (0..10).map(|i| serde_json::json!({"n": i, "pad": p})).collect::<Vec<_>>()});
1105 let bytes = serde_json::to_vec(&input).unwrap();
1106 let outcome = prune(&bytes, &opts(0.1), &ByteHeuristicEstimator)
1107 .unwrap()
1108 .unwrap();
1109 assert!(!outcome.dropped.is_empty());
1110
1111 let mut restore = std::collections::HashMap::new();
1112 let first = &outcome.dropped[0];
1113 let original: Value = serde_json::from_slice(&first.bytes).unwrap();
1114 restore.insert(first.hash.clone(), original.clone());
1115
1116 let reverted = revert_markers(&outcome.json, &restore);
1117 let s = serde_json::to_string(&reverted).unwrap();
1118 let remaining_markers = outcome.dropped.len() - 1;
1120 assert_eq!(s.matches("$tf_ref").count(), remaining_markers);
1121 let arr = reverted["items"].as_array().unwrap();
1122 assert!(arr.contains(&original));
1123 }
1124
1125 #[test]
1126 fn preserve_path_naming_something_inside_an_eligible_array_protects_that_array() {
1127 let p = padding();
1133 let input = serde_json::json!({
1134 "groups": (0..6).map(|i| serde_json::json!({"users": [1,2,3], "a": i, "pad": p})).collect::<Vec<_>>()
1135 });
1136 let bytes = serde_json::to_vec(&input).unwrap();
1137 let mut o = opts(0.0); o.preserve_paths = vec!["groups.users".to_string()];
1139 let outcome = prune(&bytes, &o, &ByteHeuristicEstimator).unwrap();
1140 assert!(
1141 outcome.is_none(),
1142 "\"groups.users\" must protect the whole \"groups\" array, leaving nothing to prune"
1143 );
1144 }
1145
1146 #[test]
1147 fn preserve_path_protects_an_eligible_root_array_too() {
1148 let p = padding();
1155 let input = serde_json::json!(
1156 (0..6)
1157 .map(|i| serde_json::json!({"users": [1,2,3], "a": i, "pad": p}))
1158 .collect::<Vec<_>>()
1159 );
1160 let bytes = serde_json::to_vec(&input).unwrap();
1161 let mut o = opts(0.0); o.preserve_paths = vec!["users".to_string()];
1163 assert!(
1164 prune(&bytes, &o, &ByteHeuristicEstimator)
1165 .unwrap()
1166 .is_none(),
1167 "a preserve path must protect the eligible ROOT array, leaving nothing to prune"
1168 );
1169 assert!(
1172 prune(&bytes, &opts(0.0), &ByteHeuristicEstimator)
1173 .unwrap()
1174 .is_some()
1175 );
1176 }
1177
1178 #[test]
1179 fn an_unrelated_preserve_path_does_not_protect_a_named_sibling_array() {
1180 let p = padding();
1184 let input = serde_json::json!({"items": (0..6).map(|i| serde_json::json!({"a": i, "pad": p})).collect::<Vec<_>>()});
1185 let bytes = serde_json::to_vec(&input).unwrap();
1186 let mut o = opts(0.0);
1187 o.preserve_paths = vec!["other.thing".to_string(), "items_extra".to_string()];
1188 assert!(
1189 prune(&bytes, &o, &ByteHeuristicEstimator)
1190 .unwrap()
1191 .is_some(),
1192 "neither an unrelated path nor a non-dot prefix extension may protect \"items\""
1193 );
1194 }
1195
1196 #[test]
1197 fn a_tier_three_acceptance_charges_the_exact_delta_not_the_independent_estimate() {
1198 let p = padding();
1204 let items: Vec<Value> = (0..40)
1205 .map(|i| serde_json::json!({"n": i, "pad": p, "note": format!("row {i}")}))
1206 .collect();
1207 let input = serde_json::json!({"items": items});
1208 let bytes = serde_json::to_vec(&input).unwrap();
1209 let est = ByteHeuristicEstimator;
1210 for ratio in [0.0, 0.2, 0.5, 0.9] {
1211 let Some(outcome) = prune(&bytes, &opts(ratio), &est).unwrap() else {
1212 continue;
1213 };
1214 let out_bytes = serde_json::to_vec(&outcome.json).unwrap();
1215 assert!(
1216 est.count_bytes(&out_bytes) <= est.count_bytes(&bytes),
1217 "ratio {ratio}: pruned output ({}) costs more than the input ({})",
1218 est.count_bytes(&out_bytes),
1219 est.count_bytes(&bytes)
1220 );
1221 }
1222 }
1223
1224 #[test]
1225 fn sibling_eligible_arrays_at_different_paths_are_both_found() {
1226 let input = serde_json::json!({
1227 "a": [1,2,3],
1228 "b": {"c": [4,5,6]},
1229 });
1230 let bytes = serde_json::to_vec(&input).unwrap();
1231 let value: Value = serde_json::from_slice(&bytes).unwrap();
1232 let mut arrays = Vec::new();
1233 collect_eligible_arrays(&value, String::new(), &mut arrays);
1234 let mut paths: Vec<&str> = arrays.iter().map(|a| a.path.as_str()).collect();
1235 paths.sort_unstable();
1236 assert_eq!(paths, vec!["a", "b.c"]);
1237 }
1238}