File size: 8,588 Bytes
6c471d8
4de8125
50f2856
 
 
6c471d8
50f2856
6c471d8
50f2856
4de8125
50f2856
 
 
 
 
6c471d8
 
4de8125
50f2856
 
 
 
 
4de8125
 
 
50f2856
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4de8125
50f2856
4de8125
50f2856
 
 
 
 
 
 
 
 
4de8125
50f2856
 
4de8125
50f2856
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4de8125
50f2856
 
 
 
 
 
4de8125
50f2856
 
 
4de8125
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
---
title: Scott Wu Math
emoji: πŸƒ
colorFrom: green
colorTo: gray
sdk: docker
app_port: 7860
pinned: false
license: apache-2.0
short_description: Scott Wu's card game as an OpenEnv RL environment
tags:
  - openenv
  - reinforcement-learning
  - math
  - reasoning
---

# Scott Wu Math

Deal some playing cards, name a number, and make that number from the cards with + βˆ’ Γ— Γ·, using every
card exactly once. People and language models play the same environment: the page at `/` drives the
same OpenEnv session over `/ws` that an agent does.

Named after the game Scott Wu plays in the clip below, as a fan tribute. It is not affiliated with or
endorsed by Scott Wu or Cognition.

## Where it comes from

In a clip filmed at a pub, Cognition's CEO Scott Wu explains the game he plays with a deck of cards.
It starts as the 24 game: four cards, make 24. "Once that got too easy", you take eight cards and make
the current year. Dealt K Q J 10 7 9 5 5, he says it is quite easy because 13 βˆ’ 12 is 1, 11 βˆ’ 10 is 1,
1 + 1 + 7 is 9, and 9 Γ— 9 Γ— 5 Γ— 5 is exactly 2025. Then someone names a four-digit number, 6,843, and
with nine cards (6 4 Q 10 10 8 J 7 3) he goes: 6 + 4 is 10, 12 βˆ’ 10 is 2, 2 Γ— 10 Γ— 10 is 200, plus 8 is
208, times 11 is 2,288, minus 7 is 2,281, times 3 is 6,843.

Both hands are in the `video` split, as he played them.

## Rules

- Use every card exactly once. A = 1, J = 11, Q = 12, K = 13.
- Combine with + βˆ’ Γ— Γ· and parentheses. Division is exact, so fractions along the way are allowed, as in the 24 game.
- No joining cards into longer numbers, no powers, no unary minus.
- One graded answer per deal.

## Levels

| Level | Cards | Target | Notes |
|---|---|---|---|
| `24` | 4 | 24 | The classic 24 game. About a third of random deals have no solution; those are redealt. |
| `three-digit` | 6 | 100–999 | A step between 24 and the year. |
| `year` | 8 | 1900–2100 | The play page and a bare `reset()` use the current year, as in the video. |
| `four-digit` | 9 | 1000–9999 | "Give me your favourite four-digit number". |
| custom | 4–9 | any up to 1,000,000 | `reset(target=6843)`; card count by size unless `n_cards` is given. |

Cards come from one 52-card deck, so a value appears at most four times. Every generated deal is checked
with the solver and redealt until it has a solution, so a task is never impossible.

## Episode

```
reset(...)  ->  observation: cards, target, prompt          (done = false)
step(CardAction(expression="..."))  ->  reward, result, a reference solution   (done = true)
```

`reset` arguments, all optional:

| Argument | Meaning |
|---|---|
| `split`, `index` | A fixed task from `train` (4,000), `test` (200) or `video` (2). Without `index`, a random one. |
| `level` | A fresh deal at that level. |
| `target`, `n_cards` | A fresh deal for your own number. |
| `cards` + `target` | Your own hand, as values 1–13. It may have no solution. |
| `seed` | Makes a fresh deal reproducible. |
| `reward_mode` | `shaped` (default) or `binary`. |

The observation carries `prompt` and `system_prompt`, the exact text a model reads. The answer goes in
`CardAction.expression`; an empty expression gives up. After the step, the observation adds `result`
(the grader's report), `solution` and `solution_steps` (one way to make the target, one combination
per line), and `elapsed_s`. The reference solution is never shown before the episode ends.

## Reward

| Answer | Reward |
|---|---|
| Uses every card once and equals the target | 1.0 |
| Uses every card once, within 10% of the target (shaped mode) | `0.3 Γ— cΒ²`, `c = 1 βˆ’ |value βˆ’ target| / (0.1 Γ— target)` |
| Anything else: no answer, parse error, division by zero, a missing or extra card | 0 |

The grader parses the expression with Python's `ast`, allows only non-negative integers, + βˆ’ Γ— Γ· and
parentheses, compares the multiset of numbers to the cards, and evaluates with exact fractions. Γ— Γ· βˆ’
and the letters A J Q K are accepted; anything after `=` is ignored. Partial credit keeps a GRPO group of
near misses from all scoring 0, and is capped far below an exact answer, so getting close never pays
more than getting it right.

## Use it

