A small, local, reproducible harness for measuring position-swap inconsistency in
LLM-as-a-judge models — the bias where a judge's verdict changes just because two answers
swap places. It runs offline on Apple Silicon (Apple MLX), writes one JSONL record per judge
call, and reports a single dark_current_score.
Status: first real runs complete. Three open-weight judges evaluated locally on a 12-item mini-slice (see Results); per-call records committed under
results/. A paper-aligned benchmark slice is the next milestone. This is independent work, not an official reproduction (see Scope).
Evaluating open-source judges usually drifts toward paid API baselines or GPU-heavy pipelines. This harness keeps the first pass local, deterministic, and auditable on a laptop:
- run each pairwise item twice — original order (A/B) and swapped order (B/A);
- parse strict
[[A]]/[[B]]/[[Tie]]verdicts from the judge's free text; - treat a judge as consistent only if it flips
A → B, flipsB → A, and keepsTie → Tie; - record hardware, prompt template, seed, and generation config alongside every call;
- aggregate a strict
dark_current_score= fraction of items where the judge was not order-invariant.
Three open-weight judges, fully local (MacBook M3 Pro 18 GB, mlx-lm 0.31.3), greedy decoding
(temperature=0.0, seed=42, max_tokens=512). The mini-slice
(data/minislice_v0.jsonl) has 12 hand-written pairwise items in
three conditions: clear_gap (one answer plainly better, better side balanced 2/2),
identical (byte-identical answers — any A/B pick is pure position signal), paraphrase
(same content, different wording). 12 items × 2 orders = 24 judge calls per model.
| Judge (4-bit MLX) | clear_gap | identical | paraphrase | overall | gen tokens | wall |
|---|---|---|---|---|---|---|
| Meta-Llama-3-8B-Instruct | 0.25* | 0.25 | 0.50 | 0.333 | 4,459 | 170 s |
| Mistral-7B-Instruct-v0.3 | 0.00 | 0.00 | 1.00 | 0.333 | 2,725 | 106 s |
| Qwen2-7B-Instruct | 0.00 | 0.50 | 0.75 | 0.417 | 1,508 | 71 s |
Cells are per-condition dark current (fraction of items whose verdict pair is not
order-invariant). *Llama's clear_gap miss is one call that answered correctly in prose but
skipped the strict [[B]] marker (parsed as unknown), not a position flip.
What the aggregate score hides:
- All three judges read a real quality signal well: parsed
clear_gapverdicts picked the objectively better answer in both orders 11/12 times. - Position bias appears exactly where the signal disappears, with a different signature per judge: Llama-3 drifts toward slot A even on byte-identical pairs; Mistral is flawless until answers are merely equivalent, then slot-locks on 4/4 paraphrase items; Qwen2 slot-locks on 2/4 identical pairs.
- Same aggregate, different pathology: Llama-3 and Mistral share the same overall score (0.333) while failing in different conditions — the case for per-condition breakdowns over a single number.
Reproduce the breakdown: python3 scripts/category_breakdown.py results/*_minislice_v0.jsonl.
Full per-call records (hardware metadata, generation config, usage stats, raw generations,
parsed verdicts) are committed under results/.
Budget note. The runner aligns the tokenizer-declared chat eos token with the
mlx-lmstop set (Llama-3 declares<|eot_id|>, whose id is missing from the wrapper's stop ids). Before the fix every call burned the full 512-token budget with identical verdicts: 12,288 vs 4,459 generated tokens and 435 s vs 170 s. Per-callusagerecords token counts, tps, peak memory, andfinish_reason; summaries aggregate them inusage_totals.
Two deterministic mock judges validate the metric end-to-end — no model download, no network.
A purely position-biased judge should score 1.0 (maximally inconsistent); an order-invariant
judge should score 0.0. Both hold:
| Mock judge baseline | Items | Swap-consistent | dark_current_score |
|---|---|---|---|
position (always picks slot A) |
2 | 0 | 1.00 |
tie (always [[Tie]]) |
2 | 2 | 0.00 |
$ python3 -m unittest discover -s tests → Ran 8 tests ... OK (~0.02s)
These mocks are sanity baselines, not real judges: they prove the pipeline, parser, and
metric behave correctly before any model is loaded. Plugging in a real MLX judge is a
one-flag change (--runner mlx --model ...).
Validates dataset loading, prompt rendering, JSONL output, and the metric:
python3 eval_pipeline.py --runner mock --input data/sample_pairwise.jsonl
# writes results/run.jsonl and results/summary.json, prints the summaryPYTHONPATH=src python3 -m unittest discover -s testsuv venv && source .venv/bin/activate
uv pip install -e ".[mlx]"
python3 eval_pipeline.py \
--runner mlx \
--model mlx-community/Meta-Llama-3-8B-Instruct-4bit \
--input data/minislice_v0.jsonl \
--output results/llama3_8b_4bit_minislice_v0.jsonl \
--report results/llama3_8b_4bit_minislice_v0_summary.jsonInput is JSONL, one pairwise item per line:
{"item_id": "sample_fibonacci", "instruction": "Write a Python function for Fibonacci numbers.", "response_a": "...", "response_b": "...", "model_a_id": "toy_recursive", "model_b_id": "toy_iterative", "metadata": {"source": "local_sample"}}Legacy prototype field names (id, model_a_response, model_b_response) are also accepted,
so older data files still load. Use --input builtin for an in-memory two-item smoke set.
One JSONL record per judge call (order is ab or ba); see schema.json:
{
"eval_id": "uuid shared across the run",
"item_id": "sample_fibonacci",
"judge_model": "mlx-community/Meta-Llama-3-8B-Instruct-4bit",
"hardware_env": {"platform": "macOS-...-arm64", "machine": "arm64", "python_version": "3.x"},
"generation_config": {"max_tokens": 256, "temperature": 0.0, "seed": 42},
"prompt_template_id": "pairwise_judge_v1",
"order": "ab",
"inputs": {"model_a_id": "...", "model_b_id": "...", "instruction": "...", "response_a": "...", "response_b": "...", "metadata": {}},
"outputs": {"raw_generation": "... [[A]]", "parsed_position_winner": "A", "parsed_original_winner": "model_a"},
"timing": {"latency_s": 0.0},
"usage": {"prompt_tokens": 310, "generation_tokens": 142, "prompt_tps": 250.0, "generation_tps": 28.5, "peak_memory_gb": 5.1, "finish_reason": "stop"}
}parsed_position_winner is which slot won (A/B/tie); parsed_original_winner maps that back
to the original model id, so a swap-inconsistent judge is visible at the item level.
src/dark_current_mlx/
cli.py # argparse CLI (entry point: dark-current-mlx)
pipeline.py # A/B + B/A runs, JSONL writer, summary metrics + usage totals
runners.py # MockRunner (offline) + lazy MlxRunner (sampler API, eos alignment, usage stats)
verdicts.py # strict [[A]]/[[B]]/[[Tie]] parser + swap-consistency logic
models.py # PairwiseItem / Verdict / GenerationConfig dataclasses
prompts.py # pairwise_judge_v1 template + A/B-swap renderer
dataset.py # JSONL loader (+ legacy schema) and builtin sample
eval_pipeline.py # backward-compatible entry point (no install needed)
data/sample_pairwise.jsonl # 2-item dry-run sample
data/minislice_v0.jsonl # 12-item mini-slice (clear_gap / identical / paraphrase)
scripts/category_breakdown.py # per-condition dark current from run JSONL
schema.json # output record shape
tests/ # 8 offline unit tests (no network / no MLX / no downloads)
docs/ # results-note template + drafts
results/ # committed: the three mini-slice v0 runs (records + summaries)
The MlxRunner imports mlx-lm lazily, so the tests and the mock runner never touch MLX —
that is why the whole suite runs in well under a second with zero downloads.
| Area | State |
|---|---|
Package + CLI (mock / mlx runners) |
✅ done |
| Strict verdict parser + swap-consistency metric | ✅ done |
| JSONL output + reproducibility metadata + summary | ✅ done |
| Offline test suite (8 tests) | ✅ done |
| Real MLX judge run on a mini-slice (3 open judges, fixed seed) | ✅ done (see Results) |
| Budget-aware usage fields (tokens, tps, peak memory, finish reason) | ✅ done (scaffold/routing dimensions planned) |
| Compact results note for the paper authors | ✍️ drafted (docs/) |
| Paper-aligned benchmark adapter (MT-Bench / AlpacaEval-style) | 🔜 planned |
Inspired by "LLM Judges Have Dark Current: A Psychometric Datasheet for LLM-as-a-Judge Evaluation" (Usami et al.). This is an independent pilot exploring local reproducibility on Apple Silicon. It is not affiliated with or endorsed by the authors and does not reproduce the paper's full results. No proprietary API calls are used anywhere in the pipeline.
MIT — see LICENSE.