Skip to content
Merged
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
133 changes: 118 additions & 15 deletions src/main/java/com/bobrust/generator/HillClimbGenerator.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

import com.bobrust.util.data.AppConstants;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ThreadLocalRandom;

class HillClimbGenerator {
private static State getBestRandomState(List<State> random_states) {
Expand All @@ -14,59 +14,162 @@ private static State getBestRandomState(List<State> random_states) {
state.shape.randomize();
}
random_states.parallelStream().forEach(State::getEnergy);

float bestEnergy = 0;
State bestState = null;
for (int i = 0; i < len; i++) {
State state = random_states.get(i);
float energy = state.getEnergy();

if (bestState == null || energy < bestEnergy) {
bestEnergy = energy;
bestState = state;
}
}

return bestState;
}

public static State getHillClimb(State state, int maxAge) {

/**
* Original hill climbing implementation. Kept as fallback when
* {@link AppConstants#USE_SIMULATED_ANNEALING} is false.
*/
public static State getHillClimbClassic(State state, int maxAge) {
float minimumEnergy = state.getEnergy();

// Prevent infinite recursion
int maxLoops = 4096;

State undo = state.getCopy();

// This function will minimize the energy of the input state
for (int i = 0; i < maxAge && (maxLoops-- > 0); i++) {
state.doMove(undo);
float energy = state.getEnergy();

if (energy >= minimumEnergy) {
state.fromValues(undo);
} else {
minimumEnergy = energy;
i = -1;
}
}

if (maxLoops <= 0 && AppConstants.DEBUG_GENERATOR) {
AppConstants.LOGGER.warn("HillClimbGenerator failed to find a better shape after {} tries", 4096);
}

return state;
}


/**
* Simulated annealing implementation that can escape local minima by
* probabilistically accepting worse moves early in the search.
*/
public static State getHillClimbSA(State state, int maxAge) {
float currentEnergy = state.getEnergy();
State bestState = state.getCopy();
float bestEnergy = currentEnergy;

// Estimate initial temperature from sample mutations
float temperature = estimateTemperature(state);
int totalIterations = maxAge * 10;
float coolingRate = computeCoolingRate(temperature, maxAge);

State undo = state.getCopy();

for (int i = 0; i < totalIterations; i++) {
state.doMove(undo);
float newEnergy = state.getEnergy();
float delta = newEnergy - currentEnergy;

if (delta < 0) {
// Improvement — always accept
currentEnergy = newEnergy;
if (currentEnergy < bestEnergy) {
bestEnergy = currentEnergy;
bestState = state.getCopy();
}
} else if (temperature > 0.001f) {
// Worse move — accept with probability exp(-delta/T)
double acceptProb = Math.exp(-delta / temperature);
if (ThreadLocalRandom.current().nextDouble() < acceptProb) {
currentEnergy = newEnergy;
} else {
state.fromValues(undo);
}
} else {
state.fromValues(undo);
}

temperature *= coolingRate;
}

// Return the best state found during the entire SA run
return bestState;
}

/**
* Dispatches to SA or classic hill climbing based on the feature flag.
*/
public static State getHillClimb(State state, int maxAge) {
if (AppConstants.USE_SIMULATED_ANNEALING) {
return getHillClimbSA(state, maxAge);
} else {
return getHillClimbClassic(state, maxAge);
}
}

/**
* Estimate a good starting temperature by sampling random mutations and
* measuring average energy deltas. Sets T so that roughly 60% of uphill
* moves are accepted at the start.
*/
static float estimateTemperature(State state) {
State probe = state.getCopy();
State undo = probe.getCopy();
float totalDelta = 0;
int samples = 30;

for (int i = 0; i < samples; i++) {
float before = probe.getEnergy();
probe.doMove(undo);
float after = probe.getEnergy();
totalDelta += Math.abs(after - before);
probe.fromValues(undo); // restore
}

float avgDelta = totalDelta / samples;
// Set T so ~60% of uphill moves are accepted initially
// P = exp(-avgDelta / T) = 0.6 => T = -avgDelta / ln(0.6)
// -1/ln(0.6) ≈ 1.957, but we use avgDelta / 0.5108 which is equivalent
return (float) (avgDelta / 0.5108);
Comment on lines +145 to +146
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.
}

/**
* Compute the geometric cooling rate so that temperature decays from
* {@code initialTemp} to near-zero (0.001) over {@code maxAge * 10} iterations.
*/
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.
if (initialTemp <= finalTemp) {
return 0.99f; // fallback if temperature is already tiny
}
// initialTemp * rate^totalIterations = finalTemp
// rate = (finalTemp / initialTemp) ^ (1 / totalIterations)
return (float) Math.pow(finalTemp / initialTemp, 1.0 / totalIterations);
}

public static State getBestHillClimbState(List<State> random_states, int age, int times) {
float bestEnergy = 0;
State bestState = null;

for (int i = 0; i < times; i++) {
State oldState = getBestRandomState(random_states);
State state = getHillClimb(oldState, age);
float energy = state.getEnergy();

if (i == 0 || bestEnergy > energy) {
bestEnergy = energy;
bestState = state.getCopy();
Expand Down
2 changes: 1 addition & 1 deletion src/main/java/com/bobrust/generator/Model.java
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ private void addShape(Circle shape) {

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.

private List<State> randomStates;

Expand Down
3 changes: 3 additions & 0 deletions src/main/java/com/bobrust/util/data/AppConstants.java
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ public interface AppConstants {
boolean DEBUG_DRAWN_COLORS = false;
boolean DEBUG_TIME = false;
int MAX_SORT_GROUP = 1000; // Max 1000 elements per sort

// When true, use simulated annealing instead of pure hill climbing for shape optimization
boolean USE_SIMULATED_ANNEALING = true;

// Average canvas colors. Used as default colors
Color CANVAS_AVERAGE = new Color(0xb3aba0);
Expand Down
Loading