Skip to content

Feature: Simulated annealing optimization#1

Merged
VoX merged 1 commit intomainfrom
feature/simulated-annealing
Apr 4, 2026
Merged

Feature: Simulated annealing optimization#1
VoX merged 1 commit intomainfrom
feature/simulated-annealing

Conversation

@VoX
Copy link
Copy Markdown
Owner

@VoX VoX commented Apr 4, 2026

Summary

  • Replace pure hill-climbing shape refinement with simulated annealing (SA) that escapes local minima by probabilistically accepting worse moves early in the search
  • Keep the old hill climbing accessible via USE_SIMULATED_ANNEALING flag in AppConstants (defaults to true)
  • Increase parallel SA chains (times in Model.java) to availableProcessors / 2 for better exploration

Changes

File Change
HillClimbGenerator.java Add getHillClimbSA(), estimateTemperature(), computeCoolingRate(); rename old method to getHillClimbClassic(); dispatch via feature flag
Model.java times now uses Runtime.getRuntime().availableProcessors() / 2 instead of hardcoded 1
AppConstants.java Add USE_SIMULATED_ANNEALING boolean flag
SimulatedAnnealingBenchmark.java JUnit 5 tests: temperature schedule validation, SA vs HC energy comparison, regression checks on multiple image types
TestImageGenerator.java Programmatic 128x128 test image generation (solid, gradient, edges, photo detail, nature)

How SA works

  1. Temperature estimation: Sample 30 random mutations, measure average energy delta, set initial temperature so ~60% of uphill moves are accepted
  2. Cooling schedule: Geometric decay computed so temperature reaches 0.001 after maxAge * 10 iterations
  3. Accept/reject: Improvements always accepted; worse moves accepted with probability exp(-delta / T); best-seen state tracked and returned

Test plan

  • testTemperatureSchedule — verifies temperature is positive/finite, cooling rate is in (0,1), final temp near zero
  • testSAProducesLowerOrEqualEnergy — SA score within 5% of HC on photo detail image
  • testSANeverSignificantlyWorse — SA within 5% of HC on solid, gradient, and edges images
  • testCoolingRateEdgeCases — handles tiny and large initial temperatures
  • ./gradlew clean build passes cleanly

🤖 Generated with Claude Code

Replace the pure hill-climbing refinement in HillClimbGenerator with
simulated annealing (SA) that can escape local minima by probabilistically
accepting worse moves early in the search. The old hill climbing is kept
as a fallback behind the USE_SIMULATED_ANNEALING flag in AppConstants.

Key changes:
- HillClimbGenerator: add getHillClimbSA(), estimateTemperature(), and
  computeCoolingRate() methods; dispatch via feature flag in getHillClimb()
- Model: increase parallel SA chains (times) to use availableProcessors/2
- AppConstants: add USE_SIMULATED_ANNEALING boolean flag (default true)
- Add JUnit 5 benchmark tests comparing SA vs classic hill climbing
- Add programmatically generated test images (128x128)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings April 4, 2026 21:06
@VoX VoX merged commit 88e4f8a into main Apr 4, 2026
2 checks passed
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR introduces a simulated annealing (SA) based optimizer for shape refinement to better escape local minima, while keeping the classic hill climb path available behind a feature flag.

Changes:

  • Added SA optimizer (getHillClimbSA) with temperature estimation and geometric cooling; classic hill climb retained as getHillClimbClassic and dispatched via USE_SIMULATED_ANNEALING.
  • Increased per-step optimization attempts (times) to scale with CPU count (availableProcessors()/2).
  • Added JUnit benchmarks/regression tests plus programmatic test-image generation; also added PNGs under test resources.

Reviewed changes

Copilot reviewed 5 out of 10 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
src/main/java/com/bobrust/generator/HillClimbGenerator.java Adds SA implementation + temperature schedule helpers; keeps classic variant and dispatches via feature flag.
src/main/java/com/bobrust/generator/Model.java Scales times with CPU count to run more optimization chains per step.
src/main/java/com/bobrust/util/data/AppConstants.java Adds USE_SIMULATED_ANNEALING feature flag (default true).
src/test/java/com/bobrust/generator/SimulatedAnnealingBenchmark.java Adds tests comparing SA vs classic and validating temperature schedule.
src/test/java/com/bobrust/generator/TestImageGenerator.java Adds deterministic in-memory test image generation + a helper main to write PNGs.
src/test/resources/test-images/solid.png Adds generated test image (currently unused by tests).
src/test/resources/test-images/gradient.png Adds generated test image (currently unused by tests).
src/test/resources/test-images/edges.png Adds generated test image (currently unused by tests).
src/test/resources/test-images/photo_detail.png Adds generated test image (currently unused by tests).
src/test/resources/test-images/nature.png Adds generated test image (currently unused by tests).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

