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