moonplan

    An explainable finite-domain constraint solver and scheduling toolkit for MoonBit.

    constraint-programming
    scheduling
    solver
    optimization
    wasm
    Download zip
    Author
    Version
    0.1.0
    License
    Apache-2.0
    Last updated
    6 hours ago
    Downloads
    2

    Dependencies

    #MoonPlan

    MoonPlan 是一个使用纯 MoonBit 实现的可解释有限域约束求解与排班工具包。它的目标不是只给出“有解/无解”,而是让业务建模者能够描述排班、资源分配、课程编排和配置组合问题,并获得可复现的解、搜索轨迹与冲突解释。

    当前仓库已经完成第一个可运行闭环:变量与有限域建模、模型边界检查、MRV(最少剩余值)变量选择、确定性回溯搜索,以及把人员技能、可用时段和同一时段冲突编译为约束的排班 API。

    #为什么值得做

    排班和资源分配广泛存在于门店、医院、实验室、赛事和学校,但业务规则经常被写成难以验证的循环和条件分支。MoonPlan 将这些规则提升为可组合约束,同时利用 MoonBit 的代数数据类型、模式匹配、多后端和 Wasm 能力,最终提供浏览器内可交互的建模、求解和解释工作台。

    项目定位聚焦于“技能与可用性驱动的可解释排班”。生态中已有通用有限域求解器,因此本项目不把通用回溯搜索作为唯一卖点,而把多人覆盖、预检证据、修复建议、可追踪目标和 Wasm/JSON 边界作为主要应用价值。

    #已实现

    • 有限域整数变量与稳定变量句柄
    • Equal、NotEqual、Different、OffsetDifferent
    • LessThan、AllDifferent、SumEquals、CountAtMost、CountBetween
    • AllowedTuples 轮班模板与任意兼容组合表约束
    • Element 索引查表约束,可把方案编号映射为成本或资源属性
    • LinearEquals、LinearAtMost 加权线性约束,可表达预算、工时和容量关系
    • MRV 默认搜索,以及可选的 degree 同分决策与 LCV 值排序
    • 节点数、回溯数和解数量统计
    • solve_with_node_limit 有界求解与明确的未完成结果
    • solve_with_trace 有界确定性搜索事件与 JSON 导出
    • 命名约束与不可满足模型的最小冲突集解释
    • suggest_capped_roster_repairs 排班修复建议与最小可行上限
    • suggest_roster_policy_repairs 组合策略诊断与最小休息间隔放宽建议
    • N 皇后、不可满足模型和人员排班测试
    • Worker、Shift、Roster 领域模型与可运行的人员排班示例
    • CoverageRequirement 与多人岗位覆盖排班
    • CoverageRequest 可从 JSON 解码并输出包含岗位名额的排班结果
    • encode_coverage_roster_csv 导出稳定顺序的岗位名额表格数据
    • analyze_coverage 按原始岗位需求定位人员资格与时段容量缺口
    • encode_coverage_analysis 导出稳定问题代码与原始业务 ID
    • analyze_coverage_with_policy 识别总岗位容量缺口和跨时段技能瓶颈
    • build_balanced_roster 有界候选优化与工作量均衡评分
    • CoverageRequest::solve_balanced 在多人岗位需求和硬性策略下评估有限候选排班
    • CoverageRequest::solve_fairest 在统一节点预算内证明最小工作量跨度
    • build_capped_roster 每人班次数量硬上限
    • WorkerQuota 与 build_roster_with_quotas 个人工作量区间
    • RosterPolicy 与 build_roster_with_policy 最小休息间隔组合规则
    • build_roster_with_consecutive_limit 连续工作时段硬上限
    • CoverageRequest::solve_with_consecutive_limit 用于多人岗位覆盖需求
    • CoverageRequest::solve_with_node_limit 区分找到解、证明无解和预算耗尽
    • AssignmentPenalty 与 build_preferred_roster 软偏好优化
    • RosterObjective 与相邻班次疲劳成本优化
    • RosterObjectiveOrder 加权或词典序多级目标排序
    • RosterPenaltyBreakdown 结构化说明每项偏好与连续班次成本
    • 基础请求与 RosterOptimizationRequest 组合目标 JSON 集成边界
    • analyze_roster 二分图匹配预检与容量冲突解释

    #运行

    moon check moon test moon run cmd/main moon run cmd/week

    cmd/main 会先生成满足技能和时段约束的排班,再演示当每人最多一班导致无解时,自动建议将统一工作量上限提高到二班。cmd/week 展示七天、21 个岗位名额的多人覆盖排班,并验证预先请假和每人最多四班的限制。

    cmd/coverage 接受 --json 参数,输出可供外部脚本处理的排班或错误 JSON。例如在 PowerShell 中运行 moon run cmd/coverage --json (Get-Content examples/coverage.json -Raw)。 添加 --format csv 可按岗位名额导出 CSV;错误仍以 JSON 返回。 添加 --check 可运行人员资格、时段容量和统一工作量上限的联合预检,并取得结构化问题 JSON;休息规则仍需完整求解。 添加 --node-limit N 可将搜索预算显式交给调用方;budget_exhausted 只表示搜索尚未完成,不会被误报成无解。 添加 --candidate-limit N 可取得有限候选中的工作量均衡排班及候选数;这个分数不代表全局最优证明。 添加 --fair-node-limit N 可搜索有证明的最小工作量跨度;预算耗尽时返回独立状态,不输出未经证明的最优排班。

    cmd/coverage_file 在 Wasm/原生目标上直接读取 --file PATH 或 --stdin,也支持 --check、--csv 与 --fair-node-limit N。例如:moon run --target wasm cmd/coverage_file -- --file examples/coverage_week.json --fair-node-limit 10000。WasmGC/JS 目标可继续使用上述 --json 入口。

    web/bridge 把预检和有预算的最公平排班导出到浏览器 WasmGC 模块;web/ 提供表单/JSON 编辑、场景导入导出和排班结果展示。运行方式见 web/README.md。

    底层模型可以通过 add_named_constraint 保留业务规则名称。无解时调用 Problem::explain,即可获得包含原始序号、规则名称和类型化约束的不可再删减冲突集合;策略修复 API 会继续把预检事实和冲突证据转换为增补合格人员、增加时段容量、提高工作量上限或放宽休息间隔等具体建议。

    #路线图

    1. 求解器内核:可撤销域与约束传播队列。
    2. 真实排班层:班次、人员、技能、工作量上限、最小休息间隔、分配偏好与相邻班次成本。
    3. 优化与解释:将现有均衡评分、冲突集和基础修复建议扩展为分支定界、加权目标与复杂规则修复。
    4. 可视化工作台:在现有 JSON 边界和搜索事件流上接入 Wasm 求解、甘特图/日历视图与交互解释。
    5. 工程成熟度:跨后端一致性、基准语料、属性测试、Mooncakes 发布与下游示例。

    详细边界和里程碑见 DESIGN.md。

    #开源说明

    项目采用 Apache-2.0 许可证。当前核心实现为原创 MoonBit 代码,不包含复制的第三方源码、测试数据或生成资产。

    AssignmentPenalty

    pub(all) struct AssignmentPenalty {
    worker_id : Int
    shift_id : Int
    penalty : Int
    } derive(Eq, ToJson,
    Debug
    ,
    FromJson
    )

    A non-negative soft cost for assigning one worker to one shift.

    Omitted worker-shift pairs have zero cost. Higher values express stronger preferences to avoid an assignment without making it impossible.

    AssignmentPenaltyCharge

    pub(all) struct AssignmentPenaltyCharge {
    worker_id : Int
    shift_id : Int
    penalty : Int
    } derive(Eq, ToJson,
    Debug
    )

    One assignment preference that contributes to a roster's soft cost.

    BalanceScore

    pub(all) struct BalanceScore {
    minimum_load : Int
    maximum_load : Int
    spread : Int
    } derive(Eq, ToJson,
    Debug
    )

    A workload balance score. Lower spread and lower maximum load are better.

    Binding

    pub(all) struct Binding {
    name : String
    value : Int
    } derive(Eq, ToJson,
    Debug
    )

    One named value in a solution.

    BudgetedSolveOutcome

    pub(all) enum BudgetedSolveOutcome {
    Found(Solution, SolveStats)
    ProvedUnsatisfiable(SolveStats)
    BudgetExhausted(SolveStats)
    } derive(Eq, ToJson,
    Debug
    )

    A first-solution search with a hard cap on attempted assignments. BudgetExhausted means no solution was found, but the model is not proven unsatisfiable because unexplored branches remain.

    ConflictItem

    pub(all) struct ConflictItem {
    index : Int
    name : String?
    constraint : Constraint
    } derive(Eq,
    Debug
    )

    One rule retained in an irreducible conflict set.

    ConflictReport

    pub(all) struct ConflictReport {
    items : Array[ConflictItem]
    solver_runs : Int
    } derive(Eq,
    Debug
    )

    A subset-minimal explanation for an unsatisfiable problem.

    Removing any one item from this report makes the retained constraint set satisfiable. solver_runs exposes the diagnostic cost.

    ConsecutiveSlotCharge

    pub(all) struct ConsecutiveSlotCharge {
    worker_id : Int
    earlier_shift_id : Int
    later_shift_id : Int
    penalty : Int
    } derive(Eq, ToJson,
    Debug
    )

    One adjacent pair of shifts that contributes to fatigue cost.

    Constraint

    pub(all) enum Constraint {
    Equal(Var, Int)
    NotEqual(Var, Int)
    Different(Var, Var)
    OffsetDifferent(Var, Var, Int)
    LessThan(Var, Var)
    AllDifferent(Array[Var])
    SumEquals(Array[Var], Int)
    CountAtMost(Array[Var], Int, Int)
    CountBetween(Array[Var], Int, Int, Int)
    AllowedTuples(Array[Var], Array[Array[Int]])
    Element(Var, Array[Int], Var)
    LinearEquals(Array[Var], Array[Int], Int)
    LinearAtMost(Array[Var], Array[Int], Int)
    } derive(Eq,
    Debug
    )

    Built-in constraints supported by the first MoonPlan solver kernel.

    ConstraintSpec

    type ConstraintSpec

    CoverageAnalysis

    pub(all) struct CoverageAnalysis {
    issues : Array[CoverageIssue]
    } derive(Eq,
    Debug
    )

    Preflight issues reported against original coverage demand identifiers.

    CoverageAnalysis::is_feasible

    fn CoverageAnalysis::is_feasible(self : CoverageAnalysis) -> Bool

    CoverageAnalysis::summary

    fn CoverageAnalysis::summary(self : CoverageAnalysis) -> String

    CoverageEntry

    pub(all) struct CoverageEntry {
    requirement : CoverageRequirement
    position : Int
    worker : Worker
    } derive(Eq, ToJson,
    Debug
    )

    One filled position within a coverage requirement.

    Positions are numbered from one in deterministic requirement order.

    CoverageIssue

    pub(all) enum CoverageIssue {
    CoverageDuplicateWorkerId(Int)
    CoverageNoEligibleWorker(Int, String)
    CoverageSlotShortfall(Int, Int, Int)
    CoverageWorkloadShortfall(Int, Int)
    CoveragePolicyShortfall(Int, Int)
    } derive(Eq,
    Debug
    )

    A definite input or slot-capacity problem in a coverage request.

    CoverageIssue::message

    fn CoverageIssue::message(self : CoverageIssue) -> String

    CoverageRequest

    pub(all) struct CoverageRequest {
    workers : Array[Worker]
    requirements : Array[CoverageRequirement]
    policy : RosterPolicy
    } derive(Eq, ToJson,
    Debug
    ,
    FromJson
    )

    Portable input for staffing demands that need multiple workers per slot.

    CoverageRequest::solve

    Solve a decoded coverage request with the standard scheduling rules.

    CoverageRequest::solve_balanced

    fn CoverageRequest::solve_balanced(self : CoverageRequest, candidate_limit : Int) -> Result[OptimizedCoverageRoster, RosterError]

    Evaluate a bounded number of feasible coverage rosters for workload balance.

    CoverageRequest::solve_fairest

    fn CoverageRequest::solve_fairest(self : CoverageRequest, node_limit : Int) -> Result[FairCoverageOutcome, RosterError]

    Search for a provably fairest coverage roster within a global node budget.

    CoverageRequest::solve_with_consecutive_limit

    fn CoverageRequest::solve_with_consecutive_limit(self : CoverageRequest, maximum_consecutive_slots : Int) -> Result[CoverageRoster, RosterError]

    Solve a JSON-compatible request with a consecutive-work hard limit.

    CoverageRequest::solve_with_node_limit

    fn CoverageRequest::solve_with_node_limit(self : CoverageRequest, node_limit : Int) -> Result[CoverageSolveOutcome, RosterError]

    Solve a request with an explicit search budget and a three-way outcome.

    CoverageRequest::solve_with_strategy

    fn CoverageRequest::solve_with_strategy(self : CoverageRequest, node_limit : Int, strategy : SearchStrategy) -> Result[CoverageSolveOutcome, RosterError]

    Search a coverage request with a selected strategy and assignment budget.

    CoverageRequirement

    pub(all) struct CoverageRequirement {
    id : Int
    name : String
    slot : Int
    required_skill : String
    workers_needed : Int
    } derive(Eq, ToJson,
    Debug
    ,
    FromJson
    )

    A staffing demand that requires several distinct workers in one slot.

    CoverageRoster

    pub(all) struct CoverageRoster {
    entries : Array[CoverageEntry]
    stats : SolveStats
    } derive(Eq, ToJson,
    Debug
    )

    A fulfilled set of staffing demands together with solver evidence.

    CoverageRoster::workers_for_requirement

    fn CoverageRoster::workers_for_requirement(self : CoverageRoster, requirement_id : Int) -> Array[Worker]

    Return the workers assigned to one coverage requirement, in position order.

    CoverageSolveOutcome

    pub(all) enum CoverageSolveOutcome {
    CoverageFound(CoverageRoster)
    CoverageProvedUnsatisfiable(SolveStats)
    CoverageBudgetExhausted(SolveStats)
    } derive(Eq,
    Debug
    )

    A bounded coverage search distinguishes a roster from a proof of infeasibility and from an incomplete search.

    CoverageSolveResponse

    pub(all) struct CoverageSolveResponse {
    status : String
    roster : Json
    stats : Json
    error : Json
    } derive(ToJson)

    Stable JSON envelope for a bounded coverage search.

    ExplainOutcome

    pub(all) enum ExplainOutcome {
    Consistent(SolveStats)
    Inconsistent(ConflictReport)
    } derive(Eq,
    Debug
    )

    Result of checking a model for a conflict explanation.

    FairCoverageOutcome

    pub(all) enum FairCoverageOutcome {
    FairCoverageFound(FairCoverageRoster)
    FairCoverageProvedUnsatisfiable(SolveStats, Int)
    FairCoverageBudgetExhausted(SolveStats, Int)
    } derive(Eq,
    Debug
    )

    An exact fairness search can finish, prove infeasibility, or exhaust its global assignment budget. An exhausted search makes no optimality claim.

    FairCoverageResponse

    pub(all) struct FairCoverageResponse {
    status : String
    roster : Json
    balance : Json
    windows_checked : Int
    total_nodes : Int
    total_backtracks : Int
    error : Json
    } derive(ToJson)

    Stable JSON envelope for an exact fairness search.

    FairCoverageRoster

    pub(all) struct FairCoverageRoster {
    roster : CoverageRoster
    balance : BalanceScore
    windows_checked : Int
    total_nodes : Int
    total_backtracks : Int
    } derive(Eq, ToJson,
    Debug
    )

    A roster whose workload spread was proven minimal within all hard rules.

    ModelError

    pub(all) enum ModelError {
    EmptyName
    DuplicateName(String)
    EmptyConstraintName
    DuplicateConstraintName(String)
    EmptyDomain(String)
    DuplicateDomainValue(String, Int)
    ForeignVariable(String)
    EmptyConstraint
    EmptyTupleTable
    EmptyElementTable
    InvalidTupleArity(Int, Int, Int)
    TupleValueOutsideDomain(Int, String, Int)
    DuplicateTuple(Int, Int)
    ElementIndexOutsideTable(String, Int, Int)
    InvalidCoefficientArity(Int, Int)
    InvalidConstraintLimit(Int)
    InvalidCountBounds(Int, Int)
    InvalidSolutionLimit(Int)
    InvalidTraceLimit(Int)
    InvalidNodeLimit(Int)
    } derive(Eq,
    Debug
    )

    Errors rejected at the modeling boundary.

    ModelError::message

    fn ModelError::message(self : ModelError) -> String

    Convert a modeling error into a concise diagnostic.

    OptimizedCoverageRoster

    pub(all) struct OptimizedCoverageRoster {
    roster : CoverageRoster
    balance : BalanceScore
    candidates_evaluated : Int
    } derive(Eq, ToJson,
    Debug
    )

    The fairest coverage roster found among a bounded set of candidates. A lower spread means more even assignment counts across all workers.

    OptimizedRoster

    pub(all) struct OptimizedRoster {
    roster : Roster
    balance : BalanceScore
    candidates_evaluated : Int
    } derive(Eq,
    Debug
    )

    The best roster found within a bounded candidate search.

    PreferredRoster

    pub(all) struct PreferredRoster {
    roster : Roster
    total_penalty : Int
    penalty_breakdown : RosterPenaltyBreakdown
    objective_order : RosterObjectiveOrder
    balance : BalanceScore
    candidates_evaluated : Int
    } derive(Eq, ToJson,
    Debug
    )

    The lowest-penalty roster found within a bounded candidate search.

    Problem

    pub struct Problem {
    variables : Array[VariableSpec]
    constraints : Array[ConstraintSpec]
    }

    A finite-domain constraint problem.

    Problem::add_constraint

    fn Problem::add_constraint(self : Problem, constraint : Constraint) -> Result[Unit, ModelError]

    Add a checked constraint to the problem.

    Problem::add_named_constraint

    fn Problem::add_named_constraint(self : Problem, name : String, constraint : Constraint) -> Result[Unit, ModelError]

    Add a checked constraint with a stable, human-readable name.

    Names must be unique within a problem so conflict reports can map solver rules back to their business meaning.

    Problem::add_variable

    fn Problem::add_variable(self : Problem, name : String, domain : Array[Int]) -> Result[Var, ModelError]

    Declare a variable and return its stable handle.

    Problem::constraint_count

    fn Problem::constraint_count(self : Problem) -> Int

    Number of constraints currently declared in the model.

    Problem::explain

    fn Problem::explain(self : Problem) -> ExplainOutcome

    Explain why a problem is unsatisfiable.

    The deletion-based pass returns an irreducible conflict set: every reported constraint is necessary for the conflict within the returned set. The original problem is not mutated.

    Problem::new

    fn Problem::new() -> Problem

    Create an empty constraint problem.

    Problem::solve

    fn Problem::solve(self : Problem) -> SolveOutcome

    Find the first solution using MRV-guided deterministic backtracking.

    Problem::solve_all

    fn Problem::solve_all(self : Problem, limit : Int) -> Result[(Array[Solution], SolveStats), ModelError]

    Enumerate up to limit solutions in deterministic order.

    Problem::solve_all_with_strategy

    fn Problem::solve_all_with_strategy(self : Problem, limit : Int, strategy : SearchStrategy) -> Result[(Array[Solution], SolveStats), ModelError]

    Enumerate solutions with an explicit deterministic search strategy.

    Problem::solve_with_node_limit

    fn Problem::solve_with_node_limit(self : Problem, node_limit : Int, strategy : SearchStrategy) -> Result[BudgetedSolveOutcome, ModelError]

    Find a solution within at most node_limit attempted assignments.

    The selected strategy changes search order, not the meaning of the result. Unlike Unsatisfied, BudgetExhausted makes no claim about unexplored branches. Existing unlimited solve APIs are unchanged.

    Problem::solve_with_strategy

    fn Problem::solve_with_strategy(self : Problem, strategy : SearchStrategy) -> SolveOutcome

    Find the first solution with an explicit deterministic search strategy.

    Problem::solve_with_trace

    fn Problem::solve_with_trace(self : Problem, event_limit : Int) -> Result[SolveTrace, ModelError]

    Find the first solution while recording a bounded deterministic event log.

    Reaching the event limit only truncates observation; it never stops or changes the underlying search.

    Problem::variable_count

    fn Problem::variable_count(self : Problem) -> Int

    Number of variables currently declared in the model.

    Roster

    pub(all) struct Roster {
    entries : Array[RosterEntry]
    stats : SolveStats
    } derive(Eq, ToJson,
    Debug
    )

    A feasible roster together with search evidence.

    Roster::assignment_count

    fn Roster::assignment_count(self : Roster, worker_id : Int) -> Int

    Count how many shifts were assigned to a worker id.

    Roster::worker_for_shift

    fn Roster::worker_for_shift(self : Roster, shift_id : Int) -> Worker?

    Find the worker assigned to a shift id.

    RosterAnalysis

    pub(all) struct RosterAnalysis {
    issues : Array[RosterIssue]
    } derive(Eq,
    Debug
    )

    Preflight result that may contain several independent modeling issues.

    RosterAnalysis::is_feasible

    fn RosterAnalysis::is_feasible(self : RosterAnalysis) -> Bool

    Whether no definite scheduling conflict was found during preflight.

    RosterAnalysis::summary

    fn RosterAnalysis::summary(self : RosterAnalysis) -> String

    Render all discovered issues as a compact diagnostic line.

    RosterEntry

    pub(all) struct RosterEntry {
    shift : Shift
    worker : Worker
    } derive(Eq, ToJson,
    Debug
    )

    One resolved worker-to-shift assignment.

    RosterError

    pub(all) enum RosterError {
    DuplicateWorkerId(Int)
    DuplicateShiftId(Int)
    DuplicateCoverageRequirementId(Int)
    InvalidCoverageWorkerCount(Int, Int)
    NoEligibleWorker(Int, String)
    InsufficientSlotCapacity(Int, Int, Int)
    InvalidCandidateLimit(Int)
    InvalidWorkloadLimit(Int)
    InvalidRestGap(Int)
    UnknownPenaltyWorker(Int)
    UnknownPenaltyShift(Int)
    DuplicateAssignmentPenalty(Int, Int)
    InvalidAssignmentPenalty(Int, Int, Int)
    InvalidConsecutiveSlotPenalty(Int)
    InvalidConsecutiveWorkLimit(Int)
    UnknownQuotaWorker(Int)
    DuplicateWorkerQuota(Int)
    InvalidWorkerQuota(Int, Int, Int)
    MinimumWorkerQuotaExceedsShiftCount(Int, Int, Int)
    InvalidModel(ModelError)
    Unsatisfiable(SolveStats)
    } derive(Eq,
    Debug
    )

    Domain-specific scheduling failures.

    RosterError::message

    fn RosterError::message(self : RosterError) -> String

    Explain a scheduling failure without exposing solver internals.

    RosterIssue

    pub(all) enum RosterIssue {
    DuplicateWorkerIdFound(Int)
    DuplicateShiftIdFound(Int)
    NoEligibleWorkerFound(Int, String)
    SlotCoverageShortfall(Int, Int, Int)
    } derive(Eq,
    Debug
    )

    A concrete reason why a roster model cannot be scheduled as written.

    RosterIssue::message

    fn RosterIssue::message(self : RosterIssue) -> String

    Explain one roster issue in business-facing terms.

    RosterJsonError

    pub(all) enum RosterJsonError {
    InvalidJson(String)
    InvalidRosterSchema(String)
    } derive(Eq,
    Debug
    )

    Errors produced while decoding a roster request.

    RosterJsonError::message

    fn RosterJsonError::message(self : RosterJsonError) -> String

    Explain a JSON decoding failure with its parser or field path detail.

    RosterObjective

    pub(all) struct RosterObjective {
    assignment_penalties : Array[AssignmentPenalty]
    consecutive_slot_penalty : Int
    } derive(Eq, ToJson,
    Debug
    ,
    FromJson
    )

    Soft costs used to rank feasible rosters.

    consecutive_slot_penalty is charged for each pair of shifts in adjacent slots assigned to the same worker. A value of zero disables this cost.

    RosterObjective::default

    Build an objective with no soft costs.

    RosterObjectiveOrder

    pub(all) enum RosterObjectiveOrder {
    WeightedTotal
    AssignmentThenConsecutive
    ConsecutiveThenAssignment
    } derive(Eq, ToJson,
    Debug
    ,
    FromJson
    )

    How objective components are compared when ranking feasible rosters.

    WeightedTotal preserves the original behavior. The other modes compare components lexicographically, so a lower-priority component can never compensate for a worse higher-priority component.

    RosterObjectiveOrder::default

    Use the backward-compatible weighted total ordering.

    RosterOptimizationRequest

    pub(all) struct RosterOptimizationRequest {
    workers : Array[Worker]
    shifts : Array[Shift]
    policy : RosterPolicy
    objective : RosterObjective
    candidate_limit : Int
    } derive(Eq, ToJson,
    Debug
    ,
    FromJson
    )

    A portable optimization request with a composable soft objective.

    This type extends the JSON integration without changing the established RosterRequest schema used by existing clients.

    RosterOptimizationRequest::solve

    Solve an objective-aware request using all hard and soft rules.

    RosterPenaltyBreakdown

    pub(all) struct RosterPenaltyBreakdown {
    assignment_charges : Array[AssignmentPenaltyCharge]
    consecutive_slot_charges : Array[ConsecutiveSlotCharge]
    assignment_total : Int
    consecutive_slot_total : Int
    total_penalty : Int
    } derive(Eq, ToJson,
    Debug
    )

    An auditable breakdown of every soft cost charged to a roster.

    RosterPolicy

    pub(all) struct RosterPolicy {
    max_shifts_per_worker : Int?
    minimum_slot_gap : Int
    } derive(Eq, ToJson,
    Debug
    ,
    FromJson
    )

    Hard rules applied while building a roster.

    Slot values are treated as ordered, equally sized time units. A minimum gap of two therefore prevents one worker from taking shifts in adjacent slots while allowing shifts two or more units apart.

    RosterPolicy::default

    fn RosterPolicy::default() -> RosterPolicy

    A policy that adds no restrictions beyond skills, availability, and same-slot exclusivity.

    RosterRepair

    pub(all) enum RosterRepair {
    RenameWorkerId(Int)
    RenameShiftId(Int)
    AddQualifiedWorker(Int, String, String)
    AddSlotCapacity(Int, Int)
    RaiseWorkloadLimit(Int, Int)
    ReduceMinimumSlotGap(Int, Int)
    } derive(Eq,
    Debug
    )

    A concrete change that can make a roster model schedulable.

    RosterRepair::message

    fn RosterRepair::message(self : RosterRepair) -> String

    Render one proposed repair in business-facing terms.

    RosterRepairPlan

    pub(all) struct RosterRepairPlan {
    issues : Array[RosterIssue]
    conflict : ConflictReport?
    repairs : Array[RosterRepair]
    } derive(Eq,
    Debug
    )

    Scheduling facts, solver evidence, and actionable repairs in one report.

    RosterRepairPlan::is_feasible

    fn RosterRepairPlan::is_feasible(self : RosterRepairPlan) -> Bool

    Whether the roster is feasible without applying a repair.

    RosterRepairPlan::summary

    fn RosterRepairPlan::summary(self : RosterRepairPlan) -> String

    Render the proposed changes as a compact diagnostic line.

    RosterRequest

    pub(all) struct RosterRequest {
    workers : Array[Worker]
    shifts : Array[Shift]
    policy : RosterPolicy
    penalties : Array[AssignmentPenalty]
    candidate_limit : Int
    } derive(Eq, ToJson,
    Debug
    ,
    FromJson
    )

    A complete, portable input document for preferred roster optimization.

    RosterRequest::solve

    Solve a decoded request using its hard policy and soft penalties.

    SearchEvent

    pub(all) enum SearchEvent {
    TryValue(String, Int, Int)
    DeadEnd(String, Int)
    Backtrack(String, Int, Int)
    SolutionFound(Int, Int)
    } derive(Eq, ToJson,
    Debug
    )

    One deterministic event emitted by the backtracking search.

    SearchEvent::message

    fn SearchEvent::message(self : SearchEvent) -> String

    Render one search event for logs and accessible user interfaces.

    SearchStrategy

    pub(all) enum SearchStrategy {
    Mrv
    DegreeLcv
    } derive(Eq,
    Debug
    )

    Deterministic variable and value ordering used by the search.

    Mrv preserves declaration order when minimum remaining domains tie. DegreeLcv prefers the variable involved in more active constraints, then tries values that leave the most choices for the remaining variables.

    Shift

    pub(all) struct Shift {
    id : Int
    name : String
    slot : Int
    required_skill : String
    } derive(Eq, ToJson,
    Debug
    ,
    FromJson
    )

    A unit of work that needs exactly one eligible worker.

    Solution

    pub(all) struct Solution {
    bindings : Array[Binding]
    } derive(Eq, ToJson,
    Debug
    )

    A complete assignment satisfying every constraint.

    Solution::get

    fn Solution::get(self : Solution, name : String) -> Int?

    Look up a value by variable name.

    SolveOutcome

    pub(all) enum SolveOutcome {
    Satisfied(Solution, SolveStats)
    Unsatisfied(SolveStats)
    } derive(Eq, ToJson,
    Debug
    )

    Solver result with an explicit unsatisfiable branch.

    SolveStats

    pub(all) struct SolveStats {
    nodes : Int
    backtracks : Int
    solutions : Int
    } derive(Eq, ToJson,
    Debug
    )

    Search telemetry useful for diagnostics and visualizations.

    SolveTrace

    pub(all) struct SolveTrace {
    outcome : SolveOutcome
    events : Array[SearchEvent]
    truncated : Bool
    } derive(Eq, ToJson,
    Debug
    )

    A first-solution result together with a bounded search trace.

    Var

    pub(all) struct Var {
    index : Int
    name : String
    } derive(Eq,
    Debug
    )

    A stable handle to a finite-domain variable.

    VariableSpec

    type VariableSpec derive(Eq,
    Debug
    )

    Worker

    pub(all) struct Worker {
    id : Int
    name : String
    skills : Array[String]
    available_slots : Array[Int]
    } derive(Eq, ToJson,
    Debug
    ,
    FromJson
    )

    A person who can be assigned to shifts.

    WorkerQuota

    pub(all) struct WorkerQuota {
    worker_id : Int
    minimum_shifts : Int
    maximum_shifts : Int
    } derive(Eq, ToJson,
    Debug
    ,
    FromJson
    )

    An inclusive workload range for one worker.

    analyze_coverage

    fn analyze_coverage(workers : Array[Worker], requirements : Array[CoverageRequirement]) -> Result[CoverageAnalysis, RosterError]

    Find definite demand conflicts before applying workload or rest policy.

    This does not prove that a policy-constrained roster is feasible.

    analyze_coverage_with_policy

    fn analyze_coverage_with_policy(workers : Array[Worker], requirements : Array[CoverageRequirement], policy : RosterPolicy) -> Result[CoverageAnalysis, RosterError]

    Find definite coverage conflicts under the requested roster policy.

    The workload bound counts each worker's distinct eligible slots, capped by their workload limit. Rest rules can make capacity smaller, so a passing analysis is not a proof that a roster exists.

    analyze_roster

    fn analyze_roster(workers : Array[Worker], shifts : Array[Shift]) -> RosterAnalysis

    Analyze definite roster conflicts before running the full solver.

    Slot capacity uses maximum bipartite matching instead of only counting available workers, so skill bottlenecks are reported accurately.

    build_balanced_coverage_roster

    fn build_balanced_coverage_roster(workers : Array[Worker], requirements : Array[CoverageRequirement], policy : RosterPolicy, candidate_limit : Int) -> Result[OptimizedCoverageRoster, RosterError]

    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.

    build_balanced_roster

    fn build_balanced_roster(workers : Array[Worker], shifts : Array[Shift], candidate_limit : Int) -> Result[OptimizedRoster, RosterError]

    Find a feasible roster with the smallest workload spread among a bounded number of deterministic candidates.

    The limit makes optimization cost explicit. Increasing it may discover a fairer roster, while the returned candidate count records the evidence used for the decision.

    build_balanced_roster_with_policy

    fn build_balanced_roster_with_policy(workers : Array[Worker], shifts : Array[Shift], policy : RosterPolicy, candidate_limit : Int) -> Result[OptimizedRoster, RosterError]

    Find the fairest roster among at most candidate_limit feasible rosters while enforcing workload and rest policy as hard constraints.

    build_capped_roster

    fn build_capped_roster(workers : Array[Worker], shifts : Array[Shift], max_shifts_per_worker : Int) -> Result[Roster, RosterError]

    Assign every shift while limiting each worker's total workload.

    A zero limit is valid for an empty schedule. If the limit makes a non-empty schedule impossible, the result reports an unsatisfiable model with search statistics.

    build_coverage_roster

    fn build_coverage_roster(workers : Array[Worker], requirements : Array[CoverageRequirement], policy : RosterPolicy) -> Result[CoverageRoster, RosterError]

    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.

    build_coverage_roster_with_consecutive_limit

    fn build_coverage_roster_with_consecutive_limit(workers : Array[Worker], requirements : Array[CoverageRequirement], policy : RosterPolicy, maximum_consecutive_slots : Int) -> Result[CoverageRoster, RosterError]

    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.

    build_coverage_roster_with_node_limit

    fn build_coverage_roster_with_node_limit(workers : Array[Worker], requirements : Array[CoverageRequirement], policy : RosterPolicy, node_limit : Int) -> Result[CoverageSolveOutcome, RosterError]

    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.

    build_coverage_roster_with_strategy

    fn build_coverage_roster_with_strategy(workers : Array[Worker], requirements : Array[CoverageRequirement], policy : RosterPolicy, node_limit : Int, strategy : SearchStrategy) -> Result[CoverageSolveOutcome, RosterError]

    Search for a coverage roster with an explicit deterministic strategy. Different strategies can change search cost without changing the rules.

    build_fairest_coverage_roster

    fn build_fairest_coverage_roster(workers : Array[Worker], requirements : Array[CoverageRequirement], policy : RosterPolicy, node_limit : Int) -> Result[FairCoverageOutcome, RosterError]

    Find a coverage roster with the globally smallest workload spread.

    Every trial constrains each worker's assignments to an inclusive range. Ranges are tried in increasing width, then increasing upper bound. The node_limit is shared by all trials; exhaustion never returns a claim of optimality or infeasibility.

    build_preferred_roster

    fn build_preferred_roster(workers : Array[Worker], shifts : Array[Shift], policy : RosterPolicy, penalties : Array[AssignmentPenalty], candidate_limit : Int) -> Result[PreferredRoster, RosterError]

    Optimize soft assignment preferences under the selected hard policy.

    Total penalty is minimized first. Workload balance breaks equal-penalty ties, and deterministic solution order breaks any remaining tie. The candidate limit keeps optimization cost explicit.

    build_preferred_roster_with_objective

    fn build_preferred_roster_with_objective(workers : Array[Worker], shifts : Array[Shift], policy : RosterPolicy, objective : RosterObjective, candidate_limit : Int) -> Result[PreferredRoster, RosterError]

    Optimize assignment preferences and consecutive-work cost together.

    Total soft cost is minimized first. Workload balance breaks equal-cost ties, and deterministic solution order breaks any remaining tie. Existing callers can keep using build_preferred_roster, which disables the consecutive-slot cost.

    build_preferred_roster_with_order

    fn build_preferred_roster_with_order(workers : Array[Worker], shifts : Array[Shift], policy : RosterPolicy, objective : RosterObjective, order : RosterObjectiveOrder, candidate_limit : Int) -> Result[PreferredRoster, RosterError]

    Optimize a roster using weighted or lexicographic objective ordering.

    Workload balance only breaks ties after the selected objective ordering. Deterministic solution order breaks any remaining tie.

    build_roster

    fn build_roster(workers : Array[Worker], shifts : Array[Shift]) -> Result[Roster, RosterError]

    Assign one eligible worker to every shift.

    Workers may serve again in a different slot, but cannot cover two shifts in the same slot. Skill and availability rules are compiled into variable domains before search starts.

    build_roster_with_consecutive_limit

    fn build_roster_with_consecutive_limit(workers : Array[Worker], shifts : Array[Shift], policy : RosterPolicy, maximum_consecutive_slots : Int) -> Result[Roster, RosterError]

    Build a roster while limiting consecutive occupied slot values.

    Slot values are treated as equally sized time units. For example, a limit of two permits work in slots 9 and 10, but not 9, 10, and 11. Missing slot values break a sequence. Standard workload and rest policy rules still apply.

    build_roster_with_policy

    fn build_roster_with_policy(workers : Array[Worker], shifts : Array[Shift], policy : RosterPolicy) -> Result[Roster, RosterError]

    Build a roster under composable workload and rest rules.

    build_roster_with_quotas

    fn build_roster_with_quotas(workers : Array[Worker], shifts : Array[Shift], policy : RosterPolicy, quotas : Array[WorkerQuota]) -> Result[Roster, RosterError]

    Build a roster under global policy rules and per-worker workload quotas.

    Quota bounds are inclusive. They compose with the uniform workload cap and minimum rest gap already provided by RosterPolicy.

    decode_coverage_request

    fn decode_coverage_request(text : String) -> Result[CoverageRequest, RosterJsonError]

    Decode a coverage request, preserving parser and schema errors separately.

    decode_roster_optimization_request

    fn decode_roster_optimization_request(text : String) -> Result[RosterOptimizationRequest, RosterJsonError]

    Decode an objective-aware roster optimization request from JSON text.

    decode_roster_request

    fn decode_roster_request(text : String) -> Result[RosterRequest, RosterJsonError]

    Decode a complete roster request from JSON text.

    encode_coverage_analysis

    fn encode_coverage_analysis(analysis : CoverageAnalysis, indent : Int) -> String

    Encode definite coverage issues with stable machine-readable codes.

    A passing preflight is not proof that workload or rest rules can be met.

    encode_coverage_roster

    fn encode_coverage_roster(roster : CoverageRoster, indent : Int) -> String

    Encode a coverage roster and its search statistics as JSON text.

    encode_coverage_roster_csv

    fn encode_coverage_roster_csv(roster : CoverageRoster) -> String

    Export filled positions as UTF-8 CSV in stable roster order.

    Requirement and worker names are quoted when needed; formula-like names receive a leading apostrophe to reduce formula interpretation risk.

    encode_coverage_solve_outcome

    fn encode_coverage_solve_outcome(outcome : CoverageSolveOutcome, indent : Int) -> String

    Encode a bounded search outcome for CLI, browser, and service callers. budget_exhausted is intentionally distinct from unsatisfiable.

    encode_fair_coverage_outcome

    fn encode_fair_coverage_outcome(outcome : FairCoverageOutcome, indent : Int) -> String

    Encode an exact fairness outcome without conflating budget exhaustion with an optimality or infeasibility proof.

    encode_preferred_roster

    fn encode_preferred_roster(result : PreferredRoster, indent : Int) -> String

    Encode an optimized roster as stable JSON text.

    int_domain

    fn int_domain(first : Int, last : Int) -> Array[Int]

    Build an inclusive integer domain.

    suggest_capped_roster_repairs

    fn suggest_capped_roster_repairs(workers : Array[Worker], shifts : Array[Shift], max_shifts_per_worker : Int) -> Result[RosterRepairPlan, RosterError]

    Diagnose a roster with a uniform workload cap and propose concrete repairs.

    This compatibility helper applies no minimum rest gap. Use suggest_roster_policy_repairs when workload and rest rules are combined.

    suggest_roster_policy_repairs

    fn suggest_roster_policy_repairs(workers : Array[Worker], shifts : Array[Shift], policy : RosterPolicy) -> Result[RosterRepairPlan, RosterError]

    Diagnose a roster policy and propose independently feasible relaxations.

    Definite input and capacity issues are reported without search. If only the policy makes the model impossible, the returned conflict retains the named rules. Workload suggestions choose the smallest feasible limit; rest suggestions choose the largest smaller gap that restores feasibility.