simthinkd 0.1.0__py3-none-any.whl
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/__init__.py +15 -0
- simthinkd/bench.py +55 -0
- simthinkd/cli.py +62 -0
- simthinkd/core.py +158 -0
- simthinkd/data/doom_defend_states.json +1 -0
- simthinkd/integrations/__init__.py +0 -0
- simthinkd/integrations/langchain_tool.py +27 -0
- simthinkd/integrations/mcp_server.py +52 -0
- simthinkd/policy.py +181 -0
- simthinkd/server.py +60 -0
- simthinkd/toy.py +53 -0
- simthinkd/train.py +222 -0
- simthinkd/weights/SHA256SUMS +2 -0
- simthinkd/weights/doom-corridor.npz +0 -0
- simthinkd/weights/doom-defend.npz +0 -0
- simthinkd-0.1.0.dist-info/METADATA +170 -0
- simthinkd-0.1.0.dist-info/RECORD +22 -0
- simthinkd-0.1.0.dist-info/WHEEL +5 -0
- simthinkd-0.1.0.dist-info/entry_points.txt +2 -0
- simthinkd-0.1.0.dist-info/licenses/LICENSE +202 -0
- simthinkd-0.1.0.dist-info/licenses/NOTICE +14 -0
- simthinkd-0.1.0.dist-info/top_level.txt +1 -0
simthinkd/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""SimThink D: a small decision model that picks one action in about 2 ms on one CPU core.
|
|
2
|
+
|
|
3
|
+
from simthinkd import Decider
|
|
4
|
+
print(Decider("doom-defend").decide("seen: Demon left a30 d5 | enemies 1 | sway left | gun ready | ammo25"))
|
|
5
|
+
"""
|
|
6
|
+
from .core import Decider, Decision, available, build_request
|
|
7
|
+
|
|
8
|
+
__version__ = '0.1.0'
|
|
9
|
+
__all__ = ['Decider', 'Decision', 'available', 'build_request', 'fit', '__version__']
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def fit(*args, **kwargs):
|
|
13
|
+
"""Train a decider from (situation, action) pairs. See simthinkd.train.fit. Needs simthinkd[train]."""
|
|
14
|
+
from .train import fit as _fit
|
|
15
|
+
return _fit(*args, **kwargs)
|
simthinkd/bench.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
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")
|
simthinkd/cli.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""simthinkd command line: decide · serve · bench · train · list."""
|
|
2
|
+
import argparse
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def main(argv=None):
|
|
8
|
+
ap = argparse.ArgumentParser(prog='simthinkd', description='SimThink D: a 2 ms decision model for real-time loops.')
|
|
9
|
+
sub = ap.add_subparsers(dest='cmd', required=True)
|
|
10
|
+
s = sub.add_parser('decide', help='one decision for one situation sentence')
|
|
11
|
+
s.add_argument('state', nargs='?', help='situation sentence (default: the preset example)')
|
|
12
|
+
s.add_argument('--decider', default='doom-defend')
|
|
13
|
+
s.add_argument('--json', action='store_true')
|
|
14
|
+
s = sub.add_parser('serve', help='HTTP server (POST /v1/systemone)')
|
|
15
|
+
s.add_argument('decider', nargs='?', default='doom-defend')
|
|
16
|
+
s.add_argument('--host', default='127.0.0.1')
|
|
17
|
+
s.add_argument('--port', type=int, default=11890)
|
|
18
|
+
s.add_argument('--delay-ms', type=float, default=0.0)
|
|
19
|
+
s = sub.add_parser('bench', help='decision time and teacher agreement on recorded states')
|
|
20
|
+
s.add_argument('--url')
|
|
21
|
+
s.add_argument('--name')
|
|
22
|
+
s.add_argument('--decider', default='doom-defend')
|
|
23
|
+
s.add_argument('--states')
|
|
24
|
+
s.add_argument('--tick-hz', type=float, default=35.0)
|
|
25
|
+
s.add_argument('--limit', type=int, default=0)
|
|
26
|
+
s.add_argument('--json', action='store_true')
|
|
27
|
+
s = sub.add_parser('train', help='train from a folder of protocol rows (needs simthinkd[train])')
|
|
28
|
+
s.add_argument('--data', required=True)
|
|
29
|
+
s.add_argument('--out', required=True)
|
|
30
|
+
s.add_argument('--steps', type=int, default=600)
|
|
31
|
+
s.add_argument('--seed', type=int, default=31)
|
|
32
|
+
sub.add_parser('list', help='bundled deciders')
|
|
33
|
+
a = ap.parse_args(argv)
|
|
34
|
+
|
|
35
|
+
if a.cmd == 'decide':
|
|
36
|
+
from .core import Decider
|
|
37
|
+
d = Decider(a.decider)
|
|
38
|
+
state = a.state or d.preset.get('example')
|
|
39
|
+
if not state:
|
|
40
|
+
sys.exit('give a situation sentence')
|
|
41
|
+
r = d.decide(state)
|
|
42
|
+
print(json.dumps({'choice': r.choice, 'confidence': r.confidence, 'ms': r.ms, 'probabilities': r.probabilities})
|
|
43
|
+
if a.json else r)
|
|
44
|
+
elif a.cmd == 'serve':
|
|
45
|
+
from .server import serve
|
|
46
|
+
serve(a.decider, a.host, a.port, a.delay_ms)
|
|
47
|
+
elif a.cmd == 'bench':
|
|
48
|
+
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))
|
|
51
|
+
elif a.cmd == 'train':
|
|
52
|
+
from .train import fit_dir
|
|
53
|
+
d = fit_dir(a.data, a.out, a.steps, a.seed)
|
|
54
|
+
print(json.dumps({'decider': a.out, 'sha256': d.sha256}))
|
|
55
|
+
elif a.cmd == 'list':
|
|
56
|
+
from .core import available
|
|
57
|
+
for name, about in available().items():
|
|
58
|
+
print(f'{name:15s} {about}')
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
if __name__ == '__main__':
|
|
62
|
+
main()
|
simthinkd/core.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""High-level API: load a decider, ask it for one action, get the choice, probabilities and time.
|
|
2
|
+
|
|
3
|
+
from simthinkd import Decider
|
|
4
|
+
d = Decider("doom-defend")
|
|
5
|
+
print(d.decide("seen: Demon left a30 d5 | enemies 1 | sway left | gun ready | ammo25"))
|
|
6
|
+
|
|
7
|
+
A decider is a weights file (about 1 MB, 265,665 parameters) plus the action set and goal it was trained with
|
|
8
|
+
(a "preset"). Your own deciders come from `simthinkd.fit(...)`; they carry their preset inside the weights folder.
|
|
9
|
+
"""
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import time
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from .policy import Policy
|
|
17
|
+
|
|
18
|
+
WEIGHTS = Path(__file__).resolve().parent / 'weights'
|
|
19
|
+
|
|
20
|
+
DOOM_DEFEND_ACTIONS = {
|
|
21
|
+
'TURN_LEFT': 'Turn left toward the target.',
|
|
22
|
+
'TURN_RIGHT': 'Turn right toward the target.',
|
|
23
|
+
'ATTACK': 'Shoot now, standing still.',
|
|
24
|
+
'STRAFE_LEFT': 'Sidestep left without shooting.',
|
|
25
|
+
'STRAFE_RIGHT': 'Sidestep right without shooting.',
|
|
26
|
+
'ATTACK_STRAFE_LEFT': 'Shoot while sidestepping left.',
|
|
27
|
+
'ATTACK_STRAFE_RIGHT': 'Shoot while sidestepping right.',
|
|
28
|
+
}
|
|
29
|
+
DOOM_CORRIDOR_ACTIONS = {
|
|
30
|
+
'MOVE_LEFT': 'Sidestep left, away from fire.',
|
|
31
|
+
'MOVE_RIGHT': 'Sidestep right, away from fire.',
|
|
32
|
+
'ATTACK': 'Shoot now; an enemy is lined up ahead.',
|
|
33
|
+
'MOVE_FORWARD': 'Advance down the corridor toward the armor.',
|
|
34
|
+
'MOVE_BACKWARD': 'Back off from what is in front.',
|
|
35
|
+
'TURN_LEFT': 'Turn left toward something on the left.',
|
|
36
|
+
'TURN_RIGHT': 'Turn right toward something on the right.',
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
PRESETS = {
|
|
40
|
+
'doom-defend': {
|
|
41
|
+
'weights': 'doom-defend.npz',
|
|
42
|
+
'sha256': 'a814587e03478f451f1ab478558de7e076dbe9a3de9cb2ee4cc84124cdfa2a19',
|
|
43
|
+
'model': 'doom-showcase-s1', 'url': 'doom://defend_the_center/showcase', 'title': 'defend the center tick {tick}',
|
|
44
|
+
'goal': 'Aim, shoot and sway to survive the horde.', 'actions': DOOM_DEFEND_ACTIONS,
|
|
45
|
+
'about': 'ViZDoom defend_the_center: aim, shoot and sway against a horde.',
|
|
46
|
+
'example': 'seen: Demon left a30 d5 | enemies 1 | sway left | gun ready | ammo25',
|
|
47
|
+
},
|
|
48
|
+
'doom-corridor': {
|
|
49
|
+
'weights': 'doom-corridor.npz',
|
|
50
|
+
'sha256': 'a164d90514f172ff2f1ac9452aaf83a637ffd8ef3136d0f553179e5c7ab035d8',
|
|
51
|
+
'model': 'doom-corridor', 'url': 'doom://deadly_corridor', 'title': 'deadly corridor tick {tick}',
|
|
52
|
+
'goal': 'Clear the corridor and reach the green armor.', 'actions': DOOM_CORRIDOR_ACTIONS,
|
|
53
|
+
'about': 'ViZDoom deadly_corridor: clear the corridor and reach the armor.',
|
|
54
|
+
'example': 'enemies: ShotgunGuy left near offset15 ; Zombieman right near offset15 | goal: armor ahead distant | body healthy | ammo stocked',
|
|
55
|
+
},
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def build_request(state, actions, goal, *, url='sim://custom', title='', model='simthinkd', recent_actions=None):
|
|
60
|
+
"""Turn (situation sentence, {action: description}, goal) into a decision request (see PROTOCOL.md)."""
|
|
61
|
+
if not isinstance(state, str) or not state.strip():
|
|
62
|
+
raise ValueError('state must be a non-empty sentence describing the situation')
|
|
63
|
+
if not actions or len(actions) > 8:
|
|
64
|
+
raise ValueError('offer between 1 and 8 actions')
|
|
65
|
+
return {
|
|
66
|
+
'model': model,
|
|
67
|
+
'state': {'page': {'url': url, 'title': title, 'text': state},
|
|
68
|
+
'elements': [{'id': name, 'role': 'button', 'label': name} for name in actions],
|
|
69
|
+
'recent_actions': list(recent_actions or [])},
|
|
70
|
+
'questions': {'operation': {'type': 'choice',
|
|
71
|
+
'criteria': {name: {'element': f'[button] {name}', 'description': why}
|
|
72
|
+
for name, why in actions.items()},
|
|
73
|
+
'instructions': {'goal': goal}}},
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def answer(policy, body):
|
|
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)
|
|
80
|
+
op_probs = {key: float(op[i]) for i, key in enumerate(operations)}
|
|
81
|
+
top = max(op_probs, key=op_probs.get)
|
|
82
|
+
answers = {'operation': {'choice': top, 'probabilities': op_probs, 'confidence': op_probs[top]}}
|
|
83
|
+
group = operations.index(top)
|
|
84
|
+
targets = [(r['target'], float(joint[i])) for i, r in enumerate(rows) if r['group'] == group and r['target'] is not None]
|
|
85
|
+
if targets:
|
|
86
|
+
total = sum(p for _, p in targets) or 1.0
|
|
87
|
+
probs = {t: p / total for t, p in targets}
|
|
88
|
+
best = max(probs, key=probs.get)
|
|
89
|
+
answers[top.lower() + '_target'] = {'choice': best, 'probabilities': probs, 'confidence': probs[best]}
|
|
90
|
+
return answers
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass(frozen=True)
|
|
94
|
+
class Decision:
|
|
95
|
+
choice: str
|
|
96
|
+
confidence: float
|
|
97
|
+
probabilities: dict = field(repr=False)
|
|
98
|
+
ms: float = 0.0
|
|
99
|
+
|
|
100
|
+
def __str__(self):
|
|
101
|
+
return f'{self.choice} ({self.confidence:.0%}, {self.ms:.2f} ms)'
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class Decider:
|
|
105
|
+
"""A trained decider. `Decider("doom-defend")`, `Decider("path/to/folder_or.npz")`."""
|
|
106
|
+
|
|
107
|
+
def __init__(self, name_or_path='doom-defend'):
|
|
108
|
+
preset = PRESETS.get(name_or_path)
|
|
109
|
+
if preset:
|
|
110
|
+
path = WEIGHTS / preset['weights']
|
|
111
|
+
digest = hashlib.sha256(path.read_bytes()).hexdigest()
|
|
112
|
+
if digest != preset['sha256']:
|
|
113
|
+
raise RuntimeError(f'weights file {path.name} does not match its recorded SHA-256')
|
|
114
|
+
self.preset = dict(preset, name=name_or_path)
|
|
115
|
+
else:
|
|
116
|
+
path = Path(name_or_path)
|
|
117
|
+
if path.is_dir():
|
|
118
|
+
card = json.loads((path / 'decider.json').read_text(encoding='utf8'))
|
|
119
|
+
self.preset = dict(card, name=card.get('name', path.name))
|
|
120
|
+
path = path / card['weights']
|
|
121
|
+
else:
|
|
122
|
+
self.preset = {'name': path.stem, 'actions': None, 'goal': '', 'url': 'sim://custom', 'title': '',
|
|
123
|
+
'model': path.stem}
|
|
124
|
+
self.policy = Policy(path)
|
|
125
|
+
self.sha256 = self.policy.digest
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def actions(self):
|
|
129
|
+
return dict(self.preset['actions'] or {})
|
|
130
|
+
|
|
131
|
+
def request(self, state, actions=None, goal=None, tick=0, recent_actions=None):
|
|
132
|
+
actions = actions or self.preset['actions']
|
|
133
|
+
if not actions:
|
|
134
|
+
raise ValueError('this decider has no stored action set; pass actions={name: description}')
|
|
135
|
+
return build_request(state, actions, self.preset['goal'] if goal is None else goal,
|
|
136
|
+
url=self.preset.get('url', 'sim://custom'),
|
|
137
|
+
title=self.preset.get('title', '').format(tick=tick),
|
|
138
|
+
model=self.preset.get('model', 'simthinkd'), recent_actions=recent_actions)
|
|
139
|
+
|
|
140
|
+
def predict(self, body):
|
|
141
|
+
"""Protocol-level call: request dict in, `answers` dict out (same as the HTTP server)."""
|
|
142
|
+
return answer(self.policy, body)
|
|
143
|
+
|
|
144
|
+
def decide(self, state, actions=None, goal=None, tick=0, recent_actions=None):
|
|
145
|
+
"""One decision for one situation sentence. Returns a Decision (choice, confidence, probabilities, ms)."""
|
|
146
|
+
body = self.request(state, actions, goal, tick, recent_actions)
|
|
147
|
+
start = time.perf_counter()
|
|
148
|
+
op = answer(self.policy, body)['operation']
|
|
149
|
+
ms = (time.perf_counter() - start) * 1000
|
|
150
|
+
return Decision(op['choice'], op['confidence'], op['probabilities'], ms)
|
|
151
|
+
|
|
152
|
+
def __repr__(self):
|
|
153
|
+
return f'Decider({self.preset["name"]!r}, sha256={self.sha256[:12]})'
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def available():
|
|
157
|
+
"""Names of the deciders that ship with the package."""
|
|
158
|
+
return {name: p['about'] for name, p in PRESETS.items()}
|