summaryrefslogtreecommitdiff
path: root/src/config.py
blob: 915cdae9838b1aa12d05eb7700a078158b044c8e (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
"""Experiment configuration. Defaults = Experiment 1 (design/preregistration.md)."""
import argparse
import json
from dataclasses import dataclass, fields


@dataclass
class Config:
    # data
    vocab_mode: str = "digits"       # "digits" | "integers"
    task_mode: str = "next_prime"    # "next_prime" | "is_prime"
    range_start: int = 2
    range_end: int = 100             # inclusive
    holdout_frac: float = 0.30
    train_frac: float = 1.0          # 1.0 = use full train split; 0.4/0.5 for E5
    seed: int = 0
    # model
    model: str = "rnn"               # "rnn" | "transformer"
    d_model: int = 128
    max_steps: int = 20              # K tied iterations (RNN)
    halting: bool = True             # False -> fixed-K ablation
    halt_penalty: float = 0.01       # lambda on mean steps
    halt_warmup_steps: int = 1000    # penalty = 0 before this (anti-collapse)
    halt_ramp_end_steps: int = 5000  # penalty ramps linearly 0 -> halt_penalty between warmup and here
    min_steps: int = 2               # ACT: halt prob forced to 0 for the first min_steps-1 steps
    n_layers: int = 2
    n_heads: int = 4
    # scratchpad / token-space recurrence (Addendum 8, E8)
    scratch_mode: str = "none"       # "none" | "structured" | "filler" | "random_learned" | "random_frozen" | "random_noise"
    scratch_len: int = 16            # length of filler / random token sequence
    # training
    lr: float = 1e-3
    lr_decay: bool = False           # cosine 1e-3 -> 1e-4 over the run (E3)
    weight_decay: float = 1.0
    max_train_steps: int = 200_000
    eval_every: int = 200
    eval_schedule: str = "fixed"     # "fixed" | "adaptive" (more frequent early, coarser later for 4M runs)
    early_stop_em: float = 1.0
    early_stop_patience: int = 5
    batch_size: int = 32
    eval_batch_size: int = 512       # chunked evaluation batch size to avoid GPU OOM
    max_out_len: int = 6
    log_n_examples: int = 10
    out_dir: str = "runs"
    device: str = "auto"             # "auto" | "cuda" | "cpu" | "mps"
    use_amp: bool = True             # FP16 mixed precision on CUDA (Turing Tensor Cores)
    compile_model: bool = False      # torch.compile (reduce-overhead / CUDA Graphs)

    @property
    def vocab(self) -> int:
        """Token count.
        - Integers mode: value tokens 0..next_prime(range_end) + EOS + pad.
        - Digits mode (none): 10 digits + EOS = 11.
        - Digits mode (scratchpad): 10 digits + EOS + SEP + PAUSE + 16 random (a-p) + 4 structured symbols (c,=,d,:) + 1 noise slot = 34.
        """
        if self.vocab_mode == "integers":
            # inline mini-sieve: next prime above range_end bounds the target domain
            limit = self.range_end + 100
            is_prime = [True] * (limit + 1)
            is_prime[0] = is_prime[1] = False
            for p in range(2, int(limit ** 0.5) + 1):
                if is_prime[p]:
                    for m in range(p * p, limit + 1, p):
                        is_prime[m] = False
            np_ = next(i for i in range(self.range_end + 1, limit + 1) if is_prime[i])
            return np_ + 2                    # values 0..np_, EOS=np_+1, pad=np_+2
        if self.scratch_mode != "none":
            return 34                         # expanded vocabulary for scratchpad/filler/random tokens
        return 11                             # digits 0-9 + EOS

    @property
    def eos_id(self) -> int:
        if self.vocab_mode == "integers":
            return self.vocab - 1
        if self.scratch_mode != "none":
            return 10                         # fixed ID 10 for EOS in digits mode
        return self.vocab - 1                 # 10 for digits mode none

    @property
    def pad_id(self) -> int:
        return self.vocab                     # one extra embedding row reserved for pad

    def to_json(self) -> dict:
        d = {f.name: getattr(self, f.name) for f in fields(self)}
        d["vocab"] = self.vocab
        d["eos_id"] = self.eos_id
        d["pad_id"] = self.pad_id
        return d

    @classmethod
    def from_json(cls, d: dict) -> "Config":
        cfg = cls()
        for f in fields(cls):
            if f.name in d:
                setattr(cfg, f.name, d[f.name])
        return cfg

    def save(self, path: str) -> None:
        with open(path, "w") as fh:
            json.dump(self.to_json(), fh, indent=2)

    @classmethod
    def load(cls, path: str) -> "Config":
        with open(path) as fh:
            return cls.from_json(json.load(fh))


def _bool_arg(s: str) -> bool:
    return s.lower() in ("1", "true", "yes", "on")


def parse_args(argv=None) -> Config:
    cfg = Config()
    p = argparse.ArgumentParser(description="prime-grokking train")
    p.add_argument("model_pos", nargs="?", default=None, help="model: rnn | transformer")
    p.add_argument("seed_pos", nargs="?", default=None, help="seed (int)")
    for f in fields(Config):
        if f.type is bool:
            p.add_argument(f"--{f.name}", default=None, type=_bool_arg)
        elif f.type in (int, float, str):
            p.add_argument(f"--{f.name}", default=None, type=f.type)
    a = p.parse_args(argv)
    if a.model_pos is not None:
        cfg.model = a.model_pos
    if a.seed_pos is not None:
        cfg.seed = int(a.seed_pos)
    for f in fields(Config):
        v = getattr(a, f.name, None)
        if v is not None:
            setattr(cfg, f.name, v)
    return cfg