Skip to content

Commit 92f02d7

Browse files
authored
Fix solver resolution precedence to preserve original config source priority (#539)
1 parent 275cbb4 commit 92f02d7

File tree

4 files changed

+102
-58
lines changed

4 files changed

+102
-58
lines changed

src/halmos/__main__.py

Lines changed: 10 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import logging
77
import os
88
import re
9+
import shlex
910
import shutil
1011
import signal
1112
import subprocess
@@ -109,7 +110,6 @@
109110
solve_end_to_end,
110111
solve_low_level,
111112
)
112-
from halmos.solvers import get_solver_command
113113
from halmos.traces import (
114114
render_trace,
115115
rendered_address,
@@ -187,7 +187,7 @@ def with_devdoc(args: HalmosConfig, fn_sig: str, contract_json: dict) -> HalmosC
187187
if not devdoc:
188188
return args
189189

190-
overrides = arg_parser().parse_args(devdoc.split())
190+
overrides = arg_parser().parse_args(shlex.split(devdoc))
191191
source = ConfigSource.function_annotation
192192
return args.with_overrides(source, **vars(overrides))
193193

@@ -202,35 +202,11 @@ def with_natspec(
202202
if not parsed:
203203
return args
204204

205-
overrides = arg_parser().parse_args(parsed.split())
205+
overrides = arg_parser().parse_args(shlex.split(parsed))
206206
source = ConfigSource.contract_annotation
207207
return args.with_overrides(source, **vars(overrides))
208208

209209

210-
def with_resolved_solver(args: HalmosConfig) -> HalmosConfig:
211-
solver, solver_source = args.value_with_source("solver")
212-
solver_command, solver_command_source = args.value_with_source("solver_command")
213-
214-
# `--solver-command` overrides `--solver` if it is at least as high precedence
215-
if solver_command and solver_command_source >= solver_source:
216-
if solver_command_source == solver_source:
217-
warn(
218-
f"--solver-command and --solver are both provided at the same "
219-
f"precedence level ({solver_source.name}), "
220-
f"--solver-command will be used and --solver will be ignored",
221-
allow_duplicate=False,
222-
)
223-
return args
224-
225-
# if no `--solver-command` is provided, or `--solver` has higher precedence,
226-
# we need to resolve `--solver <name>` to an actual command
227-
command = get_solver_command(solver)
228-
if not command:
229-
raise RuntimeError(f"Solver '{solver}' could not be found or installed.")
230-
source = ConfigSource.dynamic_resolution
231-
return args.with_overrides(source, solver_command=command)
232-
233-
234210
def load_config(_args) -> HalmosConfig:
235211
config = default_config()
236212

@@ -641,8 +617,7 @@ def _compute_frontier(ctx: ContractContext, depth: int) -> Iterator[Exec]:
641617

642618
visited = ctx.visited
643619

644-
args = with_resolved_solver(ctx.args)
645-
620+
args = ctx.args
646621
panic_error_codes = args.panic_error_codes
647622
flamegraph_enabled = args.flamegraph
648623

@@ -1523,10 +1498,9 @@ def run_contract(ctx: ContractContext) -> list[TestResult]:
15231498

15241499
try:
15251500
setup_config = with_devdoc(args, setup_info.sig, ctx.contract_json)
1526-
setup_config = with_resolved_solver(setup_config)
15271501

15281502
if setup_config.debug_config:
1529-
debug(f"{setup_config.formatted_layers()}")
1503+
debug(f"config for setUp():\n{setup_config.formatted_layers()}")
15301504

15311505
setup_solver = mk_solver(setup_config)
15321506
setup_ctx = FunctionContext(
@@ -1634,11 +1608,8 @@ def run_tests(
16341608
try:
16351609
test_config = with_devdoc(args, funsig, ctx.contract_json)
16361610

1637-
# resolve the solver command at the function level
1638-
test_config = with_resolved_solver(test_config)
1639-
16401611
if debug_config:
1641-
debug(f"{test_config.formatted_layers()}")
1612+
debug(f"config for {funsig}:\n{test_config.formatted_layers()}")
16421613

16431614
max_call_depth = (
16441615
test_config.invariant_depth if funsig.startswith("invariant_") else 0
@@ -1733,10 +1704,10 @@ def _main(_args=None) -> MainResult:
17331704
f" and {call_flamegraph.out_filepath}"
17341705
)
17351706
else:
1736-
error("flamegraph.pl not found in PATH")
1737-
error("(see https://github.com/brendangregg/FlameGraph)")
1738-
dynamic_resolution = ConfigSource.dynamic_resolution
1739-
args = args.with_overrides(dynamic_resolution, flamegraph=False)
1707+
error(
1708+
"flamegraph.pl not found in PATH (see https://github.com/brendangregg/FlameGraph)"
1709+
)
1710+
return MainResult(1)
17401711

17411712
#
17421713
# compile

src/halmos/config.py

Lines changed: 43 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,20 @@
33
import argparse
44
import os
55
import re
6+
import shlex
67
import sys
78
from collections import OrderedDict
89
from collections.abc import Callable, Generator
910
from dataclasses import MISSING, dataclass, fields
1011
from dataclasses import field as dataclass_field
1112
from enum import Enum, IntEnum
13+
from functools import cached_property
1214
from typing import Any
1315

1416
import toml
1517

1618
from halmos.logs import warn
17-
from halmos.solvers import SOLVERS
19+
from halmos.solvers import SOLVERS, get_solver_command
1820
from halmos.utils import parse_time
1921

2022
# common strings
@@ -46,17 +48,14 @@ class ConfigSource(IntEnum):
4648
# e.g. halmos.toml
4749
config_file = 2
4850

51+
# from command line
52+
command_line = 3
53+
4954
# contract-level annotation (e.g. @custom:halmos --some-option)
50-
contract_annotation = 3
55+
contract_annotation = 4
5156

5257
# function-level annotation (e.g. @custom:halmos --some-option)
53-
function_annotation = 4
54-
55-
# from command line
56-
command_line = 5
57-
58-
# dynamic resolution (e.g. after solver resolution)
59-
dynamic_resolution = 6
58+
function_annotation = 5
6059

6160

6261
# helper to define config fields
@@ -561,9 +560,11 @@ class Config:
561560
group=solver_group,
562561
)
563562

563+
# this is the low-level option that overrides the `--solver` option
564+
# use `resolved_solver_command` to get the actual command as a list of strings
564565
solver_command: str = arg(
565566
help="use the given exact command when invoking the solver (overrides automatic solver detection/download triggered by --solver)",
566-
global_default=None,
567+
global_default="",
567568
metavar="COMMAND",
568569
group=solver_group,
569570
)
@@ -723,6 +724,38 @@ def formatted_layers(self) -> str:
723724
lines.append(f" {field}: {value}")
724725
return "\n".join(lines)
725726

727+
@cached_property
728+
def resolved_solver_command(self) -> list[str]:
729+
"""
730+
Dynamically resolve the solver command from the `--solver` and `--solver-command` options.
731+
"""
732+
733+
solver, solver_source = self.value_with_source("solver")
734+
solver_command, solver_command_source = self.value_with_source("solver_command")
735+
736+
# `--solver-command` overrides `--solver` if it is at least as high precedence
737+
if (
738+
solver_command
739+
and solver_command_source
740+
and solver_command_source >= solver_source
741+
):
742+
if solver_command_source == solver_source:
743+
warn(
744+
f"--solver-command and --solver are both provided at the same "
745+
f"precedence level ({solver_source.name}), "
746+
f"--solver-command will be used and --solver will be ignored",
747+
allow_duplicate=False,
748+
)
749+
return shlex.split(solver_command)
750+
751+
# if no `--solver-command` is provided, or `--solver` has higher precedence,
752+
# we need to resolve `--solver <name>` to an actual command
753+
command = get_solver_command(solver)
754+
if not command:
755+
raise RuntimeError(f"Solver '{solver}' could not be found or installed.")
756+
757+
return command
758+
726759

727760
def resolve_config_files(args: list[str], include_missing: bool = False) -> list[str]:
728761
config_parser = argparse.ArgumentParser()

src/halmos/solve.py

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# SPDX-License-Identifier: AGPL-3.0
22

33
import re
4+
import shlex
45
import subprocess
56
from concurrent.futures import ThreadPoolExecutor
67
from dataclasses import dataclass, field
@@ -485,21 +486,15 @@ def solve_low_level(path_ctx: PathContext) -> SolverOutput:
485486
# make sure the smt2 file has been written
486487
dump(path_ctx)
487488

489+
solver_command = args.resolved_solver_command + [smt2_filename]
488490
if args.verbose >= 1:
489491
print(" Checking with external solver process")
490-
print(f" {args.solver_command} {smt2_filename} > {smt2_filename}.out")
492+
print(f" {shlex.join(solver_command)} > {smt2_filename}.out")
491493

492494
# solver_timeout_assertion == 0 means no timeout,
493495
# which translates to timeout_seconds=None for subprocess.run
494496
timeout_seconds = t if (t := args.solver_timeout_assertion) else None
495-
496-
cmd_list = (
497-
args.solver_command.split()
498-
if isinstance(args.solver_command, str)
499-
else args.solver_command
500-
)
501-
cmd_with_file = cmd_list + [smt2_filename]
502-
future = PopenFuture(cmd_with_file, timeout=timeout_seconds)
497+
future = PopenFuture(solver_command, timeout=timeout_seconds)
503498

504499
# starts the subprocess asynchronously
505500
path_ctx.solving_ctx.executor.submit(future)

tests/test_config.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,3 +340,48 @@ def test_value_with_source(config):
340340

341341
# overrides have higher precedence than defaults
342342
assert source > ConfigSource.default
343+
344+
345+
def test_solver_resolution_precedence(config):
346+
# default config
347+
assert config.solver == "yices"
348+
assert config.solver_command == ""
349+
350+
# the resolved solver command is derived from the default solver
351+
assert "yices" in " ".join(config.resolved_solver_command)
352+
353+
#########################################################
354+
# command line overrides
355+
#########################################################
356+
357+
cli_config = config.with_overrides(ConfigSource.command_line, solver="cvc5")
358+
assert cli_config.solver == "cvc5"
359+
360+
# the solver command is inherited from the default config
361+
assert cli_config.solver_command == config.solver_command
362+
363+
# but the actual command is derived from the solver option (at a higher precedence)
364+
assert "cvc5" in " ".join(cli_config.resolved_solver_command)
365+
366+
#########################################################
367+
# contract annotation overrides
368+
#########################################################
369+
370+
contract_config = cli_config.with_overrides(
371+
ConfigSource.contract_annotation,
372+
solver_command="path/to/bitwuzla --produce-models",
373+
)
374+
# the solver option is inherited from the command line config
375+
assert contract_config.value_with_source("solver") == (
376+
"cvc5",
377+
ConfigSource.command_line,
378+
)
379+
380+
# the solver command comes from the contract annotation
381+
assert contract_config.solver_command == "path/to/bitwuzla --produce-models"
382+
383+
# the resolved solver command is derived from the contract annotation
384+
assert contract_config.resolved_solver_command == [
385+
"path/to/bitwuzla",
386+
"--produce-models",
387+
]

0 commit comments

Comments
 (0)