File size: 13,081 Bytes
bce3b06
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""
Training-data generator for the tiny cube-solving model.

Emits (state, solution) pairs as JSONL:
    {"state": "<54 facelet chars>", "solution": "R U2 F' ...", "depth": <walk length>}

## Why states come from a random walk, not direct cubie sampling

Sampling a cube state directly (random piece permutation + orientations) is the
textbook approach, but it needs a consistent orientation reference per slot, and
the slot lists in `packages/engine/src/moveTables.ts` identify slots without
encoding one -- they are not ordered U/D-first with a consistent cyclic
direction. Sampling against them naively produces a valid cube only ~1 call in
37 (measured), because the corner-twist, edge-flip and permutation-parity
constraints are then being enforced against the wrong reference.

Walking from solved using the engine's own move tables sidesteps the whole
question: every reachable state is valid by construction, and the tables are
already cross-checked against an independent implementation. Measured against
the exact-uniform samples that *did* pass, the two agree on the distribution of
Kociemba solution length (mean 20.75 vs 21.0, both spanning 17-22), so the walk
is reaching the uniform distribution, not a biased corner of it.

## Why the walk is long

A deep walk is the point, not a cost. Every reachable state is at most 20 moves
from solved (God's Number), so a walk of 200 samples the same "fully mixed"
distribution as a walk of 50 or 1000 -- which is exactly why the model does not
need a depth curriculum to handle depth-50 and depth-100 benchmark buckets. It
never sees "depth"; it sees a state. Shallow depths are the rare tail, so they
are mixed in explicitly via --shallow-frac rather than left to chance.
"""
import argparse, json, os, random, sys, time

QT={
  "U": [6, 3, 0, 7, 4, 1, 8, 5, 2, 45, 46, 47, 12, 13, 14, 15, 16, 17, 9, 10, 11, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 18, 19, 20, 39, 40, 41, 42, 43, 44, 36, 37, 38, 48, 49, 50, 51, 52, 53],
  "R": [0, 1, 20, 3, 4, 23, 6, 7, 26, 15, 12, 9, 16, 13, 10, 17, 14, 11, 18, 19, 29, 21, 22, 32, 24, 25, 35, 27, 28, 51, 30, 31, 48, 33, 34, 45, 36, 37, 38, 39, 40, 41, 42, 43, 44, 8, 46, 47, 5, 49, 50, 2, 52, 53],
  "F": [0, 1, 2, 3, 4, 5, 44, 41, 38, 6, 10, 11, 7, 13, 14, 8, 16, 17, 24, 21, 18, 25, 22, 19, 26, 23, 20, 15, 12, 9, 30, 31, 32, 33, 34, 35, 36, 37, 27, 39, 40, 28, 42, 43, 29, 45, 46, 47, 48, 49, 50, 51, 52, 53],
  "D": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 24, 25, 26, 18, 19, 20, 21, 22, 23, 42, 43, 44, 33, 30, 27, 34, 31, 28, 35, 32, 29, 36, 37, 38, 39, 40, 41, 51, 52, 53, 45, 46, 47, 48, 49, 50, 15, 16, 17],
  "L": [53, 1, 2, 50, 4, 5, 47, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 0, 19, 20, 3, 22, 23, 6, 25, 26, 18, 28, 29, 21, 31, 32, 24, 34, 35, 42, 39, 36, 43, 40, 37, 44, 41, 38, 45, 46, 33, 48, 49, 30, 51, 52, 27],
  "B": [11, 14, 17, 3, 4, 5, 6, 7, 8, 9, 10, 35, 12, 13, 34, 15, 16, 33, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 36, 39, 42, 2, 37, 38, 1, 40, 41, 0, 43, 44, 51, 48, 45, 52, 49, 46, 53, 50, 47],
}


SOLVED = "".join("URFDLB"[i // 9] for i in range(54))
MOVES = [f + s for f in "URFDLB" for s in ("", "'", "2")]
_TURNS = {"": 1, "'": 3, "2": 2}


def apply_move(state: str, move: str) -> str:
    """Applies one move in standard notation to a 54-char facelet string."""
    perm = QT[move[0]]
    for _ in range(_TURNS[move[1:]]):
        state = "".join(state[perm[i]] for i in range(54))
    return state


def apply_sequence(state: str, moves) -> str:
    for m in moves:
        state = apply_move(state, m)
    return state


def random_state(rng: random.Random, depth: int) -> str:
    """Walks `depth` moves from solved, never turning the same face twice in a row."""
    state, prev = SOLVED, None
    for _ in range(depth):
        while True:
            move = rng.choice(MOVES)
            if move[0] != prev:
                break
        state = apply_move(state, move)
        prev = move[0]
    return state


def check_kociemba_is_native(warn_only=False):
    """Refuses to run against kociemba's pure-Python fallback.

    The package ships a C extension and a pure-Python implementation, and falls
    back to the latter *silently* -- only a warning -- when the extension cannot
    build (typically missing dev headers for whichever interpreter the venv was
    made from). The fallback is roughly 50x slower, which turns a two-hour dataset
    into a multi-day one. Since the only symptom is a slow log, this is checked
    up front rather than discovered later: a rented machine generating at
    fallback speed bills the whole time.
    """
    import time
    import kociemba

    probe = "DRLUUBFBRBLURRLRUBLRDDFDLFUFUFFDBRDUBRUFLLFDDBFLUBLRBD"
    kociemba.solve(probe)  # warm the pruning tables before timing
    start = time.time()
    for _ in range(10):
        kociemba.solve(probe)
    ms = (time.time() - start) / 10 * 1000
    if ms > 200:
        msg = (f"kociemba is running at {ms:.0f} ms/solve, which is pure-Python "
               f"fallback speed (native is ~20 ms). Install build-essential and "
               f"python3-dev for this interpreter, then reinstall kociemba.")
        if not warn_only:
            raise SystemExit(f"ABORT: {msg}")
        print(f"WARNING: {msg}", file=sys.stderr)
    return ms


def generate(count, seed, deep_depth, shallow_frac, max_shallow, augment, out):
    import kociemba

    rng = random.Random(seed)
    kociemba.solve(random_state(rng, 20))  # warm the pruning tables
    written = 0
    start = time.time()

    while written < count:
        if rng.random() < shallow_frac:
            depth = rng.randint(1, max_shallow)
        else:
            depth = deep_depth
        state = random_state(rng, depth)
        if state == SOLVED:
            continue
        solution = kociemba.solve(state).split()

        # One Kociemba call also settles every state along its own solution path:
        # after applying a prefix, the remaining suffix is a valid solve of the
        # state you land on. That is a ~20x multiplier on a call costing ~23ms,
        # and it is what makes a multi-million-pair dataset practical on few
        # cores. The suffix labels are valid but not what Kociemba would itself
        # emit for those states, so they are a slightly different (more
        # multi-modal) target than the canonical pairs -- hence opt-in, and worth
        # measuring against augment=0 before relying on it.
        pairs = [(state, solution, depth)]
        if augment:
            walk = state
            for i, move in enumerate(solution[:-1]):
                walk = apply_move(walk, move)
                if walk == SOLVED:
                    break
                pairs.append((walk, solution[i + 1:], len(solution) - i - 1))
            rng.shuffle(pairs)
            pairs = pairs[:augment]

        for st, sol, d in pairs:
            if written >= count:
                break
            out.write(json.dumps({"state": st, "solution": " ".join(sol), "depth": d}) + "\n")
            written += 1

        if written % 20000 < len(pairs):
            rate = written / max(time.time() - start, 1e-9)
            print(f"  {written}/{count}  ({rate:.0f}/s)", file=sys.stderr)

    return written


def _worker(job):
    """One shard, in its own process with its own seed and its own pruning tables."""
    idx, count, args = job
    path = f"{args['out']}.part{idx}"
    with open(path, "w") as fh:
        generate(count, args["seed"] + 1000 * idx, args["deep_depth"],
                 args["shallow_frac"], args["max_shallow"], args["augment"], fh)
    return path


def effective_cpus():
    """Number of CPUs this process may actually use.

    `nproc` and os.cpu_count() report the *host's* cores, which on a rented
    container is not what we get: a box advertising 128 cores routinely runs
    under a cgroup quota of a fraction of that. Spawning one worker per host core
    then puts 128 processes on ~16 CPUs, each running at an eighth speed. The
    aggregate is unchanged -- it is capped by the quota either way -- but every
    throughput estimate derived from the advertised core count is wrong by that
    factor, which is how a 20-minute job came to be predicted for a 100-minute
    one.

    Prefers the scheduler affinity mask, then the cgroup v2 and v1 quotas.
    """
    candidates = []
    try:
        candidates.append(len(os.sched_getaffinity(0)))
    except AttributeError:
        pass
    for quota_path, period_path in (
        ("/sys/fs/cgroup/cpu.max", None),
        ("/sys/fs/cgroup/cpu/cpu.cfs_quota_us", "/sys/fs/cgroup/cpu/cpu.cfs_period_us"),
    ):
        try:
            raw = open(quota_path).read().split()
            quota = raw[0]
            period = int(raw[1]) if period_path is None else int(open(period_path).read())
            if quota not in ("max", "-1"):
                candidates.append(max(1, int(int(quota) / period)))
        except (OSError, ValueError, IndexError):
            pass
    candidates.append(os.cpu_count() or 1)
    return max(1, min(candidates))


def generate_parallel(count, workers, args_dict, shards=None):
    """Shards generation across processes.

    `shards` fixes the number of shards independently of `workers`. Per-shard
    seeds are derived as seed + 1000*index, so the dataset produced depends on the
    shard count -- meaning two machines with different core counts generate
    *different* data from the same --seed. That is fine for training data and
    quietly wrong for a shared holdout: two models meant to face identical puzzles
    face merely similar ones, turning a paired comparison into an unpaired one.
    Pass a fixed `shards` whenever the output must be reproducible across
    machines.
    """
    """Shards generation across processes. Kociemba is CPU-bound and releases
    nothing to threads, so processes are the only way to use more than one core --
    and generation, not training, is the wall-clock bottleneck for this dataset."""
    import multiprocessing as mp

    n_shards = shards or workers
    per = [count // n_shards] * n_shards
    for i in range(count % n_shards):
        per[i] += 1
    jobs = [(i, per[i], args_dict) for i in range(n_shards) if per[i]]
    with mp.Pool(min(workers, len(jobs))) as pool:
        parts = pool.map(_worker, jobs)

    written = 0
    with open(args_dict["out"], "w") as out:
        for path in parts:
            with open(path) as fh:
                for line in fh:
                    out.write(line)
                    written += 1
            os.remove(path)
    return written


def main():
    p = argparse.ArgumentParser(description=__doc__)
    p.add_argument("--count", type=int, default=1_000_000)
    p.add_argument("--seed", type=int, default=0)
    p.add_argument("--deep-depth", type=int, default=200,
                   help="Walk length for the deep (fully mixed) majority of states.")
    p.add_argument("--shallow-frac", type=float, default=0.15,
                   help="Fraction drawn from near-solved states instead.")
    p.add_argument("--max-shallow", type=int, default=8)
    p.add_argument("--augment", type=int, default=0, metavar="K",
                   help="Also emit up to K states from each solution path (0 = canonical pairs only).")
    p.add_argument("--out", default="-")
    p.add_argument("--shards", type=int, default=0,
                   help="Fix the shard count so output is identical across machines "
                        "regardless of core count. Required for a shared holdout.")
    p.add_argument("--workers", type=int, default=0,
                   help="Parallel generator processes. 0 = the CPUs actually available "
                        "to this container, which is usually far fewer than nproc reports.")
    args = p.parse_args()

    ms = check_kociemba_is_native()
    if args.workers <= 0:
        args.workers = effective_cpus()
    print(f"kociemba {ms:.1f} ms/solve | workers {args.workers} "
          f"(host reports {os.cpu_count()})", file=sys.stderr)

    if args.workers > 1:
        if args.out == "-":
            p.error("--workers needs --out (shards are merged into a file)")
        start = time.time()
        n = generate_parallel(args.count, args.workers, {
            "out": args.out, "seed": args.seed, "deep_depth": args.deep_depth,
            "shallow_frac": args.shallow_frac, "max_shallow": args.max_shallow,
            "augment": args.augment}, shards=args.shards or None)
        print(f"wrote {n} pairs in {time.time()-start:.0f}s "
              f"({n/max(time.time()-start,1e-9):.0f}/s, {args.workers} workers)", file=sys.stderr)
        return

    out = sys.stdout if args.out == "-" else open(args.out, "w")
    try:
        n = generate(args.count, args.seed, args.deep_depth, args.shallow_frac,
                     args.max_shallow, args.augment, out)
        print(f"wrote {n} pairs", file=sys.stderr)
    finally:
        if out is not sys.stdout:
            out.close()


if __name__ == "__main__":
    main()