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,300 @@
1
+ """LLM modality runner — the Genie driver (a slice of what a `chipwright-llm` package would own).
2
+
3
+ Unlike the CV/ASR runners, which diff one NPU tensor against a CPU reference in the same call, the LLM
4
+ runner *generates*: it assembles the Genie bundle on the device, applies the model's chat template,
5
+ runs `genie-t2t-run`, and parses back the generated text, then RETOKENIZES that text into token ids
6
+ (QAIRT 2.51 `genie-t2t-run` emits TEXT, not ids — there is no `--print-token-ids`), plus the optional
7
+ `--profile` latency JSON. The verification against the pinned reference happens in `verify_llm.py` (the
8
+ CPU reference runs on the build host, not here — FR-L10), so this runner's job is to produce an honest
9
+ on-chip generation.
10
+
11
+ Driver facts confirmed on real SM8850 / HTP v81 (QAIRT 2.51), which this runner is written to match:
12
+ - `genie-t2t-run` takes the prompt via `--prompt_file <path>`, NOT an inline `--prompt` of the wrapped
13
+ ChatML (which carries newlines and |<> markers that fight shell quoting).
14
+ - It has NO `--print-token-ids` and NO `--max-length`; both were removed — passing a non-existent flag
15
+ makes genie error out and the gate false-FAIL a good artifact.
16
+ - Deterministic greedy is `sampler.top-k: 1` in the config; `sampler.greedy: true` is silently ignored.
17
+
18
+ Driven entirely over the device interface (`push_file` / `shell` / `pull` / `exists`) so it is
19
+ transport-agnostic (adb for Android targets); it does not import a concrete transport. The chat-template
20
+ wrap and the profile-JSON parse are pulled out as pure functions so they are unit-testable without a
21
+ device — the two places a silent bug (a mis-wrapped prompt makes an instruct model echo/loop) hides.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import re
27
+
28
+
29
+ # --- chat template (pure; the modality wraps, the caller passes raw text) -------------------------
30
+
31
+ def apply_chat_template(prompt: str, template: str = "chatml", system: str | None = None) -> str:
32
+ """Wrap raw user text in the model's chat template. The caller passes raw text; the modality wraps.
33
+
34
+ Instruct models need their chat template or they echo/loop (ChatML for Qwen). Supported templates:
35
+ - "chatml" (Qwen/Qwen2.5): <|im_start|>role\\n...<|im_end|> turns, ending with an open assistant
36
+ turn so generation continues the assistant message.
37
+ - "raw": passthrough (base/non-instruct models).
38
+ An unknown template name is refused by name rather than silently passed through — a mis-wrapped
39
+ prompt is exactly the silent failure this function exists to prevent.
40
+ """
41
+ t = (template or "raw").lower()
42
+ if t == "raw":
43
+ return prompt
44
+ if t == "chatml":
45
+ parts = []
46
+ if system:
47
+ parts.append(f"<|im_start|>system\n{system}<|im_end|>")
48
+ parts.append(f"<|im_start|>user\n{prompt}<|im_end|>")
49
+ parts.append("<|im_start|>assistant\n") # open assistant turn — generation continues it
50
+ return "\n".join(parts)
51
+ raise ValueError(f"unknown chat template {template!r} (known: chatml, raw)")
52
+
53
+
54
+ # --- Genie --profile JSON parse (pure) ------------------------------------------------------------
55
+
56
+ def parse_profile(profile_json: str) -> dict:
57
+ """Parse the `genie-t2t-run --profile` JSON into the record's latency fields.
58
+
59
+ Genie's profiler reports init/prompt-processing/token-generation rates and times. Field names vary
60
+ across Genie builds, so each metric is looked up over a set of aliases and missing ones come back
61
+ None (shown, not hidden) rather than raising — an absent number is a true state of the run.
62
+
63
+ Returns {decode_toks_per_sec, prefill_toks_per_sec, time_to_first_token_ms, model_load_s}.
64
+ """
65
+ data = json.loads(profile_json) if isinstance(profile_json, str) else profile_json
66
+ flat = _flatten(data)
67
+
68
+ def pick(*aliases):
69
+ for a in aliases:
70
+ for k, v in flat.items():
71
+ kl = k.lower()
72
+ if all(tok in kl for tok in a) and isinstance(v, (int, float)):
73
+ return float(v)
74
+ return None
75
+
76
+ return {
77
+ "decode_toks_per_sec": pick(("token", "generation", "rate"), ("decode", "rate"),
78
+ ("token", "per", "sec")),
79
+ "prefill_toks_per_sec": pick(("prompt", "processing", "rate"), ("prefill", "rate"),
80
+ ("prompt", "rate")),
81
+ "time_to_first_token_ms": pick(("time", "first", "token"), ("ttft",), ("first", "token", "ms")),
82
+ "model_load_s": pick(("init", "time"), ("load", "time"), ("model", "load")),
83
+ }
84
+
85
+
86
+ def _flatten(obj, prefix=""):
87
+ """Flatten a nested dict/list into {dotted.key: scalar} so alias lookup is order-independent."""
88
+ out = {}
89
+ if isinstance(obj, dict):
90
+ for k, v in obj.items():
91
+ out.update(_flatten(v, f"{prefix}.{k}" if prefix else str(k)))
92
+ elif isinstance(obj, list):
93
+ for i, v in enumerate(obj):
94
+ out.update(_flatten(v, f"{prefix}.{i}"))
95
+ else:
96
+ out[prefix] = obj
97
+ return out
98
+
99
+
100
+ # --- generated-text / token-ids parse (pure) ------------------------------------------------------
101
+
102
+ def parse_generation(stdout: str) -> dict:
103
+ """Pull the generated text out of `genie-t2t-run` stdout (QAIRT 2.51 emits TEXT, not token ids).
104
+
105
+ Genie prints the generated text after a `[BEGIN]` marker (with an optional `:`). On a clean run it
106
+ closes with `[END]`; but w4a16 degenerates and never emits EOS, so on the real board the run is
107
+ bounded by `timeout` and gets killed mid-generation — the `[END]` is then absent. So the text is
108
+ matched up to `[END]` *or* the end of stdout (`\\Z`), never requiring `[END]`, or a correct-but-long
109
+ generation would be dropped as if empty.
110
+
111
+ `tokens` is still parsed best-effort for any genie build that prints a bracketed id trace, but
112
+ QAIRT 2.51 `genie-t2t-run` has NO `--print-token-ids` flag, so it is normally None here and the
113
+ real token ids come from retokenizing `text` (see `retokenize`).
114
+ """
115
+ text = None
116
+ m = re.search(r"\[BEGIN\]:?(.*?)(\[END\]|\Z)", stdout, re.DOTALL)
117
+ if m:
118
+ text = m.group(1).strip()
119
+ tokens = None
120
+ tm = re.search(r"token[_ ]ids?\s*[:=]\s*\[([\d,\s]+)\]", stdout, re.IGNORECASE)
121
+ if tm:
122
+ tokens = [int(x) for x in tm.group(1).replace(" ", "").split(",") if x != ""]
123
+ return {"text": text, "tokens": tokens}
124
+
125
+
126
+ # --- retokenize generated text -> token ids (genie gives no ids on QAIRT 2.51) --------------------
127
+
128
+ def load_bundle_tokenizer(path: str):
129
+ """Load the bundle's `tokenizer.json` with the lightweight HuggingFace `tokenizers` library.
130
+
131
+ QAIRT 2.51 `genie-t2t-run` emits generated TEXT, not token ids (no `--print-token-ids`), but the
132
+ Layer-1 tripwire (`verify_llm.token_match`) compares TOKEN IDS. So the modality must turn genie's
133
+ text back into ids with the SAME tokenizer the bundle shipped — the `tokenizer.json` genie itself
134
+ decoded with — or the ids won't line up with the pinned reference.
135
+
136
+ If `tokenizers` is not importable, the modality FAILS BY NAME here (like the sshpass/adb preflights)
137
+ rather than silently returning no tokens — a missing retokenizer would otherwise look like an
138
+ all-mismatch FAIL, the exact false-FAIL this module exists to avoid.
139
+ """
140
+ try:
141
+ from tokenizers import Tokenizer
142
+ except ImportError as e: # fail by name, not silently — mirrors the adb/sshpass preflights
143
+ raise RuntimeError(
144
+ "llm modality: the 'tokenizers' library is required to retokenize genie's text output into "
145
+ "token ids (QAIRT 2.51 genie-t2t-run emits text, not ids) but could not be imported. "
146
+ "Install the 'llm' extra: pip install 'chipwright[llm]'") from e
147
+ import os
148
+ if not os.path.exists(path):
149
+ raise FileNotFoundError(
150
+ f"llm modality: bundle tokenizer not found at {path!r} — the gate cannot retokenize "
151
+ f"genie's text without the bundle's tokenizer.json")
152
+ return Tokenizer.from_file(path)
153
+
154
+
155
+ def retokenize(text: str, tokenizer_path: str | None = None, *, tokenizer=None) -> list:
156
+ """Encode generated `text` into token ids with the bundle tokenizer, special tokens OFF.
157
+
158
+ Pure/injectable: pass a loaded `tokenizer` (any object exposing
159
+ `.encode(text, add_special_tokens=False).ids`) to make it unit-testable without a file or the
160
+ library; otherwise it loads `tokenizer_path` via `load_bundle_tokenizer`. `add_special_tokens=False`
161
+ because we match the ANSWER tokens against the reference's answer tokens — a stray BOS/EOS prepended
162
+ by the tokenizer's post-processor would shift every position and tank token_match for a correct run.
163
+ """
164
+ tok = tokenizer if tokenizer is not None else load_bundle_tokenizer(tokenizer_path)
165
+ enc = tok.encode(text, add_special_tokens=False)
166
+ return list(enc.ids)
167
+
168
+
169
+ # --- deterministic greedy config (genie silently ignores sampler.greedy — use top-k:1) ------------
170
+
171
+ def greedy_sampler(base: dict | None = None) -> dict:
172
+ """Return a genie `sampler` block that decodes deterministically (greedy) via `top-k: 1`.
173
+
174
+ Bug proven on SM8850/v81: genie (QAIRT 2.51) SILENTLY IGNORES `sampler.greedy: true` and falls back
175
+ to its default temp-0.8 sampling — the tripwire went non-deterministic and good-vs-self token_match
176
+ was only 0.44. The greedy control genie actually honors is `top-k: 1` (temp 0, top-p 1). We set that
177
+ explicitly and do NOT rely on `greedy: true`.
178
+ """
179
+ s = dict(base or {})
180
+ s.setdefault("version", 1)
181
+ s["top-k"] = 1 # <-- the greedy control genie HONORS; `greedy: true` is silently ignored
182
+ s["temp"] = 0.0
183
+ s["top-p"] = 1.0
184
+ return s
185
+
186
+
187
+ def apply_greedy_to_config(config: dict) -> dict:
188
+ """Return a copy of a genie_config with its dialog sampler forced to deterministic greedy (top-k:1).
189
+
190
+ Pure (deep-copies, does not mutate the input) so it is unit-testable and the on-disk bundle config is
191
+ never clobbered. The modality emits this derived config for the tripwire's greedy run.
192
+ """
193
+ import copy
194
+ cfg = copy.deepcopy(config)
195
+ dialog = cfg.setdefault("dialog", {})
196
+ dialog["sampler"] = greedy_sampler(dialog.get("sampler"))
197
+ return cfg
198
+
199
+
200
+ # --- the modality run (drives Genie over the device interface) ------------------------------------
201
+
202
+ def run(device, bundle_dir_local, meta, prompt, *, template="chatml", system=None,
203
+ remote_dir="/data/local/tmp/chipwright_llm", greedy=True, profile=False, tokenizer=None):
204
+ """Assemble the Genie bundle on the device, generate for one prompt, return text + tokens + latency.
205
+
206
+ `meta` names the bundle pieces (context bins + genie_config + tokenizer + htp ext config) and the
207
+ model fields (template, eos). Signature stays modality-shaped: (device, bundle, meta, prompt).
208
+ Optional `meta` keys honour real-board layout: `genie_bin` (default "genie-t2t-run"), `env`
209
+ (extra shell exports, e.g. LD_LIBRARY_PATH/ADSP_LIBRARY_PATH on the proven v81 board), `timeout_s`
210
+ (wrap the run in `timeout` — w4a16 degenerates and never emits EOS, so an unbounded run hangs).
211
+
212
+ Steps:
213
+ 1. Push the bundle **file-by-file** (a single multi-GB push of a directory can wedge the adb
214
+ daemon — FR-L02), skipping files already present.
215
+ 2. Wrap the raw prompt in the chat template (ChatML for Qwen) and push it as a PROMPT FILE —
216
+ QAIRT 2.51 genie-t2t-run reads the prompt via `--prompt_file <path>`, not inline.
217
+ 3. For the greedy tripwire, emit a derived config with `sampler.top-k: 1` (genie ignores
218
+ `sampler.greedy`) and point genie at it.
219
+ 4. Run `genie-t2t-run -c <cfg> --prompt_file <file>`, parse the `[BEGIN]..[END]` text, and
220
+ RETOKENIZE it into token ids (genie emits no ids on QAIRT 2.51).
221
+
222
+ `profile` defaults **off**: on a real SM8850/v81 board, passing `--profile` to QAIRT 2.51
223
+ genie-t2t-run broke the generation output and made even a known-good artifact score token_match
224
+ 0.0 (false FAIL). Pass `profile=True` only on a genie build confirmed to accept the flag.
225
+
226
+ Returns {text, tokens, latency, generation: <raw stdout>}. The pass/fail gate is `verify_llm`, not
227
+ here — this returns the honest on-chip generation. `tokenizer` may be injected (a loaded Tokenizer)
228
+ for testing; otherwise the bundle's tokenizer.json is loaded.
229
+ """
230
+ import os
231
+
232
+ # 1. bundle transport — one file at a time (FR-L02).
233
+ files = meta.get("bundle_files") or sorted(os.listdir(bundle_dir_local))
234
+ device.shell(f"mkdir -p {remote_dir}")
235
+ for name in files:
236
+ remote = f"{remote_dir}/{name}"
237
+ if device.exists(remote):
238
+ continue
239
+ device.push_file(os.path.join(bundle_dir_local, name), remote)
240
+
241
+ # 2. chat template -> PROMPT FILE. genie-t2t-run (QAIRT 2.51) takes the prompt via --prompt_file,
242
+ # not an inline --prompt: the wrapped ChatML carries newlines and |<> markers that fight shell
243
+ # quoting, and the proven SM8850/v81 path fed it as a file.
244
+ wrapped = apply_chat_template(prompt, template=meta.get("template", template),
245
+ system=meta.get("system", system))
246
+ prompt_local = os.path.join(bundle_dir_local, "prompt.txt")
247
+ with open(prompt_local, "w") as f:
248
+ f.write(wrapped)
249
+ prompt_remote = f"{remote_dir}/prompt.txt"
250
+ device.push_file(prompt_local, prompt_remote)
251
+
252
+ # 3. config — force deterministic greedy via sampler top-k:1 (genie SILENTLY IGNORES sampler.greedy:
253
+ # true, falling back to temp-0.8 sampling — the tripwire went non-deterministic, good-vs-self
254
+ # token_match only 0.44 on SM8850/v81). Emit a derived config; never clobber the bundle's.
255
+ cfg_name = meta.get("genie_config", "genie_config.json")
256
+ if greedy:
257
+ cfg_local = os.path.join(bundle_dir_local, cfg_name)
258
+ with open(cfg_local) as f:
259
+ greedy_obj = apply_greedy_to_config(json.load(f))
260
+ cfg_name = cfg_name.rsplit(".", 1)[0] + ".greedy.json"
261
+ greedy_local = os.path.join(bundle_dir_local, cfg_name)
262
+ with open(greedy_local, "w") as f:
263
+ json.dump(greedy_obj, f)
264
+ device.push_file(greedy_local, f"{remote_dir}/{cfg_name}")
265
+
266
+ # 4. run genie-t2t-run — ONLY flags confirmed on QAIRT 2.51 (-c, --prompt_file, and --profile for
267
+ # latency). --print-token-ids and --max-length were REMOVED: neither exists on the board, and a
268
+ # non-existent flag makes genie error out -> the gate false-FAILs a good artifact.
269
+ genie_bin = meta.get("genie_bin", "genie-t2t-run")
270
+ profile_remote = f"{remote_dir}/profile.json"
271
+ profile_flag = f" --profile {profile_remote}" if profile else ""
272
+ timeout_s = meta.get("timeout_s")
273
+ timeout_prefix = f"timeout {timeout_s} " if timeout_s else ""
274
+ segments = [f"cd {remote_dir}"]
275
+ segments += [f"export {k}={_shquote(str(v))}" for k, v in (meta.get("env") or {}).items()]
276
+ segments.append(f"{timeout_prefix}{genie_bin} -c {cfg_name} --prompt_file {prompt_remote}{profile_flag}")
277
+ proc = device.shell(" && ".join(segments))
278
+ stdout = getattr(proc, "stdout", "") or ""
279
+
280
+ # 5. parse generated text, then RETOKENIZE text -> ids (genie emits no ids on QAIRT 2.51).
281
+ gen = parse_generation(stdout)
282
+ tokens = None
283
+ if gen["text"] is not None:
284
+ tok_path = os.path.join(bundle_dir_local, meta.get("tokenizer", "tokenizer.json"))
285
+ tokens = retokenize(gen["text"], tok_path, tokenizer=tokenizer)
286
+
287
+ # 6. latency (only if profiled).
288
+ latency = {}
289
+ if profile and device.exists(profile_remote):
290
+ local_profile = os.path.join(bundle_dir_local, "profile.json")
291
+ device.pull(profile_remote, local_profile)
292
+ with open(local_profile) as f:
293
+ latency = parse_profile(f.read())
294
+
295
+ return {"text": gen["text"], "tokens": tokens, "latency": latency, "generation": stdout}
296
+
297
+
298
+ def _shquote(s: str) -> str:
299
+ """Single-quote a string for the remote shell (the wrapped prompt carries newlines and |<> marks)."""
300
+ return "'" + s.replace("'", "'\\''") + "'"
chipwright/recipe.py ADDED
@@ -0,0 +1,205 @@
1
+ """Recipe — the reproducible build input, loaded from a YAML document.
2
+
3
+ A recipe is the *only* input to a build: the same recipe plus the same SDK produces the same
4
+ artifact, which is what makes the resulting verification record trustworthy (Constitution III). It is
5
+ a pure dataclass — `load(path)` reads and validates the YAML and nothing else; both build routes
6
+ (`build.classic`, `build.dlc_route`) read the same schema, only the orchestration differs.
7
+
8
+ See `specs/001-chipwright-core/data-model.md` (Recipe ✅) for the authored schema.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass, field
13
+ from typing import Any, Dict, List, Optional
14
+
15
+ import yaml
16
+
17
+ ROUTES = ("classic", "dlc_route")
18
+ RUNS_ON = ("npu", "host")
19
+
20
+ # Precision-support statuses an LLM recipe may declare (spec 002, FR-L06). A status is either the bare
21
+ # word "supported" or a "<kind>:<reason>" where kind names *why* it is not available — so a blocked or
22
+ # unsupported precision is a tracked, named limitation, never a silent gap. The reason is carried
23
+ # through to the resolver's refusal verbatim.
24
+ _PRECISION_KINDS = ("supported", "blocked", "unsupported")
25
+
26
+
27
+ @dataclass
28
+ class Source:
29
+ """The float model this recipe converts — content-addressed so a build is pinned to one graph."""
30
+ format: str = "onnx"
31
+ sha256: Optional[str] = None
32
+ url: Optional[str] = None
33
+
34
+
35
+ @dataclass
36
+ class Target:
37
+ """The single target key this recipe builds for (the axes an artifact tag carries)."""
38
+ arch: str = ""
39
+ quant: str = ""
40
+ shape: str = ""
41
+ sdk_range: str = ""
42
+
43
+
44
+ @dataclass
45
+ class Adaptation:
46
+ """One declared, auditable graph edit. `kind` is the YAML key; `params` is its value."""
47
+ kind: str
48
+ params: Dict[str, Any] = field(default_factory=dict)
49
+
50
+
51
+ @dataclass
52
+ class Quant:
53
+ """How the DLC is quantized — maps to qairt-quantizer flags (act/bias bitwidth, per-channel)."""
54
+ weights: int = 8
55
+ activations: int = 16
56
+ bias: int = 32
57
+ per_channel: bool = True
58
+ calibration_ref: Optional[str] = None
59
+ algorithm: str = "min_max"
60
+
61
+
62
+ @dataclass
63
+ class Host:
64
+ """The modality adapter — the CPU stages around the NPU graph(s). `pre`/`post` are 'module:callable'."""
65
+ pre: Optional[str] = None
66
+ post: Optional[str] = None
67
+ deps: List[str] = field(default_factory=list)
68
+
69
+
70
+ @dataclass
71
+ class Graph:
72
+ """One ordered, typed stage. `runs_on` ∈ {npu, host}; list order is execution order."""
73
+ name: str
74
+ runs_on: str = "npu"
75
+
76
+
77
+ @dataclass
78
+ class Recipe:
79
+ name: str
80
+ version: str
81
+ route: str
82
+ source: Source
83
+ target: Target
84
+ adaptations: List[Adaptation] = field(default_factory=list)
85
+ quant: Quant = field(default_factory=Quant)
86
+ host: Host = field(default_factory=Host)
87
+ graphs: List[Graph] = field(default_factory=list)
88
+ precision_support: Dict[str, str] = field(default_factory=dict) # {precision: status} — LLM, FR-L06
89
+
90
+ @property
91
+ def quant_tag(self) -> str:
92
+ """The 'w8a16'-style tag this recipe's quant block realises."""
93
+ return f"w{self.quant.weights}a{self.quant.activations}"
94
+
95
+ def precision_status(self, precision: str) -> Optional[str]:
96
+ """The declared status for `precision` ('supported' / 'blocked:<r>' / 'unsupported:<r>'), or None."""
97
+ return self.precision_support.get(precision)
98
+
99
+ @property
100
+ def supported_precisions(self) -> List[str]:
101
+ """The precisions this recipe declares runnable through the pipeline."""
102
+ return [p for p, s in self.precision_support.items() if s == "supported"]
103
+
104
+ def adaptation(self, kind: str) -> Optional[Adaptation]:
105
+ """The declared adaptation of a given kind, or None (e.g. recipe.adaptation('erf_to_tanh'))."""
106
+ return next((a for a in self.adaptations if a.kind == kind), None)
107
+
108
+ @property
109
+ def npu_graphs(self) -> List[Graph]:
110
+ return [g for g in self.graphs if g.runs_on == "npu"]
111
+
112
+
113
+ def _parse_adaptations(items: Any) -> List[Adaptation]:
114
+ """`adaptations:` is a list of single-key maps ({erf_to_tanh: {...}}) — keep key as kind, value as params."""
115
+ out: List[Adaptation] = []
116
+ for item in items or []:
117
+ if isinstance(item, dict):
118
+ for kind, params in item.items():
119
+ out.append(Adaptation(kind=str(kind), params=params if isinstance(params, dict) else {"value": params}))
120
+ else:
121
+ out.append(Adaptation(kind=str(item), params={}))
122
+ return out
123
+
124
+
125
+ def _parse_precision_support(items: Any) -> Dict[str, str]:
126
+ """Parse + validate `precision_support:` — a map {precision: status}. Raises ValueError if malformed.
127
+
128
+ A status must be "supported" or "<blocked|unsupported>:<reason>" with a non-empty reason, so a
129
+ recipe cannot smuggle in a vague "no" — a limitation is named or it is not a valid recipe (the same
130
+ strictness as the tag grammar: a status that doesn't parse is a bug, not a near-miss to paper over).
131
+ """
132
+ if not items:
133
+ return {}
134
+ if not isinstance(items, dict):
135
+ raise ValueError(f"precision_support must be a mapping {{precision: status}}, got {type(items).__name__}")
136
+ out: Dict[str, str] = {}
137
+ for precision, status in items.items():
138
+ status = str(status)
139
+ head = status.split(":", 1)[0]
140
+ if head not in _PRECISION_KINDS:
141
+ raise ValueError(
142
+ f"precision_support[{precision!r}] = {status!r}: status must start with one of "
143
+ f"{_PRECISION_KINDS} (e.g. 'supported' or 'blocked:kv-cache-encoding')")
144
+ if head != "supported" and len(status.split(":", 1)) == 2 and not status.split(":", 1)[1].strip():
145
+ raise ValueError(
146
+ f"precision_support[{precision!r}] = {status!r}: a {head!r} status must name a reason "
147
+ f"after the colon (e.g. '{head}:kv-cache-encoding')")
148
+ if head != "supported" and ":" not in status:
149
+ raise ValueError(
150
+ f"precision_support[{precision!r}] = {status!r}: a {head!r} status must name a reason "
151
+ f"('{head}:<reason>'); only 'supported' stands alone")
152
+ out[str(precision)] = status
153
+ return out
154
+
155
+
156
+ def from_dict(doc: Dict[str, Any]) -> Recipe:
157
+ """Build a Recipe from an already-parsed YAML mapping. Pure — no I/O. Raises ValueError if malformed."""
158
+ if not isinstance(doc, dict):
159
+ raise ValueError(f"recipe must be a YAML mapping, got {type(doc).__name__}")
160
+ for req in ("name", "version", "source", "target"):
161
+ if req not in doc:
162
+ raise ValueError(f"recipe missing required field {req!r}")
163
+
164
+ route = str(doc.get("route", "classic"))
165
+ if route not in ROUTES:
166
+ raise ValueError(f"unknown route {route!r}; expected one of {ROUTES}")
167
+
168
+ src = doc["source"] or {}
169
+ source = Source(format=str(src.get("format", "onnx")), sha256=src.get("sha256"), url=src.get("url"))
170
+ if source.format != "onnx":
171
+ raise ValueError(f"source.format must be 'onnx' (the only supported source); got {source.format!r}")
172
+
173
+ tgt = doc["target"] or {}
174
+ target = Target(arch=str(tgt.get("arch", "")), quant=str(tgt.get("quant", "")),
175
+ shape=str(tgt.get("shape", "")), sdk_range=str(tgt.get("sdk_range", "")))
176
+ if not target.arch:
177
+ raise ValueError("target.arch is required (the hard axis, e.g. 'v68')")
178
+
179
+ q = doc.get("quant") or {}
180
+ quant = Quant(weights=int(q.get("weights", 8)), activations=int(q.get("activations", 16)),
181
+ bias=int(q.get("bias", 32)), per_channel=bool(q.get("per_channel", True)),
182
+ calibration_ref=q.get("calibration_ref"), algorithm=str(q.get("algorithm", "min_max")))
183
+
184
+ h = doc.get("host") or {}
185
+ host = Host(pre=h.get("pre"), post=h.get("post"), deps=list(h.get("deps", []) or []))
186
+
187
+ graphs: List[Graph] = []
188
+ for g in doc.get("graphs", []) or []:
189
+ name = str(g.get("name", "")) if isinstance(g, dict) else str(g)
190
+ runs_on = str(g.get("runs_on", "npu")) if isinstance(g, dict) else "npu"
191
+ if runs_on not in RUNS_ON:
192
+ raise ValueError(f"graph {name!r} has runs_on={runs_on!r}; expected one of {RUNS_ON}")
193
+ graphs.append(Graph(name=name, runs_on=runs_on))
194
+
195
+ return Recipe(name=str(doc["name"]), version=str(doc["version"]), route=route,
196
+ source=source, target=target, adaptations=_parse_adaptations(doc.get("adaptations")),
197
+ quant=quant, host=host, graphs=graphs,
198
+ precision_support=_parse_precision_support(doc.get("precision_support")))
199
+
200
+
201
+ def load(path: str) -> Recipe:
202
+ """Load and validate a recipe YAML file into a `Recipe`. Raises ValueError on a malformed recipe."""
203
+ with open(path) as f:
204
+ doc = yaml.safe_load(f)
205
+ return from_dict(doc)
chipwright/registry.py ADDED
@@ -0,0 +1,65 @@
1
+ """Registry client — read the index (a git-repo file, cached) and list a model's variants.
2
+
3
+ Start the registry as a git repository, not a service. The index is a single YAML
4
+ file the client reads; artifacts live in object storage addressed by hash. No server to run, and the
5
+ whole index is reviewable in pull requests — which is what makes a verification record trustworthy.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import urllib.request
11
+ from typing import Dict, List
12
+
13
+ import yaml
14
+
15
+ from .resolver import Variant
16
+
17
+ # Default index: the portable public index bundled inside the package, so it ships in the wheel and
18
+ # resolves after `pip install` (not only in a source checkout). The author's machine-specific overlay
19
+ # is loaded instead via CW_REGISTRY (see registry/index.dev.yaml, git-ignored).
20
+ DEFAULT_INDEX = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data", "index.yaml")
21
+
22
+
23
+ def _load_yaml(src: str) -> dict:
24
+ if src.startswith(("http://", "https://")):
25
+ with urllib.request.urlopen(src, timeout=30) as r:
26
+ return yaml.safe_load(r.read())
27
+ with open(src) as f:
28
+ return yaml.safe_load(f)
29
+
30
+
31
+ class Registry:
32
+ def __init__(self, index: str = None):
33
+ self.index_src = index or os.environ.get("CW_REGISTRY", DEFAULT_INDEX)
34
+ self._models: Dict[str, List[Variant]] = {}
35
+ self._schema = 0
36
+ self._load()
37
+
38
+ def _load(self):
39
+ doc = _load_yaml(self.index_src) or {}
40
+ self._schema = int(doc.get("registry_schema", 1))
41
+ for entry in doc.get("models", []):
42
+ model = entry["model"]
43
+ vs = []
44
+ for v in entry.get("variants", []):
45
+ vs.append(Variant(
46
+ model=model,
47
+ version=str(v.get("version", entry.get("version", "0.0.0"))),
48
+ arch=v["arch"], quant=v["quant"], shape=v["shape"],
49
+ sdk_tested=str(v["sdk_tested"]), sdk_compatible=str(v["sdk_compatible"]),
50
+ ext=v.get("ext", "qnnctx"),
51
+ url=v.get("url"), sha256=v.get("sha256"),
52
+ local=v.get("local"), run=v.get("run"),
53
+ recipe=v.get("recipe"), verified=v.get("verified"),
54
+ ))
55
+ self._models[model] = vs
56
+
57
+ @property
58
+ def schema(self) -> int:
59
+ return self._schema
60
+
61
+ def models(self) -> List[str]:
62
+ return sorted(self._models)
63
+
64
+ def variants(self, model: str) -> List[Variant]:
65
+ return self._models.get(model, [])