├── .gitignore ├── CHANGELOG.md ├── INSTALL.md ├── LICENSE ├── README.md ├── prompts ├── maxsat │ ├── instructions.md │ └── review.md ├── mzn │ ├── instructions.md │ └── review.md ├── pysat │ ├── instructions.md │ └── review.md └── z3 │ ├── instructions.md │ └── review.md ├── pyproject.toml ├── src └── mcp_solver │ ├── client │ ├── __init__.py │ ├── client.py │ ├── llm_factory.py │ ├── mcp_tool_adapter.py │ ├── test_setup.py │ ├── token_callback.py │ ├── token_counter.py │ ├── tool_capability_detector.py │ └── tool_stats.py │ ├── core │ ├── __init__.py │ ├── __main__.py │ ├── base_manager.py │ ├── base_model_manager.py │ ├── constants.py │ ├── instructions.md │ ├── prompt_loader.py │ ├── server.py │ ├── test_setup.py │ └── validation.py │ ├── maxsat │ ├── __init__.py │ ├── constraints.py │ ├── environment.py │ ├── error_handling.py │ ├── model_manager.py │ ├── solution.py │ ├── templates │ │ ├── __init__.py │ │ └── cardinality_constraints.py │ └── test_setup.py │ ├── mzn │ ├── __init__.py │ ├── model_manager.py │ └── test_setup.py │ ├── pysat │ ├── __init__.py │ ├── constraints.py │ ├── environment.py │ ├── error_handling.py │ ├── model_manager.py │ ├── solution.py │ ├── templates │ │ ├── __init__.py │ │ ├── basic_templates.py │ │ ├── cardinality_templates.py │ │ ├── mapping.py │ │ └── pysat_constraints_examples.md │ ├── test.py │ └── test_setup.py │ ├── solution.py │ └── z3 │ ├── __init__.py │ ├── environment.py │ ├── model_manager.py │ ├── solution.py │ ├── templates │ ├── __init__.py │ ├── function_templates.py │ ├── subset_templates.py │ └── z3_templates.py │ └── test_setup.py └── tests ├── README.md ├── __init__.py ├── problems ├── maxsat │ ├── equipment_purchase.md │ ├── network_monitoring.md │ ├── package_selection.md │ ├── task_assignment.md │ ├── test.md │ └── workshop_scheduling_unsat.md ├── mzn │ ├── carpet_cutting.md │ ├── test.md │ ├── tsp.md │ ├── university_scheduling.md │ ├── university_scheduling_unsat.md │ └── zebra.md ├── pysat │ ├── equitable_coloring_hajos.md │ ├── furniture_arrangement.md │ ├── no_three_in_line_5x5.md │ ├── petersen_12_coloring_unsat.md │ ├── sudoku_16x16.md │ └── test.md └── z3 │ ├── array_property_verifier.md │ ├── bounded_sum_unsat.md │ ├── cryptarithmetic.md │ ├── processor_verification.md │ ├── sos_induction.md │ └── test.md ├── run_test.py └── test_config.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Python 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | *.so 6 | .Python 7 | build/ 8 | develop-eggs/ 9 | dist/ 10 | downloads/ 11 | eggs/ 12 | .eggs/ 13 | lib/ 14 | lib64/ 15 | parts/ 16 | sdist/ 17 | var/ 18 | wheels/ 19 | *.egg-info/ 20 | .installed.cfg 21 | *.egg 22 | MANIFEST 23 | 24 | # Virtual Environments 25 | .env 26 | .venv 27 | env/ 28 | venv/ 29 | ENV/ 30 | env.bak/ 31 | venv.bak/ 32 | .uv/ 33 | 34 | # Logs and databases 35 | *.log 36 | *.sqlite3 37 | *.sqlite3-journal 38 | 39 | # IDE specific files 40 | .idea/ 41 | .vscode/ 42 | *.swp 43 | *.swo 44 | .cursor/ 45 | 46 | # OS specific files 47 | .DS_Store 48 | .DS_Store? 49 | ._* 50 | .Spotlight-V100 51 | .Trashes 52 | ehthumbs.db 53 | Thumbs.db 54 | 55 | # Project specific files 56 | mcp_solver.log 57 | uv.lock 58 | mcp-solver.txt 59 | tests/results/ 60 | CLAUDE.md 61 | CLAUDE-TEST.md 62 | docs/ 63 | tmp/ 64 | .mcp.json 65 | LM_STUDIO_IMPLEMENTATION.md 66 | local/ 67 | tester-report.md 68 | testers_report.md 69 | fix-analysis-script.patch 70 | 71 | # Distribution / packaging 72 | dist/ 73 | *.egg-info/ 74 | 75 | # Unit test / coverage reports 76 | htmlcov/ 77 | .tox/ 78 | .nox/ 79 | .coverage 80 | .coverage.* 81 | .cache 82 | nosetests.xml 83 | coverage.xml 84 | *.cover 85 | *.py,cover 86 | .hypothesis/ 87 | .pytest_cache/ 88 | cover/ docs/ 89 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ### [3.3.0] - 2025-06-10 4 | 5 | - **New Feature:** Added MaxSAT as a 4th mode for weighted optimization problems using RC2 solver 6 | - **Improvement:** Implemented exact token counting with callback handlers for better usage tracking 7 | - **Update:** Replaced Black with Ruff for code formatting and linting 8 | - **Improvement:** Enhanced MaxSAT and Z3 instructions with clearer guidance and examples 9 | - **Fix:** Resolved error handling issues in PySAT/MaxSAT to preserve UNSAT status correctly 10 | - **Update:** Added MaxSAT test infrastructure and aligned test problems with experiment6 11 | - **Improvement:** Added cardinality constraint templates and helper functions for MaxSAT 12 | 13 | ### [3.2.0] - 2025-05-11 14 | 15 | - **Improvement:** Simplified model specification by removing internal model code shortcuts (MC1, MC2, etc.) 16 | - **Improvement:** Updated client to use direct model codes (AT:claude-3-7-sonnet-20250219, etc.) for better clarity 17 | - **Update:** Enhanced documentation for model code formats in README.md and INSTALL.md 18 | - **Update:** Added support for LM Studio local models 19 | 20 | 21 | --- 22 | 23 | ### [3.1.0] - 2025-04-03 24 | 25 | - **Improvement:** Enhanced test client with additional features for better usability and testing workflows. 26 | - **Improvement:** Improved solution export for Z3 mode, providing better data representation and accessibility. 27 | - **Update:** Enhanced installation documentation with more detailed setup notes and requirements 28 | 29 | ### [3.0.0] - 2025-03-28 30 | 31 | - **Major Change:** Added PySAT mode and Z3 mode, expanding the supported constraint programming paradigms. 32 | - **Major Change:** Added a standalone test client for easier testing and demonstration. 33 | - **Major Change:** Lite mode is now the default mode. The additional tools have been removed from the default configuration. 34 | - **Update:** The server now advertises only a reduced set of tools by default (clear_model, add_item, replace_item, delete_item, and solve_model). 35 | 36 | ### [2.3.0] - 2025-02-28 37 | 38 | - **New Feature:** Introduced Lite Mode for the MCP Solver. When run with the `--lite` flag, the server advertises only a reduced set of tools (clear_model, add_item, replace_item, delete_item, and solve_model). 39 | - **New Feature:** In Lite Mode, the `solve_model` tool returns only the status (and the solution if SAT) without additional metadata. 40 | - **New Feature:** Mode-specific instruction prompts are used: `instructions_prompt_mzn.md` for MiniZinc, `instructions_prompt_pysat.md` for PySAT, and `instructions_prompt_z3.md` for Z3. 41 | 42 | ### [2.2.0] - 2025-02-15 43 | 44 | - **New Feature:** Integrated static prompt endpoints (`prompts/list` and `prompts/get`) to advertise MCP prompt templates ("quick_prompt" and "detailed_prompt") without requiring any arguments. 45 | - **New Feature:** Advertised detailed tool capabilities by adding descriptive metadata for each tool in the server's capabilities declaration. 46 | - **Improvement:** Enhanced error reporting for tool endpoints with improved logging and standardized error responses. 47 | - **Update:** Refactored server initialization to explicitly log the declared capabilities for greater transparency and easier debugging. 48 | 49 | ### [2.1.0] - 2025-02-09 50 | 51 | - **Update:** Change minimum Python requirement to 3.11+ (to support `asyncio.timeout`). 52 | - **Update:** Bump dependency on `mcp` to version 1.2.0 or later. 53 | - **Improvement:** Update tool handler messages so that "delete_item" and "replace_item" commands correctly report the operation performed. 54 | - **Update:** Miscellaneous documentation and cleanup. 55 | 56 | ### [2.0.0] - 2024-12-29 57 | 58 | - Major change: Use item-based editing. 59 | 60 | ### [1.0.0] - 2024-12-21 61 | 62 | - Major change: Use line-based model editing. 63 | - Makes parameter handling obsolete. 64 | - Added dynamic knowledge base handling. 65 | 66 | ### [0.2.1] - 2024-12-16 67 | 68 | - Changed parameter handling. 69 | 70 | ### [0.2.0] - 2024-12-15 71 | 72 | - Initial release. -------------------------------------------------------------------------------- /INSTALL.md: -------------------------------------------------------------------------------- 1 | # MCP Solver Setup Guide 2 | 3 | Supports Windows, macOS and Linux. 4 | 5 | Tested with: 6 | 7 | - macOS Sequoia 15.3.2 8 | - Windows 11 Pro Version 10.0.26100 Build 26100 9 | - Ubuntu 24.04.2 LTS 10 | 11 | Other versions may also work, as long as the required tools (Python 3.11+, pipx, uv, MiniZinc, etc.) are available or can be installed. 12 | 13 | --- 14 | 15 | ## Step 1: Install Python 3.13+ 16 | 17 | ### macOS (via Homebrew) 18 | 19 | ```bash 20 | brew install python 21 | ``` 22 | 23 | Verify: 24 | 25 | ```bash 26 | python3 --version 27 | ``` 28 | 29 | --- 30 | 31 | ### Windows (via Microsoft Store) 32 | 33 | ```powershell 34 | winget install --id Python.Python.3.13 35 | ``` 36 | 37 | Verify: 38 | 39 | ```powershell 40 | python --version 41 | pip --version 42 | ``` 43 | 44 | --- 45 | 46 | ### Linux (Ubuntu) 47 | 48 | Ubuntu 24.04 ships with Python 3.12.3 which is fully compatible with MCP Solver. You can use the system-provided version without installing Python manually. 49 | 50 | Verify: 51 | 52 | ```bash 53 | python3 --version 54 | ``` 55 | 56 | --- 57 | 58 | ## Step 2: Install `pipx` and `uv` 59 | 60 | ### macOS 61 | 62 | ```bash 63 | brew install pipx 64 | pipx ensurepath 65 | pipx install uv 66 | ``` 67 | 68 | --- 69 | 70 | ### Windows (PowerShell) 71 | 72 | ```powershell 73 | python -m pip install --user pipx 74 | python -m pipx ensurepath 75 | pipx install uv 76 | ``` 77 | 78 | If needed, add to PATH: 79 | 80 | ``` 81 | %USERPROFILE%\AppData\Roaming\Python\Python313\Scripts 82 | ``` 83 | 84 | --- 85 | 86 | ### Linux 87 | 88 | On Debian and Ubuntu, `pipx` is available as a system package and can be installed via APT. However, the `uv` tool is not part of the standard repositories and should be installed via `pipx`. 89 | 90 | ```bash 91 | sudo apt install pipx 92 | pipx ensurepath 93 | pipx install uv 94 | ``` 95 | 96 | `uv` will be installed locally to `~/.local/bin`. Make sure this directory is included in your `PATH`. 97 | 98 | Restart your shell if needed and ensure `uv` is in your PATH. 99 | 100 | --- 101 | 102 | ## Step 3: Set up the MCP Solver project 103 | 104 | ### Clone project 105 | 106 | ```bash 107 | mkdir -p ~/projects/mcp-solver 108 | cd ~/projects/mcp-solver 109 | git clone --branch z3 https://github.com/szeider/mcp-solver.git . 110 | ``` 111 | 112 | ### Create and activate virtual environment 113 | 114 | #### macOS / Linux 115 | 116 | ```bash 117 | python3 -m venv .venv 118 | source .venv/bin/activate 119 | ``` 120 | 121 | #### Windows (PowerShell) 122 | 123 | ```powershell 124 | Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned 125 | python -m venv .venv 126 | .\.venv\Scripts\Activate.ps1 127 | ``` 128 | 129 | ### Install dependencies 130 | 131 | ```bash 132 | uv pip install -e ."[all]" 133 | ``` 134 | 135 | This installs the MCP Solver in editable mode. 136 | 137 | ### Install additional solvers 138 | 139 | ```bash 140 | uv pip install "z3-solver>=4.12.1" 141 | uv pip install python-sat 142 | ``` 143 | 144 | --- 145 | 146 | ## Step 4: Install MiniZinc 147 | 148 | ### macOS 149 | 150 | - Download from: [https://www.minizinc.org/software.html](https://www.minizinc.org/software.html) 151 | - Install `.dmg`, move app to Applications 152 | - Add to `.env`: 153 | 154 | ```env 155 | PATH=/Applications/MiniZincIDE.app/Contents/Resources:$PATH 156 | ``` 157 | 158 | ### Windows 159 | 160 | - Install to: `C:\Program Files\MiniZinc` 161 | - Add to PATH via system environment variables 162 | 163 | ### Linux 164 | 165 | - Download from: [https://www.minizinc.org/software.html](https://www.minizinc.org/software.html) 166 | - Extract and move binaries to `/opt/minizinc`, or use a package if available 167 | - Add to `.env`: 168 | 169 | ```env 170 | PATH=/opt/minizinc:$PATH 171 | ``` 172 | 173 | Ensure `minizinc --version` works in the shell. 174 | 175 | ## Step 5: Run verification tests 176 | 177 | Run the following tests to verify your MCP Solver setup: 178 | 179 | ```bash 180 | uv run test-setup-mzn 181 | uv run test-setup-z3 182 | uv run test-setup-pysat 183 | uv run test-setup-maxsat 184 | uv run test-setup-client 185 | ``` 186 | 187 | ## Step 6: Test Client Setup 188 | 189 | The client requires an **API key** from an LLM provider, unless you run a local model. 190 | 191 | ### Anthropic API Key (Default) 192 | 193 | By default, the client uses Anthropic Claude. Make sure you have an [**Anthropic API key**](https://www.anthropic.com/api). The preferred method is to add it to a `.env` file in the project root: 194 | 195 | ```env 196 | ANTHROPIC_API_KEY=sk-... 197 | ``` 198 | 199 | Alternatively, you can export it as an environment variable: 200 | 201 | #### macOS / Linux 202 | 203 | ```bash 204 | export ANTHROPIC_API_KEY=sk-... 205 | ``` 206 | 207 | #### Windows (PowerShell) 208 | 209 | ```powershell 210 | $env:ANTHROPIC_API_KEY = "sk-..." 211 | ``` 212 | 213 | ### Using Other LLM Providers 214 | 215 | The client supports multiple LLM providers through the `--mc` model code flag. The syntax follows this pattern: 216 | 217 | ``` 218 | XY:model # For cloud providers 219 | LM:model@url # For local models via LM Studio (basic) 220 | LM:model(param=value)@url # For local models with parameters 221 | ``` 222 | 223 | You can also use parameters to configure local models: 224 | 225 | ``` 226 | LM:model(format=json)@url # Request JSON output 227 | LM:model(temp=0.7)@url # Set temperature to 0.7 228 | LM:model(format=json,temp=0.7,max_tokens=1000)@url # Multiple parameters 229 | ``` 230 | 231 | Where `XY` is a two-letter code representing the platform: 232 | - `OA`: OpenAI 233 | - `AT`: Anthropic 234 | - `OR`: OpenRouter 235 | - `GO`: Google (Gemini) 236 | - `LM`: LM Studio (local models) 237 | 238 | Examples: 239 | ``` 240 | OA:gpt-4.1-2025-04-14 241 | AT:claude-3-7-sonnet-20250219 242 | OR:google/gemini-2.5-pro-preview 243 | ``` 244 | 245 | For providers other than Anthropic, you'll need to add the corresponding API key to your `.env` file: 246 | 247 | ```env 248 | OPENAI_API_KEY=sk-... 249 | GOOGLE_API_KEY=... 250 | OPENROUTER_API_KEY=sk-... 251 | # No API key needed for LM Studio 252 | ``` 253 | 254 | Test the client setup: 255 | 256 | ```bash 257 | uv run test-setup-client 258 | ``` 259 | 260 | You can now run a problem description 261 | 262 | ```bash 263 | # MiniZinc mode 264 | uv run test-client --query .md 265 | 266 | # PySAT mode 267 | uv run test-client-pysat --query .md 268 | 269 | # MaxSAT mode 270 | uv run test-client-maxsat --query .md 271 | 272 | # Z3 mode 273 | uv run test-client-z3 --query .md 274 | ``` 275 | 276 | 277 | 278 | Some problem descriptions are provided in `tests/problems` 279 | 280 | --- 281 | 282 | ## Step 7: Claude Desktop Setup 283 | 284 | ### macOS 285 | 286 | Download from: [https://claude.ai/download](https://claude.ai/download) 287 | 288 | ### Windows 289 | 290 | Download from: [https://claude.ai/download](https://claude.ai/download) 291 | 292 | ### Linux (unofficial workaround) 293 | 294 | You can use the community wrapper from [aaddrick/claude-desktop-debian](https://github.com/aaddrick/claude-desktop-debian): 295 | 296 | ```bash 297 | git clone https://github.com/aaddrick/claude-desktop-debian.git 298 | cd claude-desktop-debian 299 | sudo ./build-deb.sh 300 | sudo dpkg -i /path/to/claude-desktop_0.9.0_amd64.deb 301 | ``` 302 | 303 | Start Claude Desktop. Note that the splash screen may misleadingly show 'Claude for Windows' even on Linux. For best results, set Google Chrome as your default browser before launching. Alternatively, you can log in using your email address and a token. Login may require a few attempts regardless of method. 304 | 305 | ```bash 306 | xdg-settings set default-web-browser google-chrome.desktop # optionally 307 | /usr/bin/claude-desktop --no-sandbox 308 | ``` 309 | 310 | --- 311 | 312 | ### Configure `claude_desktop_config.json` 313 | 314 | In the examples below, replace "mcp-solver-mzn" with "mcp-solver-pysat", "mcp-solver-maxsat", or "mcp-solver-z3" depending on the mode you want to run the MCP Solver in. 315 | 316 | #### macOS 317 | 318 | The config file is located at `~/Library/Application\ Support/Claude/claude_desktop_config.json` 319 | 320 | ```json 321 | { 322 | "mcpServers": { 323 | "MCP Solver": { 324 | "command": "uv", 325 | "args": [ 326 | "--directory", 327 | "/Users/stefanszeider/git/mcp-solver", 328 | "run", 329 | "mcp-solver-mzn" 330 | ] 331 | } 332 | } 333 | } 334 | ``` 335 | 336 | #### Windows (example path) 337 | 338 | The config file is located at `%APPDATA%\Claude\claude_desktop_config.json` 339 | 340 | ```json 341 | { 342 | "mcpServers": { 343 | "MCP Solver": { 344 | "command": "cmd.exe", 345 | "args": [ 346 | "/C", 347 | "cd C:\\Users\\AC Admin\\build\\mcp-solver && uv run mcp-solver-mzn" 348 | ] 349 | } 350 | } 351 | } 352 | ``` 353 | 354 | #### Linux (example path) 355 | 356 | The config file is located at `~/.config/Claude/claude_desktop_config.json` 357 | 358 | ```json 359 | { 360 | "mcpServers": { 361 | "MCP Solver": { 362 | "command": "/bin/bash", 363 | "args": [ 364 | "-c", 365 | "cd /path/to/mcp-solver && uv run mcp-solver-mzn" 366 | ] 367 | } 368 | } 369 | } 370 | ``` 371 | 372 | --- 373 | 374 | ### Usage: 375 | 376 | - We strongly recommend using the [Claude Pro](https://claude.ai/) subscription to run the Claude 3.7 Sonnet. 377 | - When you start Claude Desktop (version > 0.8.0), you should see a *Plus Icon* and to the right of it a *Settings Slicer Icon*. 378 | - When you click the Plus Icon, you should see "Add from MCP Solver", follow this, and add the instructions prompt to your conversations. 379 | - When you click the Settings Slider Icon, you can access all the tools of the MCP Solver, and enable/disable them individually; we recommend having all enabled. 380 | - Now you are ready to type your query to the MCP solver. -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Stefan Szeider 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /prompts/maxsat/review.md: -------------------------------------------------------------------------------- 1 | # MaxSAT Solution Review Template 2 | 3 | Your task is to review a MaxSAT solution for basic correctness and proper formatting. 4 | 5 | ## Problem Statement 6 | 7 | ${PROBLEM} 8 | 9 | ## Model Implementation 10 | 11 | ${MODEL} 12 | 13 | ## Solution Results 14 | 15 | ${SOLUTION} 16 | 17 | ## Review Guidelines 18 | 19 | ⚠️ **IMPORTANT**: Do NOT try to verify that constraint encodings are correct. However, DO verify that the solution satisfies the hard constraints stated in the problem. 20 | 21 | Focus on these checks: 22 | 23 | 1. **Structural Correctness** 24 | - Is WCNF (not CNF) used for the MaxSAT problem? 25 | - Are hard constraints added WITHOUT weights? (just `wcnf.append([literals])`) 26 | - Are soft constraints added WITH weights? (`wcnf.append([literals], weight=value)`) 27 | - Is the RC2 solver used and called correctly? 28 | - Is export_solution() called with results? 29 | 30 | 2. **Hard Constraint Satisfaction** 31 | - Based on the solution values, check if obvious hard constraints are satisfied 32 | - For example: If problem says "at most 2 items per slot", verify no slot has 3+ items 33 | - For example: If problem says "A requires B", verify that if A is selected, B is also selected 34 | - Focus on constraints you can easily verify from the problem statement 35 | - If you're unsure about complex constraint encodings, skip them 36 | 37 | 3. **Solution Format & Completeness** 38 | - Does the solution include `"satisfiable": True/False`? 39 | - If satisfiable, does it include the cost from solver.cost? 40 | - Are the required outputs from the problem statement included? 41 | - Do the variable counts match the problem (e.g., if 5 items mentioned, are 5 items in solution)? 42 | - Does the solution format match what the problem asks for? 43 | 44 | 4. **For Unsatisfiable Solutions** 45 | - **IMPORTANT**: You do NOT need to explain WHY the problem is unsatisfiable 46 | - Simply verify that all hard constraints in the model are grounded in the problem description 47 | - Check that each hard constraint represents a requirement from the problem statement 48 | - Note that "unsatisfiable" is a perfectly valid result - if all constraints are justified by the problem, mark it as correct 49 | - Trust the solver's determination of unsatisfiability 50 | 51 | 5. **Basic Sanity Checks** 52 | - Are positive integer variables used (1, 2, 3...)? 53 | - Is the cost a non-negative number for satisfiable solutions? 54 | - Are the reported values consistent (e.g., if cost=10, which soft constraints were violated?) 55 | 56 | 6. **Optimality Verification** 57 | - If solver reports "optimal" with cost > 0, verify if this makes sense: 58 | - Does the solution satisfy all explicit requirements in the problem? 59 | - For "select at least k items" problems, check if k items were actually selected 60 | - Note: "Solution optimal with cost X" doesn't mean perfect - it means best possible given the encoding 61 | 62 | 7. **DO NOT Check** 63 | - ❌ Whether the constraint encoding logic is correct (e.g., don't verify if `[-a, -b, c]` correctly encodes "if a and b then c") 64 | - ❌ Whether the solution is optimal (trust the solver) 65 | - ❌ Whether there could be a "better" solution 66 | - ❌ Complex Boolean formula transformations 67 | - ❌ Whether soft constraint polarities match your interpretation 68 | 69 | ## Final Verdict 70 | 71 | Based on your review, provide a final verdict with one of the following: 72 | 73 | correct - If the solution follows proper MaxSAT structure and formats results correctly 74 | incorrect - If there are structural issues (wrong solver, missing weights, bad format, etc.) 75 | unknown - If you cannot determine correctness based on the information provided 76 | 77 | **Remember**: 78 | - If the solver found a solution (satisfiable=True), the constraint logic is almost certainly correct 79 | - Focus on structure and format, not mathematical correctness 80 | - A solution can be "correct" even if you don't understand the constraint encodings -------------------------------------------------------------------------------- /prompts/mzn/instructions.md: -------------------------------------------------------------------------------- 1 | # MCP Solver – Quick Start Guide 2 | 3 | Welcome to the MCP Solver. This document provides concise guidelines on how to interact with the MCP Solver. You have access to the essential tools for building and solving MiniZinc models. 4 | 5 | ## Overview 6 | 7 | The MCP Solver integrates MiniZinc constraint solving with the Model Context Protocol, allowing you to create, modify, and solve constraint models. The following tools are available: 8 | 9 | - **clear_model** 10 | - **add_item** 11 | - **replace_item** 12 | - **delete_item** 13 | - **solve_model** 14 | 15 | These tools let you construct your model incrementally and solve it using the Chuffed solver. 16 | 17 | ## MiniZinc Items and Model Structure 18 | 19 | - **MiniZinc Item:** 20 | A MiniZinc item is a complete statement (e.g., a variable declaration or constraint) that typically ends with a semicolon. Inline comments are considered part of the same item. 21 | 22 | - **No Output Statements:** 23 | Do not include output formatting in your model. The solver handles only declarations and constraints. 24 | 25 | - **Truncated Model Display:** 26 | The model may be returned in a truncated form for brevity. This is solely for display purposes to keep item indices consistent. 27 | 28 | - **Indices Start at 0:** 29 | Items are added one by one, starting with index 0 (i.e., index=0, index=1, etc.). 30 | 31 | ## List Semantics for Model Operations 32 | 33 | The model items behave like a standard programming list with these exact semantics: 34 | 35 | - **add_item(index, content)**: Inserts the item at the specified position, shifting all items at that index and after to the right. 36 | - Example: If model has items [A, B, C] and you call add_item(1, X), result is [A, X, B, C] 37 | - Valid index range: 0 to length (inclusive) where length is the current number of items 38 | 39 | - **delete_item(index)**: Removes the item at the specified index, shifting all subsequent items to the left. 40 | - Example: If model has items [A, B, C, D] and you call delete_item(1), result is [A, C, D] 41 | - Valid index range: 0 to length-1 (inclusive) 42 | 43 | - **replace_item(index, content)**: Replaces the item at the specified index in-place. No shifting occurs. 44 | - Example: If model has items [A, B, C] and you call replace_item(1, X), result is [A, X, C] 45 | - Valid index range: 0 to length-1 (inclusive) 46 | 47 | **Important**: All indices are 0-based. The first item is at index 0, the second at index 1, etc. 48 | 49 | ## Tool Input and Output Details 50 | 51 | 1. **clear_model** 52 | - **Input:** No arguments. 53 | - **Output:** A confirmation message indicating that the model has been cleared. 54 | 55 | 2. **add_item** 56 | - **Input:** 57 | - `index` (integer): The position at which to insert the new MiniZinc statement. 58 | - `content` (string): The complete MiniZinc statement to add. 59 | - **Output:** 60 | - A confirmation message that the item was added, along with the current (truncated) model. 61 | 62 | 3. **replace_item** 63 | - **Input:** 64 | - `index` (integer): The index of the item to replace. 65 | - `content` (string): The new MiniZinc statement that will replace the existing one. 66 | - **Output:** 67 | - A confirmation message that the item was replaced, along with the updated (truncated) model. 68 | 69 | 4. **delete_item** 70 | - **Input:** 71 | - `index` (integer): The index of the item to delete. 72 | - **Output:** 73 | - A confirmation message that the item was deleted, along with the updated (truncated) model. 74 | 75 | 5. **solve_model** 76 | - **Input:** 77 | - `timeout` (number): Time in seconds allowed for solving (between 1 and 30 seconds). 78 | - **Output:** 79 | - A JSON object with: 80 | - **status:** `"SAT"`, `"UNSAT"`, or `"TIMEOUT"`. 81 | - **solution:** (If applicable) The solution object when the model is satisfiable. 82 | In unsatisfiable or timeout cases, only the status is returned. 83 | 84 | ## Model Solving and Verification 85 | 86 | - **Solution Verification:** 87 | After solving, verify that the returned solution satisfies all specified constraints. If the model is satisfiable (`SAT`), you will receive both the status and the solution; otherwise, only the status is provided. 88 | 89 | ## Model Modification Guidelines 90 | 91 | - **Comments**: 92 | A comment is not an item by itself. Always combine a comment with the constraint or declaration it belongs to. 93 | 94 | - **Combining similar parts:** 95 | 96 | If you have a long list of similar parts like constant definitions, you can put them into the same item. 97 | 98 | - **Incremental Changes:** 99 | Use `add_item`, `replace_item`, and `delete_item` to modify your model incrementally. This allows you to maintain consistency in item numbering without needing to clear the entire model. 100 | 101 | - **Making Small Changes:** 102 | When a user requests a small change to the model (like changing a parameter value or modifying a constraint), use `replace_item` to update just the relevant item rather than rebuilding the entire model. This maintains the model structure and is more efficient. 103 | 104 | - **When to Clear the Model:** 105 | Use `clear_model` only when extensive changes are required and starting over is necessary. 106 | 107 | ## Important: Model Item Indexing 108 | 109 | MiniZinc mode uses **0-based indexing** for all model operations: 110 | - First item is at index 0 111 | - Used with add_item, replace_item, delete_item 112 | - Example: `add_item(0, "int: n = 8;")` adds at the beginning 113 | - Example: `replace_item(2, "constraint: alldifferent(queens);")` replaces the third item 114 | 115 | Note: This is different from MiniZinc arrays which typically use 1-based indexing by default. 116 | 117 | ## Final Notes 118 | 119 | - **Review Return Information:** 120 | Carefully review the confirmation messages and the current model after each tool call. 121 | 122 | - **Consistent Structure:** 123 | Remember that comments on a MiniZinc statement (on the same line) are considered part of that item, ensuring any context or annotations remain with the statement. 124 | 125 | - **Verification:** 126 | Always verify the solution after a solve operation by checking that all constraints are met. 127 | 128 | Happy modeling with MCP Solver! 129 | -------------------------------------------------------------------------------- /prompts/mzn/review.md: -------------------------------------------------------------------------------- 1 | # Solution Correctness Verification 2 | 3 | ## Task 4 | 5 | You are given a problem description, a MiniZinc model, and a solution. Verify the correctness of the solution. 6 | 7 | ## Verification Process 8 | 9 | Follow these steps carefully: 10 | 11 | 1. Create a structured table of the solution values: 12 | - For each decision variable, record its value 13 | - For array variables, list each index and its corresponding value 14 | - Present this table at the beginning of your explanation 15 | 16 | 2. For each constraint: 17 | - State the constraint precisely 18 | - Extract the relevant variable values from your table 19 | - Show your evaluation process step-by-step 20 | - Conclude whether the constraint is satisfied or violated 21 | 22 | 3. If you identify a violation: 23 | - Double-check by re-extracting the exact values from the solution 24 | - Explicitly show how these values violate the constraint 25 | - Include the relevant indices and their values to avoid indexing errors 26 | 27 | 4. Before finalizing your verdict: 28 | - Re-verify any reported violations 29 | - Ensure you haven't misread or misinterpreted array indices or values 30 | 31 | ## Evaluation Criteria 32 | 33 | - **For satisfiable solutions**: Verify that all constraints in the problem description are satisfied. Answer *correct* if satisfied, otherwise *incorrect*. You do not need to verify optimality, only check if the solution satisfies all hard constraints. 34 | 35 | Present your constraint verification in a structured format: 36 | - Constraint: [State the constraint] 37 | - Values: [List relevant variable values] 38 | - Evaluation: [Show calculation or reasoning] 39 | - Result: [Satisfied/Violated] 40 | 41 | - **For unsatisfiable solutions**: Verify that all constraints in the MiniZinc model are actually required by the problem statement or are valid symmetry breaking constraints. Answer *correct* if valid, otherwise *incorrect*. 42 | 43 | Check constraint by constraint using the same structured format. 44 | 45 | **IMPORTANT**: You do NOT need to explain WHY the instance is unsatisfiable. Trust the solver's determination. Your task is only to verify that each constraint in the model is grounded in the problem description. 46 | 47 | Note that "unsatisfiable" is a perfectly fine result. So if all constraints added to the model are valid representations of the problem requirements, then your verdict should be *correct*. 48 | 49 | - **For no solution/timeout/unverifiable cases**: Answer *unknown*. 50 | 51 | ## Output Format 52 | 53 | After your detailed analysis, provide your verdict using simple XML tags. 54 | 55 | IMPORTANT: Your answer MUST follow this structure: 56 | 1. First provide a detailed explanation of your reasoning 57 | 2. Analyze each constraint in detail 58 | 3. End with a clear conclusion statement: "The solution is correct." or "The solution is incorrect." 59 | 4. Finally, add exactly ONE of these verdict tags on a new line: 60 | correct 61 | incorrect 62 | unknown 63 | 64 | For example: 65 | ``` 66 | [Your detailed analysis here] 67 | 68 | After checking all constraints, I can confirm that each one is satisfied by the provided solution values. 69 | 70 | The solution is correct. 71 | 72 | correct 73 | ``` 74 | 75 | The verdict must be EXACTLY one of: "correct", "incorrect", or "unknown" - nothing else. 76 | 77 | IMPORTANT: Before finalizing your response, always check that: 78 | 1. Your explanation ends with a clear conclusion statement 79 | 2. The verdict tag matches your conclusion exactly 80 | 3. If your explanation concludes "The solution is correct", then use correct 81 | 4. If your explanation concludes "The solution is incorrect", then use incorrect 82 | 5. If you cannot determine correctness or establish incorrectness, use unknown 83 | 84 | ## Data 85 | 86 | ### Problem Statement 87 | 88 | $PROBLEM 89 | 90 | ### MiniZinc Model 91 | 92 | $MODEL 93 | 94 | ### Solution 95 | 96 | $SOLUTION 97 | -------------------------------------------------------------------------------- /prompts/pysat/review.md: -------------------------------------------------------------------------------- 1 | # Solution Correctness Verification 2 | 3 | ## Task 4 | 5 | You are given a problem description, a PySAT encoding, and a solution. Verify the correctness of the solution. 6 | 7 | ## Evaluation Criteria 8 | 9 | - **For satisfiable solutions**: Verify that all constraints in the problem description are satisfied. Answer *correct* if satisfied, otherwise *incorrect*. You do not need to verify optimality, only check if the solution satisfies all hard constraints. 10 | - **For unsatisfiable solutions**: Verify that all clauses produced by the encoding are actually required by the problem statement or are valid symmetry-breaking constraints. Answer *correct* if valid, otherwise *incorrect*. 11 | 12 | **IMPORTANT**: You do NOT need to explain WHY the instance is unsatisfiable. Trust the solver's determination. Your task is only to verify that each constraint in the model is grounded in the problem description. 13 | 14 | Note that "unsatisfiable" is a perfectly fine result. So if all constraints added to the model are valid representations of the problem requirements, then your verdict should be *correct*. 15 | 16 | - **For no solution/timeout/unverifiable cases**: Answer *unknown*. 17 | 18 | ## Output Format 19 | 20 | After your detailed analysis, provide your verdict using simple XML tags. 21 | 22 | IMPORTANT: Your answer MUST follow this structure: 23 | 1. First provide a detailed explanation of your reasoning 24 | 2. Analyze each clause in detail 25 | 3. End with a clear conclusion statement: "The solution is correct." or "The solution is incorrect." 26 | 4. Finally, add exactly ONE of these verdict tags on a new line: 27 | correct 28 | incorrect 29 | unknown 30 | 31 | For example: 32 | ``` 33 | [Your detailed analysis here] 34 | 35 | After checking all clauses, I can confirm that each one is satisfied by the provided solution values. 36 | 37 | The solution is correct. 38 | 39 | correct 40 | ``` 41 | 42 | The verdict must be EXACTLY one of: "correct", "incorrect", or "unknown" - nothing else. 43 | 44 | IMPORTANT: Before finalizing your response, always check that: 45 | 1. Your explanation ends with a clear conclusion statement 46 | 2. The verdict tag matches your conclusion exactly 47 | 3. If your explanation concludes "The solution is correct", then use correct 48 | 4. If your explanation concludes "The solution is incorrect", then use incorrect 49 | 5. If you cannot determine correctness or establish incorrectness, use unknown 50 | 51 | ## Data 52 | 53 | ### Problem Statement 54 | 55 | $PROBLEM 56 | 57 | ### PySAT Encoding 58 | 59 | $MODEL 60 | 61 | ### Solution 62 | 63 | $SOLUTION 64 | 65 | -------------------------------------------------------------------------------- /prompts/z3/review.md: -------------------------------------------------------------------------------- 1 | # Solution Correctness Verification 2 | 3 | ## Task 4 | 5 | You are given a problem description, a Python Z3 encoding, and a solution. Verify the correctness of the solution. 6 | 7 | ## Evaluation Criteria 8 | 9 | - **For satisfiable solutions**: Verify that all constraints in the problem description are satisfied. Answer *correct* if satisfied, otherwise *incorrect*. You do not need to verify optimality, only check if the solution satisfies all hard constraints. 10 | - **For unsatisfiable solutions**: Verify that all clauses produced by the encoding are actually required by the problem statement or are valid symmetry-breaking constraints. Answer *correct* if valid, otherwise *incorrect*. 11 | 12 | **IMPORTANT**: You do NOT need to explain WHY the instance is unsatisfiable. Trust the solver's determination. Your task is only to verify that each constraint in the model is grounded in the problem description. 13 | 14 | Note that "unsatisfiable" is a perfectly fine result. So if all constraints added to the model are valid representations of the problem requirements, then your verdict should be *correct*. 15 | - **For no solution/timeout/unverifiable cases**: Answer *unknown*. 16 | 17 | ## Output Format 18 | 19 | After your detailed analysis, provide your verdict using simple XML tags. 20 | 21 | IMPORTANT: Your answer MUST follow this structure: 22 | 1. First provide a detailed explanation of your reasoning 23 | 2. Analyze each constraint in detail 24 | 3. End with a clear conclusion statement: "The solution is correct." or "The solution is incorrect." 25 | 4. Finally, add exactly ONE of these verdict tags on a new line: 26 | correct 27 | incorrect 28 | unknown 29 | 30 | For example: 31 | ``` 32 | [Your detailed analysis here] 33 | 34 | After checking all constraints, I can confirm that each one is satisfied by the provided solution values. 35 | 36 | The solution is correct. 37 | 38 | correct 39 | ``` 40 | 41 | The verdict must be EXACTLY one of: "correct", "incorrect", or "unknown" - nothing else. 42 | 43 | IMPORTANT: Before finalizing your response, always check that: 44 | 1. Your explanation ends with a clear conclusion statement 45 | 2. The verdict tag matches your conclusion exactly 46 | 3. If your explanation concludes "The solution is correct", then use correct 47 | 4. If your explanation concludes "The solution is incorrect", then use incorrect 48 | 5. If you cannot determine correctness or establish incorrectness, use unknown 49 | 50 | ## Data 51 | 52 | ### Problem Statement 53 | 54 | $PROBLEM 55 | 56 | ### Z3 Code 57 | 58 | $MODEL 59 | 60 | ### Solution 61 | 62 | $SOLUTION 63 | 64 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [tool.hatch.build.targets.wheel] 6 | packages = ["src/mcp_solver", "tests"] 7 | 8 | [project] 9 | name = "mcp-solver" 10 | version = "3.3.0" 11 | description = "MCP server for Constraint, SAT, and SMT solving" 12 | authors = [ 13 | {name = "Stefan Szeider", email = "stefan@szeider.net"}, 14 | ] 15 | requires-python = ">=3.11" 16 | dependencies = [ 17 | "mcp>=1.5.0", 18 | "tomli>=2.2.1", 19 | "six>=1.17.0", 20 | "nest_asyncio>=1.6.0", 21 | ] 22 | 23 | [project.optional-dependencies] 24 | mzn = [ 25 | "minizinc<=0.10.0", 26 | ] 27 | z3 = [ 28 | "z3-solver>=4.14.1.0", 29 | ] 30 | pysat = [ 31 | "python-sat>=1.8.dev16", 32 | ] 33 | client = [ 34 | "langchain>=0.3.21", 35 | "langchain-core>=0.3.49", 36 | "langgraph>=0.3.21", 37 | "langchain-openai>=0.3.11", 38 | "langchain-anthropic>=0.3.10", 39 | "langchain-google-genai>=2.0.0", 40 | "openai>=1.69.0", 41 | "python-dotenv>=1.1.0", 42 | "rich>=13.9.4", 43 | "uuid>=1.30", 44 | ] 45 | all = [ 46 | "mcp-solver[mzn,z3,pysat,client,dev]", 47 | ] 48 | dev = [ 49 | "coverage>=7.7.1", 50 | "pytest>=8.3.5", 51 | "ruff>=0.4.10", 52 | ] 53 | 54 | [project.scripts] 55 | test-setup-mzn = "mcp_solver.mzn.test_setup:main" 56 | test-setup-z3 = "mcp_solver.z3.test_setup:main" 57 | test-setup-pysat = "mcp_solver.pysat.test_setup:main" 58 | test-setup-maxsat = "mcp_solver.maxsat.test_setup:main" 59 | test-setup-client = "mcp_solver.client.test_setup:main" 60 | mcp-solver = "mcp_solver.core.__main__:main" 61 | mcp-solver-mzn = "mcp_solver.core.__main__:main_mzn" 62 | mcp-solver-z3 = "mcp_solver.core.__main__:main_z3" 63 | mcp-solver-pysat = "mcp_solver.core.__main__:main_pysat" 64 | mcp-solver-maxsat = "mcp_solver.core.__main__:main_maxsat" 65 | test-client = "mcp_solver.client.client:main_cli" 66 | run-test = "tests.run_test:main" 67 | 68 | [tool.ruff] 69 | # Usage: 70 | # Format code: uv run ruff format . 71 | # Check code: uv run ruff check . 72 | # Fix issues: uv run ruff check --fix . 73 | 74 | # Python 3.11+ 75 | target-version = "py311" 76 | 77 | # Black-compatible 78 | line-length = 88 79 | 80 | # Auto-fix issues 81 | fix = true 82 | 83 | [tool.ruff.format] 84 | # Black-compatible formatting 85 | quote-style = "double" 86 | indent-style = "space" 87 | docstring-code-format = true 88 | 89 | [tool.ruff.lint] 90 | # Essential rules that catch real issues 91 | select = [ 92 | "E", # pycodestyle errors 93 | "F", # Pyflakes 94 | "I", # isort (import sorting) 95 | "UP", # pyupgrade (Python 3.11+ syntax) 96 | "B", # flake8-bugbear (likely bugs) 97 | "SIM", # flake8-simplify (code simplification) 98 | "RUF", # Ruff-specific rules 99 | "ARG", # flake8-unused-arguments 100 | "LOG", # flake8-logging (logging best practices) 101 | ] 102 | 103 | # Ignore annoying/controversial rules 104 | ignore = [ 105 | "E501", # Line length (formatter handles this) 106 | "B008", # Function calls in defaults 107 | "E402", # Module level import not at top of file (common in test files) 108 | "E722", # Do not use bare 'except' - needed for broad exception handling 109 | "F401", # Imported but unused - template modules have purposeful imports 110 | "F403", # Star imports - used intentionally for re-exports 111 | "F821", # Undefined name - used for type annotations with quotes 112 | "F841", # Local variable assigned but never used - some test setups 113 | "RUF001", # String contains ambiguous character - intentional emoji usage 114 | "RUF012", # Mutable class attributes - existing singleton pattern 115 | "RUF013", # PEP 484 prohibits implicit Optional - backward compatibility 116 | "UP007", # Use X | Y for type annotations - backward compatibility 117 | "UP038", # Use X | Y in isinstance - backward compatibility 118 | "B904", # Raise from err - existing error handling pattern 119 | "B007", # Loop control variable not used - simple iteration patterns 120 | "SIM102", # Use single if statement - readability preference 121 | "SIM105", # Use contextlib.suppress - explicit exception handling preferred 122 | "SIM108", # Use ternary operator - if/else can be clearer 123 | "SIM117", # Use single with statement - readability preference 124 | "SIM118", # Use key in dict instead of dict.keys() - explicit is better 125 | "SIM101", # Multiple isinstance calls - readability for complex checks 126 | "RUF022", # __all__ is not sorted - custom ordering for documentation 127 | "ARG001", # Unused function argument - protocol requirements 128 | "ARG002", # Unused method argument - interface compatibility 129 | "ARG005", # Unused lambda argument - fallback lambdas 130 | "LOG015", # Root logger usage - simple CLI scripts 131 | ] 132 | 133 | [tool.ruff.lint.isort] 134 | # Clean import grouping 135 | combine-as-imports = true 136 | lines-after-imports = 2 137 | 138 | [tool.ruff.lint.per-file-ignores] 139 | # Tests can use assert and have unused imports 140 | "tests/*" = ["S101", "F401", "F841"] 141 | "__init__.py" = ["F401"] 142 | 143 | [tool.mypy] 144 | python_version = "3.11" 145 | warn_return_any = true 146 | warn_unused_configs = true 147 | 148 | [tool.test_client] 149 | recursion_limit = 200 -------------------------------------------------------------------------------- /src/mcp_solver/client/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | MCP Solver Client 3 | 4 | This package offers clients for interacting with MCP solvers. 5 | 6 | Both a standard client and an agent based client are available. 7 | """ 8 | 9 | from mcp_solver.client.client import main_cli 10 | 11 | 12 | # The custom react_agent has been removed, now using built-in LangGraph implementation 13 | 14 | __all__ = [ 15 | "main_cli", 16 | ] 17 | -------------------------------------------------------------------------------- /src/mcp_solver/client/mcp_tool_adapter.py: -------------------------------------------------------------------------------- 1 | """ 2 | MCP Tool Adapter for MCP Solver 3 | 4 | This module provides functionality to convert MCP tools to LangChain tools. 5 | The code is based on langchain_mcp_adapters.tools but has been modified to 6 | ensure compatibility with mcp>=1.5. 7 | """ 8 | 9 | from typing import Any, Union 10 | 11 | from langchain_core.tools import BaseTool, StructuredTool, ToolException 12 | from mcp import ClientSession 13 | from mcp.types import ( 14 | CallToolResult, 15 | EmbeddedResource, 16 | ImageContent, 17 | TextContent, 18 | Tool as MCPTool, 19 | ) 20 | 21 | 22 | NonTextContent = Union[ImageContent, EmbeddedResource] 23 | 24 | 25 | def _convert_call_tool_result( 26 | call_tool_result: CallToolResult, 27 | ) -> tuple[str | list[str], list[NonTextContent] | None]: 28 | """ 29 | Convert a CallToolResult to a format suitable for LangChain tools. 30 | 31 | Args: 32 | call_tool_result: The result from an MCP tool call 33 | 34 | Returns: 35 | A tuple of (text_content, non_text_contents) 36 | """ 37 | text_contents: list[TextContent] = [] 38 | non_text_contents = [] 39 | for content in call_tool_result.content: 40 | if isinstance(content, TextContent): 41 | text_contents.append(content) 42 | else: 43 | non_text_contents.append(content) 44 | 45 | tool_content: str | list[str] = [content.text for content in text_contents] 46 | if len(text_contents) == 1: 47 | tool_content = tool_content[0] 48 | 49 | if call_tool_result.isError: 50 | raise ToolException(tool_content) 51 | 52 | return tool_content, non_text_contents or None 53 | 54 | 55 | def convert_mcp_tool_to_langchain_tool( 56 | session: ClientSession, 57 | tool: MCPTool, 58 | ) -> BaseTool: 59 | """ 60 | Convert an MCP tool to a LangChain tool. 61 | 62 | NOTE: This tool can be executed only in a context of an active MCP client session. 63 | 64 | Args: 65 | session: MCP client session 66 | tool: MCP tool to convert 67 | 68 | Returns: 69 | A LangChain tool 70 | """ 71 | 72 | async def call_tool( 73 | **arguments: Any, 74 | ) -> tuple[str | list[str], list[NonTextContent] | None]: 75 | call_tool_result = await session.call_tool(tool.name, arguments) 76 | return _convert_call_tool_result(call_tool_result) 77 | 78 | return StructuredTool( 79 | name=tool.name, 80 | description=tool.description or "", 81 | args_schema=tool.inputSchema, 82 | coroutine=call_tool, 83 | response_format="content_and_artifact", 84 | ) 85 | 86 | 87 | async def load_mcp_tools(session: ClientSession) -> list[BaseTool]: 88 | """ 89 | Load all available MCP tools and convert them to LangChain tools. 90 | 91 | Args: 92 | session: The MCP client session 93 | 94 | Returns: 95 | A list of LangChain tools 96 | """ 97 | tools_response = await session.list_tools() 98 | return [ 99 | convert_mcp_tool_to_langchain_tool(session, tool) 100 | for tool in tools_response.tools 101 | ] 102 | -------------------------------------------------------------------------------- /src/mcp_solver/client/token_callback.py: -------------------------------------------------------------------------------- 1 | """Token usage callback handler for capturing LLM token counts.""" 2 | 3 | from langchain.callbacks.base import BaseCallbackHandler 4 | from langchain_core.outputs import LLMResult 5 | 6 | 7 | class TokenUsageCallbackHandler(BaseCallbackHandler): 8 | """Callback handler to capture token usage from LLM responses.""" 9 | 10 | def __init__(self, token_counter, agent_type: str = "main"): 11 | """Initialize the callback handler. 12 | 13 | Args: 14 | token_counter: TokenCounter instance to update 15 | agent_type: Either "main" or "reviewer" to track different agents 16 | """ 17 | self.token_counter = token_counter 18 | self.agent_type = agent_type 19 | 20 | def on_llm_end(self, response: LLMResult, **kwargs) -> None: 21 | """Capture token usage when LLM call completes. 22 | 23 | This method captures exact token counts from Anthropic models. 24 | Other providers may also provide token counts in different formats. 25 | For ReAct agents, this accumulates tokens across multiple LLM calls. 26 | """ 27 | if not response.llm_output: 28 | return 29 | 30 | # Anthropic format: response.llm_output["usage"] 31 | if "usage" in response.llm_output: 32 | usage = response.llm_output["usage"] 33 | input_tokens = usage.get("input_tokens", 0) 34 | output_tokens = usage.get("output_tokens", 0) 35 | 36 | if self.agent_type == "main": 37 | # For main agent, accumulate tokens (multiple calls in ReAct) 38 | self.token_counter.main_input_tokens += input_tokens 39 | self.token_counter.main_output_tokens += output_tokens 40 | self.token_counter.main_is_exact = True 41 | else: 42 | # For reviewer, set tokens (single call) 43 | self.token_counter.reviewer_input_tokens = input_tokens 44 | self.token_counter.reviewer_output_tokens = output_tokens 45 | self.token_counter.reviewer_is_exact = True 46 | 47 | # OpenAI format: response.llm_output["token_usage"] 48 | elif "token_usage" in response.llm_output: 49 | usage = response.llm_output["token_usage"] 50 | input_tokens = usage.get("prompt_tokens", 0) 51 | output_tokens = usage.get("completion_tokens", 0) 52 | 53 | if self.agent_type == "main": 54 | # For main agent, accumulate tokens (multiple calls in ReAct) 55 | self.token_counter.main_input_tokens += input_tokens 56 | self.token_counter.main_output_tokens += output_tokens 57 | self.token_counter.main_is_exact = True 58 | else: 59 | # For reviewer, set tokens (single call) 60 | self.token_counter.reviewer_input_tokens = input_tokens 61 | self.token_counter.reviewer_output_tokens = output_tokens 62 | self.token_counter.reviewer_is_exact = True 63 | -------------------------------------------------------------------------------- /src/mcp_solver/client/token_counter.py: -------------------------------------------------------------------------------- 1 | """ 2 | Token counter for tracking token usage in LangGraph applications. 3 | 4 | This module provides a class for tracking token usage during a client run, 5 | separately for the main agent and reviewer agent. 6 | """ 7 | 8 | from typing import Any 9 | 10 | from langchain_core.messages.utils import count_tokens_approximately 11 | from rich.console import Console 12 | from rich.table import Table 13 | 14 | 15 | class TokenCounter: 16 | """Token counter for tracking token usage in LangGraph applications.""" 17 | 18 | _instance = None 19 | 20 | @classmethod 21 | def get_instance(cls): 22 | """Get singleton instance.""" 23 | if cls._instance is None: 24 | cls._instance = TokenCounter() 25 | return cls._instance 26 | 27 | def __init__(self): 28 | """Initialize token counter.""" 29 | self.reset() 30 | self.console = Console() 31 | 32 | def reset(self): 33 | """Reset all token counts.""" 34 | self.main_input_tokens = 0 35 | self.main_output_tokens = 0 36 | self.reviewer_input_tokens = 0 37 | self.reviewer_output_tokens = 0 38 | self.main_is_exact = False 39 | self.reviewer_is_exact = False 40 | 41 | def count_main_input(self, messages): 42 | """Count input tokens for main agent (only if not exact).""" 43 | if not self.main_is_exact: 44 | self.main_input_tokens = count_tokens_approximately(messages) 45 | 46 | def count_main_output(self, messages): 47 | """Count output tokens for main agent (only if not exact).""" 48 | if not self.main_is_exact: 49 | self.main_output_tokens = count_tokens_approximately(messages) 50 | 51 | def count_reviewer_input(self, messages): 52 | """Count input tokens for reviewer (only if not exact).""" 53 | if not self.reviewer_is_exact: 54 | self.reviewer_input_tokens = count_tokens_approximately(messages) 55 | 56 | def count_reviewer_output(self, messages): 57 | """Count output tokens for reviewer (only if not exact).""" 58 | if not self.reviewer_is_exact: 59 | self.reviewer_output_tokens = count_tokens_approximately(messages) 60 | 61 | @property 62 | def total_main_tokens(self): 63 | """Get total tokens for main agent.""" 64 | return self.main_input_tokens + self.main_output_tokens 65 | 66 | @property 67 | def total_reviewer_tokens(self): 68 | """Get total tokens for reviewer.""" 69 | return self.reviewer_input_tokens + self.reviewer_output_tokens 70 | 71 | @property 72 | def total_tokens(self): 73 | """Get total tokens across all agents.""" 74 | return self.total_main_tokens + self.total_reviewer_tokens 75 | 76 | def format_token_count(self, count: int) -> str: 77 | """Format token count with k/M suffixes.""" 78 | if count >= 1_000_000: 79 | return f"{count / 1_000_000:.1f}M" 80 | elif count >= 1_000: 81 | return f"{count / 1_000:.1f}k" 82 | return str(count) 83 | 84 | def get_stats_table(self): 85 | """Get token usage statistics as a Rich table.""" 86 | table = Table(title="Token Usage Statistics") 87 | table.add_column("Agent", style="cyan", no_wrap=True) 88 | table.add_column("Input", justify="right", style="green") 89 | table.add_column("Output", justify="right", style="blue") 90 | table.add_column("Total", justify="right", style="magenta") 91 | table.add_column("Type", justify="center", style="yellow") 92 | 93 | # Main agent row 94 | table.add_row( 95 | "ReAct Agent", 96 | self.format_token_count(self.main_input_tokens), 97 | self.format_token_count(self.main_output_tokens), 98 | self.format_token_count(self.total_main_tokens), 99 | "Exact" if self.main_is_exact else "Approx", 100 | ) 101 | 102 | # Reviewer row 103 | table.add_row( 104 | "Reviewer", 105 | self.format_token_count(self.reviewer_input_tokens), 106 | self.format_token_count(self.reviewer_output_tokens), 107 | self.format_token_count(self.total_reviewer_tokens), 108 | "Exact" if self.reviewer_is_exact else "Approx", 109 | ) 110 | 111 | # Combined row 112 | table.add_row( 113 | "COMBINED", 114 | self.format_token_count( 115 | self.main_input_tokens + self.reviewer_input_tokens 116 | ), 117 | self.format_token_count( 118 | self.main_output_tokens + self.reviewer_output_tokens 119 | ), 120 | self.format_token_count(self.total_tokens), 121 | "Mixed" 122 | if (self.main_is_exact or self.reviewer_is_exact) 123 | and not (self.main_is_exact and self.reviewer_is_exact) 124 | else ("Exact" if self.main_is_exact else "Approx"), 125 | style="bold", 126 | ) 127 | 128 | return table 129 | 130 | def print_stats(self): 131 | """Print token usage statistics.""" 132 | table = self.get_stats_table() 133 | self.console.print("\n") 134 | self.console.print(table) 135 | 136 | def display_token_usage(self): 137 | """Display token usage in a table.""" 138 | table = self.get_stats_table() 139 | self.console.print("\n") 140 | self.console.print(table) 141 | -------------------------------------------------------------------------------- /src/mcp_solver/client/tool_stats.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tool Usage Statistics Tracking Module. 3 | 4 | This module provides a class for tracking tool usage statistics during a client run. 5 | """ 6 | 7 | from collections import Counter 8 | 9 | from rich.console import Console 10 | from rich.table import Table 11 | 12 | 13 | class ToolStats: 14 | """Class for tracking tool usage statistics.""" 15 | 16 | _instance = None 17 | 18 | @classmethod 19 | def get_instance(cls): 20 | """Get the singleton instance of ToolStats.""" 21 | if cls._instance is None: 22 | cls._instance = ToolStats() 23 | return cls._instance 24 | 25 | def __init__(self): 26 | """Initialize the tool statistics tracker.""" 27 | self.tool_calls = Counter() 28 | self.total_calls = 0 29 | self.console = Console() 30 | self.enabled = True 31 | 32 | def record_tool_call(self, tool_name: str): 33 | """Record a tool call.""" 34 | if not self.enabled: 35 | return 36 | 37 | self.tool_calls[tool_name] += 1 38 | self.total_calls += 1 39 | 40 | def print_stats(self): 41 | """Print the tool usage statistics.""" 42 | if not self.enabled: 43 | return 44 | 45 | if self.total_calls == 0: 46 | self.console.print("[yellow]No tools were called during this run.[/yellow]") 47 | return 48 | 49 | table = Table(title="Tool Usage Statistics") 50 | table.add_column("Tool Name", style="cyan") 51 | table.add_column("Call Count", style="green") 52 | table.add_column("Percentage", style="magenta") 53 | 54 | # Sort tools by number of calls (descending) 55 | sorted_tools = sorted(self.tool_calls.items(), key=lambda x: x[1], reverse=True) 56 | 57 | for tool_name, count in sorted_tools: 58 | percentage = f"{(count / self.total_calls) * 100:.1f}%" 59 | table.add_row(tool_name, str(count), percentage) 60 | 61 | # Add a total row 62 | table.add_row("TOTAL", str(self.total_calls), "100.0%", style="bold") 63 | 64 | self.console.print("\n") 65 | self.console.print(table) 66 | -------------------------------------------------------------------------------- /src/mcp_solver/core/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | MCP Solver - A constraint solving server for MCP. 3 | """ 4 | 5 | # Define the version 6 | __version__ = "2.3.0" 7 | 8 | # Import core components 9 | from .base_manager import SolverManager 10 | from .base_model_manager import BaseModelManager 11 | 12 | # Import server functionality 13 | from .server import main, serve 14 | 15 | 16 | __all__ = ["SolverManager", "BaseModelManager", "__version__", "main", "serve"] 17 | -------------------------------------------------------------------------------- /src/mcp_solver/core/__main__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Main entry point for running the MCP Solver package. 3 | """ 4 | 5 | import logging 6 | import sys 7 | 8 | 9 | def main(): 10 | """Default entry point for MiniZinc mode""" 11 | from .server import main as server_main 12 | 13 | return server_main() 14 | 15 | 16 | def main_mzn(): 17 | """Entry point for MiniZinc mode (alias of main for consistency)""" 18 | return main() 19 | 20 | 21 | def main_z3(): 22 | """Entry point for Z3 mode""" 23 | try: 24 | import z3 25 | except ImportError: 26 | print("Z3 dependencies not installed. Please install with:") 27 | print(" uv pip install -e '.[z3]'") 28 | return 1 29 | 30 | from .server import main as server_main 31 | 32 | # Set command line arguments for Z3 mode 33 | sys.argv = [sys.argv[0], "--z3"] 34 | return server_main() 35 | 36 | 37 | def main_pysat(): 38 | """Entry point for PySAT mode""" 39 | try: 40 | import pysat 41 | except ImportError: 42 | print("PySAT dependencies not installed. Please install with:") 43 | print(" uv pip install -e '.[pysat]'") 44 | return 1 45 | 46 | from .server import main as server_main 47 | 48 | # Set command line arguments for PySAT mode 49 | sys.argv = [sys.argv[0], "--pysat"] 50 | return server_main() 51 | 52 | 53 | def main_maxsat(): 54 | """Entry point for MaxSAT optimization mode""" 55 | from .server import main as server_main 56 | 57 | # Set command line arguments for MaxSAT mode 58 | sys.argv = [sys.argv[0], "--maxsat"] 59 | return server_main() 60 | 61 | 62 | if __name__ == "__main__": 63 | try: 64 | sys.exit(main()) 65 | except Exception as e: 66 | logging.error(f"Error in main: {e}") 67 | sys.exit(1) 68 | -------------------------------------------------------------------------------- /src/mcp_solver/core/base_manager.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from datetime import timedelta 3 | from typing import Any 4 | 5 | 6 | class SolverManager(ABC): 7 | """ 8 | Abstract base class for solver managers. 9 | This class defines the interface that all solver implementations must follow. 10 | """ 11 | 12 | def __init__(self): 13 | """ 14 | Initialize a new solver manager. 15 | """ 16 | self.initialized = False 17 | self.last_solve_time = None 18 | 19 | @abstractmethod 20 | async def clear_model(self) -> dict[str, Any]: 21 | """ 22 | Clear the current model. 23 | 24 | Returns: 25 | A dictionary with a message indicating the model was cleared 26 | """ 27 | pass 28 | 29 | @abstractmethod 30 | async def add_item(self, index: int, content: str) -> dict[str, Any]: 31 | """ 32 | Add an item to the model at the specified index. 33 | 34 | Args: 35 | index: The index at which to add the item 36 | content: The content to add 37 | 38 | Returns: 39 | A dictionary with the result of the operation 40 | """ 41 | pass 42 | 43 | @abstractmethod 44 | async def delete_item(self, index: int) -> dict[str, Any]: 45 | """ 46 | Delete an item from the model at the specified index. 47 | 48 | Args: 49 | index: The index of the item to delete 50 | 51 | Returns: 52 | A dictionary with the result of the operation 53 | """ 54 | pass 55 | 56 | @abstractmethod 57 | async def replace_item(self, index: int, content: str) -> dict[str, Any]: 58 | """ 59 | Replace an item in the model at the specified index. 60 | 61 | Args: 62 | index: The index of the item to replace 63 | content: The new content 64 | 65 | Returns: 66 | A dictionary with the result of the operation 67 | """ 68 | pass 69 | 70 | @abstractmethod 71 | async def solve_model(self, timeout: timedelta) -> dict[str, Any]: 72 | """ 73 | Solve the current model. 74 | 75 | Args: 76 | timeout: Timeout for the solve operation 77 | 78 | Returns: 79 | A dictionary with the result of the solve operation 80 | """ 81 | pass 82 | 83 | @abstractmethod 84 | def get_solution(self) -> dict[str, Any]: 85 | """ 86 | Get the current solution. 87 | 88 | Returns: 89 | A dictionary with the current solution 90 | """ 91 | pass 92 | 93 | @abstractmethod 94 | def get_variable_value(self, variable_name: str) -> dict[str, Any]: 95 | """ 96 | Get the value of a variable from the current solution. 97 | 98 | Args: 99 | variable_name: The name of the variable 100 | 101 | Returns: 102 | A dictionary with the value of the variable 103 | """ 104 | pass 105 | 106 | @abstractmethod 107 | def get_solve_time(self) -> dict[str, Any]: 108 | """ 109 | Get the time taken for the last solve operation. 110 | 111 | Returns: 112 | A dictionary with the solve time information 113 | """ 114 | pass 115 | -------------------------------------------------------------------------------- /src/mcp_solver/core/base_model_manager.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod 2 | from datetime import timedelta 3 | from typing import Any 4 | 5 | from .base_manager import SolverManager 6 | 7 | 8 | class BaseModelManager(SolverManager): 9 | """Base implementation with common model management functionality.""" 10 | 11 | def __init__(self): 12 | super().__init__() 13 | self.code_items: list[str] = [] # Changed to simple list 14 | self.last_result = None 15 | self.last_solution = None 16 | self.last_solve_time = None 17 | 18 | async def clear_model(self) -> dict[str, Any]: 19 | """Clear the current model.""" 20 | self.code_items = [] 21 | self.last_result = None 22 | self.last_solution = None 23 | return {"success": True, "message": "Model cleared"} 24 | 25 | async def add_item(self, index: int, content: str) -> dict[str, Any]: 26 | """Add item with standard list semantics (0-based indexing).""" 27 | # Validate index (0 to len) 28 | if index < 0 or index > len(self.code_items): 29 | return { 30 | "success": False, 31 | "error": f"Invalid index {index}. Valid range: 0 to {len(self.code_items)}", 32 | } 33 | 34 | # Insert at position 35 | self.code_items.insert(index, content) 36 | return {"success": True, "message": f"Added item at index {index}"} 37 | 38 | async def delete_item(self, index: int) -> dict[str, Any]: 39 | """Delete item with standard list semantics (0-based indexing).""" 40 | if index < 0 or index >= len(self.code_items): 41 | return { 42 | "success": False, 43 | "error": f"Invalid index {index}. Valid range: 0 to {len(self.code_items) - 1}", 44 | } 45 | 46 | del self.code_items[index] 47 | return {"success": True, "message": f"Deleted item at index {index}"} 48 | 49 | async def replace_item(self, index: int, content: str) -> dict[str, Any]: 50 | """Replace item (no shifting) with 0-based indexing.""" 51 | if index < 0 or index >= len(self.code_items): 52 | return { 53 | "success": False, 54 | "error": f"Invalid index {index}. Valid range: 0 to {len(self.code_items) - 1}", 55 | } 56 | 57 | self.code_items[index] = content 58 | return {"success": True, "message": f"Replaced item at index {index}"} 59 | 60 | def get_model(self) -> list[tuple[int, str]]: 61 | """Get current model with 0-based indices.""" 62 | items = [] 63 | for i, content in enumerate(self.code_items): 64 | items.append((i, content)) 65 | return items 66 | 67 | def _get_full_code(self) -> str: 68 | """Get the full code as a single string.""" 69 | return "\n\n".join(self.code_items) 70 | 71 | def get_solution(self) -> dict[str, Any]: 72 | """Get the current solution.""" 73 | if self.last_solution is None: 74 | return {"error": "No solution available. Please run solve_model first."} 75 | return self.last_solution 76 | 77 | def get_variable_value(self, variable_name: str) -> dict[str, Any]: 78 | """Get the value of a variable from the current solution.""" 79 | if self.last_solution is None: 80 | return {"error": "No solution available. Please run solve_model first."} 81 | 82 | # Check if we have variables in the solution 83 | if "variables" in self.last_solution: 84 | variables = self.last_solution["variables"] 85 | if variable_name in variables: 86 | return {"variable": variable_name, "value": variables[variable_name]} 87 | else: 88 | return {"error": f"Variable '{variable_name}' not found in solution"} 89 | else: 90 | return {"error": "Solution does not contain variable information"} 91 | 92 | def get_solve_time(self) -> dict[str, Any]: 93 | """Get the time taken for the last solve operation.""" 94 | if self.last_solve_time is None: 95 | return {"error": "No solve operation has been performed yet."} 96 | return {"solve_time": self.last_solve_time, "unit": "seconds"} 97 | 98 | @abstractmethod 99 | async def solve_model(self, timeout: timedelta) -> dict[str, Any]: 100 | """Each mode must implement its own solve logic.""" 101 | pass 102 | -------------------------------------------------------------------------------- /src/mcp_solver/core/constants.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | from pathlib import Path 3 | 4 | 5 | try: 6 | import tomllib 7 | except ImportError: 8 | pass 9 | 10 | # Potentially truncate items when sent back to client 11 | ITEM_CHARS = None # or None to show full items 12 | 13 | # Set to True to enable validation on model changes 14 | VALIDATE_ON_CHANGE = True 15 | 16 | # Timeouts (Note: asyncio.timeout requires Python 3.11+) 17 | MIN_SOLVE_TIMEOUT = timedelta(seconds=1) 18 | MAX_SOLVE_TIMEOUT = timedelta(seconds=30) 19 | VALIDATION_TIMEOUT = timedelta(seconds=2) 20 | CLEANUP_TIMEOUT = timedelta(seconds=1) 21 | 22 | # Get the project root directory (where the prompts folder is located) 23 | PROJECT_ROOT = Path(__file__).parent.parent.parent.parent 24 | 25 | # Set the path to the prompts directory 26 | PROMPTS_DIR = PROJECT_ROOT / "prompts" 27 | -------------------------------------------------------------------------------- /src/mcp_solver/core/instructions.md: -------------------------------------------------------------------------------- 1 | # MCP Solver – Quick Start Guide 2 | 3 | Welcome to the MCP Solver. This document provides concise guidelines on how to interact with the MCP Solver. You have access to the essential tools for building and solving MiniZinc models. 4 | 5 | ## Overview 6 | 7 | The MCP Solver integrates MiniZinc constraint solving with the Model Context Protocol, allowing you to create, modify, and solve constraint models. The following tools are available: 8 | 9 | - **clear_model** 10 | - **add_item** 11 | - **replace_item** 12 | - **delete_item** 13 | - **solve_model** 14 | 15 | These tools let you construct your model incrementally and solve it using the Chuffed solver. 16 | 17 | ## MiniZinc Items and Model Structure 18 | 19 | - **MiniZinc Item:** 20 | A MiniZinc item is a complete statement (e.g., a variable declaration or constraint) that typically ends with a semicolon. Inline comments are considered part of the same item. 21 | 22 | - **No Output Statements:** 23 | Do not include output formatting in your model. The solver handles only declarations and constraints. 24 | 25 | - **Truncated Model Display:** 26 | The model may be returned in a truncated form for brevity. This is solely for display purposes to keep item indices consistent. 27 | 28 | - **Indices Start at 0:** 29 | Items are added one by one, starting with index 0 (i.e., index=0, index=1, etc.). 30 | 31 | ## Tool Input and Output Details 32 | 33 | 1. **clear_model** 34 | - **Input:** No arguments. 35 | - **Output:** A confirmation message indicating that the model has been cleared. 36 | 37 | 2. **add_item** 38 | - **Input:** 39 | - `index` (integer): The position at which to insert the new MiniZinc statement. 40 | - `content` (string): The complete MiniZinc statement to add. 41 | - **Output:** 42 | - A confirmation message that the item was added, along with the current (truncated) model. 43 | 44 | 3. **replace_item** 45 | - **Input:** 46 | - `index` (integer): The index of the item to replace. 47 | - `content` (string): The new MiniZinc statement that will replace the existing one. 48 | - **Output:** 49 | - A confirmation message that the item was replaced, along with the updated (truncated) model. 50 | 51 | 4. **delete_item** 52 | - **Input:** 53 | - `index` (integer): The index of the item to delete. 54 | - **Output:** 55 | - A confirmation message that the item was deleted, along with the updated (truncated) model. 56 | 57 | 5. **solve_model** 58 | - **Input:** 59 | - `timeout` (number): Time in seconds allowed for solving (between 1 and 30 seconds). 60 | - **Output:** 61 | - A JSON object with: 62 | - **status:** `"SAT"`, `"UNSAT"`, or `"TIMEOUT"`. 63 | - **solution:** (If applicable) The solution object when the model is satisfiable. 64 | In unsatisfiable or timeout cases, only the status is returned. 65 | 66 | ## Model Solving and Verification 67 | 68 | - **Solution Verification:** 69 | After solving, verify that the returned solution satisfies all specified constraints. If the model is satisfiable (`SAT`), you will receive both the status and the solution; otherwise, only the status is provided. 70 | 71 | ## Model Modification Guidelines 72 | 73 | - **Comments**: 74 | A comment is not an item by itself. Always combine a comment with the constraint or declaration it belongs to. 75 | 76 | - **Combining similar parts:** 77 | 78 | If you have a long list of similar parts like constant definitions, you can put them into the same item. 79 | 80 | - **Incremental Changes:** 81 | Use `add_item`, `replace_item`, and `delete_item` to modify your model incrementally. This allows you to maintain consistency in item numbering without needing to clear the entire model. 82 | 83 | - **Making Small Changes:** 84 | When a user requests a small change to the model (like changing a parameter value or modifying a constraint), use `replace_item` to update just the relevant item rather than rebuilding the entire model. This maintains the model structure and is more efficient. 85 | 86 | - **When to Clear the Model:** 87 | Use `clear_model` only when extensive changes are required and starting over is necessary. 88 | 89 | ## Final Notes 90 | 91 | - **Review Return Information:** 92 | Carefully review the confirmation messages and the current model after each tool call. 93 | 94 | - **Consistent Structure:** 95 | Remember that comments on a MiniZinc statement (on the same line) are considered part of that item, ensuring any context or annotations remain with the statement. 96 | 97 | - **Verification:** 98 | Always verify the solution after a solve operation by checking that all constraints are met. 99 | 100 | Happy modeling with MCP Solver! 101 | -------------------------------------------------------------------------------- /src/mcp_solver/core/prompt_loader.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from pathlib import Path 3 | from typing import Literal 4 | 5 | 6 | # Type definitions for better type checking 7 | PromptMode = Literal["mzn", "pysat", "z3", "maxsat"] 8 | PromptType = Literal["instructions", "review"] 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | def get_prompt_path(mode: PromptMode, prompt_type: PromptType = "instructions") -> Path: 14 | """ 15 | Get the path to a prompt file based on mode and type, without loading its content. 16 | 17 | Args: 18 | mode: The solver mode ("mzn", "pysat", "z3", or "maxsat") 19 | prompt_type: The type of prompt ("instructions" or "review"), defaults to "instructions" 20 | 21 | Returns: 22 | Path object pointing to the prompt file 23 | 24 | Raises: 25 | ValueError: If invalid mode or prompt type is provided 26 | """ 27 | # Validate inputs 28 | if mode not in ("mzn", "pysat", "z3", "maxsat"): 29 | raise ValueError( 30 | f"Invalid mode: {mode}. Must be one of: mzn, pysat, z3, maxsat" 31 | ) 32 | 33 | if prompt_type not in ("instructions", "review"): 34 | raise ValueError( 35 | f"Invalid prompt type: {prompt_type}. Must be one of: instructions, review" 36 | ) 37 | 38 | # Get the base prompts directory path 39 | base_path = Path(__file__).parent.parent.parent.parent / "prompts" 40 | 41 | # Construct the full path to the prompt file 42 | prompt_path = base_path / mode / f"{prompt_type}.md" 43 | 44 | logger.debug(f"Prompt path for {prompt_type} in {mode} mode: {prompt_path}") 45 | return prompt_path 46 | 47 | 48 | def load_prompt(mode: PromptMode, prompt_type: PromptType) -> str: 49 | """ 50 | Load a prompt file based on mode and type. 51 | 52 | Args: 53 | mode: The solver mode ("mzn", "pysat", "z3", or "maxsat") 54 | prompt_type: The type of prompt ("instructions" or "review") 55 | 56 | Returns: 57 | The content of the prompt file as a string 58 | 59 | Raises: 60 | FileNotFoundError: If the prompt file doesn't exist 61 | ValueError: If invalid mode or prompt type is provided 62 | """ 63 | # Get the prompt path 64 | prompt_path = get_prompt_path(mode, prompt_type) 65 | 66 | if not prompt_path.exists(): 67 | raise FileNotFoundError(f"Prompts directory not found at: {prompt_path.parent}") 68 | 69 | logger.debug(f"Loading {prompt_type} prompt for {mode} mode from: {prompt_path}") 70 | 71 | # Read and return the prompt content 72 | try: 73 | content = prompt_path.read_text(encoding="utf-8").strip() 74 | logger.debug(f"Successfully loaded prompt ({len(content)} characters)") 75 | return content 76 | except FileNotFoundError: 77 | raise FileNotFoundError(f"Prompt file not found: {prompt_path}") 78 | except Exception as e: 79 | raise RuntimeError(f"Error reading prompt file {prompt_path}: {e!s}") 80 | -------------------------------------------------------------------------------- /src/mcp_solver/core/test_setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Test script for verifying the core installation of MCP-Solver. 4 | This script checks: 5 | 1. Required configuration files 6 | 2. Core dependencies (MiniZinc with Chuffed solver) 7 | 3. Basic solver functionality 8 | 9 | Note: This script only tests core functionality. 10 | Optional solvers (Z3, PySAT) have their own setup verification. 11 | """ 12 | 13 | import sys 14 | from pathlib import Path 15 | 16 | from minizinc import Instance, Model, Solver 17 | 18 | # Import our centralized prompt loader 19 | from mcp_solver.core.prompt_loader import load_prompt 20 | 21 | 22 | class SetupTest: 23 | def __init__(self): 24 | self.successes: list[tuple[str, str]] = [] # (test_name, details) 25 | self.failures: list[tuple[str, str]] = [] # (test_name, error_details) 26 | self.base_dir = Path(__file__).resolve().parents[3] 27 | self.GREEN = "\033[92m" 28 | self.RED = "\033[91m" 29 | self.RESET = "\033[0m" 30 | self.BOLD = "\033[1m" 31 | 32 | def print_result(self, test_name: str, success: bool, details: str | None = None): 33 | """Print a test result with color and proper formatting.""" 34 | mark = "✓" if success else "✗" 35 | color = self.GREEN if success else self.RED 36 | print(f"{color}{mark} {test_name}{self.RESET}") 37 | if details: 38 | print(f" └─ {details}") 39 | 40 | def record_test(self, test_name: str, success: bool, details: str | None = None): 41 | """Record a test result and print it.""" 42 | if success: 43 | self.successes.append((test_name, details if details else "")) 44 | else: 45 | self.failures.append((test_name, details if details else "Test failed")) 46 | self.print_result(test_name, success, None if success else details) 47 | 48 | def check_file(self, file_name: str) -> bool: 49 | """Check if a file exists in the base directory.""" 50 | file_path = self.base_dir / file_name 51 | return file_path.exists() 52 | 53 | def test_configuration_files(self): 54 | """Test for the presence of required configuration files.""" 55 | print(f"\n{self.BOLD}Configuration Files:{self.RESET}") 56 | 57 | # Test prompt files using the centralized prompt loader 58 | prompts_to_test = [ 59 | ("mzn", "instructions"), 60 | ("mzn", "review"), 61 | ("pysat", "instructions"), 62 | ("pysat", "review"), 63 | ("z3", "instructions"), 64 | ("z3", "review"), 65 | ] 66 | 67 | for mode, prompt_type in prompts_to_test: 68 | try: 69 | # Attempt to load the prompt using the prompt loader 70 | content = load_prompt(mode, prompt_type) 71 | self.record_test( 72 | f"Prompt file: {mode}/{prompt_type}.md", 73 | True, 74 | f"Successfully loaded ({len(content)} characters)", 75 | ) 76 | except FileNotFoundError: 77 | self.record_test( 78 | f"Prompt file: {mode}/{prompt_type}.md", 79 | False, 80 | "Prompt file not found", 81 | ) 82 | except Exception as e: 83 | self.record_test( 84 | f"Prompt file: {mode}/{prompt_type}.md", 85 | False, 86 | f"Error loading prompt: {e!s}", 87 | ) 88 | 89 | # Test other required files 90 | other_files = [("pyproject.toml", True)] 91 | for file, required in other_files: 92 | exists = self.check_file(file) 93 | if required: 94 | self.record_test( 95 | f"Configuration file: {file}", 96 | exists, 97 | ( 98 | None 99 | if exists 100 | else f"Required file not found at {self.base_dir / file}" 101 | ), 102 | ) 103 | 104 | def test_core_dependencies(self): 105 | """Test MiniZinc and Chuffed solver installation.""" 106 | print(f"\n{self.BOLD}Core Dependencies:{self.RESET}") 107 | try: 108 | solver = Solver.lookup("chuffed") 109 | self.record_test( 110 | "MiniZinc Chuffed solver", True, f"Found version {solver.version}" 111 | ) 112 | except Exception as e: 113 | self.record_test( 114 | "MiniZinc Chuffed solver", 115 | False, 116 | f"Chuffed solver not found: {e!s}\nPlease install MiniZinc with Chuffed solver", 117 | ) 118 | return # Skip further tests if solver not found 119 | 120 | try: 121 | from minizinc import Instance 122 | 123 | self.record_test("MiniZinc Python binding", True) 124 | except ImportError as e: 125 | self.record_test( 126 | "MiniZinc Python binding", 127 | False, 128 | f"Error importing minizinc: {e!s}\nPlease install minizinc Python package", 129 | ) 130 | 131 | def test_basic_functionality(self): 132 | """Test basic MiniZinc functionality.""" 133 | print(f"\n{self.BOLD}Basic Functionality:{self.RESET}") 134 | model_code = """ 135 | var 0..1: x; 136 | constraint x = 1; 137 | solve satisfy; 138 | """ 139 | 140 | # Test model creation 141 | try: 142 | model = Model() 143 | model.add_string(model_code) 144 | self.record_test("Model creation", True) 145 | except Exception as e: 146 | self.record_test("Model creation", False, f"Error creating model: {e!s}") 147 | return 148 | 149 | # Test solver execution 150 | try: 151 | solver = Solver.lookup("chuffed") 152 | instance = Instance(solver, model) 153 | result = instance.solve() 154 | self.record_test("Solver execution", True, "Successfully executed solver") 155 | except Exception as e: 156 | self.record_test("Solver execution", False, f"Error during solving: {e!s}") 157 | 158 | def run_all_tests(self): 159 | """Run all setup tests and display results.""" 160 | print(f"{self.BOLD}=== MCP-Solver Core Setup Test ==={self.RESET}") 161 | 162 | self.test_configuration_files() 163 | self.test_core_dependencies() 164 | self.test_basic_functionality() 165 | 166 | print(f"\n{self.BOLD}=== Test Summary ==={self.RESET}") 167 | print(f"Passed: {len(self.successes)}") 168 | print(f"Failed: {len(self.failures)}") 169 | 170 | if self.failures: 171 | print(f"\n{self.BOLD}Failed Tests:{self.RESET}") 172 | for test, details in self.failures: 173 | print(f"\n{self.RED}✗ {test}{self.RESET}") 174 | print(f" └─ {details}") 175 | print( 176 | "\nCore system setup incomplete. Please fix the issues above before proceeding." 177 | ) 178 | sys.exit(1) 179 | else: 180 | print(f"\n{self.GREEN}✓ All core tests passed successfully!{self.RESET}") 181 | print("\nCore system is ready to use MCP-Solver.") 182 | print( 183 | "Note: Optional solvers (Z3, PySAT) require additional setup if needed." 184 | ) 185 | sys.exit(0) 186 | 187 | 188 | def main(): 189 | test = SetupTest() 190 | test.run_all_tests() 191 | 192 | 193 | if __name__ == "__main__": 194 | main() 195 | -------------------------------------------------------------------------------- /src/mcp_solver/maxsat/__init__.py: -------------------------------------------------------------------------------- 1 | """MaxSAT Mode for MCP Solver. 2 | 3 | This module provides optimization capabilities using MaxSAT. 4 | """ 5 | 6 | from .model_manager import MaxSATModelManager 7 | from .solution import export_solution 8 | 9 | 10 | __all__ = [ 11 | "MaxSATModelManager", 12 | "export_solution", 13 | ] 14 | -------------------------------------------------------------------------------- /src/mcp_solver/maxsat/constraints.py: -------------------------------------------------------------------------------- 1 | # Import and re-export the constraints module from PySAT 2 | from mcp_solver.pysat.constraints import * 3 | -------------------------------------------------------------------------------- /src/mcp_solver/maxsat/templates/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | MaxSAT templates package. 3 | 4 | This package provides basic cardinality constraint helpers for MaxSAT problems. 5 | """ 6 | 7 | from .cardinality_constraints import at_least_k, at_most_k, exactly_k 8 | 9 | 10 | __all__ = [ 11 | "at_most_k", 12 | "at_least_k", 13 | "exactly_k", 14 | ] 15 | -------------------------------------------------------------------------------- /src/mcp_solver/maxsat/templates/cardinality_constraints.py: -------------------------------------------------------------------------------- 1 | """ 2 | Cardinality constraint templates for MaxSAT. 3 | 4 | This module provides basic cardinality constraint helpers for MaxSAT formulas. 5 | These are the essential functions needed for most optimization problems. 6 | """ 7 | 8 | import itertools 9 | 10 | from pysat.formula import WCNF 11 | 12 | 13 | def at_most_k(wcnf: WCNF, variables: list[int], k: int) -> None: 14 | """ 15 | Add a hard constraint that at most k variables can be true. 16 | 17 | Args: 18 | wcnf: WCNF formula to modify 19 | variables: List of variable IDs 20 | k: Maximum number of variables that can be true 21 | """ 22 | if k >= len(variables): 23 | return # Trivially satisfied 24 | 25 | if k < 0: 26 | # No variables can be true 27 | for var in variables: 28 | wcnf.append([-var]) 29 | return 30 | 31 | # For each subset of size k+1, at least one must be false 32 | for subset in itertools.combinations(variables, k + 1): 33 | wcnf.append([-var for var in subset]) 34 | 35 | 36 | def at_least_k(wcnf: WCNF, variables: list[int], k: int) -> None: 37 | """ 38 | Add a hard constraint that at least k variables must be true. 39 | 40 | Args: 41 | wcnf: WCNF formula to modify 42 | variables: List of variable IDs 43 | k: Minimum number of variables that must be true 44 | """ 45 | if k <= 0: 46 | return # Trivially satisfied 47 | 48 | if k > len(variables): 49 | # Impossible to satisfy - add an empty clause 50 | wcnf.append([]) 51 | return 52 | 53 | n = len(variables) 54 | # For each combination of (n-k+1) variables, at least one must be true 55 | for subset in itertools.combinations(range(n), n - k + 1): 56 | clause = [variables[i] for i in subset] 57 | wcnf.append(clause) 58 | 59 | 60 | def exactly_k(wcnf: WCNF, variables: list[int], k: int) -> None: 61 | """ 62 | Add hard constraints that exactly k variables must be true. 63 | 64 | This combines at_least_k and at_most_k constraints. 65 | 66 | Args: 67 | wcnf: WCNF formula to modify 68 | variables: List of variable IDs 69 | k: Exact number of variables that must be true 70 | """ 71 | at_least_k(wcnf, variables, k) 72 | at_most_k(wcnf, variables, k) 73 | -------------------------------------------------------------------------------- /src/mcp_solver/maxsat/test_setup.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | 4 | def main(): 5 | """Test the MaxSAT setup.""" 6 | try: 7 | import pysat 8 | from pysat.examples.rc2 import RC2 9 | from pysat.formula import WCNF 10 | 11 | print("MaxSAT dependencies check: OK") 12 | 13 | # Test creating a basic MaxSAT problem and solving it 14 | wcnf = WCNF() 15 | 16 | # Add a hard clause: x1 OR x2 17 | wcnf.append([1, 2]) 18 | 19 | # Add soft clauses with weights 20 | wcnf.append([1], weight=1) # Prefer x1=True (weight 1) 21 | wcnf.append([2], weight=2) # Prefer x2=True (weight 2) 22 | 23 | # Create and use the RC2 solver 24 | with RC2(wcnf) as rc2: 25 | model = rc2.compute() 26 | cost = rc2.cost 27 | 28 | if model: 29 | print(f"MaxSAT solver test: OK (found model with cost {cost})") 30 | print(f"Solution: {model}") 31 | else: 32 | print("MaxSAT solver test: FAILED (no model found)") 33 | return 1 34 | 35 | return 0 36 | 37 | except ImportError as e: 38 | print(f"MaxSAT dependency missing: {e}") 39 | print("Please install with: uv pip install -e '.[pysat]'") 40 | return 1 41 | except Exception as e: 42 | print(f"MaxSAT setup test failed: {e}") 43 | return 1 44 | 45 | 46 | if __name__ == "__main__": 47 | sys.exit(main()) 48 | -------------------------------------------------------------------------------- /src/mcp_solver/mzn/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | MiniZinc integration for MCP Solver. 3 | """ 4 | 5 | from .model_manager import MiniZincModelManager 6 | 7 | 8 | __all__ = ["MiniZincModelManager"] 9 | -------------------------------------------------------------------------------- /src/mcp_solver/mzn/test_setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Test script for verifying the MiniZinc installation of MCP-Solver. 4 | This script checks: 5 | 1. Required configuration files 6 | 2. Core dependencies (MiniZinc with Chuffed solver) 7 | 3. Basic solver functionality 8 | 9 | Note: This script only tests core functionality. 10 | Optional solvers (Z3, PySAT) have their own setup verification. 11 | """ 12 | 13 | import sys 14 | from pathlib import Path 15 | 16 | from minizinc import Instance, Model, Solver 17 | 18 | # Import our centralized prompt loader 19 | from mcp_solver.core.prompt_loader import load_prompt 20 | 21 | 22 | class SetupTest: 23 | def __init__(self): 24 | self.successes: list[tuple[str, str]] = [] # (test_name, details) 25 | self.failures: list[tuple[str, str]] = [] # (test_name, error_details) 26 | self.base_dir = Path(__file__).resolve().parents[3] 27 | self.GREEN = "\033[92m" 28 | self.RED = "\033[91m" 29 | self.RESET = "\033[0m" 30 | self.BOLD = "\033[1m" 31 | 32 | def print_result(self, test_name: str, success: bool, details: str | None = None): 33 | """Print a test result with color and proper formatting.""" 34 | mark = "✓" if success else "✗" 35 | color = self.GREEN if success else self.RED 36 | print(f"{color}{mark} {test_name}{self.RESET}") 37 | if details: 38 | print(f" └─ {details}") 39 | 40 | def record_test(self, test_name: str, success: bool, details: str | None = None): 41 | """Record a test result and print it.""" 42 | if success: 43 | self.successes.append((test_name, details if details else "")) 44 | else: 45 | self.failures.append((test_name, details if details else "Test failed")) 46 | self.print_result(test_name, success, None if success else details) 47 | 48 | def check_file(self, file_name: str) -> bool: 49 | """Check if a file exists in the base directory.""" 50 | file_path = self.base_dir / file_name 51 | return file_path.exists() 52 | 53 | def test_configuration_files(self): 54 | """Test for the presence of required configuration files.""" 55 | print(f"\n{self.BOLD}Configuration Files:{self.RESET}") 56 | 57 | # Test prompt files using the centralized prompt loader 58 | prompts_to_test = [("mzn", "instructions"), ("mzn", "review")] 59 | 60 | for mode, prompt_type in prompts_to_test: 61 | try: 62 | # Attempt to load the prompt using the prompt loader 63 | content = load_prompt(mode, prompt_type) 64 | self.record_test( 65 | f"Prompt file: {mode}/{prompt_type}.md", 66 | True, 67 | f"Successfully loaded ({len(content)} characters)", 68 | ) 69 | except FileNotFoundError: 70 | self.record_test( 71 | f"Prompt file: {mode}/{prompt_type}.md", 72 | False, 73 | "Prompt file not found", 74 | ) 75 | except Exception as e: 76 | self.record_test( 77 | f"Prompt file: {mode}/{prompt_type}.md", 78 | False, 79 | f"Error loading prompt: {e!s}", 80 | ) 81 | 82 | # Test other required files 83 | other_files = [("pyproject.toml", True)] 84 | for file, required in other_files: 85 | exists = self.check_file(file) 86 | if required: 87 | self.record_test( 88 | f"Configuration file: {file}", 89 | exists, 90 | ( 91 | None 92 | if exists 93 | else f"Required file not found at {self.base_dir / file}" 94 | ), 95 | ) 96 | 97 | def test_core_dependencies(self): 98 | """Test MiniZinc and Chuffed solver installation.""" 99 | print(f"\n{self.BOLD}Core Dependencies:{self.RESET}") 100 | try: 101 | solver = Solver.lookup("chuffed") 102 | self.record_test( 103 | "MiniZinc Chuffed solver", True, f"Found version {solver.version}" 104 | ) 105 | except Exception as e: 106 | self.record_test( 107 | "MiniZinc Chuffed solver", 108 | False, 109 | f"Chuffed solver not found: {e!s}\nPlease install MiniZinc with Chuffed solver", 110 | ) 111 | return # Skip further tests if solver not found 112 | 113 | try: 114 | from minizinc import Instance 115 | 116 | self.record_test("MiniZinc Python binding", True) 117 | except ImportError as e: 118 | self.record_test( 119 | "MiniZinc Python binding", 120 | False, 121 | f"Error importing minizinc: {e!s}\nPlease install minizinc Python package", 122 | ) 123 | 124 | def test_basic_functionality(self): 125 | """Test basic MiniZinc functionality.""" 126 | print(f"\n{self.BOLD}Basic Functionality:{self.RESET}") 127 | model_code = """ 128 | var 0..1: x; 129 | constraint x = 1; 130 | solve satisfy; 131 | """ 132 | 133 | # Test model creation 134 | try: 135 | model = Model() 136 | model.add_string(model_code) 137 | self.record_test("Model creation", True) 138 | except Exception as e: 139 | self.record_test("Model creation", False, f"Error creating model: {e!s}") 140 | return 141 | 142 | # Test solver execution 143 | try: 144 | solver = Solver.lookup("chuffed") 145 | instance = Instance(solver, model) 146 | result = instance.solve() 147 | self.record_test("Solver execution", True, "Successfully executed solver") 148 | except Exception as e: 149 | self.record_test("Solver execution", False, f"Error during solving: {e!s}") 150 | 151 | def run_all_tests(self): 152 | """Run all setup tests and display results.""" 153 | print(f"{self.BOLD}=== MCP-Solver MiniZinc Setup Test ==={self.RESET}") 154 | 155 | self.test_configuration_files() 156 | self.test_core_dependencies() 157 | self.test_basic_functionality() 158 | 159 | print(f"\n{self.BOLD}=== Test Summary ==={self.RESET}") 160 | print(f"Passed: {len(self.successes)}") 161 | print(f"Failed: {len(self.failures)}") 162 | 163 | if self.failures: 164 | print(f"\n{self.BOLD}Failed Tests:{self.RESET}") 165 | for test, details in self.failures: 166 | print(f"\n{self.RED}✗ {test}{self.RESET}") 167 | print(f" └─ {details}") 168 | print( 169 | "\nMiniZinc setup incomplete. Please fix the issues above before proceeding." 170 | ) 171 | sys.exit(1) 172 | else: 173 | print( 174 | f"\n{self.GREEN}✓ All MiniZinc tests passed successfully!{self.RESET}" 175 | ) 176 | print("\nMiniZinc system is ready to use MCP-Solver.") 177 | sys.exit(0) 178 | 179 | 180 | def main(): 181 | test = SetupTest() 182 | test.run_all_tests() 183 | 184 | 185 | if __name__ == "__main__": 186 | main() 187 | -------------------------------------------------------------------------------- /src/mcp_solver/pysat/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | PySAT solver package for MCP (Model Context Protocol). 3 | 4 | This package provides a PySAT backend for constraint solving via MCP. 5 | """ 6 | 7 | __version__ = "2.3.0" 8 | 9 | # Include additional constraints from constraints.py 10 | from .constraints import at_most_one, exactly_one 11 | 12 | # Export error handling utilities for easier access 13 | from .error_handling import ( 14 | PySATError, 15 | format_solution_error, 16 | pysat_error_handler, 17 | validate_formula, 18 | validate_variables, 19 | ) 20 | from .model_manager import PySATModelManager 21 | from .solution import export_solution 22 | 23 | # Include common cardinality constraints from templates 24 | from .templates.cardinality_templates import at_least_k, at_most_k, exactly_k 25 | from .templates.mapping import VariableMap 26 | 27 | 28 | __all__ = [ 29 | "PySATModelManager", 30 | "export_solution", 31 | "VariableMap", 32 | # Error handling 33 | "PySATError", 34 | "pysat_error_handler", 35 | "validate_variables", 36 | "validate_formula", 37 | "format_solution_error", 38 | # Constraints 39 | "at_most_k", 40 | "at_least_k", 41 | "exactly_k", 42 | "at_most_one", 43 | "exactly_one", 44 | ] 45 | -------------------------------------------------------------------------------- /src/mcp_solver/pysat/constraints.py: -------------------------------------------------------------------------------- 1 | """ 2 | PySAT constraint helper functions. 3 | 4 | This module provides helper functions for creating common cardinality constraints 5 | with PySAT in a way that's reliable and always works, regardless of the constraint type. 6 | It also includes helper functions for working with MaxSAT soft constraints. 7 | """ 8 | 9 | import itertools 10 | 11 | # Import the robust implementations from templates 12 | from .templates.cardinality_templates import ( 13 | at_least_k as _at_least_k_robust, 14 | at_most_k as _at_most_k_robust, 15 | ) 16 | 17 | 18 | def at_most_k(variables: list[int], k: int) -> list[list[int]]: 19 | """ 20 | Create clauses for at most k of variables being true. 21 | Works for any k value, with optimization for k=1 case. 22 | 23 | Args: 24 | variables: List of variable IDs 25 | k: Upper bound (any non-negative integer) 26 | 27 | Returns: 28 | List of clauses representing the constraint 29 | """ 30 | # Delegate to the robust implementation in templates 31 | return _at_most_k_robust(variables, k) 32 | 33 | 34 | def at_least_k(variables: list[int], k: int) -> list[list[int]]: 35 | """ 36 | Create clauses for at least k of variables being true. 37 | Uses De Morgan's law for efficiency. 38 | 39 | Args: 40 | variables: List of variable IDs 41 | k: Lower bound (any non-negative integer) 42 | 43 | Returns: 44 | List of clauses representing the constraint 45 | """ 46 | # Delegate to the robust implementation in templates 47 | return _at_least_k_robust(variables, k) 48 | 49 | 50 | def exactly_k(variables: list[int], k: int) -> list[list[int]]: 51 | """ 52 | Create clauses for exactly k of variables being true. 53 | Works for any k value, with optimization for k=1 case. 54 | 55 | Args: 56 | variables: List of variable IDs 57 | k: Target value (any non-negative integer) 58 | 59 | Returns: 60 | List of clauses representing the constraint 61 | """ 62 | if k > len(variables) or k < 0: 63 | return [[]] # Unsatisfiable 64 | 65 | if k == 0: 66 | # All variables must be false 67 | return [[-v] for v in variables] 68 | 69 | if k == 1: 70 | # Exactly one variable is true 71 | # At least one is true 72 | at_least = [variables.copy()] 73 | 74 | # At most one is true (pairwise encoding) 75 | at_most = [] 76 | for i in range(len(variables)): 77 | for j in range(i + 1, len(variables)): 78 | at_most.append([-variables[i], -variables[j]]) 79 | 80 | return at_least + at_most 81 | 82 | # General case 83 | return at_most_k(variables, k) + at_least_k(variables, k) 84 | 85 | 86 | def at_most_one(variables: list[int]) -> list[list[int]]: 87 | """ 88 | Optimized function for the common at-most-one constraint. 89 | Uses pairwise encoding for better performance. 90 | 91 | Args: 92 | variables: List of variable IDs 93 | 94 | Returns: 95 | List of clauses representing the constraint 96 | """ 97 | return at_most_k(variables, 1) 98 | 99 | 100 | def exactly_one(variables: list[int]) -> list[list[int]]: 101 | """ 102 | Optimized function for the common exactly-one constraint. 103 | Uses efficient encoding with pairwise constraints. 104 | 105 | Args: 106 | variables: List of variable IDs 107 | 108 | Returns: 109 | List of clauses representing the constraint 110 | """ 111 | return exactly_k(variables, 1) 112 | 113 | 114 | def implies(a: int, b: int) -> list[list[int]]: 115 | """ 116 | Create a clause for the implication a -> b (if a then b). 117 | Equivalent to (!a OR b). 118 | 119 | Args: 120 | a: The antecedent variable ID 121 | b: The consequent variable ID 122 | 123 | Returns: 124 | A list containing one clause that represents the implication 125 | """ 126 | return [[-a, b]] 127 | 128 | 129 | def mutually_exclusive(variables: list[int]) -> list[list[int]]: 130 | """ 131 | Create clauses ensuring that at most one of the variables is true. 132 | This is equivalent to at_most_one but renamed for clarity in models. 133 | 134 | Args: 135 | variables: List of variable IDs 136 | 137 | Returns: 138 | List of clauses representing mutual exclusion 139 | """ 140 | return at_most_one(variables) 141 | 142 | 143 | def if_then_else(condition: int, then_var: int, else_var: int) -> list[list[int]]: 144 | """ 145 | Create clauses for an if-then-else construct. 146 | 147 | Args: 148 | condition: The condition variable ID 149 | then_var: The 'then' variable ID 150 | else_var: The 'else' variable ID 151 | 152 | Returns: 153 | List of clauses representing if-then-else 154 | """ 155 | # If condition, then then_var 156 | # If not condition, then else_var 157 | return [ 158 | [-condition, then_var], # condition -> then_var 159 | [condition, else_var], # !condition -> else_var 160 | ] 161 | 162 | 163 | # MaxSAT helper functions 164 | 165 | 166 | def soft_clause(literals: int | list[int], weight: int = 1) -> tuple[list[int], int]: 167 | """ 168 | Create a soft clause with a given weight for MaxSAT formulas. 169 | 170 | In MaxSAT, a soft clause is a constraint that we want to satisfy 171 | but can be violated at a cost (the weight). 172 | 173 | Args: 174 | literals: One or more literals (variable IDs) to include in the soft clause 175 | weight: The weight of the clause (cost if violated) 176 | 177 | Returns: 178 | A tuple of (clause, weight) for adding to a WCNF formula 179 | """ 180 | if isinstance(literals, int): 181 | literals = [literals] 182 | return (literals, weight) 183 | 184 | 185 | def soft_at_most_k( 186 | variables: list[int], k: int, weight: int = 1 187 | ) -> list[tuple[list[int], int]]: 188 | """ 189 | Create weighted soft at-most-k constraints for MaxSAT. 190 | 191 | Args: 192 | variables: List of variable IDs 193 | k: Maximum number of variables that should be true 194 | weight: Weight of the soft constraint 195 | 196 | Returns: 197 | List of (clause, weight) pairs for adding to a WCNF formula 198 | """ 199 | if k >= len(variables): 200 | return [] # Constraint is always satisfied 201 | 202 | soft_clauses = [] 203 | 204 | # Generate all combinations of k+1 variables 205 | for combo in itertools.combinations(variables, k + 1): 206 | # At least one variable in the combination should be false 207 | soft_clauses.append(([-(var) for var in combo], weight)) 208 | 209 | return soft_clauses 210 | 211 | 212 | def soft_at_least_k( 213 | variables: list[int], k: int, weight: int = 1 214 | ) -> list[tuple[list[int], int]]: 215 | """ 216 | Create weighted soft at-least-k constraints for MaxSAT. 217 | 218 | Args: 219 | variables: List of variable IDs 220 | k: Minimum number of variables that should be true 221 | weight: Weight of the soft constraint 222 | 223 | Returns: 224 | List of (clause, weight) pairs for adding to a WCNF formula 225 | """ 226 | if k <= 0: 227 | return [] # Constraint is always satisfied 228 | 229 | soft_clauses = [] 230 | 231 | # Generate all combinations of n-k+1 negative literals 232 | negated_vars = [-var for var in variables] 233 | for combo in itertools.combinations(negated_vars, len(variables) - k + 1): 234 | # At least one variable in the combination should be true 235 | soft_clauses.append(([-lit for lit in combo], weight)) 236 | 237 | return soft_clauses 238 | 239 | 240 | def soft_exactly_k( 241 | variables: list[int], k: int, weight: int = 1 242 | ) -> list[tuple[list[int], int]]: 243 | """ 244 | Create weighted soft exactly-k constraints for MaxSAT. 245 | 246 | Args: 247 | variables: List of variable IDs 248 | k: Exact number of variables that should be true 249 | weight: Weight of the soft constraint 250 | 251 | Returns: 252 | List of (clause, weight) pairs for adding to a WCNF formula 253 | """ 254 | return soft_at_most_k(variables, k, weight) + soft_at_least_k(variables, k, weight) 255 | 256 | 257 | def soft_implies(a: int, b: int, weight: int = 1) -> list[tuple[list[int], int]]: 258 | """ 259 | Create a weighted soft implication constraint (a -> b) for MaxSAT. 260 | 261 | Args: 262 | a: The antecedent variable ID 263 | b: The consequent variable ID 264 | weight: Weight of the soft constraint 265 | 266 | Returns: 267 | List containing one (clause, weight) pair for adding to a WCNF formula 268 | """ 269 | return [([-a, b], weight)] 270 | 271 | 272 | def soft_if_then_else( 273 | condition: int, then_var: int, else_var: int, weight: int = 1 274 | ) -> list[tuple[list[int], int]]: 275 | """ 276 | Create weighted soft if-then-else constraints for MaxSAT. 277 | 278 | Args: 279 | condition: The condition variable ID 280 | then_var: The 'then' variable ID 281 | else_var: The 'else' variable ID 282 | weight: Weight of the soft constraint 283 | 284 | Returns: 285 | List of (clause, weight) pairs for adding to a WCNF formula 286 | """ 287 | return [ 288 | ([-condition, then_var], weight), # condition -> then_var 289 | ([condition, else_var], weight), # !condition -> else_var 290 | ] 291 | 292 | 293 | def add_soft_clauses_to_wcnf( 294 | wcnf: "WCNF", clauses: list[tuple[list[int], int]] 295 | ) -> None: 296 | """ 297 | Add a list of soft clauses to a WCNF formula. 298 | 299 | Args: 300 | wcnf: The WCNF formula to update 301 | clauses: List of (clause, weight) pairs to add 302 | 303 | Returns: 304 | None (modifies the wcnf in-place) 305 | """ 306 | for clause, weight in clauses: 307 | wcnf.append(clause, weight=weight) 308 | -------------------------------------------------------------------------------- /src/mcp_solver/pysat/error_handling.py: -------------------------------------------------------------------------------- 1 | """ 2 | PySAT error handling module. 3 | 4 | This module provides enhanced error handling for PySAT operations, including: 5 | - Function wrappers that capture and translate PySAT exceptions 6 | - Context-aware error messages that help users debug problems 7 | - Structured error reporting for better user experience 8 | """ 9 | 10 | import functools 11 | import logging 12 | import traceback 13 | from collections.abc import Callable 14 | from typing import Any, TypeVar 15 | 16 | 17 | # Setup logger 18 | logger = logging.getLogger(__name__) 19 | 20 | # Type variable for generic function decorator 21 | T = TypeVar("T") 22 | 23 | # Map of common PySAT exceptions to user-friendly messages 24 | EXCEPTION_MESSAGES = { 25 | "TypeError": { 26 | "object of type 'NoneType' has no len()": "Formula appears to be empty or not properly initialized", 27 | "append_formula() missing 1 required positional argument: 'formula'": "No formula was provided to the solver", 28 | "vars() got an unexpected keyword argument": "Incorrect variable definition. Variables must be positive integers", 29 | "'int' object is not iterable": "A clause must be a list of integers, not a single integer", 30 | }, 31 | "ValueError": { 32 | "variable identifier should be a non-zero integer": "Variable IDs must be non-zero integers. Check your variable mapping", 33 | "literal should be a non-zero integer": "Clause literals must be non-zero integers. Zero is not allowed in clauses", 34 | "unexpected literal value": "Invalid literal value in clause. Literals must be integers", 35 | }, 36 | "RuntimeError": { 37 | "solver is not initialized": "Solver was not properly initialized before use", 38 | "solver is in an invalid state": "Solver has been corrupted or used after being deleted", 39 | }, 40 | "AttributeError": { 41 | "'NoneType' object has no attribute": "Attempted to use a solver or formula that doesn't exist", 42 | "object has no attribute 'append_formula'": "The solver object doesn't support append_formula. Make sure you're using a compatible solver", 43 | }, 44 | } 45 | 46 | 47 | class PySATError(Exception): 48 | """Custom exception class for enhanced PySAT errors.""" 49 | 50 | def __init__( 51 | self, 52 | message: str, 53 | original_error: Exception | None = None, 54 | context: dict[str, Any] | None = None, 55 | ): 56 | """ 57 | Initialize PySAT error with enhanced context. 58 | 59 | Args: 60 | message: User-friendly error message 61 | original_error: The original exception that was caught 62 | context: Additional context about the error (e.g., formula size, variable range) 63 | """ 64 | self.original_error = original_error 65 | self.context = context or {} 66 | self.original_traceback = traceback.format_exc() 67 | 68 | # Build enhanced message with context 69 | enhanced_message = message 70 | if context: 71 | enhanced_message += "\n\nContext:" 72 | for key, value in context.items(): 73 | enhanced_message += f"\n- {key}: {value}" 74 | 75 | if original_error: 76 | error_type = type(original_error).__name__ 77 | error_msg = str(original_error) 78 | enhanced_message += f"\n\nOriginal error ({error_type}): {error_msg}" 79 | 80 | super().__init__(enhanced_message) 81 | 82 | 83 | def pysat_error_handler(func: Callable[..., T]) -> Callable[..., T]: 84 | """ 85 | Decorator to handle PySAT exceptions with enhanced error messages. 86 | 87 | Args: 88 | func: The function to wrap with error handling 89 | 90 | Returns: 91 | Wrapped function with error handling 92 | """ 93 | 94 | @functools.wraps(func) 95 | def wrapper(*args: Any, **kwargs: Any) -> T: 96 | try: 97 | return func(*args, **kwargs) 98 | except Exception as e: 99 | error_type = type(e).__name__ 100 | error_msg = str(e) 101 | 102 | # Try to find a matching error message pattern 103 | friendly_message = None 104 | if error_type in EXCEPTION_MESSAGES: 105 | for pattern, message in EXCEPTION_MESSAGES[error_type].items(): 106 | if pattern in error_msg: 107 | friendly_message = message 108 | break 109 | 110 | # If no specific message found, use a generic one 111 | if not friendly_message: 112 | friendly_message = f"Error in PySAT operation: {error_msg}" 113 | 114 | # Gather context information 115 | context = {} 116 | try: 117 | # Get formula/solver context if available in args 118 | for arg in args: 119 | if hasattr(arg, "nof_clauses"): 120 | context["clauses"] = arg.nof_clauses() 121 | if hasattr(arg, "nof_vars"): 122 | context["variables"] = arg.nof_vars() 123 | if hasattr(arg, "clauses") and isinstance(arg.clauses, list): 124 | context["clause_count"] = len(arg.clauses) 125 | if arg.clauses: 126 | context["sample_clause"] = str(arg.clauses[0]) 127 | except Exception: 128 | # Don't fail during error handling 129 | pass 130 | 131 | # Log the error with full traceback for debugging 132 | logger.error( 133 | f"PySAT error in {func.__name__}: {error_type}: {error_msg}", 134 | exc_info=True, 135 | ) 136 | 137 | # Raise our enhanced error 138 | raise PySATError(friendly_message, original_error=e, context=context) from e 139 | 140 | return wrapper 141 | 142 | 143 | def validate_variables(variables: dict[str, int]) -> list[str]: 144 | """ 145 | Validate that all variables have proper types and values. 146 | 147 | Args: 148 | variables: Dictionary mapping variable names to IDs 149 | 150 | Returns: 151 | List of error messages (empty if no errors) 152 | """ 153 | errors = [] 154 | 155 | if not variables: 156 | errors.append("Variable dictionary is empty") 157 | return errors 158 | 159 | for key, var_id in variables.items(): 160 | if not isinstance(var_id, int): 161 | errors.append( 162 | f"Variable '{key}' has non-integer ID: {var_id} " 163 | f"(type: {type(var_id).__name__})" 164 | ) 165 | elif var_id <= 0: 166 | errors.append(f"Variable '{key}' has non-positive ID: {var_id}") 167 | 168 | # Check for duplicate IDs 169 | id_to_keys: dict[int, list[str]] = {} 170 | for key, var_id in variables.items(): 171 | if var_id not in id_to_keys: 172 | id_to_keys[var_id] = [] 173 | id_to_keys[var_id].append(key) 174 | 175 | for var_id, keys in id_to_keys.items(): 176 | if len(keys) > 1: 177 | errors.append( 178 | f"Multiple variables ({', '.join(keys)}) share the same ID: {var_id}" 179 | ) 180 | 181 | return errors 182 | 183 | 184 | def validate_formula(formula: Any) -> list[str]: 185 | """ 186 | Check formula structure for potential issues. 187 | 188 | Args: 189 | formula: PySAT CNF formula object 190 | 191 | Returns: 192 | List of error messages (empty if no errors) 193 | """ 194 | errors = [] 195 | 196 | if not hasattr(formula, "clauses"): 197 | errors.append("Object does not appear to be a valid PySAT formula") 198 | return errors 199 | 200 | if not formula.clauses: 201 | errors.append("Formula has no clauses") 202 | return errors 203 | 204 | max_var = 0 205 | for i, clause in enumerate(formula.clauses): 206 | if not clause: 207 | errors.append(f"Clause {i + 1} is empty") 208 | continue 209 | 210 | for j, lit in enumerate(clause): 211 | if not isinstance(lit, int): 212 | errors.append( 213 | f"Clause {i + 1}, literal {j + 1} is not an integer: " 214 | f"{lit} (type: {type(lit).__name__})" 215 | ) 216 | elif lit == 0: 217 | errors.append( 218 | f"Clause {i + 1}, literal {j + 1} is zero (not allowed in PySAT)" 219 | ) 220 | else: 221 | max_var = max(max_var, abs(lit)) 222 | 223 | # Check if variables seem consistent 224 | if hasattr(formula, "nof_vars") and callable(formula.nof_vars): 225 | reported_vars = formula.nof_vars() 226 | if reported_vars < max_var: 227 | errors.append( 228 | f"Formula reports {reported_vars} variables but clauses " 229 | f"reference variable {max_var}" 230 | ) 231 | 232 | return errors 233 | 234 | 235 | def format_solution_error(error: Exception) -> dict[str, Any]: 236 | """ 237 | Format an error for inclusion in the solution. 238 | 239 | Args: 240 | error: The exception to format 241 | 242 | Returns: 243 | Dictionary with error information 244 | """ 245 | error_type = type(error).__name__ 246 | error_msg = str(error) 247 | 248 | # If it's our enhanced error, use its information 249 | if isinstance(error, PySATError): 250 | result = { 251 | "satisfiable": False, 252 | "error_type": error_type, 253 | "error_message": error_msg, 254 | } 255 | 256 | # Include context if available 257 | if error.context: 258 | result["error_context"] = error.context 259 | 260 | return result 261 | 262 | # For other errors, create a more basic structure 263 | return { 264 | "satisfiable": False, 265 | "error_type": error_type, 266 | "error_message": error_msg, 267 | } 268 | -------------------------------------------------------------------------------- /src/mcp_solver/pysat/solution.py: -------------------------------------------------------------------------------- 1 | """ 2 | PySAT solution module for extracting and formatting solutions from PySAT solvers. 3 | 4 | This module provides functions for extracting solution data from PySAT solvers 5 | and converting it to a standardized format. 6 | """ 7 | 8 | import logging 9 | import os 10 | import sys 11 | from typing import Any 12 | 13 | 14 | # IMPORTANT: Properly import the PySAT library (not our local package) 15 | # First, remove the current directory from the path to avoid importing ourselves 16 | current_dir = os.path.abspath(os.path.dirname(__file__)) 17 | parent_dir = os.path.abspath(os.path.join(current_dir, "..")) 18 | if current_dir in sys.path: 19 | sys.path.remove(current_dir) 20 | if parent_dir in sys.path: 21 | sys.path.remove(parent_dir) 22 | 23 | # Add site-packages to the front of the path 24 | import site 25 | 26 | 27 | site_packages = site.getsitepackages() 28 | for p in reversed(site_packages): 29 | if p not in sys.path: 30 | sys.path.insert(0, p) 31 | 32 | # Now try to import PySAT 33 | try: 34 | from pysat.formula import CNF 35 | from pysat.solvers import Solver 36 | except ImportError: 37 | print("PySAT solver not found. Install with: pip install python-sat") 38 | sys.exit(1) 39 | 40 | # Import our error handling utilities 41 | from .error_handling import PySATError, validate_variables 42 | 43 | 44 | # Set up logger 45 | logger = logging.getLogger(__name__) 46 | 47 | # Track the last solution - make it global and accessible 48 | _LAST_SOLUTION = None 49 | 50 | # Reserved keys that shouldn't be processed as custom dictionaries 51 | RESERVED_KEYS = { 52 | "satisfiable", 53 | "model", 54 | "values", 55 | "status", 56 | "error_type", 57 | "error_message", 58 | "warnings", 59 | "unsatisfied", 60 | } 61 | 62 | 63 | class SolutionError(Exception): 64 | """ 65 | Custom exception for solution processing errors. 66 | 67 | This exception is used when errors occur during solution processing 68 | that should be captured and returned as a structured error solution. 69 | """ 70 | 71 | pass 72 | 73 | 74 | def export_solution( 75 | data: dict[str, Any] | Solver | None = None, 76 | variables: dict[str, int] | None = None, 77 | objective: float | None = None, 78 | ) -> dict[str, Any]: 79 | """ 80 | Extract and format solutions from a PySAT solver or solution data. 81 | 82 | This function processes PySAT solution data and creates a standardized 83 | output format. It supports both direct dictionary input and PySAT Solver 84 | objects. All values in custom dictionaries are automatically extracted 85 | and made available in the flat "values" dictionary. 86 | 87 | Args: 88 | data: PySAT Solver object or dictionary containing solution data 89 | variables: Dictionary mapping variable names to their variable IDs 90 | objective: Optional objective value for optimization problems 91 | 92 | Returns: 93 | Dictionary with structured solution data, including: 94 | - satisfiable: Boolean indicating satisfiability 95 | - status: String status ("sat", "unsat", or "error") 96 | - values: Flattened dictionary of all values from custom dictionaries 97 | - model: List of true variable IDs (if satisfiable) 98 | - Other custom dictionaries provided in the input 99 | 100 | If an error occurs, the returned dictionary will include: 101 | - satisfiable: False 102 | - error_type: Type of the error 103 | - error_message: Detailed error message 104 | - status: "error" 105 | """ 106 | global _LAST_SOLUTION 107 | 108 | try: 109 | solution_data = _process_input_data(data, variables, objective) 110 | solution_data = _extract_values_from_dictionaries(solution_data) 111 | 112 | # Log the solution data 113 | logger.debug(f"Setting _LAST_SOLUTION: {solution_data}") 114 | print(f"DEBUG - _LAST_SOLUTION set to: {solution_data}") 115 | 116 | # Store the solution and return it 117 | _LAST_SOLUTION = solution_data 118 | return solution_data 119 | 120 | except Exception as e: 121 | # Create an error solution with structured error information 122 | error_solution = _create_error_solution(e) 123 | 124 | # Store and return the error solution 125 | _LAST_SOLUTION = error_solution 126 | logger.error(f"Error in export_solution: {e!s}", exc_info=True) 127 | print(f"DEBUG - _LAST_SOLUTION set to error: {error_solution}") 128 | 129 | return error_solution 130 | 131 | 132 | # Removed export_maxsat_solution function as it's now in the MaxSAT module 133 | 134 | 135 | def _process_input_data( 136 | data: dict[str, Any] | Solver | None, 137 | variables: dict[str, int] | None = None, 138 | objective: float | None = None, 139 | ) -> dict[str, Any]: 140 | """ 141 | Process input data from various sources into a standardized solution dictionary. 142 | 143 | Args: 144 | data: PySAT Solver object or dictionary containing solution data 145 | variables: Dictionary mapping variable names to their variable IDs 146 | objective: Optional objective value for optimization problems 147 | 148 | Returns: 149 | Standardized solution dictionary 150 | 151 | Raises: 152 | SolutionError: If the input data cannot be processed 153 | """ 154 | # Initialize solution data 155 | solution_data: dict[str, Any] = {} 156 | 157 | # Case 1: Direct dictionary data 158 | if isinstance(data, dict): 159 | solution_data = data.copy() 160 | 161 | # Case 2: PySAT Solver object 162 | elif data is not None and hasattr(data, "get_model") and callable(data.get_model): 163 | # Extract model from solver (solver.solve() should have already been called) 164 | model = data.get_model() 165 | 166 | if model is not None: 167 | # Solver has a satisfiable solution 168 | solution_data["satisfiable"] = True 169 | solution_data["model"] = model 170 | 171 | # Extract variable assignments if variables dictionary is provided 172 | if variables: 173 | # Validate variables dictionary 174 | errors = validate_variables(variables) 175 | if errors: 176 | error_msg = "; ".join(errors) 177 | raise SolutionError(f"Invalid variables dictionary: {error_msg}") 178 | 179 | # Map variable names to their truth values based on the model 180 | solution_data["assignment"] = { 181 | name: (var_id in model) if var_id > 0 else ((-var_id) not in model) 182 | for name, var_id in variables.items() 183 | } 184 | else: 185 | # No model means unsatisfiable 186 | solution_data["satisfiable"] = False 187 | 188 | # Case 3: None or unknown type 189 | elif data is None: 190 | # Default to empty unsatisfiable solution 191 | solution_data["satisfiable"] = False 192 | else: 193 | raise SolutionError(f"Unsupported data type: {type(data).__name__}") 194 | 195 | # Ensure the satisfiable flag is set 196 | if "satisfiable" not in solution_data: 197 | solution_data["satisfiable"] = False 198 | 199 | # Set the status field to match satisfiability 200 | solution_data["status"] = ( 201 | "sat" if solution_data.get("satisfiable", False) else "unsat" 202 | ) 203 | 204 | # Include objective value if provided 205 | if objective is not None: 206 | solution_data["objective"] = objective 207 | 208 | # Ensure values dictionary exists (may be populated later) 209 | solution_data["values"] = solution_data.get("values", {}) 210 | 211 | return solution_data 212 | 213 | 214 | def _extract_values_from_dictionaries(solution_data: dict[str, Any]) -> dict[str, Any]: 215 | """ 216 | Extract values from custom dictionaries into a flat values dictionary. 217 | 218 | Args: 219 | solution_data: The solution dictionary to process 220 | 221 | Returns: 222 | The processed solution dictionary with extracted values 223 | """ 224 | # Skip extraction for unsatisfiable solutions that don't need values 225 | if not solution_data.get("satisfiable", False): 226 | # Ensure values dictionary exists even for unsatisfiable solutions 227 | solution_data["values"] = solution_data.get("values", {}) 228 | return solution_data 229 | 230 | # Create a new values dictionary 231 | values: dict[str, Any] = {} 232 | 233 | # First pass: collect all keys to detect potential collisions 234 | key_counts: dict[str, int] = {} 235 | 236 | for key, value in solution_data.items(): 237 | if key not in RESERVED_KEYS and isinstance(value, dict): 238 | for subkey in value.keys(): 239 | key_counts[subkey] = key_counts.get(subkey, 0) + 1 240 | 241 | # Second pass: extract values and handle collisions 242 | for key, value in solution_data.items(): 243 | if key not in RESERVED_KEYS and isinstance(value, dict): 244 | for subkey, subvalue in value.items(): 245 | # Only extract leaf nodes (not nested dictionaries) 246 | if not isinstance(subvalue, dict): 247 | if key_counts[subkey] > 1: 248 | # This is a collision - prefix with parent dictionary name 249 | values[f"{key}.{subkey}"] = subvalue 250 | else: 251 | # No collision - use the key directly 252 | values[subkey] = subvalue 253 | 254 | # Update the values dictionary in the solution 255 | solution_data["values"] = values 256 | 257 | return solution_data 258 | 259 | 260 | def _create_error_solution(error: Exception) -> dict[str, Any]: 261 | """ 262 | Create a standardized error solution dictionary from an exception. 263 | 264 | Args: 265 | error: The exception that occurred 266 | 267 | Returns: 268 | A solution dictionary with error information 269 | """ 270 | # If it's already a structured PySAT error, use its context 271 | if isinstance(error, PySATError): 272 | error_context = getattr(error, "context", {}) 273 | error_solution = { 274 | "satisfiable": False, 275 | "error_type": type(error).__name__, 276 | "error_message": str(error), 277 | "status": "error", 278 | "values": {}, 279 | } 280 | 281 | # Add context if available 282 | if error_context: 283 | error_solution["error_context"] = error_context 284 | 285 | else: 286 | # For standard exceptions, create a basic error solution 287 | error_solution = { 288 | "satisfiable": False, 289 | "error_type": type(error).__name__, 290 | "error_message": str(error), 291 | "status": "error", 292 | "values": {}, 293 | } 294 | 295 | return error_solution 296 | -------------------------------------------------------------------------------- /src/mcp_solver/pysat/templates/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | PySAT templates for common constraint patterns. 3 | 4 | This module provides templated solutions for common constraint patterns 5 | to make it easier to model problems with PySAT. 6 | """ 7 | 8 | # Import templates from other modules 9 | from .basic_templates import * 10 | from .cardinality_templates import * 11 | from .mapping import VariableMap 12 | 13 | # MaxSAT functionality removed 14 | -------------------------------------------------------------------------------- /src/mcp_solver/pysat/templates/basic_templates.py: -------------------------------------------------------------------------------- 1 | """ 2 | Basic templates for PySAT. 3 | 4 | This module provides template functions for common PySAT patterns related to 5 | basic SAT solving. 6 | """ 7 | 8 | import sys 9 | from typing import Any 10 | 11 | 12 | # Import PySAT but protect against failure 13 | try: 14 | from pysat.formula import CNF 15 | from pysat.solvers import Cadical153, Glucose3, Glucose4, Lingeling, Solver 16 | except ImportError: 17 | print("PySAT solver not found. Install with: pip install python-sat") 18 | sys.exit(1) 19 | 20 | 21 | def basic_sat_solver( 22 | clauses: list[list[int]], solver_type=Cadical153 23 | ) -> dict[str, Any]: 24 | """ 25 | Basic SAT solving template. 26 | 27 | Args: 28 | clauses: List of clauses (each clause is a list of integers) 29 | solver_type: PySAT solver class to use (default: Cadical153) 30 | 31 | Returns: 32 | Dictionary with results 33 | """ 34 | # Create CNF formula 35 | formula = CNF() 36 | for clause in clauses: 37 | formula.append(clause) 38 | 39 | # Create solver and add formula 40 | solver = solver_type() 41 | solver.append_formula(formula) 42 | 43 | # Solve 44 | is_sat = solver.solve() 45 | model = solver.get_model() if is_sat else None 46 | 47 | # Create result 48 | result = { 49 | "is_sat": is_sat, 50 | "model": model, 51 | "solver": solver, # Return solver for cleanup 52 | } 53 | 54 | return result 55 | 56 | 57 | def dimacs_to_cnf(dimacs_str: str) -> CNF: 58 | """ 59 | Convert DIMACS format string to CNF. 60 | 61 | Args: 62 | dimacs_str: String in DIMACS format 63 | 64 | Returns: 65 | CNF object 66 | """ 67 | formula = CNF() 68 | lines = dimacs_str.strip().split("\n") 69 | 70 | for line in lines: 71 | line = line.strip() 72 | 73 | # Skip comments and problem line 74 | if line.startswith("c") or line.startswith("p"): 75 | continue 76 | 77 | # Parse clause 78 | if line and not line.startswith("%"): 79 | clause = [int(lit) for lit in line.split() if lit != "0"] 80 | if clause: # Non-empty clause 81 | formula.append(clause) 82 | 83 | return formula 84 | 85 | 86 | def sat_to_binary_variables( 87 | variables: dict[str, int], model: list[int] 88 | ) -> dict[str, bool]: 89 | """ 90 | Convert SAT model to binary variables. 91 | 92 | Args: 93 | variables: Dictionary mapping variable names to their IDs 94 | model: SAT model (list of integers) 95 | 96 | Returns: 97 | Dictionary mapping variable names to boolean values 98 | """ 99 | result = {} 100 | 101 | for var_name, var_id in variables.items(): 102 | # Handle positive and negative variable IDs 103 | var_id_abs = abs(var_id) 104 | var_value = var_id_abs in model 105 | if var_id < 0: # If variable ID is negative, negate the value 106 | var_value = not var_value 107 | result[var_name] = var_value 108 | 109 | return result 110 | -------------------------------------------------------------------------------- /src/mcp_solver/pysat/templates/cardinality_templates.py: -------------------------------------------------------------------------------- 1 | """ 2 | Cardinality constraint templates for PySAT. 3 | 4 | This module provides template functions for common PySAT patterns related to 5 | cardinality constraints. 6 | """ 7 | 8 | import sys 9 | from typing import Any 10 | 11 | 12 | # Import PySAT but protect against failure 13 | try: 14 | from pysat.card import CardEnc, EncType 15 | from pysat.formula import CNF 16 | except ImportError: 17 | print("PySAT solver not found. Install with: pip install python-sat") 18 | sys.exit(1) 19 | 20 | 21 | def at_most_k( 22 | variables: list[int], k: int, encoding: EncType = EncType.seqcounter 23 | ) -> list[list[int]]: 24 | """ 25 | Create clauses ensuring at most k variables can be true. 26 | 27 | This is a robust implementation that handles edge cases better 28 | and falls back to direct encoding when needed. 29 | 30 | Args: 31 | variables: List of variable IDs 32 | k: Maximum number of variables that can be true 33 | encoding: Encoding type to try (will fall back to direct encoding if needed) 34 | 35 | Returns: 36 | List of clauses representing the constraint 37 | """ 38 | # Handle edge cases 39 | if k >= len(variables): 40 | return [] # No constraint needed 41 | 42 | # Use direct encoding for small sets (more reliable across versions) 43 | if len(variables) <= 20: 44 | clauses = [] 45 | import itertools 46 | 47 | for combo in itertools.combinations(variables, k + 1): 48 | clauses.append([-v for v in combo]) 49 | return clauses 50 | 51 | # For larger sets, try PySAT's encoding but handle failures 52 | try: 53 | return CardEnc.atmost(variables, k, encoding=encoding).clauses 54 | except Exception: 55 | # Fall back to direct encoding if PySAT's fails 56 | # This will be slower but more reliable 57 | clauses = [] 58 | import itertools 59 | 60 | for combo in itertools.combinations(variables, k + 1): 61 | clauses.append([-v for v in combo]) 62 | return clauses 63 | 64 | 65 | def at_least_k( 66 | variables: list[int], k: int, encoding: EncType = EncType.seqcounter 67 | ) -> list[list[int]]: 68 | """ 69 | Create clauses ensuring at least k variables must be true. 70 | 71 | Uses De Morgan's law with the robust at_most_k implementation. 72 | 73 | Args: 74 | variables: List of variable IDs 75 | k: Minimum number of variables that must be true 76 | encoding: Encoding type to try (will fall back to direct encoding if needed) 77 | 78 | Returns: 79 | List of clauses representing the constraint 80 | """ 81 | # We use De Morgan's law: at least k of variables = at most (n-k) of negated variables 82 | # For small instances, we can directly call at_most_k with negated vars 83 | if len(variables) <= 100: # Arbitrary threshold to avoid excessive computation 84 | neg_vars = [-v for v in variables] 85 | return at_most_k(neg_vars, len(variables) - k, encoding=encoding) 86 | 87 | # For larger instances, try PySAT's direct implementation 88 | try: 89 | return CardEnc.atleast(variables, k, encoding=encoding).clauses 90 | except Exception: 91 | # Fall back to at_most_k with De Morgan's law 92 | neg_vars = [-v for v in variables] 93 | return at_most_k(neg_vars, len(variables) - k, encoding=encoding) 94 | 95 | 96 | def exactly_k( 97 | variables: list[int], k: int, encoding: EncType = EncType.seqcounter 98 | ) -> CNF: 99 | """ 100 | Create CNF formula enforcing exactly k variables are true. 101 | 102 | Args: 103 | variables: List of variable IDs 104 | k: Exact number of variables that must be true 105 | encoding: Encoding type (default: sequential counter) 106 | 107 | Returns: 108 | CNF formula with the constraint 109 | """ 110 | return CardEnc.equals(variables, k, encoding=encoding) 111 | 112 | 113 | def one_hot_encoding( 114 | variables: list[int], encoding: EncType = EncType.seqcounter 115 | ) -> CNF: 116 | """ 117 | Create CNF formula enforcing exactly one variable is true. 118 | 119 | Args: 120 | variables: List of variable IDs 121 | encoding: Encoding type (default: sequential counter) 122 | 123 | Returns: 124 | CNF formula with the constraint 125 | """ 126 | return CardEnc.equals(variables, 1, encoding=encoding) 127 | 128 | 129 | def add_cardinality_constraint( 130 | formula: CNF, 131 | constraint_type: str, 132 | variables: list[int], 133 | k: int, 134 | encoding: EncType = EncType.seqcounter, 135 | ) -> CNF: 136 | """ 137 | Add a cardinality constraint to an existing CNF formula. 138 | 139 | Args: 140 | formula: Existing CNF formula 141 | constraint_type: Type of constraint ("atmost", "atleast", "exactly") 142 | variables: List of variable IDs 143 | k: Parameter value (max/min/exact number) 144 | encoding: Encoding type (default: sequential counter) 145 | 146 | Returns: 147 | CNF formula with the added constraint 148 | """ 149 | # Create constraint formula 150 | if constraint_type.lower() == "atmost": 151 | constraint = CardEnc.atmost(variables, k, encoding=encoding) 152 | elif constraint_type.lower() == "atleast": 153 | constraint = CardEnc.atleast(variables, k, encoding=encoding) 154 | elif constraint_type.lower() in ["exactly", "equals"]: 155 | constraint = CardEnc.equals(variables, k, encoding=encoding) 156 | else: 157 | raise ValueError(f"Unknown constraint type: {constraint_type}") 158 | 159 | # Add constraint clauses to the formula 160 | for clause in constraint.clauses: 161 | formula.append(clause) 162 | 163 | return formula 164 | 165 | 166 | def create_balanced_partitioning( 167 | variables: list[int], num_parts: int = 2, encoding: EncType = EncType.seqcounter 168 | ) -> dict[str, Any]: 169 | """ 170 | Create a balanced partitioning of variables. 171 | 172 | Args: 173 | variables: List of variable IDs 174 | num_parts: Number of partitions (default: 2) 175 | encoding: Encoding type (default: sequential counter) 176 | 177 | Returns: 178 | Dictionary with formulas and information about the partitioning 179 | """ 180 | if num_parts < 2: 181 | raise ValueError("Number of partitions must be at least 2") 182 | 183 | n = len(variables) 184 | 185 | # Create result dictionary 186 | result = {"formula": CNF(), "part_variables": [], "constraints": []} 187 | 188 | # Create part indicator variables 189 | # p_i_j = 1 means variable i is in part j 190 | top_var = max(abs(var) for var in variables) 191 | part_vars = [] 192 | 193 | for i, var in enumerate(variables): 194 | part_var_row = [] 195 | for j in range(num_parts): 196 | part_var = top_var + 1 + i * num_parts + j 197 | part_var_row.append(part_var) 198 | part_vars.append(part_var_row) 199 | 200 | # Each variable must be in exactly one part 201 | one_hot = CardEnc.equals(part_var_row, 1, encoding=encoding) 202 | result["constraints"].append(("one_hot", var, one_hot)) 203 | for clause in one_hot.clauses: 204 | result["formula"].append(clause) 205 | 206 | result["part_variables"] = part_vars 207 | 208 | # Calculate target size for each part 209 | target_size = n // num_parts 210 | remainder = n % num_parts 211 | 212 | # Create part size constraints 213 | for j in range(num_parts): 214 | # Variables indicating if each element is in part j 215 | part_j_vars = [part_vars[i][j] for i in range(n)] 216 | 217 | # Part size constraint 218 | part_size = target_size + (1 if j < remainder else 0) 219 | part_size_constraint = CardEnc.equals(part_j_vars, part_size, encoding=encoding) 220 | result["constraints"].append(("part_size", j, part_size_constraint)) 221 | 222 | # Add constraints to the formula 223 | for clause in part_size_constraint.clauses: 224 | result["formula"].append(clause) 225 | 226 | return result 227 | -------------------------------------------------------------------------------- /src/mcp_solver/pysat/templates/mapping.py: -------------------------------------------------------------------------------- 1 | """ 2 | Helper classes for mapping between meaningful variable names and SAT variable IDs. 3 | """ 4 | 5 | 6 | class VariableMap: 7 | """ 8 | Helper class for mapping between meaningful variable names and SAT variable IDs. 9 | """ 10 | 11 | def __init__(self): 12 | self.var_to_id = {} 13 | self.id_to_var = {} 14 | self.next_id = 1 15 | 16 | def get_id(self, var_name): 17 | """Get or create variable ID for a named variable""" 18 | if var_name not in self.var_to_id: 19 | self.var_to_id[var_name] = self.next_id 20 | self.id_to_var[self.next_id] = var_name 21 | self.next_id += 1 22 | return self.var_to_id[var_name] 23 | 24 | def get_name(self, var_id): 25 | """Get variable name from ID""" 26 | return self.id_to_var.get(abs(var_id), f"unknown_{abs(var_id)}") 27 | 28 | def interpret_model(self, model): 29 | """Convert SAT model to dictionary of variable assignments""" 30 | result = {} 31 | for lit in model: 32 | var_id = abs(lit) 33 | if var_id in self.id_to_var: 34 | result[self.id_to_var[var_id]] = lit > 0 35 | return result 36 | 37 | def get_mapping(self): 38 | """Return a copy of the current variable mapping""" 39 | return self.var_to_id.copy() 40 | -------------------------------------------------------------------------------- /src/mcp_solver/pysat/templates/pysat_constraints_examples.md: -------------------------------------------------------------------------------- 1 | # PySAT Constraint Helper Functions 2 | 3 | This guide explains how to use the cardinality constraint helper functions provided with PySAT mode in MCP Solver. 4 | 5 | ## Available Helper Functions 6 | 7 | The following helper functions are available to create common constraints: 8 | 9 | | Function | Description | 10 | |---------------------|---------------------------------------------------------| 11 | | `at_most_k` | At most k variables are true | 12 | | `at_least_k` | At least k variables are true | 13 | | `exactly_k` | Exactly k variables are true | 14 | | `at_most_one` | At most one variable is true (optimized for k=1) | 15 | | `exactly_one` | Exactly one variable is true (optimized for k=1) | 16 | | `implies` | If a is true, then b must be true | 17 | | `mutually_exclusive`| At most one variable is true (same as at_most_one) | 18 | | `if_then_else` | If condition then x else y | 19 | 20 | ## Basic Usage 21 | 22 | All constraint helpers return lists of clauses that you can add to your PySAT formula: 23 | 24 | ```python 25 | from pysat.formula import CNF 26 | 27 | formula = CNF() 28 | 29 | # Some variables 30 | courses = [1, 2, 3, 4, 5] # Variable IDs for courses 31 | 32 | # Add constraint: Take at most 3 courses 33 | formula.extend(at_most_k(courses, 3)) 34 | 35 | # Add constraint: Take at least 2 courses 36 | formula.extend(at_least_k(courses, 2)) 37 | ``` 38 | 39 | ## Example Scenarios 40 | 41 | ### 1. Scheduling: At Most 2 Per Day 42 | 43 | Suppose variables 1-5 represent courses on Monday, and 6-10 represent courses on Tuesday. To ensure at most 2 courses per day: 44 | 45 | ```python 46 | monday_courses = [1, 2, 3, 4, 5] 47 | tuesday_courses = [6, 7, 8, 9, 10] 48 | 49 | formula = CNF() 50 | formula.extend(at_most_k(monday_courses, 2)) # At most 2 courses on Monday 51 | formula.extend(at_most_k(tuesday_courses, 2)) # At most 2 courses on Tuesday 52 | ``` 53 | 54 | ### 2. Assignment: Exactly One Assignment 55 | 56 | Suppose variables 11-15 represent assigning a task to different people. To ensure the task is assigned to exactly one person: 57 | 58 | ```python 59 | task_assignments = [11, 12, 13, 14, 15] 60 | 61 | formula = CNF() 62 | formula.extend(exactly_one(task_assignments)) # Task assigned to exactly one person 63 | ``` 64 | 65 | ### 3. Selection: Exactly K Items 66 | 67 | Suppose variables 20-30 represent possible items to select. To select exactly 5 items: 68 | 69 | ```python 70 | possible_items = list(range(20, 31)) # Variables 20-30 71 | 72 | formula = CNF() 73 | formula.extend(exactly_k(possible_items, 5)) # Select exactly 5 items 74 | ``` 75 | 76 | ### 4. Dependencies: If-Then Relationships 77 | 78 | Suppose variable 40 represents taking a prerequisite course, and 41 represents taking an advanced course. 79 | To ensure the prerequisite is taken if the advanced course is taken: 80 | 81 | ```python 82 | prerequisite = 40 83 | advanced_course = 41 84 | 85 | formula = CNF() 86 | formula.extend(implies(advanced_course, prerequisite)) # If taking advanced, must take prerequisite 87 | ``` 88 | 89 | ### 5. Mutual Exclusion 90 | 91 | Suppose variables 50-55 represent different job positions. To ensure a person can hold at most one position: 92 | 93 | ```python 94 | job_positions = list(range(50, 56)) # Variables 50-55 95 | 96 | formula = CNF() 97 | formula.extend(mutually_exclusive(job_positions)) # Can hold at most one position 98 | ``` 99 | 100 | ### 6. If-Then-Else Construct 101 | 102 | Suppose variable 60 represents a condition, and we want to enforce: if condition is true, then variable 61 must be true, else variable 62 must be true: 103 | 104 | ```python 105 | condition = 60 106 | then_var = 61 107 | else_var = 62 108 | 109 | formula = CNF() 110 | formula.extend(if_then_else(condition, then_var, else_var)) 111 | ``` 112 | 113 | ## Complete Working Example 114 | 115 | Here's a complete example solving a simple scheduling problem: 116 | 117 | ```python 118 | from pysat.formula import CNF 119 | from pysat.solvers import Glucose3 120 | 121 | # Create a formula 122 | formula = CNF() 123 | 124 | # Variables: 125 | # 1, 2, 3 = Courses A, B, C on Monday 126 | # 4, 5, 6 = Courses A, B, C on Tuesday 127 | # 7, 8, 9 = Courses A, B, C on Wednesday 128 | 129 | # Each course must be scheduled exactly once 130 | formula.extend(exactly_one([1, 4, 7])) # Course A on exactly one day 131 | formula.extend(exactly_one([2, 5, 8])) # Course B on exactly one day 132 | formula.extend(exactly_one([3, 6, 9])) # Course C on exactly one day 133 | 134 | # At most 2 courses per day 135 | formula.extend(at_most_k([1, 2, 3], 2)) # At most 2 courses on Monday 136 | formula.extend(at_most_k([4, 5, 6], 2)) # At most 2 courses on Tuesday 137 | formula.extend(at_most_k([7, 8, 9], 2)) # At most 2 courses on Wednesday 138 | 139 | # Dependency: If Course A is on Monday, Course B cannot be on Monday 140 | formula.extend(implies(1, -2)) 141 | 142 | # Solve the formula 143 | with Glucose3(bootstrap_with=formula) as solver: 144 | result = solver.solve() 145 | if result: 146 | model = solver.get_model() 147 | print("Solution found!") 148 | 149 | # Extract the schedule 150 | days = ["Monday", "Tuesday", "Wednesday"] 151 | courses = ["A", "B", "C"] 152 | 153 | for day_idx, day_vars in enumerate([[1, 2, 3], [4, 5, 6], [7, 8, 9]]): 154 | day_courses = [] 155 | for course_idx, var in enumerate(day_vars): 156 | if var in model: # If variable is true (positive in model) 157 | day_courses.append(courses[course_idx]) 158 | 159 | print(f"{days[day_idx]}: {', '.join(day_courses) if day_courses else 'No courses'}") 160 | else: 161 | print("No solution exists!") 162 | 163 | # Export the solution 164 | export_solution({ 165 | "satisfiable": result, 166 | "model": model if result else None, 167 | "schedule": {day: courses for day, courses in zip(["Monday", "Tuesday", "Wednesday"], 168 | [["A"] if 1 in model else [] + ["B"] if 2 in model else [] + ["C"] if 3 in model else [], 169 | ["A"] if 4 in model else [] + ["B"] if 5 in model else [] + ["C"] if 6 in model else [], 170 | ["A"] if 7 in model else [] + ["B"] if 8 in model else [] + ["C"] if 9 in model else []])} 171 | }) 172 | ``` 173 | 174 | ## Why Use These Helper Functions? 175 | 176 | 1. **Always Work**: Unlike some PySAT native encodings, these helpers work for any valid k value 177 | 2. **Optimization**: Automatically use the most efficient encoding for special cases (like k=1) 178 | 3. **Simplicity**: Easy-to-understand function names and parameters 179 | 4. **No Exceptions**: You won't encounter UnsupportedBound exceptions 180 | 181 | ## Advanced Use Cases 182 | 183 | For more complex constraints or when performance is critical, you can also use the direct PySAT encodings: 184 | 185 | ```python 186 | from pysat.card import CardEnc, EncType 187 | 188 | # Using direct PySAT CardEnc (note: some encodings have limitations on k values) 189 | atmost_constr = CardEnc.atmost(lits=[1, 2, 3, 4], bound=2, encoding=EncType.seqcounter) 190 | formula.extend(atmost_constr.clauses) 191 | ``` 192 | 193 | However, our helper functions are recommended for most use cases as they're simpler and more reliable. -------------------------------------------------------------------------------- /src/mcp_solver/pysat/test_setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Test script for verifying the PySAT mode installation of MCP-Solver. 4 | This script checks: 5 | 1. Required configuration files for PySAT mode 6 | 2. PySAT dependencies 7 | 3. Basic SAT solver functionality 8 | 9 | Note: This script only tests PySAT mode functionality. 10 | For core MiniZinc testing, use the main test-setup script. 11 | """ 12 | 13 | import sys 14 | from pathlib import Path 15 | 16 | # Import our centralized prompt loader 17 | from mcp_solver.core.prompt_loader import load_prompt 18 | 19 | 20 | class PySATSetupTest: 21 | def __init__(self): 22 | self.successes: list[tuple[str, str]] = [] # (test_name, details) 23 | self.failures: list[tuple[str, str]] = [] # (test_name, error_details) 24 | self.base_dir = Path(__file__).resolve().parents[3] 25 | self.GREEN = "\033[92m" 26 | self.RED = "\033[91m" 27 | self.RESET = "\033[0m" 28 | self.BOLD = "\033[1m" 29 | 30 | def print_result(self, test_name: str, success: bool, details: str | None = None): 31 | """Print a test result with color and proper formatting.""" 32 | mark = "✓" if success else "✗" 33 | color = self.GREEN if success else self.RED 34 | print(f"{color}{mark} {test_name}{self.RESET}") 35 | if details: 36 | print(f" └─ {details}") 37 | 38 | def record_test(self, test_name: str, success: bool, details: str | None = None): 39 | """Record a test result and print it.""" 40 | if success: 41 | self.successes.append((test_name, details if details else "")) 42 | else: 43 | self.failures.append((test_name, details if details else "Test failed")) 44 | self.print_result(test_name, success, None if success else details) 45 | 46 | def check_file(self, file_name: str) -> bool: 47 | """Check if a file exists in the base directory.""" 48 | file_path = self.base_dir / file_name 49 | return file_path.exists() 50 | 51 | def test_configuration_files(self): 52 | """Test for the presence of required configuration files.""" 53 | print(f"\n{self.BOLD}Configuration Files:{self.RESET}") 54 | 55 | # Test prompt files using the centralized prompt loader 56 | prompts_to_test = [("pysat", "instructions"), ("pysat", "review")] 57 | 58 | for mode, prompt_type in prompts_to_test: 59 | try: 60 | # Attempt to load the prompt using the prompt loader 61 | content = load_prompt(mode, prompt_type) 62 | self.record_test( 63 | f"Prompt file: {mode}/{prompt_type}.md", 64 | True, 65 | f"Successfully loaded ({len(content)} characters)", 66 | ) 67 | except FileNotFoundError: 68 | self.record_test( 69 | f"Prompt file: {mode}/{prompt_type}.md", 70 | False, 71 | "Prompt file not found", 72 | ) 73 | except Exception as e: 74 | self.record_test( 75 | f"Prompt file: {mode}/{prompt_type}.md", 76 | False, 77 | f"Error loading prompt: {e!s}", 78 | ) 79 | 80 | # Test other required files 81 | other_files = [("pyproject.toml", True)] 82 | for file, required in other_files: 83 | exists = self.check_file(file) 84 | if required: 85 | self.record_test( 86 | f"Configuration file: {file}", 87 | exists, 88 | ( 89 | None 90 | if exists 91 | else f"Required file not found at {self.base_dir / file}" 92 | ), 93 | ) 94 | 95 | def test_pysat_dependencies(self): 96 | """Test PySAT installation and dependencies.""" 97 | print(f"\n{self.BOLD}PySAT Dependencies:{self.RESET}") 98 | 99 | # Test python-sat package 100 | try: 101 | import pysat 102 | from pysat.formula import CNF 103 | from pysat.solvers import Solver 104 | 105 | self.record_test( 106 | "python-sat package", True, f"Found version {pysat.__version__}" 107 | ) 108 | except ImportError as e: 109 | self.record_test( 110 | "python-sat package", 111 | False, 112 | f"Error importing pysat: {e!s}\nPlease install with: pip install python-sat", 113 | ) 114 | return 115 | 116 | # Test available solvers 117 | try: 118 | from pysat.solvers import Glucose3 119 | 120 | solver = Glucose3() 121 | solver.delete() # Clean up 122 | self.record_test( 123 | "SAT solver (Glucose3)", True, "Successfully initialized solver" 124 | ) 125 | except Exception as e: 126 | self.record_test( 127 | "SAT solver (Glucose3)", False, f"Error initializing solver: {e!s}" 128 | ) 129 | 130 | def test_basic_functionality(self): 131 | """Test basic SAT solving functionality.""" 132 | print(f"\n{self.BOLD}Basic Functionality:{self.RESET}") 133 | 134 | # Simple SAT problem: (x1 OR x2) AND (NOT x1 OR x2) 135 | try: 136 | from pysat.formula import CNF 137 | from pysat.solvers import Solver 138 | 139 | cnf = CNF() 140 | cnf.append([1, 2]) # x1 OR x2 141 | cnf.append([-1, 2]) # NOT x1 OR x2 142 | 143 | self.record_test("CNF creation", True, "Successfully created CNF formula") 144 | except Exception as e: 145 | self.record_test("CNF creation", False, f"Error creating CNF: {e!s}") 146 | return 147 | 148 | # Test solver initialization and basic solving 149 | try: 150 | solver = Solver(bootstrap_with=cnf) 151 | is_sat = solver.solve() 152 | solver.delete() # Clean up 153 | 154 | self.record_test( 155 | "Solver initialization", True, "Successfully initialized and ran solver" 156 | ) 157 | except Exception as e: 158 | self.record_test( 159 | "Solver initialization", False, f"Error with solver: {e!s}" 160 | ) 161 | 162 | def run_all_tests(self): 163 | """Run all setup tests and display results.""" 164 | print(f"{self.BOLD}=== MCP-Solver PySAT Mode Setup Test ==={self.RESET}") 165 | 166 | self.test_configuration_files() 167 | self.test_pysat_dependencies() 168 | self.test_basic_functionality() 169 | 170 | print(f"\n{self.BOLD}=== Test Summary ==={self.RESET}") 171 | print(f"Passed: {len(self.successes)}") 172 | print(f"Failed: {len(self.failures)}") 173 | 174 | if self.failures: 175 | print(f"\n{self.BOLD}Failed Tests:{self.RESET}") 176 | for test, details in self.failures: 177 | print(f"\n{self.RED}✗ {test}{self.RESET}") 178 | print(f" └─ {details}") 179 | print( 180 | "\nPySAT mode setup incomplete. Please fix the issues above before proceeding." 181 | ) 182 | sys.exit(1) 183 | else: 184 | print( 185 | f"\n{self.GREEN}✓ All PySAT mode tests passed successfully!{self.RESET}" 186 | ) 187 | print("\nSystem is ready to use MCP-Solver in PySAT mode.") 188 | sys.exit(0) 189 | 190 | 191 | def main(): 192 | test = PySATSetupTest() 193 | test.run_all_tests() 194 | 195 | 196 | if __name__ == "__main__": 197 | main() 198 | -------------------------------------------------------------------------------- /src/mcp_solver/solution.py: -------------------------------------------------------------------------------- 1 | """ 2 | Solution module for convenience imports. 3 | 4 | This module simplifies imports by re-exporting the export_maxsat_solution function 5 | from the maxsat.solution module, making it available with a simpler import path. 6 | """ 7 | 8 | # Re-export the export_maxsat_solution function from the maxsat.solution module 9 | # This allows users to import it directly with "from solution import export_maxsat_solution" 10 | try: 11 | from .maxsat.solution import export_maxsat_solution 12 | except ImportError: 13 | # Define a placeholder function in case the import fails 14 | def export_maxsat_solution(*args, **kwargs): 15 | """ 16 | Placeholder function when the real implementation is not available. 17 | This should never be called in normal operation. 18 | """ 19 | raise NotImplementedError( 20 | "export_maxsat_solution is not available. " 21 | "This might indicate that the MaxSAT mode is not properly installed or initialized." 22 | ) 23 | -------------------------------------------------------------------------------- /src/mcp_solver/z3/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Z3 integration for MCP Solver. 3 | """ 4 | 5 | # Export the solution module functions 6 | from .solution import _LAST_SOLUTION, export_solution 7 | 8 | 9 | __all__ = ["_LAST_SOLUTION", "export_solution"] 10 | -------------------------------------------------------------------------------- /src/mcp_solver/z3/solution.py: -------------------------------------------------------------------------------- 1 | """ 2 | Z3 solution module for extracting and formatting solutions from Z3 solvers. 3 | 4 | This module provides functions for extracting solution data from Z3 solvers 5 | and converting it to a standardized format. 6 | """ 7 | 8 | import os 9 | import sys 10 | from typing import Any 11 | 12 | 13 | # IMPORTANT: Properly import the Z3 library (not our local package) 14 | # First, remove the current directory from the path to avoid importing ourselves 15 | current_dir = os.path.abspath(os.path.dirname(__file__)) 16 | parent_dir = os.path.abspath(os.path.join(current_dir, "..")) 17 | if current_dir in sys.path: 18 | sys.path.remove(current_dir) 19 | if parent_dir in sys.path: 20 | sys.path.remove(parent_dir) 21 | 22 | # Add site-packages to the front of the path 23 | import site 24 | 25 | 26 | site_packages = site.getsitepackages() 27 | for p in reversed(site_packages): 28 | if p not in sys.path: 29 | sys.path.insert(0, p) 30 | 31 | # Now try to import Z3 32 | try: 33 | import z3 34 | except ImportError: 35 | print("Z3 solver not found. Install with: pip install z3-solver>=4.12.1") 36 | sys.exit(1) 37 | 38 | # Track the last solution 39 | _LAST_SOLUTION = None 40 | 41 | 42 | def _extract_variable_values(model, variables: dict[str, Any]) -> dict[str, Any]: 43 | """ 44 | Helper function to extract variable values from a Z3 model. 45 | 46 | Args: 47 | model: Z3 model 48 | variables: Dictionary mapping variable names to Z3 variables 49 | 50 | Returns: 51 | Dictionary with variable names and their values 52 | """ 53 | result = {} 54 | 55 | if not model or not variables: 56 | return result 57 | 58 | for name, var in variables.items(): 59 | try: 60 | # Try basic model evaluation 61 | val = model.eval(var, model_completion=True) 62 | if val is not None: 63 | # Convert to appropriate Python type 64 | if z3.is_int(val): 65 | result[name] = val.as_long() 66 | elif z3.is_real(val): 67 | # Convert rational to float 68 | result[name] = float(val.as_decimal(10)) 69 | elif z3.is_bool(val): 70 | result[name] = z3.is_true(val) 71 | elif hasattr(z3, "is_string") and z3.is_string(val): 72 | result[name] = str(val) 73 | else: 74 | # For other types, convert to string 75 | result[name] = str(val) 76 | except Exception as e: 77 | print(f"Error extracting value for {name}: {e}") 78 | result[name] = f"Error: {e!s}" 79 | 80 | return result 81 | 82 | 83 | def _extract_objective_value(model, objective): 84 | """ 85 | Helper function to extract objective value from a Z3 model. 86 | 87 | Args: 88 | model: Z3 model 89 | objective: Z3 expression being optimized 90 | 91 | Returns: 92 | Objective value in appropriate Python type or None if error 93 | """ 94 | if not model or objective is None: 95 | return None 96 | 97 | try: 98 | obj_val = model.eval(objective, model_completion=True) 99 | if z3.is_int(obj_val): 100 | return obj_val.as_long() 101 | elif z3.is_real(obj_val): 102 | return float(obj_val.as_decimal(10)) 103 | else: 104 | return str(obj_val) 105 | except Exception as e: 106 | print(f"Error extracting objective value: {e}") 107 | return f"Error: {e!s}" 108 | 109 | 110 | def export_solution( 111 | solver=None, 112 | variables=None, 113 | objective=None, 114 | satisfiable=None, 115 | solution_dict=None, 116 | is_property_verification=False, 117 | property_verified=None, 118 | ) -> dict[str, Any]: 119 | """ 120 | Extract and format solutions from a Z3 solver. 121 | 122 | This function collects and standardizes solution data from Z3 solvers. 123 | It should be called at the end of Z3 code to export the solution. 124 | 125 | Args: 126 | solver: Z3 Solver or Optimize object 127 | variables: Dictionary mapping variable names to Z3 variables 128 | objective: Z3 expression being optimized (optional) 129 | satisfiable: Explicitly override the satisfiability status (optional) 130 | solution_dict: Directly provide a solution dictionary (optional) 131 | is_property_verification: Flag indicating if this is a property verification problem (optional) 132 | property_verified: Boolean indicating if the property was verified (optional) 133 | 134 | Returns: 135 | Dictionary containing the solution details 136 | """ 137 | global _LAST_SOLUTION 138 | 139 | # Initialize result dictionary 140 | result = { 141 | "satisfiable": False, 142 | "values": {}, 143 | "objective": None, 144 | "status": "unknown", 145 | "output": [], 146 | } 147 | 148 | # If solution_dict is provided, use it as the base 149 | if solution_dict is not None and isinstance(solution_dict, dict): 150 | result.update(solution_dict) 151 | # Ensure result has all required fields 152 | result.setdefault("satisfiable", False) 153 | result.setdefault("values", {}) 154 | result.setdefault("objective", None) 155 | result.setdefault("status", "unknown") 156 | result.setdefault("output", []) 157 | 158 | # Update status if satisfiable flag has been set 159 | if "satisfiable" in solution_dict: 160 | result["status"] = "sat" if solution_dict["satisfiable"] else "unsat" 161 | 162 | # Store result and return 163 | _LAST_SOLUTION = result 164 | return result 165 | 166 | # Process the solver if provided 167 | if solver is not None: 168 | # Get the solver status 169 | if isinstance(solver, z3.Solver) or isinstance(solver, z3.Optimize): 170 | status = solver.check() 171 | result["status"] = str(status) 172 | 173 | # Extract solution if satisfiable 174 | if status == z3.sat: 175 | result["satisfiable"] = True 176 | 177 | if variables is not None: 178 | model = solver.model() 179 | # Extract variable values 180 | result["values"] = _extract_variable_values(model, variables) 181 | 182 | # Extract objective value if present 183 | if objective is not None and isinstance(solver, z3.Optimize): 184 | result["objective"] = _extract_objective_value(model, objective) 185 | else: 186 | print(f"Warning: Unknown solver type: {type(solver)}") 187 | # Handle case with variables but no solver 188 | elif variables is not None: 189 | print( 190 | "Warning: Variables provided but no solver. Only variable names will be included." 191 | ) 192 | result["values"] = {name: None for name in variables} 193 | 194 | # Override satisfiability if explicitly provided 195 | if satisfiable is not None: 196 | result["satisfiable"] = bool(satisfiable) 197 | # Update status to match satisfiability flag 198 | result["status"] = "sat" if result["satisfiable"] else "unsat" 199 | 200 | # Handle property verification cases 201 | if is_property_verification: 202 | # If property_verified is explicitly provided, use it 203 | if property_verified is not None: 204 | # Store the property verification result explicitly 205 | result["values"]["property_verified"] = bool(property_verified) 206 | 207 | # For property verification: 208 | # - If we found a counterexample (property not verified), the solver is satisfiable 209 | # - If the property is verified for all cases, there's no counterexample, so the negation is unsatisfiable 210 | if property_verified: 211 | result["output"].append("Property verified successfully.") 212 | else: 213 | result["output"].append( 214 | "Property verification failed. Counterexample found." 215 | ) 216 | # Ensure satisfiability is set correctly for counterexample 217 | result["satisfiable"] = True 218 | result["status"] = "sat" 219 | else: 220 | # Infer property verification status from solver result 221 | # If solver returned unsat, property is verified (no counterexample) 222 | # If solver returned sat, property is not verified (counterexample found) 223 | property_verified = result["status"] == "unsat" 224 | result["values"]["property_verified"] = property_verified 225 | 226 | if property_verified: 227 | result["output"].append("Property verified successfully.") 228 | else: 229 | result["output"].append( 230 | "Property verification failed. Counterexample found." 231 | ) 232 | # Ensure satisfiability is set correctly for counterexample 233 | result["satisfiable"] = True 234 | result["status"] = "sat" 235 | else: 236 | # For regular constraint solving, add appropriate output messages 237 | if result["satisfiable"]: 238 | result["output"].append("Solution found.") 239 | else: 240 | result["output"].append( 241 | "No solution exists that satisfies all constraints." 242 | ) 243 | 244 | # Store result in global variable for the environment to find 245 | _LAST_SOLUTION = result 246 | 247 | return result 248 | -------------------------------------------------------------------------------- /src/mcp_solver/z3/templates/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Template functions for Z3 quantifiers and common constraint patterns. 3 | 4 | This module provides pre-built patterns for common Z3 quantifier use cases, 5 | making it easier for users to express complex constraints without needing to 6 | write detailed quantifier logic. 7 | """ 8 | 9 | # Import and expose quantifier templates 10 | # Import and expose function templates 11 | from .function_templates import ( 12 | array_template, 13 | constraint_satisfaction_template, 14 | demo_template, 15 | optimization_template, 16 | quantifier_template, 17 | ) 18 | 19 | # Import and expose subset templates 20 | from .subset_templates import smallest_subset_with_property 21 | from .z3_templates import ( 22 | all_distinct, 23 | array_contains, 24 | array_is_sorted, 25 | at_least_k, 26 | at_most_k, 27 | exactly_k, 28 | function_is_injective, 29 | function_is_surjective, 30 | ) 31 | 32 | 33 | __all__ = [ 34 | # Quantifier templates 35 | "array_is_sorted", 36 | "all_distinct", 37 | "array_contains", 38 | "exactly_k", 39 | "at_most_k", 40 | "at_least_k", 41 | "function_is_injective", 42 | "function_is_surjective", 43 | # Function templates 44 | "constraint_satisfaction_template", 45 | "optimization_template", 46 | "array_template", 47 | "quantifier_template", 48 | "demo_template", 49 | # Subset templates 50 | "smallest_subset_with_property", 51 | ] 52 | -------------------------------------------------------------------------------- /src/mcp_solver/z3/templates/function_templates.py: -------------------------------------------------------------------------------- 1 | """ 2 | Core function templates for Z3 models. 3 | 4 | This module provides standardized function templates for common Z3 modeling patterns. 5 | These templates help address common issues like variable scope problems, import availability, 6 | and structural inconsistency by encouraging a function-based approach where all model 7 | components are encapsulated within a single function. 8 | """ 9 | 10 | import os 11 | import sys 12 | 13 | 14 | # IMPORTANT: Properly import the Z3 library (not our local package) 15 | # First, remove the current directory from the path to avoid importing ourselves 16 | current_dir = os.path.abspath(os.path.dirname(__file__)) 17 | parent_dir = os.path.abspath(os.path.join(current_dir, "..")) 18 | parent_parent_dir = os.path.abspath(os.path.join(parent_dir, "..")) 19 | if current_dir in sys.path: 20 | sys.path.remove(current_dir) 21 | if parent_dir in sys.path: 22 | sys.path.remove(parent_dir) 23 | if parent_parent_dir in sys.path: 24 | sys.path.remove(parent_parent_dir) 25 | 26 | # Add site-packages to the front of the path 27 | import site 28 | 29 | 30 | site_packages = site.getsitepackages() 31 | for p in reversed(site_packages): 32 | if p not in sys.path: 33 | sys.path.insert(0, p) 34 | 35 | # Now try to import Z3 36 | try: 37 | from z3 import ( 38 | And, 39 | Array, 40 | ArrayRef, 41 | BitVec, 42 | BitVecSort, 43 | Bool, 44 | BoolRef, 45 | BoolSort, 46 | Distinct, 47 | Exists, 48 | ExprRef, 49 | ForAll, 50 | If, 51 | Implies, 52 | Int, 53 | Ints, 54 | IntSort, 55 | Not, 56 | Optimize, 57 | Or, 58 | Real, 59 | RealSort, 60 | Solver, 61 | Sum, 62 | sat, 63 | unknown, 64 | unsat, 65 | ) 66 | except ImportError: 67 | print("Z3 solver not found. Install with: pip install z3-solver>=4.12.1") 68 | sys.exit(1) 69 | 70 | 71 | # Template for basic constraint satisfaction problems 72 | def constraint_satisfaction_template(): 73 | """ 74 | A template for basic constraint satisfaction problems. 75 | 76 | This template follows the recommended structure: 77 | 1. Variable definition 78 | 2. Solver creation 79 | 3. Constraints definition 80 | 4. Solution export 81 | 82 | Returns: 83 | A tuple containing: 84 | - The solver object 85 | - A dictionary mapping variable names to Z3 variables 86 | 87 | Usage: 88 | solver, variables = constraint_satisfaction_template() 89 | export_solution(solver=solver, variables=variables) 90 | """ 91 | 92 | # [SECTION: VARIABLE DEFINITION] 93 | # Define your variables here 94 | x = Int("x") 95 | y = Int("y") 96 | 97 | # [SECTION: SOLVER CREATION] 98 | # Create solver 99 | s = Solver() 100 | 101 | # [SECTION: CONSTRAINTS] 102 | # Add your constraints here 103 | s.add(x > 0) 104 | s.add(y > 0) 105 | s.add(x + y <= 10) 106 | 107 | # [SECTION: VARIABLES TO EXPORT] 108 | # Define the variables you want to see in the solution 109 | variables = {"x": x, "y": y} 110 | 111 | return s, variables 112 | 113 | 114 | # Template for optimization problems 115 | def optimization_template(): 116 | """ 117 | A template for optimization problems. 118 | 119 | This template follows the recommended structure: 120 | 1. Variable definition 121 | 2. Optimizer creation 122 | 3. Constraints definition 123 | 4. Objective function definition 124 | 5. Solution export 125 | 126 | Returns: 127 | A tuple containing: 128 | - The optimizer object 129 | - A dictionary mapping variable names to Z3 variables 130 | 131 | Usage: 132 | optimizer, variables = optimization_template() 133 | export_solution(solver=optimizer, variables=variables) 134 | """ 135 | 136 | # [SECTION: VARIABLE DEFINITION] 137 | # Define your variables here 138 | x = Int("x") 139 | y = Int("y") 140 | 141 | # [SECTION: OPTIMIZER CREATION] 142 | # Create optimizer 143 | opt = Optimize() 144 | 145 | # [SECTION: CONSTRAINTS] 146 | # Add your constraints here 147 | opt.add(x >= 0) 148 | opt.add(y >= 0) 149 | opt.add(x + y <= 10) 150 | 151 | # [SECTION: OBJECTIVE FUNCTION] 152 | # Define your objective function 153 | objective = x + 2 * y 154 | 155 | # Set optimization direction 156 | opt.maximize(objective) # or opt.minimize(objective) 157 | 158 | # [SECTION: VARIABLES TO EXPORT] 159 | # Define the variables you want to see in the solution 160 | variables = {"x": x, "y": y, "objective_value": objective} 161 | 162 | return opt, variables 163 | 164 | 165 | # Template for array handling 166 | def array_template(): 167 | """ 168 | A template for problems involving arrays. 169 | 170 | This template follows the recommended structure: 171 | 1. Problem size definition 172 | 2. Array variable definition 173 | 3. Solver creation 174 | 4. Array initialization and constraints 175 | 5. Solution export 176 | 177 | Returns: 178 | A tuple containing: 179 | - The solver object 180 | - A dictionary mapping variable names to Z3 variables 181 | 182 | Usage: 183 | solver, variables = array_template() 184 | export_solution(solver=solver, variables=variables) 185 | """ 186 | 187 | # [SECTION: PROBLEM SIZE] 188 | n = 5 # Size of your arrays 189 | 190 | # [SECTION: VARIABLE DEFINITION] 191 | # Define array variables 192 | arr = Array("arr", IntSort(), IntSort()) 193 | 194 | # [SECTION: SOLVER CREATION] 195 | s = Solver() 196 | 197 | # [SECTION: ARRAY INITIALIZATION] 198 | # Initialize array elements (if needed) 199 | for i in range(n): 200 | s.add(arr[i] >= 0) 201 | s.add(arr[i] < 100) 202 | 203 | # [SECTION: CONSTRAINTS] 204 | # Add array-specific constraints 205 | for i in range(n - 1): 206 | s.add(arr[i] <= arr[i + 1]) # Ensure array is sorted 207 | 208 | # [SECTION: VARIABLES TO EXPORT] 209 | # Export array elements 210 | variables = {f"arr[{i}]": arr[i] for i in range(n)} 211 | 212 | return s, variables 213 | 214 | 215 | # Template for problems with quantifiers 216 | def quantifier_template(): 217 | """ 218 | A template for problems involving quantifiers. 219 | 220 | This template follows the recommended structure: 221 | 1. Problem parameters 222 | 2. Variable definition 223 | 3. Solver creation 224 | 4. Domain constraints 225 | 5. Quantifier constraints 226 | 6. Solution export 227 | 228 | Returns: 229 | A tuple containing: 230 | - The solver object 231 | - A dictionary mapping variable names to Z3 variables 232 | 233 | Usage: 234 | solver, variables = quantifier_template() 235 | export_solution(solver=solver, variables=variables) 236 | """ 237 | 238 | # Import Z3 template helpers 239 | try: 240 | from z3_templates import all_distinct, array_is_sorted 241 | except ImportError: 242 | # Fallback if templates not available 243 | pass 244 | 245 | # [SECTION: PROBLEM PARAMETERS] 246 | n = 5 # Problem size 247 | 248 | # [SECTION: VARIABLE DEFINITION] 249 | # Define your array variables 250 | arr = Array("arr", IntSort(), IntSort()) 251 | 252 | # [SECTION: SOLVER CREATION] 253 | s = Solver() 254 | 255 | # [SECTION: DOMAIN CONSTRAINTS] 256 | for i in range(n): 257 | s.add(arr[i] >= 0, arr[i] < n) 258 | 259 | # [SECTION: QUANTIFIER CONSTRAINTS] 260 | # Option 1: Using template functions (if available) 261 | try: 262 | s.add(all_distinct(arr, n)) 263 | s.add(array_is_sorted(arr, n)) 264 | except NameError: 265 | # Option 2: Using explicit quantifiers 266 | i = Int("i") 267 | j = Int("j") 268 | unique_values = ForAll( 269 | [i, j], Implies(And(i >= 0, i < j, j < n), arr[i] != arr[j]) 270 | ) 271 | sorted_values = ForAll( 272 | [i, j], Implies(And(i >= 0, i < j, j < n), arr[i] <= arr[j]) 273 | ) 274 | s.add(unique_values) 275 | s.add(sorted_values) 276 | 277 | # [SECTION: VARIABLES TO EXPORT] 278 | variables = {f"arr[{i}]": arr[i] for i in range(n)} 279 | 280 | return s, variables 281 | 282 | 283 | # Combined demo template with sample usage 284 | def demo_template(): 285 | """ 286 | A demo template showing how to use the function-based template pattern. 287 | This demonstrates a simple constraint satisfaction problem. 288 | 289 | Returns: 290 | A tuple containing: 291 | - The solver object 292 | - A dictionary mapping variable names to Z3 variables 293 | """ 294 | 295 | # [SECTION: IMPORTS] 296 | # Import everything you need at the top of your function 297 | from z3 import Bool, Int, Solver 298 | 299 | # [SECTION: PROBLEM PARAMETERS] 300 | n_items = 5 301 | max_weight = 10 302 | 303 | # [SECTION: VARIABLE DEFINITION] 304 | # Define your variables 305 | weights = [Int(f"weight_{i}") for i in range(n_items)] 306 | selected = [Bool(f"selected_{i}") for i in range(n_items)] 307 | total_weight = Int("total_weight") 308 | 309 | # [SECTION: SOLVER CREATION] 310 | s = Solver() 311 | 312 | # [SECTION: DOMAIN CONSTRAINTS] 313 | # Set up domain constraints 314 | for i in range(n_items): 315 | s.add(weights[i] > 0) 316 | s.add(weights[i] <= max_weight) 317 | 318 | # [SECTION: CORE CONSTRAINTS] 319 | # Define the relationship between selection and weight 320 | weight_sum = 0 321 | for i in range(n_items): 322 | # If selected[i] is true, add weights[i] to total 323 | weight_sum += If(selected[i], weights[i], 0) 324 | 325 | s.add(total_weight == weight_sum) 326 | s.add(total_weight <= max_weight) 327 | 328 | # Must select at least 2 items 329 | s.add(Sum([If(selected[i], 1, 0) for i in range(n_items)]) >= 2) 330 | 331 | # [SECTION: VARIABLES TO EXPORT] 332 | variables = { 333 | "total_weight": total_weight, 334 | **{f"weight_{i}": weights[i] for i in range(n_items)}, 335 | **{f"selected_{i}": selected[i] for i in range(n_items)}, 336 | } 337 | 338 | return s, variables 339 | 340 | 341 | # If this module is run directly, demonstrate its usage 342 | if __name__ == "__main__": 343 | try: 344 | from z3 import export_solution 345 | except ImportError: 346 | # Define a simple export function for testing 347 | def export_solution(solver, variables): 348 | if solver.check() == sat: 349 | model = solver.model() 350 | print("Solution found:") 351 | for name, var in variables.items(): 352 | print(f"{name} = {model.eval(var)}") 353 | else: 354 | print("No solution found") 355 | 356 | print("Running constraint satisfaction template...") 357 | solver, variables = constraint_satisfaction_template() 358 | export_solution(solver=solver, variables=variables) 359 | -------------------------------------------------------------------------------- /src/mcp_solver/z3/templates/subset_templates.py: -------------------------------------------------------------------------------- 1 | """ 2 | Subset-related template functions for Z3. 3 | 4 | This module provides helper functions for finding optimal subsets with specific properties. 5 | """ 6 | 7 | import os 8 | import sys 9 | from collections.abc import Callable 10 | from itertools import combinations 11 | from typing import TypeVar 12 | 13 | 14 | # IMPORTANT: Properly import the Z3 library (not our local package) 15 | # First, remove the current directory from the path to avoid importing ourselves 16 | current_dir = os.path.abspath(os.path.dirname(__file__)) 17 | parent_dir = os.path.abspath(os.path.join(current_dir, "..")) 18 | parent_parent_dir = os.path.abspath(os.path.join(parent_dir, "..")) 19 | if current_dir in sys.path: 20 | sys.path.remove(current_dir) 21 | if parent_dir in sys.path: 22 | sys.path.remove(parent_dir) 23 | if parent_parent_dir in sys.path: 24 | sys.path.remove(parent_parent_dir) 25 | 26 | # Add site-packages to the front of the path 27 | import site 28 | 29 | 30 | site_packages = site.getsitepackages() 31 | for p in reversed(site_packages): 32 | if p not in sys.path: 33 | sys.path.insert(0, p) 34 | 35 | # Now try to import Z3 36 | try: 37 | from z3 import And, Bool, Int, Not, Or, Solver, sat, unsat 38 | except ImportError: 39 | print("Z3 solver not found. Install with: pip install z3-solver>=4.12.1") 40 | sys.exit(1) 41 | 42 | # Type variable for generic item types 43 | T = TypeVar("T") 44 | 45 | 46 | def smallest_subset_with_property( 47 | items: list[T], 48 | property_check_func: Callable[[list[T]], bool], 49 | min_size: int = 1, 50 | max_size: int | None = None, 51 | ) -> list[T] | None: 52 | """ 53 | Find the smallest subset of items that satisfies a given property. 54 | 55 | Args: 56 | items: List of items to consider 57 | property_check_func: Function that takes a list of items and returns True if property is satisfied 58 | min_size: Minimum size of subset to consider (default: 1) 59 | max_size: Maximum size of subset to consider (default: len(items)) 60 | 61 | Returns: 62 | The smallest subset satisfying the property, or None if no such subset exists 63 | 64 | Example: 65 | ```python 66 | # Define a property checker that returns True if tasks cannot all be scheduled 67 | def is_unschedulable(tasks): 68 | s = Solver() 69 | # ... set up scheduling constraints ... 70 | return s.check() == unsat 71 | 72 | 73 | # Find the smallest set of tasks that cannot be scheduled together 74 | result = smallest_subset_with_property(all_tasks, is_unschedulable, min_size=2) 75 | ``` 76 | """ 77 | if max_size is None: 78 | max_size = len(items) 79 | 80 | # Optional optimization: Check candidate subsets first if provided 81 | if hasattr(property_check_func, "candidate_subsets"): 82 | for subset in property_check_func.candidate_subsets: 83 | if min_size <= len(subset) <= max_size and property_check_func(subset): 84 | # Found a candidate that works, now minimize it 85 | return _minimize_subset(subset, property_check_func) 86 | 87 | # Start with the smallest possible size and increase 88 | for size in range(min_size, max_size + 1): 89 | print(f"Checking subsets of size {size}...") 90 | 91 | # Check all subsets of this size 92 | for subset in combinations(items, size): 93 | subset = list(subset) # Convert to list for consistency 94 | 95 | if property_check_func(subset): 96 | return subset 97 | 98 | return None 99 | 100 | 101 | def _minimize_subset( 102 | subset: list[T], property_check_func: Callable[[list[T]], bool] 103 | ) -> list[T]: 104 | """Helper function to minimize a subset while maintaining the property""" 105 | minimal = subset.copy() 106 | 107 | # Try removing each element to see if property still holds 108 | for item in subset: 109 | smaller = [x for x in minimal if x != item] 110 | if smaller and property_check_func(smaller): 111 | # If property still holds with item removed, recursively minimize further 112 | return _minimize_subset(smaller, property_check_func) 113 | 114 | return minimal 115 | -------------------------------------------------------------------------------- /src/mcp_solver/z3/templates/z3_templates.py: -------------------------------------------------------------------------------- 1 | """ 2 | Z3 template functions for common quantifier patterns and constraints. 3 | 4 | This module provides helper functions that generate common constraint patterns, 5 | especially those involving quantifiers, which can be difficult to write correctly. 6 | """ 7 | 8 | import os 9 | import sys 10 | from typing import Any 11 | 12 | 13 | # IMPORTANT: Properly import the Z3 library (not our local package) 14 | # First, remove the current directory from the path to avoid importing ourselves 15 | current_dir = os.path.abspath(os.path.dirname(__file__)) 16 | parent_dir = os.path.abspath(os.path.join(current_dir, "..")) 17 | parent_parent_dir = os.path.abspath(os.path.join(parent_dir, "..")) 18 | if current_dir in sys.path: 19 | sys.path.remove(current_dir) 20 | if parent_dir in sys.path: 21 | sys.path.remove(parent_dir) 22 | if parent_parent_dir in sys.path: 23 | sys.path.remove(parent_parent_dir) 24 | 25 | # Add site-packages to the front of the path 26 | import site 27 | 28 | 29 | site_packages = site.getsitepackages() 30 | for p in reversed(site_packages): 31 | if p not in sys.path: 32 | sys.path.insert(0, p) 33 | 34 | # Now try to import Z3 35 | try: 36 | from z3 import ( 37 | And, 38 | ArrayRef, 39 | BoolRef, 40 | Exists, 41 | ExprRef, 42 | ForAll, 43 | Implies, 44 | Int, 45 | Ints, 46 | Not, 47 | Or, 48 | PbEq, 49 | PbGe, 50 | PbLe, 51 | ) 52 | except ImportError: 53 | print("Z3 solver not found. Install with: pip install z3-solver>=4.12.1") 54 | sys.exit(1) 55 | 56 | 57 | # Array and sequence properties 58 | def array_is_sorted( 59 | arr: ArrayRef, size: int | ExprRef, strict: bool = False 60 | ) -> BoolRef: 61 | """ 62 | Create a constraint ensuring array is sorted in ascending order. 63 | 64 | Args: 65 | arr: Z3 array representing the sequence 66 | size: Size of the array (int or Z3 expression) 67 | strict: If True, use strict inequality (< instead of <=) 68 | 69 | Returns: 70 | Z3 constraint expression 71 | """ 72 | i, j = Ints("_i _j") 73 | if strict: 74 | return ForAll([i, j], Implies(And(i >= 0, i < j, j < size), arr[i] < arr[j])) 75 | else: 76 | return ForAll([i, j], Implies(And(i >= 0, i < j, j < size), arr[i] <= arr[j])) 77 | 78 | 79 | def all_distinct(arr: ArrayRef, size: int | ExprRef) -> BoolRef: 80 | """ 81 | Create a constraint ensuring all elements in array are distinct. 82 | 83 | Args: 84 | arr: Z3 array representing the sequence 85 | size: Size of the array (int or Z3 expression) 86 | 87 | Returns: 88 | Z3 constraint expression 89 | """ 90 | i, j = Ints("_i _j") 91 | return ForAll([i, j], Implies(And(i >= 0, i < j, j < size), arr[i] != arr[j])) 92 | 93 | 94 | def array_contains(arr: ArrayRef, size: int | ExprRef, value: Any) -> BoolRef: 95 | """ 96 | Create a constraint ensuring array contains a specific value. 97 | 98 | Args: 99 | arr: Z3 array representing the sequence 100 | size: Size of the array (int or Z3 expression) 101 | value: Value that must be present in the array 102 | 103 | Returns: 104 | Z3 constraint expression 105 | """ 106 | i = Int("_i") 107 | return Exists([i], And(i >= 0, i < size, arr[i] == value)) 108 | 109 | 110 | # Cardinality constraints 111 | def exactly_k(bool_vars: list[BoolRef], k: int | ExprRef) -> BoolRef: 112 | """ 113 | Create a constraint ensuring exactly k boolean variables are true. 114 | 115 | Args: 116 | bool_vars: List of boolean variables 117 | k: Required number of true variables 118 | 119 | Returns: 120 | Z3 constraint expression 121 | """ 122 | return PbEq([(v, 1) for v in bool_vars], k) 123 | 124 | 125 | def at_most_k(bool_vars: list[BoolRef], k: int | ExprRef) -> BoolRef: 126 | """ 127 | Create a constraint ensuring at most k boolean variables are true. 128 | 129 | Args: 130 | bool_vars: List of boolean variables 131 | k: Maximum number of true variables 132 | 133 | Returns: 134 | Z3 constraint expression 135 | """ 136 | return PbLe([(v, 1) for v in bool_vars], k) 137 | 138 | 139 | def at_least_k(bool_vars: list[BoolRef], k: int | ExprRef) -> BoolRef: 140 | """ 141 | Create a constraint ensuring at least k boolean variables are true. 142 | 143 | Args: 144 | bool_vars: List of boolean variables 145 | k: Minimum number of true variables 146 | 147 | Returns: 148 | Z3 constraint expression 149 | """ 150 | return PbGe([(v, 1) for v in bool_vars], k) 151 | 152 | 153 | # Functional properties 154 | def function_is_injective( 155 | func: ArrayRef, 156 | domain_size: int | ExprRef, 157 | range_size: int | ExprRef = None, 158 | ) -> BoolRef: 159 | """ 160 | Create a constraint ensuring a function is injective (one-to-one). 161 | 162 | Args: 163 | func: Z3 array representing the function mapping 164 | domain_size: Size of the domain (int or Z3 expression) 165 | range_size: Size of the range (optional, not used in constraint) 166 | 167 | Returns: 168 | Z3 constraint expression 169 | """ 170 | i, j = Ints("_i _j") 171 | return ForAll( 172 | [i, j], 173 | Implies( 174 | And(i >= 0, i < domain_size, j >= 0, j < domain_size, i != j), 175 | func[i] != func[j], 176 | ), 177 | ) 178 | 179 | 180 | def function_is_surjective( 181 | func: ArrayRef, domain_size: int | ExprRef, range_size: int | ExprRef 182 | ) -> BoolRef: 183 | """ 184 | Create a constraint ensuring a function is surjective (onto). 185 | 186 | Args: 187 | func: Z3 array representing the function mapping 188 | domain_size: Size of the domain 189 | range_size: Size of the range 190 | 191 | Returns: 192 | Z3 constraint expression 193 | """ 194 | j = Int("_j") 195 | i = Int("_i") 196 | return ForAll( 197 | [j], 198 | Implies( 199 | And(j >= 0, j < range_size), 200 | Exists([i], And(i >= 0, i < domain_size, func[i] == j)), 201 | ), 202 | ) 203 | -------------------------------------------------------------------------------- /src/mcp_solver/z3/test_setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Test script for verifying the Z3 mode installation of MCP-Solver. 4 | This script checks: 5 | 1. Required configuration files for Z3 mode 6 | 2. Z3 dependencies 7 | 3. Basic SMT solver functionality 8 | 9 | Note: This script only tests Z3 mode functionality. 10 | For core MiniZinc testing, use the main test-setup script. 11 | """ 12 | 13 | import sys 14 | from pathlib import Path 15 | 16 | # Import our centralized prompt loader 17 | from mcp_solver.core.prompt_loader import load_prompt 18 | 19 | 20 | class Z3SetupTest: 21 | def __init__(self): 22 | self.successes: list[tuple[str, str]] = [] # (test_name, details) 23 | self.failures: list[tuple[str, str]] = [] # (test_name, error_details) 24 | self.base_dir = Path(__file__).resolve().parents[3] 25 | self.GREEN = "\033[92m" 26 | self.RED = "\033[91m" 27 | self.RESET = "\033[0m" 28 | self.BOLD = "\033[1m" 29 | 30 | def print_result(self, test_name: str, success: bool, details: str | None = None): 31 | """Print a test result with color and proper formatting.""" 32 | mark = "✓" if success else "✗" 33 | color = self.GREEN if success else self.RED 34 | print(f"{color}{mark} {test_name}{self.RESET}") 35 | if details: 36 | print(f" └─ {details}") 37 | 38 | def record_test(self, test_name: str, success: bool, details: str | None = None): 39 | """Record a test result and print it.""" 40 | if success: 41 | self.successes.append((test_name, details if details else "")) 42 | else: 43 | self.failures.append((test_name, details if details else "Test failed")) 44 | self.print_result(test_name, success, None if success else details) 45 | 46 | def check_file(self, file_name: str) -> bool: 47 | """Check if a file exists in the base directory.""" 48 | file_path = self.base_dir / file_name 49 | return file_path.exists() 50 | 51 | def test_configuration_files(self): 52 | """Test for the presence of required configuration files.""" 53 | print(f"\n{self.BOLD}Configuration Files:{self.RESET}") 54 | 55 | # Test prompt files using the centralized prompt loader 56 | prompts_to_test = [("z3", "instructions"), ("z3", "review")] 57 | 58 | for mode, prompt_type in prompts_to_test: 59 | try: 60 | # Attempt to load the prompt using the prompt loader 61 | content = load_prompt(mode, prompt_type) 62 | self.record_test( 63 | f"Prompt file: {mode}/{prompt_type}.md", 64 | True, 65 | f"Successfully loaded ({len(content)} characters)", 66 | ) 67 | except FileNotFoundError: 68 | self.record_test( 69 | f"Prompt file: {mode}/{prompt_type}.md", 70 | False, 71 | "Prompt file not found", 72 | ) 73 | except Exception as e: 74 | self.record_test( 75 | f"Prompt file: {mode}/{prompt_type}.md", 76 | False, 77 | f"Error loading prompt: {e!s}", 78 | ) 79 | 80 | # Test other required files 81 | other_files = [("pyproject.toml", True)] 82 | for file, required in other_files: 83 | exists = self.check_file(file) 84 | if required: 85 | self.record_test( 86 | f"Configuration file: {file}", 87 | exists, 88 | ( 89 | None 90 | if exists 91 | else f"Required file not found at {self.base_dir / file}" 92 | ), 93 | ) 94 | 95 | def test_z3_dependencies(self): 96 | """Test Z3 installation and dependencies.""" 97 | print(f"\n{self.BOLD}Z3 Dependencies:{self.RESET}") 98 | 99 | # Test z3-solver package 100 | try: 101 | import z3 102 | 103 | self.record_test( 104 | "z3-solver package", True, f"Found version {z3.get_version_string()}" 105 | ) 106 | except ImportError as e: 107 | self.record_test( 108 | "z3-solver package", 109 | False, 110 | f"Error importing z3: {e!s}\nPlease install with: pip install z3-solver", 111 | ) 112 | return 113 | 114 | # Test solver initialization 115 | try: 116 | solver = z3.Solver() 117 | self.record_test( 118 | "Z3 solver initialization", True, "Successfully initialized solver" 119 | ) 120 | except Exception as e: 121 | self.record_test( 122 | "Z3 solver initialization", 123 | False, 124 | f"Error initializing solver: {e!s}", 125 | ) 126 | 127 | def test_basic_functionality(self): 128 | """Test basic SMT solving functionality.""" 129 | print(f"\n{self.BOLD}Basic Functionality:{self.RESET}") 130 | 131 | # Simple SMT problem: x + y = 2, x > 0, y > 0 132 | try: 133 | import z3 134 | 135 | x = z3.Real("x") 136 | y = z3.Real("y") 137 | solver = z3.Solver() 138 | 139 | solver.add(x + y == 2) 140 | solver.add(x > 0) 141 | solver.add(y > 0) 142 | 143 | self.record_test( 144 | "Constraint creation", True, "Successfully created constraints" 145 | ) 146 | except Exception as e: 147 | self.record_test( 148 | "Constraint creation", False, f"Error creating constraints: {e!s}" 149 | ) 150 | return 151 | 152 | # Test solving 153 | try: 154 | result = solver.check() 155 | if result == z3.sat: 156 | model = solver.model() 157 | self.record_test( 158 | "Solver execution", True, "Successfully solved constraints" 159 | ) 160 | else: 161 | self.record_test( 162 | "Solver execution", False, f"Unexpected result: {result}" 163 | ) 164 | except Exception as e: 165 | self.record_test("Solver execution", False, f"Error during solving: {e!s}") 166 | 167 | def run_all_tests(self): 168 | """Run all setup tests and display results.""" 169 | print(f"{self.BOLD}=== MCP-Solver Z3 Mode Setup Test ==={self.RESET}") 170 | 171 | self.test_configuration_files() 172 | self.test_z3_dependencies() 173 | self.test_basic_functionality() 174 | 175 | print(f"\n{self.BOLD}=== Test Summary ==={self.RESET}") 176 | print(f"Passed: {len(self.successes)}") 177 | print(f"Failed: {len(self.failures)}") 178 | 179 | if self.failures: 180 | print(f"\n{self.BOLD}Failed Tests:{self.RESET}") 181 | for test, details in self.failures: 182 | print(f"\n{self.RED}✗ {test}{self.RESET}") 183 | print(f" └─ {details}") 184 | print( 185 | "\nZ3 mode setup incomplete. Please fix the issues above before proceeding." 186 | ) 187 | sys.exit(1) 188 | else: 189 | print(f"\n{self.GREEN}✓ All Z3 mode tests passed successfully!{self.RESET}") 190 | print("\nSystem is ready to use MCP-Solver in Z3 mode.") 191 | sys.exit(0) 192 | 193 | 194 | def main(): 195 | test = Z3SetupTest() 196 | test.run_all_tests() 197 | 198 | 199 | if __name__ == "__main__": 200 | main() 201 | -------------------------------------------------------------------------------- /tests/README.md: -------------------------------------------------------------------------------- 1 | # MCP Solver Test Suite 2 | 3 | This directory contains tests for the MCP Solver project. 4 | 5 | ## Quick Tests 6 | 7 | For a quick end-to-end test after code changes, run any of these commands: 8 | 9 | ```bash 10 | # MiniZinc test 11 | uv run run-test mzn 12 | 13 | # PySAT test 14 | uv run run-test pysat 15 | 16 | # MaxSAT test 17 | uv run run-test maxsat 18 | 19 | # Z3 test - Cryptarithmetic puzzle 20 | uv run run-test z3 21 | ``` 22 | 23 | ## Test Structure 24 | 25 | ``` 26 | ├── tests/ 27 | │ ├── __init__.py # Package marker 28 | │ ├── test_config.py # Configuration values for tests 29 | │ ├── run_test.py # Unified test runner for all solvers 30 | │ ├── problems/ # All problem definitions in one place 31 | │ │ ├── mzn/ # MiniZinc problem definitions 32 | │ │ │ ├── nqueens.md # N-Queens problem 33 | │ │ │ └── sudoku.md # Sudoku problem 34 | │ │ ├── pysat/ # PySAT problem definitions 35 | │ │ │ ├── graph_coloring.md # Graph coloring problem 36 | │ │ │ ├── scheduling.md # Scheduling problem 37 | │ │ │ ├── furniture-arrangement.md # Furniture arrangement problem 38 | │ │ │ ├── mine-sweeper-hard.md # Mine sweeper problem 39 | │ │ │ └── sudoku-16x16.md # 16x16 Sudoku problem 40 | │ │ ├── maxsat/ # MaxSAT problem definitions 41 | │ │ │ └── test.md # Default test problem 42 | │ │ └── z3/ # Z3 problem definitions 43 | │ │ ├── bounded_sum.md # Bounded sum problem 44 | │ │ └── cryptarithmetic.md # Cryptarithmetic problem 45 | │ └── results/ # Directory for test results (optional) 46 | ``` 47 | 48 | ## Running Tests 49 | 50 | ### Running Tests for a Specific Solver 51 | 52 | ```bash 53 | cd /path/to/mcp-solver 54 | uv run run-test mzn # Run MiniZinc test.md by default if present, otherwise all MiniZinc tests 55 | uv run run-test pysat # Run PySAT test.md by default if present, otherwise all PySAT tests 56 | uv run run-test maxsat # Run MaxSAT test.md by default if present, otherwise all MaxSAT tests 57 | uv run run-test z3 # Run Z3 test.md by default if present, otherwise all Z3 tests 58 | ``` 59 | 60 | ### Running a Specific Problem 61 | 62 | ```bash 63 | cd /path/to/mcp-solver 64 | uv run run-test mzn --problem tests/problems/mzn/nqueens.md 65 | uv run run-test pysat --problem tests/problems/pysat/graph_coloring.md 66 | uv run run-test maxsat --problem tests/problems/maxsat/test.md 67 | uv run run-test z3 --problem tests/problems/z3/cryptarithmetic.md 68 | ``` 69 | 70 | Note: If no problem is specified, the system will look for a `test.md` file in the respective solver's problems directory and run that as a default test. 71 | 72 | ### Available Problems 73 | 74 | #### MiniZinc Problems: 75 | - `carpet_cutting.md` - Carpet cutting optimization problem 76 | - `test.md` - Default test problem 77 | - `tsp.md` - Traveling Salesperson Problem 78 | - `university_scheduling.md` - University course scheduling problem 79 | - `university_scheduling_unsat.md` - Unsatisfiable variant of scheduling problem 80 | - `zebra.md` - Einstein's Zebra puzzle (Five Houses Puzzle) 81 | 82 | #### PySAT Problems: 83 | - `equitable_coloring_hajos.md` - Equitable graph coloring problem 84 | - `furniture_arrangement.md` - Furniture arrangement problem 85 | - `petersen_12_coloring_unsat.md` - Unsatisfiable Petersen graph coloring 86 | - `queens_and_knights_6x6.md` - Combined queens and knights placement puzzle 87 | - `sudoku_16x16.md` - 16x16 Sudoku problem 88 | - `test.md` - Default test problem 89 | 90 | #### MaxSAT Problems: 91 | - `test.md` - Default test problem 92 | 93 | #### Z3 Problems: 94 | - `array_property_verifier.md` - Array property verification problem 95 | - `bounded_sum_unsat.md` - Unsatisfiable bounded sum problem 96 | - `cryptarithmetic.md` - Cryptarithmetic puzzle (SEND+MORE=MONEY) 97 | - `processor_verification.md` - Processor behavior verification 98 | - `sos_induction.md` - Sum-of-squares induction problem 99 | - `test.md` - Default test problem 100 | 101 | ### Test Options 102 | 103 | - `--verbose` or `-v`: Enable verbose output 104 | - `--timeout` or `-t`: Set timeout in seconds (default: 300) 105 | - `--save` or `-s`: Save test results to the results directory 106 | - `--result`: Save detailed JSON results to the specified directory 107 | - `--mc`: Specify direct model code (e.g., "AT:claude-3-7-sonnet-20250219") 108 | 109 | Examples: 110 | ```bash 111 | # Run with all default options 112 | uv run run-test mzn --problem tests/problems/mzn/nqueens.md --verbose --timeout 120 --save --result ./json_results 113 | 114 | # Run with specific LLM model (using direct model code) 115 | uv run run-test mzn --problem tests/problems/mzn/nqueens.md --mc "AT:claude-3-7-sonnet-20250219" 116 | ``` 117 | 118 | ## Troubleshooting Common Issues 119 | 120 | ### Error connecting to MCP server 121 | 122 | If you see "Error connecting to MCP server", check that: 123 | 1. The server command is correctly set 124 | 2. The appropriate solver package is installed 125 | 3. Environment variables are properly set if needed 126 | 127 | ### Missing prompt files 128 | 129 | If you see a warning about missing prompt files, check that the instruction prompt files exist: 130 | - MiniZinc: `instructions_prompt_mzn.md` 131 | - PySAT: `instructions_prompt_pysat.md` 132 | - MaxSAT: `instructions_prompt_maxsat.md` 133 | - Z3: `instructions_prompt_z3.md` 134 | 135 | ### PySAT and MaxSAT Environments 136 | 137 | The PySAT and MaxSAT execution environments include: 138 | 139 | 1. Standard Python modules: `math`, `random`, `collections`, `itertools`, `re`, `json` 140 | 2. PySAT modules: `pysat.formula`, `pysat.solvers`, `pysat.card` 141 | 3. Constraint helpers: `at_most_one`, `exactly_one`, `implies`, etc. 142 | 4. Cardinality templates: `at_most_k`, `at_least_k`, `exactly_k` 143 | 144 | The MaxSAT environment additionally includes: 145 | - MaxSAT solver: `pysat.examples.rc2.RC2` 146 | - Weighted CNF formulas: `pysat.formula.WCNF` 147 | 148 | If you add new helper functions, make sure to include them in: 149 | - `restricted_globals` dictionary in `environment.py` 150 | - The processed code template in `execute_pysat_code` 151 | 152 | ## Adding New Tests 153 | 154 | ### Adding a New Problem 155 | 156 | 1. Create a Markdown file in the appropriate problem directory under `tests/problems/`: 157 | - MiniZinc: `tests/problems/mzn/` 158 | - PySAT: `tests/problems/pysat/` 159 | - Z3: `tests/problems/z3/` 160 | 2. Run the test with the `uv run run-test` command -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | MCP Solver Tests Package 3 | """ 4 | -------------------------------------------------------------------------------- /tests/problems/maxsat/equipment_purchase.md: -------------------------------------------------------------------------------- 1 | # Equipment Purchase Optimization 2 | 3 | A laboratory needs to purchase equipment with a budget of $7000. Each piece of equipment has a cost and provides research capabilities. 4 | 5 | ## Available Equipment 6 | | Equipment | Cost | Capability Value | 7 | |-----------|------|------------------| 8 | | Analyzer | $3500 | 9 points | 9 | | Bench | $2500 | 6 points | 10 | | Computer | $2000 | 5 points | 11 | | Desk | $1500 | 4 points | 12 | 13 | ## Constraints 14 | 1. **Budget**: Total cost must not exceed $7000 15 | 2. **Dependencies**: Analyzer requires Computer to function 16 | 3. **Synergy**: Bench and Desk together provide an additional 2 points 17 | 18 | ## Task 19 | Find the optimal set of equipment that maximizes total capability value while satisfying all constraints. 20 | 21 | ## Expected Output 22 | - Equipment selected 23 | - Total cost 24 | - Total value (including any synergy bonus) -------------------------------------------------------------------------------- /tests/problems/maxsat/network_monitoring.md: -------------------------------------------------------------------------------- 1 | # Network Monitoring (Simplified) 2 | 3 | A company needs to place monitoring software on servers to ensure all network connections are monitored. Each server has a cost and monitoring value. 4 | 5 | ## Network Structure 6 | 7 | ### Servers (6 total) 8 | | Server | Type | Value | Cost | 9 | |--------|------|-------|------| 10 | | Core1 | Core | 10 | 3 | 11 | | Core2 | Core | 10 | 3 | 12 | | Web1 | Web | 6 | 2 | 13 | | Web2 | Web | 6 | 2 | 14 | | DB1 | Database | 8 | 3 | 15 | | Edge1 | Edge | 5 | 1 | 16 | 17 | ### Network Connections (8 edges) 18 | - Core1 connects to: Core2, Web1, DB1 19 | - Core2 connects to: Web2, DB1, Edge1 20 | - Web1 connects to: Web2 21 | - Web2 connects to: Edge1 22 | 23 | ## Constraints 24 | 25 | ### Hard Constraints 26 | 1. **Edge Coverage**: Every network connection must be monitored by at least one server on either end 27 | 2. **Critical Server**: At least one Core server (Core1 or Core2) must have monitoring 28 | 29 | ### Soft Constraints 30 | 1. **Cost Minimization**: Minimize total deployment cost (weight = cost) 31 | 2. **Value Maximization**: Maximize monitoring value (weight = 20 - value) 32 | 33 | ## Task 34 | Find the optimal set of servers to monitor that: 35 | - Covers all network connections 36 | - Includes at least one Core server 37 | - Balances cost and value 38 | 39 | ## Expected Output 40 | - List of servers selected for monitoring 41 | - Total cost 42 | - Total monitoring value 43 | - Verification that all edges are covered -------------------------------------------------------------------------------- /tests/problems/maxsat/package_selection.md: -------------------------------------------------------------------------------- 1 | # Software Package Selection 2 | 3 | ## Problem Description 4 | 5 | Select software packages for a development project, considering dependencies and value. 6 | 7 | ## Available Packages 8 | | Package | Value | Description | 9 | |---------|-------|-------------| 10 | | Core | 0 | Basic framework (required) | 11 | | UI | 6 | User interface library | 12 | | Auth | 8 | Authentication module | 13 | | API | 7 | API integration tools | 14 | | Analytics | 5 | Analytics dashboard | 15 | 16 | ## Hard Constraints 17 | 1. Core package must be selected (it's the foundation) 18 | 2. UI requires Core (dependency) 19 | 3. Auth requires Core (dependency) 20 | 4. Analytics requires both UI and API (complex dependency) 21 | 22 | ## Soft Constraints 23 | - Maximize total value of selected packages 24 | - Prefer to have at least 3 packages selected (penalty of 4 if fewer) 25 | 26 | ## Task 27 | Find the optimal set of packages that: 28 | 1. Satisfies all dependencies 29 | 2. Maximizes total value while considering the package count preference 30 | 31 | Output which packages to select and the total value achieved. -------------------------------------------------------------------------------- /tests/problems/maxsat/task_assignment.md: -------------------------------------------------------------------------------- 1 | # Task Assignment 2 | 3 | A manager needs to assign two tasks to two employees with certain constraints and preferences. 4 | 5 | ## Tasks and Employees 6 | - Task A: Data Analysis 7 | - Task B: Report Writing 8 | - Employee 1: Alice 9 | - Employee 2: Bob 10 | 11 | ## Constraints 12 | 13 | ### Hard Constraints 14 | 1. Each task must be assigned to exactly one employee 15 | 2. Alice cannot do both tasks (she's part-time) 16 | 17 | ### Preferences (minimize penalties) 18 | 1. Alice prefers Task A (penalty of 3 if she doesn't get it) 19 | 2. Bob prefers Task B (penalty of 2 if he doesn't get it) 20 | 3. Task A should ideally go to Alice (penalty of 1 if assigned to Bob) 21 | 22 | ## Task 23 | Find the optimal assignment that satisfies all constraints and minimizes the total penalty. 24 | 25 | ## Expected Output 26 | - Assignment of each task to an employee 27 | - Total penalty incurred 28 | - Verification that constraints are satisfied -------------------------------------------------------------------------------- /tests/problems/maxsat/test.md: -------------------------------------------------------------------------------- 1 | # Simple MaxSAT Test Problem 2 | 3 | I need to solve a simple MaxSAT optimization problem with just one variable. 4 | 5 | - Variable x should be true (with weight 1) 6 | - There are no hard constraints 7 | 8 | Please create a MaxSAT formulation and solve this trivial problem. Show the optimal solution where x should be true. 9 | 10 | Important: Make sure to use the correct syntax for adding soft constraints to the WCNF: 11 | ```python 12 | # Correct: 13 | wcnf.append([x], weight=1) # Note the square brackets around the variable 14 | 15 | # Incorrect: 16 | wcnf.append(x, weight=1) # Missing square brackets 17 | wcnf.append(, weight=1) # Incomplete 18 | ``` -------------------------------------------------------------------------------- /tests/problems/maxsat/workshop_scheduling_unsat.md: -------------------------------------------------------------------------------- 1 | # Workshop Scheduling 2 | 3 | A company needs to schedule 3 workshops for its employees. 4 | 5 | ## Workshops 6 | - Workshop A: Leadership Training 7 | - Workshop B: Technical Skills 8 | - Workshop C: Communication Skills 9 | 10 | ## Time Slots Available 11 | - Morning (9 AM - 12 PM) 12 | - Afternoon (1 PM - 4 PM) 13 | 14 | ## Hard Constraints 15 | 1. Each workshop must be assigned to exactly one time slot 16 | 2. No two workshops can occur at the same time 17 | 3. Workshop A must occur in the Morning slot (trainer availability) 18 | 4. Workshop B must occur in the Morning slot (equipment setup required) 19 | 5. Workshop C must occur in the Morning slot (external trainer constraint) 20 | 21 | ## Soft Constraints 22 | 1. Employees prefer Workshop A in the morning (satisfaction: 5) 23 | 2. Employees prefer Workshop B in the afternoon (penalty: 4 if in morning) 24 | 3. Employees prefer Workshop C in the afternoon (penalty: 3 if in morning) 25 | 26 | ## Task 27 | Find a schedule that satisfies all constraints and maximizes employee satisfaction. 28 | 29 | ## Expected Output 30 | - Assignment of each workshop to a time slot 31 | - Total satisfaction score achieved -------------------------------------------------------------------------------- /tests/problems/mzn/carpet_cutting.md: -------------------------------------------------------------------------------- 1 | A carpet installer needs to efficiently cut rectangular carpet pieces from a single standard-sized roll, minimizing waste. 2 | 3 | ### Given: 4 | 5 | - 1 standard carpet roll measuring 12ft × 50ft 6 | - Need to cut the following rectangular pieces: 7 | 1. 6ft × 8ft (quantity: 1) 8 | 2. 4ft × 5ft (quantity: 1) 9 | 3. 5ft × 7ft (quantity: 1) 10 | 4. 8ft × 10ft (quantity: 1) 11 | 12 | ### Constraints: 13 | 14 | 1. All pieces must be placed without overlap 15 | 2. Pieces can optionally be rotated 90 degrees 16 | 3. All pieces must fit within the roll dimensions 17 | 18 | ### Objective: 19 | 20 | Minimize the total length of carpet roll used 21 | 22 | -------------------------------------------------------------------------------- /tests/problems/mzn/test.md: -------------------------------------------------------------------------------- 1 | Find values for two variables x and y that satisfy these conditions: 2 | 3 | 1. Both x and y must be integers between 1 and 10 (inclusive) 4 | 2. The sum of x and y must be less than or equal to 10 (x + y ≤ 10) 5 | 3. The product of x and y must be greater than or equal to 10 (x × y ≥ 10) 6 | 7 | -------------------------------------------------------------------------------- /tests/problems/mzn/tsp.md: -------------------------------------------------------------------------------- 1 | A saleswoman based in Vienna needs to plan her upcoming tour through Austria, visiting each province capital once. Help find the shortest route. Distances in km: 2 | 1=Vienna, 2=St. Pölten, 3=Eisenstadt, 4=Linz, 5=Graz, 6=Klagenfurt, 7=Salzburg, 8=Innsbruck, 9=Bregenz 3 | 4 | | | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 5 | | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | 6 | | 1 | 0 | 65 | 60 | 184 | 195 | 319 | 299 | 478 | 631 | 7 | | 2 | 65 | 0 | 125 | 119 | 130 | 254 | 234 | 413 | 566 | 8 | | 3 | 60 | 125 | 0 | 184 | 157 | 281 | 261 | 440 | 593 | 9 | | 4 | 184 | 119 | 184 | 0 | 208 | 252 | 136 | 315 | 468 | 10 | | 5 | 195 | 130 | 157 | 208 | 0 | 136 | 280 | 459 | 629 | 11 | | 6 | 319 | 254 | 281 | 252 | 136 | 0 | 217 | 391 | 566 | 12 | | 7 | 299 | 234 | 261 | 136 | 280 | 217 | 0 | 188 | 343 | 13 | | 8 | 478 | 413 | 440 | 315 | 459 | 391 | 188 | 0 | 157 | 14 | | 9 | 631 | 566 | 593 | 468 | 629 | 566 | 343 | 157 | 0 | 15 | 16 | -------------------------------------------------------------------------------- /tests/problems/mzn/university_scheduling.md: -------------------------------------------------------------------------------- 1 | A university needs to schedule 10 courses across 5 time slots and 4 rooms. Each course has specific requirements: 2 | 3 | 1. Course CS101 requires a computer lab and cannot be scheduled at the same time as CS201 4 | 2. Course CS201 requires a computer lab 5 | 3. Course MA101 must be in the first time slot 6 | 4. Course PH101 requires a lab with special equipment 7 | 5. Course EN101 has no special requirements 8 | 6. Course BI101 requires a lab with special equipment 9 | 7. Course HI101 prefers not to be in the last time slot 10 | 8. Course EC101 must be scheduled in Room 2 11 | 9. Course PS101 cannot be in the first time slot and must not be in the same room as MA101 12 | 10. Course AR101 has no special requirements 13 | 14 | The rooms have the following attributes: 15 | - Room 1 is a computer lab 16 | - Room 2 is a regular classroom 17 | - Room 3 is a regular classroom 18 | - Room 4 is a lab with special equipment 19 | 20 | Schedule all courses to minimize the number of preferences violated while ensuring all hard constraints are satisfied. -------------------------------------------------------------------------------- /tests/problems/mzn/university_scheduling_unsat.md: -------------------------------------------------------------------------------- 1 | A university needs to schedule 10 courses across 5 time slots and 4 rooms. Each course has specific requirements: 2 | 3 | ## Courses and Basic Requirements: 4 | 5 | 1. CS101: Requires a computer lab 6 | 2. CS201: Requires a computer lab 7 | 3. MA101: Must be scheduled in the first time slot 8 | 4. PH101: Requires a lab with special equipment 9 | 5. EN101: No special room requirements 10 | 6. BI101: Requires a lab with special equipment 11 | 7. HI101: No special room requirements 12 | 8. EC101: Must be scheduled in Room 2 13 | 9. PS101: Cannot be scheduled in the first time slot 14 | 10. AR101: No special room requirements 15 | 16 | ## Room Types: 17 | 18 | - Room 1: Computer lab 19 | - Room 2: Regular classroom 20 | - Room 3: Regular classroom 21 | - Room 4: Lab with special equipment 22 | 23 | ## Additional Constraints: 24 | 25 | 1. No two courses can be scheduled in the same room at the same time 26 | 2. CS101 and CS201 cannot be scheduled at the same time 27 | 3. PH101 and BI101 must be scheduled in the same time slot 28 | 4. PS101 cannot be scheduled in Room 1 or Room 2 29 | 5. PS101 and AR101 must be in the same room, with AR101 in the time slot immediately after PS101 30 | 6. HI101 must be in the same room as PS101 and AR101, and must be scheduled in the time slot immediately after AR101 31 | 7. EN101 must be in the same room as HI101, PS101, and AR101, and must be scheduled in the time slot immediately after EN101 32 | 33 | A valid schedule assigns each course to exactly one time slot and one room while satisfying all of the above constraints. 34 | 35 | Show that no valid schedule exists. -------------------------------------------------------------------------------- /tests/problems/mzn/zebra.md: -------------------------------------------------------------------------------- 1 | There are four desks. The student studying Biology is sitting at Desk 3. The student who likes Pizza is sitting next to the student who is studying Chemistry. The student studying Physics is not sitting at Desk 2. The student sitting at Desk 2 likes Pasta. Anna likes apples. Ben is not studying Biology. Emily is sitting at Desk 1. The student sitting at Desk 4 likes ice cream. The name of student who is studying Physics has an even number of letters (i.e., Anna or Liam). What food does Liam like and who is studying Literature? -------------------------------------------------------------------------------- /tests/problems/pysat/equitable_coloring_hajos.md: -------------------------------------------------------------------------------- 1 | Find an equitable 3-coloring of the Hajós join of three C₅ cycles (cycle graphs with 5 vertices each). -------------------------------------------------------------------------------- /tests/problems/pysat/furniture_arrangement.md: -------------------------------------------------------------------------------- 1 | # Furniture Arrangement Problem 2 | 3 | You are tasked with arranging 5 pieces of furniture (Sofa, TV, Bookshelf, Dining Table, and Desk) in 4 rooms (Living Room, Bedroom, Study, and Dining Room) of a house. 4 | 5 | Each piece of furniture must be placed in exactly one room. The Living Room can have at most 3 pieces of furniture. The Bedroom can have at most 2 pieces of furniture. The TV and Sofa must be placed in the same room. The Desk and Bookshelf must be placed in the Study. The Dining Table must be placed in the Dining Room. The Sofa cannot be placed in the Study because it won't fit. 6 | 7 | Find a valid arrangement of all furniture items that satisfies all constraints. -------------------------------------------------------------------------------- /tests/problems/pysat/no_three_in_line_5x5.md: -------------------------------------------------------------------------------- 1 | # No-Three-in-Line Problem: Verification for n=5 2 | 3 | ## Problem Statement 4 | 5 | Consider a 5×5 grid of points with coordinates (i,j) where i,j ∈ {0,1,2,3,4}. This gives us 25 possible positions arranged in a square grid pattern. 6 | 7 | **Task:** Verify that it is possible to place exactly 10 points on this 5×5 grid such that no three of the selected points are collinear (lie on the same straight line). 8 | 9 | ## Constraints 10 | 11 | - Points must be placed at integer grid coordinates only 12 | - "No three in line" means no three selected points lie on the same straight line, regardless of the line's slope 13 | - This includes horizontal lines, vertical lines, diagonal lines, and lines with any other slope (like 1/2, 2/1, 1/3, 1/4, 3/2, etc.) 14 | 15 | ## What to Verify 16 | 17 | 1. **Existence:** Find a configuration of 10 points on the 5×5 grid that satisfies the no-three-in-line constraint 18 | 2. **Completeness:** Check all possible lines that could pass through three or more points in your configuration to confirm none contain three selected points 19 | 3. **Optimality:** Verify that 10 is indeed the maximum number of points that can be placed (optional) 20 | 21 | ## Example Grid Layout 22 | 23 | ``` 24 | (0,4) (1,4) (2,4) (3,4) (4,4) 25 | (0,3) (1,3) (2,3) (3,3) (4,3) 26 | (0,2) (1,2) (2,2) (3,2) (4,2) 27 | (0,1) (1,1) (2,1) (3,1) (4,1) 28 | (0,0) (1,0) (2,0) (3,0) (4,0) 29 | ``` 30 | 31 | ## Lines to Consider 32 | 33 | The 5×5 grid contains 32 distinct lines with 3 or more collinear points: 34 | 35 | - 5 horizontal lines 36 | - 5 vertical lines 37 | - 2 main diagonal lines 38 | - 20 other diagonal lines with various slopes 39 | 40 | Some examples of lines that must be avoided: 41 | 42 | - **Horizontal:** [(0,0), (1,0), (2,0), (3,0), (4,0)] 43 | - **Vertical:** [(0,0), (0,1), (0,2), (0,3), (0,4)] 44 | - **Diagonal:** [(0,0), (1,1), (2,2), (3,3), (4,4)] 45 | - **Slope 1/2:** [(0,0), (2,1), (4,2)] 46 | - **Slope 2:** [(0,0), (1,2), (2,4)] 47 | 48 | **Question:** Can you find such a configuration of 10 points and prove that no three of them are collinear? -------------------------------------------------------------------------------- /tests/problems/pysat/petersen_12_coloring_unsat.md: -------------------------------------------------------------------------------- 1 | In an L(2, 1)-coloring of a graph the vertices are assigned color numbers in such a way that adjacent vertices get labels that differ by at least two, and the vertices that are at a distance of two from each other get labels that differ by at least one. 2 | 3 | Check whether the Peterson graph (5-cycle + pentagram + perfect matching) has an L(2,1) coloring with 9 colors. -------------------------------------------------------------------------------- /tests/problems/pysat/sudoku_16x16.md: -------------------------------------------------------------------------------- 1 | Solve the following 16x16 Sudoku puzzle, where the letter x represents empty fields: line 1: (x,x,4,7,x,x,A,x,x,x,x,x,3,x,x,5), line 2: (x,6,x,x,x,x,x,9,x,x,B,x,x,x,x,x), line 3: (x,x,x,x,8,x,x,x,7,x,x,x,x,D,x,x), line 4: (G,x,x,x,x,3,x,x,x,x,x,1,x,x,x,x), line 5: (x,x,2,x,x,x,x,x,x,F,x,x,x,x,6,x), line 6: (x,x,x,x,x,x,7,x,x,x,x,C,x,x,x,x), line 7: (x,5,x,x,x,x,x,x,D,x,x,x,x,x,x,8), line 8: (x,x,x,x,x,9,x,x,x,x,x,x,4,x,x,x), line 9: (x,x,x,E,x,x,x,x,x,x,x,8,x,x,x,x), line 10: (x,x,1,x,x,x,x,F,x,x,x,x,x,7,x,x), line 11: (x,x,x,x,x,x,x,x,B,x,x,x,x,x,x,x), line 12: (x,8,x,x,x,x,x,x,x,x,x,x,E,x,x,x), line 13: (x,x,x,x,x,x,x,D,x,x,9,x,x,x,x,x), line 14: (x,x,x,x,F,x,x,x,x,x,x,x,x,x,2,x), line 15: (x,x,x,x,x,x,x,x,x,x,x,x,x,x,x,x), line 16: (x,x,x,2,x,x,x,x,x,x,x,x,x,x,x,x). Use the numbers 1-9 and the letters A-G as values. -------------------------------------------------------------------------------- /tests/problems/pysat/test.md: -------------------------------------------------------------------------------- 1 | Given a propositional logic formula φ in Conjunctive Normal Form (CNF): 2 | 3 | φ = (x₁ ∨ x₂) ∧ (¬x₁ ∨ x₃) ∧ (¬x₂ ∨ ¬x₃) 4 | 5 | Determine whether there exists a truth assignment to variables {x₁, x₂, x₃} such that φ evaluates to TRUE, and if such an assignment exists, identify it. 6 | -------------------------------------------------------------------------------- /tests/problems/z3/array_property_verifier.md: -------------------------------------------------------------------------------- 1 | Consider an integer array with 8 empty slots that needs to satisfy these conditions: 2 | 3 | 1. The array must be sorted in ascending order 4 | 2. Each value must be between 1 and 15 (inclusive) 5 | 3. The number 7 must appear exactly once in the array 6 | 4. The sum of all elements must equal 60 7 | 5. No two adjacent elements can both be even numbers 8 | 9 | Find a valid array that meets all these requirements. -------------------------------------------------------------------------------- /tests/problems/z3/bounded_sum_unsat.md: -------------------------------------------------------------------------------- 1 | Find an assignment of integers to variables a, b, c, d, and e that: 2 | 3 | 1. Each variable must be between 1 and 10 inclusive 4 | 2. All variables must have different values 5 | 3. The sum of all variables must be exactly 36 6 | 4. The product of a and b must be less than 20 7 | 5. The product of c, d, and e must be at least 200 8 | 6. Any two variables must sum to at least 11 9 | 10 | or decide that this is impossible. -------------------------------------------------------------------------------- /tests/problems/z3/cryptarithmetic.md: -------------------------------------------------------------------------------- 1 | # Cryptarithmetic Puzzle 2 | 3 | Can you solve this cryptarithmetic puzzle using Z3? 4 | 5 | ``` 6 | SEND 7 | + MORE 8 | ------ 9 | MONEY 10 | ``` 11 | 12 | Each letter represents a unique digit (0-9). Find the digit assignment that makes this sum equation valid. 13 | 14 | The leading digit of any number cannot be 0 (so S and M cannot be 0). 15 | -------------------------------------------------------------------------------- /tests/problems/z3/processor_verification.md: -------------------------------------------------------------------------------- 1 | You are given a simplified 8-bit processor model with the following components: 2 | 3 | - 4 registers (R0-R3), each storing 8-bit values 4 | - A small memory array with 8 locations (addressable by 3 bits) 5 | - A zero flag that gets set when certain operations produce a zero result 6 | 7 | The processor executes the following instruction sequence: 8 | 9 | ``` 10 | 1. LOAD R1, [R0] # Load memory at address in R0 into R1 11 | 2. XOR R2, R1, R0 # R2 = R1 XOR R0 12 | 3. AND R3, R2, #1 # R3 = R2 & 1 (extract lowest bit) 13 | 4. STORE R3, [R0+1] # Store R3 to memory at address R0+1 14 | 5. COND(ZERO) OR R2, R2, #1 # If zero flag set, set lowest bit of R2 15 | ``` 16 | 17 | The zero flag is updated after instructions 1-3 based on whether the result is zero. 18 | 19 | Using Z3 SMT solver with bitvector theory, determine whether the following property holds: 20 | 21 | **After executing this instruction sequence, does register R3 always contain the parity bit of register R0?** 22 | 23 | The parity bit of a value is defined as 1 if the number of 1 bits in its binary representation is odd, and 0 if the number is even. 24 | 25 | Provide a clear answer with evidence supporting your conclusion. If the property does not hold, provide a specific counterexample showing register and memory values. -------------------------------------------------------------------------------- /tests/problems/z3/sos_induction.md: -------------------------------------------------------------------------------- 1 | Consider this simple program that computes the sum of cubes from 1 to n: 2 | 3 | ``` 4 | result = 0 5 | i = 1 6 | while i <= n: 7 | result = result + (i * i * i) 8 | i = i + 1 9 | ``` 10 | 11 | Using mathematical induction, prove that for any n, the result equals [n²(n+1)²/4] 12 | 13 | Show the algebraic steps in your proof. -------------------------------------------------------------------------------- /tests/problems/z3/test.md: -------------------------------------------------------------------------------- 1 | Find values for two variables x and y that satisfy these conditions: 2 | 3 | 1. Both x and y must be integers between 1 and 10 (inclusive) 4 | 2. The sum of x and y must be less than or equal to 10 (x + y ≤ 10) 5 | 3. The product of x and y must be greater than or equal to 10 (x × y ≥ 10) 6 | 7 | -------------------------------------------------------------------------------- /tests/test_config.py: -------------------------------------------------------------------------------- 1 | """ 2 | Shared configuration for all test files. 3 | """ 4 | 5 | import os 6 | from pathlib import Path 7 | 8 | 9 | # Configuration 10 | MCP_CLIENT_DIR = os.path.abspath( 11 | os.path.dirname(os.path.dirname(__file__)) 12 | ) # Use the current project directory 13 | DEFAULT_TIMEOUT = 300 # 5 minutes default timeout 14 | 15 | # Get absolute paths to key directories 16 | ROOT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) 17 | PROBLEMS_DIR = os.path.join(os.path.dirname(__file__), "problems") 18 | MZN_PROBLEMS_DIR = os.path.join(PROBLEMS_DIR, "mzn") 19 | PYSAT_PROBLEMS_DIR = os.path.join(PROBLEMS_DIR, "pysat") 20 | Z3_PROBLEMS_DIR = os.path.join(PROBLEMS_DIR, "z3") 21 | MAXSAT_PROBLEMS_DIR = os.path.join(PROBLEMS_DIR, "maxsat") 22 | RESULTS_DIR = os.path.join(ROOT_DIR, "test_results") 23 | 24 | 25 | def get_abs_path(rel_path): 26 | """Convert a path relative to the root directory to an absolute path.""" 27 | return os.path.join(ROOT_DIR, rel_path) 28 | 29 | 30 | # Helper function to load a prompt using the centralized loader 31 | def load_prompt_for_test(mode, prompt_type="instructions"): 32 | """Load a prompt using the centralized prompt loader.""" 33 | try: 34 | from mcp_solver.core.prompt_loader import load_prompt 35 | 36 | return load_prompt(mode, prompt_type) 37 | except ImportError: 38 | # If the prompt loader isn't available, fall back to file reading 39 | prompt_path = get_abs_path(f"prompts/{mode}/{prompt_type}.md") 40 | with open(prompt_path, encoding="utf-8") as f: 41 | return f.read().strip() 42 | except Exception as e: 43 | # If all else fails, try to find the old-style prompt files 44 | old_style_paths = { 45 | "mzn": {"instructions": "instructions_prompt_mzn.md"}, 46 | "pysat": {"instructions": "instructions_prompt_pysat.md"}, 47 | "z3": {"instructions": "instructions_prompt_z3.md"}, 48 | "maxsat": {"instructions": "instructions_prompt_maxsat.md"}, 49 | } 50 | 51 | if mode in old_style_paths and prompt_type in old_style_paths[mode]: 52 | old_path = get_abs_path(old_style_paths[mode][prompt_type]) 53 | with open(old_path, encoding="utf-8") as f: 54 | return f.read().strip() 55 | else: 56 | raise ValueError(f"Could not load prompt for {mode}/{prompt_type}: {e!s}") 57 | --------------------------------------------------------------------------------