Skip to content

Commit 9eef5cf

Browse files
Akkariclaude
andcommitted
feat: add --estimate-time flag and time estimates to pre-run confirmation
- Add THOROUGH_SECONDS_PER_FILE calibration constants to cost.py (python=100s, docs=200s, config=150s, generic=120s) from production run data - Add _classify_file_type() and time_estimate() to cost.py; returns TimeEstimate with mid/min/max seconds and recommended --timeout (max+20%) - Add --estimate-time flag to quorum run: resolves files, prints estimate table, exits without validating (no API key required) - Update _show_cost_estimate_and_confirm() to show time alongside cost: 'Estimated: ~28 min, ~$0.20 (13 files, thorough) [approximate]' - 26 new tests covering file classification, time_estimate(), and CLI flag Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent e222e1f commit 9eef5cf

3 files changed

Lines changed: 344 additions & 9 deletions

File tree

reference-implementation/quorum/cli.py

Lines changed: 55 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,12 @@ def cli(verbose: bool) -> None:
144144
default=False,
145145
help="Generate audit-detail.csv and audit-summary.csv in the run directory. Auto-enabled at --depth thorough.",
146146
)
147+
@click.option(
148+
"--estimate-time",
149+
is_flag=True,
150+
default=False,
151+
help="Show a time estimate for the run (files, duration, recommended --timeout) then exit without validating.",
152+
)
147153
def run_cmd(
148154
target: str | None,
149155
pattern: str | None,
@@ -159,6 +165,7 @@ def run_cmd(
159165
max_cost: float | None,
160166
yes: bool,
161167
audit_report: bool,
168+
estimate_time: bool,
162169
) -> None:
163170
"""
164171
Validate artifacts against a rubric.
@@ -189,8 +196,8 @@ def run_cmd(
189196
print_error("Provide --target <path> to start a new run, or --resume <batch-dir> to continue an interrupted one.")
190197
sys.exit(1)
191198

192-
# Check for API keys before doing any work
193-
if not _has_api_key():
199+
# Check for API keys before doing any work (skip for --estimate-time, no LLM needed)
200+
if not estimate_time and not _has_api_key():
194201
_first_run_setup()
195202
if not _has_api_key():
196203
print_error(
@@ -256,6 +263,15 @@ def run_cmd(
256263
or "?" in target
257264
)
258265

266+
# --estimate-time: resolve files, print estimate table, exit without validating
267+
if estimate_time:
268+
if is_batch:
269+
files = resolve_targets(target, pattern)
270+
else:
271+
files = [target_path]
272+
_show_time_estimate(files, depth, quorum_config)
273+
sys.exit(0)
274+
259275
# Auto-enable audit report at thorough depth
260276
effective_audit = audit_report or (depth == "thorough")
261277

@@ -642,30 +658,61 @@ def _first_run_setup(force: bool = False) -> None:
642658
click.echo()
643659

644660

661+
def _format_duration(seconds: int) -> str:
662+
"""Format a duration in seconds to a human-readable string."""
663+
if seconds <= 0:
664+
return "0 sec"
665+
if seconds < 60:
666+
return f"{seconds} sec"
667+
mins = round(seconds / 60)
668+
return f"{mins} min"
669+
670+
671+
def _show_time_estimate(files: list, depth: str, config: object) -> None:
672+
"""Print a time estimate table for --estimate-time and exit."""
673+
from quorum.cost import time_estimate, estimate_cost
674+
675+
te = time_estimate(files, depth)
676+
ce = estimate_cost(files, config)
677+
678+
click.echo()
679+
click.echo(f" Files to validate: {te.files_count}")
680+
click.echo(
681+
f" Estimated duration: ~{_format_duration(te.estimated_seconds)} "
682+
f"(range: {_format_duration(te.min_seconds)}\u2013{_format_duration(te.max_seconds)})"
683+
)
684+
click.echo(f" Estimated cost: ~${ce.estimated_usd:.2f} [approximate]")
685+
if te.recommended_timeout > 0:
686+
click.echo(f" Recommended --timeout: {te.recommended_timeout} seconds (max + 20% buffer)")
687+
click.echo()
688+
689+
645690
def _show_cost_estimate_and_confirm(
646691
files: list,
647692
config: object,
648693
skip_prompt: bool,
649694
) -> None:
650695
"""
651-
Show a pre-run cost estimate and optionally prompt to continue.
696+
Show a pre-run cost + time estimate and optionally prompt to continue.
652697
653698
Skips the prompt when:
654699
- --yes flag is set
655700
- estimate is below $0.50 (low-cost run, no need to interrupt)
656701
- not running in an interactive terminal
657702
"""
658703
try:
659-
from quorum.cost import estimate_cost
660-
estimate = estimate_cost(files, config)
704+
from quorum.cost import estimate_cost, time_estimate
705+
ce = estimate_cost(files, config)
706+
te = time_estimate(files, getattr(config, "depth_profile", "standard"))
661707
click.echo(
662-
f"Estimated cost: ~${estimate.estimated_usd:.2f} "
663-
f"({estimate.estimated_calls} critic calls across {estimate.files_count} files) "
708+
f"Estimated: ~{_format_duration(te.estimated_seconds)}, "
709+
f"~${ce.estimated_usd:.2f} "
710+
f"({ce.files_count} files, {getattr(config, 'depth_profile', '?')}) "
664711
f"[approximate]",
665712
err=True,
666713
)
667714

668-
if skip_prompt or estimate.estimated_usd < 0.50 or not sys.stdin.isatty():
715+
if skip_prompt or ce.estimated_usd < 0.50 or not sys.stdin.isatty():
669716
return
670717

671718
if not click.confirm("Proceed with validation?", default=True):

reference-implementation/quorum/cost.py

Lines changed: 96 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,33 @@
2020

2121
logger = logging.getLogger(__name__)
2222

23-
# Approximate per-token costs (USD) for common models.
23+
# ── Time estimation calibration constants ────────────────────────────────────
24+
25+
# Thorough depth: per-file time in seconds by file type.
26+
# Multipliers relative to python baseline (1.0x): docs=2.0x, config=1.5x, generic=1.2x
27+
# Source: production runs (13 Python files → ~22 min observed; docs heavier due to critic work)
28+
THOROUGH_SECONDS_PER_FILE: dict[str, int] = {
29+
"python": 100, # observed: ~100 sec/file
30+
"docs": 200, # observed range: 180-240 sec/file
31+
"config": 150, # 1.5x python baseline
32+
"generic": 120, # 1.2x python baseline
33+
}
34+
35+
# Calibrated ranges for thorough depth per file type: (min_sec, max_sec)
36+
_THOROUGH_RANGE: dict[str, tuple[int, int]] = {
37+
"python": (85, 115),
38+
"docs": (180, 240),
39+
"config": (128, 172),
40+
"generic": (102, 138),
41+
}
42+
43+
# Quick/standard depth: (min_sec, mid_sec, max_sec) — same for all file types
44+
_DEPTH_SECONDS: dict[str, tuple[int, int, int]] = {
45+
"quick": (10, 12, 15), # pre-screen only, no LLM calls
46+
"standard": (45, 52, 60),
47+
}
48+
49+
# ── Approximate per-token costs (USD) for common models ──────────────────────
2450
# Format: {model_name_fragment: (input_per_1k_tokens, output_per_1k_tokens)}
2551
# Check your provider's current pricing — these are approximate.
2652
_MODEL_RATES: dict[str, tuple[float, float]] = {
@@ -93,6 +119,18 @@ class CostEstimate(BaseModel):
93119
is_approximate: bool = True
94120

95121

122+
class TimeEstimate(BaseModel):
123+
"""Pre-run time estimate for a validation run."""
124+
125+
depth: str
126+
files_count: int
127+
estimated_seconds: int
128+
min_seconds: int
129+
max_seconds: int
130+
recommended_timeout: int # max_seconds * 1.2, gives a 20% buffer
131+
per_type_counts: dict[str, int] = Field(default_factory=dict)
132+
133+
96134
class CostTracker:
97135
"""
98136
Thread-safe tracker for LLM token usage and cost.
@@ -261,3 +299,60 @@ def estimate_cost(files: list[Path], config: Any) -> CostEstimate:
261299
critics_count=num_critics,
262300
is_approximate=True,
263301
)
302+
303+
304+
def _classify_file_type(path: Path) -> str:
305+
"""Classify a file path into a time-estimation category."""
306+
suffix = path.suffix.lower()
307+
if suffix in {".py", ".pyx", ".pyi"}:
308+
return "python"
309+
if suffix in {".md", ".rst", ".txt"}:
310+
return "docs"
311+
if suffix in {".yaml", ".yml", ".json", ".toml"}:
312+
return "config"
313+
return "generic"
314+
315+
316+
def time_estimate(files: list[Path], depth: str) -> TimeEstimate:
317+
"""
318+
Estimate wall-clock time for a Quorum validation run.
319+
320+
Uses calibration data from production runs:
321+
- quick: ~10-15 sec/file (pre-screen only, no LLM)
322+
- standard: ~45-60 sec/file
323+
- thorough: ~85-240 sec/file depending on file type
324+
325+
Args:
326+
files: List of file paths to validate.
327+
depth: Depth profile — "quick", "standard", or "thorough".
328+
329+
Returns:
330+
TimeEstimate with mid/min/max seconds and recommended --timeout.
331+
"""
332+
depth_lower = depth.lower()
333+
per_type: dict[str, int] = {}
334+
total_min = total_mid = total_max = 0
335+
336+
for f in files:
337+
ftype = _classify_file_type(f)
338+
per_type[ftype] = per_type.get(ftype, 0) + 1
339+
340+
if depth_lower == "thorough":
341+
mid = THOROUGH_SECONDS_PER_FILE[ftype]
342+
mn, mx = _THOROUGH_RANGE[ftype]
343+
else:
344+
mn, mid, mx = _DEPTH_SECONDS.get(depth_lower, _DEPTH_SECONDS["standard"])
345+
346+
total_min += mn
347+
total_mid += mid
348+
total_max += mx
349+
350+
return TimeEstimate(
351+
depth=depth_lower,
352+
files_count=len(files),
353+
estimated_seconds=total_mid,
354+
min_seconds=total_min,
355+
max_seconds=total_max,
356+
recommended_timeout=int(total_max * 1.2),
357+
per_type_counts=per_type,
358+
)

0 commit comments

Comments
 (0)