Avra98 commited on
Commit
bb23b91
·
verified ·
1 Parent(s): f1c86cd

Add README and training/generation code

Browse files
README.md ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ tags:
4
+ - sudoku
5
+ - reasoning
6
+ - curriculum
7
+ - jax
8
+ ---
9
+
10
+ # Sudoku superposition instances
11
+
12
+ Concrete board assignments for a 12-stage Sudoku latent curriculum.
13
+ Each stage keeps a candidate set per cell; this dataset materializes
14
+ those sets as ordinary `(row, col, value)` sequences so training is
15
+ standard next-token cross-entropy (no multi-hot candidate head).
16
+
17
+ Repo: [Avra98/Sudoku_superposition](https://huggingface.co/Avra98/Sudoku_superposition)
18
+
19
+ ## Dataset
20
+
21
+ | split | puzzles | instances | mean / puzzle |
22
+ | --- | ---: | ---: | ---: |
23
+ | train | 1,804,462 | 89,236,838 | 49.45 |
24
+ | test | 99,999 | 4,945,532 | 49.46 |
25
+
26
+ Mean instances per stage (train, stage 0 → 11):
27
+ `6.28, 5.96, 5.65, 5.34, 5.02, 4.68, 4.32, 3.88, 3.33, 2.58, 1.41, 1.00`
28
+
29
+ Stage 11 is the unique solution. No empty `(puzzle, stage)` pair.
30
+
31
+ ### Files (`data/`)
32
+
33
+ | file | shape | dtype | role |
34
+ | --- | --- | --- | --- |
35
+ | `{split}_assignments.npy` | `(M, 81)` | uint8 | one full board per instance, cell `r*9+c` |
36
+ | `{split}_starts.npy` | `(N, 12)` | int32 | first row in `assignments` for `(puzzle, stage)` |
37
+ | `{split}_counts.npy` | `(N, 12)` | uint8 | number of instances for `(puzzle, stage)` |
38
+ | `{split}_index.npy` | `(M, 3)` | int32 | `[puzzle_idx, stage, k]` (optional; starts/counts are enough) |
39
+
40
+ The trainer only needs `assignments`, `starts`, and `counts`.
41
+
42
+ Puzzle clue/solution arrays are **not** in this repo (they are the
43
+ original Sudoku npy files). Candidate masks used to *build* the
44
+ instances live in `datasets_multicandidate_s12/`.
45
+
46
+ ### How a training example is built
47
+
48
+ 1. Take the usual solver-order sequence: clue triples, then K latent
49
+ placeholders, then empty-cell triples.
50
+ 2. Sample one instance for the current curriculum stage.
51
+ 3. Rewrite **values only**. Location order stays solver-order.
52
+ 4. Predict the output triples with softmax CE. Latent slots are not
53
+ predicted.
54
+
55
+ Curriculum stage `t` (1..12) trains on stage-`(t-1)` instances.
56
+ Stage 12 targets the unique solution.
57
+
58
+ ## Code (`code/`)
59
+
60
+ - `code/train/` — JAX trainer (`data.py`, `trainer.py`, `evaluater.py`,
61
+ `train_and_evaluate.py`, `train_backtrack.py`, `main.py`, `model.py`)
62
+ - `code/build_superposition_dataset.py` — instance generator
63
+ - `code/build_instance_offsets.py` — `starts` / `counts` tables
64
+ - `code/superposition_instances.py` — per-puzzle instance sampler
65
+ - `code/sbatch_instance_latent.sh` — Slurm launch (feanor / H200)
66
+
67
+ Set `SUDOKU_INSTANCE_DIR` to the `data/` folder (or a local copy).
68
+
69
+ ```bash
70
+ from huggingface_hub import snapshot_download
71
+ snapshot_download("Avra98/Sudoku_superposition", local_dir="Sudoku_superposition")
72
+ ```
code/build_instance_offsets.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Precompute (puzzle, stage) -> row range into the instance assignments.
2
+
3
+ The index rows are grouped by puzzle then stage, so a start offset plus a count
4
+ per (puzzle, stage) is enough for O(1) sampling in the data loader.
5
+
6
+ Writes {split}_starts.npy (N, S) int32 and {split}_counts.npy (N, S) uint8.
7
+ """
8
+ import argparse
9
+ import os
10
+
11
+ import numpy as np
12
+
13
+
14
+ def main():
15
+ ap = argparse.ArgumentParser()
16
+ ap.add_argument("--dir", default="datasets_superposition")
17
+ ap.add_argument("--splits", default="train,test")
18
+ ap.add_argument("--stages", type=int, default=12)
19
+ args = ap.parse_args()
20
+
21
+ S = args.stages
22
+ for split in args.splits.split(","):
23
+ split = split.strip()
24
+ ix = np.load(os.path.join(args.dir, f"{split}_index.npy"), mmap_mode="r")
25
+ n_puzzles = int(np.array(ix[-1, 0])) + 1
26
+ key = np.array(ix[:, 0]).astype(np.int64) * S + np.array(ix[:, 1])
27
+ counts = np.bincount(key, minlength=n_puzzles * S).astype(np.int64)
28
+ if counts.max() > 255:
29
+ raise ValueError(f"count {counts.max()} exceeds uint8")
30
+ starts = np.concatenate([[0], np.cumsum(counts)[:-1]]).astype(np.int64)
31
+
32
+ starts = starts.reshape(n_puzzles, S).astype(np.int32)
33
+ counts = counts.reshape(n_puzzles, S).astype(np.uint8)
34
+
35
+ np.save(os.path.join(args.dir, f"{split}_starts.npy"), starts)
36
+ np.save(os.path.join(args.dir, f"{split}_counts.npy"), counts)
37
+
38
+ empty = int((counts == 0).sum())
39
+ print(f"[{split}] puzzles={n_puzzles:,} rows={len(ix):,} "
40
+ f"(puzzle,stage) cells with zero instances: {empty:,}")
41
+ print(f" mean instances per stage: "
42
+ + " ".join(f"{counts[:, s].mean():.2f}" for s in range(S)))
43
+
44
+
45
+ if __name__ == "__main__":
46
+ main()
code/build_superposition_dataset.py ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Materialize superposition instances for every puzzle and every stage.
2
+
3
+ Input: datasets_multicandidate_s12/{split}_cand_masks.npy (N, 12, 81)
4
+ Output: datasets_superposition/{split}_assignments.npy (M, 81) uint8
5
+ datasets_superposition/{split}_index.npy (M, 3) int32
6
+ columns: [puzzle_idx, stage, instance_within_stage]
7
+
8
+ M is about 48 x N (one puzzle expands into ~6 instances at stage 0 down to 1
9
+ at stage 11). Generation is embarrassingly parallel over puzzles.
10
+ """
11
+ import argparse
12
+ import os
13
+ import time
14
+
15
+ import numpy as np
16
+ import multiprocessing as mp
17
+
18
+ import superposition_instances as SI
19
+
20
+ _MASKS = None
21
+ _ARGS = None
22
+
23
+
24
+ def _init(path, args):
25
+ global _MASKS, _ARGS
26
+ _MASKS = np.load(path, mmap_mode="r")
27
+ _ARGS = args
28
+
29
+
30
+ def _one(idx):
31
+ assigns, index = [], []
32
+ for s in range(_MASKS.shape[1]):
33
+ mask = np.array(_MASKS[idx, s]).astype(np.uint16)
34
+ r = SI.instances_for_stage(
35
+ mask,
36
+ max_confine=_ARGS.max_confine,
37
+ max_instances=_ARGS.max_instances,
38
+ max_attempts=_ARGS.max_attempts,
39
+ seed=idx * 100 + s,
40
+ max_repair=_ARGS.max_repair,
41
+ require_new=True,
42
+ patience=_ARGS.patience,
43
+ )
44
+ inst = r["instances"]
45
+ if len(inst) == 0:
46
+ continue
47
+ assigns.append(inst.astype(np.uint8))
48
+ for k in range(len(inst)):
49
+ index.append((idx, s, k))
50
+ if not assigns:
51
+ return (np.zeros((0, 81), dtype=np.uint8),
52
+ np.zeros((0, 3), dtype=np.int32))
53
+ return (np.concatenate(assigns, axis=0),
54
+ np.array(index, dtype=np.int32))
55
+
56
+
57
+ def process_split(split, args):
58
+ mask_path = os.path.join(args.mask_dir, f"{split}_cand_masks.npy")
59
+ masks = np.load(mask_path, mmap_mode="r")
60
+ n = len(masks) if args.limit is None else min(args.limit, len(masks))
61
+ print(f"[{split}] {n:,} puzzles from {mask_path}", flush=True)
62
+
63
+ t0 = time.time()
64
+ chunks_a, chunks_i = [], []
65
+ done = 0
66
+ with mp.Pool(args.workers, initializer=_init,
67
+ initargs=(mask_path, args)) as pool:
68
+ for a, i in pool.imap(_one, range(n), chunksize=8):
69
+ if len(a):
70
+ chunks_a.append(a)
71
+ chunks_i.append(i)
72
+ done += 1
73
+ if done % 2000 == 0 or done == n:
74
+ rate = done / max(time.time() - t0, 1e-6)
75
+ kept = sum(len(x) for x in chunks_i)
76
+ print(f" {done:,}/{n:,} {rate:.0f} puzzles/s "
77
+ f"{kept:,} instances "
78
+ f"({kept / done:.1f} per puzzle)", flush=True)
79
+
80
+ assignments = (np.concatenate(chunks_a, axis=0) if chunks_a
81
+ else np.zeros((0, 81), dtype=np.uint8))
82
+ index = (np.concatenate(chunks_i, axis=0) if chunks_i
83
+ else np.zeros((0, 3), dtype=np.int32))
84
+
85
+ os.makedirs(args.out_dir, exist_ok=True)
86
+ ap = os.path.join(args.out_dir, f"{split}_assignments.npy")
87
+ ip = os.path.join(args.out_dir, f"{split}_index.npy")
88
+ np.save(ap, assignments)
89
+ np.save(ip, index)
90
+
91
+ elapsed = time.time() - t0
92
+ print(f"[{split}] saved {len(assignments):,} instances "
93
+ f"({len(assignments) / n:.2f} per puzzle) in {elapsed / 60:.1f} min",
94
+ flush=True)
95
+ print(f" {ap} {os.path.getsize(ap) / 1e9:.2f} GB", flush=True)
96
+ print(f" {ip} {os.path.getsize(ip) / 1e9:.2f} GB", flush=True)
97
+
98
+ # per-stage instance counts
99
+ print(f"[{split}] instances per stage:", flush=True)
100
+ for s in range(masks.shape[1]):
101
+ c = int((index[:, 1] == s).sum())
102
+ print(f" stage {s:>2}: {c:>12,} ({c / n:.2f} per puzzle)",
103
+ flush=True)
104
+ return len(assignments)
105
+
106
+
107
+ def main():
108
+ ap = argparse.ArgumentParser()
109
+ ap.add_argument("--mask_dir", default="datasets_multicandidate_s12")
110
+ ap.add_argument("--out_dir", default="datasets_superposition")
111
+ ap.add_argument("--splits", default="train,test")
112
+ ap.add_argument("--limit", type=int, default=None)
113
+ ap.add_argument("--max-confine", type=int, default=1)
114
+ ap.add_argument("--max-instances", type=int, default=32)
115
+ ap.add_argument("--max-attempts", type=int, default=400)
116
+ ap.add_argument("--max-repair", type=int, default=80)
117
+ ap.add_argument("--patience", type=int, default=40)
118
+ ap.add_argument("--workers", type=int, default=64)
119
+ args = ap.parse_args()
120
+
121
+ print(f"confine |S|<={args.max_confine} workers={args.workers} "
122
+ f"out={args.out_dir}", flush=True)
123
+ for split in args.splits.split(","):
124
+ process_split(split.strip(), args)
125
+
126
+
127
+ if __name__ == "__main__":
128
+ main()
code/env_paths.sh ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Portable RUN_DIR / PY / CUDNN_LIB for slimgpu vs Berkeley (gandalf/feanor).
3
+ #
4
+ # Override any of these before sourcing:
5
+ # SUDOKU_RUN_DIR, SUDOKU_PY, SUDOKU_CUDNN_LIB
6
+ #
7
+ # Usage (from launch_*.sh):
8
+ # source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/env_paths.sh"
9
+
10
+ _SLIM_ROOT="/egr/research-slim/ghoshavr/llm-reasoning-logic-puzzles"
11
+ _SLIM_RUN="${_SLIM_ROOT}/sudoku-code/multicandidate_run"
12
+ _SLIM_PY="/egr/research-slim/ghoshavr/conda-envs/logicpuzzles/bin/python"
13
+ _SLIM_CUDNN="/egr/research-slim/ghoshavr/conda-envs/logicpuzzles/lib/python3.9/site-packages/nvidia/cudnn/lib"
14
+
15
+ # Berkeley: prefer scratch (home often quota-limited), then $HOME (may be a symlink).
16
+ _BERK_RUN_SCRATCH="/scratch/users/gatmiry/llm-reasoning-logic-puzzles/sudoku-code/multicandidate_run"
17
+ _BERK_RUN="${HOME}/llm-reasoning-logic-puzzles/sudoku-code/multicandidate_run"
18
+
19
+ # Prefer explicit overrides, then slimgpu tree if present, else Berkeley home.
20
+ if [ -n "${SUDOKU_RUN_DIR:-}" ]; then
21
+ RUN_DIR="${SUDOKU_RUN_DIR}"
22
+ elif [ -d "${_SLIM_RUN}" ]; then
23
+ RUN_DIR="${_SLIM_RUN}"
24
+ elif [ -d "${_BERK_RUN_SCRATCH}" ]; then
25
+ RUN_DIR="${_BERK_RUN_SCRATCH}"
26
+ else
27
+ RUN_DIR="${_BERK_RUN}"
28
+ fi
29
+
30
+ if [ -n "${SUDOKU_PY:-}" ]; then
31
+ PY="${SUDOKU_PY}"
32
+ elif [ -x "${_SLIM_PY}" ]; then
33
+ PY="${_SLIM_PY}"
34
+ else
35
+ # Common Berkeley conda layouts; first existing wins.
36
+ for _cand in \
37
+ "${HOME}/miniconda3/envs/logic_puzzles/bin/python" \
38
+ "${HOME}/miniconda3/envs/logicpuzzles/bin/python" \
39
+ "${HOME}/anaconda3/envs/logic_puzzles/bin/python" \
40
+ "${HOME}/anaconda3/envs/logicpuzzles/bin/python" \
41
+ "${HOME}/.conda/envs/logic_puzzles/bin/python" \
42
+ "${HOME}/.conda/envs/logicpuzzles/bin/python" \
43
+ "/scratch/users/gatmiry/conda/envs/logic_puzzles/bin/python" \
44
+ "/scratch/users/gatmiry/conda/envs/logicpuzzles/bin/python"; do
45
+ if [ -x "${_cand}" ]; then
46
+ PY="${_cand}"
47
+ break
48
+ fi
49
+ done
50
+ if [ -z "${PY:-}" ]; then
51
+ if command -v python >/dev/null 2>&1; then
52
+ PY="$(command -v python)"
53
+ else
54
+ echo "ERROR: no Python found. Create env: conda env create -f ~/llm-reasoning-logic-puzzles/environment.yml -n logic_puzzles" >&2
55
+ echo " then: export SUDOKU_PY=\$HOME/miniconda3/envs/logic_puzzles/bin/python" >&2
56
+ return 1 2>/dev/null || exit 1
57
+ fi
58
+ fi
59
+ fi
60
+
61
+ if [ -n "${SUDOKU_CUDNN_LIB:-}" ]; then
62
+ CUDNN_LIB="${SUDOKU_CUDNN_LIB}"
63
+ elif [ -d "${_SLIM_CUDNN}" ]; then
64
+ CUDNN_LIB="${_SLIM_CUDNN}"
65
+ else
66
+ # Derive from PY site-packages if present; else leave empty (cluster CUDA often enough).
67
+ _py_root="$(cd "$(dirname "${PY}")/.." && pwd)"
68
+ _cudnn_guess="${_py_root}/lib/python3.9/site-packages/nvidia/cudnn/lib"
69
+ if [ -d "${_cudnn_guess}" ]; then
70
+ CUDNN_LIB="${_cudnn_guess}"
71
+ else
72
+ CUDNN_LIB=""
73
+ fi
74
+ fi
75
+
76
+ export RUN_DIR PY CUDNN_LIB
77
+ unset _SLIM_ROOT _SLIM_RUN _SLIM_PY _SLIM_CUDNN _BERK_RUN _cand _py_root _cudnn_guess
code/launch_instance_latent.sh ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # 12-stage latent curriculum, instance-wise superposition targets.
3
+ #
4
+ # Same architecture and curriculum as w12_latent, except:
5
+ # - the output prompt is one sampled stage-k assignment, not the unique
6
+ # solution (input clues stay fixed)
7
+ # - the candidate-head BCE is off (SUDOKU_AUX_WEIGHT=0)
8
+ # - promotion is gated on the in-set rate (emitted digit is a stage-k
9
+ # candidate), not on candidate-set exact match
10
+ #
11
+ # Usage:
12
+ # bash launch_instance_latent.sh # GPU 0, full 800k run
13
+ # GPU=6 bash launch_instance_latent.sh # pick another GPU
14
+ # SUDOKU_MAX_STEPS=200 bash launch_instance_latent.sh # smoke
15
+
16
+ set -u
17
+
18
+ _SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
19
+ _CODE_DIR="$(cd "${_SCRIPT_DIR}/.." && pwd)"
20
+ export SUDOKU_RUN_DIR="${SUDOKU_RUN_DIR:-${_SCRIPT_DIR}}"
21
+ # shellcheck source=env_paths.sh
22
+ source "${_SCRIPT_DIR}/env_paths.sh"
23
+
24
+ cd "$RUN_DIR" || exit 1
25
+ if [ -n "${CUDNN_LIB}" ]; then export LD_LIBRARY_PATH="${CUDNN_LIB}:${LD_LIBRARY_PATH:-}"; fi
26
+ export XLA_PYTHON_CLIENT_MEM_FRACTION=0.9
27
+
28
+ GPU="${GPU:-0}"
29
+ name="${NAME:-w12_inst_latent}"
30
+ mkdir -p logs
31
+
32
+ export SUDOKU_TRAIN_PATH="${_CODE_DIR}/datasets/train_sudoku_puzzles.npy"
33
+ export SUDOKU_TEST_PATH="${_CODE_DIR}/datasets/test_sudoku_puzzles.npy"
34
+ # Masks are a metric only (in-set rate / promotion). Not a training target.
35
+ export SUDOKU_TRAIN_CAND="${_CODE_DIR}/datasets_multicandidate_s12/train_cand_masks.npy"
36
+ export SUDOKU_TEST_CAND="${_CODE_DIR}/datasets_multicandidate_s12/test_cand_masks.npy"
37
+ export SUDOKU_INSTANCE_DIR="${_CODE_DIR}/datasets_superposition"
38
+
39
+ export SUDOKU_RESUME=0
40
+ export SUDOKU_LATENT_SLOTS=12
41
+ export SUDOKU_RECURRENT=1
42
+ export SUDOKU_BACKTRACK=0
43
+ export SUDOKU_START_STAGE=1
44
+ export SUDOKU_MAX_STAGE=12
45
+ export SUDOKU_CAND_SLOT_MODE=depth
46
+ export SUDOKU_PASSES_PER_STAGE=1
47
+ export SUDOKU_AUX_WEIGHT=0.0
48
+ export SUDOKU_LEVEL_BALANCED=0
49
+ export SUDOKU_DATA_CURRICULUM=none
50
+
51
+ export SUDOKU_PLATEAU_STEPS="${SUDOKU_PLATEAU_STEPS:-20000}"
52
+ export SUDOKU_PLATEAU_DELTA=0.005
53
+ export SUDOKU_PATIENCE="${SUDOKU_PATIENCE:-80000}"
54
+ export SUDOKU_MIN_STAGE_STEPS="${SUDOKU_MIN_STAGE_STEPS:-8000}"
55
+ export SUDOKU_PROMOTE_ACC="${SUDOKU_PROMOTE_ACC:-0.90}"
56
+ export SUDOKU_MAX_STEPS="${SUDOKU_MAX_STEPS:-800000}"
57
+ export SUDOKU_EVAL_EVERY="${SUDOKU_EVAL_EVERY:-2000}"
58
+ export SUDOKU_SAVE_EVERY="${SUDOKU_SAVE_EVERY:-10000}"
59
+ export SUDOKU_CKPT_KEEP=100
60
+
61
+ export SUDOKU_LR=0.0002
62
+ export SUDOKU_DROPOUT=0.2
63
+ export SUDOKU_WD=0.005
64
+
65
+ echo "launching ${name} on GPU ${GPU}"
66
+ echo " K=12 recurrent=1 aux=0 instance_dir=${SUDOKU_INSTANCE_DIR}"
67
+ echo " max_steps=${SUDOKU_MAX_STEPS} plateau=${SUDOKU_PLATEAU_STEPS} patience=${SUDOKU_PATIENCE}"
68
+ CUDA_VISIBLE_DEVICES="${GPU}" \
69
+ nohup "$PY" -u -m train.main \
70
+ --workdir="./logs/${name}" \
71
+ --exp_name="${name}" \
72
+ > "logs/${name}.log" 2>&1 &
73
+ echo " pid $! log=${RUN_DIR}/logs/${name}.log"
code/level_instance_stats.py ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Candidate-set size and instance count per stage, level 3 vs level 8.
2
+
3
+ Two metrics only, measured on the training masks.
4
+ """
5
+ import argparse
6
+ import multiprocessing as mp
7
+
8
+ import numpy as np
9
+
10
+ import superposition_instances as SI
11
+
12
+ _MASKS = None
13
+ _ARGS = None
14
+
15
+
16
+ def _init(path, args):
17
+ global _MASKS, _ARGS
18
+ _MASKS = np.load(path, mmap_mode="r")
19
+ _ARGS = args
20
+
21
+
22
+ def _one(idx):
23
+ out = []
24
+ for s in range(_MASKS.shape[1]):
25
+ mask = np.array(_MASKS[idx, s]).astype(np.uint16)
26
+ _, empties = SI.split_cells(mask)
27
+ sizes = [len(SI.digits_of(mask[c])) for c in empties]
28
+ width = float(np.mean(sizes)) if sizes else 1.0
29
+ wmax = int(np.max(sizes)) if sizes else 1
30
+ r = SI.instances_for_stage(
31
+ mask, max_confine=_ARGS.max_confine,
32
+ max_instances=_ARGS.max_instances,
33
+ max_attempts=_ARGS.max_attempts, seed=idx * 100 + s,
34
+ max_repair=_ARGS.max_repair)
35
+ out.append((s, width, wmax, r["n_instances"], len(empties)))
36
+ return out
37
+
38
+
39
+ def run(level, args):
40
+ meta = np.load(args.meta)
41
+ idxs = np.where(meta[:, 1] == level)[0][:args.limit].tolist()
42
+ with mp.Pool(args.workers, initializer=_init,
43
+ initargs=(args.masks, args)) as pool:
44
+ res = pool.map(_one, idxs, chunksize=4)
45
+ widths, maxes, counts, opens = {}, {}, {}, {}
46
+ for rows in res:
47
+ for s, w, wm, n, ne in rows:
48
+ widths.setdefault(s, []).append(w)
49
+ maxes.setdefault(s, []).append(wm)
50
+ counts.setdefault(s, []).append(n)
51
+ opens.setdefault(s, []).append(ne)
52
+ return len(idxs), widths, maxes, counts, opens
53
+
54
+
55
+ def main():
56
+ ap = argparse.ArgumentParser()
57
+ ap.add_argument("--masks", default="datasets_multicandidate_s12/train_cand_masks.npy")
58
+ ap.add_argument("--meta", default="datasets_multicandidate_s12/train_meta.npy")
59
+ ap.add_argument("--limit", type=int, default=400)
60
+ ap.add_argument("--max-confine", type=int, default=1)
61
+ ap.add_argument("--max-instances", type=int, default=64)
62
+ ap.add_argument("--max-attempts", type=int, default=300)
63
+ ap.add_argument("--max-repair", type=int, default=120)
64
+ ap.add_argument("--workers", type=int, default=60)
65
+ args = ap.parse_args()
66
+
67
+ n3, w3, m3, c3, o3 = run(3, args)
68
+ n8, w8, m8, c8, o8 = run(8, args)
69
+
70
+ print(f"training masks, {n3} level-3 puzzles and {n8} level-8 puzzles")
71
+ print(f"confinement threshold |S| <= {args.max_confine}")
72
+ print()
73
+ print(f"{'':>5} {'------------- level 3 -------------':>42} "
74
+ f"{'------------- level 8 -------------':>42}")
75
+ print(f"{'stage':>5} {'mean':>7} {'max':>7} {'worst':>7} {'open':>7} "
76
+ f"{'inst':>7} {'mean':>7} {'max':>7} {'worst':>7} {'open':>7} {'inst':>7}")
77
+ print("-" * 96)
78
+ for s in sorted(w3):
79
+ print(f"{s:>5} "
80
+ f"{np.mean(w3[s]):>7.2f} {np.mean(m3[s]):>7.2f} "
81
+ f"{np.max(m3[s]):>7d} {np.mean(o3[s]):>7.1f} "
82
+ f"{np.mean(c3[s]):>7.1f} "
83
+ f"{np.mean(w8[s]):>7.2f} {np.mean(m8[s]):>7.2f} "
84
+ f"{np.max(m8[s]):>7d} {np.mean(o8[s]):>7.1f} "
85
+ f"{np.mean(c8[s]):>7.1f}")
86
+ print()
87
+ print("mean = mean candidate set size over undetermined cells")
88
+ print("max = mean over puzzles of the largest candidate set in that puzzle")
89
+ print("worst = largest candidate set seen in any puzzle at that stage")
90
+ print("open = undetermined cells remaining; inst = instances at saturation")
91
+
92
+
93
+ if __name__ == "__main__":
94
+ main()
code/sbatch_instance_latent.sh ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ #SBATCH --partition=songmei
3
+ #SBATCH --nodelist=feanor
4
+ #SBATCH --gres=gpu:1
5
+ #SBATCH --cpus-per-task=8
6
+ #SBATCH --mem=64G
7
+ #SBATCH --time=72:00:00
8
+ #SBATCH --job-name=w12_inst
9
+ #SBATCH --output=/scratch/users/gatmiry/llm-reasoning-logic-puzzles/sudoku-code/wavecurriculum_run/logs/slurm_%x_%j.out
10
+ #SBATCH --error=/scratch/users/gatmiry/llm-reasoning-logic-puzzles/sudoku-code/wavecurriculum_run/logs/slurm_%x_%j.err
11
+
12
+ # Same recipe as the slimgpu w12_inst_latent run:
13
+ # K=12 recurrent latent curriculum, no backtrack, no candidate-head BCE.
14
+ # Output prompt = one sampled stage-k instance (CE against that sequence).
15
+ # Instance npy files and s12 masks live on feanor /tmp (scratch quota is 20 G).
16
+
17
+ set -u
18
+ hostname
19
+ nvidia-smi -L
20
+ echo "[$(date)] w12_inst_latent"
21
+
22
+ SCRATCH_ROOT=/scratch/users/gatmiry/llm-reasoning-logic-puzzles
23
+ RUN_DIR=${SCRATCH_ROOT}/sudoku-code/wavecurriculum_run
24
+ ENV_LOCAL=/tmp/logicpuzzles
25
+ CAND_DIR=/tmp/sudoku_s12
26
+ INST_DIR=/tmp/sudoku_superposition
27
+ LOCAL_LOG=/tmp/sudoku_wave_runs/w12_inst_latent
28
+ TARBALL_GANDALF=/tmp/logicpuzzles_env.tar.gz
29
+ TARBALL_LOCAL=/tmp/logicpuzzles_env_${SLURM_JOB_ID}.tar.gz
30
+ GANDALF_INST=gandalf.berkeley.edu:/tmp/sudoku_superposition
31
+ GANDALF_CAND=gandalf.berkeley.edu:/tmp/sudoku_s12
32
+
33
+ mkdir -p "${RUN_DIR}/logs" "${LOCAL_LOG}" /tmp/sudoku_wave_runs
34
+
35
+ SCP_OPTS="-o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new"
36
+ [ -f "${HOME}/.ssh/id_ed25519_berkeley" ] && SCP_OPTS="${SCP_OPTS} -i ${HOME}/.ssh/id_ed25519_berkeley"
37
+
38
+ if ${ENV_LOCAL}/bin/python -u -c "import jax; assert jax.default_backend()=='gpu' or 'cuda' in str(jax.devices()[0]).lower()" 2>/dev/null; then
39
+ echo "[$(date)] reusing ${ENV_LOCAL}"
40
+ else
41
+ echo "[$(date)] fetching env tarball"
42
+ rm -rf "${ENV_LOCAL}"
43
+ scp ${SCP_OPTS} "gandalf.berkeley.edu:${TARBALL_GANDALF}" "${TARBALL_LOCAL}"
44
+ tar xzf "${TARBALL_LOCAL}" -C /tmp
45
+ rm -f "${TARBALL_LOCAL}"
46
+ fi
47
+
48
+ export PY=${ENV_LOCAL}/bin/python
49
+ export LD_LIBRARY_PATH=\
50
+ ${ENV_LOCAL}/lib/python3.9/site-packages/nvidia/cudnn/lib:\
51
+ ${ENV_LOCAL}/lib/python3.9/site-packages/nvidia/cublas/lib:\
52
+ ${ENV_LOCAL}/lib/python3.9/site-packages/nvidia/cuda_runtime/lib:\
53
+ ${ENV_LOCAL}/lib/python3.9/site-packages/nvidia/cuda_nvrtc/lib:\
54
+ ${ENV_LOCAL}/lib/python3.9/site-packages/nvidia/nccl/lib:\
55
+ ${LD_LIBRARY_PATH:-}
56
+
57
+ ${PY} -u -c "import jax; print(jax.__version__, jax.devices(), jax.default_backend())"
58
+
59
+ # ---- Stage instance files onto node-local /tmp (scratch cannot hold 8 G) ----
60
+ need_inst=0
61
+ for f in train_assignments.npy train_starts.npy train_counts.npy \
62
+ test_assignments.npy test_starts.npy test_counts.npy; do
63
+ [ -s "${INST_DIR}/${f}" ] || need_inst=1
64
+ done
65
+ if [ "${need_inst}" = 1 ]; then
66
+ echo "[$(date)] pulling instances from ${GANDALF_INST}"
67
+ mkdir -p "${INST_DIR}"
68
+ rsync -a --progress -e "ssh ${SCP_OPTS}" \
69
+ "${GANDALF_INST}/" "${INST_DIR}/"
70
+ fi
71
+ for f in train_assignments.npy train_starts.npy train_counts.npy \
72
+ test_assignments.npy test_starts.npy test_counts.npy; do
73
+ [ -s "${INST_DIR}/${f}" ] || { echo "missing ${INST_DIR}/${f}" >&2; exit 1; }
74
+ done
75
+
76
+ need_cand=0
77
+ for f in train_cand_masks.npy test_cand_masks.npy; do
78
+ [ -s "${CAND_DIR}/${f}" ] || need_cand=1
79
+ done
80
+ if [ "${need_cand}" = 1 ]; then
81
+ echo "[$(date)] pulling s12 masks from ${GANDALF_CAND}"
82
+ mkdir -p "${CAND_DIR}"
83
+ rsync -a -e "ssh ${SCP_OPTS}" "${GANDALF_CAND}/" "${CAND_DIR}/" || true
84
+ fi
85
+ for f in train_cand_masks.npy test_cand_masks.npy; do
86
+ [ -s "${CAND_DIR}/${f}" ] || { echo "missing ${CAND_DIR}/${f}" >&2; exit 1; }
87
+ done
88
+
89
+ # ---- Exact same recipe as slimgpu w12_inst_latent ----
90
+ export SUDOKU_RESUME=0
91
+ export SUDOKU_START_STAGE=1
92
+ export SUDOKU_MAX_STAGE=12
93
+ export SUDOKU_LATENT_SLOTS=12
94
+ export SUDOKU_RECURRENT=1
95
+ export SUDOKU_BACKTRACK=0
96
+ export SUDOKU_CAND_SLOT_MODE=depth
97
+ export SUDOKU_PASSES_PER_STAGE=1
98
+ export SUDOKU_AUX_WEIGHT=0.0
99
+ export SUDOKU_LEVEL_BALANCED=0
100
+ export SUDOKU_DATA_CURRICULUM=none
101
+ export SUDOKU_PLATEAU_STEPS=20000
102
+ export SUDOKU_PLATEAU_DELTA=0.005
103
+ export SUDOKU_PATIENCE=80000
104
+ export SUDOKU_MIN_STAGE_STEPS=8000
105
+ # Stage 1->2: inset >= 0.85 (chance is ~0.41 on stage-0 sets) AND
106
+ # loc_acc >= 0.70. Frontier is min(inset, loc), so both must clear 0.85
107
+ # unless loc is the limiter (then 0.85 loc is required). The loc floor
108
+ # is applied via min(); SUDOKU_PROMOTE_LOC is the documented intent.
109
+ export SUDOKU_PROMOTE_ACC=0.85
110
+ export SUDOKU_PROMOTE_LOC=0.70
111
+ export SUDOKU_MAX_STEPS="${SUDOKU_MAX_STEPS:-800000}"
112
+ export SUDOKU_EVAL_EVERY=2000
113
+ export SUDOKU_SAVE_EVERY=10000
114
+ export SUDOKU_CKPT_KEEP=3
115
+ export SUDOKU_LR=0.0002
116
+ export SUDOKU_DROPOUT=0.2
117
+ export SUDOKU_WD=0.005
118
+ export SUDOKU_TRAIN_PATH="${SCRATCH_ROOT}/sudoku-code/datasets/train_sudoku_puzzles.npy"
119
+ export SUDOKU_TEST_PATH="${SCRATCH_ROOT}/sudoku-code/datasets/test_sudoku_puzzles.npy"
120
+ export SUDOKU_TRAIN_CAND="${CAND_DIR}/train_cand_masks.npy"
121
+ export SUDOKU_TEST_CAND="${CAND_DIR}/test_cand_masks.npy"
122
+ export SUDOKU_INSTANCE_DIR="${INST_DIR}"
123
+ export XLA_PYTHON_CLIENT_MEM_FRACTION=0.9
124
+
125
+ cd "${RUN_DIR}"
126
+ (
127
+ while true; do sleep 900
128
+ rsync -a "${LOCAL_LOG}.log" "${RUN_DIR}/logs/w12_inst_latent.log" 2>/dev/null || true
129
+ done
130
+ ) &
131
+ SYNC_PID=$!
132
+
133
+ echo "[$(date)] starting w12_inst_latent from scratch"
134
+ echo " K=12 recurrent=1 bt=0 aux=0 instance_dir=${INST_DIR}"
135
+ CUDA_VISIBLE_DEVICES=0 ${PY} -u -m train.main \
136
+ --workdir="${LOCAL_LOG}" --exp_name="w12_inst_latent" \
137
+ > "${LOCAL_LOG}.log" 2>&1
138
+ EC=$?
139
+ kill ${SYNC_PID} 2>/dev/null || true
140
+ rsync -a "${LOCAL_LOG}.log" "${RUN_DIR}/logs/w12_inst_latent.log" 2>/dev/null || true
141
+ echo "[$(date)] w12_inst_latent exit ${EC}"
142
+ exit ${EC}
code/sbatch_instance_latent_bt.sh ADDED
@@ -0,0 +1,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ #SBATCH --partition=songmei
3
+ #SBATCH --nodelist=feanor
4
+ #SBATCH --gres=gpu:1
5
+ #SBATCH --cpus-per-task=8
6
+ #SBATCH --mem=64G
7
+ #SBATCH --time=72:00:00
8
+ #SBATCH --job-name=w12_instbt
9
+ #SBATCH --output=/scratch/users/gatmiry/llm-reasoning-logic-puzzles/sudoku-code/wavecurriculum_run/logs/slurm_%x_%j.out
10
+ #SBATCH --error=/scratch/users/gatmiry/llm-reasoning-logic-puzzles/sudoku-code/wavecurriculum_run/logs/slurm_%x_%j.err
11
+
12
+ # Same instance-CE latent curriculum as w12_inst, plus adaptive backtrack:
13
+ # if a graduated stage's in-set rate falls more than the margin below its
14
+ # graduation value, train at that stage's depth until it recovers.
15
+
16
+ set -u
17
+ hostname
18
+ nvidia-smi -L
19
+ echo "[$(date)] w12_inst_latent_bt"
20
+
21
+ SCRATCH_ROOT=/scratch/users/gatmiry/llm-reasoning-logic-puzzles
22
+ RUN_DIR=${SCRATCH_ROOT}/sudoku-code/wavecurriculum_run
23
+ ENV_LOCAL=/tmp/logicpuzzles
24
+ CAND_DIR=/tmp/sudoku_s12
25
+ INST_DIR=/tmp/sudoku_superposition
26
+ LOCAL_LOG=/tmp/sudoku_wave_runs/w12_inst_latent_bt
27
+ TARBALL_GANDALF=/tmp/logicpuzzles_env.tar.gz
28
+ TARBALL_LOCAL=/tmp/logicpuzzles_env_${SLURM_JOB_ID}.tar.gz
29
+ GANDALF_INST=gandalf.berkeley.edu:/tmp/sudoku_superposition
30
+ GANDALF_CAND=gandalf.berkeley.edu:/tmp/sudoku_s12
31
+
32
+ mkdir -p "${RUN_DIR}/logs" "${LOCAL_LOG}" /tmp/sudoku_wave_runs
33
+
34
+ SCP_OPTS="-o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new"
35
+ [ -f "${HOME}/.ssh/id_ed25519_berkeley" ] && SCP_OPTS="${SCP_OPTS} -i ${HOME}/.ssh/id_ed25519_berkeley"
36
+
37
+ if ${ENV_LOCAL}/bin/python -u -c "import jax; assert jax.default_backend()=='gpu' or 'cuda' in str(jax.devices()[0]).lower()" 2>/dev/null; then
38
+ echo "[$(date)] reusing ${ENV_LOCAL}"
39
+ else
40
+ echo "[$(date)] fetching env tarball"
41
+ rm -rf "${ENV_LOCAL}"
42
+ scp ${SCP_OPTS} "gandalf.berkeley.edu:${TARBALL_GANDALF}" "${TARBALL_LOCAL}"
43
+ tar xzf "${TARBALL_LOCAL}" -C /tmp
44
+ rm -f "${TARBALL_LOCAL}"
45
+ fi
46
+
47
+ export PY=${ENV_LOCAL}/bin/python
48
+ export LD_LIBRARY_PATH=\
49
+ ${ENV_LOCAL}/lib/python3.9/site-packages/nvidia/cudnn/lib:\
50
+ ${ENV_LOCAL}/lib/python3.9/site-packages/nvidia/cublas/lib:\
51
+ ${ENV_LOCAL}/lib/python3.9/site-packages/nvidia/cuda_runtime/lib:\
52
+ ${ENV_LOCAL}/lib/python3.9/site-packages/nvidia/cuda_nvrtc/lib:\
53
+ ${ENV_LOCAL}/lib/python3.9/site-packages/nvidia/nccl/lib:\
54
+ ${LD_LIBRARY_PATH:-}
55
+
56
+ ${PY} -u -c "import jax; print(jax.__version__, jax.devices(), jax.default_backend())"
57
+
58
+ need_inst=0
59
+ for f in train_assignments.npy train_starts.npy train_counts.npy \
60
+ test_assignments.npy test_starts.npy test_counts.npy; do
61
+ [ -s "${INST_DIR}/${f}" ] || need_inst=1
62
+ done
63
+ if [ "${need_inst}" = 1 ]; then
64
+ echo "[$(date)] pulling instances from ${GANDALF_INST}"
65
+ mkdir -p "${INST_DIR}"
66
+ rsync -a --progress -e "ssh ${SCP_OPTS}" \
67
+ "${GANDALF_INST}/" "${INST_DIR}/"
68
+ fi
69
+ for f in train_assignments.npy train_starts.npy train_counts.npy \
70
+ test_assignments.npy test_starts.npy test_counts.npy; do
71
+ [ -s "${INST_DIR}/${f}" ] || { echo "missing ${INST_DIR}/${f}" >&2; exit 1; }
72
+ done
73
+
74
+ need_cand=0
75
+ for f in train_cand_masks.npy test_cand_masks.npy; do
76
+ [ -s "${CAND_DIR}/${f}" ] || need_cand=1
77
+ done
78
+ if [ "${need_cand}" = 1 ]; then
79
+ echo "[$(date)] pulling s12 masks from ${GANDALF_CAND}"
80
+ mkdir -p "${CAND_DIR}"
81
+ rsync -a -e "ssh ${SCP_OPTS}" "${GANDALF_CAND}/" "${CAND_DIR}/" || true
82
+ fi
83
+ for f in train_cand_masks.npy test_cand_masks.npy; do
84
+ [ -s "${CAND_DIR}/${f}" ] || { echo "missing ${CAND_DIR}/${f}" >&2; exit 1; }
85
+ done
86
+
87
+ export SUDOKU_RESUME=0
88
+ export SUDOKU_START_STAGE=1
89
+ export SUDOKU_MAX_STAGE=12
90
+ export SUDOKU_LATENT_SLOTS=12
91
+ export SUDOKU_RECURRENT=1
92
+ export SUDOKU_CAND_SLOT_MODE=depth
93
+ export SUDOKU_PASSES_PER_STAGE=1
94
+ export SUDOKU_AUX_WEIGHT=0.0
95
+ export SUDOKU_LEVEL_BALANCED=0
96
+ export SUDOKU_DATA_CURRICULUM=none
97
+ export SUDOKU_PLATEAU_STEPS=20000
98
+ export SUDOKU_PLATEAU_DELTA=0.005
99
+ export SUDOKU_PATIENCE=80000
100
+ export SUDOKU_MIN_STAGE_STEPS=8000
101
+ export SUDOKU_PROMOTE_ACC=0.85
102
+ export SUDOKU_PROMOTE_LOC=0.70
103
+ export SUDOKU_MAX_STEPS="${SUDOKU_MAX_STEPS:-800000}"
104
+ export SUDOKU_EVAL_EVERY=2000
105
+ export SUDOKU_SAVE_EVERY=10000
106
+ export SUDOKU_CKPT_KEEP=3
107
+ export SUDOKU_LR=0.0002
108
+ export SUDOKU_DROPOUT=0.2
109
+ export SUDOKU_WD=0.005
110
+ export SUDOKU_TRAIN_PATH="${SCRATCH_ROOT}/sudoku-code/datasets/train_sudoku_puzzles.npy"
111
+ export SUDOKU_TEST_PATH="${SCRATCH_ROOT}/sudoku-code/datasets/test_sudoku_puzzles.npy"
112
+ export SUDOKU_TRAIN_CAND="${CAND_DIR}/train_cand_masks.npy"
113
+ export SUDOKU_TEST_CAND="${CAND_DIR}/test_cand_masks.npy"
114
+ export SUDOKU_INSTANCE_DIR="${INST_DIR}"
115
+ export XLA_PYTHON_CLIENT_MEM_FRACTION=0.9
116
+
117
+ # Adaptive repair: if stage t's in-set rate drops more than 0.03 below
118
+ # the value it graduated at, replay that depth until it recovers.
119
+ export SUDOKU_BACKTRACK=1
120
+ export SUDOKU_BACKTRACK_MODE=adaptive
121
+ export SUDOKU_BACKTRACK_MARGIN=0.03
122
+ export SUDOKU_BACKTRACK_MAX_REPAIR_STEPS=4000
123
+ export SUDOKU_BACKTRACK_MIN_FRONTIER_STEPS=8000
124
+ export SUDOKU_BACKTRACK_MAX_REPAIR_FRACTION=0.25
125
+ export SUDOKU_BACKTRACK_GRAD_DECAY=0.05
126
+ export SUDOKU_BACKTRACK_FRONTIER_MIX=1
127
+
128
+ cd "${RUN_DIR}"
129
+ (
130
+ while true; do sleep 900
131
+ rsync -a "${LOCAL_LOG}.log" "${RUN_DIR}/logs/w12_inst_latent_bt.log" 2>/dev/null || true
132
+ done
133
+ ) &
134
+ SYNC_PID=$!
135
+
136
+ echo "[$(date)] starting w12_inst_latent_bt from scratch"
137
+ echo " K=12 recurrent=1 bt=adaptive aux=0 instance_dir=${INST_DIR}"
138
+ CUDA_VISIBLE_DEVICES=0 ${PY} -u -m train.main \
139
+ --workdir="${LOCAL_LOG}" --exp_name="w12_inst_latent_bt" \
140
+ > "${LOCAL_LOG}.log" 2>&1
141
+ EC=$?
142
+ kill ${SYNC_PID} 2>/dev/null || true
143
+ rsync -a "${LOCAL_LOG}.log" "${RUN_DIR}/logs/w12_inst_latent_bt.log" 2>/dev/null || true
144
+ echo "[$(date)] w12_inst_latent_bt exit ${EC}"
145
+ exit ${EC}
code/show_instance_stages.py ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Compare what a data instance looks like at different stages, for a few cells.
2
+
3
+ Tracks the same handful of cells across stages so the shrinking candidate sets
4
+ and the resulting instances line up column by column.
5
+ """
6
+ import argparse
7
+
8
+ import numpy as np
9
+
10
+ import superposition_instances as SI
11
+
12
+
13
+ def fmt(ds):
14
+ return "{" + ",".join(str(d) for d in ds) + "}"
15
+
16
+
17
+ def cell_name(c):
18
+ return f"r{c // 9}c{c % 9}"
19
+
20
+
21
+ def main():
22
+ ap = argparse.ArgumentParser()
23
+ ap.add_argument("--masks", default="datasets_multicandidate_s12/test_cand_masks.npy")
24
+ ap.add_argument("--puzzle", type=int, default=2)
25
+ ap.add_argument("--stages", type=int, nargs="+", default=[1, 5, 11])
26
+ ap.add_argument("--ncells", type=int, default=6)
27
+ ap.add_argument("--show-instances", type=int, default=8)
28
+ ap.add_argument("--max-confine", type=int, default=3)
29
+ ap.add_argument("--max-instances", type=int, default=32)
30
+ args = ap.parse_args()
31
+
32
+ masks = np.load(args.masks, mmap_mode="r")
33
+
34
+ # Pick cells that are still undetermined at the *latest* requested stage
35
+ # before the final one, so the same cells are interesting at every stage.
36
+ pick_stage = max(s for s in args.stages if s < masks.shape[1] - 1)
37
+ m_pick = np.array(masks[args.puzzle, pick_stage]).astype(np.uint16)
38
+ _, empties = SI.split_cells(m_pick)
39
+ cells = sorted(empties, key=lambda c: -len(SI.digits_of(m_pick[c])))[:args.ncells]
40
+ cells = sorted(cells)
41
+
42
+ print(f"puzzle {args.puzzle}, tracking {len(cells)} cells: "
43
+ + ", ".join(cell_name(c) for c in cells))
44
+
45
+ for s in args.stages:
46
+ mask = np.array(masks[args.puzzle, s]).astype(np.uint16)
47
+ forced, empties = SI.split_cells(mask)
48
+ res = SI.instances_for_stage(mask, max_confine=args.max_confine,
49
+ max_instances=args.max_instances, seed=7)
50
+ inst = res["instances"]
51
+
52
+ print()
53
+ print("=" * 88)
54
+ print(f"STAGE {s} {len(empties)} cells still undetermined, "
55
+ f"mean {res['mean_width']:.2f} candidates each")
56
+ print(f" generation: {res['n_instances']} instances kept, "
57
+ f"{res['n_rejected']} ruled out, "
58
+ f"grid-wide coverage {100*res['coverage']:.1f}%")
59
+ print("=" * 88)
60
+
61
+ print(" candidate set at this stage:")
62
+ for c in cells:
63
+ ds = SI.digits_of(mask[c])
64
+ tag = " (determined)" if len(ds) == 1 else ""
65
+ print(f" {cell_name(c)} {fmt(ds)}{tag}")
66
+
67
+ if not len(inst):
68
+ print(" no instances")
69
+ continue
70
+
71
+ print(f"\n first instance, written out as the (row, col, value) triples "
72
+ f"the model would emit:")
73
+ print(" " + " ".join(
74
+ f"({c//9},{c%9},{inst[0][c]})" for c in cells))
75
+
76
+ k = min(args.show_instances, len(inst))
77
+ print(f"\n value assigned to each cell, across the first {k} instances:")
78
+ print(" inst " + " ".join(f"{cell_name(c):>6}" for c in cells))
79
+ for i in range(k):
80
+ print(f" {i:>4} " + " ".join(
81
+ f"{inst[i][c]:>6}" for c in cells))
82
+
83
+ print(f"\n coverage over all {res['n_instances']} instances:")
84
+ for c in cells:
85
+ ds = set(SI.digits_of(mask[c]))
86
+ seen = set(int(a[c]) for a in inst)
87
+ miss = sorted(ds - seen)
88
+ status = "all candidates seen" if not miss else f"never seen: {fmt(miss)}"
89
+ print(f" {cell_name(c)} candidates {fmt(sorted(ds)):<16} "
90
+ f"seen {fmt(sorted(seen)):<16} {status}")
91
+
92
+
93
+ if __name__ == "__main__":
94
+ main()
code/show_superposition_example.py ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Worked example of superposition-instance generation for one puzzle/stage.
2
+
3
+ Prints the candidate sets, the dependencies read off the mask, the instances that
4
+ survived, the ones that were ruled out, and the resulting per-cell coverage.
5
+ Display is restricted to one unit so the table is readable; generation always
6
+ runs on the full grid.
7
+ """
8
+ import argparse
9
+
10
+ import numpy as np
11
+
12
+ import superposition_instances as SI
13
+
14
+ NAMES = ([f"row {r}" for r in range(9)] + [f"col {c}" for c in range(9)]
15
+ + [f"box {b}" for b in range(9)])
16
+
17
+
18
+ def fmt(cells):
19
+ return "{" + ",".join(str(d) for d in cells) + "}"
20
+
21
+
22
+ def main():
23
+ ap = argparse.ArgumentParser()
24
+ ap.add_argument("--masks", default="datasets_multicandidate_s12/test_cand_masks.npy")
25
+ ap.add_argument("--puzzle", type=int, default=2)
26
+ ap.add_argument("--stage", type=int, default=0)
27
+ ap.add_argument("--unit", type=int, default=14, help="unit index to display (14 = col 5)")
28
+ ap.add_argument("--max-confine", type=int, default=3)
29
+ ap.add_argument("--max-instances", type=int, default=16)
30
+ args = ap.parse_args()
31
+
32
+ masks = np.load(args.masks, mmap_mode="r")
33
+ mask = np.array(masks[args.puzzle, args.stage]).astype(np.uint16)
34
+
35
+ unit = SI.UNITS[args.unit]
36
+ forced, empties = SI.split_cells(mask)
37
+
38
+ print("=" * 78)
39
+ print(f"puzzle {args.puzzle}, stage {args.stage}, displaying {NAMES[args.unit]}")
40
+ print("=" * 78)
41
+ print(f"grid: {len(forced)} determined cells, {len(empties)} undetermined, "
42
+ f"mean candidate count {np.mean([len(SI.digits_of(mask[c])) for c in empties]):.2f}")
43
+
44
+ print(f"\ncandidate sets in {NAMES[args.unit]}:")
45
+ for c in unit:
46
+ r, cc = divmod(c, 9)
47
+ ds = SI.digits_of(mask[c])
48
+ tag = "determined" if len(ds) == 1 else ""
49
+ print(f" r{r}c{cc} {fmt(ds):<22} {tag}")
50
+
51
+ all_deps = SI.dependencies_from_mask(mask, max_confine=9)
52
+ conf_deps = SI.dependencies_from_mask(mask, max_confine=args.max_confine)
53
+ print(f"\ndependencies over the whole grid:")
54
+ print(f" {len(all_deps)} total (every unit, every unplaced digit)")
55
+ print(f" {len(conf_deps)} with confinement size <= {args.max_confine} "
56
+ f"(these are the ones enforced)")
57
+ sizes = {}
58
+ for _, S in all_deps:
59
+ sizes[len(S)] = sizes.get(len(S), 0) + 1
60
+ print(" by confinement size: "
61
+ + ", ".join(f"|S|={k}: {v}" for k, v in sorted(sizes.items())))
62
+
63
+ print(f"\ndependencies enforced inside {NAMES[args.unit]}:")
64
+ shown = 0
65
+ for d, S in conf_deps:
66
+ if not set(S) <= set(unit):
67
+ continue
68
+ locs = " or ".join(f"r{c//9}c{c%9}" for c in S)
69
+ print(f" digit {d} must be placed at {locs}")
70
+ shown += 1
71
+ if shown == 0:
72
+ print(" (none at this confinement threshold)")
73
+
74
+ res = SI.instances_for_stage(mask, max_confine=args.max_confine,
75
+ max_instances=args.max_instances, seed=0)
76
+
77
+ print(f"\ngeneration: {res['n_instances']} instances kept, "
78
+ f"{res['n_rejected']} ruled out over {res['n_attempts']} attempts")
79
+ print(f"coverage: {res['n_covered']}/{res['n_pairs']} "
80
+ f"(cell, candidate) pairs = {100*res['coverage']:.1f}%")
81
+
82
+ inst = res["instances"]
83
+ if len(inst):
84
+ print(f"\nwhat each instance assigned in {NAMES[args.unit]}:")
85
+ hdr = " inst " + " ".join(f"r{c//9}c{c%9}" for c in unit)
86
+ print(hdr)
87
+ for i, a in enumerate(inst):
88
+ print(f" {i:>4} " + " ".join(f"{a[c]:>4}" for c in unit))
89
+
90
+ print(f"\nper-cell coverage in {NAMES[args.unit]}:")
91
+ for c in unit:
92
+ ds = set(SI.digits_of(mask[c]))
93
+ seen = set(int(a[c]) for a in inst)
94
+ miss = sorted(ds - seen)
95
+ r, cc = divmod(c, 9)
96
+ status = "complete" if not miss else f"missing {fmt(miss)}"
97
+ print(f" r{r}c{cc} candidates {fmt(sorted(ds)):<22} "
98
+ f"seen {fmt(sorted(seen)):<22} {status}")
99
+
100
+
101
+ if __name__ == "__main__":
102
+ main()
code/smoke_instance_loader.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Sanity-check: instance rewrite keeps clues, varies empties, stays in-set."""
2
+ import os
3
+ import sys
4
+
5
+ import numpy as np
6
+
7
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "wavecurriculum_run"))
8
+ os.environ.setdefault("CUDA_VISIBLE_DEVICES", "")
9
+
10
+ from train.data import CurriculumState, SudokuDataset # noqa: E402
11
+
12
+
13
+ class Cfg:
14
+ seed = 7
15
+ seq_order = "solver-order"
16
+ num_latent_slots = 12
17
+ latent_token_id = 10
18
+ curriculum_max_stage = 12
19
+ data_curriculum = "none"
20
+ cand_slot_mode = "depth"
21
+ passes_per_stage = 1
22
+ level_balanced_sampling = 0
23
+ train_puzzle_path = "datasets/train_sudoku_puzzles.npy"
24
+ test_puzzle_path = "datasets/test_sudoku_puzzles.npy"
25
+ train_cand_masks_path = "datasets_multicandidate_s12/train_cand_masks.npy"
26
+ test_cand_masks_path = "datasets_multicandidate_s12/test_cand_masks.npy"
27
+ instance_dir = "datasets_superposition"
28
+ train_meta_path = None
29
+ test_meta_path = None
30
+
31
+
32
+ def main():
33
+ cur = CurriculumState(stage=1, max_stage=12)
34
+ ds = SudokuDataset(Cfg(), train=True, curriculum=cur)
35
+ assert ds.instances is not None
36
+ assert ds.cand_masks is not None
37
+
38
+ n_check = 8
39
+ for stage in (0, 5, 11):
40
+ cur.stage = stage + 1
41
+ clue_ok = clue_tot = 0
42
+ empty_in = empty_tot = empty_eq = 0
43
+ for idx in range(n_check):
44
+ base = ds.train_inputs[idx].copy()
45
+ si = int(ds.train_start_index[idx, 0])
46
+ rewritten = ds.apply_instance(base, idx, stage)
47
+ sol = ds.train_puzzles[idx]
48
+ mask = np.array(ds.cand_masks[idx, stage])
49
+ orig = {(int(t[0]), int(t[1])): int(t[2])
50
+ for t in base.reshape(81, 3)}
51
+ for t in rewritten[: 3 * si].reshape(-1, 3):
52
+ r, c, v = int(t[0]), int(t[1]), int(t[2])
53
+ clue_tot += 1
54
+ clue_ok += int(v == orig[(r, c)] == int(sol[r * 9 + c]))
55
+ for t in rewritten[3 * si:].reshape(-1, 3):
56
+ r, c, v = int(t[0]), int(t[1]), int(t[2])
57
+ bits = int(mask[r * 9 + c])
58
+ empty_tot += 1
59
+ empty_in += int(1 <= v <= 9 and (bits >> (v - 1)) & 1)
60
+ empty_eq += int(v == int(sol[r * 9 + c]))
61
+ print(f"stage {stage}: clues {clue_ok}/{clue_tot} "
62
+ f"in-set {empty_in}/{empty_tot} "
63
+ f"==sol {empty_eq}/{empty_tot}")
64
+ assert clue_ok == clue_tot, "clue values must stay the original clues"
65
+ assert empty_in == empty_tot, "every empty must stay in the stage set"
66
+ if stage == 11:
67
+ assert empty_eq == empty_tot, "stage 11 must be the unique solution"
68
+ else:
69
+ assert empty_eq < empty_tot, "early stages must still be ambiguous"
70
+ print("SMOKE OK")
71
+
72
+
73
+ if __name__ == "__main__":
74
+ main()
code/superposition_instances.py ADDED
@@ -0,0 +1,274 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Build superposition instances from a stage's candidate masks.
2
+
3
+ An *instance* is one concrete assignment: exactly one digit per cell, each drawn
4
+ from that cell's candidate set at that stage. Across the instances for a stage,
5
+ every (cell, candidate) pair should appear at least once, so the candidate set is
6
+ recoverable from the instances without ever being supervised as a set.
7
+
8
+ Build chronology (per puzzle, per stage):
9
+ 1. read the candidate sets from the stage mask
10
+ 2. derive the live dependencies from that same mask
11
+ 3. propose an instance
12
+ 4. drop it if it disobeys any dependency
13
+ 5. repeat 3-4 until every (cell, candidate) pair is covered
14
+
15
+ Dependencies are recomputed from the mask rather than recorded when a technique
16
+ fires. The stage-k mask is the cumulative product of every technique applied up
17
+ to stage k, so a confinement visible in the mask *is* the disjunction those
18
+ techniques established, and it stays visible exactly as long as it is live.
19
+
20
+ The confinement threshold matters. For a unit U and a digit d not yet placed in
21
+ U, S(U,d) is the set of cells of U that can still hold d, and the dependency is
22
+ "d must be placed somewhere in S(U,d)". Enforcing this for every unit and every
23
+ digit is full Sudoku unit coverage, which forces the unique solution and leaves
24
+ no superposition at all. Restricting to small |S(U,d)| keeps only the genuinely
25
+ locked disjunctions.
26
+ """
27
+ import numpy as np
28
+
29
+ POPCOUNT = np.array([bin(i).count("1") for i in range(512)], dtype=np.int8)
30
+
31
+
32
+ def build_units():
33
+ """The 27 units, each a list of 9 cell ids (cell = r*9 + c)."""
34
+ units = []
35
+ for r in range(9):
36
+ units.append([r * 9 + c for c in range(9)])
37
+ for c in range(9):
38
+ units.append([r * 9 + c for r in range(9)])
39
+ for br in range(0, 9, 3):
40
+ for bc in range(0, 9, 3):
41
+ units.append([(br + i) * 9 + (bc + j)
42
+ for i in range(3) for j in range(3)])
43
+ return units
44
+
45
+
46
+ UNITS = build_units()
47
+
48
+
49
+ def digits_of(m):
50
+ m = int(m)
51
+ return [d for d in range(1, 10) if m & (1 << (d - 1))]
52
+
53
+
54
+ def dependencies_from_mask(mask, max_confine=9):
55
+ """Live disjunctions readable from one stage's mask.
56
+
57
+ Returns a list of (digit, cells) meaning "digit must be placed in one of
58
+ these cells". Only digits not already pinned in the unit are included, and
59
+ only when the confinement size is at most max_confine.
60
+ """
61
+ deps = []
62
+ for unit in UNITS:
63
+ pinned = 0
64
+ for c in unit:
65
+ m = int(mask[c])
66
+ if m and (m & (m - 1)) == 0:
67
+ pinned |= m
68
+ for d in range(1, 10):
69
+ bit = 1 << (d - 1)
70
+ if pinned & bit:
71
+ continue
72
+ S = [c for c in unit if int(mask[c]) & bit]
73
+ if 1 <= len(S) <= max_confine:
74
+ deps.append((d, S))
75
+ return deps
76
+
77
+
78
+ def split_cells(mask):
79
+ """(forced cell -> digit, list of undetermined cells)."""
80
+ forced, empties = {}, []
81
+ for c in range(81):
82
+ m = int(mask[c])
83
+ if m == 0:
84
+ continue
85
+ if m & (m - 1) == 0:
86
+ forced[c] = m.bit_length()
87
+ else:
88
+ empties.append(c)
89
+ return forced, empties
90
+
91
+
92
+ def _violated(assign, deps):
93
+ return [i for i, (d, S) in enumerate(deps)
94
+ if not any(assign[c] == d for c in S)]
95
+
96
+
97
+ def _violations(assign, deps):
98
+ return len(_violated(assign, deps))
99
+
100
+
101
+ def build_dep_index(deps):
102
+ """cell -> [(dep index, digit that dep wants), ...]"""
103
+ cell_deps = [[] for _ in range(81)]
104
+ for i, (d, S) in enumerate(deps):
105
+ for c in S:
106
+ cell_deps[c].append((i, d))
107
+ return cell_deps
108
+
109
+
110
+ def repair(assign, deps, forced, rng, max_iter=200, cell_deps=None):
111
+ """Min-conflicts repair: repeatedly take a violated disjunction and give its
112
+ digit to whichever of its cells breaks the fewest other disjunctions.
113
+
114
+ Purely a proposal-quality step. The accept/reject test still runs afterwards
115
+ and is the only thing that decides whether an instance enters the dataset.
116
+ Violation counts are maintained incrementally, so each move costs only the
117
+ handful of disjunctions that touch the cell being changed.
118
+ """
119
+ if not deps:
120
+ return True
121
+ if cell_deps is None:
122
+ cell_deps = build_dep_index(deps)
123
+ counts = [sum(1 for c in S if assign[c] == d) for d, S in deps]
124
+ nviol = sum(1 for x in counts if x == 0)
125
+
126
+ def apply(c, new):
127
+ nonlocal nviol
128
+ old = assign[c]
129
+ if old == new:
130
+ return
131
+ for i, d in cell_deps[c]:
132
+ if d == old:
133
+ counts[i] -= 1
134
+ if counts[i] == 0:
135
+ nviol += 1
136
+ elif d == new:
137
+ if counts[i] == 0:
138
+ nviol -= 1
139
+ counts[i] += 1
140
+ assign[c] = new
141
+
142
+ for _ in range(max_iter):
143
+ if nviol == 0:
144
+ return True
145
+ bad = [i for i, x in enumerate(counts) if x == 0]
146
+ d, S = deps[bad[rng.integers(len(bad))]]
147
+ free = [c for c in S if c not in forced]
148
+ if not free:
149
+ return False
150
+ best, best_v, prev = None, None, {}
151
+ for c in free:
152
+ prev[c] = assign[c]
153
+ before = nviol
154
+ apply(c, d)
155
+ v = nviol
156
+ apply(c, prev[c])
157
+ assert nviol == before
158
+ if best_v is None or v < best_v:
159
+ best, best_v = c, v
160
+ apply(best, d)
161
+ return nviol == 0
162
+
163
+
164
+ def propose(mask, forced, empties, deps, uncovered, rng, aware=True):
165
+ """One candidate instance. `aware` first satisfies the confined
166
+ disjunctions, then fills the rest preferring not-yet-covered pairs.
167
+ Without it, every cell is an independent draw from its candidate set."""
168
+ assign = np.zeros(81, dtype=np.int8)
169
+ for c, d in forced.items():
170
+ assign[c] = d
171
+ taken = set(forced)
172
+
173
+ if aware and deps:
174
+ order = sorted(range(len(deps)), key=lambda i: len(deps[i][1]))
175
+ for i in order:
176
+ d, S = deps[i]
177
+ if any(assign[c] == d for c in S):
178
+ continue
179
+ free = [c for c in S if c not in taken]
180
+ if not free:
181
+ continue # unsatisfiable in this proposal; test catches it
182
+ c = free[rng.integers(len(free))]
183
+ assign[c] = d
184
+ taken.add(c)
185
+
186
+ for c in empties:
187
+ if c in taken:
188
+ continue
189
+ cands = digits_of(mask[c])
190
+ unc = [d for d in cands if (c, d) in uncovered]
191
+ pool = unc if unc else cands
192
+ assign[c] = pool[rng.integers(len(pool))]
193
+ return assign
194
+
195
+
196
+ def instances_for_stage(mask, max_confine=3, max_instances=64,
197
+ max_attempts=400, seed=0, aware=True, max_repair=200,
198
+ require_new=True, patience=60):
199
+ """Run the generate/test/loop for one stage.
200
+
201
+ Returns a dict with the instances and the statistics the caller wants:
202
+ how many were produced, how many were ruled out, and how much of each
203
+ candidate set the survivors cover.
204
+ """
205
+ rng = np.random.default_rng(seed)
206
+ forced, empties = split_cells(mask)
207
+ deps = dependencies_from_mask(mask, max_confine)
208
+
209
+ all_pairs = {(c, d) for c in empties for d in digits_of(mask[c])}
210
+ uncovered = set(all_pairs)
211
+
212
+ cell_deps = build_dep_index(deps)
213
+ accepted, rejected, attempts = [], 0, 0
214
+ seen = set()
215
+ if not empties:
216
+ # Fully determined stage (the last one): the single instance is the
217
+ # solution itself, which the loop below would never emit.
218
+ only = np.zeros(81, dtype=np.int8)
219
+ for c, d in forced.items():
220
+ only[c] = d
221
+ accepted.append(only)
222
+ since_new = 0
223
+ while (uncovered and attempts < max_attempts
224
+ and len(accepted) < max_instances and since_new < patience):
225
+ attempts += 1
226
+ inst = propose(mask, forced, empties, deps, uncovered, rng, aware)
227
+ if max_repair:
228
+ repair(inst, deps, forced, rng, max_repair, cell_deps)
229
+ key = inst.tobytes()
230
+ if _violations(inst, deps) != 0 or key in seen:
231
+ rejected += 1
232
+ since_new += 1
233
+ continue
234
+ seen.add(key)
235
+ gained = [(c, int(inst[c])) for c in empties
236
+ if (c, int(inst[c])) in uncovered]
237
+ if require_new and not gained:
238
+ # Valid but redundant: adds no candidate the set already lacks.
239
+ since_new += 1
240
+ continue
241
+ since_new = 0
242
+ accepted.append(inst)
243
+ for pair in gained:
244
+ uncovered.discard(pair)
245
+
246
+ covered = len(all_pairs) - len(uncovered)
247
+ widths = [len(digits_of(mask[c])) for c in empties]
248
+ # A disjunction says "digit d appears in S", never "at most once", so an
249
+ # instance may place the same digit twice in a unit. Count how often.
250
+ dups = []
251
+ for a in accepted:
252
+ n = 0
253
+ for unit in UNITS:
254
+ seen_d = {}
255
+ for c in unit:
256
+ seen_d[a[c]] = seen_d.get(a[c], 0) + 1
257
+ n += sum(v - 1 for v in seen_d.values() if v > 1)
258
+ dups.append(n)
259
+ return {
260
+ "instances": np.array(accepted, dtype=np.int8) if accepted
261
+ else np.zeros((0, 81), dtype=np.int8),
262
+ "n_instances": len(accepted),
263
+ "n_rejected": rejected,
264
+ "n_attempts": attempts,
265
+ "n_deps": len(deps),
266
+ "dep_sizes": [len(S) for _, S in deps],
267
+ "n_empty": len(empties),
268
+ "mean_width": float(np.mean(widths)) if widths else 0.0,
269
+ "n_pairs": len(all_pairs),
270
+ "n_covered": covered,
271
+ "coverage": covered / len(all_pairs) if all_pairs else 1.0,
272
+ "hit_cap": bool(uncovered),
273
+ "mean_dups": float(np.mean(dups)) if dups else 0.0,
274
+ }
code/train/data.py ADDED
@@ -0,0 +1,537 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # coding=utf-8
2
+ # Copyright 2024 The Google Research Authors.
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+
16
+ """Data loading procedure for Othello and Sudoku game.
17
+ """
18
+
19
+ import itertools
20
+ import os
21
+ import pickle
22
+
23
+ import jax
24
+ import numpy as np
25
+ import tensorflow as tf
26
+ from tensorflow.compat.v1 import gfile
27
+
28
+ import pdb
29
+
30
+
31
+ class CurriculumState:
32
+ """Mutable curriculum stage shared between the train loop and the sampler.
33
+
34
+ stage t (1..max_stage) is a REASONING-DEPTH stage: t latent slots are
35
+ active and the first t wave snapshots are supervised. Every puzzle is
36
+ available from step 0 -- difficulty is not gated.
37
+
38
+ The difficulty tag is deliberately unused as a curriculum axis. It takes
39
+ only 6 values (3..8 = hardest solver-strategy digit needed by any cell), so
40
+ it cannot express a 12-step ladder, it is uncorrelated with puzzle size
41
+ (r=-0.003 vs empty-cell count), and it explains only ~19% of the variance
42
+ in solver round count. The ladder is over propagation depth instead.
43
+ """
44
+
45
+ def __init__(self, stage=1, max_stage=12):
46
+ self.stage = stage
47
+ self.max_stage = max_stage
48
+
49
+ def unlocked_levels(self):
50
+ """All levels, always. Kept for logging/compat with per-level reports."""
51
+ return list(range(3, 9))
52
+
53
+
54
+ def compute_puzzle_levels(strategy_codes, start_index):
55
+ """Per-puzzle difficulty level = max strategy digit over solution cells.
56
+
57
+ strategy_codes: (N, 81) int64 chain codes (digits = strategy applications).
58
+ Clue cells have code 0. Level clipped to [3, 8] (rare all-lone-single
59
+ puzzles fold into level 3).
60
+ """
61
+ n = len(strategy_codes)
62
+ levels = np.zeros(n, dtype=np.int32)
63
+ chunk = 200000
64
+ for lo in range(0, n, chunk):
65
+ x = strategy_codes[lo:lo + chunk].astype(np.int64).copy()
66
+ m = np.zeros_like(x)
67
+ while x.any():
68
+ np.maximum(m, x % 10, out=m)
69
+ x //= 10
70
+ levels[lo:lo + chunk] = m.max(axis=1).astype(np.int32)
71
+ return np.clip(levels, 3, 8)
72
+
73
+
74
+ def create_dataset(config, bs, train, curriculum=None):
75
+ """Create Sudoku dataset according to the config.
76
+
77
+ Args:
78
+ config: a config object containing the hyparameters for the dataset
79
+ creation.
80
+ bs: batch size
81
+ train: whether the dataset is for train or eval
82
+
83
+ Returns:
84
+ a tf.data.Dataset object
85
+ """
86
+ ds, output_types, output_shapes = None, None, None
87
+ ds = SudokuDataset(config, train=train, curriculum=curriculum)
88
+ # Each example is (sequence with latent slots, solution, start_index,
89
+ # difficulty level, per-slot candidate-set targets, round-bin). The
90
+ # round-bin is 0 unless the round-count data curriculum is enabled.
91
+ K = int(getattr(config, "num_latent_slots", 0))
92
+ output_types = (tf.int32, tf.int32, tf.int32, tf.int32, tf.int32, tf.int32)
93
+ output_shapes = (
94
+ tf.TensorShape([config.seq_len]),
95
+ tf.TensorShape([config.block_size]),
96
+ tf.TensorShape([1]),
97
+ tf.TensorShape([1]),
98
+ tf.TensorShape([K, config.block_size]),
99
+ tf.TensorShape([1]),
100
+ )
101
+
102
+ # Create a tf.data.Dataset object from the generator.
103
+ tf_ds = tf.data.Dataset.from_generator(
104
+ generator=ds, output_types=output_types, output_shapes=output_shapes)
105
+
106
+ # Repeat the dataset indefinitely.
107
+ tf_ds = tf_ds.repeat()
108
+ # Shuffle the dataset with a buffer size of 8 * bs and a seed of 0.
109
+ tf_ds = tf_ds.shuffle(8 * config.minibatch_size, seed=0)
110
+ # Batch the dataset with a batch size of bs.
111
+ tf_ds = tf_ds.batch(bs)
112
+ return tf_ds
113
+
114
+
115
+
116
+ def prepare_tf_data(xs):
117
+ """Convert a input batch from tf Tensors to numpy arrays."""
118
+ def _prepare(x):
119
+ return x._numpy() # pylint: disable=protected-access
120
+
121
+ return jax.tree_map(_prepare, xs)
122
+
123
+
124
+ def create_iter(config, bs, train, curriculum=None):
125
+ tf_ds = create_dataset(config, bs, train=train, curriculum=curriculum)
126
+ it = map(prepare_tf_data, tf_ds)
127
+ return it
128
+
129
+ class SudokuDataset:
130
+ """Sudoku dataset."""
131
+ def __init__(self, config, train=True, curriculum=None):
132
+ self.config = config
133
+ self.train = train
134
+ self.curriculum = curriculum
135
+ self.num_latent_slots = int(getattr(config, "num_latent_slots", 0))
136
+ self.latent_token_id = int(getattr(config, "latent_token_id", 10))
137
+ self.rng = np.random.RandomState(config.seed if hasattr(config, "seed") else 0)
138
+ self.preprocess_sudoku()
139
+ self._load_candidate_masks()
140
+ self._load_round_bins()
141
+ self._load_instances()
142
+
143
+ def _load_instances(self):
144
+ """Load superposition instances: one concrete assignment per row.
145
+
146
+ Replaces the multi-hot candidate target with ordinary value tokens. For
147
+ a puzzle at stage s there are several assignments, each picking one digit
148
+ per cell from that cell's stage-s candidate set, so the candidate set is
149
+ recoverable across instances instead of being supervised as a set.
150
+
151
+ assignments: (M, 81) uint8, cell = r*9+c
152
+ starts/counts: (N, S) row range for each (puzzle, stage)
153
+ """
154
+ self.instances = None
155
+ d = getattr(self.config, "instance_dir", None)
156
+ if not d:
157
+ return
158
+ split = "train" if self.train else "test"
159
+ self.instances = np.load(
160
+ os.path.join(d, f"{split}_assignments.npy"), mmap_mode="r")
161
+ self.inst_starts = np.load(os.path.join(d, f"{split}_starts.npy"))
162
+ self.inst_counts = np.load(os.path.join(d, f"{split}_counts.npy"))
163
+ self.inst_stages = int(self.inst_starts.shape[1])
164
+ print(f"[inst] loaded {split} instances {self.instances.shape} "
165
+ f"over {self.inst_starts.shape[0]} puzzles, "
166
+ f"{self.inst_stages} stages", flush=True)
167
+
168
+ def instance_stage(self):
169
+ """Stage whose instances this example should target.
170
+
171
+ Bound to the curriculum stage so the target's ambiguity matches the
172
+ latent depth: stage t runs t latent slots and supervises the stage-(t-1)
173
+ assignments, ending at the unique solution when t == S.
174
+ """
175
+ S = self.inst_stages
176
+ if self.curriculum is None:
177
+ return S - 1
178
+ t = int(np.clip(self.curriculum.stage, 1, S))
179
+ return t - 1
180
+
181
+ def instance_values(self, idx, stage):
182
+ """One assignment for (puzzle idx, stage), sampled uniformly."""
183
+ n = int(self.inst_counts[idx, stage])
184
+ if n <= 0:
185
+ return None
186
+ row = int(self.inst_starts[idx, stage]) + self.rng.randint(n)
187
+ return np.asarray(self.instances[row])
188
+
189
+ def apply_instance(self, seq, idx, stage):
190
+ """Rewrite the value token of every triple to this instance's digit.
191
+
192
+ The (row, col) order is untouched, so the clue block and the solver-order
193
+ output sequence are exactly as before; only the values change.
194
+ """
195
+ vals = self.instance_values(idx, stage)
196
+ if vals is None:
197
+ return seq
198
+ seq = seq.copy()
199
+ cells = seq[0::3].astype(np.int64) * 9 + seq[1::3].astype(np.int64)
200
+ seq[2::3] = vals[cells].astype(seq.dtype)
201
+ return seq
202
+
203
+ def _load_round_bins(self):
204
+ """Load per-puzzle solver round counts and bin them into max_stage
205
+ equal-count bins, for the round-count DATA curriculum.
206
+
207
+ The round count (waves needed to reach the unique solution, 5..38) is
208
+ the same propagation-depth axis the latent curriculum supervises, but
209
+ used to order the *puzzles* instead of the supervision. Bin edges are
210
+ always computed on the train split and reused for eval so a bin index
211
+ means the same thing in both.
212
+ """
213
+ self.round_bins = None
214
+ self.num_bins = int(getattr(self.config, "curriculum_max_stage", 12))
215
+ if str(getattr(self.config, "data_curriculum", "none")) != "rounds":
216
+ return
217
+ tr_path = getattr(self.config, "train_meta_path", None)
218
+ path = tr_path if self.train else getattr(
219
+ self.config, "test_meta_path", None)
220
+ if not (path and tr_path):
221
+ raise ValueError(
222
+ "data_curriculum='rounds' needs SUDOKU_TRAIN_META and "
223
+ "SUDOKU_TEST_META (the *_meta.npy written by "
224
+ "staged_candidate_gen.py; column 2 is num_rounds)")
225
+ rounds = np.load(path, mmap_mode="r")[:, 2].astype(np.int32)
226
+ # Cut points from the TRAIN split, so a bin index means the same thing
227
+ # in eval. Round counts are integers with a peaked distribution (mean
228
+ # 22, sd 4), so raw quantiles collide -- the 12-bin quantiles repeat 20
229
+ # twice on the full corpus, which would leave a bin permanently empty
230
+ # and strand its stage with no frontier to measure. Force the cuts
231
+ # strictly increasing so every bin is reachable.
232
+ train_rounds = np.load(tr_path, mmap_mode="r")[:, 2].astype(np.int32)
233
+ edges = np.quantile(train_rounds, np.linspace(0, 1, self.num_bins + 1))
234
+ cuts = np.round(edges[1:-1]).astype(np.int64)
235
+ for i in range(1, len(cuts)):
236
+ if cuts[i] <= cuts[i - 1]:
237
+ cuts[i] = cuts[i - 1] + 1
238
+ # bin j (1-based) = stage that first unlocks the puzzle.
239
+ self.round_bins = np.clip(
240
+ np.searchsorted(cuts, rounds, side="right") + 1,
241
+ 1, self.num_bins).astype(np.int32)
242
+ self.bin_index = {b: np.where(self.round_bins == b)[0]
243
+ for b in range(1, self.num_bins + 1)}
244
+ counts = {b: int(len(v)) for b, v in self.bin_index.items()}
245
+ print(f"[rounds] {'train' if self.train else 'eval'} bin counts:",
246
+ counts, flush=True)
247
+ print(f"[rounds] cuts: {cuts.tolist()} (rounds "
248
+ f"{int(rounds.min())}..{int(rounds.max())})", flush=True)
249
+ empty = [b for b, c in counts.items() if c == 0]
250
+ if empty and self.train:
251
+ raise ValueError(
252
+ f"round-bin curriculum has empty train bins {empty}; those "
253
+ f"stages would have no puzzles and no frontier signal")
254
+
255
+ def _load_candidate_masks(self):
256
+ """Load staged candidate-set masks (N, S, 81) uint16, aligned by puzzle
257
+ index with the loaded .npy. Row i here == puzzle i in the base file."""
258
+ if self.train:
259
+ path = getattr(self.config, "train_cand_masks_path", None)
260
+ else:
261
+ path = getattr(self.config, "test_cand_masks_path", None)
262
+ self.cand_masks = None
263
+ self.num_stages = 0
264
+ if path:
265
+ self.cand_masks = np.load(path, mmap_mode="r")
266
+ self.num_stages = int(self.cand_masks.shape[1])
267
+ print(f"[cand] loaded {path} shape {self.cand_masks.shape}", flush=True)
268
+
269
+ def slot_budget(self, level):
270
+ """Number of latent slots this example activates (see cand_slot_mode).
271
+
272
+ "depth" mode must agree with the recurrence depth used by the train
273
+ step, since build_latent_state only ever writes slots [0, num_passes):
274
+ supervising a slot the recurrence never filled would train the head off
275
+ an all-zero latent.
276
+ """
277
+ K = self.num_latent_slots
278
+ if getattr(self.config, "cand_slot_mode", "level") == "depth":
279
+ stage = self.curriculum.stage if self.curriculum is not None \
280
+ else getattr(self.config, "curriculum_max_stage", 6)
281
+ pps = int(getattr(self.config, "passes_per_stage", 1))
282
+ return int(np.clip(pps * stage, 1, K))
283
+ return int(np.clip(level - 2, 1, K))
284
+
285
+ def _slot_stage_targets(self, idx, level, clue_cells=None):
286
+ """Return (K, 81) int32 candidate bitmasks, one per latent slot.
287
+
288
+ The S stored stages are mapped onto the example's k active slots; see
289
+ cand_slot_mode for the two mappings ("level" re-paces the whole shrink
290
+ sequence into k slots, "depth" assigns slot j to stage j). Inactive slots
291
+ (j>=k) default to the final stage; they are masked out of the loss.
292
+
293
+ Although the array is laid out over all 81 cell positions (for a fixed
294
+ batch shape), the supervised targets are only the *empty* cells: clue
295
+ cells are zeroed out here as a sentinel (a genuine empty cell always has
296
+ >=1 candidate at every stage), and the loss ignores zero rows. So the
297
+ effective target per puzzle is (#empty cells) x 9, in solver order."""
298
+ K = self.num_latent_slots
299
+ if self.cand_masks is None or K == 0:
300
+ return np.zeros((K, 81), dtype=np.int32)
301
+ S = self.num_stages
302
+ k = self.slot_budget(level)
303
+ depth_mode = getattr(self.config, "cand_slot_mode", "level") == "depth"
304
+ stages = self.cand_masks[idx].astype(np.int32) # (S, 81)
305
+ out = np.zeros((K, 81), dtype=np.int32)
306
+ for j in range(K):
307
+ if j >= k:
308
+ # Inactive slot: masked out of the loss, value is irrelevant.
309
+ s = S - 1
310
+ elif depth_mode:
311
+ # Identity: slot j holds propagation block j, so growing the
312
+ # recurrence depth extends the chain instead of re-pacing it.
313
+ # The solution is only reached at full depth, which is what
314
+ # makes this a curriculum over reasoning depth.
315
+ s = min(j, S - 1)
316
+ else:
317
+ # Active slots span the full shrink sequence: slot 0 -> stage 0
318
+ # (widest candidate set, genuinely multi-valued), last active
319
+ # slot -> final stage (solution). For k==1 the single slot maps
320
+ # to the WIDEST set (stage 0), not the solution, so even level-3
321
+ # puzzles give the candidate head a real multi-candidate target
322
+ # (the LM head still produces the unique answer).
323
+ s = int(round(j * (S - 1) / max(k - 1, 1)))
324
+ out[j] = stages[s]
325
+ # Sentinel-zero the clue cells so only the empty cells are supervised.
326
+ if clue_cells is not None and len(clue_cells) > 0:
327
+ out[:, clue_cells] = 0
328
+ return out
329
+
330
+ def _build_level_index(self, levels):
331
+ """Map difficulty level -> array of puzzle indices."""
332
+ return {lvl: np.where(levels == lvl)[0] for lvl in range(3, 9)}
333
+
334
+ def insert_latent_slots(self, seq, start_index):
335
+ """Insert K latent placeholder tokens between clues and solution.
336
+
337
+ seq: (243,) triple sequence. Returns (243 + K,) sequence:
338
+ [clues (3*si)] [K placeholders] [solution triples].
339
+ """
340
+ k = self.num_latent_slots
341
+ if k == 0:
342
+ return seq
343
+ si3 = 3 * int(start_index)
344
+ return np.concatenate([
345
+ seq[:si3],
346
+ np.full(k, self.latent_token_id, dtype=seq.dtype),
347
+ seq[si3:],
348
+ ])
349
+
350
+
351
+ def convert_to_fixed_or_random_order(self, inputs, start_index):
352
+ """Convert the sequence of moves to either a fixed or random order.
353
+
354
+ Args:
355
+ inputs: a numpy array of shape (num_puzzles, seq_len) containing the
356
+ sequence of moves for each puzzle
357
+ start_index: a numpy array of shape (num_puzzles, 1) containing the starting
358
+ index for each puzzle
359
+
360
+ Returns:
361
+ transformed_input: a numpy array of shape (num_puzzles, seq_len) containing the
362
+ sequence of moves for each puzzle in either a fixed or random order
363
+ """
364
+ transformed_input = np.zeros_like(inputs)
365
+
366
+ for i in range(len(inputs)):
367
+ cur_seq = inputs[i]
368
+ cur_start_index = start_index[i, 0]
369
+
370
+ # Split the sequence into input and output prompts
371
+ inp_prompt = cur_seq[ :(3 * cur_start_index) ].reshape(-1, 3)
372
+ out_prompt = cur_seq[ (3 * cur_start_index): ].reshape(-1, 3)
373
+
374
+ # Sort the input prompts in a fixed order
375
+ if self.config.seq_order == "fixed":
376
+ transformed_input[i, :(3 * cur_start_index) ] = inp_prompt[ np.lexsort( inp_prompt[:, ::-1].T ) ].flatten()
377
+ # Randomly shuffle the input prompts
378
+ elif self.config.seq_order == "random":
379
+ transformed_input[i, :(3 * cur_start_index) ] = np.random.permutation(inp_prompt).flatten()
380
+
381
+ # Sort the output prompts in a fixed order
382
+ if self.config.seq_order == "fixed":
383
+ transformed_input[i, (3 * cur_start_index): ] = out_prompt[ np.lexsort( out_prompt[:, ::-1].T ) ].flatten()
384
+ # Randomly shuffle the output prompts
385
+ elif self.config.seq_order == "random":
386
+ transformed_input[i, (3 * cur_start_index): ] = np.random.permutation(out_prompt).flatten()
387
+
388
+ return transformed_input
389
+
390
+ def get_puzzles_start_index(self, path):
391
+ """Get the puzzles, start index, inputs and difficulty levels.
392
+
393
+ Returns:
394
+ inputs: (num_puzzles, 243) move sequences (strategy column removed)
395
+ puzzles: (num_puzzles, 81) solutions
396
+ start_index: (num_puzzles, 1) number of clue cells
397
+ levels: (num_puzzles,) puzzle difficulty level in [3, 8]
398
+ (= hardest solver-strategy digit needed by any cell)
399
+ """
400
+ with gfile.Open(path, "rb") as f:
401
+ inputs_with_start_index = np.load(f)
402
+ start_index = inputs_with_start_index[:, 0] # Get the start index
403
+
404
+ rest = inputs_with_start_index[:, 1:]
405
+ # Strategy chain codes (4th entry of each cell quadruple); keep them to
406
+ # derive the curriculum difficulty level, then remove from the inputs.
407
+ strategy_codes = rest.reshape(len(rest), 81, 4)[:, :, 3]
408
+ levels = compute_puzzle_levels(strategy_codes, start_index)
409
+ inputs = np.delete( rest, np.arange(81) * 4 + 3, axis=1)
410
+
411
+ puzzles = np.zeros((len(inputs), 81), dtype=np.int8) # Initialize puzzles
412
+ for j in range(81):
413
+ cell_id = inputs[:, 3 * j] * 9 + inputs[:, 3 * j + 1] # Get the cell id
414
+ puzzles[np.arange(len(inputs)), cell_id] = inputs[:, 3 * j + 2] # Set the puzzle
415
+
416
+ return inputs, puzzles, start_index.reshape(-1, 1), levels
417
+
418
+
419
+ def preprocess_sudoku(self):
420
+ """Preprocess the sudoku for train and test datasets.
421
+
422
+ Depending on the `train` flag, this method loads and processes the
423
+ sudoku puzzles and their start indices from the appropriate paths, and
424
+ optionally converts them to a fixed or random order based on the
425
+ configuration.
426
+ """
427
+ if self.train is True:
428
+ # Load train puzzles, inputs, and start indices
429
+ (self.train_inputs, self.train_puzzles, self.train_start_index,
430
+ self.train_levels) = (
431
+ self.get_puzzles_start_index(self.config.train_puzzle_path)
432
+ )
433
+ # Convert train inputs to fixed or random order if specified
434
+ if self.config.seq_order in {"fixed", "random"}:
435
+ self.train_inputs = self.convert_to_fixed_or_random_order(self.train_inputs, self.train_start_index)
436
+ self.level_index = self._build_level_index(self.train_levels)
437
+ print("train level counts:",
438
+ {l: len(v) for l, v in self.level_index.items()}, flush=True)
439
+
440
+ elif self.train is False:
441
+ # Load evaluation puzzles, inputs, and start indices
442
+ (self.eval_inputs, self.eval_puzzles, self.eval_start_index,
443
+ self.eval_levels) = (
444
+ self.get_puzzles_start_index(self.config.test_puzzle_path)
445
+ )
446
+ # Convert evaluation inputs to fixed or random order if specified
447
+ if self.config.seq_order in {"fixed", "random"}:
448
+ self.eval_inputs = self.convert_to_fixed_or_random_order(self.eval_inputs, self.eval_start_index)
449
+ self.level_index = self._build_level_index(self.eval_levels)
450
+
451
+ def __len__(self):
452
+ if self.train is True:
453
+ return len(self.train_puzzles)
454
+ elif self.train is False:
455
+ return len(self.eval_puzzles)
456
+
457
+ def __getitem__(self, idx):
458
+ """Returns one example: (sequence with latent slots, solution,
459
+ start_index, difficulty level).
460
+
461
+ The base sequence is 243 tokens of (row, column, value) triples; K
462
+ latent placeholder tokens are inserted after the clue block, giving
463
+ 243 + K tokens. start_index is the number of clue cells; level in
464
+ [3, 8] is the hardest solver strategy needed by any cell.
465
+ """
466
+ if self.train is True:
467
+ inputs, puzzles = self.train_inputs, self.train_puzzles
468
+ start_index, levels = self.train_start_index, self.train_levels
469
+ else:
470
+ inputs, puzzles = self.eval_inputs, self.eval_puzzles
471
+ start_index, levels = self.eval_start_index, self.eval_levels
472
+
473
+ base = inputs[idx, :]
474
+ if self.instances is not None and self.train:
475
+ # Same input prompt, different output prompt: the clue triples are
476
+ # untouched (their instance digit is the clue) while the empty cells
477
+ # take one draw from the stage's candidate sets. Averaged over the
478
+ # instances the target IS the candidate set, so the superposition is
479
+ # learned from ordinary next-token CE instead of a set head. Eval
480
+ # keeps the unique solution: the sequence it scores is generated, and
481
+ # `puzzles` must stay the ground truth the accuracy is measured on.
482
+ base = self.apply_instance(base, idx, self.instance_stage())
483
+ seq = self.insert_latent_slots(base, start_index[idx, 0])
484
+ # Clue cells = the first `start_index` (r,c,v) triples; their cell ids
485
+ # are excluded from candidate supervision (only empty cells are scored).
486
+ si = int(start_index[idx, 0])
487
+ clue_triples = inputs[idx, :3 * si].reshape(-1, 3)
488
+ clue_cells = (clue_triples[:, 0] * 9 + clue_triples[:, 1]).astype(np.int64)
489
+ cand_targets = self._slot_stage_targets(idx, int(levels[idx]), clue_cells)
490
+ rbin = (int(self.round_bins[idx]) if self.round_bins is not None else 0)
491
+ return (
492
+ seq,
493
+ puzzles[idx, :],
494
+ start_index[idx],
495
+ np.array([levels[idx]], dtype=np.int32),
496
+ cand_targets,
497
+ np.array([rbin], dtype=np.int32),
498
+ )
499
+
500
+ def _sample_level(self):
501
+ """Uniform over levels that have puzzles. Used for level-balanced mode
502
+ and for eval, where per-level reporting needs every level represented."""
503
+ avail = [l for l in range(3, 9) if len(self.level_index[l]) > 0]
504
+ return avail[self.rng.randint(len(avail))]
505
+
506
+ def _sample_round_gated(self):
507
+ """Uniform over puzzles whose round-bin is already unlocked (bin<=stage).
508
+ Reading the stage at yield time lets the pool grow on promotion."""
509
+ stage = self.curriculum.stage if self.curriculum is not None \
510
+ else self.num_bins
511
+ stage = int(np.clip(stage, 1, self.num_bins))
512
+ pool = np.concatenate([self.bin_index[b] for b in range(1, stage + 1)])
513
+ return int(pool[self.rng.randint(len(pool))])
514
+
515
+ def __call__(self):
516
+ # Infinite generator. Train draws puzzles uniformly from the whole
517
+ # corpus (natural difficulty mix, ~68% level 3): the difficulty tag
518
+ # selects nothing, since the curriculum axis is propagation depth.
519
+ # Eval stays level-balanced so per-level accuracy is measurable and
520
+ # comparable across runs.
521
+ #
522
+ # The exception is the round-count DATA curriculum, where the train pool
523
+ # is restricted to puzzles needing at most stage-many propagation waves.
524
+ # Eval is never gated: it must score the whole corpus at every stage.
525
+ round_gated = (self.train and self.round_bins is not None)
526
+ level_balanced = (not self.train) or bool(
527
+ int(getattr(self.config, "level_balanced_sampling", 0)))
528
+ n = len(self.train_puzzles) if self.train else len(self.eval_puzzles)
529
+ while True:
530
+ if round_gated:
531
+ idx = self._sample_round_gated()
532
+ elif level_balanced:
533
+ idx_arr = self.level_index[self._sample_level()]
534
+ idx = int(idx_arr[self.rng.randint(len(idx_arr))])
535
+ else:
536
+ idx = int(self.rng.randint(n))
537
+ yield self.__getitem__(idx)
code/train/evaluater.py ADDED
@@ -0,0 +1,455 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Evaluation related functions."""
2
+
3
+ from flax.training import common_utils
4
+ import jax
5
+ from jax import numpy as jnp
6
+ import numpy as np
7
+
8
+ from train import model
9
+
10
+ import pdb
11
+
12
+
13
+
14
+ def valid_solution(output_seq):
15
+ """
16
+ This function checks if the puzzle is a valid solution by verifying if
17
+ each row, column and box has all the numbers from 1 to 9.
18
+
19
+ Args:
20
+ output_seq: a numpy array of shape (243,) containing the sequence of
21
+ output numbers
22
+
23
+ Returns:
24
+ int: 1 if correct solution, otherwise returns 0
25
+ """
26
+ # rows[i, j] keeps track if ith row has received (j + 1) number
27
+ rows = np.zeros((9, 9))
28
+ # cols[i, j] keeps track if ith column has received (j + 1) number
29
+ cols = np.zeros((9, 9))
30
+ # boxes[i, j] keeps track if ith box has received (j + 1) number
31
+ boxes = np.zeros((9, 9))
32
+
33
+ for j in range(81):
34
+ # The row and column are in the range (0, 8) and puzzle values are in (1, 9)
35
+ if int(output_seq[3 * j]) >= 9:
36
+ return False
37
+ if int(output_seq[3 * j + 1]) >= 9:
38
+ return False
39
+ if int(output_seq[3 * j + 2]) > 9:
40
+ return False
41
+
42
+ row_num = int(output_seq[3 * j])
43
+ col_num = int(output_seq[3 * j + 1])
44
+
45
+ # Mark the number in the row, column and box
46
+ rows[row_num, int(output_seq[3 * j + 2] - 1)] += 1
47
+ cols[col_num, int(output_seq[3 * j + 2] - 1)] += 1
48
+ boxes[
49
+ int(3 * (row_num // 3) + (col_num // 3)), int(output_seq[3 * j + 2] - 1)
50
+ ] += 1
51
+
52
+ if np.all(rows) and np.all(cols) and np.all(boxes):
53
+ return True
54
+ else:
55
+ return False
56
+
57
+
58
+ def eval_step(state, batch, latent_vals, slot_pos, latent_active, config):
59
+ pred_logits, hidden, cand_logits = model.TransformerLMHeadModel(config).apply(
60
+ {"params": state.params}, batch, latent_values=latent_vals,
61
+ latent_positions=slot_pos, latent_active=latent_active,
62
+ )
63
+ return pred_logits, hidden, cand_logits
64
+
65
+
66
+ def verify_sudoku_board(puzzle, row_num, col_num, num):
67
+ """
68
+ Args:
69
+ puzzle (np.array): The correct Sudoku puzzle.
70
+ row_num (int): The row number (0-8).
71
+ col_num (int): The column number (0-8).
72
+ num (int): The number predicted at the specified row and column.
73
+
74
+ Raises:
75
+ AssertionError: If the row_num * 9 + col_num >= 81 or if the number at the specified row and column is not equal to the given number.
76
+ """
77
+ if row_num * 9 + col_num >= 81:
78
+ assert False
79
+
80
+ assert puzzle[row_num * 9 + col_num] == num
81
+
82
+
83
+ def get_eval_metrics(state, eval_data_iter, p_eval_step, config):
84
+ """This function computes given evaluation metrics (e.g, accuracy) in eval metrics for each batch and appends the metric in the list of eval_metrics.
85
+
86
+ Args:
87
+ state: contains model parameters, optimizer, etc.
88
+ eval_data_iter: data iterator for evaluation dataset
89
+ p_eval_step: pmap function for forward pass of model for evaluation
90
+ config: general experiment config file
91
+
92
+ Returns:
93
+ eval_metrics: contains list of evaluation metrics for each batch
94
+ """
95
+
96
+ eval_metrics = {
97
+ "acc": [], # Value/placement acc: correct digit at the model-chosen cell
98
+ "loc_acc": [], # Location acc: model picks the ground-truth next cell (r,c)
99
+ "val_given_loc_acc": [], # Correct digit AMONG steps where location matched
100
+ "cand_bit_acc": [], # Per-digit accuracy of predicted candidate masks
101
+ "cand_set_acc": [], # Exact candidate-SET match per empty cell (all 9 bits)
102
+ "cand_set_acc_changed": [], # ...restricted to cells that changed this stage
103
+ "acc_complete_puzzle": [] # Accuracy of predicting correct complete puzzle
104
+ }
105
+ # Per-difficulty-level cell accuracy (levels 3..8). Diagnostic only: the
106
+ # curriculum no longer keys on level.
107
+ level_ok = {lvl: 0 for lvl in range(3, 9)}
108
+ level_tot = {lvl: 0 for lvl in range(3, 9)}
109
+
110
+ K = int(config.num_latent_slots)
111
+
112
+ # Per-SLOT candidate-set accuracy, i.e. per reasoning depth. Slot j holds
113
+ # wave snapshot j, so slot_ok[j]/slot_tot[j] is "how well is propagation
114
+ # block j predicted". This is the signal the depth curriculum promotes and
115
+ # backtracks on, replacing the old per-level accuracy.
116
+ slot_ok = np.zeros(max(K, 1), dtype=np.int64)
117
+ slot_tot = np.zeros(max(K, 1), dtype=np.int64)
118
+ slot_ok_ch = np.zeros(max(K, 1), dtype=np.int64)
119
+ slot_tot_ch = np.zeros(max(K, 1), dtype=np.int64)
120
+
121
+ # Per-stage in-set rate: was the emitted digit a *member* of that stage's
122
+ # candidate set? This is the promotion signal for the instance arm, where
123
+ # the target is one sampled assignment rather than the unique solution, so
124
+ # the model is right to emit any candidate. The candidate masks are read
125
+ # here as a metric only; nothing supervises them.
126
+ inset_ok = np.zeros(max(K, 1), dtype=np.int64)
127
+ inset_tot = np.zeros(max(K, 1), dtype=np.int64)
128
+
129
+ # Per-round-bin cell accuracy, for the round-count DATA curriculum: bin b is
130
+ # unlocked at stage b, so bin_ok[b]/bin_tot[b] measures competence on the
131
+ # puzzles that stage b introduced. This is the promotion signal for the arm
132
+ # that has no latent slots and therefore no per-depth signal.
133
+ n_bins = int(getattr(config, "curriculum_max_stage", 12))
134
+ bin_ok = {b: 0 for b in range(1, n_bins + 1)}
135
+ bin_tot = {b: 0 for b in range(1, n_bins + 1)}
136
+
137
+ for eval_epoch in range(config.eval_epochs):
138
+ with jax.profiler.StepTraceAnnotation("eval", step_num=eval_epoch):
139
+
140
+ batch_tuple = next(eval_data_iter)
141
+
142
+ # Input seq is (batchsize, 3*81 + K): clue triples, K latent
143
+ # placeholder slots, then solution triples.
144
+ input_seq = np.array(batch_tuple[0])
145
+
146
+ # Puzzle solution is of the shape (batchsize, 81). Each pos in {0,.., 80}
147
+ # for each puzzle contains value at cell (pos//9+1, pos%9 + 1)
148
+ puzzle_sol = np.array(batch_tuple[1])
149
+ start_index = np.array(batch_tuple[2])
150
+ levels = np.array(batch_tuple[3]).reshape(-1)
151
+ rbins = (np.array(batch_tuple[5]).reshape(-1)
152
+ if len(batch_tuple) > 5 else np.zeros_like(levels))
153
+ total_pred, sucess_pred = 0, 0
154
+ # Location = did the model emit the ground-truth next (r,c) cell.
155
+ loc_tot, loc_ok, val_given_loc_ok = 0, 0, 0
156
+
157
+ bs = input_seq.shape[0]
158
+ bidx = np.arange(bs)
159
+ si3 = 3 * start_index.reshape(-1)
160
+ slot_pos = si3[:, None] + np.arange(K)[None, :]
161
+ if getattr(config, "cand_slot_mode", "level") == "depth":
162
+ # Eval always builds all K latents, so score all K slots.
163
+ k_budget = np.full_like(levels, K)
164
+ else:
165
+ k_budget = np.clip(levels - 2, 1, K)
166
+ active_full = np.arange(K)[None, :] < k_budget[:, None]
167
+
168
+ def run_model(seq_batch, latent_vals, act, want_cand=False):
169
+ sharded = common_utils.shard(
170
+ jax.tree_util.tree_map(np.asarray, seq_batch))
171
+ # Explicit reshape so a zero-width slot dim (K=0 baseline)
172
+ # shards without the ambiguous -1 inference of shard().
173
+ _nd = jax.local_device_count()
174
+ def _shard(x):
175
+ x = np.asarray(x)
176
+ return x.reshape((_nd, x.shape[0] // _nd) + x.shape[1:])
177
+ lv = _shard(latent_vals)
178
+ lp = _shard(slot_pos)
179
+ la = _shard(act)
180
+ logits, hidden, cand = p_eval_step(state, sharded, lv, lp, la)
181
+ logits = np.array(logits).reshape(bs, *np.array(logits).shape[2:])
182
+ hidden = np.array(hidden).reshape(bs, *np.array(hidden).shape[2:])
183
+ if want_cand:
184
+ cand = np.array(cand).reshape(bs, *np.array(cand).shape[2:])
185
+ return logits, hidden, cand
186
+ return logits, hidden
187
+
188
+ # ---- Build the continuous latent thoughts (K recurrence passes,
189
+ # difficulty-matched budget; causal masking means only the clue
190
+ # region influences them). ----
191
+ latent_vals = np.zeros((bs, K, config.emb_dim), dtype=np.float32)
192
+ build_seq = np.array(input_seq)
193
+ build_seq_masked = np.array(build_seq)
194
+ # Hide the solution region during latent build (safety; causality
195
+ # already prevents leakage into slot hiddens).
196
+ for j in range(bs):
197
+ build_seq_masked[j, si3[j] + K:] = 0
198
+ # Recurrent feedback: build each latent thought from the previous
199
+ # slot's hidden. Skipped when the model does not inject latents
200
+ # (no-recurrence control): slots stay as static placeholders, so
201
+ # latent_vals is left at zeros and never used.
202
+ recurrent = bool(int(getattr(config, "recurrent_latent", 1)))
203
+ if recurrent and K > 0:
204
+ for j in range(K):
205
+ act_j = active_full & (np.arange(K)[None, :] < j)
206
+ _, hidden = run_model(build_seq_masked, latent_vals, act_j)
207
+ src = si3 - 1 + j
208
+ latent_vals[:, j] = hidden[bidx, src]
209
+
210
+ # ---- Candidate-set prediction accuracy (the multi-value target) ----
211
+ # One forward pass with the fully-built latents; read the per-slot
212
+ # candidate head and compare to the staged bitmask targets, scored
213
+ # only over active slots and empty cells (clue cells were zeroed).
214
+ # Skipped entirely for the K=0 no-latent baseline (no candidate head).
215
+ pred_bits = tgt_bits = cand_targets = None
216
+ if K > 0:
217
+ cand_targets = np.array(batch_tuple[4]).astype(np.int64) # (bs, K, 81)
218
+ # The candidate head is off in the instance arm (aux weight 0), so
219
+ # skip its forward pass and set metrics; the masks above are still
220
+ # read for the in-set rate.
221
+ if K > 0 and float(getattr(config, "aux_cand_weight", 1.0)) > 0.0:
222
+ _, _, cand_logits = run_model(
223
+ build_seq_masked, latent_vals, active_full, want_cand=True) # (bs,K,81,9)
224
+ pred_bits = (np.array(cand_logits) > 0.0) # sigmoid>0.5
225
+ tgt_bits = ((cand_targets[..., None] >> np.arange(9)) & 1).astype(bool)
226
+ valid = (cand_targets > 0) & active_full[:, :, None] # (bs,K,81)
227
+ if valid.sum() > 0:
228
+ bit_match = (pred_bits == tgt_bits) # (bs,K,81,9)
229
+ eval_metrics["cand_bit_acc"].append(
230
+ float(bit_match[valid].mean()))
231
+ eval_metrics["cand_set_acc"].append(
232
+ float(bit_match.all(axis=3)[valid].mean()))
233
+ # Same score restricted to cells whose candidate set
234
+ # actually changed from the previous stage. The unrestricted
235
+ # metrics above are dominated by cells that are unchanged
236
+ # copies of slot j-1, so they stay high for a head that has
237
+ # learned nothing but "repeat the previous slot".
238
+ changed = np.concatenate(
239
+ [np.ones_like(cand_targets[:, :1], dtype=bool),
240
+ cand_targets[:, 1:] != cand_targets[:, :-1]], axis=1)
241
+ valid_ch = valid & changed
242
+ if valid_ch.sum() > 0:
243
+ eval_metrics["cand_set_acc_changed"].append(
244
+ float(bit_match.all(axis=3)[valid_ch].mean()))
245
+ # Accumulate the same score split by slot (= depth).
246
+ set_match = bit_match.all(axis=3) # (bs,K,81)
247
+ slot_ok += (set_match & valid).sum(axis=(0, 2))
248
+ slot_tot += valid.sum(axis=(0, 2))
249
+ slot_ok_ch += (set_match & valid_ch).sum(axis=(0, 2))
250
+ slot_tot_ch += valid_ch.sum(axis=(0, 2))
251
+
252
+ min_start_index = int(np.min(start_index))
253
+ cur_input_seq = input_seq[:, :(min_start_index*3)]
254
+ for i in range(min_start_index * 3, config.seq_len):
255
+ ### In i^th iteration, i^th number in sequence will predict
256
+ padding = np.zeros((input_seq.shape[0],
257
+ config.seq_len - len(cur_input_seq[0])),
258
+ dtype=np.int32)
259
+ concat_batch = np.hstack((cur_input_seq, padding))
260
+
261
+ pred_logits, _ = run_model(concat_batch, latent_vals, active_full)
262
+
263
+ # Positions < 3*start_index + K are given (clues + latent
264
+ # slots); the model predicts from there on. K is a multiple
265
+ # of 3, so the triple phase of i is unchanged.
266
+ if i%3 == 2:
267
+ # Model predicts the value at the cell (cur_input_seq[j][i-2],
268
+ # cur_input_seq[j][i-1])
269
+ max_number = pred_logits[:, i-1, :].argmax(axis=-1).flatten()
270
+ mask_arr = np.array(i >= (3 * start_index + K)).squeeze()
271
+
272
+ next_number = max_number * mask_arr + (1 - mask_arr) * input_seq[:, i]
273
+
274
+ cur_input_seq = np.hstack(
275
+ (cur_input_seq, np.reshape(next_number, (-1, 1)))
276
+ )
277
+
278
+ # Iterate through all examples in batch and calculate successful
279
+ # predictions of numbers
280
+ for j in range(len(cur_input_seq)):
281
+ if not mask_arr[j]:
282
+ continue
283
+
284
+ total_pred += 1
285
+ level_tot[int(levels[j])] += 1
286
+ if int(rbins[j]) in bin_tot:
287
+ bin_tot[int(rbins[j])] += 1
288
+
289
+ # Location accuracy: did the model emit the ground-truth
290
+ # next cell (r,c) for this solver-order step?
291
+ loc_tot += 1
292
+ loc_match = (int(cur_input_seq[j][i-2]) == int(input_seq[j, i-2])
293
+ and int(cur_input_seq[j][i-1]) == int(input_seq[j, i-1]))
294
+ if loc_match:
295
+ loc_ok += 1
296
+
297
+ # In-set rate per stage, scored at the ground-truth cell
298
+ # so a wrong location cannot make a digit vacuously
299
+ # legal. cand_targets[j, s, cell] is stage s's bitmask
300
+ # under cand_slot_mode="depth" (slot s <-> stage s).
301
+ if cand_targets is not None and loc_match:
302
+ cell = (int(input_seq[j, i-2]) * 9
303
+ + int(input_seq[j, i-1]))
304
+ v = int(cur_input_seq[j][i])
305
+ for s in range(K):
306
+ bits = int(cand_targets[j, s, cell])
307
+ if bits <= 0: # clue cell, not supervised
308
+ continue
309
+ inset_tot[s] += 1
310
+ if 1 <= v <= 9 and (bits >> (v - 1)) & 1:
311
+ inset_ok[s] += 1
312
+
313
+ try:
314
+ verify_sudoku_board(puzzle_sol[j], cur_input_seq[j][i-2],
315
+ cur_input_seq[j][i-1], cur_input_seq[j][i])
316
+ except AssertionError:
317
+ # Mistake
318
+ pass
319
+ else:
320
+ sucess_pred += 1
321
+ level_ok[int(levels[j])] += 1
322
+ if int(rbins[j]) in bin_ok:
323
+ bin_ok[int(rbins[j])] += 1
324
+ if loc_match:
325
+ val_given_loc_ok += 1
326
+ else:
327
+ # Model predicts either a row number or column number
328
+ max_pos = pred_logits[:, i-1, :].argmax(axis=-1).flatten()
329
+ mask = (i >= (3 * start_index + K)).squeeze()
330
+ next_pos = max_pos * mask + (1 - mask) * input_seq[:, i]
331
+
332
+ # pdb.set_trace()
333
+ cur_input_seq = np.hstack(
334
+ (cur_input_seq, np.reshape(next_pos, (-1, 1)))
335
+ )
336
+
337
+ eval_metrics["acc"].append(sucess_pred * 1.0/ total_pred)
338
+ eval_metrics["loc_acc"].append(loc_ok * 1.0 / max(loc_tot, 1))
339
+ eval_metrics["val_given_loc_acc"].append(
340
+ val_given_loc_ok * 1.0 / max(loc_ok, 1))
341
+
342
+ def strip_latent_slots(seq, si):
343
+ return np.concatenate([seq[:3*si], seq[3*si + K:]])
344
+
345
+ # ---- Print one concrete example answer the model generated ----
346
+ if eval_epoch == 0:
347
+ j = 0
348
+ si = int(start_index[j, 0])
349
+ pred = strip_latent_slots(cur_input_seq[j], si)
350
+ shown, n_ok, n_tot = [], 0, 0
351
+ for k in range(si, 81):
352
+ r, c, v = int(pred[3*k]), int(pred[3*k+1]), int(pred[3*k+2])
353
+ tv = int(puzzle_sol[j][r*9+c]) if (0 <= r < 9 and 0 <= c < 9) else -1
354
+ ok = (0 <= r < 9 and 0 <= c < 9 and v == tv)
355
+ n_tot += 1; n_ok += int(ok)
356
+ if len(shown) < 12:
357
+ shown.append(f"({r},{c})->{v}[true {tv}]{'ok' if ok else 'X'}")
358
+ print(f"EXAMPLE (level={int(levels[j])}, k={int(k_budget[j])}): "
359
+ f"model emitted {n_tot} (r,c)->v triples for the empty cells "
360
+ f"(format: (row,col)->value[true T]); first 12:", flush=True)
361
+ print(" ", " ".join(shown), flush=True)
362
+ print(f"EXAMPLE cells-correct={n_ok}/{n_tot} "
363
+ f"valid_full_grid={valid_solution(pred)}", flush=True)
364
+
365
+ # Instance arm: emitted digit next to the deepest stage's
366
+ # candidate set, so it is visible whether the model is sitting
367
+ # inside the superposition or outside it.
368
+ if K > 0 and cand_targets is not None and pred_bits is None:
369
+ tgt = strip_latent_slots(input_seq[j], si)
370
+ shown = []
371
+ for t3 in range(si, min(si + 8, 81)):
372
+ r, c = int(tgt[3*t3]), int(tgt[3*t3+1])
373
+ bits = int(cand_targets[j, K-1, r*9+c])
374
+ cset = "".join(str(d+1) for d in range(9)
375
+ if (bits >> d) & 1)
376
+ shown.append(f"(r{r},c{c})->{int(pred[3*t3+2])} "
377
+ f"in{{{cset}}}")
378
+ print(f"EXAMPLE emitted vs stage-{K} candidate set:",
379
+ " ".join(shown), flush=True)
380
+
381
+ # ---- Candidate-set (multi-value) prediction for this puzzle ----
382
+ # Show, at the last active latent slot, predicted vs target
383
+ # candidate SETS for the first few empty cells. (No latent
384
+ # slots in the K=0 baseline, so nothing to show.)
385
+ if K > 0 and pred_bits is not None:
386
+ kj = int(k_budget[j]) - 1
387
+ def _digs(bitrow):
388
+ return "".join(str(d + 1) for d in range(9) if bitrow[d])
389
+ cand_shown = []
390
+ for cell in range(81):
391
+ if cand_targets[j, kj, cell] <= 0: # clue / not supervised
392
+ continue
393
+ r, c = cell // 9, cell % 9
394
+ pset = _digs(pred_bits[j, kj, cell])
395
+ tset = _digs(tgt_bits[j, kj, cell])
396
+ cand_shown.append(f"(r{r},c{c}) pred{{{pset}}} true{{{tset}}}")
397
+ if len(cand_shown) >= 8:
398
+ break
399
+ print(f"EXAMPLE candidate-set @slot{kj} (pred vs true):",
400
+ " ".join(cand_shown), flush=True)
401
+
402
+ correct_eval_sudoku_puzzle = 0
403
+
404
+ for i in range(len(cur_input_seq)):
405
+
406
+ # increase correct_eval_sudoku_puzzle when the model output solution
407
+ # for a given puzzle is correct
408
+ stripped = strip_latent_slots(cur_input_seq[i], int(start_index[i, 0]))
409
+ correct_eval_sudoku_puzzle += valid_solution(stripped)
410
+
411
+ eval_metrics["acc_complete_puzzle"].append(
412
+ correct_eval_sudoku_puzzle * 1.0 / len(cur_input_seq)
413
+ )
414
+
415
+ per_level = {lvl: (level_ok[lvl] / level_tot[lvl] if level_tot[lvl] else -1.0)
416
+ for lvl in range(3, 9)}
417
+ eval_metrics["per_level_acc"] = per_level
418
+ print("PER-LEVEL cell acc:",
419
+ {lvl: (f"{v:.3f}" if v >= 0 else "n/a") for lvl, v in per_level.items()},
420
+ flush=True)
421
+
422
+ # Per-depth candidate-set accuracy, keyed by stage (slot j -> stage j+1) so
423
+ # the curriculum controller can index it directly by stage number.
424
+ per_slot = {j + 1: (float(slot_ok[j] / slot_tot[j]) if slot_tot[j] else -1.0)
425
+ for j in range(K)}
426
+ per_slot_ch = {j + 1: (float(slot_ok_ch[j] / slot_tot_ch[j])
427
+ if slot_tot_ch[j] else -1.0) for j in range(K)}
428
+ eval_metrics["per_slot_acc"] = per_slot
429
+ eval_metrics["per_slot_acc_changed"] = per_slot_ch
430
+
431
+ # Keyed by stage (slot s -> stage s+1) to match per_slot_acc.
432
+ per_stage_inset = {s + 1: (float(inset_ok[s] / inset_tot[s])
433
+ if inset_tot[s] else -1.0) for s in range(K)}
434
+ eval_metrics["per_stage_inset_acc"] = per_stage_inset
435
+ if K > 0 and any(v >= 0 for v in per_stage_inset.values()):
436
+ print("PER-STAGE in-set rate (emitted digit is a stage-s candidate):",
437
+ {s: (f"{v:.3f}" if v >= 0 else "n/a")
438
+ for s, v in per_stage_inset.items()}, flush=True)
439
+
440
+ per_bin = {b: (bin_ok[b] / bin_tot[b] if bin_tot[b] else -1.0)
441
+ for b in range(1, n_bins + 1)}
442
+ eval_metrics["per_bin_acc"] = per_bin
443
+ if any(v >= 0 for v in per_bin.values()):
444
+ print("PER-ROUND-BIN cell acc:",
445
+ {b: (f"{v:.3f}" if v >= 0 else "n/a") for b, v in per_bin.items()},
446
+ flush=True)
447
+ if K > 0:
448
+ print("PER-DEPTH cand-set acc:",
449
+ {s: (f"{v:.3f}" if v >= 0 else "n/a") for s, v in per_slot.items()},
450
+ flush=True)
451
+ print("PER-DEPTH cand-set acc (changed cells only):",
452
+ {s: (f"{v:.3f}" if v >= 0 else "n/a")
453
+ for s, v in per_slot_ch.items()}, flush=True)
454
+
455
+ return eval_metrics
code/train/main.py ADDED
@@ -0,0 +1,320 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # coding=utf-8
2
+ # Copyright 2024 The Google Research Authors.
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+
16
+ """Main file for Sudoku GPT experiments."""
17
+
18
+ import os
19
+ import sys
20
+
21
+ from absl import app
22
+ from absl import flags
23
+ from absl import logging
24
+
25
+ from clu import platform
26
+
27
+ import jax
28
+ import tensorflow as tf
29
+ import wandb
30
+
31
+ import ml_collections
32
+ from ml_collections import config_flags
33
+
34
+ from train import train_and_evaluate
35
+ from train import train_backtrack
36
+
37
+ import pdb
38
+
39
+
40
+
41
+ sys.dont_write_bytecode = True
42
+
43
+ logging.set_verbosity(logging.INFO)
44
+
45
+ FLAGS = flags.FLAGS
46
+
47
+ _WORKDIR = flags.DEFINE_string(
48
+ 'workdir',
49
+ None,
50
+ 'Directory to store model data.')
51
+ _EXP_NAME = flags.DEFINE_string(
52
+ 'exp_name',
53
+ None,
54
+ 'Experiment name.')
55
+ _CKPT_LOC = flags.DEFINE_string(
56
+ 'ckpt_loc',
57
+ None,
58
+ 'Directory to restore model.')
59
+
60
+ config_flags.DEFINE_config_file(
61
+ 'config',
62
+ None,
63
+ 'File path to the training hyperparameter configuration.',
64
+ lock_config=True)
65
+ flags.mark_flags_as_required(['workdir', 'exp_name'])
66
+
67
+
68
+ def get_config():
69
+ """Get the default hyperparameter configuration.
70
+
71
+ Returns:
72
+ A ConfigDict object.
73
+ """
74
+
75
+ # Common configuration for all experiments.
76
+ config = ml_collections.ConfigDict()
77
+
78
+ # Dataset choice
79
+ config.dataset = 'sudoku'
80
+
81
+ # Sequence order
82
+ config.seq_order = "solver-order" ## Choices = ["fixed", "solver-order", "random"]
83
+
84
+ # Training related parameters
85
+ config.max_steps = int(os.environ.get("SUDOKU_MAX_STEPS", 100000))
86
+ config.dtype = jax.numpy.bfloat16
87
+ config.minibatch_size = int(os.environ.get("SUDOKU_MINIBATCH", 64))
88
+
89
+ # Continuous latent thoughts (ATC / Coconut style) + curriculum.
90
+ # Set SUDOKU_LATENT_SLOTS=0 for the no-latent control baseline (plain
91
+ # transformer + difficulty curriculum, no recurrence, no candidate head).
92
+ config.num_latent_slots = int(os.environ.get("SUDOKU_LATENT_SLOTS", 6))
93
+ config.latent_token_id = 10 # placeholder id for inactive slots
94
+ # Recurrent latent feedback. 1 = full ATC/Coconut recurrence (slot k built
95
+ # from slot k-1's hidden). 0 = NO recurrence: the K stage slots + shared
96
+ # candidate head + curriculum + BCE supervision are all kept, but the latent
97
+ # hidden state is never fed back, so each stage is an independent parallel
98
+ # readout with no carried state (the "stagewise, no-recurrence" control).
99
+ config.recurrent_latent = int(os.environ.get("SUDOKU_RECURRENT", 1))
100
+ # Backtracking (stage-replay) training: when 1, use the train_backtrack loop
101
+ # which interleaves frontier-stage batches with replays of earlier stages at
102
+ # their MATCHED recurrence depth (num_passes=t for stage t) to mitigate
103
+ # forgetting. backtrack_prob = fraction of steps that are replay steps.
104
+ config.backtrack = int(os.environ.get("SUDOKU_BACKTRACK", 0))
105
+ # Replay strategy: "prob" = fixed-probability replay of a random earlier stage
106
+ # (backtrack_prob). "adaptive" = deficit-driven controller that only replays a
107
+ # stage once its held-out accuracy has regressed below its graduation value by
108
+ # backtrack_margin, repairs the most-deficient stage first with a FULL
109
+ # frontier-style step, and returns once it recovers or hits the step cap.
110
+ config.backtrack_mode = os.environ.get("SUDOKU_BACKTRACK_MODE", "prob")
111
+ config.backtrack_prob = float(os.environ.get("SUDOKU_BACKTRACK_PROB", 0.5))
112
+ config.backtrack_margin = float(os.environ.get("SUDOKU_BACKTRACK_MARGIN", 0.03))
113
+ config.backtrack_max_repair_steps = int(
114
+ os.environ.get("SUDOKU_BACKTRACK_MAX_REPAIR_STEPS", 6000))
115
+ # After leaving a repair episode, require this many frontier steps before
116
+ # another repair can trigger (prevents thrashing / starves-the-frontier).
117
+ config.backtrack_min_frontier_steps = int(
118
+ os.environ.get("SUDOKU_BACKTRACK_MIN_FRONTIER_STEPS", 0))
119
+ # While the frontier stage's own level accuracy is below this target, use a
120
+ # stricter (larger) repair trigger so stage-6 training is prioritized until
121
+ # it is itself "good". 0 disables.
122
+ config.backtrack_frontier_target_acc = float(
123
+ os.environ.get("SUDOKU_BACKTRACK_FRONTIER_TARGET_ACC", 0.0))
124
+ # Optional explicit graduation refs for inherited stages when resuming at
125
+ # stage > 1. Comma-separated floats for stages 1..start-1, e.g.
126
+ # "0.462,0.487,0.534,0.597,0.636". Empty = seed from first eval (old behavior).
127
+ config.backtrack_grad_acc_seed = os.environ.get("SUDOKU_GRAD_ACC_SEED", "")
128
+ # Max fraction of steps (since resume) that may be spent in repair mode.
129
+ # 0 disables the cap. Typical fair setting: 0.25.
130
+ config.backtrack_max_repair_fraction = float(
131
+ os.environ.get("SUDOKU_BACKTRACK_MAX_REPAIR_FRACTION", 0.0))
132
+ # Soft graduation refs: each eval, if current < grad, decay
133
+ # grad <- (1-d)*grad + d*current
134
+ # so chronic mild regression is forgiven; sharp drops still trigger.
135
+ # 0 disables (hard bars). Typical: 0.05–0.1.
136
+ config.backtrack_grad_decay = float(
137
+ os.environ.get("SUDOKU_BACKTRACK_GRAD_DECAY", 0.0))
138
+ # After this absolute step, force frontier-only (no new repairs).
139
+ # 0 disables. Use for "BT early, then freeze".
140
+ config.backtrack_freeze_after_step = int(
141
+ os.environ.get("SUDOKU_BACKTRACK_FREEZE_AFTER", 0))
142
+ # Frontier steps sample uniformly over ALL unlocked levels (standard-loop
143
+ # data mix) instead of only the frontier level. 1 = fair "standard+repairs"
144
+ # recipe (default); 0 = legacy frontier-level-only behavior.
145
+ config.backtrack_frontier_mix = int(
146
+ os.environ.get("SUDOKU_BACKTRACK_FRONTIER_MIX", 1))
147
+ config.curriculum_start_stage = int(os.environ.get("SUDOKU_START_STAGE", 1))
148
+ # Stage s = reasoning DEPTH s: s latent slots active, wave snapshots 1..s
149
+ # supervised. Difficulty is NOT gated -- every puzzle is available from
150
+ # step 0. Cap max_stage (e.g. =start_stage) to hold a single depth.
151
+ config.curriculum_max_stage = int(os.environ.get("SUDOKU_MAX_STAGE", 12))
152
+ # Promotion is gated on the candidate-set accuracy of the DEEPEST active
153
+ # slot, restricted to cells that changed from the previous snapshot.
154
+ config.promote_acc_threshold = float(os.environ.get("SUDOKU_PROMOTE_ACC", 0.85))
155
+ # Instance arm: inset is only scored on location-correct cells. Without a
156
+ # location floor, a 0.90 inset on 2% loc_acc would promote from a handful
157
+ # of cells. Stage-0 chance inset is ~3.7/9 ≈ 0.41, so 0.85 is the set bar
158
+ # and this is the "model actually emits the solver-order cells" bar.
159
+ config.promote_loc_threshold = float(os.environ.get("SUDOKU_PROMOTE_LOC", 0.70))
160
+ config.promote_patience_steps = int(os.environ.get("SUDOKU_PATIENCE", 8000))
161
+ config.min_stage_steps = int(os.environ.get("SUDOKU_MIN_STAGE_STEPS", 2000))
162
+ # Plateau promotion: advance once the frontier depth stops improving, rather
163
+ # than on a fixed timer. A stage is "done" when it has not gained
164
+ # plateau_delta over its best accuracy for plateau_steps steps. This is the
165
+ # primary rule; the accuracy threshold is a fast path for mastery and
166
+ # promote_patience_steps is a hard cap so a stuck stage cannot stall
167
+ # training forever. Set plateau_steps=0 to disable.
168
+ config.plateau_steps = int(os.environ.get("SUDOKU_PLATEAU_STEPS", 20000))
169
+ config.plateau_delta = float(os.environ.get("SUDOKU_PLATEAU_DELTA", 0.005))
170
+ # Train-time difficulty balancing. 0 (default) = draw puzzles uniformly from
171
+ # the corpus, so the difficulty tag selects nothing. 1 = uniform over the 6
172
+ # levels, which upsamples level 8 from 1.8% to 16.7% of batches. Eval is
173
+ # always level-balanced so per-level accuracy stays measurable.
174
+ config.level_balanced_sampling = int(
175
+ os.environ.get("SUDOKU_LEVEL_BALANCED", 0))
176
+ # Data curriculum over the puzzle POOL (as opposed to the latent-depth
177
+ # curriculum over the supervision).
178
+ # "none" = every puzzle available from step 0.
179
+ # "rounds" = stage t admits only puzzles whose solver round count falls in
180
+ # the first t of max_stage equal-count bins (rounds span 5..38,
181
+ # so 12 bins give a genuinely smooth 12-step ladder). This is
182
+ # the same propagation-depth axis the latent arm supervises,
183
+ # which makes the two arms directly comparable. Needs
184
+ # SUDOKU_TRAIN_META / SUDOKU_TEST_META.
185
+ config.data_curriculum = os.environ.get("SUDOKU_DATA_CURRICULUM", "none")
186
+ config.train_meta_path = os.environ.get("SUDOKU_TRAIN_META", "") or None
187
+ config.test_meta_path = os.environ.get("SUDOKU_TEST_META", "") or None
188
+
189
+ # Model related parameters
190
+ config.block_size = 81
191
+ config.seq_len = 3 * config.block_size + config.num_latent_slots
192
+ config.vocab_size = 11
193
+
194
+ # Model architecture
195
+ config.num_heads = 8
196
+ config.num_layers = 8
197
+ config.emb_dim = 576
198
+ config.qkv_dim = 576
199
+ config.mlp_dim = 6 * config.emb_dim
200
+ config.dropout_rate = float(os.environ.get("SUDOKU_DROPOUT", 0.2))
201
+ config.attention_dropout_rate = float(
202
+ os.environ.get("SUDOKU_ATTN_DROPOUT",
203
+ os.environ.get("SUDOKU_DROPOUT", 0.2)))
204
+
205
+ # Training hyperparameters
206
+ config.learning_rate = float(os.environ.get("SUDOKU_LR", 0.0002)) # Base learning rate.
207
+ config.end_lr_factor = float(os.environ.get("SUDOKU_END_LR_FACTOR", 0.2))
208
+ config.warmup_tokens = int(os.environ.get("SUDOKU_WARMUP", 10000))
209
+ config.weight_decay = float(os.environ.get("SUDOKU_WD", 0.005))
210
+ # Resume from a checkpoint (set SUDOKU_RESUME=1 and pass --ckpt_loc=<path>).
211
+ config.resume_training = os.environ.get("SUDOKU_RESUME", "0") == "1"
212
+
213
+ # Other hyperparameters
214
+ config.seed = 7
215
+ config.save_checkpoint = os.environ.get("SUDOKU_SAVE_CKPT", "1") == "1"
216
+ config.save_every_steps = int(os.environ.get("SUDOKU_SAVE_EVERY", 10000))
217
+ # How many checkpoints to retain. Large default so per-stage checkpoints are
218
+ # never rolled off (disk is plentiful; ~0.5GB each).
219
+ config.ckpt_keep = int(os.environ.get("SUDOKU_CKPT_KEEP", 100))
220
+ config.use_wandb = False
221
+ config.wandb_project_name = 'sudoku'
222
+
223
+ # Evaluation related parameters
224
+ config.eval_every_steps = int(os.environ.get("SUDOKU_EVAL_EVERY", 2000))
225
+ config.eval_epochs = int(os.environ.get("SUDOKU_EVAL_EPOCHS", 5))
226
+
227
+ # Path to dataset
228
+ config.train_puzzle_path = os.environ.get(
229
+ "SUDOKU_TRAIN_PATH", "datasets/train_sudoku_puzzles.npy")
230
+ config.train_candidate_path = "datasets/train_sudoku_puzzles_candidate.npy"
231
+ config.test_puzzle_path = os.environ.get(
232
+ "SUDOKU_TEST_PATH", "datasets/test_sudoku_puzzles.npy")
233
+ config.test_candidate_path = "datasets/test_sudoku_puzzles_candidate.npy"
234
+
235
+ # Staged multi-candidate supervision (per-latent-slot BCE targets).
236
+ # Empty string disables cand-mask loading (useful for K=0 baselines).
237
+ config.train_cand_masks_path = os.environ.get(
238
+ "SUDOKU_TRAIN_CAND", "datasets_multicandidate/train_cand_masks.npy") or None
239
+ config.test_cand_masks_path = os.environ.get(
240
+ "SUDOKU_TEST_CAND", "datasets_multicandidate/test_cand_masks.npy") or None
241
+ # Superposition-instance targets. When set, the output prompt's value tokens
242
+ # come from one sampled stage-k assignment instead of the unique solution:
243
+ # the input prompt (clues) is fixed and the same puzzle recurs with different
244
+ # legal completions, so the candidate set is represented across the batch
245
+ # rather than supervised as a multi-hot set. Setting this should go with
246
+ # SUDOKU_AUX_WEIGHT=0 (candidate head off) -- the masks are then read only
247
+ # for the in-set metric. Empty string = classic single-solution targets.
248
+ config.instance_dir = os.environ.get("SUDOKU_INSTANCE_DIR", "") or None
249
+ # Weight of the auxiliary candidate-set BCE loss relative to the LM CE loss.
250
+ config.aux_cand_weight = float(os.environ.get("SUDOKU_AUX_WEIGHT", 1.0))
251
+ # Positive-class weight inside the candidate BCE (counters the sparsity of
252
+ # the multi-hot masks so the head doesn't collapse to predicting all-zeros).
253
+ config.aux_pos_weight = float(os.environ.get("SUDOKU_CAND_POS_WEIGHT", 5.0))
254
+
255
+ # How many latent slots each example activates.
256
+ # "level" = k = clip(level-2, 1, K). Difficulty-matched, but puzzle level
257
+ # explains only ~19% of the variance in solver round count, so
258
+ # most examples leave the majority of the K slots inert (a
259
+ # level-3 puzzle activates ONE slot for ~21 rounds of work).
260
+ # "depth" = k = num_passes, uniform over the batch. Every slot the
261
+ # recurrence actually fills is active and supervised, and the
262
+ # curriculum advances reasoning depth rather than puzzle level.
263
+ config.cand_slot_mode = os.environ.get("SUDOKU_CAND_SLOT_MODE", "depth")
264
+ # Latent passes granted per curriculum stage: num_passes = min(pps*stage, K).
265
+ # pps=2 with K=12 reaches all 12 slots by stage 6.
266
+ config.passes_per_stage = int(os.environ.get("SUDOKU_PASSES_PER_STAGE", 1))
267
+ # Loss weight for candidate cells that did NOT change from the previous
268
+ # stage. Consecutive stages are highly redundant (at 12 stages ~97% of the
269
+ # target bits are copies of the previous slot), so plain BCE is dominated by
270
+ # echoing the previous slot. <1.0 down-weights the copied cells and puts the
271
+ # gradient on the digits actually eliminated at this stage. 1.0 = off.
272
+ config.aux_delta_bg = float(os.environ.get("SUDOKU_CAND_DELTA_BG", 1.0))
273
+
274
+ return config
275
+
276
+
277
+ def main(argv):
278
+ if len(argv) > 1:
279
+ raise app.UsageError('Too many command-line arguments.')
280
+
281
+ # # Hide any GPUs from TensorFlow. Otherwise TF might reserve memory and make
282
+ # # it unavailable to JAX.
283
+ tf.config.experimental.set_visible_devices([], 'GPU')
284
+
285
+ cfgs = get_config()
286
+ if cfgs.resume_training:
287
+ assert _CKPT_LOC.value is not None
288
+
289
+ if cfgs.use_wandb:
290
+ wandb.init(project=cfgs.wandb_project_name, name=_EXP_NAME.value, config=cfgs)
291
+
292
+ logging.info('JAX process: %d / %d', jax.process_index(), jax.process_count())
293
+ logging.info('JAX local devices: %r', jax.local_devices())
294
+
295
+ # Add a note so that we can tell which task is which JAX host.
296
+ # (Depending on the platform task 0 is not guaranteed to be host 0)
297
+ platform.work_unit().set_task_status(f'process_index: {jax.process_index()}, '
298
+ f'process_count: {jax.process_count()}')
299
+ platform.work_unit().create_artifact(platform.ArtifactType.DIRECTORY,
300
+ _WORKDIR.value, 'workdir')
301
+
302
+ logging.info(cfgs)
303
+
304
+ cfgs.workdir = _WORKDIR.value
305
+ cfgs.ckpt_loc = _CKPT_LOC.value
306
+ if int(getattr(cfgs, "backtrack", 0)):
307
+ if str(getattr(cfgs, "backtrack_mode", "prob")) == "adaptive":
308
+ train_backtrack.train_and_evaluate_backtrack_adaptive(cfgs, _WORKDIR.value)
309
+ else:
310
+ train_backtrack.train_and_evaluate_backtrack(cfgs, _WORKDIR.value)
311
+ else:
312
+ train_and_evaluate.train_and_evaluate(cfgs, _WORKDIR.value)
313
+
314
+ if cfgs.use_wandb:
315
+ wandb.finish()
316
+
317
+
318
+ if __name__ == '__main__':
319
+ jax.config.config_with_absl()
320
+ app.run(main)
code/train/model.py ADDED
@@ -0,0 +1,232 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # coding=utf-8
2
+ # Copyright 2024 The Google Research Authors.
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+
16
+ """Model Architecture."""
17
+
18
+ import functools
19
+ from typing import Any, Callable
20
+
21
+ from flax import linen as nn
22
+ from flax import struct
23
+ from jax import numpy as jnp
24
+
25
+ @struct.dataclass
26
+ class TransformerConfig:
27
+ """Global hyperparameters used to minimize obnoxious kwarg plumbing."""
28
+ vocab_size: int = 1
29
+ dtype: Any = jnp.float32
30
+ emb_dim: int = 512
31
+ num_heads: int = 8
32
+ num_layers: int = 6
33
+ qkv_dim: int = 512
34
+ mlp_dim: int = 2048
35
+ seq_len: int = 2048 # Maximum sequence length
36
+ dropout_rate: float = 0.1
37
+ attention_dropout_rate: float = 0.1
38
+ deterministic: bool = False
39
+ num_latent_slots: int = 0 # K continuous latent thought slots
40
+ # When False, the fed-back latent thought vectors are NOT injected into the
41
+ # slot positions (they keep their placeholder token embedding). This turns
42
+ # the K slots into static, parallel per-stage readouts with NO carried
43
+ # recurrent state -- the "stagewise supervision, no recurrence" control.
44
+ inject_latents: bool = True
45
+
46
+
47
+ class TransformerBlock(nn.Module):
48
+ config: Any = None
49
+
50
+ def setup(self):
51
+ self.vocab_size = self.config.vocab_size
52
+ self.emb_dim = self.config.emb_dim
53
+ self.num_layers = self.config.num_layers
54
+
55
+ @nn.compact
56
+ def __call__(self, inputs, causal_mask_inputs, training=True):
57
+ """
58
+ Transformer Block call function.
59
+
60
+ Args:
61
+ inputs: Input tensor.
62
+ causal_mask_inputs: Causal mask for the inputs.
63
+ training: Whether the model is in training mode.
64
+
65
+ Returns:
66
+ Transformed tensor after self-attention and MLP layers.
67
+ """
68
+
69
+ x = inputs + nn.SelfAttention(
70
+ num_heads=self.config.num_heads, dtype=self.config.dtype,
71
+ qkv_features=self.config.qkv_dim,
72
+ kernel_init=nn.initializers.xavier_uniform(),
73
+ bias_init=nn.initializers.normal(stddev=1e-6),
74
+ use_bias=False, broadcast_dropout=False,
75
+ dropout_rate=self.config.attention_dropout_rate, normalize_qk=True,
76
+ deterministic=self.config.deterministic)(inputs, causal_mask_inputs)
77
+
78
+ def mlp(x):
79
+ """
80
+ Multi-Layer Perceptron function.
81
+
82
+ Args:
83
+ x: Input tensor.
84
+
85
+ Returns:
86
+ Transformed tensor after applying MLP layers.
87
+ """
88
+ dense_with_init = functools.partial(
89
+ nn.Dense,
90
+ kernel_init=nn.initializers.xavier_uniform(),
91
+ bias_init=nn.initializers.normal(stddev=1e-6)
92
+ )
93
+ x = dense_with_init(features=self.config.mlp_dim)(x)
94
+ x = nn.gelu(x)
95
+ x = dense_with_init(features=self.config.emb_dim)(x)
96
+ x = nn.Dropout(rate=self.config.dropout_rate,
97
+ deterministic=self.config.deterministic)(x)
98
+ return x
99
+
100
+ x = x + mlp(x)
101
+ return x
102
+
103
+
104
+ class TransformerLMHeadModel(nn.Module):
105
+ config: Any = None
106
+
107
+ def setup(self):
108
+ self.vocab_size = self.config.vocab_size
109
+ self.emb_dim = self.config.emb_dim
110
+ self.num_layers = self.config.num_layers
111
+
112
+ @nn.compact
113
+ def __call__(self, inputs, latent_values=None, latent_positions=None,
114
+ latent_active=None, training=True):
115
+ """
116
+ Transformer LM Head call function.
117
+
118
+ Args:
119
+ inputs: Input token ids (batch, seq).
120
+ latent_values: Optional (batch, K, emb_dim) continuous thought
121
+ vectors (raw last-layer hiddens fed back, Coconut/ATC style).
122
+ latent_positions: Optional (batch, K) int positions of the latent
123
+ slots in the sequence (per-example, after the clue block).
124
+ latent_active: Optional (batch, K) bool; slot j uses the projected
125
+ latent vector when True, otherwise keeps the placeholder
126
+ token embedding.
127
+ training: Whether the model is in training mode.
128
+
129
+ Returns:
130
+ (logits, hidden): LM logits and final (post-LayerNorm) hidden
131
+ states, the latter used to build the next continuous thought.
132
+ """
133
+ batch_size, seq_size = inputs.shape
134
+
135
+ causal_mask_x = nn.make_causal_mask(inputs, dtype=self.config.dtype)
136
+
137
+ # Embed the input tensor using a learnable embedding matrix.
138
+ embed_with_init = functools.partial(
139
+ nn.Embed, embedding_init=nn.initializers.normal(stddev=0.02))
140
+ token_embeddings = embed_with_init(
141
+ num_embeddings=self.config.vocab_size,
142
+ features=self.config.emb_dim,
143
+ )(inputs)
144
+
145
+ # Check the shape of the embedded tensor.
146
+ assert token_embeddings.shape == (batch_size, seq_size,
147
+ self.config.emb_dim)
148
+
149
+ # Continuous latent thoughts: project fed-back hidden states and
150
+ # scatter them into the latent slot positions, replacing the
151
+ # placeholder token embedding (position embeddings still added below).
152
+ # Skipped when inject_latents is False (no-recurrence control: the slots
153
+ # stay as static placeholders and the candidate heads become parallel
154
+ # per-stage readouts with no carried state).
155
+ if latent_values is not None and self.config.inject_latents:
156
+ proj = nn.Dense(features=self.config.emb_dim,
157
+ kernel_init=nn.initializers.xavier_uniform(),
158
+ name="latent_proj_in")(latent_values)
159
+ proj = nn.gelu(proj)
160
+ proj = nn.Dense(features=self.config.emb_dim,
161
+ kernel_init=nn.initializers.xavier_uniform(),
162
+ name="latent_proj_out")(proj)
163
+ bidx = jnp.arange(batch_size)[:, None]
164
+ cur = token_embeddings[bidx, latent_positions]
165
+ new = jnp.where(latent_active[..., None],
166
+ proj.astype(cur.dtype), cur)
167
+ token_embeddings = token_embeddings.at[
168
+ bidx, latent_positions].set(new)
169
+
170
+ # Initialize the positional embedding variable.
171
+ pos_embedding_variable = self.variable(
172
+ "params",
173
+ "position_embeddings",
174
+ jnp.zeros,
175
+ (self.config.seq_len, self.config.emb_dim),
176
+ )
177
+
178
+ # Slice the positional embedding array to the correct sequence length.
179
+ pos_embeddings = pos_embedding_variable.value[:seq_size, :]
180
+
181
+ # Check the shape of the positional embedding array.
182
+ output_tuple = (pos_embeddings.shape, token_embeddings.shape[1:])
183
+ assert pos_embeddings.shape == token_embeddings.shape[1:], output_tuple
184
+
185
+ # Add the positional embeddings to the token embeddings.
186
+ x = token_embeddings + pos_embeddings[None, :, :]
187
+
188
+ # Apply dropout to the input.
189
+ x = nn.Dropout(rate=self.config.dropout_rate,
190
+ deterministic=self.config.deterministic)(x)
191
+
192
+ # Apply the Transformer layers. remat (gradient checkpointing) keeps
193
+ # the multi-pass latent recurrence within GPU memory under full BPTT.
194
+ RematBlock = nn.remat(TransformerBlock)
195
+ for i in range(self.num_layers):
196
+ x = RematBlock(config=self.config)(
197
+ x, causal_mask_x, training=training)
198
+
199
+ self.sow('intermediates', 'feature_' + str(i), x)
200
+
201
+ # Apply the final layer normalization.
202
+ x = nn.LayerNorm()(x)
203
+
204
+ # Apply the LM head.
205
+ logits = nn.Dense(features=self.config.vocab_size,
206
+ kernel_init=nn.initializers.xavier_uniform(),
207
+ bias_init=nn.initializers.normal(stddev=1e-6),
208
+ use_bias=False)(x)
209
+
210
+ # Check the shape of the output tensor.
211
+ assert logits.shape == (batch_size, seq_size, self.config.vocab_size)
212
+
213
+ # ---- Auxiliary multi-candidate head ----
214
+ # Each latent slot reads its (post-LayerNorm) hidden and predicts the
215
+ # full 81x9 candidate grid for its reasoning stage. Trained with BCE
216
+ # (independent per-digit sigmoids = candidate-set membership), NOT
217
+ # softmax, so multiple digits can be "on" at intermediate stages.
218
+ cand_logits = None
219
+ if latent_positions is not None:
220
+ bidx = jnp.arange(batch_size)[:, None]
221
+ slot_hidden = x[bidx, latent_positions] # (bs, K, emb)
222
+ h = nn.Dense(features=self.config.emb_dim,
223
+ kernel_init=nn.initializers.xavier_uniform(),
224
+ name="cand_head_in")(slot_hidden)
225
+ h = nn.gelu(h)
226
+ cand_logits = nn.Dense(features=81 * 9,
227
+ kernel_init=nn.initializers.xavier_uniform(),
228
+ name="cand_head_out")(h) # (bs, K, 729)
229
+ cand_logits = cand_logits.reshape(
230
+ batch_size, -1, 81, 9) # (bs, K, 81, 9)
231
+
232
+ return logits, x, cand_logits
code/train/train_and_evaluate.py ADDED
@@ -0,0 +1,386 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # coding=utf-8
2
+ # Copyright 2024 The Google Research Authors.
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+
16
+ """This file contains function that coordinates the training and evaluation of the model."""
17
+
18
+ import functools
19
+ import math
20
+ import os
21
+
22
+ from absl import logging
23
+ from clu import metric_writers
24
+ from flax import jax_utils
25
+ from flax import linen as nn
26
+ from flax.training import checkpoints
27
+ import jax
28
+ from jax import random
29
+ import jax.numpy as jnp
30
+ import numpy as np
31
+ import tensorflow as tf
32
+ import wandb
33
+
34
+ from train import data
35
+ from train import evaluater
36
+ from train import model
37
+ from train import trainer
38
+
39
+
40
+
41
+ def log_hyperparams_tb(
42
+ config, model_config, initial_variables, tf_summary_writer
43
+ ):
44
+ """Log hyperparameters to TensorBoard.
45
+
46
+ Args:
47
+ config: experiment's ConfigDict
48
+ model_config: model's ConfigDict
49
+ initial_variables: initial hyperparameter values
50
+ tf_summary_writer: SummaryWriter object.
51
+
52
+ Returns:
53
+ The SummaryWriter object and the config.
54
+ """
55
+ # Calculate the total number of model parameters
56
+ config.num_model_parameters = sum(
57
+ x.size for x in jax.tree_util.tree_leaves(initial_variables)
58
+ )
59
+
60
+ # Convert hyperparameters to tensors
61
+ config_hyperparameters = [
62
+ tf.convert_to_tensor([k, str(v)]) for k, v in config.items()
63
+ ]
64
+ model_config_hyperparameters = [
65
+ tf.convert_to_tensor([k, str(v)])
66
+ for k, v in model_config.__dict__.items()
67
+ ]
68
+
69
+ # Log model hyperparameters to TensorBoard
70
+ with tf_summary_writer.as_default():
71
+ tf.summary.text(
72
+ "Model hyperparameters", tf.stack(model_config_hyperparameters), step=0
73
+ )
74
+ tf.summary.text(
75
+ "Config hyperparameters", tf.stack(config_hyperparameters), step=0
76
+ )
77
+
78
+ return tf_summary_writer, config
79
+
80
+
81
+
82
+ def train_and_evaluate(config, workdir):
83
+ """The training and evaluation loops for the model.
84
+
85
+ Args:
86
+ config: experiment's config dictionary.
87
+ workdir: directory to use for logging.
88
+ """
89
+ # Orbax checkpointing requires an absolute path.
90
+ workdir = os.path.abspath(workdir)
91
+
92
+ logging.info("Creating training and evaluator dataset iterator")
93
+ curriculum = data.CurriculumState(
94
+ stage=int(getattr(config, "curriculum_start_stage", 1)),
95
+ max_stage=int(getattr(config, "curriculum_max_stage", 6)))
96
+ train_data_iter = data.create_iter(
97
+ config, config.minibatch_size, train=True, curriculum=curriculum)
98
+ eval_data_iter = data.create_iter(config, config.minibatch_size, train=False)
99
+
100
+ logging.info("Finished creating training dataset iterator")
101
+
102
+ model_config = model.TransformerConfig(
103
+ dtype=config.dtype,
104
+ vocab_size=config.vocab_size,
105
+ seq_len=config.seq_len,
106
+ num_heads=config.num_heads,
107
+ num_layers=config.num_layers,
108
+ emb_dim=config.emb_dim,
109
+ qkv_dim=config.qkv_dim,
110
+ mlp_dim=config.mlp_dim,
111
+ dropout_rate=config.dropout_rate,
112
+ attention_dropout_rate=config.attention_dropout_rate,
113
+ deterministic=False,
114
+ num_latent_slots=int(config.num_latent_slots),
115
+ inject_latents=bool(int(getattr(config, "recurrent_latent", 1))),
116
+ )
117
+
118
+ logging.info("train_config: %s", str(model_config.__dict__))
119
+ print(str(model_config.__dict__), flush=True)
120
+
121
+ rng = jax.random.PRNGKey(config.seed)
122
+ rng, init_rng, inference_rng = random.split(rng, num=3)
123
+
124
+ # Initialize the model and get initial variables. Dummy latent arguments
125
+ # are provided so the latent projector parameters are created at init.
126
+ rng, dropout_rng = jax.random.split(rng)
127
+ input_shape = (config.minibatch_size, config.seq_len)
128
+ net = model.TransformerLMHeadModel(model_config)
129
+ rng_keys = {"params": init_rng, "dropout": dropout_rng}
130
+ K = int(config.num_latent_slots)
131
+ dummy_latents = jnp.zeros(
132
+ (config.minibatch_size, K, config.emb_dim), model_config.dtype)
133
+ dummy_positions = jnp.zeros((config.minibatch_size, K), jnp.int32)
134
+ dummy_active = jnp.zeros((config.minibatch_size, K), bool)
135
+ sample_out, initial_variables = jax.jit(
136
+ net.init_with_output
137
+ )(rng_keys, jnp.ones(input_shape, jnp.int32), dummy_latents,
138
+ dummy_positions, dummy_active)
139
+
140
+ state, lr_scheduler_fn = trainer.get_state(config, net, initial_variables)
141
+ # Resume-and-extend support: when resuming, start the training loop at the
142
+ # restored optimizer step (not 0) so a larger config.max_steps continues the
143
+ # cosine LR schedule cleanly instead of re-running steps or overshooting.
144
+ start_step = 0
145
+ if config.resume_training:
146
+ state = checkpoints.restore_checkpoint(config.ckpt_loc, state)
147
+ start_step = int(state.step)
148
+ print("----------Restored model from", config.ckpt_loc,
149
+ f"at step {start_step}-----------")
150
+
151
+ writer = metric_writers.create_default_writer(
152
+ workdir, asynchronous=False, just_logging=(jax.process_index() > 0))
153
+ tf_summary_writer = tf.summary.create_file_writer(workdir)
154
+
155
+ logging.info("config: %s", str(config.__dict__))
156
+ state = jax_utils.replicate(state)
157
+
158
+ dropout_rngs = jax.random.split(rng, jax.local_device_count())
159
+
160
+ def make_p_train_step(num_passes):
161
+ return jax.pmap(
162
+ functools.partial(
163
+ trainer.train_step,
164
+ config=model_config,
165
+ hyperparams=config,
166
+ learning_rate_fn=lr_scheduler_fn,
167
+ num_passes=num_passes),
168
+ axis_name="batch",
169
+ donate_argnums=(0,))
170
+
171
+ # num_passes = latent recurrence depth for this stage, capped by the number
172
+ # of latent slots (K=0 -> 0 passes -> no-latent control baseline).
173
+ # When recurrence is disabled (control), force 0 passes so no hidden state
174
+ # is ever fed back: the slots become static, independent per-stage readouts.
175
+ recurrent = bool(int(getattr(config, "recurrent_latent", 1)))
176
+ passes_per_stage = int(getattr(config, "passes_per_stage", 1))
177
+ def passes_for(stage):
178
+ return min(passes_per_stage * stage, K) if recurrent else 0
179
+ p_train_step = make_p_train_step(passes_for(curriculum.stage))
180
+
181
+ p_eval_step = jax.pmap(functools.partial(evaluater.eval_step,
182
+ config=model_config.replace(deterministic=True)),
183
+ axis_name="batch")
184
+
185
+ hooks, report_progress, train_metrics = trainer.get_metrics_report_progress(
186
+ config, workdir, writer)
187
+
188
+ tf_summary_writer, config = log_hyperparams_tb(
189
+ config, model_config, initial_variables, tf_summary_writer
190
+ )
191
+
192
+ promote_threshold = float(getattr(config, "promote_acc_threshold", 0.85))
193
+ promote_loc_threshold = float(getattr(config, "promote_loc_threshold", 0.70))
194
+ promote_patience = int(getattr(config, "promote_patience_steps", 8000))
195
+ min_stage_steps = int(getattr(config, "min_stage_steps", 2000))
196
+ plateau_steps = int(getattr(config, "plateau_steps", 0))
197
+ plateau_delta = float(getattr(config, "plateau_delta", 0.005))
198
+ instance_mode = bool(getattr(config, "instance_dir", None))
199
+ per_stage_inset = {}
200
+ # Best frontier-depth accuracy seen in the current stage, and the step it
201
+ # was last improved: the plateau detector's state.
202
+ stage_best_acc = -1.0
203
+ stage_best_step = start_step
204
+ ckpt_keep = int(getattr(config, "ckpt_keep", 100))
205
+ # Protected directory for per-stage checkpoints (never rolled off).
206
+ stage_ckpt_dir = os.path.join(workdir, "stage_ckpts")
207
+ stage_started_at = start_step
208
+
209
+ with metric_writers.ensure_flushes(writer):
210
+ for step in range(start_step, config.max_steps):
211
+ if step%10000 == 0:
212
+ print("Step:", step, flush=True)
213
+
214
+ state, metrics = trainer.train_one_step(p_train_step, config, state,
215
+ step, dropout_rngs, train_data_iter)
216
+
217
+ for h in hooks:
218
+ h(step)
219
+
220
+ if math.isnan(metrics["loss"][0]):
221
+ print("The loss function became nan: This might be due to the choice of hyperparameters.")
222
+ break
223
+
224
+ if step % config.eval_every_steps == 0:
225
+ eval_metrics = evaluater.get_eval_metrics(
226
+ state, eval_data_iter, p_eval_step, config)
227
+ per_level = eval_metrics.pop("per_level_acc")
228
+ per_slot = eval_metrics.pop("per_slot_acc", {})
229
+ per_slot_ch = eval_metrics.pop("per_slot_acc_changed", {})
230
+ per_stage_inset = eval_metrics.pop("per_stage_inset_acc", {})
231
+ per_bin = eval_metrics.pop("per_bin_acc", {})
232
+ def _m(key):
233
+ v = eval_metrics.get(key, [])
234
+ return round(float(np.mean(v)), 4) if len(v) else -1.0
235
+ print(step,
236
+ "stage", curriculum.stage,
237
+ "loss", round(float(metrics["loss"].mean()), 4),
238
+ "ce", round(float(metrics["ce_loss"].mean()), 4),
239
+ "aux_bce", round(float(metrics["aux_loss"].mean()), 4),
240
+ "| val_acc", _m("acc"),
241
+ "loc_acc", _m("loc_acc"),
242
+ "val|loc", _m("val_given_loc_acc"),
243
+ "| cand_bit_acc", _m("cand_bit_acc"),
244
+ "cand_set_acc", _m("cand_set_acc"),
245
+ "cand_set_chg", _m("cand_set_acc_changed"),
246
+ "| inset", round(
247
+ float(per_stage_inset.get(curriculum.stage, -1.0)), 4),
248
+ "promote_need inset>={:.2f} loc>={:.2f}".format(
249
+ promote_threshold, promote_loc_threshold),
250
+ "| depth_acc", round(
251
+ float(per_slot_ch.get(curriculum.stage, -1.0)), 4),
252
+ flush=True)
253
+ with tf_summary_writer.as_default():
254
+ tf.summary.scalar("loss", metrics["loss"].mean(), step=step)
255
+ tf.summary.scalar("ce_loss", metrics["ce_loss"].mean(), step=step)
256
+ tf.summary.scalar("aux_bce_loss", metrics["aux_loss"].mean(), step=step)
257
+ tf.summary.scalar(
258
+ "learning rate", metrics["learning_rate"].mean(), step=step
259
+ )
260
+ tf.summary.scalar("curriculum_stage", curriculum.stage, step=step)
261
+
262
+ log_dict = {'loss': metrics["loss"].mean(), 'learning rate': metrics["learning_rate"].mean()}
263
+
264
+ for key in eval_metrics.keys():
265
+ vals = eval_metrics[key]
266
+ if not vals:
267
+ continue
268
+ tf.summary.scalar(
269
+ "eval_" + key, np.array(vals).mean(), step=step
270
+ )
271
+ log_dict[ "eval_" + key ] = np.array(vals).mean()
272
+
273
+ for lvl, v in per_level.items():
274
+ if v >= 0:
275
+ tf.summary.scalar(f"eval_acc_level_{lvl}", v, step=step)
276
+ log_dict[f"eval_acc_level_{lvl}"] = v
277
+
278
+ for s, v in per_slot.items():
279
+ if v >= 0:
280
+ tf.summary.scalar(f"eval_cand_depth_{s}", v, step=step)
281
+ log_dict[f"eval_cand_depth_{s}"] = v
282
+ for s, v in per_slot_ch.items():
283
+ if v >= 0:
284
+ tf.summary.scalar(f"eval_cand_depth_chg_{s}", v, step=step)
285
+
286
+ if config.use_wandb: wandb.log(log_dict, step=step)
287
+
288
+ # ---- Curriculum promotion (ATC-style: threshold + patience) ----
289
+ # Two curricula, one frontier rule. With latent slots the
290
+ # frontier is the DEEPEST active slot, i.e. the newest wave
291
+ # snapshot this stage introduced, scored on changed cells only
292
+ # (unchanged cells are copies of slot j-1 and stay correct for a
293
+ # head that learned nothing new). With the round-count data
294
+ # curriculum the frontier is the newest round-BIN, i.e. the
295
+ # longest-chain puzzles this stage admitted.
296
+ rounds_curric = str(
297
+ getattr(config, "data_curriculum", "none")) == "rounds"
298
+ has_frontier = int(config.num_latent_slots) > 0 or rounds_curric
299
+ if has_frontier and curriculum.stage < curriculum.max_stage:
300
+ t = curriculum.stage
301
+ loc_now = _m("loc_acc")
302
+ if instance_mode:
303
+ # Inset = "emitted digit is in the stage-t set", scored
304
+ # only on location-correct cells. Require BOTH: the
305
+ # solver-order locations, and values inside the set.
306
+ # Otherwise a high inset on ~2% loc_acc promotes from
307
+ # noise. Plateau is also blocked until location is up,
308
+ # so we cannot skip stage 1 the way the old patience
309
+ # promotions did at 0.32-0.46.
310
+ inset_now = per_stage_inset.get(t, -1.0)
311
+ frontier_acc = float(inset_now) if inset_now >= 0 else -1.0
312
+ elif int(config.num_latent_slots) > 0:
313
+ frontier_acc = per_slot_ch.get(t, -1.0)
314
+ if frontier_acc < 0:
315
+ frontier_acc = per_slot.get(t, -1.0)
316
+ else:
317
+ frontier_acc = per_bin.get(t, -1.0)
318
+ if frontier_acc < 0:
319
+ # The frontier bin holds as little as 6.5% of the
320
+ # corpus, so a single eval can miss it. Fall back to
321
+ # the pooled accuracy over everything unlocked.
322
+ seen = [v for b, v in per_bin.items()
323
+ if b <= t and v >= 0]
324
+ frontier_acc = (float(np.mean(seen)) if seen
325
+ else -1.0)
326
+ steps_in_stage = step - stage_started_at
327
+ # A negative accuracy means "not measured this eval". Never
328
+ # let that count as evidence: it must not reset the plateau
329
+ # tracker, and it must not satisfy the threshold or plateau
330
+ # rule. Only the hard patience cap can fire without a
331
+ # measurement.
332
+ measured = frontier_acc >= 0
333
+ if measured and frontier_acc > stage_best_acc + plateau_delta:
334
+ stage_best_acc = frontier_acc
335
+ stage_best_step = step
336
+ loc_ready = (not instance_mode) or loc_now >= promote_loc_threshold
337
+ hit_threshold = (measured and loc_ready
338
+ and frontier_acc >= promote_threshold)
339
+ stalled = (measured and loc_ready and plateau_steps > 0
340
+ and (step - stage_best_step) >= plateau_steps)
341
+ patience_over = steps_in_stage >= promote_patience
342
+ if steps_in_stage >= min_stage_steps and (
343
+ hit_threshold or stalled or patience_over):
344
+ reason = ("threshold" if hit_threshold
345
+ else "plateau" if stalled else "patience")
346
+ curriculum.stage += 1
347
+ stage_started_at = step
348
+ stage_best_acc = -1.0
349
+ stage_best_step = step
350
+ p_train_step = make_p_train_step(passes_for(curriculum.stage))
351
+ what = ("round-bin" if int(config.num_latent_slots) == 0
352
+ else "depth")
353
+ print(f"[curriculum] step {step}: promote to stage "
354
+ f"{curriculum.stage} ({reason}; graduated {what} "
355
+ f"{t} acc={frontier_acc:.3f} after "
356
+ f"{steps_in_stage} steps); "
357
+ f"latent passes={curriculum.stage}, "
358
+ f"pool/snapshots now 1..{curriculum.stage}",
359
+ flush=True)
360
+ if config.save_checkpoint:
361
+ unrep_state = jax_utils.unreplicate(state)
362
+ # Rolling checkpoint in the main workdir.
363
+ checkpoints.save_checkpoint_multiprocess(
364
+ workdir, unrep_state, step,
365
+ keep=ckpt_keep, overwrite=True)
366
+ # Protected copy: the model *entering* this stage,
367
+ # kept permanently under stage_ckpts/ (never rolled
368
+ # off), so every stage's checkpoint survives.
369
+ checkpoints.save_checkpoint_multiprocess(
370
+ stage_ckpt_dir, unrep_state, step,
371
+ keep=100, overwrite=True,
372
+ prefix=f"stage{curriculum.stage}_")
373
+
374
+ if config.save_checkpoint and step > 0 and step % config.save_every_steps == 0:
375
+ checkpoints.save_checkpoint_multiprocess(
376
+ workdir, jax_utils.unreplicate(state), step,
377
+ keep=ckpt_keep, overwrite=True
378
+ )
379
+
380
+ # Final checkpoint at the end of training
381
+ if config.save_checkpoint:
382
+ checkpoints.save_checkpoint_multiprocess(
383
+ workdir, jax_utils.unreplicate(state), config.max_steps,
384
+ keep=ckpt_keep, overwrite=True)
385
+
386
+
code/train/train_backtrack.py ADDED
@@ -0,0 +1,773 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Backtracking (stage-replay) training loop for the recurrent latent model.
2
+
3
+ Motivation
4
+ ----------
5
+ Standard curriculum training runs EVERY batch at `num_passes = curriculum.stage`
6
+ (the current frontier depth). When the curriculum advances to stage i, the
7
+ earlier, shallower readouts (stage 1..i-1, each of which should be produced by
8
+ applying the tied recurrent step exactly t times) drift / are forgotten.
9
+
10
+ Backtracking = interleaved replay with the recurrence depth MATCHED to the
11
+ replayed stage:
12
+
13
+ stage t <-> difficulty level t+2 <-> num_passes = t <-> t active slots
14
+
15
+ With cand_slot_mode="depth" and passes_per_stage=pps the depth becomes pps*t and
16
+ the active-slot count follows num_passes rather than the puzzle level. Replay is
17
+ still safe because that mode maps slot j -> stage j: slots [0, pps*t) hold the
18
+ right targets no matter which frontier stage the sampler built the batch for.
19
+
20
+ * frontier step : level (i+2) puzzles, num_passes = i -> trains stage-i readout
21
+ * backtrack step: level (t+2) puzzles, num_passes = t -> re-derives stage-t
22
+ for a randomly chosen earlier t in {1..i-1}
23
+
24
+ Each optimisation step is frontier with prob (1 - backtrack_prob) and a backtrack
25
+ step with prob backtrack_prob (t uniform over the earlier stages). Because the
26
+ recurrent step is weight-tied, replaying "apply f exactly t times = stage t"
27
+ keeps f reusable at every depth instead of specialising to the frontier depth.
28
+
29
+ Everything else (model, data, per-slot BCE + LM CE loss, eval, promotion) is
30
+ reused unchanged from the standard pipeline.
31
+ """
32
+
33
+ import functools
34
+ import math
35
+ import os
36
+
37
+ from absl import logging
38
+ from clu import metric_writers
39
+ from flax import jax_utils
40
+ from flax.training import checkpoints
41
+ from flax.training import common_utils
42
+ import jax
43
+ import numpy as np
44
+ import tensorflow as tf
45
+
46
+ from train import data
47
+ from train import evaluater
48
+ from train import model
49
+ from train import trainer
50
+
51
+
52
+ class StageBatchSampler:
53
+ """Draws minibatches of a *specific* difficulty level from the train set.
54
+
55
+ Reuses SudokuDataset (which loads the puzzles + staged candidate masks and
56
+ builds a level->indices map) and assembles numpy batches in the exact tuple
57
+ layout the train step expects: (seq, puzzle, start_index, level, cand_targets).
58
+ """
59
+
60
+ def __init__(self, config, seed=0):
61
+ self.ds = data.SudokuDataset(config, train=True)
62
+ self.rng = np.random.RandomState(seed)
63
+ # Only levels that actually have puzzles.
64
+ self.available = {lvl: idx for lvl, idx in self.ds.level_index.items()
65
+ if len(idx) > 0}
66
+
67
+ def has_level(self, level):
68
+ return level in self.available
69
+
70
+ def _gather(self, idxs):
71
+ seqs, puzzles, starts, levels, cands = [], [], [], [], []
72
+ for idx in idxs:
73
+ seq, puzzle, start_index, lvl, cand = self.ds.__getitem__(int(idx))
74
+ seqs.append(seq)
75
+ puzzles.append(puzzle)
76
+ starts.append(start_index)
77
+ levels.append(lvl)
78
+ cands.append(cand)
79
+ return (
80
+ np.stack(seqs).astype(np.int32),
81
+ np.stack(puzzles).astype(np.int32),
82
+ np.stack(starts).astype(np.int32),
83
+ np.stack(levels).astype(np.int32),
84
+ np.stack(cands).astype(np.int32),
85
+ )
86
+
87
+ def sample_all(self, bs):
88
+ """Batch drawn uniformly from the whole corpus, matching the standard
89
+ loop's sampler. Difficulty is not a curriculum axis, so every step --
90
+ frontier or repair -- uses this same distribution and differs only in
91
+ the recurrence depth it trains at."""
92
+ return self._gather(self.rng.randint(len(self.ds), size=bs))
93
+
94
+ def sample(self, level, bs):
95
+ idx_pool = self.available[level]
96
+ idxs = idx_pool[self.rng.randint(len(idx_pool), size=bs)]
97
+ return self._gather(idxs)
98
+
99
+ def sample_mixed(self, levels, bs):
100
+ """Batch drawn uniformly over `levels` (level first, then a puzzle of
101
+ that level) — the exact distribution the standard curriculum sampler
102
+ uses. Frontier steps must use this, NOT single-level batches: training
103
+ exclusively on the frontier level starves every easier level and is a
104
+ different (worse) recipe than the standard loop, not "standard + BT"."""
105
+ levels = [l for l in levels if l in self.available]
106
+ idxs = []
107
+ for _ in range(bs):
108
+ lvl = levels[self.rng.randint(len(levels))]
109
+ pool = self.available[lvl]
110
+ idxs.append(int(pool[self.rng.randint(len(pool))]))
111
+ return self._gather(idxs)
112
+
113
+
114
+ def _run_train_step(step_fn, state, batch_tuple, dropout_rngs):
115
+ """Shard a single numpy batch and run one (pmapped) train step."""
116
+ inputs, _, start_index, levels, cand_targets = batch_tuple
117
+ inputs = common_utils.shard(jax.tree_util.tree_map(np.asarray, inputs))
118
+ start_index = common_utils.shard(jax.tree_util.tree_map(np.asarray, start_index))
119
+ levels = common_utils.shard(jax.tree_util.tree_map(np.asarray, levels))
120
+ cand_targets = np.asarray(cand_targets)
121
+ _nd = jax.local_device_count()
122
+ cand_targets = cand_targets.reshape(
123
+ (_nd, cand_targets.shape[0] // _nd) + cand_targets.shape[1:])
124
+ state, metrics, _ = step_fn(
125
+ state, inputs, start_index, levels, cand_targets, dropout_rng=dropout_rngs)
126
+ return state, metrics
127
+
128
+
129
+ def _prepare_backtrack(config, workdir):
130
+ """Build the model, state, pmapped steps, sampler, and writers shared by the
131
+ probabilistic and adaptive backtracking loops.
132
+
133
+ Returns a dict of everything both loops need. `state` is already replicated.
134
+ """
135
+ K = int(config.num_latent_slots)
136
+ assert K > 0, "Backtracking is for the recurrent latent model (K>0)."
137
+
138
+ logging.info("Creating datasets (backtracking loop)")
139
+ curriculum = data.CurriculumState(
140
+ stage=int(getattr(config, "curriculum_start_stage", 1)),
141
+ max_stage=int(getattr(config, "curriculum_max_stage", 6)))
142
+ # Per-level train sampler (replaces the uniform-over-unlocked sampler so we
143
+ # can draw a batch of a specific replay stage's level on demand).
144
+ sampler = StageBatchSampler(config, seed=int(config.seed))
145
+ eval_data_iter = data.create_iter(config, config.minibatch_size, train=False)
146
+
147
+ model_config = model.TransformerConfig(
148
+ dtype=config.dtype, vocab_size=config.vocab_size, seq_len=config.seq_len,
149
+ num_heads=config.num_heads, num_layers=config.num_layers,
150
+ emb_dim=config.emb_dim, qkv_dim=config.qkv_dim, mlp_dim=config.mlp_dim,
151
+ dropout_rate=config.dropout_rate,
152
+ attention_dropout_rate=config.attention_dropout_rate,
153
+ deterministic=False, num_latent_slots=K,
154
+ inject_latents=bool(int(getattr(config, "recurrent_latent", 1))),
155
+ )
156
+ print(str(model_config.__dict__), flush=True)
157
+
158
+ rng = jax.random.PRNGKey(config.seed)
159
+ rng, init_rng, dropout_rng = jax.random.split(rng, 3)
160
+ net = model.TransformerLMHeadModel(model_config)
161
+ dummy_latents = jax.numpy.zeros(
162
+ (config.minibatch_size, K, config.emb_dim), model_config.dtype)
163
+ dummy_positions = jax.numpy.zeros((config.minibatch_size, K), jax.numpy.int32)
164
+ dummy_active = jax.numpy.zeros((config.minibatch_size, K), bool)
165
+ _, initial_variables = jax.jit(net.init_with_output)(
166
+ {"params": init_rng, "dropout": dropout_rng},
167
+ jax.numpy.ones((config.minibatch_size, config.seq_len), jax.numpy.int32),
168
+ dummy_latents, dummy_positions, dummy_active)
169
+
170
+ state, lr_scheduler_fn = trainer.get_state(config, net, initial_variables)
171
+ start_step = 0
172
+ if config.resume_training:
173
+ state = checkpoints.restore_checkpoint(config.ckpt_loc, state)
174
+ start_step = int(state.step)
175
+ print("----------Restored model from", config.ckpt_loc,
176
+ f"at step {start_step}-----------")
177
+
178
+ writer = metric_writers.create_default_writer(
179
+ workdir, asynchronous=False, just_logging=(jax.process_index() > 0))
180
+ tf_summary_writer = tf.summary.create_file_writer(workdir)
181
+
182
+ state = jax_utils.replicate(state)
183
+ dropout_rngs = jax.random.split(rng, jax.local_device_count())
184
+
185
+ def make_p_train_step(num_passes, backtrack):
186
+ return jax.pmap(
187
+ functools.partial(
188
+ trainer.train_step, config=model_config, hyperparams=config,
189
+ learning_rate_fn=lr_scheduler_fn, num_passes=num_passes,
190
+ backtrack=backtrack),
191
+ axis_name="batch", donate_argnums=(0,))
192
+
193
+ # Precompile, for every recurrence depth 1..K, both a FRONTIER step (normal
194
+ # loss: LM CE + all active-slot BCE) and a strict BACKTRACK step (only the
195
+ # last active slot's readout, no CE). Frontier is used when t == curriculum
196
+ # stage i; the strict backtrack step is used by the probabilistic loop for
197
+ # replayed earlier stages t < i.
198
+ # Stage -> recurrence depth. With passes_per_stage>1 a stage advances the
199
+ # chain by more than one slot (pps=2, K=12: stage 6 -> all 12 slots), so the
200
+ # replay depth for stage t is pps*t, not t. Keyed by stage either way.
201
+ pps = int(getattr(config, "passes_per_stage", 1))
202
+ _depth = lambda t: min(pps * t, K)
203
+ p_frontier = {t: make_p_train_step(_depth(t), False) for t in range(1, K + 1)}
204
+ p_backtrack = {t: make_p_train_step(_depth(t), True) for t in range(1, K + 1)}
205
+ p_eval_step = jax.pmap(functools.partial(
206
+ evaluater.eval_step, config=model_config.replace(deterministic=True)),
207
+ axis_name="batch")
208
+
209
+ hooks, report_progress, _ = trainer.get_metrics_report_progress(
210
+ config, workdir, writer)
211
+
212
+ return {
213
+ "K": K,
214
+ "start_step": start_step,
215
+ "curriculum": curriculum,
216
+ "sampler": sampler,
217
+ "eval_data_iter": eval_data_iter,
218
+ "model_config": model_config,
219
+ "state": state,
220
+ "dropout_rngs": dropout_rngs,
221
+ "p_frontier": p_frontier,
222
+ "p_backtrack": p_backtrack,
223
+ "p_eval_step": p_eval_step,
224
+ "hooks": hooks,
225
+ "report_progress": report_progress,
226
+ "writer": writer,
227
+ "tf_summary_writer": tf_summary_writer,
228
+ "promote_threshold": float(getattr(config, "promote_acc_threshold", 0.85)),
229
+ "promote_patience": int(getattr(config, "promote_patience_steps", 8000)),
230
+ "min_stage_steps": int(getattr(config, "min_stage_steps", 2000)),
231
+ "ckpt_keep": int(getattr(config, "ckpt_keep", 100)),
232
+ "stage_ckpt_dir": os.path.join(workdir, "stage_ckpts"),
233
+ }
234
+
235
+
236
+ def train_and_evaluate_backtrack(config, workdir):
237
+ """Backtracking curriculum training loop (stage replay with matched depth)."""
238
+ workdir = os.path.abspath(workdir)
239
+ backtrack_prob = float(getattr(config, "backtrack_prob", 0.5))
240
+ frontier_mix = bool(int(getattr(config, "backtrack_frontier_mix", 1)))
241
+
242
+ ctx = _prepare_backtrack(config, workdir)
243
+ K = ctx["K"]
244
+ curriculum = ctx["curriculum"]
245
+ sampler = ctx["sampler"]
246
+ eval_data_iter = ctx["eval_data_iter"]
247
+ state = ctx["state"]
248
+ dropout_rngs = ctx["dropout_rngs"]
249
+ p_frontier = ctx["p_frontier"]
250
+ p_backtrack = ctx["p_backtrack"]
251
+ p_eval_step = ctx["p_eval_step"]
252
+ hooks = ctx["hooks"]
253
+ writer = ctx["writer"]
254
+ tf_summary_writer = ctx["tf_summary_writer"]
255
+ promote_threshold = ctx["promote_threshold"]
256
+ promote_patience = ctx["promote_patience"]
257
+ min_stage_steps = ctx["min_stage_steps"]
258
+ ckpt_keep = ctx["ckpt_keep"]
259
+ stage_ckpt_dir = ctx["stage_ckpt_dir"]
260
+ stage_started_at = 0
261
+ sched_rng = np.random.RandomState(int(config.seed) + 1)
262
+
263
+ # Bookkeeping: how often each replay depth is actually trained.
264
+ stage_step_counts = {t: 0 for t in range(1, K + 1)}
265
+
266
+ def sample_target_stage(i):
267
+ """Frontier stage i with prob (1-p); else a backtrack stage 1..i-1."""
268
+ if i <= 1 or sched_rng.rand() >= backtrack_prob:
269
+ return i
270
+ return int(sched_rng.randint(1, i)) # uniform in {1..i-1}
271
+
272
+ with metric_writers.ensure_flushes(writer):
273
+ for step in range(0, config.max_steps):
274
+ if step % 10000 == 0:
275
+ print("Step:", step, flush=True)
276
+
277
+ i = curriculum.stage
278
+ t = sample_target_stage(i)
279
+ # Frontier step (t == i): normal loss. Backtrack step (t < i): strict
280
+ # replay of the depth-t readout only. Both draw from the whole
281
+ # corpus: depth, not difficulty, is the replayed axis.
282
+ step_fn = p_frontier[t] if t == i else p_backtrack[t]
283
+ batch = sampler.sample_all(config.minibatch_size)
284
+ state, metrics = _run_train_step(step_fn, state, batch, dropout_rngs)
285
+ stage_step_counts[t] += 1
286
+
287
+ for h in hooks:
288
+ h(step)
289
+
290
+ if math.isnan(metrics["loss"][0]):
291
+ print("Loss became nan; stopping.", flush=True)
292
+ break
293
+
294
+ if step % config.eval_every_steps == 0:
295
+ eval_metrics = evaluater.get_eval_metrics(
296
+ state, eval_data_iter, p_eval_step, config)
297
+ per_level = eval_metrics.pop("per_level_acc")
298
+ per_depth = eval_metrics.pop("per_slot_acc_changed", {})
299
+ per_depth_all = eval_metrics.pop("per_slot_acc", {})
300
+ if not any(v >= 0 for v in per_depth.values()):
301
+ per_depth = per_depth_all
302
+
303
+ def _m(key):
304
+ v = eval_metrics.get(key, [])
305
+ return round(float(np.mean(v)), 4) if len(v) else -1.0
306
+
307
+ print(step, "stage", curriculum.stage,
308
+ "target_t", t,
309
+ "loss", round(float(metrics["loss"].mean()), 4),
310
+ "ce", round(float(metrics["ce_loss"].mean()), 4),
311
+ "aux_bce", round(float(metrics["aux_loss"].mean()), 4),
312
+ "| val_acc", _m("acc"), "loc_acc", _m("loc_acc"),
313
+ "val|loc", _m("val_given_loc_acc"),
314
+ "| cand_bit_acc", _m("cand_bit_acc"),
315
+ "cand_set_acc", _m("cand_set_acc"),
316
+ "cand_set_chg", _m("cand_set_acc_changed"),
317
+ "| replay_counts", dict(stage_step_counts),
318
+ flush=True)
319
+
320
+ with tf_summary_writer.as_default():
321
+ tf.summary.scalar("loss", metrics["loss"].mean(), step=step)
322
+ tf.summary.scalar("ce_loss", metrics["ce_loss"].mean(), step=step)
323
+ tf.summary.scalar("aux_bce_loss", metrics["aux_loss"].mean(), step=step)
324
+ tf.summary.scalar("curriculum_stage", curriculum.stage, step=step)
325
+ for key in eval_metrics.keys():
326
+ tf.summary.scalar("eval_" + key,
327
+ np.array(eval_metrics[key]).mean(), step=step)
328
+ for lvl, v in per_level.items():
329
+ if v >= 0:
330
+ tf.summary.scalar(f"eval_acc_level_{lvl}", v, step=step)
331
+
332
+ # ---- Curriculum promotion (same rule as the standard loop) ----
333
+ if curriculum.stage < curriculum.max_stage:
334
+ frontier_acc = per_depth.get(curriculum.stage, -1.0)
335
+ steps_in_stage = step - stage_started_at
336
+ hit_threshold = frontier_acc >= promote_threshold
337
+ patience_over = steps_in_stage >= promote_patience
338
+ if steps_in_stage >= min_stage_steps and (hit_threshold or patience_over):
339
+ reason = "threshold" if hit_threshold else "patience"
340
+ curriculum.stage += 1
341
+ stage_started_at = step
342
+ print(f"[curriculum] step {step}: promote to stage "
343
+ f"{curriculum.stage} ({reason}; graduated depth "
344
+ f"{curriculum.stage - 1} cand-set "
345
+ f"acc={frontier_acc:.3f}); "
346
+ f"backtrack pool now depths 1..{curriculum.stage-1}",
347
+ flush=True)
348
+ if config.save_checkpoint:
349
+ unrep = jax_utils.unreplicate(state)
350
+ checkpoints.save_checkpoint_multiprocess(
351
+ workdir, unrep, step, keep=ckpt_keep, overwrite=True)
352
+ checkpoints.save_checkpoint_multiprocess(
353
+ stage_ckpt_dir, unrep, step, keep=100,
354
+ overwrite=True, prefix=f"stage{curriculum.stage}_")
355
+
356
+ if config.save_checkpoint and step > 0 and step % config.save_every_steps == 0:
357
+ checkpoints.save_checkpoint_multiprocess(
358
+ workdir, jax_utils.unreplicate(state), step,
359
+ keep=ckpt_keep, overwrite=True)
360
+
361
+ if config.save_checkpoint:
362
+ checkpoints.save_checkpoint_multiprocess(
363
+ workdir, jax_utils.unreplicate(state), config.max_steps,
364
+ keep=ckpt_keep, overwrite=True)
365
+
366
+
367
+ def train_and_evaluate_backtrack_adaptive(config, workdir):
368
+ """Deficit-driven ("adaptive") backtracking loop.
369
+
370
+ Difference from the probabilistic loop
371
+ --------------------------------------
372
+ The probabilistic loop replays a *uniformly random* earlier stage every step
373
+ with fixed probability p, regardless of whether that stage needs help. This
374
+ loop instead *watches* each earlier stage's held-out accuracy and only goes
375
+ back to repair a stage when it has actually regressed:
376
+
377
+ * Reference: when the curriculum promotes past stage t, we record that
378
+ stage's frontier-level (t+2) val_acc as its "graduation" accuracy.
379
+ * Trigger (relative drop): while at frontier stage i, at every eval we scan
380
+ earlier stages 1..i-1; a stage t is *in deficit* if its current level-(t+2)
381
+ val_acc has fallen below (graduation_acc[t] - margin).
382
+ * Selection (most-deficient first): if any stage is in deficit we switch the
383
+ training target to the single most-regressed stage and train there.
384
+ * Repair step: a FULL frontier-style step at the matched depth (level t+2,
385
+ num_passes=t, LM CE + all-active-slot BCE) -- NOT the strict readout-only
386
+ backtrack step. The metric we are trying to restore is placement accuracy
387
+ (driven by the LM CE), so the repair objective must include it.
388
+ * Exit (recover-or-cap): stay on the stage until its val_acc climbs back
389
+ above (graduation_acc[t] - margin), or until a max-repair-steps cap fires
390
+ (so a stuck stage cannot stall the frontier forever). On exit we re-scan
391
+ and either move to the next most-deficient stage or return to the frontier.
392
+
393
+ Frontier promotion is paused while repairing, and the repair time is credited
394
+ back to the frontier stage's patience clock on return.
395
+
396
+ Depth-matched eval note: the evaluator scores a level-L puzzle with exactly
397
+ k=L-2 active latent slots, so per_level_acc[t+2] is a faithful measurement of
398
+ the stage-t readout -- a clean trigger signal with no extra instrumentation.
399
+ """
400
+ workdir = os.path.abspath(workdir)
401
+ margin = float(getattr(config, "backtrack_margin", 0.03))
402
+ max_repair_steps = int(getattr(config, "backtrack_max_repair_steps", 6000))
403
+ min_frontier_steps = int(getattr(config, "backtrack_min_frontier_steps", 0))
404
+ frontier_target_acc = float(
405
+ getattr(config, "backtrack_frontier_target_acc", 0.0))
406
+ grad_acc_seed_raw = str(getattr(config, "backtrack_grad_acc_seed", "") or "")
407
+ max_repair_fraction = float(
408
+ getattr(config, "backtrack_max_repair_fraction", 0.0))
409
+ grad_decay = float(getattr(config, "backtrack_grad_decay", 0.0))
410
+ freeze_after_step = int(getattr(config, "backtrack_freeze_after_step", 0))
411
+ frontier_mix = bool(int(getattr(config, "backtrack_frontier_mix", 1)))
412
+
413
+ ctx = _prepare_backtrack(config, workdir)
414
+ K = ctx["K"]
415
+ start_step = ctx["start_step"]
416
+ curriculum = ctx["curriculum"]
417
+ sampler = ctx["sampler"]
418
+ eval_data_iter = ctx["eval_data_iter"]
419
+ state = ctx["state"]
420
+ dropout_rngs = ctx["dropout_rngs"]
421
+ p_frontier = ctx["p_frontier"]
422
+ p_eval_step = ctx["p_eval_step"]
423
+ hooks = ctx["hooks"]
424
+ writer = ctx["writer"]
425
+ tf_summary_writer = ctx["tf_summary_writer"]
426
+ promote_threshold = ctx["promote_threshold"]
427
+ promote_loc_threshold = float(getattr(config, "promote_loc_threshold", 0.70))
428
+ instance_mode = bool(getattr(config, "instance_dir", None))
429
+ promote_patience = ctx["promote_patience"]
430
+ min_stage_steps = ctx["min_stage_steps"]
431
+ ckpt_keep = ctx["ckpt_keep"]
432
+ stage_ckpt_dir = ctx["stage_ckpt_dir"]
433
+
434
+ # Controller state.
435
+ grad_acc = {} # stage t -> level-(t+2) val_acc at graduation
436
+ plateau_steps = int(getattr(config, "plateau_steps", 0))
437
+ plateau_delta = float(getattr(config, "plateau_delta", 0.005))
438
+ stage_best_acc = -1.0
439
+ stage_best_step = start_step
440
+ mode = "frontier" # "frontier" | "repair"
441
+ repair_stage = None # stage currently being repaired
442
+ repair_started_at = 0 # step the current repair stage began (cap)
443
+ repair_episode_start = 0 # step we first left the frontier (clock credit)
444
+ stage_started_at = start_step # step the current frontier stage began
445
+ last_repair_return_step = start_step # for min-frontier cooldown
446
+ step_counts = {t: 0 for t in range(1, K + 1)} # steps trained at each depth
447
+ repair_steps_total = 0 # for duty-cycle cap
448
+ print(f"[repair] knobs: margin={margin} max_repair_steps={max_repair_steps} "
449
+ f"min_frontier={min_frontier_steps} frontier_target={frontier_target_acc} "
450
+ f"max_repair_frac={max_repair_fraction} grad_decay={grad_decay} "
451
+ f"freeze_after={freeze_after_step} frontier_mix={frontier_mix}",
452
+ flush=True)
453
+ # When seeding from a mid-curriculum checkpoint (start stage > 1), the
454
+ # earlier stages graduated in the *source* run so we have no reference for
455
+ # them. Prefer an explicit SUDOKU_GRAD_ACC_SEED (true graduation refs);
456
+ # otherwise seed from the first eval (old behavior — can over-trigger).
457
+ seed_start_stage = int(getattr(config, "curriculum_start_stage", 1))
458
+ grad_acc_seeded = seed_start_stage <= 1
459
+ if (not grad_acc_seeded) and grad_acc_seed_raw.strip():
460
+ try:
461
+ vals = [float(x) for x in grad_acc_seed_raw.split(",") if x.strip()]
462
+ for s, v in enumerate(vals, start=1):
463
+ if s < seed_start_stage:
464
+ grad_acc[s] = v
465
+ if grad_acc:
466
+ grad_acc_seeded = True
467
+ print(f"[repair] seeded graduation refs from env: "
468
+ f"{dict((k, round(v, 3)) for k, v in grad_acc.items())}",
469
+ flush=True)
470
+ except ValueError:
471
+ print(f"[repair] WARNING: bad SUDOKU_GRAD_ACC_SEED="
472
+ f"{grad_acc_seed_raw!r}; falling back to first-eval seeding",
473
+ flush=True)
474
+
475
+ def compute_deficits(frontier_stage, per_depth):
476
+ """{depth t: graduation_acc[t] - current_acc} over earlier graduated
477
+ depths whose current candidate-set accuracy is measured.
478
+
479
+ Keyed on reasoning depth, not difficulty level: depth t's accuracy is
480
+ how well wave snapshot t is predicted, which is exactly what stage t
481
+ taught. A drop there means that propagation block has been forgotten."""
482
+ d = {}
483
+ for t in range(1, frontier_stage):
484
+ if t in grad_acc and per_depth.get(t, -1.0) >= 0:
485
+ d[t] = grad_acc[t] - per_depth.get(t, -1.0)
486
+ return d
487
+
488
+ def effective_margin(per_depth, frontier_stage):
489
+ """Widen the repair trigger while the frontier itself is still weak."""
490
+ m = margin
491
+ if frontier_target_acc > 0:
492
+ f_acc = per_depth.get(frontier_stage, -1.0)
493
+ if 0.0 <= f_acc < frontier_target_acc:
494
+ # Only repair clearer regressions until the frontier is good.
495
+ m = max(m, margin + (frontier_target_acc - f_acc))
496
+ return m
497
+
498
+ def most_deficient(frontier_stage, per_depth, use_margin=None):
499
+ """Most-regressed depth whose drop exceeds the margin, else None."""
500
+ d = compute_deficits(frontier_stage, per_depth)
501
+ if not d:
502
+ return None
503
+ m = margin if use_margin is None else use_margin
504
+ t = max(d, key=d.get)
505
+ return t if d[t] > m else None
506
+
507
+ with metric_writers.ensure_flushes(writer):
508
+ for step in range(start_step, config.max_steps):
509
+ if step % 10000 == 0:
510
+ print("Step:", step, flush=True)
511
+
512
+ i = curriculum.stage
513
+ # Target depth: the frontier when training normally, else the depth
514
+ # we are repairing. Difficulty is never gated, so a repair differs
515
+ # from a frontier step only in the recurrence depth it trains at:
516
+ # replaying depth t re-supervises wave snapshots 1..t on the same
517
+ # full-corpus batch distribution.
518
+ t = repair_stage if mode == "repair" else i
519
+ batch = sampler.sample_all(config.minibatch_size)
520
+ state, metrics = _run_train_step(
521
+ p_frontier[t], state, batch, dropout_rngs)
522
+ step_counts[t] += 1
523
+ if mode == "repair":
524
+ repair_steps_total += 1
525
+
526
+ for h in hooks:
527
+ h(step)
528
+
529
+ if math.isnan(metrics["loss"][0]):
530
+ print("Loss became nan; stopping.", flush=True)
531
+ break
532
+
533
+ if step % config.eval_every_steps == 0:
534
+ eval_metrics = evaluater.get_eval_metrics(
535
+ state, eval_data_iter, p_eval_step, config)
536
+ per_level = eval_metrics.pop("per_level_acc")
537
+ # Depth accuracy drives the controller. Changed-cells-only, so
538
+ # a slot that merely copies its predecessor scores zero credit.
539
+ per_depth = eval_metrics.pop("per_slot_acc_changed", {})
540
+ per_depth_all = eval_metrics.pop("per_slot_acc", {})
541
+ per_stage_inset = eval_metrics.pop("per_stage_inset_acc", {})
542
+ if instance_mode:
543
+ # Repair if a stage's in-set rate falls behind its
544
+ # graduation value. The candidate head is off.
545
+ per_depth = per_stage_inset
546
+ elif not any(v >= 0 for v in per_depth.values()):
547
+ per_depth = per_depth_all
548
+
549
+ def _m(key):
550
+ v = eval_metrics.get(key, [])
551
+ return round(float(np.mean(v)), 4) if len(v) else -1.0
552
+
553
+ # Seed graduation refs for stages inherited from a checkpoint.
554
+ if not grad_acc_seeded:
555
+ for s in range(1, seed_start_stage):
556
+ acc_s = per_depth.get(s, -1.0)
557
+ if acc_s >= 0:
558
+ grad_acc[s] = float(acc_s)
559
+ grad_acc_seeded = True
560
+ print(f"[repair] step {step}: seeded graduation refs from "
561
+ f"resume: "
562
+ f"{dict((k, round(v, 3)) for k, v in grad_acc.items())}",
563
+ flush=True)
564
+
565
+ # Soft graduation refs: forgive chronic mild regression.
566
+ if grad_decay > 0 and grad_acc:
567
+ for s in list(grad_acc.keys()):
568
+ cur_s = per_depth.get(s, -1.0)
569
+ if cur_s >= 0 and cur_s < grad_acc[s]:
570
+ old = grad_acc[s]
571
+ grad_acc[s] = (
572
+ (1.0 - grad_decay) * grad_acc[s]
573
+ + grad_decay * float(cur_s))
574
+ if step % (config.eval_every_steps * 5) == 0:
575
+ print(f"[repair] soft-grad depth {s}: "
576
+ f"{old:.3f}->{grad_acc[s]:.3f} "
577
+ f"(cur={cur_s:.3f})", flush=True)
578
+
579
+ frontier_acc = per_depth.get(curriculum.stage, -1.0)
580
+ eff_margin = effective_margin(per_depth, i)
581
+ deficits = compute_deficits(i, per_depth)
582
+ elapsed = max(1, step - start_step + 1)
583
+ repair_frac = repair_steps_total / float(elapsed)
584
+ bt_frozen = (
585
+ freeze_after_step > 0 and step >= freeze_after_step)
586
+ print(step, "stage", curriculum.stage,
587
+ "mode", mode,
588
+ "repair_stage", repair_stage,
589
+ "target_t", t,
590
+ "loss", round(float(metrics["loss"].mean()), 4),
591
+ "ce", round(float(metrics["ce_loss"].mean()), 4),
592
+ "aux_bce", round(float(metrics["aux_loss"].mean()), 4),
593
+ "| val_acc", _m("acc"), "loc_acc", _m("loc_acc"),
594
+ "val|loc", _m("val_given_loc_acc"),
595
+ "| frontier_acc", round(float(frontier_acc), 4),
596
+ "eff_margin", round(float(eff_margin), 4),
597
+ "| cand_bit_acc", _m("cand_bit_acc"),
598
+ "cand_set_acc", _m("cand_set_acc"),
599
+ "cand_set_chg", _m("cand_set_acc_changed"),
600
+ "| grad_acc", {k: round(v, 3) for k, v in grad_acc.items()},
601
+ "deficits", {k: round(v, 3) for k, v in deficits.items()},
602
+ "step_counts", dict(step_counts),
603
+ "repair_frac", round(repair_frac, 3),
604
+ "bt_frozen", bt_frozen,
605
+ flush=True)
606
+
607
+ with tf_summary_writer.as_default():
608
+ tf.summary.scalar("loss", metrics["loss"].mean(), step=step)
609
+ tf.summary.scalar("ce_loss", metrics["ce_loss"].mean(), step=step)
610
+ tf.summary.scalar("aux_bce_loss", metrics["aux_loss"].mean(), step=step)
611
+ tf.summary.scalar("curriculum_stage", curriculum.stage, step=step)
612
+ tf.summary.scalar("repair_mode", 1 if mode == "repair" else 0, step=step)
613
+ tf.summary.scalar("repair_stage", repair_stage or 0, step=step)
614
+ if frontier_acc >= 0:
615
+ tf.summary.scalar("frontier_depth_acc", frontier_acc, step=step)
616
+ tf.summary.scalar("eff_repair_margin", eff_margin, step=step)
617
+ for key in eval_metrics.keys():
618
+ tf.summary.scalar("eval_" + key,
619
+ np.array(eval_metrics[key]).mean(), step=step)
620
+ for lvl, v in per_level.items():
621
+ if v >= 0:
622
+ tf.summary.scalar(f"eval_acc_level_{lvl}", v, step=step)
623
+ for s, v in per_depth_all.items():
624
+ if v >= 0:
625
+ tf.summary.scalar(f"eval_cand_depth_{s}", v, step=step)
626
+
627
+ def _save_stage_ckpt(tag):
628
+ if config.save_checkpoint:
629
+ unrep = jax_utils.unreplicate(state)
630
+ checkpoints.save_checkpoint_multiprocess(
631
+ workdir, unrep, step, keep=ckpt_keep, overwrite=True)
632
+ checkpoints.save_checkpoint_multiprocess(
633
+ stage_ckpt_dir, unrep, step, keep=100,
634
+ overwrite=True, prefix=f"{tag}_")
635
+
636
+ # ---------------- Deficit-driven controller ----------------
637
+ # Freeze / duty-cycle: force frontier-only when budget exhausted.
638
+ duty_ok = (
639
+ max_repair_fraction <= 0
640
+ or repair_frac < max_repair_fraction)
641
+ if bt_frozen and mode == "repair":
642
+ print(f"[repair] step {step}: freeze_after="
643
+ f"{freeze_after_step}; leaving repair", flush=True)
644
+ mode = "frontier"
645
+ repair_stage = None
646
+ last_repair_return_step = step
647
+ if (not duty_ok) and mode == "repair":
648
+ print(f"[repair] step {step}: duty-cycle cap "
649
+ f"(repair_frac={repair_frac:.3f}>="
650
+ f"{max_repair_fraction}); return to frontier",
651
+ flush=True)
652
+ mode = "frontier"
653
+ repair_stage = None
654
+ last_repair_return_step = step
655
+
656
+ if mode == "frontier":
657
+ cooldown_ok = (
658
+ min_frontier_steps <= 0
659
+ or (step - last_repair_return_step) >= min_frontier_steps)
660
+ can_repair = (not bt_frozen) and duty_ok and cooldown_ok
661
+ worst = (most_deficient(i, per_depth, use_margin=eff_margin)
662
+ if can_repair else None)
663
+ if worst is not None:
664
+ mode = "repair"
665
+ repair_stage = worst
666
+ repair_started_at = step
667
+ repair_episode_start = step
668
+ print(f"[repair] step {step}: enter repair of depth "
669
+ f"{worst} (snapshot {worst} acc="
670
+ f"{per_depth.get(worst, -1.0):.3f} < grad "
671
+ f"{grad_acc.get(worst, -1.0):.3f} - "
672
+ f"eff_margin {eff_margin:.3f}; "
673
+ f"frontier_acc={frontier_acc:.3f})",
674
+ flush=True)
675
+ elif bt_frozen and most_deficient(
676
+ i, per_depth, use_margin=eff_margin) is not None:
677
+ if step % (config.eval_every_steps * 5) == 0:
678
+ print(f"[repair] step {step}: deficit present but "
679
+ f"BT frozen after {freeze_after_step}",
680
+ flush=True)
681
+ elif (not duty_ok) and most_deficient(
682
+ i, per_depth, use_margin=eff_margin) is not None:
683
+ if step % (config.eval_every_steps * 5) == 0:
684
+ print(f"[repair] step {step}: deficit present but "
685
+ f"duty-cycle cap "
686
+ f"(frac={repair_frac:.3f})",
687
+ flush=True)
688
+ elif (not cooldown_ok) and most_deficient(
689
+ i, per_depth, use_margin=eff_margin) is not None:
690
+ print(f"[repair] step {step}: deficit present but "
691
+ f"frontier cooldown "
692
+ f"({step - last_repair_return_step}/"
693
+ f"{min_frontier_steps}); staying on stage {i}",
694
+ flush=True)
695
+ elif curriculum.stage < curriculum.max_stage:
696
+ # Normal promotion (same rule as the standard loop).
697
+ steps_in_stage = step - stage_started_at
698
+ # A negative accuracy means "not measured this eval": it
699
+ # must not reset the plateau tracker nor satisfy the
700
+ # threshold/plateau rule. Only patience fires unmeasured.
701
+ measured = frontier_acc >= 0
702
+ if measured and frontier_acc > stage_best_acc + plateau_delta:
703
+ stage_best_acc = frontier_acc
704
+ stage_best_step = step
705
+ loc_now = _m("loc_acc")
706
+ loc_ready = (not instance_mode) or loc_now >= promote_loc_threshold
707
+ hit_threshold = (measured and loc_ready
708
+ and frontier_acc >= promote_threshold)
709
+ stalled = (measured and loc_ready and plateau_steps > 0
710
+ and (step - stage_best_step) >= plateau_steps)
711
+ patience_over = steps_in_stage >= promote_patience
712
+ if steps_in_stage >= min_stage_steps and (
713
+ hit_threshold or stalled or patience_over):
714
+ reason = ("threshold" if hit_threshold
715
+ else "plateau" if stalled else "patience")
716
+ # Record this stage's graduation accuracy BEFORE moving on.
717
+ grad_acc[curriculum.stage] = float(frontier_acc)
718
+ curriculum.stage += 1
719
+ stage_started_at = step
720
+ stage_best_acc = -1.0
721
+ stage_best_step = step
722
+ print(f"[curriculum] step {step}: promote to stage "
723
+ f"{curriculum.stage} ({reason}; graduated "
724
+ f"depth {curriculum.stage - 1} cand-set "
725
+ f"acc={frontier_acc:.3f})", flush=True)
726
+ _save_stage_ckpt(f"stage{curriculum.stage}")
727
+ elif (frontier_target_acc > 0
728
+ and 0.0 <= frontier_acc < frontier_target_acc
729
+ and step % (config.eval_every_steps * 5) == 0):
730
+ print(f"[frontier] step {step}: depth {i} "
731
+ f"acc={frontier_acc:.3f} "
732
+ f"< target {frontier_target_acc:.3f}; "
733
+ f"keeping frontier priority",
734
+ flush=True)
735
+ else: # mode == "repair"
736
+ r = repair_stage
737
+ cur = per_depth.get(r, -1.0)
738
+ ref = grad_acc.get(r, -1.0)
739
+ recovered = cur >= (ref - margin)
740
+ capped = (step - repair_started_at) >= max_repair_steps
741
+ if recovered or capped:
742
+ why = "recovered" if recovered else "cap"
743
+ print(f"[repair] step {step}: depth {r} done ({why}; "
744
+ f"snapshot acc={cur:.3f} vs grad {ref:.3f})",
745
+ flush=True)
746
+ _save_stage_ckpt(f"repair{r}")
747
+ # Re-scan: chain to the next most-deficient stage, or
748
+ # return to the frontier and credit repair time back to
749
+ # the frontier stage's patience clock.
750
+ nxt = None
751
+ if (not bt_frozen) and duty_ok:
752
+ nxt = most_deficient(
753
+ i, per_depth, use_margin=eff_margin)
754
+ if nxt is not None:
755
+ repair_stage = nxt
756
+ repair_started_at = step
757
+ print(f"[repair] step {step}: chain to stage {nxt}",
758
+ flush=True)
759
+ else:
760
+ mode = "frontier"
761
+ repair_stage = None
762
+ last_repair_return_step = step
763
+ stage_started_at += (step - repair_episode_start)
764
+
765
+ if config.save_checkpoint and step > 0 and step % config.save_every_steps == 0:
766
+ checkpoints.save_checkpoint_multiprocess(
767
+ workdir, jax_utils.unreplicate(state), step,
768
+ keep=ckpt_keep, overwrite=True)
769
+
770
+ if config.save_checkpoint:
771
+ checkpoints.save_checkpoint_multiprocess(
772
+ workdir, jax_utils.unreplicate(state), config.max_steps,
773
+ keep=ckpt_keep, overwrite=True)
code/train/trainer.py ADDED
@@ -0,0 +1,383 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # coding=utf-8
2
+ # Copyright 2024 The Google Research Authors.
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+
16
+ """Transformer LM trainer."""
17
+
18
+ import functools
19
+
20
+ from clu import periodic_actions
21
+ from flax.training import common_utils
22
+ from flax.training import train_state
23
+ import jax
24
+ from jax import numpy as jnp
25
+ import numpy as np
26
+ import optax
27
+ import ml_collections
28
+
29
+ from train import model
30
+
31
+
32
+ def get_state(config, net, initial_variables):
33
+ """Get the train state given an experiment config, a model and initial variables.
34
+
35
+ Args:
36
+ config: A ConfigDict containing the configuration for the experiment.
37
+ net: The model to use for training.
38
+ initial_variables: The initial variables for the model.
39
+
40
+ Returns:
41
+ A tuple containing the train state and the learning rate schedule.
42
+ """
43
+ # Learning rate schedule
44
+ lr_scheduler_fn = functools.partial(
45
+ lr_scheduler,
46
+ learning_rate=config.learning_rate,
47
+ warmup_tokens=config.warmup_tokens,
48
+ final_tokens=config.max_steps,
49
+ config=config,
50
+ )
51
+ # Optimizer
52
+ optim_fn = optax.adamw(
53
+ lr_scheduler_fn, weight_decay=config.weight_decay, b1=0.9, b2=0.95
54
+ )
55
+ # Clip the gradients to prevent exploding gradients.
56
+ optimizer = optax.chain(optax.clip_by_global_norm(1), optim_fn)
57
+
58
+ # Initialize the train state
59
+ state = train_state.TrainState.create(
60
+ apply_fn=net.apply, params=initial_variables["params"],
61
+ tx=optimizer
62
+ )
63
+
64
+ return state, lr_scheduler_fn
65
+
66
+ def lr_scheduler(
67
+ n_tokens: int, learning_rate: float, warmup_tokens: int, final_tokens: int,
68
+ config: ml_collections.ConfigDict,
69
+ ) -> float:
70
+ """Learning rate scheduler, adapted from Mikhail Grankin.
71
+
72
+ The learning rate schedule is cosine decay with a warmup period.
73
+
74
+ The learning rate starts at 0 and linearly increases to the given learning
75
+ rate over the warmup period. After the warmup period, the learning rate
76
+ decays according to a cosine schedule, with the given learning rate as the
77
+ maximum value.
78
+
79
+ Args:
80
+ n_tokens: The number of tokens processed so far.
81
+ learning_rate: The initial learning rate.
82
+ warmup_tokens: The number of tokens to warm up over.
83
+ final_tokens: The total number of tokens to process.
84
+ config: A ConfigDict containing the configuration for the learning rate
85
+ schedule.
86
+
87
+ Returns:
88
+ The learning rate at the given point in the schedule.
89
+ """
90
+ # Decay the learning rate based on our progress.
91
+ progress = (n_tokens - warmup_tokens) / max(
92
+ 1, final_tokens - warmup_tokens,
93
+ )
94
+ lr_mult = jnp.where(
95
+ n_tokens < warmup_tokens,
96
+ # Linear warmup.
97
+ n_tokens / jnp.fmax(1, warmup_tokens),
98
+ # Cosine learning rate decay.
99
+ jnp.fmax(config.end_lr_factor, 0.5 * (1.0 + jnp.cos(np.pi * progress))),
100
+ )
101
+ return learning_rate * lr_mult
102
+
103
+
104
+ def get_metrics_report_progress(config, workdir, writer):
105
+ """
106
+ Get the metrics for reporting progress during training.
107
+
108
+ Args:
109
+ config: The configuration for the experiment.
110
+ workdir: The directory for storing the logs.
111
+ writer: The writer object for recording the metrics.
112
+
113
+ Returns:
114
+ hooks: List of hooks for tracking progress.
115
+ report_progress: Object for reporting progress.
116
+ train_metrics: List of training metrics.
117
+ """
118
+ hooks = []
119
+
120
+ # Initialize the report progress object
121
+ report_progress = periodic_actions.ReportProgress(
122
+ num_train_steps=config.max_steps, writer=writer)
123
+
124
+ # Add metrics for profiling if the process index is 0
125
+ if jax.process_index() == 0:
126
+ hooks += [report_progress,
127
+ periodic_actions.Profile(logdir=workdir, num_profile_steps=5)]
128
+
129
+ # Initialize the list of training metrics
130
+ train_metrics = []
131
+
132
+ return hooks, report_progress, train_metrics
133
+
134
+
135
+ def get_input_start_index(batch, config):
136
+ inputs = jax.tree_util.tree_map(np.asarray, batch[0])
137
+ puzzles = jax.tree_util.tree_map(np.asarray, batch[1])
138
+ start_index = jax.tree_util.tree_map(np.asarray, batch[2])
139
+ levels = jax.tree_util.tree_map(np.asarray, batch[3])
140
+ cand_targets = jax.tree_util.tree_map(np.asarray, batch[4])
141
+ return inputs, puzzles, start_index, levels, cand_targets
142
+
143
+
144
+ def train_one_step(p_train_step, config, state, step, dropout_rngs, train_data_iter):
145
+ """
146
+ Single step of the training loop.
147
+
148
+ Args:
149
+ p_train_step: The training step function.
150
+ config: The experiment configuration.
151
+ state: The train state.
152
+ step: The step number.
153
+ dropout_rngs: The dropout random number generator.
154
+ train_data_iter: The iterator for the train data.
155
+
156
+ Returns:
157
+ The updated train state and train metrics.
158
+ """
159
+ with jax.profiler.StepTraceAnnotation("train", step_num=step):
160
+ # Get the next batch from the iterator
161
+ batch = next(train_data_iter)
162
+ # Extract the inputs, start index and difficulty level from the batch
163
+ inputs, _, start_index, levels, cand_targets = get_input_start_index(batch, config)
164
+ # Shard across the devices
165
+ inputs = common_utils.shard(jax.tree_util.tree_map(np.asarray, inputs))
166
+ start_index = common_utils.shard(jax.tree_util.tree_map(np.asarray, start_index))
167
+ levels = common_utils.shard(jax.tree_util.tree_map(np.asarray, levels))
168
+ # Explicit reshape (not common_utils.shard) so a zero-width slot dim
169
+ # (K=0 no-latent baseline: cand_targets is (bs, 0, 81)) shards without
170
+ # the ambiguous -1 inference. Identical to shard() when K>0.
171
+ cand_targets = np.asarray(cand_targets)
172
+ _nd = jax.local_device_count()
173
+ cand_targets = cand_targets.reshape(
174
+ (_nd, cand_targets.shape[0] // _nd) + cand_targets.shape[1:])
175
+
176
+ # Run the training step
177
+ state, metrics, _ = p_train_step(
178
+ state, inputs, start_index, levels, cand_targets, dropout_rng=dropout_rngs
179
+ )
180
+
181
+ return state, metrics
182
+
183
+
184
+ def build_latent_state(inputs, start_index, levels, config, num_passes,
185
+ apply_fn, rngs=None):
186
+ """Build the continuous latent thoughts (Coconut/ATC-style recurrence).
187
+
188
+ Pass 0 seeds z_1 from the last-layer hidden at the last clue token; pass j
189
+ reads the hidden at latent slot j to produce z_{j+1}. Slot j is active for
190
+ an example only when j < k, with k = clip(level - 2, 1, K) (difficulty-
191
+ matched latent budget). Gradients flow through all passes (full BPTT).
192
+
193
+ Returns (latent_vals, slot_pos, active_full).
194
+ """
195
+ num_slots = int(config.num_latent_slots)
196
+ bs = inputs.shape[0]
197
+ bidx = jnp.arange(bs)
198
+
199
+ si3 = 3 * start_index.reshape(-1) # (bs,)
200
+ slot_pos = si3[:, None] + jnp.arange(num_slots)[None, :] # (bs, K)
201
+ if getattr(config, "cand_slot_mode", "level") == "depth":
202
+ # Every slot the recurrence fills is active, uniformly over the batch.
203
+ k = jnp.full((bs,), max(min(num_passes, num_slots), 1), dtype=jnp.int32)
204
+ else:
205
+ k = jnp.clip(levels.reshape(-1) - 2, 1, num_slots) # (bs,)
206
+ active_full = jnp.arange(num_slots)[None, :] < k[:, None] # (bs, K)
207
+
208
+ latent_vals = jnp.zeros((bs, num_slots, config.emb_dim), dtype=config.dtype)
209
+
210
+ for j in range(num_passes):
211
+ act_j = active_full & (jnp.arange(num_slots)[None, :] < j)
212
+ _, hidden, _ = apply_fn(inputs, latent_vals, slot_pos, act_j, rngs)
213
+ src = si3 - 1 + j # j=0: last clue token; j>0: latent slot j-1
214
+ z = hidden[bidx, src]
215
+ latent_vals = latent_vals.at[:, j].set(z.astype(config.dtype))
216
+
217
+ return latent_vals, slot_pos, active_full
218
+
219
+
220
+ def train_step(state, batch, start_index, levels, cand_targets, config,
221
+ hyperparams, learning_rate_fn, num_passes, dropout_rng=None,
222
+ backtrack=False):
223
+ """One step of the training loop.
224
+
225
+ Args:
226
+ state: Train state.
227
+ batch: Input batch (bs, 3*81 + K) with latent placeholder slots.
228
+ start_index: Number of clue cells per example.
229
+ levels: Puzzle difficulty level (3..8) per example.
230
+ config: Model config.
231
+ hyperparams: Hyperparameter dictionary.
232
+ learning_rate_fn: Learning rate function.
233
+ num_passes: Number of latent recurrence passes (= curriculum stage).
234
+ dropout_rng: RNG used for dropout.
235
+
236
+ Returns:
237
+ A new train state, train metrics, and computed model predictions.
238
+ """
239
+ num_slots = int(config.num_latent_slots)
240
+ # Extract inputs and labels from the batch
241
+ inputs = batch[:, :-1]
242
+ label = batch[:, 1:]
243
+
244
+ # Update dropout_rng
245
+ dropout_rng = jax.random.fold_in(dropout_rng, state.step)
246
+ dropout_rng_dict = {"dropout": dropout_rng}
247
+
248
+ def loss_fn(params):
249
+ """Compute the loss function."""
250
+ net = model.TransformerLMHeadModel(config)
251
+
252
+ def apply_fn(x, lv, lp, la, rngs):
253
+ return net.apply({"params": params}, x, latent_values=lv,
254
+ latent_positions=lp, latent_active=la,
255
+ rngs=rngs)
256
+
257
+ latent_vals, slot_pos, active_full = build_latent_state(
258
+ inputs, start_index, levels, config, num_passes, apply_fn,
259
+ rngs=dropout_rng_dict)
260
+
261
+ pred_logits, _, cand_logits = apply_fn(
262
+ inputs, latent_vals, slot_pos, active_full, dropout_rng_dict)
263
+
264
+ label_one_hot = jax.nn.one_hot(label, num_classes=config.vocab_size)
265
+
266
+ # The variables label_one_hot and pred_logits both are 3-dimensional tensors with
267
+ # first axis corresponding to batch size, second correspondingn to sequence length
268
+ # and third corresponding to the row/column/value at a particular cell
269
+ assert label_one_hot.shape == pred_logits.shape, ("one hot label shape",
270
+ label_one_hot.shape,
271
+ label.shape,
272
+ pred_logits.shape)
273
+
274
+ # Calculate the cross-entropy loss along the last axis
275
+ pred_logits_sol = pred_logits[:, :, :]
276
+ label_one_hot_sol = label_one_hot[:, :, :]
277
+
278
+ ce_loss = optax.softmax_cross_entropy(
279
+ logits=pred_logits_sol[:, :, :], labels=label_one_hot_sol[:, :, :]
280
+ )
281
+ # assert ce_loss.ndim == 2, ("ce_loss", ce_loss.shape)
282
+
283
+ # Apply masking to the loss: supervise only the solution region,
284
+ # which now starts K latent slots after the clue block.
285
+ mask = np.repeat(
286
+ np.arange(len(ce_loss[0])).reshape(1, -1), len(ce_loss), axis=0
287
+ )
288
+ mask = (mask >= 3 * start_index + num_slots)
289
+
290
+ # Per-token mean (not per-example sum): keeps the LM CE on the same
291
+ # O(1) scale as the per-digit BCE below, so aux_cand_weight~1 actually
292
+ # balances the two instead of the candidate signal being swamped.
293
+ ce_denom = jnp.maximum(mask.sum(), 1.0)
294
+ avg_ce_loss = (ce_loss * mask).sum() / ce_denom
295
+
296
+ # ---- Auxiliary multi-candidate BCE loss on the latent slots ----
297
+ # cand_targets: (bs, K, 81) int bitmask (bit d-1 set <=> digit d is a
298
+ # candidate at that slot's stage; slot j already aligned to the puzzle's
299
+ # k=level-2 budget in the data pipeline). Expand to (bs,K,81,9) multi-hot.
300
+ aux_weight = float(getattr(hyperparams, "aux_cand_weight", 1.0))
301
+ if aux_weight == 0.0:
302
+ # Instance arm: the superposition lives in the varying targets, not
303
+ # in a set head. Drop the whole BCE graph so nothing but the LM CE
304
+ # shapes the latents.
305
+ zero = jnp.zeros((), dtype=pred_logits.dtype)
306
+ return avg_ce_loss, (pred_logits, avg_ce_loss, zero)
307
+
308
+ bits = jnp.arange(9)
309
+ cand_multi_hot = ((cand_targets[..., None].astype(jnp.int32)
310
+ >> bits) & 1).astype(cand_logits.dtype) # (bs,K,81,9)
311
+ # Positive-weighted BCE. Candidate masks are sparse (~1-3 of 9 digits
312
+ # "on"), so plain BCE collapses to predicting all-zeros. Up-weighting
313
+ # the positive (candidate-present) term by pos_weight counteracts the
314
+ # imbalance and forces the head to actually predict the candidate set.
315
+ pos_weight = float(getattr(hyperparams, "aux_pos_weight", 5.0))
316
+ log_p = jax.nn.log_sigmoid(cand_logits) # log sigmoid(x)
317
+ log_1mp = jax.nn.log_sigmoid(-cand_logits) # log(1 - sigmoid(x))
318
+ bce = -(pos_weight * cand_multi_hot * log_p
319
+ + (1.0 - cand_multi_hot) * log_1mp) # (bs,K,81,9)
320
+ # Only the empty cells are supervised: clue cells were sentinel-zeroed
321
+ # in the data pipeline, so any cell whose target row is all-zero is a
322
+ # clue and must not contribute. This makes the effective target
323
+ # (#empty cells) x 9 per puzzle rather than 81 x 9.
324
+ cell_mask = (cand_targets > 0).astype(cand_logits.dtype) # (bs, K, 81)
325
+ # Delta weighting: consecutive stages are near-duplicates (at K=12 only
326
+ # ~14% of cells change per stage, so ~97% of the target bits are copies
327
+ # of the previous slot). Weighting the unchanged cells below 1 stops the
328
+ # objective from being satisfied by echoing slot j-1. Slot 0 has no
329
+ # predecessor, so it is fully weighted.
330
+ delta_bg = float(getattr(hyperparams, "aux_delta_bg", 1.0))
331
+ if delta_bg != 1.0:
332
+ changed = jnp.concatenate(
333
+ [jnp.ones_like(cand_targets[:, :1], dtype=bool),
334
+ cand_targets[:, 1:] != cand_targets[:, :-1]], axis=1)
335
+ cell_w = cell_mask * (delta_bg + (1.0 - delta_bg)
336
+ * changed.astype(cand_logits.dtype))
337
+ else:
338
+ cell_w = cell_mask
339
+ cell_denom = jnp.maximum(cell_w.sum(axis=2), 1e-6) # (bs, K)
340
+ bce_per_cell = bce.mean(axis=3) # (bs, K, 81), mean over 9 digits
341
+ bce_per_slot = ((bce_per_cell * cell_w).sum(axis=2)
342
+ / cell_denom) # (bs, K), mean over empty cells
343
+ if backtrack:
344
+ # Strict stage-replay: supervise ONLY the stage-t readout = the last
345
+ # active latent slot per example (index k-1), whose candidate target
346
+ # is that stage's grid. Earlier slots and the LM CE are dropped, so
347
+ # this step purely re-derives "apply f exactly t times -> stage-t
348
+ # readout." Injection above still spans all active slots, so the
349
+ # recurrence reaching slot k-1 is intact.
350
+ k_per = active_full.sum(axis=1) # (bs,)
351
+ last_idx = (k_per - 1)[:, None] # (bs, 1)
352
+ slot_sel = ((jnp.arange(num_slots)[None, :] == last_idx)
353
+ & active_full) # (bs, K)
354
+ slot_active = slot_sel.astype(bce_per_slot.dtype)
355
+ else:
356
+ slot_active = active_full.astype(bce_per_slot.dtype) # (bs, K)
357
+ aux_denom = jnp.maximum(slot_active.sum(), 1.0)
358
+ avg_aux_loss = (bce_per_slot * slot_active).sum() / aux_denom
359
+
360
+ # Backtrack (replay) steps train only from the readout (no LM CE).
361
+ ce_term = 0.0 if backtrack else avg_ce_loss
362
+ total_loss = ce_term + aux_weight * avg_aux_loss
363
+
364
+ return total_loss, (pred_logits, avg_ce_loss, avg_aux_loss)
365
+
366
+ # Compute the learning rate and perform gradient descent
367
+ step = state.step
368
+ lr = learning_rate_fn(step)
369
+ (loss, aux), grads = jax.value_and_grad(loss_fn,
370
+ has_aux=True)(state.params)
371
+ pred_logits, ce_loss, aux_loss = aux
372
+ grads = jax.lax.pmean(grads, "batch")
373
+ new_state = state.apply_gradients(grads=grads)
374
+
375
+ # Update training metrics
376
+ metrics = {
377
+ "step": step, "loss": loss, "learning_rate": lr,
378
+ "ce_loss": ce_loss,
379
+ "aux_loss": aux_loss,
380
+ "pred_logits": pred_logits, "weights": inputs.shape[0]
381
+ }
382
+
383
+ return new_state, metrics, pred_logits
code/verify_superposition_instances.py ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Statistics for superposition-instance generation across all stages.
2
+
3
+ Reports, per stage: how many instances are produced per puzzle, how much of each
4
+ candidate set the instances cover, how many proposals get ruled out by the
5
+ dependencies, and how the dependency load changes as the grid resolves.
6
+ """
7
+ import argparse
8
+ import collections
9
+ import multiprocessing as mp
10
+
11
+ import numpy as np
12
+
13
+ import superposition_instances as SI
14
+
15
+ _MASKS = None
16
+ _ARGS = None
17
+
18
+
19
+ def _init(path, args):
20
+ global _MASKS, _ARGS
21
+ _MASKS = np.load(path, mmap_mode="r")
22
+ _ARGS = args
23
+
24
+
25
+ def _one(idx):
26
+ out = []
27
+ n_stages = _MASKS.shape[1]
28
+ for s in range(n_stages):
29
+ mask = np.array(_MASKS[idx, s]).astype(np.uint16)
30
+ r = SI.instances_for_stage(
31
+ mask, max_confine=_ARGS.max_confine,
32
+ max_instances=_ARGS.max_instances,
33
+ max_attempts=_ARGS.max_attempts, seed=idx * 100 + s,
34
+ max_repair=_ARGS.max_repair)
35
+ all_deps = SI.dependencies_from_mask(mask, max_confine=9)
36
+ r["n_deps_all"] = len(all_deps)
37
+ r.pop("instances")
38
+ r["stage"] = s
39
+ out.append(r)
40
+ return out
41
+
42
+
43
+ def main():
44
+ ap = argparse.ArgumentParser()
45
+ ap.add_argument("--masks", default="datasets_multicandidate_s12/test_cand_masks.npy")
46
+ ap.add_argument("--limit", type=int, default=200)
47
+ ap.add_argument("--max-confine", type=int, default=3)
48
+ ap.add_argument("--max-instances", type=int, default=32)
49
+ ap.add_argument("--max-attempts", type=int, default=200)
50
+ ap.add_argument("--max-repair", type=int, default=120)
51
+ ap.add_argument("--workers", type=int, default=32)
52
+ args = ap.parse_args()
53
+
54
+ masks = np.load(args.masks, mmap_mode="r")
55
+ n = min(args.limit, len(masks))
56
+ print(f"masks {masks.shape} from {args.masks}")
57
+ print(f"puzzles {n}, confinement threshold |S| <= {args.max_confine}, "
58
+ f"instance cap {args.max_instances}, attempt cap {args.max_attempts}")
59
+
60
+ with mp.Pool(args.workers, initializer=_init,
61
+ initargs=(args.masks, args)) as pool:
62
+ results = pool.map(_one, range(n), chunksize=4)
63
+
64
+ by_stage = collections.defaultdict(list)
65
+ for rows in results:
66
+ for r in rows:
67
+ by_stage[r["stage"]].append(r)
68
+
69
+ print()
70
+ print("=" * 112)
71
+ print("PER-STAGE INSTANCE GENERATION")
72
+ print("=" * 112)
73
+ print(f"{'stage':>5} {'empty':>6} {'width':>6} {'deps all':>9} "
74
+ f"{'deps enf':>9} {'kept':>6} {'ruled out':>10} {'reject %':>9} "
75
+ f"{'coverage':>9} {'capped %':>9} {'dup/inst':>9}")
76
+ print("-" * 112)
77
+ for s in sorted(by_stage):
78
+ rs = by_stage[s]
79
+ g = lambda k: np.mean([r[k] for r in rs])
80
+ rej = np.sum([r["n_rejected"] for r in rs])
81
+ att = np.sum([r["n_attempts"] for r in rs])
82
+ print(f"{s:>5} {g('n_empty'):>6.1f} {g('mean_width'):>6.2f} "
83
+ f"{g('n_deps_all'):>9.1f} {g('n_deps'):>9.1f} "
84
+ f"{g('n_instances'):>6.1f} {g('n_rejected'):>10.1f} "
85
+ f"{100*rej/max(att,1):>8.1f}% {100*g('coverage'):>8.1f}% "
86
+ f"{100*np.mean([r['hit_cap'] for r in rs]):>8.1f}% "
87
+ f"{g('mean_dups'):>9.2f}")
88
+
89
+ tot_inst = np.sum([r["n_instances"] for rs in by_stage.values() for r in rs])
90
+ tot_rej = np.sum([r["n_rejected"] for rs in by_stage.values() for r in rs])
91
+ print()
92
+ print(f"per puzzle across all {len(by_stage)} stages: "
93
+ f"{tot_inst/n:.1f} instances kept, {tot_rej/n:.1f} ruled out")
94
+ print(f"dataset multiplier vs one row per puzzle: {tot_inst/n:.1f}x")
95
+
96
+
97
+ if __name__ == "__main__":
98
+ main()