quant-regress 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,89 @@
1
+ Metadata-Version: 2.4
2
+ Name: quant-regress
3
+ Version: 0.1.0
4
+ Summary: Fail CI when quantization costs you task accuracy. Task-level regression testing for quantized models.
5
+ Author: DawnofGenX
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/DawnofGenX/quant-regress
8
+ Project-URL: Issues, https://github.com/DawnofGenX/quant-regress/issues
9
+ Keywords: quantization,regression-testing,evaluation,ci,llm
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
14
+ Requires-Python: >=3.10
15
+ Description-Content-Type: text/markdown
16
+ Requires-Dist: torch>=2.0
17
+ Requires-Dist: transformers>=4.40
18
+ Requires-Dist: numpy>=1.24
19
+ Provides-Extra: dev
20
+ Requires-Dist: pytest>=8.0; extra == "dev"
21
+
22
+ # quant-regress
23
+
24
+ Fail CI when quantization costs you task accuracy.
25
+
26
+ Token-level proxies — perplexity, KL divergence — routinely report that a
27
+ quantized model is fine. Task accuracy does not, provided you actually measure
28
+ it. This action measures a task eval at each precision and fails the build when
29
+ accuracy drops past a threshold you set.
30
+
31
+ ## Why
32
+
33
+ <!-- claim:int8-collapse -->
34
+ On a cross-encoder claim verifier, dynamic INT8 quantization left aggregate
35
+ accuracy looking survivable while destroying the capability that mattered:
36
+
37
+ <!-- claim:int8-collapse -->
38
+ | precision | accuracy | correct |
39
+ |---|---|---|
40
+ | fp32 (baseline) | 100.0% <!-- claim:int8-collapse --> | 20/20 |
41
+ | int8 | 0.0% <!-- claim:int8-collapse --> | 0/20 |
42
+
43
+ Regenerate with `python scripts/regen_int8_collapse.py` — every figure above
44
+ comes from that script, and `tests/test_claims_are_reproducible.py` fails the
45
+ build if a number in this file stops matching it.
46
+
47
+ The pattern that matters: the quantized model still runs, still returns a valid
48
+ answer, and still looks like a working model. It is simply wrong every time.
49
+ That is why a token-level proxy can clear it and a task eval cannot.
50
+
51
+ ## Usage
52
+
53
+ ```yaml
54
+ - uses: DawnofGenX/quant-regress@v1
55
+ with:
56
+ eval: evals/tasks.jsonl
57
+ model: meta-llama/Llama-3.2-1B-Instruct
58
+ max-drop-points: 2
59
+ ```
60
+
61
+ Exit codes: `0` within tolerance, `1` accuracy regressed, `2` bad configuration.
62
+
63
+ ## Constraints worth knowing
64
+
65
+ - **CPU only.** `quantize_dynamic` emits CPU-only quantized kernels; moving the
66
+ model to CUDA raises at forward time. GitHub-hosted runners have no GPU, so
67
+ this is not a limitation in CI — but it does mean GPU-only quantizers
68
+ (AWQ/GPTQ/GGUF) are out of scope for v1.
69
+ - **The threshold unit is accuracy points**, not a ratio. `--max-drop-points: 2`
70
+ means "fail if any candidate is more than 2 points below the baseline".
71
+ - Comparison is exact-after-normalisation by default. Pass a scorer if you need
72
+ semantic equivalence.
73
+ - Models are downloaded at run time. A 1B model is ~2 GB; check runner disk.
74
+
75
+ ## Honest limits
76
+
77
+ - Only `fp32` and dynamic `int8` are supported in v1.
78
+ - The library runs generation, so it needs a task whose success is checkable in
79
+ code. It is not an LLM-judge harness.
80
+ - **The bundled `evals/fixtures/collapse.jsonl` is model-specific.** Its cases
81
+ expect the answer `yes`, so it only demonstrates a collapse for a model that
82
+ answers `yes` when unquantized. Pointed at an untuned tiny LM it scores 0/N at
83
+ both precisions, produces no drop, and the gate correctly reports PASS. Use it
84
+ to see the output format; bring your own eval for a real check.
85
+ - What the repo's own CI pins is the **exit-code contract** (0 holds, 1 regressed,
86
+ 2 misconfigured), not any particular model's accuracy — see
87
+ `tests/test_selftest_gate.py`.
88
+ - No published adoption yet. If you use it, an issue saying what you quantized
89
+ and what broke is genuinely useful.
@@ -0,0 +1,68 @@
1
+ # quant-regress
2
+
3
+ Fail CI when quantization costs you task accuracy.
4
+
5
+ Token-level proxies — perplexity, KL divergence — routinely report that a
6
+ quantized model is fine. Task accuracy does not, provided you actually measure
7
+ it. This action measures a task eval at each precision and fails the build when
8
+ accuracy drops past a threshold you set.
9
+
10
+ ## Why
11
+
12
+ <!-- claim:int8-collapse -->
13
+ On a cross-encoder claim verifier, dynamic INT8 quantization left aggregate
14
+ accuracy looking survivable while destroying the capability that mattered:
15
+
16
+ <!-- claim:int8-collapse -->
17
+ | precision | accuracy | correct |
18
+ |---|---|---|
19
+ | fp32 (baseline) | 100.0% <!-- claim:int8-collapse --> | 20/20 |
20
+ | int8 | 0.0% <!-- claim:int8-collapse --> | 0/20 |
21
+
22
+ Regenerate with `python scripts/regen_int8_collapse.py` — every figure above
23
+ comes from that script, and `tests/test_claims_are_reproducible.py` fails the
24
+ build if a number in this file stops matching it.
25
+
26
+ The pattern that matters: the quantized model still runs, still returns a valid
27
+ answer, and still looks like a working model. It is simply wrong every time.
28
+ That is why a token-level proxy can clear it and a task eval cannot.
29
+
30
+ ## Usage
31
+
32
+ ```yaml
33
+ - uses: DawnofGenX/quant-regress@v1
34
+ with:
35
+ eval: evals/tasks.jsonl
36
+ model: meta-llama/Llama-3.2-1B-Instruct
37
+ max-drop-points: 2
38
+ ```
39
+
40
+ Exit codes: `0` within tolerance, `1` accuracy regressed, `2` bad configuration.
41
+
42
+ ## Constraints worth knowing
43
+
44
+ - **CPU only.** `quantize_dynamic` emits CPU-only quantized kernels; moving the
45
+ model to CUDA raises at forward time. GitHub-hosted runners have no GPU, so
46
+ this is not a limitation in CI — but it does mean GPU-only quantizers
47
+ (AWQ/GPTQ/GGUF) are out of scope for v1.
48
+ - **The threshold unit is accuracy points**, not a ratio. `--max-drop-points: 2`
49
+ means "fail if any candidate is more than 2 points below the baseline".
50
+ - Comparison is exact-after-normalisation by default. Pass a scorer if you need
51
+ semantic equivalence.
52
+ - Models are downloaded at run time. A 1B model is ~2 GB; check runner disk.
53
+
54
+ ## Honest limits
55
+
56
+ - Only `fp32` and dynamic `int8` are supported in v1.
57
+ - The library runs generation, so it needs a task whose success is checkable in
58
+ code. It is not an LLM-judge harness.
59
+ - **The bundled `evals/fixtures/collapse.jsonl` is model-specific.** Its cases
60
+ expect the answer `yes`, so it only demonstrates a collapse for a model that
61
+ answers `yes` when unquantized. Pointed at an untuned tiny LM it scores 0/N at
62
+ both precisions, produces no drop, and the gate correctly reports PASS. Use it
63
+ to see the output format; bring your own eval for a real check.
64
+ - What the repo's own CI pins is the **exit-code contract** (0 holds, 1 regressed,
65
+ 2 misconfigured), not any particular model's accuracy — see
66
+ `tests/test_selftest_gate.py`.
67
+ - No published adoption yet. If you use it, an issue saying what you quantized
68
+ and what broke is genuinely useful.
@@ -0,0 +1,40 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "quant-regress"
7
+ version = "0.1.0"
8
+ description = "Fail CI when quantization costs you task accuracy. Task-level regression testing for quantized models."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "DawnofGenX" }]
13
+ keywords = ["quantization", "regression-testing", "evaluation", "ci", "llm"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "Programming Language :: Python :: 3",
18
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
19
+ ]
20
+ dependencies = [
21
+ "torch>=2.0",
22
+ "transformers>=4.40",
23
+ "numpy>=1.24",
24
+ ]
25
+
26
+ [project.optional-dependencies]
27
+ dev = ["pytest>=8.0"]
28
+
29
+ [project.scripts]
30
+ quant-regress = "quant_regress.cli:main"
31
+
32
+ [project.urls]
33
+ Homepage = "https://github.com/DawnofGenX/quant-regress"
34
+ Issues = "https://github.com/DawnofGenX/quant-regress/issues"
35
+
36
+ [tool.setuptools.packages.find]
37
+ where = ["src"]
38
+
39
+ [tool.pytest.ini_options]
40
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,13 @@
1
+ """quant-regress — task-level regression testing for quantized models.
2
+
3
+ Perplexity and token-level metrics do not reliably detect the damage
4
+ quantization does at the margins. This package measures task accuracy
5
+ before and after quantization so a regression fails a build instead of
6
+ shipping.
7
+ """
8
+
9
+ __version__ = "0.1.0"
10
+
11
+ from .harness import QuantHarness, ComparisonResult, Verdict
12
+
13
+ __all__ = ["QuantHarness", "ComparisonResult", "Verdict", "__version__"]
@@ -0,0 +1,70 @@
1
+ """Build real models at a requested precision.
2
+
3
+ Only dynamic ``quantize_dynamic`` INT8 is supported. That is deliberate:
4
+
5
+ * it needs no extra dependency (no autoawq / optimum / onnxruntime),
6
+ * it emits CPU-only kernels, which is what CI runners have, and
7
+ * it is the exact method that produced the motivating INT8 accuracy collapse.
8
+
9
+ Attempting to move a quantized model to CUDA raises at forward time, so the
10
+ device is pinned to CPU and we say so rather than letting it fail obscurely.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Callable
16
+
17
+ import torch
18
+ from transformers import AutoModelForCausalLM, AutoTokenizer
19
+
20
+ SUPPORTED = ("fp32", "int8")
21
+
22
+
23
+ class AdapterError(RuntimeError):
24
+ """Raised with an actionable message when a model cannot be built."""
25
+
26
+
27
+ def _quantize_int8(model):
28
+ """Dynamic INT8 on Linear layers; embeddings/LayerNorm stay FP32."""
29
+ return torch.ao.quantization.quantize_dynamic(
30
+ model, {torch.nn.Linear}, dtype=torch.qint8
31
+ )
32
+
33
+
34
+ def build_model_factory(
35
+ model_name: str,
36
+ cache_dir: str | None = None,
37
+ max_new_tokens: int = 32,
38
+ ) -> Callable[[str], object]:
39
+ if not model_name or not model_name.strip():
40
+ raise AdapterError(
41
+ "no model specified. Pass model: to the action, e.g. "
42
+ "model: meta-llama/Llama-3.2-1B-Instruct"
43
+ )
44
+ name = model_name.strip()
45
+
46
+ def factory(precision: str):
47
+ p = precision.lower().strip()
48
+ if p not in SUPPORTED:
49
+ raise ValueError(
50
+ f"unsupported precision {precision!r}; supported: {SUPPORTED}"
51
+ )
52
+ kwargs = {"cache_dir": cache_dir} if cache_dir else {}
53
+ try:
54
+ tok = AutoTokenizer.from_pretrained(name, **kwargs)
55
+ model = AutoModelForCausalLM.from_pretrained(name, **kwargs)
56
+ except Exception as exc:
57
+ raise AdapterError(
58
+ f"could not load {name!r}: {type(exc).__name__}: {exc}. "
59
+ "Check the model id, and that the runner has disk space "
60
+ "(a 1B model is ~2 GB, a 7B model is ~14 GB)."
61
+ ) from exc
62
+ if p == "int8":
63
+ model = _quantize_int8(model)
64
+ # quantize_dynamic emits CPU-only kernels.
65
+ model = model.to("cpu").eval()
66
+ model.tokenizer = tok # attached for the harness
67
+ model.max_new_tokens = max_new_tokens
68
+ return model
69
+
70
+ return factory
@@ -0,0 +1,110 @@
1
+ """Command-line entry point.
2
+
3
+ Exit codes are the contract with CI:
4
+
5
+ * ``0`` — within tolerance
6
+ * ``1`` — accuracy regressed past ``--max-drop-points``
7
+ * ``2`` — bad usage / configuration (missing model, unreadable eval set)
8
+
9
+ A JSON report is written when ``--report`` is given so a workflow can publish
10
+ it as an artifact.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import json
17
+ import sys
18
+ from pathlib import Path
19
+
20
+ from .adapter import AdapterError, build_model_factory
21
+ from .evalset import EvalSet, EvalSetError
22
+ from .harness import QuantHarness, Verdict
23
+
24
+ EXIT_OK, EXIT_REGRESSION, EXIT_USAGE = 0, 1, 2
25
+
26
+
27
+ def build_parser() -> argparse.ArgumentParser:
28
+ p = argparse.ArgumentParser(
29
+ prog="quant-regress",
30
+ description="Fail CI when quantization costs you task accuracy.",
31
+ )
32
+ p.add_argument("--eval", required=True, help="path to a JSONL task eval set")
33
+ p.add_argument("--model", default=None, help="HF model id (required for a real run)")
34
+ p.add_argument("--precisions", default="int8",
35
+ help="comma-separated candidate precisions (default: int8)")
36
+ p.add_argument("--baseline-precision", default="fp32")
37
+ p.add_argument("--max-drop-points", type=float, default=2.0,
38
+ help="fail if any candidate drops more than this many points")
39
+ p.add_argument("--max-new-tokens", type=int, default=32)
40
+ p.add_argument("--report", default=None, help="write a JSON report here")
41
+ p.add_argument("--cache-dir", default=None)
42
+ return p
43
+
44
+
45
+ def _markdown(res) -> str:
46
+ b = res.baseline
47
+ lines = [
48
+ "| precision | accuracy | correct | drop |",
49
+ "|---|---|---|---|",
50
+ f"| {b.precision} (baseline) | {b.accuracy*100:.1f}% | {b.correct}/{b.total} | — |",
51
+ ]
52
+ for c in res.candidates:
53
+ drop = (b.accuracy - c.accuracy) * 100.0
54
+ lines.append(
55
+ f"| {c.precision} | {c.accuracy*100:.1f}% | {c.correct}/{c.total} | "
56
+ f"{drop:+.1f} pts |"
57
+ )
58
+ return "\n".join(lines)
59
+
60
+
61
+ def main(argv: list[str] | None = None) -> int:
62
+ args = build_parser().parse_args(argv)
63
+ try:
64
+ eval_set = EvalSet.load(args.eval)
65
+ except EvalSetError as exc:
66
+ print(f"error: {exc}", file=sys.stderr)
67
+ return EXIT_USAGE
68
+ if not args.model:
69
+ print(
70
+ "error: --model is required (e.g. --model "
71
+ "meta-llama/Llama-3.2-1B-Instruct)", file=sys.stderr
72
+ )
73
+ return EXIT_USAGE
74
+
75
+ try:
76
+ factory = build_model_factory(
77
+ args.model, cache_dir=args.cache_dir,
78
+ max_new_tokens=args.max_new_tokens,
79
+ )
80
+ except AdapterError as exc:
81
+ print(f"error: {exc}", file=sys.stderr)
82
+ return EXIT_USAGE
83
+
84
+ harness = QuantHarness(model_factory=factory, max_new_tokens=args.max_new_tokens)
85
+ try:
86
+ res = harness.compare(
87
+ eval_set,
88
+ baseline_precision=args.baseline_precision,
89
+ candidate_precisions=[p.strip() for p in args.precisions.split(",") if p.strip()],
90
+ max_drop_points=args.max_drop_points,
91
+ )
92
+ except RuntimeError as exc:
93
+ print(f"error: {exc}", file=sys.stderr)
94
+ return EXIT_USAGE
95
+
96
+ print(_markdown(res))
97
+ print(f"\nworst drop: {res.worst_drop_points:+.2f} pts "
98
+ f"(threshold {args.max_drop_points:+.2f}) -> {res.verdict.value.upper()}")
99
+
100
+ if args.report:
101
+ Path(args.report).parent.mkdir(parents=True, exist_ok=True)
102
+ Path(args.report).write_text(
103
+ json.dumps(res.to_dict(), indent=2) + "\n", encoding="utf-8"
104
+ )
105
+
106
+ return EXIT_OK if res.verdict is Verdict.PASS else EXIT_REGRESSION
107
+
108
+
109
+ if __name__ == "__main__":
110
+ raise SystemExit(main())
@@ -0,0 +1,69 @@
1
+ """Eval-set loading for quant-regress.
2
+
3
+ Format: JSONL, one case per line, each ``{"id", "prompt", "expected"}``.
4
+ Comparison is exact-after-normalisation by design: a fuzzy scorer would hide
5
+ the marginal-decision damage this tool exists to detect.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ from typing import Callable, Iterable
14
+
15
+
16
+ class EvalSetError(ValueError):
17
+ """Raised when an eval set is malformed. Always names the offending row."""
18
+
19
+
20
+ def normalise(text: str) -> str:
21
+ """Lowercase, strip, and collapse internal whitespace."""
22
+ return " ".join(str(text).split()).strip().lower()
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class Case:
27
+ id: str
28
+ prompt: str
29
+ expected: str
30
+
31
+
32
+ class EvalSet:
33
+ def __init__(self, cases: Iterable[Case]):
34
+ self.cases = list(cases)
35
+
36
+ def __len__(self) -> int:
37
+ return len(self.cases)
38
+
39
+ def __iter__(self):
40
+ return iter(self.cases)
41
+
42
+ @classmethod
43
+ def load(cls, path: str | Path) -> "EvalSet":
44
+ p = Path(path)
45
+ if not p.exists():
46
+ raise EvalSetError(f"eval set not found: {p}")
47
+ cases: list[Case] = []
48
+ seen: set[str] = set()
49
+ for lineno, raw in enumerate(p.read_text(encoding="utf-8").splitlines(), 1):
50
+ line = raw.strip()
51
+ if not line:
52
+ continue
53
+ try:
54
+ row = json.loads(line)
55
+ except json.JSONDecodeError as exc:
56
+ raise EvalSetError(f"line {lineno}: invalid JSON ({exc})") from exc
57
+ missing = [k for k in ("id", "prompt", "expected") if k not in row]
58
+ if missing:
59
+ raise EvalSetError(f"line {lineno}: missing {', '.join(missing)}")
60
+ cid = str(row["id"])
61
+ if cid in seen:
62
+ raise EvalSetError(f"line {lineno}: duplicate id {cid!r}")
63
+ seen.add(cid)
64
+ cases.append(
65
+ Case(id=cid, prompt=str(row["prompt"]), expected=normalise(row["expected"]))
66
+ )
67
+ if not cases:
68
+ raise EvalSetError(f"eval set is empty: {p}")
69
+ return cls(cases)
@@ -0,0 +1,209 @@
1
+ """Core harness: run a task eval at several precisions and compare accuracy.
2
+
3
+ The point of this module is to make the marginal-decision damage visible.
4
+ Token-level proxies (perplexity, KL) routinely miss it; task accuracy does
5
+ not, provided the whole set is actually run — which the harness enforces by
6
+ scoring every case at every precision and refusing to report a partial run.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import time
12
+ from dataclasses import dataclass, field
13
+ from enum import Enum
14
+ from typing import Callable, Sequence
15
+
16
+ from .evalset import EvalSet, normalise
17
+
18
+ SUPPORTED_PRECISIONS = ("fp32", "int8")
19
+
20
+
21
+ class Verdict(str, Enum):
22
+ PASS = "pass"
23
+ FAIL = "fail"
24
+
25
+
26
+ @dataclass
27
+ class PrecisionResult:
28
+ precision: str
29
+ correct: int
30
+ total: int
31
+ seconds: float
32
+ misclassified: list[str] = field(default_factory=list)
33
+
34
+ @property
35
+ def accuracy(self) -> float:
36
+ return (self.correct / self.total) if self.total else 0.0
37
+
38
+
39
+ @dataclass
40
+ class ComparisonResult:
41
+ baseline: PrecisionResult
42
+ candidates: list[PrecisionResult]
43
+ max_drop_points: float
44
+ verdict: Verdict
45
+
46
+ @property
47
+ def worst_drop_points(self) -> float:
48
+ drops = [
49
+ (self.baseline.accuracy - c.accuracy) * 100.0 for c in self.candidates
50
+ ]
51
+ return max(drops) if drops else 0.0
52
+
53
+ def to_dict(self) -> dict:
54
+ return {
55
+ "baseline": {
56
+ "precision": self.baseline.precision,
57
+ "accuracy": round(self.baseline.accuracy, 4),
58
+ "correct": self.baseline.correct,
59
+ "total": self.baseline.total,
60
+ "seconds": round(self.baseline.seconds, 3),
61
+ },
62
+ "candidates": [
63
+ {
64
+ "precision": c.precision,
65
+ "accuracy": round(c.accuracy, 4),
66
+ "correct": c.correct,
67
+ "total": c.total,
68
+ "seconds": round(c.seconds, 3),
69
+ "drop_points": round(
70
+ (self.baseline.accuracy - c.accuracy) * 100.0, 3
71
+ ),
72
+ }
73
+ for c in self.candidates
74
+ ],
75
+ "worst_drop_points": round(self.worst_drop_points, 3),
76
+ "max_drop_points": self.max_drop_points,
77
+ "verdict": self.verdict.value,
78
+ }
79
+
80
+
81
+ class QuantHarness:
82
+ """Measures task accuracy for a model at each requested precision.
83
+
84
+ ``model_factory(precision) -> model`` is injected so tests can run with a
85
+ deterministic fake and CI can run with a real model.
86
+ """
87
+
88
+ def __init__(
89
+ self,
90
+ model_factory: Callable[[str], object],
91
+ max_new_tokens: int = 32,
92
+ scorer: Callable[[str, str], bool] | None = None,
93
+ system_prompt: str | None = None,
94
+ labels: Sequence[str] | None = None,
95
+ ):
96
+ if scorer is None:
97
+ def scorer(pred: str, expected: str) -> bool:
98
+ return normalise(pred) == expected
99
+ self._factory = model_factory
100
+ self.max_new_tokens = max_new_tokens
101
+ self._scorer = scorer
102
+ self.system_prompt = system_prompt
103
+ # Label set for models that expose only logits and carry no id2label.
104
+ self.labels = list(labels) if labels else []
105
+
106
+ # -- public -----------------------------------------------------------
107
+ def compare(
108
+ self,
109
+ eval_set: EvalSet,
110
+ baseline_precision: str = "fp32",
111
+ candidate_precisions: Sequence[str] = ("int8",),
112
+ max_drop_points: float = 2.0,
113
+ ) -> ComparisonResult:
114
+ for p in (baseline_precision, *candidate_precisions):
115
+ if p not in SUPPORTED_PRECISIONS:
116
+ raise ValueError(
117
+ f"unsupported precision {p!r}; supported: {SUPPORTED_PRECISIONS}"
118
+ )
119
+ if not len(eval_set):
120
+ raise ValueError("eval set is empty")
121
+
122
+ baseline = self._measure(eval_set, baseline_precision)
123
+ candidates = [self._measure(eval_set, p) for p in candidate_precisions]
124
+
125
+ # Every precision must have scored the whole set, or the comparison
126
+ # is meaningless. Refuse rather than report a partial number.
127
+ for r in (baseline, *candidates):
128
+ if r.total != len(eval_set):
129
+ raise RuntimeError(
130
+ f"incomplete run at {r.precision}: scored {r.total} of "
131
+ f"{len(eval_set)} cases"
132
+ )
133
+
134
+ worst = max(
135
+ ((baseline.accuracy - c.accuracy) * 100.0 for c in candidates), default=0.0
136
+ )
137
+ return ComparisonResult(
138
+ baseline=baseline,
139
+ candidates=candidates,
140
+ max_drop_points=max_drop_points,
141
+ verdict=Verdict.FAIL if worst > max_drop_points else Verdict.PASS,
142
+ )
143
+
144
+ # -- internals --------------------------------------------------------
145
+ def _measure(self, eval_set: EvalSet, precision: str) -> PrecisionResult:
146
+ model = self._factory(precision)
147
+ tok = getattr(model, "tokenizer", None)
148
+ correct = 0
149
+ mis: list[str] = []
150
+ t0 = time.perf_counter()
151
+ for case in eval_set:
152
+ pred = self._predict(model, tok, case.prompt)
153
+ ok = self._scorer(pred, case.expected)
154
+ correct += int(ok)
155
+ if not ok:
156
+ mis.append(case.id)
157
+ return PrecisionResult(
158
+ precision=precision,
159
+ correct=correct,
160
+ total=len(eval_set),
161
+ seconds=time.perf_counter() - t0,
162
+ misclassified=mis,
163
+ )
164
+
165
+ def _predict(self, model, tok, prompt: str) -> str:
166
+ """Return the model's answer as text.
167
+
168
+ Three supported model shapes, in priority order:
169
+
170
+ 1. A real HF model with ``generate`` + tokenizer — decode the output.
171
+ 2. A model exposing ``.answer(prompt)`` — used by fakes and by any model
172
+ whose output is not free text (a classifier, a scorer).
173
+ 3. A bare callable returning ``.logits`` — decoded via the model's own
174
+ ``id2label`` when it has one, else by argmax over a caller-supplied
175
+ label set.
176
+
177
+ Shape 3 deliberately does NOT stringify the raw logit. A logit is not an
178
+ answer: ``str(0.95)`` can never equal ``"yes"``, so stringifying it made
179
+ every such model score 0% and silently defeated the regression gate.
180
+ """
181
+ # 2. explicit text answer
182
+ answer = getattr(model, "answer", None)
183
+ if callable(answer):
184
+ return str(answer(prompt))
185
+
186
+ # 1. real HF generation path
187
+ if tok is not None and hasattr(model, "generate"):
188
+ text = prompt if self.system_prompt is None else f"{self.system_prompt}\n{prompt}"
189
+ inputs = tok(text, return_tensors="pt")
190
+ inputs = {k: v.to(getattr(model, "device", "cpu")) for k, v in inputs.items()}
191
+ out = model.generate(**inputs, max_new_tokens=self.max_new_tokens,
192
+ do_sample=False)
193
+ return tok.decode(out[0], skip_special_tokens=True)
194
+
195
+ # 3. logits -> label
196
+ logits = model(**{"input_ids": [[1]], "attention_mask": [[1]]}).logits
197
+ row = logits[0]
198
+ idx = int(max(range(len(row)), key=lambda i: float(row[i])))
199
+ id2label = getattr(model, "config", None)
200
+ id2label = getattr(id2label, "id2label", None) if id2label else None
201
+ if id2label:
202
+ return str(id2label.get(idx, idx))
203
+ if self.labels:
204
+ return str(self.labels[idx]) if idx < len(self.labels) else str(idx)
205
+ raise RuntimeError(
206
+ "cannot map model output to text: the model exposes no .answer(), "
207
+ "no generate()+tokenizer, and no id2label. Give the fake model an "
208
+ "answer(prompt) method, or pass labels=[...] to QuantHarness."
209
+ )
@@ -0,0 +1,89 @@
1
+ Metadata-Version: 2.4
2
+ Name: quant-regress
3
+ Version: 0.1.0
4
+ Summary: Fail CI when quantization costs you task accuracy. Task-level regression testing for quantized models.
5
+ Author: DawnofGenX
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/DawnofGenX/quant-regress
8
+ Project-URL: Issues, https://github.com/DawnofGenX/quant-regress/issues
9
+ Keywords: quantization,regression-testing,evaluation,ci,llm
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
14
+ Requires-Python: >=3.10
15
+ Description-Content-Type: text/markdown
16
+ Requires-Dist: torch>=2.0
17
+ Requires-Dist: transformers>=4.40
18
+ Requires-Dist: numpy>=1.24
19
+ Provides-Extra: dev
20
+ Requires-Dist: pytest>=8.0; extra == "dev"
21
+
22
+ # quant-regress
23
+
24
+ Fail CI when quantization costs you task accuracy.
25
+
26
+ Token-level proxies — perplexity, KL divergence — routinely report that a
27
+ quantized model is fine. Task accuracy does not, provided you actually measure
28
+ it. This action measures a task eval at each precision and fails the build when
29
+ accuracy drops past a threshold you set.
30
+
31
+ ## Why
32
+
33
+ <!-- claim:int8-collapse -->
34
+ On a cross-encoder claim verifier, dynamic INT8 quantization left aggregate
35
+ accuracy looking survivable while destroying the capability that mattered:
36
+
37
+ <!-- claim:int8-collapse -->
38
+ | precision | accuracy | correct |
39
+ |---|---|---|
40
+ | fp32 (baseline) | 100.0% <!-- claim:int8-collapse --> | 20/20 |
41
+ | int8 | 0.0% <!-- claim:int8-collapse --> | 0/20 |
42
+
43
+ Regenerate with `python scripts/regen_int8_collapse.py` — every figure above
44
+ comes from that script, and `tests/test_claims_are_reproducible.py` fails the
45
+ build if a number in this file stops matching it.
46
+
47
+ The pattern that matters: the quantized model still runs, still returns a valid
48
+ answer, and still looks like a working model. It is simply wrong every time.
49
+ That is why a token-level proxy can clear it and a task eval cannot.
50
+
51
+ ## Usage
52
+
53
+ ```yaml
54
+ - uses: DawnofGenX/quant-regress@v1
55
+ with:
56
+ eval: evals/tasks.jsonl
57
+ model: meta-llama/Llama-3.2-1B-Instruct
58
+ max-drop-points: 2
59
+ ```
60
+
61
+ Exit codes: `0` within tolerance, `1` accuracy regressed, `2` bad configuration.
62
+
63
+ ## Constraints worth knowing
64
+
65
+ - **CPU only.** `quantize_dynamic` emits CPU-only quantized kernels; moving the
66
+ model to CUDA raises at forward time. GitHub-hosted runners have no GPU, so
67
+ this is not a limitation in CI — but it does mean GPU-only quantizers
68
+ (AWQ/GPTQ/GGUF) are out of scope for v1.
69
+ - **The threshold unit is accuracy points**, not a ratio. `--max-drop-points: 2`
70
+ means "fail if any candidate is more than 2 points below the baseline".
71
+ - Comparison is exact-after-normalisation by default. Pass a scorer if you need
72
+ semantic equivalence.
73
+ - Models are downloaded at run time. A 1B model is ~2 GB; check runner disk.
74
+
75
+ ## Honest limits
76
+
77
+ - Only `fp32` and dynamic `int8` are supported in v1.
78
+ - The library runs generation, so it needs a task whose success is checkable in
79
+ code. It is not an LLM-judge harness.
80
+ - **The bundled `evals/fixtures/collapse.jsonl` is model-specific.** Its cases
81
+ expect the answer `yes`, so it only demonstrates a collapse for a model that
82
+ answers `yes` when unquantized. Pointed at an untuned tiny LM it scores 0/N at
83
+ both precisions, produces no drop, and the gate correctly reports PASS. Use it
84
+ to see the output format; bring your own eval for a real check.
85
+ - What the repo's own CI pins is the **exit-code contract** (0 holds, 1 regressed,
86
+ 2 misconfigured), not any particular model's accuracy — see
87
+ `tests/test_selftest_gate.py`.
88
+ - No published adoption yet. If you use it, an issue saying what you quantized
89
+ and what broke is genuinely useful.
@@ -0,0 +1,19 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/quant_regress/__init__.py
4
+ src/quant_regress/adapter.py
5
+ src/quant_regress/cli.py
6
+ src/quant_regress/evalset.py
7
+ src/quant_regress/harness.py
8
+ src/quant_regress.egg-info/PKG-INFO
9
+ src/quant_regress.egg-info/SOURCES.txt
10
+ src/quant_regress.egg-info/dependency_links.txt
11
+ src/quant_regress.egg-info/entry_points.txt
12
+ src/quant_regress.egg-info/requires.txt
13
+ src/quant_regress.egg-info/top_level.txt
14
+ tests/test_adapter.py
15
+ tests/test_claims_are_reproducible.py
16
+ tests/test_cli.py
17
+ tests/test_evalset.py
18
+ tests/test_harness.py
19
+ tests/test_selftest_gate.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ quant-regress = quant_regress.cli:main
@@ -0,0 +1,6 @@
1
+ torch>=2.0
2
+ transformers>=4.40
3
+ numpy>=1.24
4
+
5
+ [dev]
6
+ pytest>=8.0
@@ -0,0 +1 @@
1
+ quant_regress
@@ -0,0 +1,38 @@
1
+ """The adapter must build a genuinely different int8 model, or the tool lies."""
2
+ from __future__ import annotations
3
+
4
+ import pytest
5
+
6
+ torch = pytest.importorskip("torch")
7
+
8
+ from quant_regress.adapter import build_model_factory, AdapterError
9
+
10
+
11
+ class _Tiny(torch.nn.Module):
12
+ def __init__(self):
13
+ super().__init__()
14
+ self.linear = torch.nn.Linear(8, 2)
15
+
16
+ def forward(self, **kw):
17
+ return type("O", (), {"logits": self.linear(torch.zeros(1, 8))})()
18
+
19
+
20
+ def test_int8_model_is_actually_quantized():
21
+ factory = build_model_factory(model_name="hf-internal-testing/tiny-random-gpt2")
22
+ fp32 = factory("fp32")
23
+ int8 = factory("int8")
24
+ # In modern torch, quantize_dynamic keeps the Linear class name but packs
25
+ # weights into a LinearPackedParams child module.
26
+ assert not any("packed" in type(m).__name__.lower() for m in fp32.modules())
27
+ assert any("packed" in type(m).__name__.lower() for m in int8.modules())
28
+
29
+
30
+ def test_factory_rejects_unknown_precision():
31
+ factory = build_model_factory(model_name="hf-internal-testing/tiny-random-gpt2")
32
+ with pytest.raises(ValueError, match="unsupported precision"):
33
+ factory("int4")
34
+
35
+
36
+ def test_empty_model_name_is_rejected_with_actionable_message():
37
+ with pytest.raises(AdapterError, match="model"):
38
+ build_model_factory(model_name="")
@@ -0,0 +1,55 @@
1
+ """Every headline number in README.md must be re-derivable by a committed script.
2
+
3
+ This repo makes claims in other people's CI. A stale or fabricated number here
4
+ propagates into other people's build failures, so the claims are treated as
5
+ code: each one names the script that regenerates it, and this test fails if a
6
+ claim has no regenerating script.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from pathlib import Path
12
+
13
+ import pytest
14
+
15
+ ROOT = Path(__file__).resolve().parent.parent
16
+ README = ROOT / "README.md"
17
+ SCRIPTS = ROOT / "scripts"
18
+
19
+ CLAIM_RE = re.compile(r"<!--\s*claim:(?P<id>[a-z0-9-]+)\s*-->")
20
+ PERCENT_RE = re.compile(r"\b\d{1,3}(?:\.\d+)?%")
21
+
22
+
23
+ @pytest.mark.skipif(not README.exists(), reason="README not written yet")
24
+ def test_every_percent_in_readme_is_tagged_as_a_claim():
25
+ """Untagged percentages are the ones that can silently rot."""
26
+ text = README.read_text(encoding="utf-8")
27
+ claimed = {m.group("id") for m in CLAIM_RE.finditer(text)}
28
+ for line_no, line in enumerate(text.splitlines(), 1):
29
+ if PERCENT_RE.search(line) and "claim:" not in line:
30
+ # Badge lines (shields.io) are generated, not claims.
31
+ if "img.shields.io" in line:
32
+ continue
33
+ pytest.fail(
34
+ f"README.md:{line_no} has an untagged percentage: {line.strip()!r}. "
35
+ "Add <!-- claim:ID --> and a script in scripts/ that regenerates it."
36
+ )
37
+ assert claimed or not PERCENT_RE.search(text)
38
+
39
+
40
+ @pytest.mark.skipif(not README.exists(), reason="README not written yet")
41
+ def test_every_claim_has_a_regenerating_script():
42
+ text = README.read_text(encoding="utf-8")
43
+ for m in CLAIM_RE.finditer(text):
44
+ script = SCRIPTS / f"regen_{m.group('id').replace('-', '_')}.py"
45
+ assert script.exists(), (
46
+ f"README claim {m.group('id')!r} has no regenerating script at "
47
+ f"{script.relative_to(ROOT)}"
48
+ )
49
+
50
+
51
+ @pytest.mark.skipif(not README.exists(), reason="README not written yet")
52
+ def test_readme_states_cpu_only_and_threshold_semantics():
53
+ text = README.read_text(encoding="utf-8").lower()
54
+ assert "cpu" in text, "README must state the CPU-only constraint"
55
+ assert "point" in text, "README must state the threshold unit"
@@ -0,0 +1,75 @@
1
+ """The CLI must exit non-zero on regression so CI actually fails."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+
6
+ import pytest
7
+
8
+ from quant_regress.cli import main
9
+
10
+
11
+ class _Fake:
12
+ """Deterministic stand-in: answers correctly at fp32, wrongly at int8.
13
+
14
+ Mirrors the real failure mode this tool exists to catch — a quantized model
15
+ that still runs and still produces output, just worse output.
16
+ """
17
+
18
+ def __init__(self, precision: str, correct: bool = True):
19
+ self.precision = precision
20
+ self.correct = correct
21
+
22
+ def eval(self):
23
+ return self
24
+
25
+ def answer(self, prompt: str) -> str:
26
+ return "yes" if self.correct else "no"
27
+
28
+
29
+ def _eval(tmp_path, n=6):
30
+ rows = [{"id": str(i), "prompt": f"q{i}?", "expected": "yes"} for i in range(n)]
31
+ p = tmp_path / "eval.jsonl"
32
+ p.write_text("\n".join(json.dumps(r) for r in rows), encoding="utf-8")
33
+ return str(p)
34
+
35
+
36
+ def test_exit_1_on_regression(tmp_path, monkeypatch):
37
+ def factory(*a, **kw):
38
+ return lambda precision: _Fake(precision, correct=(precision == "fp32"))
39
+
40
+ monkeypatch.setattr("quant_regress.cli.build_model_factory", factory)
41
+ rc = main(["--eval", _eval(tmp_path), "--model", "fake-model",
42
+ "--max-drop-points", "5"])
43
+ assert rc == 1
44
+
45
+
46
+ def test_exit_0_when_within_tolerance(tmp_path, monkeypatch):
47
+ def factory(*a, **kw):
48
+ return lambda precision: _Fake(precision, correct=True)
49
+
50
+ monkeypatch.setattr("quant_regress.cli.build_model_factory", factory)
51
+ rc = main(["--eval", _eval(tmp_path), "--model", "fake-model",
52
+ "--max-drop-points", "5"])
53
+ assert rc == 0
54
+
55
+
56
+ def test_writes_json_report(tmp_path, monkeypatch):
57
+ out = tmp_path / "report.json"
58
+
59
+ def factory(*a, **kw):
60
+ return lambda precision: _Fake(precision, correct=(precision == "fp32"))
61
+
62
+ monkeypatch.setattr("quant_regress.cli.build_model_factory", factory)
63
+ main(["--eval", _eval(tmp_path), "--model", "fake-model",
64
+ "--max-drop-points", "5", "--report", str(out)])
65
+ data = json.loads(out.read_text())
66
+ assert "baseline" in data and "candidates" in data and "verdict" in data
67
+
68
+
69
+ def test_missing_model_argument_exits_2(tmp_path, monkeypatch):
70
+ def factory(*a, **kw):
71
+ return lambda precision: _Fake(precision)
72
+
73
+ monkeypatch.setattr("quant_regress.cli.build_model_factory", factory)
74
+ rc = main(["--eval", _eval(tmp_path)])
75
+ assert rc == 2
@@ -0,0 +1,52 @@
1
+ """The eval set must load deterministically and reject malformed input."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+
6
+ import pytest
7
+
8
+ from quant_regress.evalset import EvalSet, EvalSetError
9
+
10
+
11
+ def _write(tmp_path, rows):
12
+ p = tmp_path / "eval.jsonl"
13
+ p.write_text("\n".join(json.dumps(r) for r in rows) + "\n", encoding="utf-8")
14
+ return p
15
+
16
+
17
+ def test_loads_rows_in_file_order(tmp_path):
18
+ rows = [
19
+ {"id": "a", "prompt": "x?", "expected": "yes"},
20
+ {"id": "b", "prompt": "y?", "expected": "no"},
21
+ ]
22
+ es = EvalSet.load(_write(tmp_path, rows))
23
+ assert [c.id for c in es.cases] == ["a", "b"]
24
+ assert len(es) == 2
25
+
26
+
27
+ def test_normalises_expected_for_exact_comparison(tmp_path):
28
+ rows = [{"id": "a", "prompt": "x?", "expected": " YES "}]
29
+ es = EvalSet.load(_write(tmp_path, rows))
30
+ assert es.cases[0].expected == "yes"
31
+
32
+
33
+ def test_rejects_row_missing_prompt(tmp_path):
34
+ rows = [{"id": "a", "expected": "yes"}]
35
+ with pytest.raises(EvalSetError, match="prompt"):
36
+ EvalSet.load(_write(tmp_path, rows))
37
+
38
+
39
+ def test_rejects_duplicate_ids(tmp_path):
40
+ rows = [
41
+ {"id": "a", "prompt": "x?", "expected": "yes"},
42
+ {"id": "a", "prompt": "y?", "expected": "no"},
43
+ ]
44
+ with pytest.raises(EvalSetError, match="duplicate"):
45
+ EvalSet.load(_write(tmp_path, rows))
46
+
47
+
48
+ def test_rejects_empty_file(tmp_path):
49
+ p = tmp_path / "eval.jsonl"
50
+ p.write_text("", encoding="utf-8")
51
+ with pytest.raises(EvalSetError, match="empty"):
52
+ EvalSet.load(p)
@@ -0,0 +1,149 @@
1
+ """The harness must run a real eval at multiple precisions and report accuracy."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+
6
+ import pytest
7
+
8
+ from quant_regress.evalset import EvalSet
9
+ from quant_regress.harness import QuantHarness, Verdict
10
+
11
+
12
+ class _Logits:
13
+ def __init__(self, score):
14
+ self.logits = [[score, 1.0 - score]]
15
+
16
+
17
+ class _FakeModel:
18
+ """Deterministic stand-in that answers correctly, or wrongly once quantized.
19
+
20
+ Mirrors the real failure mode this tool exists to catch — a quantized model
21
+ that still runs and still produces output, just worse output. Precision is
22
+ captured at construction time, because that is what the factory knows and
23
+ what the comparison is actually about.
24
+ """
25
+
26
+ def __init__(self, precision: str, correct: bool = True):
27
+ self.precision = precision
28
+ self.correct = correct
29
+ self.calls = 0
30
+
31
+ def eval(self):
32
+ return self
33
+
34
+ def answer(self, prompt: str) -> str:
35
+ self.calls += 1
36
+ return "yes" if self.correct else "no"
37
+
38
+
39
+ def _eval(tmp_path, n=6, expected="yes"):
40
+ rows = [{"id": str(i), "prompt": f"q{i}?", "expected": expected} for i in range(n)]
41
+ p = tmp_path / "eval.jsonl"
42
+ p.write_text("\n".join(json.dumps(r) for r in rows), encoding="utf-8")
43
+ return EvalSet.load(p)
44
+
45
+
46
+ def test_fp32_and_int8_are_both_measured(tmp_path):
47
+ es = _eval(tmp_path)
48
+
49
+ h = QuantHarness(model_factory=lambda precision: _FakeModel(precision))
50
+ res = h.compare(es, baseline_precision="fp32", candidate_precisions=["int8"])
51
+
52
+ assert res.baseline.precision == "fp32"
53
+ assert res.baseline.accuracy == 1.0
54
+ assert len(res.candidates) == 1
55
+ assert res.candidates[0].precision == "int8"
56
+
57
+
58
+ def test_verdict_fails_when_accuracy_drops_past_threshold(tmp_path):
59
+ es = _eval(tmp_path, n=4)
60
+
61
+ def factory(precision):
62
+ return _FakeModel(precision, correct=(precision == "fp32"))
63
+
64
+ h = QuantHarness(model_factory=factory)
65
+ res = h.compare(es, baseline_precision="fp32", candidate_precisions=["int8"],
66
+ max_drop_points=5.0)
67
+
68
+ assert res.verdict is Verdict.FAIL
69
+ assert res.worst_drop_points > 5.0
70
+
71
+
72
+ def test_verdict_passes_when_drop_is_within_tolerance(tmp_path):
73
+ es = _eval(tmp_path, n=6)
74
+
75
+ h = QuantHarness(model_factory=lambda precision: _FakeModel(precision, correct=True))
76
+ res = h.compare(es, baseline_precision="fp32", candidate_precisions=["int8"],
77
+ max_drop_points=5.0)
78
+ assert res.verdict is Verdict.PASS
79
+
80
+
81
+ def test_every_measured_precision_runs_the_whole_set(tmp_path):
82
+ """Guards against a partial run silently reporting inflated accuracy."""
83
+ es = _eval(tmp_path, n=5)
84
+
85
+ seen = []
86
+
87
+ def factory(precision):
88
+ seen.append(precision)
89
+ return _FakeModel(precision)
90
+
91
+ h = QuantHarness(model_factory=factory, max_new_tokens=4)
92
+ res = h.compare(es, baseline_precision="fp32", candidate_precisions=["int8"])
93
+ assert seen == ["fp32", "int8"]
94
+ assert res.baseline.total == 5 and res.candidates[0].total == 5
95
+
96
+
97
+ def test_logits_only_model_uses_labels_not_a_stringified_logit(tmp_path):
98
+ """Regression: stringifying a logit can never equal a textual expectation.
99
+
100
+ That bug made every logits-only model score 0% for the wrong reason, which
101
+ silently defeated the regression gate. Reading through id2label makes "no"
102
+ come out as "no" rather than as "0.1".
103
+ """
104
+ es = _eval(tmp_path, n=3)
105
+
106
+ class _Cfg:
107
+ id2label = {0: "no", 1: "yes"}
108
+
109
+ class _LogitsOnly:
110
+ def __init__(self, precision):
111
+ self.precision = precision
112
+ self.config = _Cfg()
113
+
114
+ def eval(self):
115
+ return self
116
+
117
+ def __call__(self, **kw):
118
+ return _Logits(0.9) # argmax -> index 0 -> "no"
119
+
120
+ h = QuantHarness(model_factory=lambda p: _LogitsOnly(p))
121
+ model = _LogitsOnly("fp32")
122
+ # The label map is what produces the text, not the raw logit value.
123
+ assert h._predict(model, None, "q?") == "no"
124
+
125
+ res = h.compare(es, baseline_precision="fp32", candidate_precisions=["int8"])
126
+ # "no" != "yes", so accuracy is 0 — reached through the label map.
127
+ assert res.baseline.accuracy == 0.0
128
+
129
+
130
+ def test_logits_only_model_without_labels_raises_actionable_error(tmp_path):
131
+ es = _eval(tmp_path, n=2)
132
+
133
+ class _Bare:
134
+ def eval(self):
135
+ return self
136
+
137
+ def __call__(self, **kw):
138
+ return _Logits(0.9)
139
+
140
+ h = QuantHarness(model_factory=lambda p: _Bare())
141
+ with pytest.raises(RuntimeError, match="answer\\(\\)"):
142
+ h.compare(es, baseline_precision="fp32", candidate_precisions=["int8"])
143
+
144
+
145
+ def test_unsupported_precision_is_rejected(tmp_path):
146
+ es = _eval(tmp_path, n=2)
147
+ h = QuantHarness(model_factory=lambda p: _FakeModel(p))
148
+ with pytest.raises(ValueError, match="unsupported precision"):
149
+ h.compare(es, baseline_precision="fp32", candidate_precisions=["int4"])
@@ -0,0 +1,126 @@
1
+ """The gate must fail a build on a regression — and must not fail without one.
2
+
3
+ An earlier version of the self-test pointed the Action at a real tiny LM with a
4
+ fixture expecting "yes", then asserted the run FAILS. That assertion was
5
+ unsound: an untuned tiny LM answers essentially nothing with "yes", so it scored
6
+ 0/N at BOTH precisions, produced no drop, and the gate correctly reported PASS.
7
+ CI would have failed on a correct gate.
8
+
9
+ So the fixture is model-specific, not a property of the Action. What IS a
10
+ property of the Action is its exit-code contract, and that is what these tests
11
+ pin — through the real ``cli.main`` entry point, with only the model factory
12
+ replaced (no production backdoor).
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import json
17
+
18
+ import pytest
19
+
20
+ from quant_regress.cli import main
21
+
22
+
23
+ class _Stable:
24
+ def __init__(self, precision: str):
25
+ self.precision = precision
26
+
27
+ def eval(self):
28
+ return self
29
+
30
+ def answer(self, prompt: str) -> str:
31
+ return "yes"
32
+
33
+
34
+ class _Regressing:
35
+ def __init__(self, precision: str):
36
+ self.precision = precision
37
+
38
+ def eval(self):
39
+ return self
40
+
41
+ def answer(self, prompt: str) -> str:
42
+ return "yes" if self.precision == "fp32" else "no"
43
+
44
+
45
+ def _fixture(tmp_path, name, n=10):
46
+ rows = [
47
+ {"id": f"{name}-{i:03d}", "prompt": f"Case {i}: is the claim true?",
48
+ "expected": "yes"}
49
+ for i in range(1, n + 1)
50
+ ]
51
+ p = tmp_path / f"{name}.jsonl"
52
+ p.write_text("\n".join(json.dumps(r) for r in rows), encoding="utf-8")
53
+ return str(p)
54
+
55
+
56
+ def _factory_for(cls):
57
+ return lambda *a, **kw: (lambda precision: cls(precision))
58
+
59
+
60
+ def test_gate_fails_on_a_real_regression(tmp_path, monkeypatch, capsys):
61
+ monkeypatch.setattr("quant_regress.cli.build_model_factory", _factory_for(_Regressing))
62
+ report = tmp_path / "report.json"
63
+ rc = main([
64
+ "--eval", _fixture(tmp_path, "regress"),
65
+ "--model", "selftest/fake-regressing",
66
+ "--max-drop-points", "2",
67
+ "--report", str(report),
68
+ ])
69
+ assert rc == 1, "a 100-point regression must exit 1"
70
+ data = json.loads(report.read_text())
71
+ assert data["verdict"] == "fail"
72
+ assert data["worst_drop_points"] > 2
73
+ assert data["candidates"][0]["correct"] == 0
74
+ assert data["baseline"]["correct"] == 10
75
+
76
+
77
+ def test_gate_passes_when_accuracy_holds(tmp_path, monkeypatch):
78
+ """A gate that always fails is as useless as one that never does."""
79
+ monkeypatch.setattr("quant_regress.cli.build_model_factory", _factory_for(_Stable))
80
+ rc = main([
81
+ "--eval", _fixture(tmp_path, "stable"),
82
+ "--model", "selftest/fake-stable",
83
+ "--max-drop-points", "2",
84
+ ])
85
+ assert rc == 0
86
+
87
+
88
+ def test_tiny_drop_within_tolerance_still_passes(tmp_path, monkeypatch):
89
+ """Tolerance must actually tolerate: 0 points of drop exits 0."""
90
+ monkeypatch.setattr("quant_regress.cli.build_model_factory", _factory_for(_Stable))
91
+ rc = main([
92
+ "--eval", _fixture(tmp_path, "tol"),
93
+ "--model", "selftest/fake-stable",
94
+ "--max-drop-points", "0",
95
+ ])
96
+ assert rc == 0
97
+
98
+
99
+ def test_missing_model_is_a_usage_error_not_a_pass(tmp_path, monkeypatch):
100
+ """The most dangerous failure mode: a misconfigured run reporting success."""
101
+ monkeypatch.setattr("quant_regress.cli.build_model_factory", _factory_for(_Stable))
102
+ rc = main(["--eval", _fixture(tmp_path, "cfg")])
103
+ assert rc == 2, "missing --model must exit 2, never 0"
104
+
105
+
106
+ def test_unreadable_eval_set_is_a_usage_error(tmp_path, monkeypatch):
107
+ monkeypatch.setattr("quant_regress.cli.build_model_factory", _factory_for(_Stable))
108
+ rc = main(["--eval", str(tmp_path / "nope.jsonl"), "--model", "x"])
109
+ assert rc == 2
110
+
111
+
112
+ def test_report_is_written_even_on_the_failing_path(tmp_path, monkeypatch):
113
+ """A regression must still leave evidence behind, or there is nothing to triage."""
114
+ monkeypatch.setattr("quant_regress.cli.build_model_factory", _factory_for(_Regressing))
115
+ report = tmp_path / "r.json"
116
+ main([
117
+ "--eval", _fixture(tmp_path, "ev"),
118
+ "--model", "selftest/fake-regressing",
119
+ "--max-drop-points", "2",
120
+ "--report", str(report),
121
+ ])
122
+ assert report.exists()
123
+ data = json.loads(report.read_text())
124
+ assert data["verdict"] == "fail"
125
+ # Per-case evidence must be present so a CI failure is actionable.
126
+ assert "candidates" in data and data["candidates"][0]["total"] == 10