annealing 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8b20378dea6d3ebcee0d0adca58d721576605d17c34c91a1828f887733c5fc5e
4
- data.tar.gz: 62935118c575c16e8e0efaaf58224845fc8e975f230ac58b81da41cf66a9c08c
3
+ metadata.gz: f42d7b6ef54f628c761f716ab2dffe55f227b1c3bac9c4e962c971bddc835961
4
+ data.tar.gz: 6a4a6673ce1e9893d6da3d8e4ff37781f98dd6108ab33819e6d1a1bb7d9cbf6b
5
5
  SHA512:
6
- metadata.gz: 4916ea9c5c854ce9891b9264d7dfe117f56da58fb906735f2afb015f2a0acf08757702991d988ed82096910225cca25794b78e135635d03b41dcf51496543a72
7
- data.tar.gz: f3c74cb98d4b45512fd78bf6e18dfc751550ba33f6ccfa4ab80720d01dcdf4a1a0ec7215387033523792a503876e4abd6bc7b017330539a8367bed3da3a23741
6
+ metadata.gz: 03b9fbc0ef5c027ba6f9d33a9eee1ae120c134f4d436916f076b1829ca170343b778da279096e01eda72d377c7370baf170e88315951c70e23fe39cd86315579
7
+ data.tar.gz: 3402a3824fdc915abccda29fc57ce0ab320547b44fd74a7df5b70c3171e1a08fe88b55d81582419813fb13b71e182f0dae51d4967e309a3017517abe210ffa8b
@@ -18,9 +18,9 @@ jobs:
18
18
  runs-on: ubuntu-latest
19
19
  strategy:
20
20
  matrix:
21
- ruby-version: ['2.6']
21
+ ruby-version: ['4.0']
22
22
  steps:
23
- - uses: actions/checkout@v2
23
+ - uses: actions/checkout@v4
24
24
  - name: Set up Ruby
25
25
  uses: ruby/setup-ruby@v1
26
26
  with:
@@ -32,10 +32,10 @@ jobs:
32
32
  runs-on: ubuntu-latest
33
33
  strategy:
34
34
  matrix:
35
- ruby-version: ['2.6', '2.7', '3.0', '3.1']
35
+ ruby-version: ['3.4', '4.0']
36
36
 
37
37
  steps:
38
- - uses: actions/checkout@v2
38
+ - uses: actions/checkout@v4
39
39
  - name: Set up Ruby
40
40
  uses: ruby/setup-ruby@v1
41
41
  with:
data/.rubocop.yml CHANGED
@@ -1,12 +1,12 @@
1
1
  inherit_from: .rubocop_todo.yml
2
2
 
3
- require:
3
+ plugins:
4
4
  - rubocop-performance
5
5
  - rubocop-minitest
6
6
  - rubocop-rake
7
7
 
8
8
  AllCops:
9
- TargetRubyVersion: 2.6
9
+ TargetRubyVersion: 3.4
10
10
  NewCops: enable
11
11
  DefaultFormatter: progress
12
12
  DisplayCopNames: true
data/.rubocop_todo.yml CHANGED
@@ -1,42 +1,54 @@
1
1
  # This configuration was generated by
2
2
  # `rubocop --auto-gen-config --auto-gen-only-exclude`
3
- # on 2022-02-18 23:32:32 UTC using RuboCop version 1.23.0.
3
+ # on 2022-12-12 19:04:19 UTC using RuboCop version 1.23.0.
4
4
  # The point is for the user to remove these configuration records
5
5
  # one by one as the offenses are removed from the code base.
6
6
  # Note that changes in the inspected code, or installation of new
7
7
  # versions of RuboCop, may require this file to be generated again.
8
8
 
9
- # Offense count: 9
9
+ # Offense count: 7
10
10
  # Configuration parameters: IgnoredMethods, CountRepeatedAttributes, Max.
11
11
  Metrics/AbcSize:
12
12
  Exclude:
13
- - 'test/annealing/configuration/configurator_test.rb'
13
+ - 'lib/annealing/configuration.rb'
14
+ - 'test/annealing/configuration_test.rb'
14
15
  - 'test/annealing/metal_test.rb'
15
16
  - 'test/annealing/simulator_test.rb'
16
17
  - 'test/annealing_test.rb'
17
18
 
18
- # Offense count: 3
19
+ # Offense count: 2
19
20
  # Configuration parameters: CountComments, Max, CountAsOne.
20
21
  Metrics/ClassLength:
21
22
  Exclude:
22
- - 'test/annealing/configuration/configurator_test.rb'
23
- - 'test/annealing/metal_test.rb'
23
+ - 'test/annealing/configuration_test.rb'
24
24
  - 'test/annealing/simulator_test.rb'
25
25
 
26
- # Offense count: 13
26
+ # Offense count: 1
27
+ # Configuration parameters: IgnoredMethods, Max.
28
+ Metrics/CyclomaticComplexity:
29
+ Exclude:
30
+ - 'lib/annealing/configuration.rb'
31
+
32
+ # Offense count: 11
27
33
  # Configuration parameters: CountComments, Max, CountAsOne, ExcludedMethods, IgnoredMethods.
28
34
  Metrics/MethodLength:
29
35
  Exclude:
30
36
  - 'lib/annealing/configuration.rb'
31
- - 'lib/annealing/simulator.rb'
32
- - 'test/annealing/configuration/configurator_test.rb'
37
+ - 'test/annealing/configuration_test.rb'
33
38
  - 'test/annealing/metal_test.rb'
