Metadata-Version: 2.4
Name: aps-kernel
Version: 1.67.0
Summary: Deterministic CP-SAT scheduling kernel, independent schedule validator, and headless CLI — APS standard edition
Author: APS Contributors
License: MIT
Project-URL: Homepage, https://aps.zhinengti.online
Project-URL: Documentation, https://github.com/123aiot/aps/tree/main/docs
Project-URL: Source, https://github.com/123aiot/aps
Project-URL: Changelog, https://github.com/123aiot/aps/blob/main/CHANGELOG.md
Keywords: aps,scheduling,production-planning,cpsat,ortools,finite-capacity,manufacturing,aps-kernel
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Manufacturing
Classifier: Intended Audience :: Science/Research
Classifier: Natural Language :: Chinese (Simplified)
Classifier: Natural Language :: English
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Scientific/Engineering
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
Classifier: License :: OSI Approved :: MIT License
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: ortools>=9.10
Requires-Dist: pyyaml>=6.0
Provides-Extra: dev
Requires-Dist: pytest>=8.0; extra == "dev"
Requires-Dist: ruff>=0.6; extra == "dev"
Requires-Dist: mypy>=1.10; extra == "dev"
Provides-Extra: byoe
Requires-Dist: byoe>=3.7; extra == "byoe"
Dynamic: license-file

# aps-kernel — Metis 谋序（APS 标准版）

> **运筹帷幄，谋定后动** —— 同衡·谋序·同枢，智造同频。
> One Truth. One Plan. One Rhythm.

可嵌入的**确定性**排产内核：CP-SAT 精确求解、**独立复核器**、
工序-资源适配数据模型、设备槽位与班次日历、ε-约束 trade-off、headless CLI。

> **标准版 vs 专业版**：本包只含可嵌入内核（无 UI / 无服务 / 无 LLM）。
> 甘特图前端、Web 控制台、AI 编排层、多租户 RLS、审批与 IM 通道
> 属专业版（Docker 镜像 + docker compose）。

🌐 官网：https://aps.zhinengti.online
家族：Nereus 同衡（数字孪生）· Metis 谋序（智能排产）· Hermes 同枢（物流调度）

---

## ⭐ 求解能力（横向基准，非自说自话）

在 **16 个标准 FJSP 实例**（ft/la/abz/orb/swv/ta/yn，来源 `job_shop_lib` MIT，
含**文献公认最优值**）上的实测：

| 实例 | 工单×机器 | SPT 基线 | 规则链 | **CP-SAT** | 文献最优 | vs 最优 |
|---|---|---|---|---|---|---|
| ft06 | 6×6 | 94 | 92 | **55** | 55 | **+0.0% OPTIMAL** |
| ft10 | 10×10 | 1530 | 1310 | **930** | 930 | **+0.0% OPTIMAL** |
| ft20 | 20×5 | 1513 | 1565 | **1165** | 1165 | **+0.0% OPTIMAL** |
| la01-05 | 10×5 | ~1000 | ~940 | **最优** | — | **+0.0% OPTIMAL** |
| abz5/6 | 10×10 | ~1700 | ~1600 | **1234/943** | 同 | **+0.0% OPTIMAL** |
| orb01 | 10×10 | 1489 | 1631 | 1082 | 1059 | +2.2% FEASIBLE |
| swv01/02 | 20×10 | ~2100 | ~2100 | 1495/1562 | 1407/1475 | +6% FEASIBLE |
| ta01/02 | 15×15 | ~2100 | ~2040 | 1233/1248 | 1231/1244 | +0.3% FEASIBLE |
| yn1 | 20×20 | 1547 | 1509 | 944 | 884 | +6.8% FEASIBLE |

**11/16 达到文献最优**，其余为大规模实例（20 秒预算不足，已如实标 gap）。
**全部 11/11 独立复核通过。**

```bash
python benchmarks/run_standard_bench.py --time-limit 30
```

---

## 当前状态

**v1.67.0** —— **CP-SAT K 段抢占建模（I33 求解器侧）+ G3 双实例判定 + G4 浏览器级 e2e**（收尾路线 T1/T2/T4 · 零破坏 minor）。`preemptible` 工序在求解器侧获得拆段能力：`preempt_mode="on"` 时主模型与 K 段模型仲裁取更优（默认 `off` 逐位不变，A/B 数据背书）；`aps gates --check g3` 演进为**双实例判定**（默认 demo + 交期 −6 天变体各出证书，都过才算过——1.66.0 排队型下界正式进门禁判据，紧变体比值 2.48 仍判红但原因可量化）；`e2e-g4-chain.mjs` 把专业版六环节补强到真实浏览器渲染层（CDP 受信拖拽 + 409 横幅 + 热力图，16 项断言）。另交付《客户数据征集清单》（G1 筹备，I28/I30/I31 一次征集）与 T6 商业拍板（D1 专业版/D2 暂缓/D3 轻量贪心/D4 内置保守默认）。
<br><sub>v1.66.0 —— **排队型下界：容量守恒把「工单间排队」本身变成拖期下界**（G3 攻坚实质落地 · 零破坏 minor）。逐单松弛（1.49.0）只能证明「交期本身不可能」的那类拖期，demo 上恒为 0；本版新增 `queued_tardiness_lower_bound`：每工单恰取一道代表工序（不重复计账——1.65.0 已证伪「每单多道展开」路线）→ rearrangement 同升序配对 → 全局容量守恒 `完工第 k 小 ≥ min{t: Σ_r cap_r·avail_r(0,t) ≥ 前 k 小工时和}`，论证链每步严格、多候选 FJSP 天然兼容。两种代表口径（末端 / tail 修正）都合法取 max。实测 demo 30 单紧交期（-6 天）：上界/下界从「无界」收到 **2.48**（-10 天时 **1.68 ≤ 2**，达 G3 条件②水平）；demo 默认交期 LB 仍 = 0（宽松交期的数学事实，测试钉住防口径漂移）。合法性守卫测试：2×2 规模 × 4 档交期松紧全矩阵 LB ≤ 可行解 MT，外加 closed-form 微型实例精确值（LB=100）。solve 证书下界 = max(逐单松弛, 排队型)，异常安全模式沿用。

**v1.65.0** —— **松弛下界的工时口径修正：非法下界不再可能**（⚠️ 数值口径修正，默认数据零变化）。`relaxed_tardiness_lower_bound` 的单工单最快工时曾直接用名义工时 `duration + setup`——效率 >1 的设备上 fastest **偏大** ⟹ 最早完工 e_j 偏大 ⟹ **下界偏大**，而「下界」偏大就可能超过真实最优拖期（下界的数学合法性被破坏）。本版把它收编到 `effective_duration`（models 层三方同口径唯一真源：贪心 / CP-SAT / 复核器已经在用）——松弛下界成为第四个同口径消费方，且依赖方向保持干净（relaxation 不耦合求解器，守卫测试同步守住）。抓法本身就是一段方法论：为「排队型下界」（G3）做原型验证时，tail 递推链上发现 26 处 `C_o + tail(o) > C_job` 的小量差违反，全部指向这个口径差。A/B：demo 全系列（5/10/20/30 单）松弛下界与修复前完全一致（恒 0——demo 拖期全部来自排队，逐单松弛天然为 0）；唯一受影响的是「效率>1 + 紧交期」数据上的诊断数字，方向只会更保守（更合法）。反向测试用手工推演的精确期望值钉住口径：1 资源 efficiency=2.0、duration=100、交期 30 分钟 ⟹ 下界必须 = 20（旧口径会给 70，那是非法值）。同轮归档「排队型下界」研究结论（详见 `docs/` 口径档案）：**工序级展开路线已被证伪**——逐项 tail 引理成立但每工单多道工序重复计账，demo 实测工序级和 83 万 ≫ 工单级 MT 11 万；正确框架是单机 `1|r_j|ΣT_j` 下界（NP-hard，调度文献深水区），E_k 构型（容量修正 + 日历推进）与 rearrangement 方向已分别实证合法，拼装留待专门一轮。

**v1.64.0** —— **拖拽分区重排：拖一道只动受影响资源，其余纹丝不动**（P2-1 资源分区求解第一步 · `MoveRequest.scope="affected"`，零破坏 minor）。真实数据验证的核心痛点：全量重排下拖动一道工序会把 50 道里的 **46 道**全部挪动（CP-SAT 全局优化把无关工序也重新洗牌）——用户眼里「我只是挪了一块，整张甘特图都在跳」。这一版给拖拽加了**范围开关**：`scope="affected"` 只重解「受影响资源」上的工序（被拖工序旧资源 ∪ 目标资源 ∪ 全部后道的资源；前道资源不收——前道本就钉死），分区外的已排工序全部钉在基线位置（复用 whatif 同源的 frozen 机制，**不另造建模面**——技师/模具/技能池约束对冻结工序照常生效）。同算例 A/B（la03，workers=1 两次一致）：`all` 挪 46 道 / makespan +36 / 2.1s，`affected` 挪 **10 道** / makespan +58 / **<0.05s**——扰动面缩小 4.6 倍、快 40 倍以上，代价是 makespan 略差（分区自由度收窄的预期权衡，如实记录）。三条诚实纪律：① **不同的语义，不是纯优化**——`affected` 理论上可能无解而 `all` 可行，此时如实报 INFEASIBLE 并在 notes 提示可回退 `scope="all"`，**不自动回退**（自动回退 = 静默换语义）；② **非法 scope 构造期就爆**（`ValueError`），平台侧显式校验转 400，不靠内核炸成 500；③ **基线未排的工序不钉**（无位置可钉），仍自由参与竞争并在 notes 如实计数——它们可能落在新位置，这是把之前排不下的活排上，属改善而非扰动。默认 `"all"` = 老调用方行为零变化；平台 `/api/v1/reschedule` 的 `move.scope` 字段同步透传。零破坏：不新增开关、不改既有排产行为、无 MIGRATION 条目。

**v1.63.0** —— **自然语言改计划：一句话 ⟹ 可确认的 MoveRequest**（P0-3 输入理解层第 3 步 · NL→MoveRequest，零破坏 minor）。计划员的原话是「把 OP-3 挪到明天早上 8 点，用 M2」——此前这句话到结构化 `MoveRequest`（operation_id / target_start_minute / target_resource_id）之间全靠 LLM 自由发挥或人肉换算；这一版把中间层做成了**确定性解析器**（新模块 `nl_move.py` + CLI `aps parse-move` + 会话工具 `aps_parse_move`）：纯规则零 LLM（离线可用、可复现、测试可断言），同样的输入永远得到同样的输出。三条硬规矩：① **零墙钟**——「明天/周三」全部相对 `horizon_start` 解析（排产确定性铁律对解析器同样生效）；② **不编造**——没说的字段进 `unresolved` 绝不猜默认值（只给日期没给时刻 ⟹ 候选不成立，追问「具体几点」——日期≠08:00，与「空≠零」同一纪律）；③ **永不执行**——产物是候选（`confidence: exact/partial/failed` 三态，CLI 退出码 0/1/2），执行仍走既有 move 端点，人确认闸门一步没少。确定性口径全部文档化进 docstring 并由期望值测试钉住：「周三」= ≥horizon 的第一个周三（含当天）、「11月3日」早于 horizon ⟹ 顺延一年、「下午4点半」= 16:30、「延后2小时」以该工序**当前计划开始时刻**为基（`current_starts` 提供；相对式没有基 ⟹ partial，不猜）。ID 识别只认整词（OP-3 不会从 OP-30 里误命中）+ 工单号口误给可行动提示 + 一句话多工序如实报歧义。每个已落实字段都能指出「从哪句话来的」（`filled` 审计），原话原样进 `reason`（E3-5 审计要求）。会话工具面 14→15（`aps_parse_move`）：无基线计划时词表退到数据集全部工序并在 note 里**如实说**，不假装有基线。零破坏：不新增开关、不改既有排产行为、无 MIGRATION 条目。

