1633 lines
68 KiB
Python
1633 lines
68 KiB
Python
"""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",
|
||
]
|