chipwright 0.2.5__tar.gz → 0.2.7__tar.gz

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 (70) hide show
  1. {chipwright-0.2.5 → chipwright-0.2.7}/PKG-INFO +1 -1
  2. {chipwright-0.2.5 → chipwright-0.2.7}/chipwright/__init__.py +1 -1
  3. {chipwright-0.2.5 → chipwright-0.2.7}/chipwright/cli.py +56 -13
  4. {chipwright-0.2.5 → chipwright-0.2.7}/chipwright/data/index.yaml +44 -0
  5. {chipwright-0.2.5 → chipwright-0.2.7}/chipwright/jsonout.py +7 -0
  6. {chipwright-0.2.5 → chipwright-0.2.7}/chipwright/recipe.py +54 -1
  7. {chipwright-0.2.5 → chipwright-0.2.7}/chipwright/registry.py +2 -0
  8. chipwright-0.2.7/chipwright/resolver.py +336 -0
  9. {chipwright-0.2.5 → chipwright-0.2.7}/chipwright/run.py +41 -0
  10. chipwright-0.2.7/chipwright/runtimes/__init__.py +40 -0
  11. chipwright-0.2.7/chipwright/runtimes/llamacpp_htp.py +178 -0
  12. chipwright-0.2.7/chipwright/tests/test_llamacpp_htp.py +143 -0
  13. chipwright-0.2.7/chipwright/tests/test_precision_registry.py +68 -0
  14. chipwright-0.2.7/chipwright/tests/test_run_render.py +47 -0
  15. chipwright-0.2.7/chipwright/tests/test_runtime_run.py +103 -0
  16. chipwright-0.2.7/chipwright/tests/test_runtime_support.py +261 -0
  17. {chipwright-0.2.5 → chipwright-0.2.7}/chipwright.egg-info/PKG-INFO +1 -1
  18. {chipwright-0.2.5 → chipwright-0.2.7}/chipwright.egg-info/SOURCES.txt +7 -0
  19. {chipwright-0.2.5 → chipwright-0.2.7}/pyproject.toml +2 -2
  20. chipwright-0.2.5/chipwright/resolver.py +0 -197
  21. {chipwright-0.2.5 → chipwright-0.2.7}/LICENSE +0 -0
  22. {chipwright-0.2.5 → chipwright-0.2.7}/README.md +0 -0
  23. {chipwright-0.2.5 → chipwright-0.2.7}/chipwright/adapt.py +0 -0
  24. {chipwright-0.2.5 → chipwright-0.2.7}/chipwright/build.py +0 -0
  25. {chipwright-0.2.5 → chipwright-0.2.7}/chipwright/cache.py +0 -0
  26. {chipwright-0.2.5 → chipwright-0.2.7}/chipwright/config.py +0 -0
  27. {chipwright-0.2.5 → chipwright-0.2.7}/chipwright/data/behavior_prompts.yaml +0 -0
  28. {chipwright-0.2.5 → chipwright-0.2.7}/chipwright/device.py +0 -0
  29. {chipwright-0.2.5 → chipwright-0.2.7}/chipwright/device_adb.py +0 -0
  30. {chipwright-0.2.5 → chipwright-0.2.7}/chipwright/env.py +0 -0
  31. {chipwright-0.2.5 → chipwright-0.2.7}/chipwright/errors.py +0 -0
  32. {chipwright-0.2.5 → chipwright-0.2.7}/chipwright/llm_reference.py +0 -0
  33. {chipwright-0.2.5 → chipwright-0.2.7}/chipwright/modalities/__init__.py +0 -0
  34. {chipwright-0.2.5 → chipwright-0.2.7}/chipwright/modalities/asr_encoder.py +0 -0
  35. {chipwright-0.2.5 → chipwright-0.2.7}/chipwright/modalities/cv.py +0 -0
  36. {chipwright-0.2.5 → chipwright-0.2.7}/chipwright/modalities/llm.py +0 -0
  37. {chipwright-0.2.5 → chipwright-0.2.7}/chipwright/replay.py +0 -0
  38. {chipwright-0.2.5 → chipwright-0.2.7}/chipwright/sdk.py +0 -0
  39. {chipwright-0.2.5 → chipwright-0.2.7}/chipwright/tags.py +0 -0
  40. {chipwright-0.2.5 → chipwright-0.2.7}/chipwright/task_eval.py +0 -0
  41. {chipwright-0.2.5 → chipwright-0.2.7}/chipwright/tests/conftest.py +0 -0
  42. {chipwright-0.2.5 → chipwright-0.2.7}/chipwright/tests/test_adapt.py +0 -0
  43. {chipwright-0.2.5 → chipwright-0.2.7}/chipwright/tests/test_build.py +0 -0
  44. {chipwright-0.2.5 → chipwright-0.2.7}/chipwright/tests/test_cache.py +0 -0
  45. {chipwright-0.2.5 → chipwright-0.2.7}/chipwright/tests/test_cli_ux.py +0 -0
  46. {chipwright-0.2.5 → chipwright-0.2.7}/chipwright/tests/test_device_adb.py +0 -0
  47. {chipwright-0.2.5 → chipwright-0.2.7}/chipwright/tests/test_device_ctx.py +0 -0
  48. {chipwright-0.2.5 → chipwright-0.2.7}/chipwright/tests/test_emit_record.py +0 -0
  49. {chipwright-0.2.5 → chipwright-0.2.7}/chipwright/tests/test_env.py +0 -0
  50. {chipwright-0.2.5 → chipwright-0.2.7}/chipwright/tests/test_env_adb.py +0 -0
  51. {chipwright-0.2.5 → chipwright-0.2.7}/chipwright/tests/test_json_cli.py +0 -0
  52. {chipwright-0.2.5 → chipwright-0.2.7}/chipwright/tests/test_llm_modality.py +0 -0
  53. {chipwright-0.2.5 → chipwright-0.2.7}/chipwright/tests/test_packaging.py +0 -0
  54. {chipwright-0.2.5 → chipwright-0.2.7}/chipwright/tests/test_precision_support.py +0 -0
  55. {chipwright-0.2.5 → chipwright-0.2.7}/chipwright/tests/test_registry.py +0 -0
  56. {chipwright-0.2.5 → chipwright-0.2.7}/chipwright/tests/test_replay.py +0 -0
  57. {chipwright-0.2.5 → chipwright-0.2.7}/chipwright/tests/test_resolver.py +0 -0
  58. {chipwright-0.2.5 → chipwright-0.2.7}/chipwright/tests/test_run_llm.py +0 -0
  59. {chipwright-0.2.5 → chipwright-0.2.7}/chipwright/tests/test_security.py +0 -0
  60. {chipwright-0.2.5 → chipwright-0.2.7}/chipwright/tests/test_tags.py +0 -0
  61. {chipwright-0.2.5 → chipwright-0.2.7}/chipwright/tests/test_task_eval.py +0 -0
  62. {chipwright-0.2.5 → chipwright-0.2.7}/chipwright/tests/test_verify.py +0 -0
  63. {chipwright-0.2.5 → chipwright-0.2.7}/chipwright/tests/test_verify_llm.py +0 -0
  64. {chipwright-0.2.5 → chipwright-0.2.7}/chipwright/verify.py +0 -0
  65. {chipwright-0.2.5 → chipwright-0.2.7}/chipwright/verify_llm.py +0 -0
  66. {chipwright-0.2.5 → chipwright-0.2.7}/chipwright.egg-info/dependency_links.txt +0 -0
  67. {chipwright-0.2.5 → chipwright-0.2.7}/chipwright.egg-info/entry_points.txt +0 -0
  68. {chipwright-0.2.5 → chipwright-0.2.7}/chipwright.egg-info/requires.txt +0 -0
  69. {chipwright-0.2.5 → chipwright-0.2.7}/chipwright.egg-info/top_level.txt +0 -0
  70. {chipwright-0.2.5 → chipwright-0.2.7}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chipwright
