simthinkd 0.2.0__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {simthinkd-0.2.0/src/simthinkd.egg-info → simthinkd-0.3.0}/PKG-INFO +47 -17
- {simthinkd-0.2.0 → simthinkd-0.3.0}/README.md +46 -16
- {simthinkd-0.2.0 → simthinkd-0.3.0}/pyproject.toml +1 -1
- {simthinkd-0.2.0 → simthinkd-0.3.0}/src/simthinkd/__init__.py +1 -1
- simthinkd-0.3.0/src/simthinkd/bench.py +103 -0
- {simthinkd-0.2.0 → simthinkd-0.3.0}/src/simthinkd/cli.py +7 -2
- {simthinkd-0.2.0 → simthinkd-0.3.0}/src/simthinkd/core.py +72 -2
- {simthinkd-0.2.0 → simthinkd-0.3.0}/src/simthinkd/policy.py +30 -2
- {simthinkd-0.2.0 → simthinkd-0.3.0}/src/simthinkd/server.py +27 -4
- {simthinkd-0.2.0 → simthinkd-0.3.0/src/simthinkd.egg-info}/PKG-INFO +47 -17
- {simthinkd-0.2.0 → simthinkd-0.3.0}/src/simthinkd.egg-info/SOURCES.txt +4 -0
- simthinkd-0.3.0/tests/test_java_parity.py +81 -0
- simthinkd-0.3.0/tests/test_parallel.py +194 -0
- simthinkd-0.3.0/tests/test_server_concurrency.py +75 -0
- simthinkd-0.3.0/tests/test_server_latency.py +30 -0
- simthinkd-0.2.0/src/simthinkd/bench.py +0 -55
- {simthinkd-0.2.0 → simthinkd-0.3.0}/LICENSE +0 -0
- {simthinkd-0.2.0 → simthinkd-0.3.0}/NOTICE +0 -0
- {simthinkd-0.2.0 → simthinkd-0.3.0}/setup.cfg +0 -0
- {simthinkd-0.2.0 → simthinkd-0.3.0}/src/simthinkd/data/doom_defend_states.json +0 -0
- {simthinkd-0.2.0 → simthinkd-0.3.0}/src/simthinkd/integrations/__init__.py +0 -0
- {simthinkd-0.2.0 → simthinkd-0.3.0}/src/simthinkd/integrations/langchain_tool.py +0 -0
- {simthinkd-0.2.0 → simthinkd-0.3.0}/src/simthinkd/integrations/mcp_server.py +0 -0
- {simthinkd-0.2.0 → simthinkd-0.3.0}/src/simthinkd/score.py +0 -0
- {simthinkd-0.2.0 → simthinkd-0.3.0}/src/simthinkd/toy.py +0 -0
- {simthinkd-0.2.0 → simthinkd-0.3.0}/src/simthinkd/train.py +0 -0
- {simthinkd-0.2.0 → simthinkd-0.3.0}/src/simthinkd/weights/SHA256SUMS +0 -0
- {simthinkd-0.2.0 → simthinkd-0.3.0}/src/simthinkd/weights/doom-corridor.npz +0 -0
- {simthinkd-0.2.0 → simthinkd-0.3.0}/src/simthinkd/weights/doom-defend.npz +0 -0
- {simthinkd-0.2.0 → simthinkd-0.3.0}/src/simthinkd.egg-info/dependency_links.txt +0 -0
- {simthinkd-0.2.0 → simthinkd-0.3.0}/src/simthinkd.egg-info/entry_points.txt +0 -0
- {simthinkd-0.2.0 → simthinkd-0.3.0}/src/simthinkd.egg-info/requires.txt +0 -0
- {simthinkd-0.2.0 → simthinkd-0.3.0}/src/simthinkd.egg-info/top_level.txt +0 -0
- {simthinkd-0.2.0 → simthinkd-0.3.0}/tests/test_factory_twin.py +0 -0
- {simthinkd-0.2.0 → simthinkd-0.3.0}/tests/test_notebook.py +0 -0
- {simthinkd-0.2.0 → simthinkd-0.3.0}/tests/test_package.py +0 -0
- {simthinkd-0.2.0 → simthinkd-0.3.0}/tests/test_score.py +0 -0
- {simthinkd-0.2.0 → simthinkd-0.3.0}/tests/test_space.py +0 -0
- {simthinkd-0.2.0 → simthinkd-0.3.0}/tests/test_web_page.py +0 -0
- {simthinkd-0.2.0 → simthinkd-0.3.0}/tests/test_web_parity.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: simthinkd
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: A 265k-parameter decision model that picks one action in about 2 ms on one CPU core, inside real-time loops.
|
|
5
5
|
Author: Myeongseongsimjae AX Institute
|
|
6
6
|
License: Apache-2.0
|
|
@@ -28,7 +28,9 @@ Requires-Dist: onnx>=1.15; extra == "onnx"
|
|
|
28
28
|
Requires-Dist: onnxruntime>=1.17; extra == "onnx"
|
|
29
29
|
Dynamic: license-file
|
|
30
30
|
|
|
31
|
-
<p align="center"><
|
|
31
|
+
<p align="center"><b>English</b> | <a href="README.ko.md">한국어</a> | <a href="README.ja.md">日本語</a></p>
|
|
32
|
+
|
|
33
|
+
<p align="center"><img src="assets/banner_v2.png" alt="SimThink D: a local backup for cloud decisions" width="100%"></p>
|
|
32
34
|
|
|
33
35
|
<p align="center">
|
|
34
36
|
<a href="https://pypi.org/project/simthinkd/"><img src="https://img.shields.io/pypi/v/simthinkd" alt="PyPI"></a>
|
|
@@ -39,27 +41,23 @@ Dynamic: license-file
|
|
|
39
41
|
<a href="https://doi.org/10.5281/zenodo.23111615"><img src="https://zenodo.org/badge/DOI/10.5281/zenodo.23111615.svg" alt="DOI"></a>
|
|
40
42
|
</p>
|
|
41
43
|
|
|
42
|
-
<p align="center"><
|
|
43
|
-
|
|
44
|
-
**A tiny decision model that runs on one CPU core.** It has 265,665 parameters. It picks one action in about 2 ms. That is fast enough to decide inside every tick of a game or a control loop. No GPU is needed, not even for training.
|
|
45
|
-
|
|
46
|
-
<p align="center">
|
|
47
|
-
<img src="assets/side_by_side.gif" alt="Same Doom game, same seed, same CPU. Left: SimThink D answers every tick. Right: a 421M-parameter general decision model, used as published, misses most ticks while it thinks." width="100%" />
|
|
48
|
-
</p>
|
|
49
|
-
|
|
50
|
-
<p align="center"><sub>Same game, same seed, same 6-core CPU. Left: SimThink D, 1.9 ms per decision, 1 of 420 ticks missed. Right: Laya, a 421M-parameter open decision model, used as published without training on this game. It takes about 360 ms per decision and misses 390 of 420 ticks. A dark frame means the game moved on before the decider answered.</sub></p>
|
|
44
|
+
<p align="center"><b>Network down. Decisions stay local.</b></p>
|
|
51
45
|
|
|
52
|
-
|
|
46
|
+
SimThink D is a small CPU model for offline backup decisions.
|
|
47
|
+
It has 265,665 parameters and takes about 2 ms per decision on one CPU core.
|
|
53
48
|
|
|
54
|
-
|
|
49
|
+
In the factory simulation, the internet was cut for 15 seconds.
|
|
50
|
+
The local backup got 59 of 63 parts right, with no late decisions.
|
|
55
51
|
|
|
56
|
-
|
|
52
|
+
<p align="center"><a href="assets/factory_fallback.mp4"><img src="assets/factory_twin.gif" alt="Factory simulation: the internet is cut for 15 seconds and SimThink D on the factory PC fills the gap" width="100%"></a></p>
|
|
57
53
|
|
|
58
|
-
|
|
54
|
+
<p align="center"><a href="https://mssj-ai-org.github.io/simthinkd/">Project page</a> · <a href="https://mssj-ai-org.github.io/simthinkd/#demo">Try in your browser</a> · <a href="assets/factory_fallback.mp4">Watch the video (72 s)</a> · <a href="examples/factory_twin/">Factory code</a> · <a href="docs/PAPER.md">Paper</a></p>
|
|
59
55
|
|
|
60
|
-
|
|
56
|
+
```bash
|
|
57
|
+
pip install simthinkd
|
|
58
|
+
```
|
|
61
59
|
|
|
62
|
-
|
|
60
|
+
What makes the next decision when your network goes down?
|
|
63
61
|
|
|
64
62
|
## Words used here
|
|
65
63
|
|
|
@@ -94,6 +92,23 @@ One call is one decision. A short sentence goes in. One action comes out, with i
|
|
|
94
92
|
|
|
95
93
|
Two deciders come with the package: `doom-defend` (stand in the middle and fight) and `doom-corridor` (fight your way down a corridor).
|
|
96
94
|
|
|
95
|
+
## Many decisions at once
|
|
96
|
+
|
|
97
|
+
One call can carry several questions about the same situation, and one batch can carry many situations. The answers are the same as asking one by one.
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
from simthinkd import Decider
|
|
101
|
+
|
|
102
|
+
d = Decider("doom-defend")
|
|
103
|
+
states = ["seen: Demon left a30 d5 | enemies 1 | sway left | gun ready | ammo25",
|
|
104
|
+
"seen: Demon right a30 d5 | enemies 1 | sway right | gun ready | ammo25"]
|
|
105
|
+
answers = d.predict_batch([d.request(s) for s in states]) # many situations in one pass
|
|
106
|
+
print([a["operation"]["choice"] for a in answers])
|
|
107
|
+
# ['TURN_LEFT', 'TURN_RIGHT']
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
`simthinkd bench --batch` shows where the time goes on your machine. Details and limits: [docs/PARALLEL.md](docs/PARALLEL.md).
|
|
111
|
+
|
|
97
112
|
## Train your own in seconds
|
|
98
113
|
|
|
99
114
|
A new task needs two things: a perception step and a teacher. The `toy` module below is a small made-up task, an inspection station on a factory belt. Swap its examples and actions for your own.
|
|
@@ -181,6 +196,12 @@ The second line measures any server that accepts the [decision request](docs/PRO
|
|
|
181
196
|
|
|
182
197
|
## Same CPU, same states
|
|
183
198
|
|
|
199
|
+
<p align="center">
|
|
200
|
+
<img src="assets/side_by_side.gif" alt="Same Doom game, same seed, same CPU. Left: SimThink D answers every tick. Right: a 421M-parameter general decision model, used as published, misses most ticks while it thinks." width="100%" />
|
|
201
|
+
</p>
|
|
202
|
+
|
|
203
|
+
<p align="center"><sub>Same game, same seed, same 6-core CPU. Left: SimThink D, 1.9 ms per decision, 1 of 420 ticks missed. Right: Laya, a 421M-parameter open decision model, used as published without training on this game. It takes about 360 ms per decision and misses 390 of 420 ticks. A dark frame means the game moved on before the decider answered.</sub></p>
|
|
204
|
+
|
|
184
205
|
**Read this first.** Laya is a general model and was not trained on this game. Its published speed, about 33 ms per question, is on a GPU. We only had a CPU. So this table compares time inside a real-time loop. It does not compare overall quality.
|
|
185
206
|
|
|
186
207
|
We used one workstation CPU (6 threads) and 1,050 Doom states, then 10 live games on the same seeds. "Missed ticks" are ticks that passed before the decider answered.
|
|
@@ -199,7 +220,9 @@ SimThink D only knows what its teacher knows. It does not reason, read long text
|
|
|
199
220
|
| Any language, any engine | `simthinkd serve doom-defend --port 11890`, then POST the [decision request](docs/PROTOCOL.md) to `/v1/systemone` |
|
|
200
221
|
| Unity / C# | [docs/INTEGRATION_UNITY.md](docs/INTEGRATION_UNITY.md): a client loop that keeps the game running while it waits |
|
|
201
222
|
| Browser | [web/](web/): the same model in plain JavaScript, no server |
|
|
223
|
+
| Java / JVM games and engines | [java/](java/): the same model in plain Java 8, inside your process; export weights with `tools/export_java_weights.py`; a test checks it chooses exactly what Python chooses |
|
|
202
224
|
| A factory line (simulator) | [examples/factory_twin/](examples/factory_twin/): an inspection conveyor with a 400 ms deadline per part |
|
|
225
|
+
| Gradio | [space/](space/): a small web demo you can run locally or on Hugging Face Spaces |
|
|
203
226
|
| MCP (Claude Desktop, Cursor and others) | `pip install "simthinkd[mcp]"`, then `python -m simthinkd.integrations.mcp_server` |
|
|
204
227
|
| LangChain / LangGraph | `from simthinkd.integrations.langchain_tool import simthinkd_tool` |
|
|
205
228
|
|
|
@@ -218,6 +241,13 @@ SimThink D only knows what its teacher knows. It does not reason, read long text
|
|
|
218
241
|
- Probabilities are calibrated for the decider's own task only.
|
|
219
242
|
- A score model returns one number. It gives no probability or error bar with it.
|
|
220
243
|
|
|
244
|
+
## More
|
|
245
|
+
|
|
246
|
+
- [Figures from the paper](docs/FIGURES.md)
|
|
247
|
+
- [What you can reproduce](docs/REPRODUCE.md)
|
|
248
|
+
- [Decision request format](docs/PROTOCOL.md)
|
|
249
|
+
- [The factory video on LinkedIn](https://www.linkedin.com/feed/update/urn:li:activity:7510142949981057024/)
|
|
250
|
+
|
|
221
251
|
## Citation
|
|
222
252
|
|
|
223
253
|
If you use SimThink D, please cite it with [CITATION.cff](CITATION.cff). GitHub shows a "Cite this repository" button for it.
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
<p align="center"><
|
|
1
|
+
<p align="center"><b>English</b> | <a href="README.ko.md">한국어</a> | <a href="README.ja.md">日本語</a></p>
|
|
2
|
+
|
|
3
|
+
<p align="center"><img src="assets/banner_v2.png" alt="SimThink D: a local backup for cloud decisions" width="100%"></p>
|
|
2
4
|
|
|
3
5
|
<p align="center">
|
|
4
6
|
<a href="https://pypi.org/project/simthinkd/"><img src="https://img.shields.io/pypi/v/simthinkd" alt="PyPI"></a>
|
|
@@ -9,27 +11,23 @@
|
|
|
9
11
|
<a href="https://doi.org/10.5281/zenodo.23111615"><img src="https://zenodo.org/badge/DOI/10.5281/zenodo.23111615.svg" alt="DOI"></a>
|
|
10
12
|
</p>
|
|
11
13
|
|
|
12
|
-
<p align="center"><
|
|
13
|
-
|
|
14
|
-
**A tiny decision model that runs on one CPU core.** It has 265,665 parameters. It picks one action in about 2 ms. That is fast enough to decide inside every tick of a game or a control loop. No GPU is needed, not even for training.
|
|
15
|
-
|
|
16
|
-
<p align="center">
|
|
17
|
-
<img src="assets/side_by_side.gif" alt="Same Doom game, same seed, same CPU. Left: SimThink D answers every tick. Right: a 421M-parameter general decision model, used as published, misses most ticks while it thinks." width="100%" />
|
|
18
|
-
</p>
|
|
19
|
-
|
|
20
|
-
<p align="center"><sub>Same game, same seed, same 6-core CPU. Left: SimThink D, 1.9 ms per decision, 1 of 420 ticks missed. Right: Laya, a 421M-parameter open decision model, used as published without training on this game. It takes about 360 ms per decision and misses 390 of 420 ticks. A dark frame means the game moved on before the decider answered.</sub></p>
|
|
14
|
+
<p align="center"><b>Network down. Decisions stay local.</b></p>
|
|
21
15
|
|
|
22
|
-
|
|
16
|
+
SimThink D is a small CPU model for offline backup decisions.
|
|
17
|
+
It has 265,665 parameters and takes about 2 ms per decision on one CPU core.
|
|
23
18
|
|
|
24
|
-
|
|
19
|
+
In the factory simulation, the internet was cut for 15 seconds.
|
|
20
|
+
The local backup got 59 of 63 parts right, with no late decisions.
|
|
25
21
|
|
|
26
|
-
|
|
22
|
+
<p align="center"><a href="assets/factory_fallback.mp4"><img src="assets/factory_twin.gif" alt="Factory simulation: the internet is cut for 15 seconds and SimThink D on the factory PC fills the gap" width="100%"></a></p>
|
|
27
23
|
|
|
28
|
-
|
|
24
|
+
<p align="center"><a href="https://mssj-ai-org.github.io/simthinkd/">Project page</a> · <a href="https://mssj-ai-org.github.io/simthinkd/#demo">Try in your browser</a> · <a href="assets/factory_fallback.mp4">Watch the video (72 s)</a> · <a href="examples/factory_twin/">Factory code</a> · <a href="docs/PAPER.md">Paper</a></p>
|
|
29
25
|
|
|
30
|
-
|
|
26
|
+
```bash
|
|
27
|
+
pip install simthinkd
|
|
28
|
+
```
|
|
31
29
|
|
|
32
|
-
|
|
30
|
+
What makes the next decision when your network goes down?
|
|
33
31
|
|
|
34
32
|
## Words used here
|
|
35
33
|
|
|
@@ -64,6 +62,23 @@ One call is one decision. A short sentence goes in. One action comes out, with i
|
|
|
64
62
|
|
|
65
63
|
Two deciders come with the package: `doom-defend` (stand in the middle and fight) and `doom-corridor` (fight your way down a corridor).
|
|
66
64
|
|
|
65
|
+
## Many decisions at once
|
|
66
|
+
|
|
67
|
+
One call can carry several questions about the same situation, and one batch can carry many situations. The answers are the same as asking one by one.
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
from simthinkd import Decider
|
|
71
|
+
|
|
72
|
+
d = Decider("doom-defend")
|
|
73
|
+
states = ["seen: Demon left a30 d5 | enemies 1 | sway left | gun ready | ammo25",
|
|
74
|
+
"seen: Demon right a30 d5 | enemies 1 | sway right | gun ready | ammo25"]
|
|
75
|
+
answers = d.predict_batch([d.request(s) for s in states]) # many situations in one pass
|
|
76
|
+
print([a["operation"]["choice"] for a in answers])
|
|
77
|
+
# ['TURN_LEFT', 'TURN_RIGHT']
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
`simthinkd bench --batch` shows where the time goes on your machine. Details and limits: [docs/PARALLEL.md](docs/PARALLEL.md).
|
|
81
|
+
|
|
67
82
|
## Train your own in seconds
|
|
68
83
|
|
|
69
84
|
A new task needs two things: a perception step and a teacher. The `toy` module below is a small made-up task, an inspection station on a factory belt. Swap its examples and actions for your own.
|
|
@@ -151,6 +166,12 @@ The second line measures any server that accepts the [decision request](docs/PRO
|
|
|
151
166
|
|
|
152
167
|
## Same CPU, same states
|
|
153
168
|
|
|
169
|
+
<p align="center">
|
|
170
|
+
<img src="assets/side_by_side.gif" alt="Same Doom game, same seed, same CPU. Left: SimThink D answers every tick. Right: a 421M-parameter general decision model, used as published, misses most ticks while it thinks." width="100%" />
|
|
171
|
+
</p>
|
|
172
|
+
|
|
173
|
+
<p align="center"><sub>Same game, same seed, same 6-core CPU. Left: SimThink D, 1.9 ms per decision, 1 of 420 ticks missed. Right: Laya, a 421M-parameter open decision model, used as published without training on this game. It takes about 360 ms per decision and misses 390 of 420 ticks. A dark frame means the game moved on before the decider answered.</sub></p>
|
|
174
|
+
|
|
154
175
|
**Read this first.** Laya is a general model and was not trained on this game. Its published speed, about 33 ms per question, is on a GPU. We only had a CPU. So this table compares time inside a real-time loop. It does not compare overall quality.
|
|
155
176
|
|
|
156
177
|
We used one workstation CPU (6 threads) and 1,050 Doom states, then 10 live games on the same seeds. "Missed ticks" are ticks that passed before the decider answered.
|
|
@@ -169,7 +190,9 @@ SimThink D only knows what its teacher knows. It does not reason, read long text
|
|
|
169
190
|
| Any language, any engine | `simthinkd serve doom-defend --port 11890`, then POST the [decision request](docs/PROTOCOL.md) to `/v1/systemone` |
|
|
170
191
|
| Unity / C# | [docs/INTEGRATION_UNITY.md](docs/INTEGRATION_UNITY.md): a client loop that keeps the game running while it waits |
|
|
171
192
|
| Browser | [web/](web/): the same model in plain JavaScript, no server |
|
|
193
|
+
| Java / JVM games and engines | [java/](java/): the same model in plain Java 8, inside your process; export weights with `tools/export_java_weights.py`; a test checks it chooses exactly what Python chooses |
|
|
172
194
|
| A factory line (simulator) | [examples/factory_twin/](examples/factory_twin/): an inspection conveyor with a 400 ms deadline per part |
|
|
195
|
+
| Gradio | [space/](space/): a small web demo you can run locally or on Hugging Face Spaces |
|
|
173
196
|
| MCP (Claude Desktop, Cursor and others) | `pip install "simthinkd[mcp]"`, then `python -m simthinkd.integrations.mcp_server` |
|
|
174
197
|
| LangChain / LangGraph | `from simthinkd.integrations.langchain_tool import simthinkd_tool` |
|
|
175
198
|
|
|
@@ -188,6 +211,13 @@ SimThink D only knows what its teacher knows. It does not reason, read long text
|
|
|
188
211
|
- Probabilities are calibrated for the decider's own task only.
|
|
189
212
|
- A score model returns one number. It gives no probability or error bar with it.
|
|
190
213
|
|
|
214
|
+
## More
|
|
215
|
+
|
|
216
|
+
- [Figures from the paper](docs/FIGURES.md)
|
|
217
|
+
- [What you can reproduce](docs/REPRODUCE.md)
|
|
218
|
+
- [Decision request format](docs/PROTOCOL.md)
|
|
219
|
+
- [The factory video on LinkedIn](https://www.linkedin.com/feed/update/urn:li:activity:7510142949981057024/)
|
|
220
|
+
|
|
191
221
|
## Citation
|
|
192
222
|
|
|
193
223
|
If you use SimThink D, please cite it with [CITATION.cff](CITATION.cff). GitHub shows a "Cite this repository" button for it.
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "simthinkd"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
description = "A 265k-parameter decision model that picks one action in about 2 ms on one CPU core, inside real-time loops."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
from .core import Decider, Decision, available, build_request
|
|
7
7
|
from .score import Score, Scorer
|
|
8
8
|
|
|
9
|
-
__version__ = '0.
|
|
9
|
+
__version__ = '0.3.0'
|
|
10
10
|
__all__ = ['Decider', 'Decision', 'Score', 'Scorer', 'available', 'build_request', 'fit', 'fit_score', '__version__']
|
|
11
11
|
|
|
12
12
|
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Does your decider fit inside one game tick? Measures decision time and teacher agreement on recorded states.
|
|
2
|
+
|
|
3
|
+
simthinkd bench # the bundled decider, in-process, bundled Doom states
|
|
4
|
+
simthinkd bench --url http://127.0.0.1:8000/v1/systemone --name my-model
|
|
5
|
+
simthinkd bench --states my_states.json --tick-hz 60
|
|
6
|
+
|
|
7
|
+
Any server that accepts the decision request (PROTOCOL.md) and answers {"answers": {"operation": {"choice": ...}}}
|
|
8
|
+
can be measured. States file: {"rows": [{"body": <request>, "teacher": <action>}, ...]}.
|
|
9
|
+
One request at a time; the time is the full round trip seen by the caller.
|
|
10
|
+
"""
|
|
11
|
+
import json
|
|
12
|
+
import statistics
|
|
13
|
+
import time
|
|
14
|
+
import urllib.request
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from .core import Decider
|
|
18
|
+
|
|
19
|
+
BUNDLED = Path(__file__).resolve().parent / 'data' / 'doom_defend_states.json'
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _post(url, body):
|
|
23
|
+
req = urllib.request.Request(url, data=json.dumps(body).encode(), headers={'Content-Type': 'application/json'})
|
|
24
|
+
with urllib.request.urlopen(req, timeout=300) as reply:
|
|
25
|
+
return json.load(reply)['answers']['operation']['choice']
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def run(states=None, url=None, decider='doom-defend', name=None, tick_hz=35.0, warmup=20, limit=0):
|
|
29
|
+
rows = json.loads(Path(states or BUNDLED).read_text(encoding='utf8'))['rows']
|
|
30
|
+
rows = rows[:limit] if limit else rows
|
|
31
|
+
if url:
|
|
32
|
+
call, label = (lambda body: _post(url, body)), name or url
|
|
33
|
+
else:
|
|
34
|
+
d = decider if isinstance(decider, Decider) else Decider(decider)
|
|
35
|
+
call, label = (lambda body: d.predict(body)['operation']['choice']), name or f'simthink-d:{d.preset["name"]}'
|
|
36
|
+
for row in rows[:warmup]:
|
|
37
|
+
call(row['body'])
|
|
38
|
+
times, agree = [], 0
|
|
39
|
+
for row in rows:
|
|
40
|
+
start = time.perf_counter()
|
|
41
|
+
choice = call(row['body'])
|
|
42
|
+
times.append((time.perf_counter() - start) * 1000)
|
|
43
|
+
agree += choice == row['teacher']
|
|
44
|
+
q = sorted(times)
|
|
45
|
+
tick_ms = 1000.0 / tick_hz
|
|
46
|
+
return {'name': label, 'states': len(rows), 'tick_ms': round(tick_ms, 2),
|
|
47
|
+
'p50_ms': round(statistics.median(q), 3), 'p95_ms': round(q[max(0, int(0.95 * len(q)) - 1)], 3),
|
|
48
|
+
'within_one_tick': round(sum(t <= tick_ms for t in times) / len(times), 4),
|
|
49
|
+
'teacher_agreement': round(agree / len(rows), 4)}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def throughput(states=None, decider='doom-defend', sizes=(1, 10, 100, 1000), limit=1000, repeat=3):
|
|
53
|
+
"""Decisions per second, one request at a time (predict) vs batches (predict_batch), both end to end with the same
|
|
54
|
+
answers; then, in a separate instrumented pass, where batch time goes: encoding text (per request), the network
|
|
55
|
+
(Policy.scores_batch, one pass per batch) and turning scores into answers. In-process only; best of `repeat` runs."""
|
|
56
|
+
from .core import answer_from
|
|
57
|
+
from .policy import encode
|
|
58
|
+
d = decider if isinstance(decider, Decider) else Decider(decider)
|
|
59
|
+
rows = json.loads(Path(states or BUNDLED).read_text(encoding='utf8'))['rows']
|
|
60
|
+
bodies = [rows[i % len(rows)]['body'] for i in range(limit)]
|
|
61
|
+
d.predict_batch(bodies[:20])
|
|
62
|
+
|
|
63
|
+
def best(run):
|
|
64
|
+
times = []
|
|
65
|
+
for _ in range(repeat):
|
|
66
|
+
start = time.perf_counter()
|
|
67
|
+
run()
|
|
68
|
+
times.append(time.perf_counter() - start)
|
|
69
|
+
return min(times)
|
|
70
|
+
|
|
71
|
+
out = {'name': f'simthink-d:{d.preset["name"]}', 'decisions': len(bodies), 'sizes': {}}
|
|
72
|
+
out['one_at_a_time_per_s'] = round(len(bodies) / best(lambda: [d.predict(b) for b in bodies]), 1)
|
|
73
|
+
for size in sizes:
|
|
74
|
+
chunks = [bodies[i:i + size] for i in range(0, len(bodies), size)]
|
|
75
|
+
total = best(lambda: [d.predict_batch(c) for c in chunks])
|
|
76
|
+
enc = net = dec = 0.0
|
|
77
|
+
for c in chunks:
|
|
78
|
+
t0 = time.perf_counter()
|
|
79
|
+
encoded = [encode(b) for b in c]
|
|
80
|
+
t1 = time.perf_counter()
|
|
81
|
+
scores = d.policy.scores_batch([e[0] for e in encoded])
|
|
82
|
+
t2 = time.perf_counter()
|
|
83
|
+
[answer_from(d.policy.decode(e, sc)) for e, sc in zip(encoded, scores)]
|
|
84
|
+
enc, net, dec = enc + t1 - t0, net + t2 - t1, dec + time.perf_counter() - t2
|
|
85
|
+
part = enc + net + dec
|
|
86
|
+
out['sizes'][size] = {'per_s': round(len(bodies) / total, 1), 'network_only_per_s': round(len(bodies) / net, 1),
|
|
87
|
+
'encode_share': round(enc / part, 3), 'network_share': round(net / part, 3),
|
|
88
|
+
'answer_share': round(dec / part, 3)}
|
|
89
|
+
return out
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def throughput_report(r):
|
|
93
|
+
lines = [f"{r['name']}: {r['decisions']} decisions, one at a time {r['one_at_a_time_per_s']}/s"]
|
|
94
|
+
for size, v in r['sizes'].items():
|
|
95
|
+
lines.append(f" batch {size:>5}: {v['per_s']}/s end to end; network alone {v['network_only_per_s']}/s; time: "
|
|
96
|
+
f"{v['encode_share']:.0%} encoding text, {v['network_share']:.0%} network, {v['answer_share']:.0%} building answers")
|
|
97
|
+
return '\n'.join(lines)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def report(result):
|
|
101
|
+
return (f"{result['name']}: median {result['p50_ms']} ms, p95 {result['p95_ms']} ms, "
|
|
102
|
+
f"{result['within_one_tick']:.1%} of decisions within one {result['tick_ms']} ms tick, "
|
|
103
|
+
f"teacher agreement {result['teacher_agreement']:.1%} on {result['states']} states")
|
|
@@ -23,6 +23,7 @@ def main(argv=None):
|
|
|
23
23
|
s.add_argument('--states')
|
|
24
24
|
s.add_argument('--tick-hz', type=float, default=35.0)
|
|
25
25
|
s.add_argument('--limit', type=int, default=0)
|
|
26
|
+
s.add_argument('--batch', action='store_true', help='decisions per second, one at a time vs batches of 1, 10, 100, 1000')
|
|
26
27
|
s.add_argument('--json', action='store_true')
|
|
27
28
|
s = sub.add_parser('train', help='train from a folder of protocol rows (needs simthinkd[train])')
|
|
28
29
|
s.add_argument('--data', required=True)
|
|
@@ -46,8 +47,12 @@ def main(argv=None):
|
|
|
46
47
|
serve(a.decider, a.host, a.port, a.delay_ms)
|
|
47
48
|
elif a.cmd == 'bench':
|
|
48
49
|
from . import bench
|
|
49
|
-
|
|
50
|
-
|
|
50
|
+
if a.batch:
|
|
51
|
+
result = bench.throughput(a.states, a.decider, limit=a.limit or 1000)
|
|
52
|
+
print(json.dumps(result) if a.json else bench.throughput_report(result))
|
|
53
|
+
else:
|
|
54
|
+
result = bench.run(a.states, a.url, a.decider, a.name, a.tick_hz, limit=a.limit)
|
|
55
|
+
print(json.dumps(result) if a.json else bench.report(result))
|
|
51
56
|
elif a.cmd == 'train':
|
|
52
57
|
from .train import fit_dir
|
|
53
58
|
d = fit_dir(a.data, a.out, a.steps, a.seed)
|
|
@@ -13,7 +13,7 @@ import time
|
|
|
13
13
|
from dataclasses import dataclass, field
|
|
14
14
|
from pathlib import Path
|
|
15
15
|
|
|
16
|
-
from .policy import Policy
|
|
16
|
+
from .policy import Policy, encode
|
|
17
17
|
|
|
18
18
|
WEIGHTS = Path(__file__).resolve().parent / 'weights'
|
|
19
19
|
|
|
@@ -76,7 +76,11 @@ def build_request(state, actions, goal, *, url='sim://custom', title='', model='
|
|
|
76
76
|
|
|
77
77
|
def answer(policy, body):
|
|
78
78
|
"""Reply `answers` block for a protocol request (operation, and `<op>_target` when targets were offered)."""
|
|
79
|
-
|
|
79
|
+
return answer_from(policy.predict(body))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def answer_from(predicted):
|
|
83
|
+
chosen, op, conditional, joint, rows, operations = predicted
|
|
80
84
|
op_probs = {key: float(op[i]) for i, key in enumerate(operations)}
|
|
81
85
|
top = max(op_probs, key=op_probs.get)
|
|
82
86
|
answers = {'operation': {'choice': top, 'probabilities': op_probs, 'confidence': op_probs[top]}}
|
|
@@ -90,6 +94,44 @@ def answer(policy, body):
|
|
|
90
94
|
return answers
|
|
91
95
|
|
|
92
96
|
|
|
97
|
+
def _question_body(body, question):
|
|
98
|
+
"""One general question as an operation-style request on the same state: each criterion becomes one row."""
|
|
99
|
+
kind, crit = question.get('type', 'choice'), question.get('criteria')
|
|
100
|
+
if kind == 'score':
|
|
101
|
+
levels = list(crit.values()) if isinstance(crit, dict) else list(crit or [])
|
|
102
|
+
if not 2 <= len(levels) <= 10:
|
|
103
|
+
raise ValueError('a score question needs 2 to 10 levels')
|
|
104
|
+
crit = {str(i + 1): level for i, level in enumerate(levels)}
|
|
105
|
+
elif kind == 'noul':
|
|
106
|
+
crit = crit or {'true': 'Yes.', 'false': 'No.'}
|
|
107
|
+
if set(crit) != {'true', 'false'}:
|
|
108
|
+
raise ValueError('a noul question has exactly the criteria true and false')
|
|
109
|
+
elif kind != 'choice' or not crit:
|
|
110
|
+
raise ValueError(f'question type must be choice, score or noul with criteria (got {kind!r})')
|
|
111
|
+
return {'state': body['state'],
|
|
112
|
+
'questions': {'operation': {'type': 'choice', 'criteria': crit, 'instructions': question.get('instructions', {})}}}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _general_answer(kind, predicted):
|
|
116
|
+
op, keys = predicted[1], predicted[5]
|
|
117
|
+
probs = {k: float(op[i]) for i, k in enumerate(keys)}
|
|
118
|
+
best = max(probs, key=probs.get)
|
|
119
|
+
if kind == 'noul':
|
|
120
|
+
return {'type': 'noul', 'noul': probs['true'], 'probabilities': probs}
|
|
121
|
+
if kind == 'score':
|
|
122
|
+
return {'type': 'score', 'score': int(best), 'probabilities': probs, 'confidence': probs[best]}
|
|
123
|
+
return {'type': 'choice', 'choice': best, 'probabilities': probs, 'confidence': probs[best]}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _jobs(body):
|
|
127
|
+
"""(name, type) per block: the operation question with its `<op>_target` companions is one coupled block (name None);
|
|
128
|
+
every other question is its own block."""
|
|
129
|
+
qs = body.get('questions', {})
|
|
130
|
+
coupled = {'operation'} | {op.lower() + '_target' for op in qs.get('operation', {}).get('criteria', {})}
|
|
131
|
+
jobs = [(None, None)] if 'operation' in qs else []
|
|
132
|
+
return jobs + [(name, q.get('type', 'choice')) for name, q in qs.items() if name not in coupled]
|
|
133
|
+
|
|
134
|
+
|
|
93
135
|
@dataclass(frozen=True)
|
|
94
136
|
class Decision:
|
|
95
137
|
choice: str
|
|
@@ -141,6 +183,34 @@ class Decider:
|
|
|
141
183
|
"""Protocol-level call: request dict in, `answers` dict out (same as the HTTP server)."""
|
|
142
184
|
return answer(self.policy, body)
|
|
143
185
|
|
|
186
|
+
def predict_batch(self, bodies):
|
|
187
|
+
"""predict() for many requests at once; the network runs once over all of them."""
|
|
188
|
+
return [answer_from(p) for p in self.policy.predict_batch(bodies)]
|
|
189
|
+
|
|
190
|
+
def ask(self, body):
|
|
191
|
+
"""Several questions about one situation in one call (choice, score, noul). See docs/PARALLEL.md."""
|
|
192
|
+
return self.ask_batch([body])[0]
|
|
193
|
+
|
|
194
|
+
def ask_batch(self, bodies):
|
|
195
|
+
"""ask() for many requests; every question of every request goes through one padded network pass."""
|
|
196
|
+
bodies = list(bodies)
|
|
197
|
+
if not bodies:
|
|
198
|
+
return []
|
|
199
|
+
jobs, encoded = [], []
|
|
200
|
+
for i, body in enumerate(bodies):
|
|
201
|
+
body_jobs = _jobs(body)
|
|
202
|
+
if not body_jobs: # checked per request, so a neighbour in the batch never changes the outcome
|
|
203
|
+
raise ValueError(f'request {i} has no question to answer')
|
|
204
|
+
for name, kind in body_jobs:
|
|
205
|
+
jobs.append((i, name, kind))
|
|
206
|
+
encoded.append(encode(body if name is None else _question_body(body, body['questions'][name])))
|
|
207
|
+
out = [{'answers': {}, 'meta': {'questions': 0}} for _ in bodies]
|
|
208
|
+
for (i, name, kind), e, s in zip(jobs, encoded, self.policy.scores_batch([e[0] for e in encoded])):
|
|
209
|
+
predicted = self.policy.decode(e, s)
|
|
210
|
+
out[i]['answers'].update(answer_from(predicted) if name is None else {name: _general_answer(kind, predicted)})
|
|
211
|
+
out[i]['meta']['questions'] += 1
|
|
212
|
+
return out
|
|
213
|
+
|
|
144
214
|
def decide(self, state, actions=None, goal=None, tick=0, recent_actions=None):
|
|
145
215
|
"""One decision for one situation sentence. Returns a Decision (choice, confidence, probabilities, ms)."""
|
|
146
216
|
body = self.request(state, actions, goal, tick, recent_actions)
|
|
@@ -173,9 +173,37 @@ class Policy:
|
|
|
173
173
|
return (z @ w['score.weight'].T + w['score.bias'])[:, 0]
|
|
174
174
|
|
|
175
175
|
def predict(self, body):
|
|
176
|
-
|
|
177
|
-
|
|
176
|
+
return self.decode(encode(body), self.scores)
|
|
177
|
+
|
|
178
|
+
def decode(self, encoded, scores):
|
|
179
|
+
x, groups, rows, operations = encoded
|
|
180
|
+
op, conditional, joint = distributions(scores if not callable(scores) else scores(x), groups, self.temperature)
|
|
178
181
|
chosen_group = int(op.argmax())
|
|
179
182
|
indices = np.flatnonzero(groups == chosen_group)
|
|
180
183
|
chosen = rows[int(indices[conditional[chosen_group].argmax()])]
|
|
181
184
|
return chosen, op, conditional, joint, rows, operations
|
|
185
|
+
|
|
186
|
+
def scores_batch(self, blocks):
|
|
187
|
+
"""Scores for many encoded blocks in one padded pass. A block is the row matrix of one question;
|
|
188
|
+
context pooling (mean, max) stays inside its block, so blocks never influence each other."""
|
|
189
|
+
if not len(blocks):
|
|
190
|
+
return []
|
|
191
|
+
if any(len(b) == 0 for b in blocks):
|
|
192
|
+
raise ValueError('every block needs at least one row')
|
|
193
|
+
sizes = np.array([len(b) for b in blocks])
|
|
194
|
+
starts = np.concatenate([[0], np.cumsum(sizes)[:-1]])
|
|
195
|
+
w = self.w
|
|
196
|
+
# All rows of all blocks go through each layer as one matrix product; only the pooling is per block.
|
|
197
|
+
h = np.maximum(0, np.concatenate(blocks) @ w['local.weight'].T + w['local.bias'])
|
|
198
|
+
context = np.concatenate([np.add.reduceat(h, starts) / sizes[:, None].astype(h.dtype), np.maximum.reduceat(h, starts)], axis=1)
|
|
199
|
+
z = np.concatenate([h, np.repeat(context, sizes, axis=0)], axis=1)
|
|
200
|
+
z = np.maximum(0, z @ w['context.weight'].T + w['context.bias'])
|
|
201
|
+
s = (z @ w['score.weight'].T + w['score.bias'])[:, 0]
|
|
202
|
+
return np.split(s, np.cumsum(sizes)[:-1])
|
|
203
|
+
|
|
204
|
+
def predict_batch(self, bodies):
|
|
205
|
+
"""predict() for many requests: encoding is per request, the network runs once over all of them."""
|
|
206
|
+
encoded = [encode(b) for b in bodies]
|
|
207
|
+
if not encoded:
|
|
208
|
+
return []
|
|
209
|
+
return [self.decode(e, s) for e, s in zip(encoded, self.scores_batch([e[0] for e in encoded]))]
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
Binds to 127.0.0.1 by default. `--delay-ms` adds a fixed wait after inference (latency-injection experiments).
|
|
6
6
|
"""
|
|
7
7
|
import json
|
|
8
|
+
import socket
|
|
9
|
+
import threading
|
|
8
10
|
import time
|
|
9
11
|
from datetime import datetime, timezone
|
|
10
12
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
@@ -13,6 +15,12 @@ from .core import Decider
|
|
|
13
15
|
|
|
14
16
|
|
|
15
17
|
def make_handler(decider, name, delay_ms):
|
|
18
|
+
# One inference at a time per process. The threaded server runs one thread per connection, and NumPy's BLAS keeps its
|
|
19
|
+
# own worker threads; on 2026-10-04 a server fed by four parallel StarCraft games died after ~30 min with
|
|
20
|
+
# "malloc(): unaligned tcache chunk detected". Inference takes about 1 ms, so serialising it costs little; for more
|
|
21
|
+
# throughput run several server processes and spread clients over them.
|
|
22
|
+
infer_lock = threading.Lock()
|
|
23
|
+
|
|
16
24
|
class Handler(BaseHTTPRequestHandler):
|
|
17
25
|
protocol_version = 'HTTP/1.1'
|
|
18
26
|
|
|
@@ -37,9 +45,10 @@ def make_handler(decider, name, delay_ms):
|
|
|
37
45
|
return self._send(404, {'error': 'not found'})
|
|
38
46
|
try:
|
|
39
47
|
body = json.loads(self.rfile.read(int(self.headers.get('Content-Length', 0))) or b'{}')
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
48
|
+
with infer_lock:
|
|
49
|
+
start = time.perf_counter()
|
|
50
|
+
answers = decider.predict(body)
|
|
51
|
+
infer_ms = (time.perf_counter() - start) * 1000
|
|
43
52
|
except (ValueError, KeyError, TypeError) as error:
|
|
44
53
|
return self._send(400, {'error': f'invalid request: {error}'})
|
|
45
54
|
if delay_ms:
|
|
@@ -52,9 +61,23 @@ def make_handler(decider, name, delay_ms):
|
|
|
52
61
|
return Handler
|
|
53
62
|
|
|
54
63
|
|
|
64
|
+
class NoDelayHTTPServer(ThreadingHTTPServer):
|
|
65
|
+
"""Turns off Nagle's algorithm on every connection.
|
|
66
|
+
|
|
67
|
+
The handler writes the headers and the body separately. On a kept-alive connection the second small write waits
|
|
68
|
+
for the client's delayed ACK, so every answer took about 40 ms (measured 2026-10-03: median 42 ms kept-alive vs
|
|
69
|
+
1.4 ms with TCP_NODELAY). Clients that reuse connections (Java HttpURLConnection does) missed 30 ms deadlines.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def get_request(self):
|
|
73
|
+
sock, addr = super().get_request()
|
|
74
|
+
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
|
75
|
+
return sock, addr
|
|
76
|
+
|
|
77
|
+
|
|
55
78
|
def serve(decider='doom-defend', host='127.0.0.1', port=11890, delay_ms=0.0, name=None):
|
|
56
79
|
decider = decider if isinstance(decider, Decider) else Decider(decider)
|
|
57
80
|
name = name or f'simthink-d:{decider.preset["name"]}'
|
|
58
|
-
server =
|
|
81
|
+
server = NoDelayHTTPServer((host, port), make_handler(decider, name, delay_ms))
|
|
59
82
|
print(json.dumps({'listening': f'{host}:{port}', 'weights_sha256': decider.sha256, 'model': name}), flush=True)
|
|
60
83
|
server.serve_forever()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: simthinkd
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: A 265k-parameter decision model that picks one action in about 2 ms on one CPU core, inside real-time loops.
|
|
5
5
|
Author: Myeongseongsimjae AX Institute
|
|
6
6
|
License: Apache-2.0
|
|
@@ -28,7 +28,9 @@ Requires-Dist: onnx>=1.15; extra == "onnx"
|
|
|
28
28
|
Requires-Dist: onnxruntime>=1.17; extra == "onnx"
|
|
29
29
|
Dynamic: license-file
|
|
30
30
|
|
|
31
|
-
<p align="center"><
|
|
31
|
+
<p align="center"><b>English</b> | <a href="README.ko.md">한국어</a> | <a href="README.ja.md">日本語</a></p>
|
|
32
|
+
|
|
33
|
+
<p align="center"><img src="assets/banner_v2.png" alt="SimThink D: a local backup for cloud decisions" width="100%"></p>
|
|
32
34
|
|
|
33
35
|
<p align="center">
|
|
34
36
|
<a href="https://pypi.org/project/simthinkd/"><img src="https://img.shields.io/pypi/v/simthinkd" alt="PyPI"></a>
|
|
@@ -39,27 +41,23 @@ Dynamic: license-file
|
|
|
39
41
|
<a href="https://doi.org/10.5281/zenodo.23111615"><img src="https://zenodo.org/badge/DOI/10.5281/zenodo.23111615.svg" alt="DOI"></a>
|
|
40
42
|
</p>
|
|
41
43
|
|
|
42
|
-
<p align="center"><
|
|
43
|
-
|
|
44
|
-
**A tiny decision model that runs on one CPU core.** It has 265,665 parameters. It picks one action in about 2 ms. That is fast enough to decide inside every tick of a game or a control loop. No GPU is needed, not even for training.
|
|
45
|
-
|
|
46
|
-
<p align="center">
|
|
47
|
-
<img src="assets/side_by_side.gif" alt="Same Doom game, same seed, same CPU. Left: SimThink D answers every tick. Right: a 421M-parameter general decision model, used as published, misses most ticks while it thinks." width="100%" />
|
|
48
|
-
</p>
|
|
49
|
-
|
|
50
|
-
<p align="center"><sub>Same game, same seed, same 6-core CPU. Left: SimThink D, 1.9 ms per decision, 1 of 420 ticks missed. Right: Laya, a 421M-parameter open decision model, used as published without training on this game. It takes about 360 ms per decision and misses 390 of 420 ticks. A dark frame means the game moved on before the decider answered.</sub></p>
|
|
44
|
+
<p align="center"><b>Network down. Decisions stay local.</b></p>
|
|
51
45
|
|
|
52
|
-
|
|
46
|
+
SimThink D is a small CPU model for offline backup decisions.
|
|
47
|
+
It has 265,665 parameters and takes about 2 ms per decision on one CPU core.
|
|
53
48
|
|
|
54
|
-
|
|
49
|
+
In the factory simulation, the internet was cut for 15 seconds.
|
|
50
|
+
The local backup got 59 of 63 parts right, with no late decisions.
|
|
55
51
|
|
|
56
|
-
|
|
52
|
+
<p align="center"><a href="assets/factory_fallback.mp4"><img src="assets/factory_twin.gif" alt="Factory simulation: the internet is cut for 15 seconds and SimThink D on the factory PC fills the gap" width="100%"></a></p>
|
|
57
53
|
|
|
58
|
-
|
|
54
|
+
<p align="center"><a href="https://mssj-ai-org.github.io/simthinkd/">Project page</a> · <a href="https://mssj-ai-org.github.io/simthinkd/#demo">Try in your browser</a> · <a href="assets/factory_fallback.mp4">Watch the video (72 s)</a> · <a href="examples/factory_twin/">Factory code</a> · <a href="docs/PAPER.md">Paper</a></p>
|
|
59
55
|
|
|
60
|
-
|
|
56
|
+
```bash
|
|
57
|
+
pip install simthinkd
|
|
58
|
+
```
|
|
61
59
|
|
|
62
|
-
|
|
60
|
+
What makes the next decision when your network goes down?
|
|
63
61
|
|
|
64
62
|
## Words used here
|
|
65
63
|
|
|
@@ -94,6 +92,23 @@ One call is one decision. A short sentence goes in. One action comes out, with i
|
|
|
94
92
|
|
|
95
93
|
Two deciders come with the package: `doom-defend` (stand in the middle and fight) and `doom-corridor` (fight your way down a corridor).
|
|
96
94
|
|
|
95
|
+
## Many decisions at once
|
|
96
|
+
|
|
97
|
+
One call can carry several questions about the same situation, and one batch can carry many situations. The answers are the same as asking one by one.
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
from simthinkd import Decider
|
|
101
|
+
|
|
102
|
+
d = Decider("doom-defend")
|
|
103
|
+
states = ["seen: Demon left a30 d5 | enemies 1 | sway left | gun ready | ammo25",
|
|
104
|
+
"seen: Demon right a30 d5 | enemies 1 | sway right | gun ready | ammo25"]
|
|
105
|
+
answers = d.predict_batch([d.request(s) for s in states]) # many situations in one pass
|
|
106
|
+
print([a["operation"]["choice"] for a in answers])
|
|
107
|
+
# ['TURN_LEFT', 'TURN_RIGHT']
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
`simthinkd bench --batch` shows where the time goes on your machine. Details and limits: [docs/PARALLEL.md](docs/PARALLEL.md).
|
|
111
|
+
|
|
97
112
|
## Train your own in seconds
|
|
98
113
|
|
|
99
114
|
A new task needs two things: a perception step and a teacher. The `toy` module below is a small made-up task, an inspection station on a factory belt. Swap its examples and actions for your own.
|
|
@@ -181,6 +196,12 @@ The second line measures any server that accepts the [decision request](docs/PRO
|
|
|
181
196
|
|
|
182
197
|
## Same CPU, same states
|
|
183
198
|
|
|
199
|
+
<p align="center">
|
|
200
|
+
<img src="assets/side_by_side.gif" alt="Same Doom game, same seed, same CPU. Left: SimThink D answers every tick. Right: a 421M-parameter general decision model, used as published, misses most ticks while it thinks." width="100%" />
|
|
201
|
+
</p>
|
|
202
|
+
|
|
203
|
+
<p align="center"><sub>Same game, same seed, same 6-core CPU. Left: SimThink D, 1.9 ms per decision, 1 of 420 ticks missed. Right: Laya, a 421M-parameter open decision model, used as published without training on this game. It takes about 360 ms per decision and misses 390 of 420 ticks. A dark frame means the game moved on before the decider answered.</sub></p>
|
|
204
|
+
|
|
184
205
|
**Read this first.** Laya is a general model and was not trained on this game. Its published speed, about 33 ms per question, is on a GPU. We only had a CPU. So this table compares time inside a real-time loop. It does not compare overall quality.
|
|
185
206
|
|
|
186
207
|
We used one workstation CPU (6 threads) and 1,050 Doom states, then 10 live games on the same seeds. "Missed ticks" are ticks that passed before the decider answered.
|
|
@@ -199,7 +220,9 @@ SimThink D only knows what its teacher knows. It does not reason, read long text
|
|
|
199
220
|
| Any language, any engine | `simthinkd serve doom-defend --port 11890`, then POST the [decision request](docs/PROTOCOL.md) to `/v1/systemone` |
|
|
200
221
|
| Unity / C# | [docs/INTEGRATION_UNITY.md](docs/INTEGRATION_UNITY.md): a client loop that keeps the game running while it waits |
|
|
201
222
|
| Browser | [web/](web/): the same model in plain JavaScript, no server |
|
|
223
|
+
| Java / JVM games and engines | [java/](java/): the same model in plain Java 8, inside your process; export weights with `tools/export_java_weights.py`; a test checks it chooses exactly what Python chooses |
|
|
202
224
|
| A factory line (simulator) | [examples/factory_twin/](examples/factory_twin/): an inspection conveyor with a 400 ms deadline per part |
|
|
225
|
+
| Gradio | [space/](space/): a small web demo you can run locally or on Hugging Face Spaces |
|
|
203
226
|
| MCP (Claude Desktop, Cursor and others) | `pip install "simthinkd[mcp]"`, then `python -m simthinkd.integrations.mcp_server` |
|
|
204
227
|
| LangChain / LangGraph | `from simthinkd.integrations.langchain_tool import simthinkd_tool` |
|
|
205
228
|
|
|
@@ -218,6 +241,13 @@ SimThink D only knows what its teacher knows. It does not reason, read long text
|
|
|
218
241
|
- Probabilities are calibrated for the decider's own task only.
|
|
219
242
|
- A score model returns one number. It gives no probability or error bar with it.
|
|
220
243
|
|
|
244
|
+
## More
|
|
245
|
+
|
|
246
|
+
- [Figures from the paper](docs/FIGURES.md)
|
|
247
|
+
- [What you can reproduce](docs/REPRODUCE.md)
|
|
248
|
+
- [Decision request format](docs/PROTOCOL.md)
|
|
249
|
+
- [The factory video on LinkedIn](https://www.linkedin.com/feed/update/urn:li:activity:7510142949981057024/)
|
|
250
|
+
|
|
221
251
|
## Citation
|
|
222
252
|
|
|
223
253
|
If you use SimThink D, please cite it with [CITATION.cff](CITATION.cff). GitHub shows a "Cite this repository" button for it.
|
|
@@ -25,9 +25,13 @@ src/simthinkd/weights/SHA256SUMS
|
|
|
25
25
|
src/simthinkd/weights/doom-corridor.npz
|
|
26
26
|
src/simthinkd/weights/doom-defend.npz
|
|
27
27
|
tests/test_factory_twin.py
|
|
28
|
+
tests/test_java_parity.py
|
|
28
29
|
tests/test_notebook.py
|
|
29
30
|
tests/test_package.py
|
|
31
|
+
tests/test_parallel.py
|
|
30
32
|
tests/test_score.py
|
|
33
|
+
tests/test_server_concurrency.py
|
|
34
|
+
tests/test_server_latency.py
|
|
31
35
|
tests/test_space.py
|
|
32
36
|
tests/test_web_page.py
|
|
33
37
|
tests/test_web_parity.py
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Java runtime parity: java/src/main/java/simthinkd must choose exactly what the Python decider chooses.
|
|
2
|
+
|
|
3
|
+
python -X utf8 tests/test_java_parity.py <decider folder> <requests.jsonl> [--synthetic N]
|
|
4
|
+
Compiles the Java runtime (javac --release 8), exports the weights (tools/export_java_weights.py), feeds every request to
|
|
5
|
+
both, and compares the operation choice and, when offered, the target choice. Adds synthetic requests (Korean, emoji,
|
|
6
|
+
punctuation, long texts, recent actions) built from the same decider. Exit 0 only if 100% of choices agree; reports the
|
|
7
|
+
largest probability difference.
|
|
8
|
+
"""
|
|
9
|
+
import json
|
|
10
|
+
import random
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import tempfile
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
17
|
+
sys.path.insert(0, str(ROOT / "src"))
|
|
18
|
+
from simthinkd import Decider # noqa: E402
|
|
19
|
+
|
|
20
|
+
WORDS = ["적", "해병", "zealot", "dist", "12", "hp", "40", "near", "멀리", "😀", "—", "a.b", "x_y", "Ⅻ", "fi", "Ünïcode", "\t", " "]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def synthetic(base, rng, n):
|
|
24
|
+
out = []
|
|
25
|
+
for _ in range(n):
|
|
26
|
+
b = json.loads(json.dumps(base))
|
|
27
|
+
crit = b["questions"].get("attack_target", {}).get("criteria", {})
|
|
28
|
+
for k in list(crit):
|
|
29
|
+
crit[k]["description"] = " ".join(rng.choice(WORDS) for _ in range(rng.randint(1, 25)))
|
|
30
|
+
b["state"]["page"]["text"] = " ".join(rng.choice(WORDS) for _ in range(rng.randint(0, 40)))
|
|
31
|
+
if rng.random() < 0.5:
|
|
32
|
+
b["state"]["recent_actions"] = [{"action": rng.choice(["ATTACK", "e0", "e1", "WAIT"]), "page_changed": rng.random() < 0.5}
|
|
33
|
+
for _ in range(rng.randint(1, 6))]
|
|
34
|
+
out.append(b)
|
|
35
|
+
return out
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def main():
|
|
39
|
+
model, reqs = Path(sys.argv[1]), Path(sys.argv[2])
|
|
40
|
+
n_syn = int(sys.argv[sys.argv.index("--synthetic") + 1]) if "--synthetic" in sys.argv else 500
|
|
41
|
+
bodies = [json.loads(l) for l in reqs.read_text(encoding="utf-8").splitlines() if l.strip()]
|
|
42
|
+
bodies += synthetic(bodies[0], random.Random(5), n_syn)
|
|
43
|
+
with tempfile.TemporaryDirectory() as t:
|
|
44
|
+
t = Path(t)
|
|
45
|
+
smtd = t / "w.smtd"
|
|
46
|
+
subprocess.run([sys.executable, str(ROOT / "tools" / "export_java_weights.py"), str(model), str(smtd)], check=True, capture_output=True)
|
|
47
|
+
src = sorted(str(p) for p in (ROOT / "java" / "src" / "main" / "java" / "simthinkd").glob("*.java"))
|
|
48
|
+
subprocess.run(["javac", "--release", "8", "-encoding", "UTF-8", "-d", str(t / "cls"), *src], check=True)
|
|
49
|
+
inp = "\n".join(json.dumps(b, ensure_ascii=False) for b in bodies) + "\n"
|
|
50
|
+
r = subprocess.run(["java", "-cp", str(t / "cls"), "simthinkd.SimThinkD", str(smtd)], input=inp.encode("utf-8"), capture_output=True, check=True)
|
|
51
|
+
jans = [json.loads(l) for l in r.stdout.decode("utf-8").splitlines() if l.strip()]
|
|
52
|
+
d = Decider(str(model))
|
|
53
|
+
assert len(jans) == len(bodies), (len(jans), len(bodies))
|
|
54
|
+
agree_op = agree_t = n_t = 0
|
|
55
|
+
max_diff = 0.0
|
|
56
|
+
bad = []
|
|
57
|
+
for i, (b, ja) in enumerate(zip(bodies, jans)):
|
|
58
|
+
pa = d.predict(b)
|
|
59
|
+
agree_op += pa["operation"]["choice"] == ja["operation"]["choice"]
|
|
60
|
+
for k, v in pa["operation"]["probabilities"].items():
|
|
61
|
+
max_diff = max(max_diff, abs(v - ja["operation"]["probabilities"][k]))
|
|
62
|
+
heads = [h for h in pa if h.endswith("_target")]
|
|
63
|
+
for h in heads:
|
|
64
|
+
n_t += 1
|
|
65
|
+
ok = h in ja and pa[h]["choice"] == ja[h]["choice"]
|
|
66
|
+
agree_t += ok
|
|
67
|
+
if h in ja:
|
|
68
|
+
for k, v in pa[h]["probabilities"].items():
|
|
69
|
+
max_diff = max(max_diff, abs(v - ja[h]["probabilities"].get(k, -1)))
|
|
70
|
+
if not ok and len(bad) < 5:
|
|
71
|
+
bad.append({"i": i, "py": pa[h]["choice"], "java": ja.get(h, {}).get("choice"),
|
|
72
|
+
"py_p": sorted(pa[h]["probabilities"].values())[-2:]})
|
|
73
|
+
ms = sorted(j["_ms"] for j in jans)
|
|
74
|
+
rep = {"requests": len(bodies), "operation_agree": agree_op, "target_questions": n_t, "target_agree": agree_t,
|
|
75
|
+
"max_prob_diff": max_diff, "java_ms_p50": ms[len(ms) // 2], "java_ms_p95": ms[int(len(ms) * 0.95)], "mismatches": bad}
|
|
76
|
+
print(json.dumps(rep, ensure_ascii=False))
|
|
77
|
+
sys.exit(0 if agree_op == len(bodies) and agree_t == n_t else 1)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
if __name__ == "__main__":
|
|
81
|
+
main()
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Parallel questions (Decider.ask) and batch inference (predict_batch, ask_batch): answers must not change."""
|
|
2
|
+
import copy
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
import pytest
|
|
8
|
+
except ImportError: # CI runs this file as a script without pytest
|
|
9
|
+
pytest = None
|
|
10
|
+
|
|
11
|
+
from simthinkd import Decider
|
|
12
|
+
|
|
13
|
+
STATES = Path(__file__).resolve().parent.parent / 'src' / 'simthinkd' / 'data' / 'doom_defend_states.json'
|
|
14
|
+
parametrize = pytest.mark.parametrize if pytest else (lambda *a, **k: (lambda f: f))
|
|
15
|
+
TOL = 1e-6 # float32 network: a padded batch may round differently in the last bits, never in the choice
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def bodies(n=300):
|
|
19
|
+
return [r['body'] for r in json.loads(STATES.read_text(encoding='utf8'))['rows'][:n]]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def assert_same(a, b):
|
|
23
|
+
assert a.keys() == b.keys()
|
|
24
|
+
for q in a:
|
|
25
|
+
assert a[q]['choice'] == b[q]['choice']
|
|
26
|
+
assert a[q]['probabilities'].keys() == b[q]['probabilities'].keys()
|
|
27
|
+
for k, p in a[q]['probabilities'].items():
|
|
28
|
+
assert abs(p - b[q]['probabilities'][k]) <= TOL
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def with_question(body, name, question):
|
|
32
|
+
body = copy.deepcopy(body)
|
|
33
|
+
body['questions'][name] = question
|
|
34
|
+
return body
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
DANGER = {'type': 'choice', 'instructions': {'goal': 'Which side is more dangerous?'},
|
|
38
|
+
'criteria': {'left': 'More enemies on the left.', 'right': 'More enemies on the right.', 'none': 'No enemy close.'}}
|
|
39
|
+
URGENCY = {'type': 'score', 'instructions': {'goal': 'How urgent is the situation?'},
|
|
40
|
+
'criteria': ['calm', 'some pressure', 'under attack', 'about to die']}
|
|
41
|
+
RELOAD = {'type': 'noul', 'instructions': {'goal': 'Should we save ammunition now?'}}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@parametrize('preset', ['doom-defend', 'doom-corridor'])
|
|
45
|
+
def test_predict_batch_equals_one_at_a_time(preset):
|
|
46
|
+
d = Decider(preset)
|
|
47
|
+
xs = bodies() if preset == 'doom-defend' else [d.request(s) for s in (
|
|
48
|
+
'enemies: ShotgunGuy left near offset15 | goal: armor ahead distant | body healthy | ammo stocked',
|
|
49
|
+
'enemies: none | goal: armor ahead near | body hurt | ammo low',
|
|
50
|
+
'enemies: Zombieman right near offset5 ; ShotgunGuy left far offset30 | goal: armor ahead distant | body healthy | ammo stocked')]
|
|
51
|
+
for single, batched in zip([d.predict(b) for b in xs], d.predict_batch(xs)):
|
|
52
|
+
assert_same(single, batched)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_ask_on_a_plain_request_equals_predict():
|
|
56
|
+
d = Decider('doom-defend')
|
|
57
|
+
for body in bodies(100):
|
|
58
|
+
assert_same(d.ask(body)['answers'], d.predict(body))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_extra_questions_do_not_change_the_operation_answer():
|
|
62
|
+
d = Decider('doom-defend')
|
|
63
|
+
for body in bodies(100):
|
|
64
|
+
plain = d.predict(body)
|
|
65
|
+
multi = d.ask(with_question(with_question(with_question(body, 'danger', DANGER), 'urgency', URGENCY), 'reload', RELOAD))
|
|
66
|
+
assert_same({k: multi['answers'][k] for k in plain}, plain)
|
|
67
|
+
assert multi['meta']['questions'] == 4
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_questions_stay_independent_of_each_other():
|
|
71
|
+
d = Decider('doom-defend')
|
|
72
|
+
other = {'type': 'choice', 'instructions': {'goal': 'Pick a colour.'},
|
|
73
|
+
'criteria': {f'c{i}': f'colour number {i}' for i in range(40)}} # many rows: changes the padded shape
|
|
74
|
+
for body in bodies(50):
|
|
75
|
+
alone = d.ask(with_question(body, 'danger', DANGER))['answers']['danger']
|
|
76
|
+
mixed = d.ask(with_question(with_question(body, 'danger', DANGER), 'colour', other))['answers']['danger']
|
|
77
|
+
assert alone['choice'] == mixed['choice']
|
|
78
|
+
assert all(abs(alone['probabilities'][k] - mixed['probabilities'][k]) <= TOL for k in alone['probabilities'])
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_score_and_noul_answers_have_their_shape():
|
|
82
|
+
d = Decider('doom-defend')
|
|
83
|
+
a = d.ask(with_question(with_question(bodies(1)[0], 'urgency', URGENCY), 'reload', RELOAD))['answers']
|
|
84
|
+
assert a['urgency']['type'] == 'score' and 1 <= a['urgency']['score'] <= 4
|
|
85
|
+
assert abs(sum(a['urgency']['probabilities'].values()) - 1) <= TOL
|
|
86
|
+
assert a['reload']['type'] == 'noul' and 0 <= a['reload']['noul'] <= 1
|
|
87
|
+
assert set(a['reload']['probabilities']) == {'true', 'false'}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_ask_batch_equals_ask():
|
|
91
|
+
d = Decider('doom-defend')
|
|
92
|
+
xs = [with_question(b, 'danger', DANGER) if i % 2 else b for i, b in enumerate(bodies(60))]
|
|
93
|
+
for one, many in zip([d.ask(b) for b in xs], d.ask_batch(xs)):
|
|
94
|
+
assert one['meta'] == many['meta']
|
|
95
|
+
assert_same({k: v for k, v in one['answers'].items() if 'choice' in v},
|
|
96
|
+
{k: v for k, v in many['answers'].items() if 'choice' in v})
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_question_only_request_without_operation():
|
|
100
|
+
d = Decider('doom-defend')
|
|
101
|
+
body = {'state': bodies(1)[0]['state'], 'questions': {'danger': DANGER, 'reload': RELOAD}}
|
|
102
|
+
a = d.ask(body)
|
|
103
|
+
assert set(a['answers']) == {'danger', 'reload'} and a['meta']['questions'] == 2
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@parametrize('bad', [{'type': 'score', 'criteria': ['only one']},
|
|
107
|
+
{'type': 'noul', 'criteria': {'yes': 'a', 'no': 'b'}},
|
|
108
|
+
{'type': 'rank', 'criteria': {'a': 'a'}},
|
|
109
|
+
{'type': 'choice'}])
|
|
110
|
+
def test_bad_questions_are_refused(bad):
|
|
111
|
+
try:
|
|
112
|
+
Decider('doom-defend').ask(with_question(bodies(1)[0], 'bad', bad))
|
|
113
|
+
except ValueError:
|
|
114
|
+
return
|
|
115
|
+
raise AssertionError(f'bad question accepted: {bad}')
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def target_body(n, seed):
|
|
119
|
+
"""An operation request with an ATTACK target question of n enemies and a WAIT operation without targets."""
|
|
120
|
+
enemies = {f'e{i}': {'element': f'[enemy] e{i}', 'description': f'Zergling dist {10 + 7 * ((i + seed) % 9)} hp {20 + 5 * i}'}
|
|
121
|
+
for i in range(n)}
|
|
122
|
+
return {'state': {'page': {'url': 'sim://custom', 'title': '', 'text': f'me: Marine | enemies {n}'}, 'elements': [], 'recent_actions': []},
|
|
123
|
+
'questions': {'operation': {'type': 'choice', 'instructions': {'goal': 'Win the fight.'},
|
|
124
|
+
'criteria': {'ATTACK': 'Attack one enemy.', 'WAIT': 'Hold position.'}},
|
|
125
|
+
'attack_target': {'type': 'choice', 'criteria': enemies}}}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_targets_with_unequal_counts_route_and_batch_correctly():
|
|
129
|
+
d = Decider('doom-defend')
|
|
130
|
+
xs = [target_body(n, s) for s in range(3) for n in (1, 2, 5, 9, 17)]
|
|
131
|
+
singles = [d.predict(b) for b in xs]
|
|
132
|
+
for single, batched, asked in zip(singles, d.predict_batch(xs), d.ask_batch(xs)):
|
|
133
|
+
assert_same(single, batched)
|
|
134
|
+
assert_same(single, asked['answers'])
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def test_scores_batch_equals_scores_block_by_block():
|
|
138
|
+
from simthinkd.policy import encode
|
|
139
|
+
d = Decider('doom-defend')
|
|
140
|
+
blocks = [encode(b)[0] for b in bodies(20)] + [encode(target_body(9, 1))[0]]
|
|
141
|
+
blocks += [blocks[0][:1], blocks[-1][:3]] # single-row and cut blocks, mixed sizes
|
|
142
|
+
for block, s in zip(blocks, d.policy.scores_batch(blocks)):
|
|
143
|
+
single = d.policy.scores(block)
|
|
144
|
+
assert s.dtype == single.dtype and s.shape == single.shape
|
|
145
|
+
# raw scores are float32 logits: compare relative to their size (probabilities are checked at 1e-6 elsewhere)
|
|
146
|
+
assert all(abs(float(a) - float(b)) <= 1e-5 * max(1.0, abs(float(b))) for a, b in zip(s, single))
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_empty_inputs():
|
|
150
|
+
d = Decider('doom-defend')
|
|
151
|
+
assert d.predict_batch([]) == [] and d.ask_batch([]) == [] and d.policy.scores_batch([]) == []
|
|
152
|
+
assert len(d.ask_batch(iter(bodies(2)))) == 2 # any iterable, read once
|
|
153
|
+
from simthinkd.policy import encode
|
|
154
|
+
x = encode(bodies(1)[0])[0]
|
|
155
|
+
empty = {'state': bodies(1)[0]['state'], 'questions': {}}
|
|
156
|
+
for batch in ([empty], [bodies(1)[0], empty], [empty, bodies(1)[0]]): # refused alone and beside a valid request
|
|
157
|
+
try:
|
|
158
|
+
d.ask_batch(batch)
|
|
159
|
+
except ValueError:
|
|
160
|
+
continue
|
|
161
|
+
raise AssertionError('a request without questions was accepted')
|
|
162
|
+
for blocks in ([x, x[:0]], [x[:0], x]):
|
|
163
|
+
try:
|
|
164
|
+
d.policy.scores_batch(blocks)
|
|
165
|
+
except ValueError:
|
|
166
|
+
continue
|
|
167
|
+
raise AssertionError('an empty block was accepted')
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def test_score_and_noul_values_match_their_probabilities_in_batches():
|
|
171
|
+
d = Decider('doom-defend')
|
|
172
|
+
xs = [with_question(with_question(b, 'urgency', URGENCY), 'reload', RELOAD) for b in bodies(40)]
|
|
173
|
+
for one, many in zip([d.ask(b) for b in xs], d.ask_batch(xs)):
|
|
174
|
+
for a in (one['answers'], many['answers']):
|
|
175
|
+
u, r = a['urgency'], a['reload']
|
|
176
|
+
assert u['score'] == int(max(u['probabilities'], key=u['probabilities'].get))
|
|
177
|
+
assert r['noul'] == r['probabilities']['true']
|
|
178
|
+
assert one['answers']['urgency']['score'] == many['answers']['urgency']['score']
|
|
179
|
+
assert abs(one['answers']['reload']['noul'] - many['answers']['reload']['noul']) <= TOL
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
if __name__ == '__main__': # CI runs test files as scripts
|
|
183
|
+
cases = [(test_predict_batch_equals_one_at_a_time, p) for p in ('doom-defend', 'doom-corridor')]
|
|
184
|
+
cases += [(f, None) for f in (test_ask_on_a_plain_request_equals_predict, test_extra_questions_do_not_change_the_operation_answer,
|
|
185
|
+
test_questions_stay_independent_of_each_other, test_score_and_noul_answers_have_their_shape,
|
|
186
|
+
test_ask_batch_equals_ask, test_question_only_request_without_operation,
|
|
187
|
+
test_targets_with_unequal_counts_route_and_batch_correctly, test_scores_batch_equals_scores_block_by_block,
|
|
188
|
+
test_empty_inputs, test_score_and_noul_values_match_their_probabilities_in_batches)]
|
|
189
|
+
for f, arg in cases:
|
|
190
|
+
f(arg) if arg else f()
|
|
191
|
+
for bad in [{'type': 'score', 'criteria': ['only one']}, {'type': 'noul', 'criteria': {'yes': 'a', 'no': 'b'}},
|
|
192
|
+
{'type': 'rank', 'criteria': {'a': 'a'}}, {'type': 'choice'}]:
|
|
193
|
+
test_bad_questions_are_refused(bad)
|
|
194
|
+
print(f'parallel tests: {len(cases) + 4} passed')
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Concurrent clients must get the same answers as sequential ones, and inference must never overlap inside one process
|
|
2
|
+
(regression: a server fed by four parallel games died with a malloc error on 2026-10-04; inference is now serialised)."""
|
|
3
|
+
import http.client
|
|
4
|
+
import json
|
|
5
|
+
import threading
|
|
6
|
+
|
|
7
|
+
from simthinkd.core import Decider
|
|
8
|
+
from simthinkd.server import NoDelayHTTPServer, make_handler
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CountingDecider:
|
|
12
|
+
"""Wraps a Decider and records the largest number of predict calls running at the same time."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, inner):
|
|
15
|
+
self.inner, self.sha256, self.preset = inner, inner.sha256, inner.preset
|
|
16
|
+
self.active = self.peak = 0
|
|
17
|
+
self.lock = threading.Lock()
|
|
18
|
+
|
|
19
|
+
def predict(self, body):
|
|
20
|
+
with self.lock:
|
|
21
|
+
self.active += 1
|
|
22
|
+
self.peak = max(self.peak, self.active)
|
|
23
|
+
try:
|
|
24
|
+
return self.inner.predict(body)
|
|
25
|
+
finally:
|
|
26
|
+
with self.lock:
|
|
27
|
+
self.active -= 1
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _post(port, body):
|
|
31
|
+
conn = http.client.HTTPConnection('127.0.0.1', port, timeout=10)
|
|
32
|
+
conn.request('POST', '/v1/systemone', json.dumps(body), {'Content-Type': 'application/json'})
|
|
33
|
+
return json.loads(conn.getresponse().read())['answers']
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_concurrent_answers_match_and_never_overlap():
|
|
37
|
+
inner = Decider('doom-defend')
|
|
38
|
+
body = inner.example_request() if hasattr(inner, 'example_request') else None
|
|
39
|
+
decider = CountingDecider(inner)
|
|
40
|
+
server = NoDelayHTTPServer(('127.0.0.1', 0), make_handler(decider, 'test', 0.0))
|
|
41
|
+
threading.Thread(target=server.serve_forever, daemon=True).start()
|
|
42
|
+
port = server.server_address[1]
|
|
43
|
+
try:
|
|
44
|
+
if body is None:
|
|
45
|
+
body = {'state': {'page': {'url': 'sim://custom', 'title': '', 'text': 'enemy left'},
|
|
46
|
+
'elements': [{'id': 'A', 'role': 'button', 'label': 'A'}, {'id': 'B', 'role': 'button', 'label': 'B'}],
|
|
47
|
+
'recent_actions': []},
|
|
48
|
+
'questions': {'operation': {'type': 'choice', 'criteria': {'A': {'element': '[button] A', 'description': 'go left'},
|
|
49
|
+
'B': {'element': '[button] B', 'description': 'go right'}}}}}
|
|
50
|
+
expected = _post(port, body)
|
|
51
|
+
results, errors = [], []
|
|
52
|
+
|
|
53
|
+
def worker():
|
|
54
|
+
try:
|
|
55
|
+
for _ in range(20):
|
|
56
|
+
results.append(_post(port, body))
|
|
57
|
+
except Exception as error: # noqa: BLE001 - collected and asserted below
|
|
58
|
+
errors.append(repr(error))
|
|
59
|
+
|
|
60
|
+
threads = [threading.Thread(target=worker) for _ in range(16)]
|
|
61
|
+
for t in threads:
|
|
62
|
+
t.start()
|
|
63
|
+
for t in threads:
|
|
64
|
+
t.join()
|
|
65
|
+
assert not errors, errors[:3]
|
|
66
|
+
assert len(results) == 320
|
|
67
|
+
assert all(r == expected for r in results)
|
|
68
|
+
assert decider.peak == 1, decider.peak
|
|
69
|
+
finally:
|
|
70
|
+
server.shutdown()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
if __name__ == "__main__":
|
|
74
|
+
test_concurrent_answers_match_and_never_overlap()
|
|
75
|
+
print("server concurrency test passed")
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""The HTTP server must answer fast on a kept-alive connection (regression: ~40 ms Nagle/delayed-ACK stall in 0.2.0)."""
|
|
2
|
+
import http.client
|
|
3
|
+
import statistics
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
from simthinkd.core import Decider
|
|
8
|
+
from simthinkd.server import NoDelayHTTPServer, make_handler
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_keep_alive_answers_fast():
|
|
12
|
+
decider = Decider('doom-defend')
|
|
13
|
+
server = NoDelayHTTPServer(('127.0.0.1', 0), make_handler(decider, 'test', 0.0))
|
|
14
|
+
threading.Thread(target=server.serve_forever, daemon=True).start()
|
|
15
|
+
try:
|
|
16
|
+
conn = http.client.HTTPConnection('127.0.0.1', server.server_address[1], timeout=2)
|
|
17
|
+
times = []
|
|
18
|
+
for _ in range(30):
|
|
19
|
+
start = time.perf_counter()
|
|
20
|
+
conn.request('GET', '/health')
|
|
21
|
+
conn.getresponse().read()
|
|
22
|
+
times.append((time.perf_counter() - start) * 1000)
|
|
23
|
+
assert statistics.median(times) < 20, times
|
|
24
|
+
finally:
|
|
25
|
+
server.shutdown()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
if __name__ == "__main__":
|
|
29
|
+
test_keep_alive_answers_fast()
|
|
30
|
+
print("server latency test passed")
|
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
"""Does your decider fit inside one game tick? Measures decision time and teacher agreement on recorded states.
|
|
2
|
-
|
|
3
|
-
simthinkd bench # the bundled decider, in-process, bundled Doom states
|
|
4
|
-
simthinkd bench --url http://127.0.0.1:8000/v1/systemone --name my-model
|
|
5
|
-
simthinkd bench --states my_states.json --tick-hz 60
|
|
6
|
-
|
|
7
|
-
Any server that accepts the decision request (PROTOCOL.md) and answers {"answers": {"operation": {"choice": ...}}}
|
|
8
|
-
can be measured. States file: {"rows": [{"body": <request>, "teacher": <action>}, ...]}.
|
|
9
|
-
One request at a time; the time is the full round trip seen by the caller.
|
|
10
|
-
"""
|
|
11
|
-
import json
|
|
12
|
-
import statistics
|
|
13
|
-
import time
|
|
14
|
-
import urllib.request
|
|
15
|
-
from pathlib import Path
|
|
16
|
-
|
|
17
|
-
from .core import Decider
|
|
18
|
-
|
|
19
|
-
BUNDLED = Path(__file__).resolve().parent / 'data' / 'doom_defend_states.json'
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
def _post(url, body):
|
|
23
|
-
req = urllib.request.Request(url, data=json.dumps(body).encode(), headers={'Content-Type': 'application/json'})
|
|
24
|
-
with urllib.request.urlopen(req, timeout=300) as reply:
|
|
25
|
-
return json.load(reply)['answers']['operation']['choice']
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
def run(states=None, url=None, decider='doom-defend', name=None, tick_hz=35.0, warmup=20, limit=0):
|
|
29
|
-
rows = json.loads(Path(states or BUNDLED).read_text(encoding='utf8'))['rows']
|
|
30
|
-
rows = rows[:limit] if limit else rows
|
|
31
|
-
if url:
|
|
32
|
-
call, label = (lambda body: _post(url, body)), name or url
|
|
33
|
-
else:
|
|
34
|
-
d = decider if isinstance(decider, Decider) else Decider(decider)
|
|
35
|
-
call, label = (lambda body: d.predict(body)['operation']['choice']), name or f'simthink-d:{d.preset["name"]}'
|
|
36
|
-
for row in rows[:warmup]:
|
|
37
|
-
call(row['body'])
|
|
38
|
-
times, agree = [], 0
|
|
39
|
-
for row in rows:
|
|
40
|
-
start = time.perf_counter()
|
|
41
|
-
choice = call(row['body'])
|
|
42
|
-
times.append((time.perf_counter() - start) * 1000)
|
|
43
|
-
agree += choice == row['teacher']
|
|
44
|
-
q = sorted(times)
|
|
45
|
-
tick_ms = 1000.0 / tick_hz
|
|
46
|
-
return {'name': label, 'states': len(rows), 'tick_ms': round(tick_ms, 2),
|
|
47
|
-
'p50_ms': round(statistics.median(q), 3), 'p95_ms': round(q[max(0, int(0.95 * len(q)) - 1)], 3),
|
|
48
|
-
'within_one_tick': round(sum(t <= tick_ms for t in times) / len(times), 4),
|
|
49
|
-
'teacher_agreement': round(agree / len(rows), 4)}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
def report(result):
|
|
53
|
-
return (f"{result['name']}: median {result['p50_ms']} ms, p95 {result['p95_ms']} ms, "
|
|
54
|
-
f"{result['within_one_tick']:.1%} of decisions within one {result['tick_ms']} ms tick, "
|
|
55
|
-
f"teacher agreement {result['teacher_agreement']:.1%} on {result['states']} states")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|