vmal commited on
Commit
a9349e2
·
verified ·
1 Parent(s): e9b9f67

Upload folder using huggingface_hub

Browse files
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 vmal
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
README.md CHANGED
@@ -1,3 +1,193 @@
1
  ---
2
  license: mit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  license: mit
3
+ language:
4
+ - en
5
+ tags:
6
+ - arithmetic
7
+ - process-supervision
8
+ - scratchpad
9
+ - chain-of-thought
10
+ - from-scratch
11
+ - interpretability
12
+ - small-language-model
13
+ - looped-transformer
14
+ pipeline_tag: text-generation
15
+ library_name: transformers
16
+ datasets:
17
+ - vmal/3-digit-arithmetic-scratchpad-traces
18
  ---
19
+
20
+ # 3-digit-basic-calc
21
+
22
+ A from-scratch, **1.6M-parameter** looped decoder-only transformer for addition,
23
+ subtraction, multiplication, and division on integer operands with up to three
24
+ digits. It generates explicit algorithmic scratchpads and is evaluated on
25
+ prompt-disjoint held-out expressions. No tools or external memory: next-token
26
+ prediction over a 34-token character-and-control vocabulary.
27
+
28
+ | Operation | Validation | Fresh, never-seen problems |
29
+ |-----------|-----------:|---------------------------:|
30
+ | Addition `+` | 99.9% | 99.6% |
31
+ | Subtraction `−` | 99.7% | 99.7% |
32
+ | Multiplication `×` | 100% | 100% |
33
+ | Division `÷` | 96.8% | 93.1% |
34
+ | **Overall** | **99.1%** | **98.1%** |
35
+
36
+
37
+ ---
38
+
39
+ ## Quick start
40
+
41
+ ```python
42
+ from transformers import AutoModelForCausalLM, AutoTokenizer
43
+
44
+ model = AutoModelForCausalLM.from_pretrained("vmal/3-digit-basic-calc", trust_remote_code=True).eval()
45
+ tok = AutoTokenizer.from_pretrained("vmal/3-digit-basic-calc", trust_remote_code=True)
46
+
47
+ print(model.solve(tok, "842/37")) # -> 22.757
48
+ print(model.solve(tok, "213*145")) # -> 30885
49
+ print(model.solve(tok, "-500+500")) # -> 0
50
+ print(model.solve(tok, "12/0")) # -> NAN
51
+
52
+ # See the model's actual reasoning (the scratchpad it generates):
53
+ answer, trace = model.solve(tok, "842/37", return_trace=True)
54
+ print(trace)
55
+ # 842/37=<div><pos><state>842037<step>00080008<qmul>00000<rem>008
56
+ # <step>00840084<qmul>20074<rem>010 ... <ans>022.757
57
+ ```
58
+
59
+ Operands are integers in **[−999, 999]**. Whitespace and a trailing `=` are
60
+ accepted; malformed expressions and out-of-range operands raise `ValueError`.
61
+ Division answers are rounded half-up to three decimals, and division by zero
62
+ returns `NAN`.
63
+
64
+ Mixed-length batching is supported with the tokenizer's default left padding:
65
+
66
+ ```python
67
+ batch = tok(["1+2=", "999/7="], return_tensors="pt", padding=True)
68
+ raw_traces = model.generate(**batch, do_sample=False)
69
+ ```
70
+
71
+ ## Research iterations
72
+
73
+ **Goal:** Develop a small transformer that generalizes the algorithms for
74
+ addition, subtraction, multiplication, and division, targeting at least 95%
75
+ accuracy per operation on prompt-disjoint held-out splits.
76
+
77
+ **Iterations:**
78
+
79
+ **A plain ~4M-parameter transformer.** The obvious start. It fit the training data but didn't generalize the algorithm; accuracy fell apart on unseen numbers. Scaling wasn't obviously the fix; the failures looked structural.
80
+
81
+ **A looped transformer.** Sharing a small block across many iterations gives cheap depth, the right bias for an iterative procedure. It helped a little. It didn't make the model generalize.
82
+
83
+ **Abacus embeddings.** Following *Transformers Can Do Arithmetic with the Right Embeddings* (McLeish et al., NeurIPS 2024), I added its three pieces: digit-position (abacus) embeddings, input injection, and a randomized-offset scheme for length generalization. This was the first thing that clearly moved the *hardest* cases. But it still failed to generalize division and multiplication, and even addition and subtraction stayed shakier than they should have. I also tried **GRPO** on top; it bought a small, real improvement, not the step change the problem needed.
84
+
85
+ Every one of those was a reasonable bet, and none fixed the core issue. The model was always being asked to carry too much state (a remainder, a running carry) implicitly, inside its activations. More depth, better position embeddings, and RL were all trying to make it better at holding hidden state. The move that worked was to stop asking it to.
86
+
87
+ **Process supervision (the scratchpad).** Instead of `842/37 → 22.757`, teach the model to emit the whole procedure as tokens, writing down the state at every step. Each token now predicts a local step from explicit state written in the preceding tokens, reducing the hidden-state burden. This was the turning point: the model started to *generalize* across all four operations. But two operations still fell short.
88
+
89
+ **Multiplication stalled near 65%.** Measured per step, one step did all the damage: a single token had to sum up to three digit-products and a carry at once. Meanwhile training loss had collapsed to near zero while validation sat at 65%: the model could already fit the traces perfectly. Capacity was not the primary bottleneck; one step packed too much into one decision. So I broke the column sum into **term-level micro-steps** (emit each partial product, then the digit and carry). **Multiplication → 99.6%.**
90
+
91
+ **Then division was the laggard.** A first-error diagnostic showed the errors sat almost entirely in the **interior long-division steps**, never the setup, never the rounding. The step was still doing two hard things at once: find the quotient digit *and* compute the new remainder. Same fix, **more micro-steps**: each position becomes form-the-partial → emit the quotient digit and its product → subtract for the remainder. **Division cleared its plateau and reached ~97%**, while the other operations remained near 99–100%.
92
+
93
+ Two things ran underneath all of it. **Sequence length kept growing**: more micro-steps mean longer traces, so `max_seq_len` climbed 80 → 128 → 176 to fit them. Whenever the model lagged on a specific skill (non-terminating quotients or high-carry columns), the fix was usually to *improve the data distribution* for that skill, not to change the model.
94
+
95
+ > **The lesson:** in these experiments, capacity was not the primary bottleneck; decomposition was decisive. Each plateau broke when the hardest *local function per step* was split into simpler pieces and the data was kept well-distributed. The 256-wide, 2-layer, 8-loop architecture stayed fixed through the scratchpad experiments.
96
+
97
+ ## Evaluation
98
+
99
+ All numbers are greedy decoding on prompt-disjoint held-out data. Detailed
100
+ counts, seeds, conversion checks, and timing are available in
101
+ [`evaluation_results.json`](evaluation_results.json).
102
+
103
+ ### Locked test split
104
+
105
+ The reserved test split was evaluated once after model selection:
106
+
107
+ | Operation | Accuracy |
108
+ |-----------|---------:|
109
+ | `+` | 99.9% (999/1,000) |
110
+ | `−` | 99.9% (999/1,000) |
111
+ | `×` | 100.0% (1,000/1,000) |
112
+ | `÷` | 95.2% (952/1,000) |
113
+ | **Overall** | **98.75% (3,950/4,000)** |
114
+
115
+ ### Generality, never-seen problems
116
+
117
+ Beyond the reserved test split, I sampled and evaluated **3,000 new, uniformly
118
+ generated problems with operands up to three digits**, each verified to be
119
+ absent from all 108,000 dataset prompts:
120
+
121
+ | Operation | Accuracy |
122
+ |-----------|---------:|
123
+ | `+` | 99.6% |
124
+ | `−` | 99.7% |
125
+ | `×` | **100.0%** (750/750) |
126
+ | `÷` | 93.1% |
127
+ | **Overall** | **98.1%** |
128
+
129
+ The fresh benchmark differs from the locked test by −0.3 points on addition,
130
+ −0.2 on subtraction, 0.0 on multiplication, and −2.1 on division. The larger
131
+ division gap is consistent with uniform sampling stressing a different mixture
132
+ than the scenario-balanced held-out splits. Prompt exclusion rules out exact
133
+ training-prompt memorization, but these results should not be interpreted as
134
+ length generalization: operands remain within the trained three-digit range.
135
+
136
+ ### Where it's hard, and hardest
137
+
138
+ - **Easiest:** multiplication (perfect on fresh samples) and addition/subtraction (~99.7%).
139
+ - **Hardest:** multi-step division, especially with two- and three-digit
140
+ divisors. On the locked test set, 47 of 48 division failures first diverged
141
+ at quotient-digit/product selection and one at the resulting remainder.
142
+ Errors occurred across interior positions rather than only at the rounding
143
+ digit, so a wrong quotient digit can cascade into later remainders.
144
+ - **Rare +/− failure:** near-cancellation with mixed signs (e.g. `239 + (−271)`), where the true result is tiny and the model occasionally loses sign or magnitude, about 0.3% of cases.
145
+ - **`÷0`** is a single atomic `<nan>` token.
146
+
147
+ ### Is there more headroom?
148
+
149
+ For division problems the model gets wrong at greedy decoding, sampling 32
150
+ times still reaches the correct answer in about 78% of cases, so correct
151
+ alternatives often exist in the distribution. Two conservative GRPO trials
152
+ did **not** convert that headroom into better greedy validation: one stayed at
153
+ 96.8% division accuracy and one regressed to 96.6%. The checkpoint-selection
154
+ gates therefore retained the supervised baseline released here.
155
+
156
+
157
+ ## Technical specifications
158
+
159
+ | | |
160
+ |---|---|
161
+ | Parameters | 1,596,160 |
162
+ | Architecture | looped decoder-only (2 layers × 8 shared loops) |
163
+ | Hidden size / heads | 256 / 8 |
164
+ | Vocabulary | 34 tokens (character + scratchpad control tokens) |
165
+ | Context length | 176 |
166
+ | Scratchpad digit order | reversed for `+`, `−`, `×`; ordinary order for `÷` |
167
+ | Positional encoding | RoPE |
168
+ | Normalization | RMSNorm |
169
+ | Precision | fp32 |
170
+
171
+ ## Limitations
172
+
173
+ - Operands are restricted to integers in `[-999, 999]`.
174
+ - The fixed-width scratchpad representation does not generalize to four-digit
175
+ or longer operands.
176
+ - Division reaches 95.2% on the locked test split but 93.1% on the fresh
177
+ uniform benchmark.
178
+ - This is a research model, not a reliable production calculator.
179
+
180
+ ## Intended use
181
+
182
+ Education and research. This model is a small, fully inspectable case study in process supervision, step decomposition, and rigorous evaluation of algorithmic generalization. It is intended for studying how explicit intermediate computation can support arithmetic reasoning in small transformers.
183
+
184
+ ## References
185
+
186
+ - Nye et al., *Show Your Work: Scratchpads for Intermediate Computation* (2021)
187
+ - McLeish et al., *Transformers Can Do Arithmetic with the Right Embeddings* (NeurIPS 2024)
188
+ - Dehghani et al., *Universal Transformers* (2019)
189
+ - Shao et al., *DeepSeekMath / GRPO* (2024)
190
+
191
+ ## License
192
+
193
+ MIT.
config.json ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "architectures": [
3
+ "ThreeDigitBasicCalcForCausalLM"
4
+ ],
5
+ "auto_map": {
6
+ "AutoConfig": "config.ThreeDigitBasicCalcConfig",
7
+ "AutoModelForCausalLM": "modeling.ThreeDigitBasicCalcForCausalLM"
8
+ },
9
+ "bos_token_id": 1,
10
+ "dropout": 0.05,
11
+ "dtype": "float32",
12
+ "eos_token_id": 2,
13
+ "hidden_size": 256,
14
+ "max_position_embeddings": 176,
15
+ "model_type": "3-digit-basic-calc",
16
+ "n_loops": 8,
17
+ "num_attention_heads": 8,
18
+ "num_hidden_layers": 2,
19
+ "pad_token_id": 0,
20
+ "rms_eps": 1e-06,
21
+ "rope_base": 10000.0,
22
+ "tie_word_embeddings": false,
23
+ "transformers_version": "5.3.0",
24
+ "unk_token_id": 3,
25
+ "use_cache": false,
26
+ "vocab_size": 34
27
+ }
config.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright 2026. Released under the MIT license.
2
+ """Configuration for the 3-digit-basic-calc looped arithmetic transformer."""
3
+
4
+ from transformers import PretrainedConfig
5
+
6
+
7
+ class ThreeDigitBasicCalcConfig(PretrainedConfig):
8
+ """Config for a looped ("universal") decoder-only calculator transformer.
9
+
10
+ A small stack of ``num_hidden_layers`` decoder blocks is applied ``n_loops``
11
+ times with shared weights, giving an effective depth of
12
+ ``num_hidden_layers * n_loops`` at the parameter cost of the stack alone.
13
+ """
14
+
15
+ model_type = "3-digit-basic-calc"
16
+
17
+ def __init__(
18
+ self,
19
+ vocab_size: int = 34,
20
+ max_position_embeddings: int = 176,
21
+ hidden_size: int = 256,
22
+ num_attention_heads: int = 8,
23
+ num_hidden_layers: int = 2,
24
+ n_loops: int = 8,
25
+ dropout: float = 0.05,
26
+ rms_eps: float = 1e-6,
27
+ rope_base: float = 10000.0,
28
+ pad_token_id: int = 0,
29
+ bos_token_id: int = 1,
30
+ eos_token_id: int = 2,
31
+ unk_token_id: int = 3,
32
+ **kwargs,
33
+ ):
34
+ self.vocab_size = vocab_size
35
+ self.max_position_embeddings = max_position_embeddings
36
+ self.hidden_size = hidden_size
37
+ self.num_attention_heads = num_attention_heads
38
+ self.num_hidden_layers = num_hidden_layers
39
+ self.n_loops = n_loops
40
+ self.dropout = dropout
41
+ self.rms_eps = rms_eps
42
+ self.rope_base = rope_base
43
+ self.unk_token_id = unk_token_id
44
+ kwargs.setdefault("tie_word_embeddings", False)
45
+ super().__init__(
46
+ pad_token_id=pad_token_id,
47
+ bos_token_id=bos_token_id,
48
+ eos_token_id=eos_token_id,
49
+ **kwargs,
50
+ )
evaluation_results.json ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "schema_version": 1,
3
+ "model": {
4
+ "name": "3-digit-basic-calc",
5
+ "parameters": 1596160,
6
+ "source_checkpoint": "ckp/scratchpad_v3_256x2x8_bs32.pt",
7
+ "representation": "scratchpad-v3",
8
+ "decoding": "greedy"
9
+ },
10
+ "conversion_validation": {
11
+ "date": "2026-07-24",
12
+ "dataset_rows_tokenized": 108000,
13
+ "tokenizer_id_mismatches": 0,
14
+ "learned_tensors_compared": 24,
15
+ "learned_tensor_mismatches": 0,
16
+ "export_tensor_count": 26,
17
+ "derived_export_tensors": [
18
+ "decoder_layers.0.attn.rope.inv_freq",
19
+ "decoder_layers.1.attn.rope.inv_freq"
20
+ ],
21
+ "prompt_logit_max_abs_difference": 0.0,
22
+ "complete_162_token_logit_max_abs_difference": 0.0,
23
+ "argmax_mismatches": 0
24
+ },
25
+ "validation": {
26
+ "split": "val",
27
+ "rows": 4000,
28
+ "implementation": "exported Hugging Face architecture",
29
+ "device": "cpu",
30
+ "elapsed_seconds": 1513.414,
31
+ "answer": {
32
+ "+": {"correct": 999, "total": 1000, "accuracy": 0.999},
33
+ "-": {"correct": 997, "total": 1000, "accuracy": 0.997},
34
+ "*": {"correct": 1000, "total": 1000, "accuracy": 1.0},
35
+ "/": {"correct": 968, "total": 1000, "accuracy": 0.968},
36
+ "overall": {"correct": 3964, "total": 4000, "accuracy": 0.991}
37
+ },
38
+ "trace_exact": {
39
+ "+": {"correct": 999, "total": 1000, "accuracy": 0.999},
40
+ "-": {"correct": 997, "total": 1000, "accuracy": 0.997},
41
+ "*": {"correct": 1000, "total": 1000, "accuracy": 1.0},
42
+ "/": {"correct": 949, "total": 1000, "accuracy": 0.949},
43
+ "overall": {"correct": 3945, "total": 4000, "accuracy": 0.98625}
44
+ },
45
+ "parse_success": {"correct": 4000, "total": 4000, "accuracy": 1.0}
46
+ },
47
+ "locked_test": {
48
+ "split": "test",
49
+ "rows": 4000,
50
+ "device": "mps",
51
+ "source_report": "sample/scratchpad_v3_test_eval.json",
52
+ "answer": {
53
+ "+": {"correct": 999, "total": 1000, "accuracy": 0.999},
54
+ "-": {"correct": 999, "total": 1000, "accuracy": 0.999},
55
+ "*": {"correct": 1000, "total": 1000, "accuracy": 1.0},
56
+ "/": {"correct": 952, "total": 1000, "accuracy": 0.952},
57
+ "overall": {"correct": 3950, "total": 4000, "accuracy": 0.9875}
58
+ },
59
+ "sequence_exact": {"correct": 3925, "total": 4000, "accuracy": 0.98125},
60
+ "parse_success": {"correct": 4000, "total": 4000, "accuracy": 1.0}
61
+ },
62
+ "fresh_prompt_disjoint": {
63
+ "seed": 20260723,
64
+ "rows": 3000,
65
+ "sampling": "uniform random operands and operation-balanced, excluding all 108000 dataset prompts",
66
+ "implementation": "native checkpoint after exact exported-logit parity was established",
67
+ "answer": {
68
+ "+": {"correct": 747, "total": 750, "accuracy": 0.996},
69
+ "-": {"correct": 748, "total": 750, "accuracy": 0.9973333333},
70
+ "*": {"correct": 750, "total": 750, "accuracy": 1.0},
71
+ "/": {"correct": 698, "total": 750, "accuracy": 0.9306666667},
72
+ "overall": {"correct": 2943, "total": 3000, "accuracy": 0.981}
73
+ },
74
+ "trace_exact": {
75
+ "+": {"correct": 747, "total": 750, "accuracy": 0.996},
76
+ "-": {"correct": 748, "total": 750, "accuracy": 0.9973333333},
77
+ "*": {"correct": 750, "total": 750, "accuracy": 1.0},
78
+ "/": {"correct": 669, "total": 750, "accuracy": 0.892}
79
+ },
80
+ "elapsed_seconds": 1227.1992
81
+ },
82
+ "division_failure_diagnosis": {
83
+ "source": "locked_test",
84
+ "answer_failures": 48,
85
+ "first_wrong_component": {
86
+ "quotient_digit_or_product": 47,
87
+ "remainder": 1
88
+ },
89
+ "first_wrong_position": {
90
+ "1": 4,
91
+ "2": 11,
92
+ "3": 17,
93
+ "4": 10,
94
+ "5": 4,
95
+ "6": 2
96
+ }
97
+ },
98
+ "grpo": {
99
+ "baseline_division_accuracy": 0.968,
100
+ "trial_1_division_accuracy": 0.968,
101
+ "trial_2_division_accuracy": 0.966,
102
+ "selected_checkpoint": "supervised baseline",
103
+ "reason": "neither trial improved gated greedy validation"
104
+ }
105
+ }
generation_config.json ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "_from_model_config": true,
3
+ "bos_token_id": 1,
4
+ "eos_token_id": 2,
5
+ "max_new_tokens": 176,
6
+ "output_attentions": false,
7
+ "output_hidden_states": false,
8
+ "pad_token_id": 0,
9
+ "transformers_version": "5.3.0"
10
+ }
model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:2c3ce8133b29ab0b902ff00217d3d917555df81a1bba202bcc47eeef34ab6411
3
+ size 6387352
modeling.py ADDED
@@ -0,0 +1,413 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright 2026. Released under the MIT license.
2
+ """Self-contained looped decoder-only transformer for character-level arithmetic.
3
+
4
+ This module intentionally has no dependency on the training repository so it can
5
+ run from the Hub with ``trust_remote_code=True``. The building blocks mirror the
6
+ original ``model.py`` (RMSNorm, RoPE, causal attention, GELU MLP) and the
7
+ parameter names match the released checkpoint so weights load directly.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import math
13
+ import re
14
+
15
+ import torch
16
+ import torch.nn as nn
17
+
18
+ from transformers import PreTrainedModel
19
+ from transformers.generation import GenerationMixin
20
+ from transformers.modeling_outputs import CausalLMOutputWithPast
21
+
22
+ from .config import ThreeDigitBasicCalcConfig
23
+
24
+
25
+ class WordEmbeddings(nn.Module):
26
+ def __init__(self, d_model: int, vocab_size: int):
27
+ super().__init__()
28
+ self.d_model = d_model
29
+ self.embedding = nn.Embedding(vocab_size, d_model)
30
+
31
+ def forward(self, x):
32
+ return self.embedding(x) * math.sqrt(self.d_model)
33
+
34
+
35
+ class RMSLayerNormalization(nn.Module):
36
+ def __init__(self, d_model: int, eps: float = 1e-6):
37
+ super().__init__()
38
+ self.eps = eps
39
+ self.weight = nn.Parameter(torch.ones(d_model))
40
+
41
+ def forward(self, x):
42
+ mean = x.pow(2).mean(dim=-1, keepdim=True)
43
+ return self.weight * x * torch.rsqrt(mean + self.eps)
44
+
45
+
46
+ class RotaryEmbedding(nn.Module):
47
+ def __init__(self, max_seq_len: int, h_dim: int, base: float = 10000.0):
48
+ super().__init__()
49
+ assert h_dim % 2 == 0
50
+ self.max_seq_len = max_seq_len
51
+ # Persistent so from_pretrained (low-mem loading) always materializes it;
52
+ # cos/sin are derived per forward to avoid stale non-persistent buffers.
53
+ inv_freq = 1.0 / (base ** (torch.arange(0, h_dim, 2).float() / h_dim))
54
+ self.register_buffer("inv_freq", inv_freq, persistent=True)
55
+ # Derived runtime values are deliberately not registered buffers: the
56
+ # checkpoint contains only inv_freq, while repeated loop passes reuse
57
+ # the trigonometric table.
58
+ self._cos_cached = None
59
+ self._sin_cached = None
60
+ self._cache_key = None
61
+
62
+ def _cos_sin(self, length: int, device):
63
+ key = (length, device.type, device.index, self.inv_freq.dtype)
64
+ if self._cache_key == key:
65
+ return self._cos_cached, self._sin_cached
66
+ t = torch.arange(length, device=device, dtype=self.inv_freq.dtype)
67
+ freqs = torch.outer(t, self.inv_freq)
68
+ self._cos_cached = freqs.cos()
69
+ self._sin_cached = freqs.sin()
70
+ self._cache_key = key
71
+ return self._cos_cached, self._sin_cached
72
+
73
+ def forward(self, x, position_ids=None):
74
+ seq = x.shape[-2]
75
+ if position_ids is None:
76
+ position_ids = torch.arange(seq, device=x.device).unsqueeze(0)
77
+ position_ids = position_ids.to(device=x.device, dtype=torch.long)
78
+ required = int(position_ids.max().item()) + 1
79
+ if required > self.max_seq_len:
80
+ raise ValueError(
81
+ f"position {required - 1} exceeds the configured context "
82
+ f"length {self.max_seq_len}"
83
+ )
84
+ cos_table, sin_table = self._cos_sin(required, x.device)
85
+ cos = cos_table[position_ids].unsqueeze(1)
86
+ sin = sin_table[position_ids].unsqueeze(1)
87
+ x1 = x[..., 0::2]
88
+ x2 = x[..., 1::2]
89
+ out1 = x1 * cos - x2 * sin
90
+ out2 = x1 * sin + x2 * cos
91
+ return torch.stack([out1, out2], dim=-1).flatten(-2).type_as(x)
92
+
93
+
94
+ class MultiHeadAttentionBlock(nn.Module):
95
+ def __init__(self, max_seq_len, d_model, h, dropout=0.0, base=10000.0):
96
+ super().__init__()
97
+ assert d_model % h == 0
98
+ self.heads = h
99
+ self.d_head = d_model // h
100
+ self.w_q = nn.Linear(d_model, d_model, bias=False)
101
+ self.w_k = nn.Linear(d_model, d_model, bias=False)
102
+ self.w_v = nn.Linear(d_model, d_model, bias=False)
103
+ self.w_o = nn.Linear(d_model, d_model, bias=False)
104
+ self.rope = RotaryEmbedding(max_seq_len, self.d_head, base)
105
+ self.dropout = nn.Dropout(dropout)
106
+
107
+ def forward(self, x, mask=None, position_ids=None):
108
+ b, s, d = x.shape
109
+ q = self.w_q(x).view(b, s, self.heads, self.d_head).transpose(1, 2)
110
+ k = self.w_k(x).view(b, s, self.heads, self.d_head).transpose(1, 2)
111
+ v = self.w_v(x).view(b, s, self.heads, self.d_head).transpose(1, 2)
112
+ q = self.rope(q, position_ids)
113
+ k = self.rope(k, position_ids)
114
+ attn_score = (q @ k.transpose(-2, -1)) / math.sqrt(self.d_head)
115
+ if mask is not None:
116
+ attn_score = attn_score.masked_fill(~mask, float("-inf"))
117
+ # Left-padded query rows before the first real token have no legal
118
+ # key. Their output is discarded; zeroing only those score rows
119
+ # avoids NaNs while leaving every real-token score bit-identical to
120
+ # the unpadded/native implementation.
121
+ no_legal_key = ~mask.any(dim=-1, keepdim=True)
122
+ attn_score = attn_score.masked_fill(no_legal_key, 0.0)
123
+ attn = torch.softmax(attn_score, dim=-1)
124
+ attn = self.dropout(attn)
125
+ out = (attn @ v).transpose(1, 2).contiguous().reshape(b, s, d)
126
+ return self.w_o(out)
127
+
128
+
129
+ class MLP(nn.Module):
130
+ def __init__(self, d_model, d_ff, dropout=0.0):
131
+ super().__init__()
132
+ self.dropout = nn.Dropout(dropout)
133
+ self.fc1 = nn.Linear(d_model, d_ff)
134
+ self.fc2 = nn.Linear(d_ff, d_model)
135
+ self.act = nn.GELU()
136
+
137
+ def forward(self, x):
138
+ return self.fc2(self.dropout(self.act(self.fc1(x))))
139
+
140
+
141
+ class DecoderBlock(nn.Module):
142
+ def __init__(self, max_seq_len, d_model, attention_heads, d_ff,
143
+ eps=1e-6, dropout=0.0, base=10000.0):
144
+ super().__init__()
145
+ self.layer_norm_1 = RMSLayerNormalization(d_model, eps)
146
+ self.layer_norm_2 = RMSLayerNormalization(d_model, eps)
147
+ self.attn = MultiHeadAttentionBlock(
148
+ max_seq_len, d_model, attention_heads, dropout, base)
149
+ self.ffn = MLP(d_model, d_ff, dropout)
150
+ self.dropout = nn.Dropout(dropout)
151
+
152
+ def forward(self, x, mask, position_ids=None):
153
+ x = x + self.dropout(
154
+ self.attn(self.layer_norm_1(x), mask, position_ids)
155
+ )
156
+ x = x + self.dropout(self.ffn(self.layer_norm_2(x)))
157
+ return x
158
+
159
+
160
+ class ThreeDigitBasicCalcForCausalLM(PreTrainedModel, GenerationMixin):
161
+ """Looped decoder-only causal LM. Parameter names match the checkpoint."""
162
+
163
+ config_class = ThreeDigitBasicCalcConfig
164
+ _supports_cache_class = False
165
+
166
+ def __init__(self, config: ThreeDigitBasicCalcConfig):
167
+ super().__init__(config)
168
+ d = config.hidden_size
169
+ self.embedding = WordEmbeddings(d, config.vocab_size)
170
+ self.decoder_layers = nn.ModuleList([
171
+ DecoderBlock(config.max_position_embeddings, d,
172
+ config.num_attention_heads, 4 * d,
173
+ config.rms_eps, config.dropout, config.rope_base)
174
+ for _ in range(config.num_hidden_layers)
175
+ ])
176
+ self.step_emb = nn.Embedding(config.n_loops, d)
177
+ self.layer_norm = RMSLayerNormalization(d, config.rms_eps)
178
+ self.projection = nn.Linear(d, config.vocab_size, bias=False)
179
+ self.config.use_cache = False
180
+ self.post_init()
181
+
182
+ def get_input_embeddings(self):
183
+ return self.embedding.embedding
184
+
185
+ def set_input_embeddings(self, value):
186
+ self.embedding.embedding = value
187
+
188
+ def get_output_embeddings(self):
189
+ return self.projection
190
+
191
+ def _init_weights(self, module):
192
+ if isinstance(module, nn.Linear):
193
+ module.weight.data.normal_(mean=0.0, std=0.02)
194
+ if module.bias is not None:
195
+ module.bias.data.zero_()
196
+ elif isinstance(module, nn.Embedding):
197
+ module.weight.data.normal_(mean=0.0, std=0.02)
198
+
199
+ def forward(
200
+ self,
201
+ input_ids,
202
+ attention_mask=None,
203
+ position_ids=None,
204
+ labels=None,
205
+ **kwargs,
206
+ ):
207
+ b, s = input_ids.shape
208
+ if s > self.config.max_position_embeddings:
209
+ raise ValueError(
210
+ f"sequence length {s} exceeds the configured context length "
211
+ f"{self.config.max_position_embeddings}"
212
+ )
213
+ if attention_mask is None:
214
+ attention_mask = torch.ones_like(input_ids, dtype=torch.long)
215
+ if attention_mask.shape != input_ids.shape:
216
+ raise ValueError("attention_mask must have the same shape as input_ids")
217
+ attention_mask = attention_mask.to(device=input_ids.device)
218
+ if position_ids is None:
219
+ position_ids = attention_mask.long().cumsum(dim=-1) - 1
220
+ position_ids = position_ids.clamp_min(0)
221
+ if position_ids.shape[-1] != s:
222
+ raise ValueError("position_ids must have the same sequence length as input_ids")
223
+
224
+ causal = torch.tril(
225
+ torch.ones(s, s, dtype=torch.bool, device=input_ids.device)
226
+ ).view(1, 1, s, s)
227
+ key_mask = attention_mask.to(torch.bool).view(b, 1, 1, s)
228
+ combined_mask = causal & key_mask
229
+ x = self.embedding(input_ids)
230
+ for t in range(self.config.n_loops):
231
+ x = x + self.step_emb.weight[t]
232
+ for decoder in self.decoder_layers:
233
+ x = decoder(x, combined_mask, position_ids)
234
+ x = self.layer_norm(x)
235
+ logits = self.projection(x)
236
+
237
+ loss = None
238
+ if labels is not None:
239
+ shift_logits = logits[:, :-1, :].contiguous()
240
+ shift_labels = labels[:, 1:].contiguous()
241
+ shift_attention = attention_mask[:, 1:].to(torch.bool)
242
+ shift_labels = shift_labels.masked_fill(
243
+ ~shift_attention | (shift_labels == self.config.pad_token_id),
244
+ -100,
245
+ )
246
+ loss = nn.functional.cross_entropy(
247
+ shift_logits.view(-1, shift_logits.size(-1)),
248
+ shift_labels.view(-1),
249
+ ignore_index=-100,
250
+ )
251
+ return CausalLMOutputWithPast(loss=loss, logits=logits)
252
+
253
+ def prepare_inputs_for_generation(self, input_ids, **kwargs):
254
+ # No KV cache: the model recomputes the full sequence each step.
255
+ attention_mask = kwargs.get("attention_mask")
256
+ if attention_mask is None:
257
+ attention_mask = torch.ones_like(input_ids, dtype=torch.long)
258
+ position_ids = attention_mask.long().cumsum(dim=-1) - 1
259
+ position_ids = position_ids.clamp_min(0)
260
+ return {
261
+ "input_ids": input_ids,
262
+ "attention_mask": attention_mask,
263
+ "position_ids": position_ids,
264
+ }
265
+
266
+ @torch.no_grad()
267
+ def solve(self, tokenizer, expression: str, max_new_tokens: int = 256,
268
+ return_trace: bool = False):
269
+ """Compute a 3-digit arithmetic expression, e.g. ``model.solve(tok, "842/37")``.
270
+
271
+ Returns the human-readable answer string (or a ``(answer, trace)`` tuple
272
+ when ``return_trace=True``). The model emits an explicit scratchpad; this
273
+ decodes its final ``<ans>`` field back to an ordinary number.
274
+ """
275
+ a, op, b, prompt = _parse_expression(expression)
276
+ encoded = tokenizer(prompt, return_tensors="pt")
277
+ ids = encoded.input_ids.to(self.device)
278
+ remaining = self.config.max_position_embeddings - ids.shape[1]
279
+ if remaining <= 0:
280
+ raise ValueError("the expression leaves no context for generation")
281
+ max_new_tokens = min(max_new_tokens, remaining)
282
+ out = self.generate(
283
+ ids,
284
+ attention_mask=encoded.attention_mask.to(self.device),
285
+ max_new_tokens=max_new_tokens,
286
+ do_sample=False,
287
+ eos_token_id=tokenizer.eos_token_id,
288
+ pad_token_id=tokenizer.pad_token_id,
289
+ )
290
+ if out[0, -1].item() != tokenizer.eos_token_id:
291
+ raise ValueError(
292
+ "generation reached the context limit before emitting <eos>"
293
+ )
294
+ trace = tokenizer.decode(out[0], skip_special_tokens=False)
295
+ answer = _decode_answer(
296
+ prompt,
297
+ tokenizer.decode(out[0], skip_special_tokens=True),
298
+ parsed_expression=(a, op, b),
299
+ )
300
+ return (answer, trace) if return_trace else answer
301
+
302
+
303
+ _EXPRESSION_RE = re.compile(
304
+ r"^\s*(-?\d{1,3})\s*([+\-*/])\s*(-?\d{1,3})\s*=?\s*$"
305
+ )
306
+
307
+
308
+ def _parse_expression(expression: str):
309
+ if not isinstance(expression, str):
310
+ raise TypeError("expression must be a string")
311
+ match = _EXPRESSION_RE.fullmatch(expression)
312
+ if match is None:
313
+ raise ValueError(
314
+ "expected '<integer><operator><integer>' with operands from "
315
+ "-999 through 999"
316
+ )
317
+ a, op, b = int(match.group(1)), match.group(2), int(match.group(3))
318
+ prompt = f"{a}{op}{b}="
319
+ return a, op, b, prompt
320
+
321
+
322
+ def _magnitude_plan(a: int, op: str, b: int):
323
+ if op in ("*", "/"):
324
+ effective = "mul" if op == "*" else "div"
325
+ if op == "/" and b == 0:
326
+ return effective, "positive", abs(a), 0
327
+ sign = "negative" if (a < 0) != (b < 0) else "positive"
328
+ return effective, sign, abs(a), abs(b)
329
+
330
+ left_term = a
331
+ right_term = b if op == "+" else -b
332
+ if left_term == 0 and right_term == 0:
333
+ return "add", "positive", 0, 0
334
+ if left_term == 0:
335
+ sign = "negative" if right_term < 0 else "positive"
336
+ return "add", sign, 0, abs(right_term)
337
+ if right_term == 0:
338
+ sign = "negative" if left_term < 0 else "positive"
339
+ return "add", sign, abs(left_term), 0
340
+ if (left_term < 0) == (right_term < 0):
341
+ sign = "negative" if left_term < 0 else "positive"
342
+ return "add", sign, abs(left_term), abs(right_term)
343
+ if abs(left_term) == abs(right_term):
344
+ return "sub", "positive", abs(left_term), abs(right_term)
345
+ if abs(left_term) > abs(right_term):
346
+ sign = "negative" if left_term < 0 else "positive"
347
+ return "sub", sign, abs(left_term), abs(right_term)
348
+ sign = "negative" if right_term < 0 else "positive"
349
+ return "sub", sign, abs(right_term), abs(left_term)
350
+
351
+
352
+ def _decode_answer(
353
+ prompt: str,
354
+ decoded: str,
355
+ *,
356
+ parsed_expression=None,
357
+ ) -> str:
358
+ """Strictly validate a v3 scratchpad and decode its fixed answer field."""
359
+ if parsed_expression is None:
360
+ a, op, b, canonical_prompt = _parse_expression(prompt)
361
+ else:
362
+ a, op, b = parsed_expression
363
+ canonical_prompt = f"{a}{op}{b}="
364
+ if prompt != canonical_prompt:
365
+ raise ValueError("prompt is not in canonical form")
366
+ if not decoded.startswith(canonical_prompt):
367
+ raise ValueError("generated scratchpad does not begin with the prompt")
368
+
369
+ effective, sign, left, right = _magnitude_plan(a, op, b)
370
+ operation_token = {
371
+ "add": "<add>",
372
+ "sub": "<sub>",
373
+ "mul": "<mul>",
374
+ "div": "<div>",
375
+ }[effective]
376
+ sign_token = "<neg>" if sign == "negative" else "<pos>"
377
+ if effective == "div":
378
+ state = f"{left:03d}{right:03d}"
379
+ else:
380
+ state = f"{left:03d}"[::-1] + f"{right:03d}"[::-1]
381
+ prefix = (
382
+ re.escape(canonical_prompt)
383
+ + re.escape(operation_token + sign_token + "<state>" + state)
384
+ )
385
+
386
+ if effective in ("add", "sub"):
387
+ body = r"(?:<step>\d{5}){3}<ans>(?P<answer>\d{4})"
388
+ elif effective == "mul":
389
+ groups = []
390
+ for term_count in (1, 2, 3, 2, 1):
391
+ groups.append(rf"(?:<step>\d{{7}}){{{term_count}}}<col>\d{{3}}")
392
+ body = "".join(groups) + r"<ans>(?P<answer>\d{6})"
393
+ elif b == 0:
394
+ body = r"<ans>(?P<answer><nan>)"
395
+ else:
396
+ record = r"<step>\d{8}<qmul>\d{5}<rem>\d{3}"
397
+ body = rf"(?:{record}){{7}}<ans>(?P<answer>\d{{3}}\.\d{{3}})"
398
+
399
+ match = re.fullmatch(prefix + body, decoded)
400
+ if match is None:
401
+ raise ValueError("generated scratchpad does not match the v3 grammar")
402
+ internal = match.group("answer")
403
+ if internal == "<nan>":
404
+ return "NAN"
405
+ if op == "/":
406
+ integer, fraction = internal.split(".", 1)
407
+ fraction = fraction.rstrip("0")
408
+ magnitude = str(int(integer)) + (f".{fraction}" if fraction else "")
409
+ else:
410
+ magnitude = str(int(internal[::-1]))
411
+ if sign == "negative" and magnitude != "0":
412
+ return "-" + magnitude
413
+ return magnitude
notebook.ipynb ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "metadata": {},
6
+ "source": [
7
+ "# \ud83e\uddee 3-digit-basic-calc \u2014 3-digit arithmetic with a 1.6M-parameter transformer\n",
8
+ "\n",
9
+ "A from-scratch transformer that does `+ \u2212 \u00d7 \u00f7` on 3-digit numbers by writing out\n",
10
+ "the algorithm step by step. This notebook loads the model and runs it.\n",
11
+ "\n",
12
+ "Model: [`vmal/3-digit-basic-calc`](https://huggingface.co/vmal/3-digit-basic-calc)\n"
13
+ ]
14
+ },
15
+ {
16
+ "cell_type": "code",
17
+ "metadata": {},
18
+ "execution_count": null,
19
+ "outputs": [],
20
+ "source": [
21
+ "%pip install -q transformers==5.3.0 torch safetensors\n"
22
+ ]
23
+ },
24
+ {
25
+ "cell_type": "markdown",
26
+ "metadata": {},
27
+ "source": [
28
+ "## Load the model\n"
29
+ ]
30
+ },
31
+ {
32
+ "cell_type": "code",
33
+ "metadata": {},
34
+ "execution_count": null,
35
+ "outputs": [],
36
+ "source": [
37
+ "from transformers import AutoModelForCausalLM, AutoTokenizer\n",
38
+ "\n",
39
+ "REPO = \"vmal/3-digit-basic-calc\"\n",
40
+ "model = AutoModelForCausalLM.from_pretrained(REPO, trust_remote_code=True).eval()\n",
41
+ "tok = AutoTokenizer.from_pretrained(REPO, trust_remote_code=True)\n",
42
+ "print(\"parameters:\", sum(p.numel() for p in model.parameters()))\n"
43
+ ]
44
+ },
45
+ {
46
+ "cell_type": "markdown",
47
+ "metadata": {},
48
+ "source": [
49
+ "## Solve some problems\n",
50
+ "\n",
51
+ "`model.solve(tokenizer, expression)` returns the human-readable answer.\n"
52
+ ]
53
+ },
54
+ {
55
+ "cell_type": "code",
56
+ "metadata": {},
57
+ "execution_count": null,
58
+ "outputs": [],
59
+ "source": [
60
+ "for e in [\"842/37\", \"213*145\", \"999*999\", \"-500+500\", \"3/31\", \"12/0\"]:\n",
61
+ " print(f\"{e:>10} = {model.solve(tok, e)}\")\n"
62
+ ]
63
+ },
64
+ {
65
+ "cell_type": "markdown",
66
+ "metadata": {},
67
+ "source": [
68
+ "## See the model's reasoning\n",
69
+ "\n",
70
+ "Pass `return_trace=True` to get the raw scratchpad the model generates \u2014\n",
71
+ "each `<step>`/`<qmul>`/`<rem>` is one local computation.\n"
72
+ ]
73
+ },
74
+ {
75
+ "cell_type": "code",
76
+ "metadata": {},
77
+ "execution_count": null,
78
+ "outputs": [],
79
+ "source": [
80
+ "answer, trace = model.solve(tok, \"842/37\", return_trace=True)\n",
81
+ "print(\"answer:\", answer)\n",
82
+ "print()\n",
83
+ "for c in ['<div>','<mul>','<add>','<sub>','<state>','<step>','<qmul>','<rem>','<col>','<ans>']:\n",
84
+ " trace = trace.replace(c, '\\n'+c+' ')\n",
85
+ "print(trace.strip())\n"
86
+ ]
87
+ },
88
+ {
89
+ "cell_type": "markdown",
90
+ "metadata": {},
91
+ "source": [
92
+ "## Quick in-range behavior check on random problems\n",
93
+ "\n",
94
+ "This small smoke test samples the supported operand range. It does not load\n",
95
+ "the training prompts, so it should not be described as a leakage-controlled\n",
96
+ "unseen benchmark; the card reports that benchmark separately.\n"
97
+ ]
98
+ },
99
+ {
100
+ "cell_type": "code",
101
+ "metadata": {},
102
+ "execution_count": null,
103
+ "outputs": [],
104
+ "source": [
105
+ "import random\n",
106
+ "random.seed(0)\n",
107
+ "def truth(a, b, op):\n",
108
+ " if op == '+': return str(a + b)\n",
109
+ " if op == '-': return str(a - b)\n",
110
+ " if op == '*': return str(a * b)\n",
111
+ " if b == 0: return 'NAN'\n",
112
+ " # Exact integer round-half-up to three decimals (no float/banker's rounding).\n",
113
+ " denominator = abs(b)\n",
114
+ " scaled, remainder = divmod(abs(a) * 1000, denominator)\n",
115
+ " scaled += int(2 * remainder >= denominator)\n",
116
+ " integer, fraction = divmod(scaled, 1000)\n",
117
+ " magnitude = str(integer)\n",
118
+ " if fraction:\n",
119
+ " magnitude += '.' + f'{fraction:03d}'.rstrip('0')\n",
120
+ " negative = (a < 0) != (b < 0)\n",
121
+ " return '-' + magnitude if negative and scaled else magnitude\n",
122
+ "good=n=0\n",
123
+ "for _ in range(30):\n",
124
+ " a=random.randint(-999,999); b=random.randint(-999,999); op=random.choice('+-*/')\n",
125
+ " if op=='/' and b==0: b=7\n",
126
+ " got=model.solve(tok, f'{a}{op}{b}'); exp=truth(a,b,op)\n",
127
+ " good+= (got==exp); n+=1\n",
128
+ "print(f'{good}/{n} correct on random in-range problems')\n"
129
+ ]
130
+ }
131
+ ],
132
+ "metadata": {
133
+ "kernelspec": {
134
+ "display_name": "Python 3",
135
+ "language": "python",
136
+ "name": "python3"
137
+ },
138
+ "language_info": {
139
+ "name": "python"
140
+ }
141
+ },
142
+ "nbformat": 4,
143
+ "nbformat_minor": 5
144
+ }
special_tokens_map.json ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ {
2
+ "bos_token": "<bos>",
3
+ "eos_token": "<eos>",
4
+ "pad_token": "<pad>",
5
+ "unk_token": "<unk>"
6
+ }
tokenizer.py ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright 2026. Released under the MIT license.
2
+ """Character/control-token tokenizer for the 3-digit-basic-calc calculator model.
3
+
4
+ Digits, operators and symbols are single characters; the scratchpad grammar adds
5
+ multi-character control tokens (``<add>``, ``<step>``, ``<qmul>`` ...). A minus
6
+ sign is emitted as the unary token ``~`` at the start of an expression, after
7
+ ``=``, or immediately after a binary operator, so ``12*-34`` and ``5--3`` stay
8
+ unambiguous while ``-`` remains the binary subtraction operator.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import os
15
+
16
+ from transformers import PreTrainedTokenizer
17
+
18
+
19
+ SPECIAL_TOKENS = ["<pad>", "<bos>", "<eos>", "<unk>"]
20
+ DIGITS = list("0123456789")
21
+ OPERATORS = ["+", "-", "/", "*"]
22
+ SYMBOLS = ["=", ".", "~"]
23
+ CONTROL_TOKENS = [
24
+ "<add>", "<sub>", "<mul>", "<div>", "<pos>", "<neg>",
25
+ "<state>", "<step>", "<ans>", "<nan>", "<col>", "<qmul>", "<rem>",
26
+ ]
27
+ TOKENS = SPECIAL_TOKENS + DIGITS + OPERATORS + SYMBOLS + CONTROL_TOKENS
28
+ _UNARY_PREDECESSORS = {"=", "+", "-", "/", "*"}
29
+
30
+
31
+ class ThreeDigitBasicCalcTokenizer(PreTrainedTokenizer):
32
+ vocab_files_names = {"vocab_file": "vocab.json"}
33
+ model_input_names = ["input_ids", "attention_mask"]
34
+
35
+ def __init__(self, vocab_file=None, **kwargs):
36
+ if vocab_file and os.path.isfile(vocab_file):
37
+ with open(vocab_file, encoding="utf-8") as handle:
38
+ self._vocab = json.load(handle)
39
+ else:
40
+ self._vocab = {tok: i for i, tok in enumerate(TOKENS)}
41
+ self._ids_to_tokens = {i: t for t, i in self._vocab.items()}
42
+ self._ordered_controls = sorted(CONTROL_TOKENS, key=len, reverse=True)
43
+ kwargs.setdefault("pad_token", "<pad>")
44
+ kwargs.setdefault("bos_token", "<bos>")
45
+ kwargs.setdefault("eos_token", "<eos>")
46
+ kwargs.setdefault("unk_token", "<unk>")
47
+ # Generation takes the logits from the final sequence position. Left
48
+ # padding therefore keeps the final position on a real prompt token for
49
+ # every member of a mixed-length batch.
50
+ kwargs.setdefault("padding_side", "left")
51
+ super().__init__(**kwargs)
52
+
53
+ @property
54
+ def vocab_size(self):
55
+ return len(self._vocab)
56
+
57
+ def get_vocab(self):
58
+ return dict(self._vocab)
59
+
60
+ def _tokenize(self, text):
61
+ tokens = []
62
+ index = 0
63
+ while index < len(text):
64
+ control = next(
65
+ (t for t in self._ordered_controls if text.startswith(t, index)),
66
+ None,
67
+ )
68
+ if control is not None:
69
+ tokens.append(control)
70
+ index += len(control)
71
+ continue
72
+ ch = text[index]
73
+ if ch == "-" and (
74
+ index == 0 or text[index - 1] in _UNARY_PREDECESSORS):
75
+ tokens.append("~")
76
+ else:
77
+ tokens.append(ch)
78
+ index += 1
79
+ return tokens
80
+
81
+ def _convert_token_to_id(self, token):
82
+ return self._vocab.get(token, self._vocab["<unk>"])
83
+
84
+ def _convert_id_to_token(self, index):
85
+ return self._ids_to_tokens.get(index, "<unk>")
86
+
87
+ def convert_tokens_to_string(self, tokens):
88
+ out = []
89
+ for tok in tokens:
90
+ if tok in SPECIAL_TOKENS:
91
+ continue
92
+ out.append("-" if tok == "~" else tok)
93
+ return "".join(out)
94
+
95
+ def build_inputs_with_special_tokens(self, token_ids_0, token_ids_1=None):
96
+ bos = [self._vocab["<bos>"]]
97
+ if token_ids_1 is None:
98
+ return bos + token_ids_0
99
+ return bos + token_ids_0 + token_ids_1
100
+
101
+ def save_vocabulary(self, save_directory, filename_prefix=None):
102
+ path = os.path.join(
103
+ save_directory,
104
+ (filename_prefix + "-" if filename_prefix else "") + "vocab.json",
105
+ )
106
+ with open(path, "w", encoding="utf-8") as handle:
107
+ json.dump(self._vocab, handle, ensure_ascii=False, indent=2)
108
+ return (path,)
tokenizer_config.json ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "added_tokens_decoder": {
3
+ "0": {
4
+ "content": "<pad>",
5
+ "lstrip": false,
6
+ "normalized": false,
7
+ "rstrip": false,
8
+ "single_word": false,
9
+ "special": true
10
+ },
11
+ "1": {
12
+ "content": "<bos>",
13
+ "lstrip": false,
14
+ "normalized": false,
15
+ "rstrip": false,
16
+ "single_word": false,
17
+ "special": true
18
+ },
19
+ "2": {
20
+ "content": "<eos>",
21
+ "lstrip": false,
22
+ "normalized": false,
23
+ "rstrip": false,
24
+ "single_word": false,
25
+ "special": true
26
+ },
27
+ "3": {
28
+ "content": "<unk>",
29
+ "lstrip": false,
30
+ "normalized": false,
31
+ "rstrip": false,
32
+ "single_word": false,
33
+ "special": true
34
+ }
35
+ },
36
+ "backend": "custom",
37
+ "bos_token": "<bos>",
38
+ "eos_token": "<eos>",
39
+ "model_max_length": 176,
40
+ "pad_token": "<pad>",
41
+ "padding_side": "left",
42
+ "tokenizer_class": "ThreeDigitBasicCalcTokenizer",
43
+ "unk_token": "<unk>",
44
+ "auto_map": {
45
+ "AutoTokenizer": [
46
+ "tokenizer.ThreeDigitBasicCalcTokenizer",
47
+ null
48
+ ]
49
+ }
50
+ }
vocab.json ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "<pad>": 0,
3
+ "<bos>": 1,
4
+ "<eos>": 2,
5
+ "<unk>": 3,
6
+ "0": 4,
7
+ "1": 5,
8
+ "2": 6,
9
+ "3": 7,
10
+ "4": 8,
11
+ "5": 9,
12
+ "6": 10,
13
+ "7": 11,
14
+ "8": 12,
15
+ "9": 13,
16
+ "+": 14,
17
+ "-": 15,
18
+ "/": 16,
19
+ "*": 17,
20
+ "=": 18,
21
+ ".": 19,
22
+ "~": 20,
23
+ "<add>": 21,
24
+ "<sub>": 22,
25
+ "<mul>": 23,
26
+ "<div>": 24,
27
+ "<pos>": 25,
28
+ "<neg>": 26,
29
+ "<state>": 27,
30
+ "<step>": 28,
31
+ "<ans>": 29,
32
+ "<nan>": 30,
33
+ "<col>": 31,
34
+ "<qmul>": 32,
35
+ "<rem>": 33
36
+ }