aps-agent/tests/golden/test_cp_rhs.py

674 lines
24 KiB
Python

from __future__ import annotations
import copy
import threading
import pytest
from fastapi.testclient import TestClient
from server.agent_core.async_jobs import JobCancelled, JobQueue
from server.aps_domain.cp_rhs import run_cp_rhs_resolve
from server.engines.base import EngineParams
from server.engines.cp_engine import CpSatEngine, optimize_line_assignment
from server.engines.solver_process import (
PROTOCOL_VERSION,
SolverProcessError,
_request_digest,
_validate_result_semantics,
run_cp_rhs_diagnostic,
)
from server.engines.solver_worker import _validate_request
from server.state.seed import empty_world
from tests.auth_provider import install_test_auth
from tests.golden.test_cp_cumulative import _two_order_ids, _wire_cumulative_world
from tests.golden.test_cp_marginal import _align_shift_calendar, _tight_two_order_world
def _entries_and_params(world: dict) -> tuple[list[dict], EngineParams]:
params = EngineParams(
orderIds=[], engineType="CP", strategyTemplate="COMPREHENSIVE",
planningHorizonDays=14, startDate="2026-08-03", timeLimitSeconds=4,
)
entries, _, _ = CpSatEngine().collect_and_order(world, params)
return entries, params
def _cumulative_world() -> dict:
world = _wire_cumulative_world(team=True, tooling=True, team_cap=1, tooling_cap=1)
_align_shift_calendar(world, "2026-08-03")
keep = set(_two_order_ids(world))
world["salesOrders"] = [order for order in world["salesOrders"] if order["id"] in keep]
for order in world["salesOrders"]:
order["deliveryDate"] = "2026-08-03"
return world
def test_real_due_allowance_is_exact_positive_rhs_finite_difference():
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
]
before = copy.deepcopy(world)
report = run_cp_rhs_resolve(
world, start_date="2026-08-03",
parameter_ids=["C8_due_date_allowance"],
increments={"C8_due_date_allowance": 60},
planning_horizon_days=3,
time_limit_seconds=8,
)
assert world == before
assert report["status"] == "completed"
assert report["evaluations"] == 2
assert report["baseline"]["operation"] == "diagnose_rhs_baseline"
row = report["rows"][0]
assert row["status"] == "available"
assert row["baselineRhs"] == 0
assert row["perturbedRhs"] == 60
assert row["objectiveImprovement"] > 0
assert row["objectiveImprovementPerIncrementUnit"] == pytest.approx(
row["objectiveImprovement"] / 60
)
assert row["perturbed"]["operation"] == "diagnose_rhs_perturbation"
assert row["costStatus"] == "not_configured"
assert row["costRate"] is None
assert row["costRateUnit"] is None
assert row["costCurrency"] is None
assert row["estimatedCost"] is None
@pytest.mark.parametrize(
("parameter_id", "resource_kind"),
[
("C12_team_capacity", "team"),
("C12_tooling_capacity", "tooling"),
],
)
def test_real_specific_cumulative_capacity_increment_changes_only_target_rhs(
parameter_id: str, resource_kind: str,
):
report = run_cp_rhs_resolve(
_cumulative_world(), start_date="2026-08-03",
parameter_ids=[parameter_id], increments={parameter_id: 1},
planning_horizon_days=3,
time_limit_seconds=8,
)
assert report["status"] == "completed"
row = report["rows"][0]
assert row["resourceKind"] == resource_kind
assert row["resourceId"] == 1
assert row["baselineRhs"] == 1
assert row["perturbedRhs"] == 2
assert row["status"] == "available"
assert row["objectiveImprovement"] >= 0
def test_explicit_zero_site_rate_is_configured_and_never_netted_with_objective():
report = run_cp_rhs_resolve(
_tight_two_order_world(), start_date="2026-08-03",
parameter_ids=["C8_due_date_allowance"],
increments={"C8_due_date_allowance": 30},
cost_rates={"C8_due_date_allowance": 0.0}, currency="cny",
)
row = report["rows"][0]
assert row["costStatus"] == "configured"
assert row["costRate"] == 0.0
assert row["costCurrency"] == "CNY"
assert row["estimatedCost"] == 0.0
assert "netValue" not in row
assert report["costCalibration"]["objectiveAndCostAreNotNettable"] is True
def test_inactive_cumulative_parameter_does_not_spawn_variant_solve():
world = _tight_two_order_world()
for workstation in world["workstations"]:
workstation.pop("teamId", None)
world["teams"] = []
report = run_cp_rhs_resolve(
world, start_date="2026-08-03", parameter_ids=["C12_team_capacity"],
)
assert report["evaluations"] == 1
assert report["rows"][0]["status"] == "inactive"
assert report["rows"][0]["resourceId"] is None
def test_inactive_cumulative_parameter_does_not_estimate_configured_cost():
world = _tight_two_order_world()
for workstation in world["workstations"]:
workstation.pop("teamId", None)
world["teams"] = []
report = run_cp_rhs_resolve(
world, start_date="2026-08-03", parameter_ids=["C12_team_capacity"],
cost_rates={"C12_team_capacity": 25.0},
)
row = report["rows"][0]
assert row["status"] == "inactive"
assert row["costStatus"] == "configured_inactive"
assert row["costRate"] == 25.0
assert row["estimatedCost"] is None
def test_no_demand_is_explicit_and_does_not_spawn_solver():
report = run_cp_rhs_resolve(
empty_world(), start_date="2026-08-03",
parameter_ids=["C8_due_date_allowance"],
)
assert report["status"] == "no_demand"
assert report["evaluations"] == 0
assert report["rows"] == []
@pytest.mark.parametrize(
"kwargs",
[
{"parameter_ids": []},
{"parameter_ids": ["UNKNOWN"]},
{
"parameter_ids": ["C8_due_date_allowance"],
"increments": {"C8_due_date_allowance": True},
},
{
"parameter_ids": ["C12_team_capacity"],
"increments": {"C12_team_capacity": 101},
},
{
"parameter_ids": ["C8_due_date_allowance"],
"cost_rates": {"C12_team_capacity": 1.0},
},
{
"parameter_ids": ["C8_due_date_allowance"],
"cost_rates": {"C8_due_date_allowance": float("nan")},
},
],
)
def test_domain_rejects_invalid_rhs_or_cost_configuration(kwargs):
with pytest.raises(ValueError):
run_cp_rhs_resolve(_tight_two_order_world(), start_date="2026-08-03", **kwargs)
def _worker_request(operation: str, rhs_perturbation=None) -> dict:
body = {
"protocolVersion": PROTOCOL_VERSION,
"operation": operation,
"world": {},
"entries": [],
"params": EngineParams(engineType="CP").model_dump(mode="json"),
"warmStart": None,
"pipelineLabel": "TEST-RHS",
"rhsPerturbation": rhs_perturbation,
"invocationId": "a" * 32,
}
return {**body, "requestId": _request_digest(body)}
def test_worker_accepts_strict_rhs_baseline_and_increment_operations():
baseline = _validate_request(_worker_request("diagnose_rhs_baseline"))
assert baseline[6] == "diagnose_rhs_baseline"
assert baseline[8] is None
assert baseline[-1] is True
perturbation = {
"parameterId": "C12_team_capacity", "resourceId": 1, "increment": 1,
}
increment = _validate_request(
_worker_request("diagnose_rhs_perturbation", perturbation)
)
assert increment[8] == perturbation
assert increment[-1] is True
@pytest.mark.parametrize(
"payload",
[
_worker_request("diagnose_rhs_perturbation"),
_worker_request(
"diagnose_rhs_baseline",
{"parameterId": "C8_due_date_allowance", "increment": 60},
),
_worker_request(
"diagnose_rhs_perturbation",
{"parameterId": "C12_team_capacity", "resourceId": 1, "increment": True},
),
_worker_request(
"diagnose_rhs_perturbation",
{
"parameterId": "C8_due_date_allowance", "increment": 60,
"unexpected": True,
},
),
],
)
def test_worker_rejects_invalid_rhs_operation_schema(payload):
with pytest.raises((TypeError, ValueError)):
_validate_request(payload)
def test_real_rhs_protocol_uses_random_invocation_and_fixed_solver_seed():
world = _tight_two_order_world()
entries, params = _entries_and_params(world)
_, first = run_cp_rhs_diagnostic(
world, entries, params, pipeline_label="RHS-BASELINE",
)
_, second = run_cp_rhs_diagnostic(
world, entries, params, pipeline_label="RHS-BASELINE",
)
assert first["objective"] == second["objective"]
assert first["solverProcess"]["requestId"] != second["solverProcess"]["requestId"]
assert first["solverProcess"]["invocationId"] != second["solverProcess"]["invocationId"]
assert first["numSearchWorkers"] == 1
assert first["randomSeed"] == 0
assert first["rhsDiagnosticMode"] is True
assert first["rhsPerturbation"] is None
def test_empty_rhs_baseline_protocol_returns_bound_trivial_state():
ordered, meta = run_cp_rhs_diagnostic(
{}, [], EngineParams(engineType="CP"), pipeline_label="RHS-EMPTY",
)
assert ordered == []
assert meta["status"] == "TRIVIAL"
assert meta["rhsParameterState"]["dueDateEntryCount"] == 0
assert meta["solverProcess"]["operation"] == "diagnose_rhs_baseline"
def test_production_optimizer_rejects_rhs_perturbation():
world = _tight_two_order_world()
entries, params = _entries_and_params(world)
with pytest.raises(ValueError, match="禁止 RHS"):
optimize_line_assignment(
world, entries, params, pipeline_label="PRODUCTION",
rhs_perturbation={"parameterId": "C8_due_date_allowance", "increment": 60},
)
def test_response_semantics_rejects_rhs_echo_or_state_drift():
meta = {
"pipeline": "TEST-RHS",
"status": "INFEASIBLE",
"relaxedConstraintIds": [],
"diagnosticMode": True,
"rhsDiagnosticMode": True,
"rhsPerturbation": None,
"assumptionConstraints": ["C2_no_overlap"],
"activeAssumptionConstraints": ["C2_no_overlap"],
"enforcedAssumptionConstraints": ["C2_no_overlap"],
"constraintInstanceCounts": {"C2_no_overlap": 1},
"rhsParameterState": {
"dueDateAllowanceMinutes": 0,
"dueDateEntryCount": 1,
"lineDailyCapacities": [],
"teamCapacities": [],
"toolingCapacities": [{"resourceId": 1, "capacity": 0, "intervalCount": 2}],
},
}
with pytest.raises(SolverProcessError, match="toolingCapacities"):
_validate_result_semantics(
[{"orderNo": "SO-1"}], meta,
expected_entries=[{"orderNo": "SO-1"}], pipeline_label="TEST-RHS",
expected_relaxed_constraint_ids=[], expected_diagnostic_mode=True,
expected_rhs_diagnostic_mode=True, expected_rhs_perturbation=None,
)
def _fake_rhs_meta(
*, status: str, objective: float | None, bound: float | None,
due_allowance: int,
) -> 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": ["C2_no_overlap"],
"relaxedConstraintIds": [],
"constraintInstanceCounts": {"C2_no_overlap": 1},
"diagnosticMode": True,
"rhsDiagnosticMode": True,
"rhsPerturbation": (
None if due_allowance == 0
else {"parameterId": "C8_due_date_allowance", "increment": due_allowance}
),
"rhsParameterState": {
"dueDateAllowanceMinutes": due_allowance,
"dueDateEntryCount": 2,
"lineDailyCapacities": [],
"teamCapacities": [],
"toolingCapacities": [],
},
"numSearchWorkers": 1,
"randomSeed": 0,
"cumulative": {"resources": []},
"solverProcess": {
"requestId": "req", "invocationId": "inv",
"operation": "diagnose_rhs_baseline" if due_allowance == 0 else "diagnose_rhs_perturbation",
"runtimeIdentity": {"safe": True},
},
}
def test_feasible_rhs_comparison_reports_bounds_not_incumbent_value(monkeypatch):
from server.aps_domain import cp_rhs
def fake_run(*_args, rhs_perturbation=None, **_kwargs):
if rhs_perturbation is None:
return [], _fake_rhs_meta(
status="FEASIBLE", objective=100.0, bound=80.0, due_allowance=0,
)
return [], _fake_rhs_meta(
status="FEASIBLE", objective=70.0, bound=50.0, due_allowance=60,
)
monkeypatch.setattr(cp_rhs, "run_cp_rhs_diagnostic", fake_run)
report = run_cp_rhs_resolve(
_tight_two_order_world(), start_date="2026-08-03",
parameter_ids=["C8_due_date_allowance"],
)
row = report["rows"][0]
assert row["status"] == "bounded"
assert row["objectiveImprovement"] is None
assert "incumbentDifference" not in row
assert row["improvementLowerBound"] == 10.0
assert row["improvementUpperBound"] == 50.0
def test_rhs_monotonicity_violation_suppresses_report(monkeypatch):
from server.aps_domain import cp_rhs
def fake_run(*_args, rhs_perturbation=None, **_kwargs):
objective = 100.0 if rhs_perturbation is None else 110.0
due = 0 if rhs_perturbation is None else 60
return [], _fake_rhs_meta(
status="OPTIMAL", objective=objective, bound=objective, due_allowance=due,
)
monkeypatch.setattr(cp_rhs, "run_cp_rhs_diagnostic", fake_run)
report = run_cp_rhs_resolve(
_tight_two_order_world(), start_date="2026-08-03",
parameter_ids=["C8_due_date_allowance"],
)
assert report["status"] == "monotonicity_violation"
assert report["rows"][0]["status"] == "monotonicity_violation"
assert report["rows"][0]["objectiveImprovement"] is None
def test_c8_cannot_change_feasibility(monkeypatch):
from server.aps_domain import cp_rhs
def fake_run(*_args, rhs_perturbation=None, **_kwargs):
if rhs_perturbation is None:
return [], _fake_rhs_meta(
status="INFEASIBLE", objective=None, bound=None, due_allowance=0,
)
return [], _fake_rhs_meta(
status="OPTIMAL", objective=50.0, bound=50.0, due_allowance=60,
)
monkeypatch.setattr(cp_rhs, "run_cp_rhs_diagnostic", fake_run)
report = run_cp_rhs_resolve(
_tight_two_order_world(), start_date="2026-08-03",
parameter_ids=["C8_due_date_allowance"],
)
assert report["status"] == "monotonicity_violation"
assert "仅改变延期目标" in report["rows"][0]["reason"]
def test_rhs_resource_interval_topology_drift_is_solver_error(monkeypatch):
from server.aps_domain import cp_rhs
def fake_run(*_args, rhs_perturbation=None, **_kwargs):
due = 0 if rhs_perturbation is None else 60
meta = _fake_rhs_meta(
status="OPTIMAL", objective=100.0, bound=100.0, due_allowance=due,
)
meta["rhsParameterState"]["teamCapacities"] = [{
"resourceId": 1,
"capacity": 1,
"intervalCount": 2 if rhs_perturbation is None else 3,
}]
return [], meta
monkeypatch.setattr(cp_rhs, "run_cp_rhs_diagnostic", fake_run)
report = run_cp_rhs_resolve(
_tight_two_order_world(), start_date="2026-08-03",
parameter_ids=["C8_due_date_allowance"],
)
assert report["status"] == "partial"
assert report["rows"][0]["status"] == "solver_error"
assert "模型拓扑" in report["rows"][0]["reason"]
def test_inconsistent_feasible_interval_is_solver_error(monkeypatch):
from server.aps_domain import cp_rhs
def fake_run(*_args, rhs_perturbation=None, **_kwargs):
if rhs_perturbation is None:
return [], _fake_rhs_meta(
status="FEASIBLE", objective=100.0, bound=90.0, due_allowance=0,
)
return [], _fake_rhs_meta(
status="FEASIBLE", objective=70.0, bound=95.0, due_allowance=60,
)
monkeypatch.setattr(cp_rhs, "run_cp_rhs_diagnostic", fake_run)
report = run_cp_rhs_resolve(
_tight_two_order_world(), start_date="2026-08-03",
parameter_ids=["C8_due_date_allowance"],
)
row = report["rows"][0]
assert report["status"] == "partial"
assert row["status"] == "solver_error"
assert row["objectiveImprovement"] is None
def test_cancellation_is_checked_after_baseline():
checks = 0
def cancel_after_baseline():
nonlocal checks
checks += 1
if checks == 2:
raise JobCancelled()
with pytest.raises(JobCancelled):
run_cp_rhs_resolve(
_tight_two_order_world(), start_date="2026-08-03",
parameter_ids=["C8_due_date_allowance"],
cancel_check=cancel_after_baseline,
)
assert checks == 2
class _Store:
def __init__(self) -> None:
self.data = _tight_two_order_world()
class _Projects:
def active_world_key(self) -> str:
return "project-cp-rhs"
def require_active_write(self) -> None:
return None
@pytest.fixture
def cp_rhs_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-rhs")
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", "unknown": True},
{"startDate": "2026-08-03", "parameterIds": []},
{"startDate": "2026-08-03", "parameterIds": ["UNKNOWN"]},
{
"startDate": "2026-08-03",
"parameterIds": ["C8_due_date_allowance"],
"increments": {"C8_due_date_allowance": True},
},
{
"startDate": "2026-08-03",
"parameterIds": ["C12_team_capacity"],
"increments": {"C12_team_capacity": 101},
},
{
"startDate": "2026-08-03",
"parameterIds": ["C8_due_date_allowance"],
"costRates": {"C8_due_date_allowance": True},
},
{
"startDate": "2026-08-03",
"parameterIds": ["C8_due_date_allowance"],
"costRates": {"C12_team_capacity": 1},
},
{"startDate": "2026-08-03", "currency": "CN"},
{"startDate": "20260803"},
{"startDate": "2026-08-03", "planningHorizonDays": 0},
{"startDate": "2026-08-03", "timeLimitSeconds": "4"},
],
)
def test_gateway_rejects_invalid_cp_rhs_before_submit(cp_rhs_client, params):
client, queue = cp_rhs_client
response = client.post(
"/api/jobs", json={"kind": "cp-rhs.recompute", "params": params},
)
assert response.status_code == 422, response.text
assert queue.stats()["total"] == 0
def test_gateway_cp_rhs_submit_uses_authenticated_scope_and_normalized_params(
monkeypatch, cp_rhs_client,
):
from server.aps_domain import cp_rhs
captured = {}
def fast_report(_world, **options):
captured.update(options)
options["cancel_check"]()
return {"method": "cp-rhs-one-parameter-at-a-time-resolve.v1", "rows": []}
monkeypatch.setattr(cp_rhs, "run_cp_rhs_resolve", fast_report)
client, queue = cp_rhs_client
response = client.post("/api/jobs", json={
"kind": "cp-rhs.recompute",
"actor": "forged-actor",
"params": {
"track": "fixed", "startDate": "2026-08-03",
"parameterIds": ["C8_due_date_allowance"],
"increments": {"C8_due_date_allowance": 30},
"costRates": {"C8_due_date_allowance": 0},
"currency": "cny",
},
})
assert response.status_code == 200, response.text
record = queue.wait(response.json()["jobId"], timeout=10)
assert record["status"] == "done"
assert record["actor"] == "planner"
assert record["tenant_uuid"] == "tenant-cp-rhs"
assert record["project_id"] == "project-cp-rhs"
assert captured["parameter_ids"] == ["C8_due_date_allowance"]
assert captured["increments"] == {"C8_due_date_allowance": 30}
assert captured["cost_rates"] == {"C8_due_date_allowance": 0}
assert captured["currency"] == "CNY"
import server.gateway.app as gateway
other = TestClient(gateway.create_app())
assert other.post(
"/api/auth/login", json={"username": "collaborator", "password": "test"},
).status_code == 200
assert other.get("/api/jobs").json()["jobs"] == []
assert "error" in other.get(f"/api/jobs/{record['job_id']}").json()
cancelled = other.post(f"/api/jobs/{record['job_id']}/cancel").json()
assert cancelled["cancelled"] is False
def test_gateway_real_cp_rhs_submit_poll_preserves_unconfigured_cost_nulls(cp_rhs_client):
client, queue = cp_rhs_client
response = client.post("/api/jobs", json={
"kind": "cp-rhs.recompute",
"params": {
"track": "fixed", "startDate": "2026-08-03",
"parameterIds": ["C8_due_date_allowance"],
"increments": {"C8_due_date_allowance": 60},
"timeLimitSeconds": 4,
},
})
assert response.status_code == 200, response.text
record = queue.wait(response.json()["jobId"], timeout=20)
assert record["status"] == "done"
result = record["result"]
assert result["status"] == "completed"
assert result["evaluations"] == 2
row = result["rows"][0]
assert row["costStatus"] == "not_configured"
assert row["costRate"] is None
assert row["costRateUnit"] is None
assert row["costCurrency"] is None
assert row["estimatedCost"] is None
def test_gateway_other_actor_cannot_read_or_cancel_running_cp_rhs(
monkeypatch, cp_rhs_client,
):
import server.gateway.app as gateway
from server.aps_domain import cp_rhs
started = threading.Event()
release = threading.Event()
def blocking_report(_world, **options):
started.set()
while not release.wait(0.01):
options["cancel_check"]()
return {"method": "cp-rhs-one-parameter-at-a-time-resolve.v1", "rows": []}
monkeypatch.setattr(cp_rhs, "run_cp_rhs_resolve", blocking_report)
planner, queue = cp_rhs_client
response = planner.post("/api/jobs", json={
"kind": "cp-rhs.recompute",
"params": {
"startDate": "2026-08-03",
"parameterIds": ["C8_due_date_allowance"],
},
})
assert response.status_code == 200, response.text
job_id = response.json()["jobId"]
assert started.wait(5)
other = TestClient(gateway.create_app())
assert other.post(
"/api/auth/login", json={"username": "collaborator", "password": "test"},
).status_code == 200
assert other.get("/api/jobs").json()["jobs"] == []
assert "error" in other.get(f"/api/jobs/{job_id}").json()
denied = other.post(f"/api/jobs/{job_id}/cancel").json()
assert denied["cancelled"] is False
own = planner.get(f"/api/jobs/{job_id}").json()["job"]
assert own["status"] == "running"
assert own["cancel_requested"] is False
release.set()
assert queue.wait(job_id, timeout=10)["status"] == "done"