Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
## [Unreleased]
## [Unreleased] - 2025-03-20

- Add return_best config option. When true, returns the best (lowest energy) state. When false, returns the final state (preserves old behavior).

## [0.4.0] - 2023-03-09

Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,10 @@ Annealing.configuration.termination_condition = lambda do |_state, energy, tempe
end
```

### 'return_best'

If true (the default), will return the best (lowest energy) state seen during the simulation. If false, Simulator#run will return the final state reached when the simulation completes.

## Configuration precedence

Configuration options can be set globally using `Annealing.configuration` or `Annealing.configure`, on `Annealing::Simulator.new` to be used on all subsequent runs of that instance, and just-in-time on `Annealing.simulate` and `Annealing::Simulator#run`. They are applied in reverse order of precedence.
Expand Down
8 changes: 7 additions & 1 deletion bin/run
Original file line number Diff line number Diff line change
Expand Up @@ -44,14 +44,20 @@ solution = simulator.run(locations,
energy_calculator: energy_calculator,
state_change: state_change)

initial_energy = energy_calculator.call(locations)
puts "\nInitial itinerary:"
locations.each_cons(2).with_index do |(location1, location2), index|
puts "Stop ##{index + 1}: #{location1.name} -> #{location2.name} (#{location1.distance(location2)})"
end
puts "-------\nEnergy: #{energy_calculator.call(locations)}"
puts "-------\nEnergy: #{initial_energy}"

puts "\nAnnealed itinerary:"
solution.state.each_cons(2).with_index do |(location1, location2), index|
puts "Stop ##{index + 1}: #{location1.name} -> #{location2.name} (#{location1.distance(location2)})"
end
puts "-------\nEnergy: #{solution.energy}"

if solution.energy > initial_energy
raise "Annealing failed: solution energy #{solution.energy}\
ought to be less than or equal to than initial energy #{initial_energy}"
end
6 changes: 6 additions & 0 deletions lib/annealing/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@ module Annealing
class Configuration
DEFAULT_COOLING_RATE = 0.0003
DEFAULT_INITIAL_TEMPERATURE = 10_000.0
DEFAULT_RETURN_BEST = true
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that this should default to false if this will end up going into a minor release. If we want to make it default to true, then we should def give a major version bump and I'd expect to see documentation about the upgrade path to keep the old functionality. I defer to @3zcurdia about which route he would prefer to see.


class ConfigurationError < Annealing::Error; end

attr_accessor :cool_down,
:cooling_rate,
:energy_calculator,
:return_best,
:state_change,
:temperature,
:termination_condition
Expand All @@ -20,6 +22,7 @@ def initialize(config_hash = {})
@cooling_rate = config_hash.fetch(:cooling_rate,
DEFAULT_COOLING_RATE).to_f
@energy_calculator = config_hash.fetch(:energy_calculator, nil)
@return_best = config_hash.fetch(:return_best, DEFAULT_RETURN_BEST)
@state_change = config_hash.fetch(:state_change, nil)
@temperature = config_hash.fetch(:temperature,
DEFAULT_INITIAL_TEMPERATURE).to_f
Expand All @@ -39,6 +42,8 @@ def validate!
"Cooling rate cannot be negative"
elsif !callable?(energy_calculator)
"Missing energy calculator function"
elsif ![true, false].include?(return_best)
"'Return best' specification must be either true or false"
elsif !callable?(state_change)
"Missing state change function"
elsif temperature.negative?
Expand All @@ -56,6 +61,7 @@ def attributes
cool_down: cool_down,
cooling_rate: cooling_rate,
energy_calculator: energy_calculator,
return_best: return_best,
state_change: state_change,
temperature: temperature,
termination_condition: termination_condition
Expand Down
8 changes: 7 additions & 1 deletion lib/annealing/metal.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,20 @@ def cool!(new_temperature)
end
end

def lower_energy?(cooled_metal)
cooled_metal.energy < energy
end

private

# True if cooled_metal.energy is lower than current energy, otherwise let
# probability determine if we should accept a higher value over a lower
# value
def prefer?(cooled_metal)
return true if cooled_metal.energy < energy
lower_energy?(cooled_metal) || prefer_despite_higher_energy?(cooled_metal)
end

def prefer_despite_higher_energy?(cooled_metal)
energy_delta = energy - cooled_metal.energy
(Math::E**(energy_delta / cooled_metal.temperature)) > rand
end
Expand Down
17 changes: 16 additions & 1 deletion lib/annealing/simulator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,24 @@ def initialize(config_hash = {})
@configuration = Annealing.configuration.merge(config_hash)
end

# rubocop:disable Metrics/MethodLength
def run(initial_state, config_hash = {})
with_runtime_config(config_hash) do |runtime_config|
initial_temperature = runtime_config.temperature
current = Metal.new(initial_state, initial_temperature, runtime_config)
best = current
steps = 0
until termination_condition_met?(current, runtime_config)
steps += 1
current = reduce_temperature(current, steps, runtime_config)
# If the current state has lower energy than the previous best (lowest energy) state
# we've seen so far, the current state is the new best state.
best = current if best.lower_energy?(current)
end
current
final_or_best(current, best, runtime_config)
end
end
# rubocop:enable Metrics/MethodLength

private

Expand Down Expand Up @@ -48,5 +54,14 @@ def termination_condition_met?(metal, config)
metal.energy,
metal.temperature)
end

def final_or_best(final, best, config)
if config.return_best
# preserve the temperature
Metal.new(best.state, final.temperature, config)
else
final
end
end
end
end
8 changes: 8 additions & 0 deletions test/annealing/configuration_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -145,5 +145,13 @@ def test_validates_termination_condition_is_callable
@valid_configuration.validate!
end
end

def test_validates_return_best
@valid_configuration.validate!
@valid_configuration.return_best = nil
assert_raises(@error_class, "'Return best' specification must be either true or false") do
@valid_configuration.validate!
end
end
end
end
39 changes: 39 additions & 0 deletions test/annealing/simulator_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -123,5 +123,44 @@ def test_passes_run_time_configuration_to_metal
energy_calculator.verify
state_changer.verify
end

def real_energy_calculator(ary)
ary[-1]
end

def test_finds_optimal_solution_when_return_best
initial_energy = real_energy_calculator(@collection)
final_metal = @simulator.run(@collection,
energy_calculator: ->(x) { real_energy_calculator(x) },
state_change: ->(state) { state.shuffle },
return_best: true)
final_energy = real_energy_calculator(final_metal.state)

assert final_energy < initial_energy
# we gave it plenty of time to find the optimal solution
assert_equal 1, final_energy
end

def test_finds_suboptimal_solution_when_not_return_best
max_iterations = 10
initial_energy = real_energy_calculator(@collection)
final_energy = 1
iteration_count = 0

# The simulator might happen to finish on the optimal state by random chance.
# If that happens, run it again up to MAX_ITERATIONS times.
until final_energy > 1 || iteration_count > max_iterations
iteration_count += 1
final_metal = @simulator.run(@collection,
energy_calculator: ->(x) { real_energy_calculator(x) },
state_change: ->(state) { state.shuffle },
return_best: false)
final_energy = real_energy_calculator(final_metal.state)

assert final_energy < initial_energy
end

refute_equal 1, final_energy
end
Comment on lines +144 to +164
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's still a chance this test could fail after max_attempts since it's just relying on random chance. I'd prefer a test that is deterministic, even if that means using stubs or mocks to setup the specific scenario this test is exercising.

end
end