File size: 12,099 Bytes
201dc77
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""build_data.py end-to-end on miniature inputs.
Run: uv run pytest tests/test_build_data_e2e.py -m "not gpu" -v

The build has ONE legal order -- eval sets, then the decontamination hash set,
then every training corpus excluded against it -- because any other order
produces a corpus that was never checked against the thing it will be
evaluated on, and nothing downstream can tell the difference.
"""

from __future__ import annotations

import json
import math
import os

import pytest

from align.data import build as bd
from align.data.sources import DataSources

# ---------------------------------------------------------------------------
# Miniature source fixtures: same file layout as the real snapshots, ~20 rows.
# ---------------------------------------------------------------------------


def _write_rows(path: str, rows: list[dict]) -> None:
    """JSONL fixture (sources.iter_local_rows reads it like a parquet; the
    suite stays pyarrow-free)."""
    os.makedirs(os.path.dirname(path), exist_ok=True)
    with open(path, "w") as f:
        for r in rows:
            f.write(json.dumps(r) + "\n")


@pytest.fixture
def mini_sources(tmp_workdir) -> DataSources:
    root = os.path.join(tmp_workdir, "src")
    os.makedirs(root, exist_ok=True)

    # --- eval sets -------------------------------------------------------
    m500 = os.path.join(root, "math500", "test.jsonl")
    os.makedirs(os.path.dirname(m500), exist_ok=True)
    with open(m500, "w") as f:
        for i in range(6):
            f.write(
                json.dumps(
                    {
                        "problem": f"Eval math problem {i}. What is ${i}+1$?",
                        "answer": str(i + 1),
                        "subject": "Algebra",
                        "level": 3,
                        "solution": "trivial",
                        "unique_id": f"test/algebra/{i}.json",
                    }
                )
                + "\n"
            )

    gsm = os.path.join(root, "gsm8k", "test-00000-of-00001.jsonl")
    _write_rows(
        gsm,
        [
            {"question": f"Eval gsm problem {i}?", "answer": f"reasoning\n#### {i}"}
            for i in range(4)
        ],
    )

    # --- MATH corpus for the subject join --------------------------------
    for subject, n in (("algebra", 6), ("geometry", 6)):
        _write_rows(
            os.path.join(root, "hendrycks", subject, "train-00000-of-00001.jsonl"),
            [
                {
                    "problem": f"Train problem {subject} {i}.",
                    "solution": "s",
                    "type": subject.capitalize(),
                    "level": "Level 3",
                }
                for i in range(n)
            ],
        )

    # --- PRM800K annotation records --------------------------------------
    def node(comps, chosen=None):
        return {
            "completions": [{"text": t, "rating": r, "flagged": False} for t, r in comps],
            "chosen_completion": chosen,
            "human_completion": None,
        }

    recs = []
    for subject in ("algebra", "geometry"):
        for i in range(6):
            problem = f"Train problem {subject} {i}."
            # A clean solution (SFT-eligible) ...
            recs.append(
                {
                    "question": {"problem": problem, "ground_truth_answer": str(i)},
                    "label": {
                        "finish_reason": "solution",
                        "steps": [
                            node([(f"First we consider case {i}.", 1)], chosen=0),
                            node([(f"# Answer\n\n{i}", 1)], chosen=0),
                        ],
                    },
                    "is_quality_control_question": False,
                }
            )
            # ... and an annotation with a rated sibling pair (Arm B site).
            recs.append(
                {
                    "question": {"problem": problem, "ground_truth_answer": str(i)},
                    "label": {
                        "finish_reason": "found_error",
                        "steps": [
                            node([(f"First we consider case {i}.", 1)], chosen=0),
                            node(
                                [
                                    (f"Then the answer is {i}.", 1),
                                    (f"Then the answer is {i + 99}.", -1),
                                    ("This restates the problem.", 0),
                                ],
                                chosen=0,
                            ),
                        ],
                    },
                    "is_quality_control_question": False,
                }
            )
    # One record that is contaminated: it is a MATH-500 eval problem.
    recs.append(
        {
            "question": {
                "problem": "Eval math problem 0. What is $0+1$?",
                "ground_truth_answer": "1",
            },
            "label": {
                "finish_reason": "solution",
                "steps": [
                    node([("Leaked step.", 1)], chosen=0),
                    node([("# Answer\n\n1", 1)], chosen=0),
                ],
            },
            "is_quality_control_question": False,
        }
    )

    prm_dir = os.path.join(root, "prm800k")
    os.makedirs(prm_dir, exist_ok=True)
    with open(os.path.join(prm_dir, "phase1_train.jsonl"), "w") as f:
        for r in recs[:4]:
            f.write(json.dumps(r) + "\n")
    with open(os.path.join(prm_dir, "phase2_train.jsonl"), "w") as f:
        for r in recs[4:]:
            f.write(json.dumps(r) + "\n")

    # --- argilla preference rows -----------------------------------------
    arg = os.path.join(root, "argilla", "train.jsonl")
    _write_rows(
        arg,
        [
            {
                "metadata": "{}",
                "instruction": f"Argilla problem {i}?",
                "chosen_response": "A longer chosen response with detail. " * (2 + i % 2),
                "chosen_rating": 9.0 if i % 4 else 5.0,
                "rejected_response": "Short rejected response.",
                "rejected_rating": 7.0,
            }
            for i in range(8)
        ],
    )

    return DataSources(
        prm800k_dir=prm_dir,
        math500_file=m500,
        gsm8k_test_file=gsm,
        hendrycks_math_dir=os.path.join(root, "hendrycks"),
        argilla_file=arg,
        revisions={
            "prm800k": "mini",
            "math500": "mini",
            "gsm8k": "mini",
            "hendrycks_math": "mini",
            "argilla": "mini",
        },
    )


@pytest.fixture
def mini_pins() -> bd.PinnedYields:
    """The miniature build's own pinned counts. Production defaults are the
    measured real-corpus numbers; a test that reused them would either be
    skipped or would teach us to relax the assertion."""
    return bd.PinnedYields(
        math500=6,
        gsm8k=4,
        decontamination=10,
        sft_rows=12,
        argilla_raw=8,
        argilla_filtered=6,
        arm_b_sites=12,
        arm_b_capped=12,
        arm_b_problems=12,
        arm_b_curated=8,
        descope_min_pairs=1,
        expected_chosen_longer=None,
        held_out_frac=0.2,
    )


def test_strict_build_order_and_receipts(mini_sources, mini_pins, tmp_workdir, v03_tokenizer):
    """Eval sets build FIRST, then the decontamination hash set, then training
    corpora excluded against it, then shards. Every artifact has a receipt;
    the final manifest lists all hashes + counts. Attempting to build a
    training corpus before the eval hash set exists raises."""
    out = os.path.join(tmp_workdir, "data")

    # Firing direction: the SFT stage refuses to run without its upstream.
    with pytest.raises(FileNotFoundError, match="eval"):
        bd.build_sft_corpus(mini_sources, out, v03_tokenizer, mini_pins)

    manifest = bd.build_all(mini_sources, out, v03_tokenizer, pins=mini_pins)

    assert manifest["stages"] == ["eval_sets", "decontamination", "sft", "arm_a", "arm_b"]
    assert manifest["counts"]["math500"] == 6
    assert manifest["counts"]["gsm8k_test"] == 4
    assert manifest["counts"]["decontamination_hashes"] == 10
    # The planted contaminated record is dropped, loudly and countably.
    assert manifest["counts"]["sft_rows"] == 12
    assert manifest["counts"]["sft_contaminated"] == 1
    assert manifest["counts"]["sft_rejected_boundary"] == 0

    # Stamped from a constant, never by hand: section-11's gates require
    # build_version >= 2 and RAISE on a missing field, so an unstamped build is
    # refused rather than assumed current. 1 = the doubled-marker builds.
    assert manifest["build_version"] == bd.BUILD_VERSION >= 2

    assert manifest["boundary_spec"]["vocab_absorbers"] == 0
    assert manifest["tokenizer_hash"]
    for key in ("math500", "gsm8k_test", "sft-train", "arm_a-train", "arm_b-train"):
        assert manifest["hashes"][key], f"missing content hash for {key}"

    # The corpus outcome-agreement statistic is RECORDED, with its decision
    # attached -- it is a measured property of the data, not a gate, and the
    # manifest is where it stays visible across rebuilds.
    # Arm B carries its OWN length baseline: its pairs share a step prefix, so
    # Arm A's number would arm the tripwire against a different distribution.
    lb = manifest["length_bias_baseline"]
    assert not math.isnan(lb["arm_b_chosen_longer_fraction"])
    assert not math.isnan(lb["arm_b_chosen_longer_fraction_step_basis"])
    # Arm A's pinned baseline is CHARACTER-based and must not be overwritten by
    # the token-based measurement the shard writer computes.
    assert lb["arm_a_chosen_longer_fraction"] == pytest.approx(
        manifest["counts"].get("arm_a_chosen_longer_fraction", lb["arm_a_chosen_longer_fraction"])
    )

    agreement = manifest["corpus_outcome_agreement"]
    assert (
        agreement["n"]
        == agreement["agree"]
        + agreement["equivalent_unnormalized"]
        + agreement["genuinely_divergent"]
    )
    assert agreement["rows_kept"] is True
    assert "anchor" in agreement["note"]

    on_disk = json.loads(open(os.path.join(out, "manifest.json")).read())
    assert on_disk == manifest


def test_split_by_problem_never_by_record(mini_sources, mini_pins, tmp_workdir, v03_tokenizer):
    """No problem_id appears on both sides of any split (SFT vs held-out, pair
    train vs held-out preference-accuracy set).
    Incident: PRM800K holds many annotation records per problem, so a
    record-level split reports an inflated validation number that looks like
    generalization."""
    out = os.path.join(tmp_workdir, "data")
    bd.build_all(mini_sources, out, v03_tokenizer, pins=mini_pins)

    for corpus in ("sft", "arm_b"):
        train = bd.problem_ids_in(os.path.join(out, "shards", corpus, "train"))
        held = bd.problem_ids_in(os.path.join(out, "shards", corpus, "held_out"))
        assert train, f"{corpus} train split is empty"
        assert held, f"{corpus} held-out split is empty"
        assert not (train & held), f"{corpus} leaks problems across the split"


def test_all_outputs_under_apps(tmp_workdir):
    """Every writable path statvfs-resolves onto the /apps device (uses the
    section-01 guard; tested here in the firing direction with a doctored
    output dir).
    Incident: / has ~13GB free and /tmp is a noexec tmpfs on a DIFFERENT
    device, so 'not the root device' is not enough -- it must be /apps."""
    ok = os.path.join(tmp_workdir, "data")
    bd.assert_build_paths(bd.output_paths(ok))  # silent direction

    doctored = "/tmp/nmo-should-never-write-here"
    try:
        with pytest.raises(RuntimeError, match="/apps"):
            bd.assert_build_paths(bd.output_paths(doctored))
    finally:
        # The guard creates each path before stat'ing it, so the firing
        # direction leaves a directory on the very filesystem this repo is
        # forbidden to write to. Remove it.
        import shutil

        shutil.rmtree(doctored, ignore_errors=True)