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
chipwright/env.py ADDED
@@ -0,0 +1,229 @@
1
+ """Board probe → TargetKey. The thing the resolver matches artifacts against.
2
+
3
+ On Qualcomm silicon the question that decides everything is *which chip* — specifically the Hexagon
4
+ architecture, the installed QAIRT version, and the SoC. `chipwright doctor` reads those off the board (over
5
+ SSH/ADB) rather than guessing. This mirrors the `qualcomm-env-discovery` skill's `probe-env.sh`, kept
6
+ self-contained here so chipwright-core has no hard dependency on a skill checkout.
7
+
8
+ Offline (no board reachable) is a first-class state, not an error: resolution can still run against a
9
+ TargetKey the user names by hand (`--target htpv68,qnn2.37`).
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import subprocess
14
+ from dataclasses import dataclass, field
15
+ from typing import Optional
16
+
17
+ # soc_id -> (marketing name, Hexagon HTP arch). Extend as boards are added.
18
+ SOC_ID = {
19
+ 498: ("QCS6490 / RB3 Gen2", "v68"),
20
+ 35: ("QCS6490 (Advantech)", "v68"),
21
+ 30: ("QCS8250 / RB5", "v66"),
22
+ 57: ("QCS8550", "v73"),
23
+ 69: ("QCS8650", "v75"),
24
+ # QDC fleet soc_ids (verified on-device via `chipwright doctor`):
25
+ 603: ("QCS8550 / kalama", "v73"),
26
+ 660: ("SM8850 / Snapdragon 8 Elite Gen 5", "v81"),
27
+ # Snapdragon 8 Elite (SM8750) — Hexagon v79, the arch the LLM path reaches over adb. The arch is the
28
+ # load-bearing fact (it is what fails at load and the resolver's hard axis); the on-device lib-name
29
+ # fallback in `probe`/`probe_adb` recovers it even if this marketing soc_id drifts across images.
30
+ 618: ("SM8750 / Snapdragon 8 Elite", "v79"),
31
+ }
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class TargetKey:
36
+ """What an artifact must be compatible with to load on this board."""
37
+ htp_arch: str # e.g. "v68" — the axis that most often fails at load
38
+ qairt: Optional[str] = None # on-device QAIRT version, e.g. "2.37.1"
39
+ soc_id: Optional[int] = None
40
+ soc_name: Optional[str] = None
41
+ reachable: bool = False
42
+ qnn_libs: tuple = field(default_factory=tuple) # e.g. ("libQnnHtpV68.so",)
43
+ thermal_c: Optional[float] = None
44
+ ram_kb: Optional[int] = None # MemTotal from the board — an LLM bundle's load headroom check
45
+
46
+ def __str__(self) -> str:
47
+ sdk = f", qnn{self.qairt}" if self.qairt else ""
48
+ soc = f" [{self.soc_name}]" if self.soc_name else ""
49
+ return f"htp{self.htp_arch}{sdk}{soc}"
50
+
51
+ @classmethod
52
+ def manual(cls, spec: str) -> "TargetKey":
53
+ """Build from a hand-typed spec like 'htpv68,qnn2.37' — for offline resolution."""
54
+ arch, qairt = None, None
55
+ for tok in spec.replace(" ", "").split(","):
56
+ if tok.startswith("htp"):
57
+ arch = tok[3:]
58
+ elif tok.startswith("qnn"):
59
+ qairt = tok[3:]
60
+ if not arch:
61
+ raise ValueError(f"target spec must name an arch, e.g. 'htpv68': got {spec!r}")
62
+ return cls(htp_arch=arch, qairt=qairt)
63
+
64
+
65
+ def _arch_from_libs(libs) -> Optional[str]:
66
+ """Recover the Hexagon arch from the on-device HTP lib names, or None when it is ambiguous.
67
+
68
+ A board carrying one `libQnnHtpV<NN>.so` answers the arch (libQnnHtpV68.so -> v68). We strip any
69
+ suffix (…Stub, …CalculatorStub) so we never emit a garbage arch like "v68CalculatorStub", and we
70
+ return None unless *every* HTP arch lib agrees on one version — a board with multiple arch stubs is
71
+ ambiguous and we must NOT guess. Shared by the SSH probe and the adb probe so the hardened fallback
72
+ behaves identically on both transports.
73
+ """
74
+ found = set()
75
+ for lib in libs:
76
+ if lib.startswith("libQnnHtpV"):
77
+ digits = ""
78
+ for ch in lib[len("libQnnHtpV"):]:
79
+ if ch.isdigit():
80
+ digits += ch
81
+ else:
82
+ break
83
+ if digits:
84
+ found.add("v" + digits)
85
+ return found.pop() if len(found) == 1 else None
86
+
87
+
88
+ def _ssh_opts(pw):
89
+ return ["sshpass", "-p", pw, "ssh", "-o", "StrictHostKeyChecking=no",
90
+ "-o", "UserKnownHostsFile=/dev/null", "-o", "ConnectTimeout=15",
91
+ "-o", "PreferredAuthentications=password", "-o", "PubkeyAuthentication=no"]
92
+
93
+
94
+ def probe(host: str, user: str, pw: str, port: int = 22) -> TargetKey:
95
+ """SSH to a board and read SoC id, Hexagon arch, on-device QNN libs, temperature.
96
+
97
+ Returns a TargetKey with reachable=False (and whatever it could read) if the board is down.
98
+ """
99
+ dest = f"{user}@{host}"
100
+ script = (
101
+ "echo SOC=$(cat /sys/devices/soc0/soc_id 2>/dev/null); "
102
+ "echo NETRUN=$(command -v qnn-net-run); "
103
+ "echo LIBS=$(ls /usr/lib/libQnnHtpV*.so 2>/dev/null | xargs -n1 basename 2>/dev/null | tr '\\n' ' '); "
104
+ # QAIRT version from the device runtime: 'QNN SDK v2.38.0.<build>' -> 2.38.0 (printed to stderr)
105
+ "echo QAIRT=$(qnn-net-run --version 2>&1 | grep -oE 'v[0-9]+\\.[0-9]+\\.[0-9]+' | head -n 1 | tr -d v); "
106
+ "echo TEMP=$(cat /sys/class/thermal/thermal_zone0/temp 2>/dev/null)"
107
+ )
108
+ try:
109
+ r = subprocess.run(_ssh_opts(pw) + ["-p", str(port), dest, script],
110
+ capture_output=True, text=True, timeout=30)
111
+ except Exception:
112
+ return TargetKey(htp_arch="unknown", reachable=False)
113
+ if r.returncode != 0:
114
+ return TargetKey(htp_arch="unknown", reachable=False)
115
+
116
+ out = dict(line.split("=", 1) for line in r.stdout.strip().splitlines() if "=" in line)
117
+ soc_id = int(out["SOC"]) if out.get("SOC", "").strip().isdigit() else None
118
+ soc_name, arch = SOC_ID.get(soc_id, (None, "unknown"))
119
+ libs = tuple(out.get("LIBS", "").split()) if out.get("LIBS", "").strip() else ()
120
+ # arch fallback from the on-device lib name (libQnnHtpV68.so -> v68) — only when unambiguous.
121
+ if arch == "unknown":
122
+ arch = _arch_from_libs(libs) or arch
123
+ temp = None
124
+ if out.get("TEMP", "").strip().isdigit():
125
+ temp = round(int(out["TEMP"]) / 1000.0, 1)
126
+ qairt = out.get("QAIRT", "").strip() or None
127
+ return TargetKey(htp_arch=arch, qairt=qairt, soc_id=soc_id, soc_name=soc_name,
128
+ reachable=True, qnn_libs=libs, thermal_c=temp)
129
+
130
+
131
+ # --- adb (Android) probe ------------------------------------------------------------------------
132
+ # A Snapdragon *phone*-class target (the LLM path) is reached over `adb`, not SSH. The question is the
133
+ # same — which chip, which arch, which QAIRT — so probe_adb mirrors `probe` and returns the same
134
+ # TargetKey; only the transport (an `adb -s <serial> shell …`) and the extra RAM read differ.
135
+
136
+ def _adb_base(serial, adb_bin="adb"):
137
+ return [adb_bin] + (["-s", serial] if serial else [])
138
+
139
+
140
+ def probe_adb(serial: Optional[str] = None, adb_bin: str = "adb") -> TargetKey:
141
+ """adb to a board and read SoC id, Hexagon arch, on-device QNN libs, RAM, temperature.
142
+
143
+ The adb parallel of `probe`. Returns a TargetKey with reachable=False (and whatever it could read)
144
+ if the board is down or `adb` is missing — offline is a first-class state, not an error, exactly as
145
+ on the SSH path. RAM (`MemTotal`) is read too: an LLM Genie bundle is GBs of context binaries, so the
146
+ load-headroom number is worth carrying in the key.
147
+ """
148
+ script = (
149
+ "echo SOC=$(cat /sys/devices/soc0/soc_id 2>/dev/null); "
150
+ "echo NETRUN=$(command -v qnn-net-run); "
151
+ "echo LIBS=$(ls /usr/lib/libQnnHtpV*.so 2>/dev/null | xargs -n1 basename 2>/dev/null | tr '\\n' ' '); "
152
+ "echo QAIRT=$(qnn-net-run --version 2>&1 | grep -oE 'v[0-9]+\\.[0-9]+\\.[0-9]+' | head -n 1 | tr -d v); "
153
+ "echo RAM=$(grep -oE '[0-9]+' /proc/meminfo 2>/dev/null | head -n 1); "
154
+ "echo TEMP=$(cat /sys/class/thermal/thermal_zone0/temp 2>/dev/null)"
155
+ )
156
+ try:
157
+ r = subprocess.run(_adb_base(serial, adb_bin) + ["shell", script],
158
+ capture_output=True, text=True, timeout=30)
159
+ except Exception:
160
+ return TargetKey(htp_arch="unknown", reachable=False)
161
+ if r.returncode != 0:
162
+ return TargetKey(htp_arch="unknown", reachable=False)
163
+
164
+ out = dict(line.split("=", 1) for line in r.stdout.strip().splitlines() if "=" in line)
165
+ soc_id = int(out["SOC"]) if out.get("SOC", "").strip().isdigit() else None
166
+ soc_name, arch = SOC_ID.get(soc_id, (None, "unknown"))
167
+ libs = tuple(out.get("LIBS", "").split()) if out.get("LIBS", "").strip() else ()
168
+ if arch == "unknown":
169
+ arch = _arch_from_libs(libs) or arch
170
+ temp = None
171
+ if out.get("TEMP", "").strip().isdigit():
172
+ temp = round(int(out["TEMP"]) / 1000.0, 1)
173
+ ram_kb = int(out["RAM"]) if out.get("RAM", "").strip().isdigit() else None
174
+ qairt = out.get("QAIRT", "").strip() or None
175
+ return TargetKey(htp_arch=arch, qairt=qairt, soc_id=soc_id, soc_name=soc_name,
176
+ reachable=True, qnn_libs=libs, thermal_c=temp, ram_kb=ram_kb)
177
+
178
+
179
+ # --- skel-load capability -----------------------------------------------------------------------
180
+ # Some device images (observed: SM8750/v79 on QDC) block loading the unsigned DSP skel — a trivial
181
+ # backend init dies with "Failed to load skel, error 4000" and the LLM simply cannot run there. This is
182
+ # a *device-capability* fact, not a model fault: we detect it up front and report a clean FAIL (naming
183
+ # the axis) instead of letting a run hang on a backend that will never initialize.
184
+
185
+ SKEL_ERR_CODE = 4000
186
+
187
+
188
+ @dataclass(frozen=True)
189
+ class SkelCheck:
190
+ """The result of a trivial on-device backend init. `ok=False` => the DSP skel would not load."""
191
+ ok: bool
192
+ code: Optional[int] = None # the error code when it failed (4000 = skel lockdown)
193
+ reason: str = "" # a plain, axis-naming sentence
194
+ raw: str = "" # the tail of the init output, for the record
195
+
196
+ def __bool__(self) -> bool:
197
+ return self.ok
198
+
199
+
200
+ # The default trivial init: force the HTP backend (and thus the fastRPC DSP skel) to load. On a locked
201
+ # image this is exactly where "Failed to load skel, error 4000" surfaces, before any model is involved.
202
+ _SKEL_INIT_CMD = ("export ADSP_LIBRARY_PATH=/usr/lib/rfsa/adsp:/usr/lib LD_LIBRARY_PATH=/usr/lib && "
203
+ "qnn-net-run --backend /usr/lib/libQnnHtp.so --version 2>&1")
204
+
205
+
206
+ def detect_skel_load(shell, init_cmd: str = _SKEL_INIT_CMD, timeout: int = 60) -> SkelCheck:
207
+ """Run a trivial backend init through `shell` and classify whether the DSP skel loads.
208
+
209
+ `shell` is any callable `shell(cmd, timeout=…) -> CompletedProcess` — it duck-types to both
210
+ `AdbDevice.shell` and the SSH transport, so the same detector serves either board. A
211
+ `Failed to load skel … 4000` in the output is a device-capability FAIL (named, not a hang); a
212
+ timeout or an unreachable shell is reported as its own FAIL rather than being swallowed.
213
+ """
214
+ try:
215
+ r = shell(init_cmd, timeout=timeout)
216
+ except Exception as e:
217
+ return SkelCheck(ok=False, reason=f"could not run the backend-init probe on the board ({e})")
218
+ if r is None:
219
+ return SkelCheck(ok=False, reason="backend-init probe returned no result (board unreachable?)")
220
+
221
+ raw = ((getattr(r, "stdout", "") or "") + (getattr(r, "stderr", "") or ""))
222
+ low = raw.lower()
223
+ if "failed to load skel" in low or f"error {SKEL_ERR_CODE}" in low or f"0x{SKEL_ERR_CODE:04x}" in low:
224
+ return SkelCheck(
225
+ ok=False, code=SKEL_ERR_CODE,
226
+ reason=("device image blocks loading the DSP skel (Failed to load skel, error 4000) — the "
227
+ "LLM cannot run on this board; this is a device-capability FAIL, not a model fault"),
228
+ raw=raw[-500:])
229
+ return SkelCheck(ok=True, reason="DSP skel loads; backend initialises", raw=raw[-500:])
chipwright/errors.py ADDED
@@ -0,0 +1,41 @@
1
+ """Typed build errors — the build verb fails by naming the axis, never by a bare traceback.
2
+
3
+ A build shells out to the QAIRT toolchain, which may be absent (no x86_64-linux host, no SDK) or may
4
+ reject the graph. Each failure mode is a distinct type so the route orchestrator can stop at the right
5
+ stage, set `BuildResult.failed_axis`, and let the CLI print an honest reason — the same name-the-axis
6
+ discipline the resolver uses for a FAIL.
7
+ """
8
+ from __future__ import annotations
9
+
10
+
11
+ class BuildError(Exception):
12
+ """Base for every typed build failure. `axis` names the stage that stopped the route.
13
+
14
+ The specific subclasses fix `axis` at the class level; a bare `BuildError` may name its stage
15
+ per-instance (e.g. the convert/quantize executors, which have no dedicated subclass)."""
16
+ axis: str = "build"
17
+
18
+ def __init__(self, *args, axis: str = None):
19
+ super().__init__(*args)
20
+ if axis is not None:
21
+ self.axis = axis
22
+
23
+
24
+ class PreflightError(BuildError):
25
+ """A required host tool (qairt-converter / -quantizer / context-binary-generator) is missing."""
26
+ axis = "preflight"
27
+
28
+
29
+ class OpUnsupported(BuildError):
30
+ """The op-support gate found an op that will not run on the target HTP (before any tool runs)."""
31
+ axis = "op_support"
32
+
33
+
34
+ class ContextGenError(BuildError):
35
+ """qnn-context-binary-generator failed (e.g. SDK×arch mismatch, VTCM sizing, missing backend .so)."""
36
+ axis = "context"
37
+
38
+
39
+ class FidelityBelowThreshold(BuildError):
40
+ """The built artifact loaded but missed its cosine / task-metric gate — never published."""
41
+ axis = "fidelity"
@@ -0,0 +1,114 @@
1
+ """The CPU reference decoder — what "correct" means for every LLM run, and the pinned reference.
2
+
3
+ The Layer-1 tripwire (`verify_llm.py`) needs something to compare the chip against. That something is
4
+ the **same quantized artifact** (the w4a16 ONNX) run greedily on CPU through onnxruntime — the qai-hub
5
+ `onnxruntime_genai` path. Because it is the same quantized model, a token mismatch isolates the
6
+ backend (the chip), not the quantization. This file is the reference the whole gate trusts, so it must
7
+ match the Genie decode semantics exactly: same tokenizer, same chat template, same KV layout, greedy.
8
+
9
+ **Why it is pinned (FR-L10).** The CPU reference runs on the x86-linux *build host*, not on the host
10
+ that drives the board over adb. So v1 computes the reference greedy tokens/logits **once** and records
11
+ them (with a `sha256`) in the artifact's provenance block; the on-device run compares against that pin
12
+ rather than re-running the CPU reference live over the board transport. `pin_reference` below emits
13
+ that pin and its hash.
14
+
15
+ The ORT-GenAI model load is validated on hardware later (checkpoint C2). Here the decode-loop
16
+ *structure* is written cleanly, and the pin/hash emit is made unit-testable by injecting the decode fn
17
+ (`decode` parameter) — so the provenance shape can be tested without onnxruntime or a model present.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import hashlib
22
+ import json
23
+
24
+ import numpy as np
25
+
26
+
27
+ # --- the greedy decode loop (structure; model load validated on hardware) -------------------------
28
+
29
+ def greedy_decode(session, tokenizer, prompt_ids, max_new_tokens=64, eos_id=None):
30
+ """Greedy-decode one prompt through the w4a16 prefill/decode split with KV-cache + host embedding.
31
+
32
+ This is the structure of the qai-hub `onnxruntime_genai` path:
33
+ 1. **Prefill** — run the prompt token ids through the prefill graph (seq-N, the VTCM-fitting
34
+ length the artifact was exported at), seeding the KV-cache. The embedding lookup is done on
35
+ the host (host embedding) and the hidden states fed in.
36
+ 2. **Decode** — autoregressively run the decode graph one token at a time: pick argmax (greedy,
37
+ deterministic — sampling OFF so a mismatch is signal, not sampler noise), append to the
38
+ KV-cache, feed back, until EOS or `max_new_tokens`.
39
+
40
+ Returns (tokens, logits): the generated token ids [T] and the per-step next-token logits [T, vocab].
41
+ `session` is an onnxruntime-genai-style handle exposing `.prefill(ids)->(logits, kv)` and
42
+ `.decode(token, kv)->(logits, kv)`; it is injected so the loop is testable with a fake.
43
+ """
44
+ logits0, kv = session.prefill(prompt_ids) # [vocab], seeded KV-cache
45
+ tokens, logits = [], []
46
+ nxt = int(np.argmax(logits0)) # greedy: argmax, no sampling
47
+ for _ in range(max_new_tokens):
48
+ tokens.append(nxt)
49
+ if eos_id is not None and nxt == eos_id: # terminate cleanly on EOS
50
+ break
51
+ step_logits, kv = session.decode(nxt, kv)
52
+ logits.append(np.asarray(step_logits, dtype=np.float64))
53
+ nxt = int(np.argmax(step_logits))
54
+ return tokens, (np.stack(logits) if logits else np.zeros((0, 0)))
55
+
56
+
57
+ # --- the pinned reference + its hash (provenance; unit-testable with a fake decoder) --------------
58
+
59
+ def _canonical_bytes(tokens_per_prompt, logits_per_prompt) -> bytes:
60
+ """Deterministic byte serialization of the reference, so the sha256 is stable across runs/hosts.
61
+
62
+ Logits are rounded to float32 and dumped in a fixed, sorted-key JSON envelope; token ids are plain
63
+ ints. Rounding to float32 keeps the hash reproducible across numpy/BLAS builds that differ only in
64
+ float64 noise below the precision the chip could ever match.
65
+ """
66
+ env = {
67
+ "tokens": [[int(t) for t in toks] for toks in tokens_per_prompt],
68
+ "logits": [np.asarray(lg, dtype=np.float32).round(6).tolist() for lg in logits_per_prompt],
69
+ }
70
+ return json.dumps(env, sort_keys=True, separators=(",", ":")).encode("utf-8")
71
+
72
+
73
+ def pin_reference(prompt_set, decode, max_new_tokens=64, eos_id=None) -> dict:
74
+ """Compute the pinned reference for a fixed prompt set and emit it with its sha256 (FR-L10 / T-L08b).
75
+
76
+ `prompt_set` is the fixed list of (already tokenized) prompts. `decode` is the decode function —
77
+ `greedy_decode` bound to a real session on the build host, or a fake in tests; injecting it makes
78
+ the pin/hash emit testable without onnxruntime or a model. The returned dict is the provenance
79
+ payload the record carries: the greedy tokens + per-step logits per prompt and the hash the board
80
+ compares against.
81
+
82
+ {
83
+ "prompts": <N>,
84
+ "decode": "greedy",
85
+ "tokens": [[...], ...], # per prompt
86
+ "logits": [<np.ndarray [T, vocab]>, ...],
87
+ "reference_logits_sha256": "<hex>", # goes into provenance.reference_logits_sha256
88
+ }
89
+ """
90
+ tokens_per_prompt, logits_per_prompt = [], []
91
+ for ids in prompt_set:
92
+ toks, lg = decode(ids, max_new_tokens=max_new_tokens, eos_id=eos_id)
93
+ tokens_per_prompt.append([int(t) for t in toks])
94
+ logits_per_prompt.append(np.asarray(lg))
95
+
96
+ digest = hashlib.sha256(_canonical_bytes(tokens_per_prompt, logits_per_prompt)).hexdigest()
97
+ return {
98
+ "prompts": len(prompt_set),
99
+ "decode": "greedy",
100
+ "tokens": tokens_per_prompt,
101
+ "logits": logits_per_prompt,
102
+ "reference_logits_sha256": digest,
103
+ }
104
+
105
+
106
+ def verify_pin_hash(pin: dict) -> bool:
107
+ """Recompute the sha256 from a pin's tokens/logits and check it matches the recorded hash.
108
+
109
+ Lets a reader confirm the board compared against the reference the provenance block names — the
110
+ reproducibility guarantee of rule 5 (the record is reproducible from its provenance block).
111
+ """
112
+ expect = pin.get("reference_logits_sha256")
113
+ actual = hashlib.sha256(_canonical_bytes(pin["tokens"], pin["logits"])).hexdigest()
114
+ return expect == actual
@@ -0,0 +1,3 @@
1
+ """Modality runners — the per-task glue (frontend, decoders, CPU reference) that a
2
+ `chipwright-<modality>` package owns. The core calls them through a uniform `run(device, ...)`.
3
+ """
@@ -0,0 +1,90 @@
1
+ """ASR streaming-encoder modality runner (a slice of what a `chipwright-asr` package would own).
2
+
3
+ Runs ONE encoder window two ways on the same input and hands back both outputs for the verifier:
4
+ - NPU: quantize inputs with the artifact's own I/O scales, execute the w8a16 context binary.
5
+ - CPU: the float ONNX through ONNX Runtime — the reference.
6
+
7
+ The streaming encoder takes an audio feature window plus cached state. For a single window the
8
+ state is zero, which sidesteps the quant-layout transposes the per-chunk loop needs — enough to
9
+ prove the Run verb and produce an honest cosine.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import numpy as np
15
+
16
+ _NPDT = {"QNN_DATATYPE_UFIXED_POINT_16": np.uint16, "QNN_DATATYPE_INT_32": np.int32,
17
+ "QNN_DATATYPE_FLOAT_32": np.float32}
18
+
19
+
20
+ def _dims(d):
21
+ return [1 if (isinstance(x, str) or x in (0, None)) else x for x in d]
22
+
23
+
24
+ def _load_meta(meta_path):
25
+ g = json.load(open(meta_path))["info"]["graphs"][0]["info"]
26
+ def tl(key):
27
+ out = []
28
+ for t in g[key]:
29
+ i = t["info"]; qp = i.get("quantizeParams", {}) or {}
30
+ so = qp.get("scaleOffset") if isinstance(qp, dict) else None
31
+ out.append(dict(name=i["name"], dtype=i["dataType"], dims=list(i["dimensions"]),
32
+ scale=(so or {}).get("scale"), offset=(so or {}).get("offset")))
33
+ return out
34
+ return g["graphName"], tl("graphInputs"), tl("graphOutputs")
35
+
36
+
37
+ def _q16(real, scale, offset):
38
+ return np.clip(np.rint(real / scale) - offset, 0, 65535).astype(np.uint16)
39
+
40
+
41
+ def _dq16(q, scale, offset):
42
+ return (q.astype(np.float32) + offset) * scale
43
+
44
+
45
+ def run(device, ctx_board_path, meta_path, cpu_onnx, input_npy, window=39):
46
+ """Return (npu_out, cpu_out) dicts keyed by 'encoder_out'."""
47
+ import onnxruntime as ort
48
+
49
+ gname, gin, gout = _load_meta(meta_path)
50
+ imap = {t["name"]: t for t in gin}
51
+ enc_out_spec = next(t for t in gout if t["name"] == "encoder_out")
52
+
53
+ feats = np.load(input_npy).astype(np.float32) # [frames, 80]
54
+ if feats.shape[0] < window:
55
+ feats = np.pad(feats, ((0, window - feats.shape[0]), (0, 0)), constant_values=feats.min())
56
+ x = feats[:window][None, :, :] # [1, 39, 80] (float, onnx layout)
57
+
58
+ # ---- CPU reference (float ONNX) --------------------------------------------------------------
59
+ sess = ort.InferenceSession(cpu_onnx, providers=["CPUExecutionProvider"])
60
+ cpu_feeds = {"x": x.astype(np.float32)}
61
+ for i in sess.get_inputs():
62
+ if i.name == "x":
63
+ continue
64
+ shp = _dims(i.shape)
65
+ cpu_feeds[i.name] = np.zeros(shp, np.float32 if i.type == "tensor(float)" else np.int64)
66
+ onames = [o.name for o in sess.get_outputs()]
67
+ cpu_vals = dict(zip(onames, sess.run(None, cpu_feeds)))
68
+ cpu_out = {"encoder_out": cpu_vals["encoder_out"]}
69
+
70
+ # ---- NPU (quantize with the artifact's scales, run the context binary) -----------------------
71
+ native = {}
72
+ xi = imap["x"]
73
+ native["x"] = _q16(np.transpose(x, (0, 2, 1)), xi["scale"], xi["offset"]) # -> [1,80,39]
74
+ for t in gin:
75
+ n = t["name"]
76
+ if n == "x":
77
+ continue
78
+ shp = _dims(t["dims"])
79
+ if t["dtype"] == "QNN_DATATYPE_INT_32":
80
+ native[n] = np.zeros(shp, np.int32)
81
+ else: # zero state -> quantize zeros
82
+ native[n] = _q16(np.zeros(shp, np.float32), t["scale"], t["offset"])
83
+ out_shape = tuple(_dims(enc_out_spec["dims"]))
84
+ raw = device.run(ctx_board_path, native,
85
+ [{"name": "encoder_out", "dtype": "uint16", "shape": out_shape}])
86
+ npu_eo = _dq16(raw["encoder_out"], enc_out_spec["scale"], enc_out_spec["offset"])
87
+ # align shapes (CPU may be [1,T,512]; NPU dims from meta) for a fair cosine
88
+ npu_out = {"encoder_out": npu_eo.reshape(cpu_out["encoder_out"].shape)
89
+ if npu_eo.size == cpu_out["encoder_out"].size else npu_eo}
90
+ return npu_out, cpu_out
@@ -0,0 +1,34 @@
1
+ """Single-image CV modality runner (first slice of a `chipwright-cv` package).
2
+
3
+ Works for any model whose input is one preprocessed image tensor and whose output is a single tensor
4
+ — classifier logits, a super-resolved image, a segmentation map, a depth field. Runs it two ways on
5
+ the same input and hands both outputs to the verifier:
6
+ - NPU: the quantized context binary, float I/O (qnn-net-run quantizes the input and dequantizes the
7
+ output), so the runner needs no per-tensor scales.
8
+ - CPU: the float ONNX through ONNX Runtime — the reference.
9
+
10
+ A single-graph image model is the cheapest clean end-to-end proof: fixed input shape, one output, a
11
+ cosine that directly measures how faithful the quantized graph is to float.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import numpy as np
16
+
17
+
18
+ def run(device, ctx_board_path, meta, cpu_onnx, input_npy):
19
+ """Return (npu_out, cpu_out) dicts keyed by the model's output name. `meta` is unused (float I/O
20
+ needs no scales); kept for a uniform modality signature."""
21
+ import onnxruntime as ort
22
+
23
+ x = np.load(input_npy).astype(np.float32) # [1, C, H, W]
24
+
25
+ sess = ort.InferenceSession(cpu_onnx, providers=["CPUExecutionProvider"])
26
+ in_name = sess.get_inputs()[0].name
27
+ out_names = [o.name for o in sess.get_outputs()]
28
+ cpu_vals = sess.run(None, {in_name: x})
29
+ cpu_out = {n: v for n, v in zip(out_names, cpu_vals)}
30
+
31
+ specs = [{"name": n, "dtype": "float32", "shape": tuple(cpu_out[n].shape)} for n in out_names]
32
+ npu = device.run(ctx_board_path, {in_name: x}, specs, native_io=False)
33
+ npu_out = {n: npu[n].reshape(cpu_out[n].shape) for n in out_names}
34
+ return npu_out, cpu_out