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}
95
96#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
97pub struct PruneReport {
98    pub eligible_arrays: usize,
99    pub total_candidates: usize,
100    pub preserved_candidates: usize,
101    pub kept_candidates: usize,
102    pub dropped_candidates: usize,
103}
104
105#[derive(Debug, Clone)]
106pub struct PruneOutcome {
107    /// The document with every proposed-drop item replaced by a `{"$tf_ref":...}` marker.
108    /// `pipeline::apply_lossy_reduction` may still put some items back (fail-closed) if storing
109    /// them fails — this is the *proposal*, not the final output.
110    pub json: Value,
111    pub dropped: Vec<DroppedItem>,
112    pub report: PruneReport,
113}
114
115/// Runs tiered lossy selection over `input`. Returns `Ok(None)` when there's nothing eligible to
116/// prune (no arrays of length >= [`MIN_ARRAY_LEN`] outside preserved paths, or `ratio >= 1.0`) —
117/// callers should treat that as a clean no-op, not force an empty transform report.
118pub fn prune(
119    input: &[u8],
120    options: &LossyOptions,
121    estimator: &dyn TokenEstimator,
122) -> Result<Option<PruneOutcome>, JsonPruneError> {
123    if input.is_empty() || options.ratio >= 1.0 {
124        return Ok(None);
125    }
126    let value: Value = serde_json::from_slice(input)?;
127
128    let mut arrays = Vec::new();
129    collect_eligible_arrays(&value, String::new(), &mut arrays);
130    if arrays.is_empty() {
131        return Ok(None);
132    }
133
134    let preserved_array: Vec<bool> = arrays
135        .iter()
136        .map(|a| is_preserved(&a.path, &options.preserve_paths))
137        .collect();
138    let field_stats: Vec<BTreeMap<String, FieldStats>> = arrays
139        .iter()
140        .map(|a| numeric_field_stats(&a.items))
141        .collect();
142    let markers: Vec<Vec<Value>> = arrays
143        .iter()
144        .map(|a| {
145            a.items
146                .iter()
147                .map(|item| {
148                    let bytes = serde_json::to_vec(item).unwrap_or_default();
149                    marker_json(&hex_sha256(&bytes), &options.namespace)
150                })
151                .collect()
152        })
153        .collect();
154    let marker_bytes_cache: Vec<Vec<Vec<u8>>> = markers
155        .iter()
156        .map(|per_array| {
157            per_array
158                .iter()
159                .map(|m| serde_json::to_vec(m).unwrap_or_default())
160                .collect()
161        })
162        .collect();
163
164    let total_candidates: usize = arrays.iter().map(|a| a.items.len()).sum();
165    let preserved_candidates: usize = arrays
166        .iter()
167        .zip(&preserved_array)
168        .filter(|&(_, &p)| p)
169        .map(|(a, _)| a.items.len())
170        .sum();
171
172    let mut candidates = Vec::new();
173    for (array_idx, array) in arrays.iter().enumerate() {
174        if preserved_array[array_idx] {
175            continue;
176        }
177        let stats = &field_stats[array_idx];
178        let n = array.items.len();
179        for (item_idx, item) in array.items.iter().enumerate() {
180            candidates.push(Candidate {
181                array_idx,
182                item_idx,
183                bytes: serde_json::to_vec(item).unwrap_or_default(),
184                rank: RankKey {
185                    failure_signal: has_structural_failure_signal(item),
186                    outlier_score: numeric_outlier_score(item, stats),
187                    edge_bonus: item_idx == 0 || item_idx + 1 == n,
188                },
189                fingerprint: structural_fingerprint(item),
190            });
191        }
192    }
193
194    if candidates.is_empty() {
195        return Ok(None);
196    }
197
198    // Tier 2's running totals must use the SAME estimator as the exact tier 3 and the ceiling
199    // below — mixing a cheap heuristic estimator into one side of the interpolation and the real
200    // (e.g. tiktoken) estimator into the other would compare two different scales. Per
201    // `eval/run_baselines.py::allocate()`'s own design, the "cheap" half of tier 2 comes from
202    // tokenizing each unit INDEPENDENTLY once (fast: no growing joint string), not from a
203    // different, less accurate estimator — the real estimator is used throughout, just applied
204    // per-item instead of to a joint re-tokenize until tier 3.
205    let marker_tokens_cache: Vec<Vec<i64>> = marker_bytes_cache
206        .iter()
207        .map(|per_array| {
208            per_array
209                .iter()
210                .map(|m| estimator.count_bytes(m) as i64)
211                .collect()
212        })
213        .collect();
214    let real_tokens_cache: Vec<i64> = candidates
215        .iter()
216        .map(|c| estimator.count_bytes(&c.bytes) as i64)
217        .collect();
218
219    let mut kept_mask: Vec<Vec<bool>> = arrays.iter().map(|a| vec![false; a.items.len()]).collect();
220
221    // A `$tf_ref` marker has a real, fixed cost dominated by its 64-hex-char content hash, which
222    // a real tokenizer (BPE has no repeated-pattern structure to exploit in near-random hex)
223    // often encodes far LESS efficiently than ordinary text. So "start every candidate as a
224    // marker and upgrade toward budget" — the framing everything below assumes — is only sound
225    // for candidates where the marker is actually cheaper than the real item. For any candidate
226    // where it ISN'T (real_tok <= marker_tok: dropping it can only make things worse or is a
227    // wash), auto-keep it unconditionally, for free, with no budget spent — this is not an
228    // optimization, it's a correctness requirement: without it, a document made mostly of
229    // token-cheap real content and token-expensive markers gets a budget that undershoots even
230    // the "drop everything" baseline, and the walk below "correctly" spends nothing while still
231    // leaving every item dropped, which is a real regression the pipeline's own final safety
232    // gate has to roll back wholesale.
233    let mut droppable: Vec<usize> = Vec::new();
234    for (idx, c) in candidates.iter().enumerate() {
235        let marker_tok = marker_tokens_cache[c.array_idx][c.item_idx];
236        if real_tokens_cache[idx] > marker_tok {
237            droppable.push(idx);
238        } else {
239            kept_mask[c.array_idx][c.item_idx] = true;
240        }
241    }
242
243    let walk_order = diversity_walk_order(&candidates, &droppable);
244
245    let mut pool_tokens: i64 = droppable
246        .iter()
247        .map(|&idx| marker_tokens_cache[candidates[idx].array_idx][candidates[idx].item_idx])
248        .sum();
249    // `pool_bytes`/`pool_tokens` start at the marker baseline for the droppable subset (auto-kept
250    // candidates above already contribute their real cost, not a marker cost, since they're
251    // never markers) and walk upward toward the droppable subset's all-real ceiling as items are
252    // upgraded. The budget must live on that SAME scale — a naive `ratio * sum(real bytes)`
253    // target would be dwarfed by the marker baseline whenever markers cost more than the items
254    // they'd replace, making every candidate look unaffordable regardless of ratio. Interpolating
255    // between the two baselines keeps "ratio" meaning what it says: 0.0 stays at all-markers
256    // (for the droppable subset only), 1.0 would be all-real (short-circuited above for the
257    // whole document, but the droppable subset itself can still be ratio-limited at any value
258    // below that), values between move proportionally.
259    let mut pool_bytes: i64 = droppable
260        .iter()
261        .map(|&idx| {
262            marker_bytes_cache[candidates[idx].array_idx][candidates[idx].item_idx].len() as i64
263        })
264        .sum();
265    let real_total_tokens: i64 = droppable.iter().map(|&idx| real_tokens_cache[idx]).sum();
266    let budget_tokens = pool_tokens
267        + ((real_total_tokens - pool_tokens) as f64 * options.ratio.clamp(0.0, 1.0)).round() as i64;
268    let mut accepted = 0usize;
269    // Tier 3 re-serializes and re-tokenizes the WHOLE candidate's array on every call — O(array
270    // length), not O(1). Left uncapped, an array where many candidates land right at the budget
271    // boundary (a realistic shape: uniform-size items) falls through to tier 3 for most of them,
272    // turning the walk into O(n^2) for that array. Capping how many exact calls any one array
273    // may spend bounds tier 3's total cost to O(array length) regardless of how many candidates
274    // are borderline — candidates beyond the cap fall back to tiers 1/2 only (a more conservative
275    // reject, never a wrong accept: the outer pipeline's own final exact-recount regression check
276    // still gates the whole transform, so a slightly-too-conservative per-candidate decision here
277    // can only under-prune, never produce a silent regression).
278    const MAX_EXACT_TIER_CALLS_PER_ARRAY: usize = 16;
279    let mut exact_calls_used = vec![0usize; arrays.len()];
280
281    for &idx in &walk_order {
282        let c = &candidates[idx];
283        let marker_len = marker_bytes_cache[c.array_idx][c.item_idx].len() as i64;
284        let marker_tok = marker_tokens_cache[c.array_idx][c.item_idx];
285        let real_len = c.bytes.len() as i64;
286        let real_tok = real_tokens_cache[idx];
287
288        let trial_bytes = pool_bytes - marker_len + real_len;
289        let trial_tokens = pool_tokens - marker_tok + real_tok;
290        let margin = MARGIN_FLOOR + (MARGIN_PER_ITEM * accepted as f64) as i64;
291        // Set only when tier 3 actually ran: its joint re-tokenize is the exact cost of this
292        // upgrade, so once it has been paid for it must also be what gets CHARGED to the running
293        // pool. Charging the cheaper independent tier-2 estimate instead (the old behavior) threw
294        // the exact number away and let per-item BPE-boundary error accumulate across every
295        // tier-3 acceptance, so the running total drifted from what the same document really
296        // costs -- exactly the drift tier 3 exists to eliminate.
297        let mut exact_delta: Option<i64> = None;
298
299        // Tier 1 (proven-safe): byte length is always >= token count, so if the byte-bound
300        // trial already fits, the real token count fits too — free accept, no estimator call.
301        let fits = trial_bytes <= budget_tokens
302            // Tier 2 (tested-safe): each unit's own real token count (independently computed,
303            // no joint re-tokenize) plus an empirically-calibrated margin — accept without the
304            // expensive joint-assembly exact call.
305            || trial_tokens + margin <= budget_tokens
306            // Tier 3 (exact, the only real decider when 1/2 are inconclusive, capped above): re-
307            // tokenize this candidate's OWN array as currently assembled, with and without it
308            // upgraded — a real joint re-tokenize, never a summed independent estimate, of the
309            // one place non-additive BPE boundary effects actually occur (adjacent items in the
310            // same array). The resulting delta is charged against the shared global budget.
311            || (exact_calls_used[c.array_idx] < MAX_EXACT_TIER_CALLS_PER_ARRAY && {
312                exact_calls_used[c.array_idx] += 1;
313                kept_mask[c.array_idx][c.item_idx] = true;
314                let with = estimator.count_bytes(&assemble(&arrays[c.array_idx], &kept_mask[c.array_idx], &markers[c.array_idx]));
315                kept_mask[c.array_idx][c.item_idx] = false;
316                let without = estimator.count_bytes(&assemble(&arrays[c.array_idx], &kept_mask[c.array_idx], &markers[c.array_idx]));
317                let delta = with as i64 - without as i64;
318                exact_delta = Some(delta);
319                pool_tokens + delta <= budget_tokens
320            });
321
322        if fits {
323            kept_mask[c.array_idx][c.item_idx] = true;
324            pool_bytes = trial_bytes;
325            pool_tokens = match exact_delta {
326                Some(delta) => pool_tokens + delta,
327                None => trial_tokens,
328            };
329            accepted += 1;
330        }
331    }
332
333    let mut cursor = 0usize;
334    let pruned = rewrite_tree(&value, &mut cursor, &preserved_array, &kept_mask, &markers);
335
336    let mut dropped = Vec::new();
337    for (array_idx, mask) in kept_mask.iter().enumerate() {
338        // Preserved arrays are never candidates in the first place (see the loop that builds
339        // `candidates` above), so their `kept_mask` row is left all-`false` by construction —
340        // NOT because every item was dropped. `rewrite_tree` already knows this and leaves them
341        // untouched; this loop must agree, or every preserved item gets wrongly reported (and
342        // persisted to the retrieval store by `pipeline::apply_lossy_reduction`) as dropped.
343        if preserved_array[array_idx] {
344            continue;
345        }
346        for (item_idx, &kept) in mask.iter().enumerate() {
347            if !kept {
348                dropped.push(DroppedItem {
349                    hash: hex_sha256(
350                        &serde_json::to_vec(&arrays[array_idx].items[item_idx]).unwrap_or_default(),
351                    ),
352                    bytes: serde_json::to_vec(&arrays[array_idx].items[item_idx])
353                        .unwrap_or_default(),
354                });
355            }
356        }
357    }
358
359    if dropped.is_empty() {
360        return Ok(None);
361    }
362
363    let dropped_candidates = dropped.len();
364    Ok(Some(PruneOutcome {
365        json: pruned,
366        dropped,
367        report: PruneReport {
368            eligible_arrays: arrays.len(),
369            total_candidates,
370            preserved_candidates,
371            kept_candidates: total_candidates - preserved_candidates - dropped_candidates,
372            dropped_candidates,
373        },
374    }))
375}
376
377struct RankKey {
378    failure_signal: bool,
379    outlier_score: f64,
380    edge_bonus: bool,
381}
382
383impl RankKey {
384    /// Higher is more important to keep. `Ordering::Greater` from this means `self` outranks
385    /// `other`. Ties are NOT broken here (original index is the final tiebreak, applied by the
386    /// stable sort that calls this, per candidate insertion order).
387    fn cmp(&self, other: &RankKey) -> Ordering {
388        self.failure_signal
389            .cmp(&other.failure_signal)
390            .then_with(|| {
391                self.outlier_score
392                    .partial_cmp(&other.outlier_score)
393                    .unwrap_or(Ordering::Equal)
394            })
395            .then_with(|| self.edge_bonus.cmp(&other.edge_bonus))
396    }
397}
398
399struct Candidate {
400    array_idx: usize,
401    item_idx: usize,
402    bytes: Vec<u8>,
403    rank: RankKey,
404    fingerprint: String,
405}
406
407/// Design §4C: groups candidates by structural fingerprint, ranks within each group by the
408/// static [`RankKey`] (highest first — a stable sort, so original insertion order = original
409/// array-then-item order is the final tiebreak), then interleaves groups round-robin (groups
410/// themselves ordered by their own best member's rank) so an early budget cutoff doesn't drain
411/// one group before ever touching another. `BTreeMap` keeps fingerprint iteration
412/// lexicographically deterministic — a `HashMap` here would make the walk order (and therefore
413/// the output) nondeterministic between runs, since Rust's default hasher is randomized.
414/// `eligible` restricts the walk to a subset of `candidates` (the ones actually worth
415/// considering for dropping — see the auto-keep partition in `prune`); indices not in `eligible`
416/// never appear in the returned order.
417fn diversity_walk_order(candidates: &[Candidate], eligible: &[usize]) -> Vec<usize> {
418    let mut groups: BTreeMap<String, Vec<usize>> = BTreeMap::new();
419    for &idx in eligible {
420        let c = &candidates[idx];
421        groups.entry(c.fingerprint.clone()).or_default().push(idx);
422    }
423    for members in groups.values_mut() {
424        members.sort_by(|&a, &b| candidates[b].rank.cmp(&candidates[a].rank));
425    }
426    let mut group_order: Vec<String> = groups.keys().cloned().collect();
427    group_order.sort_by(|a, b| {
428        let ra = &candidates[groups[a][0]].rank;
429        let rb = &candidates[groups[b][0]].rank;
430        rb.cmp(ra)
431    });
432
433    let mut cursors: BTreeMap<String, usize> =
434        group_order.iter().map(|k| (k.clone(), 0usize)).collect();
435    let mut order = Vec::with_capacity(eligible.len());
436    // Round-robin merge across groups (each already non-empty and internally rank-sorted),
437    // ordered by group priority. `active` is pruned of drained groups after every round instead
438    // of being rescanned in full each time — without this, a document with many small/singleton
439    // fingerprint groups plus one large one costs O(rounds * groups) to walk (every drained
440    // singleton group gets needlessly re-visited on every remaining round), which is ~O(n^2) for
441    // that shape.
442    let mut active: Vec<String> = group_order;
443    while !active.is_empty() {
444        for key in &active {
445            let members = &groups[key];
446            let cursor = cursors.get_mut(key).expect("seeded above");
447            order.push(members[*cursor]);
448            *cursor += 1;
449        }
450        active.retain(|key| cursors[key] < groups[key].len());
451    }
452    order
453}
454
455fn assemble(array: &EligibleArray, kept_mask: &[bool], markers: &[Value]) -> Vec<u8> {
456    let items: Vec<Value> = array
457        .items
458        .iter()
459        .zip(kept_mask)
460        .zip(markers)
461        .map(|((item, &kept), marker)| if kept { item.clone() } else { marker.clone() })
462        .collect();
463    serde_json::to_vec(&Value::Array(items)).unwrap_or_default()
464}
465
466fn marker_json(hash: &str, namespace: &str) -> Value {
467    let mut inner = Map::new();
468    inner.insert("hash".to_string(), Value::String(hash.to_string()));
469    inner.insert("alg".to_string(), Value::String("sha256".to_string()));
470    inner.insert(
471        "namespace".to_string(),
472        Value::String(namespace.to_string()),
473    );
474    let mut outer = Map::new();
475    outer.insert("$tf_ref".to_string(), Value::Object(inner));
476    Value::Object(outer)
477}
478
479struct EligibleArray {
480    path: String,
481    items: Vec<Value>,
482}
483
484/// Recursively finds every array with `len() >= MIN_ARRAY_LEN` in document order (objects
485/// visited key-by-key, arrays visited index-by-index). Deliberately does NOT recurse into an
486/// eligible array's own items to look for further nested eligible arrays inside them — doing so
487/// would let a dropped parent item "orphan" child candidates that were independently scored,
488/// which has no coherent selection semantics. Revisit only if real payloads need multi-level
489/// nested pruning; sibling/independent eligible arrays elsewhere in the tree are unaffected by
490/// this cut. Must stay traversal-identical to [`rewrite_tree`] — both increment their cursor in
491/// lockstep over the same `Value`, which is how the two passes stay correlated without needing a
492/// separate stable identity per array instance.
493fn collect_eligible_arrays(value: &Value, path: String, out: &mut Vec<EligibleArray>) {
494    match value {
495        Value::Array(items) if items.len() >= MIN_ARRAY_LEN => {
496            out.push(EligibleArray {
497                path,
498                items: items.clone(),
499            });
500        }
501        Value::Array(items) => {
502            for item in items {
503                collect_eligible_arrays(item, path.clone(), out);
504            }
505        }
506        Value::Object(map) => {
507            for (k, v) in map {
508                let child_path = if path.is_empty() {
509                    k.clone()
510                } else {
511                    format!("{path}.{k}")
512                };
513                collect_eligible_arrays(v, child_path, out);
514            }
515        }
516        _ => {}
517    }
518}
519
520/// Mirror of [`collect_eligible_arrays`]'s traversal, consuming `preserved_array`/`kept_mask`/
521/// `markers` (indexed by the same cursor order) to rebuild the document with dropped items
522/// replaced by their markers.
523fn rewrite_tree(
524    value: &Value,
525    cursor: &mut usize,
526    preserved_array: &[bool],
527    kept_mask: &[Vec<bool>],
528    markers: &[Vec<Value>],
529) -> Value {
530    match value {
531        Value::Array(items) if items.len() >= MIN_ARRAY_LEN => {
532            let idx = *cursor;
533            *cursor += 1;
534            if preserved_array[idx] {
535                return value.clone();
536            }
537            let rebuilt: Vec<Value> = items
538                .iter()
539                .enumerate()
540                .map(|(i, item)| {
541                    if kept_mask[idx][i] {
542                        item.clone()
543                    } else {
544                        markers[idx][i].clone()
545                    }
546                })
547                .collect();
548            Value::Array(rebuilt)
549        }
550        Value::Array(items) => Value::Array(
551            items
552                .iter()
553                .map(|item| rewrite_tree(item, cursor, preserved_array, kept_mask, markers))
554                .collect(),
555        ),
556        Value::Object(map) => {
557            let mut out = Map::new();
558            for (k, v) in map {
559                out.insert(
560                    k.clone(),
561                    rewrite_tree(v, cursor, preserved_array, kept_mask, markers),
562                );
563            }
564            Value::Object(out)
565        }
566        _ => value.clone(),
567    }
568}
569
570/// Puts specific dropped items back, keyed by content hash — the fail-closed half of the
571/// contract (`pipeline::apply_lossy_reduction`): an item whose `RetrievalStore::store` call
572/// failed must not be silently lost, so the caller restores it here rather than leaving its
573/// `$tf_ref` marker in place. Recognizes exactly the marker shape [`marker_json`] builds; any
574/// other node (including one that merely looks similar) is left untouched.
575pub fn revert_markers(json: &Value, restore: &std::collections::HashMap<String, Value>) -> Value {
576    if let Value::Object(map) = json
577        && map.len() == 1
578        && let Some(Value::Object(inner)) = map.get("$tf_ref")
579        && let Some(Value::String(hash)) = inner.get("hash")
580        && let Some(original) = restore.get(hash)
581    {
582        return original.clone();
583    }
584    match json {
585        Value::Array(items) => {
586            Value::Array(items.iter().map(|v| revert_markers(v, restore)).collect())
587        }
588        Value::Object(map) => {
589            let mut out = Map::new();
590            for (k, v) in map {
591                out.insert(k.clone(), revert_markers(v, restore));
592            }
593            Value::Object(out)
594        }
595        _ => json.clone(),
596    }
597}
598
599/// Minimal dot-separated path match (exact equality, plus one fail-safe fallback) —
600/// deliberately not full JSONPath (no `$`, `[*]`, filters, or recursive descent). A whole array
601/// is preserved or it isn't; there's no field-level partial preservation in Phase 1.
602///
603/// `collect_eligible_arrays` never recurses into an array once it's deemed eligible, so a
604/// preserve path naming something INSIDE an eligible array (e.g. `"groups.users"` when
605/// `"groups"` itself is eligible) can never match its own array — nothing named `"groups.users"`
606/// is ever registered as an `EligibleArray`. Silently matching nothing (the old behavior) means
607/// `--lossy-preserve` can silently protect NOTHING, which is a safety promise a caller relied on
608/// going unmet without any signal. Instead, treat any preserve path that is a strict
609/// dot-separated prefix-extension of `array_path` as protecting the nearest enclosing eligible
610/// array — the only array that path could possibly have meant, given Phase 1's no-recursion
611/// scope cut.
612///
613/// The ROOT array is the degenerate case of that rule and must not be special-cased away: an
614/// eligible root array has `array_path == ""`, and when the root itself is eligible it is the
615/// ONLY eligible array in the document (`collect_eligible_arrays` stops there), so *every*
616/// non-empty preserve path can only have meant something inside it. Building the prefix
617/// conditionally — `""` at the root, `"{path}."` elsewhere — makes both cases the same
618/// "strictly longer than the prefix, and starts with it" test; the earlier
619/// `starts_with("{array_path}.")` form silently protected nothing at the root, since no path
620/// starts with a bare `"."`.
621fn is_preserved(array_path: &str, preserve_paths: &[String]) -> bool {
622    let prefix = if array_path.is_empty() {
623        String::new()
624    } else {
625        format!("{array_path}.")
626    };
627    preserve_paths
628        .iter()
629        .any(|p| p == array_path || (p.len() > prefix.len() && p.starts_with(&prefix)))
630}
631
632#[derive(Debug, Clone, Copy)]
633struct FieldStats {
634    median: f64,
635    mad: f64,
636}
637
638/// Per-array, per-field median and MAD, computed once over every item in the array (outlier-ness
639/// is a property of the whole distribution, independent of which items are later chosen).
640/// Object items contribute their numeric fields by key; bare-number items contribute under
641/// [`SELF_FIELD`]. Uses a full sort for the exact median — `slice::select_nth_unstable` would be
642/// the O(n) alternative; sort is simpler and plenty fast at realistic array sizes.
643fn numeric_field_stats(items: &[Value]) -> BTreeMap<String, FieldStats> {
644    let mut values: BTreeMap<String, Vec<f64>> = BTreeMap::new();
645    for item in items {
646        match item {
647            Value::Number(n) => {
648                if let Some(f) = n.as_f64() {
649                    values.entry(SELF_FIELD.to_string()).or_default().push(f);
650                }
651            }
652            Value::Object(map) => {
653                for (k, v) in map {
654                    if let Value::Number(n) = v
655                        && let Some(f) = n.as_f64()
656                    {
657                        values.entry(k.clone()).or_default().push(f);
658                    }
659                }
660            }
661            _ => {}
662        }
663    }
664    values
665        .into_iter()
666        .filter(|(_, v)| v.len() >= MIN_ARRAY_LEN)
667        .map(|(k, mut v)| {
668            let median = exact_median(&mut v);
669            let mut deviations: Vec<f64> = v.iter().map(|x| (x - median).abs()).collect();
670            let mad = exact_median(&mut deviations);
671            (k, FieldStats { median, mad })
672        })
673        .collect()
674}
675
676fn exact_median(values: &mut [f64]) -> f64 {
677    values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
678    let n = values.len();
679    if n == 0 {
680        return 0.0;
681    }
682    if n % 2 == 1 {
683        values[n / 2]
684    } else {
685        (values[n / 2 - 1] + values[n / 2]) / 2.0
686    }
687}
688
689/// Design §4B item 2's corrected `MAD == 0` handling: `MAD > 0` uses the standard modified
690/// z-score; `MAD == 0` (a value shared by the majority) means any *different* value is itself a
691/// strong discrete-outlier signal, not "no signal" — the earlier draft had this backwards.
692fn modified_z_score(value: f64, stats: &FieldStats) -> f64 {
693    if stats.mad > 0.0 {
694        (value - stats.median).abs() / (1.4826 * stats.mad)
695    } else if value == stats.median {
696        0.0
697    } else {
698        DISCRETE_OUTLIER_SCORE
699    }
700}
701
702fn numeric_outlier_score(item: &Value, stats: &BTreeMap<String, FieldStats>) -> f64 {
703    match item {
704        Value::Number(n) => n
705            .as_f64()
706            .and_then(|f| stats.get(SELF_FIELD).map(|s| modified_z_score(f, s)))
707            .unwrap_or(0.0),
708        Value::Object(map) => map
709            .iter()
710            .filter_map(|(k, v)| {
711                let Value::Number(n) = v else { return None };
712                let f = n.as_f64()?;
713                let s = stats.get(k)?;
714                Some(modified_z_score(f, s))
715            })
716            .fold(0.0, f64::max),
717        _ => 0.0,
718    }
719}
720
721const STATUS_FALSE_KEYS: &[&str] = &["success", "ok", "healthy", "passed", "valid"];
722const ERROR_COUNT_KEYS: &[&str] = &[
723    "error_count",
724    "errors",
725    "failures",
726    "failure_count",
727    "retries",
728    "retry_count",
729];
730const STATUS_CODE_KEYS: &[&str] = &["status", "status_code", "http_status", "code"];
731
732/// Value-aware structural failure-signal detection — typed field checks, never substring
733/// matching on serialized text (which would false-positive on e.g. `"error_count": 0` or
734/// `"failed": false`, since those strings literally contain the flagged word).
735fn has_structural_failure_signal(item: &Value) -> bool {
736    let Value::Object(map) = item else {
737        return false;
738    };
739    for (k, v) in map {
740        let lower = k.to_ascii_lowercase();
741        if STATUS_FALSE_KEYS.contains(&lower.as_str()) && v == &Value::Bool(false) {
742            return true;
743        }
744        if ERROR_COUNT_KEYS.contains(&lower.as_str()) && v.as_f64().is_some_and(|f| f > 0.0) {
745            return true;
746        }
747        if STATUS_CODE_KEYS.contains(&lower.as_str())
748            && v.as_i64().is_some_and(|n| (400..=599).contains(&n))
749        {
750            return true;
751        }
752    }
753    false
754}
755
756/// Coarse structural shape signature for the diversity allocator (design §4C): object items
757/// group by their sorted key set, everything else groups by its JSON type name.
758fn structural_fingerprint(item: &Value) -> String {
759    match item {
760        Value::Object(map) => {
761            let mut keys: Vec<&str> = map.keys().map(|k| k.as_str()).collect();
762            keys.sort_unstable();
763            keys.join(",")
764        }
765        Value::Array(_) => "array".to_string(),
766        Value::Number(_) => "number".to_string(),
767        Value::String(_) => "string".to_string(),
768        Value::Bool(_) => "bool".to_string(),
769        Value::Null => "null".to_string(),
770    }
771}
772
773#[cfg(test)]
774mod tests {
775    use super::*;
776    use crate::token_estimator::ByteHeuristicEstimator;
777
778    fn opts(ratio: f64) -> LossyOptions {
779        LossyOptions {
780            preserve_paths: Vec::new(),
781            ratio,
782            namespace: "default".to_string(),
783        }
784    }
785
786    /// A `$tf_ref` marker has real, fixed overhead (hash/alg/namespace scaffolding, ~120+
787    /// bytes) — an item genuinely smaller than that can never be worth dropping (see the
788    /// budget-scale comment in `prune`), so most tests need items padded comfortably past it to
789    /// exercise actual dropping, not just the "too small to bother" free-keep path.
790    fn padding() -> String {
791        "x".repeat(150)
792    }
793
794    #[test]
795    fn ratio_at_or_above_one_is_a_clean_noop() {
796        let input = br#"{"items":[{"a":1},{"a":2},{"a":3}]}"#;
797        assert!(
798            prune(input, &opts(1.0), &ByteHeuristicEstimator)
799                .unwrap()
800                .is_none()
801        );
802    }
803
804    #[test]
805    fn no_eligible_arrays_is_a_clean_noop() {
806        let input = br#"{"a":1,"items":[1]}"#; // single-item array is below MIN_ARRAY_LEN
807        assert!(
808            prune(input, &opts(0.1), &ByteHeuristicEstimator)
809                .unwrap()
810                .is_none()
811        );
812    }
813
814    #[test]
815    fn preserved_array_is_never_touched_even_at_zero_ratio() {
816        let input = serde_json::json!({"items": [{"a":1},{"a":2},{"a":3},{"a":4}]});
817        let mut o = opts(0.0);
818        o.preserve_paths = vec!["items".to_string()];
819        let bytes = serde_json::to_vec(&input).unwrap();
820        assert!(
821            prune(&bytes, &o, &ByteHeuristicEstimator)
822                .unwrap()
823                .is_none()
824        );
825    }
826
827    #[test]
828    fn preserved_array_alongside_a_prunable_one_never_leaks_into_dropped_or_the_report() {
829        // Regression test: an adversarial review caught that the dropped-item collection loop
830        // didn't skip preserved arrays, so every item of `keep_me` was wrongly pushed into
831        // `outcome.dropped` (even though the output JSON correctly left it untouched) -- which
832        // both persisted "preserved" content to the retrieval store one layer up, and caused
833        // `total_candidates - preserved_candidates - dropped_candidates` to underflow whenever
834        // preserved items outnumbered the real kept count among prunable arrays, exactly the
835        // shape here (3 preserved vs. only a few of the 4 prune_me items surviving at ratio 0.1).
836        let p = padding();
837        // `keep_me` items carry a distinct marker field so byte-comparison below can never
838        // coincidentally match a genuinely-dropped `prune_me` item -- a real leak vs. a
839        // fixture content collision must not be ambiguous.
840        let keep_items: Vec<Value> = (0..3)
841            .map(|i| serde_json::json!({"a": i, "guard": "KEEP_ME", "pad": p}))
842            .collect();
843        let prune_items: Vec<Value> = (0..4)
844            .map(|i| serde_json::json!({"a": i, "pad": p}))
845            .collect();
846        let input = serde_json::json!({"keep_me": keep_items, "prune_me": prune_items});
847        let bytes = serde_json::to_vec(&input).unwrap();
848        let mut o = opts(0.1);
849        o.preserve_paths = vec!["keep_me".to_string()];
850
851        let outcome = prune(&bytes, &o, &ByteHeuristicEstimator).unwrap().unwrap();
852
853        // No dropped item's bytes may parse back as one of the preserved keep_me items.
854        let keep_me_bytes: Vec<Vec<u8>> = keep_items
855            .iter()
856            .map(|v| serde_json::to_vec(v).unwrap())
857            .collect();
858        for d in &outcome.dropped {
859            assert!(
860                !keep_me_bytes.contains(&d.bytes),
861                "a preserved item leaked into outcome.dropped: {:?}",
862                String::from_utf8_lossy(&d.bytes)
863            );
864        }
865        // The report's arithmetic must be internally consistent (this would have panicked with
866        // an underflow before the fix).
867        assert_eq!(
868            outcome.report.total_candidates,
869            outcome.report.preserved_candidates
870                + outcome.report.kept_candidates
871                + outcome.report.dropped_candidates
872        );
873        assert_eq!(outcome.report.preserved_candidates, 3);
874        // keep_me's 3 items must all still be present, untouched, in the output.
875        let out_keep_me = outcome.json["keep_me"].as_array().unwrap();
876        assert_eq!(out_keep_me, &keep_items);
877    }
878
879    #[test]
880    fn large_uniform_array_prunes_without_quadratic_blowup() {
881        // Regression test for an adversarial review's O(n^2) finding: many candidates landing
882        // right at the budget boundary (uniform item size) used to fall through to the
883        // expensive exact-recount tier for nearly every item once the budget saturated, with no
884        // cap -- turning the walk quadratic. This asserts it stays fast at a size where the old
885        // behavior was measured taking multiple seconds (debug build, cheap heuristic
886        // estimator): a real regression would make this test time out or take far longer than
887        // this generous bound.
888        let p = "x".repeat(150);
889        let items: Vec<Value> = (0..3000)
890            .map(|i| serde_json::json!({"n": i, "pad": p}))
891            .collect();
892        let input = serde_json::json!({"items": items});
893        let bytes = serde_json::to_vec(&input).unwrap();
894
895        let start = std::time::Instant::now();
896        let outcome = prune(&bytes, &opts(0.3), &ByteHeuristicEstimator).unwrap();
897        let elapsed = start.elapsed();
898
899        assert!(
900            elapsed < std::time::Duration::from_secs(5),
901            "prune() on 3000 uniform items took {elapsed:?} -- likely a quadratic regression"
902        );
903        // Still does real work, not a no-op that trivially "passes" by doing nothing.
904        assert!(outcome.is_some());
905    }
906
907    #[test]
908    fn zero_ratio_drops_low_priority_items_from_an_unpreserved_array() {
909        let p = padding();
910        let input = serde_json::json!({"items": (0..6).map(|i| serde_json::json!({"a": i, "pad": p})).collect::<Vec<_>>()});
911        let bytes = serde_json::to_vec(&input).unwrap();
912        let outcome = prune(&bytes, &opts(0.0), &ByteHeuristicEstimator)
913            .unwrap()
914            .expect("some items should drop at ratio 0.0");
915        assert!(outcome.report.dropped_candidates > 0);
916        assert_eq!(
917            outcome.report.dropped_candidates + outcome.report.kept_candidates,
918            outcome.report.total_candidates
919        );
920        // Every dropped item's marker must actually be present in the rewritten document.
921        let s = serde_json::to_string(&outcome.json).unwrap();
922        assert_eq!(s.matches("$tf_ref").count(), outcome.dropped.len());
923    }
924
925    #[test]
926    fn mad_zero_and_equal_to_median_has_no_outlier_signal() {
927        let stats = FieldStats {
928            median: 0.0,
929            mad: 0.0,
930        };
931        assert_eq!(modified_z_score(0.0, &stats), 0.0);
932    }
933
934    #[test]
935    fn mad_zero_and_different_from_median_is_a_strong_discrete_outlier() {
936        // The [0,0,0,0,1]-shaped case: MAD collapses to 0, but the lone `1` must NOT be
937        // suppressed as "no signal" -- it's exactly what MAD exists to catch.
938        let stats = FieldStats {
939            median: 0.0,
940            mad: 0.0,
941        };
942        assert_eq!(modified_z_score(1.0, &stats), DISCRETE_OUTLIER_SCORE);
943    }
944
945    #[test]
946    fn mad_positive_uses_the_standard_modified_z_score_formula() {
947        let stats = FieldStats {
948            median: 10.0,
949            mad: 2.0,
950        };
951        let expected = (15.0_f64 - 10.0).abs() / (1.4826 * 2.0);
952        assert!((modified_z_score(15.0, &stats) - expected).abs() < 1e-9);
953    }
954
955    #[test]
956    fn structural_failure_signal_is_value_aware_not_substring_matched() {
957        // These must NOT be flagged: the substring "error" appears, but the value is falsy/zero.
958        assert!(!has_structural_failure_signal(
959            &serde_json::json!({"error_count": 0})
960        ));
961        assert!(!has_structural_failure_signal(
962            &serde_json::json!({"failed": false})
963        ));
964        // These MUST be flagged: real signal.
965        assert!(has_structural_failure_signal(
966            &serde_json::json!({"success": false})
967        ));
968        assert!(has_structural_failure_signal(
969            &serde_json::json!({"error_count": 3})
970        ));
971        assert!(has_structural_failure_signal(
972            &serde_json::json!({"status_code": 503})
973        ));
974        assert!(!has_structural_failure_signal(
975            &serde_json::json!({"status_code": 200})
976        ));
977    }
978
979    #[test]
980    fn structural_failure_signal_survives_the_full_pipeline_at_low_ratio() {
981        // A planted mid-array anomaly with a value-aware failure signal, surrounded by bland
982        // filler -- the exact "plant the answer mid-array" shape the design doc's eval plan
983        // requires. Must survive even at an aggressive (low) ratio.
984        let p = padding();
985        let mut items: Vec<Value> = (0..20)
986            .map(|i| serde_json::json!({"id": i, "success": true, "pad": p}))
987            .collect();
988        items[10] = serde_json::json!({"id": 10, "success": false, "pad": p});
989        let input = serde_json::json!({"items": items});
990        let bytes = serde_json::to_vec(&input).unwrap();
991        let outcome = prune(&bytes, &opts(0.1), &ByteHeuristicEstimator)
992            .unwrap()
993            .unwrap();
994        let arr = outcome.json["items"].as_array().unwrap();
995        assert_eq!(arr[10]["success"], serde_json::json!(false));
996    }
997
998    #[test]
999    fn is_deterministic_across_repeated_runs() {
1000        let p = padding();
1001        let input = serde_json::json!({"items": (0..15).map(|i| serde_json::json!({"n": i, "pad": p})).collect::<Vec<_>>()});
1002        let bytes = serde_json::to_vec(&input).unwrap();
1003        let a = prune(&bytes, &opts(0.3), &ByteHeuristicEstimator)
1004            .unwrap()
1005            .unwrap();
1006        let b = prune(&bytes, &opts(0.3), &ByteHeuristicEstimator)
1007            .unwrap()
1008            .unwrap();
1009        assert_eq!(a.json, b.json);
1010        assert_eq!(
1011            a.dropped.iter().map(|d| &d.hash).collect::<Vec<_>>(),
1012            b.dropped.iter().map(|d| &d.hash).collect::<Vec<_>>()
1013        );
1014    }
1015
1016    #[test]
1017    fn diversity_walk_spreads_across_fingerprint_groups_before_draining_one() {
1018        // Two structurally distinct groups (different key sets => different fingerprints), all
1019        // otherwise tied on rank. A pure "sort by rank, take top N" would happily drain one
1020        // group entirely before touching the other; round-robin must not.
1021        let p = padding();
1022        let mut items = Vec::new();
1023        for i in 0..6 {
1024            items.push(serde_json::json!({"kind_a": i, "pad": p}));
1025        }
1026        for i in 0..6 {
1027            items.push(serde_json::json!({"kind_b": i, "pad": p}));
1028        }
1029        let input = serde_json::json!({"items": items});
1030        let bytes = serde_json::to_vec(&input).unwrap();
1031        let outcome = prune(&bytes, &opts(0.4), &ByteHeuristicEstimator)
1032            .unwrap()
1033            .unwrap();
1034        let arr = outcome.json["items"].as_array().unwrap();
1035        let kind_a_kept = arr[0..6]
1036            .iter()
1037            .filter(|v| v.get("kind_a").is_some())
1038            .count();
1039        let kind_b_kept = arr[6..12]
1040            .iter()
1041            .filter(|v| v.get("kind_b").is_some())
1042            .count();
1043        assert!(
1044            kind_a_kept > 0,
1045            "round-robin should keep at least one kind_a item"
1046        );
1047        assert!(
1048            kind_b_kept > 0,
1049            "round-robin should keep at least one kind_b item"
1050        );
1051    }
1052
1053    #[test]
1054    fn pruned_output_is_always_valid_json() {
1055        let p = padding();
1056        let input = serde_json::json!({"items": (0..10).map(|i| serde_json::json!({"n": i, "pad": p})).collect::<Vec<_>>()});
1057        let bytes = serde_json::to_vec(&input).unwrap();
1058        let outcome = prune(&bytes, &opts(0.5), &ByteHeuristicEstimator)
1059            .unwrap()
1060            .unwrap();
1061        let round_trip = serde_json::to_vec(&outcome.json).unwrap();
1062        assert!(serde_json::from_slice::<Value>(&round_trip).is_ok());
1063    }
1064
1065    #[test]
1066    fn dropped_item_hashes_match_their_own_bytes() {
1067        let p = padding();
1068        let input = serde_json::json!({"items": (0..8).map(|i| serde_json::json!({"n": i, "pad": p})).collect::<Vec<_>>()});
1069        let bytes = serde_json::to_vec(&input).unwrap();
1070        let outcome = prune(&bytes, &opts(0.1), &ByteHeuristicEstimator)
1071            .unwrap()
1072            .unwrap();
1073        for d in &outcome.dropped {
1074            assert_eq!(hex_sha256(&d.bytes), d.hash);
1075        }
1076    }
1077
1078    #[test]
1079    fn nested_arrays_are_found_but_not_recursed_into_when_the_parent_is_eligible() {
1080        // The outer "groups" array is eligible; its items' inner "users" arrays are deliberately
1081        // NOT separately recursed into (design's documented Phase 1 scope cut).
1082        let input = serde_json::json!({
1083            "groups": [
1084                {"users": [1,2,3]},
1085                {"users": [4,5,6]},
1086            ]
1087        });
1088        let bytes = serde_json::to_vec(&input).unwrap();
1089        let mut arrays = Vec::new();
1090        let value: Value = serde_json::from_slice(&bytes).unwrap();
1091        collect_eligible_arrays(&value, String::new(), &mut arrays);
1092        assert_eq!(
1093            arrays.len(),
1094            1,
1095            "only the outer array is eligible, not the nested ones"
1096        );
1097        assert_eq!(arrays[0].path, "groups");
1098    }
1099
1100    #[test]
1101    fn revert_markers_restores_only_the_named_hash_and_leaves_everything_else_alone() {
1102        let p = padding();
1103        let input = serde_json::json!({"items": (0..10).map(|i| serde_json::json!({"n": i, "pad": p})).collect::<Vec<_>>()});
1104        let bytes = serde_json::to_vec(&input).unwrap();
1105        let outcome = prune(&bytes, &opts(0.1), &ByteHeuristicEstimator)
1106            .unwrap()
1107            .unwrap();
1108        assert!(!outcome.dropped.is_empty());
1109
1110        let mut restore = std::collections::HashMap::new();
1111        let first = &outcome.dropped[0];
1112        let original: Value = serde_json::from_slice(&first.bytes).unwrap();
1113        restore.insert(first.hash.clone(), original.clone());
1114
1115        let reverted = revert_markers(&outcome.json, &restore);
1116        let s = serde_json::to_string(&reverted).unwrap();
1117        // The reverted item's marker is gone, but any remaining dropped items' markers survive.
1118        let remaining_markers = outcome.dropped.len() - 1;
1119        assert_eq!(s.matches("$tf_ref").count(), remaining_markers);
1120        let arr = reverted["items"].as_array().unwrap();
1121        assert!(arr.contains(&original));
1122    }
1123
1124    #[test]
1125    fn preserve_path_naming_something_inside_an_eligible_array_protects_that_array() {
1126        // Round-4 external review: `collect_eligible_arrays` never recurses into an array once
1127        // it's deemed eligible, so a preserve path naming something INSIDE that array (e.g.
1128        // "groups.users" when "groups" itself is eligible) could never match anything -- the
1129        // flag silently protected nothing, which is unacceptable for a caller relying on it as
1130        // a safety guarantee. It must now protect the nearest enclosing eligible array instead.
1131        let p = padding();
1132        let input = serde_json::json!({
1133            "groups": (0..6).map(|i| serde_json::json!({"users": [1,2,3], "a": i, "pad": p})).collect::<Vec<_>>()
1134        });
1135        let bytes = serde_json::to_vec(&input).unwrap();
1136        let mut o = opts(0.0); // maximum drop pressure
1137        o.preserve_paths = vec!["groups.users".to_string()];
1138        let outcome = prune(&bytes, &o, &ByteHeuristicEstimator).unwrap();
1139        assert!(
1140            outcome.is_none(),
1141            "\"groups.users\" must protect the whole \"groups\" array, leaving nothing to prune"
1142        );
1143    }
1144
1145    #[test]
1146    fn preserve_path_protects_an_eligible_root_array_too() {
1147        // Round-5 external review: the nearest-eligible-ancestor rule above was implemented as
1148        // `p.starts_with("{array_path}.")`, which is unsatisfiable at the root (`array_path` is
1149        // "", and no path starts with a bare "."). A live repro confirmed a top-level array of
1150        // objects carrying a `users` field was pruned to 20 markers despite
1151        // `--lossy-preserve users`. An eligible root array is the ONLY eligible array in its
1152        // document, so any preserve path at all must protect it.
1153        let p = padding();
1154        let input = serde_json::json!(
1155            (0..6)
1156                .map(|i| serde_json::json!({"users": [1,2,3], "a": i, "pad": p}))
1157                .collect::<Vec<_>>()
1158        );
1159        let bytes = serde_json::to_vec(&input).unwrap();
1160        let mut o = opts(0.0); // maximum drop pressure
1161        o.preserve_paths = vec!["users".to_string()];
1162        assert!(
1163            prune(&bytes, &o, &ByteHeuristicEstimator)
1164                .unwrap()
1165                .is_none(),
1166            "a preserve path must protect the eligible ROOT array, leaving nothing to prune"
1167        );
1168        // Control: without the preserve path, the same document DOES prune -- otherwise the
1169        // assertion above would pass for the wrong reason (nothing prunable in the first place).
1170        assert!(
1171            prune(&bytes, &opts(0.0), &ByteHeuristicEstimator)
1172                .unwrap()
1173                .is_some()
1174        );
1175    }
1176
1177    #[test]
1178    fn an_unrelated_preserve_path_does_not_protect_a_named_sibling_array() {
1179        // The prefix rule must stay strict for non-root arrays: "other.thing" names nothing
1180        // inside "items", so "items" is still prunable. (At the root the rule is deliberately
1181        // total -- see the test above -- but that must not leak into named paths.)
1182        let p = padding();
1183        let input = serde_json::json!({"items": (0..6).map(|i| serde_json::json!({"a": i, "pad": p})).collect::<Vec<_>>()});
1184        let bytes = serde_json::to_vec(&input).unwrap();
1185        let mut o = opts(0.0);
1186        o.preserve_paths = vec!["other.thing".to_string(), "items_extra".to_string()];
1187        assert!(
1188            prune(&bytes, &o, &ByteHeuristicEstimator)
1189                .unwrap()
1190                .is_some(),
1191            "neither an unrelated path nor a non-dot prefix extension may protect \"items\""
1192        );
1193    }
1194
1195    #[test]
1196    fn a_tier_three_acceptance_charges_the_exact_delta_not_the_independent_estimate() {
1197        // Round-5 external review: tier 3 paid for an exact joint re-tokenize and then threw the
1198        // result away, charging the cheaper independent tier-2 estimate to the running pool. This
1199        // asserts the invariant that made the exact call worth making: whatever the walk accepts,
1200        // the FINAL exact token count of the pruned document must not exceed the input's -- i.e.
1201        // the accounting the walk ran on has to track reality, not drift above it.
1202        let p = padding();
1203        let items: Vec<Value> = (0..40)
1204            .map(|i| serde_json::json!({"n": i, "pad": p, "note": format!("row {i}")}))
1205            .collect();
1206        let input = serde_json::json!({"items": items});
1207        let bytes = serde_json::to_vec(&input).unwrap();
1208        let est = ByteHeuristicEstimator;
1209        for ratio in [0.0, 0.2, 0.5, 0.9] {
1210            let Some(outcome) = prune(&bytes, &opts(ratio), &est).unwrap() else {
1211                continue;
1212            };
1213            let out_bytes = serde_json::to_vec(&outcome.json).unwrap();
1214            assert!(
1215                est.count_bytes(&out_bytes) <= est.count_bytes(&bytes),
1216                "ratio {ratio}: pruned output ({}) costs more than the input ({})",
1217                est.count_bytes(&out_bytes),
1218                est.count_bytes(&bytes)
1219            );
1220        }
1221    }
1222
1223    #[test]
1224    fn sibling_eligible_arrays_at_different_paths_are_both_found() {
1225        let input = serde_json::json!({
1226            "a": [1,2,3],
1227            "b": {"c": [4,5,6]},
1228        });
1229        let bytes = serde_json::to_vec(&input).unwrap();
1230        let value: Value = serde_json::from_slice(&bytes).unwrap();
1231        let mut arrays = Vec::new();
1232        collect_eligible_arrays(&value, String::new(), &mut arrays);
1233        let mut paths: Vec<&str> = arrays.iter().map(|a| a.path.as_str()).collect();
1234        paths.sort_unstable();
1235        assert_eq!(paths, vec!["a", "b.c"]);
1236    }
1237}