3
- Version: 0.2.5
3
+ Version: 0.2.7
4
4
  Summary: A verified package manager for Qualcomm NPUs — resolve the artifact that loads on your exact chip, run it, and prove it against a CPU reference.
5
5
  License-Expression: MIT
6
6
  Project-URL: Homepage, https://github.com/nameissakthi25/chipwright
@@ -8,4 +8,4 @@ from .resolver import Decision, Outcome, Variant, resolve
8
8
  from .tags import ArtifactTag
9
9
 
10
10
  __all__ = ["TargetKey", "probe", "Registry", "Decision", "Outcome", "Variant", "resolve", "ArtifactTag"]
11
- __version__ = "0.2.5"
11
+ __version__ = "0.2.7"
@@ -234,7 +234,8 @@ def cmd_resolve(args):
234
234
  if args.model not in reg.models():
235
235
  return _unknown_model(args, reg)
236
236
  target = _get_target(args)
237
- d = resolve(target, reg.variants(args.model), args.quant, args.shape)
237
+ d = resolve(target, reg.variants(args.model), args.quant, args.shape,
238
+ want_runtime=getattr(args, "runtime", None), priority=getattr(args, "priority", None))
238
239
  if getattr(args, "json", False):
239
240
  _emit_json(jsonout.decision_to_dict(args.model, target, d))
240
241
  else:
@@ -303,6 +304,42 @@ def cmd_verify(args):
303
304
  return _replay(reg, args.model)
304
305
 
305
306
 
307
+ def _render_run_report(rep, d):
308
+ """Print a run's verification report (human door) and return its exit code. Shape-safe: the LLM
309
+ generative gate reports a `tripwire` (token-match), CV/ASR a cosine — the human door must not diverge
310
+ from the already-shape-safe `--json` projection (Principle IV, and never a KeyError traceback)."""
311
+ if rep.get("runtime") == "llamacpp-htp": # runtime path: a silicon record + the host recipe (FR-RT07)
312
+ rec = rep.get("record") or {}
313
+ d_ = rec.get("dispatch") or {}
314
+ q_ = rec.get("quality") or {}
315
+ print(f"\n{GREEN}✔ cached + hash-verified{RST} {rep.get('artifact')}")
316
+ print(f"{YELLOW}runtime = llamacpp-htp{RST} (on-device execution via the recipe below — not the Genie transport)")
317
+ if d_:
318
+ print(f" on-silicon record ({rec.get('board')}, {rec.get('htp_arch')}, {rec.get('date')}): "
319
+ f"Q8_0 dispatched to HTP0 — prefill {d_.get('htp0_prefill_tps')} vs CPU "
320
+ f"{d_.get('cpu_prefill_tps')} t/s = {d_.get('prefill_speedup')}×")
321
+ if q_:
322
+ print(f" quality: PPL(WikiText) = {q_.get('ppl_wikitext_q8_0')} (near-lossless)")
323
+ print(f" recipe: ADSP_LIBRARY_PATH=<lib> " + " ".join(rep.get("recipe") or []))
324
+ if rep.get("warn"):
325
+ print(f"{DIM} note: {rep['warn']}{RST}")
326
+ return jsonout.EXIT_USE_WITH_WARN
327
+ mark = f"{GREEN}✔ VERIFIED{RST}" if rep["pass"] else f"{RED}✗ MISMATCH{RST}"
328
+ if "tripwire" in rep: # LLM generative gate (token-match), not a cosine
329
+ tw = rep["tripwire"]
330
+ thr = (tw.get("thresholds") or {}).get("token_match")
331
+ print(f"\n{mark} generative gate token_match = {float(tw.get('token_match', 0.0)):.5f}"
332
+ + (f" (threshold {thr})" if thr is not None else ""))
333
+ if rep.get("behavior"):
334
+ print(f" behavioral probe: {rep['behavior']}")
335
+ else:
336
+ print(f"\n{mark} NPU vs CPU-reference cosine_min = {rep['cosine_min']:.5f} "
337
+ f"(threshold {rep['threshold']})")
338
+ for name, c in rep["per_output"].items():
339
+ print(f" {name}: {c:.5f}")
340
+ return jsonout.exit_code_for(d) if rep["pass"] else jsonout.EXIT_VERIFY_MISMATCH
341
+
342
+
306
343
  def cmd_run(args):
