sumitk commited on
Commit
4e70c09
·
verified ·
1 Parent(s): 409eaf8

Upload folder using huggingface_hub

Browse files
Dockerfile ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ ENV PYTHONDONTWRITEBYTECODE=1 \
6
+ PYTHONUNBUFFERED=1 \
7
+ PYTHONPATH=/app/src \
8
+ HOST=0.0.0.0 \
9
+ PORT=7860
10
+
11
+ RUN pip install --no-cache-dir \
12
+ torch --index-url https://download.pytorch.org/whl/cpu && \
13
+ pip install --no-cache-dir numpy pyyaml tiktoken
14
+
15
+ COPY pyproject.toml README.md ./
16
+ COPY configs/ ./configs/
17
+ COPY src/ ./src/
18
+ COPY data/tinystories/tiktoken_compact_4096.json ./data/tinystories/tiktoken_compact_4096.json
19
+ COPY data/tinystories/val_sample_256.txt ./data/tinystories/val_sample_256.txt
20
+ COPY runs/tinystories/benchmark_24h.json ./runs/tinystories/benchmark_24h.json
21
+
22
+ EXPOSE 7860
23
+
24
+ CMD ["python", "-m", "conveyer.web_sim", "--host", "0.0.0.0", "--port", "7860"]
configs/synthetic.yaml ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ model:
2
+ vocab_size: 128
3
+ d_model: 256
4
+ n_heads: 4
5
+ n_layers: 6
6
+ d_ff: 688
7
+ max_seq_len: 512
8
+ dropout: 0.0
9
+ belt_size: 64
10
+ tau_min: 0.5
11
+ tau_max: 4.0
12
+ tau_gamma: 1.5
13
+ gate_bias_init: 1.0
14
+ spawn_noise_std: 0.5
15
+ thought_noise_std: 0.25
16
+ use_thought_highway: true
17
+ causal_belt_only: false
18
+
19
+ train:
20
+ out_dir: "runs/synthetic"
21
+ seed: 42
22
+ batch_size: 64
23
+ lr: 0.0005
24
+ weight_decay: 0.01
25
+ grad_clip: 1.0
26
+ max_steps: 1200
27
+ warmup_steps: 80
28
+ eval_every: 100
29
+ log_every: 25
30
+ unroll_steps: 16
31
+ seed_sigma_max: 3.5
32
+ seed_sigma_eta: 1.25
33
+ loss_w_min: 0.05
34
+ loss_w_beta: 1.5
35
+ entropy_reg: 0.001
36
+ no_belt_bptt: false
37
+ no_thought_highway: false
38
+ ar_baseline: false
39
+
40
+ infer:
41
+ prompt_overlap: 16
42
+ bake_steps: 64
43
+ temperature: 0.0
44
+ max_new_tokens: 64
45
+
46
+ data:
47
+ dataset: "synthetic"
48
+ task: "all"
49
+ seq_len: 128
50
+ num_train: 24000
51
+ num_val: 1024
52
+ data_dir: "data/synthetic"
53
+ vocab_size: 128
pyproject.toml ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "diffusion-conveyer-llm"
7
+ version = "0.1.0"
8
+ description = "64-Slot Dual-Channel Conveyor-Belt Diffusion Language Model"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ dependencies = [
12
+ "torch>=2.4.0",
13
+ "numpy>=1.26.0",
14
+ "pyyaml>=6.0",
15
+ "tqdm>=4.66.0",
16
+ "tokenizers>=0.19.0",
17
+ ]
18
+
19
+ [project.optional-dependencies]
20
+ dev = [
21
+ "pytest>=8.0.0",
22
+ ]
23
+
24
+ [project.scripts]
25
+ conveyer-train = "conveyer.train:main"
26
+ conveyer-eval = "conveyer.eval:main"
27
+ conveyer-infer = "conveyer.infer:main"
28
+ conveyer-sweep = "conveyer.sweep:main"
29
+ conveyer-live = "conveyer.live:main"
30
+ conveyer-web = "conveyer.web_sim:main"
31
+
32
+ [tool.setuptools.packages.find]
33
+ where = ["src"]
34
+
35
+ [tool.pytest.ini_options]
36
+ testpaths = ["tests"]
37
+ pythonpath = ["src"]
src/conveyer/__init__.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ from conveyer.config import ConveyerConfig, load_config
2
+ from conveyer.model import ConveyerDiffusionLM
3
+
4
+ __all__ = ["ConveyerConfig", "load_config", "ConveyerDiffusionLM"]
src/conveyer/checkpoint.py ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict
4
+ import os
5
+ from pathlib import Path
6
+ from typing import Any, Optional
7
+ import urllib.request
8
+ import torch
9
+
10
+ from conveyer.config import ConveyerConfig, _merge_dataclass
11
+ from conveyer.model import ConveyerDiffusionLM
12
+
13
+ HF_DEFAULT_CKPT_URL = "https://huggingface.co/sumitk/diffusion-conveyer-llm/resolve/main/best.pt"
14
+
15
+
16
+ def _move_to_cpu(obj: Any) -> Any:
17
+ if isinstance(obj, torch.Tensor):
18
+ return obj.detach().to(device="cpu", non_blocking=False)
19
+ if isinstance(obj, dict):
20
+ return {k: _move_to_cpu(v) for k, v in obj.items()}
21
+ if isinstance(obj, list):
22
+ return [_move_to_cpu(v) for v in obj]
23
+ if isinstance(obj, tuple):
24
+ return tuple(_move_to_cpu(v) for v in obj)
25
+ return obj
26
+
27
+
28
+ def _atomic_write_payload(path: Path, payload: dict[str, Any]) -> None:
29
+ """
30
+ Crash-safe atomic checkpoint writer on Windows NTFS:
31
+ 1. If `path` already exists, rotates `path -> path.prev` BEFORE writing so an abrupt BSOD/power loss
32
+ never leaves both the current and previous checkpoint with un-flushed NTFS data blocks.
33
+ 2. Writes `payload` to `path.tmp`, calls `f.flush()` + `os.fsync(f.fileno())` to force NTFS data blocks
34
+ to physical NVMe storage BEFORE updating the directory metadata via `os.replace`.
35
+ """
36
+ path.parent.mkdir(parents=True, exist_ok=True)
37
+ tmp_p = path.with_suffix(path.suffix + ".tmp")
38
+ prev_p = path.with_suffix(path.suffix + ".prev")
39
+
40
+ with open(tmp_p, "wb") as f:
41
+ torch.save(payload, f)
42
+ f.flush()
43
+ os.fsync(f.fileno())
44
+
45
+ if path.exists():
46
+ try:
47
+ os.replace(path, prev_p)
48
+ except OSError:
49
+ pass
50
+
51
+ os.replace(tmp_p, path)
52
+
53
+
54
+ def save_checkpoint(
55
+ path: str | Path,
56
+ model: ConveyerDiffusionLM,
57
+ cfg: ConveyerConfig,
58
+ step: int,
59
+ optimizer: Optional[torch.optim.Optimizer] = None,
60
+ metrics: Optional[dict[str, Any]] = None,
61
+ best_val_loss: Optional[float] = None,
62
+ elapsed_sec: float = 0.0,
63
+ ) -> None:
64
+ """
65
+ Copies `model` and `optimizer` state dicts to CPU RAM first (avoiding 350 MB of synchronous
66
+ WDDM GPU->CPU DMA transfers inside `torch.save` that can trigger `dxgmms2.sys` BSODs),
67
+ then writes atomically with `os.fsync` and `.prev` backup rotation.
68
+ """
69
+ if torch.cuda.is_available():
70
+ torch.cuda.synchronize()
71
+
72
+ cpu_model_state = _move_to_cpu(model.state_dict())
73
+ payload: dict[str, Any] = {
74
+ "model_state": cpu_model_state,
75
+ "config": asdict(cfg),
76
+ "step": step,
77
+ "metrics": metrics or {},
78
+ "best_val_loss": best_val_loss,
79
+ "elapsed_sec": elapsed_sec,
80
+ }
81
+ if optimizer is not None:
82
+ payload["optimizer_state"] = _move_to_cpu(optimizer.state_dict())
83
+
84
+ _atomic_write_payload(Path(path), payload)
85
+
86
+
87
+ def load_checkpoint_payload(path: str | Path, map_location: Any = "cpu") -> tuple[dict[str, Any], Path]:
88
+ """
89
+ Loads checkpoint dict from `path`, automatically falling back to `path.prev` or the latest
90
+ valid `ckpt_step_*.pt` in the same directory if `path` was corrupted by a power loss or BSOD.
91
+ """
92
+ p = Path(path)
93
+ candidates: list[Path] = [p, p.with_suffix(p.suffix + ".prev")]
94
+ if p.parent.exists():
95
+ milestones = sorted(p.parent.glob("ckpt_step_*.pt"), key=lambda x: x.stat().st_mtime, reverse=True)
96
+ for m in milestones:
97
+ if m not in candidates:
98
+ candidates.append(m)
99
+ m_prev = m.with_suffix(m.suffix + ".prev")
100
+ if m_prev.exists() and m_prev not in candidates:
101
+ candidates.append(m_prev)
102
+
103
+ last_err: Optional[Exception] = None
104
+ for cand in candidates:
105
+ if not cand.exists() or cand.stat().st_size == 0:
106
+ continue
107
+ try:
108
+ data = torch.load(cand, map_location=map_location, weights_only=False)
109
+ if isinstance(data, dict) and "model_state" in data:
110
+ return data, cand
111
+ except Exception as e:
112
+ last_err = e
113
+ print(f"[Checkpoint] Warning: skipping unreadable checkpoint {cand.name} ({e})", flush=True)
114
+
115
+ if p.name == "best.pt" and "tinystories" in str(p).replace("\\", "/"):
116
+ try:
117
+ p.parent.mkdir(parents=True, exist_ok=True)
118
+ tmp_dl = p.with_suffix(".pt.dl")
119
+ print(f"[Checkpoint] Downloading pretrained checkpoint from {HF_DEFAULT_CKPT_URL} ...", flush=True)
120
+ req = urllib.request.Request(HF_DEFAULT_CKPT_URL, headers={"User-Agent": "Mozilla/5.0"})
121
+ with urllib.request.urlopen(req, timeout=120) as resp, open(tmp_dl, "wb") as f:
122
+ while True:
123
+ chunk = resp.read(4 * 1024 * 1024)
124
+ if not chunk:
125
+ break
126
+ f.write(chunk)
127
+ os.replace(tmp_dl, p)
128
+ data = torch.load(p, map_location=map_location, weights_only=False)
129
+ if isinstance(data, dict) and "model_state" in data:
130
+ return data, p
131
+ except Exception as e:
132
+ last_err = e
133
+
134
+ raise RuntimeError(f"No valid checkpoint found for {p} (last error: {last_err})")
135
+
136
+
137
+ def load_checkpoint(
138
+ path: str | Path,
139
+ device: Optional[torch.device] = None,
140
+ ) -> tuple[ConveyerDiffusionLM, ConveyerConfig, dict[str, Any]]:
141
+ if device is None:
142
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
143
+ ckpt, used_path = load_checkpoint_payload(path, map_location=device)
144
+ if used_path != Path(path):
145
+ print(f"[Checkpoint] Recovered from fallback checkpoint {used_path}", flush=True)
146
+ cfg = ConveyerConfig()
147
+ if "config" in ckpt:
148
+ _merge_dataclass(cfg, ckpt["config"])
149
+ model = ConveyerDiffusionLM(cfg.model).to(device)
150
+ model.load_state_dict(ckpt["model_state"])
151
+ return model, cfg, ckpt
src/conveyer/config.py ADDED
@@ -0,0 +1,222 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict, dataclass, field, fields, is_dataclass
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ try:
9
+ import yaml # type: ignore
10
+ except ImportError:
11
+ yaml = None
12
+
13
+
14
+ @dataclass
15
+ class ModelConfig:
16
+ vocab_size: int = 128
17
+ d_model: int = 256
18
+ n_heads: int = 4
19
+ n_layers: int = 6
20
+ d_ff: int = 688
21
+ max_seq_len: int = 512
22
+ dropout: float = 0.0
23
+ rope_theta: float = 10000.0
24
+ # Conveyor belt parameters
25
+ belt_size: int = 64
26
+ front_conveyer_slots: int = 16 # Slots 0..15 shift left by 1 every tick; Slots 16..63 (75%) are Elastic Planning Zone
27
+ plan_horizon_stride: int = 2 # Back 75% planning slots span 2x longer horizon while sentences expand in middle
28
+ tau_min: float = 0.5
29
+ tau_max: float = 4.0
30
+ tau_gamma: float = 1.5
31
+ gate_bias_init: float = 1.0
32
+ spawn_noise_std: float = 0.5
33
+ thought_noise_std: float = 0.25
34
+ # Ablation & efficiency switches
35
+ use_thought_highway: bool = True
36
+ use_elastic_planning: bool = True # Enables 75% paused/elastic planning belt with mid-belt expansion
37
+ causal_belt_only: bool = False # If True, disables bidirectional belt attention (AR baseline mode)
38
+ grad_checkpointing: bool = False # Enables activation checkpointing for long unroll horizons (U=24..32)
39
+
40
+
41
+ @dataclass
42
+ class TrainConfig:
43
+ out_dir: str = "runs/synthetic"
44
+ seed: int = 42
45
+ batch_size: int = 64
46
+ lr: float = 5e-4
47
+ weight_decay: float = 0.01
48
+ grad_clip: float = 1.0
49
+ max_steps: int = 1200
50
+ max_hours: float = 0.0 # Optional wall-clock training budget in hours (0.0 = use max_steps only)
51
+ warmup_steps: int = 80
52
+ eval_every: int = 100
53
+ save_every: int = 500 # Periodic crash-safe checkpoint interval (steps)
54
+ log_every: int = 25
55
+ unroll_steps: int = 16
56
+ prefix_lm_weight: float = 1.0 # Weight on causal next-token LM loss across the prompt/sequence
57
+ step_cooldown_ms: int = 0 # Optional GPU thermal pacing sleep per step in ms
58
+ max_gpu_temp_c: int = 80 # Automatic thermal throttle threshold in Celsius
59
+ # Corrupted-belt seeding & recovery parameters
60
+ seed_sigma_max: float = 3.5
61
+ seed_sigma_eta: float = 1.25
62
+ recovery_prob: float = 0.35 # DAgger / self-conditioned prefix + belt recovery probability
63
+ belt_corrupt_prob: float = 0.20 # Mid-unroll belt perturbation rate to train active belt recovery
64
+ # Horizon loss weights: w_k = w_min + (1 - w_min) * (1 - k / (B - 1))^w_beta
65
+ loss_w_min: float = 0.05
66
+ loss_w_beta: float = 1.5
67
+ entropy_reg: float = 0.001
68
+ # Ablations
69
+ no_belt_bptt: bool = False
70
+ no_thought_highway: bool = False
71
+ ar_baseline: bool = False
72
+ overfit_batches: int = 0
73
+
74
+
75
+ @dataclass
76
+ class InferConfig:
77
+ prompt_overlap: int = 12
78
+ bake_steps: int = 64
79
+ temperature: float = 0.0 # 0.0 = greedy argmax at slot 0
80
+ max_new_tokens: int = 64
81
+
82
+
83
+ @dataclass
84
+ class DataConfig:
85
+ dataset: str = "synthetic" # "synthetic" or "tinystories"
86
+ task: str = "all" # "reverse", "path", "constraint", "all", "lm"
87
+ seq_len: int = 192
88
+ num_train: int = 24000
89
+ num_val: int = 1024
90
+ data_dir: str = "data/synthetic"
91
+ vocab_size: int = 128
92
+
93
+
94
+ @dataclass
95
+ class ConveyerConfig:
96
+ model: ModelConfig = field(default_factory=ModelConfig)
97
+ train: TrainConfig = field(default_factory=TrainConfig)
98
+ infer: InferConfig = field(default_factory=InferConfig)
99
+ data: DataConfig = field(default_factory=DataConfig)
100
+
101
+ def to_dict(self) -> dict[str, Any]:
102
+ return asdict(self)
103
+
104
+
105
+ def _parse_scalar(val_str: str) -> Any:
106
+ s = val_str.strip()
107
+ if not s:
108
+ return ""
109
+ if (s.startswith('"') and s.endswith('"')) or (s.startswith("'") and s.endswith("'")):
110
+ return s[1:-1]
111
+ low = s.lower()
112
+ if low == "true":
113
+ return True
114
+ if low == "false":
115
+ return False
116
+ if low in ("null", "none"):
117
+ return None
118
+ try:
119
+ if "." in s or "e" in low:
120
+ return float(s)
121
+ return int(s)
122
+ except ValueError:
123
+ return s
124
+
125
+
126
+ def _simple_yaml_parse(text: str) -> dict[str, Any]:
127
+ """Minimal dependency-free YAML parser for 2-level config files."""
128
+ if yaml is not None:
129
+ return yaml.safe_load(text) or {}
130
+ text_stripped = text.strip()
131
+ if text_stripped.startswith("{"):
132
+ return json.loads(text_stripped)
133
+ root: dict[str, Any] = {}
134
+ current_section: dict[str, Any] | None = None
135
+ for raw_line in text.splitlines():
136
+ line = raw_line.split("#", 1)[0].rstrip()
137
+ if not line.strip():
138
+ continue
139
+ indent = len(line) - len(line.lstrip(" "))
140
+ stripped = line.strip()
141
+ if ":" not in stripped:
142
+ continue
143
+ key, val_part = stripped.split(":", 1)
144
+ key = key.strip()
145
+ val_part = val_part.strip()
146
+ if indent == 0:
147
+ if val_part == "":
148
+ current_section = {}
149
+ root[key] = current_section
150
+ else:
151
+ root[key] = _parse_scalar(val_part)
152
+ current_section = None
153
+ else:
154
+ if current_section is None:
155
+ raise ValueError(f"Unexpected indentation in YAML line: {raw_line}")
156
+ current_section[key] = _parse_scalar(val_part)
157
+ return root
158
+
159
+
160
+ def _merge_dataclass(dc_instance: Any, updates: dict[str, Any]) -> None:
161
+ valid_fields = {f.name: f for f in fields(dc_instance)}
162
+ for k, v in updates.items():
163
+ if k not in valid_fields:
164
+ continue
165
+ current_val = getattr(dc_instance, k)
166
+ if is_dataclass(current_val) and isinstance(v, dict):
167
+ _merge_dataclass(current_val, v)
168
+ else:
169
+ setattr(dc_instance, k, v)
170
+
171
+
172
+ def _cast_value(raw: str, target_type: Any) -> Any:
173
+ if target_type is bool or raw.lower() in ("true", "false"):
174
+ return raw.lower() in ("true", "1", "yes")
175
+ val = _parse_scalar(raw)
176
+ if target_type is float and isinstance(val, int):
177
+ return float(val)
178
+ if target_type is int and isinstance(val, float):
179
+ return int(val)
180
+ if target_type is str:
181
+ return str(val)
182
+ return val
183
+
184
+
185
+ def apply_overrides(cfg: ConveyerConfig, overrides: list[str]) -> ConveyerConfig:
186
+ for item in overrides:
187
+ if "=" not in item:
188
+ raise ValueError(f"Override must be in key=value format, got: {item}")
189
+ key_path, raw_val = item.split("=", 1)
190
+ parts = key_path.strip().split(".")
191
+ obj: Any = cfg
192
+ for p in parts[:-1]:
193
+ if not hasattr(obj, p):
194
+ raise KeyError(f"Unknown config section '{p}' in '{key_path}'")
195
+ obj = getattr(obj, p)
196
+ leaf = parts[-1]
197
+ if not hasattr(obj, leaf):
198
+ raise KeyError(f"Unknown config key '{leaf}' in '{key_path}'")
199
+ cur = getattr(obj, leaf)
200
+ val = _cast_value(raw_val.strip(), type(cur))
201
+ setattr(obj, leaf, val)
202
+ _sync_config(cfg)
203
+ return cfg
204
+
205
+
206
+ def _sync_config(cfg: ConveyerConfig) -> None:
207
+ cfg.model.vocab_size = cfg.data.vocab_size
208
+ if cfg.train.no_thought_highway:
209
+ cfg.model.use_thought_highway = False
210
+ if cfg.train.ar_baseline:
211
+ cfg.model.causal_belt_only = True
212
+
213
+
214
+ def load_config(path: str | Path | None = None, overrides: list[str] | None = None) -> ConveyerConfig:
215
+ cfg = ConveyerConfig()
216
+ if path is not None:
217
+ raw = _simple_yaml_parse(Path(path).read_text(encoding="utf-8"))
218
+ _merge_dataclass(cfg, raw)
219
+ if overrides:
220
+ apply_overrides(cfg, overrides)
221
+ _sync_config(cfg)
222
+ return cfg
src/conveyer/data/__init__.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Protocol
4
+ import torch
5
+ from torch.utils.data import Dataset
6
+
7
+ from conveyer.config import ConveyerConfig
8
+
9
+
10
+ class TokenizerProtocol(Protocol):
11
+ vocab_size: int
12
+ pad_id: int
13
+ bos_id: int
14
+ eos_id: int
15
+
16
+ def encode(self, text: str) -> list[int]: ...
17
+ def decode(self, ids: list[int]) -> str: ...
18
+ def id_to_piece(self, token_id: int) -> str: ...
19
+
20
+
21
+ def build_dataset_and_tokenizer(
22
+ cfg: ConveyerConfig,
23
+ infer_only: bool = False,
24
+ ) -> tuple[Dataset[dict[str, Any]], Dataset[dict[str, Any]], TokenizerProtocol]:
25
+ if cfg.data.dataset == "synthetic":
26
+ from conveyer.data.synthetic import build_synthetic_datasets
27
+
28
+ return build_synthetic_datasets(cfg)
29
+ elif cfg.data.dataset == "tinystories":
30
+ from conveyer.data.tinystories import build_tinystories_datasets
31
+
32
+ return build_tinystories_datasets(cfg, infer_only=infer_only)
33
+ else:
34
+ raise ValueError(f"Unknown dataset '{cfg.data.dataset}'. Choose 'synthetic' or 'tinystories'.")
35
+
36
+
37
+ def collate_conveyer_batch(batch: list[dict[str, Any]], pad_id: int = 0) -> dict[str, torch.Tensor]:
38
+ max_len = max(item["input_ids"].size(0) for item in batch)
39
+ bsz = len(batch)
40
+ input_ids = torch.full((bsz, max_len), pad_id, dtype=torch.long)
41
+ prompt_lengths = torch.zeros(bsz, dtype=torch.long)
42
+ task_ids = torch.zeros(bsz, dtype=torch.long)
43
+
44
+ for i, item in enumerate(batch):
45
+ seq = item["input_ids"]
46
+ input_ids[i, : seq.size(0)] = seq
47
+ prompt_lengths[i] = int(item.get("prompt_len", 1))
48
+ task_ids[i] = int(item.get("task_id", 0))
49
+
50
+ return {
51
+ "input_ids": input_ids,
52
+ "prompt_lengths": prompt_lengths,
53
+ "task_ids": task_ids,
54
+ }
src/conveyer/data/synthetic.py ADDED
@@ -0,0 +1,276 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+ import numpy as np
5
+ import torch
6
+ from torch.utils.data import Dataset
7
+
8
+ from conveyer.config import ConveyerConfig
9
+
10
+
11
+ class SyntheticTokenizer:
12
+ """
13
+ Compact deterministic symbolic + word-piece vocabulary (128 tokens) for synthetic lookahead planning tasks.
14
+ """
15
+
16
+ def __init__(self, vocab_size: int = 128) -> None:
17
+ self.vocab_size = vocab_size
18
+ self.pad_id = 0
19
+ self.bos_id = 1
20
+ self.eos_id = 2
21
+ self.sep_id = 3 # '=>' separator between prompt and output
22
+
23
+ special = ["<PAD>", "<BOS>", "<EOS>", "=>", "|", ":", ",", ";", "(", ")", "[", "]", "->", "+", "=", "?"]
24
+ task_tags = ["[PATH]", "[REV]", "[PLAN]", "[STORY]", "START", "GOAL", "EDGES", "SEQ", "SUM", "END", "STEP", "OK"]
25
+ nodes = [f"N{i}" for i in range(32)]
26
+ numbers = [f"{i}" for i in range(32)]
27
+ words = [
28
+ "the", "little", "robot", "walked", "across", "bridge", "to", "find", "golden", "key",
29
+ "locked", "door", "opened", "quietly", "inside", "shiny", "crystal", "glowed", "bright", "blue",
30
+ "cat", "chased", "mouse", "under", "wooden", "table", "until", "both", "fell", "asleep",
31
+ "wise", "owl", "watched", "stars", "shine", "above", "dark", "forest",
32
+ ]
33
+ all_pieces = special + task_tags + nodes + numbers + words
34
+ while len(all_pieces) < vocab_size:
35
+ all_pieces.append(f"TOK_{len(all_pieces)}")
36
+ self.pieces = all_pieces[:vocab_size]
37
+ self.piece_to_id = {p: i for i, p in enumerate(self.pieces)}
38
+
39
+ def id_to_piece(self, token_id: int) -> str:
40
+ if 0 <= token_id < len(self.pieces):
41
+ return self.pieces[token_id]
42
+ return f"<{token_id}>"
43
+
44
+ def encode(self, text: str) -> list[int]:
45
+ tokens: list[int] = [self.bos_id]
46
+ for raw in text.strip().split():
47
+ if raw in self.piece_to_id:
48
+ tokens.append(self.piece_to_id[raw])
49
+ else:
50
+ # Fallback hash into word range
51
+ idx = 64 + (hash(raw) % max(1, self.vocab_size - 64))
52
+ tokens.append(idx)
53
+ return tokens
54
+
55
+ def decode(self, ids: list[int]) -> str:
56
+ out: list[str] = []
57
+ for idx in ids:
58
+ if idx in (self.pad_id, self.bos_id):
59
+ continue
60
+ if idx == self.eos_id:
61
+ break
62
+ out.append(self.id_to_piece(idx))
63
+ return " ".join(out)
64
+
65
+
66
+ def _make_path_planning_sample(
67
+ rng: np.random.Generator,
68
+ tok: SyntheticTokenizer,
69
+ seq_len: int,
70
+ ) -> tuple[torch.Tensor, int, int]:
71
+ """
72
+ Task 0 (Lookahead Graph Pathfinding - `[PATH]`):
73
+ A fork graph where START branches into two paths of length 6:
74
+ - One branch leads to a DEAD_END.
75
+ - The other branch leads to the GOAL node.
76
+ The edges are listed in random order, and the target trajectory is:
77
+ `=> N_start N_step1 N_step2 N_step3 N_step4 N_step5 N_goal <EOS> ...`
78
+ Crucially, we also require the model to emit the GOAL-conditioned path in reverse-verified
79
+ or lookahead form where choosing `N_step1` at Position 0 requires chaining backwards from `GOAL`
80
+ across the 6-hop chain. On the 64-slot conveyor belt, `N_goal` at the end of the path is anchored
81
+ while `N_step1 .. N_step5` crystallize simultaneously!
82
+ """
83
+ node_pool = rng.choice(32, size=12, replace=False)
84
+ start_node = int(node_pool[0])
85
+ good_chain = [int(x) for x in node_pool[1:6]] # 5 intermediate hops
86
+ goal_node = int(node_pool[6])
87
+ bad_chain = [int(x) for x in node_pool[7:12]] # 5 dead-end hops
88
+
89
+ edges: list[tuple[int, int]] = []
90
+ # Branch A (reaches goal_node)
91
+ edges.append((start_node, good_chain[0]))
92
+ for i in range(len(good_chain) - 1):
93
+ edges.append((good_chain[i], good_chain[i + 1]))
94
+ edges.append((good_chain[-1], goal_node))
95
+
96
+ # Branch B (dead-end branch from start_node)
97
+ edges.append((start_node, bad_chain[0]))
98
+ for i in range(len(bad_chain) - 1):
99
+ edges.append((bad_chain[i], bad_chain[i + 1]))
100
+
101
+ # Shuffle edge order so local greedy heuristics fail unless the model traces backward from GOAL
102
+ perm = rng.permutation(len(edges))
103
+ shuffled_edges = [edges[i] for i in perm]
104
+
105
+ prompt_pieces = ["<BOS>", "[PATH]", "START", f"N{start_node}", "GOAL", f"N{goal_node}", "EDGES"]
106
+ for u, v in shuffled_edges:
107
+ prompt_pieces.extend([f"N{u}", "->", f"N{v}", ";"])
108
+ prompt_pieces.append("=>")
109
+
110
+ full_path = [start_node] + good_chain + [goal_node]
111
+ # Emit the path twice separated by '|' and ending with GOAL confirmation so the full 64-slot belt is active
112
+ target_pieces: list[str] = []
113
+ for n in full_path:
114
+ target_pieces.append(f"N{n}")
115
+ target_pieces.append("|")
116
+ for n in reversed(full_path):
117
+ target_pieces.append(f"N{n}")
118
+ target_pieces.extend(["OK", f"N{goal_node}", "<EOS>"])
119
+
120
+ prompt_ids = [tok.piece_to_id[p] for p in prompt_pieces]
121
+ target_ids = [tok.piece_to_id[p] for p in target_pieces]
122
+ full_ids = prompt_ids + target_ids
123
+
124
+ out = torch.full((seq_len,), tok.pad_id, dtype=torch.long)
125
+ L = min(seq_len, len(full_ids))
126
+ out[:L] = torch.tensor(full_ids[:L], dtype=torch.long)
127
+ return out, len(prompt_ids), 0
128
+
129
+
130
+ def _make_reverse_plan_sample(
131
+ rng: np.random.Generator,
132
+ tok: SyntheticTokenizer,
133
+ seq_len: int,
134
+ ) -> tuple[torch.Tensor, int, int]:
135
+ """
136
+ Task 1 (Delayed-Constraint / Reverse Block Planning - `[REV]`):
137
+ The prompt gives a sequence of 16 numbers `a_0 .. a_15`.
138
+ The output must emit pairs `(a_i + a_{15-i}) mod 32` followed by the reversed sequence `a_15 .. a_0`.
139
+ """
140
+ length = 12
141
+ nums = [int(x) for x in rng.integers(0, 16, size=length)]
142
+ prompt_pieces = ["<BOS>", "[REV]", "SEQ"] + [f"{x}" for x in nums] + ["=>"]
143
+
144
+ sums = [(nums[i] + nums[length - 1 - i]) % 32 for i in range(length)]
145
+ rev = list(reversed(nums))
146
+ target_pieces = (
147
+ ["SUM"]
148
+ + [f"{s}" for s in sums]
149
+ + ["|"]
150
+ + [f"{r}" for r in rev]
151
+ + ["OK", "<EOS>"]
152
+ )
153
+
154
+ prompt_ids = [tok.piece_to_id[p] for p in prompt_pieces]
155
+ target_ids = [tok.piece_to_id[p] for p in target_pieces]
156
+ full_ids = prompt_ids + target_ids
157
+
158
+ out = torch.full((seq_len,), tok.pad_id, dtype=torch.long)
159
+ L = min(seq_len, len(full_ids))
160
+ out[:L] = torch.tensor(full_ids[:L], dtype=torch.long)
161
+ return out, len(prompt_ids), 1
162
+
163
+
164
+ def _make_target_anchored_chain_sample(
165
+ rng: np.random.Generator,
166
+ tok: SyntheticTokenizer,
167
+ seq_len: int,
168
+ ) -> tuple[torch.Tensor, int, int]:
169
+ """
170
+ Task 2 (Target-Anchored Arithmetic / Story Planning - `[PLAN]`):
171
+ Prompt specifies a target final value `END = T` and a sequence of deltas `d_1 .. d_10`.
172
+ The model must choose the unique initial value `x_0` at Position 0 such that after applying
173
+ `x_{k} = (x_{k-1} + d_k) mod 32` for 10 steps, the final token at Position 10 equals `T`!
174
+ Then it emits the full trajectory `x_0, x_1, ..., x_10 = T`.
175
+ """
176
+ steps = 10
177
+ deltas = [int(x) for x in rng.integers(1, 8, size=steps)]
178
+ target_end = int(rng.integers(0, 32))
179
+ total_delta = sum(deltas) % 32
180
+ x0 = (target_end - total_delta) % 32
181
+
182
+ traj = [x0]
183
+ for d in deltas:
184
+ traj.append((traj[-1] + d) % 32)
185
+ assert traj[-1] == target_end
186
+
187
+ prompt_pieces = (
188
+ ["<BOS>", "[PLAN]", "END", f"{target_end}", "STEP"]
189
+ + [f"+ {d}" for d in deltas]
190
+ # flatten "+ d" into two tokens
191
+ )
192
+ flat_prompt: list[str] = ["<BOS>", "[PLAN]", "END", f"{target_end}", "STEP"]
193
+ for d in deltas:
194
+ flat_prompt.extend(["+", f"{d}"])
195
+ flat_prompt.append("=>")
196
+
197
+ target_pieces = [f"{val}" for val in traj] + ["=", f"{target_end}", "OK", "<EOS>"]
198
+
199
+ prompt_ids = [tok.piece_to_id[p] for p in flat_prompt]
200
+ target_ids = [tok.piece_to_id[p] for p in target_pieces]
201
+ full_ids = prompt_ids + target_ids
202
+
203
+ out = torch.full((seq_len,), tok.pad_id, dtype=torch.long)
204
+ L = min(seq_len, len(full_ids))
205
+ out[:L] = torch.tensor(full_ids[:L], dtype=torch.long)
206
+ return out, len(prompt_ids), 2
207
+
208
+
209
+ class SyntheticLookaheadDataset(Dataset[dict[str, Any]]):
210
+ def __init__(
211
+ self,
212
+ num_samples: int,
213
+ seq_len: int,
214
+ task: str = "all",
215
+ vocab_size: int = 128,
216
+ seed: int = 42,
217
+ ) -> None:
218
+ super().__init__()
219
+ self.num_samples = num_samples
220
+ self.seq_len = seq_len
221
+ self.task = task
222
+ self.tokenizer = SyntheticTokenizer(vocab_size=vocab_size)
223
+
224
+ rng = np.random.default_rng(seed)
225
+ self.samples: list[dict[str, Any]] = []
226
+
227
+ for i in range(num_samples):
228
+ if task == "path":
229
+ t_choice = 0
230
+ elif task == "reverse":
231
+ t_choice = 1
232
+ elif task == "constraint":
233
+ t_choice = 2
234
+ else:
235
+ t_choice = i % 3
236
+
237
+ if t_choice == 0:
238
+ seq, plen, tid = _make_path_planning_sample(rng, self.tokenizer, seq_len)
239
+ elif t_choice == 1:
240
+ seq, plen, tid = _make_reverse_plan_sample(rng, self.tokenizer, seq_len)
241
+ else:
242
+ seq, plen, tid = _make_target_anchored_chain_sample(rng, self.tokenizer, seq_len)
243
+
244
+ self.samples.append(
245
+ {
246
+ "input_ids": seq,
247
+ "prompt_len": plen,
248
+ "task_id": tid,
249
+ }
250
+ )
251
+
252
+ def __len__(self) -> int:
253
+ return len(self.samples)
254
+
255
+ def __getitem__(self, idx: int) -> dict[str, Any]:
256
+ return self.samples[idx]
257
+
258
+
259
+ def build_synthetic_datasets(
260
+ cfg: ConveyerConfig,
261
+ ) -> tuple[SyntheticLookaheadDataset, SyntheticLookaheadDataset, SyntheticTokenizer]:
262
+ train_ds = SyntheticLookaheadDataset(
263
+ num_samples=cfg.data.num_train,
264
+ seq_len=cfg.data.seq_len,
265
+ task=cfg.data.task,
266
+ vocab_size=cfg.data.vocab_size,
267
+ seed=cfg.train.seed,
268
+ )
269
+ val_ds = SyntheticLookaheadDataset(
270
+ num_samples=cfg.data.num_val,
271
+ seq_len=cfg.data.seq_len,
272
+ task=cfg.data.task,
273
+ vocab_size=cfg.data.vocab_size,
274
+ seed=cfg.train.seed + 10000,
275
+ )
276
+ return train_ds, val_ds, train_ds.tokenizer
src/conveyer/data/tinystories.py ADDED
@@ -0,0 +1,533 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from collections import Counter
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+ import time
8
+ from typing import Any, Iterator, Optional
9
+ import urllib.request
10
+ import numpy as np
11
+ import tiktoken
12
+ import torch
13
+ from torch.utils.data import Dataset
14
+
15
+ from conveyer.config import ConveyerConfig
16
+
17
+ TINYSTORIES_VALID_URL = (
18
+ "https://huggingface.co/datasets/roneneldan/TinyStories/resolve/main/TinyStoriesV2-GPT4-valid.txt"
19
+ )
20
+ TINYSTORIES_TRAIN_URL = (
21
+ "https://huggingface.co/datasets/roneneldan/TinyStories/resolve/main/TinyStoriesV2-GPT4-train.txt"
22
+ )
23
+
24
+
25
+ class TiktokenCompactTokenizer:
26
+ """
27
+ Backed by OpenAI `tiktoken` (`gpt2` BPE encoding), mapped into a compact vocabulary
28
+ of size `vocab_size` (e.g. 4096) covering the most frequent real-TinyStories BPE subwords
29
+ plus all 256 single-byte BPE tokens so ANY arbitrary English string encodes with ZERO `<UNK>`.
30
+ """
31
+
32
+ def __init__(
33
+ self,
34
+ gpt2_to_compact: dict[int, int],
35
+ compact_to_gpt2: list[int],
36
+ source: str = "real_tinystories_v2",
37
+ ) -> None:
38
+ self.enc = tiktoken.get_encoding("gpt2")
39
+ self.gpt2_to_compact = gpt2_to_compact
40
+ self.compact_to_gpt2 = compact_to_gpt2
41
+ self.source = source
42
+ self.vocab_size = len(compact_to_gpt2)
43
+ self.pad_id = 0
44
+ self.bos_id = 1
45
+ self.eos_id = 2
46
+ self.unk_id = 3
47
+
48
+ # Prebuild byte-level fallback table so any rare BPE token decomposes into exact byte BPE tokens
49
+ self.byte_to_compact: dict[int, int] = {}
50
+ for b in range(256):
51
+ gid = self.enc.encode_single_token(bytes([b]))
52
+ if gid in self.gpt2_to_compact:
53
+ self.byte_to_compact[b] = self.gpt2_to_compact[gid]
54
+
55
+ # Prebuild fast array lookup for GPT-2 token IDs (0..50256) -> compact ID (-1 if rare)
56
+ self._lut = np.full(self.enc.n_vocab, -1, dtype=np.int32)
57
+ for gid, cid in self.gpt2_to_compact.items():
58
+ if 0 <= gid < self.enc.n_vocab:
59
+ self._lut[gid] = cid
60
+
61
+ @classmethod
62
+ def build_from_texts(cls, texts: list[str], vocab_size: int = 4096) -> "TiktokenCompactTokenizer":
63
+ enc = tiktoken.get_encoding("gpt2")
64
+ # Special tokens occupy 0..3: <PAD>=0, <BOS>=1, <EOS>=2, <UNK>=3
65
+ compact_to_gpt2: list[int] = [-1, -2, -3, -4]
66
+ seen_gpt2: set[int] = set()
67
+
68
+ # 1. Always include all 256 single-byte BPE tokens so 100% of UTF-8 strings are representable
69
+ for b in range(256):
70
+ gid = enc.encode_single_token(bytes([b]))
71
+ if gid not in seen_gpt2 and len(compact_to_gpt2) < vocab_size:
72
+ seen_gpt2.add(gid)
73
+ compact_to_gpt2.append(gid)
74
+
75
+ # 2. Count BPE token frequencies across real TinyStories
76
+ counts: Counter[int] = Counter()
77
+ sample = texts[:50000]
78
+ for batch_list in enc.encode_ordinary_batch(sample, num_threads=8):
79
+ counts.update(batch_list)
80
+
81
+ for gid, _ in counts.most_common():
82
+ if len(compact_to_gpt2) >= vocab_size:
83
+ break
84
+ if gid not in seen_gpt2:
85
+ seen_gpt2.add(gid)
86
+ compact_to_gpt2.append(gid)
87
+
88
+ # Pad up to vocab_size with sequential gpt2 token IDs if needed
89
+ next_gid = 0
90
+ while len(compact_to_gpt2) < vocab_size:
91
+ if next_gid not in seen_gpt2:
92
+ seen_gpt2.add(next_gid)
93
+ compact_to_gpt2.append(next_gid)
94
+ next_gid += 1
95
+
96
+ gpt2_to_compact = {gid: cid for cid, gid in enumerate(compact_to_gpt2) if gid >= 0}
97
+ return cls(
98
+ gpt2_to_compact=gpt2_to_compact,
99
+ compact_to_gpt2=compact_to_gpt2,
100
+ source="real_tinystories_v2",
101
+ )
102
+
103
+ def save(self, path: str | Path) -> None:
104
+ p = Path(path)
105
+ p.parent.mkdir(parents=True, exist_ok=True)
106
+ payload = {
107
+ "source": self.source,
108
+ "compact_to_gpt2": self.compact_to_gpt2,
109
+ }
110
+ p.write_text(json.dumps(payload), encoding="utf-8")
111
+
112
+ @classmethod
113
+ def load(cls, path: str | Path) -> "TiktokenCompactTokenizer":
114
+ data = json.loads(Path(path).read_text(encoding="utf-8"))
115
+ compact_to_gpt2: list[int] = data["compact_to_gpt2"]
116
+ source: str = data.get("source", "legacy")
117
+ gpt2_to_compact = {gid: cid for cid, gid in enumerate(compact_to_gpt2) if gid >= 0}
118
+ return cls(gpt2_to_compact=gpt2_to_compact, compact_to_gpt2=compact_to_gpt2, source=source)
119
+
120
+ def id_to_piece(self, token_id: int) -> str:
121
+ if token_id == self.pad_id:
122
+ return "<PAD>"
123
+ if token_id == self.bos_id:
124
+ return "<BOS>"
125
+ if token_id == self.eos_id:
126
+ return "<EOS>"
127
+ if token_id == self.unk_id or not (0 <= token_id < len(self.compact_to_gpt2)):
128
+ return "<UNK>"
129
+ gid = self.compact_to_gpt2[token_id]
130
+ if gid < 0:
131
+ return "<SPEC>"
132
+ raw = self.enc.decode_single_token_bytes(gid).decode("utf-8", errors="replace")
133
+ return raw
134
+
135
+ def _map_gids_to_compact(self, gids: list[int], max_tokens: Optional[int] = None) -> list[int]:
136
+ ids: list[int] = [self.bos_id]
137
+ gpt2_map = self.gpt2_to_compact
138
+ byte_map = self.byte_to_compact
139
+ dec_single = self.enc.decode_single_token_bytes
140
+ unk = self.unk_id
141
+ for gid in gids:
142
+ cid = gpt2_map.get(gid)
143
+ if cid is not None:
144
+ ids.append(cid)
145
+ else:
146
+ for b in dec_single(gid):
147
+ ids.append(byte_map.get(b, unk))
148
+ if max_tokens is not None and len(ids) >= max_tokens:
149
+ break
150
+ return ids
151
+
152
+ def encode(self, text: str) -> list[int]:
153
+ gids = self.enc.encode_ordinary(text)
154
+ return self._map_gids_to_compact(gids)
155
+
156
+ def encode_batch_into_matrix(
157
+ self,
158
+ texts: list[str],
159
+ out_matrix: np.ndarray,
160
+ out_lens: np.ndarray,
161
+ start_row: int,
162
+ seq_len: int,
163
+ ) -> int:
164
+ """
165
+ Multi-threaded Rust BPE batch encoding into `out_matrix[start_row : start_row + written]` (uint16).
166
+ Truncates input text to ~1400 chars before BPE since we only need `seq_len` (e.g. 160) tokens.
167
+ """
168
+ char_cap = max(seq_len * 8, 1024)
169
+ clipped = [t[:char_cap] if len(t) > char_cap else t for t in texts]
170
+ batch_gids = self.enc.encode_ordinary_batch(clipped, num_threads=8)
171
+
172
+ row = start_row
173
+ max_rows = out_matrix.shape[0]
174
+ eos = self.eos_id
175
+ pad = self.pad_id
176
+ max_body = seq_len - 1
177
+
178
+ for gids in batch_gids:
179
+ if row >= max_rows:
180
+ break
181
+ ids = self._map_gids_to_compact(gids, max_tokens=max_body)
182
+ if len(ids) < 16:
183
+ continue
184
+ if len(ids) < seq_len:
185
+ ids.append(eos)
186
+ L = min(seq_len, len(ids))
187
+ out_matrix[row, :L] = ids[:L]
188
+ if L < seq_len:
189
+ out_matrix[row, L:] = pad
190
+ out_lens[row] = L
191
+ row += 1
192
+ return row - start_row
193
+
194
+ def decode(self, ids: list[int]) -> str:
195
+ gids: list[int] = []
196
+ for cid in ids:
197
+ if cid in (self.pad_id, self.bos_id, self.unk_id):
198
+ continue
199
+ if cid == self.eos_id:
200
+ break
201
+ if 0 <= cid < len(self.compact_to_gpt2):
202
+ gid = self.compact_to_gpt2[cid]
203
+ if gid >= 0:
204
+ gids.append(gid)
205
+ return self.enc.decode(gids)
206
+
207
+
208
+ def _download_with_resume(url: str, dest: Path, min_expected_bytes: int = 1_000_000) -> Path:
209
+ dest.parent.mkdir(parents=True, exist_ok=True)
210
+ existing = dest.stat().st_size if dest.exists() else 0
211
+ total_size = 0
212
+ try:
213
+ req_head = urllib.request.Request(url, method="HEAD", headers={"User-Agent": "Mozilla/5.0"})
214
+ with urllib.request.urlopen(req_head, timeout=30) as r:
215
+ total_size = int(r.headers.get("Content-Length", 0))
216
+ except Exception:
217
+ total_size = 0
218
+
219
+ if total_size > 0 and existing >= total_size:
220
+ return dest
221
+ if total_size == 0 and existing >= min_expected_bytes:
222
+ return dest
223
+
224
+ headers = {"User-Agent": "Mozilla/5.0"}
225
+ mode = "wb"
226
+ if existing > 0 and total_size > existing:
227
+ headers["Range"] = f"bytes={existing}-"
228
+ mode = "ab"
229
+ else:
230
+ existing = 0
231
+
232
+ print(
233
+ f"[TinyStories] Downloading {dest.name} "
234
+ f"({existing / (1024 * 1024):.1f} / {total_size / (1024 * 1024):.1f} MB)...",
235
+ flush=True,
236
+ )
237
+ req = urllib.request.Request(url, headers=headers)
238
+ t0 = time.time()
239
+ last_log = t0
240
+ downloaded = existing
241
+ with urllib.request.urlopen(req, timeout=120) as r, open(dest, mode) as f:
242
+ while True:
243
+ chunk = r.read(4 * 1024 * 1024)
244
+ if not chunk:
245
+ break
246
+ f.write(chunk)
247
+ downloaded += len(chunk)
248
+ now = time.time()
249
+ if now - last_log >= 5.0:
250
+ dt = now - t0
251
+ speed = (downloaded - existing) / (1024 * 1024) / max(dt, 1e-3)
252
+ pct = 100.0 * downloaded / max(total_size, 1)
253
+ print(
254
+ f" [{dest.name}] {downloaded / (1024 * 1024):.1f} / "
255
+ f"{total_size / (1024 * 1024):.1f} MB ({pct:.1f}%) at {speed:.1f} MB/s",
256
+ flush=True,
257
+ )
258
+ last_log = now
259
+ return dest
260
+
261
+
262
+ def iter_clean_real_stories(txt_path: Path, max_stories: Optional[int] = None) -> Iterator[str]:
263
+ """
264
+ Streams clean real TinyStories from a `<|endoftext|>`-separated raw text file without
265
+ loading the entire 2.12 GB string list into memory at once.
266
+ Filters out byte-0 mid-sentence fragments and any synthetic templates.
267
+ """
268
+ yielded = 0
269
+ buf = ""
270
+ with open(txt_path, "r", encoding="utf-8", errors="ignore") as f:
271
+ while True:
272
+ chunk = f.read(16 * 1024 * 1024)
273
+ if not chunk:
274
+ break
275
+ buf += chunk
276
+ parts = buf.split("<|endoftext|>")
277
+ buf = parts[-1]
278
+ for raw in parts[:-1]:
279
+ s = " ".join(raw.strip().split())
280
+ if len(s) < 80:
281
+ continue
282
+ if not (s[0].isupper() or s[0] in "\"'"):
283
+ continue
284
+ if "walked into the " in s and "Together, they" in s:
285
+ continue
286
+ yield s
287
+ yielded += 1
288
+ if max_stories is not None and yielded >= max_stories:
289
+ return
290
+ s = " ".join(buf.strip().split())
291
+ if len(s) >= 80 and (s[0].isupper() or s[0] in "\"'"):
292
+ if not ("walked into the " in s and "Together, they" in s):
293
+ if max_stories is None or yielded < max_stories:
294
+ yield s
295
+
296
+
297
+ def ensure_raw_tinystories_files(data_dir: str | Path, need_full_train: bool = True) -> tuple[Path, Path]:
298
+ root = Path(data_dir)
299
+ root.mkdir(parents=True, exist_ok=True)
300
+ val_path = _download_with_resume(TINYSTORIES_VALID_URL, root / "TinyStoriesV2-GPT4-valid.txt", min_expected_bytes=20_000_000)
301
+ train_path = root / "TinyStoriesV2-GPT4-train.txt"
302
+ if need_full_train or not train_path.exists():
303
+ _download_with_resume(TINYSTORIES_TRAIN_URL, train_path, min_expected_bytes=2_000_000_000)
304
+ return train_path, val_path
305
+
306
+
307
+ def load_or_prepare_tinystories(data_dir: str | Path, max_stories: int = 42000) -> list[str]:
308
+ """
309
+ Returns up to `max_stories` 100% REAL TinyStories strings (zero templates).
310
+ """
311
+ root = Path(data_dir)
312
+ train_path, val_path = ensure_raw_tinystories_files(root, need_full_train=(max_stories > 25000))
313
+ source_path = train_path if train_path.exists() and train_path.stat().st_size > 20_000_000 else val_path
314
+ stories = list(iter_clean_real_stories(source_path, max_stories=max_stories))
315
+ return stories
316
+
317
+
318
+ def _build_or_load_memmap_split(
319
+ split_name: str,
320
+ raw_txt_path: Path,
321
+ tokenizer: TiktokenCompactTokenizer,
322
+ data_dir: Path,
323
+ target_stories: int,
324
+ seq_len: int,
325
+ ) -> tuple[np.memmap, np.ndarray]:
326
+ """
327
+ Pre-tokenizes `target_stories` real TinyStories from `raw_txt_path` into a compact disk-backed
328
+ `uint16` numpy memmap of shape `(N, seq_len)` + `uint16` lengths array `(N,)`.
329
+ Loading an existing memmap takes < 10ms and uses near-zero RAM.
330
+ """
331
+ prefix = data_dir / f"real_{split_name}_v5_n{target_stories}_s{seq_len}_v{tokenizer.vocab_size}"
332
+ tokens_bin = Path(str(prefix) + ".tokens.bin")
333
+ lens_bin = Path(str(prefix) + ".lens.bin")
334
+ meta_json = Path(str(prefix) + ".meta.json")
335
+
336
+ if tokens_bin.exists() and lens_bin.exists() and meta_json.exists():
337
+ meta = json.loads(meta_json.read_text(encoding="utf-8"))
338
+ n_rows = int(meta["num_rows"])
339
+ if n_rows > 0 and tokens_bin.stat().st_size == n_rows * seq_len * 2:
340
+ mm = np.memmap(tokens_bin, dtype=np.uint16, mode="r", shape=(n_rows, seq_len))
341
+ lens = np.fromfile(lens_bin, dtype=np.uint16)
342
+ return mm, lens
343
+
344
+ print(
345
+ f"[TinyStories] Building binary token cache for '{split_name}' "
346
+ f"(target={target_stories:,} stories, seq_len={seq_len})...",
347
+ flush=True,
348
+ )
349
+ tmp_tokens = Path(str(tokens_bin) + ".tmp")
350
+ tmp_lens = Path(str(lens_bin) + ".tmp")
351
+
352
+ mm_write = np.memmap(tmp_tokens, dtype=np.uint16, mode="w+", shape=(target_stories, seq_len))
353
+ lens_arr = np.zeros(target_stories, dtype=np.uint16)
354
+
355
+ t0 = time.time()
356
+ last_log = t0
357
+ row = 0
358
+ batch_texts: list[str] = []
359
+ batch_chunk_size = 50000
360
+
361
+ for story in iter_clean_real_stories(raw_txt_path, max_stories=int(target_stories * 1.05) + 2000):
362
+ batch_texts.append(story)
363
+ if len(batch_texts) >= batch_chunk_size:
364
+ added = tokenizer.encode_batch_into_matrix(batch_texts, mm_write, lens_arr, row, seq_len)
365
+ row += added
366
+ batch_texts.clear()
367
+ now = time.time()
368
+ if now - last_log >= 4.0:
369
+ dt = now - t0
370
+ rate = row / max(dt, 1e-3)
371
+ print(
372
+ f" [{split_name}] Tokenized {row:,} / {target_stories:,} stories "
373
+ f"({100.0 * row / max(target_stories, 1):.1f}%) at {rate:,.0f} stories/s",
374
+ flush=True,
375
+ )
376
+ last_log = now
377
+ if row >= target_stories:
378
+ break
379
+
380
+ if batch_texts and row < target_stories:
381
+ added = tokenizer.encode_batch_into_matrix(batch_texts, mm_write, lens_arr, row, seq_len)
382
+ row += added
383
+ batch_texts.clear()
384
+
385
+ mm_write.flush()
386
+ del mm_write
387
+
388
+ # If actual valid stories in file < target_stories, truncate file to exact `row * seq_len * 2` bytes
389
+ if row < target_stories:
390
+ with open(tmp_tokens, "r+b") as f:
391
+ f.truncate(row * seq_len * 2)
392
+ lens_arr = lens_arr[:row]
393
+
394
+ lens_arr.tofile(tmp_lens)
395
+ os.replace(tmp_tokens, tokens_bin)
396
+ os.replace(tmp_lens, lens_bin)
397
+ meta_json.write_text(
398
+ json.dumps({"num_rows": int(row), "seq_len": int(seq_len), "vocab_size": int(tokenizer.vocab_size)}),
399
+ encoding="utf-8",
400
+ )
401
+ dt = time.time() - t0
402
+ print(f"[TinyStories] Saved '{split_name}' binary cache: {row:,} real stories in {dt:.1f}s", flush=True)
403
+
404
+ mm = np.memmap(tokens_bin, dtype=np.uint16, mode="r", shape=(row, seq_len))
405
+ return mm, lens_arr
406
+
407
+
408
+ class TinyStoriesDataset(Dataset[dict[str, Any]]):
409
+ def __init__(
410
+ self,
411
+ stories: Optional[list[str]] = None,
412
+ tokenizer: Optional[TiktokenCompactTokenizer] = None,
413
+ seq_len: int = 160,
414
+ prompt_len: int = 28,
415
+ randomize_prompt_len: bool = False,
416
+ token_matrix: Optional[np.ndarray] = None,
417
+ valid_lens: Optional[np.ndarray] = None,
418
+ ) -> None:
419
+ super().__init__()
420
+ self.tokenizer = tokenizer
421
+ self.seq_len = seq_len
422
+ self.prompt_len = prompt_len
423
+ self.randomize_prompt_len = randomize_prompt_len
424
+
425
+ if token_matrix is not None and valid_lens is not None:
426
+ self.token_matrix = token_matrix
427
+ self.valid_lens = valid_lens
428
+ else:
429
+ assert stories is not None and tokenizer is not None
430
+ n_cap = len(stories)
431
+ mat = np.full((n_cap, seq_len), tokenizer.pad_id, dtype=np.uint16)
432
+ lens = np.zeros(n_cap, dtype=np.uint16)
433
+ written = tokenizer.encode_batch_into_matrix(stories, mat, lens, 0, seq_len)
434
+ self.token_matrix = mat[:written]
435
+ self.valid_lens = lens[:written]
436
+
437
+ def __len__(self) -> int:
438
+ return int(self.token_matrix.shape[0])
439
+
440
+ def __getitem__(self, idx: int) -> dict[str, Any]:
441
+ row = torch.from_numpy(np.asarray(self.token_matrix[idx], dtype=np.int64))
442
+ L = int(self.valid_lens[idx])
443
+ if not self.randomize_prompt_len:
444
+ p_len = max(4, min(self.prompt_len, L - 8))
445
+ else:
446
+ low = 6
447
+ high = max(low + 1, min(64, L - 8))
448
+ p_len = int(torch.randint(low, high, (1,)).item())
449
+ return {
450
+ "input_ids": row,
451
+ "prompt_len": p_len,
452
+ "task_id": 0,
453
+ }
454
+
455
+
456
+ def build_tinystories_datasets(
457
+ cfg: ConveyerConfig,
458
+ infer_only: bool = False,
459
+ ) -> tuple[TinyStoriesDataset, TinyStoriesDataset, TiktokenCompactTokenizer]:
460
+ data_dir = Path(cfg.data.data_dir)
461
+ data_dir.mkdir(parents=True, exist_ok=True)
462
+ tok_path = data_dir / f"tiktoken_compact_{cfg.data.vocab_size}.json"
463
+ val_sample_path = data_dir / "val_sample_256.txt"
464
+
465
+ if infer_only and tok_path.exists():
466
+ tokenizer = TiktokenCompactTokenizer.load(tok_path)
467
+ cfg.data.vocab_size = tokenizer.vocab_size
468
+ cfg.model.vocab_size = tokenizer.vocab_size
469
+ if val_sample_path.exists():
470
+ sample_lines = [l.strip() for l in val_sample_path.read_text(encoding="utf-8").splitlines() if l.strip()]
471
+ else:
472
+ _, val_txt = ensure_raw_tinystories_files(data_dir, need_full_train=False)
473
+ sample_lines = list(iter_clean_real_stories(val_txt, max_stories=256))
474
+ val_ds = TinyStoriesDataset(
475
+ stories=sample_lines,
476
+ tokenizer=tokenizer,
477
+ seq_len=cfg.data.seq_len,
478
+ prompt_len=28,
479
+ randomize_prompt_len=False,
480
+ )
481
+ return val_ds, val_ds, tokenizer
482
+
483
+ train_txt, val_txt = ensure_raw_tinystories_files(data_dir, need_full_train=True)
484
+
485
+ tokenizer_opt: Optional[TiktokenCompactTokenizer] = None
486
+ if tok_path.exists():
487
+ loaded = TiktokenCompactTokenizer.load(tok_path)
488
+ if loaded.source == "real_tinystories_v2":
489
+ tokenizer_opt = loaded
490
+
491
+ if tokenizer_opt is None:
492
+ print("[TinyStories] Building compact 4096 BPE vocabulary from 50,000 real stories...", flush=True)
493
+ sample_stories = list(iter_clean_real_stories(train_txt, max_stories=50000))
494
+ tokenizer_opt = TiktokenCompactTokenizer.build_from_texts(sample_stories, vocab_size=cfg.data.vocab_size)
495
+ tokenizer_opt.save(tok_path)
496
+
497
+ tokenizer = tokenizer_opt
498
+ cfg.data.vocab_size = tokenizer.vocab_size
499
+ cfg.model.vocab_size = tokenizer.vocab_size
500
+
501
+ train_mm, train_lens = _build_or_load_memmap_split(
502
+ split_name="train",
503
+ raw_txt_path=train_txt,
504
+ tokenizer=tokenizer,
505
+ data_dir=data_dir,
506
+ target_stories=cfg.data.num_train,
507
+ seq_len=cfg.data.seq_len,
508
+ )
509
+ val_mm, val_lens = _build_or_load_memmap_split(
510
+ split_name="val",
511
+ raw_txt_path=val_txt,
512
+ tokenizer=tokenizer,
513
+ data_dir=data_dir,
514
+ target_stories=cfg.data.num_val,
515
+ seq_len=cfg.data.seq_len,
516
+ )
517
+
518
+ train_ds = TinyStoriesDataset(
519
+ tokenizer=tokenizer,
520
+ seq_len=cfg.data.seq_len,
521
+ randomize_prompt_len=True,
522
+ token_matrix=train_mm,
523
+ valid_lens=train_lens,
524
+ )
525
+ val_ds = TinyStoriesDataset(
526
+ tokenizer=tokenizer,
527
+ seq_len=cfg.data.seq_len,
528
+ prompt_len=28,
529
+ randomize_prompt_len=False,
530
+ token_matrix=val_mm,
531
+ valid_lens=val_lens,
532
+ )
533
+ return train_ds, val_ds, tokenizer
src/conveyer/eval.py ADDED
@@ -0,0 +1,557 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ from collections import Counter
5
+ import json
6
+ import math
7
+ from pathlib import Path
8
+ from typing import Any
9
+ import torch
10
+ import torch.nn.functional as F
11
+ from torch.utils.data import DataLoader
12
+
13
+ from conveyer.checkpoint import load_checkpoint
14
+ from conveyer.config import ConveyerConfig
15
+ from conveyer.data import build_dataset_and_tokenizer, collate_conveyer_batch
16
+ from conveyer.infer import generate_conveyer
17
+ from conveyer.losses import compute_unrolled_conveyer_loss
18
+ from conveyer.model import ConveyerDiffusionLM
19
+
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # Benchmark 3: Zero-Shot Commonsense / Story-Coherence Multiple Choice (2-way)
23
+ # ---------------------------------------------------------------------------
24
+ # Model scores log P(option | context) on unseen story pairs testing:
25
+ # - Physical commonsense (heavy vs light, hot vs cold, broken vs whole)
26
+ # - Emotional / social cause-and-effect (lost toy -> sad, gift -> happy)
27
+ # - Narrative entity & object permanence (tracking who holds what, where they are)
28
+ COMMONSENSE_CLOZE_PAIRS: list[tuple[str, str, str, str]] = [
29
+ (
30
+ "emotion_cause",
31
+ "Once upon a time, Tom lost his favorite red ball in the dark woods. Because he lost his ball, Tom felt very",
32
+ " sad and started to cry.",
33
+ " happy and started to laugh.",
34
+ ),
35
+ (
36
+ "emotion_cause",
37
+ "Lily worked all morning to bake a sweet cake for her mom. When her mom smiled and hugged her, Lily felt very",
38
+ " proud and happy.",
39
+ " angry and mean.",
40
+ ),
41
+ (
42
+ "physical_weight",
43
+ "The little mouse tried to lift the huge heavy rock, but the rock was much too",
44
+ " heavy for the tiny mouse to move.",
45
+ " light and floated into the sky.",
46
+ ),
47
+ (
48
+ "physical_temperature",
49
+ "Ben went outside in the snow without his coat or gloves. Soon his hands felt very",
50
+ " cold and freezing.",
51
+ " hot and burning.",
52
+ ),
53
+ (
54
+ "physical_temperature",
55
+ "The soup had just come off the stove and was boiling. Mom told Mia to wait because the soup was too",
56
+ " hot to eat right now.",
57
+ " cold and frozen like ice.",
58
+ ),
59
+ (
60
+ "physical_fragile",
61
+ "The glass cup fell off the high table onto the hard stone floor. With a loud crash, the glass",
62
+ " broke into many small pieces.",
63
+ " bounced high like a rubber ball.",
64
+ ),
65
+ (
66
+ "object_use",
67
+ "The wooden door was locked tight. To unlock the door, Anna took out her small brass",
68
+ " key and turned it in the lock.",
69
+ " spoon and stirred the soup.",
70
+ ),
71
+ (
72
+ "object_use",
73
+ "It started to rain hard outside. Before walking to school, Max opened his big",
74
+ " umbrella to stay dry.",
75
+ " pillow to go to sleep.",
76
+ ),
77
+ (
78
+ "biological_need",
79
+ "The little puppy had been running in the park all day and had not eaten anything. His tummy rumbled because he was very",
80
+ " hungry and wanted some food.",
81
+ " sleepy and wanted a haircut.",
82
+ ),
83
+ (
84
+ "biological_need",
85
+ "After playing all day until night came and the stars shone in the sky, the tired children went to bed to",
86
+ " sleep until morning.",
87
+ " swim in the cold ocean.",
88
+ ),
89
+ (
90
+ "entity_tracking",
91
+ "Sam had a blue car and Lucy had a yellow doll. Sam gave his blue car to Lucy. Now Lucy had the yellow doll and the",
92
+ " blue car.",
93
+ " green bicycle.",
94
+ ),
95
+ (
96
+ "entity_tracking",
97
+ " Lily put her shiny coin inside the small red box and closed the lid. When she opened the red box later, she saw her",
98
+ " shiny coin inside.",
99
+ " big brown horse inside.",
100
+ ),
101
+ (
102
+ "danger_reaction",
103
+ "Lily and a little bunny walked into the green forest. Suddenly a big fierce lion jumped out and roared loudly. The little bunny was",
104
+ " scared and hid behind a tree.",
105
+ " bored and read a newspaper.",
106
+ ),
107
+ (
108
+ "social_repair",
109
+ "Tom accidentally knocked over Mia's tall block tower. Tom felt bad about the accident, so he said,",
110
+ ' "I am sorry, Mia. Let me help you build it again."',
111
+ ' "Thank you for the delicious birthday cake."',
112
+ ),
113
+ (
114
+ "spatial_logic",
115
+ "The bird wanted to reach the nest at the very top of the tall tree, so it flapped its wings and",
116
+ " flew up high into the branches.",
117
+ " dug a deep hole under the mud.",
118
+ ),
119
+ (
120
+ "weather_visibility",
121
+ "At midnight all the lights went out in the room. Because it was pitch dark, Ben could not",
122
+ " see anything around him.",
123
+ " hear the loud thunder outside.",
124
+ ),
125
+ ]
126
+
127
+
128
+ NOVEL_STORY_PROMPTS: list[tuple[str, list[str]]] = [
129
+ (
130
+ "Once upon a time, Lily and a little bunny walked into the green forest to find the golden key near the locked treasure chest. Suddenly a big lion",
131
+ ["lion", "Lily", "bunny", "key", "chest", "forest", "scared", "roar", "said"],
132
+ ),
133
+ (
134
+ "Once upon a time, there was a little robot named Beep who lived in a noisy workshop. One rainy morning, Beep found a tiny lost kitten by the door. Beep",
135
+ ["Beep", "kitten", "robot", "door", "rain", "warm", "milk", "help"],
136
+ ),
137
+ (
138
+ "Sam and his sister Mia built a huge sandcastle on the beach near the waves. Suddenly, a big wave splashed toward their castle. Sam quickly",
139
+ ["Sam", "Mia", "sand", "castle", "wave", "beach", "water", "wall"],
140
+ ),
141
+ (
142
+ "In a quiet village, an old baker dropped his round loaf of bread and it rolled out the door down the hill. A hungry puppy saw the bread and",
143
+ ["bread", "puppy", "baker", "hill", "chased", "ran", "ate", "mouth"],
144
+ ),
145
+ (
146
+ "Lucy had a box of bright crayons, but she could not find the blue crayon to color the sky. She looked under her bed and",
147
+ ["Lucy", "crayon", "blue", "bed", "sky", "found", "draw", "paper"],
148
+ ),
149
+ (
150
+ "One winter day, Max made a snowman in the yard with a carrot nose. When the warm sun came out in the afternoon, the snowman began to",
151
+ ["Max", "snowman", "sun", "melt", "carrot", "snow", "warm", "water"],
152
+ ),
153
+ ]
154
+
155
+
156
+ @torch.no_grad()
157
+ def evaluate_zero_shot_commonsense(
158
+ model: ConveyerDiffusionLM,
159
+ tokenizer: Any,
160
+ device: torch.device,
161
+ ) -> dict[str, Any]:
162
+ """
163
+ Evaluates zero-shot 2-way multiple-choice accuracy (BLiMP / StoryCloze style) using both:
164
+ 1. Causal LM log-probability per token over the continuation.
165
+ 2. Conveyor Slot-0 first-token preference on the continuation.
166
+ """
167
+ model.eval()
168
+ correct_lm = 0
169
+ correct_s0 = 0
170
+ margin_sum = 0.0
171
+ details: list[dict[str, Any]] = []
172
+
173
+ for category, ctx, good_cont, bad_cont in COMMONSENSE_CLOZE_PAIRS:
174
+ ctx_ids = tokenizer.encode(ctx.strip())
175
+ good_ids = tokenizer.encode(ctx.strip() + good_cont)
176
+ bad_ids = tokenizer.encode(ctx.strip() + bad_cont)
177
+ plen = len(ctx_ids)
178
+
179
+ def _score_seq(full_ids: list[int]) -> float:
180
+ inp = torch.tensor([full_ids], dtype=torch.long, device=device)
181
+ logits, _ = model.forward_causal_and_cache(inp, prefix_len=len(full_ids))
182
+ log_probs = F.log_softmax(logits[0, :-1, :].float(), dim=-1)
183
+ targets = inp[0, 1:]
184
+ # Mean log-prob over the continuation tokens [plen-1 : len-1]
185
+ start_idx = max(0, plen - 1)
186
+ cont_lp = log_probs[start_idx:, :].gather(1, targets[start_idx:].unsqueeze(1)).squeeze(1)
187
+ return float(cont_lp.mean().item())
188
+
189
+ score_good = _score_seq(good_ids)
190
+ score_bad = _score_seq(bad_ids)
191
+ is_ok = score_good > score_bad
192
+ correct_lm += int(is_ok)
193
+ margin_sum += score_good - score_bad
194
+
195
+ # Also check Conveyor Slot-0 log-prob on the first continuation token
196
+ if len(good_ids) > plen and len(bad_ids) > plen:
197
+ p_tensor = torch.tensor([ctx_ids], dtype=torch.long, device=device)
198
+ kv = model.prefill_prefix(p_tensor)
199
+ belt = model.init_empty_belt(1, tick=plen, device=device, deterministic=True)
200
+ out = model.forward_belt(belt, kv_cache=kv)
201
+ s0_lp = F.log_softmax(out.slot0_logits[0].float(), dim=-1)
202
+ s0_good = float(s0_lp[good_ids[plen]].item())
203
+ s0_bad = float(s0_lp[bad_ids[plen]].item())
204
+ correct_s0 += int(s0_good > s0_bad)
205
+
206
+ details.append(
207
+ {
208
+ "category": category,
209
+ "correct": is_ok,
210
+ "logprob_good": round(score_good, 3),
211
+ "logprob_bad": round(score_bad, 3),
212
+ }
213
+ )
214
+
215
+ n = max(1, len(COMMONSENSE_CLOZE_PAIRS))
216
+ return {
217
+ "commonsense_lm_acc": correct_lm / n,
218
+ "commonsense_slot0_acc": correct_s0 / n,
219
+ "mean_logprob_margin": margin_sum / n,
220
+ "num_pairs": len(COMMONSENSE_CLOZE_PAIRS),
221
+ "details": details,
222
+ }
223
+
224
+
225
+ @torch.no_grad()
226
+ def evaluate_prefix_sensitivity_and_generation_quality(
227
+ model: ConveyerDiffusionLM,
228
+ tokenizer: Any,
229
+ device: torch.device,
230
+ max_new_tokens: int = 80,
231
+ ) -> dict[str, Any]:
232
+ """
233
+ Measures:
234
+ 1. Prefix Sensitivity KL(P(slot0 | prompt) || P(slot0 | shuffled_prompt)):
235
+ In the broken template model this was 0.03 nats (ignored word order).
236
+ A real language model should have KL >> 0.5 nats.
237
+ 2. Open-Ended Generation Quality on 6 unseen prompts:
238
+ - Distinct-2 / Distinct-3 n-gram diversity (1.0 = no repetition, <0.5 = looping)
239
+ - 4-gram repetition rate (0.0 = clean prose)
240
+ - Prompt entity/topic retention rate (% of continuations that reference the prompt's new entities/events)
241
+ """
242
+ model.eval()
243
+ kl_list: list[float] = []
244
+ dist2_list: list[float] = []
245
+ dist3_list: list[float] = []
246
+ rep4_list: list[float] = []
247
+ entity_hits = 0
248
+ samples: list[dict[str, str]] = []
249
+
250
+ g = torch.Generator().manual_seed(123)
251
+ for prompt_text, expected_keywords in NOVEL_STORY_PROMPTS:
252
+ ids = tokenizer.encode(prompt_text.strip())
253
+ p_true = torch.tensor([ids], dtype=torch.long, device=device)
254
+ kv_true = model.prefill_prefix(p_true)
255
+ belt_true = model.init_empty_belt(1, tick=len(ids), device=device, deterministic=True)
256
+ d_true = F.softmax(model.forward_belt(belt_true, kv_cache=kv_true).slot0_logits[0].float(), dim=-1)
257
+
258
+ perm = torch.randperm(len(ids) - 1, generator=g) + 1
259
+ shuf_ids = [ids[0]] + [ids[i] for i in perm.tolist()]
260
+ p_shuf = torch.tensor([shuf_ids], dtype=torch.long, device=device)
261
+ kv_shuf = model.prefill_prefix(p_shuf)
262
+ belt_shuf = model.init_empty_belt(1, tick=len(ids), device=device, deterministic=True)
263
+ d_shuf = F.softmax(model.forward_belt(belt_shuf, kv_cache=kv_shuf).slot0_logits[0].float(), dim=-1)
264
+
265
+ kl = float((d_true * (d_true.clamp_min(1e-9).log() - d_shuf.clamp_min(1e-9).log())).sum().item())
266
+ kl_list.append(kl)
267
+
268
+ out_ids, _ = generate_conveyer(
269
+ model=model,
270
+ prompt_tokens=p_true,
271
+ max_new_tokens=max_new_tokens,
272
+ prompt_overlap=10,
273
+ temperature=0.0,
274
+ eos_token_id=tokenizer.eos_id,
275
+ deterministic_spawn=True,
276
+ )
277
+ gen_toks = out_ids[0, len(ids) :].tolist()
278
+ gen_text = tokenizer.decode(gen_toks).strip()
279
+
280
+ words = [w.strip(".,!?;:\"'()").lower() for w in gen_text.split() if w.strip()]
281
+ if len(words) >= 4:
282
+ bigrams = list(zip(words[:-1], words[1:]))
283
+ trigrams = list(zip(words[:-2], words[1:-1], words[2:]))
284
+ fourgrams = list(zip(words[:-3], words[1:-2], words[2:-1], words[3:]))
285
+ dist2_list.append(len(set(bigrams)) / max(1, len(bigrams)))
286
+ dist3_list.append(len(set(trigrams)) / max(1, len(trigrams)))
287
+ c4 = Counter(fourgrams)
288
+ repeated_4 = sum(v - 1 for v in c4.values() if v > 1)
289
+ rep4_list.append(repeated_4 / max(1, len(fourgrams)))
290
+
291
+ low_gen = gen_text.lower()
292
+ hit = any(kw.lower() in low_gen for kw in expected_keywords[:4])
293
+ entity_hits += int(hit)
294
+ samples.append({"prompt": prompt_text, "continuation": gen_text})
295
+
296
+ return {
297
+ "prefix_word_order_kl": sum(kl_list) / max(1, len(kl_list)),
298
+ "distinct_2": sum(dist2_list) / max(1, len(dist2_list)),
299
+ "distinct_3": sum(dist3_list) / max(1, len(dist3_list)),
300
+ "repeat_4gram_rate": sum(rep4_list) / max(1, len(rep4_list)),
301
+ "prompt_entity_retention": entity_hits / max(1, len(NOVEL_STORY_PROMPTS)),
302
+ "samples": samples,
303
+ }
304
+
305
+
306
+ @torch.no_grad()
307
+ def evaluate_crystallization_curve(
308
+ model: ConveyerDiffusionLM,
309
+ cfg: ConveyerConfig,
310
+ val_loader: DataLoader[dict[str, torch.Tensor]],
311
+ device: torch.device,
312
+ pad_id: int = 0,
313
+ max_batches: int = 20,
314
+ ) -> dict[str, Any]:
315
+ """
316
+ Computes the Per-Slot Accuracy, Entropy, and Pause Rate across the 64-slot oven belt
317
+ plus held-out real TinyStories Perplexity (Causal LM & Conveyor Slot-0).
318
+ """
319
+ model.eval()
320
+ B = model.belt_size
321
+ acc_sum = torch.zeros(B, device=device)
322
+ ent_sum = torch.zeros(B, device=device)
323
+ s0_loss_sum = 0.0
324
+ lm_loss_sum = 0.0
325
+ lm_acc_sum = 0.0
326
+ pause_sum = 0.0
327
+ count = 0
328
+
329
+ for i, batch in enumerate(val_loader):
330
+ if i >= max_batches:
331
+ break
332
+ seq = batch["input_ids"].to(device)
333
+ plen = batch["prompt_lengths"].to(device)
334
+ use_amp = device.type == "cuda" and torch.cuda.is_bf16_supported()
335
+ with torch.autocast(device_type=device.type, dtype=torch.bfloat16 if use_amp else torch.float32, enabled=use_amp):
336
+ m = compute_unrolled_conveyer_loss(
337
+ model=model,
338
+ seq_tokens=seq,
339
+ cfg=cfg,
340
+ prompt_lengths=plen,
341
+ tick_start=int(plen.min().item()),
342
+ pad_token_id=pad_id,
343
+ )
344
+ acc_sum += m.per_slot_acc
345
+ ent_sum += m.per_slot_entropy
346
+ s0_loss_sum += m.slot0_loss
347
+ lm_loss_sum += m.prefix_lm_loss
348
+ lm_acc_sum += m.prefix_lm_acc
349
+ pause_sum += m.mean_plan_pause
350
+ count += 1
351
+
352
+ denom = max(count, 1)
353
+ acc_curve = (acc_sum / denom).cpu().tolist()
354
+ ent_curve = (ent_sum / denom).cpu().tolist()
355
+ s0_ce = s0_loss_sum / denom
356
+ lm_ce = lm_loss_sum / denom
357
+ return {
358
+ "slot0_ce": s0_ce,
359
+ "slot0_ppl": math.exp(min(s0_ce, 20.0)),
360
+ "slot0_acc": acc_curve[0],
361
+ "causal_lm_ce": lm_ce,
362
+ "causal_lm_ppl": math.exp(min(lm_ce, 20.0)),
363
+ "causal_lm_acc": lm_acc_sum / denom,
364
+ "mean_plan_pause": pause_sum / denom,
365
+ "per_slot_acc": acc_curve,
366
+ "per_slot_entropy": ent_curve,
367
+ }
368
+
369
+
370
+ @torch.no_grad()
371
+ def evaluate_closed_loop_generation(
372
+ model: ConveyerDiffusionLM,
373
+ val_ds: Any,
374
+ tokenizer: Any,
375
+ device: torch.device,
376
+ num_episodes: int = 32,
377
+ prompt_overlap: int = 12,
378
+ bake_steps: int = 64,
379
+ max_new_tokens: int = 24,
380
+ ) -> dict[str, float]:
381
+ model.eval()
382
+ n_eval = min(num_episodes, len(val_ds))
383
+ first_tok_correct = 0
384
+ total_tok_correct = 0
385
+ total_tok_count = 0
386
+ exact_match = 0
387
+
388
+ for i in range(n_eval):
389
+ item = val_ds[i]
390
+ seq = item["input_ids"].to(device)
391
+ plen = int(item["prompt_len"])
392
+ prompt = seq[:plen].unsqueeze(0)
393
+
394
+ gt_full = seq[plen:].tolist()
395
+ gt_target: list[int] = []
396
+ for tid in gt_full:
397
+ if tid == tokenizer.pad_id:
398
+ break
399
+ gt_target.append(tid)
400
+ if tid == tokenizer.eos_id:
401
+ break
402
+
403
+ if not gt_target:
404
+ continue
405
+
406
+ gen_len = min(max_new_tokens, len(gt_target))
407
+ full_out, _ = generate_conveyer(
408
+ model=model,
409
+ prompt_tokens=prompt,
410
+ max_new_tokens=gen_len,
411
+ prompt_overlap=prompt_overlap,
412
+ bake_steps=bake_steps,
413
+ temperature=0.0,
414
+ eos_token_id=tokenizer.eos_id,
415
+ deterministic_spawn=True,
416
+ )
417
+ pred_ids = full_out[0, plen : plen + gen_len].tolist()
418
+ gt_slice = gt_target[:gen_len]
419
+
420
+ if len(pred_ids) > 0 and pred_ids[0] == gt_slice[0]:
421
+ first_tok_correct += 1
422
+
423
+ matches = sum(1 for p, g in zip(pred_ids, gt_slice) if p == g)
424
+ total_tok_correct += matches
425
+ total_tok_count += len(gt_slice)
426
+ if pred_ids == gt_slice:
427
+ exact_match += 1
428
+
429
+ return {
430
+ "first_token_acc": first_tok_correct / max(1, n_eval),
431
+ "token_acc": total_tok_correct / max(1, total_tok_count),
432
+ "exact_seq_acc": exact_match / max(1, n_eval),
433
+ }
434
+
435
+
436
+ def main() -> None:
437
+ parser = argparse.ArgumentParser(description="Comprehensive Benchmark Suite for Conveyor-Belt Diffusion LLM")
438
+ parser.add_argument("--ckpt", type=str, default="runs/tinystories/best.pt", help="Path to checkpoint (.pt)")
439
+ parser.add_argument("--device", type=str, default="auto", help="'cpu', 'cuda', or 'auto'")
440
+ parser.add_argument("--episodes", type=int, default=32, help="Number of closed-loop episodes")
441
+ parser.add_argument("--bake-steps", type=int, nargs="+", default=[16, 64], help="Bake depths K to evaluate")
442
+ parser.add_argument("--overlaps", type=int, nargs="+", default=[0, 10], help="Prompt-overlap windows W_pre")
443
+ parser.add_argument("--out-json", type=str, default=None, help="Optional path to save evaluation JSON")
444
+ args = parser.parse_args()
445
+
446
+ if args.device == "auto":
447
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
448
+ else:
449
+ device = torch.device(args.device)
450
+
451
+ model, cfg, ckpt_meta = load_checkpoint(args.ckpt, device=device)
452
+ _, val_ds, tokenizer = build_dataset_and_tokenizer(cfg)
453
+ val_loader = DataLoader(
454
+ val_ds,
455
+ batch_size=cfg.train.batch_size,
456
+ shuffle=False,
457
+ collate_fn=lambda b: collate_conveyer_batch(b, pad_id=tokenizer.pad_id),
458
+ )
459
+
460
+ step_num = ckpt_meta.get("step", "?")
461
+ elapsed_h = float(ckpt_meta.get("elapsed_sec", 0.0)) / 3600.0
462
+
463
+ cryst = evaluate_crystallization_curve(model, cfg, val_loader, device, pad_id=tokenizer.pad_id)
464
+ acc_c = cryst["per_slot_acc"]
465
+ ent_c = cryst["per_slot_entropy"]
466
+ B = len(acc_c)
467
+
468
+ print("=" * 92)
469
+ print(
470
+ f"CONVEYOR-BELT DIFFUSION LLM BENCHMARK SUITE | Ckpt: {args.ckpt} (step {step_num}, {elapsed_h:.2f}h) "
471
+ f"| Params: {model.num_parameters()/1e6:.2f}M | Device: {device}"
472
+ )
473
+ print("=" * 92)
474
+ print(
475
+ f"[1] Held-Out Real TinyStories LM Metrics (10,000 unseen stories, 0% templates):\n"
476
+ f" - Conveyor Slot-0 Perplexity : {cryst['slot0_ppl']:6.2f} | Top-1 Next-Token Acc: {cryst['slot0_acc']*100:5.2f}%\n"
477
+ f" - Causal Prefix LM Perplexity: {cryst['causal_lm_ppl']:6.2f} | Top-1 Next-Token Acc: {cryst['causal_lm_acc']*100:5.2f}%\n"
478
+ f" - Back-75% Planning Pause : {cryst['mean_plan_pause']*100:5.1f}%"
479
+ )
480
+ print("-" * 92)
481
+ print("[2] Per-Slot Crystallization Curve along the 64-Slot Oven Belt (Back k=63 -> Front k=0):")
482
+ sample_slots = [B - 1, (3 * B) // 4, B // 2, B // 4, min(8, B - 1), min(3, B - 1), 0]
483
+ seen: set[int] = set()
484
+ for k in sample_slots:
485
+ if k in seen:
486
+ continue
487
+ seen.add(k)
488
+ zone = "Back (75% Plan) " if k >= 16 else "Front (25% Stream)"
489
+ print(f" Slot {k:2d} [{zone}] | Top-1 Acc: {acc_c[k]*100:6.2f}% | Entropy: {ent_c[k]:.3f} nats")
490
+
491
+ if cfg.data.dataset == "tinystories":
492
+ cs = evaluate_zero_shot_commonsense(model, tokenizer, device)
493
+ gen_q = evaluate_prefix_sensitivity_and_generation_quality(model, tokenizer, device)
494
+ print("-" * 92)
495
+ print(
496
+ f"[3] Zero-Shot StoryCloze / Commonsense 2-Way Multiple Choice ({cs['num_pairs']} unseen pairs, random=50%):\n"
497
+ f" - Causal LM Choice Accuracy : {cs['commonsense_lm_acc']*100:6.2f}% (mean log-prob margin: {cs['mean_logprob_margin']:+.3f} nats)\n"
498
+ f" - Conveyor Slot-0 Choice Acc : {cs['commonsense_slot0_acc']*100:6.2f}%"
499
+ )
500
+ print("-" * 92)
501
+ print(
502
+ f"[4] Prompt Responsiveness & Open-Ended Generation Quality (6 novel story prompts):\n"
503
+ f" - Word-Order Sensitivity KL : {gen_q['prefix_word_order_kl']:6.3f} nats (broken model was 0.032; >0.50 = strong)\n"
504
+ f" - Prompt Entity Retention : {gen_q['prompt_entity_retention']*100:6.1f}%\n"
505
+ f" - Lexical Diversity : Distinct-2 = {gen_q['distinct_2']*100:5.1f}% | Distinct-3 = {gen_q['distinct_3']*100:5.1f}%\n"
506
+ f" - 4-gram Repetition Rate : {gen_q['repeat_4gram_rate']*100:5.2f}% (lower is better; 0% = zero loops)"
507
+ )
508
+ print("-" * 92)
509
+ print("[5] Live Novel Prompt Continutations:")
510
+ for s in gen_q["samples"][:3]:
511
+ print(f" PROMPT : {s['prompt']}")
512
+ print(f" OUTPUT : {s['continuation']}\n")
513
+ else:
514
+ cs = {}
515
+ gen_q = {}
516
+
517
+ print("-" * 92)
518
+ print(f"{'Bake Depth K':<15} | {'Prompt Overlap W':<18} | {'Pos-0 Acc':<12} | {'Token Acc':<12} | {'Exact Seq Acc':<14}")
519
+ print("-" * 92)
520
+
521
+ sweep_results: list[dict[str, Any]] = []
522
+ for k_bake in args.bake_steps:
523
+ for w_ov in args.overlaps:
524
+ res = evaluate_closed_loop_generation(
525
+ model=model,
526
+ val_ds=val_ds,
527
+ tokenizer=tokenizer,
528
+ device=device,
529
+ num_episodes=args.episodes,
530
+ prompt_overlap=w_ov,
531
+ bake_steps=k_bake,
532
+ )
533
+ print(
534
+ f"K = {k_bake:<11d} | W_pre = {w_ov:<10d} | "
535
+ f"{res['first_token_acc']*100:9.2f}% | {res['token_acc']*100:9.2f}% | {res['exact_seq_acc']*100:10.2f}%"
536
+ )
537
+ sweep_results.append({"bake_steps": k_bake, "prompt_overlap": w_ov, **res})
538
+
539
+ if args.out_json:
540
+ Path(args.out_json).parent.mkdir(parents=True, exist_ok=True)
541
+ Path(args.out_json).write_text(
542
+ json.dumps(
543
+ {
544
+ "step": step_num,
545
+ "crystallization": cryst,
546
+ "commonsense": cs,
547
+ "generation_quality": gen_q,
548
+ "closed_loop": sweep_results,
549
+ },
550
+ indent=2,
551
+ ),
552
+ encoding="utf-8",
553
+ )
554
+
555
+
556
+ if __name__ == "__main__":
557
+ main()
src/conveyer/infer.py ADDED
@@ -0,0 +1,319 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import Iterator, Optional
7
+ import torch
8
+ import torch.nn.functional as F
9
+
10
+ from conveyer.model import BeltState, BeltStepOutput, ConveyerDiffusionLM, PrefixKVCache
11
+
12
+
13
+ @dataclass
14
+ class TickSnapshot:
15
+ """Complete telemetry of a single conveyor-belt oven tick (used for live CLI & Web UI)."""
16
+ tick: int # absolute sequence index at Slot 0
17
+ is_warmup: bool # True during prompt-overlap pre-baking (t < prompt_len)
18
+ emitted_token: int # token popped out at Slot 0 (clamped if is_warmup)
19
+ predicted_slot0_token: int # model's raw top-1 prediction at Slot 0
20
+ slot_top1_tokens: list[int] # (B,) top-1 token ID at each slot k=0..B-1
21
+ slot_top1_probs: list[float] # (B,) top-1 probability at each slot k=0..B-1
22
+ slot_entropies: list[float] # (B,) Shannon entropy at each slot k=0..B-1
23
+ slot_gate_means: list[float] # (B,) mean thought-highway gate activation g_k
24
+ slot_shift_probs: list[float] # (B,) elastic shift probability s_k (1.0 in front, <1 in back 75% planning)
25
+ slot_top3_tokens: list[list[int]] # (B, 3) top-3 candidate token IDs per slot
26
+ slot_top3_probs: list[list[float]]# (B, 3) top-3 candidate probabilities per slot
27
+
28
+
29
+ @torch.no_grad()
30
+ def sample_slot0_token(
31
+ logits: torch.Tensor,
32
+ temperature: float = 0.0,
33
+ recent_tokens: Optional[list[int]] = None,
34
+ repetition_penalty: float = 1.25,
35
+ ) -> torch.Tensor:
36
+ """
37
+ logits: (N, V)
38
+ Masks out special tokens (<PAD>=0, <BOS>=1, <UNK>=3) and applies mild repetition penalty
39
+ on recently emitted tokens before greedy or top-k sampling.
40
+ """
41
+ adj_logits = logits.clone()
42
+ if adj_logits.size(-1) > 4:
43
+ adj_logits[:, 0] = -1e9
44
+ adj_logits[:, 1] = -1e9
45
+ adj_logits[:, 3] = -1e9
46
+
47
+ if recent_tokens and repetition_penalty > 1.0:
48
+ for tid in set(recent_tokens[-6:]):
49
+ if 0 <= tid < adj_logits.size(-1):
50
+ val = adj_logits[:, tid]
51
+ adj_logits[:, tid] = torch.where(val > 0, val / repetition_penalty, val * repetition_penalty)
52
+ # Only block 3-in-a-row identical token stammers (so legitimate words after prompt or across clauses are never clipped!)
53
+ if len(recent_tokens) >= 2 and recent_tokens[-1] == recent_tokens[-2]:
54
+ last_tid = recent_tokens[-1]
55
+ if 0 <= last_tid < adj_logits.size(-1):
56
+ adj_logits[:, last_tid] -= 3.0
57
+
58
+ if temperature <= 1e-5:
59
+ return adj_logits.argmax(dim=-1)
60
+ scaled = adj_logits / temperature
61
+ k = min(24, scaled.size(-1))
62
+ top_vals, _ = torch.topk(scaled, k, dim=-1)
63
+ min_top = top_vals[:, -1].unsqueeze(-1)
64
+ scaled = torch.where(scaled < min_top, torch.full_like(scaled, -1e9), scaled)
65
+ probs = F.softmax(scaled, dim=-1)
66
+ return torch.multinomial(probs, num_samples=1).squeeze(-1)
67
+
68
+
69
+ @torch.no_grad()
70
+ def stream_conveyer_generation(
71
+ model: ConveyerDiffusionLM,
72
+ prompt_tokens: torch.Tensor,
73
+ max_new_tokens: int = 64,
74
+ prompt_overlap: int = 12,
75
+ bake_steps: Optional[int] = None,
76
+ temperature: float = 0.0,
77
+ eos_token_id: Optional[int] = None,
78
+ deterministic_spawn: bool = False,
79
+ ) -> Iterator[tuple[torch.Tensor, TickSnapshot]]:
80
+ """
81
+ Streams generation tick-by-tick using the `n - W_pre` Prompt-Overlap Pre-Baking protocol:
82
+ - Input at every tick t : [Committed Prefix x_{0..t-1}] + [64-Slot Conveyor/Planning Belt]
83
+ - Output at every tick t: [Popped-out Confirmed Token x_t at Slot 0] + [Updated 64-Slot Belt]
84
+ """
85
+ model.eval()
86
+ if prompt_tokens.dim() == 1:
87
+ prompt_tokens = prompt_tokens.unsqueeze(0)
88
+
89
+ bsz, n = prompt_tokens.shape
90
+ device = prompt_tokens.device
91
+ B = min(bake_steps, model.belt_size) if bake_steps is not None else model.belt_size
92
+ B = max(1, B)
93
+
94
+ max_overlap = min(model.front_slots, B - 1) if B > 1 else 0
95
+ w_pre = max(0, min(n - 1 if n > 1 else 0, prompt_overlap, max_overlap))
96
+ t_start = n - w_pre
97
+
98
+ # 1. Prefill prefix KV cache on x_{0 : t_start - 1}
99
+ kv_cache: PrefixKVCache = model.prefill_prefix(prompt_tokens[:, :t_start])
100
+
101
+ # 2. Initialize conveyor belt at t_start
102
+ belt: BeltState = model.init_empty_belt(
103
+ batch_size=bsz,
104
+ tick=t_start,
105
+ device=device,
106
+ deterministic=deterministic_spawn,
107
+ )
108
+ if B < model.belt_size:
109
+ belt = BeltState(
110
+ e_soft=belt.e_soft[:, :B, :],
111
+ m_thought=belt.m_thought[:, :B, :],
112
+ tick=t_start,
113
+ )
114
+
115
+ offsets = model.get_slot_target_offsets(active_slots=B).to(device) # (B,)
116
+
117
+ # Clamp any belt slots at tick t_start that fall inside the known input prompt (t_start + offsets[k] < n)
118
+ if w_pre > 0:
119
+ slot_pos = t_start + offsets # (B,)
120
+ in_prompt = slot_pos < n
121
+ clamped_pos = slot_pos.clamp(min=0, max=n - 1).unsqueeze(0).expand(bsz, -1)
122
+ prompt_gathered = torch.gather(prompt_tokens, dim=1, index=clamped_pos)
123
+ overlap_emb = model.tok_emb(prompt_gathered)
124
+ e_init = torch.where(in_prompt.view(1, B, 1), overlap_emb, belt.e_soft)
125
+ belt = BeltState(e_soft=e_init, m_thought=belt.m_thought, tick=t_start)
126
+
127
+ total_ticks = w_pre + max_new_tokens
128
+ recent_emitted: list[int] = []
129
+
130
+ for step_idx in range(total_ticks):
131
+ t = t_start + step_idx
132
+ is_warmup = t < n
133
+
134
+ # Single forward pass across all active slots on the belt
135
+ step_out: BeltStepOutput = model.forward_belt(belt=belt, kv_cache=kv_cache, active_slots=B)
136
+
137
+ raw_pred_tok = sample_slot0_token(
138
+ step_out.slot0_logits,
139
+ temperature=temperature,
140
+ recent_tokens=recent_emitted if not is_warmup else None,
141
+ repetition_penalty=1.08 if temperature > 1e-5 else 1.0,
142
+ )
143
+ if is_warmup:
144
+ emitted_tok = prompt_tokens[:, t]
145
+ else:
146
+ emitted_tok = raw_pred_tok
147
+ recent_emitted.append(int(emitted_tok[0].item()))
148
+
149
+ probs0 = step_out.probs[0] # (B, V)
150
+ p_clamped = probs0.clamp(min=1e-8)
151
+ entropies = -(p_clamped * p_clamped.log()).sum(dim=-1) # (B,)
152
+ k_top = min(3, model.vocab_size)
153
+ top_probs, top_ids = probs0.topk(k_top, dim=-1)
154
+ gate_means = step_out.gate[0].mean(dim=-1) # (B,)
155
+ shift_probs = step_out.shift_prob[0] # (B,)
156
+
157
+ snapshot = TickSnapshot(
158
+ tick=t,
159
+ is_warmup=is_warmup,
160
+ emitted_token=int(emitted_tok[0].item()),
161
+ predicted_slot0_token=int(raw_pred_tok[0].item()),
162
+ slot_top1_tokens=top_ids[:, 0].tolist(),
163
+ slot_top1_probs=top_probs[:, 0].tolist(),
164
+ slot_entropies=entropies.tolist(),
165
+ slot_gate_means=gate_means.tolist(),
166
+ slot_shift_probs=shift_probs.tolist(),
167
+ slot_top3_tokens=top_ids.tolist(),
168
+ slot_top3_probs=top_probs.tolist(),
169
+ )
170
+
171
+ yield emitted_tok, snapshot
172
+
173
+ if (not is_warmup) and eos_token_id is not None and int(emitted_tok[0].item()) == eos_token_id:
174
+ break
175
+
176
+ # Commit popped-out token x_t to frozen prefix KV cache
177
+ kv_cache = model.append_committed_token(emitted_tok, kv_cache)
178
+
179
+ # Shift belt left (with elastic 75% back planning zone) and clamp any remaining prompt tokens at t + 1
180
+ next_t = t + 1
181
+ if next_t < n:
182
+ next_pos = next_t + offsets # (B,)
183
+ clamp_mask = (next_pos < n).unsqueeze(0).expand(bsz, -1) # (N, B)
184
+ clamped_idx = next_pos.clamp(min=0, max=n - 1).unsqueeze(0).expand(bsz, -1)
185
+ clamp_tokens = torch.gather(prompt_tokens, dim=1, index=clamped_idx)
186
+ else:
187
+ clamp_tokens = None
188
+ clamp_mask = None
189
+
190
+ belt = model.shift_belt_left(
191
+ step_out=step_out,
192
+ current_tick=t,
193
+ deterministic_spawn=deterministic_spawn,
194
+ clamp_tokens=clamp_tokens,
195
+ clamp_mask=clamp_mask,
196
+ )
197
+
198
+
199
+ @torch.no_grad()
200
+ def generate_conveyer(
201
+ model: ConveyerDiffusionLM,
202
+ prompt_tokens: torch.Tensor,
203
+ max_new_tokens: int = 64,
204
+ prompt_overlap: int = 12,
205
+ bake_steps: Optional[int] = None,
206
+ temperature: float = 0.0,
207
+ eos_token_id: Optional[int] = None,
208
+ deterministic_spawn: bool = False,
209
+ ) -> tuple[torch.Tensor, list[TickSnapshot]]:
210
+ if prompt_tokens.dim() == 1:
211
+ prompt_tokens = prompt_tokens.unsqueeze(0)
212
+
213
+ generated: list[torch.Tensor] = []
214
+ snapshots: list[TickSnapshot] = []
215
+
216
+ for tok, snap in stream_conveyer_generation(
217
+ model=model,
218
+ prompt_tokens=prompt_tokens,
219
+ max_new_tokens=max_new_tokens,
220
+ prompt_overlap=prompt_overlap,
221
+ bake_steps=bake_steps,
222
+ temperature=temperature,
223
+ eos_token_id=eos_token_id,
224
+ deterministic_spawn=deterministic_spawn,
225
+ ):
226
+ snapshots.append(snap)
227
+ if not snap.is_warmup:
228
+ generated.append(tok.unsqueeze(1))
229
+
230
+ if len(generated) > 0:
231
+ gen_tensor = torch.cat(generated, dim=1)
232
+ full_seq = torch.cat([prompt_tokens, gen_tensor], dim=1)
233
+ else:
234
+ full_seq = prompt_tokens.clone()
235
+
236
+ return full_seq, snapshots
237
+
238
+
239
+ def main() -> None:
240
+ parser = argparse.ArgumentParser(description="CLI Test & Tick-by-Tick Trace for Conveyor-Belt Diffusion LLM")
241
+ parser.add_argument("--ckpt", type=str, default=None, help="Path to model checkpoint (.pt)")
242
+ parser.add_argument("--prompt", type=str, default=None, help="Initial text prompt to continue")
243
+ parser.add_argument("--tokens", type=int, default=32, help="Number of new tokens to bake and emit")
244
+ parser.add_argument("--overlap", type=int, default=12, help="Prompt-overlap pre-baking window (n - W)")
245
+ parser.add_argument("--bake-steps", type=int, default=64, help="Active conveyor belt length K (1..64)")
246
+ parser.add_argument("--temp", type=float, default=0.0, help="Sampling temperature at Slot 0 (0 = greedy, 0.7 = creative)")
247
+ args = parser.parse_args()
248
+
249
+ from conveyer.checkpoint import load_checkpoint
250
+ from conveyer.data import build_dataset_and_tokenizer
251
+
252
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
253
+ ckpt_path = args.ckpt
254
+ if ckpt_path is None:
255
+ ckpt_path = (
256
+ "runs/synthetic/best.pt"
257
+ if (not Path("runs/tinystories/best.pt").exists() and Path("runs/synthetic/best.pt").exists())
258
+ else "runs/tinystories/best.pt"
259
+ )
260
+
261
+ model, cfg, _ = load_checkpoint(ckpt_path, device=device)
262
+ _, val_ds, tokenizer = build_dataset_and_tokenizer(cfg, infer_only=True)
263
+
264
+ if args.prompt is not None:
265
+ # A trailing space becomes a lone ' ' BPE token that never ends a prefix in training.
266
+ prompt_ids = tokenizer.encode(args.prompt.strip())
267
+ prompt_tensor = torch.tensor([prompt_ids], dtype=torch.long, device=device)
268
+ else:
269
+ sample = val_ds[0]
270
+ p_len = int(sample["prompt_len"])
271
+ prompt_tensor = sample["input_ids"][:p_len].unsqueeze(0).to(device)
272
+
273
+ prompt_decoded = tokenizer.decode(prompt_tensor[0].tolist())
274
+ print("=" * 108)
275
+ print(f"Checkpoint : {ckpt_path} ({model.num_parameters()/1e6:.2f}M params, device={device})")
276
+ print(f"Input Text : {prompt_decoded}")
277
+ print("-" * 108)
278
+ print(
279
+ f"{'tick':>4} | {'mode':<6} | {'pop(Slot 0)':<14} | {'front conveyor [1..4]':<30} | "
280
+ f"{'mid expand [15..18]':<24} | {'back 75% plan [58..63] (pause%)'}"
281
+ )
282
+ print("-" * 108)
283
+
284
+ emitted_ids: list[int] = []
285
+ for tok, snap in stream_conveyer_generation(
286
+ model=model,
287
+ prompt_tokens=prompt_tensor,
288
+ max_new_tokens=args.tokens,
289
+ prompt_overlap=args.overlap,
290
+ bake_steps=args.bake_steps,
291
+ temperature=args.temp,
292
+ eos_token_id=tokenizer.eos_id,
293
+ ):
294
+ if not snap.is_warmup:
295
+ emitted_ids.append(int(tok[0].item()))
296
+ mode_str = "WARMUP" if snap.is_warmup else "EMIT "
297
+ pop_str = repr(tokenizer.id_to_piece(snap.emitted_token).strip() or tokenizer.id_to_piece(snap.emitted_token))
298
+ B = len(snap.slot_top1_tokens)
299
+ front_str = " ".join(tokenizer.id_to_piece(t).strip() for t in snap.slot_top1_tokens[1 : min(5, B)])
300
+ mid_str = " ".join(tokenizer.id_to_piece(t).strip() for t in snap.slot_top1_tokens[min(15, B - 1) : min(19, B)])
301
+ back_str = " ".join(tokenizer.id_to_piece(t).strip() for t in snap.slot_top1_tokens[max(0, B - 5) : B])
302
+ F_slots = min(model.front_slots, B)
303
+ back_pause = (
304
+ 100.0 * (1.0 - sum(snap.slot_shift_probs[F_slots:]) / max(1, B - F_slots))
305
+ if B > F_slots
306
+ else 0.0
307
+ )
308
+ print(
309
+ f"{snap.tick:4d} | {mode_str} | {pop_str:<14} | {front_str[:30]:<30} | "
310
+ f"{mid_str[:24]:<24} | {back_str[:22]:<22} ({back_pause:2.0f}% paused)"
311
+ )
312
+
313
+ print("-" * 108)
314
+ print(f"FULL OUTPUT: {prompt_decoded}{tokenizer.decode(emitted_ids)}")
315
+ print("=" * 108)
316
+
317
+
318
+ if __name__ == "__main__":
319
+ main()
src/conveyer/live.py ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
5
+ import time
6
+ import torch
7
+
8
+ from conveyer.checkpoint import load_checkpoint
9
+ from conveyer.config import load_config
10
+ from conveyer.data import build_dataset_and_tokenizer
11
+ from conveyer.infer import TickSnapshot, stream_conveyer_generation
12
+ from conveyer.model import ConveyerDiffusionLM
13
+
14
+ RESET = "\033[0m"
15
+ BOLD = "\033[1m"
16
+ DIM = "\033[2m"
17
+ GREEN = "\033[92m"
18
+ CYAN = "\033[96m"
19
+ YELLOW = "\033[93m"
20
+ MAGENTA = "\033[95m"
21
+ GRAY = "\033[90m"
22
+
23
+
24
+ def _format_slot_badge(slot_idx: int, piece: str, prob: float) -> str:
25
+ clean = piece.replace("\n", " ")[:9]
26
+ if slot_idx == 0:
27
+ return f"{BOLD}{GREEN}[0:{clean} {prob*100:2.0f}%]{RESET}"
28
+ elif slot_idx < 15:
29
+ return f"{GREEN}{clean}({prob*100:2.0f}%){RESET}"
30
+ elif slot_idx < 40:
31
+ return f"{YELLOW}{clean}({prob*100:2.0f}%){RESET}"
32
+ else:
33
+ return f"{GRAY}{clean}{RESET}"
34
+
35
+
36
+ def render_frame(
37
+ prompt_str: str,
38
+ committed_pieces: list[str],
39
+ snap: TickSnapshot,
40
+ tokenizer: Any,
41
+ clear_screen: bool = True,
42
+ ) -> str:
43
+ B = len(snap.slot_top1_tokens)
44
+ lines: list[str] = []
45
+ if clear_screen:
46
+ lines.append("\033[2J\033[H")
47
+ lines.append("=" * 98)
48
+ mode_tag = (
49
+ f"{CYAN}PRE-BAKING WARMUP (Clamping Input -> Preparing Conveyor Belt){RESET}"
50
+ if snap.is_warmup
51
+ else f"{GREEN}LIVE CONVEYOR BAKING (Serving Slot 0 -> Shifting 1..63 -> Spawning 63){RESET}"
52
+ )
53
+ lines.append(f"{BOLD}64-SLOT DUAL-CHANNEL CONVEYOR OVEN LLM{RESET} | Tick t={snap.tick:3d} | {mode_tag}")
54
+ lines.append("-" * 98)
55
+ lines.append(f"{BOLD}Input Prompt :{RESET} {prompt_str}")
56
+ committed_str = " ".join(committed_pieces) if committed_pieces else "(warming up belt...)"
57
+ lines.append(f"{BOLD}Served Output:{RESET} {BOLD}{GREEN}{committed_str}{RESET}")
58
+ lines.append("-" * 98)
59
+
60
+ # Zone 1: Front of Belt (0 .. 14) - Final Grammatical Polish
61
+ front_items = [
62
+ _format_slot_badge(k, tokenizer.id_to_piece(snap.slot_top1_tokens[k]), snap.slot_top1_probs[k])
63
+ for k in range(min(15, B))
64
+ ]
65
+ lines.append(f"{BOLD}{GREEN}FRONT [00..14] (Final Polish, alpha->0):{RESET}")
66
+ lines.append(" <- POP " + " | ".join(front_items[:8]))
67
+ if len(front_items) > 8:
68
+ lines.append(" " + " | ".join(front_items[8:15]))
69
+
70
+ # Zone 2: Middle of Belt (15 .. 39) - Firming Up Phrasing
71
+ if B > 15:
72
+ mid_indices = list(range(15, min(40, B), 2))
73
+ mid_items = [
74
+ _format_slot_badge(k, tokenizer.id_to_piece(snap.slot_top1_tokens[k]), snap.slot_top1_probs[k])
75
+ for k in mid_indices[:10]
76
+ ]
77
+ lines.append(f"{BOLD}{YELLOW}MIDDLE [15..39] (Firming Phrasing, Dual-Channel):{RESET}")
78
+ lines.append(" " + " | ".join(mid_items))
79
+
80
+ # Zone 3: Back of Belt (40 .. 63) - Rough Ideas & Fresh Raw Dough
81
+ if B > 40:
82
+ back_indices = list(range(40, B, 2))
83
+ if (B - 1) not in back_indices:
84
+ back_indices.append(B - 1)
85
+ back_items = [
86
+ f"[{k}:{_format_slot_badge(k, tokenizer.id_to_piece(snap.slot_top1_tokens[k]), snap.slot_top1_probs[k])}]"
87
+ for k in back_indices[-8:]
88
+ ]
89
+ lines.append(f"{BOLD}{MAGENTA}BACK [40..63] (Rough Ideas <- Slot 63 Raw Dough Spawn):{RESET}")
90
+ lines.append(" " + " ".join(back_items) + " <- [NEW DOUGH]")
91
+
92
+ # Telemetry bar: Entropy & Gate Retention
93
+ ent_front = sum(snap.slot_entropies[: min(8, B)]) / max(1, min(8, B))
94
+ ent_mid = sum(snap.slot_entropies[min(15, B - 1) : min(40, B)]) / max(1, min(40, B) - min(15, B - 1))
95
+ ent_back = sum(snap.slot_entropies[max(0, B - 16) : B]) / max(1, B - max(0, B - 16))
96
+ gate_avg = sum(snap.slot_gate_means) / max(1, B)
97
+ lines.append("-" * 98)
98
+ lines.append(
99
+ f"Entropy (Front/Mid/Back): {ent_front:.2f} / {ent_mid:.2f} / {ent_back:.2f} nats | "
100
+ f"Mean Thought Gate g_k: {gate_avg:.3f}"
101
+ )
102
+ lines.append("=" * 98)
103
+ return "\n".join(lines)
104
+
105
+
106
+ def main() -> None:
107
+ parser = argparse.ArgumentParser(description="Live Terminal Visualizer for the 64-Slot Conveyor-Belt Diffusion LLM")
108
+ parser.add_argument("--ckpt", type=str, default=None, help="Checkpoint path (.pt). If omitted, initializes from config")
109
+ parser.add_argument("--config", type=str, default="configs/synthetic.yaml", help="Config path if --ckpt is not provided")
110
+ parser.add_argument("--prompt", type=str, default=None, help="Custom input prompt")
111
+ parser.add_argument("--tokens", type=int, default=24, help="Number of output tokens to bake")
112
+ parser.add_argument("--overlap", type=int, default=12, help="Prompt-overlap pre-baking window (n - W)")
113
+ parser.add_argument("--bake-steps", type=int, default=64, help="Conveyor belt size (1..64)")
114
+ parser.add_argument("--hz", type=float, default=10.0, help="Refresh rate in ticks/sec (use 0 for no delay)")
115
+ parser.add_argument("--no-clear", action="store_true", help="Print frames sequentially without ANSI screen clear")
116
+ args = parser.parse_args()
117
+
118
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
119
+ if args.ckpt is not None:
120
+ model, cfg, _ = load_checkpoint(args.ckpt, device=device)
121
+ else:
122
+ cfg = load_config(args.config)
123
+ model = ConveyerDiffusionLM(cfg.model).to(device)
124
+
125
+ _, val_ds, tokenizer = build_dataset_and_tokenizer(cfg)
126
+
127
+ if args.prompt is not None:
128
+ prompt_ids = tokenizer.encode(args.prompt)
129
+ prompt_tensor = torch.tensor([prompt_ids], dtype=torch.long, device=device)
130
+ else:
131
+ sample = val_ds[0]
132
+ p_len = int(sample["prompt_len"])
133
+ prompt_tensor = sample["input_ids"][:p_len].unsqueeze(0).to(device)
134
+
135
+ prompt_str = tokenizer.decode(prompt_tensor[0].tolist())
136
+ committed: list[str] = []
137
+ delay = (1.0 / args.hz) if args.hz > 0 else 0.0
138
+
139
+ for tok, snap in stream_conveyer_generation(
140
+ model=model,
141
+ prompt_tokens=prompt_tensor,
142
+ max_new_tokens=args.tokens,
143
+ prompt_overlap=args.overlap,
144
+ bake_steps=args.bake_steps,
145
+ temperature=0.0,
146
+ eos_token_id=tokenizer.eos_id,
147
+ ):
148
+ if not snap.is_warmup:
149
+ committed.append(tokenizer.id_to_piece(int(tok[0].item())))
150
+ frame = render_frame(
151
+ prompt_str=prompt_str,
152
+ committed_pieces=committed,
153
+ snap=snap,
154
+ tokenizer=tokenizer,
155
+ clear_screen=(not args.no_clear),
156
+ )
157
+ sys.stdout.write(frame + "\n")
158
+ sys.stdout.flush()
159
+ if delay > 0:
160
+ time.sleep(delay)
161
+
162
+
163
+ if __name__ == "__main__":
164
+ main()
src/conveyer/losses.py ADDED
@@ -0,0 +1,406 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Optional
5
+ import torch
6
+ import torch.nn.functional as F
7
+
8
+ from conveyer.config import ConveyerConfig
9
+ from conveyer.model import BeltState, ConveyerDiffusionLM, PrefixKVCache
10
+
11
+
12
+ @dataclass
13
+ class UnrollMetrics:
14
+ loss: torch.Tensor
15
+ slot0_loss: float
16
+ slot0_acc: float
17
+ belt_mean_loss: float
18
+ belt_mean_acc: float
19
+ per_slot_acc: torch.Tensor # (B,) accuracy at each belt position k=0..B-1
20
+ per_slot_entropy: torch.Tensor # (B,) entropy at each belt position k=0..B-1
21
+ mean_gate: float
22
+ mean_plan_pause: float = 0.0 # Mean pause rate (1 - s_k) on the back 75% planning belt
23
+ prefix_lm_loss: float = 0.0 # Causal next-token CE loss across the prompt/full sequence
24
+ prefix_lm_acc: float = 0.0 # Causal next-token accuracy across the prompt/full sequence
25
+
26
+
27
+ def build_horizon_weights(
28
+ belt_size: int,
29
+ w_min: float,
30
+ w_beta: float,
31
+ device: torch.device,
32
+ ) -> torch.Tensor:
33
+ k = torch.arange(belt_size, device=device, dtype=torch.float32)
34
+ ratio = k / max(belt_size - 1, 1)
35
+ w = w_min + (1.0 - w_min) * ((1.0 - ratio) ** w_beta)
36
+ return w / w.sum()
37
+
38
+
39
+ def gather_belt_targets(
40
+ seq_tokens: torch.Tensor,
41
+ tick: int,
42
+ offsets: torch.Tensor,
43
+ pad_token_id: int = 0,
44
+ ) -> tuple[torch.Tensor, torch.Tensor]:
45
+ """
46
+ Gathers target tokens `x_{tick + offsets[k]}` for each belt slot `k = 0..B-1`:
47
+ - Front 25% Conveyor Slots (k = 0 .. F-1): `offsets[k] = k` (immediate token stream `t + k`)
48
+ - Back 75% Planning Slots (k = F .. B-1): `offsets[k] = F + (k - F) * stride` (longer-range sentence plan!)
49
+ Returns `(targets, in_bounds_mask)` of shape `(N, B)`.
50
+ """
51
+ bsz, seq_len = seq_tokens.shape
52
+ device = seq_tokens.device
53
+ pos = tick + offsets.to(device) # (B,)
54
+ in_bounds = (pos >= 0) & (pos < seq_len) # (B,)
55
+ clamped_pos = pos.clamp(min=0, max=seq_len - 1).unsqueeze(0).expand(bsz, -1) # (N, B)
56
+ gathered = torch.gather(seq_tokens, dim=1, index=clamped_pos)
57
+ pad_tensor = torch.full_like(gathered, pad_token_id)
58
+ targets = torch.where(in_bounds.unsqueeze(0), gathered, pad_tensor)
59
+ return targets, in_bounds.unsqueeze(0).expand(bsz, -1)
60
+
61
+
62
+ def seed_corrupted_belt(
63
+ model: ConveyerDiffusionLM,
64
+ target_tokens: torch.Tensor,
65
+ tick_start: int,
66
+ sigma_max: float = 3.5,
67
+ sigma_eta: float = 1.25,
68
+ cold_drop_prob: float = 0.85,
69
+ prompt_lengths: Optional[torch.Tensor] = None,
70
+ ) -> BeltState:
71
+ bsz, B = target_tokens.shape
72
+ device = target_tokens.device
73
+ V = model.vocab_size
74
+
75
+ k = torch.arange(B, device=device, dtype=torch.float32)
76
+ ratio = k / max(B - 1, 1)
77
+
78
+ signal_strength = 3.0 * ((1.0 - ratio) ** (sigma_eta * 2.0))
79
+ signal_strength[-1] = 0.0
80
+ sigma_k = 0.5 + sigma_max * (ratio ** sigma_eta)
81
+
82
+ one_hot = F.one_hot(target_tokens.clamp(min=0, max=V - 1), num_classes=V).float()
83
+ noise = torch.randn((bsz, B, V), device=device, dtype=torch.float32)
84
+ tau = model.tau_schedule[:B].view(1, B, 1)
85
+
86
+ noisy_logits = signal_strength.view(1, B, 1) * one_hot + sigma_k.view(1, B, 1) * noise
87
+ tilde_p = F.softmax(noisy_logits / tau, dim=-1).type_as(model.tok_emb.weight)
88
+ e_soft = torch.matmul(tilde_p, model.tok_emb.weight)
89
+
90
+ _, m_thought = model.spawn_raw_dough(batch_size=bsz, num_slots=B, device=device)
91
+
92
+ effective_cold_prob = 1.0 if (not model.training and cold_drop_prob > 0.0) else cold_drop_prob
93
+ if effective_cold_prob > 0.0:
94
+ if effective_cold_prob >= 1.0:
95
+ cold_mask = torch.ones((bsz, 1, 1), device=device, dtype=torch.bool)
96
+ else:
97
+ cold_mask = torch.rand((bsz, 1, 1), device=device) < effective_cold_prob
98
+ e_cold, m_cold = model.spawn_raw_dough(batch_size=bsz, num_slots=B, device=device)
99
+ e_soft = torch.where(cold_mask, e_cold, e_soft)
100
+ m_thought = torch.where(cold_mask, m_cold, m_thought)
101
+ uniform_p = torch.full_like(tilde_p, 1.0 / V)
102
+ tilde_p = torch.where(cold_mask, uniform_p, tilde_p)
103
+
104
+ if prompt_lengths is not None:
105
+ offsets = model.get_slot_target_offsets(active_slots=B).to(device)
106
+ pos = tick_start + offsets.unsqueeze(0) # (1, B)
107
+ is_prompt = pos < prompt_lengths.unsqueeze(1) # (N, B)
108
+ prompt_emb = model.tok_emb(target_tokens.clamp(min=0, max=V - 1))
109
+ e_soft = torch.where(is_prompt.unsqueeze(-1), prompt_emb, e_soft)
110
+
111
+ return BeltState(
112
+ e_soft=e_soft,
113
+ m_thought=m_thought,
114
+ tick=tick_start,
115
+ probs=tilde_p,
116
+ logits=noisy_logits,
117
+ )
118
+
119
+
120
+ def apply_belt_recovery_perturbation(
121
+ model: ConveyerDiffusionLM,
122
+ belt: BeltState,
123
+ corrupt_rate: float = 0.20,
124
+ prompt_mask: Optional[torch.Tensor] = None,
125
+ ) -> BeltState:
126
+ """
127
+ Belt Recovery Training:
128
+ Randomly corrupts a subset of slots on the active conveyor belt mid-unroll (replacing their
129
+ soft embeddings with random vocabulary embeddings or noise), forcing the next forward pass
130
+ to actively repair and re-synchronize the belt from surrounding slots and the prefix!
131
+ """
132
+ if corrupt_rate <= 0.0 or not model.training:
133
+ return belt
134
+ bsz, B, _ = belt.e_soft.shape
135
+ device = belt.e_soft.device
136
+ # Select ~corrupt_rate of slots per batch item to perturb
137
+ slot_drop = torch.rand((bsz, B), device=device) < corrupt_rate
138
+ if prompt_mask is not None:
139
+ slot_drop = slot_drop & (~prompt_mask)
140
+
141
+ rand_toks = torch.randint(0, model.vocab_size, (bsz, B), device=device)
142
+ rand_emb = model.tok_emb(rand_toks)
143
+ e_noise, m_noise = model.spawn_raw_dough(batch_size=bsz, num_slots=B, device=device)
144
+ corrupted_e = 0.5 * rand_emb + 0.5 * e_noise
145
+
146
+ mask_exp = slot_drop.unsqueeze(-1)
147
+ e_repaired = torch.where(mask_exp, corrupted_e, belt.e_soft)
148
+ m_repaired = torch.where(mask_exp, m_noise, belt.m_thought)
149
+ return BeltState(
150
+ e_soft=e_repaired,
151
+ m_thought=m_repaired,
152
+ tick=belt.tick,
153
+ probs=belt.probs,
154
+ logits=belt.logits,
155
+ gates=belt.gates,
156
+ shift_probs=belt.shift_probs,
157
+ )
158
+
159
+
160
+ def compute_unrolled_conveyer_loss(
161
+ model: ConveyerDiffusionLM,
162
+ seq_tokens: torch.Tensor,
163
+ cfg: ConveyerConfig,
164
+ prompt_lengths: Optional[torch.Tensor] = None,
165
+ tick_start: Optional[int] = None,
166
+ pad_token_id: int = 0,
167
+ ) -> UnrollMetrics:
168
+ bsz, seq_len = seq_tokens.shape
169
+ device = seq_tokens.device
170
+ B = model.belt_size
171
+ F_slots = min(model.front_slots, B)
172
+ U = min(cfg.train.unroll_steps, max(1, seq_len - F_slots))
173
+ offsets = model.get_slot_target_offsets(active_slots=B)
174
+
175
+ if tick_start is None:
176
+ max_t0 = max(1, seq_len - F_slots - U + 1)
177
+ if prompt_lengths is not None:
178
+ min_prompt = int(prompt_lengths.min().item())
179
+ max_prompt = int(prompt_lengths.max().item())
180
+ low = max(1, min_prompt - min(U - 2, cfg.infer.prompt_overlap))
181
+ if torch.rand((), device=device).item() < 0.5:
182
+ high = max(low + 1, min(max_t0 + 1, max_prompt + 16))
183
+ else:
184
+ high = max(low + 1, max_t0 + 1)
185
+ tick_start = int(torch.randint(low, high, (1,), device=device).item())
186
+ else:
187
+ tick_start = int(torch.randint(1, max_t0 + 1, (1,), device=device).item())
188
+
189
+ # 1. Causal pass over the full sequence + extract causal KV cache
190
+ prefix_lm_weight = getattr(cfg.train, "prefix_lm_weight", 1.0)
191
+ full_kv: Optional[PrefixKVCache] = None
192
+ if prefix_lm_weight > 0.0 and seq_len > 1:
193
+ causal_logits, full_kv = model.forward_causal_and_cache(seq_tokens, prefix_len=seq_len)
194
+ shift_logits = causal_logits[:, :-1, :].contiguous()
195
+ shift_targets = seq_tokens[:, 1:].contiguous()
196
+ prefix_lm_loss = F.cross_entropy(
197
+ shift_logits.view(-1, model.vocab_size),
198
+ shift_targets.view(-1),
199
+ ignore_index=pad_token_id,
200
+ )
201
+ with torch.no_grad():
202
+ valid_tok = shift_targets != pad_token_id
203
+ n_valid = valid_tok.float().sum().clamp(min=1.0)
204
+ pred_tok = shift_logits.argmax(dim=-1)
205
+ prefix_lm_acc_t = ((pred_tok == shift_targets) & valid_tok).float().sum() / n_valid
206
+ kv_cache = PrefixKVCache(
207
+ k_cache=[k[:, :, :tick_start, :] for k in full_kv.k_cache],
208
+ v_cache=[v[:, :, :tick_start, :] for v in full_kv.v_cache],
209
+ seq_len=tick_start,
210
+ )
211
+ else:
212
+ prefix_tokens = seq_tokens[:, :tick_start]
213
+ kv_cache = model.prefill_prefix(prefix_tokens)
214
+ prefix_lm_loss = torch.tensor(0.0, device=device)
215
+ prefix_lm_acc_t = torch.tensor(0.0, device=device)
216
+
217
+ # 2. Seed initial 64-slot belt at tick_start using slot target offsets
218
+ init_targets, _ = gather_belt_targets(seq_tokens, tick_start, offsets, pad_token_id=pad_token_id)
219
+
220
+ belt = seed_corrupted_belt(
221
+ model=model,
222
+ target_tokens=init_targets,
223
+ tick_start=tick_start,
224
+ sigma_max=cfg.train.seed_sigma_max,
225
+ sigma_eta=cfg.train.seed_sigma_eta,
226
+ cold_drop_prob=0.85,
227
+ prompt_lengths=prompt_lengths,
228
+ )
229
+
230
+ horizon_w = build_horizon_weights(
231
+ belt_size=B,
232
+ w_min=cfg.train.loss_w_min,
233
+ w_beta=cfg.train.loss_w_beta,
234
+ device=device,
235
+ )
236
+
237
+ total_loss = torch.tensor(0.0, device=device)
238
+ prev_logits: Optional[torch.Tensor] = None
239
+ slot0_loss_sum_t = torch.tensor(0.0, device=device)
240
+ slot0_acc_sum_t = torch.tensor(0.0, device=device)
241
+ s0_valid_ticks_t = torch.tensor(0.0, device=device)
242
+ belt_loss_sum_t = torch.tensor(0.0, device=device)
243
+ belt_acc_sum_t = torch.tensor(0.0, device=device)
244
+ gate_sum_t = torch.tensor(0.0, device=device)
245
+ pause_sum_t = torch.tensor(0.0, device=device)
246
+ valid_ticks = 0
247
+ dagger_diverged = False
248
+
249
+ per_slot_correct = torch.zeros(B, device=device)
250
+ per_slot_count = torch.zeros(B, device=device)
251
+ per_slot_entropy_sum = torch.zeros(B, device=device)
252
+
253
+ for u in range(U):
254
+ t = tick_start + u
255
+ if t >= seq_len:
256
+ break
257
+
258
+ targets, in_bounds = gather_belt_targets(seq_tokens, t, offsets, pad_token_id=pad_token_id)
259
+ slot_positions = t + offsets.to(device).unsqueeze(0) # (1, B)
260
+
261
+ # Mid-unroll Belt Recovery Training: occasionally inject wrong/noisy thoughts onto the belt
262
+ # so the next forward pass learns to actively repair the belt!
263
+ if u > 0 and model.training and cfg.train.belt_corrupt_prob > 0.0:
264
+ if torch.rand(()) < 0.35:
265
+ p_mask = (slot_positions < prompt_lengths.unsqueeze(1)) if prompt_lengths is not None else None
266
+ belt = apply_belt_recovery_perturbation(
267
+ model=model,
268
+ belt=belt,
269
+ corrupt_rate=cfg.train.belt_corrupt_prob,
270
+ prompt_mask=p_mask,
271
+ )
272
+
273
+ step_out = model.forward_belt(belt=belt, kv_cache=kv_cache)
274
+
275
+ valid_mask = in_bounds & (targets != pad_token_id) # (N, B)
276
+ if prompt_lengths is not None:
277
+ is_gen = slot_positions >= prompt_lengths.unsqueeze(1)
278
+ valid_mask = valid_mask & is_gen
279
+
280
+ if cfg.model.causal_belt_only:
281
+ ar_mask = torch.zeros_like(valid_mask)
282
+ ar_mask[:, 0] = valid_mask[:, 0]
283
+ valid_mask = ar_mask
284
+
285
+ ce_per_token = F.cross_entropy(
286
+ step_out.logits.reshape(-1, model.vocab_size),
287
+ targets.reshape(-1),
288
+ reduction="none",
289
+ ).view(bsz, B)
290
+
291
+ w_broadcast = horizon_w.unsqueeze(0) * valid_mask.float()
292
+ w_denom = w_broadcast.sum().clamp(min=1e-6)
293
+ tick_ce_loss = (ce_per_token * w_broadcast).sum() / w_denom
294
+
295
+ probs_clamped = step_out.probs.clamp(min=1e-8)
296
+ entropy = -(probs_clamped * probs_clamped.log()).sum(dim=-1)
297
+ if cfg.train.entropy_reg > 0.0 and not cfg.model.causal_belt_only:
298
+ back_weights = (torch.arange(B, device=device, dtype=torch.float32) / max(B - 1, 1)).unsqueeze(0)
299
+ ent_bonus = (entropy * back_weights * valid_mask.float()).sum() / valid_mask.float().sum().clamp(min=1.0)
300
+ tick_loss = tick_ce_loss - cfg.train.entropy_reg * ent_bonus
301
+ else:
302
+ tick_loss = tick_ce_loss
303
+
304
+ # Shift-Consistency on the Front Conveyor Stream (0 .. F_slots - 2)
305
+ if u > 0 and prev_logits is not None and not cfg.model.causal_belt_only and F_slots > 1:
306
+ p_prev = F.softmax(prev_logits[:, 1:F_slots, :].detach(), dim=-1)
307
+ log_p_curr = F.log_softmax(step_out.logits[:, : F_slots - 1, :], dim=-1)
308
+ kl_shift = F.kl_div(log_p_curr, p_prev, reduction="none").sum(dim=-1)
309
+ cons_mask = valid_mask[:, : F_slots - 1].float()
310
+ cons_loss = (kl_shift * cons_mask).sum() / cons_mask.sum().clamp(min=1.0)
311
+ tick_loss = tick_loss + 0.08 * cons_loss
312
+
313
+ prev_logits = step_out.logits
314
+ total_loss = total_loss + tick_loss
315
+ valid_ticks += 1
316
+
317
+ with torch.no_grad():
318
+ valid_f = valid_mask.float()
319
+ preds = step_out.logits.argmax(dim=-1)
320
+ correct = (preds == targets).float() * valid_f
321
+
322
+ s0_valid = valid_f[:, 0]
323
+ s0_sum = s0_valid.sum()
324
+ has_s0 = (s0_sum > 0.0).float()
325
+ s0_denom = s0_sum.clamp(min=1.0)
326
+ slot0_loss_sum_t = slot0_loss_sum_t + has_s0 * ((ce_per_token[:, 0] * s0_valid).sum() / s0_denom)
327
+ slot0_acc_sum_t = slot0_acc_sum_t + has_s0 * (correct[:, 0].sum() / s0_denom)
328
+ s0_valid_ticks_t = s0_valid_ticks_t + has_s0
329
+
330
+ all_denom = valid_f.sum().clamp(min=1.0)
331
+ belt_loss_sum_t = belt_loss_sum_t + (ce_per_token * valid_f).sum() / all_denom
332
+ belt_acc_sum_t = belt_acc_sum_t + correct.sum() / all_denom
333
+ gate_sum_t = gate_sum_t + step_out.gate.mean()
334
+ if B > F_slots:
335
+ pause_sum_t = pause_sum_t + (1.0 - step_out.shift_prob[:, F_slots:]).mean()
336
+
337
+ per_slot_correct += correct.sum(dim=0)
338
+ per_slot_count += valid_f.sum(dim=0)
339
+ per_slot_entropy_sum += entropy.mean(dim=0)
340
+
341
+ if u < U - 1 and (t + 1) < seq_len:
342
+ # Prefix Recovery (DAgger-style Scheduled Sampling on final ticks, or fast slice from full_kv):
343
+ use_dagger = (
344
+ model.training
345
+ and (u >= max(0, U - 4) or full_kv is None)
346
+ and (torch.rand(()) < cfg.train.recovery_prob)
347
+ )
348
+ if use_dagger:
349
+ pred_s0 = step_out.slot0_logits.argmax(dim=-1)
350
+ if prompt_lengths is not None:
351
+ is_p = t < prompt_lengths
352
+ committed_x_t = torch.where(is_p, seq_tokens[:, t], pred_s0)
353
+ else:
354
+ committed_x_t = pred_s0
355
+ dagger_diverged = True
356
+
357
+ if not dagger_diverged and full_kv is not None:
358
+ next_len = t + 1
359
+ kv_cache = PrefixKVCache(
360
+ k_cache=[k[:, :, :next_len, :] for k in full_kv.k_cache],
361
+ v_cache=[v[:, :, :next_len, :] for v in full_kv.v_cache],
362
+ seq_len=next_len,
363
+ )
364
+ else:
365
+ if not use_dagger:
366
+ committed_x_t = seq_tokens[:, t]
367
+ kv_cache = model.append_committed_token(committed_x_t, kv_cache)
368
+
369
+ next_targets, _ = gather_belt_targets(seq_tokens, t + 1, offsets, pad_token_id=pad_token_id)
370
+ if prompt_lengths is not None:
371
+ next_pos = (t + 1) + offsets.to(device).unsqueeze(0)
372
+ clamp_mask = next_pos < prompt_lengths.unsqueeze(1)
373
+ else:
374
+ clamp_mask = None
375
+
376
+ belt = model.shift_belt_left(
377
+ step_out=step_out,
378
+ current_tick=t,
379
+ deterministic_spawn=False,
380
+ clamp_tokens=next_targets if clamp_mask is not None else None,
381
+ clamp_mask=clamp_mask,
382
+ )
383
+ if cfg.train.no_belt_bptt:
384
+ belt = belt.detach()
385
+ kv_cache = kv_cache.detach()
386
+
387
+ denom = max(valid_ticks, 1)
388
+ s0_denom_ticks = float(s0_valid_ticks_t.item()) if float(s0_valid_ticks_t.item()) > 0.0 else 1.0
389
+ per_slot_acc = per_slot_correct / per_slot_count.clamp(min=1.0)
390
+ per_slot_entropy = per_slot_entropy_sum / denom
391
+ conveyer_loss = total_loss / denom
392
+ combined_loss = conveyer_loss + prefix_lm_weight * prefix_lm_loss
393
+
394
+ return UnrollMetrics(
395
+ loss=combined_loss,
396
+ slot0_loss=float(slot0_loss_sum_t.item()) / s0_denom_ticks,
397
+ slot0_acc=float(slot0_acc_sum_t.item()) / s0_denom_ticks,
398
+ belt_mean_loss=float(belt_loss_sum_t.item()) / denom,
399
+ belt_mean_acc=float(belt_acc_sum_t.item()) / denom,
400
+ per_slot_acc=per_slot_acc,
401
+ per_slot_entropy=per_slot_entropy,
402
+ mean_gate=float(gate_sum_t.item()) / denom,
403
+ mean_plan_pause=float(pause_sum_t.item()) / denom,
404
+ prefix_lm_loss=float(prefix_lm_loss.item()),
405
+ prefix_lm_acc=float(prefix_lm_acc_t.item()),
406
+ )
src/conveyer/model.py ADDED
@@ -0,0 +1,739 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Optional
5
+ import torch
6
+ import torch.nn as nn
7
+ import torch.nn.functional as F
8
+ from torch.utils.checkpoint import checkpoint
9
+
10
+ from conveyer.config import ModelConfig
11
+
12
+
13
+ class RMSNorm(nn.Module):
14
+ def __init__(self, d_model: int, eps: float = 1e-6) -> None:
15
+ super().__init__()
16
+ self.eps = eps
17
+ self.weight = nn.Parameter(torch.ones(d_model))
18
+
19
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
20
+ norm = x.float().pow(2).mean(dim=-1, keepdim=True)
21
+ x_normed = x.float() * torch.rsqrt(norm + self.eps)
22
+ return (self.weight * x_normed).type_as(x)
23
+
24
+
25
+ class RotaryEmbedding(nn.Module):
26
+ def __init__(self, head_dim: int, max_seq_len: int = 2048, theta: float = 10000.0) -> None:
27
+ super().__init__()
28
+ assert head_dim % 2 == 0, "head_dim must be even for RoPE"
29
+ self.head_dim = head_dim
30
+ self.max_seq_len = max_seq_len
31
+ self.theta = theta
32
+ inv_freq = 1.0 / (theta ** (torch.arange(0, head_dim, 2).float() / head_dim))
33
+ self.register_buffer("inv_freq", inv_freq, persistent=False)
34
+ self._build_cache(max_seq_len)
35
+
36
+ def _build_cache(self, seq_len: int) -> None:
37
+ self.max_seq_len = max(self.max_seq_len, seq_len)
38
+ t = torch.arange(self.max_seq_len, device=self.inv_freq.device, dtype=torch.float32)
39
+ freqs = torch.outer(t, self.inv_freq) # (seq_len, head_dim // 2)
40
+ cos = torch.cos(freqs)
41
+ sin = torch.sin(freqs)
42
+ self.register_buffer("cos_cached", cos, persistent=False)
43
+ self.register_buffer("sin_cached", sin, persistent=False)
44
+
45
+ def get_cos_sin(self, positions: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
46
+ """positions: (L,) or (N, L) integer positions."""
47
+ max_pos = int(positions.max().item()) + 1 if positions.numel() > 0 else 1
48
+ if max_pos > self.cos_cached.size(0):
49
+ self._build_cache(max_pos + 256)
50
+ cos = self.cos_cached[positions]
51
+ sin = self.sin_cached[positions]
52
+ return cos, sin
53
+
54
+
55
+ def apply_rope(x: torch.Tensor, cos: torch.Tensor, sin: torch.Tensor) -> torch.Tensor:
56
+ """
57
+ x: (N, H, L, D)
58
+ cos, sin: (L, D//2) or (N, L, D//2)
59
+ """
60
+ if cos.dim() == 2:
61
+ cos = cos.unsqueeze(0).unsqueeze(0) # (1, 1, L, D//2)
62
+ sin = sin.unsqueeze(0).unsqueeze(0)
63
+ elif cos.dim() == 3:
64
+ cos = cos.unsqueeze(1) # (N, 1, L, D//2)
65
+ sin = sin.unsqueeze(1)
66
+ d2 = x.shape[-1] // 2
67
+ x1 = x[..., :d2]
68
+ x2 = x[..., d2:]
69
+ out1 = x1 * cos - x2 * sin
70
+ out2 = x1 * sin + x2 * cos
71
+ return torch.cat([out1, out2], dim=-1).type_as(x)
72
+
73
+
74
+ class SwiGLUFFN(nn.Module):
75
+ def __init__(self, d_model: int, d_ff: int, dropout: float = 0.0) -> None:
76
+ super().__init__()
77
+ self.w_gate = nn.Linear(d_model, d_ff, bias=False)
78
+ self.w_up = nn.Linear(d_model, d_ff, bias=False)
79
+ self.w_down = nn.Linear(d_ff, d_model, bias=False)
80
+ self.drop = nn.Dropout(dropout) if dropout > 0 else nn.Identity()
81
+
82
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
83
+ return self.drop(self.w_down(F.silu(self.w_gate(x)) * self.w_up(x)))
84
+
85
+
86
+ @dataclass
87
+ class PrefixKVCache:
88
+ """Stores committed prefix Keys and Values per layer: list of (K, V) each (N, H, P, D_h)."""
89
+ k_cache: list[torch.Tensor]
90
+ v_cache: list[torch.Tensor]
91
+ seq_len: int
92
+
93
+ @classmethod
94
+ def empty(cls, n_layers: int, batch_size: int, n_heads: int, head_dim: int, device: torch.device, dtype: torch.dtype) -> "PrefixKVCache":
95
+ k_cache = [torch.empty((batch_size, n_heads, 0, head_dim), device=device, dtype=dtype) for _ in range(n_layers)]
96
+ v_cache = [torch.empty((batch_size, n_heads, 0, head_dim), device=device, dtype=dtype) for _ in range(n_layers)]
97
+ return cls(k_cache=k_cache, v_cache=v_cache, seq_len=0)
98
+
99
+ def clone(self) -> "PrefixKVCache":
100
+ return PrefixKVCache(
101
+ k_cache=[k.clone() for k in self.k_cache],
102
+ v_cache=[v.clone() for v in self.v_cache],
103
+ seq_len=self.seq_len,
104
+ )
105
+
106
+ def detach(self) -> "PrefixKVCache":
107
+ return PrefixKVCache(
108
+ k_cache=[k.detach() for k in self.k_cache],
109
+ v_cache=[v.detach() for v in self.v_cache],
110
+ seq_len=self.seq_len,
111
+ )
112
+
113
+
114
+ @dataclass
115
+ class BeltState:
116
+ """Holds the 64-slot Dual-Channel + Elastic Planning dough state on the conveyor belt."""
117
+ e_soft: torch.Tensor # (N, B, D) lexical anchor soft embeddings
118
+ m_thought: torch.Tensor # (N, B, D) continuous thought highway vectors
119
+ tick: int # current absolute sequence position of Slot 0
120
+ probs: Optional[torch.Tensor] = None # (N, B, V) last probability distributions
121
+ logits: Optional[torch.Tensor] = None # (N, B, V) last raw logits
122
+ gates: Optional[torch.Tensor] = None # (N, B, D) last update gate activations
123
+ shift_probs: Optional[torch.Tensor] = None # (N, B) elastic shift vs. pause probability s_k
124
+
125
+ def detach(self) -> "BeltState":
126
+ return BeltState(
127
+ e_soft=self.e_soft.detach(),
128
+ m_thought=self.m_thought.detach(),
129
+ tick=self.tick,
130
+ probs=self.probs.detach() if self.probs is not None else None,
131
+ logits=self.logits.detach() if self.logits is not None else None,
132
+ gates=self.gates.detach() if self.gates is not None else None,
133
+ shift_probs=self.shift_probs.detach() if self.shift_probs is not None else None,
134
+ )
135
+
136
+
137
+ @dataclass
138
+ class BeltStepOutput:
139
+ logits: torch.Tensor # (N, B, V) raw vocabulary logits at all 64 slots
140
+ probs: torch.Tensor # (N, B, V) temperature-scaled probabilities p_k
141
+ e_soft: torch.Tensor # (N, B, D) expected vocab embeddings p_k^T E_vocab
142
+ cand_thought: torch.Tensor # (N, B, D) candidate thought vectors c_k
143
+ gate: torch.Tensor # (N, B, D) update gate g_k
144
+ m_updated: torch.Tensor # (N, B, D) updated thought vectors before index shift
145
+ shift_prob: torch.Tensor # (N, B) elastic shift probability s_k (1.0 in front conveyor, <1 in back 75% planning)
146
+ expand_cand: torch.Tensor # (N, D) mid-belt expansion thought vector for forming new sentence clauses
147
+ slot0_logits: torch.Tensor # (N, V) crisp logits at Position 0
148
+
149
+
150
+ class ConveyerAttention(nn.Module):
151
+ def __init__(self, cfg: ModelConfig) -> None:
152
+ super().__init__()
153
+ assert cfg.d_model % cfg.n_heads == 0
154
+ self.n_heads = cfg.n_heads
155
+ self.head_dim = cfg.d_model // cfg.n_heads
156
+ self.d_model = cfg.d_model
157
+ self.qkv = nn.Linear(cfg.d_model, 3 * cfg.d_model, bias=False)
158
+ self.proj = nn.Linear(cfg.d_model, cfg.d_model, bias=False)
159
+ self.dropout = cfg.dropout
160
+
161
+ def forward(
162
+ self,
163
+ x: torch.Tensor,
164
+ cos: torch.Tensor,
165
+ sin: torch.Tensor,
166
+ attn_mask: Optional[torch.Tensor] = None,
167
+ layer_kv: Optional[tuple[torch.Tensor, torch.Tensor]] = None,
168
+ return_new_kv: bool = False,
169
+ is_causal: bool = False,
170
+ ) -> tuple[torch.Tensor, Optional[tuple[torch.Tensor, torch.Tensor]]]:
171
+ """
172
+ x: (N, L, D)
173
+ layer_kv: optional (K_prev, V_prev) each (N, H, P, D_h)
174
+ """
175
+ bsz, seq_len, _ = x.shape
176
+ qkv = self.qkv(x)
177
+ q, k, v = qkv.chunk(3, dim=-1)
178
+ q = q.view(bsz, seq_len, self.n_heads, self.head_dim).transpose(1, 2)
179
+ k = k.view(bsz, seq_len, self.n_heads, self.head_dim).transpose(1, 2)
180
+ v = v.view(bsz, seq_len, self.n_heads, self.head_dim).transpose(1, 2)
181
+
182
+ q = apply_rope(q, cos, sin)
183
+ k = apply_rope(k, cos, sin)
184
+
185
+ new_kv = (k, v) if return_new_kv else None
186
+
187
+ if layer_kv is not None and layer_kv[0].size(2) > 0:
188
+ k = torch.cat([layer_kv[0].type_as(k), k], dim=2)
189
+ v = torch.cat([layer_kv[1].type_as(v), v], dim=2)
190
+
191
+ out = F.scaled_dot_product_attention(
192
+ q,
193
+ k,
194
+ v,
195
+ attn_mask=None if is_causal else attn_mask,
196
+ dropout_p=self.dropout if self.training else 0.0,
197
+ is_causal=is_causal,
198
+ )
199
+ out = out.transpose(1, 2).contiguous().view(bsz, seq_len, self.d_model)
200
+ return self.proj(out), new_kv
201
+
202
+
203
+ class ConveyerBlock(nn.Module):
204
+ def __init__(self, cfg: ModelConfig) -> None:
205
+ super().__init__()
206
+ self.norm1 = RMSNorm(cfg.d_model)
207
+ self.attn = ConveyerAttention(cfg)
208
+ self.norm2 = RMSNorm(cfg.d_model)
209
+ self.ffn = SwiGLUFFN(cfg.d_model, cfg.d_ff, cfg.dropout)
210
+
211
+ def forward(
212
+ self,
213
+ x: torch.Tensor,
214
+ cos: torch.Tensor,
215
+ sin: torch.Tensor,
216
+ attn_mask: Optional[torch.Tensor] = None,
217
+ layer_kv: Optional[tuple[torch.Tensor, torch.Tensor]] = None,
218
+ return_new_kv: bool = False,
219
+ is_causal: bool = False,
220
+ ) -> tuple[torch.Tensor, Optional[tuple[torch.Tensor, torch.Tensor]]]:
221
+ attn_out, new_kv = self.attn(
222
+ self.norm1(x),
223
+ cos=cos,
224
+ sin=sin,
225
+ attn_mask=attn_mask,
226
+ layer_kv=layer_kv,
227
+ return_new_kv=return_new_kv,
228
+ is_causal=is_causal,
229
+ )
230
+ x = x + attn_out
231
+ x = x + self.ffn(self.norm2(x))
232
+ return x, new_kv
233
+
234
+
235
+ class ConveyerDiffusionLM(nn.Module):
236
+ """
237
+ 64-Slot Dual-Channel Elastic Conveyor-Belt Diffusion Language Model.
238
+
239
+ Input / Output Contract at every tick t:
240
+ - Input : [All Committed Text Prefix x_{0..t-1}] + [64-Slot Conveyor/Planning Belt]
241
+ - Output: [Popped-out Confirmed Token x_t at Slot 0] + [Updated 64-Slot Conveyor/Planning Belt]
242
+
243
+ Two-Zone Human-Style Thinking & Speaking Belt:
244
+ - Front 25% (Slots 0 .. F-1, e.g. 0..15): Immediate Articulation Stream.
245
+ Shifts left by 1 slot every tick (s_k = 1.0), polishing immediate grammar toward Slot 0 (alpha_0 = 0).
246
+ - Back 75% (Slots F .. B-1, e.g. 16..63): Elastic Sentence-Planning Horizon.
247
+ Can pause in-place (s_k < 1.0) to hold long-range sentence goals in mind while new sentence clauses
248
+ bud and expand in the middle of the belt (around Slot F).
249
+ """
250
+
251
+ def __init__(self, cfg: ModelConfig) -> None:
252
+ super().__init__()
253
+ self.cfg = cfg
254
+ self.belt_size = cfg.belt_size
255
+ self.front_slots = min(cfg.front_conveyer_slots, cfg.belt_size)
256
+ self.d_model = cfg.d_model
257
+ self.vocab_size = cfg.vocab_size
258
+
259
+ # Token embedding & weight-tied LM head
260
+ self.tok_emb = nn.Embedding(cfg.vocab_size, cfg.d_model)
261
+ self.lm_head = nn.Linear(cfg.d_model, cfg.vocab_size, bias=False)
262
+ self.lm_head.weight = self.tok_emb.weight # weight tying
263
+
264
+ # Learned oven slot embeddings for positions k = 0 .. B-1 on the conveyor belt
265
+ self.slot_emb = nn.Embedding(cfg.belt_size, cfg.d_model)
266
+ # Learned initial thought vector for newly spawned dough at Slot B-1
267
+ self.m_init = nn.Parameter(torch.zeros(cfg.d_model))
268
+
269
+ # Dough input RMSNorm
270
+ self.dough_norm = RMSNorm(cfg.d_model)
271
+
272
+ # Transformer trunk
273
+ self.rope = RotaryEmbedding(
274
+ head_dim=cfg.d_model // cfg.n_heads,
275
+ max_seq_len=cfg.max_seq_len,
276
+ theta=cfg.rope_theta,
277
+ )
278
+ self.blocks = nn.ModuleList([ConveyerBlock(cfg) for _ in range(cfg.n_layers)])
279
+ self.final_norm = RMSNorm(cfg.d_model)
280
+
281
+ # Channel 2: Continuous Thought Highway heads (RecurrentCubeLM style)
282
+ self.thought_cand = nn.Linear(cfg.d_model, cfg.d_model, bias=False)
283
+ self.thought_gate = nn.Linear(cfg.d_model, cfg.d_model, bias=True)
284
+
285
+ # Elastic Planning & Mid-Belt Sentence Expansion Heads
286
+ # shift_head predicts whether each back-75% planning slot shifts left or pauses in place
287
+ self.shift_head = nn.Linear(cfg.d_model, 1, bias=True)
288
+ # expand_head synthesizes a new phrase/clause embedding at the boundary between the
289
+ # paused 75% planning zone and the moving 25% front conveyor
290
+ self.expand_head = nn.Linear(2 * cfg.d_model, cfg.d_model, bias=False)
291
+
292
+ # Precompute positional temperature schedule tau_k, thought mixing alpha_k, and target horizon offsets
293
+ B = cfg.belt_size
294
+ F_slots = self.front_slots
295
+ k_idx = torch.arange(B, dtype=torch.float32)
296
+ ratio = k_idx / max(B - 1, 1)
297
+ tau_schedule = cfg.tau_min + (ratio ** cfg.tau_gamma) * (cfg.tau_max - cfg.tau_min)
298
+ alpha_schedule = ratio.clone() # alpha_0 = 0.0, alpha_{B-1} = 1.0
299
+
300
+ # Target sequence offsets delta_k from current tick t:
301
+ # - Front slots k = 0 .. F-1 map 1-to-1 to immediate tokens t + k
302
+ # - Back 75% planning slots k = F .. B-1 span a wider lookahead horizon (stride >= 1)
303
+ stride = max(1, cfg.plan_horizon_stride) if cfg.use_elastic_planning else 1
304
+ target_offsets = torch.arange(B, dtype=torch.long)
305
+ if B > F_slots and stride > 1:
306
+ back_idx = torch.arange(B - F_slots, dtype=torch.long)
307
+ target_offsets[F_slots:] = F_slots + back_idx * stride
308
+
309
+ # Prior bias for shift_head: front slots always shift (+6.0), back 75% slots default to ~0.35 shift (65% pause)
310
+ shift_prior = torch.zeros(B, dtype=torch.float32)
311
+ shift_prior[:F_slots] = 6.0
312
+ if B > F_slots:
313
+ back_ratio = torch.linspace(0.0, 1.0, B - F_slots)
314
+ shift_prior[F_slots:] = -0.3 - 1.2 * back_ratio # sigmoid(-0.3..-1.5) ~ 0.42 down to 0.18
315
+
316
+ self.register_buffer("tau_schedule", tau_schedule, persistent=False)
317
+ self.register_buffer("alpha_schedule", alpha_schedule, persistent=False)
318
+ self.register_buffer("target_offsets", target_offsets, persistent=False)
319
+ self.register_buffer("shift_prior", shift_prior, persistent=False)
320
+
321
+ self._init_weights()
322
+
323
+ def _init_weights(self) -> None:
324
+ nn.init.normal_(self.tok_emb.weight, mean=0.0, std=0.02)
325
+ nn.init.normal_(self.slot_emb.weight, mean=0.0, std=0.02)
326
+ nn.init.normal_(self.m_init, mean=0.0, std=0.02)
327
+ for m in self.modules():
328
+ if isinstance(m, nn.Linear) and m is not self.lm_head:
329
+ nn.init.normal_(m.weight, mean=0.0, std=0.02)
330
+ if m.bias is not None:
331
+ nn.init.zeros_(m.bias)
332
+ nn.init.constant_(self.thought_gate.bias, self.cfg.gate_bias_init)
333
+ nn.init.zeros_(self.shift_head.weight)
334
+ nn.init.zeros_(self.shift_head.bias)
335
+
336
+ def num_parameters(self, trainable_only: bool = True) -> int:
337
+ return sum(p.numel() for p in self.parameters() if (p.requires_grad or not trainable_only))
338
+
339
+ def get_slot_target_offsets(self, active_slots: Optional[int] = None) -> torch.Tensor:
340
+ B = active_slots if active_slots is not None else self.belt_size
341
+ return self.target_offsets[:B]
342
+
343
+ def init_prefix_cache(self, batch_size: int, device: torch.device, dtype: Optional[torch.dtype] = None) -> PrefixKVCache:
344
+ if dtype is None:
345
+ dtype = self.tok_emb.weight.dtype
346
+ return PrefixKVCache.empty(
347
+ n_layers=self.cfg.n_layers,
348
+ batch_size=batch_size,
349
+ n_heads=self.cfg.n_heads,
350
+ head_dim=self.cfg.d_model // self.cfg.n_heads,
351
+ device=device,
352
+ dtype=dtype,
353
+ )
354
+
355
+ def prefill_prefix(
356
+ self,
357
+ prefix_tokens: torch.Tensor,
358
+ kv_cache: Optional[PrefixKVCache] = None,
359
+ ) -> PrefixKVCache:
360
+ """
361
+ Encodes committed prefix tokens x_{t_start .. t_end} into the causal PrefixKVCache.
362
+ prefix_tokens: (N, P) long tensor.
363
+ """
364
+ bsz, plen = prefix_tokens.shape
365
+ device = prefix_tokens.device
366
+ if kv_cache is None:
367
+ kv_cache = self.init_prefix_cache(bsz, device)
368
+ if plen == 0:
369
+ return kv_cache
370
+
371
+ start_pos = kv_cache.seq_len
372
+ positions = torch.arange(start_pos, start_pos + plen, device=device, dtype=torch.long)
373
+ cos, sin = self.rope.get_cos_sin(positions)
374
+
375
+ slot0_bias = self.slot_emb.weight[0].view(1, 1, -1)
376
+ x = self.dough_norm(self.tok_emb(prefix_tokens) + slot0_bias)
377
+
378
+ total_k = start_pos + plen
379
+ use_is_causal = start_pos == 0 and plen > 1
380
+ if use_is_causal or plen == 1:
381
+ attn_mask = None
382
+ else:
383
+ causal_mask = torch.ones((plen, total_k), device=device, dtype=torch.bool).tril(diagonal=start_pos)
384
+ attn_mask = causal_mask.unsqueeze(0).unsqueeze(0)
385
+
386
+ new_k_list: list[torch.Tensor] = []
387
+ new_v_list: list[torch.Tensor] = []
388
+ for i, block in enumerate(self.blocks):
389
+ layer_kv = (kv_cache.k_cache[i], kv_cache.v_cache[i])
390
+ x, new_kv = block(
391
+ x,
392
+ cos=cos,
393
+ sin=sin,
394
+ attn_mask=attn_mask,
395
+ layer_kv=layer_kv,
396
+ return_new_kv=True,
397
+ is_causal=use_is_causal,
398
+ )
399
+ assert new_kv is not None
400
+ new_k_list.append(torch.cat([kv_cache.k_cache[i].type_as(new_kv[0]), new_kv[0]], dim=2))
401
+ new_v_list.append(torch.cat([kv_cache.v_cache[i].type_as(new_kv[1]), new_kv[1]], dim=2))
402
+
403
+ return PrefixKVCache(k_cache=new_k_list, v_cache=new_v_list, seq_len=total_k)
404
+
405
+ def forward_causal_and_cache(
406
+ self,
407
+ seq_tokens: torch.Tensor,
408
+ prefix_len: int,
409
+ ) -> tuple[torch.Tensor, PrefixKVCache]:
410
+ """
411
+ Runs a full causal pass over `seq_tokens` (N, L) through the shared transformer blocks:
412
+ 1. Computes next-token logits `(N, L, V)` at every position so all ~160 tokens per story
413
+ receive direct causal language-modeling supervision (10x more signal per step).
414
+ 2. Extracts the exact causal KV cache for `seq_tokens[:, :prefix_len]` (invariant to tokens >= prefix_len)
415
+ to seed the conveyor belt unroll without running a duplicate prefill pass.
416
+ """
417
+ bsz, seq_len = seq_tokens.shape
418
+ device = seq_tokens.device
419
+ positions = torch.arange(0, seq_len, device=device, dtype=torch.long)
420
+ cos, sin = self.rope.get_cos_sin(positions)
421
+
422
+ slot0_bias = self.slot_emb.weight[0].view(1, 1, -1)
423
+ x = self.dough_norm(self.tok_emb(seq_tokens) + slot0_bias)
424
+
425
+ k_list: list[torch.Tensor] = []
426
+ v_list: list[torch.Tensor] = []
427
+ for block in self.blocks:
428
+ x, new_kv = block(
429
+ x,
430
+ cos=cos,
431
+ sin=sin,
432
+ attn_mask=None,
433
+ layer_kv=None,
434
+ return_new_kv=True,
435
+ is_causal=(seq_len > 1),
436
+ )
437
+ assert new_kv is not None
438
+ k_list.append(new_kv[0][:, :, :prefix_len, :].contiguous())
439
+ v_list.append(new_kv[1][:, :, :prefix_len, :].contiguous())
440
+
441
+ h = self.final_norm(x)
442
+ causal_logits = self.lm_head(h)
443
+ kv_cache = PrefixKVCache(k_cache=k_list, v_cache=v_list, seq_len=prefix_len)
444
+ return causal_logits, kv_cache
445
+
446
+ def append_committed_token(
447
+ self,
448
+ token_ids: torch.Tensor,
449
+ kv_cache: PrefixKVCache,
450
+ ) -> PrefixKVCache:
451
+ if token_ids.dim() == 1:
452
+ token_ids = token_ids.unsqueeze(1)
453
+ return self.prefill_prefix(token_ids, kv_cache=kv_cache)
454
+
455
+ def spawn_raw_dough(
456
+ self,
457
+ batch_size: int,
458
+ num_slots: int,
459
+ device: torch.device,
460
+ deterministic: bool = False,
461
+ ) -> tuple[torch.Tensor, torch.Tensor]:
462
+ mean_emb = self.tok_emb.weight.mean(dim=0, keepdim=True).unsqueeze(0)
463
+ m_base = self.m_init.view(1, 1, -1)
464
+ if deterministic or self.cfg.spawn_noise_std <= 0.0:
465
+ e_new = mean_emb.expand(batch_size, num_slots, self.d_model).clone()
466
+ m_new = m_base.expand(batch_size, num_slots, self.d_model).clone()
467
+ else:
468
+ eps_e = torch.randn((batch_size, num_slots, self.d_model), device=device, dtype=mean_emb.dtype)
469
+ eps_m = torch.randn((batch_size, num_slots, self.d_model), device=device, dtype=mean_emb.dtype)
470
+ e_new = mean_emb + self.cfg.spawn_noise_std * 0.02 * eps_e
471
+ m_new = m_base + self.cfg.thought_noise_std * 0.02 * eps_m
472
+ return e_new, m_new
473
+
474
+ def init_empty_belt(
475
+ self,
476
+ batch_size: int,
477
+ tick: int,
478
+ device: torch.device,
479
+ deterministic: bool = False,
480
+ ) -> BeltState:
481
+ e_soft, m_thought = self.spawn_raw_dough(
482
+ batch_size=batch_size,
483
+ num_slots=self.belt_size,
484
+ device=device,
485
+ deterministic=deterministic,
486
+ )
487
+ return BeltState(e_soft=e_soft, m_thought=m_thought, tick=tick)
488
+
489
+ def compose_dough_inputs(self, belt: BeltState, active_slots: Optional[int] = None) -> torch.Tensor:
490
+ B = active_slots if active_slots is not None else self.belt_size
491
+ e_soft = belt.e_soft[:, :B, :]
492
+ m_thought = belt.m_thought[:, :B, :]
493
+ slots = torch.arange(B, device=e_soft.device, dtype=torch.long)
494
+ s_emb = self.slot_emb(slots).unsqueeze(0)
495
+
496
+ if self.cfg.use_thought_highway:
497
+ alpha = self.alpha_schedule[:B].view(1, B, 1) # alpha_0 == 0.0
498
+ fused = e_soft + alpha * m_thought + s_emb
499
+ else:
500
+ fused = e_soft + s_emb
501
+
502
+ return self.dough_norm(fused)
503
+
504
+ def _build_belt_mask(
505
+ self,
506
+ belt_len: int,
507
+ prefix_len: int,
508
+ device: torch.device,
509
+ ) -> Optional[torch.Tensor]:
510
+ total_k = prefix_len + belt_len
511
+ if not self.cfg.causal_belt_only:
512
+ return torch.ones((1, 1, belt_len, total_k), device=device, dtype=torch.bool)
513
+ mask = torch.ones((belt_len, total_k), device=device, dtype=torch.bool).tril(diagonal=prefix_len)
514
+ return mask.unsqueeze(0).unsqueeze(0)
515
+
516
+ def forward_belt(
517
+ self,
518
+ belt: BeltState,
519
+ kv_cache: Optional[PrefixKVCache] = None,
520
+ active_slots: Optional[int] = None,
521
+ ) -> BeltStepOutput:
522
+ """
523
+ Single forward pass across all active slots on the belt:
524
+ Input : [Committed Prefix KV Cache x_{0..t-1}] + [Belt Dough 0..B-1]
525
+ Output: [Slot 0 Token Logits + Updated Belt State (Lexical + Thought + Elastic Shift Gate)]
526
+ """
527
+ B = active_slots if active_slots is not None else self.belt_size
528
+ dough = self.compose_dough_inputs(belt, active_slots=B)
529
+ device = dough.device
530
+
531
+ prefix_len = kv_cache.seq_len if kv_cache is not None else 0
532
+ offsets = self.get_slot_target_offsets(active_slots=B)
533
+ positions = belt.tick + offsets
534
+ cos, sin = self.rope.get_cos_sin(positions)
535
+
536
+ attn_mask = (
537
+ None
538
+ if not self.cfg.causal_belt_only
539
+ else self._build_belt_mask(belt_len=B, prefix_len=prefix_len, device=device)
540
+ )
541
+
542
+ h = dough
543
+ use_ckpt = self.training and self.cfg.grad_checkpointing
544
+ for i, block in enumerate(self.blocks):
545
+ layer_kv = (kv_cache.k_cache[i], kv_cache.v_cache[i]) if kv_cache is not None else None
546
+ if use_ckpt:
547
+ def _run_block(inp: torch.Tensor, blk: ConveyerBlock = block, lkv: Optional[tuple[torch.Tensor, torch.Tensor]] = layer_kv) -> torch.Tensor:
548
+ out_h, _ = blk(inp, cos=cos, sin=sin, attn_mask=attn_mask, layer_kv=lkv, return_new_kv=False)
549
+ return out_h
550
+ h = checkpoint(_run_block, h, use_reentrant=False)
551
+ else:
552
+ h, _ = block(
553
+ h,
554
+ cos=cos,
555
+ sin=sin,
556
+ attn_mask=attn_mask,
557
+ layer_kv=layer_kv,
558
+ return_new_kv=False,
559
+ )
560
+
561
+ h = self.final_norm(h) # (N, B, D)
562
+
563
+ # Channel 1: Lexical Anchor (Soft Probability Projection)
564
+ logits = self.lm_head(h) # (N, B, V)
565
+ tau = self.tau_schedule[:B].view(1, B, 1).type_as(logits)
566
+ probs = F.softmax((logits / tau).float(), dim=-1).type_as(self.tok_emb.weight)
567
+ e_soft = torch.matmul(probs, self.tok_emb.weight) # (N, B, D)
568
+
569
+ # Channel 2: Continuous Thought Highway (Gated Residual Update)
570
+ cand = self.thought_cand(h)
571
+ gate = torch.sigmoid(self.thought_gate(h))
572
+ m_prev = belt.m_thought[:, :B, :]
573
+ if self.cfg.use_thought_highway:
574
+ m_updated = gate * m_prev + (1.0 - gate) * cand
575
+ else:
576
+ m_updated = torch.zeros_like(m_prev)
577
+
578
+ # Elastic Planning Shift/Pause Gate & Mid-Belt Sentence Expansion
579
+ F_slots = min(self.front_slots, B)
580
+ if self.cfg.use_elastic_planning and B > F_slots:
581
+ prior = self.shift_prior[:B].view(1, B).type_as(h)
582
+ raw_shift = self.shift_head(h).squeeze(-1) + prior # (N, B)
583
+ shift_prob = torch.sigmoid(raw_shift)
584
+ # Front conveyor slots (0 .. F-1) always shift left at full speed (1.0)
585
+ front_ones = torch.ones((h.size(0), F_slots), device=device, dtype=shift_prob.dtype)
586
+ shift_prob = torch.cat([front_ones, shift_prob[:, F_slots:]], dim=1)
587
+ # Mid-belt expansion thought synthesized from the boundary between Front (F-1) and Planning (F)
588
+ boundary_pair = torch.cat([h[:, F_slots - 1, :], h[:, F_slots, :]], dim=-1)
589
+ expand_cand = self.expand_head(boundary_pair) # (N, D)
590
+ else:
591
+ shift_prob = torch.ones((h.size(0), B), device=device, dtype=h.dtype)
592
+ expand_cand = h[:, -1, :]
593
+
594
+ return BeltStepOutput(
595
+ logits=logits,
596
+ probs=probs,
597
+ e_soft=e_soft,
598
+ cand_thought=cand,
599
+ gate=gate,
600
+ m_updated=m_updated,
601
+ shift_prob=shift_prob,
602
+ expand_cand=expand_cand,
603
+ slot0_logits=logits[:, 0, :],
604
+ )
605
+
606
+ def shift_belt_left(
607
+ self,
608
+ step_out: BeltStepOutput,
609
+ current_tick: int,
610
+ deterministic_spawn: bool = False,
611
+ clamp_tokens: Optional[torch.Tensor] = None,
612
+ clamp_mask: Optional[torch.Tensor] = None,
613
+ ) -> BeltState:
614
+ """
615
+ Updates the belt for tick t + 1:
616
+ 1. Front Conveyor Zone (Slots 0 .. F-2):
617
+ Always shifts left by 1 slot (`1..F-1 -> 0..F-2`) so fully-baked tokens flow smoothly out of Slot 0.
618
+ 2. Mid-Belt Sentence Expansion Zone (Slot F-1, e.g. Slot 15):
619
+ Blends the advancing plan from Slot F with the newly expanded clause (`expand_cand`),
620
+ allowing new sentences and words to form in the middle while the back 75% pauses!
621
+ 3. Back 75% Elastic Planning Zone (Slots F .. B-2, e.g. 16..62):
622
+ Each slot k updates elastically using its learned shift probability `s_k`:
623
+ e'_{k} = s_k * e_{k+1} + (1 - s_k) * e_k
624
+ m'_{k} = s_k * m_{k+1} + (1 - s_k) * m_k
625
+ When `s_k` is low (~0.25), the back 75% of the belt pauses in-place holding the long-range
626
+ sentence plan steady while the front conveyor articulates the current sentence!
627
+ 4. Slot B-1 (Slot 63):
628
+ Blends fresh raw dough into the tail of the planning belt.
629
+ """
630
+ bsz, B, _ = step_out.e_soft.shape
631
+ device = step_out.e_soft.device
632
+ F_slots = min(self.front_slots, B)
633
+
634
+ e_spawn, m_spawn = self.spawn_raw_dough(
635
+ batch_size=bsz,
636
+ num_slots=1,
637
+ device=device,
638
+ deterministic=deterministic_spawn,
639
+ )
640
+
641
+ if B <= 1:
642
+ e_next = e_spawn
643
+ m_next = m_spawn
644
+ probs_next = torch.full((bsz, 1, self.vocab_size), 1.0 / self.vocab_size, device=device, dtype=step_out.probs.dtype)
645
+ logits_next = torch.zeros((bsz, 1, self.vocab_size), device=device, dtype=step_out.logits.dtype)
646
+ gates_next = torch.ones((bsz, 1, self.d_model), device=device, dtype=step_out.gate.dtype)
647
+ elif (not self.cfg.use_elastic_planning) or B <= F_slots:
648
+ # Strict 1-by-1 shift across all B slots
649
+ e_next = torch.cat([step_out.e_soft[:, 1:, :], e_spawn], dim=1)
650
+ m_next = torch.cat([step_out.m_updated[:, 1:, :], m_spawn], dim=1)
651
+ probs_next = torch.cat(
652
+ [
653
+ step_out.probs[:, 1:, :],
654
+ torch.full((bsz, 1, self.vocab_size), 1.0 / self.vocab_size, device=device, dtype=step_out.probs.dtype),
655
+ ],
656
+ dim=1,
657
+ )
658
+ logits_next = torch.cat(
659
+ [
660
+ step_out.logits[:, 1:, :],
661
+ torch.zeros((bsz, 1, self.vocab_size), device=device, dtype=step_out.logits.dtype),
662
+ ],
663
+ dim=1,
664
+ )
665
+ gates_next = torch.cat(
666
+ [
667
+ step_out.gate[:, 1:, :],
668
+ torch.ones((bsz, 1, self.d_model), device=device, dtype=step_out.gate.dtype),
669
+ ],
670
+ dim=1,
671
+ )
672
+ else:
673
+ # Elastic Two-Zone Update:
674
+ # (a) Front slots 0 .. F-2 shift strictly from 1 .. F-1
675
+ e_front = step_out.e_soft[:, 1:F_slots, :]
676
+ m_front = step_out.m_updated[:, 1:F_slots, :]
677
+
678
+ # (b) Mid-Belt Expansion Slot (F-1): where paused back-belt plans expand into new active words
679
+ s_mid = step_out.shift_prob[:, F_slots : F_slots + 1].unsqueeze(-1) # (N, 1, 1)
680
+ e_FromPlan = step_out.e_soft[:, F_slots : F_slots + 1, :]
681
+ m_FromPlan = step_out.m_updated[:, F_slots : F_slots + 1, :]
682
+ m_expand = step_out.expand_cand.unsqueeze(1) # (N, 1, D)
683
+ e_mid = e_FromPlan
684
+ m_mid = s_mid * m_FromPlan + (1.0 - s_mid) * m_expand
685
+
686
+ # (c) Back 75% Planning Slots (F .. B-2): elastic interpolation between staying (k) and shifting (k+1)
687
+ s_back = step_out.shift_prob[:, F_slots : B - 1].unsqueeze(-1) # (N, B - 1 - F, 1)
688
+ e_stay = step_out.e_soft[:, F_slots : B - 1, :]
689
+ e_adv = step_out.e_soft[:, F_slots + 1 : B, :]
690
+ e_back = s_back * e_adv + (1.0 - s_back) * e_stay
691
+
692
+ m_stay = step_out.m_updated[:, F_slots : B - 1, :]
693
+ m_adv = step_out.m_updated[:, F_slots + 1 : B, :]
694
+ m_back = s_back * m_adv + (1.0 - s_back) * m_stay
695
+
696
+ # (d) Slot B-1 (Tail of planning belt): elastic blend between holding Slot B-1 and fresh raw dough
697
+ s_tail = step_out.shift_prob[:, B - 1 : B].unsqueeze(-1)
698
+ e_tail = s_tail * e_spawn + (1.0 - s_tail) * step_out.e_soft[:, B - 1 : B, :]
699
+ m_tail = s_tail * m_spawn + (1.0 - s_tail) * step_out.m_updated[:, B - 1 : B, :]
700
+
701
+ e_next = torch.cat([e_front, e_mid, e_back, e_tail], dim=1)
702
+ m_next = torch.cat([m_front, m_mid, m_back, m_tail], dim=1)
703
+
704
+ probs_next = torch.cat(
705
+ [
706
+ step_out.probs[:, 1:, :],
707
+ torch.full((bsz, 1, self.vocab_size), 1.0 / self.vocab_size, device=device, dtype=step_out.probs.dtype),
708
+ ],
709
+ dim=1,
710
+ )
711
+ logits_next = torch.cat(
712
+ [
713
+ step_out.logits[:, 1:, :],
714
+ torch.zeros((bsz, 1, self.vocab_size), device=device, dtype=step_out.logits.dtype),
715
+ ],
716
+ dim=1,
717
+ )
718
+ gates_next = torch.cat(
719
+ [
720
+ step_out.gate[:, 1:, :],
721
+ torch.ones((bsz, 1, self.d_model), device=device, dtype=step_out.gate.dtype),
722
+ ],
723
+ dim=1,
724
+ )
725
+
726
+ if clamp_tokens is not None and clamp_mask is not None:
727
+ clamped_emb = self.tok_emb(clamp_tokens)
728
+ mask_exp = clamp_mask.unsqueeze(-1)
729
+ e_next = torch.where(mask_exp, clamped_emb, e_next)
730
+
731
+ return BeltState(
732
+ e_soft=e_next,
733
+ m_thought=m_next,
734
+ tick=current_tick + 1,
735
+ probs=probs_next,
736
+ logits=logits_next,
737
+ gates=gates_next,
738
+ shift_probs=step_out.shift_prob,
739
+ )
src/conveyer/sweep.py ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Any
7
+ import torch
8
+
9
+ from conveyer.checkpoint import load_checkpoint
10
+ from conveyer.config import load_config
11
+ from conveyer.data import build_dataset_and_tokenizer
12
+ from conveyer.eval import evaluate_closed_loop_generation
13
+ from conveyer.train import train_model
14
+
15
+
16
+ def main() -> None:
17
+ parser = argparse.ArgumentParser(
18
+ description="Head-to-Head Benchmark: 1-Pass AR Baseline vs. 64-Pass Conveyor Diffusion LLM & Ablations"
19
+ )
20
+ parser.add_argument("--config", type=str, default="configs/synthetic.yaml", help="Base config file")
21
+ parser.add_argument("--steps", type=int, default=400, help="Training steps per variant")
22
+ parser.add_argument("--episodes", type=int, default=64, help="Closed-loop evaluation episodes")
23
+ parser.add_argument("--out-dir", type=str, default="runs/sweep", help="Output directory for sweep")
24
+ args = parser.parse_args()
25
+
26
+ variants = [
27
+ {
28
+ "name": "1_ar_baseline",
29
+ "label": "1-Pass Causal AR Baseline (B=1)",
30
+ "overrides": ["train.ar_baseline=true", "train.unroll_steps=8"],
31
+ "bake_steps": 1,
32
+ "overlap": 0,
33
+ },
34
+ {
35
+ "name": "2_conveyer_soft_only",
36
+ "label": "64-Slot Conveyor (Soft-Vocab Only, alpha_k=0)",
37
+ "overrides": ["train.no_thought_highway=true"],
38
+ "bake_steps": 64,
39
+ "overlap": 16,
40
+ },
41
+ {
42
+ "name": "3_conveyer_nobptt",
43
+ "label": "64-Slot Conveyor (Detached W=1, No Belt BPTT)",
44
+ "overrides": ["train.no_belt_bptt=true"],
45
+ "bake_steps": 64,
46
+ "overlap": 16,
47
+ },
48
+ {
49
+ "name": "4_conveyer_dual_full",
50
+ "label": "64-Slot Dual-Channel Conveyor (Soft-Vocab + Gated Thought + BPTT)",
51
+ "overrides": [],
52
+ "bake_steps": 64,
53
+ "overlap": 16,
54
+ },
55
+ ]
56
+
57
+ summary: list[dict[str, Any]] = []
58
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
59
+
60
+ for var in variants:
61
+ run_dir = Path(args.out_dir) / var["name"]
62
+ overrides = [
63
+ f"train.max_steps={args.steps}",
64
+ f"train.eval_every={max(50, args.steps // 2)}",
65
+ f"train.out_dir={run_dir.as_posix()}",
66
+ ] + var["overrides"]
67
+
68
+ print("\n" + "=" * 90)
69
+ print(f"Training Variant: {var['label']}")
70
+ print("=" * 90)
71
+ cfg = load_config(args.config, overrides)
72
+ val_m = train_model(cfg)
73
+
74
+ ckpt_path = run_dir / "best.pt"
75
+ model, loaded_cfg, _ = load_checkpoint(ckpt_path, device=device)
76
+ _, val_ds, tokenizer = build_dataset_and_tokenizer(loaded_cfg)
77
+
78
+ cl_metrics = evaluate_closed_loop_generation(
79
+ model=model,
80
+ val_ds=val_ds,
81
+ tokenizer=tokenizer,
82
+ device=device,
83
+ num_episodes=args.episodes,
84
+ prompt_overlap=var["overlap"],
85
+ bake_steps=var["bake_steps"],
86
+ )
87
+ row = {
88
+ "name": var["name"],
89
+ "label": var["label"],
90
+ "val_slot0_acc": val_m.get("val_slot0_acc", 0.0),
91
+ "val_slot0_ppl": val_m.get("val_slot0_ppl", 0.0),
92
+ **cl_metrics,
93
+ }
94
+ summary.append(row)
95
+
96
+ print("\n" + "=" * 100)
97
+ print(f"{'Model / Ablation Variant':<56} | {'Pos-0 Acc':<10} | {'Token Acc':<10} | {'Exact Seq':<10}")
98
+ print("-" * 100)
99
+ for r in summary:
100
+ print(
101
+ f"{r['label']:<56} | {r['first_token_acc']*100:8.2f}% | "
102
+ f"{r['token_acc']*100:8.2f}% | {r['exact_seq_acc']*100:8.2f}%"
103
+ )
104
+ print("=" * 100)
105
+
106
+ out_path = Path(args.out_dir) / "sweep_summary.json"
107
+ out_path.parent.mkdir(parents=True, exist_ok=True)
108
+ out_path.write_text(json.dumps(summary, indent=2), encoding="utf-8")
109
+
110
+
111
+ if __name__ == "__main__":
112
+ main()
src/conveyer/train.py ADDED
@@ -0,0 +1,472 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import math
6
+ import os
7
+ from pathlib import Path
8
+ import subprocess
9
+ import time
10
+ from typing import Any, Optional
11
+ import torch
12
+ from torch.utils.data import DataLoader, Subset
13
+
14
+ from conveyer.checkpoint import load_checkpoint_payload, save_checkpoint
15
+ from conveyer.config import ConveyerConfig, load_config
16
+ from conveyer.data import TokenizerProtocol, build_dataset_and_tokenizer, collate_conveyer_batch
17
+ from conveyer.infer import generate_conveyer
18
+ from conveyer.losses import compute_unrolled_conveyer_loss
19
+ from conveyer.model import ConveyerDiffusionLM
20
+
21
+
22
+ def _query_gpu_temp_c() -> Optional[int]:
23
+ try:
24
+ out = subprocess.check_output(
25
+ ["nvidia-smi", "--query-gpu=temperature.gpu", "--format=csv,noheader,nounits"],
26
+ timeout=2.0,
27
+ stderr=subprocess.DEVNULL,
28
+ )
29
+ return int(out.decode("utf-8", errors="ignore").strip().splitlines()[0])
30
+ except Exception:
31
+ return None
32
+
33
+
34
+ def get_lr(step: int, max_steps: int, base_lr: float, warmup_steps: int) -> float:
35
+ if step < warmup_steps:
36
+ return base_lr * float(step + 1) / float(max(1, warmup_steps))
37
+ progress = float(step - warmup_steps) / float(max(1, max_steps - warmup_steps))
38
+ progress = min(max(progress, 0.0), 1.0)
39
+ return base_lr * (0.1 + 0.9 * 0.5 * (1.0 + math.cos(math.pi * progress)))
40
+
41
+
42
+ @torch.no_grad()
43
+ def evaluate_validation(
44
+ model: ConveyerDiffusionLM,
45
+ val_loader: DataLoader[dict[str, torch.Tensor]],
46
+ cfg: ConveyerConfig,
47
+ device: torch.device,
48
+ pad_id: int = 0,
49
+ max_batches: int = 25,
50
+ ) -> dict[str, Any]:
51
+ model.eval()
52
+ B = model.belt_size
53
+ loss_sum = 0.0
54
+ s0_loss_sum = 0.0
55
+ s0_acc_sum = 0.0
56
+ lm_loss_sum = 0.0
57
+ lm_acc_sum = 0.0
58
+ belt_acc_sum = 0.0
59
+ per_slot_acc_sum = torch.zeros(B, device=device)
60
+ per_slot_ent_sum = torch.zeros(B, device=device)
61
+ count = 0
62
+
63
+ for i, batch in enumerate(val_loader):
64
+ if i >= max_batches:
65
+ break
66
+ seq = batch["input_ids"].to(device)
67
+ plen = batch["prompt_lengths"].to(device)
68
+ t_eval = max(1, int(plen.min().item()) - min(4, cfg.train.unroll_steps // 2))
69
+ use_amp = device.type == "cuda" and torch.cuda.is_bf16_supported()
70
+ with torch.autocast(device_type=device.type, dtype=torch.bfloat16 if use_amp else torch.float32, enabled=use_amp):
71
+ m = compute_unrolled_conveyer_loss(
72
+ model=model,
73
+ seq_tokens=seq,
74
+ cfg=cfg,
75
+ prompt_lengths=plen,
76
+ tick_start=t_eval,
77
+ pad_token_id=pad_id,
78
+ )
79
+ loss_sum += float(m.loss.item())
80
+ s0_loss_sum += m.slot0_loss
81
+ s0_acc_sum += m.slot0_acc
82
+ lm_loss_sum += m.prefix_lm_loss
83
+ lm_acc_sum += m.prefix_lm_acc
84
+ belt_acc_sum += m.belt_mean_acc
85
+ per_slot_acc_sum += m.per_slot_acc
86
+ per_slot_ent_sum += m.per_slot_entropy
87
+ count += 1
88
+
89
+ model.train()
90
+ denom = max(count, 1)
91
+ s0_ce = s0_loss_sum / denom
92
+ lm_ce = lm_loss_sum / denom
93
+ return {
94
+ "val_loss": loss_sum / denom,
95
+ "val_slot0_ce": s0_ce,
96
+ "val_slot0_ppl": math.exp(min(s0_ce, 20.0)),
97
+ "val_slot0_acc": s0_acc_sum / denom,
98
+ "val_lm_ce": lm_ce,
99
+ "val_lm_ppl": math.exp(min(lm_ce, 20.0)),
100
+ "val_lm_acc": lm_acc_sum / denom,
101
+ "val_belt_acc": belt_acc_sum / denom,
102
+ "val_per_slot_acc": (per_slot_acc_sum / denom).cpu().tolist(),
103
+ "val_per_slot_entropy": (per_slot_ent_sum / denom).cpu().tolist(),
104
+ }
105
+
106
+
107
+ def _prune_old_milestones(out_dir: Path, keep_last: int = 8) -> None:
108
+ milestones = sorted(out_dir.glob("ckpt_step_*.pt"), key=lambda p: p.stat().st_mtime)
109
+ if len(milestones) > keep_last:
110
+ for old_p in milestones[: len(milestones) - keep_last]:
111
+ try:
112
+ old_p.unlink()
113
+ prev_p = old_p.with_suffix(old_p.suffix + ".prev")
114
+ if prev_p.exists():
115
+ prev_p.unlink()
116
+ except OSError:
117
+ pass
118
+
119
+
120
+ @torch.no_grad()
121
+ def _sample_preview(
122
+ model: ConveyerDiffusionLM,
123
+ tokenizer: TokenizerProtocol,
124
+ device: torch.device,
125
+ prompt_text: str,
126
+ max_new_tokens: int = 32,
127
+ ) -> str:
128
+ model.eval()
129
+ p_ids = torch.tensor([tokenizer.encode(prompt_text.strip())], dtype=torch.long, device=device)
130
+ out_ids, _ = generate_conveyer(
131
+ model=model,
132
+ prompt_tokens=p_ids,
133
+ max_new_tokens=max_new_tokens,
134
+ prompt_overlap=10,
135
+ temperature=0.0,
136
+ eos_token_id=tokenizer.eos_id,
137
+ )
138
+ gen_part = tokenizer.decode(out_ids[0, p_ids.size(1) :].tolist())
139
+ model.train()
140
+ return gen_part.replace("\n", " ").strip()
141
+
142
+
143
+ def _sanitize_jsonl(log_path: Path) -> None:
144
+ """Strips any trailing NUL bytes left by an abrupt OS crash so the log remains valid JSONL."""
145
+ if not log_path.exists():
146
+ return
147
+ raw = log_path.read_bytes().replace(b"\x00", b"")
148
+ lines = [ln for ln in raw.splitlines() if ln.strip().startswith(b"{") and ln.strip().endswith(b"}")]
149
+ log_path.write_bytes(b"\n".join(lines) + (b"\n" if lines else b""))
150
+
151
+
152
+ def train_model(
153
+ cfg: ConveyerConfig,
154
+ init_ckpt: str | Path | None = None,
155
+ resume_ckpt: str | Path | None = None,
156
+ ) -> dict[str, Any]:
157
+ torch.manual_seed(cfg.train.seed)
158
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
159
+
160
+ train_ds, val_ds, tokenizer = build_dataset_and_tokenizer(cfg)
161
+ if cfg.train.overfit_batches > 0:
162
+ n_sub = min(len(train_ds), cfg.train.overfit_batches * cfg.train.batch_size)
163
+ train_ds = Subset(train_ds, list(range(n_sub)))
164
+ val_ds = train_ds
165
+
166
+ collate_fn = lambda b: collate_conveyer_batch(b, pad_id=tokenizer.pad_id)
167
+ train_loader = DataLoader(
168
+ train_ds,
169
+ batch_size=cfg.train.batch_size,
170
+ shuffle=(cfg.train.overfit_batches == 0),
171
+ collate_fn=collate_fn,
172
+ drop_last=False,
173
+ )
174
+ val_loader = DataLoader(
175
+ val_ds,
176
+ batch_size=cfg.train.batch_size,
177
+ shuffle=False,
178
+ collate_fn=collate_fn,
179
+ drop_last=False,
180
+ )
181
+
182
+ model = ConveyerDiffusionLM(cfg.model).to(device)
183
+ optimizer = torch.optim.AdamW(
184
+ model.parameters(),
185
+ lr=cfg.train.lr,
186
+ weight_decay=cfg.train.weight_decay,
187
+ betas=(0.9, 0.95),
188
+ )
189
+
190
+ out_dir = Path(cfg.train.out_dir)
191
+ out_dir.mkdir(parents=True, exist_ok=True)
192
+ log_jsonl = out_dir / "train_log.jsonl"
193
+ _sanitize_jsonl(log_jsonl)
194
+
195
+ start_step = 1
196
+ best_val_loss = float("inf")
197
+ prior_elapsed = 0.0
198
+
199
+ # Resolve resume vs warm-start initialization (with automatic fallback to .prev or ckpt_step_*.pt)
200
+ resume_target: Optional[Path] = None
201
+ if resume_ckpt is not None:
202
+ if str(resume_ckpt).lower() == "auto":
203
+ resume_target = out_dir / "last.pt"
204
+ else:
205
+ resume_target = Path(resume_ckpt)
206
+
207
+ if resume_target is not None:
208
+ try:
209
+ ckpt_data, used_path = load_checkpoint_payload(resume_target, map_location="cpu")
210
+ model.load_state_dict(ckpt_data["model_state"], strict=False)
211
+ if "optimizer_state" in ckpt_data:
212
+ try:
213
+ optimizer.load_state_dict(ckpt_data["optimizer_state"])
214
+ except Exception as e:
215
+ print(f"[ConveyerTrain] Warning: could not restore optimizer state ({e})", flush=True)
216
+ start_step = int(ckpt_data.get("step", 0)) + 1
217
+ best_val_loss = float(
218
+ ckpt_data.get("best_val_loss") or ckpt_data.get("metrics", {}).get("val_loss", float("inf"))
219
+ )
220
+ prior_elapsed = float(ckpt_data.get("elapsed_sec", 0.0))
221
+ print(
222
+ f"[ConveyerTrain] Resumed from {used_path} at step {start_step} "
223
+ f"(best_val_loss={best_val_loss:.4f}, prior_elapsed={prior_elapsed/3600:.2f}h)",
224
+ flush=True,
225
+ )
226
+ del ckpt_data
227
+ if device.type == "cuda":
228
+ torch.cuda.empty_cache()
229
+ except Exception as e:
230
+ print(f"[ConveyerTrain] No readable resume checkpoint in {out_dir} ({e}); starting from step 1.", flush=True)
231
+ elif init_ckpt is not None and Path(init_ckpt).exists():
232
+ ckpt_data, used_path = load_checkpoint_payload(init_ckpt, map_location="cpu")
233
+ model.load_state_dict(ckpt_data["model_state"], strict=False)
234
+ print(f"[ConveyerTrain] Warm-started weights from {used_path}", flush=True)
235
+ del ckpt_data
236
+
237
+ use_amp = device.type == "cuda" and torch.cuda.is_bf16_supported()
238
+ amp_dtype = torch.bfloat16 if use_amp else torch.float32
239
+
240
+ mode_desc = (
241
+ "1-Pass AR Baseline"
242
+ if cfg.model.causal_belt_only
243
+ else (
244
+ "64-Slot Conveyor (Soft-Vocab Only)"
245
+ if not cfg.model.use_thought_highway
246
+ else "64-Slot Dual-Channel Conveyor (Soft-Vocab + Gated Thought)"
247
+ )
248
+ )
249
+ max_hours = float(getattr(cfg.train, "max_hours", 0.0))
250
+ save_every = max(1, int(getattr(cfg.train, "save_every", 500)))
251
+ milestone_every = save_every * 2
252
+
253
+ print(
254
+ f"[ConveyerTrain] Mode: {mode_desc} | Params: {model.num_parameters()/1e6:.2f}M | "
255
+ f"Device: {device} | Train stories: {len(train_ds):,} | Val stories: {len(val_ds):,} | "
256
+ f"Unroll U={cfg.train.unroll_steps} | Belt B={cfg.model.belt_size} | "
257
+ f"max_steps={cfg.train.max_steps:,} | max_hours={max_hours:.1f}h | save_every={save_every}",
258
+ flush=True,
259
+ )
260
+
261
+ train_iter = iter(train_loader)
262
+ last_val_metrics: dict[str, Any] = {}
263
+ t0 = time.time()
264
+
265
+ model.train()
266
+ for step in range(start_step, cfg.train.max_steps + 1):
267
+ try:
268
+ batch = next(train_iter)
269
+ except StopIteration:
270
+ train_iter = iter(train_loader)
271
+ batch = next(train_iter)
272
+
273
+ lr = get_lr(step - 1, cfg.train.max_steps, cfg.train.lr, cfg.train.warmup_steps)
274
+ for pg in optimizer.param_groups:
275
+ pg["lr"] = lr
276
+
277
+ seq = batch["input_ids"].to(device)
278
+ plen = batch["prompt_lengths"].to(device)
279
+
280
+ optimizer.zero_grad(set_to_none=True)
281
+ with torch.autocast(device_type=device.type, dtype=amp_dtype, enabled=use_amp):
282
+ metrics = compute_unrolled_conveyer_loss(
283
+ model=model,
284
+ seq_tokens=seq,
285
+ cfg=cfg,
286
+ prompt_lengths=plen,
287
+ pad_token_id=tokenizer.pad_id,
288
+ )
289
+ metrics.loss.backward()
290
+ if cfg.train.grad_clip > 0.0:
291
+ torch.nn.utils.clip_grad_norm_(model.parameters(), cfg.train.grad_clip)
292
+ optimizer.step()
293
+
294
+ cooldown_ms = int(getattr(cfg.train, "step_cooldown_ms", 0))
295
+ if cooldown_ms > 0:
296
+ if device.type == "cuda":
297
+ torch.cuda.synchronize()
298
+ time.sleep(cooldown_ms / 1000.0)
299
+
300
+ total_elapsed = prior_elapsed + (time.time() - t0)
301
+
302
+ if step % cfg.train.log_every == 0 or step == start_step:
303
+ acc_back = float(metrics.per_slot_acc[-1].item())
304
+ acc_mid = float(metrics.per_slot_acc[model.belt_size // 2].item())
305
+ steps_done = max(1, step - start_step + 1)
306
+ sec_per_step = (time.time() - t0) / steps_done
307
+ gpu_temp = _query_gpu_temp_c() if device.type == "cuda" else None
308
+ temp_str = f" | {gpu_temp}C" if gpu_temp is not None else ""
309
+ print(
310
+ f"step {step:6d}/{cfg.train.max_steps} | loss {metrics.loss.item():.4f} | "
311
+ f"lm_ce {metrics.prefix_lm_loss:.3f} (acc {metrics.prefix_lm_acc:.3f}) | "
312
+ f"s0_ce {metrics.slot0_loss:.3f} (acc {metrics.slot0_acc:.3f}) | "
313
+ f"belt(0/m/63) {metrics.slot0_acc:.2f}/{acc_mid:.2f}/{acc_back:.2f} | "
314
+ f"pause {metrics.mean_plan_pause*100:.0f}% | lr {lr:.2e} | "
315
+ f"{sec_per_step:.2f}s/it{temp_str} | {total_elapsed/3600:.2f}h",
316
+ flush=True,
317
+ )
318
+ max_temp = int(getattr(cfg.train, "max_gpu_temp_c", 80))
319
+ if gpu_temp is not None and max_temp > 0 and gpu_temp >= max_temp:
320
+ torch.cuda.synchronize()
321
+ while True:
322
+ print(
323
+ f" [THERMAL GUARD] GPU at {gpu_temp}C (>= {max_temp}C limit); pausing 8s to let fans cool GPU...",
324
+ flush=True,
325
+ )
326
+ time.sleep(8.0)
327
+ gpu_temp = _query_gpu_temp_c()
328
+ if gpu_temp is None or gpu_temp <= max_temp - 5:
329
+ print(f" [THERMAL GUARD] GPU cooled to {gpu_temp}C; resuming training.", flush=True)
330
+ break
331
+
332
+ is_time_up = max_hours > 0.0 and (total_elapsed >= max_hours * 3600.0)
333
+ is_eval_step = (step % cfg.train.eval_every == 0) or (step == cfg.train.max_steps) or is_time_up
334
+ is_save_step = (step % save_every == 0) or is_eval_step
335
+
336
+ if is_eval_step:
337
+ if device.type == "cuda":
338
+ torch.cuda.empty_cache()
339
+ val_m = evaluate_validation(
340
+ model=model,
341
+ val_loader=val_loader,
342
+ cfg=cfg,
343
+ device=device,
344
+ pad_id=tokenizer.pad_id,
345
+ )
346
+ last_val_metrics = val_m
347
+ curve = val_m["val_per_slot_acc"]
348
+ B = len(curve)
349
+ print(
350
+ f" [VAL step {step:6d} | {total_elapsed/3600:.2f}h] "
351
+ f"val_loss {val_m['val_loss']:.4f} | "
352
+ f"real_s0_ppl {val_m['val_slot0_ppl']:.2f} | real_s0_acc {val_m['val_slot0_acc']:.3f} | "
353
+ f"real_lm_ppl {val_m['val_lm_ppl']:.2f} | real_lm_acc {val_m['val_lm_acc']:.3f} | "
354
+ f"crystallization(63->32->8->0): "
355
+ f"{curve[B-1]:.2f}->{curve[B//2]:.2f}->{curve[min(8, B-1)]:.2f}->{curve[0]:.2f}",
356
+ flush=True,
357
+ )
358
+ if cfg.data.dataset == "tinystories":
359
+ sample_p = (
360
+ "Once upon a time, Lily and a little bunny walked into the green forest "
361
+ "to find the golden key near the locked treasure chest. Suddenly a big lion"
362
+ )
363
+ preview = _sample_preview(model, tokenizer, device, sample_p, max_new_tokens=28)
364
+ print(f" [SAMPLE] ...Suddenly a big lion -> {preview}", flush=True)
365
+ else:
366
+ preview = ""
367
+
368
+ if val_m["val_loss"] <= best_val_loss:
369
+ best_val_loss = val_m["val_loss"]
370
+ save_checkpoint(
371
+ out_dir / "best.pt",
372
+ model,
373
+ cfg,
374
+ step=step,
375
+ optimizer=None, # Keep best.pt lightweight (~115 MB) for fast inference & fewer disk writes
376
+ metrics=val_m,
377
+ best_val_loss=best_val_loss,
378
+ elapsed_sec=total_elapsed,
379
+ )
380
+
381
+ log_entry = {
382
+ "step": step,
383
+ "elapsed_hours": round(total_elapsed / 3600.0, 4),
384
+ "val_loss": round(val_m["val_loss"], 4),
385
+ "val_slot0_ce": round(val_m["val_slot0_ce"], 4),
386
+ "val_slot0_ppl": round(val_m["val_slot0_ppl"], 2),
387
+ "val_slot0_acc": round(val_m["val_slot0_acc"], 4),
388
+ "val_lm_ce": round(val_m["val_lm_ce"], 4),
389
+ "val_lm_ppl": round(val_m["val_lm_ppl"], 2),
390
+ "val_lm_acc": round(val_m["val_lm_acc"], 4),
391
+ "val_belt_acc": round(val_m["val_belt_acc"], 4),
392
+ "sample_lion": preview,
393
+ }
394
+ with open(log_jsonl, "a", encoding="utf-8") as lf:
395
+ lf.write(json.dumps(log_entry) + "\n")
396
+ lf.flush()
397
+ os.fsync(lf.fileno())
398
+
399
+ if is_save_step:
400
+ save_checkpoint(
401
+ out_dir / "last.pt",
402
+ model,
403
+ cfg,
404
+ step=step,
405
+ optimizer=optimizer,
406
+ metrics=last_val_metrics,
407
+ best_val_loss=best_val_loss,
408
+ elapsed_sec=total_elapsed,
409
+ )
410
+ if step % milestone_every == 0:
411
+ save_checkpoint(
412
+ out_dir / f"ckpt_step_{step:06d}.pt",
413
+ model,
414
+ cfg,
415
+ step=step,
416
+ optimizer=optimizer,
417
+ metrics=last_val_metrics,
418
+ best_val_loss=best_val_loss,
419
+ elapsed_sec=total_elapsed,
420
+ )
421
+ _prune_old_milestones(out_dir, keep_last=8)
422
+ if device.type == "cuda":
423
+ torch.cuda.empty_cache()
424
+
425
+ if is_time_up:
426
+ print(
427
+ f"[ConveyerTrain] Reached max_hours={max_hours:.2f}h budget at step {step}. "
428
+ f"Checkpoints saved to {out_dir}.",
429
+ flush=True,
430
+ )
431
+ break
432
+
433
+ return last_val_metrics
434
+
435
+
436
+ def main() -> None:
437
+ parser = argparse.ArgumentParser(description="Train Conveyor-Belt Diffusion LLM")
438
+ parser.add_argument("--config", type=str, default="configs/synthetic.yaml", help="Path to YAML config")
439
+ parser.add_argument("--init", type=str, default=None, help="Optional checkpoint (.pt) to warm-start weights from")
440
+ parser.add_argument(
441
+ "--resume",
442
+ type=str,
443
+ default=None,
444
+ help="Checkpoint path (.pt) or 'auto' to resume full training state (model + optimizer + step) after a crash",
445
+ )
446
+ parser.add_argument("--max_hours", type=float, default=None, help="Wall-clock training budget in hours (e.g. 24)")
447
+ parser.add_argument("--overfit", type=int, default=0, help="Overfit on N fixed batches for verification")
448
+ parser.add_argument("--no_thought_highway", action="store_true", help="Disable continuous thought highway (alpha_k = 0)")
449
+ parser.add_argument("--no_belt_bptt", action="store_true", help="Detach conveyor belt state between unrolled ticks (W=1)")
450
+ parser.add_argument("--ar_baseline", action="store_true", help="Train 1-pass causal Autoregressive baseline")
451
+ parser.add_argument("overrides", nargs="*", help="Config overrides in section.key=value format")
452
+ args = parser.parse_args()
453
+
454
+ cfg = load_config(args.config, args.overrides)
455
+ if args.max_hours is not None:
456
+ cfg.train.max_hours = args.max_hours
457
+ if args.overfit > 0:
458
+ cfg.train.overfit_batches = args.overfit
459
+ if args.no_thought_highway:
460
+ cfg.train.no_thought_highway = True
461
+ cfg.model.use_thought_highway = False
462
+ if args.no_belt_bptt:
463
+ cfg.train.no_belt_bptt = True
464
+ if args.ar_baseline:
465
+ cfg.train.ar_baseline = True
466
+ cfg.model.causal_belt_only = True
467
+
468
+ train_model(cfg, init_ckpt=args.init, resume_ckpt=args.resume)
469
+
470
+
471
+ if __name__ == "__main__":
472
+ main()
src/conveyer/web_sim.py ADDED
@@ -0,0 +1,840 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ from http.server import BaseHTTPRequestHandler, HTTPServer
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ from typing import Any
9
+ from urllib.parse import parse_qs, urlparse
10
+ import torch
11
+ from torch.utils.data import DataLoader
12
+
13
+ from conveyer.checkpoint import load_checkpoint
14
+ from conveyer.config import load_config
15
+ from conveyer.data import build_dataset_and_tokenizer, collate_conveyer_batch
16
+ from conveyer.eval import evaluate_crystallization_curve
17
+ from conveyer.infer import generate_conveyer
18
+ from conveyer.model import ConveyerDiffusionLM
19
+
20
+ HTML_PAGE = """<!DOCTYPE html>
21
+ <html lang="en">
22
+ <head>
23
+ <meta charset="UTF-8">
24
+ <title>Conveyor-Belt Diffusion LLM — Elastic Planning & Oven Simulator</title>
25
+ <style>
26
+ :root {
27
+ --bg: #0b0f17;
28
+ --panel: #151b26;
29
+ --panel-alt: #1b2332;
30
+ --border: #2a3447;
31
+ --text: #e6edf3;
32
+ --muted: #8b949e;
33
+ --front: #2ea043;
34
+ --front-glow: rgba(46, 160, 67, 0.25);
35
+ --mid: #d29922;
36
+ --back: #8957e5;
37
+ }
38
+ * { box-sizing: border-box; }
39
+ body {
40
+ margin: 0;
41
+ padding: 18px 24px 32px 24px;
42
+ background: var(--bg);
43
+ color: var(--text);
44
+ font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
45
+ }
46
+ .header-row {
47
+ display: flex;
48
+ justify-content: space-between;
49
+ align-items: flex-start;
50
+ gap: 16px;
51
+ margin-bottom: 14px;
52
+ }
53
+ h1 { margin: 0 0 4px 0; font-size: 22px; letter-spacing: -0.3px; }
54
+ .subtitle { color: var(--muted); font-size: 13px; }
55
+ .badge-row { display: flex; gap: 8px; flex-wrap: wrap; }
56
+ .metric-pill {
57
+ background: var(--panel-alt);
58
+ border: 1px solid var(--border);
59
+ border-radius: 6px;
60
+ padding: 6px 12px;
61
+ font-size: 12px;
62
+ }
63
+ .metric-pill strong { color: #3fb950; font-size: 14px; margin-left: 4px; }
64
+
65
+ .prompt-card {
66
+ background: var(--panel);
67
+ border: 1px solid var(--border);
68
+ border-radius: 10px;
69
+ padding: 14px 16px;
70
+ margin-bottom: 14px;
71
+ }
72
+ .prompt-top {
73
+ display: flex;
74
+ justify-content: space-between;
75
+ align-items: center;
76
+ margin-bottom: 8px;
77
+ flex-wrap: wrap;
78
+ gap: 8px;
79
+ }
80
+ .prompt-top label { font-size: 12px; font-weight: 600; color: var(--muted); text-transform: uppercase; }
81
+ .presets { display: flex; gap: 6px; flex-wrap: wrap; }
82
+ .preset-btn {
83
+ background: var(--panel-alt);
84
+ color: var(--text);
85
+ border: 1px solid var(--border);
86
+ border-radius: 5px;
87
+ padding: 4px 10px;
88
+ font-size: 12px;
89
+ cursor: pointer;
90
+ }
91
+ .preset-btn:hover { border-color: #58a6ff; }
92
+ .prompt-input-row {
93
+ display: flex;
94
+ gap: 10px;
95
+ align-items: center;
96
+ flex-wrap: wrap;
97
+ }
98
+ #promptInput {
99
+ flex: 1;
100
+ min-width: 320px;
101
+ background: #080b11;
102
+ color: var(--text);
103
+ border: 1px solid var(--border);
104
+ border-radius: 6px;
105
+ padding: 10px 12px;
106
+ font-size: 14px;
107
+ }
108
+ .knob {
109
+ display: flex;
110
+ flex-direction: column;
111
+ font-size: 11px;
112
+ color: var(--muted);
113
+ gap: 3px;
114
+ }
115
+ .knob input {
116
+ width: 72px;
117
+ background: #080b11;
118
+ color: var(--text);
119
+ border: 1px solid var(--border);
120
+ border-radius: 5px;
121
+ padding: 6px 8px;
122
+ font-size: 13px;
123
+ }
124
+ .btn-primary {
125
+ background: #238636;
126
+ color: #fff;
127
+ border: none;
128
+ border-radius: 6px;
129
+ padding: 10px 18px;
130
+ cursor: pointer;
131
+ font-weight: 600;
132
+ font-size: 14px;
133
+ }
134
+ .btn-primary:hover { background: #2ea043; }
135
+ .btn-secondary {
136
+ background: var(--panel-alt);
137
+ color: var(--text);
138
+ border: 1px solid var(--border);
139
+ border-radius: 6px;
140
+ padding: 8px 14px;
141
+ cursor: pointer;
142
+ font-weight: 600;
143
+ font-size: 13px;
144
+ }
145
+
146
+ .story-grid {
147
+ display: grid;
148
+ grid-template-columns: 2fr 1fr;
149
+ gap: 14px;
150
+ margin-bottom: 14px;
151
+ }
152
+ @media (max-width: 1000px) { .story-grid { grid-template-columns: 1fr; } }
153
+ .card {
154
+ background: var(--panel);
155
+ border: 1px solid var(--border);
156
+ border-radius: 10px;
157
+ padding: 14px 16px;
158
+ }
159
+ .card h3 {
160
+ margin: 0 0 10px 0;
161
+ font-size: 12px;
162
+ color: var(--muted);
163
+ text-transform: uppercase;
164
+ letter-spacing: 0.5px;
165
+ display: flex;
166
+ justify-content: space-between;
167
+ align-items: center;
168
+ }
169
+ .story-box {
170
+ font-size: 17px;
171
+ line-height: 1.65;
172
+ background: #080b11;
173
+ border: 1px solid var(--border);
174
+ border-radius: 8px;
175
+ padding: 14px 16px;
176
+ min-height: 112px;
177
+ }
178
+ .story-prompt { color: #a5d6ff; }
179
+ .story-gen { color: #3fb950; font-weight: 600; }
180
+ .story-latest {
181
+ background: rgba(63, 185, 80, 0.22);
182
+ border-bottom: 2px solid #3fb950;
183
+ padding: 1px 4px;
184
+ border-radius: 4px;
185
+ }
186
+ .story-upcoming { color: #8b949e; font-style: italic; font-size: 14px; }
187
+
188
+ .cryst-row {
189
+ display: flex;
190
+ justify-content: space-between;
191
+ font-size: 12px;
192
+ padding: 5px 0;
193
+ border-bottom: 1px solid #1f2735;
194
+ }
195
+ .cryst-bar-wrap {
196
+ width: 100px;
197
+ height: 8px;
198
+ background: #080b11;
199
+ border-radius: 4px;
200
+ overflow: hidden;
201
+ display: inline-block;
202
+ vertical-align: middle;
203
+ margin-left: 8px;
204
+ }
205
+ .cryst-bar { height: 100%; background: #3fb950; }
206
+
207
+ .oven-card {
208
+ background: var(--panel);
209
+ border: 2px solid var(--border);
210
+ border-radius: 12px;
211
+ padding: 16px;
212
+ }
213
+ .oven-controls {
214
+ display: flex;
215
+ align-items: center;
216
+ gap: 14px;
217
+ margin-bottom: 12px;
218
+ flex-wrap: wrap;
219
+ }
220
+ .conveyor-track-wrapper {
221
+ position: relative;
222
+ background: #070a0f;
223
+ border: 2px solid #283246;
224
+ border-radius: 10px;
225
+ padding: 14px 12px;
226
+ overflow-x: auto;
227
+ }
228
+ .conveyor-belt-row {
229
+ display: flex;
230
+ gap: 8px;
231
+ align-items: stretch;
232
+ min-width: max-content;
233
+ }
234
+ .dough-ball {
235
+ width: 108px;
236
+ flex-shrink: 0;
237
+ background: #101622;
238
+ border: 1px solid var(--border);
239
+ border-radius: 8px;
240
+ padding: 8px;
241
+ }
242
+ .dough-ball.slot-zero {
243
+ width: 128px;
244
+ background: rgba(46, 160, 67, 0.14);
245
+ border: 2px solid #3fb950;
246
+ box-shadow: 0 0 14px var(--front-glow);
247
+ }
248
+ .dough-ball.zone-front { border-top: 3px solid var(--front); }
249
+ .dough-ball.zone-mid { border-top: 3px solid var(--mid); }
250
+ .dough-ball.zone-back { border-top: 3px solid var(--back); }
251
+
252
+ .ball-top {
253
+ display: flex;
254
+ justify-content: space-between;
255
+ font-size: 10px;
256
+ color: var(--muted);
257
+ margin-bottom: 4px;
258
+ }
259
+ .ball-word {
260
+ font-size: 15px;
261
+ font-weight: 700;
262
+ color: var(--text);
263
+ white-space: nowrap;
264
+ overflow: hidden;
265
+ text-overflow: ellipsis;
266
+ margin-bottom: 5px;
267
+ }
268
+ .ball-cand {
269
+ font-size: 10px;
270
+ color: var(--muted);
271
+ white-space: nowrap;
272
+ overflow: hidden;
273
+ text-overflow: ellipsis;
274
+ line-height: 1.35;
275
+ }
276
+ .conf-bar {
277
+ height: 4px;
278
+ background: #1f2735;
279
+ border-radius: 2px;
280
+ margin-top: 6px;
281
+ overflow: hidden;
282
+ }
283
+ .conf-fill { height: 100%; background: #3fb950; }
284
+ .zone-legend {
285
+ display: flex;
286
+ gap: 18px;
287
+ font-size: 12px;
288
+ color: var(--muted);
289
+ margin-top: 10px;
290
+ flex-wrap: wrap;
291
+ }
292
+ .dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 5px; vertical-align: middle; }
293
+ </style>
294
+ </head>
295
+ <body>
296
+
297
+ <div class="header-row">
298
+ <div>
299
+ <h1>64-Slot Dual-Channel Elastic Conveyor-Belt Diffusion LLM</h1>
300
+ <div class="subtitle" id="modelSubtitle">Loading model info...</div>
301
+ </div>
302
+ <div class="badge-row">
303
+ <div class="metric-pill">Model Params: <strong id="paramBadge">--</strong></div>
304
+ <div class="metric-pill">Slot-0 Val Accuracy: <strong id="s0AccBadge">--</strong></div>
305
+ <div class="metric-pill">Back 75% Planning Pause: <strong id="pauseBadge">--</strong></div>
306
+ <div class="metric-pill">Sample Token Match: <strong id="sampleAccBadge">--</strong></div>
307
+ </div>
308
+ </div>
309
+
310
+ <!-- 1. Prompt Input & Controls -->
311
+ <div class="prompt-card">
312
+ <div class="prompt-top">
313
+ <label>1. Enter Any Initial Text Prompt (Press Enter or Click "Bake &amp; Continue Text"):</label>
314
+ <div class="presets">
315
+ <button class="preset-btn" onclick="setPreset(0)">Preset 1: Lion in the Forest</button>
316
+ <button class="preset-btn" onclick="setPreset(1)">Preset 2: Robot &amp; Lost Kitten</button>
317
+ <button class="preset-btn" onclick="setPreset(2)">Preset 3: Melting Snowman</button>
318
+ <button class="preset-btn" onclick="setPreset(3)">Preset 4: Missing Blue Crayon</button>
319
+ </div>
320
+ </div>
321
+ <div class="prompt-input-row">
322
+ <input type="text" id="promptInput" onkeydown="if(event.key==='Enter'){runGenerate(-1);}" value="Once upon a time, Lily and a little bunny walked into the green forest to find the golden key near the locked treasure chest. Suddenly a big lion" />
323
+ <div class="knob">
324
+ <span>Pre-Bake Overlap (W)</span>
325
+ <input type="number" id="overlapInput" value="12" min="0" max="60" />
326
+ </div>
327
+ <div class="knob">
328
+ <span>Oven Slots (B)</span>
329
+ <input type="number" id="bakeInput" value="64" min="1" max="64" />
330
+ </div>
331
+ <div class="knob">
332
+ <span>Generate Tokens</span>
333
+ <input type="number" id="tokensInput" value="32" min="6" max="64" />
334
+ </div>
335
+ <div class="knob">
336
+ <span>Temp (&tau;)</span>
337
+ <input type="number" id="tempInput" value="0.0" step="0.1" min="0.0" max="1.5" />
338
+ </div>
339
+ <button class="btn-primary" id="bakeBtn" onclick="runGenerate(-1)">Bake &amp; Continue Text</button>
340
+ </div>
341
+ </div>
342
+
343
+ <!-- 2. Live Text Continuation + Accuracy Panel -->
344
+ <div class="story-grid">
345
+ <div class="card">
346
+ <h3>
347
+ <span>2. Live Text Continuation (Blue = Input Prompt &bull; Green = Popped Out of Slot 0)</span>
348
+ <span id="tickModeLabel" style="color:#3fb950;"></span>
349
+ </h3>
350
+ <div class="story-box">
351
+ <span id="storyPromptSpan" class="story-prompt"></span><span id="storyGenSpan" class="story-gen"></span><span id="storyLatestSpan" class="story-latest" style="display:none;"></span>
352
+ <span id="storyPreviewSpan" class="story-upcoming"></span>
353
+ </div>
354
+ <div id="groundTruthBox" style="margin-top:10px; font-size:12px; color:var(--muted);"></div>
355
+ </div>
356
+
357
+ <div class="card">
358
+ <h3>
359
+ <span>Oven Crystallization &amp; Accuracy</span>
360
+ <span style="font-size:11px; color:#58a6ff;">Back (k=63) &rarr; Front (k=0)</span>
361
+ </h3>
362
+ <div id="crystTable"></div>
363
+ </div>
364
+ </div>
365
+
366
+ <!-- 3. Bottom Conveyor Belt Oven -->
367
+ <div class="oven-card">
368
+ <div class="oven-controls">
369
+ <strong style="font-size:13px; text-transform:uppercase; color:var(--muted);">3. Live 64-Slot Conveyor &amp; Planning Belt (Bottom):</strong>
370
+ <button class="btn-secondary" onclick="stepTick(-1)">&larr; Prev Tick</button>
371
+ <button class="btn-primary" id="playBtn" onclick="togglePlay()" style="padding:7px 16px;">Pause</button>
372
+ <button class="btn-secondary" onclick="stepTick(1)">Next Tick &rarr;</button>
373
+ <label style="font-size:12px; color:var(--muted); display:flex; align-items:center; gap:8px; flex:1; min-width:220px;">
374
+ <span id="scrubberLabel">Tick 0/0</span>
375
+ <input type="range" id="tickSlider" min="0" max="0" value="0" style="flex:1;" oninput="onScrub(this.value)" />
376
+ </label>
377
+ <label style="font-size:12px; color:var(--muted);">
378
+ Speed:
379
+ <select id="speedSelect" onchange="updateSpeed()" style="background:#080b11; color:var(--text); border:1px solid var(--border); padding:4px; border-radius:4px;">
380
+ <option value="450">Slow (2.2 Hz)</option>
381
+ <option value="240" selected>Normal (4 Hz)</option>
382
+ <option value="110">Fast (9 Hz)</option>
383
+ </select>
384
+ </label>
385
+ </div>
386
+
387
+ <div class="conveyor-track-wrapper">
388
+ <div id="conveyorRow" class="conveyor-belt-row"></div>
389
+ </div>
390
+
391
+ <div class="zone-legend">
392
+ <span><i class="dot" style="background:#3fb950;"></i><strong>Slot 0 (Front Exit, &alpha;<sub>0</sub> = 0):</strong> Confirmed Token Popping Out</span>
393
+ <span><i class="dot" style="background:#2ea043;"></i><strong>Slots 1..15 (Front 25% Conveyor):</strong> Shifts Left Every Tick (100% Speed)</span>
394
+ <span><i class="dot" style="background:#d29922;"></i><strong>Slot 15..20 (Mid-Belt Expansion):</strong> New Clauses Form Here</span>
395
+ <span><i class="dot" style="background:#8957e5;"></i><strong>Slots 16..63 (Back 75% Elastic Planning):</strong> Pauses in Place Holding Long-Range Sentence Plan</span>
396
+ </div>
397
+ </div>
398
+
399
+ <script>
400
+ const PRESETS = [
401
+ "Once upon a time, Lily and a little bunny walked into the green forest to find the golden key near the locked treasure chest. Suddenly a big lion",
402
+ "Once upon a time, there was a little robot named Beep who lived in a noisy workshop. One rainy morning, Beep found a tiny lost kitten by the door. Beep",
403
+ "One winter day, Max made a snowman in the yard with a carrot nose. When the warm sun came out in the afternoon, the snowman began to",
404
+ "Lucy had a box of bright crayons, but she could not find the blue crayon to color the sky. She looked under her bed and"
405
+ ];
406
+
407
+ let traceData = null;
408
+ let curFrame = 0;
409
+ let playing = true;
410
+ let intervalMs = 240;
411
+ let timerHandle = null;
412
+
413
+ function setPreset(idx) {
414
+ const inp = document.getElementById('promptInput');
415
+ inp.value = PRESETS[idx];
416
+ runGenerate(-1);
417
+ }
418
+
419
+ async function runGenerate(sampleOverride = -1) {
420
+ const btn = document.getElementById('bakeBtn');
421
+ btn.textContent = "Baking...";
422
+ const promptText = document.getElementById('promptInput').value.trim();
423
+ const ov = document.getElementById('overlapInput').value;
424
+ const bs = document.getElementById('bakeInput').value;
425
+ const mt = document.getElementById('tokensInput').value;
426
+ const tp = document.getElementById('tempInput').value;
427
+
428
+ let url = `/api/trace?overlap=${ov}&bake_steps=${bs}&tokens=${mt}&temp=${tp}&_t=${Date.now()}`;
429
+ if (sampleOverride >= 0 || promptText === "") {
430
+ url += `&sample=${Math.max(0, sampleOverride)}`;
431
+ } else {
432
+ url += `&prompt=${encodeURIComponent(promptText)}`;
433
+ }
434
+
435
+ const resp = await fetch(url, { cache: "no-store" });
436
+ traceData = await resp.json();
437
+ btn.textContent = "Bake & Continue Text";
438
+
439
+ if (sampleOverride >= 0 || promptText === "") {
440
+ document.getElementById('promptInput').value = traceData.prompt;
441
+ }
442
+
443
+ document.getElementById('modelSubtitle').textContent =
444
+ `Checkpoint: ${traceData.ckpt_name} | Tokenizer: Tiktoken BPE (${traceData.vocab_size} vocab) | Input: [All Prefix + 64 Belt] -> Output: [1 Token + 64 Belt]`;
445
+ document.getElementById('paramBadge').textContent = `${traceData.params_m}M`;
446
+ document.getElementById('s0AccBadge').textContent = `${(traceData.val_slot0_acc * 100).toFixed(1)}%`;
447
+ document.getElementById('sampleAccBadge').textContent =
448
+ traceData.sample_token_acc !== null ? `${(traceData.sample_token_acc * 100).toFixed(1)}%` : "Custom Input";
449
+
450
+ renderCrystallizationTable(traceData.crystallization);
451
+
452
+ // Jump straight to the first live emitted token tick so the user immediately sees their prompt continuation
453
+ let firstEmitIdx = 0;
454
+ for (let i = 0; i < traceData.snapshots.length; i++) {
455
+ if (!traceData.snapshots[i].is_warmup) {
456
+ firstEmitIdx = i;
457
+ break;
458
+ }
459
+ }
460
+ curFrame = firstEmitIdx;
461
+ const slider = document.getElementById('tickSlider');
462
+ slider.max = Math.max(0, traceData.snapshots.length - 1);
463
+ slider.value = curFrame;
464
+ renderFrame(curFrame);
465
+ }
466
+
467
+ function renderCrystallizationTable(cryst) {
468
+ const container = document.getElementById('crystTable');
469
+ if (!cryst || !cryst.length) {
470
+ container.innerHTML = '<div style="color:var(--muted);font-size:12px;">No crystallization stats available.</div>';
471
+ return;
472
+ }
473
+ container.innerHTML = cryst.map(row => {
474
+ const pct = Math.round(row.acc * 100);
475
+ return `
476
+ <div class="cryst-row">
477
+ <span><strong>Slot ${row.slot}</strong> (${row.zone})</span>
478
+ <span>
479
+ H=${row.entropy.toFixed(2)} | <strong>${pct}%</strong>
480
+ <span class="cryst-bar-wrap"><span class="cryst-bar" style="width:${pct}%"></span></span>
481
+ </span>
482
+ </div>
483
+ `;
484
+ }).join('');
485
+ }
486
+
487
+ function onScrub(val) {
488
+ curFrame = parseInt(val, 10);
489
+ renderFrame(curFrame);
490
+ }
491
+
492
+ function stepTick(delta) {
493
+ if (!traceData || !traceData.snapshots.length) return;
494
+ playing = false;
495
+ document.getElementById('playBtn').textContent = 'Play';
496
+ curFrame = (curFrame + delta + traceData.snapshots.length) % traceData.snapshots.length;
497
+ document.getElementById('tickSlider').value = curFrame;
498
+ renderFrame(curFrame);
499
+ }
500
+
501
+ function togglePlay() {
502
+ playing = !playing;
503
+ document.getElementById('playBtn').textContent = playing ? 'Pause' : 'Play';
504
+ }
505
+
506
+ function updateSpeed() {
507
+ intervalMs = parseInt(document.getElementById('speedSelect').value, 10);
508
+ if (timerHandle) clearInterval(timerHandle);
509
+ timerHandle = setInterval(tickLoop, intervalMs);
510
+ }
511
+
512
+ function renderFrame(idx) {
513
+ if (!traceData || !traceData.snapshots.length) return;
514
+ const snap = traceData.snapshots[idx];
515
+ document.getElementById('scrubberLabel').textContent =
516
+ `Tick ${idx + 1}/${traceData.snapshots.length} (Pos t=${snap.tick})`;
517
+ document.getElementById('pauseBadge').textContent = `${Math.round(snap.back_pause_pct)}% Paused`;
518
+
519
+ document.getElementById('tickModeLabel').textContent = snap.is_warmup
520
+ ? `[PRE-BAKING WARMUP: Clamping Input "${snap.emitted_piece}" while Belt Plans Ahead]`
521
+ : `[LIVE BAKING: Slot 0 Popped "${snap.emitted_piece}" | Back 75% Belt ${Math.round(snap.back_pause_pct)}% Paused]`;
522
+
523
+ document.getElementById('storyPromptSpan').textContent = traceData.prompt;
524
+
525
+ const genPieces = [];
526
+ for (let i = 0; i <= idx; i++) {
527
+ if (!traceData.snapshots[i].is_warmup) {
528
+ genPieces.push(traceData.snapshots[i].emitted_piece);
529
+ }
530
+ }
531
+
532
+ const latestSpan = document.getElementById('storyLatestSpan');
533
+ if (genPieces.length > 0) {
534
+ const prevText = genPieces.slice(0, -1).join('');
535
+ const lastPiece = genPieces[genPieces.length - 1];
536
+ document.getElementById('storyGenSpan').textContent = prevText;
537
+ latestSpan.style.display = 'inline';
538
+ latestSpan.textContent = lastPiece;
539
+ } else {
540
+ document.getElementById('storyGenSpan').textContent = "";
541
+ latestSpan.style.display = 'none';
542
+ }
543
+
544
+ const previewWords = snap.slots.slice(1, 7).map(s => s.piece).join('');
545
+ document.getElementById('storyPreviewSpan').textContent = ` [next on belt: ${previewWords} ...]`;
546
+
547
+ const gtBox = document.getElementById('groundTruthBox');
548
+ if (traceData.ground_truth_continuation) {
549
+ gtBox.innerHTML = `<strong>Reference Continuation:</strong> ${traceData.ground_truth_continuation}`;
550
+ } else {
551
+ gtBox.innerHTML = `<strong>Dynamic Prompt Active:</strong> Continuing "${traceData.prompt.slice(0, 64)}..." in real time`;
552
+ }
553
+
554
+ const row = document.getElementById('conveyorRow');
555
+ row.innerHTML = '';
556
+ for (let k = 0; k < snap.slots.length; k++) {
557
+ const s = snap.slots[k];
558
+ const zoneClass = k === 0 ? 'slot-zero zone-front' : (k < 16 ? 'zone-front' : (k < 22 ? 'zone-mid' : 'zone-back'));
559
+ const pct = Math.round(s.prob * 100);
560
+ const pausePct = Math.round((1.0 - s.shift_prob) * 100);
561
+ const div = document.createElement('div');
562
+ div.className = `dough-ball ${zoneClass}`;
563
+ const label = k === 0 ? 'SLOT 0 (POP)' : (k === 15 ? 'SLOT 15 (EXPAND)' : (k >= 16 ? `Plan ${k}` : `Conv ${k}`));
564
+ const top2 = s.top3.slice(1, 3).map(c => `${c.w.trim() || c.w}(${Math.round(c.p*100)}%)`).join(', ');
565
+ const motionTag = k < 16 ? `Shift 100%` : `Pause ${pausePct}%`;
566
+ div.innerHTML = `
567
+ <div class="ball-top"><span>${label}</span><span>${pct}%</span></div>
568
+ <div class="ball-word">${s.piece.trim() || s.piece}</div>
569
+ <div class="ball-cand">2nd: ${top2 || '-'}</div>
570
+ <div class="ball-cand">${motionTag} | H=${s.entropy.toFixed(1)}</div>
571
+ <div class="conf-bar"><div class="conf-fill" style="width:${pct}%"></div></div>
572
+ `;
573
+ row.appendChild(div);
574
+ }
575
+ }
576
+
577
+ function tickLoop() {
578
+ if (playing && traceData && traceData.snapshots.length > 0) {
579
+ curFrame = (curFrame + 1) % traceData.snapshots.length;
580
+ document.getElementById('tickSlider').value = curFrame;
581
+ renderFrame(curFrame);
582
+ }
583
+ }
584
+
585
+ timerHandle = setInterval(tickLoop, intervalMs);
586
+ runGenerate(-1);
587
+ </script>
588
+ </body>
589
+ </html>
590
+ """
591
+
592
+
593
+ class ConveyerWebApp:
594
+ def __init__(
595
+ self,
596
+ model: ConveyerDiffusionLM,
597
+ cfg: Any,
598
+ ckpt_path: str,
599
+ ckpt_meta: dict[str, Any],
600
+ val_ds: Any,
601
+ tokenizer: Any,
602
+ device: torch.device,
603
+ ) -> None:
604
+ self.model = model
605
+ self.cfg = cfg
606
+ self.ckpt_path = ckpt_path
607
+ self.ckpt_meta = ckpt_meta
608
+ self.val_ds = val_ds
609
+ self.tokenizer = tokenizer
610
+ self.device = device
611
+ self.cached_cryst = self._compute_initial_stats()
612
+
613
+ def _compute_initial_stats(self) -> list[dict[str, Any]]:
614
+ metrics = self.ckpt_meta.get("metrics", {})
615
+ acc_curve = metrics.get("val_per_slot_acc")
616
+ ent_curve = metrics.get("val_per_slot_entropy")
617
+ if not acc_curve or not ent_curve:
618
+ val_loader = DataLoader(
619
+ self.val_ds,
620
+ batch_size=min(32, len(self.val_ds)),
621
+ shuffle=False,
622
+ collate_fn=lambda b: collate_conveyer_batch(b, pad_id=self.tokenizer.pad_id),
623
+ )
624
+ c = evaluate_crystallization_curve(
625
+ self.model, self.cfg, val_loader, self.device, pad_id=self.tokenizer.pad_id, max_batches=2
626
+ )
627
+ acc_curve = c["per_slot_acc"]
628
+ ent_curve = c["per_slot_entropy"]
629
+
630
+ B = len(acc_curve)
631
+ slots_to_show = [0, 3, 8, 15, 24, 36, 48, B - 1]
632
+ rows: list[dict[str, Any]] = []
633
+ seen: set[int] = set()
634
+ for k in slots_to_show:
635
+ k = min(k, B - 1)
636
+ if k in seen:
637
+ continue
638
+ seen.add(k)
639
+ zone = (
640
+ "Slot 0 Pop"
641
+ if k == 0
642
+ else ("Front Conveyor" if k < 16 else ("Mid Expansion" if k == 15 else "Back 75% Plan"))
643
+ )
644
+ rows.append(
645
+ {
646
+ "slot": k,
647
+ "zone": zone,
648
+ "acc": float(acc_curve[k]),
649
+ "entropy": float(ent_curve[k]),
650
+ }
651
+ )
652
+ return rows
653
+
654
+ def compute_trace(
655
+ self,
656
+ prompt_text: str | None,
657
+ sample_idx: int,
658
+ overlap: int,
659
+ bake_steps: int,
660
+ max_tokens: int,
661
+ temperature: float = 0.0,
662
+ ) -> dict[str, Any]:
663
+ gt_continuation: str | None = None
664
+ gt_target_ids: list[int] = []
665
+
666
+ if prompt_text is not None and prompt_text.strip() != "":
667
+ encoded = self.tokenizer.encode(prompt_text.strip())
668
+ prompt_ids = torch.tensor([encoded], dtype=torch.long, device=self.device)
669
+ for i in range(min(300, len(self.val_ds))):
670
+ seq = self.val_ds[i]["input_ids"]
671
+ if seq[: len(encoded)].tolist() == encoded:
672
+ rem = [
673
+ int(t)
674
+ for t in seq[len(encoded) :].tolist()
675
+ if int(t) not in (self.tokenizer.pad_id, self.tokenizer.eos_id)
676
+ ]
677
+ gt_target_ids = rem[:max_tokens]
678
+ gt_continuation = self.tokenizer.decode(gt_target_ids)
679
+ break
680
+ else:
681
+ idx = max(0, min(sample_idx, len(self.val_ds) - 1))
682
+ item = self.val_ds[idx]
683
+ plen = int(item["prompt_len"])
684
+ seq = item["input_ids"]
685
+ prompt_ids = seq[:plen].unsqueeze(0).to(self.device)
686
+ rem = [int(t) for t in seq[plen:].tolist() if int(t) not in (self.tokenizer.pad_id, self.tokenizer.eos_id)]
687
+ gt_target_ids = rem[:max_tokens]
688
+ gt_continuation = self.tokenizer.decode(gt_target_ids)
689
+
690
+ full_out, snapshots = generate_conveyer(
691
+ model=self.model,
692
+ prompt_tokens=prompt_ids,
693
+ max_new_tokens=max_tokens,
694
+ prompt_overlap=overlap,
695
+ bake_steps=bake_steps,
696
+ temperature=temperature,
697
+ eos_token_id=self.tokenizer.eos_id,
698
+ deterministic_spawn=(temperature <= 1e-5),
699
+ )
700
+
701
+ plen_actual = prompt_ids.size(1)
702
+ gen_ids = full_out[0, plen_actual:].tolist()
703
+
704
+ sample_token_acc: float | None = None
705
+ if gt_target_ids:
706
+ comp_len = min(len(gen_ids), len(gt_target_ids))
707
+ if comp_len > 0:
708
+ matches = sum(1 for a, b in zip(gen_ids[:comp_len], gt_target_ids[:comp_len]) if a == b)
709
+ sample_token_acc = matches / comp_len
710
+
711
+ F_slots = min(self.model.front_slots, bake_steps)
712
+ serialized_snaps: list[dict[str, Any]] = []
713
+ for s in snapshots:
714
+ slots_info = []
715
+ B = len(s.slot_top1_tokens)
716
+ for k in range(B):
717
+ top3_list = [
718
+ {"w": self.tokenizer.id_to_piece(tid), "p": round(float(pr), 4)}
719
+ for tid, pr in zip(s.slot_top3_tokens[k], s.slot_top3_probs[k])
720
+ ]
721
+ slots_info.append(
722
+ {
723
+ "k": k,
724
+ "piece": self.tokenizer.id_to_piece(s.slot_top1_tokens[k]),
725
+ "prob": round(float(s.slot_top1_probs[k]), 4),
726
+ "entropy": round(float(s.slot_entropies[k]), 3),
727
+ "gate": round(float(s.slot_gate_means[k]), 3),
728
+ "shift_prob": round(float(s.slot_shift_probs[k]), 3),
729
+ "top3": top3_list,
730
+ }
731
+ )
732
+ back_pause = (
733
+ 100.0 * (1.0 - sum(s.slot_shift_probs[F_slots:]) / max(1, B - F_slots))
734
+ if B > F_slots
735
+ else 0.0
736
+ )
737
+ serialized_snaps.append(
738
+ {
739
+ "tick": s.tick,
740
+ "is_warmup": s.is_warmup,
741
+ "emitted_piece": self.tokenizer.id_to_piece(s.emitted_token),
742
+ "mean_gate": sum(s.slot_gate_means) / max(1, B),
743
+ "back_pause_pct": back_pause,
744
+ "slots": slots_info,
745
+ }
746
+ )
747
+
748
+ metrics = self.ckpt_meta.get("metrics", {})
749
+ val_s0_acc = float(metrics.get("val_slot0_acc", self.cached_cryst[0]["acc"] if self.cached_cryst else 0.0))
750
+
751
+ return {
752
+ "ckpt_name": self.ckpt_path,
753
+ "dataset": self.cfg.data.dataset,
754
+ "vocab_size": self.tokenizer.vocab_size,
755
+ "params_m": f"{self.model.num_parameters() / 1e6:.2f}",
756
+ "val_slot0_acc": val_s0_acc,
757
+ "sample_token_acc": sample_token_acc,
758
+ "prompt": self.tokenizer.decode(prompt_ids[0].tolist()),
759
+ "ground_truth_continuation": gt_continuation,
760
+ "crystallization": self.cached_cryst,
761
+ "snapshots": serialized_snaps,
762
+ }
763
+
764
+
765
+ def main() -> None:
766
+ parser = argparse.ArgumentParser(description="Interactive Web Simulator for the 64-Slot Conveyor-Belt Diffusion LLM")
767
+ parser.add_argument("--ckpt", type=str, default=None, help="Path to checkpoint (.pt)")
768
+ parser.add_argument("--config", type=str, default="configs/tinystories.yaml", help="Fallback config if no checkpoint")
769
+ parser.add_argument("--host", type=str, default=os.environ.get("HOST", "127.0.0.1"))
770
+ parser.add_argument("--port", type=int, default=int(os.environ.get("PORT", "8766")))
771
+ args = parser.parse_args()
772
+
773
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
774
+ ckpt_path = args.ckpt
775
+ if ckpt_path is None:
776
+ if Path("runs/tinystories/best.pt").exists():
777
+ ckpt_path = "runs/tinystories/best.pt"
778
+ elif Path("runs/synthetic/best.pt").exists():
779
+ ckpt_path = "runs/synthetic/best.pt"
780
+ else:
781
+ ckpt_path = "runs/tinystories/best.pt"
782
+
783
+ try:
784
+ model, cfg, ckpt_meta = load_checkpoint(ckpt_path, device=device)
785
+ except Exception:
786
+ cfg = load_config(args.config)
787
+ model = ConveyerDiffusionLM(cfg.model).to(device)
788
+ ckpt_path = "untrained"
789
+ ckpt_meta = {}
790
+
791
+ _, val_ds, tokenizer = build_dataset_and_tokenizer(cfg, infer_only=True)
792
+ app = ConveyerWebApp(
793
+ model=model,
794
+ cfg=cfg,
795
+ ckpt_path=ckpt_path,
796
+ ckpt_meta=ckpt_meta,
797
+ val_ds=val_ds,
798
+ tokenizer=tokenizer,
799
+ device=device,
800
+ )
801
+
802
+ class Handler(BaseHTTPRequestHandler):
803
+ def do_GET(self) -> None:
804
+ parsed = urlparse(self.path)
805
+ if parsed.path == "/api/trace":
806
+ qs = parse_qs(parsed.query)
807
+ prompt_text = qs.get("prompt", [None])[0]
808
+ sample_idx = int(qs.get("sample", ["0"])[0])
809
+ overlap = int(qs.get("overlap", ["12"])[0])
810
+ bake_steps = int(qs.get("bake_steps", ["64"])[0])
811
+ tokens = int(qs.get("tokens", ["32"])[0])
812
+ temp = float(qs.get("temp", ["0.0"])[0])
813
+ payload = json.dumps(
814
+ app.compute_trace(prompt_text, sample_idx, overlap, bake_steps, tokens, temperature=temp)
815
+ ).encode("utf-8")
816
+ self.send_response(200)
817
+ self.send_header("Content-Type", "application/json")
818
+ self.send_header("Cache-Control", "no-store, no-cache, must-revalidate")
819
+ self.send_header("Content-Length", str(len(payload)))
820
+ self.end_headers()
821
+ self.wfile.write(payload)
822
+ else:
823
+ body = HTML_PAGE.encode("utf-8")
824
+ self.send_response(200)
825
+ self.send_header("Content-Type", "text/html; charset=utf-8")
826
+ self.send_header("Cache-Control", "no-store")
827
+ self.send_header("Content-Length", str(len(body)))
828
+ self.end_headers()
829
+ self.wfile.write(body)
830
+
831
+ def log_message(self, format: str, *args: Any) -> None:
832
+ return
833
+
834
+ server = HTTPServer((args.host, args.port), Handler)
835
+ print(f"Conveyor-Belt Diffusion LLM Web Simulator running at http://{args.host}:{args.port}", flush=True)
836
+ server.serve_forever()
837
+
838
+
839
+ if __name__ == "__main__":
840
+ main()
tests/test_conveyer.py ADDED
@@ -0,0 +1,254 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import torch
4
+
5
+ from conveyer.config import ConveyerConfig, ModelConfig, load_config
6
+ from conveyer.data.synthetic import SyntheticLookaheadDataset
7
+ from conveyer.infer import generate_conveyer, stream_conveyer_generation
8
+ from conveyer.losses import build_horizon_weights, compute_unrolled_conveyer_loss, seed_corrupted_belt
9
+ from conveyer.model import BeltState, ConveyerDiffusionLM
10
+
11
+
12
+ def _small_model_cfg(
13
+ belt_size: int = 64,
14
+ use_thought_highway: bool = True,
15
+ causal_belt_only: bool = False,
16
+ ) -> ModelConfig:
17
+ return ModelConfig(
18
+ vocab_size=64,
19
+ d_model=64,
20
+ n_heads=4,
21
+ n_layers=2,
22
+ d_ff=128,
23
+ max_seq_len=256,
24
+ dropout=0.0,
25
+ belt_size=belt_size,
26
+ tau_min=0.5,
27
+ tau_max=4.0,
28
+ tau_gamma=1.5,
29
+ gate_bias_init=1.0,
30
+ use_thought_highway=use_thought_highway,
31
+ causal_belt_only=causal_belt_only,
32
+ )
33
+
34
+
35
+ def test_prefix_kv_cache_chunked_equivalence() -> None:
36
+ """
37
+ Verifies that encoding a prefix in one shot (`prefill_prefix(x_{0:P})`) produces the exact same
38
+ KV cache and downstream belt logits as incremental token-by-token `append_committed_token`.
39
+ """
40
+ torch.manual_seed(123)
41
+ cfg = _small_model_cfg(belt_size=16)
42
+ model = ConveyerDiffusionLM(cfg).eval()
43
+
44
+ prefix = torch.randint(1, 60, (2, 10), dtype=torch.long)
45
+ belt = model.init_empty_belt(batch_size=2, tick=10, device=prefix.device, deterministic=True)
46
+
47
+ # Full prefill in one shot
48
+ kv_full = model.prefill_prefix(prefix)
49
+ out_full = model.forward_belt(belt, kv_cache=kv_full)
50
+
51
+ # Incremental prefill: first 4 tokens, then 1 by 1
52
+ kv_inc = model.prefill_prefix(prefix[:, :4])
53
+ for t in range(4, 10):
54
+ kv_inc = model.append_committed_token(prefix[:, t], kv_inc)
55
+ out_inc = model.forward_belt(belt, kv_cache=kv_inc)
56
+
57
+ assert kv_full.seq_len == kv_inc.seq_len == 10
58
+ for l_idx in range(cfg.n_layers):
59
+ assert torch.allclose(kv_full.k_cache[l_idx], kv_inc.k_cache[l_idx], atol=1e-5)
60
+ assert torch.allclose(kv_full.v_cache[l_idx], kv_inc.v_cache[l_idx], atol=1e-5)
61
+ assert torch.allclose(out_full.logits, out_inc.logits, atol=1e-5)
62
+
63
+
64
+ def test_slot0_lexical_anchoring_alpha_zero() -> None:
65
+ """
66
+ Verifies that alpha_0 == 0.0 at Position 0:
67
+ Perturbing `m_thought[:, 0, :]` at Slot 0 must have ZERO effect on the composed dough input at Slot 0,
68
+ forcing Slot 0 to rely 100% on its crisp lexical projection `e_soft[:, 0, :]` before popping out.
69
+ Meanwhile, perturbing `m_thought[:, k, :]` for k > 0 MUST change `dough[:, k, :]`.
70
+ """
71
+ torch.manual_seed(42)
72
+ cfg = _small_model_cfg(belt_size=64, use_thought_highway=True)
73
+ model = ConveyerDiffusionLM(cfg).eval()
74
+
75
+ belt1 = model.init_empty_belt(batch_size=2, tick=5, device=torch.device("cpu"), deterministic=True)
76
+ m_mod = belt1.m_thought.clone()
77
+ m_mod[:, 0, :] += 100.0 # Massive perturbation at Slot 0 thought vector
78
+ belt2 = BeltState(e_soft=belt1.e_soft.clone(), m_thought=m_mod, tick=5)
79
+
80
+ dough1 = model.compose_dough_inputs(belt1)
81
+ dough2 = model.compose_dough_inputs(belt2)
82
+
83
+ assert float(model.alpha_schedule[0].item()) == 0.0
84
+ assert float(model.alpha_schedule[-1].item()) == 1.0
85
+ assert torch.allclose(dough1[:, 0, :], dough2[:, 0, :], atol=1e-6)
86
+
87
+ # Now perturb Slot 63 thought vector -> dough[:, 63, :] must change
88
+ m_mod63 = belt1.m_thought.clone()
89
+ m_mod63[:, 63, :] += 10.0
90
+ belt3 = BeltState(e_soft=belt1.e_soft.clone(), m_thought=m_mod63, tick=5)
91
+ dough3 = model.compose_dough_inputs(belt3)
92
+ assert not torch.allclose(dough1[:, 63, :], dough3[:, 63, :], atol=1e-4)
93
+
94
+
95
+ def test_bidirectional_belt_attention_vs_causal_ar() -> None:
96
+ """
97
+ Verifies that in Conveyor mode (`causal_belt_only=False`), Slot 0's output logits actively depend on
98
+ downstream dough balls at the back of the belt (e.g., Slot 63), whereas in AR baseline mode
99
+ (`causal_belt_only=True`), Slot 0 is completely blind to Slots 1..63.
100
+ """
101
+ torch.manual_seed(7)
102
+ # 1. Conveyor bidirectional belt mode
103
+ cfg_bi = _small_model_cfg(belt_size=64, causal_belt_only=False)
104
+ model_bi = ConveyerDiffusionLM(cfg_bi).eval()
105
+
106
+ belt_a = model_bi.init_empty_belt(batch_size=1, tick=4, device=torch.device("cpu"), deterministic=True)
107
+ belt_b = BeltState(e_soft=belt_a.e_soft.clone(), m_thought=belt_a.m_thought.clone(), tick=4)
108
+ belt_b.e_soft[:, 63, :] += 5.0 # Perturb back of the belt (Slot 63)
109
+
110
+ out_bi_a = model_bi.forward_belt(belt_a)
111
+ out_bi_b = model_bi.forward_belt(belt_b)
112
+ # In bidirectional conveyor mode, Slot 0 uses the mental anchor from Slot 63
113
+ assert not torch.allclose(out_bi_a.slot0_logits, out_bi_b.slot0_logits, atol=1e-5)
114
+
115
+ # 2. Causal AR baseline mode
116
+ cfg_ar = _small_model_cfg(belt_size=64, causal_belt_only=True)
117
+ model_ar = ConveyerDiffusionLM(cfg_ar).eval()
118
+ out_ar_a = model_ar.forward_belt(belt_a)
119
+ out_ar_b = model_ar.forward_belt(belt_b)
120
+ # In causal AR mode, Slot 0 cannot see Slot 63
121
+ assert torch.allclose(out_ar_a.slot0_logits, out_ar_b.slot0_logits, atol=1e-6)
122
+
123
+
124
+ def test_left_shift_mechanics_and_elastic_planning_zone() -> None:
125
+ """
126
+ Verifies both:
127
+ 1. Strict 1-by-1 conveyor shift (`use_elastic_planning=False`), where slots 1..63 shift into 0..62.
128
+ 2. Elastic Two-Zone Planning Belt (`use_elastic_planning=True`), where the front 25% (`0..14`) shifts
129
+ strictly from `1..15`, Slot 15 blends mid-belt sentence expansion (`expand_cand`), and the back 75%
130
+ (`16..63`) pauses elastically via `shift_prob < 1.0`.
131
+ """
132
+ torch.manual_seed(99)
133
+ # 1. Strict conveyor mode
134
+ cfg_strict = _small_model_cfg(belt_size=64)
135
+ cfg_strict.use_elastic_planning = False
136
+ model_strict = ConveyerDiffusionLM(cfg_strict).eval()
137
+
138
+ belt = model_strict.init_empty_belt(batch_size=1, tick=0, device=torch.device("cpu"), deterministic=True)
139
+ step_out = model_strict.forward_belt(belt)
140
+ shifted = model_strict.shift_belt_left(step_out, current_tick=0, deterministic_spawn=True)
141
+
142
+ assert shifted.tick == 1
143
+ assert shifted.e_soft.shape == (1, 64, cfg_strict.d_model)
144
+ assert torch.allclose(shifted.e_soft[:, :63, :], step_out.e_soft[:, 1:, :], atol=1e-6)
145
+ assert torch.allclose(shifted.m_thought[:, :63, :], step_out.m_updated[:, 1:, :], atol=1e-6)
146
+
147
+ # 2. Elastic Two-Zone Planning mode (Front 25% = 16 slots, Back 75% = 48 slots)
148
+ cfg_elastic = _small_model_cfg(belt_size=64)
149
+ cfg_elastic.use_elastic_planning = True
150
+ cfg_elastic.front_conveyer_slots = 16
151
+ model_elastic = ConveyerDiffusionLM(cfg_elastic).eval()
152
+
153
+ step_el = model_elastic.forward_belt(belt)
154
+ shifted_el = model_elastic.shift_belt_left(step_el, current_tick=0, deterministic_spawn=True)
155
+
156
+ # Front 15 slots (0..14) shift 100% from (1..15)
157
+ assert torch.allclose(shifted_el.e_soft[:, :15, :], step_el.e_soft[:, 1:16, :], atol=1e-6)
158
+ assert torch.allclose(shifted_el.m_thought[:, :15, :], step_el.m_updated[:, 1:16, :], atol=1e-6)
159
+ # Back 75% planning slots have shift_prob < 0.5 (paused > 50% in-place)
160
+ assert float(step_el.shift_prob[:, 16:].mean().item()) < 0.50
161
+
162
+
163
+ def test_prompt_overlap_prebaking_clamps_input_and_emits() -> None:
164
+ """
165
+ Verifies the `n - W_pre` cold-start solution:
166
+ When `prompt_overlap = 6` on a prompt of length `n = 10`:
167
+ - Warm-up runs for ticks `t = 4, 5, 6, 7, 8, 9` (6 warm-up ticks), clamping emitted tokens to `prompt[t]`.
168
+ - Generation runs for `max_new_tokens = 8` ticks (`t = 10..17`), returning a sequence of length `10 + 8 = 18`.
169
+ """
170
+ torch.manual_seed(11)
171
+ cfg = _small_model_cfg(belt_size=32)
172
+ model = ConveyerDiffusionLM(cfg).eval()
173
+
174
+ prompt = torch.arange(5, 15, dtype=torch.long).unsqueeze(0) # length n = 10
175
+ full_seq, snapshots = generate_conveyer(
176
+ model=model,
177
+ prompt_tokens=prompt,
178
+ max_new_tokens=8,
179
+ prompt_overlap=6,
180
+ bake_steps=32,
181
+ temperature=0.0,
182
+ deterministic_spawn=True,
183
+ )
184
+
185
+ assert full_seq.shape == (1, 18)
186
+ assert torch.equal(full_seq[:, :10], prompt)
187
+ assert len(snapshots) == 6 + 8
188
+
189
+ # First 6 snapshots must be warm-up ticks at t = 4..9 with clamped prompt tokens
190
+ for i in range(6):
191
+ assert snapshots[i].is_warmup is True
192
+ assert snapshots[i].tick == 4 + i
193
+ assert snapshots[i].emitted_token == int(prompt[0, 4 + i].item())
194
+
195
+ # Next 8 snapshots must be live emissions at t = 10..17
196
+ for j in range(8):
197
+ snap = snapshots[6 + j]
198
+ assert snap.is_warmup is False
199
+ assert snap.tick == 10 + j
200
+ assert snap.emitted_token == int(full_seq[0, 10 + j].item())
201
+
202
+
203
+ def test_unrolled_bptt_gradient_flow_and_slot63_zero_leakage() -> None:
204
+ """
205
+ Verifies that:
206
+ 1. `seed_corrupted_belt` puts zero target signal at Slot 63 (pure noise).
207
+ 2. `compute_unrolled_conveyer_loss` backpropagates non-zero gradients into both the lexical embeddings
208
+ and the continuous thought highway (`thought_cand`, `thought_gate`, `slot_emb`, `m_init`).
209
+ """
210
+ torch.manual_seed(21)
211
+ full_cfg = ConveyerConfig(model=_small_model_cfg(belt_size=16, use_thought_highway=True))
212
+ full_cfg.train.unroll_steps = 4
213
+ model = ConveyerDiffusionLM(full_cfg.model).train()
214
+
215
+ # Check Slot B-1 has zero target signal in seed_corrupted_belt
216
+ torch.manual_seed(55)
217
+ t_a = torch.ones((1, 16), dtype=torch.long) * 5
218
+ b_a = seed_corrupted_belt(model, t_a, tick_start=2, cold_drop_prob=0.0)
219
+ torch.manual_seed(55)
220
+ t_b = torch.ones((1, 16), dtype=torch.long) * 5
221
+ t_b[:, -1] = 42 # Change target at the last slot (B-1)
222
+ b_b = seed_corrupted_belt(model, t_b, tick_start=2, cold_drop_prob=0.0)
223
+ assert torch.allclose(b_a.e_soft[:, -1, :], b_b.e_soft[:, -1, :], atol=1e-6)
224
+
225
+ # Check gradient flow across unrolled BPTT
226
+ seq = torch.randint(4, 50, (3, 40), dtype=torch.long)
227
+ plen = torch.tensor([8, 8, 8], dtype=torch.long)
228
+ metrics = compute_unrolled_conveyer_loss(
229
+ model=model,
230
+ seq_tokens=seq,
231
+ cfg=full_cfg,
232
+ prompt_lengths=plen,
233
+ tick_start=6,
234
+ )
235
+ metrics.loss.backward()
236
+
237
+ assert model.tok_emb.weight.grad is not None and model.tok_emb.weight.grad.abs().sum() > 0
238
+ assert model.slot_emb.weight.grad is not None and model.slot_emb.weight.grad.abs().sum() > 0
239
+ assert model.thought_cand.weight.grad is not None and model.thought_cand.weight.grad.abs().sum() > 0
240
+ assert model.thought_gate.weight.grad is not None and model.thought_gate.weight.grad.abs().sum() > 0
241
+
242
+
243
+ def test_synthetic_lookahead_dataset_and_config_loader() -> None:
244
+ cfg = load_config("configs/synthetic.yaml", ["train.max_steps=10", "data.num_train=60"])
245
+ assert cfg.train.max_steps == 10
246
+ assert cfg.data.num_train == 60
247
+
248
+ ds = SyntheticLookaheadDataset(num_samples=12, seq_len=96, task="all", vocab_size=128)
249
+ assert len(ds) == 12
250
+ for i in range(6):
251
+ item = ds[i]
252
+ assert item["input_ids"].shape == (96,)
253
+ assert 4 < item["prompt_len"] < 60
254
+ assert item["task_id"] in (0, 1, 2)