Skip to content

Paint order optimization with 2-opt TSP (Proposal 5)#5

Merged
VoX merged 1 commit intomainfrom
feature/tsp-optimization
Apr 4, 2026
Merged

Paint order optimization with 2-opt TSP (Proposal 5)#5
VoX merged 1 commit intomainfrom
feature/tsp-optimization

Conversation

@VoX
Copy link
Copy Markdown
Owner

@VoX VoX commented Apr 4, 2026

Summary

  • Added TwoOptOptimizer class that applies 2-opt local search on top of the existing greedy BorstSorter output
  • Unified cost function: W_palette * paletteChanges + W_distance * normalizedEuclideanDistance
  • Integrated into BorstSorter.sort() — runs automatically after greedy sorting
  • Feature flag: USE_TSP_OPTIMIZATION in AppConstants (default: true)
  • Tunable weights: TSP_W_PALETTE=3.0, TSP_W_DISTANCE=1.0

Test plan

  • testTwoOptReducesCost — verifies 2-opt reduces or maintains total cost vs greedy alone
  • testAllShapesPreserved — verifies no shapes lost or duplicated during optimization
  • testCostFunction — verifies cost function correctness for same/different blobs
  • testPaletteChangeCount — verifies palette change counting (0-4 changes)
  • testEdgeCases — empty, single, and two-blob lists
  • testOptimizationPerformance — 500 blobs optimized within time budget
  • testIntegrationWithBorstSorter — end-to-end test through BorstSorter.sort()
  • ./gradlew clean build passes

🤖 Generated with Claude Code

…sal 5)

Adds TwoOptOptimizer that applies 2-opt local search to the greedy BorstSorter
output, minimizing a unified cost function of palette changes and cursor travel
distance. Integrated into BorstSorter.sort() when USE_TSP_OPTIMIZATION is true.
Tunable weights: TSP_W_PALETTE=3.0, TSP_W_DISTANCE=1.0.

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:41
@VoX VoX merged commit a4399e2 into main Apr 4, 2026
1 check 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 an additional paint-order optimization pass using a 2-opt TSP-style local search to improve the greedy BorstSorter ordering, using a unified cost function that combines palette-change “click” costs with normalized cursor travel distance.

Changes:

  • Added TwoOptOptimizer implementing 2-opt local search with a weighted (palette changes + normalized distance) cost.
  • Integrated the optimizer into BorstSorter.sort() behind AppConstants.USE_TSP_OPTIMIZATION (default true) and added tunable weights.
  • Added a new JUnit test suite covering correctness, edge cases, integration, and basic performance expectations.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.

File Description
src/main/java/com/bobrust/generator/sorter/TwoOptOptimizer.java Adds 2-opt optimization logic and cost function for ordering blobs.
src/main/java/com/bobrust/generator/sorter/BorstSorter.java Runs 2-opt after greedy sorting when the feature flag is enabled.
src/main/java/com/bobrust/util/data/AppConstants.java Introduces the feature flag and cost weights.
src/test/java/com/bobrust/generator/TwoOptOptimizerTest.java Adds tests for cost, preservation, edge cases, integration, and performance.

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

