765 lines
28 KiB
Python
765 lines
28 KiB
Python
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import copy
|
||
|
|
from datetime import date
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
from fastapi.testclient import TestClient
|
||
|
|
|
||
|
|
from server.agent_core.async_jobs import JobCancelled, JobQueue
|
||
|
|
from server.aps_domain.cp_marginal import run_cp_marginal_resolve
|
||
|
|
from server.engines import get_engine
|
||
|
|
from server.engines.base import EngineParams
|
||
|
|
from server.engines.cp_engine import CpSatEngine
|
||
|
|
from server.engines.solver_process import (
|
||
|
|
PROTOCOL_VERSION,
|
||
|
|
SolverProcessError,
|
||
|
|
_validate_result_semantics,
|
||
|
|
run_cp_constraint_diagnostic,
|
||
|
|
)
|
||
|
|
from server.engines.solver_worker import _request_digest, _validate_request
|
||
|
|
from server.state.seed import build_demo_world, empty_world
|
||
|
|
from tests.auth_provider import install_test_auth
|
||
|
|
|
||
|
|
|
||
|
|
def _align_shift_calendar(world: dict, start_date: str) -> None:
|
||
|
|
rows = world.get("shiftCalendar") or []
|
||
|
|
if not rows:
|
||
|
|
return
|
||
|
|
source_start = min(date.fromisoformat(str(row["date"])) for row in rows)
|
||
|
|
offset = date.fromisoformat(start_date) - source_start
|
||
|
|
for row in rows:
|
||
|
|
row["date"] = (date.fromisoformat(str(row["date"])) + offset).isoformat()
|
||
|
|
|
||
|
|
|
||
|
|
def _tight_two_order_world() -> dict:
|
||
|
|
world = build_demo_world()
|
||
|
|
_align_shift_calendar(world, "2026-08-03")
|
||
|
|
orders = [row for row in world["salesOrders"] if row["items"][0]["productId"] == 1][:2]
|
||
|
|
for order in orders:
|
||
|
|
order["deliveryDate"] = "2026-08-03"
|
||
|
|
keep = {row["id"] for row in orders}
|
||
|
|
world["salesOrders"] = [row for row in world["salesOrders"] if row["id"] in keep]
|
||
|
|
world["constraintProfile"] = {
|
||
|
|
"profileId": "test-isolate-c2",
|
||
|
|
"constraints": {"C7_capacity": {"enabled": False}},
|
||
|
|
}
|
||
|
|
return world
|
||
|
|
|
||
|
|
|
||
|
|
def _tight_c2_exact_world() -> dict:
|
||
|
|
world = _tight_two_order_world()
|
||
|
|
routing_id = next(
|
||
|
|
row["id"] for row in world["routings"]
|
||
|
|
if row["productId"] == 1 and row["isDefault"]
|
||
|
|
)
|
||
|
|
world["routingSteps"] = [
|
||
|
|
row for row in world["routingSteps"]
|
||
|
|
if row["routingId"] == routing_id and row["sequenceNo"] == 1
|
||
|
|
]
|
||
|
|
return world
|
||
|
|
|
||
|
|
|
||
|
|
def _counter():
|
||
|
|
values: dict[str, int] = {}
|
||
|
|
|
||
|
|
def next_id(kind: str) -> int:
|
||
|
|
values[kind] = values.get(kind, 0) + 1
|
||
|
|
return values[kind]
|
||
|
|
|
||
|
|
return next_id
|
||
|
|
|
||
|
|
|
||
|
|
def _entries_and_params(world: dict, **overrides):
|
||
|
|
values = {
|
||
|
|
"orderIds": [],
|
||
|
|
"engineType": "CP",
|
||
|
|
"strategyTemplate": "COMPREHENSIVE",
|
||
|
|
"planningHorizonDays": 14,
|
||
|
|
"startDate": "2026-08-03",
|
||
|
|
"timeLimitSeconds": 4,
|
||
|
|
"constraints": {"capacity": False},
|
||
|
|
}
|
||
|
|
values.update(overrides)
|
||
|
|
params = EngineParams(**values)
|
||
|
|
entries, _, _ = CpSatEngine().collect_and_order(world, params)
|
||
|
|
return entries, params
|
||
|
|
|
||
|
|
|
||
|
|
def test_real_c2_removal_improves_optimal_cp_objective_without_mutating_world():
|
||
|
|
world = _tight_c2_exact_world()
|
||
|
|
before = copy.deepcopy(world)
|
||
|
|
report = run_cp_marginal_resolve(
|
||
|
|
world,
|
||
|
|
start_date="2026-08-03",
|
||
|
|
planning_horizon_days=3,
|
||
|
|
constraint_ids=["C2_no_overlap"],
|
||
|
|
time_limit_seconds=8,
|
||
|
|
)
|
||
|
|
assert world == before
|
||
|
|
assert report["status"] == "completed"
|
||
|
|
assert report["evaluations"] == 2
|
||
|
|
assert report["baseline"]["status"] == "OPTIMAL"
|
||
|
|
assert report["baseline"]["operation"] == "diagnose_constraint_baseline"
|
||
|
|
assert report["baseline"]["numSearchWorkers"] == 1
|
||
|
|
assert report["baseline"]["randomSeed"] == 0
|
||
|
|
row = report["rows"][0]
|
||
|
|
assert row["status"] == "available"
|
||
|
|
assert row["comparisonQuality"] == "exact-optimal"
|
||
|
|
assert row["objectiveImprovement"] == 2100.0
|
||
|
|
assert row["relaxed"]["operation"] == "diagnose_constraint_removal"
|
||
|
|
assert row["relaxed"]["relaxedConstraintIds"] == ["C2_no_overlap"]
|
||
|
|
assert row["relaxed"]["invocationId"] != report["baseline"]["invocationId"]
|
||
|
|
assert report["objectiveSpecDigest"]
|
||
|
|
assert report["worldDigest"] and report["entriesDigest"] and report["paramsDigest"]
|
||
|
|
|
||
|
|
|
||
|
|
def test_inactive_assumption_has_no_re_solve_row():
|
||
|
|
world = _tight_two_order_world()
|
||
|
|
for workstation in world["workstations"]:
|
||
|
|
workstation.pop("teamId", None)
|
||
|
|
world["teams"] = []
|
||
|
|
report = run_cp_marginal_resolve(
|
||
|
|
world,
|
||
|
|
start_date="2026-08-03",
|
||
|
|
constraint_ids=["C12_team"],
|
||
|
|
time_limit_seconds=4,
|
||
|
|
)
|
||
|
|
assert report["evaluations"] == 1
|
||
|
|
assert report["rows"][0]["status"] == "inactive"
|
||
|
|
assert report["baseline"]["constraintInstanceCounts"]["C12_team"] == 0
|
||
|
|
|
||
|
|
|
||
|
|
def test_diagnostic_invocation_id_prevents_same_snapshot_response_replay():
|
||
|
|
world = _tight_two_order_world()
|
||
|
|
entries, params = _entries_and_params(world)
|
||
|
|
_, first = run_cp_constraint_diagnostic(
|
||
|
|
world, entries, params, pipeline_label="REPLAY-BASELINE",
|
||
|
|
)
|
||
|
|
_, second = run_cp_constraint_diagnostic(
|
||
|
|
world, entries, params, pipeline_label="REPLAY-BASELINE",
|
||
|
|
)
|
||
|
|
assert first["objective"] == second["objective"]
|
||
|
|
assert first["solverProcess"]["invocationId"] != second["solverProcess"]["invocationId"]
|
||
|
|
assert first["solverProcess"]["requestId"] != second["solverProcess"]["requestId"]
|
||
|
|
assert first["solverProcess"]["protocolVersion"] == "aps.solver-process.v2"
|
||
|
|
|
||
|
|
|
||
|
|
def test_c11_removal_disables_start_gate_and_frozen_obstacle_presence():
|
||
|
|
world = build_demo_world()
|
||
|
|
rule_params = EngineParams(
|
||
|
|
orderIds=[], engineType="RULE", strategyTemplate="COMPREHENSIVE",
|
||
|
|
planningHorizonDays=14, startDate="2026-08-03",
|
||
|
|
)
|
||
|
|
get_engine("RULE").solve(world, rule_params, _counter())
|
||
|
|
for work_order in world["workOrders"][:3]:
|
||
|
|
work_order["isFrozen"] = True
|
||
|
|
entries, params = _entries_and_params(world, freezeWindowHours=24.0)
|
||
|
|
_, baseline = run_cp_constraint_diagnostic(
|
||
|
|
world, entries, params, pipeline_label="C11-BASELINE",
|
||
|
|
)
|
||
|
|
_, relaxed = run_cp_constraint_diagnostic(
|
||
|
|
world, entries, params, pipeline_label="C11-REMOVAL",
|
||
|
|
relaxed_constraint_id="C11_freeze",
|
||
|
|
)
|
||
|
|
assert baseline["constraintInstanceCounts"]["C11_freeze"] > baseline["frozenCount"]
|
||
|
|
baseline_new = [slot for slot in baseline["operationSlots"] if not slot["isFrozen"]]
|
||
|
|
relaxed_new = [slot for slot in relaxed["operationSlots"] if not slot["isFrozen"]]
|
||
|
|
assert baseline_new and all(slot["startMin"] >= 24 * 60 for slot in baseline_new)
|
||
|
|
assert relaxed_new and any(slot["startMin"] < 24 * 60 for slot in relaxed_new)
|
||
|
|
assert relaxed["relaxedConstraintIds"] == ["C11_freeze"]
|
||
|
|
assert relaxed["frozenCount"] == 0
|
||
|
|
assert not any(slot["isFrozen"] for slot in relaxed["operationSlots"])
|
||
|
|
|
||
|
|
|
||
|
|
def test_c2_removal_also_removes_frozen_obstacle_no_overlap():
|
||
|
|
world = build_demo_world()
|
||
|
|
world["workOrders"] = [
|
||
|
|
{
|
||
|
|
"id": index,
|
||
|
|
"orderNo": f"FROZEN-{index}",
|
||
|
|
"workstationId": 1,
|
||
|
|
"lineId": 1,
|
||
|
|
"plannedStartTime": "2026-08-03 08:00",
|
||
|
|
"plannedEndTime": "2026-08-03 10:00",
|
||
|
|
"isFrozen": True,
|
||
|
|
}
|
||
|
|
for index in (1, 2)
|
||
|
|
]
|
||
|
|
entries, params = _entries_and_params(world, freezeWindowHours=24.0)
|
||
|
|
_, baseline = run_cp_constraint_diagnostic(
|
||
|
|
world, entries, params, pipeline_label="C2-FROZEN-BASELINE",
|
||
|
|
)
|
||
|
|
_, relaxed = run_cp_constraint_diagnostic(
|
||
|
|
world, entries, params, pipeline_label="C2-FROZEN-REMOVAL",
|
||
|
|
relaxed_constraint_id="C2_no_overlap",
|
||
|
|
)
|
||
|
|
assert baseline["status"] == "INFEASIBLE"
|
||
|
|
assert relaxed["status"] in {"OPTIMAL", "FEASIBLE"}
|
||
|
|
assert relaxed["frozenCount"] == 0
|
||
|
|
assert not any(slot["isFrozen"] for slot in relaxed["operationSlots"])
|
||
|
|
|
||
|
|
|
||
|
|
def test_no_demand_is_explicit_and_does_not_spawn_solver():
|
||
|
|
world = empty_world()
|
||
|
|
result = run_cp_marginal_resolve(
|
||
|
|
world,
|
||
|
|
start_date="2026-08-03",
|
||
|
|
constraint_ids=["C2_no_overlap"],
|
||
|
|
)
|
||
|
|
assert result["status"] == "no_demand"
|
||
|
|
assert result["evaluations"] == 0
|
||
|
|
assert result["rows"] == []
|
||
|
|
|
||
|
|
|
||
|
|
def test_explicit_empty_constraint_list_is_rejected():
|
||
|
|
with pytest.raises(ValueError, match="不能为空"):
|
||
|
|
run_cp_marginal_resolve(
|
||
|
|
_tight_two_order_world(),
|
||
|
|
start_date="2026-08-03",
|
||
|
|
constraint_ids=[],
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def test_cancellation_contract_exposes_parent_supervision_latency():
|
||
|
|
report = run_cp_marginal_resolve(
|
||
|
|
empty_world(),
|
||
|
|
start_date="2026-08-03",
|
||
|
|
constraint_ids=["C2_no_overlap"],
|
||
|
|
time_limit_seconds=4,
|
||
|
|
)
|
||
|
|
assert report["cancellationGranularity"] == "between-solves"
|
||
|
|
assert report["cancellationDoesNotInterruptActiveSolve"] is True
|
||
|
|
assert report["activeSolveSupervisionTimeoutSeconds"] == 16.0
|
||
|
|
assert report["cancellationCleanupTimeoutSeconds"] == 11.0
|
||
|
|
assert report["cancellationLatencyUpperBoundSeconds"] == 27.0
|
||
|
|
|
||
|
|
|
||
|
|
def test_cancellation_is_checked_after_baseline_before_partial_rows_escape():
|
||
|
|
world = _tight_two_order_world()
|
||
|
|
checks = 0
|
||
|
|
|
||
|
|
def cancel_after_baseline():
|
||
|
|
nonlocal checks
|
||
|
|
checks += 1
|
||
|
|
if checks == 2:
|
||
|
|
raise JobCancelled()
|
||
|
|
|
||
|
|
with pytest.raises(JobCancelled):
|
||
|
|
run_cp_marginal_resolve(
|
||
|
|
world,
|
||
|
|
start_date="2026-08-03",
|
||
|
|
constraint_ids=["C2_no_overlap"],
|
||
|
|
time_limit_seconds=4,
|
||
|
|
cancel_check=cancel_after_baseline,
|
||
|
|
)
|
||
|
|
assert checks == 2
|
||
|
|
|
||
|
|
|
||
|
|
def _fake_meta(
|
||
|
|
*,
|
||
|
|
status: str,
|
||
|
|
objective: float | None,
|
||
|
|
bound: float | None,
|
||
|
|
relaxed: list[str],
|
||
|
|
) -> dict:
|
||
|
|
return {
|
||
|
|
"status": status,
|
||
|
|
"objective": objective,
|
||
|
|
"bestBound": bound,
|
||
|
|
"gap": None if objective is None else round(abs(objective - bound) / abs(objective), 6),
|
||
|
|
"assumptionConstraints": ["C2_no_overlap"],
|
||
|
|
"activeAssumptionConstraints": ["C2_no_overlap"],
|
||
|
|
"enforcedAssumptionConstraints": [] if relaxed else ["C2_no_overlap"],
|
||
|
|
"relaxedConstraintIds": relaxed,
|
||
|
|
"constraintInstanceCounts": {"C2_no_overlap": 1},
|
||
|
|
"diagnosticMode": True,
|
||
|
|
"numSearchWorkers": 1,
|
||
|
|
"randomSeed": 0,
|
||
|
|
"solverProcess": {
|
||
|
|
"requestId": "req",
|
||
|
|
"invocationId": "inv",
|
||
|
|
"operation": (
|
||
|
|
"diagnose_constraint_removal" if relaxed else "diagnose_constraint_baseline"
|
||
|
|
),
|
||
|
|
"runtimeIdentity": {"safe": True},
|
||
|
|
},
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
def _fake_c3_calendar(*, relaxed: bool, digest: str = "0" * 64) -> dict:
|
||
|
|
return {
|
||
|
|
"schemaVersion": "cp-calendar-segmented.v1",
|
||
|
|
"modelMode": "assumption-gated-dual-mode",
|
||
|
|
"pausePolicy": "calendar-boundary-only",
|
||
|
|
"anchor": "2026-08-03 08:00",
|
||
|
|
"horizonMinutes": 40320,
|
||
|
|
"coverageStart": "2026-08-03",
|
||
|
|
"coverageEnd": "2026-08-31",
|
||
|
|
"coverageComplete": True,
|
||
|
|
"normalizedCalendarDigest": digest,
|
||
|
|
"calendarBucketCount": 29,
|
||
|
|
"lineWindowCounts": {"1": 80},
|
||
|
|
"segmentIntervalCount": 160,
|
||
|
|
"segmentIntervalLimit": 50000,
|
||
|
|
"maxSegmentsPerOperation": 80,
|
||
|
|
"selectedMode": "continuous" if relaxed else "calendar-boundary-only",
|
||
|
|
"active": not relaxed,
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
def test_c3_calendar_topology_drift_is_solver_error(monkeypatch):
|
||
|
|
from server.aps_domain import cp_marginal
|
||
|
|
|
||
|
|
def fake_run(*_args, relaxed_constraint_id=None, **_kwargs):
|
||
|
|
relaxed = relaxed_constraint_id is not None
|
||
|
|
meta = _fake_meta(
|
||
|
|
status="OPTIMAL", objective=100.0, bound=100.0,
|
||
|
|
relaxed=["C3_calendar"] if relaxed else [],
|
||
|
|
)
|
||
|
|
meta["assumptionConstraints"] = ["C3_calendar"]
|
||
|
|
meta["activeAssumptionConstraints"] = ["C3_calendar"]
|
||
|
|
meta["enforcedAssumptionConstraints"] = [] if relaxed else ["C3_calendar"]
|
||
|
|
meta["constraintInstanceCounts"] = {"C3_calendar": 1}
|
||
|
|
meta["c3Calendar"] = _fake_c3_calendar(
|
||
|
|
relaxed=relaxed, digest=("1" * 64 if relaxed else "0" * 64),
|
||
|
|
)
|
||
|
|
return [], meta
|
||
|
|
|
||
|
|
monkeypatch.setattr(cp_marginal, "run_cp_constraint_diagnostic", fake_run)
|
||
|
|
report = run_cp_marginal_resolve(
|
||
|
|
_tight_c2_exact_world(), start_date="2026-08-03",
|
||
|
|
constraint_ids=["C3_calendar"],
|
||
|
|
)
|
||
|
|
assert report["rows"][0]["status"] == "solver_error"
|
||
|
|
assert "模型实例清单不一致" in report["rows"][0]["reason"]
|
||
|
|
|
||
|
|
|
||
|
|
def test_feasible_comparison_reports_bounds_not_incumbent_improvement(monkeypatch):
|
||
|
|
from server.aps_domain import cp_marginal
|
||
|
|
|
||
|
|
calls = 0
|
||
|
|
|
||
|
|
def fake_run(*_args, relaxed_constraint_id=None, **_kwargs):
|
||
|
|
nonlocal calls
|
||
|
|
calls += 1
|
||
|
|
if relaxed_constraint_id is None:
|
||
|
|
return [], _fake_meta(status="FEASIBLE", objective=100.0, bound=80.0, relaxed=[])
|
||
|
|
return [], _fake_meta(
|
||
|
|
status="FEASIBLE", objective=70.0, bound=50.0, relaxed=[relaxed_constraint_id],
|
||
|
|
)
|
||
|
|
|
||
|
|
monkeypatch.setattr(cp_marginal, "run_cp_constraint_diagnostic", fake_run)
|
||
|
|
report = run_cp_marginal_resolve(
|
||
|
|
_tight_two_order_world(),
|
||
|
|
start_date="2026-08-03",
|
||
|
|
constraint_ids=["C2_no_overlap"],
|
||
|
|
)
|
||
|
|
row = report["rows"][0]
|
||
|
|
assert calls == 2
|
||
|
|
assert row["status"] == "bounded"
|
||
|
|
assert row["objectiveImprovement"] is None
|
||
|
|
assert row["incumbentDifference"] == 30.0
|
||
|
|
assert row["improvementLowerBound"] == 10.0
|
||
|
|
assert row["improvementUpperBound"] == 50.0
|
||
|
|
|
||
|
|
|
||
|
|
def test_feasible_to_infeasible_is_monotonicity_violation(monkeypatch):
|
||
|
|
from server.aps_domain import cp_marginal
|
||
|
|
|
||
|
|
def fake_run(*_args, relaxed_constraint_id=None, **_kwargs):
|
||
|
|
if relaxed_constraint_id is None:
|
||
|
|
return [], _fake_meta(status="OPTIMAL", objective=100.0, bound=100.0, relaxed=[])
|
||
|
|
return [], _fake_meta(
|
||
|
|
status="INFEASIBLE", objective=None, bound=None, relaxed=[relaxed_constraint_id],
|
||
|
|
)
|
||
|
|
|
||
|
|
monkeypatch.setattr(cp_marginal, "run_cp_constraint_diagnostic", fake_run)
|
||
|
|
report = run_cp_marginal_resolve(
|
||
|
|
_tight_two_order_world(),
|
||
|
|
start_date="2026-08-03",
|
||
|
|
constraint_ids=["C2_no_overlap"],
|
||
|
|
)
|
||
|
|
assert report["status"] == "monotonicity_violation"
|
||
|
|
assert report["rows"][0]["objectiveImprovement"] is None
|
||
|
|
|
||
|
|
|
||
|
|
def test_optimal_negative_improvement_is_monotonicity_violation(monkeypatch):
|
||
|
|
from server.aps_domain import cp_marginal
|
||
|
|
|
||
|
|
def fake_run(*_args, relaxed_constraint_id=None, **_kwargs):
|
||
|
|
if relaxed_constraint_id is None:
|
||
|
|
return [], _fake_meta(status="OPTIMAL", objective=100.0, bound=100.0, relaxed=[])
|
||
|
|
return [], _fake_meta(
|
||
|
|
status="OPTIMAL", objective=110.0, bound=110.0, relaxed=[relaxed_constraint_id],
|
||
|
|
)
|
||
|
|
|
||
|
|
monkeypatch.setattr(cp_marginal, "run_cp_constraint_diagnostic", fake_run)
|
||
|
|
report = run_cp_marginal_resolve(
|
||
|
|
_tight_two_order_world(),
|
||
|
|
start_date="2026-08-03",
|
||
|
|
constraint_ids=["C2_no_overlap"],
|
||
|
|
)
|
||
|
|
assert report["status"] == "monotonicity_violation"
|
||
|
|
assert report["rows"][0]["status"] == "monotonicity_violation"
|
||
|
|
assert report["rows"][0]["objectiveImprovement"] is None
|
||
|
|
|
||
|
|
|
||
|
|
def test_monotonicity_violation_suppresses_other_available_improvements(monkeypatch):
|
||
|
|
from server.aps_domain import cp_marginal
|
||
|
|
|
||
|
|
def fake_run(*_args, relaxed_constraint_id=None, **_kwargs):
|
||
|
|
if relaxed_constraint_id is None:
|
||
|
|
meta = _fake_meta(status="OPTIMAL", objective=100.0, bound=100.0, relaxed=[])
|
||
|
|
meta["assumptionConstraints"] = ["C1_precedence", "C2_no_overlap"]
|
||
|
|
meta["activeAssumptionConstraints"] = ["C1_precedence", "C2_no_overlap"]
|
||
|
|
meta["enforcedAssumptionConstraints"] = ["C1_precedence", "C2_no_overlap"]
|
||
|
|
meta["constraintInstanceCounts"] = {"C1_precedence": 1, "C2_no_overlap": 1}
|
||
|
|
return [], meta
|
||
|
|
objective = 80.0 if relaxed_constraint_id == "C1_precedence" else 110.0
|
||
|
|
meta = _fake_meta(
|
||
|
|
status="OPTIMAL", objective=objective, bound=objective,
|
||
|
|
relaxed=[relaxed_constraint_id],
|
||
|
|
)
|
||
|
|
meta["assumptionConstraints"] = ["C1_precedence", "C2_no_overlap"]
|
||
|
|
meta["activeAssumptionConstraints"] = ["C1_precedence", "C2_no_overlap"]
|
||
|
|
meta["enforcedAssumptionConstraints"] = [
|
||
|
|
value for value in ("C1_precedence", "C2_no_overlap")
|
||
|
|
if value != relaxed_constraint_id
|
||
|
|
]
|
||
|
|
meta["constraintInstanceCounts"] = {"C1_precedence": 1, "C2_no_overlap": 1}
|
||
|
|
return [], meta
|
||
|
|
|
||
|
|
monkeypatch.setattr(cp_marginal, "run_cp_constraint_diagnostic", fake_run)
|
||
|
|
report = run_cp_marginal_resolve(
|
||
|
|
_tight_two_order_world(),
|
||
|
|
start_date="2026-08-03",
|
||
|
|
constraint_ids=["C1_precedence", "C2_no_overlap"],
|
||
|
|
)
|
||
|
|
assert report["status"] == "monotonicity_violation"
|
||
|
|
by_id = {row["constraintId"]: row for row in report["rows"]}
|
||
|
|
assert by_id["C1_precedence"]["status"] == "suppressed"
|
||
|
|
assert by_id["C1_precedence"]["objectiveImprovement"] is None
|
||
|
|
assert by_id["C2_no_overlap"]["status"] == "monotonicity_violation"
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize(
|
||
|
|
("relaxed_status", "row_status"),
|
||
|
|
[("UNKNOWN", "unavailable"), ("MODEL_INVALID", "solver_error")],
|
||
|
|
)
|
||
|
|
def test_infeasible_baseline_does_not_overclaim_unknown_or_invalid_variant(
|
||
|
|
monkeypatch, relaxed_status, row_status,
|
||
|
|
):
|
||
|
|
from server.aps_domain import cp_marginal
|
||
|
|
|
||
|
|
def fake_run(*_args, relaxed_constraint_id=None, **_kwargs):
|
||
|
|
if relaxed_constraint_id is None:
|
||
|
|
return [], _fake_meta(status="INFEASIBLE", objective=None, bound=None, relaxed=[])
|
||
|
|
return [], _fake_meta(
|
||
|
|
status=relaxed_status, objective=None, bound=None, relaxed=[relaxed_constraint_id],
|
||
|
|
)
|
||
|
|
|
||
|
|
monkeypatch.setattr(cp_marginal, "run_cp_constraint_diagnostic", fake_run)
|
||
|
|
report = run_cp_marginal_resolve(
|
||
|
|
_tight_two_order_world(),
|
||
|
|
start_date="2026-08-03",
|
||
|
|
constraint_ids=["C2_no_overlap"],
|
||
|
|
)
|
||
|
|
assert report["status"] == "partial"
|
||
|
|
assert report["rows"][0]["status"] == row_status
|
||
|
|
|
||
|
|
|
||
|
|
def test_model_instance_signature_mismatch_is_partial_solver_error(monkeypatch):
|
||
|
|
from server.aps_domain import cp_marginal
|
||
|
|
|
||
|
|
def fake_run(*_args, relaxed_constraint_id=None, **_kwargs):
|
||
|
|
meta = _fake_meta(
|
||
|
|
status="OPTIMAL", objective=100.0, bound=100.0,
|
||
|
|
relaxed=[] if relaxed_constraint_id is None else [relaxed_constraint_id],
|
||
|
|
)
|
||
|
|
if relaxed_constraint_id is not None:
|
||
|
|
meta["constraintInstanceCounts"] = {"C2_no_overlap": 2}
|
||
|
|
return [], meta
|
||
|
|
|
||
|
|
monkeypatch.setattr(cp_marginal, "run_cp_constraint_diagnostic", fake_run)
|
||
|
|
report = run_cp_marginal_resolve(
|
||
|
|
_tight_two_order_world(),
|
||
|
|
start_date="2026-08-03",
|
||
|
|
constraint_ids=["C2_no_overlap"],
|
||
|
|
)
|
||
|
|
assert report["status"] == "partial"
|
||
|
|
assert report["rows"][0]["status"] == "solver_error"
|
||
|
|
assert "实例清单" in report["rows"][0]["reason"]
|
||
|
|
|
||
|
|
|
||
|
|
def test_feasible_baseline_to_model_invalid_is_partial_solver_error(monkeypatch):
|
||
|
|
from server.aps_domain import cp_marginal
|
||
|
|
|
||
|
|
def fake_run(*_args, relaxed_constraint_id=None, **_kwargs):
|
||
|
|
if relaxed_constraint_id is None:
|
||
|
|
return [], _fake_meta(status="OPTIMAL", objective=100.0, bound=100.0, relaxed=[])
|
||
|
|
return [], _fake_meta(
|
||
|
|
status="MODEL_INVALID", objective=None, bound=None, relaxed=[relaxed_constraint_id],
|
||
|
|
)
|
||
|
|
|
||
|
|
monkeypatch.setattr(cp_marginal, "run_cp_constraint_diagnostic", fake_run)
|
||
|
|
report = run_cp_marginal_resolve(
|
||
|
|
_tight_two_order_world(),
|
||
|
|
start_date="2026-08-03",
|
||
|
|
constraint_ids=["C2_no_overlap"],
|
||
|
|
)
|
||
|
|
assert report["status"] == "partial"
|
||
|
|
assert report["rows"][0]["status"] == "solver_error"
|
||
|
|
|
||
|
|
|
||
|
|
def _request(operation: str, relaxed: list[str] | None = None) -> dict:
|
||
|
|
body = {
|
||
|
|
"protocolVersion": PROTOCOL_VERSION,
|
||
|
|
"operation": operation,
|
||
|
|
"world": {},
|
||
|
|
"entries": [],
|
||
|
|
"params": EngineParams(engineType="CP").model_dump(mode="json"),
|
||
|
|
"warmStart": None,
|
||
|
|
"pipelineLabel": "TEST",
|
||
|
|
}
|
||
|
|
if operation.startswith("diagnose_"):
|
||
|
|
body["relaxedConstraintIds"] = relaxed or []
|
||
|
|
body["invocationId"] = "a" * 32
|
||
|
|
return {**body, "requestId": _request_digest(body)}
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize(
|
||
|
|
"payload",
|
||
|
|
[
|
||
|
|
{**_request("optimize_line_assignment"), "unknown": True},
|
||
|
|
_request("diagnose_constraint_removal", []),
|
||
|
|
_request("diagnose_constraint_removal", ["C1_precedence", "C2_no_overlap"]),
|
||
|
|
_request("diagnose_constraint_removal", ["UNKNOWN"]),
|
||
|
|
],
|
||
|
|
)
|
||
|
|
def test_worker_strict_schema_and_single_relaxation_reject_invalid_requests(payload):
|
||
|
|
payload["requestId"] = _request_digest({k: v for k, v in payload.items() if k != "requestId"})
|
||
|
|
with pytest.raises((TypeError, ValueError)):
|
||
|
|
_validate_request(payload)
|
||
|
|
|
||
|
|
|
||
|
|
def test_worker_rejects_v1_protocol():
|
||
|
|
request = _request("diagnose_constraint_baseline")
|
||
|
|
request["protocolVersion"] = "aps.solver-process.v1"
|
||
|
|
request["requestId"] = _request_digest({k: v for k, v in request.items() if k != "requestId"})
|
||
|
|
with pytest.raises(RuntimeError):
|
||
|
|
_validate_request(request)
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize("status", ["INFEASIBLE", "UNKNOWN", "MODEL_INVALID"])
|
||
|
|
def test_relaxed_response_echo_is_checked_before_nonfeasible_return(status):
|
||
|
|
meta = {
|
||
|
|
"pipeline": "TEST",
|
||
|
|
"status": status,
|
||
|
|
"relaxedConstraintIds": [],
|
||
|
|
"diagnosticMode": True,
|
||
|
|
"assumptionConstraints": ["C2_no_overlap"],
|
||
|
|
"activeAssumptionConstraints": ["C2_no_overlap"],
|
||
|
|
"enforcedAssumptionConstraints": ["C2_no_overlap"],
|
||
|
|
"constraintInstanceCounts": {"C2_no_overlap": 1},
|
||
|
|
}
|
||
|
|
with pytest.raises(SolverProcessError, match="松弛约束"):
|
||
|
|
_validate_result_semantics(
|
||
|
|
[{"orderNo": "SO-1"}],
|
||
|
|
meta,
|
||
|
|
expected_entries=[{"orderNo": "SO-1"}],
|
||
|
|
pipeline_label="TEST",
|
||
|
|
expected_relaxed_constraint_ids=["C2_no_overlap"],
|
||
|
|
expected_diagnostic_mode=True,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def test_feasible_response_rejects_invalid_bound_and_gap():
|
||
|
|
meta = {
|
||
|
|
"pipeline": "TEST",
|
||
|
|
"status": "OPTIMAL",
|
||
|
|
"objective": 10.0,
|
||
|
|
"bestBound": 11.0,
|
||
|
|
"gap": 0.0,
|
||
|
|
"relaxedConstraintIds": [],
|
||
|
|
"diagnosticMode": False,
|
||
|
|
"operationSlots": [{"orderIndex": 0, "isFrozen": False}],
|
||
|
|
}
|
||
|
|
with pytest.raises(SolverProcessError, match="bestBound"):
|
||
|
|
_validate_result_semantics(
|
||
|
|
[{"orderNo": "SO-1"}],
|
||
|
|
meta,
|
||
|
|
expected_entries=[{"orderNo": "SO-1"}],
|
||
|
|
pipeline_label="TEST",
|
||
|
|
expected_relaxed_constraint_ids=[],
|
||
|
|
expected_diagnostic_mode=False,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def test_optimal_response_requires_zero_gap_and_equal_bound():
|
||
|
|
meta = {
|
||
|
|
"pipeline": "TEST",
|
||
|
|
"status": "OPTIMAL",
|
||
|
|
"objective": 10.0,
|
||
|
|
"bestBound": 9.0,
|
||
|
|
"gap": 0.1,
|
||
|
|
"relaxedConstraintIds": [],
|
||
|
|
"diagnosticMode": False,
|
||
|
|
"operationSlots": [{"orderIndex": 0, "isFrozen": False}],
|
||
|
|
}
|
||
|
|
with pytest.raises(SolverProcessError, match="OPTIMAL"):
|
||
|
|
_validate_result_semantics(
|
||
|
|
[{"orderNo": "SO-1"}],
|
||
|
|
meta,
|
||
|
|
expected_entries=[{"orderNo": "SO-1"}],
|
||
|
|
pipeline_label="TEST",
|
||
|
|
expected_relaxed_constraint_ids=[],
|
||
|
|
expected_diagnostic_mode=False,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def test_diagnostic_active_assumptions_must_match_positive_instance_counts():
|
||
|
|
meta = {
|
||
|
|
"pipeline": "TEST",
|
||
|
|
"status": "INFEASIBLE",
|
||
|
|
"relaxedConstraintIds": [],
|
||
|
|
"diagnosticMode": True,
|
||
|
|
"assumptionConstraints": ["C2_no_overlap"],
|
||
|
|
"activeAssumptionConstraints": [],
|
||
|
|
"enforcedAssumptionConstraints": ["C2_no_overlap"],
|
||
|
|
"constraintInstanceCounts": {"C2_no_overlap": 1},
|
||
|
|
}
|
||
|
|
with pytest.raises(SolverProcessError, match="实例数"):
|
||
|
|
_validate_result_semantics(
|
||
|
|
[{"orderNo": "SO-1"}],
|
||
|
|
meta,
|
||
|
|
expected_entries=[{"orderNo": "SO-1"}],
|
||
|
|
pipeline_label="TEST",
|
||
|
|
expected_relaxed_constraint_ids=[],
|
||
|
|
expected_diagnostic_mode=True,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def test_parent_rejects_invalid_c3_calendar_topology():
|
||
|
|
meta = {
|
||
|
|
"pipeline": "TEST",
|
||
|
|
"status": "INFEASIBLE",
|
||
|
|
"relaxedConstraintIds": [],
|
||
|
|
"diagnosticMode": True,
|
||
|
|
"assumptionConstraints": ["C3_calendar"],
|
||
|
|
"activeAssumptionConstraints": ["C3_calendar"],
|
||
|
|
"enforcedAssumptionConstraints": ["C3_calendar"],
|
||
|
|
"constraintInstanceCounts": {"C3_calendar": 1},
|
||
|
|
"c3Calendar": _fake_c3_calendar(relaxed=False, digest="not-a-digest"),
|
||
|
|
}
|
||
|
|
with pytest.raises(SolverProcessError, match="C3"):
|
||
|
|
_validate_result_semantics(
|
||
|
|
[{"orderNo": "SO-1"}],
|
||
|
|
meta,
|
||
|
|
expected_entries=[{"orderNo": "SO-1"}],
|
||
|
|
pipeline_label="TEST",
|
||
|
|
expected_relaxed_constraint_ids=[],
|
||
|
|
expected_diagnostic_mode=True,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
class _Store:
|
||
|
|
def __init__(self) -> None:
|
||
|
|
self.data = _tight_c2_exact_world()
|
||
|
|
|
||
|
|
|
||
|
|
class _Projects:
|
||
|
|
def active_world_key(self) -> str:
|
||
|
|
return "project-cp-marginal"
|
||
|
|
|
||
|
|
def require_active_write(self) -> None:
|
||
|
|
return None
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def cp_marginal_client(monkeypatch):
|
||
|
|
import server.gateway.app as gateway
|
||
|
|
from server.agent_core import async_jobs
|
||
|
|
from server.state import projects
|
||
|
|
|
||
|
|
install_test_auth(monkeypatch, "tenant-cp-marginal")
|
||
|
|
monkeypatch.setattr(gateway, "get_store", lambda: _Store())
|
||
|
|
monkeypatch.setattr(projects, "get_project_store", lambda: _Projects())
|
||
|
|
queue = JobQueue()
|
||
|
|
monkeypatch.setattr(async_jobs, "_queue", queue)
|
||
|
|
client = TestClient(gateway.create_app())
|
||
|
|
assert client.post(
|
||
|
|
"/api/auth/login", json={"username": "planner", "password": "test"},
|
||
|
|
).status_code == 200
|
||
|
|
return client, queue
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize(
|
||
|
|
"params",
|
||
|
|
[
|
||
|
|
{"startDate": "2026-08-03", "track": "flex"},
|
||
|
|
{"startDate": "2026-08-03", "planningHorizonDays": 0},
|
||
|
|
{"startDate": "2026-08-03", "timeLimitSeconds": "4"},
|
||
|
|
{"startDate": "2026-08-03", "constraintIds": []},
|
||
|
|
{"startDate": "2026-08-03", "constraintIds": ["UNKNOWN"]},
|
||
|
|
{"startDate": "20260803"},
|
||
|
|
],
|
||
|
|
)
|
||
|
|
def test_gateway_rejects_invalid_cp_marginal_before_submit(cp_marginal_client, params):
|
||
|
|
client, queue = cp_marginal_client
|
||
|
|
response = client.post(
|
||
|
|
"/api/jobs", json={"kind": "cp-marginal.recompute", "params": params},
|
||
|
|
)
|
||
|
|
assert response.status_code == 422, response.text
|
||
|
|
assert queue.stats()["total"] == 0
|
||
|
|
|
||
|
|
|
||
|
|
def test_gateway_submit_poll_is_tenant_project_bound(monkeypatch, cp_marginal_client):
|
||
|
|
from server.aps_domain import cp_marginal
|
||
|
|
|
||
|
|
client, queue = cp_marginal_client
|
||
|
|
|
||
|
|
def fast_report(_world, **options):
|
||
|
|
options["cancel_check"]()
|
||
|
|
return {"method": "cp-one-constraint-at-a-time-resolve.v1", "rows": []}
|
||
|
|
|
||
|
|
monkeypatch.setattr(cp_marginal, "run_cp_marginal_resolve", fast_report)
|
||
|
|
response = client.post("/api/jobs", json={
|
||
|
|
"kind": "cp-marginal.recompute",
|
||
|
|
"params": {
|
||
|
|
"track": "fixed",
|
||
|
|
"startDate": "2026-08-03",
|
||
|
|
"constraintIds": ["C2_no_overlap"],
|
||
|
|
"timeLimitSeconds": 1,
|
||
|
|
},
|
||
|
|
})
|
||
|
|
assert response.status_code == 200, response.text
|
||
|
|
job_id = response.json()["jobId"]
|
||
|
|
record = queue.wait(job_id, timeout=10)
|
||
|
|
assert record["status"] == "done"
|
||
|
|
assert record["tenant_uuid"] == "tenant-cp-marginal"
|
||
|
|
assert record["project_id"] == "project-cp-marginal"
|
||
|
|
assert client.get(f"/api/jobs/{job_id}").json()["job"]["result"]["method"].startswith("cp-")
|
||
|
|
|
||
|
|
|
||
|
|
def test_gateway_real_cp_marginal_submit_and_poll(cp_marginal_client):
|
||
|
|
client, queue = cp_marginal_client
|
||
|
|
response = client.post("/api/jobs", json={
|
||
|
|
"kind": "cp-marginal.recompute",
|
||
|
|
"params": {
|
||
|
|
"track": "fixed",
|
||
|
|
"startDate": "2026-08-03",
|
||
|
|
"planningHorizonDays": 3,
|
||
|
|
"constraintIds": ["C2_no_overlap"],
|
||
|
|
"timeLimitSeconds": 8,
|
||
|
|
},
|
||
|
|
})
|
||
|
|
assert response.status_code == 200, response.text
|
||
|
|
job_id = response.json()["jobId"]
|
||
|
|
record = queue.wait(job_id, timeout=20)
|
||
|
|
assert record["status"] == "done"
|
||
|
|
result = record["result"]
|
||
|
|
assert result["status"] == "completed"
|
||
|
|
assert result["evaluations"] == 2
|
||
|
|
assert result["rows"][0]["objectiveImprovement"] == 2100.0
|