simthinkd 0.2.1__tar.gz → 0.5.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 (50) hide show
  1. {simthinkd-0.2.1/src/simthinkd.egg-info → simthinkd-0.5.0}/PKG-INFO +102 -6
  2. {simthinkd-0.2.1 → simthinkd-0.5.0}/README.md +101 -5
  3. {simthinkd-0.2.1 → simthinkd-0.5.0}/pyproject.toml +1 -1
  4. simthinkd-0.5.0/src/simthinkd/__init__.py +36 -0
  5. simthinkd-0.5.0/src/simthinkd/adapters.py +178 -0
  6. simthinkd-0.5.0/src/simthinkd/bench.py +103 -0
  7. {simthinkd-0.2.1 → simthinkd-0.5.0}/src/simthinkd/cli.py +7 -2
  8. {simthinkd-0.2.1 → simthinkd-0.5.0}/src/simthinkd/core.py +72 -2
  9. simthinkd-0.5.0/src/simthinkd/dom_adapter.py +414 -0
  10. {simthinkd-0.2.1 → simthinkd-0.5.0}/src/simthinkd/policy.py +30 -2
  11. simthinkd-0.5.0/src/simthinkd/relearn.py +113 -0
  12. simthinkd-0.5.0/src/simthinkd/router.py +351 -0
  13. simthinkd-0.5.0/src/simthinkd/screen_actions.py +162 -0
  14. {simthinkd-0.2.1 → simthinkd-0.5.0}/src/simthinkd/server.py +11 -3
  15. {simthinkd-0.2.1 → simthinkd-0.5.0/src/simthinkd.egg-info}/PKG-INFO +102 -6
  16. {simthinkd-0.2.1 → simthinkd-0.5.0}/src/simthinkd.egg-info/SOURCES.txt +12 -0
  17. simthinkd-0.5.0/tests/test_dom_adapter.py +280 -0
  18. simthinkd-0.5.0/tests/test_java_parity.py +81 -0
  19. simthinkd-0.5.0/tests/test_parallel.py +194 -0
  20. simthinkd-0.5.0/tests/test_router.py +487 -0
  21. simthinkd-0.5.0/tests/test_router_example.py +67 -0
  22. simthinkd-0.5.0/tests/test_screen_actions.py +64 -0
  23. simthinkd-0.5.0/tests/test_server_concurrency.py +75 -0
  24. simthinkd-0.2.1/src/simthinkd/__init__.py +0 -22
  25. simthinkd-0.2.1/src/simthinkd/bench.py +0 -55
  26. {simthinkd-0.2.1 → simthinkd-0.5.0}/LICENSE +0 -0
  27. {simthinkd-0.2.1 → simthinkd-0.5.0}/NOTICE +0 -0
  28. {simthinkd-0.2.1 → simthinkd-0.5.0}/setup.cfg +0 -0
  29. {simthinkd-0.2.1 → simthinkd-0.5.0}/src/simthinkd/data/doom_defend_states.json +0 -0
  30. {simthinkd-0.2.1 → simthinkd-0.5.0}/src/simthinkd/integrations/__init__.py +0 -0
  31. {simthinkd-0.2.1 → simthinkd-0.5.0}/src/simthinkd/integrations/langchain_tool.py +0 -0
  32. {simthinkd-0.2.1 → simthinkd-0.5.0}/src/simthinkd/integrations/mcp_server.py +0 -0
  33. {simthinkd-0.2.1 → simthinkd-0.5.0}/src/simthinkd/score.py +0 -0
  34. {simthinkd-0.2.1 → simthinkd-0.5.0}/src/simthinkd/toy.py +0 -0
  35. {simthinkd-0.2.1 → simthinkd-0.5.0}/src/simthinkd/train.py +0 -0
  36. {simthinkd-0.2.1 → simthinkd-0.5.0}/src/simthinkd/weights/SHA256SUMS +0 -0
  37. {simthinkd-0.2.1 → simthinkd-0.5.0}/src/simthinkd/weights/doom-corridor.npz +0 -0
  38. {simthinkd-0.2.1 → simthinkd-0.5.0}/src/simthinkd/weights/doom-defend.npz +0 -0
  39. {simthinkd-0.2.1 → simthinkd-0.5.0}/src/simthinkd.egg-info/dependency_links.txt +0 -0
  40. {simthinkd-0.2.1 → simthinkd-0.5.0}/src/simthinkd.egg-info/entry_points.txt +0 -0
  41. {simthinkd-0.2.1 → simthinkd-0.5.0}/src/simthinkd.egg-info/requires.txt +0 -0
  42. {simthinkd-0.2.1 → simthinkd-0.5.0}/src/simthinkd.egg-info/top_level.txt +0 -0
  43. {simthinkd-0.2.1 → simthinkd-0.5.0}/tests/test_factory_twin.py +0 -0
  44. {simthinkd-0.2.1 → simthinkd-0.5.0}/tests/test_notebook.py +0 -0
  45. {simthinkd-0.2.1 → simthinkd-0.5.0}/tests/test_package.py +0 -0
  46. {simthinkd-0.2.1 → simthinkd-0.5.0}/tests/test_score.py +0 -0
  47. {simthinkd-0.2.1 → simthinkd-0.5.0}/tests/test_server_latency.py +0 -0
  48. {simthinkd-0.2.1 → simthinkd-0.5.0}/tests/test_space.py +0 -0
  49. {simthinkd-0.2.1 → simthinkd-0.5.0}/tests/test_web_page.py +0 -0
  50. {simthinkd-0.2.1 → simthinkd-0.5.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.1
3
+ Version: 0.5.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,6 +28,8 @@ 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"><b>English</b> | <a href="README.ko.md">한국어</a> | <a href="README.ja.md">日本語</a></p>
32
+
31
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">
@@ -36,7 +38,7 @@ Dynamic: license-file
36
38
  <img src="https://img.shields.io/badge/python-3.10%2B-blue" alt="Python 3.10+">
37
39
  <a href="https://github.com/MSSJ-AI-ORG/simthinkd/actions/workflows/test.yml"><img src="https://github.com/MSSJ-AI-ORG/simthinkd/actions/workflows/test.yml/badge.svg" alt="Tests"></a>
38
40
  <a href="https://colab.research.google.com/github/MSSJ-AI-ORG/simthinkd/blob/main/notebooks/quickstart.ipynb"><img src="https://colab.research.google.com/assets/colab-badge.svg" alt="Open in Colab"></a>
39
- <a href="https://doi.org/10.5281/zenodo.23111615"><img src="https://zenodo.org/badge/DOI/10.5281/zenodo.23111615.svg" alt="DOI"></a>
41
+ <a href="https://arxiv.org/abs/2610.04810"><img src="https://img.shields.io/badge/arXiv-2610.04810-b31b1b.svg" alt="arXiv:2610.04810"></a>
40
42
  </p>
41
43
 
42
44
  <p align="center"><b>Network down. Decisions stay local.</b></p>
@@ -47,9 +49,9 @@ It has 265,665 parameters and takes about 2 ms per decision on one CPU core.
47
49
  In the factory simulation, the internet was cut for 15 seconds.
48
50
  The local backup got 59 of 63 parts right, with no late decisions.
49
51
 
50
- <p align="center"><a href="assets/factory_fallback.mp4"><img src="assets/factory_fallback_poster.jpg" alt="Watch the factory simulation: local backup during a network outage" width="100%"></a></p>
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>
51
53
 
52
- <p align="center"><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>
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>
53
55
 
54
56
  ```bash
55
57
  pip install simthinkd
@@ -90,6 +92,23 @@ One call is one decision. A short sentence goes in. One action comes out, with i
90
92
 
91
93
  Two deciders come with the package: `doom-defend` (stand in the middle and fight) and `doom-corridor` (fight your way down a corridor).
92
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
+
93
112
  ## Train your own in seconds
94
113
 
95
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.
@@ -164,6 +183,83 @@ It is not a good fit for:
164
183
  - **Answers that are not in the line you give it.** If the answer depends on history the line does not contain, add that history to the line or use a different tool.
165
184
  - **Open-ended answers.** It picks from a list or returns one number. It does not write.
166
185
 
186
+ ## Route between a fast path and a large model
187
+
188
+ New in 0.4.0 (router kit). A `Router` puts SimThink D in front of a large model. The decider names the current state, a
189
+ skill table gives the scripted action for that state, and the router either does that action or hands the case over.
190
+
191
+ ```python
192
+ from simthinkd import Decider, Router, SkillTable, ScreenAdapter
193
+
194
+ skills = SkillTable({"LOGIN": ("FILL_LOGIN", ["PROFILE"]), # state: (action, expected next states)
195
+ "PROFILE": ("SUBMIT", ["ERROR", "DONE"]),
196
+ "ERROR": ("CLOSE", ["PROFILE"]),
197
+ "DONE": ("STOP", None, True)}) # final: the task ends here
198
+ router = Router(Decider("models/screen"), skills, tau=0.9, adapter=ScreenAdapter())
199
+ router.reset(expect=["LOGIN"])
200
+ route = router.route({"pixels": screenshot, "title": "Sign in", "prev": None})
201
+ if route.handoff:
202
+ action = ask_large_model(screenshot) # route.reason says why
203
+ else:
204
+ action = route.action # about 2 ms
205
+ ```
206
+
207
+ A route is a hand-over for one of three reasons:
208
+
209
+ | Reason | When |
210
+ |---|---|
211
+ | `low_confidence` | the decider's confidence is below `tau` |
212
+ | `unknown_state` | the state has no skill in this table |
213
+ | `no_progress` | the state is not one the last fast action was expected to lead to |
214
+
215
+ The third check matters most. A small model can be sure and wrong on a screen it has never seen. If its action then
216
+ leads somewhere unexpected, or changes nothing, the very next route hands the case over. `SkillTable.from_transitions`
217
+ learns the expected next states from runs of your teacher.
218
+
219
+ - `HandoffMonitor()` watches the last 10 routes. It fires when half of them were hand-overs, or when 3 were no-progress.
220
+ - `relearn(router, teacher, observations, base_examples)` asks your teacher (a large model or a person) about those
221
+ cases. It then retrains the decider, swaps it into the running router and appends one line to a log.
222
+ - Adapters turn different inputs into the decider's sentence: `ScreenAdapter` (a colour grid of a screenshot, the window
223
+ title and the previous action), `PipelineAdapter` (request fields and a gate verdict) and `VectorAdapter` (part of a
224
+ robot's observation vector). The decider stays the same; only the input changes.
225
+
226
+ [examples/router_screen](examples/router_screen/) runs all of this on a small synthetic screen task, with no network.
227
+ It includes a sealed preregistration and a template for your own experiments. In our sealed run, 12 of 12 deliberately
228
+ wrong actions were caught at the next screen. After a redesign, 101 teacher labels and 3.7 s of training brought the
229
+ hand-over rate from 41 of 58 routes back to 0 of 96. Details: [docs/ROUTER.md](docs/ROUTER.md).
230
+
231
+ ## Drive a screen
232
+
233
+ New in 0.5.0 (screen kit). Guards for putting the router on a real screen, where a wrong click costs time or cannot be
234
+ undone.
235
+
236
+ ```python
237
+ from simthinkd import NoClickBands, open_or_press, ExpectAfter, RuleFirst, PageTextCheck
238
+
239
+ bands = NoClickBands(top=96, bottom_from=700) # fixed header and footer of the site
240
+ kind, where = open_or_press(element, allow=lambda href: "/item/" in href)
241
+ if kind == "open":
242
+ browser.goto(where) # a link: open it, do not wait for a click
243
+ elif bands.allows(where):
244
+ browser.click(*where)
245
+ ok, state, ms = ExpectAfter(read_state, timeout=5.0).wait({"CART"})
246
+ if not ok:
247
+ action = ask_large_model(screenshot) # the step went nowhere: hand over
248
+ ```
249
+
250
+ - `NoClickBands` keeps clicks off fixed headers and footers that cover the page.
251
+ - `open_or_press` opens a link by its address instead of clicking a card and waiting for a new tab.
252
+ - `ExpectAfter` checks that the expected screen appears in time after an action.
253
+ - `RuleFirst` uses a rule where one is certain and asks the decider only where it is not.
254
+ - `PageTextCheck` decides success from the page text, not from a screenshot.
255
+ - `DomVerifier` and `PopupGuard` (also in this release) check the page structure before a confident fast action, and
256
+ never close a popup that asks for a decision.
257
+
258
+ On our synthetic HR ERP, a large model alone took 118.3 s per mission on average (14 large-model calls each); with
259
+ SimThink D in front it took 7.2 s with 0 calls (3 of 3 runs each, local headless Chrome). On a synthetic image studio, a large model alone took 126.6 s (14 calls each); with SimThink D in front it took 18.7 s with 1 call, for the credits popup, which asks for a decision and always goes to the large model (3 of 3 runs each, after the review fixes). An earlier sealed run took 24.3 s with 1 or 2 calls: a screen caught halfway through a change was sometimes handed over as well. In our first sealed run one of these missions failed; the cause was in the benchmark runner and is fixed (see CHANGELOG).
260
+ `benchmarks/screen` reruns both apps; a large model is plugged in through one command. Manual:
261
+ [docs/SCREEN.md](docs/SCREEN.md).
262
+
167
263
  ## Measure your own model
168
264
 
169
265
  Does your model fit inside one tick? `simthinkd bench` replays 1,050 recorded Doom states that ship with the package. It times every decision, one request at a time.
@@ -201,6 +297,7 @@ SimThink D only knows what its teacher knows. It does not reason, read long text
201
297
  | Any language, any engine | `simthinkd serve doom-defend --port 11890`, then POST the [decision request](docs/PROTOCOL.md) to `/v1/systemone` |
202
298
  | Unity / C# | [docs/INTEGRATION_UNITY.md](docs/INTEGRATION_UNITY.md): a client loop that keeps the game running while it waits |
203
299
  | Browser | [web/](web/): the same model in plain JavaScript, no server |
300
+ | 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 |
204
301
  | A factory line (simulator) | [examples/factory_twin/](examples/factory_twin/): an inspection conveyor with a 400 ms deadline per part |
205
302
  | Gradio | [space/](space/): a small web demo you can run locally or on Hugging Face Spaces |
206
303
  | MCP (Claude Desktop, Cursor and others) | `pip install "simthinkd[mcp]"`, then `python -m simthinkd.integrations.mcp_server` |
@@ -232,8 +329,7 @@ SimThink D only knows what its teacher knows. It does not reason, read long text
232
329
 
233
330
  If you use SimThink D, please cite it with [CITATION.cff](CITATION.cff). GitHub shows a "Cite this repository" button for it.
234
331
 
235
- - Software: [doi:10.5281/zenodo.23111615](https://doi.org/10.5281/zenodo.23111615)
236
- - Paper (preprint): Shin, Lee, Jeong and Kwon, "Separating Decision Time from Decision Quality in the Real-Time Gap of Distilled Deciders: Evidence from a Game and a Conveyor Simulator", [doi:10.5281/zenodo.23111659](https://doi.org/10.5281/zenodo.23111659)
332
+ - Paper: Shin, Lee, Jeong and Kwon, "Separating Decision Time from Decision Quality in the Real-Time Gap of Distilled Deciders: Evidence from a Game and a Conveyor Simulator", [arXiv:2610.04810](https://arxiv.org/abs/2610.04810)
237
333
 
238
334
  The two bundled deciders are the exact deciders evaluated in the paper (same SHA-256). [docs/REPRODUCE.md](docs/REPRODUCE.md) lists what you can rerun from this repository and what is not released yet.
239
335
 
@@ -1,3 +1,5 @@
1
+ <p align="center"><b>English</b> | <a href="README.ko.md">한국어</a> | <a href="README.ja.md">日本語</a></p>
2
+
1
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">
@@ -6,7 +8,7 @@
6
8
  <img src="https://img.shields.io/badge/python-3.10%2B-blue" alt="Python 3.10+">
7
9
  <a href="https://github.com/MSSJ-AI-ORG/simthinkd/actions/workflows/test.yml"><img src="https://github.com/MSSJ-AI-ORG/simthinkd/actions/workflows/test.yml/badge.svg" alt="Tests"></a>
8
10
  <a href="https://colab.research.google.com/github/MSSJ-AI-ORG/simthinkd/blob/main/notebooks/quickstart.ipynb"><img src="https://colab.research.google.com/assets/colab-badge.svg" alt="Open in Colab"></a>
9
- <a href="https://doi.org/10.5281/zenodo.23111615"><img src="https://zenodo.org/badge/DOI/10.5281/zenodo.23111615.svg" alt="DOI"></a>
11
+ <a href="https://arxiv.org/abs/2610.04810"><img src="https://img.shields.io/badge/arXiv-2610.04810-b31b1b.svg" alt="arXiv:2610.04810"></a>
10
12
  </p>
11
13
 
12
14
  <p align="center"><b>Network down. Decisions stay local.</b></p>
@@ -17,9 +19,9 @@ It has 265,665 parameters and takes about 2 ms per decision on one CPU core.
17
19
  In the factory simulation, the internet was cut for 15 seconds.
18
20
  The local backup got 59 of 63 parts right, with no late decisions.
19
21
 
20
- <p align="center"><a href="assets/factory_fallback.mp4"><img src="assets/factory_fallback_poster.jpg" alt="Watch the factory simulation: local backup during a network outage" width="100%"></a></p>
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>
21
23
 
22
- <p align="center"><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>
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>
23
25
 
24
26
  ```bash
25
27
  pip install simthinkd
@@ -60,6 +62,23 @@ One call is one decision. A short sentence goes in. One action comes out, with i
60
62
 
61
63
  Two deciders come with the package: `doom-defend` (stand in the middle and fight) and `doom-corridor` (fight your way down a corridor).
62
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
+
63
82
  ## Train your own in seconds
64
83
 
65
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.
@@ -134,6 +153,83 @@ It is not a good fit for:
134
153
  - **Answers that are not in the line you give it.** If the answer depends on history the line does not contain, add that history to the line or use a different tool.
135
154
  - **Open-ended answers.** It picks from a list or returns one number. It does not write.
136
155
 
156
+ ## Route between a fast path and a large model
157
+
158
+ New in 0.4.0 (router kit). A `Router` puts SimThink D in front of a large model. The decider names the current state, a
159
+ skill table gives the scripted action for that state, and the router either does that action or hands the case over.
160
+
161
+ ```python
162
+ from simthinkd import Decider, Router, SkillTable, ScreenAdapter
163
+
164
+ skills = SkillTable({"LOGIN": ("FILL_LOGIN", ["PROFILE"]), # state: (action, expected next states)
165
+ "PROFILE": ("SUBMIT", ["ERROR", "DONE"]),
166
+ "ERROR": ("CLOSE", ["PROFILE"]),
167
+ "DONE": ("STOP", None, True)}) # final: the task ends here
168
+ router = Router(Decider("models/screen"), skills, tau=0.9, adapter=ScreenAdapter())
169
+ router.reset(expect=["LOGIN"])
170
+ route = router.route({"pixels": screenshot, "title": "Sign in", "prev": None})
171
+ if route.handoff:
172
+ action = ask_large_model(screenshot) # route.reason says why
173
+ else:
174
+ action = route.action # about 2 ms
175
+ ```
176
+
177
+ A route is a hand-over for one of three reasons:
178
+
179
+ | Reason | When |
180
+ |---|---|
181
+ | `low_confidence` | the decider's confidence is below `tau` |
182
+ | `unknown_state` | the state has no skill in this table |
183
+ | `no_progress` | the state is not one the last fast action was expected to lead to |
184
+
185
+ The third check matters most. A small model can be sure and wrong on a screen it has never seen. If its action then
186
+ leads somewhere unexpected, or changes nothing, the very next route hands the case over. `SkillTable.from_transitions`
187
+ learns the expected next states from runs of your teacher.
188
+
189
+ - `HandoffMonitor()` watches the last 10 routes. It fires when half of them were hand-overs, or when 3 were no-progress.
190
+ - `relearn(router, teacher, observations, base_examples)` asks your teacher (a large model or a person) about those
191
+ cases. It then retrains the decider, swaps it into the running router and appends one line to a log.
192
+ - Adapters turn different inputs into the decider's sentence: `ScreenAdapter` (a colour grid of a screenshot, the window
193
+ title and the previous action), `PipelineAdapter` (request fields and a gate verdict) and `VectorAdapter` (part of a
194
+ robot's observation vector). The decider stays the same; only the input changes.
195
+
196
+ [examples/router_screen](examples/router_screen/) runs all of this on a small synthetic screen task, with no network.
197
+ It includes a sealed preregistration and a template for your own experiments. In our sealed run, 12 of 12 deliberately
198
+ wrong actions were caught at the next screen. After a redesign, 101 teacher labels and 3.7 s of training brought the
199
+ hand-over rate from 41 of 58 routes back to 0 of 96. Details: [docs/ROUTER.md](docs/ROUTER.md).
200
+
201
+ ## Drive a screen
202
+
203
+ New in 0.5.0 (screen kit). Guards for putting the router on a real screen, where a wrong click costs time or cannot be
204
+ undone.
205
+
206
+ ```python
207
+ from simthinkd import NoClickBands, open_or_press, ExpectAfter, RuleFirst, PageTextCheck
208
+
209
+ bands = NoClickBands(top=96, bottom_from=700) # fixed header and footer of the site
210
+ kind, where = open_or_press(element, allow=lambda href: "/item/" in href)
211
+ if kind == "open":
212
+ browser.goto(where) # a link: open it, do not wait for a click
213
+ elif bands.allows(where):
214
+ browser.click(*where)
215
+ ok, state, ms = ExpectAfter(read_state, timeout=5.0).wait({"CART"})
216
+ if not ok:
217
+ action = ask_large_model(screenshot) # the step went nowhere: hand over
218
+ ```
219
+
220
+ - `NoClickBands` keeps clicks off fixed headers and footers that cover the page.
221
+ - `open_or_press` opens a link by its address instead of clicking a card and waiting for a new tab.
222
+ - `ExpectAfter` checks that the expected screen appears in time after an action.
223
+ - `RuleFirst` uses a rule where one is certain and asks the decider only where it is not.
224
+ - `PageTextCheck` decides success from the page text, not from a screenshot.
225
+ - `DomVerifier` and `PopupGuard` (also in this release) check the page structure before a confident fast action, and
226
+ never close a popup that asks for a decision.
227
+
228
+ On our synthetic HR ERP, a large model alone took 118.3 s per mission on average (14 large-model calls each); with
229
+ SimThink D in front it took 7.2 s with 0 calls (3 of 3 runs each, local headless Chrome). On a synthetic image studio, a large model alone took 126.6 s (14 calls each); with SimThink D in front it took 18.7 s with 1 call, for the credits popup, which asks for a decision and always goes to the large model (3 of 3 runs each, after the review fixes). An earlier sealed run took 24.3 s with 1 or 2 calls: a screen caught halfway through a change was sometimes handed over as well. In our first sealed run one of these missions failed; the cause was in the benchmark runner and is fixed (see CHANGELOG).
230
+ `benchmarks/screen` reruns both apps; a large model is plugged in through one command. Manual:
231
+ [docs/SCREEN.md](docs/SCREEN.md).
232
+
137
233
  ## Measure your own model
138
234
 
139
235
  Does your model fit inside one tick? `simthinkd bench` replays 1,050 recorded Doom states that ship with the package. It times every decision, one request at a time.
@@ -171,6 +267,7 @@ SimThink D only knows what its teacher knows. It does not reason, read long text
171
267
  | Any language, any engine | `simthinkd serve doom-defend --port 11890`, then POST the [decision request](docs/PROTOCOL.md) to `/v1/systemone` |
172
268
  | Unity / C# | [docs/INTEGRATION_UNITY.md](docs/INTEGRATION_UNITY.md): a client loop that keeps the game running while it waits |
173
269
  | Browser | [web/](web/): the same model in plain JavaScript, no server |
270
+ | 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 |
174
271
  | A factory line (simulator) | [examples/factory_twin/](examples/factory_twin/): an inspection conveyor with a 400 ms deadline per part |
175
272
  | Gradio | [space/](space/): a small web demo you can run locally or on Hugging Face Spaces |
176
273
  | MCP (Claude Desktop, Cursor and others) | `pip install "simthinkd[mcp]"`, then `python -m simthinkd.integrations.mcp_server` |
@@ -202,8 +299,7 @@ SimThink D only knows what its teacher knows. It does not reason, read long text
202
299
 
203
300
  If you use SimThink D, please cite it with [CITATION.cff](CITATION.cff). GitHub shows a "Cite this repository" button for it.
204
301
 
205
- - Software: [doi:10.5281/zenodo.23111615](https://doi.org/10.5281/zenodo.23111615)
206
- - Paper (preprint): Shin, Lee, Jeong and Kwon, "Separating Decision Time from Decision Quality in the Real-Time Gap of Distilled Deciders: Evidence from a Game and a Conveyor Simulator", [doi:10.5281/zenodo.23111659](https://doi.org/10.5281/zenodo.23111659)
302
+ - Paper: Shin, Lee, Jeong and Kwon, "Separating Decision Time from Decision Quality in the Real-Time Gap of Distilled Deciders: Evidence from a Game and a Conveyor Simulator", [arXiv:2610.04810](https://arxiv.org/abs/2610.04810)
207
303
 
208
304
  The two bundled deciders are the exact deciders evaluated in the paper (same SHA-256). [docs/REPRODUCE.md](docs/REPRODUCE.md) lists what you can rerun from this repository and what is not released yet.
209
305
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "simthinkd"
7
- version = "0.2.1"
7
+ version = "0.5.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"
@@ -0,0 +1,36 @@
1
+ """SimThink D: a small decision model that picks one action in about 2 ms on one CPU core.
2
+
3
+ from simthinkd import Decider
4
+ print(Decider("doom-defend").decide("seen: Demon left a30 d5 | enemies 1 | sway left | gun ready | ammo25"))
5
+
6
+ Router kit (0.4.0): Router + SkillTable hand a case over to a large model when the decider is unsure, the state is
7
+ unknown, or the last fast action did not lead to an expected next state; relearn() retrains and hot-swaps.
8
+ DOM input: DomAdapter, CombinedAdapter, DomVerifier + VerifiedRouter (capture decides, DOM verifies), PopupGuard.
9
+ Screen actions (0.5.0): NoClickBands, ExpectAfter, open_or_press, RuleFirst, PageTextCheck. See docs/SCREEN.md.
10
+ """
11
+ from .adapters import PipelineAdapter, ScreenAdapter, VectorAdapter
12
+ from .dom_adapter import CombinedAdapter, DomAdapter, DomVerifier, PopupGuard, VerifiedRouter
13
+ from .screen_actions import ExpectAfter, NoClickBands, PageTextCheck, RuleFirst, RuleRoute, open_or_press
14
+ from .core import Decider, Decision, available, build_request
15
+ from .relearn import RelearnReport, relearn
16
+ from .router import HandoffMonitor, Route, Router, Skill, SkillTable
17
+ from .score import Score, Scorer
18
+
19
+ __version__ = '0.5.0'
20
+ __all__ = ['Decider', 'Decision', 'Score', 'Scorer', 'available', 'build_request', 'fit', 'fit_score', '__version__',
21
+ 'Router', 'Route', 'Skill', 'SkillTable', 'HandoffMonitor', 'relearn', 'RelearnReport',
22
+ 'ScreenAdapter', 'PipelineAdapter', 'VectorAdapter',
23
+ 'DomAdapter', 'CombinedAdapter', 'DomVerifier', 'VerifiedRouter', 'PopupGuard',
24
+ 'NoClickBands', 'ExpectAfter', 'open_or_press', 'RuleFirst', 'RuleRoute', 'PageTextCheck']
25
+
26
+
27
+ def fit(*args, **kwargs):
28
+ """Train a decider from (situation, action) pairs. See simthinkd.train.fit. Needs simthinkd[train]."""
29
+ from .train import fit as _fit
30
+ return _fit(*args, **kwargs)
31
+
32
+
33
+ def fit_score(*args, **kwargs):
34
+ """Train a score model from (situation, number) pairs. See simthinkd.score.fit_score. Needs simthinkd[train]."""
35
+ from .score import fit_score as _fit_score
36
+ return _fit_score(*args, **kwargs)
@@ -0,0 +1,178 @@
1
+ """Feature adapters: turn one kind of observation into the short sentence a decider reads. Same decider, different input.
2
+
3
+ ScreenAdapter() screenshot pixels (+ window title, previous action) -> 'title: Sign in | prev: START | screen: g00w ...'
4
+ PipelineAdapter() pipeline step (request meta, gate verdict) -> 'step: draft | request: kind proposal ; pages 12 | gate: lint FAIL | reasons: ...'
5
+ VectorAdapter([...]) part of a robot observation vector -> 'obs: base_vx q5 | yaw_rate q3'
6
+
7
+ Each adapter is a plain callable, so `Router(decider, skills, adapter=ScreenAdapter())` routes screenshots directly.
8
+ Adapters are deterministic and need only NumPy; train the decider on sentences made by the same adapter.
9
+ """
10
+ import json
11
+ import math
12
+
13
+ import numpy as np
14
+
15
+ SATURATED = 'yorgbn'
16
+
17
+
18
+ def color_class(r, g, b):
19
+ """One colour letter for an RGB value: w l m d (white to dark grey), y o r g b n (yellow orange red green blue other)."""
20
+ mx, mn = max(r, g, b), min(r, g, b)
21
+ if mx - mn < 28:
22
+ return 'w' if mx > 235 else 'l' if mx > 200 else 'm' if mx > 110 else 'd'
23
+ if r > 150 and g > 150 and b < 120:
24
+ return 'y'
25
+ if r >= g and r >= b:
26
+ return 'o' if g > 90 else 'r'
27
+ if g >= r and g >= b:
28
+ return 'g'
29
+ return 'b' if b - r > 40 else 'n'
30
+
31
+
32
+ def _box(pixels, height, width):
33
+ """Area-average resize to (height, width), rounded to whole values. Exact block means when the sizes divide evenly."""
34
+ h, w = pixels.shape[:2]
35
+ if h < height or w < width:
36
+ raise ValueError(f'the screenshot ({w}x{h}) is smaller than the grid needs ({width}x{height})')
37
+ if h % height == 0 and w % width == 0:
38
+ out = pixels.reshape(height, h // height, width, w // width, 3).mean(axis=(1, 3))
39
+ else:
40
+ rows = np.linspace(0, h, height + 1).round().astype(int)
41
+ cols = np.linspace(0, w, width + 1).round().astype(int)
42
+ out = np.array([[pixels[rows[i]:rows[i + 1], cols[j]:cols[j + 1]].reshape(-1, 3).mean(0)
43
+ for j in range(width)] for i in range(height)])
44
+ return np.floor(out + 0.5).astype(np.int64)
45
+
46
+
47
+ def screen_tokens(pixels, cols=8, rows=6):
48
+ """Colour-grid tokens of a screenshot (the feature of experiment v2.1).
49
+
50
+ The image is box-resized to (8*cols) x (8*rows); each 8x8 cell gives a token g<row><col><mean colour class>, plus
51
+ a<row><col><colour> when at least 4 of its 64 pixels have a saturated colour (the most frequent one; a tie goes to the
52
+ colour seen first in the cell). pixels: an H x W x 3 array of 0-255 values (RGB), at least that many pixels."""
53
+ if not (1 <= cols <= 10 and 1 <= rows <= 10):
54
+ raise ValueError('cols and rows must be between 1 and 10')
55
+ px = np.asarray(pixels)
56
+ if px.ndim != 3 or px.shape[2] < 3:
57
+ raise ValueError('pixels must be an H x W x 3 RGB array')
58
+ small = _box(px[:, :, :3].astype(np.float64), rows * 8, cols * 8)
59
+ tokens = []
60
+ for cy in range(rows):
61
+ for cx in range(cols):
62
+ cell = small[cy * 8:cy * 8 + 8, cx * 8:cx * 8 + 8].reshape(-1, 3)
63
+ mean = cell.sum(0) // 64
64
+ tokens.append(f'g{cy}{cx}{color_class(*(int(v) for v in mean))}')
65
+ sat = [c for c in (color_class(*(int(v) for v in p)) for p in cell) if c in SATURATED]
66
+ if len(sat) >= 4:
67
+ top = max(dict.fromkeys(sat), key=sat.count) # dict keeps first-seen order: deterministic ties
68
+ tokens.append(f'a{cy}{cx}{top}')
69
+ return ' '.join(tokens)
70
+
71
+
72
+ class ScreenAdapter:
73
+ """Screen observation -> sentence. An observation is a dict {'pixels', 'title', 'prev'} or the pixel array alone.
74
+
75
+ with_title / with_prev False gives the pixel-only sentence 'screen: ...' (the router_visual model of v2.1)."""
76
+
77
+ def __init__(self, cols=8, rows=6, with_title=True, with_prev=True):
78
+ if not (1 <= cols <= 10 and 1 <= rows <= 10):
79
+ raise ValueError('cols and rows must be between 1 and 10')
80
+ self.cols, self.rows, self.with_title, self.with_prev = cols, rows, with_title, with_prev
81
+
82
+ def __call__(self, observation):
83
+ if isinstance(observation, dict):
84
+ if 'pixels' not in observation:
85
+ raise ValueError("a screen observation needs 'pixels'")
86
+ pixels, title, prev = observation['pixels'], observation.get('title', ''), observation.get('prev')
87
+ else:
88
+ pixels, title, prev = observation, '', None
89
+ parts = []
90
+ if self.with_title:
91
+ parts.append(f'title: {title or ""}')
92
+ if self.with_prev:
93
+ parts.append(f'prev: {prev or "START"}')
94
+ parts.append('screen: ' + screen_tokens(pixels, self.cols, self.rows))
95
+ return ' | '.join(parts)
96
+
97
+
98
+ def _value(v):
99
+ if isinstance(v, bool):
100
+ return 'yes' if v else 'no'
101
+ if isinstance(v, (dict, list, tuple)):
102
+ return json.dumps(v, ensure_ascii=False, sort_keys=True, separators=(',', ':'))
103
+ return ' '.join(str(v).split()) or '-'
104
+
105
+
106
+ class PipelineAdapter:
107
+ """Pipeline step -> sentence, for routing inside a fixed pipeline (which step next, which gate failure, which model).
108
+
109
+ An observation is a dict {'step': name, 'request': {meta}, 'gate': {'name', 'verdict', 'reasons'}}; every part is
110
+ optional but the observation must be a dict. fields: the request keys to keep, in this order (None: all, sorted)."""
111
+
112
+ def __init__(self, fields=None, max_reasons=5):
113
+ if fields is not None and (isinstance(fields, str) or not all(isinstance(f, str) and f for f in fields)):
114
+ raise ValueError('fields must be a list of request keys')
115
+ if not isinstance(max_reasons, int) or max_reasons < 0:
116
+ raise ValueError('max_reasons must be a whole number >= 0')
117
+ self.fields, self.max_reasons = (list(fields) if fields is not None else None), max_reasons
118
+
119
+ def __call__(self, observation):
120
+ if not isinstance(observation, dict):
121
+ raise ValueError('a pipeline observation is a dict with step, request and gate')
122
+ request = observation.get('request') or {}
123
+ if not isinstance(request, dict):
124
+ raise ValueError('request must be a dict of meta fields')
125
+ keys = self.fields if self.fields is not None else sorted(request)
126
+ meta = ' ; '.join(f'{k} {_value(request[k])}' for k in keys if k in request) or 'none'
127
+ parts = [f'step: {_value(observation.get("step") or "-")}', f'request: {meta}']
128
+ gate = observation.get('gate')
129
+ if gate is None:
130
+ parts.append('gate: none')
131
+ else:
132
+ if not isinstance(gate, dict):
133
+ raise ValueError('gate must be a dict with name, verdict and reasons')
134
+ parts.append(f'gate: {_value(gate.get("name") or "-")} {_value(gate.get("verdict") or "-").upper()}')
135
+ reasons = [_value(r) for r in (gate.get('reasons') or [])][:self.max_reasons]
136
+ if reasons:
137
+ parts.append('reasons: ' + ' ; '.join(reasons))
138
+ return ' | '.join(parts)
139
+
140
+
141
+ class VectorAdapter:
142
+ """Part of a numeric observation vector (e.g. a robot's 61 observations) -> sentence of quantised levels.
143
+
144
+ indices: which entries to use; names: one word per entry (default x<index>); low/high: the range per entry (a number
145
+ or one per entry), clipped; levels: how many bins (2-64). Entry 0.42 in [-1, 1] with 8 levels becomes 'q5'."""
146
+
147
+ def __init__(self, indices, names=None, low=-1.0, high=1.0, levels=8, prefix='obs'):
148
+ self.indices = [int(i) for i in indices]
149
+ if not self.indices:
150
+ raise ValueError('choose at least one index')
151
+ if any(i < 0 for i in self.indices) or len(set(self.indices)) != len(self.indices):
152
+ raise ValueError('indices must be distinct and >= 0')
153
+ self.names = list(names) if names is not None else [f'x{i}' for i in self.indices]
154
+ if len(self.names) != len(self.indices) or not all(isinstance(n, str) and n and ' ' not in n for n in self.names):
155
+ raise ValueError('give one name (one word) per index')
156
+ n = len(self.indices)
157
+ self.low = np.broadcast_to(np.asarray(low, np.float64), (n,)).copy()
158
+ self.high = np.broadcast_to(np.asarray(high, np.float64), (n,)).copy()
159
+ if not np.all(np.isfinite(self.low)) or not np.all(np.isfinite(self.high)) or np.any(self.high <= self.low):
160
+ raise ValueError('every range needs finite low < high')
161
+ if not isinstance(levels, int) or not 2 <= levels <= 64:
162
+ raise ValueError('levels must be a whole number from 2 to 64')
163
+ self.levels, self.prefix = levels, prefix
164
+
165
+ def level(self, k, value):
166
+ """Bin of one value for entry k (0 .. levels-1)."""
167
+ span = (min(max(value, self.low[k]), self.high[k]) - self.low[k]) / (self.high[k] - self.low[k])
168
+ return min(int(math.floor(span * self.levels)), self.levels - 1)
169
+
170
+ def __call__(self, observation):
171
+ vector = np.asarray(observation, np.float64).reshape(-1)
172
+ if max(self.indices) >= len(vector):
173
+ raise ValueError(f'the observation has {len(vector)} entries; index {max(self.indices)} is out of range')
174
+ values = vector[self.indices]
175
+ if not np.all(np.isfinite(values)):
176
+ raise ValueError('the observation has a missing or infinite value in the chosen entries')
177
+ body = ' | '.join(f'{name} q{self.level(k, v)}' for k, (name, v) in enumerate(zip(self.names, values)))
178
+ return f'{self.prefix}: {body}'