Arbor — ALNS Metaheuristic Framework

Self-tuning metaheuristic search for hard optimization

Arbor is an Adaptive Large Neighborhood Search (ALNS) framework written in C11 with zero external dependencies. Register destroy and repair operators, choose an acceptance criterion (SA, RRT, Great Deluge, or strict improving), and Arbor handles adaptive weight learning, roulette selection, temperature calibration, and restart logic. The operators that find good solutions get used more. Automatically.

66 tests 4 acceptance criteria Zero deps C11 WASM ~1.4K LoC AGPLv3 + Trucking Exception
Why

The search engine behind Surge

Hard combinatorial optimization (VRP, scheduling, packing) doesn't yield to exact solvers at real-world scale. You need metaheuristics — and the quality of your metaheuristic framework determines the quality of your solutions. Most ALNS implementations are bespoke, tightly coupled to a single problem, and impossible to test independently.

Arbor separates the search strategy (operator selection, acceptance, adaptation) from the problem domain (VRP, scheduling, etc.). Surge registers 18 operators. Arbor manages which ones run, how often, and when to accept worse solutions. This separation means Arbor is tested independently (66 tests) and Surge operators are tested independently (340 tests).

Adaptive weight learning
Segment-based weight adaptation. Operators that find better solutions get higher roulette weights. Configurable reaction factor controls learning speed: 10% new information, 90% history. Operators self-tune during search.
Four acceptance criteria
Simulated Annealing (probabilistic), Record-to-Record Travel (threshold), Great Deluge (water level decay), or strict improving-only. Switch at runtime. SA auto-calibration from problem cost.
Pluggable operators
Register any number of destroy/repair function pointers with named weights. Arbor provides helper removal strategies: random, worst-cost, and Shaw relatedness-based. Problem-specific operators plug in cleanly.
Deterministic reproducibility
Seeded Xoshiro256** RNG. Same seed, same operator selections, same acceptance decisions, same solution. Critical for benchmarking, debugging, and regression testing. Set once with ar_alns_set_seed().

Real-world usage. Surge registers 12 destroy and 6 repair operators into Arbor. On Li & Lim PDPTW benchmarks, Arbor's adaptive weights learn which operators work for each instance — string removal dominates on clustered problems, Shaw removal on random distributions. No manual tuning needed.

Framework

How ALNS works

  ar_alns_solve(ctx, initial_solution, best_out)
         │
         ▼
  ┌──────────────────────────────────────────────────────────┐
  │                    ALNS Main Loop                        │
  │                                                          │
  │  ┌────────────┐    ┌────────────┐    ┌────────────────┐  │
  │  │  Roulette   │───▶│  Destroy   │───▶│    Repair      │  │
  │  │  Selection  │    │  Operator  │    │    Operator    │  │
  │  │  (weighted) │    │  (remove q │    │    (reinsert)  │  │
  │  └────────────┘    │   elements)│    └───────┬────────┘  │
  │                    └────────────┘            │           │
  │                                              ▼           │
  │  ┌────────────┐    ┌────────────┐    ┌────────────────┐  │
  │  │  Weight     │◀───│  Segment   │◀───│   Accept /     │  │
  │  │  Update     │    │  Boundary  │    │   Reject       │  │
  │  │  (adaptive) │    │  (every N) │    │   (SA/RRT/GD)  │  │
  │  └────────────┘    └────────────┘    └────────────────┘  │
  │                                                          │
  │  Stop: max_iters │ time_limit │ stagnation │ target_cost │
  └──────────────────────────────────────────────────────────┘
         │
         ▼
  ARALNSStats { iterations, improvements, best_cost, ... }
4
Acceptance Criteria
3
Removal Helpers
16
API Functions
66
Tests

Acceptance criteria

Criterion Accepts Worse? Behavior Best For
Simulated Annealing Yes (probabilistic) P = exp(-Δ/T), T cools each iteration General-purpose exploration
Record-to-Record Travel Within threshold Accept if cost ≤ best + threshold Greedy intensification
Great Deluge Above water level Accept if cost ≤ water level (decays) Gradual convergence
Improving Only Never Only strictly better solutions Pure exploitation / polish

Adaptive weight learning

Every segment_size iterations (default 100), Arbor updates operator weights based on their performance during that segment:

