// Copyright 2010-2025 Google LLC // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. #ifndef ORTOOLS_SAT_FEASIBILITY_JUMP_H_ #define ORTOOLS_SAT_FEASIBILITY_JUMP_H_ #include #include #include #include #include #include #include #include #include #include #include "absl/container/flat_hash_map.h" #include "absl/functional/any_invocable.h" #include "absl/log/check.h" #include "absl/random/distributions.h" #include "absl/strings/str_join.h" #include "absl/strings/string_view.h" #include "absl/synchronization/mutex.h" #include "absl/types/span.h" #include "ortools/sat/constraint_violation.h" #include "ortools/sat/integer_base.h" #include "ortools/sat/linear_model.h" #include "ortools/sat/restart.h" #include "ortools/sat/sat_parameters.pb.h" #include "ortools/sat/stat_tables.h" #include "ortools/sat/subsolver.h" #include "ortools/sat/synchronization.h" #include "ortools/sat/util.h" #include "ortools/util/sorted_interval_list.h" #include "ortools/util/time_limit.h" namespace operations_research::sat { class CompoundMoveBuilder; // This class lazily caches the results of `compute_jump(var)` which returns a // pair. // Variables' scores can be manually modified using MutableScores (if the // optimal jump is known not to change), or marked for recomputation on the next // call to GetJump(var) by calling Recompute. class JumpTable { public: JumpTable() = default; void SetComputeFunction( absl::AnyInvocable(int) const> compute_jump); void RecomputeAll(int num_variables); // Gets the current jump delta and score, recomputing if necessary. std::pair GetJump(int var); // If the new optimum value and score is known, users can update it directly. // e.g. after weight rescaling, or after changing a binary variable. void SetJump(int var, int64_t delta, double score); // Recompute the jump for `var` when `GetJump(var)` is next called. void Recompute(int var); bool NeedRecomputation(int var) const { return needs_recomputation_[var]; } double Score(int var) const { return scores_[var]; } // Advanced usage, allows users to read possibly stale deltas for incremental // score updates. absl::Span Deltas() const { return absl::MakeConstSpan(deltas_); } absl::Span Scores() const { return absl::MakeConstSpan(scores_); } absl::Span MutableScores() { return absl::MakeSpan(scores_); } // For debugging and testing. // // Note if you have very high weights (e.g. when using decay), the tolerances // in this function are likely too tight. bool JumpIsUpToDate(int var) const; private: absl::AnyInvocable(int) const> compute_jump_; // For each variable, we store: // - A jump delta which represents a change in variable value: // new_value = old + delta, which is non-zero except for fixed variables. // - The associated weighted feasibility violation change if we take this // jump. std::vector deltas_; std::vector scores_; std::vector needs_recomputation_; }; // Accessing Domain can be expensive, so we maintain vector of bool for the // hot spots. class VarDomainWrapper { public: explicit VarDomainWrapper(SharedBoundsManager* shared_bounds) : shared_bounds_id_( shared_bounds == nullptr ? 0 : shared_bounds->RegisterNewId()), shared_bounds_(shared_bounds) {} Domain operator[](int var) const { return domains_[var]; } bool HasTwoValues(int var) const { return has_two_values_[var]; } size_t size() const { return domains_.size(); } void resize(int num_vars) { domains_.resize(num_vars); has_two_values_.resize(num_vars); is_fixed_.resize(num_vars, false); objective_is_positive_.resize(num_vars, false); objective_is_negative_.resize(num_vars, false); has_better_objective_value_.resize(num_vars, false); } void Set(int var, Domain d) { has_two_values_[var] = d.HasTwoValues(); if (is_fixed_[var]) { // The code here assume that once fixed, a variable stays that way. CHECK(d.IsFixed()); } else if (d.IsFixed()) { is_fixed_[var] = true; fixed_vars_.push_back(var); } domains_[var] = std::move(d); } // Return false if one of the domain becomes empty (UNSAT). This might happen // while we are cleaning up all workers at the end of a search. bool UpdateFromSharedBounds() { if (shared_bounds_ == nullptr) return true; shared_bounds_->GetChangedBounds(shared_bounds_id_, &tmp_variables_, &tmp_new_lower_bounds_, &tmp_new_upper_bounds_); for (int i = 0; i < tmp_variables_.size(); ++i) { const int var = tmp_variables_[i]; const Domain new_domain = domains_[var].IntersectionWith( Domain(tmp_new_lower_bounds_[i], tmp_new_upper_bounds_[i])); if (new_domain.IsEmpty()) return false; Set(var, new_domain); } return true; } absl::Span AsSpan() const { return domains_; } void InitializeObjective(const CpModelProto& cp_model_proto) { if (!cp_model_proto.has_objective()) return; const int num_terms = cp_model_proto.objective().vars().size(); for (int i = 0; i < num_terms; ++i) { const int var = cp_model_proto.objective().vars(i); const int coeff = cp_model_proto.objective().coeffs(i); objective_is_positive_[var] = coeff > 0; objective_is_negative_[var] = coeff < 0; } } bool IsFixed(int var) const { return is_fixed_[var]; } bool HasBetterObjectiveValue(int var) const { return has_better_objective_value_[var]; } // Tricky: this must be called on solution value change or domains update. void OnValueChange(int var, int64_t value) { has_better_objective_value_[var] = (objective_is_positive_[var] && value > domains_[var].Min()) || (objective_is_negative_[var] && value < domains_[var].Max()); } absl::Span FixedVariables() const { return fixed_vars_; } private: const int shared_bounds_id_; SharedBoundsManager* shared_bounds_; // Basically fixed once and for all. std::vector objective_is_positive_; std::vector objective_is_negative_; // Depends on domain updates. std::vector domains_; std::vector has_two_values_; std::vector is_fixed_; std::vector fixed_vars_; // This is the only one that depends on the current solution value. std::vector has_better_objective_value_; // Temporary data for UpdateFromSharedBounds() std::vector tmp_variables_; std::vector tmp_new_lower_bounds_; std::vector tmp_new_upper_bounds_; }; // Local search counters. This can either be the stats of one run without // restart or some aggregation of such runs. struct LsCounters { int64_t num_batches = 0; int64_t num_perturbations = 0; int64_t num_linear_evals = 0; int64_t num_linear_moves = 0; int64_t num_general_evals = 0; int64_t num_general_moves = 0; int64_t num_backtracks = 0; int64_t num_compound_moves = 0; int64_t num_weight_updates = 0; int64_t num_scores_computed = 0; void AddFrom(const LsCounters& o) { num_batches += o.num_batches; num_perturbations += o.num_perturbations; num_linear_evals += o.num_linear_evals; num_linear_moves += o.num_linear_moves; num_general_evals += o.num_general_evals; num_general_moves += o.num_general_moves; num_backtracks += o.num_backtracks; num_compound_moves += o.num_compound_moves; num_weight_updates += o.num_weight_updates; num_scores_computed += o.num_scores_computed; } }; // The parameters used by the local search code. struct LsOptions { // This one never changes. // - If true, each restart is independent from the other. This is nice because // it plays well with the theoretical Luby restart sequence. // - If false, we always "restart" from the current state, but we perturb it // or just reset the constraint weight. We currently use this one way less // often. bool use_restart = true; // These are randomized each restart by Randomize(). double perturbation_probability = 0.0; bool use_decay = true; bool use_compound_moves = true; bool use_objective = true; // No effect if there are no objective. // Allows to identify which options worked well. std::string name() const { std::vector parts; parts.reserve(5); if (use_restart) parts.push_back("restart"); if (use_decay) parts.push_back("decay"); if (use_compound_moves) parts.push_back("compound"); if (perturbation_probability > 0) parts.push_back("perturb"); if (use_objective) parts.push_back("obj"); return absl::StrJoin(parts, "_"); } // In order to collect statistics by options. template friend H AbslHashValue(H h, const LsOptions& o) { return H::combine(std::move(h), o.use_restart, o.perturbation_probability, o.use_decay, o.use_compound_moves, o.use_objective); } bool operator==(const LsOptions& o) const { return use_restart == o.use_restart && perturbation_probability == o.perturbation_probability && use_decay == o.use_decay && use_compound_moves == o.use_compound_moves && use_objective == o.use_objective; } void Randomize(const SatParameters& params, ModelRandomGenerator* random) { perturbation_probability = absl::Bernoulli(*random, 0.5) ? 0.0 : params.feasibility_jump_var_randomization_probability(); use_decay = absl::Bernoulli(*random, 0.5); use_compound_moves = absl::Bernoulli(*random, 0.5); use_objective = absl::Bernoulli(*random, 0.5); } }; // Each FeasibilityJumpSolver work on many LsState in an interleaved parallel // fashion. Each "batch of moves" will update one of these state. Restart // heuristic are also on a per state basis. // // This allows to not use O(problem size) per state while having a more // diverse set of heuristics. struct LsState { // The score of a solution is just the sum of infeasibility of each // constraint weighted by these weights. std::vector solution; std::vector weights; std::shared_ptr::Solution> base_solution; // Depending on the options, we use an exponentially decaying constraint // weight like for SAT activities. double bump_value = 1.0; // If using compound moves, these will be discounted on a new incumbent then // re-converge to weights after some exploration. Search will repeatedly pick // moves with negative WeightedViolationDelta using these weights. // // We limit the discrepancy in compound move search (i.e. limit the number of // backtracks to any ancestor of the current leaf). This is set to 0 whenever // a new incumbent is found or weights are updated, and increased at fixed // point. Weights are only increased if no moves are found with discrepancy 2. // Empirically we have seen very few moves applied with discrepancy > 2. int compound_move_max_discrepancy = 0; std::vector compound_weights; std::vector in_compound_weight_changed; std::vector compound_weight_changed; std::unique_ptr move; // Counters for a "non-restarted" run. LsCounters counters; // Strategy LsOptions options; // Global counters, incremented across restart. int64_t num_restarts = 0; int64_t num_solutions_imported = 0; // When this reach zero, we restart / perturbate or trigger something. int64_t num_batches_before_change = 0; // Used by LS to know the rank of the starting solution for this state. int64_t last_solution_rank = std::numeric_limits::max(); // Tricky: If this changed since last time, we need to recompute the // compound moves as the objective constraint bound changed. IntegerValue saved_inner_objective_lb = 0; IntegerValue saved_inner_objective_ub = 0; }; // Shared set of local search states that we work on. class SharedLsStates { public: // Important: max_parallelism should be greater or equal than the actual // number of thread sharing this class, otherwise the code will break. SharedLsStates(absl::string_view name, const SatParameters& params, SharedStatTables* stat_tables) : name_(name), params_(params), stat_tables_(stat_tables) { // We always start with at least 8 states. // We will create more if there are more parallel workers as needed. for (int i = 0; i < 8; ++i) CreateNewState(); } ~SharedLsStates(); void CreateNewState() { const int index = states_.size(); states_.emplace_back(new LsState()); taken_.push_back(false); num_selected_.push_back(0); // We add one no-restart per 16 states and put it last. states_.back()->options.use_restart = (index % 16 != 15); } // Returns the next available state in round-robin fashion. // This is thread safe. If we respect the max_parallelism guarantee, then // all states should be independent. LsState* GetNextState() { absl::MutexLock mutex_lock(mutex_); int next = -1; const int num_states = states_.size(); for (int i = 0; i < num_states; ++i) { const int index = round_robin_index_; round_robin_index_ = (round_robin_index_ + 1) % num_states; if (taken_[index]) continue; if (next == -1 || num_selected_[index] < num_selected_[next]) { next = index; } } if (next == -1) { // We need more parallelism and create a new state. next = num_states; CreateNewState(); } --states_[next]->num_batches_before_change; taken_[next] = true; num_selected_[next]++; return states_[next].get(); } void Release(LsState* state) { absl::MutexLock mutex_lock(mutex_); for (int i = 0; i < states_.size(); ++i) { if (state == states_[i].get()) { taken_[i] = false; break; } } } void ResetLubyCounter() { absl::MutexLock mutex_lock(mutex_); luby_counter_ = 0; } // We share a global running Luby sequence for all the "restart" state. // Note that we randomize the parameters on each restart. // // Hack: options.use_restart is constant, so we are free to inspect it. // Also if options.use_restart, then num_batches_before_change is only // modified under lock, so this code should be thread safe. void ConfigureNextLubyRestart(LsState* state) { absl::MutexLock mutex_lock(mutex_); const int factor = std::max(1, params_.feasibility_jump_restart_factor()); CHECK(state->options.use_restart); const int64_t next = factor * SUniv(++luby_counter_); state->num_batches_before_change = next; } // Accumulate in the relevant bucket the counters of the given states. void CollectStatistics(const LsState& state) { if (state.counters.num_batches == 0) return; absl::MutexLock mutex_lock(mutex_); options_to_stats_[state.options].AddFrom(state.counters); options_to_num_restarts_[state.options]++; } private: const std::string name_; const SatParameters& params_; SharedStatTables* stat_tables_; mutable absl::Mutex mutex_; int round_robin_index_ = 0; std::vector> states_; std::vector taken_; std::vector num_selected_; int luby_counter_ = 0; absl::flat_hash_map options_to_stats_; absl::flat_hash_map options_to_num_restarts_; }; // Implements and heuristic similar to the one described in the paper: // "Feasibility Jump: an LP-free Lagrangian MIP heuristic", Bjørnar // Luteberget, Giorgio Sartor, 2023, Mathematical Programming Computation. // // This is basically a Guided local search (GLS) with a nice algo to know what // value an integer variable should move to (its jump value). For binary, it // can only be swapped, so the situation is easier. // // TODO(user): If we have more than one of these solver, we might want to share // the evaluator memory between them. Right now we basically keep a copy of the // model and its transpose for each FeasibilityJumpSolver. class FeasibilityJumpSolver : public SubSolver { public: FeasibilityJumpSolver(const absl::string_view name, SubSolver::SubsolverType type, const LinearModel* linear_model, SatParameters params, std::shared_ptr ls_states, ModelSharedTimeLimit* shared_time_limit, SharedResponseManager* shared_response, SharedBoundsManager* shared_bounds, SharedLsSolutionRepository* shared_hints, SharedStatistics* shared_stats, SharedStatTables* stat_tables) : SubSolver(name, type), linear_model_(linear_model), params_(params), states_(std::move(ls_states)), shared_time_limit_(shared_time_limit), shared_response_(shared_response), shared_hints_(shared_hints), stat_tables_(stat_tables), random_(params_), var_domains_(shared_bounds) { shared_time_limit_->UpdateLocalLimit(&time_limit_); } // If VLOG_IS_ON(1), it will export a bunch of statistics. ~FeasibilityJumpSolver() override; // No synchronization needed for TaskIsAvailable(). void Synchronize() final {} // Shall we delete this subsolver? bool IsDone() final { if (!model_is_supported_.load()) return true; // We are done after the first task is done in the FIRST_SOLUTION mode. return type() == SubSolver::FIRST_SOLUTION && shared_response_->first_solution_solvers_should_stop()->load(); } bool TaskIsAvailable() final { if (IsDone()) return false; if (task_generated_.load()) return false; if (shared_response_->ProblemIsSolved()) return false; if (shared_time_limit_->LimitReached()) return false; return shared_response_->HasFeasibleSolution() == (type() == SubSolver::INCOMPLETE); } std::function GenerateTask(int64_t /*task_id*/) final; private: void ImportState(); void ReleaseState(); // Return false if we could not initialize the evaluator in the time limit. bool Initialize(); void ResetCurrentSolution(bool use_hint, bool use_objective, double perturbation_probability); void PerturbateCurrentSolution(double perturbation_probability); std::string OneLineStats() const; absl::Span ScanWeights() const { return absl::MakeConstSpan(state_->options.use_compound_moves ? state_->compound_weights : state_->weights); } // Returns the weighted violation delta plus epsilon * the objective delta. double ComputeScore(absl::Span weights, int var, int64_t delta, bool linear_only); // Computes the optimal value for variable v, considering only the violation // of linear constraints. std::pair ComputeLinearJump(int var); // Computes the optimal value for variable v, considering all constraints // (assuming violation functions are convex). std::pair ComputeGeneralJump(int var); // Marks all variables whose jump value may have changed due to the last // update, except for `changed var`. void MarkJumpsThatNeedToBeRecomputed(int changed_var); // Moves. bool DoSomeLinearIterations(); bool DoSomeGeneralIterations(); // Returns true if an improving move was found. bool ScanRelevantVariables(int num_to_scan, int* var, int64_t* value, double* score); // Increases the weight of the currently infeasible constraints. // Ensures jumps remains consistent. void UpdateViolatedConstraintWeights(); // Returns true if it is possible that `var` may have value that reduces // weighted violation or improve the objective. // Note that this is independent of the actual weights used. bool ShouldScan(int var) const; void AddVarToScan(int var); void RecomputeVarsToScan(); // Resets the weights used to find compound moves. // Ensures the following invariant holds afterwards: // compound_weights[c] = weights_[c] if c is violated, and epsilon * // weights_[c] otherwise. void ResetChangedCompoundWeights(); // Returns true if we should push this change to move_. // `novelty` is the total discount applied to the score due to using // `cumulative_weights_`, should always be positive (modulo floating-point // errors). bool ShouldExtendCompoundMove(double score, double novelty); // Validates each element in num_violated_constraints_per_var_ against // evaluator_->ViolatedConstraints. bool SlowCheckNumViolatedConstraints() const; double DeterministicTime() const { return evaluator_->DeterministicTime() + num_ops_ * 1e-8; } const LinearModel* linear_model_; SatParameters params_; std::shared_ptr states_; ModelSharedTimeLimit* shared_time_limit_; TimeLimit time_limit_; SharedResponseManager* shared_response_; SharedLsSolutionRepository* shared_hints_; SharedStatTables* stat_tables_; ModelRandomGenerator random_; VarDomainWrapper var_domains_; // Synchronization Booleans. // // Note that we don't fully support all type of model, and we will abort by // setting the model_is_supported_ bool to false when we detect this. bool is_initialized_ = false; std::atomic model_is_supported_ = true; std::atomic task_generated_ = false; bool time_limit_crossed_ = false; std::unique_ptr evaluator_; std::vector var_occurs_in_non_linear_constraint_; JumpTable jumps_; std::vector for_weight_update_; // The current sate we work on. LsState* state_; // A list of variables that might be relevant to check for improving jumps. std::vector in_vars_to_scan_; FixedCapacityVector vars_to_scan_; std::vector tmp_breakpoints_; // For counting the dtime. See DeterministicTime(). int64_t num_ops_ = 0; }; // This class helps keep track of moves that change more than one variable. // Mainly this class keeps track of how to backtrack back to the local minimum // as you make a sequence of exploratory moves, so in order to commit a compound // move, you just need to call `Clear` instead of Backtracking over the changes. class CompoundMoveBuilder { public: explicit CompoundMoveBuilder(int num_variables) : var_on_stack_(num_variables, false) {} // Adds an atomic move to the stack. // `var` must not be on the stack (this is DCHECKed). void Push(int var, int64_t prev_value, double score); // Sets var, val and score to a move that will revert the most recent atomic // move on the stack, and pops this move from the stack. // Returns false if the stack is empty. bool Backtrack(int* var, int64_t* value, double* score); // Removes all moves on the stack. void Clear(); // Returns the number of variables in the move. int Size() const { return stack_.size(); } // Returns true if this var has been set in this move already, bool OnStack(int var) const; // Returns the sum of scores of atomic moved pushed to this compound move. double Score() const { return stack_.empty() ? 0.0 : stack_.back().cumulative_score; } double BestChildScore() const { return stack_.empty() ? 0.0 : stack_.back().best_child_score; } // Returns the number of backtracks to any ancestor of the current leaf. int Discrepancy() const { return stack_.empty() ? 0 : stack_.back().discrepancy; } // Returns true if all prev_values on the stack are in the appropriate domain. bool StackValuesInDomains(absl::Span var_domains) const; private: struct UnitMove { int var; int64_t prev_value; // Note that this stores the score of reverting to prev_value. double score; // Instead of tracking this on the compound move, we store the partial sums // to avoid numerical issues causing negative scores after backtracking. double cumulative_score; // Used to avoid infinite loops, this tracks the best score of any immediate // children (and not deeper descendants) to avoid re-exploring the same // prefix. double best_child_score = 0.0; int discrepancy = 0; }; std::vector var_on_stack_; std::vector stack_; }; } // namespace operations_research::sat #endif // ORTOOLS_SAT_FEASIBILITY_JUMP_H_