clara-opt 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. clara_opt-0.1.0/.gitignore +68 -0
  2. clara_opt-0.1.0/CONTRIBUTING.md +41 -0
  3. clara_opt-0.1.0/LICENSE +21 -0
  4. clara_opt-0.1.0/PKG-INFO +123 -0
  5. clara_opt-0.1.0/README.md +89 -0
  6. clara_opt-0.1.0/benchmarks/results/final/.gitkeep +0 -0
  7. clara_opt-0.1.0/benchmarks/scripts/.gitkeep +0 -0
  8. clara_opt-0.1.0/clara/__init__.py +3 -0
  9. clara_opt-0.1.0/clara/cli.py +198 -0
  10. clara_opt-0.1.0/clara/engine/__init__.py +1 -0
  11. clara_opt-0.1.0/clara/engine/highs_backend.py +198 -0
  12. clara_opt-0.1.0/clara/engine/simplex.py +399 -0
  13. clara_opt-0.1.0/clara/explain/__init__.py +4 -0
  14. clara_opt-0.1.0/clara/explain/binding.py +77 -0
  15. clara_opt-0.1.0/clara/explain/explainer.py +127 -0
  16. clara_opt-0.1.0/clara/explain/sensitivity.py +129 -0
  17. clara_opt-0.1.0/clara/explain/types.py +315 -0
  18. clara_opt-0.1.0/clara/explain/variable.py +92 -0
  19. clara_opt-0.1.0/clara/io/__init__.py +3 -0
  20. clara_opt-0.1.0/clara/io/lp_parser.py +702 -0
  21. clara_opt-0.1.0/clara/model/__init__.py +13 -0
  22. clara_opt-0.1.0/clara/model/problem.py +46 -0
  23. clara_opt-0.1.0/clara/model/solve_state.py +332 -0
  24. clara_opt-0.1.0/clara/reopt/__init__.py +8 -0
  25. clara_opt-0.1.0/clara/reopt/types.py +129 -0
  26. clara_opt-0.1.0/docs/.gitkeep +0 -0
  27. clara_opt-0.1.0/docs/cli_spec.py +452 -0
  28. clara_opt-0.1.0/docs/explainer_spec.py +688 -0
  29. clara_opt-0.1.0/docs/lp_parser_spec.py +398 -0
  30. clara_opt-0.1.0/docs/solve_state_design.py +541 -0
  31. clara_opt-0.1.0/docs/v010_release_spec.py +571 -0
  32. clara_opt-0.1.0/examples/01_basic_lp.py +21 -0
  33. clara_opt-0.1.0/examples/02_sensitivity.py +33 -0
  34. clara_opt-0.1.0/examples/03_engine_comparison.py +37 -0
  35. clara_opt-0.1.0/examples/04_json_output.py +28 -0
  36. clara_opt-0.1.0/paper/Makefile +14 -0
  37. clara_opt-0.1.0/paper/figures/.gitkeep +0 -0
  38. clara_opt-0.1.0/paper/latexmkrc +3 -0
  39. clara_opt-0.1.0/paper/sections/.gitkeep +0 -0
  40. clara_opt-0.1.0/paper/tables/.gitkeep +0 -0
  41. clara_opt-0.1.0/pyproject.toml +119 -0
  42. clara_opt-0.1.0/tests/__init__.py +0 -0
  43. clara_opt-0.1.0/tests/fixtures/.gitkeep +0 -0
  44. clara_opt-0.1.0/tests/fixtures/albici_base.lp +20 -0
  45. clara_opt-0.1.0/tests/fixtures/albici_expected.py +216 -0
  46. clara_opt-0.1.0/tests/fixtures/parser_comprehensive.lp +33 -0
  47. clara_opt-0.1.0/tests/fixtures/parser_expected.py +96 -0
  48. clara_opt-0.1.0/tests/fixtures/parser_minimal.lp +7 -0
  49. clara_opt-0.1.0/tests/fixtures/test_albici.py +418 -0
  50. clara_opt-0.1.0/tests/test_cli.py +215 -0
  51. clara_opt-0.1.0/tests/test_cross_validation.py +116 -0
  52. clara_opt-0.1.0/tests/test_explainer.py +298 -0
  53. clara_opt-0.1.0/tests/test_lp_parser.py +332 -0
  54. clara_opt-0.1.0/tests/test_simplex.py +271 -0
@@ -0,0 +1,68 @@
1
+ # Claude
2
+ CLAUDE.md
3
+ .claude/
4
+
5
+ # Python
6
+ __pycache__/
7
+ *.py[cod]
8
+ *$py.class
9
+ *.egg-info/
10
+ *.egg
11
+ dist/
12
+ build/
13
+ .eggs/
14
+ *.so
15
+ *.whl
16
+ pip-log.txt
17
+ pip-delete-this-directory.txt
18
+
19
+ # Virtual environments
20
+ .venv/
21
+ venv/
22
+ env/
23
+
24
+ # IDE
25
+ .idea/
26
+ .vscode/
27
+ *.swp
28
+ *.swo
29
+ *~
30
+
31
+ # Testing
32
+ .pytest_cache/
33
+ .coverage
34
+ htmlcov/
35
+ .hypothesis/
36
+
37
+ # Type checking
38
+ .mypy_cache/
39
+
40
+ # Ruff
41
+ .ruff_cache/
42
+
43
+ # LaTeX (xelatex)
44
+ *.aux
45
+ *.bbl
46
+ *.bcf
47
+ *.blg
48
+ *.fdb_latexmk
49
+ *.fls
50
+ *.log
51
+ *.out
52
+ *.run.xml
53
+ *.synctex.gz
54
+ *.toc
55
+ *.lof
56
+ *.lot
57
+ *.nav
58
+ *.snm
59
+ *.vrb
60
+ *.xdv
61
+
62
+ # Benchmark results (keep only final/)
63
+ benchmarks/results/*
64
+ !benchmarks/results/final/
65
+
66
+ # OS
67
+ .DS_Store
68
+ Thumbs.db
@@ -0,0 +1,41 @@
1
+ # Contributing to CLARA
2
+
3
+ ## Development Setup
4
+
5
+ ```bash
6
+ git clone https://github.com/yoonsik-jung-opt/clara-opt.git
7
+ cd clara-opt
8
+ python3.10 -m venv .venv
9
+ source .venv/bin/activate
10
+ pip install -e ".[dev]"
11
+ ```
12
+
13
+ ## Code Style
14
+
15
+ - Python 3.10+, type hints required on all public functions
16
+ - Format: `ruff format clara/ tests/`
17
+ - Lint: `ruff check clara/ tests/`
18
+ - Tests: `pytest tests/`
19
+
20
+ ## Branch Convention
21
+
22
+ - `feat/*` — new features
23
+ - `fix/*` — bug fixes
24
+ - `bench/*` — benchmarks
25
+ - `paper/*` — paper writing
26
+ - All PRs target `dev` branch
27
+
28
+ ## Commit Convention
29
+
30
+ `feat:`, `fix:`, `test:`, `bench:`, `paper:`, `docs:`, `refactor:`, `ci:`, `chore:`
31
+
32
+ ## Testing
33
+
34
+ Every new feature must include tests. Run the full suite before submitting:
35
+
36
+ ```bash
37
+ pytest tests/ -v
38
+ ```
39
+
40
+ Cross-validation tests compare Internal Simplex vs HiGHS — both engines
41
+ must agree on optimal values, duals, and sensitivity ranges.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Yoonsik
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.
@@ -0,0 +1,123 @@
1
+ Metadata-Version: 2.4
2
+ Name: clara-opt
3
+ Version: 0.1.0
4
+ Summary: A white-box optimization solver that explains why solutions are optimal, when reoptimization is needed, and what changed.
5
+ Project-URL: Homepage, https://github.com/yoonsikyang/clara-opt
6
+ Project-URL: Repository, https://github.com/yoonsikyang/clara-opt
7
+ Project-URL: Issues, https://github.com/yoonsikyang/clara-opt/issues
8
+ Author: Yoonsik Yang
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: explainability,linear-programming,operations-research,optimization,reoptimization,sensitivity-analysis
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Education
14
+ Classifier: Intended Audience :: Science/Research
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
22
+ Classifier: Topic :: Scientific/Engineering :: Mathematics
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.10
25
+ Requires-Dist: click>=8.0
26
+ Requires-Dist: highspy>=1.7
27
+ Requires-Dist: numpy>=1.24
28
+ Provides-Extra: dev
29
+ Requires-Dist: hypothesis>=6.100; extra == 'dev'
30
+ Requires-Dist: pytest-cov>=5.0; extra == 'dev'
31
+ Requires-Dist: pytest>=8.0; extra == 'dev'
32
+ Requires-Dist: ruff>=0.4; extra == 'dev'
33
+ Description-Content-Type: text/markdown
34
+
35
+ # CLARA — Classical LP Analysis for Reoptimization and Attribution
36
+
37
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
38
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://python.org)
39
+
40
+ A white-box optimization solver that explains why solutions are optimal,
41
+ when reoptimization is needed, and what changed.
42
+
43
+ ## Features
44
+
45
+ - **Solver-intrinsic explanations** — not post-hoc ML surrogate, but direct
46
+ interpretation of basis, dual values, and sensitivity ranges
47
+ - **Dual backend** — Internal Revised Simplex (full transparency, step-by-step trace)
48
+ or HiGHS (performance for large problems)
49
+ - **Three explanation levels** mapped to the XAIOR framework (De Bock et al., 2024):
50
+ - *Understandability*: Which constraints are binding? What resources are fully used?
51
+ - *Justifiability*: Why is each variable at this value? What's the reduced cost?
52
+ - *Actionability*: What can change without breaking the solution? Where to invest?
53
+ - **CLI and Python API** — `clara explain problem.lp` or import as library
54
+
55
+ ## Installation
56
+
57
+ ```bash
58
+ pip install clara-opt
59
+ ```
60
+
61
+ For development:
62
+
63
+ ```bash
64
+ git clone https://github.com/yoonsik-jung-opt/clara-opt.git
65
+ cd clara-opt
66
+ pip install -e ".[dev]"
67
+ ```
68
+
69
+ ## Quick Start
70
+
71
+ ```bash
72
+ # Explain a production planning problem
73
+ clara explain examples/production.lp
74
+
75
+ # Brief summary
76
+ clara explain problem.lp --level brief
77
+
78
+ # JSON output
79
+ clara explain problem.lp --format json
80
+
81
+ # Use HiGHS engine for larger problems
82
+ clara explain problem.lp --engine highs
83
+
84
+ # Solve only (no explanation)
85
+ clara solve problem.lp
86
+
87
+ # Problem info
88
+ clara info problem.lp
89
+ ```
90
+
91
+ ### Python API
92
+
93
+ ```python
94
+ from clara.io import read_lp
95
+ from clara.engine.simplex import RevisedSimplex
96
+ from clara.explain import Explainer
97
+
98
+ problem = read_lp("problem.lp")
99
+ state = RevisedSimplex(problem).solve()
100
+ report = Explainer().explain(state, problem=problem)
101
+ print(report.to_text())
102
+ ```
103
+
104
+ ## Roadmap
105
+
106
+ - [x] v0.1.0 — LP solver + explainer + CLI (you are here)
107
+ - [ ] v0.5.0 — MIP (Branch-and-Bound), counterfactual analysis, TUI
108
+ - [ ] v1.0.0 — Incremental reoptimization, parametric LP, streaming
109
+ - [ ] v2.0.0 — LLM hybrid explanations, web UI
110
+
111
+ ## Academic Use
112
+
113
+ CLARA fills a gap identified in the XAIOR framework (De Bock et al., 2024, EJOR):
114
+ explainable AI for mathematical optimization solvers. If you use CLARA in research,
115
+ please cite:
116
+
117
+ ```
118
+ (citation TBD — paper in preparation for Mathematical Programming Computation)
119
+ ```
120
+
121
+ ## License
122
+
123
+ MIT © 2026 Yoonsik
@@ -0,0 +1,89 @@
1
+ # CLARA — Classical LP Analysis for Reoptimization and Attribution
2
+
3
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
4
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://python.org)
5
+
6
+ A white-box optimization solver that explains why solutions are optimal,
7
+ when reoptimization is needed, and what changed.
8
+
9
+ ## Features
10
+
11
+ - **Solver-intrinsic explanations** — not post-hoc ML surrogate, but direct
12
+ interpretation of basis, dual values, and sensitivity ranges
13
+ - **Dual backend** — Internal Revised Simplex (full transparency, step-by-step trace)
14
+ or HiGHS (performance for large problems)
15
+ - **Three explanation levels** mapped to the XAIOR framework (De Bock et al., 2024):
16
+ - *Understandability*: Which constraints are binding? What resources are fully used?
17
+ - *Justifiability*: Why is each variable at this value? What's the reduced cost?
18
+ - *Actionability*: What can change without breaking the solution? Where to invest?
19
+ - **CLI and Python API** — `clara explain problem.lp` or import as library
20
+
21
+ ## Installation
22
+
23
+ ```bash
24
+ pip install clara-opt
25
+ ```
26
+
27
+ For development:
28
+
29
+ ```bash
30
+ git clone https://github.com/yoonsik-jung-opt/clara-opt.git
31
+ cd clara-opt
32
+ pip install -e ".[dev]"
33
+ ```
34
+
35
+ ## Quick Start
36
+
37
+ ```bash
38
+ # Explain a production planning problem
39
+ clara explain examples/production.lp
40
+
41
+ # Brief summary
42
+ clara explain problem.lp --level brief
43
+
44
+ # JSON output
45
+ clara explain problem.lp --format json
46
+
47
+ # Use HiGHS engine for larger problems
48
+ clara explain problem.lp --engine highs
49
+
50
+ # Solve only (no explanation)
51
+ clara solve problem.lp
52
+
53
+ # Problem info
54
+ clara info problem.lp
55
+ ```
56
+
57
+ ### Python API
58
+
59
+ ```python
60
+ from clara.io import read_lp
61
+ from clara.engine.simplex import RevisedSimplex
62
+ from clara.explain import Explainer
63
+
64
+ problem = read_lp("problem.lp")
65
+ state = RevisedSimplex(problem).solve()
66
+ report = Explainer().explain(state, problem=problem)
67
+ print(report.to_text())
68
+ ```
69
+
70
+ ## Roadmap
71
+
72
+ - [x] v0.1.0 — LP solver + explainer + CLI (you are here)
73
+ - [ ] v0.5.0 — MIP (Branch-and-Bound), counterfactual analysis, TUI
74
+ - [ ] v1.0.0 — Incremental reoptimization, parametric LP, streaming
75
+ - [ ] v2.0.0 — LLM hybrid explanations, web UI
76
+
77
+ ## Academic Use
78
+
79
+ CLARA fills a gap identified in the XAIOR framework (De Bock et al., 2024, EJOR):
80
+ explainable AI for mathematical optimization solvers. If you use CLARA in research,
81
+ please cite:
82
+
83
+ ```
84
+ (citation TBD — paper in preparation for Mathematical Programming Computation)
85
+ ```
86
+
87
+ ## License
88
+
89
+ MIT © 2026 Yoonsik
File without changes
File without changes
@@ -0,0 +1,3 @@
1
+ """CLARA — Classical LP Analysis for Reoptimization and Attribution."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,198 @@
1
+ """CLARA command-line interface.
2
+
3
+ Pipeline: LP Parser → Solve Engine → Explainer → Output
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import Optional
11
+
12
+ import click
13
+
14
+ from clara.io.lp_parser import LPParseError, read_lp
15
+ from clara.engine.simplex import RevisedSimplex
16
+ from clara.explain.explainer import Explainer
17
+ from clara.explain.types import DetailLevel
18
+
19
+
20
+ @click.group()
21
+ @click.version_option(version="0.1.0", prog_name="clara")
22
+ def main():
23
+ """CLARA — Classical LP Analysis for Reoptimization and Attribution.
24
+
25
+ A white-box optimization solver that explains why solutions are optimal,
26
+ when reoptimization is needed, and what changed.
27
+ """
28
+ pass
29
+
30
+
31
+ @main.command()
32
+ @click.argument("file", type=click.Path(exists=True, dir_okay=False))
33
+ @click.option("--engine", type=click.Choice(["internal", "highs"]),
34
+ default="internal", help="Solve engine.")
35
+ @click.option("--level", type=click.Choice(["brief", "detailed"]),
36
+ default="detailed", help="Explanation detail level.")
37
+ @click.option("--format", "fmt", type=click.Choice(["text", "json"]),
38
+ default="text", help="Output format.")
39
+ @click.option("--output", "-o", type=click.Path(), default=None,
40
+ help="Write output to file.")
41
+ @click.option("--quiet", "-q", is_flag=True, help="Suppress header.")
42
+ def explain(file, engine, level, fmt, output, quiet):
43
+ """Solve an LP and explain the solution."""
44
+ problem = _parse_file(file)
45
+ state = _solve(problem, engine)
46
+
47
+ if not state.is_optimal:
48
+ click.secho(f"Problem is {state.status.name}.", fg="yellow", err=True)
49
+ sys.exit(1)
50
+
51
+ detail = DetailLevel.BRIEF if level == "brief" else DetailLevel.DETAILED
52
+ explainer = Explainer()
53
+ report = explainer.explain(state, level=detail, problem=problem)
54
+
55
+ if fmt == "json":
56
+ text = report.to_json()
57
+ else:
58
+ text = report.to_text(level=detail)
59
+ if quiet:
60
+ text = _strip_header(text)
61
+
62
+ _write_output(text, output)
63
+
64
+
65
+ @main.command()
66
+ @click.argument("file", type=click.Path(exists=True, dir_okay=False))
67
+ @click.option("--engine", type=click.Choice(["internal", "highs"]),
68
+ default="internal")
69
+ @click.option("--format", "fmt", type=click.Choice(["text", "json"]),
70
+ default="text")
71
+ @click.option("--output", "-o", type=click.Path(), default=None)
72
+ def solve(file, engine, fmt, output):
73
+ """Solve an LP without explanation (quick mode)."""
74
+ problem = _parse_file(file)
75
+ state = _solve(problem, engine)
76
+
77
+ if fmt == "json":
78
+ text = state.to_json()
79
+ else:
80
+ text = _format_solve_text(state)
81
+
82
+ _write_output(text, output)
83
+
84
+
85
+ @main.command()
86
+ @click.argument("file", type=click.Path(exists=True, dir_okay=False))
87
+ def info(file):
88
+ """Show problem statistics without solving."""
89
+ problem = _parse_file(file)
90
+ nonzeros = int((problem.A != 0).sum())
91
+ lines = [
92
+ f"Problem: {problem.name}",
93
+ f"Variables: {problem.num_variables}",
94
+ f"Constraints: {problem.num_constraints}",
95
+ f"Nonzeros: {nonzeros}",
96
+ ]
97
+ click.echo("\n".join(lines))
98
+
99
+
100
+ @main.command(name="what-if", hidden=True)
101
+ @click.argument("file", type=click.Path(exists=True, dir_okay=False))
102
+ def what_if(file):
103
+ """[Phase 1.5] Interactive what-if analysis."""
104
+ click.secho("what-if is not yet implemented (Phase 1.5).", fg="yellow")
105
+
106
+
107
+ @main.command(hidden=True)
108
+ @click.argument("file", type=click.Path(exists=True, dir_okay=False))
109
+ @click.option("--params", type=click.Path(exists=True))
110
+ def watch(file, params):
111
+ """[Phase 2] Stream parameter changes and reoptimize."""
112
+ click.secho("watch is not yet implemented (Phase 2).", fg="yellow")
113
+
114
+
115
+ # ============================================================
116
+ # Helpers
117
+ # ============================================================
118
+
119
+ def _parse_file(filepath: str):
120
+ """Parse LP file with user-friendly error handling."""
121
+ try:
122
+ return read_lp(filepath)
123
+ except LPParseError as e:
124
+ click.secho(f"Parse error: {e}", fg="red", err=True)
125
+ sys.exit(1)
126
+
127
+
128
+ def _solve(problem, engine_name: str):
129
+ """Solve with the selected engine."""
130
+ solver = _make_engine(engine_name)
131
+ state = solver.solve()
132
+ return state
133
+
134
+
135
+ def _make_engine(engine_name: str):
136
+ """Factory for solve engines. Returns a solver instance (not yet solved)."""
137
+ # For now, we need the problem to create the solver.
138
+ # This is handled differently — _solve creates the engine with the problem.
139
+ raise NotImplementedError # not used directly
140
+
141
+
142
+ def _solve(problem, engine_name: str):
143
+ """Solve with the selected engine."""
144
+ if engine_name == "highs":
145
+ try:
146
+ from clara.engine.highs_backend import HiGHSBackend
147
+ return HiGHSBackend().solve(problem)
148
+ except (ImportError, ModuleNotFoundError):
149
+ click.secho(
150
+ "HiGHS backend not yet available. Using internal engine.",
151
+ fg="yellow", err=True,
152
+ )
153
+
154
+ solver = RevisedSimplex(problem)
155
+ return solver.solve()
156
+
157
+
158
+ def _format_solve_text(state) -> str:
159
+ """Minimal text output for `clara solve`."""
160
+ lines = [f"Optimal value: {state.optimal_value:.4f}"]
161
+ for v in state.variables:
162
+ lines.append(f" {v.name} = {v.value:.4f}")
163
+ lines.append(
164
+ f"Solved in {state.iteration_count} iterations, "
165
+ f"{state.solve_time_seconds:.3f}s ({state.engine.name})"
166
+ )
167
+ return "\n".join(lines)
168
+
169
+
170
+ def _strip_header(text: str) -> str:
171
+ """Remove header section (everything before double newline after header)."""
172
+ # Header is between two ═ lines, skip past the second one
173
+ lines = text.split("\n")
174
+ sep_count = 0
175
+ start = 0
176
+ for i, line in enumerate(lines):
177
+ if line.startswith("\u2550"):
178
+ sep_count += 1
179
+ if sep_count == 2:
180
+ start = i + 1
181
+ break
182
+ # Skip blank line after header
183
+ while start < len(lines) and not lines[start].strip():
184
+ start += 1
185
+ return "\n".join(lines[start:])
186
+
187
+
188
+ def _write_output(text: str, output_path: Optional[str]) -> None:
189
+ """Write to file or stdout."""
190
+ if output_path:
191
+ try:
192
+ Path(output_path).write_text(text)
193
+ click.secho(f"Written to {output_path}", fg="green", err=True)
194
+ except IOError as e:
195
+ click.secho(f"Write error: {e}", fg="red", err=True)
196
+ sys.exit(1)
197
+ else:
198
+ click.echo(text)
@@ -0,0 +1 @@
1
+ """Solver engine."""