From 487554442e0ac72dac24586c089e6165fd23eeb0 Mon Sep 17 00:00:00 2001 From: Mikael Zayenz Lagerkvist Date: Wed, 28 Oct 2026 05:28:06 +0100 Subject: [PATCH 1/7] Add an option for experimental preview APIs GECODE_ENABLE_EXPERIMENTAL (CMake) and --enable-experimental (Autoconf) define GECODE_HAS_EXPERIMENTAL. The option is off by default; CI builds both settings. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/build.yml | 8 ++++++-- CMakeLists.txt | 5 +++++ configure | 31 +++++++++++++++++++++++++++++++ configure.ac | 1 + doxygen/doxygen.conf.in | 1 + gecode.m4 | 25 +++++++++++++++++++++++++ gecode/support/config.hpp.in | 3 +++ 7 files changed, 72 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a7e10cbf9a..274d4c1f1f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -69,7 +69,7 @@ jobs: - name: Configure shell: bash - run: ./configure + run: ./configure --enable-experimental - name: Build shell: bash @@ -233,6 +233,7 @@ jobs: shell: bash run: > cmake -S "$GITHUB_WORKSPACE" -B "$GITHUB_WORKSPACE/build-ninja" + -DGECODE_ENABLE_EXPERIMENTAL=ON -G Ninja -DCMAKE_BUILD_TYPE=$BUILD_TYPE -DGECODE_ENABLE_QT=OFF @@ -486,6 +487,7 @@ jobs: shell: pwsh run: cmake -E make_directory "${{ github.workspace }}\\build" + # Experimental APIs follow build_shared, so both settings are built - name: Configure CMake shell: pwsh run: > @@ -496,6 +498,7 @@ jobs: -DGECODE_ENABLE_MPFR=OFF -DGECODE_BUILD_SHARED=${{ matrix.build_shared }} -DGECODE_BUILD_STATIC=${{ matrix.build_static }} + -DGECODE_ENABLE_EXPERIMENTAL=${{ matrix.build_shared }} - name: Build shell: pwsh @@ -607,6 +610,7 @@ jobs: -DGECODE_ENABLE_MPFR=OFF \ -DGECODE_BUILD_SHARED=OFF \ -DGECODE_BUILD_STATIC=ON \ + -DGECODE_ENABLE_EXPERIMENTAL=ON \ -DGECODE_ENABLE_EXAMPLES=OFF \ -DGECODE_ENABLE_FLATZINC=OFF @@ -675,7 +679,7 @@ jobs: shell: msys2 {0} run: | export PATH="$UV_DIR:$PATH" - CC=gcc CXX=g++ ./configure --with-host-os=Windows --disable-qt --disable-gist --disable-mpfr + CC=gcc CXX=g++ ./configure --with-host-os=Windows --disable-qt --disable-gist --disable-mpfr --enable-experimental - name: Build shell: msys2 {0} diff --git a/CMakeLists.txt b/CMakeLists.txt index 145bc33f87..f3ea030be1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -107,6 +107,7 @@ endif() set(GECODE_ENABLE_GIST "${GECODE_ENABLE_GIST_DEFAULT}" CACHE STRING "Enable Gist (AUTO, ON, or OFF)") set_property(CACHE GECODE_ENABLE_GIST PROPERTY STRINGS AUTO ON OFF) option(GECODE_ENABLE_CPPROFILER "Enable CPProfiler support" ON) +option(GECODE_ENABLE_EXPERIMENTAL "Enable experimental preview APIs" OFF) option(GECODE_ENABLE_CBS "Enable counting-based search support" OFF) set(GECODE_ENABLE_EXAMPLES_DEFAULT ${PROJECT_IS_TOP_LEVEL}) option(GECODE_ENABLE_EXAMPLES "Build examples" ${GECODE_ENABLE_EXAMPLES_DEFAULT}) @@ -692,6 +693,9 @@ endif() if(GECODE_ENABLE_CPPROFILER) set(GECODE_HAS_CPPROFILER "/**/") endif() +if(GECODE_ENABLE_EXPERIMENTAL) + set(GECODE_HAS_EXPERIMENTAL "/**/") +endif() if(GECODE_ENABLE_CBS) set(GECODE_HAS_CBS "/**/") endif() @@ -1815,6 +1819,7 @@ include(FeatureSummary) foreach(gecode_option IN ITEMS GECODE_BUILD_SHARED GECODE_BUILD_STATIC GECODE_ENABLE_THREAD GECODE_ENABLE_CPPROFILER GECODE_ENABLE_CBS + GECODE_ENABLE_EXPERIMENTAL GECODE_ENABLE_EXAMPLES GECODE_INSTALL GECODE_ENABLE_SEARCH GECODE_ENABLE_INT_VARS GECODE_ENABLE_SET_VARS GECODE_ENABLE_FLOAT_VARS GECODE_ENABLE_MINIMODEL GECODE_ENABLE_DRIVER diff --git a/configure b/configure index 42ff3918e9..29247a22a9 100755 --- a/configure +++ b/configure @@ -680,6 +680,7 @@ HAVEFLEX enable_cpprofiler LINKCPPROFILER enable_cbs +enable_experimental enable_gist enable_qt QTLIBS @@ -859,6 +860,7 @@ with_mpfr_include with_mpfr_lib enable_qt enable_gist +enable_experimental enable_cbs enable_cpprofiler enable_driver @@ -1531,6 +1533,7 @@ Optional Features: --enable-qt build with Qt support, requires Qt 5.15 or newer [default=yes] --enable-gist build Gecode Interactive Search Tool [default=yes] + --enable-experimental build experimental preview APIs [default=no] --enable-cbs build with support for counting-based search [default=no] --enable-cpprofiler build with support for CPProfiler [default=yes] @@ -3409,6 +3412,8 @@ GECODE_SOVERSION=${ac_gecode_soversion} + + @@ -13593,6 +13598,32 @@ printf "%s\n" "no" >&6; } fi + # Check whether --enable-experimental was given. +if test ${enable_experimental+y} +then : + enableval=$enable_experimental; +else case e in #( + e) enable_experimental=no ;; +esac +fi + + { printf "%s\n" "$as_me:${as_lineno-$LINENO}: checking whether to build experimental preview APIs" >&5 +printf %s "checking whether to build experimental preview APIs... " >&6; } + case $enable_experimental in #( + yes) : + +printf "%s\n" "#define GECODE_HAS_EXPERIMENTAL /**/" >>confdefs.h + ;; #( + no) : + ;; #( + *) : + as_fn_error $? "--enable-experimental accepts only yes or no" "$LINENO" 5 ;; +esac + { printf "%s\n" "$as_me:${as_lineno-$LINENO}: result: $enable_experimental" >&5 +printf "%s\n" "$enable_experimental" >&6; } + + + # Check whether --enable-cbs was given. if test ${enable_cbs+y} then : diff --git a/configure.ac b/configure.ac index f5ffb8e044..5e0ea06b12 100644 --- a/configure.ac +++ b/configure.ac @@ -264,6 +264,7 @@ dnl ------------------------------------------------------------------ AC_GECODE_MPFR AC_GECODE_QT AC_GECODE_GIST +AC_GECODE_EXPERIMENTAL AC_GECODE_CBS AC_GECODE_CPPROFILER AC_GECODE_FLEXBISON diff --git a/doxygen/doxygen.conf.in b/doxygen/doxygen.conf.in index 02a6a25aef..82de20ae79 100755 --- a/doxygen/doxygen.conf.in +++ b/doxygen/doxygen.conf.in @@ -1007,6 +1007,7 @@ PREDEFINED = "GECODE_SET_EXPORT=" \ "GECODE_HAS_FLOAT_VARS=1" \ "GECODE_HAS_GIST=1" \ "GECODE_HAS_CPPROFILER=1" \ + "GECODE_HAS_EXPERIMENTAL=1" \ "GECODE_THREADS_PTHREADS=1" \ "GECODE_HAS_THREADS=1" \ "GECODE_HAS_MPFR=1" \ diff --git a/gecode.m4 b/gecode.m4 index 666e3a1817..a78152fcc9 100755 --- a/gecode.m4 +++ b/gecode.m4 @@ -1417,6 +1417,31 @@ AC_DEFUN([AC_GECODE_GIST], fi ]) +dnl +dnl Macro: +dnl AC_GECODE_EXPERIMENTAL +dnl +dnl Description: +dnl Produces the configure switch --enable-experimental +dnl for compiling experimental preview APIs. +dnl +dnl Authors: +dnl Mikael Zayenz Lagerkvist +AC_DEFUN([AC_GECODE_EXPERIMENTAL], + [ + AC_ARG_ENABLE([experimental], + AS_HELP_STRING([--enable-experimental], + [build experimental preview APIs @<:@default=no@:>@]), + [], [enable_experimental=no]) + AC_MSG_CHECKING(whether to build experimental preview APIs) + AS_CASE([$enable_experimental], + [yes], [AC_DEFINE([GECODE_HAS_EXPERIMENTAL],[],[Whether experimental preview APIs are available])], + [no], [], + [AC_MSG_ERROR([--enable-experimental accepts only yes or no])]) + AC_MSG_RESULT([$enable_experimental]) + AC_SUBST([enable_experimental]) +]) + dnl dnl Macro: dnl AC_GECODE_CBS diff --git a/gecode/support/config.hpp.in b/gecode/support/config.hpp.in index 9c9588878f..f434087688 100644 --- a/gecode/support/config.hpp.in +++ b/gecode/support/config.hpp.in @@ -67,6 +67,9 @@ /* Whether CPProfiler support available */ #undef GECODE_HAS_CPPROFILER +/* Whether experimental preview APIs are available */ +#undef GECODE_HAS_EXPERIMENTAL + /* Whether to build FLOAT variables */ #undef GECODE_HAS_FLOAT_VARS From 1c69ad7615947bdab5e37875100f8321892c4f6e Mon Sep 17 00:00:00 2001 From: Mikael Zayenz Lagerkvist Date: Wed, 28 Oct 2026 05:28:06 +0100 Subject: [PATCH 2/7] Group experimental changelog entries Entries marked Experimental: true appear under Experimental changes in each release, and tidy validates the field. Co-Authored-By: Claude Opus 5.5 --- changelog.in | 2 ++ misc/genchangelog.py | 33 +++++++++++++++---------- misc/gencurrentchangelog.py | 48 ++++++++++++++++++++++++------------- misc/gentxtchangelog.py | 47 ++++++++++++++++++++++-------------- misc/tidy.py | 43 +++++++++++++++++++++++++++++++++ 5 files changed, 125 insertions(+), 48 deletions(-) diff --git a/changelog.in b/changelog.in index e14f52adfd..92fc91efdc 100755 --- a/changelog.in +++ b/changelog.in @@ -56,6 +56,8 @@ # driver|iter|support|example|test|gist|flatzinc|other # What: bug|documentation|performance|new|removed|change # Rank: minor|major +# Experimental: true|false +# True entries appear under Experimental changes within each release. # Bug: # Issue: # Thanks: diff --git a/misc/genchangelog.py b/misc/genchangelog.py index 9bf2db440f..f8cba0b8ba 100644 --- a/misc/genchangelog.py +++ b/misc/genchangelog.py @@ -74,7 +74,6 @@ def main() -> int: whatorder = ["new", "removed", "change", "bug", "performance", "documentation"] - hastext = defaultdict(int) text = defaultdict(str) versions = "" @@ -89,16 +88,17 @@ def flush_current_release() -> None: body = body + f"\\section {sid} Changes in Version {version} ({date})\n\n" body = body + f"{info}\n\n" - for mod in modorder: - if hastext[mod]: - body = body + " - " + modclear[mod] + "\n" - hastext[mod] = 0 - for what in whatorder: - k = f"{mod}-{what}" - if text[k] != "": - body = body + " - " + whatclear[what] + "\n" - body = body + text[k] - text[k] = "" + for experimental in (False, True): + if experimental and any(text[(True, mod, what)] for mod in modorder for what in whatorder): + body += f"\\subsection {sid}_experimental Experimental changes\n\n" + for mod in modorder: + if any(text[(experimental, mod, what)] for what in whatorder): + body += " - " + modclear[mod] + "\n" + for what in whatorder: + k = (experimental, mod, what) + if text[k]: + body += " - " + whatclear[what] + "\n" + text[k] + text[k] = "" body = body + "\n\n" lines = sys.stdin.readlines() @@ -156,12 +156,20 @@ def flush_current_release() -> None: issue = "" thanks = "" author = "" + experimental = False desc = "" more = "" while idx < len(lines): l = lines[idx] idx += 1 + m = re.fullmatch(r"Experimental:[\t ]*(.*)\n?", l) + if m: + value = m.group(1).rstrip("\n") + if value not in {"true", "false"}: + print("genchangelog: invalid Experimental value " + repr(value), file=sys.stderr) + return 1 + experimental = value == "true" m = re.search(r"Module:[\t ]*(.*)$", l) if m: mod = m.group(1) @@ -214,7 +222,6 @@ def flush_current_release() -> None: more = more.replace("&", "\\&") more = re.sub(r"\n([ \t]*)\n", r"\n\1
\n", more) - hastext[mod] = 1 rb = rankclear.get(rank, "") if bug != "": rb = rb + f", bugzilla entry {bug}" @@ -238,7 +245,7 @@ def flush_current_release() -> None: ) moreinfocnt += 1 - k = f"{mod}-{what}" + k = (experimental, mod, what) text[k] = text[k] + f" - {desc} ({rb})\n" + more current = l continue diff --git a/misc/gencurrentchangelog.py b/misc/gencurrentchangelog.py index 4d5ff7424c..6e00f024f6 100644 --- a/misc/gencurrentchangelog.py +++ b/misc/gencurrentchangelog.py @@ -68,9 +68,25 @@ def main() -> int: ] whatorder = ["new", "removed", "change", "bug", "performance", "documentation"] - hastext = defaultdict(int) text = defaultdict(str) + def flush_current_release() -> None: + for experimental in (False, True): + if experimental and any(text[(True, mod, what)] for mod in modorder for what in whatorder): + sys.stdout.write("

Experimental changes

\n") + sys.stdout.write("
    \n") + for mod in modorder: + if any(text[(experimental, mod, what)] for what in whatorder): + sys.stdout.write(f"
  • {modclear[mod]}\n
      \n") + for what in whatorder: + k = (experimental, mod, what) + if text[k]: + sys.stdout.write(f"
    • {whatclear[what]}\n
        " + text[k] + "
    • \n") + sys.stdout.write("
  • \n") + sys.stdout.write("
\n") + if not any(text[(True, mod, what)] for mod in modorder for what in whatorder): + break + lines = sys.stdin.readlines() idx = 0 current = None @@ -91,20 +107,7 @@ def main() -> int: if re.match(r"^\[RELEASE\]", l): if not first: - sys.stdout.write("
    \n") - for mod in modorder: - if hastext[mod]: - sys.stdout.write(f"
  • {modclear[mod]}\n
      \n") - hastext[mod] = 0 - for what in whatorder: - k = f"{mod}-{what}" - if text[k] != "": - sys.stdout.write(f"
    • {whatclear[what]}\n
        ") - sys.stdout.write(text[k]) - sys.stdout.write("
    • \n") - text[k] = "" - sys.stdout.write("
  • \n") - sys.stdout.write("
