///|
/// Validate one time window and retain an entity context.
pub fn validate_time_window(
window : TimeWindow,
entity_id : String,
field : String,
) -> Array[Diagnostic] {
let issues : Array[Diagnostic] = []
if window.start_minute < 0 {
issues.push(
error_diagnostic(
"time.negative_start", "time window start must not be negative",
)
.for_entity(entity_id)
.at_field(field),
)
}
if window.end_minute <= window.start_minute {
issues.push(
error_diagnostic(
"time.invalid_order", "time window end must be after its start",
)
.for_entity(entity_id)
.at_field(field),
)
}
if window.end_minute > 10080 {
issues.push(
warning_diagnostic(
"time.outside_week", "time window extends beyond a seven-day horizon",
)
.for_entity(entity_id)
.at_field(field),
)
}
issues
}
///|
/// Validate location identity and coordinate bounds.
pub fn validate_location(value : Location) -> Array[Diagnostic] {
let issues : Array[Diagnostic] = []
if value.id.trim().is_empty() {
issues.push(
error_diagnostic("location.empty_id", "location id must not be empty").at_field(
"id",
),
)
}
if value.zone.trim().is_empty() {
issues.push(
warning_diagnostic("location.empty_zone", "location has no service zone")
.for_entity(value.id)
.at_field("zone"),
)
}
if abs_int(value.x) > 100000 || abs_int(value.y) > 100000 {
issues.push(
error_diagnostic(
"location.coordinate_range", "location coordinates exceed supported range",
).for_entity(value.id),
)
}
issues
}
///|
/// Validate skill name and level.
pub fn validate_skill(value : Skill, entity_id : String) -> Array[Diagnostic] {
let issues : Array[Diagnostic] = []
if value.name.trim().is_empty() {
issues.push(
error_diagnostic("skill.empty_name", "skill name must not be empty").for_entity(
entity_id,
),
)
}
if value.level <= 0 || value.level > 10 {
issues.push(
error_diagnostic(
"skill.invalid_level", "skill level must be between 1 and 10",
)
.for_entity(entity_id)
.at_field(value.name),
)
}
issues
}
///|
/// Validate one worker independently from other request entities.
pub fn validate_worker(value : Worker) -> Array[Diagnostic] {
let issues : Array[Diagnostic] = []
if value.id.trim().is_empty() {
issues.push(
error_diagnostic("worker.empty_id", "worker id must not be empty"),
)
}
if value.display_name.trim().is_empty() {
issues.push(
warning_diagnostic(
"worker.empty_display_name", "worker display name is empty",
).for_entity(value.id),
)
}
if value.max_minutes <= 0 {
issues.push(
error_diagnostic(
"worker.invalid_max_minutes", "worker maximum minutes must be positive",
)
.for_entity(value.id)
.at_field("max_minutes"),
)
}
if value.max_visits <= 0 {
issues.push(
error_diagnostic(
"worker.invalid_max_visits", "worker maximum visits must be positive",
)
.for_entity(value.id)
.at_field("max_visits"),
)
}
if value.min_break_minutes < 0 {
issues.push(
error_diagnostic(
"worker.negative_break", "worker minimum break must not be negative",
)
.for_entity(value.id)
.at_field("min_break_minutes"),
)
}
if value.availability.length() == 0 && !value.unavailable {
issues.push(
warning_diagnostic(
"worker.no_availability", "available worker has no availability windows",
).for_entity(value.id),
)
}
for window in value.availability {
issues.append(validate_time_window(window, value.id, "availability"))
}
for capability in value.skills {
issues.append(validate_skill(capability, value.id))
}
let seen_skills : Array[String] = []
for capability in value.skills {
if string_array_contains(seen_skills, capability.name) {
issues.push(
warning_diagnostic(
"worker.duplicate_skill", "worker declares the same skill more than once",
)
.for_entity(value.id)
.at_field(capability.name),
)
}
push_unique_string(seen_skills, capability.name)
}
issues.append(validate_location(value.home))
issues
}
///|
/// Validate one requested visit independently.
pub fn validate_visit(value : Visit) -> Array[Diagnostic] {
let issues : Array[Diagnostic] = []
if value.id.trim().is_empty() {
issues.push(
error_diagnostic("visit.empty_id", "visit id must not be empty"),
)
}
if value.recipient_id.trim().is_empty() {
issues.push(
error_diagnostic(
"visit.empty_recipient", "visit recipient id must not be empty",
).for_entity(value.id),
)
}
if value.title.trim().is_empty() {
issues.push(
warning_diagnostic("visit.empty_title", "visit title is empty").for_entity(
value.id,
),
)
}
if value.duration_minutes <= 0 {
issues.push(
error_diagnostic(
"visit.invalid_duration", "visit duration must be positive",
)
.for_entity(value.id)
.at_field("duration_minutes"),
)
}
if value.duration_minutes > value.window.duration() {
issues.push(
error_diagnostic(
"visit.duration_exceeds_window", "visit duration is longer than its allowed time window",
).for_entity(value.id),
)
}
issues.append(validate_time_window(value.window, value.id, "window"))
issues.append(validate_location(value.location))
for requirement in value.required_skills {
issues.append(validate_skill(requirement, value.id))
}
for worker_id in value.preferred_worker_ids {
if value.forbids(worker_id) {
issues.push(
error_diagnostic(
"visit.worker_both_preferred_and_forbidden", "worker cannot be both preferred and forbidden",
)
.for_entity(value.id)
.at_field(worker_id),
)
}
}
issues
}
///|
/// Validate policy weights and search limits.
pub fn validate_policy(value : SchedulePolicy) -> Array[Diagnostic] {
let issues : Array[Diagnostic] = []
if value.travel_minutes_per_unit <= 0 {
issues.push(
error_diagnostic(
"policy.invalid_travel_rate", "travel minutes per unit must be positive",
),
)
}
if value.cross_zone_penalty_minutes < 0 {
issues.push(
error_diagnostic(
"policy.negative_zone_penalty", "cross-zone penalty must not be negative",
),
)
}
let weights = [
value.priority_weight,
value.preference_weight,
value.continuity_weight,
value.travel_weight,
value.fairness_weight,
value.disruption_weight,
]
for weight in weights {
if weight < 0 {
issues.push(
error_diagnostic(
"policy.negative_weight", "schedule policy weights must not be negative",
),
)
}
}
if value.local_search_rounds < 0 || value.local_search_rounds > 1000 {
issues.push(
error_diagnostic(
"policy.invalid_search_rounds", "local search rounds must be 0 through 1000",
),
)
}
issues
}
///|
/// Report duplicate identifiers and cross-entity references.
fn validate_request_relations(request : ScheduleRequest) -> Array[Diagnostic] {
let issues : Array[Diagnostic] = []
let worker_ids : Array[String] = []
for worker in request.workers {
if string_array_contains(worker_ids, worker.id) {
issues.push(
error_diagnostic(
"request.duplicate_worker_id", "worker identifiers must be unique",
).for_entity(worker.id),
)
}
push_unique_string(worker_ids, worker.id)
}
let visit_ids : Array[String] = []
for visit in request.visits {
if string_array_contains(visit_ids, visit.id) {
issues.push(
error_diagnostic(
"request.duplicate_visit_id", "visit identifiers must be unique",
).for_entity(visit.id),
)
}
push_unique_string(visit_ids, visit.id)
for worker_id in visit.preferred_worker_ids {
if !string_array_contains(worker_ids, worker_id) {
issues.push(
warning_diagnostic(
"request.unknown_preferred_worker", "preferred worker does not exist",
)
.for_entity(visit.id)
.at_field(worker_id),
)
}
}
for worker_id in visit.forbidden_worker_ids {
if !string_array_contains(worker_ids, worker_id) {
issues.push(
warning_diagnostic(
"request.unknown_forbidden_worker", "forbidden worker does not exist",
)
.for_entity(visit.id)
.at_field(worker_id),
)
}
}
}
issues
}
///|
/// Validate a complete scheduling request before solving.
pub fn validate_request(request : ScheduleRequest) -> Array[Diagnostic] {
let issues : Array[Diagnostic] = []
if request.workers.length() == 0 {
issues.push(
error_diagnostic("request.no_workers", "request contains no workers"),
)
}
if request.visits.length() == 0 {
issues.push(
warning_diagnostic("request.no_visits", "request contains no visits"),
)
}
for worker in request.workers {
issues.append(validate_worker(worker))
}
for visit in request.visits {
issues.append(validate_visit(visit))
}
issues.append(validate_policy(request.policy))
issues.append(validate_request_relations(request))
issues
}