solverforge-scoring 0.8.9

Incremental constraint scoring for SolverForge
Documentation
use crate::constraint::incremental::IncrementalUniConstraint;
use crate::constraint::IncrementalBiConstraint;
use crate::director::score_director::ScoreDirector;
use solverforge_core::domain::PlanningSolution;
use solverforge_core::score::SoftScore;
use solverforge_core::{ConstraintRef, ImpactType};
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
        );
    }
}