chipwright 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. chipwright/__init__.py +11 -0
  2. chipwright/adapt.py +57 -0
  3. chipwright/build.py +329 -0
  4. chipwright/cache.py +65 -0
  5. chipwright/cli.py +242 -0
  6. chipwright/config.py +106 -0
  7. chipwright/data/behavior_prompts.yaml +108 -0
  8. chipwright/data/index.yaml +327 -0
  9. chipwright/device.py +143 -0
  10. chipwright/device_adb.py +121 -0
  11. chipwright/env.py +229 -0
  12. chipwright/errors.py +41 -0
  13. chipwright/llm_reference.py +114 -0
  14. chipwright/modalities/__init__.py +3 -0
  15. chipwright/modalities/asr_encoder.py +90 -0
  16. chipwright/modalities/cv.py +34 -0
  17. chipwright/modalities/llm.py +300 -0
  18. chipwright/recipe.py +205 -0
  19. chipwright/registry.py +65 -0
  20. chipwright/resolver.py +197 -0
  21. chipwright/run.py +173 -0
  22. chipwright/sdk.py +140 -0
  23. chipwright/tags.py +56 -0
  24. chipwright/task_eval.py +190 -0
  25. chipwright/tests/conftest.py +12 -0
  26. chipwright/tests/test_adapt.py +61 -0
  27. chipwright/tests/test_build.py +279 -0
  28. chipwright/tests/test_cache.py +147 -0
  29. chipwright/tests/test_device_adb.py +140 -0
  30. chipwright/tests/test_env.py +73 -0
  31. chipwright/tests/test_env_adb.py +120 -0
  32. chipwright/tests/test_llm_modality.py +259 -0
  33. chipwright/tests/test_packaging.py +147 -0
  34. chipwright/tests/test_precision_support.py +128 -0
  35. chipwright/tests/test_registry.py +107 -0
  36. chipwright/tests/test_resolver.py +93 -0
  37. chipwright/tests/test_run_llm.py +47 -0
  38. chipwright/tests/test_tags.py +95 -0
  39. chipwright/tests/test_task_eval.py +164 -0
  40. chipwright/tests/test_verify.py +92 -0
  41. chipwright/tests/test_verify_llm.py +244 -0
  42. chipwright/verify.py +24 -0
  43. chipwright/verify_llm.py +203 -0
  44. chipwright-0.2.0.dist-info/METADATA +268 -0
  45. chipwright-0.2.0.dist-info/RECORD +49 -0
  46. chipwright-0.2.0.dist-info/WHEEL +5 -0
  47. chipwright-0.2.0.dist-info/entry_points.txt +2 -0
  48. chipwright-0.2.0.dist-info/licenses/LICENSE +21 -0
  49. chipwright-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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")