///|
/// A staffing demand that requires several distinct workers in one slot.
pub(all) struct CoverageRequirement {
id : Int
name : String
slot : Int
required_skill : String
workers_needed : Int
} derive(Eq, Debug, ToJson, FromJson)
///|
/// One filled position within a coverage requirement.
///
/// Positions are numbered from one in deterministic requirement order.
pub(all) struct CoverageEntry {
requirement : CoverageRequirement
position : Int
worker : Worker
} derive(Eq, Debug, ToJson)
///|
/// A fulfilled set of staffing demands together with solver evidence.
pub(all) struct CoverageRoster {
entries : Array[CoverageEntry]
stats : SolveStats
} derive(Eq, Debug, ToJson)
///|
/// The fairest coverage roster found among a bounded set of candidates.
/// A lower spread means more even assignment counts across all workers.
pub(all) struct OptimizedCoverageRoster {
roster : CoverageRoster
balance : BalanceScore
candidates_evaluated : Int
} derive(Eq, Debug, ToJson)
///|
/// A bounded coverage search distinguishes a roster from a proof of
/// infeasibility and from an incomplete search.
pub(all) enum CoverageSolveOutcome {
CoverageFound(CoverageRoster)
CoverageProvedUnsatisfiable(SolveStats)
CoverageBudgetExhausted(SolveStats)
} derive(Eq, Debug)
///|
priv struct CoverageExpansion {
shifts : Array[Shift]
requirement_indexes : Array[Int]
positions : Array[Int]
}
///|
fn validate_coverage_requirements(
requirements : Array[CoverageRequirement],
) -> Result[Unit, RosterError] {
for index = 0; index < requirements.length(); index = index + 1 {
let requirement = requirements[index]
if requirement.workers_needed <= 0 {
return Err(
InvalidCoverageWorkerCount(requirement.id, requirement.workers_needed),
)
}
for previous = 0; previous < index; previous = previous + 1 {
if requirements[previous].id == requirement.id {
return Err(DuplicateCoverageRequirementId(requirement.id))
}
}
}
Ok(())
}
///|
fn expand_coverage_requirements(
requirements : Array[CoverageRequirement],
) -> CoverageExpansion {
let shifts : Array[Shift] = []
let requirement_indexes : Array[Int] = []
let positions : Array[Int] = []
for requirement_index = 0
requirement_index < requirements.length()
requirement_index = requirement_index + 1 {
let requirement = requirements[requirement_index]
for position = 1
position <= requirement.workers_needed
position = position + 1 {
shifts.push({
id: shifts.length(),
name: "\{requirement.name} #\{position}",
slot: requirement.slot,
required_skill: requirement.required_skill,
})
requirement_indexes.push(requirement_index)
positions.push(position)
}
}
{ shifts, requirement_indexes, positions, }
}
///|
fn coverage_roster_from_roster(
roster : Roster,
requirements : Array[CoverageRequirement],
expansion : CoverageExpansion,
) -> CoverageRoster {
let entries : Array[CoverageEntry] = []
for entry in roster.entries {
let shift_index = entry.shift.id
entries.push({
requirement: requirements[expansion.requirement_indexes[shift_index]],
position: expansion.positions[shift_index],
worker: entry.worker,
})
}
{ entries, stats: roster.stats, }
}
///|
/// Fill every requested position under the standard roster policy.
///
/// A worker cannot fill two positions in the same slot. Skill, availability,
/// workload, and rest rules are enforced by the regular roster model.
pub fn build_coverage_roster(
workers : Array[Worker],
requirements : Array[CoverageRequirement],
policy : RosterPolicy,
) -> Result[CoverageRoster, RosterError] {
match validate_coverage_requirements(requirements) {
Err(error) => return Err(error)
Ok(_) => ()
}
let expansion = expand_coverage_requirements(requirements)
match build_roster_with_policy(workers, expansion.shifts, policy) {
Err(error) => Err(error)
Ok(roster) =>
Ok(coverage_roster_from_roster(roster, requirements, expansion))
}
}
///|
/// Fill multi-worker demands while limiting consecutive occupied slots.
///
/// This applies the same skill, availability, workload, and rest policy as
/// `build_coverage_roster`, plus the requested consecutive-work limit.
pub fn build_coverage_roster_with_consecutive_limit(
workers : Array[Worker],
requirements : Array[CoverageRequirement],
policy : RosterPolicy,
maximum_consecutive_slots : Int,
) -> Result[CoverageRoster, RosterError] {
match validate_coverage_requirements(requirements) {
Err(error) => return Err(error)
Ok(_) => ()
}
let expansion = expand_coverage_requirements(requirements)
match
build_roster_with_consecutive_limit(
workers,
expansion.shifts,
policy,
maximum_consecutive_slots,
) {
Err(error) => Err(error)
Ok(roster) =>
Ok(coverage_roster_from_roster(roster, requirements, expansion))
}
}
///|
/// Search for a coverage roster within at most `node_limit` assignments.
///
/// Exhausting the budget does not prove that a roster is impossible. Model
/// validation errors are returned separately from all three search outcomes.
pub fn build_coverage_roster_with_node_limit(
workers : Array[Worker],
requirements : Array[CoverageRequirement],
policy : RosterPolicy,
node_limit : Int,
) -> Result[CoverageSolveOutcome, RosterError] {
build_coverage_roster_with_strategy(
workers,
requirements,
policy,
node_limit,
Mrv,
)
}
///|
/// Search for a coverage roster with an explicit deterministic strategy.
/// Different strategies can change search cost without changing the rules.
pub fn build_coverage_roster_with_strategy(
workers : Array[Worker],
requirements : Array[CoverageRequirement],
policy : RosterPolicy,
node_limit : Int,
strategy : SearchStrategy,
) -> Result[CoverageSolveOutcome, RosterError] {
if node_limit <= 0 {
return Err(InvalidModel(InvalidNodeLimit(node_limit)))
}
match validate_coverage_requirements(requirements) {
Err(error) => return Err(error)
Ok(_) => ()
}
match validate_roster_policy(policy) {
Err(error) => return Err(error)
Ok(_) => ()
}
let expansion = expand_coverage_requirements(requirements)
let model = match build_roster_model(workers, expansion.shifts) {
Err(error) => return Err(error)
Ok(model) => model
}
match apply_roster_policy(model, workers, expansion.shifts, policy) {
Err(error) => return Err(error)
Ok(_) => ()
}
match model.problem.solve_with_node_limit(node_limit, strategy) {
Err(error) => Err(InvalidModel(error))
Ok(Found(solution, stats)) =>
Ok(
CoverageFound(
coverage_roster_from_roster(
roster_from_solution(
workers,
expansion.shifts,
model.variables,
solution,
stats,
),
requirements,
expansion,
),
),
)
Ok(ProvedUnsatisfiable(stats)) => Ok(CoverageProvedUnsatisfiable(stats))
Ok(BudgetExhausted(stats)) => Ok(CoverageBudgetExhausted(stats))
}
}
///|
/// Balance multi-worker coverage under the supplied hard roster policy.
///
/// This evaluates at most `candidate_limit` feasible rosters in deterministic
/// search order. The result is best among those candidates, not necessarily
/// the global optimum.
pub fn build_balanced_coverage_roster(
workers : Array[Worker],
requirements : Array[CoverageRequirement],
policy : RosterPolicy,
candidate_limit : Int,
) -> Result[OptimizedCoverageRoster, RosterError] {
match validate_coverage_requirements(requirements) {
Err(error) => return Err(error)
Ok(_) => ()
}
let expansion = expand_coverage_requirements(requirements)
match
build_balanced_roster_with_policy(
workers,
expansion.shifts,
policy,
candidate_limit,
) {
Err(error) => Err(error)
Ok(result) =>
Ok({
roster: coverage_roster_from_roster(
result.roster,
requirements,
expansion,
),
balance: result.balance,
candidates_evaluated: result.candidates_evaluated,
})
}
}
///|
/// Return the workers assigned to one coverage requirement, in position order.
pub fn CoverageRoster::workers_for_requirement(
self : CoverageRoster,
requirement_id : Int,
) -> Array[Worker] {
let workers : Array[Worker] = []
for entry in self.entries {
if entry.requirement.id == requirement_id {
workers.push(entry.worker)
}
}
workers
}