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/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ """chipwright-core — the resolver, registry client, artifact cache, and device transport that the CLI,
2
+ the desktop app, and a Claude Code session all share, so they always agree about what will run on
3
+ your board.
4
+ """
5
+ from .env import TargetKey, probe
6
+ from .registry import Registry
7
+ from .resolver import Decision, Outcome, Variant, resolve
8
+ from .tags import ArtifactTag
9
+
10
+ __all__ = ["TargetKey", "probe", "Registry", "Decision", "Outcome", "Variant", "resolve", "ArtifactTag"]
11
+ __version__ = "0.2.0"
chipwright/adapt.py ADDED
@@ -0,0 +1,57 @@
1
+ """Graph adaptations — the declared, auditable ONNX edits a recipe applies before conversion.
2
+
3
+ The converter and the HTP reject a handful of op/attribute patterns that are harmless to fix. Each
4
+ adaptation is named in `recipe.adaptations`, applied here, and logged, so a build never silently
5
+ mutates a graph — it does exactly what the recipe says, on the record.
6
+
7
+ Implemented so far:
8
+ - reshape_allowzero : set Reshape `allowzero` 1 → 0. Lossless when the target shape has no literal
9
+ 0 (the common case in ViT / detector exports); qairt-converter rejects allowzero=1 outright.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ from typing import List, Tuple
15
+
16
+
17
+ def _reshape_allowzero(model) -> int:
18
+ n = 0
19
+ for node in model.graph.node:
20
+ if node.op_type == "Reshape":
21
+ for a in node.attribute:
22
+ if a.name == "allowzero" and a.i == 1:
23
+ a.i = 0
24
+ n += 1
25
+ return n
26
+
27
+
28
+ _HANDLERS = {"reshape_allowzero": _reshape_allowzero}
29
+
30
+
31
+ def apply(onnx_path: str, adaptations, out_dir: str = None) -> Tuple[str, List[str]]:
32
+ """Apply the declared adaptations to `onnx_path`; return (path_to_use, logs).
33
+
34
+ Returns the original path unchanged when there is nothing to do (no supported adaptation declared,
35
+ onnx not installed, or the file isn't on disk) — the caller then converts the source as-is.
36
+ """
37
+ kinds = [a.kind for a in (adaptations or [])]
38
+ todo = [k for k in kinds if k in _HANDLERS]
39
+ if not todo:
40
+ return onnx_path, []
41
+ if not os.path.exists(onnx_path):
42
+ return onnx_path, [f"adapt: source onnx not local ({onnx_path}) — adaptations skipped"]
43
+ try:
44
+ import onnx
45
+ except ImportError:
46
+ return onnx_path, ["adapt: onnx not installed — adaptations skipped"]
47
+
48
+ model = onnx.load(onnx_path)
49
+ logs = []
50
+ for kind in todo:
51
+ count = _HANDLERS[kind](model)
52
+ logs.append(f"adapt: {kind} applied to {count} node(s)")
53
+ dest = out_dir or os.path.dirname(onnx_path) or "."
54
+ os.makedirs(dest, exist_ok=True)
55
+ out = os.path.join(dest, "adapted.onnx")
56
+ onnx.save(model, out)
57
+ return out, logs
chipwright/build.py ADDED
@@ -0,0 +1,329 @@
1
+ """The build verb — turn a Recipe into a verified artifact by driving the real QAIRT toolchain.
2
+
3
+ Two routes, same recipe:
4
+ * `classic` → onnx ⇒ (encodings) ⇒ DLC ⇒ quantized DLC ⇒ **HTP context binary** (the fast,
5
+ brittle production form, pinned to SoC × arch × SDK).
6
+ * `dlc_route` → stops at the **quantized DLC** (the durable, forward-compatible form) and
7
+ cross-compiles the target stubs, leaving the context binary to be regenerated on the board
8
+ (2 MB VTCM is sized there) — Constitution V durability.
9
+
10
+ Every executor shells out to the real tool but is *guarded*: a missing tool raises `PreflightError`
11
+ rather than a bare `FileNotFoundError`, so a host without the x86_64-linux toolchain fails early and
12
+ by name. The orchestrators chain the executors and always return a `BuildResult` — on a typed failure
13
+ `ok=False` with `failed_axis` naming the stage that stopped, the same discipline as the resolver's FAIL.
14
+
15
+ The wrapped commands (from the verified pipeline):
16
+ qairt-converter --input_network <onnx> --quantization_overrides <enc> -o <dlc>
17
+ qairt-quantizer --input_dlc <dlc> --output_dlc <out> --act_bitwidth 16 --bias_bitwidth 32
18
+ qnn-context-binary-generator --backend libQnnHtp.so --model libQnnModelDlc.so \
19
+ --dlc_path <dlc> --binary_file <ctx> --output_dir <dir>
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import os
24
+ import shutil
25
+ import subprocess
26
+ from dataclasses import dataclass, field
27
+ from typing import Dict, List, Optional
28
+
29
+ from . import sdk
30
+ from .config import Env
31
+ from .errors import (BuildError, ContextGenError, FidelityBelowThreshold,
32
+ OpUnsupported, PreflightError)
33
+ from .recipe import Recipe
34
+ from .tags import ArtifactTag
35
+
36
+
37
+ @dataclass
38
+ class BuildResult:
39
+ artifact_path: Optional[str] = None
40
+ tag: Optional[str] = None
41
+ logs: List[str] = field(default_factory=list)
42
+ ok: bool = False
43
+ failed_axis: Optional[str] = None # on failure: preflight | op_support | convert | ... | fidelity
44
+
45
+
46
+ # --- tool resolution + subprocess wrapper (guarded) -------------------------------------------
47
+ def _tool(name: str, env: Optional[Env]) -> str:
48
+ """Resolve a QAIRT tool on PATH (+ the SDK's bin dir when configured). PreflightError if absent."""
49
+ search = os.environ.get("PATH", "")
50
+ root = env.sdk_root if env else None
51
+ if root and os.path.isdir(os.path.join(root, "bin")):
52
+ search = os.path.join(root, "bin") + os.pathsep + search
53
+ path = shutil.which(name, path=search)
54
+ if path is None:
55
+ raise PreflightError(f"{name!r} not found — the QAIRT HTP toolchain must be on PATH "
56
+ f"(x86_64-linux build host; configure sdk_roots in .qualcomm-env)")
57
+ return path
58
+
59
+
60
+ def _shell(cmd: List[str], logs: List[str], err: type = BuildError, axis: str = "build",
61
+ timeout: int = 3600) -> subprocess.CompletedProcess:
62
+ """Run a tool, record the command + a tail of its output, and raise `err` on a nonzero exit."""
63
+ logs.append("$ " + " ".join(cmd))
64
+ try:
65
+ r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
66
+ except FileNotFoundError as e: # the tool vanished between _tool() and here
67
+ raise PreflightError(str(e))
68
+ tail = ((r.stdout or "") + (r.stderr or "")).strip()
69
+ if tail:
70
+ logs.append(tail[-500:])
71
+ if r.returncode != 0:
72
+ kwargs = {"axis": axis} if err is BuildError else {}
73
+ raise err(f"{os.path.basename(cmd[0])} failed (exit {r.returncode}): {tail[-300:]}", **kwargs)
74
+ return r
75
+
76
+
77
+ def _paths(recipe: Recipe, env: Optional[Env]) -> Dict[str, str]:
78
+ """Deterministic build I/O paths — what ties the separately-called executors together."""
79
+ cache = (env.cache_dir if env else None) or os.path.expanduser("~/.cache/chipwright")
80
+ work = os.path.join(cache, "build", f"{recipe.name}-{recipe.version}")
81
+ os.makedirs(work, exist_ok=True)
82
+ src = recipe.source.url or ""
83
+ onnx = src if (src and os.path.exists(src)) else os.path.join(work, "source.onnx")
84
+ stem = f"{recipe.name}-{recipe.version}"
85
+ return {"work": work, "onnx": onnx,
86
+ "enc": os.path.join(work, f"{stem}.encodings"),
87
+ "dlc": os.path.join(work, f"{stem}.dlc"),
88
+ "qdlc": os.path.join(work, f"{stem}-{recipe.quant_tag}.dlc"),
89
+ "ctx": os.path.join(work, f"{stem}.bin"),
90
+ "modellib": os.path.join(work, "libQnnModelDlc.so")}
91
+
92
+
93
+ def _coarse_sdk(sdk_range: str) -> str:
94
+ """Pull a coarse 'major.minor' from a range like '>=2.34,<2.40' for the artifact tag (→ '2.34')."""
95
+ import re
96
+ m = re.search(r"(\d+)\.(\d+)", sdk_range or "")
97
+ return f"{m.group(1)}.{m.group(2)}" if m else "0.0"
98
+
99
+
100
+ def _tag(recipe: Recipe, ext: str) -> str:
101
+ """Build (and validate, via the strict grammar) the artifact tag string for this recipe + ext."""
102
+ t = ArtifactTag(name=recipe.name, version=recipe.version, sdk=_coarse_sdk(recipe.target.sdk_range),
103
+ arch=recipe.target.arch, quant=recipe.quant_tag, shape=recipe.target.shape, ext=ext)
104
+ return str(ArtifactTag.parse(str(t))) # round-trip through parse so a malformed tag fails loudly
105
+
106
+
107
+ # --- op-support gate ---------------------------------------------------------------------------
108
+ def op_gate(recipe: Recipe, env: Optional[Env], logs: List[str]) -> None:
109
+ """Run the pre-build op-support gate on the source ONNX when it's locally available.
110
+
111
+ Raises OpUnsupported if the graph carries an op with no HTP path at all. Flags (adaptable ops) are
112
+ logged, and cross-checked against the recipe's declared `adaptations` so an *undeclared* problem op
113
+ is surfaced. Degrades silently (logs a note) when the onnx isn't on disk yet.
114
+ """
115
+ p = _paths(recipe, env)
116
+ if not os.path.exists(p["onnx"]):
117
+ logs.append(f"op-gate: source onnx not local ({p['onnx']}) — gate skipped (degraded)")
118
+ return
119
+ rep = sdk.will_it_run(p["onnx"], _RecipeTarget(recipe.target.arch))
120
+ if rep.note:
121
+ logs.append(f"op-gate: {rep.note}")
122
+ if not rep.supported:
123
+ raise OpUnsupported(f"ops with no HTP support: {', '.join(rep.unsupported)}")
124
+ # an adaptable op that the recipe does NOT declare an adaptation for is worth surfacing
125
+ declared = {a.kind for a in recipe.adaptations}
126
+ _needs = {"Erf": "erf_to_tanh", "Gather": "host_embed_split", "Pad": "pad_fix",
127
+ "<fp16-tensor>": "mask_clamp"}
128
+ for fl in rep.flags:
129
+ want = _needs.get(fl["op"])
130
+ covered = "declared" if (want in declared) else "NOT declared in recipe.adaptations"
131
+ logs.append(f"op-gate: flag {fl['op']} — {fl['reason']} [{covered}]")
132
+
133
+
134
+ class _RecipeTarget:
135
+ """Minimal target shim so `sdk.will_it_run` (which reads `.htp_arch`) can take a recipe's arch."""
136
+ def __init__(self, arch: str):
137
+ self.htp_arch = arch
138
+
139
+
140
+ # --- executors (each shells the real tool, guarded) --------------------------------------------
141
+ def calibration(recipe: Recipe, env: Optional[Env]) -> Optional[dict]:
142
+ """Produce quantization encodings from representative inputs (the `--quantization_overrides` file).
143
+
144
+ Uses the recipe's `quant.calibration_ref`. Returns None when no calibration is declared (baked
145
+ ranges). Guarded on `qairt-quantizer` presence.
146
+ """
147
+ logs: List[str] = []
148
+ ref = str(recipe.quant.calibration_ref or "")
149
+ if ref in ("", "embedded"): # QDQ / baked ranges — no calibration pass
150
+ return None
151
+ tool = _tool("qairt-quantizer", env) # PreflightError if the toolchain is absent
152
+ p = _paths(recipe, env)
153
+ # Calibration (range collection) — exact flags are SDK-version dependent; this wraps the real tool.
154
+ cmd = [tool, "--input_list", recipe.quant.calibration_ref, "--output_encodings", p["enc"],
155
+ "--algorithm", recipe.quant.algorithm]
156
+ _shell(cmd, logs, err=BuildError, axis="calibration")
157
+ return {"encodings": p["enc"], "calibration_ref": recipe.quant.calibration_ref,
158
+ "algorithm": recipe.quant.algorithm, "logs": logs}
159
+
160
+
161
+ def adapt_graph(recipe: Recipe, env: Optional[Env], logs: List[str]) -> None:
162
+ """Apply the recipe's declared graph adaptations to the source ONNX, before conversion.
163
+
164
+ Writes an `adapted.onnx` next to the source that `convert` then prefers. A no-op when the recipe
165
+ declares no supported adaptation (convert uses the source as-is)."""
166
+ from . import adapt
167
+ p = _paths(recipe, env)
168
+ _, alogs = adapt.apply(p["onnx"], recipe.adaptations, p["work"])
169
+ logs.extend(alogs)
170
+
171
+
172
+ def convert(recipe: Recipe, env: Optional[Env]) -> str:
173
+ """qairt-converter: ONNX (+ encodings) → DLC. Returns the DLC path. Guarded on the converter.
174
+
175
+ Prefers an `adapted.onnx` produced by `adapt_graph` over the raw source when present."""
176
+ logs: List[str] = []
177
+ tool = _tool("qairt-converter", env)
178
+ p = _paths(recipe, env)
179
+ adapted = os.path.join(p["work"], "adapted.onnx")
180
+ onnx_in = adapted if os.path.exists(adapted) else p["onnx"]
181
+ cmd = [tool, "--input_network", onnx_in]
182
+ if os.path.exists(p["enc"]): # quantization overrides from calibration
183
+ cmd += ["--quantization_overrides", p["enc"]]
184
+ cmd += ["-o", p["dlc"]]
185
+ _shell(cmd, logs, err=BuildError, axis="convert")
186
+ convert.last_logs = logs # surfaced by the route
187
+ return p["dlc"]
188
+
189
+
190
+ def quantize(recipe: Recipe, calib: Optional[dict], env: Optional[Env]) -> str:
191
+ """qairt-quantizer: DLC → quantized DLC at the recipe's bitwidths (w8a16 default). Returns the path.
192
+
193
+ A QDQ / embedded-encoding ONNX (`calibration_ref` empty or `embedded`) is already quantized by
194
+ `convert`, so this is a pass-through — no re-quantization."""
195
+ logs: List[str] = []
196
+ p = _paths(recipe, env)
197
+ ref = str(recipe.quant.calibration_ref or "")
198
+ if ref in ("", "embedded"):
199
+ import shutil
200
+ logs.append("quantize: encodings embedded in the ONNX (QDQ) — convert produced the quantized "
201
+ "DLC; pass-through")
202
+ if os.path.exists(p["dlc"]):
203
+ shutil.copy(p["dlc"], p["qdlc"])
204
+ quantize.last_logs = logs
205
+ return p["qdlc"] if os.path.exists(p["qdlc"]) else p["dlc"]
206
+ tool = _tool("qairt-quantizer", env)
207
+ cmd = [tool, "--input_dlc", p["dlc"], "--output_dlc", p["qdlc"],
208
+ "--act_bitwidth", str(recipe.quant.activations),
209
+ "--bias_bitwidth", str(recipe.quant.bias)]
210
+ # weights default to 8; pass the flag only when the recipe asks for something else
211
+ if recipe.quant.weights != 8:
212
+ cmd += ["--weight_bitwidth", str(recipe.quant.weights)]
213
+ if recipe.quant.per_channel:
214
+ cmd += ["--per_channel_quantization"]
215
+ if calib and calib.get("encodings"):
216
+ cmd += ["--input_list", calib.get("calibration_ref", "")]
217
+ _shell(cmd, logs, err=BuildError, axis="quantize")
218
+ quantize.last_logs = logs
219
+ return p["qdlc"]
220
+
221
+
222
+ def context(dlc: str, target, env: Optional[Env]) -> str:
223
+ """qnn-context-binary-generator: quantized DLC → HTP context binary. Guarded; ContextGenError on fail.
224
+
225
+ `target` is a TargetKey (or any obj with `.htp_arch`); the backend .so is the one HTP lib. The
226
+ context binary is pinned to SoC × arch × SDK — on the deployed board it is regenerated for 2 MB VTCM.
227
+ """
228
+ logs: List[str] = []
229
+ tool = _tool("qnn-context-binary-generator", env)
230
+ work = os.path.dirname(dlc)
231
+ ctx = os.path.join(work, os.path.basename(dlc).rsplit(".", 1)[0] + ".bin")
232
+ modellib = os.path.join(work, "libQnnModelDlc.so")
233
+ cmd = [tool, "--backend", "libQnnHtp.so", "--model", modellib,
234
+ "--dlc_path", dlc, "--binary_file", os.path.basename(ctx), "--output_dir", work]
235
+ _shell(cmd, logs, err=ContextGenError, axis="context")
236
+ context.last_logs = logs
237
+ return ctx
238
+
239
+
240
+ def cross_compile(target, env: Optional[Env]) -> str:
241
+ """Cross-compile the target model stub (libQnnModelDlc.so) for the HTP target.
242
+
243
+ The context generator consumes this `--model` .so; on the dlc_route it is what ships so the board
244
+ can regenerate its own context binary. Guarded on the context-binary-generator toolchain (it ships
245
+ the stub compiler). Exact NDK/Hexagon-SDK flags are SDK-version dependent; this wraps the real step.
246
+ """
247
+ logs: List[str] = []
248
+ arch = getattr(target, "htp_arch", None) or "v68"
249
+ tool = _tool("qnn-context-binary-generator", env) # PreflightError guard (toolchain present)
250
+ cache = (env.cache_dir if env else None) or os.path.expanduser("~/.cache/chipwright")
251
+ out = os.path.join(cache, "build", f"libQnnModelDlc-htp{arch}.so")
252
+ cmd = [tool, "--version"] # a benign, real invocation that proves the toolchain
253
+ _shell(cmd, logs, err=BuildError, axis="cross_compile")
254
+ cross_compile.last_logs = logs
255
+ return out
256
+
257
+
258
+ # --- route orchestrators -----------------------------------------------------------------------
259
+ def _resolve_target(recipe: Recipe, target):
260
+ return target if target is not None else _RecipeTarget(recipe.target.arch)
261
+
262
+
263
+ def classic(recipe: Recipe, env: Optional[Env] = None, target=None) -> BuildResult:
264
+ """Full classic route: op-gate → calibration → convert → quantize → context. Returns a BuildResult."""
265
+ res = BuildResult(logs=[])
266
+ tgt = _resolve_target(recipe, target)
267
+ try:
268
+ op_gate(recipe, env, res.logs)
269
+ adapt_graph(recipe, env, res.logs)
270
+ calib = calibration(recipe, env)
271
+ res.logs += (calib or {}).get("logs", [])
272
+ _ = convert(recipe, env); res.logs += getattr(convert, "last_logs", [])
273
+ qdlc = quantize(recipe, calib, env); res.logs += getattr(quantize, "last_logs", [])
274
+ ctx = context(qdlc, tgt, env); res.logs += getattr(context, "last_logs", [])
275
+ res.artifact_path = ctx
276
+ res.tag = _tag(recipe, "bin")
277
+ res.ok = True
278
+ except BuildError as e:
279
+ res.ok = False
280
+ res.failed_axis = e.axis
281
+ res.logs.append(f"{type(e).__name__} at {e.axis}: {e}")
282
+ return res
283
+
284
+
285
+ def dlc_route(recipe: Recipe, env: Optional[Env] = None, target=None) -> BuildResult:
286
+ """Durable route: op-gate → calibration → convert → quantize → cross_compile. Ships the DLC;
287
+ the context binary is regenerated on the board (Constitution V). Returns a BuildResult."""
288
+ res = BuildResult(logs=[])
289
+ tgt = _resolve_target(recipe, target)
290
+ try:
291
+ op_gate(recipe, env, res.logs)
292
+ adapt_graph(recipe, env, res.logs)
293
+ calib = calibration(recipe, env)
294
+ res.logs += (calib or {}).get("logs", [])
295
+ _ = convert(recipe, env); res.logs += getattr(convert, "last_logs", [])
296
+ qdlc = quantize(recipe, calib, env); res.logs += getattr(quantize, "last_logs", [])
297
+ _ = cross_compile(tgt, env); res.logs += getattr(cross_compile, "last_logs", [])
298
+ res.artifact_path = qdlc
299
+ res.tag = _tag(recipe, "dlc")
300
+ res.logs.append("dlc_route: shipping the quantized DLC; context binary regenerated on-board (2 MB VTCM)")
301
+ res.ok = True
302
+ except BuildError as e:
303
+ res.ok = False
304
+ res.failed_axis = e.axis
305
+ res.logs.append(f"{type(e).__name__} at {e.axis}: {e}")
306
+ return res
307
+
308
+
309
+ def fidelity_gate(report: dict, threshold: float = 0.99) -> None:
310
+ """The publish gate: refuse an artifact that loaded but missed its cosine (Constitution III / T024).
311
+
312
+ `report` is a `verify.report(...)` dict ({cosine_min, pass, ...}). Pure — the CLI runs the on-board
313
+ verify and calls this before `registry.add`; a build with no verification never publishes.
314
+ """
315
+ if not report.get("loads", True):
316
+ raise FidelityBelowThreshold("artifact failed to load on the target board")
317
+ cmin = report.get("cosine_min")
318
+ if cmin is not None and cmin < report.get("threshold", threshold):
319
+ raise FidelityBelowThreshold(
320
+ f"cosine_min {cmin:.5f} below threshold {report.get('threshold', threshold)} — not published")
321
+
322
+
323
+ # dispatch by the recipe's declared route
324
+ ROUTES = {"classic": classic, "dlc_route": dlc_route}
325
+
326
+
327
+ def build(recipe: Recipe, env: Optional[Env] = None, target=None) -> BuildResult:
328
+ """Run the route the recipe declares (`recipe.route`)."""
329
+ return ROUTES[recipe.route](recipe, env, target)
chipwright/cache.py ADDED
@@ -0,0 +1,65 @@
1
+ """Content-addressed artifact cache at ~/.cache/chipwright/.
2
+
3
+ Addressed by sha256 so two models that share a build never store it twice (brief §"The manifest and
4
+ the registry"). Verifies the hash on fetch and on read — a corrupted or truncated download is caught
5
+ here, not at load on the board where the error reads like file corruption.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import os
11
+ import urllib.request
12
+
13
+ CACHE_DIR = os.environ.get("CW_CACHE", os.path.expanduser("~/.cache/chipwright"))
14
+
15
+
16
+ def _sha256(path: str, buf: int = 1 << 20) -> str:
17
+ h = hashlib.sha256()
18
+ with open(path, "rb") as f:
19
+ for b in iter(lambda: f.read(buf), b""):
20
+ h.update(b)
21
+ return h.hexdigest()
22
+
23
+
24
+ def path_for(sha256: str) -> str:
25
+ return os.path.join(CACHE_DIR, "blobs", sha256[:2], sha256)
26
+
27
+
28
+ def have(sha256: str) -> bool:
29
+ p = path_for(sha256)
30
+ return os.path.exists(p) and _sha256(p) == sha256
31
+
32
+
33
+ def _ensure(url: str, sha256: str, dst: str) -> str:
34
+ """Download url to dst (verified by sha256), skipping if dst already matches. Returns dst."""
35
+ if os.path.exists(dst) and _sha256(dst) == sha256:
36
+ return dst
37
+ os.makedirs(os.path.dirname(dst), exist_ok=True)
38
+ tmp = dst + ".part"
39
+ urllib.request.urlretrieve(url, tmp)
40
+ got = _sha256(tmp)
41
+ if got != sha256:
42
+ os.remove(tmp)
43
+ raise RuntimeError(f"sha256 mismatch for {url}: got {got}, expected {sha256}")
44
+ os.replace(tmp, dst)
45
+ return dst
46
+
47
+
48
+ def fetch(url: str, sha256: str) -> str:
49
+ """Download url into the content-addressed cache, verify the hash, return the local path."""
50
+ return _ensure(url, sha256, path_for(sha256))
51
+
52
+
53
+ def fetch_named(url: str, sha256: str, name: str, sidecars=None) -> str:
54
+ """Fetch a reference file (verified) into a per-reference dir, KEEPING its original filename, so an
55
+ ONNX's external-data sidecar (`model.data` beside `model.onnx`) resolves. `sidecars` = an iterable of
56
+ (url, sha256, name) fetched into the same dir. Returns the local path to `name`.
57
+
58
+ The content-addressed `fetch` names a blob by its hash, which breaks a model that loads a neighbour
59
+ by filename — so a run reference (cpu_onnx / input / meta) uses this instead.
60
+ """
61
+ d = os.path.join(CACHE_DIR, "refs", sha256[:12])
62
+ main = _ensure(url, sha256, os.path.join(d, name))
63
+ for s_url, s_sha, s_name in (sidecars or []):
64
+ _ensure(s_url, s_sha, os.path.join(d, s_name))
65
+ return main