File size: 13,007 Bytes
bcc8d35
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
"""One-shot, surgical dedup of the Arm B preference shards.

WHY THIS EXISTS. The ref-logprob cache build refused the Arm B shards with
`ValueError: duplicate pair_id` -- correctly. The train split held 11,404
rows with 87 pair_ids present exactly twice, and held_out held 596 rows with
2 doubled ids; every second occurrence is byte-identical to its first
(same input_ids, boundaries, rejected side, and extras). Root cause: PRM800K
carries multiple annotation records per problem, so two records can yield the
IDENTICAL pair site; `PairCapper.offer` caps per (problem_id, step_index) but
never checks pair_id, and `curate()` merges everything per problem
(nmo/data/prm800k_pairs.py, `by_problem.setdefault(...).append(p)`) without a
seen-pair_id check -- so the depth-round top-up re-selects a pair the first
round already took.

WHAT IT DOES. For each split (train, held_out):
  1. read the live shards via the store's own reader;
  2. keep the FIRST occurrence of every pair_id, drop second-and-later
     occurrences -- REFUSING unless the dropped row is content-identical to
     the kept one (a non-identical duplicate is a different bug and must not
     be silently resolved by row order);
  3. write fresh shards through the same ShardWriter/receipt API the build
     used, preserving shard naming (`arm_b-train-NNNN` etc.).
Then it asserts zero train/held pair_id overlap, atomically swaps
`arm_b -> arm_b_predup` and `arm_b_dedup -> arm_b` (the predup directory is
KEPT as evidence), and updates data/manifest.json in place:
`counts.arm_b_curated`, the two `arm_b-*` shard hashes (same
content_hash-of-receipt-hashes chain `_ShardSet.finalize` uses), and the
`length_bias_baseline` Arm B entries re-measured over the deduped TRAIN split
with the exact function scripts/dpo.py uses
(align.train.dpo_stage.measure_chosen_longer_fraction). A provenance block
`arm_b_dedup` records old -> new for everything it touched.

Deterministic (pure function of the input shards; no RNG), CPU-only.
The ref-logprob caches keyed on the old manifest hashes are stale after this
and must be re-precomputed -- that is the point.

Run:

    cd /apps/development/rlhf/nano-mistral-o1
    CUDA_VISIBLE_DEVICES= .venv/bin/python scripts/dedup_arm_b_shards.py --dry-run
    CUDA_VISIBLE_DEVICES= .venv/bin/python scripts/dedup_arm_b_shards.py

Refuses to run twice: an existing `arm_b_predup` means the swap already
happened.
"""

from __future__ import annotations

import os

# CPU-only by construction: never let a lazily-imported torch see a GPU.
os.environ.setdefault("CUDA_VISIBLE_DEVICES", "")

import argparse
import json
import logging
from pathlib import Path

import numpy as np

logger = logging.getLogger("dedup_arm_b")

REPO = Path("/apps/development/rlhf/nano-mistral-o1")
SPLITS = (("train", "arm_b-train"), ("held_out", "arm_b-held"))
#: manifest hash key per split prefix, matching build_all's `hashes` dict.
MANIFEST_HASH_KEY = {"arm_b-train": "arm_b-train", "arm_b-held": "arm_b-held"}


def rows_identical(a, b) -> bool:
    """Full content identity between two store Rows -- every stored field.

    Anything short of this (say, comparing input_ids only) would let a real
    divergence hide behind a shared pair_id and get resolved by row order.
    """
    return (
        np.array_equal(a.input_ids, b.input_ids)
        and np.array_equal(a.boundary_positions, b.boundary_positions)
        and a.problem_id == b.problem_id
        and a.subject == b.subject
        and sorted(a.ragged) == sorted(b.ragged)
        and all(np.array_equal(a.ragged[f], b.ragged[f]) for f in a.ragged)
        and a.extras == b.extras
    )


def dedup_row_indices(ds) -> tuple[list[int], list[int]]:
    """(kept, dropped) row indices: first occurrence per pair_id wins.

    Raises if any dropped row is NOT content-identical to its kept first
    occurrence -- that would be a semantic collision, not a re-added pair,
    and this script is only licensed to remove exact re-adds.
    """
    first_by_pair: dict[str, int] = {}
    kept: list[int] = []
    dropped: list[int] = []
    for i in range(len(ds)):
        row = ds[i]
        pair_id = row.extras["pair_id"]
        if pair_id not in first_by_pair:
            first_by_pair[pair_id] = i
            kept.append(i)
            continue
        j = first_by_pair[pair_id]
        if not rows_identical(ds[j], row):
            raise ValueError(
                f"pair_id {pair_id!r} occurs at rows {j} and {i} with "
                "DIFFERENT content. That is a hash/semantics collision, not a "
                "duplicate re-add; refusing to pick a winner by row order."
            )
        dropped.append(i)
    return kept, dropped


