solverforge-scoring 0.9.1

Incremental constraint scoring for SolverForge
Documentation
use crate::api::constraint_set::IncrementalConstraint;
use crate::constraint::incremental::IncrementalUniConstraint;
use crate::constraint::IncrementalBiConstraint;
use crate::director::score_director::ScoreDirector;
use crate::stream::collection_extract::ChangeSource;
use crate::stream::joiner::equal_bi;
use crate::stream::ConstraintFactory;
use solverforge_core::domain::PlanningSolution;
use solverforge_core::score::SoftScore;
use solverforge_core::{ConstraintRef, ImpactType};
use std::hash::Hash;
use std::hint::black_box;
use std::time::Instant;

#[derive(Clone, Debug, Hash, PartialEq, Eq)]
struct Shift {
    id: usize,
    employee_id: Option<usize>,
    start_hour: u8,
    end_hour: u8,
}

#[derive(Clone, Debug)]
struct Schedule {
    shifts: Vec<Shift>,
    score: Option<SoftScore>,
}

impl PlanningSolution for Schedule {
    type Score = SoftScore;

    fn score(&self) -> Option<Self::Score> {
        self.score
    }

    fn set_score(&mut self, score: Option<Self::Score>) {
        self.score = score;
    }
}

fn shifts(s: &Schedule) -> &[Shift] {
    s.shifts.as_slice()
}

fn calculate_full(schedule: &Schedule) -> SoftScore {
    let shifts = &schedule.shifts;
    let mut penalty = 0i64;

    for shift in shifts {
        if shift.employee_id.is_none() {
            penalty += 1;
        }
    }

    for i in 0..shifts.len() {
        for j in (i + 1)..shifts.len() {
            let a = &shifts[i];
            let b = &shifts[j];
            if a.employee_id.is_some()
                && a.employee_id == b.employee_id
                && a.start_hour < b.end_hour
                && b.start_hour < a.end_hour
            {
                penalty += 10;
            }
        }
    }

    SoftScore::of(-penalty)
}

fn create_schedule(n: usize) -> Schedule {
    let shifts: Vec<_> = (0..n)
        .map(|i| Shift {
            id: i,
            employee_id: Some(0),
            start_hour: (i % 24) as u8,
            end_hour: ((i % 24) + 1) as u8,
        })
        .collect();

    Schedule {
        shifts,
        score: None,
    }
}

#[test]
fn bench_full_recalc_moves() {
    let n = 100;
    let moves = 1000;

    let mut schedule = create_schedule(n);

    let start = Instant::now();

    for i in 0..moves {
        let shift_idx = i % n;
        let old_employee = schedule.shifts[shift_idx].employee_id;
        schedule.shifts[shift_idx].employee_id = Some((i % 5) + 1);
        let _score = calculate_full(&schedule);
        schedule.shifts[shift_idx].employee_id = old_employee;
    }

    let elapsed = start.elapsed();
    let moves_per_sec = moves as f64 / elapsed.as_secs_f64();

    eprintln!(
        "Full recalc: {} moves in {:?} ({:.0} moves/sec)",
        moves, elapsed, moves_per_sec
    );

    assert!(moves_per_sec > 0.0);
}

#[test]
fn bench_incremental_moves() {
    let n = 100;
    let moves = 1000;

    let schedule = create_schedule(n);

    let unassigned = IncrementalUniConstraint::new(
        ConstraintRef::new("", "Unassigned"),
        ImpactType::Penalty,
        shifts,
        |_sol: &Schedule, s: &Shift| s.employee_id.is_none(),
        |_s: &Shift| SoftScore::of(1),
        false,
    );

    let overlapping = IncrementalBiConstraint::new(
        ConstraintRef::new("", "Overlapping"),
        ImpactType::Penalty,
        shifts,
        |_sol: &Schedule, s: &Shift, _idx: usize| s.employee_id,
        |_sol: &Schedule, a: &Shift, b: &Shift, _ai: usize, _bi: usize| {
            a.id < b.id && a.start_hour < b.end_hour && b.start_hour < a.end_hour
        },
        |_s: &Schedule, _a_idx: usize, _b_idx: usize| SoftScore::of(10),
        false,
    );

    let constraints = (unassigned, overlapping);
    let mut director = ScoreDirector::new(schedule, constraints);

    let initial = director.calculate_score();
    assert_eq!(initial, calculate_full(director.working_solution()));

    let start = Instant::now();

    for i in 0..moves {
        let shift_idx = i % n;
        let old_employee = director.working_solution().shifts[shift_idx].employee_id;

        director.before_variable_changed(0, shift_idx);
        director.working_solution_mut().shifts[shift_idx].employee_id = Some((i % 5) + 1);
        director.after_variable_changed(0, shift_idx);
        let _score = director.get_score();
        director.before_variable_changed(0, shift_idx);
        director.working_solution_mut().shifts[shift_idx].employee_id = old_employee;
        director.after_variable_changed(0, shift_idx);
    }

    let elapsed = start.elapsed();
    let moves_per_sec = moves as f64 / elapsed.as_secs_f64();

    eprintln!(
        "Incremental: {} moves in {:?} ({:.0} moves/sec)",
        moves, elapsed, moves_per_sec
    );

    let final_score = director.get_score();
    assert_eq!(final_score, calculate_full(director.working_solution()));
    assert!(moves_per_sec > 0.0);
}