**v1.62.0** —— **「现实世界的脏表」第一次有了入口**（P0-3 输入理解层第 1+2 步 · 映射建议 + 导入即体检，零破坏 minor）。工厂里排产的输入长这样：计划员手写 Excel（表头「设备名称」「交货日期」「工序号」）、ERP 导出的脏表（一物多码、列名大小写混乱）——此前这条鸿沟全靠人手工重命名成七张严格小写列名的表。这一版补上「输入理解」的两层：① **表头映射建议**（新模块 `mapping.py` + CLI `aps map` + 会话工具 `aps_map_columns`）：给一份陌生表头，输出「你的『设备名称』列 ≈ resources.csv 的 name，置信度 0.9」——纯规则（归一 + 同义词表 + 子串），**零 LLM**（离线可用、结果可复现、测试可断言）；置信度三级（精确 1.0 / 别名 0.9 / 子串 0.7），歧义仲裁两轮走「**专属性优先**」——「工单号/工序号」并排的工单表，id 必得「工序号」（只有它认领）而不会与 job_id 争「工单号」，这类分支没有专属性轮就会看起来合理实际全错；落败方与没归宿的表头**如实列出**（不静默吞）。5 份真实形态脏表（手写 Excel / ERP 大写导出 / 表名全错 / 缺交期 / 中英混合）验收：**整体命中率 ≥ 80%**，实测 95%。⚠️ **任何一步都不自动落库**是硬闸门：`suggest_*` 是纯函数，没有 --write 出口，应用映射是人确认后的动作（验收测试结构性断言：建议里永远不含数据行、输入不被修改）。② **导入即体检**（平台 `POST /api/v1/health/preview`）：CSV bundle 不排产先体检——行级审计（哪些行进不来、逐行原因码，来自 1.46.0 的 `audit_csv_bundle`）+ 值级体检（工时分布/日历/换型/一物多码，来自 `health_report`）。此前脏数据要等 solve 跑完甚至复核器判红才暴露；体检前置 ⟹ 代价从「一次无效排产」降到「一次请求」。口径刻意与 solve 不同：缺必备表在 solve 是 422（没法排产），在体检是 **200 + blocking_error**（职责是告诉你数据哪里坏，不是拒绝体检）。同族坑记录：表名建议的两轮匹配**不能**把子串判断嵌在单表迭代里——「设备.csv」会撞上 operation_resources 别名「工序设备」的子串而抢在 resources 精确别名「设备」之前（看似合理实际指错表）；空串是任何串的子串 ⟹ 短于 2 的输入不参与子串轮。P0-3 第 3 步（自然语言插单 → MoveRequest）留下一版。零破坏：不新增开关、不改既有排产行为、无 MIGRATION 条目。

**v1.61.0** —— **AI 原生四件套闭环：可评测 → 可回测 → 可观测 → 可审计**（零破坏 minor，从本版起攒 G5 零破坏 streak）。这一版没有新增排产能力，回答的是四个此前缺位的问题：① **改得好不好，谁来当裁判？**（T1）新增 `aps eval` 评测集（`aps_kernel/eval.py` + `data/eval/cases.yaml`）：8 个 builder 变异出 13 个案例（紧交期 / 宽交期 / 多维 / 无交期 / 大规模 / 空输入…），每条期望值都带**独立于实现的依据**（写在 cases.yaml 的 source 字段），`run_eval` 跑分并给出 improvable（可改进空间）与 gap；结构性错误（非法输入）**上抛 ValueError** 而非吞成 case 失败 ⟹ `--min-score` 不达标退出码 1、结构性错误退出码 2 —— CI 里一条命令就能当回归门。② **标定改完之后，谁证明它没把别的场景改坏？**（T2）`aps calibrate --verify` 新增 `verify_calibration` 回测：把「采纳前基线」与「现在的 ratios/gap」逐条并排，verdict 四档（improved / worse / unchanged / insufficient），**worse 如实显示、不自动回滚** —— 回滚是人的决策，工具只负责让变坏无处可藏。③ **线上求解表现怎么样，谁来记账？**（T3）专业版平台埋点 `solve_events`（solver / status / wall_ms / unscheduled 数 / gap / inputs_hash / revision），`GET /api/v1/metrics/summary` 汇出 wallMs p50/p90/p99、unscheduled 率、状态分布 —— 「AI 排产快不快」第一次有了 p99 而不是感觉。④ **审批链上「谁拍板的」，账本说了不算怎么办？**（T4）`ApprovalRecord.decider_verified` 入账：专业版侧 `server_decider()` 只有**服务端配置的 key + 名字**才返回 True，客户端自报的身份**永远不会**被记成「已核验」（审批可信度的底线：身份必须服务端可证）；SQLite 老库自动 `ALTER TABLE` 补列，不弃档。四件事共用一个动机：P0-1/P0-2 交付后，AI 原生 APS 的下一步不是再加功能，而是让每一次改动**可度量、可追责**。全程零破坏：不新增开关、不改既有行为、无 MIGRATION 条目。

**v1.60.0** —— **「工艺允许让位的工序，能被插单拆开」第一次成立**（I33 插单抢占 · 最后一个「真缺代码」的缺口，从 absent 升 implemented，清单 **40/43**）。两个闸门缺一不可：`Operation.preemptible=True`（**工艺**：这道工序可以下机让位 —— 淬火/注塑周期不能标）+ `ScheduleInput.preempt_threshold`（**管理**：最小段长分钟，`0` = 不启用 —— 切碎段要重新装夹+重新首检，比等它还慢就不值得）。落点搜索 `earliest_fit_preemptive` 与 1.21.0 的 `earliest_fit_segments` 只差一件事但那是本质的：**段间空隙允许是被别的工单占掉的时段**（工件已下机 ⟹ 机器空闲时间能被碎片化利用），也因此不需要保养屏障（保养要求工件不在机上，抢占恰好满足）。择优纪律：**「不拆」与「拆」都试，取完工更早者；同刻取不拆**（少一次下机）—— A/B 实测：J1 先经 M2 前道 120 分钟占 M1 [120,420)，J2（可抢占，240 分钟）不拆 [420,660)、拆 [0,120)+[420,540)，makespan **660 → 540**；阈值开 300（每段 ≥300 用不上碎片）自动退化为不拆。占用口径是这一版的真正难点：`Assignment.preempted` 一个布尔切换 `segments` 的**读法**（跨度独占 vs 逐段独占）—— 与 1.55.0 外协「做成数据形状而不是新约束」同一思路：段数据只有一份，两个布尔字段必然能不一致，而复核器要判的恰是「段与占用是否自洽」。`occupied_intervals` 是**唯一真源**，槽位/模具/技能池/负荷/复核器五处同口径（负荷按各段之和 —— 段间空隙机器在干别人的活，算自己占着会虚增忙碌、均衡随之失真）。复核器第 22 项「插单抢占」⚠️ **判红**（纯算术四条：有段、升序不重叠、段总长=工时+固定准备、start=首段起+固定准备；「拆得值不值」不判 —— 那是求解策略）。诚实边界：**CP-SAT 侧 1.60.0 未建模**（建模不变量「一个候选↔一个区间↔一个布尔」被 7 处 zip 消费，改 K 段是单独一版且必须 A/B）⟹ 精确求解器仍整道落位，文档与 `diagnostics.preemption` 明示，不假装两边一样。顺带修掉一个既存静默错误：`reschedule._replace_assignment` 曾只挪 `start_minute` 不挪 `segments` ⟹ 跨窗/抢占分配的位移被**静默吞掉一半**（开工挪了、完工没挪）。⚠️ **本版是破坏性 minor（范围极窄）**：新开关默认关闭 ⟹ 排产主路径逐位不变（A/B 实测确认）；破坏只来自 `reschedule` 钉死位移修复（跨窗/抢占工序被 pin 后 `end_minute` 从错值变对值），迁移动作与逐位对照见 `docs/MIGRATION.md`。清单缺口 4 → 3（剩余全缺真实参数：I28 能耗 / I30 距离 / I31 历史绩效 —— **代码补不了**）。

**v1.59.0** —— **「安全库存被吃掉」第一次能拦住**（I29 库存缓冲 · 从 absent 升 implemented）。清单里这条写的是「有齐套判定、有补货建议，但两者各算各的：齐套只问『够不够做』，从不过问『做完会不会把保底量吃掉』」—— ⚠️ **实测确认缺口是真的，而且比清单写的更深**：库存 150、两单各需 60、保留量 40 时，两张单**都被判齐套**（150 ≥ 60、150 ≥ 120 都成立），可第一张做完只剩 90、第二张做完只剩 30 ⟹ **跌破保底 40**，而计划看起来完全正常、没有任何一处报错。这一版沿用 1.55.0/1.56.0 的方法论「**能做成「数据形状」就别做成「新约束」**」：安全库存**不进求解器** —— 判定在物料层算完（新模块 `buffer.py`），排产侧只消费 `kit_ready` 这个**既有**三态字段 ⟹ 贪心 / CP-SAT / 复核器 / 交期倒排 / 负荷报表 / 甘特渲染**零接线自动同口径**（对比 1.54.0 的质量门要手动接 5 处、其中 2 处写完测试还是假绿）。四条边闭成回路：① **声明**（`ScheduleInput.safety_stock`，三层优先级：调用层 override > 计划层 > 数据层 `InventoryPosition.safety_stock`，母体的 `reorder_level` 就是它）→ ② **判定**（可用量 = 现有 − 保留；缺口 = 需求 + 保留 − 现有）→ ③ **拦截**（写 `kit_ready[job]=False` 并开 `kit_blocking`，复用 1.56.0 的闸门，**闸门本身一行不改**）→ ④ **补产触发**（击穿物料进 `planned_orders`）→ **到货重判**（在途也计入）⟹ `released_after_replenishment` 实测 `('J1','J2')`，**回路真的闭合**，不是「报了缺口就完事」。⭐ **归因必须和基线比**：`buffer_caused_jobs`（因保留量被拦）与 `blocked_jobs`（全部被拦）**必须分开** —— 不区分就说不清「是我配了保底才排不出去，还是本来就没料」。⚠️ 基线**必须显式传全零映射**：传 `{}` 或 `None` 会**回退到数据层的 `safety_stock`**（这正是三层优先级的正确行为）⟹ 归因恒为空、功能**静默失效**——`buffer.py` 里为此写了一行注释钉住它。⭐⭐ 做这一条时**抓出三个既存静默错误**（都不是本版引入，但回路必须站在它们上面，否则「闭合」是假的）：① **BOM 键口径分裂** —— `load_bom` 的 docstring 写「按**产品**」，`compute_replenishment` 却按 **`job_id`** 查 ⟹ 两个公开契约互相打架。实测产品键下补货 `gross=60`（应 120）、`source_jobs=('P1',)`（**产品名冒充工单号**）、`jobs_missing_bom=2`；工单键下齐套**全部判齐套**（零库存也不拦）。修复为**需求口径唯一真源** `bom_lines_for_job()`：工单键优先（`required_qty` 已按批量放大）、产品键次之（**单位用量 ⟹ × `Job.qty`**，母体 `route_step_materials.quantity_per_unit` 0.8 kg/件正是这个口径）、都没有 ⟹ 无物料需求；修后两种键**结论一致**（`gross=120`、`source_jobs=('J1','J2')`、`net_now=20`）。② **补货净额夹 0** —— `usable = max(on_hand − ss, 0)` 再相减，`on_hand < ss` 时净额**退化成 `gross`** ⟹ 缓冲永远补不回来（本项目「夹 0 初值」同源坑第 5 次复发）；改为只在**最终缺口**处夹 0：`on_hand=20 / ss=40 / gross=120` ⟹ `net_now` **120 → 140**。③ **`aps replenish` 从不读计划层** —— `compute_replenishment` 不收 `data.safety_stock` ⟹ 用户在 JSON 里配的保底，CLI **当没看见**；三层合并上移到 `kitting.py` 后三处（补货 / 齐套 / 缺口报告）内部自洽，CLI 不传参也生效（实测 120 → 160）。新增复核器第 21 项「库存缓冲」，⚠️ **只报告 + 仅「保留量为负」判红** —— 复核器拿不到 BOM 与库存 ⟹ 判不了「是不是侵占了保留量」，硬判就是造「判据比门禁宽松」的假复核项（1.56.0 的教训）。⚠️ **本版是破坏性 minor**：BOM 用**产品键**的数据需求会变大（`gross` 从「一件的用量」变成「整单的用量」），排产结果会变，迁移动作与逐行实测对照见 `docs/MIGRATION.md`。清单缺口 5 → 4（**39/43**）。

