From 42ffdfff7df3d0312456705460a9880f1a4f7ea5 Mon Sep 17 00:00:00 2001 From: huojiarong Date: Mon, 14 Sep 2026 09:35:53 +0000 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E7=B3=BB=E7=BB=9F=E4=BB=BF?= =?UTF-8?q?=E7=9C=9F=E5=8F=82=E6=95=B0=E4=BC=98=E5=8C=96=20Skill?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- skills/system-simulation/SKILL.md | 26 +- skills/system-simulation/agents/openai.yaml | 6 +- .../references/optimization-workflow.md | 307 ++ .../scripts/optimization_skill.py | 3837 +++++++++++++++++ .../scripts/simulation_skill.py | 18 +- tests/test_system_optimization_skill.py | 1755 ++++++++ 6 files changed, 5935 insertions(+), 14 deletions(-) create mode 100644 skills/system-simulation/references/optimization-workflow.md create mode 100644 skills/system-simulation/scripts/optimization_skill.py create mode 100644 tests/test_system_optimization_skill.py diff --git a/skills/system-simulation/SKILL.md b/skills/system-simulation/SKILL.md index a063a55..94214d8 100644 --- a/skills/system-simulation/SKILL.md +++ b/skills/system-simulation/SKILL.md @@ -1,11 +1,15 @@ --- name: system-simulation -description: 读取、校验并简要解释 SystemSimulationApp 工程 JSON v1 或 System XML v3,安全规范化文件文本,并在用户选定结果曲线后启动、监视或取消仿真及导出 CSV。适用于检查模型文件、修复编码或换行、运行仿真和获取结果;不用于旧格式迁移、任意语义修复、自动迭代或网页自动预装。 +description: 读取、校验并简要解释 SystemSimulationApp 工程 JSON v1 或 System XML v3,安全规范化文件文本,运行并监视仿真、导出结果,以及在用户确认计划后对 JSON v1 执行单目标、有界连续 SI 参数优化。适用于检查模型、修复编码或换行、运行仿真、获取结果和优化结果统计量;不用于旧格式迁移、任意语义修复、离散或拓扑优化、多目标优化或网页自动预装。 +metadata: + openclaw: + requires: + bins: [python3.12] --- # 系统仿真 -使用本 Skill 随附的确定性脚本检查模型、调用现有后端并保存结果;不要让语言模型自行重写模型或猜测求解数据。 +使用本 Skill 随附的确定性脚本检查模型、调用现有后端并保存结果;不要让语言模型自行重写模型或猜测求解数据。`simulation_skill.py` 处理文件和单次仿真,同一 Skill 内的独立入口 `optimization_skill.py` 处理优化计划与执行。 ## 基本边界 @@ -13,29 +17,39 @@ description: 读取、校验并简要解释 SystemSimulationApp 工程 JSON v1 - 组件参数是仿真前设定的固定输入;结果变量才是可随时间绘制的量。不要把“参数”当成结果曲线。 - 工程 JSON 可在连续数值参数中保存受限算术表达式。检查、编译或生成 XML 时由后端安全求值并换算为 SI;不得把计算结果回写到源 JSON。 - 文件通过格式校验不等于物理系统一定可求解。不要隐瞒编译或运行阶段的诊断。 -- 不直接覆盖源文件,不自行修改参数、连接、组件类型、模型版本或求解设置。 -- 本版不支持把模型自动注入网页、生成可直接打开的预装页面、任意损坏文件修复、模型迁移或自动调参迭代。明确告知用户这些能力尚未实现,不要用手工网页操作冒充支持。 +- 不直接覆盖源文件。普通检查或仿真不自行修改参数、连接、组件类型、模型版本或求解设置;优化也只能在用户确认的派生副本中改变明确选定的参数。 +- 优化仅面向 ReactFlow 工程 JSON v1,设计变量必须由用户指定,或由用户明确授权 Skill 提议后再纳入计划;它们必须是连续、线性 SI 参数。现有后端不负责证明参数连续性,不能只因字段是数字就自动选作设计变量。带编辑器、离散选项或后端显式否决的参数必须拒绝,整数、条件显示控制量及会改变活动端口、模式或拓扑的参数不得进入连续优化。 +- 本版不支持把模型自动注入网页、生成可直接打开的预装页面、任意损坏文件修复、模型迁移、离散或拓扑优化以及多目标优化。不要用手工网页操作冒充支持。 -处理文件、解释格式或选择结果变量时,读取 [references/file-contracts.md](references/file-contracts.md)。请求文件修复时,再读取 [references/repair-policy.md](references/repair-policy.md)。需要运行、监视、取消仿真或交付结果时,读取 [references/workflows.md](references/workflows.md)。 +处理文件、解释格式或选择结果变量时,读取 [references/file-contracts.md](references/file-contracts.md)。请求文件修复时,再读取 [references/repair-policy.md](references/repair-policy.md)。需要运行、监视、取消仿真或交付结果时,读取 [references/workflows.md](references/workflows.md)。用户请求按仿真结果优化参数时,必须读取 [references/optimization-workflow.md](references/optimization-workflow.md)。 ## 工作原则 1. 先用 `inspect` 确认输入格式、版本、结构和诊断,再基于检查结果简要解释组件、连接与仿真设置。 2. 如果用户要求修复,只能执行文本规范化。先展示预览和源文件 SHA-256,获得针对该预览的明确确认后,才可写入另一个输出路径;随后重新 `inspect`。 -3. 仿真前必须让用户选择直接曲线查看方式,并解析具体结果变量: +3. 单次仿真前必须让用户选择直接曲线查看方式,并解析具体结果变量: - 分别查看所选变量; - 将多个同单位、可比较的变量叠加; - 将不同物理量或单位的变量上下排列。 4. 用户用显示名称描述组件或变量时,利用检查结果中的稳定 ID、结果 `key`、物理量和单位消歧。存在重名、多个候选或“参数/结果变量”含义不清时,先询问,不能替用户猜。 5. 使用 `simulate` 的事件流持续判断 queued、validating、compiling、integrating 和结束状态。仿真时间暂时不变但内部活动仍增长时,只说明正在处理慢步,不能宣称卡死。 6. 成功运行后交付用户选择的 SVG 曲线和完整 `results.csv`,并简要说明完成状态、实际仿真终点和重要诊断。失败或取消时交付能够安全生成的部分结果;若运行前即失败而没有 CSV,要明确说明原因。 +7. 优化需求优先按自然语言理解:从检查结果补齐稳定结果 `key`、单位和当前参数值,未指定的算法、预算、容差和输出目录采用参考文档中的推荐默认值。不要要求用户填写规格 JSON,也不要追问随机种子、变异因子等已有默认值。 +8. 信息足以形成规格后,直接在内部写入规格并执行无候选仿真的 `plan`,无需先征求生成计划的许可。计划阶段用普通语言只展示目标、可调参数及范围、约束、最多仿真次数、输出位置和重要假设。参数明显是连续物理标量时,把连续性作为计划假设,一次整体执行确认即可覆盖;只有语义确有歧义时才自然地追问。内部声明代码、SHA、`planHash` 和 `confirmationToken` 默认不展示。 +9. 生成计划不等于获准执行。只有用户看过计划摘要并明确表示开始后才能传入 `--confirmed`;用户只要求计划时必须停在计划阶段。计划任一实质内容变化都要重新确认。 +10. 优化中的失败、取消、停滞或不完整仿真不计算目标分数。最终只对预算内找到的最佳可行候选做一次绕过缓存的完整复验;复验通过前不把候选称为已验证方案。 +11. 严格区分搜索停止与候选复验:`verified` 只说明最佳搜索候选的新鲜复验通过,不等于搜索收敛、系统达到稳态或全局最优。报告时分别说明搜索候选评估、按阶段拆分的仿真预算槽位占用及完成/失败记录、未形成试验记录的槽位、缓存命中、独立复验、未用预算和真实停止原因;槽位占用不能说成后端已接收或已完成。内部防死循环上限及重复停滞后的种群塌缩都不得解释成“已收敛”。 +12. 目标使用 `final` 时必须报告脚本给出的末段趋势诊断状态;只有完整且覆盖计划终点的新鲜序列才能分析趋势,诊断不可用或样本不足时明确说明且不自行推断。检测到明显变化时,说明终点值只是快照。新鲜复验已经通过后不再建议重复同一复验;最佳点落在边界时,只能说明已采样点的趋势,未经工程可行性确认不得建议放宽边界。 脚本命令统一从仓库根目录运行: ```powershell py -3.12 skills/system-simulation/scripts/simulation_skill.py --help +py -3.12 skills/system-simulation/scripts/optimization_skill.py --help ``` +在 OpenClaw 中不要假设当前目录是仓库根目录。使用 `"{baseDir}/scripts/simulation_skill.py"` 或 `"{baseDir}/scripts/optimization_skill.py"`,也可以先进入本 Skill 目录再从 `scripts/` 运行;不要把 `{baseDir}` 当成需要手工猜测的仓库路径。 + Windows 优先使用仓库 `.venv-win\Scripts\python.exe`(若存在),否则使用 `py -3.12`;Linux 优先使用 `.venv/bin/python`,否则使用 `python3.12`。不要调用未经版本确认的 `python`,本项目要求 Python 3.12。 优先依赖脚本返回的结构化 JSON/JSONL、稳定错误码和退出码做判断,不解析中文提示文本来驱动下一步。 diff --git a/skills/system-simulation/agents/openai.yaml b/skills/system-simulation/agents/openai.yaml index 171568c..39011a1 100644 --- a/skills/system-simulation/agents/openai.yaml +++ b/skills/system-simulation/agents/openai.yaml @@ -1,7 +1,7 @@ interface: - display_name: "系统仿真文件助手" - short_description: "读取与校验模型文件,监视仿真并导出结果曲线和 CSV 文件" - default_prompt: "使用 $system-simulation 检查我的模型文件,并在我选定结果曲线后运行和监视仿真。" + display_name: "系统仿真与参数优化助手" + short_description: "用自然语言检查、仿真模型,并按安全默认值规划连续参数优化" + default_prompt: "使用 $system-simulation 按我描述的目标优化模型参数;请自动补齐安全默认值,先给出易读计划,等我一次确认后执行。" policy: allow_implicit_invocation: true diff --git a/skills/system-simulation/references/optimization-workflow.md b/skills/system-simulation/references/optimization-workflow.md new file mode 100644 index 0000000..96951e6 --- /dev/null +++ b/skills/system-simulation/references/optimization-workflow.md @@ -0,0 +1,307 @@ +# 单目标参数优化工作流 + +## 执行入口与范围 + +本 Skill 内的 `scripts/optimization_skill.py` 是独立外层优化入口,它复用同目录的 `simulation_skill.py` 访问现有 FastAPI 后端。优化算法、候选生成、目标统计和报告都在 Skill 进程中完成,不要求后端提供原生优化端点或优化参数准入字段。优化入口只接受已通过检查和编译的 ReactFlow 工程 JSON v1,不接受 System XML 作为优化源文件。 + +支持范围固定为: + +- 一个结果变量统计量构成的单目标; +- 1–16 个有限上下界内的线性尺度连续 SI 参数; +- 0–16 个结果响应约束; +- 最多占用 200 个仿真预算槽位; +- 顺序执行的有界 DE/rand/1/bin 差分进化。 + +不支持离散、整数、介质引用、条件显示控制量、活动端口数、组件类型、连接、拓扑、无界、对数尺度或多目标优化。不执行用户文本中的任意 Python 回调或表达式,也不在优化中改变仿真时段、采样间隔、最大内部步长、求解方法或物理拓扑。用户给出多个愿望时,必须选定一个目标,再将可用上下限表达的其余要求定义为响应约束;不自行设计权重合成多目标。 + +## 自然语言交互与推荐默认值 + +用户不需要了解优化规格 JSON。通常只需说明:想改善哪个结果、让它变大/变小或接近什么值,以及允许调整哪个参数和范围。若用户明确说“你先假定一个”或同等授权,可以提出一个设计变量和工程范围,但必须标成待确认的假设,不能伪装成后端验证结论。 + +先运行 `inspect` 和必要的编译检查,再把用户说法解析为严格规格: + +- “结束时”映射为 `final`;“整个仿真中的最大/最小值”映射为 `maximum` / `minimum`;未指定窗口时使用完整结果时间范围。 +- 从结果和参数合同补齐稳定 ID、`resultKey`、SI 单位和当前值;能唯一解析时不要反问用户这些机器字段。 +- 用户未提响应约束时使用空列表,不逐项询问“是否需要约束”。 +- 目标、参数、范围或方向无法唯一确定时必须询问;不要为了可默认的技术字段打断用户。 + +用户未指定高级设置时,把下列推荐配置显式写入规格文件,保证计划可复现: + +```json +{ + "algorithm": { + "name": "differentialEvolution", + "seed": 0, + "populationSize": 8, + "mutationFactor": 0.8, + "crossoverProbability": 0.7 + }, + "budget": { + "maxSimulationRuns": 25, + "maxWallSeconds": 3600 + }, + "validation": { + "relativeTolerance": 1e-08, + "absoluteTolerance": 0.0 + } +} +``` + +用户描述了响应约束但未指定容差时,默认 `tolerance = 0`;未指定 `scale` 时,取该约束所有非空边界绝对值的最大值,若结果为零则取 `1`。目标或约束接近零、后端存在可观察的不确定性,或用户给出安全裕量时,应提出有物理意义的容差建议,不能用一个跨量纲的非零绝对容差替代判断。 + +默认输出目录使用项目文件所在目录下尚不存在的 `optimization-runs/<项目名>-`。源目录不可写时,改用当前可写工作区中的同名新目录,并在计划摘要中说明实际位置。不要让用户命名目录,也不要覆盖既有目录。默认计划可对用户概括为“差分进化、最多 25 次仿真、最长 1 小时,并预留一次独立复验”;除非用户询问或计划产生覆盖不足警告,不主动讲解种群、变异因子、交叉概率、随机种子或代数公式。 + +## 优化规格 JSON + +规格是独立 JSON 文件,顶层必须且只能包含 `optimizationSchemaVersion`、`objective`、`designVariables`、`constraints`、`algorithm`、`budget` 和 `validation`。完整示例如下;其中组件 ID、参数名、结果 `key` 和单位必须换成实际 `plan` 所检查的合同: + +```json +{ + "optimizationSchemaVersion": 1, + "objective": { + "resultKey": "pressure.chamber_1.absolute", + "expectedUnit": "Pa", + "statistic": { + "kind": "maximum", + "window": {"start": 0.0, "end": 1.0} + }, + "goal": {"kind": "minimize"} + }, + "designVariables": [ + { + "id": "orifice_area", + "componentId": "valve_1", + "parameter": "area0", + "unit": "m2", + "lower": 1e-06, + "upper": 0.0001 + } + ], + "constraints": [ + { + "id": "mass_flow_limit", + "resultKey": "massFlow.valve_1.port_2.intoComponent", + "expectedUnit": "kg/s", + "statistic": {"kind": "peakAbsolute", "window": null}, + "lower": null, + "upper": 0.25, + "tolerance": 0.0, + "scale": 0.25 + } + ], + "algorithm": { + "name": "differentialEvolution", + "seed": 0, + "populationSize": 8, + "mutationFactor": 0.8, + "crossoverProbability": 0.7 + }, + "budget": { + "maxSimulationRuns": 25, + "maxWallSeconds": 3600 + }, + "validation": { + "relativeTolerance": 1e-08, + "absoluteTolerance": 0.0 + } +} +``` + +规格使用严格字段集。未知字段、缺失字段、重复键、布尔型伪装的数字、`NaN` 和无穷值均不能进入优化;`optimizationSchemaVersion` 固定为 `1`。 + +`plan` 的 `spec.resolved` 会额外显示只读派生字段,例如统计量 `metricUnit`、算法 `strategy`/`workers`/`updating`、搜索策略、边界处理、一维端点播种策略、停滞代数、预算预留量和固定复验次数;这些字段不是输入规格字段,不能复制回 schema v1 规格文件。输入优化规格仍是 schema v1;最终结果的 `optimizationResultSchemaVersion` 为 `2`,两者不要混淆。 + +### 目标 + +`objective.resultKey` 必须是编译模型声明的稳定结果键,`expectedUnit` 必须与结果元数据单位完全一致,无量纲时使用空字符串。`goal.kind` 可为: + +- `minimize`:最小化统计值; +- `maximize`:最大化统计值; +- `target`:最小化 `abs(statistic - value)`,此时必须增加有限数字 `goal.value`。 + +`minimize` 和 `maximize` 不接受 `goal.value`。`target` 只定义目标损失,不是提前停止阈值。 + +`expectedUnit` 是原始时间序列单位。`final`、`minimum`、`maximum`、`timeMean`、`rms` 和 `peakAbsolute` 的统计量单位与它相同;`integral` 与 `absoluteIntegral` 的统计量单位为原单位乘秒(结构化输出的 `metricUnit` 使用 `()*s`,无量纲序列积分为 `s`)。`target` 的 `value` 使用统计量单位。 + +### 设计变量 + +`designVariables` 必须包含 1–16 项,ID 和 `(componentId, parameter)` 均不能重复。每项必须: + +- 由用户明确指定,或在用户授权 Skill 提议后纳入计划;执行时它必须是连续、线性 SI 参数,且改变它不会切换模式、改变活动端口或拓扑; +- 不带组件合同中的 `editor` 或离散 `options`;若未来合同显式提供 `optimizationEligible: false`,该否决不可由用户确认覆盖; +- `unit` 与参数合同的 SI 单位完全一致; +- 使用有限数字 `lower < upper`,同时满足目录最小值、最大值和排他下界; +- 使源模型编译后的当前 SI 值位于闭区间 `[lower, upper]` 内。 + +当前后端目录没有能够单独证明“连续量、整数、模式控制量、活动端口数”的机器字段;字段缺失本身不是连续性证据。不得仅凭“值是数字”自动挑选设计变量。若用户已选定参数,且参数合同、物理量与单位、组件语义和用户给出的连续区间一致表明它是普通物理标量,同时不存在 `editor`、`options` 或显式否决,则可把“按连续线性 SI 参数处理且不改变结构”写成计划假设,不必在生成计划前要求用户复述声明。用户对该计划的一次整体执行确认同时接受这项假设。 + +若参数像整数、计数、无量纲模式量、条件控制量,元数据彼此矛盾,或无法判断改变它是否影响端口和拓扑,必须先用自然语言询问,例如:“这个参数可以取任意小数,并且调整时不会切换组件模式或端口吗?”不要向用户显示内部声明代码。`editor`、`options` 和未来可能出现的显式否决只用于拒绝明显不适用的参数,不能证明其余参数连续。 + +参数值、初值和边界一律使用线性 SI 合同。如果用户用显示单位给出边界,先换算为 SI 并在计划中展示。后端在计划阶段将源 JSON 转换为基准 System XML v3;每个候选都从该 XML 重新生成,只替换选中 `Parameter/@value` 的 SI 数字,不在前一个候选上累积修改,源 JSON 永不被覆盖。 + +### 统计量与响应约束 + +`statistic` 必须同时包含 `kind` 和 `window`。支持的 `kind` 为 `final`、`minimum`、`maximum`、`timeMean`、`rms`、`integral`、`absoluteIntegral` 和 `peakAbsolute`。`window` 可为 `null`,表示使用完整返回时间序列;也可为有限数字的 `{start, end}`,且 `start < end`。 + +窗口边界不在采样点时使用线性插值,不为超出结果范围的窗口外推。时间必须严格递增,时间和数值必须有限且等长。`timeMean`、`rms`、`integral` 和 `absoluteIntegral` 使用梯形时间积分。`minimum`、`maximum` 和 `peakAbsolute` 只是样本及插值边界上的统计,不证明连续时间真实峰值;安全关键峰值可能比 `sampleStep` 更窄时,必须报告采样风险。 + +`constraints` 只支持响应约束。每项必须包含唯一 `id`、`resultKey`、`expectedUnit`、`statistic`、`lower`、`upper`、`tolerance` 和 `scale`。`lower` 或 `upper` 可为 `null`,但至少一个必须是有限数字;两者都存在时必须 `lower <= upper`。`tolerance >= 0`,`scale > 0`。下界在 `value >= lower - tolerance` 时满足,上界在 `value <= upper + tolerance` 时满足。 + +`scale` 只用于将违反量归一化为 `rawViolation / scale`,以便对完整但不可行的候选排序;它不改变可行边界,也不是软约束权重。需要等式时用显式容差带,不使用浮点精确相等。 + +约束的 `lower`、`upper`、`tolerance` 和 `scale` 都使用该约束统计量的单位,而不是一律使用原始序列单位。复验中的单个 `absoluteTolerance` 数值分别按每项统计量自己的单位解释;目标与约束量纲差异很大时优先把它设为 `0` 并使用相对容差,或明确接受这一 schema v1 限制。 + +## 计划确认 + +信息足以形成规格后直接执行 `plan`,不需要用户先批准计划生成。它校验源 JSON、规格、结果键/单位、设计变量合同和边界,并请求后端生成基准 XML,但不开始优化候选仿真。其结构化输出包含源 JSON 与规格 JSON 的绝对路径/SHA-256、解析后规格、基准 XML SHA-256、目标/约束元数据、解析后设计变量、执行上限、绝对输出目录、`parameterContinuity`、`requiredAssertions`,以及值相同的 `planHash` 与 `confirmationToken`。 + +计划中的 `OPTIMIZATION_CONTINUITY_USER_ASSERTION` 是给脚本和审计使用的内部标识,不是要求用户照抄的口令。后端不会验证参数的物理/语义连续性,因此面向用户的计划摘要必须用普通语言列出相关假设。若参数语义清楚,用户在看到摘要后明确同意开始运行,即视为同时接受完整计划和这些假设;未得到这次整体确认时不得传入 `--confirmed`。若参数语义不清,则应在执行确认之前先完成自然语言消歧。 + +`confirmationToken` 绑定源 JSON SHA-256、规格文件 SHA-256、基准 XML SHA-256、解析后的输出目录、后端 base URL、流读取超时、连续性确认策略和搜索策略。向用户展示计划摘要并获得明确确认后,才能传入 `--confirmed`。`optimize` 还必须提供 `plan` 返回的源 SHA-256、规格 SHA-256 和 token;脚本会重建当前计划并拒绝旧策略或其他过期确认。任一绑定项改变,包括搜索策略升级,都必须重新 `plan` 和确认。输出目录必须是新路径或空目录。 + +`requiredAssertions[].code`、各类 SHA、`planHash` 和 `confirmationToken` 是代理执行命令时保存和回传的机器字段。普通对话中应由 Skill 内部保管,不向用户倾倒;只有用户主动要求审计细节,或排查计划过期/文件变化时才展示。 + +普通计划摘要只需要回答: + +- 要改善哪个结果,用什么统计口径; +- 调整哪些参数,各自在什么范围; +- 有哪些响应约束; +- 采用默认还是用户指定的搜索配置,最多提交多少次仿真、最长多久; +- 哪些参数连续性或工程边界属于假设; +- 输出写到哪里,并明确源模型不变。 + +摘要后只问一次自然问题,例如:“就按这个方案开始吗?”用户明确同意后直接执行,不再追加连续性声明、算法参数或 token 确认。若用户明确只要计划,则交付摘要后停止,不把问题措辞成已经准备执行;等用户之后主动要求开始。 + +## DE/rand/1/bin 搜索 + +外层优化由 `optimization_skill.py` 使用 Python 标准库自行实现,不调用 SciPy 优化器,也不安装额外优化依赖。`algorithm` 严格包含: + +```text +name = differentialEvolution +seed = 0 .. 2^32-1 的整数 +populationSize = 4 .. 50 +mutationFactor = (0, 2] +crossoverProbability = [0, 1] +``` + +`populationSize` 是实际种群个体数,不是乘以设计变量数的倍数。初始候选先在每个线性归一化坐标上做拉丁超立方分层,再用工程当前参数替换第一行,所以替换后的最终种群不承诺保持严格拉丁超立方的每层唯一性。只有一个设计变量时,初始种群还会强制包含归一化坐标 `0` 和 `1`,也就是精确测试用户确认的 SI 下界和上界;若基准点已经等于某个端点,不再为该端点制造重复行。第一次提交仍是基准仿真。基准仿真必须完整成功;基准可以不满足响应约束,此时仍可继续搜索。 + +每个完整代开始时冻结当前种群和排名;代内所有目标个体都只从这份冻结种群中选三个不同且不是自己的个体,按 `a + mutationFactor * (b - c)` 生成变异向量,接受结果在完整代结束后统一成为下一代。这是 `updating = deferred`,不会让同一代后面的候选使用刚被接受的新个体。越出归一化区间的坐标通过周期为 `2` 的镜像反射折回 `[0, 1]`,不再硬裁剪到端点;随后做 binomial crossover,并强制至少一个坐标来自变异向量。候选排名顺序是:完整可行点按目标损失;其次是完整但不可行点,按约束归一化违反量总和再按目标损失;失败点最后。平局用最早评估 ID 确定性打破。 + +`seed` 由标准库 `random.Random` 使用。固定种子可使同一 Python 实现和同一后端环境中的候选顺序可重放,但不证明跨 Python 版本、后端代码、操作系统或硬件位级一致。 + +当前搜索策略标识为 `de-rand-1-bin-deferred-reflection-1d-endpoints-stagnation-v3`。该标识随解析后算法配置进入计划,并绑定到确认 token;不能拿旧搜索策略产生的确认 token 启动新版搜索。 + +## 预算、停止、缓存与失败 + +`maxSimulationRuns` 必须至少是 `populationSize + 1`,且不得超过 200。它是仿真预算槽位的严格总上限。每次准备进度记录和打开后端流之前先保守占用一个槽位,因此即使本地进度文件创建或连接失败,该槽位也不会重新使用。搜索最多使用 `maxSimulationRuns - 1` 个槽位,始终为最佳可行候选保留一个绕过缓存的新鲜复验槽位。 + +计划会给出 `fullGenerationsWithUniqueCandidates = floor((searchRunLimit - populationSize) / populationSize)`。它表示在候选都不重复时,初始种群之外预算还能完整覆盖多少代。值为 `0` 时会返回 `OPTIMIZATION_BUDGET_INITIAL_POPULATION_ONLY` 警告;此时仍可按用户确认运行,但必须明确说明搜索覆盖很弱。若希望至少完整执行两代,预算至少应为 `3 * populationSize + 1`。 + +`maxWallSeconds` 必须在 10 秒至 7 天之间。它只在启动下一个搜索候选前检查:达到后不再启动新搜索,不取消正在处理慢步的健康仿真。已存在最佳可行候选时,即使搜索墙钟已到,预留的一次新鲜复验仍会运行。 + +搜索没有目标阈值、目标收敛容差或局部抛光阶段。每个完整 DE 代结束后会统计该代是否产生过新的后端仿真提交;连续 `3` 个完整代没有新提交时提前停止: + +- 若当前种群映射为同一个精确 SI 候选,停止原因为 `populationCollapsedAfterDuplicateStagnation`。这只表示重复停滞发生时种群已经塌缩为一个精确候选,不是数值收敛判定,也不证明全局最优; +- 若种群仍映射为多个精确候选,停止原因为 `duplicateProposalStagnation`。这表示候选生成持续重复缓存中的点,不是收敛判定。 + +仿真预算用完时为 `simulationBudgetExhausted`,搜索墙钟到达时为 `searchWallTimeReached`。为防止任何未预见的重复循环,候选请求另有 `max(100, searchRunLimit * 20)` 的内部防死循环上限;`optimizerCallLimitReached` 仅表示该安全保护触发,绝不能解释成搜索已经收敛。用户中断或结构化错误也会中止运行。 + +结果中的 `generations` 只统计完整完成的 DE 代;如果预算、墙钟或候选请求保护在一代中途阻止下一个候选,该部分代不会增加计数,也不会产生 `optimization-generation-completed` 事件。完整代进一步分成 `generationsWithNewBackendSubmissions` 和 `generationsWithoutNewBackendSubmissions`;初始种群不算一个 DE 代。“候选都唯一时预算可覆盖的完整代数”只是计划容量,不得当作实际完成或有效搜索代数。 + +缓存仅在当前 `optimize` 进程内有效。它根据固定参数 ID 顺序和映射后的精确 SI 数值识别重复候选;目标和所有响应约束共享一次仿真。缓存命中不增加后端提交数,最终复验始终绕过缓存。 + +最终结果使用 schema v2 分开记录搜索、复验、缓存和预算: + +- 顶层 `search` 给出停止原因及类别、恒为 `false` 的 `searchConvergenceEstablished`、`populationCollapsedToSingleCandidate`、搜索候选评估数、`submissionSlotsConsumed`、缓存命中率、搜索预算的上限/已用/未用/是否耗尽、完整代及有新提交/无新提交代数、停滞连续代数、最终种群精确候选数和防死循环上限; +- 顶层 `verification` 给出独立复验是否通过、源文件是否未变、比较容差和 `submissionSlotsConsumed`,并明确其含义是候选可复现且可行,而不是搜索收敛; +- `counts.searchProposals`、`searchSubmissionSlotsConsumed`、`verificationSubmissionSlotsConsumed`、`backendSubmissionSlotsConsumed`、分阶段完成/失败记录、`unrecordedSubmissionSlotsConsumed`、`cacheHits` 和 `remainingSearchRunBudget` 提供可直接核对的分项统计。`optimizerCalls` 仅为初版兼容别名,新字段 `candidateRequestsAllStages` 明确包含搜索、缓存命中和复验请求;不带 `SlotsConsumed` 的 `backendSubmissions` 系列也仅是初版兼容别名,不能解释为后端已经接收。`timing` 使用全部已形成的试验记录计算墙钟、仿真耗时总计、最短、最长和平均值。 +- `bestSearch` 只保存完整可行的最佳搜索点。没有可行点时它必须为 `null`,约束违反最小的完整不可行点只放入 `bestDiagnosticSearch`,报告必须明确称其为诊断点并展示归一化约束违反总量,不能称为方案或最佳可行点。 + +面向用户汇报时分别写“搜索停止”和“候选复验状态”,并同时报告搜索候选评估数、按阶段拆分的预算槽位占用与完成/失败记录、没有形成试验记录的槽位、缓存命中、未用搜索额度、完整代中有新仿真的代数与纯重复代数。不得把槽位占用称为后端已接收或已完成的仿真,也不得把 `solutionStatus: verified`、缓存命中次数、纯重复代、种群塌缩或理论预算容量解释为算法收敛证据。 + +本版没有持久缓存或 resume 命令。`checkpoint.json` 和 `optimization-events.jsonl` 仅用于审计已完成工作,不承诺中断后恢复同一种群。也没有自动重试:后端、网络或产物写入错误会按结构化错误中止,不悄悄再发起一次仿真。 + +只有 `status == completed`、`success == true`、结果变量键与单位仍匹配计划、所需序列存在、时间窗完整被覆盖且时间/数值结构正确时,才计算目标和约束。`failed`、`stalled`、`stopped`、取消或部分结果作为失败候选且不计分,`objectiveValue` 和 `objectiveLoss` 保持为空。若后端把缺失变量、错误单位或畸形序列标成成功完成,则视为结果合同错误并中止本次优化,避免继续解释不可靠数据。完整但不可行的点仍保留统计值,但不能优先于任何完整可行点。 + +## 最终复验、产物与措辞 + +没有完整可行候选时,`solutionStatus` 为 `noFeasibleCandidate`,`bestSearch` 为 `null`;可在 `bestDiagnosticSearch` 中报告归一化约束违反更小的完整不可行候选作诊断,但不生成 `best-*` 产物。存在最佳可行搜索候选时,脚本重新核对源 JSON SHA-256,从基准 XML 生成候选,使用新 simulation ID 并绕过缓存做一次完整新鲜仿真。 + +复验必须仍完整可行,复验结束时源 JSON SHA-256 仍与计划一致,且目标和每个约束原值均满足 `abs(search - verification) <= absoluteTolerance + relativeTolerance * max(abs(search), abs(verification))`。`validation` 只包含非负的 `relativeTolerance` 和 `absoluteTolerance`;复验次数固定为一,不是规格字段。通过时状态为 `verified`,否则为 `verificationFailed`,不用多次平均掩盖差异。`verified` 只表示最佳搜索候选通过了这次绕过缓存的新鲜复验;它不表示搜索收敛,不证明达到稳态,也不是全局最优证明。复验已经通过时,不再默认建议重跑同一项复验。 + +若目标统计量是 `final`,schema v2 的 `objectiveEndpointTrend` 会报告一次不影响复验状态的末段趋势诊断。只有新鲜复验本身完整成功,而且目标序列覆盖统计窗口终点或计划仿真终点时才分析;复验未完成、序列无效或未覆盖计划终点时标为 `unavailable` 并给出原因,不能把部分曲线末尾当成计划终点。它优先检查目标统计时段最后 `5%`,为取得至少 `6` 个样本可向前扩展,但最多使用最后 `20%`;仍不足时标为 `insufficientData`。相对量的尺度取“末段最大绝对值、完整时段最大绝对值的 `1e-6` 倍、最小正正规浮点数”三者的最大值。 + +末段净相对变化至少 `1%` 且非零相邻变化的方向一致率至少 `80%` 时记录 `directionalChange`;零增量不稀释方向一致率。末段相对峰峰范围至少 `2%` 时记录 `tailVariability`。任一条件成立就标为 `materialChangeDetected`。只有方向变化条件成立时才称为上升或下降;仅由范围条件触发时方向为 `fluctuating`,报告明显波动,并分别展示首尾净变化与峰峰范围,不能把振荡描述成单向趋势。结构化结果同时记录末段起止时刻、样本数、起止值、变化量、平均变化率、相对变化、相对范围、方向和检测原因。 + +这项检查只用于提醒“终点快照可能仍处于动态过程”。`steadyStateProven` 始终为 `false`;`noMaterialChangeDetected` 只能表述为“该启发式检查未发现明显末端变化”,不能写成“系统已达到稳态”。检查发现明显变化时,必须指出 `final` 结果只支持所选终点时刻的比较,不能外推成稳态性能更优。 + +优化输出包括 `optimization-plan.json`、`optimization-events.jsonl`、`simulation-progress/evaluation-NNNN.jsonl`、`checkpoint.json`、`evaluations.csv`、`optimization-result.json` 和 `report.md`。`evaluations.csv` 对每次形成试验记录的预算请求写一行;若本地准备或连接在形成试验记录前抛错,预算槽位占用可能比 CSV 行数多。`checkpoint.json` 是审计快照而不是 resume 状态。 + +只有 `solutionStatus == verified` 时才生成 `best-parameters.json`、`best-system.xml`、`best-project.json`、`result.json`、完整 `results.csv` 和目标/响应约束的独立 SVG 曲线。`best-project.json` 将被优化参数的原表达式替换为普通 SI 数值,源 JSON 不变。`optimize` 仅在状态为 `verified` 时返回退出码 `0`,其他结果返回 `4`。 + +最终汇报必须使用这一口径: + +> 这是实际完成仿真的搜索点中表现最好的可行候选,并已通过一次独立复验;复验不证明搜索收敛、系统达到稳态或全局最优。 + +不使用“已找到全局最优”、“必然最优”或其他超出有限搜索证据的措辞。最佳候选位于用户确认的参数边界时,只能报告它是当前边界内实际搜索得到的边界点;在用户确认更宽范围符合物理、安全和组件合同前,不建议直接放宽边界或启动扩边界搜索,也不把有限采样点概括成整个连续区间上的严格单调规律。 + +## 内部命令与 OpenClaw 路径 + +以下命令供 Skill 实现和故障排查使用;正常交互不得要求用户手工运行命令、创建规格文件或复制确认参数。 + +从仓库根目录先预览计划: + +```powershell +py -3.12 skills/system-simulation/scripts/optimization_skill.py plan PROJECT.json ` + --spec optimization-spec.json ` + --output-dir OUTPUT_DIR +``` + +向用户展示计划并获得明确确认后,原样使用 `plan` 返回的三个值: + +```powershell +py -3.12 skills/system-simulation/scripts/optimization_skill.py optimize PROJECT.json ` + --spec optimization-spec.json ` + --output-dir OUTPUT_DIR ` + --expected-source-sha256 SOURCE_SHA256 ` + --expected-spec-sha256 SPEC_SHA256 ` + --confirmation-token CONFIRMATION_TOKEN ` + --confirmed +``` + +Linux 使用已确认的 Python 3.12 解释器和相同参数: + +```bash +python3.12 skills/system-simulation/scripts/optimization_skill.py plan PROJECT.json \ + --spec optimization-spec.json \ + --output-dir OUTPUT_DIR + +python3.12 skills/system-simulation/scripts/optimization_skill.py optimize PROJECT.json \ + --spec optimization-spec.json \ + --output-dir OUTPUT_DIR \ + --expected-source-sha256 SOURCE_SHA256 \ + --expected-spec-sha256 SPEC_SHA256 \ + --confirmation-token CONFIRMATION_TOKEN \ + --confirmed +``` + +需要非默认后端或读取超时时,全局选项必须放在 `plan` / `optimize` 子命令之前,例如: + +```bash +python3.12 skills/system-simulation/scripts/optimization_skill.py \ + --base-url http://127.0.0.1:18082 --timeout 60 \ + plan PROJECT.json --spec optimization-spec.json --output-dir OUTPUT_DIR +``` + +OpenClaw 中不假设当前目录是仓库根目录,使用 Skill 根目录占位符: + +```bash +python3.12 "{baseDir}/scripts/optimization_skill.py" plan PROJECT.json \ + --spec optimization-spec.json \ + --output-dir OUTPUT_DIR +``` + +也可以先进入本 Skill 目录,再使用 `scripts/optimization_skill.py plan ...` 和 `scripts/optimization_skill.py optimize ...`。不根据用户主目录、OpenClaw 数据目录或仓库名称猜测脚本路径。持续消费 JSONL 进展,定期报告已使用/最大后端提交数、当前代数、最佳可行目标、失败数、缓存命中和内层仿真阶段;不因仿真时间短暂停滞而声称卡死。 + +通常省略 `--optimization-id` 让脚本生成唯一 ID。若显式指定,同一后端任务保留窗口内必须使用新的 ID;快速复用旧 ID 会被后端按冲突拒绝。 diff --git a/skills/system-simulation/scripts/optimization_skill.py b/skills/system-simulation/scripts/optimization_skill.py new file mode 100644 index 0000000..29cb675 --- /dev/null +++ b/skills/system-simulation/scripts/optimization_skill.py @@ -0,0 +1,3837 @@ +#!/usr/bin/env python3 +"""Deterministic single-objective optimizer for the system-simulation skill. + +The optimizer treats the existing FastAPI backend as the authority for model +compilation and simulation. It never rewrites the source project in place and +only changes explicitly selected continuous SI parameters in derived +candidates after the user confirms the complete plan. +""" + +from __future__ import annotations + +import argparse +import bisect +import copy +import csv +import hashlib +import io +import json +import math +import os +import platform +import random +import re +import sys +import tempfile +import time +import uuid +import xml.etree.ElementTree as ET +from dataclasses import dataclass, field +from pathlib import Path +from typing import Iterable, Mapping, Sequence + + +SCRIPT_DIRECTORY = Path(__file__).resolve().parent +if str(SCRIPT_DIRECTORY) not in sys.path: + sys.path.insert(0, str(SCRIPT_DIRECTORY)) + +import simulation_skill as simulation # noqa: E402 + + +OPTIMIZATION_SCHEMA_VERSION = 1 +OPTIMIZATION_RESULT_SCHEMA_VERSION = 2 +STATISTIC_IMPLEMENTATION_VERSION = 1 +CONTINUITY_POLICY = "user-assertion-explicit-false-veto-v1" +SEARCH_POLICY = "de-rand-1-bin-deferred-reflection-1d-endpoints-stagnation-v3" +NO_NEW_SUBMISSION_GENERATION_LIMIT = 3 +TERMINAL_TREND_DIAGNOSTIC_VERSION = 1 +TERMINAL_TREND_FRACTION = 0.05 +TERMINAL_TREND_MAX_FRACTION = 0.20 +TERMINAL_TREND_MIN_SAMPLES = 6 +TERMINAL_TREND_RELATIVE_CHANGE_THRESHOLD = 0.01 +TERMINAL_TREND_DIRECTIONAL_CONSISTENCY_THRESHOLD = 0.80 +TERMINAL_TREND_RANGE_THRESHOLD = 0.02 +MAX_DESIGN_VARIABLES = 16 +MAX_RESPONSE_CONSTRAINTS = 16 +MAX_SIMULATION_RUNS = 200 +MAX_WALL_SECONDS = 7 * 24 * 60 * 60 +OPTIMIZATION_ID_PATTERN = re.compile(r"^[A-Za-z0-9._-]{1,96}$") +IDENTIFIER_PATTERN = re.compile(r"^[A-Za-z][A-Za-z0-9._-]{0,63}$") +SUPPORTED_STATISTICS = { + "final", + "minimum", + "maximum", + "timeMean", + "rms", + "integral", + "absoluteIntegral", + "peakAbsolute", +} + + +def _metric_unit(series_unit: str, statistic_kind: str) -> str: + if statistic_kind in {"integral", "absoluteIntegral"}: + return f"({series_unit})*s" if series_unit else "s" + return series_unit + + +class OptimizationError(simulation.SkillCliError): + def __init__( + self, + code: str, + message: str, + details: object | None = None, + ) -> None: + super().__init__(code, message, exit_code=4, details=details) + + +class SearchStop(Exception): + def __init__(self, reason: str) -> None: + super().__init__(reason) + self.reason = reason + + +@dataclass(frozen=True) +class TimeWindow: + start: float + end: float + + def as_dict(self) -> dict[str, float]: + return {"start": self.start, "end": self.end} + + +@dataclass(frozen=True) +class StatisticSpec: + kind: str + window: TimeWindow | None + + def as_dict(self) -> dict[str, object]: + return { + "kind": self.kind, + "window": self.window.as_dict() if self.window is not None else None, + } + + +@dataclass(frozen=True) +class GoalSpec: + kind: str + value: float | None + + def as_dict(self) -> dict[str, object]: + payload: dict[str, object] = {"kind": self.kind} + if self.value is not None: + payload["value"] = self.value + return payload + + +@dataclass(frozen=True) +class ObjectiveSpec: + result_key: str + expected_unit: str + statistic: StatisticSpec + goal: GoalSpec + + def as_dict(self) -> dict[str, object]: + return { + "resultKey": self.result_key, + "expectedUnit": self.expected_unit, + "metricUnit": _metric_unit(self.expected_unit, self.statistic.kind), + "statistic": self.statistic.as_dict(), + "goal": self.goal.as_dict(), + } + + +@dataclass(frozen=True) +class DesignVariableSpec: + id: str + component_id: str + parameter: str + unit: str + lower: float + upper: float + + def as_dict(self) -> dict[str, object]: + return { + "id": self.id, + "componentId": self.component_id, + "parameter": self.parameter, + "unit": self.unit, + "lower": self.lower, + "upper": self.upper, + } + + +@dataclass(frozen=True) +class ResponseConstraintSpec: + id: str + result_key: str + expected_unit: str + statistic: StatisticSpec + lower: float | None + upper: float | None + tolerance: float + scale: float + + def as_dict(self) -> dict[str, object]: + return { + "id": self.id, + "resultKey": self.result_key, + "expectedUnit": self.expected_unit, + "metricUnit": _metric_unit(self.expected_unit, self.statistic.kind), + "statistic": self.statistic.as_dict(), + "lower": self.lower, + "upper": self.upper, + "tolerance": self.tolerance, + "scale": self.scale, + } + + +@dataclass(frozen=True) +class AlgorithmSpec: + name: str + seed: int + population_size: int + mutation_factor: float + crossover_probability: float + + def as_dict(self) -> dict[str, object]: + return { + "name": self.name, + "seed": self.seed, + "populationSize": self.population_size, + "mutationFactor": self.mutation_factor, + "crossoverProbability": self.crossover_probability, + "workers": 1, + "strategy": "DE/rand/1/bin", + "searchPolicy": SEARCH_POLICY, + "updating": "deferred", + "boundaryHandling": "reflection", + "oneDimensionalEndpointSeedingPolicy": ( + "exactBoundsWhenOneDesignVariable" + ), + "noNewSubmissionGenerationLimit": ( + NO_NEW_SUBMISSION_GENERATION_LIMIT + ), + } + + +@dataclass(frozen=True) +class BudgetSpec: + max_simulation_runs: int + max_wall_seconds: float + + def as_dict(self) -> dict[str, object]: + return { + "maxSimulationRuns": self.max_simulation_runs, + "maxWallSeconds": self.max_wall_seconds, + "reservedFreshVerificationRuns": 1, + } + + +@dataclass(frozen=True) +class ValidationSpec: + relative_tolerance: float + absolute_tolerance: float + + def as_dict(self) -> dict[str, object]: + return { + "freshRuns": 1, + "relativeTolerance": self.relative_tolerance, + "absoluteTolerance": self.absolute_tolerance, + } + + +@dataclass(frozen=True) +class OptimizationSpec: + objective: ObjectiveSpec + design_variables: tuple[DesignVariableSpec, ...] + constraints: tuple[ResponseConstraintSpec, ...] + algorithm: AlgorithmSpec + budget: BudgetSpec + validation: ValidationSpec + + def as_dict(self) -> dict[str, object]: + return { + "optimizationSchemaVersion": OPTIMIZATION_SCHEMA_VERSION, + "objective": self.objective.as_dict(), + "designVariables": [item.as_dict() for item in self.design_variables], + "constraints": [item.as_dict() for item in self.constraints], + "algorithm": self.algorithm.as_dict(), + "budget": self.budget.as_dict(), + "validation": self.validation.as_dict(), + } + + +@dataclass(frozen=True) +class ResolvedDesignVariable: + spec: DesignVariableSpec + label: str + quantity: str + initial: float + was_explicit: bool + original_value: object + catalog_optimization_eligible: bool | None + + def as_dict(self) -> dict[str, object]: + return { + **self.spec.as_dict(), + "label": self.label, + "quantity": self.quantity, + "initial": self.initial, + "wasExplicit": self.was_explicit, + "originalValue": self.original_value, + "continuity": { + "machineVerified": False, + "userAssertionRequired": True, + "catalogOptimizationEligible": ( + self.catalog_optimization_eligible + ), + "editorAbsent": True, + "optionsAbsent": True, + }, + } + + +@dataclass(frozen=True) +class RuntimePlan: + source: simulation.SourceFile + spec_source: simulation.SourceFile + spec: OptimizationSpec + inspection: dict[str, object] + baseline_xml: bytes + variables: dict[str, dict[str, object]] + resolved_design_variables: tuple[ResolvedDesignVariable, ...] + output_directory: Path + base_url: str + timeout: float + confirmation_token: str + + def public_dict(self) -> dict[str, object]: + search_run_limit = self.spec.budget.max_simulation_runs - 1 + population_size = self.spec.algorithm.population_size + full_generations_with_unique_candidates = max( + 0, (search_run_limit - population_size) // population_size + ) + warnings: list[dict[str, object]] = [] + design_variable_ids = [ + item.spec.id for item in self.resolved_design_variables + ] + if full_generations_with_unique_candidates == 0: + warnings.append( + { + "code": "OPTIMIZATION_BUDGET_INITIAL_POPULATION_ONLY", + "message": ( + "The simulation budget covers the initial population and " + "fresh verification, but no complete DE generation if every " + "candidate is unique." + ), + } + ) + return { + "ok": True, + "command": "optimization-plan", + "confirmationRequired": True, + "source": { + "path": str(self.source.path), + "sha256": self.source.sha256, + "format": self.source.format, + }, + "spec": { + "path": str(self.spec_source.path), + "sha256": self.spec_source.sha256, + "resolved": self.spec.as_dict(), + }, + "baselineSystemXmlSha256": _sha256(self.baseline_xml), + "objectiveMetadata": self.variables[self.spec.objective.result_key], + "objectiveMetricUnit": _metric_unit( + self.spec.objective.expected_unit, + self.spec.objective.statistic.kind, + ), + "constraintMetadata": [ + self.variables[item.result_key] for item in self.spec.constraints + ], + "designVariables": [ + item.as_dict() for item in self.resolved_design_variables + ], + "parameterContinuity": { + "policy": CONTINUITY_POLICY, + "machineVerified": False, + "backendDeclarationRequired": False, + "userAssertionRequiredFor": design_variable_ids, + }, + "requiredAssertions": [ + { + "code": "OPTIMIZATION_CONTINUITY_USER_ASSERTION", + "designVariableIds": design_variable_ids, + "message": ( + "Confirm that every listed design variable is continuous, " + "uses a linear SI scale, and cannot change ports, topology, " + "modes, or other discrete model structure. The backend does " + "not verify this property." + ), + } + ], + "execution": { + "algorithm": self.spec.algorithm.as_dict(), + "searchRunLimit": search_run_limit, + "totalRunLimit": self.spec.budget.max_simulation_runs, + "fullGenerationsWithUniqueCandidates": ( + full_generations_with_unique_candidates + ), + "sequential": True, + "backendBaseUrl": self.base_url, + "readTimeoutSeconds": self.timeout, + "outputDirectory": str(self.output_directory), + "sourceWillBeOverwritten": False, + }, + "warnings": warnings, + "planHash": self.confirmation_token, + "confirmationToken": self.confirmation_token, + } + + +@dataclass +class Trial: + evaluation_id: int + stage: str + simulation_id: str + parameters: dict[str, float] + status: str + duration_seconds: float + objective_value: float | None = None + objective_loss: float | None = None + constraints: list[dict[str, object]] = field(default_factory=list) + feasible: bool = False + total_constraint_violation: float | None = None + failure_code: str | None = None + failure_message: str | None = None + cache_reuse_count: int = 0 + + def as_dict(self) -> dict[str, object]: + return { + "evaluationId": self.evaluation_id, + "stage": self.stage, + "simulationId": self.simulation_id, + "parameters": self.parameters, + "status": self.status, + "durationSeconds": self.duration_seconds, + "objectiveValue": self.objective_value, + "objectiveLoss": self.objective_loss, + "constraints": self.constraints, + "feasible": self.feasible, + "totalConstraintViolation": self.total_constraint_violation, + "failureCode": self.failure_code, + "failureMessage": self.failure_message, + "cacheReuseCount": self.cache_reuse_count, + } + + +def _sha256(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def _mapping(value: object, path: str) -> dict[str, object]: + if not isinstance(value, dict): + raise simulation.InputError( + "OPTIMIZATION_SPEC_INVALID", + f"{path} must be an object.", + ) + return value + + +def _list(value: object, path: str) -> list[object]: + if not isinstance(value, list): + raise simulation.InputError( + "OPTIMIZATION_SPEC_INVALID", + f"{path} must be an array.", + ) + return value + + +def _keys( + value: Mapping[str, object], + *, + path: str, + required: set[str], + optional: set[str] = frozenset(), +) -> None: + missing = sorted(required - set(value)) + unknown = sorted(set(value) - required - optional) + if missing or unknown: + raise simulation.InputError( + "OPTIMIZATION_SPEC_INVALID", + f"{path} contains missing or unsupported fields.", + {"path": path, "missing": missing, "unknown": unknown}, + ) + + +def _text(value: object, path: str, *, identifier: bool = False) -> str: + if not isinstance(value, str) or not value: + raise simulation.InputError( + "OPTIMIZATION_SPEC_INVALID", + f"{path} must be a non-empty string.", + ) + if identifier and IDENTIFIER_PATTERN.fullmatch(value) is None: + raise simulation.InputError( + "OPTIMIZATION_SPEC_INVALID", + f"{path} must match {IDENTIFIER_PATTERN.pattern}.", + ) + return value + + +def _number(value: object, path: str) -> float: + if not isinstance(value, (int, float)) or isinstance(value, bool): + raise simulation.InputError( + "OPTIMIZATION_SPEC_INVALID", + f"{path} must be a finite number.", + ) + parsed = float(value) + if not math.isfinite(parsed): + raise simulation.InputError( + "OPTIMIZATION_SPEC_INVALID", + f"{path} must be a finite number.", + ) + return parsed + + +def _integer(value: object, path: str) -> int: + if not isinstance(value, int) or isinstance(value, bool): + raise simulation.InputError( + "OPTIMIZATION_SPEC_INVALID", + f"{path} must be an integer.", + ) + return value + + +def _optional_number(value: object, path: str) -> float | None: + return None if value is None else _number(value, path) + + +def _parse_statistic(value: object, path: str) -> StatisticSpec: + payload = _mapping(value, path) + _keys(payload, path=path, required={"kind", "window"}) + kind = _text(payload["kind"], f"{path}.kind") + if kind not in SUPPORTED_STATISTICS: + raise simulation.InputError( + "OPTIMIZATION_STATISTIC_UNSUPPORTED", + f"{path}.kind is not supported.", + {"supported": sorted(SUPPORTED_STATISTICS)}, + ) + raw_window = payload["window"] + if raw_window is None: + window = None + else: + window_payload = _mapping(raw_window, f"{path}.window") + _keys( + window_payload, + path=f"{path}.window", + required={"start", "end"}, + ) + start = _number(window_payload["start"], f"{path}.window.start") + end = _number(window_payload["end"], f"{path}.window.end") + if not start < end or not math.isfinite(end - start): + raise simulation.InputError( + "OPTIMIZATION_WINDOW_INVALID", + f"{path}.window requires start < end with a finite numeric span.", + ) + window = TimeWindow(start, end) + return StatisticSpec(kind, window) + + +def _parse_goal(value: object, path: str) -> GoalSpec: + payload = _mapping(value, path) + _keys(payload, path=path, required={"kind"}, optional={"value"}) + kind = _text(payload["kind"], f"{path}.kind") + if kind not in {"minimize", "maximize", "target"}: + raise simulation.InputError( + "OPTIMIZATION_GOAL_UNSUPPORTED", + f"{path}.kind must be minimize, maximize, or target.", + ) + if kind == "target": + if "value" not in payload: + raise simulation.InputError( + "OPTIMIZATION_SPEC_INVALID", + f"{path}.value is required for a target goal.", + ) + target = _number(payload["value"], f"{path}.value") + else: + if "value" in payload: + raise simulation.InputError( + "OPTIMIZATION_SPEC_INVALID", + f"{path}.value is only valid for a target goal.", + ) + target = None + return GoalSpec(kind, target) + + +def parse_optimization_spec(value: object) -> OptimizationSpec: + payload = _mapping(value, "optimization spec") + _keys( + payload, + path="optimization spec", + required={ + "optimizationSchemaVersion", + "objective", + "designVariables", + "constraints", + "algorithm", + "budget", + "validation", + }, + ) + schema_version = _integer( + payload["optimizationSchemaVersion"], + "optimizationSchemaVersion", + ) + if schema_version != OPTIMIZATION_SCHEMA_VERSION: + raise simulation.InputError( + "OPTIMIZATION_SCHEMA_VERSION_UNSUPPORTED", + f"optimizationSchemaVersion must be {OPTIMIZATION_SCHEMA_VERSION}.", + ) + + objective_payload = _mapping(payload["objective"], "objective") + _keys( + objective_payload, + path="objective", + required={"resultKey", "expectedUnit", "statistic", "goal"}, + ) + objective = ObjectiveSpec( + result_key=_text(objective_payload["resultKey"], "objective.resultKey"), + expected_unit=( + objective_payload["expectedUnit"] + if isinstance(objective_payload["expectedUnit"], str) + else _text(objective_payload["expectedUnit"], "objective.expectedUnit") + ), + statistic=_parse_statistic(objective_payload["statistic"], "objective.statistic"), + goal=_parse_goal(objective_payload["goal"], "objective.goal"), + ) + + raw_design_variables = _list(payload["designVariables"], "designVariables") + if not 1 <= len(raw_design_variables) <= MAX_DESIGN_VARIABLES: + raise simulation.InputError( + "OPTIMIZATION_DESIGN_VARIABLE_COUNT_INVALID", + f"designVariables must contain 1 to {MAX_DESIGN_VARIABLES} entries.", + ) + design_variables: list[DesignVariableSpec] = [] + for index, raw_item in enumerate(raw_design_variables): + path = f"designVariables[{index}]" + item = _mapping(raw_item, path) + _keys( + item, + path=path, + required={"id", "componentId", "parameter", "unit", "lower", "upper"}, + ) + unit = item["unit"] + if not isinstance(unit, str): + raise simulation.InputError( + "OPTIMIZATION_SPEC_INVALID", + f"{path}.unit must be a string.", + ) + lower = _number(item["lower"], f"{path}.lower") + upper = _number(item["upper"], f"{path}.upper") + if not lower < upper or not math.isfinite(upper - lower): + raise simulation.InputError( + "OPTIMIZATION_BOUNDS_INVALID", + f"{path} requires lower < upper with a finite numeric span.", + ) + design_variables.append( + DesignVariableSpec( + id=_text(item["id"], f"{path}.id", identifier=True), + component_id=_text(item["componentId"], f"{path}.componentId"), + parameter=_text(item["parameter"], f"{path}.parameter"), + unit=unit, + lower=lower, + upper=upper, + ) + ) + ids = [item.id for item in design_variables] + targets = [(item.component_id, item.parameter) for item in design_variables] + if len(ids) != len(set(ids)) or len(targets) != len(set(targets)): + raise simulation.InputError( + "OPTIMIZATION_DESIGN_VARIABLE_DUPLICATE", + "Design-variable IDs and component/parameter targets must be unique.", + ) + + raw_constraints = _list(payload["constraints"], "constraints") + if len(raw_constraints) > MAX_RESPONSE_CONSTRAINTS: + raise simulation.InputError( + "OPTIMIZATION_CONSTRAINT_COUNT_INVALID", + f"constraints may contain at most {MAX_RESPONSE_CONSTRAINTS} entries.", + ) + constraints: list[ResponseConstraintSpec] = [] + for index, raw_item in enumerate(raw_constraints): + path = f"constraints[{index}]" + item = _mapping(raw_item, path) + _keys( + item, + path=path, + required={ + "id", + "resultKey", + "expectedUnit", + "statistic", + "lower", + "upper", + "tolerance", + "scale", + }, + ) + unit = item["expectedUnit"] + if not isinstance(unit, str): + raise simulation.InputError( + "OPTIMIZATION_SPEC_INVALID", + f"{path}.expectedUnit must be a string.", + ) + lower = _optional_number(item["lower"], f"{path}.lower") + upper = _optional_number(item["upper"], f"{path}.upper") + tolerance = _number(item["tolerance"], f"{path}.tolerance") + scale = _number(item["scale"], f"{path}.scale") + if lower is None and upper is None: + raise simulation.InputError( + "OPTIMIZATION_CONSTRAINT_INVALID", + f"{path} requires at least one finite bound.", + ) + if lower is not None and upper is not None and lower > upper: + raise simulation.InputError( + "OPTIMIZATION_CONSTRAINT_INVALID", + f"{path} requires lower <= upper.", + ) + if tolerance < 0 or scale <= 0: + raise simulation.InputError( + "OPTIMIZATION_CONSTRAINT_INVALID", + f"{path}.tolerance must be non-negative and scale must be positive.", + ) + constraints.append( + ResponseConstraintSpec( + id=_text(item["id"], f"{path}.id", identifier=True), + result_key=_text(item["resultKey"], f"{path}.resultKey"), + expected_unit=unit, + statistic=_parse_statistic(item["statistic"], f"{path}.statistic"), + lower=lower, + upper=upper, + tolerance=tolerance, + scale=scale, + ) + ) + constraint_ids = [item.id for item in constraints] + if len(constraint_ids) != len(set(constraint_ids)): + raise simulation.InputError( + "OPTIMIZATION_CONSTRAINT_DUPLICATE", + "Constraint IDs must be unique.", + ) + + algorithm_payload = _mapping(payload["algorithm"], "algorithm") + _keys( + algorithm_payload, + path="algorithm", + required={ + "name", + "seed", + "populationSize", + "mutationFactor", + "crossoverProbability", + }, + ) + algorithm_name = _text(algorithm_payload["name"], "algorithm.name") + if algorithm_name != "differentialEvolution": + raise simulation.InputError( + "OPTIMIZATION_ALGORITHM_UNSUPPORTED", + "Only differentialEvolution is supported in optimization schema 1.", + ) + seed = _integer(algorithm_payload["seed"], "algorithm.seed") + population_size = _integer( + algorithm_payload["populationSize"], "algorithm.populationSize" + ) + mutation_factor = _number( + algorithm_payload["mutationFactor"], "algorithm.mutationFactor" + ) + crossover_probability = _number( + algorithm_payload["crossoverProbability"], + "algorithm.crossoverProbability", + ) + if not 0 <= seed <= 2**32 - 1: + raise simulation.InputError( + "OPTIMIZATION_ALGORITHM_INVALID", "algorithm.seed is out of range." + ) + if not 4 <= population_size <= 50: + raise simulation.InputError( + "OPTIMIZATION_ALGORITHM_INVALID", + "algorithm.populationSize must be between 4 and 50.", + ) + if not 0 < mutation_factor <= 2 or not 0 <= crossover_probability <= 1: + raise simulation.InputError( + "OPTIMIZATION_ALGORITHM_INVALID", + "Mutation must be in (0, 2] and crossover probability in [0, 1].", + ) + algorithm = AlgorithmSpec( + algorithm_name, + seed, + population_size, + mutation_factor, + crossover_probability, + ) + + budget_payload = _mapping(payload["budget"], "budget") + _keys( + budget_payload, + path="budget", + required={"maxSimulationRuns", "maxWallSeconds"}, + ) + max_runs = _integer( + budget_payload["maxSimulationRuns"], "budget.maxSimulationRuns" + ) + max_wall_seconds = _number( + budget_payload["maxWallSeconds"], "budget.maxWallSeconds" + ) + if not population_size + 1 <= max_runs <= MAX_SIMULATION_RUNS: + raise simulation.InputError( + "OPTIMIZATION_BUDGET_INVALID", + "maxSimulationRuns must fit the initial population plus one fresh " + f"verification run and may not exceed {MAX_SIMULATION_RUNS}.", + ) + if not 10 <= max_wall_seconds <= MAX_WALL_SECONDS: + raise simulation.InputError( + "OPTIMIZATION_BUDGET_INVALID", + f"maxWallSeconds must be between 10 and {MAX_WALL_SECONDS}.", + ) + budget = BudgetSpec(max_runs, max_wall_seconds) + + validation_payload = _mapping(payload["validation"], "validation") + _keys( + validation_payload, + path="validation", + required={"relativeTolerance", "absoluteTolerance"}, + ) + relative_tolerance = _number( + validation_payload["relativeTolerance"], + "validation.relativeTolerance", + ) + absolute_tolerance = _number( + validation_payload["absoluteTolerance"], + "validation.absoluteTolerance", + ) + if relative_tolerance < 0 or absolute_tolerance < 0: + raise simulation.InputError( + "OPTIMIZATION_VALIDATION_INVALID", + "Validation tolerances must be non-negative.", + ) + validation = ValidationSpec(relative_tolerance, absolute_tolerance) + + return OptimizationSpec( + objective=objective, + design_variables=tuple(design_variables), + constraints=tuple(constraints), + algorithm=algorithm, + budget=budget, + validation=validation, + ) + + +def _validate_output_target(path_text: str) -> Path: + path = Path(path_text).expanduser().resolve(strict=False) + if path.exists(): + if not path.is_dir(): + raise simulation.InputError( + "OUTPUT_DIR_NOT_DIRECTORY", + f"Output path is not a directory: {path}", + ) + try: + if any(path.iterdir()): + raise simulation.InputError( + "OUTPUT_DIR_NOT_EMPTY", + "Optimization output directory must be new or empty.", + {"path": str(path)}, + ) + except OSError as exc: + raise simulation.ArtifactError( + "OUTPUT_DIR_UNREADABLE", str(exc), {"path": str(path)} + ) from exc + return path + + +def _parameter_contracts( + inspection: Mapping[str, object], +) -> dict[tuple[str, str], tuple[dict[str, object], dict[str, object] | None]]: + system = inspection.get("system") + if not isinstance(system, Mapping): + return {} + details = system.get("componentDetails") + if not isinstance(details, list): + return {} + contracts: dict[ + tuple[str, str], tuple[dict[str, object], dict[str, object] | None] + ] = {} + for detail in details: + if not isinstance(detail, dict): + continue + component_id = detail.get("id") + compiled = detail.get("compiled") + if not isinstance(component_id, str) or not isinstance(compiled, dict): + continue + source_component = detail.get("source") + source_mapping = source_component if isinstance(source_component, dict) else None + parameters = compiled.get("parameters") + if not isinstance(parameters, list): + continue + for parameter in parameters: + if isinstance(parameter, dict) and isinstance(parameter.get("name"), str): + contracts[(component_id, str(parameter["name"]))] = ( + parameter, + source_mapping, + ) + return contracts + + +def _source_parameter( + source_component: Mapping[str, object] | None, + parameter_name: str, +) -> tuple[bool, object]: + if not isinstance(source_component, Mapping): + return False, None + data = source_component.get("data") + if not isinstance(data, Mapping): + return False, None + parameters = data.get("parameters") + if not isinstance(parameters, Mapping) or parameter_name not in parameters: + return False, None + return True, parameters[parameter_name] + + +def _resolve_design_variables( + spec: OptimizationSpec, + inspection: Mapping[str, object], +) -> tuple[ResolvedDesignVariable, ...]: + contracts = _parameter_contracts(inspection) + resolved: list[ResolvedDesignVariable] = [] + for variable in spec.design_variables: + target = (variable.component_id, variable.parameter) + contract_pair = contracts.get(target) + if contract_pair is None: + raise simulation.InputError( + "OPTIMIZATION_PARAMETER_UNKNOWN", + "A design variable does not identify a compiled component parameter.", + {"componentId": variable.component_id, "parameter": variable.parameter}, + ) + contract, source_component = contract_pair + catalog_optimization_eligible: bool | None = None + if "optimizationEligible" in contract: + optimization_eligible = contract["optimizationEligible"] + if type(optimization_eligible) is not bool: + raise simulation.BackendError( + "BACKEND_PARAMETER_CONTRACT_INVALID", + "The optional optimizationEligible parameter field must be a boolean.", + { + "componentId": variable.component_id, + "parameter": variable.parameter, + "optimizationEligible": optimization_eligible, + }, + ) + if not optimization_eligible: + raise simulation.InputError( + "OPTIMIZATION_PARAMETER_INELIGIBLE", + "The backend contract explicitly marks this parameter as " + "ineligible for continuous optimization.", + { + "componentId": variable.component_id, + "parameter": variable.parameter, + "optimizationEligible": False, + }, + ) + catalog_optimization_eligible = True + if "editor" in contract: + editor = contract["editor"] + if not isinstance(editor, str) or not editor: + raise simulation.BackendError( + "BACKEND_PARAMETER_CONTRACT_INVALID", + "The optional editor parameter field must be a non-empty string.", + { + "componentId": variable.component_id, + "parameter": variable.parameter, + "editor": editor, + }, + ) + raise simulation.InputError( + "OPTIMIZATION_PARAMETER_DISCRETE_METADATA", + "A parameter with an editor cannot be used for continuous optimization.", + { + "componentId": variable.component_id, + "parameter": variable.parameter, + "optimizationEligible": contract.get("optimizationEligible"), + "editor": editor, + }, + ) + if "options" in contract: + options = contract["options"] + if not isinstance(options, list) or not options: + raise simulation.BackendError( + "BACKEND_PARAMETER_CONTRACT_INVALID", + "The optional options parameter field must be a non-empty array.", + { + "componentId": variable.component_id, + "parameter": variable.parameter, + "options": options, + }, + ) + raise simulation.InputError( + "OPTIMIZATION_PARAMETER_DISCRETE_METADATA", + "A parameter with discrete options cannot be used for continuous optimization.", + { + "componentId": variable.component_id, + "parameter": variable.parameter, + "optimizationEligible": contract.get("optimizationEligible"), + "options": options, + }, + ) + contract_unit = contract.get("unit") + if not isinstance(contract_unit, str) or contract_unit != variable.unit: + raise simulation.InputError( + "OPTIMIZATION_PARAMETER_UNIT_MISMATCH", + "The design-variable unit must exactly match the backend SI contract.", + { + "id": variable.id, + "expected": contract_unit, + "received": variable.unit, + }, + ) + current = contract.get("value") + initial = _number( + current, + f"compiled parameter {variable.component_id}.{variable.parameter}.value", + ) + if not variable.lower <= initial <= variable.upper: + raise simulation.InputError( + "OPTIMIZATION_INITIAL_OUTSIDE_BOUNDS", + "The current SI parameter value must lie inside the declared search bounds.", + {"id": variable.id, "initial": initial}, + ) + minimum = contract.get("minimum") + maximum = contract.get("maximum") + if isinstance(minimum, (int, float)) and not isinstance(minimum, bool): + minimum_value = float(minimum) + minimum_exclusive = contract.get("minimumExclusive") is True + if variable.lower < minimum_value or ( + minimum_exclusive and variable.lower <= minimum_value + ): + raise simulation.InputError( + "OPTIMIZATION_BOUNDS_OUTSIDE_CONTRACT", + "A lower search bound violates the registered parameter contract.", + { + "id": variable.id, + "minimum": minimum_value, + "minimumExclusive": minimum_exclusive, + }, + ) + if isinstance(maximum, (int, float)) and not isinstance(maximum, bool): + maximum_value = float(maximum) + if variable.upper > maximum_value: + raise simulation.InputError( + "OPTIMIZATION_BOUNDS_OUTSIDE_CONTRACT", + "An upper search bound violates the registered parameter contract.", + {"id": variable.id, "maximum": maximum_value}, + ) + was_explicit, original_value = _source_parameter( + source_component, variable.parameter + ) + resolved.append( + ResolvedDesignVariable( + spec=variable, + label=str(contract.get("label") or variable.parameter), + quantity=str(contract.get("quantity") or "dimensionless"), + initial=initial, + was_explicit=was_explicit, + original_value=original_value, + catalog_optimization_eligible=catalog_optimization_eligible, + ) + ) + return tuple(resolved) + + +def _validate_result_contracts( + spec: OptimizationSpec, + variables: Mapping[str, Mapping[str, object]], +) -> None: + requested: list[tuple[str, str, str]] = [ + ( + "objective", + spec.objective.result_key, + spec.objective.expected_unit, + ) + ] + requested.extend( + (f"constraint {constraint.id}", constraint.result_key, constraint.expected_unit) + for constraint in spec.constraints + ) + for label, result_key, expected_unit in requested: + metadata = variables.get(result_key) + if metadata is None: + raise simulation.InputError( + "OPTIMIZATION_RESULT_VARIABLE_UNKNOWN", + f"The {label} result key is not declared by the compiled model.", + {"resultKey": result_key}, + ) + actual_unit = metadata.get("unit") + if not isinstance(actual_unit, str): + actual_unit = "" + if actual_unit != expected_unit: + raise simulation.InputError( + "OPTIMIZATION_RESULT_UNIT_MISMATCH", + f"The {label} expected unit does not match result metadata.", + { + "resultKey": result_key, + "expected": expected_unit, + "received": actual_unit, + }, + ) + + +def _confirmation_token( + *, + source_sha256: str, + spec_sha256: str, + baseline_xml_sha256: str, + output_directory: Path, + base_url: str, + timeout: float, +) -> str: + payload = { + "contract": "system-simulation-optimization-confirmation-v3", + "continuityPolicy": CONTINUITY_POLICY, + "searchPolicy": SEARCH_POLICY, + "sourceSha256": source_sha256, + "specSha256": spec_sha256, + "baselineSystemXmlSha256": baseline_xml_sha256, + "outputDirectory": str(output_directory), + "baseUrl": base_url, + "readTimeoutSeconds": timeout, + } + canonical = json.dumps( + payload, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ).encode("utf-8") + return _sha256(canonical) + + +def build_runtime_plan( + input_path: str, + spec_path: str, + output_directory: str, + *, + base_url: str, + timeout: float, +) -> RuntimePlan: + source = simulation.load_source(input_path, "json") + if source.format != "json": + raise simulation.InputError( + "OPTIMIZATION_SOURCE_FORMAT_UNSUPPORTED", + "Optimization schema 1 requires an editable ReactFlow project JSON v1 source.", + ) + spec_source = simulation.load_source(spec_path, "json") + spec = parse_optimization_spec(spec_source.parsed) + target_output = _validate_output_target(output_directory) + component_ids = list( + dict.fromkeys(item.component_id for item in spec.design_variables) + ) + inspection = simulation.inspect_source( + source, + base_url=base_url, + timeout=timeout, + component_ids=component_ids, + variable_limit=1_000_000, + ) + variables = simulation._available_variables(inspection) + _validate_result_contracts(spec, variables) + resolved = _resolve_design_variables(spec, inspection) + baseline_xml = simulation._json_to_xml( + source, + base_url=base_url, + timeout=timeout, + ) + token = _confirmation_token( + source_sha256=source.sha256, + spec_sha256=spec_source.sha256, + baseline_xml_sha256=_sha256(baseline_xml), + output_directory=target_output, + base_url=base_url, + timeout=timeout, + ) + return RuntimePlan( + source=source, + spec_source=spec_source, + spec=spec, + inspection=inspection, + baseline_xml=baseline_xml, + variables=variables, + resolved_design_variables=resolved, + output_directory=target_output, + base_url=base_url, + timeout=timeout, + confirmation_token=token, + ) + + +def command_plan(args: argparse.Namespace) -> int: + plan = build_runtime_plan( + args.input, + args.spec, + args.output_dir, + base_url=args.base_url, + timeout=args.timeout, + ) + simulation.emit_json(plan.public_dict()) + return 0 + + +def _loss(value: float, goal: GoalSpec) -> float: + if goal.kind == "minimize": + return value + if goal.kind == "maximize": + return -value + assert goal.value is not None + return abs(value - goal.value) + + +def _interpolate( + times: Sequence[float], + values: Sequence[float], + point: float, +) -> float: + index = bisect.bisect_left(times, point) + if index < len(times) and times[index] == point: + return values[index] + if index == 0 or index == len(times): + raise OptimizationError( + "OPTIMIZATION_WINDOW_NOT_COVERED", + "A completed simulation does not cover a requested statistic window.", + {"point": point, "start": times[0], "end": times[-1]}, + ) + left_time = times[index - 1] + right_time = times[index] + time_span = right_time - left_time + if not math.isfinite(time_span): + raise OptimizationError( + "OPTIMIZATION_METRIC_OVERFLOW", + "Interpolation time span overflowed the finite numeric contract.", + ) + fraction = (point - left_time) / time_span + interpolated = ( + (1.0 - fraction) * values[index - 1] + fraction * values[index] + ) + if not math.isfinite(interpolated): + raise OptimizationError( + "OPTIMIZATION_METRIC_OVERFLOW", + "Interpolated result value overflowed the finite numeric contract.", + ) + return interpolated + + +def _windowed_series( + times: Sequence[float], + values: Sequence[float], + window: TimeWindow | None, +) -> tuple[list[float], list[float]]: + if len(times) != len(values) or not times: + raise OptimizationError( + "OPTIMIZATION_RESULT_SERIES_INVALID", + "Objective and constraint series must be non-empty and match time length.", + ) + converted_times: list[float] = [] + converted_values: list[float] = [] + previous: float | None = None + for raw_time, raw_value in zip(times, values): + if ( + not isinstance(raw_time, (int, float)) + or isinstance(raw_time, bool) + or not math.isfinite(float(raw_time)) + or not isinstance(raw_value, (int, float)) + or isinstance(raw_value, bool) + or not math.isfinite(float(raw_value)) + ): + raise OptimizationError( + "OPTIMIZATION_RESULT_SERIES_INVALID", + "Objective and constraint series must contain finite numbers.", + ) + current_time = float(raw_time) + if previous is not None and current_time <= previous: + raise OptimizationError( + "OPTIMIZATION_RESULT_TIME_INVALID", + "Simulation time must be strictly increasing for optimization metrics.", + ) + converted_times.append(current_time) + converted_values.append(float(raw_value)) + previous = current_time + if window is None: + return converted_times, converted_values + if window.start < converted_times[0] or window.end > converted_times[-1]: + raise OptimizationError( + "OPTIMIZATION_WINDOW_NOT_COVERED", + "A completed simulation does not cover a requested statistic window.", + { + "window": window.as_dict(), + "resultStart": converted_times[0], + "resultEnd": converted_times[-1], + }, + ) + start_index = bisect.bisect_left(converted_times, window.start) + end_index = bisect.bisect_right(converted_times, window.end) + selected_times = converted_times[start_index:end_index] + selected_values = converted_values[start_index:end_index] + if not selected_times or selected_times[0] != window.start: + selected_times.insert(0, window.start) + selected_values.insert( + 0, + _interpolate(converted_times, converted_values, window.start), + ) + if selected_times[-1] != window.end: + selected_times.append(window.end) + selected_values.append( + _interpolate(converted_times, converted_values, window.end) + ) + return selected_times, selected_values + + +def statistic_value( + times: Sequence[float], + values: Sequence[float], + statistic: StatisticSpec, +) -> float: + selected_times, selected_values = _windowed_series( + times, values, statistic.window + ) + if statistic.kind == "final": + return selected_values[-1] + if statistic.kind == "minimum": + return min(selected_values) + if statistic.kind == "maximum": + return max(selected_values) + if statistic.kind == "peakAbsolute": + return max(abs(value) for value in selected_values) + if len(selected_times) < 2 or selected_times[-1] <= selected_times[0]: + raise OptimizationError( + "OPTIMIZATION_STATISTIC_WINDOW_TOO_SHORT", + f"{statistic.kind} requires a positive-duration time window.", + ) + duration = selected_times[-1] - selected_times[0] + if not math.isfinite(duration): + raise OptimizationError( + "OPTIMIZATION_METRIC_OVERFLOW", + "Statistic time span overflowed the finite numeric contract.", + ) + + def finite_sum(terms: Iterable[float]) -> float: + try: + value = math.fsum(terms) + except (OverflowError, ValueError) as exc: + raise OptimizationError( + "OPTIMIZATION_METRIC_OVERFLOW", + "Statistic integration overflowed the finite numeric contract.", + ) from exc + if not math.isfinite(value): + raise OptimizationError( + "OPTIMIZATION_METRIC_OVERFLOW", + "Statistic integration overflowed the finite numeric contract.", + ) + return value + + if statistic.kind == "integral": + return finite_sum( + 0.5 * (left_value + right_value) * (right_time - left_time) + for left_time, right_time, left_value, right_value in zip( + selected_times, + selected_times[1:], + selected_values, + selected_values[1:], + ) + ) + if statistic.kind == "absoluteIntegral": + return finite_sum( + 0.5 * (abs(left_value) + abs(right_value)) * (right_time - left_time) + for left_time, right_time, left_value, right_value in zip( + selected_times, + selected_times[1:], + selected_values, + selected_values[1:], + ) + ) + if statistic.kind == "timeMean": + integral = statistic_value( + selected_times, + selected_values, + StatisticSpec("integral", None), + ) + mean = integral / duration + if not math.isfinite(mean): + raise OptimizationError( + "OPTIMIZATION_METRIC_OVERFLOW", + "Time-mean calculation overflowed the finite numeric contract.", + ) + return mean + if statistic.kind == "rms": + square_integral = finite_sum( + 0.5 * (left_value**2 + right_value**2) * (right_time - left_time) + for left_time, right_time, left_value, right_value in zip( + selected_times, + selected_times[1:], + selected_values, + selected_values[1:], + ) + ) + rms = math.sqrt(max(square_integral / duration, 0.0)) + if not math.isfinite(rms): + raise OptimizationError( + "OPTIMIZATION_METRIC_OVERFLOW", + "RMS calculation overflowed the finite numeric contract.", + ) + return rms + raise AssertionError(f"Unhandled statistic kind: {statistic.kind}") + + +def _planned_objective_end(plan: RuntimePlan) -> float | None: + window = plan.spec.objective.statistic.window + if window is not None: + return window.end + system = plan.inspection.get("system") + simulation_settings = ( + system.get("simulation") if isinstance(system, Mapping) else None + ) + if isinstance(simulation_settings, Mapping): + for key in ("t_stop", "tStop", "endTime", "stopTime"): + value = simulation_settings.get(key) + if ( + isinstance(value, (int, float)) + and not isinstance(value, bool) + and math.isfinite(float(value)) + ): + return float(value) + try: + root = ET.fromstring(plan.baseline_xml) + except ET.ParseError: + return None + simulation_element = root.find("./Simulation") + if simulation_element is None: + return None + for attribute in ("tStop", "endTime", "stopTime"): + raw_value = simulation_element.get(attribute) + if raw_value is None: + continue + try: + value = float(raw_value) + except ValueError: + continue + if math.isfinite(value): + return value + return None + + +def _objective_endpoint_trend( + result: Mapping[str, object], + objective: ObjectiveSpec, + *, + expected_end: float | None = None, +) -> dict[str, object]: + """Describe recent movement for a final-value objective without claiming steady state.""" + base: dict[str, object] = { + "diagnosticVersion": TERMINAL_TREND_DIAGNOSTIC_VERSION, + "applicable": objective.statistic.kind == "final", + "steadyStateProven": False, + "heuristic": { + "preferredTailFraction": TERMINAL_TREND_FRACTION, + "maximumTailFraction": TERMINAL_TREND_MAX_FRACTION, + "minimumSamples": TERMINAL_TREND_MIN_SAMPLES, + "relativeChangeThreshold": ( + TERMINAL_TREND_RELATIVE_CHANGE_THRESHOLD + ), + "directionalConsistencyThreshold": ( + TERMINAL_TREND_DIRECTIONAL_CONSISTENCY_THRESHOLD + ), + "relativeRangeThreshold": TERMINAL_TREND_RANGE_THRESHOLD, + }, + } + if objective.statistic.kind != "final": + return {**base, "status": "notApplicable"} + if result.get("status") != "completed" or result.get("success") is not True: + return { + **base, + "status": "unavailable", + "reason": "freshVerificationNotCompleted", + } + raw_series = result.get("series") + if not isinstance(raw_series, Mapping): + return { + **base, + "status": "unavailable", + "reason": "freshVerificationSeriesMissing", + } + raw_times = raw_series.get("time") + raw_values = raw_series.get(objective.result_key) + if not isinstance(raw_times, list) or not isinstance(raw_values, list): + return { + **base, + "status": "unavailable", + "reason": "freshVerificationSeriesMissing", + } + try: + times, values = _windowed_series( + raw_times, + raw_values, + objective.statistic.window, + ) + except OptimizationError as exc: + return { + **base, + "status": "unavailable", + "reason": "freshVerificationSeriesInvalid", + "diagnosticErrorCode": exc.code, + } + required_end = ( + objective.statistic.window.end + if objective.statistic.window is not None + else expected_end + ) + if required_end is None: + return { + **base, + "status": "unavailable", + "reason": "plannedEndpointUnavailable", + } + end_tolerance = max(1e-12, abs(required_end) * 1e-12) + if not math.isclose( + times[-1], required_end, rel_tol=1e-12, abs_tol=end_tolerance + ): + return { + **base, + "status": "unavailable", + "reason": "plannedEndpointNotCovered", + "expectedEnd": required_end, + "actualSeriesEnd": times[-1], + } + if len(times) < TERMINAL_TREND_MIN_SAMPLES: + return { + **base, + "status": "insufficientData", + "availableSamples": len(times), + } + duration = times[-1] - times[0] + if duration <= 0.0 or not math.isfinite(duration): + return { + **base, + "status": "insufficientData", + "availableSamples": len(times), + } + preferred_start = times[-1] - TERMINAL_TREND_FRACTION * duration + start_index = bisect.bisect_left(times, preferred_start) + start_index = min(start_index, len(times) - TERMINAL_TREND_MIN_SAMPLES) + tail_duration = times[-1] - times[start_index] + if tail_duration > TERMINAL_TREND_MAX_FRACTION * duration: + return { + **base, + "status": "insufficientData", + "availableSamples": len(times), + } + tail_values = values[start_index:] + increments = [ + right - left for left, right in zip(tail_values, tail_values[1:]) + ] + positive = sum(item > 0.0 for item in increments) + negative = sum(item < 0.0 for item in increments) + nonzero_increments = positive + negative + directional_consistency = ( + max(positive, negative) / nonzero_increments + if nonzero_increments + else 0.0 + ) + delta = tail_values[-1] - tail_values[0] + maximum_absolute_full = max(abs(item) for item in values) + scale = max( + max(abs(item) for item in tail_values), + 1e-6 * maximum_absolute_full, + sys.float_info.min, + ) + relative_change = delta / scale + relative_range = (max(tail_values) - min(tail_values)) / scale + average_slope = delta / tail_duration + if any( + not math.isfinite(item) + for item in (delta, scale, relative_change, relative_range, average_slope) + ): + return { + **base, + "status": "unavailable", + "reason": "terminalTrendNumericOverflow", + } + directional_change_detected = ( + abs(relative_change) >= TERMINAL_TREND_RELATIVE_CHANGE_THRESHOLD + and directional_consistency + >= TERMINAL_TREND_DIRECTIONAL_CONSISTENCY_THRESHOLD + ) + tail_variability_detected = relative_range >= TERMINAL_TREND_RANGE_THRESHOLD + material_change = directional_change_detected or tail_variability_detected + direction = "flat" + if material_change and not directional_change_detected: + direction = "fluctuating" + elif delta > 0.0: + direction = "increasing" + elif delta < 0.0: + direction = "decreasing" + detection_reasons: list[str] = [] + if directional_change_detected: + detection_reasons.append("directionalChange") + if tail_variability_detected: + detection_reasons.append("tailVariability") + return { + **base, + "status": ( + "materialChangeDetected" + if material_change + else "noMaterialChangeDetected" + ), + "tailStart": times[start_index], + "tailEnd": times[-1], + "samplesUsed": len(tail_values), + "startValue": tail_values[0], + "endValue": tail_values[-1], + "change": delta, + "averageSlopePerSecond": average_slope, + "relativeChange": relative_change, + "absoluteRelativeChange": abs(relative_change), + "relativeRange": relative_range, + "direction": direction, + "directionalConsistency": directional_consistency, + "nonzeroIncrementCount": nonzero_increments, + "detectionReasons": detection_reasons, + "directionalChangeDetected": directional_change_detected, + "tailVariabilityDetected": tail_variability_detected, + "materialChangeDetected": material_change, + } + + +def _completed_metrics( + result: Mapping[str, object], + spec: OptimizationSpec, +) -> tuple[float, float, list[dict[str, object]], bool, float]: + status = str(result.get("status") or "") + if status != "completed" or result.get("success") is not True: + raise OptimizationError( + "OPTIMIZATION_SIMULATION_INCOMPLETE", + "Only a complete successful simulation can be scored.", + {"status": status, "success": result.get("success")}, + ) + raw_variables = result.get("variables") + if not isinstance(raw_variables, list): + raise OptimizationError( + "OPTIMIZATION_RESULT_METADATA_INVALID", + "A completed simulation does not contain result-variable metadata.", + ) + variables: dict[str, dict[str, object]] = {} + for item in raw_variables: + if not isinstance(item, dict) or not isinstance(item.get("key"), str): + raise OptimizationError( + "OPTIMIZATION_RESULT_METADATA_INVALID", + "Completed result-variable metadata is outside the backend contract.", + ) + key = str(item["key"]) + if key in variables: + raise OptimizationError( + "OPTIMIZATION_RESULT_METADATA_INVALID", + "Completed result-variable metadata contains a duplicate key.", + {"resultKey": key}, + ) + variables[key] = item + requested_metadata: list[tuple[str, str, str]] = [ + ("objective", spec.objective.result_key, spec.objective.expected_unit) + ] + requested_metadata.extend( + ( + f"constraint {constraint.id}", + constraint.result_key, + constraint.expected_unit, + ) + for constraint in spec.constraints + ) + for label, result_key, expected_unit in requested_metadata: + metadata = variables.get(result_key) + if metadata is None: + raise OptimizationError( + "OPTIMIZATION_RESULT_METADATA_MISMATCH", + f"The completed result does not declare the {label} result key.", + {"resultKey": result_key}, + ) + actual_unit = metadata.get("unit") + if not isinstance(actual_unit, str) or actual_unit != expected_unit: + raise OptimizationError( + "OPTIMIZATION_RESULT_METADATA_MISMATCH", + f"The completed result changed the {label} unit after planning.", + { + "resultKey": result_key, + "expected": expected_unit, + "received": actual_unit, + }, + ) + series = result.get("series") + if not isinstance(series, Mapping): + raise OptimizationError( + "OPTIMIZATION_RESULT_SERIES_INVALID", + "Simulation result does not contain a series object.", + ) + times = series.get("time") + objective_values = series.get(spec.objective.result_key) + if not isinstance(times, list) or not isinstance(objective_values, list): + raise OptimizationError( + "OPTIMIZATION_RESULT_VARIABLE_MISSING", + "The completed result is missing time or the objective series.", + {"resultKey": spec.objective.result_key}, + ) + objective_value = statistic_value( + times, objective_values, spec.objective.statistic + ) + objective_loss = _loss(objective_value, spec.objective.goal) + if not math.isfinite(objective_loss): + raise OptimizationError( + "OPTIMIZATION_METRIC_OVERFLOW", + "The objective loss overflowed the finite numeric contract.", + ) + + constraint_results: list[dict[str, object]] = [] + total_violation = 0.0 + feasible = True + for constraint in spec.constraints: + raw_values = series.get(constraint.result_key) + if not isinstance(raw_values, list): + raise OptimizationError( + "OPTIMIZATION_RESULT_VARIABLE_MISSING", + "The completed result is missing a constraint series.", + {"constraint": constraint.id, "resultKey": constraint.result_key}, + ) + value = statistic_value(times, raw_values, constraint.statistic) + lower_violation = ( + max(0.0, constraint.lower - constraint.tolerance - value) + if constraint.lower is not None + else 0.0 + ) + upper_violation = ( + max(0.0, value - constraint.upper - constraint.tolerance) + if constraint.upper is not None + else 0.0 + ) + raw_violation = max(lower_violation, upper_violation) + normalized_violation = raw_violation / constraint.scale + lower_margin = ( + value - constraint.lower if constraint.lower is not None else None + ) + upper_margin = ( + constraint.upper - value if constraint.upper is not None else None + ) + finite_margins = [ + margin for margin in (lower_margin, upper_margin) if margin is not None + ] + margin = min(finite_margins) if finite_margins else None + derived_values = [ + lower_violation, + upper_violation, + raw_violation, + normalized_violation, + *finite_margins, + total_violation + normalized_violation, + ] + if any(not math.isfinite(item) for item in derived_values): + raise OptimizationError( + "OPTIMIZATION_METRIC_OVERFLOW", + "A response-constraint calculation overflowed the finite numeric contract.", + {"constraint": constraint.id}, + ) + item_feasible = raw_violation == 0.0 + feasible = feasible and item_feasible + total_violation += normalized_violation + constraint_results.append( + { + "id": constraint.id, + "resultKey": constraint.result_key, + "value": value, + "unit": _metric_unit( + constraint.expected_unit, constraint.statistic.kind + ), + "seriesUnit": constraint.expected_unit, + "lower": constraint.lower, + "upper": constraint.upper, + "tolerance": constraint.tolerance, + "margin": margin, + "violation": raw_violation, + "normalizedViolation": normalized_violation, + "feasible": item_feasible, + } + ) + return ( + objective_value, + objective_loss, + constraint_results, + feasible, + total_violation, + ) + + +def _format_float(value: float) -> str: + normalized = 0.0 if value == 0.0 else value + return format(normalized, ".17g") + + +def _format_report_float(value: float) -> str: + normalized = 0.0 if value == 0.0 else value + return format(normalized, ".12g") + + +def _reflect_unit_interval(value: float) -> float: + if not math.isfinite(value): + raise AssertionError("Optimizer generated a non-finite coordinate.") + wrapped = value % 2.0 + reflected = wrapped if wrapped <= 1.0 else 2.0 - wrapped + return 0.0 if reflected == 0.0 else reflected + + +def _markdown_text(value: object) -> str: + return ( + str(value) + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("|", "|") + .replace("`", "`") + .replace("[", "[") + .replace("]", "]") + .replace("(", "(") + .replace(")", ")") + .replace("!", "!") + .replace("\r", " ") + .replace("\n", " ") + ) + + +def _candidate_parameters( + plan: RuntimePlan, + normalized_vector: Sequence[float], +) -> dict[str, float]: + if len(normalized_vector) != len(plan.resolved_design_variables): + raise AssertionError("Candidate vector dimension does not match the plan.") + values: dict[str, float] = {} + for coordinate, resolved in zip( + normalized_vector, plan.resolved_design_variables + ): + if not math.isfinite(float(coordinate)): + raise AssertionError("Optimizer generated a non-finite coordinate.") + clipped = min(1.0, max(0.0, float(coordinate))) + spec = resolved.spec + value = spec.lower + clipped * (spec.upper - spec.lower) + if not math.isfinite(value): + raise OptimizationError( + "OPTIMIZATION_CANDIDATE_OVERFLOW", + "A normalized candidate did not map to a finite SI parameter value.", + {"id": spec.id}, + ) + values[spec.id] = 0.0 if value == 0.0 else value + return values + + +def _normalized_initial(plan: RuntimePlan) -> list[float]: + normalized: list[float] = [] + for resolved in plan.resolved_design_variables: + coordinate = (resolved.initial - resolved.spec.lower) / ( + resolved.spec.upper - resolved.spec.lower + ) + if not math.isfinite(coordinate): + raise OptimizationError( + "OPTIMIZATION_CANDIDATE_OVERFLOW", + "The baseline parameter could not be normalized to a finite value.", + {"id": resolved.spec.id}, + ) + normalized.append(coordinate) + return normalized + + +def _candidate_xml(plan: RuntimePlan, parameters: Mapping[str, float]) -> bytes: + try: + root = ET.fromstring(plan.baseline_xml) + except ET.ParseError as exc: # pragma: no cover - backend contract guard + raise simulation.BackendError( + "BACKEND_BASELINE_XML_INVALID", + "Backend-generated baseline System XML is not parseable.", + ) from exc + components_parent = root.find("./Components") + if components_parent is None: + raise simulation.BackendError( + "BACKEND_BASELINE_XML_INVALID", + "Backend-generated System XML has no Components element.", + ) + components = { + component.get("id"): component + for component in components_parent.findall("./Component") + if component.get("id") is not None + } + for resolved in plan.resolved_design_variables: + component = components.get(resolved.spec.component_id) + if component is None: + raise simulation.BackendError( + "BACKEND_BASELINE_XML_INVALID", + "An optimized component is missing from baseline System XML.", + {"componentId": resolved.spec.component_id}, + ) + parameter = next( + ( + item + for item in component.findall("./Parameter") + if item.get("name") == resolved.spec.parameter + ), + None, + ) + if parameter is None: + raise simulation.BackendError( + "BACKEND_BASELINE_XML_INVALID", + "An optimized parameter is missing from baseline System XML.", + { + "componentId": resolved.spec.component_id, + "parameter": resolved.spec.parameter, + }, + ) + parameter.set("value", _format_float(parameters[resolved.spec.id])) + return ET.tostring(root, encoding="utf-8", xml_declaration=True) + + +def _optimized_project( + plan: RuntimePlan, + parameters: Mapping[str, float], +) -> dict[str, object]: + project = copy.deepcopy(plan.source.parsed) + if not isinstance(project, dict): # pragma: no cover - load_source guarantees this + raise AssertionError("ReactFlow project root is not an object.") + nodes = project.get("nodes") + if not isinstance(nodes, list): + raise simulation.InputError( + "PROJECT_NODES_INVALID", + "The source project no longer contains a nodes array.", + ) + nodes_by_id = { + node.get("id"): node + for node in nodes + if isinstance(node, dict) and isinstance(node.get("id"), str) + } + for resolved in plan.resolved_design_variables: + node = nodes_by_id.get(resolved.spec.component_id) + if not isinstance(node, dict): + raise simulation.InputError( + "OPTIMIZATION_PARAMETER_UNKNOWN", + "An optimized component is missing from the project copy.", + ) + data = node.get("data") + if not isinstance(data, dict): + raise simulation.InputError( + "OPTIMIZATION_PARAMETER_UNKNOWN", + "An optimized component has no data object.", + ) + raw_parameters = data.get("parameters") + if raw_parameters is None: + raw_parameters = {} + data["parameters"] = raw_parameters + if not isinstance(raw_parameters, dict): + raise simulation.InputError( + "OPTIMIZATION_PARAMETER_UNKNOWN", + "An optimized component has no parameter object.", + ) + raw_parameters[resolved.spec.parameter] = parameters[resolved.spec.id] + return project + + +def _candidate_cache_key(parameters: Mapping[str, float], order: Sequence[str]) -> bytes: + return json.dumps( + [_format_float(parameters[item]) for item in order], + separators=(",", ":"), + ).encode("ascii") + + +def _trial_rank(trial: Trial) -> tuple[float, ...]: + if trial.status != "completed" or trial.objective_loss is None: + return (2.0, math.inf, math.inf) + if trial.feasible: + return (0.0, trial.objective_loss, float(trial.evaluation_id)) + violation = ( + trial.total_constraint_violation + if trial.total_constraint_violation is not None + else math.inf + ) + return (1.0, violation, trial.objective_loss, float(trial.evaluation_id)) + + +def _is_close(first: float, second: float, validation: ValidationSpec) -> bool: + return math.isclose( + first, + second, + rel_tol=validation.relative_tolerance, + abs_tol=validation.absolute_tolerance, + ) + + +def _atomic_write(path: Path, data: bytes) -> None: + temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp") + try: + with temporary.open("xb") as stream: + stream.write(data) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + except OSError as exc: + try: + temporary.unlink(missing_ok=True) + except OSError: + pass + raise simulation.ArtifactError( + "OPTIMIZATION_ARTIFACT_WRITE_FAILED", + str(exc), + {"path": str(path)}, + ) from exc + + +def _atomic_json(path: Path, payload: object) -> None: + data = ( + json.dumps(payload, ensure_ascii=False, allow_nan=False, indent=2) + "\n" + ).encode("utf-8") + _atomic_write(path, data) + + +def _artifact(path: Path) -> dict[str, object]: + data = path.read_bytes() + return { + "path": str(path), + "sha256": _sha256(data), + "sizeBytes": len(data), + } + + +class OptimizationRunner: + def __init__(self, plan: RuntimePlan, optimization_id: str) -> None: + self.plan = plan + self.optimization_id = optimization_id + self.output_directory = plan.output_directory + self.progress_directory = self.output_directory / "simulation-progress" + self.events_path = self.output_directory / "optimization-events.jsonl" + self.trials: list[Trial] = [] + self.cache: dict[bytes, Trial] = {} + self.optimizer_calls = 0 + self.search_proposals = 0 + self.cache_hits = 0 + self.backend_submissions = 0 + self.search_backend_submissions = 0 + self.verification_backend_submissions = 0 + self.completed_generations = 0 + self.generations_with_new_backend_submissions = 0 + self.generations_without_new_backend_submissions = 0 + self.ending_no_new_submission_generation_streak = 0 + self.max_no_new_submission_generation_streak = 0 + self.final_population_unique_candidates: int | None = None + self.optimizer_call_limit: int | None = None + self.current_simulation_id: str | None = None + self.started_monotonic = time.monotonic() + self.started_unix = time.time() + self.search_run_limit = plan.spec.budget.max_simulation_runs - 1 + self._events: io.TextIOWrapper | None = None + + @property + def parameter_order(self) -> list[str]: + return [item.spec.id for item in self.plan.resolved_design_variables] + + def _counts_payload(self) -> dict[str, object]: + search_trials = [trial for trial in self.trials if trial.stage == "search"] + verification_trials = [ + trial for trial in self.trials if trial.stage == "verification" + ] + return { + "optimizerCalls": self.optimizer_calls, + "candidateRequestsAllStages": self.optimizer_calls, + "searchProposals": self.search_proposals, + "backendSubmissions": self.backend_submissions, + "backendSubmissionSlotsConsumed": self.backend_submissions, + "searchBackendSubmissions": self.search_backend_submissions, + "searchSubmissionSlotsConsumed": ( + self.search_backend_submissions + ), + "verificationBackendSubmissions": ( + self.verification_backend_submissions + ), + "verificationSubmissionSlotsConsumed": ( + self.verification_backend_submissions + ), + "cacheHits": self.cache_hits, + "generations": self.completed_generations, + "generationsWithNewBackendSubmissions": ( + self.generations_with_new_backend_submissions + ), + "generationsWithoutNewBackendSubmissions": ( + self.generations_without_new_backend_submissions + ), + "endingNoNewSubmissionGenerationStreak": ( + self.ending_no_new_submission_generation_streak + ), + "maxNoNewSubmissionGenerationStreak": ( + self.max_no_new_submission_generation_streak + ), + "finalPopulationUniqueCandidates": ( + self.final_population_unique_candidates + ), + "remainingSearchRunBudget": max( + 0, self.search_run_limit - self.search_backend_submissions + ), + "completedTrials": sum( + trial.status == "completed" for trial in self.trials + ), + "failedTrials": sum( + trial.status != "completed" for trial in self.trials + ), + "searchCompletedTrials": sum( + trial.status == "completed" for trial in search_trials + ), + "searchFailedTrials": sum( + trial.status != "completed" for trial in search_trials + ), + "verificationCompletedTrials": sum( + trial.status == "completed" for trial in verification_trials + ), + "verificationFailedTrials": sum( + trial.status != "completed" for trial in verification_trials + ), + "recordedTrials": len(self.trials), + "unrecordedSubmissionSlotsConsumed": max( + 0, self.backend_submissions - len(self.trials) + ), + "searchUnrecordedSubmissionSlotsConsumed": max( + 0, self.search_backend_submissions - len(search_trials) + ), + "verificationUnrecordedSubmissionSlotsConsumed": max( + 0, + self.verification_backend_submissions + - len(verification_trials), + ), + } + + def _search_summary(self, termination_reason: str) -> dict[str, object]: + categories = { + "simulationBudgetExhausted": "budget", + "searchWallTimeReached": "wallTime", + "populationCollapsedAfterDuplicateStagnation": "stagnation", + "duplicateProposalStagnation": "stagnation", + "optimizerCallLimitReached": "safeguard", + "userCancelled": "cancelled", + "error": "error", + } + return { + "terminationReason": termination_reason, + "terminationCategory": categories.get(termination_reason, "other"), + "searchConvergenceEstablished": False, + "populationCollapsedToSingleCandidate": ( + termination_reason + == "populationCollapsedAfterDuplicateStagnation" + ), + "candidateRequests": self.search_proposals, + "backendSubmissions": self.search_backend_submissions, + "submissionSlotsConsumed": self.search_backend_submissions, + "submissionCounting": "reservedBeforeProgressLogAndBackendStream", + "cacheHits": self.cache_hits, + "cacheHitRate": ( + self.cache_hits / self.search_proposals + if self.search_proposals + else 0.0 + ), + "budget": { + "limit": self.search_run_limit, + "used": self.search_backend_submissions, + "unused": max( + 0, self.search_run_limit - self.search_backend_submissions + ), + "exhausted": ( + self.search_backend_submissions >= self.search_run_limit + ), + }, + "generations": { + "completed": self.completed_generations, + "withNewBackendSubmissions": ( + self.generations_with_new_backend_submissions + ), + "withoutNewBackendSubmissions": ( + self.generations_without_new_backend_submissions + ), + "endingNoNewSubmissionStreak": ( + self.ending_no_new_submission_generation_streak + ), + "maximumNoNewSubmissionStreak": ( + self.max_no_new_submission_generation_streak + ), + }, + "finalPopulationUniqueCandidates": ( + self.final_population_unique_candidates + ), + "noNewSubmissionGenerationLimit": ( + NO_NEW_SUBMISSION_GENERATION_LIMIT + ), + "optimizerCallLimit": self.optimizer_call_limit, + } + + def _timing_payload(self) -> dict[str, object]: + durations = [trial.duration_seconds for trial in self.trials] + return { + "wallClockSeconds": max(0.0, time.monotonic() - self.started_monotonic), + "simulationRuns": len(durations), + "simulationDurationTotalSeconds": ( + math.fsum(durations) if durations else 0.0 + ), + "simulationDurationMinimumSeconds": ( + min(durations) if durations else None + ), + "simulationDurationMaximumSeconds": ( + max(durations) if durations else None + ), + "simulationDurationMeanSeconds": ( + math.fsum(durations) / len(durations) if durations else None + ), + } + + def _emit(self, payload: Mapping[str, object]) -> None: + event = dict(payload) + if self._events is not None: + serialized = json.dumps( + event, ensure_ascii=False, allow_nan=False, separators=(",", ":") + ) + self._events.write(serialized + "\n") + self._events.flush() + simulation.emit_json(event) + + def _write_evaluations(self) -> None: + columns = [ + "evaluationId", + "stage", + "status", + "feasible", + "objectiveValue", + "objectiveLoss", + "totalConstraintViolation", + "simulationId", + "durationSeconds", + "failureCode", + "cacheReuseCount", + ] + columns.extend(f"parameter.{item}" for item in self.parameter_order) + for constraint in self.plan.spec.constraints: + columns.extend( + ( + f"constraint.{constraint.id}.value", + f"constraint.{constraint.id}.margin", + f"constraint.{constraint.id}.violation", + ) + ) + stream = io.StringIO(newline="") + writer = csv.DictWriter(stream, fieldnames=columns, lineterminator="\n") + writer.writeheader() + for trial in self.trials: + row: dict[str, object] = { + "evaluationId": trial.evaluation_id, + "stage": trial.stage, + "status": trial.status, + "feasible": str(trial.feasible).lower(), + "objectiveValue": ( + _format_float(trial.objective_value) + if trial.objective_value is not None + else "" + ), + "objectiveLoss": ( + _format_float(trial.objective_loss) + if trial.objective_loss is not None + else "" + ), + "totalConstraintViolation": ( + _format_float(trial.total_constraint_violation) + if trial.total_constraint_violation is not None + else "" + ), + "simulationId": trial.simulation_id, + "durationSeconds": _format_float(trial.duration_seconds), + "failureCode": trial.failure_code or "", + "cacheReuseCount": trial.cache_reuse_count, + } + for key, value in trial.parameters.items(): + row[f"parameter.{key}"] = _format_float(value) + constraints = { + str(item.get("id")): item for item in trial.constraints + } + for constraint in self.plan.spec.constraints: + item = constraints.get(constraint.id, {}) + for name in ("value", "margin", "violation"): + raw_value = item.get(name) + row[f"constraint.{constraint.id}.{name}"] = ( + _format_float(float(raw_value)) + if isinstance(raw_value, (int, float)) + and not isinstance(raw_value, bool) + else "" + ) + writer.writerow(row) + _atomic_write( + self.output_directory / "evaluations.csv", + stream.getvalue().encode("utf-8"), + ) + + def _best_trial(self, *, feasible_only: bool) -> Trial | None: + candidates = [ + trial + for trial in self.trials + if trial.stage == "search" + and trial.status == "completed" + and trial.objective_loss is not None + and (trial.feasible or not feasible_only) + ] + return min(candidates, key=_trial_rank) if candidates else None + + def _checkpoint(self, termination_reason: str | None = None) -> None: + best = self._best_trial(feasible_only=True) + payload: dict[str, object] = { + "optimizationResultSchemaVersion": OPTIMIZATION_RESULT_SCHEMA_VERSION, + "optimizationId": self.optimization_id, + "sourceSha256": self.plan.source.sha256, + "specSha256": self.plan.spec_source.sha256, + "counts": self._counts_payload(), + "bestFeasibleSearch": best.as_dict() if best is not None else None, + "trials": [trial.as_dict() for trial in self.trials], + } + if termination_reason is not None: + payload["terminationReason"] = termination_reason + _atomic_json(self.output_directory / "checkpoint.json", payload) + self._write_evaluations() + + def _inner_event_sink(self, evaluation_id: int, simulation_id: str): + def sink(payload: object) -> None: + if not isinstance(payload, Mapping): + return + if payload.get("event") != "progress": + return + self._emit( + { + "event": "optimization-simulation-progress", + "optimizationId": self.optimization_id, + "evaluationId": evaluation_id, + "simulationId": simulation_id, + "searchRunLimit": self.search_run_limit, + "simulation": dict(payload), + } + ) + + return sink + + def _evaluate( + self, + normalized_vector: Sequence[float], + *, + stage: str, + bypass_cache: bool = False, + retain_result: bool = False, + ) -> tuple[Trial, dict[str, object] | None]: + elapsed = time.monotonic() - self.started_monotonic + if stage == "search": + if self.search_backend_submissions >= self.search_run_limit: + raise SearchStop("simulationBudgetExhausted") + if elapsed >= self.plan.spec.budget.max_wall_seconds: + raise SearchStop("searchWallTimeReached") + elif self.backend_submissions >= self.plan.spec.budget.max_simulation_runs: + raise SearchStop("simulationBudgetExhausted") + self.optimizer_calls += 1 + if stage == "search": + self.search_proposals += 1 + parameters = _candidate_parameters(self.plan, normalized_vector) + cache_key = _candidate_cache_key(parameters, self.parameter_order) + if not bypass_cache and cache_key in self.cache: + cached = self.cache[cache_key] + cached.cache_reuse_count += 1 + self.cache_hits += 1 + self._checkpoint() + self._emit( + { + "event": "optimization-cache-hit", + "optimizationId": self.optimization_id, + "evaluationId": cached.evaluation_id, + "parameters": parameters, + } + ) + return cached, None + + evaluation_id = self.backend_submissions + 1 + simulation_id = f"{self.optimization_id}.{evaluation_id:04d}" + xml = _candidate_xml(self.plan, parameters) + progress_path = self.progress_directory / f"evaluation-{evaluation_id:04d}.jsonl" + started = time.monotonic() + self.backend_submissions += 1 + if stage == "search": + self.search_backend_submissions += 1 + else: + self.verification_backend_submissions += 1 + self._emit( + { + "event": "optimization-evaluation-started", + "optimizationId": self.optimization_id, + "evaluationId": evaluation_id, + "stage": stage, + "simulationId": simulation_id, + "parameters": parameters, + } + ) + result: dict[str, object] | None = None + backend_error: dict[str, object] | None = None + self.current_simulation_id = simulation_id + try: + result, backend_error = simulation._read_simulation_stream( + self.plan.base_url, + xml, + simulation_id, + self.plan.timeout, + progress_path, + event_sink=self._inner_event_sink(evaluation_id, simulation_id), + full_result=None, + ) + except BaseException: + raise + else: + self.current_simulation_id = None + duration = time.monotonic() - started + + contract_error: OptimizationError | None = None + if ( + result is not None + and str(result.get("status") or "") == "completed" + and result.get("success") is True + ): + try: + ( + objective_value, + objective_loss, + constraints, + feasible, + total_violation, + ) = _completed_metrics(result, self.plan.spec) + except OptimizationError as exc: + contract_error = exc + trial = Trial( + evaluation_id=evaluation_id, + stage=stage, + simulation_id=simulation_id, + parameters=parameters, + status="failed", + duration_seconds=duration, + failure_code=exc.code, + failure_message=exc.message, + ) + else: + trial = Trial( + evaluation_id=evaluation_id, + stage=stage, + simulation_id=simulation_id, + parameters=parameters, + status="completed", + duration_seconds=duration, + objective_value=objective_value, + objective_loss=objective_loss, + constraints=constraints, + feasible=feasible, + total_constraint_violation=total_violation, + ) + else: + result_status = ( + str(result.get("status") or "failed") + if isinstance(result, Mapping) + else "failed" + ) + if result_status == "completed": + result_status = "failed" + failure_message = ( + str(result.get("message") or "Simulation did not complete successfully.") + if isinstance(result, Mapping) + else "Simulation ended without a usable result." + ) + trial = Trial( + evaluation_id=evaluation_id, + stage=stage, + simulation_id=simulation_id, + parameters=parameters, + status=result_status, + duration_seconds=duration, + failure_code="OPTIMIZATION_SIMULATION_INCOMPLETE", + failure_message=failure_message, + ) + if backend_error is not None: + trial.failure_message = json.dumps( + backend_error, ensure_ascii=False, allow_nan=False + ) + self.trials.append(trial) + if not bypass_cache: + self.cache[cache_key] = trial + self._checkpoint() + self._emit( + { + "event": "optimization-evaluation-completed", + "optimizationId": self.optimization_id, + "evaluation": trial.as_dict(), + "bestFeasibleSearch": ( + self._best_trial(feasible_only=True).as_dict() + if self._best_trial(feasible_only=True) is not None + else None + ), + } + ) + if contract_error is not None: + raise contract_error + return trial, result if retain_result else None + + def _initial_population(self, random_source: random.Random) -> list[list[float]]: + population_size = self.plan.spec.algorithm.population_size + dimension = len(self.plan.resolved_design_variables) + population = [[0.0] * dimension for _ in range(population_size)] + for coordinate in range(dimension): + strata = list(range(population_size)) + random_source.shuffle(strata) + for row, stratum in enumerate(strata): + population[row][coordinate] = ( + stratum + random_source.random() + ) / population_size + population[0] = _normalized_initial(self.plan) + if dimension == 1: + available_rows = iter(range(population_size - 1, 0, -1)) + for endpoint in (0.0, 1.0): + if any(row[0] == endpoint for row in population): + continue + population[next(available_rows)] = [endpoint] + return population + + def _population_unique_candidate_count( + self, population: Sequence[Sequence[float]] + ) -> int: + return len( + { + _candidate_cache_key( + _candidate_parameters(self.plan, vector), + self.parameter_order, + ) + for vector in population + } + ) + + def _search(self) -> tuple[str, int]: + algorithm = self.plan.spec.algorithm + random_source = random.Random(algorithm.seed) + population = self._initial_population(random_source) + trials: list[Trial] = [] + termination_reason = "simulationBudgetExhausted" + maximum_optimizer_calls = max(100, self.search_run_limit * 20) + self.optimizer_call_limit = maximum_optimizer_calls + try: + for index, vector in enumerate(population): + trial, _ = self._evaluate(vector, stage="search") + if index == 0 and trial.status != "completed": + raise OptimizationError( + "OPTIMIZATION_BASELINE_FAILED", + "The baseline model must complete successfully before search starts.", + trial.as_dict(), + ) + trials.append(trial) + + self.final_population_unique_candidates = ( + self._population_unique_candidate_count(population) + ) + while ( + self.search_backend_submissions < self.search_run_limit + and self.search_proposals < maximum_optimizer_calls + ): + submissions_before = self.search_backend_submissions + cache_hits_before = self.cache_hits + source_population = [list(vector) for vector in population] + source_trials = list(trials) + next_population = [list(vector) for vector in population] + next_trials = list(trials) + for target_index in range(len(population)): + if self.search_proposals >= maximum_optimizer_calls: + raise SearchStop("optimizerCallLimitReached") + available = [ + index + for index in range(len(source_population)) + if index != target_index + ] + first_index, second_index, third_index = random_source.sample( + available, 3 + ) + mutant = [ + _reflect_unit_interval( + source_population[first_index][coordinate] + + algorithm.mutation_factor + * ( + source_population[second_index][coordinate] + - source_population[third_index][coordinate] + ) + ) + for coordinate in range(len(source_population[target_index])) + ] + mandatory_coordinate = random_source.randrange(len(mutant)) + candidate = [ + ( + mutant[coordinate] + if coordinate == mandatory_coordinate + or random_source.random() + <= algorithm.crossover_probability + else source_population[target_index][coordinate] + ) + for coordinate in range(len(mutant)) + ] + candidate_trial, _ = self._evaluate( + candidate, stage="search" + ) + if _trial_rank(candidate_trial) < _trial_rank( + source_trials[target_index] + ): + next_population[target_index] = candidate + next_trials[target_index] = candidate_trial + population = next_population + trials = next_trials + self.completed_generations += 1 + new_submissions = ( + self.search_backend_submissions - submissions_before + ) + new_cache_hits = self.cache_hits - cache_hits_before + if new_submissions: + self.generations_with_new_backend_submissions += 1 + self.ending_no_new_submission_generation_streak = 0 + else: + self.generations_without_new_backend_submissions += 1 + self.ending_no_new_submission_generation_streak += 1 + self.max_no_new_submission_generation_streak = max( + self.max_no_new_submission_generation_streak, + self.ending_no_new_submission_generation_streak, + ) + self.final_population_unique_candidates = ( + self._population_unique_candidate_count(population) + ) + self._emit( + { + "event": "optimization-generation-completed", + "optimizationId": self.optimization_id, + "generation": self.completed_generations, + "backendSubmissions": self.backend_submissions, + "searchRunLimit": self.search_run_limit, + "backendSubmissionsThisGeneration": new_submissions, + "cacheHitsThisGeneration": new_cache_hits, + "consecutiveGenerationsWithoutNewBackendSubmissions": ( + self.ending_no_new_submission_generation_streak + ), + "populationUniqueCandidates": ( + self.final_population_unique_candidates + ), + } + ) + self._checkpoint() + if ( + self.ending_no_new_submission_generation_streak + >= NO_NEW_SUBMISSION_GENERATION_LIMIT + ): + termination_reason = ( + "populationCollapsedAfterDuplicateStagnation" + if self.final_population_unique_candidates == 1 + else "duplicateProposalStagnation" + ) + break + if ( + termination_reason == "simulationBudgetExhausted" + and self.search_proposals >= maximum_optimizer_calls + and self.search_backend_submissions < self.search_run_limit + ): + termination_reason = "optimizerCallLimitReached" + except SearchStop as stop: + termination_reason = stop.reason + self.final_population_unique_candidates = ( + self._population_unique_candidate_count(population) + ) + return termination_reason, self.completed_generations + + def cancel_active(self) -> None: + if self.current_simulation_id is None: + return + simulation_id = self.current_simulation_id + try: + simulation.http_json( + self.plan.base_url, + "/api/system-xml/simulations/" + + simulation.urllib.parse.quote(simulation_id, safe="") + + "/cancel", + method="POST", + payload={"reason": "user"}, + timeout=self.plan.timeout, + ) + except simulation.SkillCliError: + pass + finally: + self.current_simulation_id = None + + def _source_is_unchanged(self) -> bool: + try: + return _sha256(self.plan.source.path.read_bytes()) == self.plan.source.sha256 + except OSError: + return False + + def _verification_comparison( + self, + search: Trial, + verification: Trial, + ) -> dict[str, object]: + validation = self.plan.spec.validation + comparisons: list[dict[str, object]] = [] + objective_matches = ( + search.objective_value is not None + and verification.objective_value is not None + and _is_close( + search.objective_value, + verification.objective_value, + validation, + ) + ) + comparisons.append( + { + "id": "objective", + "search": search.objective_value, + "verification": verification.objective_value, + "matches": objective_matches, + } + ) + search_constraints = { + str(item.get("id")): item for item in search.constraints + } + verification_constraints = { + str(item.get("id")): item for item in verification.constraints + } + for constraint in self.plan.spec.constraints: + first = search_constraints.get(constraint.id, {}).get("value") + second = verification_constraints.get(constraint.id, {}).get("value") + matches = ( + isinstance(first, (int, float)) + and not isinstance(first, bool) + and isinstance(second, (int, float)) + and not isinstance(second, bool) + and _is_close(float(first), float(second), validation) + ) + comparisons.append( + { + "id": constraint.id, + "search": first, + "verification": second, + "matches": matches, + } + ) + source_unchanged = self._source_is_unchanged() + return { + "passed": ( + verification.status == "completed" + and verification.feasible + and source_unchanged + and all(item["matches"] is True for item in comparisons) + ), + "sourceUnchanged": source_unchanged, + "relativeTolerance": validation.relative_tolerance, + "absoluteTolerance": validation.absolute_tolerance, + "metrics": comparisons, + } + + def _improvement( + self, baseline: Trial | None, best: Trial | None + ) -> dict[str, object] | None: + if ( + baseline is None + or best is None + or baseline.objective_value is None + or best.objective_value is None + ): + return None + goal = self.plan.spec.objective.goal + baseline_loss = _loss(baseline.objective_value, goal) + best_loss = _loss(best.objective_value, goal) + absolute = baseline_loss - best_loss + if not math.isfinite(absolute): + absolute = None + relative = ( + absolute / abs(baseline_loss) + if absolute is not None and baseline_loss != 0.0 + else None + ) + if relative is not None and not math.isfinite(relative): + relative = None + return { + "baselineValue": baseline.objective_value, + "verifiedValue": best.objective_value, + "baselineLoss": baseline_loss, + "verifiedLoss": best_loss, + "lossReduction": absolute, + "relativeLossReduction": relative, + } + + def _write_best_artifacts( + self, + best: Trial, + result: Mapping[str, object], + ) -> dict[str, object]: + created_paths: list[Path] = [] + try: + with tempfile.TemporaryDirectory( + prefix=".best-artifacts-", + dir=self.output_directory, + ) as staging_text: + staging = Path(staging_text) + best_parameters_path = staging / "best-parameters.json" + simulation._write_new_file( + best_parameters_path, + ( + json.dumps( + { + "sourceSha256": self.plan.source.sha256, + "specSha256": self.plan.spec_source.sha256, + "parameters": [ + { + **resolved.spec.as_dict(), + "value": best.parameters[resolved.spec.id], + } + for resolved in self.plan.resolved_design_variables + ], + }, + ensure_ascii=False, + allow_nan=False, + indent=2, + ) + + "\n" + ).encode("utf-8"), + ) + + best_xml_path = staging / "best-system.xml" + simulation._write_new_file( + best_xml_path, + _candidate_xml(self.plan, best.parameters), + ) + + best_project_path = staging / "best-project.json" + best_project = _optimized_project(self.plan, best.parameters) + simulation._write_new_file( + best_project_path, + ( + json.dumps( + best_project, + ensure_ascii=False, + allow_nan=False, + indent=2, + ) + + "\n" + ).encode("utf-8"), + ) + + result_path = staging / "result.json" + simulation._write_result_json(result_path, result) + variables, series = simulation._validate_result_shape(result) + csv_path = staging / "results.csv" + csv_bytes = simulation._download_csv( + self.plan.base_url, + self.plan.timeout, + simulation._project_name(self.plan.inspection), + variables, + series, + ) + simulation._write_new_file(csv_path, csv_bytes) + + metadata = simulation._variable_metadata_by_key(variables) + chart_keys = list( + dict.fromkeys( + [self.plan.spec.objective.result_key] + + [item.result_key for item in self.plan.spec.constraints] + ) + ) + missing = [ + key + for key in chart_keys + if key not in metadata or key not in series + ] + if missing: + raise OptimizationError( + "OPTIMIZATION_RESULT_VARIABLE_MISSING", + "Verified result is missing a requested report curve.", + {"missing": missing}, + ) + staged_chart_paths = [ + Path(path) + for path in simulation._write_charts( + staging, + "separate", + chart_keys, + metadata, + series, + ) + ] + + staged_artifacts = { + "bestParameters": best_parameters_path, + "bestSystemXml": best_xml_path, + "bestProject": best_project_path, + "result": result_path, + "csv": csv_path, + } + final_artifacts: dict[str, object] = {} + for name, staged_path in staged_artifacts.items(): + final_path = self.output_directory / staged_path.name + simulation._write_new_file(final_path, staged_path.read_bytes()) + created_paths.append(final_path) + final_artifacts[name] = _artifact(final_path) + + final_chart_paths: list[Path] = [] + for staged_path in staged_chart_paths: + final_path = self.output_directory / staged_path.name + simulation._write_new_file(final_path, staged_path.read_bytes()) + created_paths.append(final_path) + final_chart_paths.append(final_path) + final_artifacts["charts"] = [ + _artifact(path) for path in final_chart_paths + ] + return final_artifacts + except BaseException as exc: + cleanup_failures: list[dict[str, str]] = [] + for path in reversed(created_paths): + try: + path.unlink(missing_ok=True) + except OSError as cleanup_exc: + cleanup_failures.append( + {"path": str(path), "error": str(cleanup_exc)} + ) + if cleanup_failures: + raise simulation.ArtifactError( + "OPTIMIZATION_ARTIFACT_ROLLBACK_FAILED", + "Failed to remove partially committed best artifacts.", + {"failures": cleanup_failures}, + ) from exc + raise + + def _discard_generated_artifacts( + self, artifacts: Mapping[str, object] + ) -> None: + output_root = self.output_directory.resolve() + paths: list[Path] = [] + for value in artifacts.values(): + items = value if isinstance(value, list) else [value] + for item in items: + if not isinstance(item, Mapping): + continue + raw_path = item.get("path") + if not isinstance(raw_path, str): + continue + path = Path(raw_path).resolve() + if not path.is_relative_to(output_root): + raise simulation.ArtifactError( + "OPTIMIZATION_ARTIFACT_PATH_INVALID", + "A generated artifact resolved outside the optimization output directory.", + {"path": str(path)}, + ) + paths.append(path) + for path in paths: + try: + path.unlink(missing_ok=True) + except OSError as exc: + raise simulation.ArtifactError( + "OPTIMIZATION_ARTIFACT_REMOVE_FAILED", + str(exc), + {"path": str(path)}, + ) from exc + + def _report_markdown(self, result: Mapping[str, object]) -> str: + objective = self.plan.spec.objective + objective_metric_unit = _metric_unit( + objective.expected_unit, objective.statistic.kind + ) + baseline = result.get("baseline") + best_search = result.get("bestSearch") + best_diagnostic_search = result.get("bestDiagnosticSearch") + search_comparison = ( + best_search + if isinstance(best_search, Mapping) + else best_diagnostic_search + ) + best = result.get("verifiedBest") + counts = result.get("counts") + count_values = counts if isinstance(counts, Mapping) else {} + search = result.get("search") + search_values = search if isinstance(search, Mapping) else {} + search_generations = search_values.get("generations") + generation_values = ( + search_generations + if isinstance(search_generations, Mapping) + else {} + ) + timing = result.get("timing") + timing_values = timing if isinstance(timing, Mapping) else {} + goal_text = objective.goal.kind + if objective.goal.value is not None: + goal_text += f" {_format_report_float(objective.goal.value)}" + window_text = ( + "完整返回时段" + if objective.statistic.window is None + else ( + f"[{_format_report_float(objective.statistic.window.start)}, " + f"{_format_report_float(objective.statistic.window.end)}] s" + ) + ) + + def trial_number(trial: object, field_name: str) -> str: + if not isinstance(trial, Mapping): + return "—" + value = trial.get(field_name) + if not isinstance(value, (int, float)) or isinstance(value, bool): + return "—" + return _format_report_float(float(value)) + + def constraint_map(trial: object) -> dict[str, Mapping[str, object]]: + if not isinstance(trial, Mapping): + return {} + raw_constraints = trial.get("constraints") + if not isinstance(raw_constraints, list): + return {} + return { + str(item.get("id")): item + for item in raw_constraints + if isinstance(item, Mapping) + } + + status = str(result.get("solutionStatus") or "") + search_points = count_values.get("searchCompletedTrials") + search_scope = ( + f"本次完整完成的 {search_points} 个搜索仿真" + if isinstance(search_points, int) and not isinstance(search_points, bool) + else "本次完整完成的搜索仿真" + ) + conclusion = { + "verified": ( + f"这是{search_scope}中表现最好的可行候选," + "且已通过一次独立复验;这不证明搜索收敛、系统达到稳态或全局最优。" + ), + "noFeasibleCandidate": ( + "预算内没有找到完整可行候选;所列诊断点不能称为优化方案。" + ), + "verificationFailed": ( + "搜索阶段找到了可行候选,但新鲜复验未通过;不交付已验证方案。" + ), + "aborted": "优化在完成可靠复验前中止;不交付已验证方案。", + }.get(status, "本次运行没有形成可交付的已验证方案。") + full_generations_with_unique_candidates = max( + 0, + ( + self.search_run_limit + - self.plan.spec.algorithm.population_size + ) + // self.plan.spec.algorithm.population_size, + ) + endpoint_seeding_text = ( + "- 单变量边界覆盖:初始种群明确包含上下界" + if len(self.plan.resolved_design_variables) == 1 + else ( + "- 多变量边界覆盖:不枚举全部边界组合;初始种群使用" + "逐坐标拉丁超立方分层" + ) + ) + termination_reason = str(result.get("terminationReason") or "") + termination_descriptions = { + "simulationBudgetExhausted": "搜索仿真预算已用完", + "searchWallTimeReached": "搜索达到墙钟时间上限", + "populationCollapsedAfterDuplicateStagnation": ( + "连续多代只生成已缓存候选,且种群已塌缩到同一个精确候选;" + "这是重复停滞状态,不是收敛判定" + ), + "duplicateProposalStagnation": ( + "连续多代只生成已缓存候选,但种群尚未合一;这是重复候选停滞," + "不是收敛判定" + ), + "optimizerCallLimitReached": ( + "候选请求触发内部防死循环上限;这是安全停止,不是收敛判定" + ), + "userCancelled": "用户取消了搜索", + "error": "搜索因错误中止", + } + termination_description = termination_descriptions.get( + termination_reason, "搜索已停止" + ) + project_name = simulation._project_name(self.plan.inspection) + status_label = { + "verified": "候选已复验", + "noFeasibleCandidate": "未找到可行候选", + "verificationFailed": "候选复验失败", + "aborted": "运行已中止", + }.get(status, "没有已验证方案") + parameter_section_title = ( + "最佳参数" if status == "verified" else "参数范围" + ) + search_comparison_label = ( + "搜索阶段最佳可行点" + if isinstance(best_search, Mapping) + else "约束违反最小的完整诊断点(不可行)" + ) + search_constraint_column = ( + "搜索最佳" + if isinstance(best_search, Mapping) + else "搜索诊断(不可行)" + ) + lines = [ + f"# {_markdown_text(project_name)} 优化结果 — {status_label}", + "", + f"- 结论:{conclusion}", + f"- 目标:`{goal_text}` `{_markdown_text(objective.result_key)}` / `{objective.statistic.kind}`({_markdown_text(objective_metric_unit or 'dimensionless')})", + f"- 目标时间窗:{window_text}", + f"- 候选复验状态:`{result.get('solutionStatus')}`", + "", + f"## {parameter_section_title}", + "", + ( + "- 连续性声明:后端不验证参数是否连续;以下变量由用户在确认" + "完整计划时声明为线性 SI 连续参数,且不会改变端口、拓扑或模式。" + ), + "", + "| ID | 组件参数 | 起点 | 下界 | 上界 | 验证值 | 单位 |", + "| --- | --- | ---: | ---: | ---: | ---: | --- |", + ] + best_parameters = ( + best.get("parameters") if isinstance(best, Mapping) else {} + ) + if not isinstance(best_parameters, Mapping): + best_parameters = {} + boundary_notes: list[str] = [] + for variable in self.plan.resolved_design_variables: + verified = best_parameters.get(variable.spec.id) + lines.append( + "| {id} | `{component}.{parameter}` | {initial} | {lower} | {upper} | {best} | {unit} |".format( + id=variable.spec.id, + component=_markdown_text(variable.spec.component_id), + parameter=_markdown_text(variable.spec.parameter), + initial=_format_report_float(variable.initial), + lower=_format_report_float(variable.spec.lower), + upper=_format_report_float(variable.spec.upper), + best=( + _format_report_float(float(verified)) + if isinstance(verified, (int, float)) + and not isinstance(verified, bool) + else "—" + ), + unit=_markdown_text(variable.spec.unit or "dimensionless"), + ) + ) + if isinstance(verified, (int, float)) and not isinstance(verified, bool): + boundary_name: str | None = None + boundary_tolerance = max( + math.ulp(variable.spec.lower), + math.ulp(variable.spec.upper), + sys.float_info.epsilon + * (variable.spec.upper - variable.spec.lower) + * 4.0, + ) + if abs(float(verified) - variable.spec.lower) <= boundary_tolerance: + boundary_name = "下界" + elif abs(float(verified) - variable.spec.upper) <= boundary_tolerance: + boundary_name = "上界" + if boundary_name is not None: + boundary_notes.append( + "`{}` 位于当前确认范围的{}".format( + _markdown_text(variable.spec.id), boundary_name + ) + ) + if boundary_notes: + lines.extend( + [ + "", + "- 边界说明:{}。这只说明当前范围内得到的是边界候选;" + "在确认更宽范围符合物理、安全和组件合同前,不据此建议放宽边界。".format( + ";".join(boundary_notes) + ), + ] + ) + lines.extend( + [ + "", + "## 目标对比", + "", + "| 项目 | 数值 |", + "| --- | ---: |", + f"| 基线 | {trial_number(baseline, 'objectiveValue')} |", + f"| {search_comparison_label} | {trial_number(search_comparison, 'objectiveValue')} |", + f"| 新鲜复验 | {trial_number(best, 'objectiveValue')} |", + "", + ] + ) + if isinstance(best_diagnostic_search, Mapping): + lines.extend( + [ + "- 该诊断点的归一化约束违反总量:{};它不是可行方案。".format( + trial_number( + best_diagnostic_search, + "totalConstraintViolation", + ) + ), + "", + ] + ) + endpoint_trend = result.get("objectiveEndpointTrend") + endpoint_lines: list[str] = [] + if isinstance(endpoint_trend, Mapping) and endpoint_trend.get("applicable"): + endpoint_lines.extend(["## 终点趋势检查", ""]) + trend_status = endpoint_trend.get("status") + if trend_status == "materialChangeDetected": + relative_change = endpoint_trend.get("absoluteRelativeChange") + relative_percent = ( + _format_report_float(float(relative_change) * 100.0) + if isinstance(relative_change, (int, float)) + and not isinstance(relative_change, bool) + else "—" + ) + relative_range = endpoint_trend.get("relativeRange") + relative_range_percent = ( + _format_report_float(float(relative_range) * 100.0) + if isinstance(relative_range, (int, float)) + and not isinstance(relative_range, bool) + else "—" + ) + if endpoint_trend.get("direction") == "fluctuating": + endpoint_lines.append( + "- 复验曲线在末段 [{start}, {end}] s 仍有明显波动:" + "首尾净变化约 {change}%,峰峰范围约 {range_}%;因此 `final` " + "只代表终点快照,不能据此声称达到稳态。".format( + start=trial_number(endpoint_trend, "tailStart"), + end=trial_number(endpoint_trend, "tailEnd"), + change=relative_percent, + range_=relative_range_percent, + ) + ) + else: + endpoint_lines.append( + "- 复验曲线在末段 [{start}, {end}] s 仍{direction}," + "首尾净变化约 {percent}%;因此 `final` 只代表终点快照," + "不能据此声称达到稳态。".format( + start=trial_number(endpoint_trend, "tailStart"), + end=trial_number(endpoint_trend, "tailEnd"), + direction=( + "上升" + if endpoint_trend.get("direction") == "increasing" + else "下降" + ), + percent=relative_percent, + ) + ) + endpoint_lines.append( + "- 末段平均变化率:{} {}/s;这是启发式风险提示,不改变复验状态。".format( + trial_number(endpoint_trend, "averageSlopePerSecond"), + _markdown_text(objective_metric_unit or "dimensionless"), + ) + ) + elif trend_status == "noMaterialChangeDetected": + endpoint_lines.append( + "- 启发式检查未发现明显的末端变化;这仍不是稳态证明。" + ) + elif trend_status == "insufficientData": + endpoint_lines.append( + "- 完整复验曲线的末段样本不足,无法可靠检查终点附近的变化;" + "不能据此判断稳态。" + ) + else: + endpoint_lines.append( + "- 趋势诊断不可用({});没有完整覆盖计划终点的新鲜序列时," + "不推断终点趋势。".format( + _markdown_text(endpoint_trend.get("reason") or "原因未提供") + ) + ) + endpoint_lines.append("") + lines.extend(["## 约束与验证", ""]) + baseline_constraints = constraint_map(baseline) + search_constraints = constraint_map(search_comparison) + verified_constraints = constraint_map(best) + if self.plan.spec.constraints: + lines.extend( + [ + f"| 约束 | 下界 | 上界 | 容差 | 基线 | {search_constraint_column} | 新鲜复验 | 复验余量 | 统计量单位 |", + "| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- |", + ] + ) + for constraint in self.plan.spec.constraints: + baseline_item = baseline_constraints.get(constraint.id, {}) + search_item = search_constraints.get(constraint.id, {}) + verified_item = verified_constraints.get(constraint.id, {}) + lines.append( + "| {id} | {lower} | {upper} | {tolerance} | {baseline} | {search} | {verified} | {margin} | {unit} |".format( + id=_markdown_text(constraint.id), + lower=( + _format_report_float(constraint.lower) + if constraint.lower is not None + else "—" + ), + upper=( + _format_report_float(constraint.upper) + if constraint.upper is not None + else "—" + ), + tolerance=_format_report_float(constraint.tolerance), + baseline=trial_number(baseline_item, "value"), + search=trial_number(search_item, "value"), + verified=trial_number(verified_item, "value"), + margin=trial_number(verified_item, "margin"), + unit=_markdown_text( + _metric_unit( + constraint.expected_unit, + constraint.statistic.kind, + ) + or "dimensionless" + ), + ) + ) + lines.append("") + else: + lines.extend(["- 未声明响应约束。", ""]) + verification = result.get("verification") + if isinstance(verification, Mapping): + lines.append( + "- 新鲜复验通过:{}".format( + "是" if verification.get("passed") is True else "否" + ) + ) + lines.append( + "- 复验结束时源工程未变化:{}".format( + "是" if verification.get("sourceUnchanged") is True else "否" + ) + ) + lines.append( + f"- 相对/绝对复验容差:`{verification.get('relativeTolerance')}` / `{verification.get('absoluteTolerance')}`" + ) + raw_metrics = verification.get("metrics") + if isinstance(raw_metrics, list): + for metric in raw_metrics: + if isinstance(metric, Mapping): + lines.append( + "- `{id}`:搜索 `{search}`,复验 `{verification}`,匹配 {matches}".format( + id=_markdown_text(metric.get("id")), + search=trial_number(metric, "search"), + verification=trial_number(metric, "verification"), + matches=( + "是" if metric.get("matches") is True else "否" + ), + ) + ) + else: + lines.append("- 未生成可验证的可行候选。") + lines.append("") + lines.extend(endpoint_lines) + lines.extend( + [ + "## 搜索过程", + "", + f"- 搜索停止:`{termination_reason}` — {termination_description}", + f"- 搜索候选评估:{count_values.get('searchProposals', '—')}", + f"- 搜索请求(计入仿真预算):{count_values.get('searchSubmissionSlotsConsumed', '—')} / {self.search_run_limit}", + f"- 搜索完成 / 失败或不完整:{count_values.get('searchCompletedTrials', '—')} / {count_values.get('searchFailedTrials', '—')}", + f"- 独立复验请求(计入仿真预算):{count_values.get('verificationSubmissionSlotsConsumed', '—')};完成 / 失败或不完整:{count_values.get('verificationCompletedTrials', '—')} / {count_values.get('verificationFailedTrials', '—')}", + f"- 计入仿真预算的请求总数:{count_values.get('backendSubmissionSlotsConsumed', '—')}", + f"- 未形成试验记录的预算请求:{count_values.get('unrecordedSubmissionSlotsConsumed', '—')}", + f"- 重复候选(缓存命中):{count_values.get('cacheHits', '—')}", + f"- 完整代:{generation_values.get('completed', '—')};产生新仿真的代:{generation_values.get('withNewBackendSubmissions', '—')};纯重复代:{generation_values.get('withoutNewBackendSubmissions', '—')}", + f"- 未使用的搜索仿真额度:{count_values.get('remainingSearchRunBudget', '—')}", + f"- 仿真耗时:总计 {trial_number(timing_values, 'simulationDurationTotalSeconds')} s;单次最短 / 最长 / 平均 {trial_number(timing_values, 'simulationDurationMinimumSeconds')} / {trial_number(timing_values, 'simulationDurationMaximumSeconds')} / {trial_number(timing_values, 'simulationDurationMeanSeconds')} s", + "", + "## 算法与预算", + "", + ( + "- 算法:标准库实现的 `DE/rand/1/bin`,每代冻结供体种群、" + "越界坐标反射回可行域,顺序执行,不做局部抛光" + ), + endpoint_seeding_text, + f"- 随机种子:`{self.plan.spec.algorithm.seed}`", + f"- 种群大小:{self.plan.spec.algorithm.population_size}", + f"- 变异因子 / 交叉概率:{_format_report_float(self.plan.spec.algorithm.mutation_factor)} / {_format_report_float(self.plan.spec.algorithm.crossover_probability)}", + f"- 后端仿真上限:{self.plan.spec.budget.max_simulation_runs}(搜索最多 {self.search_run_limit},预留 1 次新鲜复验)", + f"- 候选都唯一时预算可覆盖的完整 DE 代数:{full_generations_with_unique_candidates}", + f"- 搜索软墙钟上限:{_format_report_float(self.plan.spec.budget.max_wall_seconds)} s", + "", + "## 审计信息", + "", + f"- 源工程:`{_markdown_text(self.plan.source.path)}`", + f"- 源 SHA-256:`{self.plan.source.sha256}`", + f"- 计划哈希:`{self.plan.confirmation_token}`", + f"- 搜索策略:`{SEARCH_POLICY}`", + "", + "## 产物", + "", + "- `optimization-result.json`:权威结构化汇总", + "- `evaluations.csv`:逐次已记录后端提交尝试摘要", + "- `optimization-events.jsonl`:优化与内层仿真进度", + "- `checkpoint.json`:中断时的已完成试验快照", + ] + ) + if result.get("solutionStatus") == "verified": + lines.extend( + [ + "- `best-project.json` / `best-system.xml`:验证后的派生模型;源文件未覆盖", + "- `result.json` / `results.csv` / `curve-*.svg`:最终新鲜复验结果", + "", + "派生工程会把被优化参数的原表达式替换为普通 SI 数值;其他工程字段保持不变。", + ] + ) + return "\n".join(lines) + "\n" + + def _result_payload( + self, + *, + termination_reason: str, + solution_status: str, + baseline: Trial | None, + best_search: Trial | None, + best_diagnostic_search: Trial | None, + verification_trial: Trial | None, + verification: Mapping[str, object] | None, + artifacts: Mapping[str, object], + objective_endpoint_trend: Mapping[str, object] | None = None, + source_unchanged: bool | None = None, + error: Mapping[str, object] | None = None, + ) -> dict[str, object]: + source_status = ( + self._source_is_unchanged() + if source_unchanged is None + else source_unchanged + ) + claim = { + "verified": ( + "best verified feasible point among completed search simulations " + "within the declared budget; " + "not a proof of global optimality, search convergence, or steady state" + ), + "noFeasibleCandidate": ( + "no complete feasible candidate found within the declared budget" + ), + "verificationFailed": ( + "a feasible search candidate was found but fresh verification failed" + ), + "aborted": "optimization aborted before a verified solution was established", + }.get(solution_status, "no verified solution was established") + verification_payload = ( + dict(verification) if verification is not None else None + ) + if verification_payload is not None: + verification_payload["backendSubmissions"] = ( + self.verification_backend_submissions + ) + verification_payload["submissionSlotsConsumed"] = ( + self.verification_backend_submissions + ) + verification_payload["meaning"] = ( + "fresh candidate reproducibility and feasibility; not search convergence" + ) + result: dict[str, object] = { + "optimizationResultSchemaVersion": OPTIMIZATION_RESULT_SCHEMA_VERSION, + "optimizationId": self.optimization_id, + "solutionStatus": solution_status, + "terminationReason": termination_reason, + "search": self._search_summary(termination_reason), + "claim": claim, + "source": { + "path": str(self.plan.source.path), + "sha256": self.plan.source.sha256, + "unchanged": source_status, + }, + "spec": { + "path": str(self.plan.spec_source.path), + "sha256": self.plan.spec_source.sha256, + "resolved": self.plan.spec.as_dict(), + }, + "planHash": self.plan.confirmation_token, + "continuityPolicy": { + "id": CONTINUITY_POLICY, + "machineVerified": False, + "confirmedUserAssertion": True, + "designVariableIds": [ + item.spec.id + for item in self.plan.resolved_design_variables + ], + }, + "objectiveMetric": { + "resultKey": self.plan.spec.objective.result_key, + "statistic": self.plan.spec.objective.statistic.as_dict(), + "seriesUnit": self.plan.spec.objective.expected_unit, + "unit": _metric_unit( + self.plan.spec.objective.expected_unit, + self.plan.spec.objective.statistic.kind, + ), + }, + "environment": { + "python": platform.python_version(), + "platform": platform.platform(), + "statisticImplementationVersion": STATISTIC_IMPLEMENTATION_VERSION, + "backendBaseUrl": self.plan.base_url, + "backendBuildFingerprint": None, + }, + "counts": self._counts_payload(), + "timing": self._timing_payload(), + "baseline": baseline.as_dict() if baseline is not None else None, + "bestSearch": best_search.as_dict() if best_search is not None else None, + "bestDiagnosticSearch": ( + best_diagnostic_search.as_dict() + if best_diagnostic_search is not None + else None + ), + "verifiedBest": ( + verification_trial.as_dict() + if solution_status == "verified" and verification_trial is not None + else None + ), + "verification": verification_payload, + "objectiveEndpointTrend": ( + dict(objective_endpoint_trend) + if objective_endpoint_trend is not None + else None + ), + "improvement": self._improvement( + baseline, + verification_trial if solution_status == "verified" else None, + ), + "trials": [trial.as_dict() for trial in self.trials], + "artifacts": dict(artifacts), + } + if error is not None: + result["error"] = dict(error) + return result + + def _finalize_result(self, payload: dict[str, object]) -> dict[str, object]: + report_path = self.output_directory / "report.md" + _atomic_write(report_path, self._report_markdown(payload).encode("utf-8")) + artifacts = payload.get("artifacts") + if isinstance(artifacts, dict): + supporting_artifacts = { + "plan": self.output_directory / "optimization-plan.json", + "report": report_path, + "evaluations": self.output_directory / "evaluations.csv", + "events": self.events_path, + "checkpoint": self.output_directory / "checkpoint.json", + } + for name, path in supporting_artifacts.items(): + if path.is_file(): + artifacts[name] = _artifact(path) + result_path = self.output_directory / "optimization-result.json" + _atomic_json(result_path, payload) + return payload + + def run(self) -> dict[str, object]: + simulation._prepare_output_directory(str(self.output_directory)) + try: + self.progress_directory.mkdir() + self._events = self.events_path.open( + "x", encoding="utf-8", newline="\n" + ) + except OSError as exc: + raise simulation.ArtifactError( + "OPTIMIZATION_OUTPUT_INITIALIZATION_FAILED", + str(exc), + {"path": str(self.output_directory)}, + ) from exc + try: + _atomic_json( + self.output_directory / "optimization-plan.json", + self.plan.public_dict(), + ) + self._emit( + { + "event": "optimization-started", + "optimizationId": self.optimization_id, + "source": str(self.plan.source.path), + "sourceSha256": self.plan.source.sha256, + "maxSimulationRuns": self.plan.spec.budget.max_simulation_runs, + "searchRunLimit": self.search_run_limit, + "outputDirectory": str(self.output_directory), + } + ) + termination_reason, _ = self._search() + baseline = next( + ( + trial + for trial in self.trials + if trial.stage == "search" and trial.evaluation_id == 1 + ), + None, + ) + best_search = self._best_trial(feasible_only=True) + diagnostic_best = self._best_trial(feasible_only=False) + if best_search is None: + self._checkpoint(termination_reason) + payload = self._result_payload( + termination_reason=termination_reason, + solution_status="noFeasibleCandidate", + baseline=baseline, + best_search=None, + best_diagnostic_search=diagnostic_best, + verification_trial=None, + verification=None, + artifacts={}, + objective_endpoint_trend=_objective_endpoint_trend( + {}, + self.plan.spec.objective, + expected_end=_planned_objective_end(self.plan), + ), + ) + self._emit( + { + "event": "optimization-finished", + "optimizationId": self.optimization_id, + "solutionStatus": payload["solutionStatus"], + "terminationReason": payload["terminationReason"], + } + ) + return self._finalize_result(payload) + if not self._source_is_unchanged(): + raise OptimizationError( + "OPTIMIZATION_SOURCE_CHANGED", + "The source project changed after planning; fresh verification was not started.", + ) + + best_vector = [ + (best_search.parameters[resolved.spec.id] - resolved.spec.lower) + / (resolved.spec.upper - resolved.spec.lower) + for resolved in self.plan.resolved_design_variables + ] + verification_trial, verification_result = self._evaluate( + best_vector, + stage="verification", + bypass_cache=True, + retain_result=True, + ) + comparison = self._verification_comparison( + best_search, verification_trial + ) + objective_endpoint_trend = _objective_endpoint_trend( + verification_result or {}, + self.plan.spec.objective, + expected_end=_planned_objective_end(self.plan), + ) + verified = comparison["passed"] is True and verification_result is not None + solution_status = "verified" if verified else "verificationFailed" + artifacts: dict[str, object] = {} + if verified: + assert verification_result is not None + artifacts.update( + self._write_best_artifacts( + verification_trial, verification_result + ) + ) + final_source_unchanged = self._source_is_unchanged() + if not final_source_unchanged: + self._discard_generated_artifacts(artifacts) + artifacts.clear() + comparison["sourceUnchanged"] = False + comparison["passed"] = False + verified = False + solution_status = "verificationFailed" + else: + final_source_unchanged = self._source_is_unchanged() + self._checkpoint(termination_reason) + payload = self._result_payload( + termination_reason=termination_reason, + solution_status=solution_status, + baseline=baseline, + best_search=best_search, + best_diagnostic_search=None, + verification_trial=verification_trial, + verification=comparison, + artifacts=artifacts, + objective_endpoint_trend=objective_endpoint_trend, + source_unchanged=final_source_unchanged, + ) + self._emit( + { + "event": "optimization-finished", + "optimizationId": self.optimization_id, + "solutionStatus": payload["solutionStatus"], + "terminationReason": payload["terminationReason"], + } + ) + return self._finalize_result(payload) + finally: + if self._events is not None: + self._events.close() + self._events = None + + def finalize_error( + self, + code: str, + message: str, + *, + termination_reason: str, + ) -> None: + if not self.output_directory.exists(): + return + try: + self._checkpoint(termination_reason) + baseline = self.trials[0] if self.trials else None + best_feasible = self._best_trial(feasible_only=True) + best_diagnostic = ( + None + if best_feasible is not None + else self._best_trial(feasible_only=False) + ) + payload = self._result_payload( + termination_reason=termination_reason, + solution_status="aborted", + baseline=baseline, + best_search=best_feasible, + best_diagnostic_search=best_diagnostic, + verification_trial=None, + verification=None, + artifacts={}, + objective_endpoint_trend=_objective_endpoint_trend( + {}, + self.plan.spec.objective, + expected_end=_planned_objective_end(self.plan), + ), + error={"code": code, "message": message}, + ) + self._finalize_result(payload) + except (OSError, ValueError, simulation.SkillCliError): + pass + + +def command_optimize(args: argparse.Namespace) -> int: + if not args.confirmed: + raise simulation.InputError( + "OPTIMIZATION_CONFIRMATION_REQUIRED", + "Run optimization-plan first and explicitly confirm that exact plan.", + ) + plan = build_runtime_plan( + args.input, + args.spec, + args.output_dir, + base_url=args.base_url, + timeout=args.timeout, + ) + mismatches: dict[str, object] = {} + if args.expected_source_sha256 != plan.source.sha256: + mismatches["sourceSha256"] = { + "expected": args.expected_source_sha256, + "actual": plan.source.sha256, + } + if args.expected_spec_sha256 != plan.spec_source.sha256: + mismatches["specSha256"] = { + "expected": args.expected_spec_sha256, + "actual": plan.spec_source.sha256, + } + if args.confirmation_token != plan.confirmation_token: + mismatches["confirmationToken"] = "does not match the current plan" + if mismatches: + raise simulation.InputError( + "OPTIMIZATION_CONFIRMATION_STALE", + "Source, spec, backend conversion, or output path changed after planning.", + mismatches, + ) + optimization_id = args.optimization_id or f"opt-{uuid.uuid4().hex[:24]}" + if OPTIMIZATION_ID_PATTERN.fullmatch(optimization_id) is None: + raise simulation.InputError( + "OPTIMIZATION_ID_INVALID", + "Optimization ID must match [A-Za-z0-9._-]{1,96}.", + ) + runner = OptimizationRunner(plan, optimization_id) + try: + result = runner.run() + except KeyboardInterrupt: + runner.cancel_active() + runner.finalize_error( + "OPTIMIZATION_CANCELLED_BY_USER", + "Optimization was interrupted by the user.", + termination_reason="userCancelled", + ) + raise + except simulation.SkillCliError as exc: + runner.cancel_active() + runner.finalize_error( + exc.code, + exc.message, + termination_reason="error", + ) + raise + simulation.emit_json( + { + "event": "optimization-completed", + "optimizationId": optimization_id, + "solutionStatus": result.get("solutionStatus"), + "terminationReason": result.get("terminationReason"), + "result": str(plan.output_directory / "optimization-result.json"), + "report": str(plan.output_directory / "report.md"), + "outputDirectory": str(plan.output_directory), + } + ) + return 0 if result.get("solutionStatus") == "verified" else 4 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description=( + "Plan and run bounded, single-objective optimization for a " + "SystemSimulationApp ReactFlow project." + ) + ) + parser.add_argument( + "--base-url", + default=simulation.DEFAULT_BASE_URL, + help="Loopback FastAPI base URL (default: %(default)s).", + ) + parser.add_argument( + "--timeout", + default=simulation.DEFAULT_TIMEOUT_SECONDS, + type=simulation.positive_timeout, + help="HTTP/heartbeat read timeout in seconds (default: %(default)s).", + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + plan_parser = subparsers.add_parser( + "plan", + help="Validate and preview an immutable optimization plan without running it.", + ) + plan_parser.add_argument("input") + plan_parser.add_argument("--spec", required=True) + plan_parser.add_argument("--output-dir", required=True) + plan_parser.set_defaults(handler=command_plan) + + optimize_parser = subparsers.add_parser( + "optimize", + help="Execute an explicitly confirmed optimization plan.", + ) + optimize_parser.add_argument("input") + optimize_parser.add_argument("--spec", required=True) + optimize_parser.add_argument("--output-dir", required=True) + optimize_parser.add_argument("--expected-source-sha256", required=True) + optimize_parser.add_argument("--expected-spec-sha256", required=True) + optimize_parser.add_argument("--confirmation-token", required=True) + optimize_parser.add_argument("--confirmed", action="store_true") + optimize_parser.add_argument("--optimization-id") + optimize_parser.set_defaults(handler=command_optimize) + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + simulation.configure_standard_streams() + parser = build_parser() + args = parser.parse_args(argv) + try: + args.base_url = simulation.validate_base_url(args.base_url) + return int(args.handler(args)) + except simulation.SkillCliError as exc: + simulation.emit_json( + simulation.stable_error_payload(exc), + stream=sys.stderr, + ) + return exc.exit_code + except KeyboardInterrupt: + simulation.emit_json( + simulation.stable_error_payload( + OptimizationError( + "OPTIMIZATION_CANCELLED_BY_USER", + "Optimization was interrupted and no additional candidate will be started.", + ) + ), + stream=sys.stderr, + ) + return 4 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/system-simulation/scripts/simulation_skill.py b/skills/system-simulation/scripts/simulation_skill.py index 6f99a6a..21f7e25 100644 --- a/skills/system-simulation/scripts/simulation_skill.py +++ b/skills/system-simulation/scripts/simulation_skill.py @@ -27,7 +27,7 @@ import uuid import xml.etree.ElementTree as ET from dataclasses import dataclass from pathlib import Path -from typing import Any, Iterable, Mapping, Sequence +from typing import Any, Callable, Iterable, Mapping, Sequence MAX_INPUT_BYTES = 5 * 1024 * 1024 @@ -1294,12 +1294,16 @@ def _read_simulation_stream( simulation_id: str, timeout: float, progress_path: Path, + *, + event_sink: Callable[[object], None] | None = None, + full_result: str | None = "result.json", ) -> tuple[dict[str, object] | None, dict[str, object] | None]: final_result: dict[str, object] | None = None final_error: dict[str, object] | None = None public_phase: str | None = None public_progress: float | None = None public_emit_time = 0.0 + sink = event_sink or emit_json try: progress = progress_path.open("x", encoding="utf-8", newline="\n") except OSError as exc: @@ -1333,7 +1337,11 @@ def _read_simulation_stream( ) event_kind = event.get("event") logged_event = ( - _public_result_event(event, simulation_id) + _public_result_event( + event, + simulation_id, + full_result=full_result, + ) if event_kind == "result" else event ) @@ -1348,16 +1356,16 @@ def _read_simulation_stream( previous_progress=public_progress, seconds_since_emit=now - public_emit_time, ): - emit_json(event) + sink(event) public_phase = str(event.get("phase") or "") raw_progress = event.get("progress") if isinstance(raw_progress, (int, float)) and not isinstance(raw_progress, bool): public_progress = float(raw_progress) public_emit_time = now elif event_kind == "result": - emit_json(logged_event) + sink(logged_event) else: - emit_json(event) + sink(event) if event_kind == "result": result = event.get("result") if isinstance(result, dict): diff --git a/tests/test_system_optimization_skill.py b/tests/test_system_optimization_skill.py new file mode 100644 index 0000000..c45711c --- /dev/null +++ b/tests/test_system_optimization_skill.py @@ -0,0 +1,1755 @@ +"""Contract tests for the system-simulation optimization helper. + +The optimizer is exercised with deterministic in-process simulation results. No +test starts the FastAPI service or submits a real, potentially long simulation. +""" + +from __future__ import annotations + +import argparse +import contextlib +import copy +import dataclasses +import hashlib +import importlib.util +import json +import math +import sys +import tempfile +import unittest +import xml.etree.ElementTree as ET +from pathlib import Path +from unittest import mock + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +SCRIPTS_DIRECTORY = ( + REPOSITORY_ROOT / "skills" / "system-simulation" / "scripts" +) +SCRIPT_PATH = SCRIPTS_DIRECTORY / "optimization_skill.py" +MODULE_NAME = "system_optimization_skill_under_test" +MODULE_SPEC = importlib.util.spec_from_file_location(MODULE_NAME, SCRIPT_PATH) +if MODULE_SPEC is None or MODULE_SPEC.loader is None: # pragma: no cover + raise RuntimeError(f"Cannot load optimization helper from {SCRIPT_PATH}") +optimization = importlib.util.module_from_spec(MODULE_SPEC) +sys.modules[MODULE_NAME] = optimization +MODULE_SPEC.loader.exec_module(optimization) + + +BASE_URL = "http://127.0.0.1:8000" +ABSENT = object() +RESULT_VARIABLES = [ + { + "key": "sensor.output", + "componentId": "sensor", + "name": "output", + "label": "Output", + "quantity": "displacement", + "unit": "m", + }, + { + "key": "sensor.limit", + "componentId": "sensor", + "name": "limit", + "label": "Limit response", + "quantity": "displacement", + "unit": "m", + }, +] +PROJECT = { + "projectSchemaVersion": 1, + "name": "optimization-fixture", + "nodes": [ + { + "id": "component-a", + "type": "component", + "position": {"x": 10, "y": 20}, + "data": { + "componentType": "FIXTURE", + "label": "A", + "parameters": {"gain": "1 + 1", "fixed": 99.0}, + "custom": {"preserve": True}, + }, + }, + { + "id": "component-b", + "type": "component", + "data": { + "componentType": "FIXTURE", + "parameters": {"gain": 7.0}, + }, + }, + ], + "edges": [{"id": "edge-1", "source": "component-a", "target": "component-b"}], + "simulation": {"startTime": 0.0, "endTime": 1.0, "sampleStep": 0.1}, + "customRoot": {"preserve": [1, 2, 3]}, +} +BASELINE_XML = b""" + + + + + + + + + + + + +""" + + +def _json_bytes(value: object) -> bytes: + return (json.dumps(value, ensure_ascii=False, indent=2) + "\n").encode("utf-8") + + +def _valid_spec( + *, + seed: int = 12345, + max_simulation_runs: int = 6, + lower: float = 0.0, + upper: float = 4.0, +) -> dict[str, object]: + return { + "optimizationSchemaVersion": 1, + "objective": { + "resultKey": "sensor.output", + "expectedUnit": "m", + "statistic": {"kind": "final", "window": None}, + "goal": {"kind": "minimize"}, + }, + "designVariables": [ + { + "id": "gain", + "componentId": "component-a", + "parameter": "gain", + "unit": "m", + "lower": lower, + "upper": upper, + } + ], + "constraints": [ + { + "id": "limit", + "resultKey": "sensor.limit", + "expectedUnit": "m", + "statistic": {"kind": "maximum", "window": None}, + "lower": None, + "upper": 4.0, + "tolerance": 0.0, + "scale": 1.0, + } + ], + "algorithm": { + "name": "differentialEvolution", + "seed": seed, + "populationSize": 4, + "mutationFactor": 0.8, + "crossoverProbability": 0.7, + }, + "budget": { + "maxSimulationRuns": max_simulation_runs, + "maxWallSeconds": 60.0, + }, + "validation": { + "relativeTolerance": 1e-12, + "absoluteTolerance": 1e-12, + }, + } + + +def _inspection( + *, + optimization_eligible: object = ABSENT, + editor: object = ABSENT, + options: object = ABSENT, + unit: object = "m", + value: object = 2.0, + minimum: object = 0.0, + maximum: object = 4.0, + minimum_exclusive: object = False, +) -> dict[str, object]: + contract = { + "name": "gain", + "label": "Gain", + "quantity": "displacement", + "unit": unit, + "value": value, + "minimum": minimum, + "maximum": maximum, + "minimumExclusive": minimum_exclusive, + } + if optimization_eligible is not ABSENT: + contract["optimizationEligible"] = optimization_eligible + if editor is not ABSENT: + contract["editor"] = editor + if options is not ABSENT: + contract["options"] = options + return { + "ok": True, + "system": { + "name": "optimization-fixture", + "componentDetails": [ + { + "id": "component-a", + "compiled": {"parameters": [contract]}, + "source": copy.deepcopy(PROJECT["nodes"][0]), + } + ], + "resultVariables": copy.deepcopy(RESULT_VARIABLES), + }, + } + + +def _make_plan( + directory: Path, + *, + output_name: str = "optimization-output", + seed: int = 12345, + max_simulation_runs: int = 6, + lower: float = 0.0, + upper: float = 4.0, + inspection: dict[str, object] | None = None, +) -> optimization.RuntimePlan: + directory.mkdir(parents=True, exist_ok=True) + source_path = directory / "source.json" + source_path.write_bytes(_json_bytes(PROJECT)) + spec_payload = _valid_spec( + seed=seed, + max_simulation_runs=max_simulation_runs, + lower=lower, + upper=upper, + ) + spec_path = directory / "optimization-spec.json" + spec_path.write_bytes(_json_bytes(spec_payload)) + source = optimization.simulation.load_source(str(source_path), "json") + spec_source = optimization.simulation.load_source(str(spec_path), "json") + spec = optimization.parse_optimization_spec(spec_source.parsed) + resolved_inspection = inspection if inspection is not None else _inspection() + variables = optimization.simulation._available_variables(resolved_inspection) + resolved = optimization._resolve_design_variables(spec, resolved_inspection) + output_directory = directory / output_name + token = optimization._confirmation_token( + source_sha256=source.sha256, + spec_sha256=spec_source.sha256, + baseline_xml_sha256=hashlib.sha256(BASELINE_XML).hexdigest(), + output_directory=output_directory, + base_url=BASE_URL, + timeout=10.0, + ) + return optimization.RuntimePlan( + source=source, + spec_source=spec_source, + spec=spec, + inspection=resolved_inspection, + baseline_xml=BASELINE_XML, + variables=variables, + resolved_design_variables=resolved, + output_directory=output_directory, + base_url=BASE_URL, + timeout=10.0, + confirmation_token=token, + ) + + +def _xml_parameter_values(xml: bytes) -> dict[tuple[str, str], str]: + root = ET.fromstring(xml) + values: dict[tuple[str, str], str] = {} + for component in root.findall("./Components/Component"): + component_id = component.get("id") + if component_id is None: + continue + for parameter in component.findall("./Parameter"): + name = parameter.get("name") + value = parameter.get("value") + if name is not None and value is not None: + values[(component_id, name)] = value + return values + + +class OptimizationSpecTests(unittest.TestCase): + def test_spec_rejects_unknown_fields_at_every_nested_contract(self) -> None: + parsed = optimization.parse_optimization_spec(_valid_spec()) + self.assertEqual(parsed.algorithm.name, "differentialEvolution") + + cases: list[tuple[str, dict[str, object]]] = [] + root_unknown = _valid_spec() + root_unknown["surprise"] = True + cases.append(("root", root_unknown)) + + objective_unknown = _valid_spec() + objective = objective_unknown["objective"] + assert isinstance(objective, dict) + objective["label"] = "not part of schema 1" + cases.append(("objective", objective_unknown)) + + variable_unknown = _valid_spec() + design_variables = variable_unknown["designVariables"] + assert isinstance(design_variables, list) + assert isinstance(design_variables[0], dict) + design_variables[0]["logScale"] = True + cases.append(("design variable", variable_unknown)) + + window_unknown = _valid_spec() + objective = window_unknown["objective"] + assert isinstance(objective, dict) + statistic = objective["statistic"] + assert isinstance(statistic, dict) + statistic["window"] = {"start": 0.0, "end": 1.0, "closed": True} + cases.append(("window", window_unknown)) + + algorithm_unknown = _valid_spec() + algorithm = algorithm_unknown["algorithm"] + assert isinstance(algorithm, dict) + algorithm["workers"] = 2 + cases.append(("algorithm", algorithm_unknown)) + + for label, payload in cases: + with self.subTest(label=label): + with self.assertRaises(optimization.simulation.InputError) as caught: + optimization.parse_optimization_spec(payload) + self.assertEqual(caught.exception.code, "OPTIMIZATION_SPEC_INVALID") + self.assertTrue(caught.exception.details["unknown"]) + + def test_spec_rejects_boolean_numeric_values_and_invalid_bounds(self) -> None: + boolean_bound = _valid_spec() + variables = boolean_bound["designVariables"] + assert isinstance(variables, list) and isinstance(variables[0], dict) + variables[0]["lower"] = False + with self.assertRaises(optimization.simulation.InputError) as caught: + optimization.parse_optimization_spec(boolean_bound) + self.assertEqual(caught.exception.code, "OPTIMIZATION_SPEC_INVALID") + + reversed_bounds = _valid_spec() + variables = reversed_bounds["designVariables"] + assert isinstance(variables, list) and isinstance(variables[0], dict) + variables[0]["lower"] = 4.0 + with self.assertRaises(optimization.simulation.InputError) as caught: + optimization.parse_optimization_spec(reversed_bounds) + self.assertEqual(caught.exception.code, "OPTIMIZATION_BOUNDS_INVALID") + + overflowing_span = _valid_spec(lower=-1e308, upper=1e308) + with self.assertRaises(optimization.simulation.InputError) as caught: + optimization.parse_optimization_spec(overflowing_span) + self.assertEqual(caught.exception.code, "OPTIMIZATION_BOUNDS_INVALID") + + +class StatisticTests(unittest.TestCase): + def test_statistics_use_linearly_interpolated_window_boundaries(self) -> None: + times = [0.0, 1.0, 2.0] + values = [0.0, 2.0, 0.0] + window = optimization.TimeWindow(0.5, 1.5) + expected = { + "final": 1.0, + "minimum": 1.0, + "maximum": 2.0, + "timeMean": 1.5, + "rms": math.sqrt(2.5), + "integral": 1.5, + "absoluteIntegral": 1.5, + "peakAbsolute": 2.0, + } + for kind, expected_value in expected.items(): + with self.subTest(kind=kind): + actual = optimization.statistic_value( + times, + values, + optimization.StatisticSpec(kind, window), + ) + self.assertAlmostEqual(actual, expected_value) + + def test_statistic_rejects_uncovered_windows_and_nonmonotonic_time(self) -> None: + with self.assertRaises(optimization.OptimizationError) as caught: + optimization.statistic_value( + [0.0, 1.0], + [1.0, 2.0], + optimization.StatisticSpec( + "final", optimization.TimeWindow(-0.1, 0.5) + ), + ) + self.assertEqual(caught.exception.code, "OPTIMIZATION_WINDOW_NOT_COVERED") + + with self.assertRaises(optimization.OptimizationError) as caught: + optimization.statistic_value( + [0.0, 1.0, 1.0], + [1.0, 2.0, 3.0], + optimization.StatisticSpec("maximum", None), + ) + self.assertEqual(caught.exception.code, "OPTIMIZATION_RESULT_TIME_INVALID") + + def test_completed_metrics_reject_result_unit_drift(self) -> None: + spec = optimization.parse_optimization_spec(_valid_spec()) + variables = copy.deepcopy(RESULT_VARIABLES) + variables[0]["unit"] = "cm" + result = { + "status": "completed", + "success": True, + "variables": variables, + "series": { + "time": [0.0, 1.0], + "sensor.output": [1.0, 1.0], + "sensor.limit": [1.0, 1.0], + }, + } + + with self.assertRaises(optimization.OptimizationError) as caught: + optimization._completed_metrics(result, spec) + + self.assertEqual( + caught.exception.code, "OPTIMIZATION_RESULT_METADATA_MISMATCH" + ) + self.assertEqual(caught.exception.details["expected"], "m") + self.assertEqual(caught.exception.details["received"], "cm") + + def test_metric_overflow_is_a_structured_error(self) -> None: + with self.assertRaises(optimization.OptimizationError) as caught: + optimization.statistic_value( + [0.0, 1.0], + [1e308, 1e308], + optimization.StatisticSpec("rms", None), + ) + self.assertEqual(caught.exception.code, "OPTIMIZATION_METRIC_OVERFLOW") + + def test_signed_integral_fsum_value_error_is_structured_overflow(self) -> None: + with self.assertRaises(optimization.OptimizationError) as caught: + optimization.statistic_value( + [0.0, 1.0, 2.0, 3.0], + [1e308, 1e308, -1e308, -1e308], + optimization.StatisticSpec("integral", None), + ) + + self.assertEqual(caught.exception.code, "OPTIMIZATION_METRIC_OVERFLOW") + + def test_integral_metrics_report_series_unit_times_seconds(self) -> None: + payload = _valid_spec() + objective = payload["objective"] + constraints = payload["constraints"] + assert isinstance(objective, dict) + assert isinstance(constraints, list) and isinstance(constraints[0], dict) + objective["statistic"] = {"kind": "integral", "window": None} + constraints[0]["statistic"] = { + "kind": "absoluteIntegral", + "window": None, + } + spec = optimization.parse_optimization_spec(payload) + result = { + "status": "completed", + "success": True, + "variables": copy.deepcopy(RESULT_VARIABLES), + "series": { + "time": [0.0, 1.0], + "sensor.output": [1.0, 1.0], + "sensor.limit": [2.0, 2.0], + }, + } + + metrics = optimization._completed_metrics(result, spec) + + self.assertEqual(spec.objective.as_dict()["metricUnit"], "(m)*s") + self.assertEqual(metrics[2][0]["seriesUnit"], "m") + self.assertEqual(metrics[2][0]["unit"], "(m)*s") + + +class ObjectiveEndpointTrendTests(unittest.TestCase): + @staticmethod + def _objective(*, statistic_kind: str = "final") -> optimization.ObjectiveSpec: + payload = _valid_spec() + objective = payload["objective"] + assert isinstance(objective, dict) + statistic = objective["statistic"] + assert isinstance(statistic, dict) + statistic["kind"] = statistic_kind + return optimization.parse_optimization_spec(payload).objective + + def test_final_objective_detects_material_terminal_change(self) -> None: + times = [float(index) for index in range(101)] + values = [100.0] * 95 + [100.0, 98.0, 96.0, 94.0, 92.0, 90.0] + trend = optimization._objective_endpoint_trend( + { + "status": "completed", + "success": True, + "series": {"time": times, "sensor.output": values}, + }, + self._objective(), + expected_end=100.0, + ) + + self.assertTrue(trend["applicable"]) + self.assertEqual(trend["status"], "materialChangeDetected") + self.assertTrue(trend["materialChangeDetected"]) + self.assertEqual(trend["direction"], "decreasing") + self.assertFalse(trend["steadyStateProven"]) + + def test_final_objective_reports_no_material_terminal_change(self) -> None: + times = [float(index) for index in range(101)] + values = [100.0] * len(times) + trend = optimization._objective_endpoint_trend( + { + "status": "completed", + "success": True, + "series": {"time": times, "sensor.output": values}, + }, + self._objective(), + expected_end=100.0, + ) + + self.assertTrue(trend["applicable"]) + self.assertEqual(trend["status"], "noMaterialChangeDetected") + self.assertFalse(trend["materialChangeDetected"]) + self.assertEqual(trend["direction"], "flat") + self.assertFalse(trend["steadyStateProven"]) + + def test_final_objective_labels_range_only_signal_as_fluctuation(self) -> None: + times = [float(index) for index in range(101)] + values = [100.0] * 95 + [100.0, 104.0, 96.0, 104.0, 96.0, 100.0] + trend = optimization._objective_endpoint_trend( + { + "status": "completed", + "success": True, + "series": {"time": times, "sensor.output": values}, + }, + self._objective(), + expected_end=100.0, + ) + + self.assertEqual(trend["status"], "materialChangeDetected") + self.assertEqual(trend["direction"], "fluctuating") + self.assertFalse(trend["directionalChangeDetected"]) + self.assertTrue(trend["tailVariabilityDetected"]) + self.assertEqual(trend["detectionReasons"], ["tailVariability"]) + self.assertFalse(trend["steadyStateProven"]) + + def test_flat_tail_followed_by_step_is_directional_not_fluctuating(self) -> None: + times = [float(index) for index in range(101)] + values = [100.0] * 100 + [103.0] + trend = optimization._objective_endpoint_trend( + { + "status": "completed", + "success": True, + "series": {"time": times, "sensor.output": values}, + }, + self._objective(), + expected_end=100.0, + ) + + self.assertEqual(trend["status"], "materialChangeDetected") + self.assertEqual(trend["direction"], "increasing") + self.assertTrue(trend["directionalChangeDetected"]) + self.assertEqual(trend["directionalConsistency"], 1.0) + self.assertEqual(trend["nonzeroIncrementCount"], 1) + + def test_final_objective_reports_insufficient_terminal_samples(self) -> None: + trend = optimization._objective_endpoint_trend( + { + "status": "completed", + "success": True, + "series": { + "time": [0.0, 1.0, 2.0, 3.0, 4.0], + "sensor.output": [5.0, 4.0, 3.0, 2.0, 1.0], + } + }, + self._objective(), + expected_end=4.0, + ) + + self.assertTrue(trend["applicable"]) + self.assertEqual(trend["status"], "insufficientData") + self.assertEqual(trend["availableSamples"], 5) + self.assertFalse(trend["steadyStateProven"]) + + def test_non_final_objective_is_not_applicable(self) -> None: + trend = optimization._objective_endpoint_trend( + {}, + self._objective(statistic_kind="maximum"), + ) + + self.assertFalse(trend["applicable"]) + self.assertEqual(trend["status"], "notApplicable") + self.assertFalse(trend["steadyStateProven"]) + + def test_incomplete_or_short_of_planned_endpoint_is_unavailable(self) -> None: + incomplete = optimization._objective_endpoint_trend( + { + "status": "stopped", + "success": False, + "series": { + "time": [0.0, 0.5], + "sensor.output": [2.0, 1.0], + }, + }, + self._objective(), + expected_end=1.0, + ) + short = optimization._objective_endpoint_trend( + { + "status": "completed", + "success": True, + "series": { + "time": [index / 10 for index in range(10)], + "sensor.output": [1.0] * 10, + }, + }, + self._objective(), + expected_end=1.0, + ) + + self.assertEqual(incomplete["status"], "unavailable") + self.assertEqual(incomplete["reason"], "freshVerificationNotCompleted") + self.assertEqual(short["status"], "unavailable") + self.assertEqual(short["reason"], "plannedEndpointNotCovered") + self.assertFalse(incomplete["steadyStateProven"]) + self.assertFalse(short["steadyStateProven"]) + + def test_terminal_trend_numeric_overflow_degrades_to_unavailable(self) -> None: + times = [float(index) for index in range(101)] + values = [1e308] * 95 + [1e308, -1e308, 1e308, -1e308, 1e308, -1e308] + trend = optimization._objective_endpoint_trend( + { + "status": "completed", + "success": True, + "series": {"time": times, "sensor.output": values}, + }, + self._objective(), + expected_end=100.0, + ) + + self.assertEqual(trend["status"], "unavailable") + self.assertEqual(trend["reason"], "terminalTrendNumericOverflow") + self.assertFalse(trend["steadyStateProven"]) + + +class ReportSafetyTests(unittest.TestCase): + def test_report_escapes_markdown_link_and_image_delimiters(self) -> None: + with tempfile.TemporaryDirectory() as directory_text: + plan = _make_plan(Path(directory_text)) + injected_unit = "![x](https://example.invalid/pixel)" + objective = dataclasses.replace( + plan.spec.objective, + expected_unit=injected_unit, + ) + plan = dataclasses.replace( + plan, + spec=dataclasses.replace(plan.spec, objective=objective), + ) + runner = optimization.OptimizationRunner(plan, "report-safety-test") + + report = runner._report_markdown( + { + "solutionStatus": "noFeasibleCandidate", + "terminationReason": "simulationBudgetExhausted", + "counts": {}, + } + ) + + self.assertNotIn(injected_unit, report) + self.assertNotRegex(report, r"!\[[^]]*\]\([^)]*\)") + self.assertNotRegex(report, r"\[[^]]*\]\([^)]*\)") + + +class ParameterContractTests(unittest.TestCase): + def test_user_selected_si_parameter_resolves_with_source_provenance(self) -> None: + spec = optimization.parse_optimization_spec(_valid_spec()) + resolved = optimization._resolve_design_variables(spec, _inspection()) + + self.assertEqual(len(resolved), 1) + self.assertEqual(resolved[0].initial, 2.0) + self.assertEqual(resolved[0].spec.unit, "m") + self.assertTrue(resolved[0].was_explicit) + self.assertEqual(resolved[0].original_value, "1 + 1") + self.assertIsNone(resolved[0].catalog_optimization_eligible) + + def test_discrete_metadata_unit_and_bounds_are_authoritative(self) -> None: + cases = [ + ( + "explicit backend opt-out", + _valid_spec(), + _inspection(optimization_eligible=False), + "OPTIMIZATION_PARAMETER_INELIGIBLE", + ), + ( + "editor", + _valid_spec(), + _inspection(editor="choice"), + "OPTIMIZATION_PARAMETER_DISCRETE_METADATA", + ), + ( + "discrete options", + _valid_spec(), + _inspection(options=[{"value": 1.0, "label": "one"}]), + "OPTIMIZATION_PARAMETER_DISCRETE_METADATA", + ), + ( + "unit", + _valid_spec(), + _inspection(unit="cm"), + "OPTIMIZATION_PARAMETER_UNIT_MISMATCH", + ), + ( + "initial", + _valid_spec(), + _inspection(value=5.0, maximum=10.0), + "OPTIMIZATION_INITIAL_OUTSIDE_BOUNDS", + ), + ( + "registered minimum", + _valid_spec(lower=-0.1), + _inspection(), + "OPTIMIZATION_BOUNDS_OUTSIDE_CONTRACT", + ), + ( + "exclusive minimum", + _valid_spec(lower=0.0), + _inspection(minimum_exclusive=True), + "OPTIMIZATION_BOUNDS_OUTSIDE_CONTRACT", + ), + ( + "registered maximum", + _valid_spec(upper=4.1), + _inspection(maximum=4.0), + "OPTIMIZATION_BOUNDS_OUTSIDE_CONTRACT", + ), + ] + for label, payload, inspection, expected_code in cases: + with self.subTest(label=label): + spec = optimization.parse_optimization_spec(payload) + with self.assertRaises(optimization.simulation.InputError) as caught: + optimization._resolve_design_variables(spec, inspection) + self.assertEqual(caught.exception.code, expected_code) + + def test_optional_backend_eligibility_contract_must_be_boolean(self) -> None: + spec = optimization.parse_optimization_spec(_valid_spec()) + malformed_inspections = [ + _inspection(optimization_eligible="yes"), + _inspection(optimization_eligible=None), + _inspection(editor=""), + _inspection(editor=1), + _inspection(options=[]), + _inspection(options="choice"), + ] + for inspection in malformed_inspections: + with self.subTest(inspection=inspection): + with self.assertRaises(optimization.simulation.BackendError) as caught: + optimization._resolve_design_variables(spec, inspection) + self.assertEqual( + caught.exception.code, + "BACKEND_PARAMETER_CONTRACT_INVALID", + ) + + def test_optional_backend_eligibility_true_is_recorded(self) -> None: + spec = optimization.parse_optimization_spec(_valid_spec()) + resolved = optimization._resolve_design_variables( + spec, + _inspection(optimization_eligible=True), + ) + self.assertIs(resolved[0].catalog_optimization_eligible, True) + + +class CandidateIsolationTests(unittest.TestCase): + def test_reflection_maps_any_finite_coordinate_into_unit_interval(self) -> None: + cases = ( + (-2.25, 0.25), + (-1.25, 0.75), + (-0.25, 0.25), + (0.0, 0.0), + (0.25, 0.25), + (1.0, 1.0), + (1.25, 0.75), + (2.0, 0.0), + (2.25, 0.25), + ) + for coordinate, expected in cases: + with self.subTest(coordinate=coordinate): + self.assertEqual( + optimization._reflect_unit_interval(coordinate), + expected, + ) + + for coordinate in (math.inf, -math.inf, math.nan): + with self.subTest(coordinate=coordinate): + with self.assertRaises(AssertionError): + optimization._reflect_unit_interval(coordinate) + + def test_candidates_only_change_whitelisted_parameters_and_never_source(self) -> None: + with tempfile.TemporaryDirectory() as directory_text: + directory = Path(directory_text) + plan = _make_plan(directory) + original_project = copy.deepcopy(plan.source.parsed) + original_source_bytes = plan.source.path.read_bytes() + original_xml_values = _xml_parameter_values(plan.baseline_xml) + + first_project = optimization._optimized_project(plan, {"gain": 3.5}) + second_project = optimization._optimized_project(plan, {"gain": 0.25}) + expected_first = copy.deepcopy(original_project) + expected_second = copy.deepcopy(original_project) + expected_first["nodes"][0]["data"]["parameters"]["gain"] = 3.5 + expected_second["nodes"][0]["data"]["parameters"]["gain"] = 0.25 + + self.assertEqual(first_project, expected_first) + self.assertEqual(second_project, expected_second) + self.assertEqual(plan.source.parsed, original_project) + self.assertEqual(plan.source.path.read_bytes(), original_source_bytes) + + first_xml_values = _xml_parameter_values( + optimization._candidate_xml(plan, {"gain": 3.5}) + ) + second_xml_values = _xml_parameter_values( + optimization._candidate_xml(plan, {"gain": 0.25}) + ) + self.assertEqual(first_xml_values[("component-a", "gain")], "3.5") + self.assertEqual(second_xml_values[("component-a", "gain")], "0.25") + for target in ( + ("component-a", "fixed"), + ("component-b", "gain"), + ): + self.assertEqual(first_xml_values[target], original_xml_values[target]) + self.assertEqual(second_xml_values[target], original_xml_values[target]) + self.assertEqual(plan.baseline_xml, BASELINE_XML) + + +class RankingAndConfirmationTests(unittest.TestCase): + def test_plan_warns_when_budget_cannot_cover_a_complete_generation(self) -> None: + with tempfile.TemporaryDirectory() as directory_text: + minimal = _make_plan(Path(directory_text) / "minimal", max_simulation_runs=5) + one_generation = _make_plan( + Path(directory_text) / "one-generation", max_simulation_runs=9 + ) + + minimal_public = minimal.public_dict() + one_generation_public = one_generation.public_dict() + self.assertEqual( + minimal_public["execution"]["fullGenerationsWithUniqueCandidates"], + 0, + ) + self.assertEqual( + [warning["code"] for warning in minimal_public["warnings"]], + ["OPTIMIZATION_BUDGET_INITIAL_POPULATION_ONLY"], + ) + self.assertEqual( + minimal_public["parameterContinuity"][ + "userAssertionRequiredFor" + ], + ["gain"], + ) + self.assertEqual( + minimal_public["designVariables"][0]["continuity"], + { + "machineVerified": False, + "userAssertionRequired": True, + "catalogOptimizationEligible": None, + "editorAbsent": True, + "optionsAbsent": True, + }, + ) + self.assertEqual( + minimal_public["requiredAssertions"][0]["code"], + "OPTIMIZATION_CONTINUITY_USER_ASSERTION", + ) + self.assertEqual( + one_generation_public["execution"][ + "fullGenerationsWithUniqueCandidates" + ], + 1, + ) + self.assertEqual(one_generation_public["warnings"], []) + + def test_confirmation_token_binds_all_plan_hash_inputs(self) -> None: + arguments = { + "source_sha256": "source-hash", + "spec_sha256": "spec-hash", + "baseline_xml_sha256": "xml-hash", + "output_directory": Path("/tmp/optimization-output"), + "base_url": BASE_URL, + "timeout": 10.0, + } + token = optimization._confirmation_token(**arguments) + self.assertEqual(len(token), 64) + variants = [ + {**arguments, "source_sha256": "changed-source"}, + {**arguments, "spec_sha256": "changed-spec"}, + {**arguments, "baseline_xml_sha256": "changed-xml"}, + { + **arguments, + "output_directory": Path("/tmp/different-optimization-output"), + }, + {**arguments, "base_url": "http://localhost:8000"}, + {**arguments, "timeout": 11.0}, + ] + for changed in variants: + with self.subTest(changed=changed): + self.assertNotEqual( + optimization._confirmation_token(**changed), token + ) + with mock.patch.object( + optimization, + "CONTINUITY_POLICY", + "different-continuity-policy", + ): + self.assertNotEqual( + optimization._confirmation_token(**arguments), + token, + ) + with mock.patch.object( + optimization, + "SEARCH_POLICY", + "different-search-policy", + ): + self.assertNotEqual( + optimization._confirmation_token(**arguments), + token, + ) + + def test_failed_or_infeasible_trial_cannot_beat_a_feasible_trial(self) -> None: + feasible = optimization.Trial( + evaluation_id=3, + stage="search", + simulation_id="opt.0003", + parameters={"gain": 2.0}, + status="completed", + duration_seconds=0.1, + objective_value=1000.0, + objective_loss=1000.0, + feasible=True, + total_constraint_violation=0.0, + ) + infeasible = optimization.Trial( + evaluation_id=2, + stage="search", + simulation_id="opt.0002", + parameters={"gain": 1.0}, + status="completed", + duration_seconds=0.1, + objective_value=-100.0, + objective_loss=-100.0, + feasible=False, + total_constraint_violation=0.01, + ) + failed = optimization.Trial( + evaluation_id=1, + stage="search", + simulation_id="opt.0001", + parameters={"gain": 0.0}, + status="failed", + duration_seconds=0.1, + objective_value=-10000.0, + objective_loss=-10000.0, + feasible=True, + total_constraint_violation=0.0, + ) + + self.assertLess(optimization._trial_rank(feasible), optimization._trial_rank(infeasible)) + self.assertLess(optimization._trial_rank(feasible), optimization._trial_rank(failed)) + + with tempfile.TemporaryDirectory() as directory_text: + runner = optimization.OptimizationRunner( + _make_plan(Path(directory_text)), "ranking-test" + ) + runner.trials.extend([failed, infeasible, feasible]) + self.assertIs(runner._best_trial(feasible_only=True), feasible) + self.assertIs(runner._best_trial(feasible_only=False), feasible) + + def test_optimize_rejects_a_stale_confirmation_token_before_runner_starts(self) -> None: + with tempfile.TemporaryDirectory() as directory_text: + plan = _make_plan(Path(directory_text)) + args = argparse.Namespace( + confirmed=True, + input=str(plan.source.path), + spec=str(plan.spec_source.path), + output_dir=str(plan.output_directory), + base_url=plan.base_url, + timeout=plan.timeout, + expected_source_sha256=plan.source.sha256, + expected_spec_sha256=plan.spec_source.sha256, + confirmation_token="stale-token", + optimization_id="must-not-start", + ) + with mock.patch.object( + optimization, "build_runtime_plan", return_value=plan + ), mock.patch.object(optimization, "OptimizationRunner") as runner_class: + with self.assertRaises(optimization.simulation.InputError) as caught: + optimization.command_optimize(args) + + self.assertEqual(caught.exception.code, "OPTIMIZATION_CONFIRMATION_STALE") + self.assertIn("confirmationToken", caught.exception.details) + runner_class.assert_not_called() + + +class DeterministicRunnerTests(unittest.TestCase): + @staticmethod + def _run_mock_stagnating_search( + directory: Path, + population: list[list[float]], + ) -> tuple[ + optimization.OptimizationRunner, + str, + int, + list[dict[str, object]], + ]: + plan = _make_plan(directory, max_simulation_runs=6) + runner = optimization.OptimizationRunner(plan, "stagnation-test") + initial_trials: list[optimization.Trial] = [] + fresh_trials: dict[tuple[float, ...], optimization.Trial] = {} + events: list[dict[str, object]] = [] + + def evaluate( + normalized_vector: list[float], + *, + stage: str, + **_kwargs: object, + ) -> tuple[optimization.Trial, None]: + if stage != "search": + raise AssertionError("The search fixture only accepts search trials.") + proposal_index = runner.search_proposals + runner.optimizer_calls += 1 + runner.search_proposals += 1 + + if proposal_index < len(population): + key = tuple(normalized_vector) + trial = fresh_trials.get(key) + if trial is None: + runner.backend_submissions += 1 + runner.search_backend_submissions += 1 + parameter = optimization._candidate_parameters( + plan, normalized_vector + )["gain"] + trial = optimization.Trial( + evaluation_id=runner.backend_submissions, + stage="search", + simulation_id=( + f"stagnation-test.{runner.backend_submissions:04d}" + ), + parameters={"gain": parameter}, + status="completed", + duration_seconds=0.0, + objective_value=0.0, + objective_loss=0.0, + feasible=True, + total_constraint_violation=0.0, + ) + fresh_trials[key] = trial + runner.trials.append(trial) + else: + runner.cache_hits += 1 + initial_trials.append(trial) + return trial, None + + target_index = (proposal_index - len(population)) % len(population) + runner.cache_hits += 1 + return initial_trials[target_index], None + + with mock.patch.object( + runner, + "_initial_population", + return_value=copy.deepcopy(population), + ), mock.patch.object( + runner, + "_evaluate", + side_effect=evaluate, + ), mock.patch.object( + runner, + "_checkpoint", + ), mock.patch.object( + runner, + "_emit", + side_effect=lambda payload: events.append(dict(payload)), + ): + reason, generations = runner._search() + + return runner, reason, generations, events + + def test_one_dimensional_initial_population_contains_baseline_and_endpoints( + self, + ) -> None: + cases = ( + ("interior", 0.0, 4.0), + ("baseline-at-lower", 2.0, 4.0), + ("baseline-at-upper", 0.0, 2.0), + ) + with tempfile.TemporaryDirectory() as directory_text: + root = Path(directory_text) + for label, lower, upper in cases: + with self.subTest(label=label): + plan = _make_plan( + root / label, + lower=lower, + upper=upper, + ) + runner = optimization.OptimizationRunner(plan, label) + population = runner._initial_population( + optimization.random.Random(2026) + ) + + self.assertEqual( + population[0], + optimization._normalized_initial(plan), + ) + self.assertEqual( + len(population), + plan.spec.algorithm.population_size, + ) + self.assertIn([0.0], population) + self.assertIn([1.0], population) + mapped = [ + optimization._candidate_parameters(plan, vector)["gain"] + for vector in population + ] + self.assertIn(lower, mapped) + self.assertIn(upper, mapped) + self.assertEqual( + len({tuple(vector) for vector in population}), + len(population), + ) + + def test_de_generation_uses_a_frozen_donor_population(self) -> None: + class FixedRandom: + def __init__(self, _seed: int) -> None: + pass + + @staticmethod + def sample(values: list[int], count: int) -> list[int]: + return values[:count] + + @staticmethod + def randrange(_stop: int) -> int: + return 0 + + @staticmethod + def random() -> float: + return 0.0 + + with tempfile.TemporaryDirectory() as directory_text: + plan = _make_plan(Path(directory_text), max_simulation_runs=9) + runner = optimization.OptimizationRunner(plan, "deferred-update-test") + population = [[0.1], [0.2], [0.3], [0.4]] + candidates: list[list[float]] = [] + + def evaluate( + normalized_vector: list[float], + *, + stage: str, + **_kwargs: object, + ) -> tuple[optimization.Trial, None]: + self.assertEqual(stage, "search") + runner.optimizer_calls += 1 + runner.search_proposals += 1 + runner.backend_submissions += 1 + runner.search_backend_submissions += 1 + evaluation_id = runner.backend_submissions + if evaluation_id > len(population): + candidates.append(list(normalized_vector)) + loss = 100.0 + evaluation_id if evaluation_id <= 4 else 0.0 + trial = optimization.Trial( + evaluation_id=evaluation_id, + stage="search", + simulation_id=f"deferred-update-test.{evaluation_id:04d}", + parameters=optimization._candidate_parameters( + plan, normalized_vector + ), + status="completed", + duration_seconds=0.0, + objective_value=loss, + objective_loss=loss, + feasible=True, + total_constraint_violation=0.0, + ) + runner.trials.append(trial) + return trial, None + + with mock.patch.object( + runner, + "_initial_population", + return_value=copy.deepcopy(population), + ), mock.patch.object( + runner, + "_evaluate", + side_effect=evaluate, + ), mock.patch.object( + runner, + "_checkpoint", + ), mock.patch.object( + runner, + "_emit", + ), mock.patch.object( + optimization.random, + "Random", + FixedRandom, + ): + reason, generations = runner._search() + + self.assertEqual(reason, "simulationBudgetExhausted") + self.assertEqual(generations, 1) + self.assertEqual(len(candidates), 4) + self.assertAlmostEqual(candidates[0][0], 0.12) + self.assertAlmostEqual(candidates[1][0], 0.02) + + def test_three_empty_generations_report_population_collapse_as_stagnation( + self, + ) -> None: + with tempfile.TemporaryDirectory() as directory_text: + runner, reason, generations, events = self._run_mock_stagnating_search( + Path(directory_text), + [[0.5], [0.5], [0.5], [0.5]], + ) + + self.assertEqual( + reason, + "populationCollapsedAfterDuplicateStagnation", + ) + self.assertEqual( + generations, + optimization.NO_NEW_SUBMISSION_GENERATION_LIMIT, + ) + counts = runner._counts_payload() + self.assertEqual(counts["searchBackendSubmissions"], 1) + self.assertEqual(counts["verificationBackendSubmissions"], 0) + self.assertEqual(counts["cacheHits"], 15) + self.assertEqual(counts["generationsWithNewBackendSubmissions"], 0) + self.assertEqual(counts["generationsWithoutNewBackendSubmissions"], 3) + self.assertEqual(counts["endingNoNewSubmissionGenerationStreak"], 3) + self.assertEqual(counts["finalPopulationUniqueCandidates"], 1) + summary = runner._search_summary(reason) + self.assertFalse(summary["searchConvergenceEstablished"]) + self.assertTrue(summary["populationCollapsedToSingleCandidate"]) + self.assertEqual(summary["terminationCategory"], "stagnation") + + generation_events = [ + event + for event in events + if event.get("event") == "optimization-generation-completed" + ] + self.assertEqual(len(generation_events), 3) + self.assertEqual( + [ + event["backendSubmissionsThisGeneration"] + for event in generation_events + ], + [0, 0, 0], + ) + self.assertEqual( + [ + event["consecutiveGenerationsWithoutNewBackendSubmissions"] + for event in generation_events + ], + [1, 2, 3], + ) + + def test_three_empty_generations_report_duplicate_proposal_stagnation( + self, + ) -> None: + with tempfile.TemporaryDirectory() as directory_text: + runner, reason, generations, _events = ( + self._run_mock_stagnating_search( + Path(directory_text), + [[0.0], [0.25], [0.5], [1.0]], + ) + ) + + self.assertEqual(reason, "duplicateProposalStagnation") + self.assertEqual( + generations, + optimization.NO_NEW_SUBMISSION_GENERATION_LIMIT, + ) + counts = runner._counts_payload() + self.assertEqual(counts["searchBackendSubmissions"], 4) + self.assertEqual(counts["verificationBackendSubmissions"], 0) + self.assertEqual(counts["cacheHits"], 12) + self.assertEqual(counts["generationsWithNewBackendSubmissions"], 0) + self.assertEqual(counts["generationsWithoutNewBackendSubmissions"], 3) + self.assertEqual(counts["maxNoNewSubmissionGenerationStreak"], 3) + self.assertEqual(counts["finalPopulationUniqueCandidates"], 4) + summary = runner._search_summary(reason) + self.assertFalse(summary["searchConvergenceEstablished"]) + self.assertFalse(summary["populationCollapsedToSingleCandidate"]) + self.assertEqual( + summary["terminationCategory"], + "stagnation", + ) + + @staticmethod + def _run_mock_optimization( + directory: Path, + *, + seed: int, + change_source_after_artifacts: bool = False, + force_infeasible: bool = False, + ) -> tuple[dict[str, object], list[dict[str, object]], optimization.RuntimePlan]: + plan = _make_plan(directory, seed=seed, max_simulation_runs=6) + runner = optimization.OptimizationRunner(plan, "deterministic-test") + submissions: list[dict[str, object]] = [] + + def simulation_result( + _base_url: str, + xml: bytes, + simulation_id: str, + _timeout: float, + _progress_path: Path, + **kwargs: object, + ) -> tuple[dict[str, object], None]: + parameter_values = _xml_parameter_values(xml) + gain = float(parameter_values[("component-a", "gain")]) + submissions.append( + {"simulationId": simulation_id, "gain": gain, "xml": xml} + ) + event_sink = kwargs.get("event_sink") + if callable(event_sink): + event_sink({"event": "progress", "progress": 0.5}) + objective = (gain - 0.3) ** 2 + return ( + { + "status": "completed", + "success": True, + "variables": copy.deepcopy(RESULT_VARIABLES), + "series": { + "time": [0.0, 1.0], + "sensor.output": [objective, objective], + "sensor.limit": ( + [10.0, 10.0] if force_infeasible else [gain, gain] + ), + }, + }, + None, + ) + + original_write_best_artifacts = runner._write_best_artifacts + + def write_best_artifacts_then_change_source( + *args: object, **kwargs: object + ) -> dict[str, object]: + artifacts = original_write_best_artifacts(*args, **kwargs) + plan.source.path.write_bytes(plan.source.raw + b"\n") + return artifacts + + artifact_context = ( + mock.patch.object( + runner, + "_write_best_artifacts", + side_effect=write_best_artifacts_then_change_source, + ) + if change_source_after_artifacts + else contextlib.nullcontext() + ) + + with mock.patch.object( + optimization.simulation, + "_read_simulation_stream", + side_effect=simulation_result, + ), mock.patch.object( + optimization.simulation, + "_download_csv", + return_value=b"time,sensor.output,sensor.limit\n0,0,0\n", + ), mock.patch.object(optimization.simulation, "emit_json"), artifact_context: + result = runner.run() + return result, submissions, plan + + def test_de_seed_budget_fresh_verification_and_artifacts(self) -> None: + with tempfile.TemporaryDirectory() as directory_text: + root = Path(directory_text) + first, first_submissions, first_plan = self._run_mock_optimization( + root / "first", seed=8675309 + ) + second, second_submissions, _ = self._run_mock_optimization( + root / "second", seed=8675309 + ) + + first_search = [ + trial["parameters"] + for trial in first["trials"] + if trial["stage"] == "search" + ] + second_search = [ + trial["parameters"] + for trial in second["trials"] + if trial["stage"] == "search" + ] + self.assertEqual(first_search, second_search) + self.assertEqual( + [item["gain"] for item in first_submissions[:-1]], + [item["gain"] for item in second_submissions[:-1]], + ) + + self.assertEqual(first["optimizationResultSchemaVersion"], 2) + self.assertEqual(first["solutionStatus"], "verified") + self.assertIn("proof", first["claim"]) + self.assertIn("global optimality", first["claim"]) + self.assertEqual( + first["objectiveEndpointTrend"]["status"], + "insufficientData", + ) + self.assertFalse( + first["objectiveEndpointTrend"]["steadyStateProven"] + ) + self.assertEqual(first["planHash"], first_plan.confirmation_token) + self.assertEqual( + first["continuityPolicy"]["id"], + optimization.CONTINUITY_POLICY, + ) + self.assertTrue( + first["continuityPolicy"]["confirmedUserAssertion"] + ) + self.assertEqual( + first["continuityPolicy"]["designVariableIds"], + ["gain"], + ) + self.assertEqual(first["terminationReason"], "simulationBudgetExhausted") + self.assertEqual(first["counts"]["generations"], 0) + self.assertEqual(first["counts"]["backendSubmissions"], 6) + self.assertEqual( + first["counts"]["backendSubmissionSlotsConsumed"], + 6, + ) + self.assertEqual(first["counts"]["searchBackendSubmissions"], 5) + self.assertEqual( + first["counts"]["searchSubmissionSlotsConsumed"], + 5, + ) + self.assertEqual( + first["counts"]["verificationBackendSubmissions"], + 1, + ) + self.assertEqual( + first["counts"]["cacheHits"], + first["search"]["cacheHits"], + ) + self.assertEqual(first["search"]["backendSubmissions"], 5) + self.assertEqual(first["search"]["budget"]["used"], 5) + self.assertTrue(first["search"]["budget"]["exhausted"]) + self.assertEqual(first["verification"]["backendSubmissions"], 1) + self.assertEqual( + first["verification"]["submissionSlotsConsumed"], + 1, + ) + self.assertEqual(len(first_submissions), 6) + self.assertEqual( + sum(trial["stage"] == "search" for trial in first["trials"]), + 5, + ) + self.assertEqual(first["trials"][-1]["stage"], "verification") + self.assertTrue(first["verification"]["passed"]) + self.assertTrue(first["verification"]["sourceUnchanged"]) + self.assertEqual( + first_submissions[-1]["gain"], + first["bestSearch"]["parameters"]["gain"], + ) + self.assertEqual( + first["verifiedBest"]["parameters"], + first["bestSearch"]["parameters"], + ) + + self.assertTrue(first["source"]["unchanged"]) + self.assertEqual( + first_plan.source.path.read_bytes(), first_plan.source.raw + ) + + expected_artifacts = { + "plan", + "bestParameters", + "bestSystemXml", + "bestProject", + "result", + "csv", + "charts", + "report", + "evaluations", + "events", + "checkpoint", + } + self.assertEqual(set(first["artifacts"]), expected_artifacts) + for name, artifact in first["artifacts"].items(): + artifacts = artifact if name == "charts" else [artifact] + for item in artifacts: + path = Path(item["path"]) + data = path.read_bytes() + self.assertEqual(item["sizeBytes"], len(data)) + self.assertEqual(item["sha256"], hashlib.sha256(data).hexdigest()) + + for filename in ( + "optimization-plan.json", + "optimization-result.json", + "report.md", + "best-parameters.json", + "best-project.json", + "best-system.xml", + "result.json", + "results.csv", + ): + self.assertTrue((first_plan.output_directory / filename).is_file()) + + optimized_project = json.loads( + (first_plan.output_directory / "best-project.json").read_text( + encoding="utf-8" + ) + ) + expected_project = copy.deepcopy(PROJECT) + expected_project["nodes"][0]["data"]["parameters"]["gain"] = first[ + "verifiedBest" + ]["parameters"]["gain"] + self.assertEqual(optimized_project, expected_project) + + def test_source_change_during_fresh_verification_invalidates_solution(self) -> None: + with tempfile.TemporaryDirectory() as directory_text: + root = Path(directory_text) + plan = _make_plan(root, seed=42, max_simulation_runs=5) + runner = optimization.OptimizationRunner(plan, "source-race-test") + submissions = 0 + + def simulation_result( + _base_url: str, + xml: bytes, + _simulation_id: str, + _timeout: float, + _progress_path: Path, + **_kwargs: object, + ) -> tuple[dict[str, object], None]: + nonlocal submissions + submissions += 1 + gain = float(_xml_parameter_values(xml)[("component-a", "gain")]) + if submissions == plan.spec.budget.max_simulation_runs: + plan.source.path.write_bytes(plan.source.raw + b"\n") + return ( + { + "status": "completed", + "success": True, + "variables": copy.deepcopy(RESULT_VARIABLES), + "series": { + "time": [0.0, 1.0], + "sensor.output": [gain, gain], + "sensor.limit": [gain, gain], + }, + }, + None, + ) + + with mock.patch.object( + optimization.simulation, + "_read_simulation_stream", + side_effect=simulation_result, + ), mock.patch.object(optimization.simulation, "emit_json"): + result = runner.run() + + self.assertEqual(result["solutionStatus"], "verificationFailed") + self.assertFalse(result["verification"]["passed"]) + self.assertFalse(result["verification"]["sourceUnchanged"]) + self.assertFalse(result["source"]["unchanged"]) + self.assertFalse((plan.output_directory / "best-project.json").exists()) + self.assertFalse((plan.output_directory / "best-system.xml").exists()) + + def test_source_change_while_writing_artifacts_removes_best_outputs(self) -> None: + with tempfile.TemporaryDirectory() as directory_text: + result, _submissions, plan = self._run_mock_optimization( + Path(directory_text), + seed=7, + change_source_after_artifacts=True, + ) + + self.assertEqual(result["solutionStatus"], "verificationFailed") + self.assertFalse(result["verification"]["passed"]) + self.assertFalse(result["source"]["unchanged"]) + self.assertEqual(result["artifacts"].keys(), { + "plan", + "report", + "evaluations", + "events", + "checkpoint", + }) + for filename in ( + "best-parameters.json", + "best-system.xml", + "best-project.json", + "result.json", + "results.csv", + ): + self.assertFalse((plan.output_directory / filename).exists()) + + def test_late_best_artifact_export_failure_removes_all_best_outputs(self) -> None: + failures = ( + ("csv", "_download_csv", "CSV_EXPORT_FAILED"), + ("charts", "_write_charts", "CHART_EXPORT_FAILED"), + ) + for label, failing_export, expected_code in failures: + with self.subTest( + export=label + ), tempfile.TemporaryDirectory() as directory_text: + plan = _make_plan(Path(directory_text), max_simulation_runs=5) + plan.output_directory.mkdir() + runner = optimization.OptimizationRunner(plan, "artifact-failure-test") + trial = optimization.Trial( + evaluation_id=5, + stage="verification", + simulation_id="artifact-failure-test.0005", + parameters={"gain": 1.0}, + status="completed", + duration_seconds=0.1, + objective_value=1.0, + objective_loss=1.0, + feasible=True, + total_constraint_violation=0.0, + ) + result = { + "status": "completed", + "success": True, + "variables": copy.deepcopy(RESULT_VARIABLES), + "series": { + "time": [0.0, 1.0], + "sensor.output": [1.0, 1.0], + "sensor.limit": [1.0, 1.0], + }, + } + csv_patch = ( + mock.patch.object( + optimization.simulation, + "_download_csv", + side_effect=optimization.simulation.BackendError( + "CSV_EXPORT_FAILED", "forced CSV export failure" + ), + ) + if failing_export == "_download_csv" + else mock.patch.object( + optimization.simulation, + "_download_csv", + return_value=b"time,sensor.output,sensor.limit\n0,1,1\n", + ) + ) + chart_patch = ( + mock.patch.object( + optimization.simulation, + "_write_charts", + side_effect=optimization.simulation.ArtifactError( + "CHART_EXPORT_FAILED", "forced chart export failure" + ), + ) + if failing_export == "_write_charts" + else contextlib.nullcontext() + ) + + with csv_patch, chart_patch, self.assertRaises( + optimization.simulation.SkillCliError + ) as caught: + runner._write_best_artifacts(trial, result) + + self.assertEqual(caught.exception.code, expected_code) + for filename in ( + "best-parameters.json", + "best-system.xml", + "best-project.json", + "result.json", + "results.csv", + ): + self.assertFalse((plan.output_directory / filename).exists()) + self.assertEqual( + list(plan.output_directory.glob("curve-*.svg")), [] + ) + + def test_no_feasible_candidate_reports_diagnostic_without_best_files(self) -> None: + with tempfile.TemporaryDirectory() as directory_text: + result, submissions, plan = self._run_mock_optimization( + Path(directory_text), seed=9, force_infeasible=True + ) + + self.assertEqual(result["solutionStatus"], "noFeasibleCandidate") + self.assertIn("no complete feasible candidate", result["claim"]) + self.assertEqual(result["counts"]["backendSubmissions"], 5) + self.assertEqual(len(submissions), 5) + self.assertIsNone(result["bestSearch"]) + self.assertIsNotNone(result["bestDiagnosticSearch"]) + self.assertFalse(result["bestDiagnosticSearch"]["feasible"]) + self.assertIsNone(result["verifiedBest"]) + self.assertNotIn("bestProject", result["artifacts"]) + self.assertFalse((plan.output_directory / "best-project.json").exists()) + report = (plan.output_directory / "report.md").read_text( + encoding="utf-8" + ) + self.assertNotIn("搜索阶段最佳可行点", report) + self.assertIn("约束违反最小的完整诊断点(不可行)", report) + self.assertIn("它不是可行方案", report) + + def test_interrupted_stream_keeps_id_available_for_backend_cancel(self) -> None: + with tempfile.TemporaryDirectory() as directory_text: + runner = optimization.OptimizationRunner( + _make_plan(Path(directory_text)), "cancel-test" + ) + with mock.patch.object( + optimization.simulation, + "_read_simulation_stream", + side_effect=KeyboardInterrupt, + ), mock.patch.object( + optimization.simulation, "emit_json" + ), mock.patch.object( + optimization.simulation, "http_json" + ) as http_json: + with self.assertRaises(KeyboardInterrupt): + runner._evaluate([0.5], stage="search") + self.assertEqual(runner.current_simulation_id, "cancel-test.0001") + runner.cancel_active() + + http_json.assert_called_once() + self.assertIsNone(runner.current_simulation_id) + + def test_completed_but_unsuccessful_baseline_is_recorded_as_failed(self) -> None: + with tempfile.TemporaryDirectory() as directory_text: + plan = _make_plan(Path(directory_text), max_simulation_runs=5) + runner = optimization.OptimizationRunner(plan, "failed-baseline-test") + failed_result = { + "status": "completed", + "success": False, + "message": "solver rejected the baseline", + } + with mock.patch.object( + optimization.simulation, + "_read_simulation_stream", + return_value=(failed_result, None), + ), mock.patch.object(optimization.simulation, "emit_json"): + with self.assertRaises(optimization.OptimizationError) as caught: + runner.run() + + self.assertEqual(caught.exception.code, "OPTIMIZATION_BASELINE_FAILED") + self.assertEqual(len(runner.trials), 1) + self.assertEqual(runner.trials[0].status, "failed") + checkpoint = json.loads( + (plan.output_directory / "checkpoint.json").read_text( + encoding="utf-8" + ) + ) + self.assertEqual(checkpoint["counts"]["completedTrials"], 0) + self.assertEqual(checkpoint["counts"]["failedTrials"], 1) + + def test_completed_contract_error_is_recorded_before_abort(self) -> None: + with tempfile.TemporaryDirectory() as directory_text: + plan = _make_plan(Path(directory_text), max_simulation_runs=5) + runner = optimization.OptimizationRunner(plan, "contract-error-test") + variables = copy.deepcopy(RESULT_VARIABLES) + variables[0]["unit"] = "cm" + invalid_result = { + "status": "completed", + "success": True, + "variables": variables, + "series": { + "time": [0.0, 1.0], + "sensor.output": [1.0, 1.0], + "sensor.limit": [1.0, 1.0], + }, + } + with mock.patch.object( + optimization.simulation, + "_read_simulation_stream", + return_value=(invalid_result, None), + ), mock.patch.object(optimization.simulation, "emit_json"): + with self.assertRaises(optimization.OptimizationError) as caught: + runner.run() + + self.assertEqual( + caught.exception.code, "OPTIMIZATION_RESULT_METADATA_MISMATCH" + ) + self.assertEqual(runner.backend_submissions, 1) + self.assertEqual(len(runner.trials), 1) + self.assertEqual( + runner.trials[0].failure_code, + "OPTIMIZATION_RESULT_METADATA_MISMATCH", + ) + evaluations = ( + plan.output_directory / "evaluations.csv" + ).read_text(encoding="utf-8") + self.assertIn("OPTIMIZATION_RESULT_METADATA_MISMATCH", evaluations) + + +if __name__ == "__main__": # pragma: no cover + unittest.main()