| """ |
| 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) |
| 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)) |
| 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() |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| 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() |
|
|