File size: 6,126 Bytes
c83ff60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
"""Reshuffle the multiple-choice option ordering for every EgoMemReason question.

Rationale: the private answer key was leaked on 2026-06-17 (a submission
scored 100% overall). Rotating tokens and gating the public leaderboard
prevents new leakage, but does *not* invalidate the copy already exfiltrated.
This script permutes, per question, which option-string each letter (A-J)
points to. Any cached copy of the previous answer key becomes worse than
random — the letters no longer identify the same options.

Design constraints:
  - The *set* of letters used per question is unchanged (a 4-option question
    still uses A-D, a 10-option one still uses A-J). Only the mapping from
    letter to option-string changes.
  - The *content* of each option (the strings themselves) is unchanged.
  - Each per-question permutation is guaranteed to differ from the identity
    (so no question happens to end up unshuffled). Requires n >= 2 options,
    which every EgoMemReason question satisfies.
  - Per-question RNG is seeded so runs are reproducible.

Outputs:
  --out-private  fresh annotations_private.json    → Ted412/EgoMemReason-Private
  --out-public   fresh annotations_public.jsonl    → Ted412/EgoMemReason

Usage:
  python reshuffle_options.py \\
      --src /nas-ssd2/ziyang/Memory_project/COLM/final_benchmark/final_benchmark_500_final_release.json \\
      --out-private /tmp/annotations_private.json \\
      --out-public  /tmp/annotations_public.jsonl \\
      --seed 20260713

Upload:
  hf upload Ted412/EgoMemReason-Private annotations_private.json --repo-type=dataset
  hf upload Ted412/EgoMemReason annotations_public.jsonl --repo-type=dataset
"""

import argparse
import json
import random
from pathlib import Path


PUBLIC_DROP_FIELDS = ("correct_answer",)


def reshuffle_one(sample, master_seed):
    """Return (new_sample, new_correct_letter)."""
    letters = sorted(sample["options"].keys())
    strings = [sample["options"][L] for L in letters]
    n = len(letters)
    if n < 2:
        raise ValueError(f"example_id {sample['example_id']}: <2 options")

    rng = random.Random(master_seed * 1_000_003 + int(sample["example_id"]))
    order = list(range(n))
    # Reroll until the permutation is not the identity — kills the edge case
    # where the reshuffled key would still equal the leaked one for this Q.
    for _ in range(64):
        rng.shuffle(order)
        if order != list(range(n)):
            break
    else:
        # Fallback: swap first two. Cannot be identity because n >= 2.
        order = list(range(n))
        order[0], order[1] = order[1], order[0]

    permuted_strings = [strings[i] for i in order]
    new_options = {letters[i]: permuted_strings[i] for i in range(n)}

    old_correct_letter = sample["correct_answer"].strip().upper()
    if old_correct_letter not in sample["options"]:
        raise ValueError(
            f"example_id {sample['example_id']}: correct_answer "
            f"{old_correct_letter!r} not in options {list(sample['options'].keys())}"
        )
    old_correct_string = sample["options"][old_correct_letter]
    new_correct_letter = letters[permuted_strings.index(old_correct_string)]

    new = dict(sample)
    new["options"] = new_options
    new["correct_answer"] = new_correct_letter
    return new, new_correct_letter


def summarize_diff(before, after):
    """Sanity stats: fraction of questions whose correct letter changed."""
    same = sum(
        1 for b, a in zip(before, after)
        if b["correct_answer"] == a["correct_answer"]
    )
    return {
        "n": len(before),
        "correct_letter_changed": len(before) - same,
        "correct_letter_unchanged": same,
    }


def main():
    p = argparse.ArgumentParser()
    p.add_argument("--src", required=True,
                   help="Source-of-truth annotations JSON with correct_answer per question.")
    p.add_argument("--out-private", required=True,
                   help="Destination for the new private annotations JSON (wrapped dict).")
    p.add_argument("--out-public", required=True,
                   help="Destination for the new public annotations JSONL (no correct_answer).")
    p.add_argument("--seed", type=int, required=True,
                   help="Master RNG seed (per-question seed = seed*1_000_003 + example_id).")
    args = p.parse_args()

    src = json.loads(Path(args.src).read_text())
    if isinstance(src, dict) and "samples" in src:
        header = {k: v for k, v in src.items() if k != "samples"}
        samples = src["samples"]
    else:
        header = None
        samples = src

    new_samples = []
    for s in samples:
        ns, _ = reshuffle_one(s, args.seed)
        new_samples.append(ns)

    # Private: wrapped dict, preserves the source-of-truth metadata.
    if header is not None:
        private_out = dict(header)
        private_out["samples"] = new_samples
    else:
        private_out = {"samples": new_samples}
    Path(args.out_private).write_text(json.dumps(private_out, indent=2, ensure_ascii=False))

    # Public: flat JSONL, no correct_answer. Matches the current HF dataset schema.
    with open(args.out_public, "w") as f:
        for s in new_samples:
            row = {k: v for k, v in s.items() if k not in PUBLIC_DROP_FIELDS}
            f.write(json.dumps(row, ensure_ascii=False) + "\n")

    stats = summarize_diff(samples, new_samples)
    print(f"[reshuffle] wrote {args.out_private}")
    print(f"[reshuffle] wrote {args.out_public}")
    print(f"[reshuffle] n={stats['n']}  "
          f"correct_letter_changed={stats['correct_letter_changed']}  "
          f"unchanged={stats['correct_letter_unchanged']}")
    if stats["correct_letter_unchanged"]:
        # Not a bug — a permutation can leave a letter in place as long as it's
        # not the identity permutation. We just report it for transparency.
        print(f"[reshuffle] note: {stats['correct_letter_unchanged']} questions "
              f"happen to keep the same correct letter under this permutation "
              f"(their option content is still shuffled).")


if __name__ == "__main__":
    main()