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.
- quant_regress-0.1.0/PKG-INFO +89 -0
- quant_regress-0.1.0/README.md +68 -0
- quant_regress-0.1.0/pyproject.toml +40 -0
- quant_regress-0.1.0/setup.cfg +4 -0
- quant_regress-0.1.0/src/quant_regress/__init__.py +13 -0
- quant_regress-0.1.0/src/quant_regress/adapter.py +70 -0
- quant_regress-0.1.0/src/quant_regress/cli.py +110 -0
- quant_regress-0.1.0/src/quant_regress/evalset.py +69 -0
- quant_regress-0.1.0/src/quant_regress/harness.py +209 -0
- quant_regress-0.1.0/src/quant_regress.egg-info/PKG-INFO +89 -0
- quant_regress-0.1.0/src/quant_regress.egg-info/SOURCES.txt +19 -0
- quant_regress-0.1.0/src/quant_regress.egg-info/dependency_links.txt +1 -0
- quant_regress-0.1.0/src/quant_regress.egg-info/entry_points.txt +2 -0
- quant_regress-0.1.0/src/quant_regress.egg-info/requires.txt +6 -0
- quant_regress-0.1.0/src/quant_regress.egg-info/top_level.txt +1 -0
- quant_regress-0.1.0/tests/test_adapter.py +38 -0
- quant_regress-0.1.0/tests/test_claims_are_reproducible.py +55 -0
- quant_regress-0.1.0/tests/test_cli.py +75 -0
- quant_regress-0.1.0/tests/test_evalset.py +52 -0
- quant_regress-0.1.0/tests/test_harness.py +149 -0
- quant_regress-0.1.0/tests/test_selftest_gate.py +126 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -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
|