///|
/// Project planning primitives for dependency-constrained work.
///
/// A planning task has a start variable, a fixed duration, an optional
/// release time, a due date, and a resource demand. The API supports both a
/// fast critical-path analysis and a Solver-backed schedule for exact checks.
pub struct PlanningTask {
id : Int
name : String
duration : Int
resource : Int
demand : Int
release : Int
due : Int
}
///|
/// Create a task with no release restriction and an open due date.
pub fn planning_task(
id : Int,
name : String,
duration : Int,
resource : Int,
demand : Int,
) -> PlanningTask {
{
id,
name,
duration: if duration < 0 {
0
} else {
duration
},
resource,
demand: if demand < 0 {
0
} else {
demand
},
release: 0,
due: 2147483647,
}
}
///|
/// Return a task with a release and due window.
pub fn PlanningTask::with_window(
self : PlanningTask,
release : Int,
due : Int,
) -> PlanningTask {
{ ..self, release, due }
}
///|
/// Read the task identifier.
pub fn PlanningTask::id(self : PlanningTask) -> Int {
self.id
}
///|
/// Read the task name.
pub fn PlanningTask::name(self : PlanningTask) -> String {
self.name
}
///|
/// Read the duration.
pub fn PlanningTask::duration(self : PlanningTask) -> Int {
self.duration
}
///|
/// Read the resource identifier.
pub fn PlanningTask::resource(self : PlanningTask) -> Int {
self.resource
}
///|
/// Read the resource demand.
pub fn PlanningTask::demand(self : PlanningTask) -> Int {
self.demand
}
///|
/// Return whether the time window is valid.
pub fn PlanningTask::valid_window(self : PlanningTask) -> Bool {
self.release <= self.due
}
///|
/// Return whether an interval contains a time point.
pub fn PlanningTask::contains(
self : PlanningTask,
start : Int,
time : Int,
) -> Bool {
time >= start && time < start + self.duration
}
///|
/// A precedence arc from one task to another.
pub struct PlanningDependency {
before : Int
after : Int
lag : Int
}
///|
/// Create a dependency with a minimum lag after the predecessor.
pub fn planning_dependency(
before : Int,
after : Int,
lag : Int,
) -> PlanningDependency {
{ before, after, lag: if lag < 0 { 0 } else { lag } }
}
///|
/// Read the predecessor.
pub fn PlanningDependency::before(self : PlanningDependency) -> Int {
self.before
}
///|
/// Read the successor.
pub fn PlanningDependency::after(self : PlanningDependency) -> Int {
self.after
}
///|
/// Read the minimum lag.
pub fn PlanningDependency::lag(self : PlanningDependency) -> Int {
self.lag
}
///|
/// A project plan with an exact finite-domain start model.
pub struct ProjectPlan {
solver : Solver
tasks : Array[PlanningTask]
starts : Array[Int]
dependencies : Array[PlanningDependency]
horizon : Int
capacities : Array[Int]
}
///|
/// Construct a project plan and its start variables.
pub fn project_plan(
tasks : Array[PlanningTask],
horizon : Int,
capacities : Array[Int],
) -> ProjectPlan? {
if horizon < 0 || capacities.length() == 0 {
return None
}
for index, task in tasks {
if task.id != index ||
task.resource < 0 ||
task.resource >= capacities.length() ||
task.demand < 0 ||
!task.valid_window() ||
task.duration > horizon {
return None
}
if task.due > horizon + task.duration && task.due != 2147483647 {
return None
}
if task.demand > capacities[task.resource] {
return None
}
}
for capacity in capacities {
if capacity < 0 {
return None
}
}
let solver = new_solver()
let starts : Array[Int] = []
for task in tasks {
let latest = horizon - task.duration
let variable = solver.add_variable(variable("start_\{task.id}", 0, latest))
starts.push(variable)
solver.add_constraint(greater_equal(variable, task.release))
if task.due != 2147483647 {
solver.add_constraint(less_equal(variable, task.due - task.duration))
}
}
Some({
solver,
tasks: tasks.copy(),
starts,
dependencies: [],
horizon,
capacities: capacities.copy(),
})
}
///|
/// Read the number of tasks.
pub fn ProjectPlan::task_count(self : ProjectPlan) -> Int {
self.tasks.length()
}
///|
/// Read the planning horizon.
pub fn ProjectPlan::horizon(self : ProjectPlan) -> Int {
self.horizon
}
///|
/// Read a task.
pub fn ProjectPlan::task(self : ProjectPlan, id : Int) -> PlanningTask {
if id < 0 || id >= self.tasks.length() {
abort("planning task is outside the project")
}
self.tasks[id]
}
///|
/// Read a start variable identifier.
pub fn ProjectPlan::start_variable(self : ProjectPlan, id : Int) -> Int {
if id < 0 || id >= self.starts.length() {
abort("planning task is outside the project")
}
self.starts[id]
}
///|
/// Add a precedence relationship.
pub fn ProjectPlan::add_dependency(
self : ProjectPlan,
before : Int,
after : Int,
lag : Int,
) -> Bool {
if before < 0 ||
after < 0 ||
before >= self.tasks.length() ||
after >= self.tasks.length() ||
before == after {
return false
}
let dependency = planning_dependency(before, after, lag)
self.dependencies.push(dependency)
self.solver.add_constraint(
linear_greater_equal(
[(self.starts[after], 1), (self.starts[before], -1)],
self.tasks[before].duration + dependency.lag,
),
)
true
}
///|
/// Add a fixed assignment for a task start.
pub fn ProjectPlan::fix_start(
self : ProjectPlan,
id : Int,
start : Int,
) -> Bool {
if id < 0 ||
id >= self.tasks.length() ||
start < 0 ||
start + self.tasks[id].duration > self.horizon {
return false
}
self.solver.assign(self.starts[id], start)
}
///|
/// Add pairwise non-overlap constraints for a resource's tasks.
pub fn ProjectPlan::post_resource_non_overlap(
self : ProjectPlan,
resource : Int,
) -> Bool {
if resource < 0 || resource >= self.capacities.length() {
return false
}
let intervals : Array[(Int, Int)] = []
for index, task in self.tasks {
if task.resource == resource {
intervals.push((self.starts[index], task.duration))
}
}
if intervals.length() > 1 {
self.solver.add_constraint(no_overlap(intervals))
}
true
}
///|
/// Add non-overlap constraints for every resource.
pub fn ProjectPlan::post_all_resource_non_overlap(self : ProjectPlan) -> Unit {
for resource in 0.. Array[PlanningDependency] {
self.dependencies.copy()
}
///|
/// Return a topological task order, or None when dependencies cycle.
pub fn ProjectPlan::topological_order(self : ProjectPlan) -> Array[Int]? {
let indegree : Array[Int] = []
let outgoing : Array[Array[Int]] = []
for _ in self.tasks {
indegree.push(0)
outgoing.push([])
}
for dependency in self.dependencies {
indegree[dependency.after] += 1
outgoing[dependency.before].push(dependency.after)
}
let ready : Array[Int] = []
for id in 0.. Bool {
self.topological_order() is None
}
///|
/// Compute earliest feasible starts using a critical-path pass.
pub fn ProjectPlan::earliest_starts(self : ProjectPlan) -> Array[Int] {
let result : Array[Int] = []
for task in self.tasks {
result.push(task.release)
}
match self.topological_order() {
None => result
Some(order) => {
for task in order {
for dependency in self.dependencies {
if dependency.after == task {
let candidate = result[dependency.before] +
self.tasks[dependency.before].duration +
dependency.lag
if candidate > result[task] {
result[task] = candidate
}
}
}
}
result
}
}
}
///|
/// Compute latest starts without extending the project horizon.
pub fn ProjectPlan::latest_starts(self : ProjectPlan) -> Array[Int] {
let result : Array[Int] = []
for task in self.tasks {
result.push(self.horizon - task.duration)
}
match self.topological_order() {
None => result
Some(order) => {
let last = order.length() - 1
for position in last>=..0 {
let task = order[position]
for dependency in self.dependencies {
if dependency.before == task {
let candidate = result[dependency.after] -
self.tasks[task].duration -
dependency.lag
if candidate < result[task] {
result[task] = candidate
}
}
}
}
result
}
}
}
///|
/// Return task slack from earliest and latest passes.
pub fn ProjectPlan::slacks(self : ProjectPlan) -> Array[Int] {
let earliest = self.earliest_starts()
let latest = self.latest_starts()
let result : Array[Int] = []
for id in 0.. Array[Int] {
let result : Array[Int] = []
for id, slack in self.slacks() {
if slack <= 0 {
result.push(id)
}
}
result
}
///|
/// Return the critical-path makespan.
pub fn ProjectPlan::critical_path_length(self : ProjectPlan) -> Int {
let starts = self.earliest_starts()
let mut result = 0
for id, start in starts {
let finish = start + self.tasks[id].duration
if finish > result {
result = finish
}
}
result
}
///|
/// Solve the exact start model once.
pub fn ProjectPlan::solve(self : ProjectPlan) -> Solution? {
self.solver.solve()
}
///|
/// Enumerate project schedules.
pub fn ProjectPlan::solve_all(
self : ProjectPlan,
limit : Int,
) -> Array[Solution] {
self.solver.limit(limit)
self.solver.solve_all()
}
///|
/// Read the latest search statistics.
pub fn ProjectPlan::stats(self : ProjectPlan) -> SearchStats {
self.solver.stats()
}
///|
/// Return the underlying solver for application-specific constraints.
pub fn ProjectPlan::solver(self : ProjectPlan) -> Solver {
self.solver
}
///|
/// Extract start times from a solution.
pub fn ProjectPlan::schedule(
self : ProjectPlan,
solution : Solution,
) -> Array[Int] {
self.starts.map(variable => solution.get(variable))
}
///|
/// Return a schedule's makespan.
pub fn ProjectPlan::makespan(self : ProjectPlan, starts : Array[Int]) -> Int {
let mut result = 0
for id, start in starts {
if id < self.tasks.length() {
let finish = start + self.tasks[id].duration
if finish > result {
result = finish
}
}
}
result
}
///|
/// Return resource usage at each time slot.
pub fn ProjectPlan::resource_profile(
self : ProjectPlan,
starts : Array[Int],
resource : Int,
) -> Array[Int] {
let result : Array[Int] = []
for _ in 0..= self.capacities.length() {
return result
}
for id, task in self.tasks {
if id >= starts.length() || task.resource != resource {
continue
}
let start = starts[id]
for time in start..<(start + task.duration) {
if time >= 0 && time < result.length() {
result[time] += task.demand
}
}
}
result
}
///|
/// Validate a concrete schedule and return stable error codes.
pub fn ProjectPlan::validate_schedule(
self : ProjectPlan,
starts : Array[Int],
) -> Array[String] {
let errors : Array[String] = []
if starts.length() != self.tasks.length() {
errors.push("task-count-mismatch")
return errors
}
for id, start in starts {
let task = self.tasks[id]
if start < task.release {
errors.push("release-before-task-\{id}")
}
if start + task.duration > self.horizon {
errors.push("horizon-exceeded-\{id}")
}
if start + task.duration > task.due {
errors.push("due-date-exceeded-\{id}")
}
}
for dependency in self.dependencies {
if starts[dependency.after] <
starts[dependency.before] +
self.tasks[dependency.before].duration +
dependency.lag {
errors.push("precedence-\{dependency.before}-\{dependency.after}")
}
}
for resource in 0.. self.capacities[resource] {
errors.push("capacity-\{resource}-\{time}")
}
}
}
errors
}
///|
/// Return whether a concrete schedule is valid.
pub fn ProjectPlan::is_valid_schedule(
self : ProjectPlan,
starts : Array[Int],
) -> Bool {
self.validate_schedule(starts).length() == 0
}
///|
/// Return a stable task timeline.
pub fn ProjectPlan::render(self : ProjectPlan, starts : Array[Int]) -> String {
let builder = StringBuilder()
for id, task in self.tasks {
if id > 0 {
builder.write_char('\n')
}
let start = if id < starts.length() { starts[id] } else { -1 }
builder.write_string(
"\{id}:\{task.name}@\{start}+\{task.duration}/r\{task.resource}",
)
}
builder.to_string()
}
///|
/// Return tasks that directly precede a task.
pub fn ProjectPlan::predecessors(self : ProjectPlan, id : Int) -> Array[Int] {
let result : Array[Int] = []
for dependency in self.dependencies {
if dependency.after == id {
result.push(dependency.before)
}
}
result
}
///|
/// Return tasks that directly follow a task.
pub fn ProjectPlan::successors(self : ProjectPlan, id : Int) -> Array[Int] {
let result : Array[Int] = []
for dependency in self.dependencies {
if dependency.before == id {
result.push(dependency.after)
}
}
result
}
///|
/// Return the total work across all tasks.
pub fn ProjectPlan::total_work(self : ProjectPlan) -> Int {
let mut result = 0
for task in self.tasks {
result += task.duration
}
result
}
///|
/// Return total demand-time across all tasks.
pub fn ProjectPlan::total_load(self : ProjectPlan) -> Int {
let mut result = 0
for task in self.tasks {
result += task.duration * task.demand
}
result
}
///|
/// Return resource utilization in integer percentage points.
pub fn ProjectPlan::resource_utilization(
self : ProjectPlan,
starts : Array[Int],
resource : Int,
) -> Int {
if resource < 0 || resource >= self.capacities.length() || self.horizon == 0 {
return 0
}
let profile = self.resource_profile(starts, resource)
let mut total = 0
for load in profile {
total += load
}
total * 100 / (self.horizon * self.capacities[resource])
}
///|
/// Return the first task that uses a resource at a time.
pub fn ProjectPlan::task_at(
self : ProjectPlan,
starts : Array[Int],
resource : Int,
time : Int,
) -> Int? {
for id, task in self.tasks {
if task.resource == resource &&
id < starts.length() &&
task.contains(starts[id], time) {
return Some(id)
}
}
None
}
///|
/// Return all task ids using a resource.
pub fn ProjectPlan::tasks_on_resource(
self : ProjectPlan,
resource : Int,
) -> Array[Int] {
let result : Array[Int] = []
for id, task in self.tasks {
if task.resource == resource {
result.push(id)
}
}
result
}
///|
/// Return a stable plan fingerprint.
pub fn ProjectPlan::signature(self : ProjectPlan) -> Int {
let mut result = self.horizon * 31 + self.tasks.length()
for task in self.tasks {
result = result * 37 +
task.id +
task.duration * 3 +
task.resource * 5 +
task.demand * 7
}
for dependency in self.dependencies {
result = result * 41 +
dependency.before * 11 +
dependency.after * 13 +
dependency.lag
}
result
}
///|
/// Construct a chain of equal-duration tasks.
pub fn planning_chain(
count : Int,
duration : Int,
resource : Int,
demand : Int,
horizon : Int,
capacity : Int,
) -> ProjectPlan? {
let tasks : Array[PlanningTask] = []
for id in 0.. None
Some(plan) => {
for id in 1.. Bool {
for dependency in self.dependencies {
if dependency.before < 0 ||
dependency.after < 0 ||
dependency.before >= self.tasks.length() ||
dependency.after >= self.tasks.length() {
return false
}
}
true
}
///|
/// Return the number of critical tasks.
pub fn ProjectPlan::critical_task_count(self : ProjectPlan) -> Int {
self.critical_tasks().length()
}
///|
/// Return the maximum resource demand.
pub fn ProjectPlan::maximum_demand(self : ProjectPlan) -> Int {
let mut result = 0
for task in self.tasks {
if task.demand > result {
result = task.demand
}
}
result
}
///|
/// Return a project summary for CLI diagnostics.
pub fn ProjectPlan::describe(self : ProjectPlan) -> String {
"tasks=\{self.tasks.length()}, dependencies=\{self.dependencies.length()}, horizon=\{self.horizon}, critical=\{self.critical_path_length()}"
}