34
39
  - 'test/annealing/simulator_test.rb'
35
40
  - 'test/annealing_test.rb'
36
41
 
37
- # Offense count: 8
42
+ # Offense count: 1
43
+ # Configuration parameters: IgnoredMethods, Max.
44
+ Metrics/PerceivedComplexity:
45
+ Exclude:
46
+ - 'lib/annealing/configuration.rb'
47
+
48
+ # Offense count: 5
38
49
  # Configuration parameters: Max.
39
50
  Minitest/MultipleAssertions:
40
51
  Exclude:
41
- - 'test/annealing/configuration/configurator_test.rb'
52
+ - 'test/annealing/configuration/terminators_test.rb'
42
53
  - 'test/annealing/configuration_test.rb'
54
+ - 'test/annealing/simulator_test.rb'
data/AGENTS.md ADDED
@@ -0,0 +1,53 @@
1
+ # AGENTS.md
2
+
3
+ Reference for AI agents (and humans) working in this repo. A Ruby gem implementing
4
+ simulated annealing. See `README.md` for the public API and configuration precedence
5
+ (global < instance < just-in-time).
6
+
7
+ ## Commands
8
+
9
+ - Setup: `bin/setup` (runs `bundle install`).
10
+ - Full default check (order matters): `bundle exec rake` — runs `test`, then `end_to_end_test`, then `rubocop`.
11
+ - Run tests only: `bundle exec rake test`.
12
+ - Run a single test file: `bundle exec ruby -Ilib:test test/path/to_test.rb` (or `rake test TEST=...`).
13
+ - Lint only: `bundle exec rubocop` (or `bundle exec rake rubocop`).
14
+ - Interactive console: `bin/console`. Runnable demo: `bin/run`.
15
+
16
+ ## Gotchas
17
+
18
+ - `rake`'s default task includes `bin/run` via the `end_to_end_test` task. It runs a
19
+ real (slow, non-deterministic due to `rand`) annealing simulation; skip it with
20
+ `rake test rubocop` when you only want fast feedback.
21
+ - Tests use Minitest. `test/test_helper.rb` requires `ruby-prof` and `debug` — both are
22
+ dev deps in the `Gemfile`; ensure they're installed (`bundle install`) before running tests.
23
+ - `Annealing.configuration` is a global singleton. Tests reset it in
24
+ `Minitest::Test#teardown` by setting `Annealing.configuration = nil`. When writing
25
+ tests that touch global config, favor scoped instance/JIT config or rely on teardown.
26
+ - `Metal#cool!` is non-idempotent and depends on `rand`; never assume deterministic
27
+ output from a single run.
28
+ - `Simulator#run` raises `ConfigurationError` ( subclass of `Annealing::Error`) on
29
+ invalid config; validation happens lazily at run time, not at `Simulator.new`.
30
+
31
+ ## Style / rubocop
32
+
33
+ - Inherited from `.rubocop.yml` + `.rubocop_todo.yml`. `TargetRubyVersion: 3.4`,
34
+ `Layout/LineLength: Max 120`, double-quoted strings, NewCops enabled with rubocop-
35
+ performance/-minitest/-rake plugins loaded.
36
+ - CI lint job runs `bundle exec rubocop --parallel` on Ruby 4.0; test matrix is 3.4/4.0
37
+ (see `.github/workflows/ruby.yml`). Keep code compatible with 3.4.
38
+
39
+ ## Architecture
40
+
41
+ - Entry: `Annealing.simulate` (lib/annealing.rb) — thin wrapper over
42
+ `Annealing::Simulator.new(...).run(...).state`.
43
+ - `Simulator` (lib/annealing/simulator.rb) drives the loop, merging configs at runtime
44
+ and calling `Metal#cool!` each step until `termination_condition` returns truthy.
45
+ - `Metal` (lib/annealing/metal.rb) holds current `state`, `temperature`, and lazily
46
+ computed `energy`; `cool!` applies the probabilistic accept/reject step.
47
+ - `Configuration` (lib/annealing/configuration.rb) plus concrete
48
+ `Configuration::Coolers` and `Configuration::Terminators` provide built-in callables.
49
+
50
+ ## Release
51
+
52
+ Bump `lib/annealing/version.rb`, then `bundle exec rake release` (tags, pushes, releases
53
+ to rubygems.org; `rubygems_mfa_required` is set in the gemspec).
data/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
- ## [Unreleased]
1
+ ## [0.4.1] - 2026-07-16
2
2
 
3
+ - Add return_best config option. When true, returns the best (lowest energy) state. When false, returns the final state (preserves old behavior).
4
+ - Bump minimum supported Ruby version to 3.4.0 and add Ruby 4.0 support.
5
+ - Refresh development dependencies (rubocop, ruby-prof, and plugins) to versions
6
+ compatible with Ruby 4.0.
7
+ - Bump RuboCop `TargetRubyVersion` to 3.4 and CI test matrix to 3.4 / 4.0.
8
+
9
+ ## [0.4.0] - 2023-03-09
10
+
11
+ - Rename method and add comments to clarify use by @chrisbloom7 in #20
12
+ - Config Cleanup: remove logger config by @chrisbloom7 in #21
13
+ - Simply configuration and overrides by @chrisbloom7 in #22
14
+ - Idea: Add library of built-in functions by @chrisbloom7 in #23
3
15
 
4
16
  ## [0.3.0] - 2022-02-21
5
17
 
