Skip to content

Commit 45e6b67

Browse files
Add --num-prompts option to CLI and tests (#12)
* feature: add --num-prompts option to limit number of prompts used in CLI and tests * docs: update README and CHANGELOG to document --num-prompts CLI option * refactor: centralize prompt loading and limiting logic in _load_prompts helper
1 parent c32af1d commit 45e6b67

5 files changed

Lines changed: 130 additions & 53 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
# 0.2.0 (2026-04-02)
1+
# 0.2.3 (2026-04-07)
22

3+
- Added `--num-prompts` option to all CLI subcommands to limit the number of prompts used in a task.
34
- Added global `--max-tokens` flag (defaults to 1024) to the main CLI.
45
- Increased default `max_tokens` for all prompts from 256 to 1024.
56

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ pip install "infer-check[mlx]"
6868

6969
### Quantization sweep
7070

71-
Compare pre-quantized models against a baseline. Each model is a separate HuggingFace repo. Use `--max-tokens` to control generation length (defaults to 1024).
71+
Compare pre-quantized models against a baseline. Each model is a separate HuggingFace repo. Use `--max-tokens` to control generation length (defaults to 1024) and `--num-prompts` to limit the number of prompts used.
7272

7373
```
7474
infer-check sweep \
@@ -78,6 +78,7 @@ infer-check sweep \
7878
--backend mlx-lm \
7979
--prompts reasoning \
8080
--max-tokens 512 \
81+
--num-prompts 10 \
8182
--output ./results/sweep/
8283
```
8384

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,8 @@ select = ["E", "F", "I", "N", "W", "UP", "B", "SIM"]
7676
"html.py" = ["E501"]
7777

7878
[tool.bandit]
79-
targets= ["src"]
79+
targets = ["src"]
80+
exclude_dirs = ["tests"]
8081

8182
[tool.mypy]
8283
python_version = "3.11"

src/infer_check/cli.py

Lines changed: 48 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,12 @@ def common_options(f: F) -> F:
2727
type=click.IntRange(min=1, clamp=True),
2828
help="Override default max tokens for generation.",
2929
),
30+
click.option(
31+
"--num-prompts",
32+
default=None,
33+
type=click.IntRange(min=1, clamp=True),
34+
help="Limit number of prompts to use.",
35+
),
3036
]
3137
for option in reversed(options):
3238
f = option(f)
@@ -43,6 +49,30 @@ def _resolve_prompts(prompts: str) -> Path:
4349
raise click.BadParameter(str(exc)) from exc
4450

4551

52+
def _load_prompts(ctx: click.Context, prompts: str, max_tokens: int | None, num_prompts: int | None) -> list[Any]:
53+
"""Load and prepare a prompt suite with overrides applied."""
54+
from infer_check.suites.loader import load_suite
55+
56+
# Update defaults from subcommand if provided
57+
if max_tokens is not None:
58+
ctx.obj["max_tokens"] = max_tokens
59+
if num_prompts is not None:
60+
ctx.obj["num_prompts"] = num_prompts
61+
62+
prompt_list = load_suite(_resolve_prompts(prompts))
63+
64+
# Apply num_prompts limit
65+
if ctx.obj["num_prompts"] is not None:
66+
prompt_list = prompt_list[: ctx.obj["num_prompts"]]
67+
68+
# Apply global max_tokens only if not explicitly set in the prompt JSONL
69+
for p in prompt_list:
70+
if "max_tokens" not in p.model_fields_set:
71+
p.max_tokens = ctx.obj["max_tokens"]
72+
73+
return prompt_list
74+
75+
4676
@click.group()
4777
@click.version_option(package_name="infer-check")
4878
@click.option(
@@ -51,11 +81,18 @@ def _resolve_prompts(prompts: str) -> Path:
5181
show_default=True,
5282
help="Default max tokens for generation (applies to all prompts unless they specify their own).",
5383
)
84+
@click.option(
85+
"--num-prompts",
86+
default=None,
87+
type=click.IntRange(min=1, clamp=True),
88+
help="Limit the number of prompts to use from a suite; if omitted, all prompts are used.",
89+
)
5490
@click.pass_context
55-
def main(ctx: click.Context, max_tokens: int) -> None:
91+
def main(ctx: click.Context, max_tokens: int, num_prompts: int | None) -> None:
5692
"""infer-check: correctness and reliability testing for LLM inference engines."""
5793
ctx.ensure_object(dict)
5894
ctx.obj["max_tokens"] = max_tokens
95+
ctx.obj["num_prompts"] = num_prompts
5996

6097

6198
# ---------------------------------------------------------------------------
@@ -103,6 +140,7 @@ def sweep(
103140
baseline: str | None,
104141
base_url: str | None,
105142
max_tokens: int | None,
143+
num_prompts: int | None,
106144
) -> None:
107145
"""Run a quantization sweep: compare pre-quantized models against a baseline.
108146
@@ -119,11 +157,8 @@ def sweep(
119157
"""
120158
from infer_check.backends.base import get_backend_for_model
121159
from infer_check.runner import TestRunner
122-
from infer_check.suites.loader import load_suite
123160

