True2456 commited on
Commit
31b4aff
·
verified ·
1 Parent(s): fe567dd

Public-ready card: correct numeric/tokenizer conclusion, add accuracy + tool-call validation, REAM rejection, evidence files

Browse files
README.md CHANGED
@@ -127,6 +127,34 @@ smooth over quantization noise the way routed experts get) showed a real,
127
  consistent, cross-category improvement -- about 10x any depth-window effect,
128
  for roughly +0.03 bpw / +1GB.
129
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
130
  ### Also tested and rejected: per-expert mixed-precision tiering
131
 
132
  Splitting each layer's 245 kept experts into 61 hot (6-bit) / 123 base
@@ -153,6 +181,14 @@ repo:
153
  - `docs/SHARED8-RESULT.md` -- this model's final validation.
154
  - `docs/TIERED-EXPERTS-RESULT.md` -- the rejected per-expert tiering
155
  experiment.
 
 
 
 
 
 
 
 
156
  - `artifacts/*.json` -- raw per-category NLL/PPL from every eval run
157
  referenced above, and `plan_p15_blend03.json`, the exact REAP plan
158
  (per-layer kept/pruned expert indices and blended scores) used to build
@@ -182,97 +218,55 @@ ones are a poor fit for this tokenizer:
182
  {"temperature": 0.5, "top_p": 0.9, "top_k": 100, "min_p": 0.05, "repetition_penalty": 1.0}
