chipwright 0.2.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.
Files changed (49) hide show
  1. chipwright/__init__.py +11 -0
  2. chipwright/adapt.py +57 -0
  3. chipwright/build.py +329 -0
  4. chipwright/cache.py +65 -0
  5. chipwright/cli.py +242 -0
  6. chipwright/config.py +106 -0
  7. chipwright/data/behavior_prompts.yaml +108 -0
  8. chipwright/data/index.yaml +327 -0
  9. chipwright/device.py +143 -0
  10. chipwright/device_adb.py +121 -0
  11. chipwright/env.py +229 -0
  12. chipwright/errors.py +41 -0
  13. chipwright/llm_reference.py +114 -0
  14. chipwright/modalities/__init__.py +3 -0
  15. chipwright/modalities/asr_encoder.py +90 -0
  16. chipwright/modalities/cv.py +34 -0
  17. chipwright/modalities/llm.py +300 -0
  18. chipwright/recipe.py +205 -0
  19. chipwright/registry.py +65 -0
  20. chipwright/resolver.py +197 -0
  21. chipwright/run.py +173 -0
  22. chipwright/sdk.py +140 -0
  23. chipwright/tags.py +56 -0
  24. chipwright/task_eval.py +190 -0
  25. chipwright/tests/conftest.py +12 -0
  26. chipwright/tests/test_adapt.py +61 -0
  27. chipwright/tests/test_build.py +279 -0
  28. chipwright/tests/test_cache.py +147 -0
  29. chipwright/tests/test_device_adb.py +140 -0
  30. chipwright/tests/test_env.py +73 -0
  31. chipwright/tests/test_env_adb.py +120 -0
  32. chipwright/tests/test_llm_modality.py +259 -0
  33. chipwright/tests/test_packaging.py +147 -0
  34. chipwright/tests/test_precision_support.py +128 -0
  35. chipwright/tests/test_registry.py +107 -0
  36. chipwright/tests/test_resolver.py +93 -0
  37. chipwright/tests/test_run_llm.py +47 -0
  38. chipwright/tests/test_tags.py +95 -0
  39. chipwright/tests/test_task_eval.py +164 -0
  40. chipwright/tests/test_verify.py +92 -0
  41. chipwright/tests/test_verify_llm.py +244 -0
  42. chipwright/verify.py +24 -0
  43. chipwright/verify_llm.py +203 -0
  44. chipwright-0.2.0.dist-info/METADATA +268 -0
  45. chipwright-0.2.0.dist-info/RECORD +49 -0
  46. chipwright-0.2.0.dist-info/WHEEL +5 -0
  47. chipwright-0.2.0.dist-info/entry_points.txt +2 -0
  48. chipwright-0.2.0.dist-info/licenses/LICENSE +21 -0
  49. chipwright-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,327 @@
