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"