data/Gemfile CHANGED
@@ -5,10 +5,13 @@ source "https://rubygems.org"
5
5
  # Specify your gem's dependencies in annealing.gemspec
6
6
  gemspec
7
7
 
8
- gem "debug", ">= 1.0.0", require: false
9
- gem "minitest", "~> 5.0"
10
- gem "rake", "~> 13.0", ">= 13.0.6"
11
- gem "rubocop", "~> 1.23"
12
- gem "rubocop-minitest", "~> 0.17.0"
13
- gem "rubocop-performance", "~> 1.12"
14
- gem "rubocop-rake", "~> 0.6.0"
8
+ group :development do
9
+ gem "debug", ">= 1.0.0", require: false
10
+ gem "minitest", "~> 5.22"
11
+ gem "rake", "~> 13.0", ">= 13.0.6"
12
+ gem "rubocop", "~> 1.78"
13
+ gem "rubocop-minitest", "~> 0.37"
14
+ gem "rubocop-performance", "~> 1.25"
15
+ gem "rubocop-rake", "~> 0.7"
16
+ gem "ruby-prof", "~> 1.7", ">= 1.7.0"
17
+ end
data/Gemfile.lock CHANGED
@@ -1,49 +1,83 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- annealing (0.2.0)
4
+ annealing (0.4.1)
5
5
 
6
6
  GEM
7
7
  remote: https://rubygems.org/
8
8
  specs:
9
- ast (2.4.2)
10
- debug (1.4.0)
11
- irb (>= 1.3.6)
12
- reline (>= 0.2.7)
13
- io-console (0.5.11)
14
- irb (1.4.1)
15
- reline (>= 0.3.0)
16
- minitest (5.14.4)
17
- parallel (1.21.0)
18
- parser (3.0.3.2)
9
+ ast (2.4.3)
10
+ base64 (0.3.0)
11
+ debug (1.11.1)
12
+ irb (~> 1.10)
13
+ reline (>= 0.3.8)
14
+ erb (6.0.4)
15
+ io-console (0.8.2)
16
+ irb (1.18.0)
17
+ pp (>= 0.6.0)
18
+ prism (>= 1.3.0)
19
+ rdoc (>= 4.0.0)
20
+ reline (>= 0.4.2)
21
+ json (2.21.1)
22
+ language_server-protocol (3.17.0.6)
23
+ lint_roller (1.1.0)
24
+ logger (1.7.0)
25
+ minitest (5.27.0)
26
+ parallel (2.1.0)
27
+ parser (3.3.12.0)
19
28
  ast (~> 2.4.1)
20
- rainbow (3.0.0)
21
- rake (13.0.6)
22
- regexp_parser (2.2.0)
23
- reline (0.3.1)
29
+ racc
30
+ pp (0.6.4)
31
+ prettyprint
32
+ prettyprint (0.2.0)
33
+ prism (1.9.0)
34
+ racc (1.8.1)
35
+ rainbow (3.1.1)
36
+ rake (13.4.2)
37
+ rbs (4.0.3)
38
+ logger
39
+ prism (>= 1.6.0)
40
+ tsort
41
+ rdoc (8.0.0)
42
+ erb
43
+ prism (>= 1.6.0)
44
+ rbs (>= 4.0.0)
45
+ tsort
46
+ regexp_parser (2.12.0)
47
+ reline (0.6.3)
24
48
  io-console (~> 0.5)
25
- rexml (3.2.5)
26
- rubocop (1.23.0)
27
- parallel (~> 1.10)
28
- parser (>= 3.0.0.0)
49
+ rubocop (1.88.2)
50
+ json (~> 2.3)
51
+ language_server-protocol (~> 3.17.0.2)
52
+ lint_roller (~> 1.1.0)
53
+ parallel (>= 1.10)
54
+ parser (>= 3.3.0.2)
29
55
  rainbow (>= 2.2.2, < 4.0)
30
- regexp_parser (>= 1.8, < 3.0)
31
- rexml
32
- rubocop-ast (>= 1.12.0, < 2.0)
56
+ regexp_parser (>= 2.9.3, < 3.0)
57
+ rubocop-ast (>= 1.49.0, < 2.0)
33
58
  ruby-progressbar (~> 1.7)
34
- unicode-display_width (>= 1.4.0, < 3.0)
35
- rubocop-ast (1.14.0)
36
- parser (>= 3.0.1.1)
37
- rubocop-minitest (0.17.0)
38
- rubocop (>= 0.90, < 2.0)
39
- rubocop-performance (1.12.0)
40
- rubocop (>= 1.7.0, < 2.0)
41
- rubocop-ast (>= 0.4.0)
42
- rubocop-rake (0.6.0)
43
- rubocop (~> 1.0)
44
- ruby-prof (1.4.3)
45
- ruby-progressbar (1.11.0)
46
- unicode-display_width (2.1.0)
59
+ unicode-display_width (>= 2.4.0, < 4.0)
60
+ rubocop-ast (1.50.0)
61
+ parser (>= 3.3.7.2)
62
+ prism (~> 1.7)
63
+ rubocop-minitest (0.39.1)
64
+ lint_roller (~> 1.1)
65
+ rubocop (>= 1.75.0, < 2.0)
66
+ rubocop-ast (>= 1.38.0, < 2.0)
67
+ rubocop-performance (1.26.1)
68
+ lint_roller (~> 1.1)
69
+ rubocop (>= 1.75.0, < 2.0)
70
+ rubocop-ast (>= 1.47.1, < 2.0)
71
+ rubocop-rake (0.7.1)
72
+ lint_roller (~> 1.1)
73
+ rubocop (>= 1.72.1)
74
+ ruby-prof (1.7.2)
75
+ base64
76
+ ruby-progressbar (1.13.0)
77
+ tsort (0.2.0)
78
+ unicode-display_width (3.2.0)
79
+ unicode-emoji (~> 4.1)
80
+ unicode-emoji (4.2.0)
47
81
 