307
344
  reg = Registry(args.registry)
308
345
  if args.model not in reg.models():
@@ -312,7 +349,8 @@ def cmd_run(args):
312
349
  target = _get_target(args)
313
350
  if getattr(args, "json", False):
314
351
  return _run_json(args, reg, target)
315
- d = resolve(target, reg.variants(args.model), args.quant, args.shape)
352
+ d = resolve(target, reg.variants(args.model), args.quant, args.shape,
353
+ want_runtime=getattr(args, "runtime", None), priority=getattr(args, "priority", None))
316
354
  _print_decision(args.model, target, d)
317
355
  if not d.ok:
318
356
  if d.outcome == Outcome.BUILD:
@@ -320,7 +358,9 @@ def cmd_run(args):
320
358
  return jsonout.exit_code_for(d)
321
359
  # USE / USE_WITH_WARN: execute on the board and verify against a CPU reference.
322
360
  v = d.variant
323
- if not (v.run and v.run.get("modality")):
361
+ # the llama.cpp-HTP runtime has its own run path (fetch + silicon record + recipe, FR-RT07) and no
362
+ # cosine run block — route it through run_variant instead of the no-run-block short-circuit.
363
+ if not (v.run and v.run.get("modality")) and v.effective_runtime != "llamacpp-htp":
324
364
  print(f"{DIM}artifact resolves, but this variant has no run block (modality/meta/cpu_onnx) — "
325
365
  f"resolve-only.{RST}")
326
366
  if v.url:
@@ -333,12 +373,7 @@ def cmd_run(args):
333
373
  except Exception as e:
334
374
  print(f"{RED}run failed:{RST} {e}")
335
375
  return jsonout.EXIT_UNEXPECTED
336
- mark = f"{GREEN}✔ VERIFIED{RST}" if rep["pass"] else f"{RED}✗ MISMATCH{RST}"
337
- print(f"\n{mark} NPU vs CPU-reference cosine_min = {rep['cosine_min']:.5f} "
338
- f"(threshold {rep['threshold']})")
339
- for name, c in rep["per_output"].items():
340
- print(f" {name}: {c:.5f}")
341
- return jsonout.exit_code_for(d) if rep["pass"] else jsonout.EXIT_VERIFY_MISMATCH
376
+ return _render_run_report(rep, d)
342
377
 
343
378
 
344
379
  def _run_json(args, reg, target) -> int:
