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
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""LLM modality pure helpers — chat-template wrap, profile-JSON parse, generation parse, mock run.
|
|
2
|
+
|
|
3
|
+
No device and no onnxruntime: the device is a tiny fake exposing push_file/shell/pull/exists.
|
|
4
|
+
Runnable as `python3 test_llm_modality.py`.
|
|
5
|
+
"""
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
|
11
|
+
|
|
12
|
+
from chipwright.modalities.llm import (
|
|
13
|
+
apply_chat_template, parse_profile, parse_generation, run,
|
|
14
|
+
retokenize, load_bundle_tokenizer, greedy_sampler, apply_greedy_to_config,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# --- chat template --------------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
def test_chatml_wrap_has_im_markers_and_open_assistant_turn():
|
|
21
|
+
out = apply_chat_template("What is the capital of France?", template="chatml")
|
|
22
|
+
assert "<|im_start|>user\nWhat is the capital of France?<|im_end|>" in out
|
|
23
|
+
assert out.endswith("<|im_start|>assistant\n") # open turn so generation continues
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_chatml_wrap_includes_system_when_given():
|
|
27
|
+
out = apply_chat_template("hi", template="chatml", system="You are terse.")
|
|
28
|
+
assert "<|im_start|>system\nYou are terse.<|im_end|>" in out
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_raw_template_is_passthrough():
|
|
32
|
+
assert apply_chat_template("just text", template="raw") == "just text"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_unknown_template_is_refused_by_name():
|
|
36
|
+
try:
|
|
37
|
+
apply_chat_template("x", template="llama-chat")
|
|
38
|
+
except ValueError as e:
|
|
39
|
+
assert "llama-chat" in str(e)
|
|
40
|
+
else:
|
|
41
|
+
assert False, "expected ValueError for unknown template"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# --- profile JSON parse ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
def test_parse_profile_maps_latency_fields():
|
|
47
|
+
prof = json.dumps({
|
|
48
|
+
"token_generation_rate": 33.0,
|
|
49
|
+
"prompt_processing_rate": 292.0,
|
|
50
|
+
"time_to_first_token_ms": 55.0,
|
|
51
|
+
"init_time": 0.88,
|
|
52
|
+
})
|
|
53
|
+
lat = parse_profile(prof)
|
|
54
|
+
assert lat["decode_toks_per_sec"] == 33.0
|
|
55
|
+
assert lat["prefill_toks_per_sec"] == 292.0
|
|
56
|
+
assert lat["time_to_first_token_ms"] == 55.0
|
|
57
|
+
assert lat["model_load_s"] == 0.88
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_parse_profile_handles_nested_and_missing_fields():
|
|
61
|
+
prof = {"profile": {"decode": {"rate": 30.0}}} # nested; others absent
|
|
62
|
+
lat = parse_profile(prof)
|
|
63
|
+
assert lat["decode_toks_per_sec"] == 30.0
|
|
64
|
+
assert lat["prefill_toks_per_sec"] is None # absent -> shown as None, not hidden
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# --- generation parse -----------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
def test_parse_generation_extracts_text_and_token_ids():
|
|
70
|
+
stdout = "loading...\n[BEGIN]Paris.[END]\ntoken_ids: [40, 1138, 13]\ndone"
|
|
71
|
+
g = parse_generation(stdout)
|
|
72
|
+
assert g["text"] == "Paris."
|
|
73
|
+
assert g["tokens"] == [40, 1138, 13]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_parse_generation_missing_tokens_is_none():
|
|
77
|
+
g = parse_generation("[BEGIN]hello[END]")
|
|
78
|
+
assert g["text"] == "hello"
|
|
79
|
+
assert g["tokens"] is None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_parse_generation_without_end_marker_reads_to_eof():
|
|
83
|
+
# w4a16 degenerates and never emits EOS; the board kills the run on `timeout` before [END],
|
|
84
|
+
# so the text must be read up to EOF, not dropped as empty.
|
|
85
|
+
g = parse_generation("noise\n[BEGIN]: Paris is the capital of France and it keeps going")
|
|
86
|
+
assert g["text"] == "Paris is the capital of France and it keeps going"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# --- retokenize (text -> token ids; genie emits no ids on QAIRT 2.51) -----------------------------
|
|
90
|
+
|
|
91
|
+
class _Enc:
|
|
92
|
+
def __init__(self, ids): self.ids = ids
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class FakeTokenizer:
|
|
96
|
+
"""Duck-typed stand-in for a HuggingFace Tokenizer: encode(text, add_special_tokens).ids."""
|
|
97
|
+
def __init__(self, mapping=None): self.mapping = mapping or {}; self.calls = []
|
|
98
|
+
def encode(self, text, add_special_tokens=False):
|
|
99
|
+
self.calls.append((text, add_special_tokens))
|
|
100
|
+
return _Enc(self.mapping.get(text, [len(text)]))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_retokenize_uses_injected_tokenizer_with_special_tokens_off():
|
|
104
|
+
tok = FakeTokenizer({"Paris.": [40, 1138, 13]})
|
|
105
|
+
ids = retokenize("Paris.", tokenizer=tok)
|
|
106
|
+
assert ids == [40, 1138, 13]
|
|
107
|
+
assert tok.calls == [("Paris.", False)] # special tokens OFF — we match answer tokens
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_retokenize_with_real_tokenizers_library_roundtrips():
|
|
111
|
+
# Build a tiny real tokenizer.json via the `tokenizers` library and prove the file path loads.
|
|
112
|
+
from tokenizers import Tokenizer
|
|
113
|
+
from tokenizers.models import WordLevel
|
|
114
|
+
from tokenizers.pre_tokenizers import Whitespace
|
|
115
|
+
import tempfile
|
|
116
|
+
vocab = {"paris": 0, "london": 1, "is": 2, "the": 3, "capital": 4, "[UNK]": 5}
|
|
117
|
+
t = Tokenizer(WordLevel(vocab, unk_token="[UNK]"))
|
|
118
|
+
t.pre_tokenizer = Whitespace()
|
|
119
|
+
p = os.path.join(tempfile.mkdtemp(), "tokenizer.json")
|
|
120
|
+
t.save(p)
|
|
121
|
+
ids = retokenize("paris is the capital", p)
|
|
122
|
+
assert ids == [0, 2, 3, 4]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_load_bundle_tokenizer_missing_file_fails_by_name():
|
|
126
|
+
try:
|
|
127
|
+
load_bundle_tokenizer("/no/such/tokenizer.json")
|
|
128
|
+
except FileNotFoundError as e:
|
|
129
|
+
assert "tokenizer" in str(e).lower()
|
|
130
|
+
else:
|
|
131
|
+
assert False, "expected FileNotFoundError for a missing bundle tokenizer"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_load_bundle_tokenizer_fails_by_name_without_library(monkeypatch=None):
|
|
135
|
+
# If `tokenizers` cannot be imported, the modality must fail BY NAME, not silently return no tokens.
|
|
136
|
+
import builtins
|
|
137
|
+
real_import = builtins.__import__
|
|
138
|
+
|
|
139
|
+
def blocked(name, *a, **k):
|
|
140
|
+
if name == "tokenizers" or name.startswith("tokenizers."):
|
|
141
|
+
raise ImportError("blocked for test")
|
|
142
|
+
return real_import(name, *a, **k)
|
|
143
|
+
|
|
144
|
+
builtins.__import__ = blocked
|
|
145
|
+
try:
|
|
146
|
+
load_bundle_tokenizer("/tmp/whatever.json")
|
|
147
|
+
assert False, "expected RuntimeError naming the tokenizers library"
|
|
148
|
+
except RuntimeError as e:
|
|
149
|
+
assert "tokenizers" in str(e)
|
|
150
|
+
finally:
|
|
151
|
+
builtins.__import__ = real_import
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# --- greedy sampler: top-k:1, NOT sampler.greedy (genie silently ignores greedy) ------------------
|
|
155
|
+
|
|
156
|
+
def test_greedy_sampler_sets_topk_1_not_relying_on_greedy_flag():
|
|
157
|
+
s = greedy_sampler()
|
|
158
|
+
assert s["top-k"] == 1 # the control genie HONORS
|
|
159
|
+
assert s["temp"] == 0.0
|
|
160
|
+
assert s["top-p"] == 1.0
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_apply_greedy_to_config_forces_topk1_without_mutating_input():
|
|
164
|
+
cfg = {"dialog": {"sampler": {"version": 1, "greedy": True, "temp": 0.8, "top-k": 40}}}
|
|
165
|
+
out = apply_greedy_to_config(cfg)
|
|
166
|
+
assert out["dialog"]["sampler"]["top-k"] == 1
|
|
167
|
+
assert out["dialog"]["sampler"]["temp"] == 0.0
|
|
168
|
+
# input is not mutated (pure) — the on-disk bundle config is never clobbered
|
|
169
|
+
assert cfg["dialog"]["sampler"]["top-k"] == 40
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# --- mock-device run ------------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
class _Proc:
|
|
175
|
+
def __init__(self, stdout): self.returncode = 0; self.stdout = stdout
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class FakeDevice:
|
|
179
|
+
"""Minimal device fake over the (push_file/shell/pull/exists) interface."""
|
|
180
|
+
def __init__(self, stdout, profile=None):
|
|
181
|
+
self._stdout = stdout
|
|
182
|
+
self._profile = profile
|
|
183
|
+
self.pushed = []
|
|
184
|
+
self.shelled = []
|
|
185
|
+
self._present = set()
|
|
186
|
+
def push_file(self, local, remote):
|
|
187
|
+
self.pushed.append((os.path.basename(local), remote))
|
|
188
|
+
self._present.add(remote)
|
|
189
|
+
def shell(self, cmd):
|
|
190
|
+
self.shelled.append(cmd)
|
|
191
|
+
return _Proc(self._stdout)
|
|
192
|
+
def exists(self, remote):
|
|
193
|
+
return remote.endswith("profile.json") and self._profile is not None or remote in self._present
|
|
194
|
+
def pull(self, remote, local):
|
|
195
|
+
with open(local, "w") as f:
|
|
196
|
+
f.write(self._profile or "{}")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def test_run_pushes_bundle_file_by_file_and_parses(tmp_path=None):
|
|
200
|
+
import tempfile
|
|
201
|
+
d = tempfile.mkdtemp()
|
|
202
|
+
# genie_config.json must be valid JSON so the greedy patch (top-k:1) can read it.
|
|
203
|
+
with open(os.path.join(d, "genie_config.json"), "w") as f:
|
|
204
|
+
json.dump({"dialog": {"sampler": {"version": 1, "greedy": True, "temp": 0.8}}}, f)
|
|
205
|
+
for name in ("tokenizer.json", "context_0.bin"):
|
|
206
|
+
with open(os.path.join(d, name), "w") as f:
|
|
207
|
+
f.write("x")
|
|
208
|
+
stdout = "loading...\n[BEGIN]Paris.[END]" # genie emits TEXT, no token ids on QAIRT 2.51
|
|
209
|
+
profile = json.dumps({"token_generation_rate": 33.0, "prompt_processing_rate": 292.0})
|
|
210
|
+
dev = FakeDevice(stdout, profile=profile)
|
|
211
|
+
meta = {"template": "chatml", "genie_config": "genie_config.json",
|
|
212
|
+
"bundle_files": ["genie_config.json", "tokenizer.json", "context_0.bin"]}
|
|
213
|
+
tok = FakeTokenizer({"Paris.": [40, 1138]}) # inject: no real tokenizer.json needed
|
|
214
|
+
|
|
215
|
+
out = run(dev, d, meta, "capital of France?", tokenizer=tok, profile=True) # exercise the profile-on path
|
|
216
|
+
|
|
217
|
+
pushed = [remote for _, remote in dev.pushed]
|
|
218
|
+
# bundle pushed one file at a time (FR-L02: never a single multi-GB directory push)
|
|
219
|
+
assert sum(1 for p in pushed if p.endswith(("genie_config.json", "tokenizer.json", "context_0.bin"))) == 3
|
|
220
|
+
# prompt went over as a FILE (--prompt_file), not inline on the shell command line
|
|
221
|
+
assert any(p.endswith("prompt.txt") for p in pushed)
|
|
222
|
+
with open(os.path.join(d, "prompt.txt")) as f:
|
|
223
|
+
assert "im_start" in f.read() # chat template applied, written to the prompt file
|
|
224
|
+
cmd = " ".join(dev.shelled)
|
|
225
|
+
assert "--prompt_file" in cmd
|
|
226
|
+
assert "--print-token-ids" not in cmd and "--max-length" not in cmd # Bug 1: non-existent flags gone
|
|
227
|
+
# greedy run drives genie at a derived top-k:1 config (Bug 2), not sampler.greedy
|
|
228
|
+
assert any(p.endswith("genie_config.greedy.json") for p in pushed)
|
|
229
|
+
assert "-c genie_config.greedy.json" in cmd
|
|
230
|
+
with open(os.path.join(d, "genie_config.greedy.json")) as f:
|
|
231
|
+
assert json.load(f)["dialog"]["sampler"]["top-k"] == 1
|
|
232
|
+
# text parsed, then RETOKENIZED into ids for the gate
|
|
233
|
+
assert out["text"] == "Paris."
|
|
234
|
+
assert out["tokens"] == [40, 1138]
|
|
235
|
+
assert out["latency"]["decode_toks_per_sec"] == 33.0
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def test_run_omits_profile_by_default():
|
|
239
|
+
"""`--profile` defaults OFF: on a real SM8850/v81 board it broke genie 2.51's output and
|
|
240
|
+
false-FAILed a known-good artifact (token_match 0.0). Default run must not pass the flag."""
|
|
241
|
+
import tempfile
|
|
242
|
+
d = tempfile.mkdtemp()
|
|
243
|
+
for name in ("tokenizer.json", "genie_config.json"):
|
|
244
|
+
with open(os.path.join(d, name), "w") as f:
|
|
245
|
+
f.write("{}" if name.endswith(".json") else "x")
|
|
246
|
+
dev = FakeDevice("[BEGIN]Paris.[END]")
|
|
247
|
+
meta = {"template": "chatml", "genie_config": "genie_config.json",
|
|
248
|
+
"bundle_files": ["genie_config.json", "tokenizer.json"]}
|
|
249
|
+
out = run(dev, d, meta, "capital of France?", tokenizer=FakeTokenizer({"Paris.": [40]}))
|
|
250
|
+
cmd = " ".join(dev.shelled)
|
|
251
|
+
assert "--profile" not in cmd # default off (silicon finding)
|
|
252
|
+
assert out["latency"] == {} # no profile pulled/parsed when off
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
if __name__ == "__main__":
|
|
256
|
+
fns = [f for n, f in sorted(globals().items()) if n.startswith("test_")]
|
|
257
|
+
for f in fns:
|
|
258
|
+
f(); print("ok", f.__name__)
|
|
259
|
+
print(f"\n{len(fns)} passed")
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Packaging guards — the public registry must be portable (runnable by a stranger).
|
|
2
|
+
|
|
3
|
+
The #1 release blocker was author-local absolute paths baked into the bundled registry, which make
|
|
4
|
+
`chipwright run` fail for everyone but the author. These tests fail the build if that regresses.
|
|
5
|
+
"""
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
import yaml
|
|
11
|
+
|
|
12
|
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
|
13
|
+
|
|
14
|
+
PKG = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
15
|
+
PUBLIC_INDEX = os.path.join(PKG, "data", "index.yaml") # bundled public index (ships in the wheel)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_public_index_has_no_absolute_paths():
|
|
19
|
+
"""The bundled public registry must not carry author-local absolute paths."""
|
|
20
|
+
text = open(PUBLIC_INDEX).read()
|
|
21
|
+
offenders = [ln for ln in text.splitlines() if "/Users/" in ln or "/home/" in ln]
|
|
22
|
+
assert not offenders, "author-local absolute paths leaked into the public registry:\n" + "\n".join(offenders)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_public_index_parses_and_keeps_records():
|
|
26
|
+
"""The public index still parses and every verified record survived the portability strip."""
|
|
27
|
+
doc = yaml.safe_load(open(PUBLIC_INDEX))
|
|
28
|
+
assert doc.get("registry_schema") == 1
|
|
29
|
+
models = doc.get("models", [])
|
|
30
|
+
assert models, "public registry has no models"
|
|
31
|
+
for m in models:
|
|
32
|
+
for v in m.get("variants", []):
|
|
33
|
+
# a variant must not carry a local path; it is runnable via url, or build-only, or a record.
|
|
34
|
+
assert "local" not in v or not str(v.get("local", "")).startswith(("/Users", "/home")), m["model"]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_verified_record_on_field_not_yaml_coerced():
|
|
38
|
+
"""The verification record's device field is `on:` — which YAML 1.1 coerces to boolean `true` if
|
|
39
|
+
unquoted. Guard against a `true:` key sneaking back in: a record MUST name its device under "on"."""
|
|
40
|
+
doc = yaml.safe_load(open(PUBLIC_INDEX))
|
|
41
|
+
for m in doc.get("models", []):
|
|
42
|
+
for v in m.get("variants", []):
|
|
43
|
+
ver = v.get("verified")
|
|
44
|
+
if isinstance(ver, dict):
|
|
45
|
+
assert True not in ver, (
|
|
46
|
+
f"{m['model']}: a verified-record key coerced to boolean true (quote `on:` as \"on\")")
|
|
47
|
+
if any(k in ver for k in ("date", "loads")): # a real record → it names its device
|
|
48
|
+
assert "on" in ver, f"{m['model']}: verified record missing the `on:` device field"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_every_variant_is_resolvable_or_buildable():
|
|
52
|
+
"""Each variant either hosts an artifact (url+sha256), or is build-only, or carries a record —
|
|
53
|
+
never a dangling local path that only the author can reach."""
|
|
54
|
+
doc = yaml.safe_load(open(PUBLIC_INDEX))
|
|
55
|
+
for m in doc.get("models", []):
|
|
56
|
+
for v in m.get("variants", []):
|
|
57
|
+
has_hosted = bool(v.get("url"))
|
|
58
|
+
has_record = bool(v.get("verified"))
|
|
59
|
+
has_recipe = bool(v.get("recipe") or m.get("recipe") or v.get("source") or m.get("source"))
|
|
60
|
+
assert has_hosted or has_record or has_recipe, (
|
|
61
|
+
f"{m['model']} variant is neither hosted, recorded, nor buildable")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_default_index_is_bundled_inside_the_package():
|
|
65
|
+
"""The default index must live inside the `chipwright` package so it ships in the wheel and
|
|
66
|
+
resolves after a clean `pip install` — not at a repo-relative path that vanishes once installed."""
|
|
67
|
+
from chipwright.registry import DEFAULT_INDEX
|
|
68
|
+
assert os.path.exists(DEFAULT_INDEX), DEFAULT_INDEX
|
|
69
|
+
assert os.path.abspath(DEFAULT_INDEX).startswith(PKG + os.sep), (
|
|
70
|
+
f"DEFAULT_INDEX {DEFAULT_INDEX} is outside the package dir {PKG}; it won't ship in the wheel")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_missing_sshpass_refuses_by_name(monkeypatch):
|
|
74
|
+
"""`sshpass` is an undeclared-by-pip system dep; a missing binary must refuse by name with the
|
|
75
|
+
install hint, not raise a cryptic FileNotFoundError deep in subprocess (spec 003 FR-R05)."""
|
|
76
|
+
from chipwright import device
|
|
77
|
+
monkeypatch.setattr(device.shutil, "which", lambda _: None)
|
|
78
|
+
with pytest.raises(RuntimeError, match="sshpass"):
|
|
79
|
+
device.RemoteQNN("1.2.3.4", "root", "pw", 22)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _variant(**kw):
|
|
83
|
+
from chipwright.resolver import Variant
|
|
84
|
+
base = dict(model="x", version="1", arch="v68", quant="w8a16", shape="s",
|
|
85
|
+
sdk_tested="2.37.1", sdk_compatible=">=2.34,<2.40")
|
|
86
|
+
base.update(kw)
|
|
87
|
+
return Variant(**base)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_locate_ref_local_path_expands(monkeypatch):
|
|
91
|
+
"""A local run reference is just expanduser'd — unchanged behaviour."""
|
|
92
|
+
from chipwright import run as runmod
|
|
93
|
+
got = runmod._locate_ref({"cpu_onnx": "~/x/model.onnx"}, "cpu_onnx")
|
|
94
|
+
assert got == os.path.expanduser("~/x/model.onnx")
|
|
95
|
+
assert runmod._locate_ref({}, "meta") is None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_locate_ref_url_fetches_by_sha(monkeypatch):
|
|
99
|
+
"""A hosted (url) reference is fetched into the cache by its companion sha, keeping its filename."""
|
|
100
|
+
from chipwright import run as runmod, cache
|
|
101
|
+
calls = {}
|
|
102
|
+
monkeypatch.setattr(cache, "fetch_named",
|
|
103
|
+
lambda url, sha, name, sidecars: calls.update(url=url, sha=sha, name=name, side=sidecars) or "/c/model.onnx")
|
|
104
|
+
r = {"cpu_onnx": "https://h/f/model.onnx", "cpu_onnx_sha256": "abc",
|
|
105
|
+
"cpu_onnx_sidecars": [{"url": "https://h/f/model.data", "sha256": "def", "name": "model.data"}]}
|
|
106
|
+
got = runmod._locate_ref(r, "cpu_onnx")
|
|
107
|
+
assert got == "/c/model.onnx"
|
|
108
|
+
assert calls["url"].endswith("model.onnx") and calls["sha"] == "abc" and calls["name"] == "model.onnx"
|
|
109
|
+
assert calls["side"] == [("https://h/f/model.data", "def", "model.data")]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_locate_ref_url_without_sha_refuses_by_name():
|
|
113
|
+
"""A url reference with no sha is refused — never fetch a reference the gate can't verify."""
|
|
114
|
+
from chipwright import run as runmod
|
|
115
|
+
with pytest.raises(RuntimeError, match="unverified reference|_sha256"):
|
|
116
|
+
runmod._locate_ref({"input": "https://h/f/input.npy"}, "input")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_run_refuses_when_no_artifact(monkeypatch):
|
|
120
|
+
"""Refusal path 1 (spec 003 FR-R02 / SC-R03): a record-only variant with no hosted url and no
|
|
121
|
+
local path must refuse by name — pointing at `build` and CW_REGISTRY — not a raw traceback."""
|
|
122
|
+
from chipwright import run as runmod
|
|
123
|
+
v = _variant(run={"modality": "asr_encoder"}) # no url, no local
|
|
124
|
+
with pytest.raises(RuntimeError, match="build .* chipwright build|CW_REGISTRY"):
|
|
125
|
+
runmod.run_variant(v)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_run_refuses_when_no_cpu_reference(monkeypatch, tmp_path):
|
|
129
|
+
"""Refusal path 2 (spec 003 FR-R03 / SC-R03): an artifact present but no CPU reference in the
|
|
130
|
+
active registry must refuse — the verification gate can't run, and a pass is never reported."""
|
|
131
|
+
from chipwright import run as runmod
|
|
132
|
+
art = tmp_path / "a.bin"
|
|
133
|
+
art.write_bytes(b"x")
|
|
134
|
+
v = _variant(local=str(art), run={"modality": "asr_encoder", "board_name": "fx"}) # no cpu_onnx
|
|
135
|
+
|
|
136
|
+
class _FakeDev:
|
|
137
|
+
def __init__(self, *a, **k):
|
|
138
|
+
pass
|
|
139
|
+
|
|
140
|
+
def ensure_ctx(self, _art, _name):
|
|
141
|
+
return "/board/ctx"
|
|
142
|
+
|
|
143
|
+
monkeypatch.setattr(runmod, "_board_env",
|
|
144
|
+
lambda: dict(host="h", user="root", pw="p", port=22))
|
|
145
|
+
monkeypatch.setattr(runmod, "RemoteQNN", _FakeDev)
|
|
146
|
+
with pytest.raises(RuntimeError, match="CPU reference|verification gate"):
|
|
147
|
+
runmod.run_variant(v)
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Precision-support matrix (FR-L06) + arch v79/v81 as first-class hard axes (FR-L05).
|
|
2
|
+
|
|
3
|
+
Pure, no board: the recipe parse/validate and the resolver refusal are both plain functions. Also
|
|
4
|
+
runnable as `python3 test_precision_support.py`.
|
|
5
|
+
"""
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
|
10
|
+
|
|
11
|
+
from chipwright.env import TargetKey
|
|
12
|
+
from chipwright.recipe import from_dict
|
|
13
|
+
from chipwright.resolver import Outcome, Variant, precision_refusal, resolve
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# --- recipe schema: precision_support parses + validates -----------------------------------------
|
|
17
|
+
_BASE = {"name": "qwen2.5-1.5b", "version": "1.0.0",
|
|
18
|
+
"source": {"format": "onnx", "sha256": "abc"},
|
|
19
|
+
"target": {"arch": "v81", "quant": "w4a16", "shape": "ctx4096"}}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _recipe(**extra):
|
|
23
|
+
doc = dict(_BASE)
|
|
24
|
+
doc.update(extra)
|
|
25
|
+
return from_dict(doc)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_recipe_parses_precision_matrix():
|
|
29
|
+
r = _recipe(precision_support={
|
|
30
|
+
"w4a16": "supported",
|
|
31
|
+
"w8a16": "blocked:kv-cache-encoding",
|
|
32
|
+
"fp16": "unsupported:genie-engine"})
|
|
33
|
+
assert r.precision_status("w4a16") == "supported"
|
|
34
|
+
assert r.precision_status("w8a16") == "blocked:kv-cache-encoding"
|
|
35
|
+
assert r.supported_precisions == ["w4a16"]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_recipe_without_matrix_is_empty_not_error():
|
|
39
|
+
r = _recipe()
|
|
40
|
+
assert r.precision_support == {} and r.supported_precisions == []
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_recipe_rejects_unknown_status_kind():
|
|
44
|
+
for bad in ({"w8a16": "maybe"}, {"w8a16": "nope:x"}):
|
|
45
|
+
try:
|
|
46
|
+
_recipe(precision_support=bad)
|
|
47
|
+
except ValueError:
|
|
48
|
+
continue
|
|
49
|
+
raise AssertionError(f"expected ValueError for {bad!r}")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_recipe_rejects_blocked_without_reason():
|
|
53
|
+
for bad in ({"w8a16": "blocked"}, {"w8a16": "unsupported:"}, {"w8a16": "blocked: "}):
|
|
54
|
+
try:
|
|
55
|
+
_recipe(precision_support=bad)
|
|
56
|
+
except ValueError:
|
|
57
|
+
continue
|
|
58
|
+
raise AssertionError(f"a blocked/unsupported status must NAME a reason: {bad!r}")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# --- the pure refusal helper ---------------------------------------------------------------------
|
|
62
|
+
def test_precision_refusal_helper():
|
|
63
|
+
support = {"w4a16": "supported", "w8a16": "blocked:kv-cache-encoding"}
|
|
64
|
+
assert precision_refusal("w4a16", support) is None # supported -> no refusal
|
|
65
|
+
assert precision_refusal("int4", support) is None # not declared -> not refused here
|
|
66
|
+
assert precision_refusal("w8a16", support) == "blocked:kv-cache-encoding"
|
|
67
|
+
assert precision_refusal("w8a16", None) is None # no matrix -> nothing to refuse
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# --- resolver refuses a non-supported precision and NAMES the reason -----------------------------
|
|
71
|
+
_SUPPORT = {"w4a16": "supported", "w8a16": "blocked:kv-cache-encoding", "fp16": "unsupported:genie-engine"}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _llm_variant(arch="v81", quant="w4a16", **kw):
|
|
75
|
+
base = dict(model="qwen2.5-1.5b", version="1.0.0", arch=arch, quant=quant, shape="ctx4096",
|
|
76
|
+
sdk_tested="2.38.0", sdk_compatible=">=2.34,<2.40", precision_support=_SUPPORT)
|
|
77
|
+
base.update(kw)
|
|
78
|
+
return Variant(**base)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_resolve_refuses_blocked_precision_by_name():
|
|
82
|
+
t = TargetKey(htp_arch="v81", qairt="2.38.0")
|
|
83
|
+
d = resolve(t, [_llm_variant(url="u", sha256="s")], want_quant="w8a16")
|
|
84
|
+
assert d.outcome is Outcome.FAIL and d.axis == "precision"
|
|
85
|
+
assert "kv-cache-encoding" in d.reason
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_resolve_refuses_unsupported_precision_by_name():
|
|
89
|
+
t = TargetKey(htp_arch="v81", qairt="2.38.0")
|
|
90
|
+
d = resolve(t, [_llm_variant(url="u", sha256="s")], want_quant="fp16")
|
|
91
|
+
assert d.outcome is Outcome.FAIL and d.axis == "precision"
|
|
92
|
+
assert "genie-engine" in d.reason
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_resolve_allows_supported_precision():
|
|
96
|
+
t = TargetKey(htp_arch="v81", qairt="2.38.0")
|
|
97
|
+
d = resolve(t, [_llm_variant(url="u", sha256="s")], want_quant="w4a16")
|
|
98
|
+
assert d.ok and d.outcome is Outcome.USE
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_resolve_takes_explicit_matrix_over_variants():
|
|
102
|
+
# a caller may pass the matrix directly (e.g. straight from the recipe) — it wins
|
|
103
|
+
t = TargetKey(htp_arch="v81", qairt="2.38.0")
|
|
104
|
+
v = _llm_variant(url="u", sha256="s", precision_support=None)
|
|
105
|
+
d = resolve(t, [v], want_quant="w8a16", precision_support=_SUPPORT)
|
|
106
|
+
assert d.outcome is Outcome.FAIL and d.axis == "precision"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# --- arch v79 and v81 are first-class hard axes (not special-cased anywhere) ----------------------
|
|
110
|
+
def test_arch_v79_and_v81_resolve_as_hard_axis():
|
|
111
|
+
for arch in ("v79", "v81"):
|
|
112
|
+
t = TargetKey(htp_arch=arch, qairt="2.38.0")
|
|
113
|
+
d = resolve(t, [_llm_variant(arch=arch, url="u", sha256="s")], want_quant="w4a16")
|
|
114
|
+
assert d.ok, f"{arch} should USE its own artifact"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_arch_mismatch_v79_vs_v81_fails_on_arch_axis():
|
|
118
|
+
t = TargetKey(htp_arch="v79", qairt="2.38.0") # board is v79
|
|
119
|
+
d = resolve(t, [_llm_variant(arch="v81", url="u", sha256="s")], want_quant="w4a16")
|
|
120
|
+
assert d.outcome is Outcome.FAIL and d.axis == "htp_arch"
|
|
121
|
+
assert "v79" in d.reason
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
if __name__ == "__main__":
|
|
125
|
+
fns = [f for n, f in sorted(globals().items()) if n.startswith("test_")]
|
|
126
|
+
for f in fns:
|
|
127
|
+
f(); print("ok", f.__name__)
|
|
128
|
+
print(f"\n{len(fns)} passed")
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Registry — load the bundled public index (chipwright/data/index.yaml, read-only) and check Variant parsing.
|
|
2
|
+
|
|
3
|
+
Uses the real seed index so the test fails if the shipped index stops parsing. Also runnable as
|
|
4
|
+
`python3 test_registry.py`.
|
|
5
|
+
"""
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
|
10
|
+
|
|
11
|
+
from chipwright.registry import DEFAULT_INDEX, Registry
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_bundled_index_exists_and_loads():
|
|
15
|
+
assert os.path.exists(DEFAULT_INDEX), DEFAULT_INDEX
|
|
16
|
+
reg = Registry()
|
|
17
|
+
assert reg.schema == 1
|
|
18
|
+
models = reg.models()
|
|
19
|
+
for expected in ("laya-decision", "whisper-small-en", "zipformer-enin", "qwen2.5-1.5b-instruct"):
|
|
20
|
+
assert expected in models, expected
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_each_model_yields_variants():
|
|
24
|
+
reg = Registry()
|
|
25
|
+
for model in reg.models():
|
|
26
|
+
vs = reg.variants(model)
|
|
27
|
+
assert vs, f"{model} has no variants"
|
|
28
|
+
for v in vs:
|
|
29
|
+
assert v.model == model
|
|
30
|
+
assert v.arch and v.quant and v.shape
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_hosted_variant_has_artifact():
|
|
34
|
+
reg = Registry()
|
|
35
|
+
laya = reg.variants("laya-decision")[0]
|
|
36
|
+
assert laya.url is not None
|
|
37
|
+
assert laya.sha256 is not None
|
|
38
|
+
assert laya.has_artifact is True
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_recipe_only_variant_has_recipe_not_artifact():
|
|
42
|
+
reg = Registry()
|
|
43
|
+
whisper = reg.variants("whisper-small-en")[0]
|
|
44
|
+
assert whisper.url is None and whisper.local is None
|
|
45
|
+
assert whisper.has_artifact is False
|
|
46
|
+
assert whisper.has_recipe is True
|
|
47
|
+
assert whisper.recipe.endswith(".yaml")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# The public registry is portable (no author-local paths — see spec 003), so the `local` and
|
|
51
|
+
# run-asset fields are exercised against a fixture registry, not the shipped index. This tests the
|
|
52
|
+
# PARSER capability without coupling it to machine-specific content.
|
|
53
|
+
_FIXTURE = """registry_schema: 1
|
|
54
|
+
models:
|
|
55
|
+
- model: fixture-model
|
|
56
|
+
variants:
|
|
57
|
+
- arch: v68
|
|
58
|
+
quant: w8a16
|
|
59
|
+
shape: win39
|
|
60
|
+
sdk_tested: "2.37.1"
|
|
61
|
+
sdk_compatible: ">=2.34,<2.40"
|
|
62
|
+
ext: qnnctx
|
|
63
|
+
local: /tmp/fixture_artifact.bin
|
|
64
|
+
run: {modality: asr_encoder, board_name: fx, meta: /tmp/m.json, cpu_onnx: /tmp/e.onnx, input: /tmp/i.npy, threshold: 0.95}
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _fixture_registry(tmp_path):
|
|
69
|
+
p = tmp_path / "fixture_index.yaml"
|
|
70
|
+
p.write_text(_FIXTURE)
|
|
71
|
+
return Registry(index=str(p))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_local_field_parses_as_on_disk_artifact(tmp_path):
|
|
75
|
+
v = _fixture_registry(tmp_path).variants("fixture-model")[0]
|
|
76
|
+
# a `local:` path counts as a present artifact even with no url
|
|
77
|
+
assert v.url is None
|
|
78
|
+
assert v.local is not None and v.local.endswith(".bin")
|
|
79
|
+
assert v.has_artifact is True
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_run_field_parses_into_dict(tmp_path):
|
|
83
|
+
v = _fixture_registry(tmp_path).variants("fixture-model")[0]
|
|
84
|
+
assert isinstance(v.run, dict)
|
|
85
|
+
assert v.run["modality"] == "asr_encoder"
|
|
86
|
+
assert v.run["threshold"] == 0.95
|
|
87
|
+
for key in ("board_name", "meta", "cpu_onnx", "input"):
|
|
88
|
+
assert key in v.run
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_verified_record_parses():
|
|
92
|
+
reg = Registry()
|
|
93
|
+
laya = reg.variants("laya-decision")[0]
|
|
94
|
+
assert isinstance(laya.verified, dict)
|
|
95
|
+
assert laya.verified["loads"] is True
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_unknown_model_returns_empty_list():
|
|
99
|
+
reg = Registry()
|
|
100
|
+
assert reg.variants("does-not-exist") == []
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
if __name__ == "__main__":
|
|
104
|
+
fns = [f for n, f in sorted(globals().items()) if n.startswith("test_")]
|
|
105
|
+
for f in fns:
|
|
106
|
+
f(); print("ok", f.__name__)
|
|
107
|
+
print(f"\n{len(fns)} passed")
|