901 lines
44 KiB
Python
901 lines
44 KiB
Python
"""Pure, fail-closed validation for scheduling_problem_v2.
|
|
|
|
The validator never mutates the problem, solution, or optional world snapshot.
|
|
It independently recomputes resource, routing, supply, pegging, and provenance
|
|
invariants before a solver result can be materialised by a later integration
|
|
slice.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from collections import defaultdict
|
|
from datetime import datetime, time, timedelta
|
|
from types import MappingProxyType
|
|
from typing import Any, Iterable, Mapping, Sequence, TypeVar
|
|
|
|
from pydantic import BaseModel, ConfigDict, Field
|
|
|
|
from server.aps_domain.scheduling_problem_v2 import (
|
|
ActivityResourceRequirement,
|
|
Resource,
|
|
ResourceKind,
|
|
RoutingStatus,
|
|
ScheduledActivity,
|
|
ScheduledResourceAllocation,
|
|
SchedulingProblemV2,
|
|
SchedulingSolutionV2,
|
|
SolveStatus,
|
|
SourcingType,
|
|
SourceFingerprint,
|
|
canonical_sha256,
|
|
operation_activity_identity,
|
|
scheduling_problem_hash,
|
|
)
|
|
|
|
_EPS = 1e-6
|
|
_T = TypeVar("_T")
|
|
|
|
|
|
class _ValidationModel(BaseModel):
|
|
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
|
|
|
|
class ValidationIssue(_ValidationModel):
|
|
code: str = Field(min_length=1)
|
|
message: str = Field(min_length=1)
|
|
entityRefs: tuple[str, ...] = ()
|
|
severity: str = "HARD"
|
|
|
|
|
|
class ValidationReport(_ValidationModel):
|
|
valid: bool
|
|
problemId: str
|
|
solutionRunId: str
|
|
hardViolations: tuple[ValidationIssue, ...]
|
|
operationIdentityHash: str
|
|
recomputedKpi: Mapping[str, float | int]
|
|
|
|
|
|
def _duplicates(items: Iterable[_T], key) -> set[str]:
|
|
seen: set[str] = set()
|
|
duplicate: set[str] = set()
|
|
for item in items:
|
|
value = str(key(item))
|
|
if value in seen:
|
|
duplicate.add(value)
|
|
seen.add(value)
|
|
return duplicate
|
|
|
|
|
|
def _first_index(items: Iterable[_T], key) -> dict[str, _T]:
|
|
result: dict[str, _T] = {}
|
|
for item in items:
|
|
result.setdefault(str(key(item)), item)
|
|
return result
|
|
|
|
|
|
def _overlaps(start_a: datetime, end_a: datetime, start_b: datetime, end_b: datetime) -> bool:
|
|
return start_a < end_b and start_b < end_a
|
|
|
|
|
|
def _contains(intervals: Sequence[Any], start: datetime, end: datetime) -> bool:
|
|
return any(interval.start <= start and end <= interval.end for interval in intervals)
|
|
|
|
|
|
def _fingerprint_map(items: Iterable[SourceFingerprint]) -> dict[str, tuple[str, str]]:
|
|
return {item.sourceId: (item.revision, item.sha256.lower()) for item in items}
|
|
|
|
|
|
def _snapshot_fingerprints(raw: Any) -> dict[str, tuple[str, str]] | None:
|
|
if raw is None:
|
|
return None
|
|
result: dict[str, tuple[str, str]] = {}
|
|
if isinstance(raw, Mapping):
|
|
for source_id, value in raw.items():
|
|
if isinstance(value, SourceFingerprint):
|
|
result[value.sourceId] = (value.revision, value.sha256.lower())
|
|
elif isinstance(value, Mapping):
|
|
revision = value.get("revision")
|
|
sha256 = value.get("sha256")
|
|
if revision is not None and sha256 is not None:
|
|
result[str(source_id)] = (str(revision), str(sha256).lower())
|
|
elif isinstance(value, (tuple, list)) and len(value) == 2:
|
|
result[str(source_id)] = (str(value[0]), str(value[1]).lower())
|
|
return result
|
|
if isinstance(raw, (tuple, list)):
|
|
for value in raw:
|
|
if isinstance(value, SourceFingerprint):
|
|
result[value.sourceId] = (value.revision, value.sha256.lower())
|
|
elif isinstance(value, Mapping):
|
|
source_id = value.get("sourceId")
|
|
revision = value.get("revision")
|
|
sha256 = value.get("sha256")
|
|
if source_id is not None and revision is not None and sha256 is not None:
|
|
result[str(source_id)] = (str(revision), str(sha256).lower())
|
|
return result
|
|
return None
|
|
|
|
|
|
def _resource_ancestors(resource_id: str, resources: Mapping[str, Resource]) -> tuple[Resource, ...]:
|
|
chain: list[Resource] = []
|
|
visited: set[str] = set()
|
|
current = resource_id
|
|
while current in resources and current not in visited:
|
|
visited.add(current)
|
|
resource = resources[current]
|
|
chain.append(resource)
|
|
if resource.parentResourceId is None:
|
|
break
|
|
current = resource.parentResourceId
|
|
return tuple(chain)
|
|
|
|
|
|
def _requirement_parent_ids(requirement: Any) -> tuple[str, ...]:
|
|
"""Resolve W1 multi-parent links plus the V2 singular compatibility field."""
|
|
|
|
values = set(requirement.parentRequirementIds)
|
|
if requirement.parentRequirementId is not None:
|
|
values.add(requirement.parentRequirementId)
|
|
return tuple(sorted(values))
|
|
|
|
|
|
def _requirement_consumer_ids(requirement: Any) -> tuple[str, ...]:
|
|
values = set(requirement.requiredByActivityIds)
|
|
if requirement.requiredByActivityId is not None:
|
|
values.add(requirement.requiredByActivityId)
|
|
return tuple(sorted(values))
|
|
|
|
|
|
def _operation_identity_hash(activities: Iterable[ScheduledActivity]) -> str:
|
|
payload = [
|
|
{
|
|
"activityId": item.activityId,
|
|
"activityIdentity": item.activityIdentity.lower(),
|
|
"requirementId": item.requirementId,
|
|
"operationId": item.operationId,
|
|
"sequence": item.sequence,
|
|
"resourceId": item.resourceId,
|
|
"start": item.start.isoformat(),
|
|
"end": item.end.isoformat(),
|
|
"resourceUnits": item.resourceUnits,
|
|
"resourceAllocations": [
|
|
allocation.model_dump(mode="json")
|
|
for allocation in sorted(
|
|
item.resourceAllocations,
|
|
key=lambda value: (value.kind.value, value.resourceId),
|
|
)
|
|
],
|
|
}
|
|
for item in sorted(
|
|
activities,
|
|
key=lambda value: (value.activityId, value.start, value.end, value.resourceId),
|
|
)
|
|
]
|
|
return canonical_sha256(payload)
|
|
|
|
|
|
def _activity_resource_requirements(activity: Any) -> tuple[ActivityResourceRequirement, ...]:
|
|
if activity.resourceRequirements:
|
|
return tuple(activity.resourceRequirements)
|
|
return (ActivityResourceRequirement(
|
|
kind=ResourceKind.EQUIPMENT,
|
|
eligibleResourceIds=tuple(activity.eligibleResourceIds),
|
|
requiredCapabilities=tuple(activity.requiredCapabilities),
|
|
units=float(activity.requiredResourceUnits),
|
|
),)
|
|
|
|
|
|
def _scheduled_resource_allocations(item: ScheduledActivity) -> tuple[ScheduledResourceAllocation, ...]:
|
|
if item.resourceAllocations:
|
|
return tuple(item.resourceAllocations)
|
|
return (ScheduledResourceAllocation(
|
|
resourceId=item.resourceId,
|
|
kind=ResourceKind.EQUIPMENT,
|
|
units=float(item.resourceUnits),
|
|
),)
|
|
|
|
|
|
def _daily_load_minutes(start: datetime, end: datetime, units: float) -> tuple[tuple[str, float], ...]:
|
|
if end <= start:
|
|
return ()
|
|
rows: list[tuple[str, float]] = []
|
|
cursor = start
|
|
while cursor.date() < end.date():
|
|
boundary = datetime.combine(cursor.date() + timedelta(days=1), time.min, tzinfo=cursor.tzinfo)
|
|
rows.append((cursor.date().isoformat(), (boundary - cursor).total_seconds() / 60.0 * units))
|
|
cursor = boundary
|
|
rows.append((cursor.date().isoformat(), (end - cursor).total_seconds() / 60.0 * units))
|
|
return tuple(rows)
|
|
|
|
|
|
def validate_solution(
|
|
problem: SchedulingProblemV2,
|
|
solution: SchedulingSolutionV2,
|
|
*,
|
|
world: Mapping[str, Any] | None = None,
|
|
) -> ValidationReport:
|
|
"""Validate a solver/Skill result without mutating any supplied object.
|
|
|
|
world is an optional read-only source snapshot. When present, only source
|
|
revision/fingerprints and the problem hash are inspected.
|
|
"""
|
|
|
|
issues: list[ValidationIssue] = []
|
|
|
|
def add(code: str, message: str, *refs: str) -> None:
|
|
issues.append(
|
|
ValidationIssue(
|
|
code=code,
|
|
message=message,
|
|
entityRefs=tuple(str(ref) for ref in refs if ref is not None),
|
|
)
|
|
)
|
|
|
|
requirements = _first_index(problem.requirements, lambda item: item.requirementId)
|
|
supply_events = _first_index(problem.supplyEvents, lambda item: item.supplyEventId)
|
|
problem_activities = _first_index(problem.activities, lambda item: item.activityId)
|
|
resources = _first_index(problem.resources, lambda item: item.resourceId)
|
|
scheduled = _first_index(solution.activities, lambda item: item.activityId)
|
|
unscheduled = _first_index(solution.unscheduledRequirements, lambda item: item.requirementId)
|
|
|
|
duplicate_sets = (
|
|
("PROBLEM_DUPLICATE_REQUIREMENT", problem.requirements, lambda item: item.requirementId),
|
|
("PROBLEM_DUPLICATE_SUPPLY_EVENT", problem.supplyEvents, lambda item: item.supplyEventId),
|
|
("PROBLEM_DUPLICATE_ACTIVITY", problem.activities, lambda item: item.activityId),
|
|
("PROBLEM_DUPLICATE_RESOURCE", problem.resources, lambda item: item.resourceId),
|
|
("DUPLICATE_ACTIVITY", solution.activities, lambda item: item.activityId),
|
|
("DUPLICATE_SUPPLY_DECISION", solution.supplyDecisions, lambda item: item.decisionId),
|
|
("DUPLICATE_PEGGING", solution.pegging, lambda item: item.peggingId),
|
|
("DUPLICATE_UNSCHEDULED_REQUIREMENT", solution.unscheduledRequirements, lambda item: item.requirementId),
|
|
)
|
|
for code, items, key in duplicate_sets:
|
|
for duplicate_id in sorted(_duplicates(items, key)):
|
|
add(code, f"duplicate identifier {duplicate_id}", duplicate_id)
|
|
|
|
if solution.problemId != problem.problemId:
|
|
add("PROBLEM_ID_MISMATCH", "solution problemId does not match the problem", solution.problemId, problem.problemId)
|
|
|
|
expected_problem_hash = scheduling_problem_hash(problem)
|
|
if solution.provenance.problemHash.lower() != expected_problem_hash:
|
|
add("PROBLEM_HASH_DRIFT", "solution provenance does not bind the current problem content", problem.problemId)
|
|
if solution.provenance.businessDate != problem.businessDate:
|
|
add("BUSINESS_DATE_DRIFT", "solution business date differs from the scheduling problem", problem.problemId)
|
|
if solution.provenance.sourceRevision != problem.sourceRevision:
|
|
add("SOURCE_REVISION_DRIFT", "solution source revision differs from the scheduling problem", problem.problemId)
|
|
|
|
problem_fingerprints = _fingerprint_map(problem.sourceFingerprints)
|
|
solution_fingerprints = _fingerprint_map(solution.provenance.sourceFingerprints)
|
|
if solution_fingerprints != problem_fingerprints:
|
|
add("SOURCE_FINGERPRINT_DRIFT", "solution source fingerprints differ from the scheduling problem", problem.problemId)
|
|
|
|
if world is not None:
|
|
snapshot: Mapping[str, Any] = world
|
|
nested = world.get("schedulingSourceSnapshot")
|
|
if isinstance(nested, Mapping):
|
|
snapshot = nested
|
|
world_revision = snapshot.get("sourceRevision", snapshot.get("schedulingSourceRevision"))
|
|
if world_revision is not None and str(world_revision) != problem.sourceRevision:
|
|
add("WORLD_SOURCE_REVISION_DRIFT", "world source revision differs from the scheduling problem", problem.problemId)
|
|
world_hash = snapshot.get("problemHash", snapshot.get("schedulingProblemHash"))
|
|
if world_hash is not None and str(world_hash).lower() != expected_problem_hash:
|
|
add("WORLD_PROBLEM_HASH_DRIFT", "world problem hash differs from the scheduling problem", problem.problemId)
|
|
world_fingerprints = _snapshot_fingerprints(
|
|
snapshot.get("sourceFingerprints", snapshot.get("schedulingSourceFingerprints"))
|
|
)
|
|
if world_fingerprints is not None and world_fingerprints != problem_fingerprints:
|
|
add("WORLD_SOURCE_FINGERPRINT_DRIFT", "world source fingerprints differ from the scheduling problem", problem.problemId)
|
|
|
|
for record in (*problem.requirements, *problem.supplyEvents, *problem.activities, *problem.resources):
|
|
expected = problem_fingerprints.get(record.sourceRef)
|
|
if expected is None:
|
|
add("SOURCE_REFERENCE_ORPHAN", f"sourceRef {record.sourceRef} is not declared", record.sourceRef)
|
|
elif expected != (record.sourceRevision, record.sourceHash.lower()):
|
|
add("SOURCE_RECORD_DRIFT", f"record source evidence differs from {record.sourceRef}", record.sourceRef)
|
|
|
|
for hard_violation in solution.hardViolations:
|
|
add("SKILL_REPORTED_HARD_VIOLATION", f"{hard_violation.code}: {hard_violation.message}", *hard_violation.entityRefs)
|
|
|
|
if solution.solveStatus in (SolveStatus.OPTIMAL, SolveStatus.FEASIBLE) and solution.unscheduledRequirements:
|
|
add("SUCCESS_STATUS_WITH_UNSCHEDULED_REQUIREMENTS", "successful status cannot contain unscheduled requirements", problem.problemId)
|
|
|
|
for requirement in problem.requirements:
|
|
parent_ids = _requirement_parent_ids(requirement)
|
|
consumer_ids = _requirement_consumer_ids(requirement)
|
|
if (
|
|
requirement.parentRequirementId is not None
|
|
and requirement.parentRequirementIds
|
|
and requirement.parentRequirementId not in requirement.parentRequirementIds
|
|
):
|
|
add(
|
|
"PARENT_COMPATIBILITY_MISMATCH",
|
|
"singular parentRequirementId is not present in parentRequirementIds",
|
|
requirement.requirementId,
|
|
requirement.parentRequirementId,
|
|
)
|
|
if not parent_ids:
|
|
if consumer_ids:
|
|
add(
|
|
"ROOT_REQUIREMENT_HAS_CONSUMER",
|
|
"root requirement must not reference parent consuming activities",
|
|
requirement.requirementId,
|
|
)
|
|
else:
|
|
for parent_id in parent_ids:
|
|
if parent_id not in requirements:
|
|
add(
|
|
"ORPHAN_PARENT_REQUIREMENT",
|
|
"parent requirement does not exist",
|
|
requirement.requirementId,
|
|
parent_id,
|
|
)
|
|
|
|
consumers_by_parent: dict[str, list[str]] = defaultdict(list)
|
|
for consumer_id in consumer_ids:
|
|
consumer = problem_activities.get(consumer_id)
|
|
if consumer is None:
|
|
add(
|
|
"ORPHAN_CONSUMING_ACTIVITY",
|
|
"child requirement references an unknown consuming activity",
|
|
requirement.requirementId,
|
|
consumer_id,
|
|
)
|
|
continue
|
|
if consumer.requirementId not in parent_ids:
|
|
add(
|
|
"CONSUMER_PARENT_MISMATCH",
|
|
"consuming activity does not belong to any declared parent requirement",
|
|
requirement.requirementId,
|
|
consumer.activityId,
|
|
)
|
|
else:
|
|
consumers_by_parent[consumer.requirementId].append(consumer.activityId)
|
|
if requirement.requirementId not in consumer.inputRequirementIds:
|
|
add(
|
|
"CONSUMER_INPUT_MISSING",
|
|
"consuming activity does not declare the child requirement input",
|
|
requirement.requirementId,
|
|
consumer.activityId,
|
|
)
|
|
for parent_id in parent_ids:
|
|
if parent_id in requirements and not consumers_by_parent.get(parent_id):
|
|
add(
|
|
"PARENT_CONSUMER_MISSING",
|
|
"declared parent has no consuming activity for the child requirement",
|
|
requirement.requirementId,
|
|
parent_id,
|
|
)
|
|
|
|
if requirement.sourcingType is SourcingType.MAKE:
|
|
if requirement.routingStatus is not RoutingStatus.CONFIRMED:
|
|
add(
|
|
"ROUTING_NOT_CONFIRMED",
|
|
"MAKE requirement routing is not confirmed",
|
|
requirement.requirementId,
|
|
requirement.routingStatus.value,
|
|
)
|
|
if not requirement.activityIds:
|
|
add("MAKE_ROUTING_EMPTY", "MAKE requirement has no operation activities", requirement.requirementId)
|
|
for activity_id in requirement.activityIds:
|
|
activity = problem_activities.get(activity_id)
|
|
if activity is None:
|
|
add(
|
|
"REQUIREMENT_ACTIVITY_ORPHAN",
|
|
"requirement activityIds contains an unknown activity",
|
|
requirement.requirementId,
|
|
activity_id,
|
|
)
|
|
elif activity.requirementId != requirement.requirementId:
|
|
add(
|
|
"REQUIREMENT_ACTIVITY_MISMATCH",
|
|
"activity belongs to another requirement",
|
|
requirement.requirementId,
|
|
activity_id,
|
|
)
|
|
elif requirement.activityIds:
|
|
add(
|
|
"NON_MAKE_HAS_ACTIVITIES",
|
|
"BUY/SUBCONTRACT requirement must not create factory activities",
|
|
requirement.requirementId,
|
|
)
|
|
|
|
for activity in problem.activities:
|
|
requirement = requirements.get(activity.requirementId)
|
|
if requirement is None:
|
|
add(
|
|
"ORPHAN_PROBLEM_ACTIVITY",
|
|
"problem activity references an unknown requirement",
|
|
activity.activityId,
|
|
activity.requirementId,
|
|
)
|
|
elif requirement.sourcingType is not SourcingType.MAKE:
|
|
add(
|
|
"NON_MAKE_PROBLEM_ACTIVITY",
|
|
"BUY/SUBCONTRACT requirement must not have a factory activity",
|
|
activity.activityId,
|
|
activity.requirementId,
|
|
)
|
|
expected_identity = operation_activity_identity(activity)
|
|
if activity.activityIdentity.lower() != expected_identity:
|
|
add(
|
|
"PROBLEM_ACTIVITY_IDENTITY_DRIFT",
|
|
"problem activity identity does not match immutable operation content",
|
|
activity.activityId,
|
|
)
|
|
for predecessor_id in activity.predecessorActivityIds:
|
|
predecessor = problem_activities.get(predecessor_id)
|
|
if predecessor is None:
|
|
add("ORPHAN_PREDECESSOR", "predecessor activity does not exist", activity.activityId, predecessor_id)
|
|
elif predecessor.requirementId != activity.requirementId:
|
|
add(
|
|
"CROSS_REQUIREMENT_PREDECESSOR",
|
|
"routing predecessor must belong to the same requirement",
|
|
activity.activityId,
|
|
predecessor_id,
|
|
)
|
|
for input_requirement_id in activity.inputRequirementIds:
|
|
input_requirement = requirements.get(input_requirement_id)
|
|
if input_requirement is None:
|
|
add(
|
|
"ORPHAN_ACTIVITY_INPUT",
|
|
"activity input requirement does not exist",
|
|
activity.activityId,
|
|
input_requirement_id,
|
|
)
|
|
elif activity.activityId not in _requirement_consumer_ids(input_requirement):
|
|
add(
|
|
"ACTIVITY_INPUT_CONSUMER_MISMATCH",
|
|
"input requirement points to a different consuming activity",
|
|
activity.activityId,
|
|
input_requirement_id,
|
|
)
|
|
|
|
for activity in problem.activities:
|
|
resource_requirements = _activity_resource_requirements(activity)
|
|
for duplicate_kind in sorted(_duplicates(resource_requirements, lambda item: item.kind.value)):
|
|
add("DUPLICATE_RESOURCE_REQUIREMENT", "activity declares the same resource kind twice", activity.activityId, duplicate_kind)
|
|
for resource_requirement in resource_requirements:
|
|
if not resource_requirement.eligibleResourceIds:
|
|
add(
|
|
"RESOURCE_REQUIREMENT_NO_ELIGIBLE_RESOURCE",
|
|
"activity has no eligible resource for a required kind",
|
|
activity.activityId,
|
|
resource_requirement.kind.value,
|
|
)
|
|
for resource_id in resource_requirement.eligibleResourceIds:
|
|
resource = resources.get(resource_id)
|
|
if resource is None:
|
|
add("RESOURCE_REQUIREMENT_ORPHAN", "eligible resource does not exist", activity.activityId, resource_id)
|
|
elif resource.kind is not resource_requirement.kind:
|
|
add("RESOURCE_REQUIREMENT_KIND_MISMATCH", "eligible resource has the wrong kind", activity.activityId, resource_id)
|
|
|
|
allowed_parent_kinds = {
|
|
ResourceKind.WORKSHOP: {ResourceKind.FACTORY},
|
|
ResourceKind.LINE: {ResourceKind.WORKSHOP},
|
|
ResourceKind.WORKSTATION: {ResourceKind.LINE},
|
|
ResourceKind.EQUIPMENT: {ResourceKind.WORKSTATION, ResourceKind.LINE, ResourceKind.WORKSHOP, ResourceKind.FACTORY},
|
|
ResourceKind.TEAM: {ResourceKind.WORKSTATION, ResourceKind.LINE, ResourceKind.WORKSHOP, ResourceKind.FACTORY},
|
|
ResourceKind.TOOLING: {ResourceKind.WORKSTATION, ResourceKind.LINE, ResourceKind.WORKSHOP, ResourceKind.FACTORY},
|
|
}
|
|
strict_hierarchy = bool(problem.metadata.get("strictResourceHierarchy"))
|
|
hierarchy_present = any(resource.kind in {ResourceKind.FACTORY, ResourceKind.WORKSHOP, ResourceKind.LINE, ResourceKind.WORKSTATION} for resource in problem.resources)
|
|
for resource in problem.resources:
|
|
parent = resources.get(resource.parentResourceId or "")
|
|
if resource.parentResourceId is not None and parent is None:
|
|
add(
|
|
"ORPHAN_RESOURCE_PARENT",
|
|
"resource parent does not exist",
|
|
resource.resourceId,
|
|
resource.parentResourceId,
|
|
)
|
|
if parent is not None and resource.kind in allowed_parent_kinds and parent.kind not in allowed_parent_kinds[resource.kind]:
|
|
add("RESOURCE_HIERARCHY_KIND_MISMATCH", "resource parent has an invalid hierarchy kind", resource.resourceId, parent.resourceId)
|
|
if strict_hierarchy and hierarchy_present and resource.kind in {ResourceKind.EQUIPMENT, ResourceKind.TEAM, ResourceKind.TOOLING} and parent is None:
|
|
add("RESOURCE_HIERARCHY_PARENT_MISSING", "production resource is not attached to the organization hierarchy", resource.resourceId)
|
|
if resource.lifeTotal is not None and resource.lifeUsed > resource.lifeTotal + _EPS:
|
|
add("TOOLING_LIFE_ALREADY_EXCEEDED", "resource lifeUsed exceeds lifeTotal", resource.resourceId)
|
|
visited: set[str] = set()
|
|
cursor: Resource | None = resource
|
|
while cursor is not None:
|
|
if cursor.resourceId in visited:
|
|
add("RESOURCE_HIERARCHY_CYCLE", "resource hierarchy contains a cycle", resource.resourceId)
|
|
break
|
|
visited.add(cursor.resourceId)
|
|
cursor = resources.get(cursor.parentResourceId or "")
|
|
|
|
for item in solution.unscheduledRequirements:
|
|
requirement = requirements.get(item.requirementId)
|
|
if requirement is None:
|
|
add(
|
|
"ORPHAN_UNSCHEDULED_REQUIREMENT",
|
|
"unscheduled requirement does not exist in the problem",
|
|
item.requirementId,
|
|
)
|
|
elif abs(item.quantity - requirement.quantity) > _EPS:
|
|
add(
|
|
"UNSCHEDULED_QUANTITY_MISMATCH",
|
|
"unscheduled quantity differs from requirement quantity",
|
|
item.requirementId,
|
|
)
|
|
|
|
by_requirement_scheduled: dict[str, list[ScheduledActivity]] = defaultdict(list)
|
|
by_resource: dict[str, list[tuple[ScheduledActivity, float, float]]] = defaultdict(list)
|
|
|
|
for item in solution.activities:
|
|
definition = problem_activities.get(item.activityId)
|
|
if definition is None:
|
|
add("ORPHAN_ACTIVITY", "solution activity does not exist in the problem", item.activityId)
|
|
continue
|
|
by_requirement_scheduled[item.requirementId].append(item)
|
|
if item.requirementId != definition.requirementId:
|
|
add("ACTIVITY_REQUIREMENT_MISMATCH", "scheduled activity requirement differs from its definition", item.activityId)
|
|
if item.operationId != definition.operationId or item.sequence != definition.sequence:
|
|
add("ACTIVITY_OPERATION_MISMATCH", "scheduled operation identity differs from its definition", item.activityId)
|
|
expected_identity = operation_activity_identity(definition)
|
|
if item.activityIdentity.lower() != expected_identity:
|
|
add("ACTIVITY_IDENTITY_DRIFT", "scheduled activity identity differs from the current operation definition", item.activityId)
|
|
if item.end <= item.start:
|
|
add("INVALID_ACTIVITY_INTERVAL", "activity end must be after start", item.activityId)
|
|
else:
|
|
actual_minutes = (item.end - item.start).total_seconds() / 60.0
|
|
if actual_minutes + _EPS < definition.durationMin:
|
|
add("ACTIVITY_DURATION_UNDERRUN", "scheduled duration is shorter than required duration", item.activityId)
|
|
if item.start < problem.planningStart or item.end > problem.planningEnd:
|
|
add("ACTIVITY_OUTSIDE_HORIZON", "activity lies outside the planning horizon", item.activityId)
|
|
if definition.materialReleaseAt is not None and item.start < definition.materialReleaseAt:
|
|
add("MATERIAL_RELEASE_VIOLATION", "activity starts before its material release time", item.activityId)
|
|
|
|
required_by_kind = {requirement.kind: requirement for requirement in _activity_resource_requirements(definition)}
|
|
allocations = _scheduled_resource_allocations(item)
|
|
allocations_by_kind: dict[ResourceKind, list[ScheduledResourceAllocation]] = defaultdict(list)
|
|
for allocation in allocations:
|
|
allocations_by_kind[allocation.kind].append(allocation)
|
|
for kind, rows in allocations_by_kind.items():
|
|
if len(rows) > 1:
|
|
add("DUPLICATE_RESOURCE_ALLOCATION", "activity assigns the same resource kind more than once", item.activityId, kind.value)
|
|
for kind, requirement in required_by_kind.items():
|
|
if not allocations_by_kind.get(kind):
|
|
add("REQUIRED_RESOURCE_ASSIGNMENT_MISSING", "scheduled activity omits a required resource kind", item.activityId, kind.value)
|
|
equipment_allocations = allocations_by_kind.get(ResourceKind.EQUIPMENT, [])
|
|
equipment_resource_id = equipment_allocations[0].resourceId if equipment_allocations else item.resourceId
|
|
if equipment_allocations and item.resourceId != equipment_resource_id:
|
|
add("PRIMARY_RESOURCE_MISMATCH", "legacy resourceId differs from the equipment allocation", item.activityId, item.resourceId, equipment_resource_id)
|
|
|
|
for allocation in allocations:
|
|
resource = resources.get(allocation.resourceId)
|
|
if resource is None:
|
|
add("ORPHAN_RESOURCE", "activity references an unknown resource", item.activityId, allocation.resourceId)
|
|
continue
|
|
requirement = required_by_kind.get(allocation.kind)
|
|
if resource.kind is not allocation.kind:
|
|
add("RESOURCE_ALLOCATION_KIND_MISMATCH", "allocation kind differs from the resource kind", item.activityId, allocation.resourceId)
|
|
if requirement is None:
|
|
add("UNDECLARED_RESOURCE_ALLOCATION", "activity allocates a resource kind not required by the operation", item.activityId, allocation.kind.value)
|
|
else:
|
|
if allocation.resourceId not in requirement.eligibleResourceIds:
|
|
add("INELIGIBLE_RESOURCE", "scheduled resource is not eligible for the activity", item.activityId, allocation.resourceId)
|
|
missing_capabilities = sorted(set(requirement.requiredCapabilities) - set(resource.capabilities))
|
|
if missing_capabilities:
|
|
add("RESOURCE_CAPABILITY_MISMATCH", f"resource lacks capabilities: {', '.join(missing_capabilities)}", item.activityId, allocation.resourceId)
|
|
if allocation.units + _EPS < requirement.units:
|
|
add("RESOURCE_UNITS_UNDERRUN", "scheduled resource units are below the activity requirement", item.activityId, allocation.resourceId)
|
|
if allocation.lifeUnits + _EPS < requirement.lifeUnits:
|
|
add("TOOLING_LIFE_UNITS_UNDERRUN", "scheduled tooling life consumption is below the activity requirement", item.activityId, allocation.resourceId)
|
|
if allocation.kind is ResourceKind.TOOLING and resource.compatibleResourceIds and equipment_resource_id not in resource.compatibleResourceIds:
|
|
add("TOOLING_EQUIPMENT_INCOMPATIBLE", "tooling is not compatible with the selected equipment", item.activityId, allocation.resourceId, equipment_resource_id)
|
|
|
|
chain = _resource_ancestors(allocation.resourceId, resources)
|
|
for scoped_resource in chain:
|
|
if not scoped_resource.calendarIntervals or not _contains(scoped_resource.calendarIntervals, item.start, item.end):
|
|
add("RESOURCE_CALENDAR_VIOLATION", "activity is not fully contained in an available calendar interval", item.activityId, scoped_resource.resourceId)
|
|
if any(_overlaps(item.start, item.end, interval.start, interval.end) for interval in scoped_resource.maintenanceIntervals):
|
|
add("RESOURCE_MAINTENANCE_VIOLATION", "activity overlaps a maintenance interval", item.activityId, scoped_resource.resourceId)
|
|
if scoped_resource.resourceId == allocation.resourceId or allocation.kind is ResourceKind.EQUIPMENT:
|
|
by_resource[scoped_resource.resourceId].append((item, allocation.units, allocation.lifeUnits))
|
|
|
|
for activity_id, definition in problem_activities.items():
|
|
if activity_id not in scheduled and definition.requirementId not in unscheduled:
|
|
add(
|
|
"ACTIVITY_MISSING",
|
|
"problem activity is neither scheduled nor covered by an unscheduled requirement",
|
|
activity_id,
|
|
definition.requirementId,
|
|
)
|
|
|
|
for requirement_id, activities in by_requirement_scheduled.items():
|
|
if requirement_id in unscheduled:
|
|
add(
|
|
"UNSCHEDULED_REQUIREMENT_HAS_ACTIVITY",
|
|
"an unscheduled requirement still has scheduled activities",
|
|
requirement_id,
|
|
)
|
|
activities.sort(key=lambda value: (value.sequence, value.start, value.activityId))
|
|
for left, right in zip(activities, activities[1:]):
|
|
if left.sequence >= right.sequence:
|
|
add(
|
|
"ROUTING_SEQUENCE_DUPLICATE",
|
|
"scheduled routing sequence is not strictly increasing",
|
|
left.activityId,
|
|
right.activityId,
|
|
)
|
|
if left.end > right.start:
|
|
add(
|
|
"ROUTING_ORDER_VIOLATION",
|
|
"later operation starts before the previous operation completes",
|
|
left.activityId,
|
|
right.activityId,
|
|
)
|
|
|
|
for activity in problem.activities:
|
|
item = scheduled.get(activity.activityId)
|
|
if item is None:
|
|
continue
|
|
for predecessor_id in activity.predecessorActivityIds:
|
|
predecessor = scheduled.get(predecessor_id)
|
|
if predecessor is None:
|
|
if activity.requirementId not in unscheduled:
|
|
add(
|
|
"PREDECESSOR_UNSCHEDULED",
|
|
"scheduled activity has an unscheduled predecessor",
|
|
activity.activityId,
|
|
predecessor_id,
|
|
)
|
|
elif predecessor.end > item.start:
|
|
add(
|
|
"ROUTING_PRECEDENCE_VIOLATION",
|
|
"activity starts before its predecessor completes",
|
|
activity.activityId,
|
|
predecessor_id,
|
|
)
|
|
|
|
for resource_id, activities in by_resource.items():
|
|
resource = resources[resource_id]
|
|
events: list[tuple[datetime, int, float, str]] = []
|
|
daily_load: dict[str, float] = defaultdict(float)
|
|
life_consumption = 0.0
|
|
for item, units, life_units in activities:
|
|
events.append((item.start, 1, units, item.activityId))
|
|
events.append((item.end, 0, -units, item.activityId))
|
|
for day_key, minutes in _daily_load_minutes(item.start, item.end, units):
|
|
daily_load[day_key] += minutes
|
|
life_consumption += life_units
|
|
events.sort(key=lambda value: (value[0], value[1], value[3]))
|
|
usage = 0.0
|
|
for timestamp, _, delta, activity_id in events:
|
|
usage += delta
|
|
if usage > resource.cumulativeCapacity + _EPS:
|
|
add(
|
|
"RESOURCE_CAPACITY_OVERLAP",
|
|
f"capacity {resource.cumulativeCapacity} exceeded at {timestamp.isoformat()}",
|
|
resource_id,
|
|
activity_id,
|
|
)
|
|
break
|
|
if resource.dailyCapacityMinutes is not None:
|
|
for day_key, minutes in sorted(daily_load.items()):
|
|
if minutes > resource.dailyCapacityMinutes + _EPS:
|
|
add(
|
|
"RESOURCE_DAILY_CAPACITY_EXCEEDED",
|
|
f"daily load {minutes:.3f} exceeds {resource.dailyCapacityMinutes:.3f} minutes",
|
|
resource_id,
|
|
day_key,
|
|
)
|
|
if resource.kind is ResourceKind.TOOLING and resource.lifeTotal is not None:
|
|
if resource.lifeUsed + life_consumption > resource.lifeTotal + _EPS:
|
|
add(
|
|
"TOOLING_LIFE_EXCEEDED",
|
|
"scheduled tooling consumption exceeds remaining mold life",
|
|
resource_id,
|
|
)
|
|
|
|
supply_decisions_by_requirement: dict[str, list[Any]] = defaultdict(list)
|
|
allocated_by_event: dict[str, float] = defaultdict(float)
|
|
for decision in solution.supplyDecisions:
|
|
event = supply_events.get(decision.supplyEventId)
|
|
requirement = requirements.get(decision.requirementId)
|
|
if event is None:
|
|
add(
|
|
"ORPHAN_SUPPLY_EVENT",
|
|
"supply decision references an unknown supply event",
|
|
decision.decisionId,
|
|
decision.supplyEventId,
|
|
)
|
|
continue
|
|
if requirement is None:
|
|
add(
|
|
"ORPHAN_SUPPLY_REQUIREMENT",
|
|
"supply decision references an unknown requirement",
|
|
decision.decisionId,
|
|
decision.requirementId,
|
|
)
|
|
continue
|
|
supply_decisions_by_requirement[decision.requirementId].append(decision)
|
|
allocated_by_event[decision.supplyEventId] += decision.quantity
|
|
if event.requirementId != decision.requirementId:
|
|
add("SUPPLY_REQUIREMENT_MISMATCH", "supply event belongs to another requirement", decision.decisionId)
|
|
if event.materialId != decision.materialId or requirement.materialId != decision.materialId:
|
|
add("SUPPLY_MATERIAL_MISMATCH", "supply material differs from event or requirement", decision.decisionId)
|
|
if event.source is not decision.source:
|
|
add("SUPPLY_SOURCE_MISMATCH", "supply source differs from the supply event", decision.decisionId)
|
|
if event.availableAt != decision.availableAt:
|
|
add("SUPPLY_TIME_DRIFT", "supply availability differs from the source event", decision.decisionId)
|
|
expected_consumers = set(_requirement_consumer_ids(requirement))
|
|
if expected_consumers:
|
|
if decision.consumedByActivityId not in expected_consumers:
|
|
add("SUPPLY_CONSUMER_MISMATCH", "supply consumer differs from the requirement", decision.decisionId)
|
|
elif decision.consumedByActivityId is not None:
|
|
add("SUPPLY_CONSUMER_MISMATCH", "root supply must not name a consuming activity", decision.decisionId)
|
|
if decision.consumedByActivityId is not None:
|
|
consumer = scheduled.get(decision.consumedByActivityId)
|
|
if consumer is None:
|
|
if requirement.requirementId not in unscheduled:
|
|
add(
|
|
"SUPPLY_CONSUMER_UNSCHEDULED",
|
|
"supply is allocated to an unscheduled consuming activity",
|
|
decision.decisionId,
|
|
decision.consumedByActivityId,
|
|
)
|
|
elif event.availableAt > consumer.start:
|
|
add(
|
|
"MATERIAL_NOT_READY",
|
|
"supply becomes available after the consuming activity starts",
|
|
decision.decisionId,
|
|
decision.consumedByActivityId,
|
|
)
|
|
|
|
for event_id, quantity in allocated_by_event.items():
|
|
event = supply_events[event_id]
|
|
if quantity > event.quantity + _EPS:
|
|
add("SUPPLY_EVENT_OVERALLOCATED", "supply event is allocated beyond its quantity", event_id)
|
|
|
|
for requirement in problem.requirements:
|
|
decisions = supply_decisions_by_requirement.get(requirement.requirementId, [])
|
|
decision_total = sum(item.quantity for item in decisions)
|
|
if requirement.sourcingType is not SourcingType.MAKE:
|
|
if requirement.requirementId not in unscheduled and abs(decision_total - requirement.quantity) > _EPS:
|
|
add(
|
|
"SUPPLY_QUANTITY_NOT_CONSERVED",
|
|
"BUY/SUBCONTRACT supply quantity does not equal requirement quantity",
|
|
requirement.requirementId,
|
|
)
|
|
elif decisions and any(item.source.value != "MAKE" for item in decisions):
|
|
add(
|
|
"MAKE_REQUIREMENT_EXTERNAL_SUPPLY",
|
|
"MAKE requirement cannot be satisfied by external supply",
|
|
requirement.requirementId,
|
|
)
|
|
|
|
pegging_by_child: dict[str, float] = defaultdict(float)
|
|
pegging_by_child_parent: dict[tuple[str, str], float] = defaultdict(float)
|
|
for peg in solution.pegging:
|
|
child = requirements.get(peg.childRequirementId)
|
|
parent = requirements.get(peg.parentRequirementId)
|
|
if child is None:
|
|
add(
|
|
"ORPHAN_PEGGING_CHILD",
|
|
"pegging references an unknown child requirement",
|
|
peg.peggingId,
|
|
peg.childRequirementId,
|
|
)
|
|
continue
|
|
if parent is None:
|
|
add(
|
|
"ORPHAN_PEGGING_PARENT",
|
|
"pegging references an unknown parent requirement",
|
|
peg.peggingId,
|
|
peg.parentRequirementId,
|
|
)
|
|
continue
|
|
pegging_by_child[child.requirementId] += peg.quantity
|
|
pegging_by_child_parent[(child.requirementId, parent.requirementId)] += peg.quantity
|
|
if parent.requirementId not in _requirement_parent_ids(child):
|
|
add("PEGGING_PARENT_MISMATCH", "pegging parent differs from the requirement graph", peg.peggingId)
|
|
|
|
for requirement in problem.requirements:
|
|
parent_ids = _requirement_parent_ids(requirement)
|
|
pegged_quantity = pegging_by_child.get(requirement.requirementId, 0.0)
|
|
if not parent_ids:
|
|
if pegged_quantity > _EPS:
|
|
add("ROOT_REQUIREMENT_PEGGED", "root requirement must not be pegged", requirement.requirementId)
|
|
continue
|
|
if abs(pegged_quantity - requirement.quantity) > _EPS:
|
|
add(
|
|
"PEGGING_QUANTITY_NOT_CONSERVED",
|
|
"child pegging quantity does not equal requirement quantity",
|
|
requirement.requirementId,
|
|
)
|
|
for parent_id in parent_ids:
|
|
if pegging_by_child_parent.get((requirement.requirementId, parent_id), 0.0) <= _EPS:
|
|
add(
|
|
"PEGGING_PARENT_MISSING",
|
|
"declared parent has no pegging allocation",
|
|
requirement.requirementId,
|
|
parent_id,
|
|
)
|
|
|
|
for child in problem.requirements:
|
|
parent_ids = _requirement_parent_ids(child)
|
|
consumer_ids = _requirement_consumer_ids(child)
|
|
if not parent_ids or not consumer_ids:
|
|
continue
|
|
consumers_by_parent: dict[str, list[ScheduledActivity]] = defaultdict(list)
|
|
for consumer_id in consumer_ids:
|
|
consumer = scheduled.get(consumer_id)
|
|
definition = problem_activities.get(consumer_id)
|
|
if consumer is not None and definition is not None:
|
|
consumers_by_parent[definition.requirementId].append(consumer)
|
|
if child.sourcingType is SourcingType.MAKE:
|
|
child_activities = by_requirement_scheduled.get(child.requirementId, [])
|
|
expected_activity_ids = set(child.activityIds)
|
|
actual_activity_ids = {item.activityId for item in child_activities}
|
|
for parent_id in parent_ids:
|
|
for consumer in consumers_by_parent.get(parent_id, []):
|
|
if child.requirementId in unscheduled or not expected_activity_ids.issubset(actual_activity_ids):
|
|
add(
|
|
"CHILD_MAKE_NOT_COMPLETE",
|
|
"parent consumes an incomplete MAKE requirement",
|
|
child.requirementId,
|
|
consumer.activityId,
|
|
)
|
|
elif max(item.end for item in child_activities) > consumer.start:
|
|
add(
|
|
"PARENT_STARTS_BEFORE_CHILD_COMPLETE",
|
|
"parent starts before child MAKE completion",
|
|
child.requirementId,
|
|
consumer.activityId,
|
|
)
|
|
else:
|
|
ready_decisions = supply_decisions_by_requirement.get(child.requirementId, [])
|
|
for parent_id in parent_ids:
|
|
parent_consumers = consumers_by_parent.get(parent_id, [])
|
|
consumer_by_id = {item.activityId: item for item in parent_consumers}
|
|
expected_quantity = pegging_by_child_parent.get((child.requirementId, parent_id), 0.0)
|
|
ready_quantity = sum(
|
|
item.quantity
|
|
for item in ready_decisions
|
|
if item.consumedByActivityId in consumer_by_id
|
|
and item.availableAt <= consumer_by_id[item.consumedByActivityId].start
|
|
)
|
|
if ready_quantity + _EPS < expected_quantity:
|
|
add(
|
|
"PARENT_STARTS_BEFORE_SUPPLY_READY",
|
|
"parent starts before its pegged BUY/SUBCONTRACT supply is ready",
|
|
child.requirementId,
|
|
parent_id,
|
|
)
|
|
|
|
if solution.activities:
|
|
first_start = min(item.start for item in solution.activities)
|
|
last_end = max(item.end for item in solution.activities)
|
|
makespan_minutes = max(0.0, (last_end - first_start).total_seconds() / 60.0)
|
|
else:
|
|
makespan_minutes = 0.0
|
|
|
|
operation_identity_hash = _operation_identity_hash(solution.activities)
|
|
unique_issues = {(issue.code, issue.message, issue.entityRefs): issue for issue in issues}
|
|
ordered_issues = tuple(
|
|
unique_issues[key]
|
|
for key in sorted(unique_issues, key=lambda value: (value[0], value[2], value[1]))
|
|
)
|
|
return ValidationReport(
|
|
valid=not ordered_issues,
|
|
problemId=problem.problemId,
|
|
solutionRunId=solution.provenance.runId,
|
|
hardViolations=ordered_issues,
|
|
operationIdentityHash=operation_identity_hash,
|
|
recomputedKpi=MappingProxyType(
|
|
{
|
|
"requirementCount": len(problem.requirements),
|
|
"scheduledActivityCount": len(solution.activities),
|
|
"scheduledRequirementCount": len(by_requirement_scheduled),
|
|
"unscheduledRequirementCount": len(solution.unscheduledRequirements),
|
|
"supplyDecisionCount": len(solution.supplyDecisions),
|
|
"peggingCount": len(solution.pegging),
|
|
"makespanMinutes": makespan_minutes,
|
|
}
|
|
),
|
|
)
|
|
|
|
|
|
# Backward-compatible descriptive aliases for integration callers.
|
|
validate_scheduling_solution = validate_solution
|
|
validate_scheduling_solution_v2 = validate_solution
|