aps-agent/server/aps_domain/cp_marginal.py

492 lines
19 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.

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} 次整约束移除重解;"
"结果为业务目标反事实差值/区间,不是影子价。"
)
),
}