import threading
import time
import pytest
csp_solver = pytest.importorskip("csp_solver")
def _build_pigeonhole(n: int, m: int):
csp = csp_solver.Csp()
variables = [csp.add_variable(list(range(m))) for _ in range(n)]
csp.add_all_different(variables)
csp.finalize()
return csp
def test_heartbeat_duty_cycle_during_solve():
tick_interval = 0.005
def _run_ticker(stop: threading.Event, counter: list[int]) -> None:
while not stop.is_set():
counter[0] += 1
time.sleep(tick_interval)
baseline_stop = threading.Event()
baseline_counter = [0]
baseline_thread = threading.Thread(
target=_run_ticker, args=(baseline_stop, baseline_counter), daemon=True
)
t0 = time.perf_counter()
baseline_thread.start()
time.sleep(0.3)
baseline_stop.set()
baseline_thread.join(timeout=5)
baseline_elapsed = time.perf_counter() - t0
baseline_rate = baseline_counter[0] / baseline_elapsed
csp = _build_pigeonhole(8, 7)
config = csp_solver.SolveConfig(
pruning=csp_solver.Pruning.NONE,
ordering=csp_solver.Ordering.CHRONOLOGICAL,
max_solutions=1,
node_budget=5_000_000,
)
solve_stop = threading.Event()
solve_counter = [0]
solve_thread = threading.Thread(
target=_run_ticker, args=(solve_stop, solve_counter), daemon=True
)
t1 = time.perf_counter()
solve_thread.start()
solutions = csp.solve(config)
solve_elapsed = time.perf_counter() - t1
solve_stop.set()
solve_thread.join(timeout=5)
assert solutions == [] assert not csp.stats.budget_exceeded, "should prove UNSAT well under budget"
rate_during_solve = solve_counter[0] / solve_elapsed
duty_cycle = rate_during_solve / baseline_rate
assert duty_cycle > 0.5, (
f"ticker only achieved {duty_cycle:.0%} of its free-running rate "
f"during the solve ({solve_counter[0]} ticks / {solve_elapsed:.3f}s "
f"vs baseline {baseline_rate:.1f} ticks/s) — GIL-release regression?"
)
def test_timeout_via_cancel_token_bounds_wall_time():
csp = _build_pigeonhole(9, 8)
token = csp_solver.CancelToken()
config = csp_solver.SolveConfig(
pruning=csp_solver.Pruning.NONE,
ordering=csp_solver.Ordering.CHRONOLOGICAL,
max_solutions=1,
node_budget=200_000_000, cancel=token,
)
timeout_s = 1.0
def _fire_after(seconds: float) -> None:
time.sleep(seconds)
token.cancel()
timer = threading.Thread(target=_fire_after, args=(timeout_s,), daemon=True)
t0 = time.perf_counter()
timer.start()
solutions = csp.solve(config)
elapsed = time.perf_counter() - t0
assert csp.stats.cancelled, "CancelToken fired but stats.cancelled is False"
assert not csp.stats.budget_exceeded, "should be cancelled, not budget-exhausted"
assert solutions == []
assert timeout_s <= elapsed <= timeout_s + 0.5, (
f"expected completion within [{timeout_s}, {timeout_s + 0.5}]s of the "
f"fired timeout, took {elapsed:.3f}s"
)
def test_cancel_token_aborts_long_solve_promptly():
csp = _build_pigeonhole(9, 8)
token = csp_solver.CancelToken()
config = csp_solver.SolveConfig(
pruning=csp_solver.Pruning.NONE,
ordering=csp_solver.Ordering.CHRONOLOGICAL,
max_solutions=1,
node_budget=200_000_000,
cancel=token,
)
cancel_after_s = 0.15
def _cancel_after(seconds: float) -> None:
time.sleep(seconds)
token.cancel()
canceller = threading.Thread(target=_cancel_after, args=(cancel_after_s,), daemon=True)
t0 = time.perf_counter()
canceller.start()
solutions = csp.solve(config)
elapsed = time.perf_counter() - t0
assert csp.stats.cancelled
assert solutions == []
assert elapsed < cancel_after_s + 1.0, (
f"expected prompt abort shortly after the {cancel_after_s}s cancel "
f"fired, took {elapsed:.3f}s"
)
assert token.is_cancelled
def test_unsatisfiable_error_end_to_end():
csp = csp_solver.Csp()
x = csp.add_variable([0])
y = csp.add_variable([0])
csp.add_not_equal(x, y)
csp.finalize()
with pytest.raises(csp_solver.UnsatisfiableError) as excinfo:
csp.propagate()
message = str(excinfo.value).lower()
assert "unsatisfiable" in message or "no solution" in message
def test_invalid_input_error_end_to_end():
with pytest.raises(csp_solver.InvalidInputError) as excinfo:
csp_solver.create_sudoku_csp(N=3, values={"9999": 5})
assert "out of range" in str(excinfo.value)
with pytest.raises(csp_solver.InvalidInputError) as excinfo:
csp_solver.create_sudoku_csp(N=3, values={"not-a-number": 5})
assert "invalid position" in str(excinfo.value)
def test_all_four_typed_exception_classes_exist_and_are_exceptions():
for name in (
"UnsatisfiableError",
"BudgetExceededError",
"InvalidInputError",
"CspTimeoutError",
):
cls = getattr(csp_solver, name)
assert issubclass(cls, Exception)