aps-agent/server/engines/solver_process.py

1633 lines
68 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Fail-closed process boundary for CP-SAT line assignment.
This module is intentionally stdlib-only so callers can construct and supervise the
solver child before importing OR-Tools or any of its native dependencies.
"""
from __future__ import annotations
import dataclasses
import hashlib
import json
import math
import os
import signal
import subprocess
import sys
import tempfile
import uuid
from collections.abc import Mapping, Sequence
from datetime import date, datetime, time
from enum import Enum
from pathlib import Path
from typing import Any, cast
PROTOCOL_VERSION = "aps.solver-process.v2"
SUCCESS_MARKER = "APS_SOLVER_SUCCESS_V1"
ERROR_MARKER = "APS_SOLVER_ERROR_V1"
ENV_CHILD_COMMAND = "APS_SOLVER_CHILD_COMMAND_JSON"
ENV_CHILD_EXECUTABLE = "APS_SOLVER_CHILD_EXECUTABLE"
ENV_CHILD_ARGUMENTS = "APS_SOLVER_CHILD_ARGUMENTS_JSON"
_DEFAULT_STARTUP_GRACE_SECONDS = 12.0
_MAX_RESPONSE_BYTES = 64 * 1024 * 1024
_CAPTURE_LIMIT = 8_000
_FATAL_MARKERS = (
"windows fatal exception",
"fatal python error",
"0xc0000139",
"winerror 127",
"segmentation fault",
"access violation",
"terminate called",
"check failed",
"check failure",
)
_DIAGNOSTIC_RELAXABLE_CONSTRAINTS = frozenset({
"C1_precedence", "C2_no_overlap", "C3_calendar", "C7_capacity", "C10_changeover",
"C11_freeze", "C12_team", "C12_tooling",
})
_DIAGNOSTIC_RHS_PARAMETERS = frozenset({
"C7_line_day_capacity_minutes", "C8_due_date_allowance",
"C12_team_capacity", "C12_tooling_capacity",
})
def _normalize_rhs_perturbation(value: Any) -> dict[str, Any] | None:
if value is None:
return None
normalized = _json_value(value)
if not isinstance(normalized, dict):
raise SolverProcessError("SOLVER_REQUEST_INVALID", "rhs_perturbation 必须是对象或 None")
parameter_id = normalized.get("parameterId")
if parameter_id not in _DIAGNOSTIC_RHS_PARAMETERS:
raise SolverProcessError(
"SOLVER_REQUEST_INVALID", "rhs_perturbation parameterId 不受支持",
)
increment = normalized.get("increment")
if isinstance(increment, bool) or not isinstance(increment, int) or increment <= 0:
raise SolverProcessError("SOLVER_REQUEST_INVALID", "rhs_perturbation increment 必须为正整数")
if parameter_id == "C7_line_day_capacity_minutes":
line_id = normalized.get("lineId")
bucket_date = normalized.get("bucketDate")
if (
set(normalized) != {"parameterId", "lineId", "bucketDate", "increment"}
or isinstance(line_id, bool)
or not isinstance(line_id, int)
or line_id <= 0
or not isinstance(bucket_date, str)
or increment > 1_440
):
raise SolverProcessError("SOLVER_REQUEST_INVALID", "C7 RHS 扰动字段非法")
try:
if date.fromisoformat(bucket_date).isoformat() != bucket_date:
raise ValueError
except ValueError as exc:
raise SolverProcessError(
"SOLVER_REQUEST_INVALID", "C7 RHS bucketDate 须为 YYYY-MM-DD",
) from exc
return {
"parameterId": parameter_id,
"lineId": line_id,
"bucketDate": bucket_date,
"increment": increment,
}
if parameter_id == "C8_due_date_allowance":
if set(normalized) != {"parameterId", "increment"} or increment > 10_080:
raise SolverProcessError(
"SOLVER_REQUEST_INVALID", "C8 RHS 扰动字段非法或超过 10080 分钟",
)
return {"parameterId": parameter_id, "increment": increment}
resource_id = normalized.get("resourceId")
if (
set(normalized) != {"parameterId", "resourceId", "increment"}
or isinstance(resource_id, bool)
or not isinstance(resource_id, int)
or resource_id <= 0
or increment > 100
):
raise SolverProcessError(
"SOLVER_REQUEST_INVALID", "C12 RHS 扰动字段非法、resourceId 非法或增量超过 100",
)
return {
"parameterId": parameter_id,
"resourceId": resource_id,
"increment": increment,
}
class SolverProcessError(RuntimeError):
"""Structured, stable failure raised by the solver process boundary."""
def __init__(self, code: str, message: str, details: Mapping[str, Any] | None = None) -> None:
self.code = str(code)
self.message = str(message)
self.details = dict(details or {})
super().__init__(f"{self.code}: {self.message}")
def as_dict(self) -> dict[str, Any]:
return {"code": self.code, "message": self.message, "details": self.details}
def _json_value(value: Any) -> Any:
"""Return a detached, deterministic JSON value or fail closed."""
if hasattr(value, "model_dump"):
value = value.model_dump(mode="json")
elif dataclasses.is_dataclass(value) and not isinstance(value, type):
value = dataclasses.asdict(cast(Any, value))
elif isinstance(value, Enum):
value = value.value
elif isinstance(value, (datetime, date, time)):
return value.isoformat()
elif isinstance(value, Path):
return str(value)
if value is None or isinstance(value, (str, bool, int)):
return value
if isinstance(value, float):
if not math.isfinite(value):
raise SolverProcessError(
"SOLVER_REQUEST_INVALID",
"求解请求包含非有限浮点数",
{"value": repr(value)},
)
return value
if isinstance(value, Mapping):
return {
str(key): _json_value(item)
for key, item in sorted(value.items(), key=lambda pair: str(pair[0]))
}
if isinstance(value, (list, tuple)):
return [_json_value(item) for item in value]
if isinstance(value, (set, frozenset)):
normalized = [_json_value(item) for item in value]
return sorted(normalized, key=_canonical_json)
raise SolverProcessError(
"SOLVER_REQUEST_INVALID",
"求解请求包含不可序列化对象",
{"type": type(value).__name__},
)
def _canonical_json(value: Any) -> str:
return json.dumps(
value,
ensure_ascii=False,
allow_nan=False,
sort_keys=True,
separators=(",", ":"),
)
def _request_digest(body: Mapping[str, Any]) -> str:
return hashlib.sha256(_canonical_json(body).encode("utf-8")).hexdigest()
def _parse_string_list(raw: str, *, variable: str) -> list[str]:
try:
value = json.loads(raw)
except json.JSONDecodeError as exc:
raise SolverProcessError(
"SOLVER_COMMAND_INVALID",
f"{variable} 不是合法 JSON",
{"error": str(exc)},
) from exc
if not isinstance(value, list) or not value or not all(
isinstance(item, str) and item for item in value
):
raise SolverProcessError(
"SOLVER_COMMAND_INVALID",
f"{variable} 必须是非空字符串数组",
)
return list(value)
def _child_command() -> tuple[list[str], str]:
raw_command = os.environ.get(ENV_CHILD_COMMAND)
if raw_command:
return _parse_string_list(raw_command, variable=ENV_CHILD_COMMAND), "command-override"
frozen_executable = os.environ.get(ENV_CHILD_EXECUTABLE)
if frozen_executable:
executable = Path(frozen_executable).expanduser()
if not executable.is_absolute() or not executable.exists():
raise SolverProcessError(
"SOLVER_COMMAND_INVALID",
f"{ENV_CHILD_EXECUTABLE} 必须指向存在的绝对路径",
{"executable": str(executable)},
)
extra_args: list[str] = []
raw_args = os.environ.get(ENV_CHILD_ARGUMENTS)
if raw_args:
extra_args = _parse_string_list(raw_args, variable=ENV_CHILD_ARGUMENTS)
return [str(executable), "--solver-child", *extra_args], "frozen-executable"
if getattr(sys, "frozen", False):
executable = Path(sys.executable).resolve()
if not executable.is_file():
raise SolverProcessError(
"SOLVER_COMMAND_INVALID",
"冻结 Sidecar 可执行文件不存在",
{"executable": str(executable)},
)
return [str(executable), "--solver-child"], "frozen-self"
worker = Path(__file__).with_name("solver_worker.py").resolve()
if not worker.is_file():
raise SolverProcessError(
"SOLVER_COMMAND_INVALID",
"求解子进程 worker 不存在",
{"worker": str(worker)},
)
executable = Path(sys.executable).resolve()
if not executable.is_file():
raise SolverProcessError(
"SOLVER_COMMAND_INVALID",
"当前 Python 可执行文件不存在",
{"executable": str(executable)},
)
return [
str(executable),
"-I",
"-B",
"-X",
"utf8",
"-X",
"faulthandler",
str(worker),
], "isolated-python"
def _clean_child_path(raw_path: str) -> str:
"""Remove Conda DLL search roots from the supervised child environment."""
kept: list[str] = []
for segment in raw_path.split(os.pathsep):
lowered = segment.casefold().replace("/", "\\")
if any(token in lowered for token in ("anaconda", "miniconda", "\\conda\\", "\\condabin")):
continue
if segment:
kept.append(segment)
return os.pathsep.join(kept)
def _child_environment() -> dict[str, str]:
env = dict(os.environ)
env.pop("PYTHONHOME", None)
env.pop("PYTHONPATH", None)
for key in list(env):
if key.upper().startswith("CONDA_"):
env.pop(key, None)
env["PYTHONNOUSERSITE"] = "1"
env["PYTHONUTF8"] = "1"
env["PYTHONIOENCODING"] = "utf-8"
env["APS_SOLVER_CHILD_PROTOCOL"] = PROTOCOL_VERSION
env["PATH"] = _clean_child_path(env.get("PATH", ""))
return env
def _is_windows() -> bool:
return os.name == "nt"
def _creation_options() -> tuple[int, bool]:
if _is_windows():
flags = int(getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0))
flags |= int(getattr(subprocess, "CREATE_NO_WINDOW", 0))
return flags, False
return 0, True
def _terminate_process_tree(process: subprocess.Popen[str]) -> None:
"""Best-effort, bounded process-tree termination for timeout handling."""
if process.poll() is not None:
return
if _is_windows():
flags = int(getattr(subprocess, "CREATE_NO_WINDOW", 0))
try:
subprocess.run(
["taskkill", "/PID", str(process.pid), "/T", "/F"],
check=False,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=5,
creationflags=flags,
)
except (OSError, subprocess.SubprocessError):
pass
else:
try:
getpgid = os.getpgid # type: ignore[attr-defined]
killpg = os.killpg # type: ignore[attr-defined]
killpg(getpgid(process.pid), getattr(signal, "SIGKILL", signal.SIGTERM))
except (OSError, ProcessLookupError):
pass
if process.poll() is None:
try:
process.kill()
except OSError:
pass
try:
process.wait(timeout=5)
except (OSError, subprocess.SubprocessError):
pass
def _captured(text: str | None) -> str:
value = text or ""
if len(value) <= _CAPTURE_LIMIT:
return value
return value[:_CAPTURE_LIMIT] + "\n...[truncated]"
def _fatal_marker(stdout: str, stderr: str) -> str | None:
haystack = f"{stdout}\n{stderr}".casefold()
return next((marker for marker in _FATAL_MARKERS if marker in haystack), None)
def _effective_timeout(params: Mapping[str, Any], explicit: float | None) -> float:
if explicit is not None:
try:
value = float(explicit)
except (TypeError, ValueError) as exc:
raise SolverProcessError(
"SOLVER_REQUEST_INVALID", "timeout_seconds 必须为正数"
) from exc
if not math.isfinite(value) or value <= 0:
raise SolverProcessError("SOLVER_REQUEST_INVALID", "timeout_seconds 必须为正数")
return value
raw_limit = params.get("timeLimitSeconds")
try:
solver_limit = 8.0 if raw_limit is None else max(0.5, float(raw_limit))
except (TypeError, ValueError) as exc:
raise SolverProcessError(
"SOLVER_REQUEST_INVALID", "timeLimitSeconds 必须为数值"
) from exc
return max(15.0, solver_limit + _DEFAULT_STARTUP_GRACE_SECONDS)
def _response_object(stdout: str) -> dict[str, Any]:
if len(stdout.encode("utf-8", errors="replace")) > _MAX_RESPONSE_BYTES:
raise SolverProcessError(
"SOLVER_RESPONSE_INVALID",
"求解子进程响应超过大小上限",
{"limitBytes": _MAX_RESPONSE_BYTES},
)
if not stdout.strip():
raise SolverProcessError("SOLVER_RESPONSE_INVALID", "求解子进程没有返回响应")
try:
payload = json.loads(stdout)
except json.JSONDecodeError as exc:
raise SolverProcessError(
"SOLVER_RESPONSE_INVALID",
"求解子进程返回非法或截断 JSON",
{"error": str(exc), "stdout": _captured(stdout)},
) from exc
if not isinstance(payload, dict):
raise SolverProcessError("SOLVER_RESPONSE_INVALID", "求解子进程响应必须是 JSON 对象")
return payload
def _raise_worker_error(payload: Mapping[str, Any]) -> None:
error = payload.get("error")
if not isinstance(error, Mapping):
raise SolverProcessError("SOLVER_RESPONSE_INVALID", "求解子进程错误响应缺少 error")
code = error.get("code")
message = error.get("message")
details = error.get("details")
if not isinstance(code, str) or not code or not isinstance(message, str) or not message:
raise SolverProcessError("SOLVER_RESPONSE_INVALID", "求解子进程错误响应结构非法")
if details is not None and not isinstance(details, Mapping):
raise SolverProcessError("SOLVER_RESPONSE_INVALID", "求解子进程错误 details 必须是对象")
raise SolverProcessError(code, message, details if isinstance(details, Mapping) else None)
def _validate_response_digest(payload: Mapping[str, Any]) -> str:
provided = payload.get("responseDigest")
if not isinstance(provided, str) or len(provided) != 64:
raise SolverProcessError(
"SOLVER_RESPONSE_INVALID",
"求解子进程响应缺少有效 responseDigest",
)
try:
int(provided, 16)
except ValueError as exc:
raise SolverProcessError(
"SOLVER_RESPONSE_INVALID",
"求解子进程 responseDigest 不是十六进制摘要",
) from exc
unsigned = dict(payload)
unsigned.pop("responseDigest", None)
try:
normalized = _json_value(unsigned)
actual = hashlib.sha256(
_canonical_json(normalized).encode("utf-8")
).hexdigest()
except SolverProcessError as exc:
raise SolverProcessError(
"SOLVER_RESPONSE_INVALID",
"求解子进程响应包含非法摘要载荷",
{"cause": exc.code},
) from exc
if actual != provided.lower():
raise SolverProcessError(
"SOLVER_RESPONSE_INVALID",
"求解子进程响应摘要不匹配",
{"expected": actual, "actual": provided},
)
return actual
def _entry_signature(entry: Mapping[str, Any]) -> str:
try:
detached = _json_value(entry)
except SolverProcessError as exc:
raise SolverProcessError(
"SOLVER_RESPONSE_INVALID",
"排产条目包含非法响应值",
{"cause": exc.code},
) from exc
if not isinstance(detached, dict):
raise SolverProcessError("SOLVER_RESPONSE_INVALID", "排产条目必须是对象")
forced_line_id = detached.pop("forcedLineId", None)
if forced_line_id is not None and (
isinstance(forced_line_id, bool)
or not isinstance(forced_line_id, int)
or forced_line_id < 0
):
raise SolverProcessError(
"SOLVER_RESPONSE_INVALID", "forcedLineId 必须是非负整数"
)
return _canonical_json(detached)
def _response_invalid(message: str, details: Mapping[str, Any] | None = None) -> None:
raise SolverProcessError("SOLVER_RESPONSE_INVALID", message, details)
def _positive_int(value: Any) -> bool:
return isinstance(value, int) and not isinstance(value, bool) and value > 0
def _nonnegative_int(value: Any) -> bool:
return isinstance(value, int) and not isinstance(value, bool) and value >= 0
def _entry_identity(entry: Mapping[str, Any]) -> tuple[int, int, int]:
so = entry.get("so")
item = entry.get("item")
if not isinstance(so, Mapping) or not isinstance(item, Mapping):
_response_invalid("排产条目缺少 sales order/item 身份")
sales_order_id = so.get("id")
sales_order_item_id = item.get("id")
product_id = item.get("productId")
if not all(_positive_int(value) for value in (sales_order_id, sales_order_item_id, product_id)):
_response_invalid("排产条目的 salesOrderId/salesOrderItemId/productId 非法")
return int(sales_order_id), int(sales_order_item_id), int(product_id)
def _routing_steps_for_product(
world: Mapping[str, Any], product_id: int,
) -> list[Mapping[str, Any]]:
routings = world.get("routings")
steps = world.get("routingSteps")
if not isinstance(routings, list) or not isinstance(steps, list):
_response_invalid("world 缺少 routings/routingSteps 主数据")
routing = next((
row for row in routings
if isinstance(row, Mapping)
and row.get("productId") == product_id
and row.get("isDefault") is True
), None)
if routing is None or not _positive_int(routing.get("id")):
_response_invalid("可行解引用的产品没有有效默认工艺路线", {"productId": product_id})
result = [
row for row in steps
if isinstance(row, Mapping) and row.get("routingId") == routing.get("id")
]
if not result:
_response_invalid("可行解引用的默认工艺路线没有步骤", {"productId": product_id})
if not all(
_positive_int(row.get("id"))
and _positive_int(row.get("operationId"))
and _positive_int(row.get("sequenceNo"))
for row in result
):
_response_invalid("默认工艺路线包含非法步骤身份", {"productId": product_id})
return sorted(result, key=lambda row: (int(row["sequenceNo"]), int(row["id"])))
def _expected_workstation(
world: Mapping[str, Any], line_id: int, operation_id: int,
) -> Mapping[str, Any]:
relations = world.get("workstationOperations")
workstations = world.get("workstations")
if not isinstance(relations, list) or not isinstance(workstations, list):
_response_invalid("world 缺少 workstation 主数据")
capable = {
row.get("workstationId") for row in relations
if isinstance(row, Mapping) and row.get("operationId") == operation_id
}
workstation = next((
row for row in workstations
if isinstance(row, Mapping)
and row.get("lineId") == line_id
and row.get("id") in capable
and row.get("status", "ACTIVE") == "ACTIVE"
), None)
if workstation is None:
_response_invalid(
"operationSlot 引用的产线没有该工序的可用工位",
{"lineId": line_id, "operationId": operation_id},
)
return workstation
def _validate_slot_segments(slot: Mapping[str, Any], *, horizon: int) -> list[dict[str, Any]]:
integer_fields = (
"startMin", "endMin", "durationMin", "processingMinutes",
"elapsedSpanMinutes", "pauseMinutes", "segmentCount", "setupMin", "changeoverMin",
)
if not all(_nonnegative_int(slot.get(field)) for field in integer_fields):
_response_invalid("operationSlot 时间/工时字段必须是非负整数")
start = int(slot["startMin"])
end = int(slot["endMin"])
processing = int(slot["processingMinutes"])
elapsed = int(slot["elapsedSpanMinutes"])
pause = int(slot["pauseMinutes"])
if start >= end or end > horizon or processing <= 0:
_response_invalid(
"operationSlot 时间必须满足 0 <= start < end <= horizon 且 processing > 0",
{"startMin": start, "endMin": end, "horizonMinutes": horizon},
)
segments = slot.get("segments")
if not isinstance(segments, list) or not segments or not all(
isinstance(segment, Mapping) for segment in segments
):
_response_invalid("operationSlot.segments 必须是非空对象数组")
normalized: list[dict[str, Any]] = []
previous_end: int | None = None
for segment in segments:
segment_start = segment.get("startMin")
segment_end = segment.get("endMin")
duration = segment.get("durationMin")
if (
not _nonnegative_int(segment_start)
or not _nonnegative_int(segment_end)
or not _positive_int(duration)
or int(segment_start) >= int(segment_end)
or int(segment_end) > horizon
or int(duration) != int(segment_end) - int(segment_start)
):
_response_invalid("operationSlot segment 时间或 durationMin 非法")
if previous_end is not None and int(segment_start) < previous_end:
_response_invalid("operationSlot segments 必须有序且互不重叠")
previous_end = int(segment_end)
normalized.append(dict(segment))
summed = sum(int(segment["durationMin"]) for segment in normalized)
if (
int(slot["segmentCount"]) != len(normalized)
or int(slot["durationMin"]) != processing
or summed != processing
or normalized[0]["startMin"] != start
or normalized[-1]["endMin"] != end
or elapsed != end - start
or pause != elapsed - processing
):
_response_invalid("operationSlot segment 汇总与包络/processing/pause 不一致")
calendar_mode = slot.get("calendarMode")
if calendar_mode not in {"calendar-boundary-only", "continuous", "unrestricted", "fixed-existing"}:
_response_invalid("operationSlot.calendarMode 非法", {"calendarMode": calendar_mode})
if calendar_mode == "calendar-boundary-only":
if slot.get("calendarCompliant") is not True or not all(
isinstance(segment.get("calendarWindowId"), str)
and bool(segment.get("calendarWindowId"))
and isinstance(segment.get("bucketDate"), str)
and _positive_int(segment.get("shiftId"))
for segment in normalized
):
_response_invalid("C3 operationSlot segment 缺少有效日历窗口身份")
return normalized
def _validate_operation_slots(
world: Mapping[str, Any],
ordered: Sequence[Mapping[str, Any]],
solver_meta: Mapping[str, Any],
*,
expected_entries: Sequence[Mapping[str, Any]],
expected_relaxed_constraint_ids: Sequence[str] = (),
expected_rhs_perturbation: Mapping[str, Any] | None = None,
) -> None:
"""Validate a feasible child response before any parent-world materialization."""
c3 = solver_meta.get("c3Calendar")
if not isinstance(c3, Mapping) or not _positive_int(c3.get("horizonMinutes")):
_response_invalid("可行解缺少有效 c3Calendar.horizonMinutes")
horizon = int(c3["horizonMinutes"])
try:
anchor = datetime.fromisoformat(str(c3.get("anchor") or ""))
except ValueError as exc:
raise SolverProcessError(
"SOLVER_RESPONSE_INVALID", "可行解 c3Calendar.anchor 非法",
) from exc
slots = solver_meta.get("operationSlots")
if not isinstance(slots, list) or not all(isinstance(slot, Mapping) for slot in slots):
_response_invalid("可行解缺少 operationSlots")
expected_by_identity: dict[tuple[int, int], tuple[int, Mapping[str, Any]]] = {}
for index, entry in enumerate(expected_entries):
sales_order_id, sales_order_item_id, _product_id = _entry_identity(entry)
key = (sales_order_id, sales_order_item_id)
if key in expected_by_identity:
_response_invalid("请求条目 sales order/item 身份重复", {"identity": key})
expected_by_identity[key] = (index, entry)
ordered_by_identity: dict[tuple[int, int], Mapping[str, Any]] = {}
for entry in ordered:
sales_order_id, sales_order_item_id, _product_id = _entry_identity(entry)
key = (sales_order_id, sales_order_item_id)
if key in ordered_by_identity:
_response_invalid("响应条目 sales order/item 身份重复", {"identity": key})
ordered_by_identity[key] = entry
if set(ordered_by_identity) != set(expected_by_identity):
_response_invalid("响应条目的稳定 sales order/item 身份不完整")
active_by_index: dict[int, list[Mapping[str, Any]]] = {}
logical_keys: set[str] = set()
resource_segments: dict[tuple[str, int], list[tuple[int, int]]] = {}
for slot in slots:
if slot.get("schemaVersion") != "cp-operation-slot.v1":
_response_invalid("operationSlot schemaVersion 不受支持")
_validate_slot_segments(slot, horizon=horizon)
if slot.get("isFrozen") is True:
if slot.get("orderIndex") != -1 or not _positive_int(slot.get("sourceWorkOrderId")):
_response_invalid("冻结 operationSlot 身份非法")
source = next((
row for row in world.get("workOrders", [])
if isinstance(row, Mapping) and row.get("id") == slot.get("sourceWorkOrderId")
), None)
production_version = next((
row.get("schedulingVersionId")
for row in world.get("productionOrders", [])
if isinstance(row, Mapping) and row.get("id") == source.get("productionOrderId")
), None) if source is not None else None
source_version_id = (
source.get("schedulingVersionId") or production_version
if source is not None else None
)
try:
source_start = datetime.fromisoformat(str(source.get("plannedStartTime") or ""))
source_end = datetime.fromisoformat(str(source.get("plannedEndTime") or ""))
expected_start = max(
0, round((source_start - anchor).total_seconds() / 60.0),
)
expected_end = min(
horizon,
max(
expected_start + 1,
round((source_end - anchor).total_seconds() / 60.0),
),
)
except (AttributeError, TypeError, ValueError) as exc:
raise SolverProcessError(
"SOLVER_RESPONSE_INVALID", "冻结源工单时间非法",
) from exc
if source is None or any(
slot.get(field) != source.get(source_field)
for field, source_field in (
("orderNo", "orderNo"), ("lineId", "lineId"),
("workstationId", "workstationId"),
)
) or (
slot.get("sourceSchedulingVersionId") != source_version_id
or slot.get("logicalOperationKey") != f"frozen:{slot['sourceWorkOrderId']}"
or slot.get("calendarMode") != "fixed-existing"
or int(slot["startMin"]) != expected_start
or int(slot["endMin"]) != expected_end
):
_response_invalid("冻结 operationSlot 与源工单身份不一致")
resource_segments.setdefault(
("workstation", int(slot["workstationId"])), [],
).extend(
(int(segment["startMin"]), int(segment["endMin"]))
for segment in slot["segments"]
)
continue
order_index = slot.get("orderIndex")
if not _nonnegative_int(order_index) or int(order_index) >= len(expected_entries):
_response_invalid(
"operationSlots 包含越界 orderIndex",
{"orderIndex": order_index, "entryCount": len(expected_entries)},
)
order_index = int(order_index)
expected_entry = expected_entries[order_index]
so = expected_entry.get("so")
item = expected_entry.get("item")
assert isinstance(so, Mapping) and isinstance(item, Mapping)
sales_order_id, sales_order_item_id, product_id = _entry_identity(expected_entry)
if (
slot.get("salesOrderId") != sales_order_id
or slot.get("salesOrderItemId") != sales_order_item_id
or slot.get("productId") != product_id
or slot.get("orderNo") != so.get("orderNo")
):
_response_invalid("operationSlot 稳定订单身份与请求条目不一致", {"orderIndex": order_index})
line_id = slot.get("lineId")
workstation_id = slot.get("workstationId")
operation_id = slot.get("operationId")
routing_step_id = slot.get("routingStepId")
sequence_no = slot.get("sequenceNo")
if not all(_positive_int(value) for value in (
line_id, workstation_id, operation_id, routing_step_id, sequence_no,
)):
_response_invalid("operationSlot 工序/产线/工位身份必须为正整数")
active_lines = {
row.get("id") for row in world.get("lines", [])
if isinstance(row, Mapping) and row.get("status", "ACTIVE") == "ACTIVE"
}
product_lines = {
row.get("lineId") for row in world.get("lineProducts", [])
if isinstance(row, Mapping)
and row.get("productId") == product_id
and row.get("lineId") in active_lines
}
if line_id not in product_lines:
_response_invalid("operationSlot lineId 不是产品可用产线", {"orderIndex": order_index})
steps = _routing_steps_for_product(world, product_id)
step = next((row for row in steps if row.get("id") == routing_step_id), None)
if (
step is None
or step.get("operationId") != operation_id
or step.get("sequenceNo") != sequence_no
):
_response_invalid("operationSlot 与默认工艺路线步骤不一致", {"orderIndex": order_index})
expected_ws = _expected_workstation(world, int(line_id), int(operation_id))
if (
workstation_id != expected_ws.get("id")
or slot.get("teamId") != expected_ws.get("teamId")
or slot.get("toolingId") != expected_ws.get("toolingId")
):
_response_invalid("operationSlot 工位/team/tooling 身份与主数据不一致")
logical_key = f"{sales_order_id}:{sales_order_item_id}:{routing_step_id}"
if slot.get("logicalOperationKey") != logical_key or logical_key in logical_keys:
_response_invalid("operationSlot logicalOperationKey 缺失、重复或错配")
logical_keys.add(logical_key)
active_by_index.setdefault(order_index, []).append(slot)
for segment in slot["segments"]:
interval = (int(segment["startMin"]), int(segment["endMin"]))
resource_segments.setdefault(("workstation", int(workstation_id)), []).append(interval)
if slot.get("teamId") is not None:
resource_segments.setdefault(("team", int(slot["teamId"])), []).append(interval)
if slot.get("toolingId") is not None:
resource_segments.setdefault(("tooling", int(slot["toolingId"])), []).append(interval)
for index, expected_entry in enumerate(expected_entries):
_sales_order_id, _sales_order_item_id, product_id = _entry_identity(expected_entry)
expected_steps = _routing_steps_for_product(world, product_id)
actual = sorted(
active_by_index.get(index, []),
key=lambda slot: (int(slot["sequenceNo"]), int(slot["routingStepId"])),
)
expected_keys = [(int(step["sequenceNo"]), int(step["id"])) for step in expected_steps]
actual_keys = [(int(slot["sequenceNo"]), int(slot["routingStepId"])) for slot in actual]
if actual_keys != expected_keys:
_response_invalid(
"operationSlots 未与请求条目的默认工艺路线严格双射",
{"orderIndex": index, "expected": expected_keys, "actual": actual_keys},
)
line_ids = {int(slot["lineId"]) for slot in actual}
if len(line_ids) != 1:
_response_invalid("同一排产条目的 operationSlots 必须使用同一产线", {"orderIndex": index})
entry_key = _entry_identity(expected_entry)[:2]
ordered_entry = ordered_by_identity[entry_key]
if ordered_entry.get("forcedLineId") != next(iter(line_ids)):
_response_invalid("ordered.forcedLineId 与 operationSlots lineId 不一致")
for left, right, step in zip(actual, actual[1:], expected_steps):
gap = max(0, round(float(step.get("transferTime") or 0) + float(step.get("waitTime") or 0)))
if int(right["startMin"]) < int(left["endMin"]) + gap:
_response_invalid("operationSlots 违反工艺前后序或转移/等待间隙", {"orderIndex": index})
capacities: dict[tuple[str, int], int] = {}
for row in world.get("teams", []):
if isinstance(row, Mapping) and _positive_int(row.get("id")):
capacity = int(row.get("availableCount") or row.get("memberCount") or 0)
if capacity > 0:
capacities[("team", int(row["id"]))] = capacity
for row in world.get("toolings", []):
if isinstance(row, Mapping) and _positive_int(row.get("id")):
capacity = int(
row.get("availableCount") or row.get("quantity") or row.get("count") or 0
)
if capacity > 0:
capacities[("tooling", int(row["id"]))] = capacity
if isinstance(expected_rhs_perturbation, Mapping):
parameter_id = expected_rhs_perturbation.get("parameterId")
resource_kind = {
"C12_team_capacity": "team",
"C12_tooling_capacity": "tooling",
}.get(parameter_id)
if resource_kind is not None:
resource_id = expected_rhs_perturbation.get("resourceId")
increment = expected_rhs_perturbation.get("increment")
resource_key = (resource_kind, int(resource_id))
if resource_key not in capacities:
_response_invalid(
"RHS 扰动引用的 C12 资源缺少基础容量",
{"resourceType": resource_kind, "resourceId": resource_id},
)
capacities[resource_key] += int(increment)
cumulative = solver_meta.get("cumulative")
if not isinstance(cumulative, Mapping):
_response_invalid("可行解缺少 cumulative 资源模型元数据")
cumulative_enabled = cumulative.get("enabled")
cumulative_resources = cumulative.get("resources")
if (
not isinstance(cumulative_enabled, Mapping)
or set(cumulative_enabled) != {"team", "tooling"}
or not all(isinstance(cumulative_enabled.get(kind), bool) for kind in ("team", "tooling"))
or not isinstance(cumulative_resources, list)
or not all(isinstance(row, Mapping) for row in cumulative_resources)
):
_response_invalid("可行解 cumulative enabled/resources 元数据非法")
modeled_cumulative_resources: set[tuple[str, int]] = set()
for row in cumulative_resources:
kind = row.get("kind")
resource_id = row.get("id")
interval_count = row.get("intervalCount")
capacity = row.get("capacity")
if (
kind not in {"team", "tooling"}
or not _positive_int(resource_id)
or not _nonnegative_int(interval_count)
or not _positive_int(capacity)
):
_response_invalid("可行解 cumulative resource 身份、容量或实例数非法")
resource_key = (str(kind), int(resource_id))
expected_capacity = capacities.get(resource_key)
if expected_capacity is None or int(capacity) != expected_capacity:
_response_invalid(
"可行解 cumulative resource 容量与父进程快照不一致",
{
"resourceType": kind,
"resourceId": resource_id,
"expectedCapacity": expected_capacity,
"actualCapacity": capacity,
},
)
if cumulative_enabled.get(kind) is True and int(interval_count) >= 2:
modeled_cumulative_resources.add(resource_key)
relaxed = set(expected_relaxed_constraint_ids)
for resource, intervals in resource_segments.items():
if resource[0] == "workstation":
if "C2_no_overlap" in relaxed:
continue
capacity = 1
elif resource in modeled_cumulative_resources:
if (
resource[0] == "team" and "C12_team" in relaxed
) or (
resource[0] == "tooling" and "C12_tooling" in relaxed
):
continue
capacity = capacities.get(resource)
else:
continue
if capacity is None:
_response_invalid(
"可行解引用的 C12 资源缺少有效容量",
{"resourceType": resource[0], "resourceId": resource[1]},
)
events = sorted(
[(start, 1) for start, _end in intervals] + [(end, -1) for _start, end in intervals],
key=lambda event: (event[0], event[1]),
)
concurrent = 0
for _minute, delta in events:
concurrent += delta
if concurrent > capacity:
_response_invalid(
"operationSlots 违反 workstation/team/tooling 资源容量",
{"resourceType": resource[0], "resourceId": resource[1], "capacity": capacity},
)
def _validate_result_semantics(
ordered: Sequence[Mapping[str, Any]],
solver_meta: Mapping[str, Any],
*,
expected_world: Mapping[str, Any] | None = None,
expected_entries: Sequence[Mapping[str, Any]],
pipeline_label: str,
expected_relaxed_constraint_ids: Sequence[str],
expected_diagnostic_mode: bool,
expected_rhs_diagnostic_mode: bool = False,
expected_rhs_perturbation: Mapping[str, Any] | None = None,
) -> None:
expected_signatures = sorted(_entry_signature(entry) for entry in expected_entries)
actual_signatures = sorted(_entry_signature(entry) for entry in ordered)
if actual_signatures != expected_signatures:
raise SolverProcessError(
"SOLVER_RESPONSE_INVALID",
"求解子进程返回条目不是请求条目的完整排列",
{
"expectedCount": len(expected_signatures),
"actualCount": len(actual_signatures),
"expectedDigest": hashlib.sha256(
_canonical_json(expected_signatures).encode("utf-8")
).hexdigest(),
"actualDigest": hashlib.sha256(
_canonical_json(actual_signatures).encode("utf-8")
).hexdigest(),
},
)
status = solver_meta.get("status")
allowed_statuses = {
"TRIVIAL",
"OPTIMAL",
"FEASIBLE",
"INFEASIBLE",
"MODEL_INVALID",
"UNKNOWN",
}
if status not in allowed_statuses:
raise SolverProcessError(
"SOLVER_RESPONSE_INVALID",
"求解子进程返回未知 solve status",
{"status": status},
)
if solver_meta.get("pipeline") != pipeline_label:
raise SolverProcessError(
"SOLVER_RESPONSE_INVALID",
"求解子进程 pipeline 与请求不一致",
{"expected": pipeline_label, "actual": solver_meta.get("pipeline")},
)
if expected_entries and status == "TRIVIAL":
raise SolverProcessError(
"SOLVER_RESPONSE_INVALID", "非空排产请求不能返回 TRIVIAL"
)
if not expected_entries and status != "TRIVIAL":
raise SolverProcessError(
"SOLVER_RESPONSE_INVALID", "空排产请求必须返回 TRIVIAL"
)
actual_relaxed = solver_meta.get("relaxedConstraintIds", [])
if not isinstance(actual_relaxed, list) or not all(
isinstance(value, str) for value in actual_relaxed
):
raise SolverProcessError(
"SOLVER_RESPONSE_INVALID", "solverMeta.relaxedConstraintIds 必须是字符串数组",
)
if sorted(actual_relaxed) != sorted(expected_relaxed_constraint_ids):
raise SolverProcessError(
"SOLVER_RESPONSE_INVALID",
"求解响应的诊断松弛约束与请求不一致",
{
"expected": sorted(expected_relaxed_constraint_ids),
"actual": sorted(actual_relaxed),
},
)
actual_diagnostic_mode = solver_meta.get("diagnosticMode", False)
if actual_diagnostic_mode is not expected_diagnostic_mode:
raise SolverProcessError(
"SOLVER_RESPONSE_INVALID", "求解响应 diagnosticMode 与请求不一致",
)
actual_rhs_diagnostic_mode = solver_meta.get("rhsDiagnosticMode", False)
if actual_rhs_diagnostic_mode is not expected_rhs_diagnostic_mode:
raise SolverProcessError(
"SOLVER_RESPONSE_INVALID", "求解响应 rhsDiagnosticMode 与请求不一致",
)
actual_rhs_perturbation = solver_meta.get("rhsPerturbation")
if actual_rhs_perturbation != expected_rhs_perturbation:
raise SolverProcessError(
"SOLVER_RESPONSE_INVALID", "求解响应 RHS 扰动与请求不一致",
{"expected": expected_rhs_perturbation, "actual": actual_rhs_perturbation},
)
if expected_rhs_diagnostic_mode:
state = solver_meta.get("rhsParameterState")
if not isinstance(state, Mapping):
raise SolverProcessError(
"SOLVER_RESPONSE_INVALID", "RHS 诊断响应缺少 rhsParameterState",
)
if set(state) != {
"dueDateAllowanceMinutes", "dueDateEntryCount",
"lineDailyCapacities", "teamCapacities", "toolingCapacities",
}:
raise SolverProcessError(
"SOLVER_RESPONSE_INVALID", "RHS 诊断 rhsParameterState 字段不完整",
)
for field in ("dueDateAllowanceMinutes", "dueDateEntryCount"):
value = state.get(field)
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
raise SolverProcessError(
"SOLVER_RESPONSE_INVALID", f"RHS 诊断 {field} 非法",
)
line_values = state.get("lineDailyCapacities")
line_fields = {
"lineId", "lineCode", "bucketDate", "bucketStartMin", "bucketEndMin",
"baseCapacityMinutes", "capacityMinutes", "candidateLoadTermCount",
"changeoverTermCount", "fixedFrozenLoadMinutes",
"shiftIds", "shiftCalendarRowIds",
}
if not isinstance(line_values, list) or not all(
isinstance(item, Mapping)
and set(item) == line_fields
and isinstance(item.get("lineId"), int)
and not isinstance(item.get("lineId"), bool)
and int(item["lineId"]) > 0
and (item.get("lineCode") is None or isinstance(item.get("lineCode"), str))
and isinstance(item.get("bucketDate"), str)
and isinstance(item.get("bucketStartMin"), int)
and isinstance(item.get("bucketEndMin"), int)
and 0 <= int(item["bucketStartMin"]) < int(item["bucketEndMin"])
and isinstance(item.get("baseCapacityMinutes"), int)
and int(item["baseCapacityMinutes"]) >= 0
and isinstance(item.get("capacityMinutes"), int)
and int(item["capacityMinutes"]) >= 0
and isinstance(item.get("candidateLoadTermCount"), int)
and int(item["candidateLoadTermCount"]) >= 0
and isinstance(item.get("changeoverTermCount"), int)
and int(item["changeoverTermCount"]) >= 0
and isinstance(item.get("fixedFrozenLoadMinutes"), int)
and int(item["fixedFrozenLoadMinutes"]) >= 0
and isinstance(item.get("shiftIds"), list)
and all(isinstance(value, int) and value > 0 for value in item["shiftIds"])
and isinstance(item.get("shiftCalendarRowIds"), list)
and all(
isinstance(value, int) and value > 0
for value in item["shiftCalendarRowIds"]
)
for item in line_values
):
raise SolverProcessError(
"SOLVER_RESPONSE_INVALID", "RHS 诊断 lineDailyCapacities 非法",
)
line_keys = [(int(item["lineId"]), str(item["bucketDate"])) for item in line_values]
if line_keys != sorted(set(line_keys)):
raise SolverProcessError(
"SOLVER_RESPONSE_INVALID", "RHS 诊断 line/day 实例须唯一且有序",
)
for field in ("teamCapacities", "toolingCapacities"):
values = state.get(field)
if not isinstance(values, list) or not all(
isinstance(item, Mapping)
and set(item) == {"resourceId", "capacity", "intervalCount"}
and isinstance(item.get("resourceId"), int)
and not isinstance(item.get("resourceId"), bool)
and int(item["resourceId"]) > 0
and isinstance(item.get("capacity"), int)
and not isinstance(item.get("capacity"), bool)
and int(item["capacity"]) > 0
and isinstance(item.get("intervalCount"), int)
and not isinstance(item.get("intervalCount"), bool)
and int(item["intervalCount"]) >= 0
for item in values
):
raise SolverProcessError(
"SOLVER_RESPONSE_INVALID", f"RHS 诊断 {field} 非法",
)
resource_ids = [int(item["resourceId"]) for item in values]
if resource_ids != sorted(set(resource_ids)):
raise SolverProcessError(
"SOLVER_RESPONSE_INVALID", f"RHS 诊断 {field} 资源 ID 须唯一且有序",
)
if state.get("dueDateEntryCount") != len(expected_entries):
raise SolverProcessError(
"SOLVER_RESPONSE_INVALID", "RHS 诊断 dueDateEntryCount 与请求条目数不一致",
)
if expected_diagnostic_mode:
modeled = solver_meta.get("assumptionConstraints")
enforced = solver_meta.get("enforcedAssumptionConstraints")
active = solver_meta.get("activeAssumptionConstraints")
counts = solver_meta.get("constraintInstanceCounts")
if not all(isinstance(value, list) for value in (modeled, enforced, active)) or not isinstance(
counts, Mapping
):
raise SolverProcessError(
"SOLVER_RESPONSE_INVALID", "诊断求解响应缺少 assumption/instance 集合",
)
if set(modeled) != set(enforced) | set(actual_relaxed) or set(enforced) & set(actual_relaxed):
raise SolverProcessError(
"SOLVER_RESPONSE_INVALID", "诊断求解 assumption 分区不一致",
)
if not all(
isinstance(key, str)
and isinstance(value, int)
and not isinstance(value, bool)
and value >= 0
for key, value in counts.items()
):
raise SolverProcessError(
"SOLVER_RESPONSE_INVALID", "诊断求解 constraintInstanceCounts 非法",
)
expected_active = {key for key, value in counts.items() if value > 0}
if set(active) != expected_active or set(active) - set(modeled):
raise SolverProcessError(
"SOLVER_RESPONSE_INVALID", "activeAssumptionConstraints 与实例数不一致",
)
if set(actual_relaxed) - set(active):
raise SolverProcessError(
"SOLVER_RESPONSE_INVALID", "诊断松弛约束没有实际模型实例",
)
if "C3_calendar" in set(modeled):
c3 = solver_meta.get("c3Calendar")
required_c3_fields = {
"schemaVersion", "modelMode", "pausePolicy", "anchor", "horizonMinutes",
"coverageStart", "coverageEnd", "coverageComplete",
"normalizedCalendarDigest", "calendarBucketCount", "lineWindowCounts",
"segmentIntervalCount", "segmentIntervalLimit", "maxSegmentsPerOperation",
"selectedMode", "active",
}
valid_digest = (
isinstance(c3, Mapping)
and isinstance(c3.get("normalizedCalendarDigest"), str)
and len(c3["normalizedCalendarDigest"]) == 64
and all(char in "0123456789abcdef" for char in c3["normalizedCalendarDigest"])
)
valid_counts = (
isinstance(c3, Mapping)
and all(
isinstance(c3.get(field), int)
and not isinstance(c3.get(field), bool)
and int(c3[field]) >= 0
for field in (
"horizonMinutes", "calendarBucketCount", "segmentIntervalCount",
"segmentIntervalLimit", "maxSegmentsPerOperation",
)
)
and int(c3["horizonMinutes"]) > 0
and int(c3["segmentIntervalLimit"]) > 0
)
valid_line_counts = (
isinstance(c3, Mapping)
and isinstance(c3.get("lineWindowCounts"), Mapping)
and all(
isinstance(key, str)
and isinstance(value, int)
and not isinstance(value, bool)
and value >= 0
for key, value in c3["lineWindowCounts"].items()
)
)
expected_c3_mode = (
"continuous" if "C3_calendar" in actual_relaxed else "calendar-boundary-only"
)
if (
not isinstance(c3, Mapping)
or not required_c3_fields <= set(c3)
or c3.get("schemaVersion") != "cp-calendar-segmented.v1"
or c3.get("modelMode") != "assumption-gated-dual-mode"
or c3.get("pausePolicy") != "calendar-boundary-only"
or not isinstance(c3.get("anchor"), str)
or c3.get("coverageComplete") is not True
or not isinstance(c3.get("coverageStart"), str)
or not isinstance(c3.get("coverageEnd"), str)
or c3.get("selectedMode") != expected_c3_mode
or c3.get("active") is not (expected_c3_mode == "calendar-boundary-only")
or not valid_digest
or not valid_counts
or not valid_line_counts
):
raise SolverProcessError(
"SOLVER_RESPONSE_INVALID", "诊断求解 C3 日历拓扑或模式非法",
)
if status not in {"OPTIMAL", "FEASIBLE"}:
return
for field in ("objective", "bestBound", "gap"):
value = solver_meta.get(field)
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise SolverProcessError(
"SOLVER_RESPONSE_INVALID",
f"可行解缺少数值 {field}",
{"status": status, "field": field},
)
if not math.isfinite(float(value)):
raise SolverProcessError(
"SOLVER_RESPONSE_INVALID", f"可行解 {field} 不是有限数"
)
objective = float(solver_meta["objective"])
best_bound = float(solver_meta["bestBound"])
gap = float(solver_meta["gap"])
if best_bound > objective + 1e-6:
raise SolverProcessError(
"SOLVER_RESPONSE_INVALID", "最小化模型 bestBound 不得大于 objective",
)
expected_gap = 0.0 if abs(objective) <= 1e-9 else abs(objective - best_bound) / abs(objective)
if abs(gap - round(expected_gap, 6)) > 1e-6:
raise SolverProcessError(
"SOLVER_RESPONSE_INVALID", "求解响应 gap 与 objective/bestBound 不一致",
)
if status == "OPTIMAL" and (
abs(objective - best_bound) > 1e-6 or abs(gap) > 1e-9
):
raise SolverProcessError(
"SOLVER_RESPONSE_INVALID", "OPTIMAL 响应必须 objective=bestBound 且 gap=0",
)
if expected_world is None:
_response_invalid("可行解父进程校验缺少 world 快照")
_validate_operation_slots(
expected_world,
ordered,
solver_meta,
expected_entries=expected_entries,
expected_relaxed_constraint_ids=expected_relaxed_constraint_ids,
expected_rhs_perturbation=expected_rhs_perturbation,
)
def _validate_response(
payload: Mapping[str, Any],
*,
request_id: str,
expected_world: Mapping[str, Any],
expected_entries: Sequence[Mapping[str, Any]],
pipeline_label: str,
expected_relaxed_constraint_ids: Sequence[str],
expected_operation: str,
expected_invocation_id: str | None,
expected_diagnostic_mode: bool,
expected_rhs_diagnostic_mode: bool = False,
expected_rhs_perturbation: Mapping[str, Any] | None = None,
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
if payload.get("protocolVersion") != PROTOCOL_VERSION:
raise SolverProcessError(
"SOLVER_PROTOCOL_MISMATCH",
"求解子进程协议版本不一致",
{"expected": PROTOCOL_VERSION, "actual": payload.get("protocolVersion")},
)
if payload.get("requestId") != request_id:
raise SolverProcessError(
"SOLVER_PROTOCOL_MISMATCH",
"求解子进程响应 requestId 不匹配",
{"expected": request_id, "actual": payload.get("requestId")},
)
marker = payload.get("marker")
if marker == ERROR_MARKER:
_raise_worker_error(payload)
if marker != SUCCESS_MARKER or payload.get("ok") is not True:
raise SolverProcessError(
"SOLVER_RESPONSE_INVALID",
"求解子进程响应缺少 success marker",
{"marker": marker},
)
runtime = payload.get("runtimeIdentity")
if not isinstance(runtime, Mapping):
raise SolverProcessError("SOLVER_RESPONSE_INVALID", "响应缺少 runtimeIdentity")
if runtime.get("safe") is not True or runtime.get("implementation") != "CPython":
raise SolverProcessError(
"SOLVER_RUNTIME_UNSAFE",
"求解子进程运行时身份不安全",
{"runtimeIdentity": dict(runtime)},
)
response_digest = _validate_response_digest(payload)
result = payload.get("result")
if not isinstance(result, Mapping):
raise SolverProcessError("SOLVER_RESPONSE_INVALID", "响应缺少 result 对象")
ordered = result.get("entries")
solver_meta = result.get("solverMeta")
if not isinstance(ordered, list) or not all(isinstance(item, dict) for item in ordered):
raise SolverProcessError("SOLVER_RESPONSE_INVALID", "result.entries 必须是对象数组")
if not isinstance(solver_meta, dict):
raise SolverProcessError("SOLVER_RESPONSE_INVALID", "result.solverMeta 必须是对象")
detached_entries = _json_value(ordered)
detached_meta = _json_value(solver_meta)
detached_runtime = _json_value(runtime)
if (
not isinstance(detached_entries, list)
or not isinstance(detached_meta, dict)
or not isinstance(detached_runtime, dict)
):
raise SolverProcessError("SOLVER_RESPONSE_INVALID", "求解响应无法安全复制")
_validate_result_semantics(
detached_entries,
detached_meta,
expected_world=expected_world,
expected_entries=expected_entries,
pipeline_label=pipeline_label,
expected_relaxed_constraint_ids=expected_relaxed_constraint_ids,
expected_diagnostic_mode=expected_diagnostic_mode,
expected_rhs_diagnostic_mode=expected_rhs_diagnostic_mode,
expected_rhs_perturbation=expected_rhs_perturbation,
)
if payload.get("operation") != expected_operation:
raise SolverProcessError(
"SOLVER_PROTOCOL_MISMATCH", "求解子进程响应 operation 不匹配",
)
if payload.get("invocationId") != expected_invocation_id:
raise SolverProcessError(
"SOLVER_PROTOCOL_MISMATCH", "求解子进程响应 invocationId 不匹配",
)
detached_meta["solverProcess"] = {
"protocolVersion": PROTOCOL_VERSION,
"requestId": request_id,
"responseDigest": response_digest,
"runtimeIdentity": detached_runtime,
"operation": expected_operation,
"invocationId": expected_invocation_id,
}
return detached_entries, detached_meta
def _run_solver_request(
world: Mapping[str, Any],
entries: Sequence[Mapping[str, Any]],
params: Any,
*,
warm_start: Sequence[Mapping[str, Any]] | None = None,
pipeline_label: str,
timeout_seconds: float | None = None,
operation: str,
relaxed_constraint_ids: Sequence[str],
diagnostic_mode: bool,
rhs_diagnostic_mode: bool = False,
rhs_perturbation: Mapping[str, Any] | None = None,
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
"""Run one strict solver operation in a supervised isolated child process.
The caller's ``world`` and ``entries`` are serialized into detached canonical JSON.
The worker cannot mutate the parent objects and has no persistence or integration
handles. A frozen sidecar can later provide ``APS_SOLVER_CHILD_EXECUTABLE``;
tests and controlled launchers may provide ``APS_SOLVER_CHILD_COMMAND_JSON``.
"""
if not isinstance(pipeline_label, str) or not pipeline_label.strip():
raise SolverProcessError("SOLVER_REQUEST_INVALID", "pipeline_label 不能为空")
normalized_world = _json_value(world)
normalized_entries = _json_value(entries)
normalized_params = _json_value(params)
normalized_warm_start = _json_value(warm_start) if warm_start is not None else None
if not isinstance(normalized_world, dict):
raise SolverProcessError("SOLVER_REQUEST_INVALID", "world 必须是对象")
if not isinstance(normalized_entries, list) or not all(
isinstance(item, dict) for item in normalized_entries
):
raise SolverProcessError("SOLVER_REQUEST_INVALID", "entries 必须是对象数组")
if not isinstance(normalized_params, dict):
raise SolverProcessError("SOLVER_REQUEST_INVALID", "params 必须是对象")
normalized_relaxed = _json_value(list(relaxed_constraint_ids or []))
if not isinstance(normalized_relaxed, list) or not all(
isinstance(value, str) and value for value in normalized_relaxed
):
raise SolverProcessError(
"SOLVER_REQUEST_INVALID", "relaxed_constraint_ids 必须是非空字符串数组或空数组",
)
if len(normalized_relaxed) != len(set(normalized_relaxed)):
raise SolverProcessError("SOLVER_REQUEST_INVALID", "relaxed_constraint_ids 不得重复")
unknown_relaxed = set(normalized_relaxed) - _DIAGNOSTIC_RELAXABLE_CONSTRAINTS
if unknown_relaxed:
raise SolverProcessError(
"SOLVER_REQUEST_INVALID",
"relaxed_constraint_ids 含不支持约束",
{"constraintIds": sorted(unknown_relaxed)},
)
normalized_relaxed.sort()
normalized_rhs = _normalize_rhs_perturbation(rhs_perturbation)
if operation == "optimize_line_assignment":
if diagnostic_mode or rhs_diagnostic_mode or normalized_relaxed or normalized_rhs is not None:
raise SolverProcessError(
"SOLVER_REQUEST_INVALID", "普通排产 operation 禁止诊断松弛或 RHS 扰动",
)
elif operation == "diagnose_constraint_baseline":
if not diagnostic_mode or rhs_diagnostic_mode or normalized_relaxed or normalized_rhs is not None:
raise SolverProcessError(
"SOLVER_REQUEST_INVALID", "诊断基线 operation 不得携带松弛约束",
)
elif operation == "diagnose_constraint_removal":
if (
not diagnostic_mode or rhs_diagnostic_mode or len(normalized_relaxed) != 1
or normalized_rhs is not None
):
raise SolverProcessError(
"SOLVER_REQUEST_INVALID", "诊断移除 operation 必须携带一个松弛约束",
)
elif operation == "diagnose_rhs_baseline":
if not diagnostic_mode or not rhs_diagnostic_mode or normalized_relaxed or normalized_rhs is not None:
raise SolverProcessError(
"SOLVER_REQUEST_INVALID", "RHS 诊断基线不得携带松弛或扰动参数",
)
elif operation == "diagnose_rhs_perturbation":
if not diagnostic_mode or not rhs_diagnostic_mode or normalized_relaxed or normalized_rhs is None:
raise SolverProcessError(
"SOLVER_REQUEST_INVALID", "RHS 诊断重解必须携带一个严格扰动对象",
)
else:
raise SolverProcessError("SOLVER_REQUEST_INVALID", f"未知 solver operation:{operation}")
if normalized_warm_start is not None and (
not isinstance(normalized_warm_start, list)
or not all(isinstance(item, dict) for item in normalized_warm_start)
):
raise SolverProcessError("SOLVER_REQUEST_INVALID", "warm_start 必须是对象数组")
invocation_id = uuid.uuid4().hex if diagnostic_mode else None
body: dict[str, Any] = {
"protocolVersion": PROTOCOL_VERSION,
"operation": operation,
"world": normalized_world,
"entries": normalized_entries,
"params": normalized_params,
"warmStart": normalized_warm_start,
"pipelineLabel": pipeline_label,
}
if operation in {"diagnose_constraint_baseline", "diagnose_constraint_removal"}:
body["relaxedConstraintIds"] = normalized_relaxed
body["invocationId"] = invocation_id
elif rhs_diagnostic_mode:
body["rhsPerturbation"] = normalized_rhs
body["invocationId"] = invocation_id
request_id = _request_digest(body)
request = {**body, "requestId": request_id}
request_text = _canonical_json(request)
timeout = _effective_timeout(normalized_params, timeout_seconds)
command, command_kind = _child_command()
creationflags, start_new_session = _creation_options()
# Pipe-backed stdin can block the Windows communicate() writer before
# timeout supervision starts when a child never reads a large world.
# Stage the canonical request in an auto-deleting file inherited as stdin.
with tempfile.TemporaryFile(
mode="w+", encoding="utf-8", newline=""
) as request_stream:
request_stream.write(request_text)
request_stream.flush()
request_stream.seek(0)
try:
process = subprocess.Popen(
command,
stdin=request_stream,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
errors="replace",
env=_child_environment(),
shell=False,
creationflags=creationflags,
start_new_session=start_new_session,
)
except OSError as exc:
raise SolverProcessError(
"SOLVER_PROCESS_EXITED",
"无法启动求解子进程",
{"error": str(exc), "commandKind": command_kind},
) from exc
try:
stdout, stderr = process.communicate(timeout=timeout)
except subprocess.TimeoutExpired as exc:
_terminate_process_tree(process)
try:
stdout, stderr = process.communicate(timeout=1)
except (OSError, subprocess.SubprocessError):
stdout, stderr = "", ""
raise SolverProcessError(
"SOLVER_PROCESS_TIMEOUT",
"求解子进程超过父进程时限并已回收",
{
"timeoutSeconds": timeout,
"pid": process.pid,
"commandKind": command_kind,
"stdout": _captured(stdout),
"stderr": _captured(stderr),
},
) from exc
marker = _fatal_marker(stdout, stderr)
if marker is not None:
raise SolverProcessError(
"SOLVER_NATIVE_FATAL",
"求解子进程输出原生 fatal 标记",
{
"fatalMarker": marker,
"returnCode": process.returncode,
"commandKind": command_kind,
"stdout": _captured(stdout),
"stderr": _captured(stderr),
},
)
if process.returncode != 0:
raise SolverProcessError(
"SOLVER_PROCESS_EXITED",
"求解子进程异常退出",
{
"returnCode": process.returncode,
"commandKind": command_kind,
"stdout": _captured(stdout),
"stderr": _captured(stderr),
},
)
response = _response_object(stdout)
return _validate_response(
response,
request_id=request_id,
expected_world=normalized_world,
expected_entries=normalized_entries,
pipeline_label=pipeline_label,
expected_relaxed_constraint_ids=normalized_relaxed,
expected_operation=operation,
expected_invocation_id=invocation_id,
expected_diagnostic_mode=diagnostic_mode,
expected_rhs_diagnostic_mode=rhs_diagnostic_mode,
expected_rhs_perturbation=normalized_rhs,
)
def run_cp_assignment(
world: Mapping[str, Any],
entries: Sequence[Mapping[str, Any]],
params: Any,
*,
warm_start: Sequence[Mapping[str, Any]] | None = None,
pipeline_label: str,
timeout_seconds: float | None = None,
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
"""Run the production CP assignment operation; diagnostic relaxation is impossible."""
return _run_solver_request(
world,
entries,
params,
warm_start=warm_start,
pipeline_label=pipeline_label,
timeout_seconds=timeout_seconds,
operation="optimize_line_assignment",
relaxed_constraint_ids=[],
diagnostic_mode=False,
rhs_diagnostic_mode=False,
rhs_perturbation=None,
)
def run_cp_constraint_diagnostic(
world: Mapping[str, Any],
entries: Sequence[Mapping[str, Any]],
params: Any,
*,
pipeline_label: str,
relaxed_constraint_id: str | None = None,
timeout_seconds: float | None = None,
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
"""Run a non-materializable diagnostic baseline or one whole-constraint removal."""
return _run_solver_request(
world,
entries,
params,
warm_start=None,
pipeline_label=pipeline_label,
timeout_seconds=timeout_seconds,
operation=(
"diagnose_constraint_baseline"
if relaxed_constraint_id is None
else "diagnose_constraint_removal"
),
relaxed_constraint_ids=([] if relaxed_constraint_id is None else [relaxed_constraint_id]),
diagnostic_mode=True,
rhs_diagnostic_mode=False,
rhs_perturbation=None,
)
def run_cp_rhs_diagnostic(
world: Mapping[str, Any],
entries: Sequence[Mapping[str, Any]],
params: Any,
*,
pipeline_label: str,
rhs_perturbation: Mapping[str, Any] | None = None,
timeout_seconds: float | None = None,
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
"""Run a deterministic non-materializable RHS baseline or one parameter increment."""
return _run_solver_request(
world,
entries,
params,
warm_start=None,
pipeline_label=pipeline_label,
timeout_seconds=timeout_seconds,
operation=(
"diagnose_rhs_baseline"
if rhs_perturbation is None
else "diagnose_rhs_perturbation"
),
relaxed_constraint_ids=[],
diagnostic_mode=True,
rhs_diagnostic_mode=True,
rhs_perturbation=rhs_perturbation,
)
__all__ = [
"ERROR_MARKER",
"PROTOCOL_VERSION",
"SUCCESS_MARKER",
"SolverProcessError",
"run_cp_assignment",
"run_cp_constraint_diagnostic",
"run_cp_rhs_diagnostic",
]