quant-regress 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
quant_regress/cli.py ADDED
@@ -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,10 @@
1
+ quant_regress/__init__.py,sha256=FdX062901mNltHsvTWGpnAKR4MWqClsXuKihCBXlHYk,457
2
+ quant_regress/adapter.py,sha256=iIBYKxRBUaWzRjrZcix10nZLJoNLWAngZggDjkpxlIE,2401
3
+ quant_regress/cli.py,sha256=aTSbXbD_igfQwwULFm2lpuH_IYdjUYA824qKi6cLym8,3712
4
+ quant_regress/evalset.py,sha256=C7RtyoijUOm4crpfe9V5Pg0NwDy_JOOSmDrBBZDrG40,2144
5
+ quant_regress/harness.py,sha256=P3nPYRe99-Sh1Wb8SydGWFX0KFYm1CGiLUrcuAXFx4k,7852
6
+ quant_regress-0.1.0.dist-info/METADATA,sha256=Hev2UbdF0d0nVwD4hQgHRb5eJoiPzMA0lM_ArSVgeAY,3752
7
+ quant_regress-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
8
+ quant_regress-0.1.0.dist-info/entry_points.txt,sha256=oRiCjEh4r0wiHGdzNeGw8YktnknxPSujgpMjcDF4hCA,57
9
+ quant_regress-0.1.0.dist-info/top_level.txt,sha256=uQCAYM3v-PLGcdsYS4qoFdvTl-nGvO9RXZvT9W-Wqig,14
10
+ quant_regress-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ quant-regress = quant_regress.cli:main
@@ -0,0 +1 @@
1
+ quant_regress