Avra98 commited on
Commit
6a1771b
·
verified ·
1 Parent(s): c6ea49a

sync training code: stage-1 instance-epoch sampler, multi-stage run, superposition metrics

Browse files
code/wavecurriculum_run/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/wavecurriculum_run/hf_push_login.py ADDED
@@ -0,0 +1,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Upload scratch/gandalf staging files to Hugging Face.
3
+
4
+ Runs on the login node (python3 + huggingface_hub). The feanor sidecar only
5
+ needs to land files in --stage-dir; this process does the HTTP/LFS upload.
6
+ """
7
+ import argparse
8
+ import json
9
+ import os
10
+ import sys
11
+ import time
12
+ import traceback
13
+
14
+
15
+ def _load_json(path):
16
+ try:
17
+ with open(path) as f:
18
+ return json.load(f)
19
+ except Exception:
20
+ return {}
21
+
22
+
23
+ def _write_json(path, payload):
24
+ tmp = path + ".tmp"
25
+ with open(tmp, "w") as f:
26
+ json.dump(payload, f, indent=2, sort_keys=True)
27
+ f.write("\n")
28
+ os.replace(tmp, path)
29
+
30
+
31
+ _KEEP_HB = (
32
+ "event", "step", "stage", "loss",
33
+ "val_acc", "val_mass", "val_excess", "val_spread",
34
+ "loc_wave", "loc_coverage", "loc_lcs", "loc_dup",
35
+ )
36
+
37
+
38
+ def _slim_heartbeat(src, dest):
39
+ raw = _load_json(src)
40
+ slim = {k: raw[k] for k in _KEEP_HB if k in raw}
41
+ _write_json(dest, slim)
42
+ return dest
43
+
44
+
45
+ def _slim_log(src, dest):
46
+ """Keep only eval / promote lines: step, stage, loss, accuracies."""
47
+ keep = []
48
+ try:
49
+ with open(src, errors="replace") as f:
50
+ for line in f:
51
+ s = line.strip()
52
+ if not s:
53
+ continue
54
+ if s.startswith("[curriculum]"):
55
+ keep.append(s + "\n")
56
+ continue
57
+ # "2000 stage 1 loss 1.47 loc_acc 0.03 val_acc 0.11 inset 0.66"
58
+ if " loss " in s and "stage" in s and not s.startswith("I"):
59
+ keep.append(s + "\n")
60
+ except OSError:
61
+ return None
62
+ with open(dest, "w") as f:
63
+ f.writelines(keep)
64
+ return dest
65
+
66
+
67
+ def _upload(api, token, local, repo, dest):
68
+ api.upload_file(
69
+ path_or_fileobj=local,
70
+ path_in_repo=dest,
71
+ repo_id=repo,
72
+ repo_type="model",
73
+ token=token,
74
+ commit_message=f"sync {dest}",
75
+ )
76
+ print(f"[hf_push] uploaded {dest} ({os.path.getsize(local)} bytes)", flush=True)
77
+
78
+
79
+ def sync_once(args, state, api, token):
80
+ prefix = args.prefix.strip("/")
81
+ repo = args.repo
82
+
83
+ for local, dest, key in (
84
+ (args.heartbeat, f"{prefix}/heartbeat.json", "heartbeat"),
85
+ (args.log, f"{prefix}/train.log", "log"),
86
+ ):
87
+ if not local or not os.path.isfile(local):
88
+ continue
89
+ mtime = os.path.getmtime(local)
90
+ size = os.path.getsize(local)
91
+ last = state.get(key, {})
92
+ min_age = 0 if key == "heartbeat" else 300
93
+ due = (time.time() - float(last.get("uploaded_at", 0))) >= min_age
94
+ if due and (mtime > float(last.get("mtime", 0)) or size != last.get("size", -1)):
95
+ upload_path = local
96
+ if key == "heartbeat":
97
+ upload_path = _slim_heartbeat(
98
+ local, os.path.join(os.path.dirname(local) or ".",
99
+ "_hf_heartbeat_slim.json"))
100
+ elif key == "log":
101
+ upload_path = _slim_log(
102
+ local, os.path.join(os.path.dirname(local) or ".",
103
+ "_hf_train_slim.log"))
104
+ if upload_path:
105
+ _upload(api, token, upload_path, repo, dest)
106
+ state[key] = {"mtime": mtime, "size": size, "uploaded_at": time.time()}
107
+
108
+ if args.stage_dir and os.path.isdir(args.stage_dir):
109
+ for name in sorted(os.listdir(args.stage_dir)):
110
+ if name in ("hf_push_state.json", "heartbeat.json"):
111
+ continue
112
+ if not name.endswith(".tar") and name not in ("latest.json",):
113
+ continue
114
+ local = os.path.join(args.stage_dir, name)
115
+ if not os.path.isfile(local):
116
+ continue
117
+ # step_/stage_ names are unique per checkpoint, so once uploaded
118
+ # they never need revisiting. latest.tar/latest.json keep the same
119
+ # name and are rewritten every save, so skipping them by name would
120
+ # freeze the rolling checkpoint at whichever step happened to land
121
+ # first; track those by mtime instead.
122
+ rolling = name in ("latest.tar", "latest.json")
123
+ mtime = os.path.getmtime(local)
124
+ if rolling:
125
+ if mtime <= float(state.get("rolling", {}).get(name, 0)):
126
+ continue
127
+ elif name in state.get("staged", []):
128
+ continue
129
+ dest = f"{prefix}/{name}"
130
+ if name.startswith("stage"):
131
+ dest = f"{prefix}/stages/{name}"
132
+ elif name.startswith("step_"):
133
+ dest = f"{prefix}/steps/{name}"
134
+ _upload(api, token, local, repo, dest)
135
+ if rolling:
136
+ state.setdefault("rolling", {})[name] = mtime
137
+ continue # keep the file; the sidecar overwrites it in place
138
+ state.setdefault("staged", []).append(name)
139
+ # Drop the local copy after a successful upload to save quota.
140
+ try:
141
+ os.remove(local)
142
+ except OSError:
143
+ pass
144
+ return state
145
+
146
+
147
+ def main():
148
+ ap = argparse.ArgumentParser()
149
+ ap.add_argument("--repo", default="Avra98/Sudoku_superposition")
150
+ ap.add_argument("--prefix", default="runs/w12_inst_latent")
151
+ ap.add_argument("--heartbeat", default="")
152
+ ap.add_argument("--log", default="")
153
+ ap.add_argument("--stage-dir", default="")
154
+ ap.add_argument("--token-file", default="/scratch/users/gatmiry/.hf_token")
155
+ ap.add_argument("--state", default="")
156
+ ap.add_argument("--interval", type=int, default=60)
157
+ ap.add_argument("--once", action="store_true")
158
+ args = ap.parse_args()
159
+
160
+ token = ""
161
+ if args.token_file and os.path.isfile(args.token_file):
162
+ with open(args.token_file) as f:
163
+ token = f.read().strip()
164
+ token = token or os.environ.get("HF_TOKEN", "")
165
+ if not token:
166
+ print("[hf_push] no token", file=sys.stderr)
167
+ return 1
168
+
169
+ from huggingface_hub import HfApi
170
+ api = HfApi(token=token)
171
+ state_path = args.state or os.path.join(
172
+ args.stage_dir or "/tmp/sudoku_hf_uploads", "hf_push_state.json")
173
+ os.makedirs(os.path.dirname(state_path) or ".", exist_ok=True)
174
+ state = _load_json(state_path)
175
+ print(f"[hf_push] watching heartbeat={args.heartbeat} log={args.log} "
176
+ f"stage={args.stage_dir}", flush=True)
177
+ while True:
178
+ try:
179
+ state = sync_once(args, state, api, token)
180
+ _write_json(state_path, state)
181
+ except Exception:
182
+ traceback.print_exc()
183
+ print("[hf_push] cycle failed; will retry", flush=True)
184
+ if args.once:
185
+ break
186
+ time.sleep(max(5, args.interval))
187
+ return 0
188
+
189
+
190
+ if __name__ == "__main__":
191
+ sys.exit(main())
code/wavecurriculum_run/hf_sync.py ADDED
@@ -0,0 +1,253 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Sidecar: copy logs to scratch and push heartbeat/checkpoints to Hugging Face.
3
+
4
+ Does not train. Safe to run in a loop next to the job. Large Orbax dirs are
5
+ tarred, then uploaded as a single file so `latest.tar` can be overwritten.
6
+ """
7
+ import argparse
8
+ import json
9
+ import os
10
+ import socket
11
+ import subprocess
12
+ import sys
13
+ import time
14
+ import traceback
15
+
16
+
17
+ def _load_json(path):
18
+ try:
19
+ with open(path) as f:
20
+ return json.load(f)
21
+ except Exception:
22
+ return {}
23
+
24
+
25
+ def _write_json(path, payload):
26
+ tmp = path + ".tmp"
27
+ with open(tmp, "w") as f:
28
+ json.dump(payload, f, indent=2, sort_keys=True)
29
+ f.write("\n")
30
+ os.replace(tmp, path)
31
+
32
+
33
+ def _latest_ckpt_dir(workdir):
34
+ best = None
35
+ best_step = -1
36
+ try:
37
+ names = os.listdir(workdir)
38
+ except FileNotFoundError:
39
+ return None, -1
40
+ for name in names:
41
+ if not name.startswith("checkpoint_"):
42
+ continue
43
+ if name.endswith(".orbax-checkpoint-tmp") or ".orbax-checkpoint-tmp" in name:
44
+ continue
45
+ path = os.path.join(workdir, name)
46
+ if not os.path.isdir(path):
47
+ continue
48
+ try:
49
+ step = int(name.split("_", 1)[1])
50
+ except ValueError:
51
+ continue
52
+ if step > best_step:
53
+ best_step = step
54
+ best = path
55
+ return best, best_step
56
+
57
+
58
+ def _stage_ckpt_dirs(stage_dir):
59
+ out = []
60
+ if not os.path.isdir(stage_dir):
61
+ return out
62
+ for name in sorted(os.listdir(stage_dir)):
63
+ path = os.path.join(stage_dir, name)
64
+ if os.path.isdir(path) and not name.endswith(".tmp"):
65
+ out.append(path)
66
+ return out
67
+
68
+
69
+ def _tar(src_dir, dest_tar):
70
+ parent = os.path.dirname(os.path.abspath(src_dir))
71
+ base = os.path.basename(src_dir.rstrip("/"))
72
+ tmp = dest_tar + ".tmp"
73
+ subprocess.check_call(
74
+ ["tar", "-C", parent, "-cf", tmp, base],
75
+ stdout=subprocess.DEVNULL,
76
+ stderr=subprocess.DEVNULL,
77
+ )
78
+ os.replace(tmp, dest_tar)
79
+ return dest_tar
80
+
81
+
82
+ def _hf_api(token):
83
+ try:
84
+ from huggingface_hub import HfApi
85
+ except Exception as exc:
86
+ print(f"[hf_sync] huggingface_hub unavailable ({exc}); "
87
+ f"will stage files for the login-node pusher", flush=True)
88
+ return None
89
+ return HfApi(token=token)
90
+
91
+
92
+ def _rsync_to_login(local, remote_dir):
93
+ """Copy a file to gandalf so the login-node pusher can upload it."""
94
+ if not remote_dir or not os.path.isfile(local):
95
+ return False
96
+ dest = remote_dir.rstrip("/") + "/"
97
+ cmd = ["rsync", "-a", "-e",
98
+ "ssh -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new",
99
+ local, dest]
100
+ try:
101
+ subprocess.check_call(cmd, stdout=subprocess.DEVNULL,
102
+ stderr=subprocess.DEVNULL)
103
+ print(f"[hf_sync] staged {os.path.basename(local)} -> {dest}", flush=True)
104
+ return True
105
+ except Exception as exc:
106
+ print(f"[hf_sync] rsync to login failed: {exc}", flush=True)
107
+ return False
108
+
109
+
110
+ def _upload_file(api, local, repo, dest, token):
111
+ if api is None:
112
+ print(f"[hf_sync] skip upload {dest}: huggingface_hub missing", flush=True)
113
+ return False
114
+ api.upload_file(
115
+ path_or_fileobj=local,
116
+ path_in_repo=dest,
117
+ repo_id=repo,
118
+ repo_type="model",
119
+ token=token,
120
+ commit_message=f"sync {dest}",
121
+ )
122
+ print(f"[hf_sync] uploaded {dest} ({os.path.getsize(local)} bytes)", flush=True)
123
+ return True
124
+
125
+
126
+ def sync_once(args, state):
127
+ workdir = os.path.abspath(args.workdir)
128
+ scratch_log = args.scratch_log
129
+ if args.log and os.path.isfile(args.log) and scratch_log:
130
+ os.makedirs(os.path.dirname(scratch_log), exist_ok=True)
131
+ subprocess.call(["rsync", "-a", args.log, scratch_log])
132
+
133
+ hb_src = os.path.join(workdir, "heartbeat.json")
134
+ if os.path.isfile(hb_src) and scratch_log:
135
+ hb_scratch = os.path.join(os.path.dirname(scratch_log), "w12_inst_latent_heartbeat.json")
136
+ subprocess.call(["rsync", "-a", hb_src, hb_scratch])
137
+
138
+ token = (os.environ.get("HF_TOKEN")
139
+ or os.environ.get("HUGGING_FACE_HUB_TOKEN")
140
+ or "")
141
+ if args.token_file and os.path.isfile(args.token_file):
142
+ with open(args.token_file) as f:
143
+ token = f.read().strip() or token
144
+ if not token:
145
+ print("[hf_sync] no HF token; scratch copy only", flush=True)
146
+ return state
147
+
148
+ api = _hf_api(token)
149
+ prefix = args.prefix.strip("/")
150
+ repo = args.repo
151
+
152
+ if os.path.isfile(hb_src):
153
+ mtime = os.path.getmtime(hb_src)
154
+ if mtime > float(state.get("heartbeat_mtime", 0)):
155
+ if _upload_file(api, hb_src, repo, f"{prefix}/heartbeat.json", token):
156
+ state["heartbeat_mtime"] = mtime
157
+ _rsync_to_login(hb_src, args.login_stage)
158
+
159
+ if args.log and os.path.isfile(args.log):
160
+ mtime = os.path.getmtime(args.log)
161
+ size = os.path.getsize(args.log)
162
+ last = state.get("log", {})
163
+ due = (time.time() - float(last.get("uploaded_at", 0))) >= 300
164
+ if due and (mtime > float(last.get("mtime", 0)) or size != last.get("size", -1)):
165
+ if _upload_file(api, args.log, repo, f"{prefix}/train.log", token):
166
+ state["log"] = {"mtime": mtime, "size": size,
167
+ "uploaded_at": time.time()}
168
+
169
+ ready = _load_json(os.path.join(workdir, "ckpt_ready.json"))
170
+ ckpt_dir, ckpt_step = _latest_ckpt_dir(workdir)
171
+ if ckpt_dir and ckpt_step > int(state.get("latest_step", -1)):
172
+ tar_path = os.path.join(args.tar_dir, "latest.tar")
173
+ print(f"[hf_sync] tarring {ckpt_dir} -> {tar_path}", flush=True)
174
+ _tar(ckpt_dir, tar_path)
175
+ uploaded = _upload_file(api, tar_path, repo, f"{prefix}/latest.tar", token)
176
+ staged = _rsync_to_login(tar_path, args.login_stage)
177
+ if uploaded or staged:
178
+ state["latest_step"] = ckpt_step
179
+ meta = {
180
+ "step": ckpt_step,
181
+ "event": ready.get("event", "periodic"),
182
+ "stage": ready.get("stage"),
183
+ "host": socket.gethostname(),
184
+ "job_id": os.environ.get("SLURM_JOB_ID", ""),
185
+ "src": ckpt_dir,
186
+ }
187
+ meta_path = os.path.join(args.tar_dir, "latest.json")
188
+ _write_json(meta_path, meta)
189
+ _upload_file(api, meta_path, repo, f"{prefix}/latest.json", token)
190
+ _rsync_to_login(meta_path, args.login_stage)
191
+ if uploaded:
192
+ state["latest_step"] = ckpt_step
193
+ keep = bool(ready.get("keep_snapshot")) or (ckpt_step > 0 and ckpt_step % 50000 == 0)
194
+ if keep and ckpt_step not in set(state.get("snapshots", [])):
195
+ dest = f"{prefix}/steps/step_{ckpt_step}.tar"
196
+ snap = os.path.join(args.tar_dir, f"step_{ckpt_step}.tar")
197
+ if snap != tar_path:
198
+ subprocess.call(["/bin/cp", "-f", tar_path, snap])
199
+ if _upload_file(api, tar_path, repo, dest, token):
200
+ state.setdefault("snapshots", []).append(ckpt_step)
201
+ _rsync_to_login(snap if os.path.isfile(snap) else tar_path, args.login_stage)
202
+
203
+ stage_dir = os.path.join(workdir, "stage_ckpts")
204
+ uploaded_stages = set(state.get("stages", []))
205
+ for path in _stage_ckpt_dirs(stage_dir):
206
+ name = os.path.basename(path)
207
+ if name in uploaded_stages:
208
+ continue
209
+ tar_path = os.path.join(args.tar_dir, f"{name}.tar")
210
+ print(f"[hf_sync] tarring stage {path}", flush=True)
211
+ _tar(path, tar_path)
212
+ if _upload_file(api, tar_path, repo, f"{prefix}/stages/{name}.tar", token):
213
+ uploaded_stages.add(name)
214
+ _rsync_to_login(tar_path, args.login_stage)
215
+ state["stages"] = sorted(uploaded_stages)
216
+ return state
217
+
218
+
219
+ def main():
220
+ ap = argparse.ArgumentParser()
221
+ ap.add_argument("--workdir", required=True)
222
+ ap.add_argument("--log", default="")
223
+ ap.add_argument("--scratch-log", default="")
224
+ ap.add_argument("--repo", default="Avra98/Sudoku_superposition")
225
+ ap.add_argument("--prefix", default="runs/w12_inst_latent")
226
+ ap.add_argument("--token-file", default="")
227
+ ap.add_argument("--state", default="")
228
+ ap.add_argument("--tar-dir", default="")
229
+ ap.add_argument("--interval", type=int, default=60)
230
+ ap.add_argument("--login-stage", default="gandalf.berkeley.edu:/tmp/sudoku_hf_uploads")
231
+ ap.add_argument("--once", action="store_true")
232
+ args = ap.parse_args()
233
+ args.tar_dir = args.tar_dir or os.path.join(os.path.abspath(args.workdir), "_hf_tars")
234
+ os.makedirs(args.tar_dir, exist_ok=True)
235
+ state_path = args.state or os.path.join(args.tar_dir, "hf_sync_state.json")
236
+ state = _load_json(state_path)
237
+ print(f"[hf_sync] host={socket.gethostname()} workdir={args.workdir}",
238
+ flush=True)
239
+ while True:
240
+ try:
241
+ state = sync_once(args, state)
242
+ _write_json(state_path, state)
243
+ except Exception:
244
+ traceback.print_exc()
245
+ print("[hf_sync] cycle failed; will retry", flush=True)
246
+ if args.once:
247
+ break
248
+ time.sleep(max(5, args.interval))
249
+ return 0
250
+
251
+
252
+ if __name__ == "__main__":
253
+ sys.exit(main())
code/wavecurriculum_run/sbatch_instance_latent.sh ADDED
@@ -0,0 +1,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ echo "[$(date)] /tmp before cleanup:"
35
+ df -h /tmp | tail -1
36
+ # Drop leftover run dirs from dead jobs; keep env + instance/mask caches.
37
+ rm -rf /tmp/sudoku_wave_runs
38
+ mkdir -p "${LOCAL_LOG}"
39
+ echo "[$(date)] /tmp after cleanup:"
40
+ df -h /tmp | tail -1
41
+
42
+ SCP_OPTS="-o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new"
43
+ [ -f "${HOME}/.ssh/id_ed25519_berkeley" ] && SCP_OPTS="${SCP_OPTS} -i ${HOME}/.ssh/id_ed25519_berkeley"
44
+
45
+ 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
46
+ echo "[$(date)] reusing ${ENV_LOCAL}"
47
+ else
48
+ echo "[$(date)] fetching env tarball"
49
+ rm -rf "${ENV_LOCAL}"
50
+ scp ${SCP_OPTS} "gandalf.berkeley.edu:${TARBALL_GANDALF}" "${TARBALL_LOCAL}"
51
+ tar xzf "${TARBALL_LOCAL}" -C /tmp
52
+ rm -f "${TARBALL_LOCAL}"
53
+ fi
54
+
55
+ export PY=${ENV_LOCAL}/bin/python
56
+ export LD_LIBRARY_PATH=\
57
+ ${ENV_LOCAL}/lib/python3.9/site-packages/nvidia/cudnn/lib:\
58
+ ${ENV_LOCAL}/lib/python3.9/site-packages/nvidia/cublas/lib:\
59
+ ${ENV_LOCAL}/lib/python3.9/site-packages/nvidia/cuda_runtime/lib:\
60
+ ${ENV_LOCAL}/lib/python3.9/site-packages/nvidia/cuda_nvrtc/lib:\
61
+ ${ENV_LOCAL}/lib/python3.9/site-packages/nvidia/nccl/lib:\
62
+ ${LD_LIBRARY_PATH:-}
63
+
64
+ ${PY} -u -c "import jax; print(jax.__version__, jax.devices(), jax.default_backend())"
65
+
66
+ # ---- Stage instance files onto node-local /tmp (scratch cannot hold 8 G) ----
67
+ need_inst=0
68
+ for f in train_assignments.npy train_starts.npy train_counts.npy \
69
+ test_assignments.npy test_starts.npy test_counts.npy; do
70
+ [ -s "${INST_DIR}/${f}" ] || need_inst=1
71
+ done
72
+ if [ "${need_inst}" = 1 ]; then
73
+ echo "[$(date)] pulling instances from ${GANDALF_INST}"
74
+ mkdir -p "${INST_DIR}"
75
+ rsync -a --progress -e "ssh ${SCP_OPTS}" \
76
+ "${GANDALF_INST}/" "${INST_DIR}/"
77
+ fi
78
+ for f in train_assignments.npy train_starts.npy train_counts.npy \
79
+ test_assignments.npy test_starts.npy test_counts.npy; do
80
+ [ -s "${INST_DIR}/${f}" ] || { echo "missing ${INST_DIR}/${f}" >&2; exit 1; }
81
+ done
82
+
83
+ need_cand=0
84
+ for f in train_cand_masks.npy test_cand_masks.npy; do
85
+ [ -s "${CAND_DIR}/${f}" ] || need_cand=1
86
+ done
87
+ if [ "${need_cand}" = 1 ]; then
88
+ echo "[$(date)] pulling s12 masks from ${GANDALF_CAND}"
89
+ mkdir -p "${CAND_DIR}"
90
+ rsync -a -e "ssh ${SCP_OPTS}" "${GANDALF_CAND}/" "${CAND_DIR}/" || true
91
+ fi
92
+ for f in train_cand_masks.npy test_cand_masks.npy; do
93
+ [ -s "${CAND_DIR}/${f}" ] || { echo "missing ${CAND_DIR}/${f}" >&2; exit 1; }
94
+ done
95
+
96
+ # ---- Exact same recipe as slimgpu w12_inst_latent ----
97
+ export SUDOKU_RESUME=0
98
+ export SUDOKU_START_STAGE=1
99
+ export SUDOKU_MAX_STAGE=12
100
+ export SUDOKU_LATENT_SLOTS=12
101
+ export SUDOKU_RECURRENT=1
102
+ export SUDOKU_BACKTRACK=0
103
+ export SUDOKU_CAND_SLOT_MODE=depth
104
+ export SUDOKU_PASSES_PER_STAGE=1
105
+ export SUDOKU_AUX_WEIGHT=0.0
106
+ export SUDOKU_LEVEL_BALANCED=0
107
+ export SUDOKU_DATA_CURRICULUM=none
108
+ export SUDOKU_PLATEAU_STEPS=20000
109
+ export SUDOKU_PLATEAU_DELTA=0.005
110
+ # 12 stages x 40k is 480k of the 800k budget, so the hard cap can no longer
111
+ # consume the whole run the way 80k did.
112
+ export SUDOKU_PATIENCE=40000
113
+ export SUDOKU_MIN_STAGE_STEPS=8000
114
+ # Stage t -> t+1 requires val_mass >= 0.85: the probability mass the value head
115
+ # puts on stage t's candidate set, measured teacher-forced. Chance is ~0.41 on
116
+ # stage-0 sets. This replaces the old in-set rate, which was conditioned on an
117
+ # exact positional location match and so was estimated from ~4% of steps.
118
+ export SUDOKU_PROMOTE_ACC=0.85
119
+ # Both extra gates start OFF: this run is the one that tells us the reachable
120
+ # range of loc_wave and val_excess. Turn them on once we can read the scale.
121
+ export SUDOKU_PROMOTE_LOC_WAVE=0.0
122
+ export SUDOKU_PROMOTE_VAL_EXCESS=0.0
123
+ export SUDOKU_MAX_STEPS="${SUDOKU_MAX_STEPS:-800000}"
124
+ export SUDOKU_EVAL_EVERY=2000
125
+ export SUDOKU_SAVE_EVERY=10000
126
+ export SUDOKU_CKPT_KEEP=2
127
+ export SUDOKU_LR=0.0002
128
+ export SUDOKU_DROPOUT=0.2
129
+ export SUDOKU_WD=0.005
130
+ export SUDOKU_TRAIN_PATH="${SCRATCH_ROOT}/sudoku-code/datasets/train_sudoku_puzzles.npy"
131
+ export SUDOKU_TEST_PATH="${SCRATCH_ROOT}/sudoku-code/datasets/test_sudoku_puzzles.npy"
132
+ export SUDOKU_TRAIN_CAND="${CAND_DIR}/train_cand_masks.npy"
133
+ export SUDOKU_TEST_CAND="${CAND_DIR}/test_cand_masks.npy"
134
+ export SUDOKU_INSTANCE_DIR="${INST_DIR}"
135
+ # Leave a little H200 headroom so eval + sidecar tar do not OOM the node.
136
+ export XLA_PYTHON_CLIENT_MEM_FRACTION=0.85
137
+
138
+ HF_TOKEN_FILE="${HF_TOKEN_FILE:-/scratch/users/gatmiry/.hf_token}"
139
+ if [ -z "${HF_TOKEN:-}" ] && [ -s "${HF_TOKEN_FILE}" ]; then
140
+ HF_TOKEN="$(cat "${HF_TOKEN_FILE}")"
141
+ export HF_TOKEN
142
+ fi
143
+ export HUGGING_FACE_HUB_TOKEN="${HF_TOKEN:-}"
144
+ HF_PKGS="${HF_PKGS:-/scratch/users/gatmiry/hf_pkgs}"
145
+ TRAIN_LOG="${RUN_DIR}/logs/w12_inst_latent.log"
146
+ SYNC_LOG="${RUN_DIR}/logs/hf_sync_w12_inst_latent.log"
147
+
148
+ # Need room for 2 rolling ckpts (~1 GB) plus a tar in flight.
149
+ tmp_avail_kb=$(df -Pk /tmp | awk 'NR==2{print $4}')
150
+ echo "[$(date)] /tmp avail ${tmp_avail_kb} KB"
151
+ if [ "${tmp_avail_kb}" -lt 3000000 ]; then
152
+ echo "ERROR: /tmp has less than 3G free; refusing to start" >&2
153
+ df -h /tmp
154
+ du -sh /tmp/* 2>/dev/null | sort -h | tail -20 >&2 || true
155
+ exit 1
156
+ fi
157
+
158
+ cd "${RUN_DIR}"
159
+ # huggingface_hub lives on scratch (feanor /tmp is too full to pip-install).
160
+ PYTHONPATH="${HF_PKGS}${PYTHONPATH:+:${PYTHONPATH}}" ${PY} -u "${RUN_DIR}/hf_sync.py" \
161
+ --workdir "${LOCAL_LOG}" \
162
+ --log "${TRAIN_LOG}" \
163
+ --scratch-log "${TRAIN_LOG}" \
164
+ --repo Avra98/Sudoku_superposition \
165
+ --prefix runs/w12_inst_latent \
166
+ --token-file "${HF_TOKEN_FILE}" \
167
+ --interval 60 \
168
+ > "${SYNC_LOG}" 2>&1 &
169
+ SYNC_PID=$!
170
+
171
+ echo "[$(date)] starting w12_inst_latent from scratch"
172
+ echo " K=12 recurrent=1 bt=0 aux=0 instance_dir=${INST_DIR}"
173
+ echo " workdir=${LOCAL_LOG} log=${TRAIN_LOG}"
174
+ echo " hf=Avra98/Sudoku_superposition/runs/w12_inst_latent"
175
+ # Log goes to scratch immediately so a /tmp-full crash still leaves a traceback.
176
+ CUDA_VISIBLE_DEVICES=0 ${PY} -u -m train.main \
177
+ --workdir="${LOCAL_LOG}" --exp_name="w12_inst_latent" \
178
+ > "${TRAIN_LOG}" 2>&1
179
+ EC=$?
180
+ kill ${SYNC_PID} 2>/dev/null || true
181
+ wait ${SYNC_PID} 2>/dev/null || true
182
+ PYTHONPATH="${HF_PKGS}${PYTHONPATH:+:${PYTHONPATH}}" ${PY} -u "${RUN_DIR}/hf_sync.py" --once \
183
+ --workdir "${LOCAL_LOG}" \
184
+ --log "${TRAIN_LOG}" \
185
+ --scratch-log "${TRAIN_LOG}" \
186
+ --repo Avra98/Sudoku_superposition \
187
+ --prefix runs/w12_inst_latent \
188
+ --token-file "${HF_TOKEN_FILE}" \
189
+ >> "${SYNC_LOG}" 2>&1 || true
190
+ echo "[$(date)] w12_inst_latent exit ${EC}"
191
+ exit ${EC}
code/wavecurriculum_run/sbatch_multistage_super.sh ADDED
@@ -0,0 +1,188 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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_multi
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
+ # Multi-stage superposition run, warm-started from the stage-1 checkpoint of
13
+ # w12_s1_super and walking stages 2..12. Runs on a SECOND GPU; the stage-1 job
14
+ # keeps going untouched.
15
+ #
16
+ # Every stage repeats the stage-1 recipe: that stage's own (puzzle, instance)
17
+ # pairs are enumerated and walked in shuffled passes, so each instance of each
18
+ # puzzle is seen at least 5 times before the stage can be left. The pool is
19
+ # rebuilt on promotion because each stage has its own instances.
20
+ #
21
+ # Promotion needs BOTH superposition gates to clear, no plateau and no patience:
22
+ # mass* >= SUDOKU_PROMOTE_ACC mass sits inside the candidate set
23
+ # spread* >= SUDOKU_PROMOTE_SPREAD and is spread evenly over it
24
+ # plus SUDOKU_MIN_STAGE_STEPS, which is set to cover 5 passes at every stage
25
+ # (stage 2 is the largest at 46,555 steps for 100k puzzles).
26
+ #
27
+ # IMPORTANT: this script must never wipe /tmp/sudoku_wave_runs, because the
28
+ # stage-1 job's live checkpoints are in there.
29
+
30
+ set -u
31
+ hostname
32
+ nvidia-smi -L
33
+ echo "[$(date)] w12_multistage"
34
+
35
+ SCRATCH_ROOT=/scratch/users/gatmiry/llm-reasoning-logic-puzzles
36
+ RUN_DIR=${SCRATCH_ROOT}/sudoku-code/wavecurriculum_run
37
+ ENV_LOCAL=/tmp/logicpuzzles
38
+ CAND_DIR=/tmp/sudoku_s12
39
+ INST_DIR=/tmp/sudoku_superposition
40
+ LOCAL_LOG=/tmp/sudoku_wave_runs/w12_multistage
41
+ WARM_SRC=/tmp/sudoku_wave_runs/w12_s1_super
42
+ WARM_DIR=/tmp/sudoku_warmstart_s1
43
+ LOGIN_STAGE=gandalf.berkeley.edu:/tmp/sudoku_hf_multistage
44
+
45
+ mkdir -p "${RUN_DIR}/logs" "${LOCAL_LOG}" "${WARM_DIR}"
46
+ echo "[$(date)] /tmp free:"
47
+ df -h /tmp | tail -1
48
+
49
+ SCP_OPTS="-o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new"
50
+ [ -f "${HOME}/.ssh/id_ed25519_berkeley" ] && SCP_OPTS="${SCP_OPTS} -i ${HOME}/.ssh/id_ed25519_berkeley"
51
+
52
+ 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
53
+ echo "[$(date)] reusing ${ENV_LOCAL}"
54
+ else
55
+ echo "ERROR: ${ENV_LOCAL} missing; the stage-1 job should have created it" >&2
56
+ exit 1
57
+ fi
58
+
59
+ export PY=${ENV_LOCAL}/bin/python
60
+ export LD_LIBRARY_PATH=\
61
+ ${ENV_LOCAL}/lib/python3.9/site-packages/nvidia/cudnn/lib:\
62
+ ${ENV_LOCAL}/lib/python3.9/site-packages/nvidia/cublas/lib:\
63
+ ${ENV_LOCAL}/lib/python3.9/site-packages/nvidia/cuda_runtime/lib:\
64
+ ${ENV_LOCAL}/lib/python3.9/site-packages/nvidia/cuda_nvrtc/lib:\
65
+ ${ENV_LOCAL}/lib/python3.9/site-packages/nvidia/nccl/lib:\
66
+ ${LD_LIBRARY_PATH:-}
67
+
68
+ ${PY} -u -c "import jax; print(jax.__version__, jax.devices(), jax.default_backend())"
69
+
70
+ for f in train_assignments.npy train_starts.npy train_counts.npy \
71
+ test_assignments.npy test_starts.npy test_counts.npy; do
72
+ [ -s "${INST_DIR}/${f}" ] || { echo "missing ${INST_DIR}/${f}" >&2; exit 1; }
73
+ done
74
+ for f in train_cand_masks.npy test_cand_masks.npy; do
75
+ [ -s "${CAND_DIR}/${f}" ] || { echo "missing ${CAND_DIR}/${f}" >&2; exit 1; }
76
+ done
77
+
78
+ # ---- Warm start: snapshot the stage-1 job's newest checkpoint ----
79
+ # Copied rather than read in place, so a concurrent write by the stage-1 job
80
+ # cannot be observed half-finished, and so this run never writes into its dir.
81
+ NEWEST=$(ls -1d ${WARM_SRC}/checkpoint_* 2>/dev/null \
82
+ | grep -v 'orbax-checkpoint-tmp' \
83
+ | sed 's/.*checkpoint_//' | sort -n | tail -1)
84
+ if [ -z "${NEWEST}" ]; then
85
+ echo "ERROR: no checkpoint_* found in ${WARM_SRC}" >&2
86
+ ls -la "${WARM_SRC}" >&2 || true
87
+ exit 1
88
+ fi
89
+ echo "[$(date)] warm start from ${WARM_SRC}/checkpoint_${NEWEST}"
90
+ rm -rf "${WARM_DIR:?}"/checkpoint_*
91
+ cp -r "${WARM_SRC}/checkpoint_${NEWEST}" "${WARM_DIR}/"
92
+ du -sh "${WARM_DIR}/checkpoint_${NEWEST}"
93
+
94
+ export SUDOKU_RESUME=1
95
+ export SUDOKU_START_STAGE=2
96
+ export SUDOKU_MAX_STAGE=12
97
+ export SUDOKU_LATENT_SLOTS=12
98
+ export SUDOKU_RECURRENT=1
99
+ export SUDOKU_BACKTRACK=0
100
+ export SUDOKU_CAND_SLOT_MODE=depth
101
+ export SUDOKU_PASSES_PER_STAGE=1
102
+ export SUDOKU_AUX_WEIGHT=0.0
103
+ export SUDOKU_LEVEL_BALANCED=0
104
+ export SUDOKU_DATA_CURRICULUM=none
105
+ # Same sampling as stage 1, rebuilt per stage: every (puzzle, instance) pair of
106
+ # the current stage, walked in shuffled passes, 5 sightings each.
107
+ export SUDOKU_INSTANCE_EPOCHS=5
108
+ # 100k puzzles keeps a stage at ~47k steps (~1.2 h), so all 11 stages fit in
109
+ # ~15 h. The full corpus would be ~22 h for a single stage.
110
+ export SUDOKU_INSTANCE_PUZZLES="${SUDOKU_INSTANCE_PUZZLES:-100000}"
111
+ # Covers 5 passes at every stage; stage 2 needs the most at 46,555 steps.
112
+ export SUDOKU_MIN_STAGE_STEPS=50000
113
+ # Calibrated on what stage 1 actually reached: mass* 0.9957, spread* 0.92.
114
+ export SUDOKU_PROMOTE_ACC=0.95
115
+ export SUDOKU_PROMOTE_SPREAD=0.88
116
+ export SUDOKU_PROMOTE_LOC_WAVE=0.0
117
+ export SUDOKU_PLATEAU_STEPS=0
118
+ export SUDOKU_PATIENCE=0
119
+ # Resume continues from the restored optimizer step (~270k), plus 11 stages at
120
+ # >=50k each.
121
+ export SUDOKU_MAX_STEPS="${SUDOKU_MAX_STEPS:-950000}"
122
+ export SUDOKU_EVAL_EVERY=2000
123
+ export SUDOKU_SAVE_EVERY=10000
124
+ export SUDOKU_CKPT_KEEP=2
125
+ export SUDOKU_LR=0.0002
126
+ export SUDOKU_DROPOUT=0.2
127
+ export SUDOKU_WD=0.005
128
+ export SUDOKU_TRAIN_PATH="${SCRATCH_ROOT}/sudoku-code/datasets/train_sudoku_puzzles.npy"
129
+ export SUDOKU_TEST_PATH="${SCRATCH_ROOT}/sudoku-code/datasets/test_sudoku_puzzles.npy"
130
+ export SUDOKU_TRAIN_CAND="${CAND_DIR}/train_cand_masks.npy"
131
+ export SUDOKU_TEST_CAND="${CAND_DIR}/test_cand_masks.npy"
132
+ export SUDOKU_INSTANCE_DIR="${INST_DIR}"
133
+ # Two jobs share the H200s, so leave more headroom than the single-job setting.
134
+ export XLA_PYTHON_CLIENT_MEM_FRACTION=0.45
135
+
136
+ HF_TOKEN_FILE="${HF_TOKEN_FILE:-/scratch/users/gatmiry/.hf_token}"
137
+ if [ -z "${HF_TOKEN:-}" ] && [ -s "${HF_TOKEN_FILE}" ]; then
138
+ HF_TOKEN="$(cat "${HF_TOKEN_FILE}")"
139
+ export HF_TOKEN
140
+ fi
141
+ export HUGGING_FACE_HUB_TOKEN="${HF_TOKEN:-}"
142
+ HF_PKGS="${HF_PKGS:-/scratch/users/gatmiry/hf_pkgs}"
143
+ TRAIN_LOG="${RUN_DIR}/logs/w12_multistage.log"
144
+ SYNC_LOG="${RUN_DIR}/logs/hf_sync_w12_multistage.log"
145
+
146
+ tmp_avail_kb=$(df -Pk /tmp | awk 'NR==2{print $4}')
147
+ echo "[$(date)] /tmp avail ${tmp_avail_kb} KB"
148
+ if [ "${tmp_avail_kb}" -lt 3000000 ]; then
149
+ echo "ERROR: /tmp has less than 3G free; refusing to start" >&2
150
+ exit 1
151
+ fi
152
+
153
+ cd "${RUN_DIR}"
154
+ PYTHONPATH="${HF_PKGS}${PYTHONPATH:+:${PYTHONPATH}}" ${PY} -u "${RUN_DIR}/hf_sync.py" \
155
+ --workdir "${LOCAL_LOG}" \
156
+ --log "${TRAIN_LOG}" \
157
+ --scratch-log "${TRAIN_LOG}" \
158
+ --repo Avra98/Sudoku_superposition \
159
+ --prefix runs/w12_multistage \
160
+ --login-stage "${LOGIN_STAGE}" \
161
+ --token-file "${HF_TOKEN_FILE}" \
162
+ --interval 60 \
163
+ > "${SYNC_LOG}" 2>&1 &
164
+ SYNC_PID=$!
165
+
166
+ echo "[$(date)] starting w12_multistage"
167
+ echo " warm start from stage-1 checkpoint ${NEWEST}, stages 2..12"
168
+ echo " ${SUDOKU_INSTANCE_PUZZLES} puzzles, 5 passes per stage, min ${SUDOKU_MIN_STAGE_STEPS} steps"
169
+ echo " promote when mass* >= ${SUDOKU_PROMOTE_ACC} and spread* >= ${SUDOKU_PROMOTE_SPREAD}"
170
+ echo " hf=Avra98/Sudoku_superposition/runs/w12_multistage"
171
+ CUDA_VISIBLE_DEVICES=0 ${PY} -u -m train.main \
172
+ --workdir="${LOCAL_LOG}" --exp_name="w12_multistage" \
173
+ --ckpt_loc="${WARM_DIR}" \
174
+ > "${TRAIN_LOG}" 2>&1
175
+ EC=$?
176
+ kill ${SYNC_PID} 2>/dev/null || true
177
+ wait ${SYNC_PID} 2>/dev/null || true
178
+ PYTHONPATH="${HF_PKGS}${PYTHONPATH:+:${PYTHONPATH}}" ${PY} -u "${RUN_DIR}/hf_sync.py" --once \
179
+ --workdir "${LOCAL_LOG}" \
180
+ --log "${TRAIN_LOG}" \
181
+ --scratch-log "${TRAIN_LOG}" \
182
+ --repo Avra98/Sudoku_superposition \
183
+ --prefix runs/w12_multistage \
184
+ --login-stage "${LOGIN_STAGE}" \
185
+ --token-file "${HF_TOKEN_FILE}" \
186
+ >> "${SYNC_LOG}" 2>&1 || true
187
+ echo "[$(date)] w12_multistage exit ${EC}"
188
+ exit ${EC}
code/wavecurriculum_run/sbatch_stage1_super.sh ADDED
@@ -0,0 +1,187 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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=w1_super
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
+ # Stage-1 superposition experiment. ONE stage, no curriculum, no promotion.
13
+ #
14
+ # The point: a cell's candidate set is only visible to the model as the spread
15
+ # of digits it sees at that cell across repeated showings of the SAME puzzle.
16
+ # Random sampling showed a puzzle roughly once per 28k steps, and only one of
17
+ # its instances, so that spread was never presented. Here every (puzzle,
18
+ # instance) pair is enumerated and walked in shuffled order 5 times, so a batch
19
+ # holds one instance of each of 64 different puzzles (p1-i1, p2-i2, ...) and one
20
+ # epoch ends when every pair has appeared 5 times.
21
+
22
+ set -u
23
+ hostname
24
+ nvidia-smi -L
25
+ echo "[$(date)] w12_s1_super"
26
+
27
+ SCRATCH_ROOT=/scratch/users/gatmiry/llm-reasoning-logic-puzzles
28
+ RUN_DIR=${SCRATCH_ROOT}/sudoku-code/wavecurriculum_run
29
+ ENV_LOCAL=/tmp/logicpuzzles
30
+ CAND_DIR=/tmp/sudoku_s12
31
+ INST_DIR=/tmp/sudoku_superposition
32
+ LOCAL_LOG=/tmp/sudoku_wave_runs/w12_s1_super
33
+ TARBALL_GANDALF=/tmp/logicpuzzles_env.tar.gz
34
+ TARBALL_LOCAL=/tmp/logicpuzzles_env_${SLURM_JOB_ID}.tar.gz
35
+ GANDALF_INST=gandalf.berkeley.edu:/tmp/sudoku_superposition
36
+ GANDALF_CAND=gandalf.berkeley.edu:/tmp/sudoku_s12
37
+ LOGIN_STAGE=gandalf.berkeley.edu:/tmp/sudoku_hf_s1super
38
+
39
+ mkdir -p "${RUN_DIR}/logs" /tmp/sudoku_wave_runs
40
+ echo "[$(date)] /tmp before cleanup:"
41
+ df -h /tmp | tail -1
42
+ # Drop leftover run dirs from dead jobs; keep env + instance/mask caches.
43
+ rm -rf /tmp/sudoku_wave_runs
44
+ mkdir -p "${LOCAL_LOG}"
45
+ echo "[$(date)] /tmp after cleanup:"
46
+ df -h /tmp | tail -1
47
+
48
+ SCP_OPTS="-o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new"
49
+ [ -f "${HOME}/.ssh/id_ed25519_berkeley" ] && SCP_OPTS="${SCP_OPTS} -i ${HOME}/.ssh/id_ed25519_berkeley"
50
+
51
+ 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
52
+ echo "[$(date)] reusing ${ENV_LOCAL}"
53
+ else
54
+ echo "[$(date)] fetching env tarball"
55
+ rm -rf "${ENV_LOCAL}"
56
+ scp ${SCP_OPTS} "gandalf.berkeley.edu:${TARBALL_GANDALF}" "${TARBALL_LOCAL}"
57
+ tar xzf "${TARBALL_LOCAL}" -C /tmp
58
+ rm -f "${TARBALL_LOCAL}"
59
+ fi
60
+
61
+ export PY=${ENV_LOCAL}/bin/python
62
+ export LD_LIBRARY_PATH=\
63
+ ${ENV_LOCAL}/lib/python3.9/site-packages/nvidia/cudnn/lib:\
64
+ ${ENV_LOCAL}/lib/python3.9/site-packages/nvidia/cublas/lib:\
65
+ ${ENV_LOCAL}/lib/python3.9/site-packages/nvidia/cuda_runtime/lib:\
66
+ ${ENV_LOCAL}/lib/python3.9/site-packages/nvidia/cuda_nvrtc/lib:\
67
+ ${ENV_LOCAL}/lib/python3.9/site-packages/nvidia/nccl/lib:\
68
+ ${LD_LIBRARY_PATH:-}
69
+
70
+ ${PY} -u -c "import jax; print(jax.__version__, jax.devices(), jax.default_backend())"
71
+
72
+ # ---- Stage instance files onto node-local /tmp (scratch cannot hold 8 G) ----
73
+ need_inst=0
74
+ for f in train_assignments.npy train_starts.npy train_counts.npy \
75
+ test_assignments.npy test_starts.npy test_counts.npy; do
76
+ [ -s "${INST_DIR}/${f}" ] || need_inst=1
77
+ done
78
+ if [ "${need_inst}" = 1 ]; then
79
+ echo "[$(date)] pulling instances from ${GANDALF_INST}"
80
+ mkdir -p "${INST_DIR}"
81
+ rsync -a --progress -e "ssh ${SCP_OPTS}" "${GANDALF_INST}/" "${INST_DIR}/"
82
+ fi
83
+ for f in train_assignments.npy train_starts.npy train_counts.npy \
84
+ test_assignments.npy test_starts.npy test_counts.npy; do
85
+ [ -s "${INST_DIR}/${f}" ] || { echo "missing ${INST_DIR}/${f}" >&2; exit 1; }
86
+ done
87
+
88
+ need_cand=0
89
+ for f in train_cand_masks.npy test_cand_masks.npy; do
90
+ [ -s "${CAND_DIR}/${f}" ] || need_cand=1
91
+ done
92
+ if [ "${need_cand}" = 1 ]; then
93
+ echo "[$(date)] pulling s12 masks from ${GANDALF_CAND}"
94
+ mkdir -p "${CAND_DIR}"
95
+ rsync -a -e "ssh ${SCP_OPTS}" "${GANDALF_CAND}/" "${CAND_DIR}/" || true
96
+ fi
97
+ for f in train_cand_masks.npy test_cand_masks.npy; do
98
+ [ -s "${CAND_DIR}/${f}" ] || { echo "missing ${CAND_DIR}/${f}" >&2; exit 1; }
99
+ done
100
+
101
+ # ---- Stage 1 only: start == max, so the curriculum can never promote ----
102
+ export SUDOKU_RESUME=0
103
+ export SUDOKU_START_STAGE=1
104
+ export SUDOKU_MAX_STAGE=1
105
+ export SUDOKU_LATENT_SLOTS=12
106
+ export SUDOKU_RECURRENT=1
107
+ export SUDOKU_BACKTRACK=0
108
+ export SUDOKU_CAND_SLOT_MODE=depth
109
+ export SUDOKU_PASSES_PER_STAGE=1
110
+ export SUDOKU_AUX_WEIGHT=0.0
111
+ export SUDOKU_LEVEL_BALANCED=0
112
+ export SUDOKU_DATA_CURRICULUM=none
113
+ # One epoch = every (puzzle, instance) pair of stage 1 seen at least 5 times.
114
+ # 1,804,462 puzzles x 4-9 stored instances = 11,336,803 pairs.
115
+ export SUDOKU_INSTANCE_EPOCHS=5
116
+ export SUDOKU_INSTANCE_PUZZLES="${SUDOKU_INSTANCE_PUZZLES:-0}"
117
+ # steps = 5 * 11,336,803 / 64 = 885,688, rounded up so the epoch completes.
118
+ export SUDOKU_MAX_STEPS="${SUDOKU_MAX_STEPS:-886000}"
119
+ export SUDOKU_EVAL_EVERY=2000
120
+ export SUDOKU_SAVE_EVERY=10000
121
+ export SUDOKU_CKPT_KEEP=2
122
+ export SUDOKU_LR=0.0002
123
+ export SUDOKU_DROPOUT=0.2
124
+ export SUDOKU_WD=0.005
125
+ export SUDOKU_TRAIN_PATH="${SCRATCH_ROOT}/sudoku-code/datasets/train_sudoku_puzzles.npy"
126
+ export SUDOKU_TEST_PATH="${SCRATCH_ROOT}/sudoku-code/datasets/test_sudoku_puzzles.npy"
127
+ export SUDOKU_TRAIN_CAND="${CAND_DIR}/train_cand_masks.npy"
128
+ export SUDOKU_TEST_CAND="${CAND_DIR}/test_cand_masks.npy"
129
+ export SUDOKU_INSTANCE_DIR="${INST_DIR}"
130
+ # Leave a little H200 headroom so eval + sidecar tar do not OOM the node.
131
+ export XLA_PYTHON_CLIENT_MEM_FRACTION=0.85
132
+
133
+ HF_TOKEN_FILE="${HF_TOKEN_FILE:-/scratch/users/gatmiry/.hf_token}"
134
+ if [ -z "${HF_TOKEN:-}" ] && [ -s "${HF_TOKEN_FILE}" ]; then
135
+ HF_TOKEN="$(cat "${HF_TOKEN_FILE}")"
136
+ export HF_TOKEN
137
+ fi
138
+ export HUGGING_FACE_HUB_TOKEN="${HF_TOKEN:-}"
139
+ HF_PKGS="${HF_PKGS:-/scratch/users/gatmiry/hf_pkgs}"
140
+ TRAIN_LOG="${RUN_DIR}/logs/w12_s1_super.log"
141
+ SYNC_LOG="${RUN_DIR}/logs/hf_sync_w12_s1_super.log"
142
+
143
+ # Need room for 2 rolling ckpts (~1 GB) plus a tar in flight.
144
+ tmp_avail_kb=$(df -Pk /tmp | awk 'NR==2{print $4}')
145
+ echo "[$(date)] /tmp avail ${tmp_avail_kb} KB"
146
+ if [ "${tmp_avail_kb}" -lt 3000000 ]; then
147
+ echo "ERROR: /tmp has less than 3G free; refusing to start" >&2
148
+ df -h /tmp
149
+ du -sh /tmp/* 2>/dev/null | sort -h | tail -20 >&2 || true
150
+ exit 1
151
+ fi
152
+
153
+ cd "${RUN_DIR}"
154
+ PYTHONPATH="${HF_PKGS}${PYTHONPATH:+:${PYTHONPATH}}" ${PY} -u "${RUN_DIR}/hf_sync.py" \
155
+ --workdir "${LOCAL_LOG}" \
156
+ --log "${TRAIN_LOG}" \
157
+ --scratch-log "${TRAIN_LOG}" \
158
+ --repo Avra98/Sudoku_superposition \
159
+ --prefix runs/w12_s1_super \
160
+ --login-stage "${LOGIN_STAGE}" \
161
+ --token-file "${HF_TOKEN_FILE}" \
162
+ --interval 60 \
163
+ > "${SYNC_LOG}" 2>&1 &
164
+ SYNC_PID=$!
165
+
166
+ echo "[$(date)] starting w12_s1_super from scratch"
167
+ echo " stage pinned to 1 (start=max=1), 1 latent pass, aux=0"
168
+ echo " 1 epoch = every (puzzle, instance) pair seen 5x"
169
+ echo " workdir=${LOCAL_LOG} log=${TRAIN_LOG}"
170
+ echo " hf=Avra98/Sudoku_superposition/runs/w12_s1_super"
171
+ CUDA_VISIBLE_DEVICES=0 ${PY} -u -m train.main \
172
+ --workdir="${LOCAL_LOG}" --exp_name="w12_s1_super" \
173
+ > "${TRAIN_LOG}" 2>&1
174
+ EC=$?
175
+ kill ${SYNC_PID} 2>/dev/null || true
176
+ wait ${SYNC_PID} 2>/dev/null || true
177
+ PYTHONPATH="${HF_PKGS}${PYTHONPATH:+:${PYTHONPATH}}" ${PY} -u "${RUN_DIR}/hf_sync.py" --once \
178
+ --workdir "${LOCAL_LOG}" \
179
+ --log "${TRAIN_LOG}" \
180
+ --scratch-log "${TRAIN_LOG}" \
181
+ --repo Avra98/Sudoku_superposition \
182
+ --prefix runs/w12_s1_super \
183
+ --login-stage "${LOGIN_STAGE}" \
184
+ --token-file "${HF_TOKEN_FILE}" \
185
+ >> "${SYNC_LOG}" 2>&1 || true
186
+ echo "[$(date)] w12_s1_super exit ${EC}"
187
+ exit ${EC}
code/wavecurriculum_run/sbatch_wave12.sh ADDED
@@ -0,0 +1,203 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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=wave12
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
+ # Wave-depth curriculum, 12 stages.
13
+ #
14
+ # The curriculum axis is PROPAGATION DEPTH, not puzzle difficulty. Each puzzle's
15
+ # ~25 solver waves are subsampled to 12 evenly spaced candidate snapshots
16
+ # (staged_candidate_gen.py --stages 12, last snapshot = the unique solution).
17
+ # Curriculum stage t means: t recurrence passes, latent slots 1..t active, wave
18
+ # snapshots 1..t supervised. Promotion adds exactly one wave block.
19
+ #
20
+ # Difficulty is NOT gated: every puzzle is available from step 0, drawn
21
+ # uniformly from the corpus. The old level<=stage+2 filter is gone -- the
22
+ # difficulty tag has only 6 values (so it cannot express a 12-step ladder), is
23
+ # uncorrelated with puzzle size (r=-0.003 vs empty cells), and explains only
24
+ # ~19% of the variance in solver round count.
25
+ #
26
+ # Promotion is plateau-driven: a stage graduates when the candidate-set accuracy
27
+ # at its deepest slot stops improving, scored on cells that CHANGED from the
28
+ # previous snapshot. Unchanged cells are copies of slot j-1 and stay correct for
29
+ # a head that learned nothing, so they cannot signal acquisition. The accuracy
30
+ # threshold is a fast path and the patience cap prevents a stuck stage stalling.
31
+ #
32
+ # Submit one job per arm:
33
+ # sbatch --job-name=w12_flat --export=ALL,ARM=flat sbatch_wave12.sh
34
+ # sbatch --job-name=w12_datacur --export=ALL,ARM=datacur sbatch_wave12.sh
35
+ # sbatch --job-name=w12_latent --export=ALL,ARM=latent sbatch_wave12.sh
36
+ # sbatch --job-name=w12_latent_bt --export=ALL,ARM=latent_bt sbatch_wave12.sh
37
+ #
38
+ # ARM:
39
+ # flat K=0, no curriculum at all: pure control.
40
+ # datacur K=0 with the 12-stage round-count DATA curriculum. Stage t admits
41
+ # only puzzles in the first t round-count bins, so the propagation
42
+ # ladder is expressed in the puzzle POOL rather than in latent
43
+ # supervision. This is the no-latent curriculum arm: same axis as
44
+ # the latent arms, no latent tokens. Promotion is gated on accuracy
45
+ # over the newest round-bin.
46
+ # latent K=12 latent chain, 12-stage wave supervision, plateau promotion.
47
+ # latent_bt as latent, plus adaptive backtracking keyed on DEPTH: a repair
48
+ # replays num_passes=t when snapshot t's accuracy regresses below
49
+ # its graduation value.
50
+ #
51
+ # From scratch is forced, not chosen: pos_embeddings is exactly (3*81+K, emb_dim),
52
+ # so no 6-slot checkpoint can restore into a 12-slot model.
53
+ #
54
+ # The 12-stage masks live on feanor's node-local /tmp (sbatch_gen_s12_masks.sh)
55
+ # because the 20 G scratch quota has ~2 G free and the train masks are 3.5 G.
56
+
57
+ set -u
58
+ hostname
59
+ nvidia-smi -L
60
+ echo "[$(date)] ARM=${ARM:-unset}"
61
+
62
+ ARM="${ARM:?set ARM=flat|datacur|latent|latent_bt via --export=ALL,ARM=...}"
63
+ NAME="w12_${ARM}"
64
+
65
+ SCRATCH_ROOT=/scratch/users/gatmiry/llm-reasoning-logic-puzzles
66
+ RUN_DIR=${SCRATCH_ROOT}/sudoku-code/wavecurriculum_run
67
+ ENV_LOCAL=/tmp/logicpuzzles
68
+ CAND_DIR=/tmp/sudoku_s12
69
+ LOCAL_LOG=/tmp/sudoku_wave_runs/${NAME}
70
+ TARBALL_GANDALF=/tmp/logicpuzzles_env.tar.gz
71
+ TARBALL_LOCAL=/tmp/logicpuzzles_env_${SLURM_JOB_ID}.tar.gz
72
+
73
+ mkdir -p "${RUN_DIR}/logs" "${LOCAL_LOG}" /tmp/sudoku_wave_runs
74
+
75
+ 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
76
+ echo "[$(date)] reusing ${ENV_LOCAL}"
77
+ else
78
+ echo "[$(date)] fetching env tarball"
79
+ rm -rf "${ENV_LOCAL}"
80
+ SCP_OPTS="-o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new"
81
+ [ -f "${HOME}/.ssh/id_ed25519_berkeley" ] && SCP_OPTS="${SCP_OPTS} -i ${HOME}/.ssh/id_ed25519_berkeley"
82
+ scp ${SCP_OPTS} "gandalf.berkeley.edu:${TARBALL_GANDALF}" "${TARBALL_LOCAL}"
83
+ tar xzf "${TARBALL_LOCAL}" -C /tmp
84
+ rm -f "${TARBALL_LOCAL}"
85
+ fi
86
+
87
+ export PY=${ENV_LOCAL}/bin/python
88
+ export LD_LIBRARY_PATH=\
89
+ ${ENV_LOCAL}/lib/python3.9/site-packages/nvidia/cudnn/lib:\
90
+ ${ENV_LOCAL}/lib/python3.9/site-packages/nvidia/cublas/lib:\
91
+ ${ENV_LOCAL}/lib/python3.9/site-packages/nvidia/cuda_runtime/lib:\
92
+ ${ENV_LOCAL}/lib/python3.9/site-packages/nvidia/cuda_nvrtc/lib:\
93
+ ${ENV_LOCAL}/lib/python3.9/site-packages/nvidia/nccl/lib:\
94
+ ${LD_LIBRARY_PATH:-}
95
+
96
+ ${PY} -u -c "import jax; print(jax.__version__, jax.devices(), jax.default_backend())"
97
+
98
+ # ---- Shared recipe: identical across arms ----
99
+ export SUDOKU_RESUME=0
100
+ export SUDOKU_START_STAGE=1
101
+ export SUDOKU_MAX_STAGE="${SUDOKU_MAX_STAGE:-12}"
102
+ # Plateau promotion. A stage graduates when its frontier-depth accuracy has not
103
+ # gained 0.005 for 20k steps; PATIENCE is only a hard ceiling. This replaces the
104
+ # old fixed 15k timer, under which every promotion fired on patience at 0.32-0.46
105
+ # accuracy and the 0.70 threshold was dead code.
106
+ export SUDOKU_PLATEAU_STEPS=20000
107
+ export SUDOKU_PLATEAU_DELTA=0.005
108
+ export SUDOKU_PATIENCE=80000
109
+ export SUDOKU_MIN_STAGE_STEPS=8000
110
+ export SUDOKU_PROMOTE_ACC=0.90
111
+ # Matches the flat reference run (which reached level-3 acc 0.945 at ~800k), so
112
+ # the arms are comparable to it at equal steps rather than stopping at 250k.
113
+ export SUDOKU_MAX_STEPS="${SUDOKU_MAX_STEPS:-800000}"
114
+ # Difficulty tag selects nothing: uniform over the corpus (~68% level 3).
115
+ export SUDOKU_LEVEL_BALANCED=0
116
+ export SUDOKU_EVAL_EVERY=2000
117
+ export SUDOKU_SAVE_EVERY=10000
118
+ export SUDOKU_CKPT_KEEP=3
119
+ export SUDOKU_LR=0.0002
120
+ export SUDOKU_DROPOUT=0.2
121
+ export SUDOKU_WD=0.005
122
+ export SUDOKU_TRAIN_PATH="../datasets/train_sudoku_puzzles.npy"
123
+ export SUDOKU_TEST_PATH="../datasets/test_sudoku_puzzles.npy"
124
+ export XLA_PYTHON_CLIENT_MEM_FRACTION=0.9
125
+
126
+ # ---- Per-arm knobs ----
127
+ export SUDOKU_BACKTRACK=0
128
+ case "${ARM}" in
129
+ flat)
130
+ # No latents: no candidate head, no depth ladder. Masks left unset.
131
+ export SUDOKU_LATENT_SLOTS=0
132
+ export SUDOKU_RECURRENT=0
133
+ export SUDOKU_AUX_WEIGHT=0.0
134
+ export SUDOKU_TRAIN_CAND=""
135
+ export SUDOKU_TEST_CAND=""
136
+ ;;
137
+ datacur)
138
+ # No latents, but a real 12-stage curriculum over the puzzle pool, ordered
139
+ # by solver round count. Candidate masks stay unset (nothing to supervise);
140
+ # only the meta files are needed, for the round counts.
141
+ export SUDOKU_LATENT_SLOTS=0
142
+ export SUDOKU_RECURRENT=0
143
+ export SUDOKU_AUX_WEIGHT=0.0
144
+ export SUDOKU_TRAIN_CAND=""
145
+ export SUDOKU_TEST_CAND=""
146
+ export SUDOKU_DATA_CURRICULUM=rounds
147
+ export SUDOKU_TRAIN_META="${CAND_DIR}/train_meta.npy"
148
+ export SUDOKU_TEST_META="${CAND_DIR}/test_meta.npy"
149
+ for f in "${SUDOKU_TRAIN_META}" "${SUDOKU_TEST_META}"; do
150
+ [ -s "${f}" ] || { echo "missing meta ${f}; run sbatch_gen_s12_masks.sh on this node" >&2; exit 1; }
151
+ done
152
+ ;;
153
+ latent|latent_bt)
154
+ export SUDOKU_LATENT_SLOTS=12
155
+ export SUDOKU_RECURRENT=1
156
+ export SUDOKU_AUX_WEIGHT=1.0
157
+ export SUDOKU_CAND_SLOT_MODE=depth # k = num_passes, slot j -> snapshot j
158
+ export SUDOKU_PASSES_PER_STAGE=1 # stage t -> depth t, 12 stages -> 12 slots
159
+ export SUDOKU_CAND_DELTA_BG=0.25 # down-weight cells copied from slot j-1
160
+ export SUDOKU_TRAIN_CAND="${CAND_DIR}/train_cand_masks.npy"
161
+ export SUDOKU_TEST_CAND="${CAND_DIR}/test_cand_masks.npy"
162
+ for f in "${SUDOKU_TRAIN_CAND}" "${SUDOKU_TEST_CAND}"; do
163
+ [ -s "${f}" ] || { echo "missing masks ${f}; run sbatch_gen_s12_masks.sh on this node" >&2; exit 1; }
164
+ done
165
+ ;;
166
+ *) echo "unknown ARM '${ARM}'" >&2; exit 1 ;;
167
+ esac
168
+
169
+ if [ "${ARM}" = "latent_bt" ]; then
170
+ # Adaptive replay keyed on depth: deficits are measured on snapshot t's
171
+ # candidate-set accuracy, and a repair trains at num_passes=t on the same
172
+ # full-corpus batch distribution the frontier uses.
173
+ export SUDOKU_BACKTRACK=1
174
+ export SUDOKU_BACKTRACK_MODE=adaptive
175
+ export SUDOKU_BACKTRACK_MARGIN=0.03
176
+ export SUDOKU_BACKTRACK_MAX_REPAIR_STEPS=4000
177
+ export SUDOKU_BACKTRACK_MIN_FRONTIER_STEPS=8000
178
+ export SUDOKU_BACKTRACK_MAX_REPAIR_FRACTION=0.25
179
+ export SUDOKU_BACKTRACK_GRAD_DECAY=0.05
180
+ export SUDOKU_BACKTRACK_FRONTIER_MIX=1
181
+ fi
182
+
183
+ cd "${RUN_DIR}"
184
+ # Log-only periodic sync: checkpoints stay on /tmp (20 G scratch quota).
185
+ (
186
+ while true; do sleep 900
187
+ rsync -a "${LOCAL_LOG}.log" "${RUN_DIR}/logs/${NAME}.log" 2>/dev/null || true
188
+ done
189
+ ) &
190
+ SYNC_PID=$!
191
+
192
+ echo "[$(date)] starting ${NAME} from scratch"
193
+ echo " K=${SUDOKU_LATENT_SLOTS} recurrent=${SUDOKU_RECURRENT} bt=${SUDOKU_BACKTRACK}"
194
+ echo " max_stage=${SUDOKU_MAX_STAGE} plateau=${SUDOKU_PLATEAU_STEPS} patience=${SUDOKU_PATIENCE}"
195
+ echo " slot_mode=${SUDOKU_CAND_SLOT_MODE:-n/a} pps=${SUDOKU_PASSES_PER_STAGE:-n/a} delta_bg=${SUDOKU_CAND_DELTA_BG:-n/a}"
196
+ CUDA_VISIBLE_DEVICES=0 ${PY} -u -m train.main \
197
+ --workdir="${LOCAL_LOG}" --exp_name="${NAME}" \
198
+ > "${LOCAL_LOG}.log" 2>&1
199
+ EC=$?
200
+ kill ${SYNC_PID} 2>/dev/null || true
201
+ rsync -a "${LOCAL_LOG}.log" "${RUN_DIR}/logs/${NAME}.log" 2>/dev/null || true
202
+ echo "[$(date)] ${NAME} exit ${EC}"
203
+ exit ${EC}
code/wavecurriculum_run/show_val_example.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Worked example: one cell with candidate set S = {1,2,4}, four model behaviours.
2
+
3
+ Shows that excess = log(1/mass) + KL(Uniform(S) || p/mass) separates "support
4
+ leaks outside S" from "support inside S is not uniform".
5
+ """
6
+ import sys
7
+ import os
8
+ import types
9
+
10
+ import numpy as np
11
+
12
+ for name in ("jax", "jax.numpy", "flax", "flax.training",
13
+ "flax.training.common_utils", "train.model"):
14
+ sys.modules.setdefault(name, types.ModuleType(name))
15
+ sys.modules["jax"].numpy = sys.modules["jax.numpy"]
16
+ sys.modules["flax"].training = sys.modules["flax.training"]
17
+ sys.modules["flax.training"].common_utils = sys.modules[
18
+ "flax.training.common_utils"]
19
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
20
+ from train.evaluater import _cand_value_stats
21
+
22
+ CAND = [1, 2, 4] # digits
23
+ BITS = int(sum(1 << (d - 1) for d in CAND))
24
+
25
+ cases = [
26
+ ("A perfect superposition ", {1: 1 / 3, 2: 1 / 3, 4: 1 / 3}),
27
+ ("B uniform over all 9 ", {d: 1 / 9 for d in range(1, 10)}),
28
+ ("C in S but collapsed on 4 ", {1: 0.05, 2: 0.05, 4: 0.90}),
29
+ ("D leaks AND collapsed ", {1: 0.05, 2: 0.05, 4: 0.50,
30
+ 7: 0.20, 8: 0.20}),
31
+ # A softmax never emits an exact zero, so keep a little mass on S here;
32
+ # with p(d) == 0 for every candidate the CE is +inf by definition.
33
+ ("E almost all mass outside S", {1: 0.005, 2: 0.005, 4: 0.005, 7: 0.985}),
34
+ ]
35
+
36
+ hdr = (f"{'case':<28}{'mass':>7}{'out':>8}{'leak':>9}{'kl':>9}"
37
+ f"{'excess':>9}{'spread':>8}{'score':>8}")
38
+ print(f"candidate set S = {{{','.join(map(str, CAND))}}}, |S| = {len(CAND)}, "
39
+ f"log|S| = {np.log(len(CAND)):.4f}")
40
+ print(hdr)
41
+ print("-" * len(hdr))
42
+ for label, pmap in cases:
43
+ p = np.zeros(9)
44
+ for d, v in pmap.items():
45
+ p[d - 1] = v
46
+ assert abs(p.sum() - 1.0) < 1e-9, (label, p.sum())
47
+ logp = np.log(np.maximum(p, 1e-300))[None, :]
48
+ st = _cand_value_stats(logp, np.array([BITS]))
49
+ excess = st["ce"] - st["floor"]
50
+ mass = st["mass"]
51
+ leak = np.log(1.0 / max(mass, 1e-300))
52
+ kl = st["kl_multi"]
53
+ # the identity, recomputed independently of the code
54
+ assert abs(excess - (leak + kl)) < 1e-8, (label, excess, leak + kl)
55
+ print(f"{label:<28}{mass:>7.3f}{1 - mass:>8.3f}{leak:>9.3f}{kl:>9.3f}"
56
+ f"{excess:>9.3f}{st['spread']:>8.3f}{np.exp(-excess):>8.3f}")
57
+
58
+ print()
59
+ print("out = val_acc = 1 - mass = P(digit outside S)")
60
+ print("leak = log(1/mass) -> 0 when no support outside S")
61
+ print("kl = KL(Unif(S)||p/m) -> 0 when support inside S is uniform")
62
+ print("excess = leak + kl -> 0 iff BOTH; this is the promotion signal")
63
+ print("score = exp(-excess) -> 1.0 iff both; compared to SUDOKU_PROMOTE_ACC")
code/wavecurriculum_run/test_instance_epochs.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Check the (puzzle, instance) epoch enumeration on the real stage-1 counts.
2
+
3
+ Verifies the property the experiment rests on: after E epochs every instance of
4
+ every puzzle has been used exactly E times, and a batch holds distinct puzzles
5
+ (one instance of a given puzzle per batch).
6
+ """
7
+ import numpy as np
8
+
9
+
10
+ def build_pairs(starts, counts, stage, n_puz):
11
+ """Same expansion as SudokuDataset._build_instance_epoch_list."""
12
+ c = np.asarray(counts[:n_puz, stage]).astype(np.int64)
13
+ s = np.asarray(starts[:n_puz, stage]).astype(np.int64)
14
+ total = int(c.sum())
15
+ puzzle_ids = np.repeat(np.arange(n_puz, dtype=np.int64), c)
16
+ offsets = np.arange(total, dtype=np.int64) - np.repeat(np.cumsum(c) - c, c)
17
+ rows = np.repeat(s, c) + offsets
18
+ return puzzle_ids, rows, c
19
+
20
+
21
+ def main():
22
+ d = "/tmp/sudoku_superposition"
23
+ starts = np.load(f"{d}/train_starts.npy")
24
+ counts = np.load(f"{d}/train_counts.npy")
25
+ stage, n_puz, epochs, bs = 0, 20000, 5, 64
26
+
27
+ pids, rows, c = build_pairs(starts, counts, stage, n_puz)
28
+ total = len(rows)
29
+ print(f"stage {stage + 1}: {n_puz} puzzles -> {total} pairs, "
30
+ f"{c.min()}-{c.max()} instances/puzzle (mean {c.mean():.2f})")
31
+
32
+ # 1. rows are exactly each puzzle's contiguous assignment block, no repeats
33
+ assert total == int(c.sum())
34
+ assert len(np.unique(rows)) == total, "duplicate assignment rows"
35
+ for p in (0, 1, 7, n_puz - 1):
36
+ want = np.arange(starts[p, stage], starts[p, stage] + counts[p, stage])
37
+ got = np.sort(rows[pids == p])
38
+ assert np.array_equal(got, want), (p, got, want)
39
+ print("rows match each puzzle's assignment block OK")
40
+
41
+ # 2. every instance seen exactly `epochs` times
42
+ rng = np.random.RandomState(0)
43
+ seen = np.zeros(total, dtype=np.int32)
44
+ order = np.arange(total)
45
+ batch_dup = 0
46
+ n_batches = 0
47
+ for _ in range(epochs):
48
+ rng.shuffle(order)
49
+ seen[order] += 1
50
+ for b in range(0, total - bs, bs):
51
+ sl = pids[order[b:b + bs]]
52
+ batch_dup += bs - len(np.unique(sl))
53
+ n_batches += 1
54
+ assert seen.min() == seen.max() == epochs, (seen.min(), seen.max())
55
+ print(f"every instance seen exactly {epochs}x OK")
56
+
57
+ # 3. per-puzzle: all of its instances covered, each `epochs` times
58
+ per_puzzle = np.bincount(pids, weights=seen, minlength=n_puz)
59
+ assert np.array_equal(per_puzzle, epochs * c)
60
+ print("every puzzle: all instances x epochs OK")
61
+
62
+ # 4. batches hold distinct puzzles
63
+ print(f"same-puzzle collisions in a batch: {batch_dup} over "
64
+ f"{n_batches} batches ({batch_dup / (n_batches * bs) * 100:.3f}% "
65
+ f"of slots)")
66
+
67
+ # 5. averaged over the epochs, the target at a cell IS the candidate set
68
+ A = np.load(f"{d}/train_assignments.npy", mmap_mode="r")
69
+ p = 3
70
+ blk = np.asarray(A[starts[p, stage]:starts[p, stage] + counts[p, stage]])
71
+ multi = [c_ for c_ in range(81) if len(np.unique(blk[:, c_])) > 1]
72
+ print(f"\npuzzle {p}: {counts[p, stage]} instances, "
73
+ f"{len(multi)} cells whose digit varies across instances")
74
+ for cell in multi[:5]:
75
+ vals, cnt = np.unique(blk[:, cell], return_counts=True)
76
+ emp = ", ".join(f"{v}:{n}/{len(blk)}" for v, n in zip(vals, cnt))
77
+ print(f" cell (r{cell // 9}, c{cell % 9}) digits {emp}")
78
+ print("\nthis empirical spread over instances is exactly what the model "
79
+ "must reproduce; it only sees it if all instances are visited")
80
+
81
+
82
+ if __name__ == "__main__":
83
+ main()
code/wavecurriculum_run/test_new_metrics.py ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Numerical checks for the new location and value metrics.
2
+
3
+ Run: python wavecurriculum_run/test_new_metrics.py
4
+ """
5
+ import sys
6
+ import os
7
+ import types
8
+
9
+ import numpy as np
10
+
11
+ # The helpers under test are pure numpy, so stub the heavy imports at the top of
12
+ # evaluater.py to keep this runnable on any node without jax installed.
13
+ for name in ("jax", "jax.numpy", "flax", "flax.training",
14
+ "flax.training.common_utils", "train.model"):
15
+ sys.modules.setdefault(name, types.ModuleType(name))
16
+ sys.modules["jax"].numpy = sys.modules["jax.numpy"]
17
+ sys.modules["flax"].training = sys.modules["flax.training"]
18
+ sys.modules["flax.training"].common_utils = sys.modules[
19
+ "flax.training.common_utils"]
20
+
21
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
22
+ from train.evaluater import (_cand_value_stats, _wave_order_score, _lcs_len)
23
+
24
+ fails = []
25
+
26
+
27
+ def check(name, got, want, tol=1e-6):
28
+ ok = abs(float(got) - float(want)) <= tol
29
+ print(f"{'PASS' if ok else 'FAIL'} {name}: got {got:.6f} want {want:.6f}")
30
+ if not ok:
31
+ fails.append(name)
32
+
33
+
34
+ def logp_from_probs(p):
35
+ p = np.asarray(p, dtype=np.float64)
36
+ return np.log(np.maximum(p, 1e-300))
37
+
38
+
39
+ BITS_123 = 0b000000111 # candidates {1,2,3}
40
+ BITS_5 = 0b000010000 # candidate {5}
41
+
42
+ # ---- val_excess == 0 exactly at uniform over the candidate set ----
43
+ p = np.zeros((1, 9)); p[0, :3] = 1.0 / 3.0
44
+ st = _cand_value_stats(logp_from_probs(p), np.array([BITS_123]))
45
+ check("uniform-over-S excess", st["ce"] - st["floor"], 0.0)
46
+ check("uniform-over-S mass", st["mass"], 1.0)
47
+ check("uniform-over-S spread", st["spread"], 1.0)
48
+ check("uniform-over-S floor==log3", st["floor"], np.log(3))
49
+
50
+ # ---- collapse onto one candidate: excess > 0, spread -> 0 ----
51
+ p = np.zeros((1, 9)); p[0, 0] = 0.98; p[0, 1] = 0.01; p[0, 2] = 0.01
52
+ st = _cand_value_stats(logp_from_probs(p), np.array([BITS_123]))
53
+ excess = st["ce"] - st["floor"]
54
+ print(f" collapsed excess = {excess:.4f} (want >> 0), "
55
+ f"spread = {st['spread']:.4f} (want ~0)")
56
+ if not excess > 1.0:
57
+ fails.append("collapsed excess > 1")
58
+ if not st["spread"] < 0.2:
59
+ fails.append("collapsed spread < 0.2")
60
+
61
+ # ---- leakage outside the set lowers mass and raises excess ----
62
+ p = np.zeros((1, 9)); p[0, :3] = 0.2; p[0, 5] = 0.4
63
+ st = _cand_value_stats(logp_from_probs(p), np.array([BITS_123]))
64
+ check("leaky mass", st["mass"], 0.6)
65
+ if not (st["ce"] - st["floor"]) > 0.4:
66
+ fails.append("leaky excess")
67
+ print(f" leaky excess = {st['ce'] - st['floor']:.4f} (want > 0.4)")
68
+
69
+ # ---- uniform over all 9 digits: excess == log(9/|S|) ----
70
+ p = np.full((1, 9), 1.0 / 9.0)
71
+ st = _cand_value_stats(logp_from_probs(p), np.array([BITS_123]))
72
+ check("uniform-over-9 excess", st["ce"] - st["floor"], np.log(9) - np.log(3))
73
+ check("uniform-over-9 mass", st["mass"], 3.0 / 9.0)
74
+ check("uniform-over-9 spread", st["spread"], 1.0)
75
+
76
+ # ---- singleton set is excluded from spread but counted elsewhere ----
77
+ p = np.zeros((1, 9)); p[0, 4] = 1.0
78
+ st = _cand_value_stats(logp_from_probs(p), np.array([BITS_5]))
79
+ check("singleton excess", st["ce"] - st["floor"], 0.0)
80
+ check("singleton spread_count", st["spread_count"], 0)
81
+ check("singleton count", st["count"], 1)
82
+
83
+ # ---- the decomposition identity, on many random distributions ----
84
+ # excess = log(1/mass) + KL(Uniform(S) || p/mass)
85
+ # This is the whole claim: one number that is zero iff the support outside the
86
+ # candidate set has vanished AND the support inside it is uniform.
87
+ rng = np.random.default_rng(0)
88
+ worst = 0.0
89
+ for trial in range(2000):
90
+ k = int(rng.integers(2, 7))
91
+ cand = rng.choice(9, size=k, replace=False)
92
+ bits = int(sum(1 << int(d) for d in cand))
93
+ p = rng.dirichlet(np.full(9, float(rng.choice([0.15, 1.0, 5.0]))))
94
+ st = _cand_value_stats(logp_from_probs(p[None, :]), np.array([bits]))
95
+ excess = st["ce"] - st["floor"]
96
+ mass = st["mass"]
97
+ q = p[cand] / mass
98
+ kl = float(np.sum((1.0 / k) * np.log((1.0 / k) / q)))
99
+ worst = max(worst, abs(excess - (np.log(1.0 / mass) + kl)))
100
+ # both terms are non-negative, so excess is too
101
+ if excess < -1e-9 or kl < -1e-9:
102
+ fails.append("decomposition non-negativity")
103
+ # the code's own kl_multi must equal the independent one
104
+ if abs(st["kl_multi"] - kl) > 1e-8:
105
+ fails.append(f"kl_multi mismatch trial {trial}")
106
+ break
107
+ print(f"{'PASS' if worst < 1e-8 else 'FAIL'} decomposition identity over 2000 "
108
+ f"random cases: max |excess - (log(1/mass) + KL)| = {worst:.2e}")
109
+ if worst >= 1e-8:
110
+ fails.append("decomposition identity")
111
+
112
+ # ---- the two failure modes are separated by the split ----
113
+ S = np.array([0b000010111]) # candidates {1,2,3,5}, |S| = 4
114
+ # (a) perfectly uniform inside S, but only 50% of the mass is inside
115
+ p = np.zeros((1, 9)); p[0, [0, 1, 2, 4]] = 0.125; p[0, [6, 7]] = 0.25
116
+ st = _cand_value_stats(logp_from_probs(p), S)
117
+ check("leak-only: kl term is 0", st["kl_multi"], 0.0)
118
+ check("leak-only: excess == log(1/mass)", st["ce"] - st["floor"], np.log(2.0))
119
+ # (b) all the mass inside S, but skewed across the candidates
120
+ p = np.zeros((1, 9)); p[0, 0] = 0.7; p[0, 1] = 0.1; p[0, 2] = 0.1; p[0, 4] = 0.1
121
+ st = _cand_value_stats(logp_from_probs(p), S)
122
+ check("skew-only: mass is 1", st["mass"], 1.0)
123
+ check("skew-only: excess == kl", st["ce"] - st["floor"], st["kl_multi"])
124
+ print(f" skew-only kl = {st['kl_multi']:.4f} (want > 0), "
125
+ f"spread = {st['spread']:.4f}")
126
+ if not st["kl_multi"] > 0.2:
127
+ fails.append("skew-only kl > 0.2")
128
+
129
+ # ---- singleton cells are excluded from the *_multi aggregates ----
130
+ st = _cand_value_stats(logp_from_probs(np.full((1, 9), 1.0 / 9.0)),
131
+ np.array([0b000010000]))
132
+ check("singleton excluded from excess_multi", st["excess_multi"], 0.0)
133
+ check("singleton excluded from mass_multi", st["mass_multi"], 0.0)
134
+ check("singleton excluded from kl_multi", st["kl_multi"], 0.0)
135
+
136
+ # ---- loc_wave: order up to ties within a wave ----
137
+ truth = [(0, 0), (0, 1), (0, 2), (1, 0)]
138
+ waves = np.array([0, 0, 1, 1])
139
+
140
+ check("wave exact order", _wave_order_score(truth, truth, waves, 12), 4)
141
+ # swapping within a wave must cost nothing
142
+ check("wave swap inside wave",
143
+ _wave_order_score([(0, 1), (0, 0), (1, 0), (0, 2)], truth, waves, 12), 4)
144
+ # pulling a later wave forward costs exactly those steps
145
+ check("wave 1 pulled ahead of wave 0",
146
+ _wave_order_score([(0, 2), (0, 0), (0, 1), (1, 0)], truth, waves, 12), 3)
147
+ # duplicates and non-cells score nothing and consume nothing
148
+ check("wave duplicates",
149
+ _wave_order_score([(0, 0), (0, 0), (0, 0), (0, 0)], truth, waves, 12), 1)
150
+ check("wave invalid cells",
151
+ _wave_order_score([(7, 7), (8, 8), (0, 0), (0, 1)], truth, waves, 12), 2)
152
+
153
+ # ---- LCS: a reordering is not wiped out the way a positional match is ----
154
+ check("lcs identical", _lcs_len(truth, truth), 4)
155
+ check("lcs one swap",
156
+ _lcs_len([(0, 1), (0, 0), (0, 2), (1, 0)], truth), 3)
157
+ check("lcs reversed", _lcs_len(truth[::-1], truth), 1)
158
+
159
+ print()
160
+ if fails:
161
+ print(f"{len(fails)} FAILURES: {fails}")
162
+ sys.exit(1)
163
+ print("all metric checks passed")
code/wavecurriculum_run/train/data.py ADDED
@@ -0,0 +1,692 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ self._build_instance_epoch_list()
143
+
144
+ def _load_instances(self):
145
+ """Load superposition instances: one concrete assignment per row.
146
+
147
+ Replaces the multi-hot candidate target with ordinary value tokens. For
148
+ a puzzle at stage s there are several assignments, each picking one digit
149
+ per cell from that cell's stage-s candidate set, so the candidate set is
150
+ recoverable across instances instead of being supervised as a set.
151
+
152
+ assignments: (M, 81) uint8, cell = r*9+c
153
+ starts/counts: (N, S) row range for each (puzzle, stage)
154
+ """
155
+ self.instances = None
156
+ d = getattr(self.config, "instance_dir", None)
157
+ if not d:
158
+ return
159
+ split = "train" if self.train else "test"
160
+ self.instances = np.load(
161
+ os.path.join(d, f"{split}_assignments.npy"), mmap_mode="r")
162
+ self.inst_starts = np.load(os.path.join(d, f"{split}_starts.npy"))
163
+ self.inst_counts = np.load(os.path.join(d, f"{split}_counts.npy"))
164
+ self.inst_stages = int(self.inst_starts.shape[1])
165
+ print(f"[inst] loaded {split} instances {self.instances.shape} "
166
+ f"over {self.inst_starts.shape[0]} puzzles, "
167
+ f"{self.inst_stages} stages", flush=True)
168
+
169
+ def _build_instance_epoch_list(self):
170
+ """Schedule for the pinned stage, so each puzzle is shown many times.
171
+
172
+ Default sampling draws a puzzle uniformly and then one of its
173
+ assignments at random, so over 8k steps at batch 64 a given puzzle is
174
+ seen at most once and most of its instances are never seen at all. The
175
+ superposition at a cell is only visible to the model as the spread of
176
+ digits it sees at that cell across repeated showings of the SAME puzzle,
177
+ so the schedule is built explicitly and walked in shuffled epochs.
178
+
179
+ Two sources of instances:
180
+
181
+ uniform (instance_uniform_draws=N)
182
+ Synthesize N instances per puzzle on the fly, each empty cell drawn
183
+ uniformly and independently from its candidate set. The per-cell
184
+ digit frequencies are then equal by construction, so the
185
+ cross-entropy optimum at that cell IS the uniform superposition.
186
+
187
+ pool (instance_epochs=E)
188
+ Walk the stored assignments E times each. Their per-cell frequencies
189
+ are whatever the coverage-driven generator produced (a |S|=2 cell is
190
+ typically 4:1), so CE converges to that skew, not to uniform.
191
+ """
192
+ self.inst_pairs = None
193
+ self.inst_uniform = 0
194
+ self.inst_pairs_stage = None
195
+ draws = int(getattr(self.config, "instance_uniform_draws", 0))
196
+ epochs = int(getattr(self.config, "instance_epochs", 0))
197
+ if not self.train or (draws <= 0 and epochs <= 0):
198
+ return
199
+ self._build_pairs_for_stage(self.instance_stage())
200
+
201
+ def _build_pairs_for_stage(self, stage):
202
+ """(Re)build the schedule for one stage.
203
+
204
+ Called again on promotion: each stage has its own instance pool, so the
205
+ pair list and the pass counter both restart when the stage advances.
206
+ """
207
+ draws = int(getattr(self.config, "instance_uniform_draws", 0))
208
+ epochs = int(getattr(self.config, "instance_epochs", 0))
209
+ n_puz = int(getattr(self.config, "instance_puzzles", 0))
210
+ n_all = len(self.train_puzzles)
211
+ n_puz = n_all if n_puz <= 0 else min(n_puz, n_all)
212
+
213
+ if draws > 0:
214
+ if self.cand_masks is None:
215
+ raise ValueError("instance_uniform_draws needs the candidate "
216
+ "masks (SUDOKU_TRAIN_CAND)")
217
+ self.inst_uniform = draws
218
+ widths = self._stage_widths(stage, min(n_puz, 2000))
219
+ pids = np.repeat(np.arange(n_puz, dtype=np.int32), draws)
220
+ # -1 = synthesize this instance instead of reading a stored row.
221
+ self.inst_pairs = (pids, np.full(len(pids), -1, dtype=np.int64))
222
+ print(f"[inst-uniform] stage {stage + 1}: {n_puz} puzzles x "
223
+ f"{draws} uniform draws = {len(pids)} examples; mean |S| "
224
+ f"{widths.mean():.2f}, max {widths.max()}; each candidate of "
225
+ f"a cell is drawn ~{draws / widths.mean():.1f} times "
226
+ f"(>=5 needs {int(5 * widths.max())} draws for the widest "
227
+ f"cell)", flush=True)
228
+ self.inst_pairs_stage = stage
229
+ return
230
+
231
+ if self.instances is None:
232
+ raise ValueError("instance_epochs needs SUDOKU_INSTANCE_DIR")
233
+ counts = np.asarray(self.inst_counts[:n_puz, stage]).astype(np.int64)
234
+ starts = np.asarray(self.inst_starts[:n_puz, stage]).astype(np.int64)
235
+ total = int(counts.sum())
236
+ # Expand (puzzle -> its `count` consecutive assignment rows) without a
237
+ # Python loop: repeat the puzzle id, then add the within-puzzle offset.
238
+ puzzle_ids = np.repeat(np.arange(n_puz, dtype=np.int64), counts)
239
+ offsets = (np.arange(total, dtype=np.int64)
240
+ - np.repeat(np.cumsum(counts) - counts, counts))
241
+ rows = np.repeat(starts, counts) + offsets
242
+ self.inst_pairs = (puzzle_ids.astype(np.int32), rows.astype(np.int64))
243
+ self.inst_epochs = epochs
244
+ self.inst_pairs_stage = stage
245
+ print(f"[inst] stage {stage + 1}: {n_puz} puzzles, {total} "
246
+ f"(puzzle, instance) pairs, {counts.min()}-{counts.max()} "
247
+ f"instances per puzzle (mean {counts.mean():.2f}); one epoch = "
248
+ f"every pair seen {epochs}x = {epochs * total} examples",
249
+ flush=True)
250
+
251
+ def instance_stage(self):
252
+ """Stage whose instances this example should target.
253
+
254
+ Bound to the curriculum stage so the target's ambiguity matches the
255
+ latent depth: stage t runs t latent slots and supervises the stage-(t-1)
256
+ assignments, ending at the unique solution when t == S.
257
+ """
258
+ S = int(getattr(self, "inst_stages", 0)) or int(self.num_stages)
259
+ if self.curriculum is None:
260
+ return S - 1
261
+ t = int(np.clip(self.curriculum.stage, 1, S))
262
+ return t - 1
263
+
264
+ def _stage_widths(self, stage, n_sample):
265
+ """|S| for every multi-candidate cell over the first n_sample puzzles."""
266
+ m = np.asarray(self.cand_masks[:n_sample, stage]).astype(np.int64)
267
+ bits = ((m[..., None] >> np.arange(9)) & 1).sum(-1)
268
+ return bits[bits >= 2]
269
+
270
+ def uniform_instance_values(self, idx, stage):
271
+ """Synthesize one assignment: each cell drawn uniformly from its set.
272
+
273
+ Returns (81,) digits indexed by cell = r*9+c. Cells the mask leaves
274
+ empty (mask 0) return 0, and the caller keeps the base sequence's digit
275
+ there, so the clue block is untouched.
276
+
277
+ The uniform draw over set bits is done by giving every set bit an iid
278
+ random key and taking the argmax: the max is equally likely to land on
279
+ any set bit, which is exactly a uniform choice, and it vectorizes over
280
+ all 81 cells at once.
281
+ """
282
+ m = np.asarray(self.cand_masks[idx, stage]).astype(np.int64) # (81,)
283
+ bits = ((m[:, None] >> np.arange(9)) & 1).astype(np.float64) # (81, 9)
284
+ keys = self.rng.random_sample((81, 9)) * bits
285
+ vals = (keys.argmax(1) + 1).astype(np.int8)
286
+ return np.where(m > 0, vals, 0)
287
+
288
+ def instance_values(self, idx, stage, inst_row=None):
289
+ """One assignment for (puzzle idx, stage).
290
+
291
+ inst_row pins an exact assignment row (epoch mode, so every instance is
292
+ visited a fixed number of times), -1 synthesizes a fresh uniform draw,
293
+ and None samples one of the stored rows at random.
294
+ """
295
+ if inst_row is not None and int(inst_row) < 0:
296
+ return self.uniform_instance_values(idx, stage)
297
+ if inst_row is not None:
298
+ return np.asarray(self.instances[int(inst_row)])
299
+ n = int(self.inst_counts[idx, stage])
300
+ if n <= 0:
301
+ return None
302
+ row = int(self.inst_starts[idx, stage]) + self.rng.randint(n)
303
+ return np.asarray(self.instances[row])
304
+
305
+ def apply_instance(self, seq, idx, stage, inst_row=None):
306
+ """Rewrite the value token of every triple to this instance's digit.
307
+
308
+ The (row, col) order is untouched, so the clue block and the solver-order
309
+ output sequence are exactly as before; only the values change.
310
+ """
311
+ vals = self.instance_values(idx, stage, inst_row=inst_row)
312
+ if vals is None:
313
+ return seq
314
+ seq = seq.copy()
315
+ cells = seq[0::3].astype(np.int64) * 9 + seq[1::3].astype(np.int64)
316
+ new = vals[cells].astype(seq.dtype)
317
+ # 0 means "this cell has no candidate mask"; keep the base digit there
318
+ # so the clue block survives untouched. Stored assignments carry the
319
+ # clue digit itself, so they overwrite with the same value either way.
320
+ seq[2::3] = np.where(new > 0, new, seq[2::3])
321
+ return seq
322
+
323
+ def _load_round_bins(self):
324
+ """Load per-puzzle solver round counts and bin them into max_stage
325
+ equal-count bins, for the round-count DATA curriculum.
326
+
327
+ The round count (waves needed to reach the unique solution, 5..38) is
328
+ the same propagation-depth axis the latent curriculum supervises, but
329
+ used to order the *puzzles* instead of the supervision. Bin edges are
330
+ always computed on the train split and reused for eval so a bin index
331
+ means the same thing in both.
332
+ """
333
+ self.round_bins = None
334
+ self.num_bins = int(getattr(self.config, "curriculum_max_stage", 12))
335
+ if str(getattr(self.config, "data_curriculum", "none")) != "rounds":
336
+ return
337
+ tr_path = getattr(self.config, "train_meta_path", None)
338
+ path = tr_path if self.train else getattr(
339
+ self.config, "test_meta_path", None)
340
+ if not (path and tr_path):
341
+ raise ValueError(
342
+ "data_curriculum='rounds' needs SUDOKU_TRAIN_META and "
343
+ "SUDOKU_TEST_META (the *_meta.npy written by "
344
+ "staged_candidate_gen.py; column 2 is num_rounds)")
345
+ rounds = np.load(path, mmap_mode="r")[:, 2].astype(np.int32)
346
+ # Cut points from the TRAIN split, so a bin index means the same thing
347
+ # in eval. Round counts are integers with a peaked distribution (mean
348
+ # 22, sd 4), so raw quantiles collide -- the 12-bin quantiles repeat 20
349
+ # twice on the full corpus, which would leave a bin permanently empty
350
+ # and strand its stage with no frontier to measure. Force the cuts
351
+ # strictly increasing so every bin is reachable.
352
+ train_rounds = np.load(tr_path, mmap_mode="r")[:, 2].astype(np.int32)
353
+ edges = np.quantile(train_rounds, np.linspace(0, 1, self.num_bins + 1))
354
+ cuts = np.round(edges[1:-1]).astype(np.int64)
355
+ for i in range(1, len(cuts)):
356
+ if cuts[i] <= cuts[i - 1]:
357
+ cuts[i] = cuts[i - 1] + 1
358
+ # bin j (1-based) = stage that first unlocks the puzzle.
359
+ self.round_bins = np.clip(
360
+ np.searchsorted(cuts, rounds, side="right") + 1,
361
+ 1, self.num_bins).astype(np.int32)
362
+ self.bin_index = {b: np.where(self.round_bins == b)[0]
363
+ for b in range(1, self.num_bins + 1)}
364
+ counts = {b: int(len(v)) for b, v in self.bin_index.items()}
365
+ print(f"[rounds] {'train' if self.train else 'eval'} bin counts:",
366
+ counts, flush=True)
367
+ print(f"[rounds] cuts: {cuts.tolist()} (rounds "
368
+ f"{int(rounds.min())}..{int(rounds.max())})", flush=True)
369
+ empty = [b for b, c in counts.items() if c == 0]
370
+ if empty and self.train:
371
+ raise ValueError(
372
+ f"round-bin curriculum has empty train bins {empty}; those "
373
+ f"stages would have no puzzles and no frontier signal")
374
+
375
+ def _load_candidate_masks(self):
376
+ """Load staged candidate-set masks (N, S, 81) uint16, aligned by puzzle
377
+ index with the loaded .npy. Row i here == puzzle i in the base file."""
378
+ if self.train:
379
+ path = getattr(self.config, "train_cand_masks_path", None)
380
+ else:
381
+ path = getattr(self.config, "test_cand_masks_path", None)
382
+ self.cand_masks = None
383
+ self.num_stages = 0
384
+ if path:
385
+ self.cand_masks = np.load(path, mmap_mode="r")
386
+ self.num_stages = int(self.cand_masks.shape[1])
387
+ print(f"[cand] loaded {path} shape {self.cand_masks.shape}", flush=True)
388
+
389
+ def slot_budget(self, level):
390
+ """Number of latent slots this example activates (see cand_slot_mode).
391
+
392
+ "depth" mode must agree with the recurrence depth used by the train
393
+ step, since build_latent_state only ever writes slots [0, num_passes):
394
+ supervising a slot the recurrence never filled would train the head off
395
+ an all-zero latent.
396
+ """
397
+ K = self.num_latent_slots
398
+ if getattr(self.config, "cand_slot_mode", "level") == "depth":
399
+ stage = self.curriculum.stage if self.curriculum is not None \
400
+ else getattr(self.config, "curriculum_max_stage", 6)
401
+ pps = int(getattr(self.config, "passes_per_stage", 1))
402
+ return int(np.clip(pps * stage, 1, K))
403
+ return int(np.clip(level - 2, 1, K))
404
+
405
+ def _slot_stage_targets(self, idx, level, clue_cells=None):
406
+ """Return (K, 81) int32 candidate bitmasks, one per latent slot.
407
+
408
+ The S stored stages are mapped onto the example's k active slots; see
409
+ cand_slot_mode for the two mappings ("level" re-paces the whole shrink
410
+ sequence into k slots, "depth" assigns slot j to stage j). Inactive slots
411
+ (j>=k) default to the final stage; they are masked out of the loss.
412
+
413
+ Although the array is laid out over all 81 cell positions (for a fixed
414
+ batch shape), the supervised targets are only the *empty* cells: clue
415
+ cells are zeroed out here as a sentinel (a genuine empty cell always has
416
+ >=1 candidate at every stage), and the loss ignores zero rows. So the
417
+ effective target per puzzle is (#empty cells) x 9, in solver order."""
418
+ K = self.num_latent_slots
419
+ if self.cand_masks is None or K == 0:
420
+ return np.zeros((K, 81), dtype=np.int32)
421
+ S = self.num_stages
422
+ k = self.slot_budget(level)
423
+ depth_mode = getattr(self.config, "cand_slot_mode", "level") == "depth"
424
+ stages = self.cand_masks[idx].astype(np.int32) # (S, 81)
425
+ out = np.zeros((K, 81), dtype=np.int32)
426
+ for j in range(K):
427
+ if j >= k:
428
+ # Inactive slot: masked out of the loss, value is irrelevant.
429
+ s = S - 1
430
+ elif depth_mode:
431
+ # Identity: slot j holds propagation block j, so growing the
432
+ # recurrence depth extends the chain instead of re-pacing it.
433
+ # The solution is only reached at full depth, which is what
434
+ # makes this a curriculum over reasoning depth.
435
+ s = min(j, S - 1)
436
+ else:
437
+ # Active slots span the full shrink sequence: slot 0 -> stage 0
438
+ # (widest candidate set, genuinely multi-valued), last active
439
+ # slot -> final stage (solution). For k==1 the single slot maps
440
+ # to the WIDEST set (stage 0), not the solution, so even level-3
441
+ # puzzles give the candidate head a real multi-candidate target
442
+ # (the LM head still produces the unique answer).
443
+ s = int(round(j * (S - 1) / max(k - 1, 1)))
444
+ out[j] = stages[s]
445
+ # Sentinel-zero the clue cells so only the empty cells are supervised.
446
+ if clue_cells is not None and len(clue_cells) > 0:
447
+ out[:, clue_cells] = 0
448
+ return out
449
+
450
+ def _build_level_index(self, levels):
451
+ """Map difficulty level -> array of puzzle indices."""
452
+ return {lvl: np.where(levels == lvl)[0] for lvl in range(3, 9)}
453
+
454
+ def insert_latent_slots(self, seq, start_index):
455
+ """Insert K latent placeholder tokens between clues and solution.
456
+
457
+ seq: (243,) triple sequence. Returns (243 + K,) sequence:
458
+ [clues (3*si)] [K placeholders] [solution triples].
459
+ """
460
+ k = self.num_latent_slots
461
+ if k == 0:
462
+ return seq
463
+ si3 = 3 * int(start_index)
464
+ return np.concatenate([
465
+ seq[:si3],
466
+ np.full(k, self.latent_token_id, dtype=seq.dtype),
467
+ seq[si3:],
468
+ ])
469
+
470
+
471
+ def convert_to_fixed_or_random_order(self, inputs, start_index):
472
+ """Convert the sequence of moves to either a fixed or random order.
473
+
474
+ Args:
475
+ inputs: a numpy array of shape (num_puzzles, seq_len) containing the
476
+ sequence of moves for each puzzle
477
+ start_index: a numpy array of shape (num_puzzles, 1) containing the starting
478
+ index for each puzzle
479
+
480
+ Returns:
481
+ transformed_input: a numpy array of shape (num_puzzles, seq_len) containing the
482
+ sequence of moves for each puzzle in either a fixed or random order
483
+ """
484
+ transformed_input = np.zeros_like(inputs)
485
+
486
+ for i in range(len(inputs)):
487
+ cur_seq = inputs[i]
488
+ cur_start_index = start_index[i, 0]
489
+
490
+ # Split the sequence into input and output prompts
491
+ inp_prompt = cur_seq[ :(3 * cur_start_index) ].reshape(-1, 3)
492
+ out_prompt = cur_seq[ (3 * cur_start_index): ].reshape(-1, 3)
493
+
494
+ # Sort the input prompts in a fixed order
495
+ if self.config.seq_order == "fixed":
496
+ transformed_input[i, :(3 * cur_start_index) ] = inp_prompt[ np.lexsort( inp_prompt[:, ::-1].T ) ].flatten()
497
+ # Randomly shuffle the input prompts
498
+ elif self.config.seq_order == "random":
499
+ transformed_input[i, :(3 * cur_start_index) ] = np.random.permutation(inp_prompt).flatten()
500
+
501
+ # Sort the output prompts in a fixed order
502
+ if self.config.seq_order == "fixed":
503
+ transformed_input[i, (3 * cur_start_index): ] = out_prompt[ np.lexsort( out_prompt[:, ::-1].T ) ].flatten()
504
+ # Randomly shuffle the output prompts
505
+ elif self.config.seq_order == "random":
506
+ transformed_input[i, (3 * cur_start_index): ] = np.random.permutation(out_prompt).flatten()
507
+
508
+ return transformed_input
509
+
510
+ def get_puzzles_start_index(self, path):
511
+ """Get the puzzles, start index, inputs and difficulty levels.
512
+
513
+ Returns:
514
+ inputs: (num_puzzles, 243) move sequences (strategy column removed)
515
+ puzzles: (num_puzzles, 81) solutions
516
+ start_index: (num_puzzles, 1) number of clue cells
517
+ levels: (num_puzzles,) puzzle difficulty level in [3, 8]
518
+ (= hardest solver-strategy digit needed by any cell)
519
+ """
520
+ with gfile.Open(path, "rb") as f:
521
+ inputs_with_start_index = np.load(f)
522
+ start_index = inputs_with_start_index[:, 0] # Get the start index
523
+
524
+ rest = inputs_with_start_index[:, 1:]
525
+ # Strategy chain codes (4th entry of each cell quadruple); keep them to
526
+ # derive the curriculum difficulty level, then remove from the inputs.
527
+ strategy_codes = rest.reshape(len(rest), 81, 4)[:, :, 3]
528
+ levels = compute_puzzle_levels(strategy_codes, start_index)
529
+ inputs = np.delete( rest, np.arange(81) * 4 + 3, axis=1)
530
+
531
+ puzzles = np.zeros((len(inputs), 81), dtype=np.int8) # Initialize puzzles
532
+ for j in range(81):
533
+ cell_id = inputs[:, 3 * j] * 9 + inputs[:, 3 * j + 1] # Get the cell id
534
+ puzzles[np.arange(len(inputs)), cell_id] = inputs[:, 3 * j + 2] # Set the puzzle
535
+
536
+ return inputs, puzzles, start_index.reshape(-1, 1), levels
537
+
538
+
539
+ def preprocess_sudoku(self):
540
+ """Preprocess the sudoku for train and test datasets.
541
+
542
+ Depending on the `train` flag, this method loads and processes the
543
+ sudoku puzzles and their start indices from the appropriate paths, and
544
+ optionally converts them to a fixed or random order based on the
545
+ configuration.
546
+ """
547
+ if self.train is True:
548
+ # Load train puzzles, inputs, and start indices
549
+ (self.train_inputs, self.train_puzzles, self.train_start_index,
550
+ self.train_levels) = (
551
+ self.get_puzzles_start_index(self.config.train_puzzle_path)
552
+ )
553
+ # Convert train inputs to fixed or random order if specified
554
+ if self.config.seq_order in {"fixed", "random"}:
555
+ self.train_inputs = self.convert_to_fixed_or_random_order(self.train_inputs, self.train_start_index)
556
+ self.level_index = self._build_level_index(self.train_levels)
557
+ print("train level counts:",
558
+ {l: len(v) for l, v in self.level_index.items()}, flush=True)
559
+
560
+ elif self.train is False:
561
+ # Load evaluation puzzles, inputs, and start indices
562
+ (self.eval_inputs, self.eval_puzzles, self.eval_start_index,
563
+ self.eval_levels) = (
564
+ self.get_puzzles_start_index(self.config.test_puzzle_path)
565
+ )
566
+ # Convert evaluation inputs to fixed or random order if specified
567
+ if self.config.seq_order in {"fixed", "random"}:
568
+ self.eval_inputs = self.convert_to_fixed_or_random_order(self.eval_inputs, self.eval_start_index)
569
+ self.level_index = self._build_level_index(self.eval_levels)
570
+
571
+ def __len__(self):
572
+ if self.train is True:
573
+ return len(self.train_puzzles)
574
+ elif self.train is False:
575
+ return len(self.eval_puzzles)
576
+
577
+ def __getitem__(self, idx, inst_row=None):
578
+ """Returns one example: (sequence with latent slots, solution,
579
+ start_index, difficulty level).
580
+
581
+ The base sequence is 243 tokens of (row, column, value) triples; K
582
+ latent placeholder tokens are inserted after the clue block, giving
583
+ 243 + K tokens. start_index is the number of clue cells; level in
584
+ [3, 8] is the hardest solver strategy needed by any cell.
585
+ """
586
+ if self.train is True:
587
+ inputs, puzzles = self.train_inputs, self.train_puzzles
588
+ start_index, levels = self.train_start_index, self.train_levels
589
+ else:
590
+ inputs, puzzles = self.eval_inputs, self.eval_puzzles
591
+ start_index, levels = self.eval_start_index, self.eval_levels
592
+
593
+ base = inputs[idx, :]
594
+ if (self.instances is not None or getattr(self, "inst_uniform", 0)) \
595
+ and self.train:
596
+ # Same input prompt, different output prompt: the clue triples are
597
+ # untouched (their instance digit is the clue) while the empty cells
598
+ # take one draw from the stage's candidate sets. Averaged over the
599
+ # instances the target IS the candidate set, so the superposition is
600
+ # learned from ordinary next-token CE instead of a set head. Eval
601
+ # keeps the unique solution: the sequence it scores is generated, and
602
+ # `puzzles` must stay the ground truth the accuracy is measured on.
603
+ base = self.apply_instance(base, idx, self.instance_stage(),
604
+ inst_row=inst_row)
605
+ seq = self.insert_latent_slots(base, start_index[idx, 0])
606
+ # Clue cells = the first `start_index` (r,c,v) triples; their cell ids
607
+ # are excluded from candidate supervision (only empty cells are scored).
608
+ si = int(start_index[idx, 0])
609
+ clue_triples = inputs[idx, :3 * si].reshape(-1, 3)
610
+ clue_cells = (clue_triples[:, 0] * 9 + clue_triples[:, 1]).astype(np.int64)
611
+ cand_targets = self._slot_stage_targets(idx, int(levels[idx]), clue_cells)
612
+ rbin = (int(self.round_bins[idx]) if self.round_bins is not None else 0)
613
+ return (
614
+ seq,
615
+ puzzles[idx, :],
616
+ start_index[idx],
617
+ np.array([levels[idx]], dtype=np.int32),
618
+ cand_targets,
619
+ np.array([rbin], dtype=np.int32),
620
+ )
621
+
622
+ def _sample_level(self):
623
+ """Uniform over levels that have puzzles. Used for level-balanced mode
624
+ and for eval, where per-level reporting needs every level represented."""
625
+ avail = [l for l in range(3, 9) if len(self.level_index[l]) > 0]
626
+ return avail[self.rng.randint(len(avail))]
627
+
628
+ def _sample_round_gated(self):
629
+ """Uniform over puzzles whose round-bin is already unlocked (bin<=stage).
630
+ Reading the stage at yield time lets the pool grow on promotion."""
631
+ stage = self.curriculum.stage if self.curriculum is not None \
632
+ else self.num_bins
633
+ stage = int(np.clip(stage, 1, self.num_bins))
634
+ pool = np.concatenate([self.bin_index[b] for b in range(1, stage + 1)])
635
+ return int(pool[self.rng.randint(len(pool))])
636
+
637
+ def __call__(self):
638
+ # Infinite generator. Train draws puzzles uniformly from the whole
639
+ # corpus (natural difficulty mix, ~68% level 3): the difficulty tag
640
+ # selects nothing, since the curriculum axis is propagation depth.
641
+ # Eval stays level-balanced so per-level accuracy is measurable and
642
+ # comparable across runs.
643
+ #
644
+ # The exception is the round-count DATA curriculum, where the train pool
645
+ # is restricted to puzzles needing at most stage-many propagation waves.
646
+ # Eval is never gated: it must score the whole corpus at every stage.
647
+ round_gated = (self.train and self.round_bins is not None)
648
+ level_balanced = (not self.train) or bool(
649
+ int(getattr(self.config, "level_balanced_sampling", 0)))
650
+ n = len(self.train_puzzles) if self.train else len(self.eval_puzzles)
651
+
652
+ if getattr(self, "inst_pairs", None) is not None:
653
+ # Epoch mode: walk every (puzzle, instance) pair, reshuffled each
654
+ # epoch, so each instance of each puzzle is visited exactly once per
655
+ # epoch. Shuffling means a batch holds distinct puzzles, i.e. one
656
+ # instance of a given puzzle per batch rather than all of its
657
+ # instances side by side.
658
+ reps = max(int(getattr(self, "inst_epochs", 1)), 1)
659
+ npass = 0
660
+ while True:
661
+ stage = self.instance_stage()
662
+ if stage != self.inst_pairs_stage:
663
+ # Promotion: this stage has its own instance pool, so the
664
+ # pair list and the pass count both restart.
665
+ self._build_pairs_for_stage(stage)
666
+ npass = 0
667
+ puzzle_ids, rows = self.inst_pairs
668
+ total = len(rows)
669
+ order = np.arange(total)
670
+ self.rng.shuffle(order)
671
+ npass += 1
672
+ print(f"[inst] stage {stage + 1} pass {npass}: every "
673
+ f"(puzzle, instance) pair seen {npass}x of {reps} "
674
+ f"({total} pairs)", flush=True)
675
+ for i, t in enumerate(order):
676
+ # Promotion can land mid-pass; checking periodically keeps
677
+ # the targets on the current stage instead of finishing the
678
+ # old pool first. 256 examples is 4 batches.
679
+ if (i & 255) == 0 and self.instance_stage() != stage:
680
+ break
681
+ yield self.__getitem__(int(puzzle_ids[t]),
682
+ inst_row=int(rows[t]))
683
+
684
+ while True:
685
+ if round_gated:
686
+ idx = self._sample_round_gated()
687
+ elif level_balanced:
688
+ idx_arr = self.level_index[self._sample_level()]
689
+ idx = int(idx_arr[self.rng.randint(len(idx_arr))])
690
+ else:
691
+ idx = int(self.rng.randint(n))
692
+ yield self.__getitem__(idx)
code/wavecurriculum_run/train/evaluater.py ADDED
@@ -0,0 +1,723 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Evaluation related functions."""
2
+
3
+ import os
4
+
5
+ from flax.training import common_utils
6
+ import jax
7
+ from jax import numpy as jnp
8
+ import numpy as np
9
+
10
+ from train import model
11
+
12
+ import pdb
13
+
14
+
15
+ def _verbose_eval():
16
+ return os.environ.get("SUDOKU_VERBOSE_EVAL", "0") == "1"
17
+
18
+
19
+ def _lcs_len(a, b):
20
+ """Length of the longest common subsequence of two cell sequences.
21
+
22
+ Measures order agreement while tolerating insertions, so a single early
23
+ deviation costs one step instead of desyncing every later comparison the
24
+ way a positional match does.
25
+ """
26
+ prev = [0] * (len(b) + 1)
27
+ for x in a:
28
+ cur = [0]
29
+ for k, y in enumerate(b):
30
+ cur.append(prev[k] + 1 if x == y else max(cur[k], prev[k + 1]))
31
+ prev = cur
32
+ return prev[-1]
33
+
34
+
35
+ def _cand_value_stats(logp_digits, bits):
36
+ """Value-head statistics against one stage's candidate sets.
37
+
38
+ The instance target at a cell is drawn uniformly from that cell's candidate
39
+ set S, so the CE the trainer minimizes is
40
+ CE = -(1/|S|) sum_{d in S} log p(d) >= log|S|,
41
+ with equality iff p == Uniform(S); collapsing onto one candidate sends it to
42
+ infinity. So CE - log|S| == KL(Uniform(S) || p) is zero exactly at true
43
+ superposition and grows from leakage outside S or collapse inside it.
44
+
45
+ Args:
46
+ logp_digits: (n, 9) log-probabilities of digits 1..9 under the model.
47
+ bits: (n,) int bitmask; bit d-1 set iff digit d is a candidate.
48
+
49
+ Returns:
50
+ Dict of sums over the n cells plus the counts they divide by. `spread`
51
+ (normalized within-set entropy, 1.0 = uniform over S) is only defined
52
+ for |S| >= 2, hence its own count.
53
+ """
54
+ in_s = ((bits[:, None] >> np.arange(9)) & 1).astype(np.float64) # (n, 9)
55
+ size = in_s.sum(axis=1)
56
+ p_d = np.exp(logp_digits)
57
+ mass = (in_s * p_d).sum(axis=1)
58
+ ce = -(in_s * logp_digits).sum(axis=1) / size
59
+ # excess splits exactly into the two things we want driven to zero:
60
+ # excess = CE - log|S| = log(1/mass) + KL(Uniform(S) || p/mass)
61
+ # \_________/ \_________/
62
+ # leak outside non-uniformity inside
63
+ # Both terms are >= 0, so excess == 0 iff all mass is on S AND spread
64
+ # uniformly over it. Derivation: writing p(d) = mass * q(d) for d in S,
65
+ # CE = -log(mass) - (1/|S|) sum log q(d) = -log(mass) + log|S| + KL(U||q).
66
+ excess = ce - np.log(size)
67
+ out = {
68
+ "ce": float(ce.sum()),
69
+ "floor": float(np.log(size).sum()),
70
+ "mass": float(mass.sum()),
71
+ "count": int(size.size),
72
+ "spread": 0.0,
73
+ "spread_count": 0,
74
+ # Same quantities restricted to genuinely superposed cells (|S| >= 2).
75
+ # Singleton cells are already determined at this stage, so there is
76
+ # nothing to be uniform about; as the curriculum deepens they come to
77
+ # dominate the pooled average and dilute the superposition signal.
78
+ "excess_multi": 0.0,
79
+ "mass_multi": 0.0,
80
+ "kl_multi": 0.0,
81
+ }
82
+ multi = size >= 2
83
+ if multi.any():
84
+ m = np.maximum(mass[multi], 1e-12)
85
+ q = in_s[multi] * p_d[multi] / m[:, None]
86
+ h = -(q * np.log(np.maximum(q, 1e-12))).sum(axis=1)
87
+ out["spread"] = float((h / np.log(size[multi])).sum())
88
+ out["spread_count"] = int(multi.sum())
89
+ out["excess_multi"] = float(excess[multi].sum())
90
+ out["mass_multi"] = float(mass[multi].sum())
91
+ # KL(Uniform(S) || q): the non-uniformity term of the split above.
92
+ # Stricter than 1 - spread, because it weights every candidate equally
93
+ # and so blows up when the model drops one candidate to ~0.
94
+ out["kl_multi"] = float((excess[multi] + np.log(m)).sum())
95
+ return out
96
+
97
+
98
+ def _wave_order_score(emitted, truth, waves, max_wave):
99
+ """How many emitted cells came from the earliest still-unfilled wave.
100
+
101
+ A cell's wave is the propagation depth that determines it. Cells sharing a
102
+ wave are order-interchangeable, so the solver order in the data is only one
103
+ valid linearization; crediting any cell from the frontier wave measures
104
+ "is the model respecting propagation depth" without penalizing an arbitrary
105
+ tie-break. Cells that repeat or are not real empty cells score nothing.
106
+ """
107
+ left = dict(zip(truth, waves))
108
+ per_wave = np.bincount(np.asarray(waves, dtype=np.int64),
109
+ minlength=max_wave + 1)
110
+ min_w, ok = 0, 0
111
+ for cell in emitted:
112
+ while min_w <= max_wave and per_wave[min_w] == 0:
113
+ min_w += 1
114
+ if min_w > max_wave:
115
+ break
116
+ cw = left.pop(cell, None)
117
+ if cw is None:
118
+ continue
119
+ per_wave[cw] -= 1
120
+ ok += int(cw == min_w)
121
+ return ok
122
+
123
+
124
+ def valid_solution(output_seq):
125
+ """
126
+ This function checks if the puzzle is a valid solution by verifying if
127
+ each row, column and box has all the numbers from 1 to 9.
128
+
129
+ Args:
130
+ output_seq: a numpy array of shape (243,) containing the sequence of
131
+ output numbers
132
+
133
+ Returns:
134
+ int: 1 if correct solution, otherwise returns 0
135
+ """
136
+ # rows[i, j] keeps track if ith row has received (j + 1) number
137
+ rows = np.zeros((9, 9))
138
+ # cols[i, j] keeps track if ith column has received (j + 1) number
139
+ cols = np.zeros((9, 9))
140
+ # boxes[i, j] keeps track if ith box has received (j + 1) number
141
+ boxes = np.zeros((9, 9))
142
+
143
+ for j in range(81):
144
+ # The row and column are in the range (0, 8) and puzzle values are in (1, 9)
145
+ if int(output_seq[3 * j]) >= 9:
146
+ return False
147
+ if int(output_seq[3 * j + 1]) >= 9:
148
+ return False
149
+ if int(output_seq[3 * j + 2]) > 9:
150
+ return False
151
+
152
+ row_num = int(output_seq[3 * j])
153
+ col_num = int(output_seq[3 * j + 1])
154
+
155
+ # Mark the number in the row, column and box
156
+ rows[row_num, int(output_seq[3 * j + 2] - 1)] += 1
157
+ cols[col_num, int(output_seq[3 * j + 2] - 1)] += 1
158
+ boxes[
159
+ int(3 * (row_num // 3) + (col_num // 3)), int(output_seq[3 * j + 2] - 1)
160
+ ] += 1
161
+
162
+ if np.all(rows) and np.all(cols) and np.all(boxes):
163
+ return True
164
+ else:
165
+ return False
166
+
167
+
168
+ def eval_step(state, batch, latent_vals, slot_pos, latent_active, config):
169
+ pred_logits, hidden, cand_logits = model.TransformerLMHeadModel(config).apply(
170
+ {"params": state.params}, batch, latent_values=latent_vals,
171
+ latent_positions=slot_pos, latent_active=latent_active,
172
+ )
173
+ return pred_logits, hidden, cand_logits
174
+
175
+
176
+ def verify_sudoku_board(puzzle, row_num, col_num, num):
177
+ """
178
+ Args:
179
+ puzzle (np.array): The correct Sudoku puzzle.
180
+ row_num (int): The row number (0-8).
181
+ col_num (int): The column number (0-8).
182
+ num (int): The number predicted at the specified row and column.
183
+
184
+ Raises:
185
+ 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.
186
+ """
187
+ if row_num * 9 + col_num >= 81:
188
+ assert False
189
+
190
+ assert puzzle[row_num * 9 + col_num] == num
191
+
192
+
193
+ def get_eval_metrics(state, eval_data_iter, p_eval_step, config):
194
+ """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.
195
+
196
+ Args:
197
+ state: contains model parameters, optimizer, etc.
198
+ eval_data_iter: data iterator for evaluation dataset
199
+ p_eval_step: pmap function for forward pass of model for evaluation
200
+ config: general experiment config file
201
+
202
+ Returns:
203
+ eval_metrics: contains list of evaluation metrics for each batch
204
+ """
205
+
206
+ eval_metrics = {
207
+ "acc": [], # Unique-solution placement (parent leftover; not printed as val_acc)
208
+ "loc_acc": [], # Location acc: model picks the ground-truth next cell (r,c)
209
+ # Permutation-tolerant location diagnostics. The upstream code scored
210
+ # only "acc" -- the digit at the cell the model chose -- and never
211
+ # compared (r,c) to the target order, because the solver order is one
212
+ # arbitrary linearization of a partial order: cells that become
213
+ # determined in the same propagation wave are interchangeable. loc_acc
214
+ # above therefore reads ~0.04 even when the model emits a valid
215
+ # permutation of the right cells, since one early deviation desyncs the
216
+ # rest of the positional comparison. These three replace it.
217
+ "loc_coverage": [], # distinct emitted cells that are really empty / #empty
218
+ "loc_dup": [], # fraction of emitted cells that repeat an earlier one
219
+ "loc_lcs": [], # longest common subsequence with target order / #empty
220
+ "loc_wave": [], # emitted cell sits in the earliest unfilled wave
221
+ "val_given_loc_acc": [], # Correct digit AMONG steps where location matched
222
+ "cand_bit_acc": [], # Per-digit accuracy of predicted candidate masks
223
+ "cand_set_acc": [], # Exact candidate-SET match per empty cell (all 9 bits)
224
+ "cand_set_acc_changed": [], # ...restricted to cells that changed this stage
225
+ "acc_complete_puzzle": [] # Accuracy of predicting correct complete puzzle
226
+ }
227
+ # Per-difficulty-level cell accuracy (levels 3..8). Diagnostic only: the
228
+ # curriculum no longer keys on level.
229
+ level_ok = {lvl: 0 for lvl in range(3, 9)}
230
+ level_tot = {lvl: 0 for lvl in range(3, 9)}
231
+
232
+ K = int(config.num_latent_slots)
233
+
234
+ # Per-SLOT candidate-set accuracy, i.e. per reasoning depth. Slot j holds
235
+ # wave snapshot j, so slot_ok[j]/slot_tot[j] is "how well is propagation
236
+ # block j predicted". This is the signal the depth curriculum promotes and
237
+ # backtracks on, replacing the old per-level accuracy.
238
+ slot_ok = np.zeros(max(K, 1), dtype=np.int64)
239
+ slot_tot = np.zeros(max(K, 1), dtype=np.int64)
240
+ slot_ok_ch = np.zeros(max(K, 1), dtype=np.int64)
241
+ slot_tot_ch = np.zeros(max(K, 1), dtype=np.int64)
242
+
243
+ # Per-stage in-set rate: was the emitted digit a *member* of that stage's
244
+ # candidate set? This is the promotion signal for the instance arm, where
245
+ # the target is one sampled assignment rather than the unique solution, so
246
+ # the model is right to emit any candidate. The candidate masks are read
247
+ # here as a metric only; nothing supervises them.
248
+ inset_ok = np.zeros(max(K, 1), dtype=np.int64)
249
+ inset_tot = np.zeros(max(K, 1), dtype=np.int64)
250
+
251
+ # Per-stage value-distribution stats, measured TEACHER-FORCED so the cell is
252
+ # given and the value head is scored in isolation from the model's choice of
253
+ # location. For a cell whose stage-s candidate set is S, the instance target
254
+ # is drawn uniformly from S, so the CE the trainer minimizes is
255
+ # CE = -(1/|S|) sum_{d in S} log p(d) >= log|S|,
256
+ # with equality iff p == Uniform(S). Collapsing onto a single candidate
257
+ # sends CE to infinity. So (CE - log|S|) == KL(Uniform(S) || p) is a single
258
+ # number that is 0 exactly at true superposition and grows from either
259
+ # leakage outside S or mode collapse inside it.
260
+ vce_sum = np.zeros(max(K, 1), dtype=np.float64) # sum of CE vs Uniform(S)
261
+ vfloor_sum = np.zeros(max(K, 1), dtype=np.float64) # sum of log|S|
262
+ vmass_sum = np.zeros(max(K, 1), dtype=np.float64) # sum of prob mass on S
263
+ vspread_sum = np.zeros(max(K, 1), dtype=np.float64) # sum of H(p|S)/log|S|
264
+ vcnt = np.zeros(max(K, 1), dtype=np.int64)
265
+ vspread_cnt = np.zeros(max(K, 1), dtype=np.int64) # only |S| >= 2 cells
266
+ # Same, restricted to |S| >= 2 (the cells that carry real superposition).
267
+ vexc_m_sum = np.zeros(max(K, 1), dtype=np.float64)
268
+ vmass_m_sum = np.zeros(max(K, 1), dtype=np.float64)
269
+ vkl_m_sum = np.zeros(max(K, 1), dtype=np.float64)
270
+
271
+ # Per-round-bin cell accuracy, for the round-count DATA curriculum: bin b is
272
+ # unlocked at stage b, so bin_ok[b]/bin_tot[b] measures competence on the
273
+ # puzzles that stage b introduced. This is the promotion signal for the arm
274
+ # that has no latent slots and therefore no per-depth signal.
275
+ n_bins = int(getattr(config, "curriculum_max_stage", 12))
276
+ bin_ok = {b: 0 for b in range(1, n_bins + 1)}
277
+ bin_tot = {b: 0 for b in range(1, n_bins + 1)}
278
+
279
+ for eval_epoch in range(config.eval_epochs):
280
+ with jax.profiler.StepTraceAnnotation("eval", step_num=eval_epoch):
281
+
282
+ batch_tuple = next(eval_data_iter)
283
+
284
+ # Input seq is (batchsize, 3*81 + K): clue triples, K latent
285
+ # placeholder slots, then solution triples.
286
+ input_seq = np.array(batch_tuple[0])
287
+
288
+ # Puzzle solution is of the shape (batchsize, 81). Each pos in {0,.., 80}
289
+ # for each puzzle contains value at cell (pos//9+1, pos%9 + 1)
290
+ puzzle_sol = np.array(batch_tuple[1])
291
+ start_index = np.array(batch_tuple[2])
292
+ levels = np.array(batch_tuple[3]).reshape(-1)
293
+ rbins = (np.array(batch_tuple[5]).reshape(-1)
294
+ if len(batch_tuple) > 5 else np.zeros_like(levels))
295
+ total_pred, sucess_pred = 0, 0
296
+ # Location = did the model emit the ground-truth next (r,c) cell.
297
+ loc_tot, loc_ok, val_given_loc_ok = 0, 0, 0
298
+
299
+ bs = input_seq.shape[0]
300
+ bidx = np.arange(bs)
301
+ si3 = 3 * start_index.reshape(-1)
302
+ slot_pos = si3[:, None] + np.arange(K)[None, :]
303
+ if getattr(config, "cand_slot_mode", "level") == "depth":
304
+ # Eval always builds all K latents, so score all K slots.
305
+ k_budget = np.full_like(levels, K)
306
+ else:
307
+ k_budget = np.clip(levels - 2, 1, K)
308
+ active_full = np.arange(K)[None, :] < k_budget[:, None]
309
+
310
+ def run_model(seq_batch, latent_vals, act, want_cand=False):
311
+ sharded = common_utils.shard(
312
+ jax.tree_util.tree_map(np.asarray, seq_batch))
313
+ # Explicit reshape so a zero-width slot dim (K=0 baseline)
314
+ # shards without the ambiguous -1 inference of shard().
315
+ _nd = jax.local_device_count()
316
+ def _shard(x):
317
+ x = np.asarray(x)
318
+ return x.reshape((_nd, x.shape[0] // _nd) + x.shape[1:])
319
+ lv = _shard(latent_vals)
320
+ lp = _shard(slot_pos)
321
+ la = _shard(act)
322
+ logits, hidden, cand = p_eval_step(state, sharded, lv, lp, la)
323
+ logits = np.array(logits).reshape(bs, *np.array(logits).shape[2:])
324
+ hidden = np.array(hidden).reshape(bs, *np.array(hidden).shape[2:])
325
+ if want_cand:
326
+ cand = np.array(cand).reshape(bs, *np.array(cand).shape[2:])
327
+ return logits, hidden, cand
328
+ return logits, hidden
329
+
330
+ # ---- Build the continuous latent thoughts (K recurrence passes,
331
+ # difficulty-matched budget; causal masking means only the clue
332
+ # region influences them). ----
333
+ latent_vals = np.zeros((bs, K, config.emb_dim), dtype=np.float32)
334
+ build_seq = np.array(input_seq)
335
+ build_seq_masked = np.array(build_seq)
336
+ # Hide the solution region during latent build (safety; causality
337
+ # already prevents leakage into slot hiddens).
338
+ for j in range(bs):
339
+ build_seq_masked[j, si3[j] + K:] = 0
340
+ # Recurrent feedback: build each latent thought from the previous
341
+ # slot's hidden. Skipped when the model does not inject latents
342
+ # (no-recurrence control): slots stay as static placeholders, so
343
+ # latent_vals is left at zeros and never used.
344
+ recurrent = bool(int(getattr(config, "recurrent_latent", 1)))
345
+ if recurrent and K > 0:
346
+ for j in range(K):
347
+ act_j = active_full & (np.arange(K)[None, :] < j)
348
+ _, hidden = run_model(build_seq_masked, latent_vals, act_j)
349
+ src = si3 - 1 + j
350
+ latent_vals[:, j] = hidden[bidx, src]
351
+
352
+ # ---- Candidate-set prediction accuracy (the multi-value target) ----
353
+ # One forward pass with the fully-built latents; read the per-slot
354
+ # candidate head and compare to the staged bitmask targets, scored
355
+ # only over active slots and empty cells (clue cells were zeroed).
356
+ # Skipped entirely for the K=0 no-latent baseline (no candidate head).
357
+ pred_bits = tgt_bits = cand_targets = None
358
+ if K > 0:
359
+ cand_targets = np.array(batch_tuple[4]).astype(np.int64) # (bs, K, 81)
360
+ # The candidate head is off in the instance arm (aux weight 0), so
361
+ # skip its forward pass and set metrics; the masks above are still
362
+ # read for the in-set rate.
363
+ if K > 0 and float(getattr(config, "aux_cand_weight", 1.0)) > 0.0:
364
+ _, _, cand_logits = run_model(
365
+ build_seq_masked, latent_vals, active_full, want_cand=True) # (bs,K,81,9)
366
+ pred_bits = (np.array(cand_logits) > 0.0) # sigmoid>0.5
367
+ tgt_bits = ((cand_targets[..., None] >> np.arange(9)) & 1).astype(bool)
368
+ valid = (cand_targets > 0) & active_full[:, :, None] # (bs,K,81)
369
+ if valid.sum() > 0:
370
+ bit_match = (pred_bits == tgt_bits) # (bs,K,81,9)
371
+ eval_metrics["cand_bit_acc"].append(
372
+ float(bit_match[valid].mean()))
373
+ eval_metrics["cand_set_acc"].append(
374
+ float(bit_match.all(axis=3)[valid].mean()))
375
+ # Same score restricted to cells whose candidate set
376
+ # actually changed from the previous stage. The unrestricted
377
+ # metrics above are dominated by cells that are unchanged
378
+ # copies of slot j-1, so they stay high for a head that has
379
+ # learned nothing but "repeat the previous slot".
380
+ changed = np.concatenate(
381
+ [np.ones_like(cand_targets[:, :1], dtype=bool),
382
+ cand_targets[:, 1:] != cand_targets[:, :-1]], axis=1)
383
+ valid_ch = valid & changed
384
+ if valid_ch.sum() > 0:
385
+ eval_metrics["cand_set_acc_changed"].append(
386
+ float(bit_match.all(axis=3)[valid_ch].mean()))
387
+ # Accumulate the same score split by slot (= depth).
388
+ set_match = bit_match.all(axis=3) # (bs,K,81)
389
+ slot_ok += (set_match & valid).sum(axis=(0, 2))
390
+ slot_tot += valid.sum(axis=(0, 2))
391
+ slot_ok_ch += (set_match & valid_ch).sum(axis=(0, 2))
392
+ slot_tot_ch += valid_ch.sum(axis=(0, 2))
393
+
394
+ # ---- Teacher-forced value distribution vs the candidate set ----
395
+ # One forward pass on the ground-truth solver-order sequence. The
396
+ # prefix pins down which cell each value slot refers to, so this
397
+ # measures the value head alone: location cannot contaminate it,
398
+ # and no sampling is needed because the full softmax is available.
399
+ if K > 0 and cand_targets is not None:
400
+ tf_logits, _ = run_model(input_seq, latent_vals, active_full)
401
+ # log_softmax over the whole vocab, matching the training CE.
402
+ tf_shift = tf_logits - tf_logits.max(axis=-1, keepdims=True)
403
+ tf_logp = tf_shift - np.log(
404
+ np.exp(tf_shift).sum(axis=-1, keepdims=True))
405
+ for j in range(bs):
406
+ si = int(start_index[j].reshape(-1)[0])
407
+ n_out = 81 - si
408
+ if n_out <= 0:
409
+ continue
410
+ base = 3 * si + K
411
+ t = np.arange(n_out)
412
+ v_pos = base + 3 * t + 2 # value token positions
413
+ if v_pos[-1] >= config.seq_len:
414
+ keep = v_pos < config.seq_len
415
+ t, v_pos = t[keep], v_pos[keep]
416
+ if t.size == 0:
417
+ continue
418
+ # Token ids are the numbers themselves: rows/cols 0..8 and
419
+ # digits 1..9, so digit d lives at vocab index d.
420
+ cells = (input_seq[j, base + 3 * t] * 9
421
+ + input_seq[j, base + 3 * t + 1]).astype(np.int64)
422
+ ok_cell = (cells >= 0) & (cells < 81)
423
+ # logits at index p-1 predict the token at position p.
424
+ logp_d = tf_logp[j, v_pos - 1, 1:10] # (n, 9)
425
+ for s in range(K):
426
+ bits = cand_targets[j, s, np.where(ok_cell, cells, 0)]
427
+ # bits <= 0 marks a clue cell, which is not supervised.
428
+ sel = ok_cell & (bits > 0)
429
+ if not sel.any():
430
+ continue
431
+ st = _cand_value_stats(logp_d[sel], bits[sel])
432
+ vce_sum[s] += st["ce"]
433
+ vfloor_sum[s] += st["floor"]
434
+ vmass_sum[s] += st["mass"]
435
+ vcnt[s] += st["count"]
436
+ vspread_sum[s] += st["spread"]
437
+ vspread_cnt[s] += st["spread_count"]
438
+ vexc_m_sum[s] += st["excess_multi"]
439
+ vmass_m_sum[s] += st["mass_multi"]
440
+ vkl_m_sum[s] += st["kl_multi"]
441
+
442
+ min_start_index = int(np.min(start_index))
443
+ cur_input_seq = input_seq[:, :(min_start_index*3)]
444
+ for i in range(min_start_index * 3, config.seq_len):
445
+ ### In i^th iteration, i^th number in sequence will predict
446
+ padding = np.zeros((input_seq.shape[0],
447
+ config.seq_len - len(cur_input_seq[0])),
448
+ dtype=np.int32)
449
+ concat_batch = np.hstack((cur_input_seq, padding))
450
+
451
+ pred_logits, _ = run_model(concat_batch, latent_vals, active_full)
452
+
453
+ # Positions < 3*start_index + K are given (clues + latent
454
+ # slots); the model predicts from there on. K is a multiple
455
+ # of 3, so the triple phase of i is unchanged.
456
+ if i%3 == 2:
457
+ # Model predicts the value at the cell (cur_input_seq[j][i-2],
458
+ # cur_input_seq[j][i-1])
459
+ max_number = pred_logits[:, i-1, :].argmax(axis=-1).flatten()
460
+ mask_arr = np.array(i >= (3 * start_index + K)).squeeze()
461
+
462
+ next_number = max_number * mask_arr + (1 - mask_arr) * input_seq[:, i]
463
+
464
+ cur_input_seq = np.hstack(
465
+ (cur_input_seq, np.reshape(next_number, (-1, 1)))
466
+ )
467
+
468
+ # Iterate through all examples in batch and calculate successful
469
+ # predictions of numbers
470
+ for j in range(len(cur_input_seq)):
471
+ if not mask_arr[j]:
472
+ continue
473
+
474
+ total_pred += 1
475
+ level_tot[int(levels[j])] += 1
476
+ if int(rbins[j]) in bin_tot:
477
+ bin_tot[int(rbins[j])] += 1
478
+
479
+ # Location accuracy: did the model emit the ground-truth
480
+ # next cell (r,c) for this solver-order step?
481
+ loc_tot += 1
482
+ loc_match = (int(cur_input_seq[j][i-2]) == int(input_seq[j, i-2])
483
+ and int(cur_input_seq[j][i-1]) == int(input_seq[j, i-1]))
484
+ if loc_match:
485
+ loc_ok += 1
486
+
487
+ # In-set rate per stage, scored at the ground-truth cell
488
+ # so a wrong location cannot make a digit vacuously
489
+ # legal. cand_targets[j, s, cell] is stage s's bitmask
490
+ # under cand_slot_mode="depth" (slot s <-> stage s).
491
+ if cand_targets is not None and loc_match:
492
+ cell = (int(input_seq[j, i-2]) * 9
493
+ + int(input_seq[j, i-1]))
494
+ v = int(cur_input_seq[j][i])
495
+ for s in range(K):
496
+ bits = int(cand_targets[j, s, cell])
497
+ if bits <= 0: # clue cell, not supervised
498
+ continue
499
+ inset_tot[s] += 1
500
+ if 1 <= v <= 9 and (bits >> (v - 1)) & 1:
501
+ inset_ok[s] += 1
502
+
503
+ try:
504
+ verify_sudoku_board(puzzle_sol[j], cur_input_seq[j][i-2],
505
+ cur_input_seq[j][i-1], cur_input_seq[j][i])
506
+ except AssertionError:
507
+ # Mistake
508
+ pass
509
+ else:
510
+ sucess_pred += 1
511
+ level_ok[int(levels[j])] += 1
512
+ if int(rbins[j]) in bin_ok:
513
+ bin_ok[int(rbins[j])] += 1
514
+ if loc_match:
515
+ val_given_loc_ok += 1
516
+ else:
517
+ # Model predicts either a row number or column number
518
+ max_pos = pred_logits[:, i-1, :].argmax(axis=-1).flatten()
519
+ mask = (i >= (3 * start_index + K)).squeeze()
520
+ next_pos = max_pos * mask + (1 - mask) * input_seq[:, i]
521
+
522
+ # pdb.set_trace()
523
+ cur_input_seq = np.hstack(
524
+ (cur_input_seq, np.reshape(next_pos, (-1, 1)))
525
+ )
526
+
527
+ eval_metrics["acc"].append(sucess_pred * 1.0/ total_pred)
528
+ eval_metrics["loc_acc"].append(loc_ok * 1.0 / max(loc_tot, 1))
529
+ eval_metrics["val_given_loc_acc"].append(
530
+ val_given_loc_ok * 1.0 / max(loc_ok, 1))
531
+
532
+ def strip_latent_slots(seq, si):
533
+ return np.concatenate([seq[:3*si], seq[3*si + K:]])
534
+
535
+ # ---- Permutation-tolerant location diagnostics ----
536
+ # A cell's wave = the first stage at which its candidate set becomes
537
+ # a singleton, i.e. the propagation depth that determines it. Cells
538
+ # sharing a wave are order-interchangeable, so "did you name a cell
539
+ # from the earliest wave that is still unfilled" is the meaningful
540
+ # ordering signal; the exact index within the wave is arbitrary.
541
+ cov_b, dup_b, lcs_b, wav_b = [], [], [], []
542
+ for j in range(bs):
543
+ si = int(start_index[j].reshape(-1)[0])
544
+ pred = strip_latent_slots(cur_input_seq[j], si)
545
+ true = strip_latent_slots(input_seq[j], si)
546
+ emitted = [(int(pred[3 * k]), int(pred[3 * k + 1]))
547
+ for k in range(si, 81)]
548
+ truth = [(int(true[3 * k]), int(true[3 * k + 1]))
549
+ for k in range(si, 81)]
550
+ if not truth or not emitted:
551
+ continue
552
+ n_true = len(truth)
553
+ cov_b.append(len(set(emitted) & set(truth)) / n_true)
554
+ dup_b.append(1.0 - len(set(emitted)) / len(emitted))
555
+ # LCS is quadratic, so sample a few examples per batch.
556
+ if j < 32:
557
+ lcs_b.append(_lcs_len(emitted, truth) / n_true)
558
+
559
+ if cand_targets is None or K == 0:
560
+ continue
561
+ cids = np.array([r * 9 + c for r, c in truth], dtype=np.int64)
562
+ bits = cand_targets[j][:, cids] # (K, n)
563
+ singleton = (bits > 0) & ((bits & (bits - 1)) == 0)
564
+ w = np.where(singleton.any(axis=0), singleton.argmax(axis=0), K)
565
+ wav_b.append(
566
+ _wave_order_score(emitted, truth, w, K) / n_true)
567
+
568
+ if cov_b:
569
+ eval_metrics["loc_coverage"].append(float(np.mean(cov_b)))
570
+ eval_metrics["loc_dup"].append(float(np.mean(dup_b)))
571
+ if lcs_b:
572
+ eval_metrics["loc_lcs"].append(float(np.mean(lcs_b)))
573
+ if wav_b:
574
+ eval_metrics["loc_wave"].append(float(np.mean(wav_b)))
575
+
576
+ # ---- Print one concrete example answer the model generated ----
577
+ if eval_epoch == 0:
578
+ j = 0
579
+ si = int(start_index[j, 0])
580
+ pred = strip_latent_slots(cur_input_seq[j], si)
581
+ shown, n_ok, n_tot = [], 0, 0
582
+ for k in range(si, 81):
583
+ r, c, v = int(pred[3*k]), int(pred[3*k+1]), int(pred[3*k+2])
584
+ tv = int(puzzle_sol[j][r*9+c]) if (0 <= r < 9 and 0 <= c < 9) else -1
585
+ ok = (0 <= r < 9 and 0 <= c < 9 and v == tv)
586
+ n_tot += 1; n_ok += int(ok)
587
+ if len(shown) < 12:
588
+ shown.append(f"({r},{c})->{v}[true {tv}]{'ok' if ok else 'X'}")
589
+ if _verbose_eval():
590
+ print(f"EXAMPLE (level={int(levels[j])}, k={int(k_budget[j])}): "
591
+ f"model emitted {n_tot} (r,c)->v triples for the empty cells "
592
+ f"(format: (row,col)->value[true T]); first 12:", flush=True)
593
+ print(" ", " ".join(shown), flush=True)
594
+ print(f"EXAMPLE cells-correct={n_ok}/{n_tot} "
595
+ f"valid_full_grid={valid_solution(pred)}", flush=True)
596
+
597
+ # Instance arm: emitted digit next to the deepest stage's
598
+ # candidate set, so it is visible whether the model is sitting
599
+ # inside the superposition or outside it.
600
+ if K > 0 and cand_targets is not None and pred_bits is None:
601
+ tgt = strip_latent_slots(input_seq[j], si)
602
+ shown = []
603
+ for t3 in range(si, min(si + 8, 81)):
604
+ r, c = int(tgt[3*t3]), int(tgt[3*t3+1])
605
+ bits = int(cand_targets[j, K-1, r*9+c])
606
+ cset = "".join(str(d+1) for d in range(9)
607
+ if (bits >> d) & 1)
608
+ shown.append(f"(r{r},c{c})->{int(pred[3*t3+2])} "
609
+ f"in{{{cset}}}")
610
+ if _verbose_eval():
611
+ print(f"EXAMPLE emitted vs stage-{K} candidate set:",
612
+ " ".join(shown), flush=True)
613
+
614
+ # ---- Candidate-set (multi-value) prediction for this puzzle ----
615
+ # Show, at the last active latent slot, predicted vs target
616
+ # candidate SETS for the first few empty cells. (No latent
617
+ # slots in the K=0 baseline, so nothing to show.)
618
+ if K > 0 and pred_bits is not None:
619
+ kj = int(k_budget[j]) - 1
620
+ def _digs(bitrow):
621
+ return "".join(str(d + 1) for d in range(9) if bitrow[d])
622
+ cand_shown = []
623
+ for cell in range(81):
624
+ if cand_targets[j, kj, cell] <= 0: # clue / not supervised
625
+ continue
626
+ r, c = cell // 9, cell % 9
627
+ pset = _digs(pred_bits[j, kj, cell])
628
+ tset = _digs(tgt_bits[j, kj, cell])
629
+ cand_shown.append(f"(r{r},c{c}) pred{{{pset}}} true{{{tset}}}")
630
+ if len(cand_shown) >= 8:
631
+ break
632
+ if _verbose_eval():
633
+ print(f"EXAMPLE candidate-set @slot{kj} (pred vs true):",
634
+ " ".join(cand_shown), flush=True)
635
+
636
+ correct_eval_sudoku_puzzle = 0
637
+
638
+ for i in range(len(cur_input_seq)):
639
+
640
+ # increase correct_eval_sudoku_puzzle when the model output solution
641
+ # for a given puzzle is correct
642
+ stripped = strip_latent_slots(cur_input_seq[i], int(start_index[i, 0]))
643
+ correct_eval_sudoku_puzzle += valid_solution(stripped)
644
+
645
+ eval_metrics["acc_complete_puzzle"].append(
646
+ correct_eval_sudoku_puzzle * 1.0 / len(cur_input_seq)
647
+ )
648
+
649
+ per_level = {lvl: (level_ok[lvl] / level_tot[lvl] if level_tot[lvl] else -1.0)
650
+ for lvl in range(3, 9)}
651
+ eval_metrics["per_level_acc"] = per_level
652
+ if _verbose_eval():
653
+ print("PER-LEVEL cell acc:",
654
+ {lvl: (f"{v:.3f}" if v >= 0 else "n/a") for lvl, v in per_level.items()},
655
+ flush=True)
656
+
657
+ # Per-depth candidate-set accuracy, keyed by stage (slot j -> stage j+1) so
658
+ # the curriculum controller can index it directly by stage number.
659
+ per_slot = {j + 1: (float(slot_ok[j] / slot_tot[j]) if slot_tot[j] else -1.0)
660
+ for j in range(K)}
661
+ per_slot_ch = {j + 1: (float(slot_ok_ch[j] / slot_tot_ch[j])
662
+ if slot_tot_ch[j] else -1.0) for j in range(K)}
663
+ eval_metrics["per_slot_acc"] = per_slot
664
+ eval_metrics["per_slot_acc_changed"] = per_slot_ch
665
+
666
+ # Keyed by stage (slot s -> stage s+1) to match per_slot_acc.
667
+ per_stage_inset = {s + 1: (float(inset_ok[s] / inset_tot[s])
668
+ if inset_tot[s] else -1.0) for s in range(K)}
669
+ eval_metrics["per_stage_inset_acc"] = per_stage_inset
670
+
671
+ # Per-stage value-distribution metrics, keyed by stage to match the above.
672
+ # val_excess = CE(uniform-over-candidates || model) - log|S| >= 0 is the
673
+ # superposition score: 0 means the model spreads exactly uniformly over the
674
+ # stage's candidate set, and it rises if probability leaks outside the set
675
+ # or collapses onto one member of it.
676
+ def _per_stage(num, den):
677
+ return {s + 1: (float(num[s] / den[s]) if den[s] else -1.0)
678
+ for s in range(K)}
679
+
680
+ per_stage_vce = _per_stage(vce_sum, vcnt)
681
+ per_stage_vfloor = _per_stage(vfloor_sum, vcnt)
682
+ per_stage_vexcess = {
683
+ s: (per_stage_vce[s] - per_stage_vfloor[s]
684
+ if per_stage_vce[s] >= 0 else -1.0) for s in per_stage_vce}
685
+ eval_metrics["per_stage_val_ce"] = per_stage_vce
686
+ eval_metrics["per_stage_val_floor"] = per_stage_vfloor
687
+ eval_metrics["per_stage_val_excess"] = per_stage_vexcess
688
+ eval_metrics["per_stage_val_mass"] = _per_stage(vmass_sum, vcnt)
689
+ eval_metrics["per_stage_val_spread"] = _per_stage(vspread_sum, vspread_cnt)
690
+ # Superposition metrics on |S| >= 2 cells only. excess_multi is the single
691
+ # number to drive to 0: it equals log(1/mass_multi) + kl_multi, so it falls
692
+ # only when leakage outside the set AND non-uniformity inside it both fall.
693
+ eval_metrics["per_stage_val_excess_multi"] = _per_stage(
694
+ vexc_m_sum, vspread_cnt)
695
+ eval_metrics["per_stage_val_mass_multi"] = _per_stage(
696
+ vmass_m_sum, vspread_cnt)
697
+ eval_metrics["per_stage_val_kl_multi"] = _per_stage(vkl_m_sum, vspread_cnt)
698
+ if _verbose_eval() and K > 0 and any(v >= 0 for v in per_stage_vce.values()):
699
+ print("PER-STAGE val_excess (0 = uniform over candidate set):",
700
+ {s: (f"{v:.3f}" if per_stage_vce[s] >= 0 else "n/a")
701
+ for s, v in per_stage_vexcess.items()}, flush=True)
702
+
703
+ if _verbose_eval() and K > 0 and any(v >= 0 for v in per_stage_inset.values()):
704
+ print("PER-STAGE in-set rate (emitted digit is a stage-s candidate):",
705
+ {s: (f"{v:.3f}" if v >= 0 else "n/a")
706
+ for s, v in per_stage_inset.items()}, flush=True)
707
+
708
+ per_bin = {b: (bin_ok[b] / bin_tot[b] if bin_tot[b] else -1.0)
709
+ for b in range(1, n_bins + 1)}
710
+ eval_metrics["per_bin_acc"] = per_bin
711
+ if _verbose_eval() and any(v >= 0 for v in per_bin.values()):
712
+ print("PER-ROUND-BIN cell acc:",
713
+ {b: (f"{v:.3f}" if v >= 0 else "n/a") for b, v in per_bin.items()},
714
+ flush=True)
715
+ if _verbose_eval() and K > 0:
716
+ print("PER-DEPTH cand-set acc:",
717
+ {s: (f"{v:.3f}" if v >= 0 else "n/a") for s, v in per_slot.items()},
718
+ flush=True)
719
+ print("PER-DEPTH cand-set acc (changed cells only):",
720
+ {s: (f"{v:.3f}" if v >= 0 else "n/a")
721
+ for s, v in per_slot_ch.items()}, flush=True)
722
+
723
+ return eval_metrics
code/wavecurriculum_run/train/main.py ADDED
@@ -0,0 +1,336 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ # Instance arm: both gates required. mass = P(digit in raw candidate set S);
155
+ # spread = H(p|S)/log|S| on |S|>=2 cells. S is the wave-solver candidate
156
+ # set, not the filtered-instance support.
157
+ config.promote_acc_threshold = float(os.environ.get("SUDOKU_PROMOTE_ACC", 0.85))
158
+ config.promote_spread_threshold = float(
159
+ os.environ.get("SUDOKU_PROMOTE_SPREAD", 0.85))
160
+ # Location floor, applied to loc_wave. 0 = off.
161
+ config.promote_loc_threshold = float(
162
+ os.environ.get("SUDOKU_PROMOTE_LOC_WAVE", 0.0))
163
+ # 0 disables patience; instance arm never uses it regardless.
164
+ config.promote_patience_steps = int(os.environ.get("SUDOKU_PATIENCE", 0))
165
+ config.min_stage_steps = int(os.environ.get("SUDOKU_MIN_STAGE_STEPS", 2000))
166
+ # Plateau promotion: advance once the frontier depth stops improving, rather
167
+ # than on a fixed timer. A stage is "done" when it has not gained
168
+ # plateau_delta over its best accuracy for plateau_steps steps. This is the
169
+ # primary rule; the accuracy threshold is a fast path for mastery and
170
+ # promote_patience_steps is a hard cap so a stuck stage cannot stall
171
+ # training forever. Set plateau_steps=0 to disable.
172
+ config.plateau_steps = int(os.environ.get("SUDOKU_PLATEAU_STEPS", 20000))
173
+ config.plateau_delta = float(os.environ.get("SUDOKU_PLATEAU_DELTA", 0.005))
174
+ # Train-time difficulty balancing. 0 (default) = draw puzzles uniformly from
175
+ # the corpus, so the difficulty tag selects nothing. 1 = uniform over the 6
176
+ # levels, which upsamples level 8 from 1.8% to 16.7% of batches. Eval is
177
+ # always level-balanced so per-level accuracy stays measurable.
178
+ config.level_balanced_sampling = int(
179
+ os.environ.get("SUDOKU_LEVEL_BALANCED", 0))
180
+ # Data curriculum over the puzzle POOL (as opposed to the latent-depth
181
+ # curriculum over the supervision).
182
+ # "none" = every puzzle available from step 0.
183
+ # "rounds" = stage t admits only puzzles whose solver round count falls in
184
+ # the first t of max_stage equal-count bins (rounds span 5..38,
185
+ # so 12 bins give a genuinely smooth 12-step ladder). This is
186
+ # the same propagation-depth axis the latent arm supervises,
187
+ # which makes the two arms directly comparable. Needs
188
+ # SUDOKU_TRAIN_META / SUDOKU_TEST_META.
189
+ config.data_curriculum = os.environ.get("SUDOKU_DATA_CURRICULUM", "none")
190
+ config.train_meta_path = os.environ.get("SUDOKU_TRAIN_META", "") or None
191
+ config.test_meta_path = os.environ.get("SUDOKU_TEST_META", "") or None
192
+
193
+ # Model related parameters
194
+ config.block_size = 81
195
+ config.seq_len = 3 * config.block_size + config.num_latent_slots
196
+ config.vocab_size = 11
197
+
198
+ # Model architecture
199
+ config.num_heads = 8
200
+ config.num_layers = 8
201
+ config.emb_dim = 576
202
+ config.qkv_dim = 576
203
+ config.mlp_dim = 6 * config.emb_dim
204
+ config.dropout_rate = float(os.environ.get("SUDOKU_DROPOUT", 0.2))
205
+ config.attention_dropout_rate = float(
206
+ os.environ.get("SUDOKU_ATTN_DROPOUT",
207
+ os.environ.get("SUDOKU_DROPOUT", 0.2)))
208
+
209
+ # Training hyperparameters
210
+ config.learning_rate = float(os.environ.get("SUDOKU_LR", 0.0002)) # Base learning rate.
211
+ config.end_lr_factor = float(os.environ.get("SUDOKU_END_LR_FACTOR", 0.2))
212
+ config.warmup_tokens = int(os.environ.get("SUDOKU_WARMUP", 10000))
213
+ config.weight_decay = float(os.environ.get("SUDOKU_WD", 0.005))
214
+ # Resume from a checkpoint (set SUDOKU_RESUME=1 and pass --ckpt_loc=<path>).
215
+ config.resume_training = os.environ.get("SUDOKU_RESUME", "0") == "1"
216
+
217
+ # Other hyperparameters
218
+ config.seed = 7
219
+ config.save_checkpoint = os.environ.get("SUDOKU_SAVE_CKPT", "1") == "1"
220
+ config.save_every_steps = int(os.environ.get("SUDOKU_SAVE_EVERY", 10000))
221
+ # How many checkpoints to retain. Large default so per-stage checkpoints are
222
+ # never rolled off (disk is plentiful; ~0.5GB each).
223
+ config.ckpt_keep = int(os.environ.get("SUDOKU_CKPT_KEEP", 100))
224
+ config.use_wandb = False
225
+ config.wandb_project_name = 'sudoku'
226
+
227
+ # Evaluation related parameters
228
+ config.eval_every_steps = int(os.environ.get("SUDOKU_EVAL_EVERY", 2000))
229
+ config.eval_epochs = int(os.environ.get("SUDOKU_EVAL_EPOCHS", 5))
230
+
231
+ # Path to dataset
232
+ config.train_puzzle_path = os.environ.get(
233
+ "SUDOKU_TRAIN_PATH", "datasets/train_sudoku_puzzles.npy")
234
+ config.train_candidate_path = "datasets/train_sudoku_puzzles_candidate.npy"
235
+ config.test_puzzle_path = os.environ.get(
236
+ "SUDOKU_TEST_PATH", "datasets/test_sudoku_puzzles.npy")
237
+ config.test_candidate_path = "datasets/test_sudoku_puzzles_candidate.npy"
238
+
239
+ # Staged multi-candidate supervision (per-latent-slot BCE targets).
240
+ # Empty string disables cand-mask loading (useful for K=0 baselines).
241
+ config.train_cand_masks_path = os.environ.get(
242
+ "SUDOKU_TRAIN_CAND", "datasets_multicandidate/train_cand_masks.npy") or None
243
+ config.test_cand_masks_path = os.environ.get(
244
+ "SUDOKU_TEST_CAND", "datasets_multicandidate/test_cand_masks.npy") or None
245
+ # Superposition-instance targets. When set, the output prompt's value tokens
246
+ # come from one sampled stage-k assignment instead of the unique solution:
247
+ # the input prompt (clues) is fixed and the same puzzle recurs with different
248
+ # legal completions, so the candidate set is represented across the batch
249
+ # rather than supervised as a multi-hot set. Setting this should go with
250
+ # SUDOKU_AUX_WEIGHT=0 (candidate head off) -- the masks are then read only
251
+ # for the in-set metric. Empty string = classic single-solution targets.
252
+ config.instance_dir = os.environ.get("SUDOKU_INSTANCE_DIR", "") or None
253
+ # Weight of the auxiliary candidate-set BCE loss relative to the LM CE loss.
254
+ config.aux_cand_weight = float(os.environ.get("SUDOKU_AUX_WEIGHT", 1.0))
255
+ # Positive-class weight inside the candidate BCE (counters the sparsity of
256
+ # the multi-hot masks so the head doesn't collapse to predicting all-zeros).
257
+ config.aux_pos_weight = float(os.environ.get("SUDOKU_CAND_POS_WEIGHT", 5.0))
258
+
259
+ # How many latent slots each example activates.
260
+ # "level" = k = clip(level-2, 1, K). Difficulty-matched, but puzzle level
261
+ # explains only ~19% of the variance in solver round count, so
262
+ # most examples leave the majority of the K slots inert (a
263
+ # level-3 puzzle activates ONE slot for ~21 rounds of work).
264
+ # "depth" = k = num_passes, uniform over the batch. Every slot the
265
+ # recurrence actually fills is active and supervised, and the
266
+ # curriculum advances reasoning depth rather than puzzle level.
267
+ config.cand_slot_mode = os.environ.get("SUDOKU_CAND_SLOT_MODE", "depth")
268
+ # Latent passes granted per curriculum stage: num_passes = min(pps*stage, K).
269
+ # pps=2 with K=12 reaches all 12 slots by stage 6.
270
+ config.passes_per_stage = int(os.environ.get("SUDOKU_PASSES_PER_STAGE", 1))
271
+ # Superposition experiment: iterate every (puzzle, instance) pair of the
272
+ # pinned stage in shuffled epochs, so each instance of each puzzle is seen
273
+ # exactly `instance_epochs` times. 0 = old behaviour (draw a puzzle at
274
+ # random, then one of its assignments at random). instance_puzzles caps the
275
+ # puzzle count (0 = whole corpus) to shorten the experiment.
276
+ config.instance_epochs = int(os.environ.get("SUDOKU_INSTANCE_EPOCHS", 0))
277
+ config.instance_puzzles = int(os.environ.get("SUDOKU_INSTANCE_PUZZLES", 0))
278
+ # Preferred source: N instances per puzzle synthesized on the fly, each cell
279
+ # drawn uniformly from its candidate set, so the per-cell digit frequencies
280
+ # are equal and the CE optimum at that cell is the uniform superposition.
281
+ config.instance_uniform_draws = int(
282
+ os.environ.get("SUDOKU_UNIFORM_DRAWS", 0))
283
+ # Loss weight for candidate cells that did NOT change from the previous
284
+ # stage. Consecutive stages are highly redundant (at 12 stages ~97% of the
285
+ # target bits are copies of the previous slot), so plain BCE is dominated by
286
+ # echoing the previous slot. <1.0 down-weights the copied cells and puts the
287
+ # gradient on the digits actually eliminated at this stage. 1.0 = off.
288
+ config.aux_delta_bg = float(os.environ.get("SUDOKU_CAND_DELTA_BG", 1.0))
289
+
290
+ return config
291
+
292
+
293
+ def main(argv):
294
+ if len(argv) > 1:
295
+ raise app.UsageError('Too many command-line arguments.')
296
+
297
+ # # Hide any GPUs from TensorFlow. Otherwise TF might reserve memory and make
298
+ # # it unavailable to JAX.
299
+ tf.config.experimental.set_visible_devices([], 'GPU')
300
+
301
+ cfgs = get_config()
302
+ if cfgs.resume_training:
303
+ assert _CKPT_LOC.value is not None
304
+
305
+ if cfgs.use_wandb:
306
+ wandb.init(project=cfgs.wandb_project_name, name=_EXP_NAME.value, config=cfgs)
307
+
308
+ logging.info('JAX process: %d / %d', jax.process_index(), jax.process_count())
309
+ logging.info('JAX local devices: %r', jax.local_devices())
310
+
311
+ # Add a note so that we can tell which task is which JAX host.
312
+ # (Depending on the platform task 0 is not guaranteed to be host 0)
313
+ platform.work_unit().set_task_status(f'process_index: {jax.process_index()}, '
314
+ f'process_count: {jax.process_count()}')
315
+ platform.work_unit().create_artifact(platform.ArtifactType.DIRECTORY,
316
+ _WORKDIR.value, 'workdir')
317
+
318
+ logging.info(cfgs)
319
+
320
+ cfgs.workdir = _WORKDIR.value
321
+ cfgs.ckpt_loc = _CKPT_LOC.value
322
+ if int(getattr(cfgs, "backtrack", 0)):
323
+ if str(getattr(cfgs, "backtrack_mode", "prob")) == "adaptive":
324
+ train_backtrack.train_and_evaluate_backtrack_adaptive(cfgs, _WORKDIR.value)
325
+ else:
326
+ train_backtrack.train_and_evaluate_backtrack(cfgs, _WORKDIR.value)
327
+ else:
328
+ train_and_evaluate.train_and_evaluate(cfgs, _WORKDIR.value)
329
+
330
+ if cfgs.use_wandb:
331
+ wandb.finish()
332
+
333
+
334
+ if __name__ == '__main__':
335
+ jax.config.config_with_absl()
336
+ app.run(main)
code/wavecurriculum_run/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/wavecurriculum_run/train/train_and_evaluate.py ADDED
@@ -0,0 +1,596 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 json
20
+ import math
21
+ import os
22
+ import signal
23
+ import socket
24
+ import time
25
+ import traceback
26
+
27
+ from absl import logging
28
+ from clu import metric_writers
29
+ from flax import jax_utils
30
+ from flax import linen as nn
31
+ from flax.training import checkpoints
32
+ import jax
33
+ from jax import random
34
+ import jax.numpy as jnp
35
+ import numpy as np
36
+ import tensorflow as tf
37
+ import wandb
38
+
39
+ from train import data
40
+ from train import evaluater
41
+ from train import model
42
+ from train import trainer
43
+
44
+
45
+
46
+ def log_hyperparams_tb(
47
+ config, model_config, initial_variables, tf_summary_writer
48
+ ):
49
+ """Log hyperparameters to TensorBoard.
50
+
51
+ Args:
52
+ config: experiment's ConfigDict
53
+ model_config: model's ConfigDict
54
+ initial_variables: initial hyperparameter values
55
+ tf_summary_writer: SummaryWriter object.
56
+
57
+ Returns:
58
+ The SummaryWriter object and the config.
59
+ """
60
+ # Calculate the total number of model parameters
61
+ config.num_model_parameters = sum(
62
+ x.size for x in jax.tree_util.tree_leaves(initial_variables)
63
+ )
64
+
65
+ # Convert hyperparameters to tensors
66
+ config_hyperparameters = [
67
+ tf.convert_to_tensor([k, str(v)]) for k, v in config.items()
68
+ ]
69
+ model_config_hyperparameters = [
70
+ tf.convert_to_tensor([k, str(v)])
71
+ for k, v in model_config.__dict__.items()
72
+ ]
73
+
74
+ # Log model hyperparameters to TensorBoard
75
+ with tf_summary_writer.as_default():
76
+ tf.summary.text(
77
+ "Model hyperparameters", tf.stack(model_config_hyperparameters), step=0
78
+ )
79
+ tf.summary.text(
80
+ "Config hyperparameters", tf.stack(config_hyperparameters), step=0
81
+ )
82
+
83
+ return tf_summary_writer, config
84
+
85
+
86
+
87
+
88
+ def _write_json(path, payload):
89
+ """Atomic JSON write so a sidecar never reads a half-written file."""
90
+ tmp = path + ".tmp"
91
+ with open(tmp, "w") as f:
92
+ json.dump(payload, f, indent=2, sort_keys=True)
93
+ f.write("\n")
94
+ os.replace(tmp, path)
95
+
96
+
97
+ def _write_heartbeat(workdir, payload):
98
+ payload = dict(payload)
99
+ payload.setdefault("host", socket.gethostname())
100
+ payload.setdefault("job_id", os.environ.get("SLURM_JOB_ID", ""))
101
+ payload["unix_time"] = time.time()
102
+ payload["iso_time"] = time.strftime("%Y-%m-%dT%H:%M:%S%z")
103
+ slim = {k: payload[k] for k in
104
+ ("event", "step", "stage", "loss",
105
+ # val_acc = P(digit not in the stage-t candidate set)
106
+ "val_acc", "puzzle_acc",
107
+ # location order, tolerant to ties within a propagation wave
108
+ "loc_wave", "loc_coverage", "loc_lcs", "loc_dup", "loc_acc",
109
+ # superposition: excess 0 == uniform over the candidate set
110
+ "val_excess", "val_mass", "val_spread",
111
+ "val_excess_multi", "val_mass_multi", "val_out_multi",
112
+ "val_kl_multi",
113
+ "last_ckpt_step", "error")
114
+ if k in payload}
115
+ _write_json(os.path.join(workdir, "heartbeat.json"), slim)
116
+
117
+
118
+ def train_and_evaluate(config, workdir):
119
+ """The training and evaluation loops for the model.
120
+
121
+ Args:
122
+ config: experiment's config dictionary.
123
+ workdir: directory to use for logging.
124
+ """
125
+ # Orbax checkpointing requires an absolute path.
126
+ workdir = os.path.abspath(workdir)
127
+
128
+ logging.info("Creating training and evaluator dataset iterator")
129
+ curriculum = data.CurriculumState(
130
+ stage=int(getattr(config, "curriculum_start_stage", 1)),
131
+ max_stage=int(getattr(config, "curriculum_max_stage", 6)))
132
+ train_data_iter = data.create_iter(
133
+ config, config.minibatch_size, train=True, curriculum=curriculum)
134
+ eval_data_iter = data.create_iter(config, config.minibatch_size, train=False)
135
+
136
+ logging.info("Finished creating training dataset iterator")
137
+
138
+ model_config = model.TransformerConfig(
139
+ dtype=config.dtype,
140
+ vocab_size=config.vocab_size,
141
+ seq_len=config.seq_len,
142
+ num_heads=config.num_heads,
143
+ num_layers=config.num_layers,
144
+ emb_dim=config.emb_dim,
145
+ qkv_dim=config.qkv_dim,
146
+ mlp_dim=config.mlp_dim,
147
+ dropout_rate=config.dropout_rate,
148
+ attention_dropout_rate=config.attention_dropout_rate,
149
+ deterministic=False,
150
+ num_latent_slots=int(config.num_latent_slots),
151
+ inject_latents=bool(int(getattr(config, "recurrent_latent", 1))),
152
+ )
153
+
154
+ logging.info("train_config: %s", str(model_config.__dict__))
155
+ print(str(model_config.__dict__), flush=True)
156
+
157
+ rng = jax.random.PRNGKey(config.seed)
158
+ rng, init_rng, inference_rng = random.split(rng, num=3)
159
+
160
+ # Initialize the model and get initial variables. Dummy latent arguments
161
+ # are provided so the latent projector parameters are created at init.
162
+ rng, dropout_rng = jax.random.split(rng)
163
+ input_shape = (config.minibatch_size, config.seq_len)
164
+ net = model.TransformerLMHeadModel(model_config)
165
+ rng_keys = {"params": init_rng, "dropout": dropout_rng}
166
+ K = int(config.num_latent_slots)
167
+ dummy_latents = jnp.zeros(
168
+ (config.minibatch_size, K, config.emb_dim), model_config.dtype)
169
+ dummy_positions = jnp.zeros((config.minibatch_size, K), jnp.int32)
170
+ dummy_active = jnp.zeros((config.minibatch_size, K), bool)
171
+ sample_out, initial_variables = jax.jit(
172
+ net.init_with_output
173
+ )(rng_keys, jnp.ones(input_shape, jnp.int32), dummy_latents,
174
+ dummy_positions, dummy_active)
175
+
176
+ state, lr_scheduler_fn = trainer.get_state(config, net, initial_variables)
177
+ # Resume-and-extend support: when resuming, start the training loop at the
178
+ # restored optimizer step (not 0) so a larger config.max_steps continues the
179
+ # cosine LR schedule cleanly instead of re-running steps or overshooting.
180
+ start_step = 0
181
+ if config.resume_training:
182
+ state = checkpoints.restore_checkpoint(config.ckpt_loc, state)
183
+ start_step = int(state.step)
184
+ print("----------Restored model from", config.ckpt_loc,
185
+ f"at step {start_step}-----------")
186
+
187
+ writer = metric_writers.create_default_writer(
188
+ workdir, asynchronous=False, just_logging=(jax.process_index() > 0))
189
+ tf_summary_writer = tf.summary.create_file_writer(workdir)
190
+
191
+ logging.info("config: %s", str(config.__dict__))
192
+ state = jax_utils.replicate(state)
193
+
194
+ dropout_rngs = jax.random.split(rng, jax.local_device_count())
195
+
196
+ def make_p_train_step(num_passes):
197
+ return jax.pmap(
198
+ functools.partial(
199
+ trainer.train_step,
200
+ config=model_config,
201
+ hyperparams=config,
202
+ learning_rate_fn=lr_scheduler_fn,
203
+ num_passes=num_passes),
204
+ axis_name="batch",
205
+ donate_argnums=(0,))
206
+
207
+ # num_passes = latent recurrence depth for this stage, capped by the number
208
+ # of latent slots (K=0 -> 0 passes -> no-latent control baseline).
209
+ # When recurrence is disabled (control), force 0 passes so no hidden state
210
+ # is ever fed back: the slots become static, independent per-stage readouts.
211
+ recurrent = bool(int(getattr(config, "recurrent_latent", 1)))
212
+ passes_per_stage = int(getattr(config, "passes_per_stage", 1))
213
+ def passes_for(stage):
214
+ return min(passes_per_stage * stage, K) if recurrent else 0
215
+ p_train_step = make_p_train_step(passes_for(curriculum.stage))
216
+
217
+ p_eval_step = jax.pmap(functools.partial(evaluater.eval_step,
218
+ config=model_config.replace(deterministic=True)),
219
+ axis_name="batch")
220
+
221
+ hooks, report_progress, train_metrics = trainer.get_metrics_report_progress(
222
+ config, workdir, writer)
223
+
224
+ tf_summary_writer, config = log_hyperparams_tb(
225
+ config, model_config, initial_variables, tf_summary_writer
226
+ )
227
+
228
+ promote_threshold = float(getattr(config, "promote_acc_threshold", 0.85))
229
+ # Applies to loc_wave (order up to wave ties), not the positional loc_acc.
230
+ # 0 disables the gate.
231
+ promote_loc_threshold = float(getattr(config, "promote_loc_threshold", 0.0))
232
+ # Max tolerated val_excess = CE(Uniform(S) || p) - log|S|. 0 disables.
233
+ promote_excess_threshold = float(
234
+ getattr(config, "promote_val_excess_threshold", 0.0))
235
+ promote_patience = int(getattr(config, "promote_patience_steps", 8000))
236
+ min_stage_steps = int(getattr(config, "min_stage_steps", 2000))
237
+ plateau_steps = int(getattr(config, "plateau_steps", 0))
238
+ plateau_delta = float(getattr(config, "plateau_delta", 0.005))
239
+ instance_mode = bool(getattr(config, "instance_dir", None))
240
+ per_stage_inset = {}
241
+ per_stage_vexcess, per_stage_vmass, per_stage_vspread = {}, {}, {}
242
+ per_stage_vexc_m, per_stage_vmass_m, per_stage_vkl_m = {}, {}, {}
243
+ # Best frontier-depth accuracy seen in the current stage, and the step it
244
+ # was last improved: the plateau detector's state.
245
+ stage_best_acc = -1.0
246
+ stage_best_step = start_step
247
+ ckpt_keep = int(getattr(config, "ckpt_keep", 100))
248
+ # Protected directory for per-stage checkpoints (never rolled off).
249
+ stage_ckpt_dir = os.path.join(workdir, "stage_ckpts")
250
+ stage_started_at = start_step
251
+ last_ckpt_step = start_step
252
+ stop_requested = {"flag": False}
253
+
254
+ def _on_stop(signum, _frame):
255
+ print(f"[signal] {signum} received; will save and exit after this step",
256
+ flush=True)
257
+ stop_requested["flag"] = True
258
+
259
+ signal.signal(signal.SIGTERM, _on_stop)
260
+ signal.signal(signal.SIGINT, _on_stop)
261
+ _write_heartbeat(workdir, {
262
+ "event": "start",
263
+ "step": start_step,
264
+ "stage": curriculum.stage,
265
+ "max_steps": int(config.max_steps),
266
+ })
267
+
268
+ with metric_writers.ensure_flushes(writer):
269
+ for step in range(start_step, config.max_steps):
270
+ if step%10000 == 0:
271
+ print("Step:", step, flush=True)
272
+
273
+ state, metrics = trainer.train_one_step(p_train_step, config, state,
274
+ step, dropout_rngs, train_data_iter)
275
+
276
+ for h in hooks:
277
+ h(step)
278
+
279
+ if math.isnan(metrics["loss"][0]):
280
+ print("The loss function became nan: This might be due to the choice of hyperparameters.")
281
+ _write_heartbeat(workdir, {
282
+ "event": "nan_loss", "step": step, "stage": curriculum.stage,
283
+ })
284
+ break
285
+
286
+ if step % config.eval_every_steps == 0:
287
+ try:
288
+ eval_metrics = evaluater.get_eval_metrics(
289
+ state, eval_data_iter, p_eval_step, config)
290
+ except Exception:
291
+ traceback.print_exc()
292
+ _write_heartbeat(workdir, {
293
+ "event": "eval_error",
294
+ "step": step,
295
+ "stage": curriculum.stage,
296
+ "loss": float(metrics["loss"].mean()),
297
+ "ce": float(metrics["ce_loss"].mean()),
298
+ "error": traceback.format_exc()[-2000:],
299
+ })
300
+ print("[eval] failed; skipping this eval and continuing",
301
+ flush=True)
302
+ eval_metrics = None
303
+ if eval_metrics is not None:
304
+ per_level = eval_metrics.pop("per_level_acc")
305
+ per_slot = eval_metrics.pop("per_slot_acc", {})
306
+ per_slot_ch = eval_metrics.pop("per_slot_acc_changed", {})
307
+ per_stage_inset = eval_metrics.pop("per_stage_inset_acc", {})
308
+ per_stage_vexcess = eval_metrics.pop(
309
+ "per_stage_val_excess", {})
310
+ per_stage_vmass = eval_metrics.pop("per_stage_val_mass", {})
311
+ per_stage_vspread = eval_metrics.pop(
312
+ "per_stage_val_spread", {})
313
+ per_stage_vexc_m = eval_metrics.pop(
314
+ "per_stage_val_excess_multi", {})
315
+ per_stage_vmass_m = eval_metrics.pop(
316
+ "per_stage_val_mass_multi", {})
317
+ per_stage_vkl_m = eval_metrics.pop(
318
+ "per_stage_val_kl_multi", {})
319
+ eval_metrics.pop("per_stage_val_ce", {})
320
+ eval_metrics.pop("per_stage_val_floor", {})
321
+ per_bin = eval_metrics.pop("per_bin_acc", {})
322
+ def _m(key):
323
+ v = eval_metrics.get(key, [])
324
+ return round(float(np.mean(v)), 4) if len(v) else -1.0
325
+ def _s(d):
326
+ return round(float(d.get(curriculum.stage, -1.0)), 4)
327
+ # val_acc = P(digit not in stage-t candidate set),
328
+ # teacher-forced so location cannot contaminate it.
329
+ # 0 = all mass inside S; chance at stage 0 is ~0.59.
330
+ mass = _s(per_stage_vmass)
331
+ val_out = round(1.0 - mass, 4) if mass >= 0 else -1.0
332
+ mass_m = _s(per_stage_vmass_m)
333
+ val_out_m = round(1.0 - mass_m, 4) if mass_m >= 0 else -1.0
334
+ print(step,
335
+ "stage", curriculum.stage,
336
+ "loss", round(float(metrics["loss"].mean()), 4),
337
+ "val_acc", val_out,
338
+ "loc_wave", _m("loc_wave"),
339
+ "loc_cov", _m("loc_coverage"),
340
+ # superposition on |S|>=2 cells: excess = leak + kl
341
+ "excess*", _s(per_stage_vexc_m),
342
+ "out*", val_out_m,
343
+ "kl*", _s(per_stage_vkl_m),
344
+ "val_spread", _s(per_stage_vspread),
345
+ flush=True)
346
+ _write_heartbeat(workdir, {
347
+ "event": "eval",
348
+ "step": int(step),
349
+ "stage": int(curriculum.stage),
350
+ "loss": round(float(metrics["loss"].mean()), 6),
351
+ "ce": round(float(metrics["ce_loss"].mean()), 6),
352
+ "val_acc": val_out,
353
+ "puzzle_acc": _m("acc_complete_puzzle"),
354
+ # Permutation-tolerant location signals; loc_acc is the
355
+ # old positional match, kept only for continuity.
356
+ "loc_wave": _m("loc_wave"),
357
+ "loc_coverage": _m("loc_coverage"),
358
+ "loc_lcs": _m("loc_lcs"),
359
+ "loc_dup": _m("loc_dup"),
360
+ "loc_acc": _m("loc_acc"),
361
+ # Superposition: 0 excess == uniform over the stage's
362
+ # candidate set; spread 1.0 == no mode collapse.
363
+ "val_excess": _s(per_stage_vexcess),
364
+ "val_mass": _s(per_stage_vmass),
365
+ "val_spread": _s(per_stage_vspread),
366
+ # |S|>=2 cells only: excess_multi == log(1/mass_multi)
367
+ # + kl_multi, so it is the single number that falls only
368
+ # when leakage and non-uniformity both fall.
369
+ "val_excess_multi": _s(per_stage_vexc_m),
370
+ "val_mass_multi": mass_m,
371
+ "val_out_multi": val_out_m,
372
+ "val_kl_multi": _s(per_stage_vkl_m),
373
+ "last_ckpt_step": int(last_ckpt_step),
374
+ })
375
+ with tf_summary_writer.as_default():
376
+ tf.summary.scalar("loss", metrics["loss"].mean(), step=step)
377
+ tf.summary.scalar("ce_loss", metrics["ce_loss"].mean(), step=step)
378
+ tf.summary.scalar("aux_bce_loss", metrics["aux_loss"].mean(), step=step)
379
+ tf.summary.scalar(
380
+ "learning rate", metrics["learning_rate"].mean(), step=step
381
+ )
382
+ tf.summary.scalar("curriculum_stage", curriculum.stage, step=step)
383
+
384
+ log_dict = {'loss': metrics["loss"].mean(), 'learning rate': metrics["learning_rate"].mean()}
385
+
386
+ for key in eval_metrics.keys():
387
+ vals = eval_metrics[key]
388
+ if not vals:
389
+ continue
390
+ tf.summary.scalar(
391
+ "eval_" + key, np.array(vals).mean(), step=step
392
+ )
393
+ log_dict[ "eval_" + key ] = np.array(vals).mean()
394
+
395
+ for lvl, v in per_level.items():
396
+ if v >= 0:
397
+ tf.summary.scalar(f"eval_acc_level_{lvl}", v, step=step)
398
+ log_dict[f"eval_acc_level_{lvl}"] = v
399
+
400
+ for s, v in per_slot.items():
401
+ if v >= 0:
402
+ tf.summary.scalar(f"eval_cand_depth_{s}", v, step=step)
403
+ log_dict[f"eval_cand_depth_{s}"] = v
404
+ for s, v in per_slot_ch.items():
405
+ if v >= 0:
406
+ tf.summary.scalar(f"eval_cand_depth_chg_{s}", v, step=step)
407
+
408
+ if config.use_wandb: wandb.log(log_dict, step=step)
409
+
410
+ # ---- Curriculum promotion (ATC-style: threshold + patience) ----
411
+ # Two curricula, one frontier rule. With latent slots the
412
+ # frontier is the DEEPEST active slot, i.e. the newest wave
413
+ # snapshot this stage introduced, scored on changed cells only
414
+ # (unchanged cells are copies of slot j-1 and stay correct for a
415
+ # head that learned nothing new). With the round-count data
416
+ # curriculum the frontier is the newest round-BIN, i.e. the
417
+ # longest-chain puzzles this stage admitted.
418
+ rounds_curric = str(
419
+ getattr(config, "data_curriculum", "none")) == "rounds"
420
+ has_frontier = int(config.num_latent_slots) > 0 or rounds_curric
421
+ if has_frontier and curriculum.stage < curriculum.max_stage:
422
+ t = curriculum.stage
423
+ loc_now = _m("loc_wave")
424
+ if instance_mode:
425
+ # Frontier = probability mass the value head puts on
426
+ # stage t's candidate set, measured teacher-forced so
427
+ # it does not depend on the model's choice of order.
428
+ # This is the soft form of the old in-set rate, which
429
+ # was conditioned on an exact positional location
430
+ # match and so was estimated from ~4% of steps.
431
+ # Frontier = exp(-val_excess_multi) on stage t, i.e.
432
+ # the superposition score mapped into (0, 1] so the
433
+ # existing ">= threshold" machinery still applies.
434
+ # val_excess_multi = log(1/mass) + KL(Uniform(S)||q)
435
+ # on |S| >= 2 cells, so it reaches 0 (score 1.0)
436
+ # only when the model puts all its mass on the
437
+ # candidate set AND spreads uniformly over it.
438
+ exc_now = per_stage_vexc_m.get(t, -1.0)
439
+ frontier_acc = (float(np.exp(-exc_now))
440
+ if exc_now >= 0 else -1.0)
441
+ elif int(config.num_latent_slots) > 0:
442
+ frontier_acc = per_slot_ch.get(t, -1.0)
443
+ if frontier_acc < 0:
444
+ frontier_acc = per_slot.get(t, -1.0)
445
+ else:
446
+ frontier_acc = per_bin.get(t, -1.0)
447
+ if frontier_acc < 0:
448
+ # The frontier bin holds as little as 6.5% of the
449
+ # corpus, so a single eval can miss it. Fall back to
450
+ # the pooled accuracy over everything unlocked.
451
+ seen = [v for b, v in per_bin.items()
452
+ if b <= t and v >= 0]
453
+ frontier_acc = (float(np.mean(seen)) if seen
454
+ else -1.0)
455
+ steps_in_stage = step - stage_started_at
456
+ # A negative accuracy means "not measured this eval". Never
457
+ # let that count as evidence: it must not reset the plateau
458
+ # tracker, and it must not satisfy the threshold or plateau
459
+ # rule. Only the hard patience cap can fire without a
460
+ # measurement.
461
+ measured = frontier_acc >= 0
462
+ if measured and frontier_acc > stage_best_acc + plateau_delta:
463
+ stage_best_acc = frontier_acc
464
+ stage_best_step = step
465
+ # Location gate. The upstream code never compared the
466
+ # emitted (r,c) to the target order at all, and the
467
+ # earlier gate on the positional loc_acc was
468
+ # unsatisfiable (0.70 required, ~0.04 observed), so every
469
+ # promotion fired on the patience cap alone. It now keys
470
+ # on loc_wave, which credits any cell from the earliest
471
+ # unfilled propagation wave, and defaults to off until
472
+ # the reachable range is known from a run.
473
+ loc_ready = (promote_loc_threshold <= 0.0
474
+ or loc_now >= promote_loc_threshold)
475
+ excess_now = per_stage_vexcess.get(t, -1.0)
476
+ excess_ready = (promote_excess_threshold <= 0.0
477
+ or (0.0 <= excess_now
478
+ <= promote_excess_threshold))
479
+ gates_ok = loc_ready and excess_ready
480
+ hit_threshold = (measured and gates_ok
481
+ and frontier_acc >= promote_threshold)
482
+ stalled = (measured and gates_ok and plateau_steps > 0
483
+ and (step - stage_best_step) >= plateau_steps)
484
+ patience_over = steps_in_stage >= promote_patience
485
+ if steps_in_stage >= min_stage_steps and (
486
+ hit_threshold or stalled or patience_over):
487
+ reason = ("threshold" if hit_threshold
488
+ else "plateau" if stalled else "patience")
489
+ curriculum.stage += 1
490
+ stage_started_at = step
491
+ stage_best_acc = -1.0
492
+ stage_best_step = step
493
+ p_train_step = make_p_train_step(passes_for(curriculum.stage))
494
+ what = ("round-bin" if int(config.num_latent_slots) == 0
495
+ else "depth")
496
+ print(f"[curriculum] step {step}: promote to stage "
497
+ f"{curriculum.stage} ({reason}; graduated {what} "
498
+ f"{t} acc={frontier_acc:.3f} after "
499
+ f"{steps_in_stage} steps); "
500
+ f"latent passes={curriculum.stage}, "
501
+ f"pool/snapshots now 1..{curriculum.stage}",
502
+ flush=True)
503
+ if config.save_checkpoint:
504
+ unrep_state = jax_utils.unreplicate(state)
505
+ # Rolling checkpoint in the main workdir.
506
+ checkpoints.save_checkpoint_multiprocess(
507
+ workdir, unrep_state, step,
508
+ keep=ckpt_keep, overwrite=True)
509
+ # Protected copy: the model *entering* this stage,
510
+ # kept permanently under stage_ckpts/ (never rolled
511
+ # off), so every stage's checkpoint survives.
512
+ checkpoints.save_checkpoint_multiprocess(
513
+ stage_ckpt_dir, unrep_state, step,
514
+ keep=100, overwrite=True,
515
+ prefix=f"stage{curriculum.stage}_")
516
+ last_ckpt_step = step
517
+ _write_json(os.path.join(workdir, "ckpt_ready.json"), {
518
+ "event": "stage",
519
+ "step": int(step),
520
+ "stage": int(curriculum.stage),
521
+ "reason": reason,
522
+ "workdir": workdir,
523
+ "stage_ckpt_dir": stage_ckpt_dir,
524
+ "keep_snapshot": True,
525
+ })
526
+ _write_heartbeat(workdir, {
527
+ "event": "promote",
528
+ "step": int(step),
529
+ "stage": int(curriculum.stage),
530
+ "reason": reason,
531
+ "graduated_acc": round(float(frontier_acc), 6),
532
+ "last_ckpt_step": int(last_ckpt_step),
533
+ })
534
+
535
+ if config.save_checkpoint and step > 0 and step % config.save_every_steps == 0:
536
+ checkpoints.save_checkpoint_multiprocess(
537
+ workdir, jax_utils.unreplicate(state), step,
538
+ keep=ckpt_keep, overwrite=True
539
+ )
540
+ last_ckpt_step = step
541
+ _write_json(os.path.join(workdir, "ckpt_ready.json"), {
542
+ "event": "periodic",
543
+ "step": int(step),
544
+ "stage": int(curriculum.stage),
545
+ "workdir": workdir,
546
+ "keep_snapshot": (step % 50000 == 0),
547
+ })
548
+ _write_heartbeat(workdir, {
549
+ "event": "ckpt",
550
+ "step": int(step),
551
+ "stage": int(curriculum.stage),
552
+ "last_ckpt_step": int(last_ckpt_step),
553
+ })
554
+
555
+ if stop_requested["flag"]:
556
+ print(f"[signal] stopping at step {step}", flush=True)
557
+ if config.save_checkpoint:
558
+ checkpoints.save_checkpoint_multiprocess(
559
+ workdir, jax_utils.unreplicate(state), step,
560
+ keep=ckpt_keep, overwrite=True)
561
+ last_ckpt_step = step
562
+ _write_json(os.path.join(workdir, "ckpt_ready.json"), {
563
+ "event": "signal",
564
+ "step": int(step),
565
+ "stage": int(curriculum.stage),
566
+ "workdir": workdir,
567
+ "keep_snapshot": True,
568
+ })
569
+ _write_heartbeat(workdir, {
570
+ "event": "stopped",
571
+ "step": int(step),
572
+ "stage": int(curriculum.stage),
573
+ "last_ckpt_step": int(last_ckpt_step),
574
+ })
575
+ break
576
+
577
+ # Final checkpoint at the end of training
578
+ if config.save_checkpoint and not stop_requested["flag"]:
579
+ checkpoints.save_checkpoint_multiprocess(
580
+ workdir, jax_utils.unreplicate(state), config.max_steps,
581
+ keep=ckpt_keep, overwrite=True)
582
+ _write_json(os.path.join(workdir, "ckpt_ready.json"), {
583
+ "event": "final",
584
+ "step": int(config.max_steps),
585
+ "stage": int(curriculum.stage),
586
+ "workdir": workdir,
587
+ "keep_snapshot": True,
588
+ })
589
+ _write_heartbeat(workdir, {
590
+ "event": "done",
591
+ "step": int(last_ckpt_step),
592
+ "stage": int(curriculum.stage),
593
+ "last_ckpt_step": int(last_ckpt_step),
594
+ })
595
+
596
+
code/wavecurriculum_run/train/train_backtrack.py ADDED
@@ -0,0 +1,763 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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_patience = ctx["promote_patience"]
428
+ min_stage_steps = ctx["min_stage_steps"]
429
+ ckpt_keep = ctx["ckpt_keep"]
430
+ stage_ckpt_dir = ctx["stage_ckpt_dir"]
431
+
432
+ # Controller state.
433
+ grad_acc = {} # stage t -> level-(t+2) val_acc at graduation
434
+ plateau_steps = int(getattr(config, "plateau_steps", 0))
435
+ plateau_delta = float(getattr(config, "plateau_delta", 0.005))
436
+ stage_best_acc = -1.0
437
+ stage_best_step = start_step
438
+ mode = "frontier" # "frontier" | "repair"
439
+ repair_stage = None # stage currently being repaired
440
+ repair_started_at = 0 # step the current repair stage began (cap)
441
+ repair_episode_start = 0 # step we first left the frontier (clock credit)
442
+ stage_started_at = start_step # step the current frontier stage began
443
+ last_repair_return_step = start_step # for min-frontier cooldown
444
+ step_counts = {t: 0 for t in range(1, K + 1)} # steps trained at each depth
445
+ repair_steps_total = 0 # for duty-cycle cap
446
+ print(f"[repair] knobs: margin={margin} max_repair_steps={max_repair_steps} "
447
+ f"min_frontier={min_frontier_steps} frontier_target={frontier_target_acc} "
448
+ f"max_repair_frac={max_repair_fraction} grad_decay={grad_decay} "
449
+ f"freeze_after={freeze_after_step} frontier_mix={frontier_mix}",
450
+ flush=True)
451
+ # When seeding from a mid-curriculum checkpoint (start stage > 1), the
452
+ # earlier stages graduated in the *source* run so we have no reference for
453
+ # them. Prefer an explicit SUDOKU_GRAD_ACC_SEED (true graduation refs);
454
+ # otherwise seed from the first eval (old behavior — can over-trigger).
455
+ seed_start_stage = int(getattr(config, "curriculum_start_stage", 1))
456
+ grad_acc_seeded = seed_start_stage <= 1
457
+ if (not grad_acc_seeded) and grad_acc_seed_raw.strip():
458
+ try:
459
+ vals = [float(x) for x in grad_acc_seed_raw.split(",") if x.strip()]
460
+ for s, v in enumerate(vals, start=1):
461
+ if s < seed_start_stage:
462
+ grad_acc[s] = v
463
+ if grad_acc:
464
+ grad_acc_seeded = True
465
+ print(f"[repair] seeded graduation refs from env: "
466
+ f"{dict((k, round(v, 3)) for k, v in grad_acc.items())}",
467
+ flush=True)
468
+ except ValueError:
469
+ print(f"[repair] WARNING: bad SUDOKU_GRAD_ACC_SEED="
470
+ f"{grad_acc_seed_raw!r}; falling back to first-eval seeding",
471
+ flush=True)
472
+
473
+ def compute_deficits(frontier_stage, per_depth):
474
+ """{depth t: graduation_acc[t] - current_acc} over earlier graduated
475
+ depths whose current candidate-set accuracy is measured.
476
+
477
+ Keyed on reasoning depth, not difficulty level: depth t's accuracy is
478
+ how well wave snapshot t is predicted, which is exactly what stage t
479
+ taught. A drop there means that propagation block has been forgotten."""
480
+ d = {}
481
+ for t in range(1, frontier_stage):
482
+ if t in grad_acc and per_depth.get(t, -1.0) >= 0:
483
+ d[t] = grad_acc[t] - per_depth.get(t, -1.0)
484
+ return d
485
+
486
+ def effective_margin(per_depth, frontier_stage):
487
+ """Widen the repair trigger while the frontier itself is still weak."""
488
+ m = margin
489
+ if frontier_target_acc > 0:
490
+ f_acc = per_depth.get(frontier_stage, -1.0)
491
+ if 0.0 <= f_acc < frontier_target_acc:
492
+ # Only repair clearer regressions until the frontier is good.
493
+ m = max(m, margin + (frontier_target_acc - f_acc))
494
+ return m
495
+
496
+ def most_deficient(frontier_stage, per_depth, use_margin=None):
497
+ """Most-regressed depth whose drop exceeds the margin, else None."""
498
+ d = compute_deficits(frontier_stage, per_depth)
499
+ if not d:
500
+ return None
501
+ m = margin if use_margin is None else use_margin
502
+ t = max(d, key=d.get)
503
+ return t if d[t] > m else None
504
+
505
+ with metric_writers.ensure_flushes(writer):
506
+ for step in range(start_step, config.max_steps):
507
+ if step % 10000 == 0:
508
+ print("Step:", step, flush=True)
509
+
510
+ i = curriculum.stage
511
+ # Target depth: the frontier when training normally, else the depth
512
+ # we are repairing. Difficulty is never gated, so a repair differs
513
+ # from a frontier step only in the recurrence depth it trains at:
514
+ # replaying depth t re-supervises wave snapshots 1..t on the same
515
+ # full-corpus batch distribution.
516
+ t = repair_stage if mode == "repair" else i
517
+ batch = sampler.sample_all(config.minibatch_size)
518
+ state, metrics = _run_train_step(
519
+ p_frontier[t], state, batch, dropout_rngs)
520
+ step_counts[t] += 1
521
+ if mode == "repair":
522
+ repair_steps_total += 1
523
+
524
+ for h in hooks:
525
+ h(step)
526
+
527
+ if math.isnan(metrics["loss"][0]):
528
+ print("Loss became nan; stopping.", flush=True)
529
+ break
530
+
531
+ if step % config.eval_every_steps == 0:
532
+ eval_metrics = evaluater.get_eval_metrics(
533
+ state, eval_data_iter, p_eval_step, config)
534
+ per_level = eval_metrics.pop("per_level_acc")
535
+ # Depth accuracy drives the controller. Changed-cells-only, so
536
+ # a slot that merely copies its predecessor scores zero credit.
537
+ per_depth = eval_metrics.pop("per_slot_acc_changed", {})
538
+ per_depth_all = eval_metrics.pop("per_slot_acc", {})
539
+ if not any(v >= 0 for v in per_depth.values()):
540
+ per_depth = per_depth_all
541
+
542
+ def _m(key):
543
+ v = eval_metrics.get(key, [])
544
+ return round(float(np.mean(v)), 4) if len(v) else -1.0
545
+
546
+ # Seed graduation refs for stages inherited from a checkpoint.
547
+ if not grad_acc_seeded:
548
+ for s in range(1, seed_start_stage):
549
+ acc_s = per_depth.get(s, -1.0)
550
+ if acc_s >= 0:
551
+ grad_acc[s] = float(acc_s)
552
+ grad_acc_seeded = True
553
+ print(f"[repair] step {step}: seeded graduation refs from "
554
+ f"resume: "
555
+ f"{dict((k, round(v, 3)) for k, v in grad_acc.items())}",
556
+ flush=True)
557
+
558
+ # Soft graduation refs: forgive chronic mild regression.
559
+ if grad_decay > 0 and grad_acc:
560
+ for s in list(grad_acc.keys()):
561
+ cur_s = per_depth.get(s, -1.0)
562
+ if cur_s >= 0 and cur_s < grad_acc[s]:
563
+ old = grad_acc[s]
564
+ grad_acc[s] = (
565
+ (1.0 - grad_decay) * grad_acc[s]
566
+ + grad_decay * float(cur_s))
567
+ if step % (config.eval_every_steps * 5) == 0:
568
+ print(f"[repair] soft-grad depth {s}: "
569
+ f"{old:.3f}->{grad_acc[s]:.3f} "
570
+ f"(cur={cur_s:.3f})", flush=True)
571
+
572
+ frontier_acc = per_depth.get(curriculum.stage, -1.0)
573
+ eff_margin = effective_margin(per_depth, i)
574
+ deficits = compute_deficits(i, per_depth)
575
+ elapsed = max(1, step - start_step + 1)
576
+ repair_frac = repair_steps_total / float(elapsed)
577
+ bt_frozen = (
578
+ freeze_after_step > 0 and step >= freeze_after_step)
579
+ print(step, "stage", curriculum.stage,
580
+ "mode", mode,
581
+ "repair_stage", repair_stage,
582
+ "target_t", t,
583
+ "loss", round(float(metrics["loss"].mean()), 4),
584
+ "ce", round(float(metrics["ce_loss"].mean()), 4),
585
+ "aux_bce", round(float(metrics["aux_loss"].mean()), 4),
586
+ "| val_acc", _m("acc"), "loc_acc", _m("loc_acc"),
587
+ "val|loc", _m("val_given_loc_acc"),
588
+ "| frontier_acc", round(float(frontier_acc), 4),
589
+ "eff_margin", round(float(eff_margin), 4),
590
+ "| cand_bit_acc", _m("cand_bit_acc"),
591
+ "cand_set_acc", _m("cand_set_acc"),
592
+ "cand_set_chg", _m("cand_set_acc_changed"),
593
+ "| grad_acc", {k: round(v, 3) for k, v in grad_acc.items()},
594
+ "deficits", {k: round(v, 3) for k, v in deficits.items()},
595
+ "step_counts", dict(step_counts),
596
+ "repair_frac", round(repair_frac, 3),
597
+ "bt_frozen", bt_frozen,
598
+ flush=True)
599
+
600
+ with tf_summary_writer.as_default():
601
+ tf.summary.scalar("loss", metrics["loss"].mean(), step=step)
602
+ tf.summary.scalar("ce_loss", metrics["ce_loss"].mean(), step=step)
603
+ tf.summary.scalar("aux_bce_loss", metrics["aux_loss"].mean(), step=step)
604
+ tf.summary.scalar("curriculum_stage", curriculum.stage, step=step)
605
+ tf.summary.scalar("repair_mode", 1 if mode == "repair" else 0, step=step)
606
+ tf.summary.scalar("repair_stage", repair_stage or 0, step=step)
607
+ if frontier_acc >= 0:
608
+ tf.summary.scalar("frontier_depth_acc", frontier_acc, step=step)
609
+ tf.summary.scalar("eff_repair_margin", eff_margin, step=step)
610
+ for key in eval_metrics.keys():
611
+ tf.summary.scalar("eval_" + key,
612
+ np.array(eval_metrics[key]).mean(), step=step)
613
+ for lvl, v in per_level.items():
614
+ if v >= 0:
615
+ tf.summary.scalar(f"eval_acc_level_{lvl}", v, step=step)
616
+ for s, v in per_depth_all.items():
617
+ if v >= 0:
618
+ tf.summary.scalar(f"eval_cand_depth_{s}", v, step=step)
619
+
620
+ def _save_stage_ckpt(tag):
621
+ if config.save_checkpoint:
622
+ unrep = jax_utils.unreplicate(state)
623
+ checkpoints.save_checkpoint_multiprocess(
624
+ workdir, unrep, step, keep=ckpt_keep, overwrite=True)
625
+ checkpoints.save_checkpoint_multiprocess(
626
+ stage_ckpt_dir, unrep, step, keep=100,
627
+ overwrite=True, prefix=f"{tag}_")
628
+
629
+ # ---------------- Deficit-driven controller ----------------
630
+ # Freeze / duty-cycle: force frontier-only when budget exhausted.
631
+ duty_ok = (
632
+ max_repair_fraction <= 0
633
+ or repair_frac < max_repair_fraction)
634
+ if bt_frozen and mode == "repair":
635
+ print(f"[repair] step {step}: freeze_after="
636
+ f"{freeze_after_step}; leaving repair", flush=True)
637
+ mode = "frontier"
638
+ repair_stage = None
639
+ last_repair_return_step = step
640
+ if (not duty_ok) and mode == "repair":
641
+ print(f"[repair] step {step}: duty-cycle cap "
642
+ f"(repair_frac={repair_frac:.3f}>="
643
+ f"{max_repair_fraction}); return to frontier",
644
+ flush=True)
645
+ mode = "frontier"
646
+ repair_stage = None
647
+ last_repair_return_step = step
648
+
649
+ if mode == "frontier":
650
+ cooldown_ok = (
651
+ min_frontier_steps <= 0
652
+ or (step - last_repair_return_step) >= min_frontier_steps)
653
+ can_repair = (not bt_frozen) and duty_ok and cooldown_ok
654
+ worst = (most_deficient(i, per_depth, use_margin=eff_margin)
655
+ if can_repair else None)
656
+ if worst is not None:
657
+ mode = "repair"
658
+ repair_stage = worst
659
+ repair_started_at = step
660
+ repair_episode_start = step
661
+ print(f"[repair] step {step}: enter repair of depth "
662
+ f"{worst} (snapshot {worst} acc="
663
+ f"{per_depth.get(worst, -1.0):.3f} < grad "
664
+ f"{grad_acc.get(worst, -1.0):.3f} - "
665
+ f"eff_margin {eff_margin:.3f}; "
666
+ f"frontier_acc={frontier_acc:.3f})",
667
+ flush=True)
668
+ elif bt_frozen and most_deficient(
669
+ i, per_depth, use_margin=eff_margin) is not None:
670
+ if step % (config.eval_every_steps * 5) == 0:
671
+ print(f"[repair] step {step}: deficit present but "
672
+ f"BT frozen after {freeze_after_step}",
673
+ flush=True)
674
+ elif (not duty_ok) and most_deficient(
675
+ i, per_depth, use_margin=eff_margin) is not None:
676
+ if step % (config.eval_every_steps * 5) == 0:
677
+ print(f"[repair] step {step}: deficit present but "
678
+ f"duty-cycle cap "
679
+ f"(frac={repair_frac:.3f})",
680
+ flush=True)
681
+ elif (not cooldown_ok) and most_deficient(
682
+ i, per_depth, use_margin=eff_margin) is not None:
683
+ print(f"[repair] step {step}: deficit present but "
684
+ f"frontier cooldown "
685
+ f"({step - last_repair_return_step}/"
686
+ f"{min_frontier_steps}); staying on stage {i}",
687
+ flush=True)
688
+ elif curriculum.stage < curriculum.max_stage:
689
+ # Normal promotion (same rule as the standard loop).
690
+ steps_in_stage = step - stage_started_at
691
+ # A negative accuracy means "not measured this eval": it
692
+ # must not reset the plateau tracker nor satisfy the
693
+ # threshold/plateau rule. Only patience fires unmeasured.
694
+ measured = frontier_acc >= 0
695
+ if measured and frontier_acc > stage_best_acc + plateau_delta:
696
+ stage_best_acc = frontier_acc
697
+ stage_best_step = step
698
+ hit_threshold = measured and frontier_acc >= promote_threshold
699
+ stalled = (measured and plateau_steps > 0
700
+ and (step - stage_best_step) >= plateau_steps)
701
+ patience_over = steps_in_stage >= promote_patience
702
+ if steps_in_stage >= min_stage_steps and (
703
+ hit_threshold or stalled or patience_over):
704
+ reason = ("threshold" if hit_threshold
705
+ else "plateau" if stalled else "patience")
706
+ # Record this stage's graduation accuracy BEFORE moving on.
707
+ grad_acc[curriculum.stage] = float(frontier_acc)
708
+ curriculum.stage += 1
709
+ stage_started_at = step
710
+ stage_best_acc = -1.0
711
+ stage_best_step = step
712
+ print(f"[curriculum] step {step}: promote to stage "
713
+ f"{curriculum.stage} ({reason}; graduated "
714
+ f"depth {curriculum.stage - 1} cand-set "
715
+ f"acc={frontier_acc:.3f})", flush=True)
716
+ _save_stage_ckpt(f"stage{curriculum.stage}")
717
+ elif (frontier_target_acc > 0
718
+ and 0.0 <= frontier_acc < frontier_target_acc
719
+ and step % (config.eval_every_steps * 5) == 0):
720
+ print(f"[frontier] step {step}: depth {i} "
721
+ f"acc={frontier_acc:.3f} "
722
+ f"< target {frontier_target_acc:.3f}; "
723
+ f"keeping frontier priority",
724
+ flush=True)
725
+ else: # mode == "repair"
726
+ r = repair_stage
727
+ cur = per_depth.get(r, -1.0)
728
+ ref = grad_acc.get(r, -1.0)
729
+ recovered = cur >= (ref - margin)
730
+ capped = (step - repair_started_at) >= max_repair_steps
731
+ if recovered or capped:
732
+ why = "recovered" if recovered else "cap"
733
+ print(f"[repair] step {step}: depth {r} done ({why}; "
734
+ f"snapshot acc={cur:.3f} vs grad {ref:.3f})",
735
+ flush=True)
736
+ _save_stage_ckpt(f"repair{r}")
737
+ # Re-scan: chain to the next most-deficient stage, or
738
+ # return to the frontier and credit repair time back to
739
+ # the frontier stage's patience clock.
740
+ nxt = None
741
+ if (not bt_frozen) and duty_ok:
742
+ nxt = most_deficient(
743
+ i, per_depth, use_margin=eff_margin)
744
+ if nxt is not None:
745
+ repair_stage = nxt
746
+ repair_started_at = step
747
+ print(f"[repair] step {step}: chain to stage {nxt}",
748
+ flush=True)
749
+ else:
750
+ mode = "frontier"
751
+ repair_stage = None
752
+ last_repair_return_step = step
753
+ stage_started_at += (step - repair_episode_start)
754
+
755
+ if config.save_checkpoint and step > 0 and step % config.save_every_steps == 0:
756
+ checkpoints.save_checkpoint_multiprocess(
757
+ workdir, jax_utils.unreplicate(state), step,
758
+ keep=ckpt_keep, overwrite=True)
759
+
760
+ if config.save_checkpoint:
761
+ checkpoints.save_checkpoint_multiprocess(
762
+ workdir, jax_utils.unreplicate(state), config.max_steps,
763
+ keep=ckpt_keep, overwrite=True)
code/wavecurriculum_run/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/wavecurriculum_run/verify_uniform_loader.py ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Verify the on-the-fly uniform instance sampler on the real stage-1 masks.
2
+
3
+ Replicates SudokuDataset.uniform_instance_values / apply_instance with numpy
4
+ only, then checks the three things the experiment depends on:
5
+ 1. the clue block is untouched, so the input prompt is fixed per puzzle
6
+ 2. every drawn digit lies in that cell's candidate set
7
+ 3. over N draws each candidate of each cell turns up a near-equal number of
8
+ times, and at least 5 times
9
+ """
10
+ import numpy as np
11
+
12
+ PUZ = ("/scratch/users/gatmiry/llm-reasoning-logic-puzzles/sudoku-code/"
13
+ "datasets/train_sudoku_puzzles.npy")
14
+ CAND = "/tmp/sudoku_s12/train_cand_masks.npy"
15
+ STAGE = 0
16
+ N_DRAWS = 64
17
+ K, LATENT_ID = 12, 10
18
+
19
+
20
+ def load_inputs(n):
21
+ raw = np.load(PUZ, mmap_mode="r")
22
+ rows = np.asarray(raw[:n]).astype(np.int64)
23
+ return np.delete(rows[:, 1:], np.arange(81) * 4 + 3, axis=1), rows[:, 0]
24
+
25
+
26
+ def uniform_instance_values(mask, rng):
27
+ """Same argmax-of-random-keys draw as the loader."""
28
+ m = mask.astype(np.int64)
29
+ bits = ((m[:, None] >> np.arange(9)) & 1).astype(np.float64)
30
+ keys = rng.random_sample((81, 9)) * bits
31
+ vals = (keys.argmax(1) + 1).astype(np.int8)
32
+ return np.where(m > 0, vals, 0)
33
+
34
+
35
+ def apply_instance(seq, vals):
36
+ seq = seq.copy()
37
+ cells = seq[0::3] * 9 + seq[1::3]
38
+ new = vals[cells]
39
+ seq[2::3] = np.where(new > 0, new, seq[2::3])
40
+ return seq
41
+
42
+
43
+ def digits_of(m):
44
+ return [d for d in range(1, 10) if int(m) & (1 << (d - 1))]
45
+
46
+
47
+ def main():
48
+ n_show = 500
49
+ inputs, si_all = load_inputs(n_show)
50
+ masks = np.load(CAND, mmap_mode="r")
51
+ rng = np.random.RandomState(0)
52
+
53
+ p = 3
54
+ si, seq = int(si_all[p]), inputs[p]
55
+ mask = np.asarray(masks[p, STAGE])
56
+ tr = seq.reshape(-1, 3)
57
+ empty = (tr[si:, 0] * 9 + tr[si:, 1])
58
+
59
+ drawn = [apply_instance(seq, uniform_instance_values(mask, rng))
60
+ for _ in range(N_DRAWS)]
61
+
62
+ print(f"puzzle p={p}: {si} clues, {len(empty)} empty cells, "
63
+ f"{N_DRAWS} uniform draws")
64
+
65
+ clue_ok = all(np.array_equal(d[:3 * si], seq[:3 * si]) for d in drawn)
66
+ print(f" clue block identical in all {N_DRAWS} draws: {clue_ok}")
67
+
68
+ bad = sum(int(d[2::3][j + si]) not in digits_of(mask[empty[j]])
69
+ for d in drawn for j in range(len(empty)))
70
+ print(f" drawn digits outside the candidate set: {bad}")
71
+
72
+ blk = np.stack([d[2::3] for d in drawn])[:, si:] # (N, n_empty)
73
+
74
+ print(f"\ninstance = one digit for every empty cell. First 10 cells:")
75
+ print(" cells: " + " ".join(f"({c // 9},{c % 9})" for c in empty[:10]))
76
+ for i in range(4):
77
+ print(f" i{i + 1}: " + " ".join(
78
+ f"{int(x)}" for x in blk[i, :10]))
79
+ print(" S: " + " ".join(f"{digits_of(mask[c])}" for c in empty[:4])
80
+ + " ...")
81
+
82
+ print(f"\nreading DOWN a cell's column over {N_DRAWS} draws:")
83
+ print(f" {'cell':>8} {'|S|':>3} {'S':<16}{'counts':<34}min")
84
+ shown = 0
85
+ worst = 10 ** 9
86
+ for j, c in enumerate(empty):
87
+ S = digits_of(mask[c])
88
+ if len(S) < 2:
89
+ continue
90
+ v, k = np.unique(blk[:, j], return_counts=True)
91
+ cnt = {int(a): int(b) for a, b in zip(v, k)}
92
+ worst = min(worst, min(cnt.get(d, 0) for d in S))
93
+ shown += 1
94
+ if shown > 8:
95
+ continue
96
+ cs = " ".join(f"{d}:{cnt.get(d, 0)}" for d in S)
97
+ print(f" ({c // 9},{c % 9}) {len(S):>3} {str(S):<16}{cs:<34}"
98
+ f"{min(cnt.get(d, 0) for d in S)}")
99
+ print(f" ... {shown} multi-candidate cells; rarest candidate anywhere in "
100
+ f"this puzzle appeared {worst}x (need >=5)")
101
+
102
+ print(f"\nover {n_show} puzzles:")
103
+ ge5 = tot = 0
104
+ spreads = []
105
+ for q in range(n_show):
106
+ m = np.asarray(masks[q, STAGE])
107
+ b = np.stack([uniform_instance_values(m, rng) for _ in range(N_DRAWS)])
108
+ for c in range(81):
109
+ S = digits_of(m[c])
110
+ if len(S) < 2:
111
+ continue
112
+ v, k = np.unique(b[:, c], return_counts=True)
113
+ cnt = {int(a): int(b_) for a, b_ in zip(v, k)}
114
+ tot += len(S)
115
+ ge5 += sum(1 for d in S if cnt.get(d, 0) >= 5)
116
+ pr = np.array([cnt.get(d, 0) for d in S], dtype=float)
117
+ pr /= pr.sum()
118
+ nz = pr[pr > 0]
119
+ spreads.append(float(-(nz * np.log(nz)).sum()) / np.log(len(S)))
120
+ print(f" (cell, candidate) pairs seen >=5 times: {ge5 / tot:.4f}")
121
+ print(f" mean spread H(p)/log|S|: {np.mean(spreads):.4f} "
122
+ f"(1.0 = uniform superposition)")
123
+
124
+ full = np.concatenate([drawn[0][:3 * si],
125
+ np.full(K, LATENT_ID, dtype=drawn[0].dtype),
126
+ drawn[0][3 * si:]])
127
+ print(f"\ntoken sequence: {len(full)} = {3 * si} clue + {K} latent + "
128
+ f"{3 * (81 - si)} output")
129
+
130
+
131
+ if __name__ == "__main__":
132
+ main()