From 626fb2d3072c207c6266eade446bae63dfc8206d Mon Sep 17 00:00:00 2001 From: Mikael Zayenz Lagerkvist Date: Thu, 10 Sep 2026 10:04:25 +0200 Subject: [PATCH 1/7] random: add splittable engines and full-state test replay --- CMakeLists.txt | 8 + Makefile.in | 2 +- cmake/GecodeSources.cmake | 1 + gecode/kernel/data/rnd.cpp | 8 +- gecode/kernel/data/rnd.hpp | 34 ++-- gecode/support/random.hpp | 196 +++++++++++++++++++- plans/random.md | 369 +++++++++++++++++++++++++++++++++++++ test/random-replay.cmake | 23 +++ test/random-replay.cpp | 23 +++ test/random.cpp | 118 ++++++++++++ test/test.cpp | 82 +++++++-- test/test.hh | 6 +- 12 files changed, 830 insertions(+), 40 deletions(-) create mode 100644 plans/random.md create mode 100644 test/random-replay.cmake create mode 100644 test/random-replay.cpp create mode 100644 test/random.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index e9d0600f6b..7f18ab4ad4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1399,6 +1399,13 @@ if(BUILD_TESTING) list(APPEND GECODE_TEST_LINK_LIBS gecodeflatzinc) endif() target_link_libraries(gecode-test PRIVATE ${GECODE_TEST_LINK_LIBS}) + add_executable(gecode-random-replay EXCLUDE_FROM_ALL + test/test.cpp test/random-replay.cpp) + target_link_libraries(gecode-random-replay PRIVATE ${GECODE_TEST_LINK_LIBS}) + add_dependencies(gecode-test gecode-random-replay) + add_test(NAME random-state-replay + COMMAND ${CMAKE_COMMAND} -DREPLAY=$ + -P ${CMAKE_CURRENT_SOURCE_DIR}/test/random-replay.cmake) if(GECODE_ENABLE_FAULT_INJECTION) add_executable(gecode-fault-test EXCLUDE_FROM_ALL ${GECODE_FAULT_TEST_SOURCES}) @@ -1418,6 +1425,7 @@ if(BUILD_TESTING) endif() set(GECODE_CHECK_TESTS + Random::Contract Branch::Int::Dense::3 Int::Arithmetic::Abs Int::Arithmetic::ArgMax diff --git a/Makefile.in b/Makefile.in index b446405125..ee83dfea42 100755 --- a/Makefile.in +++ b/Makefile.in @@ -1276,7 +1276,7 @@ BLACKBOXEXECSRC = test/flatzinc/blackbox-exec.cpp BLACKBOXDLLSRC = test/flatzinc/blackbox-dll.cpp BLACKBOXSRC = $(BLACKBOXEXECSRC) $(BLACKBOXDLLSRC) -TESTSRC0 = test/test.cpp test/afc.cpp test/ldsb.cpp test/region.cpp \ +TESTSRC0 = test/test.cpp test/afc.cpp test/ldsb.cpp test/region.cpp test/random.cpp \ test/groups.cpp # FailPoint is CMake-only; keep the Autoconf test executable fault-free. diff --git a/cmake/GecodeSources.cmake b/cmake/GecodeSources.cmake index 0c9defc0f1..fc9f128938 100644 --- a/cmake/GecodeSources.cmake +++ b/cmake/GecodeSources.cmake @@ -411,6 +411,7 @@ set(GECODE_TEST_SOURCES test/ldsb.cpp test/nogoods.cpp test/region.cpp + test/random.cpp test/search.cpp test/set.cpp test/set/channel.cpp diff --git a/gecode/kernel/data/rnd.cpp b/gecode/kernel/data/rnd.cpp index f7dc782889..395e050452 100644 --- a/gecode/kernel/data/rnd.cpp +++ b/gecode/kernel/data/rnd.cpp @@ -40,13 +40,13 @@ namespace Gecode { Support::Mutex Rnd::IMP::m; forceinline - Rnd::IMP::IMP(unsigned int s) + Rnd::IMP::IMP(uint64_t s) : rg(s) {} Rnd::IMP::~IMP(void) {} forceinline void - Rnd::_seed(unsigned int s) { + Rnd::_seed(uint64_t s) { if (object() == nullptr) { object(new IMP(s)); } else { @@ -55,7 +55,7 @@ namespace Gecode { } Rnd::Rnd(void) {} - Rnd::Rnd(unsigned int s) { + Rnd::Rnd(uint64_t s) { object(new IMP(s)); } Rnd::Rnd(const Rnd& r) @@ -68,7 +68,7 @@ namespace Gecode { Rnd::~Rnd(void) {} void - Rnd::seed(unsigned int s) { + Rnd::seed(uint64_t s) { _seed(s); } void diff --git a/gecode/kernel/data/rnd.hpp b/gecode/kernel/data/rnd.hpp index 9cdd4afd2b..7de75e136f 100755 --- a/gecode/kernel/data/rnd.hpp +++ b/gecode/kernel/data/rnd.hpp @@ -50,11 +50,11 @@ namespace Gecode { Support::RandomGenerator rg; public: /// Initialize generator with seed \a s - IMP(unsigned int s); - /// Return seed - unsigned int seed(void) const; + IMP(uint64_t s); + /// Return complete state + Support::RandomGenerator::State state(void) const; /// Set seed to \a s - void seed(unsigned int s); + void seed(uint64_t s); /// Returns a random integer from the interval \f$[0\ldots n)\f$ unsigned int operator ()(unsigned int n); /// Returns a random integer from the interval \f$[0\ldots n)\f$ @@ -67,7 +67,7 @@ namespace Gecode { virtual ~IMP(void); }; /// Set the current seed to \a s (initializes if needed) - void _seed(unsigned int s); + void _seed(uint64_t s); public: /// Default constructor that does not initialize the generator GECODE_KERNEL_EXPORT @@ -83,18 +83,18 @@ namespace Gecode { ~Rnd(void); /// Initialize with seed \a s GECODE_KERNEL_EXPORT - Rnd(unsigned int s); + Rnd(uint64_t s); /// Set the current seed to \a s (initializes if needed) GECODE_KERNEL_EXPORT - void seed(unsigned int s); + void seed(uint64_t s); /// Set current seed based on time (initializes if needed) GECODE_KERNEL_EXPORT void time(void); /// Set current seed to hardware-based random number (initializes if needed) GECODE_KERNEL_EXPORT void hw(void); - /// Return current seed - unsigned int seed(void) const; + /// Return complete current state + Support::RandomGenerator::State state(void) const; /// Returns a random integer from the interval \f$[0\ldots n)\f$ unsigned int operator ()(unsigned int n); /// Returns a random integer from the interval \f$[0\ldots n)\f$ @@ -105,16 +105,16 @@ namespace Gecode { long long int operator ()(long long int n); }; - forceinline unsigned int - Rnd::IMP::seed(void) const { - unsigned int s; + forceinline Support::RandomGenerator::State + Rnd::IMP::state(void) const { + Support::RandomGenerator::State s; const_cast(*this).m.acquire(); - s = rg.seed(); + s = rg.state(); const_cast(*this).m.release(); return s; } forceinline void - Rnd::IMP::seed(unsigned int s) { + Rnd::IMP::seed(uint64_t s) { m.acquire(); rg.seed(s); m.release(); @@ -152,10 +152,10 @@ namespace Gecode { return r; } - forceinline unsigned int - Rnd::seed(void) const { + forceinline Support::RandomGenerator::State + Rnd::state(void) const { const IMP* i = static_cast(object()); - return i->seed(); + return i->state(); } forceinline unsigned int Rnd::operator ()(unsigned int n) { diff --git a/gecode/support/random.hpp b/gecode/support/random.hpp index 760176ee64..1d087a4ae3 100755 --- a/gecode/support/random.hpp +++ b/gecode/support/random.hpp @@ -34,6 +34,13 @@ */ #include +#include +#include +#include +#include +#include +#include +#include namespace Gecode { namespace Support { @@ -180,7 +187,194 @@ namespace Gecode { namespace Support { * \ingroup FuncSupport */ typedef LinearCongruentialGenerator<2147483647, 48271, 44488, 3399> - RandomGenerator; + LegacyRandomGenerator; + + /// Parse a decimal or hexadecimal 64-bit seed without truncation. + inline uint64_t + random_seed(const std::string& text) { + const char* first = text.data(); + const char* last = first + text.size(); + int base = 10; + if ((text.size() > 2) && (text[0] == '0') && + ((text[1] == 'x') || (text[1] == 'X'))) { + first += 2; + base = 16; + } + uint64_t value; + auto r = std::from_chars(first,last,value,base); + if ((r.ec != std::errc()) || (r.ptr != last)) + throw std::invalid_argument("Invalid 64-bit random seed"); + return value; + } + + /** \brief Splittable SplitMix with two 64-bit state words + * + * Implements the SplitMix design of Steele, Lea, and Flood (OOPSLA 2014), + * using Stafford's Mix13 output permutation and the MurmurHash3 finalizer + * for gamma selection. Indexed splitting returns the child of the (a+1)th + * successive split without changing the parent. For a 32-bit alternative + * index, the inputs s + (2*a+1)*gamma are distinct: gamma is odd and the + * offsets span less than 2^64. Mix13 is bijective, so child states differ. + * + * \ingroup FuncSupport + */ + class SplitMix { + public: + using State = std::array; + private: + State s; + static uint64_t mix(uint64_t z) { + z = (z ^ (z >> 30)) * UINT64_C(0xbf58476d1ce4e5b9); + z = (z ^ (z >> 27)) * UINT64_C(0x94d049bb133111eb); + return z ^ (z >> 31); + } + static uint64_t gamma(uint64_t z) { + z = (z ^ (z >> 33)) * UINT64_C(0xff51afd7ed558ccd); + z = (z ^ (z >> 33)) * UINT64_C(0xc4ceb9fe1a85ec53); + z = (z ^ (z >> 33)) | 1; + unsigned int n = 0; + for (uint64_t bits = z ^ (z >> 1); bits; bits &= bits-1) + ++n; + return (n < 24) ? z ^ UINT64_C(0xaaaaaaaaaaaaaaaa) : z; + } + public: + explicit SplitMix(uint64_t value=1) { seed(value); } + static const char* name(void) { return "splitmix-v1"; } + static constexpr uint64_t min(void) { return 0; } + static constexpr uint64_t max(void) { return UINT64_MAX; } + void seed(uint64_t value) { + s = {{value, UINT64_C(0x9e3779b97f4a7c15)}}; + } + State state(void) const { return s; } + void state(const State& value) { + if (!(value[1] & 1)) + throw std::invalid_argument("SplitMix increment must be odd"); + s = value; + } + uint64_t next(void) { return mix(s[0] += s[1]); } + SplitMix split(uint32_t alternative) const { + uint64_t first = s[0] + (2*uint64_t(alternative)+1)*s[1]; + SplitMix child; + child.s = {{mix(first),gamma(first+s[1])}}; + return child; + } + }; + + /** \brief One-word xorshift64* engine for standalone use + * + * Uses shifts 12, 25, 27 and Vigna's multiplier. Zero seeds map to one; + * restoring zero state is an error. This engine does not provide splitting. + * \ingroup FuncSupport + */ + class Xorshift64Star { + public: + using State = std::array; + private: + uint64_t s; + public: + explicit Xorshift64Star(uint64_t value=1) { seed(value); } + static const char* name(void) { return "xorshift64star-v1"; } + static constexpr uint64_t min(void) { return 1; } + static constexpr uint64_t max(void) { return UINT64_MAX; } + void seed(uint64_t value) { s = value ? value : 1; } + State state(void) const { return {{s}}; } + void state(const State& value) { + if (!value[0]) + throw std::invalid_argument("Xorshift64* state must be nonzero"); + s = value[0]; + } + uint64_t next(void) { + s ^= s >> 12; + s ^= s << 25; + s ^= s >> 27; + return s * UINT64_C(2685821657736338717); + } + }; + + /** \brief Value-type generator with reproducible bounded draws and state + * + * Engine supplies a State array of 64-bit words, name(), seed(), state() + * getter/setter, and next() over [0,UINT64_MAX] or [1,UINT64_MAX]. Search + * engines additionally supply split(uint32_t) const. No state-size limit + * is imposed. Copying a generator preserves its exact state. + * \ingroup FuncSupport + */ + template + class Random { + private: + Engine e; + public: + using State = typename Engine::State; + using result_type = uint64_t; + static_assert(Engine::max() == UINT64_MAX && Engine::min() <= 1, + "Random engine must generate full or nonzero 64-bit words"); + explicit Random(uint64_t seed=1) : e(seed) {} + explicit Random(const Engine& engine) : e(engine) {} + static constexpr result_type min(void) { return Engine::min(); } + static constexpr result_type max(void) { return Engine::max(); } + static const char* name(void) { return Engine::name(); } + void seed(uint64_t value) { e.seed(value); } + State state(void) const { return e.state(); } + void state(const State& value) { e.state(value); } + result_type next(void) { return e.next(); } + result_type operator ()(void) { return next(); } + size_t size(void) const { return sizeof(*this); } + Random split(uint32_t alternative) const { + return Random(e.split(alternative)); + } + /// Bounds <= 1 return zero without consuming a draw. + template + Type operator ()(Type bound) { + static_assert(std::is_integral::value && sizeof(Type) <= 8, + "Random bound must be an integer of at most 64 bits"); + if (bound <= 1) + return 0; + uint64_t n = static_cast(bound); + uint64_t value; + if constexpr (Engine::min() == 0) { + // Accept an exact multiple of n values from the 2^64-value source. + uint64_t threshold = (uint64_t(0)-n) % n; + do { value = next(); } while (value < threshold); + } else { + // Nonzero engines have 2^64-1 values, not 2^64. + uint64_t limit = UINT64_MAX - (UINT64_MAX % n); + do { value = next()-1; } while (value >= limit); + } + return static_cast(value % n); + } + /// Canonical identifier followed by fixed-width hexadecimal state words. + std::string state_string(void) const { + std::string text(name()); + constexpr char digits[] = "0123456789abcdef"; + for (uint64_t word : state()) { + text += ':'; + for (int shift=60; shift>=0; shift-=4) + text += digits[(word >> shift) & 15]; + } + return text; + } + /// Restore full state, rejecting incompatible identifiers or invalid words. + void state(const std::string& text) { + const std::string prefix = std::string(name()) + ':'; + State words{}; + if ((text.compare(0,prefix.size(),prefix) != 0) || + (text.size() != prefix.size()+17*words.size()-1)) + throw std::invalid_argument("Invalid or incompatible random state"); + size_t pos = prefix.size(); + for (size_t i=0; i; }} diff --git a/plans/random.md b/plans/random.md new file mode 100644 index 0000000000..4cd68b8ca3 --- /dev/null +++ b/plans/random.md @@ -0,0 +1,369 @@ +# Plan: Compact, splittable random generators for Gecode 7 + +> Source: the feature/random design discussion, 2026-09-10. +> Status: implementation authorized; Phase 1 complete, Phase 2 next. +> Workflow: review, update this plan, and commit after each phase. + +## Goal + +Replace the old default random generator, provide user-extensible alternatives, +and make random branching streams stable under recomputation. Gecode 7 permits +breaking source, binary, and seeded-sequence compatibility for this change. + +Xorshift64* was the original proposed replacement. The subsequent requirement +for alternative-specific splitting must also be satisfied before selecting the +default. The design must stay compact in branching descriptions, branchers, +choices, and search paths. + +## User requirements + +1. A model author can use a better default generator and supply a custom engine + through Gecode's supported extension interface. +2. A test failure can be replayed from its complete random state, including state + required for subsequent splitting, without reconstructing preceding draws. +3. Every alternative of a branching decision gets a distinct successor state. + Replaying the same recorded decision and alternative gets the same state. +4. Cloning and recomputation do not introduce additional splits or make streams + depend on exploration order. +5. A command-line executable can use one configured engine. Large internal state + does not force users to supply equally large numeric seeds. +6. Memory and execution costs remain appropriate for Gecode search. + +## Architectural decisions + +### Branching and replay contract + +Generating a choice may consume random values for variable and value selection. +After those selections, the choice captures the complete state needed to derive +the streams for its alternatives. Committing an alternative derives and installs +its successor state from that immutable choice data and the alternative index. +It must not derive it from whatever mutable generator state happens to be in the +destination space. + +The same operation applies during exploration and recomputation. Reconstructing +a choice from an archive restores its splitting data without drawing or splitting. +Cloning preserves generator state without advancing either source or destination. +Sibling spaces must not consume each other's mutable streams. + +The sibling-state distinction must hold for all valid alternative indices, +including large multiway choices. A hash with merely a low collision probability +does not establish this requirement. Distinct states do not imply globally +non-overlapping sequences or uniqueness across an unbounded search tree. + +Store one common splitting payload per choice for the common single-stream case, +not an array of successor states. Deriving a late alternative must not require +generating all preceding alternatives. If multiple streams need separate state, +their cost must be explicit; do not silently assume one snapshot covers them. + +This is a guarantee about random state for a recorded path. It does not promise +identical global scheduling, solution order, adaptive heuristic state, or search +trees under parallel search, restarts, or weakly monotonic propagation. + +### Generator operations and extensibility + +Specify initialization, raw generation, bounded generation, exact copying, +alternative-indexed splitting, and full-state save/restore separately. +The names and C++ representation remain to be chosen during Phase 1. + +Custom engines must be usable by supported branching APIs, not only by callers +of the low-level support library. Define the output range, valid states, state +size, and splitting obligations of an engine. Engines without the required +splitting behavior may be usable as standalone generators but cannot silently +qualify as search generators. + +Keep the command-line engine selection independent of the extension mechanism: +one configured engine per executable is sufficient. No runtime plugin registry +or mandatory catalogue of engines is required. If configuration changes public +types or layouts, export the configuration consistently to downstream builds. + +Gecode specifies bounded integer conversion and draw consumption, including zero, +one, negative signed bounds, and maximum supported bounds. Use integer conversion +with rejection where required; account for the engine's actual output range. +In particular, a nonzero-state xorshift engine must not be treated as emitting +every 64-bit value with equal frequency over its period. Avoid distribution +caches unless their state is part of snapshots. + +### Seeds and complete state + +Ordinary initialization accepts a checked 64-bit seed, in decimal or hexadecimal. +Specify seed expansion and the treatment of zero for every supported engine. +Restoring state bypasses seed expansion and never silently repairs invalid state. + +Use a canonical text representation containing an algorithm/format identifier +and all state words in a specified order. Stream increments, counters, and other +mutable or per-instance parameters belong in it. An executable rejects an +incompatible state identifier rather than switching engines implicitly. + +The test runner records a snapshot immediately before an iteration and prints a +command that restores that snapshot directly for the named test. That command +bypasses normal suite-level seed derivation. Ordinary failure and exception paths +must both report the relevant iteration state, along with existing test options. + +The test runner needs full-state input in Phase 1. Public drivers receive the +same seed/state conventions in Phase 4. Use separate seed and state options; +reject conflicting input. A wider seed interface is unnecessary for this scope. + +### Compactness + +Measure the complete representation, not just the engine's state words: public +descriptions, selectors, local storage, choice objects, archive words, and retained +search paths. Include padding, dispatch data, and allocations. + +An 8-byte engine state is preferred; 16 bytes is a candidate budget for a truly +splittable engine, not an already approved limit. Keep runtime dispatch metadata +and textual identifiers out of repeated state payloads where they are implied +by the configured type. Avoid adding random-state storage to models that never +use random branching where practical, and measure any unavoidable common cost. + +## Decisions to close during implementation + +### Engine and indexed splitting + +Compare the original xorshift64* candidate with a published splittable design. +Distinguish fixed-increment SplitMix64 (one state word) from splittable SplitMix +(state plus a per-stream increment). Neither the name "SplitMix" nor the +existence of a sequential split operation establishes our indexed sibling-state +contract. Document the indexed adaptation, its cost, and why sibling states are +distinct before accepting it. + +Select a small initial set of alternatives with useful differences. At least one +additional search-capable engine should exercise the extension interface before +release. Preserve xorshift64* as a comparison candidate; do not invent an untested +splitting construction just to keep it as the default. Record the default decision +after correctness, published quality evidence, and Gecode measurements agree. + +### Ownership across selectors and branchers + +This decision must be closed before the Phase 2 integration is considered done. +Random variable selection and value selection may currently share a handle or +use separate handles; later posted branchers may also retain those handles. +Splitting only the active selector leaves later consumers unchanged. + +Start by evaluating a space-local random context shared by cooperating branchers, +using Gecode's existing local-object cloning mechanism if appropriate. Compare it +with compact state held directly in branchers. Choose the smallest design that +meets the contract; neither representation is mandated by this plan. + +Specify what reusing a generator in multiple descriptions means, how distinct +generators coexist, when external initialization is bound to a space, and how +later branchers inherit the alternative-specific state. Cover deterministic +choices before a randomized brancher: the later random stream must reflect the +selected alternative even when the earlier choice used no random values. +Also cover one-alternative assignments, dynamically posted branchers, and custom +callbacks. Explain where splitting state is captured and installed so custom +branchers have a clear participation contract. + +Use a small worked example with two sequential branchers, random variable and +value selection, and both shared and separate initial generators to settle this. +Do not add a general stream registry or per-node map without demonstrating why +a simpler ownership model cannot meet these cases. + +## Phase 1: Extensible generation with exact test replay + +**User requirements:** 1, 2, 5, 6. + +### What to build + +Introduce the engine contract and a compact candidate implementation, connected +to the existing test runner through complete-state input and failure reporting. +Specify raw output, bounded output, initialization, indexed splitting, and state +encoding together. Demonstrate another engine supplied outside the implementation +without editing the engine-selection logic. Resolve the ownership design needed +for the next phase using the worked example above. + +### Acceptance criteria + +- [x] Saving after mixed bounded draws and splits, restoring, and continuing + reproduces both future draws and future split states exactly. +- [x] A deliberately failing test iteration can be replayed directly from the + reported command, including when the failure is an exception. +- [x] Published vectors validate the selected engine where available; focused + checks cover invalid state, seed expansion, bounds, and state round trips. +- [x] The indexed splitting rule has an argument for sibling-state distinction + and handles the full valid alternative-index range without linear replay. +- [x] Custom engine state size is not artificially fixed to the default's size. +- [x] The ownership decision and initial memory measurements are recorded. + +### Phase 1 review + +Implemented `Support::Random` with full-state encoding and integer +rejection sampling, a 16-byte splittable SplitMix candidate, and the 8-byte +xorshift64* standalone alternative. The old congruential engine remains named +for comparison, but is no longer the default. Final default selection remains +subject to the Phase 3 measurements. + +For SplitMix parent `(s,g)`, alternative `a` gets state +`(Mix13(s+(2a+1)g), mixGamma(s+(2a+2)g))`, using unsigned 64-bit arithmetic. +This is the child of the `(a+1)`th sequential SplitMix split, computed directly. +Since `g` is odd and `a` is 32 bits, the first inputs are distinct modulo 2^64; +Mix13 is a permutation, so the complete sibling states are distinct. No new +generator recurrence or probabilistic collision assumption is introduced. + +The chosen ownership direction for Phase 2 is a lazily allocated space-local +collection of bound streams. Reusing the same initialization handle binds the +same local stream; separate handles retain separate streams. Space cloning +duplicates their mutable state. Choices snapshot all bound streams, including +ones belonging to later branchers, and commit derives each from the recorded +alternative. This also handles deterministic choices preceding random ones. +A compact linear collection is sufficient for the normally small number of +streams; there is no general registry or per-node lookup map. Raw engine words +are packed into one optional choice payload, with layout implied by the bound +engines. Phase 2 must measure and review the actual overhead of this design. + +Worked example: descriptions for branchers A and B reuse handle R, and A's +variable and value selectors both use R. All three bind one stream in the space. +After A's selections, its choice records R's state. Committing alternative 1 +installs R's child 1, which B subsequently uses. If A's value selector instead +uses a separate handle V, the choice stores R and V once each and commits both +child states. Replaying from an earlier clone uses those snapshots even though +A's selection draws were not re-executed. + +Validation: `Random::Contract` passed, including known raw-output vectors, +mixed draw/split replay, invalid state/seed input, bounds, and a user engine with +three state words. The dedicated `random-state-replay` CTest passes both ordinary +failure and exception replay by executing the printed command and comparing the +failing draw and state. The exception fixture first completes two iterations, +so this checks an advanced state rather than just initial seeding. Filtered +random tie selection also passes with the maximum 64-bit seed. +The existing `check` target passes, including its fault-injection checks and +selected integer, set, float, FlatZinc, branching, and search regressions. + +Measured on arm64 macOS: engine 4 -> 16 bytes; Rnd 8, IntVarBranch 112, +IntValBranch 80, Choice 16, integer PosValChoice 24, and Space 288 bytes remain +unchanged in this phase. Search still has its old shared-handle behavior until +Phase 2; this intermediate limitation is intentional and not a completed search +reproducibility claim. + +## Phase 2: Alternative-specific streams through one search path + +**User requirements:** 1, 3, 4, 6. + +### What to build + +Carry the new random state through a representative integer branching path, +choice archiving, commit, and sequential recomputation. Include both random +variable and value selection and the transition to a second brancher. Exercise +the same path with a user-supplied engine to prove that extension reaches search. + +### Acceptance criteria + +- [ ] All alternatives of a recorded choice have distinct successor states. +- [ ] Committing an alternative directly, after intervening sibling exploration, + or after restoring an archived choice produces identical successor state + and subsequent draws on equivalent spaces. +- [ ] The same recorded path yields the same state with frequent cloning and + substantial recomputation, including last-alternative optimization. +- [ ] A deterministic choice followed by random branching, and a transition + between randomized branchers, both retain the alternative-specific stream. +- [ ] Shared and separate variable/value generators follow the documented + ownership policy; cloning does not mutate the source's generators. +- [ ] Choice payload growth is independent of the number of alternatives. +- [ ] Measure description, brancher, choice, and archive sizes against baseline. + +## Phase 3: Complete branching and search integration + +**User requirements:** 1, 3, 4, 6. + +### What to build + +Apply the verified contract across integer, Boolean, set, and float random +branching, tie breaking, multiway branching, assignment, and supported custom +branchers. Check the common commit boundary and both sequential and parallel +search replay paths. Account explicitly for restarts, relaxation, and model +callbacks that use randomness outside ordinary choice selection. + +### Acceptance criteria + +- [ ] Focused integration cases cover binary, multiway, and one-alternative + branching, plus handover to later branchers and custom-engine use. +- [ ] Alternative identity follows the public choice index, including when a + brancher reverses the mapping from alternative index to selected value. +- [ ] Clone, archive, disposal, failure, traced commit, and conditional commit + paths preserve the contract without shared mutable state between spaces. +- [ ] Sequential and parallel replay of the same recorded path agree on random + state; checks do not require identical parallel solution order. +- [ ] Restart and portfolio initialization policies are documented and do not + accidentally introduce worker-scheduling-dependent shared streams. +- [ ] Existing relevant branching and search correctness tests pass. Add tests + for the new semantic guarantees rather than duplicating each API wrapper. +- [ ] Random generation, bounded draws, splitting, clone cost, and retained-path + memory are measured in representative Gecode workloads. Use controlled + paths to separate overhead from changes in the randomized search tree. + +## Phase 4: Configured command lines and Gecode 7 migration + +**User requirements:** 1, 2, 5, 6. + +### What to build + +Complete seed/state handling in the example driver and FlatZinc, propagate the +configured engine through their random consumers, and finalize the default and +initial alternatives using the preceding evidence. Document the extension API, +ownership rules, replay contract, and compatibility changes for Gecode 7. + +### Acceptance criteria + +- [ ] Drivers accept full 64-bit seeds without signed narrowing or truncation + and use the same initialization/state conventions as the test runner. +- [ ] Complete state is accepted and reproduced for the configured engine; + malformed, incompatible, and conflicting options are rejected clearly. +- [ ] Time/hardware initialization can report the concrete initialized state + needed for a later replay. +- [ ] A runnable custom-engine example works through built-in branching; custom + brancher documentation explains choice snapshots and replay obligations. +- [ ] FlatZinc restart sampling no longer relies on the old generator's restricted + range or sequence-preservation workaround where the new contract replaces it. +- [ ] Release notes describe changed seeded sequences, state replay, and copying + versus sharing semantics. No legacy sequence mode is required. +- [ ] Supported build configurations and relevant regression suites pass; the + default choice and measured memory/performance tradeoffs are documented. + +## Validation boundaries + +Keep a small set of high-value tests: reference vectors, save/restore including +splits, direct failure replay, and equivalent-path integration tests. Assertions +on state are stronger than expecting different first outputs from siblings: +distinct states can legitimately produce equal individual outputs. + +Do not put probabilistic distribution tests into ordinary CI. Use established +statistical tools during engine evaluation when needed, especially for any +indexed-splitting adaptation. Passing a statistical battery alone is not evidence +that sibling states are always distinct or replay is correct. + +Implementation proceeds in the phase order above. Each phase review records +its evidence and any adjustment before the phase is committed. + +## Initial code observations supporting the plan + +- `gecode/support/random.hpp`: current state is one unsigned integer, normally + 32 bits. Bounded output combines low-bit chunks and scaling/modulo operations. +- `gecode/kernel/data/rnd.hpp` and `.cpp`: public random handles share mutable + implementation state and use a static mutex across implementations. +- `gecode/kernel/branch/var.hpp`, `val.hpp`, and `view-sel.hpp`: branching + descriptions and selectors retain these handles, including during cloning. +- `gecode/kernel/branch/view-val.hpp` and `gecode/int/branch/view-values.hpp`: + choices currently store selected positions/values, without random snapshots. +- `gecode/kernel/core.cpp`: exploration and replay reach brancher commit through + common space operations; archive reconstruction dispatches by brancher identity. +- `gecode/kernel/core.hpp`: space-local objects already support cloning shared + objects within a space. Their suitability must be weighed against overhead. +- `gecode/kernel/archive.hpp`: archive storage uses unsigned integer words and + has no existing 64-bit integer overload; explicitly preserve every state bit. +- `gecode/search/seq/path.hpp` and `gecode/search/par/path.hpp`: retained choices + and alternative indices drive recomputation and make payload size significant. +- `test/test.cpp`: iteration replay currently treats the current generator state + as an unsigned seed; exceptions report the suite seed instead. +- `gecode/driver.hh` and `gecode/flatzinc.hh`: seed option types differ, with a + signed seed path in FlatZinc. +- `gecode/flatzinc/restart-random.hpp`: special handling compensates for the + current bounded generator while preserving established sequences. + +## Algorithm references + +- [Steele, Lea, and Flood: Fast Splittable Pseudorandom Number Generators](https://gee.cs.oswego.edu/dl/papers/oopsla14.pdf) + describes the splittable SplitMix design. +- [Vigna: older scrambled linear generators](https://prng.di.unimi.it/xorshift.php) + discusses limitations of the xorshift family and its low bits. +- [Blackman and Vigna's generator overview](https://prng.di.unimi.it/) + provides modern alternatives, reference implementations, and seed-expansion + guidance. Fixed-increment SplitMix64 is distinct from full splittable SplitMix. diff --git a/test/random-replay.cmake b/test/random-replay.cmake new file mode 100644 index 0000000000..513db233da --- /dev/null +++ b/test/random-replay.cmake @@ -0,0 +1,23 @@ +# Copyright (c) 2026 Mikael Zayenz Lagerkvist. MIT license; see LICENSE. +foreach(kind Failure Exception) + execute_process(COMMAND "${REPLAY}" -seed 1 -iter 100 -test-exact "Random::Replay::${kind}" + RESULT_VARIABLE first_result OUTPUT_VARIABLE first ERROR_VARIABLE first_error) + if(NOT first_result EQUAL 1) + message(FATAL_ERROR "Fixture did not fail: ${first} ${first_error}") + endif() + string(REGEX MATCH "Options: ([^\n]+)" command "${first}") + set(arguments "${CMAKE_MATCH_1}") + string(REGEX MATCH "Replay draw: [0-9]+" draw "${first}") + if(NOT command OR NOT draw) + message(FATAL_ERROR "Missing replay report: ${first}") + endif() + separate_arguments(arguments UNIX_COMMAND "${arguments}") + execute_process(COMMAND "${REPLAY}" ${arguments} + RESULT_VARIABLE replay_result OUTPUT_VARIABLE replay ERROR_VARIABLE replay_error) + string(REGEX MATCH "Replay draw: [0-9]+" replay_draw "${replay}") + string(REGEX MATCH "Options: [^\n]+" replay_command "${replay}") + if(NOT replay_result EQUAL 1 OR NOT draw STREQUAL replay_draw OR NOT command STREQUAL replay_command) + message(FATAL_ERROR "Replay differs:\n${first}\n${replay}\n${replay_error}") + endif() +endforeach() +message(STATUS "Failure and exception state replay agree") diff --git a/test/random-replay.cpp b/test/random-replay.cpp new file mode 100644 index 0000000000..cf7bbd4e9f --- /dev/null +++ b/test/random-replay.cpp @@ -0,0 +1,23 @@ +/* -*- mode: C++; c-basic-offset: 2; indent-tabs-mode: nil -*- */ +// Copyright (c) 2026 Mikael Zayenz Lagerkvist. MIT license; see LICENSE. +// Deliberately failing fixtures for the test runner's state replay protocol. +#include "test/test.hh" + +namespace { + class Replay : public Test::Base { + bool exception; + public: + explicit Replay(bool e) + : Base(e ? "Random::Replay::Exception" : "Random::Replay::Failure"), + exception(e) {} + bool run() override { + if (_rand(4) != 0) + return true; + auto child = _rand.split(7); + std::cout << "Replay draw: " << child.next() << '\n'; + if (exception) + throw Gecode::Exception("Random::Replay", "deliberate exception"); + return false; + } + } failure(false), exception(true); +} diff --git a/test/random.cpp b/test/random.cpp new file mode 100644 index 0000000000..86f2892a15 --- /dev/null +++ b/test/random.cpp @@ -0,0 +1,118 @@ +/* -*- mode: C++; c-basic-offset: 2; indent-tabs-mode: nil -*- */ +// Copyright (c) 2026 Mikael Zayenz Lagerkvist. MIT license; see LICENSE. + +#include "test/test.hh" + +namespace Test { + namespace Random { + using namespace Gecode::Support; + + // A user engine with extra state exercises the public engine contract. + // The counter deliberately affects output, so omitting it breaks replay. + class CountedSplitMix { + SplitMix source; + uint64_t count = 0; + public: + using State = std::array; + explicit CountedSplitMix(uint64_t s=1) : source(s) {} + static const char* name() { return "counted-splitmix-test-v1"; } + static constexpr uint64_t min() { return 0; } + static constexpr uint64_t max() { return UINT64_MAX; } + void seed(uint64_t s) { source.seed(s); count=0; } + uint64_t next() { return source.next() ^ count++; } + State state() const { + auto s = source.state(); + return {{s[0],s[1],count}}; + } + void state(const State& s) { + source.state({{s[0],s[1]}}); + count=s[2]; + } + CountedSplitMix split(uint32_t a) const { + auto child = *this; + child.source = source.split(a); + return child; + } + }; + + template + bool replay() { + Gecode::Support::Random original(UINT64_MAX), restored; + for (unsigned int i=0; i<17; ++i) + (void) original(13); + original = original.split(37); + (void) original(UINT64_MAX); + restored.state(original.state_string()); + for (uint32_t a : {0U,1U,17U,UINT32_MAX}) { + if (original.split(a).state() != restored.split(a).state()) + return false; + if (original.next() != restored.next()) + return false; + } + auto before = restored.state(); + if (restored(0) || restored(1) || restored(-1) || + restored.state() != before) + return false; + return true; + } + + class Contract : public Base { + public: + Contract() : Base("Random::Contract") {} + bool run() override { + // SplitMix64 reference sequence, seed zero and golden-ratio increment. + RandomGenerator r(0); + for (uint64_t expected : {UINT64_C(0xe220a8397b1dcdaf), + UINT64_C(0x6e789e6aa1b965f4), + UINT64_C(0x06c45d188009454f)}) + if (r.next() != expected) + return false; + Xorshift64Star xs(1); + if (xs.next() != UINT64_C(0x47e4ce4b896cdd1d)) + return false; + if (!replay() || !replay()) + return false; + auto parent = r.state(); + // Indexing skips pairs of parent words, exactly as sequential splits. + RandomGenerator sequential = r; + for (uint32_t a=0; a<100; ++a) { + auto child = r.split(a); + if (child.state()[0] != sequential.next()) + return false; + (void) sequential.next(); + if ((a>0) && (child.state() == r.split(a-1).state())) + return false; + } + if (r.state() != parent || r.split(0).state()==r.split(UINT32_MAX).state()) + return false; + if (random_seed("18446744073709551615") != UINT64_MAX || + random_seed("0xffffffffffffffff") != UINT64_MAX) + return false; + for (const char* invalid : {"", "-1", "+1", "1x", "0x", "18446744073709551616"}) { + try { (void) random_seed(invalid); return false; } + catch (const std::invalid_argument&) {} + } + const auto saved = r.state(); + for (const char* invalid : { + "splitmix-v1:0000000000000000:0000000000000000", + "splitmix-v1:0000000000000000:0000000000000002", + "splitmix-v1:000000000000000g:9e3779b97f4a7c15", + "splitmix-v1:0:9e3779b97f4a7c15", + "other-v1:0000000000000000:9e3779b97f4a7c15"}) { + try { r.state(std::string(invalid)); return false; } + catch (const std::invalid_argument&) {} + if (r.state() != saved) + return false; + } + Gecode::Support::Random x(0); + try { x.state(Xorshift64Star::State{{0}}); return false; } + catch (const std::invalid_argument&) {} + for (uint64_t bound : {UINT64_C(2),UINT64_C(3),UINT64_C(0x8000000000000001),UINT64_MAX}) + for (unsigned int i=0; i<100; ++i) + if (r(bound)>=bound || x(bound)>=bound) + return false; + return true; + } + } contract; + } +} diff --git a/test/test.cpp b/test/test.cpp index 5d808d8dd2..d6da2b67f9 100644 --- a/test/test.cpp +++ b/test/test.cpp @@ -94,11 +94,18 @@ namespace Test { Options opt; - void report_error(const std::string& name, unsigned int seed, Options& options, std::ostream& ostream) { - ostream << "Options: -seed " << seed; + void report_error(const std::string& name, const std::string& state, + const Options& options, std::ostream& ostream) { + ostream << "Options: -state " << state << " -iter 1"; if (options.fixprob != Test::Options::deffixprob) ostream << " -fixprob " << options.fixprob; - ostream << " -test " << name << std::endl; + ostream << " -test-exact '"; + for (char c : name) + if (c == '\'') + ostream << "'\\''"; + else + ostream << c; + ostream << "'" << std::endl; if (options.log) ostream << olog.str(); } @@ -106,6 +113,7 @@ namespace Test { void Options::parse(int argc, char* argv[]) { int i = 1; + bool seed_given = false; while (i < argc) { if (!strcmp(argv[i],"-help") || !strcmp(argv[i],"--help")) { std::cerr << "Options for testing:" << std::endl @@ -113,12 +121,16 @@ namespace Test { << "\t\tnumber of threads to use. If 0, as many threads as there are cores are used.\n" << "\t\tThreaded execution and logging can not be used at the same time." << std::endl - << "\t-seed (unsigned int or \"time\") default: " + << "\t-seed (64-bit unsigned integer or \"time\") default: " << seed << std::endl - << "\t\tseed for random number generator (unsigned int)," + << "\t\tseed for random number generator (decimal or hexadecimal)," << std::endl << "\t\tor \"time\" for a random seed based on " << "current time" << std::endl + << "\t-state (complete random state)" << std::endl + << "\t\treplay one test directly; requires -test-exact" << std::endl + << "\t-test-exact (string)" << std::endl + << "\t\texact name of the test to run" << std::endl << "\t-fixprob (unsigned int) default: " << fixprob << std::endl << "\t\t1/fixprob is the probability of computing a fixpoint" @@ -158,11 +170,30 @@ namespace Test { } } else if (!strcmp(argv[i],"-seed")) { if (++i == argc) goto missing; + seed_given = true; if (!strcmp(argv[i],"time")) { - seed = static_cast(time(nullptr)); + seed = static_cast(time(nullptr)); } else { - seed = static_cast(atoi(argv[i])); + try { + seed = Gecode::Support::random_seed(argv[i]); + } catch (const std::invalid_argument& e) { + std::cerr << e.what() << std::endl; + exit(EXIT_FAILURE); + } + } + } else if (!strcmp(argv[i],"-state")) { + if (++i == argc) goto missing; + random_state = argv[i]; + try { + Gecode::Support::RandomGenerator check; + check.state(random_state); + } catch (const std::invalid_argument& e) { + std::cerr << e.what() << std::endl; + exit(EXIT_FAILURE); } + } else if (!strcmp(argv[i],"-test-exact")) { + if (++i == argc) goto missing; + exact_test = argv[i]; } else if (!strcmp(argv[i],"-iter")) { if (++i == argc) goto missing; iter = static_cast(atoi(argv[i])); @@ -195,6 +226,12 @@ namespace Test { i++; } + if (!random_state.empty() && + (seed_given || exact_test.empty() || threads != 1)) { + std::cerr << "State replay requires -test-exact, one thread, and no -seed." + << std::endl; + exit(EXIT_FAILURE); + } if (threads > 1 && log) { std::cerr << "Logging and multi threading can not be used jointly." << std::endl; exit(EXIT_FAILURE); @@ -208,6 +245,8 @@ namespace Test { } bool Options::is_test_name_matching(const std::string& test_name) { + if (!exact_test.empty()) + return test_name == exact_test; if (!testpat.empty()) { bool positive_patterns = false; bool match_found = false; @@ -248,19 +287,22 @@ namespace Test { } /// Run a single test, returning true iff the test succeeded - bool run_test(Base* test, unsigned int test_seed, const Options& options, std::ostream& ostream) { + bool run_test(Base* test, uint64_t test_seed, const Options& options, std::ostream& ostream) { + test->_rand.seed(test_seed); + if (!options.random_state.empty()) + test->_rand.state(options.random_state); + std::string iteration_state = test->_rand.state_string(); try { ostream << test->name() << " "; ostream.flush(); - test->_rand.seed(test_seed); for (unsigned int i = options.iter; i--;) { - unsigned int seed = test->_rand.seed(); + iteration_state = test->_rand.state_string(); if (test->run()) { ostream << '+'; ostream.flush(); } else { ostream << "-" << std::endl; - report_error(test->name(), seed, opt, ostream); + report_error(test->name(), iteration_state, options, ostream); return false; } } @@ -270,7 +312,11 @@ namespace Test { ostream << "Exception in \"Gecode::" << e.what() << "." << std::endl << "Stopping..." << std::endl; - report_error(test->name(), options.seed, opt, ostream); + report_error(test->name(), iteration_state, options, ostream); + return false; + } catch (const std::exception& e) { + ostream << "Exception: " << e.what() << std::endl; + report_error(test->name(), iteration_state, options, ostream); return false; } } @@ -280,7 +326,7 @@ namespace Test { Gecode::Support::RandomGenerator seed_sequence(options.seed); int result = EXIT_SUCCESS; for (auto test : tests) { - unsigned int test_seed = seed_sequence.next(); + uint64_t test_seed = seed_sequence.next(); if (!run_test(test, test_seed, options, std::cout)) { if (opt.stop) { return EXIT_FAILURE; @@ -396,10 +442,10 @@ namespace Test { /// The common controller for running tests TestExecutionControl& tec; /// The initial seed to start with - const int initial_seed; + const uint64_t initial_seed; public: - TestExecutor(TestExecutionControl& tec, const int initialSeed) + TestExecutor(TestExecutionControl& tec, uint64_t initialSeed) : tec(tec), initial_seed(initialSeed) {} void run(void) override { @@ -422,7 +468,7 @@ namespace Test { break; } auto test = tec.tests[i]; - unsigned int test_seed = seed_sequence.next(); + uint64_t test_seed = seed_sequence.next(); std::ostringstream test_output; if (!run_test(test, test_seed, tec.options, test_output)) { tec.set_failure(); @@ -489,6 +535,10 @@ main(int argc, char* argv[]) { } } + if (!opt.exact_test.empty() && tests.size() != 1) { + std::cerr << "Exact test name did not select a test." << std::endl; + return EXIT_FAILURE; + } if (opt.threads > 1) { return run_tests_parallel(tests, opt); } else { diff --git a/test/test.hh b/test/test.hh index 9092165615..fd9f82cf0b 100755 --- a/test/test.hh +++ b/test/test.hh @@ -85,7 +85,11 @@ namespace Test { /// Number of threads to use unsigned int threads; /// The random seed to be used - unsigned int seed; + uint64_t seed; + /// Complete state for replaying exactly one named test + std::string random_state; + /// Exact test name (also used by failure replay commands) + std::string exact_test; /// Number of iterations for each test unsigned int iter; /// Default number of iterations From b4ec5dfe9978f98e37fd84b3a8b5549a44b38179 Mon Sep 17 00:00:00 2001 From: Mikael Zayenz Lagerkvist Date: Thu, 10 Sep 2026 10:14:34 +0200 Subject: [PATCH 2/7] random: derive alternative streams from recorded choices --- CMakeLists.txt | 1 + gecode/flatzinc/branch.hpp | 3 +- gecode/float/branch/val-sel.hpp | 5 +- gecode/int/branch/val-sel.hpp | 5 +- gecode/kernel/branch/view-sel.hpp | 4 +- gecode/kernel/core.cpp | 44 ++++- gecode/kernel/core.hpp | 14 +- gecode/kernel/data/rnd.cpp | 64 ++++--- gecode/kernel/data/rnd.hpp | 287 ++++++++++++++++-------------- gecode/set/branch/val-sel.hpp | 5 +- plans/random.md | 57 +++++- test/random.cpp | 124 +++++++++++++ 12 files changed, 422 insertions(+), 191 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 7f18ab4ad4..c175d9dac4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1426,6 +1426,7 @@ if(BUILD_TESTING) set(GECODE_CHECK_TESTS Random::Contract + Random::BranchReplay Branch::Int::Dense::3 Int::Arithmetic::Abs Int::Arithmetic::ArgMax diff --git a/gecode/flatzinc/branch.hpp b/gecode/flatzinc/branch.hpp index cb287f1b62..70517cdd17 100644 --- a/gecode/flatzinc/branch.hpp +++ b/gecode/flatzinc/branch.hpp @@ -35,7 +35,7 @@ namespace Gecode { namespace FlatZinc { forceinline IntBoolVarBranch::IntBoolVarBranch(Select s0, double d) - : VarBranch(d), s(s0) {} + : VarBranch(d,nullptr), s(s0) {} forceinline IntBoolVarBranch::IntBoolVarBranch(Select s0, IntAFC i, BoolAFC b) @@ -442,4 +442,3 @@ namespace Gecode { namespace FlatZinc { }} // STATISTICS: flatzinc-branch - diff --git a/gecode/float/branch/val-sel.hpp b/gecode/float/branch/val-sel.hpp index 811663f4ab..c41a18ec19 100644 --- a/gecode/float/branch/val-sel.hpp +++ b/gecode/float/branch/val-sel.hpp @@ -77,10 +77,10 @@ namespace Gecode { namespace Float { namespace Branch { forceinline ValSelRnd::ValSelRnd(Space& home, const ValBranch& vb) - : ValSel(home,vb), r(vb.rnd()) {} + : ValSel(home,vb), r(home,vb.rnd()) {} forceinline ValSelRnd::ValSelRnd(Space& home, ValSelRnd& vs) - : ValSel(home,vs), r(vs.r) { + : ValSel(home,vs), r(home,vs.r) { } forceinline FloatNumBranch ValSelRnd::val(const Space&, FloatView x, int) { @@ -100,4 +100,3 @@ namespace Gecode { namespace Float { namespace Branch { }}} // STATISTICS: float-branch - diff --git a/gecode/int/branch/val-sel.hpp b/gecode/int/branch/val-sel.hpp index 7d91368c3e..0e2e172796 100755 --- a/gecode/int/branch/val-sel.hpp +++ b/gecode/int/branch/val-sel.hpp @@ -97,11 +97,11 @@ namespace Gecode { namespace Int { namespace Branch { forceinline ValSelRnd::ValSelRnd (Space& home, const ValBranch::Var>& vb) - : ValSel(home,vb), r(vb.rnd()) {} + : ValSel(home,vb), r(home,vb.rnd()) {} template forceinline ValSelRnd::ValSelRnd(Space& home, ValSelRnd& vs) - : ValSel(home,vs), r(vs.r) { + : ValSel(home,vs), r(home,vs.r) { } template forceinline int @@ -166,4 +166,3 @@ namespace Gecode { namespace Int { namespace Branch { }}} // STATISTICS: int-branch - diff --git a/gecode/kernel/branch/view-sel.hpp b/gecode/kernel/branch/view-sel.hpp index 2ffa423cd0..1c0620c5a4 100644 --- a/gecode/kernel/branch/view-sel.hpp +++ b/gecode/kernel/branch/view-sel.hpp @@ -482,11 +482,11 @@ namespace Gecode { template forceinline ViewSelRnd::ViewSelRnd(Space& home, const VarBranch& vb) - : ViewSel(home,vb), r(vb.rnd()) {} + : ViewSel(home,vb), r(home,vb.rnd()) {} template forceinline ViewSelRnd::ViewSelRnd(Space& home, ViewSelRnd& vs) - : ViewSel(home,vs), r(vs.r) {} + : ViewSel(home,vs), r(home,vs.r) {} template int ViewSelRnd::select(Space&, ViewArray& x, int s) { diff --git a/gecode/kernel/core.cpp b/gecode/kernel/core.cpp index 4293cfe6a8..c5a40bdcd3 100644 --- a/gecode/kernel/core.cpp +++ b/gecode/kernel/core.cpp @@ -235,6 +235,7 @@ namespace Gecode { if (_vars_d[i] != nullptr) vd[i]->dispose(*this, _vars_d[i]); #endif + delete randoms; // Release memory from memory manager mm.release(ssd.data().sm); } @@ -594,16 +595,35 @@ namespace Gecode { } // Make sure that b_commit does not point to a deleted brancher! b_commit = b_status; - return b_status->choice(*this); + std::unique_ptr c(b_status->choice(*this)); + if (randoms) + const_cast(c.get())->random_state = randoms->snapshot(); + return c.release(); } const Choice* Space::choice(Archive& e) const { unsigned int id; e >> id; + unsigned int n; e >> n; + std::unique_ptr data; + if (n) { + if (n > static_cast((e.size()-2)/2)) + throw std::invalid_argument("Invalid random choice archive size"); + data = std::make_unique(size_t(n)+1); + data[0]=n; + for (unsigned int i=0; i> lo >> hi; + data[i+1] = uint64_t(lo) | (uint64_t(hi)<<32); + } + } Brancher* b_cur = Brancher::cast(bl.next()); while (b_cur != Brancher::cast(&bl)) { - if (id == b_cur->id()) - return b_cur->choice(*this,e); + if (id == b_cur->id()) { + const Choice* c = b_cur->choice(*this,e); + const_cast(c)->random_state = data.release(); + return c; + } b_cur = Brancher::cast(b_cur->next()); } throw SpaceNoBrancher("Space::choice"); @@ -616,6 +636,11 @@ namespace Gecode { if (failed()) return; if (Brancher* b = brancher(c.bid)) { + if (c.random_state) { + if (!randoms) + throw std::invalid_argument("Random choice requires space streams"); + randoms->commit(c.random_state,a); + } // There is a matching brancher if (pc.p.bid_sc & sc_trace) { TraceRecorder* tr = findtracerecorder(); @@ -647,6 +672,11 @@ namespace Gecode { if (failed()) return; if (Brancher* b = brancher(c.bid)) { + if (c.random_state) { + if (!randoms) + throw std::invalid_argument("Random choice requires space streams"); + randoms->commit(c.random_state,a); + } // There is a matching brancher if (pc.p.bid_sc & sc_trace) { TraceRecorder* tr = findtracerecorder(); @@ -741,6 +771,8 @@ namespace Gecode { pl.init(); bl.init(); b_status = b_commit = Brancher::cast(&bl); + if (s.randoms) + randoms = new RandomContext(*s.randoms); // Copy all propagators { ActorLink* p = &pl; @@ -785,6 +817,7 @@ namespace Gecode { } catch (...) { recover(s); pc.c.source = nullptr; + delete randoms; mm.release(ssd.data().sm); throw; } @@ -945,6 +978,11 @@ namespace Gecode { void Choice::archive(Archive& e) const { e << id(); + unsigned int n = random_state ? static_cast(random_state[0]) : 0; + e << n; + for (unsigned int i=0; i(random_state[i+1]) + << static_cast(random_state[i+1]>>32); } bool diff --git a/gecode/kernel/core.hpp b/gecode/kernel/core.hpp index 169aab6599..1a67603421 100755 --- a/gecode/kernel/core.hpp +++ b/gecode/kernel/core.hpp @@ -144,6 +144,8 @@ namespace Gecode { class Advisor; class AFC; class Choice; + class Rnd; + class RandomContext; class Brancher; class Group; class PropagatorGroup; @@ -1425,6 +1427,10 @@ namespace Gecode { private: unsigned int bid; ///< Identity to match creating brancher unsigned int alt; ///< Number of alternatives + /// Optional packed random-state snapshot (independent of alternative count) + uint64_t* random_state; + Choice(const Choice&) = delete; + Choice& operator =(const Choice&) = delete; /// Return id of the creating brancher unsigned int id(void) const; @@ -1795,6 +1801,8 @@ namespace Gecode { Kernel::SharedSpaceData ssd; /// Performs memory management for space Kernel::MemoryManager mm; + /// Lazily allocated random streams, owned by this space + RandomContext* randoms = nullptr; #ifdef GECODE_HAS_CBS /// Global counter for variable ids unsigned int var_id_counter; @@ -2037,6 +2045,8 @@ namespace Gecode { GECODE_KERNEL_EXPORT void ap_ignore_dispose(Actor* a, bool d); public: + /// Bind a random handle to this space, mapping local handles during cloning + GECODE_KERNEL_EXPORT Rnd random(const Rnd& source); /** * \brief Default constructor * \ingroup TaskModelScript @@ -3885,7 +3895,7 @@ namespace Gecode { */ forceinline Choice::Choice(const Brancher& b, const unsigned int a) - : bid(b.id()), alt(a) {} + : bid(b.id()), alt(a), random_state(nullptr) {} forceinline unsigned int Choice::alternatives(void) const { @@ -3898,7 +3908,7 @@ namespace Gecode { } forceinline - Choice::~Choice(void) {} + Choice::~Choice(void) { delete[] random_state; } diff --git a/gecode/kernel/data/rnd.cpp b/gecode/kernel/data/rnd.cpp index 395e050452..3be7cc82b2 100644 --- a/gecode/kernel/data/rnd.cpp +++ b/gecode/kernel/data/rnd.cpp @@ -36,48 +36,46 @@ #include namespace Gecode { + Rnd::Rnd(Space& home, const Rnd& source) + : SharedHandle(home.random(source)) {} - Support::Mutex Rnd::IMP::m; - - forceinline - Rnd::IMP::IMP(uint64_t s) - : rg(s) {} - - Rnd::IMP::~IMP(void) {} + void Rnd::seed(uint64_t value) { + if (!object()) + *this = Rnd(value); + else + imp().seed(value); + } - forceinline void - Rnd::_seed(uint64_t s) { - if (object() == nullptr) { - object(new IMP(s)); + void Rnd::state(const std::string& text) { + if (!object()) { + Rnd candidate(1); + candidate.state(text); + *this = candidate; } else { - static_cast(object())->seed(s); + imp().state(text); } } - Rnd::Rnd(void) {} - Rnd::Rnd(uint64_t s) { - object(new IMP(s)); - } - Rnd::Rnd(const Rnd& r) - : SharedHandle(r) {} - Rnd& - Rnd::operator =(const Rnd& r) { - (void) SharedHandle::operator =(r); - return *this; + void Rnd::time(void) { + seed(static_cast(::time(nullptr))); } - Rnd::~Rnd(void) {} - void - Rnd::seed(uint64_t s) { - _seed(s); + void Rnd::hw(void) { + seed((uint64_t(Support::hwrnd()) << 32) | Support::hwrnd()); } - void - Rnd::time(void) { - _seed(static_cast(::time(nullptr))); - } - void - Rnd::hw(void) { - _seed(Support::hwrnd()); + + Rnd Space::random(const Rnd& source) { + if (!source) + throw UninitializedRnd("Space::random"); + // During actor/model copying, map source-local handles by their position. + if (is_partial_clone() && pc.c.source && pc.c.source->randoms) { + size_t i = pc.c.source->randoms->find(source); + if (i < pc.c.source->randoms->size()) + return randoms->at(i); + } + if (!randoms) + randoms = new RandomContext; + return randoms->bind(source); } } diff --git a/gecode/kernel/data/rnd.hpp b/gecode/kernel/data/rnd.hpp index 7de75e136f..f8f3b5c71b 100755 --- a/gecode/kernel/data/rnd.hpp +++ b/gecode/kernel/data/rnd.hpp @@ -32,152 +32,175 @@ */ #include +#include +#include namespace Gecode { + class RandomContext; + /** - * \brief Random number generator + * \brief Handle to a random stream + * + * Standalone handles share a stream. Binding to a space creates a local + * stream, shared by handles bound from the same source. Space clones have + * independent stream state. Use copy() for an independent standalone copy. + * A stream must not be drawn from concurrently. * \ingroup TaskModel */ class Rnd : public SharedHandle { + friend class RandomContext; private: - /// Implementation of generator class IMP : public SharedHandle::Object { - protected: - /// Mutex for locking - GECODE_KERNEL_EXPORT static Support::Mutex m; - /// The actual generator - Support::RandomGenerator rg; public: - /// Initialize generator with seed \a s - IMP(uint64_t s); - /// Return complete state - Support::RandomGenerator::State state(void) const; - /// Set seed to \a s - void seed(uint64_t s); - /// Returns a random integer from the interval \f$[0\ldots n)\f$ - unsigned int operator ()(unsigned int n); - /// Returns a random integer from the interval \f$[0\ldots n)\f$ - int operator ()(int n); - /// Returns a random integer from the interval \f$[0\ldots n)\f$ - unsigned long long int operator ()(unsigned long long int n); - /// Returns a random integer from the interval \f$[0\ldots n)\f$ - long long int operator ()(long long int n); - /// Delete implemenentation - virtual ~IMP(void); + virtual IMP* copy(void) const = 0; + virtual IMP* split(uint32_t a) const = 0; + virtual void seed(uint64_t value) = 0; + virtual uint64_t draw(uint64_t bound) = 0; + virtual size_t words(void) const = 0; + virtual void save(uint64_t* out) const = 0; + virtual void commit(const uint64_t* in, uint32_t a) = 0; + virtual std::string state(void) const = 0; + virtual void state(const std::string& text) = 0; + virtual const char* name(void) const = 0; + }; + template + class Implementation : public IMP { + Support::Random r; + public: + explicit Implementation(const Support::Random& value) : r(value) {} + IMP* copy(void) const override { return new Implementation(r); } + IMP* split(uint32_t a) const override { + return new Implementation(r.split(a)); + } + void seed(uint64_t value) override { r.seed(value); } + uint64_t draw(uint64_t bound) override { return r(bound); } + size_t words(void) const override { + return std::tuple_size::value; + } + void save(uint64_t* out) const override { + auto s = r.state(); + std::copy(s.begin(),s.end(),out); + } + void commit(const uint64_t* in, uint32_t a) override { + typename Engine::State s; + std::copy(in,in+s.size(),s.begin()); + auto parent = r; + parent.state(s); + r = parent.split(a); + } + std::string state(void) const override { return r.state_string(); } + void state(const std::string& text) override { r.state(text); } + const char* name(void) const override { return Engine::name(); } }; - /// Set the current seed to \a s (initializes if needed) - void _seed(uint64_t s); + IMP& imp(void) const { + if (!object()) + throw UninitializedRnd("Rnd"); + return *static_cast(object()); + } + Rnd(IMP* value, bool) : SharedHandle(value) {} public: - /// Default constructor that does not initialize the generator - GECODE_KERNEL_EXPORT - Rnd(void); - /// Initialize from generator \a r - GECODE_KERNEL_EXPORT - Rnd(const Rnd& r); - /// Assignment operator - GECODE_KERNEL_EXPORT - Rnd& operator =(const Rnd& r); - /// Destructor - GECODE_KERNEL_EXPORT - ~Rnd(void); - /// Initialize with seed \a s - GECODE_KERNEL_EXPORT - Rnd(uint64_t s); - /// Set the current seed to \a s (initializes if needed) - GECODE_KERNEL_EXPORT - void seed(uint64_t s); - /// Set current seed based on time (initializes if needed) - GECODE_KERNEL_EXPORT - void time(void); - /// Set current seed to hardware-based random number (initializes if needed) - GECODE_KERNEL_EXPORT - void hw(void); - /// Return complete current state - Support::RandomGenerator::State state(void) const; - /// Returns a random integer from the interval \f$[0\ldots n)\f$ - unsigned int operator ()(unsigned int n); - /// Returns a random integer from the interval \f$[0\ldots n)\f$ - int operator ()(int n); - /// Returns a random integer from the interval \f$[0\ldots n)\f$ - unsigned long long int operator ()(unsigned long long int n); - /// Returns a random integer from the interval \f$[0\ldots n)\f$ - long long int operator ()(long long int n); + /// Uninitialized handle + Rnd(void) = default; + /// Share an existing handle + Rnd(const Rnd&) = default; + Rnd& operator =(const Rnd&) = default; + ~Rnd(void) = default; + /// Create a standalone default stream + explicit Rnd(uint64_t seed) + : Rnd(Support::RandomGenerator(seed)) {} + /// Create a standalone user-defined splittable stream + template + explicit Rnd(const Support::Random& r) + : SharedHandle(new Implementation(r)) {} + /// Bind a source stream to a space, or update its handle during cloning + GECODE_KERNEL_EXPORT Rnd(Space& home, const Rnd& source); + /// Create and bind a default stream to a space + Rnd(Space& home, uint64_t seed) : Rnd(home,Rnd(seed)) {} + /// Make an independent exact copy; does not split or advance the source + Rnd copy(void) const { return Rnd(imp().copy(),true); } + /// Derive an alternative stream without modifying this stream + Rnd split(uint32_t a) const { return Rnd(imp().split(a),true); } + /// Seed this stream (initializes a default engine if uninitialized) + GECODE_KERNEL_EXPORT void seed(uint64_t value); + /// Initialize using time or hardware entropy + GECODE_KERNEL_EXPORT void time(void); + GECODE_KERNEL_EXPORT void hw(void); + /// Complete state, with algorithm identifier + std::string state(void) const { return imp().state(); } + /// Restore a state for this engine (default engine if uninitialized) + GECODE_KERNEL_EXPORT void state(const std::string& text); + const char* name(void) const { return imp().name(); } + /// Number of 64-bit words in the engine state + size_t words(void) const { return imp().words(); } + /// Draw an integer in [0,bound); bounds <= 1 consume no values + template + Type operator ()(Type bound) { + static_assert(std::is_integral::value && sizeof(Type)<=8, + "Random bound must be an integer of at most 64 bits"); + return bound<=1 ? 0 : + static_cast(imp().draw(static_cast(bound))); + } }; - forceinline Support::RandomGenerator::State - Rnd::IMP::state(void) const { - Support::RandomGenerator::State s; - const_cast(*this).m.acquire(); - s = rg.state(); - const_cast(*this).m.release(); - return s; - } - forceinline void - Rnd::IMP::seed(uint64_t s) { - m.acquire(); - rg.seed(s); - m.release(); - } - forceinline unsigned int - Rnd::IMP::operator ()(unsigned int n) { - unsigned int r; - m.acquire(); - r=rg(n); - m.release(); - return r; - } - forceinline int - Rnd::IMP::operator ()(int n) { - int r; - m.acquire(); - r=rg(n); - m.release(); - return r; - } - forceinline unsigned long long int - Rnd::IMP::operator ()(unsigned long long int n) { - unsigned long long int r; - m.acquire(); - r=rg(n); - m.release(); - return r; - } - forceinline long long int - Rnd::IMP::operator ()(long long int n) { - long long int r; - m.acquire(); - r=rg(n); - m.release(); - return r; - } - - forceinline Support::RandomGenerator::State - Rnd::state(void) const { - const IMP* i = static_cast(object()); - return i->state(); - } - forceinline unsigned int - Rnd::operator ()(unsigned int n) { - IMP* i = static_cast(object()); - return (*i)(n); - } - forceinline int - Rnd::operator ()(int n) { - IMP* i = static_cast(object()); - return (*i)(n); - } - forceinline unsigned long long int - Rnd::operator ()(unsigned long long int n) { - IMP* i = static_cast(object()); - return (*i)(n); - } - forceinline long long int - Rnd::operator ()(long long int n) { - IMP* i = static_cast(object()); - return (*i)(n); - } - + /// Internal space-local stream storage. Entries keep initialization handles alive. + class RandomContext { + struct Entry { + Rnd source; + Rnd local; + }; + std::vector streams; + public: + RandomContext(void) = default; + RandomContext(const RandomContext& other) { + streams.reserve(other.streams.size()); + for (const auto& entry : other.streams) + streams.push_back({entry.source,entry.local.copy()}); + } + size_t find(const Rnd& source) const { + for (size_t i=0; i(n+1); + data[0]=n; + size_t pos=1; + for (const auto& entry : streams) { + entry.local.imp().save(data.get()+pos); + pos += entry.local.words(); + } + return data.release(); + } + /// Restore and split the recorded streams; layout follows registration order. + void commit(const uint64_t* data, uint32_t a) { + size_t n=0; + for (const auto& entry : streams) + n += entry.local.words(); + if (data[0] != n) + throw std::invalid_argument("Random choice does not match space streams"); + size_t pos=1; + for (auto& entry : streams) { + entry.local.imp().commit(data+pos,a); + pos += entry.local.words(); + } + } + }; } // STATISTICS: kernel-other diff --git a/gecode/set/branch/val-sel.hpp b/gecode/set/branch/val-sel.hpp index 113c6314e4..51fbf7db00 100644 --- a/gecode/set/branch/val-sel.hpp +++ b/gecode/set/branch/val-sel.hpp @@ -91,10 +91,10 @@ namespace Gecode { namespace Set { namespace Branch { forceinline ValSelRnd::ValSelRnd(Space& home, const ValBranch& vb) - : ValSel(home,vb), r(vb.rnd()) {} + : ValSel(home,vb), r(home,vb.rnd()) {} forceinline ValSelRnd::ValSelRnd(Space& home, ValSelRnd& vs) - : ValSel(home,vs), r(vs.r) { + : ValSel(home,vs), r(home,vs.r) { } forceinline int ValSelRnd::val(const Space&, SetView x, int) { @@ -120,4 +120,3 @@ namespace Gecode { namespace Set { namespace Branch { }}} // STATISTICS: set-branch - diff --git a/plans/random.md b/plans/random.md index 4cd68b8ca3..752e19979b 100644 --- a/plans/random.md +++ b/plans/random.md @@ -1,7 +1,7 @@ # Plan: Compact, splittable random generators for Gecode 7 > Source: the feature/random design discussion, 2026-09-10. -> Status: implementation authorized; Phase 1 complete, Phase 2 next. +> Status: Phases 1 and 2 complete; Phase 3 next. > Workflow: review, update this plan, and commit after each phase. ## Goal @@ -247,18 +247,59 @@ the same path with a user-supplied engine to prove that extension reaches search ### Acceptance criteria -- [ ] All alternatives of a recorded choice have distinct successor states. -- [ ] Committing an alternative directly, after intervening sibling exploration, +- [x] All alternatives of a recorded choice have distinct successor states. +- [x] Committing an alternative directly, after intervening sibling exploration, or after restoring an archived choice produces identical successor state and subsequent draws on equivalent spaces. -- [ ] The same recorded path yields the same state with frequent cloning and +- [x] The same recorded path yields the same state with frequent cloning and substantial recomputation, including last-alternative optimization. -- [ ] A deterministic choice followed by random branching, and a transition +- [x] A deterministic choice followed by random branching, and a transition between randomized branchers, both retain the alternative-specific stream. -- [ ] Shared and separate variable/value generators follow the documented +- [x] Shared and separate variable/value generators follow the documented ownership policy; cloning does not mutate the source's generators. -- [ ] Choice payload growth is independent of the number of alternatives. -- [ ] Measure description, brancher, choice, and archive sizes against baseline. +- [x] Choice payload growth is independent of the number of alternatives. +- [x] Measure description, brancher, choice, and archive sizes against baseline. + +### Phase 2 review + +Implemented the space-local collection and common choice/commit integration. +`Rnd` accepts a user engine through `Support::Random` and keeps its +8-byte handle representation. Each space binds initialization handles to local +streams and clones those streams independently. Selector and model handles are +mapped back to the corresponding local stream during copying. Standalone handles +retain handle-style sharing; `copy()` makes an independent exact copy. + +All bound state words are captured after choice selection and restored/split +before the brancher's commit. Archives include the complete packed payload. +This works for custom choices that use the standard space choice/commit and base +choice archive interfaces, without per-brancher snapshot code. Binary and multiway +choices have the same random payload for the same registered streams. The small +selector binding change was applied to set and float selectors in this phase too, +because the ownership contract is common; their wider verification is Phase 3. + +The global draw mutex is removed: mutable streams are space-local, and standalone +handles are documented as requiring caller synchronization if shared by threads. +Making numeric construction explicit also exposed a FlatZinc decay constructor +that accidentally converted a double to a random seed. It now selects the actual +decay constructor explicitly. + +Validation: `Random::BranchReplay` checks reverse-order alternative exploration, +archived choices replayed on pre-selection clones, intentionally perturbed +destination state, shared/separate streams, deterministic-to-random handover, +and a user engine with three state words. For each configuration, all 81 solution +values and final states agree between commit distances 1 and 100. Both random +tests and filtered tie selection pass twice, state-report replay still passes, +and the full existing `check` target (including fault tests) passes. + +Arm64 sizes: Rnd 8, IntVarBranch 112, IntValBranch 80, and selectors remain +unchanged. Space grows 288 -> 296 bytes; Choice 16 -> 24 and integer PosValChoice +24 -> 32 bytes. One default stream adds a separately allocated 24-byte snapshot +(8-byte length + 16-byte state), making the logical integer choice footprint +56 bytes before allocator overhead. Its archive grows from 3 to 8 unsigned words. +Two default streams use a 40-byte snapshot; the custom engine uses 8 additional +bytes per stream. Nonrandom choices have no snapshot allocation but pay the +8-byte optional pointer and one archive count word. These costs are explicit +inputs to Phase 3 performance review, not yet a performance acceptance claim. ## Phase 3: Complete branching and search integration diff --git a/test/random.cpp b/test/random.cpp index 86f2892a15..eac16c2f1a 100644 --- a/test/random.cpp +++ b/test/random.cpp @@ -114,5 +114,129 @@ namespace Test { return true; } } contract; + + class ReplaySpace : public Gecode::Space { + public: + Gecode::IntVarArray x; + Gecode::Rnd variable, value; + ReplaySpace(const Gecode::Rnd& source, bool separate, bool multi=false) + : x(*this,4,0,2), variable(*this,source), + value(*this,separate ? source.copy() : source) { + using namespace Gecode; + // The first branch is deterministic, but later streams must split. + branch(*this,x[0],multi ? INT_VALUES_MIN() : INT_VAL_MIN()); + IntVarArgs first(2); + first[0]=x[1]; first[1]=x[2]; + branch(*this,first,INT_VAR_RND(variable),INT_VAL_RND(value)); + branch(*this,x[3],INT_VAL_RND(value)); + } + ReplaySpace(ReplaySpace& s) + : Space(s), variable(*this,s.variable), value(*this,s.value) { + x.update(*this,s.x); + } + Space* copy() override { return new ReplaySpace(*this); } + }; + + bool same_archive(const Gecode::Choice& a, const Gecode::Choice& b) { + Gecode::Archive x,y; + a.archive(x); b.archive(y); + if (x.size()!=y.size()) + return false; + for (int i=0; i root(new ReplaySpace(source,separate,multi)); + while (root->status()==SS_BRANCH) { + std::unique_ptr before(static_cast(root->clone())); + const auto source_state = source.state(); + std::unique_ptr choice(root->choice()); + Archive packed; + choice->archive(packed); + if (packed[1] != source.words()*(separate ? 2 : 1)) + return false; + const auto variable = root->variable.copy(); + const auto value = root->value.copy(); + std::vector siblings; + // Explore backwards, exercising late alternatives without earlier draws. + for (unsigned int a=choice->alternatives(); a--;) { + std::unique_ptr direct(static_cast(root->clone())); + std::unique_ptr replay(static_cast(before->clone())); + Archive archive; + choice->archive(archive); + std::unique_ptr restored(replay->choice(archive)); + direct->commit(*choice,a); + // State on the recomputed space is intentionally different before commit. + (void) replay->variable(13); + replay->commit(*restored,a); + if (direct->variable.state()!=variable.split(a).state() || + direct->value.state()!=value.split(a).state() || + replay->variable.state()!=direct->variable.state() || + replay->value.state()!=direct->value.state()) + return false; + for (const auto& previous : siblings) + if (previous==direct->variable.state()) + return false; + siblings.push_back(direct->variable.state()); + auto status = direct->status(); + if (status!=replay->status()) + return false; + if (status==SS_BRANCH) { + std::unique_ptr next(direct->choice()); + std::unique_ptr next_replay(replay->choice()); + if (!same_archive(*next,*next_replay)) + return false; + } + } + if (source.state()!=source_state || root->variable.state()!=variable.state()) + return false; + root->commit(*choice,0); + } + return true; + } + + std::vector solutions(const Gecode::Rnd& source, + unsigned int distance, bool separate, + bool multi) { + using namespace Gecode; + ReplaySpace root(source,separate,multi); + Search::Options options; + options.c_d=distance; + options.a_d=distance; + DFS search(&root,options); + std::vector result; + while (std::unique_ptr s{search.next()}) { + std::ostringstream item; + item << s->x << ':' << s->variable.state() << ':' << s->value.state(); + result.push_back(item.str()); + } + return result; + } + + class BranchReplay : public Base { + public: + BranchReplay() : Base("Random::BranchReplay") {} + bool run() override { + Gecode::Rnd engines[] = { + Gecode::Rnd(42), + Gecode::Rnd(Gecode::Support::Random(42)) + }; + for (const auto& engine : engines) + for (bool separate : {false,true}) + for (bool multi : {false,true}) { + if (!choice_replay(engine,separate,multi)) + return false; + auto cloned = solutions(engine,1,separate,multi); + auto recomputed = solutions(engine,100,separate,multi); + if (cloned.size()!=81 || cloned!=recomputed) + return false; + } + return true; + } + } branch_replay; } } From 2130ef3850a5095d917633d02278e51e6a44f31e Mon Sep 17 00:00:00 2001 From: Mikael Zayenz Lagerkvist Date: Thu, 10 Sep 2026 10:36:23 +0200 Subject: [PATCH 3/7] random: complete stream integration and compare splitting costs --- CMakeLists.txt | 1 + docs/random.md | 200 +++++++++++++++++++++++++++++++++++ examples/job-shop.cpp | 5 +- examples/photo.cpp | 6 +- gecode/flatzinc/flatzinc.cpp | 10 +- gecode/kernel/core.hpp | 4 + gecode/kernel/data/rnd.cpp | 24 +++-- gecode/kernel/data/rnd.hpp | 50 +++++---- gecode/search/relax.hh | 1 + gecode/support/random.hpp | 65 ++++++++++-- plans/random.md | 70 ++++++++++-- test/fault.cpp | 66 ++++++++++++ test/random.cpp | 101 +++++++++++++++--- tools/random-benchmark.cpp | 139 ++++++++++++++++++++++++ tools/random-benchmark.py | 72 +++++++++++++ 15 files changed, 753 insertions(+), 61 deletions(-) create mode 100644 docs/random.md create mode 100644 tools/random-benchmark.cpp create mode 100644 tools/random-benchmark.py diff --git a/CMakeLists.txt b/CMakeLists.txt index c175d9dac4..4a46ec25ca 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1427,6 +1427,7 @@ if(BUILD_TESTING) set(GECODE_CHECK_TESTS Random::Contract Random::BranchReplay + Random::CommitBoundary Branch::Int::Dense::3 Int::Arithmetic::Abs Int::Arithmetic::ArgMax diff --git a/docs/random.md b/docs/random.md new file mode 100644 index 0000000000..0f69567c07 --- /dev/null +++ b/docs/random.md @@ -0,0 +1,200 @@ +# Random generators in Gecode 7 + +Random branching uses a stream local to each space. Each recorded choice contains +the state needed to derive a different stream for each alternative. Recomputing +that choice installs the same stream as the original commit. Cloning copies state; +it does not split or consume random values. + +## Engines and full state + +`Support::Random` is a value type. Copying it makes an independent, exact +copy. It supplies bounded integer generation and state text on top of the engine. +An engine provides: + +- `State`, a nonempty `std::array` containing all mutable state and + per-stream parameters; +- construction and `seed(uint64_t)`, with a documented rule for every seed; +- `next()`, `min()`, and `max()`, with raw output in `[0,UINT64_MAX]` or + `[1,UINT64_MAX]`; +- `state()` and `state(const State&)` for exact capture and validated restoration; +- `name()`, a stable identifier for its state format and algorithm; +- `split(uint32_t) const`, returning the selected child without changing its + parent, for use in search. + +All valid alternative indices of a parent must produce distinct child states. +An engine must document that property; different first output values are neither +required nor sufficient. Copying and restoring state must reproduce subsequent +draws and splits. A state setter must reject invalid input before mutating state. +Keep engine-owned resources exception-safe; copying an engine may occur while +cloning a space. Raw pointers to external mutable state are unsuitable snapshots. + +The built-in engines are: + +| Engine | State | Splitting | +| --- | ---: | --- | +| `Support::SplitMix` | 16 bytes | Indexed sequential SplitMix splits, constant time | +| `Support::Xorshift64Star` | 8 bytes | Indexed jumps in the native recurrence | + +SplitMix uses the published two-word splittable design: a state and an odd +increment. It is the preferred default. For parent `(s,g)`, child `a` is +`(Mix13(s+(2a+1)g), mixGamma(s+(2a+2)g))` modulo 2^64. The first word is distinct +for every 32-bit index because `g` is odd and Mix13 is a permutation. This is +exactly the child obtained by sequentially splitting `a+1` times, without doing +the intervening work. + +Xorshift64* uses shifts 12, 25, and 27, with multiplier 2685821657736338717. +Child `a` starts `(a+1)*2^32` recurrence steps ahead. Since 2^32 is coprime to +the period 2^64-1, sibling states are distinct. Binary powers of the transition +matrix compute jumps in logarithmic time, with a shared 16 KiB table initialized +once. That table is not part of individual states or choices. For the maximum +32-bit index, 2^64 steps reduce to one step modulo the period. These jumps keep +the original recurrence; they are not a new seed-hashing scheme. + +Neither engine guarantees globally disjoint streams throughout an unbounded +search tree. Xorshift64* also has known statistical weaknesses in its low bits; +its smaller state and different splitting cost should be considered together. +See [Vigna's discussion](https://prng.di.unimi.it/xorshift.php) and the +[SplitMix paper](https://gee.cs.oswego.edu/dl/papers/oopsla14.pdf). + +Bounded draws use integer rejection sampling. Bounds zero, one, or negative +signed bounds return zero without consuming output. Full-range engines reject +the incomplete bucket at the bottom of the raw range; nonzero engines first +subtract one and reject the incomplete bucket at the top. Thus xorshift's missing +zero is accounted for. No distribution state is cached. The conversion and its +draw consumption are part of Gecode's reproducible sequence. + +Full state text is an identifier followed by fixed-width hexadecimal words, for +example `splitmix-v1:000000000000002a:9e3779b97f4a7c15`. Word order is explicit +and independent of host byte order. Restoration does not run seed expansion or +repair invalid states. A normal 64-bit seed is a convenience for initialization, +not a substitute for a complete snapshot. + +## Handles, binding, and callbacks + +`Rnd(seed)` creates a standalone default stream. `Rnd(Support::Random(seed))` +creates a user-defined one. Copying a standalone `Rnd` shares its stream; +`r.copy()` creates an independent exact copy, and `r.split(a)` creates a child. +Concurrent draws through shared standalone handles require caller synchronization. + +`Rnd(home, source)` binds a stream to a space. Binding the same stream again +returns the same local stream. Different initialization handles remain different +streams even when their initial states are equal. Binding retains stream identity +across clones, so callbacks can resolve an ancestor's handle in their own space. +Reseeding an external initialization handle after binding does not reseed the +space-local copy; reseed the bound handle explicitly if that is intended. + +Built-in random selectors bind during posting and remap during cloning. A model +that retains a bound handle should likewise use `rnd(*this, s.rnd)` in its copy +constructor. User callbacks that capture a handle must resolve it through the +space passed to the callback rather than draw directly from the captured handle: + +```cpp +Rnd source(*this, 42); +branch(*this, x, INT_VAR_NONE(), + INT_VAL([source](const Space& home, IntVar v, int) { + Rnd local(home, source); + unsigned int offset = local(v.size()); + IntVarValues values(v); + while (offset--) ++values; + return values.val(); + })); +``` + +The const-space overload only looks up an already bound stream. Bind streams +before taking choices or clones that will replay their use. In particular, a +choice-selection callback must not first register a new stream that is absent +from an earlier clone. Dynamically posted branchers can reuse existing streams; +new streams introduced during commit must be introduced consistently on replay. +An explicitly new stream starts at its specified initialization point; it does +not retrospectively consume earlier alternatives. + +Each choice snapshots all bound streams, including streams belonging to later +branchers. Therefore a deterministic branch before a random branch still gives +the later branch different states for its alternatives. Multiple selectors sharing +one stream incur only one snapshot. Custom branchers participate through +`Space::choice()`, `Space::commit()`/`trycommit()`, and the base `Choice::archive()`; +their choice payload still contains their usual position/value data. Choices own +their snapshots and cannot be copied by C++ copy construction; use archiving when +an independent choice representation is needed. + +## Restarts, portfolios, and reproducibility limits + +Ordinary space cloning preserves stream states exactly. Generic meta-engines do +not add implicit RNG splits when creating clones. Models can explicitly call +`random_split(index)` in their `slave()` callback to derive all bound streams +from a logical restart or asset index. The callback must follow a fixed policy, +independent of which worker happens to execute it. + +FlatZinc uses a fixed three-step policy: split by 0 for restart or 1 for portfolio, +then by the high and low 32-bit words of the logical index. The Photo example +uses the high and low restart-index words before relaxation. The common relaxation +helper binds its input stream to its destination space, avoiding shared draws +between sibling spaces. + +The guarantee is the RNG state for a recorded path, not identical scheduling or +solution order in parallel search. Adaptive heuristics, restart constraints, and +weakly monotonic propagation can still change the search tree. Reproducing a +whole run also requires the same model, relevant options, and compatible Gecode +code and random algorithm. Standard-library distributions are outside Gecode's +bounded-sequence contract. + +## Test failure replay + +The test runner records complete state immediately before each iteration. On a +failure or exception it prints arguments using `-state`, `-iter 1`, and +`-test-exact`. Run the same test executable with those arguments. This restores +the iteration directly, bypassing suite seed derivation. A state replay requires +one thread and rejects an accompanying `-seed` or an incompatible state format. + +## Measurements + +Build the benchmark against the candidate using the same release compiler flags +as Gecode (adjust library search paths for your platform): + +```sh +clang++ -O3 -DNDEBUG -std=c++17 -DRANDOM_NEW -Ibuild/random -I. \ + tools/random-benchmark.cpp -Lbuild/random -lgecodeint -lgecodesearch \ + -lgecodekernel -lgecodesupport -Wl,-rpath,build/random \ + -o build/random/random-benchmark +python3 tools/random-benchmark.py build/random/random-benchmark \ + --baseline /path/to/baseline/random-benchmark --repeat 5 --output results.json +``` + +For the baseline, compile the same benchmark source against baseline headers and +libraries without `-DRANDOM_NEW`. The script alternates execution order, discards +one warmup, and reports medians while retaining individual measurements in JSON. +Use a new output filename for another run. Avoid concurrent compilation or other +heavy work while collecting timings. + +The controlled search enumerates a complete binary tree of 32767 nodes and 16384 +solutions for every engine and seed, so timings compare overhead without changes +in tree size. The queens case also reports node count: changed random choices can +change its search tree, and wall time alone is not an overhead comparison. Raw +baseline draws emit 32-bit words while the new engines emit 64-bit words. Raw +throughput can also benefit from compiler optimizations that do not apply inside +branchers; the search measurements matter more for the default decision. + +On arm64 macOS 26.6.2 with Apple Clang 21, baseline 6b7de57b04, and seed 42, +five measured repetitions after one warmup gave these medians: + +| Measurement | Baseline | SplitMix with recorded streams | +| --- | ---: | ---: | +| Bounded draw through Rnd | 14.85 ns | 2.96 ns | +| Clone a random space | 178.70 ns | 345.95 ns | +| Binary-tree node, frequent cloning | 153.07 ns | 254.32 ns | +| Binary-tree node, recomputation | 226.76 ns | 368.53 ns | +| Enumerate 10-queens | 20.04 ms | 20.96 ms | + +The controlled random tree is about 1.6x slower: local copying and recorded state +have a cost that faster drawing does not eliminate. Nonrandom tree overhead was +1–3%. Seeds 1 and 1337 gave similar results; queens time increased about 4–5%, +with node counts within 0.1% of baseline. These measurements do not establish a +general speedup or statistical confidence beyond this machine and these cases. + +A binary SplitMix split followed by a draw measured about 15.7 ns; xorshift's +native jump followed by a draw measured about 237 ns. This favors SplitMix as the +default while retaining xorshift for its 8-byte state. The default integer choice +occupies 32 bytes plus a 24-byte snapshot allocation, compared with 24 bytes on +baseline. Archives occupy 32 rather than 12 bytes. No snapshot is allocated for +a nonrandom choice, although its optional pointer costs 8 bytes. Allocator +overhead is additional to these logical sizes. diff --git a/examples/job-shop.cpp b/examples/job-shop.cpp index 356fac4cfd..e9eb7694f1 100755 --- a/examples/job-shop.cpp +++ b/examples/job-shop.cpp @@ -465,7 +465,7 @@ class JobShopSolve : public JobShopBase { JobShopSolve(const JobShopOptions& o) : JobShopBase(o), sorder(*this, spec.machines()*spec.jobs()*(spec.jobs()-1)/2, 0, 1), - rnd(o.seed()) { + rnd(*this,o.seed()) { if (opt.propagation() == PROP_UNARY) nooverload(); @@ -565,7 +565,7 @@ class JobShopSolve : public JobShopBase { JobShopSolve(JobShopSolve& s) : JobShopBase(s), sorder(s.sorder), fst(s.fst), snd(s.snd), iafc(s.iafc), iaction(s.iaction), baction(s.baction), - ichb(s.ichb), bchb(s.bchb), rnd(s.rnd) {} + ichb(s.ichb), bchb(s.bchb), rnd(*this,s.rnd) {} /// Copy during cloning virtual Space* copy(void) { @@ -828,4 +828,3 @@ main(int argc, char* argv[]) { #include "examples/job-shop-instances.hpp" // STATISTICS: example-any - diff --git a/examples/photo.cpp b/examples/photo.cpp index ed264158c5..7d92baa9be 100644 --- a/examples/photo.cpp +++ b/examples/photo.cpp @@ -99,7 +99,7 @@ class Photo : public IntMinimizeScript { spec(opt.size()), pos(*this,spec.people(), 0, spec.people()-1), violations(*this,0,spec.preferences()), - rnd(opt.seed()), p(opt.relax()) + rnd(*this,opt.seed()), p(opt.relax()) { // Map preferences to violation BoolVarArgs viol(spec.preferences()); @@ -132,6 +132,8 @@ class Photo : public IntMinimizeScript { bool slave(const MetaInfo& mi) { if ((mi.type() == MetaInfo::RESTART) && (mi.restart() > 0) && (p > 0.0)) { + random_split(static_cast(uint64_t(mi.restart())>>32)); + random_split(static_cast(mi.restart())); const Photo& l = static_cast(*mi.last()); relax(*this, pos, l.pos, rnd, p); return false; @@ -141,7 +143,7 @@ class Photo : public IntMinimizeScript { } /// Constructor for cloning \a s Photo(Photo& s) : - IntMinimizeScript(s), spec(s.spec), rnd(s.rnd), p(s.p) { + IntMinimizeScript(s), spec(s.spec), rnd(*this,s.rnd), p(s.p) { pos.update(*this, s.pos); violations.update(*this, s.violations); } diff --git a/gecode/flatzinc/flatzinc.cpp b/gecode/flatzinc/flatzinc.cpp index e35bd3638c..7c13b1489f 100644 --- a/gecode/flatzinc/flatzinc.cpp +++ b/gecode/flatzinc/flatzinc.cpp @@ -780,7 +780,7 @@ namespace Gecode { namespace FlatZinc { FlatZincSpace::FlatZincSpace(FlatZincSpace& f) : Space(f), - _initData(nullptr), _random(f._random), + _initData(nullptr), _random(*this,f._random), _solveAnnotations(nullptr), restart_data(f.restart_data), iv_boolalias(nullptr), @@ -864,7 +864,7 @@ namespace Gecode { namespace FlatZinc { : _initData(new FlatZincSpaceInitData), intVarCount(-1), boolVarCount(-1), floatVarCount(-1), setVarCount(-1), _optVar(-1), _optVarIsInt(true), _lns(0), _lnsInitialSolution(0), - _random(random), + _random(*this,random), _solveAnnotations(nullptr), needAuxVars(true) { branchInfo.init(); } @@ -2078,6 +2078,12 @@ namespace Gecode { namespace FlatZinc { bool FlatZincSpace::slave(const MetaInfo& mi) { + // Meta-engine clones start from the master's state. Derive their streams + // from logical restart/asset indices, never from worker scheduling. + uint64_t index = mi.type()==MetaInfo::RESTART ? mi.restart() : mi.asset(); + random_split(mi.type()==MetaInfo::RESTART ? 0 : 1); + random_split(static_cast(index>>32)); + random_split(static_cast(index)); if (mi.type() == MetaInfo::RESTART) { if (restart_data.initialized() && restart_data().mark_complete) { // Fail the space diff --git a/gecode/kernel/core.hpp b/gecode/kernel/core.hpp index 1a67603421..aefcfb871f 100755 --- a/gecode/kernel/core.hpp +++ b/gecode/kernel/core.hpp @@ -2047,6 +2047,10 @@ namespace Gecode { public: /// Bind a random handle to this space, mapping local handles during cloning GECODE_KERNEL_EXPORT Rnd random(const Rnd& source); + /// Access an already bound stream, for callbacks receiving a const space + GECODE_KERNEL_EXPORT Rnd random(const Rnd& source) const; + /// Explicitly split all bound streams, for restart/portfolio initialization + GECODE_KERNEL_EXPORT void random_split(uint32_t alternative); /** * \brief Default constructor * \ingroup TaskModelScript diff --git a/gecode/kernel/data/rnd.cpp b/gecode/kernel/data/rnd.cpp index 3be7cc82b2..ef26d92a92 100644 --- a/gecode/kernel/data/rnd.cpp +++ b/gecode/kernel/data/rnd.cpp @@ -38,6 +38,8 @@ namespace Gecode { Rnd::Rnd(Space& home, const Rnd& source) : SharedHandle(home.random(source)) {} + Rnd::Rnd(const Space& home, const Rnd& source) + : SharedHandle(home.random(source)) {} void Rnd::seed(uint64_t value) { if (!object()) @@ -67,16 +69,26 @@ namespace Gecode { Rnd Space::random(const Rnd& source) { if (!source) throw UninitializedRnd("Space::random"); - // During actor/model copying, map source-local handles by their position. - if (is_partial_clone() && pc.c.source && pc.c.source->randoms) { - size_t i = pc.c.source->randoms->find(source); - if (i < pc.c.source->randoms->size()) - return randoms->at(i); - } if (!randoms) randoms = new RandomContext; return randoms->bind(source); } + + Rnd Space::random(const Rnd& source) const { + if (randoms) { + size_t i = randoms->find(source); + if (i < randoms->size()) + return randoms->at(i); + } + throw UninitializedRnd("Space::random: stream is not bound"); + } + + void Space::random_split(uint32_t alternative) { + if (randoms) { + std::unique_ptr snapshot(randoms->snapshot()); + randoms->commit(snapshot.get(),alternative); + } + } } // STATISTICS: kernel-other diff --git a/gecode/kernel/data/rnd.hpp b/gecode/kernel/data/rnd.hpp index f8f3b5c71b..1b17d11aa3 100755 --- a/gecode/kernel/data/rnd.hpp +++ b/gecode/kernel/data/rnd.hpp @@ -51,8 +51,18 @@ namespace Gecode { class Rnd : public SharedHandle { friend class RandomContext; private: + class Origin : public SharedHandle { + public: + Origin(void) = default; + explicit Origin(Object* value) : SharedHandle(value) {} + const Object* get(void) const { return object(); } + }; class IMP : public SharedHandle::Object { public: + Origin origin; + const Object* identity(void) const { + return origin ? origin.get() : this; + } virtual IMP* copy(void) const = 0; virtual IMP* split(uint32_t a) const = 0; virtual void seed(uint64_t value) = 0; @@ -99,6 +109,11 @@ namespace Gecode { return *static_cast(object()); } Rnd(IMP* value, bool) : SharedHandle(value) {} + Rnd local_copy(void) const { + Rnd result(imp().copy(),true); + result.imp().origin = imp().origin ? imp().origin : Origin(&imp()); + return result; + } public: /// Uninitialized handle Rnd(void) = default; @@ -115,6 +130,8 @@ namespace Gecode { : SharedHandle(new Implementation(r)) {} /// Bind a source stream to a space, or update its handle during cloning GECODE_KERNEL_EXPORT Rnd(Space& home, const Rnd& source); + /// Access an already bound stream through a const space + GECODE_KERNEL_EXPORT Rnd(const Space& home, const Rnd& source); /// Create and bind a default stream to a space Rnd(Space& home, uint64_t seed) : Rnd(home,Rnd(seed)) {} /// Make an independent exact copy; does not split or advance the source @@ -143,47 +160,42 @@ namespace Gecode { } }; - /// Internal space-local stream storage. Entries keep initialization handles alive. + /// Internal space-local stream storage. Local handles retain their stream origin. class RandomContext { - struct Entry { - Rnd source; - Rnd local; - }; - std::vector streams; + std::vector streams; public: RandomContext(void) = default; RandomContext(const RandomContext& other) { streams.reserve(other.streams.size()); for (const auto& entry : other.streams) - streams.push_back({entry.source,entry.local.copy()}); + streams.push_back(entry.local_copy()); } size_t find(const Rnd& source) const { for (size_t i=0; i(n+1); data[0]=n; size_t pos=1; for (const auto& entry : streams) { - entry.local.imp().save(data.get()+pos); - pos += entry.local.words(); + entry.imp().save(data.get()+pos); + pos += entry.words(); } return data.release(); } @@ -191,13 +203,13 @@ namespace Gecode { void commit(const uint64_t* data, uint32_t a) { size_t n=0; for (const auto& entry : streams) - n += entry.local.words(); + n += entry.words(); if (data[0] != n) throw std::invalid_argument("Random choice does not match space streams"); size_t pos=1; for (auto& entry : streams) { - entry.local.imp().commit(data+pos,a); - pos += entry.local.words(); + entry.imp().commit(data+pos,a); + pos += entry.words(); } } }; diff --git a/gecode/search/relax.hh b/gecode/search/relax.hh index 5e8b1aee60..dcd5bb9f36 100755 --- a/gecode/search/relax.hh +++ b/gecode/search/relax.hh @@ -52,6 +52,7 @@ namespace Gecode { namespace Search { double p, Post& post) { if (home.failed()) return; + r = Rnd(static_cast(home),r); Region reg; // Which variables to assign Support::BitSet ax(reg, static_cast(x.size())); diff --git a/gecode/support/random.hpp b/gecode/support/random.hpp index 1d087a4ae3..65129ad23f 100755 --- a/gecode/support/random.hpp +++ b/gecode/support/random.hpp @@ -232,9 +232,17 @@ namespace Gecode { namespace Support { z = (z ^ (z >> 33)) * UINT64_C(0xff51afd7ed558ccd); z = (z ^ (z >> 33)) * UINT64_C(0xc4ceb9fe1a85ec53); z = (z ^ (z >> 33)) | 1; - unsigned int n = 0; - for (uint64_t bits = z ^ (z >> 1); bits; bits &= bits-1) - ++n; + uint64_t bits = z ^ (z >> 1); +#ifdef GECODE_HAS_BUILTIN_POPCOUNTLL + unsigned int n = __builtin_popcountll(bits); +#else + bits -= (bits >> 1) & UINT64_C(0x5555555555555555); + bits = (bits & UINT64_C(0x3333333333333333)) + + ((bits >> 2) & UINT64_C(0x3333333333333333)); + bits = (bits + (bits >> 4)) & UINT64_C(0x0f0f0f0f0f0f0f0f); + unsigned int n = static_cast + ((bits * UINT64_C(0x0101010101010101)) >> 56); +#endif return (n < 24) ? z ^ UINT64_C(0xaaaaaaaaaaaaaaaa) : z; } public: @@ -260,10 +268,12 @@ namespace Gecode { namespace Support { } }; - /** \brief One-word xorshift64* engine for standalone use + /** \brief One-word xorshift64* engine with indexed jump splitting * * Uses shifts 12, 25, 27 and Vigna's multiplier. Zero seeds map to one; - * restoring zero state is an error. This engine does not provide splitting. + * restoring zero state is an error. Alternative a jumps (a+1)*2^32 steps + * along the native recurrence. Sibling states are distinct since 2^32 is + * coprime to the period 2^64-1. Jump matrices are shared, not per-stream state. * \ingroup FuncSupport */ class Xorshift64Star { @@ -271,6 +281,24 @@ namespace Gecode { namespace Support { using State = std::array; private: uint64_t s; + using Matrix = std::array; + static uint64_t transition(uint64_t x) { + x ^= x >> 12; + x ^= x << 25; + return x ^ (x >> 27); + } + static uint64_t apply(const Matrix& m, uint64_t x) { + uint64_t result=0; + for (unsigned int i=0; x; ++i,x>>=1) + if (x & 1) result ^= m[i]; + return result; + } + static Matrix square(const Matrix& m) { + Matrix result; + for (unsigned int i=0; i<64; ++i) + result[i]=apply(m,m[i]); + return result; + } public: explicit Xorshift64Star(uint64_t value=1) { seed(value); } static const char* name(void) { return "xorshift64star-v1"; } @@ -284,11 +312,32 @@ namespace Gecode { namespace Support { s = value[0]; } uint64_t next(void) { - s ^= s >> 12; - s ^= s << 25; - s ^= s >> 27; + s = transition(s); return s * UINT64_C(2685821657736338717); } + Xorshift64Star split(uint32_t alternative) const { + // Binary powers of the linear transition, starting at T^(2^32). + static const std::array powers = [] { + Matrix m; + for (unsigned int i=0; i<64; ++i) + m[i]=transition(uint64_t(1)< p; + p[0]=m; + for (unsigned int i=1; i<32; ++i) p[i]=square(p[i-1]); + return p; + }(); + Xorshift64Star child=*this; + if (alternative==UINT32_MAX) { + // 2^64 steps equal one step modulo the period 2^64-1. + child.s=transition(s); + } else { + uint32_t steps=alternative+1; + for (unsigned int i=0; steps; ++i,steps>>=1) + if (steps & 1) child.s=apply(powers[i],child.s); + } + return child; + } }; /** \brief Value-type generator with reproducible bounded draws and state diff --git a/plans/random.md b/plans/random.md index 752e19979b..4b8a25bbcb 100644 --- a/plans/random.md +++ b/plans/random.md @@ -1,7 +1,7 @@ # Plan: Compact, splittable random generators for Gecode 7 > Source: the feature/random design discussion, 2026-09-10. -> Status: Phases 1 and 2 complete; Phase 3 next. +> Status: Phases 1–3 complete; Phase 4 next. > Workflow: review, update this plan, and commit after each phase. ## Goal @@ -315,22 +315,76 @@ callbacks that use randomness outside ordinary choice selection. ### Acceptance criteria -- [ ] Focused integration cases cover binary, multiway, and one-alternative +- [x] Focused integration cases cover binary, multiway, and one-alternative branching, plus handover to later branchers and custom-engine use. -- [ ] Alternative identity follows the public choice index, including when a +- [x] Alternative identity follows the public choice index, including when a brancher reverses the mapping from alternative index to selected value. -- [ ] Clone, archive, disposal, failure, traced commit, and conditional commit +- [x] Clone, archive, disposal, failure, traced commit, and conditional commit paths preserve the contract without shared mutable state between spaces. -- [ ] Sequential and parallel replay of the same recorded path agree on random +- [x] Sequential and parallel replay of the same recorded path agree on random state; checks do not require identical parallel solution order. -- [ ] Restart and portfolio initialization policies are documented and do not +- [x] Restart and portfolio initialization policies are documented and do not accidentally introduce worker-scheduling-dependent shared streams. -- [ ] Existing relevant branching and search correctness tests pass. Add tests +- [x] Existing relevant branching and search correctness tests pass. Add tests for the new semantic guarantees rather than duplicating each API wrapper. -- [ ] Random generation, bounded draws, splitting, clone cost, and retained-path +- [x] Random generation, bounded draws, splitting, clone cost, and retained-path memory are measured in representative Gecode workloads. Use controlled paths to separate overhead from changes in the randomized search tree. +### Phase 3 review + +Added const-space stream lookup for callbacks and retained stream identity across +ancestor handles. Review showed that mapping only the immediate source space +was insufficient for a callback capturing an already bound ancestor handle. +The collection now holds one handle per stream; each local implementation keeps +its initialization origin alive. Mutable states remain independent across spaces. +The net per-stream collection/implementation storage is unchanged from Phase 2; +the default standalone implementation gains an 8-byte origin handle. + +FlatZinc binds its restart/relaxation handle to the space and derives meta-engine +streams from fixed restart/portfolio indices. The shared relaxation helper and +the Photo and JobShop examples now use local handles. Generic cloning still does +not split; models have an explicit `random_split()` operation for their own +meta-engine policy. Callback, registration, and replay boundaries are documented +in `docs/random.md`. + +Xorshift64* is now also a search-capable alternative: indexed splitting jumps +`(a+1)*2^32` native recurrence steps using shared transition matrices. This keeps +8-byte engine state, costs a shared 16 KiB table, and preserves the original +generator rather than inventing a new seeding construction. Composition and +period-wrap checks supplement its raw vector and full-state replay checks. +Its sibling-state distinction follows from coprimality with the native period. + +Validation passes for three engines (SplitMix, xorshift64*, and an external +three-word engine), shared/separate streams, binary/multiway choices, dynamic +posting through a one-alternative custom brancher, const callbacks, and sequential +versus two-worker search. Complete solution/state sets agree in the parallel +fixture without assuming solution order. Traced and conditional commits, failed +spaces, and illegal alternatives are checked explicitly. A dedicated fault test +counts live engine instances across allocation and brancher-copy failures and +checks that source states survive. Existing Boolean, set, float, FlatZinc restart, +and the full `check` selections also pass. + +Release measurements use Apple Clang 21, arm64 macOS 26.6.2, baseline commit +6b7de57b04, one warmup, five repetitions for seed 42, and three repetitions each +for seeds 1 and 1337. Raw records are in `build/random/phase3-benchmark*.json`; +the reusable harness and summary are documented in `docs/random.md`. +Representative seed-42 medians: bounded Rnd draws 14.85 -> 2.96 ns; random-space +clones 178.70 -> 345.95 ns; controlled random binary-tree nodes 153.07 -> 254.32 ns +with frequent cloning and 226.76 -> 368.53 ns with recomputation. Nonrandom tree +overhead was 1–3%. Queens wall time rose about 4–5% across the three seeds, with +similar but not identical node counts. These are local measurements, not a +cross-platform performance guarantee. + +The cost of independent streams and recorded state is real: the smallest random +tree is about 1.6x slower, despite much faster individual draws. We retain the +simple optional packed snapshot representation rather than add a second compact +choice hierarchy to hide that cost. SplitMix is the preferred default: binary +split-plus-draw measured about 15.7 ns, versus 237 ns for xorshift's jump-plus-draw. +The latter remains the 8-byte-state alternative. The popcount implementation was +improved after measurement without changing its bit sequence. Phase 4 will make +the executable's default configurable and verify both configurations. + ## Phase 4: Configured command lines and Gecode 7 migration **User requirements:** 1, 2, 5, 6. diff --git a/test/fault.cpp b/test/fault.cpp index d0cd8ce1f1..35598826f1 100644 --- a/test/fault.cpp +++ b/test/fault.cpp @@ -1174,6 +1174,72 @@ namespace Test { namespace Fault { } }; + // Count engine instances so failed clones cannot hide leaked stream handles. + class LiveRandom : public Support::SplitMix { + public: + static int live; + explicit LiveRandom(uint64_t seed=1) : SplitMix(seed) { ++live; } + LiveRandom(const LiveRandom& r) : SplitMix(r) { ++live; } + explicit LiveRandom(const SplitMix& r) : SplitMix(r) { ++live; } + ~LiveRandom() { --live; } + LiveRandom split(uint32_t a) const { return LiveRandom(SplitMix::split(a)); } + }; + int LiveRandom::live=0; + + class RandomSpace : public Space { + public: + IntVarArray x; + Rnd r; + RandomSpace() : x(*this,3,0,2), + r(*this,Rnd(Support::Random(7))) { + branch(*this,x,INT_VAR_RND(r),INT_VAL_RND(r)); + ThrowingBrancher::post(*this); + } + RandomSpace(RandomSpace& s) : Space(s), r(*this,s.r) { + x.update(*this,s.x); + } + Space* copy() override { return new RandomSpace(*this); } + }; + + class RandomCloneFailures : public Base { + public: + RandomCloneFailures() : Base("Fault::Random::CloneFailures") {} + bool run() override { + FaultScope scope; + { + RandomSpace root; + if (root.status()!=SS_BRANCH) + return false; + const auto state=root.r.state(); + const int live=LiveRandom::live; + bool succeeded=false; + for (unsigned int n=0; n<128 && !succeeded; ++n) { + Support::FailPoint::fail_after(Phase::Heap,n); + try { + std::unique_ptr copy(root.clone()); + succeeded=true; + } catch (const MemoryExhausted&) {} + Support::FailPoint::reset(); + if (LiveRandom::live!=live || root.r.state()!=state) + return false; + } + if (!succeeded) + return false; + // Fail after a random brancher and its stream handles have been copied. + Support::FailPoint::fail_after(Phase::BrancherCopy,0); + try { + std::unique_ptr copy(root.clone()); + return false; + } catch (const MemoryExhausted&) {} + Support::FailPoint::reset(); + if (LiveRandom::live!=live || root.r.state()!=state) + return false; + std::unique_ptr copy(root.clone()); + } + return LiveRandom::live==0; + } + } random_clone_failures; + BranchActionHeapFailures branch_action_heap_failures; BranchChbHeapFailures branch_chb_heap_failures; CloneDisposalArray clone_disposal_array; diff --git a/test/random.cpp b/test/random.cpp index eac16c2f1a..f47c7e3caa 100644 --- a/test/random.cpp +++ b/test/random.cpp @@ -70,7 +70,14 @@ namespace Test { Xorshift64Star xs(1); if (xs.next() != UINT64_C(0x47e4ce4b896cdd1d)) return false; - if (!replay() || !replay()) + if (!replay() || !replay() || + !replay()) + return false; + if (xs.split(0).split(0).state()!=xs.split(1).state()) + return false; + auto one_step=xs; + (void) one_step.next(); + if (xs.split(UINT32_MAX).state()!=one_step.state()) return false; auto parent = r.state(); // Indexing skips pairs of parent words, exactly as sequential splits. @@ -119,7 +126,8 @@ namespace Test { public: Gecode::IntVarArray x; Gecode::Rnd variable, value; - ReplaySpace(const Gecode::Rnd& source, bool separate, bool multi=false) + ReplaySpace(const Gecode::Rnd& source, bool separate, bool multi=false, + bool callback=false) : x(*this,4,0,2), variable(*this,source), value(*this,separate ? source.copy() : source) { using namespace Gecode; @@ -128,7 +136,22 @@ namespace Test { IntVarArgs first(2); first[0]=x[1]; first[1]=x[2]; branch(*this,first,INT_VAR_RND(variable),INT_VAL_RND(value)); - branch(*this,x[3],INT_VAL_RND(value)); + if (callback) { + // A custom one-alternative branch posts a later random brancher. + // The callback resolves the stream through its own space each time. + branch(*this,[source=variable](Space& home) { + auto& self = static_cast(home); + branch(home,self.x[3],INT_VAL([source](const Space& h, IntVar v, int) { + Rnd local(h,source); + unsigned int p=local(v.size()); + IntVarValues values(v); + while (p--) ++values; + return values.val(); + })); + }); + } else { + branch(*this,x[3],INT_VAL_RND(value)); + } } ReplaySpace(ReplaySpace& s) : Space(s), variable(*this,s.variable), value(*this,s.value) { @@ -148,9 +171,10 @@ namespace Test { return true; } - bool choice_replay(const Gecode::Rnd& source, bool separate, bool multi) { + bool choice_replay(const Gecode::Rnd& source, bool separate, bool multi, + bool callback) { using namespace Gecode; - std::unique_ptr root(new ReplaySpace(source,separate,multi)); + std::unique_ptr root(new ReplaySpace(source,separate,multi,callback)); while (root->status()==SS_BRANCH) { std::unique_ptr before(static_cast(root->clone())); const auto source_state = source.state(); @@ -201,12 +225,14 @@ namespace Test { std::vector solutions(const Gecode::Rnd& source, unsigned int distance, bool separate, - bool multi) { + bool multi, bool callback, + unsigned int threads=1) { using namespace Gecode; - ReplaySpace root(source,separate,multi); + ReplaySpace root(source,separate,multi,callback); Search::Options options; options.c_d=distance; options.a_d=distance; + options.threads=threads; DFS search(&root,options); std::vector result; while (std::unique_ptr s{search.next()}) { @@ -223,20 +249,69 @@ namespace Test { bool run() override { Gecode::Rnd engines[] = { Gecode::Rnd(42), + Gecode::Rnd(Gecode::Support::Random(42)), Gecode::Rnd(Gecode::Support::Random(42)) }; for (const auto& engine : engines) for (bool separate : {false,true}) for (bool multi : {false,true}) { - if (!choice_replay(engine,separate,multi)) - return false; - auto cloned = solutions(engine,1,separate,multi); - auto recomputed = solutions(engine,100,separate,multi); - if (cloned.size()!=81 || cloned!=recomputed) - return false; + for (bool callback : {false,true}) { + if (!choice_replay(engine,separate,multi,callback)) + return false; + auto cloned = solutions(engine,1,separate,multi,callback); + auto recomputed = solutions(engine,100,separate,multi,callback); + if (cloned.size()!=81 || cloned!=recomputed) + return false; + auto parallel = solutions(engine,100,separate,multi,callback,2); + std::sort(cloned.begin(),cloned.end()); + std::sort(parallel.begin(),parallel.end()); + if (cloned!=parallel) + return false; + } } return true; } } branch_replay; + + class StateTracer : public Gecode::Tracer { + public: + std::string observed; + void propagate(const Gecode::Space&, const Gecode::PropagateTraceInfo&) override {} + void post(const Gecode::Space&, const Gecode::PostTraceInfo&) override {} + void commit(const Gecode::Space& home, const Gecode::CommitTraceInfo&) override { + observed=static_cast(home).variable.state(); + } + }; + + class CommitBoundary : public Base { + public: + CommitBoundary() : Base("Random::CommitBoundary") {} + bool run() override { + using namespace Gecode; + StateTracer tracer; + ReplaySpace root(Rnd(7),false); + trace(root,TE_COMMIT,tracer); + root.status(); + std::unique_ptr choice(root.choice()); + auto expected=root.variable.split(1).state(); + std::unique_ptr copy(static_cast(root.clone())); + copy->trycommit(*choice,1); + if (copy->variable.state()!=expected || tracer.observed!=expected) + return false; + std::unique_ptr skipped(static_cast(root.clone())); + BrancherGroup::all.kill(*skipped); + auto before=skipped->variable.state(); + skipped->trycommit(*choice,1); + if (skipped->variable.state()!=before) + return false; + skipped->fail(); + skipped->commit(*choice,1); + if (skipped->variable.state()!=before) + return false; + try { root.commit(*choice,choice->alternatives()); return false; } + catch (const SpaceIllegalAlternative&) {} + return root.variable.state()==before; + } + } commit_boundary; } } diff --git a/tools/random-benchmark.cpp b/tools/random-benchmark.cpp new file mode 100644 index 0000000000..aefeefeb38 --- /dev/null +++ b/tools/random-benchmark.cpp @@ -0,0 +1,139 @@ +// Copyright (c) 2026 Mikael Zayenz Lagerkvist. MIT license; see LICENSE. +// Compile against main without RANDOM_NEW, or against feature/random with it. +#include +#include +#include +#include +#include + +using namespace Gecode; +using Clock = std::chrono::steady_clock; + +template +void measure(const char* name, uint64_t operations, F work) { + auto start=Clock::now(); + uint64_t checksum=work(); + double ns=std::chrono::duration(Clock::now()-start).count(); + std::cout << name << '\t' << ns/operations << "\tns/op\t" << checksum << '\n'; +} + +class Model : public Space { +public: + IntVarArray x; + Model(unsigned int n, unsigned int seed, bool queens, bool random) + : x(*this,n,0,queens ? n-1 : 1) { + if (queens) { + distinct(*this,x,IPL_DOM); + IntArgs up(n),down(n); + for (unsigned int i=0; i1 ? std::stoull(argv[1]) : 1000000; + const unsigned int seed=argc>2 ? std::stoul(argv[2]) : 42; + if (!draws) return 1; + std::cout << "size.engine\t" << sizeof(Support::RandomGenerator) << "\tbytes\t0\n" + << "size.space\t" << sizeof(Space) << "\tbytes\t0\n" + << "size.choice\t" << sizeof(PosValChoice) << "\tbytes\t0\n"; + Support::RandomGenerator raw(seed); + measure("raw.default",draws,[&] { + uint64_t sum=0; + for (uint64_t i=0; i xs(seed); + measure("raw.xorshift64star",draws,[&] { + uint64_t sum=0; + for (uint64_t i=0; i choice(root.choice()); + Archive archive; + choice->archive(archive); + std::cout << (random ? "size.random_archive" : "size.plain_archive") + << '\t' << archive.size()*sizeof(unsigned int) << "\tbytes\t0\n"; +#ifdef RANDOM_NEW + std::cout << (random ? "size.random_snapshot" : "size.plain_snapshot") + << '\t' << (archive[1] ? (archive[1]+1)*sizeof(uint64_t) : 0) + << "\tbytes\t0\n"; +#endif + measure(random ? "clone.random" : "clone.plain",10000,[&] { + uint64_t sum=0; + for (int i=0; i<10000; ++i) { + std::unique_ptr copy(root.clone()); + sum += copy->status(); + } + return sum; + }); + for (unsigned int distance : {1U,16U}) { + Search::Options options; + options.c_d=distance; + options.a_d=distance; + const char* name=random ? (distance==1 ? "tree.random.clone" : "tree.random.recompute") + : (distance==1 ? "tree.plain.clone" : "tree.plain.recompute"); + // Complete binary tree: exactly 32767 nodes for every engine/seed. + measure(name,32767,[&] { + DFS search(&root,options); + uint64_t solutions=0; + while (std::unique_ptr s{search.next()}) ++solutions; + if (solutions!=16384 || search.statistics().node!=32767) + throw std::runtime_error("Controlled tree changed"); + return solutions; + }); + } + } + Model queens(10,seed,true,true); + Search::Options options; + uint64_t nodes=0; + measure("queens.random",1,[&] { + DFS search(&queens,options); + uint64_t solutions=0; + while (std::unique_ptr s{search.next()}) ++solutions; + nodes=search.statistics().node; + if (solutions!=724) throw std::runtime_error("Queens solutions changed"); + return solutions; + }); + std::cout << "queens.nodes\t" << nodes << "\tnodes\t0\n"; +} diff --git a/tools/random-benchmark.py b/tools/random-benchmark.py new file mode 100644 index 0000000000..96c9514b2a --- /dev/null +++ b/tools/random-benchmark.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +"""Compare compiled random-benchmark executables with bounded repeated runs. + +Example: python3 tools/random-benchmark.py build/random/random-benchmark \ + --baseline /tmp/baseline/random-benchmark --repeat 5 --output results.json +Build instructions and the controls are recorded in docs/random.md. +""" +import argparse +import json +import platform +import statistics +import subprocess +from pathlib import Path + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("candidate", type=Path) + parser.add_argument("--baseline", type=Path) + parser.add_argument("--repeat", type=int, default=5) + parser.add_argument("--draws", type=int, default=1_000_000) + parser.add_argument("--seed", type=int, default=42) + parser.add_argument("--output", type=Path) + args = parser.parse_args() + if args.repeat < 1 or args.draws < 1: + parser.error("repeat and draws must be positive") + if not 0 <= args.seed <= 0xffffffff: + parser.error("the baseline comparison requires a 32-bit unsigned seed") + binaries = {"candidate": args.candidate.resolve()} + if args.baseline: + binaries["baseline"] = args.baseline.resolve() + records = {name: [] for name in binaries} + for iteration in range(args.repeat + 1): + # Reverse order on alternating repetitions; first repetition is warmup. + names = list(binaries) + if iteration % 2: + names.reverse() + for name in names: + run = subprocess.run( + [str(binaries[name]), str(args.draws), str(args.seed)], + capture_output=True, text=True, timeout=120, + ) + if run.returncode: + parser.exit(1, f"{binaries[name]} failed:\n{run.stdout}\n{run.stderr}") + rows = {} + for line in run.stdout.splitlines(): + case, value, unit, checksum = line.split("\t") + rows[case] = {"value": float(value), "unit": unit, "checksum": checksum} + if iteration: + records[name].append(rows) + medians = { + name: {case: statistics.median(run[case]["value"] for run in runs) + for case in runs[0]} + for name, runs in records.items() + } + for case, value in medians["candidate"].items(): + unit = records["candidate"][0][case]["unit"] + previous = medians.get("baseline", {}).get(case) + comparison = f" (baseline {previous:.2f}, ratio {value / previous:.3f})" if previous else "" + print(f"{case}: {value:.2f} {unit}{comparison}") + if args.output: + # Preserve earlier runs; choose a new output name to collect another run. + with args.output.open("x") as out: + json.dump({"platform": platform.platform(), "machine": platform.machine(), + "draws": args.draws, "seed": args.seed, + "binaries": {k: str(v) for k, v in binaries.items()}, + "runs": records, "medians": medians}, out, indent=2) + out.write("\n") + + +if __name__ == "__main__": + main() From 3665a1bed2171799f1952d0492609d970aa12e19 Mon Sep 17 00:00:00 2001 From: Mikael Zayenz Lagerkvist Date: Thu, 10 Sep 2026 10:56:50 +0200 Subject: [PATCH 4/7] random: configure engines and complete seed/state CLI migration --- CMakeLists.txt | 19 ++++++ Makefile.in | 3 +- changelog.in | 22 ++++++ configure | 25 +++++++ configure.ac | 10 +++ docs/random.md | 74 +++++++++++++++++++++ examples/CMakeLists.txt | 1 + examples/job-shop.cpp | 4 +- examples/photo.cpp | 2 +- examples/random-engine.cpp | 75 +++++++++++++++++++++ gecode/driver.hh | 26 +++++++- gecode/driver/options.cpp | 70 ++++++++++++++++++++ gecode/driver/options.hpp | 9 ++- gecode/flatzinc.hh | 7 +- gecode/flatzinc/flatzinc.cpp | 3 +- gecode/flatzinc/restart-random.hpp | 67 ------------------- gecode/support/config.hpp.in | 3 + gecode/support/hw-rnd.cpp | 10 ++- gecode/support/random.hpp | 6 +- plans/random.md | 81 ++++++++++++++++++++--- test/flatzinc.cpp | 3 +- test/flatzinc/on_restart_last_val_int.cpp | 67 +------------------ test/random-options.cmake | 65 ++++++++++++++++++ test/random-options.cpp | 32 +++++++++ test/random.cpp | 4 +- tools/flatzinc/fzn-gecode.cpp | 2 +- 26 files changed, 527 insertions(+), 163 deletions(-) create mode 100644 examples/random-engine.cpp delete mode 100644 gecode/flatzinc/restart-random.hpp create mode 100644 test/random-options.cmake create mode 100644 test/random-options.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 4a46ec25ca..a72f6770ce 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -98,6 +98,13 @@ option(GECODE_BUILD_SHARED "Build shared libraries" ${GECODE_BUILD_SHARED_DEFAUL option(GECODE_BUILD_STATIC "Build static libraries" ${GECODE_BUILD_STATIC_DEFAULT}) option(GECODE_ENABLE_THREAD "Enable thread support" ON) +set(GECODE_RANDOM_ENGINE "splitmix" CACHE STRING "Default random engine (splitmix or xorshift64star)") +set_property(CACHE GECODE_RANDOM_ENGINE PROPERTY STRINGS splitmix xorshift64star) +if(GECODE_RANDOM_ENGINE STREQUAL "xorshift64star") + set(GECODE_RANDOM_XORSHIFT64STAR 1) +elseif(NOT GECODE_RANDOM_ENGINE STREQUAL "splitmix") + message(FATAL_ERROR "GECODE_RANDOM_ENGINE must be splitmix or xorshift64star") +endif() set(GECODE_ENABLE_QT "AUTO" CACHE STRING "Enable Qt support (AUTO, ON, or OFF)") set_property(CACHE GECODE_ENABLE_QT PROPERTY STRINGS AUTO ON OFF) set(GECODE_ENABLE_GIST_DEFAULT AUTO) @@ -1406,6 +1413,18 @@ if(BUILD_TESTING) add_test(NAME random-state-replay COMMAND ${CMAKE_COMMAND} -DREPLAY=$ -P ${CMAKE_CURRENT_SOURCE_DIR}/test/random-replay.cmake) + if(GECODE_ENABLE_DRIVER) + add_executable(gecode-random-options EXCLUDE_FROM_ALL test/random-options.cpp) + target_link_libraries(gecode-random-options PRIVATE gecodedriver ${GECODE_TEST_LINK_LIBS}) + if(GECODE_ENABLE_FLATZINC) + target_compile_definitions(gecode-random-options PRIVATE TEST_RANDOM_FLATZINC) + endif() + add_dependencies(gecode-test gecode-random-options) + add_test(NAME random-options + COMMAND ${CMAKE_COMMAND} -DOPTIONS=$ + -DFLATZINC=${GECODE_ENABLE_FLATZINC} + -P ${CMAKE_CURRENT_SOURCE_DIR}/test/random-options.cmake) + endif() if(GECODE_ENABLE_FAULT_INJECTION) add_executable(gecode-fault-test EXCLUDE_FROM_ALL ${GECODE_FAULT_TEST_SOURCES}) diff --git a/Makefile.in b/Makefile.in index ee83dfea42..859732e710 100755 --- a/Makefile.in +++ b/Makefile.in @@ -896,7 +896,7 @@ INTEXAMPLEHDR0 = \ INTEXAMPLESRC0 = \ alpha bacp bibd donald efpa eq20 golomb-ruler \ graph-color grocery ind-set magic-sequence magic-square \ - money ortho-latin partition photo queens sudoku sudoku-advanced kakuro \ + money ortho-latin partition photo queens random-engine sudoku sudoku-advanced kakuro \ nonogram pentominoes crowded-chess black-hole \ minesweeper domino steel-mill sports-league \ all-interval langford-number warehouses radiotherapy \ @@ -1345,6 +1345,7 @@ test: mkcompiledirs $(BLACKBOXFIXTURES) @$(MAKE) $(VARIMP) $(TESTEXE) CHECKTESTS = Branch::Int::Dense::3 \ + Random::Contract Random::BranchReplay Random::CommitBoundary \ FlatZinc::magic_square \ Int::Arithmetic::Abs \ Int::Arithmetic::ArgMax \ diff --git a/changelog.in b/changelog.in index 5356b24303..a53329d4d3 100755 --- a/changelog.in +++ b/changelog.in @@ -67,6 +67,28 @@ # optional section in the html page. # +[RELEASE] +Version: 7.0.0 +Date: unreleased +[DESCRIPTION] +Development changes for Gecode 7; source, binary, and seeded-sequence +compatibility with Gecode 6 is not preserved. + +[ENTRY] +Module: kernel +What: new +Rank: major +[DESCRIPTION] +Random branching uses space-local streams and records complete splitting state +in choices. Every alternative derives a distinct successor state that is stable +when the same recorded path is recomputed. Splittable SplitMix is the default; +xorshift64* is a configurable smaller-state alternative. Users can supply engines +through the existing branching APIs. Standalone Rnd copies share a stream; +copy() makes an independent exact copy, while space cloning copies local state. +Drivers accept checked 64-bit seeds and complete state for replay. Test failures +report the exact iteration state. Seeded sequences and choice archives change; +no legacy sequence mode is provided. See docs/random.md for migration and costs. + [RELEASE] Version: 6.5.0 Date: unreleased diff --git a/configure b/configure index 0a59cb0548..7797bc119a 100755 --- a/configure +++ b/configure @@ -821,6 +821,7 @@ SHELL' ac_subst_files='' ac_user_opts=' enable_option_checking +with_random_engine with_host_os with_compiler_vendor enable_resource @@ -1546,6 +1547,9 @@ Optional Features: Optional Packages: --with-PACKAGE[=ARG] use PACKAGE [ARG=yes] --without-PACKAGE do not use PACKAGE (same as --with-PACKAGE=no) + --with-random-engine=ENGINE + default random engine: splitmix (default) or + xorshift64star --with-host-os Override operating system test. Valid values are Linux, Darwin, FreeBSD, NetBSD, and Windows. --with-compiler-vendor Override compiler test. Valid values are gnu, intel, @@ -3280,6 +3284,27 @@ ac_config_headers="$ac_config_headers gecode/support/config.hpp" + +# Check whether --with-random-engine was given. +if test ${with_random_engine+y} +then : + withval=$with_random_engine; +else case e in #( + e) with_random_engine=splitmix ;; +esac +fi + +case $with_random_engine in #( + splitmix) : + ;; #( + xorshift64star) : + +printf "%s\n" "#define GECODE_RANDOM_XORSHIFT64STAR 1" >>confdefs.h + ;; #( + *) : + as_fn_error $? "random engine must be splitmix or xorshift64star" "$LINENO" 5 ;; +esac + ac_gecode_soversion=51 GECODE_SOVERSION=${ac_gecode_soversion} diff --git a/configure.ac b/configure.ac index b2c3ef0d58..4493755a75 100644 --- a/configure.ac +++ b/configure.ac @@ -41,6 +41,16 @@ AC_INIT([GECODE], GECODE_M4_VERSION, [users@gecode.dev]) AC_CONFIG_HEADERS([gecode/support/config.hpp]) AC_CONFIG_SRCDIR(gecode/kernel.hh) +AC_ARG_WITH([random-engine], + [AS_HELP_STRING([--with-random-engine=ENGINE], + [default random engine: splitmix (default) or xorshift64star])], + [], [with_random_engine=splitmix]) +AS_CASE([$with_random_engine], + [splitmix], [], + [xorshift64star], [AC_DEFINE([GECODE_RANDOM_XORSHIFT64STAR], [1], + [Use xorshift64* instead of splittable SplitMix as the default random engine.])], + [AC_MSG_ERROR([random engine must be splitmix or xorshift64star])]) + ac_gecode_soversion=GECODE_M4_SOVERSION AC_SUBST(GECODE_SOVERSION, ${ac_gecode_soversion}) diff --git a/docs/random.md b/docs/random.md index 0f69567c07..a7db1d3404 100644 --- a/docs/random.md +++ b/docs/random.md @@ -71,6 +71,23 @@ not a substitute for a complete snapshot. ## Handles, binding, and callbacks +The runnable `examples/random-engine.cpp` defines an engine outside Gecode and +uses it with built-in random variable and value selection. Its three-word state +contains SplitMix's two words and a raw-draw counter. The counter follows the +path, including across splits, and wraps modulo 2^64. This demonstrates extending +state without introducing another generator algorithm or changing Gecode's +engine-selection code. Build and run it with: + +```sh +cmake --build build/random --target random-engine +build/random/bin/random-engine +build/random/bin/random-engine counted-splitmix-v1:000000000000002a:9e3779b97f4a7c15:0000000000000000 +``` + +Both commands enumerate the same 24 permutations and print the same solution +states. The optional argument restores this example's custom engine, independently +of the library's configured default. + `Rnd(seed)` creates a standalone default stream. `Rnd(Support::Random(seed))` creates a user-defined one. Copying a standalone `Rnd` shares its stream; `r.copy()` creates an independent exact copy, and `r.split(a)` creates a child. @@ -146,6 +163,63 @@ failure or exception it prints arguments using `-state`, `-iter 1`, and the iteration directly, bypassing suite seed derivation. A state replay requires one thread and rejects an accompanying `-seed` or an incompatible state format. +## Build configuration and command lines + +The default is splittable SplitMix. Select xorshift64* with CMake +`-DGECODE_RANDOM_ENGINE=xorshift64star` or Autoconf +`--with-random-engine=xorshift64star`. Both accept `splitmix` to select the default +explicitly and reject other names. The installed configuration header carries +the choice to clients; build libraries and clients with matching headers. There +is no runtime engine registry. Both engines remain available as C++ types in +either configuration. + +The example driver accepts `-seed`; FlatZinc accepts `-r`. Values are unsigned +64-bit decimal or `0x` hexadecimal integers, or `time` and `hw`. SplitMix seeds +set its state word directly and use the fixed initial increment +`9e3779b97f4a7c15`. Xorshift seeds set its state directly, except that zero maps +to one. A 64-bit seed suffices to initialize either engine; it need not enumerate +all possible full states. + +Both drivers accept `-state` with complete state for their configured engine. +Seed and state options are mutually exclusive, in either order. Invalid words, +invalid engine states, and incompatible identifiers are errors. Double-hyphen +spellings are also accepted. For example, with a SplitMix build: + +```sh +build/random/bin/photo -seed 0xffffffffffffffff +build/random/bin/photo -state splitmix-v1:ffffffffffffffff:9e3779b97f4a7c15 +build/random/bin/fzn-gecode -r 42 model.fzn +build/random/bin/fzn-gecode -state splitmix-v1:000000000000002a:9e3779b97f4a7c15 model.fzn +``` + +Time and hardware initialization happen once during option parsing and print +`% Random state: -state ...` to standard error. Reuse those state arguments with +the same model and search options. Help also prints the configured initial state. +For complete-state test replay, use the test runner's reported command instead +of a driver command. + +## Migrating from Gecode 6 + +Seeded sequences, bounded-draw consumption, random search trees, and choice +archives change. There is no legacy sequence mode. `Rnd` numeric construction is +explicit and accepts 64 bits. `seed(value)` initializes. Replace the old +zero-argument `seed()` snapshot accessor with `state()`, which returns complete +state text, and use `state(text)` to restore it. +Use `copy()` for an independent generator and ordinary handle copying for shared +standalone use. Binding and space cloning follow the ownership rules above. + +Models using driver options should replace `Rnd(opt.seed())` with `opt.rnd()`. +Each call creates an independent generator at the configured initial state; +retain and reuse that handle when selectors should share a stream. This honors +both seed and state input. The numeric `opt.seed()` accessor remains available +for numeric initialization, but throws when full state, time, or hardware input +was used. This prevents silently ignoring a requested state. + +FlatZinc's restart sampler now uses the common bounded generator directly; its +old 31-bit chunk workaround is removed. These are Gecode 7 compatibility changes. +The feature branch does not change the library's release version or ABI number; +release preparation must apply those changes before shipping. + ## Measurements Build the benchmark against the candidate using the same release compiler flags diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt index a223ae844a..498de28fad 100755 --- a/examples/CMakeLists.txt +++ b/examples/CMakeLists.txt @@ -50,6 +50,7 @@ set(GECODE_EXAMPLE_SOURCES queen-armies.cpp queens.cpp radiotherapy.cpp + random-engine.cpp sat.cpp schurs-lemma.cpp sports-league.cpp diff --git a/examples/job-shop.cpp b/examples/job-shop.cpp index e9eb7694f1..f4a4c385f5 100755 --- a/examples/job-shop.cpp +++ b/examples/job-shop.cpp @@ -465,7 +465,7 @@ class JobShopSolve : public JobShopBase { JobShopSolve(const JobShopOptions& o) : JobShopBase(o), sorder(*this, spec.machines()*spec.jobs()*(spec.jobs()-1)/2, 0, 1), - rnd(*this,o.seed()) { + rnd(*this,o.rnd()) { if (opt.propagation() == PROP_UNARY) nooverload(); @@ -616,7 +616,7 @@ print(const Search::Statistics& stat, bool restart) { /// Solver void solve(const JobShopOptions& opt) { - Rnd rnd(opt.seed()); + Rnd rnd = opt.rnd(); /* * Invariant: diff --git a/examples/photo.cpp b/examples/photo.cpp index 7d92baa9be..db9631d373 100644 --- a/examples/photo.cpp +++ b/examples/photo.cpp @@ -99,7 +99,7 @@ class Photo : public IntMinimizeScript { spec(opt.size()), pos(*this,spec.people(), 0, spec.people()-1), violations(*this,0,spec.preferences()), - rnd(*this,opt.seed()), p(opt.relax()) + rnd(*this,opt.rnd()), p(opt.relax()) { // Map preferences to violation BoolVarArgs viol(spec.preferences()); diff --git a/examples/random-engine.cpp b/examples/random-engine.cpp new file mode 100644 index 0000000000..1d15d342b6 --- /dev/null +++ b/examples/random-engine.cpp @@ -0,0 +1,75 @@ +/* -*- mode: C++; c-basic-offset: 2; indent-tabs-mode: nil -*- */ +// Copyright (c) 2026 Mikael Zayenz Lagerkvist. MIT license; see LICENSE. + +#include +#include +#include +#include + +/// A user engine that counts raw draws along each path. +/// Generation and indexed splitting retain SplitMix's algorithms and guarantees. +class CountedSplitMix { + Gecode::Support::SplitMix engine; + uint64_t draws = 0; +public: + using State = std::array; + explicit CountedSplitMix(uint64_t seed=1) : engine(seed) {} + static const char* name(void) { return "counted-splitmix-v1"; } + static constexpr uint64_t min(void) { return 0; } + static constexpr uint64_t max(void) { return UINT64_MAX; } + void seed(uint64_t value) { engine.seed(value); draws = 0; } + uint64_t next(void) { ++draws; return engine.next(); } + State state(void) const { + auto s = engine.state(); + return {{s[0],s[1],draws}}; + } + void state(const State& s) { + engine.state({{s[0],s[1]}}); // Validate before changing the counter. + draws = s[2]; + } + CountedSplitMix split(uint32_t alternative) const { + auto child = *this; + child.engine = engine.split(alternative); + return child; + } +}; + +/// Enumerate permutations using one user stream for both random selectors. +class Permutations : public Gecode::Space { + Gecode::IntVarArray x; + Gecode::Rnd random; +public: + explicit Permutations(const Gecode::Rnd& source) + : x(*this,4,0,3), random(*this,source) { + Gecode::distinct(*this,x); + Gecode::branch(*this,x,Gecode::INT_VAR_RND(random), + Gecode::INT_VAL_RND(random)); + } + Permutations(Permutations& s) + : Space(s), random(*this,s.random) { x.update(*this,s.x); } + Gecode::Space* copy(void) override { return new Permutations(*this); } + void print(void) const { + std::cout << x << " " << random.state() << '\n'; + } +}; + +int main(int argc, char* argv[]) { + try { + if (argc > 2) + throw std::invalid_argument("Usage: random-engine [complete-state]"); + Gecode::Support::Random engine(42); + if (argc == 2) + engine.state(std::string(argv[1])); + std::cout << "Initial state: " << engine.state_string() << '\n'; + Gecode::Rnd source(engine); + auto root = std::make_unique(source); + Gecode::DFS search(root.get()); + root.reset(); + while (auto solution = std::unique_ptr(search.next())) + solution->print(); + return 0; + } catch (const std::exception& e) { + std::cerr << e.what() << '\n'; + return 1; + } +} diff --git a/gecode/driver.hh b/gecode/driver.hh index 409968d979..d3694889ca 100755 --- a/gecode/driver.hh +++ b/gecode/driver.hh @@ -143,6 +143,24 @@ namespace Gecode { static void strdel(const char* s); }; + /// Checked seed or complete state for the build-configured random engine. + class GECODE_DRIVER_EXPORT RandomOption : public BaseOption { + uint64_t cur; + std::string initial; + bool seed_given = false; + bool state_given = false; + bool state_only = false; + public: + RandomOption(const char* o, const char* e, uint64_t v); + void value(uint64_t v); + /// Return the seed; throws if initialization used complete state. + uint64_t value(void) const; + /// Construct an independent generator at the configured initial state. + Rnd rnd(void) const; + virtual int parse(int argc, char* argv[]); + virtual void help(void); + }; + /** * \brief String-valued option * @@ -417,7 +435,7 @@ namespace Gecode { Driver::IplOption _ipl; ///< Integer propagation level Driver::StringOption _branching; ///< Branching options Driver::DoubleOption _decay; ///< Decay option - Driver::UnsignedIntOption _seed; ///< Seed option + Driver::RandomOption _seed; ///< Seed or complete random state Driver::DoubleOption _step; ///< Step option //@} @@ -509,9 +527,11 @@ namespace Gecode { double decay(void) const; /// Set default seed value - void seed(unsigned int s); + void seed(uint64_t s); /// Return seed value - unsigned int seed(void) const; + uint64_t seed(void) const; + /// Independent generator initialized from the seed or full-state option + Rnd rnd(void) const; /// Set default step value void step(double s); diff --git a/gecode/driver/options.cpp b/gecode/driver/options.cpp index 5c6c1fbf87..8793832f6a 100755 --- a/gecode/driver/options.cpp +++ b/gecode/driver/options.cpp @@ -108,6 +108,76 @@ namespace Gecode { } + RandomOption::RandomOption(const char* o, const char* e, uint64_t v) + : BaseOption(o,e) { + value(v); + } + void RandomOption::value(uint64_t v) { + cur = v; + initial = Support::RandomGenerator(v).state_string(); + seed_given = state_given = state_only = false; + } + uint64_t RandomOption::value(void) const { + if (state_only) + throw std::logic_error("Random initialization has no seed; use rnd()"); + return cur; + } + Rnd RandomOption::rnd(void) const { + Rnd r; + r.state(initial); + return r; + } + int RandomOption::parse(int argc, char* argv[]) { + bool full = argc >= 2 && + (!strcmp(argv[1],"-state") || !strcmp(argv[1],"--state")); + const char* arg; + if (full) { + if (argc < 3) { + std::cerr << "Missing argument for option -state" << std::endl; + exit(EXIT_FAILURE); + } + arg = argv[2]; + } else { + arg = argument(argc,argv); + if (!arg) + return 0; + } + try { + if ((full && seed_given) || (!full && state_given)) + throw std::invalid_argument("Seed and state options cannot be combined"); + if (full) { + Support::RandomGenerator r; + r.state(std::string(arg)); + initial = r.state_string(); + state_given = state_only = true; + } else { + if (!strcmp(arg,"time") || !strcmp(arg,"hw")) { + Rnd r; + if (!strcmp(arg,"time")) r.time(); else r.hw(); + initial = r.state(); + state_only = true; + std::cerr << "% Random state: -state " << initial << std::endl; + } else { + cur = Support::random_seed(arg); + initial = Support::RandomGenerator(cur).state_string(); + state_only = false; + } + seed_given = true; + } + } catch (const std::exception& e) { + std::cerr << "Invalid random option: " << e.what() << std::endl; + exit(EXIT_FAILURE); + } + return 2; + } + void RandomOption::help(void) { + std::cerr << "\t" << iopt << " (64-bit decimal/hex seed, time, hw)\n" + << "\t\t" << exp << "\n" + << "\t-state (complete " << Support::RandomGenerator::name() + << " state; mutually exclusive with " << iopt << ")\n" + << "\t\tCurrent initial state: " << initial << std::endl; + } + StringValueOption::StringValueOption(const char* o, const char* e, const char* v) : BaseOption(o,e), cur(strdup(v)) {} diff --git a/gecode/driver/options.hpp b/gecode/driver/options.hpp index 2aa4c337c4..34bc8add3f 100755 --- a/gecode/driver/options.hpp +++ b/gecode/driver/options.hpp @@ -273,14 +273,19 @@ namespace Gecode { } inline void - Options::seed(unsigned int s) { + Options::seed(uint64_t s) { _seed.value(s); } - inline unsigned int + inline uint64_t Options::seed(void) const { return _seed.value(); } + inline Rnd + Options::rnd(void) const { + return _seed.rnd(); + } + inline void Options::step(double s) { _step.value(s); diff --git a/gecode/flatzinc.hh b/gecode/flatzinc.hh index f4d934d3d1..b42d36d268 100755 --- a/gecode/flatzinc.hh +++ b/gecode/flatzinc.hh @@ -238,7 +238,7 @@ namespace Gecode { namespace FlatZinc { Gecode::Driver::UnsignedLongLongIntOption _fail; ///< Cutoff for number of failures Gecode::Driver::DoubleOption _time; ///< Cutoff for time Gecode::Driver::DoubleOption _time_limit; ///< Cutoff for time (for compatibility with flatzinc command line) - Gecode::Driver::IntOption _seed; ///< Random seed + Gecode::Driver::RandomOption _seed; ///< Random seed or state Gecode::Driver::StringOption _restart; ///< Restart method option Gecode::Driver::DoubleOption _r_base; ///< Restart base Gecode::Driver::UnsignedIntOption _r_scale; ///< Restart scale factor @@ -349,7 +349,8 @@ namespace Gecode { namespace FlatZinc { unsigned long long int node(void) const { return _node.value(); } unsigned long long int fail(void) const { return _fail.value(); } double time(void) const { return _time.value(); } - int seed(void) const { return _seed.value(); } + uint64_t seed(void) const { return _seed.value(); } + Rnd rnd(void) const { return _seed.rnd(); } double step(void) const { return _step.value(); } const char* output(void) const { return _output.value(); } @@ -655,7 +656,7 @@ namespace Gecode { namespace FlatZinc { * If \a ignoreUnknown is true, unknown solve item annotations will be * ignored, otherwise a warning is written to \a err. * - * The seed for random branchers is given by the \a seed parameter. + * Random branchers use the seed or complete state configured in \a opt. * */ void createBranchers(Printer& p, AST::Node* ann, diff --git a/gecode/flatzinc/flatzinc.cpp b/gecode/flatzinc/flatzinc.cpp index 7c13b1489f..4d38a811c7 100644 --- a/gecode/flatzinc/flatzinc.cpp +++ b/gecode/flatzinc/flatzinc.cpp @@ -1060,9 +1060,8 @@ namespace Gecode { namespace FlatZinc { FlatZincSpace::createBranchers(Printer&p, AST::Node* ann, FlatZincOptions& opt, bool ignoreUnknown, std::ostream& err) { - int seed = opt.seed(); double decay = opt.decay(); - Rnd rnd(static_cast(seed)); + Rnd rnd = opt.rnd(); TieBreak def_int_varsel = INT_VAR_AFC_SIZE_MAX(0.99); IntBoolVarBranch def_intbool_varsel = INTBOOL_VAR_AFC_SIZE_MAX(0.99); IntValBranch def_int_valsel = INT_VAL_MIN(); diff --git a/gecode/flatzinc/restart-random.hpp b/gecode/flatzinc/restart-random.hpp deleted file mode 100644 index c272d2aaac..0000000000 --- a/gecode/flatzinc/restart-random.hpp +++ /dev/null @@ -1,67 +0,0 @@ -/* -*- 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_FLATZINC_RESTART_RANDOM_HPP -#define GECODE_FLATZINC_RESTART_RANDOM_HPP - -namespace Gecode { namespace FlatZinc { namespace Internal { - - /// Sample an offset for an inclusive integer restart range - template - unsigned long long int - uniform_int_offset(Random& random, unsigned long long int width) { - const unsigned long long int chunk_width = 1ULL << 31; - - // Retain the established seeded sequence for ordinary integer ranges. - if ((width <= chunk_width) || (width > (1ULL << 32))) - return random(width); - - // Draw uniformly from [0,2^62), rejecting its incomplete final bucket. - const unsigned long long int source_width = 1ULL << 62; - const unsigned long long int limit = - source_width - (source_width % width); - unsigned long long int sample; - do { - sample = - (static_cast( - random(static_cast(chunk_width))) << 31) | - random(static_cast(chunk_width)); - } while (sample >= limit); - return sample % width; - } - -}}} - -#endif - -// STATISTICS: flatzinc-other diff --git a/gecode/support/config.hpp.in b/gecode/support/config.hpp.in index 9c9588878f..85beb03270 100644 --- a/gecode/support/config.hpp.in +++ b/gecode/support/config.hpp.in @@ -61,6 +61,9 @@ /* whether __builtin_popcountll is available */ #undef GECODE_HAS_BUILTIN_POPCOUNTLL +/* Use xorshift64* instead of splittable SplitMix as the default random engine. */ +#undef GECODE_RANDOM_XORSHIFT64STAR + /* Whether counting-based search support available */ #undef GECODE_HAS_CBS diff --git a/gecode/support/hw-rnd.cpp b/gecode/support/hw-rnd.cpp index e93012a5ae..1d672bac10 100644 --- a/gecode/support/hw-rnd.cpp +++ b/gecode/support/hw-rnd.cpp @@ -35,6 +35,7 @@ #define _CRT_RAND_S #include +#include #include @@ -42,7 +43,8 @@ namespace Gecode { namespace Support { unsigned int hwrnd(void) { unsigned int r; - (void) rand_s(&r); + if (rand_s(&r) != 0) + throw std::runtime_error("Hardware random initialization failed"); return r; } @@ -53,14 +55,17 @@ namespace Gecode { namespace Support { #include #include +#include namespace Gecode { namespace Support { unsigned int hwrnd(void) { std::fstream devrandom; - devrandom.open("/dev/random", std::fstream::in); + devrandom.open("/dev/random", std::fstream::in | std::fstream::binary); unsigned int rnd; devrandom.read(reinterpret_cast(&rnd),sizeof(unsigned int)); + if (!devrandom) + throw std::runtime_error("Cannot read hardware random source /dev/random"); devrandom.close(); return rnd; } @@ -70,4 +75,3 @@ namespace Gecode { namespace Support { #endif // STATISTICS: support-any - diff --git a/gecode/support/random.hpp b/gecode/support/random.hpp index 65129ad23f..9aeb78ddf8 100755 --- a/gecode/support/random.hpp +++ b/gecode/support/random.hpp @@ -422,8 +422,12 @@ namespace Gecode { namespace Support { } }; - /// Default generator; full state consists of two 64-bit words. + /// Build-configured default generator, also used by command-line clients. +#ifdef GECODE_RANDOM_XORSHIFT64STAR + using RandomGenerator = Random; +#else using RandomGenerator = Random; +#endif }} diff --git a/plans/random.md b/plans/random.md index 4b8a25bbcb..0bcb5ee5cb 100644 --- a/plans/random.md +++ b/plans/random.md @@ -1,7 +1,7 @@ # Plan: Compact, splittable random generators for Gecode 7 > Source: the feature/random design discussion, 2026-09-10. -> Status: Phases 1–3 complete; Phase 4 next. +> Status: All four phases complete and reviewed. > Workflow: review, update this plan, and commit after each phase. ## Goal @@ -398,21 +398,86 @@ ownership rules, replay contract, and compatibility changes for Gecode 7. ### Acceptance criteria -- [ ] Drivers accept full 64-bit seeds without signed narrowing or truncation +- [x] Drivers accept full 64-bit seeds without signed narrowing or truncation and use the same initialization/state conventions as the test runner. -- [ ] Complete state is accepted and reproduced for the configured engine; +- [x] Complete state is accepted and reproduced for the configured engine; malformed, incompatible, and conflicting options are rejected clearly. -- [ ] Time/hardware initialization can report the concrete initialized state +- [x] Time/hardware initialization can report the concrete initialized state needed for a later replay. -- [ ] A runnable custom-engine example works through built-in branching; custom +- [x] A runnable custom-engine example works through built-in branching; custom brancher documentation explains choice snapshots and replay obligations. -- [ ] FlatZinc restart sampling no longer relies on the old generator's restricted +- [x] FlatZinc restart sampling no longer relies on the old generator's restricted range or sequence-preservation workaround where the new contract replaces it. -- [ ] Release notes describe changed seeded sequences, state replay, and copying +- [x] Release notes describe changed seeded sequences, state replay, and copying versus sharing semantics. No legacy sequence mode is required. -- [ ] Supported build configurations and relevant regression suites pass; the +- [x] Supported build configurations and relevant regression suites pass; the default choice and measured memory/performance tradeoffs are documented. +### Phase 4 review + +The example driver and FlatZinc share `Driver::RandomOption`: checked 64-bit +decimal/hex seeds, full-state input, and mutually exclusive seed/state arguments. +The existing seed flag names remain `-seed` and `-r`, respectively. `time` and +`hw` initialize once and print the resulting state. Hardware-source failures now +throw rather than return uninitialized data. `opt.rnd()` produces an independent +generator at the configured initial state; all in-tree numeric seed consumers +were migrated. The numeric accessor rejects state-only initialization rather +than silently ignoring it. + +Both CMake (`GECODE_RANDOM_ENGINE`) and Autoconf (`--with-random-engine`) select +SplitMix or xorshift64*. The generated, installed configuration header carries +that selection to clients. SplitMix remains the default for the measured splitting +cost; xorshift64* remains the 8-byte alternative. Both concrete engine types and +the public custom-engine interface are available in either build. + +`examples/random-engine.cpp` uses a three-word user engine with built-in random +selectors. It delegates generation/splitting to SplitMix and adds a path-local +draw counter, demonstrating full-state extension without another algorithm. +Seeded and full-state runs enumerate the same 24 permutations and solution states. +The output also agrees across both default configurations and the no-thread and +static builds. The old unused FlatZinc restart sampler and its chunk-specific test +are removed. The range-validation and restart integration tests remain. Review +also corrected the FlatZinc test harness to pass its configured generator into +parsing, and replaced an unsupported test `--seed` flag with `-r`. + +Validation on arm64 macOS with Apple Clang 21: + +- Full CMake `check`, including fault-injection tests, passes with both defaults. + All enabled example targets also build with the default configuration. +- `random-options` and `random-state-replay` CTests pass with both defaults. + CLI checks cover maximum-width decimal/hex seeds, arbitrary full states + (including a non-default SplitMix increment), incompatible identifiers, + malformed/conflicting input, and replay of reported time/hardware state. +- Boolean, set, and float branching selections, filtered random ties, and all + FlatZinc restart tests pass with both defaults. A direct FlatZinc run enumerates + the same 27 assignments from a maximum-width seed and its complete state. +- A reduced static CMake build with threads, set, float, and FlatZinc disabled + passes the random contract/branch replay/commit tests and both replay/CLI CTests. +- Autoconf builds all libraries and the custom example with xorshift64* and + threading disabled. Out-of-tree direct example building requires the existing + `make mkcompiledirs` preparation target. Its output matches the CMake builds. + +Logs and comparison outputs are under `build/random/phase4-*`; configurations are +in `build/random`, `build/random-xorshift`, `build/random-static`, and +`build/random-autoconf`. These are local configuration checks, not a claim of +having run every platform's CI. `docs/random.md` and the Gecode 7 changelog section +describe the API, compatibility changes, and measured costs. Release version and +ABI-number changes remain release preparation, outside this feature plan. + +### Completion audit + +Requirements 1 and 5 are exercised by both configured defaults, the CLI fixture, +and the separately defined custom engine. Requirement 2 is exercised by commands +replayed from actual failure and exception reports, plus driver full-state replay. +Requirements 3 and 4 are exercised by immutable choice snapshots, reverse sibling +exploration, archived replay on pre-selection clones with perturbed state, +multiway and callback handover, and equivalent solution/state sets under cloning, +recomputation, and parallel search. Review confirms that all variable/value +selectors bind and remap local streams and that commit installs them before +callbacks. Requirement 6 is covered by the retained Phase 3 measurements and +explicit description, space, choice, snapshot, archive, and splitting costs. +The final review found no uncompleted acceptance criterion in this plan. + ## Validation boundaries Keep a small set of high-value tests: reference vectors, save/restore including diff --git a/test/flatzinc.cpp b/test/flatzinc.cpp index b7c1873b0b..f9bfe7dd42 100755 --- a/test/flatzinc.cpp +++ b/test/flatzinc.cpp @@ -142,8 +142,9 @@ namespace Test { namespace FlatZinc { _before(); } std::stringstream ss(_source); + Rnd random = fznopt.rnd(); std::unique_ptr fg( - Gecode::FlatZinc::parse(ss, p, olog)); + Gecode::FlatZinc::parse(ss, p, olog, nullptr, random)); if (fg) { fg->createBranchers(p, fg->solveAnnotations(), fznopt, diff --git a/test/flatzinc/on_restart_last_val_int.cpp b/test/flatzinc/on_restart_last_val_int.cpp index 09edac36e0..775b40b228 100644 --- a/test/flatzinc/on_restart_last_val_int.cpp +++ b/test/flatzinc/on_restart_last_val_int.cpp @@ -37,8 +37,6 @@ #include "test/flatzinc.hh" -#include "gecode/flatzinc/restart-random.hpp" - namespace Test { namespace FlatZinc { namespace { @@ -67,68 +65,6 @@ solve satisfy; } }; - /// Scripted generator for testing wide restart integer sampling - class ScriptedRandom { - private: - const unsigned int* values; - unsigned int size; - unsigned int next; - public: - /// Initialize with the values to return - ScriptedRandom(const unsigned int* values0, unsigned int size0) - : values(values0), size(size0), next(0) {} - /// Return the next scripted 31-bit chunk - unsigned int operator ()(unsigned int n) { - if ((n != (1U << 31)) || (next >= size)) - return 0; - return values[next++]; - } - /// Stub for the narrow-path overload - unsigned long long int operator ()(unsigned long long int) { - return 0; - } - /// Return whether all scripted chunks were consumed - bool done(void) const { - return next == size; - } - }; - - /// Test wide restart integer range endpoints deterministically - class WideUniformInt : public Base { - public: - /// Create and register test - WideUniformInt(void) - : Base("FlatZinc::on_restart::uniform_int_wide_endpoints") {} - /// Perform test - virtual bool run(void) { - { - const unsigned int chunks[] = { - (1U << 31) - 1U, (1U << 31) - 1U, 1U, 0U - }; - ScriptedRandom random(chunks, 4); - const unsigned long long int width = (1ULL << 31) + 1ULL; - const unsigned long long int offset = - Gecode::FlatZinc::Internal::uniform_int_offset(random,width); - if ((offset != width - 1ULL) || - (static_cast(INT_MIN) + - static_cast(offset) != 0) || !random.done()) - return false; - } - { - const unsigned int chunks[] = {1U, (1U << 31) - 1U}; - ScriptedRandom random(chunks, 2); - const unsigned long long int width = 1ULL << 32; - const unsigned long long int offset = - Gecode::FlatZinc::Internal::uniform_int_offset(random,width); - if ((offset != width - 1ULL) || - (static_cast(INT_MIN) + - static_cast(offset) != INT_MAX) || !random.done()) - return false; - } - return true; - } - }; - /// Helper class to create and register tests class Create { public: @@ -184,13 +120,12 @@ solve satisfy; )FZN", R"OUT(y = 1; ---------- -)OUT", true, {"--restart", "constant", "--restart-base", "100", "--seed", "2"}); +)OUT", true, {"--restart", "constant", "--restart-base", "100", "-r", "2"}); } }; Create c; UniformIntInvalidRange invalid_range; - WideUniformInt w; } }} diff --git a/test/random-options.cmake b/test/random-options.cmake new file mode 100644 index 0000000000..2807bc5f7d --- /dev/null +++ b/test/random-options.cmake @@ -0,0 +1,65 @@ +# Copyright (c) 2026 Mikael Zayenz Lagerkvist. MIT license; see LICENSE. +set(modes driver) +if(FLATZINC) + list(APPEND modes flatzinc) +endif() +foreach(mode IN LISTS modes) + set(prefix) + set(seed -seed) + if(mode STREQUAL flatzinc) + set(prefix flatzinc) + set(seed -r) + endif() + execute_process(COMMAND "${OPTIONS}" ${prefix} ${seed} 18446744073709551615 + RESULT_VARIABLE result OUTPUT_VARIABLE expected ERROR_VARIABLE error) + if(NOT result EQUAL 0) + message(FATAL_ERROR "64-bit seed failed: ${error}") + endif() + string(REGEX MATCH "^[^\n]+" state "${expected}") + if(NOT state MATCHES "-v1:ffffffffffffffff(:|$)") + message(FATAL_ERROR "Seed was narrowed: ${state}") + endif() + foreach(args "${seed};0xffffffffffffffff" "-state;${state}") + execute_process(COMMAND "${OPTIONS}" ${prefix} ${args} + RESULT_VARIABLE result OUTPUT_VARIABLE actual ERROR_VARIABLE error) + if(NOT result EQUAL 0 OR NOT actual STREQUAL expected) + message(FATAL_ERROR "State/hex replay differs: ${actual} ${error}") + endif() + endforeach() + if(state MATCHES "^splitmix") + set(arbitrary "splitmix-v1:fedcba9876543210:0123456789abcdef") + set(incompatible "xorshift64star-v1:0000000000000001") + else() + set(arbitrary "xorshift64star-v1:fedcba9876543210") + set(incompatible "splitmix-v1:0000000000000000:9e3779b97f4a7c15") + endif() + execute_process(COMMAND "${OPTIONS}" ${prefix} -state "${arbitrary}" + RESULT_VARIABLE result OUTPUT_VARIABLE actual ERROR_VARIABLE error) + string(REGEX MATCH "^[^\n]+" restored "${actual}") + if(NOT result EQUAL 0 OR NOT restored STREQUAL arbitrary) + message(FATAL_ERROR "Complete state was not restored: ${actual} ${error}") + endif() + foreach(args "${seed};-1" "${seed};18446744073709551616" + "${seed};3x" "-state;bad" "-state" + "-state;${incompatible}" + "${seed};1;-state;${state}" "-state;${state};${seed};1") + execute_process(COMMAND "${OPTIONS}" ${prefix} ${args} + RESULT_VARIABLE result OUTPUT_VARIABLE actual ERROR_VARIABLE error) + if(result EQUAL 0) + message(FATAL_ERROR "Invalid options accepted: ${args}") + endif() + endforeach() + foreach(source time hw) + execute_process(COMMAND "${OPTIONS}" ${prefix} ${seed} ${source} + RESULT_VARIABLE result OUTPUT_VARIABLE expected ERROR_VARIABLE report) + string(REGEX MATCH "Random state: -state ([^\n]+)" matched "${report}") + if(NOT result EQUAL 0 OR NOT matched) + message(FATAL_ERROR "${source} initialization did not report state: ${report}") + endif() + execute_process(COMMAND "${OPTIONS}" ${prefix} -state "${CMAKE_MATCH_1}" + RESULT_VARIABLE result OUTPUT_VARIABLE actual ERROR_VARIABLE error) + if(NOT result EQUAL 0 OR NOT actual STREQUAL expected) + message(FATAL_ERROR "${source} state replay differs: ${actual} ${error}") + endif() + endforeach() +endforeach() diff --git a/test/random-options.cpp b/test/random-options.cpp new file mode 100644 index 0000000000..54464a7e87 --- /dev/null +++ b/test/random-options.cpp @@ -0,0 +1,32 @@ +// Copyright (c) 2026 Mikael Zayenz Lagerkvist. MIT license; see LICENSE. +#include +#ifdef TEST_RANDOM_FLATZINC +#include +#endif +#include + +template +int check(int argc, char* argv[]) { + Options opt("random-options"); + opt.parse(argc,argv); + if (argc != 1) + return 2; + auto first = opt.rnd(); + auto second = opt.rnd(); + std::cout << first.state() << '\n'; + for (int i=0; i<8; ++i) { + const auto draw = first(UINT64_MAX); + if (draw != second(UINT64_MAX)) + return 3; + std::cout << draw << '\n'; + } + return 0; +} + +int main(int argc, char* argv[]) { +#ifdef TEST_RANDOM_FLATZINC + if (argc > 1 && std::string(argv[1]) == "flatzinc") + return check(argc-1,argv+1); +#endif + return check(argc,argv); +} diff --git a/test/random.cpp b/test/random.cpp index f47c7e3caa..3f056acd75 100644 --- a/test/random.cpp +++ b/test/random.cpp @@ -61,7 +61,7 @@ namespace Test { Contract() : Base("Random::Contract") {} bool run() override { // SplitMix64 reference sequence, seed zero and golden-ratio increment. - RandomGenerator r(0); + Gecode::Support::Random r(0); for (uint64_t expected : {UINT64_C(0xe220a8397b1dcdaf), UINT64_C(0x6e789e6aa1b965f4), UINT64_C(0x06c45d188009454f)}) @@ -81,7 +81,7 @@ namespace Test { return false; auto parent = r.state(); // Indexing skips pairs of parent words, exactly as sequential splits. - RandomGenerator sequential = r; + Gecode::Support::Random sequential = r; for (uint32_t a=0; a<100; ++a) { auto child = r.split(a); if (child.state()[0] != sequential.next()) diff --git a/tools/flatzinc/fzn-gecode.cpp b/tools/flatzinc/fzn-gecode.cpp index 3bd8fb134d..3e04defb05 100755 --- a/tools/flatzinc/fzn-gecode.cpp +++ b/tools/flatzinc/fzn-gecode.cpp @@ -56,7 +56,7 @@ int main(int argc, char** argv) { FlatZinc::Printer p; FlatZinc::FlatZincSpace* fg = nullptr; - Rnd rnd(opt.seed()); + Rnd rnd = opt.rnd(); try { if (!strcmp(filename, "-")) { fg = FlatZinc::parse(cin, p, std::cerr, nullptr, rnd); From a1ff8e33850cea1d3207b9fb97de1396a5830fd9 Mon Sep 17 00:00:00 2001 From: Mikael Zayenz Lagerkvist Date: Thu, 10 Sep 2026 10:59:06 +0200 Subject: [PATCH 5/7] random: use standard Gecode file headers --- examples/random-engine.cpp | 32 +++++++++++++++++++++++++++++++- test/random-options.cmake | 32 +++++++++++++++++++++++++++++++- test/random-options.cpp | 34 +++++++++++++++++++++++++++++++++- test/random-replay.cmake | 32 +++++++++++++++++++++++++++++++- test/random-replay.cpp | 33 ++++++++++++++++++++++++++++++++- test/random.cpp | 32 +++++++++++++++++++++++++++++++- tools/random-benchmark.cpp | 34 +++++++++++++++++++++++++++++++++- tools/random-benchmark.py | 32 ++++++++++++++++++++++++++++++++ 8 files changed, 254 insertions(+), 7 deletions(-) diff --git a/examples/random-engine.cpp b/examples/random-engine.cpp index 1d15d342b6..b83fdb9e6a 100644 --- a/examples/random-engine.cpp +++ b/examples/random-engine.cpp @@ -1,5 +1,35 @@ /* -*- mode: C++; c-basic-offset: 2; indent-tabs-mode: nil -*- */ -// Copyright (c) 2026 Mikael Zayenz Lagerkvist. MIT license; see LICENSE. +/* + * 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 diff --git a/test/random-options.cmake b/test/random-options.cmake index 2807bc5f7d..78f492ca4a 100644 --- a/test/random-options.cmake +++ b/test/random-options.cmake @@ -1,4 +1,34 @@ -# Copyright (c) 2026 Mikael Zayenz Lagerkvist. MIT license; see LICENSE. +# +# 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. +# + set(modes driver) if(FLATZINC) list(APPEND modes flatzinc) diff --git a/test/random-options.cpp b/test/random-options.cpp index 54464a7e87..bb9bc5a8f2 100644 --- a/test/random-options.cpp +++ b/test/random-options.cpp @@ -1,4 +1,36 @@ -// Copyright (c) 2026 Mikael Zayenz Lagerkvist. MIT license; see LICENSE. +/* -*- 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 #ifdef TEST_RANDOM_FLATZINC #include diff --git a/test/random-replay.cmake b/test/random-replay.cmake index 513db233da..bbc4febf65 100644 --- a/test/random-replay.cmake +++ b/test/random-replay.cmake @@ -1,4 +1,34 @@ -# Copyright (c) 2026 Mikael Zayenz Lagerkvist. MIT license; see LICENSE. +# +# 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. +# + foreach(kind Failure Exception) execute_process(COMMAND "${REPLAY}" -seed 1 -iter 100 -test-exact "Random::Replay::${kind}" RESULT_VARIABLE first_result OUTPUT_VARIABLE first ERROR_VARIABLE first_error) diff --git a/test/random-replay.cpp b/test/random-replay.cpp index cf7bbd4e9f..9eaa16d8fa 100644 --- a/test/random-replay.cpp +++ b/test/random-replay.cpp @@ -1,5 +1,36 @@ /* -*- mode: C++; c-basic-offset: 2; indent-tabs-mode: nil -*- */ -// Copyright (c) 2026 Mikael Zayenz Lagerkvist. MIT license; see LICENSE. +/* + * 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. + * + */ + // Deliberately failing fixtures for the test runner's state replay protocol. #include "test/test.hh" diff --git a/test/random.cpp b/test/random.cpp index 3f056acd75..3fc9cb817c 100644 --- a/test/random.cpp +++ b/test/random.cpp @@ -1,5 +1,35 @@ /* -*- mode: C++; c-basic-offset: 2; indent-tabs-mode: nil -*- */ -// Copyright (c) 2026 Mikael Zayenz Lagerkvist. MIT license; see LICENSE. +/* + * 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 "test/test.hh" diff --git a/tools/random-benchmark.cpp b/tools/random-benchmark.cpp index aefeefeb38..8bbf408ef0 100644 --- a/tools/random-benchmark.cpp +++ b/tools/random-benchmark.cpp @@ -1,4 +1,36 @@ -// Copyright (c) 2026 Mikael Zayenz Lagerkvist. MIT license; see LICENSE. +/* -*- 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. + * + */ + // Compile against main without RANDOM_NEW, or against feature/random with it. #include #include diff --git a/tools/random-benchmark.py b/tools/random-benchmark.py index 96c9514b2a..7f0e1c429a 100644 --- a/tools/random-benchmark.py +++ b/tools/random-benchmark.py @@ -1,4 +1,36 @@ #!/usr/bin/env python3 + +# +# 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. +# + """Compare compiled random-benchmark executables with bounded repeated runs. Example: python3 tools/random-benchmark.py build/random/random-benchmark \ From e734d741e5fdc1580dd8e5f60c81ff02402e4695 Mon Sep 17 00:00:00 2001 From: Mikael Zayenz Lagerkvist Date: Thu, 10 Sep 2026 13:54:26 +0200 Subject: [PATCH 6/7] random: keep generator state in consumer-owned values --- changelog.in | 9 +- docs/random.md | 440 ++++++++-------- examples/job-shop.cpp | 4 +- examples/photo.cpp | 8 +- examples/random-engine.cpp | 29 +- gecode/flatzinc/flatzinc.cpp | 10 +- gecode/float.hh | 2 +- gecode/float/branch.hh | 3 + gecode/float/branch/val-sel.hpp | 6 +- gecode/float/relax.cpp | 3 +- gecode/int.hh | 4 +- gecode/int/branch.hh | 9 +- gecode/int/branch/val-sel.hpp | 30 +- gecode/int/branch/view-values.cpp | 3 +- gecode/int/branch/view-values.hpp | 20 +- gecode/int/ldsb/brancher.hpp | 17 +- gecode/int/relax.cpp | 5 +- gecode/kernel/branch/val-sel-commit.hpp | 12 + gecode/kernel/branch/val-sel.hpp | 3 + gecode/kernel/branch/val.hpp | 5 +- gecode/kernel/branch/var.hpp | 5 +- gecode/kernel/branch/view-sel.hpp | 72 +-- gecode/kernel/branch/view-val.hpp | 21 +- gecode/kernel/branch/view.hpp | 52 ++ gecode/kernel/core.cpp | 44 +- gecode/kernel/core.hpp | 18 +- gecode/kernel/data/rnd.cpp | 56 -- gecode/kernel/data/rnd.hpp | 211 ++------ gecode/search/relax.hh | 5 +- gecode/set.hh | 2 +- gecode/set/branch.hh | 4 +- gecode/set/branch/val-sel.hpp | 6 +- gecode/set/relax.cpp | 3 +- gecode/support/random.hpp | 1 + plans/random.md | 656 +++++------------------- test/fault.cpp | 20 +- test/random.cpp | 299 ++++++----- tools/random-benchmark.cpp | 11 +- 38 files changed, 816 insertions(+), 1292 deletions(-) diff --git a/changelog.in b/changelog.in index a53329d4d3..cb61744977 100755 --- a/changelog.in +++ b/changelog.in @@ -79,12 +79,13 @@ Module: kernel What: new Rank: major [DESCRIPTION] -Random branching uses space-local streams and records complete splitting state -in choices. Every alternative derives a distinct successor state that is stable +Random generators are compact values stored directly in their consumers. +Randomized branchers record only their own selectors' splitting state in choices. +Every alternative derives a distinct successor state that is stable when the same recorded path is recomputed. Splittable SplitMix is the default; xorshift64* is a configurable smaller-state alternative. Users can supply engines -through the existing branching APIs. Standalone Rnd copies share a stream; -copy() makes an independent exact copy, while space cloning copies local state. +through the generic branching APIs. Rnd copies are independent state copies; +there is no space-managed random context or shared mutable generator handle. Drivers accept checked 64-bit seeds and complete state for replay. Test failures report the exact iteration state. Seeded sequences and choice archives change; no legacy sequence mode is provided. See docs/random.md for migration and costs. diff --git a/docs/random.md b/docs/random.md index a7db1d3404..232a21d449 100644 --- a/docs/random.md +++ b/docs/random.md @@ -1,82 +1,92 @@ -# Random generators in Gecode 7 +# Random generators for a future Gecode release -Random branching uses a stream local to each space. Each recorded choice contains -the state needed to derive a different stream for each alternative. Recomputing -that choice installs the same stream as the original commit. Cloning copies state; -it does not split or consume random values. +This is a provisional Gecode 7 design. It changes APIs, seeded sequences, and +randomized choice archives and is intended only for a breaking-change release. -## Engines and full state +## State belongs to the consumer -`Support::Random` is a value type. Copying it makes an independent, exact -copy. It supplies bounded integer generation and state text on top of the engine. -An engine provides: +A generator is a small value. A model, selector, or custom brancher stores that +value directly in its own state. Copying it copies all its state independently. +There is no random context in Space, no registration, no shared mutable handle, +and no automatic coordination between consumers. -- `State`, a nonempty `std::array` containing all mutable state and - per-stream parameters; -- construction and `seed(uint64_t)`, with a documented rule for every seed; +```cpp +Rnd a(42); +Rnd b = a; // Independent copy at the same state. +Rnd child = a.split(1); // Does not change a. +``` + +The default `Rnd` contains exactly the configured engine's state: 16 bytes +for splittable SplitMix or 8 bytes for xorshift64*. It has no pointer, vtable, +reference count, or heap allocation. Default construction initializes seed 1. + +Passing a generator to two branching descriptions gives them two independent +copies. To start them differently, pass explicitly split generators. Posting +copies the description's state into its selector. Cloning copies selector state +without drawing or splitting. A model member is copied normally in the model's +copy constructor; it is not aliased to a selector that was initialized from it. + +## Branch selection and recomputation + +After choosing its position and value, a randomized brancher records the complete +state of its own selectors in its choice. Committing alternative `a` restores +that recorded state and derives each selector's `split(a)` state before the +value commit. The destination's current selector state is not the splitting +input. Archive reconstruction restores choice data without drawing or splitting. + +Only the active brancher's selectors participate. A deterministic brancher does +not advance a later random brancher or a model-owned generator. Nor does finishing +one random brancher change a separately posted brancher's state. Earlier branch +constraints can still affect later selection, of course. + +The rule applies to binary, multiway, and one-alternative assignment branchers. +It uses the public alternative index even when values are visited in reverse +order. Each random choice stores one parent state per selector, not one state +per alternative. Late alternatives require no replay of preceding alternatives. + +Randomized choices use `RndChoice`, with packed state words in the same +allocation as the choice. Nonrandom choices retain their original layout and +archive format. The base `Space` and `Choice` classes contain no RNG storage or +snapshot hooks. Multiple selectors are recorded independently, in tie-break order +followed by value selection; their types determine the state-word layout. + +This guarantees random state for the same recorded path. It does not guarantee +identical scheduling, solution order, adaptive heuristics, or entire search trees +under parallel search or weakly monotonic propagation. + +## Engines and extension + +`Support::Random` is the low-level value wrapper, supplying bounded +integer draws and canonical state text. `RndGenerator` is the modeling +wrapper; `Rnd` names its build-configured specialization. + +An engine supplies: + +- A nonempty `State = std::array` containing all mutable state and + per-stream parameters. +- Construction and `seed(uint64_t)`, with a documented rule for every seed. - `next()`, `min()`, and `max()`, with raw output in `[0,UINT64_MAX]` or - `[1,UINT64_MAX]`; -- `state()` and `state(const State&)` for exact capture and validated restoration; -- `name()`, a stable identifier for its state format and algorithm; -- `split(uint32_t) const`, returning the selected child without changing its - parent, for use in search. - -All valid alternative indices of a parent must produce distinct child states. -An engine must document that property; different first output values are neither -required nor sufficient. Copying and restoring state must reproduce subsequent -draws and splits. A state setter must reject invalid input before mutating state. -Keep engine-owned resources exception-safe; copying an engine may occur while -cloning a space. Raw pointers to external mutable state are unsuitable snapshots. - -The built-in engines are: - -| Engine | State | Splitting | -| --- | ---: | --- | -| `Support::SplitMix` | 16 bytes | Indexed sequential SplitMix splits, constant time | -| `Support::Xorshift64Star` | 8 bytes | Indexed jumps in the native recurrence | - -SplitMix uses the published two-word splittable design: a state and an odd -increment. It is the preferred default. For parent `(s,g)`, child `a` is -`(Mix13(s+(2a+1)g), mixGamma(s+(2a+2)g))` modulo 2^64. The first word is distinct -for every 32-bit index because `g` is odd and Mix13 is a permutation. This is -exactly the child obtained by sequentially splitting `a+1` times, without doing -the intervening work. - -Xorshift64* uses shifts 12, 25, and 27, with multiplier 2685821657736338717. -Child `a` starts `(a+1)*2^32` recurrence steps ahead. Since 2^32 is coprime to -the period 2^64-1, sibling states are distinct. Binary powers of the transition -matrix compute jumps in logarithmic time, with a shared 16 KiB table initialized -once. That table is not part of individual states or choices. For the maximum -32-bit index, 2^64 steps reduce to one step modulo the period. These jumps keep -the original recurrence; they are not a new seed-hashing scheme. - -Neither engine guarantees globally disjoint streams throughout an unbounded -search tree. Xorshift64* also has known statistical weaknesses in its low bits; -its smaller state and different splitting cost should be considered together. -See [Vigna's discussion](https://prng.di.unimi.it/xorshift.php) and the -[SplitMix paper](https://gee.cs.oswego.edu/dl/papers/oopsla14.pdf). + `[1,UINT64_MAX]`. +- `state()` and `state(const State&)` for exact capture and validated restoration. +- `name()`, identifying the algorithm and state format. +- `split(uint32_t) const`, returning a child without changing its parent. -Bounded draws use integer rejection sampling. Bounds zero, one, or negative -signed bounds return zero without consuming output. Full-range engines reject -the incomplete bucket at the bottom of the raw range; nonzero engines first -subtract one and reject the incomplete bucket at the top. Thus xorshift's missing -zero is accounted for. No distribution state is cached. The conversion and its -draw consumption are part of Gecode's reproducible sequence. +All valid sibling indices must yield distinct states, not merely different first +outputs. State restoration must reproduce subsequent draws and splits and reject +invalid input before mutation. Ordinary copying must make state independent. -Full state text is an identifier followed by fixed-width hexadecimal words, for -example `splitmix-v1:000000000000002a:9e3779b97f4a7c15`. Word order is explicit -and independent of host byte order. Restoration does not run seed expansion or -repair invalid states. A normal 64-bit seed is a convenience for initialization, -not a substitute for a complete snapshot. +The runnable `examples/random-engine.cpp` defines a three-word engine outside +Gecode. It delegates to SplitMix and adds a path-local raw-draw counter. Both +variable and value selectors store this user engine inline: -## Handles, binding, and callbacks +```cpp +using Random = RndGenerator; +using VariableSelector = ViewSelRnd; +using ValueSelector = Int::Branch::ValSelRnd; +``` -The runnable `examples/random-engine.cpp` defines an engine outside Gecode and -uses it with built-in random variable and value selection. Its three-word state -contains SplitMix's two words and a raw-draw counter. The counter follows the -path, including across splits, and wraps modulo 2^64. This demonstrates extending -state without introducing another generator algorithm or changing Gecode's -engine-selection code. Build and run it with: +The example posts these with the existing generic view/value brancher machinery. +Its optional argument is a complete custom-engine state: ```sh cmake --build build/random --target random-engine @@ -84,146 +94,103 @@ build/random/bin/random-engine build/random/bin/random-engine counted-splitmix-v1:000000000000002a:9e3779b97f4a7c15:0000000000000000 ``` -Both commands enumerate the same 24 permutations and print the same solution -states. The optional argument restores this example's custom engine, independently -of the library's configured default. +Both runs print the same initial state and enumerate the same 24 permutations. -`Rnd(seed)` creates a standalone default stream. `Rnd(Support::Random(seed))` -creates a user-defined one. Copying a standalone `Rnd` shares its stream; -`r.copy()` creates an independent exact copy, and `r.split(a)` creates a child. -Concurrent draws through shared standalone handles require caller synchronization. +Custom selectors participate through `random_words()`, `random_save()`, and +`random_commit()`. State size and layout must be stable across clones. +Custom branchers can instead store their generator and its choice snapshot as +ordinary typed members and implement the same capture/restore/split rule directly. -`Rnd(home, source)` binds a stream to a space. Binding the same stream again -returns the same local stream. Different initialization handles remain different -streams even when their initial states are equal. Binding retains stream identity -across clones, so callbacks can resolve an ancestor's handle in their own space. -Reseeding an external initialization handle after binding does not reseed the -space-local copy; reseed the bound handle explicitly if that is intended. +Randomness hidden inside a callback is not automatically recorded. If a callback +draws model-owned state during choice generation, its custom choice/commit +implementation must preserve that state for replay. A callback must not capture +a mutable RNG in a shared function object and assume that cloning copies it. +Callbacks that operate on model state during commit should access the destination +model and make their state transitions explicit. -Built-in random selectors bind during posting and remap during cloning. A model -that retains a bound handle should likewise use `rnd(*this, s.rnd)` in its copy -constructor. User callbacks that capture a handle must resolve it through the -space passed to the callback rather than draw directly from the captured handle: +## Built-in algorithms -```cpp -Rnd source(*this, 42); -branch(*this, x, INT_VAR_NONE(), - INT_VAL([source](const Space& home, IntVar v, int) { - Rnd local(home, source); - unsigned int offset = local(v.size()); - IntVarValues values(v); - while (offset--) ++values; - return values.val(); - })); -``` +| Engine | State | Indexed splitting | +| --- | ---: | --- | +| `Support::SplitMix` | 16 bytes | Constant-time sequential SplitMix child | +| `Support::Xorshift64Star` | 8 bytes | Jump in its native recurrence | + +For SplitMix parent `(s,g)`, child `a` is +`(Mix13(s+(2a+1)g), mixGamma(s+(2a+2)g))` modulo 2^64. This is the child +obtained by sequentially splitting `a+1` times, computed directly. Since `g` +is odd and Mix13 is a permutation, the first child word is distinct for every +32-bit alternative index. Seed expansion sets `s` to the seed and `g` to +`9e3779b97f4a7c15`. + +Xorshift64* uses shifts 12, 25, and 27 and multiplier 2685821657736338717. +Child `a` starts `(a+1)*2^32` recurrence steps ahead. Since 2^32 is coprime +to the period 2^64-1, sibling states are distinct. A shared immutable 16 KiB jump +table is initialized once; it is not per-generator state. The maximum index +jumps 2^64 steps, equivalent to one step modulo the period. Seeds set the state +directly, except seed zero maps to one; restoring zero state is an error. + +Neither construction promises globally non-overlapping streams throughout an +unbounded search tree. Xorshift64* has known low-bit weaknesses; see +[Vigna's discussion](https://prng.di.unimi.it/xorshift.php) and the +[SplitMix paper](https://gee.cs.oswego.edu/dl/papers/oopsla14.pdf). -The const-space overload only looks up an already bound stream. Bind streams -before taking choices or clones that will replay their use. In particular, a -choice-selection callback must not first register a new stream that is absent -from an earlier clone. Dynamically posted branchers can reuse existing streams; -new streams introduced during commit must be introduced consistently on replay. -An explicitly new stream starts at its specified initialization point; it does -not retrospectively consume earlier alternatives. - -Each choice snapshots all bound streams, including streams belonging to later -branchers. Therefore a deterministic branch before a random branch still gives -the later branch different states for its alternatives. Multiple selectors sharing -one stream incur only one snapshot. Custom branchers participate through -`Space::choice()`, `Space::commit()`/`trycommit()`, and the base `Choice::archive()`; -their choice payload still contains their usual position/value data. Choices own -their snapshots and cannot be copied by C++ copy construction; use archiving when -an independent choice representation is needed. - -## Restarts, portfolios, and reproducibility limits - -Ordinary space cloning preserves stream states exactly. Generic meta-engines do -not add implicit RNG splits when creating clones. Models can explicitly call -`random_split(index)` in their `slave()` callback to derive all bound streams -from a logical restart or asset index. The callback must follow a fixed policy, -independent of which worker happens to execute it. - -FlatZinc uses a fixed three-step policy: split by 0 for restart or 1 for portfolio, -then by the high and low 32-bit words of the logical index. The Photo example -uses the high and low restart-index words before relaxation. The common relaxation -helper binds its input stream to its destination space, avoiding shared draws -between sibling spaces. - -The guarantee is the RNG state for a recorded path, not identical scheduling or -solution order in parallel search. Adaptive heuristics, restart constraints, and -weakly monotonic propagation can still change the search tree. Reproducing a -whole run also requires the same model, relevant options, and compatible Gecode -code and random algorithm. Standard-library distributions are outside Gecode's -bounded-sequence contract. - -## Test failure replay - -The test runner records complete state immediately before each iteration. On a -failure or exception it prints arguments using `-state`, `-iter 1`, and -`-test-exact`. Run the same test executable with those arguments. This restores -the iteration directly, bypassing suite seed derivation. A state replay requires -one thread and rejects an accompanying `-seed` or an incompatible state format. - -## Build configuration and command lines - -The default is splittable SplitMix. Select xorshift64* with CMake -`-DGECODE_RANDOM_ENGINE=xorshift64star` or Autoconf -`--with-random-engine=xorshift64star`. Both accept `splitmix` to select the default -explicitly and reject other names. The installed configuration header carries -the choice to clients; build libraries and clients with matching headers. There -is no runtime engine registry. Both engines remain available as C++ types in -either configuration. - -The example driver accepts `-seed`; FlatZinc accepts `-r`. Values are unsigned -64-bit decimal or `0x` hexadecimal integers, or `time` and `hw`. SplitMix seeds -set its state word directly and use the fixed initial increment -`9e3779b97f4a7c15`. Xorshift seeds set its state directly, except that zero maps -to one. A 64-bit seed suffices to initialize either engine; it need not enumerate -all possible full states. - -Both drivers accept `-state` with complete state for their configured engine. -Seed and state options are mutually exclusive, in either order. Invalid words, -invalid engine states, and incompatible identifiers are errors. Double-hyphen -spellings are also accepted. For example, with a SplitMix build: +Bounded generation uses integer rejection sampling. Bounds zero, one, and negative +signed bounds return zero without drawing. Full-range engines reject an incomplete +bottom bucket; nonzero engines subtract one and reject an incomplete top bucket. +This accounts for xorshift's missing zero. There is no distribution cache. -```sh -build/random/bin/photo -seed 0xffffffffffffffff -build/random/bin/photo -state splitmix-v1:ffffffffffffffff:9e3779b97f4a7c15 -build/random/bin/fzn-gecode -r 42 model.fzn -build/random/bin/fzn-gecode -state splitmix-v1:000000000000002a:9e3779b97f4a7c15 model.fzn -``` +## Seeds, state, and command lines + +Full state text contains an algorithm/format identifier and fixed-width hexadecimal +words in a defined order, independent of host byte order. For example: +`splitmix-v1:000000000000002a:9e3779b97f4a7c15`. Restoration bypasses seed +expansion; it never repairs invalid state silently. + +The example driver accepts `-seed`; FlatZinc accepts `-r`. Values are checked +unsigned 64-bit decimal or `0x` hexadecimal integers, or `time` and `hw`. +Both accept `-state` for the configured engine and reject conflicting seed/state +arguments, invalid states, and incompatible identifiers. Double-hyphen spellings +are accepted. A 64-bit seed need not enumerate every possible full state. + +Time and hardware initialization occur once during parsing and report +`% Random state: -state ...` on standard error. Reuse those arguments with the +same executable, model, and search options. Help also prints the initial state. + +`opt.rnd()` returns an independent value at the configured initial state. Use it +instead of `Rnd(opt.seed())` to honor full-state input. The numeric accessor +throws for full-state, time, or hardware initialization. + +The test runner snapshots immediately before each iteration. Failure and exception +reports print `-state`, `-iter 1`, and `-test-exact` arguments that restore +the iteration directly, bypassing suite seed derivation. Replay requires one +thread and rejects an accompanying seed. + +CMake `-DGECODE_RANDOM_ENGINE=xorshift64star` and Autoconf +`--with-random-engine=xorshift64star` select the alternative default; both accept +`splitmix`. The installed configuration header carries that choice to clients. +Both concrete engine types remain available; no runtime registry is required. + +## Model-owned randomness and migration + +FlatZinc's restart/relaxation generator is an ordinary model member. Its explicit +meta-engine policy splits by restart/portfolio kind and the high/low words of the +logical index. This affects that member only, not the model's branchers. +Photo likewise splits its member before relaxation. The relaxation APIs take +`Rnd&` and advance the caller's value directly. + +Gecode 6 shared-handle behavior is removed: ordinary copying now copies state. +Replace the old zero-argument `seed()` snapshot accessor with `state()` and +restore with `state(text)`. Numeric construction is explicit; default construction +initializes seed 1. `copy()` remains a convenience equivalent to ordinary copying. -Time and hardware initialization happen once during option parsing and print -`% Random state: -state ...` to standard error. Reuse those state arguments with -the same model and search options. Help also prints the configured initial state. -For complete-state test replay, use the test runner's reported command instead -of a driver command. - -## Migrating from Gecode 6 - -Seeded sequences, bounded-draw consumption, random search trees, and choice -archives change. There is no legacy sequence mode. `Rnd` numeric construction is -explicit and accepts 64 bits. `seed(value)` initializes. Replace the old -zero-argument `seed()` snapshot accessor with `state()`, which returns complete -state text, and use `state(text)` to restore it. -Use `copy()` for an independent generator and ordinary handle copying for shared -standalone use. Binding and space cloning follow the ownership rules above. - -Models using driver options should replace `Rnd(opt.seed())` with `opt.rnd()`. -Each call creates an independent generator at the configured initial state; -retain and reuse that handle when selectors should share a stream. This honors -both seed and state input. The numeric `opt.seed()` accessor remains available -for numeric initialization, but throws when full state, time, or hardware input -was used. This prevents silently ignoring a requested state. - -FlatZinc's restart sampler now uses the common bounded generator directly; its -old 31-bit chunk workaround is removed. These are Gecode 7 compatibility changes. -The feature branch does not change the library's release version or ABI number; -release preparation must apply those changes before shipping. +Seeded sequences and randomized choice archives change. There is no legacy sequence +mode. The old FlatZinc 31-bit sampling workaround is removed. Release version and +ABI-number changes remain release preparation; this feature must not ship in a +compatibility-preserving release. ## Measurements -Build the benchmark against the candidate using the same release compiler flags -as Gecode (adjust library search paths for your platform): +Use the existing bounded benchmark harness after building Gecode: ```sh clang++ -O3 -DNDEBUG -std=c++17 -DRANDOM_NEW -Ibuild/random -I. \ @@ -234,41 +201,38 @@ python3 tools/random-benchmark.py build/random/random-benchmark \ --baseline /path/to/baseline/random-benchmark --repeat 5 --output results.json ``` -For the baseline, compile the same benchmark source against baseline headers and -libraries without `-DRANDOM_NEW`. The script alternates execution order, discards -one warmup, and reports medians while retaining individual measurements in JSON. -Use a new output filename for another run. Avoid concurrent compilation or other -heavy work while collecting timings. - -The controlled search enumerates a complete binary tree of 32767 nodes and 16384 -solutions for every engine and seed, so timings compare overhead without changes -in tree size. The queens case also reports node count: changed random choices can -change its search tree, and wall time alone is not an overhead comparison. Raw -baseline draws emit 32-bit words while the new engines emit 64-bit words. Raw -throughput can also benefit from compiler optimizations that do not apply inside -branchers; the search measurements matter more for the default decision. - -On arm64 macOS 26.6.2 with Apple Clang 21, baseline 6b7de57b04, and seed 42, -five measured repetitions after one warmup gave these medians: - -| Measurement | Baseline | SplitMix with recorded streams | -| --- | ---: | ---: | -| Bounded draw through Rnd | 14.85 ns | 2.96 ns | -| Clone a random space | 178.70 ns | 345.95 ns | -| Binary-tree node, frequent cloning | 153.07 ns | 254.32 ns | -| Binary-tree node, recomputation | 226.76 ns | 368.53 ns | -| Enumerate 10-queens | 20.04 ms | 20.96 ms | - -The controlled random tree is about 1.6x slower: local copying and recorded state -have a cost that faster drawing does not eliminate. Nonrandom tree overhead was -1–3%. Seeds 1 and 1337 gave similar results; queens time increased about 4–5%, -with node counts within 0.1% of baseline. These measurements do not establish a -general speedup or statistical confidence beyond this machine and these cases. - -A binary SplitMix split followed by a draw measured about 15.7 ns; xorshift's -native jump followed by a draw measured about 237 ns. This favors SplitMix as the -default while retaining xorshift for its 8-byte state. The default integer choice -occupies 32 bytes plus a 24-byte snapshot allocation, compared with 24 bytes on -baseline. Archives occupy 32 rather than 12 bytes. No snapshot is allocated for -a nonrandom choice, although its optional pointer costs 8 bytes. Allocator -overhead is additional to these logical sizes. +Compile the same source against baseline headers/libraries without `-DRANDOM_NEW`. +The harness alternates run order, discards one warmup, and reports medians. The +controlled tree always has 32767 nodes and 16384 solutions. Queens also reports +node count because changes in its tree can affect timing. Avoid concurrent +compilation while measuring. + +Measurements after the ownership correction, on arm64 macOS with Apple Clang 21 +in Release mode, use five measured repetitions, one warmup, seed 42, and main +at `6b7de57b04` as the baseline. Sizes are bytes: + +| Object | Main | SplitMix | Xorshift64* | +| --- | ---: | ---: | ---: | +| `Rnd` | 8 (handle) | 16 | 8 | +| Random variable selector | 16 | 24 | 16 | +| Random value selector | 8 | 16 | 8 | +| Integer variable description | 112 | 120 | 112 | +| Integer value description | 80 | 88 | 80 | +| `Space` | 288 | 288 | 288 | +| Nonrandom position/value choice | 24 | 24 | 24 | +| Random choice, one selector | 24 | 48 | 40 | +| Nonrandom choice archive | 12 | 12 | 12 | +| Random choice archive, one selector | 12 | 28 | 20 | + +Main's handle size excludes its separately allocated shared implementation; the +new values contain all engine state. Random choice sizes include the inline +payload. Adding another random selector adds only its state words to that payload. + +Median controlled random-tree time relative to main was 1.02x with cloning and +1.16x with recomputation for SplitMix, versus 1.63x and 2.88x for xorshift64*. +The plain tree was 1.03x and 1.05x in both builds. Indexed binary split-plus-draw +cost about 15.7 ns for SplitMix and 237 ns for xorshift64*. Queens took 20.1 ms +with SplitMix (approximately main's time) and 21.2 ms with xorshift64* (1.06x). +Its node counts were 9333 on main, 9325 with SplitMix, and 9323 with xorshift64*. +These are local measurements, not general performance guarantees. They replace +the earlier space-local results and leave the default-engine decision provisional. diff --git a/examples/job-shop.cpp b/examples/job-shop.cpp index f4a4c385f5..e564c3bac4 100755 --- a/examples/job-shop.cpp +++ b/examples/job-shop.cpp @@ -465,7 +465,7 @@ class JobShopSolve : public JobShopBase { JobShopSolve(const JobShopOptions& o) : JobShopBase(o), sorder(*this, spec.machines()*spec.jobs()*(spec.jobs()-1)/2, 0, 1), - rnd(*this,o.rnd()) { + rnd(o.rnd()) { if (opt.propagation() == PROP_UNARY) nooverload(); @@ -565,7 +565,7 @@ class JobShopSolve : public JobShopBase { JobShopSolve(JobShopSolve& s) : JobShopBase(s), sorder(s.sorder), fst(s.fst), snd(s.snd), iafc(s.iafc), iaction(s.iaction), baction(s.baction), - ichb(s.ichb), bchb(s.bchb), rnd(*this,s.rnd) {} + ichb(s.ichb), bchb(s.bchb), rnd(s.rnd) {} /// Copy during cloning virtual Space* copy(void) { diff --git a/examples/photo.cpp b/examples/photo.cpp index db9631d373..6c9341d25f 100644 --- a/examples/photo.cpp +++ b/examples/photo.cpp @@ -99,7 +99,7 @@ class Photo : public IntMinimizeScript { spec(opt.size()), pos(*this,spec.people(), 0, spec.people()-1), violations(*this,0,spec.preferences()), - rnd(*this,opt.rnd()), p(opt.relax()) + rnd(opt.rnd()), p(opt.relax()) { // Map preferences to violation BoolVarArgs viol(spec.preferences()); @@ -132,8 +132,8 @@ class Photo : public IntMinimizeScript { bool slave(const MetaInfo& mi) { if ((mi.type() == MetaInfo::RESTART) && (mi.restart() > 0) && (p > 0.0)) { - random_split(static_cast(uint64_t(mi.restart())>>32)); - random_split(static_cast(mi.restart())); + rnd = rnd.split(static_cast(uint64_t(mi.restart())>>32)); + rnd = rnd.split(static_cast(mi.restart())); const Photo& l = static_cast(*mi.last()); relax(*this, pos, l.pos, rnd, p); return false; @@ -143,7 +143,7 @@ class Photo : public IntMinimizeScript { } /// Constructor for cloning \a s Photo(Photo& s) : - IntMinimizeScript(s), spec(s.spec), rnd(*this,s.rnd), p(s.p) { + IntMinimizeScript(s), spec(s.spec), rnd(s.rnd), p(s.p) { pos.update(*this, s.pos); violations.update(*this, s.violations); } diff --git a/examples/random-engine.cpp b/examples/random-engine.cpp index b83fdb9e6a..a2582282c1 100644 --- a/examples/random-engine.cpp +++ b/examples/random-engine.cpp @@ -32,6 +32,7 @@ */ #include +#include #include #include #include @@ -64,22 +65,30 @@ class CountedSplitMix { } }; -/// Enumerate permutations using one user stream for both random selectors. +/// Enumerate permutations with independent values of a user-defined engine. class Permutations : public Gecode::Space { Gecode::IntVarArray x; - Gecode::Rnd random; public: - explicit Permutations(const Gecode::Rnd& source) - : x(*this,4,0,3), random(*this,source) { - Gecode::distinct(*this,x); - Gecode::branch(*this,x,Gecode::INT_VAR_RND(random), - Gecode::INT_VAL_RND(random)); + using Random = Gecode::RndGenerator; + explicit Permutations(const Random& source) : x(*this,4,0,3) { + using namespace Gecode; + using namespace Gecode::Int; + distinct(*this,x); + IntVarArgs variables(x); + ViewArray views(*this,variables); + ViewSel* selectors[] = { + new (*this) ViewSelRnd(*this,source) + }; + using Values = ValSelCommit, + Branch::ValCommitEq>; + auto* values = new (*this) Values(*this,INT_VAL_MIN(),source.split(0)); + postviewvalbrancher(*this,views,selectors,values,nullptr,nullptr); } Permutations(Permutations& s) - : Space(s), random(*this,s.random) { x.update(*this,s.x); } + : Space(s) { x.update(*this,s.x); } Gecode::Space* copy(void) override { return new Permutations(*this); } void print(void) const { - std::cout << x << " " << random.state() << '\n'; + std::cout << x << '\n'; } }; @@ -91,7 +100,7 @@ int main(int argc, char* argv[]) { if (argc == 2) engine.state(std::string(argv[1])); std::cout << "Initial state: " << engine.state_string() << '\n'; - Gecode::Rnd source(engine); + Permutations::Random source(engine); auto root = std::make_unique(source); Gecode::DFS search(root.get()); root.reset(); diff --git a/gecode/flatzinc/flatzinc.cpp b/gecode/flatzinc/flatzinc.cpp index 4d38a811c7..52fdbb467d 100644 --- a/gecode/flatzinc/flatzinc.cpp +++ b/gecode/flatzinc/flatzinc.cpp @@ -780,7 +780,7 @@ namespace Gecode { namespace FlatZinc { FlatZincSpace::FlatZincSpace(FlatZincSpace& f) : Space(f), - _initData(nullptr), _random(*this,f._random), + _initData(nullptr), _random(f._random), _solveAnnotations(nullptr), restart_data(f.restart_data), iv_boolalias(nullptr), @@ -864,7 +864,7 @@ namespace Gecode { namespace FlatZinc { : _initData(new FlatZincSpaceInitData), intVarCount(-1), boolVarCount(-1), floatVarCount(-1), setVarCount(-1), _optVar(-1), _optVarIsInt(true), _lns(0), _lnsInitialSolution(0), - _random(*this,random), + _random(random), _solveAnnotations(nullptr), needAuxVars(true) { branchInfo.init(); } @@ -2080,9 +2080,9 @@ namespace Gecode { namespace FlatZinc { // Meta-engine clones start from the master's state. Derive their streams // from logical restart/asset indices, never from worker scheduling. uint64_t index = mi.type()==MetaInfo::RESTART ? mi.restart() : mi.asset(); - random_split(mi.type()==MetaInfo::RESTART ? 0 : 1); - random_split(static_cast(index>>32)); - random_split(static_cast(index)); + _random = _random.split(mi.type()==MetaInfo::RESTART ? 0 : 1); + _random = _random.split(static_cast(index>>32)); + _random = _random.split(static_cast(index)); if (mi.type() == MetaInfo::RESTART) { if (restart_data.initialized() && restart_data().mark_complete) { // Fail the space diff --git a/gecode/float.hh b/gecode/float.hh index 40542fdf9e..8a2fa95b6e 100755 --- a/gecode/float.hh +++ b/gecode/float.hh @@ -2042,7 +2042,7 @@ namespace Gecode { */ GECODE_FLOAT_EXPORT void relax(Home home, const FloatVarArgs& x, const FloatVarArgs& sx, - Rnd r, double p); + Rnd& r, double p); } diff --git a/gecode/float/branch.hh b/gecode/float/branch.hh index 75dcddcdb8..619fe273ed 100644 --- a/gecode/float/branch.hh +++ b/gecode/float/branch.hh @@ -259,6 +259,9 @@ namespace Gecode { namespace Float { namespace Branch { /// The used random number generator Rnd r; public: + unsigned int random_words(void) const { return r.words(); } + uint64_t* random_save(uint64_t* out) const { return r.save(out); } + const uint64_t* random_commit(const uint64_t* in, unsigned int a) { return r.restore_split(in,a); } /// Constructor for initialization ValSelRnd(Space& home, const ValBranch& vb); /// Constructor for cloning diff --git a/gecode/float/branch/val-sel.hpp b/gecode/float/branch/val-sel.hpp index c41a18ec19..f506b99a90 100644 --- a/gecode/float/branch/val-sel.hpp +++ b/gecode/float/branch/val-sel.hpp @@ -77,10 +77,10 @@ namespace Gecode { namespace Float { namespace Branch { forceinline ValSelRnd::ValSelRnd(Space& home, const ValBranch& vb) - : ValSel(home,vb), r(home,vb.rnd()) {} + : ValSel(home,vb), r(vb.rnd()) {} forceinline ValSelRnd::ValSelRnd(Space& home, ValSelRnd& vs) - : ValSel(home,vs), r(home,vs.r) { + : ValSel(home,vs), r(vs.r) { } forceinline FloatNumBranch ValSelRnd::val(const Space&, FloatView x, int) { @@ -90,7 +90,7 @@ namespace Gecode { namespace Float { namespace Branch { } forceinline bool ValSelRnd::notice(void) const { - return true; + return false; } forceinline void ValSelRnd::dispose(Space&) { diff --git a/gecode/float/relax.cpp b/gecode/float/relax.cpp index 0f778084f1..94249926b7 100644 --- a/gecode/float/relax.cpp +++ b/gecode/float/relax.cpp @@ -55,7 +55,7 @@ namespace Gecode { void relax(Home home, const FloatVarArgs& x, const FloatVarArgs& sx, - Rnd r, double p) { + Rnd& r, double p) { if (x.size() != sx.size()) throw Float::ArgumentSizeMismatch("Float::relax"); if ((p < 0.0) || (p > 1.0)) @@ -67,4 +67,3 @@ namespace Gecode { } // STATISTICS: float-other - diff --git a/gecode/int.hh b/gecode/int.hh index 2b6c2ea796..64d04f8976 100755 --- a/gecode/int.hh +++ b/gecode/int.hh @@ -5827,7 +5827,7 @@ namespace Gecode { */ GECODE_INT_EXPORT void relax(Home home, const IntVarArgs& x, const IntVarArgs& sx, - Rnd r, double p); + Rnd& r, double p); /* * \brief Relaxed assignment of variables in \a x from values in \a sx @@ -5852,7 +5852,7 @@ namespace Gecode { */ GECODE_INT_EXPORT void relax(Home home, const BoolVarArgs& x, const BoolVarArgs& sx, - Rnd r, double p); + Rnd& r, double p); } diff --git a/gecode/int/branch.hh b/gecode/int/branch.hh index 1192ee1282..7ccebbd09c 100755 --- a/gecode/int/branch.hh +++ b/gecode/int/branch.hh @@ -349,13 +349,18 @@ namespace Gecode { namespace Int { namespace Branch { * Requires \code #include \endcode * \ingroup FuncIntValSel */ - template + template class ValSelRnd : public ValSel { using typename ValSel::Var; protected: /// The used random number generator - Rnd r; + Random r; public: + ValSelRnd(Space& home, const Random& random) + : ValSel(home,ValBranch()), r(random) {} + unsigned int random_words(void) const { return r.words(); } + uint64_t* random_save(uint64_t* out) const { return r.save(out); } + const uint64_t* random_commit(const uint64_t* in, unsigned int a) { return r.restore_split(in,a); } /// Constructor for initialization ValSelRnd(Space& home, const ValBranch& vb); /// Constructor for cloning diff --git a/gecode/int/branch/val-sel.hpp b/gecode/int/branch/val-sel.hpp index 0e2e172796..7d5af94453 100755 --- a/gecode/int/branch/val-sel.hpp +++ b/gecode/int/branch/val-sel.hpp @@ -93,19 +93,19 @@ namespace Gecode { namespace Int { namespace Branch { return (x.width() == 2U) ? x.min() : ((x.min()+x.max()) / 2); } - template + template forceinline - ValSelRnd::ValSelRnd - (Space& home, const ValBranch::Var>& vb) - : ValSel(home,vb), r(home,vb.rnd()) {} - template + ValSelRnd::ValSelRnd + (Space& home, const ValBranch::Var>& vb) + : ValSel(home,vb), r(vb.rnd()) {} + template forceinline - ValSelRnd::ValSelRnd(Space& home, ValSelRnd& vs) - : ValSel(home,vs), r(home,vs.r) { + ValSelRnd::ValSelRnd(Space& home, ValSelRnd& vs) + : ValSel(home,vs), r(vs.r) { } - template + template forceinline int - ValSelRnd::val(const Space&, View x, int) { + ValSelRnd::val(const Space&, View x, int) { unsigned int p = r(x.size()); for (ViewRanges i(x); i(); ++i) { if (i.width() > p) @@ -115,15 +115,15 @@ namespace Gecode { namespace Int { namespace Branch { GECODE_NEVER; return 0; } - template + template forceinline bool - ValSelRnd::notice(void) const { - return true; + ValSelRnd::notice(void) const { + return !std::is_trivially_destructible::value; } - template + template forceinline void - ValSelRnd::dispose(Space&) { - r.~Rnd(); + ValSelRnd::dispose(Space&) { + r.~Random(); } forceinline diff --git a/gecode/int/branch/view-values.cpp b/gecode/int/branch/view-values.cpp index 7d9686753d..0d0d122e5f 100644 --- a/gecode/int/branch/view-values.cpp +++ b/gecode/int/branch/view-values.cpp @@ -48,6 +48,7 @@ namespace Gecode { namespace Int { namespace Branch { w += r.width(); i++; } pm[i].pos = w; + pm[i].min = 0; } PosValuesChoice::PosValuesChoice(const Brancher& b, unsigned int a, Pos p, @@ -65,7 +66,7 @@ namespace Gecode { namespace Int { namespace Branch { heap.free(pm,n+1); } - forceinline void + void PosValuesChoice::archive(Archive& e) const { PosChoice::archive(e); e << this->alternatives() << n; diff --git a/gecode/int/branch/view-values.hpp b/gecode/int/branch/view-values.hpp index 124acac92f..02263d320a 100644 --- a/gecode/int/branch/view-values.hpp +++ b/gecode/int/branch/view-values.hpp @@ -123,8 +123,14 @@ namespace Gecode { namespace Int { namespace Branch { const Choice* ViewValuesBrancher::choice(Space& home) { Pos p = ViewBrancher::pos(home); - return new PosValuesChoice(*this,p, - ViewBrancher::view(p)); + unsigned int words = this->random_words(); + auto view = ViewBrancher::view(p); + if (!words) + return new PosValuesChoice(*this,p,view); + std::unique_ptr> c( + new (words) RndChoice(words,*this,p,view)); + this->random_save(c->data()); + return c.release(); } template @@ -135,7 +141,13 @@ namespace Gecode { namespace Int { namespace Branch { int p; unsigned int a; e >> p >> a; - return new PosValuesChoice(*this,a,p,e); + unsigned int words = this->random_words(); + if (!words) + return new PosValuesChoice(*this,a,p,e); + std::unique_ptr> c( + new (words) RndChoice(words,*this,a,p,e)); + c->read(e); + return c.release(); } template @@ -144,6 +156,8 @@ namespace Gecode { namespace Int { namespace Branch { unsigned int a) { const PosValuesChoice& pvc = static_cast(c); + if (this->random_words()) + this->random_commit(pvc.random_data(),a); IntView x(ViewBrancher::view(pvc.pos())); unsigned int b = min ? a : (pvc.alternatives() - 1 - a); return me_failed(x.eq(home,pvc.val(b))) ? ES_FAILED : ES_OK; diff --git a/gecode/int/ldsb/brancher.hpp b/gecode/int/ldsb/brancher.hpp index e9569c4799..d7de70ddd7 100755 --- a/gecode/int/ldsb/brancher.hpp +++ b/gecode/int/ldsb/brancher.hpp @@ -189,7 +189,14 @@ namespace Gecode { namespace Int { namespace LDSB { ++it; } - return new LDSBChoice(*this,a,choicePos,choiceVal, literals, nliterals); + unsigned int words = this->random_words()+this->vsc->random_words(); + if (!words) + return new LDSBChoice(*this,a,choicePos,choiceVal,literals,nliterals); + std::unique_ptr>> result( + new (words) RndChoice> + (words,*this,a,choicePos,choiceVal,literals,nliterals)); + this->vsc->random_save(this->random_save(result->data())); + return result.release(); } @@ -207,7 +214,13 @@ namespace Gecode { namespace Int { namespace LDSB { e >> literals[i]._variable; e >> literals[i]._value; } - return new LDSBChoice(*this,a,p,v, literals, nliterals); + unsigned int words = this->random_words()+this->vsc->random_words(); + if (!words) + return new LDSBChoice(*this,a,p,v,literals,nliterals); + std::unique_ptr>> result( + new (words) RndChoice>(words,*this,a,p,v,literals,nliterals)); + result->read(e); + return result.release(); } template <> diff --git a/gecode/int/relax.cpp b/gecode/int/relax.cpp index cb06c91d83..cbc7f31cfc 100644 --- a/gecode/int/relax.cpp +++ b/gecode/int/relax.cpp @@ -63,7 +63,7 @@ namespace Gecode { void relax(Home home, const IntVarArgs& x, const IntVarArgs& sx, - Rnd r, double p) { + Rnd& r, double p) { if (x.size() != sx.size()) throw Int::ArgumentSizeMismatch("Int::relax"); if ((p < 0.0) || (p > 1.0)) @@ -74,7 +74,7 @@ namespace Gecode { void relax(Home home, const BoolVarArgs& x, const BoolVarArgs& sx, - Rnd r, double p) { + Rnd& r, double p) { if (x.size() != sx.size()) throw Int::ArgumentSizeMismatch("Int::relax"); if ((p < 0.0) || (p > 1.0)) @@ -86,4 +86,3 @@ namespace Gecode { } // STATISTICS: int-other - diff --git a/gecode/kernel/branch/val-sel-commit.hpp b/gecode/kernel/branch/val-sel-commit.hpp index 8a21e86fdb..6671a69c0f 100644 --- a/gecode/kernel/branch/val-sel-commit.hpp +++ b/gecode/kernel/branch/val-sel-commit.hpp @@ -43,6 +43,9 @@ namespace Gecode { template class ValSelCommitBase { public: + virtual unsigned int random_words(void) const { return 0; } + virtual uint64_t* random_save(uint64_t* out) const { return out; } + virtual const uint64_t* random_commit(const uint64_t* in, unsigned int) { return in; } /// View type typedef View_ View; /// Corresponding variable type @@ -101,8 +104,17 @@ namespace Gecode { /// The commit object used ValCommit c; public: + unsigned int random_words(void) const override { return s.random_words(); } + uint64_t* random_save(uint64_t* out) const override { return s.random_save(out); } + const uint64_t* random_commit(const uint64_t* in, unsigned int a) override { + return s.random_commit(in,a); + } /// Constructor for initialization ValSelCommit(Space& home, const ValBranch& vb); + /// Construct a user-parameterized selector with an ordinary commit policy. + template + ValSelCommit(Space& home, const ValBranch& vb, const Random& random) + : ValSelCommitBase(home,vb), s(home,random), c(home,vb) {} /// Constructor for cloning ValSelCommit(Space& home, ValSelCommit& vsc); /// Return value of view \a x at position \a i diff --git a/gecode/kernel/branch/val-sel.hpp b/gecode/kernel/branch/val-sel.hpp index 6f21c02fc3..5fa821eb87 100755 --- a/gecode/kernel/branch/val-sel.hpp +++ b/gecode/kernel/branch/val-sel.hpp @@ -43,6 +43,9 @@ namespace Gecode { template class ValSel { public: + unsigned int random_words(void) const { return 0; } + uint64_t* random_save(uint64_t* out) const { return out; } + const uint64_t* random_commit(const uint64_t* in, unsigned int) { return in; } /// View type typedef View_ View; /// Corresponding variable type diff --git a/gecode/kernel/branch/val.hpp b/gecode/kernel/branch/val.hpp index fa43814a51..31f7215d41 100644 --- a/gecode/kernel/branch/val.hpp +++ b/gecode/kernel/branch/val.hpp @@ -75,10 +75,7 @@ namespace Gecode { template inline ValBranch::ValBranch(Rnd r0) - : r(r0), vf(nullptr), cf(nullptr) { - if (!r) - throw UninitializedRnd("ValBranch::ValBranch"); - } + : r(r0), vf(nullptr), cf(nullptr) {} template inline diff --git a/gecode/kernel/branch/var.hpp b/gecode/kernel/branch/var.hpp index 5107400d5d..a7e20497fa 100644 --- a/gecode/kernel/branch/var.hpp +++ b/gecode/kernel/branch/var.hpp @@ -153,10 +153,7 @@ namespace Gecode { template inline VarBranch::VarBranch(Rnd r) - : _tbl(nullptr), _rnd(r), _decay(1.0) { - if (!_rnd) - throw UninitializedRnd("VarBranch::VarBranch"); - } + : _tbl(nullptr), _rnd(r), _decay(1.0) {} template inline diff --git a/gecode/kernel/branch/view-sel.hpp b/gecode/kernel/branch/view-sel.hpp index 1c0620c5a4..4beb10f1be 100644 --- a/gecode/kernel/branch/view-sel.hpp +++ b/gecode/kernel/branch/view-sel.hpp @@ -43,6 +43,10 @@ namespace Gecode { template class ViewSel { public: + /// State owned by this selector, recorded only by its brancher. + virtual unsigned int random_words(void) const { return 0; } + virtual uint64_t* random_save(uint64_t* out) const { return out; } + virtual const uint64_t* random_commit(const uint64_t* in, unsigned int) { return in; } /// Define the view type typedef View_ View; /// The corresponding variable type @@ -145,19 +149,27 @@ namespace Gecode { }; /// Select a view randomly - template + template class ViewSelRnd : public ViewSel { protected: typedef typename ViewSel::Var Var; /// The random number generator used - Rnd r; + Random r; public: + unsigned int random_words(void) const override { return r.words(); } + uint64_t* random_save(uint64_t* out) const override { return r.save(out); } + const uint64_t* random_commit(const uint64_t* in, unsigned int a) override { + return r.restore_split(in,a); + } + /// Construct a selector with a user-supplied value-type generator. + ViewSelRnd(Space& home, const Random& random) + : ViewSel(home,VarBranch()), r(random) {} /// \name Initialization //@{ /// Constructor for creation ViewSelRnd(Space& home, const VarBranch& vb); /// Constructor for copying during cloning - ViewSelRnd(Space& home, ViewSelRnd& vs); + ViewSelRnd(Space& home, ViewSelRnd& vs); //@} /// \name View selection and tie breaking //@{ @@ -479,17 +491,17 @@ namespace Gecode { } - template + template forceinline - ViewSelRnd::ViewSelRnd(Space& home, const VarBranch& vb) - : ViewSel(home,vb), r(home,vb.rnd()) {} - template + ViewSelRnd::ViewSelRnd(Space& home, const VarBranch& vb) + : ViewSel(home,vb), r(vb.rnd()) {} + template forceinline - ViewSelRnd::ViewSelRnd(Space& home, ViewSelRnd& vs) - : ViewSel(home,vs), r(home,vs.r) {} - template + ViewSelRnd::ViewSelRnd(Space& home, ViewSelRnd& vs) + : ViewSel(home,vs), r(vs.r) {} + template int - ViewSelRnd::select(Space&, ViewArray& x, int s) { + ViewSelRnd::select(Space&, ViewArray& x, int s) { unsigned int n=1; int j=s; for (int i=s+1; i + template int - ViewSelRnd::select(Space& home, ViewArray& x, int s, + ViewSelRnd::select(Space& home, ViewArray& x, int s, BrancherFilter& f) { unsigned int n=1; int j=s; @@ -514,44 +526,44 @@ namespace Gecode { } return j; } - template + template void - ViewSelRnd::ties(Space& home, ViewArray& x, int s, + ViewSelRnd::ties(Space& home, ViewArray& x, int s, int* ties, int& n) { n=1; ties[0] = select(home,x,s); } - template + template void - ViewSelRnd::ties(Space& home, ViewArray& x, int s, + ViewSelRnd::ties(Space& home, ViewArray& x, int s, int* ties, int& n, BrancherFilter& f) { n=1; ties[0] = select(home,x,s,f); } - template + template void - ViewSelRnd::brk(Space&, ViewArray&, int* ties, int& n) { + ViewSelRnd::brk(Space&, ViewArray&, int* ties, int& n) { ties[0] = ties[static_cast(r(static_cast(n)))]; n=1; } - template + template int - ViewSelRnd::select(Space&, ViewArray&, int* ties, int n) { + ViewSelRnd::select(Space&, ViewArray&, int* ties, int n) { return ties[static_cast(r(static_cast(n)))]; } - template + template ViewSel* - ViewSelRnd::copy(Space& home) { - return new (home) ViewSelRnd(home,*this); + ViewSelRnd::copy(Space& home) { + return new (home) ViewSelRnd(home,*this); } - template + template forceinline bool - ViewSelRnd::notice(void) const { - return true; + ViewSelRnd::notice(void) const { + return !std::is_trivially_destructible::value; } - template + template forceinline void - ViewSelRnd::dispose(Space&) { - r.~Rnd(); + ViewSelRnd::dispose(Space&) { + r.~Random(); } diff --git a/gecode/kernel/branch/view-val.hpp b/gecode/kernel/branch/view-val.hpp index 333d014cfd..33f63f1e38 100644 --- a/gecode/kernel/branch/view-val.hpp +++ b/gecode/kernel/branch/view-val.hpp @@ -271,7 +271,14 @@ namespace Gecode { ViewValBrancher::choice(Space& home) { Pos p = ViewBrancher::pos(home); View v = ViewBrancher::view(p); - return new PosValChoice(*this,a,p,vsc->val(home,v,p.pos)); + Val value = vsc->val(home,v,p.pos); + unsigned int words = this->random_words()+vsc->random_words(); + if (!words) + return new PosValChoice(*this,a,p,value); + std::unique_ptr>> c( + new (words) RndChoice>(words,*this,a,p,value)); + vsc->random_save(this->random_save(c->data())); + return c.release(); } template> p; Val v; e >> v; - return new PosValChoice(*this,a,p,v); + unsigned int words = this->random_words()+vsc->random_words(); + if (!words) + return new PosValChoice(*this,a,p,v); + std::unique_ptr>> c( + new (words) RndChoice>(words,*this,a,p,v)); + c->read(e); + return c.release(); } template& pvc = static_cast&>(c); + if (this->random_words()+vsc->random_words()) { + assert(pvc.random_data() != nullptr); + vsc->random_commit(this->random_commit(pvc.random_data(),b),b); + } return me_failed(vsc->commit(home,b, ViewBrancher::view(pvc.pos()), pvc.pos().pos, diff --git a/gecode/kernel/branch/view.hpp b/gecode/kernel/branch/view.hpp index 8bdca1d1e1..1700c7d632 100644 --- a/gecode/kernel/branch/view.hpp +++ b/gecode/kernel/branch/view.hpp @@ -31,6 +31,8 @@ * */ +#include + namespace Gecode { /** @@ -64,10 +66,46 @@ namespace Gecode { PosChoice(const Brancher& b, unsigned int a, const Pos& p); /// Return position in array const Pos& pos(void) const; + /// Random selector state, present only in randomized brancher choices. + virtual const uint64_t* random_data(void) const { return nullptr; } /// Archive into \a e virtual void archive(Archive& e) const; }; + /// Randomized brancher choice with state words in the same allocation. + /// Nonrandom branchers use the original choice type without this payload. + template + class alignas(uint64_t) RndChoice : public Base { + unsigned int count; + public: + template + RndChoice(unsigned int words, Args&&... args) + : Base(std::forward(args)...), count(words) { + std::uninitialized_default_construct_n(data(),count); + } + RndChoice(const RndChoice&) = delete; + static void* operator new(size_t size, unsigned int words) { + return ::operator new(size+size_t(words)*sizeof(uint64_t)); + } + static void operator delete(void* p) { ::operator delete(p); } + static void operator delete(void* p, unsigned int) { ::operator delete(p); } + uint64_t* data(void) { return reinterpret_cast(this+1); } + const uint64_t* data(void) const { return reinterpret_cast(this+1); } + const uint64_t* random_data(void) const override { return data(); } + void read(Archive& e) { + for (unsigned int i=0; i> lo >> hi; + data()[i] = uint64_t(lo) | (uint64_t(hi)<<32); + } + } + void archive(Archive& e) const override { + Base::archive(e); + for (unsigned int i=0; i(data()[i]) + << static_cast(data()[i]>>32); + } + }; + /** * \brief Generic brancher by view selection * @@ -85,6 +123,20 @@ namespace Gecode { mutable int start; /// View selection objects ViewSel* vs[n]; + /// Compact state of this brancher's variable selectors only. + unsigned int random_words(void) const { + unsigned int words=0; + for (int i=0; irandom_words(); + return words; + } + uint64_t* random_save(uint64_t* out) const { + for (int i=0; irandom_save(out); + return out; + } + const uint64_t* random_commit(const uint64_t* in, unsigned int a) { + for (int i=0; irandom_commit(in,a); + return in; + } /// Filter function Filter f; /// Return position information diff --git a/gecode/kernel/core.cpp b/gecode/kernel/core.cpp index c5a40bdcd3..4293cfe6a8 100644 --- a/gecode/kernel/core.cpp +++ b/gecode/kernel/core.cpp @@ -235,7 +235,6 @@ namespace Gecode { if (_vars_d[i] != nullptr) vd[i]->dispose(*this, _vars_d[i]); #endif - delete randoms; // Release memory from memory manager mm.release(ssd.data().sm); } @@ -595,35 +594,16 @@ namespace Gecode { } // Make sure that b_commit does not point to a deleted brancher! b_commit = b_status; - std::unique_ptr c(b_status->choice(*this)); - if (randoms) - const_cast(c.get())->random_state = randoms->snapshot(); - return c.release(); + return b_status->choice(*this); } const Choice* Space::choice(Archive& e) const { unsigned int id; e >> id; - unsigned int n; e >> n; - std::unique_ptr data; - if (n) { - if (n > static_cast((e.size()-2)/2)) - throw std::invalid_argument("Invalid random choice archive size"); - data = std::make_unique(size_t(n)+1); - data[0]=n; - for (unsigned int i=0; i> lo >> hi; - data[i+1] = uint64_t(lo) | (uint64_t(hi)<<32); - } - } Brancher* b_cur = Brancher::cast(bl.next()); while (b_cur != Brancher::cast(&bl)) { - if (id == b_cur->id()) { - const Choice* c = b_cur->choice(*this,e); - const_cast(c)->random_state = data.release(); - return c; - } + if (id == b_cur->id()) + return b_cur->choice(*this,e); b_cur = Brancher::cast(b_cur->next()); } throw SpaceNoBrancher("Space::choice"); @@ -636,11 +616,6 @@ namespace Gecode { if (failed()) return; if (Brancher* b = brancher(c.bid)) { - if (c.random_state) { - if (!randoms) - throw std::invalid_argument("Random choice requires space streams"); - randoms->commit(c.random_state,a); - } // There is a matching brancher if (pc.p.bid_sc & sc_trace) { TraceRecorder* tr = findtracerecorder(); @@ -672,11 +647,6 @@ namespace Gecode { if (failed()) return; if (Brancher* b = brancher(c.bid)) { - if (c.random_state) { - if (!randoms) - throw std::invalid_argument("Random choice requires space streams"); - randoms->commit(c.random_state,a); - } // There is a matching brancher if (pc.p.bid_sc & sc_trace) { TraceRecorder* tr = findtracerecorder(); @@ -771,8 +741,6 @@ namespace Gecode { pl.init(); bl.init(); b_status = b_commit = Brancher::cast(&bl); - if (s.randoms) - randoms = new RandomContext(*s.randoms); // Copy all propagators { ActorLink* p = &pl; @@ -817,7 +785,6 @@ namespace Gecode { } catch (...) { recover(s); pc.c.source = nullptr; - delete randoms; mm.release(ssd.data().sm); throw; } @@ -978,11 +945,6 @@ namespace Gecode { void Choice::archive(Archive& e) const { e << id(); - unsigned int n = random_state ? static_cast(random_state[0]) : 0; - e << n; - for (unsigned int i=0; i(random_state[i+1]) - << static_cast(random_state[i+1]>>32); } bool diff --git a/gecode/kernel/core.hpp b/gecode/kernel/core.hpp index aefcfb871f..169aab6599 100755 --- a/gecode/kernel/core.hpp +++ b/gecode/kernel/core.hpp @@ -144,8 +144,6 @@ namespace Gecode { class Advisor; class AFC; class Choice; - class Rnd; - class RandomContext; class Brancher; class Group; class PropagatorGroup; @@ -1427,10 +1425,6 @@ namespace Gecode { private: unsigned int bid; ///< Identity to match creating brancher unsigned int alt; ///< Number of alternatives - /// Optional packed random-state snapshot (independent of alternative count) - uint64_t* random_state; - Choice(const Choice&) = delete; - Choice& operator =(const Choice&) = delete; /// Return id of the creating brancher unsigned int id(void) const; @@ -1801,8 +1795,6 @@ namespace Gecode { Kernel::SharedSpaceData ssd; /// Performs memory management for space Kernel::MemoryManager mm; - /// Lazily allocated random streams, owned by this space - RandomContext* randoms = nullptr; #ifdef GECODE_HAS_CBS /// Global counter for variable ids unsigned int var_id_counter; @@ -2045,12 +2037,6 @@ namespace Gecode { GECODE_KERNEL_EXPORT void ap_ignore_dispose(Actor* a, bool d); public: - /// Bind a random handle to this space, mapping local handles during cloning - GECODE_KERNEL_EXPORT Rnd random(const Rnd& source); - /// Access an already bound stream, for callbacks receiving a const space - GECODE_KERNEL_EXPORT Rnd random(const Rnd& source) const; - /// Explicitly split all bound streams, for restart/portfolio initialization - GECODE_KERNEL_EXPORT void random_split(uint32_t alternative); /** * \brief Default constructor * \ingroup TaskModelScript @@ -3899,7 +3885,7 @@ namespace Gecode { */ forceinline Choice::Choice(const Brancher& b, const unsigned int a) - : bid(b.id()), alt(a), random_state(nullptr) {} + : bid(b.id()), alt(a) {} forceinline unsigned int Choice::alternatives(void) const { @@ -3912,7 +3898,7 @@ namespace Gecode { } forceinline - Choice::~Choice(void) { delete[] random_state; } + Choice::~Choice(void) {} diff --git a/gecode/kernel/data/rnd.cpp b/gecode/kernel/data/rnd.cpp index ef26d92a92..98544a51b9 100644 --- a/gecode/kernel/data/rnd.cpp +++ b/gecode/kernel/data/rnd.cpp @@ -35,60 +35,4 @@ #include -namespace Gecode { - Rnd::Rnd(Space& home, const Rnd& source) - : SharedHandle(home.random(source)) {} - Rnd::Rnd(const Space& home, const Rnd& source) - : SharedHandle(home.random(source)) {} - - void Rnd::seed(uint64_t value) { - if (!object()) - *this = Rnd(value); - else - imp().seed(value); - } - - void Rnd::state(const std::string& text) { - if (!object()) { - Rnd candidate(1); - candidate.state(text); - *this = candidate; - } else { - imp().state(text); - } - } - - void Rnd::time(void) { - seed(static_cast(::time(nullptr))); - } - - void Rnd::hw(void) { - seed((uint64_t(Support::hwrnd()) << 32) | Support::hwrnd()); - } - - Rnd Space::random(const Rnd& source) { - if (!source) - throw UninitializedRnd("Space::random"); - if (!randoms) - randoms = new RandomContext; - return randoms->bind(source); - } - - Rnd Space::random(const Rnd& source) const { - if (randoms) { - size_t i = randoms->find(source); - if (i < randoms->size()) - return randoms->at(i); - } - throw UninitializedRnd("Space::random: stream is not bound"); - } - - void Space::random_split(uint32_t alternative) { - if (randoms) { - std::unique_ptr snapshot(randoms->snapshot()); - randoms->commit(snapshot.get(),alternative); - } - } -} - // STATISTICS: kernel-other diff --git a/gecode/kernel/data/rnd.hpp b/gecode/kernel/data/rnd.hpp index 1b17d11aa3..f3cfa8f1d9 100755 --- a/gecode/kernel/data/rnd.hpp +++ b/gecode/kernel/data/rnd.hpp @@ -2,9 +2,11 @@ /* * Main authors: * Christian Schulte + * Mikael Zayenz Lagerkvist * * Copyright: * Christian Schulte, 2008 + * Mikael Zayenz Lagerkvist, 2026 * * This file is part of Gecode, the generic constraint * development environment: @@ -32,187 +34,58 @@ */ #include -#include -#include namespace Gecode { - class RandomContext; - /** - * \brief Handle to a random stream + * \brief Small value-type random generator owned by its consumer * - * Standalone handles share a stream. Binding to a space creates a local - * stream, shared by handles bound from the same source. Space clones have - * independent stream state. Use copy() for an independent standalone copy. - * A stream must not be drawn from concurrently. + * Copying copies complete state. There is no shared handle, registration, + * or implicit connection to a space. Engine supplies the Support::Random + * contract, including indexed splitting. * \ingroup TaskModel */ - class Rnd : public SharedHandle { - friend class RandomContext; - private: - class Origin : public SharedHandle { - public: - Origin(void) = default; - explicit Origin(Object* value) : SharedHandle(value) {} - const Object* get(void) const { return object(); } - }; - class IMP : public SharedHandle::Object { - public: - Origin origin; - const Object* identity(void) const { - return origin ? origin.get() : this; - } - virtual IMP* copy(void) const = 0; - virtual IMP* split(uint32_t a) const = 0; - virtual void seed(uint64_t value) = 0; - virtual uint64_t draw(uint64_t bound) = 0; - virtual size_t words(void) const = 0; - virtual void save(uint64_t* out) const = 0; - virtual void commit(const uint64_t* in, uint32_t a) = 0; - virtual std::string state(void) const = 0; - virtual void state(const std::string& text) = 0; - virtual const char* name(void) const = 0; - }; - template - class Implementation : public IMP { - Support::Random r; - public: - explicit Implementation(const Support::Random& value) : r(value) {} - IMP* copy(void) const override { return new Implementation(r); } - IMP* split(uint32_t a) const override { - return new Implementation(r.split(a)); - } - void seed(uint64_t value) override { r.seed(value); } - uint64_t draw(uint64_t bound) override { return r(bound); } - size_t words(void) const override { - return std::tuple_size::value; - } - void save(uint64_t* out) const override { - auto s = r.state(); - std::copy(s.begin(),s.end(),out); - } - void commit(const uint64_t* in, uint32_t a) override { - typename Engine::State s; - std::copy(in,in+s.size(),s.begin()); - auto parent = r; - parent.state(s); - r = parent.split(a); - } - std::string state(void) const override { return r.state_string(); } - void state(const std::string& text) override { r.state(text); } - const char* name(void) const override { return Engine::name(); } - }; - IMP& imp(void) const { - if (!object()) - throw UninitializedRnd("Rnd"); - return *static_cast(object()); - } - Rnd(IMP* value, bool) : SharedHandle(value) {} - Rnd local_copy(void) const { - Rnd result(imp().copy(),true); - result.imp().origin = imp().origin ? imp().origin : Origin(&imp()); - return result; - } + template + class RndGenerator { + Support::Random r; public: - /// Uninitialized handle - Rnd(void) = default; - /// Share an existing handle - Rnd(const Rnd&) = default; - Rnd& operator =(const Rnd&) = default; - ~Rnd(void) = default; - /// Create a standalone default stream - explicit Rnd(uint64_t seed) - : Rnd(Support::RandomGenerator(seed)) {} - /// Create a standalone user-defined splittable stream - template - explicit Rnd(const Support::Random& r) - : SharedHandle(new Implementation(r)) {} - /// Bind a source stream to a space, or update its handle during cloning - GECODE_KERNEL_EXPORT Rnd(Space& home, const Rnd& source); - /// Access an already bound stream through a const space - GECODE_KERNEL_EXPORT Rnd(const Space& home, const Rnd& source); - /// Create and bind a default stream to a space - Rnd(Space& home, uint64_t seed) : Rnd(home,Rnd(seed)) {} - /// Make an independent exact copy; does not split or advance the source - Rnd copy(void) const { return Rnd(imp().copy(),true); } - /// Derive an alternative stream without modifying this stream - Rnd split(uint32_t a) const { return Rnd(imp().split(a),true); } - /// Seed this stream (initializes a default engine if uninitialized) - GECODE_KERNEL_EXPORT void seed(uint64_t value); - /// Initialize using time or hardware entropy - GECODE_KERNEL_EXPORT void time(void); - GECODE_KERNEL_EXPORT void hw(void); - /// Complete state, with algorithm identifier - std::string state(void) const { return imp().state(); } - /// Restore a state for this engine (default engine if uninitialized) - GECODE_KERNEL_EXPORT void state(const std::string& text); - const char* name(void) const { return imp().name(); } - /// Number of 64-bit words in the engine state - size_t words(void) const { return imp().words(); } - /// Draw an integer in [0,bound); bounds <= 1 consume no values - template - Type operator ()(Type bound) { - static_assert(std::is_integral::value && sizeof(Type)<=8, - "Random bound must be an integer of at most 64 bits"); - return bound<=1 ? 0 : - static_cast(imp().draw(static_cast(bound))); + using State = typename Engine::State; + explicit RndGenerator(uint64_t seed=1) : r(seed) {} + explicit RndGenerator(const Support::Random& source) : r(source) {} + RndGenerator copy(void) const { return *this; } + RndGenerator split(uint32_t a) const { return RndGenerator(r.split(a)); } + void seed(uint64_t value) { r.seed(value); } + void time(void) { seed(static_cast(::time(nullptr))); } + void hw(void) { + seed((uint64_t(Support::hwrnd()) << 32) | Support::hwrnd()); } - }; - - /// Internal space-local stream storage. Local handles retain their stream origin. - class RandomContext { - std::vector streams; - public: - RandomContext(void) = default; - RandomContext(const RandomContext& other) { - streams.reserve(other.streams.size()); - for (const auto& entry : other.streams) - streams.push_back(entry.local_copy()); - } - size_t find(const Rnd& source) const { - for (size_t i=0; i(n+1); - data[0]=n; - size_t pos=1; - for (const auto& entry : streams) { - entry.imp().save(data.get()+pos); - pos += entry.words(); - } - return data.release(); + std::string state(void) const { return r.state_string(); } + void state(const std::string& text) { r.state(text); } + State state_words(void) const { return r.state(); } + void state(const State& words) { r.state(words); } + static const char* name(void) { return Engine::name(); } + static constexpr unsigned int words(void) { return std::tuple_size::value; } + template + Type operator ()(Type bound) { return r(bound); } + /// Save compact choice data and advance the output pointer. + uint64_t* save(uint64_t* out) const { + auto s = r.state(); + return std::copy(s.begin(),s.end(),out); } - /// Restore and split the recorded streams; layout follows registration order. - void commit(const uint64_t* data, uint32_t a) { - size_t n=0; - for (const auto& entry : streams) - n += entry.words(); - if (data[0] != n) - throw std::invalid_argument("Random choice does not match space streams"); - size_t pos=1; - for (auto& entry : streams) { - entry.imp().commit(data+pos,a); - pos += entry.words(); - } + /// Derive this consumer's next state from its recorded choice data. + const uint64_t* restore_split(const uint64_t* in, uint32_t a) { + State s; + std::copy(in,in+s.size(),s.begin()); + auto parent = r; + parent.state(s); + r = parent.split(a); + return in+s.size(); } }; + + /// Build-configured default, with exactly the engine's inline state size. + using Rnd = RndGenerator; + } // STATISTICS: kernel-other diff --git a/gecode/search/relax.hh b/gecode/search/relax.hh index dcd5bb9f36..ecf2b03a18 100755 --- a/gecode/search/relax.hh +++ b/gecode/search/relax.hh @@ -42,17 +42,16 @@ namespace Gecode { namespace Search { /// Relax variables in \a x from solution \a sx with probability \a p template forceinline void - relax(Home home, const VarArgs& x, const VarArgs& sx, Rnd r, + relax(Home home, const VarArgs& x, const VarArgs& sx, Rnd& r, double p, Post& post); template forceinline void - relax(Home home, const VarArgs& x, const VarArgs& sx, Rnd r, + relax(Home home, const VarArgs& x, const VarArgs& sx, Rnd& r, double p, Post& post) { if (home.failed()) return; - r = Rnd(static_cast(home),r); Region reg; // Which variables to assign Support::BitSet ax(reg, static_cast(x.size())); diff --git a/gecode/set.hh b/gecode/set.hh index 331e8bf074..566caee175 100755 --- a/gecode/set.hh +++ b/gecode/set.hh @@ -1741,7 +1741,7 @@ namespace Gecode { */ GECODE_SET_EXPORT void relax(Home home, const SetVarArgs& x, const SetVarArgs& sx, - Rnd r, double p); + Rnd& r, double p); } diff --git a/gecode/set/branch.hh b/gecode/set/branch.hh index 4a9f678e46..5d73652e63 100644 --- a/gecode/set/branch.hh +++ b/gecode/set/branch.hh @@ -279,6 +279,9 @@ namespace Gecode { namespace Set { namespace Branch { /// The used random number generator Rnd r; public: + unsigned int random_words(void) const { return r.words(); } + uint64_t* random_save(uint64_t* out) const { return r.save(out); } + const uint64_t* random_commit(const uint64_t* in, unsigned int a) { return r.restore_split(in,a); } /// Constructor for initialization ValSelRnd(Space& home, const ValBranch& vb); /// Constructor for cloning @@ -414,4 +417,3 @@ namespace Gecode { namespace Set { namespace Branch { #endif // STATISTICS: set-branch - diff --git a/gecode/set/branch/val-sel.hpp b/gecode/set/branch/val-sel.hpp index 51fbf7db00..972fe154c8 100644 --- a/gecode/set/branch/val-sel.hpp +++ b/gecode/set/branch/val-sel.hpp @@ -91,10 +91,10 @@ namespace Gecode { namespace Set { namespace Branch { forceinline ValSelRnd::ValSelRnd(Space& home, const ValBranch& vb) - : ValSel(home,vb), r(home,vb.rnd()) {} + : ValSel(home,vb), r(vb.rnd()) {} forceinline ValSelRnd::ValSelRnd(Space& home, ValSelRnd& vs) - : ValSel(home,vs), r(home,vs.r) { + : ValSel(home,vs), r(vs.r) { } forceinline int ValSelRnd::val(const Space&, SetView x, int) { @@ -110,7 +110,7 @@ namespace Gecode { namespace Set { namespace Branch { } forceinline bool ValSelRnd::notice(void) const { - return true; + return false; } forceinline void ValSelRnd::dispose(Space&) { diff --git a/gecode/set/relax.cpp b/gecode/set/relax.cpp index 21f3a3b726..6b6594e50b 100644 --- a/gecode/set/relax.cpp +++ b/gecode/set/relax.cpp @@ -60,7 +60,7 @@ namespace Gecode { void relax(Home home, const SetVarArgs& x, const SetVarArgs& sx, - Rnd r, double p) { + Rnd& r, double p) { if (x.size() != sx.size()) throw Set::ArgumentSizeMismatch("Set::relax"); if ((p < 0.0) || (p > 1.0)) @@ -72,4 +72,3 @@ namespace Gecode { } // STATISTICS: set-other - diff --git a/gecode/support/random.hpp b/gecode/support/random.hpp index 9aeb78ddf8..27d1becb3f 100755 --- a/gecode/support/random.hpp +++ b/gecode/support/random.hpp @@ -353,6 +353,7 @@ namespace Gecode { namespace Support { private: Engine e; public: + using EngineType = Engine; using State = typename Engine::State; using result_type = uint64_t; static_assert(Engine::max() == UINT64_MAX && Engine::min() <= 1, diff --git a/plans/random.md b/plans/random.md index 0bcb5ee5cb..9d1709218f 100644 --- a/plans/random.md +++ b/plans/random.md @@ -1,529 +1,135 @@ -# Plan: Compact, splittable random generators for Gecode 7 +# Plan: Compact random values and reproducible splitting -> Source: the feature/random design discussion, 2026-09-10. -> Status: All four phases complete and reviewed. -> Workflow: review, update this plan, and commit after each phase. +> Provisional draft for Gecode 7 or another future breaking-change release only. +> Ownership corrected after review: state belongs to the consumer, not Space. +> Status: implementation corrected; final verification and measurements in progress. ## Goal -Replace the old default random generator, provide user-extensible alternatives, -and make random branching streams stable under recomputation. Gecode 7 permits -breaking source, binary, and seeded-sequence compatibility for this change. - -Xorshift64* was the original proposed replacement. The subsequent requirement -for alternative-specific splitting must also be satisfied before selecting the -default. The design must stay compact in branching descriptions, branchers, -choices, and search paths. - -## User requirements - -1. A model author can use a better default generator and supply a custom engine - through Gecode's supported extension interface. -2. A test failure can be replayed from its complete random state, including state - required for subsequent splitting, without reconstructing preceding draws. -3. Every alternative of a branching decision gets a distinct successor state. - Replaying the same recorded decision and alternative gets the same state. -4. Cloning and recomputation do not introduce additional splits or make streams - depend on exploration order. -5. A command-line executable can use one configured engine. Large internal state - does not force users to supply equally large numeric seeds. -6. Memory and execution costs remain appropriate for Gecode search. - -## Architectural decisions - -### Branching and replay contract - -Generating a choice may consume random values for variable and value selection. -After those selections, the choice captures the complete state needed to derive -the streams for its alternatives. Committing an alternative derives and installs -its successor state from that immutable choice data and the alternative index. -It must not derive it from whatever mutable generator state happens to be in the -destination space. - -The same operation applies during exploration and recomputation. Reconstructing -a choice from an archive restores its splitting data without drawing or splitting. -Cloning preserves generator state without advancing either source or destination. -Sibling spaces must not consume each other's mutable streams. - -The sibling-state distinction must hold for all valid alternative indices, -including large multiway choices. A hash with merely a low collision probability -does not establish this requirement. Distinct states do not imply globally -non-overlapping sequences or uniqueness across an unbounded search tree. - -Store one common splitting payload per choice for the common single-stream case, -not an array of successor states. Deriving a late alternative must not require -generating all preceding alternatives. If multiple streams need separate state, -their cost must be explicit; do not silently assume one snapshot covers them. - -This is a guarantee about random state for a recorded path. It does not promise -identical global scheduling, solution order, adaptive heuristic state, or search -trees under parallel search, restarts, or weakly monotonic propagation. - -### Generator operations and extensibility - -Specify initialization, raw generation, bounded generation, exact copying, -alternative-indexed splitting, and full-state save/restore separately. -The names and C++ representation remain to be chosen during Phase 1. - -Custom engines must be usable by supported branching APIs, not only by callers -of the low-level support library. Define the output range, valid states, state -size, and splitting obligations of an engine. Engines without the required -splitting behavior may be usable as standalone generators but cannot silently -qualify as search generators. - -Keep the command-line engine selection independent of the extension mechanism: -one configured engine per executable is sufficient. No runtime plugin registry -or mandatory catalogue of engines is required. If configuration changes public -types or layouts, export the configuration consistently to downstream builds. - -Gecode specifies bounded integer conversion and draw consumption, including zero, -one, negative signed bounds, and maximum supported bounds. Use integer conversion -with rejection where required; account for the engine's actual output range. -In particular, a nonzero-state xorshift engine must not be treated as emitting -every 64-bit value with equal frequency over its period. Avoid distribution -caches unless their state is part of snapshots. - -### Seeds and complete state - -Ordinary initialization accepts a checked 64-bit seed, in decimal or hexadecimal. -Specify seed expansion and the treatment of zero for every supported engine. -Restoring state bypasses seed expansion and never silently repairs invalid state. - -Use a canonical text representation containing an algorithm/format identifier -and all state words in a specified order. Stream increments, counters, and other -mutable or per-instance parameters belong in it. An executable rejects an -incompatible state identifier rather than switching engines implicitly. - -The test runner records a snapshot immediately before an iteration and prints a -command that restores that snapshot directly for the named test. That command -bypasses normal suite-level seed derivation. Ordinary failure and exception paths -must both report the relevant iteration state, along with existing test options. - -The test runner needs full-state input in Phase 1. Public drivers receive the -same seed/state conventions in Phase 4. Use separate seed and state options; -reject conflicting input. A wider seed interface is unnecessary for this scope. - -### Compactness - -Measure the complete representation, not just the engine's state words: public -descriptions, selectors, local storage, choice objects, archive words, and retained -search paths. Include padding, dispatch data, and allocations. - -An 8-byte engine state is preferred; 16 bytes is a candidate budget for a truly -splittable engine, not an already approved limit. Keep runtime dispatch metadata -and textual identifiers out of repeated state payloads where they are implied -by the configured type. Avoid adding random-state storage to models that never -use random branching where practical, and measure any unavoidable common cost. - -## Decisions to close during implementation - -### Engine and indexed splitting - -Compare the original xorshift64* candidate with a published splittable design. -Distinguish fixed-increment SplitMix64 (one state word) from splittable SplitMix -(state plus a per-stream increment). Neither the name "SplitMix" nor the -existence of a sequential split operation establishes our indexed sibling-state -contract. Document the indexed adaptation, its cost, and why sibling states are -distinct before accepting it. - -Select a small initial set of alternatives with useful differences. At least one -additional search-capable engine should exercise the extension interface before -release. Preserve xorshift64* as a comparison candidate; do not invent an untested -splitting construction just to keep it as the default. Record the default decision -after correctness, published quality evidence, and Gecode measurements agree. - -### Ownership across selectors and branchers - -This decision must be closed before the Phase 2 integration is considered done. -Random variable selection and value selection may currently share a handle or -use separate handles; later posted branchers may also retain those handles. -Splitting only the active selector leaves later consumers unchanged. - -Start by evaluating a space-local random context shared by cooperating branchers, -using Gecode's existing local-object cloning mechanism if appropriate. Compare it -with compact state held directly in branchers. Choose the smallest design that -meets the contract; neither representation is mandated by this plan. - -Specify what reusing a generator in multiple descriptions means, how distinct -generators coexist, when external initialization is bound to a space, and how -later branchers inherit the alternative-specific state. Cover deterministic -choices before a randomized brancher: the later random stream must reflect the -selected alternative even when the earlier choice used no random values. -Also cover one-alternative assignments, dynamically posted branchers, and custom -callbacks. Explain where splitting state is captured and installed so custom -branchers have a clear participation contract. - -Use a small worked example with two sequential branchers, random variable and -value selection, and both shared and separate initial generators to settle this. -Do not add a general stream registry or per-node map without demonstrating why -a simpler ownership model cannot meet these cases. - -## Phase 1: Extensible generation with exact test replay - -**User requirements:** 1, 2, 5, 6. - -### What to build - -Introduce the engine contract and a compact candidate implementation, connected -to the existing test runner through complete-state input and failure reporting. -Specify raw output, bounded output, initialization, indexed splitting, and state -encoding together. Demonstrate another engine supplied outside the implementation -without editing the engine-selection logic. Resolve the ownership design needed -for the next phase using the worked example above. - -### Acceptance criteria - -- [x] Saving after mixed bounded draws and splits, restoring, and continuing - reproduces both future draws and future split states exactly. -- [x] A deliberately failing test iteration can be replayed directly from the - reported command, including when the failure is an exception. -- [x] Published vectors validate the selected engine where available; focused - checks cover invalid state, seed expansion, bounds, and state round trips. -- [x] The indexed splitting rule has an argument for sibling-state distinction - and handles the full valid alternative-index range without linear replay. -- [x] Custom engine state size is not artificially fixed to the default's size. -- [x] The ownership decision and initial memory measurements are recorded. - -### Phase 1 review - -Implemented `Support::Random` with full-state encoding and integer -rejection sampling, a 16-byte splittable SplitMix candidate, and the 8-byte -xorshift64* standalone alternative. The old congruential engine remains named -for comparison, but is no longer the default. Final default selection remains -subject to the Phase 3 measurements. - -For SplitMix parent `(s,g)`, alternative `a` gets state -`(Mix13(s+(2a+1)g), mixGamma(s+(2a+2)g))`, using unsigned 64-bit arithmetic. -This is the child of the `(a+1)`th sequential SplitMix split, computed directly. -Since `g` is odd and `a` is 32 bits, the first inputs are distinct modulo 2^64; -Mix13 is a permutation, so the complete sibling states are distinct. No new -generator recurrence or probabilistic collision assumption is introduced. - -The chosen ownership direction for Phase 2 is a lazily allocated space-local -collection of bound streams. Reusing the same initialization handle binds the -same local stream; separate handles retain separate streams. Space cloning -duplicates their mutable state. Choices snapshot all bound streams, including -ones belonging to later branchers, and commit derives each from the recorded -alternative. This also handles deterministic choices preceding random ones. -A compact linear collection is sufficient for the normally small number of -streams; there is no general registry or per-node lookup map. Raw engine words -are packed into one optional choice payload, with layout implied by the bound -engines. Phase 2 must measure and review the actual overhead of this design. - -Worked example: descriptions for branchers A and B reuse handle R, and A's -variable and value selectors both use R. All three bind one stream in the space. -After A's selections, its choice records R's state. Committing alternative 1 -installs R's child 1, which B subsequently uses. If A's value selector instead -uses a separate handle V, the choice stores R and V once each and commits both -child states. Replaying from an earlier clone uses those snapshots even though -A's selection draws were not re-executed. - -Validation: `Random::Contract` passed, including known raw-output vectors, -mixed draw/split replay, invalid state/seed input, bounds, and a user engine with -three state words. The dedicated `random-state-replay` CTest passes both ordinary -failure and exception replay by executing the printed command and comparing the -failing draw and state. The exception fixture first completes two iterations, -so this checks an advanced state rather than just initial seeding. Filtered -random tie selection also passes with the maximum 64-bit seed. -The existing `check` target passes, including its fault-injection checks and -selected integer, set, float, FlatZinc, branching, and search regressions. - -Measured on arm64 macOS: engine 4 -> 16 bytes; Rnd 8, IntVarBranch 112, -IntValBranch 80, Choice 16, integer PosValChoice 24, and Space 288 bytes remain -unchanged in this phase. Search still has its old shared-handle behavior until -Phase 2; this intermediate limitation is intentional and not a completed search -reproducibility claim. - -## Phase 2: Alternative-specific streams through one search path - -**User requirements:** 1, 3, 4, 6. - -### What to build - -Carry the new random state through a representative integer branching path, -choice archiving, commit, and sequential recomputation. Include both random -variable and value selection and the transition to a second brancher. Exercise -the same path with a user-supplied engine to prove that extension reaches search. - -### Acceptance criteria - -- [x] All alternatives of a recorded choice have distinct successor states. -- [x] Committing an alternative directly, after intervening sibling exploration, - or after restoring an archived choice produces identical successor state - and subsequent draws on equivalent spaces. -- [x] The same recorded path yields the same state with frequent cloning and - substantial recomputation, including last-alternative optimization. -- [x] A deterministic choice followed by random branching, and a transition - between randomized branchers, both retain the alternative-specific stream. -- [x] Shared and separate variable/value generators follow the documented - ownership policy; cloning does not mutate the source's generators. -- [x] Choice payload growth is independent of the number of alternatives. -- [x] Measure description, brancher, choice, and archive sizes against baseline. - -### Phase 2 review - -Implemented the space-local collection and common choice/commit integration. -`Rnd` accepts a user engine through `Support::Random` and keeps its -8-byte handle representation. Each space binds initialization handles to local -streams and clones those streams independently. Selector and model handles are -mapped back to the corresponding local stream during copying. Standalone handles -retain handle-style sharing; `copy()` makes an independent exact copy. - -All bound state words are captured after choice selection and restored/split -before the brancher's commit. Archives include the complete packed payload. -This works for custom choices that use the standard space choice/commit and base -choice archive interfaces, without per-brancher snapshot code. Binary and multiway -choices have the same random payload for the same registered streams. The small -selector binding change was applied to set and float selectors in this phase too, -because the ownership contract is common; their wider verification is Phase 3. - -The global draw mutex is removed: mutable streams are space-local, and standalone -handles are documented as requiring caller synchronization if shared by threads. -Making numeric construction explicit also exposed a FlatZinc decay constructor -that accidentally converted a double to a random seed. It now selects the actual -decay constructor explicitly. - -Validation: `Random::BranchReplay` checks reverse-order alternative exploration, -archived choices replayed on pre-selection clones, intentionally perturbed -destination state, shared/separate streams, deterministic-to-random handover, -and a user engine with three state words. For each configuration, all 81 solution -values and final states agree between commit distances 1 and 100. Both random -tests and filtered tie selection pass twice, state-report replay still passes, -and the full existing `check` target (including fault tests) passes. - -Arm64 sizes: Rnd 8, IntVarBranch 112, IntValBranch 80, and selectors remain -unchanged. Space grows 288 -> 296 bytes; Choice 16 -> 24 and integer PosValChoice -24 -> 32 bytes. One default stream adds a separately allocated 24-byte snapshot -(8-byte length + 16-byte state), making the logical integer choice footprint -56 bytes before allocator overhead. Its archive grows from 3 to 8 unsigned words. -Two default streams use a 40-byte snapshot; the custom engine uses 8 additional -bytes per stream. Nonrandom choices have no snapshot allocation but pay the -8-byte optional pointer and one archive count word. These costs are explicit -inputs to Phase 3 performance review, not yet a performance acceptance claim. - -## Phase 3: Complete branching and search integration - -**User requirements:** 1, 3, 4, 6. - -### What to build - -Apply the verified contract across integer, Boolean, set, and float random -branching, tie breaking, multiway branching, assignment, and supported custom -branchers. Check the common commit boundary and both sequential and parallel -search replay paths. Account explicitly for restarts, relaxation, and model -callbacks that use randomness outside ordinary choice selection. - -### Acceptance criteria - -- [x] Focused integration cases cover binary, multiway, and one-alternative - branching, plus handover to later branchers and custom-engine use. -- [x] Alternative identity follows the public choice index, including when a - brancher reverses the mapping from alternative index to selected value. -- [x] Clone, archive, disposal, failure, traced commit, and conditional commit - paths preserve the contract without shared mutable state between spaces. -- [x] Sequential and parallel replay of the same recorded path agree on random - state; checks do not require identical parallel solution order. -- [x] Restart and portfolio initialization policies are documented and do not - accidentally introduce worker-scheduling-dependent shared streams. -- [x] Existing relevant branching and search correctness tests pass. Add tests - for the new semantic guarantees rather than duplicating each API wrapper. -- [x] Random generation, bounded draws, splitting, clone cost, and retained-path - memory are measured in representative Gecode workloads. Use controlled - paths to separate overhead from changes in the randomized search tree. - -### Phase 3 review - -Added const-space stream lookup for callbacks and retained stream identity across -ancestor handles. Review showed that mapping only the immediate source space -was insufficient for a callback capturing an already bound ancestor handle. -The collection now holds one handle per stream; each local implementation keeps -its initialization origin alive. Mutable states remain independent across spaces. -The net per-stream collection/implementation storage is unchanged from Phase 2; -the default standalone implementation gains an 8-byte origin handle. - -FlatZinc binds its restart/relaxation handle to the space and derives meta-engine -streams from fixed restart/portfolio indices. The shared relaxation helper and -the Photo and JobShop examples now use local handles. Generic cloning still does -not split; models have an explicit `random_split()` operation for their own -meta-engine policy. Callback, registration, and replay boundaries are documented -in `docs/random.md`. - -Xorshift64* is now also a search-capable alternative: indexed splitting jumps -`(a+1)*2^32` native recurrence steps using shared transition matrices. This keeps -8-byte engine state, costs a shared 16 KiB table, and preserves the original -generator rather than inventing a new seeding construction. Composition and -period-wrap checks supplement its raw vector and full-state replay checks. -Its sibling-state distinction follows from coprimality with the native period. - -Validation passes for three engines (SplitMix, xorshift64*, and an external -three-word engine), shared/separate streams, binary/multiway choices, dynamic -posting through a one-alternative custom brancher, const callbacks, and sequential -versus two-worker search. Complete solution/state sets agree in the parallel -fixture without assuming solution order. Traced and conditional commits, failed -spaces, and illegal alternatives are checked explicitly. A dedicated fault test -counts live engine instances across allocation and brancher-copy failures and -checks that source states survive. Existing Boolean, set, float, FlatZinc restart, -and the full `check` selections also pass. - -Release measurements use Apple Clang 21, arm64 macOS 26.6.2, baseline commit -6b7de57b04, one warmup, five repetitions for seed 42, and three repetitions each -for seeds 1 and 1337. Raw records are in `build/random/phase3-benchmark*.json`; -the reusable harness and summary are documented in `docs/random.md`. -Representative seed-42 medians: bounded Rnd draws 14.85 -> 2.96 ns; random-space -clones 178.70 -> 345.95 ns; controlled random binary-tree nodes 153.07 -> 254.32 ns -with frequent cloning and 226.76 -> 368.53 ns with recomputation. Nonrandom tree -overhead was 1–3%. Queens wall time rose about 4–5% across the three seeds, with -similar but not identical node counts. These are local measurements, not a -cross-platform performance guarantee. - -The cost of independent streams and recorded state is real: the smallest random -tree is about 1.6x slower, despite much faster individual draws. We retain the -simple optional packed snapshot representation rather than add a second compact -choice hierarchy to hide that cost. SplitMix is the preferred default: binary -split-plus-draw measured about 15.7 ns, versus 237 ns for xorshift's jump-plus-draw. -The latter remains the 8-byte-state alternative. The popcount implementation was -improved after measurement without changing its bit sequence. Phase 4 will make -the executable's default configurable and verify both configurations. - -## Phase 4: Configured command lines and Gecode 7 migration - -**User requirements:** 1, 2, 5, 6. - -### What to build - -Complete seed/state handling in the example driver and FlatZinc, propagate the -configured engine through their random consumers, and finalize the default and -initial alternatives using the preceding evidence. Document the extension API, -ownership rules, replay contract, and compatibility changes for Gecode 7. - -### Acceptance criteria - -- [x] Drivers accept full 64-bit seeds without signed narrowing or truncation - and use the same initialization/state conventions as the test runner. -- [x] Complete state is accepted and reproduced for the configured engine; - malformed, incompatible, and conflicting options are rejected clearly. -- [x] Time/hardware initialization can report the concrete initialized state - needed for a later replay. -- [x] A runnable custom-engine example works through built-in branching; custom - brancher documentation explains choice snapshots and replay obligations. -- [x] FlatZinc restart sampling no longer relies on the old generator's restricted - range or sequence-preservation workaround where the new contract replaces it. -- [x] Release notes describe changed seeded sequences, state replay, and copying - versus sharing semantics. No legacy sequence mode is required. -- [x] Supported build configurations and relevant regression suites pass; the - default choice and measured memory/performance tradeoffs are documented. - -### Phase 4 review - -The example driver and FlatZinc share `Driver::RandomOption`: checked 64-bit -decimal/hex seeds, full-state input, and mutually exclusive seed/state arguments. -The existing seed flag names remain `-seed` and `-r`, respectively. `time` and -`hw` initialize once and print the resulting state. Hardware-source failures now -throw rather than return uninitialized data. `opt.rnd()` produces an independent -generator at the configured initial state; all in-tree numeric seed consumers -were migrated. The numeric accessor rejects state-only initialization rather -than silently ignoring it. - -Both CMake (`GECODE_RANDOM_ENGINE`) and Autoconf (`--with-random-engine`) select -SplitMix or xorshift64*. The generated, installed configuration header carries -that selection to clients. SplitMix remains the default for the measured splitting -cost; xorshift64* remains the 8-byte alternative. Both concrete engine types and -the public custom-engine interface are available in either build. - -`examples/random-engine.cpp` uses a three-word user engine with built-in random -selectors. It delegates generation/splitting to SplitMix and adds a path-local -draw counter, demonstrating full-state extension without another algorithm. -Seeded and full-state runs enumerate the same 24 permutations and solution states. -The output also agrees across both default configurations and the no-thread and -static builds. The old unused FlatZinc restart sampler and its chunk-specific test -are removed. The range-validation and restart integration tests remain. Review -also corrected the FlatZinc test harness to pass its configured generator into -parsing, and replaced an unsupported test `--seed` flag with `-r`. - -Validation on arm64 macOS with Apple Clang 21: - -- Full CMake `check`, including fault-injection tests, passes with both defaults. - All enabled example targets also build with the default configuration. -- `random-options` and `random-state-replay` CTests pass with both defaults. - CLI checks cover maximum-width decimal/hex seeds, arbitrary full states - (including a non-default SplitMix increment), incompatible identifiers, - malformed/conflicting input, and replay of reported time/hardware state. -- Boolean, set, and float branching selections, filtered random ties, and all - FlatZinc restart tests pass with both defaults. A direct FlatZinc run enumerates - the same 27 assignments from a maximum-width seed and its complete state. -- A reduced static CMake build with threads, set, float, and FlatZinc disabled - passes the random contract/branch replay/commit tests and both replay/CLI CTests. -- Autoconf builds all libraries and the custom example with xorshift64* and - threading disabled. Out-of-tree direct example building requires the existing - `make mkcompiledirs` preparation target. Its output matches the CMake builds. - -Logs and comparison outputs are under `build/random/phase4-*`; configurations are -in `build/random`, `build/random-xorshift`, `build/random-static`, and -`build/random-autoconf`. These are local configuration checks, not a claim of -having run every platform's CI. `docs/random.md` and the Gecode 7 changelog section -describe the API, compatibility changes, and measured costs. Release version and -ABI-number changes remain release preparation, outside this feature plan. - -### Completion audit - -Requirements 1 and 5 are exercised by both configured defaults, the CLI fixture, -and the separately defined custom engine. Requirement 2 is exercised by commands -replayed from actual failure and exception reports, plus driver full-state replay. -Requirements 3 and 4 are exercised by immutable choice snapshots, reverse sibling -exploration, archived replay on pre-selection clones with perturbed state, -multiway and callback handover, and equivalent solution/state sets under cloning, -recomputation, and parallel search. Review confirms that all variable/value -selectors bind and remap local streams and that commit installs them before -callbacks. Requirement 6 is covered by the retained Phase 3 measurements and -explicit description, space, choice, snapshot, archive, and splitting costs. -The final review found no uncompleted acceptance criterion in this plan. - -## Validation boundaries - -Keep a small set of high-value tests: reference vectors, save/restore including -splits, direct failure replay, and equivalent-path integration tests. Assertions -on state are stronger than expecting different first outputs from siblings: -distinct states can legitimately produce equal individual outputs. - -Do not put probabilistic distribution tests into ordinary CI. Use established -statistical tools during engine evaluation when needed, especially for any -indexed-splitting adaptation. Passing a statistical battery alone is not evidence -that sibling states are always distinct or replay is correct. - -Implementation proceeds in the phase order above. Each phase review records -its evidence and any adjustment before the phase is committed. - -## Initial code observations supporting the plan - -- `gecode/support/random.hpp`: current state is one unsigned integer, normally - 32 bits. Bounded output combines low-bit chunks and scaling/modulo operations. -- `gecode/kernel/data/rnd.hpp` and `.cpp`: public random handles share mutable - implementation state and use a static mutex across implementations. -- `gecode/kernel/branch/var.hpp`, `val.hpp`, and `view-sel.hpp`: branching - descriptions and selectors retain these handles, including during cloning. -- `gecode/kernel/branch/view-val.hpp` and `gecode/int/branch/view-values.hpp`: - choices currently store selected positions/values, without random snapshots. -- `gecode/kernel/core.cpp`: exploration and replay reach brancher commit through - common space operations; archive reconstruction dispatches by brancher identity. -- `gecode/kernel/core.hpp`: space-local objects already support cloning shared - objects within a space. Their suitability must be weighed against overhead. -- `gecode/kernel/archive.hpp`: archive storage uses unsigned integer words and - has no existing 64-bit integer overload; explicitly preserve every state bit. -- `gecode/search/seq/path.hpp` and `gecode/search/par/path.hpp`: retained choices - and alternative indices drive recomputation and make payload size significant. -- `test/test.cpp`: iteration replay currently treats the current generator state - as an unsigned seed; exceptions report the suite seed instead. -- `gecode/driver.hh` and `gecode/flatzinc.hh`: seed option types differ, with a - signed seed path in FlatZinc. -- `gecode/flatzinc/restart-random.hpp`: special handling compensates for the - current bounded generator while preserving established sequences. - -## Algorithm references - -- [Steele, Lea, and Flood: Fast Splittable Pseudorandom Number Generators](https://gee.cs.oswego.edu/dl/papers/oopsla14.pdf) - describes the splittable SplitMix design. -- [Vigna: older scrambled linear generators](https://prng.di.unimi.it/xorshift.php) - discusses limitations of the xorshift family and its low bits. -- [Blackman and Vigna's generator overview](https://prng.di.unimi.it/) - provides modern alternatives, reference implementations, and seed-expansion - guidance. Fixed-increment SplitMix64 is distinct from full splittable SplitMix. +Replace the old default generator, provide user-extensible alternatives and exact +state replay, and split randomized branching states by alternative. Keep states +small enough to store directly in the model, selector, or brancher that uses them. + +The original implementation introduced a space-managed context and shared stream +identity. That was an incorrect expansion of the requirement. The earlier phase +reviews and performance claims are superseded where they depend on that ownership +model. Their history remains in commits 626fb2d307, b4ec5dfe99, 2130ef3850, +3665a1bed2, and a1ff8e3385. + +## Required behavior + +1. A generator is a value containing its engine state inline. Copying it produces + independent state, with no allocation, registry, or aliasing for built-in engines. +2. Model members and selectors own their own copies. Posting or cloning copies + state without drawing. Passing the same value to two consumers does not share + their future state. +3. After variable/value selection, the active randomized brancher captures its + own selectors' states in its choice. Committing alternative a derives each + next state from that snapshot and a, not from destination mutable state. +4. Each sibling index produces a distinct successor state. Direct exploration + and archived replay of the same choice/alternative reproduce state exactly. + Cloning does not split. Late alternatives do not replay preceding siblings. +5. Deterministic branchers, unrelated branchers, and model-owned RNGs are not + implicitly advanced. Custom owners explicitly record/split their own state + when required; hidden callback randomness is not automatically enrolled. +6. Nonrandom Space and Choice representations have no additional RNG fields or + archive words. Randomized brancher choices store only the states they need, + in the same allocation as the choice. +7. Users can define engines with different state sizes and use generic branching + machinery. No runtime engine registry or fixed custom-state-size cap is needed. +8. Checked 64-bit seeds and canonical full-state text remain distinct interfaces. + Drivers use one configured engine. Test failures and exceptions report exact + iteration-state replay commands. +9. This remains a provisional breaking-change design, not a compatibility release. + +## Implementation + +### Value ownership + +- [x] Replace shared-handle Rnd with RndGenerator and a configured Rnd alias. +- [x] Remove RandomContext, origin identities, binding, Space::random(), and + Space::random_split(). +- [x] Restore kernel/core.hpp and core.cpp to their pre-feature state. +- [x] Copy selector and model generators normally; remove random-handle disposal + overhead for trivially destructible built-in engines. +- [x] Make relaxation take a generator reference, explicitly advancing its owner. + +### Brancher-local replay + +- [x] Add selector state save/restore/split hooks with no-op defaults. +- [x] Capture only the active brancher's selectors, in tie-break then value order. +- [x] Store words inline in a randomized choice subtype, without per-engine + identifiers, a state array per alternative, or a separate payload allocation. +- [x] Cover binary, multiway, assignment, reversed alternatives, and LDSB choices. +- [x] Support user-parameterized variable and integer/Boolean value selectors + through the existing generic brancher machinery. +- [x] Keep model/custom-brancher state transitions explicit. + +### Tests and documentation + +- [x] Replace tests of global stream coordination with tests of independent copies, + untouched model/later-selector state, and consumer-local splitting. +- [x] Check direct/archived choices, perturbed destination selector state, + sibling exploration, cloning/recomputation, and parallel solution agreement. +- [x] Exercise a three-word external engine in selectors and a runnable example. +- [x] Retain engine vectors, full-state failure replay, CLI validation, and + failed-clone resource checks. +- [x] Rewrite docs and release notes to describe value ownership accurately. +- [x] Run full relevant checks for both defaults and the reduced static/no-thread build. +- [x] Remeasure compactness and representative costs after removing the context. +- [x] Review the correction and update the existing provisional draft PR. + +## Verification strategy + +Use the existing focused Random::Contract, Random::BranchReplay, and +Random::CommitBoundary tests. The latter now checks consumer ownership rather than +the removed kernel-wide commit hook. Fault::Random::CloneFailures counts inline +custom-engine instances across failed clones. Existing Boolean, set, float, +assignment, LDSB, and FlatZinc restart cases cover their integration paths. + +Run random-options and random-state-replay CTests with both configured engines. +Verify that the custom example's initial state replays its output. Keep direct +assertions on state and choice archives; different first outputs alone do not +prove sibling-state distinction. + +Measure against the same main baseline (6b7de57b04), using the existing controlled +tree and queens harness. Report actual generator/description/selector/choice sizes +and any performance costs. Do not reuse the space-local implementation's results +as evidence for the corrected design. + +## Algorithm and CLI decisions retained + +Splittable SplitMix has two state words and constant-time indexed splitting. +Xorshift64* has one state word and indexed native jumps. Both have documented +sibling-state arguments in docs/random.md. The default remains provisional +SplitMix; xorshift64* remains configurable through CMake and Autoconf. + +Support::Random defines bounded integer conversion and canonical full-state +encoding. RndGenerator provides the modeling value interface. Full state +includes every word needed for future draws and splits. Seed expansion never +substitutes for state restoration. + +Drivers retain -seed (examples), -r (FlatZinc), and -state. Time/hardware +initialization reports concrete state. Incompatible, malformed, and conflicting +input is rejected. No legacy sequence mode is required. + +## Correction review + +The ownership error was architectural, not a bug in state copying. The previous +tests verified an expanded contract that the user did not intend. This correction +removes that contract and its infrastructure instead of optimizing it. + +Both engine configurations pass CMake check and all five CTests, including +command-line state replay and fault injection. The reduced static/no-thread build +passes check and the focused Random tests. Additional Boolean, set, float, +assignment, filtered-tie, LDSB, and FlatZinc restart checks pass with both defaults. +Random::BranchReplay now also exercises randomized LDSB choice archives. The +custom-engine example's full-state replay and output agree across all three builds. + +The corrected Space and base Choice implementation matches main exactly. Rnd is +16 bytes with SplitMix and 8 with xorshift64*, without shared allocation. Fresh +five-run measurements are recorded in docs/random.md: SplitMix's controlled +random tree costs 1.02x with cloning and 1.16x with recomputation relative to main; +xorshift64* costs 1.63x and 2.88x. No result from the removed space-local design is +used to justify this correction. Draft PR #241 describes this corrected contract +and remains provisional, for a future breaking-change release only. diff --git a/test/fault.cpp b/test/fault.cpp index 35598826f1..3ff428779a 100644 --- a/test/fault.cpp +++ b/test/fault.cpp @@ -32,6 +32,7 @@ */ #include +#include #include #include "test/test.hh" @@ -1174,7 +1175,7 @@ namespace Test { namespace Fault { } }; - // Count engine instances so failed clones cannot hide leaked stream handles. + // Count inline engine instances across selector cloning and failed clones. class LiveRandom : public Support::SplitMix { public: static int live; @@ -1189,13 +1190,18 @@ namespace Test { namespace Fault { class RandomSpace : public Space { public: IntVarArray x; - Rnd r; - RandomSpace() : x(*this,3,0,2), - r(*this,Rnd(Support::Random(7))) { - branch(*this,x,INT_VAR_RND(r),INT_VAL_RND(r)); + RndGenerator r; + RandomSpace() : x(*this,3,0,2), r(7) { + IntVarArgs vars(x); + ViewArray views(*this,vars); + ViewSel* selectors[] = { + new (*this) ViewSelRnd>(*this,r) + }; + auto* values = Int::Branch::valselcommit(*this,INT_VAL_RND(Rnd(7))); + postviewvalbrancher(*this,views,selectors,values,nullptr,nullptr); ThrowingBrancher::post(*this); } - RandomSpace(RandomSpace& s) : Space(s), r(*this,s.r) { + RandomSpace(RandomSpace& s) : Space(s), r(s.r) { x.update(*this,s.x); } Space* copy() override { return new RandomSpace(*this); } @@ -1225,7 +1231,7 @@ namespace Test { namespace Fault { } if (!succeeded) return false; - // Fail after a random brancher and its stream handles have been copied. + // Fail after a random brancher and its inline engines have been copied. Support::FailPoint::fail_after(Phase::BrancherCopy,0); try { std::unique_ptr copy(root.clone()); diff --git a/test/random.cpp b/test/random.cpp index 3fc9cb817c..a600f82b49 100644 --- a/test/random.cpp +++ b/test/random.cpp @@ -32,6 +32,7 @@ */ #include "test/test.hh" +#include namespace Test { namespace Random { @@ -152,195 +153,233 @@ namespace Test { } } contract; + // The model's RNG is an ordinary value, independent of selector copies. + template class ReplaySpace : public Gecode::Space { public: Gecode::IntVarArray x; - Gecode::Rnd variable, value; - ReplaySpace(const Gecode::Rnd& source, bool separate, bool multi=false, - bool callback=false) - : x(*this,4,0,2), variable(*this,source), - value(*this,separate ? source.copy() : source) { + Random own; + Gecode::ViewSel* variable = nullptr; + Gecode::ValSelCommitBase* value = nullptr; + void post(const Gecode::IntVarArgs& vars, bool multi, bool different) { using namespace Gecode; - // The first branch is deterministic, but later streams must split. - branch(*this,x[0],multi ? INT_VALUES_MIN() : INT_VAL_MIN()); - IntVarArgs first(2); - first[0]=x[1]; first[1]=x[2]; - branch(*this,first,INT_VAR_RND(variable),INT_VAL_RND(value)); - if (callback) { - // A custom one-alternative branch posts a later random brancher. - // The callback resolves the stream through its own space each time. - branch(*this,[source=variable](Space& home) { - auto& self = static_cast(home); - branch(home,self.x[3],INT_VAL([source](const Space& h, IntVar v, int) { - Rnd local(h,source); - unsigned int p=local(v.size()); - IntVarValues values(v); - while (p--) ++values; - return values.val(); - })); - }); + using Int::IntView; + ViewArray views(*this,vars); + ViewSel* selectors[] = { + new (*this) ViewSelRnd(*this,own) + }; + if (multi) { + Int::Branch::postviewvaluesbrancher<1,true>(*this,views,selectors,nullptr,nullptr); } else { - branch(*this,x[3],INT_VAL_RND(value)); + using Values = ValSelCommit, + Int::Branch::ValCommitEq>; + value = new (*this) Values(*this,INT_VAL_MIN(),different ? own.split(99) : own); + postviewvalbrancher(*this,views,selectors,value,nullptr,nullptr); } + variable = selectors[0]; } - ReplaySpace(ReplaySpace& s) - : Space(s), variable(*this,s.variable), value(*this,s.value) { - x.update(*this,s.x); + ReplaySpace(const Random& source, bool different, bool multi=false, + bool callback=false) : x(*this,4,0,2), own(source) { + using namespace Gecode; + branch(*this,x[0],INT_VAL_MIN()); + IntVarArgs first(2); first[0]=x[1]; first[1]=x[2]; + post(first,multi,different); + if (callback) + branch(*this,[](Space& home) { + auto& model=static_cast(home); + // Explicit model-owned state transition in a one-alternative commit. + model.own=model.own.split(0); + }); + branch(*this,x[3],INT_VAL_MIN()); } + ReplaySpace(ReplaySpace& s) : Space(s), own(s.own) { x.update(*this,s.x); } Space* copy() override { return new ReplaySpace(*this); } }; bool same_archive(const Gecode::Choice& a, const Gecode::Choice& b) { Gecode::Archive x,y; a.archive(x); b.archive(y); - if (x.size()!=y.size()) - return false; + if (x.size()!=y.size()) return false; for (int i=0; i + bool choice_replay(const Random& source, bool different, bool multi, bool callback) { using namespace Gecode; - std::unique_ptr root(new ReplaySpace(source,separate,multi,callback)); + using Model=ReplaySpace; + std::unique_ptr root(new Model(source,different,multi,callback)); while (root->status()==SS_BRANCH) { - std::unique_ptr before(static_cast(root->clone())); - const auto source_state = source.state(); + std::unique_ptr before(static_cast(root->clone())); + const auto owner_state=root->own.state(); std::unique_ptr choice(root->choice()); - Archive packed; - choice->archive(packed); - if (packed[1] != source.words()*(separate ? 2 : 1)) - return false; - const auto variable = root->variable.copy(); - const auto value = root->value.copy(); - std::vector siblings; - // Explore backwards, exercising late alternatives without earlier draws. + if (root->own.state()!=owner_state) return false; for (unsigned int a=choice->alternatives(); a--;) { - std::unique_ptr direct(static_cast(root->clone())); - std::unique_ptr replay(static_cast(before->clone())); - Archive archive; - choice->archive(archive); + std::unique_ptr direct(static_cast(root->clone())); + std::unique_ptr replay(static_cast(before->clone())); + Archive archive; choice->archive(archive); std::unique_ptr restored(replay->choice(archive)); + if (!same_archive(*choice,*restored)) return false; direct->commit(*choice,a); - // State on the recomputed space is intentionally different before commit. - (void) replay->variable(13); replay->commit(*restored,a); - if (direct->variable.state()!=variable.split(a).state() || - direct->value.state()!=value.split(a).state() || - replay->variable.state()!=direct->variable.state() || - replay->value.state()!=direct->value.state()) - return false; - for (const auto& previous : siblings) - if (previous==direct->variable.state()) - return false; - siblings.push_back(direct->variable.state()); - auto status = direct->status(); - if (status!=replay->status()) + auto status=direct->status(); + if (status!=replay->status() || direct->own.state()!=replay->own.state()) return false; if (status==SS_BRANCH) { std::unique_ptr next(direct->choice()); std::unique_ptr next_replay(replay->choice()); - if (!same_archive(*next,*next_replay)) - return false; + if (!same_archive(*next,*next_replay)) return false; } } - if (source.state()!=source_state || root->variable.state()!=variable.state()) - return false; root->commit(*choice,0); } return true; } - std::vector solutions(const Gecode::Rnd& source, - unsigned int distance, bool separate, - bool multi, bool callback, + template + std::vector solutions(const Random& source, unsigned int distance, + bool different, bool multi, bool callback, unsigned int threads=1) { using namespace Gecode; - ReplaySpace root(source,separate,multi,callback); + using Model=ReplaySpace; + Model root(source,different,multi,callback); Search::Options options; - options.c_d=distance; - options.a_d=distance; - options.threads=threads; - DFS search(&root,options); + options.c_d=distance; options.a_d=distance; options.threads=threads; + DFS search(&root,options); std::vector result; - while (std::unique_ptr s{search.next()}) { + while (std::unique_ptr s{search.next()}) { std::ostringstream item; - item << s->x << ':' << s->variable.state() << ':' << s->value.state(); + item << s->x << ':' << s->own.state(); result.push_back(item.str()); } return result; } + template + bool branch_replay(const Random& source) { + for (bool different : {false,true}) + for (bool multi : {false,true}) + for (bool callback : {false,true}) { + if (!choice_replay(source,different,multi,callback)) return false; + auto cloned=solutions(source,1,different,multi,callback); + auto recomputed=solutions(source,100,different,multi,callback); + if (cloned.size()!=81 || cloned!=recomputed) return false; + auto parallel=solutions(source,100,different,multi,callback,2); + std::sort(cloned.begin(),cloned.end()); + std::sort(parallel.begin(),parallel.end()); + if (cloned!=parallel) return false; + } + return true; + } + + class LDSBSpace : public Gecode::Space { + public: + Gecode::IntVarArray x; + LDSBSpace() : x(*this,4,0,3) { + using namespace Gecode; + Symmetries syms; + syms << VariableSymmetry(x); + distinct(*this,x); + branch(*this,x,INT_VAR_RND(Rnd(42)),INT_VAL_RND(Rnd(7)),syms); + } + LDSBSpace(LDSBSpace& s) : Space(s) { x.update(*this,s.x); } + Space* copy() override { return new LDSBSpace(*this); } + }; + + bool ldsb_replay() { + using namespace Gecode; + LDSBSpace root; + while (root.status()==SS_BRANCH) { + std::unique_ptr before(root.clone()); + std::unique_ptr choice(root.choice()); + for (unsigned int a=choice->alternatives(); a--;) { + std::unique_ptr direct(root.clone()), replay(before->clone()); + Archive archive; choice->archive(archive); + std::unique_ptr restored(replay->choice(archive)); + if (!same_archive(*choice,*restored)) return false; + direct->commit(*choice,a); replay->commit(*restored,a); + auto status=direct->status(); + if (status!=replay->status()) return false; + if (status==SS_BRANCH) { + std::unique_ptr next(direct->choice()); + std::unique_ptr next_replay(replay->choice()); + if (!same_archive(*next,*next_replay)) return false; + } + } + root.commit(*choice,0); + } + return true; + } + class BranchReplay : public Base { public: BranchReplay() : Base("Random::BranchReplay") {} bool run() override { - Gecode::Rnd engines[] = { - Gecode::Rnd(42), - Gecode::Rnd(Gecode::Support::Random(42)), - Gecode::Rnd(Gecode::Support::Random(42)) - }; - for (const auto& engine : engines) - for (bool separate : {false,true}) - for (bool multi : {false,true}) { - for (bool callback : {false,true}) { - if (!choice_replay(engine,separate,multi,callback)) - return false; - auto cloned = solutions(engine,1,separate,multi,callback); - auto recomputed = solutions(engine,100,separate,multi,callback); - if (cloned.size()!=81 || cloned!=recomputed) - return false; - auto parallel = solutions(engine,100,separate,multi,callback,2); - std::sort(cloned.begin(),cloned.end()); - std::sort(parallel.begin(),parallel.end()); - if (cloned!=parallel) - return false; - } - } - return true; + return ldsb_replay() && branch_replay(Gecode::Rnd(42)) && + branch_replay(Gecode::RndGenerator(42)) && + branch_replay(Gecode::RndGenerator(42)); } - } branch_replay; + } branch_replay_test; - class StateTracer : public Gecode::Tracer { - public: - std::string observed; - void propagate(const Gecode::Space&, const Gecode::PropagateTraceInfo&) override {} - void post(const Gecode::Space&, const Gecode::PostTraceInfo&) override {} - void commit(const Gecode::Space& home, const Gecode::CommitTraceInfo&) override { - observed=static_cast(home).variable.state(); + template + bool consumer_states(const Random& source, bool multi) { + using namespace Gecode; + // Inspect original-space selectors only; clones own separate copies. + for (unsigned int a=0; a<(multi ? 3U : 2U); ++a) { + ReplaySpace root(source,false,multi); + if (root.status()!=SS_BRANCH) return false; + auto original=source.state_words(); + std::vector observed(Random::words()); + root.variable->random_save(observed.data()); + if (!std::equal(original.begin(),original.end(),observed.begin())) return false; + std::unique_ptr first(root.choice()); + const auto& deterministic=static_cast(*first); + if (deterministic.random_data()!=nullptr) return false; + Archive plain; first->archive(plain); + if (plain.size()!=3) return false; // No global RNG pointer/count/archive data. + root.commit(*first,0); + root.variable->random_save(observed.data()); + if (!std::equal(original.begin(),original.end(),observed.begin())) return false; + if (root.status()!=SS_BRANCH) return false; + std::unique_ptr choice(root.choice()); + const uint64_t* recorded=static_cast(*choice).random_data(); + if (!recorded) return false; + Random expected=source; + expected.restore_split(recorded,a); + // Perturb the selector after taking the choice. Commit must restore it. + root.variable->random_commit(recorded,123); + root.commit(*choice,a); + root.variable->random_save(observed.data()); + auto expected_words=expected.state_words(); + if (!std::equal(expected_words.begin(),expected_words.end(),observed.begin())) + return false; + if (!multi) { + expected.restore_split(recorded+Random::words(),a); + root.value->random_save(observed.data()); + expected_words=expected.state_words(); + if (!std::equal(expected_words.begin(),expected_words.end(),observed.begin())) + return false; + } + if (root.own.state()!=source.state()) return false; } - }; + return true; + } class CommitBoundary : public Base { public: CommitBoundary() : Base("Random::CommitBoundary") {} bool run() override { using namespace Gecode; - StateTracer tracer; - ReplaySpace root(Rnd(7),false); - trace(root,TE_COMMIT,tracer); - root.status(); - std::unique_ptr choice(root.choice()); - auto expected=root.variable.split(1).state(); - std::unique_ptr copy(static_cast(root.clone())); - copy->trycommit(*choice,1); - if (copy->variable.state()!=expected || tracer.observed!=expected) - return false; - std::unique_ptr skipped(static_cast(root.clone())); - BrancherGroup::all.kill(*skipped); - auto before=skipped->variable.state(); - skipped->trycommit(*choice,1); - if (skipped->variable.state()!=before) - return false; - skipped->fail(); - skipped->commit(*choice,1); - if (skipped->variable.state()!=before) - return false; - try { root.commit(*choice,choice->alternatives()); return false; } - catch (const SpaceIllegalAlternative&) {} - return root.variable.state()==before; + static_assert(sizeof(Rnd)==sizeof(Support::RandomGenerator), + "Rnd must contain only inline engine state"); + Rnd r(7), copy=r; + auto state=r.state(); + (void) copy(UINT64_MAX); + if (r.state()!=state || copy.state()==state) return false; + return consumer_states(r,false) && consumer_states(r,true) && + consumer_states(RndGenerator(7),false) && + consumer_states(RndGenerator(7),true); } } commit_boundary; } diff --git a/tools/random-benchmark.cpp b/tools/random-benchmark.cpp index 8bbf408ef0..6e3699bc5d 100644 --- a/tools/random-benchmark.cpp +++ b/tools/random-benchmark.cpp @@ -33,6 +33,7 @@ // Compile against main without RANDOM_NEW, or against feature/random with it. #include +#include #include #include #include @@ -72,6 +73,11 @@ int main(int argc, char** argv) { const unsigned int seed=argc>2 ? std::stoul(argv[2]) : 42; if (!draws) return 1; std::cout << "size.engine\t" << sizeof(Support::RandomGenerator) << "\tbytes\t0\n" + << "size.rnd\t" << sizeof(Rnd) << "\tbytes\t0\n" + << "size.var_selector\t" << sizeof(ViewSelRnd) << "\tbytes\t0\n" + << "size.val_selector\t" << sizeof(Int::Branch::ValSelRnd) << "\tbytes\t0\n" + << "size.var_description\t" << sizeof(IntVarBranch) << "\tbytes\t0\n" + << "size.val_description\t" << sizeof(IntValBranch) << "\tbytes\t0\n" << "size.space\t" << sizeof(Space) << "\tbytes\t0\n" << "size.choice\t" << sizeof(PosValChoice) << "\tbytes\t0\n"; Support::RandomGenerator raw(seed); @@ -128,8 +134,11 @@ int main(int argc, char** argv) { << '\t' << archive.size()*sizeof(unsigned int) << "\tbytes\t0\n"; #ifdef RANDOM_NEW std::cout << (random ? "size.random_snapshot" : "size.plain_snapshot") - << '\t' << (archive[1] ? (archive[1]+1)*sizeof(uint64_t) : 0) + << '\t' << (random ? sizeof(Support::RandomGenerator) : 0) << "\tbytes\t0\n"; + std::cout << (random ? "size.random_choice" : "size.plain_choice") + << '\t' << (random ? sizeof(RndChoice>)+sizeof(Support::RandomGenerator) + : sizeof(PosValChoice)) << "\tbytes\t0\n"; #endif measure(random ? "clone.random" : "clone.plain",10000,[&] { uint64_t sum=0; From 0d04689eddfe1d1708703e6e06d713a20bba445c Mon Sep 17 00:00:00 2001 From: Mikael Zayenz Lagerkvist Date: Thu, 10 Sep 2026 14:54:20 +0200 Subject: [PATCH 7/7] random: clarify feature overview and clean up integration --- CMakeLists.txt | 4 +++ Makefile.in | 1 - changelog.in | 25 +++++++++++-------- cmake/GecodeSources.cmake | 1 - docs/random.md | 20 +++++++++++---- gecode/flatzinc.hh | 2 +- gecode/int/ldsb/brancher.hpp | 13 +++------- gecode/kernel/data/rnd.cpp | 38 ----------------------------- plans/random.md | 47 ++++++++++++------------------------ 9 files changed, 54 insertions(+), 97 deletions(-) delete mode 100644 gecode/kernel/data/rnd.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index a72f6770ce..350308f5d6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1413,6 +1413,8 @@ if(BUILD_TESTING) add_test(NAME random-state-replay COMMAND ${CMAKE_COMMAND} -DREPLAY=$ -P ${CMAKE_CURRENT_SOURCE_DIR}/test/random-replay.cmake) + set_tests_properties(random-state-replay PROPERTIES + FIXTURES_REQUIRED gecode-test-built) if(GECODE_ENABLE_DRIVER) add_executable(gecode-random-options EXCLUDE_FROM_ALL test/random-options.cpp) target_link_libraries(gecode-random-options PRIVATE gecodedriver ${GECODE_TEST_LINK_LIBS}) @@ -1424,6 +1426,8 @@ if(BUILD_TESTING) COMMAND ${CMAKE_COMMAND} -DOPTIONS=$ -DFLATZINC=${GECODE_ENABLE_FLATZINC} -P ${CMAKE_CURRENT_SOURCE_DIR}/test/random-options.cmake) + set_tests_properties(random-options PROPERTIES + FIXTURES_REQUIRED gecode-test-built) endif() if(GECODE_ENABLE_FAULT_INJECTION) add_executable(gecode-fault-test EXCLUDE_FROM_ALL diff --git a/Makefile.in b/Makefile.in index 859732e710..2aed374fa1 100755 --- a/Makefile.in +++ b/Makefile.in @@ -201,7 +201,6 @@ VARIMP = $(VARIMPHDR) KERNELSRC0 = \ archive core exception gpi \ - data/rnd \ branch/action branch/afc branch/chb branch/function \ memory/manager memory/region \ trace/recorder trace/filter trace/tracer trace/general \ diff --git a/changelog.in b/changelog.in index cb61744977..f7cb0160d6 100755 --- a/changelog.in +++ b/changelog.in @@ -79,16 +79,21 @@ Module: kernel What: new Rank: major [DESCRIPTION] -Random generators are compact values stored directly in their consumers. -Randomized branchers record only their own selectors' splitting state in choices. -Every alternative derives a distinct successor state that is stable -when the same recorded path is recomputed. Splittable SplitMix is the default; -xorshift64* is a configurable smaller-state alternative. Users can supply engines -through the generic branching APIs. Rnd copies are independent state copies; -there is no space-managed random context or shared mutable generator handle. -Drivers accept checked 64-bit seeds and complete state for replay. Test failures -report the exact iteration state. Seeded sequences and choice archives change; -no legacy sequence mode is provided. See docs/random.md for migration and costs. +Added modern, user-extensible random number generators with reproducible +stream splitting. Splittable SplitMix replaces the old default generator; +xorshift64* is available as a smaller-state alternative. The default is +configurable at build time, and users can supply their own engines. +Randomized branching splits the recorded generator state by alternative, +giving distinct successor states that are reproduced during recomputation. +Complete-state save/restore and command-line state input support exact replay, +including test failures. +[MORE] +Generators store their compact state directly in their consumers, and copying +a generator produces independent state. Drivers accept checked 64-bit seeds +as well as complete state. APIs, seeded sequences, and randomized choice +archives change; no legacy sequence mode is provided. This design remains +provisional for a future breaking-change release. See docs/random.md for +engine contracts, migration, and measured costs. [RELEASE] Version: 6.5.0 diff --git a/cmake/GecodeSources.cmake b/cmake/GecodeSources.cmake index fc9f128938..2f189a35e5 100644 --- a/cmake/GecodeSources.cmake +++ b/cmake/GecodeSources.cmake @@ -19,7 +19,6 @@ set(GECODE_KERNEL_SOURCES gecode/kernel/branch/function.cpp gecode/kernel/core.cpp gecode/kernel/data/array.cpp - gecode/kernel/data/rnd.cpp gecode/kernel/exception.cpp gecode/kernel/gpi.cpp gecode/kernel/memory/manager.cpp diff --git a/docs/random.md b/docs/random.md index 232a21d449..6fe23fffc2 100644 --- a/docs/random.md +++ b/docs/random.md @@ -1,8 +1,18 @@ -# Random generators for a future Gecode release +# Extensible random generators and stream splitting This is a provisional Gecode 7 design. It changes APIs, seeded sequences, and randomized choice archives and is intended only for a breaking-change release. +Gecode provides splittable SplitMix and xorshift64* generators, together with an +engine interface for user-defined alternatives. The build selects one default +for the modeling API and command-line drivers; generic selectors can use other +engines. SplitMix is the provisional default because its indexed splitting is +cheaper, while xorshift64* uses half the state storage. + +Randomized branching splits streams by alternative and reproduces their states +during recomputation. Complete-state save/restore supports replay independently +of seed expansion, including engines with more than 64 bits of state. + ## State belongs to the consumer A generator is a small value. A model, selector, or custom brancher stores that @@ -207,8 +217,8 @@ controlled tree always has 32767 nodes and 16384 solutions. Queens also reports node count because changes in its tree can affect timing. Avoid concurrent compilation while measuring. -Measurements after the ownership correction, on arm64 macOS with Apple Clang 21 -in Release mode, use five measured repetitions, one warmup, seed 42, and main +Measurements on arm64 macOS with Apple Clang 21 in Release mode use five +measured repetitions, one warmup, seed 42, and main at `6b7de57b04` as the baseline. Sizes are bytes: | Object | Main | SplitMix | Xorshift64* | @@ -234,5 +244,5 @@ The plain tree was 1.03x and 1.05x in both builds. Indexed binary split-plus-dra cost about 15.7 ns for SplitMix and 237 ns for xorshift64*. Queens took 20.1 ms with SplitMix (approximately main's time) and 21.2 ms with xorshift64* (1.06x). Its node counts were 9333 on main, 9325 with SplitMix, and 9323 with xorshift64*. -These are local measurements, not general performance guarantees. They replace -the earlier space-local results and leave the default-engine decision provisional. +These are local measurements, not general performance guarantees. The +default-engine decision remains provisional. diff --git a/gecode/flatzinc.hh b/gecode/flatzinc.hh index b42d36d268..d78497246c 100755 --- a/gecode/flatzinc.hh +++ b/gecode/flatzinc.hh @@ -411,7 +411,7 @@ namespace Gecode { namespace FlatZinc { BranchInformation& operator =(const BranchInformation&) = default; }; - /// Uninitialized default random number generator + /// Default random number generator, initialized with seed 0 GECODE_FLATZINC_EXPORT extern Rnd defrnd; diff --git a/gecode/int/ldsb/brancher.hpp b/gecode/int/ldsb/brancher.hpp index d7de70ddd7..8556f2df1c 100755 --- a/gecode/int/ldsb/brancher.hpp +++ b/gecode/int/ldsb/brancher.hpp @@ -146,15 +146,10 @@ namespace Gecode { namespace Int { namespace LDSB { class Filter, class Print> const Choice* LDSBBrancher::choice(Space& home) { - // Making the PVC here is not so nice, I think. - const Choice* c = ViewValBrancher::choice(home); - const PosValChoice* pvc = static_cast* >(c); - - // Compute symmetries. - - int choicePos = pvc->pos().pos; - int choiceVal = pvc->val(); - delete c; + Pos p = ViewBrancher::pos(home); + View v = ViewBrancher::view(p); + int choicePos = p.pos; + Val choiceVal = this->vsc->val(home,v,choicePos); _prevPos = choicePos; diff --git a/gecode/kernel/data/rnd.cpp b/gecode/kernel/data/rnd.cpp deleted file mode 100644 index 98544a51b9..0000000000 --- a/gecode/kernel/data/rnd.cpp +++ /dev/null @@ -1,38 +0,0 @@ -/* -*- mode: C++; c-basic-offset: 2; indent-tabs-mode: nil -*- */ -/* - * Main authors: - * Christian Schulte - * Mikael Zayenz Lagerkvist - * - * Copyright: - * Christian Schulte, 2008 - * Mikael Zayenz Lagerkvist, 2008 - * - * 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 - -// STATISTICS: kernel-other diff --git a/plans/random.md b/plans/random.md index 9d1709218f..2f818c8fc6 100644 --- a/plans/random.md +++ b/plans/random.md @@ -1,8 +1,7 @@ -# Plan: Compact random values and reproducible splitting +# Plan: Extensible random generators and reproducible splitting > Provisional draft for Gecode 7 or another future breaking-change release only. -> Ownership corrected after review: state belongs to the consumer, not Space. -> Status: implementation corrected; final verification and measurements in progress. +> Status: implemented and locally verified; design remains under review in draft PR #241. ## Goal @@ -10,12 +9,6 @@ Replace the old default generator, provide user-extensible alternatives and exac state replay, and split randomized branching states by alternative. Keep states small enough to store directly in the model, selector, or brancher that uses them. -The original implementation introduced a space-managed context and shared stream -identity. That was an incorrect expansion of the requirement. The earlier phase -reviews and performance claims are superseded where they depend on that ownership -model. Their history remains in commits 626fb2d307, b4ec5dfe99, 2130ef3850, -3665a1bed2, and a1ff8e3385. - ## Required behavior 1. A generator is a value containing its engine state inline. Copying it produces @@ -47,9 +40,6 @@ model. Their history remains in commits 626fb2d307, b4ec5dfe99, 2130ef3850, ### Value ownership - [x] Replace shared-handle Rnd with RndGenerator and a configured Rnd alias. -- [x] Remove RandomContext, origin identities, binding, Space::random(), and - Space::random_split(). -- [x] Restore kernel/core.hpp and core.cpp to their pre-feature state. - [x] Copy selector and model generators normally; remove random-handle disposal overhead for trivially destructible built-in engines. - [x] Make relaxation take a generator reference, explicitly advancing its owner. @@ -67,23 +57,22 @@ model. Their history remains in commits 626fb2d307, b4ec5dfe99, 2130ef3850, ### Tests and documentation -- [x] Replace tests of global stream coordination with tests of independent copies, - untouched model/later-selector state, and consumer-local splitting. +- [x] Test independent copies, untouched model/later-selector state, and + consumer-local splitting. - [x] Check direct/archived choices, perturbed destination selector state, sibling exploration, cloning/recomputation, and parallel solution agreement. - [x] Exercise a three-word external engine in selectors and a runnable example. - [x] Retain engine vectors, full-state failure replay, CLI validation, and failed-clone resource checks. -- [x] Rewrite docs and release notes to describe value ownership accurately. +- [x] Document engine extension, splitting, full-state replay, and migration. - [x] Run full relevant checks for both defaults and the reduced static/no-thread build. -- [x] Remeasure compactness and representative costs after removing the context. -- [x] Review the correction and update the existing provisional draft PR. +- [x] Measure compactness and representative costs against main. +- [x] Review the implementation and update the provisional draft PR. ## Verification strategy Use the existing focused Random::Contract, Random::BranchReplay, and -Random::CommitBoundary tests. The latter now checks consumer ownership rather than -the removed kernel-wide commit hook. Fault::Random::CloneFailures counts inline +Random::CommitBoundary tests. Fault::Random::CloneFailures counts inline custom-engine instances across failed clones. Existing Boolean, set, float, assignment, LDSB, and FlatZinc restart cases cover their integration paths. @@ -94,10 +83,9 @@ prove sibling-state distinction. Measure against the same main baseline (6b7de57b04), using the existing controlled tree and queens harness. Report actual generator/description/selector/choice sizes -and any performance costs. Do not reuse the space-local implementation's results -as evidence for the corrected design. +and any performance costs. -## Algorithm and CLI decisions retained +## Algorithm and CLI decisions Splittable SplitMix has two state words and constant-time indexed splitting. Xorshift64* has one state word and indexed native jumps. Both have documented @@ -113,23 +101,18 @@ Drivers retain -seed (examples), -r (FlatZinc), and -state. Time/hardware initialization reports concrete state. Incompatible, malformed, and conflicting input is rejected. No legacy sequence mode is required. -## Correction review - -The ownership error was architectural, not a bug in state copying. The previous -tests verified an expanded contract that the user did not intend. This correction -removes that contract and its infrastructure instead of optimizing it. +## Verification results Both engine configurations pass CMake check and all five CTests, including command-line state replay and fault injection. The reduced static/no-thread build passes check and the focused Random tests. Additional Boolean, set, float, assignment, filtered-tie, LDSB, and FlatZinc restart checks pass with both defaults. -Random::BranchReplay now also exercises randomized LDSB choice archives. The +Random::BranchReplay also exercises randomized LDSB choice archives. The custom-engine example's full-state replay and output agree across all three builds. -The corrected Space and base Choice implementation matches main exactly. Rnd is +The Space and base Choice implementation matches main exactly. Rnd is 16 bytes with SplitMix and 8 with xorshift64*, without shared allocation. Fresh five-run measurements are recorded in docs/random.md: SplitMix's controlled random tree costs 1.02x with cloning and 1.16x with recomputation relative to main; -xorshift64* costs 1.63x and 2.88x. No result from the removed space-local design is -used to justify this correction. Draft PR #241 describes this corrected contract -and remains provisional, for a future breaking-change release only. +xorshift64* costs 1.63x and 2.88x. Draft PR #241 remains provisional, for a future +breaking-change release only.