48
82
  PLATFORMS
49
83
  ruby
@@ -51,13 +85,13 @@ PLATFORMS
51
85
  DEPENDENCIES
52
86
  annealing!
53
87
  debug (>= 1.0.0)
54
- minitest (~> 5.0)
88
+ minitest (~> 5.22)
55
89
  rake (~> 13.0, >= 13.0.6)
56
- rubocop (~> 1.23)
57
- rubocop-minitest (~> 0.17.0)
58
- rubocop-performance (~> 1.12)
59
- rubocop-rake (~> 0.6.0)
60
- ruby-prof (~> 1.4, >= 1.4.3)
90
+ rubocop (~> 1.78)
91
+ rubocop-minitest (~> 0.37)
92
+ rubocop-performance (~> 1.25)
93
+ rubocop-rake (~> 0.7)
94
+ ruby-prof (~> 1.7, >= 1.7.0)
61
95
 
62
96
  BUNDLED WITH
63
- 2.1.4
97
+ 2.7.2
data/README.md CHANGED
@@ -102,12 +102,26 @@ The annealer supports a number of configuration options. See the [configuration
102
102
 
103
103
  ### `cool_down`
104
104
 
105
- By default, the simulation will decrease the `temperature` linearly by `cooling_rate` on each step of the annealing process. In some cases you may wish to override this to use a different cooling algorithm. To do so, you can specify a custom `cool_down` function. The function can be any object that responds to `#call` and accepts three arguments: the `energy` calculation of the current object, the current `temperature` of the annealer, the `cooling_rate` for the simulation, and the number of `steps` the annealer has taken so far. It should return the new temperature as a Float.
105
+ By default, the simulation will decrease the `temperature` linearly by `cooling_rate` on each step of the annealing process. In some cases you may wish to override this to use a different cooling algorithm. To do so, you can use one of the other built-in cooling functions or you can specify a custom `cool_down` function. Custom functions can be any object that responds to `#call` and accepts four arguments: the `energy` calculation of the current object, the current `temperature` of the annealer, the `cooling_rate` for the simulation, and the number of `steps` the annealer has taken so far. It should return the new temperature as a Float.
106
106
 
107
107
  ```ruby
108
- Annealing.configuration.cool_down = lambda do |_energy, temperature, cooling_rate, steps|
109
- # Reduce temperature exponentially
110
- temperature - (cooling_rate * (steps**2))
108
+ # Use the built-in linear cool-down function (the default)
109
+ Annealing.configuration.cool_down = Annealing::Configuration::Coolers.linear
110
+
111
+ # Use the built-in exponential cool-down function
112
+ Annealing.configuration.cool_down = Annealing::Configuration::Coolers.exponential
113
+
114
+ # Use the built-in geometric cool-down function with a custom ratio (default ratio is 2)
115
+ Annealing.configuration.cool_down = Annealing::Configuration::Coolers.exponential(1.5)
116
+
117
+ # Use a custom cool down function
118
+ Annealing.configuration.cool_down = lambda do |energy, temperature, cooling_rate, steps|
119
+ # Reduce temperature exponentially when the temperature is above 500, then linearly
120
+ if temperature > 500
121
+ Annealing::Configuration::Coolers.exponential.call(energy, temperature, cooling_rate, steps)
122
+ else
123
+ Annealing::Configuration::Coolers.linear.call(energy, temperature, cooling_rate, steps)
124
+ end
111
125
  end
112
126
  ```
113
127
 
@@ -161,16 +175,6 @@ calculator = PotentialSalesCalculator.new(8)
161
175
  Annealing.configuration.energy_calculator = calculator.method(:energy)
162
176
  ```
163
177
 
164
- ### `logger`
165
-
166
- The default logger is a standard Ruby Logger that writes to stdout. You can specify a different `logger` if you wish.
167
-
168
- ```ruby
169
- logger = Logger.new('logfile.log')
170
- logger.level = Logger::DEBUG
171
- Annealing.configuration.logger = logger
172
- ```
173
-
174
178
  ### `state_change`
175
179
 
176
180
  As with `energy_calculator`, you must specify a `state_change` function in order to run any simulations; no default function is provided. The function can be any object that responds to `#call` and accepts a single argument: the `state` representing the current state that should be changed. It should return the changed state.
@@ -193,15 +197,26 @@ Annealing.configuration.state_change = instance.method(:state_change)
193
197
 
194
198
  ### `termination_condition`
195
199
 
196
- By default, a simulation will run until the temperature reaches 0. In some cases, you might want to specify a termination condition that will stop the annealing process as soon as some other condition is met regardless of the current temperature. You can define a custom `termination_condition` function, which can be any object that responds to `#call` and accepts three arguments: the current `state` of the object, the `energy` calculation of the current object, and the current `temperature` of the simulation. It should return a boolean value where `true` indicates the simulation should stop.
200
+ By default, a simulation will run until the temperature reaches 0. In some cases, you might want to specify a termination condition that will stop the annealing process as soon as some other condition is met regardless of the current temperature. To do so, you can use one of the other built-in termination condition functions or you can specify a custom one. Custom `termination_condition` functions can be any object that responds to `#call` and accepts three arguments: the current `state` of the object, the `energy` calculation of the current object, and the current `temperature` of the simulation. It should return a boolean value where `true` indicates the simulation should stop.
197
201
 
198
202
  ```ruby
203
+ # Use the built-in zero-temperature termination condition function
204
+ Annealing.configuration.termination_condition = Annealing::Configuration::Terminators.temp_is_zero?
205
+
206
+ # Use the built-in zero-energy termination condition function
207
+ Annealing.configuration.termination_condition = Annealing::Configuration::Terminators.energy_or_temp_is_zero?
208
+
209
+ # Use a custom termination condition function
199
210
  Annealing.configuration.termination_condition = lambda do |_state, energy, temperature|
200
- # Stop early if we encounter any 0-energy state
201
- energy == 0 || temperature <= 0.0
211
+ # Stop if the energy is below 500 and the temperature is below 100, or the temperature is already 0
212
+ temperature <= 0 || (energy <= 500 && temperature <= 500)
202
213
  end
203
214
  ```
204
215
 
216
+ ### 'return_best'
217
+
218
+ 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.
219
+
205
220
  ## Configuration precedence
206
221
 
207
222
  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.
data/annealing.gemspec CHANGED
@@ -12,7 +12,7 @@ Gem::Specification.new do |spec|
12
12
  spec.summary = "Simulated Annealing"
13
13
  spec.description = "Simulated Annealing algoritm implementation."
14
14
  spec.homepage = "https://github.com/3zcurdia/annealing"
15
- spec.required_ruby_version = Gem::Requirement.new(">= 2.6.0")
15
+ spec.required_ruby_version = Gem::Requirement.new(">= 3.4.0")
16
16
 
17
17
  # spec.metadata["allowed_push_host"] = "TODO: Set to 'http://mygemserver.com'"
18
18
 
@@ -29,6 +29,5 @@ Gem::Specification.new do |spec|
29
29
  spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
30
30
  spec.require_paths = ["lib"]
31
31
 
32
- spec.add_development_dependency "ruby-prof", "~> 1.4", ">= 1.4.3"
33
32
  spec.metadata["rubygems_mfa_required"] = "true"
34
33
  end
data/bin/run CHANGED
@@ -39,20 +39,25 @@ state_change = lambda do |state|
39
39
  swapped
40
40
  end
41
41
 
42
- Annealing.configuration.logger.level = Logger::DEBUG
43
42
  simulator = Annealing::Simulator.new(temperature: 10_000, cooling_rate: 0.01)
44
43
  solution = simulator.run(locations,
45
44
  energy_calculator: energy_calculator,
46
45
  state_change: state_change)
47
46
 
47
+ initial_energy = energy_calculator.call(locations)
48
48
  puts "\nInitial itinerary:"
49
- locations.each_cons(2).each_with_index do |(location1, location2), index|
49
+ locations.each_cons(2).with_index do |(location1, location2), index|
50
50
  puts "Stop ##{index + 1}: #{location1.name} -> #{location2.name} (#{location1.distance(location2)})"
51
51
  end
52
- puts "-------\nEnergy: #{energy_calculator.call(locations)}"
52
+ puts "-------\nEnergy: #{initial_energy}"
53
53
 
54
54
  puts "\nAnnealed itinerary:"
55
- solution.state.each_cons(2).each_with_index do |(location1, location2), index|
55
+ solution.state.each_cons(2).with_index do |(location1, location2), index|
56
56
  puts "Stop ##{index + 1}: #{location1.name} -> #{location2.name} (#{location1.distance(location2)})"
57
57
  end
58
58
  puts "-------\nEnergy: #{solution.energy}"
59
+
60
+ if solution.energy > initial_energy
61
+ raise "Annealing failed: solution energy #{solution.energy}\
62
+ ought to be less than or equal to than initial energy #{initial_energy}"
63
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Annealing
4
+ class Configuration
5
+ # Built-in cool down functions
6
+ module Coolers
7
+ module_function
8
+
9
+ # Reduce temperature linearly by the cooling rate
10
+ def linear
11
+ lambda { |_energy, temperature, cooling_rate, _steps|
12
+ temperature - cooling_rate
13
+ }
14
+ end
15
+
16
+ # Reduce temperature exponentially on each step by the cooling rate
17
+ def exponential
18
+ lambda { |_energy, temperature, cooling_rate, steps|
19
+ temperature - (Math.exp(steps - 1) * cooling_rate)
20
+ }
21
+ end
22
+
23
+ # Reduce temperature geometrically at a given ratio by the cooling rate
24
+ def geometric(ratio = 2)
25
+ unless ratio.positive?
26
+ raise(Annealing::Configuration::ConfigurationError,
27
+ "geometric ratio must be positive")
28
+ end
29
+
30
+ lambda { |_energy, temperature, cooling_rate, steps|
31
+ temperature - (cooling_rate * (ratio**(steps - 1)))
32
+ }
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Annealing
4
+ class Configuration
5
+ # Built-in termination condition check functions
6
+ module Terminators
7
+ module_function
8
+
9
+ # Returns true when the temperature is at or below zero
10
+ def temp_is_zero?
11
+ lambda { |_state, _energy, temperature|
12
+ temperature <= 0
13
+ }
14
+ end
15
+
16
+ # Returns true if a 0-energy state is detected, or when the temperature
17
+ # is at or below zero
18
+ def energy_or_temp_is_zero?
19
+ lambda { |state, energy, temperature|
20
+ energy <= 0 || temp_is_zero?.call(state, energy, temperature)
21
+ }
22
+ end
23
+ end
24
+ end
25
+ end
@@ -3,28 +3,76 @@
3
3
  module Annealing
4
4
  # It enables the gem configuration
5
5
  class Configuration
6
+ DEFAULT_COOLING_RATE = 0.0003
7
+ DEFAULT_INITIAL_TEMPERATURE = 10_000.0
8
+ DEFAULT_RETURN_BEST = false
9
+
10
+ class ConfigurationError < Annealing::Error; end
11
+
6
12
  attr_accessor :cool_down,
7
13
  :cooling_rate,
8
14
  :energy_calculator,
9
- :logger,
15
+ :return_best,
10
16
  :state_change,
11
17
  :temperature,
12
18
  :termination_condition
13
19
 
14
- def initialize
15
- @cool_down = lambda do |_energy, temperature, cooling_rate, _steps|
16
- # Reduce the temperature linearly by default
17
- temperature - cooling_rate
18
- end
19
- @cooling_rate = 0.0003
20
- @energy_calculator = nil
21
- @logger = Logger.new($stdout, level: Logger::INFO)
22
- @state_change = nil
23
- @temperature = 10_000.0
24
- @termination_condition = lambda do |_state, _energy, temperature|
25
- # Terminate the simulation as soon as the temperature reaches 0
26
- temperature <= 0.0
27
- end
20
+ def initialize(config_hash = {})
21
+ @cool_down = config_hash.fetch(:cool_down, Coolers.linear)
22
+ @cooling_rate = config_hash.fetch(:cooling_rate,
23
+ DEFAULT_COOLING_RATE).to_f
24
+ @energy_calculator = config_hash.fetch(:energy_calculator, nil)
25
+ @return_best = config_hash.fetch(:return_best, DEFAULT_RETURN_BEST)
26
+ @state_change = config_hash.fetch(:state_change, nil)
27
+ @temperature = config_hash.fetch(:temperature,
28
+ DEFAULT_INITIAL_TEMPERATURE).to_f
29
+ @termination_condition = config_hash.fetch(:termination_condition,
30
+ Terminators.temp_is_zero?)
31
+ end
32
+
33
+ # Return new configuration that merges new attributes with current
34
+ def merge(config_hash)
35
+ self.class.new(attributes.merge(config_hash))
36
+ end
37
+
38
+ def validate!
39
+ message = if !callable?(cool_down)
40
+ "Missing cool down function"
41
+ elsif cooling_rate.negative?
42
+ "Cooling rate cannot be negative"
43
+ elsif !callable?(energy_calculator)
44
+ "Missing energy calculator function"
45
+ elsif ![true, false].include?(return_best)
46
+ "'Return best' specification must be either true or false"
47
+ elsif !callable?(state_change)
48
+ "Missing state change function"
49
+ elsif temperature.negative?
50
+ "Initial temperature cannot be negative"
51
+ elsif !callable?(termination_condition)
52
+ "Missing termination condition function"
53
+ end
54
+ raise(ConfigurationError, message) if message
55
+ end
56
+
57
+ private
58
+
59
+ def attributes
60
+ {
61
+ cool_down: cool_down,
62
+ cooling_rate: cooling_rate,
63
+ energy_calculator: energy_calculator,
64
+ return_best: return_best,
65
+ state_change: state_change,
66
+ temperature: temperature,
67
+ termination_condition: termination_condition
68
+ }
69
+ end
70
+
71
+ def callable?(attribute)
72
+ attribute.respond_to?(:call)
28
73
  end
29
74
  end
30
75
  end
76
+
77
+ require "annealing/configuration/coolers"
78
+ require "annealing/configuration/terminators"
@@ -3,28 +3,23 @@
3
3
  module Annealing
4
4
  # It manages the total energy of a given collection
5
5
  class Metal
6
- include Configuration::Configurator
7
- attr_reader :state, :temperature
6
+ attr_reader :configuration, :state, :temperature
8
7
 
9
- def initialize(current_state, current_temperature, **config)
10
- init_configuration(config)
8
+ def initialize(current_state, current_temperature, configuration = nil)
9
+ @configuration = configuration || Annealing.configuration.merge({})
11
10
  @state = current_state
12
11
  @temperature = current_temperature
13
-
14
- raise(ArgumentError, "Missing energy calculator function") unless energy_calculator.respond_to?(:call)
15
-
16
- raise(ArgumentError, "Missing state change function") unless state_change.respond_to?(:call)
17
12
  end
18
13
 
19
14
  def energy
20
- @energy ||= energy_calculator.call(state)
15
+ @energy ||= configuration.energy_calculator.call(state)
21
16
  end
22
17
 
23
18
  # This method is not idempotent!
24
19
  # It relies on random probability to select the next state
25
20
  def cool!(new_temperature)
26
21
  cooled_metal = cool(new_temperature)
27
- if better_than?(cooled_metal)
22
+ if prefer?(cooled_metal)
28
23
  cooled_metal
29
24
  else
30
25
  @temperature = new_temperature
@@ -32,32 +27,27 @@ module Annealing
32
27
  end
33
28
  end
34
29
 
35
- def to_s
36
- format("%<temperature>.4f:%<energy>.4f:%<value>s",
37
- temperature: temperature,
38
- energy: energy,
39
- value: state)
30
+ def lower_energy?(cooled_metal)
31
+ cooled_metal.energy < energy
40
32
  end
41
33
 
42
34
  private
43
35
 
44
- def energy_calculator
45
- current_config_for(:energy_calculator)
46
- end
47
-
48
- def state_change
49
- current_config_for(:state_change)
36
+ # True if cooled_metal.energy is lower than current energy, otherwise let
37
+ # probability determine if we should accept a higher value over a lower
38
+ # value
39
+ def prefer?(cooled_metal)
40
+ lower_energy?(cooled_metal) || prefer_despite_higher_energy?(cooled_metal)
50
41
  end
51
42
 
52
- def better_than?(cooled_metal)
43
+ def prefer_despite_higher_energy?(cooled_metal)
53
44
  energy_delta = energy - cooled_metal.energy
54
- energy_delta.positive? ||
55
- (Math::E**(energy_delta / cooled_metal.temperature)) > rand
45
+ (Math::E**(energy_delta / cooled_metal.temperature)) > rand
56
46
  end
57
47
 
58
48
  def cool(new_temperature)
59
- next_state = state_change.call(state)
60
- Metal.new(next_state, new_temperature, **configuration_overrides)
49
+ next_state = configuration.state_change.call(state)
50
+ Metal.new(next_state, new_temperature, configuration)
61
51
  end
62
52
  end
63
53
  end
@@ -3,68 +3,65 @@
3
3
  module Annealing
4
4
  # It runs simulated annealing
5
5
  class Simulator
6
- include Configuration::Configurator
6
+ attr_reader :configuration
7
7
 
8
- def initialize(**config)
9
- init_configuration(config)
8
+ def initialize(config_hash = {})
9
+ @configuration = Annealing.configuration.merge(config_hash)
10
10
  end
11
11
 
12
+ # rubocop:disable Metrics/MethodLength
12
13
  def run(initial_state, config_hash = {})
13
- with_configuration_overrides(config_hash) do
14
- validate_configuration!
15
- current = Metal.new(initial_state, temperature,
16
- **configuration_overrides)
17
- Annealing.logger.debug("Original: #{current}")
14
+ with_runtime_config(config_hash) do |runtime_config|
15
+ initial_temperature = runtime_config.temperature
16
+ current = Metal.new(initial_state, initial_temperature, runtime_config)
17
+ best = current
18
18
  steps = 0
19
- until termination_condition_met?(termination_condition, current)
19
+ until termination_condition_met?(current, runtime_config)
20
20
  steps += 1
21
- current = reduce_temperature(cool_down, current, steps)
21
+ current = reduce_temperature(current, steps, runtime_config)
22
+ # If the current state has lower energy than the previous best (lowest energy) state
23
+ # we've seen so far, the current state is the new best state.
24
+ best = current if best.lower_energy?(current)
22
25
  end
23
- Annealing.logger.debug("Optimized: #{current}")
24
- current
26
+ final_or_best(current, best, runtime_config)
25
27
  end
26
28
  end
29
+ # rubocop:enable Metrics/MethodLength
27
30
 
28
31
  private
29
32
 
30
- def cool_down
31
- current_config_for(:cool_down)
33
+ # Wrapper for public methods that may use a custom configuration
34
+ def with_runtime_config(config_hash)
35
+ runtime_config = if config_hash.is_a?(Hash) && config_hash.any?
36
+ configuration.merge(config_hash)
37
+ else
38
+ configuration
39
+ end
40
+ runtime_config.validate!
41
+ yield(runtime_config)
32
42
  end
33
43
 
34
- def cooling_rate
35
- current_config_for(:cooling_rate).to_f
36
- end
37
-
38
- def logger
39
- current_config_for(:logger)
40
- end
41
-
42
- def temperature
43
- current_config_for(:temperature).to_f
44
- end
45
-
46
- def termination_condition
47
- current_config_for(:termination_condition)
48
- end
49
-
50
- def reduce_temperature(cool_down, metal, steps)
51
- new_temperature = cool_down.call(metal.energy, metal.temperature,
52
- cooling_rate, steps)
44
+ def reduce_temperature(metal, steps, config)
45
+ new_temperature = config.cool_down.call(metal.energy,
46
+ metal.temperature,
47
+ config.cooling_rate,
48
+ steps)
53
49
  metal.cool!(new_temperature)
54
50
  end
55
51
 
56
- def termination_condition_met?(termination_condition, metal)
57
- termination_condition.call(metal.state, metal.energy, metal.temperature)
52
+ def termination_condition_met?(metal, config)
53
+ config.termination_condition.call(metal.state,
54
+ metal.energy,
55
+ metal.temperature)
58
56
  end
59
57
 
60
- def validate_configuration!
61
- raise(ArgumentError, "Invalid initial temperature") if temperature.negative?
62
-
63
- raise(ArgumentError, "Invalid initial cooling rate") if cooling_rate.negative?
64
-
65
- raise(ArgumentError, "Missing cool down function") unless cool_down.respond_to?(:call)
66
-
67
- raise(ArgumentError, "Missing termination condition function") unless termination_condition.respond_to?(:call)
58
+ def final_or_best(final, best, config)
59
+ if config.return_best
60
+ # preserve the temperature
61
+ Metal.new(best.state, final.temperature, config)
62
+ else
63
+ final
64
+ end
68
65
  end
69
66
  end
70
67
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Annealing
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.1"
5
5
  end
data/lib/annealing.rb CHANGED
@@ -1,12 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "logger"
4
- require "annealing/version"
5
- require "annealing/configuration"
6
- require "annealing/configuration/configurator"
7
- require "annealing/metal"
8
- require "annealing/simulator"
9
-
10
3
  # Simulated Annealing algoritm
11
4
  # https://en.wikipedia.org/wiki/Simulated_annealing
12
5
  module Annealing
@@ -23,13 +16,15 @@ module Annealing
23
16
 
24
17
  def self.configure
25
18
  yield(configuration)
19
+ configuration
26
20
  end
27
21
 
28
22
  def self.simulate(initial_state, config_hash = {})
29
23
  Simulator.new.run(initial_state, config_hash).state
30
24
  end
31
-
32
- def self.logger
33
- configuration.logger
34
- end
35
25
  end
26
+
27
+ require "annealing/configuration"
28
+ require "annealing/metal"
29
+ require "annealing/simulator"
30
+ require "annealing/version"
data/mise.toml ADDED
@@ -0,0 +1,2 @@
1
+ [tools]
2
+ ruby = "4.0.5"
metadata CHANGED
@@ -1,36 +1,15 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: annealing
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Luis Ezcurdia
8
8
  - Chris Bloom
9
- autorequire:
10
9
  bindir: exe
11
10
  cert_chain: []
12
- date: 2022-02-22 00:00:00.000000000 Z
13
- dependencies:
14
- - !ruby/object:Gem::Dependency
15
- name: ruby-prof
16
- requirement: !ruby/object:Gem::Requirement
17
- requirements:
18
- - - "~>"
19
- - !ruby/object:Gem::Version
20
- version: '1.4'
21
- - - ">="
22
- - !ruby/object:Gem::Version
23
- version: 1.4.3
24
- type: :development
25
- prerelease: false
26
- version_requirements: !ruby/object:Gem::Requirement
27
- requirements:
28
- - - "~>"
29
- - !ruby/object:Gem::Version
30
- version: '1.4'
31
- - - ">="
32
- - !ruby/object:Gem::Version
33
- version: 1.4.3
11
+ date: 1980-01-02 00:00:00.000000000 Z
12
+ dependencies: []
34
13
  description: Simulated Annealing algoritm implementation.
35
14
  email:
36
15
  - ing.ezcurdia@gmail.com
@@ -43,6 +22,7 @@ files:
43
22
  - ".gitignore"
44
23
  - ".rubocop.yml"
45
24
  - ".rubocop_todo.yml"
25
+ - AGENTS.md
46
26
  - CHANGELOG.md
47
27
  - CODE_OF_CONDUCT.md
48
28
  - Gemfile
@@ -56,10 +36,12 @@ files:
56
36
  - bin/setup
57
37
  - lib/annealing.rb
58
38
  - lib/annealing/configuration.rb
59
- - lib/annealing/configuration/configurator.rb
39
+ - lib/annealing/configuration/coolers.rb
40
+ - lib/annealing/configuration/terminators.rb
60
41
  - lib/annealing/metal.rb
61
42
  - lib/annealing/simulator.rb
62
43
  - lib/annealing/version.rb
44
+ - mise.toml
63
45
  homepage: https://github.com/3zcurdia/annealing
64
46
  licenses:
65
47
  - MIT
@@ -68,7 +50,6 @@ metadata:
68
50
  source_code_uri: https://github.com/3zcurdia/annealing
69
51
  changelog_uri: https://github.com/3zcurdia/annealing/blob/main/CHANGELOG.md
70
52
  rubygems_mfa_required: 'true'
71
- post_install_message:
72
53
  rdoc_options: []
73
54
  require_paths:
74
55
  - lib
@@ -76,15 +57,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
76
57
  requirements:
77
58
  - - ">="
78
59
  - !ruby/object:Gem::Version
79
- version: 2.6.0
60
+ version: 3.4.0
80
61
  required_rubygems_version: !ruby/object:Gem::Requirement
81
62
  requirements:
82
63
  - - ">="
83
64
  - !ruby/object:Gem::Version
84
65
  version: '0'
85
66
  requirements: []
86
- rubygems_version: 3.3.7
87
- signing_key:
67
+ rubygems_version: 4.0.10
88
68
  specification_version: 4
89
69
  summary: Simulated Annealing
90
70
  test_files: []
@@ -1,41 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Annealing
4
- class Configuration
5
- # Configuration mixin
6
- module Configurator
7
- def self.included(base)
8
- base.send :include, InstanceMethods
9
- end
10
-
11
- # Mixin methods
12
- module InstanceMethods
13
- def init_configuration(config_hash = {})
14
- @instance_configuration = config_hash
15
- @temporary_configuration = {}
16
- end
17
-
18
- def with_configuration_overrides(local_config_hash = {})
19
- @temporary_configuration = local_config_hash
20
- yield
21
- ensure
22
- @temporary_configuration = {}
23
- end
24
-
25
- def configuration_overrides
26
- instance_configuration.merge(temporary_configuration)
27
- end
28
-
29
- private
30
-
31
- attr_accessor :instance_configuration, :temporary_configuration
32
-
33
- def current_config_for(config)
34
- temporary_configuration[config] ||
35
- instance_configuration[config] ||
36
- Annealing.configuration.public_send(config)
37
- end
38
- end
39
- end
40
- end
41
- end