File size: 11,070 Bytes
8f46582
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
# Latent-CoT Graph Reachability — Experiment Log

Persistent working notes so progress/commands survive chat resets. Newest status
at the top of "Current Status"; details and history below. Update this file
whenever we decide/do something.

Repo: `reasoning-by-superposition-main` (NeurIPS 2025 "Reasoning by Superposition",
authors' official code). Env: conda env `superposition` (torch 2.5.1+cu121,
transformers 4.46.2). Node: `slimgpu` (8× RTX A5000).

---

## Goal

Small from-scratch GPT-2 (symbol, 2-layer / 8-head / 768-dim) trained with Coconut
continuous latent CoT on a 2-arm **star-graph reachability** task, where the token
vocabulary **is the set of graph node ids** (node id == token id). This makes the
LM head a "node dictionary": applying it (logit lens) to each latent thought reads
out *which graph node that thought points to*, so we can interpret each latent as
one BFS hop of reasoning depth. Then: scale depth (L6 -> L20) and study
backtracking (retrain earlier latent stages when they regress).

Task detail: two disjoint star components, each with root + 2 arms of length L.
One component is reachable from the query root, the other is a distractor. Model
must answer which candidate leaf is reachable. `bfs_variant: True` => the "frontier"
at hop k is the two reachable-arm nodes at depth k. Key metric = per-hop
`frontier` accuracy (does latent k decode to a correct depth-k reachable node).

---

## Current Status (2026-07-21)

- **Depth-6 (L6): DONE, success.** Per-hop `frontier` accuracy 0.98-1.0 across all
  6 hops. Logit-lens probe (`probe_latents.py`) confirms each of the 6 latents
  decodes to a correct node one BFS hop deeper on the reachable arm, never the
  distractor. Checkpoints in `ckpts/star-coconut-L6-bfs/`. Log:
  `logs/star_coconut_L6_bfs.log`.
- **Depth-20 (L20), epoch-scheduled version: FAILED to learn.** Config
  `args/star_coconut_L20_bfs.yaml`. The curriculum advanced the frontier on a fixed
  epoch timer (`scheduled_stage = epoch // epochs_per_stage`) and reached frontier
  14, but per-hop `frontier` acc stayed ~0.02-0.04 at EVERY hop (incl. hop 1).
  i.e. training compute got smeared across stages that were never mastered.
  **This run was killed.**
- **Depth-20, ACCURACY-GATED version: the current approach.** Config
  `args/star_coconut_L20_bfs_accstage.yaml`. Promotion is now driven by measured
  accuracy, not the epoch counter (see "Accuracy-gated curriculum" below).

### 2026-07-21 evening: root-caused the L20 failure = TRAINING DIVERGENCE
- The accuracy-gated run correctly HELD at stage 1 for 65 epochs (~1h) because
  hop-1 acc never cleared 0.9 -- but the real issue is that **eval loss was
  monotonically INCREASING**: ~4.62 -> 5.9+, while random loss for the 128-vocab is
  ln(128) ~= 4.85. So the model started ~chance and diverged past chance. hop-1 acc
  ~0.02 == uniform random over ~100 nodes. The old epoch-scheduled L20 run had the
  same near-random accuracy => this is an L20 training-instability problem, NOT a
  data or curriculum problem.
- Data verified well-formed (root/target/neighbor_k valid, node ids <= 99, 80 edges,
  20 steps). Difference from working L6: ~330-token sequences (80 edges) vs ~6 edges,
  wider vocab, `lr=1e-4`, and NO gradient clipping. Classic long-seq divergence.

### Fixes applied (this session)
1. **Stabilization** in `run.py` + config: gradient clipping (`grad_clip: 1.0`),
   lower `lr` 1e-4 -> 3e-5, linear LR `warmup_steps: 200`.
