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.
- {simthinkd-0.2.1/src/simthinkd.egg-info → simthinkd-0.5.0}/PKG-INFO +102 -6
- {simthinkd-0.2.1 → simthinkd-0.5.0}/README.md +101 -5
- {simthinkd-0.2.1 → 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/bench.py +103 -0
- {simthinkd-0.2.1 → simthinkd-0.5.0}/src/simthinkd/cli.py +7 -2
- {simthinkd-0.2.1 → simthinkd-0.5.0}/src/simthinkd/core.py +72 -2
- simthinkd-0.5.0/src/simthinkd/dom_adapter.py +414 -0
- {simthinkd-0.2.1 → simthinkd-0.5.0}/src/simthinkd/policy.py +30 -2
- 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.2.1 → simthinkd-0.5.0}/src/simthinkd/server.py +11 -3
- {simthinkd-0.2.1 → simthinkd-0.5.0/src/simthinkd.egg-info}/PKG-INFO +102 -6
- {simthinkd-0.2.1 → simthinkd-0.5.0}/src/simthinkd.egg-info/SOURCES.txt +12 -0
- simthinkd-0.5.0/tests/test_dom_adapter.py +280 -0
- simthinkd-0.5.0/tests/test_java_parity.py +81 -0
- simthinkd-0.5.0/tests/test_parallel.py +194 -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.5.0/tests/test_server_concurrency.py +75 -0
- simthinkd-0.2.1/src/simthinkd/__init__.py +0 -22
- simthinkd-0.2.1/src/simthinkd/bench.py +0 -55
- {simthinkd-0.2.1 → simthinkd-0.5.0}/LICENSE +0 -0
- {simthinkd-0.2.1 → simthinkd-0.5.0}/NOTICE +0 -0
- {simthinkd-0.2.1 → simthinkd-0.5.0}/setup.cfg +0 -0
- {simthinkd-0.2.1 → simthinkd-0.5.0}/src/simthinkd/data/doom_defend_states.json +0 -0
- {simthinkd-0.2.1 → simthinkd-0.5.0}/src/simthinkd/integrations/__init__.py +0 -0
- {simthinkd-0.2.1 → simthinkd-0.5.0}/src/simthinkd/integrations/langchain_tool.py +0 -0
- {simthinkd-0.2.1 → simthinkd-0.5.0}/src/simthinkd/integrations/mcp_server.py +0 -0
- {simthinkd-0.2.1 → simthinkd-0.5.0}/src/simthinkd/score.py +0 -0
- {simthinkd-0.2.1 → simthinkd-0.5.0}/src/simthinkd/toy.py +0 -0
- {simthinkd-0.2.1 → simthinkd-0.5.0}/src/simthinkd/train.py +0 -0
- {simthinkd-0.2.1 → simthinkd-0.5.0}/src/simthinkd/weights/SHA256SUMS +0 -0
- {simthinkd-0.2.1 → simthinkd-0.5.0}/src/simthinkd/weights/doom-corridor.npz +0 -0
- {simthinkd-0.2.1 → simthinkd-0.5.0}/src/simthinkd/weights/doom-defend.npz +0 -0
- {simthinkd-0.2.1 → simthinkd-0.5.0}/src/simthinkd.egg-info/dependency_links.txt +0 -0
- {simthinkd-0.2.1 → simthinkd-0.5.0}/src/simthinkd.egg-info/entry_points.txt +0 -0
- {simthinkd-0.2.1 → simthinkd-0.5.0}/src/simthinkd.egg-info/requires.txt +0 -0
- {simthinkd-0.2.1 → simthinkd-0.5.0}/src/simthinkd.egg-info/top_level.txt +0 -0
- {simthinkd-0.2.1 → simthinkd-0.5.0}/tests/test_factory_twin.py +0 -0
- {simthinkd-0.2.1 → simthinkd-0.5.0}/tests/test_notebook.py +0 -0
- {simthinkd-0.2.1 → simthinkd-0.5.0}/tests/test_package.py +0 -0
- {simthinkd-0.2.1 → simthinkd-0.5.0}/tests/test_score.py +0 -0
- {simthinkd-0.2.1 → simthinkd-0.5.0}/tests/test_server_latency.py +0 -0
- {simthinkd-0.2.1 → simthinkd-0.5.0}/tests/test_space.py +0 -0
- {simthinkd-0.2.1 → simthinkd-0.5.0}/tests/test_web_page.py +0 -0
- {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.
|
|
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://
|
|
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/
|
|
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
|
-
-
|
|
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://
|
|
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/
|
|
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
|
-
-
|
|
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.
|
|
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}'
|