**v1.58.0** —— **「保养时工件不能留在机上」第一次真的成立**（I32 保养避让 · 从 absent 升 implemented）。清单里这条写的是「现有能力只是『保养**期间**不可排产』，缺的是①保养前 N 分钟不许开工（避免工件横跨保养）」—— ⚠️ **实测确认这条缺口是真的，而且比清单描述的更隐蔽**：单设备、单工单、600 分钟 `interruptible=True` 工序、保养 08:00-12:00 ⟹ 贪心与 CP-SAT **都**把它切成 `((0,480), (720,840))`，而 span 是 `0→840` ⟹ **工件在保养窗 `[480,720)` 期间一直留在机上**。⭐⭐ **根因不是「忘了实现避让」，而是两个语义共用了一个信号**：保养洞从 `resource_intervals` 里挖掉 ⟹ 「保养期间不可排产」自动成立；但同一个「区间缺失」也被 `_gap_is_rest` 判成「**停机**」⟹ **可跨越**。停机可跨是对的（午休/夜间机器闲着，工件在机上无妨）；**保养要动设备 ⟹ 工件必须下机 ⟹ 两者语义不同却共用同一信号**。这一版把两者**分开建模**：新增 `Calendar.barriers`（资源级「不可跨越」屏障）+ `build_calendar` 挖洞时**同步**记录屏障（同一处生成 ⟹ 两个求解器与复核器**天然同口径**）；贪心侧在屏障处**断链**；CP-SAT 侧要求 span 与屏障**不相交**（reified 析取 `(span_end <= bs) OR (span_start >= be)` —— ⚠️ **不能用两条 `only_enforce_if`**，那是 AND 语义 ⟹ 强制空区间 ⟹ 永不可行）；新增复核器第 20 项「保养避让」，⚠️ **判红**（与第 19 项「负荷均衡」相反：均衡是**偏好** ⟹ 不均不是错 ⟹ 只报告；保养避让是**约束** ⟹ 工件在保养期间留在机上 = **计划不可执行**）。⭐ **判据是 span 相交，不是「起点落在保养窗内」** —— 后者是**弱**判据 ⟹ 它允许工件从保养前开工、跨过整个保养窗、到保养后才结束。⚠️ **本版是破坏性 minor**（有保养 + `interruptible` 的数据排产结果会变：`0→840` 变成 `720→1320`，贪心与 CP-SAT 两侧一致），迁移动作与实测样例见 `docs/MIGRATION.md`；**没有新增开关** —— 这是**正确性修复**而非可选偏好 ⟹ 不给「要不要一个坏计划」的选择（同 I15 齐套、I26 质量门）。⚠️ 子项②「保养后需重新首检」**未建模** —— 它需要「哪些工序需首检 / 首检工时 / 触发条件」等**业务参数**（同 I28 能耗、I30 距离、I31 历史绩效的缺参性质，不是代码问题）。清单缺口 6 → 5（**38/43**）。

**v1.57.0** —— **「设备别都往一台机器上堆」第一次在两条路径上都说同一句话**（I25 设备负荷均衡 · 从 partial 升 implemented）。此前清单里这条写的是「只是**可选 ε 约束**（`max_load_imbalance`），**不在默认字典序目标里**，且**贪心路径完全没有这个目标**」—— 也就是：CP-SAT 有个旋钮、贪心路径连旋钮都没有 ⟹ 同一条业务规则在两条主路径上口径分裂（**本项目返工最多的原因**）。这一版沿用 1.55.0 的方法论 23「**能做成「数据形状」就别做成「新约束」**」：新增 `ScheduleInput.load_balance`（开关，默认 `False` ⟹ 不声明就不改行为）+ 新模块 `load_balance.py` 作**唯一真源**（`load_by_resource` / `load_imbalance` / `resource_load_minutes`）⟹ 贪心择优、CP-SAT 回退校验、复核器第 19 项**共用同一个公式**，不会两边各写一遍后漂移。⭐⭐ **口径是本条最容易做错的地方**：不均衡度 = max(负荷) − mean(负荷)，而**负荷必须是「占用分钟数」（`end − start`），不是浮点 `duration_minutes`**（1.7.0 实测 bug：模型侧按 `ceil(duration)` 累加，用浮点工时算 ⟹ 回退校验与模型约束口径分裂 ⟹ cap 刀刃上模型认可的解法被复核判违规）；且**分母只数「有活干的设备」**（按资源总数算，一台闲置设备就把 mean 拉低，不均衡度被砍掉 1/3 ⟹ 旋钮形同虚设）。⭐ **贪心侧的实现刻意很轻**：资源择优时**只在开工时刻相同时**才拿累计负荷做 tie-break（「同等时效下选闲的那台」）⟹ **makespan 与交期逐位不变**，只改设备间分布，且零求解成本。⚠️ **CP-SAT 侧最终「不加」第 ④ 级均衡目标 —— 这是 A/B 实测否决的，不是没想清楚**：CP-SAT 是精确求解器 ⟹ 优化 makespan 时**自然会找到均衡解**（6 组随机数据实测：关均衡时 imb 已多为 0）⟹ 第 ④ 级几乎不改变结果；而它要多花一次求解，且 `minimize(max_load)` 会**改变搜索启发式** ⟹ 可能影响第 ③ 级的解。6 组连跑里有 2 组「开均衡」imb 反而更大，但**单独重跑不复现**（4 次连跑全一致）⟹ 是搜索时序差异、**不是稳定劣化**（墙钟 ≠ 判据）⟹ 正因如此更说明「这一级没有稳定收益」。⟹ **决策：CP-SAT 保持三级字典序不变，均衡由贪心侧承担**（`load_balance=True` 对 CP-SAT 只通过 warm start 间接影响）。新增复核器第 19 项「负荷均衡」，⚠️ **只报告不判红** —— 前 18 项查的都是「**约束**有没有被违反」，而均衡是「**偏好**」：贪心是启发式 ⟹ 保证不了均衡 ⟹ 若判红 ⟹ 用户一开这个开关就必然拿到「复核未通过」⟹ **这个开关等于没法用**。（用户钉死的 ε 上限 `max_load_imbalance` 仍在 **CP-SAT 侧**校验 —— 那是「不许超过」，与「尽量均衡」不是一回事。）另加 CLI `--load-balance`、JSON 往返保留 `load_balance` 字段（否则「导出→导入」后均衡**静默消失**，用户以为开着均衡、实际排的还是老口径）。清单缺口 7 → 6（**37/43**）。⚠️ 顺带抓到一处**更隐蔽的缺口**：把 I25 的核对目标从 `epsilon:SUPPORTED_METRICS` 换成「承载位」之后，**ε 约束的入口就没人守了** —— 而清单里**没有别的条目覆盖它**（已全量盘点确认：43 条里只有 I25 提到 ε）⟹ 这就是「换一个更精确的 target」**必须同时问「旧的谁守」**。⟹ 给清单的 `verify` 扩展了**多目标**（`targets` 列表，`target` 单数仍兼容）⟹ I25 同时守「承载位」+「ε 入口」，两个目标各有反向注入测试。⚠️ 顺带修：`scripts/regress.py` 的映射补上了 `load_balance.py` 与 5 处消费方（贪心 / CP-SAT / 复核器 / CLI / 模型）—— 手工列文件 = 假绿灯来源（方法论 8），这次让脚本自己选；同时补 `tests/test_validator.py` 的检查项清单期望值（少一项就会红 ⟹ 说明那份清单是活的，不是摆设）。

**v1.56.0** —— **「物料没齐的活不许开工」终于在两条路径上都说同一句话**（I15 齐套硬约束 · 从 partial 升 implemented）。这条能力 1.50.0 就有雏形，但当时它**只是挂在 CP-SAT 求解器的一个选项上**（`CpSatOptions.kit_blocking`）—— 于是**贪心路径完全没有保护**：直接调 `aps_kernel.solver.greedy.schedule()` 的调用方（专业版平台、热力图、what-if、reschedule…）照样把未齐套的工单排进计划。这不是「少一个功能」，是**同一个业务规则在两条主路径上口径分裂** —— 而当时复核器**也没有这一项** ⟹ 谁都看不出来。这一版把它提升为**数据形状**：`ScheduleInput.kit_ready`（工单级三态字典）+ `ScheduleInput.kit_blocking`（开关，默认 `False` ⟹ 不声明就不改变任何行为），新模块 `kit_gate.py` 作**唯一真源**，贪心与 CP-SAT **共用同一个判据**，不会两边各写一遍后漂移。两条路径都在**入口处**（建索引之前）剔除 ⟹ 被拦工序不进排序候选集，不占排序名额、不影响其他工单落位；剔除与**记账同源** ⟹ 进 `unscheduled` + `unscheduled_reasons` + `unscheduled_jobs` + `diagnostics["kit_blocking"]`，**不静默消失**。新增复核器第 18 项「齐套放行」（三查：该拦的没被排 / 拦下的要记账 / 没拦的别冤枉）—— 这一项不能省：齐套拦截是**主动剔除**，不是参与求解的约束 ⟹ 剔错了**没有任何求解器会报错**，计划看起来完全正常。⭐⭐ **三态纪律**是本条的核心：`kit_ready[job] is False` 才拦；**缺键 / `None` = 没做判定 ⟹ 放行**。用 `not ready` 判断会把「没导 BOM」当成「没齐」⟹ 用户什么都没配，整个车间排不出来 —— 这是**比不做还糟的默认**。另加 CLI `--kit-blocking`、CSV 可选表 `kitting.csv`、JSON 往返保留两个字段（否则「导出→导入」后齐套拦截**静默消失**，而复核器读的是导入后的数据，也看不出异常）。1.50.0 的老用法（`CpSatOptions.kit_blocking=True` + `solve(kit_ready=...)`）**逐位不变**（显式传参优先于数据字段）。清单缺口 8 → 7（**36/43**）。⚠️⚠️ **发布前逐条走用户路径，又抓出 5 个「看起来正常、实际错」的缺陷**（写完全绿，但旧测试测的是**被调用方**而不是**接线**）：① `--kit-blocking` 注册在 `solve` 而处理代码写在 `whatif` ⟹ **`aps solve --kit-blocking` 完全无效**；② `parse_input` 用 `bool(v)` ⟹ JSON `null` 被当成「未齐套」（误拦）、`"false"` 被当成「已齐套」（**漏拦** —— 物料没齐的工单被排进计划且不报错）；③ `kit_ready` 引用不存在的工单 ⟹ **幽灵工单**进未排清单；④ JSON 层不回填 `Job.operation_ids` ⟹ 工单**安静消失**（CSV 层早有回填 ⟹ 同一模型两个入口两套行为）；⑤ 求解器**从未**把 `kit_ready` 传给排序上下文 ⟹ R06「齐套优先」（1.12.0 就实现了）**从未真正生效**。五个的共同教训：**接线测试必须走真实入口** —— 开关类要**真跑一次**看行为、解析类要用**目标格式原文**、规则类要测「求解器 → 规则」那一段。