124-
# Update max_tokens from subcommand if provided
125-
if max_tokens is not None:
126-
ctx.obj["max_tokens"] = max_tokens
161+
prompt_list = _load_prompts(ctx, prompts, max_tokens, num_prompts)
127162

128163
# Parse label=model_path pairs
129164
model_map: dict[str, str] = {}
@@ -152,11 +187,6 @@ def sweep(
152187
for label, path in model_map.items():
153188
tag = " (baseline)" if label == baseline_label else ""
154189
console.print(f" {label}: {path}{tag}")
155-
prompt_list = load_suite(_resolve_prompts(prompts))
156-
# Apply global max_tokens only if not explicitly set in the prompt JSONL
157-
for p in prompt_list:
158-
if "max_tokens" not in p.model_fields_set:
159-
p.max_tokens = ctx.obj["max_tokens"]
160190

161191
# Build a separate backend for each model
162192
backend_map: dict[str, Any] = {}
@@ -308,6 +338,7 @@ def compare(
308338
label_b: str | None,
309339
report: bool,
310340
max_tokens: int | None,
341+
num_prompts: int | None,
311342
) -> None:
312343
"""Compare two quantizations of the same model.
313344
@@ -335,11 +366,8 @@ def compare(
335366
# ── Resolve both model specs ─────────────────────────────────────
336367
from infer_check.resolve import resolve_model
337368
from infer_check.runner import TestRunner
338-
from infer_check.suites.loader import load_suite
339369

340-
# Update max_tokens from subcommand if provided
341-
if max_tokens is not None:
342-
ctx.obj["max_tokens"] = max_tokens
370+
prompt_list = _load_prompts(ctx, prompts, max_tokens, num_prompts)
343371

344372
resolved_a = resolve_model(model_a, base_url=base_url, label=label_a)
345373
resolved_b = resolve_model(model_b, base_url=base_url, label=label_b)
@@ -350,12 +378,6 @@ def compare(
350378
f"vs B={resolved_b.label} ({resolved_b.backend})"
351379
)
352380

353-
prompt_list = load_suite(_resolve_prompts(prompts))
354-
# Apply global max_tokens only if not explicitly set in the prompt JSONL
355-
for p in prompt_list:
356-
if "max_tokens" not in p.model_fields_set:
357-
p.max_tokens = ctx.obj["max_tokens"]
358-
359381
console.print(f" prompts: {len(prompt_list)} from '{prompts}'")
360382

361383
# ── Build backends ───────────────────────────────────────────────
@@ -573,15 +595,13 @@ def diff(
573595
base_urls: str | None,
574596
chat: bool,
575597
max_tokens: int | None,
598+
num_prompts: int | None,
576599
) -> None:
577600
"""Compare outputs across different backends for the same model and prompts."""
578601
from infer_check.backends.base import BackendConfig, get_backend
579602
from infer_check.runner import TestRunner
580-
from infer_check.suites.loader import load_suite
581603

582-
# Update max_tokens from subcommand if provided
583-
if max_tokens is not None:
584-
ctx.obj["max_tokens"] = max_tokens
604+
prompt_list = _load_prompts(ctx, prompts, max_tokens, num_prompts)
585605

586606
backend_names = [b.strip() for b in backends.split(",") if b.strip()]
587607
url_list: list[str | None] = [u.strip() for u in base_urls.split(",")] if base_urls else [None] * len(backend_names)
@@ -591,12 +611,6 @@ def diff(
591611

592612
console.print(f"[bold cyan]diff[/bold cyan] model={model} backends={backend_names} quant={quant}")
593613

594-
prompt_list = load_suite(_resolve_prompts(prompts))
595-
# Apply global max_tokens only if not explicitly set in the prompt JSONL
596-
for p in prompt_list:
597-
if "max_tokens" not in p.model_fields_set:
598-
p.max_tokens = ctx.obj["max_tokens"]
599-
600614
backend_instances = []
601615
for name, url in zip(backend_names, url_list, strict=True):
602616
config = BackendConfig(
@@ -693,15 +707,13 @@ def stress(
693707
concurrency: str,
694708
base_url: str | None,
695709
max_tokens: int | None,
710+
num_prompts: int | None,
696711
) -> None:
697712
"""Stress-test a backend with varying concurrency levels."""
698713
from infer_check.backends.base import get_backend_for_model
699714
from infer_check.runner import TestRunner
700-
from infer_check.suites.loader import load_suite
701715

702-
# Update max_tokens from subcommand if provided
703-
if max_tokens is not None:
704-
ctx.obj["max_tokens"] = max_tokens
716+
prompt_list = _load_prompts(ctx, prompts, max_tokens, num_prompts)
705717

706718
concurrency_levels = [int(c.strip()) for c in concurrency.split(",") if c.strip()]
707719

@@ -715,12 +727,6 @@ def stress(
715727
f"[bold cyan]stress[/bold cyan] model={model} backend={backend_instance.name} concurrency={concurrency_levels}"
716728
)
717729

718-
prompt_list = load_suite(_resolve_prompts(prompts))
719-
# Apply global max_tokens only if not explicitly set in the prompt JSONL
720-
for p in prompt_list:
721-
if "max_tokens" not in p.model_fields_set:
722-
p.max_tokens = ctx.obj["max_tokens"]
723-
724730
runner = TestRunner()
725731
stress_results = asyncio.run(
726732
runner.stress(
@@ -790,15 +796,13 @@ def determinism(
790796
runs: int,
791797
base_url: str | None,
792798
max_tokens: int | None,
799+
num_prompts: int | None,
793800
) -> None:
794801
"""Test whether a backend produces identical outputs across repeated runs at temperature=0."""
795802
from infer_check.backends.base import get_backend_for_model
796803
from infer_check.runner import TestRunner
797-
from infer_check.suites.loader import load_suite
798804

799-
# Update max_tokens from subcommand if provided
800-
if max_tokens is not None:
801-
ctx.obj["max_tokens"] = max_tokens
805+
prompt_list = _load_prompts(ctx, prompts, max_tokens, num_prompts)
802806

803807
backend_instance = get_backend_for_model(
804808
model_str=model,
@@ -808,12 +812,6 @@ def determinism(
808812

809813
console.print(f"[bold cyan]determinism[/bold cyan] model={model} backend={backend_instance.name} runs={runs}")
810814

811-
prompt_list = load_suite(_resolve_prompts(prompts))
812-
# Apply global max_tokens only if not explicitly set in the prompt JSONL
813-
for p in prompt_list:
814-
if "max_tokens" not in p.model_fields_set:
815-
p.max_tokens = ctx.obj["max_tokens"]
816-
817815
runner = TestRunner()
818816
det_results = asyncio.run(
819817
runner.determinism(

tests/unit/test_cli_num_prompts.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import json
2+
from datetime import UTC, datetime
3+
from pathlib import Path
4+
from unittest.mock import patch
5+
6+
from click.testing import CliRunner
7+
8+
from infer_check.cli import main
9+
from infer_check.runner import TestRunner
10+
from infer_check.types import SweepResult
11+
12+
13+
def test_num_prompts_limit() -> None:
14+
"""Test that --num-prompts correctly limits the number of prompts used."""
15+
runner = CliRunner()
16+
with runner.isolated_filesystem():
17+
prompt_path = Path("test_prompts.jsonl")
18+
prompts = [
19+
{"text": "Prompt 1"},
20+
{"text": "Prompt 2"},
21+
{"text": "Prompt 3"},
22+
{"text": "Prompt 4"},
23+
]
24+
prompt_path.write_text("\n".join(json.dumps(p) for p in prompts), encoding="utf-8")
25+
26+
mock_result = SweepResult(
27+
model_id="m1",
28+
backend_name="mlx",
29+
quantization_levels=["a", "b"],
30+
comparisons=[],
31+
timestamp=datetime.now(UTC),
32+
summary={},
33+
)
34+
35+
with (
36+
patch("infer_check.cli._resolve_prompts", return_value=prompt_path),
37+
patch("infer_check.backends.base.get_backend_for_model"),
38+
patch.object(TestRunner, "sweep", return_value=mock_result) as mock_sweep,
39+
):
40+
# 1. Test global --num-prompts
41+
result = runner.invoke(
42+
main, ["--num-prompts", "2", "sweep", "--models", "a=m1,b=m2", "--prompts", "test_prompts.jsonl"]
43+
)
44+
assert result.exit_code == 0
45+
args, kwargs = mock_sweep.call_args
46+
captured_prompts = kwargs.get("prompts") or args[2]
47+
assert len(captured_prompts) == 2
48+
assert captured_prompts[0].text == "Prompt 1"
49+
assert captured_prompts[1].text == "Prompt 2"
50+
51+
# 2. Test subcommand --num-prompts override
52+
result = runner.invoke(
53+
main,
54+
[
55+
"--num-prompts",
56+
"2",
57+
"sweep",
58+
"--models",
59+
"a=m1,b=m2",
60+
"--prompts",
61+
"test_prompts.jsonl",
62+
"--num-prompts",
63+
"3",
64+
],
65+
)
66+
assert result.exit_code == 0
67+
args, kwargs = mock_sweep.call_args
68+
captured_prompts = kwargs.get("prompts") or args[2]
69+
assert len(captured_prompts) == 3
70+
71+
# 3. Test without --num-prompts (should use all)
72+
result = runner.invoke(main, ["sweep", "--models", "a=m1,b=m2", "--prompts", "test_prompts.jsonl"])
73+
assert result.exit_code == 0
74+
args, kwargs = mock_sweep.call_args
75+
captured_prompts = kwargs.get("prompts") or args[2]
76+
assert len(captured_prompts) == 4

0 commit comments

Comments
 (0)