#[test]
fn bench_compare_approaches() {
    for n in [50, 100, 200] {
        let moves = 500;
        let schedule = create_schedule(n);

        let mut schedule_full = schedule.clone();
        let start = Instant::now();
        for i in 0..moves {
            let shift_idx = i % n;
            let old = schedule_full.shifts[shift_idx].employee_id;
            schedule_full.shifts[shift_idx].employee_id = Some((i % 5) + 1);
            let _score = calculate_full(&schedule_full);
            schedule_full.shifts[shift_idx].employee_id = old;
        }
        let full_elapsed = start.elapsed();

        let unassigned = IncrementalUniConstraint::new(
            ConstraintRef::new("", "Unassigned"),
            ImpactType::Penalty,
            shifts,
            |_sol: &Schedule, s: &Shift| s.employee_id.is_none(),
            |_s: &Shift| SoftScore::of(1),
            false,
        );

        let overlapping = IncrementalBiConstraint::new(
            ConstraintRef::new("", "Overlapping"),
            ImpactType::Penalty,
            shifts,
            |_sol: &Schedule, s: &Shift, _idx: usize| s.employee_id,
            |_sol: &Schedule, a: &Shift, b: &Shift, _ai: usize, _bi: usize| {
                a.id < b.id && a.start_hour < b.end_hour && b.start_hour < a.end_hour
            },
            |_s: &Schedule, _a_idx: usize, _b_idx: usize| SoftScore::of(10),
            false,
        );

        let constraints = (unassigned, overlapping);
        let mut director = ScoreDirector::new(schedule.clone(), constraints);
        director.calculate_score();

        let start = Instant::now();
        for i in 0..moves {
            let shift_idx = i % n;
            let old = director.working_solution().shifts[shift_idx].employee_id;
            director.before_variable_changed(0, shift_idx);
            director.working_solution_mut().shifts[shift_idx].employee_id = Some((i % 5) + 1);
            director.after_variable_changed(0, shift_idx);
            let _score = director.get_score();
            director.before_variable_changed(0, shift_idx);
            director.working_solution_mut().shifts[shift_idx].employee_id = old;
            director.after_variable_changed(0, shift_idx);
        }
        let incr_elapsed = start.elapsed();

        let full_rate = moves as f64 / full_elapsed.as_secs_f64();
        let incr_rate = moves as f64 / incr_elapsed.as_secs_f64();
        let speedup = incr_rate / full_rate;

        eprintln!(
            "n={:3}: Full={:7.0} m/s, Incr={:7.0} m/s, Speedup={:.1}x",
            n, full_rate, incr_rate, speedup
        );
    }
}

#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
struct ExistsBenchKey(usize);

#[derive(Clone, Debug)]
struct ExistsBenchState<K> {
    customers: Vec<K>,
    routes: Vec<Vec<K>>,
}

fn exists_bench_customers<K>(state: &ExistsBenchState<K>) -> &[K] {
    state.customers.as_slice()
}

fn exists_bench_routes<K>(state: &ExistsBenchState<K>) -> &[Vec<K>] {
    state.routes.as_slice()
}