// Reward for each operator usage during segment
if (new_global_best)   reward = 8.0;   // Best solution found
else if (improved)     reward = 4.0;   // Improving accepted
else if (accepted)     reward = 2.0;   // Non-improving accepted
else                   reward = 0.5;   // Rejected

// Weight update at segment boundary
avg_reward = segment_score / segment_uses;
new_weight = (1 - reaction) * old_weight + reaction * avg_reward;
// reaction = 0.1: 10% new data, 90% history

This means operators that consistently find improvements get selected more often. Operators that waste CPU get downweighted. The reaction_factor (default 0.1) controls learning speed — low values keep weights stable, high values react quickly to recent performance.

SA temperature calibration

ar_alns_calibrate_sa() auto-tunes the initial temperature and cooling rate from the problem cost and iteration budget:

  • T0 = 0.05 × |cost| / ln(2) — a 5%-worse solution is accepted with ~50% probability initially
  • Cooling rate = exp(-6.9 / max_iterations) — temperature drops to 0.1% of T0 by the end
  • Example: cost=1000, 1000 iters → T0 ≈ 72, cooling ≈ 0.993, final T ≈ 0.07
Operators

Built-in removal strategies + pluggable anything

Standard removal helpers

Arbor provides three parameterized removal strategies in ar_operators.h. These are building blocks — your domain operators call them with problem-specific callbacks for element access, cost, and relatedness.

Strategy Selection Complexity Character
ar_remove_random Uniform random from solution O(q) Pure diversification
ar_remove_worst Highest-cost elements, rank-based randomness O(n log n) Targeted improvement
ar_remove_related Seed + expand by relatedness (Shaw) O(n × q) Structural neighborhood

Surge's 18 operators (real-world example)

12 Destroy operators

  • random — uniform selection
  • worst — highest reduced cost
  • shaw — distance relatedness
  • paired-shaw — PD pair relatedness
  • string — contiguous stop sequences
  • route-removal — entire routes
  • route-cluster — geographic clustering
  • time-cluster — temporal clustering
  • time-window-removal — TW clustering
  • criticality-worst — cost criticality
  • vehicle-target — target empty runs
  • vehicle-empty — empty vehicle removal

6 Repair operators

  • greedy-insert — best insertion cost
  • regret-2 — 2-opt regret heuristic
  • regret-3 — 3-opt regret (primary)
  • regret-4 — 4-opt regret heuristic
  • noise-regret — regret + noise for diversity
  • pair-sync — PD synchronization

Each operator starts with weight 1.0. After 100 iterations, Arbor has already learned which operators work for the current instance.

Adaptive destruction size

When enabled, the number of elements removed (q) adapts to search progress. During improvement phases, q stays small (exploitation). During stagnation, q grows up to 3× the initial maximum (exploration). Resets to initial when improvements resume.

API

Register, solve, read stats — three steps

#include "ar_alns.h"

// 1. Configure
ARALNSParams params;
ar_alns_params_default(&params);
params.max_iterations = 5000;
params.accept_type = AR_ACCEPT_SA;
ar_alns_calibrate_sa(&params, initial_cost, 5000);

// Solution callbacks: copy, free, cost, size, validate, is_better
ARSolutionOps ops = {
    .copy = my_copy, .free = my_free,
    .cost = my_cost, .size = my_size,
    .is_better = my_compare
};

// 2. Create context and register operators
ARALNSContext *ctx = ar_alns_create(&params, &ops, solver_data);
ar_alns_set_seed(ctx, 42);

ar_alns_add_destroy(ctx, "random",    destroy_random,  NULL, 1.0);
ar_alns_add_destroy(ctx, "worst",     destroy_worst,   NULL, 1.0);
ar_alns_add_destroy(ctx, "shaw",      destroy_shaw,    NULL, 1.0);
ar_alns_add_repair(ctx,  "regret-3",  repair_regret3,  NULL, 1.0);
ar_alns_add_repair(ctx,  "greedy",    repair_greedy,   NULL, 1.0);

// 3. Solve
void *best = NULL;
ar_alns_solve(ctx, initial_solution, &best);

// 4. Read results
ARALNSStats stats;
ar_alns_get_stats(ctx, &stats);
// stats.best_cost, stats.iterations, stats.improvements

