Skip to main content

tokenfold_core/transforms/
json_prune.rs

1//! `json_prune` — Phase 1 opt-in **lossy** JSON array-item selection (canonical id
2//! `"json_prune"`). Unlike every other transform in this module, this one is NOT lossless: it
3//! drops array items to hit a token budget, replacing each with a recoverable `{"$tf_ref":...}`
4//! marker. It is never part of the default lossless pipeline (`modes.rs`/`apply_transforms`) —
5//! see `pipeline::apply_lossy_reduction`, which only runs it when `policy.lossy.is_some()`, as a
6//! terminal stage strictly after the normal lossless transform loop.
7//!
8//! Full design/rationale: `docs/solution-design/lossy-json-compression.md` (gitignored,
9//! local-only). Summary of the algorithm implemented here:
10//!
11//! - **Explicit preserve, not inferred force-keep**: an array survives untouched iff its path is
12//!   listed in `LossyOptions.preserve_paths`. There is no generic "this row is important"
13//!   inference — that was verified to not exist anywhere in this codebase and to not be
14//!   invent-able generically.
15//! - **Static per-item rank**: value-aware structural failure signal (typed field checks, not
16//!   substring matching) > per-array-field median-absolute-deviation numeric outlier > edge
17//!   position > original index. Compared lexicographically, no weighted sum, so no cross-scale
18//!   calibration problem.
19//! - **Diversity as a separate allocator step**: candidates are grouped by a coarse structural
20//!   fingerprint and walked round-robin across groups (in each group's own static-rank order),
21//!   not folded into the static rank tuple — diversity is set-dependent, the rank tuple isn't.
22//! - **One global, shared budget** over every eligible array's items combined (not an
23//!   independent ratio per array), spent via a deterministic greedy walk mirroring
24//!   `eval/run_baselines.py::allocate()`'s three-tier fit check (proven-safe byte bound →
25//!   tested-safe heuristic-plus-margin bound → exact re-tokenize fallback) — never summed
26//!   independent per-item token estimates, which are not additive across tokenizer boundaries.
27//! - **Deterministic**: no randomness anywhere; the same input always produces the same output.
28//!
29//! This module never touches `RetrievalStore` — per `transforms/mod.rs`'s convention ("the
30//! pipeline owns all bookkeeping"), [`prune`] only decides *which* items to drop and returns
31//! their original bytes; `pipeline::apply_lossy_reduction` does the actual (fail-closed) storing
32//! and marker substitution.
33
34use 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
45/// Arrays with fewer items than this are never worth pruning — same threshold `json_field_fold`
46/// uses for its own "worth folding" early-out (`json_fold::MIN_ROWS`).
47const MIN_ARRAY_LEN: usize = 2;
48
49/// Sentinel field key representing "the array item's own scalar value" (used for arrays of bare
50/// numbers, as opposed to arrays of objects, in the per-field numeric-outlier stats map).
51const SELF_FIELD: &str = "$self";
52
53/// A discrete-outlier score assigned when `MAD == 0` and a value differs from the shared modal
54/// value anyway (design doc §4B item 2: deviating from a value the majority of a field's
55/// observations share is itself rare, and must not be scored as "no signal"). Any realistic
56/// modified z-score stays well under this, so it reliably outranks continuous outliers too.
57const DISCRETE_OUTLIER_SCORE: f64 = 1_000.0;
58
59/// Mirrors `eval/run_baselines.py::allocate()`'s empirically-calibrated tested-safe margin
60/// constants (design doc §4, "Selection mechanism").
61const 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    /// Dot-separated object-key paths (e.g. `"items"`, `"data.results"`) whose arrays must never
73    /// be pruned. Deliberately a minimal path syntax, not full JSONPath — see `is_preserved`.
74    pub preserve_paths: Vec<String>,
75    /// BEST-EFFORT selection hint, not an enforced budget (see `CompressionPolicy::lossy_ratio`):
76    /// the fraction (0.0..=1.0, clamped) of the prunable pool's own estimated token cost to keep.
77    /// It sets `budget_tokens` for the walk below and is never re-checked against the final
78    /// document — the pool is only the droppable candidates, so the achieved whole-document ratio
79    /// differs by design. `>= 1.0` is treated as "nothing to prune" and short-circuits to
80    /// `Ok(None)`.
81    pub ratio: f64,
82    /// Retrieval namespace `pipeline::apply_lossy_reduction` will store dropped items under —
83    /// only used here to size the marker template for cost accounting, never for storage itself.
84    pub namespace: String,
85}
86
87/// One dropped item's original bytes, keyed by its content hash (== the hash the marker
88/// substituted in its place points at). `pipeline::apply_lossy_reduction` persists these
89/// fail-closed: an item is only actually removed from the output if `RetrievalStore::store`
90/// returns `Ok` for it.
91#[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    /// The document with every proposed-drop item replaced by a `{"$tf_ref":...}` marker.
109    /// `pipeline::apply_lossy_reduction` may still put some items back (fail-closed) if storing
110    /// them fails — this is the *proposal*, not the final output.
111    pub json: Value,
112    pub dropped: Vec<DroppedItem>,
113    pub report: PruneReport,
114}
115
116/// Runs tiered lossy selection over `input`. Returns `Ok(None)` when there's nothing eligible to
117/// prune (no arrays of length >= [`MIN_ARRAY_LEN`] outside preserved paths, or `ratio >= 1.0`) —
118/// callers should treat that as a clean no-op, not force an empty transform report.
119pub 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    // Tier 2's running totals must use the SAME estimator as the exact tier 3 and the ceiling
200    // below — mixing a cheap heuristic estimator into one side of the interpolation and the real
201    // (e.g. tiktoken) estimator into the other would compare two different scales. Per
202    // `eval/run_baselines.py::allocate()`'s own design, the "cheap" half of tier 2 comes from
203    // tokenizing each unit INDEPENDENTLY once (fast: no growing joint string), not from a
204    // different, less accurate estimator — the real estimator is used throughout, just applied
205    // per-item instead of to a joint re-tokenize until tier 3.
206    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    // A `$tf_ref` marker has a real, fixed cost dominated by its 64-hex-char content hash, which
223    // a real tokenizer (BPE has no repeated-pattern structure to exploit in near-random hex)
224    // often encodes far LESS efficiently than ordinary text. So "start every candidate as a
225    // marker and upgrade toward budget" — the framing everything below assumes — is only sound
226    // for candidates where the marker is actually cheaper than the real item. For any candidate
227    // where it ISN'T (real_tok <= marker_tok: dropping it can only make things worse or is a
228    // wash), auto-keep it unconditionally, for free, with no budget spent — this is not an
229    // optimization, it's a correctness requirement: without it, a document made mostly of
230    // token-cheap real content and token-expensive markers gets a budget that undershoots even
231    // the "drop everything" baseline, and the walk below "correctly" spends nothing while still
232    // leaving every item dropped, which is a real regression the pipeline's own final safety
233    // gate has to roll back wholesale.
234    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    // `pool_bytes`/`pool_tokens` start at the marker baseline for the droppable subset (auto-kept
251    // candidates above already contribute their real cost, not a marker cost, since they're
252    // never markers) and walk upward toward the droppable subset's all-real ceiling as items are
253    // upgraded. The budget must live on that SAME scale — a naive `ratio * sum(real bytes)`
254    // target would be dwarfed by the marker baseline whenever markers cost more than the items
255    // they'd replace, making every candidate look unaffordable regardless of ratio. Interpolating
256    // between the two baselines keeps "ratio" meaning what it says: 0.0 stays at all-markers
257    // (for the droppable subset only), 1.0 would be all-real (short-circuited above for the
258    // whole document, but the droppable subset itself can still be ratio-limited at any value
259    // below that), values between move proportionally.
260    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    // Tier 3 re-serializes and re-tokenizes the WHOLE candidate's array on every call — O(array
271    // length), not O(1). Left uncapped, an array where many candidates land right at the budget
272    // boundary (a realistic shape: uniform-size items) falls through to tier 3 for most of them,
273    // turning the walk into O(n^2) for that array. Capping how many exact calls any one array
274    // may spend bounds tier 3's total cost to O(array length) regardless of how many candidates
275    // are borderline — candidates beyond the cap fall back to tiers 1/2 only (a more conservative
276    // reject, never a wrong accept: the outer pipeline's own final exact-recount regression check
277    // still gates the whole transform, so a slightly-too-conservative per-candidate decision here
278    // can only under-prune, never produce a silent regression).
279    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        // Set only when tier 3 actually ran: its joint re-tokenize is the exact cost of this
293        // upgrade, so once it has been paid for it must also be what gets CHARGED to the running
294        // pool. Charging the cheaper independent tier-2 estimate instead (the old behavior) threw
295        // the exact number away and let per-item BPE-boundary error accumulate across every
296        // tier-3 acceptance, so the running total drifted from what the same document really
297        // costs -- exactly the drift tier 3 exists to eliminate.
298        let mut exact_delta: Option<i64> = None;
299
300        // Tier 1 (proven-safe): byte length is always >= token count, so if the byte-bound
301        // trial already fits, the real token count fits too — free accept, no estimator call.
302        let fits = trial_bytes <= budget_tokens
303            // Tier 2 (tested-safe): each unit's own real token count (independently computed,
304            // no joint re-tokenize) plus an empirically-calibrated margin — accept without the
305            // expensive joint-assembly exact call.
306            || trial_tokens + margin <= budget_tokens
307            // Tier 3 (exact, the only real decider when 1/2 are inconclusive, capped above): re-
308            // tokenize this candidate's OWN array as currently assembled, with and without it
309            // upgraded — a real joint re-tokenize, never a summed independent estimate, of the
310            // one place non-additive BPE boundary effects actually occur (adjacent items in the
311            // same array). The resulting delta is charged against the shared global budget.
312            || (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        // Preserved arrays are never candidates in the first place (see the loop that builds
340        // `candidates` above), so their `kept_mask` row is left all-`false` by construction —
341        // NOT because every item was dropped. `rewrite_tree` already knows this and leaves them
342        // untouched; this loop must agree, or every preserved item gets wrongly reported (and
343        // persisted to the retrieval store by `pipeline::apply_lossy_reduction`) as dropped.
344        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    /// Higher is more important to keep. `Ordering::Greater` from this means `self` outranks
386    /// `other`. Ties are NOT broken here (original index is the final tiebreak, applied by the
387    /// stable sort that calls this, per candidate insertion order).
388    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
408/// Design §4C: groups candidates by structural fingerprint, ranks within each group by the
409/// static [`RankKey`] (highest first — a stable sort, so original insertion order = original
410/// array-then-item order is the final tiebreak), then interleaves groups round-robin (groups
411/// themselves ordered by their own best member's rank) so an early budget cutoff doesn't drain
412/// one group before ever touching another. `BTreeMap` keeps fingerprint iteration
413/// lexicographically deterministic — a `HashMap` here would make the walk order (and therefore
414/// the output) nondeterministic between runs, since Rust's default hasher is randomized.
415/// `eligible` restricts the walk to a subset of `candidates` (the ones actually worth
416/// considering for dropping — see the auto-keep partition in `prune`); indices not in `eligible`
417/// never appear in the returned order.
418fn 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    // Round-robin merge across groups (each already non-empty and internally rank-sorted),
438    // ordered by group priority. `active` is pruned of drained groups after every round instead
439    // of being rescanned in full each time — without this, a document with many small/singleton
440    // fingerprint groups plus one large one costs O(rounds * groups) to walk (every drained
441    // singleton group gets needlessly re-visited on every remaining round), which is ~O(n^2) for
442    // that shape.
443    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
485/// Recursively finds every array with `len() >= MIN_ARRAY_LEN` in document order (objects
486/// visited key-by-key, arrays visited index-by-index). Deliberately does NOT recurse into an
487/// eligible array's own items to look for further nested eligible arrays inside them — doing so
488/// would let a dropped parent item "orphan" child candidates that were independently scored,
489/// which has no coherent selection semantics. Revisit only if real payloads need multi-level
490/// nested pruning; sibling/independent eligible arrays elsewhere in the tree are unaffected by
491/// this cut. Must stay traversal-identical to [`rewrite_tree`] — both increment their cursor in
492/// lockstep over the same `Value`, which is how the two passes stay correlated without needing a
493/// separate stable identity per array instance.
494fn 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
521/// Mirror of [`collect_eligible_arrays`]'s traversal, consuming `preserved_array`/`kept_mask`/
522/// `markers` (indexed by the same cursor order) to rebuild the document with dropped items
523/// replaced by their markers.
524fn 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
571/// Puts specific dropped items back, keyed by content hash — the fail-closed half of the
572/// contract (`pipeline::apply_lossy_reduction`): an item whose `RetrievalStore::store` call
573/// failed must not be silently lost, so the caller restores it here rather than leaving its
574/// `$tf_ref` marker in place. Recognizes exactly the marker shape [`marker_json`] builds; any
575/// other node (including one that merely looks similar) is left untouched.
576pub 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
600/// Minimal dot-separated path match (exact equality, plus one fail-safe fallback) —
601/// deliberately not full JSONPath (no `$`, `[*]`, filters, or recursive descent). A whole array
602/// is preserved or it isn't; there's no field-level partial preservation in Phase 1.
603///
604/// `collect_eligible_arrays` never recurses into an array once it's deemed eligible, so a
605/// preserve path naming something INSIDE an eligible array (e.g. `"groups.users"` when
606/// `"groups"` itself is eligible) can never match its own array — nothing named `"groups.users"`
607/// is ever registered as an `EligibleArray`. Silently matching nothing (the old behavior) means
608/// `--lossy-preserve` can silently protect NOTHING, which is a safety promise a caller relied on
609/// going unmet without any signal. Instead, treat any preserve path that is a strict
610/// dot-separated prefix-extension of `array_path` as protecting the nearest enclosing eligible
611/// array — the only array that path could possibly have meant, given Phase 1's no-recursion
612/// scope cut.
613///
614/// The ROOT array is the degenerate case of that rule and must not be special-cased away: an
615/// eligible root array has `array_path == ""`, and when the root itself is eligible it is the
616/// ONLY eligible array in the document (`collect_eligible_arrays` stops there), so *every*
617/// non-empty preserve path can only have meant something inside it. Building the prefix
618/// conditionally — `""` at the root, `"{path}."` elsewhere — makes both cases the same
619/// "strictly longer than the prefix, and starts with it" test; the earlier
620/// `starts_with("{array_path}.")` form silently protected nothing at the root, since no path
621/// starts with a bare `"."`.
622fn 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
639/// Per-array, per-field median and MAD, computed once over every item in the array (outlier-ness
640/// is a property of the whole distribution, independent of which items are later chosen).
641/// Object items contribute their numeric fields by key; bare-number items contribute under
642/// [`SELF_FIELD`]. Uses a full sort for the exact median — `slice::select_nth_unstable` would be
643/// the O(n) alternative; sort is simpler and plenty fast at realistic array sizes.
644fn 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
690/// Design §4B item 2's corrected `MAD == 0` handling: `MAD > 0` uses the standard modified
691/// z-score; `MAD == 0` (a value shared by the majority) means any *different* value is itself a
692/// strong discrete-outlier signal, not "no signal" — the earlier draft had this backwards.
693fn 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
733/// Value-aware structural failure-signal detection — typed field checks, never substring
734/// matching on serialized text (which would false-positive on e.g. `"error_count": 0` or
735/// `"failed": false`, since those strings literally contain the flagged word).
736fn 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
757/// Coarse structural shape signature for the diversity allocator (design §4C): object items
758/// group by their sorted key set, everything else groups by its JSON type name.
759fn 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    /// A `$tf_ref` marker has real, fixed overhead (hash/alg/namespace scaffolding, ~120+
788    /// bytes) — an item genuinely smaller than that can never be worth dropping (see the
789    /// budget-scale comment in `prune`), so most tests need items padded comfortably past it to
790    /// exercise actual dropping, not just the "too small to bother" free-keep path.
791    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]}"#; // single-item array is below MIN_ARRAY_LEN
808        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        // Regression test: an adversarial review caught that the dropped-item collection loop
831        // didn't skip preserved arrays, so every item of `keep_me` was wrongly pushed into
832        // `outcome.dropped` (even though the output JSON correctly left it untouched) -- which
833        // both persisted "preserved" content to the retrieval store one layer up, and caused
834        // `total_candidates - preserved_candidates - dropped_candidates` to underflow whenever
835        // preserved items outnumbered the real kept count among prunable arrays, exactly the
836        // shape here (3 preserved vs. only a few of the 4 prune_me items surviving at ratio 0.1).
837        let p = padding();
838        // `keep_me` items carry a distinct marker field so byte-comparison below can never
839        // coincidentally match a genuinely-dropped `prune_me` item -- a real leak vs. a
840        // fixture content collision must not be ambiguous.
841        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        // No dropped item's bytes may parse back as one of the preserved keep_me items.
855        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        // The report's arithmetic must be internally consistent (this would have panicked with
867        // an underflow before the fix).
868        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        // keep_me's 3 items must all still be present, untouched, in the output.
876        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        // Regression test for an adversarial review's O(n^2) finding: many candidates landing
883        // right at the budget boundary (uniform item size) used to fall through to the
884        // expensive exact-recount tier for nearly every item once the budget saturated, with no
885        // cap -- turning the walk quadratic. This asserts it stays fast at a size where the old
886        // behavior was measured taking multiple seconds (debug build, cheap heuristic
887        // estimator): a real regression would make this test time out or take far longer than
888        // this generous bound.
889        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        // Still does real work, not a no-op that trivially "passes" by doing nothing.
905        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        // Every dropped item's marker must actually be present in the rewritten document.
922        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        // The [0,0,0,0,1]-shaped case: MAD collapses to 0, but the lone `1` must NOT be
938        // suppressed as "no signal" -- it's exactly what MAD exists to catch.
939        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        // These must NOT be flagged: the substring "error" appears, but the value is falsy/zero.
959        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        // These MUST be flagged: real signal.
966        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        // A planted mid-array anomaly with a value-aware failure signal, surrounded by bland
983        // filler -- the exact "plant the answer mid-array" shape the design doc's eval plan
984        // requires. Must survive even at an aggressive (low) ratio.
985        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        // Two structurally distinct groups (different key sets => different fingerprints), all
1020        // otherwise tied on rank. A pure "sort by rank, take top N" would happily drain one
1021        // group entirely before touching the other; round-robin must not.
1022        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        // The outer "groups" array is eligible; its items' inner "users" arrays are deliberately
1082        // NOT separately recursed into (design's documented Phase 1 scope cut).
1083        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        // The reverted item's marker is gone, but any remaining dropped items' markers survive.
1119        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        // Round-4 external review: `collect_eligible_arrays` never recurses into an array once
1128        // it's deemed eligible, so a preserve path naming something INSIDE that array (e.g.
1129        // "groups.users" when "groups" itself is eligible) could never match anything -- the
1130        // flag silently protected nothing, which is unacceptable for a caller relying on it as
1131        // a safety guarantee. It must now protect the nearest enclosing eligible array instead.
1132        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); // maximum drop pressure
1138        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        // Round-5 external review: the nearest-eligible-ancestor rule above was implemented as
1149        // `p.starts_with("{array_path}.")`, which is unsatisfiable at the root (`array_path` is
1150        // "", and no path starts with a bare "."). A live repro confirmed a top-level array of
1151        // objects carrying a `users` field was pruned to 20 markers despite
1152        // `--lossy-preserve users`. An eligible root array is the ONLY eligible array in its
1153        // document, so any preserve path at all must protect it.
1154        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); // maximum drop pressure
1162        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        // Control: without the preserve path, the same document DOES prune -- otherwise the
1170        // assertion above would pass for the wrong reason (nothing prunable in the first place).
1171        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        // The prefix rule must stay strict for non-root arrays: "other.thing" names nothing
1181        // inside "items", so "items" is still prunable. (At the root the rule is deliberately
1182        // total -- see the test above -- but that must not leak into named paths.)
1183        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        // Round-5 external review: tier 3 paid for an exact joint re-tokenize and then threw the
1199        // result away, charging the cheaper independent tier-2 estimate to the running pool. This
1200        // asserts the invariant that made the exact call worth making: whatever the walk accepts,
1201        // the FINAL exact token count of the pruned document must not exceed the input's -- i.e.
1202        // the accounting the walk ran on has to track reality, not drift above it.
1203        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}