def write_split(ds, kept: list[int], out_dir: Path, prefix: str) -> str:
    """Write the kept rows through the build's own shard-set API.

    Returns the manifest-level content hash (content_hash over the per-shard
    receipt hashes -- the same chain `_ShardSet.finalize` produces, so the
    updated manifest entry is computed by the code that defined it).
    """
    from align.data.build import _PREF_FIELDS, _ShardSet

    shard_set = _ShardSet(out_dir, prefix, _PREF_FIELDS)
    for i in kept:
        row = ds[i]
        shard_set.add(
            input_ids=row.input_ids,
            problem_id=row.problem_id,
            subject=row.subject,
            boundary_positions=row.boundary_positions,
            ragged={
                "rejected_input_ids": row.ragged["rejected_input_ids"],
                "rejected_boundary_positions": row.ragged["rejected_boundary_positions"],
            },
            **row.extras,
        )
    return shard_set.finalize()


def step_basis_fraction(rows) -> float:
    """Arm B's step-basis chosen-longer fraction over collator-shaped rows.

    Mirrors build.py's `chosen_longer_fraction_step_tokens`: length measured
    from the SHARED PREFIX (the one step that differs), counted over every
    row. Kept next to the completion-basis measurement so the manifest's two
    Arm B baselines stay siblings of the same dataset state.
    """
    longer = sum(
        int(
            len(r["chosen_input_ids"]) - int(r["shared_prefix_len"])
            > len(r["rejected_input_ids"]) - int(r["shared_prefix_len"])
        )
        for r in rows
    )
    return longer / len(rows) if rows else float("nan")