fn make_exists_bench_state<K, F>(
    customer_count: usize,
    route_count: usize,
    route_len: usize,
    make_key: F,
) -> ExistsBenchState<K>
where
    K: Copy,
    F: Fn(usize) -> K + Copy,
{
    let customers = (0..customer_count).map(make_key).collect();
    let mut routes = Vec::with_capacity(route_count);
    for route_idx in 0..route_count {
        let mut route = Vec::with_capacity(route_len);
        for offset in 0..route_len {
            route.push(make_key((route_idx * route_len + offset) % customer_count));
        }
        routes.push(route);
    }
    ExistsBenchState { customers, routes }
}

fn rewrite_exists_bench_route<K, F>(
    route: &mut [K],
    move_idx: usize,
    customer_count: usize,
    make_key: F,
) where
    K: Copy,
    F: Fn(usize) -> K + Copy,
{
    let base = (move_idx * 37) % customer_count;
    for (offset, value) in route.iter_mut().enumerate() {
        *value = make_key((base + offset * 17) % customer_count);
    }
}

fn run_flattened_exists_storage_bench<K, F>(label: &str, make_key: F) -> (SoftScore, f64)
where
    K: Copy + Clone + Eq + Hash + Send + Sync + 'static,
    F: Fn(usize) -> K + Copy,
{
    const CUSTOMER_COUNT: usize = 12_000;
    const ROUTE_COUNT: usize = 96;
    const ROUTE_LEN: usize = 96;
    const MOVES: usize = 25_000;

    let mut state = make_exists_bench_state(CUSTOMER_COUNT, ROUTE_COUNT, ROUTE_LEN, make_key);
    let mut constraint = ConstraintFactory::<ExistsBenchState<K>, SoftScore>::new()
        .for_each_tracked(
            exists_bench_customers::<K> as fn(&ExistsBenchState<K>) -> &[K],
            ChangeSource::Static,
        )
        .if_not_exists((
            ConstraintFactory::<ExistsBenchState<K>, SoftScore>::new()
                .for_each_tracked(
                    exists_bench_routes::<K> as fn(&ExistsBenchState<K>) -> &[Vec<K>],
                    ChangeSource::Descriptor(0),
                )
                .flattened(|route: &Vec<K>| route),
            equal_bi(|customer: &K| *customer, |assigned: &K| *assigned),
        ))
        .penalize(SoftScore::of(1))
        .named(label);

    let mut total = constraint.initialize(&state);
    assert_eq!(total, constraint.evaluate(&state));

    let start = Instant::now();
    for move_idx in 0..MOVES {
        let route_idx = move_idx % ROUTE_COUNT;
        total = total + constraint.on_retract(&state, route_idx, 0);
        rewrite_exists_bench_route(
            &mut state.routes[route_idx],
            move_idx,
            CUSTOMER_COUNT,
            make_key,
        );
        total = total + constraint.on_insert(&state, route_idx, 0);

        if move_idx % 5_000 == 0 {
            assert_eq!(total, constraint.evaluate(&state));
        }
        black_box(total);
    }
    let elapsed = start.elapsed();

    assert_eq!(total, constraint.evaluate(&state));
    let moves_per_sec = MOVES as f64 / elapsed.as_secs_f64();
    eprintln!(
        "{label}: {MOVES} route rewrites in {:?} ({:.0} moves/sec), final score {}",
        elapsed, moves_per_sec, total
    );
    (total, moves_per_sec)
}

#[test]
#[ignore = "release-only storage comparison; run with --release -- --ignored --nocapture"]
fn bench_exists_usize_storage() {
    let (usize_score, usize_rate) =
        run_flattened_exists_storage_bench("exists usize indexed", |idx| idx);
    let (key_score, key_rate) =
        run_flattened_exists_storage_bench("exists newtype hashed", ExistsBenchKey);

    assert_eq!(usize_score, key_score);
    eprintln!(
        "exists storage comparison: usize indexed {:.0} moves/sec, newtype hashed {:.0} moves/sec",
        usize_rate, key_rate
    );
}

#[test]
#[ignore = "release-only indexed-storage profile; run with perf in release mode"]
fn bench_exists_indexed_usize_storage_only() {
    let (score, moves_per_sec) =
        run_flattened_exists_storage_bench("exists usize indexed only", |idx| idx);
    eprintln!(
        "exists indexed-only profile target: {:.0} moves/sec, final score {}",
        moves_per_sec, score
    );
}