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.
Files changed (40) hide show
  1. {simthinkd-0.2.1/src/simthinkd.egg-info → simthinkd-0.3.0}/PKG-INFO +23 -3
  2. {simthinkd-0.2.1 → simthinkd-0.3.0}/README.md +22 -2
  3. {simthinkd-0.2.1 → simthinkd-0.3.0}/pyproject.toml +1 -1
  4. {simthinkd-0.2.1 → simthinkd-0.3.0}/src/simthinkd/__init__.py +1 -1
  5. simthinkd-0.3.0/src/simthinkd/bench.py +103 -0
  6. {simthinkd-0.2.1 → simthinkd-0.3.0}/src/simthinkd/cli.py +7 -2
  7. {simthinkd-0.2.1 → simthinkd-0.3.0}/src/simthinkd/core.py +72 -2
  8. {simthinkd-0.2.1 → simthinkd-0.3.0}/src/simthinkd/policy.py +30 -2
  9. {simthinkd-0.2.1 → simthinkd-0.3.0}/src/simthinkd/server.py +11 -3
  10. {simthinkd-0.2.1 → simthinkd-0.3.0/src/simthinkd.egg-info}/PKG-INFO +23 -3
  11. {simthinkd-0.2.1 → simthinkd-0.3.0}/src/simthinkd.egg-info/SOURCES.txt +3 -0
  12. simthinkd-0.3.0/tests/test_java_parity.py +81 -0
  13. simthinkd-0.3.0/tests/test_parallel.py +194 -0
  14. simthinkd-0.3.0/tests/test_server_concurrency.py +75 -0
  15. simthinkd-0.2.1/src/simthinkd/bench.py +0 -55
  16. {simthinkd-0.2.1 → simthinkd-0.3.0}/LICENSE +0 -0
  17. {simthinkd-0.2.1 → simthinkd-0.3.0}/NOTICE +0 -0
  18. {simthinkd-0.2.1 → simthinkd-0.3.0}/setup.cfg +0 -0
  19. {simthinkd-0.2.1 → simthinkd-0.3.0}/src/simthinkd/data/doom_defend_states.json +0 -0
  20. {simthinkd-0.2.1 → simthinkd-0.3.0}/src/simthinkd/integrations/__init__.py +0 -0
  21. {simthinkd-0.2.1 → simthinkd-0.3.0}/src/simthinkd/integrations/langchain_tool.py +0 -0
  22. {simthinkd-0.2.1 → simthinkd-0.3.0}/src/simthinkd/integrations/mcp_server.py +0 -0
  23. {simthinkd-0.2.1 → simthinkd-0.3.0}/src/simthinkd/score.py +0 -0
  24. {simthinkd-0.2.1 → simthinkd-0.3.0}/src/simthinkd/toy.py +0 -0
  25. {simthinkd-0.2.1 → simthinkd-0.3.0}/src/simthinkd/train.py +0 -0
  26. {simthinkd-0.2.1 → simthinkd-0.3.0}/src/simthinkd/weights/SHA256SUMS +0 -0
  27. {simthinkd-0.2.1 → simthinkd-0.3.0}/src/simthinkd/weights/doom-corridor.npz +0 -0
  28. {simthinkd-0.2.1 → simthinkd-0.3.0}/src/simthinkd/weights/doom-defend.npz +0 -0
  29. {simthinkd-0.2.1 → simthinkd-0.3.0}/src/simthinkd.egg-info/dependency_links.txt +0 -0
  30. {simthinkd-0.2.1 → simthinkd-0.3.0}/src/simthinkd.egg-info/entry_points.txt +0 -0
  31. {simthinkd-0.2.1 → simthinkd-0.3.0}/src/simthinkd.egg-info/requires.txt +0 -0
  32. {simthinkd-0.2.1 → simthinkd-0.3.0}/src/simthinkd.egg-info/top_level.txt +0 -0
  33. {simthinkd-0.2.1 → simthinkd-0.3.0}/tests/test_factory_twin.py +0 -0
  34. {simthinkd-0.2.1 → simthinkd-0.3.0}/tests/test_notebook.py +0 -0
  35. {simthinkd-0.2.1 → simthinkd-0.3.0}/tests/test_package.py +0 -0
  36. {simthinkd-0.2.1 → simthinkd-0.3.0}/tests/test_score.py +0 -0
  37. {simthinkd-0.2.1 → simthinkd-0.3.0}/tests/test_server_latency.py +0 -0
  38. {simthinkd-0.2.1 → simthinkd-0.3.0}/tests/test_space.py +0 -0
  39. {simthinkd-0.2.1 → simthinkd-0.3.0}/tests/test_web_page.py +0 -0
  40. {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.2.1
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/factory_fallback_poster.jpg" alt="Watch the factory simulation: local backup during a network outage" width="100%"></a></p>
52
+ <p align="center"><a href="assets/factory_fallback.mp4"><img src="assets/factory_twin.gif" alt="Factory simulation: the internet is cut for 15 seconds and SimThink D on the factory PC fills the gap" width="100%"></a></p>
51
53
 
52
- <p align="center"><a href="https://mssj-ai-org.github.io/simthinkd/#demo">Try in your browser</a> · <a href="assets/factory_fallback.mp4">Watch the video (72 s)</a> · <a href="examples/factory_twin/">Factory code</a> · <a href="docs/PAPER.md">Paper</a></p>
54
+ <p align="center"><a href="https://mssj-ai-org.github.io/simthinkd/">Project page</a> · <a href="https://mssj-ai-org.github.io/simthinkd/#demo">Try in your browser</a> · <a href="assets/factory_fallback.mp4">Watch the video (72 s)</a> · <a href="examples/factory_twin/">Factory code</a> · <a href="docs/PAPER.md">Paper</a></p>
53
55
 
54
56
  ```bash
55
57
  pip install simthinkd
@@ -90,6 +92,23 @@ One call is one decision. A short sentence goes in. One action comes out, with i
90
92
 
91
93
  Two deciders come with the package: `doom-defend` (stand in the middle and fight) and `doom-corridor` (fight your way down a corridor).
92
94
 
95
+ ## Many decisions at once
96
+
97
+ One call can carry several questions about the same situation, and one batch can carry many situations. The answers are the same as asking one by one.
98
+
99
+ ```python
100
+ from simthinkd import Decider
101
+
102
+ d = Decider("doom-defend")
103
+ states = ["seen: Demon left a30 d5 | enemies 1 | sway left | gun ready | ammo25",
104
+ "seen: Demon right a30 d5 | enemies 1 | sway right | gun ready | ammo25"]
105
+ answers = d.predict_batch([d.request(s) for s in states]) # many situations in one pass
106
+ print([a["operation"]["choice"] for a in answers])
107
+ # ['TURN_LEFT', 'TURN_RIGHT']
108
+ ```
109
+
110
+ `simthinkd bench --batch` shows where the time goes on your machine. Details and limits: [docs/PARALLEL.md](docs/PARALLEL.md).
111
+
93
112
  ## Train your own in seconds
94
113
 
95
114
  A new task needs two things: a perception step and a teacher. The `toy` module below is a small made-up task, an inspection station on a factory belt. Swap its examples and actions for your own.
@@ -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/factory_fallback_poster.jpg" alt="Watch the factory simulation: local backup during a network outage" width="100%"></a></p>
22
+ <p align="center"><a href="assets/factory_fallback.mp4"><img src="assets/factory_twin.gif" alt="Factory simulation: the internet is cut for 15 seconds and SimThink D on the factory PC fills the gap" width="100%"></a></p>
21
23
 
22
- <p align="center"><a href="https://mssj-ai-org.github.io/simthinkd/#demo">Try in your browser</a> · <a href="assets/factory_fallback.mp4">Watch the video (72 s)</a> · <a href="examples/factory_twin/">Factory code</a> · <a href="docs/PAPER.md">Paper</a></p>
24
+ <p align="center"><a href="https://mssj-ai-org.github.io/simthinkd/">Project page</a> · <a href="https://mssj-ai-org.github.io/simthinkd/#demo">Try in your browser</a> · <a href="assets/factory_fallback.mp4">Watch the video (72 s)</a> · <a href="examples/factory_twin/">Factory code</a> · <a href="docs/PAPER.md">Paper</a></p>
23
25
 
24
26
  ```bash
25
27
  pip install simthinkd
@@ -60,6 +62,23 @@ One call is one decision. A short sentence goes in. One action comes out, with i
60
62
 
61
63
  Two deciders come with the package: `doom-defend` (stand in the middle and fight) and `doom-corridor` (fight your way down a corridor).
62
64
 
65
+ ## Many decisions at once
66
+
67
+ One call can carry several questions about the same situation, and one batch can carry many situations. The answers are the same as asking one by one.
68
+
69
+ ```python
70
+ from simthinkd import Decider
71
+
72
+ d = Decider("doom-defend")
73
+ states = ["seen: Demon left a30 d5 | enemies 1 | sway left | gun ready | ammo25",
74
+ "seen: Demon right a30 d5 | enemies 1 | sway right | gun ready | ammo25"]
75
+ answers = d.predict_batch([d.request(s) for s in states]) # many situations in one pass
76
+ print([a["operation"]["choice"] for a in answers])
77
+ # ['TURN_LEFT', 'TURN_RIGHT']
78
+ ```
79
+
80
+ `simthinkd bench --batch` shows where the time goes on your machine. Details and limits: [docs/PARALLEL.md](docs/PARALLEL.md).
81
+
63
82
  ## Train your own in seconds
64
83
 
65
84
  A new task needs two things: a perception step and a teacher. The `toy` module below is a small made-up task, an inspection station on a factory belt. Swap its examples and actions for your own.
@@ -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.2.1"
7
+ version = "0.3.0"
8
8
  description = "A 265k-parameter decision model that picks one action in about 2 ms on one CPU core, inside real-time loops."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -6,7 +6,7 @@
6
6
  from .core import Decider, Decision, available, build_request
7
7
  from .score import Score, Scorer
8
8
 
9
- __version__ = '0.2.1'
9
+ __version__ = '0.3.0'
10
10
  __all__ = ['Decider', 'Decision', 'Score', 'Scorer', 'available', 'build_request', 'fit', 'fit_score', '__version__']
11
11
 
12
12
 
@@ -0,0 +1,103 @@
1
+ """Does your decider fit inside one game tick? Measures decision time and teacher agreement on recorded states.
2
+
3
+ simthinkd bench # the bundled decider, in-process, bundled Doom states
4
+ simthinkd bench --url http://127.0.0.1:8000/v1/systemone --name my-model
5
+ simthinkd bench --states my_states.json --tick-hz 60
6
+
7
+ Any server that accepts the decision request (PROTOCOL.md) and answers {"answers": {"operation": {"choice": ...}}}
8
+ can be measured. States file: {"rows": [{"body": <request>, "teacher": <action>}, ...]}.
9
+ One request at a time; the time is the full round trip seen by the caller.
10
+ """
11
+ import json
12
+ import statistics
13
+ import time
14
+ import urllib.request
15
+ from pathlib import Path
16
+
17
+ from .core import Decider
18
+
19
+ BUNDLED = Path(__file__).resolve().parent / 'data' / 'doom_defend_states.json'
20
+
21
+
22
+ def _post(url, body):
23
+ req = urllib.request.Request(url, data=json.dumps(body).encode(), headers={'Content-Type': 'application/json'})
24
+ with urllib.request.urlopen(req, timeout=300) as reply:
25
+ return json.load(reply)['answers']['operation']['choice']
26
+
27
+
28
+ def run(states=None, url=None, decider='doom-defend', name=None, tick_hz=35.0, warmup=20, limit=0):
29
+ rows = json.loads(Path(states or BUNDLED).read_text(encoding='utf8'))['rows']
30
+ rows = rows[:limit] if limit else rows
31
+ if url:
32
+ call, label = (lambda body: _post(url, body)), name or url
33
+ else:
34
+ d = decider if isinstance(decider, Decider) else Decider(decider)
35
+ call, label = (lambda body: d.predict(body)['operation']['choice']), name or f'simthink-d:{d.preset["name"]}'
36
+ for row in rows[:warmup]:
37
+ call(row['body'])
38
+ times, agree = [], 0
39
+ for row in rows:
40
+ start = time.perf_counter()
41
+ choice = call(row['body'])
42
+ times.append((time.perf_counter() - start) * 1000)
43
+ agree += choice == row['teacher']
44
+ q = sorted(times)
45
+ tick_ms = 1000.0 / tick_hz
46
+ return {'name': label, 'states': len(rows), 'tick_ms': round(tick_ms, 2),
47
+ 'p50_ms': round(statistics.median(q), 3), 'p95_ms': round(q[max(0, int(0.95 * len(q)) - 1)], 3),
48
+ 'within_one_tick': round(sum(t <= tick_ms for t in times) / len(times), 4),
49
+ 'teacher_agreement': round(agree / len(rows), 4)}
50
+
51
+
52
+ def throughput(states=None, decider='doom-defend', sizes=(1, 10, 100, 1000), limit=1000, repeat=3):
53
+ """Decisions per second, one request at a time (predict) vs batches (predict_batch), both end to end with the same
54
+ answers; then, in a separate instrumented pass, where batch time goes: encoding text (per request), the network
55
+ (Policy.scores_batch, one pass per batch) and turning scores into answers. In-process only; best of `repeat` runs."""
56
+ from .core import answer_from
57
+ from .policy import encode
58
+ d = decider if isinstance(decider, Decider) else Decider(decider)
59
+ rows = json.loads(Path(states or BUNDLED).read_text(encoding='utf8'))['rows']
60
+ bodies = [rows[i % len(rows)]['body'] for i in range(limit)]
61
+ d.predict_batch(bodies[:20])
62
+
63
+ def best(run):
64
+ times = []
65
+ for _ in range(repeat):
66
+ start = time.perf_counter()
67
+ run()
68
+ times.append(time.perf_counter() - start)
69
+ return min(times)
70
+
71
+ out = {'name': f'simthink-d:{d.preset["name"]}', 'decisions': len(bodies), 'sizes': {}}
72
+ out['one_at_a_time_per_s'] = round(len(bodies) / best(lambda: [d.predict(b) for b in bodies]), 1)
73
+ for size in sizes:
74
+ chunks = [bodies[i:i + size] for i in range(0, len(bodies), size)]
75
+ total = best(lambda: [d.predict_batch(c) for c in chunks])
76
+ enc = net = dec = 0.0
77
+ for c in chunks:
78
+ t0 = time.perf_counter()
79
+ encoded = [encode(b) for b in c]
80
+ t1 = time.perf_counter()
81
+ scores = d.policy.scores_batch([e[0] for e in encoded])
82
+ t2 = time.perf_counter()
83
+ [answer_from(d.policy.decode(e, sc)) for e, sc in zip(encoded, scores)]
84
+ enc, net, dec = enc + t1 - t0, net + t2 - t1, dec + time.perf_counter() - t2
85
+ part = enc + net + dec
86
+ out['sizes'][size] = {'per_s': round(len(bodies) / total, 1), 'network_only_per_s': round(len(bodies) / net, 1),
87
+ 'encode_share': round(enc / part, 3), 'network_share': round(net / part, 3),
88
+ 'answer_share': round(dec / part, 3)}
89
+ return out
90
+
91
+
92
+ def throughput_report(r):
93
+ lines = [f"{r['name']}: {r['decisions']} decisions, one at a time {r['one_at_a_time_per_s']}/s"]
94
+ for size, v in r['sizes'].items():
95
+ lines.append(f" batch {size:>5}: {v['per_s']}/s end to end; network alone {v['network_only_per_s']}/s; time: "
96
+ f"{v['encode_share']:.0%} encoding text, {v['network_share']:.0%} network, {v['answer_share']:.0%} building answers")
97
+ return '\n'.join(lines)
98
+
99
+
100
+ def report(result):
101
+ return (f"{result['name']}: median {result['p50_ms']} ms, p95 {result['p95_ms']} ms, "
102
+ f"{result['within_one_tick']:.1%} of decisions within one {result['tick_ms']} ms tick, "
103
+ f"teacher agreement {result['teacher_agreement']:.1%} on {result['states']} states")
@@ -23,6 +23,7 @@ def main(argv=None):
23
23
  s.add_argument('--states')
24
24
  s.add_argument('--tick-hz', type=float, default=35.0)
25
25
  s.add_argument('--limit', type=int, default=0)
26
+ s.add_argument('--batch', action='store_true', help='decisions per second, one at a time vs batches of 1, 10, 100, 1000')
26
27
  s.add_argument('--json', action='store_true')
27
28
  s = sub.add_parser('train', help='train from a folder of protocol rows (needs simthinkd[train])')
28
29
  s.add_argument('--data', required=True)
@@ -46,8 +47,12 @@ def main(argv=None):
46
47
  serve(a.decider, a.host, a.port, a.delay_ms)
47
48
  elif a.cmd == 'bench':
48
49
  from . import bench
49
- result = bench.run(a.states, a.url, a.decider, a.name, a.tick_hz, limit=a.limit)
50
- print(json.dumps(result) if a.json else bench.report(result))
50
+ if a.batch:
51
+ result = bench.throughput(a.states, a.decider, limit=a.limit or 1000)
52
+ print(json.dumps(result) if a.json else bench.throughput_report(result))
53
+ else:
54
+ result = bench.run(a.states, a.url, a.decider, a.name, a.tick_hz, limit=a.limit)
55
+ print(json.dumps(result) if a.json else bench.report(result))
51
56
  elif a.cmd == 'train':
52
57
  from .train import fit_dir
53
58
  d = fit_dir(a.data, a.out, a.steps, a.seed)
@@ -13,7 +13,7 @@ import time
13
13
  from dataclasses import dataclass, field
14
14
  from pathlib import Path
15
15
 
16
- from .policy import Policy
16
+ from .policy import Policy, encode
17
17
 
18
18
  WEIGHTS = Path(__file__).resolve().parent / 'weights'
19
19
 
@@ -76,7 +76,11 @@ def build_request(state, actions, goal, *, url='sim://custom', title='', model='
76
76
 
77
77
  def answer(policy, body):
78
78
  """Reply `answers` block for a protocol request (operation, and `<op>_target` when targets were offered)."""
79
- chosen, op, conditional, joint, rows, operations = policy.predict(body)
79
+ return answer_from(policy.predict(body))
80
+
81
+
82
+ def answer_from(predicted):
83
+ chosen, op, conditional, joint, rows, operations = predicted
80
84
  op_probs = {key: float(op[i]) for i, key in enumerate(operations)}
81
85
  top = max(op_probs, key=op_probs.get)
82
86
  answers = {'operation': {'choice': top, 'probabilities': op_probs, 'confidence': op_probs[top]}}
@@ -90,6 +94,44 @@ def answer(policy, body):
90
94
  return answers
91
95
 
92
96
 
97
+ def _question_body(body, question):
98
+ """One general question as an operation-style request on the same state: each criterion becomes one row."""
99
+ kind, crit = question.get('type', 'choice'), question.get('criteria')
100
+ if kind == 'score':
101
+ levels = list(crit.values()) if isinstance(crit, dict) else list(crit or [])
102
+ if not 2 <= len(levels) <= 10:
103
+ raise ValueError('a score question needs 2 to 10 levels')
104
+ crit = {str(i + 1): level for i, level in enumerate(levels)}
105
+ elif kind == 'noul':
106
+ crit = crit or {'true': 'Yes.', 'false': 'No.'}
107
+ if set(crit) != {'true', 'false'}:
108
+ raise ValueError('a noul question has exactly the criteria true and false')
109
+ elif kind != 'choice' or not crit:
110
+ raise ValueError(f'question type must be choice, score or noul with criteria (got {kind!r})')
111
+ return {'state': body['state'],
112
+ 'questions': {'operation': {'type': 'choice', 'criteria': crit, 'instructions': question.get('instructions', {})}}}
113
+
114
+
115
+ def _general_answer(kind, predicted):
116
+ op, keys = predicted[1], predicted[5]
117
+ probs = {k: float(op[i]) for i, k in enumerate(keys)}
118
+ best = max(probs, key=probs.get)
119
+ if kind == 'noul':
120
+ return {'type': 'noul', 'noul': probs['true'], 'probabilities': probs}
121
+ if kind == 'score':
122
+ return {'type': 'score', 'score': int(best), 'probabilities': probs, 'confidence': probs[best]}
123
+ return {'type': 'choice', 'choice': best, 'probabilities': probs, 'confidence': probs[best]}
124
+
125
+
126
+ def _jobs(body):
127
+ """(name, type) per block: the operation question with its `<op>_target` companions is one coupled block (name None);
128
+ every other question is its own block."""
129
+ qs = body.get('questions', {})
130
+ coupled = {'operation'} | {op.lower() + '_target' for op in qs.get('operation', {}).get('criteria', {})}
131
+ jobs = [(None, None)] if 'operation' in qs else []
132
+ return jobs + [(name, q.get('type', 'choice')) for name, q in qs.items() if name not in coupled]
133
+
134
+
93
135
  @dataclass(frozen=True)
94
136
  class Decision:
95
137
  choice: str
@@ -141,6 +183,34 @@ class Decider:
141
183
  """Protocol-level call: request dict in, `answers` dict out (same as the HTTP server)."""
142
184
  return answer(self.policy, body)
143
185
 
186
+ def predict_batch(self, bodies):
187
+ """predict() for many requests at once; the network runs once over all of them."""
188
+ return [answer_from(p) for p in self.policy.predict_batch(bodies)]
189
+
190
+ def ask(self, body):
191
+ """Several questions about one situation in one call (choice, score, noul). See docs/PARALLEL.md."""
192
+ return self.ask_batch([body])[0]
193
+
194
+ def ask_batch(self, bodies):
195
+ """ask() for many requests; every question of every request goes through one padded network pass."""
196
+ bodies = list(bodies)
197
+ if not bodies:
198
+ return []
199
+ jobs, encoded = [], []
200
+ for i, body in enumerate(bodies):
201
+ body_jobs = _jobs(body)
202
+ if not body_jobs: # checked per request, so a neighbour in the batch never changes the outcome
203
+ raise ValueError(f'request {i} has no question to answer')
204
+ for name, kind in body_jobs:
205
+ jobs.append((i, name, kind))
206
+ encoded.append(encode(body if name is None else _question_body(body, body['questions'][name])))
207
+ out = [{'answers': {}, 'meta': {'questions': 0}} for _ in bodies]
208
+ for (i, name, kind), e, s in zip(jobs, encoded, self.policy.scores_batch([e[0] for e in encoded])):
209
+ predicted = self.policy.decode(e, s)
210
+ out[i]['answers'].update(answer_from(predicted) if name is None else {name: _general_answer(kind, predicted)})
211
+ out[i]['meta']['questions'] += 1
212
+ return out
213
+
144
214
  def decide(self, state, actions=None, goal=None, tick=0, recent_actions=None):
145
215
  """One decision for one situation sentence. Returns a Decision (choice, confidence, probabilities, ms)."""
146
216
  body = self.request(state, actions, goal, tick, recent_actions)
@@ -173,9 +173,37 @@ class Policy:
173
173
  return (z @ w['score.weight'].T + w['score.bias'])[:, 0]
174
174
 
175
175
  def predict(self, body):
176
- x, groups, rows, operations = encode(body)
177
- op, conditional, joint = distributions(self.scores(x), groups, self.temperature)
176
+ return self.decode(encode(body), self.scores)
177
+
178
+ def decode(self, encoded, scores):
179
+ x, groups, rows, operations = encoded
180
+ op, conditional, joint = distributions(scores if not callable(scores) else scores(x), groups, self.temperature)
178
181
  chosen_group = int(op.argmax())
179
182
  indices = np.flatnonzero(groups == chosen_group)
180
183
  chosen = rows[int(indices[conditional[chosen_group].argmax()])]
181
184
  return chosen, op, conditional, joint, rows, operations
185
+
186
+ def scores_batch(self, blocks):
187
+ """Scores for many encoded blocks in one padded pass. A block is the row matrix of one question;
188
+ context pooling (mean, max) stays inside its block, so blocks never influence each other."""
189
+ if not len(blocks):
190
+ return []
191
+ if any(len(b) == 0 for b in blocks):
192
+ raise ValueError('every block needs at least one row')
193
+ sizes = np.array([len(b) for b in blocks])
194
+ starts = np.concatenate([[0], np.cumsum(sizes)[:-1]])
195
+ w = self.w
196
+ # All rows of all blocks go through each layer as one matrix product; only the pooling is per block.
197
+ h = np.maximum(0, np.concatenate(blocks) @ w['local.weight'].T + w['local.bias'])
198
+ context = np.concatenate([np.add.reduceat(h, starts) / sizes[:, None].astype(h.dtype), np.maximum.reduceat(h, starts)], axis=1)
199
+ z = np.concatenate([h, np.repeat(context, sizes, axis=0)], axis=1)
200
+ z = np.maximum(0, z @ w['context.weight'].T + w['context.bias'])
201
+ s = (z @ w['score.weight'].T + w['score.bias'])[:, 0]
202
+ return np.split(s, np.cumsum(sizes)[:-1])
203
+
204
+ def predict_batch(self, bodies):
205
+ """predict() for many requests: encoding is per request, the network runs once over all of them."""
206
+ encoded = [encode(b) for b in bodies]
207
+ if not encoded:
208
+ return []
209
+ return [self.decode(e, s) for e, s in zip(encoded, self.scores_batch([e[0] for e in encoded]))]
@@ -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
- start = time.perf_counter()
42
- answers = decider.predict(body)
43
- infer_ms = (time.perf_counter() - start) * 1000
48
+ with infer_lock:
49
+ start = time.perf_counter()
50
+ answers = decider.predict(body)
51
+ infer_ms = (time.perf_counter() - start) * 1000
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.2.1
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/factory_fallback_poster.jpg" alt="Watch the factory simulation: local backup during a network outage" width="100%"></a></p>
52
+ <p align="center"><a href="assets/factory_fallback.mp4"><img src="assets/factory_twin.gif" alt="Factory simulation: the internet is cut for 15 seconds and SimThink D on the factory PC fills the gap" width="100%"></a></p>
51
53
 
52
- <p align="center"><a href="https://mssj-ai-org.github.io/simthinkd/#demo">Try in your browser</a> · <a href="assets/factory_fallback.mp4">Watch the video (72 s)</a> · <a href="examples/factory_twin/">Factory code</a> · <a href="docs/PAPER.md">Paper</a></p>
54
+ <p align="center"><a href="https://mssj-ai-org.github.io/simthinkd/">Project page</a> · <a href="https://mssj-ai-org.github.io/simthinkd/#demo">Try in your browser</a> · <a href="assets/factory_fallback.mp4">Watch the video (72 s)</a> · <a href="examples/factory_twin/">Factory code</a> · <a href="docs/PAPER.md">Paper</a></p>
53
55
 
54
56
  ```bash
55
57
  pip install simthinkd
@@ -90,6 +92,23 @@ One call is one decision. A short sentence goes in. One action comes out, with i
90
92
 
91
93
  Two deciders come with the package: `doom-defend` (stand in the middle and fight) and `doom-corridor` (fight your way down a corridor).
92
94
 
95
+ ## Many decisions at once
96
+
97
+ One call can carry several questions about the same situation, and one batch can carry many situations. The answers are the same as asking one by one.
98
+
99
+ ```python
100
+ from simthinkd import Decider
101
+
102
+ d = Decider("doom-defend")
103
+ states = ["seen: Demon left a30 d5 | enemies 1 | sway left | gun ready | ammo25",
104
+ "seen: Demon right a30 d5 | enemies 1 | sway right | gun ready | ammo25"]
105
+ answers = d.predict_batch([d.request(s) for s in states]) # many situations in one pass
106
+ print([a["operation"]["choice"] for a in answers])
107
+ # ['TURN_LEFT', 'TURN_RIGHT']
108
+ ```
109
+
110
+ `simthinkd bench --batch` shows where the time goes on your machine. Details and limits: [docs/PARALLEL.md](docs/PARALLEL.md).
111
+
93
112
  ## Train your own in seconds
94
113
 
95
114
  A new task needs two things: a perception step and a teacher. The `toy` module below is a small made-up task, an inspection station on a factory belt. Swap its examples and actions for your own.
@@ -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