def main(argv=None) -> int:
    logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
    ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    ap.add_argument("--root", type=Path, default=REPO, help="repo root")
    ap.add_argument(
        "--dry-run", action="store_true", help="measure and report; write and swap nothing"
    )
    args = ap.parse_args(argv)

    from align.data.loader import preference_dataset
    from align.data.store import ShardDataset

    shards_root = args.root / "data" / "shards"
    live = shards_root / "arm_b"
    staged = shards_root / "arm_b_dedup"
    predup = shards_root / "arm_b_predup"
    manifest_path = args.root / "data" / "manifest.json"

    if predup.exists():
        raise SystemExit(
            f"{predup} already exists: the swap already happened. This script "
            "is one-shot; a second run would dedup the already-deduped shards "
            "and overwrite the evidence."
        )
    if staged.exists():
        raise SystemExit(
            f"{staged} already exists (a previous run died mid-write?). "
            "Inspect and delete it before re-running; a half-written shard "
            "set masquerades as resumable."
        )

    # -- pass 1: dedup into the staging directory ---------------------------
    pair_ids: dict[str, set[str]] = {}
    before_after: dict[str, tuple[int, int]] = {}
    new_hashes: dict[str, str] = {}
    for split, prefix in SPLITS:
        ds = ShardDataset(live / split)
        kept, dropped = dedup_row_indices(ds)
        before_after[split] = (len(ds), len(kept))
        pair_ids[split] = {ds[i].extras["pair_id"] for i in kept}
        logger.info(
            "%s: %d rows -> %d kept (%d exact-duplicate re-adds dropped)",
            split,
            len(ds),
            len(kept),
            len(dropped),
        )
        if not args.dry_run:
            new_hashes[prefix] = write_split(ds, kept, staged / split, prefix)

    overlap = pair_ids["train"] & pair_ids["held_out"]
    if overlap:
        raise SystemExit(
            f"{len(overlap)} pair_ids appear in BOTH deduped splits (e.g. "
            f"{sorted(overlap)[:3]}); the train/held split is broken and a "
            "dedup must not paper over it."
        )

    if args.dry_run:
        logger.info("dry run: nothing written, nothing swapped")
        return 0

    # -- pass 2: verify the staged shards through a fresh reader ------------
    for split, _prefix in SPLITS:
        staged_ds = ShardDataset(staged / split)
        staged_ids = [staged_ds[i].extras["pair_id"] for i in range(len(staged_ds))]
        if len(staged_ids) != len(set(staged_ids)) or len(staged_ids) != before_after[split][1]:
            raise SystemExit(
                f"staged {split} re-read: {len(staged_ids)} rows, "
                f"{len(set(staged_ids))} unique -- the write did not produce "
                "what the dedup computed; refusing to swap."
            )

    # -- pass 3: atomic swap; the pre-dedup shards are KEPT as evidence -----
    live.rename(predup)
    staged.rename(live)
    logger.info("swapped: %s -> %s, %s -> %s", "arm_b", "arm_b_predup", "arm_b_dedup", "arm_b")

    # -- pass 4: manifest update, through the numbers' own definitions ------
    manifest = json.loads(manifest_path.read_text())
    old = {
        "counts.arm_b_curated": manifest["counts"]["arm_b_curated"],
        "hashes.arm_b-train": manifest["hashes"]["arm_b-train"],
        "hashes.arm_b-held": manifest["hashes"]["arm_b-held"],
        "arm_b_chosen_longer_fraction": manifest["length_bias_baseline"][
            "arm_b_chosen_longer_fraction"
        ],
        "arm_b_chosen_longer_fraction_step_basis": manifest["length_bias_baseline"][
            "arm_b_chosen_longer_fraction_step_basis"
        ],
    }

    # Re-measure the length-bias baseline over the deduped TRAIN split with
    # the exact function scripts/dpo.py falls back to, on the exact rows it
    # would build (read-only imports; nothing here re-implements the metric).
    from align.train.dpo_stage import measure_chosen_longer_fraction

    rows = preference_dataset(live / "train")
    new_fraction = measure_chosen_longer_fraction(rows)
    new_fraction_step = step_basis_fraction(rows)

    n_train, n_held = before_after["train"][1], before_after["held_out"][1]
    manifest["counts"]["arm_b_curated"] = n_train + n_held
    manifest["hashes"]["arm_b-train"] = new_hashes["arm_b-train"]
    manifest["hashes"]["arm_b-held"] = new_hashes["arm_b-held"]
    manifest["length_bias_baseline"]["arm_b_chosen_longer_fraction"] = new_fraction
    manifest["length_bias_baseline"]["arm_b_chosen_longer_fraction_step_basis"] = new_fraction_step
    manifest["arm_b_dedup"] = {
        "reason": (
            "87 train + 2 held_out pair_ids were present exactly twice, every "
            "second occurrence content-identical to its first (curation "
            "depth-round top-up re-added pairs already selected; the merge in "
            "prm800k_pairs.curate never deduped by pair_id). The ref-logprob "
            "cache build correctly refused the duplicates."
        ),
        "script": "scripts/dedup_arm_b_shards.py",
        "train_rows": {"before": before_after["train"][0], "after": n_train},
        "held_out_rows": {"before": before_after["held_out"][0], "after": n_held},
        "old": old,
        "predup_shards": str(predup),
    }
    manifest_path.write_text(json.dumps(manifest, indent=1))

    logger.info("train: %d -> %d rows", before_after["train"][0], n_train)
    logger.info("held_out: %d -> %d rows", before_after["held_out"][0], n_held)
    logger.info("counts.arm_b_curated: %s -> %d", old["counts.arm_b_curated"], n_train + n_held)
    logger.info(
        "arm_b_chosen_longer_fraction: %s -> %.6f (step basis %s -> %.6f)",
        old["arm_b_chosen_longer_fraction"],
        new_fraction,
        old["arm_b_chosen_longer_fraction_step_basis"],
        new_fraction_step,
    )
    logger.info(
        "hashes: arm_b-train %s -> %s, arm_b-held %s -> %s",
        old["hashes.arm_b-train"],
        new_hashes["arm_b-train"],
        old["hashes.arm_b-held"],
        new_hashes["arm_b-held"],
    )
    logger.info(
        "NOTE: ref caches keyed on the old manifest hashes are now stale; "
        "re-run scripts/precompute_ref_logprobs.py before scripts/dpo.py."
    )
    return 0


if __name__ == "__main__":
    # House rule: everything above is import-safe because spawn-based worker
    # pools re-import __main__; the entry point stays behind this guard.
    raise SystemExit(main())