**v1.55.0** —— **「这道活发出去做」第一次能表达**（I27 外协/委外）。清单里这行长期写着「无外协资源类型、无外协工序标记、**无外协提前期（lead time）**、无发运/返回时序」。这一版加 `OutsourceSpec(lead_minutes, vendor_id, vendor_slots)` 与 `Operation.outsource`：一道工序整道发出去，`lead_minutes` 是从**发出到返回**的总周转（运输 + 供应商排产 + 加工 + 回程），`vendor_slots` 是这家厂同时能接几单。⭐⭐ **关键决定是把它做成「构造期归一化」而不是「一条新约束」**：供应商合成成一台厂外资源 `@vendor:<id>`，工序的候选里补上一条 `duration = lead_minutes` —— 于是**外协工序就是一道普通工序，只是它的设备在厂外**。收益不是少写代码，而是**口径安全**：贪心 / CP-SAT / 独立复核 / 交期倒排 / 负荷报表 / 甘特渲染**全部自动同口径，消费方零接线**（对比 1.54.0 的质量门要在 5 处分别接线、其中 2 处写完测试还是假绿）。归一发生在 `ScheduleInput.__post_init__`，**结构上不可跳过**，且靠结构判据幂等（`dataclasses.replace` 不会叠加重复资源）。⭐ 供应商挂 **24/7 连续日历**且刻意不受本厂班次约束：对方班次我们不知道，套用本厂班次会让 30 小时周转期被切成 4 天，而计划看起来完全合理；这也让负荷报表的分母是 1440 而不是默认 480（否则跨夜周转期会被报成 300% 假过载）。新增复核器第 17 项「外协时序」，**六查**：提前期为正 / 保留前缀自洽 / 同厂槽位声明一致 / 时长==提前期 / 没发错厂 / 并发 ≤ 槽位 —— 前五项各自用**只制造那一种违规**的数据单独注入验证过，条条真红。刻意不建模（写进 docstring）：**外协成本与汇率** ⟹ 因此**不能替你权衡「自制 vs 外发」**（一道工序同时声明内部候选与外协时，求解器只会按最早完工挑，诊断里把它数成 `alternative`）；分批发运；外协件不合格返工（要检就在下一道挂 `InspectionGate`）。⚠️ 顺带抓到一类更危险的老 bug：**JSON 往返在静默丢字段** —— `Resource.attributes`、`Operation.requirements`（1.52.0）、`inspection_gate`（1.54.0）、`skill_pool`（1.16.0）、`Location.available`、`split_report` 都没进 `to_dict()/parse_input()`，而 `parse_input` 的 docstring 上写着「可无损往返」。后果是**存盘再读回来，人手变无限、质检锁定消失、吨位要求全判无合格设备**，症状全在排产上、根因在序列化。还抓到 `Priority` **每往返一次就降一档**（0 基下标碰枚举值：MEDIUM→LOW、URGENT→CRITICAL）。新增 `tests/test_roundtrip_contract.py`：一条「整图相等」断言（新字段默认失败，不可能假绿）+ 一条「夹具里每个字段都必须是非默认值」的**反向断言**守着上一条的前提。清单缺口 9 → 8（35/43）。

**v1.54.0** —— **「这道活过了检没有」第一次能挡住下一道**（I26 质量锁定）。清单里这行写着「内核没有『未过检 ⟹ 强制不可开工』的硬约束标记位，只能把质检当普通工序建模」—— 后者能表达顺序，却把检验变成**产出**（进甘特图、进未排对账、进产能报表），而且表达不了**静置**：注塑件必须凉 4 小时再检，那 4 小时不占人、不占机，只是不许开工。这一版加 `InspectionGate`（`hold_minutes` 静置 + `duration_minutes` 检验 + 可选 `resource_id` 检验台）与 `Operation.inspection_gate`，放行时刻 = 完工 + 静置 + 检验，后继开工不得早于它；检验占用走**并行通道** `ScheduleResult.inspection_bookings`（像换型占技师那样，不进 `assignments`）。⭐ 四处同源接线，**每处都单独注入验证过**：贪心**两条路**（跨工单 `pred_floor` 与同工单 `level_end` —— 只接一条，另一条就静默绕过）、CP-SAT 三处（`precedence`、`stage` 串行、检验台进 `res_demands` 容量池 —— 容量约束**必须排在门之后**，否则检验台无限产能）、warm 校验、复核器第 16 项「质量门放行」（三查：放行约束 / 记录对账 / 检验台容量）。⚠️ 测试写完发现有**两处假绿**：同工单用例被 `level_end` 兜住，`pred_floor` 删掉也不红；CP-SAT 的 `precedence` 同理 —— 补了跨工单用例才抓住。刻意不建模（写进 docstring）：不合格返工（需不合格率与返工路径，属仿真）、首件检验（与 `interruptible` 语义冲突）、抽检比例（抽检 20% 与全检同样占满时长）。清单缺口 10 → 9（34/43）。

**v1.53.0** —— **交期第一次成为「排程方向」**（I21）。清单里这行长期写着「没有倒排调度。交期在模型里的角色仅是**惩罚项与排序键**，不是排程方向」——事实是 `_tardiness` 与 CR/EDD 只回答「先排哪个」，而排产引擎从 0.x 起只有**一个方向**（从最早可开工往后排），于是现场最日常的那句「客户要求 3 月 8 日交货，这批活最晚哪天必须投料」在 CLI 里根本没有入口：只有 `aps trace`（为什么排在这个时刻），方向正好相反。这一版加 `aps backward` 与 `backward_schedule()`：在一份**已排好的计划**上做逆拓扑回推（工单内 `seq` 降序 + 跨工单 `predecessors`），给出每道工序的**最晚开工**、**最晚设备就位时刻**（`− setup`）与**交期浮动** `total_float_minutes`（< 0 = 已经来不及，绝对值就是差多少）。⭐ 三个刻意选择：① **不做逆序重排** —— 逆推只读计划、不改分配，不引入第二套时间语义；真逆排会在换型上翻车（`A→B` 45 分钟 ≠ `B→A` 45 分钟，负时间轴把方向搞反，而**结果看起来完全合理**）。② **贴日历** —— `latest_fit` 找的是「交期前最后一个能放下整段占用区间的起点」而不是算术相减：交期落在午休就把最晚开工退到上午窗口末尾，否则会给一个物理上不存在的时刻。③ **浮动不叫 slack** —— `trace` 的 `slack_minutes` 是「哪条约束顶住了这个时刻」，这里的 `total_float_minutes` 是「离交期还有多少余量」，同一个词两个意思，混用会让两个答案看起来像一个。⚠️ 三种「不猜」各有出口且都带原因文本：无交期工单、末道未排（前道因此也无锚点，**宁可报无法反推也不给假时刻**）、交期前放不下整段窗口；跨窗口工序按实际跨度逆推（分段停机无法复原）。浮动是「依赖链 + 日历」口径，**不是**「还能右移多久而不撞车」（后者要重排才知道）。⭐ 五条不变量在测试里**无条件**断言（依赖方向 / 层间串行 / 日历上界 / 浮动单调性 / 逆推不改计划），并用两次注入证明它们真会红：忽略后继 ⟹ 5 条红；`latest_fit` 退化成「取窗口起点」⟹ 8 条红。⚠️ 写的时候自己又踩了一次 `date + timedelta(hours=18)` **静默丢小时**（交期变成 00:00、全部判来不及），测试里已立成显式契约。清单缺口 11 → 10（33/43）。

**v1.52.0** —— **「这台机够不够大」第一次能被表达**（I18）。清单里这行写着「缺数据也缺机制：内核没有『资源数值属性』与『工序需求下限』两个概念，所以『这台模具要 160 吨以上冲床』既无字段可填也无代码可比」。此前候选筛选的五个条件**全是集合成员判定**（`resource_id` / `skill` / `tool_id` 在不在），没有一处数值比较 —— 于是「够不够大」在模型里根本不存在，160 吨的模具会被排到 100 吨冲床上，而计划看起来完全正常。这一版补上两个概念：`Resource.attributes`（设备属性，如 `{"tonnage": 200}` 吨）+ `Operation.requirements`（工序要求，如 `tonnage ≥ 160`），配两张可选表 `resource_attributes.csv` / `operation_requirements.csv`。⭐ 刻意用**通用属性字典**而不是 `tonnage` 具名字段：注塑要锁模力、机加工要行程与台面、热处理要炉膛，具名字段只能追着一个行业跑。⭐ **fail-closed**：资源没标该属性 ⟹ 判**不满足**，不是「不校验」—— 这与 `Resource.tools` 的「空 = 不校验」刻意相反，因为撞机是不可逆的物理事故，「没标吨位」不能被当成「吨位无限」。两种原因的**文案必须不同**（「未声明」= 去补数据；「小于要求」= 换设备），压成一个「不可用」用户就不知道该干什么。⭐ 判据只有一个实现（`requirements.unmet_requirement`），**五处共用**：贪心候选 / CP-SAT 候选 / CP-SAT warm 校验 / 诊断模型 / 独立复核 —— 少接一处就会出现「求解器说不能排、复核器说排得对」。复核器新增第 15 项「资源适配」，**从 assignments 独立重算**。⚠️ 「不写需求就逐位不变」已实测：demo(30,seed=7) 13821 分钟、3 单模板 1020 分钟均未变。清单缺口 12 → 11。

