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