aps-agent/server/aps_domain/cp_marginal.py

492 lines
19 KiB
Python
Raw Normal View History

from __future__ import annotations
import copy
import hashlib
import json
from collections.abc import Callable, Mapping
from datetime import date
from typing import Any
from server.aps_domain.constraints import engine_constraint_flags
from server.aps_domain.params import get_schedule_params
from server.engines.base import EngineParams
from server.engines.cp_engine import CP_DIAGNOSTIC_RELAXABLE_CONSTRAINTS, CpSatEngine
from server.engines.solver_process import (
SolverProcessError,
run_cp_constraint_diagnostic,
)
World = dict[str, Any]
_METHOD = "cp-one-constraint-at-a-time-resolve.v1"
_OBJECTIVE_UNIT = "weighted-tardiness-minute"
_LABELS = {
"C1_precedence": "工艺先后序",
"C2_no_overlap": "工位/设备独占",
"C3_calendar": "班次日历窗口",
"C7_capacity": "产线日产能上限",
"C10_changeover": "顺序相关换型",
"C11_freeze": "冻结窗口",
"C12_team": "班组并发容量",
"C12_tooling": "工装并发容量",
}
def _resolve_start_date(world: World, start_date: str | None) -> tuple[str, str]:
source = "request" if start_date is not None else "world.businessDate"
raw = start_date if start_date is not None else world.get("businessDate")
if not isinstance(raw, str) or not raw.strip():
raise ValueError("startDate 必填;仅可省略于 world.businessDate 已设置时")
value = raw.strip()
try:
parsed = date.fromisoformat(value)
except ValueError as exc:
raise ValueError("startDate 须为 YYYY-MM-DD") from exc
if parsed.isoformat() != value:
raise ValueError("startDate 须为 YYYY-MM-DD")
return value, source
def _digest(payload: Any) -> str:
text = json.dumps(
payload, ensure_ascii=False, allow_nan=False, sort_keys=True,
separators=(",", ":"), default=str,
)
return hashlib.sha256(text.encode("utf-8")).hexdigest()
def _solver_snapshot(meta: dict[str, Any]) -> dict[str, Any]:
process = meta.get("solverProcess") or {}
raw_c3 = meta.get("c3Calendar")
c3_calendar = (
{
key: raw_c3.get(key)
for key in (
"schemaVersion", "modelMode", "pausePolicy", "anchor", "horizonMinutes",
"coverageStart", "coverageEnd", "coverageComplete",
"normalizedCalendarDigest", "calendarBucketCount", "lineWindowCounts",
"segmentIntervalCount", "segmentIntervalLimit", "maxSegmentsPerOperation",
"selectedMode", "active",
)
}
if isinstance(raw_c3, Mapping)
else None
)
return {
"status": meta.get("status"),
"objective": meta.get("objective"),
"bestBound": meta.get("bestBound"),
"gap": meta.get("gap"),
"wallTimeSec": meta.get("wallTimeSec"),
"timeLimitSec": meta.get("timeLimitSec"),
"assumptionConstraints": meta.get("assumptionConstraints") or [],
"activeAssumptionConstraints": meta.get("activeAssumptionConstraints") or [],
"enforcedAssumptionConstraints": meta.get("enforcedAssumptionConstraints") or [],
"relaxedConstraintIds": meta.get("relaxedConstraintIds") or [],
"constraintInstanceCounts": meta.get("constraintInstanceCounts") or {},
"diagnosticMode": meta.get("diagnosticMode"),
"numSearchWorkers": meta.get("numSearchWorkers"),
"randomSeed": meta.get("randomSeed"),
"requestId": process.get("requestId"),
"invocationId": process.get("invocationId"),
"operation": process.get("operation"),
"runtimeSafe": (process.get("runtimeIdentity") or {}).get("safe"),
"c3Calendar": c3_calendar,
}
def _model_identity(snapshot: dict[str, Any]) -> dict[str, Any]:
c3_calendar = snapshot.get("c3Calendar")
c3_topology = (
{
key: c3_calendar.get(key)
for key in (
"schemaVersion", "modelMode", "pausePolicy", "anchor", "horizonMinutes",
"coverageStart", "coverageEnd", "coverageComplete",
"normalizedCalendarDigest", "calendarBucketCount", "lineWindowCounts",
"segmentIntervalCount", "segmentIntervalLimit", "maxSegmentsPerOperation",
)
}
if isinstance(c3_calendar, Mapping)
else None
)
return {
"assumptionConstraints": snapshot.get("assumptionConstraints") or [],
"activeAssumptionConstraints": snapshot.get("activeAssumptionConstraints") or [],
"constraintInstanceCounts": snapshot.get("constraintInstanceCounts") or {},
"c3Topology": c3_topology,
"objectiveUnit": _OBJECTIVE_UNIT,
}
def run_cp_marginal_resolve(
world: World,
*,
start_date: str | None = None,
strategy: str = "COMPREHENSIVE",
planning_horizon_days: int = 14,
time_limit_seconds: float = 4.0,
constraint_ids: list[str] | None = None,
cancel_check: Callable[[], None] | None = None,
) -> dict[str, Any]:
"""Compare the CP business objective after whole-constraint removal, one at a time."""
raw_constraint_ids = (
list(CP_DIAGNOSTIC_RELAXABLE_CONSTRAINTS)
if constraint_ids is None
else list(constraint_ids)
)
if not raw_constraint_ids:
raise ValueError("constraintIds 不能为空")
if len(raw_constraint_ids) != len(set(raw_constraint_ids)):
raise ValueError("constraintIds 不得重复")
requested = sorted(raw_constraint_ids)
unknown = set(requested) - CP_DIAGNOSTIC_RELAXABLE_CONSTRAINTS
if unknown:
raise ValueError(f"不支持 CP 诊断约束:{sorted(unknown)}")
if not 1 <= int(planning_horizon_days) <= 90:
raise ValueError("planningHorizonDays 须在 1~90")
if not 0.5 <= float(time_limit_seconds) <= 10.0:
raise ValueError("timeLimitSeconds 须在 0.5~10")
resolved_start, start_source = _resolve_start_date(world, start_date)
sandbox = copy.deepcopy(world)
schedule_params = get_schedule_params(sandbox)
params = EngineParams(
orderIds=[],
engineType="CP",
strategyTemplate=strategy,
planningHorizonDays=int(planning_horizon_days),
startDate=resolved_start,
constraints=engine_constraint_flags(sandbox),
deliveryBufferRatio=float(schedule_params.get("deliveryBufferRatio") or 0.95),
freezeWindowHours=float(schedule_params.get("freezeWindowHours") or 0.0),
timeLimitSeconds=float(time_limit_seconds),
name="cp-marginal-diagnostic",
)
entries, _, source_count = CpSatEngine().collect_and_order(sandbox, params)
world_digest = _digest(sandbox)
entries_digest = _digest(entries)
params_digest = _digest(params.model_dump(mode="json"))
objective_spec = {
"kind": "min-weighted-tardiness",
"unit": _OBJECTIVE_UNIT,
"late": "max(0, jobEndMin-dueMin)",
"weight": "customerLevelWeight*100 + rush/forecast adjustments",
}
objective_spec_digest = _digest(objective_spec)
digest = _digest({
"worldDigest": world_digest,
"entriesDigest": entries_digest,
"paramsDigest": params_digest,
"objectiveSpecDigest": objective_spec_digest,
"constraintIds": requested,
})
common = {
"method": _METHOD,
"modelScope": "operation-level-cp-sat-re-solve",
"counterfactualMode": "one-constraint-at-a-time-whole-removal",
"objectiveUnit": _OBJECTIVE_UNIT,
"isDualValue": False,
"isUnitMarginalValue": False,
"nonAdditiveAcrossConstraints": True,
"cancellationGranularity": "between-solves",
"cancellationDoesNotInterruptActiveSolve": True,
"activeSolveSupervisionTimeoutSeconds": max(
15.0, float(time_limit_seconds) + 12.0,
),
"cancellationCleanupTimeoutSeconds": 11.0,
"cancellationLatencyUpperBoundSeconds": (
max(15.0, float(time_limit_seconds) + 12.0) + 11.0
),
"track": "fixed",
"strategy": strategy,
"startDate": resolved_start,
"startDateSource": start_source,
"planningHorizonDays": int(planning_horizon_days),
"timeLimitSeconds": float(time_limit_seconds),
"inputDigest": digest,
"worldDigest": world_digest,
"entriesDigest": entries_digest,
"paramsDigest": params_digest,
"objectiveSpec": objective_spec,
"objectiveSpecDigest": objective_spec_digest,
"entryCount": len(entries),
"sourceOrderCount": source_count,
}
if not entries:
return {
**common,
"status": "no_demand",
"evaluations": 0,
"baseline": None,
"rows": [],
"summary": "当前 fixed 轨没有可进入 CP-SAT 的待排订单,未执行重解。",
}
if cancel_check is not None:
cancel_check()
try:
_, baseline_meta = run_cp_constraint_diagnostic(
sandbox,
entries,
params,
pipeline_label="CP-MARGINAL-BASELINE",
)
except SolverProcessError as exc:
return {
**common,
"status": "baseline_solver_error",
"evaluations": 1,
"baseline": None,
"rows": [],
"summary": f"CP 基线求解失败:{exc.code}",
"error": exc.as_dict(),
}
if cancel_check is not None:
cancel_check()
baseline = _solver_snapshot(baseline_meta)
active = set(baseline_meta.get("activeAssumptionConstraints") or [])
baseline_status = str(baseline.get("status") or "")
baseline_objective = baseline.get("objective")
if baseline_status not in {"OPTIMAL", "FEASIBLE", "INFEASIBLE"}:
return {
**common,
"status": "baseline_unavailable",
"evaluations": 1,
"baseline": baseline,
"rows": [],
"summary": f"CP 基线状态 {baseline_status or 'UNKNOWN'},无法比较业务目标。",
}
if baseline_status in {"OPTIMAL", "FEASIBLE"} and not isinstance(
baseline_objective, (int, float)
):
return {
**common,
"status": "baseline_unavailable",
"evaluations": 1,
"baseline": baseline,
"rows": [],
"summary": "CP 基线缺少可比较 objective。",
}
rows: list[dict[str, Any]] = []
evaluations = 1
monotonicity_violation = False
for constraint_id in requested:
base_row = {
"constraintId": constraint_id,
"constraintName": _LABELS[constraint_id],
"wholeConstraintRemoval": True,
"isDualValue": False,
"isUnitMarginalValue": False,
"nonAdditiveAcrossConstraints": True,
}
if constraint_id not in active:
rows.append({
**base_row,
"status": "inactive",
"reason": "该约束在本次 CP 模型中未启用,未执行反事实重解。",
"objectiveImprovement": None,
})
continue
if cancel_check is not None:
cancel_check()
evaluations += 1
try:
_, relaxed_meta = run_cp_constraint_diagnostic(
sandbox,
entries,
params,
pipeline_label=f"CP-MARGINAL-{constraint_id}",
relaxed_constraint_id=constraint_id,
)
except SolverProcessError as exc:
rows.append({
**base_row,
"status": "solver_error",
"reason": exc.code,
"error": exc.as_dict(),
"objectiveImprovement": None,
})
continue
if cancel_check is not None:
cancel_check()
relaxed_snapshot = _solver_snapshot(relaxed_meta)
if _model_identity(relaxed_snapshot) != _model_identity(baseline):
rows.append({
**base_row,
"status": "solver_error",
"reason": "基线与松弛重解的模型实例清单不一致",
"relaxed": relaxed_snapshot,
"objectiveImprovement": None,
})
continue
relaxed_status = str(relaxed_snapshot.get("status") or "")
relaxed_objective = relaxed_snapshot.get("objective")
if baseline_status == "INFEASIBLE":
if relaxed_status in {"OPTIMAL", "FEASIBLE"}:
rows.append({
**base_row,
"status": "restores_feasibility",
"comparisonQuality": (
"exact-optimal" if relaxed_status == "OPTIMAL" else "feasible-only"
),
"relaxed": relaxed_snapshot,
"objectiveImprovement": None,
"interpretation": "基线不可行,完整移除该约束后恢复可行;不计算目标差。",
})
elif relaxed_status == "INFEASIBLE":
rows.append({
**base_row,
"status": "does_not_restore_feasibility",
"reason": "基线和松弛后均不可行,整约束移除未恢复可行性。",
"relaxed": relaxed_snapshot,
"objectiveImprovement": None,
})
elif relaxed_status == "MODEL_INVALID":
rows.append({
**base_row,
"status": "solver_error",
"reason": "松弛后 CP 模型无效,无法得出可行性结论。",
"relaxed": relaxed_snapshot,
"objectiveImprovement": None,
})
else:
rows.append({
**base_row,
"status": "unavailable",
"reason": f"松弛后状态 {relaxed_status or 'UNKNOWN'},未得出可行性结论。",
"relaxed": relaxed_snapshot,
"objectiveImprovement": None,
})
continue
if relaxed_status == "MODEL_INVALID":
rows.append({
**base_row,
"status": "solver_error",
"reason": "松弛后 CP 模型无效,无法比较业务目标。",
"relaxed": relaxed_snapshot,
"objectiveImprovement": None,
})
continue
if relaxed_status == "INFEASIBLE":
monotonicity_violation = True
rows.append({
**base_row,
"status": "monotonicity_violation",
"reason": "移除约束后反而不可行,违反单调性,整份报告失败关闭。",
"relaxed": relaxed_snapshot,
"objectiveImprovement": None,
})
continue
if relaxed_status not in {"OPTIMAL", "FEASIBLE"} or not isinstance(
relaxed_objective, (int, float)
):
rows.append({
**base_row,
"status": "unavailable",
"reason": f"松弛后求解状态 {relaxed_status or 'UNKNOWN'}",
"relaxed": relaxed_snapshot,
"objectiveImprovement": None,
})
continue
exact = baseline_status == "OPTIMAL" and relaxed_status == "OPTIMAL"
incumbent_difference = float(baseline_objective) - float(relaxed_objective)
if exact:
if incumbent_difference < -1e-6:
monotonicity_violation = True
rows.append({
**base_row,
"status": "monotonicity_violation",
"reason": "OPTIMAL 结果显示移除约束后目标恶化,违反单调性。",
"relaxed": relaxed_snapshot,
"objectiveImprovement": None,
})
continue
rows.append({
**base_row,
"status": "available",
"comparisonQuality": "exact-optimal",
"isExactCounterfactual": True,
"baselineObjective": float(baseline_objective),
"relaxedObjective": float(relaxed_objective),
"objectiveImprovement": round(incumbent_difference, 6),
"improvementLowerBound": round(incumbent_difference, 6),
"improvementUpperBound": round(incumbent_difference, 6),
"relaxed": relaxed_snapshot,
"interpretation": (
"完整移除该约束后 CP 加权延期最优目标的精确 one-at-a-time 改善;"
"该值不是对偶,也不是每分钟边际价格。"
),
})
continue
baseline_bound = float(baseline["bestBound"])
relaxed_bound = float(relaxed_snapshot["bestBound"])
lower = max(0.0, baseline_bound - float(relaxed_objective))
upper = float(baseline_objective) - relaxed_bound
if upper < -1e-6:
monotonicity_violation = True
rows.append({
**base_row,
"status": "monotonicity_violation",
"reason": "FEASIBLE 界限显示移除约束可能恶化最优目标,报告失败关闭。",
"relaxed": relaxed_snapshot,
"objectiveImprovement": None,
})
continue
rows.append({
**base_row,
"status": "bounded",
"comparisonQuality": "bound-interval",
"isExactCounterfactual": False,
"objectiveImprovement": None,
"incumbentDifference": round(incumbent_difference, 6),
"improvementLowerBound": round(lower, 6),
"improvementUpperBound": round(max(0.0, upper), 6),
"relaxed": relaxed_snapshot,
"interpretation": (
"至少一侧仅 FEASIBLE,只报告由 incumbent/bound 推导的改善区间;"
"incumbent 差值不用于排序。"
),
})
if monotonicity_violation:
for row in rows:
if row["status"] != "monotonicity_violation":
row["suppressedStatus"] = row["status"]
row["status"] = "suppressed"
row["reason"] = "报告存在单调性违反,本行改善值已抑制。"
for field in (
"objectiveImprovement", "objectiveDelta", "incumbentDifference",
"improvementLowerBound", "improvementUpperBound",
):
row[field] = None
rows.sort(key=lambda row: (
0 if row["status"] == "available" else 1,
-(row.get("objectiveImprovement") or 0.0),
row["constraintId"],
))
if cancel_check is not None:
cancel_check()
partial = any(row["status"] in {"solver_error", "unavailable"} for row in rows)
report_status = (
"monotonicity_violation" if monotonicity_violation
else "partial" if partial
else "completed"
)
return {
**common,
"status": report_status,
"evaluations": evaluations,
"baseline": baseline,
"rows": rows,
"summary": (
"检测到约束移除单调性违反,报告失败关闭。"
if monotonicity_violation
else "部分约束重解失败或未得出结论;仅保留可验证行。"
if partial
else (
f"完成 CP 基线 + {evaluations - 1} 次整约束移除重解;"
"结果为业务目标反事实差值/区间,不是影子价。"
)
),
}