**v1.51.0** —— **换型终于要占用技师了**（I03）。清单里这行曾长期写着「技师容量从未被占用」：`Resource.setup_resource` 与 `SetupRule.resource` 都只起「这台设备要不要建模换型」的**门控**作用，技师本身不在任何容量池里 ⟹ **3 台设备同时换型、只有 1 名调机工，模型照样让它们并行**。这一版把技师真正放进产能：换型窗口 = `[工序占用起点 − 换型时长, 工序占用起点)`，容量 = 技师资源的槽位数（2 个槽位 = 2 名技师可并行）。`SetupRule.requires_resource` 那个**零调用点的死字段**随之复活成 `setup_resource_for()`（规则级 `resource` 覆盖设备级），于是「A→B 换型要模具师傅、其它换型要普通调机工」这类真实分工第一次有承载位。⭐ 换型占用走**独立通道** `ScheduleResult.setup_bookings`，不塞进 `assignments` —— 塞进去会同时打破「每道工序恰有一条分配」的对账、给甘特图添一排假工序、把技师分钟算成产能产出。⭐ 复核器新增第 14 项「技师容量」，**从 assignments 独立重算**而不是信求解器自报，并与 `setup_bookings` 逐资源对账（「记录」与「计划」分叉时，用户看到的是好看的那份）。资源负荷报表里技师不再恒为 0%（demo 实测 RES-SETUP 峰值 45.4%，此前 0）。⚠️ **破坏性**：声明了 `setup_resource` 且技师真的不够时，换型现在会**排队** ⟹ 计划可能变长、工序可能后移；无争用时**逐位不变**（demo 13821 分钟、3 单模板 1020 分钟均未变，见 `docs/MIGRATION.md`）。⚠️ **CP-SAT 侧尚未建模**，`diagnostics.setup_technician.modeled` 会明说，`aps validate` 会如实判红 —— 不假装两边一样。

**v1.50.0** —— **未齐套的工单不再占着产能空等**（I15，opt-in）。`aps rules --list` 从 1.47.0 起就诚实写着「齐套不是硬约束：未齐套的工单照样被排上设备」——齐套只影响「排哪个在前」，不影响「能不能排」。这一版补上，但**做成开关**：`CpSatOptions(kit_blocking=True)` + `solve(kit_ready={...})` 时，`kit_ready[job] is False` 的工单**整单不进模型**。⭐ 刻意选「从 data 剔除」而不是「加 `presence == 0` 约束」：`_solve_core` 内部还有「贪心 warm 当 horizon 上界 + hint」那一层，只在模型里加约束 ⟹ 贪心仍会排上未齐套单 ⟹ 上界不合法、hint 冲突，正是「只改主路径不改回退路径」的老坑；剔除 data 后 CP-SAT / 回退 / 无解三条路径**结构上**都看不到这些工序。⚠️ **三态**：`kit_ready` 的 `None` 是「没做判定」（没导 BOM），**必须放行**，用 `is False` 严格判断 —— 写成 `not ready` 就等于「谁导了 BOM 谁才能排产」。被拦工序补进 `unscheduled` + `unscheduled_reasons` + `diagnostics["kit_blocking"]`，不静默丢。**本版无破坏性变更**（默认关闭 ⟹ 现有计划逐位不变）；清单 I15 由 `absent` 转 `partial`（⚠️ 缺口总数**不变**，仍是 13：`absent` 11→10、`partial` 2→3）。 ⭐ 同版补上 **`aps gates --check all`**：把六道门收成一条命令，三态 `pass` / `fail` / `unknown` + 退出码 0/1/2 —— **判不了既不算过也不算不过**，并写明去哪儿判（G1 缺真实 bundle、G3 需 30s 求解、G4 在平台包 e2e 里）。有门判不了时退出码是 **2（无法认证）不是 0**。

**v1.46.0（门禁判据）** —— **让「数据治理做到什么程度算做到了」能被判定**。1.45.0 给了归一器，可 2.0.0 的 G1 门禁写着「可执行率 ≥ 80%」却**没法判**：那份 21,218 米的数据不在仓库里，每轮都只能重复「指标没动」。这一版补的不是功能，是**判据** —— 一份可复现的脏数据语料（`tests/fixtures/ingest_corpus/`）：`raw/` 是**会动的字节**（不靠生成脚本重建，否则改脚本就悄悄改语料）、`corpus.yaml` 把期望值**写成数据**（不存 golden file —— 那是把实现的输出抄一遍）、通用 runner 消费它（122 项）。它判的是「归一器有没有漏 / 原因码判得对不对 / 审计层会不会静默丢行」。⭐ 顺带逼出**四处真缺陷**（都是「以前没人验证过」那一类）：`DUP_KEY` 声明了 1.42.0 起**从未抛出**（重复工序 id 两条都进模型，同一道活被排两遍、各占一遍产能且不报错）、工序工时**没有 > 0 校验**（填 0 占位被当成「很快的工序」，整张计划看起来非常漂亮）、休息日 `00:00,00:00` 被当成「起止填反」、工序没资源时报的修法指向了错误的地方（拆成新码 `NO_RESOURCE`）。⭐ 反向注入还抓出一处**「能力没守卫」**：`operation_resources` 的查重豁免（保护「一工序多资源」这个 FJSP 核心能力）当时没有任何样本覆盖 ⟹ 注入后测试纹丝不动，已补样本（现变红 16 处）。⚠️ 破坏性：主键重复不再静默接受、工时 0 现在被拒。**但这份语料判不了 44.1% → 80%** —— 它是「尺子准不准」的回归护栏，不是「客户数据好不好」的测量 —— 迁移见 `docs/MIGRATION.md`。

**v1.49.0** —— **门禁判据自己也有漏洞 —— 补上**；**并给拖期加了第二条下界来源（松弛下界）**；**并把第五道门（G5 契约稳定）也变成可执行判据**。1.48.0 把 G3（拖期可核证）变成可执行判据后，**回头实测小实例**时发现判据本身有两处「比门禁宽松」，与 1.47.0 那四处「声明与实现不一致」同一族。① **实例规模不足曾能「通过」**：门禁原文是「**30 单实例**必须满足其一」，但实现只判证书不判规模 ⟹ `--jobs 5` 会去判 5 单的证书，而**小实例更容易拿到 `tardiness_certified=true`**（下界=上界=0）⟹ 5 单就能「通过」一门要求 30 单的门禁。修法：新增 `gates.scale_sufficient()` 做**唯一真源**；规模不足 ⟹ **强制判红**（且理由要说清「差在哪」）。⚠️ 规模不足**不是**「判据坏了」（退出码 2）—— 判据本身好好的，是**被测对象**不满足门禁的前提 ⟹ 属「门禁没过」（退出码 1），退出码三档语义不变。② **`--json` 原本不是纯 JSON**：JSON 前面有人读格式的头部 ⟹ `jq` 直接解析失败。门禁判据的消费者就是 CI ⟹ 输出必须机器可读；现所有人读格式都收进非 JSON 分支，错误路径也输出 JSON。⭐ 两条都有守卫：`tests/test_gates.py` +6 条（规模门禁 / 纯 JSON / **反向断言**非 JSON 模式仍有人读格式），`tests/test_gates_injection.py` +2 条（其中「规模门禁」那条是**端到端**的：复制整棵包 ⟹ 注入 `scale_sufficient` 恒真 ⟹ 断言 2 单从「rc=1 判红」变成「rc=0 放行」⟹ 证明它**确实是**那道拦截）。⚠️ 顺带把文档里的实测数字改了口径：原写「上界实测 115,497 ~ 119,440」，后续实测出现 **108,700** 落在区间外 ⟹ 不再把随机值写成区间，改为「**11 万**分钟量级（⚠️ `FEASIBLE` 的上界每次找到的解不同）」。

**v1.48.0** —— **第四道门（G3 拖期可核证）也能自己判了 —— 而且它诚实地说「没通过」**。2.0.0 门禁里的 G3 此前只有一行文档（「下界只到 0，最小拖期从未被证明」），做没做只能靠读文档。新增 **`aps gates --check g3`**：按门禁原文读 `aps solve --json` 的证书四个字段，判 ① 已证最优、② 诚实区间（`tardiness_lower_bound > 0` 且上界/下界 ≤ 2），并**明确拒绝「下界 0」**；退出码 0/1/2 与 `aps rules --list` 同构。⚠️ **实测未通过**（30 单 / 240 工序 / 30s）：`FEASIBLE`、拖期上界在 **11 万** 分钟量级（⚠️ `FEASIBLE` 的上界每次找到的解不同，实测见过 108,700 ~ 119,440）、下界 **0**、gap **95.45%** ⟹ 判红。⭐ 这正是门禁该有的样子：以前这一格是「可核对未可证」，现在能算出**差在哪**（差一个正下界）。根因已定位（第②级 `FEASIBLE` 时 CP-SAT 只证明了平凡界「拖期 ≥ 0」）；下一步是加一条**松弛下界**（忽略资源争抢、按工单算最早完工时间），⚠️ 但那会改变对外字段的数值 ⟹ 属数值口径变更，**必须先 A/B** 才能上线。⚠️ `aps gates` 不是六道门总览 —— G1（真实客户数据）与 G5（连续 3 个干净 minor）没有可执行判据，硬写成「跑一下就算」就是造永远绿的假绿灯。

**v1.47.0** —— **三道门从「文档里的说法」变成「可执行的判据」**。2.0.0 门禁里的 G2（规则清单）、G4（专业版 e2e）、G6（文案对齐）此前**只有文档、没有判据** ⟹ 「做没做」只能靠读文档。这一版把三道都变成可跑的：① **`aps rules --list`** —— aps 侧规则清单（**43 条**：通用排序规则 `G##` 10 条**全部已实现** + 行业约束 `I##` 33 条，**缺口在这**），每条带「实现位置 / 适用分册 / **缺什么** / 取证位置」，并**逐条与代码核对**（实现被删 ⟹ 红；**缺口被填了 ⟹ 也红**）⟹ 清单永远与 reality 同步。⭐ 顺带抓出**四处「声明与实现不一致」**：换型的**技师容量从未被占用**（`setup_resource` 只做门控）、**齐套不是硬约束**（未齐套的工单照样被排上设备）、负荷均衡只是**可选 ε 约束**、资源优选匹配（吨位/机床适配）**确实没有**。② **专业版一条 e2e**：CSV bundle 上传 → 排产 → 甘特只读 → 拖拽改期 → 乐观锁冲突 **409** → 负荷热力图刷新，任一步失败即整条红（+ 5 条反向注入证明它真能红）。③ **文案守卫**：对外材料（README / PyPI / 官网 / 前端 / CLI）不许自称「完整 APS 系统」等，内部文档豁免。⚠️ **G1 的真门禁仍未达成** —— 真实客户数据 ≥80% 至今无法判定（那份数据不在仓库里）。

**v1.45.0** —— **让「进不来的那 56%」回来一部分**。1.42.0 的 `aps health` 只说了「哪一行进不来、为什么、怎么修」，现场下一句是「然后呢，500 行我手工改到什么时候」。而其中相当一部分**根本不用人来判断**：「１，２００ 分钟」「2026.8.15」「300 秒」「有」——这些不是内容错，是**写法**错。新增 `aps_kernel.normalize` 与 `aps health --what-if / --repair-out`：只修写法、不修内容，只动审计已判为「进不来」的行，逐格给出 before/after 凭证，写完回读自证。构造脏 bundle 实测 **81.25% → 100%**（救回 3 行 / 改 7 格 / 2 轮）。红线写死在代码里：名称里带全角数字**不改**（改了名就对不上路由且不报错）、单位只按列白名单换算（`setup_seconds` 常年是分钟，再除以 60 等于把 1.43.0 的坑挖深）、开始/结束填反**不自动交换**。⚠️ 原因码变具体（`UNKNOWN` → `BAD_SLOT` / `TIME_ORDER` / `OUT_OF_RANGE`），且「所有行都被逐行拒掉」不再被误报成「整表缺列阻断」——迁移见 `docs/MIGRATION.md`。

