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