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.
Files changed (40) hide show
  1. {simthinkd-0.2.0/src/simthinkd.egg-info → simthinkd-0.3.0}/PKG-INFO +47 -17
  2. {simthinkd-0.2.0 → simthinkd-0.3.0}/README.md +46 -16
  3. {simthinkd-0.2.0 → simthinkd-0.3.0}/pyproject.toml +1 -1
  4. {simthinkd-0.2.0 → simthinkd-0.3.0}/src/simthinkd/__init__.py +1 -1
  5. simthinkd-0.3.0/src/simthinkd/bench.py +103 -0
  6. {simthinkd-0.2.0 → simthinkd-0.3.0}/src/simthinkd/cli.py +7 -2
  7. {simthinkd-0.2.0 → simthinkd-0.3.0}/src/simthinkd/core.py +72 -2
  8. {simthinkd-0.2.0 → simthinkd-0.3.0}/src/simthinkd/policy.py +30 -2
  9. {simthinkd-0.2.0 → simthinkd-0.3.0}/src/simthinkd/server.py +27 -4
  10. {simthinkd-0.2.0 → simthinkd-0.3.0/src/simthinkd.egg-info}/PKG-INFO +47 -17
  11. {simthinkd-0.2.0 → simthinkd-0.3.0}/src/simthinkd.egg-info/SOURCES.txt +4 -0
  12. simthinkd-0.3.0/tests/test_java_parity.py +81 -0
  13. simthinkd-0.3.0/tests/test_parallel.py +194 -0
  14. simthinkd-0.3.0/tests/test_server_concurrency.py +75 -0
  15. simthinkd-0.3.0/tests/test_server_latency.py +30 -0
  16. simthinkd-0.2.0/src/simthinkd/bench.py +0 -55
  17. {simthinkd-0.2.0 → simthinkd-0.3.0}/LICENSE +0 -0
  18. {simthinkd-0.2.0 → simthinkd-0.3.0}/NOTICE +0 -0
  19. {simthinkd-0.2.0 → simthinkd-0.3.0}/setup.cfg +0 -0
  20. {simthinkd-0.2.0 → simthinkd-0.3.0}/src/simthinkd/data/doom_defend_states.json +0 -0
  21. {simthinkd-0.2.0 → simthinkd-0.3.0}/src/simthinkd/integrations/__init__.py +0 -0
  22. {simthinkd-0.2.0 → simthinkd-0.3.0}/src/simthinkd/integrations/langchain_tool.py +0 -0
  23. {simthinkd-0.2.0 → simthinkd-0.3.0}/src/simthinkd/integrations/mcp_server.py +0 -0
  24. {simthinkd-0.2.0 → simthinkd-0.3.0}/src/simthinkd/score.py +0 -0
  25. {simthinkd-0.2.0 → simthinkd-0.3.0}/src/simthinkd/toy.py +0 -0
  26. {simthinkd-0.2.0 → simthinkd-0.3.0}/src/simthinkd/train.py +0 -0
  27. {simthinkd-0.2.0 → simthinkd-0.3.0}/src/simthinkd/weights/SHA256SUMS +0 -0
  28. {simthinkd-0.2.0 → simthinkd-0.3.0}/src/simthinkd/weights/doom-corridor.npz +0 -0
  29. {simthinkd-0.2.0 → simthinkd-0.3.0}/src/simthinkd/weights/doom-defend.npz +0 -0
  30. {simthinkd-0.2.0 → simthinkd-0.3.0}/src/simthinkd.egg-info/dependency_links.txt +0 -0
  31. {simthinkd-0.2.0 → simthinkd-0.3.0}/src/simthinkd.egg-info/entry_points.txt +0 -0
  32. {simthinkd-0.2.0 → simthinkd-0.3.0}/src/simthinkd.egg-info/requires.txt +0 -0
  33. {simthinkd-0.2.0 → simthinkd-0.3.0}/src/simthinkd.egg-info/top_level.txt +0 -0
  34. {simthinkd-0.2.0 → simthinkd-0.3.0}/tests/test_factory_twin.py +0 -0
  35. {simthinkd-0.2.0 → simthinkd-0.3.0}/tests/test_notebook.py +0 -0
  36. {simthinkd-0.2.0 → simthinkd-0.3.0}/tests/test_package.py +0 -0
  37. {simthinkd-0.2.0 → simthinkd-0.3.0}/tests/test_score.py +0 -0
  38. {simthinkd-0.2.0 → simthinkd-0.3.0}/tests/test_space.py +0 -0
  39. {simthinkd-0.2.0 → simthinkd-0.3.0}/tests/test_web_page.py +0 -0
  40. {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.2.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"><img src="assets/banner.png" alt="SimThink D: a tiny decision model that runs on one CPU core" width="100%"></p>
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"><a href="https://mssj-ai-org.github.io/simthinkd/"><b>Project page, live demo and video →</b></a></p>
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
- <div align="center">
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
- [Open in Colab](https://colab.research.google.com/github/MSSJ-AI-ORG/simthinkd/blob/main/notebooks/quickstart.ipynb) · [Try it in your browser](https://mssj-ai-org.github.io/simthinkd/#demo) · [Gradio demo](space/) · [Factory twin](examples/factory_twin/) · [Paper](docs/PAPER.md) · [Reproduce the paper](docs/REPRODUCE.md) · [Figures](docs/FIGURES.md) · [Protocol](docs/PROTOCOL.md)
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
- </div>
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
- ### Video: the internet goes down, the line keeps going
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
- <a href="assets/factory_fallback.mp4"><img src="assets/factory_fallback_poster.jpg" alt="Internet down. The line kept going." width="100%"></a>
56
+ ```bash
57
+ pip install simthinkd
58
+ ```
61
59
 
62
- A cloud decision service runs a simulated inspection line, with SimThink D on the factory PC as its backup. When an answer does not come back in time, or the network is cut, SimThink D makes the decision. [Watch the video](assets/factory_fallback.mp4) (72 s) or see it [on LinkedIn](https://www.linkedin.com/feed/update/urn:li:activity:7510142949981057024/). The simulator is in [examples/factory_twin](examples/factory_twin/).
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"><img src="assets/banner.png" alt="SimThink D: a tiny decision model that runs on one CPU core" width="100%"></p>
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"><a href="https://mssj-ai-org.github.io/simthinkd/"><b>Project page, live demo and video →</b></a></p>
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
- <div align="center">
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
- [Open in Colab](https://colab.research.google.com/github/MSSJ-AI-ORG/simthinkd/blob/main/notebooks/quickstart.ipynb) · [Try it in your browser](https://mssj-ai-org.github.io/simthinkd/#demo) · [Gradio demo](space/) · [Factory twin](examples/factory_twin/) · [Paper](docs/PAPER.md) · [Reproduce the paper](docs/REPRODUCE.md) · [Figures](docs/FIGURES.md) · [Protocol](docs/PROTOCOL.md)
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
- </div>
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
- ### Video: the internet goes down, the line keeps going
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
- <a href="assets/factory_fallback.mp4"><img src="assets/factory_fallback_poster.jpg" alt="Internet down. The line kept going." width="100%"></a>
26
+ ```bash
27
+ pip install simthinkd
28
+ ```
31
29
 
32
- A cloud decision service runs a simulated inspection line, with SimThink D on the factory PC as its backup. When an answer does not come back in time, or the network is cut, SimThink D makes the decision. [Watch the video](assets/factory_fallback.mp4) (72 s) or see it [on LinkedIn](https://www.linkedin.com/feed/update/urn:li:activity:7510142949981057024/). The simulator is in [examples/factory_twin](examples/factory_twin/).
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.2.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.2.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
- result = bench.run(a.states, a.url, a.decider, a.name, a.tick_hz, limit=a.limit)
50
- print(json.dumps(result) if a.json else bench.report(result))
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
- chosen, op, conditional, joint, rows, operations = policy.predict(body)
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
- x, groups, rows, operations = encode(body)
177
- op, conditional, joint = distributions(self.scores(x), groups, self.temperature)
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
- start = time.perf_counter()
41
- answers = decider.predict(body)
42
- infer_ms = (time.perf_counter() - start) * 1000
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 = ThreadingHTTPServer((host, port), make_handler(decider, name, delay_ms))
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.2.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"><img src="assets/banner.png" alt="SimThink D: a tiny decision model that runs on one CPU core" width="100%"></p>
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"><a href="https://mssj-ai-org.github.io/simthinkd/"><b>Project page, live demo and video →</b></a></p>
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
- <div align="center">
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
- [Open in Colab](https://colab.research.google.com/github/MSSJ-AI-ORG/simthinkd/blob/main/notebooks/quickstart.ipynb) · [Try it in your browser](https://mssj-ai-org.github.io/simthinkd/#demo) · [Gradio demo](space/) · [Factory twin](examples/factory_twin/) · [Paper](docs/PAPER.md) · [Reproduce the paper](docs/REPRODUCE.md) · [Figures](docs/FIGURES.md) · [Protocol](docs/PROTOCOL.md)
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
- </div>
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
- ### Video: the internet goes down, the line keeps going
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
- <a href="assets/factory_fallback.mp4"><img src="assets/factory_fallback_poster.jpg" alt="Internet down. The line kept going." width="100%"></a>
56
+ ```bash
57
+ pip install simthinkd
58
+ ```
61
59
 
62
- A cloud decision service runs a simulated inspection line, with SimThink D on the factory PC as its backup. When an answer does not come back in time, or the network is cut, SimThink D makes the decision. [Watch the video](assets/factory_fallback.mp4) (72 s) or see it [on LinkedIn](https://www.linkedin.com/feed/update/urn:li:activity:7510142949981057024/). The simulator is in [examples/factory_twin](examples/factory_twin/).
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