@@ -348,13 +383,14 @@ def _run_json(args, reg, target) -> int:
348
383
  """
349
384
  # Everything human — the decision render and the runner's progress lines — to stderr.
350
385
  with contextlib.redirect_stdout(sys.stderr):
351
- d = resolve(target, reg.variants(args.model), args.quant, args.shape)
386
+ d = resolve(target, reg.variants(args.model), args.quant, args.shape,
387
+ want_runtime=getattr(args, "runtime", None), priority=getattr(args, "priority", None))
352
388
  _print_decision(args.model, target, d)
353
389
  if not d.ok:
354
390
  doc, code = jsonout.decision_to_dict(args.model, target, d), jsonout.exit_code_for(d)
355
391
  else:
356
392
  v = d.variant
357
- if not (v.run and v.run.get("modality")):
393
+ if not (v.run and v.run.get("modality")) and v.effective_runtime != "llamacpp-htp":
358
394
  print(f"artifact resolves, but this variant has no run block — resolve-only.")
359
395
  doc = jsonout.decision_to_dict(args.model, target, d)
360
396
  doc["verified"] = None
@@ -363,7 +399,10 @@ def _run_json(args, reg, target) -> int:
363
399
  from . import run as runner
364
400
  rep = runner.run_variant(v, args.input) # may raise → caught by the --json wrapper in main
365
401
  doc = jsonout.run_to_dict(args.model, target, d, rep)
366
- code = jsonout.exit_code_for(d) if rep.get("pass") else jsonout.EXIT_VERIFY_MISMATCH
402
+ if rep.get("runtime") == "llamacpp-htp":
403
+ code = jsonout.EXIT_USE_WITH_WARN # usable, with the documented recipe caveat (FR-RT07)
404
+ else:
405
+ code = jsonout.exit_code_for(d) if rep.get("pass") else jsonout.EXIT_VERIFY_MISMATCH
367
406
  _emit_json(doc)
368
407
  return code
369
408
 
@@ -422,7 +461,8 @@ def cmd_build(args):
422
461
  if args.model not in reg.models():
423
462
  return _unknown_model(args, reg)
424
463
  target = _get_target(args)
425
- d = resolve(target, reg.variants(args.model), args.quant, args.shape)
464
+ d = resolve(target, reg.variants(args.model), args.quant, args.shape,
465
+ want_runtime=getattr(args, "runtime", None), priority=getattr(args, "priority", None))
426
466
  _print_decision(args.model, target, d)
427
467
  if d.ok:
428
468
  print(f"{DIM}already resolves to USE — nothing to build.{RST}")
@@ -486,6 +526,9 @@ def main(argv=None):
486
526
  ap.add_argument("--target", help="offline target, e.g. 'htpv68,qnn2.37'")
487
527
  ap.add_argument("--quant", help="narrow to a quant, e.g. w8a16")
488
528
  ap.add_argument("--shape", help="narrow to a shape profile, e.g. win30s")
529
+ ap.add_argument("--runtime", help="pick an inference runtime, e.g. llamacpp-htp (default: the genie floor)")
530
+ ap.add_argument("--priority", choices=["quality", "decode-latency"],
531
+ help="when a model spans runtimes, prefer quality (llamacpp-htp) or decode-latency (genie)")
489
532
  ap.add_argument("--json", action="store_true",
490
533
  help="emit one JSON document to stdout (resolve/run/list); human text → stderr, "
491
534
  "always plain. Exit codes follow the table below.")
@@ -142,6 +142,50 @@ models:
142
142
  sdk_compatible: '>=2.34,<2.40'
143
143
  ext: qnnctx
144
144
  recipe: recipes/qwen2.5-1.5b/1.0.0.yaml
145
+ # The llama.cpp-HTP runtime variant — a >4-bit integer route (Q8_0) that classic Genie structurally
146
+ # lacks. Silicon-verified on v73 (spec 004 Track 2). On-device execution is via the documented
147
+ # llama.cpp-HTP recipe (staged glibc loader + ADSP_LIBRARY_PATH + libggml-htp-v73.so); chipwright-run
148
+ # CLI integration for this runtime is pending (T-RT11), so `run` here fetches + hash-verifies the GGUF
149
+ # and surfaces the recipe + this record (FR-RT07), rather than driving the board itself.
150
+ - arch: v73
151
+ quant: Q8_0
152
+ shape: ctx4096
153
+ runtime: llamacpp-htp
154
+ sdk_tested: b3278e921
155
+ sdk_compatible: llama.cpp-HTP (no QAIRT dependency)
156
+ ext: gguf
157
+ url: https://huggingface.co/nameissakthi/chipwright-llm/resolve/main/qwen2.5-1.5b-Q8_0.gguf
158
+ sha256: cedde4175edae2e9467d8b98d70843e57c8fb101afd54ca773bce022e80f7e4e
159
+ runtime_support:
160
+ llamacpp-htp:
161
+ v68: 'unsupported:needs-v73-or-newer-hexagon'
162
+ v73: supported
163
+ genie:
164
+ v68: supported
165
+ v73: supported
166
+ verified:
167
+ 'on': QCS9075 (Dragonwing IQ-9075, soc_id 676, Hexagon v73)
168
+ date: 2026-10-24
169
+ loads: true
170
+ runtime: llamacpp-htp
171
+ vs_reference:
172
+ backend: llama.cpp-cpu (same Q8_0 GGUF, GGML_HEXAGON_NDEV=0)
173
+ metric: dispatch+ppl
174
+ dispatch:
175
+ device: HTP0
176
+ htp0_prefill_tps: 592.82
177
+ cpu_prefill_tps: 217.55
178
+ prefill_speedup: 2.72
179
+ note: Q8_0 MUL_MAT dispatched to HTP0 on the compute-DSP (_dom=cdsp), not CPU fallback (SC-RT03)
180
+ quality:
181
+ metric: ppl_wikitext
182
+ chunks: 16
183
+ ppl_q8_0: 11.6631
184
+ ppl_stderr: 0.49427
185
+ ppl_f16_baseline: 11.6388
186
+ note: near-lossless vs the model's own f16
187
+ llamacpp_build: b3278e921
188
+ note: the >4-bit integer route Genie lacks; on-device exec via the llama.cpp-HTP recipe (CLI integration T-RT11)
145
189
  - model: qwen2.5-7b-instruct
146
190
  source: Qwen/Qwen2.5-7B-Instruct
147
191
  license: apache-2.0
@@ -100,6 +100,13 @@ def run_to_dict(model: str, target, d: Decision, report: dict) -> dict:
100
100
  rec["outputs"] = dict(report.get("per_output") or {})
101
101
  if "tripwire" in report: # LLM path: a generative tripwire, not a cosine
102
102
  rec["tripwire"] = report["tripwire"]
103
+ if report.get("runtime") == "llamacpp-htp": # runtime path: a silicon record + the host recipe
104
+ rec["runtime"] = report["runtime"]
105
+ rec["record"] = report.get("record")
106
+ rec["recipe"] = report.get("recipe")
107
+ rec["cli_execution"] = report.get("cli_execution")
108
+ if report.get("warn"):
109
+ rec.setdefault("warnings", []).append(report["warn"])
103
110
  return rec
104
111
 
105
112
 
@@ -23,6 +23,13 @@ RUNS_ON = ("npu", "host")
23
23
  # through to the resolver's refusal verbatim.
24
24
  _PRECISION_KINDS = ("supported", "blocked", "unsupported")
25
25
 
26
+ # Runtime-support statuses a recipe may declare (spec 004, FR-RT04). The runtime axis is the precision
27
+ # axis a second time — the same {key: "supported" | "<kind>:<reason>"} matrix the resolver already refuses
28
+ # on — only one level deeper, because a runtime's support is *per arch*: `{runtime: {arch: status}}`. A
29
+ # status is the bare word "supported", or "deprecated:<successor>" (runnable, but deprecating upstream —
30
+ # resolve warns), or "unsupported:<reason>" (the board's arch cannot host it — resolve refuses by name).
31
+ _RUNTIME_KINDS = ("supported", "deprecated", "unsupported")
32
+
26
33
 
27
34
  @dataclass
28
35
  class Source:
@@ -86,6 +93,7 @@ class Recipe:
86
93
  host: Host = field(default_factory=Host)
87
94
  graphs: List[Graph] = field(default_factory=list)
88
95
  precision_support: Dict[str, str] = field(default_factory=dict) # {precision: status} — LLM, FR-L06
96
+ runtime_support: Dict[str, Dict[str, str]] = field(default_factory=dict) # {runtime: {arch: status}} — FR-RT04
89
97
 
90
98
  @property
91
99
  def quant_tag(self) -> str:
@@ -101,6 +109,15 @@ class Recipe:
101
109
  """The precisions this recipe declares runnable through the pipeline."""
102
110
  return [p for p, s in self.precision_support.items() if s == "supported"]
103
111
 
112
+ def runtime_status(self, runtime: str, arch: str) -> Optional[str]:
113
+ """The declared status for `runtime` on `arch` ('supported'/'deprecated:<s>'/'unsupported:<r>'), or None."""
114
+ return self.runtime_support.get(runtime, {}).get(arch)
115
+
116
+ def runnable_runtimes(self, arch: str) -> List[str]:
117
+ """The runtimes this recipe declares runnable on `arch` — 'supported' OR 'deprecated' (not 'unsupported')."""
118
+ return sorted(rt for rt, am in self.runtime_support.items()
119
+ if str(am.get(arch, "")).split(":", 1)[0] in ("supported", "deprecated"))
120
+
104
121
  def adaptation(self, kind: str) -> Optional[Adaptation]:
105
122
  """The declared adaptation of a given kind, or None (e.g. recipe.adaptation('erf_to_tanh'))."""
106
123
  return next((a for a in self.adaptations if a.kind == kind), None)
@@ -153,6 +170,41 @@ def _parse_precision_support(items: Any) -> Dict[str, str]:
153
170
  return out
154
171
 
155
172
 
173
+ def _parse_runtime_support(items: Any) -> Dict[str, Dict[str, str]]:
174
+ """Parse + validate `runtime_support:` — a nested map {runtime: {arch: status}}. Raises ValueError if malformed.
175
+
176
+ A near-clone of `_parse_precision_support`, one level deeper (runtime -> arch -> status) because a
177
+ runtime's support is per-arch. A status must be "supported" or "<deprecated|unsupported>:<tail>" with a
178
+ non-empty tail (the successor for `deprecated`, the reason for `unsupported`) — so a recipe cannot smuggle
179
+ in a vague "no": a limitation is named or it is not a valid recipe (the same strictness as the tag grammar).
180
+ """
181
+ if not items:
182
+ return {}
183
+ if not isinstance(items, dict):
184
+ raise ValueError(f"runtime_support must be a mapping {{runtime: {{arch: status}}}}, got {type(items).__name__}")
185
+ out: Dict[str, Dict[str, str]] = {}
186
+ for runtime, archmap in items.items():
187
+ if not isinstance(archmap, dict):
188
+ raise ValueError(
189
+ f"runtime_support[{runtime!r}] must be a mapping {{arch: status}}, got {type(archmap).__name__}")
190
+ per_arch: Dict[str, str] = {}
191
+ for arch, status in archmap.items():
192
+ status = str(status)
193
+ head = status.split(":", 1)[0]
194
+ if head not in _RUNTIME_KINDS:
195
+ raise ValueError(
196
+ f"runtime_support[{runtime!r}][{arch!r}] = {status!r}: status must start with one of "
197
+ f"{_RUNTIME_KINDS} (e.g. 'supported' or 'unsupported:htp-backend-excludes-v68')")
198
+ if head != "supported" and (":" not in status or not status.split(":", 1)[1].strip()):
199
+ raise ValueError(
200
+ f"runtime_support[{runtime!r}][{arch!r}] = {status!r}: a {head!r} status must name a "
201
+ f"reason/successor after the colon (e.g. 'deprecated:geniex-qairt' or "
202
+ f"'unsupported:htp-backend-excludes-v68'); only 'supported' stands alone")
203
+ per_arch[str(arch)] = status
204
+ out[str(runtime)] = per_arch
205
+ return out
206
+
207
+
156
208
  def from_dict(doc: Dict[str, Any]) -> Recipe:
157
209
  """Build a Recipe from an already-parsed YAML mapping. Pure — no I/O. Raises ValueError if malformed."""
158
210
  if not isinstance(doc, dict):
@@ -195,7 +247,8 @@ def from_dict(doc: Dict[str, Any]) -> Recipe:
195
247
  return Recipe(name=str(doc["name"]), version=str(doc["version"]), route=route,
196
248
  source=source, target=target, adaptations=_parse_adaptations(doc.get("adaptations")),
197
249
  quant=quant, host=host, graphs=graphs,
198
- precision_support=_parse_precision_support(doc.get("precision_support")))
250
+ precision_support=_parse_precision_support(doc.get("precision_support")),
251
+ runtime_support=_parse_runtime_support(doc.get("runtime_support")))
199
252
 
200
253
 
201
254
  def load(path: str) -> Recipe:
@@ -57,6 +57,8 @@ class Registry:
57
57
  url=v.get("url"), sha256=v.get("sha256"),
58
58
  local=v.get("local"), run=v.get("run"),
59
59
  recipe=v.get("recipe"), verified=v.get("verified"),
60
+ runtime=v.get("runtime"), runtime_support=v.get("runtime_support"),
61
+ precision_support=v.get("precision_support"),
60
62
  ))
61
63
  self._models[model] = vs
62
64
 
@@ -0,0 +1,336 @@
1
+ """Resolver — match registry variants against a board's TargetKey, with an explicit miss path.
2
+
3
+ The resolution rules, in order:
4
+
5
+ 1. Exact match on arch, quant, shape; SDK inside the compatible range -> USE
6
+ 2. Match, but SDK outside the tested point (still in range) -> USE_WITH_WARN
7
+ 3. No artifact, but a recipe whose target can be built -> BUILD (offer, estimate)
8
+ 4. Nothing -> FAIL, name the axis
9
+
10
+ Never silently downgrade to a near-miss artifact. The arch axis is hard (a mismatch fails at load);
11
+ the SDK axis is a range, not a point (warn outside the tested point, don't refuse).
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass
16
+ from enum import Enum
17
+ from typing import List, Optional
18
+
19
+ from .env import TargetKey
20
+
21
+
22
+ class Outcome(Enum):
23
+ USE = "use" # exact match — run it
24
+ USE_WITH_WARN = "use_warn" # in-range but outside the tested SDK point
25
+ BUILD = "build" # no artifact, but a recipe can produce one
26
+ FAIL = "fail" # nothing — the named axis is why
27
+
28
+
29
+ @dataclass
30
+ class Variant:
31
+ """One build variant from the registry (the registry's unit is a variant, not a model)."""
32
+ model: str
33
+ version: str
34
+ arch: str
35
+ quant: str
36
+ shape: str
37
+ sdk_tested: str # e.g. "2.37.1"
38
+ sdk_compatible: str # e.g. ">=2.34,<2.40"
39
+ ext: str = "qnnctx"
40
+ url: Optional[str] = None # present => a built artifact exists (remote)
41
+ sha256: Optional[str] = None
42
+ local: Optional[str] = None # present => an artifact file on disk (not yet hosted)
43
+ run: Optional[dict] = None # how `run` executes it: {modality, meta, cpu_onnx, board_name, input}
44
+ recipe: Optional[str] = None # present => can be built when no artifact matches
45
+ verified: Optional[dict] = None # the verification record (loads / cosine / task / latency)
46
+ precision_support: Optional[dict] = None # LLM: {precision: "supported"|"blocked:<r>"|"unsupported:<r>"}
47
+ runtime: Optional[str] = None # the inference runtime this variant binds to (None => classic genie)
48
+ runtime_support: Optional[dict] = None # {runtime: {arch: "supported"|"deprecated:<s>"|"unsupported:<r>"}} — FR-RT04
49
+
50
+ @property
51
+ def has_artifact(self) -> bool:
52
+ return bool(self.url or self.local)
53
+
54
+ @property
55
+ def effective_runtime(self) -> str:
56
+ """The runtime this variant runs on. Historically every variant silently assumed classic Genie
57
+ (spec 004), so an unset runtime IS `genie` — the deprecating-but-default floor."""
58
+ return self.runtime or "genie"
59
+
60
+ @property
61
+ def has_recipe(self) -> bool:
62
+ return bool(self.recipe)
63
+
64
+
65
+ @dataclass
66
+ class Decision:
67
+ outcome: Outcome
68
+ variant: Optional[Variant] = None
69
+ reason: str = ""
70
+ axis: Optional[str] = None # on FAIL: which axis could not be satisfied
71
+ warning: Optional[str] = None # on USE_WITH_WARN
72
+
73
+ @property
74
+ def ok(self) -> bool:
75
+ return self.outcome in (Outcome.USE, Outcome.USE_WITH_WARN)
76
+
77
+
78
+ # --- precision-support axis (LLM) -------------------------------------------------------------
79
+ # Not every precision survives the qai-hub -> Genie pipeline (spec 002, FR-L06): w4a16 is supported,
80
+ # w8a16 is blocked on a KV-cache-encoding divergence at convert, fp16 is unsupported because Genie's
81
+ # transformer path is integer-only. A recipe declares this per model; `resolve` refuses a non-supported
82
+ # precision and NAMES the reason — the same discipline as FAIL-names-the-axis, applied one axis earlier.
83
+ _SUPPORTED = "supported"
84
+
85
+
86
+ def precision_refusal(precision: Optional[str], support: Optional[dict]) -> Optional[str]:
87
+ """Return the named reason to refuse `precision`, or None if it is supported / not constrained.
88
+
89
+ Pure and tiny so it is trivial to test. A precision absent from the matrix is *not* refused here
90
+ (the matrix only constrains what it declares); the normal variant/quant matching handles the rest.
91
+ A declared non-"supported" status (e.g. "blocked:kv-cache-encoding") is returned verbatim so the
92
+ caller can quote the reason by name.
93
+ """
94
+ if not precision or not support:
95
+ return None
96
+ status = support.get(precision)
97
+ if status is None or status == _SUPPORTED:
98
+ return None
99
+ return str(status)
100
+
101
+
102
+ # --- runtime axis (spec 004) ------------------------------------------------------------------
103
+ # The same shape as precision, one axis over. A model+board can be served by different inference runtimes
104
+ # ({genie, geniex-qairt, llamacpp-htp}) with genuinely different arch + precision support (classic Genie is
105
+ # w4a16-only and deprecating; the llama.cpp HTP backend does Q4_0/Q8_0 on v73+ but NOT v68). So `resolve`
106
+ # must (a) refuse a runtime the board's arch cannot host, BY NAME (`axis=runtime`), and (b) among the
107
+ # runtimes the board CAN host, pick the one matching the caller's priority. The support table is per-arch:
108
+ # `{runtime: {arch: status}}`, validated in recipe.py (`_parse_runtime_support`).
109
+ #
110
+ # Priority -> the order runtimes are preferred in, among those runnable on the board's arch. Grounded in the
111
+ # v73 measurement: llamacpp-htp Q8_0 is the quality/prefill win (WikiText PPL 11.66, near-lossless, the
112
+ # >4-bit integer route Genie structurally lacks) but regresses decode (14.4 vs 22.5 tok/s); genie w4a16 is
113
+ # the decode win and the default floor. So quality-first prefers llamacpp-htp, decode-latency-first prefers
114
+ # genie, and the no-priority default is the conservative floor (genie) with a warn to choose explicitly.
115
+ _RUNTIME_PRIORITY = {
116
+ "quality": ("llamacpp-htp", "geniex-qairt", "genie"),
117
+ "decode-latency": ("genie", "geniex-qairt", "llamacpp-htp"),
118
+ }
119
+ _DEFAULT_RUNTIME_ORDER = ("genie", "geniex-qairt", "llamacpp-htp")
120
+ _RUNNABLE = ("supported", "deprecated") # 'unsupported' is the only hard refusal; 'deprecated' still runs
121
+
122
+
123
+ def runtime_refusal(runtime: Optional[str], arch: str, support: Optional[dict]) -> Optional[str]:
124
+ """Return the named reason to refuse `runtime` on `arch`, or None if it is runnable / not constrained.
125
+
126
+ Pure and tiny, mirroring `precision_refusal`. A runtime absent from the matrix (or an arch the matrix
127
+ doesn't declare for it) is *not* refused here — the matrix only constrains what it declares. Only an
128
+ explicit `unsupported:<reason>` is a refusal; a `deprecated:<successor>` status still runs (resolve warns).
129
+ """
130
+ if not runtime or not support:
131
+ return None
132
+ archmap = support.get(runtime)
133
+ if not isinstance(archmap, dict):
134
+ return None
135
+ status = archmap.get(arch)
136
+ if status is None or str(status).split(":", 1)[0] != "unsupported":
137
+ return None
138
+ return str(status)
139
+
140
+
141
+ def runnable_runtimes(arch: str, support: Optional[dict]) -> List[str]:
142
+ """The runtimes the matrix declares runnable on `arch` (status 'supported' or 'deprecated')."""
143
+ if not support:
144
+ return []
145
+ return sorted(rt for rt, am in support.items()
146
+ if isinstance(am, dict) and str(am.get(arch, "")).split(":", 1)[0] in _RUNNABLE)
147
+
148
+
149
+ def pick_runtime(runtimes: List[str], priority: Optional[str]):
150
+ """Choose one runtime from `runtimes` (all runnable on the board's arch) by `priority`.
151
+
152
+ Returns (chosen, note). `note` records the trade-off (which runtime was chosen over which) so the
153
+ Decision can carry it — tested-is-a-record, now across runtimes.
154
+ """
155
+ order = _RUNTIME_PRIORITY.get(priority)
156
+ if order:
157
+ for rt in order:
158
+ if rt in runtimes:
159
+ others = [r for r in runtimes if r != rt]
160
+ return rt, f"priority={priority}: chose runtime {rt} over {others}"
161
+ # no (or unknown) priority -> the conservative default floor, and say how to choose
162
+ for rt in _DEFAULT_RUNTIME_ORDER:
163
+ if rt in runtimes:
164
+ others = [r for r in runtimes if r != rt]
165
+ return rt, (f"multiple runtimes available {sorted(runtimes)}; defaulted to {rt} "
166
+ f"(pass priority=quality|decode-latency to choose)" + (f"; alternatives {others}" if others else ""))
167
+ rt = sorted(runtimes)[0] # only unrecognised runtimes present
168
+ return rt, f"multiple runtimes {sorted(runtimes)}; defaulted to {rt}"
169
+
170
+
171
+ def _merge_warn(*parts: Optional[str]) -> Optional[str]:
172
+ """Join the non-empty warning fragments, or None."""
173
+ return " | ".join(p for p in parts if p) or None
174
+
175
+
176
+ # --- tiny semver range check (enough for QAIRT x.y.z) -----------------------------------------
177
+ def _ver(v: str):
178
+ return tuple(int(x) for x in (v.split("-")[0].split("+")[0].split(".") + ["0", "0"])[:3])
179
+
180
+
181
+ def _in_range(point: str, rng: str) -> bool:
182
+ """point like '2.37.1' against a comma range like '>=2.34,<2.40'."""
183
+ p = _ver(point)
184
+ for clause in rng.split(","):
185
+ clause = clause.strip()
186
+ for op in (">=", "<=", "==", ">", "<"):
187
+ if clause.startswith(op):
188
+ bound = _ver(clause[len(op):])
189
+ ok = {">=": p >= bound, "<=": p <= bound, "==": p == bound,
190
+ ">": p > bound, "<": p < bound}[op]
191
+ if not ok:
192
+ return False
193
+ break
194
+ return True
195
+
196
+
197
+ def resolve(target: TargetKey, variants: List[Variant],
198
+ want_quant: Optional[str] = None, want_shape: Optional[str] = None,
199
+ precision_support: Optional[dict] = None,
200
+ want_runtime: Optional[str] = None, priority: Optional[str] = None,
201
+ runtime_support: Optional[dict] = None) -> Decision:
202
+ """Return the single Decision for this board. Pure function — easy to test without a board."""
203
+ if not variants:
204
+ return Decision(Outcome.FAIL, reason="no variants registered for this model", axis="model")
205
+
206
+ # The runtime-support matrix may be passed explicitly (e.g. straight from the recipe) or carried on the
207
+ # variants (the registry populates it). Resolve it once so both the refusal and the selection use it.
208
+ rsupport = runtime_support
209
+ if rsupport is None:
210
+ rsupport = next((v.runtime_support for v in variants if v.runtime_support), None)
211
+
212
+ # Axis 0b (runtime refusal): mirror the precision refusal one axis over. If the caller names a runtime
213
+ # the board's arch cannot host, refuse up front and NAME it — listing the runtimes that ARE runnable on
214
+ # this arch — before arch/SDK matching, because the runtime is unavailable on this silicon regardless of
215
+ # which artifact is in hand (FAIL-names-the-axis, FR-RT03 / SC-RT02).
216
+ if want_runtime:
217
+ reason = runtime_refusal(want_runtime, target.htp_arch, rsupport)
218
+ if reason:
219
+ ok = runnable_runtimes(target.htp_arch, rsupport)
220
+ return Decision(Outcome.FAIL, axis="runtime",
221
+ reason=f"runtime {want_runtime} is not available on {target.htp_arch}: {reason} "
222
+ f"(supported on {target.htp_arch}: {ok})")
223
+
224
+ # Axis 0 (LLM precision-support): if the caller asked for a precision the model declares as
225
+ # blocked/unsupported, refuse up front and name the reason — before arch/SDK matching, because the
226
+ # precision is unavailable through the pipeline regardless of which board is in hand. The matrix may
227
+ # be passed explicitly or carried on the variants (the registry populates it per model).
228
+ if want_quant:
229
+ support = precision_support
230
+ if support is None:
231
+ support = next((v.precision_support for v in variants if v.precision_support), None)
232
+ reason = precision_refusal(want_quant, support)
233
+ if reason:
234
+ return Decision(Outcome.FAIL, axis="precision",
235
+ reason=f"precision {want_quant} is not available: {reason}")
236
+
237
+ # Axis 1 (hard): Hexagon architecture must match exactly, or the binary fails at load.
238
+ arch_ok = [v for v in variants if v.arch == target.htp_arch]
239
+ if not arch_ok:
240
+ have = sorted({v.arch for v in variants})
241
+ return Decision(Outcome.FAIL, axis="htp_arch",
242
+ reason=f"no variant for arch {target.htp_arch}; registry has {have}")
243
+
244
+ # Optional narrowing on quant / shape when the caller asked for a specific one.
245
+ cand = arch_ok
246
+ if want_quant:
247
+ c2 = [v for v in cand if v.quant == want_quant]
248
+ if not c2:
249
+ have = sorted({v.quant for v in cand})
250
+ return Decision(Outcome.FAIL, axis="quant",
251
+ reason=f"no {want_quant} for {target.htp_arch}; have {have}")
252
+ cand = c2
253
+ if want_shape:
254
+ c2 = [v for v in cand if v.shape == want_shape]
255
+ if not c2:
256
+ have = sorted({v.shape for v in cand})
257
+ return Decision(Outcome.FAIL, axis="shape",
258
+ reason=f"no shape {want_shape} for {target.htp_arch}; have {have}")
259
+ cand = c2
260
+
261
+ # Runtime axis (spec 004): pick WHICH runtime among the candidates runnable on this board's arch.
262
+ # If the caller named a runtime, narrow to it (its refusal was already handled above). Otherwise, only
263
+ # when the candidates span more than one runtime do we choose by priority — a single-runtime model
264
+ # resolves exactly as before, so nothing that pre-dates the runtime axis changes behaviour.
265
+ runtime_warning = None
266
+ if want_runtime:
267
+ c2 = [v for v in cand if v.effective_runtime == want_runtime]
268
+ if not c2:
269
+ have = sorted({v.effective_runtime for v in cand})
270
+ return Decision(Outcome.FAIL, axis="runtime",
271
+ reason=f"runtime {want_runtime} has no variant for {target.htp_arch}; have {have}")
272
+ cand = c2
273
+ else:
274
+ runtimes_here = sorted({v.effective_runtime for v in cand})
275
+ if len(runtimes_here) > 1:
276
+ chosen, runtime_warning = pick_runtime(runtimes_here, priority)
277
+ cand = [v for v in cand if v.effective_runtime == chosen]
278
+ # Warn if the chosen runtime is marked deprecated on this arch (its successor is named in the matrix).
279
+ if rsupport and cand:
280
+ dep = rsupport.get(cand[0].effective_runtime, {})
281
+ dep = dep.get(target.htp_arch, "") if isinstance(dep, dict) else ""
282
+ if str(dep).split(":", 1)[0] == "deprecated":
283
+ runtime_warning = _merge_warn(runtime_warning,
284
+ f"runtime {cand[0].effective_runtime} on {target.htp_arch} is {dep}")
285
+
286
+ built = [v for v in cand if v.has_artifact]
287
+
288
+ # The QAIRT SDK range is a genie/QNN concept — a GGUF on the llama.cpp-HTP runtime has no QAIRT
289
+ # dependency, its "sdk" is the llama.cpp build, which is not comparable to the board's QAIRT. So skip
290
+ # the QAIRT axis for it: a matching built artifact on this arch is USE (carry any runtime warning).
291
+ if built and built[0].effective_runtime == "llamacpp-htp":
292
+ v = built[0]
293
+ if runtime_warning:
294
+ return Decision(Outcome.USE_WITH_WARN, variant=v, warning=runtime_warning,
295
+ reason=f"artifact matches arch/quant/shape; runtime llamacpp-htp (llama.cpp {v.sdk_tested})")
296
+ return Decision(Outcome.USE, variant=v,
297
+ reason=f"artifact matches arch/quant/shape; runtime llamacpp-htp (llama.cpp {v.sdk_tested})")
298
+
299
+ # Axis: SDK range. If we don't know the board's QAIRT (offline), we can't check the point —
300
+ # prefer a built artifact and warn that the SDK match is unverified.
301
+ if built:
302
+ if target.qairt is None:
303
+ v = built[0]
304
+ return Decision(Outcome.USE_WITH_WARN, variant=v,
305
+ warning=_merge_warn("board QAIRT unknown (offline) — SDK compatibility unverified",
306
+ runtime_warning),
307
+ reason="artifact matches arch/quant/shape")
308
+ in_range = [v for v in built if _in_range(target.qairt, v.sdk_compatible)]
309
+ if in_range:
310
+ exact = [v for v in in_range if _ver(v.sdk_tested) == _ver(target.qairt)]
311
+ if exact:
312
+ return Decision(Outcome.USE, variant=exact[0], reason="exact match", warning=runtime_warning)
313
+ v = in_range[0]
314
+ return Decision(Outcome.USE_WITH_WARN, variant=v,
315
+ warning=_merge_warn(f"board QAIRT {target.qairt} is in range {v.sdk_compatible} "
316
+ f"but the artifact was tested at {v.sdk_tested}", runtime_warning),
317
+ reason="in-range, outside tested point")
318
+ # artifact exists but SDK is out of every range -> fall through to recipe/fail on the SDK axis
319
+ sdk_axis = True
320
+ else:
321
+ sdk_axis = False
322
+
323
+ # Axis 3: no usable artifact, but a recipe can build one for this target.
324
+ buildable = [v for v in cand if v.has_recipe]
325
+ if buildable:
326
+ return Decision(Outcome.BUILD, variant=buildable[0], warning=runtime_warning,
327
+ reason=("no artifact matches the board SDK; a recipe can build one"
328
+ if sdk_axis else "no built artifact, but a recipe exists"))
329
+
330
+ # Axis 4: nothing.
331
+ if sdk_axis:
332
+ ranges = sorted({v.sdk_compatible for v in built})
333
+ return Decision(Outcome.FAIL, axis="qairt",
334
+ reason=f"board QAIRT {target.qairt} outside every artifact range {ranges}, and no recipe")
335
+ return Decision(Outcome.FAIL, axis="artifact",
336
+ reason="arch matches but no artifact and no recipe for this target")