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.
- chipwright/__init__.py +11 -0
- chipwright/adapt.py +57 -0
- chipwright/build.py +329 -0
- chipwright/cache.py +65 -0
- chipwright/cli.py +242 -0
- chipwright/config.py +106 -0
- chipwright/data/behavior_prompts.yaml +108 -0
- chipwright/data/index.yaml +327 -0
- chipwright/device.py +143 -0
- chipwright/device_adb.py +121 -0
- chipwright/env.py +229 -0
- chipwright/errors.py +41 -0
- chipwright/llm_reference.py +114 -0
- chipwright/modalities/__init__.py +3 -0
- chipwright/modalities/asr_encoder.py +90 -0
- chipwright/modalities/cv.py +34 -0
- chipwright/modalities/llm.py +300 -0
- chipwright/recipe.py +205 -0
- chipwright/registry.py +65 -0
- chipwright/resolver.py +197 -0
- chipwright/run.py +173 -0
- chipwright/sdk.py +140 -0
- chipwright/tags.py +56 -0
- chipwright/task_eval.py +190 -0
- chipwright/tests/conftest.py +12 -0
- chipwright/tests/test_adapt.py +61 -0
- chipwright/tests/test_build.py +279 -0
- chipwright/tests/test_cache.py +147 -0
- chipwright/tests/test_device_adb.py +140 -0
- chipwright/tests/test_env.py +73 -0
- chipwright/tests/test_env_adb.py +120 -0
- chipwright/tests/test_llm_modality.py +259 -0
- chipwright/tests/test_packaging.py +147 -0
- chipwright/tests/test_precision_support.py +128 -0
- chipwright/tests/test_registry.py +107 -0
- chipwright/tests/test_resolver.py +93 -0
- chipwright/tests/test_run_llm.py +47 -0
- chipwright/tests/test_tags.py +95 -0
- chipwright/tests/test_task_eval.py +164 -0
- chipwright/tests/test_verify.py +92 -0
- chipwright/tests/test_verify_llm.py +244 -0
- chipwright/verify.py +24 -0
- chipwright/verify_llm.py +203 -0
- chipwright-0.2.0.dist-info/METADATA +268 -0
- chipwright-0.2.0.dist-info/RECORD +49 -0
- chipwright-0.2.0.dist-info/WHEEL +5 -0
- chipwright-0.2.0.dist-info/entry_points.txt +2 -0
- chipwright-0.2.0.dist-info/licenses/LICENSE +21 -0
- chipwright-0.2.0.dist-info/top_level.txt +1 -0
chipwright/task_eval.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Layer-2 task evaluation — WikiText perplexity + MMLU accuracy, reference-side and REPORTED-not-gated.
|
|
2
|
+
|
|
3
|
+
The generative gate has two layers kept separate (see `verify_llm.py`):
|
|
4
|
+
|
|
5
|
+
- **Layer 1** (`verify_llm.tripwire_report`) is the on-chip backend tripwire: the same quantized
|
|
6
|
+
artifact, run greedily on the chip, must match a pinned CPU reference. It can only rule degradation
|
|
7
|
+
*out* — near the FP baseline the correlation between its cheap signals and task quality collapses
|
|
8
|
+
(the "silent zone", arXiv 2606.19558). That is *why* Layer 2 exists.
|
|
9
|
+
- **Layer 2 — task** (this file) answers the other question: *is the quantized model still good enough?*
|
|
10
|
+
That is a property of the **artifact**, not of a particular chip, so it is measured **reference-side
|
|
11
|
+
on the build host** (perplexity and MMLU both need per-token logits over a corpus, which the chip's
|
|
12
|
+
Genie path does not emit — Genie returns text, not logits). FR-L04: task metrics are
|
|
13
|
+
necessary-not-sufficient and are REPORTED, never gated. The gate is Layer 1 + the behavioral flag.
|
|
14
|
+
|
|
15
|
+
This module is deliberately thin. It does NOT re-implement perplexity or MMLU — it wraps qai-hub's own
|
|
16
|
+
eval harness (`qai_hub_models.datasets` + `qai_hub_models.evaluators.{ppl,mmlu}_evaluator`, the same
|
|
17
|
+
`add_from_dataset` / `get_accuracy_score` path the qwen reference test drives). Chipwright's job here is
|
|
18
|
+
the **record shape + named provenance** (dataset / split / samples) so a reader can reproduce the number,
|
|
19
|
+
not the eval kernel.
|
|
20
|
+
|
|
21
|
+
Two honest constraints shape the design:
|
|
22
|
+
- `qai-hub-models` is a heavy dependency (torch + transformers + the dataset downloaders). It is
|
|
23
|
+
**lazy-imported inside the scorer** and, if absent, raised **by name** with the install to run — the
|
|
24
|
+
same preflight discipline as the sshpass/adb/tokenizers checks elsewhere. There is NO top-level
|
|
25
|
+
import of it, so `import chipwright.task_eval` stays cheap and the record-assembly stays testable.
|
|
26
|
+
- Record-assembly is kept **pure and injectable**: `run_task_eval` takes an optional `scorer` seam,
|
|
27
|
+
so the record shape can be unit-tested with a fake scorer and no model, dataset, or heavy dep.
|
|
28
|
+
|
|
29
|
+
Honest ceiling: this is a reference-side **quality** characterization of the artifact, not an on-silicon
|
|
30
|
+
measurement — the record must say "build host, ORT-GenAI / FP16 torch," not imply the chip was measured.
|
|
31
|
+
Report WikiText as ΔPPL% vs the model's own FP baseline (PPL can paradoxically *drop* under quantization
|
|
32
|
+
and hide errors); use >=256 WikiText samples for a believable number (tiny_mmlu / 256-512 MMLU for CI vs
|
|
33
|
+
release). It is also why task metrics alone are not enough: a quantized LLM can keep its perplexity
|
|
34
|
+
(1.03x) while silently losing 15.2% of its refusals (arXiv 2606.09864) — that failure is invisible here
|
|
35
|
+
and is caught by the separate behavioral probe (`verify_llm.behavior_report`).
|
|
36
|
+
"""
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
from collections import namedtuple
|
|
40
|
+
|
|
41
|
+
# A task's identity: the chipwright task name, the metric key written into the record, the qai-hub
|
|
42
|
+
# dataset name, and which evaluator computes it. Pure data — no heavy import to define this.
|
|
43
|
+
TaskSpec = namedtuple("TaskSpec", "name metric dataset evaluator")
|
|
44
|
+
|
|
45
|
+
# The task registry. Datasets confirmed present in qai_hub_models.datasets (wikitext, mmlu, tiny_mmlu).
|
|
46
|
+
# - "wikitext" -> WikiText perplexity (PerplexityEvaluator.get_accuracy_score() == exp(mean NLL))
|
|
47
|
+
# - "mmlu" -> MMLU accuracy (MMLUEvaluator.get_accuracy_score() == correct / total)
|
|
48
|
+
# - "tiny_mmlu" -> the ~100-question CI-grade MMLU subset (same accuracy metric, cheaper)
|
|
49
|
+
TASKS = {
|
|
50
|
+
"wikitext": TaskSpec("wikitext", "wikitext_perplexity", "wikitext", "perplexity"),
|
|
51
|
+
"mmlu": TaskSpec("mmlu", "mmlu_accuracy", "mmlu", "mmlu"),
|
|
52
|
+
"tiny_mmlu": TaskSpec("tiny_mmlu", "mmlu_accuracy", "tiny_mmlu", "mmlu"),
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def resolve_task(task) -> TaskSpec:
|
|
57
|
+
"""Resolve a task name (or a ready TaskSpec) to its TaskSpec; refuse an unknown name BY NAME.
|
|
58
|
+
|
|
59
|
+
An unknown task is refused with the known set rather than silently skipped — a typo must not quietly
|
|
60
|
+
drop a metric a reader expects in the record.
|
|
61
|
+
"""
|
|
62
|
+
if isinstance(task, TaskSpec):
|
|
63
|
+
return task
|
|
64
|
+
try:
|
|
65
|
+
return TASKS[task]
|
|
66
|
+
except KeyError:
|
|
67
|
+
raise ValueError(f"unknown task {task!r} (known: {', '.join(sorted(TASKS))})") from None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def task_record(metric, value, dataset, split, samples) -> dict:
|
|
71
|
+
"""Assemble one `task:` record (the contract block shape). Pure — the unit-testable core.
|
|
72
|
+
|
|
73
|
+
Mirrors the contract's Layer-2 `task:` list entry: `{metric, value, dataset, split, samples}`. The
|
|
74
|
+
value is stored as a float so the record serializes cleanly; everything a reader needs to reproduce
|
|
75
|
+
the number (which dataset, which split, how many samples) travels with it (FR-L04 / FR-L09).
|
|
76
|
+
"""
|
|
77
|
+
return {
|
|
78
|
+
"metric": metric,
|
|
79
|
+
"value": float(value),
|
|
80
|
+
"dataset": dataset,
|
|
81
|
+
"split": split,
|
|
82
|
+
"samples": int(samples),
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def run_task_eval(generator=None, tasks=("wikitext",), samples=256, split="test",
|
|
87
|
+
*, scorer=None, tokenizer=None, context_length=4096, device="cpu") -> list[dict]:
|
|
88
|
+
"""Reference-side Layer-2 task metrics for an LLM artifact (build host, NOT on-device).
|
|
89
|
+
|
|
90
|
+
Returns one `task:` record per requested metric — each naming dataset/split/samples so a reader can
|
|
91
|
+
reproduce it (FR-L04, contract `task:` block). Perplexity/MMLU are REPORTED here, never gated; the
|
|
92
|
+
gate is Layer 1 (`verify_llm.tripwire_report`) plus the behavioral flag. Call this at build/publish
|
|
93
|
+
time to populate the record's `task:` block — not on the hot `run` path that drives the board.
|
|
94
|
+
|
|
95
|
+
The scoring seam is injectable for testing: pass `scorer(spec, split, samples) -> float` to compute
|
|
96
|
+
the metric without the heavy qai-hub dependency, a model, or a dataset. When `scorer` is None, the
|
|
97
|
+
real qai-hub harness is wired against `generator` (an `LLM_Generator` wrapping the quantized/FP model)
|
|
98
|
+
+ `tokenizer`, lazy-importing qai-hub and raising BY NAME if it is absent.
|
|
99
|
+
|
|
100
|
+
Example return:
|
|
101
|
+
[{"metric": "wikitext_perplexity", "value": 10.58, "dataset": "wikitext",
|
|
102
|
+
"split": "test", "samples": 256},
|
|
103
|
+
{"metric": "mmlu_accuracy", "value": 0.554, "dataset": "mmlu", "split": "test", "samples": 256}]
|
|
104
|
+
"""
|
|
105
|
+
specs = [resolve_task(t) for t in tasks]
|
|
106
|
+
score = scorer if scorer is not None else _qai_hub_scorer(
|
|
107
|
+
generator, tokenizer=tokenizer, context_length=context_length, device=device)
|
|
108
|
+
return [task_record(s.metric, score(s, split, samples), s.dataset, split, samples) for s in specs]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# --- the qai-hub harness scorer (lazy, heavy; raises BY NAME if the dep is absent) ----------------
|
|
112
|
+
|
|
113
|
+
def _require_qai_hub():
|
|
114
|
+
"""Import the qai-hub eval harness lazily, or raise BY NAME naming the install (preflight style).
|
|
115
|
+
|
|
116
|
+
`qai-hub-models` pulls in torch + transformers + the dataset downloaders — far too heavy to import at
|
|
117
|
+
module load. Like the sshpass/adb/tokenizers preflights, its absence is reported by name with the
|
|
118
|
+
exact install to run, never as a bare ImportError deep in a traceback. Returns the handful of symbols
|
|
119
|
+
the real scorer needs.
|
|
120
|
+
"""
|
|
121
|
+
try:
|
|
122
|
+
from qai_hub_models.datasets import get_dataset_from_name
|
|
123
|
+
from qai_hub_models.datasets.common import DatasetSplit
|
|
124
|
+
from qai_hub_models.evaluators.ppl_evaluator import PerplexityEvaluator
|
|
125
|
+
from qai_hub_models.evaluators.mmlu_evaluator import MMLUEvaluator
|
|
126
|
+
except ImportError as e: # fail by name, not silently — mirrors the adb/sshpass/tokenizers preflights
|
|
127
|
+
raise RuntimeError(
|
|
128
|
+
"task_eval: the 'qai-hub-models' package is required to compute reference-side WikiText "
|
|
129
|
+
"perplexity / MMLU accuracy (it supplies the datasets and the eval kernels) but could not be "
|
|
130
|
+
"imported. Install the 'eval' extra: pip install 'chipwright[eval]'") from e
|
|
131
|
+
return {
|
|
132
|
+
"get_dataset_from_name": get_dataset_from_name,
|
|
133
|
+
"DatasetSplit": DatasetSplit,
|
|
134
|
+
"PerplexityEvaluator": PerplexityEvaluator,
|
|
135
|
+
"MMLUEvaluator": MMLUEvaluator,
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _split_enum(DatasetSplit, split):
|
|
140
|
+
"""Map a split string ('test'/'train') to the qai-hub `DatasetSplit` enum; refuse an unknown split."""
|
|
141
|
+
key = str(split).upper()
|
|
142
|
+
try:
|
|
143
|
+
return getattr(DatasetSplit, key)
|
|
144
|
+
except AttributeError:
|
|
145
|
+
raise ValueError(f"unknown split {split!r} (known: test, train)") from None
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _qai_hub_scorer(generator, *, tokenizer=None, context_length=4096, device="cpu"):
|
|
149
|
+
"""Build the real reference-side scorer over qai-hub's eval harness.
|
|
150
|
+
|
|
151
|
+
Returns `score(spec, split, samples) -> float`. For each task it builds the named dataset
|
|
152
|
+
(`get_dataset_from_name(spec.dataset, split=<enum>, num_samples=samples)`), constructs the matching
|
|
153
|
+
evaluator (`PerplexityEvaluator` / `MMLUEvaluator`, each `(context_length, device, tokenizer)`),
|
|
154
|
+
drives it over the generator (`evaluator.add_from_dataset(generator, dataset, eval_iterations=samples)`
|
|
155
|
+
— the same path qai-hub's own model tests use), and reads `evaluator.get_accuracy_score()`
|
|
156
|
+
(PPL == exp(mean NLL); MMLU == correct / total). Exactly the qai-hub harness, no re-implemented math.
|
|
157
|
+
|
|
158
|
+
`generator` is an `LLM_Generator` wrapping the quantized (ORT-GenAI) or FP16-torch model, built on the
|
|
159
|
+
build host (model/recipe-specific, so it is passed in, not constructed here). It and `tokenizer` are
|
|
160
|
+
required for the real path; their absence is refused by name before any dataset is downloaded.
|
|
161
|
+
"""
|
|
162
|
+
mods = _require_qai_hub()
|
|
163
|
+
|
|
164
|
+
if generator is None:
|
|
165
|
+
raise RuntimeError(
|
|
166
|
+
"task_eval: run_task_eval needs an `LLM_Generator` (the quantized/FP model wrapped for the "
|
|
167
|
+
"qai-hub harness, built on the build host) to score tasks, but none was passed. Pass "
|
|
168
|
+
"`generator=` (and `tokenizer=`), or inject a `scorer=` for a dry run. Never on-device — "
|
|
169
|
+
"perplexity/MMLU need logits the chip's Genie path does not emit.")
|
|
170
|
+
if tokenizer is None:
|
|
171
|
+
raise RuntimeError(
|
|
172
|
+
"task_eval: the qai-hub PerplexityEvaluator/MMLUEvaluator require the model's `tokenizer`; "
|
|
173
|
+
"pass `tokenizer=` alongside `generator=`.")
|
|
174
|
+
|
|
175
|
+
def _evaluator(kind):
|
|
176
|
+
if kind == "perplexity":
|
|
177
|
+
return mods["PerplexityEvaluator"](context_length, device, tokenizer)
|
|
178
|
+
if kind == "mmlu":
|
|
179
|
+
return mods["MMLUEvaluator"](context_length, device, tokenizer)
|
|
180
|
+
raise ValueError(f"unknown evaluator {kind!r}")
|
|
181
|
+
|
|
182
|
+
def score(spec, split, samples):
|
|
183
|
+
spec = resolve_task(spec)
|
|
184
|
+
dataset = mods["get_dataset_from_name"](
|
|
185
|
+
spec.dataset, split=_split_enum(mods["DatasetSplit"], split), num_samples=int(samples))
|
|
186
|
+
ev = _evaluator(spec.evaluator)
|
|
187
|
+
ev.add_from_dataset(generator, dataset, eval_iterations=int(samples))
|
|
188
|
+
return float(ev.get_accuracy_score())
|
|
189
|
+
|
|
190
|
+
return score
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Make `import chipwright` work under pytest regardless of where pytest is invoked.
|
|
2
|
+
|
|
3
|
+
The repo root (the directory that *contains* the `chipwright/` package) is put on sys.path so the
|
|
4
|
+
tests import the package being developed, not an installed copy. Mirrors the sys.path shim each test
|
|
5
|
+
file carries for standalone `python3 <file>` runs.
|
|
6
|
+
"""
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
11
|
+
if _REPO_ROOT not in sys.path:
|
|
12
|
+
sys.path.insert(0, _REPO_ROOT)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Graph-adaptation tests — pure, no board/QAIRT; skip cleanly if `onnx` isn't installed."""
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
import tempfile
|
|
5
|
+
|
|
6
|
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
|
7
|
+
|
|
8
|
+
from chipwright import adapt
|
|
9
|
+
from chipwright.recipe import Adaptation
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
import onnx
|
|
13
|
+
from onnx import TensorProto, helper
|
|
14
|
+
HAVE_ONNX = True
|
|
15
|
+
except ImportError:
|
|
16
|
+
HAVE_ONNX = False
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _reshape_onnx(path, allowzero):
|
|
20
|
+
inp = helper.make_tensor_value_info("x", TensorProto.FLOAT, [2, 3])
|
|
21
|
+
out = helper.make_tensor_value_info("y", TensorProto.FLOAT, [6])
|
|
22
|
+
shape = helper.make_tensor("shape", TensorProto.INT64, [1], [6])
|
|
23
|
+
node = helper.make_node("Reshape", ["x", "shape"], ["y"], allowzero=allowzero)
|
|
24
|
+
g = helper.make_graph([node], "g", [inp], [out], [shape])
|
|
25
|
+
onnx.save(helper.make_model(g, opset_imports=[helper.make_opsetid("", 13)]), path)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _allowzero_count(path):
|
|
29
|
+
m = onnx.load(path)
|
|
30
|
+
return sum(1 for n in m.graph.node if n.op_type == "Reshape"
|
|
31
|
+
and any(a.name == "allowzero" and a.i == 1 for a in n.attribute))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_reshape_allowzero_cleared():
|
|
35
|
+
if not HAVE_ONNX:
|
|
36
|
+
return
|
|
37
|
+
with tempfile.TemporaryDirectory() as d:
|
|
38
|
+
src = os.path.join(d, "m.onnx")
|
|
39
|
+
_reshape_onnx(src, allowzero=1)
|
|
40
|
+
assert _allowzero_count(src) == 1
|
|
41
|
+
out, logs = adapt.apply(src, [Adaptation(kind="reshape_allowzero", params={})], d)
|
|
42
|
+
assert _allowzero_count(out) == 0
|
|
43
|
+
assert any("reshape_allowzero" in m for m in logs)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_noop_when_not_declared():
|
|
47
|
+
# no supported adaptation declared -> original path returned unchanged, no logs
|
|
48
|
+
out, logs = adapt.apply("/nonexistent.onnx", [], None)
|
|
49
|
+
assert out == "/nonexistent.onnx" and logs == []
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_missing_file_is_graceful():
|
|
53
|
+
out, logs = adapt.apply("/nope.onnx", [Adaptation(kind="reshape_allowzero", params={})], None)
|
|
54
|
+
assert out == "/nope.onnx" and logs and "skipped" in logs[0]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
if __name__ == "__main__":
|
|
58
|
+
fns = [f for n, f in sorted(globals().items()) if n.startswith("test_")]
|
|
59
|
+
for f in fns:
|
|
60
|
+
f(); print("ok", f.__name__)
|
|
61
|
+
print(f"\n{len(fns)} passed" + ("" if HAVE_ONNX else " (onnx-dependent test was a no-op)"))
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""The build verb is testable with no QAIRT and no board: the recipe loader and the op-check rule
|
|
2
|
+
table are pure, and the executors are exercised with the external tools MOCKED (shutil.which +
|
|
3
|
+
subprocess.run monkeypatched). So this suite runs green on a laptop with nothing installed.
|
|
4
|
+
"""
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import tempfile
|
|
8
|
+
import types
|
|
9
|
+
|
|
10
|
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
|
11
|
+
|
|
12
|
+
from chipwright import build, config, recipe, sdk
|
|
13
|
+
from chipwright.errors import (ContextGenError, FidelityBelowThreshold,
|
|
14
|
+
OpUnsupported, PreflightError)
|
|
15
|
+
|
|
16
|
+
# --- a minimal valid recipe document -----------------------------------------------------------
|
|
17
|
+
RECIPE_YAML = """
|
|
18
|
+
name: whisper-small-en
|
|
19
|
+
version: 1.2.0
|
|
20
|
+
route: classic
|
|
21
|
+
source:
|
|
22
|
+
format: onnx
|
|
23
|
+
sha256: deadbeef
|
|
24
|
+
url: hf://openai/whisper-small.en/encoder.onnx
|
|
25
|
+
target:
|
|
26
|
+
arch: v68
|
|
27
|
+
quant: w8a16
|
|
28
|
+
shape: win30s
|
|
29
|
+
sdk_range: ">=2.34,<2.40"
|
|
30
|
+
adaptations:
|
|
31
|
+
- static_shape: {bucket: win30s, dims: {audio: [1, 80, 3000]}}
|
|
32
|
+
- erf_to_tanh: {ops: [Erf]}
|
|
33
|
+
- mask_clamp: {from: -3.4e38, to: -1e4}
|
|
34
|
+
quant:
|
|
35
|
+
weights: 8
|
|
36
|
+
activations: 16
|
|
37
|
+
bias: 32
|
|
38
|
+
per_channel: true
|
|
39
|
+
calibration_ref: calib/whisper_encoder_30s.npz
|
|
40
|
+
algorithm: min_max
|
|
41
|
+
host:
|
|
42
|
+
pre: modalities.asr.frontend:log_mel
|
|
43
|
+
post: modalities.asr.decoders.ctc:greedy
|
|
44
|
+
deps: [numpy]
|
|
45
|
+
graphs:
|
|
46
|
+
- {name: encoder, runs_on: npu}
|
|
47
|
+
- {name: decode, runs_on: host}
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _write(text, suffix=".yaml"):
|
|
52
|
+
fd, path = tempfile.mkstemp(suffix=suffix)
|
|
53
|
+
os.write(fd, text.encode())
|
|
54
|
+
os.close(fd)
|
|
55
|
+
return path
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _recipe():
|
|
59
|
+
return recipe.load(_write(RECIPE_YAML))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# --- recipe loader -----------------------------------------------------------------------------
|
|
63
|
+
def test_recipe_loads_full_schema():
|
|
64
|
+
r = _recipe()
|
|
65
|
+
assert r.name == "whisper-small-en" and r.version == "1.2.0" and r.route == "classic"
|
|
66
|
+
assert r.source.format == "onnx" and r.source.sha256 == "deadbeef"
|
|
67
|
+
assert r.target.arch == "v68" and r.target.sdk_range == ">=2.34,<2.40"
|
|
68
|
+
assert r.quant.activations == 16 and r.quant.bias == 32 and r.quant.per_channel is True
|
|
69
|
+
assert r.quant_tag == "w8a16"
|
|
70
|
+
assert r.host.pre == "modalities.asr.frontend:log_mel"
|
|
71
|
+
assert [g.runs_on for g in r.graphs] == ["npu", "host"]
|
|
72
|
+
assert len(r.npu_graphs) == 1
|
|
73
|
+
assert r.adaptation("erf_to_tanh") is not None and r.adaptation("nope") is None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_recipe_rejects_bad_runs_on():
|
|
77
|
+
bad = RECIPE_YAML.replace("runs_on: host", "runs_on: gpu")
|
|
78
|
+
try:
|
|
79
|
+
recipe.load(_write(bad)); assert False, "expected ValueError"
|
|
80
|
+
except ValueError:
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_recipe_rejects_unknown_route_and_missing_field():
|
|
85
|
+
for mut in [RECIPE_YAML.replace("route: classic", "route: magic"),
|
|
86
|
+
RECIPE_YAML.replace(" arch: v68\n", "")]:
|
|
87
|
+
try:
|
|
88
|
+
recipe.load(_write(mut)); assert False, "expected ValueError"
|
|
89
|
+
except ValueError:
|
|
90
|
+
pass
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# --- op-check rule table (fed a fake op list, no onnx file needed) ------------------------------
|
|
94
|
+
class _FakeTarget:
|
|
95
|
+
def __init__(self, arch): self.htp_arch = arch
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _patch_op_scan(monkey_ops, fp16=False):
|
|
99
|
+
"""Patch sdk._onnx_op_types to return a fixed op list so the rule table is tested directly."""
|
|
100
|
+
orig = sdk._onnx_op_types
|
|
101
|
+
sdk._onnx_op_types = lambda path: (monkey_ops, {"fp16": fp16}, None)
|
|
102
|
+
return orig
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_will_it_run_flags_erf_and_gather():
|
|
106
|
+
orig = _patch_op_scan(["Conv", "Erf", "Gather", "MatMul"])
|
|
107
|
+
try:
|
|
108
|
+
# point at a real existing file so the os.path.exists guard passes; content is ignored (scan patched)
|
|
109
|
+
rep = sdk.will_it_run(__file__, _FakeTarget("v68"))
|
|
110
|
+
finally:
|
|
111
|
+
sdk._onnx_op_types = orig
|
|
112
|
+
assert rep.supported is True # Erf/Gather are adaptable, not unsupported
|
|
113
|
+
flagged = {f["op"] for f in rep.flags}
|
|
114
|
+
assert "Erf" in flagged and "Gather" in flagged
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_will_it_run_marks_truly_unsupported_op():
|
|
118
|
+
orig = _patch_op_scan(["Conv", "NonMaxSuppression"])
|
|
119
|
+
try:
|
|
120
|
+
rep = sdk.will_it_run(__file__, _FakeTarget("v68"))
|
|
121
|
+
finally:
|
|
122
|
+
sdk._onnx_op_types = orig
|
|
123
|
+
assert rep.supported is False and "NonMaxSuppression" in rep.unsupported
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_will_it_run_flags_fp16_on_v6x():
|
|
127
|
+
orig = _patch_op_scan(["Conv"], fp16=True)
|
|
128
|
+
try:
|
|
129
|
+
rep = sdk.will_it_run(__file__, _FakeTarget("v68"))
|
|
130
|
+
finally:
|
|
131
|
+
sdk._onnx_op_types = orig
|
|
132
|
+
assert any(f["op"] == "<fp16-tensor>" for f in rep.flags)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_will_it_run_degrades_without_onnx_file():
|
|
136
|
+
rep = sdk.will_it_run("/no/such/model.onnx", _FakeTarget("v68"))
|
|
137
|
+
assert rep.supported is True and rep.note # graceful, not a crash
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# --- tool mocking ------------------------------------------------------------------------------
|
|
141
|
+
class _FakeCompleted:
|
|
142
|
+
def __init__(self, returncode=0, stdout="ok", stderr=""):
|
|
143
|
+
self.returncode, self.stdout, self.stderr = returncode, stdout, stderr
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _mock_tools(present=True, returncode=0):
|
|
147
|
+
"""Return (which, run) replacements. present=False ⇒ tool missing (PreflightError path)."""
|
|
148
|
+
def which(name, path=None):
|
|
149
|
+
return f"/opt/qairt/bin/{name}" if present else None
|
|
150
|
+
def run(cmd, capture_output=True, text=True, timeout=None):
|
|
151
|
+
return _FakeCompleted(returncode=returncode)
|
|
152
|
+
return which, run
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _env(tmp):
|
|
156
|
+
return config.Env(cache_dir=tmp)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# --- executor guards: missing tool raises PreflightError ---------------------------------------
|
|
160
|
+
def test_missing_tool_raises_preflight():
|
|
161
|
+
which, run = _mock_tools(present=False)
|
|
162
|
+
orig_which, orig_run = build.shutil.which, build.subprocess.run
|
|
163
|
+
build.shutil.which, build.subprocess.run = which, run
|
|
164
|
+
try:
|
|
165
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
166
|
+
try:
|
|
167
|
+
build.convert(_recipe(), _env(tmp)); assert False, "expected PreflightError"
|
|
168
|
+
except PreflightError:
|
|
169
|
+
pass
|
|
170
|
+
finally:
|
|
171
|
+
build.shutil.which, build.subprocess.run = orig_which, orig_run
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def test_classic_route_reports_preflight_axis_when_tool_missing():
|
|
175
|
+
which, run = _mock_tools(present=False)
|
|
176
|
+
orig_which, orig_run = build.shutil.which, build.subprocess.run
|
|
177
|
+
build.shutil.which, build.subprocess.run = which, run
|
|
178
|
+
try:
|
|
179
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
180
|
+
res = build.classic(_recipe(), _env(tmp))
|
|
181
|
+
finally:
|
|
182
|
+
build.shutil.which, build.subprocess.run = orig_which, orig_run
|
|
183
|
+
assert res.ok is False and res.failed_axis == "preflight" and res.artifact_path is None
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# --- mocked successful chain returns a BuildResult(ok=True) ------------------------------------
|
|
187
|
+
def test_classic_chain_succeeds_mocked():
|
|
188
|
+
which, run = _mock_tools(present=True, returncode=0)
|
|
189
|
+
orig_which, orig_run = build.shutil.which, build.subprocess.run
|
|
190
|
+
build.shutil.which, build.subprocess.run = which, run
|
|
191
|
+
try:
|
|
192
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
193
|
+
res = build.classic(_recipe(), _env(tmp))
|
|
194
|
+
finally:
|
|
195
|
+
build.shutil.which, build.subprocess.run = orig_which, orig_run
|
|
196
|
+
assert res.ok is True and res.failed_axis is None
|
|
197
|
+
assert res.artifact_path and res.artifact_path.endswith(".bin")
|
|
198
|
+
assert res.tag == "whisper-small-en-1.2.0+qnn2.34-htpv68-w8a16-win30s.bin"
|
|
199
|
+
assert any("qairt-converter" in line for line in res.logs)
|
|
200
|
+
assert any("qnn-context-binary-generator" in line for line in res.logs)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def test_dlc_route_ships_dlc_mocked():
|
|
204
|
+
which, run = _mock_tools(present=True, returncode=0)
|
|
205
|
+
orig_which, orig_run = build.shutil.which, build.subprocess.run
|
|
206
|
+
build.shutil.which, build.subprocess.run = which, run
|
|
207
|
+
try:
|
|
208
|
+
r = _recipe(); r.route = "dlc_route"
|
|
209
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
210
|
+
res = build.build(r, _env(tmp)) # dispatch by recipe.route
|
|
211
|
+
finally:
|
|
212
|
+
build.shutil.which, build.subprocess.run = orig_which, orig_run
|
|
213
|
+
assert res.ok is True and res.artifact_path.endswith(".dlc")
|
|
214
|
+
assert res.tag.endswith(".dlc")
|
|
215
|
+
assert any("on-board" in line for line in res.logs)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def test_context_tool_failure_raises_contextgen_axis():
|
|
219
|
+
# converter/quantizer succeed, context generator returns nonzero → ContextGenError, failed_axis=context
|
|
220
|
+
def which(name, path=None):
|
|
221
|
+
return f"/opt/qairt/bin/{name}"
|
|
222
|
+
def run(cmd, capture_output=True, text=True, timeout=None):
|
|
223
|
+
rc = 1 if "context-binary-generator" in cmd[0] else 0
|
|
224
|
+
return _FakeCompleted(returncode=rc, stderr="VTCM overflow")
|
|
225
|
+
orig_which, orig_run = build.shutil.which, build.subprocess.run
|
|
226
|
+
build.shutil.which, build.subprocess.run = which, run
|
|
227
|
+
try:
|
|
228
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
229
|
+
res = build.classic(_recipe(), _env(tmp))
|
|
230
|
+
# and the executor raises the typed error directly
|
|
231
|
+
dlc = os.path.join(tmp, "x.dlc")
|
|
232
|
+
raised = False
|
|
233
|
+
try:
|
|
234
|
+
build.context(dlc, _FakeTarget("v68"), _env(tmp))
|
|
235
|
+
except ContextGenError:
|
|
236
|
+
raised = True
|
|
237
|
+
finally:
|
|
238
|
+
build.shutil.which, build.subprocess.run = orig_which, orig_run
|
|
239
|
+
assert res.ok is False and res.failed_axis == "context"
|
|
240
|
+
assert raised
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
# --- host preflight + config roundtrip ---------------------------------------------------------
|
|
244
|
+
def test_host_preflight_reports_missing_tools():
|
|
245
|
+
orig = config.shutil.which
|
|
246
|
+
config.shutil.which = lambda name, path=None: None
|
|
247
|
+
try:
|
|
248
|
+
pf = config.host_preflight(config.Env())
|
|
249
|
+
finally:
|
|
250
|
+
config.shutil.which = orig
|
|
251
|
+
assert pf.ok is False and set(config.REQUIRED_TOOLS).issubset(set(pf.missing))
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def test_env_write_read_roundtrip():
|
|
255
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
256
|
+
path = os.path.join(tmp, ".qualcomm-env")
|
|
257
|
+
e = config.Env(hosts=[{"name": "rb3", "host": "10.0.0.5"}], sdk_roots=["/opt/qairt/2.37"],
|
|
258
|
+
keys=["~/.ssh/id_rsa"], cache_dir=os.path.join(tmp, "cache"))
|
|
259
|
+
config.write_env(e, path)
|
|
260
|
+
back = config.read_env(path)
|
|
261
|
+
assert back.hosts[0]["name"] == "rb3" and back.sdk_root == "/opt/qairt/2.37"
|
|
262
|
+
assert back.cache_dir.endswith("cache")
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
# --- fidelity gate (the publish gate, pure) ----------------------------------------------------
|
|
266
|
+
def test_fidelity_gate_rejects_low_cosine():
|
|
267
|
+
try:
|
|
268
|
+
build.fidelity_gate({"loads": True, "cosine_min": 0.80, "threshold": 0.99}); assert False
|
|
269
|
+
except FidelityBelowThreshold:
|
|
270
|
+
pass
|
|
271
|
+
# a passing report does not raise
|
|
272
|
+
build.fidelity_gate({"loads": True, "cosine_min": 0.995, "threshold": 0.99})
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
if __name__ == "__main__":
|
|
276
|
+
fns = [f for n, f in sorted(globals().items()) if n.startswith("test_") and isinstance(f, types.FunctionType)]
|
|
277
|
+
for f in fns:
|
|
278
|
+
f(); print("ok", f.__name__)
|
|
279
|
+
print(f"\n{len(fns)} passed")
|