183
  ```
184
 
185
- **Keep `repetition_penalty` at 1.0.** This tokenizer emits **one token per
186
- digit** (ids 19–28 for `'0'`–`'9'`) and a **standalone space token** (223),
187
- so numbers carry no token-level redundancy a single mis-sampled token
188
- corrupts an entire number, while prose has enough redundancy to absorb it.
189
- Repetition penalty is the worst offender, and it is the only common sampler
190
- setting that *reorders* candidates rather than merely filtering them so
191
- `top_p` and `min_p` cannot compensate for it.
192
-
193
- `mlx_lm`'s implementation divides raw logits for any token seen in the last
194
- 20, which has two consequences. The shift is proportional to the logit, so the
195
- model's **most confident** token takes the largest absolute hit. And `0` and
196
- `1` are among the most frequent tokens in source code, so they sit in that
197
- rolling window almost permanently while `.` often does not — demote the
198
- correct digit, leave the runner-up untouched, and `.` wins.
199
-
200
- The symptom is digits replaced by letters (`2456` `2a5b`) or stray
201
- delimiters inside numbers, with everything non-numeric looking perfect. Use
202
- `min_p` for loop suppression instead raise it to 0.1 before reaching for a
203
- penalty.
204
-
205
- ### Known issue: numbers corrupted inside the reasoning block
206
-
207
- In a context dense with delimited numerals (MAC addresses, IP tables, float
208
- literals), the model can commit to a wrong number at its **first mention
209
- inside the chain of thought**, then answer its own corrupted premise faithfully:
210
-
211
- ```
212
- prompt: "...kill process ID 18452 with signal 9" (MAC-address context)
213
- answer: kill -9 1845 5/5 at temperature 0
214
- ```
215
-
216
- Also observed as delimiter insertion — `2456, 1337, 495` → `2 4 5 6, 1 3 3 7,
217
- 4 9 5`, and in float-dense code `12.0` → `1.2.0`, `sed -n '447,495p'` →
218
- `sed -n '4.4.7,4.9.5p'`.
219
-
220
- In the worst observed case the model does not corrupt the digits at all — it
221
- **substitutes content from the context**, answering a request for
222
- `2456, 1337, 495` with `2a:5b:c4:9f:1d:e0, 3c:df:a9:b2:44:`. The mild form
223
- (`2456` → `2 4 5 6`) and this one appear to be the same failure at different
224
- strengths: numeric output being captured by numerals already in the window.
225
- The separator varies with conditions (`2 4 5 6` vs `2,4,5,6,...`).
226
-
227
- **What it is not.** Measured on this checkpoint:
228
 
229
- - *Not `repetition_penalty`.* Direct A/B, everything else identical:
230
- penalty 1.02 gives 0/8 and 1/8 on the two failing cases, penalty pinned to
231
- 1.0 gives 0/8 and 0/8. No effect.
232
  - *Not the quantized output head.* Digit rows carry 1.03x the all-row
233
- reconstruction error, and the quantization perturbation is 12% of the
234
- tightest inter-digit margin.
235
  - *Not degraded digit copying.* Teacher-forced probes (no sampler in the path)
236
- rank the correct next digit **first** at p = 0.91–0.9996, with and without
237
- the triggering context.
238
- - *Not a reading failure.* In the same context the model reports `18452` has
239
- 5 digits and echoes it back verbatim, both correct.
240
- - *Not fixable with `reasoning_effort`.* Setting `Reasoning: medium` fixed one
241
- case completely (0/12 → 12/12, and a 3x shorter trace) but made the other
242
- substantially worse traces grew from ~6.1k to ~13.2k characters, 11 of 12
243
- samples exceeded an 8000-token budget without producing an answer, and the
244
- one that finished returned the context substitution above. Do not treat it
245
- as a mitigation.
246
-
247
- **What it is.** A narrow interaction between a specific digit string and a
248
- specific context: a different 5-digit PID (`90317`) in the identical context
249
- answers correctly, and `18452` with no context answers correctly. Once the
250
- wrong value appears in the reasoning it is never recovered — in one trace the
251
- model re-read the question more than twenty times and reaffirmed the corrupted
252
- value every time, because its own earlier text had become the strongest
253
- evidence in context.
254
-
255
- **Practical guidance.** No sampler or template setting fixes this, so the
256
- mitigation is operational. Verify numeric arguments programmatically rather
257
- than relying on the model to self-check: a wrong value that is *syntactically
258
- valid* (`idx+1.5`, a shortened PID) is caught by neither a syntax check nor the
259
- model's own review. Prefer structured file-editing tools over line-numbered
260
- `sed`. Where exact numerals matter, avoid keeping large delimiter-dense blocks
261
- (ARP tables, IP configs, float-heavy source) in the same window as the request.
262
-
263
- Note also that a runtime's own sampler settings override what this file
264
- declares — an LM Studio per-model config with repeat penalty enabled will win
265
- over the `repetition_penalty: 1.0` in `generation_config.json`.
266
-
267
- Not yet isolated to this build versus upstream Step-3.7 — no unpruned
268
- reference exists as a loadable quant, and the evidence above (clean probes,
269
- clean reads, correct handling of other numbers) points away from the pruning
270
- and quantization as the cause.
271
-
272
- Temperature is deliberately below StepFun's suggested 0.7–1.0 for the same
273
- reason: that guidance is tuned for prose diversity and does not account for
274
- per-digit tokenization. Raise it for open-ended writing; keep it low for
275
- tool calls, structured output, and anything numeric.
276
 
277
  ## Reasoning
278
 
@@ -325,8 +319,10 @@ limitations.
325
  - No BF16 multimodal reference was run, so absolute (not just relative)
326
  vision-task damage from quantization remains unmeasured.
327
  - This checkpoint has not been evaluated on standard downstream benchmarks
328
- (MMLU, GSM8K, SWE-bench, etc.) all numbers above are perplexity/NLL
329
- proxies, not task accuracy.
 
 
330
  - **JSON-schema structured output can crash `llguidance`-based runtimes.**
331
  This is a base-model tokenizer interaction, not a defect introduced by the
332
  pruning or quantization here — it reproduces with zero model weights loaded,
 
127
  consistent, cross-category improvement -- about 10x any depth-window effect,
128
  for roughly +0.03 bpw / +1GB.
129
 
130
+ ### Capability validation (exact-answer accuracy, not perplexity)
131
+
132
+ Perplexity is a proxy: it rewards a confident distribution, which is not the
133
+ same as a correct one. To check that the numbers above reflect real capability,
134
+ this model was also scored **right/wrong** on exact-answer tasks where a good
135
+ proxy cannot substitute for a correct answer — greedy decoding, all sampler
136
+ fields pinned:
137
+
138
+ | test | items | score |
139
+ |---|---|---|
140
+ | multi-step arithmetic + factual recall | 24 | **24/24** |
141
+ | tool selection + argument extraction (from a provided catalog) | 15 | **14/15** |
142
+
143
+ These are spot checks, not a benchmark suite, but they confirm the model
144
+ reasons and calls tools correctly, not merely that it has low perplexity. Item
145
+ sets and per-item results: `artifacts/acc_*.json`, `artifacts/tc_*.json`.
146
+
147
+ ### Also tested and rejected: expert merging (REAM)
148
+
149
+ Merging the 43 lowest-saliency experts per layer into their nearest kept
150
+ neighbour (instead of pruning them) *lowered perplexity substantially* —
151
+ overall text NLL −0.194, with tool_use perplexity dropping 41% (26.5 → 15.6).
152
+ On the exact-answer tests above, however, the merged model scored **identically**
153
+ (24/24 → 23/24, tool-call 14/15 → 14/15): the perplexity gain was distribution
154
+ smoothing with no capability behind it. A clean demonstration that perplexity
155
+ can move without capability following — and the reason pruning, not merging, was
156
+ shipped. Full write-up in `docs/REAM-RESULT.md`.
157
+
158
  ### Also tested and rejected: per-expert mixed-precision tiering
159
 
160
  Splitting each layer's 245 kept experts into 61 hot (6-bit) / 123 base
 
181
  - `docs/SHARED8-RESULT.md` -- this model's final validation.
182
  - `docs/TIERED-EXPERTS-RESULT.md` -- the rejected per-expert tiering
183
  experiment.
184
+ - `docs/REAM-RESULT.md` -- the rejected expert-merging experiment and the
185
+ perplexity-vs-capability demonstration behind it.
186
+ - `docs/TOKENIZER-INVESTIGATION.md` -- why the numeric behaviour is intrinsic
187
+ tokenization and not a checkpoint defect (and why the base tokenizer must not
188
+ be copied in).
189
+ - `artifacts/acc_*.json`, `artifacts/tc_*.json` -- per-item results for the
190
+ exact-answer accuracy and tool-call spot checks, with
191
+ `artifacts/accuracy_eval.py` and `artifacts/toolcall_eval.py`.
192
  - `artifacts/*.json` -- raw per-category NLL/PPL from every eval run
193
  referenced above, and `plan_p15_blend03.json`, the exact REAP plan
194
  (per-layer kept/pruned expert indices and blended scores) used to build
 
218
  {"temperature": 0.5, "top_p": 0.9, "top_k": 100, "min_p": 0.05, "repetition_penalty": 1.0}
219
  ```
220
 
221
+ `repetition_penalty` is pinned to **1.0**. It is the only common sampler
222
+ setting that *reorders* candidates rather than merely filtering them, so it is
223
+ the riskiest knob to raisebut note it was **measured not to be the cause** of
224
+ the numeric behaviour described under *Numeric handling* below (a direct A/B at
225
+ 1.02 vs 1.0 was identical). Keep it at 1.0 as a safe default; use `min_p` for
226
+ loop suppression, raising it to 0.1 before reaching for a penalty.
227
+
228
+ Temperature is deliberately below StepFun's suggested 0.7–1.0. That guidance is
229
+ tuned for prose diversity; this tokenizer spells numbers one digit at a time
230
+ (see *Numeric handling*), which makes numeric output more temperature-sensitive
231
+ than prose. Raise it for open-ended writing; keep it low for tool calls,
232
+ structured output, and anything numeric.
233
+
234
+ ### Numeric handling (a base-model property, not a defect)
235
+
236
+ Step-3.7's tokenizer emits **one token per digit** (ids 19–28 for `'0'`–`'9'`)
237
+ plus a **standalone space token** (223). Numbers therefore carry no
238
+ token-level redundancy: a single mis-sampled or mis-copied token corrupts an
239
+ entire number, while prose has enough redundancy to absorb it. In long,
240
+ number-dense contexts (IP/MAC tables, float-heavy source) this shows up as
241
+ digits dropped or split by a delimiter `2456` → `2 4 5 6`, `12.0` → `1.2.0`,
242
+ `sed -n '447,495p'` → `sed -n '4.4.7,4.9.5p'` — with non-numeric output
243
+ unaffected.
244
+
245
+ **This is StepFun's tokenization design, not something introduced by the
246
+ pruning or quantization here.** It was investigated at length and each
247
+ plausible in-checkpoint cause was ruled out with measurements:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
248
 
 
 
 
249
  - *Not the quantized output head.* Digit rows carry 1.03x the all-row
250
+ reconstruction error; the quantization perturbation is 12% of the tightest
251
+ inter-digit argmax margin.
252
  - *Not degraded digit copying.* Teacher-forced probes (no sampler in the path)
253
+ rank the correct next digit **first** at p = 0.91–0.9996.
254
+ - *Not the sampler.* A direct `repetition_penalty` A/B (1.02 vs 1.0) was
255
+ identical.
256
+ - *Not a reading failure.* In the same context the model reports `18452` has 5
257
+ digits and echoes it back verbatim.
258
+
259
+ **Do not "fix" this by replacing `tokenizer.json` with the base checkpoint's.**
260
+ The upstream `stepfun-ai/Step-3.7-Flash` `tokenizer.json` is not usable
261
+ standalone read directly by the `tokenizers` library (which is what MLX
262
+ runtimes do) it yields **zero tokens for Chinese** (`你好世界` → `[]`) and
263
+ mangles English word boundaries; it only works via `transformers`' load-time
264
+ reconstruction. This checkpoint ships the correct, standalone-usable tokenizer.
265
+
266
+ **Practical guidance.** Keep temperature low for numeric/tool work; for
267
+ agentic use, verify numeric arguments programmatically rather than relying on
268
+ the model to re-check its own output. A helper (`scripts/numeric_guard.py`) and
269
+ a Pi editor extension for exactly this are included in the source repo.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
270
 
271
  ## Reasoning
272
 
 
319
  - No BF16 multimodal reference was run, so absolute (not just relative)
320
  vision-task damage from quantization remains unmeasured.
321
  - This checkpoint has not been evaluated on standard downstream benchmarks
322
+ (MMLU, GSM8K, SWE-bench, etc.). The category tables are perplexity/NLL
323
+ proxies; the *Capability validation* section adds small exact-answer accuracy
324
+ spot checks (39 items), but these are not a substitute for a full benchmark
325
+ suite.
326
  - **JSON-schema structured output can crash `llguidance`-based runtimes.**
327
  This is a base-model tokenizer interaction, not a defect introduced by the
328
  pruning or quantization here — it reproduces with zero model weights loaded,
artifacts/acc_ream.json ADDED
@@ -0,0 +1,187 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "model": "step-3.7-p15-ream-shared8-head8",
3
+ "overall": [
4
+ 23,
5
+ 24
6
+ ],
7
+ "by_cat": {
8
+ "math": [
9
+ 13,
10
+ 14
11
+ ],
12
+ "factual": [
13
+ 10,
14
+ 10
15
+ ]
16
+ },
17
+ "results": [
18
+ {
19
+ "cat": "math",
20
+ "expected": "330",
21
+ "ok": true,
22
+ "answer": "330",
23
+ "finish": "stop"
24
+ },
25
+ {
26
+ "cat": "math",
27
+ "expected": "84",
28
+ "ok": true,
29
+ "answer": "84",
30
+ "finish": "stop"
31
+ },
32
+ {
33
+ "cat": "math",
34
+ "expected": "120",
35
+ "ok": false,
36
+ "answer": "52",
37
+ "finish": "stop"
38
+ },
39
+ {
40
+ "cat": "math",
41
+ "expected": "355",
42
+ "ok": true,
43
+ "answer": "355",
44
+ "finish": "stop"
45
+ },
46
+ {
47
+ "cat": "math",
48
+ "expected": "18",
49
+ "ok": true,
50
+ "answer": "18",
51
+ "finish": "stop"
52
+ },
53
+ {
54
+ "cat": "math",
55
+ "expected": "270",
56
+ "ok": true,
57
+ "answer": "270",
58
+ "finish": "stop"
59
+ },
60
+ {
61
+ "cat": "math",
62
+ "expected": "101",
63
+ "ok": true,
64
+ "answer": "101",
65
+ "finish": "stop"
66
+ },
67
+ {
68
+ "cat": "math",
69
+ "expected": "114",
70
+ "ok": true,
71
+ "answer": "114",
72
+ "finish": "stop"
73
+ },
74
+ {
75
+ "cat": "math",
76
+ "expected": "215",
77
+ "ok": true,
78
+ "answer": "215",
79
+ "finish": "stop"
80
+ },
81
+ {
82
+ "cat": "math",
83
+ "expected": "60",
84
+ "ok": true,
85
+ "answer": "60",
86
+ "finish": "stop"
87
+ },
88
+ {
89
+ "cat": "math",
90
+ "expected": "1",
91
+ "ok": true,
92
+ "answer": "1",
93
+ "finish": "stop"
94
+ },
95
+ {
96
+ "cat": "math",
97
+ "expected": "25",
98
+ "ok": true,
99
+ "answer": "25",
100
+ "finish": "stop"
101
+ },
102
+ {
103
+ "cat": "math",
104
+ "expected": "518.4",
105
+ "ok": true,
106
+ "answer": "To calculate the final price:\n1. First apply the 20% discount: $600 * (1 - 0.2) ",
107
+ "finish": "stop"
108
+ },
109
+ {
110
+ "cat": "math",
111
+ "expected": "36",
112
+ "ok": true,
113
+ "answer": "36",
114
+ "finish": "stop"
115
+ },
116
+ {
117
+ "cat": "factual",
118
+ "expected": "Au",
119
+ "ok": true,
120
+ "answer": "Au",
121
+ "finish": "stop"
122
+ },
123
+ {
124
+ "cat": "factual",
125
+ "expected": "1969",
126
+ "ok": true,
127
+ "answer": "1969",
128
+ "finish": "stop"
129
+ },
130
+ {
131
+ "cat": "factual",
132
+ "expected": "Ottawa",
133
+ "ok": true,
134
+ "answer": "Ottawa",
135
+ "finish": "stop"
136
+ },
137
+ {
138
+ "cat": "factual",
139
+ "expected": "6",
140
+ "ok": true,
141
+ "answer": "6",
142
+ "finish": "stop"
143
+ },
144
+ {
145
+ "cat": "factual",
146
+ "expected": "Mars",
147
+ "ok": true,
148
+ "answer": "Mars",
149
+ "finish": "stop"
150
+ },
151
+ {
152
+ "cat": "factual",
153
+ "expected": "Pacific",
154
+ "ok": true,
155
+ "answer": "Pacific",
156
+ "finish": "stop"
157
+ },
158
+ {
159
+ "cat": "factual",
160
+ "expected": "Shakespeare",
161
+ "ok": true,
162
+ "answer": "Shakespeare",
163
+ "finish": "stop"
164
+ },
165
+ {
166
+ "cat": "factual",
167
+ "expected": "0",
168
+ "ok": true,
169
+ "answer": "0",
170
+ "finish": "stop"
171
+ },
172
+ {
173
+ "cat": "factual",
174
+ "expected": "carbon dioxide",
175
+ "ok": true,
176
+ "answer": "Carbon dioxide",
177
+ "finish": "stop"
178
+ },
179
+ {
180
+ "cat": "factual",
181
+ "expected": "90",
182
+ "ok": true,
183
+ "answer": "90",
184
+ "finish": "stop"
185
+ }
186
+ ]
187
+ }
artifacts/acc_shared8.json ADDED
@@ -0,0 +1,187 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "model": "step-3.7-p15-vblend-shared8-head8",
3
+ "overall": [
4
+ 24,
5
+ 24
6
+ ],
7
+ "by_cat": {
8
+ "math": [
9
+ 14,
10
+ 14
11
+ ],
12
+ "factual": [
13
+ 10,
14
+ 10
15
+ ]
16
+ },
17
+ "results": [
18
+ {
19
+ "cat": "math",
20
+ "expected": "330",
21
+ "ok": true,
22
+ "answer": "330",
23
+ "finish": "stop"
24
+ },
25
+ {
26
+ "cat": "math",
27
+ "expected": "84",
28
+ "ok": true,
29
+ "answer": "84",
30
+ "finish": "stop"
31
+ },
32
+ {
33
+ "cat": "math",
34
+ "expected": "120",
35
+ "ok": true,
36
+ "answer": "120",
37
+ "finish": "stop"
38
+ },
39
+ {
40
+ "cat": "math",
41
+ "expected": "355",
42
+ "ok": true,
43
+ "answer": "355",
44
+ "finish": "stop"
45
+ },
46
+ {
47
+ "cat": "math",
48
+ "expected": "18",
49
+ "ok": true,
50
+ "answer": "18",
51
+ "finish": "stop"
52
+ },
53
+ {
54
+ "cat": "math",
55
+ "expected": "270",
56
+ "ok": true,
57
+ "answer": "270",
58
+ "finish": "stop"
59
+ },
60
+ {
61
+ "cat": "math",
62
+ "expected": "101",
63
+ "ok": true,
64
+ "answer": "101",
65
+ "finish": "stop"
66
+ },
67
+ {
68
+ "cat": "math",
69
+ "expected": "114",
70
+ "ok": true,
71
+ "answer": "114",
72
+ "finish": "stop"
73
+ },
74
+ {
75
+ "cat": "math",
76
+ "expected": "215",
77
+ "ok": true,
78
+ "answer": "To solve this:\n1. Interpret the formatted times correctly: departure is **09:45",
79
+ "finish": "stop"
80
+ },
81
+ {
82
+ "cat": "math",
83
+ "expected": "60",
84
+ "ok": true,
85
+ "answer": "60",
86
+ "finish": "stop"
87
+ },
88
+ {
89
+ "cat": "math",
90
+ "expected": "1",
91
+ "ok": true,
92
+ "answer": "1",
93
+ "finish": "stop"
94
+ },
95
+ {
96
+ "cat": "math",
97
+ "expected": "25",
98
+ "ok": true,
99
+ "answer": "25",
100
+ "finish": "stop"
101
+ },
102
+ {
103
+ "cat": "math",
104
+ "expected": "518.4",
105
+ "ok": true,
106
+ "answer": "518.4",
107
+ "finish": "stop"
108
+ },
109
+ {
110
+ "cat": "math",
111
+ "expected": "36",
112
+ "ok": true,
113
+ "answer": "36",
114
+ "finish": "stop"
115
+ },
116
+ {
117
+ "cat": "factual",
118
+ "expected": "Au",
119
+ "ok": true,
120
+ "answer": "Au",
121
+ "finish": "stop"
122
+ },
123
+ {
124
+ "cat": "factual",
125
+ "expected": "1969",
126
+ "ok": true,
127
+ "answer": "1969",
128
+ "finish": "stop"
129
+ },
130
+ {
131
+ "cat": "factual",
132
+ "expected": "Ottawa",
133
+ "ok": true,
134
+ "answer": "Ottawa",
135
+ "finish": "stop"
136
+ },
137
+ {
138
+ "cat": "factual",
139
+ "expected": "6",
140
+ "ok": true,
141
+ "answer": "6",
142
+ "finish": "stop"
143
+ },
144
+ {
145
+ "cat": "factual",
146
+ "expected": "Mars",
147
+ "ok": true,
148
+ "answer": "Mars",
149
+ "finish": "stop"
150
+ },
151
+ {
152
+ "cat": "factual",
153
+ "expected": "Pacific",
154
+ "ok": true,
155
+ "answer": "Pacific",
156
+ "finish": "stop"
157
+ },
158
+ {
159
+ "cat": "factual",
160
+ "expected": "Shakespeare",
161
+ "ok": true,
162
+ "answer": "Shakespeare",
163
+ "finish": "stop"
164
+ },
165
+ {
166
+ "cat": "factual",
167
+ "expected": "0",
168
+ "ok": true,
169
+ "answer": "0",
170
+ "finish": "stop"
171
+ },
172
+ {
173
+ "cat": "factual",
174
+ "expected": "carbon dioxide",
175
+ "ok": true,
176
+ "answer": "Carbon dioxide.",
177
+ "finish": "stop"
178
+ },
179
+ {
180
+ "cat": "factual",
181
+ "expected": "90",
182
+ "ok": true,
183
+ "answer": "90",
184
+ "finish": "stop"
185
+ }
186
+ ]
187
+ }
artifacts/accuracy_eval.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Discriminating eval: does REAM's -0.19 PPL reflect capability or smoothing?
2
+
3
+ PPL rewards a flatter output distribution; it cannot tell a real gain from a
4
+ harmless smoothing effect. This scores EXACT-ANSWER tasks right/wrong -- tasks
5
+ where there is one correct answer and smoothing cannot help. Same scorer for
6
+ both models, so the comparison is fair regardless of scorer strictness.
7
+
8
+ Prediction if -0.19 is smoothing: REAM accuracy ~= shared8-head8 accuracy,
9
+ especially on math (PPL already showed reasoning_math did NOT improve, +0.007).
10
+ If REAM is genuinely better, math accuracy rises.
11
+
12
+ Usage: python accuracy_eval.py <model-id> <out.json>
13
+ """
14
+ import json, re, sys, urllib.request
15
+
16
+ MODEL = sys.argv[1]
17
+ OUT = sys.argv[2] if len(sys.argv) > 2 else None
18
+
19
+ # (category, prompt, expected). Hand-written to avoid benchmark contamination;
20
+ # multi-step so smoothing cannot luck into them, but within a strong model's reach.
21
+ ITEMS = [
22
+ ("math", "A tank holds 480 liters. It drains at 12 liters per minute for 15 minutes, then is refilled by 30 liters. How many liters are in it now? Give only the number.", "330"),
23
+ ("math", "A book has 342 pages. Maria reads 18 pages a day for 9 days, then 24 pages a day for 4 days. How many pages are left? Number only.", "84"),
24
+ ("math", "There are 7 boxes with 23 apples each. 41 apples are rotten and removed. How many good apples remain? Number only.", "120"),
25
+ ("math", "A car travels 65 km/h for 3 hours, then 80 km/h for 2 hours. Total distance in km? Number only.", "355"),
26
+ ("math", "A store sells pens at 3 for $2. How much do 27 pens cost, in dollars? Number only.", "18"),
27
+ ("math", "Compute 144 divided by 8, then multiply the result by 15. Number only.", "270"),
28
+ ("math", "A rectangle is 14 by 9. A square of side 5 is cut out. Remaining area? Number only.", "101"),
29
+ ("math", "Sarah has $250. She buys 6 shirts at $18 each and 2 hats at $14 each. How much money is left? Number only.", "114"),
30
+ ("math", "A train departs at 09:45 and arrives at 13:20. Journey length in minutes? Number only.", "215"),
31
+ ("math", "If 5 machines make 5 widgets in 5 minutes, how many widgets do 5 machines make in 60 minutes? Number only.", "60"),
32
+ ("math", "A recipe needs 3 eggs per cake. You have 40 eggs. After making as many whole cakes as possible, how many eggs are left over? Number only.", "1"),
33
+ ("math", "The sum of three consecutive integers is 72. What is the largest of them? Number only.", "25"),
34
+ ("math", "A phone costs $600. It is discounted 20%, then 8% sales tax is added. Final price in dollars? Number only.", "518.4"),
35
+ ("math", "A garden is 12 m by 8 m. A path 1 m wide runs around the inside edge. Area of the path in square metres? Number only.", "36"),
36
+ ("factual", "What is the chemical symbol for gold? Symbol only.", "Au"),
37
+ ("factual", "In what year did the first human land on the Moon? Year only.", "1969"),
38
+ ("factual", "What is the capital city of Canada? One word.", "Ottawa"),
39
+ ("factual", "How many sides does a hexagon have? Number only.", "6"),
40
+ ("factual", "What planet is known as the Red Planet? One word.", "Mars"),
41
+ ("factual", "What is the largest ocean on Earth? One word.", "Pacific"),
42
+ ("factual", "Who wrote the play 'Romeo and Juliet'? Last name only.", "Shakespeare"),
43
+ ("factual", "What is the freezing point of water in Celsius? Number only.", "0"),
44
+ ("factual", "What gas do plants primarily absorb from the air for photosynthesis? Two words.", "carbon dioxide"),
45
+ ("factual", "How many degrees are in a right angle? Number only.", "90"),
46
+ ]
47
+
48
+
49
+ def ask(prompt, mt=3500):
50
+ body = json.dumps({"model": MODEL, "messages": [{"role": "user", "content": prompt}],
51
+ "temperature": 0.0, "top_p": 1.0, "top_k": 0, "min_p": 0.0,
52
+ "repetition_penalty": 1.0, "max_tokens": mt, "stream": False}).encode()
53
+ r = urllib.request.Request("http://localhost:1234/v1/chat/completions", data=body,
54
+ headers={"Content-Type": "application/json"})
55
+ d = json.load(urllib.request.urlopen(r, timeout=600))["choices"][0]
56
+ m = d["message"]
57
+ return (m.get("content") or "").strip(), d.get("finish_reason")
58
+
59
+
60
+ def norm_num(s):
61
+ # strip digit-internal separators (handles the corruption AND legit commas);
62
+ # both models scored identically so this is fair.
63
+ return re.sub(r"(?<=\d)[ ,_](?=\d)", "", s)
64
+
65
+
66
+ def check(ans, expected, cat):
67
+ a = norm_num(ans)
68
+ if cat == "factual" and not expected.replace(".", "").isdigit():
69
+ return expected.lower() in a.lower()
70
+ # numeric: compare the set of numbers present; credit if expected appears
71
+ want = expected
72
+ nums = re.findall(r"-?\d+\.?\d*", a)
73
+ # exact match, or match ignoring trailing .0
74
+ for n in nums:
75
+ if n == want or n.rstrip("0").rstrip(".") == want.rstrip("0").rstrip("."):
76
+ return True
77
+ return False
78
+
79
+
80
+ def self_test():
81
+ assert check("330", "330", "math")
82
+ assert check("The answer is 518.40 dollars.", "518.4", "math")
83
+ assert check("3 3 0", "330", "math") # space-corrupted
84
+ assert check("**Au**", "Au", "factual")
85
+ assert not check("The answer is 331.", "330", "math")
86
+ assert check("Ottawa is the capital.", "Ottawa", "factual")
87
+ print("[selftest] answer-extraction ok", flush=True)
88
+
89
+
90
+ if __name__ == "__main__":
91
+ self_test()
92
+ if MODEL == "SELFTEST":
93
+ sys.exit(0)
94
+ from collections import defaultdict
95
+ tally = defaultdict(lambda: [0, 0]); results = []
96
+ for i, (cat, q, exp) in enumerate(ITEMS):
97
+ try:
98
+ ans, fin = ask(q)
99
+ except Exception as e:
100
+ print(f" item {i} ERROR {e}", flush=True); continue
101
+ ok = check(ans, exp, cat)
102
+ tally[cat][0] += ok; tally[cat][1] += 1
103
+ results.append({"cat": cat, "expected": exp, "ok": ok, "answer": ans[:80], "finish": fin})
104
+ print(f" [{'OK ' if ok else 'XX '}] {cat:8} want={exp:14} got={ans[:44]!r}", flush=True)
105
+ tot_ok = sum(v[0] for v in tally.values()); tot_n = sum(v[1] for v in tally.values())
106
+ print(f"\n{MODEL}", flush=True)
107
+ for cat, (ok, n) in sorted(tally.items()):
108
+ print(f" {cat:10} {ok}/{n}", flush=True)
109
+ print(f" OVERALL {tot_ok}/{tot_n} ({100*tot_ok/tot_n:.0f}%)", flush=True)
110
+ if OUT:
111
+ json.dump({"model": MODEL, "overall": [tot_ok, tot_n],
112
+ "by_cat": {k: v for k, v in tally.items()}, "results": results},
113
+ open(OUT, "w"), indent=2)
artifacts/tc_ream.json ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "model": "step-3.7-p15-ream-shared8-head8",
3
+ "score": [
4
+ 14,
5
+ 15
6
+ ],
7
+ "results": [
8
+ {
9
+ "req": "Set a timer for 25 minutes.",
10
+ "ok": true,
11
+ "why": "ok",
12
+ "name": "set_timer",
13
+ "args": {
14
+ "minutes": 25
15
+ }
16
+ },
17
+ {
18
+ "req": "Convert 150 USD to EUR.",
19
+ "ok": true,
20
+ "why": "ok",
21
+ "name": "convert_currency",
22
+ "args": {
23
+ "amount": 150,
24
+ "from": "USD",
25
+ "to": "EUR"
26
+ }
27
+ },
28
+ {
29
+ "req": "Book a flight from Denver to Miami for 3 passengers.",
30
+ "ok": true,
31
+ "why": "ok",
32
+ "name": "book_flight",
33
+ "args": {
34
+ "from_city": "Denver",
35
+ "to_city": "Miami",
36
+ "passengers": 3
37
+ }
38
+ },
39
+ {
40
+ "req": "Resize the image to 800 by 600.",
41
+ "ok": true,
42
+ "why": "ok",
43
+ "name": "resize_image",
44
+ "args": {
45
+ "width": 800,
46
+ "height": 600
47
+ }
48
+ },
49
+ {
50
+ "req": "Set the thermostat to 68 degrees.",
51
+ "ok": true,
52
+ "why": "ok",
53
+ "name": "set_thermostat",
54
+ "args": {
55
+ "temperature": 68
56
+ }
57
+ },
58
+ {
59
+ "req": "Add 5 apples to my cart.",
60
+ "ok": true,
61
+ "why": "ok",
62
+ "name": "add_to_cart",
63
+ "args": {
64
+ "item": "apples",
65
+ "quantity": 5
66
+ }
67
+ },
68
+ {
69
+ "req": "Calculate an 18% tip on an 80 dollar bill.",
70
+ "ok": true,
71
+ "why": "ok",
72
+ "name": "calculate_tip",
73
+ "args": {
74
+ "bill": 80,
75
+ "percent": 18
76
+ }
77
+ },
78
+ {
79
+ "req": "Play Bohemian Rhapsody by Queen.",
80
+ "ok": true,
81
+ "why": "ok",
82
+ "name": "play_song",
83
+ "args": {
84
+ "title": "Bohemian Rhapsody",
85
+ "artist": "Queen"
86
+ }
87
+ },
88
+ {
89
+ "req": "What's the weather in Paris?",
90
+ "ok": true,
91
+ "why": "ok",
92
+ "name": "get_weather",
93
+ "args": {
94
+ "city": "Paris"
95
+ }
96
+ },
97
+ {
98
+ "req": "Get the stock price for AAPL.",
99
+ "ok": true,
100
+ "why": "ok",
101
+ "name": "get_stock_price",
102
+ "args": {
103
+ "ticker": "AAPL"
104
+ }
105
+ },
106
+ {
107
+ "req": "Find headphones under 200 dollars.",
108
+ "ok": true,
109
+ "why": "ok",
110
+ "name": "search_products",
111
+ "args": {
112
+ "query": "headphones",
113
+ "max_price": 200
114
+ }
115
+ },
116
+ {
117
+ "req": "Translate 'good morning' into Spanish.",
118
+ "ok": false,
119
+ "why": "target_lang: want 'Spanish' got 'es'",
120
+ "name": "translate",
121
+ "args": {
122
+ "text": "good morning",
123
+ "target_lang": "es"
124
+ }
125
+ },
126
+ {
127
+ "req": "Transfer 340 dollars from checking to savings.",
128
+ "ok": true,
129
+ "why": "ok",
130
+ "name": "transfer_money",
131
+ "args": {
132
+ "amount": 340,
133
+ "from_account": "checking",
134
+ "to_account": "savings"
135
+ }
136
+ },
137
+ {
138
+ "req": "Schedule a meeting on Tuesday at 3pm.",
139
+ "ok": true,
140
+ "why": "ok",
141
+ "name": "schedule_meeting",
142
+ "args": {
143
+ "day": "Tuesday",
144
+ "time": "3pm"
145
+ }
146
+ },
147
+ {
148
+ "req": "Send 12 invitations to the birthday event.",
149
+ "ok": true,
150
+ "why": "ok",
151
+ "name": "send_invitations",
152
+ "args": {
153
+ "count": 12,
154
+ "event": "birthday event"
155
+ }
156
+ }
157
+ ]
158
+ }
artifacts/tc_shared8.json ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "model": "step-3.7-p15-vblend-shared8-head8",
3
+ "score": [
4
+ 14,
5
+ 15
6
+ ],
7
+ "results": [
8
+ {
9
+ "req": "Set a timer for 25 minutes.",
10
+ "ok": true,
11
+ "why": "ok",
12
+ "name": "set_timer",
13
+ "args": {
14
+ "minutes": 25
15
+ }
16
+ },
17
+ {
18
+ "req": "Convert 150 USD to EUR.",
19
+ "ok": true,
20
+ "why": "ok",
21
+ "name": "convert_currency",
22
+ "args": {
23
+ "amount": 150,
24
+ "from": "USD",
25
+ "to": "EUR"
26
+ }
27
+ },
28
+ {
29
+ "req": "Book a flight from Denver to Miami for 3 passengers.",
30
+ "ok": true,
31
+ "why": "ok",
32
+ "name": "book_flight",
33
+ "args": {
34
+ "from_city": "Denver",
35
+ "to_city": "Miami",
36
+ "passengers": 3
37
+ }
38
+ },
39
+ {
40
+ "req": "Resize the image to 800 by 600.",
41
+ "ok": true,
42
+ "why": "ok",
43
+ "name": "resize_image",
44
+ "args": {
45
+ "width": 800,
46
+ "height": 600
47
+ }
48
+ },
49
+ {
50
+ "req": "Set the thermostat to 68 degrees.",
51
+ "ok": true,
52
+ "why": "ok",
53
+ "name": "set_thermostat",
54
+ "args": {
55
+ "temperature": 68
56
+ }
57
+ },
58
+ {
59
+ "req": "Add 5 apples to my cart.",
60
+ "ok": true,
61
+ "why": "ok",
62
+ "name": "add_to_cart",
63
+ "args": {
64
+ "item": "apples",
65
+ "quantity": 5
66
+ }
67
+ },
68
+ {
69
+ "req": "Calculate an 18% tip on an 80 dollar bill.",
70
+ "ok": true,
71
+ "why": "ok",
72
+ "name": "calculate_tip",
73
+ "args": {
74
+ "bill": 80,
75
+ "percent": 18
76
+ }
77
+ },
78
+ {
79
+ "req": "Play Bohemian Rhapsody by Queen.",
80
+ "ok": true,
81
+ "why": "ok",
82
+ "name": "play_song",
83
+ "args": {
84
+ "title": "Bohemian Rhapsody",
85
+ "artist": "Queen"
86
+ }
87
+ },
88
+ {
89
+ "req": "What's the weather in Paris?",
90
+ "ok": true,
91
+ "why": "ok",
92
+ "name": "get_weather",
93
+ "args": {
94
+ "city": "Paris"
95
+ }
96
+ },
97
+ {
98
+ "req": "Get the stock price for AAPL.",
99
+ "ok": true,
100
+ "why": "ok",
101
+ "name": "get_stock_price",
102
+ "args": {
103
+ "ticker": "AAPL"
104
+ }
105
+ },
106
+ {
107
+ "req": "Find headphones under 200 dollars.",
108
+ "ok": true,
109
+ "why": "ok",
110
+ "name": "search_products",
111
+ "args": {
112
+ "query": "headphones",
113
+ "max_price": 200
114
+ }
115
+ },
116
+ {
117
+ "req": "Translate 'good morning' into Spanish.",
118
+ "ok": false,
119
+ "why": "target_lang: want 'Spanish' got 'es'",
120
+ "name": "translate",
121
+ "args": {
122
+ "text": "good morning",
123
+ "target_lang": "es"
124
+ }
125
+ },
126
+ {
127
+ "req": "Transfer 340 dollars from checking to savings.",
128
+ "ok": true,
129
+ "why": "ok",
130
+ "name": "transfer_money",
131
+ "args": {
132
+ "amount": 340,
133
+ "from_account": "checking",
134
+ "to_account": "savings"
135
+ }
136
+ },
137
+ {
138
+ "req": "Schedule a meeting on Tuesday at 3pm.",
139
+ "ok": true,
140
+ "why": "ok",
141
+ "name": "schedule_meeting",
142
+ "args": {
143
+ "day": "Tuesday",
144
+ "time": "3pm"
145
+ }
146
+ },
147
+ {
148
+ "req": "Send 12 invitations to the birthday event.",
149
+ "ok": true,
150
+ "why": "ok",
151
+ "name": "send_invitations",
152
+ "args": {
153
+ "count": 12,
154
+ "event": "birthday event"
155
+ }
156
+ }
157
+ ]
158
+ }
artifacts/toolcall_eval.py ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Agentic discriminator: does REAM pick the right tool with the RIGHT arguments?
2
+
3
+ The math/factual accuracy test covered the category where PPL predicted no gain.
4
+ REAM's biggest PPL gains were tool_use (27->15.6) and coding/agentic -- untested
5
+ for capability. This scores tool-call correctness right/wrong: correct function
6
+ name AND correct arguments. Argument extraction is exactly what smoothing cannot
7
+ fake -- a flatter distribution does not produce '800 by 600' or 'USD->EUR'
8
+ correctly. Same scorer for both models.
9
+
10
+ Usage: python toolcall_eval.py <model-id> <out.json>
11
+ """
12
+ import json, re, sys, urllib.request
13
+
14
+ MODEL = sys.argv[1]
15
+ OUT = sys.argv[2] if len(sys.argv) > 2 else None
16
+
17
+ CATALOG = """Available functions (use EXACTLY these names and argument keys):
18
+ - set_timer(minutes)
19
+ - convert_currency(amount, from, to)
20
+ - book_flight(from_city, to_city, passengers)
21
+ - resize_image(width, height)
22
+ - set_thermostat(temperature)
23
+ - add_to_cart(item, quantity)
24
+ - calculate_tip(bill, percent)
25
+ - play_song(title, artist)
26
+ - get_weather(city)
27
+ - get_stock_price(ticker)
28
+ - search_products(query, max_price)
29
+ - translate(text, target_lang)
30
+ - transfer_money(amount, from_account, to_account)
31
+ - schedule_meeting(day, time)
32
+ - send_invitations(count, event)"""
33
+
34
+ SYS = (CATALOG + "\n\nCall exactly ONE function. Reply with ONLY a JSON object "
35
+ '{"name": <function>, "arguments": {<args>}} and nothing else. '
36
+ "Use the exact names above and the exact argument values implied by the request.")
37
+
38
+ # (request, expected_name, {arg: expected}). Numeric/string args are the
39
+ # discriminator; free-text args use keyword membership.
40
+ ITEMS = [
41
+ ("Set a timer for 25 minutes.", "set_timer", {"minutes": "25"}),
42
+ ("Convert 150 USD to EUR.", "convert_currency", {"amount": "150", "from": "USD", "to": "EUR"}),
43
+ ("Book a flight from Denver to Miami for 3 passengers.", "book_flight",
44
+ {"from_city": "Denver", "to_city": "Miami", "passengers": "3"}),
45
+ ("Resize the image to 800 by 600.", "resize_image", {"width": "800", "height": "600"}),
46
+ ("Set the thermostat to 68 degrees.", "set_thermostat", {"temperature": "68"}),
47
+ ("Add 5 apples to my cart.", "add_to_cart", {"item": "apples", "quantity": "5"}),
48
+ ("Calculate an 18% tip on an 80 dollar bill.", "calculate_tip", {"bill": "80", "percent": "18"}),
49
+ ("Play Bohemian Rhapsody by Queen.", "play_song", {"title": "Bohemian Rhapsody", "artist": "Queen"}),
50
+ ("What's the weather in Paris?", "get_weather", {"city": "Paris"}),
51
+ ("Get the stock price for AAPL.", "get_stock_price", {"ticker": "AAPL"}),
52
+ ("Find headphones under 200 dollars.", "search_products", {"query": "headphones", "max_price": "200"}),
53
+ ("Translate 'good morning' into Spanish.", "translate", {"text": "good morning", "target_lang": "Spanish"}),
54
+ ("Transfer 340 dollars from checking to savings.", "transfer_money",
55
+ {"amount": "340", "from_account": "checking", "to_account": "savings"}),
56
+ ("Schedule a meeting on Tuesday at 3pm.", "schedule_meeting", {"day": "Tuesday", "time": "3"}),
57
+ ("Send 12 invitations to the birthday event.", "send_invitations", {"count": "12", "event": "birthday"}),
58
+ ]
59
+
60
+
61
+ def ask(prompt, mt=3000):
62
+ body = json.dumps({"model": MODEL,
63
+ "messages": [{"role": "system", "content": SYS},
64
+ {"role": "user", "content": prompt}],
65
+ "temperature": 0.0, "top_p": 1.0, "top_k": 0, "min_p": 0.0,
66
+ "repetition_penalty": 1.0, "max_tokens": mt, "stream": False}).encode()
67
+ r = urllib.request.Request("http://localhost:1234/v1/chat/completions", data=body,
68
+ headers={"Content-Type": "application/json"})
69
+ m = json.load(urllib.request.urlopen(r, timeout=600))["choices"][0]["message"]
70
+ return (m.get("content") or "").strip()
71
+
72
+
73
+ def parse_call(text):
74
+ """Extract {name, arguments} from a JSON object or ```json block."""
75
+ t = re.sub(r"```(?:json)?", "", text)
76
+ # find the outermost {...} that has a "name"
77
+ for m in re.finditer(r"\{.*\}", t, re.S):
78
+ try:
79
+ o = json.loads(m.group(0))
80
+ if isinstance(o, dict) and "name" in o:
81
+ return o.get("name"), (o.get("arguments") or o.get("args") or {})
82
+ except Exception:
83
+ continue
84
+ return None, {}
85
+
86
+
87
+ def norm(v):
88
+ return re.sub(r"(?<=\d)[ ,_](?=\d)", "", str(v)).strip().lower()
89
+
90
+
91
+ def check(name, args, exp_name, exp_args):
92
+ if norm(name) != norm(exp_name):
93
+ return False, "name"
94
+ for k, want in exp_args.items():
95
+ got = args.get(k)
96
+ if got is None:
97
+ # allow the value to appear under any key (arg-naming can differ)
98
+ if any(norm(want) == norm(val) or norm(want) in norm(val)
99
+ for val in args.values()):
100
+ continue
101
+ return False, f"missing {k}={want}"
102
+ if norm(want) == norm(got) or norm(want) in norm(got):
103
+ continue
104
+ return False, f"{k}: want {want!r} got {got!r}"
105
+ return True, "ok"
106
+
107
+
108
+ def self_test():
109
+ n, a = parse_call('```json\n{"name": "set_timer", "arguments": {"minutes": 25}}\n```')
110
+ assert n == "set_timer" and str(a["minutes"]) == "25"
111
+ ok, _ = check(n, a, "set_timer", {"minutes": "25"}); assert ok
112
+ ok, why = check("set_timer", {"minutes": "30"}, "set_timer", {"minutes": "25"})
113
+ assert not ok, why
114
+ ok, _ = check("resize_image", {"w": "800", "h": "600"}, "resize_image",
115
+ {"width": "800", "height": "600"})
116
+ assert ok, "value-under-any-key fallback"
117
+ print("[selftest] tool-call parser ok", flush=True)
118
+
119
+
120
+ if __name__ == "__main__":
121
+ self_test()
122
+ if MODEL == "SELFTEST":
123
+ sys.exit(0)
124
+ ok_n = 0; results = []
125
+ for req, en, ea in ITEMS:
126
+ try:
127
+ raw = ask(req)
128
+ except Exception as e:
129
+ print(f" ERROR {e}", flush=True); continue
130
+ n, a = parse_call(raw)
131
+ ok, why = check(n, a, en, ea)
132
+ ok_n += ok
133
+ results.append({"req": req, "ok": ok, "why": why, "name": n, "args": a})
134
+ print(f" [{'OK ' if ok else 'XX '}] want {en}({ea}) -> got {n}({a}) {'' if ok else why}", flush=True)
135
+ print(f"\n{MODEL} TOOL-CALL {ok_n}/{len(ITEMS)} ({100*ok_n/len(ITEMS):.0f}%)", flush=True)
136
+ if OUT:
137
+ json.dump({"model": MODEL, "score": [ok_n, len(ITEMS)], "results": results},
138
+ open(OUT, "w"), indent=2)
docs/HEAD8-RESULT.md ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # head8: the always-on weight class the sweep never tested
2
+
3
+ **Date:** 2026-07-24. `lm_head` and `embed_tokens` at 8-bit instead of 4-bit,
4
+ on top of the shared8 deploy model. Improves every measured category for
5
+ +0.53 GB. Found while investigating a numeric-corruption report
6
+ (`TOKENIZER-INVESTIGATION.md`) — the head was the prime suspect, was cleared,
7
+ and was then noticed to be untested.
8
+
9
+ ## Why it was invisible
10
+
11
+ `SHARED8-RESULT.md`'s thesis: components that fire on **every token** carry the
12
+ quantization damage, because they get no implicit smoothing from top-k partial
13
+ activation. `lm_head`/`embed_tokens` are the extreme case — every token, and
14
+ every one of the 128,896 vocabulary rows participates in every argmax.
15
+
16
+ But the build predicate returns bare `True` for them, so they inherited the
17
+ 4-bit default, and `tomography_sweep.py`'s variants were six layer windows plus
18
+ shared8. The head was never a variant. It is the same blind spot twice over:
19
+ REAP saliency cannot see the shared expert, and the tomography sweep could not
20
+ see the head.
21
+
22
+ ## Method
23
+
24
+ Rather than rebuild 93 GB (the disk had no room, and a fresh build could differ
25
+ elsewhere), `scripts/build_head8_inplace.py` takes the deployed shared8
26
+ checkpoint, **hardlinks the 18 untouched shards**, and rewrites only the two
27
+ holding the head tensors — 5.7 GB of new bytes, ~2 minutes. Shared inodes prove
28
+ nothing outside those two tensors changed.
29
+
30
+ The 8-bit weights are quantized from the **BF16 base**, not from the student's
31
+ existing 4-bit head. This matters — see the failure below.
32
+
33
+ ## Result (ΔNLL vs shared8, 500 held-out prompts, negative = better)
34
+
35
+ | category | BF16 | shared8 | **+head8** | ΔNLL |
36
+ |---|---|---|---|---|
37
+ | agentic | 6.796 | 6.921 | **6.896** | −0.0037 |
38
+ | coding | 8.016 | 6.488 | **6.363** | −0.0194 |
39
+ | general_instruction | 6.184 | 5.115 | **5.082** | −0.0064 |
40
+ | reasoning_math | 2.472 | 2.485 | **2.482** | −0.0012 |
41
+ | tool_use | 43.855 | 27.177 | **26.544** | −0.0236 |
42
+ | **OVERALL** | 6.650 | 5.930 | **5.880** | **−0.0086** |
43
+
44
+ Every category improves, no regressions — the same clean sweep shared8 produced,
45
+ at half the cost. Magnitude is ~9% of shared8's −0.100, proportionate given
46
+ shared8 touched 177 modules and this touches one tensor pair. Largest gains are
47
+ `tool_use` and `coding`, the two most token-precision-sensitive categories,
48
+ mirroring shared8's pattern.
49
+
50
+ **Cost:** +0.53 GB (1.06B params, 4.5 → 8.5 bpw). 93 → 93.5 GB, ~4.68 bpw.
51
+
52
+ ## The failed first attempt (kept — it measures something useful)
53
+
54
+ The first build dequantized the student's existing **4-bit** head and
55
+ requantized it to 8-bit. Same tensors, same bit-width, same +0.53 GB:
56
+
57
+ | | OVERALL | vs shared8 |
58
+ |---|---|---|
59
+ | shared8 (4-bit head) | 5.930 | — |
60
+ | head8 **from BF16** | **5.880** | **−0.0086** |
61
+ | head8 **from the 4-bit head** | 6.010 | **+0.013** |
62
+
63
+ The two runs bracket the baseline in opposite directions, differing *only* in
64
+ source precision. Quantization is one-way: the wider container stored
65
+ already-degraded values and added a second rounding pass. Written up as
66
+ `FINDINGS.md` §8a, because it generalizes — converting a released quantized
67
+ checkpoint to another format is a quantization of an already-damaged model, not
68
+ a requantization of the original.
69
+
70
+ It also yields a free invariant: **more bits cannot beat the source**, so a
71
+ precision *increase* that measures worse means the source is wrong, not the
72
+ target. That is what caught this one.
73
+
74
+ ## How low can the head go?
75
+
76
+ `reap_stream/diag_head_digits.py` measures quantization perturbation on the
77
+ digit rows against the tightest inter-digit argmax margin (0.8463 in BF16):
78
+
79
+ | bits | gs | ratio | cosine | size |
80
+ |---|---|---|---|---|
81
+ | 8 | 64 | 0.007 | 0.999986 | 1.12 GB |
82
+ | 6 | 64 | 0.029 | 0.999764 | 0.86 GB |
83
+ | 4 | 64 | 0.121 | 0.995886 | 0.59 GB |
84
+ | 3 | 64 | 0.254 | — | 0.51 GB |
85
+ | 2 | 64 | 0.525 | — | 0.36 GB |
86
+
87
+ The ratio roughly doubles per bit removed; at 2-bit the noise exceeds half the
88
+ distance between adjacent digit rows. BF16 is not worth it — 8-bit is already
89
+ at 0.7% of the margin with cosine 0.999986, and BF16 costs +0.99 GB beyond
90
+ 8-bit to remove an error nothing can resolve.
91
+
92
+ **Policy for the 64GB recipe:** head at 8-bit; never below 4-bit. Now stated
93
+ explicitly in the predicate rather than inherited from the default, so lowering
94
+ the global bit-width cannot drag it down silently.
95
+
96
+ ## Status
97
+
98
+ - `scripts/build_student_shared8.py` predicate updated — head at 8-bit.
99
+ - **Promoted**: deployed at
100
+ `~/.lmstudio/models/truemod/Step-3.7-p15-vblend-shared8-head8` (symlinked into
101
+ `models/`), loaded at 262144 context / parallel 4. Note LM Studio does not
102
+ inherit per-model context or sampler settings across a checkpoint swap.
103
+ - **Not uploaded to HF** — blocked by the private-repo storage limit. The Hub
104
+ still carries the 4-bit-head weights.
105
+ - Multimodal NLL (250 held-out images) **not yet re-run** — shared8 was
106
+ validated on both instruments and this has only text PPL so far.
107
+
108
+ ## Artifacts
109
+
110
+ `artifacts/ppl-shared8-head8-500.json`,
111
+ `artifacts/ppl-head8-requant-from-4bit-500.json`,
112
+ `artifacts/ppl-p15-vblend-shared8-500.json` (baseline),
113
+ `scripts/build_head8_inplace.py`, `reap_stream/diag_head_digits.py`.
docs/REAM-RESULT.md ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # REAM: built, measured, rejected — and a clean case of PPL over-crediting
2
+
3
+ **Date:** 2026-07-25. Merging low-saliency experts (instead of pruning them)
4
+ was built end-to-end and evaluated. **Verdict: rejected as a deploy candidate.**
5
+ Its large perplexity gain turned out to be a smoothing artifact with no
6
+ capability behind it — the most useful thing the exercise produced.
7
+
8
+ ## What was built
9
+
10
+ `reap_stream/ream.py` (+ `test_ream.py`, 6/6 unit tests) and
11
+ `scripts/build_student_ream.py`. Each pruned expert is merged into its
12
+ most-similar kept expert (router-row cosine), and the kept expert becomes the
13
+ saliency-weighted average of itself plus everything it absorbed. Router matrix
14
+ and bias are merged the same way. Output count is identical to pruning (245/288)
15
+ — REAM changes the kept experts' *values*, not the count.
16
+
17
+ Built clean on the first end-to-end run: 1806 experts merged (43×42), 4.683
18
+ bpw, 93 GB, coherent and correct generation (17×23=391, exact Fibonacci, exact
19
+ tool-call JSON, correct Rayleigh-scattering prose). The merge code, which had
20
+ only seen synthetic tensors, worked on the real 198B model.
21
+
22
+ ## The trap: perplexity said it was a big win
23
+
24
+ 500 held-out prompts, vs the shared8-head8 deploy model:
25
+
26
+ | category | shared8-head8 ppl | REAM ppl | ΔNLL |
27
+ |---|---|---|---|
28
+ | tool_use | 26.54 | **15.57** | −0.533 |
29
+ | coding | 6.36 | **4.66** | −0.312 |
30
+ | general_instruction | 5.08 | **4.10** | −0.215 |
31
+ | agentic | 6.90 | **5.95** | −0.148 |
32
+ | reasoning_math | 2.48 | 2.50 | **+0.007** |
33
+ | **OVERALL** | 5.880 | **4.843** | **−0.194** |
34
+
35
+ −0.194 NLL overall is ~2× the shared8 + head8 gains *combined*, from a weight
36
+ merge, on a model measured to have no exploitable slack. Two tells said "don't
37
+ believe it": the magnitude was implausible, and the gains scaled with category
38
+ entropy while **reasoning_math — the one category with a single right answer —
39
+ did not improve.** That is the textbook signature of smoothing: averaging expert
40
+ weights flattens the output distribution, which perplexity rewards on
41
+ hedge-friendly text without improving the model.
42
+
43
+ ## The arbiter: exact-answer accuracy
44
+
45
+ Perplexity cannot distinguish a real gain from smoothing, so both models were
46
+ scored right/wrong on 24 exact-answer items (14 multi-step arithmetic + 10
47
+ factual) where smoothing cannot help — same scorer, temperature 0, parallel 1.
48
+ `scripts/accuracy_eval.py`, results in `artifacts/acc_{ream,shared8}.json`.
49
+
50
+ | model | overall | math | factual | PPL |
51
+ |---|---|---|---|---|
52
+ | **shared8-head8 (prune)** | **24/24** | 14/14 | 10/10 | 5.880 |
53
+ | REAM (merge) | 23/24 | 13/14 | 10/10 | 4.843 |
54
+
55
+ **The −0.194 PPL advantage bought zero accuracy** — REAM is one item behind
56
+ (noise). Every prediction held: PPL said math wouldn't improve, and math
57
+ accuracy is level; the PPL gains were all in high-entropy categories, exactly
58
+ where a flatter distribution lowers perplexity for free.
59
+
60
+ ## Follow-up: the agentic categories, tested directly (the important one)
61
+
62
+ The accuracy test above was math + factual -- the category where PPL predicted
63
+ no gain. But REAM's *biggest* PPL gains were tool_use (-0.53) and coding, which
64
+ that test did not cover. So it was re-run on 15 tool-call items scored right/
65
+ wrong: correct function name (from a provided catalog) AND correct extracted
66
+ arguments -- exactly what smoothing cannot fake. `scripts/toolcall_eval.py`,
67
+ `artifacts/tc_{ream,shared8}.json`.
68
+
69
+ | test | shared8-head8 (prune) | REAM (merge) |
70
+ |---|---|---|
71
+ | tool-call accuracy | **14/15** | **14/15** |
72
+ | tool_use *perplexity* | 26.5 | 15.6 (**-0.53 NLL**) |
73
+
74
+ **A 41% lower perplexity on tool_use converted to zero tool-call capability --
75
+ a dead tie.** This is the strongest case against PPL in the whole project: its
76
+ largest and most agentic-relevant signal (-0.53 NLL on the exact category that
77
+ matters for agent work) was worth nothing in practice. If PPL were trustworthy
78
+ anywhere it would be where its signal is biggest; that is precisely where it
79
+ most oversold.
80
+
81
+ ## Conclusions
82
+
83
+ 1. **Pruning stays.** REAM is not a deploy candidate. On this prune-resistant,
84
+ flat-saliency model, merging the least-salient experts produces a mushier
85
+ model that scores better on perplexity and no better on task accuracy -- including tool-call/agentic tasks, tested directly.
86
+ 2. **This is the session's central lesson, demonstrated.** PPL moved −0.194
87
+ while real capability was flat-to-worse. Trusting the perplexity number would
88
+ have shipped a worse model as an upgrade. Perplexity is a proxy; on any
89
+ quality change, confirm with a non-PPL arbiter before promoting.
90
+ 3. **The co-occurrence gate would have predicted this** (docs/FINDINGS.md §10):
91
+ flat saliency + prune-resistance implies merge partners are near-orthogonal,
92
+ so blending them averages away specialization. We skipped that gate to
93
+ measure directly; the direct measurement agrees.
94
+
95
+ ## Kept
96
+
97
+ Build + eval code retained — REAM is a correct, working method, just not a win
98
+ *here*. It could still help a genuinely redundant MoE (one with a real
99
+ low-saliency tail), and `assign_merges` accepts a co-occurrence matrix for a
100
+ principled partner choice when that data exists. Artifacts:
101
+ `artifacts/ppl-ream-shared8-head8-500.json`, `artifacts/acc_ream.json`,
102
+ `artifacts/acc_shared8.json`, `scripts/accuracy_eval.py`, `reap_stream/ream.py`.
103
+ The 93 GB REAM checkpoint can be deleted (rebuildable from
104
+ `scripts/build_student_ream.py`).
docs/TOKENIZER-INVESTIGATION.md ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Tokenizer investigation: no defect found; digit fragmentation is by design
2
+
3
+ **Date:** 2026-07-24. Chasing numeric corruption in LM Studio led through a
4
+ wrong diagnosis and a reverted "fix". Conclusion: **there is no tokenizer bug.**
5
+ The build script's tokenizer is correct, single-digit number tokenization is
6
+ Step-3.7's intended behaviour, and the numeric fragility it causes is a property
7
+ of the model rather than something to repair in the checkpoint.
8
+
9
+ Recorded because the wrong version of this was believed for several hours and
10
+ acted on, including edits to four build scripts and two pushes to the Hub.
11
+
12
+ ## The claim that was wrong
13
+
14
+ `processor.save_pretrained(out_dir)` writes a different `tokenizer.json` than
15
+ the base checkpoint ships — 2 pretokenizer stages instead of 4, dropping a
16
+ `\p{N}{1,3}` digit-grouping rule. That looked like the build silently corrupting
17
+ the tokenizer, and it fit the symptom: numbers tokenised one digit per token,
18
+ so a number arrives as loose fragments with nothing binding it into a unit.
19
+
20
+ Restoring the base file even *fixed the numeric cases*:
21
+
22
+ | case | built student | base tokenizer |
23
+ |---|---|---|
24
+ | `kill process ID 18452` | 0/12 | **6/6** |
25
+ | `sed -n '447,495p'` | corrupted to `'4.4.7,4.9.5p'` | **6/6** |
26
+
27
+ ## Why it was wrong
28
+
29
+ The base `tokenizer.json` **cannot be used standalone.** Read directly by the
30
+ `tokenizers` library — which is what LM Studio's MLX backend does — it is not
31
+ merely imperfect, it is broken:
32
+
33
+ ```
34
+ built student (deployed) base tokenizer.json
35
+ 'hello world' -> ['hello','Ġworld'] OK ['hell','ow','orld'] 'helloworld'
36
+ 'id is 5' -> ['id','Ġis','Ġ','5'] OK ['idis','5'] 'idis5'
37
+ '你好世界' -> ['ä½łå¥½','ä¸ĸçķĮ'] OK [] ''
38
+ '中文 with english' -> OK ['wit','heng','lish'] 'withenglish'
39
+ ```
40
+
41
+ **Chinese produces zero tokens**, and English word boundaries land in the wrong
42
+ places. This is why `tokenizer_config.json` carries `fix_mistral_regex: true`:
43
+ `transformers` *reconstructs* a working tokenizer at load and never reads the
44
+ shipped file. `save_pretrained` then serialises that working reconstruction into
45
+ a standalone file — which is **correct behaviour, not corruption.** The build
46
+ script was fixing an unusable upstream artifact, not breaking a good one.
47
+
48
+ Deploying the base tokenizer broke generation outright: output came back as
49
+ `Thefindcommandfailedbecauseofspacesinthepath.`, shell commands failed on
50
+ unquoted paths, and the agent stalled. Reverted locally and on the Hub.
51
+
52
+ ## Consequence
53
+
54
+ Single-digit number tokenization is **genuine and intended**. Step-3.7 emits one
55
+ token per digit (ids 19–28) and a standalone space token (223), so numbers carry
56
+ no token-level redundancy: one mis-sampled or mis-copied token corrupts an entire
57
+ number, while prose absorbs it. That is the real explanation for
58
+ "numbers are wrong, everything else is perfect", and it is not fixable in the
59
+ checkpoint.
60
+
61
+ The model's own chain of thought describing input as *"process ID 1 8 4 5"* is
62
+ therefore an accurate report of what it receives, not a hallucination.
63
+
64
+ ## How the mistake happened
65
+
66
+ Three failures worth remembering:
67
+
68
+ 1. **Evidence collected and dismissed.** The space-dropping round-trip
69
+ (`'The order id is 2456.'` → `'Theorderidis2456.'`) was measured early and
70
+ written off as a `transformers`-path quirk.
71
+ 2. **Verification that could not fail.** The "fix" was checked by decoding
72
+ tokens *individually* (`['sed','-n','447',',','495','p']`), which
73
+ structurally cannot reveal a missing space.
74
+ 3. **Only testing English.** One Chinese string would have exposed it
75
+ immediately — `你好世界` → `[]`. Step-3.7 is a Chinese-English model; that
76
+ should have been in the first test, not the last.
77
+
78
+ **Rule:** validate any tokenizer change with a full-string round-trip through
79
+ `mlx_lm`'s streaming detokenizer, in **both** languages.
80
+
81
+ ## Guardrails: removed
82
+
83
+ An earlier version of this document instructed all four builders to copy the
84
+ base tokenizer over `save_pretrained`'s output. Those calls have been removed
85
+ from `build_student{,_shared8,_tiered}.py` and `tomography_sweep.py`, and each
86
+ now carries a comment explaining why the base file must not be copied.
87
+
88
+ ## Ruled out along the way
89
+
90
+ Useful negatives, each measured:
91
+
92
+ - **Quantized output head.** Digit rows carry 1.033x the all-row reconstruction
93
+ error; perturbation is 12% of the tightest inter-digit argmax margin.
94
+ `reap_stream/diag_head_digits.py`.
95
+ - **REAP / degraded digit copying.** Teacher-forced probes, no sampler in path,
96
+ rank the correct next digit **first** at p = 0.91–0.9996.
97
+ `reap_stream/diag_digit_logits.py`.
98
+ - **`repetition_penalty`.** A/B at 1.02 vs 1.0: 0/8 and 1/8 vs 0/8 and 0/8.
99
+ (LM Studio's per-model UI sampler settings silently override
100
+ `generation_config.json` — pin sampler fields explicitly when testing.)
101
+ - **`reasoning_effort`.** `Reasoning: medium` fixed one case (0/12 → 12/12) and
102
+ made another much worse. Not a mitigation.
103
+ - **SFT / QLoRA.** Rejected: the capability is intact and no existing instrument
104
+ could verify success or catch regression.
105
+ - **PPL/NLL confounding.** Claimed at one point, false: `transformers` rebuilds
106
+ the tokenizer at load, so all evaluations used identical tokenisation
107
+ (identical `n_tokens: 383130`; 0-token diff over 40 held-out rows).
108
+
109
+ ## Mitigation
110
+
111
+ Since the fragility is intrinsic, the mitigation is operational:
112
+
113
+ - `scripts/numeric_guard.py` — signature checks, provenance, sed bounds, and an
114
+ AST check for floats in integer-only positions (valid Python that fails only
115
+ at runtime).
116
+ - `~/.pi/agent/extensions/numeric-guard.ts` — blocks corrupted tool calls with
117
+ an actionable reason. Errors are surfaced and the call allowed through, so the
118
+ guard can never itself stall a turn. Retrying does not help — output is
119
+ identical every time; telling the model *what* was wrong does.
120
+
121
+ ## Still open
122
+
123
+ - **The echo case.** `2456, 1337, 495` → `2 4 5 6, 1 3 3 7, 4 9 5`, 0/12, never
124
+ resolved.
125
+ - **Whether upstream Step-3.7 behaves the same.** No unpruned Step-3.7 exists
126
+ locally as a loadable quant; the Hub's 148B is a *different* REAP (212/288
127
+ experts, nvfp4), so it is not a control.
scripts/numeric_guard.py ADDED
@@ -0,0 +1,175 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Catch Step-3.7's numeric corruption before a tool call executes.
3
+
4
+ Meant to be copied into an agent harness as a pre-tool-use check, not run as
5
+ part of the REAP pipeline. See the HF README's "numbers corrupted inside the
6
+ reasoning block" section for the measured behaviour this defends against.
7
+
8
+ Why code and not the model: the failure survives self-review. In one traced
9
+ run the model re-read the question 20+ times and reaffirmed the wrong value
10
+ each time, because by then its own output was the strongest evidence in
11
+ context. A checker that does not share that context does not share the bias.
12
+
13
+ Three checks, cheapest first:
14
+
15
+ scan_signature(text) known corruption shapes -- 4.4.7, "1 8 4 5",
16
+ 2,4,5,6,1,3,3,7. No source needed.
17
+ check_provenance(cmd,src) every numeral in cmd must appear in src. Catches
18
+ invented numbers that happen to look well-formed.
19
+ check_sed_bounds(cmd) sed line ranges against the real file length.
20
+
21
+ Usage:
22
+ from numeric_guard import guard
23
+ problems = guard(proposed_command, source_text=file_contents)
24
+ if problems: ...retry instead of executing...
25
+
26
+ $ python3 scripts/numeric_guard.py "sed -n '4.4.7,4.9.5p' src/aero.py"
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import re
31
+ import sys
32
+ from pathlib import Path
33
+
34
+ # 1.2.0 / 4.4.7 -- a numeral carrying more interior dots than a decimal can.
35
+ # Version-like strings are legitimate in many contexts, so this is reported
36
+ # rather than treated as certainly wrong; see guard()'s `strict` flag.
37
+ MULTI_DOT = re.compile(r"(?<![\w.])\d+\.\d+\.\d+(?![\w.])")
38
+
39
+ # "1 8 4 5" / "2 4 5 6" -- three or more single digits separated by spaces.
40
+ SPACED_DIGITS = re.compile(r"(?<!\d)\d(?: \d){2,}(?!\d)")
41
+
42
+ # "2,4,5,6,1,3,3,7" -- single digits comma-separated. Distinguished from a
43
+ # real list like "2456, 1337" by every element being exactly one digit.
44
+ COMMA_DIGITS = re.compile(r"(?<!\d)\d(?:,\d){3,}(?!\d)")
45
+
46
+ NUMERALS = re.compile(r"\d+")
47
+ SED_RANGE = re.compile(r"sed\s+-n\s+['\"]?(\d+),(\d+)p['\"]?\s+(\S+)")
48
+
49
+ # 5_1_8_4_0_0 -- underscore inserted between single digits. Python's numeric
50
+ # separators make `_` a legitimate delimiter, which primes exactly this. Note
51
+ # `5_1_8_4_0_0.0 == 518400.0` is True, so in Python this is cosmetic rather
52
+ # than wrong -- but it is a hard error in JSON, YAML and shell.
53
+ # Trailing `.` must be allowed -- these appear as float literals (5_1_8_4_0_0.0).
54
+ UNDERSCORE_NUM = re.compile(r"(?<![\w.])\d[\d_]*_[\d_]*\d(?!\w)")
55
+
56
+
57
+ def _bad_underscore_grouping(lit: str) -> bool:
58
+ """True unless the literal uses conventional 3-digit grouping.
59
+
60
+ Legitimate: 5_000_000, 1_234, 12_345_678 -- every group after the first is
61
+ exactly 3 digits and the first is 1-3. Anything else (5_1_8_4, 1_00_000)
62
+ is the corruption signature.
63
+ """
64
+ groups = lit.split("_")
65
+ if any(g == "" for g in groups):
66
+ return True
67
+ return not (1 <= len(groups[0]) <= 3 and all(len(g) == 3 for g in groups[1:]))
68
+
69
+
70
+ def scan_signature(text: str, strict: bool = False) -> list[str]:
71
+ """Known corruption shapes. No source text required."""
72
+ out = []
73
+ for m in SPACED_DIGITS.finditer(text):
74
+ out.append(f"digits split by spaces: {m.group(0)!r}")
75
+ for m in COMMA_DIGITS.finditer(text):
76
+ out.append(f"digits split by commas: {m.group(0)!r}")
77
+ for m in UNDERSCORE_NUM.finditer(text):
78
+ if _bad_underscore_grouping(m.group(0)):
79
+ out.append(f"digits split by underscores: {m.group(0)!r} "
80
+ f"(valid Python, but wrong in JSON/YAML/shell)")
81
+ for m in MULTI_DOT.finditer(text):
82
+ label = "malformed number" if strict else "version-like numeral (check)"
83
+ out.append(f"{label}: {m.group(0)!r}")
84
+ return out
85
+
86
+
87
+ def check_provenance(command: str, source: str, min_len: int = 3) -> list[str]:
88
+ """Every numeral of >=min_len digits in `command` must occur in `source`.
89
+
90
+ min_len avoids flagging small incidental numbers (-9, exit codes, 0/1).
91
+ Numbers the model legitimately *computed* will also trip this, so treat
92
+ hits as "confirm before running", not as proof of corruption.
93
+ """
94
+ return [f"numeral {n!r} does not appear in the source"
95
+ for n in {m.group(0) for m in NUMERALS.finditer(command)}
96
+ if len(n) >= min_len and n not in source]
97
+
98
+
99
+ def check_sed_bounds(command: str, root: str | Path = ".") -> list[str]:
100
+ """sed line ranges against the file's real length."""
101
+ out = []
102
+ for start, end, path in SED_RANGE.findall(command):
103
+ p = Path(root) / path
104
+ if not p.exists():
105
+ continue
106
+ n = sum(1 for _ in p.open(errors="replace"))
107
+ s, e = int(start), int(end)
108
+ if s > e:
109
+ out.append(f"sed range {s},{e} is inverted")
110
+ if s > n or e > n:
111
+ out.append(f"sed range {s},{e} exceeds {path} ({n} lines)")
112
+ return out
113
+
114
+
115
+ def check_python_int_positions(code: str) -> list[str]:
116
+ """Float literals where Python requires an int: slice indices, range().
117
+
118
+ This is the corruption's most dangerous form, because one inserted '.'
119
+ yields a *valid* float that no shape-based check can distinguish from a
120
+ legitimate one. `content[idx-5:idx+1.5]` parses fine and fails only at
121
+ runtime with "slice indices must be integers" -- the exact loop seen in
122
+ the wild. Catching it needs the syntactic position, not the literal.
123
+ """
124
+ import ast
125
+ try:
126
+ tree = ast.parse(code)
127
+ except SyntaxError:
128
+ return []
129
+
130
+ out = []
131
+
132
+ def floats_in(node):
133
+ return [n for n in ast.walk(node)
134
+ if isinstance(n, ast.Constant) and isinstance(n.value, float)]
135
+
136
+ for node in ast.walk(tree):
137
+ if isinstance(node, ast.Subscript):
138
+ for f in floats_in(node.slice):
139
+ out.append(f"float {f.value!r} used as a slice index "
140
+ f"(line {f.lineno}) -- fails at runtime")
141
+ elif isinstance(node, ast.Call) and getattr(node.func, "id", "") == "range":
142
+ for arg in node.args:
143
+ for f in floats_in(arg):
144
+ out.append(f"float {f.value!r} passed to range() "
145
+ f"(line {f.lineno}) -- fails at runtime")
146
+ return out
147
+
148
+
149
+ def guard(command: str, source_text: str | None = None,
150
+ root: str | Path = ".", strict: bool = False,
151
+ as_python: bool = False) -> list[str]:
152
+ """All applicable checks. Empty list means nothing suspicious."""
153
+ problems = scan_signature(command, strict=strict)
154
+ if source_text is not None:
155
+ problems += check_provenance(command, source_text)
156
+ problems += check_sed_bounds(command, root)
157
+ if as_python:
158
+ problems += check_python_int_positions(command)
159
+ return problems
160
+
161
+
162
+ if __name__ == "__main__":
163
+ if len(sys.argv) < 2:
164
+ print(__doc__.strip().split("Usage:")[-1].strip())
165
+ raise SystemExit(2)
166
+ cmd = sys.argv[1]
167
+ src = Path(sys.argv[2]).read_text() if len(sys.argv) > 2 else None
168
+ found = guard(cmd, source_text=src)
169
+ if not found:
170
+ print("ok")
171
+ else:
172
+ print(f"SUSPECT: {cmd!r}")
173
+ for p in found:
174
+ print(f" - {p}")
175
+ raise SystemExit(1)