///|
/// A recoverable asset with its BIA-derived priority and resource requirement.
pub(all) struct RecoveryItem {
asset : AssetRef
impact_level : ImpactLevel
recovery_time : TimeSpan
resource : String
}
///|
/// Creates a recovery item with a positive estimated recovery duration.
pub fn RecoveryItem::new(
asset~ : AssetRef,
impact_level~ : ImpactLevel,
recovery_time~ : TimeSpan,
resource~ : String,
) -> RecoveryItem raise ModelError {
if recovery_time.amount < 0 {
raise ModelError::InvalidDuration(
"recovery_time.amount must be non-negative",
)
}
if recovery_time.amount == 0 {
raise ModelError::InvalidDuration("recovery_time must be positive")
}
if resource == "" {
raise ModelError::EmptyField("recovery.resource")
}
{ asset, impact_level, recovery_time, resource, }
}
///|
/// Resource and total-window limits for a recovery plan.
pub(all) struct RecoveryConstraints {
target_window : TimeSpan
available_resources : Array[String]
}
///|
/// Creates recovery constraints and rejects duplicate or empty resources.
pub fn RecoveryConstraints::new(
target_window~ : TimeSpan,
available_resources~ : Array[String],
) -> RecoveryConstraints raise ModelError {
if target_window.amount < 0 {
raise ModelError::InvalidDuration(
"recovery.target_window.amount must be non-negative",
)
}
if target_window.amount == 0 {
raise ModelError::InvalidDuration("recovery.target_window must be positive")
}
for i in 0.. Int {
match level {
Low => 0
Medium => 1
High => 2
Critical => 3
}
}
///|
fn has_resource(resources : Array[String], resource : String) -> Bool {
let mut found = false
for available in resources {
if available == resource {
found = true
}
}
found
}
///|
fn validate_recovery_constraints(
items : Array[RecoveryItem],
constraints : RecoveryConstraints,
) -> Unit raise ModelError {
if constraints.target_window.amount <= 0 {
raise ModelError::InvalidDuration("recovery.target_window must be positive")
}
for item in items {
if item.recovery_time.amount <= 0 {
raise ModelError::InvalidDuration("recovery_time must be positive")
}
if item.resource == "" {
raise ModelError::EmptyField("recovery.resource")
}
if !has_resource(constraints.available_resources, item.resource) {
raise ModelError::RecoveryResourceConflict(
"resource unavailable for \{asset_ref_key(item.asset)}: \{item.resource}",
)
}
}
}
///|
fn prerequisites_ready(
candidate : AssetRef,
assets : Array[AssetRef],
dependencies : Array[Dependency],
selected : Array[Bool],
) -> Bool {
let mut ready = true
for dependency in dependencies {
if asset_ref_key(dependency.dependent) == asset_ref_key(candidate) {
let dependency_index = match
asset_index(assets, asset_ref_key(dependency.dependency)) {
Some(index) => index
None => {
ready = false
-1
}
}
if dependency_index >= 0 && !selected[dependency_index] {
ready = false
}
}
}
ready
}
///|
fn checked_recovery_minutes(span : TimeSpan) -> Int raise ModelError {
let factor = match span.unit {
Minutes => 1
Hours => 60
Days => 1440
}
if span.amount <= 0 || span.amount > 2147483647 / factor {
raise ModelError::InvalidDuration(
"recovery duration must fit positive minutes",
)
}
span.amount * factor
}
///|
fn checked_recovery_sum(left : Int, right : Int) -> Int raise ModelError {
if right > 2147483647 - left {
raise ModelError::InvalidDuration("recovery schedule exceeds minute range")
}
left + right
}
///|
fn better_recovery_candidate(
candidate : Int,
current : Int,
items : Array[RecoveryItem],
downstream_rank : Array[Int],
remaining_path : Array[Int],
durations : Array[Int],
) -> Bool {
if downstream_rank[candidate] != downstream_rank[current] {
downstream_rank[candidate] > downstream_rank[current]
} else if remaining_path[candidate] - durations[candidate] !=
remaining_path[current] - durations[current] {
remaining_path[candidate] - durations[candidate] >
remaining_path[current] - durations[current]
} else if impact_rank(items[candidate].impact_level) !=
impact_rank(items[current].impact_level) {
impact_rank(items[candidate].impact_level) >
impact_rank(items[current].impact_level)
} else if durations[candidate] != durations[current] {
durations[candidate] < durations[current]
} else {
asset_ref_key(items[candidate].asset) < asset_ref_key(items[current].asset)
}
}
///|
/// Builds a deterministic earliest-slot list schedule. Each resource has one
/// non-preemptive slot; distinct resources can work in parallel. The ordering
/// heuristic is not an optimality proof for the resulting makespan.
pub fn prioritize_recovery(
items~ : Array[RecoveryItem],
dependencies~ : Array[Dependency],
constraints~ : RecoveryConstraints,
) -> RecoveryPlan raise ModelError {
validate_recovery_constraints(items, constraints)
let assets : Array[AssetRef] = []
for item in items {
assets.push(item.asset)
}
let (adjacency, _) = build_adjacency(assets, dependencies)
ensure_acyclic(assets, adjacency)
let durations : Array[Int] = []
for item in items {
durations.push(checked_recovery_minutes(item.recovery_time))
}
let target_window_minutes = checked_recovery_minutes(
constraints.target_window,
)
let topological = dependency_order(assets, dependencies)
let downstream_rank : Array[Int] = []
let remaining_path : Array[Int] = []
for i in 0.. value
None => abort("validated recovery asset disappeared")
}
for dependent in adjacency[index] {
if downstream_rank[dependent] > downstream_rank[index] {
downstream_rank[index] = downstream_rank[dependent]
}
let path = checked_recovery_sum(
durations[index],
remaining_path[dependent],
)
if path > remaining_path[index] {
remaining_path[index] = path
}
}
}
let mut dependency_lower_bound_minutes = 0
for path in remaining_path {
if path > dependency_lower_bound_minutes {
dependency_lower_bound_minutes = path
}
}
let mut resource_lower_bound_minutes = 0
for resource in constraints.available_resources {
let mut load = 0
for i, item in items {
if item.resource == resource {
load = checked_recovery_sum(load, durations[i])
}
}
if load > resource_lower_bound_minutes {
resource_lower_bound_minutes = load
}
}
let lower_bound = if dependency_lower_bound_minutes >
resource_lower_bound_minutes {
dependency_lower_bound_minutes
} else {
resource_lower_bound_minutes
}
if lower_bound > target_window_minutes {
raise ModelError::RecoveryTargetUnmet(
"target \{target_window_minutes}m is below unavoidable lower bound \{lower_bound}m (dependency \{dependency_lower_bound_minutes}m, resource \{resource_lower_bound_minutes}m)",
)
}
let selected : Array[Bool] = []
let completion : Array[Int] = []
for _ in 0.. chosen = Some(i)
Some(current) =>
if better_recovery_candidate(
i, current, items, downstream_rank, remaining_path, durations,
) {
chosen = Some(i)
}
}
}
}
let index = match chosen {
Some(value) => value
None =>
raise ModelError::DependencyCycle(
"recovery prerequisites cannot be satisfied",
)
}
let item = items[index]
let mut start_minutes = 0
for dependency in dependencies {
if asset_ref_key(dependency.dependent) == asset_ref_key(item.asset) {
let predecessor = match
asset_index(assets, asset_ref_key(dependency.dependency)) {
Some(value) => value
None => abort("validated recovery dependency disappeared")
}
if completion[predecessor] > start_minutes {
start_minutes = completion[predecessor]
}
}
}
let mut searching = true
while searching {
searching = false
let candidate_end = checked_recovery_sum(start_minutes, durations[index])
for booked in schedule {
if booked.item.resource == item.resource &&
start_minutes < booked.end_minutes &&
booked.start_minutes < candidate_end {
start_minutes = booked.end_minutes
searching = true
break
}
}
}
let end_minutes = checked_recovery_sum(start_minutes, durations[index])
selected[index] = true
completion[index] = end_minutes
ordered.push(item)
schedule.push({ item, start_minutes, end_minutes, })
if end_minutes > total_time_minutes {
total_time_minutes = end_minutes
}
}
if total_time_minutes > target_window_minutes {
raise ModelError::RecoveryTargetUnmet(
"scheduled \{total_time_minutes}m exceeds target \{target_window_minutes}m; unavoidable lower bound \{lower_bound}m (dependency \{dependency_lower_bound_minutes}m, resource \{resource_lower_bound_minutes}m)",
)
}
{
ordered,
schedule,
total_time_minutes,
target_window_minutes,
dependency_lower_bound_minutes,
resource_lower_bound_minutes,
slack_minutes: target_window_minutes - total_time_minutes,
}
}