From 03b86f52ba9d69d6c9722c18d1eb0a7f0210e058 Mon Sep 17 00:00:00 2001 From: ljz <425868052@qq.com> Date: Wed, 2 Sep 2026 13:48:51 +0800 Subject: [PATCH 1/2] =?UTF-8?q?P0=E8=B7=A8=E5=B9=B3=E5=8F=B0=E6=9A=82?= =?UTF-8?q?=E5=AD=98=E9=97=AE=E9=A2=98=E8=A7=A3=E5=86=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitattributes | 7 + .github/workflows/solver-regression.yml | 23 + docs/README.md | 4 + .../C语言数值内核实施计划与可行性评估.md | 406 ++++++++++++++++++ tests/test_regression_fixture_line_endings.py | 85 ++++ 5 files changed, 525 insertions(+) create mode 100644 docs/other/C语言数值内核实施计划与可行性评估.md create mode 100644 tests/test_regression_fixture_line_endings.py diff --git a/.gitattributes b/.gitattributes index faa300a..4c28bac 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,10 @@ *.bat text eol=crlf *.cmd text eol=crlf *.sh text eol=lf + +# Regression manifests hash these files as raw bytes. Keep their checkout +# representation identical on Windows and Linux so hashes remain portable. +tests/data/test-mql-8.xml text eol=lf +tests/data/test-mql-8.json text eol=lf +tests/data/test_mql-full-branches-01-04.xml text eol=lf +tests/baselines/simulation/**/*.json text eol=lf diff --git a/.github/workflows/solver-regression.yml b/.github/workflows/solver-regression.yml index b9eb402..b3d58f6 100644 --- a/.github/workflows/solver-regression.yml +++ b/.github/workflows/solver-regression.yml @@ -8,6 +8,7 @@ on: - "requirements.txt" - "constraints/**" - ".python-version" + - ".gitattributes" - "README.md" - ".github/workflows/solver-regression.yml" pull_request: @@ -17,6 +18,7 @@ on: - "requirements.txt" - "constraints/**" - ".python-version" + - ".gitattributes" - "README.md" - ".github/workflows/solver-regression.yml" schedule: @@ -60,6 +62,27 @@ permissions: contents: read jobs: + fixture-byte-contract: + if: >- + github.event_name == 'push' || + github.event_name == 'pull_request' || + (github.event_name == 'workflow_dispatch' && inputs.suite == 'quick') + strategy: + fail-fast: false + matrix: + os: + - ubuntu-24.04 + - windows-2022 + runs-on: ${{ matrix.os }} + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version-file: .python-version + - name: Verify portable regression fixture bytes + run: python -m unittest tests.test_regression_fixture_line_endings + quick: if: >- github.event_name == 'push' || diff --git a/docs/README.md b/docs/README.md index 6ef2807..3103141 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,6 +12,10 @@ 新增或移动文档时,应根据文档用途放入对应目录。目录链接可用于查看其中的全部文档,无需在本文件中逐项维护清单。 +## 重点实施计划 + +- [C 语言数值内核实施计划与可行性评估](other/C语言数值内核实施计划与可行性评估.md):后端数值内核的分阶段迁移顺序、验收门、风险和可行性评估。 + ## `update-log` 书写规范 以下规范适用于新建和后续追加的日志。历史日志缺少准确完成时间时,不猜测或补写时间。 diff --git a/docs/other/C语言数值内核实施计划与可行性评估.md b/docs/other/C语言数值内核实施计划与可行性评估.md new file mode 100644 index 0000000..03be492 --- /dev/null +++ b/docs/other/C语言数值内核实施计划与可行性评估.md @@ -0,0 +1,406 @@ +# C 语言数值内核实施计划与可行性评估 + +> 文档状态:待评审实施方案 +> 建立日期:2026-09-02 +> 适用分支:`model-development` +> 关联文档:[求解器性能优化任务清单](求解器性能优化任务清单.md)、[后端求解逻辑与效率优化调研](后端求解逻辑与效率优化调研.md) +> 范围:后端数值执行内核;不包含前端重写,也不主张把整个 Python 后端改写为 C + +## 1. 结论 + +这条路线值得实施,但正确的目标不是“把现有 Python 代码逐行翻译成 C”,而是: + +1. 保留 Python 作为模型解析、网络编译、任务管理、结果服务和正确性参考; +2. 先把完整的一次 RHS 计算编译成无 Python 回调的扁平数值 IR; +3. 再让 C 内核一次完成整次 RHS、stream、物性、Jacobian 和事件计算; +4. Python 旧引擎长期保留为不支持模型的兼容路径和故障回退路径。 + +总体技术可行性为**中高**。仓库已经具备因果计划、参考 IR、稀疏 Jacobian 结构、性能埋点和 AMESim 回归基础,因此不是从零开始。主要难点不是 C 语法,而是完整冻结当前模型的计算顺序、副作用、事件和回滚语义。 + +“实现稳定的 C 数值后端”具有较高可能性;“仅靠换成 C 就达到接近 AMESim 的速度”目前不能承诺。高刚度 LSTP 接触会让积分器产生大量纳秒级小步,有限差分 Jacobian 又会重复调用 RHS。C 能降低每次计算的成本,但不会自动减少这些计算次数。真正的大幅收益需要同时完成: + +- 完整扁平 IR; +- 整体 C RHS; +- 稀疏解析或半解析 Jacobian; +- 正确的接触事件和模式切换; +- 必要时再评估原生积分器。 + +## 2. 推荐的最终边界 + +```text +XML / JSON + ↓ +Python:解析、校验、组件注册、网络编译 + ↓ +完整扁平 IR:槽位、阶段、依赖、模式、事件、Jacobian 结构 + ↓ 一次跨语言调用完成整次计算 +C 数值内核:RHS / stream / 物性 / Jacobian / Event + ↓ +SciPy BDF(首阶段保留)或后续通过验证的原生积分器 + ↓ +Python:采样、结果编码、API、任务状态和诊断 +``` + +以下边界必须坚持: + +- 不在每个组件上来回调用 Python 和 C;一次 RHS 最多进行一次主要跨语言调用。 +- C 兼容模型的热路径内不得调用 Python callback。 +- C 内核不直接解析 XML,不管理 HTTP,也不承担组件编辑和数据库职责。 +- 自定义 Python 组件不能静默降级为跨语言逐组件调用;只要整模型不满足原生能力合同,就明确走 Python 引擎。 +- 迁移期间始终保留 `python`、`native`、`shadow` 三种模式;通过长时发布门后才能增加 `auto` 默认选择。 + +建议统一配置为: + +```text +SIMULATION_NUMERIC_ENGINE=python|native|shadow|auto +``` + +其中 `shadow` 由 Python 控制正式积分,C 只接收相同输入并逐阶段比较,不允许影响 Python 工作区。 + +## 3. 当前基础与首个阻断 + +### 3.1 已有基础 + +- `causal_ir.py` 已有 schema v1、结构签名、预分配工作区、阶段观察和事务回滚原型。 +- 当前因果执行器已经消去一部分重复逻辑坐标,并证明了数组化和预编译执行计划的方向有效。 +- 已有 Jacobian 稀疏图、局部切向原语和显式实验回退路径。 +- 已有 AMESim 结果读取、物理投影、递进时域 runner、性能统计、取消和故障注入测试。 +- 当前主模型约有 156 个运行组件、20 种组件类型和 132 个连续状态,适合用“能力矩阵 + 分阶段覆盖”推进。 + +### 3.2 当前 IR 的缺口 + +现有 schema v1 只覆盖全局因果代数计划,绑定仍依赖 Python 的 reader、writer 和 evaluator 回调。以下内容尚未进入同一个无回调执行计划: + +- 状态写入和信号传播; +- dynamic volume 和物性状态包; +- secondary 压力/流量块; +- stream SCC/DAG 和热流体外层闭合; +- 状态导数; +- 离散 mode、事件检测、reset 和重启; +- Jacobian 数值填充; +- 结果投影。 + +因此,当前 IR 不能直接包一层 C 接口后就获得预期收益。 + +### 3.3 P0 跨平台换行阻断(已解决) + +当前 Windows 工作树中的权威输入按原始字节计算时与 manifest 不一致;进一步核对确认,差异全部来自 Git 检出后的 `CRLF` 换行,而不是模型内容变化: + +| 文件 | Windows 工作树原始字节 | 将 `CRLF` 还原为 `LF` 后 | manifest 记录值 | +| --- | --- | --- | --- | +| `tests/data/test-mql-8.xml` | 106,188 B;2,078 个 `CRLF`;SHA-256 `e7eef641...b801` | 104,110 B;`0a2d9331...7b0b` | 104,110 B;`0a2d9331...7b0b` | +| `tests/data/test-mql-8.json` | 282,796 B;10,204 个 `CRLF`;SHA-256 `51e1acb3...11e2` | 272,592 B;`b44bf540...fbe0` | 272,592 B;`b44bf540...fbe0` | +| `AmesimModels/test_mql.ame` | 21,708,800 B;`cbc3aadd...c20fbb` | 不适用 | 一致 | + +因此没有证据表明 fixture 语义发生漂移,也不应仅因这一差异重建 golden。真正的问题是:manifest 按原始字节锁定输入,而 `.gitattributes` 没有固定这两个文本 fixture 的换行;当前加载器又直接校验工作树原始字节,所以同一提交在 Windows 上可能被拒绝、在 Linux 上通过。 + +该阻断已于 2026-09-02 完成本地修复:`.gitattributes` 已将两个主模型输入、历史 0.81 s 输入和 `tests/baselines/simulation` 下的 JSON 证据固定为 `LF`,当前 Windows 工作树也已从 Git 索引重新检出为 `LF`。自动测试会同时检查属性规则、禁止原始 `CR` 字节,并按 manifest 复核输入大小和 SHA-256;CI 已配置为在 Ubuntu 和 Windows 上分别执行这一合同,远端运行证据待提交后取得。 + +本地验收显示这些文件均为 `index=LF / worktree=LF / eol=lf`,两个 regression manifest 可在 Windows 正常完整加载,现有 manifest 和 golden 无需更新。未来只有在规范化后的内容确实变化时,才进入“模型变更、重建 manifest/golden”的流程。历史性能数字仍需按其代码版本看待,但不因换行差异失效。 + +## 4. 按重要性排序的实施计划 + +优先级定义: + +- `P0`:正确性和架构前置,不完成就不能安全编写生产 C 内核; +- `P1`:形成可用且有明显收益的 C 后端; +- `P2`:P1 证明有效后再实施的增强项。 + +表中的工期是单名熟悉现有求解器的开发者的粗略有效工作量,不是交付承诺,也不包含长时仿真排队时间。 + +| 顺序 | ID | 优先级 | 工作项 | 可行性 | 粗略工作量 | 主要依赖 | +| ---: | --- | --- | --- | --- | --- | --- | +| 1 | C-00 | P0 | 固定权威基准的跨平台合同,建立双引擎与回滚合同 | 高 | 1–2 人周 | 现有 OPT-00 | +| 2 | C-01 | P0 | 定义完整数值 IR schema v2 | 高 | 3–5 人周 | C-00、OPT-01/02 | +| 3 | C-02 | P0 | 建立纯数值组件 kernel 合同和能力矩阵 | 中高 | 3–6 人周 | C-01 | +| 4 | C-03 | P0 | 完成 Python 扁平参考执行器与 Shadow 差分 | 中高 | 3–5 人周 | C-01、C-02 | +| 5 | C-04 | P1 | 稳定 C ABI、构建链和隔离 worker | 高 | 3–5 人周 | C-01,可并行 | +| 6 | C-05 | P1 | 完成一个闭环代表子系统的 C 纵向切片 | 高 | 2–4 人周 | C-03、C-04 | +| 7 | C-06 | P1 | 扩展到完整内置 RHS、stream 和物性 | 中高 | 5–9 人周 | C-05、OPT-04 | +| 8 | C-07 | P1 | 稀疏解析/半解析 Jacobian 与局部回退 | 中 | 6–12 人周 | C-02、C-06、OPT-03 | +| 9 | C-08 | P1 | LSTP/MECMAS 事件、模式和默认灰度 | 中 | 4–8 人周 | C-06、C-07、OPT-05/06 | +| 10 | C-09 | P2 | 模型专用 C 代码生成和编译缓存 | 中 | 4–8 人周 | C-06~C-08 | +| 11 | C-10 | P2 | 评估 CVODE;有真实 DAE 需求时再评估 IDA | 中/当前低 | 4–10 人周 | C-06~C-08 | +| 12 | C-11 | P2 | 输出、部署、并发和发布收口 | 中高 | 3–6 人周 | 原生默认候选 | + +### 4.1 P0:先固定语义和参考答案 + +#### C-00 固定权威基准、双引擎和回滚合同 + +工作内容: + +- [x] 固定 `test-mql-8.xml/json`、历史输入和回归证据的跨平台 `LF` 字节合同,实际重新规范化当前 Windows 工作树,并用 Git EOL 状态、原始 SHA-256 和双平台 CI 保护该合同;未重建语义未变的 golden。 +- 重新加载 manifest 并执行 Python `0.01 s` smoke;只有发现规范化后的内容或物理结果确实变化时,才重新生成结构清单和 golden。 +- 冻结当前 Python 引擎为参考实现,定义 `python/native/shadow/auto` 的启用条件。 +- 原生不支持的模型只允许在编译或加载阶段整模型回退;正式验收时禁止静默回退。 +- 每个阶段使用独立小提交,记录输入哈希、环境、二进制哈希和回滚开关。 + +完成标准:相同输入能够稳定强制走 Python;关闭原生后行为与当前参考提交一致;模型、环境和结果来源都能追溯。 + +#### C-01 完整数值 IR schema v2 + +完整 IR 必须描述共享数值阶段,以及 RHS、Event、Jacobian 和输出四类独立的按需入口。它们可以复用同一套槽位和依赖信息,但不能被误实现成“每次 RHS 都顺序计算 Event、Jacobian 和输出”。共享 primal 计划为: + +```text +state scatter +→ signal +→ mechanical equivalence / dynamic volume +→ property bundle +→ first pressure-flow closure +→ stream SCC/DAG +→ temperature reference update +→ sensitive-island closure / thermofluid fixed point +→ mechanical acceleration +``` + +四类入口分别编译自己的依赖切片: + +```text +eval_rhs = required primal stages → derivative +eval_events = required primal stages → event values +eval_jacobian = RHS primal / local derivative or JVP → CSR values +eval_outputs = required primal stages → output projection +``` + +IR 至少描述: + +- 连续状态、代数坐标、参数、常量、离散模式和工作区槽位; +- 每个阶段和 opcode 的读写集合、单位、缩放、上下界和错误来源; +- stream 图、SCC、物性状态包、外层固定点和事务恢复集合; +- event ID、左/右状态、reset、Jacobian 失效和模式计划; +- 固定的 CSR Jacobian 结构和 output projection; +- IR schema、native ABI、组件模型/实现、介质、编译器、dtype、平台和构建选项组成的结构签名。 + +原生兼容 program 中不得存在 Python callback。schema v1 保留为参考适配器,不直接扩展成生产 ABI。 + +完成标准:同一模型重复编译得到字节级稳定的结构和签名;所有索引及读写集合可静态校验;缺少能力时明确拒绝编译。 + +#### C-02 纯数值组件合同 + +每个内置组件需声明: + +- `kernel_id` 和实现版本; +- 输入、输出、参数、状态和 mode 槽位; +- primal、局部导数/JVP、event 和 reset 能力; +- 支持的正反流、接触和物性范围; +- 类型化错误码和局部数值差分回退边界。 + +组件 kernel 必须满足“相同输入得到相同输出”,不能依赖隐藏的 Python 对象写入。旧组件类继续作为参考实现。 + +首批纵向切片建议选择一条完整的 `MECMAS21 → PNRP17 → PNCH012 → PNL0001/LSTP00A` 支路及其介质/stream 闭合,因为它同时覆盖机械、气动、物性、流量和高刚度接触。 + +#### C-03 Python 扁平参考执行器 + +先用 Python/NumPy 完整执行 schema v2,目标是验证语义,而不是追求最终速度。它应摆脱 `PortState` 作为主语义载体,能在每个阶段与当前对象引擎比较首个差异。 + +完成标准:代表模型在普通点、反向流点、事件左右和 Jacobian 扰动点逐槽一致;`0.01/0.2/0.81/2.10 s` 的状态、事件、守恒和物理投影满足现有合同。 + +### 4.2 P1:形成真正有价值的 C 后端 + +#### C-04 稳定 C ABI、构建链和隔离 worker + +底层使用稳定纯 C ABI,Python 绑定保持很薄。最小接口应包含: + +```text +model_create +eval_rhs +eval_jacobian +eval_events +apply_event +eval_outputs +model_destroy +``` + +接口使用不透明 `ModelContext`、固定宽度整数、连续 `float64` 数组和显式长度;CSR 结构在编译时固定,运行时只填 values。每个任务独占可变 context/workspace/cache/mode,不使用可变全局缓存,热路径预分配且不进行常规堆分配。 + +首阶段仍由 SciPy BDF 积分,只替换整个 RHS/Jacobian 计算。构建链需覆盖 Windows x64 和 Linux x86_64,固定构建依赖,并禁止 `/fp:fast`、`-ffast-math`、`-march=native` 等会改变事件边界或破坏可移植性的默认选项。 + +原生访问违规、崩溃或死循环不能在同一 Python 进程中安全恢复。因此原生默认启用前,仿真必须运行在可硬终止的隔离 worker 中;C 同时接收取消标志并在组件阶段、stream SCC、Jacobian 和迭代处有界检查。 + +#### C-05 闭环纵向切片和首次 Go/No-Go + +一次跨语言调用应完成代表子系统的整次 RHS,禁止按组件往返。先为该闭环子系统建立独立代表 fixture,执行 Shadow 和短时积分,不立即扩大组件覆盖。由于此阶段尚未覆盖主模型的全部组件,`test-mql-8` 整模型按能力合同回退 Python 是预期行为,不能拿它衡量 C-05 的端到端收益。 + +继续扩面的最低门槛: + +- RHS 微基准至少达到 Python 的 `2×`;目标值为 `3×` 以上; +- 独立代表 fixture 的短时完整仿真中位时间至少降低 25%; +- 代表 fixture 的状态、事件、模式和守恒门全部通过;存在对应 AMESim 投影时也必须通过; +- `nfev/njev/nlu` 和接受步等工作量没有无法解释的变化; +- 无 Python callback、无静默回退、无内存错误。 + +如果 RHS 已快很多而完整仿真几乎不变,应先检查 Jacobian、微步数和跨语言边界,而不是直接继续扩大 C 代码。 + +#### C-06 完整内置 RHS、stream 和物性 + +- 把所有受支持的内置组件纳入能力矩阵;一个组件不支持时整模型明确走 Python。 +- 将 stream 图编译成 SCC 和缩点 DAG:无环部分一次传播,只在循环 SCC 中迭代。 +- 将 `p/T/rho/u/h` 及其导数组成同一物性状态包,按精确输入和阶段统一复用。 +- 移除每轮临时字典/列表,使用预分配数组和原地误差统计。 +- 保留试探状态事务回滚、反向流、温度参考更新和类型化可恢复失败语义。 + +完成标准:主目标使用的全部内置组件可原生执行;stream 迭代、压力流量残差和物性结果不恶化;跨平台字节合同修复后的同一权威输入通过短程与中程回归,且 `0.2 s` 完整仿真中位时间至少降低 30%。 + +#### C-07 稀疏解析/半解析 Jacobian + +这是获得大幅端到端收益的关键步骤。当前历史报告中,有限差分 Jacobian 会贡献大量额外 RHS 调用;仅把 primal RHS 改成 C 仍会重复执行它。 + +- 组件局部导数沿完整 IR 传播,直接填充固定 CSR values。 +- 不支持或非光滑点只对局部组件、状态列或 SCC 做数值差分,不能让一个局部问题恢复全网有限差分。 +- 每次构建记录解析列、局部差分列、回退位置、JVP 审计和装配时间。 +- LSTP 接触、流向切换、饱和及临界流动使用分段导数,并在边界上明确选择事件分段或局部回退。 + +完成标准:有限差分附加 RHS 至少减少 50%,相对完整 C RHS 阶段再降低至少 20% 总时间;事件顺序、模式和物理投影不变。 + +#### C-08 事件、模式和默认灰度 + +- 将 LSTP/MECMAS 的 event、mode、reset、Jacobian 失效和 solver restart 纳入正式 IR/C 合同。 +- 对接触进入、保持、释放,上下端挡、回弹、同时事件、擦边事件和防抖分别测试。 +- 按 `shadow → 显式 native → 模型签名白名单 → auto` 逐级启用。 +- 只有当前权威 `10 s` 连续三次通过后,才允许受支持模型默认走 C。 + +需要特别注意:C-08 不只是把同一接触公式写成 C,还要减少因接触表达方式造成的不必要纳秒级微步。任何软化、容差或事件改动都属于数值算法变化,必须与纯执行优化分开提交和验收。 + +### 4.3 P2:证明 P1 有效后再做 + +#### C-09 模型专用 C 代码生成 + +通用 C IR 解释器稳定后,若 opcode 分派仍是明确热点,再为固定模型生成专用 C。XML 文本不得直接拼入源码;生成器只消费校验后的数值 IR。编译缓存键必须包含完整结构签名、编译器和 flags。 + +只有相对通用 C 执行器额外获得至少约 `1.5×` 的稳定收益,并且编译成本能在重复运行中摊销时才继续。否则保留通用执行器,放弃这一层复杂度。 + +#### C-10 原生积分器 + +- 当前系统是“RHS 内完成代数闭合”的半显式 ODE,先评估 CVODE,不直接切换为 DASSL/IDA。 +- 只有 C RHS、Jacobian、事件、可恢复缩步、partial result、activity 和 cancel 合同稳定后,才对 CVODE 做独立 A/B。 +- CVODE 在相同正确性门下额外降低至少 25% 总时间才值得替换 SciPy。 +- IDA/DASSL 需要新的 `F(t,y,ydot)=0`、代数变量布局、一致初始化、指数和质量矩阵合同。只有真实模型证明无法稳健因果化或 ODE 化时再立项,当前不把它视为提速开关。 + +#### C-11 输出、部署和发布收口 + +吸收按需结果变量、列式输出、结果分块、编译缓存、并发限流、worker 硬终止和原生崩溃恢复。确保 native 崩溃只影响当前任务,服务仍能接收新任务。 + +## 5. 分阶段验证与 Go/No-Go 门 + +| 验证阶段 | 主要内容 | 通过条件 | 未通过时的处理 | +| --- | --- | --- | --- | +| V0 | fixture、环境、Python 和 AMESim 基线 | 来源一致;Python 连续 3 次稳定;现有测试通过 | 基线仍漂移时停止 C 默认路径开发 | +| V1 | 完整 Python IR | 槽位、阶段、读写集合、事件和回滚差分通过 | 先修隐藏副作用和 IR 合同 | +| V2 | C 纵向切片 | 单组件/逐阶段通过;RHS 至少 `2×` | 未达门槛则检查 IR/FFI,必要时停止扩面 | +| V3 | 完整 C RHS + SciPy | `0.01/0.2 s` 通过;总时间至少降低 30%;工作量变化不超过可解释范围 | 不作为纯执行优化合入默认路径 | +| V4 | C Jacobian/Event | FD 附加 RHS 至少减少 50%;相对 V3 再降低 20%;事件/模式一致 | 保留局部差分,禁止整网静默回退 | +| V5 | 长时与鲁棒性 | `1/5/10 s` 递进;10 s 连续 3 次;无泄漏、无静默回退 | 任一短时前驱失败即停止后续长跑 | +| V6 | 默认启用 | Windows/Linux 发布通过;支持模型走 C,不支持模型明确走 Python;可一键关闭 | 保持显式 opt-in | + +正式性能比较统一要求:预热 1 次、完整仿真至少测量 3 次并比较中位数、固定机器与电源模式,冷编译和热缓存分开报告。至少记录: + +- 总时间、积分、RHS、闭合、stream、物性、Jacobian 和输出时间; +- `nfev/njev/nlu`、接受/拒绝步、solver 启动和事件次数; +- Jacobian 附加 RHS、局部数值差分和回退原因; +- Python/C 边界调用次数和耗时; +- 峰值 RSS、C 工作区、IR/ABI 版本、编译器、flags 和二进制哈希。 + +附加硬门:关闭 C 时 Python 路径额外开销不超过 2%;纯执行层替换若使 `nfev/njev/nlu` 或接受步数变化超过 2%,必须按数值语义变化单独调查,不能直接归为性能优化;峰值 RSS 不超过 Python 基线的 110%;10 s 中 C 固定工作区不随步数增长,只有结果数组可随采样数增长。 + +## 6. 正确性验证范围 + +### 6.1 Shadow 阶段观察点 + +两个引擎必须接收完全相同的 `time/state/mode/parameters`,按第 4.1 节的 RHS 阶段逐项比较。差异报告至少包含:阶段、槽位、组件、输入、Python 值、C 值、绝对/相对误差和当前模式。 + +- 槽位布局、模式码、事件 ID、执行顺序和迭代次数要求一致。 +- 连续量使用“每种物理量绝对容差 + 相对容差”,不能用一个绝对容差覆盖压力、流量、温度和位移。 +- NaN/Inf、压力越界、符号错误和模式不同立即失败。 +- 压力流量最大缩放残差继续使用现有 `1e-7` 门。 +- 事件点分别比较左极限、事件处理结果和右极限,不扩大容差掩盖不连续。 + +差分语料至少覆盖:初始状态、接受状态、Jacobian 扰动状态、固定随机种子扰动、压力近似相等、流量换向、接触启停、端挡释放、物性边界,以及历史 `0.69/0.81/2.05 s` 区域。 + +### 6.2 AMESim 物理门 + +Python golden 只用于发现实现漂移,AMESim 结果仍是外部物理基线。正式默认前,应在现有 physical-state-v2.1 基础上至少覆盖: + +- 代表性气室压力、温度和质量; +- PNL 管路压力、流量及换向; +- PNRP 活塞力; +- MECMAS 位移、速度和模式; +- LSTP 间隙、接触力和接触切换; +- 每个主要支路至少一个代表量。 + +检查点至少包含 `0`、`0.04 s` 左右、`0.2`、`0.8 s` 左右、`1`、`2`、`5` 和 `10 s`。AMESim 未保存的内部守恒量继续执行独立绝对残差门。 + +### 6.3 错误、取消和并发 + +必须覆盖 C 返回非有限值、物性域错误、闭合不收敛、未知 opcode、ABI 不匹配、损坏二进制、失败后事务回滚、编译失败、并发 context 隔离、单任务取消、重复创建/销毁无内存增长和 native 崩溃后的服务存活。 + +回退分为三层: + +1. **启动前自动选择**:编译或加载时发现不支持能力,整次任务在开始积分前明确选择 Python;这是正式模式唯一允许的自动换引擎路径。 +2. **受控运行时错误**:native 已开始积分后返回错误时,当前 native 任务直接失败并保留诊断。灰度验证可以从不可变原始请求的 `tStart` 另起一个 Python 重放任务,但必须标为独立重放,不能算作 native 成功。没有完整保存 BDF 历史、事件上下文和已有输出前,禁止从某个 `(t,y,mode)` 假装无缝续跑。 +3. **进程级故障**:访问违规或无法返回时,由父进程硬终止 worker,禁止在受损进程中继续;如需 Python 对照,同样从原始请求重新执行。 + +正式 C 验收要求异常 `fallbackCount=0`。声明过的局部 Jacobian 数值差分不是异常回退,但必须单独计数。 + +## 7. 对现有优化清单的取舍 + +现有优化清单不能整体放弃,应按新路线重组: + +| 现有任务 | 决策 | 在新路线中的位置 | +| --- | --- | --- | +| OPT-00 基线与回归 | 保留并加强 | C-00 和全部发布门 | +| OPT-01 因果代数内核 | 冻结成果 | 作为 IR 编译输入和 Python 回退,不再继续零散微调 | +| OPT-02 扁平 IR | 升为主线 | C-01、C-03、C-05 | +| OPT-03 Jacobian | 升为主线 | C-07 | +| OPT-04 stream/物性 | 升为主线 | C-06 | +| OPT-05 步长、接触和鲁棒性 | 必须保留 | C-08;C 不能消除微步根因 | +| OPT-06 dense output | 部分前置、其余后置 | 事件合同进入 C-01/C-08,插值微调后置 | +| OPT-07 输出与内存 | 暂缓但不删除 | C-11 | +| OPT-08 取消与并发 | 必须保留并提前 | C-04/C-11;原生代码更需要进程隔离 | +| OPT-09 10 s 验收 | 必须保留 | V5/V6 发布门 | +| OPT-10 高指数 DAE | 有条件暂缓 | 只有真实 DAE 需求时进入 C-10 | + +可以停止继续投入的方向: + +- 在现有对象热路径上继续做零散字典和属性访问微优化; +- 继续叠加缺少统一失效合同的通用缓存; +- 优化目标模型中未触发的 `least_squares` 回退; +- 不断增加少量特例半解析列,而不先建立通用组件导数合同; +- 直接对当前对象图使用 Numba; +- 通过修改容差、软化物理或寻找“幸运 maxStep”伪装性能收益。 + +Numba 可以在完整数组 IR 后用于 1–2 周的架构验证,但不作为长期生产依赖。若完整数组 RHS 在 Numba 原型中仍没有明显改善,应先修正 IR 和算法,而不是立即开始大规模 C 重写。 + +## 8. 可行性评估 + +| 目标 | 当前判断 | 原因 | +| --- | --- | --- | +| 验证框架、双引擎和基准恢复 | 高 | 现有 runner、golden、AMESim 读取和埋点可复用 | +| 完整 Python 扁平 IR | 中高 | 结构基础已有,主要工作是显式化隐藏副作用和事件语义 | +| 代表子系统通用 C RHS | 高 | 数值边界清楚,一次调用可避免 FFI 碎片化 | +| 当前全部内置组件的 C RHS | 中高 | 约 20 种类型可逐类迁移,但 stream/物性/模式较复杂 | +| 可维护的稀疏解析/半解析 Jacobian | 中 | 收益大,但非光滑接触、流向切换和导数覆盖难度最高 | +| 模型专用 C 代码生成 | 中 | 收益上限高,但构建、缓存、安全和诊断成本明显增加 | +| CVODE 替换 SciPy | 中,且后置 | 可能减少调度开销,但不会自动解决错误方程或接触微步 | +| IDA/DASSL 直接提速 | 当前低 | 目前缺少真正 DAE 的残差、一致初始化和质量矩阵合同 | +| 接近 AMESim 的总速度 | 中低、待实测 | 缺少同机 AMESim 墙钟基线,且当前主要瓶颈同时包含 Jacobian 和接触微步数 | + +从项目节奏看,建议把目标分成三档;这里与第 4 节一样使用“人周”,多人并行时日历时间可短于人周总量: + +1. **约 15–27 人周:技术决策闭环。** 完成 C-00~C-05,回答完整 IR 是否正确、C RHS 是否有足够收益。 +2. **累计约 20–36 人周:可选原生后端。** 完成 C-06,使主要内置 RHS、stream/物性、双平台构建和受控回退可由用户显式启用。 +3. **累计约 30–56 人周:默认候选。** 完成 C-07/C-08、10 s 长时、并发隔离和发布门。 + +模型专用代码生成和原生积分器属于额外阶段,不应计入首个可用 C 后端的承诺。多人并行可以缩短日历时间,但 IR、组件合同、Jacobian 和事件语义存在强依赖,不能按人数等比例压缩。 + +## 9. 建议立即开展的第一批工作 + +第一批只做 P0,不直接开始大规模 C 编码: + +1. **已完成:** 固定当前权威输入和回归证据的 `LF` 检出规则,重新规范化 Windows 工作树,并增加 Windows/Linux 字节合同测试;未重建语义未变的 golden。 +2. 生成当前模型的组件类型、槽位、阶段、副作用和事件能力矩阵。 +3. 将 schema v2 写成独立规范,先冻结 RHS 阶段、错误、事务、事件和结构签名。 +4. 建立对象引擎与扁平 IR 的逐阶段 Shadow runner。 +5. 用一条完整机械—气动—管路—接触支路完成 Python 参考闭环。 +6. 评审通过后,再建立最小 C ABI 和纵向切片。 + +第一批的退出条件不是“已经写了多少 C”,而是:当前基准可信、同一计算能被无回调 IR 完整表达、差异能定位到具体阶段和槽位。只有达到这个条件,后续 C 工作才具有可预测的收益和可控的回滚成本。 diff --git a/tests/test_regression_fixture_line_endings.py b/tests/test_regression_fixture_line_endings.py new file mode 100644 index 0000000..c1c845c --- /dev/null +++ b/tests/test_regression_fixture_line_endings.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +import unittest + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +BASELINE_ROOT = REPOSITORY_ROOT / "tests" / "baselines" / "simulation" +GIT_ATTRIBUTES_PATH = REPOSITORY_ROOT / ".gitattributes" + +EXPECTED_ATTRIBUTE_RULES = { + ("tests/data/test-mql-8.xml", "text", "eol=lf"), + ("tests/data/test-mql-8.json", "text", "eol=lf"), + ("tests/data/test_mql-full-branches-01-04.xml", "text", "eol=lf"), + ("tests/baselines/simulation/**/*.json", "text", "eol=lf"), +} + + +def _manifest_paths() -> tuple[Path, ...]: + return tuple(sorted(BASELINE_ROOT.glob("*/manifest.json"))) + + +def _manifest_source_descriptors() -> tuple[tuple[Path, dict[str, object]], ...]: + descriptors: list[tuple[Path, dict[str, object]]] = [] + for manifest_path in _manifest_paths(): + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + source = manifest["source"] + descriptors.append((manifest_path, source)) + companion = source.get("companionProject") + if companion is not None: + descriptors.append((manifest_path, companion)) + return tuple(descriptors) + + +class RegressionFixtureLineEndingTests(unittest.TestCase): + def test_gitattributes_pin_byte_locked_artifacts_to_lf(self) -> None: + attribute_rules = { + tuple(line.split()) + for raw_line in GIT_ATTRIBUTES_PATH.read_text(encoding="utf-8").splitlines() + if (line := raw_line.strip()) and not line.startswith("#") + } + + self.assertTrue( + EXPECTED_ATTRIBUTE_RULES.issubset(attribute_rules), + "Raw-byte regression artifacts must be explicitly pinned to LF.", + ) + + def test_byte_locked_artifacts_contain_no_carriage_returns(self) -> None: + paths = { + path + for path in BASELINE_ROOT.rglob("*.json") + if path.is_file() + } + paths.update( + REPOSITORY_ROOT / str(descriptor["path"]) + for _, descriptor in _manifest_source_descriptors() + ) + + self.assertTrue(paths, "No byte-locked regression artifacts were found.") + for path in sorted(paths): + with self.subTest(path=path.relative_to(REPOSITORY_ROOT).as_posix()): + self.assertNotIn( + b"\r", + path.read_bytes(), + "Byte-locked regression artifacts must use LF line endings.", + ) + + def test_manifest_source_identities_match_raw_worktree_bytes(self) -> None: + self.assertTrue(_manifest_paths(), "No regression manifests were found.") + for manifest_path, descriptor in _manifest_source_descriptors(): + relative_path = str(descriptor["path"]) + payload = (REPOSITORY_ROOT / relative_path).read_bytes() + label = f"{manifest_path.parent.name}:{relative_path}" + with self.subTest(source=label): + self.assertEqual(len(payload), descriptor["bytes"]) + self.assertEqual( + hashlib.sha256(payload).hexdigest(), + descriptor["sha256"], + ) + + +if __name__ == "__main__": + unittest.main() From ce353d2dd277758c1a2261d3690d620dd8e4eb12 Mon Sep 17 00:00:00 2001 From: ljz <425868052@qq.com> Date: Wed, 2 Sep 2026 19:17:55 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E5=AE=8C=E6=88=90=E4=BB=BF=E7=9C=9F?= =?UTF-8?q?=E7=B3=BB=E7=BB=9FIR-schema=E8=A7=84=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/simulation/ir/__init__.py | 40 + app/simulation/ir/compiler.py | 2246 +++++++++++++++++ app/simulation/ir/schema.py | 973 +++++++ app/simulation/ir/validation.py | 1583 ++++++++++++ .../C语言数值内核实施计划与可行性评估.md | 11 +- docs/standard/system-numeric-ir-v2.md | 262 ++ schemas/system-numeric-ir-v2.schema.json | 71 + tests/test_system_numeric_ir_v2.py | 767 ++++++ 8 files changed, 5949 insertions(+), 4 deletions(-) create mode 100644 app/simulation/ir/__init__.py create mode 100644 app/simulation/ir/compiler.py create mode 100644 app/simulation/ir/schema.py create mode 100644 app/simulation/ir/validation.py create mode 100644 docs/standard/system-numeric-ir-v2.md create mode 100644 schemas/system-numeric-ir-v2.schema.json create mode 100644 tests/test_system_numeric_ir_v2.py diff --git a/app/simulation/ir/__init__.py b/app/simulation/ir/__init__.py new file mode 100644 index 0000000..1698523 --- /dev/null +++ b/app/simulation/ir/__init__.py @@ -0,0 +1,40 @@ +"""Whole-system numeric intermediate representation.""" + +from .compiler import compile_system_ir +from .schema import * # noqa: F403 - this package is the public schema facade. +from .schema import __dict__ as _schema_namespace +from .validation import ( + IRValidationIssue, + IRValidationReport, + SystemIRValidationError, + require_valid_system_ir, + validate_system_ir, +) + + +__all__ = [ + "compile_system_ir", + "IRValidationIssue", + "IRValidationReport", + "SystemIRValidationError", + "require_valid_system_ir", + "validate_system_ir", + *sorted( + name + for name in _schema_namespace + if name.startswith("IR") + or name.startswith("SystemIR") + or name.startswith("SYSTEM_NUMERIC_IR") + or name + in { + "CURRENT_SYSTEM_IR_VERSION", + "NATIVE_NUMERIC_ABI_VERSION", + "canonical_json_bytes", + "native_artifact_key", + "operation_read_slots", + "operation_write_slots", + } + ), +] + +del _schema_namespace diff --git a/app/simulation/ir/compiler.py b/app/simulation/ir/compiler.py new file mode 100644 index 0000000..1a6cd31 --- /dev/null +++ b/app/simulation/ir/compiler.py @@ -0,0 +1,2246 @@ +"""Compile :class:`GenericFluidSystem` into callback-free System IR v2. + +The current component implementations are Python reference kernels. This +compiler therefore records their complete, stable numeric layout and execution +plans, but deliberately marks every component kernel ``reference_only`` until +the C-02 pure numeric kernel contract exists. Object references and callbacks +are used only while inspecting the already-constructed system; none are stored +in the returned program. +""" + +from __future__ import annotations + +from collections.abc import Iterable, Mapping, Sequence +from dataclasses import fields, is_dataclass, replace +from math import isfinite +from typing import TYPE_CHECKING, Any + +from app.simulation.core.base import Component, DynamicComponent +from app.simulation.solvers.causal_ir import compile_causal_numeric_ir +from app.simulation.solvers.mechanical import MechanicalConstraintGroup + +from .schema import ( + CURRENT_SYSTEM_IR_VERSION, + SYSTEM_NUMERIC_IR_COMPILER_ID, + SYSTEM_NUMERIC_IR_COMPILER_VERSION, + IRAlgebraicUnknown, + IRBlockKind, + IRBufferKind, + IRBufferSpec, + IRCSRMatrix, + IRCSRPattern, + IRCacheKind, + IRCapabilityIssue, + IRCapabilityLevel, + IRCapabilityReport, + IRCausalEffortStageRef, + IRCausalPlan, + IRCheckFiniteOperation, + IRComponentCapability, + IRComponentInstance, + IRConnectionRule, + IRConnectionSpec, + IRConnectionVariable, + IRConvergenceSpec, + IRCopyOperation, + IRDType, + IRDiagnosticSeverity, + IREffortBroadcastOperation, + IREntryPoint, + IREntryPointKind, + IREquationOwner, + IREquationRelation, + IREventDirection, + IREventSpec, + IRExecutionBlock, + IRFailurePolicy, + IRFillOperation, + IRFiniteDifferenceColumn, + IRFlowAssignmentOperation, + IRJacobianPlan, + IRKernelAvailability, + IRKernelCallOperation, + IRKernelPhase, + IRKernelPhaseSpec, + IRKernelSpec, + IRLinearCombinationOperation, + IRMediumSpec, + IRModeSpec, + IRModeValueSpec, + IROutputSpec, + IRPortKind, + IRPortNominalRole, + IRPortSpec, + IRPortVariable, + IRPositiveFlowDirection, + IRPressureFlowBlock, + IRPressureFlowEquation, + IRPressureFlowPlan, + IRPressureFlowScope, + IRPressureFlowScopeKind, + IRSlotRef, + IRStage, + IRStageKind, + IRStateMapKind, + IRStateMapOperation, + IRStateReducer, + IRStepKind, + IRStepRef, + IRStreamPlan, + IRStreamSCC, + IRThermofluidPlan, + IRTransactionPlan, + IRValueSpec, + IRVariableRole, + SystemIR, + operation_read_slots, + operation_write_slots, +) + +if TYPE_CHECKING: + from app.simulation.systems.generic import GenericFluidSystem + + +_FLOAT_BUFFERS = frozenset( + kind + for kind in IRBufferKind + if kind not in {IRBufferKind.MODE, IRBufferKind.WORK_INT} +) + + +def _unique_slots(slots: Iterable[IRSlotRef]) -> tuple[IRSlotRef, ...]: + return tuple(dict.fromkeys(slots)) + + +class _Slots: + """Deterministic buffer allocator plus value dictionary builder.""" + + def __init__(self) -> None: + self.initial: dict[IRBufferKind, list[float | int]] = { + kind: [] for kind in IRBufferKind + } + self.values: list[IRValueSpec] = [] + + def add( + self, + buffer: IRBufferKind, + value_id: str, + *, + initial: float | int = 0.0, + semantic: str, + role: str, + quantity: str = "dimensionless", + unit: str = "", + scale: float = 1.0, + lower_bound: float | None = None, + upper_bound: float | None = None, + owner_component_index: int | None = None, + ) -> IRSlotRef: + values = self.initial[buffer] + slot = IRSlotRef(buffer, len(values)) + if buffer in _FLOAT_BUFFERS: + value = float(initial) + if not isfinite(value): + raise ValueError(f"Initial IR value {value_id!r} must be finite.") + values.append(value) + else: + values.append(int(initial)) + numeric_scale = float(scale) + if not isfinite(numeric_scale) or numeric_scale <= 0.0: + numeric_scale = 1.0 + self.values.append( + IRValueSpec( + value_id=value_id, + slot=slot, + semantic=semantic, + role=role, + quantity=quantity or "dimensionless", + unit=unit or "", + scale=numeric_scale, + lower_bound=lower_bound, + upper_bound=upper_bound, + owner_component_index=owner_component_index, + ) + ) + return slot + + def buffers(self) -> tuple[IRBufferSpec, ...]: + result: list[IRBufferSpec] = [] + for kind in IRBufferKind: + values = self.initial[kind] + if kind in _FLOAT_BUFFERS: + result.append( + IRBufferSpec( + kind=kind, + dtype=IRDType.FLOAT64, + size=len(values), + initial_float_values=tuple(float(item) for item in values), + ) + ) + else: + result.append( + IRBufferSpec( + kind=kind, + dtype=IRDType.INT32, + size=len(values), + initial_int_values=tuple(int(item) for item in values), + ) + ) + return tuple(result) + + +def _csr_matrix_from_rows( + row_count: int, + column_count: int, + rows: Sequence[Mapping[int, float]], +) -> IRCSRMatrix: + if len(rows) != row_count: + raise ValueError("CSR row data does not match the declared row count.") + row_pointers = [0] + column_indices: list[int] = [] + values: list[float] = [] + for row in rows: + for column, value in sorted(row.items()): + if not 0 <= int(column) < column_count: + raise ValueError("CSR column is outside the declared matrix shape.") + numeric = float(value) + if numeric == 0.0: + continue + column_indices.append(int(column)) + values.append(numeric) + row_pointers.append(len(column_indices)) + return IRCSRMatrix( + IRCSRPattern( + row_count=row_count, + column_count=column_count, + row_pointers=tuple(row_pointers), + column_indices=tuple(column_indices), + ), + tuple(values), + ) + + +def _csr_pattern(matrix: object) -> IRCSRPattern: + csr = matrix.tocsr().astype(bool) + csr.sort_indices() + return IRCSRPattern( + row_count=int(csr.shape[0]), + column_count=int(csr.shape[1]), + row_pointers=tuple(int(item) for item in csr.indptr), + column_indices=tuple(int(item) for item in csr.indices), + ) + + +def _state_names(component: DynamicComponent) -> tuple[str, ...]: + size = int(component.state_size) + model_type = str(component.model_type) + if model_type == "amesim_mecmas21" and size == 2: + return ("v", "x") + if hasattr(component, "medium") and size >= 2 and size % 2 == 0: + if size == 2: + return ("m", "U") + names: list[str] = [] + for partition in range(size // 2): + names.extend((f"m_{partition + 1}", f"U_{partition + 1}")) + return tuple(names) + return tuple(f"state_{index}" for index in range(size)) + + +def _state_metadata(name: str) -> tuple[str, str]: + if name == "v": + return "velocity", "m/s" + if name == "x": + return "length", "m" + if name == "m" or name.startswith("m_"): + return "mass", "kg" + if name == "U" or name.startswith("U_"): + return "internal_energy", "J" + return "dimensionless", "" + + +def _mechanical_group_key( + group: MechanicalConstraintGroup, +) -> tuple[str, ...]: + """Return the topology-stable identity used only during compilation.""" + + return tuple(sorted(component.name for component in group.components)) + + +def _component_phases(component: Component) -> tuple[IRKernelPhase, ...]: + phases = [IRKernelPhase.PRIMAL] + if component.pressure_flow_equation_residuals(): + phases.append(IRKernelPhase.RESIDUAL) + if isinstance(component, DynamicComponent): + phases.extend((IRKernelPhase.PROPERTY, IRKernelPhase.DERIVATIVE)) + if getattr(component, "signal_event_times", None) is not None: + phases.extend((IRKernelPhase.EVENT, IRKernelPhase.RESET)) + if component.RESULT_VARIABLES or any( + variable.result_visible + for port in component.active_port_definitions + for variable in port.variables + ): + phases.append(IRKernelPhase.OUTPUT) + return tuple(dict.fromkeys(phases)) + + +def _numeric_constants(value: object, prefix: str = "") -> tuple[tuple[str, float], ...]: + """Flatten stable finite numeric dataclass constants without object identity.""" + + result: list[tuple[str, float]] = [] + if is_dataclass(value) and not isinstance(value, type): + for item in fields(value): + current = getattr(value, item.name) + name = f"{prefix}.{item.name}" if prefix else item.name + if isinstance(current, bool): + continue + if isinstance(current, (int, float)) and isfinite(float(current)): + result.append((name, float(current))) + elif is_dataclass(current) and not isinstance(current, type): + result.extend(_numeric_constants(current, name)) + fluid = getattr(value, "fluid", None) + if fluid is not None and fluid is not value and is_dataclass(fluid): + result.extend(_numeric_constants(fluid, "fluid")) + return tuple(dict.fromkeys(result)) + + +def _medium_identity(medium: object) -> tuple[str, str, str]: + medium_type = type(medium) + substance = str(getattr(medium_type, "SUBSTANCE_ID", medium_type.__name__)) + method = str(getattr(medium_type, "PROPERTY_METHOD_ID", "reference")) + implementation = f"{medium_type.__module__}.{medium_type.__qualname__}" + return f"{substance}:{method}", implementation, str( + getattr(medium_type, "IMPLEMENTATION_VERSION", "python-reference-1") + ) + + +def _stage( + stage_id: str, + kind: IRStageKind, + operations: Sequence[Any], +) -> IRStage: + operation_tuple = tuple(operations) + return IRStage( + stage_id=stage_id, + kind=kind, + operations=operation_tuple, + declared_read_slots=_unique_slots( + slot + for operation in operation_tuple + for slot in operation_read_slots(operation) + ), + declared_write_slots=_unique_slots( + slot + for operation in operation_tuple + for slot in operation_write_slots(operation) + ), + ) + + +def _step(index: int, kind: IRStepKind = IRStepKind.STAGE) -> IRStepRef: + return IRStepRef(kind, int(index)) + + +def compile_system_ir( + system: "GenericFluidSystem", + *, + model_version: str = "unversioned", +) -> SystemIR: + """Compile an already-constructed generic system into validated IR v2.""" + + # Local import keeps the public IR package independent of GenericFluidSystem + # construction and prevents a systems.generic -> ir -> systems.generic cycle. + from app.simulation.systems.generic import GenericFluidSystem + + if not isinstance(system, GenericFluidSystem): + raise TypeError("compile_system_ir() requires a GenericFluidSystem instance.") + + network = system.network + components = tuple(network.components.values()) + component_index = {component.name: index for index, component in enumerate(components)} + connections = tuple(network.connections) + connection_index = {connection.id: index for index, connection in enumerate(connections)} + slots = _Slots() + capability_issues: list[IRCapabilityIssue] = [] + + time_slot = slots.add( + IRBufferKind.TIME, + "time", + semantic="independent_variable", + role="input", + quantity="time", + unit="s", + ) + + # The reducer's initialization is the authoritative state order and also + # performs the same supported initial consistency projection as simulate(). + initial_state = tuple(float(item) for item in system.initial_state_vector()) + state_input_slots: list[IRSlotRef] = [] + derivative_output_slots: list[IRSlotRef] = [] + for index, value in enumerate(initial_state): + state_input_slots.append( + slots.add( + IRBufferKind.STATE_INPUT, + f"solver.state.{index}", + initial=value, + semantic="solver_state", + role="state", + ) + ) + derivative_output_slots.append( + slots.add( + IRBufferKind.DERIVATIVE_OUTPUT, + f"solver.derivative.{index}", + semantic="solver_derivative", + role="derivative", + ) + ) + + dynamic_components = tuple(system.dynamic_components) + local_state_by_component: dict[str, tuple[IRSlotRef, ...]] = {} + local_derivative_by_component: dict[str, tuple[IRSlotRef, ...]] = {} + local_state_row_by_component: dict[str, tuple[int, ...]] = {} + local_derivative_column_by_component: dict[str, tuple[int, ...]] = {} + local_state_slots: list[IRSlotRef] = [] + local_derivative_slots: list[IRSlotRef] = [] + for component in dynamic_components: + state_values = tuple(float(item) for item in component.get_state_vector()) + names = _state_names(component) + component_states: list[IRSlotRef] = [] + component_derivatives: list[IRSlotRef] = [] + state_rows: list[int] = [] + derivative_columns: list[int] = [] + for local_index, (name, value) in enumerate(zip(names, state_values)): + quantity, unit = _state_metadata(name) + state_rows.append(len(local_state_slots)) + derivative_columns.append(len(local_derivative_slots)) + state_slot = slots.add( + IRBufferKind.LOCAL_STATE, + f"{component.name}.state.{name}", + initial=value, + semantic="component_state", + role="state", + quantity=quantity, + unit=unit, + scale=max(abs(value), 1.0), + owner_component_index=component_index[component.name], + ) + derivative_slot = slots.add( + IRBufferKind.LOCAL_DERIVATIVE, + f"{component.name}.derivative.{name}", + semantic="component_derivative", + role="derivative", + quantity=quantity, + unit=unit, + owner_component_index=component_index[component.name], + ) + component_states.append(state_slot) + component_derivatives.append(derivative_slot) + local_state_slots.append(state_slot) + local_derivative_slots.append(derivative_slot) + local_state_by_component[component.name] = tuple(component_states) + local_derivative_by_component[component.name] = tuple(component_derivatives) + local_state_row_by_component[component.name] = tuple(state_rows) + local_derivative_column_by_component[component.name] = tuple(derivative_columns) + + # Build exact scatter/gather matrices from the real reduced state entries. + scatter_rows: list[dict[int, float]] = [ + {} for _ in range(len(local_state_slots)) + ] + gather_rows: list[dict[int, float]] = [ + {} for _ in range(len(initial_state)) + ] + solver_cursor = 0 + group_solver_offset: dict[tuple[str, ...], int] = {} + for entry in system.mechanical_state_reducer.state_entries: + if isinstance(entry, MechanicalConstraintGroup): + entry_size = 2 + group_solver_offset[_mechanical_group_key(entry)] = solver_cursor + representative = entry.representative + for member in entry.components: + for local_row, solver_column in zip( + local_state_row_by_component[member.name], + range(solver_cursor, solver_cursor + entry_size), + ): + scatter_rows[local_row][solver_column] = 1.0 + for output_row, local_column in zip( + range(solver_cursor, solver_cursor + entry_size), + local_derivative_column_by_component[representative.name], + ): + gather_rows[output_row][local_column] = 1.0 + else: + entry_size = int(entry.state_size) + for local_row, solver_column in zip( + local_state_row_by_component[entry.name], + range(solver_cursor, solver_cursor + entry_size), + ): + scatter_rows[local_row][solver_column] = 1.0 + for output_row, local_column in zip( + range(solver_cursor, solver_cursor + entry_size), + local_derivative_column_by_component[entry.name], + ): + gather_rows[output_row][local_column] = 1.0 + solver_cursor += entry_size + if solver_cursor != len(initial_state): + raise RuntimeError("Reduced state entries do not cover the solver vector.") + + # Ideal C-C storage coupling keeps public state coordinates but projects + # derivatives by physical volume. Fold that exact projection into gather. + for group in system.pneumatic_storage_reducer.groups: + offsets = [ + int(system.pneumatic_storage_reducer._global_offset(partition)) + for partition in group.partitions + ] + volumes = [float(partition.volume) for partition in group.partitions] + total_volume = sum(volumes) + # ``GenericFluidSystem.apply_state_vector`` first projects every + # ideally connected storage partition onto one common mass/energy + # density, then scatters that projected vector into components. The + # IR matrix must include the same projection (not merely the matching + # derivative projection below), otherwise arbitrary solver trial + # vectors would reach different component states in Python and IR. + for partition, volume in zip(group.partitions, volumes): + target_rows = local_state_row_by_component[partition.component.name] + fraction = volume / total_volume + for field_offset in (0, 1): + target_row = target_rows[partition.state_offset + field_offset] + scatter_rows[target_row] = { + source_offset + field_offset: fraction + for source_offset in offsets + } + for offset, volume in zip(offsets, volumes): + fraction = volume / total_volume + for field_offset in (0, 1): + combined: dict[int, float] = {} + for source_offset in offsets: + for column, weight in gather_rows[source_offset + field_offset].items(): + combined[column] = combined.get(column, 0.0) + fraction * weight + gather_rows[offset + field_offset] = combined + + state_scatter = _csr_matrix_from_rows( + len(local_state_slots), len(initial_state), scatter_rows + ) + derivative_gather = _csr_matrix_from_rows( + len(initial_state), len(local_derivative_slots), gather_rows + ) + absolute_tolerances = tuple( + float(item) + for item in system.mechanical_state_reducer.absolute_tolerances( + 1.0e-8, + mechanical=1.0e-12, + mode="legacy", + ) + ) + state_reducer = IRStateReducer( + solver_state_count=len(initial_state), + local_state_slots=tuple(local_state_slots), + raw_derivative_slots=tuple(local_derivative_slots), + state_scatter=state_scatter, + derivative_gather=derivative_gather, + initial_state=initial_state, + absolute_tolerances=absolute_tolerances, + ) + + # Normalized component parameter slots. + parameter_slots_by_component: dict[str, tuple[IRSlotRef, ...]] = {} + for component in components: + values = component.parameter_values + component_slots: list[IRSlotRef] = [] + for definition in component.PARAMETERS: + value = float(values[definition.name]) + component_slots.append( + slots.add( + IRBufferKind.PARAMETER, + f"{component.name}.parameter.{definition.name}", + initial=value, + semantic="component_parameter", + role="parameter", + quantity=definition.quantity, + unit=definition.unit, + scale=max(abs(value), abs(float(definition.default)), 1.0), + lower_bound=definition.minimum, + upper_bound=definition.maximum, + owner_component_index=component_index[component.name], + ) + ) + parameter_slots_by_component[component.name] = tuple(component_slots) + + # Port and connector tables use actual active-port order from compilation. + port_specs: list[IRPortSpec] = [] + port_index_by_id: dict[str, int] = {} + port_slot_by_variable_id: dict[str, IRSlotRef] = {} + port_slots_by_component: dict[str, list[IRSlotRef]] = { + component.name: [] for component in components + } + port_indices_by_component: dict[str, list[int]] = { + component.name: [] for component in components + } + for component in components: + owner = component_index[component.name] + for definition in component.active_port_definitions: + port = component.get_port(definition.name) + variables: list[IRPortVariable] = [] + port_id = f"{component.name}.{definition.name}" + for variable in definition.variables: + variable_id = f"{port_id}.{variable.name}" + buffer = ( + IRBufferKind.SIGNAL + if definition.kind == "signal" + else IRBufferKind.ALGEBRAIC + ) + value = float(getattr(port, variable.name)) + lower_bound = 0.0 if variable.name == "p" else None + slot = slots.add( + buffer, + variable_id, + initial=value, + semantic="port_variable", + role=variable.role, + quantity=variable.quantity, + unit=variable.unit, + scale=max(abs(value), 1.0), + lower_bound=lower_bound, + owner_component_index=owner, + ) + port_slot_by_variable_id[variable_id] = slot + port_slots_by_component[component.name].append(slot) + variables.append( + IRPortVariable( + variable_id=variable_id, + name=variable.name, + role=IRVariableRole(variable.role), + connection_rule=IRConnectionRule(variable.connection_rule), + quantity=variable.quantity or "dimensionless", + unit=variable.unit or "", + result_visible=bool(variable.result_visible), + slot=slot, + ) + ) + port_index = len(port_specs) + port_index_by_id[port_id] = port_index + port_indices_by_component[component.name].append(port_index) + port_specs.append( + IRPortSpec( + port_id=port_id, + component_index=owner, + name=definition.name, + kind=IRPortKind(definition.kind), + domain=definition.domain, + nominal_role=IRPortNominalRole(definition.nominal_role), + positive_flow_direction=( + IRPositiveFlowDirection(definition.positive_flow_direction) + if definition.positive_flow_direction is not None + else None + ), + variables=tuple(variables), + ) + ) + + connection_specs: list[IRConnectionSpec] = [] + for connection in connections: + endpoint_a_id = str(connection.endpoint_a) + endpoint_b_id = str(connection.endpoint_b) + endpoint_a_port = port_specs[port_index_by_id[endpoint_a_id]] + endpoint_b_port = port_specs[port_index_by_id[endpoint_b_id]] + variables = tuple( + IRConnectionVariable( + name=first.name, + rule=first.connection_rule, + endpoint_a_slot=first.slot, + endpoint_b_slot=second.slot, + ) + for first, second in zip( + endpoint_a_port.variables, + endpoint_b_port.variables, + strict=True, + ) + ) + connection_specs.append( + IRConnectionSpec( + connection_id=connection.id, + kind=IRPortKind(connection.kind), + domain=connection.domain, + endpoint_a_port_index=port_index_by_id[endpoint_a_id], + endpoint_b_port_index=port_index_by_id[endpoint_b_id], + variables=variables, + ) + ) + + # Project-scoped media are grouped by semantic implementation and numeric + # constants, never by Python object address. + medium_groups: dict[ + tuple[str, str, str, tuple[tuple[str, float], ...]], list[int] + ] = {} + medium_objects: dict[ + tuple[str, str, str, tuple[tuple[str, float], ...]], object + ] = {} + for index, component in enumerate(components): + medium = getattr(component, "medium", None) + if medium is None: + continue + medium_id, implementation_id, implementation_version = _medium_identity(medium) + constants = _numeric_constants(medium) + key = (medium_id, implementation_id, implementation_version, constants) + medium_groups.setdefault(key, []).append(index) + medium_objects.setdefault(key, medium) + medium_specs: list[IRMediumSpec] = [] + medium_variant_counts: dict[str, int] = {} + for medium_id, _implementation_id, _implementation_version, _constants in medium_groups: + medium_variant_counts[medium_id] = medium_variant_counts.get(medium_id, 0) + 1 + medium_variant_offsets: dict[str, int] = {} + for key, member_indices in medium_groups.items(): + medium_id, implementation_id, implementation_version, constants = key + variant_offset = medium_variant_offsets.get(medium_id, 0) + medium_variant_offsets[medium_id] = variant_offset + 1 + compiled_medium_id = ( + medium_id + if medium_variant_counts[medium_id] == 1 + else f"{medium_id}:variant-{variant_offset + 1}" + ) + parameter_slots = tuple( + slots.add( + IRBufferKind.CONSTANT, + f"medium.{compiled_medium_id}.{name}", + initial=value, + semantic="medium_constant", + role="constant", + scale=max(abs(value), 1.0), + ) + for name, value in constants + ) + medium = medium_objects[key] + medium_specs.append( + IRMediumSpec( + medium_id=compiled_medium_id, + name=str(getattr(medium, "name", medium_id)), + implementation_id=implementation_id, + implementation_version=implementation_version, + parameter_slots=parameter_slots, + component_indices=tuple(member_indices), + ) + ) + + # Discrete mechanical modes are shared by every inertia in one rigid group. + modes: list[IRModeSpec] = [] + mode_slots_by_component: dict[str, list[IRSlotRef]] = { + component.name: [] for component in components + } + mode_slot_by_group: dict[tuple[str, ...], IRSlotRef] = {} + event_groups: list[MechanicalConstraintGroup] = [] + for group in system.mechanical_state_reducer.groups: + if not group.discrete_endstop_components: + continue + member_names = tuple(sorted(component.name for component in group.components)) + mode_id = "mechanical_group:" + ",".join(member_names) + ".mode" + mode_slot = slots.add( + IRBufferKind.MODE, + mode_id, + initial=-1, + semantic="mechanical_constraint_mode", + role="mode", + ) + mode_slot_by_group[_mechanical_group_key(group)] = mode_slot + event_groups.append(group) + owners = tuple(component_index[name] for name in member_names) + for name in member_names: + mode_slots_by_component[name].append(mode_slot) + modes.append( + IRModeSpec( + mode_id=mode_id, + slot=mode_slot, + owner_component_indices=owners, + values=( + IRModeValueSpec(-1, "uninitialized"), + IRModeValueSpec(0, "free"), + IRModeValueSpec(1, "lower"), + IRModeValueSpec(2, "upper"), + ), + initial_value=-1, + ) + ) + + # Allocate result projection sources before component records so each + # instance can point at its immutable output indices. + result_metadata = network.result_variable_metadata() + output_indices_by_component: dict[str, list[int]] = { + component.name: [] for component in components + } + output_source_slots: list[IRSlotRef] = [] + output_slots: list[IRSlotRef] = [] + for output_index, metadata in enumerate(result_metadata): + output_indices_by_component[metadata.component_id].append(output_index) + if metadata.scope == "port": + source_id = f"{metadata.component_id}.{metadata.port_name}.{metadata.name}" + source_slot = port_slot_by_variable_id[source_id] + else: + source_slot = slots.add( + IRBufferKind.ALGEBRAIC, + f"{metadata.key}.output_source", + semantic="component_result_source", + role="output_source", + quantity=metadata.quantity, + unit=metadata.unit, + owner_component_index=component_index[metadata.component_id], + ) + output_source_slots.append(source_slot) + output_slots.append( + slots.add( + IRBufferKind.RESULT_OUTPUT, + f"result.{metadata.key}", + semantic="result_output", + role="output", + quantity=metadata.quantity, + unit=metadata.unit, + owner_component_index=component_index[metadata.component_id], + ) + ) + + # One reference kernel per component implementation plus an explicit + # reference-only system executor for topology-wide operations. + kernels: list[IRKernelSpec] = [] + component_kernel_index: dict[str, int] = {} + components_by_kernel: dict[ + tuple[str, str, str, int, int, int], list[Component] + ] = {} + for component in components: + component_type = type(component) + model_type = str(component.model_type) + model_model_version = str(component_type.MODEL_VERSION or "unversioned") + implementation = f"{component_type.__module__}.{component_type.__qualname__}" + key = ( + model_type, + model_model_version, + implementation, + len(component.PARAMETERS), + int(component.state_size) if isinstance(component, DynamicComponent) else 0, + len(mode_slots_by_component[component.name]), + ) + components_by_kernel.setdefault(key, []).append(component) + for key, instances in components_by_kernel.items(): + ( + model_type, + model_model_version, + implementation, + parameter_count, + state_count, + mode_count, + ) = key + phases = tuple( + dict.fromkeys( + phase for component in instances for phase in _component_phases(component) + ) + ) + kernel_index = len(kernels) + kernels.append( + IRKernelSpec( + kernel_id=( + f"reference:{model_type}@{model_model_version}" + f":{implementation}:p{parameter_count}:s{state_count}:m{mode_count}" + ), + model_type=model_type, + model_version=model_model_version, + implementation_version=implementation, + availability=IRKernelAvailability.REFERENCE_ONLY, + unavailable_reason="C-02 pure numeric kernel contract is not declared.", + phases=tuple(IRKernelPhaseSpec(phase) for phase in phases), + parameter_count=parameter_count, + state_count=state_count, + mode_count=mode_count, + workspace_float_count=0, + workspace_int_count=0, + ) + ) + for component in instances: + component_kernel_index[component.name] = kernel_index + system_kernel_index = len(kernels) + kernels.append( + IRKernelSpec( + kernel_id="reference:generic-fluid-system@2", + model_type="generic_fluid_system", + model_version="2.0.0", + implementation_version=SYSTEM_NUMERIC_IR_COMPILER_VERSION, + availability=IRKernelAvailability.REFERENCE_ONLY, + unavailable_reason="Whole-system native execution is not implemented.", + phases=tuple( + IRKernelPhaseSpec(phase) + for phase in ( + IRKernelPhase.PRIMAL, + IRKernelPhase.RESIDUAL, + IRKernelPhase.DERIVATIVE, + IRKernelPhase.EVENT, + IRKernelPhase.RESET, + IRKernelPhase.JACOBIAN, + IRKernelPhase.OUTPUT, + ) + ), + parameter_count=0, + state_count=len(initial_state), + mode_count=len(modes), + workspace_float_count=0, + workspace_int_count=0, + ) + ) + + component_instances = tuple( + IRComponentInstance( + instance_id=component.name, + kernel_index=component_kernel_index[component.name], + parameter_slots=parameter_slots_by_component[component.name], + state_slots=local_state_by_component.get(component.name, ()), + derivative_slots=local_derivative_by_component.get(component.name, ()), + mode_slots=tuple(mode_slots_by_component[component.name]), + port_indices=tuple(port_indices_by_component[component.name]), + port_slots=tuple(port_slots_by_component[component.name]), + output_indices=tuple(output_indices_by_component[component.name]), + workspace_float_slots=(), + workspace_int_slots=(), + ) + for component in components + ) + + # Pressure-flow structure and work slots. + pressure_solver = system.pressure_flow_solver + algebraic_scales = pressure_solver._scales() + unknown_index_by_id = { + unknown.id: index for index, unknown in enumerate(pressure_solver.unknowns) + } + algebraic_unknowns: list[IRAlgebraicUnknown] = [] + for unknown in pressure_solver.unknowns: + scale = float(algebraic_scales.get(unknown.variable, 1.0)) + algebraic_unknowns.append( + IRAlgebraicUnknown( + unknown_id=unknown.id, + component_index=component_index[unknown.component], + port_index=port_index_by_id[f"{unknown.component}.{unknown.port}"], + variable=unknown.variable, + role=IRVariableRole(unknown.role), + slot=port_slot_by_variable_id[unknown.id], + scale=max(scale, 1.0e-300), + lower_bound=0.0 if unknown.variable == "p" else None, + upper_bound=None, + ) + ) + + equation_index_by_id = { + equation.id: index + for index, equation in enumerate(pressure_solver.equation_templates) + } + residual_slots: list[IRSlotRef] = [] + pressure_equations: list[IRPressureFlowEquation] = [] + for equation_index_value, equation in enumerate(pressure_solver.equation_templates): + residual_slot = slots.add( + IRBufferKind.WORK_FLOAT, + f"pressure_flow.residual.{equation.id}", + semantic="pressure_flow_residual", + role="residual", + ) + residual_slots.append(residual_slot) + variable_slots: list[IRSlotRef] = [] + for variable_id in equation.variables: + slot = port_slot_by_variable_id.get(variable_id) + if slot is not None: + variable_slots.append(slot) + continue + if variable_id.endswith(".state"): + owner_name = variable_id[: -len(".state")] + variable_slots.extend(local_state_by_component.get(owner_name, ())) + continue + capability_issues.append( + IRCapabilityIssue( + code="IR_EQUATION_VARIABLE_UNRESOLVED", + severity=IRDiagnosticSeverity.ERROR, + scope_id=equation.id, + message=f"Equation variable {variable_id!r} has no numeric slot.", + ) + ) + if equation.owner == "component": + owner = component_index[equation.owner_id] + else: + owner = connection_index[equation.owner_id] + if equation.role == "flow": + scale = algebraic_scales.get("m_flow", 1.0) + if any(variable.endswith(".f") for variable in equation.variables): + scale = algebraic_scales.get("f", 1.0) + elif equation.role == "effort": + suffixes = {item.rsplit(".", 1)[-1] for item in equation.variables} + scale = ( + algebraic_scales.get("x", 1.0) + if "x" in suffixes + else algebraic_scales.get("v", 1.0) + if "v" in suffixes + else algebraic_scales.get("p", 1.0) + ) + else: + scale = 1.0 + pressure_equations.append( + IRPressureFlowEquation( + equation_id=equation.id, + owner=IREquationOwner(equation.owner), + owner_index=owner, + relation=IREquationRelation(equation.relation), + role=IRVariableRole(equation.role) if equation.role is not None else None, + variable_slots=tuple(variable_slots), + residual_slot=residual_slot, + scale=max(float(scale), 1.0e-300), + ) + ) + + pressure_blocks: list[IRPressureFlowBlock] = [] + block_key_to_index: dict[tuple[tuple[int, ...], tuple[int, ...]], int] = {} + + def add_pressure_block( + unknown_indices: Sequence[int], + equation_indices: Sequence[int], + pattern: object, + ) -> int: + unknown_tuple = tuple(int(item) for item in unknown_indices) + equation_tuple = tuple(int(item) for item in equation_indices) + key = (unknown_tuple, equation_tuple) + existing = block_key_to_index.get(key) + if existing is not None: + return existing + index = len(pressure_blocks) + block_key_to_index[key] = index + pressure_blocks.append( + IRPressureFlowBlock( + block_id=f"pressure_flow.block.{index}", + unknown_indices=unknown_tuple, + equation_indices=equation_tuple, + jacobian_pattern=_csr_pattern(pattern), + ) + ) + return index + + for block in pressure_solver.equation_blocks: + pattern = pressure_solver.jacobian_sparsity[ + list(block.equation_indices), : + ][:, list(block.unknown_indices)] + add_pressure_block(block.unknown_indices, block.equation_indices, pattern) + + # Preserve the callback-free structural half of causal IR v1. + causal_compilation = compile_causal_numeric_ir(pressure_solver) + causal_plans: list[IRCausalPlan] = [] + causal_program = causal_compilation.ir.program if causal_compilation.supported else None + causal_canonical_slots: list[IRSlotRef] = [] + if causal_program is not None: + for canonical in causal_program.canonical_slots: + causal_canonical_slots.append( + slots.add( + IRBufferKind.WORK_FLOAT, + f"causal_v1.canonical.{canonical.id}", + semantic="causal_coordinate", + role=canonical.kind, + ) + ) + + # Main and causal stages are assembled together so stage references in the + # causal plan are ordinary v2 indices. + stages: list[IRStage] = [] + + def add_stage(stage_id: str, kind: IRStageKind, operations: Sequence[Any]) -> int: + index = len(stages) + stages.append(_stage(stage_id, kind, operations)) + return index + + state_stage = add_stage( + "rhs.state_scatter", + IRStageKind.STATE_REDUCE, + ( + IRStateMapOperation( + IRStateMapKind.SCATTER, + tuple(state_input_slots), + tuple(local_state_slots), + ), + ), + ) + + # Signal output functions, followed by directed connection propagation. + signal_operations: list[Any] = [] + for binding in system.signal_resolver._output_bindings: + component = binding.component + writes = tuple( + variable.slot + for port_index_value in port_indices_by_component[component.name] + for variable in port_specs[port_index_value].variables + if port_specs[port_index_value].kind is IRPortKind.SIGNAL + and port_specs[port_index_value].nominal_role is IRPortNominalRole.OUTPUT + ) + signal_operations.append( + IRKernelCallOperation( + kernel_index=component_kernel_index[component.name], + component_index=component_index[component.name], + phase=IRKernelPhase.PRIMAL, + read_slots=(time_slot, *parameter_slots_by_component[component.name]), + write_slots=writes, + ) + ) + for connection in connections: + if connection.kind != "signal": + continue + endpoint_a = port_specs[port_index_by_id[str(connection.endpoint_a)]] + endpoint_b = port_specs[port_index_by_id[str(connection.endpoint_b)]] + source, target = ( + (endpoint_a, endpoint_b) + if endpoint_a.nominal_role is IRPortNominalRole.OUTPUT + else (endpoint_b, endpoint_a) + ) + signal_operations.append(IRCopyOperation(source.variables[0].slot, target.variables[0].slot)) + signal_stage = add_stage("rhs.signal", IRStageKind.SIGNAL, signal_operations) + + effort_slots = tuple( + unknown.slot + for unknown in algebraic_unknowns + if unknown.variable in {"x", "v"} + ) + mechanical_equivalence_stage = add_stage( + "rhs.mechanical_equivalence", + IRStageKind.MECHANICAL_EQUIVALENCE, + ( + IRKernelCallOperation( + system_kernel_index, + None, + IRKernelPhase.PRIMAL, + tuple(local_state_slots), + effort_slots, + ), + ), + ) + + pneumatic_volume_slots = tuple( + variable.slot + for port in port_specs + if port.kind is IRPortKind.PHYSICAL and port.domain == "pneumatic" + for variable in port.variables + if variable.name in {"volume", "volume_flow"} + ) + volume_operations: list[Any] = [IRFillOperation(pneumatic_volume_slots, 0.0)] + volume_output_names: dict[str, tuple[str, ...]] = {} + for component in system.pneumatic_volume_resolver._output_components: + output_names = tuple(component.pneumatic_volume_outputs()) + volume_output_names[component.name] = output_names + writes = tuple( + port_slot_by_variable_id[f"{component.name}.{port_name}.{variable}"] + for port_name in output_names + for variable in ("volume", "volume_flow") + ) + volume_operations.append( + IRKernelCallOperation( + component_kernel_index[component.name], + component_index[component.name], + IRKernelPhase.PRIMAL, + ( + *local_state_by_component.get(component.name, ()), + *parameter_slots_by_component[component.name], + *port_slots_by_component[component.name], + ), + writes, + ) + ) + connected_endpoint: dict[str, str] = {} + for connection in connections: + if connection.kind == "physical" and connection.domain == "pneumatic": + connected_endpoint[str(connection.endpoint_a)] = str(connection.endpoint_b) + connected_endpoint[str(connection.endpoint_b)] = str(connection.endpoint_a) + for component_name, port_names in volume_output_names.items(): + for port_name in port_names: + source_endpoint = f"{component_name}.{port_name}" + target_endpoint = connected_endpoint.get(source_endpoint) + if target_endpoint is None: + continue + for variable in ("volume", "volume_flow"): + volume_operations.append( + IRCopyOperation( + port_slot_by_variable_id[f"{source_endpoint}.{variable}"], + port_slot_by_variable_id[f"{target_endpoint}.{variable}"], + ) + ) + volume_stage = add_stage("rhs.dynamic_volume", IRStageKind.DYNAMIC_VOLUME, volume_operations) + + property_operations: list[Any] = [] + for component in dynamic_components: + writes = tuple( + port_slot_by_variable_id[f"{component.name}.{definition.name}.{variable.name}"] + for definition in component.active_port_definitions + if definition.kind == "physical" and definition.domain == "pneumatic" + for variable in definition.variables + if variable.name in {"p", "h_outflow"} + ) + property_operations.append( + IRKernelCallOperation( + component_kernel_index[component.name], + component_index[component.name], + IRKernelPhase.PROPERTY, + ( + *local_state_by_component[component.name], + *parameter_slots_by_component[component.name], + *port_slots_by_component[component.name], + ), + writes, + ) + ) + property_stage = add_stage("rhs.property_bundle", IRStageKind.PROPERTY, property_operations) + + all_unknown_slots = tuple(item.slot for item in algebraic_unknowns) + # Reference pressure-flow evaluation still reaches through the object model + # to parameters, modes, medium constants and every physical port value. C-01 + # records a conservative dependency superset so cache invalidation remains + # safe until C-02 replaces this system call with pure component signatures. + pressure_flow_model_read_slots = _unique_slots( + IRSlotRef(kind, index) + for kind in ( + IRBufferKind.TIME, + IRBufferKind.LOCAL_STATE, + IRBufferKind.ALGEBRAIC, + IRBufferKind.SIGNAL, + IRBufferKind.PARAMETER, + IRBufferKind.CONSTANT, + IRBufferKind.MODE, + IRBufferKind.RUNTIME_INPUT, + ) + for index in range(len(slots.initial[kind])) + ) + global_pressure_stage = add_stage( + "rhs.pressure_flow.global", + IRStageKind.PRESSURE_FLOW, + ( + IRKernelCallOperation( + system_kernel_index, + None, + IRKernelPhase.RESIDUAL, + pressure_flow_model_read_slots, + (*all_unknown_slots, *residual_slots), + tuple(range(len(pressure_equations))), + ), + IRCheckFiniteOperation(all_unknown_slots, "PRESSURE_FLOW_NONFINITE"), + ), + ) + + # Causal v1 structural stages are intentionally not Python bindings. + causal_effort_refs: list[IRCausalEffortStageRef] = [] + causal_flow_stage_indices: list[int] = [] + if causal_program is not None: + for source_stage in causal_program.effort_stages: + operations = tuple( + IREffortBroadcastOperation( + variable=operation.variable, + anchor_slot=port_slot_by_variable_id[ + causal_program.compatibility_slots[ + operation.anchor_compatibility_slot + ].id + ], + residual_slot=residual_slots[equation_index_by_id[operation.equation_id]], + result_slot=causal_canonical_slots[operation.result_slot], + scatter_slots=tuple( + port_slot_by_variable_id[ + causal_program.compatibility_slots[index].id + ] + for index in operation.scatter_compatibility_slots + ), + equation_id=operation.equation_id, + lower_bound=0.0 if operation.variable == "p" else None, + ) + for operation in source_stage.operations + ) + stage_index = add_stage( + f"causal_v1.effort.{source_stage.variable}", + IRStageKind.PRESSURE_FLOW, + operations, + ) + causal_effort_refs.append( + IRCausalEffortStageRef(source_stage.variable, stage_index) + ) + for flow_index, source_stage in enumerate(causal_program.flow_stages): + operations = tuple( + IRFlowAssignmentOperation( + value_slot=residual_slots[equation_index_by_id[equation_id]], + result_slot=causal_canonical_slots[target], + scatter_slots=( + port_slot_by_variable_id[ + causal_program.compatibility_slots[compatibility].id + ], + ), + equation_id=equation_id, + ) + for target, compatibility, equation_id in zip( + source_stage.target_slots, + source_stage.scatter_compatibility_slots, + source_stage.equation_ids, + strict=True, + ) + ) + causal_flow_stage_indices.append( + add_stage( + f"causal_v1.flow.{flow_index}", + IRStageKind.PRESSURE_FLOW, + operations, + ) + ) + causal_plans.append( + IRCausalPlan( + plan_id="pressure_flow.global.causal_v1", + scope_component_indices=tuple(range(len(components))), + source_schema_version=int(causal_program.schema_version), + source_structural_signature=causal_program.structural_signature, + fallback_reason=None, + canonical_slots=tuple(causal_canonical_slots), + compatibility_slots=tuple( + port_slot_by_variable_id[item.id] + for item in causal_program.compatibility_slots + ), + reset_slots=tuple( + port_slot_by_variable_id[ + causal_program.compatibility_slots[index].id + ] + for index in causal_program.reset_compatibility_slots + ), + external_effort_slots=tuple( + port_slot_by_variable_id[ + causal_program.compatibility_slots[index].id + ] + for index in causal_program.external_effort_compatibility_slots + ), + effort_stages=tuple(causal_effort_refs), + flow_stage_indices=tuple(causal_flow_stage_indices), + ) + ) + else: + causal_plans.append( + IRCausalPlan( + plan_id="pressure_flow.global.causal_v1", + scope_component_indices=tuple(range(len(components))), + source_schema_version=1, + source_structural_signature=None, + fallback_reason=causal_compilation.fallback_reason, + canonical_slots=(), + compatibility_slots=all_unknown_slots, + reset_slots=(), + external_effort_slots=(), + effort_stages=(), + flow_stage_indices=(), + ) + ) + capability_issues.append( + IRCapabilityIssue( + code="IR_CAUSAL_PRESSURE_FLOW_FALLBACK", + severity=IRDiagnosticSeverity.WARNING, + scope_id="pressure_flow.global", + message=( + "Causal IR v1 is unavailable; the Python reference path may " + "fall back to finite-difference least_squares: " + f"{causal_compilation.fallback_reason or 'unspecified reason'}." + ), + ) + ) + + # Stream operations mirror the current resolver: non-dynamic components + # iterate to an enthalpy fixed point; dynamic stream hooks run afterwards. + connected_h_slots_by_component: dict[str, list[IRSlotRef]] = { + component.name: [] for component in components + } + for connection in connections: + if connection.kind != "physical": + continue + first, second = str(connection.endpoint_a), str(connection.endpoint_b) + first_h = port_slot_by_variable_id.get(f"{first}.h_outflow") + second_h = port_slot_by_variable_id.get(f"{second}.h_outflow") + if first_h is not None and second_h is not None: + connected_h_slots_by_component[connection.endpoint_a.component].append(second_h) + connected_h_slots_by_component[connection.endpoint_b.component].append(first_h) + stream_node_slots = tuple( + variable.slot + for port in port_specs + if port.kind is IRPortKind.PHYSICAL + for variable in port.variables + if variable.name == "h_outflow" + ) + + def stream_operations(selected: Sequence[Component]) -> list[Any]: + operations: list[Any] = [] + for component in selected: + writes = tuple( + variable.slot + for port_index_value in port_indices_by_component[component.name] + for variable in port_specs[port_index_value].variables + if variable.name == "h_outflow" + ) + operations.append( + IRKernelCallOperation( + component_kernel_index[component.name], + component_index[component.name], + IRKernelPhase.PRIMAL, + tuple(connected_h_slots_by_component[component.name]), + writes, + ) + ) + return operations + + stream_nondynamic_stage = add_stage( + "rhs.stream.reference_fixed_point", + IRStageKind.STREAM, + stream_operations(system.stream_resolver._non_dynamic_components), + ) + stream_dynamic_stage = add_stage( + "rhs.stream.dynamic_outflows", + IRStageKind.STREAM, + stream_operations(dynamic_components), + ) + temperature_reference_stage = add_stage( + "rhs.stream.temperature_reference", + IRStageKind.TEMPERATURE_REFERENCE, + tuple( + IRKernelCallOperation( + component_kernel_index[component.name], + component_index[component.name], + IRKernelPhase.PRIMAL, + tuple(connected_h_slots_by_component[component.name]), + tuple(port_slots_by_component[component.name]), + ) + for component in system.stream_resolver._flow_temperature_reference_components + ), + ) + + # Pressure scopes: global plus exact selected blocks used after stream. + scopes: list[IRPressureFlowScope] = [] + scopes.append( + IRPressureFlowScope( + scope_id="pressure_flow.global", + kind=IRPressureFlowScopeKind.NETWORK, + component_indices=tuple(range(len(components))), + unknown_indices=tuple(range(len(algebraic_unknowns))), + equation_indices=tuple(range(len(pressure_equations))), + block_indices=tuple(range(len(pressure_blocks))), + causal_plan_index=0, + residual_tolerance=float(pressure_solver.residual_tolerance), + max_evaluations=int(pressure_solver.max_evaluations), + sparse_pattern_trusted=bool(pressure_solver.jacobian_sparsity_is_trusted), + sparse_fallback_reason=pressure_solver.jacobian_sparsity_fallback_reason, + ) + ) + if not pressure_solver.jacobian_sparsity_is_trusted: + capability_issues.append( + IRCapabilityIssue( + code="IR_PRESSURE_FLOW_FINITE_DIFFERENCE_FALLBACK", + severity=IRDiagnosticSeverity.WARNING, + scope_id="pressure_flow.global", + message=( + "The reference nonlinear solve cannot trust its structural " + "Jacobian and may use finite-difference least_squares: " + f"{pressure_solver.jacobian_sparsity_fallback_reason or 'unspecified reason'}." + ), + ) + ) + secondary_scope_indices: list[int] = [] + closure_plan = system._thermofluid_closure_plan + if closure_plan.uses_conservative_global_solver: + secondary_scope_indices.append(0) + capability_issues.append( + IRCapabilityIssue( + code="IR_THERMOFLUID_CONSERVATIVE_GLOBAL_FALLBACK", + severity=IRDiagnosticSeverity.WARNING, + scope_id="thermofluid", + message=( + "Stream-sensitive closure reuses the whole-network pressure-flow " + "solver: " + f"{closure_plan.conservative_fallback_reason or 'unspecified reason'}." + ), + ) + ) + else: + for block_solver in closure_plan.secondary_block_solvers: + if not block_solver.available: + continue + for block in block_solver.blocks: + unknown_indices = tuple( + unknown_index_by_id[unknown.id] for unknown in block.unknowns + ) + equation_indices = tuple( + equation_index_by_id[equation.id] for equation in block.equations + ) + # block.jacobian_entries is already local row/column structure. + rows: list[dict[int, float]] = [ + {} for _ in range(len(equation_indices)) + ] + for row, column in block.jacobian_entries: + rows[int(row)][int(column)] = 1.0 + local_pattern = _csr_matrix_from_rows( + len(equation_indices), len(unknown_indices), rows + ).pattern + block_index = len(pressure_blocks) + key = (unknown_indices, equation_indices) + existing = block_key_to_index.get(key) + if existing is None: + block_key_to_index[key] = block_index + pressure_blocks.append( + IRPressureFlowBlock( + f"pressure_flow.secondary_block.{block_index}", + unknown_indices, + equation_indices, + local_pattern, + ) + ) + else: + block_index = existing + scope_index = len(scopes) + secondary_scope_indices.append(scope_index) + scopes.append( + IRPressureFlowScope( + scope_id=f"pressure_flow.secondary.{scope_index}", + kind=IRPressureFlowScopeKind.EQUATION_BLOCK, + component_indices=tuple( + component_index[name] for name in block.scope_components + ), + unknown_indices=unknown_indices, + equation_indices=equation_indices, + block_indices=(block_index,), + causal_plan_index=None, + residual_tolerance=float( + block_solver.pressure_flow_solver.residual_tolerance + ), + max_evaluations=int( + block_solver.pressure_flow_solver.max_evaluations + ), + sparse_pattern_trusted=True, + sparse_fallback_reason=None, + ) + ) + if not secondary_scope_indices: + for pressure_scope_solver, names in zip( + closure_plan.secondary_pressure_solvers, + closure_plan.secondary_component_groups, + strict=True, + ): + selected = frozenset(names) + unknown_indices = tuple( + index + for index, unknown in enumerate(pressure_solver.unknowns) + if unknown.component in selected + ) + equation_indices = tuple( + index + for index, equation in enumerate(pressure_solver.equation_templates) + if ( + equation.owner == "component" and equation.owner_id in selected + ) + or ( + equation.owner == "connection" + and connection_specs[connection_index[equation.owner_id]].endpoint_a_port_index + in { + port_index + for name in selected + for port_index in port_indices_by_component[name] + } + ) + ) + scope_index = len(scopes) + secondary_scope_indices.append(scope_index) + scopes.append( + IRPressureFlowScope( + scope_id=f"pressure_flow.physical_island.{scope_index}", + kind=IRPressureFlowScopeKind.PHYSICAL_ISLAND, + component_indices=tuple(component_index[name] for name in names), + unknown_indices=unknown_indices, + equation_indices=equation_indices, + block_indices=(), + causal_plan_index=None, + residual_tolerance=float(pressure_scope_solver.residual_tolerance), + max_evaluations=int(pressure_scope_solver.max_evaluations), + sparse_pattern_trusted=bool( + pressure_scope_solver.jacobian_sparsity_is_trusted + ), + sparse_fallback_reason=( + pressure_scope_solver.jacobian_sparsity_fallback_reason + ), + ) + ) + if not pressure_scope_solver.jacobian_sparsity_is_trusted: + capability_issues.append( + IRCapabilityIssue( + code="IR_PRESSURE_FLOW_FINITE_DIFFERENCE_FALLBACK", + severity=IRDiagnosticSeverity.WARNING, + scope_id=f"pressure_flow.physical_island.{scope_index}", + message=( + "This secondary nonlinear scope cannot trust its " + "structural Jacobian and may use finite-difference " + "least_squares: " + f"{pressure_scope_solver.jacobian_sparsity_fallback_reason or 'unspecified reason'}." + ), + ) + ) + + secondary_pressure_stage = add_stage( + "rhs.pressure_flow.stream_sensitive", + IRStageKind.PRESSURE_FLOW, + ( + IRKernelCallOperation( + system_kernel_index, + None, + IRKernelPhase.RESIDUAL, + pressure_flow_model_read_slots, + (*all_unknown_slots, *residual_slots), + tuple(range(len(pressure_equations))), + ), + ) + if secondary_scope_indices + else (), + ) + + mechanical_acceleration_slots: dict[tuple[str, ...], IRSlotRef] = {} + acceleration_operations: list[Any] = [] + for group in system.mechanical_state_reducer.groups: + names = tuple(sorted(component.name for component in group.components)) + acceleration_slot = slots.add( + IRBufferKind.WORK_FLOAT, + "mechanical_group:" + ",".join(names) + ".acceleration", + semantic="mechanical_acceleration", + role="workspace", + quantity="acceleration", + unit="m/s2", + ) + group_key = _mechanical_group_key(group) + mechanical_acceleration_slots[group_key] = acceleration_slot + reads = tuple( + slot + for name in names + for slot in port_slots_by_component[name] + ) + mode_slot = mode_slot_by_group.get(group_key) + if mode_slot is not None: + reads = (*reads, mode_slot) + acceleration_operations.append( + IRKernelCallOperation( + system_kernel_index, + None, + IRKernelPhase.PRIMAL, + reads, + (acceleration_slot,), + ) + ) + acceleration_stage = add_stage( + "rhs.mechanical_acceleration", + IRStageKind.MECHANICAL_ACCELERATION, + acceleration_operations, + ) + + derivative_operations: list[Any] = [] + for entry in system.mechanical_state_reducer.state_entries: + component = entry.representative if isinstance(entry, MechanicalConstraintGroup) else entry + extra = ( + (mechanical_acceleration_slots[_mechanical_group_key(entry)],) + if isinstance(entry, MechanicalConstraintGroup) + else () + ) + derivative_operations.append( + IRKernelCallOperation( + component_kernel_index[component.name], + component_index[component.name], + IRKernelPhase.DERIVATIVE, + ( + *local_state_by_component[component.name], + *port_slots_by_component[component.name], + *connected_h_slots_by_component[component.name], + *parameter_slots_by_component[component.name], + *extra, + ), + local_derivative_by_component[component.name], + ) + ) + derivative_operations.append( + IRStateMapOperation( + IRStateMapKind.DERIVATIVE_GATHER, + tuple(local_derivative_slots), + tuple(derivative_output_slots), + ) + ) + derivative_stage = add_stage( + "rhs.derivative_gather", + IRStageKind.DERIVATIVE_REDUCE, + derivative_operations, + ) + + # Blocks encode the nested current implementation: stream converges first, + # then the outer stream/temperature/pressure loop monitors mass flows. + blocks: list[IRExecutionBlock] = [] + stream_block_index = len(blocks) + blocks.append( + IRExecutionBlock( + block_id="stream.reference_global_fixed_point", + kind=IRBlockKind.STREAM_SCC, + steps=(_step(stream_nondynamic_stage),), + convergence=IRConvergenceSpec( + monitor_slots=stream_node_slots, + absolute_tolerance=0.0, + relative_tolerance=float(system.stream_resolver.relative_tolerance), + max_iterations=int(system.stream_resolver.max_iterations), + relaxation=1.0, + rollback_slots=stream_node_slots, + failure_policy=IRFailurePolicy.RETRY_SMALLER_STEP, + ), + ) + ) + flow_slots = tuple( + unknown.slot for unknown in algebraic_unknowns if unknown.variable == "m_flow" + ) + transaction_plan_object = system._thermofluid_transaction_plan + transaction_snapshot_slots = _unique_slots( + port_slot_by_variable_id[ + f"{binding.component_name}.{binding.port_name}.{binding.variable}" + ] + for binding in transaction_plan_object.port_value_bindings + ) + thermofluid_block_index = len(blocks) + blocks.append( + IRExecutionBlock( + block_id="thermofluid.reference_fixed_point", + kind=IRBlockKind.FIXED_POINT, + steps=( + _step(stream_block_index, IRStepKind.BLOCK), + _step(stream_dynamic_stage), + _step(temperature_reference_stage), + _step(secondary_pressure_stage), + ), + convergence=IRConvergenceSpec( + monitor_slots=flow_slots, + absolute_tolerance=0.0, + relative_tolerance=1.0e-12, + max_iterations=25, + relaxation=1.0, + rollback_slots=transaction_snapshot_slots, + failure_policy=IRFailurePolicy.RETRY_SMALLER_STEP, + ), + ) + ) + stream_plans = ( + IRStreamPlan( + plan_id="stream.reference_global", + node_slots=stream_node_slots, + strongly_connected_components=( + IRStreamSCC( + "stream.reference_global.scc", + stream_node_slots, + stream_block_index, + ), + ) + if stream_node_slots + else (), + condensed_edges=(), + topological_scc_indices=(0,) if stream_node_slots else (), + ), + ) + + sensitive_names = tuple( + dict.fromkeys( + name + for block_solver in closure_plan.secondary_block_solvers + for name in block_solver.sensitive_components + ) + ) + thermofluid = IRThermofluidPlan( + physical_port_indices=tuple( + index + for index, port in enumerate(port_specs) + if port.kind is IRPortKind.PHYSICAL + ), + global_component_indices=tuple( + component_index[name] for name in closure_plan.global_component_group + ), + stream_plan_index=0, + secondary_pressure_scope_indices=tuple(secondary_scope_indices), + sensitive_component_indices=tuple( + component_index[name] for name in sensitive_names + ), + maximum_iterations=25, + flow_relative_tolerance=1.0e-12, + uses_conservative_global_solver=bool( + closure_plan.uses_conservative_global_solver + ), + conservative_fallback_reason=closure_plan.conservative_fallback_reason, + ) + + pressure_flow = IRPressureFlowPlan( + unknowns=tuple(algebraic_unknowns), + equations=tuple(pressure_equations), + blocks=tuple(pressure_blocks), + scopes=tuple(scopes), + global_scope_index=0, + secondary_scope_indices=tuple(secondary_scope_indices), + pressure_lower_bound=0.0, + ) + + rhs_steps = ( + _step(state_stage), + _step(signal_stage), + _step(mechanical_equivalence_stage), + _step(volume_stage), + _step(property_stage), + _step(global_pressure_stage), + _step(thermofluid_block_index, IRStepKind.BLOCK), + _step(acceleration_stage), + _step(derivative_stage), + ) + + # Event roots and reset plans. Signal event entries are event-source + # families because concrete breakpoint times belong to the run plan. + events: list[IREventSpec] = [] + event_operations: list[Any] = [] + pending_mechanical_events: list[ + tuple[str, MechanicalConstraintGroup, str, IRSlotRef] + ] = [] + for group in event_groups: + representative = group.representative + position_slot = local_state_by_component[representative.name][1] + owners = tuple(component_index[item.name] for item in group.components) + for side, bound, direction in ( + ("lower", group.lower_bound, IREventDirection.DECREASING), + ("upper", group.upper_bound, IREventDirection.INCREASING), + ): + if bound is None: + continue + event_id = ( + "mechanical_group:" + + ",".join(sorted(item.name for item in group.components)) + + f".{side}_impact" + ) + root_slot = slots.add( + IRBufferKind.EVENT_OUTPUT, + event_id, + semantic="event_root", + role="event", + quantity="length", + unit="m", + ) + event_operations.append( + IRLinearCombinationOperation((position_slot,), (1.0,), root_slot, -float(bound)) + ) + pending_mechanical_events.append((event_id, group, side, root_slot)) + for component_name, _event_times in system.signal_resolver._event_sources: + event_id = f"{component_name}.signal_breakpoint_source" + root_slot = slots.add( + IRBufferKind.EVENT_OUTPUT, + event_id, + semantic="time_breakpoint_family", + role="event", + quantity="time", + unit="s", + owner_component_index=component_index[component_name], + ) + event_operations.append( + IRKernelCallOperation( + component_kernel_index[component_name], + component_index[component_name], + IRKernelPhase.EVENT, + (time_slot, *parameter_slots_by_component[component_name]), + (root_slot,), + ) + ) + events.append( + IREventSpec( + event_id=event_id, + event_kind="known_time_breakpoint_source", + owner_component_indices=(component_index[component_name],), + root_slot=root_slot, + direction=IREventDirection.ANY, + terminal=False, + priority=100, + mode_guards=(), + reset_steps=(), + invalidated_caches=( + IRCacheKind.PRESSURE_FLOW, + IRCacheKind.STREAM, + IRCacheKind.JACOBIAN, + IRCacheKind.OUTPUT, + ), + restarts_integrator=True, + ) + ) + event_stage = add_stage("events.evaluate", IRStageKind.EVENT, event_operations) + for event_id, group, side, root_slot in pending_mechanical_events: + group_key = _mechanical_group_key(group) + state_offset = group_solver_offset[group_key] + mode_slot = mode_slot_by_group[group_key] + reset_stage = add_stage( + f"reset.{event_id}", + IRStageKind.RESET, + ( + IRKernelCallOperation( + system_kernel_index, + None, + IRKernelPhase.RESET, + ( + state_input_slots[state_offset], + state_input_slots[state_offset + 1], + mode_slot, + ), + ( + state_input_slots[state_offset], + state_input_slots[state_offset + 1], + mode_slot, + ), + ), + ), + ) + events.append( + IREventSpec( + event_id=event_id, + event_kind=f"mechanical_{side}_impact", + owner_component_indices=tuple( + component_index[item.name] for item in group.components + ), + root_slot=root_slot, + direction=( + IREventDirection.DECREASING + if side == "lower" + else IREventDirection.INCREASING + ), + terminal=False, + priority=10, + mode_guards=(), + reset_steps=(_step(reset_stage),), + invalidated_caches=( + IRCacheKind.PRESSURE_FLOW, + IRCacheKind.STREAM, + IRCacheKind.JACOBIAN, + IRCacheKind.OUTPUT, + ), + restarts_integrator=True, + ) + ) + + # Fixed CSR state Jacobian and deterministic seed-0 coloring. + jacobian_pattern = _csr_pattern(system.jacobian_sparsity()) + jacobian_value_slots = tuple( + slots.add( + IRBufferKind.JACOBIAN_VALUE, + f"jacobian.value.{index}", + semantic="jacobian_value", + role="jacobian", + ) + for index in range(jacobian_pattern.nonzero_count) + ) + try: + from scipy.optimize._numdiff import group_columns + + groups = group_columns(system.jacobian_sparsity(), order=0) + color_count = int(groups.max(initial=-1)) + 1 + color_groups = tuple( + tuple(int(index) for index, color in enumerate(groups) if int(color) == group) + for group in range(color_count) + ) + except (ImportError, AttributeError, TypeError): + color_groups = tuple((index,) for index in range(len(initial_state))) + capability_issues.append( + IRCapabilityIssue( + code="IR_JACOBIAN_COLORING_UNAVAILABLE", + severity=IRDiagnosticSeverity.WARNING, + scope_id="jacobian", + message="Deterministic SciPy column coloring was unavailable.", + ) + ) + positions_by_column: list[list[int]] = [ + [] for _ in range(jacobian_pattern.column_count) + ] + for row in range(jacobian_pattern.row_count): + for position in range( + jacobian_pattern.row_pointers[row], + jacobian_pattern.row_pointers[row + 1], + ): + positions_by_column[jacobian_pattern.column_indices[position]].append(position) + exact_rows = system._exact_ode_jacobian_rows() + analytic_positions: list[int] = [] + for row, columns in exact_rows.items(): + for position in range( + jacobian_pattern.row_pointers[row], + jacobian_pattern.row_pointers[row + 1], + ): + if jacobian_pattern.column_indices[position] in columns: + analytic_positions.append(position) + analytic_position_set = set(analytic_positions) + finite_difference_columns = tuple( + IRFiniteDifferenceColumn( + column_index=column, + value_indices=tuple( + position for position in positions if position not in analytic_position_set + ), + relative_step=1.4901161193847656e-08, + ) + for column, positions in enumerate(positions_by_column) + if any(position not in analytic_position_set for position in positions) + ) + jacobian_stage = add_stage( + "jacobian.fill", + IRStageKind.JACOBIAN, + ( + IRKernelCallOperation( + system_kernel_index, + None, + IRKernelPhase.JACOBIAN, + (time_slot, *state_input_slots), + jacobian_value_slots, + ), + ), + ) + jacobian = IRJacobianPlan( + pattern=jacobian_pattern, + value_slots=jacobian_value_slots, + color_groups=color_groups, + fill_steps=(*rhs_steps, _step(jacobian_stage)), + analytic_value_indices=tuple(sorted(set(analytic_positions))), + local_finite_difference_columns=finite_difference_columns, + ) + + output_operations: list[Any] = [] + for component in components: + derived_sources = tuple( + output_source_slots[index] + for index in output_indices_by_component[component.name] + if result_metadata[index].scope == "component" + ) + if derived_sources: + output_operations.append( + IRKernelCallOperation( + component_kernel_index[component.name], + component_index[component.name], + IRKernelPhase.OUTPUT, + ( + *local_state_by_component.get(component.name, ()), + *port_slots_by_component[component.name], + *parameter_slots_by_component[component.name], + ), + derived_sources, + ) + ) + output_operations.extend( + IRCopyOperation(source, target) + for source, target in zip(output_source_slots, output_slots, strict=True) + ) + output_stage = add_stage("outputs.project", IRStageKind.OUTPUT, output_operations) + output_specs = tuple( + IROutputSpec( + output_id=metadata.key, + component_index=component_index[metadata.component_id], + scope=metadata.scope, + port_name=metadata.port_name, + name=metadata.name, + label=metadata.label, + category=metadata.category, + quantity=metadata.quantity, + unit=metadata.unit, + order=int(metadata.order), + source_slot=output_source_slots[index], + output_slot=output_slots[index], + ) + for index, metadata in enumerate(result_metadata) + ) + + # C-01 can only describe the current object kernels as reference calls. Use + # conservative, explicit input supersets so dependency slicing and cache + # invalidation never omit parameters, modes, medium constants, or port + # values that those Python methods may reach indirectly. C-02 will replace + # these supersets with independently declared, fixed kernel signatures. + medium_slots_by_component: dict[int, list[IRSlotRef]] = { + index: [] for index in range(len(components)) + } + for medium in medium_specs: + for owner_index in medium.component_indices: + medium_slots_by_component[owner_index].extend(medium.parameter_slots) + component_reference_reads = tuple( + _unique_slots( + ( + *component.parameter_slots, + *component.state_slots, + *component.mode_slots, + *component.port_slots, + *component.workspace_float_slots, + *component.workspace_int_slots, + *medium_slots_by_component[owner_index], + ) + ) + for owner_index, component in enumerate(component_instances) + ) + system_reference_reads = _unique_slots( + IRSlotRef(kind, index) + for kind in ( + IRBufferKind.TIME, + IRBufferKind.STATE_INPUT, + IRBufferKind.LOCAL_STATE, + IRBufferKind.ALGEBRAIC, + IRBufferKind.SIGNAL, + IRBufferKind.PARAMETER, + IRBufferKind.CONSTANT, + IRBufferKind.MODE, + IRBufferKind.RUNTIME_INPUT, + ) + for index in range(len(slots.initial[kind])) + ) + completed_stages: list[IRStage] = [] + for stage in stages: + completed_operations: list[Any] = [] + for operation in stage.operations: + if isinstance(operation, IRKernelCallOperation): + extra_reads = ( + system_reference_reads + if operation.component_index is None + else component_reference_reads[operation.component_index] + ) + operation = replace( + operation, + read_slots=_unique_slots((*operation.read_slots, *extra_reads)), + ) + completed_operations.append(operation) + completed_stages.append( + _stage(stage.stage_id, stage.kind, completed_operations) + ) + stages = completed_stages + + # The Python PortState object has an ``m_flow`` attribute even for + # mechanical ports, and the reference transaction helper consequently + # carries inert bindings for those ports. They are always hidden zeroes: + # mechanical active metadata exposes ``f``, not ``m_flow``. The numeric IR + # records only the physical thermofluid closure quantities that can change + # (active pneumatic ``m_flow`` slots); inventing hidden slots or treating + # force as mass flow would give the native contract different semantics. + transaction_flow_slots = _unique_slots( + slot + for item in transaction_plan_object.flow_bindings + for slot in ( + port_slot_by_variable_id.get( + f"{item.component_name}.{item.port_name}.m_flow" + ), + ) + if slot is not None + ) + transaction = IRTransactionPlan( + snapshot_slots=transaction_snapshot_slots, + flow_slots=transaction_flow_slots, + cache_component_indices=tuple( + component_index[item.component.name] + for item in transaction_plan_object.component_cache_bindings + ), + cache_attribute_ids=tuple( + f"{item.component.name}.{name}" + for item in transaction_plan_object.component_cache_bindings + for name in item.attribute_names + ), + diagnostic_owner_ids=tuple( + f"{type(owner).__module__}.{type(owner).__qualname__}:{index}" + for index, owner in enumerate(transaction_plan_object.diagnostic_owners) + ), + restores_on_recoverable_failure=True, + restores_on_fatal_failure=True, + ) + + component_capabilities = tuple( + IRComponentCapability( + component_index=index, + level=IRCapabilityLevel.REFERENCE_ONLY, + supported_phases=_component_phases(component), + missing_features=("c02_pure_numeric_kernel", "native_implementation_version"), + ) + for index, component in enumerate(components) + ) + capability_issues.insert( + 0, + IRCapabilityIssue( + code="IR_NATIVE_KERNELS_NOT_DECLARED", + severity=IRDiagnosticSeverity.WARNING, + scope_id=network.name, + message=( + "System structure and conservative reference-kernel dependencies " + "are described, but Python kernel effects remain opaque until C-02 " + "declares pure numeric call signatures." + ), + ), + ) + capabilities = IRCapabilityReport( + system_level=( + IRCapabilityLevel.UNSUPPORTED + if any(issue.severity is IRDiagnosticSeverity.ERROR for issue in capability_issues) + else IRCapabilityLevel.REFERENCE_ONLY + ), + components=component_capabilities, + issues=tuple(capability_issues), + ) + + entry_points = ( + IREntryPoint( + IREntryPointKind.RHS, + rhs_steps, + (time_slot, *state_input_slots), + tuple(derivative_output_slots), + ), + IREntryPoint( + IREntryPointKind.EVENTS, + (_step(state_stage), _step(event_stage)), + (time_slot, *state_input_slots), + tuple(event.root_slot for event in events), + ), + IREntryPoint( + IREntryPointKind.JACOBIAN, + (*rhs_steps, _step(jacobian_stage)), + (time_slot, *state_input_slots), + jacobian_value_slots, + ), + IREntryPoint( + IREntryPointKind.OUTPUTS, + ( + _step(state_stage), + _step(signal_stage), + _step(mechanical_equivalence_stage), + _step(volume_stage), + _step(property_stage), + _step(global_pressure_stage), + _step(thermofluid_block_index, IRStepKind.BLOCK), + _step(acceleration_stage), + _step(output_stage), + ), + (time_slot, *state_input_slots), + tuple(output_slots), + ), + ) + + required_features = ( + "callback_free", + "independent_entry_points", + "transactional_closure", + "fixed_csr_jacobian", + "reference_kernel_dispatch", + ) + + program = SystemIR( + version=CURRENT_SYSTEM_IR_VERSION, + model_id=network.name, + model_version=str(model_version), + compiler_id=SYSTEM_NUMERIC_IR_COMPILER_ID, + compiler_version=SYSTEM_NUMERIC_IR_COMPILER_VERSION, + numeric_dtype=IRDType.FLOAT64, + buffers=slots.buffers(), + values=tuple(slots.values), + kernels=tuple(kernels), + components=component_instances, + mediums=tuple(medium_specs), + ports=tuple(port_specs), + connections=tuple(connection_specs), + state_reducer=state_reducer, + causal_plans=tuple(causal_plans), + pressure_flow=pressure_flow, + stream_plans=stream_plans, + thermofluid=thermofluid, + stages=tuple(stages), + blocks=tuple(blocks), + entry_points=entry_points, + transaction=transaction, + modes=tuple(modes), + jacobian=jacobian, + events=tuple(events), + outputs=output_specs, + capabilities=capabilities, + required_features=required_features, + ) + + # Validation is mandatory at the compiler boundary. Validation owns the + # exception type and complete invariant list; callers never receive an + # unchecked program. + from .validation import require_valid_system_ir + + return require_valid_system_ir(program) + + +__all__ = ["compile_system_ir"] diff --git a/app/simulation/ir/schema.py b/app/simulation/ir/schema.py new file mode 100644 index 0000000..5bd902d --- /dev/null +++ b/app/simulation/ir/schema.py @@ -0,0 +1,973 @@ +"""Callback-free data contract for the whole-system numeric IR. + +System IR v2 describes a compiled simulation model. It is intentionally a +pure, immutable data graph: Python functions, model objects, object addresses, +and run-local diagnostic state are not part of this module's wire contract. +""" + +from __future__ import annotations + +from dataclasses import dataclass, fields, is_dataclass +from enum import StrEnum +from hashlib import sha256 +import json +from math import isfinite +from struct import pack +from typing import ClassVar +from unicodedata import normalize + + +SYSTEM_NUMERIC_IR_SCHEMA_ID = "system-numeric-ir" +SYSTEM_NUMERIC_IR_SCHEMA_MAJOR = 2 +SYSTEM_NUMERIC_IR_SCHEMA_MINOR = 0 +SYSTEM_NUMERIC_IR_COMPILER_ID = "generic-fluid-system" +SYSTEM_NUMERIC_IR_COMPILER_VERSION = "2.0.0" +NATIVE_NUMERIC_ABI_VERSION = 1 + + +class IRDType(StrEnum): + FLOAT64 = "float64" + INT32 = "int32" + + +class IRBufferKind(StrEnum): + TIME = "time" + STATE_INPUT = "state_input" + DERIVATIVE_OUTPUT = "derivative_output" + LOCAL_STATE = "local_state" + LOCAL_DERIVATIVE = "local_derivative" + ALGEBRAIC = "algebraic" + SIGNAL = "signal" + PARAMETER = "parameter" + CONSTANT = "constant" + MODE = "mode" + WORK_FLOAT = "work_float" + WORK_INT = "work_int" + EVENT_OUTPUT = "event_output" + JACOBIAN_VALUE = "jacobian_value" + RESULT_OUTPUT = "result_output" + RUNTIME_INPUT = "runtime_input" + + +class IRKernelPhase(StrEnum): + PRIMAL = "primal" + RESIDUAL = "residual" + DERIVATIVE = "derivative" + PROPERTY = "property" + EVENT = "event" + RESET = "reset" + JACOBIAN = "jacobian" + OUTPUT = "output" + + +class IRKernelAvailability(StrEnum): + NATIVE = "native" + REFERENCE_ONLY = "reference_only" + + +class IRPortKind(StrEnum): + PHYSICAL = "physical" + SIGNAL = "signal" + + +class IRPortNominalRole(StrEnum): + INLET = "inlet" + OUTLET = "outlet" + BIDIRECTIONAL = "bidirectional" + INPUT = "input" + OUTPUT = "output" + + +class IRPositiveFlowDirection(StrEnum): + INTO_COMPONENT = "intoComponent" + + +class IRVariableRole(StrEnum): + EFFORT = "effort" + FLOW = "flow" + STREAM = "stream" + SIGNAL = "signal" + + +class IRConnectionRule(StrEnum): + EQUAL = "equal" + SUM_TO_ZERO = "sumToZero" + STREAM_MIX = "streamMix" + DIRECTED = "directed" + + +class IREquationOwner(StrEnum): + COMPONENT = "component" + CONNECTION = "connection" + + +class IREquationRelation(StrEnum): + EQUAL = "equal" + SUM_TO_ZERO = "sumToZero" + CONSTITUTIVE = "constitutive" + STATE = "state" + + +class IRPressureFlowScopeKind(StrEnum): + NETWORK = "network" + PHYSICAL_ISLAND = "physical_island" + EQUATION_BLOCK = "equation_block" + + +class IRStageKind(StrEnum): + STATE_REDUCE = "state_reduce" + SIGNAL = "signal" + MECHANICAL_EQUIVALENCE = "mechanical_equivalence" + DYNAMIC_VOLUME = "dynamic_volume" + PROPERTY = "property" + PRESSURE_FLOW = "pressure_flow" + STREAM = "stream" + TEMPERATURE_REFERENCE = "temperature_reference" + THERMOFLUID_FIXED_POINT = "thermofluid_fixed_point" + MECHANICAL_ACCELERATION = "mechanical_acceleration" + DERIVATIVE_REDUCE = "derivative_reduce" + EVENT = "event" + JACOBIAN = "jacobian" + OUTPUT = "output" + RESET = "reset" + + +class IREntryPointKind(StrEnum): + RHS = "rhs" + EVENTS = "events" + JACOBIAN = "jacobian" + OUTPUTS = "outputs" + + +class IRBlockKind(StrEnum): + SEQUENCE = "sequence" + FIXED_POINT = "fixed_point" + STREAM_SCC = "stream_scc" + + +class IRStepKind(StrEnum): + STAGE = "stage" + BLOCK = "block" + + +class IRStateMapKind(StrEnum): + SCATTER = "scatter" + DERIVATIVE_GATHER = "derivative_gather" + + +class IREventDirection(StrEnum): + DECREASING = "decreasing" + ANY = "any" + INCREASING = "increasing" + + +class IRFailurePolicy(StrEnum): + FAIL = "fail" + RETRY_SMALLER_STEP = "retry_smaller_step" + + +class IRCacheKind(StrEnum): + PROPERTY = "property" + PRESSURE_FLOW = "pressure_flow" + STREAM = "stream" + JACOBIAN = "jacobian" + OUTPUT = "output" + + +class IRCapabilityLevel(StrEnum): + NATIVE = "native" + REFERENCE_ONLY = "reference_only" + UNSUPPORTED = "unsupported" + + +class IRDiagnosticSeverity(StrEnum): + INFO = "info" + WARNING = "warning" + ERROR = "error" + + +class IROpcode(StrEnum): + FILL = "fill" + COPY = "copy" + SCATTER = "scatter" + LINEAR_COMBINATION = "linear_combination" + STATE_MAP = "state_map" + KERNEL_CALL = "kernel_call" + EFFORT_BROADCAST = "effort_broadcast" + FLOW_ASSIGN = "flow_assign" + CHECK_FINITE = "check_finite" + + +@dataclass(frozen=True, slots=True) +class IRSchemaVersion: + schema_id: str = SYSTEM_NUMERIC_IR_SCHEMA_ID + major: int = SYSTEM_NUMERIC_IR_SCHEMA_MAJOR + minor: int = SYSTEM_NUMERIC_IR_SCHEMA_MINOR + + +CURRENT_SYSTEM_IR_VERSION = IRSchemaVersion() + + +@dataclass(frozen=True, slots=True) +class IRSlotRef: + buffer: IRBufferKind + index: int + + +@dataclass(frozen=True, slots=True) +class IRBufferSpec: + kind: IRBufferKind + dtype: IRDType + size: int + initial_float_values: tuple[float, ...] = () + initial_int_values: tuple[int, ...] = () + + +@dataclass(frozen=True, slots=True) +class IRValueSpec: + value_id: str + slot: IRSlotRef + semantic: str + role: str + quantity: str + unit: str + scale: float + lower_bound: float | None = None + upper_bound: float | None = None + owner_component_index: int | None = None + + +@dataclass(frozen=True, slots=True) +class IRKernelPhaseSpec: + """Phase capability tag; each call's ordered slot lists define its C-01 ABI. + + Fixed component-kernel signatures deliberately belong to the C-02 contract. + Recording placeholder arities here would make the current reference-only + kernels look more strictly specified than they are. + """ + + phase: IRKernelPhase + + +@dataclass(frozen=True, slots=True) +class IRKernelSpec: + kernel_id: str + model_type: str + model_version: str + implementation_version: str + availability: IRKernelAvailability + unavailable_reason: str | None + phases: tuple[IRKernelPhaseSpec, ...] + parameter_count: int + state_count: int + mode_count: int + workspace_float_count: int + workspace_int_count: int + + +@dataclass(frozen=True, slots=True) +class IRComponentInstance: + instance_id: str + kernel_index: int + parameter_slots: tuple[IRSlotRef, ...] + state_slots: tuple[IRSlotRef, ...] + derivative_slots: tuple[IRSlotRef, ...] + mode_slots: tuple[IRSlotRef, ...] + port_indices: tuple[int, ...] + port_slots: tuple[IRSlotRef, ...] + output_indices: tuple[int, ...] + workspace_float_slots: tuple[IRSlotRef, ...] + workspace_int_slots: tuple[IRSlotRef, ...] + + +@dataclass(frozen=True, slots=True) +class IRPortVariable: + variable_id: str + name: str + role: IRVariableRole + connection_rule: IRConnectionRule + quantity: str + unit: str + result_visible: bool + slot: IRSlotRef + + +@dataclass(frozen=True, slots=True) +class IRPortSpec: + port_id: str + component_index: int + name: str + kind: IRPortKind + domain: str + nominal_role: IRPortNominalRole + positive_flow_direction: IRPositiveFlowDirection | None + variables: tuple[IRPortVariable, ...] + + +@dataclass(frozen=True, slots=True) +class IRConnectionVariable: + name: str + rule: IRConnectionRule + endpoint_a_slot: IRSlotRef + endpoint_b_slot: IRSlotRef + + +@dataclass(frozen=True, slots=True) +class IRConnectionSpec: + connection_id: str + kind: IRPortKind + domain: str + endpoint_a_port_index: int + endpoint_b_port_index: int + variables: tuple[IRConnectionVariable, ...] + + +@dataclass(frozen=True, slots=True) +class IRMediumSpec: + medium_id: str + name: str + implementation_id: str + implementation_version: str + parameter_slots: tuple[IRSlotRef, ...] + component_indices: tuple[int, ...] + + +@dataclass(frozen=True, slots=True) +class IRCSRPattern: + row_count: int + column_count: int + row_pointers: tuple[int, ...] + column_indices: tuple[int, ...] + + @property + def nonzero_count(self) -> int: + return len(self.column_indices) + + +@dataclass(frozen=True, slots=True) +class IRCSRMatrix: + pattern: IRCSRPattern + values: tuple[float, ...] + + +@dataclass(frozen=True, slots=True) +class IRStateReducer: + solver_state_count: int + local_state_slots: tuple[IRSlotRef, ...] + raw_derivative_slots: tuple[IRSlotRef, ...] + state_scatter: IRCSRMatrix + derivative_gather: IRCSRMatrix + initial_state: tuple[float, ...] + absolute_tolerances: tuple[float, ...] + + +@dataclass(frozen=True, slots=True) +class IRFillOperation: + opcode: ClassVar[IROpcode] = IROpcode.FILL + target_slots: tuple[IRSlotRef, ...] + value: float + + +@dataclass(frozen=True, slots=True) +class IRCopyOperation: + opcode: ClassVar[IROpcode] = IROpcode.COPY + source_slot: IRSlotRef + target_slot: IRSlotRef + + +@dataclass(frozen=True, slots=True) +class IRScatterOperation: + opcode: ClassVar[IROpcode] = IROpcode.SCATTER + source_slot: IRSlotRef + target_slots: tuple[IRSlotRef, ...] + + +@dataclass(frozen=True, slots=True) +class IRLinearCombinationOperation: + opcode: ClassVar[IROpcode] = IROpcode.LINEAR_COMBINATION + source_slots: tuple[IRSlotRef, ...] + weights: tuple[float, ...] + target_slot: IRSlotRef + bias: float = 0.0 + + +@dataclass(frozen=True, slots=True) +class IRStateMapOperation: + opcode: ClassVar[IROpcode] = IROpcode.STATE_MAP + map_kind: IRStateMapKind + source_slots: tuple[IRSlotRef, ...] + target_slots: tuple[IRSlotRef, ...] + + +@dataclass(frozen=True, slots=True) +class IRKernelCallOperation: + opcode: ClassVar[IROpcode] = IROpcode.KERNEL_CALL + kernel_index: int + component_index: int | None + phase: IRKernelPhase + read_slots: tuple[IRSlotRef, ...] + write_slots: tuple[IRSlotRef, ...] + equation_indices: tuple[int, ...] = () + + +@dataclass(frozen=True, slots=True) +class IREffortBroadcastOperation: + opcode: ClassVar[IROpcode] = IROpcode.EFFORT_BROADCAST + variable: str + anchor_slot: IRSlotRef + residual_slot: IRSlotRef + result_slot: IRSlotRef + scatter_slots: tuple[IRSlotRef, ...] + equation_id: str + lower_bound: float | None = None + upper_bound: float | None = None + + +@dataclass(frozen=True, slots=True) +class IRFlowAssignmentOperation: + opcode: ClassVar[IROpcode] = IROpcode.FLOW_ASSIGN + value_slot: IRSlotRef + result_slot: IRSlotRef + scatter_slots: tuple[IRSlotRef, ...] + equation_id: str + + +@dataclass(frozen=True, slots=True) +class IRCheckFiniteOperation: + opcode: ClassVar[IROpcode] = IROpcode.CHECK_FINITE + slots: tuple[IRSlotRef, ...] + error_code: str + + +IROperation = ( + IRFillOperation + | IRCopyOperation + | IRScatterOperation + | IRLinearCombinationOperation + | IRStateMapOperation + | IRKernelCallOperation + | IREffortBroadcastOperation + | IRFlowAssignmentOperation + | IRCheckFiniteOperation +) + + +def operation_read_slots(operation: IROperation) -> tuple[IRSlotRef, ...]: + if isinstance(operation, IRFillOperation): + return () + if isinstance(operation, (IRCopyOperation, IRScatterOperation)): + return (operation.source_slot,) + if isinstance(operation, (IRLinearCombinationOperation, IRStateMapOperation)): + return operation.source_slots + if isinstance(operation, IRKernelCallOperation): + return operation.read_slots + if isinstance(operation, IREffortBroadcastOperation): + return (operation.anchor_slot, operation.residual_slot) + if isinstance(operation, IRFlowAssignmentOperation): + return (operation.value_slot,) + if isinstance(operation, IRCheckFiniteOperation): + return operation.slots + raise TypeError(f"Unsupported IR operation: {type(operation).__name__}.") + + +def operation_write_slots(operation: IROperation) -> tuple[IRSlotRef, ...]: + if isinstance(operation, IRFillOperation): + return operation.target_slots + if isinstance(operation, IRCopyOperation): + return (operation.target_slot,) + if isinstance(operation, IRScatterOperation): + return operation.target_slots + if isinstance(operation, IRLinearCombinationOperation): + return (operation.target_slot,) + if isinstance(operation, IRStateMapOperation): + return operation.target_slots + if isinstance(operation, IRKernelCallOperation): + return operation.write_slots + if isinstance(operation, IREffortBroadcastOperation): + return (operation.result_slot, *operation.scatter_slots) + if isinstance(operation, IRFlowAssignmentOperation): + return (operation.result_slot, *operation.scatter_slots) + if isinstance(operation, IRCheckFiniteOperation): + return () + raise TypeError(f"Unsupported IR operation: {type(operation).__name__}.") + + +@dataclass(frozen=True, slots=True) +class IRStage: + stage_id: str + kind: IRStageKind + operations: tuple[IROperation, ...] + declared_read_slots: tuple[IRSlotRef, ...] + declared_write_slots: tuple[IRSlotRef, ...] + + +@dataclass(frozen=True, slots=True) +class IRStepRef: + kind: IRStepKind + index: int + + +@dataclass(frozen=True, slots=True) +class IRConvergenceSpec: + monitor_slots: tuple[IRSlotRef, ...] + absolute_tolerance: float + relative_tolerance: float + max_iterations: int + relaxation: float + rollback_slots: tuple[IRSlotRef, ...] + failure_policy: IRFailurePolicy + + +@dataclass(frozen=True, slots=True) +class IRExecutionBlock: + block_id: str + kind: IRBlockKind + steps: tuple[IRStepRef, ...] + convergence: IRConvergenceSpec | None = None + + +@dataclass(frozen=True, slots=True) +class IREntryPoint: + kind: IREntryPointKind + steps: tuple[IRStepRef, ...] + input_slots: tuple[IRSlotRef, ...] + output_slots: tuple[IRSlotRef, ...] + + +@dataclass(frozen=True, slots=True) +class IRCausalEffortStageRef: + variable: str + stage_index: int + + +@dataclass(frozen=True, slots=True) +class IRCausalPlan: + plan_id: str + scope_component_indices: tuple[int, ...] + source_schema_version: int + source_structural_signature: str | None + fallback_reason: str | None + canonical_slots: tuple[IRSlotRef, ...] + compatibility_slots: tuple[IRSlotRef, ...] + reset_slots: tuple[IRSlotRef, ...] + external_effort_slots: tuple[IRSlotRef, ...] + effort_stages: tuple[IRCausalEffortStageRef, ...] + flow_stage_indices: tuple[int, ...] + + +@dataclass(frozen=True, slots=True) +class IRAlgebraicUnknown: + unknown_id: str + component_index: int + port_index: int + variable: str + role: IRVariableRole + slot: IRSlotRef + scale: float + lower_bound: float | None = None + upper_bound: float | None = None + + +@dataclass(frozen=True, slots=True) +class IRPressureFlowEquation: + equation_id: str + owner: IREquationOwner + owner_index: int + relation: IREquationRelation + role: IRVariableRole | None + variable_slots: tuple[IRSlotRef, ...] + residual_slot: IRSlotRef + scale: float + + +@dataclass(frozen=True, slots=True) +class IRPressureFlowBlock: + block_id: str + unknown_indices: tuple[int, ...] + equation_indices: tuple[int, ...] + jacobian_pattern: IRCSRPattern + + +@dataclass(frozen=True, slots=True) +class IRPressureFlowScope: + scope_id: str + kind: IRPressureFlowScopeKind + component_indices: tuple[int, ...] + unknown_indices: tuple[int, ...] + equation_indices: tuple[int, ...] + block_indices: tuple[int, ...] + causal_plan_index: int | None + residual_tolerance: float + max_evaluations: int + sparse_pattern_trusted: bool + sparse_fallback_reason: str | None + + +@dataclass(frozen=True, slots=True) +class IRPressureFlowPlan: + unknowns: tuple[IRAlgebraicUnknown, ...] + equations: tuple[IRPressureFlowEquation, ...] + blocks: tuple[IRPressureFlowBlock, ...] + scopes: tuple[IRPressureFlowScope, ...] + global_scope_index: int + secondary_scope_indices: tuple[int, ...] + pressure_lower_bound: float + + +@dataclass(frozen=True, slots=True) +class IRStreamSCC: + scc_id: str + node_slots: tuple[IRSlotRef, ...] + block_index: int + + +@dataclass(frozen=True, slots=True) +class IRStreamEdge: + source_scc_index: int + target_scc_index: int + + +@dataclass(frozen=True, slots=True) +class IRStreamPlan: + plan_id: str + node_slots: tuple[IRSlotRef, ...] + strongly_connected_components: tuple[IRStreamSCC, ...] + condensed_edges: tuple[IRStreamEdge, ...] + topological_scc_indices: tuple[int, ...] + + +@dataclass(frozen=True, slots=True) +class IRThermofluidPlan: + physical_port_indices: tuple[int, ...] + global_component_indices: tuple[int, ...] + stream_plan_index: int + secondary_pressure_scope_indices: tuple[int, ...] + sensitive_component_indices: tuple[int, ...] + maximum_iterations: int + flow_relative_tolerance: float + uses_conservative_global_solver: bool + conservative_fallback_reason: str | None + + +@dataclass(frozen=True, slots=True) +class IRTransactionPlan: + snapshot_slots: tuple[IRSlotRef, ...] + flow_slots: tuple[IRSlotRef, ...] + cache_component_indices: tuple[int, ...] + cache_attribute_ids: tuple[str, ...] + diagnostic_owner_ids: tuple[str, ...] + restores_on_recoverable_failure: bool + restores_on_fatal_failure: bool + + +@dataclass(frozen=True, slots=True) +class IRModeValueSpec: + value: int + name: str + + +@dataclass(frozen=True, slots=True) +class IRModeSpec: + mode_id: str + slot: IRSlotRef + owner_component_indices: tuple[int, ...] + values: tuple[IRModeValueSpec, ...] + initial_value: int + + +@dataclass(frozen=True, slots=True) +class IRFiniteDifferenceColumn: + column_index: int + value_indices: tuple[int, ...] + relative_step: float + + +@dataclass(frozen=True, slots=True) +class IRJacobianPlan: + pattern: IRCSRPattern + value_slots: tuple[IRSlotRef, ...] + color_groups: tuple[tuple[int, ...], ...] + fill_steps: tuple[IRStepRef, ...] + analytic_value_indices: tuple[int, ...] + local_finite_difference_columns: tuple[IRFiniteDifferenceColumn, ...] + + +@dataclass(frozen=True, slots=True) +class IRModeGuard: + mode_slot: IRSlotRef + allowed_values: tuple[int, ...] + + +@dataclass(frozen=True, slots=True) +class IREventSpec: + event_id: str + event_kind: str + owner_component_indices: tuple[int, ...] + root_slot: IRSlotRef + direction: IREventDirection + terminal: bool + priority: int + mode_guards: tuple[IRModeGuard, ...] + reset_steps: tuple[IRStepRef, ...] + invalidated_caches: tuple[IRCacheKind, ...] + restarts_integrator: bool + + +@dataclass(frozen=True, slots=True) +class IROutputSpec: + output_id: str + component_index: int + scope: str + port_name: str | None + name: str + label: str + category: str + quantity: str + unit: str + order: int + source_slot: IRSlotRef + output_slot: IRSlotRef + scale: float = 1.0 + offset: float = 0.0 + + +@dataclass(frozen=True, slots=True) +class IRComponentCapability: + component_index: int + level: IRCapabilityLevel + supported_phases: tuple[IRKernelPhase, ...] + missing_features: tuple[str, ...] + + +@dataclass(frozen=True, slots=True) +class IRCapabilityIssue: + code: str + severity: IRDiagnosticSeverity + scope_id: str + message: str + + +@dataclass(frozen=True, slots=True) +class IRCapabilityReport: + system_level: IRCapabilityLevel + components: tuple[IRComponentCapability, ...] + issues: tuple[IRCapabilityIssue, ...] + + +@dataclass(frozen=True, slots=True) +class SystemIR: + version: IRSchemaVersion + model_id: str + model_version: str + compiler_id: str + compiler_version: str + numeric_dtype: IRDType + buffers: tuple[IRBufferSpec, ...] + values: tuple[IRValueSpec, ...] + kernels: tuple[IRKernelSpec, ...] + components: tuple[IRComponentInstance, ...] + mediums: tuple[IRMediumSpec, ...] + ports: tuple[IRPortSpec, ...] + connections: tuple[IRConnectionSpec, ...] + state_reducer: IRStateReducer + causal_plans: tuple[IRCausalPlan, ...] + pressure_flow: IRPressureFlowPlan + stream_plans: tuple[IRStreamPlan, ...] + thermofluid: IRThermofluidPlan + stages: tuple[IRStage, ...] + blocks: tuple[IRExecutionBlock, ...] + entry_points: tuple[IREntryPoint, ...] + transaction: IRTransactionPlan + modes: tuple[IRModeSpec, ...] + jacobian: IRJacobianPlan + events: tuple[IREventSpec, ...] + outputs: tuple[IROutputSpec, ...] + capabilities: IRCapabilityReport + required_features: tuple[str, ...] = () + + def canonical_json_bytes(self) -> bytes: + return canonical_json_bytes(self) + + def calculate_structural_signature(self) -> str: + return sha256(self.canonical_json_bytes()).hexdigest() + + @property + def structural_signature(self) -> str: + return self.calculate_structural_signature() + + +@dataclass(frozen=True, slots=True) +class IRNativeBuildIdentity: + abi_version: int + target_triple: str + compiler_id: str + compiler_version: str + compile_flags: tuple[str, ...] + floating_point_policy: str + kernel_library_signature: str + + +@dataclass(frozen=True, slots=True) +class _IRNativeArtifactKeyInput: + program_signature: str + build: IRNativeBuildIdentity + + +_CANONICAL_TYPE_NAMES: tuple[tuple[type[object], str], ...] = ( + (IRSchemaVersion, "schema_version"), + (IRSlotRef, "slot_ref"), + (IRBufferSpec, "buffer"), + (IRValueSpec, "value"), + (IRKernelPhaseSpec, "kernel_phase"), + (IRKernelSpec, "kernel"), + (IRComponentInstance, "component"), + (IRPortVariable, "port_variable"), + (IRPortSpec, "port"), + (IRConnectionVariable, "connection_variable"), + (IRConnectionSpec, "connection"), + (IRMediumSpec, "medium"), + (IRCSRPattern, "csr_pattern"), + (IRCSRMatrix, "csr_matrix"), + (IRStateReducer, "state_reducer"), + (IRFillOperation, "operation"), + (IRCopyOperation, "operation"), + (IRScatterOperation, "operation"), + (IRLinearCombinationOperation, "operation"), + (IRStateMapOperation, "operation"), + (IRKernelCallOperation, "operation"), + (IREffortBroadcastOperation, "operation"), + (IRFlowAssignmentOperation, "operation"), + (IRCheckFiniteOperation, "operation"), + (IRStage, "stage"), + (IRStepRef, "step_ref"), + (IRConvergenceSpec, "convergence"), + (IRExecutionBlock, "block"), + (IREntryPoint, "entry_point"), + (IRCausalEffortStageRef, "causal_effort_stage"), + (IRCausalPlan, "causal_plan"), + (IRAlgebraicUnknown, "algebraic_unknown"), + (IRPressureFlowEquation, "pressure_flow_equation"), + (IRPressureFlowBlock, "pressure_flow_block"), + (IRPressureFlowScope, "pressure_flow_scope"), + (IRPressureFlowPlan, "pressure_flow_plan"), + (IRStreamSCC, "stream_scc"), + (IRStreamEdge, "stream_edge"), + (IRStreamPlan, "stream_plan"), + (IRThermofluidPlan, "thermofluid_plan"), + (IRTransactionPlan, "transaction_plan"), + (IRModeValueSpec, "mode_value"), + (IRModeSpec, "mode"), + (IRFiniteDifferenceColumn, "finite_difference_column"), + (IRJacobianPlan, "jacobian_plan"), + (IRModeGuard, "mode_guard"), + (IREventSpec, "event"), + (IROutputSpec, "output"), + (IRComponentCapability, "component_capability"), + (IRCapabilityIssue, "capability_issue"), + (IRCapabilityReport, "capability_report"), + (SystemIR, "system_ir"), + (IRNativeBuildIdentity, "native_build"), + (_IRNativeArtifactKeyInput, "native_artifact_key_input"), +) + + +_OPERATION_TYPES = ( + IRFillOperation, + IRCopyOperation, + IRScatterOperation, + IRLinearCombinationOperation, + IRStateMapOperation, + IRKernelCallOperation, + IREffortBroadcastOperation, + IRFlowAssignmentOperation, + IRCheckFiniteOperation, +) + + +def _canonical_type_name(value: object) -> str: + value_type = type(value) + for candidate, name in _CANONICAL_TYPE_NAMES: + if value_type is candidate: + return name + raise TypeError(f"Unsupported IR schema object: {value_type.__name__}.") + + +def _canonical_float(value: float) -> object: + numeric = float(value) + if not isfinite(numeric): + raise ValueError("Canonical IR JSON does not permit NaN or infinity.") + if numeric == 0.0: + numeric = 0.0 + return {"$float64": pack(">d", numeric).hex()} + + +def _canonical_value(value: object) -> object: + if value is None or isinstance(value, bool): + return value + if isinstance(value, StrEnum): + return value.value + if isinstance(value, int): + return value + if isinstance(value, float): + return _canonical_float(value) + if isinstance(value, str): + return normalize("NFC", value) + if isinstance(value, tuple): + return [_canonical_value(item) for item in value] + if is_dataclass(value) and not isinstance(value, type): + payload: dict[str, object] = {"$type": _canonical_type_name(value)} + if isinstance(value, _OPERATION_TYPES): + payload["opcode"] = value.opcode.value + for item in fields(value): + payload[item.name] = _canonical_value(getattr(value, item.name)) + return payload + raise TypeError( + "Canonical IR JSON accepts only schema dataclasses, tuples, enums, and " + f"scalar values; received {type(value).__name__}." + ) + + +def canonical_json_bytes(value: object) -> bytes: + """Return the exact platform-independent canonical JSON byte sequence.""" + + return json.dumps( + _canonical_value(value), + ensure_ascii=True, + allow_nan=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + + +def native_artifact_key( + program: SystemIR, + build: IRNativeBuildIdentity, +) -> str: + """Build cache key; target details never contaminate the program hash.""" + + if build.abi_version != NATIVE_NUMERIC_ABI_VERSION: + raise ValueError( + "Native build ABI does not match NATIVE_NUMERIC_ABI_VERSION." + ) + for field_name, value in ( + ("target_triple", build.target_triple), + ("compiler_id", build.compiler_id), + ("compiler_version", build.compiler_version), + ("floating_point_policy", build.floating_point_policy), + ): + if not value: + raise ValueError(f"Native build {field_name} must not be empty.") + if any(not flag for flag in build.compile_flags): + raise ValueError("Native build flags must not contain empty entries.") + if ( + len(build.kernel_library_signature) != 64 + or any( + character not in "0123456789abcdef" + for character in build.kernel_library_signature + ) + ): + raise ValueError( + "Native kernel library signature must be lowercase SHA-256 hex." + ) + payload = _IRNativeArtifactKeyInput(program.structural_signature, build) + return sha256(canonical_json_bytes(payload)).hexdigest() diff --git a/app/simulation/ir/validation.py b/app/simulation/ir/validation.py new file mode 100644 index 0000000..cf0564e --- /dev/null +++ b/app/simulation/ir/validation.py @@ -0,0 +1,1583 @@ +"""Fail-closed static validation for System Numeric IR v2.""" + +from __future__ import annotations + +from dataclasses import dataclass, fields, is_dataclass +from math import isfinite +from types import UnionType +from typing import Union, get_args, get_origin, get_type_hints + +from app.simulation.ir import schema as ir + + +SUPPORTED_REQUIRED_FEATURES = frozenset( + { + "callback_free", + "independent_entry_points", + "transactional_closure", + "fixed_csr_jacobian", + "reference_kernel_dispatch", + } +) + + +@dataclass(frozen=True, slots=True) +class IRValidationIssue: + code: str + path: str + message: str + + +@dataclass(frozen=True, slots=True) +class IRValidationReport: + issues: tuple[IRValidationIssue, ...] + + @property + def valid(self) -> bool: + return not self.issues + + +class SystemIRValidationError(ValueError): + def __init__(self, report: IRValidationReport) -> None: + self.report = report + summary = "; ".join( + f"{issue.code} at {issue.path}: {issue.message}" + for issue in report.issues[:8] + ) + if len(report.issues) > 8: + summary += f"; and {len(report.issues) - 8} more issue(s)" + super().__init__(f"System IR validation failed: {summary}") + + +class _Issues: + def __init__(self) -> None: + self.items: list[IRValidationIssue] = [] + + def add(self, code: str, path: str, message: str) -> None: + self.items.append(IRValidationIssue(code, path, message)) + + def unique_ids(self, values: tuple[object, ...], attribute: str, path: str) -> None: + seen: dict[str, int] = {} + for index, value in enumerate(values): + identity = str(getattr(value, attribute)) + if not identity: + self.add("EMPTY_ID", f"{path}[{index}].{attribute}", "ID must not be empty.") + if identity in seen: + self.add( + "DUPLICATE_ID", + f"{path}[{index}].{attribute}", + f"Duplicates {path}[{seen[identity]}].", + ) + else: + seen[identity] = index + + +def _finite(value: float | None) -> bool: + return value is None or isfinite(float(value)) + + +_TYPE_HINT_CACHE: dict[type[object], dict[str, object]] = {} + + +def _matches_runtime_type(value: object, annotation: object) -> bool: + origin = get_origin(annotation) + if origin in {UnionType, Union}: + return any(_matches_runtime_type(value, item) for item in get_args(annotation)) + if origin is tuple: + return type(value) is tuple + if annotation is type(None): + return value is None + if isinstance(annotation, type): + return type(value) is annotation + return False + + +def _validate_runtime_types( + value: object, + annotation: object, + path: str, + issues: _Issues, + seen: set[tuple[int, object]], +) -> None: + """Reject values that would serialize differently from their schema type.""" + + origin = get_origin(annotation) + if origin in {UnionType, Union}: + matching = tuple( + item for item in get_args(annotation) if _matches_runtime_type(value, item) + ) + if len(matching) != 1: + issues.add( + "RUNTIME_TYPE_MISMATCH", + path, + f"Value has type {type(value).__name__}, which does not match the declared union.", + ) + return + _validate_runtime_types(value, matching[0], path, issues, seen) + return + if origin is tuple: + if type(value) is not tuple: + issues.add( + "RUNTIME_TYPE_MISMATCH", + path, + "Schema arrays must be immutable tuples before serialization.", + ) + return + arguments = get_args(annotation) + if len(arguments) == 2 and arguments[1] is Ellipsis: + for index, item in enumerate(value): + _validate_runtime_types( + item, + arguments[0], + f"{path}[{index}]", + issues, + seen, + ) + return + if len(value) != len(arguments): + issues.add( + "RUNTIME_TYPE_MISMATCH", + path, + "Tuple length differs from its declared schema type.", + ) + return + for index, (item, item_type) in enumerate(zip(value, arguments)): + _validate_runtime_types( + item, + item_type, + f"{path}[{index}]", + issues, + seen, + ) + return + if annotation is type(None): + if value is not None: + issues.add("RUNTIME_TYPE_MISMATCH", path, "Expected null.") + return + if not isinstance(annotation, type) or type(value) is not annotation: + expected_name = getattr(annotation, "__name__", str(annotation)) + issues.add( + "RUNTIME_TYPE_MISMATCH", + path, + f"Expected {expected_name}, received {type(value).__name__}.", + ) + return + if not is_dataclass(value): + return + marker = (id(value), annotation) + if marker in seen: + return + seen.add(marker) + hints = _TYPE_HINT_CACHE.get(annotation) + if hints is None: + hints = get_type_hints(annotation) + _TYPE_HINT_CACHE[annotation] = hints + for item in fields(value): + _validate_runtime_types( + getattr(value, item.name), + hints[item.name], + f"{path}.{item.name}", + issues, + seen, + ) + + +def _validate_csr( + pattern: ir.IRCSRPattern, + path: str, + issues: _Issues, +) -> None: + if pattern.row_count < 0 or pattern.column_count < 0: + issues.add("CSR_NEGATIVE_SHAPE", path, "CSR dimensions must be non-negative.") + pointers = pattern.row_pointers + columns = pattern.column_indices + if len(pointers) != pattern.row_count + 1: + issues.add("CSR_ROW_POINTER_LENGTH", f"{path}.row_pointers", "Length must be row_count + 1.") + return + if not pointers or pointers[0] != 0: + issues.add("CSR_ROW_POINTER_START", f"{path}.row_pointers", "First pointer must be zero.") + return + if any(first > second for first, second in zip(pointers, pointers[1:])): + issues.add("CSR_ROW_POINTER_ORDER", f"{path}.row_pointers", "Pointers must be monotonic.") + return + if pointers[-1] != len(columns): + issues.add("CSR_NONZERO_COUNT", path, "Final row pointer must equal column count length.") + return + for row in range(pattern.row_count): + row_columns = columns[pointers[row] : pointers[row + 1]] + if any(column < 0 or column >= pattern.column_count for column in row_columns): + issues.add("CSR_COLUMN_BOUNDS", f"{path}.row[{row}]", "Column index is out of bounds.") + if tuple(sorted(set(row_columns))) != row_columns: + issues.add("CSR_ROW_CANONICAL", f"{path}.row[{row}]", "Columns must be sorted and unique.") + + +def _operation_slots(operation: ir.IROperation) -> tuple[ir.IRSlotRef, ...]: + return (*ir.operation_read_slots(operation), *ir.operation_write_slots(operation)) + + +def validate_system_ir(program: ir.SystemIR) -> IRValidationReport: + """Validate every cross-reference and numeric invariant in one program.""" + + issues = _Issues() + _validate_runtime_types(program, ir.SystemIR, "$", issues, set()) + if issues.items: + return IRValidationReport(tuple(issues.items)) + if program.version.schema_id != ir.SYSTEM_NUMERIC_IR_SCHEMA_ID: + issues.add("SCHEMA_ID_UNSUPPORTED", "version.schema_id", "Unknown schema ID.") + if program.version.major != ir.SYSTEM_NUMERIC_IR_SCHEMA_MAJOR: + issues.add("SCHEMA_MAJOR_UNSUPPORTED", "version.major", "Unknown schema major version.") + if program.version.minor < 0: + issues.add("SCHEMA_MINOR_INVALID", "version.minor", "Schema minor version cannot be negative.") + elif program.version.minor > ir.SYSTEM_NUMERIC_IR_SCHEMA_MINOR: + issues.add("SCHEMA_MINOR_UNSUPPORTED", "version.minor", "Schema minor version is newer than this reader.") + if program.numeric_dtype is not ir.IRDType.FLOAT64: + issues.add("NUMERIC_DTYPE_UNSUPPORTED", "numeric_dtype", "Only float64 programs are supported.") + for name, value in ( + ("model_id", program.model_id), + ("model_version", program.model_version), + ("compiler_id", program.compiler_id), + ("compiler_version", program.compiler_version), + ): + if not value: + issues.add("EMPTY_ID", name, f"{name} must not be empty.") + unknown_features = sorted(set(program.required_features) - SUPPORTED_REQUIRED_FEATURES) + if unknown_features: + issues.add("REQUIRED_FEATURE_UNSUPPORTED", "required_features", ", ".join(unknown_features)) + if len(set(program.required_features)) != len(program.required_features): + issues.add("DUPLICATE_REQUIRED_FEATURE", "required_features", "Features must be unique.") + + buffer_by_kind: dict[ir.IRBufferKind, ir.IRBufferSpec] = {} + expected_buffer_dtypes = { + kind: ( + ir.IRDType.INT32 + if kind in {ir.IRBufferKind.MODE, ir.IRBufferKind.WORK_INT} + else ir.IRDType.FLOAT64 + ) + for kind in ir.IRBufferKind + } + for index, buffer in enumerate(program.buffers): + path = f"buffers[{index}]" + if buffer.kind in buffer_by_kind: + issues.add("DUPLICATE_BUFFER", path, f"Buffer {buffer.kind.value} is declared twice.") + buffer_by_kind[buffer.kind] = buffer + if buffer.size < 0: + issues.add("BUFFER_NEGATIVE_SIZE", f"{path}.size", "Size must be non-negative.") + if buffer.dtype is not expected_buffer_dtypes[buffer.kind]: + issues.add( + "BUFFER_DTYPE_INVALID", + f"{path}.dtype", + f"{buffer.kind.value} requires {expected_buffer_dtypes[buffer.kind].value}.", + ) + if buffer.initial_float_values and buffer.initial_int_values: + issues.add("BUFFER_MIXED_INITIAL_VALUES", path, "A buffer cannot have both float and integer initial values.") + if buffer.initial_float_values and len(buffer.initial_float_values) != buffer.size: + issues.add("BUFFER_INITIAL_SIZE", path, "Float initial values must cover the full buffer.") + if buffer.initial_int_values and len(buffer.initial_int_values) != buffer.size: + issues.add("BUFFER_INITIAL_SIZE", path, "Integer initial values must cover the full buffer.") + if any(not isfinite(float(value)) for value in buffer.initial_float_values): + issues.add("NONFINITE_VALUE", path, "Buffer initial values must be finite.") + if buffer.dtype is ir.IRDType.FLOAT64 and buffer.initial_int_values: + issues.add("BUFFER_INITIAL_TYPE", path, "float64 buffer cannot use integer initial values.") + if buffer.dtype is not ir.IRDType.FLOAT64 and buffer.initial_float_values: + issues.add("BUFFER_INITIAL_TYPE", path, "Integer buffer cannot use float initial values.") + if any(value < -(2**31) or value > 2**31 - 1 for value in buffer.initial_int_values): + issues.add("BUFFER_INT32_RANGE", path, "Integer initial values must fit signed int32.") + + required_buffers = set(ir.IRBufferKind) + for kind in sorted(required_buffers - set(buffer_by_kind), key=lambda value: value.value): + issues.add("MISSING_BUFFER", "buffers", f"Missing {kind.value} buffer.") + time_buffer = buffer_by_kind.get(ir.IRBufferKind.TIME) + if time_buffer is not None and time_buffer.size != 1: + issues.add("TIME_BUFFER_SIZE", "buffers", "The time buffer must contain exactly one float64 value.") + + def valid_slot(slot: ir.IRSlotRef, path: str) -> bool: + buffer = buffer_by_kind.get(slot.buffer) + if buffer is None: + issues.add("SLOT_BUFFER_MISSING", path, f"Buffer {slot.buffer.value} is not declared.") + return False + if slot.index < 0 or slot.index >= buffer.size: + issues.add("SLOT_INDEX_BOUNDS", path, f"Index {slot.index} is outside buffer size {buffer.size}.") + return False + return True + + issues.unique_ids(program.values, "value_id", "values") + value_slots: set[ir.IRSlotRef] = set() + for index, value in enumerate(program.values): + path = f"values[{index}]" + valid_slot(value.slot, f"{path}.slot") + if value.slot in value_slots: + issues.add("DUPLICATE_VALUE_SLOT", f"{path}.slot", "A numeric slot has more than one value descriptor.") + value_slots.add(value.slot) + if not value.semantic or not value.role or not value.quantity: + issues.add( + "VALUE_METADATA_INCOMPLETE", + path, + "Value semantic, role and quantity must not be empty.", + ) + if not isfinite(value.scale) or value.scale <= 0.0: + issues.add("VALUE_SCALE_INVALID", f"{path}.scale", "Scale must be finite and positive.") + if not _finite(value.lower_bound) or not _finite(value.upper_bound): + issues.add("NONFINITE_VALUE", path, "Bounds must be finite or null.") + if value.lower_bound is not None and value.upper_bound is not None and value.lower_bound > value.upper_bound: + issues.add("VALUE_BOUNDS_ORDER", path, "Lower bound exceeds upper bound.") + if value.owner_component_index is not None and not 0 <= value.owner_component_index < len(program.components): + issues.add("COMPONENT_INDEX_BOUNDS", f"{path}.owner_component_index", "Component index is invalid.") + expected_value_slots = { + ir.IRSlotRef(buffer.kind, index) + for buffer in program.buffers + for index in range(max(0, buffer.size)) + } + if value_slots != expected_value_slots: + issues.add( + "VALUE_SLOT_COVERAGE", + "values", + "Value descriptors must cover every declared buffer slot exactly once.", + ) + + issues.unique_ids(program.kernels, "kernel_id", "kernels") + for index, kernel in enumerate(program.kernels): + path = f"kernels[{index}]" + if not kernel.model_type or not kernel.model_version or not kernel.implementation_version: + issues.add("KERNEL_IDENTITY_INCOMPLETE", path, "Kernel type and versions are required.") + counts = ( + kernel.parameter_count, + kernel.state_count, + kernel.mode_count, + kernel.workspace_float_count, + kernel.workspace_int_count, + ) + if any(count < 0 for count in counts): + issues.add("KERNEL_NEGATIVE_ARITY", path, "Kernel arities must be non-negative.") + phases = tuple(phase.phase for phase in kernel.phases) + if not phases: + issues.add("KERNEL_PHASE_MISSING", f"{path}.phases", "Every kernel must declare at least one phase.") + if len(set(phases)) != len(phases): + issues.add("KERNEL_DUPLICATE_PHASE", f"{path}.phases", "Kernel phases must be unique.") + if kernel.availability is ir.IRKernelAvailability.NATIVE and kernel.unavailable_reason is not None: + issues.add("KERNEL_AVAILABILITY_CONFLICT", path, "Native kernel cannot have an unavailable reason.") + if kernel.availability is ir.IRKernelAvailability.REFERENCE_ONLY and not kernel.unavailable_reason: + issues.add("KERNEL_AVAILABILITY_REASON_MISSING", path, "Reference-only kernel needs a reason.") + + issues.unique_ids(program.components, "instance_id", "components") + for index, component in enumerate(program.components): + path = f"components[{index}]" + if not 0 <= component.kernel_index < len(program.kernels): + issues.add("KERNEL_INDEX_BOUNDS", f"{path}.kernel_index", "Kernel index is invalid.") + continue + kernel = program.kernels[component.kernel_index] + for slot_index, slot in enumerate( + (*component.parameter_slots, *component.state_slots, *component.derivative_slots, + *component.mode_slots, *component.port_slots, *component.workspace_float_slots, + *component.workspace_int_slots) + ): + valid_slot(slot, f"{path}.slots[{slot_index}]") + binding_kinds = ( + (component.parameter_slots, ir.IRBufferKind.PARAMETER, "parameter_slots"), + (component.state_slots, ir.IRBufferKind.LOCAL_STATE, "state_slots"), + ( + component.derivative_slots, + ir.IRBufferKind.LOCAL_DERIVATIVE, + "derivative_slots", + ), + (component.mode_slots, ir.IRBufferKind.MODE, "mode_slots"), + ( + component.workspace_float_slots, + ir.IRBufferKind.WORK_FLOAT, + "workspace_float_slots", + ), + ( + component.workspace_int_slots, + ir.IRBufferKind.WORK_INT, + "workspace_int_slots", + ), + ) + for bound_slots, expected_kind, label in binding_kinds: + if any(slot.buffer is not expected_kind for slot in bound_slots): + issues.add( + "COMPONENT_BINDING_BUFFER", + f"{path}.{label}", + f"Bindings must use the {expected_kind.value} buffer.", + ) + if len(set(bound_slots)) != len(bound_slots): + issues.add( + "COMPONENT_BINDING_DUPLICATE", + f"{path}.{label}", + "Bindings within one component must be unique.", + ) + if len(component.parameter_slots) != kernel.parameter_count: + issues.add("KERNEL_PARAMETER_ARITY", path, "Component parameter binding count differs from kernel.") + if len(component.state_slots) != kernel.state_count or len(component.derivative_slots) != kernel.state_count: + issues.add("KERNEL_STATE_ARITY", path, "Component state bindings differ from kernel.") + if len(component.mode_slots) != kernel.mode_count: + issues.add("KERNEL_MODE_ARITY", path, "Component mode bindings differ from kernel.") + if len(component.workspace_float_slots) != kernel.workspace_float_count: + issues.add("KERNEL_WORKSPACE_ARITY", path, "Float workspace bindings differ from kernel.") + if len(component.workspace_int_slots) != kernel.workspace_int_count: + issues.add("KERNEL_WORKSPACE_ARITY", path, "Integer workspace bindings differ from kernel.") + if any(port < 0 or port >= len(program.ports) for port in component.port_indices): + issues.add("PORT_INDEX_BOUNDS", f"{path}.port_indices", "Port index is invalid.") + if any(output < 0 or output >= len(program.outputs) for output in component.output_indices): + issues.add("OUTPUT_INDEX_BOUNDS", f"{path}.output_indices", "Output index is invalid.") + + issues.unique_ids(program.ports, "port_id", "ports") + port_variable_ids: set[str] = set() + for index, port in enumerate(program.ports): + path = f"ports[{index}]" + if not port.name or not port.domain: + issues.add("PORT_METADATA_INCOMPLETE", path, "Port name and domain must not be empty.") + if not 0 <= port.component_index < len(program.components): + issues.add("COMPONENT_INDEX_BOUNDS", f"{path}.component_index", "Component index is invalid.") + if port.kind is ir.IRPortKind.PHYSICAL and port.positive_flow_direction is None: + issues.add("PORT_FLOW_DIRECTION_MISSING", path, "Physical ports need a positive-flow direction.") + if port.kind is ir.IRPortKind.SIGNAL and port.positive_flow_direction is not None: + issues.add("PORT_FLOW_DIRECTION_INVALID", path, "Signal ports cannot define physical flow direction.") + names: set[str] = set() + for variable_index, variable in enumerate(port.variables): + variable_path = f"{path}.variables[{variable_index}]" + if not variable.variable_id or not variable.name: + issues.add("PORT_VARIABLE_ID_INVALID", variable_path, "Port variable names and IDs must be non-empty.") + if not variable.quantity: + issues.add("PORT_VARIABLE_METADATA_INCOMPLETE", variable_path, "Port variable quantity must not be empty.") + if variable.variable_id in port_variable_ids: + issues.add("PORT_VARIABLE_ID_INVALID", variable_path, "Port variable ID must be globally unique.") + port_variable_ids.add(variable.variable_id) + if variable.name in names: + issues.add("PORT_VARIABLE_ID_INVALID", variable_path, "Port variable name must be unique within its port.") + names.add(variable.name) + valid_slot(variable.slot, f"{variable_path}.slot") + expected_rule = { + ir.IRVariableRole.EFFORT: ir.IRConnectionRule.EQUAL, + ir.IRVariableRole.FLOW: ir.IRConnectionRule.SUM_TO_ZERO, + ir.IRVariableRole.STREAM: ir.IRConnectionRule.STREAM_MIX, + ir.IRVariableRole.SIGNAL: ir.IRConnectionRule.DIRECTED, + }[variable.role] + if variable.connection_rule is not expected_rule: + issues.add( + "PORT_VARIABLE_RULE_INVALID", + variable_path, + f"{variable.role.value} variables require {expected_rule.value}.", + ) + if 0 <= port.component_index < len(program.components) and index not in program.components[port.component_index].port_indices: + issues.add("COMPONENT_PORT_BACKREF", path, "Owning component does not reference this port.") + + for component_index, component in enumerate(program.components): + expected_port_indices = tuple( + index + for index, port in enumerate(program.ports) + if port.component_index == component_index + ) + if component.port_indices != expected_port_indices: + issues.add( + "COMPONENT_PORT_COVERAGE", + f"components[{component_index}].port_indices", + "Component port indices must exactly cover its owned ports in program order.", + ) + if all(0 <= index < len(program.ports) for index in component.port_indices): + expected_port_slots = tuple( + variable.slot + for port_index in component.port_indices + for variable in program.ports[port_index].variables + ) + if component.port_slots != expected_port_slots: + issues.add( + "COMPONENT_PORT_SLOT_COVERAGE", + f"components[{component_index}].port_slots", + "Component port slots must match its ordered port-variable bindings.", + ) + + issues.unique_ids(program.connections, "connection_id", "connections") + connected_port_indices: list[int] = [] + endpoint_pairs: set[tuple[int, int]] = set() + for index, connection in enumerate(program.connections): + path = f"connections[{index}]" + endpoints = (connection.endpoint_a_port_index, connection.endpoint_b_port_index) + if endpoints[0] == endpoints[1]: + issues.add("CONNECTION_SELF_LOOP", path, "Connection endpoints must be distinct ports.") + if any(endpoint < 0 or endpoint >= len(program.ports) for endpoint in endpoints): + issues.add("PORT_INDEX_BOUNDS", path, "Connection endpoint is invalid.") + continue + connected_port_indices.extend(endpoints) + endpoint_pair = tuple(sorted(endpoints)) + if endpoint_pair in endpoint_pairs: + issues.add("CONNECTION_DUPLICATE", path, "The same port pair is connected more than once.") + endpoint_pairs.add(endpoint_pair) + first, second = (program.ports[endpoint] for endpoint in endpoints) + if first.kind is not connection.kind or second.kind is not connection.kind or first.domain != connection.domain or second.domain != connection.domain: + issues.add("CONNECTION_DOMAIN_MISMATCH", path, "Connection kind/domain differs from an endpoint.") + first_variables = {variable.name: variable for variable in first.variables} + second_variables = {variable.name: variable for variable in second.variables} + connection_names = tuple(variable.name for variable in connection.variables) + if ( + len(set(connection_names)) != len(connection_names) + or set(connection_names) != set(first_variables) + or set(connection_names) != set(second_variables) + ): + issues.add( + "CONNECTION_VARIABLE_COVERAGE", + f"{path}.variables", + "Connection variables must cover both endpoint contracts exactly once.", + ) + for variable_index, variable in enumerate(connection.variables): + variable_path = f"{path}.variables[{variable_index}]" + valid_slot(variable.endpoint_a_slot, f"{variable_path}.endpoint_a_slot") + valid_slot(variable.endpoint_b_slot, f"{variable_path}.endpoint_b_slot") + if variable.name not in first_variables or variable.name not in second_variables: + issues.add("CONNECTION_VARIABLE_MISSING", variable_path, "Variable is absent from an endpoint.") + elif ( + first_variables[variable.name].slot != variable.endpoint_a_slot + or second_variables[variable.name].slot != variable.endpoint_b_slot + or first_variables[variable.name].connection_rule is not variable.rule + or second_variables[variable.name].connection_rule is not variable.rule + ): + issues.add("CONNECTION_VARIABLE_MISMATCH", variable_path, "Connection variable does not match its ports.") + connection_counts: dict[int, int] = {} + for port_index in connected_port_indices: + connection_counts[port_index] = connection_counts.get(port_index, 0) + 1 + for port_index, count in connection_counts.items(): + port = program.ports[port_index] + allows_fan_out = ( + port.kind is ir.IRPortKind.SIGNAL + and port.nominal_role is ir.IRPortNominalRole.OUTPUT + ) + if count > 1 and not allows_fan_out: + issues.add( + "CONNECTION_PORT_REUSED", + f"ports[{port_index}]", + "Physical ports and signal inputs may have at most one connection.", + ) + + issues.unique_ids(program.mediums, "medium_id", "mediums") + for index, medium in enumerate(program.mediums): + path = f"mediums[{index}]" + if not medium.name or not medium.implementation_id or not medium.implementation_version: + issues.add("MEDIUM_IDENTITY_INCOMPLETE", path, "Medium identity is incomplete.") + if len(set(medium.parameter_slots)) != len(medium.parameter_slots): + issues.add("MEDIUM_PARAMETER_DUPLICATE", f"{path}.parameter_slots", "Medium parameter slots must be unique.") + if len(set(medium.component_indices)) != len(medium.component_indices): + issues.add("MEDIUM_COMPONENT_DUPLICATE", f"{path}.component_indices", "Medium component indices must be unique.") + for slot_index, slot in enumerate(medium.parameter_slots): + valid_slot(slot, f"{path}.parameter_slots[{slot_index}]") + if slot.buffer is not ir.IRBufferKind.CONSTANT: + issues.add("MEDIUM_PARAMETER_BUFFER", f"{path}.parameter_slots[{slot_index}]", "Medium constants must use the constant buffer.") + if any(component < 0 or component >= len(program.components) for component in medium.component_indices): + issues.add("COMPONENT_INDEX_BOUNDS", f"{path}.component_indices", "Medium component index is invalid.") + + reducer = program.state_reducer + state_buffer = buffer_by_kind.get(ir.IRBufferKind.STATE_INPUT) + derivative_buffer = buffer_by_kind.get(ir.IRBufferKind.DERIVATIVE_OUTPUT) + if state_buffer is not None and state_buffer.size != reducer.solver_state_count: + issues.add("STATE_COUNT_MISMATCH", "state_reducer", "State buffer size differs from reducer count.") + if derivative_buffer is not None and derivative_buffer.size != reducer.solver_state_count: + issues.add("DERIVATIVE_COUNT_MISMATCH", "state_reducer", "Derivative buffer size differs from reducer count.") + if len(reducer.initial_state) != reducer.solver_state_count or len(reducer.absolute_tolerances) != reducer.solver_state_count: + issues.add("STATE_METADATA_LENGTH", "state_reducer", "Initial state and tolerances must cover every state.") + if any(not isfinite(value) for value in reducer.initial_state): + issues.add("NONFINITE_VALUE", "state_reducer.initial_state", "Initial state must be finite.") + if ( + state_buffer is not None + and state_buffer.initial_float_values != reducer.initial_state + ): + issues.add( + "STATE_INITIAL_VALUE_MISMATCH", + "state_reducer.initial_state", + "State-input buffer initial values must exactly equal the reducer initial state.", + ) + if any(not isfinite(value) or value <= 0.0 for value in reducer.absolute_tolerances): + issues.add("STATE_TOLERANCE_INVALID", "state_reducer.absolute_tolerances", "Tolerances must be finite and positive.") + for index, slot in enumerate(reducer.local_state_slots): + valid_slot(slot, f"state_reducer.local_state_slots[{index}]") + if slot.buffer is not ir.IRBufferKind.LOCAL_STATE: + issues.add("STATE_REDUCER_BUFFER", f"state_reducer.local_state_slots[{index}]", "Local states must use the local_state buffer.") + for index, slot in enumerate(reducer.raw_derivative_slots): + valid_slot(slot, f"state_reducer.raw_derivative_slots[{index}]") + if slot.buffer is not ir.IRBufferKind.LOCAL_DERIVATIVE: + issues.add("STATE_REDUCER_BUFFER", f"state_reducer.raw_derivative_slots[{index}]", "Raw derivatives must use the local_derivative buffer.") + expected_local_state_slots = tuple( + slot for component in program.components for slot in component.state_slots + ) + expected_raw_derivative_slots = tuple( + slot for component in program.components for slot in component.derivative_slots + ) + if reducer.local_state_slots != expected_local_state_slots: + issues.add( + "STATE_REDUCER_LOCAL_COVERAGE", + "state_reducer.local_state_slots", + "Reducer local-state order must exactly match component state bindings.", + ) + if reducer.raw_derivative_slots != expected_raw_derivative_slots: + issues.add( + "STATE_REDUCER_DERIVATIVE_COVERAGE", + "state_reducer.raw_derivative_slots", + "Reducer derivative order must exactly match component derivative bindings.", + ) + _validate_csr(reducer.state_scatter.pattern, "state_reducer.state_scatter.pattern", issues) + _validate_csr(reducer.derivative_gather.pattern, "state_reducer.derivative_gather.pattern", issues) + if reducer.state_scatter.pattern.row_count != len(reducer.local_state_slots) or reducer.state_scatter.pattern.column_count != reducer.solver_state_count: + issues.add("STATE_SCATTER_SHAPE", "state_reducer.state_scatter", "Scatter shape must be local_state_count x solver_state_count.") + if reducer.derivative_gather.pattern.row_count != reducer.solver_state_count or reducer.derivative_gather.pattern.column_count != len(reducer.raw_derivative_slots): + issues.add("DERIVATIVE_GATHER_SHAPE", "state_reducer.derivative_gather", "Gather shape must be solver_state_count x raw_derivative_count.") + if len(reducer.state_scatter.values) != reducer.state_scatter.pattern.nonzero_count: + issues.add("CSR_VALUE_COUNT", "state_reducer.state_scatter.values", "Value count differs from pattern.") + if len(reducer.derivative_gather.values) != reducer.derivative_gather.pattern.nonzero_count: + issues.add("CSR_VALUE_COUNT", "state_reducer.derivative_gather.values", "Value count differs from pattern.") + if any(not isfinite(value) for value in (*reducer.state_scatter.values, *reducer.derivative_gather.values)): + issues.add("NONFINITE_VALUE", "state_reducer", "Reducer matrix values must be finite.") + if ( + reducer.state_scatter.pattern.row_pointers + and any( + first == second + for first, second in zip( + reducer.state_scatter.pattern.row_pointers, + reducer.state_scatter.pattern.row_pointers[1:], + ) + ) + ): + issues.add("STATE_SCATTER_EMPTY_ROW", "state_reducer.state_scatter", "Every local state must depend on at least one solver state.") + if set(reducer.state_scatter.pattern.column_indices) != set( + range(reducer.solver_state_count) + ): + issues.add("STATE_SCATTER_COLUMN_COVERAGE", "state_reducer.state_scatter", "Every solver state must feed at least one local state.") + if set(reducer.derivative_gather.pattern.column_indices) != set( + range(len(reducer.raw_derivative_slots)) + ): + issues.add("DERIVATIVE_GATHER_COLUMN_COVERAGE", "state_reducer.derivative_gather", "Every raw derivative must contribute to a solver derivative.") + if ( + reducer.derivative_gather.pattern.row_pointers + and any( + first == second + for first, second in zip( + reducer.derivative_gather.pattern.row_pointers, + reducer.derivative_gather.pattern.row_pointers[1:], + ) + ) + ): + issues.add("DERIVATIVE_GATHER_EMPTY_ROW", "state_reducer.derivative_gather", "Every solver state must receive at least one derivative contribution.") + + pressure_flow = program.pressure_flow + issues.unique_ids(pressure_flow.unknowns, "unknown_id", "pressure_flow.unknowns") + issues.unique_ids(pressure_flow.equations, "equation_id", "pressure_flow.equations") + issues.unique_ids(pressure_flow.blocks, "block_id", "pressure_flow.blocks") + issues.unique_ids(pressure_flow.scopes, "scope_id", "pressure_flow.scopes") + for index, unknown in enumerate(pressure_flow.unknowns): + path = f"pressure_flow.unknowns[{index}]" + valid_slot(unknown.slot, f"{path}.slot") + if unknown.slot.buffer is not ir.IRBufferKind.ALGEBRAIC: + issues.add("PRESSURE_FLOW_UNKNOWN_BUFFER", f"{path}.slot", "Pressure-flow unknowns must use the algebraic buffer.") + if not 0 <= unknown.component_index < len(program.components) or not 0 <= unknown.port_index < len(program.ports): + issues.add("PRESSURE_FLOW_UNKNOWN_OWNER", path, "Unknown owner is invalid.") + else: + port = program.ports[unknown.port_index] + matching_variables = tuple( + variable + for variable in port.variables + if variable.name == unknown.variable + ) + if ( + port.component_index != unknown.component_index + or len(matching_variables) != 1 + or matching_variables[0].variable_id != unknown.unknown_id + or matching_variables[0].role is not unknown.role + or matching_variables[0].slot != unknown.slot + ): + issues.add( + "PRESSURE_FLOW_UNKNOWN_OWNER", + path, + "Unknown must match one variable on a port owned by its component.", + ) + if not isfinite(unknown.scale) or unknown.scale <= 0.0: + issues.add("VALUE_SCALE_INVALID", f"{path}.scale", "Scale must be finite and positive.") + if not _finite(unknown.lower_bound) or not _finite(unknown.upper_bound): + issues.add("NONFINITE_VALUE", path, "Unknown bounds must be finite or null.") + if unknown.lower_bound is not None and unknown.upper_bound is not None and unknown.lower_bound > unknown.upper_bound: + issues.add("VALUE_BOUNDS_ORDER", path, "Unknown lower bound exceeds upper bound.") + residual_slots: set[ir.IRSlotRef] = set() + for index, equation in enumerate(pressure_flow.equations): + path = f"pressure_flow.equations[{index}]" + valid_slot(equation.residual_slot, f"{path}.residual_slot") + if equation.residual_slot in residual_slots: + issues.add( + "PRESSURE_FLOW_RESIDUAL_SLOT_DUPLICATE", + f"{path}.residual_slot", + "Every pressure-flow equation requires a unique residual slot.", + ) + residual_slots.add(equation.residual_slot) + if equation.residual_slot.buffer is not ir.IRBufferKind.WORK_FLOAT: + issues.add("EQUATION_RESIDUAL_BUFFER", f"{path}.residual_slot", "Equation residuals must use the float workspace.") + for slot_index, slot in enumerate(equation.variable_slots): + valid_slot(slot, f"{path}.variable_slots[{slot_index}]") + if slot.buffer not in { + ir.IRBufferKind.ALGEBRAIC, + ir.IRBufferKind.LOCAL_STATE, + ir.IRBufferKind.SIGNAL, + ir.IRBufferKind.PARAMETER, + ir.IRBufferKind.CONSTANT, + ir.IRBufferKind.MODE, + ir.IRBufferKind.RUNTIME_INPUT, + }: + issues.add( + "EQUATION_VARIABLE_BUFFER", + f"{path}.variable_slots[{slot_index}]", + "Equation variables must be model values, not time or result buffers.", + ) + if len(set(equation.variable_slots)) != len(equation.variable_slots): + issues.add("EQUATION_VARIABLE_DUPLICATE", f"{path}.variable_slots", "Equation variables must be unique.") + owner_count = len(program.components) if equation.owner is ir.IREquationOwner.COMPONENT else len(program.connections) + if not 0 <= equation.owner_index < owner_count: + issues.add("EQUATION_OWNER_BOUNDS", path, "Equation owner index is invalid.") + if not isfinite(equation.scale) or equation.scale <= 0.0: + issues.add("VALUE_SCALE_INVALID", f"{path}.scale", "Equation scale must be finite and positive.") + for index, block in enumerate(pressure_flow.blocks): + path = f"pressure_flow.blocks[{index}]" + if len(set(block.unknown_indices)) != len(block.unknown_indices): + issues.add("ALGEBRAIC_BLOCK_DUPLICATE", f"{path}.unknown_indices", "Block unknowns must be unique.") + if len(set(block.equation_indices)) != len(block.equation_indices): + issues.add("ALGEBRAIC_BLOCK_DUPLICATE", f"{path}.equation_indices", "Block equations must be unique.") + if any(item < 0 or item >= len(pressure_flow.unknowns) for item in block.unknown_indices): + issues.add("UNKNOWN_INDEX_BOUNDS", path, "Block unknown index is invalid.") + if any(item < 0 or item >= len(pressure_flow.equations) for item in block.equation_indices): + issues.add("EQUATION_INDEX_BOUNDS", path, "Block equation index is invalid.") + _validate_csr(block.jacobian_pattern, f"{path}.jacobian_pattern", issues) + if block.jacobian_pattern.row_count != len(block.equation_indices) or block.jacobian_pattern.column_count != len(block.unknown_indices): + issues.add("ALGEBRAIC_BLOCK_SHAPE", path, "Block Jacobian shape differs from its rows/columns.") + block_unknowns = tuple( + item for block in pressure_flow.blocks for item in block.unknown_indices + ) + block_equations = tuple( + item for block in pressure_flow.blocks for item in block.equation_indices + ) + if ( + tuple(sorted(block_unknowns)) != tuple(range(len(pressure_flow.unknowns))) + or len(set(block_unknowns)) != len(block_unknowns) + ): + issues.add("ALGEBRAIC_BLOCK_UNKNOWN_PARTITION", "pressure_flow.blocks", "Blocks must partition all pressure-flow unknowns.") + if ( + tuple(sorted(block_equations)) != tuple(range(len(pressure_flow.equations))) + or len(set(block_equations)) != len(block_equations) + ): + issues.add("ALGEBRAIC_BLOCK_EQUATION_PARTITION", "pressure_flow.blocks", "Blocks must partition all pressure-flow equations.") + for index, scope in enumerate(pressure_flow.scopes): + path = f"pressure_flow.scopes[{index}]" + bounds = ( + (scope.component_indices, len(program.components), "component"), + (scope.unknown_indices, len(pressure_flow.unknowns), "unknown"), + (scope.equation_indices, len(pressure_flow.equations), "equation"), + (scope.block_indices, len(pressure_flow.blocks), "block"), + ) + for indexes, upper, label in bounds: + if any(item < 0 or item >= upper for item in indexes): + issues.add(f"{label.upper()}_INDEX_BOUNDS", path, f"Scope {label} index is invalid.") + if len(set(indexes)) != len(indexes): + issues.add(f"{label.upper()}_INDEX_DUPLICATE", path, f"Scope {label} indices must be unique.") + if scope.causal_plan_index is not None and not 0 <= scope.causal_plan_index < len(program.causal_plans): + issues.add("CAUSAL_PLAN_INDEX_BOUNDS", path, "Causal plan index is invalid.") + if not isfinite(scope.residual_tolerance) or scope.residual_tolerance <= 0.0 or scope.max_evaluations <= 0: + issues.add("PRESSURE_FLOW_LIMIT_INVALID", path, "Solver limits must be positive and finite.") + if scope.sparse_pattern_trusted == (scope.sparse_fallback_reason is not None): + issues.add( + "PRESSURE_FLOW_SPARSE_STATUS", + path, + "A trusted sparse pattern must have no fallback reason; an untrusted one must explain its fallback.", + ) + if all(0 <= item < len(pressure_flow.blocks) for item in scope.block_indices): + scoped_unknowns = { + item + for block_index in scope.block_indices + for item in pressure_flow.blocks[block_index].unknown_indices + } + scoped_equations = { + item + for block_index in scope.block_indices + for item in pressure_flow.blocks[block_index].equation_indices + } + if scoped_unknowns != set(scope.unknown_indices): + issues.add("PRESSURE_FLOW_SCOPE_UNKNOWN_COVERAGE", path, "Scope unknowns must equal its blocks' unknowns.") + if scoped_equations != set(scope.equation_indices): + issues.add("PRESSURE_FLOW_SCOPE_EQUATION_COVERAGE", path, "Scope equations must equal its blocks' equations.") + if not pressure_flow.scopes or not 0 <= pressure_flow.global_scope_index < len(pressure_flow.scopes): + issues.add("GLOBAL_SCOPE_INDEX_BOUNDS", "pressure_flow.global_scope_index", "Global scope is invalid.") + else: + global_scope = pressure_flow.scopes[pressure_flow.global_scope_index] + if ( + global_scope.kind is not ir.IRPressureFlowScopeKind.NETWORK + or set(global_scope.component_indices) != set(range(len(program.components))) + or set(global_scope.unknown_indices) != set(range(len(pressure_flow.unknowns))) + or set(global_scope.equation_indices) != set(range(len(pressure_flow.equations))) + or set(global_scope.block_indices) != set(range(len(pressure_flow.blocks))) + ): + issues.add("GLOBAL_SCOPE_COVERAGE", "pressure_flow.global_scope_index", "Global scope must cover the complete network.") + if any(index < 0 or index >= len(pressure_flow.scopes) for index in pressure_flow.secondary_scope_indices): + issues.add("SECONDARY_SCOPE_INDEX_BOUNDS", "pressure_flow.secondary_scope_indices", "Secondary scope is invalid.") + if ( + len(set(pressure_flow.secondary_scope_indices)) + != len(pressure_flow.secondary_scope_indices) + or pressure_flow.global_scope_index in pressure_flow.secondary_scope_indices + ): + issues.add("SECONDARY_SCOPE_INDEX_INVALID", "pressure_flow.secondary_scope_indices", "Secondary scopes must be unique and exclude the global scope.") + if not isfinite(pressure_flow.pressure_lower_bound): + issues.add("NONFINITE_VALUE", "pressure_flow.pressure_lower_bound", "Pressure bound must be finite.") + + issues.unique_ids(program.stages, "stage_id", "stages") + for stage_index, stage in enumerate(program.stages): + path = f"stages[{stage_index}]" + reads: list[ir.IRSlotRef] = [] + writes: list[ir.IRSlotRef] = [] + for operation_index, operation in enumerate(stage.operations): + operation_path = f"{path}.operations[{operation_index}]" + for slot_index, slot in enumerate(_operation_slots(operation)): + valid_slot(slot, f"{operation_path}.slots[{slot_index}]") + reads.extend(ir.operation_read_slots(operation)) + writes.extend(ir.operation_write_slots(operation)) + if isinstance(operation, ir.IRLinearCombinationOperation): + if len(operation.source_slots) != len(operation.weights) or any(not isfinite(value) for value in (*operation.weights, operation.bias)): + issues.add("LINEAR_COMBINATION_INVALID", operation_path, "Weights must be finite and match sources.") + if isinstance(operation, ir.IRKernelCallOperation): + if len(set(operation.read_slots)) != len(operation.read_slots): + issues.add("KERNEL_CALL_READ_DUPLICATE", operation_path, "Kernel read slots must be unique.") + if len(set(operation.write_slots)) != len(operation.write_slots): + issues.add("KERNEL_CALL_WRITE_DUPLICATE", operation_path, "Kernel write slots must be unique.") + if not 0 <= operation.kernel_index < len(program.kernels): + issues.add("KERNEL_INDEX_BOUNDS", operation_path, "Operation kernel index is invalid.") + else: + phases = {phase.phase for phase in program.kernels[operation.kernel_index].phases} + if operation.phase not in phases: + issues.add("KERNEL_PHASE_UNDECLARED", operation_path, "Operation uses an undeclared kernel phase.") + if operation.component_index is not None and not 0 <= operation.component_index < len(program.components): + issues.add("COMPONENT_INDEX_BOUNDS", operation_path, "Operation component index is invalid.") + elif ( + operation.component_index is not None + and 0 <= operation.kernel_index < len(program.kernels) + and program.components[operation.component_index].kernel_index + != operation.kernel_index + ): + issues.add( + "KERNEL_COMPONENT_MISMATCH", + operation_path, + "Component-bound operation must use the component's declared kernel.", + ) + if any(item < 0 or item >= len(pressure_flow.equations) for item in operation.equation_indices): + issues.add("EQUATION_INDEX_BOUNDS", operation_path, "Operation equation index is invalid.") + if len(set(operation.equation_indices)) != len(operation.equation_indices): + issues.add("EQUATION_INDEX_DUPLICATE", operation_path, "Operation equation indices must be unique.") + if operation.equation_indices and operation.phase is not ir.IRKernelPhase.RESIDUAL: + issues.add("KERNEL_EQUATION_PHASE_INVALID", operation_path, "Equation indices are only valid for residual calls.") + if ( + operation.phase is ir.IRKernelPhase.RESIDUAL + and all( + 0 <= item < len(pressure_flow.equations) + for item in operation.equation_indices + ) + ): + required_reads = { + slot + for equation_index in operation.equation_indices + for slot in pressure_flow.equations[ + equation_index + ].variable_slots + } + required_writes = { + pressure_flow.equations[equation_index].residual_slot + for equation_index in operation.equation_indices + } + if not required_reads.issubset(operation.read_slots): + issues.add("RESIDUAL_CALL_READ_COVERAGE", operation_path, "Residual call reads must cover every referenced equation variable.") + if not required_writes.issubset(operation.write_slots): + issues.add("RESIDUAL_CALL_WRITE_COVERAGE", operation_path, "Residual call writes must cover every referenced equation residual.") + if isinstance(operation, (ir.IREffortBroadcastOperation, ir.IRFlowAssignmentOperation)): + if operation.equation_id not in {equation.equation_id for equation in pressure_flow.equations}: + issues.add("EQUATION_ID_MISSING", operation_path, "Assignment references an unknown equation.") + if len(set(stage.declared_read_slots)) != len(stage.declared_read_slots) or set(stage.declared_read_slots) != set(reads): + issues.add("STAGE_READ_SET_MISMATCH", f"{path}.declared_read_slots", "Declared reads must equal the operation read union.") + if len(set(stage.declared_write_slots)) != len(stage.declared_write_slots) or set(stage.declared_write_slots) != set(writes): + issues.add("STAGE_WRITE_SET_MISMATCH", f"{path}.declared_write_slots", "Declared writes must equal the operation write union.") + + def valid_step(step: ir.IRStepRef, path: str) -> bool: + upper = len(program.stages) if step.kind is ir.IRStepKind.STAGE else len(program.blocks) + if step.index < 0 or step.index >= upper: + issues.add("STEP_INDEX_BOUNDS", path, f"{step.kind.value} index is invalid.") + return False + return True + + issues.unique_ids(program.blocks, "block_id", "blocks") + for block_index, block in enumerate(program.blocks): + path = f"blocks[{block_index}]" + for step_index, step in enumerate(block.steps): + valid_step(step, f"{path}.steps[{step_index}]") + iterative_kinds = {ir.IRBlockKind.FIXED_POINT, ir.IRBlockKind.STREAM_SCC} + if block.kind in iterative_kinds and block.convergence is None: + issues.add("FIXED_POINT_CONVERGENCE_MISSING", path, "Iterative block needs convergence settings.") + if block.kind not in iterative_kinds and block.convergence is not None: + issues.add("UNEXPECTED_CONVERGENCE", path, "Only iterative blocks may define convergence.") + if block.convergence is not None: + convergence = block.convergence + if ( + not isfinite(convergence.absolute_tolerance) + or convergence.absolute_tolerance < 0.0 + or not isfinite(convergence.relative_tolerance) + or convergence.relative_tolerance < 0.0 + or convergence.max_iterations <= 0 + or not isfinite(convergence.relaxation) + or not 0.0 < convergence.relaxation <= 1.0 + ): + issues.add("CONVERGENCE_LIMIT_INVALID", f"{path}.convergence", "Convergence limits are invalid.") + for slot_index, slot in enumerate((*convergence.monitor_slots, *convergence.rollback_slots)): + valid_slot(slot, f"{path}.convergence.slots[{slot_index}]") + + visit_state = [0] * len(program.blocks) + + def visit_block(block_index: int) -> None: + if visit_state[block_index] == 1: + issues.add("BLOCK_REFERENCE_CYCLE", f"blocks[{block_index}]", "Execution blocks must be acyclic.") + return + if visit_state[block_index] == 2: + return + visit_state[block_index] = 1 + for step in program.blocks[block_index].steps: + if step.kind is ir.IRStepKind.BLOCK and 0 <= step.index < len(program.blocks): + visit_block(step.index) + visit_state[block_index] = 2 + + for block_index in range(len(program.blocks)): + visit_block(block_index) + + def expanded_stage_indices( + steps: tuple[ir.IRStepRef, ...], + ) -> tuple[int, ...]: + result: list[int] = [] + + def append_step(step: ir.IRStepRef, active_blocks: set[int]) -> None: + if step.kind is ir.IRStepKind.STAGE: + if 0 <= step.index < len(program.stages): + result.append(step.index) + return + if not 0 <= step.index < len(program.blocks) or step.index in active_blocks: + return + nested_active = {*active_blocks, step.index} + for nested in program.blocks[step.index].steps: + append_step(nested, nested_active) + + for step in steps: + append_step(step, set()) + return tuple(result) + + expected_entry_inputs = tuple( + ir.IRSlotRef(kind, index) + for kind in ( + ir.IRBufferKind.TIME, + ir.IRBufferKind.STATE_INPUT, + ir.IRBufferKind.RUNTIME_INPUT, + ) + for buffer in (buffer_by_kind.get(kind),) + if buffer is not None + for index in range(buffer.size) + ) + entry_required_stage = { + ir.IREntryPointKind.RHS: ir.IRStageKind.DERIVATIVE_REDUCE, + ir.IREntryPointKind.EVENTS: ir.IRStageKind.EVENT, + ir.IREntryPointKind.JACOBIAN: ir.IRStageKind.JACOBIAN, + ir.IREntryPointKind.OUTPUTS: ir.IRStageKind.OUTPUT, + } + entry_forbidden_stages = { + ir.IREntryPointKind.RHS: { + ir.IRStageKind.EVENT, + ir.IRStageKind.JACOBIAN, + ir.IRStageKind.OUTPUT, + ir.IRStageKind.RESET, + }, + ir.IREntryPointKind.EVENTS: { + ir.IRStageKind.DERIVATIVE_REDUCE, + ir.IRStageKind.JACOBIAN, + ir.IRStageKind.OUTPUT, + ir.IRStageKind.RESET, + }, + ir.IREntryPointKind.JACOBIAN: { + ir.IRStageKind.EVENT, + ir.IRStageKind.OUTPUT, + ir.IRStageKind.RESET, + }, + ir.IREntryPointKind.OUTPUTS: { + ir.IRStageKind.EVENT, + ir.IRStageKind.JACOBIAN, + ir.IRStageKind.RESET, + }, + } + + entry_kinds = tuple(entry.kind for entry in program.entry_points) + expected_entry_kinds = set(ir.IREntryPointKind) + if len(entry_kinds) != len(expected_entry_kinds) or set(entry_kinds) != expected_entry_kinds: + issues.add("ENTRY_POINT_SET_INVALID", "entry_points", "Exactly rhs, events, jacobian and outputs are required.") + for entry_index, entry in enumerate(program.entry_points): + path = f"entry_points[{entry_index}]" + for step_index, step in enumerate(entry.steps): + valid_step(step, f"{path}.steps[{step_index}]") + for slot_index, slot in enumerate((*entry.input_slots, *entry.output_slots)): + valid_slot(slot, f"{path}.slots[{slot_index}]") + output_kinds = {slot.buffer for slot in entry.output_slots} + expected_output_kind = { + ir.IREntryPointKind.RHS: ir.IRBufferKind.DERIVATIVE_OUTPUT, + ir.IREntryPointKind.EVENTS: ir.IRBufferKind.EVENT_OUTPUT, + ir.IREntryPointKind.JACOBIAN: ir.IRBufferKind.JACOBIAN_VALUE, + ir.IREntryPointKind.OUTPUTS: ir.IRBufferKind.RESULT_OUTPUT, + }[entry.kind] + if output_kinds - {expected_output_kind}: + issues.add("ENTRY_POINT_OUTPUT_BUFFER", path, f"Outputs must use {expected_output_kind.value}.") + if entry.input_slots != expected_entry_inputs: + issues.add( + "ENTRY_POINT_INPUT_CONTRACT", + f"{path}.input_slots", + "Entry inputs must be ordered time, full solver state and runtime inputs.", + ) + expected_outputs = { + ir.IREntryPointKind.RHS: tuple( + ir.IRSlotRef(ir.IRBufferKind.DERIVATIVE_OUTPUT, index) + for index in range(buffer_by_kind.get(ir.IRBufferKind.DERIVATIVE_OUTPUT).size) + ) + if buffer_by_kind.get(ir.IRBufferKind.DERIVATIVE_OUTPUT) is not None + else (), + ir.IREntryPointKind.EVENTS: tuple(event.root_slot for event in program.events), + ir.IREntryPointKind.JACOBIAN: program.jacobian.value_slots, + ir.IREntryPointKind.OUTPUTS: tuple(output.output_slot for output in program.outputs), + }[entry.kind] + if entry.output_slots != expected_outputs: + issues.add( + "ENTRY_POINT_OUTPUT_COVERAGE", + f"{path}.output_slots", + "Entry outputs must exactly match its ordered public result contract.", + ) + reachable_stage_indices = expanded_stage_indices(entry.steps) + reachable_kinds = { + program.stages[index].kind for index in reachable_stage_indices + } + if entry_required_stage[entry.kind] not in reachable_kinds: + issues.add( + "ENTRY_POINT_FINAL_STAGE_MISSING", + f"{path}.steps", + f"{entry.kind.value} must execute a {entry_required_stage[entry.kind].value} stage.", + ) + forbidden = reachable_kinds & entry_forbidden_stages[entry.kind] + if forbidden: + issues.add( + "ENTRY_POINT_STAGE_FORBIDDEN", + f"{path}.steps", + "Entry executes incompatible stage kinds: " + + ", ".join(sorted(kind.value for kind in forbidden)), + ) + reachable_writes = { + slot + for index in reachable_stage_indices + for slot in program.stages[index].declared_write_slots + } + if not set(entry.output_slots).issubset(reachable_writes): + issues.add( + "ENTRY_POINT_OUTPUT_NOT_WRITTEN", + f"{path}.steps", + "Entry steps do not write every declared output slot.", + ) + forbidden_write_buffers = { + ir.IRBufferKind.STATE_INPUT, + ir.IRBufferKind.PARAMETER, + ir.IRBufferKind.CONSTANT, + ir.IRBufferKind.MODE, + ir.IRBufferKind.RUNTIME_INPUT, + } + if any(slot.buffer in forbidden_write_buffers for slot in reachable_writes): + issues.add( + "ENTRY_POINT_PERSISTENT_WRITE", + f"{path}.steps", + "Evaluation entries may not mutate state, parameters, constants, modes or runtime inputs.", + ) + + issues.unique_ids(program.causal_plans, "plan_id", "causal_plans") + for index, plan in enumerate(program.causal_plans): + path = f"causal_plans[{index}]" + if plan.source_schema_version != 1: + issues.add("CAUSAL_SOURCE_VERSION_INVALID", path, "Only causal IR v1 metadata is supported.") + if plan.source_structural_signature is not None and ( + len(plan.source_structural_signature) != 64 + or any(character not in "0123456789abcdef" for character in plan.source_structural_signature) + ): + issues.add("CAUSAL_SIGNATURE_INVALID", path, "Source signature must be lowercase SHA-256.") + if any(item < 0 or item >= len(program.components) for item in plan.scope_component_indices): + issues.add("COMPONENT_INDEX_BOUNDS", path, "Causal scope component index is invalid.") + for slot_index, slot in enumerate((*plan.canonical_slots, *plan.compatibility_slots, *plan.reset_slots, *plan.external_effort_slots)): + valid_slot(slot, f"{path}.slots[{slot_index}]") + for stage in plan.effort_stages: + if stage.stage_index < 0 or stage.stage_index >= len(program.stages): + issues.add("STAGE_INDEX_BOUNDS", path, "Causal effort stage index is invalid.") + if any(stage < 0 or stage >= len(program.stages) for stage in plan.flow_stage_indices): + issues.add("STAGE_INDEX_BOUNDS", path, "Causal flow stage index is invalid.") + + issues.unique_ids(program.stream_plans, "plan_id", "stream_plans") + for plan_index, plan in enumerate(program.stream_plans): + path = f"stream_plans[{plan_index}]" + scc_ids: set[str] = set() + for slot_index, slot in enumerate(plan.node_slots): + valid_slot(slot, f"{path}.node_slots[{slot_index}]") + partition = tuple(slot for component in plan.strongly_connected_components for slot in component.node_slots) + if len(set(partition)) != len(partition) or set(partition) != set(plan.node_slots): + issues.add("STREAM_SCC_PARTITION", path, "SCCs must partition stream nodes exactly once.") + for scc_index, scc in enumerate(plan.strongly_connected_components): + if not scc.scc_id or scc.scc_id in scc_ids: + issues.add("STREAM_SCC_ID_INVALID", f"{path}.strongly_connected_components[{scc_index}]", "SCC IDs must be non-empty and unique.") + scc_ids.add(scc.scc_id) + if scc.block_index < 0 or scc.block_index >= len(program.blocks): + issues.add("BLOCK_INDEX_BOUNDS", f"{path}.strongly_connected_components[{scc_index}]", "SCC block index is invalid.") + elif program.blocks[scc.block_index].kind is not ir.IRBlockKind.STREAM_SCC: + issues.add("STREAM_SCC_BLOCK_KIND", f"{path}.strongly_connected_components[{scc_index}]", "SCCs must reference stream_scc blocks.") + elif ( + program.blocks[scc.block_index].convergence is None + or set(program.blocks[scc.block_index].convergence.monitor_slots) + != set(scc.node_slots) + ): + issues.add("STREAM_SCC_MONITOR_COVERAGE", f"{path}.strongly_connected_components[{scc_index}]", "SCC convergence monitors must match its node slots.") + scc_count = len(plan.strongly_connected_components) + if tuple(sorted(plan.topological_scc_indices)) != tuple(range(scc_count)): + issues.add("STREAM_TOPOLOGICAL_ORDER", path, "Topological order must cover each SCC once.") + position = {scc: index for index, scc in enumerate(plan.topological_scc_indices)} + for edge_index, edge in enumerate(plan.condensed_edges): + if not 0 <= edge.source_scc_index < scc_count or not 0 <= edge.target_scc_index < scc_count: + issues.add("STREAM_EDGE_BOUNDS", f"{path}.condensed_edges[{edge_index}]", "SCC edge is invalid.") + elif position.get(edge.source_scc_index, -1) >= position.get(edge.target_scc_index, -1): + issues.add("STREAM_DAG_CYCLE", f"{path}.condensed_edges[{edge_index}]", "Edge contradicts topological order.") + + thermofluid = program.thermofluid + if not 0 <= thermofluid.stream_plan_index < len(program.stream_plans): + issues.add("STREAM_PLAN_INDEX_BOUNDS", "thermofluid.stream_plan_index", "Stream plan is invalid.") + for indexes, upper, label in ( + (thermofluid.physical_port_indices, len(program.ports), "port"), + (thermofluid.global_component_indices, len(program.components), "component"), + (thermofluid.sensitive_component_indices, len(program.components), "component"), + (thermofluid.secondary_pressure_scope_indices, len(pressure_flow.scopes), "scope"), + ): + if any(item < 0 or item >= upper for item in indexes): + issues.add(f"{label.upper()}_INDEX_BOUNDS", "thermofluid", f"Thermofluid {label} index is invalid.") + if len(set(indexes)) != len(indexes): + issues.add(f"{label.upper()}_INDEX_DUPLICATE", "thermofluid", f"Thermofluid {label} indices must be unique.") + expected_physical_ports = { + index for index, port in enumerate(program.ports) if port.kind is ir.IRPortKind.PHYSICAL + } + if set(thermofluid.physical_port_indices) != expected_physical_ports: + issues.add("THERMOFLUID_PORT_COVERAGE", "thermofluid.physical_port_indices", "Thermofluid plan must cover every physical port.") + if thermofluid.secondary_pressure_scope_indices != pressure_flow.secondary_scope_indices: + issues.add("THERMOFLUID_SCOPE_MISMATCH", "thermofluid.secondary_pressure_scope_indices", "Thermofluid and pressure-flow secondary scopes must match.") + if not set(thermofluid.sensitive_component_indices).issubset( + thermofluid.global_component_indices + ): + issues.add("THERMOFLUID_SENSITIVE_SCOPE", "thermofluid.sensitive_component_indices", "Sensitive components must belong to the global closure component set.") + if thermofluid.maximum_iterations <= 0 or not isfinite(thermofluid.flow_relative_tolerance) or thermofluid.flow_relative_tolerance <= 0.0: + issues.add("THERMOFLUID_LIMIT_INVALID", "thermofluid", "Fixed-point limits must be positive.") + if thermofluid.uses_conservative_global_solver != ( + thermofluid.conservative_fallback_reason is not None + ): + issues.add("THERMOFLUID_FALLBACK_REASON", "thermofluid", "Conservative fallback flag and reason must be declared together.") + + transaction = program.transaction + for slot_index, slot in enumerate((*transaction.snapshot_slots, *transaction.flow_slots)): + valid_slot(slot, f"transaction.slots[{slot_index}]") + if len(set(transaction.snapshot_slots)) != len(transaction.snapshot_slots): + issues.add("TRANSACTION_DUPLICATE_SLOT", "transaction.snapshot_slots", "Snapshot slots must be unique.") + if len(set(transaction.flow_slots)) != len(transaction.flow_slots): + issues.add("TRANSACTION_DUPLICATE_SLOT", "transaction.flow_slots", "Flow slots must be unique.") + if not set(transaction.flow_slots).issubset(set(transaction.snapshot_slots)): + issues.add("TRANSACTION_FLOW_COVERAGE", "transaction.flow_slots", "Flow slots must be part of the snapshot.") + if any(item < 0 or item >= len(program.components) for item in transaction.cache_component_indices): + issues.add("COMPONENT_INDEX_BOUNDS", "transaction.cache_component_indices", "Cache owner is invalid.") + if len(set(transaction.cache_component_indices)) != len(transaction.cache_component_indices): + issues.add("TRANSACTION_CACHE_OWNER_DUPLICATE", "transaction.cache_component_indices", "Cache owners must be unique.") + if len(set(transaction.cache_attribute_ids)) != len(transaction.cache_attribute_ids): + issues.add("TRANSACTION_CACHE_ID_DUPLICATE", "transaction.cache_attribute_ids", "Cache attribute IDs must be unique.") + if any(not value for value in transaction.cache_attribute_ids): + issues.add("TRANSACTION_CACHE_ID_INVALID", "transaction.cache_attribute_ids", "Cache attribute IDs must not be empty.") + if len(set(transaction.diagnostic_owner_ids)) != len(transaction.diagnostic_owner_ids) or any( + not value for value in transaction.diagnostic_owner_ids + ): + issues.add("TRANSACTION_DIAGNOSTIC_ID_INVALID", "transaction.diagnostic_owner_ids", "Diagnostic owner IDs must be non-empty and unique.") + if "transactional_closure" in program.required_features and not ( + transaction.restores_on_recoverable_failure + and transaction.restores_on_fatal_failure + ): + issues.add("TRANSACTION_RESTORE_REQUIRED", "transaction", "Transactional closure requires rollback for every failed trial.") + for block_index, block in enumerate(program.blocks): + if block.convergence is not None and not set( + block.convergence.rollback_slots + ).issubset(transaction.snapshot_slots): + issues.add("TRANSACTION_ROLLBACK_COVERAGE", f"blocks[{block_index}].convergence.rollback_slots", "Iterative rollback slots must belong to the transaction snapshot.") + expected_flow_slots = { + variable.slot + for port in program.ports + if port.kind is ir.IRPortKind.PHYSICAL and port.domain == "pneumatic" + for variable in port.variables + if variable.name == "m_flow" + } + if set(transaction.flow_slots) != expected_flow_slots: + issues.add("TRANSACTION_FLOW_SEMANTICS", "transaction.flow_slots", "Transaction flow slots must exactly cover active pneumatic m_flow variables.") + expected_snapshot_slots = { + variable.slot + for port in program.ports + if port.kind is ir.IRPortKind.PHYSICAL + for variable in port.variables + } + if set(transaction.snapshot_slots) != expected_snapshot_slots: + issues.add("TRANSACTION_SNAPSHOT_COVERAGE", "transaction.snapshot_slots", "Transaction snapshots must exactly cover mutable physical port values.") + + issues.unique_ids(program.modes, "mode_id", "modes") + mode_by_slot: dict[ir.IRSlotRef, ir.IRModeSpec] = {} + for index, mode in enumerate(program.modes): + path = f"modes[{index}]" + valid_slot(mode.slot, f"{path}.slot") + if mode.slot.buffer is not ir.IRBufferKind.MODE: + issues.add("MODE_BUFFER_INVALID", f"{path}.slot", "Mode must use the mode buffer.") + if mode.slot in mode_by_slot: + issues.add("MODE_SLOT_DUPLICATE", f"{path}.slot", "Each mode requires a distinct slot.") + mode_by_slot[mode.slot] = mode + values = tuple(item.value for item in mode.values) + names = tuple(item.name for item in mode.values) + if any(value < -(2**31) or value > 2**31 - 1 for value in (*values, mode.initial_value)): + issues.add("MODE_INT32_RANGE", path, "Mode values and initial value must fit signed int32.") + if len(set(values)) != len(values) or len(set(names)) != len(names) or mode.initial_value not in values: + issues.add("MODE_VALUES_INVALID", path, "Mode values/names must be unique and include the initial value.") + if any(item < 0 or item >= len(program.components) for item in mode.owner_component_indices): + issues.add("COMPONENT_INDEX_BOUNDS", path, "Mode owner is invalid.") + if len(set(mode.owner_component_indices)) != len(mode.owner_component_indices): + issues.add("MODE_OWNER_DUPLICATE", path, "Mode owners must be unique.") + mode_buffer = buffer_by_kind.get(ir.IRBufferKind.MODE) + if mode_buffer is not None and set(mode_by_slot) != { + ir.IRSlotRef(ir.IRBufferKind.MODE, index) + for index in range(mode_buffer.size) + }: + issues.add("MODE_SLOT_COVERAGE", "modes", "Modes must cover every mode buffer slot exactly once.") + if mode_buffer is not None and mode_buffer.initial_int_values: + for mode in program.modes: + if ( + 0 <= mode.slot.index < len(mode_buffer.initial_int_values) + and mode_buffer.initial_int_values[mode.slot.index] != mode.initial_value + ): + issues.add("MODE_INITIAL_VALUE_MISMATCH", mode.mode_id, "Mode metadata and buffer initial value differ.") + for component_index, component in enumerate(program.components): + expected_mode_slots = { + mode.slot + for mode in program.modes + if component_index in mode.owner_component_indices + } + if set(component.mode_slots) != expected_mode_slots: + issues.add("COMPONENT_MODE_COVERAGE", f"components[{component_index}].mode_slots", "Component mode bindings must match mode ownership.") + + jacobian = program.jacobian + _validate_csr(jacobian.pattern, "jacobian.pattern", issues) + if jacobian.pattern.row_count != reducer.solver_state_count or jacobian.pattern.column_count != reducer.solver_state_count: + issues.add("JACOBIAN_SHAPE", "jacobian.pattern", "Jacobian must be state_count square.") + if len(jacobian.value_slots) != jacobian.pattern.nonzero_count: + issues.add("JACOBIAN_VALUE_COUNT", "jacobian.value_slots", "One value slot is required per nonzero.") + for slot_index, slot in enumerate(jacobian.value_slots): + valid_slot(slot, f"jacobian.value_slots[{slot_index}]") + if slot.buffer is not ir.IRBufferKind.JACOBIAN_VALUE: + issues.add("JACOBIAN_VALUE_BUFFER", f"jacobian.value_slots[{slot_index}]", "Jacobian values need the Jacobian buffer.") + jacobian_buffer = buffer_by_kind.get(ir.IRBufferKind.JACOBIAN_VALUE) + expected_jacobian_slots = tuple( + ir.IRSlotRef(ir.IRBufferKind.JACOBIAN_VALUE, index) + for index in range(jacobian_buffer.size if jacobian_buffer is not None else 0) + ) + if jacobian.value_slots != expected_jacobian_slots: + issues.add("JACOBIAN_VALUE_SLOT_COVERAGE", "jacobian.value_slots", "Jacobian value slots must be unique and cover their buffer in CSR order.") + colors = tuple(column for group in jacobian.color_groups for column in group) + if tuple(sorted(colors)) != tuple(range(reducer.solver_state_count)) or len(set(colors)) != len(colors): + issues.add("JACOBIAN_COLOR_PARTITION", "jacobian.color_groups", "Colors must partition all state columns.") + if ( + len(jacobian.pattern.row_pointers) == jacobian.pattern.row_count + 1 + and jacobian.pattern.row_pointers + and jacobian.pattern.row_pointers[0] == 0 + and jacobian.pattern.row_pointers[-1] + == len(jacobian.pattern.column_indices) + and all( + first <= second + for first, second in zip( + jacobian.pattern.row_pointers, + jacobian.pattern.row_pointers[1:], + ) + ) + ): + rows_by_column: dict[int, set[int]] = { + column: set() for column in range(jacobian.pattern.column_count) + } + for row in range(jacobian.pattern.row_count): + for column in jacobian.pattern.column_indices[ + jacobian.pattern.row_pointers[row] + : jacobian.pattern.row_pointers[row + 1] + ]: + if column in rows_by_column: + rows_by_column[column].add(row) + for color_index, group in enumerate(jacobian.color_groups): + occupied_rows: set[int] = set() + for column in group: + if column not in rows_by_column: + continue + if occupied_rows & rows_by_column[column]: + issues.add( + "JACOBIAN_COLOR_CONFLICT", + f"jacobian.color_groups[{color_index}]", + "Columns in one color may not share a nonzero row.", + ) + break + occupied_rows.update(rows_by_column[column]) + for step_index, step in enumerate(jacobian.fill_steps): + valid_step(step, f"jacobian.fill_steps[{step_index}]") + jacobian_entry = next( + ( + entry + for entry in program.entry_points + if entry.kind is ir.IREntryPointKind.JACOBIAN + ), + None, + ) + if jacobian_entry is not None and jacobian.fill_steps != jacobian_entry.steps: + issues.add("JACOBIAN_FILL_STEP_MISMATCH", "jacobian.fill_steps", "Jacobian fill steps must equal the public Jacobian entry plan.") + analytic = set(jacobian.analytic_value_indices) + if len(analytic) != len(jacobian.analytic_value_indices): + issues.add("JACOBIAN_ANALYTIC_DUPLICATE", "jacobian.analytic_value_indices", "Analytic value indices must be unique.") + numerical: set[int] = set() + numerical_sequence: list[int] = [] + seen_columns: set[int] = set() + for column_index, column in enumerate(jacobian.local_finite_difference_columns): + if not 0 <= column.column_index < reducer.solver_state_count or column.column_index in seen_columns: + issues.add("JACOBIAN_FD_COLUMN_INVALID", f"jacobian.local_finite_difference_columns[{column_index}]", "FD column is invalid or duplicated.") + seen_columns.add(column.column_index) + if not isfinite(column.relative_step) or column.relative_step <= 0.0: + issues.add("JACOBIAN_FD_STEP_INVALID", f"jacobian.local_finite_difference_columns[{column_index}]", "FD step must be positive.") + numerical.update(column.value_indices) + numerical_sequence.extend(column.value_indices) + if len(set(column.value_indices)) != len(column.value_indices): + issues.add("JACOBIAN_FD_VALUE_DUPLICATE", f"jacobian.local_finite_difference_columns[{column_index}]", "FD value indices within one column must be unique.") + for value_index in column.value_indices: + if ( + 0 <= value_index < len(jacobian.pattern.column_indices) + and jacobian.pattern.column_indices[value_index] + != column.column_index + ): + issues.add("JACOBIAN_FD_COLUMN_MISMATCH", f"jacobian.local_finite_difference_columns[{column_index}]", "FD values must belong to the declared CSR column.") + if len(set(numerical_sequence)) != len(numerical_sequence): + issues.add("JACOBIAN_FD_VALUE_DUPLICATE", "jacobian.local_finite_difference_columns", "FD value indices may be assigned only once.") + all_value_indices = set(range(jacobian.pattern.nonzero_count)) + if analytic & numerical or analytic | numerical != all_value_indices: + issues.add("JACOBIAN_FILL_COVERAGE", "jacobian", "Every nonzero needs exactly one analytic or local-FD strategy.") + if any(index < 0 or index >= jacobian.pattern.nonzero_count for index in analytic | numerical): + issues.add("JACOBIAN_VALUE_INDEX_BOUNDS", "jacobian", "Jacobian strategy references an invalid nonzero.") + + issues.unique_ids(program.events, "event_id", "events") + event_root_slots: set[ir.IRSlotRef] = set() + for index, event in enumerate(program.events): + path = f"events[{index}]" + valid_slot(event.root_slot, f"{path}.root_slot") + if event.root_slot.buffer is not ir.IRBufferKind.EVENT_OUTPUT: + issues.add("EVENT_BUFFER_INVALID", f"{path}.root_slot", "Event root must use the event buffer.") + if event.root_slot in event_root_slots: + issues.add("EVENT_ROOT_DUPLICATE", f"{path}.root_slot", "Each event must have a distinct root slot.") + event_root_slots.add(event.root_slot) + if any(item < 0 or item >= len(program.components) for item in event.owner_component_indices): + issues.add("COMPONENT_INDEX_BOUNDS", path, "Event owner is invalid.") + for guard_index, guard in enumerate(event.mode_guards): + if any( + value < -(2**31) or value > 2**31 - 1 + for value in guard.allowed_values + ): + issues.add( + "MODE_INT32_RANGE", + f"{path}.mode_guards[{guard_index}]", + "Mode guard values must fit signed int32.", + ) + if guard.mode_slot not in mode_by_slot: + issues.add("EVENT_MODE_MISSING", f"{path}.mode_guards[{guard_index}]", "Guard mode is not declared.") + elif not set(guard.allowed_values).issubset({value.value for value in mode_by_slot[guard.mode_slot].values}): + issues.add("EVENT_MODE_VALUE_INVALID", f"{path}.mode_guards[{guard_index}]", "Guard value is not declared.") + if len({guard.mode_slot for guard in event.mode_guards}) != len(event.mode_guards): + issues.add("EVENT_MODE_GUARD_DUPLICATE", f"{path}.mode_guards", "An event may guard each mode only once.") + for step_index, step in enumerate(event.reset_steps): + valid_step(step, f"{path}.reset_steps[{step_index}]") + reset_stage_indices = expanded_stage_indices(event.reset_steps) + if any( + program.stages[stage_index].kind is not ir.IRStageKind.RESET + for stage_index in reset_stage_indices + ): + issues.add("EVENT_RESET_STAGE_INVALID", f"{path}.reset_steps", "Event reset plans may contain only reset stages.") + if len(set(event.invalidated_caches)) != len(event.invalidated_caches): + issues.add("EVENT_CACHE_DUPLICATE", f"{path}.invalidated_caches", "Invalidated caches must be unique.") + event_buffer = buffer_by_kind.get(ir.IRBufferKind.EVENT_OUTPUT) + expected_event_slots = { + ir.IRSlotRef(ir.IRBufferKind.EVENT_OUTPUT, index) + for index in range(event_buffer.size if event_buffer is not None else 0) + } + if event_root_slots != expected_event_slots: + issues.add("EVENT_ROOT_COVERAGE", "events", "Events must cover every event output slot exactly once.") + + issues.unique_ids(program.outputs, "output_id", "outputs") + output_slots: set[ir.IRSlotRef] = set() + output_order_keys: set[tuple[int, str, str | None, int]] = set() + for index, output in enumerate(program.outputs): + path = f"outputs[{index}]" + valid_slot(output.source_slot, f"{path}.source_slot") + valid_slot(output.output_slot, f"{path}.output_slot") + if output.output_slot.buffer is not ir.IRBufferKind.RESULT_OUTPUT: + issues.add("OUTPUT_BUFFER_INVALID", f"{path}.output_slot", "Output must use result buffer.") + if output.output_slot in output_slots: + issues.add("OUTPUT_SLOT_DUPLICATE", f"{path}.output_slot", "Output slot is duplicated.") + output_slots.add(output.output_slot) + if not output.scope or not output.name or not output.label or not output.category or not output.quantity: + issues.add("OUTPUT_METADATA_INCOMPLETE", path, "Output scope, name, label, category and quantity are required.") + if output.order < 0: + issues.add("OUTPUT_ORDER_INVALID", f"{path}.order", "Output order cannot be negative.") + order_key = ( + output.component_index, + output.scope, + output.port_name, + output.order, + ) + if order_key in output_order_keys: + issues.add("OUTPUT_ORDER_DUPLICATE", f"{path}.order", "Output order must be unique within one component scope/port.") + output_order_keys.add(order_key) + if not 0 <= output.component_index < len(program.components): + issues.add("COMPONENT_INDEX_BOUNDS", path, "Output component is invalid.") + if not isfinite(output.scale) or output.scale == 0.0 or not isfinite(output.offset): + issues.add("OUTPUT_AFFINE_INVALID", path, "Output scale/offset must be finite and scale nonzero.") + if index not in program.components[output.component_index].output_indices if 0 <= output.component_index < len(program.components) else False: + issues.add("COMPONENT_OUTPUT_BACKREF", path, "Owning component does not reference this output.") + result_buffer = buffer_by_kind.get(ir.IRBufferKind.RESULT_OUTPUT) + expected_output_slots = { + ir.IRSlotRef(ir.IRBufferKind.RESULT_OUTPUT, index) + for index in range(result_buffer.size if result_buffer is not None else 0) + } + if output_slots != expected_output_slots: + issues.add("OUTPUT_SLOT_COVERAGE", "outputs", "Outputs must cover every result buffer slot exactly once.") + for component_index, component in enumerate(program.components): + expected_output_indices = tuple( + index + for index, output in enumerate(program.outputs) + if output.component_index == component_index + ) + if component.output_indices != expected_output_indices: + issues.add("COMPONENT_OUTPUT_COVERAGE", f"components[{component_index}].output_indices", "Component outputs must exactly cover its owned outputs in program order.") + + capabilities = program.capabilities + component_capabilities = tuple(item.component_index for item in capabilities.components) + called_phases_by_component: dict[int, set[ir.IRKernelPhase]] = { + index: set() for index in range(len(program.components)) + } + for stage in program.stages: + for operation in stage.operations: + if ( + isinstance(operation, ir.IRKernelCallOperation) + and operation.component_index is not None + and 0 <= operation.component_index < len(program.components) + ): + called_phases_by_component[operation.component_index].add( + operation.phase + ) + if tuple(sorted(component_capabilities)) != tuple(range(len(program.components))) or len(set(component_capabilities)) != len(component_capabilities): + issues.add("CAPABILITY_COMPONENT_COVERAGE", "capabilities.components", "Capability report must cover every component once.") + for index, capability in enumerate(capabilities.components): + if not 0 <= capability.component_index < len(program.components): + issues.add("COMPONENT_INDEX_BOUNDS", f"capabilities.components[{index}]", "Capability component is invalid.") + continue + kernel_index = program.components[capability.component_index].kernel_index + if not 0 <= kernel_index < len(program.kernels): + continue + kernel = program.kernels[kernel_index] + kernel_phases = {phase.phase for phase in kernel.phases} + if len(set(capability.supported_phases)) != len(capability.supported_phases): + issues.add("CAPABILITY_PHASE_DUPLICATE", f"capabilities.components[{index}]", "Supported phases must be unique.") + if not set(capability.supported_phases).issubset(kernel_phases): + issues.add("CAPABILITY_PHASE_MISMATCH", f"capabilities.components[{index}]", "Capability phases must be declared by the component kernel.") + if ( + capability.level is ir.IRCapabilityLevel.NATIVE + and not called_phases_by_component[capability.component_index].issubset( + capability.supported_phases + ) + ): + issues.add( + "CAPABILITY_NATIVE_PHASE_MISSING", + f"capabilities.components[{index}].supported_phases", + "A native component must support every phase used by its kernel calls.", + ) + if ( + capability.level is ir.IRCapabilityLevel.NATIVE + and kernel.availability is not ir.IRKernelAvailability.NATIVE + ): + issues.add("CAPABILITY_KERNEL_MISMATCH", f"capabilities.components[{index}]", "A component cannot be native while its kernel is reference-only.") + if capability.level is ir.IRCapabilityLevel.NATIVE and capability.missing_features: + issues.add("CAPABILITY_MISSING_FEATURE_CONFLICT", f"capabilities.components[{index}]", "Native components cannot list missing features.") + if capability.level is not ir.IRCapabilityLevel.NATIVE and not capability.missing_features: + issues.add("CAPABILITY_MISSING_FEATURE_REQUIRED", f"capabilities.components[{index}]", "Non-native components must identify missing features.") + if len(set(capability.missing_features)) != len(capability.missing_features) or any( + not value for value in capability.missing_features + ): + issues.add("CAPABILITY_MISSING_FEATURE_INVALID", f"capabilities.components[{index}]", "Missing feature IDs must be non-empty and unique.") + issue_keys: set[tuple[str, str]] = set() + for index, issue in enumerate(capabilities.issues): + key = (issue.code, issue.scope_id) + if not issue.code or not issue.scope_id or not issue.message: + issues.add("CAPABILITY_ISSUE_INCOMPLETE", f"capabilities.issues[{index}]", "Capability issues require code, scope and message.") + if key in issue_keys: + issues.add("CAPABILITY_ISSUE_DUPLICATE", f"capabilities.issues[{index}]", "Capability issue code/scope pairs must be unique.") + issue_keys.add(key) + if any(issue.severity is ir.IRDiagnosticSeverity.ERROR for issue in capabilities.issues) and capabilities.system_level is not ir.IRCapabilityLevel.UNSUPPORTED: + issues.add("CAPABILITY_LEVEL_CONFLICT", "capabilities.system_level", "Error issues require unsupported system level.") + if capabilities.system_level is ir.IRCapabilityLevel.NATIVE and any( + item.level is not ir.IRCapabilityLevel.NATIVE for item in capabilities.components + ): + issues.add("CAPABILITY_LEVEL_CONFLICT", "capabilities.system_level", "Native system contains a non-native component.") + if capabilities.system_level is ir.IRCapabilityLevel.NATIVE and any( + kernel.availability is not ir.IRKernelAvailability.NATIVE + for kernel in program.kernels + ): + issues.add("CAPABILITY_LEVEL_CONFLICT", "capabilities.system_level", "Native system contains a reference-only kernel.") + if ( + capabilities.system_level is ir.IRCapabilityLevel.NATIVE + and transaction.cache_attribute_ids + ): + issues.add( + "CAPABILITY_LEVEL_CONFLICT", + "transaction.cache_attribute_ids", + "Native systems cannot depend on opaque Python cache attributes.", + ) + if ( + capabilities.system_level is ir.IRCapabilityLevel.NATIVE + and "reference_kernel_dispatch" in program.required_features + ): + issues.add( + "CAPABILITY_LEVEL_CONFLICT", + "required_features", + "Native systems cannot require reference-kernel dispatch.", + ) + if ( + capabilities.system_level is not ir.IRCapabilityLevel.UNSUPPORTED + and any( + item.level is ir.IRCapabilityLevel.UNSUPPORTED + for item in capabilities.components + ) + ): + issues.add( + "CAPABILITY_LEVEL_CONFLICT", + "capabilities.system_level", + "A system containing an unsupported component must be unsupported.", + ) + if ( + capabilities.system_level is ir.IRCapabilityLevel.UNSUPPORTED + and not any( + issue.severity is ir.IRDiagnosticSeverity.ERROR + for issue in capabilities.issues + ) + ): + issues.add("CAPABILITY_LEVEL_CONFLICT", "capabilities.system_level", "Unsupported systems require an error capability issue.") + + try: + program.canonical_json_bytes() + except (TypeError, ValueError) as exc: + issues.add("CANONICAL_SERIALIZATION_FAILED", "$", str(exc)) + + return IRValidationReport(tuple(issues.items)) + + +def require_valid_system_ir(program: ir.SystemIR) -> ir.SystemIR: + """Return ``program`` or raise one error containing all static issues.""" + + report = validate_system_ir(program) + if not report.valid: + raise SystemIRValidationError(report) + return program diff --git a/docs/other/C语言数值内核实施计划与可行性评估.md b/docs/other/C语言数值内核实施计划与可行性评估.md index 03be492..395a534 100644 --- a/docs/other/C语言数值内核实施计划与可行性评估.md +++ b/docs/other/C语言数值内核实施计划与可行性评估.md @@ -139,6 +139,8 @@ SIMULATION_NUMERIC_ENGINE=python|native|shadow|auto #### C-01 完整数值 IR schema v2 +状态:**已完成 v2.0 schema、规范、系统编译器、fail-closed 语义校验和复杂模型结构回归。** 当前结果明确为 `reference_only`,C-02/C-03 尚未完成,默认 Python 求解路径没有切换。 + 完整 IR 必须描述共享数值阶段,以及 RHS、Event、Jacobian 和输出四类独立的按需入口。它们可以复用同一套槽位和依赖信息,但不能被误实现成“每次 RHS 都顺序计算 Event、Jacobian 和输出”。共享 primal 计划为: ```text @@ -169,11 +171,12 @@ IR 至少描述: - stream 图、SCC、物性状态包、外层固定点和事务恢复集合; - event ID、左/右状态、reset、Jacobian 失效和模式计划; - 固定的 CSR Jacobian 结构和 output projection; -- IR schema、native ABI、组件模型/实现、介质、编译器、dtype、平台和构建选项组成的结构签名。 +- 完整 IR 内容(含组件模型/实现、介质、dtype、参数和执行计划)组成跨平台内容签名; +- native artifact key 再把内容签名与 native ABI、目标平台、编译器、构建选项、浮点策略和 kernel 库签名组合。 原生兼容 program 中不得存在 Python callback。schema v1 保留为参考适配器,不直接扩展成生产 ABI。 -完成标准:同一模型重复编译得到字节级稳定的结构和签名;所有索引及读写集合可静态校验;缺少能力时明确拒绝编译。 +完成标准:同一模型重复编译得到字节级稳定的结构和签名;所有索引及读写集合可静态校验;结构可完整描述但缺少原生能力时明确标为 `reference_only`,原生加载器必须拒绝接管。 #### C-02 纯数值组件合同 @@ -397,8 +400,8 @@ Numba 可以在完整数组 IR 后用于 1–2 周的架构验证,但不作为 第一批只做 P0,不直接开始大规模 C 编码: 1. **已完成:** 固定当前权威输入和回归证据的 `LF` 检出规则,重新规范化 Windows 工作树,并增加 Windows/Linux 字节合同测试;未重建语义未变的 golden。 -2. 生成当前模型的组件类型、槽位、阶段、副作用和事件能力矩阵。 -3. 将 schema v2 写成独立规范,先冻结 RHS 阶段、错误、事务、事件和结构签名。 +2. **已完成(C-01 结构层):** 生成当前模型的组件类型、槽位、阶段、副作用和事件能力描述;纯数值 kernel 能力矩阵的实现细节继续归入 C-02。 +3. **已完成:** 将 schema v2 写成独立规范,并冻结 RHS/Event/Jacobian/Outputs 入口、错误能力、事务、事件、Jacobian 和内容签名。 4. 建立对象引擎与扁平 IR 的逐阶段 Shadow runner。 5. 用一条完整机械—气动—管路—接触支路完成 Python 参考闭环。 6. 评审通过后,再建立最小 C ABI 和纵向切片。 diff --git a/docs/standard/system-numeric-ir-v2.md b/docs/standard/system-numeric-ir-v2.md new file mode 100644 index 0000000..edef963 --- /dev/null +++ b/docs/standard/system-numeric-ir-v2.md @@ -0,0 +1,262 @@ +# 全系统数值中间表示(System Numeric IR)规范 v2.0 + +状态:C-01 已实现并冻结 v2.0 数据合同;当前编译结果为 `reference_only`,原生 kernel 与执行器属于 C-02/C-03 及后续工作。 + +适用范围:后端 `GenericFluidSystem` 编译后的整个仿真系统。 + +机器可读定义:`schemas/system-numeric-ir-v2.schema.json`。 + +## 1. 定位与边界 + +System Numeric IR(以下简称 IR)描述“一个已经解析并编译好的系统,数值求解时需要哪些数据、按什么关系执行”。它是系统级合同,不是单个部件的文件。 + +```text +XML / 建模 JSON + → 模型解析和连接检查 + → GenericFluidSystem 对象图 + → System IR v2.0 + → 参考执行器 / 未来原生执行器 +``` + +XML 保存用户建立了哪些元件、参数和连线;IR 在此基础上补充求解器真正需要的槽位编号、状态降维、方程块、执行阶段、闭合范围、事务回滚、事件、Jacobian 和输出投影。因此二者看起来相似,但用途和层级不同。 + +本版本只完成“完整、确定、可校验的数据合同”和从现有系统生成该合同的编译器。它没有替换当前默认 Python 求解路径,不改变现有仿真结果。IR 中禁止保存 Python 函数、闭包、模型对象、对象地址和运行期临时状态。 + +## 2. 三个权威来源 + +三份实现共同定义 v2.0: + +- `app/simulation/ir/schema.py`:Python 不可变数据类型、枚举、规范序列化和签名算法; +- `schemas/system-numeric-ir-v2.schema.json`:跨语言 JSON 结构合同; +- `app/simulation/ir/validation.py`:仅靠 JSON Schema 无法表达的引用、覆盖、拓扑和数值语义校验。 + +生产者必须同时满足机器 Schema 和语义校验。字段有增删时必须同步修改三处以及合同测试,不能只更新文档。 + +## 3. 版本、兼容性和严格读取 + +版本不是顶层整数,而是 `version` 对象: + +```json +{ + "$type": "schema_version", + "schema_id": "system-numeric-ir", + "major": 2, + "minor": 0 +} +``` + +- `major` 改变表示不兼容的字段或执行语义变化;读取方必须拒绝未知主版本。 +- `minor` 用于同一主版本内向前演进;当前读取方拒绝负数和高于自身能力的次版本。 +- JSON Schema 对所有合同对象使用 `additionalProperties: false`,v2.0 读取方不会静默忽略未知字段或枚举值。 +- 原生二进制 ABI 不写入 `SystemIR`,而由独立的 `IRNativeBuildIdentity.abi_version` 管理,当前值为 `1`。 +- 压力流量中的 `causal_plans.source_schema_version == 1` 只表示其来源是既有 causal IR v1;它不是完整系统 IR 的版本,也不能携带 v1 的 Python 回调。 + +## 4. 线格式和顶层结构 + +每个 dataclass 序列化后都带有稳定的 `$type`;操作对象还带有 `opcode`。顶层 `$type` 为 `system_ir`,其字段完整集合如下: + +| 字段 | 含义 | +| --- | --- | +| `version` | IR schema 身份与版本 | +| `model_id` / `model_version` | 输入系统的稳定身份和调用方提供的模型版本 | +| `compiler_id` / `compiler_version` | 产生 IR 的编译器身份,当前为 `generic-fluid-system` / `2.0.0` | +| `numeric_dtype` | 主数值类型,v2.0 只接受 `float64` | +| `buffers` / `values` | 连续缓冲区及每一个数值槽位的元数据 | +| `kernels` / `components` | kernel 声明与元件实例绑定 | +| `mediums` | 介质实现、介质常量和使用该介质的元件 | +| `ports` / `connections` | 端口变量和系统拓扑 | +| `state_reducer` | 求解器状态与元件局部状态/导数的线性映射 | +| `causal_plans` / `pressure_flow` | 因果子计划和完整压力流量方程计划 | +| `stream_plans` / `thermofluid` | stream SCC/DAG 与热流体外层闭合计划 | +| `stages` / `blocks` | 无回调操作阶段与复合/迭代执行块 | +| `entry_points` | `rhs`、`events`、`jacobian`、`outputs` 四个入口 | +| `transaction` | 试算快照、流量恢复和参考缓存诊断 | +| `modes` / `events` | 离散模式、根函数、reset 与缓存失效 | +| `jacobian` | 固定 CSR 结构、着色和局部有限差分计划 | +| `outputs` | 结果元数据和投影 | +| `capabilities` | 系统和元件的原生可执行能力及缺口 | +| `required_features` | 读取/执行该程序必须理解的功能 ID | + +所有列表的顺序都是合同的一部分。引用统一采用数组索引或稳定 ID,不能依赖哈希表遍历顺序。 + +## 5. 规范序列化与内容签名 + +`canonical_json_bytes()` 是跨平台唯一线表示: + +- UTF-8,ASCII 转义开启,JSON 键排序,无无意义空白; +- 字符串先做 Unicode NFC 规范化; +- tuple 写成 JSON array,不接受 list、dict、set 或任意对象; +- 浮点数写成 IEEE-754 binary64 大端十六进制对象,例如 `{"$float64":"3ff0000000000000"}`; +- `-0.0` 统一为 `+0.0`,NaN 和正负无穷直接拒绝; +- 枚举写成规范字符串,整数和布尔值保持其 JSON 类型。 + +`SystemIR.structural_signature` 是上述完整 `SystemIR` 内容字节的 SHA-256 小写十六进制值。它准确回答“这份 IR 内容是否完全相同”,包含参数值和所有计划,因此不声称不同表达形式的数学系统会得到同一签名。操作系统、机器路径、构建时间、编译器和 native flags 不进入这个签名。 + +## 6. 缓冲区、槽位和值 + +槽位引用的格式为 `{"$type":"slot_ref","buffer":"...","index":N}`。v2.0 恰好声明以下 16 类缓冲区,每类一次: + +| dtype | 缓冲区 | +| --- | --- | +| `float64` | `time`、`state_input`、`derivative_output`、`local_state`、`local_derivative`、`algebraic`、`signal`、`parameter`、`constant`、`work_float`、`event_output`、`jacobian_value`、`result_output`、`runtime_input` | +| `int32` | `mode`、`work_int` | + +`time` 的长度必须为 1;`int32` 初值必须在有符号 32 位范围内。每个缓冲区内索引为 `[0, size)`,而且每一个实际槽位必须恰好有一个 `IRValueSpec`。值描述包含稳定 ID、语义、角色、物理量、单位、缩放、可选上下界和可选所属元件。缩放必须为正有限数,边界必须有序且有限。 + +缓冲区是执行器唯一的数值寻址合同。名称用于诊断,不允许执行器重新用名称查找取代槽位访问。 + +## 7. Kernel 与元件绑定 + +`IRKernelSpec` 声明模型类型、模型版本、实现版本、能力、支持的 phase 以及参数/状态/mode/workspace 数量。phase 枚举为: + +`primal`、`residual`、`derivative`、`property`、`event`、`reset`、`jacobian`、`output`。 + +phase 在 C-01 中只是稳定的功能标签。不同模型在同一 phase 下可能有不同输入输出数量,所以不能在 phase 上填写虚假的统一 arity。当前每个 `IRKernelCallOperation` 自身的有序 `read_slots`、`write_slots` 和 `equation_indices` 才是该次调用的权威依赖合同。C-02 将在此基础上冻结每个 `kernel_id + phase` 的纯数值调用签名并验证所有调用实例一致。 + +`IRComponentInstance` 把一个元件实例绑定到 kernel,并明确列出参数、局部状态、局部导数、mode、端口、输出和两类 workspace。绑定数量必须与 kernel 声明一致;端口和输出必须与其反向所属关系精确一致。 + +当前编译器把所有既有 Python kernel 标记为 `reference_only`。为便于结构审计,reference kernel 调用声明了保守读集合:可能多读,但不能漏掉模型对象当前可见的数值输入。Python 内部隐藏缓存仍不是原生槽位,因而任何含这类依赖的程序都不得宣称 `native`。 + +## 8. 介质、端口和连接 + +介质记录稳定 `medium_id`、名称、实现及其版本、常量槽位和使用它的元件索引。介质参数必须位于 `constant` 缓冲区。 + +端口分为 `physical` 与 `signal`: + +- 物理端口必须声明正流方向,当前统一为 `intoComponent`; +- signal 端口不得声明物理流向; +- 变量角色与连接规则固定对应:`effort → equal`、`flow → sumToZero`、`stream → streamMix`、`signal → directed`。 + +连接必须引用两个已声明、不同、同 kind/同 domain 的端口,并精确覆盖两个端口的全部同名变量合同。物理端口和 signal 输入最多被一条连接占用;signal 输出允许扇出到多个输入。禁止重复端点对和悬空索引。 + +## 9. 状态降维与导数汇总 + +`IRStateReducer` 使求解器的 `state_input` 与各元件 `local_state` 分离。`state_reducer.initial_state` 是状态初值的语义描述,必须与 `state_input` 缓冲区的初值逐项完全相同,避免消费者面对两个不同初值: + +- `state_scatter` 用 CSR 矩阵把求解器状态散射到有序局部状态槽位; +- `derivative_gather` 把有序局部导数汇总为 `derivative_output`; +- `initial_state` 与 `absolute_tolerances` 按求解器状态顺序定义。 + +这能显式表达共享机械坐标和气动储能状态的降维关系。例如同一气动储能状态可以按体积权重散射到多个局部状态,而不是由执行器临时按对象身份猜测。两个矩阵必须满足 CSR 不变量、维度和值数量合同,并覆盖全部组件状态/导数绑定。 + +## 10. 压力—流量计划与因果元数据 + +`IRPressureFlowPlan` 包含: + +- `unknowns`:未知量的元件、端口、变量角色、槽位、缩放和边界; +- `equations`:元件或连接拥有的方程、关系、涉及槽位、残差槽位和缩放; +- `blocks`:未知量/方程的方块分解及每块 Jacobian CSR 结构; +- `scopes`:全网、敏感物理岛或方程块作用域及求解限制; +- `global_scope_index` 和 `secondary_scope_indices`:第一次全网求解与后续局部重算范围; +- `pressure_lower_bound`:全局压力下界。 + +全局 scope 必须覆盖完整网络;每个 scope 的未知量和方程必须等于它包含的 blocks 之并集;secondary scope 唯一且不能包含 global scope。每条方程必须拥有唯一的 residual 槽位,防止两个残差互相覆盖。`sparse_pattern_trusted=false` 时必须给出回退原因,可信结构则不得携带回退原因。 + +`IRCausalPlan` 保存当前 causal IR v1 编译得到的无回调元数据,包括作用域、规范/兼容/重置槽位、外部 effort、effort 阶段和 flow 阶段。它只作为 v2 压力流量计划的一部分,不代替完整系统计划。 + +## 11. 操作、阶段、执行块和四个入口 + +v2.0 的无回调 opcode 为: + +`fill`、`copy`、`scatter`、`linear_combination`、`state_map`、`kernel_call`、`effort_broadcast`、`flow_assign`、`check_finite`。 + +`IRStage` 给出 stage kind、操作序列以及声明的读/写集合;声明集合必须与操作读写并集完全一致。`IRExecutionBlock` 可以按顺序引用 stage 或其他 block;引用图必须无环。`fixed_point` 和 `stream_scc` block 必须声明监控槽位、绝对/相对容差、最大迭代、松弛、回滚槽位和失败策略,其他 block 禁止携带收敛合同。 + +四个入口必须恰好各一个,且入口输入统一按 `time`、完整 `state_input`、完整 `runtime_input` 排列: + +| 入口 | 必须到达的结果阶段 | 精确输出缓冲区 | 不允许夹带 | +| --- | --- | --- | --- | +| `rhs` | `derivative_reduce` | 全部 `derivative_output` | event、Jacobian、output、reset | +| `events` | `event` | 按事件顺序的全部 `event_output` 根槽位 | derivative reduce、Jacobian、output、reset | +| `jacobian` | `jacobian` | 按 CSR 顺序的全部 `jacobian_value` | event、output、reset | +| `outputs` | `output` | 按结果顺序的全部 `result_output` | event、Jacobian、reset | + +入口可以复用前置 primal 阶段,但不能把四个入口合并成“每次 RHS 都把事件、Jacobian 和全部输出计算一遍”。入口执行不得写入 `state_input`、`parameter`、`constant` 或 `mode` 等持久输入。 + +## 12. Stream、热流体闭合和事务 + +`IRStreamPlan` 显式列出 stream 节点、强连通分量(SCC)、SCC 间缩点 DAG 和拓扑顺序。每个 SCC 对应一个 `stream_scc` block,循环只在 SCC 内迭代;监控槽位必须覆盖该 SCC 节点。 + +`IRThermofluidPlan` 覆盖全部物理端口,关联 stream plan、全局元件集合、敏感元件、secondary 压力 scope、最大迭代和流量相对容差。是否使用保守全网求解及原因必须成对出现,避免执行器静默扩大作用域。 + +`IRTransactionPlan` 冻结一次试探计算需要快照和恢复的端口变量,并单列实际活动气动端口上的 `m_flow`。当前目标系统中的这部分数量为 232;机械模型对象里没有作为活动端口变量出现的隐藏 `m_flow` 字段不会被误算进该集合。所有失败试算必须恢复快照;正常成功返回即为隐式提交,不另设可被误排序的 commit opcode。 + +`cache_component_indices`、`cache_attribute_ids` 和 `diagnostic_owner_ids` 只记录当前 Python 参考路径中仍需关注的隐藏副作用,供 C-02/C-03 清除和 Shadow 诊断;它们不是原生内存布局。存在 opaque Python cache 属性的系统不能标记为 `native`。 + +## 13. 模式、事件和 Reset + +每个 `IRModeSpec` 记录 int32 mode 槽位、所属元件、合法值及初值。模式槽位必须全部且只被一个 mode 说明,组件 mode 绑定与 owner 关系必须双向覆盖。 + +每个 `IREventSpec` 记录稳定事件 ID、事件类型、owner、根槽位、触发方向、终止性、优先级、mode guard、reset steps、失效缓存种类以及是否重启积分器。reset 只能引用 `reset` stage,事件根槽位必须精确覆盖 `event_output` 缓冲区。 + +当前 IR 已能表达现有元件暴露的事件和模式结构;仍隐藏在 Python 信号求解或机械密集输出逻辑中的行为属于 `reference_only` 能力缺口,必须在 C-02/C-08 显式化后才能原生执行。 + +## 14. Jacobian 合同 + +`IRJacobianPlan` 包含固定 CSR pattern、与非零项一一对应的 `value_slots`、颜色组、填充值步骤、解析 value 索引和局部有限差分列。 + +CSR 必须满足:`row_pointers` 长度为行数加一、首项为 0、单调不减、末项等于非零项数量;每行列索引递增、唯一且在范围内。颜色组中的列不能共享同一潜在非零行,列不能重复着色。解析项和有限差分项不得重复或越界;每个有限差分列只能填写该列在 CSR 中确实存在的 value 索引,步长必须为正有限数。Jacobian 入口的执行步骤必须与 `fill_steps` 完全一致。 + +结构可以保守地多报潜在非零项,但不能漏报可能依赖。结构、模式布局或 kernel 实现改变会自然改变整份 IR 内容签名。 + +## 15. 输出合同 + +每个 `IROutputSpec` 包含稳定 output ID、所属元件、scope、可选端口名、内部名、显示标签、类别、物理量、单位、局部顺序、来源槽位、结果槽位以及线性 scale/offset。 + +`output_id` 和 `result_output` 槽位在全系统唯一。`order` 只在 `(component_index, scope, port_name)` 内排序,因此不同元件出现相同 `order` 是合法的;全局最终列顺序由 `outputs` 数组顺序确定。组件的 `output_indices` 必须精确反向覆盖其所有输出。 + +## 16. 能力报告与拒绝规则 + +能力级别只有: + +- `native`:所有 kernel phase、状态、事件、事务和缓存都满足原生合同; +- `reference_only`:数学/结构已描述,但至少一个阶段仍依赖 Python 参考实现; +- `unsupported`:当前 IR 无法安全表达或执行,必须带 error 级能力问题。 + +每个元件必须恰好有一条 capability,列出支持 phase 与缺失 feature。系统为 `native` 时所有元件和 kernels 都必须是 native,且不能依赖 opaque Python cache;系统含任意 reference-only 元件时不能伪装为 native。能力问题具有 code、severity、scope ID 和消息,相同 code/scope 不得重复。 + +当前 `compile_system_ir()` 的输出明确为 `reference_only`,原因是 C-02 的纯数值 kernel 签名与 C-03 的扁平参考执行器尚未完成。这不是 IR 编译失败,也不允许 native loader 越过能力报告运行。标为 native 的系统还必须覆盖所有实际调用 phase,并且不得要求 `reference_kernel_dispatch`。 + +## 17. 原生构建产物键 + +二进制缓存身份与 IR 内容签名严格分离。`native_artifact_key(program, build)` 对以下信息再次做规范序列化和 SHA-256: + +- `program.structural_signature`; +- native ABI 版本; +- target triple; +- 编译器 ID 与版本; +- 有序编译 flags; +- 浮点策略; +- kernel 库 SHA-256 签名。 + +ABI 必须等于当前支持值,字符串不能为空,flags 不能含空项,kernel 库签名必须为 64 位小写十六进制。这样相同 IR 在 Windows/Linux 上具有相同内容签名,但得到不同且安全的 native artifact key。 + +## 18. 编译、校验和消费流程 + +当前公开入口为: + +```python +from app.simulation.ir import compile_system_ir, require_valid_system_ir + +program = compile_system_ir(system, model_version="...") +require_valid_system_ir(program) +payload = program.canonical_json_bytes() +signature = program.structural_signature +``` + +`compile_system_ir()` 接收已完成解析和系统构建的 `GenericFluidSystem`,不直接解析 XML。消费者必须先验证,再根据 `capabilities.system_level` 选择参考路径或未来原生路径;不得把“JSON Schema 能读取”误当成“具备 native 执行能力”。 + +静态校验采用 fail-closed 策略,覆盖:版本与 required feature、全部槽位、数值范围、组件/kernel arity、端口/连接、介质、状态映射、压力流量方程与 scope、阶段读写、block 无环、四入口切片、stream/热流体、事务、mode/event/reset、Jacobian、输出及能力一致性。`require_valid_system_ir()` 聚合错误后拒绝程序。 + +## 19. C-01 验收边界与后续工作 + +C-01 的完成标准是: + +- 能从当前目标复杂模型和历史 0.81 s 模型生成完整系统级结构; +- 同一系统重复编译得到字节完全相同的 canonical JSON 和签名; +- 换行方式、Python 哈希种子和目标平台不会污染 IR 内容身份; +- 故意破坏引用、覆盖、CSR、事务、入口或能力合同会被拒绝; +- IR 中没有 callback、对象地址或任意 Python 对象; +- 默认 Python 仿真路径保持不变。 + +C-01 不等于已经拥有可运行的 C 后端。下一步 C-02 要冻结每个 kernel 的纯数值签名、隐藏缓存和错误码;C-03 要用扁平 Python 执行器逐槽 Shadow 对照;完成这两项后,才可以建立 C ABI、原生执行器并逐步把 capability 从 `reference_only` 提升为 `native`。 diff --git a/schemas/system-numeric-ir-v2.schema.json b/schemas/system-numeric-ir-v2.schema.json new file mode 100644 index 0000000..1cf2c38 --- /dev/null +++ b/schemas/system-numeric-ir-v2.schema.json @@ -0,0 +1,71 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://systemsimulationapp.local/schema/system-numeric-ir-v2.schema.json", + "title": "System Numeric IR v2", + "description": "Schema for the callback-free canonical JSON emitted by app.simulation.ir.schema.SystemIR.canonical_json_bytes().", + "$ref": "#/$defs/system_ir", + "$defs": { + "float64": {"type":"object","additionalProperties":false,"required":["$float64"],"properties":{"$float64":{"type":"string","pattern":"^(?!8000000000000000$)(?!(?:7ff|fff)[0-9a-f]{13}$)[0-9a-f]{16}$"}}}, + "str": {"type":"string"}, + "int": {"type":"integer"}, + "int32": {"type":"integer","minimum":-2147483648,"maximum":2147483647}, + "nonnegative_int": {"type":"integer","minimum":0}, + "positive_int": {"type":"integer","minimum":1}, + "bool": {"type":"boolean"}, + "float_or_null": {"anyOf":[{"$ref":"#/$defs/float64"},{"type":"null"}]}, + "str_or_null": {"anyOf":[{"type":"string"},{"type":"null"}]}, + "slot_ref": {"type":"object","additionalProperties":false,"required":["$type","buffer","index"],"properties":{"$type":{"const":"slot_ref"},"buffer":{"type":"string","enum":["time","state_input","derivative_output","local_state","local_derivative","algebraic","signal","parameter","constant","mode","work_float","work_int","event_output","jacobian_value","result_output","runtime_input"]},"index":{"$ref":"#/$defs/nonnegative_int"}}}, + "schema_version": {"type":"object","additionalProperties":false,"required":["$type","schema_id","major","minor"],"properties":{"$type":{"const":"schema_version"},"schema_id":{"const":"system-numeric-ir"},"major":{"const":2},"minor":{"const":0}}}, + "buffer": {"type":"object","additionalProperties":false,"required":["$type","kind","dtype","size","initial_float_values","initial_int_values"],"properties":{"$type":{"const":"buffer"},"kind":{"type":"string","enum":["time","state_input","derivative_output","local_state","local_derivative","algebraic","signal","parameter","constant","mode","work_float","work_int","event_output","jacobian_value","result_output","runtime_input"]},"dtype":{"type":"string","enum":["float64","int32"]},"size":{"$ref":"#/$defs/nonnegative_int"},"initial_float_values":{"type":"array","items":{"$ref":"#/$defs/float64"}},"initial_int_values":{"type":"array","items":{"$ref":"#/$defs/int32"}}}}, + "value": {"type":"object","additionalProperties":false,"required":["$type","value_id","slot","semantic","role","quantity","unit","scale","lower_bound","upper_bound","owner_component_index"],"properties":{"$type":{"const":"value"},"value_id":{"type":"string"},"slot":{"$ref":"#/$defs/slot_ref"},"semantic":{"type":"string"},"role":{"type":"string"},"quantity":{"type":"string"},"unit":{"type":"string"},"scale":{"$ref":"#/$defs/float64"},"lower_bound":{"$ref":"#/$defs/float_or_null"},"upper_bound":{"$ref":"#/$defs/float_or_null"},"owner_component_index":{"anyOf":[{"$ref":"#/$defs/nonnegative_int"},{"type":"null"}]} }}, + "kernel_phase": {"type":"object","additionalProperties":false,"required":["$type","phase"],"properties":{"$type":{"const":"kernel_phase"},"phase":{"type":"string","enum":["primal","residual","derivative","property","event","reset","jacobian","output"]}}}, + "kernel": {"type":"object","additionalProperties":false,"required":["$type","kernel_id","model_type","model_version","implementation_version","availability","unavailable_reason","phases","parameter_count","state_count","mode_count","workspace_float_count","workspace_int_count"],"properties":{"$type":{"const":"kernel"},"kernel_id":{"type":"string"},"model_type":{"type":"string"},"model_version":{"type":"string"},"implementation_version":{"type":"string"},"availability":{"type":"string","enum":["native","reference_only"]},"unavailable_reason":{"$ref":"#/$defs/str_or_null"},"phases":{"type":"array","items":{"$ref":"#/$defs/kernel_phase"}},"parameter_count":{"$ref":"#/$defs/nonnegative_int"},"state_count":{"$ref":"#/$defs/nonnegative_int"},"mode_count":{"$ref":"#/$defs/nonnegative_int"},"workspace_float_count":{"$ref":"#/$defs/nonnegative_int"},"workspace_int_count":{"$ref":"#/$defs/nonnegative_int"}}}, + "component": {"type":"object","additionalProperties":false,"required":["$type","instance_id","kernel_index","parameter_slots","state_slots","derivative_slots","mode_slots","port_indices","port_slots","output_indices","workspace_float_slots","workspace_int_slots"],"properties":{"$type":{"const":"component"},"instance_id":{"type":"string"},"kernel_index":{"$ref":"#/$defs/nonnegative_int"},"parameter_slots":{"type":"array","items":{"$ref":"#/$defs/slot_ref"}},"state_slots":{"type":"array","items":{"$ref":"#/$defs/slot_ref"}},"derivative_slots":{"type":"array","items":{"$ref":"#/$defs/slot_ref"}},"mode_slots":{"type":"array","items":{"$ref":"#/$defs/slot_ref"}},"port_indices":{"type":"array","items":{"$ref":"#/$defs/nonnegative_int"}},"port_slots":{"type":"array","items":{"$ref":"#/$defs/slot_ref"}},"output_indices":{"type":"array","items":{"$ref":"#/$defs/nonnegative_int"}},"workspace_float_slots":{"type":"array","items":{"$ref":"#/$defs/slot_ref"}},"workspace_int_slots":{"type":"array","items":{"$ref":"#/$defs/slot_ref"}}}}, + "port_variable": {"type":"object","additionalProperties":false,"required":["$type","variable_id","name","role","connection_rule","quantity","unit","result_visible","slot"],"properties":{"$type":{"const":"port_variable"},"variable_id":{"type":"string"},"name":{"type":"string"},"role":{"type":"string","enum":["effort","flow","stream","signal"]},"connection_rule":{"type":"string","enum":["equal","sumToZero","streamMix","directed"]},"quantity":{"type":"string"},"unit":{"type":"string"},"result_visible":{"type":"boolean"},"slot":{"$ref":"#/$defs/slot_ref"}}}, + "port": {"type":"object","additionalProperties":false,"required":["$type","port_id","component_index","name","kind","domain","nominal_role","positive_flow_direction","variables"],"properties":{"$type":{"const":"port"},"port_id":{"type":"string"},"component_index":{"$ref":"#/$defs/nonnegative_int"},"name":{"type":"string"},"kind":{"type":"string","enum":["physical","signal"]},"domain":{"type":"string"},"nominal_role":{"type":"string","enum":["inlet","outlet","bidirectional","input","output"]},"positive_flow_direction":{"anyOf":[{"const":"intoComponent"},{"type":"null"}]},"variables":{"type":"array","items":{"$ref":"#/$defs/port_variable"}}}}, + "connection_variable": {"type":"object","additionalProperties":false,"required":["$type","name","rule","endpoint_a_slot","endpoint_b_slot"],"properties":{"$type":{"const":"connection_variable"},"name":{"type":"string"},"rule":{"type":"string","enum":["equal","sumToZero","streamMix","directed"]},"endpoint_a_slot":{"$ref":"#/$defs/slot_ref"},"endpoint_b_slot":{"$ref":"#/$defs/slot_ref"}}}, + "connection": {"type":"object","additionalProperties":false,"required":["$type","connection_id","kind","domain","endpoint_a_port_index","endpoint_b_port_index","variables"],"properties":{"$type":{"const":"connection"},"connection_id":{"type":"string"},"kind":{"type":"string","enum":["physical","signal"]},"domain":{"type":"string"},"endpoint_a_port_index":{"$ref":"#/$defs/nonnegative_int"},"endpoint_b_port_index":{"$ref":"#/$defs/nonnegative_int"},"variables":{"type":"array","items":{"$ref":"#/$defs/connection_variable"}}}}, + "medium": {"type":"object","additionalProperties":false,"required":["$type","medium_id","name","implementation_id","implementation_version","parameter_slots","component_indices"],"properties":{"$type":{"const":"medium"},"medium_id":{"type":"string"},"name":{"type":"string"},"implementation_id":{"type":"string"},"implementation_version":{"type":"string"},"parameter_slots":{"type":"array","items":{"$ref":"#/$defs/slot_ref"}},"component_indices":{"type":"array","items":{"$ref":"#/$defs/nonnegative_int"}}}}, + "csr_pattern": {"type":"object","additionalProperties":false,"required":["$type","row_count","column_count","row_pointers","column_indices"],"properties":{"$type":{"const":"csr_pattern"},"row_count":{"$ref":"#/$defs/nonnegative_int"},"column_count":{"$ref":"#/$defs/nonnegative_int"},"row_pointers":{"type":"array","items":{"$ref":"#/$defs/nonnegative_int"}},"column_indices":{"type":"array","items":{"$ref":"#/$defs/nonnegative_int"}}}}, + "csr_matrix": {"type":"object","additionalProperties":false,"required":["$type","pattern","values"],"properties":{"$type":{"const":"csr_matrix"},"pattern":{"$ref":"#/$defs/csr_pattern"},"values":{"type":"array","items":{"$ref":"#/$defs/float64"}}}}, + "state_reducer": {"type":"object","additionalProperties":false,"required":["$type","solver_state_count","local_state_slots","raw_derivative_slots","state_scatter","derivative_gather","initial_state","absolute_tolerances"],"properties":{"$type":{"const":"state_reducer"},"solver_state_count":{"$ref":"#/$defs/nonnegative_int"},"local_state_slots":{"type":"array","items":{"$ref":"#/$defs/slot_ref"}},"raw_derivative_slots":{"type":"array","items":{"$ref":"#/$defs/slot_ref"}},"state_scatter":{"$ref":"#/$defs/csr_matrix"},"derivative_gather":{"$ref":"#/$defs/csr_matrix"},"initial_state":{"type":"array","items":{"$ref":"#/$defs/float64"}},"absolute_tolerances":{"type":"array","items":{"$ref":"#/$defs/float64"}}}}, + "operation": {"oneOf":[{"$ref":"#/$defs/fill"},{"$ref":"#/$defs/copy"},{"$ref":"#/$defs/scatter"},{"$ref":"#/$defs/linear_combination"},{"$ref":"#/$defs/state_map"},{"$ref":"#/$defs/kernel_call"},{"$ref":"#/$defs/effort_broadcast"},{"$ref":"#/$defs/flow_assign"},{"$ref":"#/$defs/check_finite"}]}, + "fill": {"type":"object","additionalProperties":false,"required":["$type","opcode","target_slots","value"],"properties":{"$type":{"const":"operation"},"opcode":{"const":"fill"},"target_slots":{"type":"array","items":{"$ref":"#/$defs/slot_ref"}},"value":{"$ref":"#/$defs/float64"}}}, + "copy": {"type":"object","additionalProperties":false,"required":["$type","opcode","source_slot","target_slot"],"properties":{"$type":{"const":"operation"},"opcode":{"const":"copy"},"source_slot":{"$ref":"#/$defs/slot_ref"},"target_slot":{"$ref":"#/$defs/slot_ref"}}}, + "scatter": {"type":"object","additionalProperties":false,"required":["$type","opcode","source_slot","target_slots"],"properties":{"$type":{"const":"operation"},"opcode":{"const":"scatter"},"source_slot":{"$ref":"#/$defs/slot_ref"},"target_slots":{"type":"array","items":{"$ref":"#/$defs/slot_ref"}}}}, + "linear_combination": {"type":"object","additionalProperties":false,"required":["$type","opcode","source_slots","weights","target_slot","bias"],"properties":{"$type":{"const":"operation"},"opcode":{"const":"linear_combination"},"source_slots":{"type":"array","items":{"$ref":"#/$defs/slot_ref"}},"weights":{"type":"array","items":{"$ref":"#/$defs/float64"}},"target_slot":{"$ref":"#/$defs/slot_ref"},"bias":{"$ref":"#/$defs/float64"}}}, + "state_map": {"type":"object","additionalProperties":false,"required":["$type","opcode","map_kind","source_slots","target_slots"],"properties":{"$type":{"const":"operation"},"opcode":{"const":"state_map"},"map_kind":{"type":"string","enum":["scatter","derivative_gather"]},"source_slots":{"type":"array","items":{"$ref":"#/$defs/slot_ref"}},"target_slots":{"type":"array","items":{"$ref":"#/$defs/slot_ref"}}}}, + "kernel_call": {"type":"object","additionalProperties":false,"required":["$type","opcode","kernel_index","component_index","phase","read_slots","write_slots","equation_indices"],"properties":{"$type":{"const":"operation"},"opcode":{"const":"kernel_call"},"kernel_index":{"$ref":"#/$defs/nonnegative_int"},"component_index":{"anyOf":[{"$ref":"#/$defs/nonnegative_int"},{"type":"null"}]},"phase":{"type":"string","enum":["primal","residual","derivative","property","event","reset","jacobian","output"]},"read_slots":{"type":"array","items":{"$ref":"#/$defs/slot_ref"}},"write_slots":{"type":"array","items":{"$ref":"#/$defs/slot_ref"}},"equation_indices":{"type":"array","items":{"$ref":"#/$defs/nonnegative_int"}}}}, + "effort_broadcast": {"type":"object","additionalProperties":false,"required":["$type","opcode","variable","anchor_slot","residual_slot","result_slot","scatter_slots","equation_id","lower_bound","upper_bound"],"properties":{"$type":{"const":"operation"},"opcode":{"const":"effort_broadcast"},"variable":{"type":"string"},"anchor_slot":{"$ref":"#/$defs/slot_ref"},"residual_slot":{"$ref":"#/$defs/slot_ref"},"result_slot":{"$ref":"#/$defs/slot_ref"},"scatter_slots":{"type":"array","items":{"$ref":"#/$defs/slot_ref"}},"equation_id":{"type":"string"},"lower_bound":{"$ref":"#/$defs/float_or_null"},"upper_bound":{"$ref":"#/$defs/float_or_null"}}}, + "flow_assign": {"type":"object","additionalProperties":false,"required":["$type","opcode","value_slot","result_slot","scatter_slots","equation_id"],"properties":{"$type":{"const":"operation"},"opcode":{"const":"flow_assign"},"value_slot":{"$ref":"#/$defs/slot_ref"},"result_slot":{"$ref":"#/$defs/slot_ref"},"scatter_slots":{"type":"array","items":{"$ref":"#/$defs/slot_ref"}},"equation_id":{"type":"string"}}}, + "check_finite": {"type":"object","additionalProperties":false,"required":["$type","opcode","slots","error_code"],"properties":{"$type":{"const":"operation"},"opcode":{"const":"check_finite"},"slots":{"type":"array","items":{"$ref":"#/$defs/slot_ref"}},"error_code":{"type":"string"}}}, + "stage": {"type":"object","additionalProperties":false,"required":["$type","stage_id","kind","operations","declared_read_slots","declared_write_slots"],"properties":{"$type":{"const":"stage"},"stage_id":{"type":"string"},"kind":{"type":"string","enum":["state_reduce","signal","mechanical_equivalence","dynamic_volume","property","pressure_flow","stream","temperature_reference","thermofluid_fixed_point","mechanical_acceleration","derivative_reduce","event","jacobian","output","reset"]},"operations":{"type":"array","items":{"$ref":"#/$defs/operation"}},"declared_read_slots":{"type":"array","items":{"$ref":"#/$defs/slot_ref"}},"declared_write_slots":{"type":"array","items":{"$ref":"#/$defs/slot_ref"}}}}, + "step_ref": {"type":"object","additionalProperties":false,"required":["$type","kind","index"],"properties":{"$type":{"const":"step_ref"},"kind":{"type":"string","enum":["stage","block"]},"index":{"$ref":"#/$defs/nonnegative_int"}}}, + "convergence": {"type":"object","additionalProperties":false,"required":["$type","monitor_slots","absolute_tolerance","relative_tolerance","max_iterations","relaxation","rollback_slots","failure_policy"],"properties":{"$type":{"const":"convergence"},"monitor_slots":{"type":"array","items":{"$ref":"#/$defs/slot_ref"}},"absolute_tolerance":{"$ref":"#/$defs/float64"},"relative_tolerance":{"$ref":"#/$defs/float64"},"max_iterations":{"$ref":"#/$defs/positive_int"},"relaxation":{"$ref":"#/$defs/float64"},"rollback_slots":{"type":"array","items":{"$ref":"#/$defs/slot_ref"}},"failure_policy":{"type":"string","enum":["fail","retry_smaller_step"]}}}, + "block": {"type":"object","additionalProperties":false,"required":["$type","block_id","kind","steps","convergence"],"properties":{"$type":{"const":"block"},"block_id":{"type":"string"},"kind":{"type":"string","enum":["sequence","fixed_point","stream_scc"]},"steps":{"type":"array","items":{"$ref":"#/$defs/step_ref"}},"convergence":{"anyOf":[{"$ref":"#/$defs/convergence"},{"type":"null"}]} }}, + "entry_point": {"type":"object","additionalProperties":false,"required":["$type","kind","steps","input_slots","output_slots"],"properties":{"$type":{"const":"entry_point"},"kind":{"type":"string","enum":["rhs","events","jacobian","outputs"]},"steps":{"type":"array","items":{"$ref":"#/$defs/step_ref"}},"input_slots":{"type":"array","items":{"$ref":"#/$defs/slot_ref"}},"output_slots":{"type":"array","items":{"$ref":"#/$defs/slot_ref"}}}}, + "causal_effort_stage": {"type":"object","additionalProperties":false,"required":["$type","variable","stage_index"],"properties":{"$type":{"const":"causal_effort_stage"},"variable":{"type":"string"},"stage_index":{"$ref":"#/$defs/nonnegative_int"}}}, + "causal_plan": {"type":"object","additionalProperties":false,"required":["$type","plan_id","scope_component_indices","source_schema_version","source_structural_signature","fallback_reason","canonical_slots","compatibility_slots","reset_slots","external_effort_slots","effort_stages","flow_stage_indices"],"properties":{"$type":{"const":"causal_plan"},"plan_id":{"type":"string"},"scope_component_indices":{"type":"array","items":{"$ref":"#/$defs/nonnegative_int"}},"source_schema_version":{"$ref":"#/$defs/nonnegative_int"},"source_structural_signature":{"$ref":"#/$defs/str_or_null"},"fallback_reason":{"$ref":"#/$defs/str_or_null"},"canonical_slots":{"type":"array","items":{"$ref":"#/$defs/slot_ref"}},"compatibility_slots":{"type":"array","items":{"$ref":"#/$defs/slot_ref"}},"reset_slots":{"type":"array","items":{"$ref":"#/$defs/slot_ref"}},"external_effort_slots":{"type":"array","items":{"$ref":"#/$defs/slot_ref"}},"effort_stages":{"type":"array","items":{"$ref":"#/$defs/causal_effort_stage"}},"flow_stage_indices":{"type":"array","items":{"$ref":"#/$defs/nonnegative_int"}}}}, + "algebraic_unknown": {"type":"object","additionalProperties":false,"required":["$type","unknown_id","component_index","port_index","variable","role","slot","scale","lower_bound","upper_bound"],"properties":{"$type":{"const":"algebraic_unknown"},"unknown_id":{"type":"string"},"component_index":{"$ref":"#/$defs/nonnegative_int"},"port_index":{"$ref":"#/$defs/nonnegative_int"},"variable":{"type":"string"},"role":{"type":"string","enum":["effort","flow","stream","signal"]},"slot":{"$ref":"#/$defs/slot_ref"},"scale":{"$ref":"#/$defs/float64"},"lower_bound":{"$ref":"#/$defs/float_or_null"},"upper_bound":{"$ref":"#/$defs/float_or_null"}}}, + "pressure_flow_equation": {"type":"object","additionalProperties":false,"required":["$type","equation_id","owner","owner_index","relation","role","variable_slots","residual_slot","scale"],"properties":{"$type":{"const":"pressure_flow_equation"},"equation_id":{"type":"string"},"owner":{"type":"string","enum":["component","connection"]},"owner_index":{"$ref":"#/$defs/nonnegative_int"},"relation":{"type":"string","enum":["equal","sumToZero","constitutive","state"]},"role":{"anyOf":[{"type":"string","enum":["effort","flow","stream","signal"]},{"type":"null"}]},"variable_slots":{"type":"array","items":{"$ref":"#/$defs/slot_ref"}},"residual_slot":{"$ref":"#/$defs/slot_ref"},"scale":{"$ref":"#/$defs/float64"}}}, + "pressure_flow_block": {"type":"object","additionalProperties":false,"required":["$type","block_id","unknown_indices","equation_indices","jacobian_pattern"],"properties":{"$type":{"const":"pressure_flow_block"},"block_id":{"type":"string"},"unknown_indices":{"type":"array","items":{"$ref":"#/$defs/nonnegative_int"}},"equation_indices":{"type":"array","items":{"$ref":"#/$defs/nonnegative_int"}},"jacobian_pattern":{"$ref":"#/$defs/csr_pattern"}}}, + "pressure_flow_scope": {"type":"object","additionalProperties":false,"required":["$type","scope_id","kind","component_indices","unknown_indices","equation_indices","block_indices","causal_plan_index","residual_tolerance","max_evaluations","sparse_pattern_trusted","sparse_fallback_reason"],"properties":{"$type":{"const":"pressure_flow_scope"},"scope_id":{"type":"string"},"kind":{"type":"string","enum":["network","physical_island","equation_block"]},"component_indices":{"type":"array","items":{"$ref":"#/$defs/nonnegative_int"}},"unknown_indices":{"type":"array","items":{"$ref":"#/$defs/nonnegative_int"}},"equation_indices":{"type":"array","items":{"$ref":"#/$defs/nonnegative_int"}},"block_indices":{"type":"array","items":{"$ref":"#/$defs/nonnegative_int"}},"causal_plan_index":{"anyOf":[{"$ref":"#/$defs/nonnegative_int"},{"type":"null"}]},"residual_tolerance":{"$ref":"#/$defs/float64"},"max_evaluations":{"$ref":"#/$defs/positive_int"},"sparse_pattern_trusted":{"type":"boolean"},"sparse_fallback_reason":{"$ref":"#/$defs/str_or_null"}}}, + "pressure_flow_plan": {"type":"object","additionalProperties":false,"required":["$type","unknowns","equations","blocks","scopes","global_scope_index","secondary_scope_indices","pressure_lower_bound"],"properties":{"$type":{"const":"pressure_flow_plan"},"unknowns":{"type":"array","items":{"$ref":"#/$defs/algebraic_unknown"}},"equations":{"type":"array","items":{"$ref":"#/$defs/pressure_flow_equation"}},"blocks":{"type":"array","items":{"$ref":"#/$defs/pressure_flow_block"}},"scopes":{"type":"array","items":{"$ref":"#/$defs/pressure_flow_scope"}},"global_scope_index":{"$ref":"#/$defs/nonnegative_int"},"secondary_scope_indices":{"type":"array","items":{"$ref":"#/$defs/nonnegative_int"}},"pressure_lower_bound":{"$ref":"#/$defs/float64"}}}, + "stream_scc": {"type":"object","additionalProperties":false,"required":["$type","scc_id","node_slots","block_index"],"properties":{"$type":{"const":"stream_scc"},"scc_id":{"type":"string"},"node_slots":{"type":"array","items":{"$ref":"#/$defs/slot_ref"}},"block_index":{"$ref":"#/$defs/nonnegative_int"}}}, + "stream_edge": {"type":"object","additionalProperties":false,"required":["$type","source_scc_index","target_scc_index"],"properties":{"$type":{"const":"stream_edge"},"source_scc_index":{"$ref":"#/$defs/nonnegative_int"},"target_scc_index":{"$ref":"#/$defs/nonnegative_int"}}}, + "stream_plan": {"type":"object","additionalProperties":false,"required":["$type","plan_id","node_slots","strongly_connected_components","condensed_edges","topological_scc_indices"],"properties":{"$type":{"const":"stream_plan"},"plan_id":{"type":"string"},"node_slots":{"type":"array","items":{"$ref":"#/$defs/slot_ref"}},"strongly_connected_components":{"type":"array","items":{"$ref":"#/$defs/stream_scc"}},"condensed_edges":{"type":"array","items":{"$ref":"#/$defs/stream_edge"}},"topological_scc_indices":{"type":"array","items":{"$ref":"#/$defs/nonnegative_int"}}}}, + "thermofluid_plan": {"type":"object","additionalProperties":false,"required":["$type","physical_port_indices","global_component_indices","stream_plan_index","secondary_pressure_scope_indices","sensitive_component_indices","maximum_iterations","flow_relative_tolerance","uses_conservative_global_solver","conservative_fallback_reason"],"properties":{"$type":{"const":"thermofluid_plan"},"physical_port_indices":{"type":"array","items":{"$ref":"#/$defs/nonnegative_int"}},"global_component_indices":{"type":"array","items":{"$ref":"#/$defs/nonnegative_int"}},"stream_plan_index":{"$ref":"#/$defs/nonnegative_int"},"secondary_pressure_scope_indices":{"type":"array","items":{"$ref":"#/$defs/nonnegative_int"}},"sensitive_component_indices":{"type":"array","items":{"$ref":"#/$defs/nonnegative_int"}},"maximum_iterations":{"$ref":"#/$defs/positive_int"},"flow_relative_tolerance":{"$ref":"#/$defs/float64"},"uses_conservative_global_solver":{"type":"boolean"},"conservative_fallback_reason":{"$ref":"#/$defs/str_or_null"}}}, + "transaction_plan": {"type":"object","additionalProperties":false,"required":["$type","snapshot_slots","flow_slots","cache_component_indices","cache_attribute_ids","diagnostic_owner_ids","restores_on_recoverable_failure","restores_on_fatal_failure"],"properties":{"$type":{"const":"transaction_plan"},"snapshot_slots":{"type":"array","items":{"$ref":"#/$defs/slot_ref"}},"flow_slots":{"type":"array","items":{"$ref":"#/$defs/slot_ref"}},"cache_component_indices":{"type":"array","items":{"$ref":"#/$defs/nonnegative_int"}},"cache_attribute_ids":{"type":"array","items":{"type":"string"}},"diagnostic_owner_ids":{"type":"array","items":{"type":"string"}},"restores_on_recoverable_failure":{"type":"boolean"},"restores_on_fatal_failure":{"type":"boolean"}}}, + "mode_value": {"type":"object","additionalProperties":false,"required":["$type","value","name"],"properties":{"$type":{"const":"mode_value"},"value":{"$ref":"#/$defs/int32"},"name":{"type":"string"}}}, + "mode": {"type":"object","additionalProperties":false,"required":["$type","mode_id","slot","owner_component_indices","values","initial_value"],"properties":{"$type":{"const":"mode"},"mode_id":{"type":"string"},"slot":{"$ref":"#/$defs/slot_ref"},"owner_component_indices":{"type":"array","items":{"$ref":"#/$defs/nonnegative_int"}},"values":{"type":"array","items":{"$ref":"#/$defs/mode_value"}},"initial_value":{"$ref":"#/$defs/int32"}}}, + "finite_difference_column": {"type":"object","additionalProperties":false,"required":["$type","column_index","value_indices","relative_step"],"properties":{"$type":{"const":"finite_difference_column"},"column_index":{"$ref":"#/$defs/nonnegative_int"},"value_indices":{"type":"array","items":{"$ref":"#/$defs/nonnegative_int"}},"relative_step":{"$ref":"#/$defs/float64"}}}, + "jacobian_plan": {"type":"object","additionalProperties":false,"required":["$type","pattern","value_slots","color_groups","fill_steps","analytic_value_indices","local_finite_difference_columns"],"properties":{"$type":{"const":"jacobian_plan"},"pattern":{"$ref":"#/$defs/csr_pattern"},"value_slots":{"type":"array","items":{"$ref":"#/$defs/slot_ref"}},"color_groups":{"type":"array","items":{"type":"array","items":{"$ref":"#/$defs/nonnegative_int"}}},"fill_steps":{"type":"array","items":{"$ref":"#/$defs/step_ref"}},"analytic_value_indices":{"type":"array","items":{"$ref":"#/$defs/nonnegative_int"}},"local_finite_difference_columns":{"type":"array","items":{"$ref":"#/$defs/finite_difference_column"}}}}, + "mode_guard": {"type":"object","additionalProperties":false,"required":["$type","mode_slot","allowed_values"],"properties":{"$type":{"const":"mode_guard"},"mode_slot":{"$ref":"#/$defs/slot_ref"},"allowed_values":{"type":"array","items":{"$ref":"#/$defs/int32"}}}}, + "event": {"type":"object","additionalProperties":false,"required":["$type","event_id","event_kind","owner_component_indices","root_slot","direction","terminal","priority","mode_guards","reset_steps","invalidated_caches","restarts_integrator"],"properties":{"$type":{"const":"event"},"event_id":{"type":"string"},"event_kind":{"type":"string"},"owner_component_indices":{"type":"array","items":{"$ref":"#/$defs/nonnegative_int"}},"root_slot":{"$ref":"#/$defs/slot_ref"},"direction":{"type":"string","enum":["decreasing","any","increasing"]},"terminal":{"type":"boolean"},"priority":{"type":"integer"},"mode_guards":{"type":"array","items":{"$ref":"#/$defs/mode_guard"}},"reset_steps":{"type":"array","items":{"$ref":"#/$defs/step_ref"}},"invalidated_caches":{"type":"array","items":{"type":"string","enum":["property","pressure_flow","stream","jacobian","output"]}},"restarts_integrator":{"type":"boolean"}}}, + "output": {"type":"object","additionalProperties":false,"required":["$type","output_id","component_index","scope","port_name","name","label","category","quantity","unit","order","source_slot","output_slot","scale","offset"],"properties":{"$type":{"const":"output"},"output_id":{"type":"string"},"component_index":{"$ref":"#/$defs/nonnegative_int"},"scope":{"type":"string"},"port_name":{"$ref":"#/$defs/str_or_null"},"name":{"type":"string"},"label":{"type":"string"},"category":{"type":"string"},"quantity":{"type":"string"},"unit":{"type":"string"},"order":{"$ref":"#/$defs/nonnegative_int"},"source_slot":{"$ref":"#/$defs/slot_ref"},"output_slot":{"$ref":"#/$defs/slot_ref"},"scale":{"$ref":"#/$defs/float64"},"offset":{"$ref":"#/$defs/float64"}}}, + "component_capability": {"type":"object","additionalProperties":false,"required":["$type","component_index","level","supported_phases","missing_features"],"properties":{"$type":{"const":"component_capability"},"component_index":{"$ref":"#/$defs/nonnegative_int"},"level":{"type":"string","enum":["native","reference_only","unsupported"]},"supported_phases":{"type":"array","items":{"type":"string","enum":["primal","residual","derivative","property","event","reset","jacobian","output"]}},"missing_features":{"type":"array","items":{"type":"string"}}}}, + "capability_issue": {"type":"object","additionalProperties":false,"required":["$type","code","severity","scope_id","message"],"properties":{"$type":{"const":"capability_issue"},"code":{"type":"string"},"severity":{"type":"string","enum":["info","warning","error"]},"scope_id":{"type":"string"},"message":{"type":"string"}}}, + "capability_report": {"type":"object","additionalProperties":false,"required":["$type","system_level","components","issues"],"properties":{"$type":{"const":"capability_report"},"system_level":{"type":"string","enum":["native","reference_only","unsupported"]},"components":{"type":"array","items":{"$ref":"#/$defs/component_capability"}},"issues":{"type":"array","items":{"$ref":"#/$defs/capability_issue"}}}}, + "system_ir": {"type":"object","additionalProperties":false,"required":["$type","version","model_id","model_version","compiler_id","compiler_version","numeric_dtype","buffers","values","kernels","components","mediums","ports","connections","state_reducer","causal_plans","pressure_flow","stream_plans","thermofluid","stages","blocks","entry_points","transaction","modes","jacobian","events","outputs","capabilities","required_features"],"properties":{"$type":{"const":"system_ir"},"version":{"$ref":"#/$defs/schema_version"},"model_id":{"type":"string"},"model_version":{"type":"string"},"compiler_id":{"type":"string"},"compiler_version":{"type":"string"},"numeric_dtype":{"const":"float64"},"buffers":{"type":"array","items":{"$ref":"#/$defs/buffer"}},"values":{"type":"array","items":{"$ref":"#/$defs/value"}},"kernels":{"type":"array","items":{"$ref":"#/$defs/kernel"}},"components":{"type":"array","items":{"$ref":"#/$defs/component"}},"mediums":{"type":"array","items":{"$ref":"#/$defs/medium"}},"ports":{"type":"array","items":{"$ref":"#/$defs/port"}},"connections":{"type":"array","items":{"$ref":"#/$defs/connection"}},"state_reducer":{"$ref":"#/$defs/state_reducer"},"causal_plans":{"type":"array","items":{"$ref":"#/$defs/causal_plan"}},"pressure_flow":{"$ref":"#/$defs/pressure_flow_plan"},"stream_plans":{"type":"array","items":{"$ref":"#/$defs/stream_plan"}},"thermofluid":{"$ref":"#/$defs/thermofluid_plan"},"stages":{"type":"array","items":{"$ref":"#/$defs/stage"}},"blocks":{"type":"array","items":{"$ref":"#/$defs/block"}},"entry_points":{"type":"array","items":{"$ref":"#/$defs/entry_point"}},"transaction":{"$ref":"#/$defs/transaction_plan"},"modes":{"type":"array","items":{"$ref":"#/$defs/mode"}},"jacobian":{"$ref":"#/$defs/jacobian_plan"},"events":{"type":"array","items":{"$ref":"#/$defs/event"}},"outputs":{"type":"array","items":{"$ref":"#/$defs/output"}},"capabilities":{"$ref":"#/$defs/capability_report"},"required_features":{"type":"array","items":{"type":"string"}}}} + } +} diff --git a/tests/test_system_numeric_ir_v2.py b/tests/test_system_numeric_ir_v2.py new file mode 100644 index 0000000..111d15f --- /dev/null +++ b/tests/test_system_numeric_ir_v2.py @@ -0,0 +1,767 @@ +from __future__ import annotations + +from dataclasses import fields, is_dataclass, replace +import json +from pathlib import Path +import unittest + +from app.main import compile_reactflow_network, compile_system_xml_network +from app.simulation.ir import schema as ir_schema +from app.simulation.ir import ( + IRBufferKind, + IRCapabilityLevel, + IRDType, + IREntryPointKind, + IRKernelAvailability, + IRKernelCallOperation, + IRNativeBuildIdentity, + IRStageKind, + IRStepKind, + canonical_json_bytes, + compile_system_ir, + native_artifact_key, +) +from app.simulation.ir.validation import ( + SystemIRValidationError, + require_valid_system_ir, + validate_system_ir, +) +from app.simulation.systems.generic import GenericFluidSystem +from app.system_xml import validate_system_xml_document +from tests.test_amesim_mechanical_xml import zero_force_mass_project + + +TARGET_XML = Path("tests/data/test-mql-8.xml") +HISTORICAL_XML = Path("tests/data/test_mql-full-branches-01-04.xml") +MACHINE_SCHEMA = Path("schemas/system-numeric-ir-v2.schema.json") + + +def _system_from_xml(path: Path) -> GenericFluidSystem: + report = validate_system_xml_document(path.read_bytes()) + if not report.valid or report.document is None: + raise AssertionError(report.as_dict()) + return GenericFluidSystem(compile_system_xml_network(report.document)) + + +def _entry_stage_kinds(program, entry_kind: IREntryPointKind) -> set[IRStageKind]: + entry = next(item for item in program.entry_points if item.kind is entry_kind) + result: set[IRStageKind] = set() + visited_blocks: set[int] = set() + + def visit(step) -> None: + if step.kind is IRStepKind.STAGE: + result.add(program.stages[step.index].kind) + return + if step.index in visited_blocks: + return + visited_blocks.add(step.index) + for nested in program.blocks[step.index].steps: + visit(nested) + + for step in entry.steps: + visit(step) + return result + + +def _assert_callback_free(test: unittest.TestCase, value: object) -> None: + if is_dataclass(value) and not isinstance(value, type): + for item in fields(value): + _assert_callback_free(test, getattr(value, item.name)) + return + if isinstance(value, tuple): + for item in value: + _assert_callback_free(test, item) + return + test.assertFalse(callable(value), type(value).__name__) + test.assertNotIsInstance(value, (dict, list, set)) + + +class SystemNumericIRV2ContractTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.system = GenericFluidSystem( + compile_reactflow_network(zero_force_mass_project()) + ) + cls.program = compile_system_ir(cls.system) + + def test_compiler_returns_a_statically_valid_callback_free_program(self) -> None: + report = validate_system_ir(self.program) + + self.assertTrue(report.valid, report.issues) + self.assertIs(require_valid_system_ir(self.program), self.program) + _assert_callback_free(self, self.program) + with self.assertRaises(TypeError): + canonical_json_bytes(lambda: None) + + def test_canonical_bytes_and_signature_are_deterministic(self) -> None: + second = compile_system_ir( + GenericFluidSystem( + compile_reactflow_network(zero_force_mass_project()) + ) + ) + + self.assertEqual( + self.program.canonical_json_bytes(), + second.canonical_json_bytes(), + ) + self.assertEqual( + self.program.structural_signature, + second.structural_signature, + ) + self.assertEqual(len(self.program.structural_signature), 64) + self.assertEqual( + self.program.structural_signature, + self.program.calculate_structural_signature(), + ) + + decomposed = replace(self.program, model_id="e\u0301") + composed = replace(self.program, model_id="é") + self.assertEqual( + decomposed.canonical_json_bytes(), + composed.canonical_json_bytes(), + ) + + def test_parameter_change_invalidates_the_program_signature(self) -> None: + changed_project = zero_force_mass_project() + changed_project.nodes[1].data.parameters["mass"] = 3.0 + changed = compile_system_ir( + GenericFluidSystem(compile_reactflow_network(changed_project)) + ) + + self.assertNotEqual( + self.program.structural_signature, + changed.structural_signature, + ) + + def test_native_artifact_key_is_separate_from_structural_signature(self) -> None: + windows = IRNativeBuildIdentity( + abi_version=1, + target_triple="x86_64-pc-windows-msvc", + compiler_id="msvc", + compiler_version="19.40", + compile_flags=("/O2", "/fp:precise"), + floating_point_policy="strict", + kernel_library_signature="a" * 64, + ) + linux = replace( + windows, + target_triple="x86_64-unknown-linux-gnu", + compiler_id="gcc", + compiler_version="14.2", + compile_flags=("-O2", "-fno-fast-math"), + ) + + signature = self.program.structural_signature + self.assertNotEqual( + native_artifact_key(self.program, windows), + native_artifact_key(self.program, linux), + ) + self.assertEqual(self.program.structural_signature, signature) + with self.assertRaises(ValueError): + native_artifact_key( + self.program, + replace(windows, abi_version=windows.abi_version + 1), + ) + + def test_four_entry_points_are_independent(self) -> None: + self.assertEqual( + {item.kind for item in self.program.entry_points}, + set(IREntryPointKind), + ) + rhs_kinds = _entry_stage_kinds(self.program, IREntryPointKind.RHS) + event_kinds = _entry_stage_kinds( + self.program, IREntryPointKind.EVENTS + ) + + self.assertIn(IRStageKind.DERIVATIVE_REDUCE, rhs_kinds) + self.assertNotIn(IRStageKind.EVENT, rhs_kinds) + self.assertNotIn(IRStageKind.JACOBIAN, rhs_kinds) + self.assertNotIn(IRStageKind.OUTPUT, rhs_kinds) + self.assertIn(IRStageKind.EVENT, event_kinds) + self.assertNotIn(IRStageKind.JACOBIAN, event_kinds) + self.assertNotIn(IRStageKind.OUTPUT, event_kinds) + + def test_validator_rejects_a_missing_entry_point(self) -> None: + broken = replace( + self.program, + entry_points=self.program.entry_points[:-1], + ) + + report = validate_system_ir(broken) + self.assertFalse(report.valid) + self.assertIn( + "ENTRY_POINT_SET_INVALID", + {item.code for item in report.issues}, + ) + with self.assertRaises(SystemIRValidationError): + require_valid_system_ir(broken) + + def test_validator_rejects_entry_contract_and_buffer_dtype_corruption(self) -> None: + rhs_index = next( + index + for index, entry in enumerate(self.program.entry_points) + if entry.kind is IREntryPointKind.RHS + ) + events_entry = next( + entry + for entry in self.program.entry_points + if entry.kind is IREntryPointKind.EVENTS + ) + corrupted_entries = list(self.program.entry_points) + corrupted_entries[rhs_index] = replace( + corrupted_entries[rhs_index], + steps=events_entry.steps, + output_slots=(), + ) + entry_report = validate_system_ir( + replace(self.program, entry_points=tuple(corrupted_entries)) + ) + self.assertTrue( + { + "ENTRY_POINT_OUTPUT_COVERAGE", + "ENTRY_POINT_FINAL_STAGE_MISSING", + "ENTRY_POINT_STAGE_FORBIDDEN", + }.issubset({item.code for item in entry_report.issues}) + ) + + state_buffer_index = next( + index + for index, buffer in enumerate(self.program.buffers) + if buffer.kind is IRBufferKind.STATE_INPUT + ) + corrupted_buffers = list(self.program.buffers) + corrupted_buffers[state_buffer_index] = replace( + corrupted_buffers[state_buffer_index], + dtype=IRDType.INT32, + initial_float_values=(), + initial_int_values=tuple( + 0 for _ in range(corrupted_buffers[state_buffer_index].size) + ), + ) + dtype_report = validate_system_ir( + replace(self.program, buffers=tuple(corrupted_buffers)) + ) + self.assertIn( + "BUFFER_DTYPE_INVALID", + {item.code for item in dtype_report.issues}, + ) + + def test_validator_rejects_a_component_call_bound_to_another_kernel(self) -> None: + stage_index, operation_index, operation = next( + (stage_index, operation_index, operation) + for stage_index, stage in enumerate(self.program.stages) + for operation_index, operation in enumerate(stage.operations) + if isinstance(operation, IRKernelCallOperation) + and operation.component_index is not None + ) + wrong_kernel_index = next( + index + for index in range(len(self.program.kernels)) + if index != operation.kernel_index + ) + broken_operations = list(self.program.stages[stage_index].operations) + broken_operations[operation_index] = replace( + operation, + kernel_index=wrong_kernel_index, + ) + broken_stages = list(self.program.stages) + broken_stages[stage_index] = replace( + broken_stages[stage_index], + operations=tuple(broken_operations), + ) + + report = validate_system_ir( + replace(self.program, stages=tuple(broken_stages)) + ) + + self.assertFalse(report.valid) + self.assertIn( + "KERNEL_COMPONENT_MISMATCH", + {item.code for item in report.issues}, + ) + + def test_validator_rejects_native_component_with_missing_called_phases(self) -> None: + native_kernels = tuple( + replace( + kernel, + availability=IRKernelAvailability.NATIVE, + unavailable_reason=None, + ) + for kernel in self.program.kernels + ) + native_capabilities = tuple( + replace( + capability, + level=IRCapabilityLevel.NATIVE, + supported_phases=(), + missing_features=(), + ) + for capability in self.program.capabilities.components + ) + broken = replace( + self.program, + kernels=native_kernels, + required_features=tuple( + feature + for feature in self.program.required_features + if feature != "reference_kernel_dispatch" + ), + transaction=replace( + self.program.transaction, + cache_attribute_ids=(), + ), + capabilities=replace( + self.program.capabilities, + system_level=IRCapabilityLevel.NATIVE, + components=native_capabilities, + issues=(), + ), + ) + + report = validate_system_ir(broken) + + self.assertFalse(report.valid) + self.assertIn( + "CAPABILITY_NATIVE_PHASE_MISSING", + {item.code for item in report.issues}, + ) + + def test_validator_rejects_runtime_types_that_break_the_wire_schema(self) -> None: + outputs = list(self.program.outputs) + outputs[0] = replace(outputs[0], scale=1) + + report = validate_system_ir( + replace(self.program, outputs=tuple(outputs)) + ) + + self.assertFalse(report.valid) + self.assertIn( + "RUNTIME_TYPE_MISMATCH", + {item.code for item in report.issues}, + ) + + def test_validator_propagates_unsupported_component_to_system_level(self) -> None: + capabilities = list(self.program.capabilities.components) + capabilities[0] = replace( + capabilities[0], + level=IRCapabilityLevel.UNSUPPORTED, + ) + + report = validate_system_ir( + replace( + self.program, + capabilities=replace( + self.program.capabilities, + components=tuple(capabilities), + ), + ) + ) + + self.assertFalse(report.valid) + self.assertIn( + "CAPABILITY_LEVEL_CONFLICT", + {item.code for item in report.issues}, + ) + + def test_machine_schema_has_no_dangling_local_references(self) -> None: + schema = json.loads(MACHINE_SCHEMA.read_text(encoding="utf-8")) + definitions = schema["$defs"] + references: list[str] = [] + pending: list[object] = [schema] + while pending: + current = pending.pop() + if isinstance(current, dict): + references.extend( + value + for key, value in current.items() + if key == "$ref" and isinstance(value, str) + ) + pending.extend(current.values()) + elif isinstance(current, list): + pending.extend(current) + + self.assertFalse( + { + reference + for reference in references + if reference.startswith("#/$defs/") + and reference.removeprefix("#/$defs/") not in definitions + } + ) + payload = json.loads(self.program.canonical_json_bytes()) + self.assertEqual(payload["$type"], "system_ir") + self.assertEqual( + set(payload), + set(definitions["system_ir"]["required"]), + ) + self.assertEqual( + definitions["kernel_phase"]["required"], + ["$type", "phase"], + ) + + def test_machine_schema_fields_match_every_serialized_dataclass(self) -> None: + definitions = json.loads( + MACHINE_SCHEMA.read_text(encoding="utf-8") + )["$defs"] + skipped_types = {"native_build", "native_artifact_key_input"} + + for value_type, canonical_type in ir_schema._CANONICAL_TYPE_NAMES: + if canonical_type in skipped_types: + continue + definition_name = ( + value_type.opcode.value + if canonical_type == "operation" + else canonical_type + ) + definition = definitions[definition_name] + expected_fields = {"$type", *(item.name for item in fields(value_type))} + if canonical_type == "operation": + expected_fields.add("opcode") + + self.assertEqual( + set(definition["required"]), + expected_fields, + definition_name, + ) + self.assertEqual( + set(definition["properties"]), + expected_fields, + definition_name, + ) + self.assertFalse( + definition["additionalProperties"], + definition_name, + ) + + +class TargetSystemNumericIRV2Tests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.system = _system_from_xml(TARGET_XML) + cls.program = compile_system_ir(cls.system) + + def test_target_model_is_fully_described(self) -> None: + program = self.program + + self.assertEqual(len(program.components), 156) + self.assertEqual(len(program.ports), 356) + self.assertEqual(len(program.connections), 178) + self.assertEqual(program.state_reducer.solver_state_count, 132) + self.assertEqual(len(program.pressure_flow.unknowns), 776) + self.assertEqual(len(program.pressure_flow.equations), 776) + self.assertEqual(len(program.outputs), 1784) + self.assertEqual(program.jacobian.pattern.row_count, 132) + self.assertEqual(program.jacobian.pattern.column_count, 132) + self.assertGreater(program.jacobian.pattern.nonzero_count, 132) + self.assertLessEqual(len(program.jacobian.color_groups), 132) + + self.assertEqual(len(program.causal_plans), 1) + causal = program.causal_plans[0] + self.assertEqual(len(causal.canonical_slots), 452) + self.assertEqual(len(causal.compatibility_slots), 776) + self.assertIsNone(causal.fallback_reason) + + self.assertEqual(len(program.modes), 2) + self.assertEqual(len(program.events), 14) + self.assertTrue(program.thermofluid.sensitive_component_indices) + self.assertEqual( + program.thermofluid.secondary_pressure_scope_indices, + program.pressure_flow.secondary_scope_indices, + ) + + def test_state_reducer_and_all_buffers_have_complete_index_contracts(self) -> None: + reducer = self.program.state_reducer + self.assertEqual( + (reducer.state_scatter.pattern.row_count, + reducer.state_scatter.pattern.column_count), + (len(reducer.local_state_slots), reducer.solver_state_count), + ) + self.assertEqual( + (reducer.derivative_gather.pattern.row_count, + reducer.derivative_gather.pattern.column_count), + (reducer.solver_state_count, len(reducer.raw_derivative_slots)), + ) + + by_buffer: dict[IRBufferKind, list[int]] = { + buffer.kind: [] for buffer in self.program.buffers + } + for value in self.program.values: + by_buffer[value.slot.buffer].append(value.slot.index) + for buffer in self.program.buffers: + self.assertEqual( + sorted(by_buffer[buffer.kind]), + list(range(buffer.size)), + buffer.kind, + ) + + def test_transaction_tracks_exactly_the_active_pneumatic_flows(self) -> None: + pneumatic_flow_slots = { + variable.slot + for port in self.program.ports + if port.kind.value == "physical" and port.domain == "pneumatic" + for variable in port.variables + if variable.name == "m_flow" + } + + self.assertEqual( + set(self.program.transaction.flow_slots), + pneumatic_flow_slots, + ) + + def test_native_support_is_not_claimed_before_c02(self) -> None: + self.assertIs( + self.program.capabilities.system_level, + IRCapabilityLevel.REFERENCE_ONLY, + ) + self.assertTrue(self.program.capabilities.components) + self.assertTrue( + all( + item.level is IRCapabilityLevel.REFERENCE_ONLY + for item in self.program.capabilities.components + ) + ) + self.assertIn( + "IR_NATIVE_KERNELS_NOT_DECLARED", + {item.code for item in self.program.capabilities.issues}, + ) + + def test_validator_rejects_cross_plan_and_sparse_contract_corruption(self) -> None: + program = self.program + variants: list[tuple[str, object, str]] = [] + + components = list(program.components) + foreign_port = next( + index + for index, port in enumerate(program.ports) + if port.component_index != 0 + ) + components[0] = replace( + components[0], + port_indices=(*components[0].port_indices, foreign_port), + ) + variants.append( + ( + "component port back-reference", + replace(program, components=tuple(components)), + "COMPONENT_PORT_COVERAGE", + ) + ) + + variants.append( + ( + "thermofluid scope mismatch", + replace( + program, + thermofluid=replace( + program.thermofluid, + secondary_pressure_scope_indices=(), + ), + ), + "THERMOFLUID_SCOPE_MISMATCH", + ) + ) + + equations = list(program.pressure_flow.equations) + equations[1] = replace( + equations[1], + residual_slot=equations[0].residual_slot, + ) + variants.append( + ( + "pressure-flow residual slot alias", + replace( + program, + pressure_flow=replace( + program.pressure_flow, + equations=tuple(equations), + ), + ), + "PRESSURE_FLOW_RESIDUAL_SLOT_DUPLICATE", + ) + ) + + buffers = list(program.buffers) + state_buffer_index = next( + index + for index, buffer in enumerate(buffers) + if buffer.kind is IRBufferKind.STATE_INPUT + ) + state_values = list(buffers[state_buffer_index].initial_float_values) + state_values[0] += 1.0 + buffers[state_buffer_index] = replace( + buffers[state_buffer_index], + initial_float_values=tuple(state_values), + ) + variants.append( + ( + "state initial value disagreement", + replace(program, buffers=tuple(buffers)), + "STATE_INITIAL_VALUE_MISMATCH", + ) + ) + + aliased_values = ( + program.jacobian.value_slots[0], + program.jacobian.value_slots[0], + *program.jacobian.value_slots[2:], + ) + variants.append( + ( + "Jacobian value slot alias", + replace( + program, + jacobian=replace( + program.jacobian, + value_slots=aliased_values, + ), + ), + "JACOBIAN_VALUE_SLOT_COVERAGE", + ) + ) + + variants.append( + ( + "Jacobian color conflict", + replace( + program, + jacobian=replace( + program.jacobian, + color_groups=( + tuple(range(program.state_reducer.solver_state_count)), + ), + ), + ), + "JACOBIAN_COLOR_CONFLICT", + ) + ) + + fd_columns = list(program.jacobian.local_finite_difference_columns) + fd_column = fd_columns[0] + wrong_value_index = next( + index + for index, column in enumerate( + program.jacobian.pattern.column_indices + ) + if column != fd_column.column_index + ) + fd_columns[0] = replace( + fd_column, + value_indices=(wrong_value_index, *fd_column.value_indices[1:]), + ) + variants.append( + ( + "Jacobian finite-difference column mismatch", + replace( + program, + jacobian=replace( + program.jacobian, + local_finite_difference_columns=tuple(fd_columns), + ), + ), + "JACOBIAN_FD_COLUMN_MISMATCH", + ) + ) + + capabilities = list(program.capabilities.components) + capabilities[0] = replace( + capabilities[0], + level=IRCapabilityLevel.NATIVE, + missing_features=(), + ) + variants.append( + ( + "native capability overclaim", + replace( + program, + capabilities=replace( + program.capabilities, + components=tuple(capabilities), + ), + ), + "CAPABILITY_KERNEL_MISMATCH", + ) + ) + + variants.append( + ( + "duplicate transaction flow", + replace( + program, + transaction=replace( + program.transaction, + flow_slots=( + *program.transaction.flow_slots, + program.transaction.flow_slots[0], + ), + ), + ), + "TRANSACTION_DUPLICATE_SLOT", + ) + ) + + modes = list(program.modes) + modes[1] = replace(modes[1], slot=modes[0].slot) + variants.append( + ( + "duplicate mode slot", + replace(program, modes=tuple(modes)), + "MODE_SLOT_DUPLICATE", + ) + ) + + gather_pattern = program.state_reducer.derivative_gather.pattern + gather_pointers = list(gather_pattern.row_pointers) + gather_pointers[1] = gather_pointers[0] + variants.append( + ( + "empty derivative row", + replace( + program, + state_reducer=replace( + program.state_reducer, + derivative_gather=replace( + program.state_reducer.derivative_gather, + pattern=replace( + gather_pattern, + row_pointers=tuple(gather_pointers), + ), + ), + ), + ), + "DERIVATIVE_GATHER_EMPTY_ROW", + ) + ) + + for label, corrupted, expected_code in variants: + with self.subTest(label=label): + report = validate_system_ir(corrupted) + self.assertFalse(report.valid) + self.assertIn( + expected_code, + {item.code for item in report.issues}, + ) + + def test_target_recompilation_is_byte_stable(self) -> None: + second = compile_system_ir(_system_from_xml(TARGET_XML)) + + self.assertEqual( + self.program.canonical_json_bytes(), + second.canonical_json_bytes(), + ) + + +class HistoricalSystemNumericIRV2Tests(unittest.TestCase): + def test_historical_complex_model_also_compiles(self) -> None: + program = compile_system_ir(_system_from_xml(HISTORICAL_XML)) + + self.assertTrue(validate_system_ir(program).valid) + self.assertEqual(len(program.components), 98) + self.assertEqual(len(program.connections), 106) + self.assertEqual(program.state_reducer.solver_state_count, 74) + self.assertEqual(len(program.pressure_flow.unknowns), 472) + self.assertEqual(len(program.pressure_flow.equations), 472) + self.assertEqual(len(program.outputs), 1021) + + +if __name__ == "__main__": + unittest.main()