\n") + flush_current_release() return 0 first = False @@ -135,6 +138,7 @@ def main() -> int: if re.match(r"^\[ENTRY\]", l): entry_number += 1 + experimental = False desc = "" bug = "" rank = "" @@ -149,6 +153,13 @@ def main() -> int: l = l.replace("%Gecode", "Gecode") if re.search(r"\[DESCRIPTION\]", l): break + m = re.fullmatch(r"Experimental:[\t ]*(.*)\n?", l) + if m: + value = m.group(1).rstrip("\n") + if value not in {"true", "false"}: + print("gencurrentchangelog: invalid Experimental value " + repr(value), file=sys.stderr) + return 1 + experimental = value == "true" m = re.search(r"Module:[\t ]*(.*)$", l) if m: mod = m.group(1) @@ -204,16 +215,19 @@ def main() -> int: ) return 1 - hastext[mod] = 1 rb = rankclear[rank] if bug != "": rb = rb + f", bugzilla entry {bug}" if thanks != "": rb = rb + f", thanks to {thanks}" - k = f"{mod}-{what}" + k = (experimental, mod, what) text[k] = text[k] + f"
  • {desc}({rb})
  • \n" continue + if not first: + flush_current_release() + return 0 + sys.stdout.write( """Initial release (2005-12-06) diff --git a/misc/gentxtchangelog.py b/misc/gentxtchangelog.py index 8cffd2b604..d1d4d085de 100644 --- a/misc/gentxtchangelog.py +++ b/misc/gentxtchangelog.py @@ -76,9 +76,24 @@ def main() -> int: whatorder = ["new", "removed", "change", "bug", "performance", "documentation"] - hastext = defaultdict(int) text = defaultdict(str) + def flush_current_release() -> None: + sys.stdout.write(f"Changes in Version {version} ({date})\n\n") + sys.stdout.write(f"Scope:{info}") + for experimental in (False, True): + if experimental and any(text[(True, mod, what)] for mod in modorder for what in whatorder): + sys.stdout.write("Experimental changes\n") + for mod in modorder: + if any(text[(experimental, mod, what)] for what in whatorder): + sys.stdout.write(f"- {modclear[mod]}\n") + for what in whatorder: + k = (experimental, mod, what) + if text[k]: + sys.stdout.write(f" - {whatclear[what]}\n" + text[k]) + text[k] = "" + sys.stdout.write("\n------------------------------------------------------------------------------\n\n") + lines = sys.stdin.readlines() idx = 0 current = None @@ -99,21 +114,7 @@ def main() -> int: if re.match(r"^\[RELEASE\]", l): if not first: - sys.stdout.write(f"Changes in Version {version} ({date})\n\n") - sys.stdout.write(f"Scope:{info}") - - for mod in modorder: - if hastext[mod]: - sys.stdout.write(f"- {modclear[mod]}\n") - hastext[mod] = 0 - for what in whatorder: - k = f"{mod}-{what}" - if text[k] != "": - sys.stdout.write(f" - {whatclear[what]}\n") - sys.stdout.write(text[k]) - text[k] = "" - - sys.stdout.write("\n------------------------------------------------------------------------------\n\n") + flush_current_release() first = False version = "" @@ -144,6 +145,7 @@ def main() -> int: continue if re.match(r"^\[ENTRY\]", l): + experimental = False desc = "" bug = "" issue = "" @@ -158,6 +160,13 @@ def main() -> int: l = l.replace("%Gecode", "Gecode") if re.search(r"\[DESCRIPTION\]", l): break + m = re.fullmatch(r"Experimental:[\t ]*(.*)\n?", l) + if m: + value = m.group(1).rstrip("\n") + if value not in {"true", "false"}: + print("gentxtchangelog: invalid Experimental value " + repr(value), file=sys.stderr) + return 1 + experimental = value == "true" m = re.search(r"Module:[\t ]*(.*)$", l) if m: mod = m.group(1) @@ -193,7 +202,6 @@ def main() -> int: if desc.endswith("\n"): desc = desc[:-1] - hastext[mod] = 1 rb = rankclear.get(rank, "") if bug != "": rb = rb + f", bugzilla entry {bug}" @@ -202,10 +210,13 @@ def main() -> int: if thanks != "": rb = rb + f", thanks to {thanks}" - k = f"{mod}-{what}" + k = (experimental, mod, what) text[k] = text[k] + f" - {desc}({rb})\n" continue + if not first and any(text.values()): + flush_current_release() + sys.stdout.write( """Initial release (2005-12-06) diff --git a/misc/tidy.py b/misc/tidy.py index b84a7d593e..1948ccdaf5 100644 --- a/misc/tidy.py +++ b/misc/tidy.py @@ -61,6 +61,7 @@ class Entry: module: str what: str rank: str + experimental: str bug: str issue: str thanks: str @@ -144,6 +145,7 @@ def parse_changelog() -> tuple[list[Release], list[Entry]]: "module": "", "what": "", "rank": "", + "experimental": "false", "bug": "", "issue": "", "thanks": "", @@ -153,6 +155,7 @@ def parse_changelog() -> tuple[list[Release], list[Entry]]: "module": "Module", "what": "What", "rank": "Rank", + "experimental": "Experimental", "bug": "Bug", "issue": "Issue", "thanks": "Thanks", @@ -233,6 +236,8 @@ def validate_changelog_entries(generated: str) -> list[str]: errors.append(f"line {entry.line}: unknown module {entry.module!r}") if entry.what not in WHATS: errors.append(f"line {entry.line}: unknown change type {entry.what!r}") + if entry.experimental not in {"true", "false"}: + errors.append(f"line {entry.line}: invalid Experimental value {entry.experimental!r}") if entry.rank not in RANKS: errors.append(f"line {entry.line}: unknown rank {entry.rank!r}") @@ -470,10 +475,48 @@ def validate_current_changelog() -> list[str]: return errors +def validate_experimental_changelog() -> list[str]: + errors = [] + release = "[RELEASE]\nVersion: 1.0.0\nDate: 2000-01-01\n[DESCRIPTION]\nFixture.\n" + + def entry(description: str, experimental: str = "", module: str = "search", what: str = "new") -> str: + field = f"Experimental: {experimental}\n" if experimental else "" + return (f"[ENTRY]\nModule: {module}\nWhat: {what}\nRank: minor\n" + + field + f"[DESCRIPTION]\n{description}\n") + + # Mixed input order checks separation while preserving module/type order. + source = release + entry("Preview search change.", "true", what="change") + source += entry("Ordinary omitted.") + entry("Preview kernel.", "true", "kernel") + source += entry("Ordinary false.", "false") + entry("Preview search addition.", "true") + older = release.replace("1.0.0", "0.9.0") + entry("Older ordinary.") + for generator in (GEN_CHANGELOG, ROOT / "misc" / "gentxtchangelog.py", GEN_CURRENT_CHANGELOG): + for suffix in ("", older): + result = run(["uv", "run", str(generator)], stdin=source + suffix) + output = result.stdout + markers = ["Ordinary omitted.", "Ordinary false.", "Experimental changes", + "Preview kernel.", "Preview search addition.", "Preview search change."] + positions = [output.find(marker) for marker in markers] + if result.returncode or min(positions) < 0 or positions != sorted(positions): + errors.append(f"{generator.name}: experimental separation/order regression") + if output.count("Experimental changes") != 1: + errors.append(f"{generator.name}: experimental heading leaked across releases") + omitted = run(["uv", "run", str(generator)], stdin=release + entry("Ordinary.")) + explicit = run(["uv", "run", str(generator)], stdin=release + entry("Ordinary.", "false")) + if omitted.returncode or explicit.returncode or omitted.stdout != explicit.stdout or "Experimental changes" in omitted.stdout: + errors.append(f"{generator.name}: false/omitted historical rendering differs") + for value in ("TRUE", "False", "yes", "1", "", "true "): + invalid = entry("Invalid.", "true").replace("Experimental: true", f"Experimental: {value}") + result = run(["uv", "run", str(generator)], stdin=release + invalid) + if result.returncode == 0 or "invalid Experimental value" not in result.stderr: + errors.append(f"{generator.name}: accepted invalid Experimental {value!r}") + return errors + + def check_changelog(*, fix: bool) -> int: generated = generate_changelog() errors = validate_changelog_entries(generated) errors.extend(validate_current_changelog()) + errors.extend(validate_experimental_changelog()) if is_tracked(CHANGELOG_HH): if CHANGELOG_HH.exists() and CHANGELOG_HH.read_text() == generated: From 32a5a3136b9045e5f5d1d397d8f88bf4b9941ee8 Mon Sep 17 00:00:00 2001 From: Mikael Zayenz Lagerkvist Date: Wed, 28 Oct 2026 05:29:53 +0100 Subject: [PATCH 3/7] Add experimental A* and CABS search AStar and CABS (complete anytime beam search) are best-solution engines for ordinary Space subclasses. Their guidance, an optimistic objective bound with an optional rank and optional CABS dominance, is passed through Search::Options::guidance, so they have the same constructor as BAB and run with Script::run; the driver forwards Options::guidance. Search::Options::max_width and frontier_limit bound CABS. Both engines support parallel search, worker control, and portfolios. Open nodes, layers and beams use the Support containers; node order, incumbent, A* queue and CABS beam are shared in search/heuristic.hh, and parallel A* and CABS are subclasses of Par::Frontier. Co-Authored-By: Claude Opus 5.5 --- Makefile.in | 14 +- changelog.in | 15 + cmake/GecodeSources.cmake | 8 + docs/worker-control.md | 12 +- gecode/driver.hh | 14 + gecode/driver/options.cpp | 3 + gecode/driver/options.hpp | 11 + gecode/driver/script.hpp | 9 + gecode/search.hh | 142 +++++++++ gecode/search/astar.cpp | 247 ++++++++++++++++ gecode/search/astar.hpp | 93 ++++++ gecode/search/cabs.cpp | 331 +++++++++++++++++++++ gecode/search/cabs.hpp | 93 ++++++ gecode/search/exception.cpp | 7 + gecode/search/exception.hpp | 15 + gecode/search/heuristic.hh | 397 +++++++++++++++++++++++++ gecode/search/options.hpp | 6 +- gecode/search/par/frontier.cpp | 399 ++++++++++++++++++++++++++ gecode/search/par/frontier.hh | 175 +++++++++++ gecode/search/seq/astar.hh | 95 ++++++ gecode/search/seq/cabs.hh | 105 +++++++ gecode/search/seq/choice-expansion.hh | 144 ++++++++++ test/search.cpp | 268 +++++++++++++++++ 23 files changed, 2593 insertions(+), 10 deletions(-) create mode 100644 gecode/search/astar.cpp create mode 100644 gecode/search/astar.hpp create mode 100644 gecode/search/cabs.cpp create mode 100644 gecode/search/cabs.hpp create mode 100644 gecode/search/heuristic.hh create mode 100644 gecode/search/par/frontier.cpp create mode 100644 gecode/search/par/frontier.hh create mode 100644 gecode/search/seq/astar.hh create mode 100644 gecode/search/seq/cabs.hh create mode 100644 gecode/search/seq/choice-expansion.hh diff --git a/Makefile.in b/Makefile.in index 75ab6033ca..656c1ac39a 100755 --- a/Makefile.in +++ b/Makefile.in @@ -269,19 +269,25 @@ SEARCHSRC0 = \ cpprofiler/tracer worker-control SEARCHHDR0 = \ statistics.hpp stop.hpp options.hpp cutoff.hpp \ - support.hh worker.hh worker-control.hh exception.hpp engine.hpp base.hpp \ + support.hh heuristic.hh \ + worker.hh worker-control.hh exception.hpp engine.hpp base.hpp \ nogoods.hh nogoods.hpp build.hpp traits.hpp sebs.hpp \ seq/path.hh seq/path.hpp seq/dfs.hh seq/dfs.hpp \ - seq/bab.hh seq/bab.hpp seq/lds.hh seq/lds.hpp \ + seq/bab.hh seq/bab.hpp seq/astar.hh seq/cabs.hh seq/choice-expansion.hh \ + seq/lds.hh seq/lds.hpp \ seq/rbs.hh seq/rbs.hpp seq/dead.hh \ seq/pbs.hh seq/pbs.hpp \ par/path.hh par/path.hpp par/engine.hh par/engine.hpp \ par/dfs.hh par/dfs.hpp par/bab.hh par/bab.hpp \ - par/pbs.hh par/pbs.hpp \ - dfs.hpp bab.hpp lds.hpp rbs.hpp pbs.hpp \ + par/pbs.hh par/pbs.hpp par/frontier.hh \ + dfs.hpp bab.hpp astar.hpp cabs.hpp lds.hpp rbs.hpp pbs.hpp \ relax.hh tracer.hpp trace-recorder.hpp \ cpprofiler/message.hpp cpprofiler/connector.hpp +ifeq "@enable_experimental@" "yes" +SEARCHSRC0 += astar cabs par/frontier +endif + SEARCHSRC = $(SEARCHSRC0:%=gecode/search/%.cpp) SEARCHHDR = gecode/search.hh $(SEARCHHDR0:%=gecode/search/%) SEARCHOBJ = $(SEARCHSRC:%.cpp=%$(OBJSUFFIX)) diff --git a/changelog.in b/changelog.in index 92fc91efdc..8dc0eaefb3 100755 --- a/changelog.in +++ b/changelog.in @@ -118,6 +118,21 @@ Rank: minor Honor propagation annotations in cumulative and unary scheduling bindings, including the unary specializations of cumulative constraints. +[ENTRY] +Module: search +What: new +Rank: major +Experimental: true +[DESCRIPTION] +Add experimental A* (AStar) and complete anytime beam search (CABS). +[MORE] +Both engines take an objective bound and an optional rank from +Search::Options::guidance. They support parallel search, dynamic worker +control, and portfolios with BAB. CABS also supports model-defined dominance. +Enable with --enable-experimental or GECODE_ENABLE_EXPERIMENTAL=ON; the +preview is disabled by default and its APIs may change in future releases. +The fifteen-puzzle and tsptw examples demonstrate both engines. + [ENTRY] Module: int What: new diff --git a/cmake/GecodeSources.cmake b/cmake/GecodeSources.cmake index be1a613b08..a74ecadedd 100644 --- a/cmake/GecodeSources.cmake +++ b/cmake/GecodeSources.cmake @@ -51,6 +51,14 @@ set(GECODE_SEARCH_SOURCES gecode/search/worker-control.cpp ) +if(GECODE_ENABLE_EXPERIMENTAL) + list(APPEND GECODE_SEARCH_SOURCES + gecode/search/astar.cpp + gecode/search/cabs.cpp + gecode/search/par/frontier.cpp + ) +endif() + set(GECODE_INT_SOURCES gecode/int/arithmetic.cpp gecode/int/arithmetic/mult.cpp diff --git a/docs/worker-control.md b/docs/worker-control.md index a1a0f26623..f6b3188d98 100644 --- a/docs/worker-control.md +++ b/docs/worker-control.md @@ -3,7 +3,8 @@ ## Purpose `Search::WorkerControl` lets an external scheduler change how many workers a -running DFS or BAB engine may use. A portfolio with a fixed thread budget can +running DFS or BAB engine may use. Experimental AStar and CABS engines also +support worker control. A portfolio with a fixed thread budget can move workers among its engines at runtime. The engine still starts with a fixed maximum: @@ -26,8 +27,8 @@ control.request(1); // Resume Gecode resolves the `threads` option when it constructs the engine. The result is the fixed worker capacity. A request must be between zero and that capacity, inclusive. Zero pauses the engine without discarding its search state. -This also applies when the resolved capacity is one: sequential DFS and BAB -wait at search boundaries until the control resumes them. +This also applies when the resolved capacity is one: sequential DFS, BAB, +AStar, and CABS wait at search boundaries until the control resumes them. Pausing requires a build with thread support. Without thread support, attaching an initially paused control or calling `request(0)` raises `Search::InvalidWorkerRequest`; controls must be used from one thread. @@ -80,8 +81,9 @@ An empty default-constructed handle means that worker adjustment is disabled. Calling `request` on an empty handle raises `Search::UninitializedWorkerControl`. -A shared identity can be bound to only one leaf engine at a time. It cannot be -shared by two DFS/BAB engines or attached directly to an enclosing meta-engine. +A shared identity can be bound to only one leaf engine at a time (DFS, BAB, +AStar, or CABS). It cannot be shared by two engines or attached directly to an +enclosing meta-engine. Ordinary reuse for a replacement engine after destruction is also rejected. These uses raise `Search::WorkerControlInUse`. diff --git a/gecode/driver.hh b/gecode/driver.hh index 409968d979..75733bd80b 100755 --- a/gecode/driver.hh +++ b/gecode/driver.hh @@ -446,6 +446,10 @@ namespace Gecode { Driver::UnsignedIntOption _nogoods_limit; ///< Limit for no-good extraction Driver::DoubleOption _relax; ///< Probability to relax variable Driver::BoolOption _interrupt; ///< Whether to catch SIGINT +#ifdef GECODE_HAS_EXPERIMENTAL + /// Guidance for heuristic search + const Search::HeuristicGuidance* _guidance; +#endif //@} /// \name Execution options @@ -668,6 +672,16 @@ namespace Gecode { #endif //@} +#ifdef GECODE_HAS_EXPERIMENTAL + /// \name Heuristic search options + //@{ + /// Set guidance for heuristic search (not copied) + void guidance(const Search::HeuristicGuidance* g); + /// Return guidance for heuristic search + const Search::HeuristicGuidance* guidance(void) const; + //@} +#endif + #ifdef GECODE_HAS_GIST /// Helper class storing Gist inspectors class I_ { diff --git a/gecode/driver/options.cpp b/gecode/driver/options.cpp index 5c6c1fbf87..4d0d60b636 100755 --- a/gecode/driver/options.cpp +++ b/gecode/driver/options.cpp @@ -650,6 +650,9 @@ namespace Gecode { _interrupt("interrupt","whether to catch Ctrl-C (true) or not (false)", true), +#ifdef GECODE_HAS_EXPERIMENTAL + _guidance(nullptr), +#endif _mode("mode","how to execute script",SM_SOLUTION), _samples("samples","how many samples (time mode)",1), _iterations("iterations","iterations per sample (time mode)",1), diff --git a/gecode/driver/options.hpp b/gecode/driver/options.hpp index 2aa4c337c4..d87aeee3fc 100755 --- a/gecode/driver/options.hpp +++ b/gecode/driver/options.hpp @@ -326,6 +326,17 @@ namespace Gecode { return _threads.value(); } +#ifdef GECODE_HAS_EXPERIMENTAL + inline void + Options::guidance(const Search::HeuristicGuidance* g) { + _guidance = g; + } + inline const Search::HeuristicGuidance* + Options::guidance(void) const { + return _guidance; + } +#endif + inline void Options::c_d(unsigned int d) { _c_d.value(d); diff --git a/gecode/driver/script.hpp b/gecode/driver/script.hpp index ccbc287af0..1c8f707088 100755 --- a/gecode/driver/script.hpp +++ b/gecode/driver/script.hpp @@ -388,6 +388,9 @@ namespace Gecode { namespace Driver { so.stop = CombinedStop::create(o.node(),o.fail(), o.time(), o.restart_limit(), o.interrupt()); so.cutoff = createCutoff(o); +#ifdef GECODE_HAS_EXPERIMENTAL + so.guidance = o.guidance(); +#endif so.clone = false; so.nogoods_limit = o.nogoods() ? o.nogoods_limit() : 0U; if (o.interrupt()) @@ -491,6 +494,9 @@ namespace Gecode { namespace Driver { so.stop = CombinedStop::create(o.node(),o.fail(), o.time(), o.restart_limit(), o.interrupt()); so.cutoff = createCutoff(o); +#ifdef GECODE_HAS_EXPERIMENTAL + so.guidance = o.guidance(); +#endif so.nogoods_limit = o.nogoods() ? o.nogoods_limit() : 0U; if (o.interrupt()) CombinedStop::installCtrlHandler(true); @@ -553,6 +559,9 @@ namespace Gecode { namespace Driver { sok.stop = CombinedStop::create(o.node(),o.fail(), o.time(), o.restart_limit(), false); sok.cutoff = createCutoff(o); +#ifdef GECODE_HAS_EXPERIMENTAL + sok.guidance = o.guidance(); +#endif sok.nogoods_limit = o.nogoods() ? o.nogoods_limit() : 0U; { Meta e(s1,sok); diff --git a/gecode/search.hh b/gecode/search.hh index 13dfd9eec3..25a1357c27 100755 --- a/gecode/search.hh +++ b/gecode/search.hh @@ -47,6 +47,10 @@ #include #include #include +#ifdef GECODE_HAS_EXPERIMENTAL +#include +#include +#endif #include @@ -749,6 +753,9 @@ namespace Gecode { namespace Search { }; class Stop; +#ifdef GECODE_HAS_EXPERIMENTAL + class HeuristicGuidance; +#endif /** * \brief %Search engine options @@ -818,6 +825,14 @@ namespace Gecode { namespace Search { Cutoff* cutoff; /// Tracer object for tracing search SearchTracer* tracer; +#ifdef GECODE_HAS_EXPERIMENTAL + /// Guidance for heuristic search (AStar and CABS, not copied) + const HeuristicGuidance* guidance; + /// Maximal beam width for CABS (zero for no limit) + unsigned long int max_width; + /// Maximal number of stored nodes for CABS (zero for no limit) + unsigned long int frontier_limit; +#endif /// Default options GECODE_SEARCH_EXPORT static const Options def; /// Initialize with default values @@ -1152,6 +1167,128 @@ namespace Gecode { #include +#ifdef GECODE_HAS_EXPERIMENTAL +namespace Gecode { namespace Search { + + /** + * \brief Guidance for heuristic search engines (experimental) + * + * The bound is an optimistic objective value for a stable space: a lower + * bound when minimizing, an upper bound when maximizing, and the exact + * objective for a solution. It must agree with the model's constrain + * function. The optional rank orders nodes (lower first) but is never + * used for pruning. + * + * The optional dominance predicate returns whether every completion of + * the second space is matched by a completion of the first that is not + * worse. It must be transitive. CABS uses it to prune nodes in the same + * layer. If a dominance key is given, only nodes with equal keys are + * compared. + * + * Callbacks must only read the space passed to them. With several threads + * they can run concurrently. + * \ingroup TaskModelSearch + */ + class HeuristicGuidance { + public: + /// Objective direction + enum Sense { MINIMIZE, MAXIMIZE }; + /// Function evaluating a stable space + typedef std::function Evaluate; + /// Objective direction + Sense sense; + /// Optimistic objective bound (required) + Evaluate bound; + /// Node rank (optional, lower first) + Evaluate rank; + /// Whether the first space dominates the second (optional) + std::function dominates; + /// Dominance key (optional) + std::function dominance_key; + /// Initialize guidance + HeuristicGuidance(Sense s, Evaluate b, Evaluate r=Evaluate()) + : sense(s), bound(std::move(b)), rank(std::move(r)) {} + }; + +}} + +namespace Gecode { + + /** + * \brief A* search engine (experimental) + * + * Expands nodes in order of their bound, then their rank, using the + * guidance from Search::Options::guidance. Like BAB, next() returns + * improving solutions and exhausting the search proves the last + * solution optimal. + * + * All open nodes are stored as clones, so c_d and a_d are ignored. + * Tracing is not supported. + * \ingroup TaskModelSearch + */ + template + class AStar : public Search::Base { + public: + /// Initialize engine for space \a s and options \a o + AStar(T* s, const Search::Options& o=Search::Options::def); + /// Whether engine does best solution search + static const bool best = true; + }; + + /// Perform A* search for subclass \a T of space \a s and options \a o + template + T* astar(T* s, const Search::Options& o=Search::Options::def); + + /// Return an A* search engine builder + template + SEB astar(const Search::Options& o=Search::Options::def); + +} + +#include + +namespace Gecode { + + /** + * \brief Complete anytime beam search engine (experimental) + * + * Explores the tree layer by layer and keeps the best nodes of each + * layer, ordered by rank or, without rank, by bound. The first pass + * keeps one node per layer. If a pass discarded nodes that could + * improve the incumbent, the search restarts with twice the width. + * The guidance is taken from Search::Options::guidance. Like BAB, + * next() returns improving solutions and exhausting the search proves + * the last solution optimal. + * + * Search stops when the width would exceed Search::Options::max_width or + * the stored nodes would exceed Search::Options::frontier_limit. Nodes are + * stored as clones, so c_d and a_d are ignored. Tracing is not supported. + * Statistics::restart counts the restarts. + * \ingroup TaskModelSearch + */ + template + class CABS : public Search::Base { + public: + /// Initialize engine for space \a s and options \a o + CABS(T* s, const Search::Options& o=Search::Options::def); + /// Whether engine does best solution search + static const bool best = true; + }; + + /// Perform CABS search for subclass \a T of space \a s and options \a o + template + T* cabs(T* s, const Search::Options& o=Search::Options::def); + + /// Return a CABS search engine builder + template + SEB cabs(const Search::Options& o=Search::Options::def); + +} + +#include + +#endif + namespace Gecode { /** @@ -1349,6 +1486,11 @@ namespace Gecode { * * The outer options must not contain a worker control. Each builder can * instead supply a distinct control for its underlying engine. + * In parallel PBS, the outer thread count limits how many builders run, + * in array order; each builder's options set its own engine's thread count. + * + * With experimental search, AStar and CABS builders can be used + * together with BAB builders. */ PBS(T* s, SEBs& sebs, const Search::Options& o=Search::Options::def); /// Constrain future portfolio solutions to be better than \a b diff --git a/gecode/search/astar.cpp b/gecode/search/astar.cpp new file mode 100644 index 0000000000..9ed5eeab81 --- /dev/null +++ b/gecode/search/astar.cpp @@ -0,0 +1,247 @@ +/* -*- mode: C++; c-basic-offset: 2; indent-tabs-mode: nil -*- */ +/* + * Main authors: + * Mikael Zayenz Lagerkvist + * + * Copyright: + * Mikael Zayenz Lagerkvist, 2026 + * + * This file is part of Gecode, the generic constraint + * development environment: + * http://www.gecode.dev + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the + * "Software"), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to + * the following conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * + */ + + +#include +#include +#include +#include + +#ifdef GECODE_HAS_EXPERIMENTAL + +#ifdef GECODE_HAS_THREADS +namespace Gecode { namespace Search { namespace Par { + + /// Parallel A* search + class AStar : public Frontier { + protected: + /// Open nodes + Queue queue; + bool open(void) const override { + return !queue.empty(); + } + Entry select(void) override { + return queue.pop(); + } + void submit(const Entry& e, bool) override { + if (!best.improves(e.bound)) { + stat.fail++; + delete e.space; + return; + } + Entry f = e; + f.order = order++; + queue.push(f); + } + bool advance(void) override { + return false; + } + void clear(void) override { + queue.clear(); + } + public: + /// Initialize for space \a s with options \a o + AStar(Space* s, const Options& o) + : Frontier(o), queue(Priority{guidance.sense,false}) { + initialize(s,o.clone); + } + /// Destructor + ~AStar(void) override { + terminate(); + } + }; + +}}} +#endif + +namespace Gecode { namespace Search { + + Engine* + astarengine(Space* s, const Options& o) { + Options e = o.expand(); + if (e.tracer != nullptr) + throw UnsupportedTracer("AStar::AStar"); + if ((e.guidance == nullptr) || !e.guidance->bound) + throw UninitializedGuidance("AStar::AStar"); +#ifdef GECODE_HAS_THREADS + if (e.threads > 1.0) + return new Par::AStar(s,e); +#endif + return new WorkerToEngine(s,e); + } + +}} + +namespace Gecode { namespace Search { namespace Seq { + + AStar::AStar(Space* s, const Options& o) + : opt(o), guidance(*o.guidance), queue(Priority{guidance.sense,false}), + order(0), status(SS_FAILED), depth(0), revision(0), counted(false), + best(guidance.sense) { + initialize(s,o.clone); + } + + void + AStar::initialize(Space* s, bool clone) { + if (s == nullptr) { + fail++; + return; + } + status = s->status(*this); + if (status == SS_FAILED) { + fail++; + if (!clone) + delete s; + return; + } + expansion.select(std::unique_ptr(clone ? s->clone() : s)); + } + + void + AStar::enqueue(std::unique_ptr s, unsigned long int d) { + std::int64_t b = guidance.bound(*s); + if (!best.improves(b)) { + fail++; + return; + } + std::int64_t r = guidance.rank ? guidance.rank(*s) : 0; + queue.push({s.release(),b,r,0,d,order++,best.revision}); + } + + Space* + AStar::publish(std::unique_ptr s) { + // Delete exhausted branchers before cloning the solution + (void) s->choice(); + std::int64_t b = guidance.bound(*s); + if (!best.improves(b)) + return nullptr; + Space* copy = s->clone(); + best.update(s.release(),b); + return copy; + } + + Space* + AStar::next(void) { + start(); + while (true) { + if (stop_seq(opt)) + return nullptr; + if (!expansion) { + if (queue.empty()) + return nullptr; + Entry e = queue.pop(); + std::unique_ptr s(e.space); + if (!best.improves(e.bound)) { + fail++; + continue; + } + expansion.select(std::move(s)); + depth = e.depth; + revision = e.revision; + counted = true; + } + if (!counted) { + // The root + node++; + stack_depth(depth); + counted = true; + if (status == SS_SOLVED) { + if (Space* s = publish(expansion.take())) + return s; + continue; + } + if (!best.improves(guidance.bound(expansion.space()))) { + fail++; + expansion.reset(); + continue; + } + } + ExpansionStep step = expansion.next(*this,depth+1,revision, + best.space.get(),best.revision); + if (step.state == ExpansionStep::EXHAUSTED) + continue; + switch (step.status) { + case SS_FAILED: + fail++; + break; + case SS_SOLVED: + if (Space* s = publish(std::move(step.space))) + return s; + break; + case SS_BRANCH: + enqueue(std::move(step.space),depth+1); + break; + default: + GECODE_NEVER; + } + } + } + + Statistics + AStar::statistics(void) const { + return *this; + } + + void + AStar::constrain(const Space& s) { + std::int64_t b = guidance.bound(s); + if (best.improves(b)) + best.update(s.clone(),b); + } + + void + AStar::reset(Space* s) { + std::unique_ptr replacement(s); + expansion.reset(); + queue.clear(); + best.reset(); + order = 0; + revision = 0; + depth = 0; + counted = false; + Worker::reset(); + initialize(s,false); + // The root is owned by the engine after initialize returns + (void) replacement.release(); + } + + NoGoods& + AStar::nogoods(void) { + return NoGoods::eng; + } + +}}} + +#endif + +// STATISTICS: search-other diff --git a/gecode/search/astar.hpp b/gecode/search/astar.hpp new file mode 100644 index 0000000000..544e0c6759 --- /dev/null +++ b/gecode/search/astar.hpp @@ -0,0 +1,93 @@ +/* -*- mode: C++; c-basic-offset: 2; indent-tabs-mode: nil -*- */ +/* + * Main authors: + * Mikael Zayenz Lagerkvist + * + * Copyright: + * Mikael Zayenz Lagerkvist, 2026 + * + * This file is part of Gecode, the generic constraint + * development environment: + * http://www.gecode.dev + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the + * "Software"), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to + * the following conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * + */ + +#ifdef GECODE_HAS_EXPERIMENTAL +namespace Gecode { namespace Search { + + /// Create A* engine + GECODE_SEARCH_EXPORT Engine* + astarengine(Space* s, const Options& o); + + /// An A* engine builder + template + class AStarBuilder : public Builder { + using Builder::opt; + public: + /// The constructor + AStarBuilder(const Options& opt); + /// The actual build function + virtual Engine* operator() (Space* s) const; + }; + + template + inline + AStarBuilder::AStarBuilder(const Options& opt) + : Builder(opt,AStar::best) {} + + template + Engine* + AStarBuilder::operator() (Space* s) const { + return build(s,opt); + } + +}} + +namespace Gecode { + + template + inline + AStar::AStar(T* s, const Search::Options& o) + : Search::Base(Search::astarengine(s,o)) {} + + template + inline T* + astar(T* s, const Search::Options& o) { + AStar a(s,o); + T* l = nullptr; + while (T* n = a.next()) { + delete l; l = n; + } + return l; + } + + template + SEB + astar(const Search::Options& o) { + return new Search::AStarBuilder(o); + } + +} + +#endif + +// STATISTICS: search-other diff --git a/gecode/search/cabs.cpp b/gecode/search/cabs.cpp new file mode 100644 index 0000000000..009973a3e1 --- /dev/null +++ b/gecode/search/cabs.cpp @@ -0,0 +1,331 @@ +/* -*- mode: C++; c-basic-offset: 2; indent-tabs-mode: nil -*- */ +/* + * Main authors: + * Mikael Zayenz Lagerkvist + * + * Copyright: + * Mikael Zayenz Lagerkvist, 2026 + * + * This file is part of Gecode, the generic constraint + * development environment: + * http://www.gecode.dev + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the + * "Software"), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to + * the following conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * + */ + + +#include +#include +#include +#include + +#ifdef GECODE_HAS_EXPERIMENTAL + +#ifdef GECODE_HAS_THREADS +namespace Gecode { namespace Search { namespace Par { + + /// Parallel complete anytime beam search + class CABS : public Frontier { + protected: + /// Order of nodes in a layer + Priority priority; + /// Root for restarting with a wider beam + std::unique_ptr root; + /// Nodes of the current layer, preferred node on top + Entries layer; + /// Candidates for the next layer + Beam candidates; + bool open(void) const override { + return !layer.empty(); + } + Entry select(void) override { + return layer.pop(); + } + void submit(const Entry& e, bool leased) override { + if (!best.improves(e.bound)) { + stat.fail++; + delete e.space; + return; + } + Entry f = e; + f.order = order++; + // Every other worker holds a parent and at most one child + unsigned long int stored = layer.entries() + candidates.size() + + n_pending + 2*(active-1) + (leased ? 1 : 0); + bool room = (opt.frontier_limit == 0UL) || (stored < opt.frontier_limit); + if (candidates.offer(f,guidance,room) == Beam::FULL) { + delete f.space; + limited = _stopped = true; + running = false; + } + } + bool advance(void) override { + if (!candidates.empty()) { + candidates.release(layer); + layer.sort(priority); + return true; + } + if (!candidates.improving(best)) + return false; + if (!candidates.widen(opt.max_width)) { + limited = _stopped = true; + return false; + } + stat.restart++; + std::unique_ptr t(new Task); + t->expansion.select(std::unique_ptr(root->clone())); + push(t.release()); + return true; + } + void clear(void) override { + layer.clear(); + candidates.clear(); + root.reset(); + } + void started(const Space& r) override { + root.reset(r.clone()); + } + public: + /// Initialize for space \a s with options \a o + CABS(Space* s, const Options& o) + : Frontier(o), priority{guidance.sense,static_cast(guidance.rank)}, + candidates(priority,guidance.dominates && guidance.dominance_key) { + batch = (o.frontier_limit == 0UL) ? max_batch : 1; + keyed = guidance.dominates && guidance.dominance_key; + initialize(s,o.clone); + } + /// Destructor + ~CABS(void) override { + terminate(); + } + }; + +}}} +#endif + +namespace Gecode { namespace Search { + + Engine* + cabsengine(Space* s, const Options& o) { + Options e = o.expand(); + if (e.tracer != nullptr) + throw UnsupportedTracer("CABS::CABS"); + if ((e.guidance == nullptr) || !e.guidance->bound) + throw UninitializedGuidance("CABS::CABS"); +#ifdef GECODE_HAS_THREADS + if (e.threads > 1.0) + return new Par::CABS(s,e); +#endif + return new WorkerToEngine(s,e); + } + +}} + +namespace Gecode { namespace Search { namespace Seq { + + CABS::CABS(Space* s, const Options& o) + : opt(o), guidance(*o.guidance), + priority{guidance.sense,static_cast(guidance.rank)}, + candidates(priority,guidance.dominates && guidance.dominance_key), + order(0), status(SS_FAILED), depth(0), revision(0), counted(false), + best(guidance.sense), limited(false) { + initialize(s,o.clone); + } + + void + CABS::initialize(Space* s, bool clone) { + if (s == nullptr) { + fail++; + return; + } + status = s->status(*this); + if (status == SS_FAILED) { + fail++; + if (!clone) + delete s; + return; + } + // The restart root and the first node are clones of the stable root + std::unique_ptr r(clone ? s->clone() : nullptr); + expansion.select(std::unique_ptr((clone ? r.get() : s)->clone())); + root.reset(clone ? r.release() : s); + } + + bool + CABS::admit(std::unique_ptr s, unsigned long int d) { + std::int64_t b = guidance.bound(*s); + if (!best.improves(b)) { + fail++; + return true; + } + std::int64_t r = guidance.rank ? guidance.rank(*s) : 0; + std::size_t k = (guidance.dominates && guidance.dominance_key) ? + guidance.dominance_key(*s) : 0; + unsigned long int stored = layer.entries() + candidates.size() + + (expansion ? 1 : 0); + bool room = (opt.frontier_limit == 0UL) || (stored < opt.frontier_limit); + Entry e{s.get(),b,r,k,d,order++,best.revision}; + if (candidates.offer(e,guidance,room) == Beam::FULL) { + limited = _stopped = true; + return false; + } + (void) s.release(); + return true; + } + + Space* + CABS::publish(std::unique_ptr s) { + // Delete exhausted branchers before cloning the solution + (void) s->choice(); + std::int64_t b = guidance.bound(*s); + if (!best.improves(b)) + return nullptr; + Space* copy = s->clone(); + best.update(s.release(),b); + return copy; + } + + bool + CABS::advance(void) { + if (!candidates.empty()) { + candidates.release(layer); + layer.sort(priority); + return true; + } + if (!candidates.improving(best)) + return false; + if (!candidates.widen(opt.max_width)) { + limited = _stopped = true; + return false; + } + restart++; + expansion.select(std::unique_ptr(root->clone())); + status = SS_BRANCH; + depth = 0; + revision = 0; + counted = false; + return true; + } + + Space* + CABS::next(void) { + if (limited) { + _stopped = true; + return nullptr; + } + start(); + while (true) { + if (stop_seq(opt)) + return nullptr; + if (!expansion) { + if (layer.empty() && !advance()) + return nullptr; + if (!expansion) { + Entry e = layer.pop(); + std::unique_ptr s(e.space); + if (!best.improves(e.bound)) { + fail++; + continue; + } + expansion.select(std::move(s)); + depth = e.depth; + revision = e.revision; + counted = true; + } + } + if (!counted) { + // The root of a pass + node++; + stack_depth(depth); + counted = true; + if (status == SS_SOLVED) { + if (Space* s = publish(expansion.take())) + return s; + continue; + } + if (!best.improves(guidance.bound(expansion.space()))) { + fail++; + expansion.reset(); + continue; + } + } + ExpansionStep step = expansion.next(*this,depth+1,revision, + best.space.get(),best.revision); + if (step.state == ExpansionStep::EXHAUSTED) + continue; + switch (step.status) { + case SS_FAILED: + fail++; + break; + case SS_SOLVED: + if (Space* s = publish(std::move(step.space))) + return s; + break; + case SS_BRANCH: + if (!admit(std::move(step.space),depth+1)) + return nullptr; + break; + default: + GECODE_NEVER; + } + } + } + + Statistics + CABS::statistics(void) const { + return *this; + } + + void + CABS::constrain(const Space& s) { + std::int64_t b = guidance.bound(s); + if (best.improves(b)) + best.update(s.clone(),b); + } + + void + CABS::reset(Space* s) { + std::unique_ptr replacement(s); + expansion.reset(); + layer.clear(); + candidates.clear(); + root.reset(); + best.reset(); + order = 0; + revision = 0; + depth = 0; + counted = limited = false; + Worker::reset(); + initialize(s,false); + // The root is owned by the engine after initialize returns + (void) replacement.release(); + } + + NoGoods& + CABS::nogoods(void) { + return NoGoods::eng; + } + +}}} + +#endif + +// STATISTICS: search-other diff --git a/gecode/search/cabs.hpp b/gecode/search/cabs.hpp new file mode 100644 index 0000000000..96f7fd83df --- /dev/null +++ b/gecode/search/cabs.hpp @@ -0,0 +1,93 @@ +/* -*- mode: C++; c-basic-offset: 2; indent-tabs-mode: nil -*- */ +/* + * Main authors: + * Mikael Zayenz Lagerkvist + * + * Copyright: + * Mikael Zayenz Lagerkvist, 2026 + * + * This file is part of Gecode, the generic constraint + * development environment: + * http://www.gecode.dev + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the + * "Software"), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to + * the following conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * + */ + +#ifdef GECODE_HAS_EXPERIMENTAL +namespace Gecode { namespace Search { + + /// Create CABS engine + GECODE_SEARCH_EXPORT Engine* + cabsengine(Space* s, const Options& o); + + /// A CABS engine builder + template + class CABSBuilder : public Builder { + using Builder::opt; + public: + /// The constructor + CABSBuilder(const Options& opt); + /// The actual build function + virtual Engine* operator() (Space* s) const; + }; + + template + inline + CABSBuilder::CABSBuilder(const Options& opt) + : Builder(opt,CABS::best) {} + + template + Engine* + CABSBuilder::operator() (Space* s) const { + return build(s,opt); + } + +}} + +namespace Gecode { + + template + inline + CABS::CABS(T* s, const Search::Options& o) + : Search::Base(Search::cabsengine(s,o)) {} + + template + inline T* + cabs(T* s, const Search::Options& o) { + CABS c(s,o); + T* l = nullptr; + while (T* n = c.next()) { + delete l; l = n; + } + return l; + } + + template + SEB + cabs(const Search::Options& o) { + return new Search::CABSBuilder(o); + } + +} + +#endif + +// STATISTICS: search-other diff --git a/gecode/search/exception.cpp b/gecode/search/exception.cpp index 7b36ff7f01..8d99738996 100644 --- a/gecode/search/exception.cpp +++ b/gecode/search/exception.cpp @@ -34,6 +34,13 @@ #include namespace Gecode { namespace Search { +#ifdef GECODE_HAS_EXPERIMENTAL + UninitializedGuidance::UninitializedGuidance(const char* l) + : Exception(l,"Heuristic guidance is missing") {} + + UnsupportedTracer::UnsupportedTracer(const char* l) + : Exception(l,"Search engine does not support tracing") {} +#endif UninitializedCutoff::UninitializedCutoff(const char* l) : Exception(l,"Cutoff for restart-based search is missing") {} diff --git a/gecode/search/exception.hpp b/gecode/search/exception.hpp index 26a6c0e6e9..e28c3fd9ce 100644 --- a/gecode/search/exception.hpp +++ b/gecode/search/exception.hpp @@ -80,6 +80,21 @@ namespace Gecode { namespace Search { /// Initialize with location \a l WorkerControlInUse(const char* l); }; +#ifdef GECODE_HAS_EXPERIMENTAL + /// %Exception: Missing guidance for heuristic search + class GECODE_SEARCH_EXPORT UninitializedGuidance : public Exception { + public: + /// Initialize with location \a l + UninitializedGuidance(const char* l); + }; + /// %Exception: Search engine does not support tracing + class GECODE_SEARCH_EXPORT UnsupportedTracer : public Exception { + public: + /// Initialize with location \a l + UnsupportedTracer(const char* l); + }; +#endif + //@} }} diff --git a/gecode/search/heuristic.hh b/gecode/search/heuristic.hh new file mode 100644 index 0000000000..87fde7c609 --- /dev/null +++ b/gecode/search/heuristic.hh @@ -0,0 +1,397 @@ +/* -*- mode: C++; c-basic-offset: 2; indent-tabs-mode: nil -*- */ +/* + * Main authors: + * Mikael Zayenz Lagerkvist + * + * Copyright: + * Mikael Zayenz Lagerkvist, 2026 + * + * This file is part of Gecode, the generic constraint + * development environment: + * http://www.gecode.dev + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the + * "Software"), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to + * the following conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * + */ + +#ifndef GECODE_SEARCH_HEURISTIC_HH +#define GECODE_SEARCH_HEURISTIC_HH + +#include +#include +#include +#include +#include + +#ifdef GECODE_HAS_EXPERIMENTAL +namespace Gecode { namespace Search { + + /// Whether bound \a a is better than bound \a b for objective direction \a s + forceinline bool + better(HeuristicGuidance::Sense s, std::int64_t a, std::int64_t b) { + return (s == HeuristicGuidance::MINIMIZE) ? (a < b) : (a > b); + } + + /// Open node of a heuristic search engine + class Entry { + public: + /// The stable space (owned by the container holding the entry) + Space* space; + /// Bound and rank of the space + std::int64_t bound, rank; + /// Dominance key of the space + std::size_t key; + /// Depth of the node + unsigned long int depth; + /// Creation order, for breaking ties + unsigned long long int order; + /// Incumbent revision the space has been constrained with + unsigned long long int revision; + }; + + /// Order of open nodes: whether \a a is preferred to \a b + class Priority { + public: + /// Objective direction + HeuristicGuidance::Sense sense; + /// Whether to order by rank only (CABS with rank) + bool rank_only; + /// Test whether \a a is preferred to \a b + bool operator ()(const Entry& a, const Entry& b) const { + if (!rank_only && (a.bound != b.bound)) + return better(sense,a.bound,b.bound); + if (a.rank != b.rank) + return a.rank < b.rank; + return a.order < b.order; + } + }; + + /// Best solution found so far + class Incumbent { + public: + /// Objective direction + HeuristicGuidance::Sense sense; + /// The solution (if any) + std::unique_ptr space; + /// Its objective value + std::int64_t bound; + /// Incremented for every new incumbent + unsigned long long int revision; + /// Initialize without incumbent + Incumbent(HeuristicGuidance::Sense s) + : sense(s), bound(0), revision(0) {} + /// Whether a node with bound \a b can improve the incumbent + bool improves(std::int64_t b) const { + return !space || better(sense,b,bound); + } + /// Make \a s with objective value \a b the incumbent + void update(Space* s, std::int64_t b) { + space.reset(s); bound = b; revision++; + } + /// Remove incumbent + void reset(void) { + space.reset(); revision = 0; + } + }; + + /// Stack of entries owning their spaces + class Entries : public Support::DynamicStack { + public: + /// Initialize + Entries(void) : Support::DynamicStack(heap) {} + /// Delete all entries + void clear(void) { + while (!empty()) + delete pop().space; + } + /// Release memory + ~Entries(void) { clear(); } + /// Sort such that the preferred entry is on top + void sort(const Priority& p) { + if (entries() > 1) + std::sort(&(*this)[0],&(*this)[0]+entries(), + [&](const Entry& a, const Entry& b) { return p(b,a); }); + } + }; + + /// Priority queue of open nodes for A* + class Queue { + protected: + /// The entries as a binary heap with the preferred entry first + Entries entries; + /// The priority + Priority p; + public: + /// Initialize with priority \a p + Queue(const Priority& p0) : p(p0) {} + /// Whether the queue is empty + bool empty(void) const { return entries.empty(); } + /// Add entry \a e + void push(const Entry& e) { + entries.push(e); + Entry* f = &entries[0]; + std::push_heap(f,f+entries.entries(), + [this](const Entry& a, const Entry& b) { return p(b,a); }); + } + /// Remove and return the preferred entry + Entry pop(void) { + Entry* f = &entries[0]; + std::pop_heap(f,f+entries.entries(), + [this](const Entry& a, const Entry& b) { return p(b,a); }); + return entries.pop(); + } + /// Delete all entries + void clear(void) { entries.clear(); } + }; + + /** + * \brief Candidates for the next layer of CABS + * + * Keeps at most as many entries as the current width, with the least + * preferred one on top. + * Entries are stored in fixed slots so that slots with the same + * dominance key can be chained for dominance checks. + */ + class Beam { + public: + /// Result of offering an entry + enum Offer { + TAKEN, ///< The entry has been added or deleted + FULL ///< The entry needs a new slot but there is no room + }; + private: + /// Slot of an entry + class Slot { + public: + /// The entry + Entry e; + /// Position in heap (-1 for a free slot) + int pos; + /// Next and previous slot in bucket (or next free slot) + int next, prev; + }; + /// The slots + Support::DynamicArray slot; + /// Number of slots used so far and first free slot + int n_slot, free; + /// Heap of slot indices with the least preferred entry first + Support::DynamicArray pq; + /// Number of entries + int n; + /// First slot of each bucket for keyed dominance + Support::DynamicArray bucket; + /// Number of buckets (a power of two, zero if not keyed) + int n_bucket; + /// The priority + Priority p; + /// The current width + unsigned long int _width; + /// Best bound of discarded entries (if any) + std::optional discarded; + + /// Return bucket for key \a k + int index(std::size_t k) const { + return static_cast((k ^ (k >> 16)) & (n_bucket-1)); + } + /// Add slot \a s to its bucket + void link(int s) { + int b = index(slot[s].e.key); + slot[s].prev = -1; slot[s].next = bucket[b]; + if (bucket[b] >= 0) + slot[bucket[b]].prev = s; + bucket[b] = s; + } + /// Remove slot \a s from its bucket + void unlink(int s) { + Slot& x = slot[s]; + if (x.prev >= 0) + slot[x.prev].next = x.next; + else + bucket[index(x.e.key)] = x.next; + if (x.next >= 0) + slot[x.next].prev = x.prev; + } + /// Whether slot \a a must be above slot \a b in the heap + bool above(int a, int b) const { + return p(slot[b].e,slot[a].e); + } + /// Put slot \a s at heap position \a i + void place(int i, int s) { + pq[i] = s; slot[s].pos = i; + } + /// Restore heap order for slot at position \a i + void repair(int i) { + int s = pq[i]; + while ((i > 0) && above(s,pq[(i-1)/2])) { + place(i,pq[(i-1)/2]); i = (i-1)/2; + } + while (2*i+1 < n) { + int c = 2*i+1; + if ((c+1 < n) && above(pq[c+1],pq[c])) + c++; + if (!above(pq[c],s)) + break; + place(i,pq[c]); i = c; + } + place(i,s); + } + /// Add entry \a e + void add(const Entry& e) { + int s; + if (free >= 0) { + s = free; free = slot[s].next; + } else { + s = n_slot++; + } + slot[s].e = e; + if (n_bucket > 0) { + if (n >= n_bucket) { + n_bucket *= 2; + for (int b=0; b 0) + unlink(s); + slot[s].pos = -1; slot[s].next = free; free = s; + if (i < --n) { + place(i,pq[n]); repair(i); + } + } + /// Whether a dominance check between \a e and slot \a s is needed + bool comparable(const Entry& e, int s) const { + return (slot[s].pos >= 0) && ((n_bucket == 0) || (slot[s].e.key == e.key)); + } + /// Return first slot to check for dominance with \a e + int chain_first(const Entry& e) const { + return (n_bucket > 0) ? bucket[index(e.key)] : 0; + } + /// Return slot after \a s to check for dominance + int chain_next(int s) const { + if (n_bucket > 0) + return slot[s].next; + return (s+1 < n_slot) ? s+1 : -1; + } + public: + /// Initialize with priority \a p, using keys for dominance if \a keyed + Beam(const Priority& p0, bool keyed) + : slot(heap), n_slot(0), free(-1), pq(heap), n(0), bucket(heap), + n_bucket(keyed ? 16 : 0), p(p0), _width(1) { + for (int b=0; b ULONG_MAX/2) || ((m > 0UL) && (2*_width > m))) + return false; + _width *= 2; discarded.reset(); + return true; + } + /** + * \brief Offer entry \a e + * + * Deletes \a e if a candidate dominates it or if the beam is full and + * all candidates are preferred. Deletes candidates that \a e dominates + * and the least preferred candidate if \a e replaces it. Returns FULL + * without deleting \a e if \a e needs a new slot and \a room is false. + */ + Offer offer(const Entry& e, const HeuristicGuidance& g, bool room) { + if (g.dominates && (n > 0)) { + for (int s=chain_first(e); s>=0; s=chain_next(s)) + if (comparable(e,s) && g.dominates(*slot[s].e.space,*e.space)) { + delete e.space; + return TAKEN; + } + bool victim = false; + for (int s=chain_first(e); s>=0; ) { + int t = chain_next(s); + if (comparable(e,s) && g.dominates(*e.space,*slot[s].e.space)) { + remove(s); victim = true; + } + s = t; + } + if (victim) { + add(e); + return TAKEN; + } + } + if (static_cast(n) == _width) { + const Entry& worst = slot[pq[0]].e; + bool replace = p(e,worst); + std::int64_t b = replace ? worst.bound : e.bound; + if (!discarded || better(p.sense,b,*discarded)) + discarded = b; + if (replace) { + remove(pq[0]); add(e); + } else { + delete e.space; + } + return TAKEN; + } + if (!room) + return FULL; + add(e); + return TAKEN; + } + /// Move all entries to \a l + void release(Entries& l) { + for (int i=0; i + * + * Copyright: + * Mikael Zayenz Lagerkvist, 2026 + * + * This file is part of Gecode, the generic constraint + * development environment: + * http://www.gecode.dev + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the + * "Software"), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to + * the following conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * + */ + + +#include + +#if defined(GECODE_HAS_EXPERIMENTAL) && defined(GECODE_HAS_THREADS) +namespace Gecode { namespace Search { namespace Par { + + Frontier::Runner::Runner(Frontier& e, unsigned int i) + : Support::Runnable(false), engine(e), id(i) {} + + void + Frontier::Runner::run(void) { + engine.work(id); + } + + Support::Terminator* + Frontier::Runner::terminator(void) const { + return const_cast(this); + } + + void + Frontier::Runner::terminated(void) { + done.signal(); + } + + Frontier::Frontier(const Options& o) + : Search::Engine(o,static_cast(o.threads)), opt(o), + guidance(*o.guidance), best(guidance.sense), order(0), + pending(heap), n_pending(0), runners(nullptr), active(0), + running(false), shutdown(false), _stopped(false), limited(false), + batch(1), keyed(false) {} + + void + Frontier::started(const Space&) {} + + void + Frontier::push(Task* t) { + pending.push(t); n_pending++; + } + + void + Frontier::initialize(Space* s, bool clone) { + if (s == nullptr) { + stat.fail++; + return; + } + if (s->status(stat) == SS_FAILED) { + stat.fail++; + if (!clone) + delete s; + return; + } + started(*s); + std::unique_ptr t(new Task); + t->expansion.select(std::unique_ptr(clone ? s->clone() : s)); + push(t.release()); + } + + void + Frontier::launch(void) { + if (runners != nullptr) + return; + unsigned int n = static_cast(opt.threads); + runners = heap.alloc(n); + for (unsigned int i=0; idone.wait(); + for (unsigned int i=0; i(runners,n); + runners = nullptr; + { Support::Lock lock(mutex); shutdown = false; } + throw; + } + } + + void + Frontier::wake(void) { + if (worker_control.engaged()) { + WorkerControlAccess::signal_all(worker_control); + } else if (runners != nullptr) { + for (unsigned int i=0; i(opt.threads); i++) + runners[i]->wake.signal(); + } + } + + bool + Frontier::enabled(unsigned int id) { + return !worker_control.engaged() || + (id < WorkerControlAccess::requested(worker_control)); + } + + void + Frontier::terminate(void) { + if (runners == nullptr) + return; + { Support::Lock lock(mutex); shutdown = true; running = false; } + wake(); + unsigned int n = static_cast(opt.threads); + for (unsigned int i=0; idone.wait(); + for (unsigned int i=0; i(runners,n); + runners = nullptr; + } + + Frontier::~Frontier(void) { + terminate(); + while (n_pending > 0) { + delete pending.pop(); n_pending--; + } + } + + void + Frontier::publish(Space* s, std::int64_t b) { + if (!best.improves(b)) { + delete s; + return; + } + std::unique_ptr incumbent(s); + solution.reset(s->clone()); + best.update(incumbent.release(),b); + running = false; + } + + void + Frontier::work(unsigned int id) { + // The leased task and the incumbent used for constraining its children + Task* task = nullptr; + std::unique_ptr incumbent; + unsigned long long int revision = 0; + std::optional cutoff; + Worker local; + // Children and solution found since the last submission + Entry children[max_batch]; + unsigned int n_children = 0, submitted = 0; + std::unique_ptr solved; + std::int64_t solved_bound = 0; + while (true) { + try { + bool notify = false; + { + Support::Lock lock(mutex); + bool idle = (n_pending == 0) && !open(); + bool enabled = this->enabled(id); + if (task != nullptr) { + stat += local; + local.reset(); + for (submitted=0; submitted(task->expansion)); + n_children = submitted = 0; + if (solved) + publish(solved.release(),solved_bound); + } + if (running && (opt.stop != nullptr) && opt.stop->stop(stat,opt)) { + _stopped = true; running = false; + } + if (task != nullptr) { + if (!task->expansion || !running || !enabled || shutdown) { + if (task->expansion) + push(task); + else + delete task; + task = nullptr; + active--; + // An enabled worker must check for termination + notify = (active == 0) && running && !enabled; + } + } + if (shutdown) { + incumbent.reset(); + return; + } + while (running && enabled && (task == nullptr)) { + if ((n_pending == 0) && !open() && (active == 0) && !advance()) + running = false; + if (!running || ((n_pending == 0) && !open())) + break; + if (n_pending > 0) { + task = pending.pop(); n_pending--; + } else { + Entry e = select(); + std::unique_ptr s(e.space); + if (!best.improves(e.bound)) { + stat.fail++; + if ((opt.stop != nullptr) && opt.stop->stop(stat,opt)) { + _stopped = true; running = false; + } + continue; + } + task = new Task; + task->expansion.select(std::move(s)); + task->depth = e.depth; + task->revision = e.revision; + task->counted = true; + } + active++; + } + if (task != nullptr) { + if (revision != best.revision) + incumbent.reset(); + if (best.space && (task->revision != best.revision) && !incumbent) + incumbent.reset(best.space->clone()); + revision = best.revision; + cutoff.reset(); + if (best.space) + cutoff = best.bound; + } else { + incumbent.reset(); + revision = 0; + } + notify = notify || (idle && ((n_pending > 0) || open())); + if (!running && (active == 0)) + changed.signal(); + } + if (notify) + wake(); + if (task == nullptr) { + if (worker_control.engaged()) + WorkerControlAccess::wait(worker_control,id); + else + runners[id]->wake.wait(); + continue; + } + if (!task->counted) { + local.node++; + local.stack_depth(task->depth); + task->counted = true; + } + // Limit the work between stop checks + for (unsigned int i=0; (i<32) && task->expansion; i++) { + Seq::ExpansionStep step = + task->expansion.next(local,task->depth+1,task->revision, + incumbent.get(),revision); + if (step.state == Seq::ExpansionStep::EXHAUSTED) + break; + if (step.status != SS_FAILED) { + if (step.status == SS_SOLVED) + (void) step.space->choice(); + std::int64_t b = guidance.bound(*step.space); + if (step.status == SS_SOLVED) { + if (!cutoff || better(guidance.sense,b,*cutoff)) { + solved = std::move(step.space); + solved_bound = b; + break; + } + continue; + } + if ((step.status == SS_BRANCH) && + (!cutoff || better(guidance.sense,b,*cutoff))) { + std::int64_t r = guidance.rank ? guidance.rank(*step.space) : 0; + std::size_t k = keyed ? guidance.dominance_key(*step.space) : 0; + children[n_children++] = + {step.space.release(),b,r,k,task->depth+1,0,revision}; + if (n_children == batch) + break; + continue; + } + } + local.fail++; + if (!enabled(id)) + break; + } + } catch (...) { + Support::Lock lock(mutex); + for (unsigned int i=submitted; i 0)) + continue; + if (error) + std::rethrow_exception(error); + return solution.release(); + } + } + + Search::Statistics + Frontier::statistics(void) const { + Support::Lock lock(mutex); + return stat; + } + + bool + Frontier::stopped(void) const { + Support::Lock lock(mutex); + return _stopped; + } + + void + Frontier::constrain(const Space& s) { + std::int64_t b = guidance.bound(s); + Support::Lock lock(mutex); + if (best.improves(b)) { + best.update(s.clone(),b); + solution.reset(); + } + } + + void + Frontier::reset(Space* s) { + std::unique_ptr replacement(s); + Support::Lock lock(mutex); + while (n_pending > 0) { + delete pending.pop(); n_pending--; + } + clear(); + best.reset(); + solution.reset(); + order = 0; + _stopped = limited = false; + error = nullptr; + stat.reset(); + initialize(s,false); + // The root is owned by the engine after initialize returns + (void) replacement.release(); + } + +}}} +#endif + +// STATISTICS: search-par diff --git a/gecode/search/par/frontier.hh b/gecode/search/par/frontier.hh new file mode 100644 index 0000000000..75dc27245a --- /dev/null +++ b/gecode/search/par/frontier.hh @@ -0,0 +1,175 @@ +/* -*- mode: C++; c-basic-offset: 2; indent-tabs-mode: nil -*- */ +/* + * Main authors: + * Mikael Zayenz Lagerkvist + * + * Copyright: + * Mikael Zayenz Lagerkvist, 2026 + * + * This file is part of Gecode, the generic constraint + * development environment: + * http://www.gecode.dev + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the + * "Software"), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to + * the following conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * + */ + + +#ifndef GECODE_SEARCH_PAR_FRONTIER_HH +#define GECODE_SEARCH_PAR_FRONTIER_HH + +#include +#include +#include +#include + +#if defined(GECODE_HAS_EXPERIMENTAL) && defined(GECODE_HAS_THREADS) +namespace Gecode { namespace Search { namespace Par { + + /** + * \brief Parallel engine for heuristic search + * + * Workers lease a node, expand it outside the lock, and submit its + * children. Subclasses decide how open nodes are stored and selected. + * All members are protected by the mutex. + */ + class Frontier : public Search::Engine { + protected: + /// Node leased by a worker, or partially expanded node + class Task { + public: + /// The node with its remaining alternatives + Seq::ChoiceExpansion expansion; + /// Depth of the node + unsigned long int depth; + /// Incumbent revision the node is constrained with + unsigned long long int revision; + /// Whether the node has been counted + bool counted; + /// Initialize + Task(void) : depth(0), revision(0), counted(false) {} + }; + /// Thread running a worker + class Runner : public Support::Runnable, public Support::Terminator { + /// The engine + Frontier& engine; + /// Worker identifier + unsigned int id; + public: + /// Events for waking up and for termination + Support::Event wake, done; + /// Initialize + Runner(Frontier& e, unsigned int i); + /// Run worker + void run(void) override; + /// Return terminator + Support::Terminator* terminator(void) const override; + /// Signal termination + void terminated(void) override; + }; + /// Search options + Options opt; + /// Guidance + HeuristicGuidance guidance; + /// Best solution so far + Incumbent best; + /// Solution to be returned by next() + std::unique_ptr solution; + /// Number of created entries + unsigned long long int order; + /// Partially expanded nodes + Support::DynamicQueue pending; + /// Number of partially expanded nodes + unsigned int n_pending; + /// The threads (allocated on first next()) + Runner** runners; + /// Mutex protecting all data + mutable Support::Mutex mutex; + /// Signalled when search is idle + Support::Event changed; + /// Statistics + Search::Statistics stat; + /// Number of workers holding a task + unsigned int active; + /// Whether search is running, being shut down, stopped, or limited + bool running, shutdown, _stopped, limited; + /// First exception thrown by a worker + std::exception_ptr error; + + /// \name Open nodes (called with the mutex held) + //@{ + /// Whether there are open nodes + virtual bool open(void) const = 0; + /// Remove and return the next open node + virtual Entry select(void) = 0; + /// Add child \a e, or delete it; \a leased tells whether its parent is still leased + virtual void submit(const Entry& e, bool leased) = 0; + /// Called when all work is done, return whether there is new work + virtual bool advance(void) = 0; + /// Delete all open nodes + virtual void clear(void) = 0; + /// Called with the root \a r when search starts + virtual void started(const Space& r); + //@} + /// Maximal number of children a worker collects before submitting + static const unsigned int max_batch = 8; + /// Number of children a worker collects before submitting + unsigned int batch; + /// Whether to compute dominance keys + bool keyed; + + /// Initialize with options \a o + Frontier(const Options& o); + /// Start search from root \a s + void initialize(Space* s, bool clone); + /// Add pending task \a t + void push(Task* t); + /// Stop all workers (must be called by subclass destructors) + void terminate(void); + /// Make \a s with bound \a b the incumbent, if it improves + void publish(Space* s, std::int64_t b); + /// Start the threads + void launch(void); + /// Wake up all workers + void wake(void); + /// Whether worker \a id is currently enabled + bool enabled(unsigned int id); + /// Run worker \a id + void work(unsigned int id); + public: + /// Destructor + ~Frontier(void) override; + /// Return next better solution (nullptr if none or stopped) + Space* next(void) override; + /// Return statistics + Search::Statistics statistics(void) const override; + /// Whether search has been stopped + bool stopped(void) const override; + /// Constrain future solutions to be better than \a s + void constrain(const Space& s) override; + /// Reset engine to restart at space \a s + void reset(Space* s) override; + }; + +}}} +#endif +#endif + +// STATISTICS: search-par diff --git a/gecode/search/seq/astar.hh b/gecode/search/seq/astar.hh new file mode 100644 index 0000000000..1241d28074 --- /dev/null +++ b/gecode/search/seq/astar.hh @@ -0,0 +1,95 @@ +/* -*- mode: C++; c-basic-offset: 2; indent-tabs-mode: nil -*- */ +/* + * Main authors: + * Mikael Zayenz Lagerkvist + * + * Copyright: + * Mikael Zayenz Lagerkvist, 2026 + * + * This file is part of Gecode, the generic constraint + * development environment: + * http://www.gecode.dev + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the + * "Software"), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to + * the following conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * + */ + + +#ifndef GECODE_SEARCH_SEQ_ASTAR_HH +#define GECODE_SEARCH_SEQ_ASTAR_HH + +#include +#include +#include + +#ifdef GECODE_HAS_EXPERIMENTAL +namespace Gecode { namespace Search { namespace Seq { + + /// Sequential A* search + class AStar : public Worker { + private: + /// Search options + Options opt; + /// Guidance + HeuristicGuidance guidance; + /// Open nodes + Queue queue; + /// Number of created entries + unsigned long long int order; + /// Node being expanded + ChoiceExpansion expansion; + /// Status of the root node before it is counted + SpaceStatus status; + /// Depth of the node being expanded + unsigned long int depth; + /// Incumbent revision the node being expanded is constrained with + unsigned long long int revision; + /// Whether the node being expanded has been counted + bool counted; + /// Best solution so far + Incumbent best; + /// Add node \a s at depth \a d if it can improve the incumbent + void enqueue(std::unique_ptr s, unsigned long int d); + /// Make \a s the incumbent and return a copy, if it improves + Space* publish(std::unique_ptr s); + /// Start search from root \a s + void initialize(Space* s, bool clone); + public: + /// Initialize for space \a s with options \a o + AStar(Space* s, const Options& o); + /// Return next better solution (nullptr if none or stopped) + Space* next(void); + /// Return statistics + Statistics statistics(void) const; + /// Constrain future solutions to be better than \a b + void constrain(const Space& b); + /// Reset engine to restart at space \a s + void reset(Space* s); + /// Return no-goods (none) + NoGoods& nogoods(void); + }; + +}}} + +#endif + +#endif + +// STATISTICS: search-seq diff --git a/gecode/search/seq/cabs.hh b/gecode/search/seq/cabs.hh new file mode 100644 index 0000000000..301ed45822 --- /dev/null +++ b/gecode/search/seq/cabs.hh @@ -0,0 +1,105 @@ +/* -*- mode: C++; c-basic-offset: 2; indent-tabs-mode: nil -*- */ +/* + * Main authors: + * Mikael Zayenz Lagerkvist + * + * Copyright: + * Mikael Zayenz Lagerkvist, 2026 + * + * This file is part of Gecode, the generic constraint + * development environment: + * http://www.gecode.dev + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the + * "Software"), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to + * the following conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * + */ + + +#ifndef GECODE_SEARCH_SEQ_CABS_HH +#define GECODE_SEARCH_SEQ_CABS_HH + +#include +#include +#include + +#ifdef GECODE_HAS_EXPERIMENTAL +namespace Gecode { namespace Search { namespace Seq { + + /// Sequential complete anytime beam search + class CABS : public Worker { + private: + /// Search options + Options opt; + /// Guidance + HeuristicGuidance guidance; + /// Order of nodes in a layer + Priority priority; + /// Root for restarting with a wider beam + std::unique_ptr root; + /// Nodes of the current layer, preferred node on top + Entries layer; + /// Candidates for the next layer + Beam candidates; + /// Number of created entries + unsigned long long int order; + /// Node being expanded + ChoiceExpansion expansion; + /// Status of the root node before it is counted + SpaceStatus status; + /// Depth of the node being expanded + unsigned long int depth; + /// Incumbent revision the node being expanded is constrained with + unsigned long long int revision; + /// Whether the node being expanded has been counted + bool counted; + /// Best solution so far + Incumbent best; + /// Whether search stopped at the width or frontier limit + bool limited; + /// Offer node \a s at depth \a d to the next layer, false if no room + bool admit(std::unique_ptr s, unsigned long int d); + /// Make \a s the incumbent and return a copy, if it improves + Space* publish(std::unique_ptr s); + /// Move to the next layer or pass, false if done + bool advance(void); + /// Start search from root \a s + void initialize(Space* s, bool clone); + public: + /// Initialize for space \a s with options \a o + CABS(Space* s, const Options& o); + /// Return next better solution (nullptr if none or stopped) + Space* next(void); + /// Return statistics + Statistics statistics(void) const; + /// Constrain future solutions to be better than \a b + void constrain(const Space& b); + /// Reset engine to restart at space \a s + void reset(Space* s); + /// Return no-goods (none) + NoGoods& nogoods(void); + }; + +}}} + +#endif + +#endif + +// STATISTICS: search-seq diff --git a/gecode/search/seq/choice-expansion.hh b/gecode/search/seq/choice-expansion.hh new file mode 100644 index 0000000000..150d681344 --- /dev/null +++ b/gecode/search/seq/choice-expansion.hh @@ -0,0 +1,144 @@ +/* -*- mode: C++; c-basic-offset: 2; indent-tabs-mode: nil -*- */ +/* + * Main authors: + * Mikael Zayenz Lagerkvist + * + * Copyright: + * Mikael Zayenz Lagerkvist, 2026 + * + * This file is part of Gecode, the generic constraint + * development environment: + * http://www.gecode.dev + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the + * "Software"), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to + * the following conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * + */ + +#ifndef GECODE_SEARCH_SEQ_CHOICE_EXPANSION_HH +#define GECODE_SEARCH_SEQ_CHOICE_EXPANSION_HH + +#include +#include +#include + +#ifdef GECODE_HAS_EXPERIMENTAL +namespace Gecode { namespace Search { namespace Seq { + + /// Result of expanding one alternative + class ExpansionStep { + public: + /// What the step produced + enum State { + CHILD, ///< A propagated child + TERMINAL, ///< The node itself, as it does not branch + EXHAUSTED ///< Nothing, all alternatives have been expanded + }; + /// The state + State state; + /// The child or node + std::unique_ptr space; + /// Status of the space + SpaceStatus status; + }; + + /// Node whose alternatives are expanded one at a time + class ChoiceExpansion { + private: + /// The node (declared before the choice so the choice is deleted first) + std::unique_ptr parent; + /// The choice of the node + std::unique_ptr choice; + /// Next alternative + unsigned int alternative; + public: + /// Initialize without node + ChoiceExpansion(void) : alternative(0) {} + /// Whether there is a node + explicit operator bool(void) const { return static_cast(parent); } + /// Return the node + Space& space(void) const { return *parent; } + /// Take stable node \a s + void select(std::unique_ptr s) { + assert(!parent && !choice); + parent = std::move(s); + alternative = 0; + } + /// Return the node before its choice has been computed + std::unique_ptr take(void) { + assert(!choice); + return std::move(parent); + } + /// Delete the node + void reset(void) { + choice.reset(); + parent.reset(); + alternative = 0; + } + /** + * \brief Commit and propagate the next alternative + * + * The child is constrained by \a incumbent if the node has been + * constrained with an older revision than \a revision. The last + * alternative reuses the node itself. + */ + ExpansionStep next(Worker& worker, unsigned long int depth, + unsigned long long int node_revision, + const Space* incumbent, + unsigned long long int revision) { + assert(parent); + if (!choice) { + SpaceStatus status = parent->status(worker); + if (status != SS_BRANCH) + return {ExpansionStep::TERMINAL,std::move(parent),status}; + choice.reset(parent->choice()); + alternative = 0; + } + if (alternative == choice->alternatives()) { + reset(); + return {ExpansionStep::EXHAUSTED,nullptr,SS_FAILED}; + } + std::unique_ptr child; + // Keeps the choice alive while committing the last alternative + std::unique_ptr last; + const Choice* c = choice.get(); + if (alternative+1 == choice->alternatives()) { + child = std::move(parent); + last = std::move(choice); + } else { + child.reset(parent->clone()); + } + child->commit(*c,alternative); + if ((incumbent != nullptr) && (node_revision != revision)) + child->constrain(*incumbent); + worker.node++; + worker.stack_depth(depth); + SpaceStatus status = child->status(worker); + alternative++; + return {ExpansionStep::CHILD,std::move(child),status}; + } + }; + +}}} + +#endif + +#endif + +// STATISTICS: search-seq diff --git a/test/search.cpp b/test/search.cpp index 70cf9d8cc0..439f94ff83 100644 --- a/test/search.cpp +++ b/test/search.cpp @@ -44,6 +44,7 @@ #include #include #include +#include #include #include #include @@ -83,6 +84,235 @@ namespace Test { using namespace Gecode; using namespace Gecode::Int; +#ifdef GECODE_HAS_EXPERIMENTAL + /// Explicit finite tree with prefix costs, admissible bounds, and ranks + class TreeSpace : public Space { + public: + /// Tree node + struct Node { + int cost, bound, rank, best; + std::vector children; + }; + typedef std::vector Graph; + std::shared_ptr graph; + bool maximize; + int vertex, ceiling; + /// Brancher choosing a child of the current node + class Decisions : public Brancher { + class Decision : public Choice { + public: + int vertex; + Decision(const Brancher& b, int v, unsigned int n) + : Choice(b,n), vertex(v) {} + void archive(Archive& e) const override { + Choice::archive(e); e << vertex << alternatives(); + } + }; + public: + Decisions(Home home) : Brancher(home) {} + Decisions(Space& home, Decisions& b) : Brancher(home,b) {} + bool status(const Space& s) const override { + return !static_cast(s).node().children.empty(); + } + const Choice* choice(Space& s) override { + const TreeSpace& t = static_cast(s); + return new Decision(*this,t.vertex, + static_cast(t.node().children.size())); + } + const Choice* choice(const Space&, Archive& e) override { + int v; unsigned int n; e >> v >> n; + return new Decision(*this,v,n); + } + ExecStatus commit(Space& s, const Choice& c, unsigned int a) override { + TreeSpace& t = static_cast(s); + t.vertex = (*t.graph)[static_cast(c).vertex].children[a]; + return t.node().cost >= t.ceiling ? ES_FAILED : ES_OK; + } + Actor* copy(Space& home) override { + return new (home) Decisions(home,*this); + } + size_t dispose(Space& home) override { + (void) Brancher::dispose(home); + return sizeof(*this); + } + }; + TreeSpace(std::shared_ptr g, bool max) + : graph(g), maximize(max), vertex(0), + ceiling(std::numeric_limits::max()) { + (void) new (*this) Decisions(*this); + } + TreeSpace(TreeSpace& s) + : Space(s), graph(s.graph), maximize(s.maximize), vertex(s.vertex), + ceiling(s.ceiling) {} + Space* copy(void) override { return new TreeSpace(*this); } + bool master(const MetaInfo&) override { return true; } + const Node& node(void) const { return (*graph)[vertex]; } + void constrain(const Space& s) override { + ceiling = static_cast(s).node().cost; + if (node().cost >= ceiling) fail(); + } + /// Guidance minimizing cost, or maximizing negated cost + static Gecode::Search::HeuristicGuidance + guidance(bool max, bool ranked, bool dominance, bool keyed) { + using Gecode::Search::HeuristicGuidance; + HeuristicGuidance g(max ? HeuristicGuidance::MAXIMIZE : + HeuristicGuidance::MINIMIZE, + [](const Space& s) -> std::int64_t { + const TreeSpace& t = static_cast(s); + return t.maximize ? -t.node().bound : t.node().bound; + }); + if (ranked) + g.rank = [](const Space& s) -> std::int64_t { + return static_cast(s).node().rank; + }; + if (dominance) + g.dominates = [](const Space& a, const Space& b) { + return static_cast(a).node().best <= + static_cast(b).node().best; + }; + if (keyed) + g.dominance_key = [](const Space& s) -> std::size_t { + return static_cast + (static_cast(s).node().best % 3); + }; + return g; + } + /// Return a random tree where all nodes above depth \a d branch + static std::shared_ptr + random(Gecode::Support::RandomGenerator& r, int d=1) { + Graph g; + g.push_back({0,0,0,0,{}}); + grow(g,0,0,d,r); + return std::make_shared(std::move(g)); + } + private: + static void grow(Graph& g, int v, int depth, int d, + Gecode::Support::RandomGenerator& r) { + if ((depth < d) || ((depth < 5) && (r(4U) != 0U))) { + unsigned int n = (depth < d ? 2U : 1U) + r(3U); + for (unsigned int i=0; i(g.size()); + g.push_back({g[v].cost+static_cast(r(5U)),0, + static_cast(r(10U)),0,{}}); + g[v].children.push_back(c); + grow(g,c,depth+1,d,r); + } + } + if (g[v].children.empty()) { + g[v].best = g[v].bound = g[v].cost; + } else { + g[v].best = std::numeric_limits::max(); + for (int c : g[v].children) + g[v].best = std::min(g[v].best,g[c].best); + g[v].bound = g[v].cost + + static_cast(r(static_cast(g[v].best-g[v].cost+1))); + } + } + }; + + /// Stop object raising its node limit on each stop + class Increasing : public Gecode::Search::Stop { + unsigned long long int limit, step; + public: + unsigned int stops; + Increasing(unsigned long long int s) : limit(s), step(s), stops(0) {} + bool stop(const Gecode::Search::Statistics& s, + const Gecode::Search::Options&) override { + if (s.node <= limit) + return false; + limit += step; + stops++; + return true; + } + }; + + /// Check that improving solutions reach the optimum unless search stops + template + static bool improving(Engine& e, const TreeSpace& root, + const Increasing* stop=nullptr) { + int last = std::numeric_limits::max(); + unsigned int stops = 0; + while (true) { + if (TreeSpace* s = e.next()) { + int cost = s->node().cost; + delete s; + if (cost >= last) + return false; + last = cost; + } else if ((stop != nullptr) && (stop->stops != stops)) { + // Resume after the stop object stopped search + stops = stop->stops; + } else { + break; + } + } + return e.stopped() || (last == (*root.graph)[0].best); + } + + /// Heuristic search engines on random trees + template class E> + class Heuristic : public Base { + public: + Heuristic(const std::string& name) + : Base("Search::"+name,TestTags::check()) {} + bool run(void) override { + for (bool max : {false,true}) + for (bool ranked : {false,true}) + for (bool dominance : {false,true}) + for (unsigned int threads : {1U,2U,4U}) + for (unsigned int step : {0U,1U,7U}) { +#ifndef GECODE_HAS_THREADS + if (threads > 1U) continue; +#endif + TreeSpace root(TreeSpace::random(_rand),max); + Gecode::Search::HeuristicGuidance g = + TreeSpace::guidance(max,ranked,dominance,_rand(2U) == 0U); + Increasing stop(step); + Gecode::Search::Options o; + o.threads = threads; + o.guidance = &g; + if (step > 0U) + o.stop = &stop; + if (_rand(3U) == 0U) + o.max_width = 1UL << _rand(3U); + E e(&root,o); + if (!improving(e,root,step > 0U ? &stop : nullptr)) + return false; + if ((o.max_width == 0UL) && e.stopped()) + return false; + } + return true; + } + }; + + Heuristic astar_test("AStar"); + Heuristic cabs_test("CABS"); + + /// Portfolio of BAB, A*, and CABS assets + class HeuristicPortfolio : public Base { + public: + HeuristicPortfolio(void) + : Base("Search::Heuristic::Portfolio",TestTags::check()) {} + bool run(void) override { + TreeSpace root(TreeSpace::random(_rand),false); + Gecode::Search::HeuristicGuidance g = + TreeSpace::guidance(false,true,false,false); + Gecode::Search::Options a; + a.threads = 1; + a.guidance = &g; + SEBs assets({bab(a),astar(a), + cabs(a)}); + Gecode::Search::Options o; +#ifdef GECODE_HAS_THREADS + o.threads = 3; +#endif + PBS e(&root,assets,o); + return improving(e,root); + } + } heuristic_portfolio; +#endif + + /// Values for selecting branchers enum HowToBranch { HTB_NONE, ///< Do not branch @@ -584,11 +814,49 @@ namespace Test { } return ok && !engine.stopped() && (solutions == root.solutions()); } +#ifdef GECODE_HAS_EXPERIMENTAL + template class E> + bool heuristic(unsigned int node, unsigned int threads) { + Gecode::Search::WorkerControl control(node == 0U ? 0U : 1U); + Pause pause(control,node); + TreeSpace root(TreeSpace::random(_rand,4),false); + Gecode::Search::HeuristicGuidance g = + TreeSpace::guidance(false,true,false,false); + Gecode::Search::Options o; + o.threads = threads; + o.worker_control = control; + o.stop = &pause; + o.guidance = &g; + E engine(&root,o); + int last = std::numeric_limits::max(); + auto consume = [&] { + while (TreeSpace* s = engine.next()) { + last = s->node().cost; + delete s; + } + }; + auto result = std::async(std::launch::async,consume); + pause.reached.get_future().wait(); + bool ok = result.wait_for(std::chrono::milliseconds(20)) == + std::future_status::timeout; + control.request(threads); + result.get(); + return ok && !engine.stopped() && (last == (*root.graph)[0].best); + } +#endif + public: WorkerControlSequentialPause(void) : Base("Search::WorkerControl::SequentialPause", TestTags::check()) {} bool run(void) override { #ifdef GECODE_HAS_THREADS +#ifdef GECODE_HAS_EXPERIMENTAL + for (unsigned int node : {0U,1U}) + for (unsigned int threads : {1U,4U}) + if (!heuristic(node,threads) || + !heuristic(node,threads)) + return false; +#endif return check(0U) && check(1U) && check(0U) && check(1U); #else From dacf16bc2c8331f9cfe77051563f7a58b179a164 Mon Sep 17 00:00:00 2001 From: Mikael Zayenz Lagerkvist Date: Wed, 28 Oct 2026 05:29:53 +0100 Subject: [PATCH 4/7] Keep portfolios running when an asset stops at a local limit A CABS asset that reaches its width or node limit cannot continue, but both portfolio engines took its stop for a user stop: the sequential one kept returning without running the other assets, and the parallel one stopped the portfolio while keeping the asset. Search::Engine::limited now tells that an engine stopped at a local limit; portfolios remove such an asset and report that they stopped once all assets are done. Other assets are handled as before. The sequential engine also no longer takes a stop after a finished slice for the end of the slice. Co-Authored-By: Claude Opus 5.5 --- changelog.in | 10 +++++++ gecode/search.hh | 4 +++ gecode/search/cabs.cpp | 36 +++++++++++++++++++------ gecode/search/engine.cpp | 7 +++++ gecode/search/par/frontier.cpp | 12 ++++++--- gecode/search/par/frontier.hh | 4 ++- gecode/search/par/pbs.hh | 4 +++ gecode/search/par/pbs.hpp | 21 ++++++++++++--- gecode/search/seq/cabs.hh | 4 ++- gecode/search/seq/pbs.hh | 4 +++ gecode/search/seq/pbs.hpp | 19 +++++++++++--- test/search.cpp | 48 +++++++++++++++++++++++++++++++++- 12 files changed, 153 insertions(+), 20 deletions(-) diff --git a/changelog.in b/changelog.in index 8dc0eaefb3..7ec371b72d 100755 --- a/changelog.in +++ b/changelog.in @@ -133,6 +133,16 @@ Enable with --enable-experimental or GECODE_ENABLE_EXPERIMENTAL=ON; the preview is disabled by default and its APIs may change in future releases. The fifteen-puzzle and tsptw examples demonstrate both engines. +[ENTRY] +Module: search +What: bug +Rank: minor +[DESCRIPTION] +Fix sequential portfolio search ignoring stop objects after a slice has +ended. In sequential and parallel portfolios, an asset that stops at a local +limit (the width or node limit of CABS) no longer stops the portfolio; it is +removed and the portfolio reports that it has stopped once all assets are done. + [ENTRY] Module: int What: new diff --git a/gecode/search.hh b/gecode/search.hh index 25a1357c27..05b2b9ccec 100755 --- a/gecode/search.hh +++ b/gecode/search.hh @@ -1018,6 +1018,10 @@ namespace Gecode { namespace Search { virtual Statistics statistics(void) const = 0; /// Check whether engine has been stopped virtual bool stopped(void) const = 0; +#ifdef GECODE_HAS_EXPERIMENTAL + /// Whether the engine stopped at a local limit (such as a CABS width limit) and cannot continue + virtual bool limited(void) const; +#endif /// Constrain future solutions to be better than \a b (raises exception) virtual void constrain(const Space& b); /// Reset engine to restart at space \a s (does nothing) diff --git a/gecode/search/cabs.cpp b/gecode/search/cabs.cpp index 009973a3e1..5616a2e202 100644 --- a/gecode/search/cabs.cpp +++ b/gecode/search/cabs.cpp @@ -73,7 +73,7 @@ namespace Gecode { namespace Search { namespace Par { bool room = (opt.frontier_limit == 0UL) || (stored < opt.frontier_limit); if (candidates.offer(f,guidance,room) == Beam::FULL) { delete f.space; - limited = _stopped = true; + _limited = _stopped = true; running = false; } } @@ -86,7 +86,7 @@ namespace Gecode { namespace Search { namespace Par { if (!candidates.improving(best)) return false; if (!candidates.widen(opt.max_width)) { - limited = _stopped = true; + _limited = _stopped = true; return false; } stat.restart++; @@ -121,6 +121,21 @@ namespace Gecode { namespace Search { namespace Par { }}} #endif +namespace Gecode { namespace Search { namespace Seq { + + /// Sequential CABS engine + class CABSEngine : public WorkerToEngine { + public: + /// Initialize for space \a s with options \a o + CABSEngine(Space* s, const Options& o) : WorkerToEngine(s,o) {} + /// Whether search stopped at the width or frontier limit + bool limited(void) const override { + return w.limited(); + } + }; + +}}} + namespace Gecode { namespace Search { Engine* @@ -134,7 +149,7 @@ namespace Gecode { namespace Search { if (e.threads > 1.0) return new Par::CABS(s,e); #endif - return new WorkerToEngine(s,e); + return new Seq::CABSEngine(s,e); } }} @@ -146,7 +161,7 @@ namespace Gecode { namespace Search { namespace Seq { priority{guidance.sense,static_cast(guidance.rank)}, candidates(priority,guidance.dominates && guidance.dominance_key), order(0), status(SS_FAILED), depth(0), revision(0), counted(false), - best(guidance.sense), limited(false) { + best(guidance.sense), _limited(false) { initialize(s,o.clone); } @@ -184,7 +199,7 @@ namespace Gecode { namespace Search { namespace Seq { bool room = (opt.frontier_limit == 0UL) || (stored < opt.frontier_limit); Entry e{s.get(),b,r,k,d,order++,best.revision}; if (candidates.offer(e,guidance,room) == Beam::FULL) { - limited = _stopped = true; + _limited = _stopped = true; return false; } (void) s.release(); @@ -213,7 +228,7 @@ namespace Gecode { namespace Search { namespace Seq { if (!candidates.improving(best)) return false; if (!candidates.widen(opt.max_width)) { - limited = _stopped = true; + _limited = _stopped = true; return false; } restart++; @@ -227,7 +242,7 @@ namespace Gecode { namespace Search { namespace Seq { Space* CABS::next(void) { - if (limited) { + if (_limited) { _stopped = true; return nullptr; } @@ -294,6 +309,11 @@ namespace Gecode { namespace Search { namespace Seq { return *this; } + bool + CABS::limited(void) const { + return _limited; + } + void CABS::constrain(const Space& s) { std::int64_t b = guidance.bound(s); @@ -312,7 +332,7 @@ namespace Gecode { namespace Search { namespace Seq { order = 0; revision = 0; depth = 0; - counted = limited = false; + counted = _limited = false; Worker::reset(); initialize(s,false); // The root is owned by the engine after initialize returns diff --git a/gecode/search/engine.cpp b/gecode/search/engine.cpp index e7551ddcdf..d3b1e978a3 100644 --- a/gecode/search/engine.cpp +++ b/gecode/search/engine.cpp @@ -49,6 +49,13 @@ namespace Gecode { namespace Search { (void) b; throw NoBest("Engine::constrain"); } +#ifdef GECODE_HAS_EXPERIMENTAL + bool + Engine::limited(void) const { + return false; + } + +#endif void Engine::reset(Space* s) { (void) s; diff --git a/gecode/search/par/frontier.cpp b/gecode/search/par/frontier.cpp index f3d1d85791..d6c422117d 100644 --- a/gecode/search/par/frontier.cpp +++ b/gecode/search/par/frontier.cpp @@ -59,7 +59,7 @@ namespace Gecode { namespace Search { namespace Par { : Search::Engine(o,static_cast(o.threads)), opt(o), guidance(*o.guidance), best(guidance.sense), order(0), pending(heap), n_pending(0), runners(nullptr), active(0), - running(false), shutdown(false), _stopped(false), limited(false), + running(false), shutdown(false), _stopped(false), _limited(false), batch(1), keyed(false) {} void @@ -333,7 +333,7 @@ namespace Gecode { namespace Search { namespace Par { Support::Lock lock(mutex); if (error) std::rethrow_exception(error); - if (limited) { + if (_limited) { _stopped = true; return nullptr; } @@ -364,6 +364,12 @@ namespace Gecode { namespace Search { namespace Par { return _stopped; } + bool + Frontier::limited(void) const { + Support::Lock lock(mutex); + return _limited; + } + void Frontier::constrain(const Space& s) { std::int64_t b = guidance.bound(s); @@ -385,7 +391,7 @@ namespace Gecode { namespace Search { namespace Par { best.reset(); solution.reset(); order = 0; - _stopped = limited = false; + _stopped = _limited = false; error = nullptr; stat.reset(); initialize(s,false); diff --git a/gecode/search/par/frontier.hh b/gecode/search/par/frontier.hh index 75dc27245a..a3825097c9 100644 --- a/gecode/search/par/frontier.hh +++ b/gecode/search/par/frontier.hh @@ -109,7 +109,7 @@ namespace Gecode { namespace Search { namespace Par { /// Number of workers holding a task unsigned int active; /// Whether search is running, being shut down, stopped, or limited - bool running, shutdown, _stopped, limited; + bool running, shutdown, _stopped, _limited; /// First exception thrown by a worker std::exception_ptr error; @@ -162,6 +162,8 @@ namespace Gecode { namespace Search { namespace Par { Search::Statistics statistics(void) const override; /// Whether search has been stopped bool stopped(void) const override; + /// Whether search stopped at the width or frontier limit + bool limited(void) const override; /// Constrain future solutions to be better than \a s void constrain(const Space& s) override; /// Reset engine to restart at space \a s diff --git a/gecode/search/par/pbs.hh b/gecode/search/par/pbs.hh index 8db69e5507..40d93e7252 100644 --- a/gecode/search/par/pbs.hh +++ b/gecode/search/par/pbs.hh @@ -101,6 +101,8 @@ namespace Gecode { namespace Search { namespace Par { Statistics statistics(void) const; /// Check whether slave has been stopped bool stopped(void) const; + /// Check whether slave stopped at a local limit + bool limited(void) const; /// Return the completion terminator virtual Support::Terminator* terminator(void) const; /// Wait for the slave to complete its current run @@ -176,6 +178,8 @@ namespace Gecode { namespace Search { namespace Par { unsigned int n_active; /// Whether a slave has been stopped std::atomic slave_stop; + /// Whether a slave stopped at a local limit + bool incomplete; /// Shared stop flag std::atomic tostop; /// Collect solutions in this diff --git a/gecode/search/par/pbs.hpp b/gecode/search/par/pbs.hpp index e4c8f33135..0f7f03e769 100755 --- a/gecode/search/par/pbs.hpp +++ b/gecode/search/par/pbs.hpp @@ -163,6 +163,15 @@ namespace Gecode { namespace Search { namespace Par { return slave->stopped(); } template + forceinline bool + Slave::limited(void) const { +#ifdef GECODE_HAS_EXPERIMENTAL + return slave->limited(); +#else + return false; +#endif + } + template forceinline Support::Terminator* Slave::terminator(void) const { return const_cast(&completion); @@ -196,7 +205,7 @@ namespace Gecode { namespace Search { namespace Par { const Statistics& stat0) : stat(stat0), slaves(heap.alloc*>(n)), n_slaves(n), n_active(n), - slave_stop(false), tostop(false), n_busy(0) { + slave_stop(false), incomplete(false), tostop(false), n_busy(0) { // Initialize slaves for (unsigned int i=0U; i(this,engines[i],stops[i]); @@ -218,14 +227,17 @@ namespace Gecode { namespace Search { namespace Par { tostop.store(true, std::memory_order_release); wake = true; } - } else if (slave->stopped()) { + } else if (slave->stopped() && !slave->limited()) { if (!tostop.load(std::memory_order_acquire)) { slave_stop.store(true, std::memory_order_release); tostop.store(true, std::memory_order_release); wake = true; } } else { - // Move slave to inactive, as it has exhausted its engine + // The slave is done or stopped at a local limit (then search is incomplete) + if (slave->limited()) + incomplete = true; + // Move slave to inactive unsigned int i=0; while (slaves[i] != slave) i++; @@ -287,6 +299,9 @@ namespace Gecode { namespace Search { namespace Par { // Invariant all slaves are idle! assert(n_busy == 0); + if (solutions.empty() && (n_active == 0) && incomplete) + slave_stop.store(true, std::memory_order_release); + Space* s; // Process solutions diff --git a/gecode/search/seq/cabs.hh b/gecode/search/seq/cabs.hh index 301ed45822..f75e9ed056 100644 --- a/gecode/search/seq/cabs.hh +++ b/gecode/search/seq/cabs.hh @@ -72,7 +72,7 @@ namespace Gecode { namespace Search { namespace Seq { /// Best solution so far Incumbent best; /// Whether search stopped at the width or frontier limit - bool limited; + bool _limited; /// Offer node \a s at depth \a d to the next layer, false if no room bool admit(std::unique_ptr s, unsigned long int d); /// Make \a s the incumbent and return a copy, if it improves @@ -88,6 +88,8 @@ namespace Gecode { namespace Search { namespace Seq { Space* next(void); /// Return statistics Statistics statistics(void) const; + /// Whether search stopped at the width or frontier limit + bool limited(void) const; /// Constrain future solutions to be better than \a b void constrain(const Space& b); /// Reset engine to restart at space \a s diff --git a/gecode/search/seq/pbs.hh b/gecode/search/seq/pbs.hh index 6ed977b04a..cd7cf2488f 100644 --- a/gecode/search/seq/pbs.hh +++ b/gecode/search/seq/pbs.hh @@ -85,6 +85,8 @@ namespace Gecode { namespace Search { namespace Seq { Statistics statistics(void) const; /// Check whether slave has been stopped bool stopped(void) const; + /// Check whether slave stopped at a local limit + bool limited(void) const; /// Constrain with better solution \a b void constrain(const Space& b); /// Perform one run @@ -111,6 +113,8 @@ namespace Gecode { namespace Search { namespace Seq { unsigned int cur; /// Whether a slave has been stopped bool slave_stop; + /// Whether a slave stopped at a local limit + bool incomplete; public: /// Initialize PBS(Engine** slaves, Stop** stops, unsigned int n, diff --git a/gecode/search/seq/pbs.hpp b/gecode/search/seq/pbs.hpp index 59d66f43cc..4dc77efef1 100644 --- a/gecode/search/seq/pbs.hpp +++ b/gecode/search/seq/pbs.hpp @@ -64,6 +64,14 @@ namespace Gecode { namespace Search { namespace Seq { Slave::stopped(void) const { return slave->stopped(); } + forceinline bool + Slave::limited(void) const { +#ifdef GECODE_HAS_EXPERIMENTAL + return slave->limited(); +#else + return false; +#endif + } forceinline void Slave::constrain(const Space& b) { slave->constrain(b); @@ -82,7 +90,7 @@ namespace Gecode { namespace Search { namespace Seq { const Search::Options& opt) : stat(stat0), slice(opt.slice), slaves(heap.alloc(n)), n_slaves(n), cur(0), - slave_stop(false) { + slave_stop(false), incomplete(false) { ssi.done = false; ssi.l = opt.slice; @@ -98,6 +106,7 @@ namespace Gecode { namespace Search { namespace Seq { slave_stop = false; unsigned int n_exhausted = 0; while (n_slaves > 0) { + ssi.done = false; if (Space* s = slaves[cur].next()) { // Constrain other slaves if (best) { @@ -108,7 +117,7 @@ namespace Gecode { namespace Search { namespace Seq { } return s; } - if (slaves[cur].stopped()) { + if (slaves[cur].stopped() && !slaves[cur].limited()) { if (ssi.done) { cur++; n_exhausted++; } else { @@ -116,7 +125,10 @@ namespace Gecode { namespace Search { namespace Seq { return nullptr; } } else { - // This slave is done, kill it after saving the statistics + // The slave is done or stopped at a local limit (then search is incomplete) + if (slaves[cur].limited()) + incomplete = true; + // Kill the slave after saving the statistics stat += slaves[cur].statistics(); slaves[cur].~Slave(); slaves[cur] = slaves[--n_slaves]; @@ -132,6 +144,7 @@ namespace Gecode { namespace Search { namespace Seq { if (cur == n_slaves) cur = 0; } + slave_stop = incomplete; return nullptr; } diff --git a/test/search.cpp b/test/search.cpp index 439f94ff83..05d2598a48 100644 --- a/test/search.cpp +++ b/test/search.cpp @@ -290,6 +290,15 @@ namespace Test { /// Portfolio of BAB, A*, and CABS assets class HeuristicPortfolio : public Base { + /// Stop object that never stops but takes some time + class Slow : public Gecode::Search::Stop { + public: + bool stop(const Gecode::Search::Statistics&, + const Gecode::Search::Options&) override { + std::this_thread::sleep_for(std::chrono::microseconds(200)); + return false; + } + }; public: HeuristicPortfolio(void) : Base("Search::Heuristic::Portfolio",TestTags::check()) {} @@ -307,7 +316,44 @@ namespace Test { o.threads = 3; #endif PBS e(&root,assets,o); - return improving(e,root); + if (!improving(e,root)) + return false; + // A CABS asset stopping at its width limit must not stop the others + return limited(1) && limited(2); + } + /// Run width-limited CABS and BAB on a tree where CABS stops early + static bool limited(unsigned int threads) { +#ifndef GECODE_HAS_THREADS + if (threads > 1U) + return true; +#endif + // CABS with width one follows the preferred branch to cost 5 + TreeSpace::Graph g = { + {0,0,0,1,{1,2}}, {0,0,0,5,{3}}, {0,0,1,1,{4}}, + {5,5,0,5,{}}, {1,1,0,1,{}} + }; + TreeSpace root(std::make_shared(g),false); + Gecode::Search::HeuristicGuidance h = + TreeSpace::guidance(false,true,false,false); + Gecode::Search::Options c; + c.threads = 1; + c.guidance = &h; + c.max_width = 1; + // Slow down BAB so that CABS stops first + Slow slow; + Gecode::Search::Options b; + b.threads = 1; + b.stop = &slow; + SEBs assets({cabs(c),bab(b)}); + Gecode::Search::Options o; + o.threads = threads; + PBS e(&root,assets,o); + int last = std::numeric_limits::max(); + while (TreeSpace* s = e.next()) { + last = s->node().cost; + delete s; + } + return (last == 1) && e.stopped(); } } heuristic_portfolio; #endif From 52ad2b61a8dbdf8f6a2caa141199142402371144 Mon Sep 17 00:00:00 2001 From: Mikael Zayenz Lagerkvist Date: Wed, 28 Oct 2026 05:29:53 +0100 Subject: [PATCH 5/7] Add A* and CABS to the fifteen-puzzle and add a TSPTW example The fifteen-puzzle gains -search astar|cabs using the moves so far plus the Manhattan distance as bound. TSPTW is a driver-based travelling salesperson model with time windows and two inline instances; it runs with BAB, A* and CABS, and CABS uses a dominance check on partial tours. Co-Authored-By: Claude Opus 5.5 --- Makefile.in | 3 +- examples/CMakeLists.txt | 1 + examples/fifteen-puzzle.cpp | 60 ++++++- examples/tsptw.cpp | 306 ++++++++++++++++++++++++++++++++++++ 4 files changed, 367 insertions(+), 3 deletions(-) create mode 100644 examples/tsptw.cpp diff --git a/Makefile.in b/Makefile.in index 656c1ac39a..c8a0955da7 100755 --- a/Makefile.in +++ b/Makefile.in @@ -918,7 +918,8 @@ INTEXAMPLESRC0 = \ word-square crossword open-shop car-sequencing sat \ bin-packing knights tsp perfect-square schurs-lemma \ dominating-queens colored-matrix multi-bin-packing \ - qcp job-shop open-route common-pattern fifteen-puzzle + qcp job-shop open-route common-pattern fifteen-puzzle \ + tsptw INTEXAMPLEHDR = $(INTEXAMPLEHDR0:%=examples/%.hpp) \ diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt index 6282beaeb8..f5c12bdcb9 100755 --- a/examples/CMakeLists.txt +++ b/examples/CMakeLists.txt @@ -61,6 +61,7 @@ set(GECODE_EXAMPLE_SOURCES sudoku-advanced.cpp sudoku.cpp tsp.cpp + tsptw.cpp warehouses.cpp word-square.cpp) diff --git a/examples/fifteen-puzzle.cpp b/examples/fifteen-puzzle.cpp index af5edcdef5..16ef255fb2 100644 --- a/examples/fifteen-puzzle.cpp +++ b/examples/fifteen-puzzle.cpp @@ -34,8 +34,10 @@ #include #include #include +#include #include +#include #include #include #include @@ -52,6 +54,7 @@ class PuzzleOptions : public Options { public: enum { MODEL_OPEN, MODEL_FIXED }; enum { BRANCH_MIN, BRANCH_MANHATTAN }; + enum { SEARCH_BAB, SEARCH_ASTAR, SEARCH_CABS }; PuzzleOptions(void) : Options("FifteenPuzzle"), _scramble("scramble","number of random slides from the goal",12), @@ -65,6 +68,12 @@ class PuzzleOptions : public Options { branching(BRANCH_MIN,"min","try the lowest blank position first"); branching(BRANCH_MANHATTAN,"manhattan", "try the slide with least Manhattan distance first"); + search(SEARCH_BAB); + search(SEARCH_BAB,"bab","depth-first branch and bound"); +#ifdef GECODE_HAS_EXPERIMENTAL + search(SEARCH_ASTAR,"astar","A* with Manhattan distance"); + search(SEARCH_CABS,"cabs","complete anytime beam search"); +#endif solutions(0); seed(1); } @@ -222,6 +231,8 @@ post_goal(Home home, const IntVarArgs& tiles, IntVar length, * Each new board uses the same slide and goal constraints as the fixed * representation. Propagation determines how much of the path is constructed; * the initial Manhattan distance bounds the number of moves from below. + * With A* and CABS, the moves so far plus the Manhattan distance of the + * current board is the bound. * * The goal closes the path. Length counts states including the start, so * branch and bound minimizes moves by minimizing length minus one. @@ -285,6 +296,19 @@ class OpenPuzzle : public Script { result[j] = tiles[16*i+j]; return result; } + int + depth(void) const { + int i = 0; + while (i+1 < path.known_length() && path[i+1].assigned()) + i++; + return i; + } + int + bound(void) const { + const int g = depth(); + const int h = manhattan(values(state(g))); + return std::max(g+h,path.length().min()-1); + } void constrain(const Space& solution) override { const OpenPuzzle& best = static_cast(solution); @@ -363,6 +387,12 @@ class FixedPuzzle : public Script { i++; return i; } + int + bound(void) const { + const int g = depth(); + const int h = manhattan(values(state(g))); + return std::max(g+h,length.min()-1); + } void constrain(const Space& solution) override { const FixedPuzzle& best = static_cast(solution); @@ -377,6 +407,32 @@ class FixedPuzzle : public Script { } }; +template +static void +run(PuzzleOptions& opt) { + switch (opt.search()) { + case PuzzleOptions::SEARCH_BAB: + Script::run(opt); + break; +#ifdef GECODE_HAS_EXPERIMENTAL + case PuzzleOptions::SEARCH_ASTAR: + case PuzzleOptions::SEARCH_CABS: + { + Search::HeuristicGuidance guidance(Search::HeuristicGuidance::MINIMIZE, + [](const Space& home) -> std::int64_t { + return static_cast(home).bound(); + }); + opt.guidance(&guidance); + if (opt.search() == PuzzleOptions::SEARCH_ASTAR) + Script::run(opt); + else + Script::run(opt); + } + break; +#endif + } +} + /** \brief Main-function * \relates OpenPuzzle */ @@ -389,9 +445,9 @@ main(int argc, char* argv[]) { print_board(std::cout,initial_board(opt)); } if (opt.model() == PuzzleOptions::MODEL_OPEN) - Script::run(opt); + run(opt); else - Script::run(opt); + run(opt); return 0; } diff --git a/examples/tsptw.cpp b/examples/tsptw.cpp new file mode 100644 index 0000000000..6f6e7f5941 --- /dev/null +++ b/examples/tsptw.cpp @@ -0,0 +1,306 @@ +/* -*- mode: C++; c-basic-offset: 2; indent-tabs-mode: nil -*- */ +/* + * Main author: + * Mikael Zayenz Lagerkvist + * + * Copyright: + * Mikael Zayenz Lagerkvist, 2026 + * + * This file is part of Gecode, the generic constraint + * development environment: + * http://www.gecode.dev + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the + * "Software"), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to + * the following conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * + */ + +#include +#include +#include + +#include + +using namespace Gecode; + +/// Instances for TSPTW +namespace { + + /// TSPTW instance + class Instance { + public: + /// Number of nodes, node 0 is the depot + int n; + /// Travel times + const int* d; + /// Ready and due time for each node + const int* w; + }; + + /// Instance rbg010a (Ascheuer) + const int PA_n = 11; + const int PA_d[PA_n*PA_n] = { + 0,0,0,0,0,0,0,0,0,0,0, + 71,0,85,85,85,96,85,85,97,85,77, + 50,65,0,77,77,62,77,77,62,77,74, + 64,74,88,0,88,80,88,88,82,88,87, + 44,54,68,68,0,60,68,68,62,68,67, + 51,75,51,51,51,0,51,51,80,51,65, + 53,64,78,78,78,68,0,78,69,78,76, + 51,67,69,69,69,75,69,0,75,69,63, + 43,61,71,71,71,43,71,71,0,71,68, + 53,64,78,78,78,68,78,78,69,0,80, + 42,66,52,52,52,70,52,52,70,52,0 + }; + const int PA_w[2*PA_n] = { + 0,9396, 0,865, 399,1299, 550,1450, 685,1585, 819,1719, + 1208,2108, 1412,2312, 1701,2330, 2413,3313, 3798,4698 + }; + + /// Instance n20w20.001 (Dumas et al.) + const int PB_n = 21; + const int PB_d[PB_n*PB_n] = { + 0,19,17,34,7,20,10,17,28,15,23,29,23,29,21,20,9,16,21,13,12, + 19,0,10,41,26,3,27,25,15,17,17,14,18,48,17,6,21,14,17,13,31, + 17,10,0,47,23,13,26,15,25,22,26,24,27,44,7,5,23,21,25,18,29, + 34,41,47,0,36,39,25,51,36,24,27,38,25,44,54,45,25,28,26,28,27, + 7,26,23,36,0,27,11,17,35,22,30,36,30,22,25,26,14,23,28,20,10, + 20,3,13,39,27,0,26,27,12,15,14,11,15,49,20,9,20,11,14,11,30, + 10,27,26,25,11,26,0,26,31,14,23,32,22,25,31,28,6,17,21,15,4, + 17,25,15,51,17,27,26,0,39,31,38,38,38,34,13,20,26,31,36,28,27, + 28,15,25,36,35,12,31,39,0,17,9,2,11,56,32,21,24,13,11,15,35, + 15,17,22,24,22,15,14,31,17,0,9,18,8,39,29,21,8,4,7,4,18, + 23,17,26,27,30,14,23,38,9,9,0,11,2,48,33,23,17,7,2,10,27, + 29,14,24,38,36,11,32,38,2,18,11,0,13,57,31,20,25,14,13,17,36, + 23,18,27,25,30,15,22,38,11,8,2,13,0,47,34,24,16,7,2,10,26, + 29,48,44,44,22,49,25,34,56,39,48,57,47,0,46,48,31,42,46,40,21, + 21,17,7,54,25,20,31,13,32,29,33,31,34,46,0,11,29,28,32,25,33, + 20,6,5,45,26,9,28,20,21,21,23,20,24,48,11,0,23,19,22,17,32, + 9,21,23,25,14,20,6,26,24,8,17,25,16,31,29,23,0,11,15,9,10, + 16,14,21,28,23,11,17,31,13,4,7,14,7,42,28,19,11,0,5,3,21, + 21,17,25,26,28,14,21,36,11,7,2,13,2,46,32,22,15,5,0,8,25, + 13,13,18,28,20,11,15,28,15,4,10,17,10,40,25,17,9,3,8,0,19, + 12,31,29,27,10,30,4,27,35,18,27,36,26,21,33,32,10,21,25,19,0 + }; + const int PB_w[2*PB_n] = { + 0,408, 62,68, 181,205, 306,324, 214,217, 51,61, + 102,129, 175,186, 250,263, 3,23, 21,49, 79,90, + 78,96, 140,154, 354,386, 42,63, 2,13, 24,42, + 20,33, 9,21, 275,300 + }; + + /// The instances + const Instance instances[] = { + {PA_n, PA_d, PA_w}, + {PB_n, PB_d, PB_w} + }; + /// Number of instances + const unsigned int n_instances = sizeof(instances)/sizeof(Instance); + +} + +/** + * \brief %Example: Travelling salesperson with time windows + * + * Starting and ending at the depot, visit every customer once such that + * service at each customer starts within its time window (waiting is + * allowed). Minimize the total travel time. The instances are from + * López-Ibáñez and + * Blum's collection. + * + * The tour is built from the depot one customer at a time. With the + * experimental heuristic search engines, the minimal total travel time + * is the bound. CABS also uses dominance: a partial tour dominates + * another if it has visited the same customers, ends at the same + * customer, and is neither later nor more expensive. + * + * \ingroup Example + */ +class TSPTW : public IntMinimizeScript { +protected: + /// The instance + const Instance& p; + /// Customer at each position of the tour + IntVarArray x; + /// Position of each customer + IntVarArray pos; + /// Travel time to each position, and back to the depot + IntVarArray c; + /// Start of service at each position + IntVarArray t; + /// Total travel time + IntVar total; +public: + /// Search engines + enum { + SEARCH_BAB, SEARCH_ASTAR, SEARCH_CABS + }; + /// The actual problem + TSPTW(const SizeOptions& opt) + : IntMinimizeScript(opt), p(instances[opt.size()]), + x(*this,p.n-1,1,p.n-1), pos(*this,p.n-1,0,p.n-2), + c(*this,p.n,0,Int::Limits::max), + t(*this,p.n-1,0,p.w[1]), total(*this,0,Int::Limits::max) { + int n = p.n; + IntArgs d(n*n,p.d), from(n), to(n), ready(n), due(n); + for (int i=0; i due[j])) + rel(*this, pos[j-1] < pos[k-1]); + element(*this, from, x[0], c[0]); + rel(*this, t[0] == max(element(ready,x[0]), c[0])); + for (int i=1; i 0) && (k == s.prefix()) && + (x[k-1].val() == s.x[k-1].val()) && (visited(k) == s.visited(k)) && + (t[k-1].val() <= s.t[k-1].val()) && + (prefix_cost(k) <= s.prefix_cost(k)); + } + /// Return a key for dominance + std::size_t key(void) const { + int k = prefix(); + std::size_t h = static_cast(visited(k)); + cmb_hash(h, k > 0 ? x[k-1].val() : 0); + return h; + } + /// Constructor for cloning \a s + TSPTW(TSPTW& s) : IntMinimizeScript(s), p(s.p) { + x.update(*this, s.x); + pos.update(*this, s.pos); + c.update(*this, s.c); + t.update(*this, s.t); + total.update(*this, s.total); + } + /// Copy during cloning + virtual Space* copy(void) { + return new TSPTW(*this); + } + /// Print solution + virtual void print(std::ostream& os) const { + os << "\tTour: 0"; + for (int i=0; i " << x[i]; + os << " -> 0" << std::endl + << "\tTravel time: " << total << std::endl; + } +}; + +/** \brief Main-function + * \relates TSPTW + */ +int +main(int argc, char* argv[]) { + SizeOptions opt("TSPTW"); + opt.size(0); + opt.solutions(0); + opt.search(TSPTW::SEARCH_BAB); + opt.search(TSPTW::SEARCH_BAB, "bab", "branch and bound"); +#ifdef GECODE_HAS_EXPERIMENTAL + opt.search(TSPTW::SEARCH_ASTAR, "astar", "A*"); + opt.search(TSPTW::SEARCH_CABS, "cabs", "complete anytime beam search"); +#endif + opt.parse(argc,argv); + if (opt.size() >= n_instances) { + std::cerr << "Error: size must be between 0 and " + << n_instances-1 << std::endl; + return 1; + } + switch (opt.search()) { + case TSPTW::SEARCH_BAB: + IntMinimizeScript::run(opt); + break; +#ifdef GECODE_HAS_EXPERIMENTAL + case TSPTW::SEARCH_ASTAR: + case TSPTW::SEARCH_CABS: + { + Search::HeuristicGuidance g(Search::HeuristicGuidance::MINIMIZE, + [](const Space& s) -> std::int64_t { + return static_cast(s).cost().min(); + }); + g.dominates = [](const Space& a, const Space& b) { + return static_cast(a) + .dominates(static_cast(b)); + }; + g.dominance_key = [](const Space& s) { + return static_cast(s).key(); + }; + opt.guidance(&g); + if (opt.search() == TSPTW::SEARCH_ASTAR) + IntMinimizeScript::run(opt); + else + IntMinimizeScript::run(opt); + } + break; +#endif + } + return 0; +} + +// STATISTICS: example-any From e88f66d2b3a2649cbd9eb7f143e608a7555ff229 Mon Sep 17 00:00:00 2001 From: Mikael Zayenz Lagerkvist Date: Fri, 30 Oct 2026 06:55:32 +0100 Subject: [PATCH 6/7] Explain assignment branching for TSPTW prefix dominance Assign the next customer on every alternative so CABS prefix dominance does not overlook value exclusions on the unassigned suffix. Use the same branching strategy for BAB, A*, and CABS. Validation: both bundled instances reached their expected optima across all three engines with 1, 2, and 4 threads (18 runs). --- examples/tsptw.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/examples/tsptw.cpp b/examples/tsptw.cpp index 6f6e7f5941..88391c3154 100644 --- a/examples/tsptw.cpp +++ b/examples/tsptw.cpp @@ -187,7 +187,8 @@ class TSPTW : public IntMinimizeScript { element(*this, to, x[n-2], c[n-1]); rel(*this, t[n-2] + c[n-1] <= due[0]); linear(*this, c, IRT_EQ, total); - // Each branch picks the next customer, as dominance compares prefixes + // Assign the next customer on every alternative: CABS prefix dominance + // does not account for value exclusions on the unassigned suffix. branch(*this, x, INT_VAR_NONE(), INT_VALUES_MIN()); } /// Return cost From 55df02d0f2794af26cf5cd2d10fe15c492001c30 Mon Sep 17 00:00:00 2001 From: Mikael Zayenz Lagerkvist Date: Fri, 30 Oct 2026 06:55:32 +0100 Subject: [PATCH 7/7] Drop the shutdown reset lock after frontier workers terminate The launch failure handler waits for every started runner to finish before resetting shutdown. No workers can read the flag at that point, so the reset needs no mutex. Keep the lock when setting shutdown while workers can still be running. Validation: focused search and open-list tests passed over five iterations. Twelve injected thread-creation failures covered partial launches and successful retries for A* and CABS, with and without worker control. --- gecode/search/par/frontier.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/gecode/search/par/frontier.cpp b/gecode/search/par/frontier.cpp index d6c422117d..dacb958871 100644 --- a/gecode/search/par/frontier.cpp +++ b/gecode/search/par/frontier.cpp @@ -109,7 +109,8 @@ namespace Gecode { namespace Search { namespace Par { delete runners[i]; heap.free(runners,n); runners = nullptr; - { Support::Lock lock(mutex); shutdown = false; } + // All started runners have terminated, so there are no concurrent readers. + shutdown = false; throw; } }