Comment on lines +155 to +156
// Apply 2-opt local search to reduce palette changes + travel distance
if (AppConstants.USE_TSP_OPTIMIZATION && result.size() > 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.

BorstSorter.sort() now runs 2-opt on the entire concatenated result list. Because the 2-opt implementation is O(n²) per iteration, this can become prohibitively slow (or effectively hang) when data.size() is large (the sorter already chunks work via MAX_SORT_GROUP, but this optimization ignores that and reintroduces a quadratic pass over all blobs). Consider capping optimization to a maximum N, running it per chunk/window, or making optimization opt-in at call sites for large runs.

Suggested change
// Apply 2-opt local search to reduce palette changes + travel distance
if (AppConstants.USE_TSP_OPTIMIZATION && result.size() > 2) {
// Apply 2-opt local search to reduce palette changes + travel distance.
// Cap optimization to the existing chunk size so large runs do not
// reintroduce an expensive whole-list quadratic pass.
if (AppConstants.USE_TSP_OPTIMIZATION
&& result.size() > 2
&& result.size() <= AppConstants.MAX_SORT_GROUP) {

Copilot uses AI. Check for mistakes.
Comment on lines +34 to +44
// Sort with greedy (BorstSorter)
BlobList greedy = BorstSorter.sort(input, CANVAS_SIZE);

// Extract greedy order, compute cost before 2-opt
Blob[] greedyArray = greedy.getList().toArray(new Blob[0]);
double greedyCost = optimizer.totalCost(greedyArray);

// Apply 2-opt
Blob[] optimized = greedyArray.clone();
optimizer.optimize(optimized);
double optimizedCost = optimizer.totalCost(optimized);
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.

This test intends to compare greedy-only ordering vs 2-opt, but it uses BorstSorter.sort(...) as the "greedy" baseline. Since this PR integrates 2-opt into BorstSorter.sort() when USE_TSP_OPTIMIZATION is true, the baseline is already optimized, so the assertion no longer validates the intended behavior. To keep this meaningful, add a greedy-only sorter entry point (or a way to disable optimization for the call) and use that for the baseline here.

Copilot uses AI. Check for mistakes.
Comment on lines +166 to +173
// Test with a moderate number of blobs
BlobList input = generateRandomBlobs(rnd, 500);
BlobList greedy = BorstSorter.sort(input, CANVAS_SIZE);
Blob[] blobs = greedy.getList().toArray(new Blob[0]);

long start = System.nanoTime();
optimizer.optimize(blobs);
long elapsed = System.nanoTime() - start;
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 performance benchmark times optimizer.optimize(blobs), but the blobs are obtained via BorstSorter.sort(...), which now applies 2-opt already. This means you're measuring a second optimization pass on an already locally-optimized route and may substantially underreport runtime. For a representative benchmark, build the input route without 2-opt (greedy-only) before timing.

Copilot uses AI. Check for mistakes.
Comment on lines +72 to +78
// Count occurrences by hashCode
java.util.Map<Integer, Integer> beforeCounts = new java.util.HashMap<>();
java.util.Map<Integer, Integer> afterCounts = new java.util.HashMap<>();
for (Blob b : before) beforeCounts.merge(b.hashCode(), 1, Integer::sum);
for (Blob b : after) afterCounts.merge(b.hashCode(), 1, Integer::sum);

assertEquals(beforeCounts, afterCounts, "Same blobs before and after 2-opt");
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 "all shapes preserved" check uses hashCode() counts as a proxy for identity. Since Blob does not override equals(), and hash codes can theoretically collide, this can produce false positives/negatives. Because optimization should only permute the existing object references, prefer an identity-based check (e.g., IdentityHashMap counts or comparing sorted lists of System.identityHashCode(...)).

Suggested change
// Count occurrences by hashCode
java.util.Map<Integer, Integer> beforeCounts = new java.util.HashMap<>();
java.util.Map<Integer, Integer> afterCounts = new java.util.HashMap<>();
for (Blob b : before) beforeCounts.merge(b.hashCode(), 1, Integer::sum);
for (Blob b : after) afterCounts.merge(b.hashCode(), 1, Integer::sum);
assertEquals(beforeCounts, afterCounts, "Same blobs before and after 2-opt");
// Count occurrences by object identity to verify the optimizer only permutes references
java.util.Map<Blob, Integer> beforeCounts = new java.util.IdentityHashMap<>();
java.util.Map<Blob, Integer> afterCounts = new java.util.IdentityHashMap<>();
for (Blob b : before) beforeCounts.merge(b, 1, Integer::sum);
for (Blob b : after) afterCounts.merge(b, 1, Integer::sum);
assertEquals(beforeCounts, afterCounts, "Same blob references before and after 2-opt");

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