2. **Gate off-by-one fix**: metric "hop k" uses (k-1) latents to predict a depth-k
   node, so "stage s learned" == per-hop acc at hop (s+1). The promotion gate now
   requires min acc over hops 1..(cur_stage+1) >= threshold (previously 1..cur_stage,
   which never checked the current stage's own target).
3. **Limited backprop**: `backprop_depth: 1` (was 6) -- gradient only through the
   newest latent step + answer, matching the incremental curriculum. Simple, not
   full BPTT across stages.
- Status: relaunch `star_coconut_L20_bfs_accstage.yaml` with these fixes.

### 2026-07-21 late: first stabilization attempt still diverged -> FSDP clip bug
- With lr 3e-5 + grad_clip 1.0 + warmup, eval loss STILL climbed (4.62 -> 5.2 over
  80 epochs) and stage 1 stayed at chance (HOLD 80 epochs). So clipping wasn't
  actually working.
- Root cause: the model is wrapped in **FSDP** (params sharded), but the clip used
  `torch.nn.utils.clip_grad_norm_(parallel_model.parameters(), ...)`, which computes
  the norm over only each rank's local shard -> under-counts -> effectively no clip.
- Fix: use `parallel_model.clip_grad_norm_()` when the model is FSDP (all-reduces the
  global norm). Also lowered `lr` 3e-5 -> 1e-5.
- If loss STILL rises after this, the remaining suspect is model capacity: a 2-layer
  GPT-2 doing multi-hop pointer chasing over ~80 shuffled edges (vs ~6 at L6). Next
  step would be a controlled L6-with-same-code sanity run, then consider more layers.

---

## Accuracy-gated curriculum + backtracking (what we changed and why)

Motivation (K's guidance): stage promotion should be **accuracy-dependent**, not
iteration-dependent. Only advance to stage k+1 once every stage 1..k has reached a
target accuracy. And keep the sudoku-style backtracking: at each eval check all
previous stages; if any falls below threshold, go back and rehearse/retrain it.

Implemented in `run.py`:
- New config flag `accuracy_staging: True`. When on, `scheduled_stage = cur_stage`,
  a persistent frontier that only advances when earned.
- After each per-hop eval: promote `cur_stage -> cur_stage+1` only when
  `min(frontier_acc[1..cur_stage]) >= promote_threshold` (default 0.9). Because it
  checks *all* stages <= frontier, a regression anywhere blocks promotion.
- Logs per-stage timing: `[acc-stage] PROMOTE stage k -> k+1 | solved in N epochs / Ts`.
  `HOLD` lines mean the current frontier isn't solved yet.
- Backtracking rehearsal preserved: `bt_r_current` = earliest hop below
  `backtrack_detect_threshold`; the training-set builder
  `get_graph_latent_cot_dataset_backtrack` samples each example's stage from a
  rehearsal distribution (broad w.p. `remember_rate`, else targeted
  `[r_current..frontier]`).
- `perhop_val_samples` caps val per-hop eval cost so frequent eval stays cheap.

Earlier enabling fixes (already in the code):
- **Vectorized latent feedback** in `coconut.py` (clone+scatter instead of a
  Python double-loop of ~11k tiny GPU ops). Proven numerically identical.
- **O(batch^2 x depth) sync bug** in `coconut.py` `latent_lists` construction
  (per-element `.item()` GPU->CPU syncs) replaced with one `.tolist()` + pure-Python
  grouping. This was the real speed killer; latency went from 7357ms -> 561ms/it at
  stage 20 (~13x), roughly flat across depth.
- **Truncated BPTT** `backprop_depth: W` in `coconut.py`: detaches fed-back thought
  + KV older than W latent steps => bounded backward memory/compute regardless of
  depth. Default None = full BPTT (L6 baseline unchanged). L20 uses `backprop_depth: 6`.
- Widened node vocab to 100 (`stokenizer.py` `NUM_NODES=100`), model
  `configs/symbol-2layer-8head-768dim-L20.json` `vocab_size=128`, data regenerated
  at L=20 (`data/star_2arm_L20_*_fo_bfs.json`, 82 nodes/sample, 80 edges).

---

## Curriculum semantics (what each stage learns)

From `expand_data`: at **stage s** the prompt has **s latent tokens** and the model
is trained to output the **next hop's frontier node (depth s+1)** -- NOT the final
leaf. Only the final stage (`k = max_steps+1`, all latents + `[A]`) predicts the
target leaf. So each intermediate stage teaches one more hop of the walk:
- stage 0: 0 latents -> predict a depth-1 node (root's neighbor)
- stage 1: 1 latent  -> predict a depth-2 node
- stage s: s latents -> predict a depth-(s+1) node
- final:   all latents + [A] -> predict the target leaf
Per-hop metric "hop k" = (k-1) latents -> predict depth-k node, i.e. hop k tests
training stage (k-1). (This is why the promotion gate checks hop cur_stage+1.)

## Interpretability / probing (how latent -> node works)

- A latent "thought" is the previous position's last-layer hidden state
  h in R^768, fed back in place of a token embedding (never discretized).
- Logit lens: node_hat_k = argmax over node-id columns of (W_U h), where W_U is the
  model's own tied unembedding. Since node id == token id, this reads the node.
- `probe_latents.py`: one teacher-forced validation forward pass over held-out
  graphs; reads the head at each latent-feeder position; scores set-membership in
  the hop-k reachable frontier vs the distractor arm.

---

## Commands

Activate env:
```
source ~/miniforge3/etc/profile.d/conda.sh && conda activate superposition
```

Launch accuracy-gated L20 (single line; GPUs 2,3):
```
source ~/miniforge3/etc/profile.d/conda.sh && conda activate superposition && cd /egr/research-slim/ghoshavr/reasoning-by-superposition-main && CUDA_VISIBLE_DEVICES=2,3 WANDB_MODE=offline nohup torchrun --standalone --nnodes 1 --nproc_per_node 2 run.py args/star_coconut_L20_bfs_accstage.yaml > logs/star_coconut_L20_bfs_accstage.log 2>&1 & echo "PID=$!"
```

Kill a run:
```
pkill -9 -f 'star_coconut_L20_bfs_accstage.yaml'
```

Monitor:
```
grep -E 'acc-stage|scheduled_stage|eval per-hop|Accuracy on validation' logs/star_coconut_L20_bfs_accstage.log | tail -n 40
```

Probe L6 latents (example):
```
CUDA_VISIBLE_DEVICES=3 python probe_latents.py   # see script for args
```

---

## Known issues / gotchas

- **GPU 2 has hung on CUDA init** in this session (bad state). Probing all GPUs or
  `nvidia-smi` can hang the shell. Always pin `CUDA_VISIBLE_DEVICES` to a known-good
  device and wrap ad-hoc probes in `timeout`. (Note: the L20 training itself ran on
  2,3 fine, so 2 may be intermittently OK.)
- **Cursor agent shell repeatedly wedges** behind an unkillable CUDA-init; a window
  reload did not always free the agent's worker. Workaround: run launch/kill
  commands in a normal terminal; the agent can still read logs/files directly.
- **Multi-line paste hazard:** pasted multi-line blocks lost their newlines and ran
  glued together (`superpositioncd ...`). Use the single-line command forms above.
- **Resume-logic crash (fixed):** `run.py` treated any non-empty `ckpts/<name>/`
  dir as a resume and crashed when no `checkpoint_*` file existed
  (`'NoneType'.split`). Fixed to only resume when real `checkpoint_*` files exist.

---

## Next steps

1. Confirm accuracy-gated L20 starts cleanly and learns hop 1 (stage 1 -> >=0.9).
   This is the real test: with all early compute focused on stage 1, does a single
   hop become learnable (unlike the smeared epoch-scheduled run)?
2. Record stage-by-stage solve time (epochs + wall-clock) from `[acc-stage]` logs.
3. Watch for backtracking `HOLD`s (earlier stage regressed -> rehearsed).
4. If hop 1 still won't train, debug that specifically (LR, backprop_depth,
   data/vocab) rather than advancing the frontier. Consider an L10 bisection run.
5. Re-run `probe_latents.py` on the best L20 checkpoint to see how deep the latents
   stay faithful.