artificialbrains-sdk 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.
ab_sdk/__init__.py ADDED
@@ -0,0 +1,63 @@
1
+ """
2
+ Top-level package for ArtificialBrains Python SDK.
3
+
4
+ This package provides a client and helper classes to interact with the
5
+ ArtificialBrains server over HTTP and realtime (Socket.IO/WebSocket-style)
6
+ events. The SDK wraps the low-level REST + realtime protocols so developers
7
+ can focus on:
8
+ - providing sensor inputs (camera, depth, etc.)
9
+ - turning brain outputs (spikes) into actuator commands
10
+ - defining reward / feedback (optional)
11
+
12
+ High-level imports (recommended):
13
+
14
+ from ab_sdk import ABClient, RunSession, InputStreamer, RobotLoop
15
+
16
+ Decoding output spikes (robot-agnostic):
17
+
18
+ from ab_sdk.plugins.decoder import MappingEntry, decode_stream_rows
19
+
20
+ Optional convenience helper (if your robot uses dq/dg style):
21
+
22
+ from ab_sdk.plugins.decoder import deltas_to_dq_dg
23
+
24
+ Reward / deviation plugins (optional defaults):
25
+
26
+ from ab_sdk.plugins.deviation import DefaultDeviation
27
+ from ab_sdk.plugins.reward import DefaultReward
28
+ """
29
+
30
+ from .client import ABClient # noqa: F401
31
+ from .run_session import RunSession # noqa: F401
32
+ from .input_streamer import InputStreamer # noqa: F401
33
+ from .robot_loop import RobotLoop # noqa: F401
34
+
35
+ # plugins (robot-agnostic decoding)
36
+ from .plugins.decoder import ( # noqa: F401
37
+ MappingEntry,
38
+ decode_stream_rows,
39
+ deltas_to_dq_dg,
40
+ )
41
+
42
+ # contract + policy scaffolding
43
+ from .contract_scaffold import sync_policies_from_contract # noqa: F401
44
+
45
+ # optional defaults (policies)
46
+ from .plugins.deviation import DefaultDeviation # noqa: F401
47
+ from .plugins.reward import DefaultReward # noqa: F401
48
+
49
+ __all__ = [
50
+ "ABClient",
51
+ "RunSession",
52
+ "InputStreamer",
53
+ "RobotLoop",
54
+ # decoder plugin
55
+ "MappingEntry",
56
+ "decode_stream_rows",
57
+ "deltas_to_dq_dg",
58
+ # scaffolding
59
+ "sync_policies_from_contract",
60
+ # policies
61
+ "DefaultDeviation",
62
+ "DefaultReward",
63
+ ]
ab_sdk/client.py ADDED
@@ -0,0 +1,270 @@
1
+ """HTTP and realtime client for ArtificialBrains.
2
+
3
+ This module defines the :class:`ABClient` class which wraps the REST
4
+ endpoints exposed by the Artificial Brains server and manages the
5
+ underlying realtime (Socket.IO) connection. It is responsible for
6
+ starting and stopping runs, querying the current input state and
7
+ creating :class:`~ab_sdk.run_session.RunSession` instances which
8
+ encapsulate per‑run state and socket clients. You should not need to
9
+ deal with low level HTTP or Socket.IO interactions outside of this
10
+ class.
11
+
12
+ Usage example::
13
+
14
+ from ab_sdk import ABClient
15
+
16
+ client = ABClient("https://brains.example.com/api", api_key="your_key")
17
+ run = client.start("my_project")
18
+ # ... attach sensors, run loop ...
19
+ client.stop("my_project")
20
+
21
+ The `start` method returns a :class:`~ab_sdk.run_session.RunSession` object
22
+ containing the run contract (IO manifest, constants) and a Socket.IO
23
+ client already joined to the run room. See the documentation on
24
+ `RunSession` for details.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import json
30
+ import logging
31
+ import time
32
+ from typing import Any, Dict, Optional
33
+
34
+ import httpx
35
+ import socketio
36
+
37
+ from .run_session import RunSession
38
+ from . import endpoints
39
+ from .contract_scaffold import sync_policies_from_contract
40
+
41
+ logger = logging.getLogger(__name__)
42
+
43
+
44
+ class ABClient:
45
+ """Client for interacting with the Artificial Brains backend.
46
+
47
+ Auth:
48
+ - This SDK always sends your machine API key on *every* HTTP request using:
49
+ * `x-api-key: <key>` (preferred by the server)
50
+ and also:
51
+ * `Authorization: Bearer <key>` (accepted by the server as a fallback)
52
+
53
+ Base URL:
54
+ - Provide either:
55
+ * https://artificialbrains.app/api
56
+ * http://localhost:3000/api
57
+ If you pass a host without `/api`, the client will append `/api` automatically.
58
+ """
59
+
60
+ def __init__(
61
+ self,
62
+ base_url: str,
63
+ api_key: Optional[str] = None,
64
+ timeout: float = 10.0,
65
+ socket_namespace: str = "/ab",
66
+ ) -> None:
67
+ if not base_url:
68
+ raise ValueError("base_url must be provided")
69
+
70
+ base = base_url.rstrip("/")
71
+ # Accept either host root or /api, but store a base_url that ends with /api.
72
+ if not base.endswith("/api"):
73
+ base = base + "/api"
74
+
75
+ self.base_url = base
76
+ self.api_key = api_key or None
77
+ self.timeout = timeout
78
+ self.socket_namespace = socket_namespace
79
+
80
+ headers: Dict[str, str] = {}
81
+ if self.api_key:
82
+ headers["x-api-key"] = self.api_key
83
+ headers["Authorization"] = f"Bearer {self.api_key}"
84
+
85
+ # httpx base_url joins *relative* paths; leading '/' would reset the path.
86
+ self._http = httpx.Client(base_url=self.base_url, headers=headers, timeout=timeout)
87
+ logger.debug("ABClient initialized with base_url=%s", self.base_url)
88
+
89
+ def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
90
+ """Internal helper for sending HTTP requests."""
91
+ url = path.lstrip("/") # preserve /api prefix in base_url
92
+ try:
93
+ response = self._http.request(method, url, **kwargs)
94
+ response.raise_for_status()
95
+ return response
96
+ except httpx.HTTPStatusError as exc:
97
+ logger.error("HTTP error: %s", exc)
98
+ raise
99
+ except httpx.RequestError as exc:
100
+ logger.error("Request failed: %s", exc)
101
+ raise
102
+
103
+ def start(self, project_id: str, **kwargs: Any) -> RunSession:
104
+ """Start a new run and connect to realtime."""
105
+ if not project_id:
106
+ raise ValueError("project_id must be provided")
107
+
108
+ path = endpoints.START_RUN.format(project_id=project_id)
109
+ logger.info("Starting run for project %s", project_id)
110
+ response = self._request("POST", path, json=kwargs or {})
111
+ contract = response.json()
112
+
113
+ run_id = contract.get("runId")
114
+ if not run_id:
115
+ raise ValueError("start response missing 'runId'")
116
+
117
+ # Determine Socket.IO connection details
118
+ rt_info = contract.get("realtime", {}) or {}
119
+ ns = rt_info.get("namespace", self.socket_namespace)
120
+ url = rt_info.get("url")
121
+
122
+ if not url:
123
+ # derive host root from base_url (/api stripped)
124
+ url = self.base_url[:-4] if self.base_url.endswith("/api") else self.base_url
125
+
126
+ socket = socketio.Client(reconnection=True, logger=False, engineio_logger=False)
127
+
128
+ connect_headers: Dict[str, str] = {}
129
+ auth_payload: Optional[Dict[str, Any]] = None
130
+ if self.api_key:
131
+ connect_headers["x-api-key"] = self.api_key
132
+ connect_headers["Authorization"] = f"Bearer {self.api_key}"
133
+ # Some server middleware reads handshake.auth
134
+ auth_payload = {"token": self.api_key, "apiKey": self.api_key}
135
+
136
+ logger.info("Connecting to realtime at %s namespace %s", url, ns)
137
+ # Helpful visibility: log namespace connect/disconnect
138
+ @socket.on("connect", namespace=ns)
139
+ def _on_connect():
140
+ logger.info("Realtime connected to namespace %s (namespaces=%s)", ns, list(getattr(socket, "namespaces", {}).keys()))
141
+
142
+ @socket.on("disconnect", namespace=ns)
143
+ def _on_disconnect():
144
+ logger.warning("Realtime disconnected from namespace %s", ns)
145
+
146
+ # Robust connect strategy:
147
+ # 1) Try websocket-only (does NOT require `requests`)
148
+ # 2) Fallback to default transports (polling+websocket) if needed
149
+ # Provide a clear error message if dependencies are missing.
150
+ try:
151
+ socket.connect(
152
+ url,
153
+ headers=connect_headers,
154
+ auth=auth_payload,
155
+ namespaces=[ns],
156
+ transports=["websocket"],
157
+ wait=True,
158
+ wait_timeout=self.timeout,
159
+ )
160
+ except Exception as e1:
161
+ # If websocket-client is missing, python-socketio will fail here.
162
+ # If polling is needed and `requests` is missing, it will fail on fallback.
163
+ try:
164
+ socket.connect(
165
+ url,
166
+ headers=connect_headers,
167
+ auth=auth_payload,
168
+ namespaces=[ns],
169
+ wait=True,
170
+ wait_timeout=self.timeout,
171
+ )
172
+ except Exception as e2:
173
+ msg = (
174
+ "Realtime connection failed.\n\n"
175
+ "Tried websocket-only then default transports.\n\n"
176
+ "Common fixes:\n"
177
+ " - pip install websocket-client\n"
178
+ " - pip install requests\n\n"
179
+ f"websocket error: {e1}\n"
180
+ f"fallback error: {e2}"
181
+ )
182
+ raise socketio.exceptions.ConnectionError(msg)
183
+
184
+
185
+ # HARD ASSERT: the namespace must actually be connected, or emits will fail with:
186
+ # "/ab is not a connected namespace."
187
+ namespaces = getattr(socket, "namespaces", {}) or {}
188
+ if ns not in namespaces:
189
+ try:
190
+ socket.disconnect()
191
+ except Exception:
192
+ pass
193
+ raise socketio.exceptions.ConnectionError(
194
+ f"Socket connected but namespace '{ns}' is NOT connected. Connected namespaces: {list(namespaces.keys())}"
195
+ )
196
+
197
+ # Join run
198
+ socket.emit(endpoints.RUN_JOIN_EVENT, {"runId": run_id}, namespace=ns)
199
+
200
+ return RunSession(
201
+ client=self,
202
+ project_id=project_id,
203
+ run_id=run_id,
204
+ contract=contract,
205
+ socket=socket,
206
+ namespace=ns,
207
+ )
208
+
209
+ def stop(self, project_id: str, run_id: Optional[str] = None) -> Dict[str, Any]:
210
+ """
211
+ Stops a run on the server.
212
+
213
+ NOTE: Your server's stop endpoint expects a body with { runId }.
214
+ If run_id is not provided, server may not stop anything.
215
+ """
216
+ if not project_id:
217
+ raise ValueError("project_id must be provided")
218
+
219
+ path = endpoints.STOP_RUN.format(project_id=project_id)
220
+ payload: Dict[str, Any] = {}
221
+ if run_id:
222
+ payload["runId"] = run_id
223
+
224
+ logger.info("Stopping run for project %s runId=%s", project_id, run_id)
225
+ response = self._request("POST", path, json=payload)
226
+ return response.json()
227
+
228
+ def get_io_state(self, project_id: str) -> Dict[str, Any]:
229
+ if not project_id:
230
+ raise ValueError("project_id must be provided")
231
+ path = endpoints.IO_STATE.format(project_id=project_id)
232
+ logger.debug("Fetching IO state for project %s", project_id)
233
+ response = self._request("GET", path)
234
+ return response.json()
235
+
236
+ def get_contract(self, project_id: str) -> Dict[str, Any]:
237
+ """
238
+ Fetch the IO/constants contract without starting a run.
239
+
240
+ Requires your server to implement:
241
+ GET /api/robot/:project_id/contract
242
+ returning:
243
+ { ok: true, projectId, constants: {...}, io: {...} }
244
+ """
245
+ if not project_id:
246
+ raise ValueError("project_id must be provided")
247
+ path = endpoints.CONTRACT.format(project_id=project_id)
248
+ logger.debug("Fetching contract for project %s", project_id)
249
+ response = self._request("GET", path)
250
+ return response.json()
251
+
252
+ def sync_policies(self, project_id: str, *, policies_dir: str = "policies") -> Dict[str, Any]:
253
+ """
254
+ One command devs can run whenever they want:
255
+ - overwrites machine-owned contract files
256
+ - never overwrites user-owned policy files
257
+ """
258
+ contract = self.get_contract(project_id)
259
+ res = sync_policies_from_contract(contract, policies_dir=policies_dir)
260
+ return {
261
+ "ok": True,
262
+ "policiesDir": res.policies_dir,
263
+ "sha256": res.sha256,
264
+ "createdRewardPolicy": res.created_reward_policy,
265
+ "createdDeviationPolicy": res.created_deviation_policy,
266
+ }
267
+
268
+
269
+ def close(self) -> None:
270
+ self._http.close()
@@ -0,0 +1,315 @@
1
+ # ab_sdk/contract_scaffold.py
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import json
6
+ import os
7
+ from dataclasses import dataclass
8
+ from typing import Any, Dict, List, Optional
9
+
10
+
11
+ MACHINE_OWNED_JSON = "_contract.json"
12
+ MACHINE_OWNED_PY = "_contract.py"
13
+ MACHINE_OWNED_SHA = "_contract.sha256"
14
+
15
+ USER_REWARD_POLICY = "reward_policy.py"
16
+ USER_DEVIATION_POLICY = "error_deviation_policy.py"
17
+
18
+
19
+ def _stable_contract_view(contract: Dict[str, Any]) -> Dict[str, Any]:
20
+ """
21
+ Strip run-specific fields and keep only what a dev needs to write policies.
22
+ This is what we hash + persist.
23
+ """
24
+ io = (contract or {}).get("io") or {}
25
+ consts = (contract or {}).get("constants") or {}
26
+
27
+ # Keep only the things that define the "policy contract"
28
+ view = {
29
+ "constants": {
30
+ "gamma": int(consts.get("gamma", 64)),
31
+ "outputWindowN": int(consts.get("outputWindowN", 32)),
32
+ "feedbackN": int(consts.get("feedbackN", 128)),
33
+ "feedbackT": int(consts.get("feedbackT", consts.get("FEEDBACK_WINDOW_T", 128))),
34
+ },
35
+ "io": {
36
+ "inputs": list(io.get("inputs") or []),
37
+ "outputs": list(io.get("outputs") or []),
38
+ "feedback": list(io.get("feedback") or []),
39
+ "stdp3": {"layers": list(((io.get("stdp3") or {}).get("layers")) or [])},
40
+ },
41
+ }
42
+ return view
43
+
44
+
45
+ def _json_bytes(obj: Any) -> bytes:
46
+ # stable, deterministic serialization
47
+ s = json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
48
+ return s.encode("utf-8")
49
+
50
+
51
+ def _sha256_hex(b: bytes) -> str:
52
+ return hashlib.sha256(b).hexdigest()
53
+
54
+
55
+ def _ensure_dir(path: str) -> None:
56
+ os.makedirs(path, exist_ok=True)
57
+
58
+
59
+ def _write_text(path: str, text: str) -> None:
60
+ with open(path, "w", encoding="utf-8") as f:
61
+ f.write(text)
62
+
63
+
64
+ def _write_bytes(path: str, b: bytes) -> None:
65
+ with open(path, "wb") as f:
66
+ f.write(b)
67
+
68
+
69
+ def _exists(path: str) -> bool:
70
+ try:
71
+ return os.path.exists(path)
72
+ except Exception:
73
+ return False
74
+
75
+
76
+ def _render_contract_py(view: Dict[str, Any], sha256_hex: str) -> str:
77
+ consts = (view.get("constants") or {})
78
+ io = (view.get("io") or {})
79
+ inputs = io.get("inputs") or []
80
+ outputs = io.get("outputs") or []
81
+ feedback = io.get("feedback") or []
82
+ stdp_layers = ((io.get("stdp3") or {}).get("layers")) or []
83
+
84
+ input_ids = [str(x.get("id")) for x in inputs if isinstance(x, dict) and x.get("id")]
85
+ output_ids = [str(x.get("id")) for x in outputs if isinstance(x, dict) and x.get("id")]
86
+ feedback_ids = [str(x.get("id")) for x in feedback if isinstance(x, dict) and x.get("id")]
87
+
88
+ # Keep a tiny amount of metadata that’s helpful for policy authors
89
+ feedback_meta = []
90
+ for fb in feedback:
91
+ if not isinstance(fb, dict):
92
+ continue
93
+ feedback_meta.append(
94
+ {
95
+ "id": str(fb.get("id")),
96
+ "n": int(fb.get("n", consts.get("feedbackN", 128))),
97
+ "fromOutput": fb.get("fromOutput"),
98
+ "outputKind": fb.get("outputKind"),
99
+ }
100
+ )
101
+
102
+ return f'''"""
103
+ AUTO-GENERATED FILE. DO NOT EDIT.
104
+
105
+ This file is machine-owned and overwritten whenever you "sync contract".
106
+ It is meant to give developers the IDs they need for:
107
+ - per-layer STDP3 reward (by stdp layer id)
108
+ - per-feedback deviation (by feedback input id)
109
+
110
+ If this changes, your graph/IO changed. Compare sha256 or diff _contract.json.
111
+ """
112
+
113
+ from __future__ import annotations
114
+ from dataclasses import dataclass
115
+ from typing import Dict, List, Optional, TypedDict
116
+
117
+
118
+ CONTRACT_SHA256 = "{sha256_hex}"
119
+
120
+ # Constants (as reported by server)
121
+ GAMMA: int = {int(consts.get("gamma", 64))}
122
+ OUTPUT_WINDOW_N: int = {int(consts.get("outputWindowN", 32))}
123
+ FEEDBACK_N: int = {int(consts.get("feedbackN", 128))}
124
+ FEEDBACK_T: int = {int(consts.get("feedbackT", 128))}
125
+
126
+ # IDs you typically need in policies
127
+ INPUT_IDS: List[str] = {input_ids!r}
128
+ OUTPUT_IDS: List[str] = {output_ids!r}
129
+ FEEDBACK_IDS: List[str] = {feedback_ids!r}
130
+
131
+ # Per-layer reward keys (STDP3)
132
+ STDP3_LAYERS: List[str] = {list(map(str, stdp_layers))!r}
133
+
134
+
135
+ class FeedbackInfo(TypedDict, total=False):
136
+ id: str
137
+ n: int
138
+ fromOutput: Optional[str]
139
+ outputKind: Optional[str]
140
+
141
+
142
+ FEEDBACK_INFO: List[FeedbackInfo] = {feedback_meta!r}
143
+ '''
144
+
145
+
146
+ def _render_default_reward_policy() -> str:
147
+ return '''"""
148
+ User-owned policy file (created once; never overwritten).
149
+
150
+ Implement:
151
+ compute_reward(summary, stdp_layers) -> (global_reward, by_layer)
152
+
153
+ - global_reward: float in [0,1] (or whatever your server expects)
154
+ - by_layer: dict mapping STDP3 layer-id -> reward float (same range)
155
+
156
+ You can import ids from:
157
+ from policies._contract import STDP3_LAYERS
158
+
159
+ Note: Keep this deterministic. No RNG here unless you *explicitly* want it.
160
+ """
161
+
162
+ from __future__ import annotations
163
+
164
+ from dataclasses import dataclass
165
+ from typing import Dict, Optional, Tuple, List
166
+
167
+
168
+ @dataclass
169
+ class CycleSummary:
170
+ # Example fields – you can change these to match your controller summary.
171
+ startDist: Optional[float] = None
172
+ endDist: Optional[float] = None
173
+ success: bool = False
174
+
175
+
176
+ def compute_reward(
177
+ summary: Optional[CycleSummary],
178
+ *,
179
+ stdp_layers: List[str],
180
+ ) -> Tuple[float, Dict[str, float]]:
181
+ """
182
+ Return (global_reward, by_layer).
183
+
184
+ Default behavior:
185
+ - If success: reward = 1.0
186
+ - Else if distance improved: reward = 0.6
187
+ - Else: reward = 0.4
188
+
189
+ Change freely.
190
+ """
191
+ if summary is None:
192
+ r = 0.5
193
+ else:
194
+ if summary.success:
195
+ r = 1.0
196
+ elif (summary.startDist is not None and summary.endDist is not None and summary.endDist < summary.startDist):
197
+ r = 0.6
198
+ else:
199
+ r = 0.4
200
+
201
+ by_layer = {layer_id: float(r) for layer_id in (stdp_layers or [])}
202
+ return float(r), by_layer
203
+ '''
204
+
205
+
206
+ def _render_default_deviation_policy() -> str:
207
+ return '''"""
208
+ User-owned policy file (created once; never overwritten).
209
+
210
+ Goal:
211
+ For EACH feedback input id (fb.id), produce deviation_f32:
212
+ dev: list[float] length T, values typically in [-1,1]
213
+ The server will convert dev into a feedback raster using baseline + your corrections.
214
+
215
+ You can import ids from:
216
+ from policies._contract import FEEDBACK_IDS, FEEDBACK_INFO
217
+
218
+ You decide the meaning of dev[t] (closer/farther, torque error, etc).
219
+ Keep deterministic.
220
+ """
221
+
222
+ from __future__ import annotations
223
+
224
+ from dataclasses import dataclass
225
+ from typing import Dict, List, Optional
226
+
227
+
228
+ @dataclass
229
+ class DeviationContext:
230
+ """
231
+ Put whatever you want here: distances by timestep, joint errors, etc.
232
+ The controller can pass this in when a feedback need arrives.
233
+ """
234
+ # Example:
235
+ dist_by_t: Optional[Dict[int, float]] = None
236
+
237
+
238
+ def compute_deviation(
239
+ feedback_id: str,
240
+ *,
241
+ T: int,
242
+ ctx: Optional[DeviationContext] = None,
243
+ ) -> List[float]:
244
+ """
245
+ Return dev[t] length T.
246
+
247
+ Default is all zeros (no correction).
248
+ Customize per feedback_id if you want different deviation semantics per channel.
249
+ """
250
+ _ = feedback_id
251
+ _ = ctx
252
+ return [0.0] * int(T)
253
+ '''
254
+
255
+
256
+ @dataclass
257
+ class ScaffoldResult:
258
+ policies_dir: str
259
+ wrote_contract: bool
260
+ created_reward_policy: bool
261
+ created_deviation_policy: bool
262
+ sha256: str
263
+
264
+
265
+ def sync_policies_from_contract(
266
+ contract: Dict[str, Any],
267
+ *,
268
+ policies_dir: str = "policies",
269
+ ) -> ScaffoldResult:
270
+ """
271
+ - Always overwrites:
272
+ policies/_contract.json
273
+ policies/_contract.py
274
+ policies/_contract.sha256
275
+ - Creates once (never overwrites):
276
+ policies/reward_policy.py
277
+ policies/error_deviation_policy.py
278
+ """
279
+ _ensure_dir(policies_dir)
280
+
281
+ view = _stable_contract_view(contract)
282
+ jb = _json_bytes(view)
283
+ sha = _sha256_hex(jb)
284
+
285
+ # Machine-owned outputs (always overwritten)
286
+ contract_json_path = os.path.join(policies_dir, MACHINE_OWNED_JSON)
287
+ contract_py_path = os.path.join(policies_dir, MACHINE_OWNED_PY)
288
+ contract_sha_path = os.path.join(policies_dir, MACHINE_OWNED_SHA)
289
+
290
+ _write_text(contract_json_path, json.dumps(view, indent=2, ensure_ascii=False) + "\n")
291
+ _write_text(contract_py_path, _render_contract_py(view, sha))
292
+ _write_text(contract_sha_path, sha + "\n")
293
+
294
+ # User-owned policies (create once)
295
+ reward_path = os.path.join(policies_dir, USER_REWARD_POLICY)
296
+ dev_path = os.path.join(policies_dir, USER_DEVIATION_POLICY)
297
+
298
+ created_reward = False
299
+ created_dev = False
300
+
301
+ if not _exists(reward_path):
302
+ _write_text(reward_path, _render_default_reward_policy())
303
+ created_reward = True
304
+
305
+ if not _exists(dev_path):
306
+ _write_text(dev_path, _render_default_deviation_policy())
307
+ created_dev = True
308
+
309
+ return ScaffoldResult(
310
+ policies_dir=policies_dir,
311
+ wrote_contract=True,
312
+ created_reward_policy=created_reward,
313
+ created_deviation_policy=created_dev,
314
+ sha256=sha,
315
+ )
ab_sdk/endpoints.py ADDED
@@ -0,0 +1,48 @@
1
+ """API endpoint definitions.
2
+
3
+ This module defines the HTTP endpoint paths used by the SDK relative
4
+ to the base URL provided when instantiating :class:`~ab_sdk.client.ABClient`.
5
+ Keeping these values in one place makes it easy to audit and update
6
+ the API surface when the server changes.
7
+
8
+ Note that all paths are joined to the base URL (for example
9
+ ``https://artificialbrains.app/api``) so you should not include
10
+ ``/api`` at the beginning when constructing the `ABClient`.
11
+ """
12
+
13
+ START_RUN = "/robot/{project_id}/start"
14
+ """POST start a new run. Replace ``{project_id}`` with the target project identifier."""
15
+
16
+ STOP_RUN = "/robot/{project_id}/stop"
17
+ """POST stop the current run for the project. Safe to call even if no run is active."""
18
+
19
+ IO_STATE = "/robot/{project_id}/io/state"
20
+ """GET fetch the current IO state (needed inputs, cycle, etc.) for resynchronization."""
21
+
22
+ # The following endpoints are used implicitly via Socket.IO events. They are
23
+ # documented here for completeness; your backend should implement the
24
+ # corresponding handlers on its realtime gateway.
25
+
26
+ RUN_JOIN_EVENT = "run:join"
27
+ """Client emits this event to join the room for a given run ID."""
28
+
29
+ IO_NEED_EVENT = "io:need"
30
+ """Server emits this event to inform the client which inputs are needed for the next cycle."""
31
+
32
+ IO_CHUNK_EVENT = "io:chunk"
33
+ """Client emits this event to send raw input data (image/audio/lidar/etc.) or feedback rasters."""
34
+
35
+ ROBOT_STATE_EVENT = "robot:state"
36
+ """Client emits this event periodically with the robot's current joint positions, velocities and gripper state."""
37
+
38
+ ROBOT_CMD_EVENT = "robot:cmd"
39
+ """Server may emit this legacy event with direct joint commands. Newer versions omit this in favour of decoding on the client."""
40
+
41
+ CYCLE_UPDATE_EVENT = "cycle:update"
42
+ """Server emits this event after each cycle with the latest telemetry (spike activity, error, etc.)."""
43
+
44
+ LEARN_REWARD_EVENT = "learn:reward"
45
+ """Client emits this event with global and per‑layer reward values for STDP3 learning."""
46
+
47
+ CONTRACT = "/robot/{project_id}/contract"
48
+ """GET fetch the IO/constants contract without starting a run."""