Code and verified results accompanying our paper on position bias in LLM listwise rerankers, reframed as an exploitable attack surface.
LLM listwise rerankers are strong, but as decoder-only models they score candidates depending on input order. We show this position bias is not benign noise: an adversary who can influence candidate order (e.g. a caller of a multi-tenant / API reranker) can manufacture top-k exposure for irrelevant items without changing any content or relevance. We characterize the surface (structured, scale-independent, position ≫ content), evaluate defenses as a security property, and surface a cheap audit signal that predicts exploitability without running any attack.
We propose no new architecture. Our contribution is the attack-surface analysis + defense evaluation. This is a vulnerability audit + defense validation, not a manipulation tool: we report aggregate magnitudes and a working defense, content edits are diagnostic only, and there is no optimized manipulation pipeline.
The reranker implementation this study attacks and defends is InvariRank,
from a published paper. The top-level Python package here — config.py,
configs/, datasets/, model/, prompts/, ranking/, retriever/,
scripts/, training/ — is a redistributed copy of the InvariRank reference
implementation (MIT, © 2026 Ethan Bito; see LICENSE-InvariRank and
NOTICE). We vendor it only so our analysis code runs out of the box.
If you use this repository, please also cite InvariRank:
@misc{bito2026onepassanyorder,
title = {One Pass, Any Order: Position-Invariant Listwise Reranking for LLM-Based Recommendation},
author = {Ethan Bito and Yongli Ren and Estrid He},
year = {2026},
eprint = {2604.27599},
archivePrefix = {arXiv},
primaryClass = {cs.IR},
url = {https://arxiv.org/abs/2604.27599},
}Terminology map (ours ↔ InvariRank): the vulnerable default we attack is the
causal reranker (standard attention + RoPE) = InvariRank's LFT baseline
(configs/*_lft.yaml, internal tag lft); the position-invariant reranker
(structured attention mask + shared candidate position ids) = InvariRank's
defense mechanism (prompt_style: invarirank, internal tag invarirank), which
we evaluate as one defense among several.
- C1 — structured & exploitable. Position bias is a structured U-shaped
position→rank curve (not noise). A plain random-order search promotes 10–57% of
otherwise-below-top-5 irrelevant candidates into top-5 (
promo@5). No scaling law — larger models are not immune. - C2 — position ≫ content. The position lever is ≈5.6× a content edit, and leaves no content trace.
- C3 — defense landscape. Naive order-augmented training fails; consistency-KL training and architectural invariance close the surface in a single forward pass; test-time order averaging works at O(P) cost. Mechanism: the attention channel dominates over the position-id channel.
- C4 — cheap audit signal. Across 24 (model, domain) cells, a causal
reranker's ordinary permutation stability (Kendall τ, attack-free) predicts
its exploitability (
promo@5) at Pearson r ≈ −0.97 (partial r = −0.78 controlling for domain). Practitioners can flag attack surface by measuring τ alone. - Domain-dependent severity.
promo@5on MovieLens (0.10–0.15) < Fashion (0.23–0.39) < Books (0.43–0.57): the weaker the content signal, the larger the hole. Defenses drivepromo@5 ≈ 0in every domain, at a non-uniform quality cost (Books largest, ~17%).
position_bias_attack/
├── README.md ← you are here
├── LICENSE MIT (our contribution)
├── LICENSE-InvariRank MIT (vendored InvariRank code)
├── NOTICE provenance of every top-level component
├── requirements.txt
├── pyproject.toml
│
│ ── vendored InvariRank reference implementation (see NOTICE) ──
├── config.py YAML/JSON config loading
├── configs/ example dataset / train / rank configs (incl. *_lft.yaml)
├── datasets/ MovieLens-32M + Amazon raw→JSONL builders
├── model/ tokenizer/model/LoRA loading; InvariRank mask+position logic
├── prompts/ prompt builders + JSON wording templates
├── ranking/ ranking pipeline + mean-log-prob listwise scorer
├── retriever/ LightGCN first-stage retrieval
├── scripts/ build_dataset / train_model / run_ranking / evaluate_ranking
├── training/ tokenized listwise dataset, losses, metrics, training loop
│
│ ── our contribution ──
├── position_bias/ attack-surface & defense analysis (RQ1–RQ5 + revisions)
│ ├── scan_position.py RQ1 position→rank causal curve (curve_range)
│ ├── adversarial_perm.py RQ2 budget-R permutation attacker (promo@5) — core
│ ├── greedy_attack.py RQ2 structured greedy (lower bound; footnote)
│ ├── content_vs_position.py RQ3 position vs content 2×2 attribution
│ ├── pointwise_eval.py pointwise reference baseline
│ ├── defense_avg.py C test-time order averaging defense
│ ├── bootstrap_ci.py B2 bootstrap CIs over candidate sets
│ ├── analyze_correlation.py C4 domain-controlled τ↔promo@5 (runs locally)
│ ├── pointwise_adv.py B1 pointwise on the attack-budget axis
│ ├── listt5_scorer.py B4 T5 encoder listwise scorer
│ ├── listt5_ranker.py B4 generative ListT5 ranker (deprecated route)
│ ├── listt5_eval.py B4 run the T5 scorer through the same 3 probes
│ ├── train_listt5_scorer.py B4 train the T5 encoder scorer
│ ├── train_listt5.py B4 train the generative ListT5 (deprecated route)
│ └── results/ verified numeric results (CSV) + data dictionary
│
└── position_bias_check/ ablation: can *prompting* remove the bias? (no)
├── collect.py aggregate scan/adv JSON → prompt_check.csv
├── templates/ p0 baseline + p1–p5 anti-position prompt styles
└── results/ prompt_check.csv + raw per-variant JSON
The analysis scripts add the repository root to sys.path
(Path(__file__).resolve().parents[1]) and import the vendored InvariRank
modules, so keep position_bias/ and position_bias_check/ as direct
subdirectories of the repo root — that layout is what makes the imports resolve.
Python ≥ 3.10 (3.12 recommended).
python -m venv .venv
source .venv/bin/activate # Windows: .\.venv\Scripts\Activate.ps1
python -m pip install --upgrade pip
pip install -r requirements.txtFor gated Hugging Face models, authenticate with the HF CLI or set HF_TOKEN.
Fine-tuning and evaluation expect a CUDA GPU; the analysis scripts run against
the trained adapters produced by the training step below.
The pipeline is: build data → train a reranker → run the analysis probes.
All entry points take a --config (see configs/ for the expected fields — edit
paths, model id, and sample counts before running anything large).
1. Build a dataset (MovieLens-32M or Amazon; writes
data/processed/<dataset>/<K>/{train,val,test}.jsonl):
python scripts/build_dataset.py --config configs/dataset.yaml2. Train a reranker. Causal (the vulnerable default we attack) vs. position-invariant (one defense) differ only by these config fields:
# causal (LFT) — configs/train_lft.yaml
attention_mask: causal
position_ids: standard
prompt_style: invarirank # markers only; standard attention/positions
# position-invariant defense — configs/train.yaml
attention_mask: block
position_ids: shared
prompt_style: invarirankpython scripts/train_model.py --config configs/train_lft.yaml # causal
python scripts/train_model.py --config configs/train.yaml # invariant3. Rank + evaluate quality/robustness (nDCG, HR, Kendall τ, permutation overlap):
python scripts/run_ranking.py --config configs/rank_lft.yaml
python scripts/evaluate_ranking.py --config configs/rank_lft.yamlEach script takes a rank-style --config pointing at a trained adapter and
writes a JSON result. Representative invocations (see each script's --help and
module docstring for the full option set):
cd position_bias
# RQ1 — position→rank causal curve (curve_range). Slide a fixed target through
# every input position; large range + structure ⇒ exploitable.
python scan_position.py --config <rank_cfg> --num-samples 300 --out scan.json
# RQ2 — budget-R permutation attacker. "Try R orderings, keep the best for the
# target." promo@5 = fraction of below-top-5 irrelevant targets forced into top-5.
python adversarial_perm.py --config <rank_cfg> -R 50 --num-samples 300 --out adv.json
# attack-budget curve (Fig. 5, R_max=R=50): add --r-grid 1,5,10,20,50
# RQ3 — position vs content 2×2 attribution.
python content_vs_position.py --config <rank_cfg> --num-samples 300 --out pvc.json
# Baselines / defenses
python pointwise_eval.py --config <rank_cfg> --num-samples 300 --out pw.json
python defense_avg.py --config <rank_cfg> --avg-perms 20 --num-samples 300 --out defC.json
python greedy_attack.py --config <rank_cfg> -R 50 --num-samples 300 --out greedy.jsonRevision-round analyses:
# B2 — bootstrap 95% CIs (resamples over candidate SETS) from adv_*.json --dump-raw
python bootstrap_ci.py adv.json --all-R --out results/budget_curve_ci.csv
# C4 — domain-controlled τ↔promo@5 correlation. Runs locally off the shipped CSV:
python analyze_correlation.py # reads results/correlation_data.csv
# B1 — pointwise on the budget axis (flat ≈0 by construction)
python pointwise_adv.py --config <rank_cfg> -R 50 --r-grid 1,5,10,20,50 --out pw_adv.json
# B4 — encoder-decoder (T5) comparison: train the scorer, then the same 3 probes
python train_listt5_scorer.py --config <listt5_cfg>
python listt5_eval.py --config <listt5_cfg> --scorer encode --task all --out-dir listt5_out
# B4 budget curve (Fig 5 ListT5 row), R_max=R=50:
python listt5_eval.py --config <listt5_cfg> --scorer encode --task adv \
-R 50 --r-grid 1,5,10,20,50 --num-samples 300 --out-dir listt5_outHolds the same trained causal adapter fixed and changes only the eval-time
prompt wording (templates/p0–p5: baseline + five distinct anti-position
styles — declarative, imperative, persona, chain-of-thought, structured rules),
then re-runs scan_position.py (curve_range) and adversarial_perm.py (promo@5)
for each. Result: prompting does not close the surface (all variants stay at
promo@5 ≈ 0.12, vs ≈0 for architectural invariance) — the bias is
mechanistic, not instructional. Aggregate the per-variant JSON with:
python position_bias_check/collect.py <dir_with_scan_and_adv_json> \
--out position_bias_check/results/prompt_check.csvVerified numbers ship as CSVs in position_bias/results/ — see
position_bias/results/README.md for the column dictionary and provenance, and
position_bias_check/results/ for the prompt ablation. Headlines:
| Result | File |
|---|---|
| Main matrix — per (dataset, model, variant, seed) | results_by_model_dataset.csv |
| Defense landscape (anchor, MovieLens) | defense_matrix.csv |
| Cross-domain defense (ML / Books / Fashion) | defense_cross_domain.csv |
| C4 audit signal — 24-cell τ vs promo@5 | correlation_data.csv, tau_promo_correlation_analysis.csv |
| Attack-budget curve (Fig. 5) + CIs | budget_curve.csv, budget_curve_ci.csv |
| Encoder-decoder (T5) comparison | mechanism_comparison.csv |
| Anchor deep-dive (all metrics, both strata) | anchor_deepdive.csv |
| Dataset build stats | dataset_stats.csv |
| Prompt ablation (prompting fails) | ../position_bias_check/results/prompt_check.csv |
Defense matrix at the anchor (Llama-3.2-3B, MovieLens):
| Defense | nDCG@10 | promo@5 | cost | exact |
|---|---|---|---|---|
| baseline causal | 0.829 | 0.120 | 1× | no |
| architectural invariance | 0.784 | 0.000 | 1× | yes |
| permutation-consistency KL | 0.777 | 0.007 | 1× | ≈ |
| order-augmented training | 0.849 | 0.117 (fails) | 1× | no |
| test-time averaging (P=20) | 0.848 | ≈0 | 20× | no |
| pointwise reference | 0.763 | 0 | 25× | yes |
This is a defensive security study. We report aggregate vulnerability magnitudes and a validated defense; content edits are diagnostic (to separate position from content) only; we ship no optimized manipulation pipeline. The intended use is auditing and hardening deployed rerankers.
Please cite both our paper (position-bias attack surface) and InvariRank (the reranker we build on). The InvariRank BibTeX is above; our paper's citation will be added on publication.
Our contribution (position_bias/, position_bias_check/, this README) is MIT —
see LICENSE. The vendored InvariRank code is MIT © 2026 Ethan Bito — see
LICENSE-InvariRank and NOTICE.