diff --git a/backend/scenario_contract.py b/backend/scenario_contract.py new file mode 100644 index 0000000..ffa5d8d --- /dev/null +++ b/backend/scenario_contract.py @@ -0,0 +1,314 @@ +""" +backend/scenario_contract.py +單一 SKU / 單一 PO 的情境資料契約(dataclass),供 scenario_engine 與測試共用。 + +獨立研究模組:只依賴 Python 標準函式庫,不 import 任何 Streamlit / ERP 模組, +以便在隔離 venv 中與 Stockpyl 一起重跑(對應 vault `19-學術專題開源整合候選重查.md:95-130`)。 + +單一 PO 契約語意(GPT-6 CHANGES_REQUIRED 修正後): + - 契約必須明示 po_id、po_quantity(>0)與 arrival timing(lead_time_days,整數天)。 + - 整個 horizon 內只允許該 PO「一次」到貨(到貨期 t=lead_time_days);不是無限供應、 + 也不是每期補貨的 base-stock policy。 + - lead_time_days 非整數 → 共同 validate 層 fail-closed(needs_input),不得 round()。 + +共同 validate 層(第二修正回合,GPT-6 最終 verdict #5): + - 型別、有限性、整數語意與既定值域在引擎前統一攔截;validate() 本身不得拋 + 未捕捉 TypeError/OverflowError。 + - horizon_days 必須是 int(>0):明確拒絕 float(如 20.0)與 bool。 + - po_quantity/initial_stock 必須是有限數值(NaN/Infinity/字串 → needs_input)。 + - demand_list 逐項檢查:每項必須是有限非負數值(None/NaN/負數 → needs_input)。 + +注意:一般 `import backend.scenario_*` 仍會先執行既有 backend/__init__.py(載入既有 +ERP 模組);本模組本身未新增任何 ERP 依賴,測試以 repo venv / 隔離 venv 分層執行。 +""" +from __future__ import annotations + +import json +import math +from dataclasses import dataclass, field + +SCENARIO_ENGINE_VERSION = "1.3.0" +# 1.0.0=修正前(base-stock 語意,GPT-6 否決);1.1.0=單一 PO 語意; +# 1.2.0=第二修正回合:comparator 非有限值 fail-closed、共同 validate 層型別/有限性/值域攔截、 +# Stockpyl adapter policy 訂單與 override 同步 + 逐期簿記(order_bookkeeping)。 +# 1.3.0=第四修正回合(reviewer 獨立重現之剩餘缺口):comparator 容器契約(inventory_levels 只 +# 接受數值 list、po_arrivals 驗證容器/二欄/期數/有限數量)、validation 安全訊息 +# (_safe_repr,不因超大 int repr 拋例外)、運算後非有限 fail-closed(numerical failure)、 +# 失敗輸入可保存(rejected 標記、標準 JSON)。 +NUMERIC_TOLERANCE = 1e-9 + + +@dataclass +class DemandSpec: + type: str | None = None + demand_list: list[float] | None = None + mean: float | None = None + standard_deviation: float | None = None + + +@dataclass +class Validation: + ok: bool = True + needs_input: list[str] = field(default_factory=list) + issues: list[str] = field(default_factory=list) + + +def _is_real_number(x) -> bool: + """int/float 且非 bool(bool 是 int 子類,契約上不算數值)。""" + return isinstance(x, (int, float)) and not isinstance(x, bool) + + +def _is_finite_number(x) -> bool: + """有限數值(排除 NaN/Infinity、字串、None、bool、超出 float 範圍的超大 int)。 + + 超大 int(如 10**10000)→ math.isfinite/float() 會 OverflowError,先轉 float + 並捕捉;不合約值一律回 False,不得拋例外(第三修正回合,父 Agent 探針反例)。 + """ + if not _is_real_number(x): + return False + try: + return math.isfinite(float(x)) + except (OverflowError, ValueError): + return False + + +def _safe_repr(x) -> str: + """有界、絕不拋例外的值描述(第四修正回合)。 + + 驗證訊息/差異紀錄嵌入使用者提供值時一律用本函式,不用 {x!r}: + repr 超大 int(如 10**10000)會觸發 int_max_str_digits 的 ValueError, + 讓「建構錯誤訊息」本身再度崩潰。repr 失敗時回型別/位元數描述,不提高全域限制。 + """ + try: + r = repr(x) + except Exception: + if isinstance(x, int): + try: + return f"" + except Exception: + return "" + return f"<{type(x).__name__}: 不可安全表示>" + if len(r) > 200: + r = r[:200] + "…" + return r + + +def _is_whole_number(x) -> bool: + """整數值數字(int 或整數值 float,如 2 / 2.0);1.5 → False。 + + 非有限值(inf/nan)→ False(不在 inf 上呼叫 is_integer(),避免 OverflowError)。 + """ + if isinstance(x, bool): + return False + if isinstance(x, int): + return True + if isinstance(x, float): + return math.isfinite(x) and x.is_integer() + return False + + +@dataclass +class ScenarioInput: + case_id: str | None = None + scenario_id: str | None = None + input_version: int | None = None + seed: int = 0 + horizon_days: int | None = None + sku: str | None = None + po_id: str | None = None + po_quantity: float | None = None + initial_stock: float | None = None + lead_time_days: float | None = None + holding_cost: float | None = None + stockout_cost: float | None = None + demand: DemandSpec | None = None + assumption_source: str | None = None + description: str | None = None + + def validate(self) -> Validation: + """fail-closed 驗證:缺必要輸入或輸入型別/有限性/值域不合約 → needs_input。 + + 單一 PO 契約必要輸入:horizon_days(int,>0)、po_id(非空字串)、 + po_quantity(有限數值,>0)、initial_stock(有限數值,>=0)、 + lead_time_days(有限整數值且 0 <= lead < horizon_days,即到貨期必須落在 + horizon 內)、demand(type=D 時 demand_list 長度==horizon 且逐項有限非負)。 + 成本為選填,但若提供必須是有限數值且 >= 0;缺成本不回傳推測成本。 + + 本方法對任何輸入型別不得拋例外:所有檢查都先做型別/有限性判斷再比較。 + """ + v = Validation() + + def _need(field: str, issue: str) -> None: + v.needs_input.append(field) + v.issues.append(issue) + + horizon_ok = isinstance(self.horizon_days, int) and not isinstance(self.horizon_days, bool) + + # horizon_days:必須是 int 且 > 0(明確拒絕 float,如 20.0;引擎以 range(horizon) 使用) + if not horizon_ok or self.horizon_days <= 0: + _need("horizon_days", "horizon_days 必須是正整數 int(不是 float/bool)") + + # po_id:非空字串(單一 PO 契約的必要識別) + if not self.po_id or not isinstance(self.po_id, str): + _need("po_id", "po_id 必須是非空字串(單一 PO 契約的必要識別)") + + # po_quantity:有限數值且 > 0(NaN/Infinity/字串/None/負/0 → needs_input) + if self.po_quantity is None or not _is_finite_number(self.po_quantity) or self.po_quantity <= 0: + _need("po_quantity", "po_quantity 未提供、非有限數值或 <= 0(單一 PO 契約須明示 PO 數量)") + + # initial_stock:有限數值且 >= 0 + if self.initial_stock is None or not _is_finite_number(self.initial_stock) or self.initial_stock < 0: + _need("initial_stock", "initial_stock 未提供、非有限數值或為負") + + # lead_time_days:有限數值、整數值、>= 0;型別檢查先於比較,不拋 TypeError + if ( + self.lead_time_days is None + or not _is_finite_number(self.lead_time_days) + or not _is_whole_number(self.lead_time_days) + or self.lead_time_days < 0 + ): + _need( + "lead_time_days", + "lead_time_days 未提供、非數值/非有限、為負或非整數值(到貨 timing 以整天計,fail-closed,不 round)", + ) + elif horizon_ok and self.lead_time_days >= self.horizon_days: + _need("lead_time_days", f"lead_time_days={_safe_repr(self.lead_time_days)} 不在 horizon 內(單張 PO 無法到貨)") + + # 成本(選填):若提供必須是有限數值且 >= 0;缺成本不回傳推測成本 + if self.holding_cost is not None and (not _is_finite_number(self.holding_cost) or self.holding_cost < 0): + _need("holding_cost", "holding_cost 若提供必須是有限數值且 >= 0") + if self.stockout_cost is not None and (not _is_finite_number(self.stockout_cost) or self.stockout_cost < 0): + _need("stockout_cost", "stockout_cost 若提供必須是有限數值且 >= 0") + + # demand:先驗容器/型別,再存取 .type、len() 或迭代;錯型別回 needs_input,不拋例外 + d = self.demand + if not isinstance(d, DemandSpec) or d.type is None: + _need("demand", "demand 未提供或不是 DemandSpec(或 type 缺失)") + elif d.type == "D": + if not isinstance(d.demand_list, list) or not d.demand_list: + _need("demand", "demand type=D 但 demand_list 缺失或非 list 容器") + else: + if horizon_ok and len(d.demand_list) != self.horizon_days: + _need("demand", f"demand_list 長度 {len(d.demand_list)} != horizon_days {self.horizon_days}") + for i, x in enumerate(d.demand_list): + if x is None or not _is_finite_number(x) or x < 0: + # 用 _safe_repr:超大 int 的 repr 會觸發 int_max_str_digits 上限, + # 錯誤訊息建構本身不得再度崩潰(第四修正回合,reviewer 反例)。 + _need("demand", f"demand_list[{i}]={_safe_repr(x)} 必須是有限非負數值(逐項檢查)") + elif d.type == "N": + if d.mean is None or not _is_finite_number(d.mean): + _need("demand", "demand type=N 但 mean 缺失或非有限數值") + if ( + d.standard_deviation is None + or not _is_finite_number(d.standard_deviation) + or d.standard_deviation < 0 + ): + _need("demand", "demand type=N 但 standard_deviation 缺失、非有限數值或為負") + else: + _need("demand", f"未知 demand type: {_safe_repr(d.type)}") + # 去重、保持順序 + v.needs_input = list(dict.fromkeys(v.needs_input)) + v.ok = not v.needs_input + return v + + +@dataclass +class ScenarioResult: + engine: str = "" + engine_version: str = SCENARIO_ENGINE_VERSION + stockpyl_version: str | None = None + case_id: str | None = None + scenario_id: str | None = None + input_version: int | None = None + seed: int = 0 + horizon_days: int | None = None + po_id: str | None = None + po_quantity: float | None = None + po_arrivals: list = field(default_factory=list) # [[到貨期, 數量], ...];單一 PO 契約下至多一筆 + order_bookkeeping: list | None = None + # 僅 Stockpyl adapter 填寫:逐期 [{"t", "order_quantity_fg", "pending_finished_goods", + # "raw_material_inventory"}];用於證明 policy 決策與單一 PO override 同步、無殘留訂單簿記。 + # baseline 引擎為 None。 + inventory_levels: list[float] = field(default_factory=list) + fill_rate: float | None = None + first_stockout_period: int | None = None + stockout_periods: int | None = None + mean_cost_per_day: float | None = None + holding_cost: float | None = None + stockout_cost: float | None = None + total_cost: float | None = None + costs_provided: bool = False + needs_input: list[str] = field(default_factory=list) + assumptions: list[dict] = field(default_factory=list) + demand_total_realized: float | None = None + executed_at: str | None = None + execution_time_seconds: float | None = None + + def canonical_bytes(self) -> bytes: + """canonical structured result:固定欄位、固定精度、排除執行時間等非數值 metadata。 + + - 小數固定 12 位(round),-0.0 正規化為 0.0,避免平台 repr 差異。 + - 排除 executed_at / execution_time_seconds(acceptance #1)。 + - po_id / po_quantity / po_arrivals / order_bookkeeping 為單一 PO 契約與簿記 + 證據的一部分,納入 canonical(po_id 參與輸出語意,不是無效欄位)。 + - allow_nan=False:引擎在契約層已拒絕非有限輸入,canonical 遇 NaN/Infinity + 會直接 ValueError(fail-closed,不產出假一致的 bytes)。 + """ + def _canon(x): + if x is None: + return None + if isinstance(x, bool): + return x + r = round(float(x), 12) + return 0.0 if r == 0 else r + + payload = { + "engine": self.engine, + "engine_version": self.engine_version, + "stockpyl_version": self.stockpyl_version, + "case_id": self.case_id, + "scenario_id": self.scenario_id, + "input_version": self.input_version, + "seed": self.seed, + "horizon_days": self.horizon_days, + "po_id": self.po_id, + "po_quantity": _canon(self.po_quantity), + "po_arrivals": [[int(t), _canon(q)] for t, q in self.po_arrivals], + "order_bookkeeping": ( + None + if self.order_bookkeeping is None + else [ + [ + int(e["t"]), + _canon(e["order_quantity_fg"]), + _canon(e["pending_finished_goods"]), + _canon(e["raw_material_inventory"]), + ] + for e in self.order_bookkeeping + ] + ), + "inventory_levels": [_canon(x) for x in self.inventory_levels], + "fill_rate": _canon(self.fill_rate), + "first_stockout_period": self.first_stockout_period, + "stockout_periods": self.stockout_periods, + "mean_cost_per_day": _canon(self.mean_cost_per_day), + "holding_cost": _canon(self.holding_cost), + "stockout_cost": _canon(self.stockout_cost), + "total_cost": _canon(self.total_cost), + "costs_provided": self.costs_provided, + "needs_input": list(self.needs_input), + "assumptions": self.assumptions, + "demand_total_realized": _canon(self.demand_total_realized), + } + return json.dumps(payload, sort_keys=True, separators=(",", ":"), allow_nan=False).encode("utf-8") + + +@dataclass +class ScenarioRun: + inputs: ScenarioInput | None = None + baseline: ScenarioResult | None = None + stockpyl: ScenarioResult | None = None + explanation: str | None = None + failures: list[str] = field(default_factory=list) + engine_version: str = SCENARIO_ENGINE_VERSION + stockpyl_version: str | None = None diff --git a/backend/scenario_engine.py b/backend/scenario_engine.py new file mode 100644 index 0000000..1c867f4 --- /dev/null +++ b/backend/scenario_engine.py @@ -0,0 +1,844 @@ +""" +backend/scenario_engine.py +情境引擎:deterministic baseline + Stockpyl adapter + 執行 / 差異 / 保存。 + +設計原則(vault `19-學術專題開源整合候選重查.md:95-140`、`18-2026產品功能與技術架構重估.md:150-156`): + - LLM 只能讀取「深拷貝快照」產生文字解釋,不得生成或覆寫任何數值欄位。 + - 缺少需求分布、PO 數量、到貨 timing 或交期非整數 → fail closed(needs_input),不 round。 + - Stockpyl 採 lazy import,避免未安裝 Stockpyl 時連 baseline 都不能用。 + - 只處理單一 SKU / 單一 PO;不做多階 BOM、MCP、DBOS、前端。 + +單一 PO 語意(2026-09-12 第一修正回合,GPT-6 verdict #3;2026-09-13 第二修正回合): + - 契約明示 po_quantity(Q)與 arrival timing(lead_time_days,整數天)。 + - baseline:I0 起算,t=lead 到貨 Q(先補 backorder、餘量入現貨),之後不再有任何補貨; + 每期先到貨 → 再遇需求 d_t → 期末 IL_t。 + - Stockpyl 1.0.2 對應(唯讀 API/state_vars 檢查 + spike 實測): + sim.initialize + 逐期 sim.step(order_quantity_override=...) + sim.close(公開 API, + 文件說明供外部 RL 控制逐期訂單);外部供應節點 shipment_lead_time=lead、 + order_lead_time=0;t=0 override 下唯一一筆 Q,其餘期 override=0。 + FG 訂單由 policy 產生(override 只覆寫 RM 採購),故以 rQ reorder_point 逐期 + 切換 +inf/−inf 使 policy 決策與 override 同步(第二修正回合,詳見 + stockpyl_simulation docstring 與 docs/research/scenario_validation.md §4)。 + - 第二修正回合新增:comparator 非有限值 fail-closed、共同 validate 層型別/有限性/ + 值域攔截、adapter 逐期簿記(order_bookkeeping)與映射後置條件 fail-closed 檢查。 + - 第四修正回合(1.3.0)新增:comparator 容器契約(inventory_levels 只接受數值 + list、po_arrivals 驗證容器/二欄/期數/有限數量)、_safe_repr 安全訊息、 + _assert_result_finite 運算後非有限後置條件、rejected 標記的標準 JSON 保存。 +""" +from __future__ import annotations + +import copy +import datetime +import json +import math +import pathlib +import re +import time + +from .scenario_contract import ( + NUMERIC_TOLERANCE, + SCENARIO_ENGINE_VERSION, + DemandSpec, + ScenarioInput, + ScenarioResult, + ScenarioRun, + _is_finite_number, + _is_whole_number, + _safe_repr, +) + +_IDENTIFIER_RE = re.compile(r"[A-Za-z0-9_.-]{1,64}\Z") + + +class LLMAdapter: + """LLM 解釋 adapter 介面:只回傳文字解釋,不得回傳數值。""" + + def explain(self, run: ScenarioRun) -> str: # pragma: no cover - 介面 + raise NotImplementedError + + +class FailingLLMAdapter(LLMAdapter): + """只要被呼叫就失敗的 adapter(acceptance #4)。""" + + def explain(self, run: ScenarioRun) -> str: + raise RuntimeError("LLM disabled by test: numeric results must still be produced") + + +def get_stockpyl_version() -> str: + try: + import importlib.metadata + + return importlib.metadata.version("stockpyl") + except Exception: + return "unavailable" + + +def _assumptions_for(inp: ScenarioInput) -> list[dict]: + """明示提供的合成假設:標示 assumption=True 與來源(acceptance #5)。 + + 單一 PO 契約的合成輸入全部列入:demand、lead_time_days、initial_stock、 + po_quantity;成本若提供也列入。 + """ + if not inp.assumption_source: + return [] + fields = ["demand", "lead_time_days", "initial_stock", "po_quantity"] + if inp.holding_cost is not None: + fields.append("holding_cost") + if inp.stockout_cost is not None: + fields.append("stockout_cost") + return [ + {"field": f, "assumption": True, "source": inp.assumption_source} + for f in fields + ] + + +def _empty_result(inp: ScenarioInput, engine: str, needs_input: list[str] | None = None) -> ScenarioResult: + return ScenarioResult( + engine=engine, + case_id=inp.case_id, + scenario_id=inp.scenario_id, + input_version=inp.input_version, + seed=inp.seed, + horizon_days=inp.horizon_days, + po_id=inp.po_id, + # 被拒絕的非有限/不可表示值不帶進結果欄位(失敗結果不應以 nan 外觀回傳); + # 原值類型與拒絕原因由 persist 的 inputs rejected 標記保存(第四修正回合)。 + po_quantity=inp.po_quantity if _is_finite_number(inp.po_quantity) else None, + needs_input=list(needs_input or []), + ) + + +def _assert_result_finite(res: ScenarioResult, engine: str) -> None: + """成功結果回傳前的數值有限性後置條件(第四修正回合,reviewer 反例)。 + + 僅驗證「輸入」有限,不足以保證乘法/累加後的結果有限: + 例 holding_cost=1e308 → holding_total=inf、total_cost=inf。 + 任何數值欄位非有限 → 拋 ValueError(numerical failure),由 run_scenario 記入 + failures 且該引擎結果為 None——不得留下 failures=[] 的成功外觀。 + 不要求支援天文規模運算,只要求不支援時 fail-closed。 + """ + def _fail(field: str, value) -> None: + raise ValueError( + f"numerical failure ({engine}): {field} 為非有限數值 {_safe_repr(value)}," + "拒絕回傳成功結果(fail-closed,不支援天文規模運算)" + ) + + for name in ( + "po_quantity", + "fill_rate", + "first_stockout_period", + "stockout_periods", + "mean_cost_per_day", + "holding_cost", + "stockout_cost", + "total_cost", + "demand_total_realized", + ): + value = getattr(res, name) + if value is not None and not _is_finite_number(value): + _fail(name, value) + for i, x in enumerate(res.inventory_levels): + if x is None or not _is_finite_number(x): + _fail(f"inventory_levels[{i}]", x) + for i, entry in enumerate(res.po_arrivals): + if ( + not isinstance(entry, (list, tuple)) + or len(entry) != 2 + or not _is_finite_number(entry[0]) + or not _is_finite_number(entry[1]) + ): + _fail(f"po_arrivals[{i}]", entry) + if res.order_bookkeeping: + for i, e in enumerate(res.order_bookkeeping): + for k in ("order_quantity_fg", "pending_finished_goods", "raw_material_inventory"): + value = e.get(k) + if value is not None and not _is_finite_number(value): + _fail(f"order_bookkeeping[{i}].{k}", value) + + +def _single_po_baseline(inp: ScenarioInput) -> ScenarioResult: + """單一 SKU / 單一 PO 的確定性 baseline(手算可驗算)。 + + 每期事件順序(與 Stockpyl state_vars 實測對齊): + 1. 到貨:僅 t == lead 一次,數量 Q;先補 backorder,餘量入現貨。 + 2. 需求:met = min(現貨, d_t);不足部分記為 backorder。 + 3. 期末 IL_t = 現貨 - backorder。 + 無任何補貨政策(不是 base-stock、不是無限供應)。 + 只支援 demand type='D';成本為選填(缺任一則不回傳推測成本)。 + """ + d = inp.demand + if d.type != "D" or not d.demand_list: + raise ValueError("deterministic_baseline 只支援 demand type='D' 且提供 demand_list") + + t0 = time.perf_counter() + horizon = inp.horizon_days + lead = int(inp.lead_time_days) # validate 已保證整數值且 < horizon + demand = [float(x) for x in d.demand_list] + costs_provided = inp.holding_cost is not None and inp.stockout_cost is not None + h = float(inp.holding_cost) if costs_provided else None + p = float(inp.stockout_cost) if costs_provided else None + + on_hand = float(inp.initial_stock) + backlog = 0.0 + il = [0.0] * horizon + met_total = 0.0 + stockout_periods = 0 + first_stockout = None + holding_total = 0.0 + stockout_total = 0.0 + po_arrivals: list = [] + + for t in range(horizon): + # 1) 到貨(唯一一次) + if t == lead: + arrival = float(inp.po_quantity) + po_arrivals.append([t, arrival]) + fill_backlog = min(backlog, arrival) + backlog -= fill_backlog + on_hand += arrival - fill_backlog + # 2) 需求 + dt = demand[t] + met = min(on_hand, dt) + on_hand -= met + backlog += dt - met + met_total += met + # 3) 期末 IL(無任何下單) + il_t = on_hand - backlog + il[t] = il_t + if il_t < 0: + stockout_periods += 1 + if first_stockout is None: + first_stockout = t + if costs_provided: + holding_total += h * max(il_t, 0.0) + stockout_total += p * backlog + + total_demand = sum(demand) + result = ScenarioResult( + engine="baseline", + case_id=inp.case_id, + scenario_id=inp.scenario_id, + input_version=inp.input_version, + seed=inp.seed, + horizon_days=horizon, + po_id=inp.po_id, + po_quantity=float(inp.po_quantity), + po_arrivals=po_arrivals, + inventory_levels=il, + fill_rate=(met_total / total_demand) if total_demand > 0 else None, + first_stockout_period=first_stockout, + stockout_periods=stockout_periods, + mean_cost_per_day=((holding_total + stockout_total) / horizon) if costs_provided else None, + holding_cost=holding_total if costs_provided else None, + stockout_cost=stockout_total if costs_provided else None, + total_cost=(holding_total + stockout_total) if costs_provided else None, + costs_provided=costs_provided, + demand_total_realized=total_demand, + assumptions=_assumptions_for(inp), + executed_at=datetime.datetime.now(datetime.timezone.utc).isoformat(), + execution_time_seconds=time.perf_counter() - t0, + ) + _assert_result_finite(result, "baseline") + return result + + +def deterministic_baseline(inp: ScenarioInput) -> ScenarioResult: + v = inp.validate() + if not v.ok: + return _empty_result(inp, "baseline", v.needs_input) + return _single_po_baseline(inp) + + +def stockpyl_simulation(inp: ScenarioInput) -> ScenarioResult: + """Stockpyl 單一 SKU / 單一 PO 模擬 adapter(單次到貨,非每期補貨)。 + + Stockpyl 1.0.2 對應方式(唯讀檢查 + spike 實測,見 docs fit/gap): + - sim.initialize / sim.step(order_quantity_override) / sim.close 手動逐期驅動。 + - t=0 外部供應訂單 override=Q(shipment_lead_time=lead → t=lead 到貨一次), + 其餘期 override=0。 + - FG policy 決策與每期 override 同步(GPT-6 最終 verdict #3):order_quantity_override + 只覆寫原物料採購,FG 訂單只能由 policy 產生(sim.py 公開原始碼確認)。 + rQ 的觸發條件是 inventory_position <= reorder_point,故逐期切換 + reorder_point=+inf(t=0 必訂 Q)/−inf(t>=1 永不訂),公開屬性 setter。 + 這使 t=lead 到貨時 share_frac = order_quantity_fg(t=0)/raw_order(t=0) = 1, + RM 到貨當期即全數轉為可用 FG——不再用有限常數(1e12)冒充無限,也不留 + 每期 FG 殘留訂單(舊實作期末 pending_finished_goods=3800)。 + - 逐期簿記(order_quantity_fg/pending_finished_goods/raw_material_inventory) + 寫入 result.order_bookkeeping;模擬結束做後置條件檢查,映射不成立即 + fail-closed 拋錯(拒絕回傳可能錯位的數值),不靠隱藏上限放行。 + """ + v = inp.validate() + if not v.ok: + return _empty_result(inp, "stockpyl", v.needs_input) + + try: + from stockpyl.demand_source import DemandSource as SPDemandSource + from stockpyl.policy import Policy as SPPolicy + from stockpyl.supply_chain_network import SupplyChainNetwork, SupplyChainNode + import stockpyl.sim as spsim + except Exception as exc: + raise RuntimeError(f"Stockpyl unavailable: {exc}") from exc + + t0 = time.perf_counter() + horizon = inp.horizon_days + lead = int(inp.lead_time_days) # validate 已保證整數值 + q = float(inp.po_quantity) + costs_provided = inp.holding_cost is not None and inp.stockout_cost is not None + h = float(inp.holding_cost) if inp.holding_cost is not None else 0.0 + p = float(inp.stockout_cost) if inp.stockout_cost is not None else 0.0 + + d = inp.demand + if d.type == "D": + sp_demand = SPDemandSource(type="D", demand_list=[float(x) for x in d.demand_list]) + elif d.type == "N": + sp_demand = SPDemandSource(type="N", mean=float(d.mean), standard_deviation=float(d.standard_deviation)) + else: + raise ValueError(f"Stockpyl adapter 不支援 demand type: {d.type!r}") + + network = SupplyChainNetwork() + node = SupplyChainNode( + 0, + network=network, + local_holding_cost=h, + stockout_cost=p, + shipment_lead_time=lead, + order_lead_time=0, + demand_source=sp_demand, + initial_inventory_level=float(inp.initial_stock), + supply_type="U", + inventory_policy=SPPolicy(type="rQ", reorder_point=0.0, order_quantity=q), + ) + network.add_node(node) + prod = node.product_indices[0] + policy = node.get_attribute("inventory_policy", product=prod) + + spsim.initialize(network, horizon, rand_seed=int(inp.seed)) + for t in range(horizon): + # FG policy 訂單與 raw override 逐期同步:t=0 唯一一筆 Q,t>=1 零。 + # rQ 觸發條件為 inventory_position <= reorder_point(policy.py 公開原始碼), + # +inf=必訂、-inf=永不訂;不使用有限常數(1e12)冒充無限。 + policy.reorder_point = float("inf") if t == 0 else float("-inf") + spsim.step( + network, + order_quantity_override={0: {None: {None: (q if t == 0 else 0.0)}}}, + consistency_checks="W", + ) + spsim.close(network) + + svs = node.state_vars[:horizon] + rm_index = node.raw_materials_by_product(prod, return_indices=True, network_BOM=True)[0] + order_bookkeeping = [ + { + "t": t, + "order_quantity_fg": float(sv.order_quantity_fg[prod]), + "pending_finished_goods": float(sv.pending_finished_goods[prod]), + "raw_material_inventory": float(sv.raw_material_inventory[rm_index]), + } + for t, sv in enumerate(svs) + ] + inventory_levels = [float(sv.inventory_level[prod]) for sv in svs] + first_stockout = None + stockout_periods = 0 + for t, il_t in enumerate(inventory_levels): + if il_t < 0: + stockout_periods += 1 + if first_stockout is None: + first_stockout = t + fill_rate = float(svs[-1].fill_rate[prod]) if svs and horizon > 0 else None + holding_total = sum(float(sv.holding_cost_incurred) for sv in svs) + stockout_total = sum(float(sv.stockout_cost_incurred) for sv in svs) + demand_total = float(svs[-1].demand_cumul[prod]) if svs else None + + # 到貨事件:直接從 state_vars.inbound_shipment 逐期觀察(單一 PO → 至多一筆) + po_arrivals: list = [] + for t, sv in enumerate(svs): + received = 0.0 + for pred_dict in sv.inbound_shipment.values(): + for qty in pred_dict.values(): + if qty and qty > 0: + received += float(qty) + if received > 0: + po_arrivals.append([t, received]) + + # 後置條件檢查(fail-closed):單一 PO 映射不成立即拋錯,拒絕回傳可能錯位的數值。 + issues: list[str] = [] + ok_arrival = ( + len(po_arrivals) == 1 + and po_arrivals[0][0] == lead + and abs(po_arrivals[0][1] - q) <= NUMERIC_TOLERANCE + ) + if not ok_arrival: + issues.append(f"到貨事件 {po_arrivals} != 預期單一 PO [[{lead}, {q}]]") + if any(abs(bk["order_quantity_fg"]) > NUMERIC_TOLERANCE for bk in order_bookkeeping[1:]): + issues.append("t>0 存在非零 policy FG 訂單(殘留訂單簿記)") + if order_bookkeeping and abs(order_bookkeeping[-1]["pending_finished_goods"]) > NUMERIC_TOLERANCE: + issues.append(f"期末 pending_finished_goods 非零: {order_bookkeeping[-1]['pending_finished_goods']}") + if order_bookkeeping and abs(order_bookkeeping[-1]["raw_material_inventory"]) > NUMERIC_TOLERANCE: + issues.append(f"期末 raw_material_inventory 非零: {order_bookkeeping[-1]['raw_material_inventory']}") + if issues: + raise RuntimeError( + "Stockpyl 單一 PO 映射後置條件違反(fail-closed,拒絕回傳可能錯位的數值): " + + "; ".join(issues) + ) + + result = ScenarioResult( + engine="stockpyl", + stockpyl_version=get_stockpyl_version(), + case_id=inp.case_id, + scenario_id=inp.scenario_id, + input_version=inp.input_version, + seed=inp.seed, + horizon_days=horizon, + po_id=inp.po_id, + po_quantity=q, + po_arrivals=po_arrivals, + order_bookkeeping=order_bookkeeping, + inventory_levels=inventory_levels, + fill_rate=fill_rate, + first_stockout_period=first_stockout, + stockout_periods=stockout_periods, + mean_cost_per_day=((holding_total + stockout_total) / horizon) if costs_provided else None, + holding_cost=holding_total if costs_provided else None, + stockout_cost=stockout_total if costs_provided else None, + total_cost=(holding_total + stockout_total) if costs_provided else None, + costs_provided=costs_provided, + demand_total_realized=demand_total, + assumptions=_assumptions_for(inp), + executed_at=datetime.datetime.now(datetime.timezone.utc).isoformat(), + execution_time_seconds=time.perf_counter() - t0, + ) + _assert_result_finite(result, "stockpyl") + return result + + +_COMPARED_SCALARS = ( + "fill_rate", + "holding_cost", + "stockout_cost", + "total_cost", + "mean_cost_per_day", + "stockout_periods", + "demand_total_realized", + "first_stockout_period", +) + + +def compare_results(baseline: ScenarioResult, stockpyl: ScenarioResult) -> dict: + """結構化差異紀錄:每個可比欄位附 baseline / stockpyl / 絕對差與原因。 + + fail-closed 規則(GPT-6 verdict #1 + 最終 verdict #1 + 第三修正回合父 Agent 探針 + + 第四修正回合容器契約,reviewer 重現之反例 A/B): + - inventory_levels 只接受契約規定的數值 list;None、mapping(dict)、string 或 + 其他任意 iterable → match=False、max_abs_diff=None,reason 明列欄位與原因 + (不得 list(dict) 轉鍵列表、不得拋 TypeError)。 + - inventory_levels 長度不同或任一邊為空 → match=False、max_abs_diff=None + (不得以 0.0 宣稱一致)。 + - 任何元素對含 NaN/Infinity/不可比較值 → match=False、max_abs_diff=None, + reason 逐項列出 inventory_levels[i] 與兩側值(max() 不會傳播後續 NaN, + 故必須逐元素先驗證有限性,而不是算完 max 再比容差)。 + - scalar 單邊 None 或非有限/不可比較 → match=False、abs_diff=None, + reason 必須逐項列出欄位名。 + - po_arrivals:驗證容器(只接受 list;None/其他容器 → mismatch 不拋例外)、 + 每筆須為二欄 list [期數, 數量]、期數非負整數值、數量有限數值;任何違反 → + match=False、reason 列欄位/位置(不宣稱 engines agree)。 + """ + cmp: dict = {"_tolerance": NUMERIC_TOLERANCE} + + def _scalar(name: str, a, b) -> dict: + if a is None and b is None: + return {"baseline": None, "stockpyl": None, "abs_diff": None, "match": True} + if a is None or b is None: + return {"baseline": a, "stockpyl": b, "abs_diff": None, "match": False} + if not (_is_finite_number(a) and _is_finite_number(b)): + return {"baseline": a, "stockpyl": b, "abs_diff": None, "match": False} + return {"baseline": a, "stockpyl": b, "abs_diff": abs(a - b), "match": abs(a - b) <= NUMERIC_TOLERANCE} + + def _coerce_il(values): + """inventory_levels → list;只接受契約規定的數值 list,不得把 mapping/string + 等任意 iterable 轉 list(dict 會被轉成鍵列表而假一致)。""" + if values is None: + return None, "為 None" + if not isinstance(values, list): + return None, f"不合約容器(型別 {type(values).__name__};契約只接受數值 list)" + return list(values), None + + a_il, a_il_why = _coerce_il(baseline.inventory_levels) + b_il, b_il_why = _coerce_il(stockpyl.inventory_levels) + il_coerce_notes: list = [] + if a_il_why: + il_coerce_notes.append(f"inventory_levels: baseline {a_il_why},無法比較") + if b_il_why: + il_coerce_notes.append(f"inventory_levels: stockpyl {b_il_why},無法比較") + non_finite_pairs: list = [] + max_abs_diff = None + il_match = False + if a_il is not None and b_il is not None and len(a_il) == len(b_il) and a_il and b_il: + for i, (x, y) in enumerate(zip(a_il, b_il)): + if not (_is_finite_number(x) and _is_finite_number(y)): + non_finite_pairs.append((i, x, y)) + if non_finite_pairs: + max_abs_diff = None # 任何非有限/不可比較 → 不給數值 max_abs_diff + else: + max_abs_diff = max(abs(x - y) for x, y in zip(a_il, b_il)) + il_match = max_abs_diff <= NUMERIC_TOLERANCE + cmp["inventory_levels"] = { + "baseline": a_il, + "stockpyl": b_il, + "max_abs_diff": max_abs_diff, + "match": il_match, + } + + for f in _COMPARED_SCALARS: + cmp[f] = _scalar(f, getattr(baseline, f), getattr(stockpyl, f)) + + cmp["po_id"] = { + "baseline": baseline.po_id, + "stockpyl": stockpyl.po_id, + "match": baseline.po_id == stockpyl.po_id, + } + + # po_arrivals:先驗契約容器與每筆二欄結構,再比較(第四修正回合)。 + def _coerce_arrivals(values, side: str): + issues: list = [] + if values is None: + return None, [f"po_arrivals ({side}): 為 None,無法比較"] + if not isinstance(values, list): + return None, [ + f"po_arrivals ({side}): 不合約容器(型別 {type(values).__name__};契約只接受 list)" + ] + entries = [] + for i, entry in enumerate(values): + if not isinstance(entry, list) or len(entry) != 2: + issues.append( + f"po_arrivals[{i}] ({side}): 每筆須為二欄 list [期數, 數量](實際 {_safe_repr(entry)})" + ) + entries.append(None) + continue + t, q = entry + ok_t = _is_finite_number(t) and _is_whole_number(t) and t >= 0 + ok_q = _is_finite_number(q) + if not ok_t: + issues.append( + f"po_arrivals[{i}][0] ({side}): 期數須為非負整數值(實際 {_safe_repr(t)})" + ) + if not ok_q: + issues.append( + f"po_arrivals[{i}][1] ({side}): 數量須為有限數值(實際 {_safe_repr(q)})" + ) + entries.append([float(t), float(q)] if ok_t and ok_q else None) + return entries, issues + + a_arr, a_arr_notes = _coerce_arrivals(baseline.po_arrivals, "baseline") + b_arr, b_arr_notes = _coerce_arrivals(stockpyl.po_arrivals, "stockpyl") + arrival_notes = a_arr_notes + b_arr_notes + arrivals_match = False + if a_arr is not None and b_arr is not None and not arrival_notes: + if len(a_arr) == len(b_arr): + arrivals_match = all( + abs(x[0] - y[0]) <= NUMERIC_TOLERANCE and abs(x[1] - y[1]) <= NUMERIC_TOLERANCE + for x, y in zip(a_arr, b_arr) + ) + if not arrivals_match: + arrival_notes.append( + f"po_arrivals: 二欄數值超過容差(baseline={_safe_repr(baseline.po_arrivals)} " + f"stockpyl={_safe_repr(stockpyl.po_arrivals)})" + ) + else: + arrival_notes.append( + f"po_arrivals: 筆數不同(baseline={len(a_arr)} stockpyl={len(b_arr)})" + ) + cmp["po_arrivals"] = { + "baseline": baseline.po_arrivals, + "stockpyl": stockpyl.po_arrivals, + "match": arrivals_match, + } + cmp["po_quantity"] = _scalar("po_quantity", baseline.po_quantity, stockpyl.po_quantity) + + notes = [] + notes.extend(il_coerce_notes) + notes.extend(arrival_notes) + if cmp["inventory_levels"]["match"] is False: + if a_il is not None and b_il is not None and (len(a_il) != len(b_il) or not a_il or not b_il): + notes.append( + f"inventory_levels: length mismatch or empty " + f"(baseline={len(a_il)} stockpyl={len(b_il)}), cannot compare" + ) + for i, x, y in non_finite_pairs: + notes.append( + f"inventory_levels[{i}]: non-finite or non-comparable values " + f"(baseline={_safe_repr(x)} stockpyl={_safe_repr(y)}); treated as mismatch" + ) + if max_abs_diff is not None: + notes.append( + f"inventory_levels: max_abs_diff={max_abs_diff} (> tol {NUMERIC_TOLERANCE})" + ) + for f in _COMPARED_SCALARS: + entry = cmp[f] + if entry["match"] is False: + if entry["abs_diff"] is None and entry["baseline"] is not None and entry["stockpyl"] is not None: + notes.append( + f"{f}: non-finite or non-comparable values " + f"(baseline={_safe_repr(entry['baseline'])} stockpyl={_safe_repr(entry['stockpyl'])})" + ) + elif entry["abs_diff"] is None: + notes.append( + f"{f}: one-sided None " + f"(baseline={_safe_repr(entry['baseline'])} stockpyl={_safe_repr(entry['stockpyl'])})" + ) + else: + notes.append( + f"{f}: baseline={entry['baseline']} stockpyl={entry['stockpyl']} " + f"abs_diff={entry['abs_diff']}" + ) + for f in ("po_id", "po_quantity"): + if cmp[f]["match"] is False: + notes.append( + f"{f}: baseline={_safe_repr(cmp[f]['baseline'])} stockpyl={_safe_repr(cmp[f]['stockpyl'])}" + ) + cmp["reason"] = ( + "; ".join(notes) + if notes + else f"engines agree within tolerance {NUMERIC_TOLERANCE} on all compared fields " + "(deterministic fixed-demand fixture; single-PO semantics: one PO arrival at t=lead, " + "no replenishment)" + ) + return cmp + + +def run_scenario(inp: ScenarioInput, engine: str = "both", llm: LLMAdapter | None = None) -> ScenarioRun: + """執行情境:validate fail-closed;引擎數值計算與 LLM 解釋分離。 + + LLM 只在數值計算完成後被呼叫,且收到「深拷貝快照」(copy.deepcopy(run))—— + adapter 對快照的任何原地修改(含巢狀 inventory_levels、inputs)都不會影響正式 + ScenarioRun;LLM 失敗只記錄於 failures、explanation=None(acceptance #4)。 + """ + run = ScenarioRun(inputs=inp, engine_version=SCENARIO_ENGINE_VERSION) + + # 共同 validate 層:設計上不拋例外;萬一仍拋(防禦性),fail-closed 記入 failures + # 並回空結果,不得讓未捕捉例外逸出 run_scenario(第四修正回合)。 + try: + v = inp.validate() + except Exception as exc: + run.failures.append(f"validation crashed (fail-closed): {type(exc).__name__}: {_safe_repr(exc)}") + if engine in ("baseline", "both"): + run.baseline = _empty_result(inp, "baseline") + if engine in ("stockpyl", "both"): + run.stockpyl = _empty_result(inp, "stockpyl") + return run + if not v.ok: + missing = ", ".join(v.needs_input) + run.failures.append(f"missing required input: {missing}") + if engine in ("baseline", "both"): + run.baseline = _empty_result(inp, "baseline", v.needs_input) + if engine in ("stockpyl", "both"): + run.stockpyl = _empty_result(inp, "stockpyl", v.needs_input) + return run + + def _compute(name: str, fn) -> ScenarioResult | None: + try: + return fn(inp) + except Exception as exc: # 引擎失敗記錄,不丟棄其他引擎結果 + run.failures.append(f"{name}: {type(exc).__name__}: {exc}") + return None + + if engine in ("baseline", "both"): + run.baseline = _compute("baseline", deterministic_baseline) + if engine in ("stockpyl", "both"): + run.stockpyl = _compute("stockpyl", stockpyl_simulation) + if engine not in ("baseline", "stockpyl", "both"): + run.failures.append(f"unknown engine: {engine!r}") + return run + + # LLM 解釋:深拷貝快照,adapter 改不到正式 run。 + if llm is not None: + has_numbers = bool(run.baseline and run.baseline.fill_rate is not None) or bool( + run.stockpyl and run.stockpyl.fill_rate is not None + ) + if has_numbers: + try: + run.explanation = llm.explain(copy.deepcopy(run)) + except Exception as exc: + run.failures.append(f"llm_explanation_failed: {type(exc).__name__}: {exc}") + run.explanation = None + return run + + +def load_scenario_input(path: str) -> ScenarioInput: + """從 JSON fixture 載入 ScenarioInput。""" + payload = json.loads(pathlib.Path(path).read_text(encoding="utf-8")) + demand = payload.pop("demand", None) + if demand is not None: + demand = DemandSpec(**demand) + return ScenarioInput(demand=demand, **payload) + + +def _input_to_dict(inp: ScenarioInput) -> dict: + """輸入 → 標準 JSON 可表示 dict;被拒絕值以 rejected 標記取代(不猜數值)。""" + return _json_safe_input(inp) + + +def _rejected_marker(field: str, original_type: str, original_value: str, reason: str) -> dict: + """被契約拒絕/標準 JSON 不可表示值的明確標記:欄位、原值類型、原值描述、拒絕原因。""" + return { + "rejected": True, + "field": field, + "original_type": original_type, + "original_value": original_value, + "reason": reason, + } + + +def _json_safe_value(v, field: str): + """遞迴轉成標準 JSON 可表示值;NaN/inf/超大 int/其他型別 → rejected 標記。 + + 不猜成數值、不用 allow_nan=True;正常值原樣通過(canonical 讀回位元組穩定)。 + """ + if v is None or isinstance(v, (bool, str)): + return v + if isinstance(v, int): + try: + str(v) # 超大 int(如 10**10000)在 int_max_str_digits 下拋 ValueError + return v + except ValueError: + return _rejected_marker( + field, + "int", + f"", + "整數超出標準 JSON 安全表示上限(int_max_str_digits),未猜成數值", + ) + if isinstance(v, float): + if math.isfinite(v): + return v + original = "nan" if math.isnan(v) else ("inf" if v > 0 else "-inf") + return _rejected_marker(field, "float", original, "非有限浮點數(NaN/Infinity)不合契約") + if isinstance(v, DemandSpec): + return { + attr: _json_safe_value(getattr(v, attr, None), f"{field}.{attr}") + for attr in ("type", "demand_list", "mean", "standard_deviation") + } + if isinstance(v, dict): + out = {} + for k, vv in v.items(): + key = k if isinstance(k, str) else _safe_repr(k) + out[key] = _json_safe_value(vv, f"{field}.{key}") + return out + if isinstance(v, (list, tuple)): + return [_json_safe_value(vv, f"{field}[{i}]") for i, vv in enumerate(v)] + return _rejected_marker( + field, + type(v).__name__, + _safe_repr(v), + f"型別 {type(v).__name__} 非標準 JSON 可表示且不合契約", + ) + + +def _json_safe_input(inp: ScenarioInput) -> dict: + """ScenarioInput → 標準 JSON dict(欄位順序與 asdict 一致)。""" + fields = ( + "case_id", "scenario_id", "input_version", "seed", "horizon_days", "sku", + "po_id", "po_quantity", "initial_stock", "lead_time_days", "holding_cost", + "stockout_cost", "demand", "assumption_source", "description", + ) + return {f: _json_safe_value(getattr(inp, f, None), f) for f in fields} + + +def _dict_to_input(d: dict) -> ScenarioInput: + demand = d.pop("demand", None) + if demand is not None: + try: + demand = DemandSpec(**demand) + except TypeError: + demand = None # rejected 標記 dict 等無法重建形式:保留未知,不猜 + return ScenarioInput(demand=demand, **d) + + +def _safe_identifier(value, field: str) -> str: + """識別碼/檔名元件驗證:只允許 [A-Za-z0-9_.-]{1,64},拒絕絕對路徑、..、分隔符。""" + if ( + not isinstance(value, str) + or not _IDENTIFIER_RE.match(value) + or value in (".", "..") + or value.startswith("..") + ): + raise ValueError( + f"{field}={value!r} 不是合法識別碼(只允許 [A-Za-z0-9_.-]、長度 1–64," + "不得含路徑分隔符、不得為 . 或 ..)" + ) + return value + + +def persist_run(run: ScenarioRun, out_dir: str) -> str: + """保存實際執行結果:版本、seed、完整輸入、結果、失敗項目(acceptance #6)。 + + 路徑安全:case_id / scenario_id / po_id 逐項驗證 + resolved containment, + 最終路徑必須位於 out_dir 內(GPT-6 verdict #6)。 + """ + from dataclasses import asdict + + if run.inputs is None: + raise ValueError("ScenarioRun.inputs 為 None,無法保存") + inp = run.inputs + case_id = _safe_identifier(inp.case_id or "case", "case_id") + scenario_id = _safe_identifier(inp.scenario_id or "scenario", "scenario_id") + po_id = _safe_identifier(inp.po_id or "po", "po_id") + if not isinstance(inp.seed, int): + raise ValueError(f"seed={_safe_repr(inp.seed)} 非整數,拒絕組入檔名") + try: + str(inp.seed) # 超大 int seed 無法安全組入檔名 → fail-closed 拒絕 + except ValueError: + raise ValueError("seed 超出安全表示範圍,拒絕組入檔名") from None + if inp.input_version is not None: + if not isinstance(inp.input_version, int): + raise ValueError(f"input_version={_safe_repr(inp.input_version)} 非整數,拒絕組入檔名") + try: + str(inp.input_version) + except ValueError: + raise ValueError("input_version 超出安全表示範圍,拒絕組入檔名") from None + version = inp.input_version if inp.input_version is not None else "na" + + out = pathlib.Path(out_dir).resolve() + name = f"{case_id}-{scenario_id}-{po_id}-seed{inp.seed}-v{version}.json" + target = (out / name).resolve() + if target.parent != out: + raise ValueError(f"保存路徑 {target} 越出 out_dir={out}") + + # inputs/results 經 _json_safe_value:被契約拒絕的 NaN/inf/超大 int 以 rejected + # 標記保存(欄位、原值類型、拒絕原因),allow_nan=False 產出標準 JSON。 + payload = { + "schema": "scenario-run/1", + "engine_version": SCENARIO_ENGINE_VERSION, + "stockpyl_version": get_stockpyl_version(), + "inputs": _json_safe_input(inp), + "results": { + "baseline": _json_safe_value(asdict(run.baseline), "results.baseline") if run.baseline is not None else None, + "stockpyl": _json_safe_value(asdict(run.stockpyl), "results.stockpyl") if run.stockpyl is not None else None, + }, + "explanation": run.explanation, + "failures": list(run.failures), + } + out.mkdir(parents=True, exist_ok=True) + target.write_text( + json.dumps(payload, ensure_ascii=False, indent=2, allow_nan=False), encoding="utf-8" + ) + return str(target) + + +def load_run(path: str) -> ScenarioRun: + """載入 persist_run 的結果。""" + payload = json.loads(pathlib.Path(path).read_text(encoding="utf-8")) + inp = _dict_to_input(dict(payload["inputs"])) + + def _result(d: dict | None) -> ScenarioResult | None: + if d is None: + return None + return ScenarioResult(**d) + + return ScenarioRun( + inputs=inp, + baseline=_result(payload["results"].get("baseline")), + stockpyl=_result(payload["results"].get("stockpyl")), + explanation=payload.get("explanation"), + failures=list(payload.get("failures") or []), + engine_version=payload.get("engine_version", SCENARIO_ENGINE_VERSION), + stockpyl_version=payload.get("stockpyl_version"), + ) diff --git a/docs/research/scenario_validation.md b/docs/research/scenario_validation.md new file mode 100644 index 0000000..d47ed77 --- /dev/null +++ b/docs/research/scenario_validation.md @@ -0,0 +1,400 @@ +# Scenario Validation:情境契約 + Stockpyl adapter 實驗紀錄 + +日期:2026-09-12(DAG-20260912-7edaeaf2,writer=DeepSeek V4 Pro / xhigh); +第二修正回合 2026-09-13(GPT-6 最終 verdict,見 §11); +第三修正回合 2026-09-13(父 Agent 獨立探針反例的最小補修,見 §12); +第四修正回合 2026-09-14(reviewer 獨立重現之剩餘缺口,見 §13)。 +狀態:研究實驗,非企業預測、非產品功能。核准 spec 的停止條件為「達到 90 分鐘 +(累計)即停止」;本文件不把 90 分鐘改寫為每回合重置——各回合的完整累計工時沒有 +保存證據,無法如實判定是否觸發;第四修正回合係由使用者於 Discord 明確要求完成 +reviewer 重現之剩餘缺口(見 §8)。 +第一版為 GPT-6 `CHANGES_REQUIRED` 之後的修正紀錄;第二版(本版)為 GPT-6 最終 +verdict(comparator 非有限值誤報、輸入契約缺口、Stockpyl 簿記/可用時間錯位、 +證據敘述錯誤)之後的續修紀錄。歷史宣告一律以現存證據為準,不補造。 + +## 1. 範圍與硬邊界(本次只做這些) + +- 新增獨立 domain module:`backend/scenario_contract.py`(資料契約)與 + `backend/scenario_engine.py`(deterministic baseline + Stockpyl adapter + 差異紀錄 + 保存)。 +- 單一 SKU、單一 PO、三種情境(正常/中斷/替代)的合成 fixture + (`tests/fixtures/scenarios/*.json`),全部為手算可驗算的固定需求案例。 +- 交付:可重跑程式、結構化結果與差異紀錄。不接 UI、不改正式資料庫、 + 不做多階 BOM、MCP、DBOS 或前端。 + +### 1.1 關於 import 隔離的事實(修正 verifier 指出的錯誤宣稱) + +先前文件宣稱「完全不 import ERP」不成立。事實是:一般 `import backend.scenario_*` +仍會先執行既有 `backend/__init__.py`,該檔會載入 database、auth、inventory 等既有 +ERP 模組。本 DAG 能做到與做到的只有: + +- 新增模組(scenario_contract.py / scenario_engine.py)本身**未新增**任何 ERP import, + 只依賴標準函式庫 + lazy import Stockpyl。 +- 測試的隔離方式:新測試在隔離 venv `/tmp/scn-stockpyl-venv`(只裝 stockpyl==1.0.2 + 與 pytest)執行;既有 327 項回歸在相同 venv 執行並排除新增檔 + (`tests/ --ignore=tests/test_scenario_engine.py`)——第二修正回合實測同 venv + 直接執行全部 327 項亦全過(§11)。第一修正回合所稱「用 repo venv 執行」的 + 具體解釋器未能以現存證據核對,不作為本文件的主張。 +- verifier 的獨立執行(-I -B -S、程序內寫入/網路拒絕)也確認未修改正式資料庫。 + +## 2. 版本、seed 與容差 + +| 項目 | 值 | +|---|---| +| engine_version(契約/引擎) | `1.3.0`(第四修正回合;`1.2.0` 為第二/三修正回合版,`1.1.0` 為單一 PO 語意版,`1.0.0` 為修正前 base-stock 語意版,已被 GPT-6 否決) | +| Stockpyl 版本 | `1.0.2`(`get_stockpyl_version()` 實測;隔離 venv `/tmp/scn-stockpyl-venv`,Python 3.13.12) | +| pytest | 9.1.1 | +| seed | 0(三 fixture 與手算案例);stochastic 對照另跑 seed 12345 | +| 數值容差(事前固定) | `NUMERIC_TOLERANCE = 1e-9`(contract 內固定,所有比對共用) | +| canonical bytes 精度 | 小數固定 12 位,-0.0 正規化為 0.0,排除 `executed_at`/`execution_time_seconds`;含 `po_id`/`po_quantity`/`po_arrivals` | +| 重跑方式 | `cd && /tmp/scn-stockpyl-venv/bin/python -m pytest tests/test_scenario_engine.py -v` | + +## 3. 合成案例與手算基準(非企業資料) + +單一 PO 契約語意:固定輸入 D=10/期 × 20 期、單張 PO Q=200、初始庫存 I0=15、 +h=1、p=50。每期事件順序(與 Stockpyl state_vars 實測對齊):先到貨(先補 backorder、 +餘量入現貨)→ 再遇需求 → 期末 IL_t。**t=lead 到貨一次之後沒有任何補貨**(不是 +base-stock、不是無限供應)。 + +手算驗算(baseline 與 Stockpyl 兩引擎實跑值完全相同,容差 1e-9 內 0 差異): + +| 情境 | lead | 期末 IL 軌跡 | 首次缺貨 | fill_rate | 持有 | 缺貨 | 總成本 | +|---|---|---|---|---|---|---|---| +| 正常 | 1 | [5] + [195,185,…,15] | 無 | 200/200 = 1.0 | 2000.0 | 0.0 | 2000.0 | +| 替代 | 2 | [5,-5] + [185,175,…,15] | t=1 | 195/200 = 0.975 | 1805.0 | 250.0 | 2055.0 | +| 中斷 | 3 | [5,-5,-15] + [175,165,…,15] | t=1 | 185/200 = 0.925 | 1620.0 | 1000.0 | 2620.0 | + +驗算例(中斷,L=3):t=0 現貨 15 → 滿足 10,IL=5;t=1 現貨 5 → 滿足 5、欠 5, +IL=-5;t=2 現貨 0 → 全欠,IL=-15;t=3 到貨 200,先補 backorder 15 → 現貨 185, +再滿足 10 → IL=175。缺貨成本 = (5+15)×50 = 1000;持有 = 5×1 + Σ(175..15)×1 = 1620; +總成本 2620。fill_rate = 185/200 = 0.925。 + +註:p=50(修正前為 p=10)是為了讓單一 PO 語意下成本仍嚴格遞增 +(正常 2000 < 替代 2055 < 中斷 2620)。p=10 時較晚到貨省下的持有成本會蓋過缺貨成本 +增量(1820 < 1855),與「中斷最差」的直覺關係相反;fixture 為合成值,改 p 屬 +任務範圍內(fixtures/ 為 allowed_paths)。 + +## 4. Stockpyl 1.0.2 對單一 PO 的忠實對應(fit/gap) + +先做了唯讀 API/state_vars 檢查與 spike 實測(`/tmp/spike_single_po.py`、 +`/tmp/spike_single_po2.py`、`/tmp/spike_rq.py`、`/tmp/spike3.py`),結論:**可以忠實表示 +單張既有 PO**,方式如下(全部為公開 API): + +- `sim.initialize(network, horizon, rand_seed)` + 逐期 + `sim.step(network, order_quantity_override={0: {None: {None: qty}}}, consistency_checks="W")` + + `sim.close(network)`。docstring 明示三者依序呼叫等同 `sim.simulation()`,且 + `order_quantity_override` 是給外部(如 RL)逐期控制訂單的公開參數。 +- 單節點外部供應(supply_type="U"),`shipment_lead_time=lead`、`order_lead_time=0`; + t=0 override 下唯一一筆 Q(t+lead 到貨),其餘期 override=0 → **horizon 內只有一次到貨**, + 以 `state_vars.inbound_shipment` 逐期實測確認(三情境皆恰好一個正數到貨期,期數=lead)。 +- `inventory_policy` 是 Stockpyl `initialize()` 的強制要求(每個節點必須有 policy), + 且 `order_quantity_override` **只覆寫原物料採購**、不覆寫 FG 訂單——FG 訂單只能由 + policy 產生(sim.py 公開原始碼:policy 訂單寫入 `order_quantity_fg` 與 + `pending_finished_goods`,override 只改 `order_quantity`)。 +- 舊版用 `rQ(reorder_point=1e12, order_quantity=Q)` 把有限常數 1e12 冒充「無限」, + 是錯誤前提:當 initial_stock > 1e12 時 policy 不下 FG 訂單,t=lead 到貨的 RM 因 + `share_frac = order_quantity_fg(t=0)/raw_order(t=0) = 0` 無法當期轉成可用 FG, + 要到下一期經 units_ordered==0 的等分分支才轉(GPT-6 大 initial_stock 反例重現: + baseline t=1 IL=1000000000280、Stockpyl=1000000000080,總成本少 200)。 +- 第二修正回合改為 **policy 決策與每期 override 同步**:rQ 的觸發條件是 + `inventory_position <= reorder_point`(policy.py 公開原始碼),故逐期切換公開屬性 + `reorder_point`:t=0 設 `+inf`(必訂 Q 一筆)、t>=1 設 `-inf`(永不訂)。如此 + t=lead 到貨時 share_frac=Q/Q=1,RM 到貨當期全數轉為可用 FG;t>0 的 + `order_quantity_fg` 全為 0,期末 `pending_finished_goods` 與 + `raw_material_inventory` 皆為 0(逐期簿記見 result.order_bookkeeping)。 +- 模擬結束做映射後置條件檢查(單一 PO 於 t=lead 到貨一次、t>0 無 FG 訂單、期末 + pending/raw 歸零);任一違反即拋 RuntimeError(fail-closed),不靠隱藏上限或不實 + 文件放行。此對應依賴 Stockpyl 1.0.2 的 rQ 語意;若未來版本改變,後置條件會擋下。 + + +spike 實測結果(三情境 state_vars IL、fill、持有、缺貨、總成本)與第 3 節手算 +完全一致,且逐期 `inbound_shipment>0` 的期數 = {1}、{2}、{3}。 + +Fit/Gap 記錄(誠實版): + +| 項目 | 狀態 | +|---|---| +| 單一 SKU/單一 PO(明示 Q 與到貨期,單次到貨) | fit(兩引擎數值一致,容差 1e-9 內 0 差異;到貨事件一致) | +| 到貨語意(先補 backorder 再滿足需求) | fit(spike 與手算一致) | +| 到貨可用時間(t=lead 到貨當期即為可用成品) | fit(第二修正回合;含大 initial_stock 反例:initial_stock=1000000000100、lead=1/2/3 逐期 IL 與成本均與 baseline 一致) | +| 無殘留政策訂單簿記 | fit(第二修正回合;逐期 order_bookkeeping 證明 t>0 order_quantity_fg=0、期末 pending/raw=0;模擬結束有 fail-closed 後置條件檢查) | +| Stockpyl 對應方式 | fit,但依賴手動 initialize/step/close + order_quantity_override + rQ `reorder_point` 公開屬性逐期切換(公開 API、docstring/原始碼明示用途);不使用 `sim.simulation()` 包裝 | +| 隨機需求(type='N') | 只有 Stockpyl 引擎可跑(固定 seed);baseline 不支援。gap:N 下兩引擎無法對照 | +| 總需求為零時的 fill_rate | gap(已知定義差異,非映射失敗):total demand=0 時 baseline 回 `None`(0/0 比例無法定義),Stockpyl state_vars 回 `1.0`。reviewer 獨立重跑的 512 組固定需求邊界案例中,兩引擎唯一差異即此 64 個零需求案例的 fill_rate 定義差異;其餘庫存、缺貨、成本與簿記全部一致 | +| 中斷語意 | 以 PO 到貨期延長承載(t=1/2/3);未用 DisruptionProcess(M/E/OP/SP/TP/RP)。gap:真實中斷(暫停出貨、隨機 Markov 中斷)未涵蓋 | +| 輸入型別/有限性/值域 | 共同 validate 層 fail-closed(第二修正回合):horizon 必須 int(拒 float/bool)、po_quantity/initial_stock/成本必須有限數值、lead 必須有限整數值且 < horizon、demand_list 逐項有限非負;validate() 對任意型別不拋例外 | +| 多階 BOM/網路 | 不支援(刻意不做) | +| 正式資料 | 需求分布、成本、正式 lead time repo 內不存在;所有數值皆為明示合成假設(assumption=True + 來源),非企業事實 | + +## 5. 修正回合(GPT-6 CHANGES_REQUIRED 後)逐項狀態 + +嚴格以新 RED→GREEN 進行。C1、C3、C4、C5、C6 共**五組** RED(C2 是首次即通過的 +測試強化,如實無 RED,見下表)。現存 RED 檔包含 pytest 原始輸出與實際 exit code +(以 `tee` 輸出 + `PIPESTATUS` 記 exit code,非事後補造),但**沒有保存當時的完整 +執行命令**——這是歷史證據限制,本文件不偽稱有;檔案位置見 §10。 + +| 組 | verdict 問題 | 本回合 RED 證據(exit code 實測) | 修正後狀態 | +|---|---|---|---| +| 1. Comparator P1 | 長度不符/單邊空 → match=True、max_abs_diff=0;單邊 None、stockout_periods、demand_total_realized 未列入 reason | `/tmp/dag-fix-red-c1.txt`:6 failed, exit=1(含「reason 仍宣稱全部一致」的重現) | 已修正:長度不符或任一邊空 → match=False、max_abs_diff=None;所有已比較 scalar(含 stockout_periods、demand_total_realized、first_stockout_period)任一 mismatch 或單邊 None → reason 逐項列出欄位名;另有逐欄位斷言測試 | +| 2. Seed 測試弱點 | 用 canonical bytes(內含 seed)證明 seed 影響數值 | 無 RED(如實):強化測試 `test_seed_changes_actual_numeric_trajectory` 首次執行即通過(`/tmp/dag-fix-c2-first-run.txt`:1 passed, exit=0)——實作本就以 rand_seed 驅動 N 需求,弱點在舊測試本身;舊弱測試已刪除 | 已修正(測試層):直接比較 demand_total_realized/inventory_levels/total_cost 數值軌跡;同 seed 位元組一致仍由 acceptance #1 測試覆蓋 | +| 3. 單一 PO P1 語意 | 實作是無限供應 + BS 每期補貨,po_id 只是標籤 | `/tmp/dag-fix-red-c3.txt`:7 failed, exit=1(契約缺 po_quantity 欄位:TypeError) | 已修正:契約新增 po_quantity(明示 Q)與到貨 timing(lead_time_days 整數、 1e-9(後由 green3b 轉綠)。 +- 修正前七份 RED(`/tmp/dag-evidence-red1..7.txt`)當時**沒有保存完整執行命令與實際 + exit code**,這是歷史證據限制:本報告不偽造其 exit code,只標明其失敗數 + (5、3、2、3、4、2、2)與失敗類型(功能骨架 NotImplementedError、fixture 不存在、 + 行為 AssertionError;經 GPT-6 核對無 import error/缺依賴/skip 充數)。 + 第一修正回合的五份新 RED 全部有實際 exit code(§5 表格);其完整執行命令 + 未保存(限制如 §5 開頭所述)。 + +## 6. 六項 acceptance tests 逐條現況(只列有測試證據者) + +1. 同輸入/版本/seed 重跑 canonical bytes 一致(排除執行時間;含 po 欄位)→ + 通過(test_deterministic_fixture_rerun_is_byte_stable、test_seeded_stochastic_rerun_is_byte_stable、 + test_canonical_bytes_exclude_execution_metadata)。 +2. 手算案例驗算庫存/首次缺貨/成本 + 事前固定容差 + 差異與原因 → + 通過(test_single_po_baseline_hand_calc_normal/substitute/disruption、 + test_stockpyl_matches_baseline_within_tolerance、compare_results 附 reason)。 + comparator 對非有限/不可比較值 fail-closed(第二修正回合): + test_comparator_nan_at_index_fails_closed(NaN 於首/中/末索引)、 + test_comparator_infinity_single_sided/double_sided_fails_closed、 + test_comparator_non_comparable_value_fails_closed_without_raise、 + test_comparator_scalar_nan/infinity_fails_closed_and_listed → + match=False、max_abs_diff/abs_diff=None、reason 列欄位與索引。 + 第四修正回合補齊容器契約:dict inventory_levels 不再被轉成鍵列表假一致、 + po_arrivals 驗證容器/二欄結構/期數/有限數量(None/NaN/Infinity/錯誤 tuple → + 結構化 mismatch),見 §13。 +3. 三 fixture 預期關係先寫測試 → 通過(test_fixture_expected_relationships:fill + 1.0 > 0.975 > 0.925、成本 2000 < 2055 < 2620、三 fixture 皆一次到貨;只對受控 + fixture 驗證,不宣稱任意隨機模型單調)。 +4. LLM 呼叫即失敗仍完成數值計算;LLM 不能覆寫數值(含原地改、巢狀改、改後拋錯)→ + 通過(test_failing_llm_does_not_block_numeric_results、test_llm_cannot_overwrite_numeric_fields、 + test_llm_receives_snapshot_cannot_mutate_run、test_llm_mutation_then_exception_does_not_affect_results)。 +5. 缺必要需求/交期/PO 欄位 → needs_input;成本缺失不回推測成本;非整數交期 fail-closed; + 合成假設標示來源 → 通過(needs_input ×3 + test_fractional_lead_time_fails_closed_in_common_validation、 + test_missing_cost_is_not_guessed、test_provided_synthetic_assumptions_are_marked_with_source、 + test_synthetic_fixture_inputs_all_marked_as_assumptions)。 + 第二修正回合補齊共同 validate 層:demand_list 逐項(None/NaN/負數)、horizon float、 + lead/po_quantity 字串、NaN/Infinity 輸入 → needs_input 且 validate/引擎不拋未捕捉例外 + (test_validation_* 十項,見 §11)。 + 第四修正回合:超大逐期需求(demand_list=[10**10000])與超大 demand type → + 有界安全描述(_safe_repr)、needs_input=["demand"]、兩引擎空數值結果,見 §13。 +6. 保存實際執行結果、版本、seed、失敗項目;路徑穿越防護 → 通過 + (test_persist_and_reload_run、test_persist_records_failures、 + test_persist_rejects_path_traversal_identifiers、test_persist_valid_ids_stay_inside_out_dir)。 + 第四修正回合:被契約拒絕的 NaN/Infinity/超大 int 輸入可保存標準 JSON 失敗紀錄 + (rejected 標記含欄位、原值類型、拒絕原因;版本與 seed 隨檔),讀回 canonical + 位元組穩定(test_persist_rejected_* 四項,見 §13)。 + +## 7. 已知限制與未驗證項目 + +- 只驗證受控固定需求 fixture;隨機需求下兩引擎的數值一致性、多次 trial 統計 + (信賴區間、分位數)未做。 +- 總需求為零時 fill_rate 的定義差異(baseline=None、Stockpyl=1.0)已記錄於 §4 + fit/gap;本 DAG 未統一為單一定義。 +- Stockpyl 的 DisruptionProcess(M/E 型中斷、OP/SP/TP/RP)未接、未測。 +- baseline 只支援 demand type='D';N 型需求只有 Stockpyl 引擎(固定 seed)。 +- Stockpyl 對應依賴手動 initialize/step/close 驅動 + rQ `reorder_point` 公開屬性 + 逐期切換(+inf/−inf);未用 `sim.simulation()` 包裝(它不接受 order_quantity_override)。 + 若 Stockpyl 未來版本改變 rQ 觸發語意,adapter 的映射後置條件檢查會 fail-closed 拋錯 + (不會靜默產出錯位數值),但本 DAG 未在 1.0.2 以外的版本驗證。 +- 未做 UI、ERP 寫回、資料庫整合;`backend/scenario_repository.py`(vault 規劃的 + 第三個檔案)未建,本次以 `persist_run/load_run` + JSON 檔取代(範圍內最小實現)。 +- 既有 327 項回歸以隔離 venv 執行並排除新增檔(`tests/ --ignore=tests/test_scenario_engine.py`); + 本回合實測同 venv 亦能直接執行全部 327 項且全過(§11 證據)。第一修正回合的最終 + 隔離執行紀錄見 §10;其完整執行命令未保存(歷史限制,見 §5 開頭)。 + +## 8. 停止條件狀態(誠實版) + +- 核准 spec 的停止條件為「達到 90 分鐘(累計)即停止」。現存證據**無法證明**各 + 回合的完整累計工時,本文件不宣稱「未觸發」,也不把 90 分鐘改寫為每回合重置 + (2026-09-13 版曾在此宣稱「未觸發」,已撤銷)。 +- 可核對的事實:Stockpyl 1.0.2 經唯讀檢查與 spike 實測確認可忠實表示單張既有 PO + (§4),兩引擎在單一 PO 語意下數值一致;無需猜補正式資料;未越界修改任何 + 非授權路徑;未 commit/push/PR(git status 可查)。 +- 第四修正回合(2026-09-14)係由使用者於 Discord 明確要求完成 reviewer 獨立重現 + 的剩餘缺口(comparator 容器契約、validation 安全訊息、運算後非有限 fail-closed、 + 失敗輸入可保存、文件事實修正);未要求的項目維持不啟動。 +- 第二修正回合曾評估「Stockpyl 公開 API 無法忠實對齊 → BLOCKED」的選項:實測後 + 找到公開 API 內的忠實對應(order_quantity_override + rQ reorder_point 公開屬性 + 逐期切換,§4),加上映射後置條件 fail-closed 檢查,故不需 BLOCKED、也不靠隱藏 + 上限或不實文件放行。 +- 修正前「語意已對齊、六項全部通過」的無保留宣告已撤銷;本版所有通過宣告均附 + 測試名稱與證據檔(§6、§10、§11、§12、§13),未驗證項目列於 §7。 + +## 9. 重跑指令(完整) + +以下為本文件撰寫當下可重跑的命令(含第二修正回合的隔離驗收執行,全部為實際執行過 +的命令;第一修正回合歷史 RED 的完整命令未保存,限制見 §5 開頭,不補造)。 + +``` +# 0) 隔離 venv(只裝 stockpyl + pytest) +python3 -m venv /tmp/scn-stockpyl-venv +/tmp/scn-stockpyl-venv/bin/pip install -r requirements-scenario.txt + +# 1) 情境測試全檔 +cd /home/kali/.hermes/daily-agent/worktrees/DAG-20260912-7edaeaf2 +/tmp/scn-stockpyl-venv/bin/python -m pytest tests/test_scenario_engine.py -v + +# 2) 既有回歸(排除新增檔) +/tmp/scn-stockpyl-venv/bin/python -m pytest tests/ --ignore=tests/test_scenario_engine.py + +# 3) 隔離驗收執行(第二修正回合實際執行): +# env -i = 零環境變數(API keys 清空、ERP_DB_PATH 未設定); +# PYTHONPATH=/tmp/dag-network-guard = sitecustomize 阻斷 outbound socket/DNS。 +# (tests/conftest.py 於 backend import 前把 ERP_DB_PATH 指到 /tmp 暫存目錄, +# 其餘與上面第 1、2 步完全相同。) +env -i PYTHONPATH=/tmp/dag-network-guard /tmp/scn-stockpyl-venv/bin/python -m pytest tests/test_scenario_engine.py -v | tee /tmp/dag2-final-new.txt; echo "exit=${PIPESTATUS[0]}" >> /tmp/dag2-final-new.txt +env -i PYTHONPATH=/tmp/dag-network-guard /tmp/scn-stockpyl-venv/bin/python -m pytest tests/ --ignore=tests/test_scenario_engine.py | tee /tmp/dag2-final-regression.txt; echo "exit=${PIPESTATUS[0]}" >> /tmp/dag2-final-regression.txt + +# 4) 網路阻斷自證(預期 RuntimeError: outbound network blocked) +env -i PYTHONPATH=/tmp/dag-network-guard /tmp/scn-stockpyl-venv/bin/python -c "import socket; socket.create_connection(('1.1.1.1', 80))" + +# 5) 空環境自證(預期 {}) +env -i PYTHONPATH=/tmp/dag-network-guard /tmp/scn-stockpyl-venv/bin/python -c "import os; print(dict(os.environ))" +``` + +## 10. 證據檔位置 + +- 修正回合新 RED(含實際 exit code):`/tmp/dag-fix-red-c1.txt`(6f,1)、 + `/tmp/dag-fix-red-c3.txt`(7f,1)、`/tmp/dag-fix-red-c4.txt`(2f,1)、 + `/tmp/dag-fix-red-c5.txt`(2f,1)、`/tmp/dag-fix-red-c6.txt`(1f,1)。 +- seed 強化測試首次執行(無 RED,如實):`/tmp/dag-fix-c2-first-run.txt`(1 passed, exit 0)。 +- 修正回合 GREEN:`/tmp/dag-fix-green-corrections.txt`、`/tmp/dag-fix-green-c6.txt`、 + `/tmp/dag-fix-green-c46.txt`、`/tmp/dag-fix-green-full.txt`(36 passed, exit 0)。 +- 最終隔離執行(env -i:API keys 清空、ERP_DB_PATH 未設定;sitecustomize 阻斷 + outbound network;見 §9 第 3 步的完整命令): + `/tmp/dag-fix-final-new.txt`(36 passed, exit 0)、 + `/tmp/dag-fix-final-regression.txt`(327 passed, exit 0)、 + `/tmp/dag-fix-final-evidence.txt`(canonical sha256/差異紀錄/stochastic seed 對照)。 +- 新語意保存檔:`/tmp/scn-results-fix/CASE-SKU-001-{normal,substitute,disruption}-*.json`。 +- 第二修正回合(2026-09-13)證據:`/tmp/dag2-red-r2.txt`(新測試 RED:23 failed, + exit=1)、`/tmp/dag2-green-r2.txt`(新測試 GREEN:24 passed, exit=0)、 + `/tmp/dag2-green-full-v.txt`(全檔 60 passed, exit=0)、`/tmp/dag2-regression.txt` + (327 passed, exit=0)、`/tmp/dag2-probe-huge-before.txt`/`/tmp/dag2-probe-huge-after.txt` + (大 initial_stock 反例修正前後)、`/tmp/dag2-final-new.txt`、`/tmp/dag2-final-regression.txt` + (隔離驗收,見 §11)。 +- 修正前歷史證據(限制見 §5.1):`/tmp/dag-evidence-red1..7.txt`、 + `/tmp/dag-evidence-green*.txt`、`/tmp/dag-evidence-full-new.txt`、 + `/tmp/dag-evidence-regression*.txt`。 +- Stockpyl 唯讀 spike:`/tmp/spike_single_po.py`、`/tmp/spike_single_po2.py`、 + `/tmp/spike_rq.py`、`/tmp/spike3.py`。 + +## 11. 第二修正回合(2026-09-13,GPT-6 最終 verdict)逐項狀態 + +以新 RED→GREEN 進行。新測試共 24 項(23 failed→GREEN 的 RED 證據 + 1 項既有行為 +回歸 pin)。如實紀錄:本回合產生「24 selected、36 deselected」那組 RED/GREEN 的 +**實際選擇命令(-k 選擇參數)未保存**;§10 所列 `dag2-red-r2.txt`/ +`dag2-green-r2.txt` 為 pytest 原始輸出與 exit code(tee+PIPESTATUS),§9 列的是 +全檔/回歸/最終隔離命令,均非該組的實際選擇命令。此為歷史證據限制,不補造。 + +| 組 | verdict 問題(最終) | 本回合 RED 證據(exit code 實測) | 修正後狀態 | +|---|---|---|---| +| 1. Comparator 非有限值 P1 | NaN 在後續索引被 max() 吞掉 → match=True、max_abs_diff=0.0;scalar NaN/inf 給出 nan/inf abs_diff;不可比較值拋 TypeError | `/tmp/dag2-red-r2.txt`:23 failed, exit=1(含重現「NaN@idx=1 → match=True」與字串拋 TypeError) | 已修正:比較前逐元素驗證有限數值;任一非有限/不可比較 → match=False、max_abs_diff=None(inventory_levels)/abs_diff=None(scalar),reason 逐項列出 `inventory_levels[i]` 與兩側值或 scalar 欄位名。測試覆蓋 NaN 於索引 0/1/2、單邊/雙邊 Infinity、字串、scalar NaN/inf | +| 2. 輸入契約 P2 | demand_list[1]=None → validate ok=True、引擎 TypeError 且不回 needs_input;horizon=20.0 被接受;lead/po_quantity 字串讓 validate 拋 TypeError;NaN/Infinity 被接受;負需求被接受 | 同 `/tmp/dag2-red-r2.txt`(23 failed 含上述重現) | 已修正:共同 validate 層先查型別/有限性/整數語意/值域——horizon 必須 int(拒 float/bool)、po_quantity/initial_stock/成本必須有限數值、lead 必須有限整數值且 < horizon、demand_list 逐項有限非負;validate() 對任意型別不拋例外;所有不合約輸入 → 對應 needs_input,兩引擎回空結果不拋未捕捉 TypeError。engine_version → 1.2.0 | +| 3. Stockpyl 簿記與可用時間 P2 | FG 政策訂單每期 200、期末 pending=3800(未揭露簿記);1e12 冒充無限 → initial_stock>1e12 時 RM 到貨當期不轉 FG(可用庫存晚一期、成本少 200) | 同 `/tmp/dag2-red-r2.txt`(大 initial_stock 三 lead 全錯位、order_bookkeeping 不存在)+`/tmp/dag2-probe-huge-before.txt`(t=1 IL 差 200、total_cost 差 200) | 已修正:policy 決策與每期 override 同步(rQ `reorder_point` 公開屬性逐期切換 +inf/−inf),t=lead 到貨當期轉為可用 FG;逐期簿記(order_quantity_fg/pending/raw)進 result.order_bookkeeping;模擬結束做映射後置條件 fail-closed 檢查。測試同時核對到貨、可用庫存與簿記;大 initial_stock 反例(lead=1/2/3)修正後兩引擎逐期一致(`/tmp/dag2-probe-huge-after.txt`)。未動用 BLOCKED——公開 API 即可忠實對齊(§4、§8) | +| 4. 證據敘述 | C2 非 RED 卻被算進「六組 RED」;「RED 皆保存完整命令」不實;§11 引用不存在;隔離配方未完整列出 | —(文件修正,無測試) | 已修正:§5 改為五組 RED、明示 RED 檔無完整執行命令的歷史限制;所有 §11 引用改指 §10/新增本節;§9 列全隔離命令(本回合實際執行) | + +本回合 GREEN 證據:`/tmp/dag2-green-r2.txt`(新測試 24 passed, exit=0)、 +`/tmp/dag2-green-full-v.txt`(全檔 60 passed, exit=0)、`/tmp/dag2-regression.txt` +(既有回歸 327 passed, exit=0)、`/tmp/dag2-final-new.txt`、`/tmp/dag2-final-regression.txt` +(env -i 隔離驗收)。 + +如實紀錄:新測試中 `test_validation_lead_time_infinity_rejected_not_overflow` 在 RED 前 +即通過(舊 validate 對 inf lead 已回 needs_input),本回合將之定位為回歸 pin 而非新 RED; +RED 統計 23 failed 不包含它。三 fixture 與既有 36 項測試的數值在本回合修正後逐項未變 +(GREEN 全檔 60 passed 含全部既有斷言)。 + +## 12. 第三修正回合(2026-09-13,父 Agent 獨立探針反例)逐項狀態 + +背景:父 Agent 以 `/tmp/DAG-20260912-7edaeaf2-parent-adversarial.py` 獨立探針實測, +修正前 6 類反例全部 `raised=true`(3× OverflowError: int too large to convert to float、 +1× TypeError: 'int' has no len()、1× AttributeError: 'object' has no attribute 'type'、 +1× TypeError: 'NoneType' is not iterable),exit=1。本回合為最小補修,不擴需求、 +不變更契約語意,engine_version 維持 `1.2.0`。 + +| 反例 | 修正前(父層探針實測) | 修正後(探針重跑實測) | +|---|---|---| +| po_quantity=10**10000 | OverflowError | raised=false、needs_input=["po_quantity"] | +| initial_stock=10**10000 | OverflowError | raised=false、needs_input=["initial_stock"] | +| holding_cost=10**10000 | OverflowError | raised=false、needs_input=["holding_cost"] | +| demand_list=123(int) | TypeError: no len() | raised=false、needs_input=["demand"] | +| demand=object() | AttributeError: no 'type' | raised=false、needs_input=["demand"] | +| inventory_levels=None | TypeError: not iterable | raised=false、match=false | + +最小修正(三處): + +- `_is_finite_number`(scenario_contract.py):int/float 先 `float(x)` 轉換並捕捉 + OverflowError/ValueError,超大 int 回 False(不合約值),不再讓 math.isfinite 對 + 超大 int 拋例外。 +- `validate()`(scenario_contract.py):demand 先 `isinstance(d, DemandSpec)`、 + demand_list 先 `isinstance(..., list)` 才存取 `.type`、`len()` 或迭代;錯型別回 + `needs_input=["demand"]`。 +- `compare_results()`(scenario_engine.py):inventory_levels 經 `_coerce_il` 轉 list; + None 或非 iterable → match=False、max_abs_diff=None、reason 明列欄位與原因。 + +本回合 TDD(完整命令同 §9 隔離配方;RED/GREEN 皆為 env -i + network guard 實跑, +tee+PIPESTATUS 記 exit code): + +- 新增測試 8 項(6 個函式;inventory 案例 parametrize None/123/"ab")。 +- RED:`/tmp/DAG-20260912-7edaeaf2-fix-red-r3.txt` → **7 failed, 61 passed, exit=1**。 + 如實紀錄:`"ab"`(不合約容器)在 RED 前即通過——舊長度不符分支本就 fail-closed, + 定位為回歸 pin,7 failed 不含它(與 §11 的 lead_time inf pin 同理)。 +- GREEN:`/tmp/DAG-20260912-7edaeaf2-fix-green-r3.txt` → **68 passed, exit=0** + (全檔 = 既有 60 + 新增 8;既有 60 項數值未變)。 +- 既有回歸:`/tmp/DAG-20260912-7edaeaf2-fix-regression-r3.txt` → **327 passed, exit=0** + (`tests/ --ignore=tests/test_scenario_engine.py`)。 +- 父層探針重跑:`/tmp/DAG-20260912-7edaeaf2-fix-probe-after-r3.txt` → 6 案例全 + raised=false、failures=0、**exit=0**。執行時 PYTHONPATH 需含 worktree 根目錄 + (探針 script 位於 /tmp,`import backend` 依賴 sys.path): + `env -i PYTHONPATH=:/tmp/dag-network-guard /tmp/scn-stockpyl-venv/bin/python /tmp/DAG-20260912-7edaeaf2-parent-adversarial.py` + +計數變動:`tests/test_scenario_engine.py` 60 → **68** 項;既有回歸 327 項不變。 + +## 13. 第四修正回合(2026-09-14,reviewer 獨立重現之剩餘缺口)逐項狀態 + +背景:獨立 reviewer 對 1.2.0 版重跑(情境 68 passed、既有回歸 327 passed、 +獨立反例 6 failed、exit=1),六個反例全部重現:dict inventory_levels 假一致、 +inf 到貨 deepcopy 假一致(None 到貨拋 TypeError)、`demand_list=[10**10000]` 讓 +validate 在訊息建構時拋 ValueError、`holding_cost=1e308` 產出 total_cost=inf 卻 +`failures=[]`、NaN/超大 int 失敗輸入無法保存。本回合只修這四類契約/persist 缺口 +與文件事實邊界,不擴 UI/ERP/MCP/DBOS/BOM。engine_version → **1.3.0**。 + +本回合 TDD:每類先以最小失敗測試 RED、再修正 GREEN(隔離配方同 §9,另加 +`PYTHONPATH=:/tmp/dag-network-guard`、`PYTHONDONTWRITEBYTECODE=1`、 +`ERP_DB_PATH=/tmp/dag4-erp`;tee+PIPESTATUS 記 exit code)。 + +| 類 | 缺口 | RED 證據(exit code 實測) | 修正後狀態 | +|---|---|---|---| +| 1. comparator 容器契約 | dict inventory_levels 被 list() 轉成鍵列表假一致;inf/nan 到貨 deepcopy 假一致;po_arrivals=None 拋 TypeError;錯誤二欄結構(3 欄 tuple、1 欄、字串期數、非整數期數、負期數、dict 欄位)被當相等 | `/tmp/dag4-red-c1.txt`:11 failed, exit=1 | 已修正:inventory_levels 只接受契約數值 list(dict/str/任意 iterable → mismatch,不轉鍵列表);po_arrivals 驗證容器、每筆二欄 list、期數非負整數值、數量有限;非法 → match=False、reason 列欄位/位置,不拋 TypeError、不宣稱 engines agree。RED 中如實無 pin(nan 反例因 deepcopy identity shortcut 亦屬假一致,全部為真 RED) | +| 2. validation 安全訊息 | `demand_list=[10**10000]` 在 `{x!r}` 建構訊息時拋 ValueError(int_max_str_digits);超大 demand.type 同 | `/tmp/dag4-red-c2.txt`:2 failed, exit=1 | 已修正:新增 `_safe_repr`(有界、repr 失敗回型別/位元描述);validate 三處訊息改用;run_scenario 對 validate 加防禦性 fail-closed 包覆。超大逐期需求 → needs_input=["demand"]、兩引擎空數值結果;未提高全域 int 限制。RED 中 1 項(超大 lead_time,其分支未嵌值)為既有行為回歸 pin | +| 3. 運算後非有限 | holding_cost=1e308(輸入全有限、validate().ok=True)→ holding/mean/total=inf、failures=[] 成功外觀 | `/tmp/dag4-red-c3.txt`:1 failed, exit=1 | 已修正:兩引擎回傳前 `_assert_result_finite` 全欄位(scalar、inventory_levels、po_arrivals、order_bookkeeping)有限性後置條件;非有限 → ValueError(numerical failure),run.failures 記錄、該引擎結果 None;不支援天文規模運算,fail-closed | +| 4. 失敗輸入可保存 | initial_stock=nan / po_quantity=inf / po_quantity=10**10000 / demand=[inf] 被契約拒絕後,persist json.dumps 拋 ValueError(nan / Out of range / 4300 digits) | `/tmp/dag4-red-c4.txt`:4 failed, exit=1 | 已修正:`_json_safe_input`/`_json_safe_value` 為被拒絕值建立 rejected 標記(rejected=True、field、original_type、original_value、reason),allow_nan=False 不變、產出標準 JSON;seed/input_version 超大拒組檔名;`_empty_result` 不再把非有限 po_quantity 帶進結果;load_run 對不可重建 demand 容錯。讀回 canonical 位元組穩定(重存檔案位元組一致) | + +本回合 GREEN 證據:`/tmp/dag4-green-c1..c4.txt`(11/3/1/4 passed, exit=0); +全檔 `/tmp/dag4-final-new.txt`(**87 passed, exit=0**);既有回歸 +`/tmp/dag4-final-regression.txt`(**327 passed, exit=0**);獨立探針 +`/tmp/dag4-probe-after.txt`(reviewer 六反例 5 組探針全過, failures=0, exit=0; +`/tmp/dag4-probe.py` 可重跑)。既有 68 項情境測試的數值斷言逐項未變(全檔 87 = +既有 68 + 新增 19;新增 19 含 parametrize 展開)。 + +文件事實修正(本回合): +- §4 fit/gap 補記總需求為零時 fill_rate 的已知定義差異(baseline=None、 + Stockpyl=1.0;reviewer 512 組邊界案例中唯一差異,非映射失敗);§7 同步。 +- §11 撤銷「本回合 RED/GREEN 完整命令見 §9」:24 selected / 36 deselected 那組 + 的實際選擇命令未保存,如實標示(§10 檔案僅有 pytest 輸出與 exit code)。 +- 首頁狀態與 §8:90 分鐘停止條件為累計制,不再改寫成「每回合重置」;撤銷 + 「未觸發」宣告,累計工時證據不足,後續由使用者明確要求完成。 +- 不補造任何歷史。 + +計數變動:`tests/test_scenario_engine.py` 68 → **87** 項;既有回歸 327 項不變。 diff --git a/requirements-dev.txt b/requirements-dev.txt index 231e5b5..d6fadaf 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,2 +1,3 @@ # 開發/測試用依賴(跑系統本身不需要) +-r requirements-scenario.txt pytest==9.1.1 diff --git a/requirements-scenario.txt b/requirements-scenario.txt new file mode 100644 index 0000000..975158d --- /dev/null +++ b/requirements-scenario.txt @@ -0,0 +1,16 @@ +# requirements-scenario.txt +# 隔離研究依賴:情境契約 + Stockpyl adapter 實驗用(acceptance 實驗重跑)。 +# 此檔獨立於主 requirements.txt,不進入 production / 正式 UI。 +# +# 用法:在 /tmp 建隔離 venv(不要把 venv 或快取寫進 repo): +# python3 -m venv /tmp/scn-stockpyl-venv +# /tmp/scn-stockpyl-venv/bin/pip install -r requirements-scenario.txt +# cd +# /tmp/scn-stockpyl-venv/bin/python -m pytest tests/test_scenario_engine.py -v +# +# 對應 vault `19-學術專題開源整合候選重查.md:79-82`:Stockpyl v1.0.2(MIT)。 +# 單一 PO adapter 使用 Stockpyl 公開 API:sim.initialize / sim.step(order_quantity_override) / +# sim.close 逐期手動驅動(見 docs/research/scenario_validation.md §4 fit/gap)。 + +stockpyl==1.0.2 +pytest>=8 diff --git a/tests/fixtures/scenarios/disruption_single_sku.json b/tests/fixtures/scenarios/disruption_single_sku.json new file mode 100644 index 0000000..5965235 --- /dev/null +++ b/tests/fixtures/scenarios/disruption_single_sku.json @@ -0,0 +1,20 @@ +{ + "case_id": "CASE-SKU-001", + "scenario_id": "disruption", + "input_version": 1, + "seed": 0, + "horizon_days": 20, + "sku": "SKU-001", + "po_id": "PO-001", + "po_quantity": 200.0, + "initial_stock": 15.0, + "lead_time_days": 3, + "holding_cost": 1.0, + "stockout_cost": 50.0, + "demand": { + "type": "D", + "demand_list": [10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0] + }, + "assumption_source": "synthetic-fixture/stockpyl-spike-v1 (hand-computable fixed demand; not enterprise data)", + "description": "中斷情境:單張 PO-001(Q=200)交期中斷,延至 t=3 一次到貨,之後無任何補貨。合成假設,非企業資料。" +} diff --git a/tests/fixtures/scenarios/normal_single_sku.json b/tests/fixtures/scenarios/normal_single_sku.json new file mode 100644 index 0000000..ef8959b --- /dev/null +++ b/tests/fixtures/scenarios/normal_single_sku.json @@ -0,0 +1,20 @@ +{ + "case_id": "CASE-SKU-001", + "scenario_id": "normal", + "input_version": 1, + "seed": 0, + "horizon_days": 20, + "sku": "SKU-001", + "po_id": "PO-001", + "po_quantity": 200.0, + "initial_stock": 15.0, + "lead_time_days": 1, + "holding_cost": 1.0, + "stockout_cost": 50.0, + "demand": { + "type": "D", + "demand_list": [10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0] + }, + "assumption_source": "synthetic-fixture/stockpyl-spike-v1 (hand-computable fixed demand; not enterprise data)", + "description": "正常情境:單張 PO-001(Q=200)於 t=1 一次到貨,之後無任何補貨。合成假設,非企業資料。" +} diff --git a/tests/fixtures/scenarios/substitute_single_sku.json b/tests/fixtures/scenarios/substitute_single_sku.json new file mode 100644 index 0000000..612c83b --- /dev/null +++ b/tests/fixtures/scenarios/substitute_single_sku.json @@ -0,0 +1,20 @@ +{ + "case_id": "CASE-SKU-001", + "scenario_id": "substitute", + "input_version": 1, + "seed": 0, + "horizon_days": 20, + "sku": "SKU-001", + "po_id": "PO-002", + "po_quantity": 200.0, + "initial_stock": 15.0, + "lead_time_days": 2, + "holding_cost": 1.0, + "stockout_cost": 50.0, + "demand": { + "type": "D", + "demand_list": [10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0] + }, + "assumption_source": "synthetic-fixture/stockpyl-spike-v1 (hand-computable fixed demand; not enterprise data)", + "description": "替代情境:改用替代供應商單張 PO-002(Q=200)於 t=2 一次到貨(介於正常與中斷之間),之後無任何補貨。合成假設,非企業資料。" +} diff --git a/tests/test_scenario_engine.py b/tests/test_scenario_engine.py new file mode 100644 index 0000000..4389681 --- /dev/null +++ b/tests/test_scenario_engine.py @@ -0,0 +1,1155 @@ +""" +tests/test_scenario_engine.py +獨立情境契約 + Stockpyl adapter 的 TDD 測試。 + +範圍(對應 vault `19-學術專題開源整合候選重查.md:95-146、373-377、400-404` +與 `18-2026產品功能與技術架構重估.md:150-156、301-306`): + - 單一 SKU、單一 PO 的正常 / 中斷 / 替代情境 fixture。 + - deterministic baseline(手算可驗算)對照 Stockpyl 模擬。 + - 六項 acceptance tests 逐條對應的測試函式。 + +事前固定的合成案例(所有測試共用,非企業資料;2026-09-12 修正回合改為單一 PO 語意): + - 需求 D=10/期(固定 20 期)、單張 PO Q=200、初始庫存 I0=15、h=1、p=50。 + - 單一 PO 語意(GPT-6 verdict #3):t=lead 一次到貨 Q,之後無任何補貨 + (非 base-stock、非無限供應)。 + - 每期事件順序(與 Stockpyl state_vars 實測對齊):先到貨(先補 backorder、 + 餘量入現貨)→ 再遇需求 → 期末 IL_t。 + - 手算基準(IL 為期末值): + L=1(正常) : IL=[5]+[195,185,...,15]、無缺貨、fill=200/200=1.0、成本=2000。 + L=2(替代) : IL=[5,-5]+[185,175,...,15]、首次缺貨 t=1、fill=195/200=0.975、成本=2055。 + L=3(中斷) : IL=[5,-5,-15]+[175,165,...,15]、首次缺貨 t=1、fill=185/200=0.925、成本=2620。 + - 容差(事前固定):NUMERIC_TOLERANCE = 1e-9。 +""" + +from __future__ import annotations + +import copy +import json +import math +import pathlib + +import pytest + +from backend.scenario_contract import ( + NUMERIC_TOLERANCE, + SCENARIO_ENGINE_VERSION, + DemandSpec, + ScenarioInput, + ScenarioResult, +) +from backend.scenario_engine import ( + FailingLLMAdapter, + compare_results, + deterministic_baseline, + get_stockpyl_version, + load_run, + load_scenario_input, + persist_run, + run_scenario, + stockpyl_simulation, +) + +FIXTURES_DIR = pathlib.Path(__file__).parent / "fixtures" / "scenarios" + +# 事前固定數值容差(acceptance #2)。所有 baseline↔stockpyl 數值比對統一用此值。 +TOL = NUMERIC_TOLERANCE + + +def make_input(**overrides) -> ScenarioInput: + """標準合成 fixture 工廠:單一 SKU、單一 PO(I0=15、Q=200、D=10×20、h=1、p=50)。""" + base = dict( + case_id="CASE-SKU-001", + scenario_id="normal", + input_version=1, + seed=0, + horizon_days=20, + sku="SKU-001", + po_id="PO-001", + po_quantity=200.0, + initial_stock=15.0, + lead_time_days=1, + holding_cost=1.0, + stockout_cost=50.0, + demand=DemandSpec(type="D", demand_list=[10.0] * 20), + assumption_source="synthetic-fixture/stockpyl-spike-v1 (hand-computable fixed demand; not enterprise data)", + ) + base.update(overrides) + return ScenarioInput(**base) + + +# ────────────────────────────────────────────────────────────────────────── +# 修正回合新增:Comparator 正確性(GPT-6 verdict 已重現之 P1) +# - 長度不同/單邊空 → match=False,不得 max_abs_diff=0 且宣稱一致 +# - 單邊 None、stockout_periods、demand_total_realized mismatch → reason 必須逐項列出 +# ────────────────────────────────────────────────────────────────────────── + +def test_comparator_length_mismatch_is_not_a_match(): + a = ScenarioResult(engine="baseline", inventory_levels=[1.0]) + b = ScenarioResult(engine="stockpyl", inventory_levels=[]) + cmp = compare_results(a, b) + assert cmp["inventory_levels"]["match"] is False + assert cmp["inventory_levels"]["max_abs_diff"] is None # 不能是 0.0 + assert "inventory_levels" in cmp["reason"] + + +def test_comparator_one_sided_empty_is_not_a_match(): + a = ScenarioResult(engine="baseline", inventory_levels=[]) + b = ScenarioResult(engine="stockpyl", inventory_levels=[]) + cmp = compare_results(a, b) + assert cmp["inventory_levels"]["match"] is False # 無資料可比較=不得宣稱一致 + assert "inventory_levels" in cmp["reason"] + + +def test_comparator_one_sided_none_scalar_is_listed_in_reason(): + a = ScenarioResult( + engine="baseline", inventory_levels=[1.0], fill_rate=0.5, holding_cost=1.0, + stockout_cost=2.0, total_cost=3.0, mean_cost_per_day=0.1, stockout_periods=0, + demand_total_realized=10.0, first_stockout_period=None, + ) + b = ScenarioResult( + engine="stockpyl", inventory_levels=[1.0], fill_rate=0.5, holding_cost=1.0, + stockout_cost=2.0, total_cost=None, mean_cost_per_day=0.1, stockout_periods=0, + demand_total_realized=10.0, first_stockout_period=None, + ) + cmp = compare_results(a, b) + assert cmp["total_cost"]["match"] is False + assert cmp["total_cost"]["abs_diff"] is None + assert "total_cost" in cmp["reason"] + + +def test_comparator_stockout_periods_mismatch_is_listed_in_reason(): + a = ScenarioResult(engine="baseline", stockout_periods=1, inventory_levels=[1.0]) + b = ScenarioResult(engine="stockpyl", stockout_periods=2, inventory_levels=[1.0]) + cmp = compare_results(a, b) + assert cmp["stockout_periods"]["match"] is False + assert cmp["stockout_periods"]["abs_diff"] == 1 + assert "stockout_periods" in cmp["reason"] + + +def test_comparator_demand_total_realized_mismatch_is_listed_in_reason(): + a = ScenarioResult(engine="baseline", demand_total_realized=10.0, inventory_levels=[1.0]) + b = ScenarioResult(engine="stockpyl", demand_total_realized=20.0, inventory_levels=[1.0]) + cmp = compare_results(a, b) + assert cmp["demand_total_realized"]["match"] is False + assert "demand_total_realized" in cmp["reason"] + + +def test_comparator_reason_lists_every_mismatched_field(): + # 同時多欄位錯(含單邊 None):reason 必須逐項列出每個 mismatch 欄位名。 + a = ScenarioResult( + engine="baseline", inventory_levels=[1.0, 2.0], fill_rate=0.5, + total_cost=None, stockout_periods=1, demand_total_realized=10.0, + ) + b = ScenarioResult( + engine="stockpyl", inventory_levels=[3.0], fill_rate=0.9, + total_cost=1.0, stockout_periods=2, demand_total_realized=20.0, + ) + cmp = compare_results(a, b) + for f in ("inventory_levels", "fill_rate", "total_cost", "stockout_periods", "demand_total_realized"): + assert cmp[f]["match"] is False, f + for f in ("inventory_levels", "fill_rate", "total_cost", "stockout_periods", "demand_total_realized"): + assert f in cmp["reason"], f"reason 未列出 {f}: {cmp['reason']}" + + +# ────────────────────────────────────────────────────────────────────────── +# 修正回合新增:seed 測試弱點(GPT-6 verdict #2) +# - canonical bytes 內含 seed,不能拿來證明 seed 影響數值 +# - 直接比較數值軌跡/需求實現/成本欄位 +# ────────────────────────────────────────────────────────────────────────── + +def test_seed_changes_actual_numeric_trajectory(): + """不同 seed → 至少一個實際數值欄位改變(非 canonical bytes 差異)。""" + def run(seed): + return stockpyl_simulation( + make_input( + seed=seed, + scenario_id="stochastic", + demand=DemandSpec(type="N", mean=10.0, standard_deviation=3.0), + ) + ) + + s0 = run(0) + s1 = run(12345) + changed = ( + s0.demand_total_realized != s1.demand_total_realized + or s0.inventory_levels != s1.inventory_levels + or s0.total_cost != s1.total_cost + ) + assert changed, ( + f"seed 0 vs 12345 的數值完全相同:demand_total_realized " + f"{s0.demand_total_realized} vs {s1.demand_total_realized}" + ) + # 同 seed 重跑仍位元組一致(既有 acceptance #1) + assert run(0).canonical_bytes() == run(0).canonical_bytes() + + +# ────────────────────────────────────────────────────────────────────────── +# 修正回合新增:LLM 硬邊界(GPT-6 verdict #4) +# - llm.explain() 不得拿到與正式 ScenarioRun 共用的可變參照 +# - adapter 直接改 baseline/stockpyl 數值、巢狀 inventory list、inputs,甚至改完拋錯 +# - 正式 run 必須完全不變 +# ────────────────────────────────────────────────────────────────────────── + +class _MutatingLLM: + """惡意 adapter:直接修改傳入物件的數值欄位與巢狀 list。""" + + def explain(self, run): + run.baseline.fill_rate = 999.0 + run.baseline.total_cost = -1.0 + run.baseline.inventory_levels[0] = 999.0 + run.inputs.lead_time_days = 99 + return "mutated the run in place" + + +def test_llm_receives_snapshot_cannot_mutate_run(): + inp = make_input(lead_time_days=3) + before = deterministic_baseline(inp) + run = run_scenario(inp, engine="both", llm=_MutatingLLM()) + assert run.baseline.fill_rate == before.fill_rate + assert run.baseline.total_cost == before.total_cost + assert run.baseline.inventory_levels[0] == before.inventory_levels[0] + assert run.inputs.lead_time_days == 3 + assert run.stockpyl is not None + assert run.stockpyl.fill_rate is not None # 數值仍正常產出 + + +class _MutateThenRaiseLLM: + """惡意 adapter:先改數值,再丟例外。""" + + def explain(self, run): + run.baseline.total_cost = -1.0 + run.inputs.lead_time_days = 99 + raise RuntimeError("boom after mutation") + + +def test_llm_mutation_then_exception_does_not_affect_results(): + inp = make_input(lead_time_days=3) + run = run_scenario(inp, engine="both", llm=_MutateThenRaiseLLM()) + assert run.baseline.total_cost == pytest.approx(2620.0, abs=TOL) # 單一 PO:L=3,p=50 手算 2620 + assert run.inputs.lead_time_days == 3 + assert run.explanation is None + assert any("llm_explanation_failed" in f for f in run.failures) + + +# ────────────────────────────────────────────────────────────────────────── +# 修正回合新增:輸入 fail-closed 與假設範圍(GPT-6 verdict #5) +# ────────────────────────────────────────────────────────────────────────── + +def test_fractional_lead_time_fails_closed_in_common_validation(): + """lead_time_days=1.5 必須在共同 validate 層 fail-closed,不得 round() 成 2。""" + inp = make_input(lead_time_days=1.5) + v = inp.validate() + assert not v.ok + assert "lead_time_days" in v.needs_input + run = run_scenario(inp, engine="both") + assert "lead_time_days" in run.baseline.needs_input + assert "lead_time_days" in run.stockpyl.needs_input + assert run.baseline.inventory_levels == [] + assert run.baseline.total_cost is None # 不得用 lead=2 算出一組成本 + + +def test_synthetic_fixture_inputs_all_marked_as_assumptions(): + """initial_stock、PO quantity/arrival、demand、交期、成本全部要標 assumption+source。""" + inp = make_input(lead_time_days=3) + res = deterministic_baseline(inp) + marked = {a["field"] for a in res.assumptions} + assert {"initial_stock", "po_quantity", "lead_time_days", "demand", + "holding_cost", "stockout_cost"} <= marked + for a in res.assumptions: + assert a["assumption"] is True + assert a["source"] + + +# ────────────────────────────────────────────────────────────────────────── +# 修正回合新增:persist 路徑穿越(GPT-6 verdict #6) +# ────────────────────────────────────────────────────────────────────────── + +def test_persist_rejects_path_traversal_identifiers(tmp_path): + """case_id/scenario_id 不得造成絕對路徑、.. 或分隔符越過 out_dir。""" + for field in ("case_id", "scenario_id"): + for bad in ("/tmp/outside", "../evil", "a/b", "..", "...", ".", "C:\\evil", "x" * 100): + inp = make_input(lead_time_days=3, **{field: bad}) + run = run_scenario(inp, engine="baseline") + with pytest.raises(ValueError): + persist_run(run, str(tmp_path)) + assert list(tmp_path.iterdir()) == [] # 全部被拒絕,out_dir 內不該寫出任何檔案 + + +def test_persist_valid_ids_stay_inside_out_dir(tmp_path): + inp = make_input(lead_time_days=3, case_id="CASE-A", scenario_id="normal", po_id="PO-001") + run = run_scenario(inp, engine="baseline") + path = pathlib.Path(persist_run(run, str(tmp_path))) + assert path.exists() + assert path.resolve().parent == pathlib.Path(str(tmp_path)).resolve() + + +# ────────────────────────────────────────────────────────────────────────── +# 修正回合新增:單一 SKU、單一 PO 契約語意(GPT-6 verdict #3) +# - 契約須有明示 PO quantity 與 arrival timing/lead +# - horizon 內只允許該 PO 一次到貨(不是無限供應 + 每期補貨) +# - po_id 參與保存/輸出語意 +# - 手算預期(I0=15、Q=200、D=10×20、h=1、p=50、到貨於 t=lead): +# L=1: IL=[5,195..15]、fill=1.0、hold=2000、so=0、total=2000 +# L=2: IL=[5,-5,185..15]、fill=0.975、hold=1805、so=250、total=2055 +# L=3: IL=[5,-5,-15,175..15]、fill=0.925、hold=1620、so=1000、total=2620 +# ────────────────────────────────────────────────────────────────────────── + +def test_single_po_contract_requires_explicit_po_fields(): + v = make_input(po_quantity=None).validate() + assert "po_quantity" in v.needs_input + assert not v.ok + v2 = make_input(po_id=None).validate() + assert "po_id" in v2.needs_input + v3 = make_input(po_id="").validate() + assert "po_id" in v3.needs_input + v4 = make_input(po_quantity=0.0).validate() + assert "po_quantity" in v4.needs_input + # 到貨 timing 必須落在 horizon 內 + v5 = make_input(lead_time_days=20).validate() + assert "lead_time_days" in v5.needs_input + + +def test_single_po_baseline_hand_calc_normal(): + res = deterministic_baseline(make_input(lead_time_days=1)) + assert res.inventory_levels == [5.0] + [195.0 - 10.0 * k for k in range(19)] + assert res.first_stockout_period is None + assert res.stockout_periods == 0 + assert res.fill_rate == pytest.approx(1.0, abs=TOL) + assert res.holding_cost == pytest.approx(2000.0, abs=TOL) + assert res.stockout_cost == pytest.approx(0.0, abs=TOL) + assert res.total_cost == pytest.approx(2000.0, abs=TOL) + + +def test_single_po_baseline_hand_calc_substitute(): + res = deterministic_baseline(make_input(lead_time_days=2)) + assert res.inventory_levels == [5.0, -5.0] + [185.0 - 10.0 * k for k in range(18)] + assert res.first_stockout_period == 1 + assert res.fill_rate == pytest.approx(195.0 / 200.0, abs=TOL) + assert res.holding_cost == pytest.approx(1805.0, abs=TOL) + assert res.stockout_cost == pytest.approx(250.0, abs=TOL) + assert res.total_cost == pytest.approx(2055.0, abs=TOL) + + +def test_single_po_baseline_hand_calc_disruption(): + res = deterministic_baseline(make_input(lead_time_days=3)) + assert res.inventory_levels == [5.0, -5.0, -15.0] + [175.0 - 10.0 * k for k in range(17)] + assert res.first_stockout_period == 1 + assert res.fill_rate == pytest.approx(185.0 / 200.0, abs=TOL) + assert res.holding_cost == pytest.approx(1620.0, abs=TOL) + assert res.stockout_cost == pytest.approx(1000.0, abs=TOL) + assert res.total_cost == pytest.approx(2620.0, abs=TOL) + + +def test_single_po_only_one_arrival_baseline(): + assert deterministic_baseline(make_input(lead_time_days=1)).po_arrivals == [[1, 200.0]] + assert deterministic_baseline(make_input(lead_time_days=2)).po_arrivals == [[2, 200.0]] + assert deterministic_baseline(make_input(lead_time_days=3)).po_arrivals == [[3, 200.0]] + + +def test_single_po_stockpyl_matches_baseline_and_one_arrival(): + for lead in (1, 2, 3): + inp = make_input(lead_time_days=lead) + base = deterministic_baseline(inp) + sp = stockpyl_simulation(inp) + assert sp.po_arrivals == [[lead, 200.0]], f"lead={lead}: po_arrivals={sp.po_arrivals}" + assert sp.inventory_levels == base.inventory_levels + assert sp.first_stockout_period == base.first_stockout_period + assert sp.fill_rate == pytest.approx(base.fill_rate, abs=TOL) + assert sp.holding_cost == pytest.approx(base.holding_cost, abs=TOL) + assert sp.stockout_cost == pytest.approx(base.stockout_cost, abs=TOL) + assert sp.total_cost == pytest.approx(base.total_cost, abs=TOL) + assert sp.inventory_levels[-1] == pytest.approx(15.0, abs=TOL) # I0+Q−總需求 + + +def test_po_id_and_quantity_participate_in_result_and_canonical(): + a = deterministic_baseline(make_input(po_id="PO-001")) + b = deterministic_baseline(make_input(po_id="PO-002")) + assert a.po_id == "PO-001" + assert b.po_id == "PO-002" + assert a.po_quantity == 200.0 + payload_a = json.loads(a.canonical_bytes()) + assert payload_a["po_id"] == "PO-001" + assert payload_a["po_quantity"] == 200.0 + assert payload_a["po_arrivals"] == [[1, 200.0]] + assert a.canonical_bytes() != b.canonical_bytes() + + +# ────────────────────────────────────────────────────────────────────────── +# Acceptance 5:缺少必要需求或交期 → needs_input;成本缺失不得回傳推測成本; +# 明示合成假設標示 assumption 與來源。 +# ────────────────────────────────────────────────────────────────────────── + +def test_missing_demand_returns_needs_input(): + inp = make_input(demand=DemandSpec(type="D", demand_list=None)) + run = run_scenario(inp, engine="baseline") + assert "demand" in run.baseline.needs_input + assert run.baseline.inventory_levels == [] + assert run.baseline.fill_rate is None + + +def test_missing_lead_time_returns_needs_input(): + inp = make_input(lead_time_days=None) + run = run_scenario(inp, engine="baseline") + assert "lead_time_days" in run.baseline.needs_input + + +def test_missing_normal_distribution_params_returns_needs_input(): + inp = make_input(demand=DemandSpec(type="N", mean=10.0, standard_deviation=None)) + run = run_scenario(inp, engine="baseline") + assert "demand" in run.baseline.needs_input + + +def test_missing_cost_is_not_guessed(): + inp = make_input(lead_time_days=3, holding_cost=None, stockout_cost=None) + res = deterministic_baseline(inp) + assert res.costs_provided is False + assert res.holding_cost is None + assert res.stockout_cost is None + assert res.total_cost is None + assert res.mean_cost_per_day is None + # 庫存 / 缺貨 / 填充率仍可計算(不需成本) + assert res.inventory_levels + assert res.fill_rate is not None + assert res.first_stockout_period == 1 + + +def test_provided_synthetic_assumptions_are_marked_with_source(): + inp = make_input( + holding_cost=1.0, + stockout_cost=50.0, + assumption_source="synthetic-fixture/stockpyl-spike-v1", + ) + res = deterministic_baseline(inp) + assert res.costs_provided is True + marked = {a["field"] for a in res.assumptions} + assert {"holding_cost", "stockout_cost", "demand", "lead_time_days", + "initial_stock", "po_quantity"} <= marked + for a in res.assumptions: + assert a["source"] == "synthetic-fixture/stockpyl-spike-v1" + assert a["assumption"] is True + + +# ────────────────────────────────────────────────────────────────────────── +# Acceptance 2(Stockpyl 對照):固定容差 + 差異原因紀錄。 +# ────────────────────────────────────────────────────────────────────────── + +def test_stockpyl_matches_baseline_within_tolerance(): + for lead in (1, 2, 3): + inp = make_input(lead_time_days=lead) + base = deterministic_baseline(inp) + sp = stockpyl_simulation(inp) + cmp = compare_results(base, sp) + assert cmp["inventory_levels"]["max_abs_diff"] <= TOL + for f in ("fill_rate", "holding_cost", "stockout_cost", "total_cost", + "mean_cost_per_day", "stockout_periods", "demand_total_realized", + "first_stockout_period"): + assert cmp[f]["match"] is True, f"lead={lead} 欄位 {f} 不一致: {cmp[f]}" + assert cmp["po_id"]["match"] is True + assert cmp["po_arrivals"]["match"] is True + # 差異原因必須被記錄(不能是空字串) + assert cmp["reason"] + + +def test_stockpyl_version_reported(): + assert get_stockpyl_version() == "1.0.2" + + +# ────────────────────────────────────────────────────────────────────────── +# Acceptance 3:正常 / 中斷 / 替代 fixture 的預期關係(先寫入測試, +# 只對受控 fixture 驗證,不宣稱任意隨機模型必然單調)。 +# ────────────────────────────────────────────────────────────────────────── + +def test_fixture_files_exist_and_load(): + for name in ("normal_single_sku.json", "substitute_single_sku.json", "disruption_single_sku.json"): + path = FIXTURES_DIR / name + assert path.exists(), f"fixture 缺少 {name}" + inp = load_scenario_input(str(path)) + assert inp.validate().ok + assert inp.po_quantity == 200.0 + assert inp.po_id + res = deterministic_baseline(inp) + assert res.inventory_levels + assert res.fill_rate is not None + + +def test_fixture_expected_relationships(): + normal = stockpyl_simulation(load_scenario_input(str(FIXTURES_DIR / "normal_single_sku.json"))) + substitute = stockpyl_simulation(load_scenario_input(str(FIXTURES_DIR / "substitute_single_sku.json"))) + disruption = stockpyl_simulation(load_scenario_input(str(FIXTURES_DIR / "disruption_single_sku.json"))) + + # 手算基準(單一 PO、到貨 t=lead、p=50) + assert normal.fill_rate == pytest.approx(1.0, abs=TOL) + assert normal.first_stockout_period is None + assert normal.total_cost == pytest.approx(2000.0, abs=TOL) + + assert substitute.fill_rate == pytest.approx(195.0 / 200.0, abs=TOL) + assert substitute.first_stockout_period == 1 + assert substitute.total_cost == pytest.approx(2055.0, abs=TOL) + + assert disruption.fill_rate == pytest.approx(185.0 / 200.0, abs=TOL) + assert disruption.first_stockout_period == 1 + assert disruption.total_cost == pytest.approx(2620.0, abs=TOL) + + # 預期關係(只對受控 fixture 驗證) + assert disruption.fill_rate < normal.fill_rate + assert substitute.fill_rate >= disruption.fill_rate + + def _order(so): # None(永不缺貨)視為 +inf + return math.inf if so is None else so + + assert _order(substitute.first_stockout_period) >= _order(disruption.first_stockout_period) + assert substitute.fill_rate <= normal.fill_rate + + # 成本排序(嚴格遞增):2000 < 2055 < 2620 + assert normal.total_cost < substitute.total_cost < disruption.total_cost + + # 單一 PO:三個 fixture 都只有一次到貨 + for res in (normal, substitute, disruption): + assert len(res.po_arrivals) == 1 + + +# ────────────────────────────────────────────────────────────────────────── +# Acceptance 1:同一輸入、版本、seed 重跑 → canonical bytes 一致(排除時間)。 +# ────────────────────────────────────────────────────────────────────────── + +def test_deterministic_fixture_rerun_is_byte_stable(): + inp = make_input(lead_time_days=3) + a = run_scenario(inp, engine="both") + b = run_scenario(inp, engine="both") + assert a.baseline.canonical_bytes() == b.baseline.canonical_bytes() + assert a.stockpyl.canonical_bytes() == b.stockpyl.canonical_bytes() + + +def test_seeded_stochastic_rerun_is_byte_stable(): + inp = make_input( + scenario_id="stochastic", + demand=DemandSpec(type="N", mean=10.0, standard_deviation=3.0), + ) + a = stockpyl_simulation(inp) + b = stockpyl_simulation(inp) + assert a.canonical_bytes() == b.canonical_bytes() + + +def test_canonical_bytes_exclude_execution_metadata(): + inp = make_input(lead_time_days=3) + r1 = deterministic_baseline(inp) + r1.executed_at = "2026-01-01T00:00:00" + r1.execution_time_seconds = 1.0 + r2 = deterministic_baseline(inp) + r2.executed_at = "2026-12-31T23:59:59" + r2.execution_time_seconds = 999.0 + assert r1.canonical_bytes() == r2.canonical_bytes() + + +# ────────────────────────────────────────────────────────────────────────── +# Acceptance 4:LLM adapter 呼叫即失敗時,數值計算仍完成;LLM 不得覆寫數值。 +# ────────────────────────────────────────────────────────────────────────── + +def test_failing_llm_does_not_block_numeric_results(): + inp = make_input(lead_time_days=3) + run = run_scenario(inp, engine="both", llm=FailingLLMAdapter()) + assert run.baseline.fill_rate is not None + assert run.baseline.total_cost is not None + assert run.stockpyl.fill_rate is not None + assert run.stockpyl.total_cost is not None + assert run.explanation is None # LLM 失敗不阻斷,也不塞假解釋 + + +class _NumberOverwritingLLM: + """惡意 adapter:試圖回傳一組與引擎不同的數字。""" + + def explain(self, run): + return "fill_rate=999.0 total_cost=-1.0 這是 LLM 自行編造的數字" + + +def test_llm_cannot_overwrite_numeric_fields(): + inp = make_input(lead_time_days=3) + before = stockpyl_simulation(inp) + run = run_scenario(inp, engine="stockpyl", llm=_NumberOverwritingLLM()) + assert run.stockpyl.fill_rate == before.fill_rate + assert run.stockpyl.total_cost == before.total_cost + assert "999.0" not in str(run.stockpyl.canonical_bytes()) + assert run.explanation == "fill_rate=999.0 total_cost=-1.0 這是 LLM 自行編造的數字" + + +# ────────────────────────────────────────────────────────────────────────── +# Acceptance 6:保存實際執行結果、版本、seed 與失敗項目。 +# ────────────────────────────────────────────────────────────────────────── + +def test_persist_and_reload_run(tmp_path): + inp = make_input(lead_time_days=3) + run = run_scenario(inp, engine="both") + path = persist_run(run, str(tmp_path)) + assert pathlib.Path(path).exists() + assert "PO-001" in pathlib.Path(path).name # po_id 參與保存檔名語意 + payload = json.loads(pathlib.Path(path).read_text(encoding="utf-8")) + assert payload["engine_version"] == SCENARIO_ENGINE_VERSION + assert payload["stockpyl_version"] == get_stockpyl_version() + assert payload["inputs"]["seed"] == 0 + assert payload["inputs"]["input_version"] == 1 + assert payload["inputs"]["po_quantity"] == 200.0 + assert payload["failures"] == [] + assert payload["results"]["stockpyl"]["total_cost"] is not None + + loaded = load_run(path) + assert loaded.inputs.seed == 0 + assert loaded.stockpyl.total_cost is not None + assert loaded.stockpyl.po_id == "PO-001" + assert loaded.stockpyl.po_arrivals == [[3, 200.0]] + assert loaded.stockpyl.canonical_bytes() == run.stockpyl.canonical_bytes() + + +def test_persist_records_failures(tmp_path): + inp = make_input(demand=DemandSpec(type="D", demand_list=None)) + run = run_scenario(inp, engine="both") + assert run.failures + path = persist_run(run, str(tmp_path)) + payload = json.loads(pathlib.Path(path).read_text(encoding="utf-8")) + assert payload["failures"] + assert any("demand" in f for f in payload["failures"]) + + +# ────────────────────────────────────────────────────────────────────────── +# 第二修正回合新增(GPT-6 最終 verdict): +# §1 comparator 對 NaN/Infinity/不可比較值 fail closed +# §2 共同 validate 層型別/有限性/整數語意/值域攔截 + demand_list 逐項檢查 +# §3 Stockpyl adapter 殘留訂單簿記 + 1e12 冒充無限造成的到貨可用時間錯位 +# ────────────────────────────────────────────────────────────────────────── + +# ---- §1 comparator:非有限/不可比較值 → match=False、max_abs_diff=None、reason 明列欄位與索引 ---- + +@pytest.mark.parametrize("idx", [0, 1, 2]) +def test_comparator_nan_at_index_fails_closed(idx): + """NaN 出現在任一索引(首、中、末)→ match=False、max_abs_diff=None、reason 列索引。 + + max() 不傳播後續 NaN:NaN 在 idx>0 時舊實作 max_abs_diff=0.0 且 match=True(P1)。 + """ + a = ScenarioResult(engine="baseline", inventory_levels=[1.0, 2.0, 3.0]) + b_il = [1.0, 2.0, 3.0] + b_il[idx] = math.nan + b = ScenarioResult(engine="stockpyl", inventory_levels=b_il) + cmp = compare_results(a, b) + assert cmp["inventory_levels"]["match"] is False + assert cmp["inventory_levels"]["max_abs_diff"] is None + assert "inventory_levels" in cmp["reason"] + assert str(idx) in cmp["reason"] + + +def test_comparator_infinity_single_sided_fails_closed(): + """單邊 Infinity → match=False、max_abs_diff=None、reason 列欄位與索引。""" + a = ScenarioResult(engine="baseline", inventory_levels=[1.0, 2.0]) + b = ScenarioResult(engine="stockpyl", inventory_levels=[math.inf, 2.0]) + cmp = compare_results(a, b) + assert cmp["inventory_levels"]["match"] is False + assert cmp["inventory_levels"]["max_abs_diff"] is None + assert "inventory_levels" in cmp["reason"] + assert "0" in cmp["reason"] + + +def test_comparator_infinity_double_sided_fails_closed(): + """雙邊 Infinity(inf-inf=nan)→ match=False、max_abs_diff=None。""" + a = ScenarioResult(engine="baseline", inventory_levels=[math.inf, 2.0]) + b = ScenarioResult(engine="stockpyl", inventory_levels=[math.inf, 2.0]) + cmp = compare_results(a, b) + assert cmp["inventory_levels"]["match"] is False + assert cmp["inventory_levels"]["max_abs_diff"] is None + assert "inventory_levels" in cmp["reason"] + + +def test_comparator_non_comparable_value_fails_closed_without_raise(): + """不可比較值(字串)→ 回傳結構化 mismatch,不得拋 TypeError。""" + a = ScenarioResult(engine="baseline", inventory_levels=[1.0, 2.0]) + b = ScenarioResult(engine="stockpyl", inventory_levels=["x", 2.0]) + cmp = compare_results(a, b) # 舊實作在此拋 TypeError + assert cmp["inventory_levels"]["match"] is False + assert cmp["inventory_levels"]["max_abs_diff"] is None + assert "0" in cmp["reason"] + + +def test_comparator_scalar_nan_fails_closed_and_listed(): + """scalar 雙邊 NaN → match=False、abs_diff=None、reason 列出欄位名。""" + a = ScenarioResult(engine="baseline", inventory_levels=[1.0], fill_rate=math.nan) + b = ScenarioResult(engine="stockpyl", inventory_levels=[1.0], fill_rate=math.nan) + cmp = compare_results(a, b) + assert cmp["fill_rate"]["match"] is False + assert cmp["fill_rate"]["abs_diff"] is None + assert "fill_rate" in cmp["reason"] + + +def test_comparator_scalar_infinity_fails_closed_and_listed(): + """scalar 單邊 Infinity → match=False、abs_diff=None、reason 列出欄位名。""" + a = ScenarioResult(engine="baseline", inventory_levels=[1.0], total_cost=math.inf) + b = ScenarioResult(engine="stockpyl", inventory_levels=[1.0], total_cost=100.0) + cmp = compare_results(a, b) + assert cmp["total_cost"]["match"] is False + assert cmp["total_cost"]["abs_diff"] is None + assert "total_cost" in cmp["reason"] + + +# ---- §2 共同 validate 層:型別、有限性、整數語意、值域、demand_list 逐項 ---- + +def test_validation_demand_list_none_element_needs_input(): + """demand_list 逐期缺一項(None)仍是缺必要需求 → needs_input,不得進引擎。""" + dl = [10.0] * 20 + dl[1] = None + inp = make_input(demand=DemandSpec(type="D", demand_list=dl)) + v = inp.validate() + assert not v.ok + assert "demand" in v.needs_input + assert any("demand_list[1]" in i for i in v.issues) + run = run_scenario(inp, engine="both") + assert run.baseline is not None and "demand" in run.baseline.needs_input + assert run.stockpyl is not None and "demand" in run.stockpyl.needs_input + assert run.baseline.inventory_levels == [] + assert run.stockpyl.inventory_levels == [] + assert any("missing required input" in f for f in run.failures) + + +def test_validation_horizon_float_rejected(): + """horizon_days=20.0(float)→ 共同 validate 明確拒絕,不得進引擎(range(20.0) 會崩)。""" + inp = make_input(horizon_days=20.0) + v = inp.validate() + assert not v.ok + assert "horizon_days" in v.needs_input + run = run_scenario(inp, engine="both") + assert run.baseline is not None and "horizon_days" in run.baseline.needs_input + assert run.stockpyl is not None and "horizon_days" in run.stockpyl.needs_input + assert run.baseline.inventory_levels == [] + assert any("missing required input" in f for f in run.failures) + + +def test_validation_lead_time_string_returns_needs_input_not_typeerror(): + """lead_time_days=\"1\" → needs_input,validate 不得拋未捕捉 TypeError。""" + inp = make_input(lead_time_days="1") + v = inp.validate() # 舊實作在此拋 TypeError + assert not v.ok + assert "lead_time_days" in v.needs_input + run = run_scenario(inp, engine="both") + assert run.baseline is not None and "lead_time_days" in run.baseline.needs_input + assert run.stockpyl is not None and "lead_time_days" in run.stockpyl.needs_input + + +def test_validation_po_quantity_string_returns_needs_input_not_typeerror(): + """po_quantity=\"200\" → needs_input,validate 不得拋未捕捉 TypeError。""" + inp = make_input(po_quantity="200") + v = inp.validate() # 舊實作在此拋 TypeError + assert not v.ok + assert "po_quantity" in v.needs_input + + +@pytest.mark.parametrize("field,val", [ + ("initial_stock", math.nan), + ("initial_stock", math.inf), + ("po_quantity", math.nan), + ("po_quantity", math.inf), +]) +def test_validation_nan_infinity_rejected(field, val): + """NaN/Infinity 輸入 → needs_input,不得被接受而產出非有限結果。""" + inp = make_input(**{field: val}) + v = inp.validate() + assert not v.ok, f"{field}={val} 不應通過驗證" + assert field in v.needs_input + + +def test_validation_lead_time_infinity_rejected_not_overflow(): + """lead_time_days=inf → needs_input;validate 不得對非有限值拋 OverflowError。 + + (舊實作對 inf 已回 needs_input;本測試為回歸 pin,鎖定行為不得退化, + 且 _is_whole_number 不得在 inf 上呼叫 is_integer()。) + """ + inp = make_input(lead_time_days=math.inf) + v = inp.validate() + assert not v.ok + assert "lead_time_days" in v.needs_input + + +def test_validation_negative_demand_rejected(): + """負需求 → needs_input(舊實作接受,Stockpyl fill_rate 可 >1)。""" + dl = [10.0] * 20 + dl[3] = -5.0 + inp = make_input(demand=DemandSpec(type="D", demand_list=dl)) + v = inp.validate() + assert not v.ok + assert "demand" in v.needs_input + assert any("demand_list[3]" in i for i in v.issues) + + +def test_validation_bad_inputs_fail_closed_via_both_engines(): + """不合約輸入跑兩引擎 → 全部回 needs_input 空結果,無任何未捕捉例外。""" + bad_dl = [10.0] * 20 + bad_dl[1] = None + cases = [ + make_input(horizon_days=20.0), + make_input(lead_time_days="1"), + make_input(po_quantity="200"), + make_input(initial_stock=math.nan), + make_input(po_quantity=math.inf), + make_input(demand=DemandSpec(type="D", demand_list=bad_dl)), + ] + for inp in cases: + v = inp.validate() + assert not v.ok + run = run_scenario(inp, engine="both") + assert run.baseline is not None and run.baseline.needs_input + assert run.stockpyl is not None and run.stockpyl.needs_input + assert run.baseline.inventory_levels == [] + assert run.failures + + +# ---- §3 Stockpyl adapter:殘留訂單簿記 + 1e12 冒充無限的到貨可用時間錯位 ---- + +def test_stockpyl_bookkeeping_single_po_no_residual_orders(): + """簿記核對:FG policy 訂單只在 t=0 一筆(與 override 同步)、期末 pending/raw 歸零。 + + 同時核對實體到貨(po_arrivals)、可用庫存(inventory_levels vs baseline)。 + 舊實作 FG 訂單每期 200、期末 pending_finished_goods=3800(未揭露的政策簿記)。 + """ + for lead in (0, 1, 2, 3): + inp = make_input(lead_time_days=lead) + sp = stockpyl_simulation(inp) + bk = sp.order_bookkeeping + assert bk is not None and len(bk) == inp.horizon_days, f"lead={lead}: 缺逐期簿記" + assert bk[0]["order_quantity_fg"] == pytest.approx(200.0, abs=TOL), f"lead={lead}: t=0 FG 訂單應為 Q" + assert all(b["order_quantity_fg"] == 0.0 for b in bk[1:]), f"lead={lead}: t>0 不得有殘留 FG 訂單" + assert bk[-1]["pending_finished_goods"] == pytest.approx(0.0, abs=TOL), f"lead={lead}: 期末 pending 非零" + assert bk[-1]["raw_material_inventory"] == pytest.approx(0.0, abs=TOL), f"lead={lead}: 期末 raw 非零" + assert sp.po_arrivals == [[lead, 200.0]], f"lead={lead}: 到貨事件錯誤 {sp.po_arrivals}" + assert sp.inventory_levels == deterministic_baseline(inp).inventory_levels, f"lead={lead}: 可用庫存錯位" + + +@pytest.mark.parametrize("lead", [1, 2, 3]) +def test_stockpyl_large_initial_stock_matches_baseline(lead): + """GPT-6 大 initial_stock 反例:到貨期當期可用庫存必須立即含 Q(不得晚一期)。 + + 舊實作(reorder_point=1e12 冒充無限):initial_stock=1000000000100 時 t=lead 可用 + 庫存少 200(t=lead+1 才轉 FG),total_cost 少 200;po_arrivals 卻仍記 t=lead 到貨。 + """ + inp = make_input(lead_time_days=lead, initial_stock=1000000000100.0) + base = deterministic_baseline(inp) + sp = stockpyl_simulation(inp) + assert sp.po_arrivals == [[lead, 200.0]], f"lead={lead}: 到貨事件 {sp.po_arrivals}" + assert sp.inventory_levels == base.inventory_levels, ( + f"lead={lead}: 可用庫存錯位\n base={base.inventory_levels[:4]}\n sp ={sp.inventory_levels[:4]}" + ) + # 手算:I0=1000000000100,t