1020 lines
36 KiB
Python
1020 lines
36 KiB
Python
from __future__ import annotations
|
|
|
|
from datetime import date, datetime, timezone
|
|
from types import MappingProxyType
|
|
|
|
import pytest
|
|
|
|
from server.aps_domain.closed_loop_problem import build_closed_loop_problem
|
|
from server.aps_domain.scheduling_problem_v2 import (
|
|
ConstraintPolicy,
|
|
ObjectivePolicy,
|
|
OperationActivity,
|
|
PeggingAllocation,
|
|
Requirement,
|
|
Resource,
|
|
ResourceKind,
|
|
RoutingStatus,
|
|
ScheduledActivity,
|
|
SchedulingProblemV2,
|
|
SchedulingSolutionV2,
|
|
SolutionProvenance,
|
|
SolveStatus,
|
|
SourceFingerprint,
|
|
SupplyDecision,
|
|
SupplyEvent,
|
|
SupplySource,
|
|
SourcingType,
|
|
TimeInterval,
|
|
operation_activity_identity,
|
|
scheduling_problem_hash,
|
|
)
|
|
from server.aps_domain.scheduling_validator import (
|
|
validate_scheduling_solution,
|
|
validate_solution,
|
|
)
|
|
|
|
|
|
BUSINESS_DATE = date(2026, 8, 2)
|
|
SOURCE_ID = "factory-pack"
|
|
SOURCE_REVISION = "ruiyang-2026-08-02-r1"
|
|
SOURCE_HASH = "a" * 64
|
|
|
|
|
|
def _dt(hour: int, minute: int = 0) -> datetime:
|
|
return datetime(2026, 8, 2, hour, minute, tzinfo=timezone.utc)
|
|
|
|
|
|
def _source_fields() -> dict[str, str]:
|
|
return {
|
|
"sourceRef": SOURCE_ID,
|
|
"sourceRevision": SOURCE_REVISION,
|
|
"sourceHash": SOURCE_HASH,
|
|
}
|
|
|
|
|
|
def _operation(
|
|
*,
|
|
activity_id: str,
|
|
requirement_id: str,
|
|
operation_code: str,
|
|
sequence: int,
|
|
eligible_resource_id: str,
|
|
predecessor_ids: tuple[str, ...] = (),
|
|
input_requirement_ids: tuple[str, ...] = (),
|
|
) -> OperationActivity:
|
|
raw = {
|
|
"activityId": activity_id,
|
|
"requirementId": requirement_id,
|
|
"routingId": f"route-{requirement_id}",
|
|
"routingVersion": "confirmed-r1",
|
|
"operationId": f"op-{activity_id}",
|
|
"operationCode": operation_code,
|
|
"sequence": sequence,
|
|
"durationMin": 60.0,
|
|
"predecessorActivityIds": predecessor_ids,
|
|
"inputRequirementIds": input_requirement_ids,
|
|
"eligibleResourceIds": (eligible_resource_id,),
|
|
"requiredCapabilities": (operation_code,),
|
|
"requiredResourceUnits": 1.0,
|
|
"continuous": False,
|
|
**_source_fields(),
|
|
}
|
|
return OperationActivity(
|
|
activityIdentity=operation_activity_identity(raw),
|
|
**raw,
|
|
)
|
|
|
|
|
|
def _scheduled(
|
|
activity: OperationActivity,
|
|
*,
|
|
start: datetime,
|
|
end: datetime,
|
|
resource_id: str,
|
|
) -> ScheduledActivity:
|
|
return ScheduledActivity(
|
|
activityId=activity.activityId,
|
|
activityIdentity=activity.activityIdentity,
|
|
requirementId=activity.requirementId,
|
|
operationId=activity.operationId,
|
|
sequence=activity.sequence,
|
|
resourceId=resource_id,
|
|
start=start,
|
|
end=end,
|
|
resourceUnits=1.0,
|
|
)
|
|
|
|
|
|
def _rebind(problem: SchedulingProblemV2, solution: SchedulingSolutionV2) -> SchedulingSolutionV2:
|
|
provenance = solution.provenance.model_copy(
|
|
update={
|
|
"problemHash": scheduling_problem_hash(problem),
|
|
"sourceRevision": problem.sourceRevision,
|
|
"sourceFingerprints": problem.sourceFingerprints,
|
|
"businessDate": problem.businessDate,
|
|
}
|
|
)
|
|
return solution.model_copy(update={"problemId": problem.problemId, "provenance": provenance})
|
|
|
|
|
|
def _build_valid_fixture() -> tuple[SchedulingProblemV2, SchedulingSolutionV2, MappingProxyType]:
|
|
calendar = (TimeInterval(start=_dt(8), end=_dt(18), label="day-shift"),)
|
|
resources = (
|
|
Resource(
|
|
resourceId="factory-1",
|
|
code="F1",
|
|
name="Factory 1",
|
|
kind=ResourceKind.FACTORY,
|
|
calendarIntervals=calendar,
|
|
cumulativeCapacity=4,
|
|
**_source_fields(),
|
|
),
|
|
Resource(
|
|
resourceId="workshop-1",
|
|
code="WS1",
|
|
name="Workshop 1",
|
|
kind=ResourceKind.WORKSHOP,
|
|
parentResourceId="factory-1",
|
|
calendarIntervals=calendar,
|
|
cumulativeCapacity=2,
|
|
**_source_fields(),
|
|
),
|
|
Resource(
|
|
resourceId="res-cut",
|
|
code="CUT-01",
|
|
name="Cut machine",
|
|
kind=ResourceKind.EQUIPMENT,
|
|
parentResourceId="workshop-1",
|
|
capabilities=("CUT",),
|
|
calendarIntervals=calendar,
|
|
cumulativeCapacity=1,
|
|
**_source_fields(),
|
|
),
|
|
Resource(
|
|
resourceId="res-weld",
|
|
code="WELD-01",
|
|
name="Weld machine",
|
|
kind=ResourceKind.EQUIPMENT,
|
|
parentResourceId="workshop-1",
|
|
capabilities=("WELD",),
|
|
calendarIntervals=calendar,
|
|
cumulativeCapacity=1,
|
|
**_source_fields(),
|
|
),
|
|
Resource(
|
|
resourceId="res-asm",
|
|
code="ASM-01",
|
|
name="Assembly station",
|
|
kind=ResourceKind.EQUIPMENT,
|
|
parentResourceId="workshop-1",
|
|
capabilities=("ASM",),
|
|
calendarIntervals=calendar,
|
|
cumulativeCapacity=1,
|
|
**_source_fields(),
|
|
),
|
|
)
|
|
|
|
child_cut = _operation(
|
|
activity_id="act-child-cut",
|
|
requirement_id="req-child-make",
|
|
operation_code="CUT",
|
|
sequence=10,
|
|
eligible_resource_id="res-cut",
|
|
)
|
|
child_weld = _operation(
|
|
activity_id="act-child-weld",
|
|
requirement_id="req-child-make",
|
|
operation_code="WELD",
|
|
sequence=20,
|
|
eligible_resource_id="res-weld",
|
|
predecessor_ids=(child_cut.activityId,),
|
|
)
|
|
root_asm = _operation(
|
|
activity_id="act-root-asm",
|
|
requirement_id="req-root",
|
|
operation_code="ASM",
|
|
sequence=10,
|
|
eligible_resource_id="res-asm",
|
|
input_requirement_ids=("req-child-make", "req-buy", "req-sub"),
|
|
)
|
|
|
|
requirements = (
|
|
Requirement(
|
|
requirementId="req-root",
|
|
orderId="MOM--00280",
|
|
orderLineId="MOM--00280:1",
|
|
materialId="FG-001",
|
|
quantity=1,
|
|
sourcingType=SourcingType.MAKE,
|
|
requiredAt=_dt(17),
|
|
routingStatus=RoutingStatus.CONFIRMED,
|
|
routingId="route-req-root",
|
|
routingVersion="confirmed-r1",
|
|
activityIds=(root_asm.activityId,),
|
|
**_source_fields(),
|
|
),
|
|
Requirement(
|
|
requirementId="req-child-make",
|
|
orderId="MOM--00280",
|
|
orderLineId="MOM--00280:1",
|
|
materialId="SEMI-001",
|
|
quantity=1,
|
|
sourcingType=SourcingType.MAKE,
|
|
requiredAt=_dt(10),
|
|
parentRequirementId="req-root",
|
|
requiredByActivityId=root_asm.activityId,
|
|
routingStatus=RoutingStatus.CONFIRMED,
|
|
routingId="route-req-child-make",
|
|
routingVersion="confirmed-r1",
|
|
activityIds=(child_cut.activityId, child_weld.activityId),
|
|
**_source_fields(),
|
|
),
|
|
Requirement(
|
|
requirementId="req-buy",
|
|
orderId="MOM--00280",
|
|
orderLineId="MOM--00280:1",
|
|
materialId="RAW-001",
|
|
quantity=2,
|
|
sourcingType=SourcingType.BUY,
|
|
requiredAt=_dt(10),
|
|
parentRequirementId="req-root",
|
|
requiredByActivityId=root_asm.activityId,
|
|
**_source_fields(),
|
|
),
|
|
Requirement(
|
|
requirementId="req-sub",
|
|
orderId="MOM--00280",
|
|
orderLineId="MOM--00280:1",
|
|
materialId="SUB-001",
|
|
quantity=1,
|
|
sourcingType=SourcingType.SUBCONTRACT,
|
|
requiredAt=_dt(10),
|
|
parentRequirementId="req-root",
|
|
requiredByActivityId=root_asm.activityId,
|
|
**_source_fields(),
|
|
),
|
|
)
|
|
|
|
supply_events = (
|
|
SupplyEvent(
|
|
supplyEventId="supply-buy",
|
|
requirementId="req-buy",
|
|
materialId="RAW-001",
|
|
source=SupplySource.PURCHASE,
|
|
quantity=2,
|
|
availableAt=_dt(9),
|
|
**_source_fields(),
|
|
),
|
|
SupplyEvent(
|
|
supplyEventId="supply-sub",
|
|
requirementId="req-sub",
|
|
materialId="SUB-001",
|
|
source=SupplySource.SUBCONTRACT,
|
|
quantity=1,
|
|
availableAt=_dt(9, 30),
|
|
**_source_fields(),
|
|
),
|
|
)
|
|
|
|
fingerprints = (
|
|
SourceFingerprint(sourceId=SOURCE_ID, revision=SOURCE_REVISION, sha256=SOURCE_HASH),
|
|
)
|
|
problem = SchedulingProblemV2(
|
|
problemId="problem-round65-w3",
|
|
businessDate=BUSINESS_DATE,
|
|
planningStart=_dt(8),
|
|
planningEnd=_dt(18),
|
|
requirements=requirements,
|
|
supplyEvents=supply_events,
|
|
activities=(child_cut, child_weld, root_asm),
|
|
resources=resources,
|
|
objectivePolicy=ObjectivePolicy(
|
|
priorities=("weightedTardiness", "changeover", "loadBalance"),
|
|
weights={"weightedTardiness": 1000.0, "changeover": 10.0, "loadBalance": 1.0},
|
|
),
|
|
constraintPolicy=ConstraintPolicy(
|
|
enabled={"routing": True, "material": True, "resource": True},
|
|
hardConstraints=("routing", "material", "resource", "calendar", "maintenance"),
|
|
),
|
|
sourceRevision=SOURCE_REVISION,
|
|
sourceFingerprints=fingerprints,
|
|
metadata={"factory": "閿愭壃", "businessDate": "2026-08-02"},
|
|
)
|
|
|
|
scheduled_activities = (
|
|
_scheduled(child_cut, start=_dt(8), end=_dt(9), resource_id="res-cut"),
|
|
_scheduled(child_weld, start=_dt(9), end=_dt(10), resource_id="res-weld"),
|
|
_scheduled(root_asm, start=_dt(10), end=_dt(11), resource_id="res-asm"),
|
|
)
|
|
solution = SchedulingSolutionV2(
|
|
problemId=problem.problemId,
|
|
solveStatus=SolveStatus.FEASIBLE,
|
|
objectiveValues={"weightedTardiness": 0.0, "changeover": 0.0, "loadBalance": 0.25},
|
|
activities=scheduled_activities,
|
|
supplyDecisions=(
|
|
SupplyDecision(
|
|
decisionId="decision-buy",
|
|
supplyEventId="supply-buy",
|
|
requirementId="req-buy",
|
|
materialId="RAW-001",
|
|
source=SupplySource.PURCHASE,
|
|
quantity=2,
|
|
availableAt=_dt(9),
|
|
consumedByActivityId=root_asm.activityId,
|
|
),
|
|
SupplyDecision(
|
|
decisionId="decision-sub",
|
|
supplyEventId="supply-sub",
|
|
requirementId="req-sub",
|
|
materialId="SUB-001",
|
|
source=SupplySource.SUBCONTRACT,
|
|
quantity=1,
|
|
availableAt=_dt(9, 30),
|
|
consumedByActivityId=root_asm.activityId,
|
|
),
|
|
),
|
|
pegging=(
|
|
PeggingAllocation(
|
|
peggingId="peg-make",
|
|
childRequirementId="req-child-make",
|
|
parentRequirementId="req-root",
|
|
quantity=1,
|
|
),
|
|
PeggingAllocation(
|
|
peggingId="peg-buy",
|
|
childRequirementId="req-buy",
|
|
parentRequirementId="req-root",
|
|
quantity=2,
|
|
),
|
|
PeggingAllocation(
|
|
peggingId="peg-sub",
|
|
childRequirementId="req-sub",
|
|
parentRequirementId="req-root",
|
|
quantity=1,
|
|
),
|
|
),
|
|
provenance=SolutionProvenance(
|
|
runId="skill-run-valid",
|
|
solverId="fixture-skill",
|
|
solverVersion="2.0.0",
|
|
generatedAt=_dt(12),
|
|
businessDate=BUSINESS_DATE,
|
|
problemHash=scheduling_problem_hash(problem),
|
|
sourceRevision=SOURCE_REVISION,
|
|
sourceFingerprints=fingerprints,
|
|
),
|
|
)
|
|
world = MappingProxyType(
|
|
{
|
|
"sourceRevision": SOURCE_REVISION,
|
|
"problemHash": scheduling_problem_hash(problem),
|
|
"sourceFingerprints": MappingProxyType(
|
|
{SOURCE_ID: (SOURCE_REVISION, SOURCE_HASH)}
|
|
),
|
|
}
|
|
)
|
|
return problem, solution, world
|
|
|
|
|
|
@pytest.fixture
|
|
def valid_skill_fixture():
|
|
return _build_valid_fixture()
|
|
|
|
|
|
@pytest.fixture
|
|
def malicious_skill_fixture(valid_skill_fixture):
|
|
problem, solution, world = valid_skill_fixture
|
|
orphan = ScheduledActivity(
|
|
activityId="act-orphan",
|
|
activityIdentity="f" * 64,
|
|
requirementId="req-root",
|
|
operationId="op-orphan",
|
|
sequence=99,
|
|
resourceId="res-asm",
|
|
start=_dt(12),
|
|
end=_dt(13),
|
|
)
|
|
duplicate_orphan = solution.model_copy(
|
|
update={"activities": (*solution.activities, solution.activities[0], orphan)}
|
|
)
|
|
|
|
child_cut, child_weld, root_asm = solution.activities
|
|
reversed_routing = solution.model_copy(
|
|
update={
|
|
"activities": (
|
|
child_cut.model_copy(update={"start": _dt(9), "end": _dt(10)}),
|
|
child_weld.model_copy(update={"start": _dt(8), "end": _dt(9)}),
|
|
root_asm,
|
|
)
|
|
}
|
|
)
|
|
overlap_wrong_resource = solution.model_copy(
|
|
update={
|
|
"activities": (
|
|
child_cut,
|
|
child_weld,
|
|
root_asm.model_copy(
|
|
update={"resourceId": "res-weld", "start": _dt(9, 30), "end": _dt(10, 30)}
|
|
),
|
|
)
|
|
}
|
|
)
|
|
return {
|
|
"problem": problem,
|
|
"world": world,
|
|
"duplicate_orphan": duplicate_orphan,
|
|
"reversed_routing": reversed_routing,
|
|
"overlap_wrong_resource": overlap_wrong_resource,
|
|
}
|
|
|
|
|
|
|
|
|
|
def _codes(report) -> set[str]:
|
|
return {issue.code for issue in report.hardViolations}
|
|
|
|
|
|
def test_valid_skill_fixture_passes_and_contract_round_trips(valid_skill_fixture):
|
|
problem, solution, world = valid_skill_fixture
|
|
world_before = {
|
|
"sourceRevision": world["sourceRevision"],
|
|
"problemHash": world["problemHash"],
|
|
"sourceFingerprints": dict(world["sourceFingerprints"]),
|
|
}
|
|
|
|
report = validate_solution(problem, solution, world=world)
|
|
|
|
assert report.valid is True
|
|
assert report.hardViolations == ()
|
|
assert len(report.operationIdentityHash) == 64
|
|
assert report.recomputedKpi["scheduledActivityCount"] == 3
|
|
assert report.recomputedKpi["requirementCount"] == 4
|
|
assert problem.businessDate == BUSINESS_DATE
|
|
assert world_before == {
|
|
"sourceRevision": world["sourceRevision"],
|
|
"problemHash": world["problemHash"],
|
|
"sourceFingerprints": dict(world["sourceFingerprints"]),
|
|
}
|
|
|
|
dumped = problem.model_dump(mode="json")
|
|
restored = SchedulingProblemV2.model_validate(dumped)
|
|
assert restored == problem
|
|
assert dumped["requirements"][1]["requirementId"] == "req-child-make"
|
|
assert dumped["supplyEvents"][0]["supplyEventId"] == "supply-buy"
|
|
|
|
alias_report = validate_scheduling_solution(problem, solution, world=world)
|
|
assert alias_report == report
|
|
|
|
|
|
def test_operation_identity_hash_is_order_independent(valid_skill_fixture):
|
|
problem, solution, world = valid_skill_fixture
|
|
first = validate_solution(problem, solution, world=world)
|
|
reordered = solution.model_copy(update={"activities": tuple(reversed(solution.activities))})
|
|
second = validate_solution(problem, reordered, world=world)
|
|
|
|
assert first.valid and second.valid
|
|
assert first.operationIdentityHash == second.operationIdentityHash
|
|
|
|
|
|
def test_duplicate_and_orphan_skill_activities_are_rejected(malicious_skill_fixture):
|
|
report = validate_solution(
|
|
malicious_skill_fixture["problem"],
|
|
malicious_skill_fixture["duplicate_orphan"],
|
|
world=malicious_skill_fixture["world"],
|
|
)
|
|
|
|
assert report.valid is False
|
|
assert {"DUPLICATE_ACTIVITY", "ORPHAN_ACTIVITY"} <= _codes(report)
|
|
|
|
|
|
def test_wrong_routing_order_is_rejected(malicious_skill_fixture):
|
|
report = validate_solution(
|
|
malicious_skill_fixture["problem"],
|
|
malicious_skill_fixture["reversed_routing"],
|
|
world=malicious_skill_fixture["world"],
|
|
)
|
|
|
|
assert report.valid is False
|
|
assert {"ROUTING_ORDER_VIOLATION", "ROUTING_PRECEDENCE_VIOLATION"} <= _codes(report)
|
|
|
|
|
|
def test_overlap_wrong_resource_and_capability_are_rejected(malicious_skill_fixture):
|
|
report = validate_solution(
|
|
malicious_skill_fixture["problem"],
|
|
malicious_skill_fixture["overlap_wrong_resource"],
|
|
world=malicious_skill_fixture["world"],
|
|
)
|
|
|
|
assert report.valid is False
|
|
assert {
|
|
"RESOURCE_CAPACITY_OVERLAP",
|
|
"INELIGIBLE_RESOURCE",
|
|
"RESOURCE_CAPABILITY_MISMATCH",
|
|
"PARENT_STARTS_BEFORE_CHILD_COMPLETE",
|
|
} <= _codes(report)
|
|
|
|
|
|
def test_calendar_and_maintenance_violations_are_rejected(valid_skill_fixture):
|
|
problem, solution, world = valid_skill_fixture
|
|
child_cut, child_weld, root_asm = solution.activities
|
|
outside_calendar = solution.model_copy(
|
|
update={
|
|
"activities": (
|
|
child_cut,
|
|
child_weld,
|
|
root_asm.model_copy(update={"start": _dt(7), "end": _dt(8)}),
|
|
)
|
|
}
|
|
)
|
|
calendar_report = validate_solution(problem, outside_calendar, world=world)
|
|
assert {"RESOURCE_CALENDAR_VIOLATION", "ACTIVITY_OUTSIDE_HORIZON"} <= _codes(calendar_report)
|
|
|
|
maintenance = TimeInterval(start=_dt(10, 15), end=_dt(10, 45), label="planned-maintenance")
|
|
changed_resources = tuple(
|
|
resource.model_copy(update={"maintenanceIntervals": (maintenance,)})
|
|
if resource.resourceId == "res-asm"
|
|
else resource
|
|
for resource in problem.resources
|
|
)
|
|
maintenance_problem = problem.model_copy(update={"resources": changed_resources})
|
|
maintenance_solution = _rebind(maintenance_problem, solution)
|
|
maintenance_world = MappingProxyType(
|
|
{
|
|
"sourceRevision": SOURCE_REVISION,
|
|
"problemHash": scheduling_problem_hash(maintenance_problem),
|
|
"sourceFingerprints": world["sourceFingerprints"],
|
|
}
|
|
)
|
|
maintenance_report = validate_solution(
|
|
maintenance_problem,
|
|
maintenance_solution,
|
|
world=maintenance_world,
|
|
)
|
|
assert "RESOURCE_MAINTENANCE_VIOLATION" in _codes(maintenance_report)
|
|
|
|
|
|
def test_material_readiness_and_parent_start_gate_are_rejected(valid_skill_fixture):
|
|
problem, solution, world = valid_skill_fixture
|
|
late_events = tuple(
|
|
event.model_copy(update={"availableAt": _dt(10, 30)})
|
|
if event.supplyEventId == "supply-buy"
|
|
else event.model_copy(update={"availableAt": _dt(10, 45)})
|
|
for event in problem.supplyEvents
|
|
)
|
|
late_problem = problem.model_copy(update={"supplyEvents": late_events})
|
|
late_decisions = tuple(
|
|
decision.model_copy(
|
|
update={
|
|
"availableAt": _dt(10, 30)
|
|
if decision.supplyEventId == "supply-buy"
|
|
else _dt(10, 45)
|
|
}
|
|
)
|
|
for decision in solution.supplyDecisions
|
|
)
|
|
late_solution = _rebind(
|
|
late_problem,
|
|
solution.model_copy(update={"supplyDecisions": late_decisions}),
|
|
)
|
|
late_world = MappingProxyType(
|
|
{
|
|
"sourceRevision": SOURCE_REVISION,
|
|
"problemHash": scheduling_problem_hash(late_problem),
|
|
"sourceFingerprints": world["sourceFingerprints"],
|
|
}
|
|
)
|
|
|
|
report = validate_solution(late_problem, late_solution, world=late_world)
|
|
|
|
assert report.valid is False
|
|
assert {"MATERIAL_NOT_READY", "PARENT_STARTS_BEFORE_SUPPLY_READY"} <= _codes(report)
|
|
material_refs = {
|
|
ref
|
|
for issue in report.hardViolations
|
|
if issue.code == "MATERIAL_NOT_READY"
|
|
for ref in issue.entityRefs
|
|
}
|
|
assert {"decision-buy", "decision-sub"} <= material_refs
|
|
|
|
|
|
def test_supply_and_pegging_quantity_conservation_is_enforced(valid_skill_fixture):
|
|
problem, solution, world = valid_skill_fixture
|
|
short_buy = solution.supplyDecisions[0].model_copy(update={"quantity": 1.0})
|
|
short_peg = solution.pegging[1].model_copy(update={"quantity": 1.0})
|
|
malicious = solution.model_copy(
|
|
update={
|
|
"supplyDecisions": (short_buy, solution.supplyDecisions[1]),
|
|
"pegging": (solution.pegging[0], short_peg, solution.pegging[2]),
|
|
}
|
|
)
|
|
|
|
report = validate_solution(problem, malicious, world=world)
|
|
|
|
assert report.valid is False
|
|
assert {"SUPPLY_QUANTITY_NOT_CONSERVED", "PEGGING_QUANTITY_NOT_CONSERVED"} <= _codes(report)
|
|
|
|
|
|
def test_identity_provenance_and_world_hash_drift_are_rejected(valid_skill_fixture):
|
|
problem, solution, world = valid_skill_fixture
|
|
tampered_activity = solution.activities[0].model_copy(update={"activityIdentity": "b" * 64})
|
|
drifted_fingerprint = SourceFingerprint(
|
|
sourceId=SOURCE_ID,
|
|
revision="stale-r0",
|
|
sha256="c" * 64,
|
|
)
|
|
drifted_provenance = solution.provenance.model_copy(
|
|
update={
|
|
"problemHash": "d" * 64,
|
|
"sourceRevision": "stale-r0",
|
|
"sourceFingerprints": (drifted_fingerprint,),
|
|
}
|
|
)
|
|
malicious = solution.model_copy(
|
|
update={
|
|
"activities": (tampered_activity, *solution.activities[1:]),
|
|
"provenance": drifted_provenance,
|
|
}
|
|
)
|
|
stale_world = MappingProxyType(
|
|
{
|
|
"sourceRevision": "stale-world",
|
|
"problemHash": "e" * 64,
|
|
"sourceFingerprints": MappingProxyType(
|
|
{SOURCE_ID: ("stale-world", "f" * 64)}
|
|
),
|
|
}
|
|
)
|
|
before = {
|
|
"sourceRevision": stale_world["sourceRevision"],
|
|
"problemHash": stale_world["problemHash"],
|
|
"sourceFingerprints": dict(stale_world["sourceFingerprints"]),
|
|
}
|
|
|
|
report = validate_solution(problem, malicious, world=stale_world)
|
|
|
|
assert report.valid is False
|
|
assert {
|
|
"ACTIVITY_IDENTITY_DRIFT",
|
|
"PROBLEM_HASH_DRIFT",
|
|
"SOURCE_REVISION_DRIFT",
|
|
"SOURCE_FINGERPRINT_DRIFT",
|
|
"WORLD_SOURCE_REVISION_DRIFT",
|
|
"WORLD_PROBLEM_HASH_DRIFT",
|
|
"WORLD_SOURCE_FINGERPRINT_DRIFT",
|
|
} <= _codes(report)
|
|
assert before == {
|
|
"sourceRevision": stale_world["sourceRevision"],
|
|
"problemHash": stale_world["problemHash"],
|
|
"sourceFingerprints": dict(stale_world["sourceFingerprints"]),
|
|
}
|
|
|
|
|
|
def test_template_make_routing_is_fail_closed(valid_skill_fixture):
|
|
problem, solution, world = valid_skill_fixture
|
|
changed_requirements = tuple(
|
|
requirement.model_copy(update={"routingStatus": RoutingStatus.TEMPLATE})
|
|
if requirement.requirementId == "req-child-make"
|
|
else requirement
|
|
for requirement in problem.requirements
|
|
)
|
|
template_problem = problem.model_copy(update={"requirements": changed_requirements})
|
|
template_solution = _rebind(template_problem, solution)
|
|
template_world = MappingProxyType(
|
|
{
|
|
"sourceRevision": SOURCE_REVISION,
|
|
"problemHash": scheduling_problem_hash(template_problem),
|
|
"sourceFingerprints": world["sourceFingerprints"],
|
|
}
|
|
)
|
|
|
|
report = validate_solution(template_problem, template_solution, world=template_world)
|
|
|
|
assert report.valid is False
|
|
assert "ROUTING_NOT_CONFIRMED" in _codes(report)
|
|
|
|
|
|
|
|
|
|
def _w1_multi_parent_world() -> dict:
|
|
return {
|
|
"materials": [
|
|
{"id": 1, "code": "FG", "name": "Finished", "type": "FINISHED_PRODUCT", "unit": "PCS"},
|
|
{"id": 2, "code": "SFA", "name": "Semi A", "type": "SEMI_FINISHED", "unit": "PCS"},
|
|
{"id": 3, "code": "SFB", "name": "Semi B", "type": "SEMI_FINISHED", "unit": "PCS"},
|
|
{"id": 4, "code": "RM", "name": "Shared raw", "type": "RAW_MATERIAL", "unit": "KG", "stock": 5},
|
|
],
|
|
"flexMaterials": [],
|
|
"boms": [
|
|
{"id": 1, "productId": 1, "isDefault": True, "status": "ACTIVE"},
|
|
{"id": 2, "productId": 2, "isDefault": True, "status": "ACTIVE"},
|
|
{"id": 3, "productId": 3, "isDefault": True, "status": "ACTIVE"},
|
|
],
|
|
"bomItems": [
|
|
{"id": 1, "bomId": 1, "materialId": 2, "quantity": 1},
|
|
{"id": 2, "bomId": 1, "materialId": 3, "quantity": 1},
|
|
{"id": 3, "bomId": 2, "materialId": 4, "quantity": 2},
|
|
{"id": 4, "bomId": 3, "materialId": 4, "quantity": 3},
|
|
],
|
|
"flexBom": [],
|
|
"routings": [
|
|
{"id": 1, "productId": 1, "isDefault": True, "status": "ACTIVE"},
|
|
{"id": 2, "productId": 2, "isDefault": True, "status": "ACTIVE"},
|
|
{"id": 3, "productId": 3, "isDefault": True, "status": "ACTIVE"},
|
|
],
|
|
"routingSteps": [
|
|
{"id": 1, "routingId": 1, "operationId": 1, "sequenceNo": 1, "isExternal": False},
|
|
{"id": 2, "routingId": 2, "operationId": 2, "sequenceNo": 1, "isExternal": False},
|
|
{"id": 3, "routingId": 3, "operationId": 3, "sequenceNo": 1, "isExternal": False},
|
|
],
|
|
"routingOperations": [],
|
|
"operations": [
|
|
{"id": 1, "code": "ASM", "name": "Assembly"},
|
|
{"id": 2, "code": "WELD", "name": "Weld A"},
|
|
{"id": 3, "code": "CUT", "name": "Cut B"},
|
|
],
|
|
"flexRoutings": [],
|
|
"workstations": [],
|
|
"workstationOperations": [],
|
|
"equipment": [],
|
|
"flexEquipment": [
|
|
{"id": 1, "code": "EQ-ASM", "status": "RUNNING", "capabilities": ["ASM"]},
|
|
{"id": 2, "code": "EQ-WELD", "status": "RUNNING", "capabilities": ["WELD"]},
|
|
{"id": 3, "code": "EQ-CUT", "status": "RUNNING", "capabilities": ["CUT"]},
|
|
],
|
|
"salesOrders": [
|
|
{
|
|
"id": 1,
|
|
"orderNo": "SO-W1-MULTI",
|
|
"status": "APPROVED",
|
|
"deliveryDate": "2026-08-05",
|
|
"items": [
|
|
{"id": 1, "productId": 1, "productCode": "FG", "quantity": 1, "unit": "PCS"}
|
|
],
|
|
}
|
|
],
|
|
"purchaseOrders": [],
|
|
"outsourceOrders": [],
|
|
}
|
|
|
|
|
|
def _w1_payload_to_v2(payload: dict) -> tuple[SchedulingProblemV2, SchedulingSolutionV2, MappingProxyType]:
|
|
source_hash = payload["sourceHash"]
|
|
source_id = "w1-closed-loop"
|
|
source = {
|
|
"sourceRef": source_id,
|
|
"sourceRevision": source_hash,
|
|
"sourceHash": source_hash,
|
|
}
|
|
fingerprint = SourceFingerprint(sourceId=source_id, revision=source_hash, sha256=source_hash)
|
|
rows = {row["requirement_id"]: row for row in payload["requirements"]}
|
|
activity_id_by_requirement = {
|
|
requirement_id: f"w1-act:{requirement_id}"
|
|
for requirement_id, row in rows.items()
|
|
if row["sourcing_type"] == "MAKE"
|
|
}
|
|
consumer_ids_by_child = {
|
|
requirement_id: tuple(
|
|
activity_id_by_requirement[parent_id]
|
|
for parent_id in row["parent_requirement_ids"]
|
|
if parent_id in activity_id_by_requirement
|
|
)
|
|
for requirement_id, row in rows.items()
|
|
}
|
|
|
|
requirement_models = []
|
|
activity_models = []
|
|
for requirement_id, row in rows.items():
|
|
parent_ids = tuple(row["parent_requirement_ids"])
|
|
consumer_ids = consumer_ids_by_child[requirement_id]
|
|
activity_ids = (
|
|
(activity_id_by_requirement[requirement_id],)
|
|
if requirement_id in activity_id_by_requirement
|
|
else ()
|
|
)
|
|
routing_status = {
|
|
"READY": RoutingStatus.CONFIRMED,
|
|
"TEMPLATE": RoutingStatus.TEMPLATE,
|
|
"MISSING": RoutingStatus.MISSING,
|
|
"NOT_APPLICABLE": RoutingStatus.CONFIRMED,
|
|
}[row["routing_status"]]
|
|
requirement_models.append(
|
|
Requirement(
|
|
requirementId=requirement_id,
|
|
orderId=str(row["sales_order_id"]),
|
|
orderLineId=str(row["sales_order_line_id"]),
|
|
materialId=str(row["material_key"]),
|
|
quantity=row["quantity"],
|
|
sourcingType=SourcingType(row["sourcing_type"]),
|
|
requiredAt=datetime.fromisoformat(row["required_at"]).replace(tzinfo=timezone.utc),
|
|
parentRequirementId=parent_ids[0] if len(parent_ids) == 1 else None,
|
|
parentRequirementIds=parent_ids,
|
|
requiredByActivityId=consumer_ids[0] if len(consumer_ids) == 1 else None,
|
|
requiredByActivityIds=consumer_ids,
|
|
routingStatus=routing_status,
|
|
routingId=f"w1-route:{requirement_id}" if activity_ids else None,
|
|
routingVersion=source_hash if activity_ids else None,
|
|
activityIds=activity_ids,
|
|
**source,
|
|
)
|
|
)
|
|
|
|
make_rows = sorted(
|
|
(row for row in rows.values() if row["sourcing_type"] == "MAKE"),
|
|
key=lambda row: (-row["bom_depth"], row["requirement_id"]),
|
|
)
|
|
scheduled_models = []
|
|
for index, row in enumerate(make_rows):
|
|
requirement_id = row["requirement_id"]
|
|
start = _dt(8 + index)
|
|
end = _dt(9 + index)
|
|
raw = {
|
|
"activityId": activity_id_by_requirement[requirement_id],
|
|
"requirementId": requirement_id,
|
|
"routingId": f"w1-route:{requirement_id}",
|
|
"routingVersion": source_hash,
|
|
"operationId": f"w1-op:{requirement_id}",
|
|
"operationCode": "GENERAL",
|
|
"sequence": 10,
|
|
"durationMin": 60.0,
|
|
"predecessorActivityIds": (),
|
|
"inputRequirementIds": tuple(row["child_requirement_ids"]),
|
|
"eligibleResourceIds": ("w1-equipment",),
|
|
"requiredCapabilities": ("GENERAL",),
|
|
"requiredResourceUnits": 1.0,
|
|
"continuous": False,
|
|
**source,
|
|
}
|
|
definition = OperationActivity(
|
|
activityIdentity=operation_activity_identity(raw),
|
|
**raw,
|
|
)
|
|
activity_models.append(definition)
|
|
scheduled_models.append(
|
|
_scheduled(definition, start=start, end=end, resource_id="w1-equipment")
|
|
)
|
|
|
|
calendar = (TimeInterval(start=_dt(8), end=_dt(18), label="W1 mapped shift"),)
|
|
resources = (
|
|
Resource(
|
|
resourceId="w1-factory",
|
|
code="W1-F",
|
|
kind=ResourceKind.FACTORY,
|
|
calendarIntervals=calendar,
|
|
cumulativeCapacity=1,
|
|
**source,
|
|
),
|
|
Resource(
|
|
resourceId="w1-equipment",
|
|
code="W1-EQ",
|
|
kind=ResourceKind.EQUIPMENT,
|
|
parentResourceId="w1-factory",
|
|
capabilities=("GENERAL",),
|
|
calendarIntervals=calendar,
|
|
cumulativeCapacity=1,
|
|
**source,
|
|
),
|
|
)
|
|
|
|
supply_models = []
|
|
supply_decisions = []
|
|
supply_event_id_by_requirement = {}
|
|
for event in payload["supplyEvents"]:
|
|
if event["source"] == "MAKE":
|
|
continue
|
|
if not event["available_at"]:
|
|
continue
|
|
available_at = datetime.fromisoformat(event["available_at"]).replace(tzinfo=timezone.utc)
|
|
for allocation in event["allocations"]:
|
|
requirement_id = allocation["requirement_id"]
|
|
row = rows[requirement_id]
|
|
mapped_event_id = f"w1:{event['event_id']}:{requirement_id}"
|
|
supply_event_id_by_requirement[requirement_id] = mapped_event_id
|
|
supply_models.append(
|
|
SupplyEvent(
|
|
supplyEventId=mapped_event_id,
|
|
requirementId=requirement_id,
|
|
materialId=str(row["material_key"]),
|
|
source=SupplySource(event["source"]),
|
|
quantity=allocation["quantity"],
|
|
availableAt=available_at,
|
|
**source,
|
|
)
|
|
)
|
|
for peg in row["pegging_refs"]:
|
|
parent_id = peg["parent_requirement_id"]
|
|
supply_decisions.append(
|
|
SupplyDecision(
|
|
decisionId=f"w1-decision:{requirement_id}:{parent_id}",
|
|
supplyEventId=mapped_event_id,
|
|
requirementId=requirement_id,
|
|
materialId=str(row["material_key"]),
|
|
source=SupplySource(event["source"]),
|
|
quantity=peg["quantity"],
|
|
availableAt=available_at,
|
|
consumedByActivityId=activity_id_by_requirement[parent_id],
|
|
)
|
|
)
|
|
|
|
pegging = tuple(
|
|
PeggingAllocation(
|
|
peggingId=f"w1-peg:{row['requirement_id']}:{peg['parent_requirement_id']}",
|
|
childRequirementId=row["requirement_id"],
|
|
parentRequirementId=peg["parent_requirement_id"],
|
|
quantity=peg["quantity"],
|
|
)
|
|
for row in rows.values()
|
|
for peg in row["pegging_refs"]
|
|
)
|
|
problem = SchedulingProblemV2(
|
|
problemId=payload["problemId"],
|
|
businessDate=date.fromisoformat(payload["businessDate"]),
|
|
planningStart=_dt(8),
|
|
planningEnd=_dt(18),
|
|
requirements=tuple(requirement_models),
|
|
supplyEvents=tuple(supply_models),
|
|
activities=tuple(activity_models),
|
|
resources=resources,
|
|
sourceRevision=source_hash,
|
|
sourceFingerprints=(fingerprint,),
|
|
metadata={"adapter": "explicit-w1-to-v2-test", "w1SchemaVersion": payload["schemaVersion"]},
|
|
)
|
|
solution = SchedulingSolutionV2(
|
|
problemId=problem.problemId,
|
|
solveStatus=SolveStatus.FEASIBLE,
|
|
activities=tuple(scheduled_models),
|
|
supplyDecisions=tuple(supply_decisions),
|
|
pegging=pegging,
|
|
provenance=SolutionProvenance(
|
|
runId="w1-map-valid",
|
|
solverId="w1-map-fixture",
|
|
solverVersion="2.0",
|
|
generatedAt=_dt(14),
|
|
businessDate=BUSINESS_DATE,
|
|
problemHash=scheduling_problem_hash(problem),
|
|
sourceRevision=source_hash,
|
|
sourceFingerprints=(fingerprint,),
|
|
),
|
|
)
|
|
world = MappingProxyType(
|
|
{
|
|
"sourceRevision": source_hash,
|
|
"problemHash": scheduling_problem_hash(problem),
|
|
"sourceFingerprints": MappingProxyType(
|
|
{source_id: (source_hash, source_hash)}
|
|
),
|
|
}
|
|
)
|
|
return problem, solution, world
|
|
|
|
|
|
def test_w1_snake_case_payload_maps_to_v2_and_preserves_all_parents():
|
|
w1_problem = build_closed_loop_problem(
|
|
_w1_multi_parent_world(),
|
|
business_date="2026-08-02",
|
|
)
|
|
payload = w1_problem.to_dict()
|
|
shared = next(
|
|
row
|
|
for row in payload["requirements"]
|
|
if row["material_code"] == "RM"
|
|
)
|
|
assert len(shared["parent_requirement_ids"]) == 2
|
|
assert sum(peg["quantity"] for peg in shared["pegging_refs"]) == shared["quantity"]
|
|
|
|
problem, solution, world = _w1_payload_to_v2(payload)
|
|
mapped = next(row for row in problem.requirements if row.requirementId == shared["requirement_id"])
|
|
report = validate_solution(problem, solution, world=world)
|
|
|
|
assert mapped.parentRequirementId is None
|
|
assert set(mapped.parentRequirementIds) == set(shared["parent_requirement_ids"])
|
|
assert len(mapped.requiredByActivityIds) == 2
|
|
assert report.valid is True, report.hardViolations
|
|
|
|
|
|
def test_multi_parent_pegging_checks_every_parent_not_only_compatibility_parent():
|
|
payload = build_closed_loop_problem(
|
|
_w1_multi_parent_world(),
|
|
business_date="2026-08-02",
|
|
).to_dict()
|
|
problem, solution, world = _w1_payload_to_v2(payload)
|
|
shared = next(row for row in problem.requirements if len(row.parentRequirementIds) == 2)
|
|
shared_pegs = [peg for peg in solution.pegging if peg.childRequirementId == shared.requirementId]
|
|
assert len(shared_pegs) == 2
|
|
|
|
first = shared_pegs[0].model_copy(update={"quantity": shared.quantity})
|
|
malicious = solution.model_copy(
|
|
update={
|
|
"pegging": tuple(
|
|
peg
|
|
for peg in solution.pegging
|
|
if peg.childRequirementId != shared.requirementId
|
|
) + (first,)
|
|
}
|
|
)
|
|
report = validate_solution(problem, malicious, world=world)
|
|
|
|
assert report.valid is False
|
|
assert "PEGGING_PARENT_MISSING" in _codes(report)
|
|
assert "PEGGING_QUANTITY_NOT_CONSERVED" not in _codes(report)
|