///|
/// Portable input for staffing demands that need multiple workers per slot.
pub(all) struct CoverageRequest {
workers : Array[Worker]
requirements : Array[CoverageRequirement]
policy : RosterPolicy
} derive(Eq, Debug, ToJson, FromJson)
///|
/// Stable JSON envelope for a bounded coverage search.
pub(all) struct CoverageSolveResponse {
status : String
roster : Json
stats : Json
error : Json
} derive(ToJson)
///|
/// Stable JSON envelope for an exact fairness search.
pub(all) struct FairCoverageResponse {
status : String
roster : Json
balance : Json
windows_checked : Int
total_nodes : Int
total_backtracks : Int
error : Json
} derive(ToJson)
///|
/// Decode a coverage request, preserving parser and schema errors separately.
pub fn decode_coverage_request(
text : String,
) -> Result[CoverageRequest, RosterJsonError] {
let json = @json.parse(text) catch {
error => return Err(InvalidJson(error.to_string()))
}
let request : CoverageRequest = @json.from_json(json) catch {
error => return Err(InvalidRosterSchema(error.to_string()))
}
Ok(request)
}
///|
/// Solve a decoded coverage request with the standard scheduling rules.
pub fn CoverageRequest::solve(
self : CoverageRequest,
) -> Result[CoverageRoster, RosterError] {
build_coverage_roster(self.workers, self.requirements, self.policy)
}
///|
/// Solve a JSON-compatible request with a consecutive-work hard limit.
pub fn CoverageRequest::solve_with_consecutive_limit(
self : CoverageRequest,
maximum_consecutive_slots : Int,
) -> Result[CoverageRoster, RosterError] {
build_coverage_roster_with_consecutive_limit(
self.workers,
self.requirements,
self.policy,
maximum_consecutive_slots,
)
}
///|
/// Solve a request with an explicit search budget and a three-way outcome.
pub fn CoverageRequest::solve_with_node_limit(
self : CoverageRequest,
node_limit : Int,
) -> Result[CoverageSolveOutcome, RosterError] {
build_coverage_roster_with_node_limit(
self.workers,
self.requirements,
self.policy,
node_limit,
)
}
///|
/// Search a coverage request with a selected strategy and assignment budget.
pub fn CoverageRequest::solve_with_strategy(
self : CoverageRequest,
node_limit : Int,
strategy : SearchStrategy,
) -> Result[CoverageSolveOutcome, RosterError] {
build_coverage_roster_with_strategy(
self.workers,
self.requirements,
self.policy,
node_limit,
strategy,
)
}
///|
/// Evaluate a bounded number of feasible coverage rosters for workload balance.
pub fn CoverageRequest::solve_balanced(
self : CoverageRequest,
candidate_limit : Int,
) -> Result[OptimizedCoverageRoster, RosterError] {
build_balanced_coverage_roster(
self.workers,
self.requirements,
self.policy,
candidate_limit,
)
}
///|
/// Search for a provably fairest coverage roster within a global node budget.
pub fn CoverageRequest::solve_fairest(
self : CoverageRequest,
node_limit : Int,
) -> Result[FairCoverageOutcome, RosterError] {
build_fairest_coverage_roster(
self.workers,
self.requirements,
self.policy,
node_limit,
)
}
///|
/// Encode an exact fairness outcome without conflating budget exhaustion
/// with an optimality or infeasibility proof.
pub fn encode_fair_coverage_outcome(
outcome : FairCoverageOutcome,
indent : Int,
) -> String {
let absent : String? = None
let null = @json.to_json(absent)
let response : FairCoverageResponse = match outcome {
FairCoverageFound(result) =>
{
status: "optimal",
roster: @json.to_json(result.roster),
balance: @json.to_json(result.balance),
windows_checked: result.windows_checked,
total_nodes: result.total_nodes,
total_backtracks: result.total_backtracks,
error: null,
}
FairCoverageProvedUnsatisfiable(stats, windows_checked) =>
{
status: "unsatisfiable",
roster: null,
balance: null,
windows_checked,
total_nodes: stats.nodes,
total_backtracks: stats.backtracks,
error: null,
}
FairCoverageBudgetExhausted(stats, windows_checked) =>
{
status: "budget_exhausted",
roster: null,
balance: null,
windows_checked,
total_nodes: stats.nodes,
total_backtracks: stats.backtracks,
error: null,
}
}
@json.to_json(response).stringify(indent~)
}
///|
/// Encode a bounded search outcome for CLI, browser, and service callers.
/// `budget_exhausted` is intentionally distinct from `unsatisfiable`.
pub fn encode_coverage_solve_outcome(
outcome : CoverageSolveOutcome,
indent : Int,
) -> String {
let absent : String? = None
let response : CoverageSolveResponse = match outcome {
CoverageFound(roster) =>
{
status: "found",
roster: @json.to_json(roster),
stats: @json.to_json(roster.stats),
error: @json.to_json(absent),
}
CoverageProvedUnsatisfiable(stats) =>
{
status: "unsatisfiable",
roster: @json.to_json(absent),
stats: @json.to_json(stats),
error: @json.to_json(absent),
}
CoverageBudgetExhausted(stats) =>
{
status: "budget_exhausted",
roster: @json.to_json(absent),
stats: @json.to_json(stats),
error: @json.to_json(absent),
}
}
@json.to_json(response).stringify(indent~)
}
///|
/// Encode a coverage roster and its search statistics as JSON text.
pub fn encode_coverage_roster(roster : CoverageRoster, indent : Int) -> String {
@json.to_json(roster).stringify(indent~)
}