```python
from scott_wu_math import CardAction, ScottWuMathEnv

with ScottWuMathEnv(base_url="https://fineenvs-scottwumath.hf.space").sync() as env:
    obs = env.reset(split="video", index=1).observation
    print(obs.prompt)
    result = env.step(CardAction(expression="(((12 - 10) * (6 + 4) * 10 + 8) * 11 - 7) * 3"))
    print(result.reward, result.observation.result["check"]["value"])   # 1.0 6843
```

Over plain WebSocket (what the play page does): connect to `/ws`, send
`{"type": "reset", "data": {"level": "four-digit"}}`, then `{"type": "step", "data": {"expression": "..."}}`.

Task discovery follows OpenEnv's Task API: `GET /scott_wu_math/splits`, `POST /scott_wu_math/tasks`
with `{"split": "test"}`, and so on. Task listings leave out the reference solutions.

For text-only RL (no server), `scott_wu_math.game.reward.grade(expression, card_values, target)` and
`extract_answer(completion)` are the same grader as a plain function, and `game.prompts.messages(cards, target)`
builds the same prompt.

## The solver

Every expression tree splits the cards at its root into two groups, and the smaller group has at most
half the cards. The solver enumerates every value a group of up to four cards can make, then asks the
remaining cards for the one number that completes the target (`target = v + rest` needs
`rest = target βˆ’ v`, and so on for the six ways to place `v`), recursing until four cards remain, where
the answer is a table lookup. That is complete for up to nine cards, and finds a solution for an
eight- or nine-card deal in a few milliseconds. A first pass keeps every intermediate value a whole
non-negative number, the way people play; a second pass allows fractions.

## Run locally

```bash
pip install -e .
uvicorn scott_wu_math.server.app:app --port 8012     # play at http://localhost:8012
python scripts/build_tasks.py                          # rebuild data/tasks (deterministic)
pytest
```

## Evaluations

`eval/rollout.py` in the [FineEnvs repository](https://github.com/adithya-s-k/FineEnvs/tree/main/12-scott-wu-math)
runs OpenAI, Anthropic and open models on Hugging Face Inference Providers against a running server, in
parallel, one OpenEnv session per episode.

### Results (2026-10-23)

Twelve models on the first five `test` deals of each level (20 deals, one sample each) and on the two
`video` hands (three samples each), in three modes:

- **Reasoning**: the model's own reasoning at high effort, up to 32k output tokens.
- **No reasoning**: reasoning switched off, or as low as the API allows (`*`), up to 32k tokens, so the
  model may still work it out in its visible reply.
- **At once**: no reasoning, a system line asking for the answer tag alone, and 300 output tokens: the
  mental-arithmetic version of the game.

| Model | Reasoning | No reasoning | At once | Video hands: reasoning / none / at once |
|---|---|---|---|---|
| GPT-6.1 Sol | 20/20 | 20/20* | 14/20* | 6/6 / 6/6 / 3/6 |
| Claude Opus 5.5 | 20/20 | 19/20* | 8/20* | 6/6 / 6/6 / 5/6 |
| GLM-5.3 | 17/20 | 19/20 | 5/20 | 6/6 / 5/6 / 0/6 |
| Qwen3.8 2.4T | 20/20 | 19/20 | 4/20 | 6/6 / 5/6 / 0/6 |
| Claude Sonnet 5.5 | 20/20 | 15/20 | 4/20 | 6/6 / 4/6 / 0/6 |
| Claude Haiku 5.5 | 20/20 | 11/20 | 3/20 | 6/6 / 5/6 / 0/6 |
| Kimi K2.6 | 19/20 | 20/20 | 2/20 | 6/6 / 6/6 / 0/6 |
| DeepSeek V4 Pro | 20/20 | 10/20 | 2/20 | 6/6 / 3/6 / 0/6 |
| gpt-oss-120b | 16/20 | 6/20* | 2/20* | 6/6 / 1/6 / 0/6 |
| GPT-6 Luna | 20/20 | 2/20 | 2/20 | 5/6 / 0/6 / 0/6 |
| Qwen3.8 27B | 18/20 | 20/20 | 1/20 | 6/6 / 6/6 / 0/6 |
| Gemma 4 31B | 19/20 | 18/20 | 1/20 | 5/6 / 5/6 / 0/6 |

`*` lowest effort the API allows: GPT-6.1 Sol and gpt-oss-120b at `low`, Claude Opus 5.5 at `low` (its
thinking cannot be disabled). Open models ran on Hugging Face Inference Providers (Qwen3.8 2.4T and Kimi
K2.6 on DeepInfra; DeepSeek V4 Pro, GLM-5.3 and Qwen3.8 27B on Novita; gpt-oss-120b on Cerebras; Gemma 4
31B on DeepInfra). Gemma and Kimi each lost one or two episodes to provider errors, counted as failures.

With reasoning the game is close to solved: every model makes 6,843 on the first try. Answering at once
separates them: only GPT-6.1 Sol and Claude Opus 5.5, which still reason briefly at their lowest effort,
get far; most other models start working out loud and run out of room. That gap, and the no-reasoning
column for the smaller models, is where there is something to train.