*/
static float computeCoolingRate(float initialTemp, int maxAge) {
int totalIterations = maxAge * 10;
float finalTemp = 0.001f;
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

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

computeCoolingRate can divide by zero when maxAge is 0 (since totalIterations = maxAge * 10 becomes 0, and the later Math.pow(..., 1.0 / totalIterations) becomes undefined). Add an explicit guard for maxAge <= 0 (and/or totalIterations <= 0) to return a sane rate (e.g., 1.0f) to keep the method safe for all callers.

Suggested change
float finalTemp = 0.001f;
float finalTemp = 0.001f;
if (maxAge <= 0 || totalIterations <= 0) {
return 1.0f; // no cooling when iteration budget is invalid
}

Copilot uses AI. Check for mistakes.
Comment on lines +145 to +146
// -1/ln(0.6) ≈ 1.957, but we use avgDelta / 0.5108 which is equivalent
return (float) (avgDelta / 0.5108);
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

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

estimateTemperature uses the magic constant 0.5108 (approx -ln(0.6)) which makes the math harder to audit and invites drift from the comment. Consider computing it from Math.log(0.6) (or declaring a named constant) to keep the intent precise and self-documenting.

Suggested change
// -1/ln(0.6) ≈ 1.957, but we use avgDelta / 0.5108 which is equivalent
return (float) (avgDelta / 0.5108);
return (float) (avgDelta / -Math.log(0.6));

Copilot uses AI. Check for mistakes.
private static final int max_random_states = 1000;
private static final int age = 100;
private static final int times = 1;
private static final int times = Math.max(1, Runtime.getRuntime().availableProcessors() / 2);
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

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

Deriving times directly from availableProcessors()/2 can scale work linearly with core count (each processStep() does times full hillclimb runs), which can cause large latency spikes on high-core machines. Consider adding an upper cap and/or making this configurable (e.g., via a constant/setting) so performance remains predictable.

Suggested change
private static final int times = Math.max(1, Runtime.getRuntime().availableProcessors() / 2);
private static final int max_times = 8;
private static final int times = Math.min(max_times,
Math.max(1, Runtime.getRuntime().availableProcessors() / 2));

Copilot uses AI. Check for mistakes.
Comment on lines +66 to +74
/** Reflective helper to get the worker from Model (package-private field) */
private static Worker getWorker(Model model) {
try {
var field = Model.class.getDeclaredField("worker");
field.setAccessible(true);
return (Worker) field.get(model);
} catch (Exception e) {
throw new RuntimeException(e);
}
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

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

The test uses reflection to access private internals (Model.worker and Model.addShape), which is brittle and can break on refactors or stricter JVM access rules. Prefer exposing a package-private test hook (e.g., a package-private accessor/add method) or restructuring the benchmark to avoid reflective access.

Copilot uses AI. Check for mistakes.
Comment on lines +162 to +165
System.out.println("Hill climb score: " + hillClimbScore);
System.out.println("SA score: " + saScore);
float improvement = (hillClimbScore - saScore) / hillClimbScore * 100;
System.out.println("SA improvement: " + improvement + "%");
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

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

Avoid System.out.println in unit tests; it adds noise to CI logs and can slow execution. Prefer assertions-only, or log via the test framework when debugging is explicitly enabled.

Copilot uses AI. Check for mistakes.
Comment on lines +3 to +14
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;

import java.awt.*;
import java.awt.image.BufferedImage;
import java.awt.image.DataBufferInt;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import javax.imageio.ImageIO;
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

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

Several imports appear unused in this test class (BeforeAll, DataBufferInt, File, IOException, ImageIO). Removing unused imports will keep the test file focused and avoid confusion about intended resource-based testing.

Suggested change
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.awt.image.DataBufferInt;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import javax.imageio.ImageIO;
import org.junit.jupiter.api.Test;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

Copilot uses AI. Check for mistakes.
Comment on lines +167 to +191
// SA should not be dramatically worse (allow 5% tolerance due to stochastic nature)
assertTrue(saScore <= hillClimbScore * 1.05f,
"SA score (" + saScore + ") should not be significantly worse than hill climb (" + hillClimbScore + ")");
}

// ---- Test 4: Regression — No Worse Than Baseline on Multiple Images ----

@Test
void testSANeverSignificantlyWorse() {
BufferedImage[] images = {
TestImageGenerator.createSolid(),
TestImageGenerator.createGradient(),
TestImageGenerator.createEdges(),
};
String[] names = {"solid", "gradient", "edges"};
int maxShapes = 30; // Small for test speed

for (int idx = 0; idx < images.length; idx++) {
float hcScore = runGenerator(images[idx], maxShapes, false);
float saScore = runGenerator(images[idx], maxShapes, true);
System.out.println(names[idx] + " — HC: " + hcScore + ", SA: " + saScore);

// SA should never be more than 5% worse (stochastic tolerance)
assertTrue(saScore <= hcScore * 1.05f,
names[idx] + ": SA (" + saScore + ") should not be significantly worse than HC (" + hcScore + ")");
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

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

These assertions compare two stochastic optimizers using unseeded randomness (Worker.getRandom() uses ThreadLocalRandom), so the 5% tolerance can still lead to intermittent CI failures. To avoid flaky tests, consider injecting a deterministic RNG/seed for tests or running multiple trials and asserting on an aggregate (median/mean) rather than a single run.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants