From 61d2958b524d4b76cf55d3e2d004cc2f7b7850f5 Mon Sep 17 00:00:00 2001 From: ThomSerg Date: Wed, 9 Sep 2026 15:20:42 +0200 Subject: [PATCH 01/12] Start of a skills file --- skills/.gitignore | 5 +++++ skills/cpmpy-model/SKILL.md | 30 ++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) create mode 100644 skills/.gitignore create mode 100644 skills/cpmpy-model/SKILL.md diff --git a/skills/.gitignore b/skills/.gitignore new file mode 100644 index 000000000..8d95cc565 --- /dev/null +++ b/skills/.gitignore @@ -0,0 +1,5 @@ +# Generated reference files (see generate_references.py) are derived +# entirely from this repo's own docs/, already tracked in git — committing a +# second copy here would just be a duplicate that can silently drift. +# Run `python skills/generate_references.py` to (re)create them locally. +**/references/*.generated.md diff --git a/skills/cpmpy-model/SKILL.md b/skills/cpmpy-model/SKILL.md new file mode 100644 index 000000000..d68fa004d --- /dev/null +++ b/skills/cpmpy-model/SKILL.md @@ -0,0 +1,30 @@ +--- +name: cpmpy-model +description: Write and solve problems in CPMpy, a constraint programming and modelling library in Python, based on numpy, with direct solver access. +version: 0.0.1 +license: Apache-2.0 +metadata: + author: CPMpy team + domain: constraint-programming + library: cpmpy + tags: + - constraint-programming + - combinatorial-optimization + - constraint-modeling + - decision-variables + - global-constraints + - python + - or-tools + - optimization +--- + +# CPMpy modeling + +Model and solve constrained (optimisation) problems using CPMpy with one of its many solver backends across SMT, CP, ILP, PB, (max)SAT. Model once in a numpy-style high-level language, using **decision variables** plus **constraints** over them, optionally with an **objective**, then solve with any backend. + +For an overview of the available backends and their abilities: [index](https://cpmpy.readthedocs.io/en/latest/index.html) + +For a compact, complete API listing: [references/api-cheatsheet.generated.md](references/api-cheatsheet.generated.md) + +For the full narrative documentation (global constraints catalog, solver selection, incremental solving, I/O, debugging), see [modeling](https://cpmpy.readthedocs.io/en/latest/modeling.html). + From 6d056561109384d5568a0afaa81a9ba293f411dc Mon Sep 17 00:00:00 2001 From: ThomSerg Date: Wed, 9 Sep 2026 15:54:55 +0200 Subject: [PATCH 02/12] Add sphinx-llm --- docs/conf.py | 10 ++++++++-- setup.py | 2 +- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index d7b5dddc2..d9122ffc4 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -48,7 +48,8 @@ 'sphinx_automodapi.smart_resolver', 'sphinx.ext.napoleon', 'sphinx.ext.todo', - 'sphinx.ext.autosectionlabel' + 'sphinx.ext.autosectionlabel', + 'sphinx_llm.txt' ] myst_enable_extensions = [ @@ -117,4 +118,9 @@ # -- Options for HTMLHelp output --------------------------------------------- # Output file base name for HTML help builder. -htmlhelp_basename = 'cpmpy' \ No newline at end of file +htmlhelp_basename = 'cpmpy' + +# Config options for sphinx_llm +llms_txt_exclude = [ + 'docs_todo', +] \ No newline at end of file diff --git a/setup.py b/setup.py index 958e1d6c1..3ee475429 100644 --- a/setup.py +++ b/setup.py @@ -83,7 +83,7 @@ def get_version(rel_path): # Other "test": ["pytest", "pytest-timeout"], "type": ["mypy", "types-tqdm"], - "docs": ["sphinx>=5.3.0", "sphinx_rtd_theme>=2.0.0", "myst_parser", "sphinx-automodapi", "readthedocs-sphinx-search>=0.3.2"], + "docs": ["sphinx>=5.3.0", "sphinx_rtd_theme>=2.0.0", "myst_parser", "sphinx-automodapi", "readthedocs-sphinx-search>=0.3.2", "sphinx_llm"], }, entry_points={ 'console_scripts': [ From 5d89c76b49fd11ab9a2e27839ad7c50021be3b0b Mon Sep 17 00:00:00 2001 From: ThomSerg Date: Thu, 10 Sep 2026 09:44:26 +0200 Subject: [PATCH 03/12] Skill install setup --- .gitignore | 3 + MANIFEST.in | 3 + cpmpy/__init__.py | 4 + cpmpy/cli.py | 84 ++++++++++++-- setup.py | 26 +++++ skills/generate_references.py | 202 ++++++++++++++++++++++++++++++++++ skills/sync_into_package.py | 63 +++++++++++ 7 files changed, 378 insertions(+), 7 deletions(-) create mode 100644 MANIFEST.in create mode 100644 skills/generate_references.py create mode 100644 skills/sync_into_package.py diff --git a/.gitignore b/.gitignore index 400278d4a..18530bbef 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# Generated copy of skills/ for library-skills discovery (see skills/sync_into_package.py) +cpmpy/.agents/ + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 000000000..f0c2eabef --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,3 @@ +# Hidden package-data dirs are skipped unless listed. Copied at build time +# from skills/ into cpmpy/.agents/skills/ (see skills/sync_into_package.py). +recursive-include cpmpy/.agents * diff --git a/cpmpy/__init__.py b/cpmpy/__init__.py index 778fec95d..514f13724 100644 --- a/cpmpy/__init__.py +++ b/cpmpy/__init__.py @@ -11,6 +11,10 @@ - `expressions`: all forms of expression objects that allow you to specify constraints and objectives over variables - `solvers`: CPMpy classes that translate a model into approriate calls of a solver's API - `transformations`: common methods for transforming expressions into other expressions, used by `solvers` modules to simplify/rewrite expressions + +Agent Skills are authored in ``skills/`` and copied into ``cpmpy/.agents/skills/`` +at install time. Install them into a project with ``pip install 'cpmpy[skills]'`` +then ``cpmpy skills``; refresh after upgrading with ``cpmpy skills update``. """ # Tias Guns, 2019-2026 diff --git a/cpmpy/cli.py b/cpmpy/cli.py index 2ac220b0d..a1ce14f72 100644 --- a/cpmpy/cli.py +++ b/cpmpy/cli.py @@ -1,32 +1,102 @@ """ Command-line interface for CPMpy. -This module provides a simple CLI to interact with CPMpy, primarily to display -version information about CPMpy itself and the available solver backends. - Usage: cpmpy Commands: version Show the CPMpy library version and the versions of installed solver backends. + skills Install or update Agent Skills for the current project (requires cpmpy[skills]). """ +from __future__ import annotations + import argparse +import importlib.util +import shutil +import subprocess +import sys + from cpmpy import __version__ import cpmpy as cp +SKILLS_EXTRA_HINT = "cpmpy skills requires optional dependencies. Install with: pip install 'cpmpy[skills]'" + +SKILLS_HELP = """\ +usage: cpmpy skills [-h] [update] [library-skills args ...] + +Install or refresh CPMpy Agent Skills in the current project via library-skills. + +Commands: + (none) Interactive discover / install / repair + update Refresh project links to the currently installed CPMpy + (library-skills --yes). Does not upgrade the package; + run `pip install -U 'cpmpy[skills]'` first for new content. + +Any other arguments are forwarded to library-skills, for example: + cpmpy skills install -y --claude + cpmpy skills list + cpmpy skills update --claude --copy +""" + def command_version(args): print(f"CPMpy version: {__version__}") cp.SolverLookup().print_version() -def main(): + +def _library_skills_available() -> bool: + return importlib.util.find_spec("library_skills") is not None + + +def run_library_skills(argv: list[str]) -> int: + """ + Run the library-skills CLI with ``argv``. Returns the process exit code. + """ + if not _library_skills_available(): + print(SKILLS_EXTRA_HINT, file=sys.stderr) + return 1 + + executable = shutil.which("library-skills") + if executable: + cmd = [executable, *argv] + else: + cmd = [sys.executable, "-m", "library_skills", *argv] + completed = subprocess.run(cmd) + return completed.returncode + + +def command_skills(argv: list[str]) -> int: + """ + Native ``cpmpy skills`` command. ``update`` maps to ``library-skills --yes``. + """ + if argv and argv[0] in ("-h", "--help"): + print(SKILLS_HELP, end="") + return 0 + if argv and argv[0] == "update": + argv = ["--yes", *argv[1:]] + return run_library_skills(argv) + + +def main(argv: list[str] | None = None): + argv = sys.argv[1:] if argv is None else list(argv) + if argv and argv[0] == "skills": + raise SystemExit(command_skills(argv[1:])) + parser = argparse.ArgumentParser(description="CPMpy command line interface") subparsers = parser.add_subparsers(dest="command", required=True) - # cpmpy version - version_parser = subparsers.add_parser("version", help="Show version information on CPMpy and its solver backends") + version_parser = subparsers.add_parser( + "version", help="Show version information on CPMpy and its solver backends" + ) version_parser.set_defaults(func=command_version) - args = parser.parse_args() + # Registered so `cpmpy --help` lists it; dispatch is handled above so + # library-skills flags like --claude are not swallowed by argparse. + subparsers.add_parser( + "skills", + help="Install or update Agent Skills for the current project (using library-skills)", + ) + + args = parser.parse_args(argv) args.func(args) diff --git a/setup.py b/setup.py index 3ee475429..b00ad5a9c 100644 --- a/setup.py +++ b/setup.py @@ -1,7 +1,29 @@ from setuptools import find_packages, setup +from setuptools.command.build_py import build_py as _build_py import codecs +import importlib.util import os.path +def _load_sync_skills(): + """ + Load skills/sync_into_package.py without importing the cpmpy package. + """ + path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "skills", "sync_into_package.py") + spec = importlib.util.spec_from_file_location("cpmpy_sync_skills", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module.sync_skills + + +class build_py(_build_py): + """ + Copy authored skills/ into cpmpy/.agents/skills/ before packaging. + """ + + def run(self): + _load_sync_skills()() + super().run() + def read(rel_path): here = os.path.abspath(os.path.dirname(__file__)) with codecs.open(os.path.join(here, rel_path), 'r') as fp: @@ -84,6 +106,10 @@ def get_version(rel_path): "test": ["pytest", "pytest-timeout"], "type": ["mypy", "types-tqdm"], "docs": ["sphinx>=5.3.0", "sphinx_rtd_theme>=2.0.0", "myst_parser", "sphinx-automodapi", "readthedocs-sphinx-search>=0.3.2", "sphinx_llm"], + "skills": ["library-skills"], + }, + cmdclass={ + "build_py": build_py, }, entry_points={ 'console_scripts': [ diff --git a/skills/generate_references.py b/skills/generate_references.py new file mode 100644 index 000000000..a945e6837 --- /dev/null +++ b/skills/generate_references.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python3 +""" +Generate skill reference files from this repo's Sphinx docs. + +A skill's `references/*.generated.md` files are never hand-edited, they are +"generated" from the agent-friendly Markdown mirror that the `sphinx_llm` Sphinx +extension writes alongside the HTML build (one `.html.md` per doc page +in `docs/_build/html/`, enabled in `docs/conf.py`). This keeps a skill's +reference material in sync with the actual documentation. + +Setup (only needed to run this script, not to use a skill): + pip install -e ".[docs]" # from the repo root; installs sphinx_llm + python skills/generate_references.py --build + # builds the docs, then generates + +Or build the docs yourself first (`cd docs && make html`) and just run: + python skills/generate_references.py + +Usage: + python skills/generate_references.py # (re)generate every file below + python skills/generate_references.py --build # build the docs first + python skills/generate_references.py --check # verify generated files are + # up to date; exit 1 if not + # (use in CI after docs change) + +To add a new generated reference file, add an entry to SOURCES below. +""" +from __future__ import annotations + +import argparse +import os +import re +import subprocess +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +DOCS_DIR = REPO_ROOT / "docs" +DOCS_BUILD_HTML = DOCS_DIR / "_build" / "html" + +# Each entry: (sphinx_llm-generated markdown file, relative to +# docs/_build/html/, +# ordered list of heading texts to extract from it, or None to +# copy the whole page, +# output file relative to repo root) +SOURCES = [ + ( + "summary.html.md", + None, + "skills/cpmpy-model/references/api-cheatsheet.generated.md", + ), +] + +GENERATED_HEADER = """ + +""" + +_HEADING_RE = re.compile(r"^(#{1,6})\s+(.*?)\s*$") + + +def _find_headings(lines: list[str]) -> list[tuple[int, int, str]]: + """Return (line_index, level, text) for every Markdown heading line.""" + marks = [] + in_code_block = False + for i, line in enumerate(lines): + if line.startswith("```"): + in_code_block = not in_code_block + continue + if in_code_block: + continue + m = _HEADING_RE.match(line) + if m: + marks.append((i, len(m.group(1)), m.group(2))) + return marks + + +def extract_sections(text: str, headings: list[str]) -> str: + """Extract each named section (heading + its body + any subsections) from + a Markdown document, in the order given by `headings`, not the order they + appear in the source.""" + lines = text.splitlines(keepends=True) + marks = _find_headings(lines) + by_text: dict[str, list[int]] = {} + for idx, (_line_i, _level, heading_text) in enumerate(marks): + by_text.setdefault(heading_text, []).append(idx) + + chunks = [] + for wanted in headings: + matches = by_text.get(wanted) + if not matches: + raise ValueError( + f"heading {wanted!r} not found (looked for an exact Markdown " + f"heading match)" + ) + if len(matches) > 1: + raise ValueError(f"heading {wanted!r} is ambiguous ({len(matches)} matches)") + mark_idx = matches[0] + start_line, level, _ = marks[mark_idx] + end_line = len(lines) + for line_i, other_level, _ in marks[mark_idx + 1 :]: + if other_level <= level: + end_line = line_i + break + chunk = "".join(lines[start_line:end_line]).rstrip("\n") + chunks.append(chunk) + return "\n\n".join(chunks) + "\n" + + +def build_docs() -> None: + # Invoke sphinx-build as a module of *this* interpreter (sys.executable), + # not via `make`/PATH — that can silently resolve to a different Python + # environment's sphinx-build, one without sphinx_llm installed. + subprocess.run( + [sys.executable, "-m", "sphinx", "-b", "html", str(DOCS_DIR), str(DOCS_BUILD_HTML)], + check=True, + ) + + +# sphinx_llm's markdown links other doc pages relative to docs/_build/html/ +# (e.g. `modeling.html.md`, `api/expressions/globalconstraints.html.md#...`). +# Once copied into a skill's references/ dir those links resolve to the wrong +# place, so rewrite them relative to the generated file's own location instead. +_MD_LINK_RE = re.compile(r"\]\(([^)\s]+)\)") + + +def _rebase_links(text: str, output_path: Path) -> str: + prefix = os.path.relpath(DOCS_BUILD_HTML, output_path.parent) + if prefix == ".": + return text + + def repl(match: re.Match) -> str: + target = match.group(1) + if target.startswith(("http://", "https://", "mailto:", "#", "/")): + return match.group(0) + return f"]({prefix}/{target})" + + return _MD_LINK_RE.sub(repl, text) + + +def build(source: str, headings: list[str] | None, output: str) -> str: + source_path = DOCS_BUILD_HTML / source + if not source_path.exists(): + raise FileNotFoundError( + f"{source_path.relative_to(REPO_ROOT)} not found. Build the docs first: " + f"`pip install -e '.[docs]'` then `python skills/generate_references.py " + f"--build` (or `cd docs && make html` yourself)." + ) + text = source_path.read_text() + body = text if headings is None else extract_sections(text, headings) + body = _rebase_links(body, REPO_ROOT / output) + + sections_note = f" (sections: {', '.join(headings)})" if headings else "" + header = GENERATED_HEADER.format(source=source, sections_note=sections_note) + return header + body + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--build", action="store_true", help="build the Sphinx docs first (runs `make html`)" + ) + parser.add_argument( + "--check", + action="store_true", + help="don't write files; exit 1 if any generated file is stale/missing", + ) + args = parser.parse_args() + + if args.build: + build_docs() + + stale = [] + for source, headings, output in SOURCES: + content = build(source, headings, output) + output_path = REPO_ROOT / output + if args.check: + current = output_path.read_text() if output_path.exists() else None + if current != content: + stale.append(output) + else: + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(content) + print(f"wrote {output}") + + if args.check and stale: + print("stale or missing generated reference file(s):", file=sys.stderr) + for output in stale: + print(f" {output}", file=sys.stderr) + print("run: python skills/generate_references.py --build", file=sys.stderr) + return 1 + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/sync_into_package.py b/skills/sync_into_package.py new file mode 100644 index 000000000..d89a63cad --- /dev/null +++ b/skills/sync_into_package.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +""" +Copy Agent Skills into the importable CPMpy package. + +Agent skills live in this directory (``skills//``). ``library-skills`` +only discovers ``.agents/skills/*/SKILL.md`` inside the installed package, so +this script copies those directories to ``cpmpy/.agents/skills/`` at +build/install time. The destination is generated (gitignored); do not edit it +by hand. + +Called from ``setup.py``'s ``build_py`` (wheels and editable installs). You can +also run it directly: + + python skills/sync_into_package.py +""" + +from __future__ import annotations + +import shutil +from pathlib import Path + +SKILLS_DIR = Path(__file__).resolve().parent +REPO_ROOT = SKILLS_DIR.parent +DEFAULT_SRC = SKILLS_DIR +DEFAULT_DEST = REPO_ROOT / "cpmpy" / ".agents" / "skills" + + +def sync_skills( + src: Path | str | None = None, + dest: Path | str | None = None, +) -> list[str]: + """ + Copy each ``skills//`` directory that contains a ``SKILL.md``. + + Wipes ``dest`` first so removed skills do not linger. Returns the names of + skill directories that were copied. Generated ``*.generated.md`` files are + included when present (they stay gitignored under ``skills/``). + """ + src_dir = Path(src) if src is not None else DEFAULT_SRC + dest_dir = Path(dest) if dest is not None else DEFAULT_DEST + + if dest_dir.exists(): + shutil.rmtree(dest_dir) + dest_dir.mkdir(parents=True, exist_ok=True) + + copied: list[str] = [] + if not src_dir.is_dir(): + return copied + + for skill in sorted(src_dir.iterdir()): + if not skill.is_dir(): + continue + if not (skill / "SKILL.md").is_file(): + continue + shutil.copytree(skill, dest_dir / skill.name) + copied.append(skill.name) + return copied + + +if __name__ == "__main__": + names = sync_skills() + dest = DEFAULT_DEST + print(f"Copied {len(names)} skill(s) to {dest}: {', '.join(names) or '(none)'}") From eb7379fc2d8f81ff4bca5586c678ece842c43e79 Mon Sep 17 00:00:00 2001 From: ThomSerg Date: Thu, 10 Sep 2026 10:59:24 +0200 Subject: [PATCH 04/12] Regenerate skill references --- cpmpy/__init__.py | 2 + cpmpy/cli.py | 195 +++++++++++++++++++++++++++++++++- setup.py | 11 +- skills/generate_references.py | 29 +++++ skills/sync_into_package.py | 83 +++++++++++++-- 5 files changed, 308 insertions(+), 12 deletions(-) diff --git a/cpmpy/__init__.py b/cpmpy/__init__.py index 514f13724..ec9fbbc72 100644 --- a/cpmpy/__init__.py +++ b/cpmpy/__init__.py @@ -15,6 +15,8 @@ Agent Skills are authored in ``skills/`` and copied into ``cpmpy/.agents/skills/`` at install time. Install them into a project with ``pip install 'cpmpy[skills]'`` then ``cpmpy skills``; refresh after upgrading with ``cpmpy skills update``. +Rebuild generated references from a source checkout with +``cpmpy skills update --regenerate`` (requires ``cpmpy[docs]``). """ # Tias Guns, 2019-2026 diff --git a/cpmpy/cli.py b/cpmpy/cli.py index a1ce14f72..82175c66c 100644 --- a/cpmpy/cli.py +++ b/cpmpy/cli.py @@ -16,13 +16,41 @@ import shutil import subprocess import sys +from pathlib import Path from cpmpy import __version__ import cpmpy as cp SKILLS_EXTRA_HINT = "cpmpy skills requires optional dependencies. Install with: pip install 'cpmpy[skills]'" +DOCS_EXTRA_HINT = ( + "cpmpy skills update --regenerate requires the docs extra. " + "Install with: pip install 'cpmpy[docs]'" +) +REGENERATE_SOURCE_HINT = """\ +--regenerate is only available when CPMpy is installed from source. -SKILLS_HELP = """\ +This install looks like a wheel (e.g. pip install cpmpy). There is no docs/ +tree here to rebuild from; generated reference files already ship in the +package. To pick up a newer snapshot: + + pip install -U cpmpy + cpmpy skills update + +To regenerate from the Sphinx docs, clone the repo and install editable: + + git clone https://github.com/CPMpy/cpmpy.git + cd cpmpy + pip install -e '.[docs]' + cpmpy skills update --regenerate +""" + +CONTINUE_WITHOUT_GENERATED_PROMPT = "Continue without them? [y/N] " +NON_TTY_MISSING_REFS_HINT = ( + "Not a TTY; aborting. Generate the files with " + "`cpmpy skills update --regenerate` or rerun from a terminal." +) + +SKILLS_HELP_PREAMBLE = """\ usage: cpmpy skills [-h] [update] [library-skills args ...] Install or refresh CPMpy Agent Skills in the current project via library-skills. @@ -32,7 +60,15 @@ update Refresh project links to the currently installed CPMpy (library-skills --yes). Does not upgrade the package; run `pip install -U 'cpmpy[skills]'` first for new content. +""" +SKILLS_HELP_REGENERATE = """\ + update --regenerate Rebuild generated reference files from the Sphinx docs, + recopy them into the package, then refresh project links. + Requires pip install 'cpmpy[docs]'. +""" + +SKILLS_HELP_EPILOG = """ Any other arguments are forwarded to library-skills, for example: cpmpy skills install -y --claude cpmpy skills list @@ -49,6 +85,37 @@ def _library_skills_available() -> bool: return importlib.util.find_spec("library_skills") is not None +def _source_root() -> Path | None: + """ + Repo root if this is an editable/source install, else None. + """ + candidate = Path(cp.__file__).resolve().parent.parent + if (candidate / "skills" / "generate_references.py").is_file(): + return candidate + return None + + +def _can_regenerate() -> bool: + """ + True when generated refs can be rebuilt (source checkout, not a wheel). + """ + return _source_root() is not None + + +def skills_help() -> str: + text = SKILLS_HELP_PREAMBLE + if _can_regenerate(): + text += SKILLS_HELP_REGENERATE + return text + SKILLS_HELP_EPILOG + + +def _docs_extra_available() -> bool: + return ( + importlib.util.find_spec("sphinx") is not None + and importlib.util.find_spec("sphinx_llm") is not None + ) + + def run_library_skills(argv: list[str]) -> int: """ Run the library-skills CLI with ``argv``. Returns the process exit code. @@ -66,15 +133,137 @@ def run_library_skills(argv: list[str]) -> int: return completed.returncode +def _sync_helper_path(root: Path | None = None) -> Path | None: + candidates = [] + if root is not None: + candidates.append(root / "skills" / "sync_into_package.py") + candidates.append(Path(__file__).resolve().parent.parent / "skills" / "sync_into_package.py") + for path in candidates: + if path.is_file(): + return path + return None + + +def _load_sync_module(root: Path | None = None): + sync_path = _sync_helper_path(root) + if sync_path is None: + raise FileNotFoundError("skills/sync_into_package.py") + spec = importlib.util.spec_from_file_location("cpmpy_sync_into_package", sync_path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _stdin_is_tty() -> bool: + return sys.stdin.isatty() + + +def confirm_continue_with_missing_generated_refs() -> bool: + """Ask whether to continue after missing generated refs. Default is no.""" + if not _stdin_is_tty(): + print(NON_TTY_MISSING_REFS_HINT, file=sys.stderr) + return False + try: + print(CONTINUE_WITHOUT_GENERATED_PROMPT, end="", file=sys.stderr, flush=True) + answer = input() + except EOFError: + print(file=sys.stderr) + return False + return answer.strip().lower() in {"y", "yes"} + + +def warn_if_generated_refs_missing(*, skip: bool = False) -> bool: + """ + On an editable/source install, warn if gitignored generated refs are absent. + + Returns True if the command should proceed. When files are missing, asks + whether to continue (default no). + """ + if skip: + return True + root = _source_root() + if root is None: + return True + if _sync_helper_path(root) is None: + return True + missing = _load_sync_module(root).warn_if_generated_refs_missing(root / "skills") + if not missing: + return True + return confirm_continue_with_missing_generated_refs() + + +def regenerate_skill_references() -> int: + """ + Rebuild ``*.generated.md`` from Sphinx docs and recopy skills into the package. + """ + root = _source_root() + if root is None: + print(REGENERATE_SOURCE_HINT, file=sys.stderr) + return 1 + if not _docs_extra_available(): + print(DOCS_EXTRA_HINT, file=sys.stderr) + return 1 + + generator = root / "skills" / "generate_references.py" + completed = subprocess.run([sys.executable, str(generator), "--build"]) + if completed.returncode: + return completed.returncode + + _load_sync_module(root).sync_skills( + root / "skills", + root / "cpmpy" / ".agents" / "skills", + ) + return 0 + + +def command_skills_update(argv: list[str]) -> int: + """ + ``cpmpy skills update`` → ``library-skills --yes``, optionally regenerating refs. + """ + parser = argparse.ArgumentParser( + prog="cpmpy skills update", + description=( + "Refresh project links to the currently installed CPMpy. " + "Does not run pip; upgrade the package first for newer skill content." + ), + ) + can_regenerate = _can_regenerate() + if can_regenerate: + parser.add_argument( + "--regenerate", + action="store_true", + help=( + "Rebuild generated skill reference files from the Sphinx docs " + "(requires pip install 'cpmpy[docs]')." + ), + ) + args, rest = parser.parse_known_args(argv) + if not can_regenerate and "--regenerate" in rest: + print(REGENERATE_SOURCE_HINT, file=sys.stderr) + return 1 + if can_regenerate and args.regenerate: + code = regenerate_skill_references() + if code: + return code + return run_library_skills(["--yes", *rest]) + + def command_skills(argv: list[str]) -> int: """ Native ``cpmpy skills`` command. ``update`` maps to ``library-skills --yes``. """ if argv and argv[0] in ("-h", "--help"): - print(SKILLS_HELP, end="") + print(skills_help(), end="") return 0 + help_requested = "-h" in argv or "--help" in argv + skip_missing_prompt = bool( + argv and argv[0] == "update" and "--regenerate" in argv[1:] + ) + if not help_requested: + if not warn_if_generated_refs_missing(skip=skip_missing_prompt): + return 1 if argv and argv[0] == "update": - argv = ["--yes", *argv[1:]] + return command_skills_update(argv[1:]) return run_library_skills(argv) diff --git a/setup.py b/setup.py index b00ad5a9c..0415c2e84 100644 --- a/setup.py +++ b/setup.py @@ -77,6 +77,15 @@ def get_version(rel_path): } format_dependencies["io.all"] = list({pkg for group in format_dependencies.values() for pkg in group}) +docs_require = [ + "sphinx>=5.3.0", + "sphinx_rtd_theme>=2.0.0", + "myst_parser", + "sphinx-automodapi", + "readthedocs-sphinx-search>=0.3.2", + "sphinx_llm", +] + setup( name='cpmpy', version=get_version("cpmpy/__init__.py"), @@ -105,7 +114,7 @@ def get_version(rel_path): # Other "test": ["pytest", "pytest-timeout"], "type": ["mypy", "types-tqdm"], - "docs": ["sphinx>=5.3.0", "sphinx_rtd_theme>=2.0.0", "myst_parser", "sphinx-automodapi", "readthedocs-sphinx-search>=0.3.2", "sphinx_llm"], + "docs": docs_require, "skills": ["library-skills"], }, cmdclass={ diff --git a/skills/generate_references.py b/skills/generate_references.py index a945e6837..1fe707fab 100644 --- a/skills/generate_references.py +++ b/skills/generate_references.py @@ -22,12 +22,14 @@ python skills/generate_references.py --check # verify generated files are # up to date; exit 1 if not # (use in CI after docs change) + python skills/generate_references.py --clean # delete generated files (dev) To add a new generated reference file, add an entry to SOURCES below. """ from __future__ import annotations import argparse +import importlib.util import os import re import subprocess @@ -160,6 +162,14 @@ def build(source: str, headings: list[str] | None, output: str) -> str: return header + body +def _load_sync_module(): + path = Path(__file__).resolve().parent / "sync_into_package.py" + spec = importlib.util.spec_from_file_location("cpmpy_sync_into_package", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( @@ -170,8 +180,27 @@ def main() -> int: action="store_true", help="don't write files; exit 1 if any generated file is stale/missing", ) + parser.add_argument( + "--clean", + action="store_true", + help="delete generated reference files and recopy skills into the package", + ) args = parser.parse_args() + if args.clean and (args.build or args.check): + parser.error("--clean cannot be combined with --build or --check") + + if args.clean: + sync = _load_sync_module() + removed = sync.clean_generated_refs(REPO_ROOT / "skills") + sync.sync_skills(warn_missing=False) + if removed: + for path in removed: + print(f"removed {path.relative_to(REPO_ROOT)}") + else: + print("no generated skill reference files to remove") + return 0 + if args.build: build_docs() diff --git a/skills/sync_into_package.py b/skills/sync_into_package.py index d89a63cad..8e8f0443b 100644 --- a/skills/sync_into_package.py +++ b/skills/sync_into_package.py @@ -16,7 +16,9 @@ from __future__ import annotations +import re import shutil +import sys from pathlib import Path SKILLS_DIR = Path(__file__).resolve().parent @@ -24,10 +26,79 @@ DEFAULT_SRC = SKILLS_DIR DEFAULT_DEST = REPO_ROOT / "cpmpy" / ".agents" / "skills" +_GENERATED_LINK_RE = re.compile(r"\]\(([^)\s#]+\.generated\.md)") + +GENERATED_REFS_HINT = """\ +Agent skill is missing its reference files. These are not included by default in a source checkout +Install the docs extra and rebuild them: + + pip install 'cpmpy[docs]' + cpmpy skills update --regenerate +""" + + +def iter_skill_dirs(src_dir: Path) -> list[Path]: + """Return skill directories under ``src_dir`` (each contains ``SKILL.md``).""" + if not src_dir.is_dir(): + return [] + skills = [] + for skill in sorted(src_dir.iterdir()): + if skill.is_dir() and (skill / "SKILL.md").is_file(): + skills.append(skill) + return skills + + +def generated_refs_missing_from(src_dir: Path) -> list[Path]: + """Return ``*.generated.md`` files linked from ``SKILL.md`` that are absent.""" + missing: list[Path] = [] + for skill in iter_skill_dirs(src_dir): + text = (skill / "SKILL.md").read_text(encoding="utf-8") + for rel in _GENERATED_LINK_RE.findall(text): + target = skill / rel + if not target.is_file(): + missing.append(target) + return missing + + +def warn_if_generated_refs_missing(src_dir: Path) -> list[Path]: + """Print a hint on stderr if generated refs are missing. Returns the missing paths.""" + missing = generated_refs_missing_from(src_dir) + if missing: + print(GENERATED_REFS_HINT, file=sys.stderr, end="") + for path in missing: + try: + rel = path.relative_to(src_dir.parent) + except ValueError: + rel = path + print(f" missing: {rel}", file=sys.stderr) + return missing + + +def iter_generated_ref_files(src_dir: Path) -> list[Path]: + """Return existing ``references/*.generated.md`` files under skill directories.""" + found: list[Path] = [] + if not src_dir.is_dir(): + return found + for path in sorted(src_dir.rglob("*.generated.md")): + if path.is_file() and path.parent.name == "references": + found.append(path) + return found + + +def clean_generated_refs(src_dir: Path) -> list[Path]: + """Delete generated skill reference files under ``src_dir``. Returns removed paths.""" + removed: list[Path] = [] + for path in iter_generated_ref_files(src_dir): + path.unlink() + removed.append(path) + return removed + def sync_skills( src: Path | str | None = None, dest: Path | str | None = None, + *, + warn_missing: bool = True, ) -> list[str]: """ Copy each ``skills//`` directory that contains a ``SKILL.md``. @@ -44,16 +115,12 @@ def sync_skills( dest_dir.mkdir(parents=True, exist_ok=True) copied: list[str] = [] - if not src_dir.is_dir(): - return copied - - for skill in sorted(src_dir.iterdir()): - if not skill.is_dir(): - continue - if not (skill / "SKILL.md").is_file(): - continue + for skill in iter_skill_dirs(src_dir): shutil.copytree(skill, dest_dir / skill.name) copied.append(skill.name) + + if warn_missing: + warn_if_generated_refs_missing(src_dir) return copied From 93f1395ce8d207f3e1d02dd71d9464bb1ad7a158 Mon Sep 17 00:00:00 2001 From: ThomSerg Date: Thu, 10 Sep 2026 11:08:59 +0200 Subject: [PATCH 05/12] Simplify by including refs for skills.sh --- cpmpy/cli.py | 68 +-------- skills/.gitignore | 5 - .../references/api-cheatsheet.generated.md | 141 ++++++++++++++++++ skills/generate_references.py | 2 +- skills/sync_into_package.py | 44 +----- 5 files changed, 145 insertions(+), 115 deletions(-) delete mode 100644 skills/.gitignore create mode 100644 skills/cpmpy-model/references/api-cheatsheet.generated.md diff --git a/cpmpy/cli.py b/cpmpy/cli.py index 82175c66c..51b595855 100644 --- a/cpmpy/cli.py +++ b/cpmpy/cli.py @@ -44,12 +44,6 @@ cpmpy skills update --regenerate """ -CONTINUE_WITHOUT_GENERATED_PROMPT = "Continue without them? [y/N] " -NON_TTY_MISSING_REFS_HINT = ( - "Not a TTY; aborting. Generate the files with " - "`cpmpy skills update --regenerate` or rerun from a terminal." -) - SKILLS_HELP_PREAMBLE = """\ usage: cpmpy skills [-h] [update] [library-skills args ...] @@ -133,65 +127,14 @@ def run_library_skills(argv: list[str]) -> int: return completed.returncode -def _sync_helper_path(root: Path | None = None) -> Path | None: - candidates = [] - if root is not None: - candidates.append(root / "skills" / "sync_into_package.py") - candidates.append(Path(__file__).resolve().parent.parent / "skills" / "sync_into_package.py") - for path in candidates: - if path.is_file(): - return path - return None - - -def _load_sync_module(root: Path | None = None): - sync_path = _sync_helper_path(root) - if sync_path is None: - raise FileNotFoundError("skills/sync_into_package.py") +def _load_sync_module(root: Path): + sync_path = root / "skills" / "sync_into_package.py" spec = importlib.util.spec_from_file_location("cpmpy_sync_into_package", sync_path) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return module -def _stdin_is_tty() -> bool: - return sys.stdin.isatty() - - -def confirm_continue_with_missing_generated_refs() -> bool: - """Ask whether to continue after missing generated refs. Default is no.""" - if not _stdin_is_tty(): - print(NON_TTY_MISSING_REFS_HINT, file=sys.stderr) - return False - try: - print(CONTINUE_WITHOUT_GENERATED_PROMPT, end="", file=sys.stderr, flush=True) - answer = input() - except EOFError: - print(file=sys.stderr) - return False - return answer.strip().lower() in {"y", "yes"} - - -def warn_if_generated_refs_missing(*, skip: bool = False) -> bool: - """ - On an editable/source install, warn if gitignored generated refs are absent. - - Returns True if the command should proceed. When files are missing, asks - whether to continue (default no). - """ - if skip: - return True - root = _source_root() - if root is None: - return True - if _sync_helper_path(root) is None: - return True - missing = _load_sync_module(root).warn_if_generated_refs_missing(root / "skills") - if not missing: - return True - return confirm_continue_with_missing_generated_refs() - - def regenerate_skill_references() -> int: """ Rebuild ``*.generated.md`` from Sphinx docs and recopy skills into the package. @@ -255,13 +198,6 @@ def command_skills(argv: list[str]) -> int: if argv and argv[0] in ("-h", "--help"): print(skills_help(), end="") return 0 - help_requested = "-h" in argv or "--help" in argv - skip_missing_prompt = bool( - argv and argv[0] == "update" and "--regenerate" in argv[1:] - ) - if not help_requested: - if not warn_if_generated_refs_missing(skip=skip_missing_prompt): - return 1 if argv and argv[0] == "update": return command_skills_update(argv[1:]) return run_library_skills(argv) diff --git a/skills/.gitignore b/skills/.gitignore deleted file mode 100644 index 8d95cc565..000000000 --- a/skills/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -# Generated reference files (see generate_references.py) are derived -# entirely from this repo's own docs/, already tracked in git — committing a -# second copy here would just be a duplicate that can silently drift. -# Run `python skills/generate_references.py` to (re)create them locally. -**/references/*.generated.md diff --git a/skills/cpmpy-model/references/api-cheatsheet.generated.md b/skills/cpmpy-model/references/api-cheatsheet.generated.md new file mode 100644 index 000000000..7653137d3 --- /dev/null +++ b/skills/cpmpy-model/references/api-cheatsheet.generated.md @@ -0,0 +1,141 @@ + + +# Summary sheet + +More extensive user documentation in [Modeling and solving with CPMpy](../../../docs/_build/html/modeling.html.md). + +`import cpmpy as cp` + +## Model class + +- [`model = cp.Model()`](../../../docs/_build/html/api/model.html.md#cpmpy.model.Model.__init__) – Create a [`Model`](../../../docs/_build/html/api/model.html.md#cpmpy.model.Model). +- [`model.add(constraint)`](../../../docs/_build/html/api/model.html.md#cpmpy.model.Model.add) – Add a constraint (an [`Expression`](../../../docs/_build/html/api/expressions.html.md#module-cpmpy.expressions)) to the model, also allowed: model += constraint. +- [`model.maximize(obj)`](../../../docs/_build/html/api/model.html.md#cpmpy.model.Model.maximize) or [`model.minimize(obj)`](../../../docs/_build/html/api/model.html.md#cpmpy.model.Model.minimize) – Set the objective (an [`Expression`](../../../docs/_build/html/api/expressions.html.md#module-cpmpy.expressions)). +- [`model.solve()`](../../../docs/_build/html/api/model.html.md#cpmpy.model.Model.solve) – Solve the model with the default solver, returns True/False. +- [`model.solveAll()`](../../../docs/_build/html/api/model.html.md#cpmpy.model.Model.solveAll) – Solve and enumerate all solutions, returns number of solutions. +- [`model.status()`](../../../docs/_build/html/api/model.html.md#cpmpy.model.Model.status) – Get the status of the last solver run. +- [`model.objective_value()`](../../../docs/_build/html/api/model.html.md#cpmpy.model.Model.objective_value) – Get the objective value obtained during the last solver run. + +## Solvers + +[`Solvers`](../../../docs/_build/html/api/solvers.html.md#module-cpmpy.solvers) have the same API as [`Model`](../../../docs/_build/html/api/model.html.md#cpmpy.model.Model). Solvers are instantiated throught the static [`cp.SolverLookup`](../../../docs/_build/html/api/solvers/utils.html.md#cpmpy.solvers.utils.SolverLookup) class: + +- [`cp.SolverLookup.solvernames()`](../../../docs/_build/html/api/solvers/utils.html.md#cpmpy.solvers.utils.SolverLookup.solvernames) – List all installed solvers (including subsolvers). +- [`cp.SolverLookup.get(solvername, model=None)`](../../../docs/_build/html/api/solvers/utils.html.md#cpmpy.solvers.utils.SolverLookup.get) – Initialize a specific solver. + +## Decision Variables + +[`Decision variables`](../../../docs/_build/html/api/expressions/variables.html.md#module-cpmpy.expressions.variables) are NumPy-like objects: `shape=None|1` creates one variable, `shape=4` creates a vector of 4 variables, `shape=(2,3)` creates a matrix of 2x3 variables, etc. +Name is optional too, indices are automatically added to the name so each variable has a unique name. + +- [`x = cp.boolvar(shape=4, name="x")`](../../../docs/_build/html/api/expressions/variables.html.md#cpmpy.expressions.variables.boolvar) – Create four Boolean decision variables. +- [`x = cp.intvar(lb, ub)`](../../../docs/_build/html/api/expressions/variables.html.md#cpmpy.expressions.variables.intvar) – Create one integer decision variable with domain `[lb, ub]` (inclusive). +- [`x.value()`](../../../docs/_build/html/api/expressions/variables.html.md#cpmpy.expressions.variables._NumVarImpl.value) – Get the value of `x` obtained during the last solver run. + +## Core Expressions + +You can apply the following standard Python operators on CPMpy expressions, which creates the corresponding [`Core Expression`](../../../docs/_build/html/api/expressions/core.html.md#module-cpmpy.expressions.core) object: + +- Comparison: `==`, `!=`, `<`, `<=`, `>`, `>=` +- Arithmetic: `+`, `-`, `*`, `//` (integer division), `%` (modulo), `**` (power) +- Logical: `&` (and), `|` (or), `~` (not), `^` (xor) +- Logical implication: [`x.implies(y)`](../../../docs/_build/html/api/expressions/core.html.md#cpmpy.expressions.core.Expression.implies) + +Logical operators only work on Boolean variables/constraints, numeric operators work on both integer and Boolean variables/expressions. + +CPMpy overwrites the following [`Python built-ins`](../../../docs/_build/html/api/expressions/python_builtins.html.md#module-cpmpy.expressions.python_builtins), they allow vectorized operations: + +- [`cp.sum`](../../../docs/_build/html/api/expressions/python_builtins.html.md#cpmpy.expressions.python_builtins.sum), [`cp.abs`](../../../docs/_build/html/api/expressions/python_builtins.html.md#cpmpy.expressions.python_builtins.abs), [`cp.max`](../../../docs/_build/html/api/expressions/python_builtins.html.md#cpmpy.expressions.python_builtins.max), [`cp.min`](../../../docs/_build/html/api/expressions/python_builtins.html.md#cpmpy.expressions.python_builtins.min) +- [`cp.all`](../../../docs/_build/html/api/expressions/python_builtins.html.md#cpmpy.expressions.python_builtins.all), [`cp.any`](../../../docs/_build/html/api/expressions/python_builtins.html.md#cpmpy.expressions.python_builtins.any) + +You can **index** CPMpy expressions with an integer decision variable: `x[y]`, which will create an [`Element`](../../../docs/_build/html/api/expressions/globalfunctions.html.md#cpmpy.expressions.globalfunctions.Element) expression object. +To index non-CPMpy arrays, wrap them with [`cpm_array()`](../../../docs/_build/html/api/expressions/variables.html.md#cpmpy.expressions.variables.cpm_array): `cpm_array([1,2,3])[y]`. + +## Global Functions + +[`Global functions`](../../../docs/_build/html/api/expressions/globalfunctions.html.md#module-cpmpy.expressions.globalfunctions) are numeric functions that some solvers support natively (through a solver-specific global constraint). CPMpy automatically rewrites the global function as needed to work with any solver. + +| [`Minimum`](../../../docs/_build/html/api/expressions/globalfunctions.html.md#cpmpy.expressions.globalfunctions.Minimum) | Computes the minimum value of the arguments | +|--------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------| +| [`Maximum`](../../../docs/_build/html/api/expressions/globalfunctions.html.md#cpmpy.expressions.globalfunctions.Maximum) | Computes the maximum value of the arguments | +| [`Abs`](../../../docs/_build/html/api/expressions/globalfunctions.html.md#cpmpy.expressions.globalfunctions.Abs) | Computes the absolute value of the argument | +| [`Element`](../../../docs/_build/html/api/expressions/globalfunctions.html.md#cpmpy.expressions.globalfunctions.Element) | The Element(Arr, Idx) global function allows indexing into an array with a decision variable. | +| [`Count`](../../../docs/_build/html/api/expressions/globalfunctions.html.md#cpmpy.expressions.globalfunctions.Count) | The Count global function represents the number of occurrences of a value in an array | +| [`Among`](../../../docs/_build/html/api/expressions/globalfunctions.html.md#cpmpy.expressions.globalfunctions.Among) | The Among global function counts how many variables in an array take values that are in a given set of values. | +| [`NValue`](../../../docs/_build/html/api/expressions/globalfunctions.html.md#cpmpy.expressions.globalfunctions.NValue) | The NValue global function counts the number of distinct values in an array. | +| [`NValueExcept`](../../../docs/_build/html/api/expressions/globalfunctions.html.md#cpmpy.expressions.globalfunctions.NValueExcept) | The NValueExcept global function counts the number of distinct values in an array, excluding a specified value. | + +## Global Constraints + +[`Global constraints`](../../../docs/_build/html/api/expressions/globalconstraints.html.md#module-cpmpy.expressions.globalconstraints) are constraints (Boolean functions) that some solvers support natively. All global constraints can be reified (implication, equivalence) and used in other expressions, which CPMpy will handle. + +| [`AllDifferent`](../../../docs/_build/html/api/expressions/globalconstraints.html.md#cpmpy.expressions.globalconstraints.AllDifferent) | Enforces that all arguments have a different (distinct) value | +|------------------------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| [`AllDifferentExcept0`](../../../docs/_build/html/api/expressions/globalconstraints.html.md#cpmpy.expressions.globalconstraints.AllDifferentExcept0) | Enforces that all arguments, except those equal to 0, have a different (distinct) value. | +| [`AllDifferentExceptN`](../../../docs/_build/html/api/expressions/globalconstraints.html.md#cpmpy.expressions.globalconstraints.AllDifferentExceptN) | Enforces that all arguments, except those equal to a value in n, have a different (distinct) value. | +| [`AllEqual`](../../../docs/_build/html/api/expressions/globalconstraints.html.md#cpmpy.expressions.globalconstraints.AllEqual) | Enforces that all arguments have the same value | +| [`AllEqualExceptN`](../../../docs/_build/html/api/expressions/globalconstraints.html.md#cpmpy.expressions.globalconstraints.AllEqualExceptN) | Enforces that all arguments, except those equal to a value in n, have the same value. | +| [`Circuit`](../../../docs/_build/html/api/expressions/globalconstraints.html.md#cpmpy.expressions.globalconstraints.Circuit) | Enforces that the sequence of variables form a circuit, where x[i] = j means that node j is the successor of node i. | +| [`Inverse`](../../../docs/_build/html/api/expressions/globalconstraints.html.md#cpmpy.expressions.globalconstraints.Inverse) | Enforces that the forward and reverse arrays represent the inverse function of one another. | +| [`Table`](../../../docs/_build/html/api/expressions/globalconstraints.html.md#cpmpy.expressions.globalconstraints.Table) | Enforces that the values of the variables in 'array' correspond to a row in 'table'. | +| [`ShortTable`](../../../docs/_build/html/api/expressions/globalconstraints.html.md#cpmpy.expressions.globalconstraints.ShortTable) | Extension of the Table constraint where the table matrix may contain wildcards (STAR), meaning there are no restrictions for the corresponding variable in that tuple. | +| [`NegativeTable`](../../../docs/_build/html/api/expressions/globalconstraints.html.md#cpmpy.expressions.globalconstraints.NegativeTable) | The values of the variables in 'array' do not correspond to any row in 'table'. | +| [`IfThenElse`](../../../docs/_build/html/api/expressions/globalconstraints.html.md#cpmpy.expressions.globalconstraints.IfThenElse) | Enforces a conditional expression of the form: if condition then if_true else if_false. | +| [`InDomain`](../../../docs/_build/html/api/expressions/globalconstraints.html.md#cpmpy.expressions.globalconstraints.InDomain) | Enforces the expression is assigned to a value in the given domain. | +| [`Xor`](../../../docs/_build/html/api/expressions/globalconstraints.html.md#cpmpy.expressions.globalconstraints.Xor) | Enforces the exclusive-or relation of the arguments. | +| [`Cumulative`](../../../docs/_build/html/api/expressions/globalconstraints.html.md#cpmpy.expressions.globalconstraints.Cumulative) | Enforces that a set of tasks is scheduled such that the capacity of the resource is never exceeded and enforces: | +| [`Precedence`](../../../docs/_build/html/api/expressions/globalconstraints.html.md#cpmpy.expressions.globalconstraints.Precedence) | Enforces a precedence relationship between a set of variables. | +| [`NoOverlap`](../../../docs/_build/html/api/expressions/globalconstraints.html.md#cpmpy.expressions.globalconstraints.NoOverlap) | Enforces that a set of tasks are scheduled without overlapping, and enforces: | +| [`GlobalCardinalityCount`](../../../docs/_build/html/api/expressions/globalconstraints.html.md#cpmpy.expressions.globalconstraints.GlobalCardinalityCount) | Enforces that the number of occurrences of each value vals[i] in the list of variables vars is equal to occ[i]. | +| [`Increasing`](../../../docs/_build/html/api/expressions/globalconstraints.html.md#cpmpy.expressions.globalconstraints.Increasing) | Enforces that the expressions are assigned to (non-strictly) increasing values. | +| [`Decreasing`](../../../docs/_build/html/api/expressions/globalconstraints.html.md#cpmpy.expressions.globalconstraints.Decreasing) | Enforces that the expressions are assigned to (non-strictly) decreasing values. | +| [`IncreasingStrict`](../../../docs/_build/html/api/expressions/globalconstraints.html.md#cpmpy.expressions.globalconstraints.IncreasingStrict) | Enforces that the expressions are assigned to strictly increasing values. | +| [`DecreasingStrict`](../../../docs/_build/html/api/expressions/globalconstraints.html.md#cpmpy.expressions.globalconstraints.DecreasingStrict) | Enforces that the expressions are assigned to strictly decreasing values. | +| [`LexLess`](../../../docs/_build/html/api/expressions/globalconstraints.html.md#cpmpy.expressions.globalconstraints.LexLess) | Enforces that the first list is lexicographically smaller than the second list. | +| [`LexLessEq`](../../../docs/_build/html/api/expressions/globalconstraints.html.md#cpmpy.expressions.globalconstraints.LexLessEq) | Enforces that the first list is lexicographically smaller than or equal to the second list. | +| [`LexChainLess`](../../../docs/_build/html/api/expressions/globalconstraints.html.md#cpmpy.expressions.globalconstraints.LexChainLess) | Enforces that all rows of the matrix are lexicographically ordered. | +| [`LexChainLessEq`](../../../docs/_build/html/api/expressions/globalconstraints.html.md#cpmpy.expressions.globalconstraints.LexChainLessEq) | Enforces that all rows of the matrix are lexicographically ordered (less or equal) | +| [`DirectConstraint`](../../../docs/_build/html/api/expressions/globalconstraints.html.md#cpmpy.expressions.globalconstraints.DirectConstraint) | A `DirectConstraint` will directly call a function of the underlying solver when added to a CPMpy solver | + +## Guidelines and tips + +- Do not `from cpmpy import *`, the implicit overloading of any/all and sum may break or slow down other libraries. +- Explicitly use CPMpy versions of built-in functions (`cp.sum`, `cp.all`, etc.). +- Use global constraints/global functions where possible, some solvers will be much faster. +- Stick to integer constants in constraints and model objectives (some solvers support FloatSum but its very limited) +- For maintainability, use logical code organization and comments to explain your constraints. + +## Toy example + +```python +import cpmpy as cp + +# Decision Variables +b = cp.boolvar() +x1, x2, x3 = x = cp.intvar(1, 10, shape=3) + +# Constraints +model = cp.Model() + +model.add(x[0] == 1) +model.add(cp.AllDifferent(x)) +model.add(cp.Count(x, 9) == 1) +model.add(b.implies(x[1] + x[2] > 5)) + +# Objective +model.maximize(cp.sum(x) + 100 * b) + +# Solving +solved = model.solve() +if solved: + print("Solution found:") + print('b:', b.value(), ' x:', x.value().tolist()) +else: + print("No solution found.") +``` diff --git a/skills/generate_references.py b/skills/generate_references.py index 1fe707fab..ea89e6e44 100644 --- a/skills/generate_references.py +++ b/skills/generate_references.py @@ -193,7 +193,7 @@ def main() -> int: if args.clean: sync = _load_sync_module() removed = sync.clean_generated_refs(REPO_ROOT / "skills") - sync.sync_skills(warn_missing=False) + sync.sync_skills() if removed: for path in removed: print(f"removed {path.relative_to(REPO_ROOT)}") diff --git a/skills/sync_into_package.py b/skills/sync_into_package.py index 8e8f0443b..5cde8a820 100644 --- a/skills/sync_into_package.py +++ b/skills/sync_into_package.py @@ -16,9 +16,7 @@ from __future__ import annotations -import re import shutil -import sys from pathlib import Path SKILLS_DIR = Path(__file__).resolve().parent @@ -26,16 +24,6 @@ DEFAULT_SRC = SKILLS_DIR DEFAULT_DEST = REPO_ROOT / "cpmpy" / ".agents" / "skills" -_GENERATED_LINK_RE = re.compile(r"\]\(([^)\s#]+\.generated\.md)") - -GENERATED_REFS_HINT = """\ -Agent skill is missing its reference files. These are not included by default in a source checkout -Install the docs extra and rebuild them: - - pip install 'cpmpy[docs]' - cpmpy skills update --regenerate -""" - def iter_skill_dirs(src_dir: Path) -> list[Path]: """Return skill directories under ``src_dir`` (each contains ``SKILL.md``).""" @@ -48,32 +36,6 @@ def iter_skill_dirs(src_dir: Path) -> list[Path]: return skills -def generated_refs_missing_from(src_dir: Path) -> list[Path]: - """Return ``*.generated.md`` files linked from ``SKILL.md`` that are absent.""" - missing: list[Path] = [] - for skill in iter_skill_dirs(src_dir): - text = (skill / "SKILL.md").read_text(encoding="utf-8") - for rel in _GENERATED_LINK_RE.findall(text): - target = skill / rel - if not target.is_file(): - missing.append(target) - return missing - - -def warn_if_generated_refs_missing(src_dir: Path) -> list[Path]: - """Print a hint on stderr if generated refs are missing. Returns the missing paths.""" - missing = generated_refs_missing_from(src_dir) - if missing: - print(GENERATED_REFS_HINT, file=sys.stderr, end="") - for path in missing: - try: - rel = path.relative_to(src_dir.parent) - except ValueError: - rel = path - print(f" missing: {rel}", file=sys.stderr) - return missing - - def iter_generated_ref_files(src_dir: Path) -> list[Path]: """Return existing ``references/*.generated.md`` files under skill directories.""" found: list[Path] = [] @@ -97,15 +59,13 @@ def clean_generated_refs(src_dir: Path) -> list[Path]: def sync_skills( src: Path | str | None = None, dest: Path | str | None = None, - *, - warn_missing: bool = True, ) -> list[str]: """ Copy each ``skills//`` directory that contains a ``SKILL.md``. Wipes ``dest`` first so removed skills do not linger. Returns the names of skill directories that were copied. Generated ``*.generated.md`` files are - included when present (they stay gitignored under ``skills/``). + included when present (they are committed under ``skills/``). """ src_dir = Path(src) if src is not None else DEFAULT_SRC dest_dir = Path(dest) if dest is not None else DEFAULT_DEST @@ -119,8 +79,6 @@ def sync_skills( shutil.copytree(skill, dest_dir / skill.name) copied.append(skill.name) - if warn_missing: - warn_if_generated_refs_missing(src_dir) return copied From a5e354d72f38b022fdd283be14c4a5d06f60418a Mon Sep 17 00:00:00 2001 From: ThomSerg Date: Thu, 10 Sep 2026 11:31:38 +0200 Subject: [PATCH 06/12] update skill frontmatter --- skills/cpmpy-model/SKILL.md | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/skills/cpmpy-model/SKILL.md b/skills/cpmpy-model/SKILL.md index d68fa004d..5b3108943 100644 --- a/skills/cpmpy-model/SKILL.md +++ b/skills/cpmpy-model/SKILL.md @@ -1,21 +1,12 @@ --- name: cpmpy-model -description: Write and solve problems in CPMpy, a constraint programming and modelling library in Python, based on numpy, with direct solver access. -version: 0.0.1 +description: Write and solve problems in CPMpy, a constraint programming and modelling library in Python, based on numpy, with direct solver access. license: Apache-2.0 +compatibility: Requires Python 3.10+ metadata: author: CPMpy team domain: constraint-programming library: cpmpy - tags: - - constraint-programming - - combinatorial-optimization - - constraint-modeling - - decision-variables - - global-constraints - - python - - or-tools - - optimization --- # CPMpy modeling From 2698bfea7e3450359caf96552922f6cd59422b49 Mon Sep 17 00:00:00 2001 From: ThomSerg Date: Thu, 10 Sep 2026 11:36:55 +0200 Subject: [PATCH 07/12] point to agent friendly docs --- skills/cpmpy-model/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/cpmpy-model/SKILL.md b/skills/cpmpy-model/SKILL.md index 5b3108943..721426c13 100644 --- a/skills/cpmpy-model/SKILL.md +++ b/skills/cpmpy-model/SKILL.md @@ -17,5 +17,5 @@ For an overview of the available backends and their abilities: [index](https://c For a compact, complete API listing: [references/api-cheatsheet.generated.md](references/api-cheatsheet.generated.md) -For the full narrative documentation (global constraints catalog, solver selection, incremental solving, I/O, debugging), see [modeling](https://cpmpy.readthedocs.io/en/latest/modeling.html). +For the full narrative documentation (global constraints catalog, solver selection, incremental solving, I/O, debugging), see [documentation](https://cpmpy.readthedocs.io/en/latest/llm.txt). From 717d4b3decfd09d536f0bfaa2a31ac1cd8ecbf09 Mon Sep 17 00:00:00 2001 From: ThomSerg Date: Thu, 10 Sep 2026 11:42:56 +0200 Subject: [PATCH 08/12] Add readme --- skills/README.md | 53 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 skills/README.md diff --git a/skills/README.md b/skills/README.md new file mode 100644 index 000000000..28eb9cd45 --- /dev/null +++ b/skills/README.md @@ -0,0 +1,53 @@ +# Agent Skills + +This directory holds **Agent Skills** for CPMpy. They follow the open +[Agent Skills specification](https://agentskills.io/specification). + +## Using skills in a project + +Skills are **authored here** (`skills//`) and **copied into the installed +package** at `cpmpy/.agents/skills/` when you build or `pip install` CPMpy (including +editable installs), via [`sync_into_package.py`](sync_into_package.py). That copy is +gitignored; do not edit it by hand. + +To link them into a consuming project's `.agents/skills/` (so Cursor, Claude Code, +etc. can load them): + +```bash +pip install 'cpmpy[skills]' # CPMpy plus library-skills +cpmpy skills # interactive install into the current project +``` + +After upgrading CPMpy, refresh the project's links to the newly installed content +(this does not run `pip` itself): + +```bash +pip install -U 'cpmpy[skills]' +cpmpy skills update +``` + +To also rebuild generated reference files (`*.generated.md`) from the Sphinx docs, +use a **source checkout** and the docs extra: + +```bash +pip install -e '.[docs]' # Sphinx + sphinx_llm +cpmpy skills update --regenerate +``` + +`--regenerate` is only advertised in `cpmpy skills --help` for a source/editable +install; a wheel from PyPI has no docs tree to rebuild from. It runs +`skills/generate_references.py --build`, recopies `skills/` into +`cpmpy/.agents/skills/`, then `library-skills --yes`. `cpmpy[docs]` is +enough if `cpmpy[skills]` is already installed. + +A clone of this repo includes `*.generated.md` so `npx skills add` and a +plain `pip install -e .` both get the reference files. Rebuild them when +docs change (`pip install 'cpmpy[docs]'` then `cpmpy skills update --regenerate`). + +`cpmpy skills update` is `library-skills --yes`. Any other arguments are forwarded +to [library-skills](https://library-skills.io/), e.g. `cpmpy skills install -y --claude` +or `cpmpy skills list`. Without the extra, `cpmpy skills` tells you to +`pip install 'cpmpy[skills]'`. + +The `skills/` tree is also what `npx skills add` looks at in this git repo, +including committed `*.generated.md` reference files. \ No newline at end of file From 5a620dd6bbe4881f65fa53d5e4f46a1c226a4b5b Mon Sep 17 00:00:00 2001 From: ThomSerg Date: Thu, 10 Sep 2026 12:58:35 +0200 Subject: [PATCH 09/12] fix mypy --- cpmpy/cli.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cpmpy/cli.py b/cpmpy/cli.py index 51b595855..36b491dd5 100644 --- a/cpmpy/cli.py +++ b/cpmpy/cli.py @@ -130,6 +130,8 @@ def run_library_skills(argv: list[str]) -> int: def _load_sync_module(root: Path): sync_path = root / "skills" / "sync_into_package.py" spec = importlib.util.spec_from_file_location("cpmpy_sync_into_package", sync_path) + if spec is None or spec.loader is None: + raise ImportError(f"Cannot load skill sync module from {sync_path}") module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return module From 3388cffc9504c032dda6426ce02a5a959d8393e4 Mon Sep 17 00:00:00 2001 From: ThomSerg Date: Mon, 12 Oct 2026 11:00:54 +0200 Subject: [PATCH 10/12] Missing requirement --- docs/requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/requirements.txt b/docs/requirements.txt index 41f88aadb..f33ccf365 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -3,4 +3,5 @@ myst-parser sphinx-automodapi sphinx>=5.3.0 # cannot be updated due to m2r2 no longer being maintained -> switch to MyST? sphinx_rtd_theme>=2.0.0 -readthedocs-sphinx-search>=0.3.2 \ No newline at end of file +readthedocs-sphinx-search>=0.3.2 +sphinx_llm \ No newline at end of file From 4b1a9dd982982ada53ed0f6b9f32c9ceb6748859 Mon Sep 17 00:00:00 2001 From: ThomSerg Date: Mon, 12 Oct 2026 11:04:10 +0200 Subject: [PATCH 11/12] Reference full docs --- skills/cpmpy-model/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/cpmpy-model/SKILL.md b/skills/cpmpy-model/SKILL.md index 721426c13..b549e2f85 100644 --- a/skills/cpmpy-model/SKILL.md +++ b/skills/cpmpy-model/SKILL.md @@ -17,5 +17,5 @@ For an overview of the available backends and their abilities: [index](https://c For a compact, complete API listing: [references/api-cheatsheet.generated.md](references/api-cheatsheet.generated.md) -For the full narrative documentation (global constraints catalog, solver selection, incremental solving, I/O, debugging), see [documentation](https://cpmpy.readthedocs.io/en/latest/llm.txt). +For the full narrative documentation (global constraints catalog, solver selection, incremental solving, I/O, debugging), see [documentation](https://cpmpy.readthedocs.io/en/latest/llms-full.txt). From 8a5a96c6cf12825e12a5f6be2ce931b3e95bca42 Mon Sep 17 00:00:00 2001 From: ThomSerg Date: Tue, 13 Oct 2026 14:57:15 +0200 Subject: [PATCH 12/12] Skill for explanation tools --- skills/cpmpy-explain/SKILL.md | 149 +++++++++++++++ skills/cpmpy-explain/references/advanced.md | 200 ++++++++++++++++++++ skills/cpmpy-explain/references/recipes.md | 172 +++++++++++++++++ 3 files changed, 521 insertions(+) create mode 100644 skills/cpmpy-explain/SKILL.md create mode 100644 skills/cpmpy-explain/references/advanced.md create mode 100644 skills/cpmpy-explain/references/recipes.md diff --git a/skills/cpmpy-explain/SKILL.md b/skills/cpmpy-explain/SKILL.md new file mode 100644 index 000000000..9f1876b24 --- /dev/null +++ b/skills/cpmpy-explain/SKILL.md @@ -0,0 +1,149 @@ +--- +name: cpmpy-explain +description: Explain why a CPMpy model is unsatisfiable, or why an expected solution is missing, using cpmpy.tools.explain (MUS, QuickXplain, smallest/optimal MUS, OCUS, MSS, MCS, MARCO enumeration). Use when a CPMpy model returns UNSAT or a wrong solution, when the user asks which constraints conflict, what to relax or remove to make it feasible, why a solution is optimal or why X and not Y, or wants step-by-step explanations or hints (e.g. for puzzles like sudoku) of a CPMpy model. +license: Apache-2.0 +compatibility: Requires Python 3.10+ and cpmpy +metadata: + author: CPMpy team + domain: constraint-programming + library: cpmpy +--- + +# CPMpy explanation tools + +Find *which constraints* cause a CPMpy model to be unsatisfiable, and *which constraints to drop or relax* to make it satisfiable. All tools live in `cpmpy.tools.explain` and work on arbitrary CPMpy expressions, including global constraints and nested expressions. + +## Contents + +- [Which tool answers which question](#which-tool-answers-which-question) +- [Quick start](#quick-start) +- [Solver requirements](#solver-requirements) +- [Making explanations readable](#making-explanations-readable) +- [Workflow: debugging an unexpected result](#workflow-debugging-an-unexpected-result) +- [More involved tasks](#more-involved-tasks) +- [Common issues](#common-issues) +- [Further reading](#further-reading) +- Worked recipes (QuickXplain, weighted MUS, OCUS, MCS/MSS, MARCO, assumptions): [references/recipes.md](references/recipes.md) +- Advanced use cases (step-wise hints, optimality, "why X not Y", repair, slack, counterfactuals): [references/advanced.md](references/advanced.md) + +## Which tool answers which question + +All functions take `soft` (the constraints that may be part of the answer) and optionally `hard` (always enforced, never reported), and return a **list of constraints from `soft`**. + +| User question | Concept | Function | +|---|---|---| +| "Why is it UNSAT?" (any small conflict) | MUS: Minimal Unsatisfiable Subset | `mus(soft, hard)` | +| "Why is it UNSAT?" in terms of the constraints I care about most | Preferred MUS (order of `soft` = preference, earlier = preferred) | `quickxplain(soft, hard)` | +| "Give me the smallest reason" | Smallest MUS | `smus(soft, hard)` | +| "Give me the cheapest reason" (some constraints are harder to understand) | Optimal MUS, minimises `sum(weights)` | `optimal_mus(soft, hard, weights)` | +| "Give me the cheapest reason that satisfies extra conditions" | OCUS: Optimal *Constrained* US | `ocus(soft, hard, weights, meta_constraint)` | +| "What can I keep?" | MSS: Maximal Satisfiable Subset | `mss(soft, hard)`, weighted: `mss_opt(soft, hard, weights)` | +| "What is the least I must remove/relax?" | MCS: Minimal Correction Subset (complement of MSS) | `mcs(soft, hard)`, weighted: `mcs_opt(soft, hard, weights)` | +| "Show me all conflicts / all fixes" | MUS + MCS enumeration | `marco(soft, hard)`, a generator yielding `("MUS", cons)` / `("MCS", cons)` | + +Fixing one MUS does not make the model satisfiable when there are several conflicts. To actually *repair* a model, use an MCS. To *understand* it, use a MUS. + +## Quick start + +```python +import cpmpy as cp +from cpmpy.tools.explain import mus, mcs + +# Example UNSAT model +x = cp.boolvar(shape=3, name="x") +model = cp.Model( + x[0], + x[0] | x[1], + x[2].implies(x[1]), + ~x[0], +) +assert model.solve() is False + +print(mus(model.constraints)) # [x[0], ~x[0]] -> the conflict +print(mcs(model.constraints)) # [~x[0]] or [x[0]] -> remove this to become SAT +``` + +Domain knowledge belongs in `hard`. Constraints that are true by definition (channeling, the structure of the problem) should go in `hard` so they never show up in the explanation. Only the constraints the user can reason about or change go in `soft`: + +```python +mus(soft=user_requirements, hard=structural_constraints) +``` + +## Solver requirements + +Use `cpmpy version` to check which solvers are installed. The non-naive tools need *assumption support*: the solver must accept `solve(assumptions=[...])` and return an unsat core through `get_core()` (see [unsat core extraction](https://cpmpy.readthedocs.io/en/latest/unsat_core_extraction.html.md)). A solver without it raises `NotSupportedError` from `get_core()`. To check: + +```python +from cpmpy.solvers.solver_interface import SolverInterface +cls = cp.SolverLookup.lookup("gurobi") +has_assumptions = cls.get_core is not SolverInterface.get_core # False for gurobi +``` + +When the user's solver has no assumption support, use the `*_naive` variant **with that solver**, or explain with `ortools` instead. + +## Making explanations readable + +The tools return the constraint objects you passed in, so **the granularity and names of `soft` decide how good the explanation is**. + +- **Group related constraints** into one soft constraint with `cp.all(group)`. Do **not** pass a nested Python list: `soft` is flattened, so `[[c1, c2], [c3]]` is treated as `[c1, c2, c3]`. +- **Keep a dict from soft constraint to a human label**, and translate the result back: + ```python + groups = { + "all shifts are covered": cp.all(coverage_constraints), + "nurse A is off on Monday": shifts[A, MON] == OFF, + "at most 5 shifts per nurse": cp.all(max_shift_constraints), + } + label = {c: name for name, c in groups.items()} + conflict = mus(list(groups.values()), hard=structural) + print([label[c] for c in conflict]) + ``` +- **Give decision variables meaningful, unique names** (`cp.intvar(..., name="start")`), so `print(constraint)` reads well. + +## Workflow: debugging an unexpected result + +1. **UNSAT model**: `print(model)` first, then call `mus(model.constraints)` and inspect the (much smaller) set. Use `mcs` to find what to relax. +2. **SAT, but a known-good solution is rejected**: add that solution as constraints, which makes the model UNSAT, then explain: + ```python + expected = [x == 1, y == 2, z == 3] + conflict = mus(soft=model.constraints, hard=expected) # constraints that forbid the expected solution + ``` + For an optimisation model, drop the objective. To check that a value is reachable, add `model.objective_ == value` as a hard constraint instead. +3. **Error while solving**: this is not a job for the explanation tools. See [how to debug](https://cpmpy.readthedocs.io/en/latest/how_to_debug.html.md). + +## More involved tasks + +Most other explanation questions reduce to a MUS/MCS call after adding the right constraints. Details, code and reference implementations: [references/advanced.md](references/advanced.md). + +| Task | Reduction | +|---|---| +| Step-wise explanation / "give me a hint" (e.g. sudoku) | `ocus(rules + facts + negated_targets, weights, meta_constraint=cp.sum(negated_targets) == 1)`, repeated | +| "Why is there no better solution?" | `mus(model.constraints, hard=[model.objective_ < best])` | +| "Why X and not Y?" | `mus(model.constraints, hard=[Y])`, or re-solve with `Y` and compare | +| Interactive repair of an infeasible model | loop: `mus` → user drops a constraint → repeat; or `mcs_opt` in one go | +| Relax numeric constraints instead of dropping them | slack variables + minimise violation | +| "Which weights make my solution optimal?" | inverse optimisation (XCP-explain `counterfactual.py`) | + +These loops call a solver many times. On bigger instances, use an incremental SAT solver (`solver="pindakaas"`/`"exact"`/`"pysat"`), and an incremental hitting-set solver (`hs_solver="gurobi"`/`"exact"`) for `ocus`. + +## Common issues + +| Problem | Likely cause | Fix | +|---|---|---| +| `AssertionError: MUS: model must be UNSAT` / `The model should be UNSAT!` | `hard + soft` is satisfiable, **or** the solver silently ignores assumptions | Check `cp.Model(hard + soft).solve()` is `False`; use an assumption-capable solver or the `*_naive` variant | +| `NotSupportedError: ... unsat core extraction` | Solver has no assumption support | Use `ortools` or the `*_naive` variant | +| `mus`/`smus` return `[]`, `quickxplain` raises `ValueError: max() ...`, `mcs` raises a bare `AssertionError` | `hard` on its own is UNSAT | Check `cp.Model(hard).solve()`; move constraints from `hard` to `soft` | +| Explanation lists many tiny constraints | Too fine-grained `soft` | Group with `cp.all(...)` per user-level requirement | +| `OCUSException` | No unsatisfiable subset meets the `meta_constraint` | Relax the meta-constraint | +| `smus` / `optimal_mus` / `marco` slow | Many hitting-set iterations | Use `mus`/`quickxplain` (subset-minimal, not optimal), coarser groups, or `hs_solver="gurobi"`/`"exact"` | +| `mus` gives a different MUS each run/solver | Many MUSes exist; `mus` returns *a* MUS | Use `quickxplain` (preference order), `smus`/`optimal_mus` (cost), or `marco` (all) | + +## Further reading + +- Worked recipes (weights, OCUS meta-constraints, MARCO enumeration): [references/recipes.md](references/recipes.md) +- Advanced use cases (step-wise hints, optimality, counterfactuals, ML + CP), with links to reference implementations: [references/advanced.md](references/advanced.md) +- Explainable constraint solving tutorial (nurse rostering, all techniques): https://github.com/CPMpy/XCP-explain +- Debugging guide: https://cpmpy.readthedocs.io/en/latest/how_to_debug.html.md +- Assumptions and unsat cores: https://cpmpy.readthedocs.io/en/latest/unsat_core_extraction.html.md +- API docs: https://cpmpy.readthedocs.io/en/latest/api/tools/explain.html +- Full documentation for agents: https://cpmpy.readthedocs.io/en/latest/llms-full.txt +- Advanced examples: https://github.com/CPMpy/cpmpy/tree/master/examples/advanced (`ocus_explanations.py`, `explain_stepwise_csp.ipynb`, `counterfactual_explain.py`, `quickxplain.py`, `marco_musmss_enumeration.py`) diff --git a/skills/cpmpy-explain/references/advanced.md b/skills/cpmpy-explain/references/advanced.md new file mode 100644 index 000000000..ae6508b83 --- /dev/null +++ b/skills/cpmpy-explain/references/advanced.md @@ -0,0 +1,200 @@ +# Explanation use cases beyond "why UNSAT?" + +Most explanation questions can be reduced to *"explain why this set of constraints is UNSAT"*: add the right extra constraints, then call a MUS/MCS tool. This page lists those reductions, gives a compact pattern for each, and links a reference implementation. + +## Contents + +- [Overview: question → reduction → tool](#overview-question--reduction--tool) +- [Step-wise explanations (hints) for a satisfiable problem](#step-wise-explanations-hints-for-a-satisfiable-problem) +- [Why is there no better solution?](#why-is-there-no-better-solution) +- [Why X and not Y?](#why-x-and-not-y) +- [Repairing an UNSAT model interactively](#repairing-an-unsat-model-interactively) +- [Relaxing instead of removing: slack](#relaxing-instead-of-removing-slack) +- [Counterfactuals on the objective: inverse optimisation](#counterfactuals-on-the-objective-inverse-optimisation) +- [Combining with machine learning: visual sudoku](#combining-with-machine-learning-visual-sudoku) +- [Reference material](#reference-material) + +## Overview: question → reduction → tool + +| User question | Reduce to | CPMpy tool | Reference example | +|---|---|---|---| +| "What is the next easiest step / give me a hint" | `rules + known facts + ¬(one new fact)` is UNSAT | `ocus(..., meta_constraint=sum(negs)==1)` | [ACP26 notebook §3a][acp26], [IJCAI22 sudoku][ijcai], [`ocus_explanations.py`][ocus-ex] | +| "Explain the whole solution, step by step" | Repeat the hint, add each derived fact | loop over `ocus` / `mus` | [`explain_stepwise_csp.ipynb`][stepwise-nb], [IJCAI22 sudoku][ijcai] | +| "Explain this UNSAT proof step by step" | Step-wise sequence over a MUS | `mus` + XCP `stepwise.find_sequence` | [ECAI24 notebook][ecai], [XCP `explanations/stepwise`][xcp-stepwise] | +| "Why can't the objective be better?" | `constraints + (objective < opt)` is UNSAT | `mus`, `mcs` | [ACP26 §3b][acp26] | +| "Why is X true in the solution, not Y?" | `constraints + Y` is UNSAT, or re-solve with `Y` and compare | `mus` / re-solve | [ACP26 §4b, §4c][acp26] | +| "Help me fix my infeasible model" | Iterate: MUS → user drops one → repeat | `mus` in a loop, `mcs`, `mcs_opt` | XCP [`diagnosis.py`][xcp-diag] | +| "Fix it with as few/small changes as possible" | Add slack variables, minimise them | plain CPMpy model | [ACP26 §4a][acp26], [ECAI24 part 2][ecai2] | +| "What weights would make my preferred solution optimal?" | Inverse optimisation | XCP [`counterfactual.py`][xcp-cf], [`counterfactual_explain.py`][cf-ex] | [ACP26 §4c][acp26] | +| "Read and solve a sudoku from a photo, with hints" | Max-likelihood COP over a CNN's outputs + step-wise hints | `Model.maximize` + `ocus` | [`visual_sudoku.ipynb`][vsudoku], [Sudoku assistant][vs-site] | + +## Step-wise explanations (hints) for a satisfiable problem + +A step explains one new fact `n` from a few constraints `S` and known facts `E`, so that `S ∧ E → n`. That is the same as saying `S ∧ E ∧ ¬n` is UNSAT. Here `¬n` is the negation of one fact still to be explained. To pick the *easiest* step, give each constraint and fact a cost and ask OCUS for the cheapest unsatisfiable subset that contains **exactly one** negated target. This is the method behind the hint button of the [Sudoku assistant][vs-site] (Bogaerts et al., AIJ 2021; Gamba et al., IJCAI 2021). + +```python +import numpy as np +import cpmpy as cp +from cpmpy.tools.explain import ocus + +def explain_steps(rules, facts, targets, rule_cost=10, fact_cost=1): + """Yield (facts_used, rules_used, new_fact), easiest step first. + `targets` must be implied by rules + facts (e.g. the cells of a unique solution).""" + facts, targets = list(facts), list(targets) + while targets: + neg = {~t: t for t in targets} # negated target -> target + soft = rules + facts + list(neg) + weights = [rule_cost] * len(rules) + [fact_cost] * len(facts) + [0] * len(neg) + step = ocus(soft, weights=weights, meta_constraint=cp.sum(list(neg)) == 1) + new = next(neg[c] for c in step if c in neg) + yield ([c for c in step if c in facts], [c for c in step if c in rules], new) + facts.append(new) + targets.remove(new) + +grid = np.array([[0, 3, 4, 0], [4, 0, 0, 2], [1, 0, 0, 3], [0, 2, 1, 0]]) +cells = cp.intvar(1, 4, shape=grid.shape, name="cell") +rules = [cp.AllDifferent(r) for r in cells] + [cp.AllDifferent(c) for c in cells.T] +rules += [cp.AllDifferent(cells[i:i+2, j:j+2]) for i in (0, 2) for j in (0, 2)] +givens = [cells[i, j] == v for (i, j), v in np.ndenumerate(grid) if v] + +assert cp.Model(rules + givens).solve() +targets = [cells[i, j] == cells[i, j].value() for (i, j), v in np.ndenumerate(grid) if not v] + +for used_facts, used_rules, new in explain_steps(rules, givens, targets): + print(new, "because", used_facts, "and", used_rules) +# e.g. cell[0,0] == 2 because [cell[0,1] == 3, cell[1,3] == 2] +# and [alldifferent(cell[1,0],...,cell[1,3]), alldifferent(cell[0,0],cell[0,1],cell[1,0],cell[1,1])] +# (step order may vary between runs: several steps can have the same cost) +``` + +Design choices: + +- **Targets** must be facts that *follow* from the problem. For a puzzle with a unique solution these are the solution's assignments. Otherwise, use the facts shared by all solutions (`solution_intersection` in [`explain_stepwise_csp.ipynb`][stepwise-nb]). +- **The cost function defines "easy".** Constraint cost ≫ fact cost prefers steps that use few rules. Give global constraints, or ones that are hard to understand, a higher cost. The Sudoku assistant counts the constraints used. +- **Granularity**: targets can be positive assignments (`x == v`, used in the sudoku examples) or negative ones (`x != v`, for "pencil-mark" reasoning). Use `cp.all(...)` to group constraints that the user sees as one rule. +- **Show a hint without the answer**: highlight `used_facts` and `used_rules` and hide `new` (the Sudoku assistant's UX). +- **Lighter variant**: to pick a step without full optimality, compute `mus(rules + facts, hard=[~t])` for each target `t` and keep the cheapest. This is not cost-optimal, but each step is cheaper to compute. + +Scaling. Each step is an implicit-hitting-set loop, so a full sequence is expensive: + +| Instance | Approach | Time measured (ortools hitting set) | +|---|---|---| +| 4x4 sudoku | `explain_steps` above | 1–8 s per step | +| 9x9 sudoku | `explain_steps` above | > 5 min for the first step | +| 9x9 sudoku | cheapest per-target `mus`, `solver="pindakaas"` | ~17 s per step | + +To speed things up: +- use an incremental hitting-set solver (`hs_solver="gurobi"` or `"exact"`); +- use an incremental SAT-based `solver` (`"pindakaas"`, `"exact"`, `"pysat"`), which is roughly 10x faster than ortools per MUS here. Pass `do_solution_hint=False` to `ocus` for solvers without `solution_hint`; +- put facts that can't be part of the step in `hard`, or drop them; +- keep one hitting-set solver per target alive between calls, and always advance the most promising one (`split_ocus` in the [IJCAI22 notebook][ijcai]); +- for large problems, use the propagation-based sequence construction in [XCP `explanations/stepwise`][xcp-stepwise] (Bleukx et al., CP 2023). + +## Why is there no better solution? + +Turn optimality into infeasibility: forbid any better objective value, then explain that UNSAT problem. + +```python +import cpmpy as cp +from cpmpy.tools.explain import mus, mcs + +edges = [(0, 1), (0, 2), (1, 2), (1, 3), (2, 3), (0, 3), (3, 4)] +x = cp.intvar(1, 5, shape=5, name="x") +m = cp.Model([x[i] != x[j] for i, j in edges], minimize=cp.max(x)) +m.solve() +better = [m.objective_ < m.objective_value()] + +print(mus(m.constraints, hard=better)) # the K4 clique (0,1,2,3) forces 4 colours +print(mcs(m.constraints, hard=better)) # drop one of its edges to allow 3 colours +``` + +For realistic models, MUSes of optimality tend to be large. Group constraints (`cp.all`) per user-level concept before explaining. + +## Why X and not Y? + +The user proposes a foil `Y`, e.g. "why isn't node 4 the same colour as node 3?": + +- **Deductive**: `mus(model.constraints, hard=[Y])` returns the constraints that rule `Y` out. +- **Counterfactual**: re-solve with `Y` added (`m2 = model.copy(); m2 += Y; m2.solve()`), then show how the solution and objective change. The XCP notebooks highlight the changed cells (`highlight_changes`). + +## Repairing an UNSAT model interactively + +One MUS is one conflict. Fixing it may reveal the next one. An interactive repair loop: + +```python +import cpmpy as cp +from cpmpy.tools.explain import mus + +def diagnose(soft, hard=[], choose=lambda conflict: conflict[0]): + soft, removed = list(soft), [] + while not cp.Model(hard + soft).solve(): + conflict = mus(soft, hard) + c = choose(conflict) # ask the user, or use a heuristic + soft.remove(c) + removed.append(c) + return removed +``` + +An assumption-based, incremental version with user input is in XCP [`diagnosis.py`][xcp-diag]; it also has `fastdiag`, the MCS counterpart of QuickXplain. When the user doesn't need to choose, `mcs` / `mcs_opt(weights=priority)` gives the cheapest repair in one call. + +## Relaxing instead of removing: slack + +For numeric constraints (coverage, capacity), removing a constraint is too coarse. Add a slack variable to each constraint and minimise how much the constraints are violated: + +```python +under = cp.intvar(0, UB, shape=n, name="under") +over = cp.intvar(0, UB, shape=n, name="over") +model += [cp.sum(assigned[d]) + under[d] - over[d] == required[d] for d in range(n)] + +slack = cp.cpm_array(list(under) + list(over)) +model.minimize(cp.sum(slack)) # total violation +model.minimize(cp.sum(slack != 0)) # number of violated constraints +model.minimize(10000 * cp.max(slack) + 1000 * cp.sum(slack != 0) + cp.sum(slack)) # lexicographic +``` + +Full nurse rostering version: `NurseSchedulingFactory.get_slack_model` in [XCP `factory.py`][xcp], used in [ACP26 §4a][acp26]. + +## Counterfactuals on the objective: inverse optimisation + +"What objective weights would have to change so that my preferred assignment becomes optimal?" This is solved by alternating a master problem (new weights, close to the old ones) with a sub-problem (re-optimise under those weights) (Korikov & Beck, CP 2021). + +- Knapsack example: [`examples/advanced/counterfactual_explain.py`][cf-ex] +- Generic `inverse_optimize(model, user_sol, allowed_to_change)`: XCP [`counterfactual.py`][xcp-cf], used in [ACP26 §4c][acp26]. + +## Combining with machine learning: visual sudoku + +The [Sudoku assistant][vs-site] reads a photographed puzzle and then gives hints: + +1. A CNN outputs, for each cell, a probability for each digit 1–9 and for "empty". +2. **Joint inference**: instead of taking the argmax per cell, solve a COP that maximises the log-likelihood of the chosen digits subject to the sudoku rules. This corrects misread digits that would make the puzzle UNSAT or ambiguous (see [`visual_sudoku.ipynb`][vsudoku], Mulamba et al., CPAIOR 2020). +3. **Hints**: [step-wise explanations](#step-wise-explanations-hints-for-a-satisfiable-problem) on the recognised grid, with the cost = number of constraints used. + +## Reference material + +- [XCP-explain][xcp]: explainable constraint solving tutorial repo (Bleukx, Tsouros, Guns). Nurse rostering running example, with: + - [ACP 2026 notebook][acp26]: the most recent; covers all of the above; + - [ECAI 2024 notebook][ecai] and practice parts [1][ecai1], [2][ecai2], [3][ecai3] (Colab-ready); + - `explanations/`: less mature algorithms not (yet) in `cpmpy.tools.explain`: step-wise sequences, `diagnose`, `fastdiag`, inverse optimisation, proof-log based cores with Pumpkin. +- CPMpy examples: [`ocus_explanations.py`][ocus-ex], [`explain_stepwise_csp.ipynb`][stepwise-nb], [`counterfactual_explain.py`][cf-ex], [`visual_sudoku.ipynb`][vsudoku], [IJCAI22 tutorial: explain sudoku][ijcai] +- Papers: + - Bogaerts, Gamba, Guns. *A framework for step-wise explaining how to solve constraint satisfaction problems.* AIJ 2021. + - Gamba, Bogaerts, Guns. *Efficiently explaining CSPs with unsatisfiable subset optimization.* IJCAI 2021 / JAIR 2023. + - Bleukx, Devriendt, Gamba, Bogaerts, Guns. *Simplifying step-wise explanation sequences.* CP 2023. + - Korikov, Beck. *Counterfactual explanations via inverse constraint programming.* CP 2021. + - Mulamba, Mandi, Canoy, Guns. *Hybrid classification and reasoning for image-based constraint solving.* CPAIOR 2020. + +[xcp]: https://github.com/CPMpy/XCP-explain +[acp26]: https://github.com/CPMpy/XCP-explain/blob/main/acp26_presentation.ipynb +[ecai]: https://github.com/CPMpy/XCP-explain/blob/main/ecai2024_presentation.ipynb +[ecai1]: https://colab.research.google.com/github/CPMpy/XCP-explain/blob/main/ecai2024_practice_part1.ipynb +[ecai2]: https://colab.research.google.com/github/CPMpy/XCP-explain/blob/main/ecai2024_practice_part2.ipynb +[ecai3]: https://colab.research.google.com/github/CPMpy/XCP-explain/blob/main/ecai2024_practice_part3.ipynb +[xcp-stepwise]: https://github.com/CPMpy/XCP-explain/tree/main/explanations/stepwise +[xcp-diag]: https://github.com/CPMpy/XCP-explain/blob/main/explanations/diagnosis.py +[xcp-cf]: https://github.com/CPMpy/XCP-explain/blob/main/explanations/counterfactual.py +[ocus-ex]: https://github.com/CPMpy/cpmpy/blob/master/examples/advanced/ocus_explanations.py +[stepwise-nb]: https://github.com/CPMpy/cpmpy/blob/master/examples/advanced/explain_stepwise_csp.ipynb +[cf-ex]: https://github.com/CPMpy/cpmpy/blob/master/examples/advanced/counterfactual_explain.py +[vsudoku]: https://github.com/CPMpy/cpmpy/blob/master/examples/advanced/visual_sudoku.ipynb +[ijcai]: https://github.com/CPMpy/cpmpy/blob/master/examples/tutorial_ijcai22/6_explain_sudoku.ipynb +[vs-site]: https://visualsudoku.cs.kuleuven.be/behind-the-scenes.html diff --git a/skills/cpmpy-explain/references/recipes.md b/skills/cpmpy-explain/references/recipes.md new file mode 100644 index 000000000..847af3dd4 --- /dev/null +++ b/skills/cpmpy-explain/references/recipes.md @@ -0,0 +1,172 @@ +# CPMpy explanation recipes + +Worked patterns for `cpmpy.tools.explain`. Every snippet is self-contained and runs with only `ortools` installed. + +## Contents + +- [Preferred explanation with QuickXplain](#preferred-explanation-with-quickxplain) +- [Weighted (optimal) MUS](#weighted-optimal-mus) +- [Constrained explanation with OCUS](#constrained-explanation-with-ocus) +- [Weighted repair with MCS / MSS](#weighted-repair-with-mcs--mss) +- [Enumerate all conflicts and repairs with MARCO](#enumerate-all-conflicts-and-repairs-with-marco) +- [Why does the solver not find my solution?](#why-does-the-solver-not-find-my-solution) +- [Low level: assumptions and unsat cores](#low-level-assumptions-and-unsat-cores) +- [More involved tasks](#more-involved-tasks) + +## Preferred explanation with QuickXplain + +`quickxplain` returns the MUS that is lexicographically preferred according to the **order** of `soft`: put the constraints you would *rather* see in the explanation first. + +```python +import cpmpy as cp +from cpmpy.tools.explain import quickxplain + +x = cp.intvar(0, 10, shape=3, name="x") +soft = [ + x[0] + x[1] <= 4, # most preferred in the explanation + x[0] >= 3, + x[1] >= 3, + x[2] == x[0] + x[1], + x[2] >= 7, # least preferred +] +print(quickxplain(soft)) +# [x[0] + x[1] <= 4, x[0] >= 3, x[1] >= 3] +``` + +## Weighted (optimal) MUS + +Use weights to say how *hard to understand* each constraint is. `optimal_mus` minimises `sum(weights)` over all MUSes; `smus` is the unweighted special case (fewest constraints). + +```python +import cpmpy as cp +from cpmpy.tools.explain import optimal_mus, smus + +x = cp.intvar(0, 10, shape=3, name="x") +soft = [ + cp.AllDifferent(x), # global constraint: costly to explain + x[0] == x[1], + x[0] >= 5, + x[1] <= 4, +] +weights = [10, 1, 1, 1] + +print(smus(soft)) # one of the 2-constraint MUSes +print(optimal_mus(soft, weights=weights)) # [x[0] == x[1], x[0] >= 5, x[1] <= 4], avoids AllDifferent +``` + +## Constrained explanation with OCUS + +`ocus` adds a `meta_constraint`: a Boolean CPMpy expression *over the soft constraints themselves*. Each soft constraint stands for "this constraint is in the explanation". Useful to force or forbid constraints, or to require "at most one of this group". + +```python +import cpmpy as cp +from cpmpy.tools.explain import ocus + +x = cp.intvar(0, 10, shape=3, name="x") +soft = [ + x[0] == x[1], + x[0] >= 5, + x[1] <= 4, + x[0] + x[1] <= 3, +] + +# explanation must use the sum constraint +print(ocus(soft, meta_constraint=soft[3])) +# explanation must not use the equality +print(ocus(soft, meta_constraint=~soft[0])) +``` + +If no MUS satisfies the meta-constraint, `ocus` raises `cpmpy.tools.explain.OCUSException`. + +## Weighted repair with MCS / MSS + +`mcs_opt` returns the cheapest set of constraints to remove; `mss_opt` the most valuable set to keep. Weights = the cost of dropping each constraint (e.g. priority of a user request). + +```python +import cpmpy as cp +from cpmpy.tools.explain import mcs_opt, mss_opt + +slot = cp.intvar(1, 3, shape=3, name="slot") # meeting slot of 3 people +requests = { + "alice wants slot 1": slot[0] == 1, + "bob wants slot 1": slot[1] == 1, + "carol wants slot 2": slot[2] == 2, + "carol wants to meet alice": slot[2] == slot[0], +} +priority = [3, 1, 2, 5] +hard = [cp.AllDifferent(slot[:2])] # alice and bob can't share + +soft = list(requests.values()) +label = {c: n for n, c in requests.items()} +print("drop:", [label[c] for c in mcs_opt(soft, hard, priority)]) +print("keep:", [label[c] for c in mss_opt(soft, hard, priority)]) +``` + +`mcs_grow` / `mss_grow` give a subset-minimal (not cost-optimal) answer without needing optimisation, and are often faster on large models. + +## Enumerate all conflicts and repairs with MARCO + +`marco` is a generator. Stop early when you have enough; the full enumeration can be exponential. + +```python +import itertools +import cpmpy as cp +from cpmpy.tools.explain import marco + +x = cp.boolvar(shape=3, name="x") +soft = [x[0], x[0] | x[1], x[2].implies(x[1]), ~x[0], ~x[1]] + +for kind, cons in itertools.islice(marco(soft), 10): + print(kind, cons) + +# only MUSes +muses = [cons for _, cons in marco(soft, return_mcs=False)] +``` + +Pass `map_solver="gurobi"` or `"exact"` (incremental) for large instances. + +## Why does the solver not find my solution? + +Add the expected solution as hard constraints, then explain the now-UNSAT model. + +```python +import cpmpy as cp +from cpmpy.tools.explain import mus + +x = cp.intvar(0, 9, shape=3, name="x") +model = cp.Model( + cp.AllDifferent(x), + x[0] + x[1] == x[2], + x[2] < 5, +) +expected = [x[0] == 2, x[1] == 3, x[2] == 5] +print(mus(soft=model.constraints, hard=expected)) +# [x[2] < 5] -> this is the constraint that rules out the expected solution +``` + +For an optimisation model, explain the feasibility part only (`model.constraints`), optionally with `hard=expected + [model.objective_ == value]`. + +## Low level: assumptions and unsat cores + +All default tools build on `make_assump_model` + `solve(assumptions=...)` + `get_core()`. Use this directly to write your own algorithm or to reuse one incremental solver. + +```python +import cpmpy as cp +from cpmpy.tools.explain import make_assump_model + +x = cp.intvar(0, 9, shape=3, name="x") +soft = [x[0] > x[1], x[1] > x[2], x[2] > x[0]] + +model, soft, assump = make_assump_model(soft, name="a") # adds a[i] -> soft[i] +s = cp.SolverLookup.get("ortools", model) +assert s.solve(assumptions=assump) is False +core = s.get_core() # subset of assump, not necessarily minimal +amap = dict(zip(assump, soft)) +print([amap[a] for a in core]) +``` + +OR-Tools is not incremental across `solve(assumptions=...)` calls; `exact`, `pysat`, `z3` are. + +## More involved tasks + +Step-wise explanations (hints), explaining optimality, "why X and not Y", interactive repair, slack relaxation and counterfactuals: see [advanced.md](advanced.md).