simthinkd 0.3.0__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.
- {simthinkd-0.3.0/src/simthinkd.egg-info → simthinkd-0.5.0}/PKG-INFO +80 -4
- {simthinkd-0.3.0 → simthinkd-0.5.0}/README.md +79 -3
- {simthinkd-0.3.0 → simthinkd-0.5.0}/pyproject.toml +1 -1
- simthinkd-0.5.0/src/simthinkd/__init__.py +36 -0
- simthinkd-0.5.0/src/simthinkd/adapters.py +178 -0
- simthinkd-0.5.0/src/simthinkd/dom_adapter.py +414 -0
- simthinkd-0.5.0/src/simthinkd/relearn.py +113 -0
- simthinkd-0.5.0/src/simthinkd/router.py +351 -0
- simthinkd-0.5.0/src/simthinkd/screen_actions.py +162 -0
- {simthinkd-0.3.0 → simthinkd-0.5.0/src/simthinkd.egg-info}/PKG-INFO +80 -4
- {simthinkd-0.3.0 → simthinkd-0.5.0}/src/simthinkd.egg-info/SOURCES.txt +9 -0
- simthinkd-0.5.0/tests/test_dom_adapter.py +280 -0
- simthinkd-0.5.0/tests/test_router.py +487 -0
- simthinkd-0.5.0/tests/test_router_example.py +67 -0
- simthinkd-0.5.0/tests/test_screen_actions.py +64 -0
- simthinkd-0.3.0/src/simthinkd/__init__.py +0 -22
- {simthinkd-0.3.0 → simthinkd-0.5.0}/LICENSE +0 -0
- {simthinkd-0.3.0 → simthinkd-0.5.0}/NOTICE +0 -0
- {simthinkd-0.3.0 → simthinkd-0.5.0}/setup.cfg +0 -0
- {simthinkd-0.3.0 → simthinkd-0.5.0}/src/simthinkd/bench.py +0 -0
- {simthinkd-0.3.0 → simthinkd-0.5.0}/src/simthinkd/cli.py +0 -0
- {simthinkd-0.3.0 → simthinkd-0.5.0}/src/simthinkd/core.py +0 -0
- {simthinkd-0.3.0 → simthinkd-0.5.0}/src/simthinkd/data/doom_defend_states.json +0 -0
- {simthinkd-0.3.0 → simthinkd-0.5.0}/src/simthinkd/integrations/__init__.py +0 -0
- {simthinkd-0.3.0 → simthinkd-0.5.0}/src/simthinkd/integrations/langchain_tool.py +0 -0
- {simthinkd-0.3.0 → simthinkd-0.5.0}/src/simthinkd/integrations/mcp_server.py +0 -0
- {simthinkd-0.3.0 → simthinkd-0.5.0}/src/simthinkd/policy.py +0 -0
- {simthinkd-0.3.0 → simthinkd-0.5.0}/src/simthinkd/score.py +0 -0
- {simthinkd-0.3.0 → simthinkd-0.5.0}/src/simthinkd/server.py +0 -0
- {simthinkd-0.3.0 → simthinkd-0.5.0}/src/simthinkd/toy.py +0 -0
- {simthinkd-0.3.0 → simthinkd-0.5.0}/src/simthinkd/train.py +0 -0
- {simthinkd-0.3.0 → simthinkd-0.5.0}/src/simthinkd/weights/SHA256SUMS +0 -0
- {simthinkd-0.3.0 → simthinkd-0.5.0}/src/simthinkd/weights/doom-corridor.npz +0 -0
- {simthinkd-0.3.0 → simthinkd-0.5.0}/src/simthinkd/weights/doom-defend.npz +0 -0
- {simthinkd-0.3.0 → simthinkd-0.5.0}/src/simthinkd.egg-info/dependency_links.txt +0 -0
- {simthinkd-0.3.0 → simthinkd-0.5.0}/src/simthinkd.egg-info/entry_points.txt +0 -0
- {simthinkd-0.3.0 → simthinkd-0.5.0}/src/simthinkd.egg-info/requires.txt +0 -0
- {simthinkd-0.3.0 → simthinkd-0.5.0}/src/simthinkd.egg-info/top_level.txt +0 -0
- {simthinkd-0.3.0 → simthinkd-0.5.0}/tests/test_factory_twin.py +0 -0
- {simthinkd-0.3.0 → simthinkd-0.5.0}/tests/test_java_parity.py +0 -0
- {simthinkd-0.3.0 → simthinkd-0.5.0}/tests/test_notebook.py +0 -0
- {simthinkd-0.3.0 → simthinkd-0.5.0}/tests/test_package.py +0 -0
- {simthinkd-0.3.0 → simthinkd-0.5.0}/tests/test_parallel.py +0 -0
- {simthinkd-0.3.0 → simthinkd-0.5.0}/tests/test_score.py +0 -0
- {simthinkd-0.3.0 → simthinkd-0.5.0}/tests/test_server_concurrency.py +0 -0
- {simthinkd-0.3.0 → simthinkd-0.5.0}/tests/test_server_latency.py +0 -0
- {simthinkd-0.3.0 → simthinkd-0.5.0}/tests/test_space.py +0 -0
- {simthinkd-0.3.0 → simthinkd-0.5.0}/tests/test_web_page.py +0 -0
- {simthinkd-0.3.0 → 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.
|
|
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
|
|
@@ -38,7 +38,7 @@ Dynamic: license-file
|
|
|
38
38
|
<img src="https://img.shields.io/badge/python-3.10%2B-blue" alt="Python 3.10+">
|
|
39
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>
|
|
40
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>
|
|
41
|
-
<a href="https://
|
|
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>
|
|
42
42
|
</p>
|
|
43
43
|
|
|
44
44
|
<p align="center"><b>Network down. Decisions stay local.</b></p>
|
|
@@ -183,6 +183,83 @@ It is not a good fit for:
|
|
|
183
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.
|
|
184
184
|
- **Open-ended answers.** It picks from a list or returns one number. It does not write.
|
|
185
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
|
+
|
|
186
263
|
## Measure your own model
|
|
187
264
|
|
|
188
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.
|
|
@@ -252,8 +329,7 @@ SimThink D only knows what its teacher knows. It does not reason, read long text
|
|
|
252
329
|
|
|
253
330
|
If you use SimThink D, please cite it with [CITATION.cff](CITATION.cff). GitHub shows a "Cite this repository" button for it.
|
|
254
331
|
|
|
255
|
-
-
|
|
256
|
-
- 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)
|
|
257
333
|
|
|
258
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.
|
|
259
335
|
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
<img src="https://img.shields.io/badge/python-3.10%2B-blue" alt="Python 3.10+">
|
|
9
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>
|
|
10
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>
|
|
11
|
-
<a href="https://
|
|
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>
|
|
12
12
|
</p>
|
|
13
13
|
|
|
14
14
|
<p align="center"><b>Network down. Decisions stay local.</b></p>
|
|
@@ -153,6 +153,83 @@ It is not a good fit for:
|
|
|
153
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.
|
|
154
154
|
- **Open-ended answers.** It picks from a list or returns one number. It does not write.
|
|
155
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
|
+
|
|
156
233
|
## Measure your own model
|
|
157
234
|
|
|
158
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.
|
|
@@ -222,8 +299,7 @@ SimThink D only knows what its teacher knows. It does not reason, read long text
|
|
|
222
299
|
|
|
223
300
|
If you use SimThink D, please cite it with [CITATION.cff](CITATION.cff). GitHub shows a "Cite this repository" button for it.
|
|
224
301
|
|
|
225
|
-
-
|
|
226
|
-
- 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)
|
|
227
303
|
|
|
228
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.
|
|
229
305
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "simthinkd"
|
|
7
|
-
version = "0.
|
|
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}'
|