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