aps-agent/server/aps_domain/scheduling_validator.py

903 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