aps-agent/tests/golden/test_cp_c3_calendar.py

507 lines
18 KiB
Python
Raw Permalink Normal View History

from __future__ import annotations
from datetime import date, timedelta
from itertools import combinations
import pytest
import server.engines.cp_engine as cp_engine
from server.engines.base import EngineParams
from server.engines.cp_engine import CpSatEngine, optimize_line_assignment
from server.engines.queries import find_routing_steps, find_workstation_for_operation
from server.engines.solver_process import SolverProcessError
from server.aps_domain.constraints import hard_blocking_conflicts
from server.state.seed import build_demo_world
from server.timeutil import add_minutes, fmt_dt, parse_dt
_C3_SCHEMA = "cp-calendar-segmented.v1"
_CALENDAR_MODE = "calendar-boundary-only"
_CONTINUOUS_MODE = "continuous"
_SLOT_CONTRACT = {
"segments",
"processingMinutes",
"elapsedSpanMinutes",
"pauseMinutes",
"segmentCount",
"calendarCompliant",
"calendarMode",
}
def _calendar_date(world: dict, line_id: int, *, working: bool) -> str:
return min(
row["date"]
for row in world["shiftCalendar"]
if row["lineId"] == line_id and bool(row["isWorking"]) is working
)
def _single_line_world(
*,
quantity: int,
orders: int = 1,
start_date: str | None = None,
day_shift_only: bool = False,
) -> tuple[dict, str]:
world = build_demo_world()
resolved_start = start_date or _calendar_date(world, 1, working=True)
selected = [
row for row in world["salesOrders"]
if row["items"][0]["productId"] == 1
][:orders]
assert len(selected) == orders
for order in selected:
order["deliveryDate"] = resolved_start
order["items"][0]["quantity"] = quantity
world["salesOrders"] = selected
world["lineProducts"] = [
row for row in world["lineProducts"]
if row["productId"] == 1 and row["lineId"] == 1
]
routing_id = next(
row["id"] for row in world["routings"]
if row["productId"] == 1 and row["isDefault"]
)
world["routingSteps"] = [
row for row in world["routingSteps"]
if row["routingId"] == routing_id and row["sequenceNo"] == 1
]
if day_shift_only:
for row in world["shiftCalendar"]:
if row["lineId"] == 1 and row["shiftId"] != 1:
row["isWorking"] = False
world["scheduleParams"]["freezeWindowHours"] = 0
world["scheduleParams"]["deliveryBufferRatio"] = 1.0
return world, resolved_start
def _params(start_date: str, *, cumulative: bool = False) -> EngineParams:
return EngineParams(
orderIds=[],
engineType="CP",
strategyTemplate="COMPREHENSIVE",
planningHorizonDays=3,
startDate=start_date,
timeLimitSeconds=3,
freezeWindowHours=0,
deliveryBufferRatio=1.0,
constraints={
"capacity": False,
"changeover": False,
"personnel": cumulative,
"tooling": cumulative,
},
)
def _diagnose(
world: dict,
start_date: str,
*,
relaxed_constraint_id: str | None = None,
cumulative: bool = False,
) -> dict:
params = _params(start_date, cumulative=cumulative)
entries, _, _ = CpSatEngine().collect_and_order(world, params)
_, meta = optimize_line_assignment(
world,
entries,
params,
pipeline_label=(
"C3-CALENDAR-BASELINE"
if relaxed_constraint_id is None
else "C3-CALENDAR-REMOVAL"
),
relaxed_constraint_ids=(
[] if relaxed_constraint_id is None else [relaxed_constraint_id]
),
diagnostic_mode=True,
)
assert meta["status"] in {"OPTIMAL", "FEASIBLE"}
return meta
def _active_slots(meta: dict) -> list[dict]:
return [
slot for slot in meta["operationSlots"]
if not slot.get("isFrozen") and slot.get("lineId") is not None
]
def _assert_slot_contract(slot: dict) -> list[dict]:
assert _SLOT_CONTRACT <= set(slot)
segments = slot["segments"]
assert isinstance(segments, list) and segments
assert slot["segmentCount"] == len(segments)
assert all(
isinstance(segment.get("startMin"), int)
and isinstance(segment.get("endMin"), int)
and segment["startMin"] < segment["endMin"]
for segment in segments
)
assert segments == sorted(segments, key=lambda segment: segment["startMin"])
processing = sum(segment["endMin"] - segment["startMin"] for segment in segments)
elapsed = segments[-1]["endMin"] - segments[0]["startMin"]
assert slot["processingMinutes"] == processing
assert slot["elapsedSpanMinutes"] == elapsed
assert slot["pauseMinutes"] == elapsed - processing
assert slot["startMin"] == segments[0]["startMin"]
assert slot["endMin"] == segments[-1]["endMin"]
return segments
def _assert_no_overlap(intervals: list[tuple[int, int]]) -> None:
for left, right in combinations(intervals, 2):
assert left[1] <= right[0] or right[1] <= left[0], (left, right)
def _counter():
values: dict[str, int] = {}
def next_id(kind: str) -> int:
values[kind] = values.get(kind, 0) + 1
return values[kind]
return next_id
def test_short_operation_uses_one_compliant_calendar_segment() -> None:
world, start_date = _single_line_world(quantity=60)
meta = _diagnose(world, start_date)
c3 = meta["c3Calendar"]
assert c3["schemaVersion"] == _C3_SCHEMA
assert meta["constraintInstanceCounts"]["C3_calendar"] > 0
assert "C3_calendar" in meta["activeAssumptionConstraints"]
slot = _active_slots(meta)[0]
_assert_slot_contract(slot)
assert slot["processingMinutes"] == 60
assert slot["elapsedSpanMinutes"] == 60
assert slot["pauseMinutes"] == 0
assert slot["segmentCount"] == 1
assert slot["calendarCompliant"] is True
assert slot["calendarMode"] == _CALENDAR_MODE
def test_operation_pauses_only_at_calendar_break_boundary() -> None:
world, start_date = _single_line_world(
quantity=500,
day_shift_only=True,
)
meta = _diagnose(world, start_date)
slot = _active_slots(meta)[0]
segments = _assert_slot_contract(slot)
assert slot["processingMinutes"] == 280
assert slot["segmentCount"] == 2
assert slot["pauseMinutes"] == 60
assert segments[0]["endMin"] <= 240
assert segments[1]["startMin"] >= 300
assert slot["calendarCompliant"] is True
assert slot["calendarMode"] == _CALENDAR_MODE
def test_nonworking_start_day_defers_all_processing_to_next_working_window() -> None:
probe = build_demo_world()
start_date = _calendar_date(probe, 1, working=False)
next_working_date = min(
row["date"]
for row in probe["shiftCalendar"]
if row["lineId"] == 1
and row["isWorking"]
and row["date"] > start_date
)
world, _ = _single_line_world(quantity=60, start_date=start_date)
meta = _diagnose(world, start_date)
slot = _active_slots(meta)[0]
segments = _assert_slot_contract(slot)
next_working_offset = (
date.fromisoformat(next_working_date) - date.fromisoformat(start_date)
).days * 24 * 60
assert all(segment["startMin"] >= next_working_offset for segment in segments)
assert slot["calendarCompliant"] is True
assert slot["calendarMode"] == _CALENDAR_MODE
def test_c3_removal_restores_one_continuous_interval() -> None:
world, start_date = _single_line_world(
quantity=500,
day_shift_only=True,
)
baseline = _diagnose(world, start_date)
relaxed = _diagnose(
world,
start_date,
relaxed_constraint_id="C3_calendar",
)
baseline_slot = _active_slots(baseline)[0]
relaxed_slot = _active_slots(relaxed)[0]
_assert_slot_contract(baseline_slot)
_assert_slot_contract(relaxed_slot)
assert baseline_slot["segmentCount"] == 2
assert baseline_slot["pauseMinutes"] == 60
assert relaxed["relaxedConstraintIds"] == ["C3_calendar"]
assert baseline["c3Calendar"]["selectedMode"] == _CALENDAR_MODE
assert relaxed["c3Calendar"]["selectedMode"] == _CONTINUOUS_MODE
assert relaxed_slot["calendarMode"] == _CONTINUOUS_MODE
assert relaxed_slot["segmentCount"] == 1
assert relaxed_slot["pauseMinutes"] == 0
assert relaxed_slot["elapsedSpanMinutes"] == relaxed_slot["processingMinutes"] == 280
assert relaxed_slot["calendarCompliant"] is False
def _shared_resource_world() -> tuple[dict, str]:
world = build_demo_world()
start_date = _calendar_date(world, 1, working=True)
selected = [
row for row in world["salesOrders"]
if row["items"][0]["productId"] == 1
][:2]
selected += [
row for row in world["salesOrders"]
if row["items"][0]["productId"] == 3
][:1]
assert len(selected) == 3
for order in selected:
order["deliveryDate"] = start_date
order["items"][0]["quantity"] = 500
world["salesOrders"] = selected
world["lineProducts"] = [
row for row in world["lineProducts"]
if (row["productId"], row["lineId"]) in {(1, 1), (3, 3)}
]
routing_ids = {
row["id"] for row in world["routings"]
if row["productId"] in {1, 3} and row["isDefault"]
}
world["routingSteps"] = [
row for row in world["routingSteps"]
if row["routingId"] in routing_ids and row["sequenceNo"] == 1
]
for row in world["shiftCalendar"]:
if row["lineId"] in {1, 3} and row["shiftId"] != 1:
row["isWorking"] = False
for workstation in world["workstations"]:
if workstation["code"] in {"WS001", "WS010"}:
workstation["teamId"] = 1
workstation["toolingId"] = 1
team = next(row for row in world["teams"] if row["id"] == 1)
team["memberCount"] = 1
team["availableCount"] = 1
world["toolings"] = [{
"id": 1,
"code": "TOOL-1",
"name": "Shared tool",
"availableCount": 1,
}]
world["scheduleParams"]["freezeWindowHours"] = 0
world["scheduleParams"]["deliveryBufferRatio"] = 1.0
return world, start_date
def test_c2_and_c12_apply_to_processing_segments_not_elapsed_spans() -> None:
world, start_date = _shared_resource_world()
meta = _diagnose(world, start_date, cumulative=True)
slots = _active_slots(meta)
assert len(slots) == 3
by_workstation: dict[int, list[tuple[int, int]]] = {}
by_team: dict[int, list[tuple[int, int]]] = {}
by_tooling: dict[int, list[tuple[int, int]]] = {}
workstation_by_id = {row["id"]: row for row in world["workstations"]}
for slot in slots:
segments = _assert_slot_contract(slot)
intervals = [(segment["startMin"], segment["endMin"]) for segment in segments]
workstation_id = int(slot["workstationId"])
by_workstation.setdefault(workstation_id, []).extend(intervals)
workstation = workstation_by_id[workstation_id]
if workstation.get("teamId") is not None:
by_team.setdefault(int(workstation["teamId"]), []).extend(intervals)
if workstation.get("toolingId") is not None:
by_tooling.setdefault(int(workstation["toolingId"]), []).extend(intervals)
for intervals in by_workstation.values():
_assert_no_overlap(intervals)
_assert_no_overlap(by_team[1])
_assert_no_overlap(by_tooling[1])
cumulative = meta["cumulative"]
team_resource = next(
row for row in cumulative["resources"]
if row["kind"] == "team" and row["id"] == 1
)
tooling_resource = next(
row for row in cumulative["resources"]
if row["kind"] == "tooling" and row["id"] == 1
)
assert team_resource["peakConcurrent"] == 1
assert tooling_resource["peakConcurrent"] == 1
def test_c3_metadata_reports_schema_and_model_limit() -> None:
world, start_date = _single_line_world(quantity=60)
meta = _diagnose(world, start_date)
c3 = meta["c3Calendar"]
assert c3["schemaVersion"] == _C3_SCHEMA
assert c3["calendarMode"] == _CALENDAR_MODE
assert c3["modelSegmentLimit"] == 50_000
assert 0 < c3["modelSegmentCount"] <= c3["modelSegmentLimit"]
assert c3["calendarCompliant"] is True
def test_single_logical_operation_does_not_activate_c2() -> None:
world, start_date = _single_line_world(quantity=60)
meta = _diagnose(world, start_date)
assert meta["constraintInstanceCounts"]["C2_no_overlap"] == 0
assert "C2_no_overlap" not in meta["activeAssumptionConstraints"]
def test_zero_objective_hint_does_not_fix_across_frozen_obstacle() -> None:
world, start_date = _single_line_world(quantity=60)
world["salesOrders"][0]["deliveryDate"] = (
date.fromisoformat(start_date) + timedelta(days=10)
).isoformat()
step = find_routing_steps(world, 1)[0]
workstation = find_workstation_for_operation(world, 1, step["operationId"])
assert workstation is not None
anchor = parse_dt(start_date + " 08:00")
world["workOrders"].append({
"id": 99001,
"orderNo": "WO-FROZEN-CROSS-FREEZE",
"lineId": 1,
"workstationId": workstation["id"],
"plannedStartTime": fmt_dt(add_minutes(anchor, 50 * 60)),
"plannedEndTime": fmt_dt(add_minutes(anchor, 70 * 60)),
"isFrozen": True,
})
params = _params(start_date).model_copy(update={"freezeWindowHours": 60})
entries, _, _ = CpSatEngine().collect_and_order(world, params)
_, meta = optimize_line_assignment(
world, entries, params, pipeline_label="C3-FROZEN-HINT", diagnostic_mode=True,
)
assert meta["status"] in {"OPTIMAL", "FEASIBLE"}
assert meta["zeroObjectiveHintCandidate"] is True
assert meta["zeroObjectiveHintFixed"] is False
slot = _active_slots(meta)[0]
assert slot["startMin"] >= 70 * 60
assert meta["constraintInstanceCounts"]["C2_no_overlap"] > 0
def test_c3_model_rejects_more_than_50k_segment_decisions(monkeypatch) -> None:
world, start_date = _single_line_world(quantity=60)
params = _params(start_date)
entries, _, _ = CpSatEngine().collect_and_order(world, params)
original = cp_engine._line_day_buckets
def oversized_buckets(world_arg, line_ids, anchor, horizon):
buckets = original(world_arg, line_ids, anchor, horizon)
target = next(row for row in buckets if row["lineId"] == 1)
target["effectiveWindows"] = [
{"startMin": index, "endMin": index + 1, "shiftId": 1}
for index in range(50_001)
]
return buckets
monkeypatch.setattr(cp_engine, "_line_day_buckets", oversized_buckets)
with pytest.raises((ValueError, RuntimeError), match=r"C3.*(?:50,?000|50000)"):
optimize_line_assignment(
world,
entries,
params,
pipeline_label="C3-MODEL-LIMIT",
diagnostic_mode=True,
)
def test_short_rule_materialization_remains_calendar_compliant() -> None:
world, start_date = _single_line_world(quantity=60)
result = CpSatEngine().solve(world, _params(start_date), _counter())
version = next(row for row in world["scheduleVersions"] if row["id"] == result.versionId)
meta = version["solverMeta"]
validation = meta["materializedC3Validation"]
slot = _active_slots(meta)[0]
work_order = world["workOrders"][0]
assert result.solveStatus in {"OPTIMAL", "FEASIBLE"}
assert meta["placement"] == "cp-calendar-segmented-direct"
assert meta["directlyConsumedByMaterializer"] is True
assert meta["operationTimingValidation"]["passed"] is True
assert validation["checked"] is True
assert validation["passed"] is True
assert validation["cpTimingApplied"] is True
assert validation["exactAlignmentCount"] == 1
assert validation["violationCount"] == 0
assert work_order["cpTimingSource"] == "operationSlots"
assert work_order["logicalOperationKey"] == slot["logicalOperationKey"]
assert work_order["processingMinutes"] == slot["processingMinutes"]
assert work_order["elapsedSpanMinutes"] == slot["elapsedSpanMinutes"]
assert work_order["pauseMinutes"] == slot["pauseMinutes"]
assert work_order["segmentCount"] == len(work_order["plannedSegments"]) == 1
assert not [
row for row in world["conflicts"]
if row.get("versionId") == result.versionId and row.get("conflictType") == "CALENDAR"
]
def test_rule_materialization_crossing_break_creates_c3_hard_blocker() -> None:
world, start_date = _single_line_world(quantity=500, day_shift_only=True)
result = CpSatEngine().solve(world, _params(start_date), _counter())
version = next(row for row in world["scheduleVersions"] if row["id"] == result.versionId)
validation = version["solverMeta"]["materializedC3Validation"]
blockers = hard_blocking_conflicts(world, result.versionId)
calendar_blockers = [
row for row in blockers if row.get("constraintId") == "C3_calendar"
]
assert result.solveStatus in {"OPTIMAL", "FEASIBLE"}
assert validation["checked"] is True
assert validation["passed"] is True
assert validation["cpTimingApplied"] is True
assert validation["exactAlignmentCount"] == 1
assert validation["violationCount"] == 0
assert calendar_blockers == []
work_order = world["workOrders"][0]
segments = work_order["plannedSegments"]
assert work_order["segmentCount"] == len(segments) == 2
assert work_order["processingMinutes"] == sum(row["durationMin"] for row in segments) == 280
assert work_order["elapsedSpanMinutes"] == 340
assert work_order["pauseMinutes"] == 60
assert segments[0]["endTime"] < segments[1]["startTime"]
def test_direct_materialization_rejects_segment_calendar_window_drift_before_write() -> None:
world, start_date = _single_line_world(quantity=500, day_shift_only=True)
params = _params(start_date)
entries, _, _ = CpSatEngine().collect_and_order(world, params)
ordered, meta = optimize_line_assignment(
world, entries, params, pipeline_label="C3-PARENT-PREFLIGHT",
)
meta["operationSlots"][0]["segments"][0]["calendarWindowId"] = "forged-window"
before_counts = {
key: len(world[key])
for key in ("scheduleVersions", "productionOrders", "workOrders", "conflicts")
}
with pytest.raises(SolverProcessError) as caught:
cp_engine._prepare_cp_operation_timing(world, entries, ordered, meta)
assert caught.value.code == "SOLVER_RESPONSE_INVALID"
assert before_counts == {
key: len(world[key])
for key in ("scheduleVersions", "productionOrders", "workOrders", "conflicts")
}