// Per-operator performance
for (int i = 0; i < ar_alns_destroy_count(ctx); i++) {
    ARALNSOperatorStats os;
    ar_alns_get_destroy_stats(ctx, i, &os);
    // os.name, os.weight, os.selected, os.improvements
}

ar_alns_free(ctx);

Solution interface

Arbor is problem-agnostic. You provide six callbacks that define your problem:

Callback Signature Purpose
copy void *(*)(const void *) Deep copy a solution
free void (*)(void *) Free a solution
cost double (*)(const void *) Evaluate solution cost
size int (*)(const void *) Number of elements (for q calculation)
validate int (*)(const void *) Check feasibility (optional)
is_better int (*)(const void *, const void *) Compare two solutions

Progress callback

// Called at each segment boundary (~100 iterations)
int on_progress(const ARALNSStats *stats, void *user_data) {
    printf("iter=%lld cost=%.1f improved=%lld\n",
           stats->iterations, stats->best_cost, stats->improvements);
    return should_cancel ? 1 : 0;  // return 1 to stop
}

ar_alns_set_progress_callback(ctx, on_progress, my_data);
Under the Hood

Design decisions

Restart mechanism

When stagnation exceeds restart_threshold, Arbor resets to the best known solution and reheats the SA temperature to initial_temp × restart_temp_ratio. This escapes local optima without losing the global best. Statistics track restart count.

Memory model

Operator arrays grow dynamically (default capacity 8, doubles as needed). The best solution is cached in context. One SHRng (Xoshiro256**) instance per context. No global state — multiple ALNS contexts run concurrently without interference.

Stop conditions

Reason Configuration Use Case
AR_STOP_MAX_ITERATIONS max_iterations Fixed compute budget
AR_STOP_TIME_LIMIT max_time_seconds Wall-clock deadline
AR_STOP_STAGNATION max_stagnation_iterations No progress detection
AR_STOP_TARGET_COST target_cost Known optimum (benchmarking)
AR_STOP_CANCELLED Progress callback returns 1 User cancellation

Codebase

~1.4K
Lines of C
66
Tests
16
API Functions
0
Ext Dependencies
Quick Start

Get started in minutes

1. Build from source

# Clone and build
git clone https://github.com/ottofleet/otto.git
cd otto && make arbor

# Run tests
make test-arbor

2. Minimal example

// Define your solution type
typedef struct { int *assignment; int n; double cost; } MySolution;

// Implement 6 callbacks: copy, free, cost, size, validate, is_better
// Write at least 1 destroy + 1 repair operator

// Create, register, solve
ARALNSParams p;
ar_alns_params_default(&p);
p.max_iterations = 1000;

ARALNSContext *ctx = ar_alns_create(&p, &ops, NULL);
ar_alns_add_destroy(ctx, "random", my_destroy, NULL, 1.0);
ar_alns_add_repair(ctx, "greedy", my_repair, NULL, 1.0);

void *best = NULL;
ar_alns_solve(ctx, initial, &best);
ar_alns_free(ctx);
FAQ

Common questions

Why separate Arbor from Surge?

Testability and reuse. Arbor's 66 tests validate the ALNS framework independently of any problem domain. If we build a scheduling module tomorrow, it registers its own operators and gets adaptive weight learning, SA acceptance, restarts, and per-operator statistics for free. Surge focuses purely on VRP domain logic.

Which acceptance criterion should I use?

Start with Simulated Annealing and use ar_alns_calibrate_sa() to auto-tune. SA is the most versatile criterion — it explores aggressively early (high temperature) and converges late (low temperature). Use RRT for quick diversification with a known quality threshold. Use Great Deluge for slow, steady convergence. Use improving-only for final polishing after SA has explored.

How many operators should I register?

More is better, up to a point. Surge uses 18 operators. The adaptive weights handle operator selection — poor operators get weight ≈0.01 and rarely fire. Minimum: 1 destroy + 1 repair. For competitive results on VRP: 6–12 destroy + 3–6 repair operators covering random, worst, related, and structural removal.

What about genetic algorithms or tabu search?

ALNS is the state-of-the-art metaheuristic for vehicle routing and scheduling problems. Ropke & Pisinger (2006) showed it consistently outperforms tabu search and GA on VRPTW. Surge also implements SREX crossover (population-based) alongside Arbor's ALNS — the two approaches complement each other.

What license?

AGPLv3 with a Trucking Exception. Same terms as all OTTO modules.