1
+ # Chipwright registry — the index is a single reviewable file; artifacts live by hash in object storage.
2
+ # The registry's unit is a BUILD VARIANT, not a model: one model version fans out to many artifacts,
3
+ # one per (arch × quant × shape × SDK-range). "verified:" is a record, not a badge — the evidence of
4
+ # the run that validated the variant, so a reader who distrusts the claim can check the inputs.
5
+ #
6
+ # Board context for every verified record below: QCS6490 / RB3 Gen2, Hexagon v68, QAIRT 2.38.0.
7
+ # The HTP is w8a16-only on v68 (no fp16); LLMs go all-integer w4a16. Our artifacts are built and
8
+ # tested at QAIRT 2.37.1 with a compatible range >=2.34,<2.40, so the board's in-field 2.38.0 is
9
+ # in-range (resolves USE_WITH_WARN, not a refusal).
10
+ registry_schema: 1
11
+ models:
12
+ - model: laya-decision
13
+ source: convaiinnovations/laya
14
+ license: apache-2.0
15
+ modality: typed-decision
16
+ version: 1.0.0
17
+ variants:
18
+ - arch: v68
19
+ quant: w8a16
20
+ shape: win256
21
+ sdk_tested: 2.37.1
22
+ sdk_compatible: '>=2.34,<2.40'
23
+ ext: qnnctx
24
+ url: https://huggingface.co/nameissakthi/laya-qcs6490-encoder/resolve/main/ctx/laya_encoder_256_sqnr_ctx.bin
25
+ sha256: b338a4067e9d9d3147aa8e016892450e51018de6db425ef2e3a4e533a4548a54
26
+ verified:
27
+ 'on': QCS6490
28
+ loads: true
29
+ vs_reference:
30
+ backend: onnxruntime-cpu
31
+ metric: hidden_state_pearson
32
+ value: 0.88
33
+ latency_ms:
34
+ p50: 84
35
+ - model: laya-intent
36
+ source: convaiinnovations/laya
37
+ license: apache-2.0
38
+ modality: intent-slot
39
+ version: 1.0.0
40
+ variants:
41
+ - arch: v68
42
+ quant: w8a16
43
+ shape: win256
44
+ sdk_tested: 2.37.1
45
+ sdk_compatible: '>=2.34,<2.40'
46
+ ext: qnnctx
47
+ recipe: recipes/laya-intent/1.0.0.yaml
48
+ verified:
49
+ 'on': QCS6490 (soc_id 498)
50
+ loads: true
51
+ vs_reference:
52
+ metric: top1_vs_fp32
53
+ value: 1.0
54
+ ptq_baseline: 0.425
55
+ task_metric:
56
+ name: intent_top1
57
+ value: 1.0
58
+ note: held-out synthetic test; fp32 also ~100% — proves QAT removed the on-device quantization loss, not real-world accuracy
59
+ latency_ms:
60
+ note: 120 inputs in ~3s on-device
61
+ - model: zipformer-enin
62
+ source: nameissakthi/zipformer-enin-qairt-aimet
63
+ license: apache-2.0
64
+ modality: asr
65
+ version: 7.0.0
66
+ variants:
67
+ - arch: v68
68
+ quant: w8a16
69
+ shape: win39
70
+ sdk_tested: 2.37.1
71
+ sdk_compatible: '>=2.34,<2.40'
72
+ ext: qnnctx
73
+ run:
74
+ modality: asr_encoder
75
+ board_name: zipformer_encoder_w8a16_ctx.bin
76
+ threshold: 0.95
77
+ recipe: recipes/zipformer-enin/7.0.0.yaml
78
+ verified:
79
+ 'on': QCS6490 (soc_id 498)
80
+ date: 2026-10-08
81
+ loads: true
82
+ vs_reference:
83
+ backend: onnxruntime-cpu-float
84
+ metric: wer_delta
85
+ htp_wer: 0.27
86
+ cpu_float_wer: 0.287
87
+ note: w8a16 ~lossless vs float
88
+ latency_ms:
89
+ note: enc ~16.4 ms/chunk, RTF ~0.05 persistent-context
90
+ - model: supertonic-tts
91
+ source: supertonic/supertonic-tts
92
+ license: apache-2.0
93
+ modality: tts
94
+ version: 1.0.0
95
+ variants:
96
+ - arch: v68
97
+ quant: w8a16
98
+ shape: multigraph
99
+ sdk_tested: 2.37.1
100
+ sdk_compatible: '>=2.34,<2.40'
101
+ ext: qnnctx
102
+ recipe: recipes/supertonic-tts/1.0.0.yaml
103
+ verified:
104
+ 'on': QCS6490 (soc_id 498)
105
+ loads: true
106
+ vs_reference:
107
+ metric: logmel_corr_dtw
108
+ value: 0.77
109
+ latency_ms:
110
+ note: ~3.8s wall / 3.07s audio, cold
111
+ note: small residual buzz; w8a16 flow-matching vocoder
112
+ - model: whisper-small-en
113
+ source: openai/whisper-small.en
114
+ license: mit
115
+ modality: asr
116
+ version: 1.2.0
117
+ variants:
118
+ - arch: v68
119
+ quant: w8a16
120
+ shape: win30s
121
+ sdk_tested: 2.37.1
122
+ sdk_compatible: '>=2.34,<2.40'
123
+ ext: qnnctx
124
+ recipe: recipes/whisper-small-en/1.2.0.yaml
125
+ - model: qwen2.5-1.5b-instruct
126
+ source: Qwen/Qwen2.5-1.5B-Instruct
127
+ license: apache-2.0
128
+ modality: llm
129
+ version: 1.0.0
130
+ variants:
131
+ - arch: v68
132
+ quant: w4a16
133
+ shape: ctx4096
134
+ sdk_tested: 2.37.1
135
+ sdk_compatible: '>=2.34,<2.40'
136
+ ext: qnnctx
137
+ recipe: recipes/qwen2.5-1.5b/1.0.0.yaml
138
+ - arch: v73
139
+ quant: w4a16
140
+ shape: ctx4096
141
+ sdk_tested: 2.37.1
142
+ sdk_compatible: '>=2.34,<2.40'
143
+ ext: qnnctx
144
+ recipe: recipes/qwen2.5-1.5b/1.0.0.yaml
145
+ - model: qwen2.5-7b-instruct
146
+ source: Qwen/Qwen2.5-7B-Instruct
147
+ license: apache-2.0
148
+ modality: llm
149
+ version: 1.0.0
150
+ variants:
151
+ - arch: v73
152
+ quant: w4a16
153
+ shape: ctx4096
154
+ sdk_tested: 2.37.1
155
+ sdk_compatible: '>=2.34,<2.40'
156
+ ext: qnnctx
157
+ recipe: recipes/qwen2.5-7b/1.0.0.yaml
158
+ - arch: v75
159
+ quant: w4a16
160
+ shape: ctx4096
161
+ sdk_tested: 2.37.1
162
+ sdk_compatible: '>=2.34,<2.40'
163
+ ext: qnnctx
164
+ recipe: recipes/qwen2.5-7b/1.0.0.yaml
165
+ - model: mobilenet-v2
166
+ source: qualcomm/MobileNet-v2
167
+ license: bsd-3-clause
168
+ modality: cv-classifier
169
+ version: 1.0.0
170
+ variants:
171
+ - arch: v68
172
+ quant: w8a16
173
+ shape: img224
174
+ sdk_tested: 2.37.1
175
+ sdk_compatible: '>=2.34,<2.40'
176
+ ext: dlc
177
+ run:
178
+ modality: cv
179
+ board_name: mobilenet
180
+ meta: ''
181
+ threshold: 0.95
182
+ verified:
183
+ 'on': QCS6490 (soc_id 498)
184
+ date: 2026-10-09
185
+ loads: true
186
+ vs_reference:
187
+ backend: onnxruntime-cpu-float
188
+ metric: cosine_min
189
+ value: 0.956
190
+ note: real image (ImageNet 'Samoyed'); random-noise input gives 0.776 and the gate correctly FAILs it
191
+ latency_ms:
192
+ note: single 224x224 image; DLC compiled to context on-board
193
+ - model: quicksrnet-small
194
+ source: qualcomm/QuickSRNetSmall
195
+ license: bsd-3-clause
196
+ modality: cv-superres
197
+ version: 1.0.0
198
+ variants:
199
+ - arch: v68
200
+ quant: w8a8
201
+ shape: img128
202
+ sdk_tested: 2.37.1
203
+ sdk_compatible: '>=2.34,<2.40'
204
+ ext: dlc
205
+ run:
206
+ modality: cv
207
+ board_name: quicksrnet
208
+ meta: ''
209
+ threshold: 0.9
210
+ verified:
211
+ 'on': QCS6490 (soc_id 498)
212
+ date: 2026-10-09
213
+ loads: true
214
+ vs_reference:
215
+ backend: onnxruntime-cpu-float
216
+ metric: cosine_min
217
+ value: 0.9999
218
+ note: 4x super-resolution; w8a8 upscaled image is near-lossless vs float
219
+ latency_ms:
220
+ note: 128x128 -> 512x512; DLC compiled to context on-board
221
+ - model: fcn-resnet50
222
+ source: qualcomm/FCN-ResNet50
223
+ license: bsd-3-clause
224
+ modality: cv-segmentation
225
+ version: 1.0.0
226
+ variants:
227
+ - arch: v68
228
+ quant: w8a8
229
+ shape: img520
230
+ sdk_tested: 2.37.1
231
+ sdk_compatible: '>=2.34,<2.40'
232
+ ext: dlc
233
+ run:
234
+ modality: cv
235
+ board_name: fcn
236
+ meta: ''
237
+ threshold: 0.9
238
+ verified:
239
+ 'on': QCS6490 (soc_id 498)
240
+ date: 2026-10-09
241
+ loads: true
242
+ vs_reference:
243
+ backend: onnxruntime-cpu-float
244
+ metric: cosine_min
245
+ value: 0.998
246
+ note: per-pixel semantic mask; w8a8 near-lossless vs float
247
+ latency_ms:
248
+ note: 520x520; DLC compiled to context on-board (spills to DDR)
249
+ - model: mobilenet-cv-built
250
+ source: qualcomm/MobileNet-v2
251
+ license: bsd-3-clause
252
+ modality: cv-classification
253
+ version: 1.0.0
254
+ variants:
255
+ - arch: v68
256
+ quant: w8a16
257
+ shape: img224
258
+ sdk_tested: 2.37.1
259
+ sdk_compatible: '>=2.34,<2.40'
260
+ ext: dlc
261
+ run:
262
+ modality: cv
263
+ board_name: mobilenet_built
264
+ meta: ''
265
+ threshold: 0.95
266
+ verified:
267
+ 'on': QCS6490 (soc_id 498)
268
+ date: 2026-10-09
269
+ loads: true
270
+ vs_reference:
271
+ backend: onnxruntime-cpu-float
272
+ metric: cosine_min
273
+ value: 0.956
274
+ note: the artifact chipwright BUILT (real qairt-converter on the A100) then verified on the board — full build->run->verify
275
+ loop
276
+ latency_ms:
277
+ note: built via dlc_route; context compiled on-board
278
+ - model: depth-anything-v2
279
+ source: qualcomm/Depth-Anything-V2
280
+ license: apache-2.0
281
+ modality: cv-depth
282
+ version: 1.0.0
283
+ variants:
284
+ - arch: v68
285
+ quant: w8a16
286
+ shape: img518
287
+ sdk_tested: 2.37.1
288
+ sdk_compatible: '>=2.34,<2.40'
289
+ ext: dlc
290
+ run:
291
+ modality: cv
292
+ board_name: depth
293
+ meta: ''
294
+ threshold: 0.9
295
+ verified:
296
+ "on": QCS6490 (soc_id 498)
297
+ date: 2026-10-09
298
+ loads: true
299
+ vs_reference:
300
+ backend: onnxruntime-cpu
301
+ metric: depth_cosine
302
+ value: 0.99996
303
+ - model: movenet
304
+ source: qualcomm/MoveNet
305
+ license: apache-2.0
306
+ modality: cv-pose
307
+ version: 1.0.0
308
+ variants:
309
+ - arch: v68
310
+ quant: w8a16
311
+ shape: img192
312
+ sdk_tested: 2.37.1
313
+ sdk_compatible: '>=2.34,<2.40'
314
+ ext: dlc
315
+ run:
316
+ modality: cv
317
+ board_name: pose
318
+ meta: ''
319
+ threshold: 0.9
320
+ verified:
321
+ "on": QCS6490 (soc_id 498)
322
+ date: 2026-10-09
323
+ loads: true
324
+ vs_reference:
325
+ backend: onnxruntime-cpu
326
+ metric: keypoint_cosine
327
+ value: 0.91607
chipwright/device.py ADDED
@@ -0,0 +1,143 @@
1
+ """Remote QNN runner — push a context binary to the board and execute it over SSH.
2
+
3
+ The device transport the `run` verb uses. It is deliberately quantization-agnostic: it moves raw
4
+ bytes in the graph's native dtype and runs `qnn-net-run --retrieve_context`. Quant/dequant belongs
5
+ to the modality runner, which owns the artifact's I/O scales. (Generalized from the proven
6
+ board-side driver; here it drives the board from the host.)
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import glob
11
+ import os
12
+ import shutil
13
+ import subprocess
14
+ import tempfile
15
+ import time
16
+
17
+ import numpy as np
18
+
19
+
20
+ def _require_sshpass():
21
+ """`sshpass` is a system binary (not a pip package) the SSH transport shells out to.
22
+
23
+ Fail here, by name, with the install hint — not later with a cryptic FileNotFoundError from
24
+ subprocess — so a missing system dependency is reported the way the framework reports everything:
25
+ plainly, naming the fix.
26
+ """
27
+ if shutil.which("sshpass") is None:
28
+ raise RuntimeError(
29
+ "chipwright run needs the `sshpass` system binary to reach the board over SSH, and it "
30
+ "is not on PATH. Install it: `brew install hudochenkov/sshpass/sshpass` (macOS) or "
31
+ "`apt-get install sshpass` (Debian/Ubuntu).")
32
+
33
+
34
+ def _ssh(pw, port, dest, *cmd, timeout=600, capture=True):
35
+ base = ["sshpass", "-p", pw, "ssh", "-o", "StrictHostKeyChecking=no",
36
+ "-o", "UserKnownHostsFile=/dev/null", "-o", "ConnectTimeout=20",
37
+ "-o", "PreferredAuthentications=password", "-o", "PubkeyAuthentication=no",
38
+ "-p", str(port), dest, *cmd]
39
+ return subprocess.run(base, capture_output=capture, text=True, timeout=timeout)
40
+
41
+
42
+ def _scp(pw, port, *args, timeout=1800):
43
+ base = ["sshpass", "-p", pw, "scp", "-P", str(port), "-o", "StrictHostKeyChecking=no",
44
+ "-o", "UserKnownHostsFile=/dev/null", "-o", "ConnectTimeout=20",
45
+ "-o", "PreferredAuthentications=password", "-o", "PubkeyAuthentication=no", *args]
46
+ return subprocess.run(base, capture_output=True, text=True, timeout=timeout)
47
+
48
+
49
+ class RemoteQNN:
50
+ def __init__(self, host, user, pw, port=22, backend="/usr/lib/libQnnHtp.so",
51
+ board_dir="/opt/chipwright"):
52
+ _require_sshpass()
53
+ self.host, self.user, self.pw, self.port = host, user, pw, int(port)
54
+ self.backend, self.board_dir = backend, board_dir
55
+ self.dest = f"{user}@{host}"
56
+
57
+ def _retry(self, fn, tries=4, wait=3):
58
+ last = None
59
+ for i in range(tries):
60
+ try:
61
+ r = fn()
62
+ if getattr(r, "returncode", 0) == 0:
63
+ return r
64
+ last = r
65
+ except Exception as e: # pragma: no cover
66
+ last = e
67
+ time.sleep(wait)
68
+ return last
69
+
70
+ def ensure_ctx(self, local_path, name):
71
+ """Push an artifact to the board and return a runnable context-binary path (cached).
72
+
73
+ A DLC is the durable artifact; the HTP context binary is per-target, so a `.dlc` is compiled
74
+ into a context binary ON the board (2 MB VTCM) — regenerate, don't ship brittle. A prebuilt
75
+ context binary (`.bin`/`.qnnctx`) is pushed as-is.
76
+ """
77
+ self._retry(lambda: _ssh(self.pw, self.port, self.dest, f"mkdir -p {self.board_dir}", timeout=40))
78
+ if local_path.endswith(".dlc"):
79
+ dlc_board = f"{self.board_dir}/{name}"
80
+ ctx_board = f"{self.board_dir}/ctx_{name}/{name}_ctx.bin"
81
+ present = _ssh(self.pw, self.port, self.dest, f"test -f {ctx_board} && echo OK", timeout=40)
82
+ if "OK" not in (present.stdout or ""):
83
+ r = self._retry(lambda: _scp(self.pw, self.port, local_path, f"{self.dest}:{dlc_board}"))
84
+ if getattr(r, "returncode", 1) != 0:
85
+ raise RuntimeError(f"failed to push DLC: {getattr(r,'stderr','')}")
86
+ env = "export ADSP_LIBRARY_PATH=/usr/lib/rfsa/adsp:/usr/lib LD_LIBRARY_PATH=/usr/lib"
87
+ gen = (f"cd {self.board_dir} && {env} && rm -rf ctx_{name} && "
88
+ f"qnn-context-binary-generator --backend {self.backend} "
89
+ f"--model /usr/lib/libQnnModelDlc.so --dlc_path {name} "
90
+ f"--binary_file {name}_ctx --output_dir ctx_{name}")
91
+ g = self._retry(lambda: _ssh(self.pw, self.port, self.dest, gen, timeout=1200))
92
+ check = _ssh(self.pw, self.port, self.dest, f"test -f {ctx_board} && echo OK", timeout=40)
93
+ if "OK" not in (check.stdout or ""):
94
+ raise RuntimeError("on-board context-gen failed:\n" + (getattr(g, "stdout", "") or "")[-500:])
95
+ return ctx_board
96
+ board_path = f"{self.board_dir}/{name}"
97
+ present = _ssh(self.pw, self.port, self.dest, f"test -f {board_path} && echo OK", timeout=40)
98
+ if "OK" not in (present.stdout or ""):
99
+ r = self._retry(lambda: _scp(self.pw, self.port, local_path, f"{self.dest}:{board_path}"))
100
+ if getattr(r, "returncode", 1) != 0:
101
+ raise RuntimeError(f"failed to push context binary: {getattr(r,'stderr','')}")
102
+ return board_path
103
+
104
+ def run(self, ctx_board_path, feeds_native, outputs, native_io=True):
105
+ """feeds_native: {name: ndarray}. outputs: [{name,dtype,shape}]. Returns {name: ndarray[shape]}.
106
+
107
+ native_io=True: raws are the graph's native quantized dtype (the modality quantizes/dequantizes).
108
+ native_io=False: feed float32, let qnn-net-run quantize the input and dequantize the output —
109
+ the simple path for graphs with float I/O semantics (e.g. an image classifier).
110
+ """
111
+ dt = {"uint16": np.uint16, "int32": np.int32, "float32": np.float32}
112
+ with tempfile.TemporaryDirectory() as td:
113
+ parts = []
114
+ for n, a in feeds_native.items():
115
+ np.ascontiguousarray(a).tofile(f"{td}/{n}.raw")
116
+ parts.append(f"{n}:={n}.raw")
117
+ open(f"{td}/il.txt", "w").write(" ".join(parts) + "\n")
118
+ rdir = f"{self.board_dir}/req_{int(time.time()*1000)}"
119
+ self._retry(lambda: _ssh(self.pw, self.port, self.dest, f"mkdir -p {rdir}", timeout=40))
120
+ files = [f"{td}/{f}" for f in os.listdir(td)]
121
+ r = self._retry(lambda: _scp(self.pw, self.port, *files, f"{self.dest}:{rdir}/"))
122
+ if getattr(r, "returncode", 1) != 0:
123
+ raise RuntimeError("failed to push inputs")
124
+ env = "export ADSP_LIBRARY_PATH=/usr/lib/rfsa/adsp:/usr/lib LD_LIBRARY_PATH=/usr/lib"
125
+ flags = " --use_native_input_files --use_native_output_files" if native_io else ""
126
+ cmd = (f"cd {rdir} && {env} && qnn-net-run --backend {self.backend} "
127
+ f"--retrieve_context {ctx_board_path} --input_list il.txt --output_dir out" + flags)
128
+ run = self._retry(lambda: _ssh(self.pw, self.port, self.dest, cmd, timeout=600))
129
+ if getattr(run, "returncode", 1) != 0:
130
+ _ssh(self.pw, self.port, self.dest, f"rm -rf {rdir}", timeout=30)
131
+ raise RuntimeError("qnn-net-run failed:\n" + (getattr(run, "stdout", "") or "")[-500:])
132
+ self._retry(lambda: _scp(self.pw, self.port, "-r", f"{self.dest}:{rdir}/out", td))
133
+ _ssh(self.pw, self.port, self.dest, f"rm -rf {rdir}", timeout=30)
134
+ res = {}
135
+ for spec in outputs:
136
+ nm, d, sh = spec["name"], dt[spec.get("dtype", "float32")], tuple(spec["shape"])
137
+ fs = (glob.glob(f"{td}/out/**/{nm}_native.raw", recursive=True)
138
+ or glob.glob(f"{td}/out/**/{nm}.raw", recursive=True)
139
+ or glob.glob(f"{td}/out/**/{nm}*.raw", recursive=True))
140
+ if not fs:
141
+ raise RuntimeError(f"missing output {nm}")
142
+ res[nm] = np.fromfile(fs[0], d).reshape(sh)
143
+ return res
@@ -0,0 +1,121 @@
1
+ """ADB transport — push an LLM bundle to an Android board and drive it over adb.
2
+
3
+ The device layer the `llm` modality uses. The existing SSH transport (`device.py::RemoteQNN`) reaches
4
+ a Linux board (QCS6490/RB3, the QDC fleet) over `ssh`/`scp`; a Snapdragon *phone* class target speaks
5
+ `adb` instead. This module is the adb parallel — same shape (`shell`/`push`/`pull`/`exists`,
6
+ retry-wrapped, a preflight that fails by name) so the modality can hold one interface and pick a
7
+ transport by target, exactly as the resolver holds one grammar across arches.
8
+
9
+ Two things are deliberate and load-bearing (both learned on real silicon, see spec 002 edge cases):
10
+
11
+ * **push one file at a time.** A single multi-GB `adb push` of a *directory* (an LLM Genie bundle is
12
+ N context binaries, often several GB) can wedge the device's adb daemon — the push stalls and every
13
+ later command hangs. `push_file` moves exactly one regular file and refuses a directory by name, so
14
+ the modality walks the bundle file-by-file rather than handing adb a tree that bricks the session.
15
+ * **`adb` is a system binary, not a pip dep.** Like `sshpass` for the SSH transport, it is shelled
16
+ out to; `_require_adb()` fails here, by name, with the install hint — not later with a cryptic
17
+ FileNotFoundError from subprocess.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import os
22
+ import shutil
23
+ import subprocess
24
+ import time
25
+
26
+
27
+ def _require_adb():
28
+ """`adb` is a system binary (Android platform-tools), not a pip package, the transport shells to.
29
+
30
+ Fail here, by name, with the install hint — mirroring `device.py::_require_sshpass` — so a missing
31
+ system dependency is reported the way the framework reports everything: plainly, naming the fix.
32
+ """
33
+ if shutil.which("adb") is None:
34
+ raise RuntimeError(
35
+ "chipwright llm needs the `adb` system binary to reach the board over Android Debug "
36
+ "Bridge, and it is not on PATH. Install the Android platform-tools: "
37
+ "`brew install --cask android-platform-tools` (macOS) or "
38
+ "`apt-get install android-tools-adb` (Debian/Ubuntu).")
39
+
40
+
41
+ class AdbDevice:
42
+ """Drive one Android board over adb. `serial` disambiguates when several are attached.
43
+
44
+ The surface the LLM modality calls: `shell(cmd) -> CompletedProcess`, `push_file(local, remote)`
45
+ (one regular file), `pull(remote, local)`, `exists(remote) -> bool`. Every call is wrapped in the
46
+ same bounded retry as `RemoteQNN` because adb — like ssh to a thermally-throttling board — drops a
47
+ command now and then and a blind retry is the right, honest fix.
48
+ """
49
+
50
+ def __init__(self, serial=None, board_dir="/data/local/tmp/chipwright", adb_bin="adb"):
51
+ _require_adb()
52
+ self.serial = serial
53
+ self.board_dir = board_dir
54
+ self.adb_bin = adb_bin
55
+
56
+ # --- plumbing -----------------------------------------------------------------------------
57
+ def _base(self):
58
+ """The adb argv prefix, pinned to this device's serial when one was given."""
59
+ return [self.adb_bin] + (["-s", self.serial] if self.serial else [])
60
+
61
+ def _run(self, *args, timeout=600):
62
+ return subprocess.run(self._base() + list(args), capture_output=True, text=True, timeout=timeout)
63
+
64
+ def _retry(self, fn, tries=4, wait=3):
65
+ """Run `fn` up to `tries` times; return the first returncode==0 result, else the last result/err.
66
+
67
+ Identical discipline to `RemoteQNN._retry` — the caller inspects `returncode` and decides; a
68
+ transport blip is retried, a real failure is returned so it can be reported by name.
69
+ """
70
+ last = None
71
+ for _ in range(tries):
72
+ try:
73
+ r = fn()
74
+ if getattr(r, "returncode", 0) == 0:
75
+ return r
76
+ last = r
77
+ except Exception as e: # pragma: no cover
78
+ last = e
79
+ time.sleep(wait)
80
+ return last
81
+
82
+ # --- the interface the modality calls -----------------------------------------------------
83
+ def shell(self, cmd, timeout=600):
84
+ """Run `cmd` on the board (`adb shell`) and return the CompletedProcess (retry-wrapped)."""
85
+ return self._retry(lambda: self._run("shell", cmd, timeout=timeout))
86
+
87
+ def push_file(self, local, remote, timeout=1800):
88
+ """Push ONE regular file to `remote`. Refuses a directory by name.
89
+
90
+ A directory push is the exact thing that wedges the adb daemon on a multi-GB bundle, so we make
91
+ it an error here rather than let a caller do it by accident — the modality is expected to walk
92
+ the bundle and push each file. Ensures the remote parent dir exists first.
93
+ """
94
+ if not os.path.isfile(local):
95
+ if os.path.isdir(local):
96
+ raise ValueError(
97
+ f"push_file is one file at a time by design (a multi-GB directory push wedges the "
98
+ f"adb daemon); {local!r} is a directory — walk it and push each file.")
99
+ raise FileNotFoundError(f"no such file to push: {local!r}")
100
+ parent = os.path.dirname(remote.rstrip("/"))
101
+ if parent:
102
+ self._retry(lambda: self._run("shell", f"mkdir -p {parent}", timeout=40))
103
+ r = self._retry(lambda: self._run("push", local, remote, timeout=timeout))
104
+ if getattr(r, "returncode", 1) != 0:
105
+ raise RuntimeError(f"adb push failed ({local} -> {remote}): {getattr(r, 'stderr', '')}")
106
+ return remote
107
+
108
+ def pull(self, remote, local, timeout=1800):
109
+ """Pull `remote` off the board to the local path `local` (retry-wrapped)."""
110
+ parent = os.path.dirname(local.rstrip("/"))
111
+ if parent:
112
+ os.makedirs(parent, exist_ok=True)
113
+ r = self._retry(lambda: self._run("pull", remote, local, timeout=timeout))
114
+ if getattr(r, "returncode", 1) != 0:
115
+ raise RuntimeError(f"adb pull failed ({remote} -> {local}): {getattr(r, 'stderr', '')}")
116
+ return local
117
+
118
+ def exists(self, remote, timeout=40) -> bool:
119
+ """True iff `remote` is present on the board (`test -e … && echo OK`)."""
120
+ r = self.shell(f"test -e {remote} && echo OK", timeout=timeout)
121
+ return "OK" in (getattr(r, "stdout", "") or "")