diff --git a/.github/ci/generated-source-freshness.sh b/.github/ci/generated-source-freshness.sh index 5be8629767..a2f2d108d6 100755 --- a/.github/ci/generated-source-freshness.sh +++ b/.github/ci/generated-source-freshness.sh @@ -225,7 +225,7 @@ normalize_generated_whitespace() { # Ignore any autom4te cache so output matches a clean source release. autoconf --force # Autoconf 2.72 appends blank lines after the generated script body; source - # releases retain exactly one final newline. + # releases have exactly one final newline. normalize_generated_whitespace configure uv run --script ./misc/genvarimp.py -typehpp "${vis_files[@]}" \ > gecode/kernel/var-type.hpp diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2d70f7172f..d9998925c1 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 @@ -220,6 +220,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 @@ -402,6 +403,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 @@ -513,6 +515,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 @@ -581,7 +584,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 01b980c7be..463b55aeb9 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}) @@ -691,6 +692,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() @@ -1784,6 +1788,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/Makefile.in b/Makefile.in index 50a0830ab5..728e8a41fd 100755 --- a/Makefile.in +++ b/Makefile.in @@ -272,16 +272,20 @@ SEARCHHDR0 = \ support.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/rbs.hh seq/rbs.hpp seq/dead.hh \ + seq/bab.hh seq/bab.hpp seq/astar.hh seq/cabs.hh seq/lds.hh seq/lds.hpp \ + seq/rbs.hh seq/rbs.hpp seq/dead.hh seq/choice-expansion.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)) @@ -912,7 +916,12 @@ INTEXAMPLESRC0 = \ qcp job-shop open-route common-pattern fifteen-puzzle -INTEXAMPLEHDR = $(INTEXAMPLEHDR0:%=examples/%.hpp) +ifeq "@enable_experimental@" "yes" +INTEXAMPLESRC0 += tsptw +endif + +INTEXAMPLEHDR = $(INTEXAMPLEHDR0:%=examples/%.hpp) \ + examples/tsptw-data.hh examples/tsptw-transition.hh INTEXAMPLESRC = $(INTEXAMPLESRC0:%=examples/%.cpp) ifeq "@enable_examples@" "yes" INSTALLEXAMPLEHDR = $(INTEXAMPLEHDR) diff --git a/README.md b/README.md index 6bd1d280b4..58798142f0 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ state. | `--enable-qt` | `GECODE_ENABLE_QT` | Supported directly | `AUTO` by default; Qt 6 preferred; Qt 5.15+ supported | | `--enable-gist` | `GECODE_ENABLE_GIST` | Supported directly | `AUTO` by default except macOS; disabled automatically if Qt GUI support is unavailable | | `--enable-cpprofiler` | `GECODE_ENABLE_CPPROFILER` | Supported directly | Default `ON` | +| `--enable-experimental` | `GECODE_ENABLE_EXPERIMENTAL` | Supported directly | Default `OFF`; experimental 6.5.0 preview APIs | | `--enable-cbs` | `GECODE_ENABLE_CBS` | Supported directly | Default `OFF` | | `--enable-examples` | `GECODE_ENABLE_EXAMPLES` | Supported directly | Default `ON` for top-level builds, `OFF` for subprojects | | `--enable-search` | `GECODE_ENABLE_SEARCH` | Supported directly | Default `ON` | @@ -117,6 +118,24 @@ build that supports the active macOS SDK; recent Qt 6 releases are the safest choice. Gist is opt-in for CMake builds on macOS. Use `-DGECODE_ENABLE_GIST=ON` when Qt is installed and its framework link interface is usable. +## Experimental search preview + +Gecode 6.5.0 includes an opt-in preview of `AStar` and `CABS`, with bound/rank +guidance. `AStar` offers bound-first and rank-first queue ordering. +Enable it when building the library with `-DGECODE_ENABLE_EXPERIMENTAL=ON` +for CMake or `--enable-experimental` for Autoconf. Both builds disable the +preview by default. The installed configuration defines +`GECODE_HAS_EXPERIMENTAL` only when the library includes these APIs. +Applications can use this macro to check availability; defining it in an +application does not enable the library feature. + +Experimental APIs may change or be removed in future feature releases. +Their documented search correctness contracts still apply. Native preview +engines work directly with ordinary `Space` subclasses and do not require +Model or portfolio setup. The `fifteen-puzzle` and `tsptw` examples demonstrate +the engines. Ordinary DFS/BAB and shared kernel/OpenCP APIs remain +available independently of the preview option. + ## CMake Package Consumption For CMake build/install workflows and downstream `find_package(Gecode)` usage, diff --git a/changelog.in b/changelog.in index 449ab60ee6..f339fb051e 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: @@ -73,6 +75,35 @@ Date: unreleased [DESCRIPTION] This is the development changelog for the next Gecode release. +[ENTRY] +Module: search +What: new +Rank: major +Experimental: true +[DESCRIPTION] +Add experimental AStar and CABS search for 6.5.0, with bound and rank guidance +and optional rank-first ordering for AStar. Enable with +--enable-experimental or GECODE_ENABLE_EXPERIMENTAL=ON; the preview is disabled +by default. Preview APIs may change or be removed in future feature releases; +their documented search correctness contracts still apply. +CABS accepts an optional model-defined dominance predicate for live candidates +in the next layer. An optional cached key indexes live candidates for dominance +comparisons. The TSPTW transition example enables keyed dominance by default. +Both engines support native parallel search through Options::threads and dynamic +worker control. Parallel CABS shares one beam across workers; parallel AStar +selects from one shared priority queue. More threads can increase coordination, +memory use, and search work; measure against sequential search and BAB. +The astar and cabs builders support mixed PBS portfolios. +The TSPTW example accepts --threads N. + +[ENTRY] +Module: kernel +What: performance +Rank: minor +[DESCRIPTION] +Space chunk allocation and disposal bypass a busy chunk cache through the +configured heap allocator. Workers do not wait for this optional cache. + [ENTRY] Module: int What: new @@ -150,7 +181,7 @@ What: performance Rank: major [DESCRIPTION] Reduce construction memory for regular constraints by building the layered -graph from sparse forward frontiers and retaining only backward co-reachable +graph from sparse forward frontiers and including only backward co-reachable states. This avoids allocating one temporary state for every automaton state at every sequence position while preserving the persistent graph. @@ -160,7 +191,7 @@ What: new Rank: major [DESCRIPTION] Add DFA::nfa factories for finite automata with nondeterministic transitions. -The factories retain nondeterminism without determinization or minimization; +The factories support nondeterminism without determinization or minimization; epsilon transitions are not supported. Regular propagation now preserves branching assigned prefixes when cloning such automata. @@ -171,7 +202,7 @@ Rank: major [DESCRIPTION] Add self-declared check, standard, and sweep tags to the public test harness. The standard check targets use a focused integrity selection, while standard -testing retains broad coverage without the exhaustive multi-hour sweeps. +testing provides broad coverage without the exhaustive multi-hour sweeps. [ENTRY] Module: test @@ -228,7 +259,7 @@ Issue: 166 Thanks: Mats Carlsson [DESCRIPTION] Fix domain-level global cardinality propagation losing solutions by counting -assigned variables twice and retaining stale conflict marks when cardinality +assigned variables twice and leaving stale conflict marks when cardinality bounds shrink. [ENTRY] diff --git a/cmake/GecodeSources.cmake b/cmake/GecodeSources.cmake index c3e7b24359..068ba7be6c 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/configure b/configure index 0a59cb0548..6b9db2a20b 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] @@ -3407,6 +3410,8 @@ GECODE_SOVERSION=${ac_gecode_soversion} + + @@ -13888,6 +13893,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 b2c3ef0d58..85254e03f2 100644 --- a/configure.ac +++ b/configure.ac @@ -275,6 +275,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/examples/CMakeLists.txt b/examples/CMakeLists.txt index 6282beaeb8..05218bc681 100755 --- a/examples/CMakeLists.txt +++ b/examples/CMakeLists.txt @@ -115,3 +115,9 @@ endfunction() foreach(example_source IN LISTS GECODE_EXAMPLE_SOURCES) gecode_add_example("${example_source}") endforeach() + +if(GECODE_ENABLE_EXPERIMENTAL) + add_executable(tsptw tsptw.cpp) + target_link_libraries(tsptw PRIVATE gecodesearch gecodeint) + set_property(TARGET tsptw PROPERTY FOLDER "Examples") +endif() diff --git a/examples/data/tsptw/README.md b/examples/data/tsptw/README.md new file mode 100644 index 0000000000..fb684adb84 --- /dev/null +++ b/examples/data/tsptw/README.md @@ -0,0 +1,109 @@ +# TSPTW inputs and checking + +`examples/tsptw.cpp` solves the travelling salesperson problem with time windows. +It has a conventional closed CP model and two transition models, with BAB, A* +and CABS search. The bundled data and independent Python checker exercise their +cost and feasibility rules. + +## Input and tour formats + +The native reader in `examples/tsptw-data.hh` and `misc/tsptw-check.py` accept +the canonical matrix format from the +[López-Ibáñez/Blum collection](https://lopez-ibanez.eu/tsptw-instances): a node +count including depot 0, an n-by-n matrix, then n ready/due pairs. Comments +begin with `#`. Whitespace separates values; exactly n*n + 2*n numeric values +must follow the node count. + +Each matrix arc is both travel time and objective cost. The objective includes +the return arc. Waiting affects feasibility but adds no cost. Service time is +already folded into outgoing arcs where the source data supplies it. Keep the +supplied asymmetric, nonmetric matrix without adding service again. + +Departure is fixed at zero. Each visit waits until its ready time and must +begin by its due time. Return to depot 0 must also meet its window. The reader +rejects a nonzero depot ready time. A tour lists each customer ID 1 through n-1 +once, omitting both depot visits. The checker reconstructs the earliest schedule +and compares an optional claimed cost exactly in original units. + +The reader accepts ordinary nonnegative decimals, without signs or exponents. +It scales all values by a common power of ten, ignoring trailing zeroes. +Dimensions, scaled values, scale, n*maximum-arc and maximum-due + n*maximum-arc +must fit `Gecode::Int::Limits::max`. The Python reader checks these limits +separately using exact rational arithmetic. + +## Bundled fixtures + +The five generated tiny instances have optima established by enumeration: + +| Case | Check | Optimum | +| --- | --- | --- | +| waiting | waiting, asymmetric and nonmetric arcs | 5 | +| decimal | exact decimal arcs and waiting | 1.5 | +| folded-service | service included in outgoing arcs | 9 | +| late-customer | every tour misses a customer due time | infeasible | +| late-return | every return is late | infeasible | + +`published/` contains an unchanged canonical input and a published travel-cost +tour from each of the Dumas, GendreauDumasExtended, SolomonPotvinBengio and AFG +families in the López-Ibáñez/Blum collection. `manifest.json` records the source +archive and tour-table URLs, bundled file hashes and exact tour costs. +These are feasible best-known tours; checking them does not prove optimality. +The Solomon tour costs exactly 444.5425, while the published table displays +444.54. Makespan tables are not travel-cost references. + +## Running and checking + +Enable this example with `GECODE_ENABLE_EXPERIMENTAL=ON` when configuring Gecode. +Replace `build/open-search` below with that build directory. + +```sh +cmake --build build/open-search --target tsptw -j 8 +build/open-search/bin/tsptw examples/data/tsptw/tiny/waiting.txt --engine bab +build/open-search/bin/tsptw examples/data/tsptw/tiny/waiting.txt --formulation transition --engine astar +build/open-search/bin/tsptw examples/data/tsptw/tiny/waiting.txt --formulation eager --engine cabs +uv run --script misc/tsptw-check.py enumerate examples/data/tsptw/tiny/decimal.txt +uv run --script misc/tsptw-check.py check examples/data/tsptw/published/SolomonPotvinBengio/rc_201.1.txt examples/data/tsptw/published/SolomonPotvinBengio/rc_201.1.txt.tour --cost 444.5425 +c++ -std=c++17 -Ibuild/open-search -I. misc/tsptw-read.cpp -o /tmp/tsptw-read +uv run --script misc/tsptw-validate.py --reader /tmp/tsptw-read --solver build/open-search/bin/tsptw +``` + +Validation runs offline. It compares native scaled values against the independent +reader, enumerates tiny optima, checks published tours, and rejects malformed +inputs and invalid tours. With `--solver`, it checks the native formulations +and engines against tiny optima, nonmetric cases, bounds, seeds and resource +stops, then runs each published case with its checked seed and a five-second +limit. Omit `--solver` to check data and readers alone. The checker exits with +status 1 for invalid data or tours. Enumeration is limited to nine customers. + +The default `--formulation closed` uses a costed circuit and reified timing +constraints. `transition` materializes visit variables as the route grows; +`eager` allocates the same visit list at the root. Both transition models use +explicit waiting and depot return checks. `--bound prefix|min-arcs` selects the +transition lower bound, and `--residual-propagation off|cheap|unary` selects no +residual filtering, shortest-path deadline checks, or a temporary unary +scheduling relaxation. Defaults are `prefix` and `off`. Shortest-path bounds +preserve soundness for nonmetric arcs. + +CABS enables dominance by default for `transition` and `eager`. Among live +next-layer candidates with the same location and unvisited customers, an earlier +arrival with no greater prefix cost subsumes a later, no cheaper arrival. +Use `--dominance off` to compare runs without this pruning. + +`--seed TOUR` checks the seed before adopting it and emits it before any +improvements. `--time-ms N` and `--node-limit N` use ordinary search stops; +time includes reading and preprocessing. CABS also accepts `--max-width N` and +`--frontier-limit N`, where zero disables the cap. Limits are checked at engine +boundaries and cannot interrupt propagation. Runs default to one search thread; +use `--threads N` to change the thread count. + +The closed model also accepts `--circuit-propagation dom|val` and +`--cost-branching regret|size`, defaulting to `dom` and `regret`. Its `rbs` +engine uses RBS of BAB with Luby scale 1,000 and no-good depth 128. + +The executable emits JSON lines: improving `solution` records followed by a +`summary`. Each tour has exact cost and return time, scaled cost, scale and +elapsed milliseconds. Natural exhaustion reports `optimal` with an incumbent +or `infeasible` without one. Any resource stop reports `stopped` and supplies +no proof. Summaries also include native search statistics and model settings. +Transition allocation counts and bound/inference timings describe the example's +work; they do not measure process memory or search frontier storage. diff --git a/examples/data/tsptw/manifest.json b/examples/data/tsptw/manifest.json new file mode 100644 index 0000000000..4b3c1eb2fc --- /dev/null +++ b/examples/data/tsptw/manifest.json @@ -0,0 +1,99 @@ +{ + "sources": { + "Dumas": { + "archive_url": "https://lopez-ibanez.eu/files/TSPTW/Dumas.tar.gz", + "tour_url": "https://lopez-ibanez.eu/files/TSPTW/Dumas-best-known-traveltime.txt" + }, + "GendreauDumasExtended": { + "archive_url": "https://lopez-ibanez.eu/files/TSPTW/GendreauDumasExtended.tar.gz", + "tour_url": "https://lopez-ibanez.eu/files/TSPTW/GendreauDumasExtended-best-known-traveltime.txt" + }, + "SolomonPotvinBengio": { + "archive_url": "https://lopez-ibanez.eu/files/TSPTW/SolomonPotvinBengio.tar.gz", + "tour_url": "https://lopez-ibanez.eu/files/TSPTW/SolomonPotvinBengio-best-known-traveltime.txt" + }, + "AFG": { + "archive_url": "https://lopez-ibanez.eu/files/TSPTW/AFG.tar.gz", + "tour_url": "https://lopez-ibanez.eu/files/TSPTW/AFG-best-known-traveltime.txt" + } + }, + "tiny": [ + { + "path": "tiny/decimal.txt", + "sha256": "add8c302d37fc661ca9f8667b6b530f7e3be2b519d30b3df9b31a2e3c8094169", + "optimum": "1.5", + "feasible_tours": 1, + "proof": "exhaustive enumeration" + }, + { + "path": "tiny/folded-service.txt", + "sha256": "61f45ff0e7bd9453e61d02d00dc0680db3fc5e5fb7a0710c8465ac483cf806bb", + "optimum": "9", + "feasible_tours": 2, + "proof": "exhaustive enumeration" + }, + { + "path": "tiny/late-customer.txt", + "sha256": "c1438cc1fa564c8b22011684aab37ae461e5f598d5d714a758c885ba14713ef6", + "optimum": null, + "feasible_tours": 0, + "proof": "exhaustive enumeration" + }, + { + "path": "tiny/late-return.txt", + "sha256": "f44a2168bc9638d0c657235b2eb9df1bc08fcc0dca06e6729782ed20235dae62", + "optimum": null, + "feasible_tours": 0, + "proof": "exhaustive enumeration" + }, + { + "path": "tiny/waiting.txt", + "sha256": "9bf9bf7ff8a243500d75d09436a75a92bef276c1ea596d7d8af5bc272952220e", + "optimum": "5", + "feasible_tours": 1, + "proof": "exhaustive enumeration" + } + ], + "published": [ + { + "family": "Dumas", + "path": "published/Dumas/n20w20.001.txt", + "sha256": "83c354a30228db900577252a845fe2671175cb5e621a9a1061a01f9b56aaa748", + "tour_path": "published/Dumas/n20w20.001.txt.tour", + "tour_sha256": "bb65acd7d979b3225c9f0869cc15ed77222b23faa489a7a00b0199f37221fe74", + "published_cost": "378", + "exact_cost": "378", + "status": "published best-known tour; no optimality proof inferred" + }, + { + "family": "GendreauDumasExtended", + "path": "published/GendreauDumasExtended/n20w120.001.txt", + "sha256": "633d3be7abef529e7faf8bd6094a40f548d8310f41cb2f3014d21b550ebf5fba", + "tour_path": "published/GendreauDumasExtended/n20w120.001.txt.tour", + "tour_sha256": "4b49c9807ac9a66ceb3f6d3857c66ee9e8929e0c37b69234d905426bad20dbb2", + "published_cost": "267", + "exact_cost": "267", + "status": "published best-known tour; no optimality proof inferred" + }, + { + "family": "SolomonPotvinBengio", + "path": "published/SolomonPotvinBengio/rc_201.1.txt", + "sha256": "e0c1dfb4fb08559c01c3ab3193316269fa8a3a215d529e0cc893ef300659766f", + "tour_path": "published/SolomonPotvinBengio/rc_201.1.txt.tour", + "tour_sha256": "5b1695a48e6eefb47b79bbebcaf45558740e4229d02df8281911d1820c177e55", + "published_cost": "444.54", + "exact_cost": "444.5425", + "status": "published best-known tour; no optimality proof inferred" + }, + { + "family": "AFG", + "path": "published/AFG/rbg010a.tw", + "sha256": "4a8e0c37052047063b19d698487bae0ed5256bb22de4f675fca3868975f40ef3", + "tour_path": "published/AFG/rbg010a.tw.tour", + "tour_sha256": "70d011b502f29dd14297cc341cccdb80ad3b736c666b9bd88c209ad52cc5673d", + "published_cost": "671", + "exact_cost": "671", + "status": "published best-known tour; no optimality proof inferred" + } + ] +} diff --git a/examples/data/tsptw/published/AFG/rbg010a.tw b/examples/data/tsptw/published/AFG/rbg010a.tw new file mode 100644 index 0000000000..8bec350870 --- /dev/null +++ b/examples/data/tsptw/published/AFG/rbg010a.tw @@ -0,0 +1,24 @@ +11 +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 +0 9396 +0 865 +399 1299 +550 1450 +685 1585 +819 1719 +1208 2108 +1412 2312 +1701 2330 +2413 3313 +3798 4698 +# Sum of service times: 522 diff --git a/examples/data/tsptw/published/AFG/rbg010a.tw.tour b/examples/data/tsptw/published/AFG/rbg010a.tw.tour new file mode 100644 index 0000000000..8479b87ec0 --- /dev/null +++ b/examples/data/tsptw/published/AFG/rbg010a.tw.tour @@ -0,0 +1 @@ +3 1 2 5 4 6 8 7 9 10 diff --git a/examples/data/tsptw/published/Dumas/n20w20.001.txt b/examples/data/tsptw/published/Dumas/n20w20.001.txt new file mode 100644 index 0000000000..789c9a6572 --- /dev/null +++ b/examples/data/tsptw/published/Dumas/n20w20.001.txt @@ -0,0 +1,43 @@ +21 +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 +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 diff --git a/examples/data/tsptw/published/Dumas/n20w20.001.txt.tour b/examples/data/tsptw/published/Dumas/n20w20.001.txt.tour new file mode 100644 index 0000000000..b60af63faf --- /dev/null +++ b/examples/data/tsptw/published/Dumas/n20w20.001.txt.tour @@ -0,0 +1 @@ +16 9 19 17 18 10 5 15 1 11 12 6 13 7 2 4 8 20 3 14 diff --git a/examples/data/tsptw/published/GendreauDumasExtended/n20w120.001.txt b/examples/data/tsptw/published/GendreauDumasExtended/n20w120.001.txt new file mode 100644 index 0000000000..d98b26740b --- /dev/null +++ b/examples/data/tsptw/published/GendreauDumasExtended/n20w120.001.txt @@ -0,0 +1,43 @@ +21 +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 +0 458 +12 118 +131 255 +256 374 +164 267 +1 111 +52 179 +125 236 +200 313 +0 73 +0 99 +29 140 +28 146 +90 204 +304 436 +0 113 +0 63 +0 92 +0 83 +0 71 +225 350 diff --git a/examples/data/tsptw/published/GendreauDumasExtended/n20w120.001.txt.tour b/examples/data/tsptw/published/GendreauDumasExtended/n20w120.001.txt.tour new file mode 100644 index 0000000000..35b2ed70c1 --- /dev/null +++ b/examples/data/tsptw/published/GendreauDumasExtended/n20w120.001.txt.tour @@ -0,0 +1 @@ +6 16 9 19 17 18 12 10 11 5 1 15 2 7 4 13 20 3 8 14 diff --git a/examples/data/tsptw/published/SolomonPotvinBengio/rc_201.1.txt b/examples/data/tsptw/published/SolomonPotvinBengio/rc_201.1.txt new file mode 100644 index 0000000000..2418609f7a --- /dev/null +++ b/examples/data/tsptw/published/SolomonPotvinBengio/rc_201.1.txt @@ -0,0 +1,41 @@ +20 +0 45.1774 35.0571 35 57.0088 55.7136 52.2015 52 50.2892 51.4782 38.0526 33.541 26.9258 39.0512 11.1803 13.0384 25.2982 42.9418 19.2354 35.4683 +55.1774 10 20.198 20.7703 69.1692 64.1202 64.9181 60.6063 63.2541 55.618 17.2801 28.6011 34.4131 35.807 46.1386 42.1403 52.0595 27.4642 36.9258 36.1725 +45.0571 20.198 10 12 66.648 62.2015 62 58.3839 60.1597 54.1475 13 23.9284 25.6205 33.5372 35.9615 32.0227 44.8281 28.2483 26.7631 32.4722 +45 20.7703 12 10 68.5235 64.1202 63.8516 60.2892 62 56.0977 13.6056 25.8114 24.1421 35.4951 35.4951 32.0227 46.1248 30.2237 26.2788 34.3516 +67.0088 69.1692 66.648 68.5235 10 15.831 15 18.6023 17 24.1421 67.7754 52.72 75.7647 43.541 70.208 64.037 41.7805 51.8808 68.1378 44.176 +65.7136 64.1202 62.2015 64.1202 15.831 10 15.3852 14 16.4031 18.6023 63.1413 48.3275 72.2013 38.7924 67.8705 61.4782 40.4631 46.7151 65.0091 39.8329 +62.2015 64.9181 62 63.8516 15 15.3852 10 15.3852 12 21.1803 63.2259 48.0789 70.8276 39.1548 65.2268 59.0408 36.9258 47.8021 63.1507 39.5466 +62 60.6063 58.3839 60.2892 18.6023 14 15.3852 10 15 15.831 59.3964 44.4819 68.2151 35.0799 63.9351 57.5184 36.8328 43.2866 61.0098 35.9615 +60.2892 63.2541 60.1597 62 17 16.4031 12 15 10 20.4403 61.4296 46.2491 68.8558 37.4591 63.2353 57.0425 35 46.2353 61.1566 37.7308 +61.4782 55.618 54.1475 56.0977 24.1421 18.6023 21.1803 15.831 20.4403 10 54.9222 40.4138 65 30.6155 62.2015 55.607 37.0185 38.178 58.3735 32.0907 +48.0526 17.2801 13 13.6056 67.7754 63.1413 63.2259 59.3964 61.4296 54.9222 10 25.2643 27.6918 34.3516 38.8617 35.02 47.2022 28 29.6469 33.7065 +43.541 28.6011 23.9284 25.8114 52.72 48.3275 48.0789 44.4819 46.2491 40.4138 25.2643 10 35.4951 20 38.2843 32.0227 33.7697 19.434 31.095 18.544 +36.9258 34.4131 25.6205 24.1421 75.7647 72.2013 70.8276 68.2151 68.8558 65 27.6918 35.4951 10 45.3553 25.8114 26.2788 48.0132 42.6956 19.2195 43.0606 +49.0512 35.807 33.5372 35.4951 43.541 38.7924 39.1548 35.0799 37.4591 30.6155 34.3516 20 45.3553 10 46.0555 39.4109 32.0227 19.434 40.0832 13.6056 +21.1803 46.1386 35.9615 35.4951 70.208 67.8705 65.2268 63.9351 63.2353 62.2015 38.8617 38.2843 25.8114 46.0555 10 16.7082 39.0689 47.5366 19.2195 42.7567 +23.0384 42.1403 32.0227 32.0227 64.037 61.4782 59.0408 57.5184 57.0425 55.607 35.02 32.0227 26.2788 39.4109 16.7082 10 33.5372 41.4006 17.2111 36.0768 +35.2982 52.0595 44.8281 46.1248 41.7805 40.4631 36.9258 36.8328 35 37.0185 47.2022 33.7697 48.0132 32.0227 39.0689 33.5372 10 40.2655 39.1548 29.0263 +52.9418 27.4642 28.2483 30.2237 51.8808 46.7151 47.8021 43.2866 46.2353 38.178 28 19.434 42.6956 19.434 47.5366 41.4006 40.2655 10 39.8329 21.4018 +29.2354 36.9258 26.7631 26.2788 68.1378 65.0091 63.1507 61.0098 61.1566 58.3735 29.6469 31.095 19.2195 40.0832 19.2195 17.2111 39.1548 39.8329 10 37.2029 +45.4683 36.1725 32.4722 34.3516 44.176 39.8329 39.5466 35.9615 37.7308 32.0907 33.7065 18.544 43.0606 13.6056 42.7567 36.0768 29.0263 21.4018 37.2029 10 +0 960 +335 455 +537 657 +375 495 +146 266 +149 269 +194 314 +246 366 +165 285 +80 200 +440 560 +326 446 +397 517 +39 159 +11 131 +566 686 +268 388 +268 388 +105 225 +344 464 diff --git a/examples/data/tsptw/published/SolomonPotvinBengio/rc_201.1.txt.tour b/examples/data/tsptw/published/SolomonPotvinBengio/rc_201.1.txt.tour new file mode 100644 index 0000000000..7f65605637 --- /dev/null +++ b/examples/data/tsptw/published/SolomonPotvinBengio/rc_201.1.txt.tour @@ -0,0 +1 @@ +14 18 13 9 5 4 6 8 7 16 19 11 17 1 10 3 12 2 15 diff --git a/examples/data/tsptw/tiny/decimal.txt b/examples/data/tsptw/tiny/decimal.txt new file mode 100644 index 0000000000..b9d37e8f20 --- /dev/null +++ b/examples/data/tsptw/tiny/decimal.txt @@ -0,0 +1,7 @@ +3 +0 0.25 2 +1 0 0.50 +0.75 3 0 +0 4 +1 1 +0 2 diff --git a/examples/data/tsptw/tiny/folded-service.txt b/examples/data/tsptw/tiny/folded-service.txt new file mode 100644 index 0000000000..89df0a4402 --- /dev/null +++ b/examples/data/tsptw/tiny/folded-service.txt @@ -0,0 +1,8 @@ +3 +0 1 3 +6 0 5 +3 4 0 +0 20 +0 10 +0 15 +# Outgoing arcs already include service: customer 1 = 4, customer 2 = 2. diff --git a/examples/data/tsptw/tiny/late-customer.txt b/examples/data/tsptw/tiny/late-customer.txt new file mode 100644 index 0000000000..5566375c34 --- /dev/null +++ b/examples/data/tsptw/tiny/late-customer.txt @@ -0,0 +1,7 @@ +3 +0 5 9 +4 0 1 +2 8 0 +0 20 +0 4 +0 12 diff --git a/examples/data/tsptw/tiny/late-return.txt b/examples/data/tsptw/tiny/late-return.txt new file mode 100644 index 0000000000..392addfbe4 --- /dev/null +++ b/examples/data/tsptw/tiny/late-return.txt @@ -0,0 +1,7 @@ +3 +0 2 9 +4 0 1 +2 8 0 +0 7 +5 10 +0 12 diff --git a/examples/data/tsptw/tiny/waiting.txt b/examples/data/tsptw/tiny/waiting.txt new file mode 100644 index 0000000000..a120d114ff --- /dev/null +++ b/examples/data/tsptw/tiny/waiting.txt @@ -0,0 +1,8 @@ +3 +0 2 9 +4 0 1 +2 8 0 +0 20 +5 10 +0 12 +# Asymmetric and nonmetric: 0->2 is more costly than 0->1->2. diff --git a/examples/fifteen-puzzle.cpp b/examples/fifteen-puzzle.cpp index fac1060391..ad236fd798 100644 --- a/examples/fifteen-puzzle.cpp +++ b/examples/fifteen-puzzle.cpp @@ -34,9 +34,11 @@ #include #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); } @@ -284,6 +293,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); @@ -356,6 +378,12 @@ class FixedPuzzle : public Script { return result; } int + bound(void) const { + const int g = depth(); + const int h = manhattan(values(state(g))); + return std::max(g+h,length.min()-1); + } + int depth(void) const { int i = 0; while (i+1 < path.size() && path[i+1].assigned()) @@ -376,6 +404,51 @@ class FixedPuzzle : public Script { } }; +#ifdef GECODE_HAS_EXPERIMENTAL +// Both heuristic engines use the same total objective bound. +template +static Search::HeuristicGuidance +guidance(void) { + return Search::HeuristicGuidance(Search::HeuristicGuidance::MINIMIZE, + [](const Space& home) -> std::int64_t { + return static_cast(home).bound(); + }); +} + +template +// Adapt the guidance argument to the constructor expected by Script::run. +class PuzzleAStar : public AStar { +public: + PuzzleAStar(Model* root, const Search::Options& opt) + : AStar(root,guidance(),opt) {} +}; + +template +class PuzzleCABS : public CABS { +public: + PuzzleCABS(Model* root, const Search::Options& opt) + : CABS(root,guidance(),opt) {} +}; +#endif + +template +static void +run_model(const PuzzleOptions& opt) { + switch (opt.search()) { + case PuzzleOptions::SEARCH_BAB: + Script::run(opt); + break; +#ifdef GECODE_HAS_EXPERIMENTAL + case PuzzleOptions::SEARCH_ASTAR: + Script::run(opt); + break; + case PuzzleOptions::SEARCH_CABS: + Script::run(opt); + break; +#endif + } +} + /** \brief Main-function * \relates OpenPuzzle */ @@ -388,9 +461,9 @@ main(int argc, char* argv[]) { print_board(std::cout,initial_board(opt)); } if (opt.model() == PuzzleOptions::MODEL_OPEN) - Script::run(opt); + run_model(opt); else - Script::run(opt); + run_model(opt); return 0; } diff --git a/examples/tsptw-data.hh b/examples/tsptw-data.hh new file mode 100644 index 0000000000..5d9e0db35a --- /dev/null +++ b/examples/tsptw-data.hh @@ -0,0 +1,143 @@ +/* -*- mode: C++; c-basic-offset: 2; indent-tabs-mode: nil -*- */ +#ifndef GECODE_EXAMPLES_TSPTW_DATA_HH +#define GECODE_EXAMPLES_TSPTW_DATA_HH + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/// Canonical matrix data shared by the native TSPTW examples. +namespace TSPTW { + struct Instance { + int n, scale; + int cost_limit, time_limit; + std::vector cost, ready, due; + int arc(int from, int to) const { return cost[from*n+to]; } + }; + + /// Checked arithmetic for total bounds, without rounding or saturation. + inline std::int64_t add_cost(std::int64_t a, std::int64_t b) { + if (b<0 || a<0 || a>std::numeric_limits::max()-b) + throw std::overflow_error("TSPTW bound arithmetic overflow"); + return a+b; + } + + /// Paths may use any vertex, relaxing residual reachability on nonmetric data. + inline std::vector shortest_paths(const Instance& data) { + const int n=data.n; + std::vector distance(data.cost.begin(),data.cost.end()); + for (int i=0; i 1 && token[0] == '.') token.insert(0,"0"); + if (dot != std::string::npos) { + while (!token.empty() && token.back() == '0') token.pop_back(); + if (!token.empty() && token.back() == '.') token.pop_back(); + } + Decimal result = {0, 0}; + bool fraction = false, digit = false; + for (std::size_t i=0; i '9') + throw std::runtime_error("invalid nonnegative decimal: " + original); + digit = true; + if (result.value > (Gecode::Int::Limits::max-(c-'0'))/10) + throw std::runtime_error("decimal exceeds Gecode integer limits: " + original); + result.value = result.value*10 + c-'0'; + if (fraction) result.places++; + } + if (!digit) + throw std::runtime_error("invalid decimal: " + original); + return result; + } + } + + /// Read decimal text exactly; comments begin with #. No service is added. + inline Instance read(std::istream& input) { + std::vector tokens; + std::string line, token; + while (std::getline(input,line)) { + std::istringstream row(line.substr(0,line.find('#'))); + while (row >> token) tokens.push_back(token); + } + if (input.bad() || tokens.empty()) + throw std::runtime_error("cannot read TSPTW instance"); + if (tokens[0].find_first_not_of("0123456789") != std::string::npos) + throw std::runtime_error("node count must be an integer"); + const long long n = Detail::decimal(tokens[0]).value; + if (n < 2 || n*n > Gecode::Int::Limits::max || + static_cast(1+n*n+2*n) != tokens.size()) + throw std::runtime_error("invalid TSPTW dimensions"); + std::vector values; + int places = 0; + for (std::size_t i=1; i(n); + data.scale = 1; + for (int i=0; i Gecode::Int::Limits::max/10) + throw std::runtime_error("decimal scale exceeds Gecode integer limits"); + data.scale *= 10; + } + std::vector scaled; + for (std::size_t i=0; i Gecode::Int::Limits::max/10) + throw std::runtime_error("scaled value exceeds Gecode integer limits"); + value *= 10; + } + scaled.push_back(static_cast(value)); + } + data.cost.assign(scaled.begin(),scaled.begin()+n*n); + for (int i=0; i data.due[i]) + throw std::runtime_error("window ready time exceeds due time"); + } + if (data.ready[0] != 0) + throw std::runtime_error("nonzero start-depot ready time is unsupported"); + // Bound every n-arc tour and waiting-plus-travel arithmetic, including + // infeasible prefixes. Models can safely use these as IntVar domains. + const long long cost_limit = n * *std::max_element(data.cost.begin(),data.cost.end()); + const long long time_limit = cost_limit + *std::max_element(data.due.begin(),data.due.end()); + if (time_limit > Gecode::Int::Limits::max) + throw std::runtime_error("tour arithmetic exceeds Gecode integer limits"); + data.cost_limit = static_cast(cost_limit); + data.time_limit = static_cast(time_limit); + return data; + } + + inline Instance read(const std::string& path) { + std::ifstream input(path.c_str()); + if (!input) throw std::runtime_error("cannot open TSPTW instance: " + path); + return read(input); + } +} +#endif diff --git a/examples/tsptw-transition.hh b/examples/tsptw-transition.hh new file mode 100644 index 0000000000..f8b0ce9e12 --- /dev/null +++ b/examples/tsptw-transition.hh @@ -0,0 +1,353 @@ +/* -*- mode: C++; c-basic-offset: 2; indent-tabs-mode: nil -*- */ +#ifndef GECODE_EXAMPLES_TSPTW_TRANSITION_HH +#define GECODE_EXAMPLES_TSPTW_TRANSITION_HH + +#include "tsptw-data.hh" +#include +#include +#include +#include + +namespace TSPTW { + + /// One run owns these aggregates; clones and callbacks count repeated work. + struct Measurements { + const bool parallel; + Gecode::Support::Mutex mutex; + explicit Measurements(bool p=false) : parallel(p) {} + std::uint64_t bound_calls=0, inference_calls=0; + void add(double& total, double elapsed, std::uint64_t* calls=nullptr) { + if (parallel) mutex.acquire(); + total+=elapsed; + if (calls) (*calls)++; + if (parallel) mutex.release(); + } + double bound_ms=0, inference_ms=0, propagation_ms=0; + }; + + /// One concrete visit per search edge, with an explicit depot return. + class Transition : public Gecode::Space { + public: + enum Representation { LAZY, EAGER }; + enum BoundMode { PREFIX, MIN_ARCS }; + enum ResidualPropagation { OFF, CHEAP, UNARY }; + std::shared_ptr data; + std::vector unvisited; + int location=0, time=0, prefix_cost=0, depth=0; + bool returned=false; + + explicit Transition(std::shared_ptr instance, + Representation representation=LAZY, + ResidualPropagation propagation=OFF, + std::shared_ptr measurements=nullptr); + Transition(Transition& s); + Gecode::Space* copy(void) override { return new Transition(*this); } + void constrain(const Gecode::Space& s) override; + std::vector tour(void) const; + int cost(void) const { return prefix_cost; } + int allocated_positions(void) const { + return representation==LAZY ? lazy.known_length() : eager.size(); + } + Gecode::IntVar position(int p) const { + return representation==LAZY ? lazy[p] : eager[p]; + } + std::vector actions(void) const { + std::vector result; + if (returned) return result; + if (unvisited.empty()) { + if (applicable(0)) result.push_back(0); + } else { + for (int node : unvisited) + if (applicable(node)) result.push_back(node); + } + return result; + } + std::int64_t bound(BoundMode mode) const; + /// Equal residual tours share a key; the predicate checks resource dominance. + static std::size_t dominance_key(const Gecode::Space& s0) { + const auto& s=static_cast(s0); + std::size_t key=static_cast(s.location); + for (int node : s.unvisited) Gecode::cmb_hash(key,node); + return key; + } + /// Same residual tour, no later arrival, and no greater travel cost. + static bool dominates(const Gecode::Space& a0, const Gecode::Space& b0) { + const auto& a=static_cast(a0); + const auto& b=static_cast(b0); + return a.data==b.data && a.location==b.location && + a.unvisited==b.unvisited && a.returned==b.returned && + a.time<=b.time && a.prefix_cost<=b.prefix_cost; + } +#ifdef GECODE_HAS_EXPERIMENTAL + static Gecode::Search::HeuristicGuidance + guidance(BoundMode mode=PREFIX, + std::shared_ptr measurements=nullptr) { + return {Gecode::Search::HeuristicGuidance::MINIMIZE, + [mode,measurements](const Gecode::Space& s)->std::int64_t { + const auto start=std::chrono::steady_clock::now(); + const auto result=static_cast(s).bound(mode); + if (measurements) { + measurements->add(measurements->bound_ms, + std::chrono::duration( + std::chrono::steady_clock::now()-start).count(), + &measurements->bound_calls); + } + return result; + }}; + } + +#endif + + private: + class VisitBrancher; + Representation representation; + ResidualPropagation propagation; + std::shared_ptr> distance; + std::shared_ptr measurements; + std::vector residual_actions; + Gecode::OpenIntVarList lazy; + Gecode::IntVarArray eager; + int ceiling; + + bool applicable(int node) const { + return time+data->arc(location,node)<=data->due[node] && + prefix_cost+data->arc(location,node) actions; + VisitChoice(const Gecode::Brancher& b, int p, std::vector a) + : Choice(b,static_cast(a.size())), + position(p), actions(std::move(a)) {} + void archive(Gecode::Archive& e) const override { + Choice::archive(e); + e << position << static_cast(actions.size()); + for (int node : actions) e << node; + } + }; + public: + VisitBrancher(Gecode::Home home) : Brancher(home) {} + VisitBrancher(Gecode::Space& home, VisitBrancher& b) : Brancher(home,b) {} + bool status(const Gecode::Space& home) const override { + return !static_cast(home).returned; + } + void prepare(Gecode::Space& home, Gecode::StatusStatistics& stat) override { + auto& s = static_cast(home); + (void) s.materialize(s.depth); + (void) s.status(stat); + } + const Gecode::Choice* choice(Gecode::Space& home) override { + const auto& s=static_cast(home); + return new VisitChoice(*this,s.depth,s.actions()); + } + const Gecode::Choice* choice(const Gecode::Space&, + Gecode::Archive& e) override { + int position; + unsigned int count; + e >> position >> count; + std::vector actions(count); + for (int& node : actions) e >> node; + return new VisitChoice(*this,position,std::move(actions)); + } + Gecode::ExecStatus commit(Gecode::Space& home, const Gecode::Choice& c, + unsigned int alternative) override { + auto& s=static_cast(home); + const auto& choice=static_cast(c); + const int node=choice.actions[alternative]; + const auto customer=std::find(s.unvisited.begin(),s.unvisited.end(),node); + if (s.returned || choice.position!=s.depth || !s.applicable(node) || + (node==0 ? !s.unvisited.empty() : customer==s.unvisited.end())) + return Gecode::ES_FAILED; + // BAB replay can skip prepare for historical choices. Allocating the + // recorded position has no visit effects, just as in prepare. + Gecode::IntVar next=s.materialize(choice.position); + if (home.failed() || Gecode::me_failed(Gecode::Int::IntView(next).eq(home,node))) + return Gecode::ES_FAILED; + const int arc=s.data->arc(s.location,node); + s.time=std::max(s.time+arc,s.data->ready[node]); + s.prefix_cost+=arc; + s.location=node; + s.depth++; + if (node==0) { + s.returned=true; + if (s.representation==LAZY) s.lazy.close(home); + } else { + s.unvisited.erase(customer); + } + // Rebuild from the new residual state, including historical BAB replay. + if (!s.refresh_inference() || (!s.returned && s.actions().empty())) + return Gecode::ES_FAILED; + return home.failed() ? Gecode::ES_FAILED : Gecode::ES_OK; + } + void print(const Gecode::Space&, const Gecode::Choice& c, + unsigned int alternative, std::ostream& out) const override { + const auto& choice=static_cast(c); + out << "visit[" << choice.position << "] = " << choice.actions[alternative]; + } + Gecode::Actor* copy(Gecode::Space& home) override { + return new (home) VisitBrancher(home,*this); + } + size_t dispose(Gecode::Space& home) override { + (void) Brancher::dispose(home); + return sizeof(*this); + } + }; + + inline Transition::Transition(std::shared_ptr instance, + Representation representation0, + ResidualPropagation propagation0, + std::shared_ptr measurements0) + : data(std::move(instance)), unvisited(data->n-1), + representation(representation0), propagation(propagation0), + measurements(std::move(measurements0)), ceiling(data->cost_limit+1) { + std::iota(unvisited.begin(),unvisited.end(),1); + if (propagation!=OFF) + distance=std::make_shared>( + shortest_paths(*data)); + if (representation==LAZY) + lazy=Gecode::OpenIntVarList(*this, + [](Gecode::Space& home, int) { + const auto& s=static_cast(home); + return Gecode::IntVar(home,0,s.data->n-1); + },data->n); + else + eager=Gecode::IntVarArray(*this,data->n,0,data->n-1); + (void) new (*this) VisitBrancher(*this); + if (!refresh_inference() || actions().empty()) fail(); + } + + inline Transition::Transition(Transition& s) + : Space(s), data(s.data), unvisited(s.unvisited), location(s.location), + time(s.time), prefix_cost(s.prefix_cost), depth(s.depth), + returned(s.returned), representation(s.representation), + propagation(s.propagation), distance(s.distance), measurements(s.measurements), + residual_actions(s.residual_actions), ceiling(s.ceiling) { + if (representation==LAZY) lazy.update(*this,s.lazy); + else eager.update(*this,s.eager); + } + + inline std::int64_t Transition::bound(BoundMode mode) const { + if (returned || mode==PREFIX) return prefix_cost; + if (unvisited.empty()) return add_cost(prefix_cost,data->arc(location,0)); + std::int64_t incoming=0, outgoing=0; + int enter=std::numeric_limits::max(); + int returning=std::numeric_limits::max(); + for (int u : unvisited) { + enter=std::min(enter,data->arc(location,u)); + returning=std::min(returning,data->arc(u,0)); + int leave=data->arc(u,0), arrive=data->arc(location,u); + for (int v : unvisited) if (u!=v) { + leave=std::min(leave,data->arc(u,v)); + arrive=std::min(arrive,data->arc(v,u)); + } + outgoing=add_cost(outgoing,leave); + incoming=add_cost(incoming,arrive); + } + outgoing=add_cost(outgoing,enter); + incoming=add_cost(incoming,returning); + return add_cost(prefix_cost,std::max(incoming,outgoing)); + } + + /// Every residual tour induces nonoverlapping minimum-outgoing intervals. + class ResidualSchedule : public Gecode::Space { + public: + Gecode::IntVarArray starts; + ResidualSchedule(const Instance& data, const std::vector& unvisited, + int location, int time, + const std::vector& distance) + : starts(*this,static_cast(unvisited.size()),0,data.time_limit) { + Gecode::IntArgs durations(starts.size()); + for (int k=0; k(data.ready[u], + add_cost(time,distance[location*data.n+u])); + const auto upper=std::min(data.due[u], + std::min(data.due[0]-distance[u*data.n], + data.due[0]-duration)); + if (lower>upper) { fail(); return; } + Gecode::dom(*this,starts[k],static_cast(lower),static_cast(upper)); + } + Gecode::unary(*this,starts,durations,Gecode::IPL_DEF); + } + ResidualSchedule(ResidualSchedule& s) : Space(s) { + starts.update(*this,s.starts); + } + Gecode::Space* copy(void) override { return new ResidualSchedule(*this); } + }; + + inline bool Transition::refresh_inference(void) { + if (propagation==OFF) return true; + const auto start=std::chrono::steady_clock::now(); + residual_actions.clear(); + bool feasible=true; + if (!returned && unvisited.empty()) { + if (time+data->arc(location,0)<=data->due[0]) residual_actions.push_back(0); + else feasible=false; + } else if (!returned && propagation==CHEAP) { + // Shortest paths may use unavailable vertices: this weakens the check + // but keeps it necessary for asymmetric and nonmetric travel matrices. + for (int u : unvisited) { + const auto earliest=std::max(data->ready[u], + add_cost(time,(*distance)[location*data->n+u])); + if (earliest>data->due[u] || + add_cost(earliest,(*distance)[u*data->n])>data->due[0]) { + feasible=false; + break; + } + } + } else if (!returned) { + ResidualSchedule schedule(*data,unvisited,location,time,*distance); + const auto propagation_start=std::chrono::steady_clock::now(); + feasible=schedule.status()!=Gecode::SS_FAILED; + if (measurements) + measurements->add(measurements->propagation_ms, + std::chrono::duration( + std::chrono::steady_clock::now()-propagation_start).count()); + if (feasible) + for (std::size_t k=0; karc(location,u),data->ready[u]); + if (schedule.starts[static_cast(k)].in(earliest)) + residual_actions.push_back(u); + } + } + if (measurements) { + measurements->add(measurements->inference_ms, + std::chrono::duration( + std::chrono::steady_clock::now()-start).count(), + &measurements->inference_calls); + } + return feasible; + } + + + inline void Transition::constrain(const Gecode::Space& s0) { + ceiling=std::min(ceiling,static_cast(s0).cost()); + if (cost()>=ceiling || (!returned && actions().empty())) fail(); + } + + inline std::vector Transition::tour(void) const { + if (!returned || depth!=data->n) + throw std::runtime_error("incomplete transition solution"); + std::vector result; + for (int p=0; p + * + * 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 "tsptw-data.hh" +#include "tsptw-transition.hh" +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace Gecode; + +// A costed circuit with a departure-to-return timing chain. Time slack +// remains unbranched: a fixed route has an earliest feasible schedule. +class TSPTWClosed : public Space { +public: + std::shared_ptr data; + IntVarArray successor, arc_cost, visit; + IntVar departure, returning, total; + + explicit TSPTWClosed(std::shared_ptr instance, + IntPropLevel circuit_propagation, bool size_branching) + : data(std::move(instance)), + successor(*this,data->n,0,data->n-1), + arc_cost(*this,data->n,0,data->cost_limit), + visit(*this,data->n-1,0,data->time_limit), + departure(*this,0,0), returning(*this,0,data->due[0]), + total(*this,0,data->cost_limit) { + const int n=data->n; + // Direct arcs need not bound paths in nonmetric data. Shortest paths + // relax the tour and give sound elapsed-time bounds in both directions. + const auto shortest=TSPTW::shortest_paths(*data); + std::vector lower(n,0), upper(n,data->due[0]); + for (int i=1; i(data->ready[i],shortest[i]); + const long long hi=std::min(data->due[i], + data->due[0]-shortest[i*n]); + if (lo>hi) { fail(); return; } + lower[i]=static_cast(lo); + upper[i]=static_cast(hi); + dom(*this,visit[i-1],lower[i],upper[i]); + } + circuit(*this,IntArgs(n*n,data->cost.data()),successor,arc_cost,total, + circuit_propagation); + for (int i=0; iarc(i,j); + if (static_cast(lower[i])+cost>upper[j]) { + rel(*this,successor[i],IRT_NQ,j); + continue; + } + BoolVar edge(*this,0,1); + rel(*this,successor[i],IRT_EQ,j,Reify(edge,RM_EQV)); + const IntVar from=i==0 ? departure : visit[i-1]; + const IntVar to=j==0 ? returning : visit[j-1]; + linear(*this,IntArgs({1,-1}),IntVarArgs({from,to}),IRT_LQ,-cost, + Reify(edge,RM_IMP),IPL_BND); + } + } + branch(*this,arc_cost,size_branching ? INT_VAR_SIZE_MIN() : + INT_VAR_REGRET_MAX_MAX(),INT_VAL_MIN()); + branch(*this,successor,INT_VAR_SIZE_MIN(),INT_VAL_MIN()); + } + + TSPTWClosed(TSPTWClosed& s) : Space(s), data(s.data) { + successor.update(*this,s.successor); + arc_cost.update(*this,s.arc_cost); + visit.update(*this,s.visit); + departure.update(*this,s.departure); + returning.update(*this,s.returning); + total.update(*this,s.total); + } + Space* copy(void) override { return new TSPTWClosed(*this); } + int cost(void) const { return total.val(); } + static Search::HeuristicGuidance guidance(void) { + return {Search::HeuristicGuidance::MINIMIZE, + [](const Space& s)->std::int64_t { + return static_cast(s).total.min(); + }}; + } + void constrain(const Space& s0) override { + const auto& s=static_cast(s0); + rel(*this,total,IRT_LE,s.total.val()); + } + std::vector tour(void) const { + std::vector result; + int node=0; + for (int i=1; in; i++) { + node=successor[node].val(); + if (node==0) throw std::runtime_error("incomplete circuit solution"); + result.push_back(node); + } + if (successor[node].val()!=0) + throw std::runtime_error("circuit solution does not return to depot"); + return result; + } +}; + +struct TourValue { + int cost, return_time; +}; + +// Check concrete routes independently of the CP timing and cost variables. +static TourValue evaluate_tour(const TSPTW::Instance& data, + const std::vector& tour) { + if (tour.size()!=static_cast(data.n-1)) + throw std::runtime_error("tour omits customers or has extra visits"); + std::vector seen(data.n,false); + for (int node : tour) { + if (node<=0 || node>=data.n || seen[node]) + throw std::runtime_error("tour has a duplicate or invalid customer"); + seen[node]=true; + } + long long time=0, cost=0; + int previous=0; + for (std::size_t i=0; i<=tour.size(); i++) { + const int node=i==tour.size() ? 0 : tour[i]; + const int arc=data.arc(previous,node); + time=std::max(time+arc,data.ready[node]); + cost+=arc; + if (time>data.due[node]) + throw std::runtime_error(node==0 ? "late return" : "late customer"); + previous=node; + } + // The reader bounds all n-arc tours and waiting-plus-travel arithmetic. + return {static_cast(cost),static_cast(time)}; +} + +static std::vector read_tour(const std::string& path) { + std::ifstream input(path); + if (!input) throw std::runtime_error("cannot open seed tour: "+path); + std::vector tour; + std::string token; + while (input>>token) { + if (token.find_first_not_of("0123456789")!=std::string::npos) + throw std::runtime_error("invalid seed tour IDs"); + tour.push_back(std::stoi(token)); + } + if (input.bad()) throw std::runtime_error("cannot read seed tour"); + return tour; +} + +static std::unique_ptr +make_seed(TSPTWClosed& root, const std::vector& tour, TourValue value) { + if (root.status()==SS_FAILED) + throw std::runtime_error("checked seed rejected by CP root"); + std::unique_ptr seed(static_cast(root.clone())); + int previous=0; + for (int node : tour) { + rel(*seed,seed->successor[previous],IRT_EQ,node); + previous=node; + } + rel(*seed,seed->successor[previous],IRT_EQ,0); + if (seed->status()!=SS_SOLVED || seed->total.val()!=value.cost) + throw std::runtime_error("checked seed rejected by CP model"); + return seed; +} + +static std::unique_ptr +make_seed(TSPTW::Transition& root, const std::vector& tour, TourValue value) { + if (root.status() == SS_FAILED) + throw std::runtime_error("checked seed rejected by transition root"); + std::unique_ptr seed( + static_cast(root.clone())); + Search::Statistics stat; + // Replay checked customer IDs and then the explicit return using the same + // decisions as search, without directly installing residual state. + for (std::size_t p=0; p<=tour.size(); p++) { + const int node = p == tour.size() ? 0 : tour[p]; + if (seed->status() == SS_BRANCH) seed->prepare(stat); + if (seed->status() != SS_BRANCH) + throw std::runtime_error("checked seed rejected by transition model"); + const auto actions = seed->actions(); + const auto action = std::find(actions.begin(),actions.end(),node); + if (action == actions.end()) + throw std::runtime_error("checked seed has no applicable transition"); + std::unique_ptr choice(seed->choice()); + seed->commit(*choice,static_cast(action-actions.begin())); + } + if (seed->status() != SS_SOLVED || seed->cost() != value.cost) + throw std::runtime_error("checked seed rejected by transition model"); + return seed; +} + +static std::string exact_text(int value, int scale) { + std::string text=std::to_string(value/scale); + if (value%scale) { + std::string fraction=std::to_string(scale+value%scale).substr(1); + while (fraction.back()=='0') fraction.pop_back(); + text+='.'+fraction; + } + return text; +} + +static void write_tour(const std::vector& tour, TourValue value, int scale) { + std::cout << "\"tour\":["; + for (std::size_t i=0; i time_ms, node_limit; + Search::CABSOptions cabs; + unsigned int threads=1; + bool dominance=true, dominance_set=false; + bool width_set=false, frontier_set=false; + bool closed_settings_set=false; +}; + +static unsigned long long read_limit(const std::string& text) { + if (text.empty() || text.find_first_not_of("0123456789")!=std::string::npos) + throw std::runtime_error("limits must be nonnegative integers"); + return std::stoull(text); +} + +static RunOptions read_options(int argc, char* argv[]) { + if (argc<2) + throw std::runtime_error("usage: tsptw INSTANCE [--engine bab|rbs|astar|cabs] " + "[--formulation closed|transition|eager] " + "[--bound prefix|min-arcs] [--residual-propagation off|cheap|unary] " + "[--circuit-propagation dom|val] [--cost-branching regret|size] " + "[--threads N] [--seed TOUR] [--time-ms N] [--node-limit N] " + "[--max-width N] [--frontier-limit N] [--dominance off|on]"); + RunOptions options; + options.instance=argv[1]; + for (int i=2; istd::numeric_limits::max()) + throw std::runtime_error("threads must be a positive integer"); + options.threads=static_cast(n); + } + else if (option=="--max-width" || option=="--frontier-limit") { + const auto limit=read_limit(value); + if (limit>std::numeric_limits::max()) + throw std::runtime_error("search limit is too large"); + if (option=="--max-width") { + options.cabs.max_width=limit; options.width_set=true; + } else { + options.cabs.frontier_limit=limit; options.frontier_set=true; + } + } else throw std::runtime_error("unknown option: "+option); + } + if (options.engine!="bab" && options.engine!="astar" && + options.engine!="cabs" && options.engine!="rbs") + throw std::runtime_error("engine must be bab, rbs, astar, or cabs"); + if (options.formulation!="closed" && options.formulation!="transition" && + options.formulation!="eager") + throw std::runtime_error("formulation must be closed, transition, or eager"); + if (options.circuit_propagation!="dom" && options.circuit_propagation!="val") + throw std::runtime_error("circuit propagation must be dom or val"); + if (options.cost_branching!="regret" && options.cost_branching!="size") + throw std::runtime_error("cost branching must be regret or size"); + if (options.formulation!="closed" && + (options.closed_settings_set || options.engine=="rbs")) + throw std::runtime_error("circuit/branching settings and rbs require closed"); + if (options.bound!="prefix" && options.bound!="min-arcs") + throw std::runtime_error("bound must be prefix or min-arcs"); + if (options.residual_propagation!="off" && options.residual_propagation!="cheap" && + options.residual_propagation!="unary") + throw std::runtime_error("residual propagation must be off, cheap or unary"); + if (options.formulation=="closed" && + (options.bound!="prefix" || options.residual_propagation!="off")) + throw std::runtime_error("bound and residual propagation switches require transition/eager"); + if ((options.width_set || options.frontier_set) && options.engine!="cabs") + throw std::runtime_error("width and frontier limits require cabs"); + if (options.dominance_set && + (options.engine!="cabs" || options.formulation=="closed")) + throw std::runtime_error("dominance requires transition/eager cabs"); + return options; +} + +class LimitsStop : public Search::Stop { + std::unique_ptr time, nodes; +public: + explicit LimitsStop(const RunOptions& options) { + if (options.time_ms) time.reset(Search::Stop::time(*options.time_ms)); + if (options.node_limit) nodes.reset(Search::Stop::node(*options.node_limit)); + } + bool stop(const Search::Statistics& s, const Search::Options& o) override { + return (time && time->stop(s,o)) || (nodes && nodes->stop(s,o)); + } +}; + +template +static int run_search(const RunOptions& options, Model& root, + const Search::Options& search, + std::chrono::steady_clock::time_point start, + std::shared_ptr measurements=nullptr) { + auto elapsed=[start]() { + return std::chrono::duration( + std::chrono::steady_clock::now()-start).count(); + }; + const auto& data=root.data; + std::optional incumbent; + std::vector best_tour; + if (!options.seed.empty()) { + best_tour=read_tour(options.seed); + incumbent=evaluate_tour(*data,best_tour); + } + std::unique_ptr seed; + if (incumbent) seed=make_seed(root,best_tour,*incumbent); + int root_positions = 0, prepared_positions = 0, solution_positions = 0; + if constexpr (std::is_same::value) { + if (seed) + solution_positions = seed->allocated_positions(); + root_positions = root.allocated_positions(); + if (root.status() == SS_BRANCH) { + Search::Statistics stat; + // Record the first prepared prefix without committing a visit. + (void) root.prepare(stat); + } + prepared_positions = root.allocated_positions(); + } + const auto guidance=[&]() { + if constexpr (std::is_same::value) + return Model::guidance(options.bound=="prefix" ? Model::PREFIX : Model::MIN_ARCS, + measurements); + else return Model::guidance(); + }(); + const double preprocessing_ms=elapsed(); + const auto search_start=std::chrono::steady_clock::now(); + std::unique_ptr engine; + if (options.engine=="bab") engine.reset(Search::babengine(&root,search)); + else if (options.engine=="rbs") { + std::unique_ptr builder(rbs(search)); + engine.reset((*builder)(&root)); + } else if (options.engine=="astar") + engine.reset(Search::astarengine(&root,guidance,search)); + else { + auto cabs=options.cabs; + if constexpr (std::is_same::value) + if (options.dominance) { + cabs.dominates=Model::dominates; + cabs.dominance_key=Model::dominance_key; + } + engine.reset(Search::cabsengine(&root,guidance,search,cabs)); + } + // Default clone ownership keeps root and seed independently owned here. + // The stop object was declared first and outlives every engine. + if (seed) engine->constrain(*seed); + std::cout << std::fixed << std::setprecision(3); + if (incumbent) { + std::cout << "{\"type\":\"solution\","; + write_tour(best_tour,*incumbent,data->scale); + std::cout << ",\"elapsed_ms\":" << elapsed() << '}' << std::endl; + } + while (std::unique_ptr solution{ + static_cast(engine->next())}) { + auto tour=solution->tour(); + const TourValue value=evaluate_tour(*data,tour); + if (value.cost!=solution->cost() || (incumbent && value.cost>=incumbent->cost)) + throw std::runtime_error("invalid or nonimproving model solution"); + incumbent=value; + best_tour=std::move(tour); + if constexpr (std::is_same::value) + solution_positions=solution->allocated_positions(); + std::cout << "{\"type\":\"solution\","; + write_tour(best_tour,value,data->scale); + std::cout << ",\"elapsed_ms\":" << elapsed() << '}' << std::endl; + } + const double search_ms=std::chrono::duration( + std::chrono::steady_clock::now()-search_start).count(); + const auto statistics=engine->statistics(); + const char* termination=engine->stopped() ? "stopped" : + incumbent ? "optimal" : "infeasible"; + std::cout << "{\"type\":\"summary\",\"engine\":\"" << options.engine + << "\",\"formulation\":\"" << options.formulation + << "\",\"bound\":\"" << (options.formulation=="closed" ? "cp-total" : options.bound) + << "\",\"residual_propagation\":\"" << options.residual_propagation + << "\",\"termination\":\"" << termination << '"'; + std::cout << ",\"threads\":" << options.threads; + if (options.formulation=="closed") + std::cout << ",\"circuit_propagation\":\"" << options.circuit_propagation + << "\",\"cost_branching\":\"" << options.cost_branching << '"'; + if (options.engine=="rbs") + std::cout << ",\"restart_cutoff\":\"luby\",\"restart_scale\":1000" + << ",\"nogoods_limit\":128"; + if (options.engine=="cabs" && options.formulation!="closed") + std::cout << ",\"dominance\":\"" << (options.dominance ? "on" : "off") << '"'; + std::cout << ",\"incumbent\":"; + if (incumbent) { + std::cout << '{'; + write_tour(best_tour,*incumbent,data->scale); + std::cout << '}'; + } else std::cout << "null"; + if constexpr (std::is_same::value) { + std::cout << ",\"allocation\":{\"space_bytes\":" << sizeof(Model) + << ",\"intvar_handle_bytes\":" << sizeof(IntVar) + << ",\"root_positions\":" << root_positions + << ",\"prepared_root_positions\":" << prepared_positions + << ",\"solution_positions\":" << solution_positions << '}'; + std::cout << ",\"measurements\":{\"bound_calls\":" << measurements->bound_calls + << ",\"bound_ms\":" << measurements->bound_ms + << ",\"inference_calls\":" << measurements->inference_calls + << ",\"inference_ms\":" << measurements->inference_ms + << ",\"propagation_ms\":" << measurements->propagation_ms << '}'; + } + std::cout << ",\"preprocessing_ms\":" << preprocessing_ms + << ",\"search_ms\":" << search_ms + << ",\"elapsed_ms\":" << elapsed() << ",\"statistics\":{\"node\":" + << statistics.node << ",\"fail\":" << statistics.fail + << ",\"depth\":" << statistics.depth << ",\"propagate\":" + << statistics.propagate << ",\"restart\":" << statistics.restart + << ",\"nogood\":" << statistics.nogood << "}}\n"; + return 0; +} + +static int run(const RunOptions& options) { + const auto start=std::chrono::steady_clock::now(); + LimitsStop stop(options); + auto data=std::make_shared(TSPTW::read(options.instance)); + Search::Options search; + search.clone=true; + search.threads=options.threads; + search.stop=&stop; + if (options.engine=="rbs") { + search.cutoff=Search::Cutoff::luby(1000); + search.nogoods_limit=128; + } + if (options.formulation=="closed") { + TSPTWClosed root(data,options.circuit_propagation=="dom" ? IPL_DOM : IPL_VAL, + options.cost_branching=="size"); + return run_search(options,root,search,start); + } + auto measurements=std::make_shared(options.threads>1); + TSPTW::Transition root(data,options.formulation=="transition" ? + TSPTW::Transition::LAZY : TSPTW::Transition::EAGER, + options.residual_propagation=="off" ? + TSPTW::Transition::OFF : options.residual_propagation=="cheap" ? + TSPTW::Transition::CHEAP : TSPTW::Transition::UNARY, + measurements); + return run_search(options,root,search,start,measurements); +} + +int main(int argc, char* argv[]) { + try { + return run(read_options(argc,argv)); + } catch (const std::exception& error) { + std::cerr << error.what() << '\n'; + return 1; + } +} diff --git a/gecode.m4 b/gecode.m4 index b236a906a5..12db3d586d 100755 --- a/gecode.m4 +++ b/gecode.m4 @@ -1382,6 +1382,22 @@ AC_DEFUN([AC_GECODE_GIST], ]) dnl +dnl Experimental preview APIs are opt-in. +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 Macro: dnl AC_GECODE_CBS dnl diff --git a/gecode/kernel/core.cpp b/gecode/kernel/core.cpp index c5ea98eb78..f3a8425c8c 100644 --- a/gecode/kernel/core.cpp +++ b/gecode/kernel/core.cpp @@ -565,6 +565,19 @@ namespace Gecode { } + void + Brancher::prepare(Space&, StatusStatistics&) {} + + void + Space::prepare(StatusStatistics& stat) { + while (!failed() && b_status != Brancher::cast(&bl)) { + Brancher* active = b_status; + active->prepare(*this,stat); + if (status(stat) != SS_BRANCH || b_status == active) + return; + } + } + const Choice* Space::choice(void) { if (!stable()) diff --git a/gecode/kernel/core.hpp b/gecode/kernel/core.hpp index 2f8b79326a..5d63c1f703 100755 --- a/gecode/kernel/core.hpp +++ b/gecode/kernel/core.hpp @@ -145,6 +145,7 @@ namespace Gecode { class AFC; class Choice; class Brancher; + class StatusStatistics; class Group; class PropagatorGroup; class BrancherGroup; @@ -1487,6 +1488,17 @@ namespace Gecode { * */ virtual bool status(const Space& home) const = 0; + /** + * Prepare a choice before the search path changes. Preparation may leave + * the space failed, solved, or branching. Space::prepare propagates and + * prepares a newly active brancher. + * + * The space must be stable, and its most recent status query must have + * selected this brancher. Path recomputation can replay commits without + * calling prepare; commit must reconstruct any required preparation effects. + */ + GECODE_KERNEL_EXPORT + virtual void prepare(Space& home, StatusStatistics& stat); /** * \brief Return choice * @@ -1502,6 +1514,8 @@ namespace Gecode { * * The current brancher in the space \a home performs a commit from * the information provided by the choice \a c and the alternative \a a. + * Path recomputation can call this method without calling prepare first. + * The commit must reconstruct any preparation effects needed by the choice. */ virtual ExecStatus commit(Space& home, const Choice& c, unsigned int a) = 0; @@ -2219,6 +2233,18 @@ namespace Gecode { */ GECODE_KERNEL_EXPORT SpaceStatus status(StatusStatistics& stat); + /** + * \brief Prepare the current brancher before a search engine changes its path + * + * The space must be stable with a current status from Space::status. + * Preparation propagates any changes and prepares a newly active brancher. + * Built-in search engines invoke preparation automatically when experimental + * search is enabled. Callers can also invoke this method explicitly. + * Path recomputation can replay commits without calling this method; + * brancher commits must reconstruct any required preparation effects. + */ + GECODE_KERNEL_EXPORT + void prepare(StatusStatistics& stat); /// Query space status without collecting statistics SpaceStatus status(void); @@ -2299,6 +2325,8 @@ namespace Gecode { * Note that no propagation is performed (to support path * recomputation), in order to perform propagation the member * function status must be used. + * Recomputation can replay commits without calling Space::prepare; + * brancher commits must reconstruct any preparation effects they require. * * Committing with choices must be carried * out in the same order as the choices have been @@ -2338,6 +2366,8 @@ namespace Gecode { * Note that no propagation is performed (to support path * recomputation), in order to perform propagation the member * function status must be used. + * Recomputation can replay commits without calling Space::prepare; + * brancher commits must reconstruct any preparation effects they require. * * Committing with choices must be carried * out in the same order as the choices have been diff --git a/gecode/kernel/memory/manager.hpp b/gecode/kernel/memory/manager.hpp index 8b138130b0..49eed2a305 100644 --- a/gecode/kernel/memory/manager.hpp +++ b/gecode/kernel/memory/manager.hpp @@ -198,8 +198,18 @@ namespace Gecode { namespace Kernel { forceinline HeapChunk* SharedMemory::alloc(size_t s, size_t l) { + // The cache is optional: use the heap directly when its mutex is busy. + Support::Mutex& mutex = m(); + if (!mutex.tryacquire()) { + HeapChunk* hc = static_cast(Gecode::heap.ralloc(s)); + hc->size = s; + return hc; + } // To protect from exceptions from heap.ralloc() - Support::Lock guard(m()); + struct Unlock { + Support::Mutex& mutex; + ~Unlock(void) { mutex.release(); } + } guard{mutex}; while ((heap.hc != nullptr) && (heap.hc->size < l)) { heap.n_hc--; HeapChunk* hc = heap.hc; @@ -220,13 +230,18 @@ namespace Gecode { namespace Kernel { } forceinline void SharedMemory::free(HeapChunk* hc) { - Support::Lock guard(m()); + Support::Mutex& mutex = m(); + if (!mutex.tryacquire()) { + Gecode::heap.rfree(hc); + return; + } if (heap.n_hc == MemoryConfig::n_hc_cache) { Gecode::heap.rfree(hc); } else { heap.n_hc++; hc->next = heap.hc; heap.hc = hc; } + mutex.release(); } diff --git a/gecode/search.hh b/gecode/search.hh index 13dfd9eec3..5fd9d67cf5 100755 --- a/gecode/search.hh +++ b/gecode/search.hh @@ -47,6 +47,12 @@ #include #include #include +#include +#include +#include +#include +#include +#include #include @@ -990,7 +996,7 @@ namespace Gecode { namespace Search { */ class GECODE_SEARCH_EXPORT Engine : public HeapAllocated { protected: - /// Control retained for the lifetime of a leaf engine + /// Control shared for the lifetime of a leaf engine WorkerControl worker_control; /// Construct a meta engine without worker control Engine(void); @@ -1152,6 +1158,320 @@ namespace Gecode { #include +#ifdef GECODE_HAS_EXPERIMENTAL +namespace Gecode { namespace Search { + + /** + * \brief Read-only guidance for scalar integer heuristic search + * + * The bound is an optimistic total objective value on a stable Space: + * a lower bound for minimization, an upper bound for maximization, and + * the exact objective on a solution. It must agree with the strict + * improvement restriction in Space::constrain. The engine adds no prefix cost. + * Values are signed 64-bit integers compared directly; callback arithmetic + * must stay in range. Engines define how they use the optional ranking + * (lower first); rank can be arbitrary and never justifies pruning. + * Guidance is copied into engines and portfolio builders. Callbacks read + * only the supplied Space and must not post constraints or capture another + * Space's local state. Captured dependencies must outlive every engine and + * builder using them. With multiple threads, callbacks can run concurrently; + * captured shared state and model copy/prepare operations must be thread-safe. + * \ingroup TaskModelSearch + */ + class HeuristicGuidance { + public: + /// Objective direction + enum Sense { MINIMIZE, MAXIMIZE }; + /// Evaluate a propagated stable space + typedef std::function Evaluate; + /// Objective direction + Sense sense; + /// Optimistic total objective bound (required) + Evaluate bound; + /// Optional ranking (lower first) + Evaluate rank; + /// Initialize guidance + HeuristicGuidance(Sense sense, Evaluate bound, Evaluate rank=Evaluate()); + }; + + /// Fixed exploration order for A* search + enum AStarOrder { + BOUND_FIRST, ///< Optimistic bound, lower rank, then insertion order + RANK_FIRST ///< Lower rank, optimistic bound, then insertion order + }; + +}} + +namespace Gecode { + + /** + * \brief A* with improving solutions and a fixed exploration order + * + * Experimental 6.5.0 preview: build with GECODE_ENABLE_EXPERIMENTAL=ON + * or --enable-experimental; GECODE_HAS_EXPERIMENTAL reports availability. + * APIs may change or be removed in future feature releases. The documented + * correctness contracts still apply. + * + * Spaces must implement strict improvement constraints compatible with the + * guidance's exact scalar integer objective on solutions. A generated solution + * can precede a better one, so the first result need not be optimal. + * Continue next() until nullptr with stopped()==false + * to prove optimality of the best accepted incumbent, including external + * adoption, or infeasibility if no incumbent was accepted. After external + * adoption, no new solution does not imply infeasibility. Completeness + * requires a finite search tree and sound bounds and improvement constraints. + * Returned solutions belong to the caller, independently of the engine's + * incumbent copy. + * + * The default BOUND_FIRST order compares optimistic bound, lower rank, + * then insertion order. RANK_FIRST compares lower rank, optimistic bound, + * then insertion order. Bounds follow the objective direction; ranks are + * lower-first for either direction. Missing rank falls back to bound order. + * Both values are cached when a stable branching node enters the frontier. + * Ordering is fixed for the engine, including native reset; values are not + * rekeyed. Rank selects pending branching Spaces and never prunes. Generated + * solutions are published immediately, so neither order guarantees the first + * result is optimal or has the best rank. + * + * The frontier stores clones; c_d and a_d do not control recomputation. + * There is no hard memory guarantee and no-good extraction is empty. + * Use BAB as an optimization baseline. Retaining frontier clones can cost + * much more memory and cloning work than depth-first search. Start with one + * thread and measure a few thread counts on representative instances. + * Expensive propagation can benefit from parallel expansion, but frontier + * coordination, memory traffic, and changes in search order can outweigh it. + * More threads do not necessarily run faster. Neither AStar nor CABS + * guarantees a speedup over BAB. + * + * Options::threads selects sequential or parallel search. Parallel workers + * select the best currently queued node; concurrent expansions can change + * generation and solution order. Exhaustion waits for all expansions. + * Worker control can pause, grow, or shrink the active worker count while + * preserving pending work. Tracing is unsupported. After an ordinary stop, + * raise or reset its limit and call next() to resume. Parallel workers check + * stops after at most 32 consecutive failed or incumbent-pruned alternatives; + * worker-count requests are checked after each alternative. Statistics depth + * counts committed edges. Parallel next() waits for active model operations before + * returning; failures in workers are rethrown on the caller. + * Before generating a choice, Brancher::prepare finishes any lazy model + * preparation on the stable Space. + * The native Search::Engine::reset interface takes ownership of its root + * regardless of Options::clone, clearing pending work, incumbent, and + * statistics while retaining guidance and ordering. + * If guidance or a model operation throws, destroy the engine + * before further use. + * \ingroup TaskModelSearch + */ + template + class AStar : public Search::Base { + public: + /** + * \brief Initialize from space, guidance, and ordinary search options + * + * Options::clone=true (the default) leaves the supplied root with the + * caller. With clone=false, successful construction transfers ownership + * to the engine. Constructor failure leaves it with the caller. + */ + AStar(T* s, const Search::HeuristicGuidance& guidance, + const Search::Options& o=Search::Options::def); + /// Initialize with an explicit fixed order; ownership follows the constructor above + AStar(T* s, const Search::HeuristicGuidance& guidance, + Search::AStarOrder ordering, + const Search::Options& o=Search::Options::def); + /// Copy a compatible external solution if its exact bound strictly improves + void constrain(const Space& incumbent); + /// Whether engine does best solution search + static const bool best = true; + }; + + /** + * \brief Search to completion or a stop and return the best solution found + * + * The caller owns the result; nullptr means no solution was found. A stop + * can leave the result unproven. The helper cannot report whether search + * stopped; use AStar to inspect completion or resume search. + * Root ownership follows AStar and Options::clone. + * \ingroup TaskModelSearch + */ + template + T* astar(T* s, const Search::HeuristicGuidance& guidance, + const Search::Options& o=Search::Options::def); + + /// Return an A* builder for PBS portfolios, with copied guidance and options + template + SEB astar(const Search::HeuristicGuidance& guidance, + const Search::Options& o=Search::Options::def); + + /// Search with a fixed order; result, stop, and ownership contracts follow astar above + template + T* astar(T* s, const Search::HeuristicGuidance& guidance, + Search::AStarOrder ordering, + const Search::Options& o=Search::Options::def); + + /// Return an A* builder for PBS portfolios, with copied guidance, order, and options + template + SEB astar(const Search::HeuristicGuidance& guidance, + Search::AStarOrder ordering, + const Search::Options& o=Search::Options::def); + +} + +#include + +namespace Gecode { namespace Search { + + /// CABS resource limits and optional candidate dominance + class CABSOptions { + public: + /// Maximum permitted width (passes use widths 1, 2, 4, ...); zero is unlimited + unsigned long int max_width; + /// Pending-Space quota, including the selected parent; zero is unlimited + unsigned long int frontier_limit; + /** + * \brief Whether the first space subsumes the second; empty disables pruning + * + * Inputs are stable, read-only next-layer candidates. Returning true must + * preserve every improving completion of the second space at no worse total + * objective, including prefix cost, residual feasibility, and branching + * progress. A completion using no more transitions is sufficient. The model + * must establish that repeated replacement is safe, with a consistent, + * transitive no-worse relation; equality may use the same predicate. + * CABS does not infer these properties from bounds or ranks. + * + * Only live candidates in the same layer and pass justify pruning. A new + * dominator inherits a removed candidate's beam slot, regardless of rank. + * The engine copies the callable; captured references must outlive it. + * The predicate must not mutate either space. Parallel admission serializes + * calls to this predicate, but guidance and key callbacks may run concurrently + * with it. Shared captured state must support that use. If it throws, destroy + * the engine before further use. + */ + std::function dominates; + /** + * \brief Optional key grouping candidates for dominance comparisons + * + * Used only when dominates is set. Different keys skip comparison; equal + * keys still require the dominance predicate, so collisions are safe. + * Potential dominators must have equal keys. The engine caches the key + * when a stable branching candidate is generated. An empty callable puts + * all candidates in one group. + * + * The callable reads only its supplied Space and is copied with the options. + * Captured dependencies must outlive the engine. Parallel workers can call + * it concurrently with guidance and dominance; shared captured state must + * support that use. If it throws, destroy the engine before further use. + */ + std::function dominance_key; + /// Initialize limits, with dominance disabled + CABSOptions(unsigned long int maximum_width=0, + unsigned long int frontier_nodes=0); + }; + +}} + +namespace Gecode { + + /** + * \brief Complete anytime beam search with widening restarts + * + * Experimental 6.5.0 preview: build with GECODE_ENABLE_EXPERIMENTAL=ON + * or --enable-experimental; GECODE_HAS_EXPERIMENTAL reports availability. + * APIs may change or be removed in future feature releases. The documented + * correctness contracts still apply. + * + * Each native Choice commit advances one layer. Width starts at one and + * doubles after a completed pass if discarded bounds still permit improvement + * or no incumbent exists. A pass can finish without widening when every + * discarded bound excludes strict improvement of the accepted incumbent. + * Finite search trees are required for widening completeness. + * Lower rank selects the beam; without rank, the optimistic total bound + * selects it. Equal priorities use generation order. Admissible bounds and + * the optional CABSOptions::dominates predicate justify pruning and completion. + * + * Returned solutions belong to the caller, independently of the engine's + * incumbent copy, and strictly improve it. Continue next() until nullptr with + * stopped()==false to prove + * optimality of the best accepted incumbent, including external adoption, + * or infeasibility if none was accepted. A shallow goal can precede a deeper + * improvement; the first result need not be optimal. No new solution after + * external adoption does not imply infeasibility. Publishing a solution + * preserves unfinished passes. + * After an ordinary stop, raise or reset its limit and call next() to resume. + * Worker control also preserves continuation, including a zero-worker pause. + * Width/frontier + * limits and width overflow report stopped() until native Engine::reset. + * A complete pass can prove its result at the maximum width; a maximum of + * three allows passes at widths one and two. Native reset takes ownership + * of its root regardless of Options::clone and clears the incumbent, pending + * work, intrinsic stops, and statistics while retaining guidance and CABS options. + * + * The frontier quota includes current-layer entries, next-layer candidates, + * and selected parents. In parallel search, each unfinished expansion reserves + * a parent and an unsubmitted successor at admission; there is at most one + * temporary successor per worker. The quota excludes the restart root, + * incumbent copies, and returned solutions. It is checked when adding a beam + * slot, without a byte or process-memory guarantee. Clones store the search; + * c_d/a_d do not enable replay and no-good extraction is empty. + * The threading tradeoffs described for AStar also apply here. + * Options::threads selects sequential or parallel expansion of layer parents. + * All workers share one next-layer beam and dominance admission. A layer or + * pass advances only after all current work has been submitted. Parallel + * generation order and returned improvements can differ from sequential search. + * Stop and worker-count checks follow AStar. next() waits for active model + * operations before returning and rethrows worker failures on the caller. + * Tracing is unsupported. Guidance and strict + * improvement constraints obey HeuristicGuidance's scalar integer contract. + * Brancher preparation follows AStar. Statistics depth counts committed + * edges; restarts count widening passes. + * If guidance, dominance, a key callback, or a model operation throws, + * destroy the engine before further use. + * \ingroup TaskModelSearch + */ + template + class CABS : public Search::Base { + public: + /** + * \brief Initialize from space, guidance, ordinary options, and CABS options + * + * Options::clone=true (the default) leaves the supplied root with the + * caller. With clone=false, successful construction transfers ownership + * to the engine. Constructor failure leaves it with the caller. + */ + CABS(T* s, const Search::HeuristicGuidance& guidance, + const Search::Options& o=Search::Options::def, + const Search::CABSOptions& limits=Search::CABSOptions()); + /// Copy a compatible external solution if its exact bound strictly improves + void constrain(const Space& incumbent); + /// Whether engine does best solution search + static const bool best = true; + }; + + /** + * \brief Search to completion or a stop and return the best solution found + * + * The caller owns the result; nullptr means no solution was found. Ordinary + * stops or beam limits can leave the result unproven. The helper cannot + * report whether search stopped; use CABS to inspect completion or resume + * search. Root ownership follows CABS and Options::clone. + * \ingroup TaskModelSearch + */ + template + T* cabs(T* s, const Search::HeuristicGuidance& guidance, + const Search::Options& o=Search::Options::def, + const Search::CABSOptions& limits=Search::CABSOptions()); + + /// Return a CABS builder for PBS portfolios, with copied guidance and CABS options + template + SEB cabs(const Search::HeuristicGuidance& guidance, + const Search::Options& o=Search::Options::def, + const Search::CABSOptions& limits=Search::CABSOptions()); + +} + +#include + +#endif + namespace Gecode { /** @@ -1349,6 +1669,24 @@ 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 enabled, BAB, AStar, and CABS builders can share + * improving solutions in one portfolio. The model must install branching + * in slave() or override master() to retain it: Space::master removes + * branchers for portfolios by default. For compatible model and guidance: + * \code + * Search::Options asset; + * asset.threads = 1; + * SEBs assets{bab(asset), astar(guidance,asset), + * cabs(guidance,asset)}; + * Search::Options portfolio; + * portfolio.threads = 3; + * PBS engine(model,assets,portfolio); + * \endcode + * These settings admit all three assets with one thread each. Asset + * selection and any policy for changing worker counts belong to the caller. */ 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..c409a4b3b8 --- /dev/null +++ b/gecode/search/astar.cpp @@ -0,0 +1,227 @@ +/* -*- 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 +#include + +namespace Gecode { namespace Search { + + Engine* + astarengine(Space* s, const HeuristicGuidance& g, const Options& o) { + return bestfirstengine(s,g,BOUND_FIRST,o); + } + + Engine* + bestfirstengine(Space* s, const HeuristicGuidance& g, AStarOrder ordering, + const Options& o) { + Options expanded = o.expand(); + if (expanded.tracer != nullptr) + throw UnsupportedAStarTracer("AStar"); + if (!g.bound) + throw UninitializedAStarBound("AStar"); +#ifdef GECODE_HAS_THREADS + if (expanded.threads != 1.0) + return new Par::Frontier(s,g,ordering,expanded,false); +#endif + return new WorkerToEngine(s,expanded, + Seq::AStar::Configuration{g,ordering}); + } + +}} + +namespace Gecode { namespace Search { namespace Seq { + + bool + AStar::Later::operator()(const Entry& a, const Entry& b) const { + if (ordering == RANK_FIRST && a.rank != b.rank) + return a.rank > b.rank; + if (a.bound != b.bound) + return sense == HeuristicGuidance::MINIMIZE ? + a.bound > b.bound : a.bound < b.bound; + if (a.rank != b.rank) + return a.rank > b.rank; + return a.order > b.order; + } + + AStar::AStar(Space* s, const Options& o, const Configuration& c) + : opt(o), guidance(c.guidance), ordering(c.ordering), + order(0), incumbent_revision(0), + current_status(SS_FAILED), current_depth(0), current_revision(0), + counted(false), best_bound(0) { + initialize(s,o.clone); + } + + void + AStar::initialize(Space* s, bool clone) { + // Evaluate guidance only after construction has transferred ownership. + if (s == nullptr) { + fail++; + } else { + current_status = s->status(*this); + if (current_status == SS_FAILED) { + fail++; + if (!clone) delete s; + } else { + expansion.select(std::unique_ptr(clone ? s->clone() : s)); + } + } + } + + bool + AStar::improves(std::int64_t bound) const { + return !best || (guidance.sense == HeuristicGuidance::MINIMIZE ? + bound < best_bound : bound > best_bound); + } + + void + AStar::enqueue(std::unique_ptr s, unsigned long int depth, + unsigned long long int revision) { + const std::int64_t bound = guidance.bound(*s); + if (!improves(bound)) { + fail++; + return; + } + const std::int64_t rank = guidance.rank ? guidance.rank(*s) : 0; + frontier.push_back({std::move(s),bound,rank,depth,order++,revision}); + std::push_heap(frontier.begin(),frontier.end(),Later{guidance.sense,ordering}); + } + + Space* + AStar::publish(std::unique_ptr s) { + // Delete exhausted branchers before cloning the solution. + (void) s->choice(); + const std::int64_t bound = guidance.bound(*s); + if (!improves(bound)) return nullptr; + std::unique_ptr result(s->clone()); + best = std::move(s); + best_bound = bound; + incumbent_revision++; + return result.release(); + } + + Space* + AStar::next(void) { + start(); + while (true) { + if (stop_seq(opt)) return nullptr; + if (!expansion) { + if (frontier.empty()) return nullptr; + std::pop_heap(frontier.begin(),frontier.end(),Later{guidance.sense,ordering}); + Entry entry = std::move(frontier.back()); + frontier.pop_back(); + if (!improves(entry.bound)) { + fail++; + continue; + } + expansion.select(std::move(entry.space)); + current_depth = entry.depth; + current_revision = entry.incumbent_revision; + current_status = SS_BRANCH; + counted = true; + } + if (!counted) { + node++; + stack_depth(current_depth); + counted = true; + if (current_status == SS_SOLVED) { + if (Space* solution = publish(expansion.take())) return solution; + continue; + } + if (!improves(guidance.bound(expansion.space()))) { + fail++; + expansion.reset(); + continue; + } + } + const auto child_depth = current_depth+1; + ExpansionStep step = expansion.next(*this,child_depth,current_revision, + best.get(),incumbent_revision); + if (step.state == ExpansionStep::EXHAUSTED) continue; + switch (step.status) { + case SS_FAILED: + fail++; + break; + case SS_SOLVED: + if (Space* solution = publish(std::move(step.space))) return solution; + break; + case SS_BRANCH: + enqueue(std::move(step.space),child_depth,incumbent_revision); + break; + default: + GECODE_NEVER; + } + } + } + + Statistics + AStar::statistics(void) const { + return *this; + } + + void + AStar::constrain(const Space& b) { + const std::int64_t bound = guidance.bound(b); + if (!improves(bound)) return; + std::unique_ptr incumbent(b.clone()); + best = std::move(incumbent); + best_bound = bound; + incumbent_revision++; + } + + void + AStar::reset(Space* s) { + std::unique_ptr replacement(s); + expansion.reset(); + frontier.clear(); + best.reset(); + order = 0; + incumbent_revision = current_revision = 0; + current_depth = 0; + counted = false; + Worker::reset(); + initialize(s,false); + // initialize consumes the root on normal return, including failure. + replacement.release(); + } + + NoGoods& + AStar::nogoods(void) { + return NoGoods::eng; + } + +}}} + +// STATISTICS: search-other diff --git a/gecode/search/astar.hpp b/gecode/search/astar.hpp new file mode 100644 index 0000000000..18dfe1b2db --- /dev/null +++ b/gecode/search/astar.hpp @@ -0,0 +1,124 @@ +/* -*- 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 native A* engine + GECODE_SEARCH_EXPORT Engine* + astarengine(Space* s, const HeuristicGuidance& guidance, const Options& o); + + /// Create a native best-first engine with fixed exploration order + GECODE_SEARCH_EXPORT Engine* + bestfirstengine(Space* s, const HeuristicGuidance& guidance, + AStarOrder ordering, const Options& o); + + /// A* engine builder for PBS portfolios + template + class AStarBuilder : public Builder { + HeuristicGuidance guidance; + AStarOrder ordering; + public: + AStarBuilder(const HeuristicGuidance& g, const Options& o, + AStarOrder order=BOUND_FIRST) + : Builder(o,true), guidance(g), ordering(order) {} + Engine* operator()(Space* s) const override { + return bestfirstengine(dynamic_cast(s),guidance,ordering,opt); + } + }; + + inline + HeuristicGuidance::HeuristicGuidance(Sense s, Evaluate b, Evaluate r) + : sense(s), bound(std::move(b)), rank(std::move(r)) {} + +}} + +namespace Gecode { + + template + inline + AStar::AStar(T* s, const Search::HeuristicGuidance& g, + const Search::Options& o) + : Search::Base(Search::astarengine(s,g,o)) {} + + template + inline void + AStar::constrain(const Space& incumbent) { + this->e->constrain(incumbent); + } + + template + inline T* + astar(T* s, const Search::HeuristicGuidance& g, const Search::Options& o) { + AStar engine(s,g,o); + std::unique_ptr best; + while (T* solution = engine.next()) + best.reset(solution); + return best.release(); + } + + template + inline + AStar::AStar(T* s, const Search::HeuristicGuidance& g, + Search::AStarOrder ordering, const Search::Options& o) + : Search::Base(Search::bestfirstengine(s,g,ordering,o)) {} + + template + inline T* + astar(T* s, const Search::HeuristicGuidance& g, + Search::AStarOrder ordering, const Search::Options& o) { + AStar engine(s,g,ordering,o); + std::unique_ptr best; + while (T* solution = engine.next()) + best.reset(solution); + return best.release(); + } + + template + inline SEB + astar(const Search::HeuristicGuidance& g, const Search::Options& o) { + return new Search::AStarBuilder(g,o); + } + + template + inline SEB + astar(const Search::HeuristicGuidance& g, Search::AStarOrder ordering, + const Search::Options& o) { + return new Search::AStarBuilder(g,o,ordering); + } + +} + +#endif + +// STATISTICS: search-other diff --git a/gecode/search/cabs.cpp b/gecode/search/cabs.cpp new file mode 100644 index 0000000000..d293061542 --- /dev/null +++ b/gecode/search/cabs.cpp @@ -0,0 +1,346 @@ +/* -*- 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 +#include +#include + +namespace Gecode { namespace Search { + + Engine* + cabsengine(Space* s, const HeuristicGuidance& g, const Options& o, + const CABSOptions& limits) { + Options expanded = o.expand(); + if (expanded.tracer != nullptr) + throw UnsupportedCABSTracer("CABS"); + if (!g.bound) + throw UninitializedCABSBound("CABS"); +#ifdef GECODE_HAS_THREADS + if (expanded.threads != 1.0) + return new Par::Frontier(s,g,BOUND_FIRST,expanded,true,limits); +#endif + return new WorkerToEngine(s,expanded, + Seq::CABS::Configuration{g,limits}); + } + +}} + +namespace Gecode { namespace Search { namespace Seq { + + bool + CABS::Earlier::operator()(const Entry& a, const Entry& b) const { + if (ranked) { + if (a.rank != b.rank) return a.rank < b.rank; + } else if (a.bound != b.bound) { + return sense == HeuristicGuidance::MINIMIZE ? + a.bound < b.bound : a.bound > b.bound; + } + return a.order < b.order; + } + + CABS::CABS(Space* s, const Options& o, const Configuration& c) + : opt(o), guidance(c.guidance), limits(c.limits), width(1), order(0), + incumbent_revision(0), current_status(SS_FAILED), current_depth(0), + current_revision(0), counted(false), best_bound(0), + limited(false) { + initialize(s,o.clone); + } + + void + CABS::initialize(Space* s, bool clone) { + if (s == nullptr) { + fail++; + return; + } + current_status = s->status(*this); + if (current_status == SS_FAILED) { + fail++; + if (!clone) delete s; + return; + } + // Finish all construction-time clones before taking the caller's root. + std::unique_ptr restart_root(clone ? s->clone() : nullptr); + expansion.select(std::unique_ptr( + (clone ? restart_root.get() : s)->clone())); + root.reset(clone ? restart_root.release() : s); + } + + bool + CABS::improves(std::int64_t bound) const { + return !best || (guidance.sense == HeuristicGuidance::MINIMIZE ? + bound < best_bound : bound > best_bound); + } + + void + CABS::unindex(const Entry& entry) { + if (!limits.dominates || !limits.dominance_key) return; + const auto range=candidate_index.equal_range(entry.key); + for (auto i=range.first; i!=range.second; ++i) + if (i->second == entry.space.get()) { + candidate_index.erase(i); + return; + } + GECODE_NEVER; + } + + bool + CABS::admit(std::unique_ptr s, unsigned long int depth, + unsigned long long int revision) { + const std::int64_t bound = guidance.bound(*s); + if (!improves(bound)) { + fail++; + return true; + } + const std::int64_t rank = guidance.rank ? guidance.rank(*s) : 0; + const std::size_t key=limits.dominates && limits.dominance_key ? + limits.dominance_key(*s) : 0; + Entry entry{std::move(s),bound,rank,key,depth,order++,revision}; + Earlier earlier{guidance.sense,static_cast(guidance.rank)}; + if (limits.dominates) { + std::vector victims; + if (limits.dominance_key) { + const auto range=candidate_index.equal_range(entry.key); + for (auto i=range.first; i!=range.second; ++i) + if (limits.dominates(*i->second,*entry.space)) return true; + std::vector dominated; + for (auto i=range.first; i!=range.second; ++i) + if (limits.dominates(*entry.space,*i->second)) dominated.push_back(i->second); + // Heap entries move; recover victim slots only after finding dominance. + if (!dominated.empty()) + for (std::size_t i=0; i1; i--) + candidates.erase(candidates.begin()+victims[i-1]); + std::make_heap(candidates.begin(),candidates.end(),earlier); + } + return true; + } + } + if (candidates.size() == width) { + const bool replace = earlier(entry,candidates.front()); + const std::int64_t discarded = replace ? candidates.front().bound : entry.bound; + if (!discarded_bound || (guidance.sense == HeuristicGuidance::MINIMIZE ? + discarded < *discarded_bound : + discarded > *discarded_bound)) + discarded_bound = discarded; + if (!replace) return true; + // Replacement uses the discarded entry's existing slot. + if (limits.dominates && limits.dominance_key) + candidate_index.emplace(entry.key,entry.space.get()); + unindex(candidates.front()); + std::pop_heap(candidates.begin(),candidates.end(),earlier); + candidates.back() = std::move(entry); + std::push_heap(candidates.begin(),candidates.end(),earlier); + return true; + } + const auto pending = layer.size() + candidates.size() + (expansion ? 1U : 0U); + if (limits.frontier_limit && pending >= limits.frontier_limit) { + limited = _stopped = true; + return false; + } + candidates.push_back(std::move(entry)); + try { + if (limits.dominates && limits.dominance_key) + candidate_index.emplace(candidates.back().key,candidates.back().space.get()); + } catch (...) { + candidates.pop_back(); + throw; + } + std::push_heap(candidates.begin(),candidates.end(),earlier); + return true; + } + + Space* + CABS::publish(std::unique_ptr s) { + (void) s->choice(); + const std::int64_t bound = guidance.bound(*s); + if (!improves(bound)) return nullptr; + std::unique_ptr result(s->clone()); + best = std::move(s); + best_bound = bound; + incumbent_revision++; + return result.release(); + } + + bool + CABS::advance(void) { + if (!candidates.empty()) { + candidate_index.clear(); + layer.swap(candidates); + Earlier earlier{guidance.sense,static_cast(guidance.rank)}; + // Keep the preferred node at the back for constant-time selection. + std::sort(layer.begin(),layer.end(), + [&](const Entry& a, const Entry& b) { return earlier(b,a); }); + return true; + } + if (!discarded_bound || !improves(*discarded_bound)) return false; + if (width > std::numeric_limits::max()/2 || + (limits.max_width && width*2 > limits.max_width)) { + limited = _stopped = true; + return false; + } + width *= 2; + restart++; + discarded_bound.reset(); + expansion.select(std::unique_ptr(root->clone())); + current_status = SS_BRANCH; + current_depth = 0; + current_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 entry = std::move(layer.back()); + layer.pop_back(); + if (!improves(entry.bound)) { + fail++; + continue; + } + expansion.select(std::move(entry.space)); + current_depth = entry.depth; + current_revision = entry.incumbent_revision; + current_status = SS_BRANCH; + counted = true; + } + } + if (!counted) { + node++; + stack_depth(current_depth); + counted = true; + if (current_status == SS_SOLVED) { + if (Space* solution = publish(expansion.take())) return solution; + continue; + } + if (!improves(guidance.bound(expansion.space()))) { + fail++; + expansion.reset(); + continue; + } + } + const auto child_depth = current_depth+1; + ExpansionStep step = expansion.next(*this,child_depth,current_revision, + best.get(),incumbent_revision); + if (step.state == ExpansionStep::EXHAUSTED) continue; + switch (step.status) { + case SS_FAILED: + fail++; + break; + case SS_SOLVED: + if (Space* solution = publish(std::move(step.space))) return solution; + break; + case SS_BRANCH: + if (!admit(std::move(step.space),child_depth,incumbent_revision)) + return nullptr; + break; + default: + GECODE_NEVER; + } + } + } + + Statistics + CABS::statistics(void) const { + return *this; + } + + void + CABS::constrain(const Space& b) { + const std::int64_t bound = guidance.bound(b); + if (!improves(bound)) return; + std::unique_ptr incumbent(b.clone()); + best = std::move(incumbent); + best_bound = bound; + incumbent_revision++; + } + + void + CABS::reset(Space* s) { + std::unique_ptr replacement(s); + expansion.reset(); + layer.clear(); + candidate_index.clear(); + candidates.clear(); + root.reset(); + best.reset(); + width = 1; + order = 0; + incumbent_revision = current_revision = 0; + current_depth = 0; + discarded_bound.reset(); + counted = limited = false; + Worker::reset(); + start(); + initialize(s,false); + // initialize consumes the root on normal return, including failure. + replacement.release(); + } + + NoGoods& + CABS::nogoods(void) { + return NoGoods::eng; + } + +}}} + +// STATISTICS: search-other diff --git a/gecode/search/cabs.hpp b/gecode/search/cabs.hpp new file mode 100644 index 0000000000..0db7b27549 --- /dev/null +++ b/gecode/search/cabs.hpp @@ -0,0 +1,99 @@ +/* -*- 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 native CABS engine + GECODE_SEARCH_EXPORT Engine* + cabsengine(Space* s, const HeuristicGuidance& guidance, const Options& o, + const CABSOptions& limits=CABSOptions()); + + /// CABS engine builder + template + class CABSBuilder : public Builder { + HeuristicGuidance guidance; + CABSOptions limits; + public: + CABSBuilder(const HeuristicGuidance& g, const Options& o, + const CABSOptions& l) + : Builder(o,true), guidance(g), limits(l) {} + Engine* operator()(Space* s) const override { + return cabsengine(dynamic_cast(s),guidance,opt,limits); + } + }; + + inline + CABSOptions::CABSOptions(unsigned long int maximum_width, + unsigned long int frontier_nodes) + : max_width(maximum_width), frontier_limit(frontier_nodes) {} + +}} + +namespace Gecode { + + template + inline + CABS::CABS(T* s, const Search::HeuristicGuidance& g, + const Search::Options& o, const Search::CABSOptions& limits) + : Search::Base(Search::cabsengine(s,g,o,limits)) {} + + template + inline void + CABS::constrain(const Space& incumbent) { + this->e->constrain(incumbent); + } + + template + inline T* + cabs(T* s, const Search::HeuristicGuidance& g, const Search::Options& o, + const Search::CABSOptions& limits) { + CABS engine(s,g,o,limits); + std::unique_ptr best; + while (T* solution = engine.next()) + best.reset(solution); + return best.release(); + } + + template + inline SEB + cabs(const Search::HeuristicGuidance& g, const Search::Options& o, + const Search::CABSOptions& limits) { + return new Search::CABSBuilder(g,o,limits); + } + +} + +#endif + +// STATISTICS: search-other diff --git a/gecode/search/exception.cpp b/gecode/search/exception.cpp index 7b36ff7f01..f49e74bb29 100644 --- a/gecode/search/exception.cpp +++ b/gecode/search/exception.cpp @@ -34,6 +34,28 @@ #include namespace Gecode { namespace Search { +#ifdef GECODE_HAS_EXPERIMENTAL + UnsupportedCABSThreads::UnsupportedCABSThreads(const char* l) + : Exception(l,"CABS requires exactly one expanded search thread") {} + + UnsupportedCABSTracer::UnsupportedCABSTracer(const char* l) + : Exception(l,"CABS does not support search tracers") {} + + UninitializedCABSBound::UninitializedCABSBound(const char* l) + : Exception(l,"CABS total-bound guidance is missing") {} + + UnsupportedAStarThreads::UnsupportedAStarThreads(const char* l) + : Exception(l,"A* requires exactly one expanded search thread") {} + + UnsupportedAStarTracer::UnsupportedAStarTracer(const char* l) + : Exception(l,"A* does not support search tracers") {} + + UninitializedAStarBound::UninitializedAStarBound(const char* l) + : Exception(l,"A* total-bound guidance is missing") {} + + + +#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..1fc921f701 100644 --- a/gecode/search/exception.hpp +++ b/gecode/search/exception.hpp @@ -80,6 +80,39 @@ namespace Gecode { namespace Search { /// Initialize with location \a l WorkerControlInUse(const char* l); }; +#ifdef GECODE_HAS_EXPERIMENTAL + /// %Exception: CABS requires exactly one expanded thread + class GECODE_SEARCH_EXPORT UnsupportedCABSThreads : public Exception { + public: + UnsupportedCABSThreads(const char* l); + }; + /// %Exception: CABS does not support search tracers + class GECODE_SEARCH_EXPORT UnsupportedCABSTracer : public Exception { + public: + UnsupportedCABSTracer(const char* l); + }; + /// %Exception: CABS requires total-bound guidance + class GECODE_SEARCH_EXPORT UninitializedCABSBound : public Exception { + public: + UninitializedCABSBound(const char* l); + }; + /// %Exception: A* requires exactly one expanded thread + class GECODE_SEARCH_EXPORT UnsupportedAStarThreads : public Exception { + public: + UnsupportedAStarThreads(const char* l); + }; + /// %Exception: A* does not support search tracers + class GECODE_SEARCH_EXPORT UnsupportedAStarTracer : public Exception { + public: + UnsupportedAStarTracer(const char* l); + }; + /// %Exception: A* requires total-bound guidance + class GECODE_SEARCH_EXPORT UninitializedAStarBound : public Exception { + public: + UninitializedAStarBound(const char* l); + }; +#endif + //@} }} diff --git a/gecode/search/par/bab.hpp b/gecode/search/par/bab.hpp index 3afbe8ca9f..249f67c7ab 100755 --- a/gecode/search/par/bab.hpp +++ b/gecode/search/par/bab.hpp @@ -288,6 +288,10 @@ namespace Gecode { namespace Search { namespace Par { engine().stop(); } else { node++; +#ifdef GECODE_HAS_EXPERIMENTAL + if (cur->status(*this) == SS_BRANCH) + cur->prepare(*this); +#endif SearchTracer::EdgeInfo ei; if (tracer) { if (path.entries() > 0) { diff --git a/gecode/search/par/dfs.hpp b/gecode/search/par/dfs.hpp index 205d7c31be..946b992b6e 100755 --- a/gecode/search/par/dfs.hpp +++ b/gecode/search/par/dfs.hpp @@ -232,6 +232,10 @@ namespace Gecode { namespace Search { namespace Par { engine().stop(); } else { node++; +#ifdef GECODE_HAS_EXPERIMENTAL + if (cur->status(*this) == SS_BRANCH) + cur->prepare(*this); +#endif SearchTracer::EdgeInfo ei; if (tracer) { if (path.entries() > 0) { diff --git a/gecode/search/par/frontier.cpp b/gecode/search/par/frontier.cpp new file mode 100644 index 0000000000..9cfc9c9b7e --- /dev/null +++ b/gecode/search/par/frontier.cpp @@ -0,0 +1,501 @@ +/* -*- 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 + +#if defined(GECODE_HAS_EXPERIMENTAL) && defined(GECODE_HAS_THREADS) +namespace Gecode { namespace Search { namespace Par { + + bool + Frontier::Earlier::operator()(const Entry& a, const Entry& b) const { + if ((beam ? ranked : ordering == RANK_FIRST) && a.rank != b.rank) + return a.rank < b.rank; + if ((!beam || !ranked) && a.bound != b.bound) + return sense == HeuristicGuidance::MINIMIZE ? + a.bound < b.bound : a.bound > b.bound; + if (!beam && a.rank != b.rank) return a.rank < b.rank; + return a.order < b.order; + } + + Frontier::Earlier + Frontier::earlier(void) const { + return {guidance.sense,ordering,beam,static_cast(guidance.rank)}; + } + + 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(Space* s, const HeuristicGuidance& g, AStarOrder ordering0, + const Options& o, bool beam0, const CABSOptions& limits0) + : Search::Engine(o,static_cast(o.threads)), opt(o), + guidance(g), ordering(ordering0), beam(beam0), limits(limits0) { + initialize(s,o.clone); + } + + void + Frontier::initialize(Space* s, bool clone) { + if (!s) { stat.fail++; return; } + const SpaceStatus status=s->status(stat); + if (status == SS_FAILED) { + stat.fail++; + if (!clone) delete s; + return; + } + auto first=std::make_unique(); + std::unique_ptr restart_root; + if (beam) restart_root.reset(s->clone()); + std::unique_ptr initial(clone ? s->clone() : nullptr); + // Allocation and cloning must finish before taking the caller's root. + pending.push_back(std::move(first)); + pending.back()->expansion.select( + std::unique_ptr(clone ? initial.release() : s)); + root=std::move(restart_root); + } + + void + Frontier::launch(void) { + if (!runners.empty()) return; + const auto capacity=static_cast(opt.threads); + std::vector> allocated; + allocated.reserve(capacity); + for (unsigned int i=0; i(*this,i)); + runners=std::move(allocated); + unsigned int started=0; + try { + for (; starteddone.wait(); + runners.clear(); + throw; + } + } + + void + Frontier::wake(void) { + if (worker_control.engaged()) WorkerControlAccess::signal_all(worker_control); + else for (const auto& r : runners) r->wake.signal(); + } + + Frontier::~Frontier(void) { + { Support::Lock lock(mutex); shutdown=true; running=false; } + wake(); + for (const auto& r : runners) r->done.wait(); + } + + bool + Frontier::improves(std::int64_t bound) const { + return !best || (guidance.sense == HeuristicGuidance::MINIMIZE ? + bound < best_bound : bound > best_bound); + } + + bool + Frontier::advance(void) { + assert(active == 0 && pending.empty() && frontier.empty()); + if (!beam) return false; + if (!candidates.empty()) { + candidate_index.clear(); + frontier.swap(candidates); + const Earlier cmp=earlier(); + std::sort(frontier.begin(),frontier.end(), + [&](const Entry& a, const Entry& b) { return cmp(b,a); }); + return true; + } + if (!discarded_bound || !improves(*discarded_bound)) return false; + if (width > std::numeric_limits::max()/2 || + (limits.max_width && width*2 > limits.max_width)) { + limited=stopped_=true; + return false; + } + width*=2; + stat.restart++; + discarded_bound.reset(); + auto first=std::make_unique(); + first->expansion.select(std::unique_ptr(root->clone())); + pending.push_back(std::move(first)); + return true; + } + + void + Frontier::unindex(const Entry& entry) { + if (!limits.dominates || !limits.dominance_key) return; + const auto range=candidate_index.equal_range(entry.key); + for (auto i=range.first; i!=range.second; ++i) + if (i->second == entry.space.get()) { + candidate_index.erase(i); + return; + } + GECODE_NEVER; + } + + std::unique_ptr + Frontier::admit(Entry entry, bool parent) { + if (!improves(entry.bound)) { + stat.fail++; + return std::move(entry.space); + } + entry.order=order++; + const Earlier cmp=earlier(); + if (!beam) { + frontier.push_back(std::move(entry)); + std::push_heap(frontier.begin(),frontier.end(), + [&](const Entry& a, const Entry& b) { return cmp(b,a); }); + return nullptr; + } + if (limits.dominates) { + std::vector victims; + if (limits.dominance_key) { + const auto range=candidate_index.equal_range(entry.key); + for (auto i=range.first; i!=range.second; ++i) + if (limits.dominates(*i->second,*entry.space)) + return std::move(entry.space); + std::vector dominated; + for (auto i=range.first; i!=range.second; ++i) + if (limits.dominates(*entry.space,*i->second)) dominated.push_back(i->second); + // Heap entries move; recover victim slots only after finding dominance. + if (!dominated.empty()) + for (std::size_t i=0; i1; i--) + candidates.erase(candidates.begin()+victims[i-1]); + std::make_heap(candidates.begin(),candidates.end(),cmp); + } + return std::move(entry.space); + } + } + if (candidates.size() == width) { + const bool replace=cmp(entry,candidates.front()); + const std::int64_t discarded=replace ? candidates.front().bound : entry.bound; + if (!discarded_bound || (guidance.sense == HeuristicGuidance::MINIMIZE ? + discarded < *discarded_bound : + discarded > *discarded_bound)) discarded_bound=discarded; + if (!replace) return std::move(entry.space); + if (limits.dominates && limits.dominance_key) + candidate_index.emplace(entry.key,entry.space.get()); + unindex(candidates.front()); + std::pop_heap(candidates.begin(),candidates.end(),cmp); + std::swap(candidates.back(),entry); + std::push_heap(candidates.begin(),candidates.end(),cmp); + return std::move(entry.space); + } + // Other leases reserve a parent and an unsubmitted successor. + assert(active > 0); + const auto retained=frontier.size()+candidates.size()+pending.size()+ + 2*(active-1)+parent; + if (limits.frontier_limit && retained >= limits.frontier_limit) { + limited=stopped_=true; + running=false; + return std::move(entry.space); + } + candidates.push_back(std::move(entry)); + try { + if (limits.dominates && limits.dominance_key) + candidate_index.emplace(candidates.back().key,candidates.back().space.get()); + } catch (...) { + candidates.pop_back(); + throw; + } + std::push_heap(candidates.begin(),candidates.end(),cmp); + return nullptr; + } + + void + Frontier::publish(std::unique_ptr s, std::int64_t bound) { + if (!improves(bound)) return; + std::unique_ptr copy(s->clone()); + best=std::move(s); + best_bound=bound; + revision++; + solution=std::move(copy); + running=false; + } + + void + Frontier::work(unsigned int id) { + std::unique_ptr task; + std::unique_ptr incumbent; + unsigned long long int current_revision=0; + Worker local; + bool leased=false; + Seq::ExpansionStep step{Seq::ExpansionStep::EXHAUSTED,nullptr,SS_FAILED}; + std::int64_t bound=0, rank=0; + std::size_t key=0; + std::optional cutoff; + while (true) { + try { + bool notify=false, retiring=false; + { + Support::Lock lock(mutex); + const bool empty=pending.empty() && frontier.empty(); + if (leased) { + stat+=local; + local.reset(); + if (step.state != Seq::ExpansionStep::EXHAUSTED) { + switch (step.status) { + case SS_FAILED: stat.fail++; break; + case SS_SOLVED: publish(std::move(step.space),bound); break; + case SS_BRANCH: + step.space=admit({std::move(step.space),bound,rank,key, + task->depth+1,0,current_revision}, + static_cast(task->expansion)); + break; + default: GECODE_NEVER; + } + } + step.state=Seq::ExpansionStep::EXHAUSTED; + } + if (running && opt.stop && opt.stop->stop(stat,opt)) { + stopped_=true; + running=false; + } + const bool enabled=!worker_control.engaged() || + id < WorkerControlAccess::requested(worker_control); + if (task && (!task->expansion || !running || !enabled || shutdown)) { + // Keep the lease until the rejected or replaced space is gone. + retiring=static_cast(step.space); + if (!retiring) { + if (task->expansion) pending.push_back(std::move(task)); + task.reset(); + active--; + leased=false; + } + } + if (shutdown && !retiring) { + incumbent.reset(); + return; + } + if (running && enabled && !task) { + while (running && !task) { + if (pending.empty() && frontier.empty() && active == 0 && !advance()) + running=false; + if (!running || (pending.empty() && frontier.empty())) break; + if (!pending.empty()) { + task=std::move(pending.front()); + pending.pop_front(); + } else { + if (!beam) { + const Earlier cmp=earlier(); + std::pop_heap(frontier.begin(),frontier.end(), + [&](const Entry& a, const Entry& b) { return cmp(b,a); }); + } + Entry entry=std::move(frontier.back()); + frontier.pop_back(); + if (!improves(entry.bound)) { + stat.fail++; + if (opt.stop && opt.stop->stop(stat,opt)) { + stopped_=true; + running=false; + } + if (worker_control.engaged() && + id >= WorkerControlAccess::requested(worker_control)) break; + continue; + } + task=std::make_unique(); + task->expansion.select(std::move(entry.space)); + task->depth=entry.depth; + task->revision=entry.revision; + task->counted=true; + } + active++; + leased=true; + break; + } + } + if (task) { + if (current_revision != revision) incumbent.reset(); + if (best && task->revision != revision && !incumbent) + incumbent.reset(best->clone()); + current_revision=revision; + cutoff=best ? std::optional(best_bound) : std::nullopt; + } else { + // Retire cached model state before the caller observes quiescence. + incumbent.reset(); + current_revision=0; + } + notify=empty && (!pending.empty() || !frontier.empty()); + if (!running && active == 0) changed.signal(); + } + if (notify) wake(); + step.space.reset(); + if (retiring) continue; + if (!task) { + 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; + } + // Failed alternatives need no shared admission. Keep stop and worker + // control checks bounded even for choices with many failing children. + for (unsigned int i=0; i<32 && task->expansion; i++) { + step=task->expansion.next(local,task->depth+1, + task->revision,incumbent.get(),current_revision); + bound=rank=0; + key=0; + if (step.space && step.status != SS_FAILED) { + if (step.status == SS_SOLVED) (void) step.space->choice(); + bound=guidance.bound(*step.space); + if (step.status == SS_BRANCH && cutoff && + (guidance.sense == HeuristicGuidance::MINIMIZE ? + bound >= *cutoff : bound <= *cutoff)) { + step.status=SS_FAILED; + } else { + if (step.status == SS_BRANCH && guidance.rank) rank=guidance.rank(*step.space); + if (step.status == SS_BRANCH && beam && limits.dominates && limits.dominance_key) + key=limits.dominance_key(*step.space); + break; + } + } + if (step.state != Seq::ExpansionStep::EXHAUSTED) local.fail++; + step.space.reset(); + step.state=Seq::ExpansionStep::EXHAUSTED; + if (worker_control.engaged() && + id >= WorkerControlAccess::requested(worker_control)) break; + } + } catch (...) { + { + Support::Lock lock(mutex); + step.space.reset(); + step.state=Seq::ExpansionStep::EXHAUSTED; + task.reset(); + incumbent.reset(); + current_revision=0; + if (leased) { active--; stat+=local; local.reset(); leased=false; } + if (!error) error=std::current_exception(); + running=false; + if (active == 0) changed.signal(); + } + wake(); + } + } + } + + Space* + Frontier::next(void) { + launch(); + { + Support::Lock lock(mutex); + if (error) std::rethrow_exception(error); + if (limited) { stopped_=true; return nullptr; } + stopped_=false; + running=true; + } + wake(); + while (true) { + changed.wait(); + Support::Lock lock(mutex); + if (running || active) 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) { + const std::int64_t bound=guidance.bound(s); + Support::Lock lock(mutex); + if (!improves(bound)) return; + std::unique_ptr copy(s.clone()); + best=std::move(copy); + best_bound=bound; + revision++; + solution.reset(); + } + + void + Frontier::reset(Space* s) { + std::unique_ptr replacement(s); + Support::Lock lock(mutex); + assert(!running && active == 0); + candidate_index.clear(); + pending.clear(); frontier.clear(); candidates.clear(); + root.reset(); best.reset(); solution.reset(); + width=1; order=revision=0; + discarded_bound.reset(); + stopped_=limited=false; + stat.reset(); + initialize(s,false); + 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..d32e44d474 --- /dev/null +++ b/gecode/search/par/frontier.hh @@ -0,0 +1,126 @@ +/* -*- 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 +#include +#include +#include + +#if defined(GECODE_HAS_EXPERIMENTAL) && defined(GECODE_HAS_THREADS) +namespace Gecode { namespace Search { namespace Par { + + /// Shared frontier and worker lifecycle for native A* and CABS + class Frontier : public Search::Engine { + struct Entry { + std::unique_ptr space; + std::int64_t bound, rank; + std::size_t key; + unsigned long int depth; + unsigned long long int order, revision; + }; + struct Earlier { + HeuristicGuidance::Sense sense; + AStarOrder ordering; + bool beam, ranked; + bool operator()(const Entry& a, const Entry& b) const; + }; + struct Pending { + Seq::ChoiceExpansion expansion; + unsigned long int depth=0; + unsigned long long int revision=0; + bool counted=false; + }; + class Runner : public Support::Runnable, public Support::Terminator { + Frontier& engine; + unsigned int id; + public: + Support::Event wake, done; + Runner(Frontier& e, unsigned int i); + void run(void) override; + Support::Terminator* terminator(void) const override; + void terminated(void) override; + }; + Options opt; + HeuristicGuidance guidance; + AStarOrder ordering; + bool beam; + CABSOptions limits; + unsigned long int width=1; + unsigned long long int order=0, revision=0; + std::unique_ptr root, best, solution; + std::int64_t best_bound=0; + std::optional discarded_bound; + std::vector frontier, candidates; + std::unordered_multimap candidate_index; + std::deque> pending; + std::vector> runners; + mutable Support::Mutex mutex; + Support::Event changed; + Search::Statistics stat; + unsigned int active=0; + bool running=false, shutdown=false, stopped_=false, limited=false; + std::exception_ptr error; + + void initialize(Space* s, bool clone); + void launch(void); + void wake(void); + void work(unsigned int id); + bool improves(std::int64_t bound) const; + void unindex(const Entry& entry); + bool advance(void); + /// Return a rejected or replaced space for disposal by its worker. + std::unique_ptr admit(Entry entry, bool parent); + void publish(std::unique_ptr s, std::int64_t bound); + Earlier earlier(void) const; + public: + Frontier(Space* s, const HeuristicGuidance& g, AStarOrder ordering, + const Options& o, bool beam, const CABSOptions& limits=CABSOptions()); + ~Frontier(void) override; + Space* next(void) override; + Search::Statistics statistics(void) const override; + bool stopped(void) const override; + void constrain(const Space& s) override; + 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..bbdd394e6f --- /dev/null +++ b/gecode/search/seq/astar.hh @@ -0,0 +1,102 @@ +/* -*- 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 +#include + +#ifdef GECODE_HAS_EXPERIMENTAL +namespace Gecode { namespace Search { namespace Seq { + + /// Sequential tree search with owned stable frontier clones + class AStar : public Worker { + public: + struct Configuration { + HeuristicGuidance guidance; + AStarOrder ordering; + }; + private: + struct Entry { + std::unique_ptr space; + std::int64_t bound, rank; + unsigned long int depth; + unsigned long long int order, incumbent_revision; + }; + /// Heap comparison with fixed exploration order + struct Later { + HeuristicGuidance::Sense sense; + AStarOrder ordering; + bool operator()(const Entry& a, const Entry& b) const; + }; + Options opt; + HeuristicGuidance guidance; + AStarOrder ordering; + std::vector frontier; + unsigned long long int order; + /// Revision of the currently accepted incumbent + unsigned long long int incumbent_revision; + /// Selected parent with its original alternative cursor + ChoiceExpansion expansion; + SpaceStatus current_status; + unsigned long int current_depth; + /// Incumbent restriction already inherited by the selected source + unsigned long long int current_revision; + bool counted; + std::unique_ptr best; + std::int64_t best_bound; + + bool improves(std::int64_t bound) const; + void enqueue(std::unique_ptr s, unsigned long int depth, + unsigned long long int revision); + Space* publish(std::unique_ptr s); + void initialize(Space* s, bool clone); + public: + AStar(Space* s, const Options& o, const Configuration& c); + Space* next(void); + Statistics statistics(void) const; + void constrain(const Space& b); + void reset(Space* s); + NoGoods& nogoods(void); + }; + +}}} + +#endif + +#endif + +// STATISTICS: search-seq diff --git a/gecode/search/seq/bab.hpp b/gecode/search/seq/bab.hpp index dade2cb075..beaacce626 100644 --- a/gecode/search/seq/bab.hpp +++ b/gecode/search/seq/bab.hpp @@ -42,7 +42,7 @@ namespace Gecode { namespace Search { namespace Seq { template forceinline BAB::BAB(Space* s, const Options& o) - : tracer(o.tracer), opt(o), path(opt.nogoods_limit), d(0), mark(0), + : tracer(o.tracer), opt(o), path(opt.nogoods_limit), d(0), mark(0), best(nullptr) { if (tracer) { tracer.engine(SearchTracer::EngineType::BAB, 1U); @@ -91,6 +91,10 @@ namespace Gecode { namespace Search { namespace Seq { path.next(); } node++; +#ifdef GECODE_HAS_EXPERIMENTAL + if (cur->status(*this) == SS_BRANCH) + cur->prepare(*this); +#endif SearchTracer::EdgeInfo ei; if (tracer && (path.entries() > 0)) { typename Path::Edge& top = path.top(); diff --git a/gecode/search/seq/cabs.hh b/gecode/search/seq/cabs.hh new file mode 100644 index 0000000000..719729dc01 --- /dev/null +++ b/gecode/search/seq/cabs.hh @@ -0,0 +1,112 @@ +/* -*- 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 +#include +#include + +#ifdef GECODE_HAS_EXPERIMENTAL +namespace Gecode { namespace Search { namespace Seq { + + /// Sequential layered beams, widening while discarded work can improve + class CABS : public Worker { + public: + /// Additional configuration carried through the sequential engine adapter + struct Configuration { + HeuristicGuidance guidance; + CABSOptions limits; + }; + private: + struct Entry { + std::unique_ptr space; + std::int64_t bound, rank; + std::size_t key; + unsigned long int depth; + unsigned long long int order, incumbent_revision; + }; + /// Better priority first; a candidate heap therefore exposes its worst node + struct Earlier { + HeuristicGuidance::Sense sense; + bool ranked; + bool operator()(const Entry& a, const Entry& b) const; + }; + Options opt; + HeuristicGuidance guidance; + CABSOptions limits; + unsigned long int width; + unsigned long long int order, incumbent_revision; + /// Never restricted; every wider pass regenerates omitted alternatives + std::unique_ptr root; + std::vector layer, candidates; + std::unordered_multimap candidate_index; + /// Source and original alternative cursor survive next() + ChoiceExpansion expansion; + SpaceStatus current_status; + unsigned long int current_depth; + unsigned long long int current_revision; + bool counted; + std::unique_ptr best; + std::int64_t best_bound; + /// Optimistic bound over all width-discarded candidates in this pass + std::optional discarded_bound; + /// Intrinsic resource stops persist until reset + bool limited; + + bool improves(std::int64_t bound) const; + void unindex(const Entry& entry); + bool admit(std::unique_ptr s, unsigned long int depth, + unsigned long long int revision); + Space* publish(std::unique_ptr s); + bool advance(void); + void initialize(Space* s, bool clone); + public: + CABS(Space* s, const Options& o, const Configuration& c); + Space* next(void); + Statistics statistics(void) const; + void constrain(const Space& b); + void reset(Space* s); + 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..871ef9e42a --- /dev/null +++ b/gecode/search/seq/choice-expansion.hh @@ -0,0 +1,134 @@ +/* -*- 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 { + + /// One propagated child, prepared terminal, or exhausted choice + struct ExpansionStep { + enum State { CHILD, PREPARED_TERMINAL, EXHAUSTED }; + State state; + std::unique_ptr space; + SpaceStatus status; + }; + + /// Own a selected parent and its original-choice continuation + class ChoiceExpansion { + private: + // Member declaration order destroys the choice before its source. + std::unique_ptr parent; + std::unique_ptr choice; + unsigned int alternative; + public: + ChoiceExpansion(void) : alternative(0) {} + + explicit operator bool(void) const { return static_cast(parent); } + Space& space(void) const { return *parent; } + + /// Take ownership of a stable selected source + void select(std::unique_ptr s) { + assert(!parent && !choice); + parent = std::move(s); + alternative = 0; + } + + /// Take a solved root, which has no saved choice + std::unique_ptr take(void) { + assert(!choice); + return std::move(parent); + } + + /// Clear continuation before deleting its source + void reset(void) { + choice.reset(); + parent.reset(); + alternative = 0; + } + + /// Commit and propagate one original alternative without changing siblings + ExpansionStep next(Worker& worker, + unsigned long int child_depth, + unsigned long long int source_revision, + const Space* incumbent, + unsigned long long int incumbent_revision) { + assert(parent); + if (!choice) { + parent->prepare(worker); + const SpaceStatus status = parent->status(worker); + if (status != SS_BRANCH) + return {ExpansionStep::PREPARED_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; + // Keep the final choice alive through commit, cut, and status, + // including exceptional cleanup, after its source becomes the child. + std::unique_ptr final_choice; + const Choice* decision = choice.get(); + if (alternative+1 == choice->alternatives()) { + child = std::move(parent); + final_choice = std::move(choice); + } else { + child.reset(parent->clone()); + } + child->commit(*decision,alternative); + if (incumbent && source_revision != incumbent_revision) { + child->constrain(*incumbent); + } + worker.node++; + worker.stack_depth(child_depth); + const SpaceStatus status = child->status(worker); + alternative++; + // The final source is already retired before successor admission. + return {ExpansionStep::CHILD,std::move(child),status}; + } + }; + +}}} + +#endif + +#endif + +// STATISTICS: search-seq diff --git a/gecode/search/seq/dfs.hpp b/gecode/search/seq/dfs.hpp index 0a90f8a53a..c2d6fa7ed3 100644 --- a/gecode/search/seq/dfs.hpp +++ b/gecode/search/seq/dfs.hpp @@ -100,6 +100,10 @@ namespace Gecode { namespace Search { namespace Seq { path.next(); } node++; +#ifdef GECODE_HAS_EXPERIMENTAL + if (cur->status(*this) == SS_BRANCH) + cur->prepare(*this); +#endif SearchTracer::EdgeInfo ei; if (tracer && (path.entries() > 0)) { typename Path::Edge& top = path.top(); diff --git a/gecode/search/seq/lds.hpp b/gecode/search/seq/lds.hpp index 01f5c628f5..4a480d82f3 100644 --- a/gecode/search/seq/lds.hpp +++ b/gecode/search/seq/lds.hpp @@ -175,6 +175,11 @@ namespace Gecode { namespace Search { namespace Seq { cur = s; return nullptr; } +#ifdef GECODE_HAS_EXPERIMENTAL + s->prepare(*this); + if (s->status(*this) != SS_BRANCH) + break; +#endif const Choice* ch = s->choice(); if (ch->alternatives() > 1) exhausted = false; @@ -212,6 +217,10 @@ namespace Gecode { namespace Search { namespace Seq { } } else { node++; +#ifdef GECODE_HAS_EXPERIMENTAL + if (cur->status(*this) == SS_BRANCH) + cur->prepare(*this); +#endif switch (cur->status(*this)) { case SS_FAILED: if (tracer) { diff --git a/gecode/search/seq/pbs.hpp b/gecode/search/seq/pbs.hpp index 59d66f43cc..75b8cebe2d 100644 --- a/gecode/search/seq/pbs.hpp +++ b/gecode/search/seq/pbs.hpp @@ -98,6 +98,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) { diff --git a/gecode/search/support.hh b/gecode/search/support.hh index fe25d487b2..e90e8a47bd 100644 --- a/gecode/search/support.hh +++ b/gecode/search/support.hh @@ -34,6 +34,10 @@ #ifndef GECODE_SEARCH_SUPPORT_HH #define GECODE_SEARCH_SUPPORT_HH +#include +#include +#include +#include #include namespace Gecode { namespace Search { @@ -51,6 +55,9 @@ namespace Gecode { namespace Search { public: /// Initialization WorkerToEngine(Space* s, const Options& o); + /// Initialization with additional worker configuration + template + WorkerToEngine(Space* s, const Options& o, const Configuration& configuration); /// Return next solution (nullptr, if none exists or search has been stopped) virtual Space* next(void); /// Return statistics @@ -73,10 +80,33 @@ namespace Gecode { namespace Search { } + /// Repair one replaced heap entry without rebuilding unchanged subtrees. + template + inline void + repair_heap(std::vector& entries, std::size_t i, Compare cmp) { + if (i && cmp(entries[(i-1)/2],entries[i])) { + std::push_heap(entries.begin(),entries.begin()+i+1,cmp); + } else { + while (i WorkerToEngine::WorkerToEngine(Space* s, const Options& o) : Engine(o,1U), w(s,o) {} template + template + WorkerToEngine::WorkerToEngine(Space* s, const Options& o, + const Configuration& configuration) + : Engine(o,1U), w(s,o,configuration) {} + template Space* WorkerToEngine::next(void) { return w.next(); 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 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: diff --git a/misc/tsptw-check.py b/misc/tsptw-check.py new file mode 100644 index 0000000000..92b20fdf91 --- /dev/null +++ b/misc/tsptw-check.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +"""Check canonical TSPTW tours using exact rational arithmetic, without Gecode.""" +import argparse +from fractions import Fraction +import itertools +import json +from pathlib import Path +import re +import sys + +INTEGER_MAX = 2147483646 # Gecode::Int::Limits::max + + +def read_instance(path): + tokens = " ".join(line.split("#", 1)[0] for line in + Path(path).read_text().splitlines()).split() + if not tokens or not re.fullmatch(r"[0-9]+", tokens[0]): + raise ValueError("node count must be an integer") + n = int(tokens[0]) + if n < 2 or n*n > INTEGER_MAX or len(tokens) != 1+n*n+2*n: + raise ValueError("invalid TSPTW dimensions") + if any(not re.fullmatch(r"(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)", x) + for x in tokens[1:]): + raise ValueError("invalid nonnegative decimal") + values = [Fraction(x) for x in tokens[1:]] + # The common power of ten is deliberately computed from text, independently + # of the native reader's mantissa conversion. Trailing zeroes need no scale. + places = max(len(x.rstrip("0").split(".")[1]) if "." in x else 0 + for x in tokens[1:]) + scale = 10**places + if scale > INTEGER_MAX or any(x*scale > INTEGER_MAX for x in values): + raise ValueError("scaled value exceeds Gecode integer limits") + matrix = [values[i*n:(i+1)*n] for i in range(n)] + windows = [tuple(values[n*n+2*i:n*n+2*i+2]) for i in range(n)] + if any(ready > due for ready, due in windows): + raise ValueError("window ready time exceeds due time") + if windows[0][0] != 0: + raise ValueError("nonzero start-depot ready time is unsupported") + cost_limit = n*max(values[:n*n]) + time_limit = cost_limit+max(due for _, due in windows) + if time_limit*scale > INTEGER_MAX: + raise ValueError("tour arithmetic exceeds Gecode integer limits") + return matrix, windows, scale + + +def exact_text(value): + """Render a finite decimal Fraction without rounding.""" + denominator = value.denominator + places = 0 + while (10**places) % denominator: + places += 1 + integer = value.numerator * (10**places // denominator) + if not places: + return str(integer) + text = str(integer).zfill(places+1) + return (text[:-places]+"."+text[-places:]).rstrip("0").rstrip(".") + + +def check_tour(data, tour, claimed_cost=None): + matrix, windows, scale = data + n = len(matrix) + if len(tour) != n-1: + raise ValueError("tour omits customers or has extra visits") + if set(tour) != set(range(1, n)): + raise ValueError("tour has a duplicate or invalid customer") + time = Fraction(0) + cost = Fraction(0) + previous = 0 + visits = [] + for node in [*tour, 0]: + arrival = time+matrix[previous][node] + time = max(arrival, windows[node][0]) + cost += matrix[previous][node] + if time > windows[node][1]: + raise ValueError("late return" if node == 0 else f"late customer {node}") + visits.append({"node": node, "arrival": exact_text(arrival), + "waiting": exact_text(time-arrival), "time": exact_text(time)}) + previous = node + if claimed_cost is not None and cost != Fraction(claimed_cost): + raise ValueError(f"incorrect cost: recomputed {exact_text(cost)}") + return {"cost": exact_text(cost), "scaled_cost": int(cost*scale), + "return_time": exact_text(time), "scale": scale, "visits": visits} + + +def enumerate_tours(data): + n = len(data[0]) + if n > 10: + raise ValueError("tiny enumeration is limited to nine customers") + best = None + feasible = 0 + for tour in itertools.permutations(range(1, n)): + try: + result = check_tour(data, tour) + except ValueError: + continue + feasible += 1 + if best is None or result["scaled_cost"] < best["scaled_cost"]: + best = {**result, "tour": list(tour)} + return {"feasible_tours": feasible, "optimum": best, "proof": "exhaustive enumeration"} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + check = commands.add_parser("check") + check.add_argument("instance", type=Path) + check.add_argument("tour", type=Path, help="whitespace customer IDs; omit both depot visits") + check.add_argument("--cost", help="exact original-unit arc cost, including return") + enumerate_command = commands.add_parser("enumerate") + enumerate_command.add_argument("instance", type=Path) + args = parser.parse_args() + try: + data = read_instance(args.instance) + if args.command == "check": + tour = [int(x) for x in args.tour.read_text().split()] + result = check_tour(data, tour, args.cost) + else: + result = enumerate_tours(data) + print(json.dumps(result)) + except (OSError, ValueError) as error: + print(str(error), file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/misc/tsptw-read.cpp b/misc/tsptw-read.cpp new file mode 100644 index 0000000000..b8b94969bb --- /dev/null +++ b/misc/tsptw-read.cpp @@ -0,0 +1,14 @@ +// Small CLI for exercising the native example input reader. +#include "examples/tsptw-data.hh" +#include +int main(int argc, char* argv[]) { + if (argc != 2) { std::cerr << "usage: tsptw-read INSTANCE\n"; return 2; } + try { + TSPTW::Instance d = TSPTW::read(argv[1]); + std::cout << d.n << ' ' << d.scale << ' ' << d.cost_limit << ' ' + << d.time_limit << '\n'; + for (std::size_t i=0; i None: + try: + action() + except ValueError as error: + assert expected in str(error), str(error) + else: + raise AssertionError(f"accepted invalid input: {expected}") + + +def validate_native(reader: Path, path: Path) -> None: + matrix, windows, scale = CHECKER["read_instance"](path) + result = subprocess.run( + [str(reader), str(path)], capture_output=True, text=True, check=True + ) + integers = [int(x) for x in result.stdout.split()] + n = len(matrix) + cost_limit = n * max(x for row in matrix for x in row) + time_limit = cost_limit + max(due for _, due in windows) + expected = [n, scale, int(cost_limit * scale), int(time_limit * scale)] + expected += [int(x * scale) for row in matrix for x in row] + expected += [int(x * scale) for window in windows for x in window] + assert integers == expected, str(path) + + +def run_solver( + solver: Path, + path: Path, + engine: str, + options: Sequence[str] = (), + seed: Path | None = None, + formulation: str = "closed", +) -> tuple[dict[str, Any], list[dict[str, Any]]]: + data = CHECKER["read_instance"](path) + command = [ + str(solver), + str(path), + "--engine", + engine, + "--formulation", + formulation, + *options, + ] + seed_result = None + if seed: + tour = [int(x) for x in seed.read_text().split()] + # Approve the seed independently before the native engine can cut. + seed_result = CHECKER["check_tour"](data, tour) + command += ["--seed", str(seed)] + output = subprocess.run( + command, capture_output=True, text=True, check=True, timeout=30 + ) + records = [json.loads(line) for line in output.stdout.splitlines()] + assert records and records[-1]["type"] == "summary", output.stdout + summary = records[-1] + assert summary["engine"] == engine + assert summary["formulation"] == formulation + bound = options[options.index("--bound") + 1] if "--bound" in options else "prefix" + propagation = ( + options[options.index("--residual-propagation") + 1] + if "--residual-propagation" in options + else "off" + ) + assert summary["bound"] == ("cp-total" if formulation == "closed" else bound) + assert summary["residual_propagation"] == propagation + if formulation != "closed": + allocation = summary["allocation"] + n = len(data[0]) + assert allocation["space_bytes"] > 0 and allocation["intvar_handle_bytes"] > 0 + assert allocation["root_positions"] == (0 if formulation == "transition" else n) + assert allocation["prepared_root_positions"] in ( + {0, 1} if formulation == "transition" else {n} + ) + assert allocation["solution_positions"] == (n if summary["incumbent"] else 0) + measurements = summary["measurements"] + assert all( + isinstance(x, (int, float)) and x >= 0 for x in measurements.values() + ) + assert measurements["propagation_ms"] <= measurements["inference_ms"] + if propagation == "off": + assert measurements["inference_calls"] == 0 + assert measurements["inference_ms"] == measurements["propagation_ms"] == 0 + assert summary["termination"] in {"optimal", "infeasible", "stopped"} + assert all(isinstance(x, int) and x >= 0 for x in summary["statistics"].values()) + last = None + elapsed = 0 + for record in records[:-1]: + assert record["type"] == "solution", record + assert all(type(node) is int for node in record["tour"]), record + checked = CHECKER["check_tour"](data, record["tour"], record["cost"]) + for field in ("scaled_cost", "scale", "cost", "return_time"): + assert record[field] == checked[field], (path, engine, field, record) + assert last is None or checked["scaled_cost"] < last["scaled_cost"], records + assert record["elapsed_ms"] >= elapsed + elapsed = record["elapsed_ms"] + last = { + field: record[field] + for field in ("tour", "scaled_cost", "scale", "cost", "return_time") + } + assert summary["elapsed_ms"] >= elapsed + assert summary["incumbent"] == last, records + if seed_result: + assert records[0]["tour"] == tour + assert records[0]["scaled_cost"] == seed_result["scaled_cost"] + if summary["termination"] == "optimal": + assert last is not None, records + if summary["termination"] == "infeasible": + assert last is None, records + return summary, records[:-1] + + +def validate_strengthening( + solver: Path, path: Path, configurations: Sequence[tuple[str, str]] +) -> None: + optimum = CHECKER["enumerate_tours"](CHECKER["read_instance"](path))["optimum"] + for bound, propagation in configurations: + for engine in ("bab", "astar", "cabs"): + summary, _ = run_solver( + solver, + path, + engine, + ("--bound", bound, "--residual-propagation", propagation), + formulation="transition", + ) + assert summary["termination"] == ("optimal" if optimum else "infeasible") + if optimum: + assert summary["incumbent"]["scaled_cost"] == optimum["scaled_cost"] + + +def validate_solver(solver: Path, manifest: dict[str, Any]) -> None: + engines = ("bab", "astar", "cabs") + formulations = ("closed", "transition", "eager") + for case in manifest["tiny"]: + path = DATA / case["path"] + optimum = CHECKER["enumerate_tours"](CHECKER["read_instance"](path))["optimum"] + validate_strengthening( + solver, path, (("min-arcs", "off"), ("min-arcs", "unary")) + ) + for engine in engines: + lazy_tours = None + for formulation in formulations: + summary, solutions = run_solver( + solver, path, engine, formulation=formulation + ) + assert summary["termination"] == ( + "optimal" if optimum else "infeasible" + ) + if optimum: + assert summary["incumbent"]["scaled_cost"] == optimum["scaled_cost"] + tours = [record["tour"] for record in solutions] + if formulation == "transition": + lazy_tours = tours + elif formulation == "eager": + assert tours == lazy_tours, (case, engine, lazy_tours, tours) + with tempfile.TemporaryDirectory(prefix="tsptw-solver-") as directory: + directory = Path(directory) + nonmetric = directory / "nonmetric.txt" + nonmetric.write_text( + (DATA / "tiny/waiting.txt").read_text().replace("0 12", "0 6") + ) + assert ( + CHECKER["enumerate_tours"](CHECKER["read_instance"](nonmetric))["optimum"][ + "cost" + ] + == "5" + ) + validate_strengthening(solver, nonmetric, (("min-arcs", "unary"),)) + validate_strengthening( + solver, DATA / "tiny/waiting.txt", (("prefix", "unary"),) + ) + seed = directory / "seed.tour" + folded = DATA / "tiny/folded-service.txt" + for formulation in formulations: + for engine in engines: + summary, _ = run_solver( + solver, nonmetric, engine, formulation=formulation + ) + assert ( + summary["termination"] == "optimal" + and summary["incumbent"]["cost"] == "5" + ) + seed.write_text("2 1\n") + summary, solutions = run_solver( + solver, folded, engine, seed=seed, formulation=formulation + ) + assert ( + summary["termination"] == "optimal" + and summary["incumbent"]["cost"] == "9" + ) + assert solutions[0]["cost"] == "13" and len(solutions) >= 2 + seed.write_text("1 2\n") + summary, solutions = run_solver( + solver, folded, engine, seed=seed, formulation=formulation + ) + assert summary["termination"] == "optimal" and len(solutions) == 1 + for instance, tour in [ + (folded, "1 1\n"), + (DATA / "tiny/waiting.txt", "2 1\n"), + (folded, "1 2 999999999999999999999\n"), + ]: + seed.write_text(tour) + result = subprocess.run( + [ + str(solver), + str(instance), + "--engine", + engine, + "--formulation", + formulation, + "--seed", + str(seed), + ], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 1 and not result.stdout, result + # A cheap prefix can publish an expensive return before better tours. + improving = directory / "improving.txt" + improving.write_text( + "4\n0 1 2 3\n1 0 1 4\n1 3 0 1\n20 1 3 0\n0 100\n5 100\n0 100\n0 100\n" + ) + optimum = CHECKER["enumerate_tours"](CHECKER["read_instance"](improving))[ + "optimum" + ] + for engine in engines: + lazy_tours = None + for formulation in ("transition", "eager"): + summary, solutions = run_solver( + solver, improving, engine, formulation=formulation + ) + assert summary["termination"] == "optimal" + assert summary["incumbent"]["scaled_cost"] == optimum["scaled_cost"] + assert len(solutions) >= 2 + tours = [record["tour"] for record in solutions] + if lazy_tours is None: + lazy_tours = tours + else: + assert tours == lazy_tours, (engine, lazy_tours, tours) + # Equal-cost routes still need decisions after cost branching. + branching = directory / "branching.txt" + branching.write_text( + "5\n" + + "\n".join( + " ".join("0" if i == j else "1" for j in range(5)) for i in range(5) + ) + + "\n" + + "0 100\n" * 5 + ) + branching_optimum = CHECKER["enumerate_tours"]( + CHECKER["read_instance"](branching) + )["optimum"]["scaled_cost"] + for formulation in formulations: + for engine in engines: + summary, _ = run_solver( + solver, + branching, + engine, + ("--node-limit", "0"), + formulation=formulation, + ) + assert summary["termination"] == "stopped" + assert summary["statistics"]["node"] > 0 + for limit in ("--max-width", "--frontier-limit"): + summary, _ = run_solver( + solver, branching, "cabs", (limit, "1"), formulation=formulation + ) + # A ceiling stops only when further pending work or widening + # is needed. Tight total bounds can prove this equal-cost tree + # complete before hitting the ceiling. + assert summary["termination"] in ("stopped", "optimal"), summary + if summary["termination"] == "optimal": + assert summary["incumbent"]["scaled_cost"] == branching_optimum + complete = stopped = 0 + for case in manifest["published"]: + proven_cost = None + for formulation in formulations: + for engine in engines: + summary, _ = run_solver( + solver, + DATA / case["path"], + engine, + ("--time-ms", "5000"), + DATA / case["tour_path"], + formulation, + ) + assert Fraction(summary["incumbent"]["cost"]) <= Fraction( + case["exact_cost"] + ) + assert summary["termination"] != "infeasible" + if summary["termination"] == "optimal": + cost = summary["incumbent"]["scaled_cost"] + assert proven_cost is None or cost == proven_cost + proven_cost = cost + complete += 1 + else: + stopped += 1 + print( + f"passed closed/lazy/eager BAB/A*/CABS: tiny optima/infeasibility, " + f"matching transition tours, nonmetric filtering, " + f"minimum-arc bounds and residual unary propagation, " + f"checked seeds and stops; " + f"canonical runs: {complete} proven, {stopped} stopped (5 seconds per run)" + ) + + +def validate(reader: Path, solver: Path | None) -> None: + manifest = json.loads((DATA / "manifest.json").read_text()) + checked = 0 + for case in [*manifest["tiny"], *manifest["published"]]: + path = DATA / case["path"] + assert hashlib.sha256(path.read_bytes()).hexdigest() == case["sha256"] + validate_native(reader, path) + data = CHECKER["read_instance"](path) + if "tour_path" in case: + tour_path = DATA / case["tour_path"] + assert ( + hashlib.sha256(tour_path.read_bytes()).hexdigest() + == case["tour_sha256"] + ) + tour = [int(x) for x in tour_path.read_text().split()] + result = CHECKER["check_tour"](data, tour, case["exact_cost"]) + displayed = case["published_cost"] + places = len(displayed.split(".")[1]) if "." in displayed else 0 + assert abs(Fraction(result["cost"]) - Fraction(displayed)) <= Fraction( + 1, 2 * 10**places + ) + else: + result = CHECKER["enumerate_tours"](data) + assert result["feasible_tours"] == case["feasible_tours"] + optimum = result["optimum"] + assert (None if optimum is None else optimum["cost"]) == case["optimum"] + checked += 1 + waiting = CHECKER["read_instance"](DATA / "tiny/waiting.txt") + result = CHECKER["check_tour"](waiting, [1, 2], "5") + assert result["visits"][0]["waiting"] == "3" and result["return_time"] == "8" + reject(lambda: CHECKER["check_tour"](waiting, [1, 1]), "duplicate") + reject(lambda: CHECKER["check_tour"](waiting, [1]), "omits") + reject(lambda: CHECKER["check_tour"](waiting, [1, 3]), "invalid") + reject(lambda: CHECKER["check_tour"](waiting, [1, 2], "8"), "incorrect cost") + for case, message in [ + ("late-customer", "late customer"), + ("late-return", "late return"), + ]: + data = CHECKER["read_instance"](DATA / f"tiny/{case}.txt") + reject(lambda data=data: CHECKER["check_tour"](data, [1, 2]), message) + with tempfile.TemporaryDirectory(prefix="tsptw-validate-") as directory: + path = Path(directory) / "input.txt" + good = "2\n0 1\n1 0\n0 10\n0 10\n" + bad_inputs = [ + good.replace("2\n", "3\n", 1), + good + "1\n", + good.replace("0 1", "0 -1", 1), + good.replace("0 1", "0 1.0.00", 1), + good.replace("0 1", "0 1e1", 1), + good.replace("0 1", "0 .", 1), + good.replace("0 10", "11 10", 1), + good.replace("0 10", "1 10", 1), + good.replace("0 1", "0 2147483647", 1), + good.replace("0 1", "0 1073741824", 1), + good.replace("0 1", "0 0.0000000001", 1), + good.replace("0 10", "0 2147483646", 1), + ] + for content in bad_inputs: + path.write_text(content) + try: + CHECKER["read_instance"](path) + except ValueError: + pass + else: + raise AssertionError(f"Python accepted invalid data: {content}") + assert ( + subprocess.run( + [str(reader), str(path)], capture_output=True, check=False + ).returncode + == 1 + ) + path.write_text(good.replace("0 1", "0 1.000", 1) + "# optional comment\n") + validate_native(reader, path) + for zero in (".0", ".00"): + path.write_text(good.replace("0 1", f"{zero} 1", 1)) + validate_native(reader, path) + tour_path = Path(directory) / "tour.txt" + tour_path.write_text("1 2\n") + command = [ + sys.executable, + str(Path(__file__).with_name("tsptw-check.py")), + "check", + str(DATA / "tiny/waiting.txt"), + str(tour_path), + "--cost", + "5", + ] + assert subprocess.run(command, capture_output=True, check=False).returncode == 0 + tour_path.write_text("1 1\n") + assert subprocess.run(command, capture_output=True, check=False).returncode == 1 + print( + f"passed: {checked} bundled instances, five tiny enumerations, four published tours, " + "reader rejection cases, checker failures and CLI smoke" + ) + if solver: + validate_solver(solver, manifest) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--reader", type=Path, required=True, help="compiled misc/tsptw-read.cpp" + ) + parser.add_argument( + "--solver", type=Path, help="also validate the native tsptw executable" + ) + args = parser.parse_args() + validate(args.reader.resolve(), args.solver.resolve() if args.solver else None) diff --git a/test/search.cpp b/test/search.cpp index 70cf9d8cc0..f5d606edfa 100644 --- a/test/search.cpp +++ b/test/search.cpp @@ -40,11 +40,18 @@ #include #include "test/test.hh" +#ifdef GECODE_HAS_EXPERIMENTAL +#include "examples/tsptw-transition.hh" +#endif #include #include #include #include +#include +#include +#include +#include #include #include @@ -83,6 +90,1343 @@ namespace Test { using namespace Gecode; using namespace Gecode::Int; +#ifdef GECODE_HAS_EXPERIMENTAL + /// Successful preparation can end in failure or skip to a solved brancher. + class PreparedTerminal : public Base { + class PreparedSpace : public Space { + class PreparedBrancher : public Brancher { + bool prepared, failure; + std::shared_ptr> choices; + class PreparedChoice : public Choice { + public: + PreparedChoice(const Brancher& b) : Choice(b,1) {} + }; + public: + PreparedBrancher(Home home, bool f, std::shared_ptr> c) + : Brancher(home), prepared(false), failure(f), choices(std::move(c)) { + home.notice(*this,AP_DISPOSE,false); + } + PreparedBrancher(Space& home, PreparedBrancher& b) + : Brancher(home,b), prepared(b.prepared), failure(b.failure), choices(b.choices) {} + bool status(const Space&) const override { return !prepared; } + void prepare(Space& home, StatusStatistics&) override { + prepared=true; + if (failure) home.fail(); + } + const Choice* choice(Space&) override { + (*choices)++; + return new PreparedChoice(*this); + } + const Choice* choice(const Space&, Archive&) override { return new PreparedChoice(*this); } + ExecStatus commit(Space&, const Choice&, unsigned int) override { return ES_OK; } + Actor* copy(Space& home) override { return new (home) PreparedBrancher(home,*this); } + size_t dispose(Space& home) override { + home.ignore(*this,AP_DISPOSE,false); + choices.~shared_ptr(); + (void) Brancher::dispose(home); + return sizeof(*this); + } + }; + public: + PreparedSpace(bool failure, std::shared_ptr> choices) { + (void) new (*this) PreparedBrancher(*this,false,choices); + (void) new (*this) PreparedBrancher(*this,failure,choices); + } + PreparedSpace(PreparedSpace& s) : Space(s) {} + Space* copy(void) override { return new PreparedSpace(*this); } + }; + public: + PreparedTerminal(void) : Base("Search::PreparedTerminal",TestTags::check()) {} + bool run(void) override { + for (bool failure : {false,true}) + for (unsigned int kind=0; kind<5; kind++) { + auto choices=std::make_shared>(0); + Gecode::Search::NodeStop stop(100); + Gecode::Search::Options options; options.clone=false; options.stop=&stop; + std::unique_ptr engine; + if (kind==0) engine.reset(Gecode::Search::dfsengine(new PreparedSpace(failure,choices),options)); + else if (kind==1) engine.reset(Gecode::Search::ldsengine(new PreparedSpace(failure,choices),options)); + else if (kind==2) { + options.threads=2; + engine.reset(Gecode::Search::babengine(new PreparedSpace(failure,choices),options)); + } + else { + using Guidance=Gecode::Search::HeuristicGuidance; + Guidance guidance(Guidance::MINIMIZE,[](const Space&) -> std::int64_t { return 0; }); + if (kind==3) engine.reset(Gecode::Search::astarengine(new PreparedSpace(failure,choices),guidance,options)); + else engine.reset(Gecode::Search::cabsengine(new PreparedSpace(failure,choices),guidance,options)); + } + std::unique_ptr result(engine->next()); + auto stat=engine->statistics(); + if (static_cast(result)==failure || choices->load() || engine->stopped() || + stat.node!=1 || stat.fail!=(failure ? 1U : 0U) || stat.depth) return false; + } + return true; + } + } preparedTerminal; + + + /// The example's real n-ary transitions survive cloning and continuation. + class TSPTWTransitionTest : public Base { + using Transition=TSPTW::Transition; + static std::shared_ptr instance(void) { + std::istringstream input( + "4\n0 1 2 3\n1 0 1 4\n1 3 0 1\n20 1 3 0\n" + "0 100\n5 100\n0 100\n0 100\n"); + return std::make_shared(TSPTW::read(input)); + } + static bool visit(Transition& s, int node) { + Gecode::Search::Statistics stat; + if (s.status()!=SS_BRANCH) return false; + s.prepare(stat); + const auto actions=s.actions(); + const auto action=std::find(actions.begin(),actions.end(),node); + if (action==actions.end()) return false; + std::unique_ptr choice(s.choice()); + s.commit(*choice,static_cast(action-actions.begin())); + return !s.failed(); + } + static bool replay(Transition::Representation representation, + Transition::ResidualPropagation propagation=Transition::OFF) { + const auto data=instance(); + Transition root(data,representation,propagation); + Gecode::Search::Statistics stat; + const bool lazy=representation==Transition::LAZY; + if (root.status()!=SS_BRANCH || root.allocated_positions()!=(lazy ? 0 : 4)) + return false; + std::unique_ptr absent(static_cast(root.clone())); + root.prepare(stat); + if (root.allocated_positions()!=(lazy ? 1 : 4) || + root.position(0).assigned() || root.depth || root.location || + root.time || root.cost() || root.unvisited!=std::vector({1,2,3})) + return false; + std::unique_ptr original(root.choice()); + Archive archive; original->archive(archive); + std::unique_ptr restored(root.choice(archive)); + if (restored->alternatives()!=3) return false; + absent->commit(*restored,0); + if (absent->status()!=SS_BRANCH || absent->depth!=1 || absent->location!=1 || + absent->time!=5 || absent->cost()!=1 || absent->returned || + absent->unvisited!=std::vector({2,3}) || + absent->position(0).val()!=1 || root.position(0).assigned()) + return false; + std::unique_ptr next(static_cast(absent->clone())); + absent->prepare(stat); + if (absent->depth!=1 || absent->cost()!=1 || + absent->allocated_positions()!=(lazy ? 2 : 4) || absent->position(1).assigned()) + return false; + original.reset(absent->choice()); + Archive prefix_archive; original->archive(prefix_archive); + restored.reset(absent->choice(prefix_archive)); + next->commit(*restored,0); + if (next->status()!=SS_BRANCH || next->depth!=2 || next->cost()!=2 || + next->time!=6 || next->position(1).val()!=2 || absent->position(1).assigned()) + return false; + + Transition optimum(data,representation,propagation); + for (int node : {2,3,1,0}) if (!visit(optimum,node)) return false; + if (optimum.status()!=SS_SOLVED || optimum.cost()!=5) return false; + std::unique_ptr cut(static_cast(absent->clone())); + cut->constrain(optimum); + if (cut->status()!=SS_BRANCH || cut->actions()!=std::vector({2})) return false; + // The original second alternative still means customer 3 after a cut + // shrinks the fresh choice; it cannot silently become another action. + cut->commit(*restored,1); + if (cut->status()!=SS_FAILED) return false; + if (!visit(*next,3) || next->returned || next->cost()!=3 || + next->status()!=SS_BRANCH || next->actions()!=std::vector({0}) || + !visit(*next,0)) return false; + return next->status()==SS_SOLVED && next->returned && next->depth==4 && + next->cost()==23 && next->time==27 && next->allocated_positions()==4 && + next->tour()==std::vector({1,2,3}) && root.depth==0 && absent->depth==1; + } + static bool continuation(Transition::Representation representation, int kind) { + const auto data=instance(); + Transition root(data,representation), incumbent(data,representation); + for (int node : {3,1,2,0}) if (!visit(incumbent,node)) return false; + if (incumbent.status()!=SS_SOLVED || incumbent.cost()!=6) return false; + Gecode::Search::NodeStop stop(10000); + Gecode::Search::Options options; + options.threads=1; options.stop=&stop; + // Force BAB to replay edges between stored prefix clones. + options.c_d=5; options.a_d=2; + Gecode::Search::WorkerControl control(1U); +#ifdef GECODE_HAS_THREADS + options.worker_control=control; +#endif + std::unique_ptr engine(kind==0 ? + Gecode::Search::babengine(&root,options) : kind==1 ? + Gecode::Search::astarengine(&root,Transition::guidance(),options) : + Gecode::Search::cabsengine(&root,Transition::guidance(),options)); + std::unique_ptr first(static_cast(engine->next())); + if (!first || first->cost()!=23 || first->tour()!=std::vector({1,2,3})) + return false; + const auto before=engine->statistics().node; + stop.limit(before-1); + if (engine->next() || !engine->stopped() || engine->statistics().node!=before) + return false; + stop.limit(10000); + engine->constrain(incumbent); + std::unique_ptr better; +#ifdef GECODE_HAS_THREADS + control.request(0U); + std::atomic entered(false), done(false); + std::thread caller([&] { + entered.store(true,std::memory_order_release); + better.reset(static_cast(engine->next())); + done.store(true,std::memory_order_release); + }); + while (!entered.load(std::memory_order_acquire)) std::this_thread::yield(); + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + const bool parked=!done.load(std::memory_order_acquire) && + engine->statistics().node==before; + control.request(1U); + caller.join(); + if (!parked) return false; +#else + better.reset(static_cast(engine->next())); +#endif + return better && better->cost()==5 && better->tour()==std::vector({2,3,1}) && + !engine->next() && !engine->stopped() && engine->statistics().depth==4; + } + struct Suffix { + std::int64_t cost; + int first; + }; + // Numeric permutations use no bound, transition applicability or CP model. + static std::vector completions(const Transition& state) { + if (state.returned) return {{state.prefix_cost,-1}}; + std::vector result; + auto suffix=state.unvisited; + do { + std::int64_t time=state.time, cost=state.prefix_cost; + int location=state.location; + bool feasible=true; + for (std::size_t p=0; p<=suffix.size(); p++) { + const int node=p==suffix.size() ? 0 : suffix[p]; + const int arc=state.data->arc(location,node); + time=std::max(time+arc,state.data->ready[node]); + cost+=arc; + if (time>state.data->due[node]) { feasible=false; break; } + location=node; + } + if (feasible) result.push_back({cost,suffix.empty() ? 0 : suffix.front()}); + } while (std::next_permutation(suffix.begin(),suffix.end())); + return result; + } + static bool residuals(Transition& state, const std::vector& prefix, + bool& filtered, bool& rejected) { + const auto suffixes=completions(state); + const auto bound=state.bound(Transition::MIN_ARCS); + if (boundarc(state.location,0)) return false; + for (const auto& suffix : suffixes) if (bound>suffix.cost) return false; + + for (auto propagation : {Transition::CHEAP,Transition::UNARY}) { + Transition inferred(state.data,Transition::LAZY,propagation); + bool survived=inferred.status()!=SS_FAILED; + for (int node : prefix) { + if (!survived) break; + survived=visit(inferred,node); + } + if (!survived) { + if (propagation==Transition::CHEAP) rejected=true; + if (!suffixes.empty()) return false; + } else { + if (inferred.unvisited!=state.unvisited || inferred.location!=state.location || + inferred.time!=state.time || inferred.cost()!=state.cost()) return false; + const auto actions=inferred.actions(); + if (actions.size() choice(state.choice()); + for (std::size_t a=0; a child(static_cast(state.clone())); + child->commit(*choice,static_cast(a)); + auto next=prefix; + next.push_back(actions[a]); + // Even a failed committed residual state must have no feasible suffix. + if (!residuals(*child,next,filtered,rejected)) return false; + } + return true; + } + static bool dominance(std::shared_ptr data) { + Transition reference(data); + const auto suffixes=completions(reference); + std::int64_t optimum=std::numeric_limits::max(); + for (const auto& suffix : suffixes) optimum=std::min(optimum,suffix.cost); + for (auto representation : {Transition::LAZY,Transition::EAGER}) + for (auto propagation : {Transition::OFF,Transition::CHEAP,Transition::UNARY}) { + if (data->n==5) { + auto label=[&](std::initializer_list prefix) { + auto state=std::make_unique(data,representation,propagation); + for (int node : prefix) + if (!visit(*state,node)) return std::unique_ptr{}; + return state; + }; + auto a=label({1,4,2}), b=label({4,1,2}); + // The earlier arrival costs more: neither label subsumes the other. + if (!a || !b || a->time!=21 || a->cost()!=9 || + b->time!=20 || b->cost()!=11 || Transition::dominates(*a,*b) || + Transition::dominates(*b,*a)) return false; + a=label({1,3,2}); b=label({3,1,2}); + if (!a || !b || !Transition::dominates(*a,*a) || + !Transition::dominates(*a,*b) || Transition::dominates(*b,*a) || + Transition::dominates(*a,reference) || + Transition::dominance_key(*a)!=Transition::dominance_key(*b)) + return false; + } + for (auto bound : {Transition::PREFIX,Transition::MIN_ARCS}) + for (bool enabled : {false,true}) { + Transition root(data,representation,propagation); + Gecode::Search::CABSOptions options; + if (enabled) { + options.dominates=Transition::dominates; + options.dominance_key=Transition::dominance_key; + } + Gecode::CABS engine(&root,Transition::guidance(bound), + Gecode::Search::Options::def,options); + std::int64_t best=std::numeric_limits::max(); + while (std::unique_ptr solution{engine.next()}) + best=solution->cost(); + if (engine.stopped() || best!=optimum) return false; + } + } + return true; + } + static bool bounds_and_inference(void) { + bool filtered=false, rejected=false; + // Waiting/nonmetric and unequal/incomparable (t,g) labels; tight unary + // filtering and failure; zero-duration intervals; pending/finished return. + for (const std::string& text : { + std::string("4\n0 1 2 3\n1 0 1 4\n1 3 0 1\n20 1 3 0\n" + "0 100\n5 100\n0 100\n0 100\n"), + std::string("5\n0 3 1 7 4\n4 0 5 1 3\n2 1 0 4 6\n" + "3 4 2 0 1\n1 2 3 5 0\n" + "0 100\n15 100\n0 100\n0 100\n5 100\n"), + std::string("3\n0 2 9\n9 0 2\n1 9 0\n0 6\n5 5\n0 7\n"), + // Direct 0->2 misses due 6 and, after waiting, direct 1->0 + // misses depot due 8; visiting 1,2 still returns at 8, cost 5. + std::string("3\n0 2 9\n4 0 1\n2 8 0\n0 8\n5 10\n0 6\n"), + std::string("3\n0 0 0\n3 0 3\n3 3 0\n0 10\n0 0\n0 4\n"), + std::string("3\n0 0 0\n3 0 3\n3 3 0\n0 20\n0 2\n0 2\n"), + std::string("3\n0 0 0\n0 0 0\n0 0 0\n0 0\n0 0\n0 0\n")}) { + std::istringstream input(text); + auto data=std::make_shared(TSPTW::read(input)); + Transition root(data); + if (!residuals(root,{},filtered,rejected) || !dominance(data)) return false; + } + return filtered && rejected; + } + public: + TSPTWTransitionTest(void) : Base("Search::TSPTW::Transition", TestTags::check()) {} + bool run(void) override { + for (auto representation : {Transition::LAZY,Transition::EAGER}) { + if (!replay(representation) || !replay(representation,Transition::CHEAP) || + !replay(representation,Transition::UNARY)) + return false; + for (int kind : {0,1,2}) + if (!continuation(representation,kind)) return false; + } + return bounds_and_inference(); + } + } tsptwTransitionTest; + + /// A tiny closed tree with a generated goal and deeper alternatives. + class AStarSpace : public Space { + public: + static std::atomic live; + IntVar x, y; + bool maximize; + AStarSpace(bool max=false, int fixed=-1, bool failed=false) + : x(*this,0,3), y(*this,0,1), maximize(max) { + live++; + TupleSet tuples(2); + for (int i=0;i<4;i++) { + tuples.add(IntArgs({i,0})); + if (i) tuples.add(IntArgs({i,1})); + } + tuples.finalize(); + extensional(*this,IntVarArgs({x,y}),tuples); + if (fixed >= 0) { + rel(*this,x,IRT_EQ,fixed); + rel(*this,y,IRT_EQ,0); + } + if (failed) fail(); + Gecode::branch(*this,x,INT_VALUES_MIN()); + Gecode::branch(*this,y,INT_VALUES_MIN()); + } + AStarSpace(AStarSpace& s) + : Space(s), maximize(s.maximize) { + live++; + x.update(*this,s.x); y.update(*this,s.y); + } + ~AStarSpace(void) override { live--; } + Space* copy(void) override { return new AStarSpace(*this); } + // Preserve this model's branching in each portfolio asset. + bool master(const Gecode::MetaInfo&) override { return true; } + std::int64_t score(int value) const { + const std::int64_t minimum[] = { + 9,-2,std::numeric_limits::min(),5}; + const std::int64_t maximum[] = { + std::numeric_limits::min(),2, + std::numeric_limits::max(),-5}; + return maximize ? maximum[value] : minimum[value]; + } + std::int64_t objective(void) const { return score(x.val()); } + bool improves(std::int64_t a, std::int64_t b) const { + return maximize ? a>b : a(s0); + for (int i=0;i<4;i++) + if (!improves(score(i),s.objective())) rel(*this,x,IRT_NQ,i); + } + static Gecode::Search::HeuristicGuidance guidance(bool max, + bool loose=false) { + using Guidance=Gecode::Search::HeuristicGuidance; + return Guidance(max ? Guidance::MAXIMIZE : Guidance::MINIMIZE, + [loose](const Space& s0) -> std::int64_t { + const auto& s=static_cast(s0); + if (loose && (!s.x.assigned() || !s.y.assigned())) + return s.maximize ? std::numeric_limits::max() : + std::numeric_limits::min(); + std::int64_t bound=s.score(s.x.min()); + for (IntVarValues values(s.x); values(); ++values) + if (s.improves(s.score(values.val()),bound)) + bound=s.score(values.val()); + return bound; + }, + [](const Space& s0) -> std::int64_t { + const auto& s=static_cast(s0); + // An arbitrary preference opposes the optimum at alternative 2. + return s.x.assigned() && s.x.val()==2 ? 1000 : 0; + }); + } + }; + + std::atomic AStarSpace::live(0); + + /// Throw before a replacement root can be adopted by an engine. + class ThrowingRoot : public AStarSpace { + public: + ThrowingRoot(bool propagation) { + if (propagation) { + Gecode::wait(*this,x,[](Space&) { + throw std::runtime_error("propagation failure"); + }); + rel(*this,x,IRT_EQ,0); + } + } + Space* copy(void) override { + throw std::runtime_error("copy failure"); + } + }; + + static bool root_exceptions(bool cabs, unsigned int threads=1) { + Gecode::Search::Options options; options.clone=false; + options.threads=threads; + const auto guidance=AStarSpace::guidance(false); + auto engine=[&](Space* root) { + return std::unique_ptr(cabs ? + Gecode::Search::cabsengine(root,guidance,options) : + Gecode::Search::astarengine(root,guidance,options)); + }; + for (bool propagation : {false,true}) { + // AStar with clone=false does not copy a root during construction. + if (!cabs && !propagation) options.clone=true; + std::unique_ptr root(new ThrowingRoot(propagation)); + try { auto invalid=engine(root.get()); return false; } + catch (const std::runtime_error&) {} + if (AStarSpace::live.load()!=1) return false; + root.reset(); + options.clone=false; + // Reset transfers ownership even when propagation or cloning throws. + if (cabs || propagation) { + auto reset=engine(nullptr); + try { reset->reset(new ThrowingRoot(propagation)); return false; } + catch (const std::runtime_error&) {} + reset.reset(); + if (AStarSpace::live.load()!=0) return false; + } + } + auto reset=engine(nullptr); + reset->reset(new AStarSpace(false,-1,true)); + return !reset->next() && !reset->stopped() && + AStarSpace::live.load()==0; + } + // The parallel test runner shares this fixture's ownership counter. + static std::mutex heuristic_ownership_mutex; + + /// An explicit finite tree with bounds and ranks independent of its costs. + class TreeSpace : public Space { + public: + struct Node { + int cost, bound, rank; + std::vector children; + }; + using Graph=std::vector; + std::shared_ptr graph; + bool maximize; + int vertex=0, ceiling=std::numeric_limits::max(); + 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).value().children.empty(); + } + const Choice* choice(Space& s) override { + const auto& state=static_cast(s); + return new Decision(*this,state.vertex, + static_cast(state.value().children.size())); + } + const Choice* choice(const Space&, Archive& e) override { + int vertex; unsigned int n; e >> vertex >> n; + return new Decision(*this,vertex,n); + } + ExecStatus commit(Space& s0, const Choice& c, unsigned int a) override { + auto& s=static_cast(s0); + const auto& decision=static_cast(c); + s.vertex=(*s.graph)[decision.vertex].children[a]; + return s.value().cost>=s.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); + } + }; + explicit TreeSpace(Graph nodes, bool max=false) + : graph(std::make_shared(std::move(nodes))), maximize(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); } + const Node& value(void) const { return (*graph)[vertex]; } + void constrain(const Space& s0) override { + ceiling=static_cast(s0).value().cost; + if (value().cost>=ceiling) fail(); + } + static Gecode::Search::HeuristicGuidance guidance(bool max=false) { + using Guidance=Gecode::Search::HeuristicGuidance; + // Maximizing the negated cost mirrors the same finite paths and cuts. + return {max ? Guidance::MAXIMIZE : Guidance::MINIMIZE, + [](const Space& s)->std::int64_t { + const auto& state=static_cast(s); + const std::int64_t bound=state.value().bound; + return state.maximize ? -bound : bound; + },[](const Space& s)->std::int64_t { + return static_cast(s).value().rank; + }}; + } + static int optimum(const Graph& graph, int vertex=0) { + const auto& node=graph[vertex]; + if (node.children.empty()) return node.cost; + int best=std::numeric_limits::max(); + for (int child : node.children) best=std::min(best,optimum(graph,child)); + return best; + } + }; + + class AStarTest : public Base { + static bool ordering(void) { + using namespace Gecode::Search; + // Every root alternative needs another commit. The first arrival has + // the preferred rank but the worse bound; arrivals 2 and 3 lead to + // the same goal. + const TreeSpace::Graph graph={ + {0,0,0,{1,2,3}}, {0,8,-100,{4}}, + {0,4,100,{5}}, {0,4,100,{5}}, + {10,10,0,{}}, {5,5,0,{}}}; + auto solve=[&](bool max, AStarOrder order, bool ranked) { + Gecode::Search::Options options; options.clone=false; + auto guidance=TreeSpace::guidance(max); + if (!ranked) guidance.rank={}; + Gecode::AStar engine(new TreeSpace(graph,max), + guidance,order,options); + if (!engine.best) return false; + std::vector costs; + while (std::unique_ptr solution{ + engine.next()}) + costs.push_back(solution->value().cost); + const std::vector expected=order==RANK_FIRST && ranked ? + std::vector{10,5} : std::vector{5}; + return costs==expected && !engine.stopped() && + costs.back()==TreeSpace::optimum(graph); + }; + for (bool max : {false,true}) + if (!solve(max,BOUND_FIRST,true) || !solve(max,RANK_FIRST,true)) return false; + return solve(false,RANK_FIRST,false); + } + static bool solutions(bool max, bool loose, bool interrupt) { + Gecode::Search::NodeStop stop(1); + Gecode::Search::Options o; o.clone=false; + if (interrupt) o.stop=&stop; + Gecode::AStar engine(new AStarSpace(max), + AStarSpace::guidance(max,loose),o); + std::unique_ptr first(engine.next()); + if (!first || first->x.val()!=0) return false; + auto previous=first->objective(); + if (interrupt) { + const auto before=engine.statistics(); + if (engine.next() || !engine.stopped() || + engine.statistics().node!=before.node) return false; + stop.limit(10000); + } + unsigned int found=1; + while (std::unique_ptr solution{engine.next()}) { + if (!solution->improves(solution->objective(),previous)) return false; + previous=solution->objective(); found++; + } + // Enumerating the four scores independently identifies alternative 2. + return previous==first->score(2) && found==(loose ? 3U : 2U) && + !engine.stopped() && engine.statistics().depth==2; + } + static bool adoption(bool max, bool queued) { + Gecode::Search::Options o; o.clone=false; + Gecode::AStar engine(new AStarSpace(max), + AStarSpace::guidance(max,true),o); + std::unique_ptr first(engine.next()); + if (!first) return false; + if (queued) { + std::unique_ptr second( + engine.next()); + if (!second || second->x.val()!=1) return false; + } + AStarSpace incumbent(max,queued ? 2 : 1), worse(max,0); + incumbent.status(); worse.status(); + engine.constrain(incumbent); + engine.constrain(incumbent); + engine.constrain(worse); + unsigned int found=0; + while (std::unique_ptr solution{ + engine.next()}) { + if (!solution->improves(solution->objective(),incumbent.objective()) || + solution->x.val()!=2) return false; + found++; + } + if (engine.stopped() || found!=(queued ? 0U : 1U)) return false; + return true; + } + static bool helpers(bool max) { + Gecode::Search::Options o; o.clone=false; + const auto guidance=AStarSpace::guidance(max,true); + // These searches first publish x=0; the helpers must keep improving. + std::unique_ptr astar( + Gecode::astar(new AStarSpace(max),guidance,o)); + std::unique_ptr ordered(Gecode::astar( + new AStarSpace(max),guidance,Gecode::Search::RANK_FIRST,o)); + if (!astar || astar->x.val()!=2 || !ordered || ordered->x.val()!=2) + return false; + std::unique_ptr failed_astar( + Gecode::astar(new AStarSpace(max,-1,true),guidance,o)); + std::unique_ptr failed_ordered(Gecode::astar( + new AStarSpace(max,-1,true),guidance,Gecode::Search::RANK_FIRST,o)); + return !failed_astar && !failed_ordered; + } + static bool ownership(void) { + Gecode::Search::Options o; + for (bool clone : {false,true}) { + o.clone=clone; + for (int state : {-2,-1,0}) { + std::unique_ptr root(new AStarSpace(false,state,state==-2)); + { + Gecode::AStar engine(root.get(), + AStarSpace::guidance(false),o); + if (!clone) root.release(); + std::unique_ptr result(engine.next()); + if ((state!=-2)!=static_cast(result)) return false; + if (state==0 && (engine.next() || engine.stopped())) return false; + // Destroy after publication with a pending parent and queued work. + if (state==-1) delete engine.next(); + } + root.reset(); + if (AStarSpace::live.load()!=0) return false; + } + } + AStarSpace root; + o.clone=false; + using Guidance=Gecode::Search::HeuristicGuidance; + try { + Gecode::AStar invalid(&root,Guidance(Guidance::MINIMIZE,{}),o); + return false; + } catch (const Gecode::Search::UninitializedAStarBound&) {} + o.clone=true; + { + Guidance throws(Guidance::MINIMIZE,[](const Space&) -> std::int64_t { + throw std::runtime_error("guidance failure"); + }); + Gecode::AStar engine(&root,throws,o); + try { delete engine.next(); return false; } + catch (const std::runtime_error&) {} + } + return AStarSpace::live.load()==1; + } + public: + AStarTest(void) : Base("Search::AStar", TestTags::check()) {} + bool run(void) override { + std::lock_guard lock(heuristic_ownership_mutex); + for (bool max : {false,true}) + if (!solutions(max,false,false) || !solutions(max,true,true) || + !adoption(max,false) || !adoption(max,true) || !helpers(max)) + return false; + if (!ordering() || !root_exceptions(false) || !ownership() || + AStarSpace::live.load()!=0) + return false; + Gecode::Search::Options o; o.clone=false; + std::unique_ptr engine( + Gecode::Search::astarengine(nullptr,AStarSpace::guidance(false),o)); + if (engine->next() || engine->stopped()) return false; + engine->reset(new AStarSpace(false,2)); + std::unique_ptr solution(engine->next()); + if (!solution || engine->statistics().node!=1) return false; + engine->reset(nullptr); + return !engine->next() && !engine->stopped() && + engine->nogoods().ng()==0; + } + } astarTest; + + /// A narrow beam can keep a stable but ultimately infeasible branch. + class CABSFeasibility : public Space { + public: + IntVar x; + IntVarArray y; + CABSFeasibility(bool infeasible=false) : x(*this,0,1), y(*this,3,0,1) { + TupleSet tuples(3); + for (int branch=0;branch<2;branch++) + for (int a=0;a<2;a++) + for (int b=0;b<2;b++) + if (branch || a!=b) tuples.add(IntArgs({branch,a,b})); + tuples.finalize(); + for (int a=0;a<3;a++) + extensional(*this,IntVarArgs({x,y[a],y[(a+1)%3]}),tuples); + if (infeasible) rel(*this,x,IRT_EQ,0); + Gecode::branch(*this,x,INT_VALUES_MIN()); + Gecode::branch(*this,y,INT_VAR_NONE(),INT_VALUES_MIN()); + } + CABSFeasibility(CABSFeasibility& s) : Space(s) { + x.update(*this,s.x); y.update(*this,s.y); + } + Space* copy(void) override { return new CABSFeasibility(*this); } + void constrain(const Space& s0) override { + rel(*this,x,IRT_LE,static_cast(s0).x.val()); + } + static Gecode::Search::HeuristicGuidance guidance(void) { + return {Gecode::Search::HeuristicGuidance::MINIMIZE, + [](const Space& s) -> std::int64_t { + return static_cast(s).x.min(); + }}; + } + }; + + class CABSTest : public Base { + static bool dominance(unsigned int threads=1, bool keyed=false) { + using Relation=std::vector>; + auto solve=[threads,keyed](const TreeSpace::Graph& graph, const Relation& relation, + unsigned long int width, int cost, bool stopped, + unsigned long int restarts, bool reset=false, + std::vector keys={}) { + Gecode::Search::Options options; options.clone=false; + options.threads=threads; + Gecode::Search::CABSOptions beam(width,0); + unsigned int calls=0; + bool grouped=true; + beam.dominates=[relation,keys,&calls,&grouped](const Space& a, const Space& b) { + calls++; + const auto x=static_cast(a).vertex; + const auto y=static_cast(b).vertex; + if (!keys.empty() && keys[x]!=keys[y]) grouped=false; + return std::find(relation.begin(),relation.end(),std::make_pair(x,y)) + !=relation.end(); + }; + if (keyed || !keys.empty()) beam.dominance_key=[keys](const Space& s) { + return keys.empty() ? std::size_t{0} : + keys[static_cast(s).vertex]; + }; + std::unique_ptr engine( + Gecode::Search::cabsengine(new TreeSpace(graph), + TreeSpace::guidance(),options,beam)); + beam.dominates={}; + beam.dominance_key={}; + for (int run=0; run<(reset ? 2 : 1); run++) { + const auto before=calls; + if (run) engine->reset(new TreeSpace(graph)); + int best=std::numeric_limits::max(); + while (std::unique_ptr solution{ + static_cast(engine->next())}) + best=solution->value().cost; + if (best!=cost || engine->stopped()!=stopped || calls==before || !grouped || + engine->statistics().restart!=restarts) return false; + } + return true; + }; + const TreeSpace::Graph pair={ + {0,0,0,{1,2}}, {0,0,0,{3}}, {0,0,9,{4}}, + {10,10,0,{}}, {5,5,0,{}}}; + // The worse-ranked dominator takes the only slot without width loss. + if (!solve(pair,{{2,1}},1,5,false,0)) return false; + auto equivalent=pair; + equivalent[3].cost=equivalent[3].bound=5; + // Equal labels retain the first representative, even with a better rank. + equivalent[2].rank=-1; + if (!solve(equivalent,{{1,2},{2,1}},1,5,false,0) || + !solve(pair,{},0,5,false,1)) return false; + + const TreeSpace::Graph multiple={ + {0,0,0,{1,2,3,4}}, {0,0,0,{5}}, {0,0,1,{6}}, + {0,0,9,{7}}, {0,0,10,{8}}, + {10,10,0,{}}, {11,11,0,{}}, {5,5,0,{}}, {4,4,0,{}}}; + // Removing both victims frees a slot for the worse-ranked optimum. + // Reset repeats widening and must retain the copied callback. + if (!solve(multiple,{{3,1},{3,2}},2,4,false,1,true) || + !solve(multiple,{{3,1},{3,2}},2,4,false,1,true,{0,7,7,7,9,0,0,0,0})) + return false; + + TreeSpace::Graph lifetime={ + {0,0,0,{1,2,3}}, {0,0,1,{4}}, {0,0,0,{5}}, {0,0,-1,{6}}, + {4,4,0,{}}, {10,10,0,{}}, {5,5,0,{}}}; + // Candidate 1 can subsume 3, but cannot do so after beam eviction. + if (!solve(lifetime,{{1,3}},1,5,true,0)) return false; + lifetime[0].children={2,1,3}; + // Nor may candidate 1 justify pruning after admission discards it. + return solve(lifetime,{{1,3}},1,5,true,0); + } + static bool discarded_bounds(bool max) { + Gecode::Search::Options options; options.clone=false; + auto solve=[&](const TreeSpace::Graph& graph, unsigned long int width, + int cost, bool stopped, unsigned long int restarts) { + Gecode::CABS engine(new TreeSpace(graph,max), + TreeSpace::guidance(max),options,{width,0}); + int previous=std::numeric_limits::max(); + bool found=false; + while (std::unique_ptr solution{engine.next()}) { + if (solution->value().cost>=previous) return false; + previous=solution->value().cost; + found=true; + } + return found && previous==cost && engine.stopped()==stopped && + engine.statistics().restart==restarts; + }; + for (bool evict : {false,true}) { + // Reject a worse-ranked candidate, or evict one arriving first. + // Equality also excludes strict improvement of the incumbent. + const int bound=evict ? 10 : 20; + const TreeSpace::Graph bounded={ + {0,0,0,evict ? std::vector{2,1} : std::vector{1,2}}, + {0,0,0,{3}}, {0,bound,1,{4}}, + {10,10,0,{}}, {bound,bound,0,{}}}; + if (!solve(bounded,1,TreeSpace::optimum(bounded),false,0)) + return false; + // Ranking keeps bound 5 over bound 0; the selected bound then falls + // to 1. A later discard at bound 20 must not erase the earlier 0. + const TreeSpace::Graph improving={ + {0,0,0,evict ? std::vector{2,1} : std::vector{1,2}}, + {0,5,0,{3,4}}, {0,0,1,{5}}, + {0,1,0,{6}}, {0,20,1,{7}}, + {4,4,0,{}}, {10,10,0,{}}, {20,20,0,{}}}; + if (!solve(improving,1,10,true,0) || + !solve(improving,0,TreeSpace::optimum(improving),false,1)) + return false; + } + return true; + } + static bool interrupted_discard(bool external) { + // Width loss precedes a shallow goal. The original fourth alternative + // and the pending parent both improve it and survive publication. + const TreeSpace::Graph graph={ + {0,0,0,{1,2,3,4}}, {0,0,0,{5}}, {0,7,1,{6}}, + {10,10,0,{}}, {8,8,0,{}}, + {6,6,0,{}}, {7,7,0,{}}}; + Gecode::Search::NodeStop stop(10000); + Gecode::Search::Options options; options.clone=false; options.stop=&stop; + Gecode::CABS engine(new TreeSpace(graph), + TreeSpace::guidance(),options,{1,0}); + std::unique_ptr first(engine.next()); + if (!first || first->value().cost!=10) return false; + const auto before=engine.statistics().node; + stop.limit(before-1); + for (int i=0;i<2;i++) + if (engine.next() || !engine.stopped() || + engine.statistics().node!=before) return false; + if (external) { + TreeSpace incumbent(graph); + incumbent.vertex=5; + if (incumbent.status()!=SS_SOLVED) return false; + engine.constrain(incumbent); + } + stop.limit(10000); + std::vector costs; + while (std::unique_ptr solution{engine.next()}) + costs.push_back(solution->value().cost); + return costs==(external ? std::vector{} : std::vector{8,6}) && + !engine.stopped() && !engine.statistics().restart && + engine.statistics().node==before+2; + } + static bool reset_discard(void) { + const TreeSpace::Graph bounded={ + {0,0,0,{1,2}}, {0,0,0,{3}}, {0,20,1,{4}}, + {10,10,0,{}}, {20,20,0,{}}}; + auto improving=bounded; + improving[2].bound=0; + improving[4].cost=improving[4].bound=4; + Gecode::Search::Options options; options.clone=false; + for (unsigned long int width : {1UL,2UL}) { + std::unique_ptr engine( + Gecode::Search::cabsengine(new TreeSpace(bounded), + TreeSpace::guidance(),options,{width,0})); + for (int run=0;run<3;run++) { + // Repeat widening after reset to check that width starts at one. + const bool unresolved=run==1 || (run==2 && width==2); + if (run) engine->reset(new TreeSpace(unresolved ? improving : bounded)); + if (engine->stopped() || engine->statistics().node || + engine->statistics().restart) return false; + std::vector costs; + while (std::unique_ptr solution{ + static_cast(engine->next())}) + costs.push_back(solution->value().cost); + const bool widen=unresolved && width==2; + if (costs!=(widen ? std::vector{10,4} : std::vector{10}) || + engine->stopped()!=(unresolved && width==1) || + engine->statistics().restart!=(widen ? 1UL : 0UL)) return false; + } + } + return true; + } + static bool solutions(bool max, unsigned long int width) { + Gecode::Search::Options o; o.clone=false; + Gecode::CABS engine(new AStarSpace(max), + AStarSpace::guidance(max,true),o,{width,0}); + std::int64_t previous=0; + unsigned int found=0; + while (std::unique_ptr s{engine.next()}) { + if (found && !s->improves(s->objective(),previous)) return false; + previous=s->objective(); found++; + } + // The helper returns the last improvement even when the width cap stops it. + std::unique_ptr best(Gecode::cabs(new AStarSpace(max), + AStarSpace::guidance(max,true),o,{width,0})); + if (!best || best->x.val()!=(width==1 ? 1 : 2)) return false; + AStarSpace reference(max,2); + if (width==1) { + // A shallow goal and a deeper improvement survive publication, but + // the misleading rank drops the optimum from the width-one beam. + const auto before=engine.statistics().node; + return found==2 && previous==reference.score(1) && engine.stopped() && + !engine.next() && engine.stopped() && engine.statistics().node==before; + } + return found==3 && previous==reference.objective() && !engine.stopped() && + engine.statistics().restart>0; + } + static bool feasibility(void) { + Gecode::Search::Options o; o.clone=false; + for (unsigned long int width : {1UL,0UL}) { + Gecode::CABS engine(new CABSFeasibility, + CABSFeasibility::guidance(),o,{width,0}); + std::unique_ptr s(engine.next()); + if (width==1) { + if (s || !engine.stopped() || engine.next() || !engine.stopped()) + return false; + } else { + if (!s || s->x.val()!=1 || engine.next() || engine.stopped() || + !engine.statistics().restart) return false; + } + } + std::unique_ptr failed_helper(Gecode::cabs( + new CABSFeasibility(true),CABSFeasibility::guidance(),o)); + if (failed_helper) return false; + Gecode::CABS failed(new CABSFeasibility(true), + CABSFeasibility::guidance(),o); + Gecode::BAB reference(new CABSFeasibility(true),o); + if (failed.next() || failed.stopped() || reference.next() || + reference.stopped()) return false; + Gecode::BAB bab(new AStarSpace,o); + int optimum=-1; + while (std::unique_ptr s{bab.next()}) optimum=s->x.val(); + return optimum==2 && !bab.stopped(); + } + static bool adoption(bool queued) { + Gecode::Search::Options o; o.clone=false; + Gecode::CABS engine(new AStarSpace, + AStarSpace::guidance(false,true),o); + std::unique_ptr first(engine.next()); + if (!first) return false; + if (queued) { + std::unique_ptr second( + engine.next()); + if (!second || second->x.val()!=1) return false; + } + { + AStarSpace incumbent(false,queued ? 2 : 1), worse(false,0); + incumbent.status(); worse.status(); + engine.constrain(incumbent); + engine.constrain(incumbent); + engine.constrain(worse); + } + // The accepted external copy survives the caller's destruction. Its + // exact INT64_MIN objective also bounds out every discarded candidate; + // a weaker adoption still lets the original alternatives recover x=2. + std::unique_ptr optimum( + engine.next()); + return (queued ? !optimum : optimum && optimum->x.val()==2) && + !engine.next() && !engine.stopped() && + (!queued || engine.statistics().restart==0); + } + static bool ownership_and_limits(void) { + Gecode::Search::Options o; + for (bool clone : {false,true}) { + o.clone=clone; + for (int state : {-2,-1,0}) { + std::unique_ptr root(new AStarSpace(false,state,state==-2)); + std::unique_ptr result; + { + Gecode::CABS engine(root.get(), + AStarSpace::guidance(false),o); + if (!clone) root.release(); + result.reset(engine.next()); + if ((state!=-2)!=static_cast(result)) return false; + if (state==0 && (engine.next() || engine.stopped())) return false; + } + // A returned solution remains independent of all engine storage. + if (result && result->status()!=SS_SOLVED) return false; + result.reset(); root.reset(); + if (AStarSpace::live.load()!=0) return false; + } + } + o.clone=false; + for (const auto limits : {Gecode::Search::CABSOptions(1,0), + Gecode::Search::CABSOptions(0,1)}) { + std::unique_ptr engine( + Gecode::Search::cabsengine(new AStarSpace, + AStarSpace::guidance(false,true),o,limits)); + while (std::unique_ptr s{engine->next()}) {} + const auto before=engine->statistics().node; + if (!engine->stopped() || engine->next() || !engine->stopped() || + engine->statistics().node!=before) return false; + engine->reset(new AStarSpace(false,2)); + std::unique_ptr s(engine->next()); + if (!s || engine->stopped() || engine->statistics().node!=1 || + engine->statistics().restart || engine->next()) return false; + engine->reset(nullptr); + if (engine->next() || engine->stopped() || engine->nogoods().ng()) + return false; + } + AStarSpace root; + o.tracer=&Gecode::StdSearchTracer::def; + try { + Gecode::CABS invalid(&root,AStarSpace::guidance(false),o); + return false; + } catch (const Gecode::Search::UnsupportedCABSTracer&) {} + o.tracer=nullptr; + using Guidance=Gecode::Search::HeuristicGuidance; + try { + Gecode::CABS invalid(&root,Guidance(Guidance::MINIMIZE,{}),o); + return false; + } catch (const Gecode::Search::UninitializedCABSBound&) {} + o.clone=true; + { + Guidance throws(Guidance::MINIMIZE,[](const Space&) -> std::int64_t { + throw std::runtime_error("guidance failure"); + }); + Gecode::CABS engine(&root,throws,o); + try { delete engine.next(); return false; } + catch (const std::runtime_error&) {} + } + return AStarSpace::live.load()==1; + } + public: + CABSTest(void) : Base("Search::CABS", TestTags::check()) {} + bool run(void) override { + std::lock_guard lock(heuristic_ownership_mutex); + for (bool max : {false,true}) + if (!solutions(max,1) || !solutions(max,0) || !discarded_bounds(max)) + return false; + return interrupted_discard(false) && interrupted_discard(true) && + dominance() && dominance(4) && dominance(1,true) && dominance(4,true) && + reset_discard() && feasibility() && adoption(false) && adoption(true) && + root_exceptions(true) && ownership_and_limits() && + AStarSpace::live.load()==0; + } + } cabsTest; + + /// Mixed optimization builders share incumbents and resume after a stop. + class HeuristicPortfolioTest : public Base { + public: + HeuristicPortfolioTest(void) + : Base("Search::Heuristic::Portfolio", TestTags::check()) {} + bool run(void) override { + std::lock_guard lock(heuristic_ownership_mutex); + for (bool max : {false,true}) + for (unsigned int threads : {1U,3U}) { + Gecode::Search::NodeStop stop(0); + Gecode::Search::Options asset; + asset.threads=1; asset.stop=&stop; + const auto guidance=AStarSpace::guidance(max,true); + Gecode::SEBs assets{Gecode::bab(asset), + Gecode::astar(guidance,asset), + Gecode::cabs(guidance,asset)}; + Gecode::Search::Options options; + options.threads=threads; + AStarSpace root(max), incumbent(max,1); + incumbent.status(); + Gecode::PBS engine(&root,assets,options); + while (std::unique_ptr solution{engine.next()}) {} + if (!engine.stopped()) return false; + engine.constrain(incumbent); + stop.limit(10000); + auto previous=incumbent.objective(); + bool found=false; + while (std::unique_ptr solution{engine.next()}) { + if (!solution->improves(solution->objective(),previous)) + return false; + previous=solution->objective(); found=true; + } + if (!found || previous!=incumbent.score(2) || engine.stopped()) + return false; + } + return AStarSpace::live.load()==0; + } + } heuristicPortfolioTest; + +#ifdef GECODE_HAS_THREADS + class ParallelHeuristic : public Base { + static bool check(bool beam, bool max, unsigned int threads) { + using namespace Gecode::Search; + NodeStop stop(1); + Gecode::Search::Options options; options.clone=false; options.threads=threads; options.stop=&stop; + const auto guidance=AStarSpace::guidance(max,true); + auto make=[&](Space* root, CABSOptions limits=CABSOptions()) { + return std::unique_ptr(beam ? cabsengine(root,guidance,options,limits) : + bestfirstengine(root,guidance,RANK_FIRST,options)); + }; + auto engine=make(new AStarSpace(max)); + std::optional previous; + auto consume=[&]() { + while (std::unique_ptr s{ + static_cast(engine->next())}) { + if (previous && !s->improves(s->objective(),*previous)) return false; + previous=s->objective(); + } + return true; + }; + if (!consume() || !engine->stopped()) return false; + const auto before=engine->statistics().node; + if (engine->next() || !engine->stopped() || engine->statistics().node!=before) + return false; + stop.limit(100000); + if (!consume() || engine->stopped()) return false; + AStarSpace optimum(max,2); + optimum.status(); + if (!previous || *previous!=optimum.objective()) return false; + for (int repeat=0; repeat<3; repeat++) { + engine->reset(new AStarSpace(max)); + if (engine->statistics().node || engine->statistics().restart) return false; + AStarSpace incumbent(max,1); incumbent.status(); + engine->constrain(incumbent); + previous=incumbent.objective(); + if (!consume() || engine->stopped() || *previous!=optimum.objective()) return false; + engine->reset(new AStarSpace(max)); + engine->constrain(optimum); + if (engine->next() || engine->stopped()) return false; + engine->reset(new AStarSpace(max,-1,true)); + if (engine->next() || engine->stopped()) return false; + } + engine.reset(); + if (beam) for (const auto limits : {CABSOptions(1,0),CABSOptions(0,1)}) { + engine=make(new AStarSpace(max),limits); + previous.reset(); + if (!consume() || !engine->stopped()) return false; + const auto nodes=engine->statistics().node; + if (engine->next() || !engine->stopped() || engine->statistics().node!=nodes) + return false; + engine->reset(new AStarSpace(max,2)); + std::unique_ptr s(engine->next()); + if (!s || engine->stopped() || engine->statistics().node!=1 || engine->next()) + return false; + } + return true; + } + static bool exceptions(bool beam, unsigned int threads=4) { + using namespace Gecode::Search; + Gecode::Search::Options options; options.threads=threads; + AStarSpace root; + for (bool rank : {false,true}) { + auto guidance=AStarSpace::guidance(false); + auto fail=[](const Space&)->std::int64_t { + throw std::runtime_error("parallel guidance failure"); + }; + if (rank) guidance.rank=fail; else guidance.bound=fail; + std::unique_ptr engine(beam ? cabsengine(&root,guidance,options) : + astarengine(&root,guidance,options)); + bool caught=false; + try { while (std::unique_ptr s{engine->next()}) {} } + catch (const std::runtime_error&) { caught=true; } + if (!caught) return false; + } + if (beam) for (bool key : {false,true}) { + CABSOptions limits; + limits.dominates=[key](const Space&,const Space&)->bool { + if (!key) throw std::runtime_error("dominance failure"); + return false; + }; + if (key) limits.dominance_key=[](const Space&)->std::size_t { + throw std::runtime_error("dominance key failure"); + }; + std::unique_ptr engine(cabsengine(&root, + AStarSpace::guidance(false,true),options,limits)); + bool caught=false; + try { while (std::unique_ptr s{engine->next()}) {} } + catch (const std::runtime_error&) { caught=true; } + if (!caught) return false; + // A key alone does not enable comparisons or evaluate the callback. + if (key) { + limits.dominates={}; + engine.reset(cabsengine(&root,AStarSpace::guidance(false,true),options,limits)); + while (std::unique_ptr s{engine->next()}) {} + if (engine->stopped()) return false; + } + } + return AStarSpace::live.load()==1; + } + static bool failed_alternatives(bool beam, bool bounded) { + using namespace Gecode::Search; + TreeSpace::Graph graph(131,{2,2,0,{}}); + graph[0]={0,0,0,{}}; + for (int i=1; i<=129; i++) graph[0].children.push_back(i); + if (bounded) + for (int i=1; i<=128; i++) graph[i]={0,2,0,{130}}; + graph[129]={0,0,0,{}}; + TreeSpace incumbent(graph); + incumbent.vertex=130; + incumbent.status(); + NodeStop stop(7); + Gecode::Search::Options options; + options.clone=false; options.threads=4; options.stop=&stop; + std::unique_ptr engine(beam ? + cabsengine(new TreeSpace(graph),TreeSpace::guidance(),options) : + astarengine(new TreeSpace(graph),TreeSpace::guidance(),options)); + engine->constrain(incumbent); + // Interrupt a long original choice, then reach its final improving goal. + std::unique_ptr early(engine->next()); + if (early || !engine->stopped() || engine->statistics().node>=130) + return false; + stop.limit(1000); + std::unique_ptr solution(static_cast(engine->next())); + if (!solution || solution->value().cost!=0) return false; + std::unique_ptr extra(engine->next()); + return !extra && !engine->stopped() && + engine->statistics().node==130 && engine->statistics().fail==128; + } + static bool queued_failures(bool beam) { + using namespace Gecode::Search; + class Boundary : public Stop { + public: + bool queued=false, resumed=false; + bool stop(const Statistics& s, const Gecode::Search::Options&) override { + return !resumed && (queued ? s.fail>0 : s.node>=129); + } + } stop; + TreeSpace::Graph graph(130,{0,2,0,{129}}); + graph[0]={0,0,0,{}}; + for (int i=1; i<=128; i++) graph[0].children.push_back(i); + graph[129]={2,2,0,{}}; + Gecode::Search::Options options; + options.clone=false; options.threads=2; options.stop=&stop; + options.worker_control=WorkerControl(1); + std::unique_ptr engine(beam ? + cabsengine(new TreeSpace(graph),TreeSpace::guidance(),options) : + astarengine(new TreeSpace(graph),TreeSpace::guidance(),options)); + std::unique_ptr early(engine->next()); + if (early || !engine->stopped() || engine->statistics().fail) return false; + TreeSpace incumbent(graph); + incumbent.vertex=129; + incumbent.status(); + engine->constrain(incumbent); + stop.queued=true; + // Rejecting queued nodes must still observe the first failure boundary. + early.reset(engine->next()); + if (early || !engine->stopped() || engine->statistics().fail!=1) return false; + stop.resumed=true; + early.reset(engine->next()); + return !early && !engine->stopped() && + engine->statistics().node==129 && + engine->statistics().fail==(beam ? 1U : 128U); + } + static bool overlap(bool beam) { + using namespace Gecode::Search; + // All shallow leaves are costly; widening must reach both parents + // before the better goal at the final original alternative. + const TreeSpace::Graph graph={ + {0,0,0,{1,2,3,4}}, {0,0,0,{5,6}}, {0,0,1,{7,8}}, + {0,0,2,{9,10}}, {0,0,3,{11,12}}, + {20,20,0,{}}, {19,19,0,{}}, {18,18,0,{}}, {17,17,0,{}}, + {16,16,0,{}}, {15,15,0,{}}, {14,14,0,{}}, {1,1,0,{}}}; + Gecode::Search::Options options; options.clone=false; options.threads=4; + auto guidance=TreeSpace::guidance(); + auto bound=guidance.bound; + std::atomic inside{0}, peak{0}; + guidance.bound=[&](const Space& s) { + const int count=++inside; + int old=peak.load(); + while (old engine(beam ? + cabsengine(new TreeSpace(graph),guidance,options) : + astarengine(new TreeSpace(graph),guidance,options)); + int best=100; + while (std::unique_ptr s{static_cast(engine->next())}) { + if (s->value().cost>=best) return false; + best=s->value().cost; + if (inside.load()) return false; + } + return best==1 && !engine->stopped() && peak.load()>1; + } + public: + ParallelHeuristic(void) : Base("Search::ParallelHeuristic",TestTags::check()) {} + bool run(void) override { + std::lock_guard lock(heuristic_ownership_mutex); + for (bool beam : {false,true}) { + for (bool max : {false,true}) + for (unsigned int threads : {2U,4U}) + if (!check(beam,max,threads)) return false; + if (!root_exceptions(beam,4) || !exceptions(beam) || + (beam && !exceptions(beam,1)) || !overlap(beam) || + !failed_alternatives(beam,false) || + !failed_alternatives(beam,true) || !queued_failures(beam)) return false; + if (AStarSpace::live.load()!=0) return false; + } + return true; + } + } parallelHeuristic; +#endif + + +#endif + /// Values for selecting branchers enum HowToBranch { HTB_NONE, ///< Do not branch @@ -545,6 +1889,7 @@ namespace Test { bool paused; public: std::promise reached; + std::atomic terminate{false}; Pause(const Gecode::Search::WorkerControl& c, unsigned int n) : control(c), node(n), paused(false) {} bool stop(const Gecode::Search::Statistics& s, @@ -554,7 +1899,7 @@ namespace Test { control.request(0U); reached.set_value(); } - return false; + return terminate.load(std::memory_order_acquire); } }; template class E> @@ -584,11 +1929,66 @@ namespace Test { } return ok && !engine.stopped() && (solutions == root.solutions()); } +#ifdef GECODE_HAS_EXPERIMENTAL + bool native(unsigned int kind, unsigned int node, bool stopping, + unsigned int threads=1) { + Gecode::Search::WorkerControl control(node == 0U ? 0U : 1U); + Pause pause(control,node); + Gecode::Search::Options options; + options.threads=threads; options.worker_control=control; options.stop=&pause; + AStarSpace root; + const auto guidance=AStarSpace::guidance(false); + std::unique_ptr engine(kind==0U ? + Gecode::Search::astarengine(&root,guidance,options) : + Gecode::Search::cabsengine(&root,guidance,options)); + std::int64_t optimum=0; + bool found=false; + auto consume=[&] { + while (std::unique_ptr solution{ + static_cast(engine->next())}) { + optimum=solution->objective(); found=true; + } + }; + // A shallow generated goal may return before the pause threshold. + // Keep consuming publications until the engine reaches that boundary. + auto result=std::async(std::launch::async,consume); + pause.reached.get_future().wait(); + bool paused=result.wait_for(std::chrono::milliseconds(20))== + std::future_status::timeout; + const auto before=engine->statistics().node; + if (stopping) { + pause.terminate.store(true,std::memory_order_release); + Gecode::Search::WorkerControlAccess::signal_all(control); + result.get(); + if (!engine->stopped() || engine->statistics().node!=before) + return false; + pause.terminate.store(false,std::memory_order_release); + control.request(threads); + consume(); + } else { + control.request(threads); + result.get(); + } + const bool complete=paused && found && optimum==root.score(2) && !engine->stopped(); + // Destruction must wake parked workers before waiting for completion. + control.request(0U); + engine.reset(); + return complete; + } +#endif + public: WorkerControlSequentialPause(void) : Base("Search::WorkerControl::SequentialPause", TestTags::check()) {} bool run(void) override { #ifdef GECODE_HAS_THREADS +#ifdef GECODE_HAS_EXPERIMENTAL + std::lock_guard lock(heuristic_ownership_mutex); + for (unsigned int kind : {0U,1U}) + if (!native(kind,0U,false) || !native(kind,1U,false) || + !native(kind,0U,true) || !native(kind,0U,false,4) || + !native(kind,1U,false,4) || !native(kind,0U,true,4)) return false; +#endif return check(0U) && check(1U) && check(0U) && check(1U); #else