**v1.44.0（换型门控）** —— **换型矩阵「配了却不用」**。上一版把换型矩阵列进「未做」，这一版做完了，才发现真正的坑不在数据、而在**内核的门控**：`setup_rules` 配了 ≠ 换型被建模 —— 两个求解器都要求资源声明过 `setup_resource`（换型要占用调机工/换模工），否则规则一条都不用，而且不报错。最小夹具实测：600 → 645 分钟，文件在、格式对、`validate` 也不报错，只是少了换型时间。新增 `SETUP_RULES_IGNORED`（WARNING）让它可见；**刻意没有**改求解器语义 ——那会改变所有既存数据的 makespan（同 1.40.0 的教训）。Aethon 侧补上 `--setup-group-map` / `--setup-matrix` / `--setup-resource-slots` / `--probe-setup`，顺带修掉三处接入问题：产品要按**编码**匹配路由（订单常填编码、路由写中文名）、一张脏单不该让整包失败（改为整单跳过 + 逐单归因）、某类型设备**全部停机**时要把根因说出来。取证结论很硬：母体四个换型落点**全都给不出 from→to** —— 模具台账只有 to、换模记录无 `from_mold_code` 且样本不足、`route_steps.mold_code` 0/82、`setup_time_sec` 是每单必付准备（与顺序无关）。⟹ 换型矩阵只能客户给；这次至少把「要什么」变成了有据可查的两张表。
上一版 **v1.43.0** —— **让「母体数据能进来了」这句话能被验证**。上一版给了尺子（能说清多少行进不来），但没有解决「从 Aethon 导出一次能不能进得来」；实测结论是**手工导必错，而且错得无声**。两件事：① ⚠️ **列名陷阱**：`operation_resources.csv` 的 `setup_seconds` 列，值一直是**分钟**（解析时 ×60）——文档从没写过，按列名填就放大 60 倍且不报错。修法不是改名了事：新增语义正确的 `setup_minutes`（模板改用它），老的保留为别名且**值口径不变**（改口径比不改更危险），两列同给则**拒绝**；这是同类错误第 3 次（前两次：`Priority.parse("1")` 静默变 MEDIUM、Aethon 交期被写进 `status` 列）。② Aethon 侧新建**唯一导出入口** `scripts/export_to_aps.py`：三条硬规矩 —— **不猜**（产品有两条 active 路由时先判工序指纹是否等价，等价才消歧留痕，不等价直接失败并列候选）、**缺失不当零也不当约束**（节拍为 0 用 `run_time_sec` 兜底并逐条报；日历三张表全 0 行所以必须 `--workweek` 显式给）、**导出物必须能进来**（写完立刻调 `aps_kernel.ingest` 审计 + 试排一次，结果写进 `export_manifest.json`，逐列记录来源与假设）。实测 `aethon.db`：可执行率 **100%/100%**、11/11 全排上。三条以前没被说出来过的事实：`run_time_sec` **恒等于**节拍×4（派生字段，而母体自己的 `aps_routes.py` 却优先取它 ⟹ 工时放大 4 倍）；大批量工序不标跨窗就是 **11/11 全部 UNSCHEDULED** 且只报 `INFEASIBLE`；排上之后拖期仍达跨度的 **111%** ⟹ 那是**承诺超出产能**，调求解器参数没用。
上一版 v1.42.0 —— **把「数据进不来」变成可数的东西**。`cp_sat_demo` 那条教训（初排 21,218 米 → 可执行 9,361 米，**-56%**）一直只有结论、没有工具：`healthcheck` 算的是「**进来了**的数据值可不可信」（折扣估计），而真正咬人的是**压根没进来的行** —— 它们在 CSV 解析里第一行脏就整包 `raise`，`healthcheck` 根本看不到。新增 `aps_kernel.ingest` + `aps health <目录>`：复用唯一解析器逐行归因，给出**按行**与**按工时加权**两个可执行率（后者与「21,218 米→9,361 米」同口径），15 个原因码各带「怎么修」，级联失败单独标（1 张工单被拒 ⟹ 8 道工序一起进不来）。⚠️ **破坏性**：`Priority.parse("1")` 曾静默返回 `MEDIUM` —— 而自带模板写的就是 `"5"`/`"2"`，等于所有从模板出发的数据优先级都是错的、急单排到最后且不报错；已修，**排产顺序会变**（迁移见 `docs/MIGRATION.md`）。
上一版 v1.41.0 —— **「交期优先」从声明变成可核对的证书**。上一版留的判断题是「第 ② 级（拖期）的最优值既非上界也非下界（23,006 vs 19,117）」，实测后发现**约束一直在**（最终解 19,117 ≤ 23,006 恒成立），问题在别处：① 证书里没有一句话解释这两个数的关系，于是被读成「承诺 23,006、实际给 19,117」；② 第 ② 级是 `FEASIBLE`、**下界只到 0**（gap 100%），「最小拖期」从未被证明；③ **第 ② 级 UNKNOWN 时那条约束压根没执行** ⟹ 第 ③ 级对拖期毫无约束（实测 det=0.08：T=7,364 vs det=0.2：T=4,916，状态都报 `FEASIBLE`）。修法：objective 新增 `tardiness_bound` / `tardiness_lower_bound` / `tardiness_certified` / `tardiness_improved_by_stage3` 四个字段；第 ② 级 UNKNOWN 时用**贪心拖期兜底**当上界（A/B 四种 det × 两种 seed 解逐位相同，只多一行证书）⟹ 承诺退到最硬的一级**「不劣于规则链」**；回退路径与空解路径同步补齐字段。连带修一个**只改主路径会漏掉**的坑：平台 `objective` 标量原本「累加所有数值项」，新字段会把它从 2,427 悄悄变成 3 万量级 ⟹ 改成白名单。⚠️ 诚实结论：这一版让「交期优先」**可核对**，但**没有变强** —— 默认配置下第 ② 级普遍未证最优，对外只能说「不劣于该级所得解 / 不劣于规则链」。
上一版 v1.40.0 —— **把「预算」和「证书」变得可测量**，并借此否掉三个「显然该做」的改动。① `max_deterministic_time` 此前是**每级各给一份** ⟹ 实际总工作量 3×det，且 `STAGE_*_SHARE` 对 det **完全失效**（改份额只改墙钟）⟹ 「同一 det 预算下比较两种份额」的实验根本做不出来；现改为按墙钟份额切分，新增 `CpSatStats.stage_det` 可断言。② 新增可信 A/B 工具：`workers=1` + 固定 seed + 固定 det **逐位可复现**（实测两次 `branches=204497` 完全相同、墙钟差 6%）⟹ 差异只来自算法。③ `lexicographic_stages` 会**位置漂移**（某级没跑就整体前移，实测 `[0.0, 6698.0]` 把 makespan 摆在索引 1）⟹ 新增 `lexicographic_stage_names`；并统一回退路径的键名（此前回退只写 `stage_values`，按 `lexicographic_stages` 取值拿到 `None`）。**实测否决三个优化**（结论已写进代码，防止重来）：给拖期加「不劣于规则链」硬上界 ⟹ 分支数砍半、seed 42 直接回退（6,331/19,117 → 贪心 10,890/27,686）；同预算拆多起点取最优 ⟹ 拖期 +30%；份额再平衡 20/80 ⟹ seed 42 变好、seed 143 反向，**实例敏感**故不改默认。上一版 1.39.0 —— **20 单求解从「时好时坏」到稳定**。三个真因：① 字典序三级各建 solver 却从不传解 ⟹ 第 ③ 级重付 18 秒去找一个**已经拿到过**的可行解（修：`_carry_solution_as_hint`，`CpSatStats.hints_carried` 可观测）；② 「makespan 不劣于贪心」的上界只加在第 ③ 级，而第 ② 级的解 makespan 13,772 > 贪心 10,890 ⟹ 传下去的解被自己的上界排除 ⟹ 修 hint 失败（修：上界提前到第 ② 级前）；③ **「总拖期」一直是逐工序累加**，一单 8 道工序越期罚 8 次 —— demo 20 单放大 6.68 倍（184,818 vs 27,686），第 ② 级的最优方向被「工序数」污染（修：模型/报告/贪心三处统一按工单 `Σ max(0, C_j − d_j)`）。⚠️ 拖期口径变更**破坏兼容**：`total_tardiness_minutes` 与 ε 预算值需要按新口径重新标定。顺带修一个诚实性 bug：「前级 FEASIBLE + 末级 OPTIMAL」曾被标成 OPTIMAL（实测低报 773 分钟却挂着最优标签）。实测 20 单 30s 六次：2/6 → **6/6** 不回退，makespan 3,849~6,825（贪心 10,890）。上一版 1.38.0：**P1-9 的成因查清了，不是 presolve**：presolve 本就全程关闭，把它打开反而 `branches=0`（1.8.0 结论仍成立）。真因是`COMPACT_AUTO_OPS = 320` 按旧模型标定 —— 逐窗口建模每工序约 600 个变量，20 单就是 **96,118 个变量**，30 秒内一个可行解都没有；紧凑建模同一数据 **2,662 个变量**（3%），同预算能到 5,032。阈值改为 **64**（8 单起紧凑），实测 12 单从 4,857（回退贪心）变为 2,506 / 平台 2,115（不回退）。另修两处「声明与实现不符」：回退路径把 `hint_used`/`hint_feasible` 写死成 `True`（`hint=False` 也报「已用且可行」），以及原阈值测试是**恒真断言**。⚠️ 20 单 30 秒**仍不稳**（16 次仅 2 次成功），已排除五个嫌疑并如实登记。上一版 1.37.0：网页不再静默回退（`solverFallback`）。

| 模块 | 状态 | 关键能力 |
|---|---|---|
| 数据模型 P0 | ✅ | 工序-资源适配、**空适配 ≠ 全设备可用**、槽位 |
| 输入校验 P0 | ✅ | 三项 P0 准则 + 5 类检查 + **跨窗口工序预警**（`OP_WINDOW_TOO_SMALL`，1.19.0） |
| 规则引擎 P1 | ✅ | **7 条规则**（CR/EDD/SPT/WKWR/ATC/加权优先级/**FIFO**）+ 8 条链 |
| 日历与齐套 P2 | ✅ | 三日历求交、**班次×资源×位置**、工单级齐套布尔判定 |
| 换型规则链 P2 | ✅ | frePPLe 语义、**无匹配 = 硬约束禁止**、参数配置化、**非对称 A→B≠B→A**、矩阵格式 |
| CP-SAT 求解 P3 | ✅ | **字典序三级目标**、容量池用 `AddCumulative`、冻结窗口 |
| 独立复核器 P4 | ✅ | **22 项真检查**（含日历可行、模具互斥、技能资格、**依赖满足**、**技能池容量**、**换型成本**、**技师容量**、**资源适配**、**质量门放行**、**外协时序**、**齐套放行**、**负荷均衡**（只报告不判红：均衡是偏好不是约束）、**保养避让**（判红：工件在保养期间留在机上 = 计划不可执行）、**库存缓冲**（只报告 + 仅保留量为负判红：复核器拿不到 BOM/库存，硬判即成假复核项）、**插单抢占**（判红：段与工时对不上 = 占用口径分裂））、用输入重算不信任求解器 |
| Provenance P4 | ✅ | 快照 hash + 配置/源码/求解器版本 |
| CLI + 基准 P5 | ✅ | `solve` / `benchmark` / `explain` / `chains` / `health` / **`lotting`** |
| 冲突诊断 | ✅ | `aps explain` —— 最小冲突集 + 排不下原因 |
| ε-约束 trade-off | ✅ | 钉住交期预算，问 makespan 能压到多少 |
| **数据体检** | ✅ | `aps health` —— 四张底账逐项核对 + 可执行率估计 |
| **规则清单（可执行）** | ✅ | `aps rules --list` —— **43 条**（通用排序 `G##` 10 条全实现 + 行业约束 `I##` 33 条，**40 条已实现 / 缺口 3 条**（absent：I28 能耗 / I30 距离 / I31 历史绩效 —— 全部**缺真实参数或数据源**，代码补不出来；「真缺代码」的功能缺口已清零）），逐条与代码核对（实现被删 ⟹ 红；**缺口被填 ⟹ 也红**），退出码可接 CI |
| **设备负载均衡旋钮** | ✅ | ε-约束第 3 个旋钮：钉住设备负荷不均衡度 |
| **工装模具约束** | ✅ | 跨设备 NoOverlap —— 冲压/注塑的头号约束，「机床等模具」不再是隐患 |
| **并行工序** | ✅ | 同 `seq` 的工序可同时执行（双人工位装配 / 多工位并行检测）|
| **序列相关换型** | ✅ | 换型**真实占用产能**（贪心+CP-SAT+复核器三方一致）；A→B 与 B→A 可不同 |
| **换型成本** | ✅ | `SetupRule.cost`（元/次）→ `Assignment.setup_cost`，result.csv/甘特图/求解证书三个出口（1.17.0）。⚠️ 只计真换型，固定准备不计；四层费率由用户自配 |
| **换型矩阵格式** | ✅ | 直接贴工厂的换型矩阵表（行业通用格式），支持非对称与「禁止该换型」|
| **拆批合批** | ✅ | 求解前预处理；工时按数量缩放；最小批量约束；合批守交期红线 |
| **人员技能资格** | ✅ | 工序声明技能 → 资源必须**持证**（1.6.0）。无证资源自动跳过；缺证时警告点名技能而非静默 |
| **工序依赖** | ✅ | `predecessors` + `min_lag_minutes`（1.7.0）：跨工单拓扑就绪门 + 并行组内分层 + 前道完工地板；pred 未排 ⟹ 约束不生效（与 CP-SAT presence 语义一致） |
| **固定准备时间** | ✅ | `setup_seconds`（1.7.0）：装夹/调机首件确认每单必付，占用区间 `[start−P, end]` 两求解器+复核器三方同口径 |
| **有效工时（能率/倍速）** | ✅ | `Resource.max_efficiency`（设备能率上限）× `OperationResource.efficiency`（产品实际倍速），取 `min` 后折算工时（1.11.0）。2 腔注塑机跑 1 穴产品**不会**凭空快一倍 |
| **设备保养窗口** | ✅ | `Resource.maintenance` + `calendar.maintenance_schedule(every_days=6, duration_days=1)`（1.15.0）。日历层挖洞，两求解器+复核器天然同口径。⚠️ 周期按自然日近似，非按累计加工量 |
| **计划员入口（标准版用起来）** | ✅ | `aps demo` 生成 CSV 模板 → Excel 填报 → `aps solve --gantt/--csv/--certificate/--validation-report`。全程不写代码（1.9.0）。⭐ `aps demo --tight-due` 紧交期变体 + 配套急单：`aps whatif 目录 --add whatif-urgent.json --freeze-before 1000` 一跑就能看到 pushed/flipped_late（默认模板交期偏宽，这两栏恒为空——1.29.0 补的演示盲区） |
| **插单影响仿真（What-If）** | ✅ | `aps whatif --add delta.json`：插一个急单，**谁被推后/谁从准时变拖期/makespan 增多少**。冻结已下达计划 + 重排 + 差分；冻结逐条核对（`frozen_honored`），CP-SAT 回退贪心时如实标注质量降级（1.20.0） |
| **可解释规则链推荐** | ✅ | `aps recommend 输入.json`：按实例特征（换型组覆盖/交期紧度/等级差/候选数）推荐规则链，**每条推荐带可核对理由**；特征不足降级 default 标 weak，**不硬凑**；推荐不排他——附 benchmark 对比命令（1.30.0，策略回路第一步） |
| **计划执行风险（概率交期）** | ✅ | `aps risk 输入.json --plan plan.json`：时长 PERT-Beta 矩匹配采样 + 按计划顺序前向传播 ⟹ makespan 与各工单完工 P50/P80/P95、**每张单按期概率**、敏感性 TOP（哪道工序波动最伤交期）。口径诚实：计划固定、时长波动，非重新优化；同 seed 必同结果（1.30.0） |
| **日补货计划** | ✅ | `aps replenish 输入 --bom --inventory [--plan]`：工单物料需求 ⟹ **净额双口径**（net_now=需求−现库存[在途救不了当天开工] / net_total=再减在途[订单按它提]）+ 批量取整 + **在途 ETA 晚于开工警告**（总缺口 0 也会延期开工）。建议=「计划工件」(PlannedOrder,MO/PO 种子),转单是下游决策;单层 BOM/无预测口径写死（1.34.0,E3-1 决策 8.1） |
| **审批账本（HITL）** | ✅ | `ApprovalLedger`（追加写 JSONL/SQLite）+ `ApprovalGate`：commit/方案切换/undo 等改变基线的动作过门，未批准 ⟹ 结构化拒绝 `APPROVAL_REQUIRED`；**AI 工具面刻意没有 approve**（AI 可提议可查看，不能裁决自己）。账本管决定不管执行（诚实边界写死）（1.31.0） |
| **求解参数/链草稿推荐** | ✅ | `recommend_solver_options`（特征⟹时间预算，出处式推论非新基准）；`validate_chain_draft`（LLM 写草稿、内核校验、人审入库 —— P2-6 纪律进校验） |
| **MS Project 导入** | ✅ | `aps import-mspdi 计划.xml`：只读 MSPDI（Project 另存为 XML）⟹ ScheduleInput + 逐条映射报告（摘要跳过/里程碑零工时/SS 等类型点名跳过/未分配落 UNASSIGNED 告警/日历不搬）。导入产物可排可复核（1.31.0） |
| **AI 会话方案管理** | ✅ | `ApsSession.commit/discard/undo/save_scheme/load_scheme`：「就按插了 J-7 的方案继续」可执行；commit 落库并**作废旧基线**（必须重解）；方案 A/B 对比；工具 `aps_apply_whatif`/`aps_scheme` 带结构化拒绝码（1.30.0） |
| **滚动重排编排（专业版）** | ✅ | `POST /api/v1/reschedule/rolling`：到点/偏差触发 ⟹ **冻结=已发生的现实**（实绩覆盖计划——实际 8:25 开工，新计划从 8:25 接续）+ 带符号差分 + baseRevision 409 防互踩。内核 `solve_with_maintenance(frozen=)` 两求解器同源透传（1.30.0） |
| **专业版工程面** | ✅ | API key 认证（`APS_API_KEY`，未设=关闭+显式警告）+ SQLite 持久化（`APS_DB_PATH`：会话基线/滚动历史）+ Dockerfile/compose + GitHub Actions CI（1.30.0 —— 此前是家族三仓中唯一无 CI/无 Docker 的） |
| **家族闭环实验** | ✅ | `scripts/family/`：①计划⟹ILESP TaskBody 派工契约（缺站点映射=跳过+告警）②CPS 影子扰动⟹推演输入（故障→保养窗口/急单→whatif delta）③baseRevision 家族协议 PoC（两系统两条路径独立算令牌⟹同值）。各带 `--self-test`（1.30.0） |
| **计划 vs 实绩对账** | ✅ | `aps compare 输入.json --plan plan.json --actual 报工.csv`：逐工序偏差（开工/完工/时长/换设备）+ 按设备汇总 + 工单完工偏差 + **换型假设 vs 实绩间隙**（校准的数据源）。四态如实报告：配对 / 实绩缺报 / 计划外 / **计划未排但现场干了**；实绩为空或缺 horizon 响亮失败，不出空报告（1.29.0，实绩回路第一步——**对账先于学习**） |
| **拖拽重排（交互式甘特）** | ✅ | `aps_kernel.reschedule_move(data, MoveRequest(...))`：把一道工序拖到新位置，**前道原位钉死 + 后道按同一位移量顺延**，其余交 CP-SAT 重排 ⟹ 完整新计划 + 差分报告。⭐ 差分三档口径：`moved`（全部位移，**带符号**，含被提前的）/ `pushed`（只取正，向后兼容）/ `flipped_late`+`new_late`（准时变拖期）。⭐ 诚实闸门 `moved_honored` **逐条核对**资源+槽位+起点，无解时如实报 `INFEASIBLE` ⟹ 前端必须提示「拖拽未生效」而非假装成功（1.22.0）|
| **行业分册（专业版规则库）** | ✅ | `src/aps_kernel/data/industries/*.yaml` 可执行配置（**随 wheel 分发**，pip 用户 `aps industries` 直接可见）+ `aps industries` / `solve --industry`。**注塑册**（1.18.0：非对称换型矩阵 + 四层成本 + 保养 + 首检/烘料）、**冲压册**（1.19.0：同吨位 12min / 跨吨位 45min + 首件检测 2h）、**机加班**（1.22.0：**换型三档** 45/60/90min + 组名三段式约定 + Aethon 实测的固定准备 15min）。每条参数标来源；应用后结果必须改变（测试锁住） |

---

## 三个设计立场

### 1. 诚实高于漂亮

`OPTIMAL` 只在**真正证明最优**时才标；`FEASIBLE` 必带真实 gap；
超时回退贪心解时 `diagnostics["solver_fallback"]` 明确标注来源；
**全部未排时标 `INFEASIBLE` 而非 `OPTIMAL`**（避免"看起来排好了"）。

> 这直接对应论文《Formalize, Don't Optimize》的三个统计口径：
> **返回结果率 / 验证通过率 / 已返回中通过比例** —— 分母不同，不可混比。

### 2. 复核器是必需品，不是锦上添花

`validator.independent_validate()` 用**输入数据重算**（时长、槽位、日历、
换型、设备资格、技能资格、**依赖满足**），**不信任求解结果自报的值**。这不是防御性冗余 ——

> 开发中它抓到过**槽位重叠 48 处而求解器报 `OPTIMAL`**、
> **makespan = 2.3e18 的垃圾值**（`UNKNOWN` 时仍读未赋值变量）。

### 3. 基准必须公平

所有基线必须在**相同约束集**下比较。曾实现一个无视日历/工序先后的 SPT 基线，
得出「SPT 比 CP-SAT 还优」的**颠倒结论**（vsSPT = -203%）。

> 非法基线比没有基线更糟 —— 它会得出完全相反的结论。

---

## 🏗 分层架构：本包的位置

博客园《CP-SAT 排产、排程、排班》：

> 「APS（长周期规划）+ RTS/RTD（实时派工）**双层架构是业界最佳实践**」
> 「前一天做的排程，第二天全乱套」—— 排程 ≠ 派工 是经典误区

```
┌─────────────────────────────────────────────────┐
│  上层（aps-platform / 本包之外）                  │
│  · MRP / 采购 / 库存                              │
│  · APS 长周期计划（天/周）                        │
│  · RTS / RTD 实时派工（分钟级）  ← 不在本包        │
│  · MES 执行回写 / 甘特图 UI                       │
└─────────────────────────────────────────────────┘
                ↕ ScheduleInput / ScheduleResult
┌─────────────────────────────────────────────────┐
│  aps-kernel（本包）—— 求解内核，不含流程            │
│  ✅ 数据模型 / 输入校验                            │
│  ✅ 规则引擎（7 规则 + 8 链）                     │
│  ✅ CP-SAT 精确求解 + 贪心兜底                    │
│  ✅ 冻结窗口（支持增量重排）                      │
│  ✅ 独立复核器（22 项）                          │
│  ✅ ε-约束 trade-off / 冲突诊断 / 基准            │
│  ❌ 不做派工、不做 UI、不做持久化、不含 LLM         │
└─────────────────────────────────────────────────┘
```

⚠️ **为什么派工不在本包**：派工是**高频决策**（分钟级刷新），
需要实时状态推送与人工干预；本包是**低频决策**（计划级），
追求确定性与可审计。把两者混在一个包里会同时失去两者。

⚠️ **本包负责「证明这个计划可执行」，不负责「生成甘特图」**：

> 「排产的难点从来不是生成一个计划，而是**证明这个计划在约束下可执行**。」
> —— 《LLM 不会给你最优排产，求解器会》

---

## 版本与兼容性

| | |
|---|---|
| 当前 | **1.67.0** —— 功能优先迭代期，API 会随功能演进 |
| 含义 | ⚠️ **1.0.0 的 API 冻结承诺已于 2026-10-17 取消**，现按功能优先迭代 |
| 变更记录 | [CHANGELOG.md](CHANGELOG.md) —— 逐版本变更 |
| **升级迁移** | **[docs/MIGRATION.md](docs/MIGRATION.md)** —— 破坏性变更清单 + 「数值会变多少」的实测对照。**升级前必读** |
| 2.0.0 门禁 | [docs/2.0.0门禁.md](docs/2.0.0门禁.md) —— 六道可核对的门，回答「现在离 2.0 还有多远」 |

---

## 快速开始

### ⭐ 不会写 JSON？走这条路（1.9.0 起，给计划员用的入口）

```bash
pip install aps-kernel

# 1) 生成一套带示例数据的 CSV 模板（五张表）
aps demo mydata

# 2) 用 Excel 打开 mydata/ 下的 CSV，改成你的数据
#    （jobs / operations / operation_resources / resources / calendar）

# 3) 求解 + 出甘特图 + 出可贴回 Excel 的结果 + 求解证书 + 复核报告
aps solve mydata/ --solver cpsat \
    --gantt gantt.html --csv result.csv \
    --certificate cert.txt --validation-report audit.html

# 4) 双击 gantt.html 看甘特图；audit.html 看独立复核的 22 项
```

**三个产物各回答一个问题**：

| 产物 | 回答 |
|---|---|
| `gantt.html` | 计划长什么样 |
| `cert.txt` | 这个解**证明过吗**（OPTIMAL 才写「已证明最优」，贪心解写「未经求解器验证」） |
| `audit.html` | 独立复核器逐条验算的结果（不复用求解器任何中间结果） |

全程**不需要写一行代码、不需要懂 JSON**。五张表都是扁平 CSV，
Excel 直接改；导入时缺列/类型错会**点名到文件和列名**，不会静默取默认值。

### 程序员路径（JSON / Python）

```bash
# 0) ⭐ 上线前先体检（核对工时/BOM/日历/换型矩阵四张底账）
aps health input.json --bom bom.json --inventory stock.json

# 1) 看内置规则链
aps chains

# 2) 求解（贪心，快）
aps solve input.json --chain default

# 3) 求解（CP-SAT，可证 OPTIMAL）
aps solve input.json --solver cpsat --time-limit 30 --gantt g.html --csv r.csv

# 4) 排不下时诊断原因（最小冲突集）
aps explain input.json

# 5) 基准验证（规则链 vs SPT vs CP-SAT）
aps benchmark --sizes 5,8,10 --chains default

# 6) ⭐ 看 aps 侧规则清单（通用排序规则 vs 行业约束，分母分开）
aps rules --list
```

> `aps health` 的退出码可直接接 CI：`0` 可上线 / `1` 需补数 / `2` 阻断。
>
> `aps rules --list` 的退出码同样可接 CI：`0` 清单与代码完全一致 /
> `1` 有条目核对失败或清单与代码脱节 / `2` 清单本身坏了。
> ⚠️ 两档失败**刻意分开**：「清单坏了」要修文件，「清单过期了」要补实现或更新清单。

```python
from aps_kernel import schedule, solve, CpSatOptions, independent_validate

# 贪心（快，不证明最优）
result = schedule(data, chain="default")

# CP-SAT（可证 OPTIMAL）
result, stats = solve(
    data, options=CpSatOptions(time_limit_seconds=30, workers=8),
)

# 独立复核 —— 不信任求解器自报
checks = independent_validate(data, result)
assert all(ok for _, ok, _ in checks)
```

---

## 🤖 AI 集成（两条路，按场景选）

> ⭐ **本包在完全没有 LLM 的环境下 100% 可用** —— 核心（求解/复核/诊断）
> **零 LLM 依赖**，`pytest tests/` 不需要装 byoe、不需要 API key、不需要网络。
> AI 能力是**可选增强**。

### 路径 A：进程内类库（**AI 副驾 / 对话式排产，推荐**）

同进程调用，**数据留在 Python 侧**，LLM 只发小增量：

```bash
pip install 'aps-kernel[byoe]'    # 可选依赖，MIT
```

```python
import byoe
from aps_kernel.byoe_bridge import build_aps_agent

agent = build_aps_agent(data=my_schedule_input)
print(agent.chat("这周产能够吗？插一个急单会挤掉谁？"))
```

**为什么类库比 MCP 更适合 AI 场景**（两条硬伤，均实测）：

| | MCP 路径 | 类库路径 |
|---|---|---|
| **数据搬运** | 3 单的 `ScheduleInput` JSON = **27,908 字节**；`tools/call` 无状态 ⟹ LLM 每次全量搬 | Python 侧持有数据集，LLM 只发**小增量**（插单只传一个新工单） |
| **超时** | byoe 的 MCP 客户端**硬编码 30 秒** ⟹ 需 `async_mode` + `task_id` 轮询 | 进程内直调，**无此限制** |

提供 6 个会话工具：`aps_solve` / `aps_whatif_insert` / `aps_plan_summary` /
`aps_explain` / `aps_health` / `aps_validate`。

### 路径 B：MCP server（跨进程 / 跨语言 / 任何 MCP 客户端）

```bash
aps-mcp        # 或 python -m aps_kernel.mcp_server
```

```python
import byoe

agent = byoe.create_agent(
    model="deepseek-v4-flash",
    api_key="sk-...",
    mcp_servers=[{
        "name": "aps",
        "command": ["aps-mcp"],
        "allow_tools": ["aps_*"],
    }],
)
```

> ⚠️ MCP 客户端超时 30 秒 ⟹ 大算例请用 `options.async_mode=true`
> （立即返回 `task_id`），再用 `aps_get_result` 轮询。

### 两条路共用同一份实现

工具定义在 `aps_kernel/tools.py`（**协议无关层**），MCP 与类库只是两个宿主
⟹ 结构上杜绝「口径分裂」。

---

## 已知局限（诚实列出）

| 局限 | 说明 |
|---|---|
| ~~`Job.due_date` 只能表达天~~ | ✅ 已修复（v0.10.0）：改为 `date | datetime` 联合类型，支持小时级交期。用 `due_offset_from_horizon()` 计算偏移（**不要**用 `.days * 1440`，会丢小时） |
| 交期需**整日或整时** | 建议传 `datetime`；传 `date` 时按当天 00:00 算，不是「当天结束」—— 与部分 APS 的约定不同，转换时请留意 |
| 大规模 gap 仍大 | **实测（1.8.0，12 机 24 槽位，30 秒预算）**：该实例族上约 50 工单以内达 OPTIMAL，80 工单起超时回退贪心。⚠️ **不能推广成「工单数 < N 就能证最优」**：2026-10-31 用 demo 数据复测（30 秒 / 8 workers）只有 4 单 OPTIMAL，6/8/10/12/16 单均为 FEASIBLE —— 能否证明取决于实例结构、预算与目标级数，请以证书里的 `status` 为准，不要按工单数推断。**已修的根因**是 presolve 吃光预算（`branches: 0`，搜索从未运行）—— 熔断后 3 工单快 8 倍、20 工单从 UNKNOWN 变 OPTIMAL/6.9s。**仍存限制**：makespan 级本身在 30 秒内证不了最优（裸模型能 OPTIMAL，说明瓶颈在该目标的建模而非 presolve，属下一步优化对象）|
| ~~换型为**静态规则**，非序列相关~~ | ✅ 已修复（v1.4.0/1.5.0）：换型时间**真实占用产能**（此前只记录不预留，计划现场执行不了）；A→B 与 B→A 可不同（换下的模具比装上的更重是物理现实）。仍不支持「A→B→C 链式换型」的组合优化 |
| ~~缺**拆批合批**~~ | ✅ 已修复（v1.5.0）：`split_input` / `merge_compatible_jobs`。**工时按数量线性缩放**（不缩放时拆 4 批 makespan 120→480）。合批守交期红线，默认关闭 |
| 换型建模假设「同槽位同组成块」 | CP-SAT 的换型约束是**充分非必要**的（排除交错排法），非等价改写。换型极贵时该假设成立，但这是真实限制 |
| ~~**工序必须放进单个日历窗口**~~ | ✅ 已修复（v1.21.0）：`Operation.interruptible` 支持跨窗口续排（午休/夜间停机时暂停加工）。⚠️ 约束：**跨度独占槽位**（工件不下机，段间空隙不能插别的活）；**首段必须容得下固定准备**（`setup_seconds` 装夹/调机不可打断）；各段长度之和 == `ceil(工时) + 准备`。两个求解器同口径 |
| ~~模具**无寿命/维护周期**建模~~ | ✅ 已修复（v1.15.0）：`Resource.maintenance` + `calendar.maintenance_schedule`（日历层挖洞，两求解器+复核器同口径）。⚠️ 周期按**自然日**近似，非按累计加工量；「这副模还能用几次」的寿命计数仍未建模（冲压按冲次保养同属此列，P2-8） |
| ~~技能只有**资格过滤**，无池容量~~ | ✅ 已修复（v1.16.0）：`ScheduleInput.skill_pool`（厂级几人 = 几并行），CP-SAT/贪心/复核器三方同口径。⚠️ 多工种共享同一设备的并发上限仍未建模（专业版） |
| 无持久化状态 | 每次求解无状态，冻结窗口需由调用方传入 |
| 合成 demo 交期偏宽 | demo 默认交期 = horizon+5 天 ≫ makespan → 拖期恒为 0；验证拖期逻辑需改用紧交期 |
| 体检的**可执行率是估计值** | 基于静态校验，不检查「现场是否会临时变更」。cp_sat_demo 实测 44%，本估计通常偏乐观 |

---

## 安装与依赖

- Python ≥ 3.11
- 运行时依赖：`ortools >= 9.12`、`PyYAML`
- **仅发 wheel**（`py3-none-any`），不发 sdist

## License

MIT
