From b435daecf27bf337d18d2be513e4cd55b0953cfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=A2=E4=BA=AC=E6=B3=BD?= Date: Tue, 18 Aug 2026 06:41:36 +0000 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E9=80=9A=E7=94=A8=E6=B1=82?= =?UTF-8?q?=E8=A7=A3=E5=99=A8=E5=9B=9E=E5=BD=92=E4=B8=8E=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 引入因果坐标内核、热流体恢复和递进长时回归\n- 完善正交连线、线桥、视图保持与结果曲线缩放\n- 补充依赖约束、CI、测试基线和北京时间更新日志 --- .github/workflows/solver-regression.yml | 175 + .python-version | 1 + README.md | 30 +- app/simulation/benchmark_regression.py | 2132 + .../components/amesim/flow/pipes.py | 18 +- app/simulation/max_step_matrix.py | 1179 + app/simulation/solvers/algebraic.py | 518 +- app/simulation/solvers/algebraic_blocks.py | 179 +- app/simulation/solvers/causal_ir.py | 783 + app/simulation/solvers/solver.py | 269 +- app/simulation/solvers/stream.py | 20 + app/simulation/solvers/tangent.py | 179 +- app/simulation/solvers/thermofluid.py | 567 + app/simulation/systems/generic.py | 117 +- constraints/python312-direct.txt | 12 + docs/other/求解器性能优化任务清单.md | 245 +- docs/update-log/更新日志-2026-08-17.md | 32 + docs/update-log/更新日志-2026-08-18.md | 12 + frontend/src/App.tsx | 1177 +- frontend/src/ContactAwareEdge.tsx | 241 +- frontend/src/SimulationResultsView.tsx | 1932 +- frontend/src/edgeRouting.ts | 536 + frontend/src/styles.css | 130 + frontend/tests/e2e/component-symbols.spec.ts | 32 + frontend/tests/e2e/edge-interactions.spec.ts | 719 + frontend/tests/e2e/fit-view.spec.ts | 268 + .../tests/e2e/results-chart-windows.spec.ts | 631 +- requirements.txt | 4 + .../goldens/production-0.2s-v1.json | 592 + .../simulation/test_mql_8/manifest.json | 198 + .../runs/2026-08-17-extension-decision.json | 34 + ...10s-max-step-0p02-general-recovery-v3.json | 6623 +++ ...duction-10s-max-step-0p02-recovery-v1.json | 6496 +++ ...-2s-max-step-0p02-general-recovery-v3.json | 5041 +++ ...uction-2s-max-step-matrix-recovery-v2.json | 34745 ++++++++++++++++ ...8-17-production-2s-max-step-matrix-v1.json | 26298 ++++++++++++ ...uction-5s-max-step-matrix-recovery-v1.json | 11969 ++++++ ...026-08-17-production-canonical-v1-0.2.json | 6211 +++ ...17-production-general-recovery-v2-0.2.json | 6301 +++ ...uction-general-recovery-v2-repeat-0.2.json | 6305 +++ ...-production-general-recovery-v2-smoke.json | 3083 ++ ...roduction-thermofluid-recovery-v1-0.2.json | 6283 +++ .../runs/2026-08-17-production-v2-0.2.json | 6133 +++ ...8-17-production-v2-extension-decision.json | 37 + .../runs/2026-08-17-production-v2-smoke.json | 3017 ++ ...17-solver-only-canonical-extension-v1.json | 13123 ++++++ .../runs/2026-08-17-solver-only-v1.json | 2745 ++ .../test_mql_full_branches/manifest.json | 120 + tests/data/test-mql-8.json | 9884 +++++ tests/data/test-mql-8.xml | 2014 + tests/test_amesim_pnl00r_component.py | 260 +- tests/test_benchmark_regression.py | 708 + tests/test_causal_numeric_ir.py | 281 + tests/test_core_solver.py | 478 +- tests/test_dependency_constraints.py | 105 + tests/test_max_step_matrix.py | 275 + tests/test_mql_full_branches_regression.py | 114 + tests/test_pressure_flow_causal_execution.py | 270 +- tests/test_simulation_paths.py | 3 +- tests/test_stream_pressure_block_solver.py | 150 +- tests/test_stream_resolver_execution_plan.py | 66 + tests/test_supported_piston_tangent.py | 289 + tests/test_test_mql_8_regression.py | 237 + tests/test_thermofluid_recovery.py | 339 + tests/test_three_piston_tangent.py | 7 +- 65 files changed, 172271 insertions(+), 701 deletions(-) create mode 100644 .github/workflows/solver-regression.yml create mode 100644 .python-version create mode 100644 app/simulation/benchmark_regression.py create mode 100644 app/simulation/max_step_matrix.py create mode 100644 app/simulation/solvers/causal_ir.py create mode 100644 app/simulation/solvers/thermofluid.py create mode 100644 constraints/python312-direct.txt create mode 100644 docs/update-log/更新日志-2026-08-18.md create mode 100644 frontend/src/edgeRouting.ts create mode 100644 frontend/tests/e2e/edge-interactions.spec.ts create mode 100644 tests/baselines/simulation/test_mql_8/goldens/production-0.2s-v1.json create mode 100644 tests/baselines/simulation/test_mql_8/manifest.json create mode 100644 tests/baselines/simulation/test_mql_8/runs/2026-08-17-extension-decision.json create mode 100644 tests/baselines/simulation/test_mql_8/runs/2026-08-17-production-10s-max-step-0p02-general-recovery-v3.json create mode 100644 tests/baselines/simulation/test_mql_8/runs/2026-08-17-production-10s-max-step-0p02-recovery-v1.json create mode 100644 tests/baselines/simulation/test_mql_8/runs/2026-08-17-production-2s-max-step-0p02-general-recovery-v3.json create mode 100644 tests/baselines/simulation/test_mql_8/runs/2026-08-17-production-2s-max-step-matrix-recovery-v2.json create mode 100644 tests/baselines/simulation/test_mql_8/runs/2026-08-17-production-2s-max-step-matrix-v1.json create mode 100644 tests/baselines/simulation/test_mql_8/runs/2026-08-17-production-5s-max-step-matrix-recovery-v1.json create mode 100644 tests/baselines/simulation/test_mql_8/runs/2026-08-17-production-canonical-v1-0.2.json create mode 100644 tests/baselines/simulation/test_mql_8/runs/2026-08-17-production-general-recovery-v2-0.2.json create mode 100644 tests/baselines/simulation/test_mql_8/runs/2026-08-17-production-general-recovery-v2-repeat-0.2.json create mode 100644 tests/baselines/simulation/test_mql_8/runs/2026-08-17-production-general-recovery-v2-smoke.json create mode 100644 tests/baselines/simulation/test_mql_8/runs/2026-08-17-production-thermofluid-recovery-v1-0.2.json create mode 100644 tests/baselines/simulation/test_mql_8/runs/2026-08-17-production-v2-0.2.json create mode 100644 tests/baselines/simulation/test_mql_8/runs/2026-08-17-production-v2-extension-decision.json create mode 100644 tests/baselines/simulation/test_mql_8/runs/2026-08-17-production-v2-smoke.json create mode 100644 tests/baselines/simulation/test_mql_8/runs/2026-08-17-solver-only-canonical-extension-v1.json create mode 100644 tests/baselines/simulation/test_mql_8/runs/2026-08-17-solver-only-v1.json create mode 100644 tests/baselines/simulation/test_mql_full_branches/manifest.json create mode 100644 tests/data/test-mql-8.json create mode 100644 tests/data/test-mql-8.xml create mode 100644 tests/test_benchmark_regression.py create mode 100644 tests/test_causal_numeric_ir.py create mode 100644 tests/test_dependency_constraints.py create mode 100644 tests/test_max_step_matrix.py create mode 100644 tests/test_mql_full_branches_regression.py create mode 100644 tests/test_supported_piston_tangent.py create mode 100644 tests/test_test_mql_8_regression.py create mode 100644 tests/test_thermofluid_recovery.py diff --git a/.github/workflows/solver-regression.yml b/.github/workflows/solver-regression.yml new file mode 100644 index 0000000..6b35f2b --- /dev/null +++ b/.github/workflows/solver-regression.yml @@ -0,0 +1,175 @@ +name: Solver regression + +on: + push: + paths: + - "app/simulation/**" + - "tests/**" + - "requirements.txt" + - "constraints/**" + - ".python-version" + - "README.md" + - ".github/workflows/solver-regression.yml" + pull_request: + paths: + - "app/simulation/**" + - "tests/**" + - "requirements.txt" + - "constraints/**" + - ".python-version" + - "README.md" + - ".github/workflows/solver-regression.yml" + schedule: + - cron: "17 3 * * 1-6" + - cron: "17 3 * * 0" + workflow_dispatch: + inputs: + suite: + description: Regression tier + required: true + default: quick + type: choice + options: + - quick + - historical + - main-long + case: + description: Longest main-model horizon (predecessors run first) + required: true + default: 0.2s + type: choice + options: + - 0.2s + - 1s + - 5s + - 10s + lane: + description: Output sampling lane + required: true + default: production + type: choice + options: + - solver-only + - production + +concurrency: + group: solver-regression-${{ github.ref }}-${{ github.event_name }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + quick: + if: >- + github.event_name == 'push' || + github.event_name == 'pull_request' || + (github.event_name == 'workflow_dispatch' && inputs.suite == 'quick') + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version-file: .python-version + cache: pip + cache-dependency-path: | + requirements.txt + constraints/python312-direct.txt + - name: Install reference dependencies + run: | + python -m pip install \ + -r requirements.txt \ + -c constraints/python312-direct.txt + python -m pip check + - name: Run solver foundation tests + env: + SYSTEM_SIMULATION_VERIFY_LOCKED_ENV: "1" + run: | + python -W error::ResourceWarning -m unittest \ + tests.test_dependency_constraints \ + tests.test_benchmark_regression \ + tests.test_test_mql_8_regression \ + tests.test_mql_full_branches_regression \ + tests.test_pressure_flow_causal_execution \ + tests.test_stream_pressure_block_solver \ + tests.test_core_solver \ + tests.test_supported_piston_tangent \ + tests.test_three_piston_tangent \ + tests.test_sparse_secant_jacobian \ + tests.test_generic_jacobian_sparsity \ + tests.test_generic_system_xml_simulation + + historical-nightly: + if: >- + (github.event_name == 'schedule' && github.event.schedule == '17 3 * * 1-6') || + (github.event_name == 'workflow_dispatch' && inputs.suite == 'historical') + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version-file: .python-version + cache: pip + cache-dependency-path: | + requirements.txt + constraints/python312-direct.txt + - name: Install reference dependencies + run: | + python -m pip install \ + -r requirements.txt \ + -c constraints/python312-direct.txt + python -m pip check + - name: Run 0.81 and 2.10 second historical regression + run: | + mkdir -p artifacts + python -m app.simulation.benchmark_regression \ + --manifest tests/baselines/simulation/test_mql_full_branches/manifest.json \ + --lane solver-only \ + --output artifacts/test-mql-full-branches.json + - if: always() + uses: actions/upload-artifact@v4 + with: + name: historical-solver-regression + path: artifacts/*.json + if-no-files-found: warn + + main-periodic: + if: >- + (github.event_name == 'schedule' && github.event.schedule == '17 3 * * 0') || + (github.event_name == 'workflow_dispatch' && inputs.suite == 'main-long') + runs-on: ubuntu-24.04 + timeout-minutes: 180 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version-file: .python-version + cache: pip + cache-dependency-path: | + requirements.txt + constraints/python312-direct.txt + - name: Install reference dependencies + run: | + python -m pip install \ + -r requirements.txt \ + -c constraints/python312-direct.txt + python -m pip check + - name: Run bounded progressive main-model regression + env: + REQUESTED_CASE: ${{ github.event_name == 'workflow_dispatch' && inputs.case || '10s' }} + REQUESTED_LANE: ${{ github.event_name == 'workflow_dispatch' && inputs.lane || 'production' }} + run: | + mkdir -p artifacts + python -m app.simulation.benchmark_regression \ + --manifest tests/baselines/simulation/test_mql_8/manifest.json \ + --lane "$REQUESTED_LANE" \ + --case "$REQUESTED_CASE" \ + --output artifacts/test-mql-8-progressive.json + - if: always() + uses: actions/upload-artifact@v4 + with: + name: main-model-progressive-regression + path: artifacts/*.json + if-no-files-found: warn diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..871f80a --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12.3 diff --git a/README.md b/README.md index fb81b80..b5d5365 100644 --- a/README.md +++ b/README.md @@ -4,22 +4,42 @@ ReactFlow 系统建模与 `app.simulation` 仿真后端。 ## 开发环境准备 -后端依赖分别安装在平台对应的虚拟环境中。 +后端统一使用 Python 3.12;仓库根目录的 `.python-version` 记录本轮参考补丁版本 +`3.12.3`。`requirements.txt` 保留支持范围, +`constraints/python312-direct.txt` 固定应用直接依赖的参考版本。普通开发、性能复测和 +CI 应同时使用两者,以避免同一代码在不同时间解析到不同的 NumPy、SciPy 或 Web +框架版本。 Windows: ```powershell -py -3 -m venv .venv-win -.\.venv-win\Scripts\python.exe -m pip install -r requirements.txt +py -3.12 -m venv .venv-win +.\.venv-win\Scripts\python.exe -m pip install ` + -r requirements.txt ` + -c constraints/python312-direct.txt +.\.venv-win\Scripts\python.exe -m pip check ``` Linux: ```bash -python3 -m venv .venv -./.venv/bin/python -m pip install -r requirements.txt +python3.12 -m venv .venv +./.venv/bin/python -m pip install \ + -r requirements.txt \ + -c constraints/python312-direct.txt +./.venv/bin/python -m pip check ``` +约束文件只固定代码直接导入或启动的 `fastapi`、`lxml`、`numpy`、`pydantic`、 +`scipy` 和 `uvicorn`。`uvicorn[standard]` 的可选传递依赖包含平台差异,因此仍由 pip +按目标平台解析;这套方案固定求解器和接口层的主要版本,但不是带 wheel 哈希的逐位 +相同发布锁。若要测试 `requirements.txt` 声明的兼容范围,可显式省略 `-c`,但这类 +结果不应与受约束环境的性能数据直接比较。 + +升级参考版本时,应在干净的 Python 3.12 虚拟环境中同时安装范围文件和约束文件, +运行 `pip check` 与后端测试,再更新约束;不要从单个平台的 `pip freeze` 直接复制 +所有传递依赖。 + 前端使用 Vite 8,需要 Node.js `20.19+` 或 `22.12+`。首次启动前安装前端依赖。 Windows(PowerShell,使用仓库内的便携 Node.js): diff --git a/app/simulation/benchmark_regression.py b/app/simulation/benchmark_regression.py new file mode 100644 index 0000000..38e199a --- /dev/null +++ b/app/simulation/benchmark_regression.py @@ -0,0 +1,2132 @@ +"""Manifest-driven, progressively bounded System XML regression runner. + +The parent process launches one child process per requested simulation horizon. +It first asks the child to cancel cooperatively at the soft deadline and then +terminates it at the hard deadline. This keeps an unexpectedly expensive +long-run probe from blocking later optimization work indefinitely. + +The source XML is immutable and authoritative. Stop time, sampling, and an +explicitly configured lane max step are changed only in the child process's +in-memory XML copy. +""" + +from __future__ import annotations + +import argparse +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass +from datetime import UTC, datetime +import hashlib +import importlib.metadata +import json +import math +import os +from pathlib import Path +import platform +import queue +import subprocess +import sys +import threading +from time import monotonic, perf_counter, process_time +from typing import Any +import xml.etree.ElementTree as ET + +try: # resource is unavailable on native Windows Python. + import resource +except ImportError: # pragma: no cover - Windows regression job + resource = None # type: ignore[assignment] + + +REPORT_SCHEMA_VERSION = 2 +MANIFEST_SCHEMA_VERSION = 1 +REGRESSION_GOLDEN_SCHEMA_VERSION = 1 +DEFAULT_MANIFEST_PATH = ( + Path(__file__).resolve().parents[2] + / "tests" + / "baselines" + / "simulation" + / "test_mql_8" + / "manifest.json" +) + + +class RegressionManifestError(ValueError): + """Raised when a regression manifest is incomplete or inconsistent.""" + + +@dataclass(frozen=True) +class RegressionCaseRequest: + case_id: str + source_path: Path + expected_sha256: str + lane: str + stop_time: float + sample_step: float + max_step: float + checkpoint_times: tuple[float, ...] + soft_timeout_seconds: float + hard_timeout_seconds: float + termination_grace_seconds: float + instrumentation_mode: str = "standard" + environment_overrides: tuple[tuple[str, str], ...] = () + + +CaseExecutor = Callable[[RegressionCaseRequest], dict[str, object]] + + +def _sha256(payload: bytes) -> str: + return hashlib.sha256(payload).hexdigest() + + +def _canonical_json_sha256(value: object) -> str: + payload = json.dumps( + value, + default=str, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + return _sha256(payload) + + +def _valid_sha256(value: object) -> bool: + return ( + isinstance(value, str) + and len(value) == 64 + and all(character in "0123456789abcdef" for character in value) + ) + + +def _repository_root(manifest_path: Path) -> Path: + for candidate in (manifest_path.parent, *manifest_path.parents): + if (candidate / "app").is_dir() and (candidate / "tests").is_dir(): + return candidate + raise RegressionManifestError( + f"Could not locate the repository root above {manifest_path}." + ) + + +def _finite_positive(value: object, *, field: str) -> float: + try: + numeric = float(value) + except (TypeError, ValueError) as exc: + raise RegressionManifestError(f"{field} must be numeric.") from exc + if not math.isfinite(numeric) or numeric <= 0.0: + raise RegressionManifestError(f"{field} must be finite and positive.") + return numeric + + +def _finite_nonnegative(value: object, *, field: str) -> float: + try: + numeric = float(value) + except (TypeError, ValueError) as exc: + raise RegressionManifestError(f"{field} must be numeric.") from exc + if not math.isfinite(numeric) or numeric < 0.0: + raise RegressionManifestError( + f"{field} must be finite and non-negative." + ) + return numeric + + +def load_regression_golden( + path: Path | str, + *, + expected_sha256: str | None = None, + repository_root: Path | None = None, +) -> dict[str, object]: + """Load one reviewed physical/output contract without trusting its values.""" + + golden_path = Path(path).resolve() + try: + payload = golden_path.read_bytes() + golden = json.loads(payload) + except (OSError, json.JSONDecodeError) as exc: + raise RegressionManifestError( + f"Could not read regression golden {golden_path}: {exc}" + ) from exc + actual_sha256 = _sha256(payload) + if expected_sha256 is not None and actual_sha256 != expected_sha256: + raise RegressionManifestError( + "Regression golden hash mismatch: " + f"expected {expected_sha256}, received {actual_sha256}." + ) + if not isinstance(golden, dict): + raise RegressionManifestError("Regression golden must be a JSON object.") + if golden.get("schemaVersion") != REGRESSION_GOLDEN_SCHEMA_VERSION: + raise RegressionManifestError( + "Unsupported regression golden schemaVersion: " + f"{golden.get('schemaVersion')!r}." + ) + for field in ("id", "caseId", "lane", "sourceXmlSha256"): + if not isinstance(golden.get(field), str) or not golden[field]: + raise RegressionManifestError( + f"Regression golden {field} must be a non-empty string." + ) + if not _valid_sha256(golden["sourceXmlSha256"]): + raise RegressionManifestError( + "Regression golden sourceXmlSha256 must be a lowercase SHA-256 digest." + ) + + approval = golden.get("approval") + if not isinstance(approval, Mapping) or approval.get("status") != "approved": + raise RegressionManifestError( + "Regression golden must have approval.status='approved'." + ) + provenance = golden.get("provenance") + source_report = ( + provenance.get("sourceReport") + if isinstance(provenance, Mapping) + else None + ) + if not isinstance(source_report, Mapping): + raise RegressionManifestError( + "Regression golden provenance.sourceReport must be an object." + ) + report_path_value = source_report.get("path") + report_sha256 = source_report.get("sha256") + report_generated_at = source_report.get("generatedAt") + if not isinstance(report_path_value, str) or not report_path_value: + raise RegressionManifestError( + "Regression golden source report path must be non-empty." + ) + if not _valid_sha256(report_sha256): + raise RegressionManifestError( + "Regression golden source report sha256 must be a lowercase digest." + ) + if not isinstance(report_generated_at, str) or not report_generated_at: + raise RegressionManifestError( + "Regression golden source report generatedAt must be non-empty." + ) + compatibility = source_report.get("metadataCompatibility") + if not isinstance(compatibility, Mapping) or compatibility.get("status") not in { + "current", + "acceptedHistorical", + }: + raise RegressionManifestError( + "Regression golden source report must declare metadata compatibility." + ) + differences = compatibility.get("differences") + if not isinstance(differences, list) or not all( + isinstance(value, str) and value for value in differences + ): + raise RegressionManifestError( + "Regression golden metadata compatibility differences must be strings." + ) + if compatibility.get("status") == "current" and differences: + raise RegressionManifestError( + "A current source report must not declare metadata differences." + ) + + layout = golden.get("physicalLayout") + if not isinstance(layout, Mapping): + raise RegressionManifestError( + "Regression golden physicalLayout must be an object." + ) + categories = layout.get("projectionCategories") + state_keys = layout.get("stateKeys") + if not isinstance(categories, list) or "state" not in categories or not all( + isinstance(value, str) and value for value in categories + ): + raise RegressionManifestError( + "Regression golden projectionCategories must contain 'state'." + ) + if not isinstance(state_keys, list) or not state_keys or not all( + isinstance(value, str) and value for value in state_keys + ): + raise RegressionManifestError( + "Regression golden stateKeys must be a non-empty string list." + ) + if len(set(state_keys)) != len(state_keys): + raise RegressionManifestError("Regression golden stateKeys must be unique.") + if layout.get("stateKeyLayoutSha256") != _canonical_json_sha256(state_keys): + raise RegressionManifestError( + "Regression golden stateKeyLayoutSha256 does not match stateKeys." + ) + + tolerance = golden.get("tolerance") + if not isinstance(tolerance, Mapping): + raise RegressionManifestError( + "Regression golden tolerance must be an object." + ) + _finite_nonnegative( + tolerance.get("relative"), field="golden.tolerance.relative" + ) + _finite_nonnegative( + tolerance.get("absolute"), field="golden.tolerance.absolute" + ) + _finite_nonnegative( + tolerance.get("checkpointTimeAbsoluteSeconds"), + field="golden.tolerance.checkpointTimeAbsoluteSeconds", + ) + + checkpoints = golden.get("physicalCheckpoints") + if not isinstance(checkpoints, list) or not checkpoints: + raise RegressionManifestError( + "Regression golden physicalCheckpoints must be a non-empty list." + ) + for index, checkpoint in enumerate(checkpoints): + if not isinstance(checkpoint, Mapping): + raise RegressionManifestError( + f"Regression golden checkpoint {index} must be an object." + ) + _finite_nonnegative( + checkpoint.get("requestedTime"), + field=f"golden.physicalCheckpoints[{index}].requestedTime", + ) + values = checkpoint.get("values") + if not isinstance(values, list) or len(values) != len(state_keys): + raise RegressionManifestError( + f"Regression golden checkpoint {index} has the wrong value layout." + ) + if any( + not isinstance(value, (int, float)) or not math.isfinite(float(value)) + for value in values + ): + raise RegressionManifestError( + f"Regression golden checkpoint {index} values must be finite numbers." + ) + + output_contract = golden.get("outputContract") + if output_contract is not None: + if not isinstance(output_contract, Mapping) or not _valid_sha256( + output_contract.get("sha256") + ): + raise RegressionManifestError( + "Regression golden outputContract.sha256 must be a lowercase digest." + ) + + if repository_root is not None: + root = repository_root.resolve() + report_path = (root / report_path_value).resolve() + if not report_path.is_relative_to(root): + raise RegressionManifestError( + "Regression golden source report must remain inside the repository." + ) + try: + report_payload = report_path.read_bytes() + except OSError as exc: + raise RegressionManifestError( + f"Could not read golden source report {report_path}: {exc}" + ) from exc + if _sha256(report_payload) != report_sha256: + raise RegressionManifestError( + "Regression golden source report hash no longer matches provenance." + ) + try: + source_report_document = json.loads(report_payload) + except json.JSONDecodeError as exc: + raise RegressionManifestError( + "Regression golden source report is not valid JSON." + ) from exc + report_source = ( + source_report_document.get("source") + if isinstance(source_report_document, Mapping) + else None + ) + if ( + not isinstance(report_source, Mapping) + or report_source.get("sha256") != golden["sourceXmlSha256"] + or source_report_document.get("generatedAt") != report_generated_at + ): + raise RegressionManifestError( + "Regression golden source report identity does not match the golden." + ) + report_cases = source_report_document.get("cases") + matching_cases = ( + [ + case + for case in report_cases + if isinstance(case, Mapping) + and case.get("caseId") == golden["caseId"] + and case.get("lane") == golden["lane"] + ] + if isinstance(report_cases, list) + else [] + ) + if len(matching_cases) != 1: + raise RegressionManifestError( + "Regression golden source report does not contain exactly one matching case." + ) + worker = matching_cases[0].get("worker") + summary = worker.get("summary") if isinstance(worker, Mapping) else None + report_checkpoints = ( + summary.get("physicalContract", {}).get("checkpoints") + if isinstance(summary, Mapping) + and isinstance(summary.get("physicalContract"), Mapping) + else summary.get("checkpoints") if isinstance(summary, Mapping) else None + ) + if not isinstance(report_checkpoints, list) or len(report_checkpoints) != len( + checkpoints + ): + raise RegressionManifestError( + "Regression golden checkpoints do not match the source report." + ) + for golden_checkpoint, report_checkpoint in zip( + checkpoints, report_checkpoints + ): + report_values = ( + report_checkpoint.get("stateValues") + if isinstance(report_checkpoint, Mapping) + else None + ) + extracted_values = ( + [report_values.get(key) for key in state_keys] + if isinstance(report_values, Mapping) + and set(report_values) == set(state_keys) + else None + ) + if ( + not isinstance(report_checkpoint, Mapping) + or report_checkpoint.get("requestedTime") + != golden_checkpoint.get("requestedTime") + or extracted_values != golden_checkpoint.get("values") + ): + raise RegressionManifestError( + "Regression golden values are not an exact extraction of its source report." + ) + if output_contract is not None: + report_output_contract = ( + summary.get("outputContract") + if isinstance(summary, Mapping) + else None + ) + if ( + not isinstance(report_output_contract, Mapping) + or report_output_contract.get("sha256") + != output_contract.get("sha256") + ): + raise RegressionManifestError( + "Regression golden output contract does not match its source report." + ) + golden["_sourceReportPath"] = str(report_path) + + golden["_path"] = str(golden_path) + golden["_sha256"] = actual_sha256 + return golden + + +def load_regression_manifest(path: Path | str) -> dict[str, object]: + """Load and validate the stable, machine-independent suite definition.""" + + manifest_path = Path(path).resolve() + try: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise RegressionManifestError( + f"Could not read regression manifest {manifest_path}: {exc}" + ) from exc + if not isinstance(manifest, dict): + raise RegressionManifestError("Regression manifest must be a JSON object.") + if manifest.get("schemaVersion") != MANIFEST_SCHEMA_VERSION: + raise RegressionManifestError( + f"Unsupported regression manifest schemaVersion: " + f"{manifest.get('schemaVersion')!r}." + ) + + source = manifest.get("source") + if not isinstance(source, dict): + raise RegressionManifestError("Manifest source must be an object.") + raw_source_path = source.get("path") + expected_sha256 = source.get("sha256") + if not isinstance(raw_source_path, str) or not raw_source_path: + raise RegressionManifestError("Manifest source.path must be non-empty.") + if not _valid_sha256(expected_sha256): + raise RegressionManifestError( + "Manifest source.sha256 must be a lowercase SHA-256 digest." + ) + repository_root = _repository_root(manifest_path) + source_path = (repository_root / raw_source_path).resolve() + try: + source_payload = source_path.read_bytes() + except OSError as exc: + raise RegressionManifestError( + f"Could not read authoritative XML {source_path}: {exc}" + ) from exc + actual_sha256 = _sha256(source_payload) + if actual_sha256 != expected_sha256: + raise RegressionManifestError( + "Authoritative XML hash mismatch: " + f"expected {expected_sha256}, received {actual_sha256}." + ) + expected_source_bytes = source.get("bytes") + if not isinstance(expected_source_bytes, int) or expected_source_bytes != len( + source_payload + ): + raise RegressionManifestError( + "Authoritative XML byte count does not match manifest source.bytes." + ) + + companion = source.get("companionProject") + companion_path: Path | None = None + if companion is not None: + if not isinstance(companion, dict): + raise RegressionManifestError( + "Manifest source.companionProject must be an object when declared." + ) + companion_path_value = companion.get("path") + companion_sha256 = companion.get("sha256") + companion_bytes = companion.get("bytes") + if not isinstance(companion_path_value, str) or not companion_path_value: + raise RegressionManifestError( + "Manifest companion project path must be non-empty." + ) + if not _valid_sha256(companion_sha256): + raise RegressionManifestError( + "Manifest companion project sha256 must be a lowercase digest." + ) + companion_path = (repository_root / companion_path_value).resolve() + try: + companion_payload = companion_path.read_bytes() + except OSError as exc: + raise RegressionManifestError( + f"Could not read companion project {companion_path}: {exc}" + ) from exc + if _sha256(companion_payload) != companion_sha256: + raise RegressionManifestError("Companion project hash mismatch.") + if not isinstance(companion_bytes, int) or companion_bytes != len( + companion_payload + ): + raise RegressionManifestError("Companion project byte count mismatch.") + if companion.get("executionInput") is not False: + raise RegressionManifestError( + "Companion project must be explicitly marked as non-execution input." + ) + + historical_reports = manifest.get("historicalReports", []) + if not isinstance(historical_reports, list): + raise RegressionManifestError("historicalReports must be a list.") + for index, historical in enumerate(historical_reports): + if not isinstance(historical, Mapping): + raise RegressionManifestError( + f"historicalReports[{index}] must be an object." + ) + historical_path_value = historical.get("path") + historical_sha256 = historical.get("sha256") + historical_source_sha256 = historical.get("sourceXmlSha256") + if ( + historical.get("status") != "historicalOnly" + or not isinstance(historical_path_value, str) + or not historical_path_value + or not _valid_sha256(historical_sha256) + or not _valid_sha256(historical_source_sha256) + or historical_source_sha256 == expected_sha256 + or historical.get("compatibleWithCurrentSource") is not False + or not isinstance(historical.get("reason"), str) + or not historical.get("reason") + ): + raise RegressionManifestError( + f"historicalReports[{index}] is incomplete." + ) + historical_path = (repository_root / historical_path_value).resolve() + try: + historical_payload = historical_path.read_bytes() + except OSError as exc: + raise RegressionManifestError( + f"Could not read historical report {historical_path}: {exc}" + ) from exc + if _sha256(historical_payload) != historical_sha256: + raise RegressionManifestError( + f"historicalReports[{index}] hash mismatch." + ) + + sequence = manifest.get("sequence") + variants = manifest.get("variants") + lanes = manifest.get("lanes") + execution = manifest.get("execution") + if not isinstance(sequence, list) or not sequence: + raise RegressionManifestError("Manifest sequence must be a non-empty list.") + if not isinstance(variants, dict): + raise RegressionManifestError("Manifest variants must be an object.") + if not isinstance(lanes, dict) or not lanes: + raise RegressionManifestError("Manifest lanes must be a non-empty object.") + if not isinstance(execution, dict): + raise RegressionManifestError("Manifest execution must be an object.") + + previous_stop = -math.inf + for raw_case_id in sequence: + if not isinstance(raw_case_id, str) or raw_case_id not in variants: + raise RegressionManifestError( + f"Sequence entry {raw_case_id!r} has no matching variant." + ) + variant = variants[raw_case_id] + if not isinstance(variant, dict): + raise RegressionManifestError( + f"Variant {raw_case_id!r} must be an object." + ) + stop_time = _finite_positive( + variant.get("stopTime"), field=f"variants.{raw_case_id}.stopTime" + ) + if stop_time <= previous_stop: + raise RegressionManifestError( + "Variant stop times must be strictly increasing in sequence order." + ) + previous_stop = stop_time + soft_timeout = _finite_positive( + variant.get("softTimeoutSeconds"), + field=f"variants.{raw_case_id}.softTimeoutSeconds", + ) + hard_timeout = _finite_positive( + variant.get("hardTimeoutSeconds"), + field=f"variants.{raw_case_id}.hardTimeoutSeconds", + ) + if hard_timeout <= soft_timeout: + raise RegressionManifestError( + f"Variant {raw_case_id!r} hard timeout must exceed its soft timeout." + ) + prediction_eligible = variant.get("useForRuntimePrediction", True) + if not isinstance(prediction_eligible, bool): + raise RegressionManifestError( + f"Variant {raw_case_id!r} useForRuntimePrediction must be boolean." + ) + checkpoint_times = variant.get("checkpointTimes", []) + if not isinstance(checkpoint_times, list): + raise RegressionManifestError( + f"Variant {raw_case_id!r} checkpointTimes must be a list." + ) + for index, checkpoint in enumerate(checkpoint_times): + checkpoint_value = _finite_nonnegative( + checkpoint, + field=( + f"variants.{raw_case_id}.checkpointTimes[{index}]" + ), + ) + if checkpoint_value > stop_time: + raise RegressionManifestError( + f"Variant {raw_case_id!r} checkpoint exceeds stopTime." + ) + + for lane_name, raw_lane in lanes.items(): + if not isinstance(lane_name, str) or not isinstance(raw_lane, dict): + raise RegressionManifestError("Every lane must be a named object.") + sampling_mode = raw_lane.get("samplingMode") + if sampling_mode not in {"source", "fixed"}: + raise RegressionManifestError( + f"Lane {lane_name!r} samplingMode must be source or fixed." + ) + if sampling_mode == "fixed": + _finite_positive( + raw_lane.get("sampleStep"), + field=f"lanes.{lane_name}.sampleStep", + ) + max_step_mode = raw_lane.get("maxStepMode", "source") + if max_step_mode not in {"source", "fixed"}: + raise RegressionManifestError( + f"Lane {lane_name!r} maxStepMode must be source or fixed." + ) + if max_step_mode == "fixed": + _finite_positive( + raw_lane.get("maxStep"), + field=f"lanes.{lane_name}.maxStep", + ) + instrumentation = raw_lane.get("instrumentationMode", "standard") + if instrumentation not in {"off", "standard", "audit"}: + raise RegressionManifestError( + f"Lane {lane_name!r} has an unsupported instrumentationMode." + ) + + correctness = manifest.get("correctness") + if not isinstance(correctness, dict): + raise RegressionManifestError("Manifest correctness must be an object.") + state_relative_tolerance = _finite_nonnegative( + correctness.get("stateRelativeTolerance"), + field="correctness.stateRelativeTolerance", + ) + state_absolute_tolerance = _finite_nonnegative( + correctness.get("stateAbsoluteTolerance", 0.0), + field="correctness.stateAbsoluteTolerance", + ) + checkpoint_time_tolerance = _finite_nonnegative( + correctness.get("checkpointTimeAbsoluteToleranceSeconds", 1.0e-12), + field="correctness.checkpointTimeAbsoluteToleranceSeconds", + ) + + loaded_goldens: dict[str, dict[str, dict[str, object]]] = {} + for case_id in sequence: + variant = variants[case_id] + assert isinstance(variant, dict) + raw_goldens = variant.get("goldens", {}) + if not isinstance(raw_goldens, dict): + raise RegressionManifestError( + f"Variant {case_id!r} goldens must be an object keyed by lane." + ) + for lane_name, reference in raw_goldens.items(): + if lane_name not in lanes or not isinstance(reference, Mapping): + raise RegressionManifestError( + f"Variant {case_id!r} has an invalid golden lane reference." + ) + golden_path_value = reference.get("path") + golden_sha256 = reference.get("sha256") + if ( + not isinstance(golden_path_value, str) + or not golden_path_value + or not _valid_sha256(golden_sha256) + ): + raise RegressionManifestError( + f"Variant {case_id!r} golden reference is incomplete." + ) + golden_path = (repository_root / golden_path_value).resolve() + if not golden_path.is_relative_to(repository_root): + raise RegressionManifestError( + "Regression golden must remain inside the repository." + ) + golden = load_regression_golden( + golden_path, + expected_sha256=str(golden_sha256), + repository_root=repository_root, + ) + if ( + golden["caseId"] != case_id + or golden["lane"] != lane_name + or golden["sourceXmlSha256"] != expected_sha256 + ): + raise RegressionManifestError( + f"Variant {case_id!r} golden identity does not match the manifest." + ) + tolerance = golden["tolerance"] + assert isinstance(tolerance, Mapping) + if ( + float(tolerance["relative"]) != state_relative_tolerance + or float(tolerance["absolute"]) != state_absolute_tolerance + or float(tolerance["checkpointTimeAbsoluteSeconds"]) + != checkpoint_time_tolerance + ): + raise RegressionManifestError( + f"Variant {case_id!r} golden tolerance differs from the manifest." + ) + expected_times = [float(value) for value in variant["checkpointTimes"]] + golden_times = [ + float(checkpoint["requestedTime"]) + for checkpoint in golden["physicalCheckpoints"] + ] + if expected_times != golden_times: + raise RegressionManifestError( + f"Variant {case_id!r} golden checkpoint times differ from manifest." + ) + loaded_goldens.setdefault(case_id, {})[lane_name] = golden + + _finite_positive( + execution.get("predictionSafetyFactor", 1.0), + field="execution.predictionSafetyFactor", + ) + _finite_positive( + execution.get("terminationGraceSeconds", 5.0), + field="execution.terminationGraceSeconds", + ) + if source.get("simulation") != source_simulation_config(source_payload): + raise RegressionManifestError( + "Manifest source.simulation does not match the authoritative XML." + ) + + manifest["_manifestPath"] = str(manifest_path) + manifest["_repositoryRoot"] = str(repository_root) + manifest["_sourcePath"] = str(source_path) + manifest["_companionPath"] = ( + str(companion_path) if companion_path is not None else None + ) + manifest["_goldens"] = loaded_goldens + return manifest + + +def _simulation_element(root: ET.Element) -> ET.Element: + simulations = root.findall("./Simulation") + if len(simulations) != 1: + raise RegressionManifestError( + f"Expected exactly one System/Simulation element, received {len(simulations)}." + ) + return simulations[0] + + +def source_simulation_config(payload: bytes) -> dict[str, object]: + """Read the source settings without importing or invoking the simulator.""" + + try: + root = ET.fromstring(payload) + except ET.ParseError as exc: + raise RegressionManifestError(f"Authoritative XML is not well formed: {exc}") from exc + simulation = _simulation_element(root) + try: + return { + "tStart": float(simulation.attrib["tStart"]), + "tStop": float(simulation.attrib["tStop"]), + "sampleStep": float(simulation.attrib["sampleStep"]), + "maxStep": float(simulation.attrib["maxStep"]), + "method": simulation.attrib["method"], + } + except (KeyError, ValueError) as exc: + raise RegressionManifestError( + "Authoritative XML has an incomplete Simulation configuration." + ) from exc + + +def derive_simulation_xml( + source_payload: bytes, + *, + stop_time: float, + sample_step: float, + max_step: float | None = None, +) -> bytes: + """Return an in-memory derivative with explicit, audited setting overrides.""" + + stop_value = _finite_positive(stop_time, field="stop_time") + sample_value = _finite_positive(sample_step, field="sample_step") + max_value = ( + _finite_positive(max_step, field="max_step") + if max_step is not None + else None + ) + try: + root = ET.fromstring(source_payload) + except ET.ParseError as exc: + raise RegressionManifestError(f"Authoritative XML is not well formed: {exc}") from exc + simulation = _simulation_element(root) + original_attributes = dict(simulation.attrib) + simulation.set("tStop", format(stop_value, ".17g")) + simulation.set("sampleStep", format(sample_value, ".17g")) + if max_value is not None: + simulation.set("maxStep", format(max_value, ".17g")) + changed_attributes = { + key + for key in set(original_attributes) | set(simulation.attrib) + if original_attributes.get(key) != simulation.attrib.get(key) + } + if not changed_attributes.issubset({"tStop", "sampleStep", "maxStep"}): + raise AssertionError( + "In-memory regression derivative changed unexpected Simulation attributes." + ) + return ET.tostring(root, encoding="utf-8", xml_declaration=True) + + +def _package_version(distribution: str) -> str | None: + try: + return importlib.metadata.version(distribution) + except importlib.metadata.PackageNotFoundError: + return None + + +def _repository_snapshot() -> dict[str, object]: + repository_root = Path(__file__).resolve().parents[2] + + def git_output(*arguments: str) -> str | None: + try: + completed = subprocess.run( + ("git", "-C", str(repository_root), *arguments), + check=False, + capture_output=True, + text=True, + timeout=5.0, + ) + except (OSError, subprocess.SubprocessError): + return None + if completed.returncode != 0: + return None + return completed.stdout.strip() + + status = git_output("status", "--short", "--untracked-files=all") + return { + "root": str(repository_root), + "head": git_output("rev-parse", "HEAD"), + "branch": git_output("branch", "--show-current"), + "dirty": bool(status) if status is not None else None, + "status": status.splitlines() if status else [], + } + + +def runtime_snapshot() -> dict[str, object]: + return { + "python": sys.version, + "pythonImplementation": platform.python_implementation(), + "executable": sys.executable, + "platform": platform.platform(), + "machine": platform.machine(), + "processor": platform.processor(), + "cpuCount": os.cpu_count(), + "repository": _repository_snapshot(), + "packages": { + "numpy": _package_version("numpy"), + "scipy": _package_version("scipy"), + "lxml": _package_version("lxml"), + }, + "environment": { + "SIMULATIONAPP_PROFILE": os.getenv("SIMULATIONAPP_PROFILE"), + "SIMULATION_ODE_JACOBIAN_MODE": os.getenv( + "SIMULATION_ODE_JACOBIAN_MODE", "scipy" + ), + "SIMULATIONAPP_PROPERTY_CACHE": os.getenv( + "SIMULATIONAPP_PROPERTY_CACHE", "on" + ), + "SIMULATION_CAUSAL_EXECUTOR_V2": os.getenv( + "SIMULATION_CAUSAL_EXECUTOR_V2", "1" + ), + "SIMULATION_CAUSAL_COORDINATE_KERNEL": os.getenv( + "SIMULATION_CAUSAL_COORDINATE_KERNEL", "1" + ), + "SIMULATION_CAUSAL_FAST_PATH": os.getenv( + "SIMULATION_CAUSAL_FAST_PATH", "1" + ), + }, + } + + +def _peak_rss_bytes() -> tuple[int | None, int | None, str]: + if resource is None: + return None, None, "unavailable" + raw_value = int(resource.getrusage(resource.RUSAGE_SELF).ru_maxrss) + if sys.platform == "darwin": + return raw_value, raw_value, "bytes" + return raw_value * 1024, raw_value, "KiB" + + +def _emit_worker_event(payload: Mapping[str, object]) -> None: + print( + json.dumps(payload, ensure_ascii=False, separators=(",", ":"), default=str), + flush=True, + ) + + +def _event_trace(diagnostics: Mapping[str, object]) -> dict[str, object]: + signal = diagnostics.get("signal") + integration = diagnostics.get("integration") + signal_times: list[object] = [] + segment_trace: list[dict[str, object]] = [] + mechanical_transition_times: list[float] = [] + mechanical_transition_times_available = True + totals: dict[str, object] = {} + if isinstance(signal, Mapping): + raw_signal_times = signal.get("eventTimes") + if isinstance(raw_signal_times, list): + signal_times = list(raw_signal_times) + if isinstance(integration, Mapping): + raw_totals = integration.get("totals") + if isinstance(raw_totals, Mapping): + totals = dict(raw_totals) + raw_segments = integration.get("segments") + if isinstance(raw_segments, list): + for segment in raw_segments: + if not isinstance(segment, Mapping): + continue + segment_trace.append( + { + key: segment.get(key) + for key in ( + "startTime", + "requestedStopTime", + "simulatedUntil", + "solverStartCount", + "stateTransitionCount", + "stateTransitionTimes", + "recoverableRetryCount", + ) + } + ) + raw_transition_times = segment.get("stateTransitionTimes") + raw_transition_count = segment.get("stateTransitionCount", 0) + transition_count = ( + int(raw_transition_count) + if isinstance(raw_transition_count, (int, float)) + else 0 + ) + if isinstance(raw_transition_times, list): + finite_transition_times = [ + float(value) + for value in raw_transition_times + if isinstance(value, (int, float)) + and math.isfinite(float(value)) + ] + mechanical_transition_times.extend(finite_transition_times) + if len(finite_transition_times) != transition_count: + mechanical_transition_times_available = False + elif transition_count != 0: + mechanical_transition_times_available = False + total_transition_count = totals.get("stateTransitionCount", 0) + if isinstance(total_transition_count, (int, float)) and int( + total_transition_count + ) != len(mechanical_transition_times): + mechanical_transition_times_available = False + return { + "signalEventTimes": signal_times, + "stateTransitionCount": totals.get("stateTransitionCount", 0), + "mechanicalTransitionTimes": mechanical_transition_times, + "solverStartCount": totals.get("solverStartCount", 0), + "segments": segment_trace, + "mechanicalTransitionTimesAvailable": ( + mechanical_transition_times_available + ), + } + + +def _series_health(series: object) -> dict[str, object]: + if not isinstance(series, Mapping): + return { + "seriesCount": 0, + "scalarCount": 0, + "nonfiniteCount": 0, + "timeStrictlyIncreasing": False, + } + scalar_count = 0 + nonfinite_count = 0 + for values in series.values(): + if not isinstance(values, list): + continue + scalar_count += len(values) + for value in values: + if not isinstance(value, (int, float)) or not math.isfinite(float(value)): + nonfinite_count += 1 + times = series.get("time") + time_values = times if isinstance(times, list) else [] + return { + "seriesCount": len(series), + "scalarCount": scalar_count, + "nonfiniteCount": nonfinite_count, + "timeStrictlyIncreasing": all( + float(first) < float(second) + for first, second in zip(time_values, time_values[1:]) + ), + "timeStart": time_values[0] if time_values else None, + "timeEnd": time_values[-1] if time_values else None, + } + + +def _state_checkpoints( + result: Mapping[str, object], + requested_times: Sequence[float], + sample_step: float, +) -> list[dict[str, object]]: + series = result.get("series") + variables = result.get("variables") + if not isinstance(series, Mapping) or not isinstance(variables, list): + return [] + raw_times = series.get("time") + if not isinstance(raw_times, list) or not raw_times: + return [] + state_keys = [ + str(variable["key"]) + for variable in variables + if isinstance(variable, Mapping) + and variable.get("category") == "state" + and isinstance(variable.get("key"), str) + ] + tolerance = max(1.0e-12, 0.51 * float(sample_step)) + checkpoints: list[dict[str, object]] = [] + for requested in requested_times: + index = min( + range(len(raw_times)), + key=lambda candidate: abs(float(raw_times[candidate]) - requested), + ) + actual_time = float(raw_times[index]) + if abs(actual_time - requested) > tolerance: + checkpoints.append( + { + "requestedTime": requested, + "available": False, + "nearestTime": actual_time, + } + ) + continue + values: dict[str, object] = {} + for key in state_keys: + variable_series = series.get(key) + if isinstance(variable_series, list) and index < len(variable_series): + values[key] = variable_series[index] + checkpoints.append( + { + "requestedTime": requested, + "actualTime": actual_time, + "available": True, + "stateValues": values, + } + ) + return checkpoints + + +def _output_contract(result: Mapping[str, object]) -> dict[str, object]: + """Hash output metadata and shape without including any physical values.""" + + raw_variables = result.get("variables") + variables = ( + [dict(variable) for variable in raw_variables if isinstance(variable, Mapping)] + if isinstance(raw_variables, list) + else [] + ) + raw_series = result.get("series") + series_shape = ( + [ + { + "key": str(key), + "length": len(values) if isinstance(values, list) else None, + } + for key, values in raw_series.items() + ] + if isinstance(raw_series, Mapping) + else [] + ) + lengths = [entry["length"] for entry in series_shape] + numeric_lengths = [value for value in lengths if isinstance(value, int)] + contract_payload = { + "variables": variables, + "seriesShape": series_shape, + } + time_entry = next( + (entry for entry in series_shape if entry["key"] == "time"), + None, + ) + return { + "schemaVersion": 1, + "sha256": _canonical_json_sha256(contract_payload), + "variableMetadataSha256": _canonical_json_sha256(variables), + "seriesShapeSha256": _canonical_json_sha256(series_shape), + "variableCount": len(variables), + "seriesKeyCount": len(series_shape), + "sampleCount": time_entry["length"] if time_entry is not None else None, + "seriesLengthsConsistent": ( + bool(numeric_lengths) + and len(numeric_lengths) == len(lengths) + and len(set(numeric_lengths)) == 1 + ), + "containsPhysicalValues": False, + "hashPayload": "variables metadata + ordered series keys/lengths", + } + + +def summarize_simulation_result( + result: Mapping[str, object], + *, + checkpoint_times: Sequence[float], + sample_step: float, +) -> dict[str, object]: + diagnostics = result.get("diagnostics") + diagnostic_mapping = diagnostics if isinstance(diagnostics, Mapping) else {} + final = result.get("final") + checkpoints = _state_checkpoints(result, checkpoint_times, sample_step) + event_trace = _event_trace(diagnostic_mapping) + physical_contract = { + "schemaVersion": 1, + "projectionCategories": ["state"], + "checkpoints": checkpoints, + "eventTrace": event_trace, + "comparisonMode": "numericTolerance", + } + return { + "success": bool(result.get("success")), + "status": result.get("status"), + "partial": bool(result.get("partial")), + "message": result.get("message"), + "simulatedUntil": result.get("simulatedUntil"), + "requestedStopTime": result.get("requestedStopTime"), + "variableCount": ( + len(result["variables"]) + if isinstance(result.get("variables"), list) + else 0 + ), + "seriesHealth": _series_health(result.get("series")), + "final": dict(final) if isinstance(final, Mapping) else {}, + "physicalContract": physical_contract, + "outputContract": _output_contract(result), + # Compatibility aliases for schema-v1 report consumers. + "checkpoints": checkpoints, + "diagnostics": dict(diagnostic_mapping), + "eventTrace": event_trace, + } + + +def _worker_control_listener(cancel_event: threading.Event) -> None: + try: + for line in sys.stdin: + if line.strip().lower() == "cancel": + cancel_event.set() + return + except (OSError, ValueError): + return + + +def run_worker(arguments: argparse.Namespace) -> int: + """Execute one real simulation inside the bounded child process.""" + + os.environ["SIMULATIONAPP_PROFILE"] = arguments.instrumentation_mode + source_path = Path(arguments.xml).resolve() + source_payload = source_path.read_bytes() + actual_sha256 = _sha256(source_payload) + if actual_sha256 != arguments.expected_sha256: + raise RegressionManifestError( + "Worker source hash mismatch: " + f"expected {arguments.expected_sha256}, received {actual_sha256}." + ) + derived_xml = derive_simulation_xml( + source_payload, + stop_time=arguments.stop_time, + sample_step=arguments.sample_step, + max_step=arguments.max_step, + ) + cancel_event = threading.Event() + threading.Thread( + target=_worker_control_listener, + args=(cancel_event,), + name="regression-soft-cancel-listener", + daemon=True, + ).start() + + latest_simulated_time: float | None = None + + def progress_callback( + progress: int, + phase: str, + message: str, + simulated_time: float | None = None, + total_time: float | None = None, + ) -> None: + nonlocal latest_simulated_time + if simulated_time is not None and math.isfinite(float(simulated_time)): + latest_simulated_time = max( + float(simulated_time), + latest_simulated_time if latest_simulated_time is not None else -math.inf, + ) + _emit_worker_event( + { + "event": "progress", + "progress": progress, + "phase": phase, + "message": message, + "simulatedTime": simulated_time, + "totalTime": total_time, + "wallSeconds": perf_counter() - wall_started, + } + ) + + wall_started = perf_counter() + cpu_started = process_time() + try: + from app.main import run_system_xml_simulation + + result = run_system_xml_simulation( + derived_xml, + progress_callback=progress_callback, + cancel_check=cancel_event.is_set, + ) + wall_seconds = perf_counter() - wall_started + cpu_seconds = process_time() - cpu_started + simulated_until = result.get("simulatedUntil") + if isinstance(simulated_until, (int, float)) and math.isfinite( + float(simulated_until) + ): + latest_simulated_time = max( + float(simulated_until), + latest_simulated_time if latest_simulated_time is not None else -math.inf, + ) + peak_rss_bytes, peak_rss_raw, peak_rss_raw_unit = _peak_rss_bytes() + summary = summarize_simulation_result( + result, + checkpoint_times=arguments.checkpoint_time, + sample_step=arguments.sample_step, + ) + _emit_worker_event( + { + "event": "result", + "outcome": ( + "completed" + if bool(result.get("success")) + and result.get("status") == "completed" + else str(result.get("status", "failed")) + ), + "sourceSha256": actual_sha256, + "sourcePath": str(source_path), + "derivedConfiguration": { + "stopTime": arguments.stop_time, + "sampleStep": arguments.sample_step, + "maxStep": arguments.max_step, + "lane": arguments.lane, + "sourceXmlUnmodified": True, + }, + "wallSeconds": wall_seconds, + "cpuSeconds": cpu_seconds, + "peakRssBytes": peak_rss_bytes, + "peakRssRaw": peak_rss_raw, + "peakRssRawUnit": peak_rss_raw_unit, + "lastSimulatedTime": latest_simulated_time, + "runtime": runtime_snapshot(), + "summary": summary, + } + ) + return 0 + except BaseException as exc: + peak_rss_bytes, peak_rss_raw, peak_rss_raw_unit = _peak_rss_bytes() + _emit_worker_event( + { + "event": "result", + "outcome": "error", + "errorType": type(exc).__name__, + "message": str(exc), + "wallSeconds": perf_counter() - wall_started, + "cpuSeconds": process_time() - cpu_started, + "peakRssBytes": peak_rss_bytes, + "peakRssRaw": peak_rss_raw, + "peakRssRawUnit": peak_rss_raw_unit, + "lastSimulatedTime": latest_simulated_time, + "runtime": runtime_snapshot(), + } + ) + return 1 + + +def run_bounded_child_process( + command: Sequence[str], + *, + soft_timeout_seconds: float, + hard_timeout_seconds: float, + termination_grace_seconds: float, + environment: Mapping[str, str] | None = None, +) -> dict[str, object]: + """Run a JSON-lines worker with cooperative and forced timeout layers.""" + + soft_timeout = _finite_positive( + soft_timeout_seconds, field="soft_timeout_seconds" + ) + hard_timeout = _finite_positive( + hard_timeout_seconds, field="hard_timeout_seconds" + ) + grace = _finite_positive( + termination_grace_seconds, field="termination_grace_seconds" + ) + if hard_timeout <= soft_timeout: + raise ValueError("hard_timeout_seconds must exceed soft_timeout_seconds.") + + process = subprocess.Popen( + list(command), + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + bufsize=1, + env=dict(environment) if environment is not None else None, + ) + output_queue: queue.Queue[tuple[str, str | None]] = queue.Queue() + + def read_stream(name: str, stream: Any) -> None: + try: + for line in stream: + output_queue.put((name, line.rstrip("\n"))) + finally: + output_queue.put((name, None)) + + stdout_thread = threading.Thread( + target=read_stream, + args=("stdout", process.stdout), + daemon=True, + ) + stderr_thread = threading.Thread( + target=read_stream, + args=("stderr", process.stderr), + daemon=True, + ) + stdout_thread.start() + stderr_thread.start() + + started_at = monotonic() + soft_cancel_sent = False + hard_timeout_reached = False + worker_result: dict[str, object] | None = None + progress_events: list[dict[str, object]] = [] + stderr_lines: list[str] = [] + + def consume_output() -> None: + nonlocal worker_result + while True: + try: + stream_name, line = output_queue.get_nowait() + except queue.Empty: + return + if line is None: + continue + if stream_name == "stderr": + stderr_lines.append(line) + continue + try: + event = json.loads(line) + except json.JSONDecodeError: + stderr_lines.append(f"[non-json stdout] {line}") + continue + if not isinstance(event, dict): + continue + if event.get("event") == "progress": + progress_events.append(event) + elif event.get("event") == "result": + worker_result = event + + while process.poll() is None: + consume_output() + elapsed = monotonic() - started_at + if not soft_cancel_sent and elapsed >= soft_timeout: + soft_cancel_sent = True + try: + assert process.stdin is not None + process.stdin.write("cancel\n") + process.stdin.flush() + except (BrokenPipeError, OSError, ValueError): + pass + if elapsed >= hard_timeout: + hard_timeout_reached = True + process.terminate() + try: + process.wait(timeout=grace) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=grace) + break + threading.Event().wait(0.02) + + try: + process.wait(timeout=grace) + except subprocess.TimeoutExpired: + hard_timeout_reached = True + process.kill() + process.wait(timeout=grace) + consume_output() + if process.stdin is not None: + try: + process.stdin.close() + except (BrokenPipeError, OSError, ValueError): + pass + stdout_thread.join(timeout=grace) + stderr_thread.join(timeout=grace) + consume_output() + for stream in (process.stdout, process.stderr): + if stream is not None: + stream.close() + + last_simulated_time: float | None = None + for progress in progress_events: + value = progress.get("simulatedTime") + if isinstance(value, (int, float)) and math.isfinite(float(value)): + last_simulated_time = max( + float(value), + last_simulated_time if last_simulated_time is not None else -math.inf, + ) + if worker_result is not None: + value = worker_result.get("lastSimulatedTime") + if isinstance(value, (int, float)) and math.isfinite(float(value)): + last_simulated_time = max( + float(value), + last_simulated_time if last_simulated_time is not None else -math.inf, + ) + + if hard_timeout_reached: + outcome = "hard_timeout" + elif soft_cancel_sent: + outcome = "soft_timeout" + elif worker_result is None: + outcome = "worker_error" + else: + outcome = str(worker_result.get("outcome", "worker_error")) + return { + "outcome": outcome, + "softCancelSent": soft_cancel_sent, + "hardTimeoutReached": hard_timeout_reached, + "orchestrationWallSeconds": monotonic() - started_at, + "returnCode": process.returncode, + "lastSimulatedTime": last_simulated_time, + "progressEventCount": len(progress_events), + "lastProgressEvent": progress_events[-1] if progress_events else None, + "stderrTail": stderr_lines[-40:], + "worker": worker_result, + } + + +def execute_regression_case(request: RegressionCaseRequest) -> dict[str, object]: + command = [ + sys.executable, + "-u", + "-m", + "app.simulation.benchmark_regression", + "--worker", + "--xml", + str(request.source_path), + "--expected-sha256", + request.expected_sha256, + "--lane", + request.lane, + "--stop-time", + format(request.stop_time, ".17g"), + "--sample-step", + format(request.sample_step, ".17g"), + "--max-step", + format(request.max_step, ".17g"), + "--instrumentation-mode", + request.instrumentation_mode, + ] + for checkpoint in request.checkpoint_times: + command.extend(("--checkpoint-time", format(checkpoint, ".17g"))) + environment = os.environ.copy() + environment["PYTHONDONTWRITEBYTECODE"] = "1" + environment.update(dict(request.environment_overrides)) + return run_bounded_child_process( + command, + soft_timeout_seconds=request.soft_timeout_seconds, + hard_timeout_seconds=request.hard_timeout_seconds, + termination_grace_seconds=request.termination_grace_seconds, + environment=environment, + ) + + +def _completed_case(result: Mapping[str, object], stop_time: float) -> bool: + if result.get("outcome") != "completed": + return False + worker = result.get("worker") + if not isinstance(worker, Mapping): + return False + summary = worker.get("summary") + if not isinstance(summary, Mapping): + return False + simulated_until = summary.get("simulatedUntil") + return ( + bool(summary.get("success")) + and summary.get("status") == "completed" + and isinstance(simulated_until, (int, float)) + and math.isclose( + float(simulated_until), + float(stop_time), + rel_tol=0.0, + abs_tol=max(1.0e-12, 8.0 * math.ulp(max(abs(stop_time), 1.0))), + ) + ) + + +def evaluate_regression_golden( + summary: Mapping[str, object] | None, + golden: Mapping[str, object] | None, +) -> dict[str, object]: + """Compare physical values numerically and output shape by contract hash.""" + + if golden is None: + return { + "configured": False, + "evaluated": False, + "passed": None, + "issues": [], + } + provenance = golden["provenance"] + assert isinstance(provenance, Mapping) + source_report = provenance["sourceReport"] + assert isinstance(source_report, Mapping) + tolerance = golden["tolerance"] + layout = golden["physicalLayout"] + assert isinstance(tolerance, Mapping) + assert isinstance(layout, Mapping) + audit: dict[str, object] = { + "configured": True, + "evaluated": summary is not None, + "passed": False, + "goldenId": golden["id"], + "goldenSha256": golden.get("_sha256"), + "goldenPath": golden.get("_path"), + "sourceReport": { + "path": source_report.get("path"), + "sha256": source_report.get("sha256"), + "generatedAt": source_report.get("generatedAt"), + "metadataCompatibility": source_report.get("metadataCompatibility"), + }, + "relativeTolerance": float(tolerance["relative"]), + "absoluteTolerance": float(tolerance["absolute"]), + "checkpointTimeAbsoluteToleranceSeconds": float( + tolerance["checkpointTimeAbsoluteSeconds"] + ), + "stateKeyLayoutSha256": layout["stateKeyLayoutSha256"], + "expectedCheckpointCount": len(golden["physicalCheckpoints"]), + "expectedStateValueCountPerCheckpoint": len(layout["stateKeys"]), + "comparedValueCount": 0, + "maxAbsoluteError": None, + "maxToleranceRatio": None, + "worstValue": None, + "issues": [], + } + if summary is None: + audit["issues"] = ["missingSummaryForGolden"] + return audit + + physical_contract = summary.get("physicalContract") + actual_checkpoints = ( + physical_contract.get("checkpoints") + if isinstance(physical_contract, Mapping) + else summary.get("checkpoints") + ) + expected_checkpoints = golden["physicalCheckpoints"] + state_keys = layout["stateKeys"] + assert isinstance(expected_checkpoints, list) + assert isinstance(state_keys, list) + issues: list[str] = [] + compared_count = 0 + max_absolute_error = 0.0 + max_tolerance_ratio = 0.0 + worst_value: dict[str, object] | None = None + if not isinstance(actual_checkpoints, list) or len(actual_checkpoints) != len( + expected_checkpoints + ): + issues.append("stateCheckpointGoldenCountMismatch") + else: + relative_tolerance = float(tolerance["relative"]) + absolute_tolerance = float(tolerance["absolute"]) + time_tolerance = float(tolerance["checkpointTimeAbsoluteSeconds"]) + for expected_checkpoint, actual_checkpoint in zip( + expected_checkpoints, actual_checkpoints + ): + assert isinstance(expected_checkpoint, Mapping) + if not isinstance(actual_checkpoint, Mapping): + issues.append("stateCheckpointGoldenLayoutMismatch") + continue + requested_time = float(expected_checkpoint["requestedTime"]) + actual_requested = actual_checkpoint.get("requestedTime") + actual_time = actual_checkpoint.get("actualTime") + if ( + not isinstance(actual_requested, (int, float)) + or not isinstance(actual_time, (int, float)) + or not math.isclose( + float(actual_requested), + requested_time, + rel_tol=0.0, + abs_tol=time_tolerance, + ) + or not math.isclose( + float(actual_time), + requested_time, + rel_tol=0.0, + abs_tol=time_tolerance, + ) + ): + issues.append("stateCheckpointGoldenTimeMismatch") + continue + actual_values = actual_checkpoint.get("stateValues") + expected_values = expected_checkpoint["values"] + assert isinstance(expected_values, list) + if not isinstance(actual_values, Mapping) or set(actual_values) != set( + state_keys + ): + issues.append("stateCheckpointGoldenLayoutMismatch") + continue + for key, expected_value in zip(state_keys, expected_values): + actual_value = actual_values[key] + if not isinstance(actual_value, (int, float)) or not math.isfinite( + float(actual_value) + ): + issues.append("stateCheckpointGoldenNonfiniteValue") + continue + expected_numeric = float(expected_value) + actual_numeric = float(actual_value) + absolute_error = abs(actual_numeric - expected_numeric) + allowed_error = absolute_tolerance + relative_tolerance * abs( + expected_numeric + ) + tolerance_ratio = ( + absolute_error / allowed_error + if allowed_error > 0.0 + else 0.0 if absolute_error == 0.0 else math.inf + ) + compared_count += 1 + if absolute_error > max_absolute_error: + max_absolute_error = absolute_error + if tolerance_ratio > max_tolerance_ratio: + max_tolerance_ratio = tolerance_ratio + worst_value = { + "requestedTime": requested_time, + "key": key, + "expected": expected_numeric, + "actual": actual_numeric, + "absoluteError": absolute_error, + "allowedError": allowed_error, + } + if tolerance_ratio > 1.0: + issues.append("stateCheckpointGoldenValueMismatch") + + expected_output_contract = golden.get("outputContract") + actual_output_contract = summary.get("outputContract") + if expected_output_contract is not None: + if not isinstance(actual_output_contract, Mapping): + issues.append("missingOutputContract") + elif actual_output_contract.get("sha256") != expected_output_contract.get( + "sha256" + ): + issues.append("outputContractMismatch") + deduplicated_issues = list(dict.fromkeys(issues)) + audit.update( + { + "passed": not deduplicated_issues, + "comparedValueCount": compared_count, + "maxAbsoluteError": max_absolute_error if compared_count else None, + "maxToleranceRatio": max_tolerance_ratio if compared_count else None, + "worstValue": worst_value, + "issues": deduplicated_issues, + "outputContractExpected": ( + dict(expected_output_contract) + if isinstance(expected_output_contract, Mapping) + else None + ), + "outputContractActual": ( + dict(actual_output_contract) + if isinstance(actual_output_contract, Mapping) + else None + ), + } + ) + return audit + + +def _case_correctness_issues( + result: Mapping[str, object], + *, + variant: Mapping[str, object], + correctness: Mapping[str, object], + golden_evaluation: Mapping[str, object] | None = None, +) -> tuple[str, ...]: + """Evaluate structural/event checks and an optional reviewed golden.""" + + worker = result.get("worker") + summary = worker.get("summary") if isinstance(worker, Mapping) else None + if not isinstance(summary, Mapping): + return ("missingWorkerSummary",) + issues: list[str] = [] + health = summary.get("seriesHealth") + if bool(correctness.get("requireFiniteSeries", False)): + if not isinstance(health, Mapping): + issues.append("missingSeriesHealth") + elif int(health.get("nonfiniteCount", -1)) != 0: + issues.append("nonfiniteSeries") + if bool(correctness.get("requireStrictlyIncreasingTimes", False)): + if not isinstance(health, Mapping) or not bool( + health.get("timeStrictlyIncreasing", False) + ): + issues.append("sampleTimesNotStrictlyIncreasing") + stop_time = float(variant["stopTime"]) + if not isinstance(health, Mapping) or not isinstance( + health.get("seriesCount"), int + ) or int(health["seriesCount"]) <= 0: + issues.append("emptySeries") + elif not isinstance(health.get("timeEnd"), (int, float)) or not math.isclose( + float(health["timeEnd"]), + stop_time, + rel_tol=0.0, + abs_tol=max(1.0e-12, 8.0 * math.ulp(max(abs(stop_time), 1.0))), + ): + issues.append("seriesDoesNotReachStopTime") + + checkpoints = summary.get("checkpoints") + expected_checkpoint_times = variant.get("checkpointTimes", []) + if not isinstance(checkpoints, list) or not isinstance( + expected_checkpoint_times, list + ): + issues.append("missingStateCheckpoints") + elif len(checkpoints) != len(expected_checkpoint_times) or any( + not isinstance(checkpoint, Mapping) + or not bool(checkpoint.get("available", False)) + for checkpoint in checkpoints + ): + issues.append("stateCheckpointUnavailable") + elif any( + not isinstance(checkpoint.get("stateValues"), Mapping) + or not checkpoint["stateValues"] + for checkpoint in checkpoints + if isinstance(checkpoint, Mapping) + ): + issues.append("stateCheckpointValuesMissing") + + maximum_residual = correctness.get("maximumScaledResidual") + diagnostics = summary.get("diagnostics") + pressure_flow = ( + diagnostics.get("pressureFlow") + if isinstance(diagnostics, Mapping) + else None + ) + observed_residual = ( + pressure_flow.get("maxScaledResidual") + if isinstance(pressure_flow, Mapping) + else None + ) + if isinstance(maximum_residual, (int, float)): + if not isinstance(observed_residual, (int, float)) or not math.isfinite( + float(observed_residual) + ): + issues.append("missingOrNonfiniteScaledResidual") + elif float(observed_residual) > float(maximum_residual): + issues.append("scaledResidualExceedsLimit") + + expected_signal_times = variant.get("expectedSignalEventTimes", []) + event_trace = summary.get("eventTrace") + actual_signal_times = ( + event_trace.get("signalEventTimes") + if isinstance(event_trace, Mapping) + else None + ) + signal_tolerance = float( + correctness.get("signalEventTimeAbsoluteToleranceSeconds", 1.0e-12) + ) + if not isinstance(expected_signal_times, list) or not isinstance( + actual_signal_times, list + ): + issues.append("missingSignalEventTrace") + elif len(expected_signal_times) != len(actual_signal_times) or any( + not isinstance(actual, (int, float)) + or not math.isclose( + float(actual), + float(expected), + rel_tol=0.0, + abs_tol=signal_tolerance, + ) + for expected, actual in zip(expected_signal_times, actual_signal_times) + ): + issues.append("signalEventTraceMismatch") + segments = event_trace.get("segments") if isinstance(event_trace, Mapping) else None + segment_start_times = ( + [ + segment.get("startTime") + for segment in segments + if isinstance(segment, Mapping) + ] + if isinstance(segments, list) + else [] + ) + if isinstance(expected_signal_times, list) and any( + not any( + isinstance(actual, (int, float)) + and math.isclose( + float(actual), + float(expected), + rel_tol=0.0, + abs_tol=signal_tolerance, + ) + for actual in segment_start_times + ) + for expected in expected_signal_times + ): + issues.append("signalEventSegmentMissing") + + if bool(correctness.get("mechanicalTransitionTimesAvailable", False)): + if not isinstance(event_trace, Mapping) or not bool( + event_trace.get("mechanicalTransitionTimesAvailable", False) + ): + issues.append("mechanicalTransitionTimesUnavailable") + expected_mechanical_times = variant.get("expectedMechanicalTransitionTimes") + if isinstance(expected_mechanical_times, list): + actual_mechanical_times = ( + event_trace.get("mechanicalTransitionTimes") + if isinstance(event_trace, Mapping) + else None + ) + event_tolerance = float( + correctness.get("eventTimeAbsoluteToleranceSeconds", 2.0e-5) + ) + if not isinstance(actual_mechanical_times, list) or len( + actual_mechanical_times + ) != len(expected_mechanical_times) or any( + not isinstance(actual, (int, float)) + or not math.isclose( + float(actual), + float(expected), + rel_tol=0.0, + abs_tol=event_tolerance, + ) + for expected, actual in zip( + expected_mechanical_times, actual_mechanical_times + ) + ): + issues.append("mechanicalTransitionTraceMismatch") + if isinstance(golden_evaluation, Mapping): + raw_golden_issues = golden_evaluation.get("issues") + if isinstance(raw_golden_issues, list): + issues.extend( + str(issue) for issue in raw_golden_issues if isinstance(issue, str) + ) + return tuple(dict.fromkeys(issues)) + + +def _case_wall_seconds(result: Mapping[str, object]) -> float | None: + worker = result.get("worker") + if isinstance(worker, Mapping): + value = worker.get("wallSeconds") + if isinstance(value, (int, float)) and float(value) > 0.0: + return float(value) + value = result.get("orchestrationWallSeconds") + if isinstance(value, (int, float)) and float(value) > 0.0: + return float(value) + return None + + +def run_regression_suite( + manifest_path: Path | str = DEFAULT_MANIFEST_PATH, + *, + lane: str = "production", + case_ids: Sequence[str] | None = None, + case_executor: CaseExecutor = execute_regression_case, +) -> dict[str, object]: + """Run requested horizons in order, deferring unsafe downstream work.""" + + manifest = load_regression_manifest(manifest_path) + lanes = manifest["lanes"] + assert isinstance(lanes, dict) + if lane not in lanes: + raise RegressionManifestError(f"Unknown regression lane {lane!r}.") + lane_config = lanes[lane] + assert isinstance(lane_config, dict) + variants = manifest["variants"] + assert isinstance(variants, dict) + sequence = manifest["sequence"] + assert isinstance(sequence, list) + if case_ids is not None: + requested = set(case_ids) + if not requested: + raise RegressionManifestError("No regression variants were selected.") + unknown = requested - set(sequence) + if unknown: + raise RegressionManifestError( + f"Unknown requested variants: {', '.join(sorted(unknown))}." + ) + last_requested_index = max(sequence.index(case_id) for case_id in requested) + selected_sequence = list(sequence[: last_requested_index + 1]) + else: + selected_sequence = list(sequence) + source_path = Path(str(manifest["_sourcePath"])) + source_payload = source_path.read_bytes() + source_config = source_simulation_config(source_payload) + sampling_mode = lane_config["samplingMode"] + sample_step = ( + float(source_config["sampleStep"]) + if sampling_mode == "source" + else float(lane_config["sampleStep"]) + ) + max_step_mode = lane_config.get("maxStepMode", "source") + max_step = ( + float(source_config["maxStep"]) + if max_step_mode == "source" + else float(lane_config["maxStep"]) + ) + execution = manifest["execution"] + assert isinstance(execution, dict) + safety_factor = float(execution.get("predictionSafetyFactor", 1.5)) + termination_grace = float(execution.get("terminationGraceSeconds", 5.0)) + instrumentation_mode = str( + lane_config.get("instrumentationMode", "standard") + ) + expected_sha256 = str(manifest["source"]["sha256"]) # type: ignore[index] + raw_environment = execution.get("environment", {}) + if not isinstance(raw_environment, dict) or not all( + isinstance(key, str) and isinstance(value, str) + for key, value in raw_environment.items() + ): + raise RegressionManifestError("execution.environment must map strings to strings.") + environment_overrides = tuple(sorted(raw_environment.items())) + correctness = manifest["correctness"] + assert isinstance(correctness, dict) + loaded_goldens = manifest.get("_goldens", {}) + assert isinstance(loaded_goldens, dict) + + case_reports: list[dict[str, object]] = [] + predecessor_completed = True + previous_stop: float | None = None + previous_wall: float | None = None + deferral_reason: str | None = None + + for case_id in selected_sequence: + variant = variants[case_id] + assert isinstance(variant, dict) + stop_time = float(variant["stopTime"]) + soft_timeout = float(variant["softTimeoutSeconds"]) + hard_timeout = float(variant["hardTimeoutSeconds"]) + runtime_prediction_eligible = bool( + variant.get("useForRuntimePrediction", True) + ) + predicted_wall: float | None = None + if previous_stop is not None and previous_wall is not None: + predicted_wall = ( + previous_wall * stop_time / previous_stop * safety_factor + ) + if not predecessor_completed: + deferral_reason = deferral_reason or "predecessorDidNotComplete" + elif predicted_wall is not None and predicted_wall > soft_timeout: + predecessor_completed = False + deferral_reason = "predictedWallExceedsSoftBudget" + + if not predecessor_completed: + case_reports.append( + { + "caseId": case_id, + "stopTime": stop_time, + "sampleStep": sample_step, + "maxStep": max_step, + "lane": lane, + "outcome": "deferred", + "reason": deferral_reason, + "predictedWallSeconds": predicted_wall, + "softTimeoutSeconds": soft_timeout, + "hardTimeoutSeconds": hard_timeout, + "runtimePredictionEligible": runtime_prediction_eligible, + } + ) + continue + + request = RegressionCaseRequest( + case_id=case_id, + source_path=source_path, + expected_sha256=expected_sha256, + lane=lane, + stop_time=stop_time, + sample_step=sample_step, + max_step=max_step, + checkpoint_times=tuple( + float(value) for value in variant.get("checkpointTimes", []) + ), + soft_timeout_seconds=soft_timeout, + hard_timeout_seconds=hard_timeout, + termination_grace_seconds=termination_grace, + instrumentation_mode=instrumentation_mode, + environment_overrides=environment_overrides, + ) + result = case_executor(request) + solver_completed = _completed_case(result, stop_time) + worker = result.get("worker") + summary = worker.get("summary") if isinstance(worker, Mapping) else None + case_goldens = loaded_goldens.get(case_id, {}) + golden = ( + case_goldens.get(lane) + if isinstance(case_goldens, Mapping) + else None + ) + golden_evaluation = evaluate_regression_golden( + summary if isinstance(summary, Mapping) else None, + golden if isinstance(golden, Mapping) else None, + ) + correctness_issues = ( + _case_correctness_issues( + result, + variant=variant, + correctness=correctness, + golden_evaluation=golden_evaluation, + ) + if solver_completed + else () + ) + report = { + "caseId": case_id, + "stopTime": stop_time, + "sampleStep": sample_step, + "maxStep": max_step, + "lane": lane, + "samplingMode": sampling_mode, + "maxStepMode": max_step_mode, + "runtimePredictionEligible": runtime_prediction_eligible, + "predictedWallSeconds": predicted_wall, + "softTimeoutSeconds": soft_timeout, + "hardTimeoutSeconds": hard_timeout, + **result, + "acceptance": { + "evaluated": solver_completed, + "passed": solver_completed and not correctness_issues, + "issues": list(correctness_issues), + "regressionGolden": golden_evaluation, + }, + } + if solver_completed and correctness_issues: + report["outcome"] = "correctness_failed" + case_reports.append(report) + predecessor_completed = solver_completed and not correctness_issues + if predecessor_completed: + if runtime_prediction_eligible: + previous_stop = stop_time + previous_wall = _case_wall_seconds(result) + else: + deferral_reason = "predecessorDidNotComplete" + + public_manifest = { + key: value + for key, value in manifest.items() + if not key.startswith("_") + } + return { + "schemaVersion": REPORT_SCHEMA_VERSION, + "generatedAt": datetime.now(UTC).isoformat(), + "manifestId": manifest.get("id"), + "manifestPath": str(manifest["_manifestPath"]), + "lane": lane, + "laneDescription": lane_config.get("description"), + "source": { + **dict(manifest["source"]), # type: ignore[arg-type] + "resolvedPath": str(source_path), + "companionResolvedPath": manifest.get("_companionPath"), + "bytes": len(source_payload), + "simulation": source_config, + }, + "manifest": public_manifest, + "cases": case_reports, + } + + +def _parse_arguments(argv: Sequence[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Run progressively bounded System XML regression horizons." + ) + parser.add_argument("--manifest", type=Path, default=DEFAULT_MANIFEST_PATH) + parser.add_argument( + "--lane", + default="production", + help="Manifest lane: production runs approved correctness contracts; solver-only is an explicit coarse-output iteration lane.", + ) + parser.add_argument("--case", action="append", default=[]) + parser.add_argument("--output", type=Path) + parser.add_argument("--worker", action="store_true", help=argparse.SUPPRESS) + parser.add_argument("--xml", help=argparse.SUPPRESS) + parser.add_argument("--expected-sha256", help=argparse.SUPPRESS) + parser.add_argument("--stop-time", type=float, help=argparse.SUPPRESS) + parser.add_argument("--sample-step", type=float, help=argparse.SUPPRESS) + parser.add_argument("--max-step", type=float, help=argparse.SUPPRESS) + parser.add_argument( + "--checkpoint-time", + type=float, + action="append", + default=[], + help=argparse.SUPPRESS, + ) + parser.add_argument( + "--instrumentation-mode", + choices=("off", "standard", "audit"), + default="standard", + help=argparse.SUPPRESS, + ) + arguments = parser.parse_args(argv) + if arguments.worker: + missing = [ + name + for name in ( + "xml", + "expected_sha256", + "stop_time", + "sample_step", + "max_step", + ) + if getattr(arguments, name) is None + ] + if missing: + parser.error("Worker arguments missing: " + ", ".join(missing)) + return arguments + + +def main(argv: Sequence[str] | None = None) -> int: + arguments = _parse_arguments(argv) + if arguments.worker: + return run_worker(arguments) + report = run_regression_suite( + arguments.manifest, + lane=arguments.lane, + case_ids=arguments.case or None, + ) + serialized = json.dumps(report, ensure_ascii=False, indent=2, default=str) + "\n" + if arguments.output is None: + print(serialized, end="") + else: + arguments.output.parent.mkdir(parents=True, exist_ok=True) + arguments.output.write_text(serialized, encoding="utf-8") + print(f"Regression report written to {arguments.output.resolve()}") + outcomes = [case.get("outcome") for case in report["cases"]] + if outcomes and all(outcome == "completed" for outcome in outcomes): + return 0 + if outcomes and all( + outcome in {"completed", "deferred"} for outcome in outcomes + ): + return 2 + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/app/simulation/components/amesim/flow/pipes.py b/app/simulation/components/amesim/flow/pipes.py index 8adb2b4..1330ae8 100644 --- a/app/simulation/components/amesim/flow/pipes.py +++ b/app/simulation/components/amesim/flow/pipes.py @@ -189,6 +189,15 @@ class AmesimPnl00r(AlgebraicComponent): self.port_1.h_outflow = initial_h self.port_2 = self.register_declared_port("port_2") self.port_2.h_outflow = initial_h + # A zero-volume two-port transports its stream outflow from the + # opposite connection, so ``port.h_outflow`` is deliberately crossed. + # Pressure loss, however, needs the enthalpy arriving at the same-side + # upstream connection. Keep that reference separate from the public + # connector outflow state. + self._connected_h = { + "port_1": initial_h, + "port_2": initial_h, + } @staticmethod def _integer_parameter(name: str, value: float) -> int: @@ -219,7 +228,7 @@ class AmesimPnl00r(AlgebraicComponent): return max( self.medium.temperature_from_pressure_enthalpy( max(port.p, 1.0), - port.h_outflow, + self._connected_h[port_name], ), 1.0, ) @@ -388,9 +397,16 @@ class AmesimPnl00r(AlgebraicComponent): ) def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None: + self._connected_h = dict(connected_h) self.port_1.h_outflow = connected_h["port_2"] self.port_2.h_outflow = connected_h["port_1"] + def update_flow_temperature_references( + self, + connected_h: Mapping[str, float], + ) -> None: + self._connected_h = dict(connected_h) + class AmesimPnl0001(ThermodynamicVolumeComponent): """AMESim PNL0001 C-R pneumatic pipe with compressibility and friction.""" diff --git a/app/simulation/max_step_matrix.py b/app/simulation/max_step_matrix.py new file mode 100644 index 0000000..6097aff --- /dev/null +++ b/app/simulation/max_step_matrix.py @@ -0,0 +1,1179 @@ +"""Reproducible horizon/max-step matrix runs for one authoritative System XML. + +The module deliberately delegates every simulation to +``execute_regression_case``. Consequently each cell inherits the existing +soft-cancel/hard-termination boundary and derives its XML in child-process +memory; the source XML is never rewritten. + +The two comparison axes answer different questions: + +* same horizon, different maximum steps: ordinary step-size sensitivity; +* same maximum step, different horizons: prefix invariance with respect to + ``tStop`` (a longer request should not change already-reached checkpoints). + +This is a diagnostic matrix, not a golden generator. Numerical state values +are retained in its JSON evidence but are never written back to a manifest. +""" + +from __future__ import annotations + +import argparse +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass +from datetime import UTC, datetime +from itertools import combinations +import json +import math +from pathlib import Path + +from app.simulation.benchmark_regression import ( + DEFAULT_MANIFEST_PATH, + RegressionCaseRequest, + RegressionManifestError, + execute_regression_case, + load_regression_manifest, + source_simulation_config, +) + + +MATRIX_REPORT_SCHEMA_VERSION = 1 +DEFAULT_HORIZON_CASE_IDS = ("1s", "5s", "10s") +DEFAULT_COMMON_CHECKPOINT_TIMES = (0.0, 0.04, 0.8, 1.0) +DEFAULT_EXPECTED_PROJECTION_COUNT = 134 + +MatrixCaseExecutor = Callable[[RegressionCaseRequest], dict[str, object]] + + +@dataclass(frozen=True) +class MatrixTolerance: + state_relative: float + state_absolute: float + checkpoint_time_absolute_seconds: float + event_time_absolute_seconds: float + signal_event_time_absolute_seconds: float + maximum_scaled_residual: float | None + + +@dataclass(frozen=True) +class MatrixHorizon: + case_id: str + source_variant_id: str | None + stop_time: float + checkpoint_times: tuple[float, ...] + expected_signal_event_times: tuple[float, ...] + soft_timeout_seconds: float + hard_timeout_seconds: float + + +def _finite_positive(value: object, *, field: str) -> float: + try: + numeric = float(value) + except (TypeError, ValueError) as exc: + raise RegressionManifestError(f"{field} must be numeric.") from exc + if not math.isfinite(numeric) or numeric <= 0.0: + raise RegressionManifestError(f"{field} must be finite and positive.") + return numeric + + +def _finite_nonnegative(value: object, *, field: str) -> float: + try: + numeric = float(value) + except (TypeError, ValueError) as exc: + raise RegressionManifestError(f"{field} must be numeric.") from exc + if not math.isfinite(numeric) or numeric < 0.0: + raise RegressionManifestError( + f"{field} must be finite and non-negative." + ) + return numeric + + +def _matrix_tolerance(manifest: Mapping[str, object]) -> MatrixTolerance: + correctness = manifest.get("correctness") + if not isinstance(correctness, Mapping): + raise RegressionManifestError("Manifest correctness must be an object.") + maximum_residual = correctness.get("maximumScaledResidual") + return MatrixTolerance( + state_relative=_finite_nonnegative( + correctness.get("stateRelativeTolerance", 0.0), + field="correctness.stateRelativeTolerance", + ), + state_absolute=_finite_nonnegative( + correctness.get("stateAbsoluteTolerance", 0.0), + field="correctness.stateAbsoluteTolerance", + ), + checkpoint_time_absolute_seconds=_finite_nonnegative( + correctness.get("checkpointTimeAbsoluteToleranceSeconds", 1.0e-12), + field="correctness.checkpointTimeAbsoluteToleranceSeconds", + ), + event_time_absolute_seconds=_finite_nonnegative( + correctness.get("eventTimeAbsoluteToleranceSeconds", 0.0), + field="correctness.eventTimeAbsoluteToleranceSeconds", + ), + signal_event_time_absolute_seconds=_finite_nonnegative( + correctness.get("signalEventTimeAbsoluteToleranceSeconds", 0.0), + field="correctness.signalEventTimeAbsoluteToleranceSeconds", + ), + maximum_scaled_residual=( + _finite_nonnegative( + maximum_residual, + field="correctness.maximumScaledResidual", + ) + if maximum_residual is not None + else None + ), + ) + + +def _resolve_horizons( + manifest: Mapping[str, object], + requested: Sequence[str | float], + *, + additional_checkpoint_times: Sequence[float], +) -> tuple[MatrixHorizon, ...]: + variants = manifest.get("variants") + sequence = manifest.get("sequence") + if not isinstance(variants, Mapping) or not isinstance(sequence, list): + raise RegressionManifestError("Manifest variants/sequence are incomplete.") + extra_checkpoints = tuple( + _finite_nonnegative(value, field=f"additionalCheckpointTimes[{index}]") + for index, value in enumerate(additional_checkpoint_times) + ) + ordered_variants: list[tuple[str, Mapping[str, object]]] = [] + for raw_case_id in sequence: + variant = variants.get(str(raw_case_id)) + if isinstance(variant, Mapping): + ordered_variants.append((str(raw_case_id), variant)) + selected: list[MatrixHorizon] = [] + for raw_request in requested: + request = str(raw_request).strip() + if request in variants: + source_variant_id: str | None = request + variant = variants[request] + assert isinstance(variant, Mapping) + stop_time = float(variant["stopTime"]) + else: + try: + stop_time = float(request.removesuffix("s")) + except ValueError as exc: + raise RegressionManifestError( + f"Unknown matrix horizon {raw_request!r}." + ) from exc + stop_time = _finite_positive(stop_time, field=f"horizon[{request}]") + matches = [ + (str(candidate), variant) + for candidate, variant in variants.items() + if isinstance(variant, Mapping) + and isinstance(variant.get("stopTime"), (int, float)) + and math.isclose( + float(variant["stopTime"]), + stop_time, + rel_tol=0.0, + abs_tol=max(1.0e-12, 8.0 * math.ulp(max(1.0, abs(stop_time)))), + ) + ] + if len(matches) > 1: + raise RegressionManifestError( + f"Matrix horizon {raw_request!r} identifies multiple variants." + ) + if matches: + source_variant_id, variant = matches[0] + else: + source_variant_id = None + variant = None + case_id = source_variant_id or f"{format(stop_time, '.12g')}s-custom" + if any(item.case_id == case_id for item in selected): + raise RegressionManifestError( + f"Matrix horizon {case_id!r} was selected more than once." + ) + + timeout_variant = variant + if timeout_variant is None: + enclosing = [ + candidate + for _, candidate in ordered_variants + if float(candidate["stopTime"]) >= stop_time + ] + timeout_variant = enclosing[0] if enclosing else ordered_variants[-1][1] + raw_checkpoints = ( + variant.get("checkpointTimes", []) + if isinstance(variant, Mapping) + else [] + ) + checkpoint_candidates = [ + *DEFAULT_COMMON_CHECKPOINT_TIMES, + *( + float(value) + for value in raw_checkpoints + if isinstance(value, (int, float)) + ), + *extra_checkpoints, + stop_time, + ] + checkpoint_times = tuple( + sorted( + { + float(value) + for value in checkpoint_candidates + if 0.0 <= float(value) <= stop_time + } + ) + ) + if isinstance(variant, Mapping) and isinstance( + variant.get("expectedSignalEventTimes"), list + ): + expected_signal_times = tuple( + float(value) for value in variant["expectedSignalEventTimes"] + ) + else: + expected_signal_times = tuple( + sorted( + { + float(value) + for _, candidate in ordered_variants + for value in candidate.get("expectedSignalEventTimes", []) + if isinstance(value, (int, float)) + and float(value) <= stop_time + } + ) + ) + selected.append( + MatrixHorizon( + case_id=case_id, + source_variant_id=source_variant_id, + stop_time=stop_time, + checkpoint_times=checkpoint_times, + expected_signal_event_times=expected_signal_times, + soft_timeout_seconds=_finite_positive( + timeout_variant.get("softTimeoutSeconds"), + field=f"horizon[{request}].softTimeoutSeconds", + ), + hard_timeout_seconds=_finite_positive( + timeout_variant.get("hardTimeoutSeconds"), + field=f"horizon[{request}].hardTimeoutSeconds", + ), + ) + ) + if not selected: + raise RegressionManifestError("At least one matrix horizon is required.") + stop_times = [item.stop_time for item in selected] + if any(right <= left for left, right in zip(stop_times, stop_times[1:])): + raise RegressionManifestError( + "Matrix horizons must have strictly increasing stop times." + ) + return tuple(selected) + + +def _normalise_max_steps(max_steps: Sequence[float]) -> tuple[float, ...]: + values = tuple( + _finite_positive(value, field=f"maxSteps[{index}]") + for index, value in enumerate(max_steps) + ) + if not values: + raise RegressionManifestError("At least one maximum step is required.") + if len(set(values)) != len(values): + raise RegressionManifestError("Maximum steps must be unique.") + return values + + +def _summary(case: Mapping[str, object]) -> Mapping[str, object] | None: + worker = case.get("worker") + if not isinstance(worker, Mapping): + return None + summary = worker.get("summary") + return summary if isinstance(summary, Mapping) else None + + +def _completed(case: Mapping[str, object], stop_time: float) -> bool: + summary = _summary(case) + simulated_until = summary.get("simulatedUntil") if summary is not None else None + return ( + case.get("outcome") == "completed" + and summary is not None + and bool(summary.get("success")) + and summary.get("status") == "completed" + and isinstance(simulated_until, (int, float)) + and math.isclose( + float(simulated_until), + stop_time, + rel_tol=0.0, + abs_tol=max(1.0e-12, 8.0 * math.ulp(max(1.0, abs(stop_time)))), + ) + ) + + +def _checkpoints(summary: Mapping[str, object] | None) -> list[Mapping[str, object]]: + if summary is None: + return [] + contract = summary.get("physicalContract") + raw = ( + contract.get("checkpoints") + if isinstance(contract, Mapping) + else summary.get("checkpoints") + ) + if not isinstance(raw, list): + return [] + return [checkpoint for checkpoint in raw if isinstance(checkpoint, Mapping)] + + +def _event_trace(summary: Mapping[str, object] | None) -> Mapping[str, object]: + if summary is None: + return {} + contract = summary.get("physicalContract") + raw = ( + contract.get("eventTrace") + if isinstance(contract, Mapping) + else summary.get("eventTrace") + ) + return raw if isinstance(raw, Mapping) else {} + + +def _diagnostics(summary: Mapping[str, object] | None) -> Mapping[str, object]: + if summary is None: + return {} + raw = summary.get("diagnostics") + return raw if isinstance(raw, Mapping) else {} + + +def _scaled_residual(summary: Mapping[str, object] | None) -> float | None: + pressure_flow = _diagnostics(summary).get("pressureFlow") + value = ( + pressure_flow.get("maxScaledResidual") + if isinstance(pressure_flow, Mapping) + else None + ) + if isinstance(value, (int, float)) and math.isfinite(float(value)): + return float(value) + return None + + +def _integration_totals(summary: Mapping[str, object] | None) -> dict[str, float]: + integration = _diagnostics(summary).get("integration") + totals = integration.get("totals") if isinstance(integration, Mapping) else None + if not isinstance(totals, Mapping): + return {} + return { + str(key): float(value) + for key, value in totals.items() + if isinstance(value, (int, float)) and math.isfinite(float(value)) + } + + +def _finite_event_times(value: object) -> list[float] | None: + if not isinstance(value, list): + return None + if any( + not isinstance(item, (int, float)) or not math.isfinite(float(item)) + for item in value + ): + return None + return [float(item) for item in value] + + +def _case_observation( + case: Mapping[str, object], + *, + stop_time: float, + expected_projection_count: int | None, + tolerance: MatrixTolerance, + expected_signal_event_times: Sequence[float] | None, +) -> tuple[dict[str, object], tuple[str, ...]]: + summary = _summary(case) + checkpoints = _checkpoints(summary) + checkpoint_details: list[dict[str, object]] = [] + issues: list[str] = [] + for checkpoint in checkpoints: + values = checkpoint.get("stateValues") + state_values = values if isinstance(values, Mapping) else {} + nonfinite = [ + str(key) + for key, value in state_values.items() + if not isinstance(value, (int, float)) or not math.isfinite(float(value)) + ] + detail = { + "requestedTime": checkpoint.get("requestedTime"), + "actualTime": checkpoint.get("actualTime"), + "available": bool(checkpoint.get("available")), + "projectionKeyCount": len(state_values), + "nonfiniteKeyCount": len(nonfinite), + } + checkpoint_details.append(detail) + if not detail["available"]: + issues.append("checkpointUnavailable") + if expected_projection_count is not None and len(state_values) != int( + expected_projection_count + ): + issues.append("projectionCountMismatch") + if nonfinite: + issues.append("nonfiniteProjection") + if not checkpoints: + issues.append("checkpointsUnavailable") + + residual = _scaled_residual(summary) + if tolerance.maximum_scaled_residual is not None and ( + residual is None or residual > tolerance.maximum_scaled_residual + ): + issues.append("scaledResidualExceeded") + + events = _event_trace(summary) + signal_times = _finite_event_times(events.get("signalEventTimes")) + if expected_signal_event_times is not None: + expected = [float(value) for value in expected_signal_event_times] + if signal_times is None or len(signal_times) != len(expected) or any( + not math.isclose( + actual, + wanted, + rel_tol=0.0, + abs_tol=tolerance.signal_event_time_absolute_seconds, + ) + for actual, wanted in zip(signal_times or (), expected) + ): + issues.append("signalEventTraceMismatch") + if events.get("mechanicalTransitionTimesAvailable") is False: + issues.append("mechanicalTransitionTimesUnavailable") + + if not _completed(case, stop_time): + issues.insert(0, "simulationDidNotComplete") + unique_issues = tuple(dict.fromkeys(issues)) + return ( + { + "completed": _completed(case, stop_time), + "checkpointCount": len(checkpoints), + "expectedProjectionCount": expected_projection_count, + "checkpoints": checkpoint_details, + "events": dict(events), + "maximumScaledResidual": residual, + "integrationTotals": _integration_totals(summary), + "passed": not unique_issues, + "issues": list(unique_issues), + }, + unique_issues, + ) + + +def _pair_checkpoints( + left: Mapping[str, object], + right: Mapping[str, object], + *, + tolerance: MatrixTolerance, +) -> dict[str, object]: + left_checkpoints = _checkpoints(_summary(left)) + right_checkpoints = _checkpoints(_summary(right)) + paired: list[tuple[Mapping[str, object], Mapping[str, object]]] = [] + for left_checkpoint in left_checkpoints: + left_time = left_checkpoint.get("requestedTime") + if not isinstance(left_time, (int, float)): + continue + match = next( + ( + right_checkpoint + for right_checkpoint in right_checkpoints + if isinstance(right_checkpoint.get("requestedTime"), (int, float)) + and math.isclose( + float(right_checkpoint["requestedTime"]), + float(left_time), + rel_tol=0.0, + abs_tol=tolerance.checkpoint_time_absolute_seconds, + ) + ), + None, + ) + if match is not None: + paired.append((left_checkpoint, match)) + + comparisons: list[dict[str, object]] = [] + mismatch_count = 0 + key_set_mismatch_count = 0 + nonnumeric_count = 0 + maximum_absolute_difference = 0.0 + maximum_relative_difference = 0.0 + for left_checkpoint, right_checkpoint in paired: + left_values = left_checkpoint.get("stateValues") + right_values = right_checkpoint.get("stateValues") + left_mapping = left_values if isinstance(left_values, Mapping) else {} + right_mapping = right_values if isinstance(right_values, Mapping) else {} + left_keys = set(map(str, left_mapping)) + right_keys = set(map(str, right_mapping)) + missing_from_left = sorted(right_keys - left_keys) + missing_from_right = sorted(left_keys - right_keys) + key_set_mismatch_count += len(missing_from_left) + len(missing_from_right) + mismatches: list[dict[str, object]] = [] + for key in sorted(left_keys & right_keys): + left_value = left_mapping.get(key) + right_value = right_mapping.get(key) + if not isinstance(left_value, (int, float)) or not isinstance( + right_value, (int, float) + ) or not math.isfinite(float(left_value)) or not math.isfinite( + float(right_value) + ): + nonnumeric_count += 1 + mismatches.append( + {"key": key, "left": left_value, "right": right_value} + ) + continue + left_numeric = float(left_value) + right_numeric = float(right_value) + absolute = abs(left_numeric - right_numeric) + denominator = max(abs(left_numeric), abs(right_numeric)) + relative = absolute / denominator if denominator else 0.0 + maximum_absolute_difference = max(maximum_absolute_difference, absolute) + maximum_relative_difference = max(maximum_relative_difference, relative) + if not math.isclose( + left_numeric, + right_numeric, + rel_tol=tolerance.state_relative, + abs_tol=tolerance.state_absolute, + ): + mismatches.append( + { + "key": key, + "left": left_numeric, + "right": right_numeric, + "absoluteDifference": absolute, + "relativeDifference": relative, + } + ) + mismatch_count += len(mismatches) + comparisons.append( + { + "requestedTime": left_checkpoint.get("requestedTime"), + "leftProjectionKeyCount": len(left_keys), + "rightProjectionKeyCount": len(right_keys), + "missingFromLeft": missing_from_left, + "missingFromRight": missing_from_right, + "valueMismatchCount": len(mismatches), + "mismatches": mismatches, + } + ) + passed = bool(paired) and not ( + mismatch_count or key_set_mismatch_count or nonnumeric_count + ) + return { + "evaluated": bool(paired), + "passed": passed, + "commonCheckpointCount": len(paired), + "commonCheckpointTimes": [ + pair[0].get("requestedTime") for pair in paired + ], + "valueMismatchCount": mismatch_count, + "keySetMismatchCount": key_set_mismatch_count, + "nonnumericValueCount": nonnumeric_count, + "maximumAbsoluteDifference": maximum_absolute_difference, + "maximumRelativeDifference": maximum_relative_difference, + "checkpoints": comparisons, + } + + +def _compare_time_sequences( + left: object, + right: object, + *, + prefix_stop: float, + absolute_tolerance: float, +) -> dict[str, object]: + left_times = _finite_event_times(left) + right_times = _finite_event_times(right) + if left_times is None or right_times is None: + return { + "available": False, + "passed": False, + "left": left, + "right": right, + } + left_prefix = [ + value for value in left_times if value <= prefix_stop + absolute_tolerance + ] + right_prefix = [ + value for value in right_times if value <= prefix_stop + absolute_tolerance + ] + passed = len(left_prefix) == len(right_prefix) and all( + math.isclose( + left_value, + right_value, + rel_tol=0.0, + abs_tol=absolute_tolerance, + ) + for left_value, right_value in zip(left_prefix, right_prefix) + ) + return { + "available": True, + "passed": passed, + "left": left_prefix, + "right": right_prefix, + } + + +def _pair_events( + left: Mapping[str, object], + right: Mapping[str, object], + *, + prefix_stop: float, + tolerance: MatrixTolerance, +) -> dict[str, object]: + left_trace = _event_trace(_summary(left)) + right_trace = _event_trace(_summary(right)) + signal = _compare_time_sequences( + left_trace.get("signalEventTimes"), + right_trace.get("signalEventTimes"), + prefix_stop=prefix_stop, + absolute_tolerance=tolerance.signal_event_time_absolute_seconds, + ) + mechanical = _compare_time_sequences( + left_trace.get("mechanicalTransitionTimes"), + right_trace.get("mechanicalTransitionTimes"), + prefix_stop=prefix_stop, + absolute_tolerance=tolerance.event_time_absolute_seconds, + ) + availability = ( + left_trace.get("mechanicalTransitionTimesAvailable") is not False + and right_trace.get("mechanicalTransitionTimesAvailable") is not False + ) + return { + "evaluated": bool(left_trace) and bool(right_trace), + "passed": bool(signal["passed"] and mechanical["passed"] and availability), + "prefixStopTime": prefix_stop, + "signalEventTimes": signal, + "mechanicalTransitionTimes": mechanical, + "mechanicalTransitionTimesAvailable": availability, + "leftStateTransitionCount": left_trace.get("stateTransitionCount"), + "rightStateTransitionCount": right_trace.get("stateTransitionCount"), + } + + +def _ratio(right: float, left: float) -> float | None: + if left == 0.0: + return 1.0 if right == 0.0 else None + return right / left + + +def _pair_diagnostics( + left: Mapping[str, object], + right: Mapping[str, object], + *, + maximum_scaled_residual: float | None, +) -> dict[str, object]: + left_residual = _scaled_residual(_summary(left)) + right_residual = _scaled_residual(_summary(right)) + residuals_pass = maximum_scaled_residual is None or ( + left_residual is not None + and right_residual is not None + and left_residual <= maximum_scaled_residual + and right_residual <= maximum_scaled_residual + ) + left_totals = _integration_totals(_summary(left)) + right_totals = _integration_totals(_summary(right)) + integration: dict[str, dict[str, float | None]] = {} + for key in sorted(set(left_totals) | set(right_totals)): + left_value = left_totals.get(key) + right_value = right_totals.get(key) + integration[key] = { + "left": left_value, + "right": right_value, + "delta": ( + right_value - left_value + if left_value is not None and right_value is not None + else None + ), + "rightOverLeft": ( + _ratio(right_value, left_value) + if left_value is not None and right_value is not None + else None + ), + } + return { + "scaledResidual": { + "left": left_residual, + "right": right_residual, + "limit": maximum_scaled_residual, + "passed": residuals_pass, + }, + "integrationTotals": integration, + } + + +def compare_matrix_cases( + left: Mapping[str, object], + right: Mapping[str, object], + *, + tolerance: MatrixTolerance, +) -> dict[str, object]: + """Compare two completed matrix cells on their common time prefix.""" + + left_stop = float(left["stopTime"]) + right_stop = float(right["stopTime"]) + prefix_stop = min(left_stop, right_stop) + if not _completed(left, left_stop) or not _completed(right, right_stop): + return { + "evaluated": False, + "passed": False, + "reason": "oneOrBothCasesDidNotComplete", + } + state = _pair_checkpoints(left, right, tolerance=tolerance) + events = _pair_events( + left, + right, + prefix_stop=prefix_stop, + tolerance=tolerance, + ) + diagnostics = _pair_diagnostics( + left, + right, + maximum_scaled_residual=tolerance.maximum_scaled_residual, + ) + passed = bool( + state["passed"] + and events["passed"] + and diagnostics["scaledResidual"]["passed"] + ) + return { + "evaluated": True, + "passed": passed, + "commonPrefixStopTime": prefix_stop, + "stateProjection": state, + "events": events, + "diagnostics": diagnostics, + } + + +def _cell_identity(cell: Mapping[str, object]) -> dict[str, object]: + return { + "matrixCaseId": cell.get("matrixCaseId"), + "horizonCaseId": cell.get("horizonCaseId"), + "stopTime": cell.get("stopTime"), + "maxStep": cell.get("maxStep"), + } + + +def _comparison_record( + kind: str, + left: Mapping[str, object], + right: Mapping[str, object], + tolerance: MatrixTolerance, +) -> dict[str, object]: + return { + "kind": kind, + "left": _cell_identity(left), + "right": _cell_identity(right), + **compare_matrix_cases(left, right, tolerance=tolerance), + } + + +def _build_comparisons( + cases: Sequence[Mapping[str, object]], + *, + horizon_case_ids: Sequence[str], + max_steps: Sequence[float], + tolerance: MatrixTolerance, +) -> dict[str, object]: + by_identity = { + (str(case.get("horizonCaseId")), float(case.get("maxStep"))): case + for case in cases + if case.get("outcome") != "deferred" + and isinstance(case.get("maxStep"), (int, float)) + } + same_horizon: list[dict[str, object]] = [] + for horizon_case_id in horizon_case_ids: + for left_step, right_step in combinations(max_steps, 2): + left = by_identity.get((horizon_case_id, float(left_step))) + right = by_identity.get((horizon_case_id, float(right_step))) + if left is not None and right is not None: + same_horizon.append( + _comparison_record( + "sameHorizonAcrossMaxSteps", left, right, tolerance + ) + ) + same_max_step: list[dict[str, object]] = [] + for max_step in max_steps: + for left_horizon, right_horizon in combinations(horizon_case_ids, 2): + left = by_identity.get((left_horizon, float(max_step))) + right = by_identity.get((right_horizon, float(max_step))) + if left is not None and right is not None: + same_max_step.append( + _comparison_record( + "sameMaxStepAcrossHorizons", left, right, tolerance + ) + ) + evaluated = [*same_horizon, *same_max_step] + comparisons_passed = ( + len(by_identity) == 1 and not evaluated + ) or ( + bool(evaluated) + and all( + bool(item.get("evaluated")) and bool(item.get("passed")) + for item in evaluated + ) + ) + return { + "sameHorizonAcrossMaxSteps": same_horizon, + "sameMaxStepAcrossHorizons": same_max_step, + "evaluatedCount": sum(bool(item.get("evaluated")) for item in evaluated), + "failedCount": sum( + bool(item.get("evaluated")) and not bool(item.get("passed")) + for item in evaluated + ), + "passed": comparisons_passed, + } + + +def _max_step_slug(value: float) -> str: + return format(value, ".12g").replace("-", "m").replace(".", "p") + + +def run_max_step_matrix( + manifest_path: Path | str = DEFAULT_MANIFEST_PATH, + *, + lane: str = "production", + horizon_case_ids: Sequence[str | float] = DEFAULT_HORIZON_CASE_IDS, + max_steps: Sequence[float], + additional_checkpoint_times: Sequence[float] = (), + soft_timeout_seconds: float | None = None, + hard_timeout_seconds: float | None = None, + expected_projection_count: int | None = DEFAULT_EXPECTED_PROJECTION_COUNT, + stop_after_failed_tier: bool = True, + case_executor: MatrixCaseExecutor = execute_regression_case, +) -> dict[str, object]: + """Run a staged, sequential max-step matrix without mutating its source XML.""" + + manifest = load_regression_manifest(manifest_path) + lanes = manifest.get("lanes") + if not isinstance(lanes, Mapping) or lane not in lanes: + raise RegressionManifestError(f"Unknown regression lane {lane!r}.") + lane_config = lanes[lane] + if not isinstance(lane_config, Mapping): + raise RegressionManifestError(f"Lane {lane!r} must be an object.") + selected_horizons = _resolve_horizons( + manifest, + horizon_case_ids, + additional_checkpoint_times=additional_checkpoint_times, + ) + selected_max_steps = _normalise_max_steps(max_steps) + if expected_projection_count is not None and expected_projection_count <= 0: + raise RegressionManifestError("expectedProjectionCount must be positive.") + soft_override = ( + _finite_positive(soft_timeout_seconds, field="softTimeoutSeconds") + if soft_timeout_seconds is not None + else None + ) + hard_override = ( + _finite_positive(hard_timeout_seconds, field="hardTimeoutSeconds") + if hard_timeout_seconds is not None + else None + ) + if soft_override is not None and hard_override is not None and ( + hard_override <= soft_override + ): + raise RegressionManifestError( + "hardTimeoutSeconds must exceed softTimeoutSeconds." + ) + + source_path = Path(str(manifest["_sourcePath"])) + source_payload = source_path.read_bytes() + source_config = source_simulation_config(source_payload) + sampling_mode = lane_config.get("samplingMode", "source") + sample_step = ( + float(source_config["sampleStep"]) + if sampling_mode == "source" + else _finite_positive(lane_config.get("sampleStep"), field="lane.sampleStep") + ) + instrumentation_mode = str( + lane_config.get("instrumentationMode", "standard") + ) + execution = manifest.get("execution") + if not isinstance(execution, Mapping): + raise RegressionManifestError("Manifest execution must be an object.") + raw_environment = execution.get("environment", {}) + if not isinstance(raw_environment, Mapping) or not all( + isinstance(key, str) and isinstance(value, str) + for key, value in raw_environment.items() + ): + raise RegressionManifestError("execution.environment must map strings to strings.") + environment_overrides = tuple(sorted(raw_environment.items())) + termination_grace = _finite_positive( + execution.get("terminationGraceSeconds", 5.0), + field="execution.terminationGraceSeconds", + ) + expected_sha256 = str(manifest["source"]["sha256"]) # type: ignore[index] + tolerance = _matrix_tolerance(manifest) + + cells: list[dict[str, object]] = [] + tier_decisions: list[dict[str, object]] = [] + later_tiers_enabled = True + for horizon in selected_horizons: + horizon_case_id = horizon.case_id + stop_time = horizon.stop_time + soft_timeout = soft_override or horizon.soft_timeout_seconds + hard_timeout = hard_override or horizon.hard_timeout_seconds + if hard_timeout <= soft_timeout: + raise RegressionManifestError( + f"Hard timeout must exceed soft timeout for {horizon_case_id!r}." + ) + tier_cells: list[dict[str, object]] = [] + if not later_tiers_enabled: + for max_step in selected_max_steps: + cell = { + "matrixCaseId": ( + f"{horizon_case_id}__max_step_{_max_step_slug(max_step)}" + ), + "horizonCaseId": horizon_case_id, + "sourceVariantId": horizon.source_variant_id, + "stopTime": stop_time, + "sampleStep": sample_step, + "maxStep": max_step, + "lane": lane, + "softTimeoutSeconds": soft_timeout, + "hardTimeoutSeconds": hard_timeout, + "outcome": "deferred", + "reason": "previousTierDidNotPass", + "matrixAcceptance": { + "evaluated": False, + "passed": False, + "issues": ["previousTierDidNotPass"], + }, + } + cells.append(cell) + tier_cells.append(cell) + tier_decisions.append( + { + "horizonCaseId": horizon_case_id, + "sourceVariantId": horizon.source_variant_id, + "stopTime": stop_time, + "executed": False, + "passed": False, + "reason": "previousTierDidNotPass", + } + ) + continue + + for max_step in selected_max_steps: + matrix_case_id = ( + f"{horizon_case_id}__max_step_{_max_step_slug(max_step)}" + ) + request = RegressionCaseRequest( + case_id=matrix_case_id, + source_path=source_path, + expected_sha256=expected_sha256, + lane=lane, + stop_time=stop_time, + sample_step=sample_step, + max_step=max_step, + checkpoint_times=tuple( + horizon.checkpoint_times + ), + soft_timeout_seconds=soft_timeout, + hard_timeout_seconds=hard_timeout, + termination_grace_seconds=termination_grace, + instrumentation_mode=instrumentation_mode, + environment_overrides=environment_overrides, + ) + result = case_executor(request) + base_cell = { + "matrixCaseId": matrix_case_id, + "horizonCaseId": horizon_case_id, + "sourceVariantId": horizon.source_variant_id, + "stopTime": stop_time, + "sampleStep": sample_step, + "maxStep": max_step, + "lane": lane, + "softTimeoutSeconds": soft_timeout, + "hardTimeoutSeconds": hard_timeout, + **result, + } + observation, issues = _case_observation( + base_cell, + stop_time=stop_time, + expected_projection_count=expected_projection_count, + tolerance=tolerance, + expected_signal_event_times=horizon.expected_signal_event_times, + ) + cell = { + **base_cell, + "matrixObservation": observation, + "matrixAcceptance": { + "evaluated": True, + "passed": not issues, + "issues": list(issues), + }, + } + cells.append(cell) + tier_cells.append(cell) + tier_passed = all( + bool(cell.get("matrixAcceptance", {}).get("passed")) + for cell in tier_cells + if isinstance(cell.get("matrixAcceptance"), Mapping) + ) + tier_decisions.append( + { + "horizonCaseId": horizon_case_id, + "stopTime": stop_time, + "executed": True, + "passed": tier_passed, + "cellCount": len(tier_cells), + } + ) + if stop_after_failed_tier and not tier_passed: + later_tiers_enabled = False + + comparisons = _build_comparisons( + cells, + horizon_case_ids=[horizon.case_id for horizon in selected_horizons], + max_steps=selected_max_steps, + tolerance=tolerance, + ) + executed_acceptance = [ + cell.get("matrixAcceptance") + for cell in cells + if cell.get("outcome") != "deferred" + and isinstance(cell.get("matrixAcceptance"), Mapping) + ] + deferred_count = sum(cell.get("outcome") == "deferred" for cell in cells) + public_manifest = { + key: value for key, value in manifest.items() if not key.startswith("_") + } + overall_passed = ( + bool(executed_acceptance) + and not deferred_count + and all(bool(item.get("passed")) for item in executed_acceptance) + and bool(comparisons["passed"]) + ) + return { + "schemaVersion": MATRIX_REPORT_SCHEMA_VERSION, + "reportKind": "maxStepHorizonMatrix", + "generatedAt": datetime.now(UTC).isoformat(), + "manifestId": manifest.get("id"), + "manifestPath": str(manifest["_manifestPath"]), + "lane": lane, + "source": { + **dict(manifest["source"]), # type: ignore[arg-type] + "resolvedPath": str(source_path), + "bytes": len(source_payload), + "simulation": source_config, + "mutationPolicy": "readOnly; per-cell overrides are child-memory only", + }, + "configuration": { + "horizons": [ + { + "horizonCaseId": horizon.case_id, + "sourceVariantId": horizon.source_variant_id, + "stopTime": horizon.stop_time, + "checkpointTimes": list(horizon.checkpoint_times), + "defaultSoftTimeoutSeconds": horizon.soft_timeout_seconds, + "defaultHardTimeoutSeconds": horizon.hard_timeout_seconds, + } + for horizon in selected_horizons + ], + "additionalCheckpointTimes": [ + float(value) for value in additional_checkpoint_times + ], + "maxSteps": list(selected_max_steps), + "sampleStep": sample_step, + "samplingMode": sampling_mode, + "softTimeoutOverrideSeconds": soft_override, + "hardTimeoutOverrideSeconds": hard_override, + "expectedProjectionCount": expected_projection_count, + "stopAfterFailedTier": stop_after_failed_tier, + "executionOrder": "horizon-major, max-step serial", + "tolerance": { + "stateRelative": tolerance.state_relative, + "stateAbsolute": tolerance.state_absolute, + "checkpointTimeAbsoluteSeconds": ( + tolerance.checkpoint_time_absolute_seconds + ), + "eventTimeAbsoluteSeconds": tolerance.event_time_absolute_seconds, + "signalEventTimeAbsoluteSeconds": ( + tolerance.signal_event_time_absolute_seconds + ), + "maximumScaledResidual": tolerance.maximum_scaled_residual, + }, + }, + "manifest": public_manifest, + "tierDecisions": tier_decisions, + "cases": cells, + "comparisons": comparisons, + "acceptance": { + "passed": overall_passed, + "executedCellCount": len(executed_acceptance), + "deferredCellCount": deferred_count, + "caseFailureCount": sum( + not bool(item.get("passed")) for item in executed_acceptance + ), + "comparisonFailureCount": comparisons["failedCount"], + }, + } + + +def _parse_arguments(argv: Sequence[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Run a bounded 1s/5s/10s matrix over multiple maximum integration steps." + ) + ) + parser.add_argument("--manifest", type=Path, default=DEFAULT_MANIFEST_PATH) + parser.add_argument("--lane", default="production") + parser.add_argument( + "--horizon", + action="append", + default=[], + help="Manifest case id or stop time; repeat in sequence order (default: 1s,5s,10s).", + ) + parser.add_argument( + "--checkpoint", + type=float, + action="append", + default=[], + help=( + "Additional checkpoint for every horizon that reaches it; repeat for event-neighbour probes." + ), + ) + parser.add_argument( + "--max-step", + type=float, + action="append", + required=True, + help="Maximum integration step; repeat to form matrix columns.", + ) + parser.add_argument("--soft-timeout", type=float) + parser.add_argument("--hard-timeout", type=float) + parser.add_argument( + "--expected-projection-count", + type=int, + default=DEFAULT_EXPECTED_PROJECTION_COUNT, + ) + parser.add_argument( + "--continue-after-failure", + action="store_true", + help="Run later horizons even when a preceding horizon tier fails.", + ) + parser.add_argument("--output", type=Path) + return parser.parse_args(argv) + + +def main(argv: Sequence[str] | None = None) -> int: + arguments = _parse_arguments(argv) + report = run_max_step_matrix( + arguments.manifest, + lane=arguments.lane, + horizon_case_ids=arguments.horizon or DEFAULT_HORIZON_CASE_IDS, + max_steps=arguments.max_step, + additional_checkpoint_times=arguments.checkpoint, + soft_timeout_seconds=arguments.soft_timeout, + hard_timeout_seconds=arguments.hard_timeout, + expected_projection_count=arguments.expected_projection_count, + stop_after_failed_tier=not arguments.continue_after_failure, + ) + serialized = json.dumps(report, ensure_ascii=False, indent=2, default=str) + "\n" + if arguments.output is None: + print(serialized, end="") + else: + arguments.output.parent.mkdir(parents=True, exist_ok=True) + arguments.output.write_text(serialized, encoding="utf-8") + print(f"Max-step matrix report written to {arguments.output.resolve()}") + if report["acceptance"]["passed"]: + return 0 + if report["acceptance"]["deferredCellCount"] and not report["acceptance"][ + "caseFailureCount" + ]: + return 2 + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/app/simulation/solvers/algebraic.py b/app/simulation/solvers/algebraic.py index e3e1200..c6af17f 100644 --- a/app/simulation/solvers/algebraic.py +++ b/app/simulation/solvers/algebraic.py @@ -31,6 +31,10 @@ PRESSURE_LOWER_BOUND_PA = 0.0 CAUSAL_FAST_PATH_ENVIRONMENT_VARIABLE = "SIMULATION_CAUSAL_FAST_PATH" +CAUSAL_EXECUTOR_V2_ENVIRONMENT_VARIABLE = "SIMULATION_CAUSAL_EXECUTOR_V2" +CAUSAL_COORDINATE_KERNEL_ENVIRONMENT_VARIABLE = ( + "SIMULATION_CAUSAL_COORDINATE_KERNEL" +) CAUSAL_FAST_PATH_AUDIT_INTERVAL = 64 @@ -39,6 +43,20 @@ def _causal_fast_path_environment_enabled() -> bool: return value.strip().lower() not in {"0", "false", "no", "off"} +def _causal_executor_v2_environment_enabled() -> bool: + """Return whether the allocation-light causal executor is enabled.""" + + value = os.getenv(CAUSAL_EXECUTOR_V2_ENVIRONMENT_VARIABLE, "1") + return value.strip().lower() not in {"0", "false", "no", "off"} + + +def _causal_coordinate_kernel_environment_enabled() -> bool: + """Return whether the canonical-coordinate causal kernel is enabled.""" + + value = os.getenv(CAUSAL_COORDINATE_KERNEL_ENVIRONMENT_VARIABLE, "1") + return value.strip().lower() not in {"0", "false", "no", "off"} + + class AlgebraicSolveError(RuntimeError): def __init__( self, @@ -120,6 +138,42 @@ class CausalEffortAssignment: anchor: EffortAnchor +@dataclass(frozen=True) +class CausalEffortKernelTarget: + """One canonical effort coordinate extracted from a residual evaluator.""" + + coordinate_index: int + assignment: CausalEffortAssignment + equation_index: int + equation_id: str + + +@dataclass(frozen=True) +class CausalEffortKernelEvaluation: + """One component call shared by every state anchor that it owns.""" + + evaluate: Callable[[], tuple[float, ...]] + targets: tuple[CausalEffortKernelTarget, ...] + + +@dataclass(frozen=True) +class CausalEffortKernelStage: + """Precompiled canonical coordinates and compatibility broadcasts.""" + + variable: str + assignments: tuple[tuple[int, CausalEffortAssignment], ...] + direct_targets: tuple[tuple[int, CausalEffortAssignment], ...] + component_evaluations: tuple[CausalEffortKernelEvaluation, ...] + + +@dataclass(frozen=True) +class CausalFlowKernelStage: + """Map one existing dependency stage into the canonical workspace.""" + + stage: ExplicitFlowStage + coordinate_indices: tuple[int, ...] + + @dataclass(frozen=True) class ConnectionEquationEvaluation: template: EquationResidual @@ -366,6 +420,56 @@ class PressureFlowSolver: self._causal_fast_path_environment_enabled = ( _causal_fast_path_environment_enabled() ) + self._causal_executor_v2_environment_enabled = ( + _causal_executor_v2_environment_enabled() + ) + self._causal_coordinate_kernel_environment_enabled = ( + _causal_coordinate_kernel_environment_enabled() + ) + self._causal_compiled_effort_unknown_count = sum( + len(assignment.members) + for assignments in self._causal_effort_plan_by_variable.values() + for assignment in assignments + ) + self._causal_compiled_flow_assignment_count = sum( + len(stage.assignments) for stage in self._explicit_flow_plan + ) + self._causal_external_effort_unknowns = tuple( + member + for variable in ("x", "v") + for assignment in self._causal_effort_plan_by_variable.get( + variable, () + ) + for member in assignment.members + ) + self._causal_external_x_states = tuple( + unknown.state + for unknown in self._causal_external_effort_unknowns + if unknown.variable == "x" + ) + self._causal_external_v_states = tuple( + unknown.state + for unknown in self._causal_external_effort_unknowns + if unknown.variable == "v" + ) + ( + self._causal_effort_kernel_by_variable, + self._causal_flow_kernel_plan, + self._causal_coordinate_values, + ) = self._compile_causal_coordinate_kernel() + self._causal_logical_effort_coordinate_count = sum( + len(stage.assignments) + for stage in self._causal_effort_kernel_by_variable.values() + ) + self._causal_eliminated_effort_alias_count = max( + self._causal_compiled_effort_unknown_count + - self._causal_logical_effort_coordinate_count, + 0, + ) + self._causal_compatibility_scatter_count = ( + self._causal_compiled_effort_unknown_count + + self._causal_compiled_flow_assignment_count + ) self._causal_runtime_disabled_reason: str | None = None self._causal_audit_interval = CAUSAL_FAST_PATH_AUDIT_INTERVAL self._causal_audit_required = True @@ -374,7 +478,11 @@ class PressureFlowSolver: self._causal_full_residual_audit_count = 0 self._causal_audit_failure_count = 0 self._causal_legacy_fallback_count = 0 + self._causal_v2_fast_solve_count = 0 + self._causal_v2_runtime_validation_failure_count = 0 + self._causal_coordinate_fast_solve_count = 0 self._causal_last_verified_diagnostics: AlgebraicSolveDiagnostics | None = None + self._causal_cached_fast_diagnostics: AlgebraicSolveDiagnostics | None = None self.last_diagnostics: AlgebraicSolveDiagnostics | None = None @property @@ -435,6 +543,25 @@ class PressureFlowSolver: and self._causal_runtime_disabled_reason is None ) + @property + def causal_executor_v2_enabled(self) -> bool: + """Whether this run may use the v2 executor (environment opt-out).""" + + return ( + self._causal_executor_v2_environment_enabled + and self.causal_fast_path_enabled + ) + + @property + def causal_coordinate_kernel_enabled(self) -> bool: + """Whether the canonical-coordinate executor may run now.""" + + return ( + self._causal_coordinate_kernel_environment_enabled + and self.causal_executor_v2_enabled + and bool(self._causal_coordinate_values) + ) + def causal_execution_diagnostics(self) -> dict[str, object]: disabled_reason = self._causal_runtime_disabled_reason if not self._causal_fast_path_environment_enabled: @@ -458,6 +585,39 @@ class PressureFlowSolver: if last_verified is not None else None ), + "executorV2Configured": self._causal_executor_v2_environment_enabled, + "executorV2Enabled": self.causal_executor_v2_enabled, + "executorV2FastSolveCount": self._causal_v2_fast_solve_count, + "executorV2RuntimeValidationFailureCount": ( + self._causal_v2_runtime_validation_failure_count + ), + "coordinateKernelConfigured": ( + self._causal_coordinate_kernel_environment_enabled + ), + "coordinateKernelEnabled": self.causal_coordinate_kernel_enabled, + "coordinateKernelFastSolveCount": ( + self._causal_coordinate_fast_solve_count + ), + "compiledEffortUnknownCount": ( + self._causal_compiled_effort_unknown_count + ), + "compiledFlowAssignmentCount": ( + self._causal_compiled_flow_assignment_count + ), + "compiledAssignmentCount": ( + self._causal_compiled_effort_unknown_count + + self._causal_compiled_flow_assignment_count + ), + "logicalEffortCoordinateCount": ( + self._causal_logical_effort_coordinate_count + ), + "eliminatedEffortAliasCount": ( + self._causal_eliminated_effort_alias_count + ), + "canonicalCoordinateCount": len(self._causal_coordinate_values), + "compatibilityScatterCount": ( + self._causal_compatibility_scatter_count + ), } def request_causal_audit(self) -> None: @@ -651,10 +811,163 @@ class PressureFlowSolver: None, ) + def _compile_causal_coordinate_kernel( + self, + ) -> tuple[ + dict[str, CausalEffortKernelStage], + tuple[CausalFlowKernelStage, ...], + list[float], + ]: + """Compile independent coordinates without changing public port state. + + ``PortState`` remains the compatibility surface consumed by component + methods. The workspace stores one value per proven effort equality + group and one per explicit flow assignment; compatibility aliases are + populated only after every target in an effort stage has been checked. + """ + + if not self._causal_fast_path_eligible: + return {}, (), [] + + equation_index_by_id = { + equation.id: index + for index, equation in enumerate(self._equation_templates) + } + effort_stages: dict[str, CausalEffortKernelStage] = {} + next_coordinate = 0 + for variable in ("p", "x", "v"): + assignments = self._causal_effort_plan_by_variable.get(variable, ()) + indexed_assignments = tuple( + (next_coordinate + offset, assignment) + for offset, assignment in enumerate(assignments) + ) + next_coordinate += len(indexed_assignments) + direct_targets: list[tuple[int, CausalEffortAssignment]] = [] + targets_by_component: dict[ + int, + list[CausalEffortKernelTarget], + ] = {} + component_evaluators: dict[int, Callable[[], tuple[float, ...]]] = {} + for coordinate_index, assignment in indexed_assignments: + equation_index = equation_index_by_id[ + assignment.anchor.equation_id + ] + kind, evaluation_plan, source = ( + self._equation_evaluation_locations[equation_index] + ) + if kind != "component": + direct_targets.append((coordinate_index, assignment)) + continue + component_plan = evaluation_plan + key = id(component_plan) + component_evaluators[key] = component_plan.evaluate + targets_by_component.setdefault(key, []).append( + CausalEffortKernelTarget( + coordinate_index=coordinate_index, + assignment=assignment, + equation_index=source, + equation_id=assignment.anchor.equation_id, + ) + ) + effort_stages[variable] = CausalEffortKernelStage( + variable=variable, + assignments=indexed_assignments, + direct_targets=tuple(direct_targets), + component_evaluations=tuple( + CausalEffortKernelEvaluation( + evaluate=component_evaluators[key], + targets=tuple(targets), + ) + for key, targets in targets_by_component.items() + ), + ) + + flow_stages: list[CausalFlowKernelStage] = [] + for stage in self._explicit_flow_plan: + coordinate_indices = tuple( + range(next_coordinate, next_coordinate + len(stage.assignments)) + ) + next_coordinate += len(stage.assignments) + flow_stages.append( + CausalFlowKernelStage( + stage=stage, + coordinate_indices=coordinate_indices, + ) + ) + return effort_stages, tuple(flow_stages), [0.0] * next_coordinate + + @staticmethod + def _read_effort_anchor(assignment: CausalEffortAssignment) -> float: + state = assignment.anchor.unknown.state + if assignment.variable == "p": + return state.p + if assignment.variable == "x": + return state.x + return state.v + + @staticmethod + def _scatter_effort_assignment( + assignment: CausalEffortAssignment, + value: float, + ) -> None: + if assignment.variable == "p": + for unknown in assignment.members: + unknown.state.p = value + return + if assignment.variable == "x": + for unknown in assignment.members: + unknown.state.x = value + return + for unknown in assignment.members: + unknown.state.v = value + + def _execute_causal_coordinate_effort_plan( + self, + variables: tuple[str, ...], + ) -> bool: + """Evaluate canonical effort coordinates in component-sized batches.""" + + workspace = self._causal_coordinate_values + for variable in variables: + stage = self._causal_effort_kernel_by_variable.get(variable) + if stage is None: + return False + for coordinate_index, assignment in stage.direct_targets: + workspace[coordinate_index] = ( + self._read_effort_anchor(assignment) + - assignment.anchor.evaluate() + ) + for evaluation in stage.component_evaluations: + equation_values = evaluation.evaluate() + for target in evaluation.targets: + if target.equation_index >= len(equation_values): + raise RuntimeError( + "Compiled algebraic equation disappeared at runtime: " + f"{target.equation_id}." + ) + workspace[target.coordinate_index] = ( + self._read_effort_anchor(target.assignment) + - float(equation_values[target.equation_index]) + ) + for coordinate_index, assignment in stage.assignments: + target = workspace[coordinate_index] + if not isfinite(target) or ( + variable == "p" and target <= PRESSURE_LOWER_BOUND_PA + ): + return False + for coordinate_index, assignment in stage.assignments: + self._scatter_effort_assignment( + assignment, + workspace[coordinate_index], + ) + return True + def _execute_causal_effort_plan( self, variables: tuple[str, ...], ) -> bool: + if self.causal_coordinate_kernel_enabled: + return self._execute_causal_coordinate_effort_plan(variables) for variable in variables: assignments = self._causal_effort_plan_by_variable.get(variable) if assignments is None: @@ -685,15 +998,8 @@ class PressureFlowSolver: self._causal_solves_since_audit = 0 self._causal_audit_required = False self._causal_last_verified_diagnostics = diagnostics - - def _causal_fast_diagnostics( - self, - ) -> AlgebraicSolveDiagnostics: - verified = self._causal_last_verified_diagnostics - if verified is None: - raise RuntimeError("Causal execution has no verified residual baseline.") - return replace( - verified, + self._causal_cached_fast_diagnostics = replace( + diagnostics, message=( "Compiled causal pressure-flow program completed; residuals " "reuse the latest full audit." @@ -709,6 +1015,14 @@ class PressureFlowSolver: causal_fast_path_used=True, ) + def _causal_fast_diagnostics( + self, + ) -> AlgebraicSolveDiagnostics: + cached = self._causal_cached_fast_diagnostics + if cached is None: + raise RuntimeError("Causal execution has no verified residual baseline.") + return cached + def _build_jacobian_sparsity(self): """Compile the residual dependency contract into one CSR pattern. @@ -1024,6 +1338,10 @@ class PressureFlowSolver: unknown = sorted(set(variables) - set(self._effort_groups)) if unknown: raise ValueError("Unsupported effort variables: " + ", ".join(unknown)) + if self.causal_coordinate_kernel_enabled: + if self._execute_causal_coordinate_effort_plan(variables): + return + self._disable_causal_fast_path("nonFiniteCausalEffortAnchor") for variable in variables: self._seed_equal_effort(variable) @@ -1835,6 +2153,121 @@ class PressureFlowSolver: seeded_ids.add(assignment.unknown.id) return seeded_ids + def _execute_compiled_causal_flow_plan(self) -> str | None: + """Execute the compile-proven full flow plan without coverage sets.""" + + if self.causal_coordinate_kernel_enabled: + return self._execute_causal_coordinate_flow_plan() + + # Position and velocity are propagated by the mechanical reducer + # before the pressure-only causal solve. They are therefore not + # rewritten below, but remain part of the compiled algebraic contract. + # Validate that small external boundary explicitly instead of restoring + # the legacy scan over every pressure/flow/force unknown. + if any( + not isfinite(unknown.read()) + for unknown in self._causal_external_effort_unknowns + ): + return "nonFiniteCausalExternalEffort" + + reset_unknowns = self._explicit_flow_unknowns_by_variables[ + frozenset(("f", "m_flow")) + ] + for unknown in reset_unknowns: + unknown.write(0.0) + + for stage in self._explicit_flow_plan: + try: + values = self._evaluate_explicit_flow_stage(stage) + except MemoryError: + raise + except (ArithmeticError, RuntimeError, ValueError) as exc: + return f"causalFlowEvaluationFailed:{type(exc).__name__}" + if len(values) != len(stage.assignments): + return "causalFlowAssignmentCountMismatch" + for assignment, target_value in zip(stage.assignments, values): + if not isfinite(target_value): + return "nonFiniteCausalFlowAssignment" + assignment.unknown.write(target_value) + return None + + def _execute_causal_coordinate_flow_plan(self) -> str | None: + """Run flow stages through reusable canonical coordinates.""" + + if any(not isfinite(state.x) for state in self._causal_external_x_states): + return "nonFiniteCausalExternalEffort" + if any(not isfinite(state.v) for state in self._causal_external_v_states): + return "nonFiniteCausalExternalEffort" + + return self._execute_causal_coordinate_flow_stages( + self._causal_flow_kernel_plan, + self._causal_coordinate_values, + ) + + @staticmethod + def _execute_causal_coordinate_flow_stages( + kernel_plan: tuple[CausalFlowKernelStage, ...], + workspace: list[float], + ) -> str | None: + """Execute proven flow stages without per-call result containers.""" + + # Residual-based explicit assignments use ``-residual`` and therefore + # require their target coordinate to be zero. Keep this compatibility + # initialization until a component exposes a proven direct target op. + for kernel_stage in kernel_plan: + for assignment in kernel_stage.stage.assignments: + if assignment.unknown.variable == "m_flow": + assignment.unknown.state.m_flow = 0.0 + else: + assignment.unknown.state.f = 0.0 + + for kernel_stage in kernel_plan: + stage = kernel_stage.stage + coordinate_indices = kernel_stage.coordinate_indices + if len(coordinate_indices) != len(stage.assignments): + return "causalFlowAssignmentCountMismatch" + try: + for assignment_index, evaluate in stage.direct_evaluations: + workspace[coordinate_indices[assignment_index]] = float( + evaluate() + ) + for evaluation in stage.component_evaluations: + equation_values = evaluation.evaluate() + for assignment_index, equation_index, equation_id in zip( + evaluation.assignment_indices, + evaluation.equation_indices, + evaluation.equation_ids, + ): + if equation_index >= len(equation_values): + raise RuntimeError( + "Compiled algebraic equation disappeared at " + f"runtime: {equation_id}." + ) + workspace[coordinate_indices[assignment_index]] = ( + 0.0 - float(equation_values[equation_index]) + ) + except MemoryError: + raise + except (ArithmeticError, RuntimeError, ValueError) as exc: + return f"causalFlowEvaluationFailed:{type(exc).__name__}" + for assignment, coordinate_index in zip( + stage.assignments, + coordinate_indices, + ): + target_value = workspace[coordinate_index] + if not isfinite(target_value): + return "nonFiniteCausalFlowAssignment" + for assignment, coordinate_index in zip( + stage.assignments, + coordinate_indices, + ): + target_value = workspace[coordinate_index] + if assignment.unknown.variable == "m_flow": + assignment.unknown.state.m_flow = target_value + else: + assignment.unknown.state.f = target_value + return None + def _build_closed_resistance_pressure_plan( self, ) -> tuple[ClosedResistancePressureBinding, ...]: @@ -2088,6 +2521,9 @@ class PressureFlowSolver: causal_audit_due = ( self._causal_audit_is_due() if causal_candidate else False ) + causal_v2_candidate = ( + causal_candidate and self._causal_executor_v2_environment_enabled + ) for component in self._causal_contact_components: component.clear_causal_contact() @@ -2100,29 +2536,55 @@ class PressureFlowSolver: self._disable_causal_fast_path("nonFiniteCausalEffortAnchor") causal_candidate = False causal_audit_due = False + causal_v2_candidate = False self._seed_equal_efforts(effort_variables) else: self._seed_equal_efforts(effort_variables) - self._seed_closed_resistance_pressures() - self._seed_resistance_pnl0001_series_pressures() - seeded_flow_ids = self._solve_explicit_flow_unknowns() - contact_bindings = self._seed_unilateral_contacts() - if contact_bindings: - seeded_flow_ids.update(self._solve_explicit_flow_unknowns(("f",))) - self._refresh_unilateral_contacts(contact_bindings) + + seeded_flow_ids: set[str] | None = None + contact_bindings: tuple[UnilateralContactBinding, ...] = () + if causal_v2_candidate: + v2_failure_reason = self._execute_compiled_causal_flow_plan() + if v2_failure_reason is not None: + self._causal_v2_runtime_validation_failure_count += 1 + self._causal_legacy_fallback_count += 1 + self._disable_causal_fast_path(v2_failure_reason) + causal_candidate = False + causal_audit_due = False + causal_v2_candidate = False + # Rebuild the ordinary seed from scratch in the same solve. + # A partial compiled stage must never influence fallback. + self._seed_equal_efforts(effort_variables) + if not causal_v2_candidate: + self._seed_closed_resistance_pressures() + self._seed_resistance_pnl0001_series_pressures() + seeded_flow_ids = self._solve_explicit_flow_unknowns() + contact_bindings = self._seed_unilateral_contacts() + if contact_bindings: + seeded_flow_ids.update( + self._solve_explicit_flow_unknowns(("f",)) + ) + self._refresh_unilateral_contacts(contact_bindings) if causal_candidate: - causal_unknowns_are_feasible = ( - not contact_bindings - and seeded_flow_ids == self._causal_flow_unknown_ids - and all( - isfinite(unknown.read()) - and ( - unknown.variable != "p" - or unknown.read() > PRESSURE_LOWER_BOUND_PA + if causal_v2_candidate: + # Compilation proves a disjoint, complete effort/flow + # partition. The v2 executors validate each produced value, + # so no coverage set or full unknown scan is needed here. + causal_unknowns_are_feasible = True + else: + assert seeded_flow_ids is not None + causal_unknowns_are_feasible = ( + not contact_bindings + and seeded_flow_ids == self._causal_flow_unknown_ids + and all( + isfinite(unknown.read()) + and ( + unknown.variable != "p" + or unknown.read() > PRESSURE_LOWER_BOUND_PA + ) + for unknown in self.unknowns ) - for unknown in self.unknowns ) - ) if not causal_unknowns_are_feasible: self._causal_legacy_fallback_count += 1 self._disable_causal_fast_path("causalRuntimeGateFailed") @@ -2131,6 +2593,10 @@ class PressureFlowSolver: elif not causal_audit_due: diagnostics = self._causal_fast_diagnostics() self._causal_fast_solve_count += 1 + if causal_v2_candidate: + self._causal_v2_fast_solve_count += 1 + if self.causal_coordinate_kernel_enabled: + self._causal_coordinate_fast_solve_count += 1 self._causal_solves_since_audit += 1 self.last_diagnostics = diagnostics return diagnostics diff --git a/app/simulation/solvers/algebraic_blocks.py b/app/simulation/solvers/algebraic_blocks.py index f0e9077..ee3245b 100644 --- a/app/simulation/solvers/algebraic_blocks.py +++ b/app/simulation/solvers/algebraic_blocks.py @@ -9,6 +9,7 @@ from app.simulation.solvers.algebraic import ( PRESSURE_LOWER_BOUND_PA, AlgebraicSolveDiagnostics, AlgebraicUnknown, + CausalFlowKernelStage, ExplicitFlowStage, PressureFlowSolver, ) @@ -237,6 +238,27 @@ class StreamPressureBlockSolver: ) for stage in pressure_flow_solver._explicit_flow_plan ) + secondary_coordinate = 0 + secondary_kernel_plan: list[CausalFlowKernelStage] = [] + for stage in self._selected_explicit_flow_plan: + coordinate_indices = tuple( + range( + secondary_coordinate, + secondary_coordinate + len(stage.assignments), + ) + ) + secondary_coordinate += len(stage.assignments) + secondary_kernel_plan.append( + CausalFlowKernelStage( + stage=stage, + coordinate_indices=coordinate_indices, + ) + ) + self._causal_secondary_flow_kernel_plan = tuple(secondary_kernel_plan) + self._causal_secondary_coordinate_values = [0.0] * secondary_coordinate + self._causal_v2_entry_values = [0.0] * len( + self._selected_flow_unknowns + ) self._selected_equation_evaluation = ( self._compile_selected_equation_evaluation() if self.blocks @@ -259,7 +281,11 @@ class StreamPressureBlockSolver: self._causal_full_residual_audit_count = 0 self._causal_audit_failure_count = 0 self._causal_legacy_fallback_count = 0 + self._causal_v2_fast_solve_count = 0 + self._causal_v2_runtime_validation_failure_count = 0 + self._causal_coordinate_fast_solve_count = 0 self._causal_last_verified_diagnostics: AlgebraicSolveDiagnostics | None = None + self._causal_cached_fast_diagnostics: AlgebraicSolveDiagnostics | None = None @property def available(self) -> bool: @@ -273,6 +299,13 @@ class StreamPressureBlockSolver: and self.pressure_flow_solver.causal_fast_path_enabled ) + @property + def causal_executor_v2_enabled(self) -> bool: + return ( + self.causal_fast_path_enabled + and self.pressure_flow_solver._causal_executor_v2_environment_enabled + ) + def request_causal_audit(self) -> None: self._causal_audit_required = True @@ -280,7 +313,9 @@ class StreamPressureBlockSolver: parent = self.pressure_flow_solver.causal_execution_diagnostics() disabled_reason = self._causal_runtime_disabled_reason if not bool(parent["enabled"]): - disabled_reason = str(parent["disabledReason"] or "parentCausalPathDisabled") + disabled_reason = str( + parent["disabledReason"] or "parentCausalPathDisabled" + ) elif not self._causal_fast_path_eligible: disabled_reason = self._causal_fast_path_fallback_reason verified = self._causal_last_verified_diagnostics @@ -298,6 +333,34 @@ class StreamPressureBlockSolver: "lastVerifiedMaxScaledResidual": ( verified.max_scaled_residual if verified is not None else None ), + "executorV2Configured": ( + self.pressure_flow_solver._causal_executor_v2_environment_enabled + ), + "executorV2Enabled": self.causal_executor_v2_enabled, + "executorV2FastSolveCount": self._causal_v2_fast_solve_count, + "executorV2RuntimeValidationFailureCount": ( + self._causal_v2_runtime_validation_failure_count + ), + "coordinateKernelConfigured": ( + self.pressure_flow_solver._causal_coordinate_kernel_environment_enabled + ), + "coordinateKernelEnabled": ( + self.causal_executor_v2_enabled + and self.pressure_flow_solver.causal_coordinate_kernel_enabled + ), + "coordinateKernelFastSolveCount": ( + self._causal_coordinate_fast_solve_count + ), + "compiledEffortUnknownCount": len( + self._causal_effort_entry_positions + ), + "compiledFlowAssignmentCount": len( + self._causal_expected_flow_equation_ids + ), + "compiledAssignmentCount": ( + len(self._causal_effort_entry_positions) + + len(self._causal_expected_flow_equation_ids) + ), } def _disable_causal_fast_path(self, reason: str) -> None: @@ -405,6 +468,28 @@ class StreamPressureBlockSolver: self._causal_solves_since_audit = 0 self._causal_audit_required = False self._causal_last_verified_diagnostics = diagnostics + self._causal_cached_fast_diagnostics = replace( + diagnostics, + message=( + "Compiled causal stream-pressure block completed; residuals " + "reuse the latest full audit." + ), + evaluations=0, + residual_evaluations=0, + dense_fallback_used=False, + nonlinear_block_count=0, + nonlinear_block_unknown_count=0, + block_fallback_used=False, + block_fallback_reason=None, + residual_verified_this_solve=False, + causal_fast_path_used=True, + ) + + def _causal_v2_fast_diagnostics(self) -> AlgebraicSolveDiagnostics: + cached = self._causal_cached_fast_diagnostics + if cached is None: + raise RuntimeError("Stream causal execution has no residual audit.") + return cached def _causal_fast_diagnostics( self, @@ -788,6 +873,45 @@ class StreamPressureBlockSolver: unknown.write(entry_values[position]) return frozenset(seeded_equation_ids) + def _execute_compiled_secondary_flow_plan(self) -> str | None: + """Execute selected flow assignments without equation-id sets.""" + + if self.pressure_flow_solver.causal_coordinate_kernel_enabled: + failure = ( + self.pressure_flow_solver._execute_causal_coordinate_flow_stages( + self._causal_secondary_flow_kernel_plan, + self._causal_secondary_coordinate_values, + ) + ) + if failure == "causalFlowAssignmentCountMismatch": + return "causalSecondaryFlowAssignmentCountMismatch" + if failure == "nonFiniteCausalFlowAssignment": + return "nonFiniteCausalSecondaryFlowAssignment" + if failure and failure.startswith("causalFlowEvaluationFailed:"): + return "causalSecondaryFlowEvaluationFailed:" + failure.rsplit( + ":", 1 + )[-1] + return failure + + for unknown in self._selected_flow_unknowns: + unknown.write(0.0) + for stage in self._selected_explicit_flow_plan: + try: + values = self.pressure_flow_solver._evaluate_explicit_flow_stage( + stage + ) + except MemoryError: + raise + except (ArithmeticError, RuntimeError, ValueError) as exc: + return f"causalSecondaryFlowEvaluationFailed:{type(exc).__name__}" + if len(values) != len(stage.assignments): + return "causalSecondaryFlowAssignmentCountMismatch" + for assignment, target_value in zip(stage.assignments, values): + if not isfinite(target_value): + return "nonFiniteCausalSecondaryFlowAssignment" + assignment.unknown.write(target_value) + return None + @staticmethod def _equation_scales_from_specs( specs: tuple[_EquationScaleSpec, ...], @@ -1083,11 +1207,62 @@ class StreamPressureBlockSolver: scale_context: Mapping[str, float] | None = None, ) -> StreamBlockSolveResult: solver = self.pressure_flow_solver - context = dict(scale_context or solver.scale_context()) causal_candidate = self.causal_fast_path_enabled causal_audit_due = ( self._causal_audit_is_due() if causal_candidate else False ) + causal_v2_candidate = ( + causal_candidate + and solver._causal_executor_v2_environment_enabled + and not causal_audit_due + ) + if causal_v2_candidate: + # The secondary causal proof rejects every special pressure seed, + # so this executor mutates selected flow coordinates only. Keep + # the minimal transactional snapshot for the rare fallback path. + v2_entry_values = self._causal_v2_entry_values + for position, unknown in enumerate(self._selected_flow_unknowns): + v2_entry_values[position] = unknown.state.m_flow + + def restore_v2_entry_mutations() -> None: + for unknown, value in zip( + self._selected_flow_unknowns, + v2_entry_values, + ): + unknown.state.m_flow = value + + try: + v2_failure_reason = ( + self._execute_compiled_secondary_flow_plan() + ) + except BaseException: + restore_v2_entry_mutations() + raise + if v2_failure_reason is None: + diagnostics = self._causal_v2_fast_diagnostics() + self._causal_fast_solve_count += 1 + self._causal_v2_fast_solve_count += 1 + if solver.causal_coordinate_kernel_enabled: + self._causal_coordinate_fast_solve_count += 1 + self._causal_solves_since_audit += 1 + selected = self._selected_equation_evaluation + assert selected is not None + return StreamBlockSolveResult( + diagnostics=(diagnostics,), + scopes=(selected.scope_components,), + used_global_fallback=False, + ) + restore_v2_entry_mutations() + self._causal_v2_runtime_validation_failure_count += 1 + self._causal_legacy_fallback_count += 1 + self._disable_causal_fast_path(v2_failure_reason) + causal_candidate = False + causal_audit_due = False + + # Keep scale construction and the full mutation snapshot off the v2 + # success path. Callers may still precompute a shared scale mapping; + # avoiding that producer requires a later Generic-system API change. + context = dict(scale_context or solver.scale_context()) entry_values = tuple( unknown.read() for unknown in self._entry_mutated_unknowns ) diff --git a/app/simulation/solvers/causal_ir.py b/app/simulation/solvers/causal_ir.py new file mode 100644 index 0000000..678451d --- /dev/null +++ b/app/simulation/solvers/causal_ir.py @@ -0,0 +1,783 @@ +"""Executable reference IR for compile-proven causal algebraic programs. + +The IR eliminates duplicate *logical* effort coordinates, but intentionally +keeps a compatibility scatter map to existing ``PortState`` objects. Stream +propagation, derivatives, and result collection still consume those objects; +this is a reference for a future flat backend, not physical slot deletion. +""" + +from __future__ import annotations + +from collections.abc import Callable, Iterable +from dataclasses import dataclass, replace +from enum import StrEnum +from hashlib import sha256 +import json +from math import isfinite +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + import numpy as np + + +CAUSAL_NUMERIC_IR_SCHEMA_VERSION = 1 +PRESSURE_LOWER_BOUND_PA = 0.0 + + +class CausalIROpcode(StrEnum): + EFFORT_BROADCAST = "effort_broadcast" + EFFORT_DIRECT_RESIDUAL = "effort_direct_residual" + EFFORT_COMPONENT_RESIDUAL = "effort_component_residual" + FLOW_DIRECT = "flow_direct" + FLOW_COMPONENT_RESIDUAL = "flow_component_residual" + + +@dataclass(frozen=True, slots=True) +class CausalIRCompatibilitySlot: + slot: int + id: str + variable: str + + +@dataclass(frozen=True, slots=True) +class CausalIRCanonicalSlot: + slot: int + id: str + variable: str + kind: str + + +@dataclass(frozen=True, slots=True) +class CausalIREffortOperation: + opcode: CausalIROpcode + variable: str + result_slot: int + anchor_compatibility_slot: int + scatter_compatibility_slots: tuple[int, ...] + equation_id: str + + +@dataclass(frozen=True, slots=True) +class CausalIREffortEvaluation: + opcode: CausalIROpcode + output_indices: tuple[int, ...] + equation_indices: tuple[int, ...] + equation_ids: tuple[str, ...] + evaluator_slot: int + + +@dataclass(frozen=True, slots=True) +class CausalIREffortStage: + variable: str + operations: tuple[CausalIREffortOperation, ...] + evaluations: tuple[CausalIREffortEvaluation, ...] + + +@dataclass(frozen=True, slots=True) +class CausalIRFlowOperation: + opcode: CausalIROpcode + output_indices: tuple[int, ...] + equation_indices: tuple[int, ...] + equation_ids: tuple[str, ...] + evaluator_slot: int + + +@dataclass(frozen=True, slots=True) +class CausalIRFlowStage: + target_slots: tuple[int, ...] + scatter_compatibility_slots: tuple[int, ...] + equation_ids: tuple[str, ...] + operations: tuple[CausalIRFlowOperation, ...] + + +@dataclass(frozen=True, slots=True) +class CausalIRProgram: + """Immutable callback-free structure used as the backend cache key.""" + + schema_version: int + canonical_slots: tuple[CausalIRCanonicalSlot, ...] + compatibility_slots: tuple[CausalIRCompatibilitySlot, ...] + reset_compatibility_slots: tuple[int, ...] + external_effort_compatibility_slots: tuple[int, ...] + effort_stages: tuple[CausalIREffortStage, ...] + flow_stages: tuple[CausalIRFlowStage, ...] + structural_signature: str + + @property + def assignment_count(self) -> int: + return len(self.canonical_slots) + + @property + def effort_group_count(self) -> int: + return sum(len(stage.operations) for stage in self.effort_stages) + + @property + def flow_assignment_count(self) -> int: + return sum(len(stage.target_slots) for stage in self.flow_stages) + + @property + def effort_scatter_count(self) -> int: + return sum( + len(operation.scatter_compatibility_slots) + for stage in self.effort_stages + for operation in stage.operations + ) + + @property + def eliminated_effort_replica_count(self) -> int: + return self.effort_scatter_count - self.effort_group_count + + @property + def maximum_effort_stage_width(self) -> int: + return max((len(stage.operations) for stage in self.effort_stages), default=0) + + @property + def maximum_flow_stage_width(self) -> int: + return max((len(stage.target_slots) for stage in self.flow_stages), default=0) + + def structural_dict(self) -> dict[str, object]: + return { + "schemaVersion": self.schema_version, + "canonicalSlots": [ + { + "slot": item.slot, + "id": item.id, + "variable": item.variable, + "kind": item.kind, + } + for item in self.canonical_slots + ], + "compatibilitySlots": [ + {"slot": item.slot, "id": item.id, "variable": item.variable} + for item in self.compatibility_slots + ], + "resetCompatibilitySlots": list(self.reset_compatibility_slots), + "externalEffortCompatibilitySlots": list( + self.external_effort_compatibility_slots + ), + "effortStages": [ + { + "variable": stage.variable, + "operations": [ + { + "opcode": operation.opcode.value, + "resultSlot": operation.result_slot, + "anchorCompatibilitySlot": ( + operation.anchor_compatibility_slot + ), + "scatterCompatibilitySlots": list( + operation.scatter_compatibility_slots + ), + "equationId": operation.equation_id, + } + for operation in stage.operations + ], + "evaluations": [ + { + "opcode": evaluation.opcode.value, + "outputIndices": list(evaluation.output_indices), + "equationIndices": list(evaluation.equation_indices), + "equationIds": list(evaluation.equation_ids), + "evaluatorSlot": evaluation.evaluator_slot, + } + for evaluation in stage.evaluations + ], + } + for stage in self.effort_stages + ], + "flowStages": [ + { + "targetSlots": list(stage.target_slots), + "scatterCompatibilitySlots": list( + stage.scatter_compatibility_slots + ), + "equationIds": list(stage.equation_ids), + "operations": [ + { + "opcode": operation.opcode.value, + "outputIndices": list(operation.output_indices), + "equationIndices": list(operation.equation_indices), + "equationIds": list(operation.equation_ids), + "evaluatorSlot": operation.evaluator_slot, + } + for operation in stage.operations + ], + } + for stage in self.flow_stages + ], + } + + def calculate_structural_signature(self) -> str: + payload = json.dumps( + self.structural_dict(), + ensure_ascii=True, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + return sha256(payload).hexdigest() + + +@dataclass(frozen=True, slots=True) +class CausalIRBindings: + readers: tuple[Callable[[], float], ...] + writers: tuple[Callable[[float], None], ...] + evaluators: tuple[Callable[[], object], ...] + + +@dataclass(slots=True) +class CausalIRWorkspace: + structural_signature: str + canonical_values: "np.ndarray[Any, Any]" + effort_residuals: "np.ndarray[Any, Any]" + effort_written: "np.ndarray[Any, Any]" + flow_values: "np.ndarray[Any, Any]" + flow_written: "np.ndarray[Any, Any]" + transaction_values: "np.ndarray[Any, Any]" + + +@dataclass(frozen=True, slots=True) +class CausalIRExecutionResult: + success: bool + fallback_reason: str | None + structural_signature: str + effort_assignment_count: int + flow_assignment_count: int + completed_effort_stage_count: int + completed_flow_stage_count: int + rolled_back: bool + + +StageObserver = Callable[ + [str, int, tuple[int, ...], tuple[float, ...]], + None, +] + + +@dataclass(frozen=True, slots=True) +class CausalNumericIR: + """Bound reference IR; its normal path performs no full snapshot.""" + + program: CausalIRProgram + bindings: CausalIRBindings + + def create_workspace(self) -> CausalIRWorkspace: + try: + import numpy as np + except ImportError as exc: # pragma: no cover + raise RuntimeError("The causal numeric reference IR requires NumPy.") from exc + return CausalIRWorkspace( + structural_signature=self.program.structural_signature, + canonical_values=np.empty( + max(len(self.program.canonical_slots), 1), dtype=np.float64 + ), + effort_residuals=np.empty( + max(self.program.maximum_effort_stage_width, 1), dtype=np.float64 + ), + effort_written=np.empty( + max(self.program.maximum_effort_stage_width, 1), dtype=np.bool_ + ), + flow_values=np.empty( + max(self.program.maximum_flow_stage_width, 1), dtype=np.float64 + ), + flow_written=np.empty( + max(self.program.maximum_flow_stage_width, 1), dtype=np.bool_ + ), + transaction_values=np.empty( + max(len(self.program.compatibility_slots), 1), dtype=np.float64 + ), + ) + + def execute( + self, + workspace: CausalIRWorkspace, + *, + effort_variables: tuple[str, ...] = ("p",), + transactional: bool = False, + stage_observer: StageObserver | None = None, + ) -> CausalIRExecutionResult: + """Interpret the IR; transactional snapshots are audit-only.""" + + program = self.program + bindings = self.bindings + signature = program.structural_signature + if workspace.structural_signature != signature: + raise ValueError("Causal IR workspace belongs to a different program.") + if len(bindings.readers) != len(program.compatibility_slots) or len( + bindings.writers + ) != len(program.compatibility_slots): + raise ValueError("Causal IR compatibility binding count is inconsistent.") + if any(variable not in {"p", "x", "v"} for variable in effort_variables): + return CausalIRExecutionResult( + False, "unsupportedEffortVariable", signature, 0, 0, 0, 0, False + ) + + snapshot_count = 0 + if transactional: + try: + for slot, reader in enumerate(bindings.readers): + workspace.transaction_values[slot] = float(reader()) + snapshot_count += 1 + except MemoryError: + raise + except (ArithmeticError, RuntimeError, TypeError, ValueError) as exc: + return CausalIRExecutionResult( + False, + f"slotReadFailed:{type(exc).__name__}", + signature, + 0, + 0, + 0, + 0, + False, + ) + + effort_count = 0 + flow_count = 0 + completed_effort_stages = 0 + completed_flow_stages = 0 + + def failed(reason: str) -> CausalIRExecutionResult: + rolled_back = False + if transactional: + for slot in range(snapshot_count): + bindings.writers[slot](float(workspace.transaction_values[slot])) + rolled_back = True + return CausalIRExecutionResult( + False, + reason, + signature, + effort_count, + flow_count, + completed_effort_stages, + completed_flow_stages, + rolled_back, + ) + + selected_efforts = frozenset(effort_variables) + for stage_index, stage in enumerate(program.effort_stages): + if stage.variable not in selected_efforts: + continue + width = len(stage.operations) + workspace.effort_written[:width] = False + for evaluation in stage.evaluations: + try: + evaluated = bindings.evaluators[evaluation.evaluator_slot]() + if evaluation.opcode is CausalIROpcode.EFFORT_DIRECT_RESIDUAL: + output = evaluation.output_indices[0] + workspace.effort_residuals[output] = float(evaluated) + workspace.effort_written[output] = True + continue + if not hasattr(evaluated, "__len__"): + raise TypeError("component evaluator returned no sequence") + for output, equation in zip( + evaluation.output_indices, evaluation.equation_indices + ): + if equation >= len(evaluated): + raise IndexError("component equation disappeared") + workspace.effort_residuals[output] = float(evaluated[equation]) + workspace.effort_written[output] = True + except MemoryError: + raise + except ( + ArithmeticError, + IndexError, + RuntimeError, + TypeError, + ValueError, + ) as exc: + return failed(f"effortEvaluationFailed:{type(exc).__name__}") + if any(not bool(workspace.effort_written[index]) for index in range(width)): + return failed("effortEvaluationCoverageMismatch") + for output, operation in enumerate(stage.operations): + try: + anchor = float(bindings.readers[operation.anchor_compatibility_slot]()) + target = anchor - float(workspace.effort_residuals[output]) + except MemoryError: + raise + except ( + ArithmeticError, + IndexError, + RuntimeError, + TypeError, + ValueError, + ) as exc: + return failed(f"effortAssignmentFailed:{type(exc).__name__}") + if not isfinite(target) or ( + stage.variable == "p" and target <= PRESSURE_LOWER_BOUND_PA + ): + return failed("nonFiniteOrInvalidEffortAssignment") + workspace.canonical_values[operation.result_slot] = target + for slot in operation.scatter_compatibility_slots: + bindings.writers[slot](target) + effort_count += 1 + completed_effort_stages += 1 + if stage_observer is not None: + try: + stage_observer( + f"effort:{stage.variable}", + stage_index, + tuple(item.result_slot for item in stage.operations), + tuple( + float(workspace.canonical_values[item.result_slot]) + for item in stage.operations + ), + ) + except MemoryError: + raise + except Exception as exc: + return failed(f"stageObserverFailed:{type(exc).__name__}") + + try: + external_finite = all( + isfinite(float(bindings.readers[slot]())) + for slot in program.external_effort_compatibility_slots + ) + except MemoryError: + raise + except (ArithmeticError, RuntimeError, TypeError, ValueError) as exc: + return failed(f"externalEffortReadFailed:{type(exc).__name__}") + if not external_finite: + return failed("nonFiniteExternalEffort") + + for slot in program.reset_compatibility_slots: + bindings.writers[slot](0.0) + for stage_index, stage in enumerate(program.flow_stages): + width = len(stage.target_slots) + workspace.flow_written[:width] = False + for operation in stage.operations: + try: + evaluated = bindings.evaluators[operation.evaluator_slot]() + if operation.opcode is CausalIROpcode.FLOW_DIRECT: + output = operation.output_indices[0] + workspace.flow_values[output] = float(evaluated) + workspace.flow_written[output] = True + continue + if not hasattr(evaluated, "__len__"): + raise TypeError("component evaluator returned no sequence") + for output, equation in zip( + operation.output_indices, operation.equation_indices + ): + if equation >= len(evaluated): + raise IndexError("component equation disappeared") + # Targets are zero before the stage; preserve -residual. + workspace.flow_values[output] = -float(evaluated[equation]) + workspace.flow_written[output] = True + except MemoryError: + raise + except ( + ArithmeticError, + IndexError, + RuntimeError, + TypeError, + ValueError, + ) as exc: + return failed(f"flowEvaluationFailed:{type(exc).__name__}") + if any(not bool(workspace.flow_written[index]) for index in range(width)): + return failed("flowAssignmentCoverageMismatch") + for output, (canonical, compatibility) in enumerate( + zip(stage.target_slots, stage.scatter_compatibility_slots) + ): + target = float(workspace.flow_values[output]) + if not isfinite(target): + return failed("nonFiniteFlowAssignment") + workspace.canonical_values[canonical] = target + bindings.writers[compatibility](target) + flow_count += 1 + completed_flow_stages += 1 + if stage_observer is not None: + try: + stage_observer( + "flow", + stage_index, + stage.target_slots, + tuple(float(workspace.flow_values[i]) for i in range(width)), + ) + except MemoryError: + raise + except Exception as exc: + return failed(f"stageObserverFailed:{type(exc).__name__}") + + return CausalIRExecutionResult( + True, + None, + signature, + effort_count, + flow_count, + completed_effort_stages, + completed_flow_stages, + False, + ) + + +@dataclass(frozen=True, slots=True) +class CausalIRCompilation: + ir: CausalNumericIR | None + fallback_reason: str | None + + @property + def supported(self) -> bool: + return self.ir is not None and self.fallback_reason is None + + +def _unsupported(reason: str) -> CausalIRCompilation: + return CausalIRCompilation(ir=None, fallback_reason=reason) + + +def _unique_slots(items: Iterable[int]) -> tuple[int, ...]: + return tuple(dict.fromkeys(int(item) for item in items)) + + +def _compile_effort_evaluations( + operations: tuple[CausalIREffortOperation, ...], + anchor_evaluators: tuple[Callable[[], float], ...], + component_locations: dict[ + str, tuple[object, Callable[[], tuple[float, ...]], int] + ], + evaluators: list[Callable[[], object]], +) -> tuple[CausalIREffortEvaluation, ...]: + grouped: dict[int, list[tuple[int, int, str]]] = {} + component_callbacks: dict[int, Callable[[], tuple[float, ...]]] = {} + direct: list[tuple[int, Callable[[], float], str]] = [] + for output, (operation, anchor_evaluate) in enumerate( + zip(operations, anchor_evaluators) + ): + location = component_locations.get(operation.equation_id) + if location is None: + direct.append((output, anchor_evaluate, operation.equation_id)) + continue + owner, evaluate, equation = location + key = id(owner) + component_callbacks[key] = evaluate + grouped.setdefault(key, []).append((output, equation, operation.equation_id)) + + compiled: list[CausalIREffortEvaluation] = [] + for output, evaluate, equation_id in direct: + evaluator = len(evaluators) + evaluators.append(evaluate) + compiled.append( + CausalIREffortEvaluation( + CausalIROpcode.EFFORT_DIRECT_RESIDUAL, + (output,), + (), + (equation_id,), + evaluator, + ) + ) + for key, entries in grouped.items(): + evaluator = len(evaluators) + evaluators.append(component_callbacks[key]) + compiled.append( + CausalIREffortEvaluation( + CausalIROpcode.EFFORT_COMPONENT_RESIDUAL, + tuple(item[0] for item in entries), + tuple(item[1] for item in entries), + tuple(item[2] for item in entries), + evaluator, + ) + ) + return tuple(compiled) + + +def compile_causal_numeric_ir(solver: object) -> CausalIRCompilation: + """Lower a compile-proven global plan; unsupported plans fail closed.""" + + if not bool(getattr(solver, "_causal_fast_path_eligible", False)): + return _unsupported( + str( + getattr(solver, "_causal_fast_path_fallback_reason", None) + or "causalProofNotAvailable" + ) + ) + try: + unknowns = tuple(getattr(solver, "unknowns")) + effort_plan = getattr(solver, "_causal_effort_plan_by_variable") + flow_plan = tuple(getattr(solver, "_explicit_flow_plan")) + component_plan = tuple(getattr(solver, "_component_equation_plan")) + reset_unknowns = tuple( + getattr(solver, "_explicit_flow_unknowns_by_variables")[ + frozenset(("f", "m_flow")) + ] + ) + external_unknowns = tuple( + getattr(solver, "_causal_external_effort_unknowns") + ) + except (AttributeError, KeyError, TypeError): + return _unsupported("unsupportedCausalSolverContract") + + unknown_ids = tuple(str(item.id) for item in unknowns) + if len(set(unknown_ids)) != len(unknown_ids): + return _unsupported("duplicateAlgebraicUnknown") + compatibility_slot_by_id = { + unknown_id: slot for slot, unknown_id in enumerate(unknown_ids) + } + compatibility_slots = tuple( + CausalIRCompatibilitySlot(slot, unknown_id, str(unknown.variable)) + for slot, (unknown_id, unknown) in enumerate(zip(unknown_ids, unknowns)) + ) + readers = tuple(item.read for item in unknowns) + writers = tuple(item.write for item in unknowns) + evaluators: list[Callable[[], object]] = [] + canonical_slots: list[CausalIRCanonicalSlot] = [] + + component_locations: dict[ + str, tuple[object, Callable[[], tuple[float, ...]], int] + ] = {} + try: + for plan in component_plan: + for equation, template in enumerate(plan.templates): + component_locations[str(template.id)] = ( + plan.component, + plan.evaluate, + equation, + ) + except (AttributeError, TypeError): + return _unsupported("unsupportedComponentEvaluationContract") + + effort_stages: list[CausalIREffortStage] = [] + try: + for variable in ("p", "x", "v"): + operations: list[CausalIREffortOperation] = [] + anchors: list[Callable[[], float]] = [] + for assignment in effort_plan[variable]: + result = len(canonical_slots) + equation_id = str(assignment.anchor.equation_id) + scatter = tuple( + compatibility_slot_by_id[item.id] + for item in assignment.members + ) + if not scatter or len(set(scatter)) != len(scatter): + return _unsupported("invalidEffortScatterSlots") + canonical_slots.append( + CausalIRCanonicalSlot( + result, + f"effort:{variable}:{equation_id}", + variable, + "effort_group", + ) + ) + operations.append( + CausalIREffortOperation( + CausalIROpcode.EFFORT_BROADCAST, + variable, + result, + compatibility_slot_by_id[assignment.anchor.unknown.id], + scatter, + equation_id, + ) + ) + anchors.append(assignment.anchor.evaluate) + operation_tuple = tuple(operations) + effort_stages.append( + CausalIREffortStage( + variable, + operation_tuple, + _compile_effort_evaluations( + operation_tuple, + tuple(anchors), + component_locations, + evaluators, + ), + ) + ) + except (AttributeError, KeyError, TypeError): + return _unsupported("unsupportedEffortPlanContract") + + flow_stages: list[CausalIRFlowStage] = [] + try: + for stage in flow_plan: + scatter = tuple( + compatibility_slot_by_id[item.unknown.id] + for item in stage.assignments + ) + equation_ids = tuple(str(item.equation_id) for item in stage.assignments) + if len(set(scatter)) != len(scatter): + return _unsupported("duplicateFlowTargetInStage") + targets: list[int] = [] + for assignment in stage.assignments: + target = len(canonical_slots) + targets.append(target) + canonical_slots.append( + CausalIRCanonicalSlot( + target, + f"flow:{assignment.unknown.id}", + str(assignment.unknown.variable), + "flow_assignment", + ) + ) + covered: list[int] = [] + operations: list[CausalIRFlowOperation] = [] + for output, evaluate in stage.direct_evaluations: + output = int(output) + evaluator = len(evaluators) + evaluators.append(evaluate) + operations.append( + CausalIRFlowOperation( + CausalIROpcode.FLOW_DIRECT, + (output,), + (), + (equation_ids[output],), + evaluator, + ) + ) + covered.append(output) + for evaluation in stage.component_evaluations: + evaluator = len(evaluators) + evaluators.append(evaluation.evaluate) + outputs = tuple(int(item) for item in evaluation.assignment_indices) + operations.append( + CausalIRFlowOperation( + CausalIROpcode.FLOW_COMPONENT_RESIDUAL, + outputs, + tuple(int(item) for item in evaluation.equation_indices), + tuple(str(item) for item in evaluation.equation_ids), + evaluator, + ) + ) + covered.extend(outputs) + if sorted(covered) != list(range(len(scatter))): + return _unsupported("flowStageEvaluationCoverageMismatch") + flow_stages.append( + CausalIRFlowStage( + tuple(targets), scatter, equation_ids, tuple(operations) + ) + ) + except (AttributeError, IndexError, KeyError, TypeError): + return _unsupported("unsupportedFlowPlanContract") + + try: + reset_slots = _unique_slots( + compatibility_slot_by_id[item.id] for item in reset_unknowns + ) + external_slots = _unique_slots( + compatibility_slot_by_id[item.id] for item in external_unknowns + ) + except (AttributeError, KeyError): + return _unsupported("unknownCausalBoundarySlot") + flow_scatter = tuple( + item for stage in flow_stages for item in stage.scatter_compatibility_slots + ) + if len(set(flow_scatter)) != len(flow_scatter): + return _unsupported("duplicateExplicitFlowAssignment") + if set(flow_scatter) != set(reset_slots): + return _unsupported("incompleteExplicitFlowCoverage") + + program = CausalIRProgram( + CAUSAL_NUMERIC_IR_SCHEMA_VERSION, + tuple(canonical_slots), + compatibility_slots, + reset_slots, + external_slots, + tuple(effort_stages), + tuple(flow_stages), + "", + ) + program = replace( + program, structural_signature=program.calculate_structural_signature() + ) + return CausalIRCompilation( + CausalNumericIR( + program, + CausalIRBindings(readers, writers, tuple(evaluators)), + ), + None, + ) diff --git a/app/simulation/solvers/solver.py b/app/simulation/solvers/solver.py index 711e7ca..4a4c775 100644 --- a/app/simulation/solvers/solver.py +++ b/app/simulation/solvers/solver.py @@ -29,6 +29,8 @@ StateTransitionHandler = Callable[ ] _MAX_STATE_TRANSITIONS_AT_SAME_TIME = 64 +_MAX_RECOVERABLE_RETRIES = 16 +_RECOVERABLE_RETRY_FACTOR = 0.5 class IntegrationCancelled(Exception): @@ -48,6 +50,29 @@ class SolveIVPConfig: first_step: float | None = None +@dataclass(frozen=True) +class RecoverableRetryDiagnostics: + """One recoverable trial failure and the step cap chosen for its retry.""" + + phase: Literal["constructor", "step", "solver-status"] + attempted_step: float + reason: str + next_max_step: float | None = None + next_first_step: float | None = None + + def as_dict(self) -> dict[str, object]: + result: dict[str, object] = { + "phase": self.phase, + "attemptedStep": self.attempted_step, + "reason": self.reason, + } + if self.next_max_step is not None: + result["nextMaxStep"] = self.next_max_step + if self.next_first_step is not None: + result["nextFirstStep"] = self.next_first_step + return result + + @dataclass(frozen=True) class SolverSegmentDiagnostics: """Work performed by implicit solver instances inside one event segment.""" @@ -61,6 +86,7 @@ class SolverSegmentDiagnostics: accepted_step_count: int = 0 solver_start_count: int = 0 state_transition_count: int = 0 + state_transition_times: tuple[float, ...] = () recoverable_retry_count: int = 0 jacobian_evaluation_count: int = 0 jacobian_full_build_count: int = 0 @@ -72,9 +98,10 @@ class SolverSegmentDiagnostics: exact_column_build_count: int = 0 exact_column_fallback_count: int = 0 jacobian_assembly_seconds: float = 0.0 + recoverable_retries: tuple[RecoverableRetryDiagnostics, ...] = () - def as_dict(self) -> dict[str, float | int]: - result: dict[str, float | int] = { + def as_dict(self) -> dict[str, object]: + result: dict[str, object] = { "startTime": self.start_time, "requestedStopTime": self.requested_stop_time, "simulatedUntil": self.simulated_until, @@ -86,6 +113,14 @@ class SolverSegmentDiagnostics: "stateTransitionCount": self.state_transition_count, "recoverableRetryCount": self.recoverable_retry_count, } + if self.state_transition_times: + result["stateTransitionTimes"] = list( + self.state_transition_times + ) + if self.recoverable_retries: + result["recoverableRetries"] = [ + retry.as_dict() for retry in self.recoverable_retries + ] if ( self.jacobian_evaluation_count or self.finite_difference_rhs_evaluation_count @@ -143,6 +178,80 @@ def _jacobian_diagnostic_snapshot( } +def _positive_finite_step(value: object) -> float | None: + if value is None: + return None + try: + candidate = abs(float(value)) + except (TypeError, ValueError, OverflowError): + return None + return candidate if candidate > 0.0 and math.isfinite(candidate) else None + + +def _smallest_positive_finite_step(*values: object) -> float: + """Return a conservative step bound from configuration candidates.""" + + candidates = [ + candidate + for value in values + if (candidate := _positive_finite_step(value)) is not None + ] + if not candidates: + raise ValueError("No positive finite integration step is available.") + return min(candidates) + + +def _solver_attempted_step( + solver: object, + *, + segment_max_step: float, + remaining_interval: float, +) -> float: + """Snapshot the real trial scale before calling ``solver.step()``. + + SciPy exposes the proposed step as ``h_abs``. ``step_size`` is the prior + accepted step, so it is only a fallback for solvers without a valid + ``h_abs``; it must not reduce an otherwise valid failed-trial estimate. + """ + + configured_cap = _smallest_positive_finite_step( + segment_max_step, + remaining_interval, + ) + for attribute in ("h_abs", "step_size"): + try: + candidate = _positive_finite_step( + getattr(solver, attribute, None) + ) + except Exception: + # A third-party OdeSolver may implement these as fragile + # properties. The configured cap remains a safe fallback. + continue + if candidate is not None: + return min(candidate, configured_cap) + return configured_cap + + +def _recoverable_retry_steps( + attempted_step: float, + *, + last_accepted_time: float, +) -> tuple[float, float] | None: + """Return strictly smaller max/first steps, or None at machine precision.""" + + next_step = _RECOVERABLE_RETRY_FACTOR * attempted_step + minimum_step = 64.0 * math.ulp(max(abs(last_accepted_time), 1.0)) + if ( + not math.isfinite(next_step) + or next_step <= minimum_step + or next_step >= attempted_step + ): + return None + # This first step is intentionally one-shot. Keeping it equal to the new + # cap makes both controls strictly smaller than the failed trial scale. + return next_step, next_step + + @dataclass(frozen=True) class ODESolution: t: list[float] @@ -751,13 +860,18 @@ def _integrate_scipy_stepwise( segment_max_step = float(config.max_step) recoverable_retry_count = 0 last_recoverable_error: RecoverableTrialStateError | None = None + retry_first_step: float | None = None segment_nfev = 0 segment_njev = 0 segment_nlu = 0 segment_accepted_steps = 0 segment_solver_starts = 0 segment_state_transitions = 0 + segment_state_transition_times: list[float] = [] segment_recoverable_retries = 0 + segment_recoverable_retry_diagnostics: list[ + RecoverableRetryDiagnostics + ] = [] jacobian_work_start = _jacobian_diagnostic_snapshot(implicit_jac) while has_integration_interval and last_accepted_time < integration_end: @@ -777,8 +891,8 @@ def _integrate_scipy_stepwise( elif jac_sparsity is not None: solver_options["jac_sparsity"] = jac_sparsity requested_first_step = ( - 0.1 * segment_max_step - if last_recoverable_error is not None + retry_first_step + if retry_first_step is not None else config.first_step ) if requested_first_step is not None: @@ -786,8 +900,12 @@ def _integrate_scipy_stepwise( requested_first_step, integration_end - last_accepted_time, ) - try: + constructor_attempted_step = _smallest_positive_finite_step( + segment_max_step, + integration_end - last_accepted_time, + solver_options.get("first_step"), + ) start_segment = getattr(implicit_jac, "start_segment", None) if start_segment is not None: start_segment() @@ -806,14 +924,33 @@ def _integrate_scipy_stepwise( recoverable_retry_count += 1 segment_recoverable_retries += 1 last_recoverable_error = exc - next_step = 0.5 * segment_max_step - minimum_step = 64.0 * math.ulp(max(abs(last_accepted_time), 1.0)) - if recoverable_retry_count > 16 or next_step <= minimum_step: + retry_steps = ( + _recoverable_retry_steps( + constructor_attempted_step, + last_accepted_time=last_accepted_time, + ) + if recoverable_retry_count <= _MAX_RECOVERABLE_RETRIES + else None + ) + segment_recoverable_retry_diagnostics.append( + RecoverableRetryDiagnostics( + phase="constructor", + attempted_step=constructor_attempted_step, + reason=str(exc), + next_max_step=( + retry_steps[0] if retry_steps is not None else None + ), + next_first_step=( + retry_steps[1] if retry_steps is not None else None + ), + ) + ) + if retry_steps is None: status = "failed" message = str(exc) error = exc break - segment_max_step = next_step + segment_max_step, retry_first_step = retry_steps continue except Exception as exc: status = "failed" @@ -836,6 +973,13 @@ def _integrate_scipy_stepwise( step_start_time = last_accepted_time step_start_state = list(last_accepted_state) try: + attempted_step = _solver_attempted_step( + solver, + segment_max_step=segment_max_step, + remaining_interval=( + integration_end - last_accepted_time + ), + ) step_message = solver.step() except IntegrationCancelled: status = "cancelled" @@ -847,17 +991,38 @@ def _integrate_scipy_stepwise( recoverable_retry_count += 1 segment_recoverable_retries += 1 last_recoverable_error = exc - attempted_step = segment_max_step - next_step = 0.5 * attempted_step - minimum_step = 64.0 * math.ulp( - max(abs(last_accepted_time), 1.0) + retry_steps = ( + _recoverable_retry_steps( + attempted_step, + last_accepted_time=last_accepted_time, + ) + if recoverable_retry_count + <= _MAX_RECOVERABLE_RETRIES + else None ) - if recoverable_retry_count > 16 or next_step <= minimum_step: + segment_recoverable_retry_diagnostics.append( + RecoverableRetryDiagnostics( + phase="step", + attempted_step=attempted_step, + reason=str(exc), + next_max_step=( + retry_steps[0] + if retry_steps is not None + else None + ), + next_first_step=( + retry_steps[1] + if retry_steps is not None + else None + ), + ) + ) + if retry_steps is None: status = "failed" message = str(exc) error = exc break - segment_max_step = next_step + segment_max_step, retry_first_step = retry_steps restart_after_recoverable = True break except Exception as exc: @@ -871,21 +1036,62 @@ def _integrate_scipy_stepwise( if last_recoverable_error is not None: recoverable_retry_count += 1 segment_recoverable_retries += 1 - next_step = 0.5 * segment_max_step - minimum_step = 64.0 * math.ulp( - max(abs(last_accepted_time), 1.0) + retry_steps = ( + _recoverable_retry_steps( + attempted_step, + last_accepted_time=last_accepted_time, + ) + if recoverable_retry_count + <= _MAX_RECOVERABLE_RETRIES + else None ) - if ( - recoverable_retry_count <= 16 - and next_step > minimum_step - ): - segment_max_step = next_step + failure_reason = str( + step_message or last_recoverable_error + ) + segment_recoverable_retry_diagnostics.append( + RecoverableRetryDiagnostics( + phase="solver-status", + attempted_step=attempted_step, + reason=failure_reason, + next_max_step=( + retry_steps[0] + if retry_steps is not None + else None + ), + next_first_step=( + retry_steps[1] + if retry_steps is not None + else None + ), + ) + ) + if retry_steps is not None: + segment_max_step, retry_first_step = retry_steps restart_after_recoverable = True break status = "failed" message = str(step_message or "Integration step failed.") break + # A returned running/finished status means this step was + # accepted. Any prior recoverable failure is now historical: + # it must not influence an event restart or an ordinary later + # solver failure. The reduced cap is local to the failed + # trial: after one accepted retry step, let this solver grow + # adaptively again and ensure a later event restart receives + # the configured maximum. The retry-specific first step is + # likewise strictly one-shot. + if retry_first_step is not None: + segment_max_step = float(config.max_step) + try: + solver.max_step = segment_max_step + except (AttributeError, TypeError, ValueError): + # Third-party OdeSolver-compatible test doubles may not + # expose a writable cap. SciPy's supported solvers do. + pass + last_recoverable_error = None + retry_first_step = None + recoverable_retry_count = 0 segment_accepted_steps += 1 step_end_time = float(solver.t) step_end_state = [float(value) for value in solver.y] @@ -940,6 +1146,9 @@ def _integrate_scipy_stepwise( if transition is not None: segment_state_transitions += 1 + segment_state_transition_times.append( + float(transition.time) + ) try: same_time_transition_count = ( _next_same_time_transition_count( @@ -997,7 +1206,6 @@ def _integrate_scipy_stepwise( last_accepted_time = step_end_time last_accepted_state = step_end_state - recoverable_retry_count = 0 reported_time = ( float(segment_end) if is_breakpoint and solver.status == "finished" @@ -1057,7 +1265,13 @@ def _integrate_scipy_stepwise( accepted_step_count=segment_accepted_steps, solver_start_count=segment_solver_starts, state_transition_count=segment_state_transitions, + state_transition_times=tuple( + segment_state_transition_times + ), recoverable_retry_count=segment_recoverable_retries, + recoverable_retries=tuple( + segment_recoverable_retry_diagnostics + ), jacobian_evaluation_count=int( jacobian_work["jacobianEvaluationCount"] ), @@ -1150,6 +1364,7 @@ def integrate_ode( state_transition_handler: StateTransitionHandler | None = None, jac_sparsity=None, jac: JacobianCallable | None = None, + recoverable_trial_retries: bool = False, ): """Integrate an ODE, optionally restarting at equation discontinuities. @@ -1161,6 +1376,11 @@ def integrate_ode( interpolant. When it returns a transition, samples before the event retain the pre-event trajectory, the reset state is stored at the event, and a fresh solver continues from that state. + + ``recoverable_trial_retries`` opts an eventless/cancellation-free caller + into the stepwise path so a ``RecoverableTrialStateError`` can rebuild the + solver from its last accepted state. It defaults to false to preserve the + direct ``solve_ivp`` path for ordinary callers. """ if ( @@ -1209,6 +1429,7 @@ def integrate_ode( cancel_check is not None or normalized_breakpoints or state_transition_handler is not None + or recoverable_trial_retries ): return _integrate_scipy_stepwise( rhs, diff --git a/app/simulation/solvers/stream.py b/app/simulation/solvers/stream.py index 1029124..1c9a460 100644 --- a/app/simulation/solvers/stream.py +++ b/app/simulation/solvers/stream.py @@ -60,6 +60,16 @@ class StreamResolver: for component in self._components if not isinstance(component, DynamicComponent) ) + # State ownership and pressure-flow stream sensitivity are independent + # classifications. Compile this hook by behavior so algebraic + # components such as PNL00R receive their upstream-temperature + # references without dispatching a no-op to every component at runtime. + self._flow_temperature_reference_components = tuple( + component + for component in self._components + if type(component).update_flow_temperature_references + is not Component.update_flow_temperature_references + ) self._ports = tuple( (component.name, port_name, port) for component in self._components @@ -122,6 +132,16 @@ class StreamResolver: ) return values + @profile_phase("simulation.refresh", minimum_mode="audit") + def refresh_flow_temperature_references(self) -> None: + """Refresh pressure-flow property inputs without changing stream outflows.""" + + connected = self.connected_temperature_reference_enthalpies() + for component in self._flow_temperature_reference_components: + component.update_flow_temperature_references( + connected[component.name] + ) + @profile_phase("simulation.refresh", minimum_mode="audit") def _refresh_dynamic_components(self) -> None: for component in self._dynamic_components: diff --git a/app/simulation/solvers/tangent.py b/app/simulation/solvers/tangent.py index fea4799..0414729 100644 --- a/app/simulation/solvers/tangent.py +++ b/app/simulation/solvers/tangent.py @@ -1,11 +1,13 @@ -"""Proof-gated tangent columns for the three-piston reference network. +"""Proof-gated tangent columns for supported piston branch networks. This module is deliberately narrower than the generic algebraic solver. It only compiles a tangent provider after proving the state layout, component -types, physical connections, and causal execution plan used by the committed -three-piston XML. A failed proof leaves the ordinary seed-0 numerical -Jacobian in control; a runtime mode boundary requests the same one-build -fallback through :class:`ExactColumnsUnavailable`. +types, physical connections, and causal execution plan used by every selected +piston branch. The legacy three-piston entry point remains available for its +committed fixture, while the topology-driven entry point discovers any number +of branches without depending on component names. A failed proof leaves the +ordinary seed-0 numerical Jacobian in control; a runtime mode boundary requests +the same one-build fallback through :class:`ExactColumnsUnavailable`. """ from __future__ import annotations @@ -57,6 +59,7 @@ class ThreePistonBranch: chamber: object pipe: object contact: object + chamber_connection_port: str velocity_index: int position_index: int @@ -91,7 +94,7 @@ def _failed(reason: str) -> ThreePistonTangentCompilation: class ThreePistonTangentProvider: - """Batched six-direction provider compiled for one system instance.""" + """Batched selected-branch provider compiled for one system instance.""" def __init__( self, @@ -611,10 +614,17 @@ class ThreePistonTangentProvider: return out -def compile_three_piston_tangent_provider( +@dataclass(frozen=True) +class _PistonBranchSpec: + names: tuple[str, str, str, str, str] + chamber_connection_port: str + + +def _compile_named_piston_tangent_provider( system: "GenericFluidSystem", + branch_specs: Sequence[_PistonBranchSpec], ) -> ThreePistonTangentCompilation: - """Compile the proof-gated target provider, or return a stable reason.""" + """Compile a named, topology-proven set of supported piston branches.""" solver = system.pressure_flow_solver if not solver.causal_fast_path_eligible: @@ -651,7 +661,8 @@ def compile_three_piston_tangent_provider( "amesim_lstp00a", ) branches: list[ThreePistonBranch] = [] - for names in _TARGET_BRANCH_NAMES: + for branch_spec in branch_specs: + names = branch_spec.names try: components = tuple(system.network.components[name] for name in names) except KeyError: @@ -671,6 +682,9 @@ def compile_three_piston_tangent_provider( chamber=chamber, pipe=pipe, contact=contact, + chamber_connection_port=( + branch_spec.chamber_connection_port + ), velocity_index=offset, position_index=offset + 1, ) @@ -681,7 +695,15 @@ def compile_three_piston_tangent_provider( required_pairs.update( { frozenset((Endpoint(branch.mass.name, "port_1"), Endpoint(branch.piston.name, "port_2"))), - frozenset((Endpoint(branch.piston.name, "port_1"), Endpoint(branch.chamber.name, "port_3"))), + frozenset( + ( + Endpoint(branch.piston.name, "port_1"), + Endpoint( + branch.chamber.name, + branch.chamber_connection_port, + ), + ) + ), frozenset((Endpoint(branch.chamber.name, "port_1"), Endpoint(branch.pipe.name, "port_1"))), frozenset((Endpoint(branch.piston.name, "port_5"), Endpoint(branch.contact.name, "port_1"))), } @@ -790,7 +812,7 @@ def compile_three_piston_tangent_provider( # Secondary pressure blocks may contain the same causal flow coordinates, # so membership alone is not evidence of a stream derivative. The direct # enthalpy reach proof below, plus the runtime dynamic-owner gate, is the - # relevant condition for this target-specific program. + # relevant condition for this proof-gated branch program. neighbor_by_endpoint: dict[Endpoint, Endpoint] = {} for connection in system.network.connections: if connection.kind != "physical": @@ -848,3 +870,138 @@ def compile_three_piston_tangent_provider( provider, reached_assignment_count=len(reached_assignments), ) + + +def _physical_neighbor_map( + system: "GenericFluidSystem", +) -> dict[Endpoint, Endpoint]: + """Return the one-to-one physical connector map proved by the network.""" + + neighbors: dict[Endpoint, Endpoint] = {} + for connection in system.network.connections: + if connection.kind != "physical": + continue + first, second = connection.endpoints + # SimulationNetwork already rejects multiply connected physical ports. + # Retain a defensive gate because this compiler may also be called by + # custom network builders in tests or downstream applications. + if first in neighbors or second in neighbors: + raise ValueError("Physical endpoint has more than one connection.") + neighbors[first] = second + neighbors[second] = first + return neighbors + + +def _discover_supported_piston_branch_specs( + system: "GenericFluidSystem", +) -> tuple[_PistonBranchSpec, ...] | ThreePistonTangentCompilation: + """Discover every complete catalog piston branch by type and port topology. + + A PNRP17 is the unambiguous root: its mechanical piston-side port must be + driven by a singleton MECMAS21 coordinate, its pneumatic port must feed a + PNCH012 whose first port feeds PNL0001, and its rod-side port must meet an + LSTP00A contact. If even one PNRP17 is only partially supported, reject the + batch with a stable reason instead of silently omitting derivative columns. + """ + + try: + neighbors = _physical_neighbor_map(system) + except ValueError: + return _failed("unsupportedPistonBranchTopology:multipleConnection") + + components = system.network.components + + def model_type(endpoint: Endpoint | None) -> str | None: + if endpoint is None: + return None + return getattr(components[endpoint.component], "MODEL_TYPE", None) + + pistons = tuple( + component + for component in components.values() + if getattr(component, "MODEL_TYPE", None) == "amesim_pnrp17" + ) + if not pistons: + return _failed("supportedPistonBranchMissing") + + specs: list[_PistonBranchSpec] = [] + for piston in pistons: + mass_endpoint = neighbors.get(Endpoint(piston.name, "port_2")) + if ( + model_type(mass_endpoint) != "amesim_mecmas21" + or mass_endpoint is None + or mass_endpoint.port != "port_1" + ): + return _failed("unsupportedPistonBranchTopology:mass") + + chamber_endpoint = neighbors.get(Endpoint(piston.name, "port_1")) + if model_type(chamber_endpoint) != "amesim_pnch012": + return _failed("unsupportedPistonBranchTopology:chamber") + assert chamber_endpoint is not None + + pipe_endpoint = neighbors.get( + Endpoint(chamber_endpoint.component, "port_1") + ) + if ( + model_type(pipe_endpoint) != "amesim_pnl0001" + or pipe_endpoint is None + or pipe_endpoint.port != "port_1" + ): + return _failed("unsupportedPistonBranchTopology:pipe") + + contact_endpoint = neighbors.get(Endpoint(piston.name, "port_5")) + if ( + model_type(contact_endpoint) != "amesim_lstp00a" + or contact_endpoint is None + or contact_endpoint.port != "port_1" + ): + return _failed("unsupportedPistonBranchTopology:contact") + + specs.append( + _PistonBranchSpec( + names=( + mass_endpoint.component, + piston.name, + chamber_endpoint.component, + pipe_endpoint.component, + contact_endpoint.component, + ), + chamber_connection_port=chamber_endpoint.port, + ) + ) + + # Port uniqueness already proves unique pistons and masses, but explicitly + # reject a custom multi-port chamber/contact/pipe shared by two roots. The + # tangent propagation assumes one geometry seed per selected state owner. + for role_index in range(5): + if len({spec.names[role_index] for spec in specs}) != len(specs): + return _failed("unsupportedPistonBranchTopology:sharedComponent") + return tuple(specs) + + +def compile_supported_piston_tangent_provider( + system: "GenericFluidSystem", +) -> ThreePistonTangentCompilation: + """Compile all name-independent, topology-supported piston branches.""" + + discovered = _discover_supported_piston_branch_specs(system) + if isinstance(discovered, ThreePistonTangentCompilation): + return discovered + return _compile_named_piston_tangent_provider(system, discovered) + + +def compile_three_piston_tangent_provider( + system: "GenericFluidSystem", +) -> ThreePistonTangentCompilation: + """Compile the committed legacy three-piston target by its stable names.""" + + return _compile_named_piston_tangent_provider( + system, + tuple( + _PistonBranchSpec( + names=names, + chamber_connection_port="port_3", + ) + for names in _TARGET_BRANCH_NAMES + ), + ) diff --git a/app/simulation/solvers/thermofluid.py b/app/simulation/solvers/thermofluid.py new file mode 100644 index 0000000..d2f028e --- /dev/null +++ b/app/simulation/solvers/thermofluid.py @@ -0,0 +1,567 @@ +from __future__ import annotations + +from collections.abc import Callable, Sequence +from copy import copy +from dataclasses import dataclass, replace + +from app.simulation.core.errors import RecoverableTrialStateError +from app.simulation.core.ports import PortState + + +_STREAM_CACHE_ATTRIBUTE_NAMES = frozenset( + { + "_connected_h", + "temperature_reference_h", + } +) + + +def _is_stream_cache_attribute(name: str) -> bool: + """Return whether an attribute belongs to the stream/temperature replay state. + + Catalog components currently use ``_connected_h`` and + ``temperature_reference_h``. The name-based extension keeps conservative + third-party caches recoverable without copying an entire component graph. + Components with opaque cache names can provide the explicit hooks documented + by :class:`ThermofluidTransactionPlan`. + """ + + lowered = name.lower() + return ( + name in _STREAM_CACHE_ATTRIBUTE_NAMES + or lowered.startswith("_stream_") + or "connected_h" in lowered + or "connected_enthalpy" in lowered + or "temperature_reference" in lowered + ) + + +def _copy_cache_value(value: object) -> object: + """Shallow-copy a stream cache without traversing the component graph.""" + + if isinstance(value, (dict, list, set, bytearray)): + return copy(value) + return value + + +@dataclass(frozen=True) +class ThermofluidWorstPort: + component: str + port: str + value: float + signed_delta: float + + def as_dict(self) -> dict[str, object]: + return { + "component": self.component, + "port": self.port, + "value": self.value, + "signedDelta": self.signed_delta, + } + + +@dataclass(frozen=True) +class ThermofluidIterationDelta: + iteration: int + max_delta: float + scale: float + tolerance: float + worst_port: ThermofluidWorstPort | None + + def as_dict(self) -> dict[str, object]: + return { + "iteration": self.iteration, + "maxDelta": self.max_delta, + "scale": self.scale, + "tolerance": self.tolerance, + "worstPort": ( + self.worst_port.as_dict() + if self.worst_port is not None + else None + ), + } + + +@dataclass(frozen=True) +class ThermofluidClosureSuccess: + rhs_time: float + iterations: int + max_delta: float + scale: float + tolerance: float + worst_port: ThermofluidWorstPort | None + + @classmethod + def from_iteration( + cls, + rhs_time: float, + delta: ThermofluidIterationDelta, + ) -> ThermofluidClosureSuccess: + return cls( + rhs_time=float(rhs_time), + iterations=delta.iteration, + max_delta=delta.max_delta, + scale=delta.scale, + tolerance=delta.tolerance, + worst_port=delta.worst_port, + ) + + def as_dict(self) -> dict[str, object]: + return { + "rhsTime": self.rhs_time, + "iterations": self.iterations, + "maxDelta": self.max_delta, + "scale": self.scale, + "tolerance": self.tolerance, + "worstPort": ( + self.worst_port.as_dict() + if self.worst_port is not None + else None + ), + } + + +@dataclass(frozen=True) +class ThermofluidClosureFailure: + failed_rhs_time: float + iterations: int + delta_tail: tuple[ThermofluidIterationDelta, ...] + max_delta: float + scale: float + tolerance: float + worst_port: ThermofluidWorstPort | None + failure_count: int = 0 + + @classmethod + def from_iterations( + cls, + failed_rhs_time: float, + deltas: Sequence[ThermofluidIterationDelta], + *, + tail_limit: int = 8, + ) -> ThermofluidClosureFailure: + if not deltas: + raise ValueError("A thermofluid failure requires iteration diagnostics.") + final = deltas[-1] + return cls( + failed_rhs_time=float(failed_rhs_time), + iterations=final.iteration, + delta_tail=tuple(deltas[-tail_limit:]), + max_delta=final.max_delta, + scale=final.scale, + tolerance=final.tolerance, + worst_port=final.worst_port, + ) + + def as_dict(self) -> dict[str, object]: + return { + "failedRhsTime": self.failed_rhs_time, + "iterations": self.iterations, + "deltaTail": [item.as_dict() for item in self.delta_tail], + "maxDelta": self.max_delta, + "scale": self.scale, + "tolerance": self.tolerance, + "worstPort": ( + self.worst_port.as_dict() + if self.worst_port is not None + else None + ), + "failureCount": self.failure_count, + } + + +class ThermofluidClosureError(RecoverableTrialStateError): + """Recoverable exhaustion of the stream/pressure-flow fixed point. + + Stream propagation failures and algebraic-solver failures intentionally + retain their original exception types: rollback is still applied, but a + smaller ODE step is not known to repair those structural/numerical errors. + """ + + def __init__(self, diagnostics: ThermofluidClosureFailure) -> None: + super().__init__( + "Stream enthalpy and pressure-flow coupling did not converge " + f"after {diagnostics.iterations} iterations at " + f"t={diagnostics.failed_rhs_time:.17g}." + ) + self.diagnostics = diagnostics + + +class ThermofluidClosureDiagnostics: + """Run-level RHS outcomes; maintenance/postprocessing calls do not write it.""" + + def __init__(self) -> None: + self.failure_count = 0 + self.last_failure: ThermofluidClosureFailure | None = None + self.last_success: ThermofluidClosureSuccess | None = None + + def record_success(self, success: ThermofluidClosureSuccess) -> None: + self.last_success = success + + def record_failure( + self, + failure: ThermofluidClosureFailure, + ) -> ThermofluidClosureFailure: + self.failure_count += 1 + recorded = replace(failure, failure_count=self.failure_count) + self.last_failure = recorded + return recorded + + def as_dict(self) -> dict[str, object]: + return { + "failureCount": self.failure_count, + "lastFailure": ( + self.last_failure.as_dict() + if self.last_failure is not None + else None + ), + "lastSuccess": ( + self.last_success.as_dict() + if self.last_success is not None + else None + ), + } + + +@dataclass(frozen=True) +class _PortValueBinding: + component_name: str + port_name: str + state: PortState + variable: str + + +@dataclass(frozen=True) +class _PortFieldPlan: + variable: str + states: tuple[PortState, ...] + + +@dataclass(frozen=True) +class _FlowBinding: + component_name: str + port_name: str + state: PortState + + +@dataclass(frozen=True) +class _ComponentCacheBinding: + component: object + attribute_names: tuple[str, ...] + attribute_name_set: frozenset[str] + snapshot_hook: Callable[[], object] | None + restore_hook: Callable[[object], None] | None + + +@dataclass +class ThermofluidTransactionSnapshot: + plan: ThermofluidTransactionPlan + port_values: tuple[list[float], ...] + component_cache_values: tuple[list[object], ...] + custom_cache_values: list[object | None] + diagnostic_values: list[object] + + def restore(self) -> None: + plan = self.plan + plan._restore_port_values(self.port_values) + + for binding, values, custom_value in zip( + plan.component_cache_bindings, + self.component_cache_values, + self.custom_cache_values, + ): + component = binding.component + for name in tuple(getattr(component, "__dict__", {})): + if ( + name.startswith("_causal_") + or _is_stream_cache_attribute(name) + ) and name not in binding.attribute_name_set: + delattr(component, name) + for name, value in zip(binding.attribute_names, values): + setattr(component, name, _copy_cache_value(value)) + if binding.restore_hook is not None: + binding.restore_hook(custom_value) + + for owner, value in zip( + plan.diagnostic_owners, + self.diagnostic_values, + ): + owner.last_diagnostics = value + + +class ThermofluidTransactionPlan: + """Compiled, lightweight rollback boundary for one Generic RHS closure. + + It snapshots active physical-port values, catalog stream-temperature caches, + component ``_causal_*`` seed fields, and resolver/solver last diagnostics. + A custom stream-aware component with an opaque mutable cache can implement + both ``snapshot_thermofluid_closure_cache()`` and + ``restore_thermofluid_closure_cache(snapshot)``; these hooks are invoked in + addition to the standard name-based cache capture. + """ + + def __init__( + self, + *, + port_value_bindings: tuple[_PortValueBinding, ...], + port_field_plans: tuple[_PortFieldPlan, ...], + flow_bindings: tuple[_FlowBinding, ...], + component_cache_bindings: tuple[_ComponentCacheBinding, ...], + component_count: int, + diagnostic_owners: tuple[object, ...], + ) -> None: + self.port_value_bindings = port_value_bindings + self.port_field_plans = port_field_plans + self.flow_bindings = flow_bindings + self.component_cache_bindings = component_cache_bindings + self.component_count = component_count + self.diagnostic_owners = diagnostic_owners + self._snapshot = ThermofluidTransactionSnapshot( + plan=self, + port_values=tuple( + [0.0] * len(field.states) + for field in port_field_plans + ), + component_cache_values=tuple( + [None] * len(binding.attribute_names) + for binding in component_cache_bindings + ), + custom_cache_values=[None] * len(component_cache_bindings), + diagnostic_values=[None] * len(diagnostic_owners), + ) + + @classmethod + def compile( + cls, + network: object, + *, + diagnostic_owners: Sequence[object] = (), + ) -> ThermofluidTransactionPlan: + components = tuple(getattr(network, "components").values()) + port_value_bindings: list[_PortValueBinding] = [] + port_states_by_variable: dict[str, list[PortState]] = {} + flow_bindings: list[_FlowBinding] = [] + component_cache_bindings: list[_ComponentCacheBinding] = [] + for component in components: + active_definitions = tuple( + definition + for definition in component.active_port_definitions + if definition.kind == "physical" + ) + for definition in active_definitions: + state = component.get_port(definition.name) + flow_bindings.append( + _FlowBinding(component.name, definition.name, state) + ) + for variable in definition.variables: + port_states_by_variable.setdefault(variable.name, []).append(state) + port_value_bindings.append( + _PortValueBinding( + component.name, + definition.name, + state, + variable.name, + ) + ) + + attribute_names = tuple( + name + for name in getattr(component, "__dict__", {}) + if name.startswith("_causal_") + or _is_stream_cache_attribute(name) + ) + snapshot_hook = getattr( + component, + "snapshot_thermofluid_closure_cache", + None, + ) + restore_hook = getattr( + component, + "restore_thermofluid_closure_cache", + None, + ) + hooks_are_available = callable(snapshot_hook) and callable(restore_hook) + if attribute_names or hooks_are_available: + component_cache_bindings.append( + _ComponentCacheBinding( + component=component, + attribute_names=attribute_names, + attribute_name_set=frozenset(attribute_names), + snapshot_hook=(snapshot_hook if hooks_are_available else None), + restore_hook=(restore_hook if hooks_are_available else None), + ) + ) + + owners = tuple( + dict.fromkeys( + owner + for owner in diagnostic_owners + if hasattr(owner, "last_diagnostics") + ) + ) + return cls( + port_value_bindings=tuple(port_value_bindings), + port_field_plans=tuple( + _PortFieldPlan(variable, tuple(states)) + for variable, states in port_states_by_variable.items() + ), + flow_bindings=tuple(flow_bindings), + component_cache_bindings=tuple(component_cache_bindings), + component_count=len(components), + diagnostic_owners=owners, + ) + + def capture(self) -> ThermofluidTransactionSnapshot: + # GenericFluidSystem executes one RHS serially. Reuse one compiled + # workspace rather than allocating a snapshot object and several outer + # tuples at every successful trial point. + snapshot = self._snapshot + self._capture_port_values(snapshot.port_values) + for binding, values in zip( + self.component_cache_bindings, + snapshot.component_cache_values, + ): + for position, name in enumerate(binding.attribute_names): + values[position] = _copy_cache_value( + getattr(binding.component, name) + ) + for position, binding in enumerate(self.component_cache_bindings): + snapshot.custom_cache_values[position] = ( + binding.snapshot_hook() + if binding.snapshot_hook is not None + else None + ) + for position, owner in enumerate(self.diagnostic_owners): + snapshot.diagnostic_values[position] = owner.last_diagnostics + return snapshot + + def _capture_port_values( + self, + workspaces: tuple[list[float], ...], + ) -> None: + for field, values in zip(self.port_field_plans, workspaces): + variable = field.variable + states = field.states + if variable == "p": + for position, state in enumerate(states): + values[position] = state.p + elif variable == "m_flow": + for position, state in enumerate(states): + values[position] = state.m_flow + elif variable == "h_outflow": + for position, state in enumerate(states): + values[position] = state.h_outflow + elif variable == "volume": + for position, state in enumerate(states): + values[position] = state.volume + elif variable == "volume_flow": + for position, state in enumerate(states): + values[position] = state.volume_flow + elif variable == "x": + for position, state in enumerate(states): + values[position] = state.x + elif variable == "v": + for position, state in enumerate(states): + values[position] = state.v + elif variable == "f": + for position, state in enumerate(states): + values[position] = state.f + else: + for position, state in enumerate(states): + values[position] = getattr(state, variable) + + def _restore_port_values( + self, + workspaces: tuple[list[float], ...], + ) -> None: + for field, values in zip(self.port_field_plans, workspaces): + variable = field.variable + states = field.states + if variable == "p": + for state, value in zip(states, values): + state.p = value + elif variable == "m_flow": + for state, value in zip(states, values): + state.m_flow = value + elif variable == "h_outflow": + for state, value in zip(states, values): + state.h_outflow = value + elif variable == "volume": + for state, value in zip(states, values): + state.volume = value + elif variable == "volume_flow": + for state, value in zip(states, values): + state.volume_flow = value + elif variable == "x": + for state, value in zip(states, values): + state.x = value + elif variable == "v": + for state, value in zip(states, values): + state.v = value + elif variable == "f": + for state, value in zip(states, values): + state.f = value + else: + for state, value in zip(states, values): + setattr(state, variable, value) + + def flow_values(self) -> tuple[float, ...]: + return tuple(float(binding.state.m_flow) for binding in self.flow_bindings) + + def measure_flow_delta( + self, + previous: Sequence[float], + *, + iteration: int, + relative_tolerance: float, + ) -> ThermofluidIterationDelta: + current = self.flow_values() + scale = max( + (abs(value) for value in (*previous, *current)), + default=1.0, + ) + scale = max(scale, 1.0) + worst_index = -1 + worst_signed_delta = 0.0 + max_delta = 0.0 + for index, (old, new) in enumerate(zip(previous, current)): + signed_delta = new - old + magnitude = abs(signed_delta) + if magnitude > max_delta: + worst_index = index + worst_signed_delta = signed_delta + max_delta = magnitude + worst_port = None + if worst_index >= 0: + binding = self.flow_bindings[worst_index] + worst_port = ThermofluidWorstPort( + component=binding.component_name, + port=binding.port_name, + value=current[worst_index], + signed_delta=worst_signed_delta, + ) + return ThermofluidIterationDelta( + iteration=int(iteration), + max_delta=max_delta, + scale=scale, + tolerance=float(relative_tolerance) * scale, + worst_port=worst_port, + ) + + def diagnostics(self) -> dict[str, int]: + stream_cache_slot_count = sum( + len(binding.attribute_names) + for binding in self.component_cache_bindings + ) + return { + "physicalPortValueSlotCount": len(self.port_value_bindings), + "physicalFlowPortCount": len(self.flow_bindings), + "componentCount": self.component_count, + "cacheBindingCount": len(self.component_cache_bindings), + "streamAndCausalCacheSlotCount": stream_cache_slot_count, + "customCacheHookCount": sum( + binding.snapshot_hook is not None + for binding in self.component_cache_bindings + ), + "diagnosticOwnerCount": len(self.diagnostic_owners), + } diff --git a/app/simulation/systems/generic.py b/app/simulation/systems/generic.py index 876cb59..eebee7a 100644 --- a/app/simulation/systems/generic.py +++ b/app/simulation/systems/generic.py @@ -37,7 +37,14 @@ from app.simulation.solvers.stream import StreamResolver from app.simulation.solvers.tangent import ( ThreePistonTangentCompilation, ThreePistonTangentProvider, - compile_three_piston_tangent_provider, + compile_supported_piston_tangent_provider, +) +from app.simulation.solvers.thermofluid import ( + ThermofluidClosureDiagnostics, + ThermofluidClosureError, + ThermofluidClosureFailure, + ThermofluidClosureSuccess, + ThermofluidTransactionPlan, ) from app.simulation.systems.network import Endpoint, SimulationNetwork @@ -114,10 +121,6 @@ class SimulationPreparationError(ValueError): self.issues = issues -class ThermofluidClosureError(RuntimeError): - """Raised when stream enthalpy and pressure-flow do not reach one fixed point.""" - - class SimulationSampleTimeError(ValueError): """Stable failure contract for an unsafe or unrepresentable sample grid.""" @@ -428,6 +431,17 @@ class GenericFluidSystem: self.signal_resolver = SignalResolver(network) self.stream_resolver = StreamResolver(network) self._thermofluid_closure_plan = self._build_thermofluid_closure_plan() + self._thermofluid_transaction_plan = ThermofluidTransactionPlan.compile( + network, + diagnostic_owners=( + self.signal_resolver, + self.pneumatic_volume_resolver, + self.stream_resolver, + self.pressure_flow_solver, + *self._thermofluid_closure_plan.secondary_pressure_solvers, + ), + ) + self._thermofluid_closure_diagnostics = ThermofluidClosureDiagnostics() self.algebraic_solve_count = 0 self.algebraic_seeded_solve_count = 0 self.algebraic_nonlinear_solve_count = 0 @@ -945,7 +959,45 @@ class GenericFluidSystem: minimum_mode="audit", reset_property_shadow=True, ) - def _close_current_state(self, time: float) -> dict[str, dict[str, float]]: + def _close_current_state( + self, + time: float, + *, + record_rhs_outcome: bool = False, + ) -> dict[str, dict[str, float]]: + transaction = self._thermofluid_transaction_plan.capture() + last_algebraic_diagnostics = self._last_algebraic_diagnostics + last_algebraic_scope = self._last_algebraic_scope + try: + connected_h, success = self._close_current_state_unchecked(time) + except ThermofluidClosureError as exc: + transaction.restore() + self._last_algebraic_diagnostics = last_algebraic_diagnostics + self._last_algebraic_scope = last_algebraic_scope + self._request_causal_residual_audit() + if record_rhs_outcome: + failure = self._thermofluid_closure_diagnostics.record_failure( + exc.diagnostics + ) + raise ThermofluidClosureError(failure) from None + raise + except BaseException: + transaction.restore() + self._last_algebraic_diagnostics = last_algebraic_diagnostics + self._last_algebraic_scope = last_algebraic_scope + self._request_causal_residual_audit() + raise + if record_rhs_outcome: + self._thermofluid_closure_diagnostics.record_success(success) + return connected_h + + def _close_current_state_unchecked( + self, + time: float, + ) -> tuple[ + dict[str, dict[str, float]], + ThermofluidClosureSuccess, + ]: signal = self.signal_resolver.solve(time) self.signal_propagation_count += signal.propagated self.pressure_flow_solver.propagate_equal_efforts(("x", "v")) @@ -971,27 +1023,22 @@ class GenericFluidSystem: closure_plan = self._thermofluid_closure_plan self._last_algebraic_diagnostics = initial_algebraic self._last_algebraic_scope = closure_plan.global_component_group - physical_ports = closure_plan.physical_ports secondary_pressure_solvers = closure_plan.secondary_pressure_solvers secondary_block_solvers = closure_plan.secondary_block_solvers connected_h: dict[str, dict[str, float]] = {} stream_diagnostics = [] + coupling_deltas = [] max_coupling_iterations = 25 flow_relative_tolerance = 1.0e-12 for coupling_iteration in range(1, max_coupling_iterations + 1): - previous_flows = tuple(port.m_flow for port in physical_ports) + previous_flows = self._thermofluid_transaction_plan.flow_values() stream, connected_h = self.stream_resolver.solve( dynamic_ports_are_current=True, ) stream_diagnostics.append(stream) - temperature_reference_h = ( - self.stream_resolver.connected_temperature_reference_enthalpies() - ) for component in self.dynamic_components: component.update_stream_outflows(connected_h[component.name]) - component.update_flow_temperature_references( - temperature_reference_h[component.name] - ) + self.stream_resolver.refresh_flow_temperature_references() if secondary_pressure_solvers: self.thermofluid_pressure_pass_count += 1 block_scale_context = ( @@ -1043,26 +1090,23 @@ class GenericFluidSystem: self._last_algebraic_diagnostics = algebraic self._last_algebraic_scope = component_group pressure_flow_solve_count += 1 - current_flows = tuple(port.m_flow for port in physical_ports) - flow_scale = max( - [abs(value) for value in (*previous_flows, *current_flows)] + [1.0] - ) - max_flow_delta = max( - ( - abs(current - previous) - for previous, current in zip(previous_flows, current_flows) - ), - default=0.0, + coupling_delta = self._thermofluid_transaction_plan.measure_flow_delta( + previous_flows, + iteration=coupling_iteration, + relative_tolerance=flow_relative_tolerance, ) + coupling_deltas.append(coupling_delta) if ( not secondary_pressure_solvers - or max_flow_delta <= flow_relative_tolerance * flow_scale + or coupling_delta.max_delta <= coupling_delta.tolerance ): break else: raise ThermofluidClosureError( - "Stream enthalpy and pressure-flow coupling did not converge " - f"after {max_coupling_iterations} iterations." + ThermofluidClosureFailure.from_iterations( + time, + coupling_deltas, + ) ) self.max_thermofluid_iterations = max( self.max_thermofluid_iterations, @@ -1105,7 +1149,10 @@ class GenericFluidSystem: self.max_stream_iterations, *(item.iterations for item in stream_diagnostics), ) - return connected_h + return connected_h, ThermofluidClosureSuccess.from_iteration( + time, + coupling_deltas[-1], + ) @profile_phase("simulation.refresh", minimum_mode="audit") def _refresh_dynamic_components(self) -> None: @@ -1130,7 +1177,10 @@ class GenericFluidSystem: @profile_phase("simulation.rhs", minimum_mode="audit") def rhs(self, _time: float, state_vector: list[float]) -> list[float]: self.apply_state_vector(state_vector) - connected_h = self._close_current_state(_time) + connected_h = self._close_current_state( + _time, + record_rhs_outcome=True, + ) derivatives = self._state_derivatives(connected_h) provider = self._ode_tangent_provider if provider is not None: @@ -1253,7 +1303,7 @@ class GenericFluidSystem: exact_columns = None if requested_jacobian_mode == "semi-analytic": tangent_compilation = ( - compile_three_piston_tangent_provider(self) + compile_supported_piston_tangent_provider(self) ) if tangent_compilation.eligible: provider = tangent_compilation.provider @@ -1329,6 +1379,7 @@ class GenericFluidSystem: ), jac_sparsity=jac_sparsity, jac=jacobian, + recoverable_trial_retries=True, ) finally: self._ode_tangent_provider = None @@ -1650,6 +1701,12 @@ class GenericFluidSystem: "stream": { "maxIterationsPerSolve": self.max_stream_iterations, "maxThermofluidIterations": self.max_thermofluid_iterations, + "thermofluidClosure": { + **self._thermofluid_closure_diagnostics.as_dict(), + "transaction": ( + self._thermofluid_transaction_plan.diagnostics() + ), + }, "last": ( self.stream_resolver.last_diagnostics.as_dict() if self.stream_resolver.last_diagnostics is not None diff --git a/constraints/python312-direct.txt b/constraints/python312-direct.txt new file mode 100644 index 0000000..65b1d4b --- /dev/null +++ b/constraints/python312-direct.txt @@ -0,0 +1,12 @@ +# Reference direct-dependency set for CPython 3.12. +# +# This intentionally pins only packages imported or invoked directly by the +# application. In particular, it does not pin uvicorn[standard]'s optional, +# platform-dependent transitive dependencies. Regenerate and validate these +# pins in a clean CPython 3.12 environment when intentionally upgrading them. +fastapi==0.141.1 +lxml==6.1.1 +numpy==2.5.2 +pydantic==2.13.4 +scipy==1.18.0 +uvicorn==0.52.3 diff --git a/docs/other/求解器性能优化任务清单.md b/docs/other/求解器性能优化任务清单.md index cb22609..b42ee41 100644 --- a/docs/other/求解器性能优化任务清单.md +++ b/docs/other/求解器性能优化任务清单.md @@ -5,6 +5,9 @@ > 基线代码:`6bb0591d320d0c448ee8d224dd44127bfe3ce00f`(本地 `model-development`) > 基线模型:`tests/data/test_mql-full-branches-01-04.xml` > 模型 SHA-256:`2fb95e65f5de0c85a6a17802aef74ea004087323fd00fd8d01acf0184ff71d48` +> 当前主固化目标:`tests/data/test-mql-8.xml` +> 主目标 XML SHA-256:`170463d65d074da01f0f9e9dab730b3815c94c1cc80b5190ec2e3fe623da74d3` +> 配套项目 JSON:`tests/data/test-mql-8.json`,SHA-256 `258c50ee4850baa72fb7c2cc24536d0a631fc6a7f1fa6cedb7b6eea7c857cbaa` ## 1. 使用规则 @@ -34,7 +37,7 @@ ### 2.2 环境说明 -仓库内 `.venv` 当前不完整,本次复测使用现有 `/opt/srm-trial-review/.venv`: +首次历史复测时仓库 `.venv` 尚不完整,因此当时使用现有 `/opt/srm-trial-review/.venv`: | 项目 | 本次值 | | --- | --- | @@ -92,16 +95,34 @@ | stream 块 / stream 未知量 | 9 / 192 | | 结果变量 | 1,021 | +### 2.5 新主固化目标 `test-mql-8` + +自 2026-08-17 起,后续通用求解器优化以 `tests/data/test-mql-8.xml` 为主固化目标;配套 `test-mql-8.json` 用于校验项目结构,但 XML 是权威执行输入。原 `test_mql-full-branches-01-04.xml` 继续保留为历史慢区、2.05 s 与首批半解析 Jacobian 的回归样例。runner 只在内存中覆盖 `tStop/sampleStep/maxStep`,不得改写权威输入。 + +| 项目 | 主目标值 | +| --- | ---: | +| 运行组件 / 连接 | 152 / 174 | +| 动态组件 / 连续状态 | 54 / 124 | +| 代数未知量 / 方程 | 760 / 760 | +| ODE Jacobian 结构 | 3296 nnz / 52 色 | +| 因果 effort / flow 赋值 | 432 / 328 | +| secondary 代数块 / 未知量 | 12 / 352 | +| 结果变量 | 1,716 | +| 原始 `tStop / sampleStep / maxStep` | 0.2 / 0.01 / 0.01 s | +| 信号断点 | 0.04、0.8 s | + +本轮参考环境使用仓库 `.venv`:Python 3.12.3、NumPy 2.5.2、SciPy 1.18.0。`.python-version` 与 `constraints/python312-direct.txt` 已固定 Python 和六个直接依赖,README、CI 与依赖契约测试使用同一安装口径;它有意不锁平台相关传递依赖与 wheel 哈希,因此是可审计的参考约束,不是发布级逐位锁。机器可读 manifest 与 runner 分别位于 `tests/baselines/simulation/test_mql_8/manifest.json` 和 `app/simulation/benchmark_regression.py`;默认顺序为 `0.01 smoke → 0.2 → 1 → 5 → 10 s`,每档均有合作取消、硬终止、资源记录与后续档延迟门,且 `sampleStep` 与 `maxStep` 可按 lane 独立覆盖。 + ## 3. 总体验收协议 每个优化 PR 至少执行以下分层验证;高风险改动不得只用单点输出或单个哈希判断正确性。 ### 3.1 快速结构检查(CI) -- [ ] 模型输入 SHA-256 与固定 fixture 一致。 -- [ ] 组件、连接、状态、代数方程和 stream 结构数量符合预期。 -- [ ] Jacobian 结构至少覆盖已知跨域依赖,并通过稠密数值扰动抽查。 -- [ ] 因果计划覆盖率、回退原因和审计失败数可观测。 +- [x] 模型输入 SHA-256 与固定 fixture 一致。 +- [x] 组件、连接、状态、代数方程和 stream 结构数量符合预期。 +- [x] Jacobian 结构至少覆盖已知跨域依赖,并通过稠密数值扰动抽查。 +- [x] 因果计划覆盖率、回退原因和审计失败数可观测。 ### 3.2 数值检查点 @@ -110,7 +131,7 @@ - [ ] `0.68–0.71 s`:历史慢区。 - [ ] `0.79–0.81 s`:原始模型终点及信号事件附近。 - [ ] `2.00–2.10 s`:此前报告卡死区间和状态切换。 -- [ ] `10 s`:长时间模式变化验证,完成 OPT-09 后启用。 +- [x] `10 s`:最终通用接线后的当前工作树已完成首次长时间模式变化运行;连续 3 次和批准 golden 仍属于 OPT-09 后续。 每个检查点比较:连续状态、关键压力/流量/位移/速度、事件时刻与顺序、模式状态、有限性、最大缩放残差及守恒量。 @@ -131,16 +152,16 @@ | ID | 优先级 | 任务 | 当前状态 | 难度 | 预期价值 | 主要依赖 | | --- | --- | --- | --- | --- | --- | --- | -| OPT-00 | P0 | 固化复现、环境和回归基线 | 进行中 | 中 | 很高 | 无 | -| OPT-01 | P1 | 完成因果代数内核与坐标消元 | 部分实现 | 中高 | 中高 | OPT-00 | -| OPT-02 | P1 | 建立扁平数值 IR 和数组执行内核 | 未开始 | 很高 | 很高 | OPT-01 | +| OPT-00 | P0 | 固化复现、环境和回归基线 | 部分实现(基础闭环) | 中 | 很高 | 无 | +| OPT-01 | P1 | 完成因果代数内核与坐标消元 | 基本完成(主要矛盾闭环) | 中高 | 中高 | OPT-00 | +| OPT-02 | P1 | 建立扁平数值 IR 和数组执行内核 | 部分实现(参考 IR) | 很高 | 很高 | OPT-01 | | OPT-03 | P1 | 稀疏 Jacobian 数值层与解析/半解析演进 | 部分实现 | 很高 | 很高 | OPT-00;解析链可与 OPT-02 分阶段 | | OPT-04 | P1 | stream 拓扑传播与物性成组复用 | 部分实现 | 中高 | 中高 | OPT-00 | -| OPT-05 | P1 | 状态缩放、分量容差和步长策略 | 未开始 | 中高 | 中高 | OPT-00 | +| OPT-05 | P1 | 状态缩放、分量容差和步长策略 | 部分实现(可恢复试探步) | 中高 | 中高 | OPT-00 | | OPT-06 | P2 | 事件检测与 dense output 按需化 | 部分实现 | 中 | 中 | OPT-00 | | OPT-07 | P2 | 输出、后处理和传输内存优化 | 未开始 | 中 | 中高(长仿真) | OPT-00 | | OPT-08 | P2 | 进度、取消和服务并发鲁棒性 | 部分实现 | 中 | 中 | OPT-00 | -| OPT-09 | P0/P2 | 建立 10 s 长时验证与模式覆盖 | 未开始 | 中高 | 很高 | OPT-00 | +| OPT-09 | P0/P2 | 建立 10 s 长时验证与模式覆盖 | 进行中 | 中高 | 很高 | OPT-00 | | OPT-10 | P3 | 明确高指数 DAE/强非光滑系统边界 | 未开始 | 很高 | 条件性 | OPT-09 | 推荐实施顺序:`OPT-00 → OPT-03/OPT-01 → OPT-04/OPT-05 → OPT-02 → OPT-06/OPT-07/OPT-08 → OPT-09`。其中 OPT-02 与 OPT-03 可先做最小原型,再根据端到端数据调整顺序。 @@ -151,82 +172,127 @@ **目标**:先让“是否更快、是否仍正确、是否又卡住”可以稳定复现和自动判断。 -**当前状态**:已有手工 `0.81 s` 和 `2.10 s` 复测及若干结构回归;复杂 XML、正式运行环境、分层性能门槛尚未完整固化。 +**当前状态**:P0 基础设施与新主目标的有界 `0.2 s` 基线已经闭环:权威 XML/JSON、参考依赖约束、仓库内 runner、状态 golden、输出形状契约、三层 CI 和机器可读报告均已建立。完整 OPT-00 仍缺发布级依赖锁、远端 CI 首次实跑、历史 `2.10 s` 三次复测以及更长时域的批准 golden。 **工作项**: -- [ ] 将复杂 XML 作为正式测试 fixture 纳入版本控制,并在测试中校验哈希。 -- [ ] 修复或重建项目 `.venv`,锁定 Python、NumPy、SciPy 及平台信息。 -- [ ] 将临时探针整理为仓库内可重复运行的 benchmark,不依赖 `/tmp` 文件。 -- [ ] 添加 `0.81 s` 和仅改 `tStop=2.10 s` 的标准运行入口。 -- [ ] 添加模型结构快照断言;结构有意变化时显式更新原因。 -- [ ] 定义 `physical-state-v2`:仅包含物理状态、关键代数量、事件与模式,不包含展示字段和易变诊断字段。 -- [ ] 将完整 API 输出哈希与物理解哈希分开,分别用于输出契约和数值回归。 -- [ ] 建立短 CI、夜间 `0.81/2.10 s`、定期 `10 s` 三层任务。 -- [ ] 保存机器可读的 JSON 基准结果,避免只在文档中抄写数字。 +- [x] 将新主目标 XML/JSON 放入固定 fixture 路径,并在 manifest/测试中校验双哈希、字节数和配对配置;提交本轮工作时必须一并纳入版本控制。 +- [x] 建立 Python 3.12.3 与六个直接依赖的跨平台参考约束,并在 CI 中校验;发布级传递依赖/wheel 哈希锁仍待后续。 +- [x] 将临时探针整理为仓库内可重复运行的 benchmark,不依赖 `/tmp` 文件。 +- [x] 添加 `0.81 s` 和仅改 `tStop=2.10 s` 的历史标准运行入口。 +- [x] 添加模型结构快照断言;结构有意变化时显式更新原因。 +- [x] 建立 `physical-state-v2` 的首批 state/checkpoint/event 投影,并批准 production `0.2 s` golden。 +- [ ] 将关键压力、流量、守恒量和离散模式加入 `physical-state-v2.1` 数值投影。 +- [x] 将无数值的完整输出形状契约与物理状态 golden 分开;完整 API 序列化契约若需逐字段稳定性,后续另行定义。 +- [x] 建立短 CI、夜间 `0.81/2.10 s`、定期递进至 `10 s` 的三层 workflow;远端首次执行待提交后确认。 +- [x] 保存带环境、仓库、输入、运行统计和验收结果的机器可读 JSON 报告。 **验收条件**: -- [ ] 干净环境一条命令可复现;失败时能区分超时、无进度、数值失败和服务失败。 +- [ ] 干净环境一条命令可复现;当前参考约束、`pip check`、soft/hard timeout、worker error 与 correctness failure 分类已完成,独立无进度看门狗、服务级故障分类和发布级空环境重建尚未验收。 - [ ] 正式环境连续 3 次完成 `2.10 s`,结果满足数值契约且无非预期回退。 -- [ ] 性能报告完整记录环境、提交、工作树、输入哈希和统计口径。 +- [x] 新主目标 `0.2 s` 性能报告完整记录环境、提交、工作树、输入哈希和统计口径。 **前后对比**: | 指标 | 当前 | 完成后 | | --- | --- | --- | -| 正式锁定环境 | 无 | 待填 | -| 复杂模型自动回归 | 部分 | 待填 | -| 物理解哈希 | 环境相关 v1 | 待填 | +| 正式锁定环境 | 无 | Python 3.12.3 + 直接依赖参考约束;发布锁待补 | +| 复杂模型自动回归 | 部分 | 新主目标 0.01/0.2/1/5/10 递进 + 历史 0.81/2.10 入口 | +| 物理解哈希 | 环境相关 v1 | production 0.2 state golden + 独立 output shape contract | | `2.10 s` 连续成功率 | 单次证据 | 待填 | +#### 2026-08-17 / `test-mql-8` 固化 runner v2 + +- 状态:进行中 → 部分实现(P0 基础闭环)。新权威 XML/JSON、双哈希、结构快照、参考环境约束、分层 manifest、机器可读报告、批准的 production `0.2 s` golden 和仓库内 runner 已建立;发布级依赖锁、关键代数量投影以及该里程碑时尚未运行的 1/5/10 s 结果仍待后续。 +- runner 行为:默认严格按 `0.01 smoke → 0.2 → 1 → 5 → 10 s` 递进;smoke 不参与耗时外推。soft deadline 先经 stdin 合作取消,hard deadline 再 terminate/kill;失败、超时、物理验收失败或下一档预测超过预算时,剩余档位统一标记 `deferred`。 +- 已执行正确性门:完成并到达终点、非空且全有限的输出序列、采样时间严格递增、检查点及状态值、最大缩放残差、预期信号事件及其实际积分分段、机械切换次数/时刻、golden 来源报告与布局哈希、逐状态容差比较和独立 output-shape contract。 +- 两条 lane:manifest 中 `solver-only` 在内存把 `sampleStep` 改为 0.02 s,并把 `maxStep` 固定为 0.05 s,用于算法迭代;`production` 的 `sampleStep/maxStep` 均使用权威 XML 源值,当前为 0.01/0.01 s。max-step 矩阵可再显式覆盖单次运行的 `maxStep`;所有覆盖都只发生在内存,不改写 XML。 +- 进程鲁棒性:软取消、硬终止、子进程提前关闭 stdin 的 BrokenPipe 和 stdout/stderr 资源清理均有自动测试。 +- 备份:`backup/general-solver-v1-before-20260817-16a7eb2` 精确指向进入本轮前的 `16a7eb2d6c2f01b23e3bdc7781a6cf6cc3fbe369`。 +- P0 证据:`tests/baselines/simulation/test_mql_8/runs/2026-08-17-production-v2-0.2.json`、`goldens/production-0.2s-v1.json` 与 `runs/2026-08-17-production-v2-extension-decision.json`。 +- 自动验证:CI 同口径快速基础套件共 149 项,OK(2 项长时测试按开关跳过);全量后端 discover 共 792 项,OK(3 项长时/可选测试跳过)。原有 5 个失败均确认是仓库整理后的旧文档/XML/CSV 路径,并已修正为现有 fixture 路径。 + +递进复测命令: + +```bash +PYTHONPATH=. .venv/bin/python -m app.simulation.benchmark_regression \ + --manifest tests/baselines/simulation/test_mql_8/manifest.json \ + --lane production \ + --output tests/baselines/simulation/test_mql_8/runs/latest-production.json +``` + +正式验收默认使用 `production`,从而执行已批准的 0.2 s 数值 golden;算法迭代若需降低输出成本,可显式改为 `--lane solver-only`。仅重跑首个正式基线档可加 `--case 0.2s`。 +命令退出码约定:`0` 表示所有选定档完成,`2` 表示依据预算安全暂缓后续档,`1` 表示运行失败或正确性验收失败。显式选择 `1s/5s/10s` 时,runner 仍会自动补齐并先执行所有较短前置档。 + ### OPT-01 完成因果代数内核与坐标消元 **目标**:在已存在的因果快速路径上,真正移除运行时冗余坐标和对象访问,而不是再次实现一套同类快速路径。 -**当前状态**:主要思路已经实现。全局和 secondary stream 块可以执行显式因果计划,完整残差按 64 次间隔审计;`2.10 s` 中快速执行 22,216 次、审计 351 次、失败和旧路径回退均为 0。仍保留 472 个运行时未知量,204 个重复 effort 坐标尚未在执行层消除,且存在清零、复制、缩放、`getattr/setattr` 和完整对象遍历成本。 +**当前状态**:新主目标的主要矛盾已经在执行层闭环。原有 `760` 个 PortState 兼容代数槽由 `432` 个 effort 槽和 `328` 个 flow/force 槽组成;当前内核将其编译为 `112` 个 effort 等价组和 `328` 条显式赋值,即 `440` 个逻辑坐标,在求解执行层消去 `320` 个 effort 别名。全局与 secondary stream 块均使用预分配 workspace、按 component 批量计算 anchor 并直接 scatter,完整残差仍在初始化、事件和每 64 次求解时审计。 -一次已预热的 A/B 微基准显示,整个 RHS 的因果快速模式中位数约 `200.8 ms/100 次`,强制完整检查约 `297.4 ms/100 次`,即现有路径已经取得约 `1.48×` 的整 RHS 收益。单纯继续增大审计间隔预计收益有限。 +这里的“消去”是逻辑求解坐标消元:stream、状态导数和结果提取仍直接读取 `760` 个 PortState 兼容镜像,因此对象槽尚未物理删除;这属于 OPT-02 后续。旧 `472/204/68/200` 是历史 `test_mql-full-branches-01-04.xml` 的规模,只保留为历史基线,不再描述当前主目标。 **工作项**: -- [ ] 将 68 个 effort 等价组压缩为独立运行时坐标,消除 204 个重复 effort 槽。 -- [ ] 将 200 条显式 flow/force 规则预编译为稳定顺序和整数槽索引。 -- [ ] 用预分配连续数组代替热路径对象读写、临时字典和重复缩放。 +- [x] 将 112 个 effort 等价组压缩为独立逻辑坐标,在执行层消去 320 个重复 effort 别名。 +- [x] 将 328 条显式 flow/force 规则预编译为稳定阶段和槽绑定。 +- [x] 用预分配 workspace、批量 component anchor 和直接属性 scatter 减少热路径对象遍历、临时集合与重复缩放。 - [ ] 仅清理会被当前计划写入的槽,避免每次全量清零和复制。 -- [ ] 保留初始化、事件后、接受步或固定间隔的完整残差审计。 -- [ ] 自定义组件、声明缺失、审计失败或奇异结构必须自动回退旧求解器。 -- [ ] 输出编译统计:消元数、显式规则覆盖率、审计率、失败原因和回退次数。 +- [x] 保留初始化、事件后、显式请求或固定间隔的完整残差审计。 +- [x] 自定义组件、声明缺失、审计失败、非有限外部 effort 或奇异结构自动回退旧求解器。 +- [x] 输出逻辑/兼容坐标数、消元数、显式规则覆盖率、审计率、失败原因和回退次数。 + +“仅清理当前计划写入槽”暂不勾选:当前 flow 目标仍先清零再赋值,以保持既有 `target = -residual(target=0)` 语义逐位一致;在 IR 能证明目标系数与历史无关前不移除这一步。 **验收条件**: -- [ ] 复杂模型因果覆盖率大于 98%,完整 `0.81/2.10 s` 运行审计失败为 0。 -- [ ] 新旧路径的状态、事件、关键代数量和残差均满足统一数值契约。 -- [ ] 自定义组件、接触模型和非因果结构的回退测试全部通过。 -- [ ] 在完整模型上证明端到端收益;不得只提交代数微基准。 +- [x] 新主目标因果 flow/force 覆盖率为 `328/328`,0.01/0.2 s 中审计、运行时验证和旧路径回退均为 0。 +- [x] kernel on/off 的状态导数、760 个兼容代数槽、积分统计、物理解与输出契约一致。 +- [x] 自定义组件、接触模型、非因果结构和故障注入的回退测试通过。 +- [x] 在 0.01 s 与 production 0.2 s 证明端到端不退化并取得单次收益;严格性能签收仍需补 3 次中位数。 **风险与回滚**:别名写回、事件后模式改变和不完整依赖声明可能造成静默错误。新路径必须可通过配置关闭,并在审计失败时记录首个违规方程与变量。 | 指标 | 当前 | 完成后 | | --- | ---: | ---: | -| 运行时代数槽 | 472 | 待填 | -| 重复 effort 槽 | 204 | 待填 | -| 已预热 Python 调用/单 RHS | 约 15,774 | 待填 | -| 因果审计失败 | 0 | 待填 | -| `0.81/2.10 s` 墙钟中位数 | 63.779 / 126.211 s(单次环境值) | 待填 | +| 兼容代数槽 | 760 | 760(逻辑坐标 440) | +| 重复 effort 别名 | 320 | 逻辑消去 320;兼容镜像保留 | +| 已预热 Python 调用/单 RHS | 9,423 | 5,955(`-36.8%`) | +| 全局代数 solve 中位时间 | 0.708890 ms | 0.521711 ms(`-26.4%`) | +| 整体 RHS 中位时间 | 250.742 ms / 100 次 | 218.343 ms / 100 次(`-12.9%`) | +| 0.01 s worker 墙钟 | 13.5983 s | 12.5878 s(`-7.43%`) | +| production 0.2 s worker 墙钟 | 135.8240 s | 130.8233 s(单次 `-3.68%`) | +| 因果审计 / 运行时验证 / 旧路径回退失败 | 0 / 0 / 0 | 0 / 0 / 0 | + +#### 2026-08-17 / 通用因果执行器 v2 + +- 状态:该段记录低分配执行器 v2 的首版里程碑;后续因果坐标内核已将新主目标的 `760` 个兼容槽压缩为 `440` 个逻辑坐标,OPT-01 当前已达到“基本完成(主要矛盾闭环)”。`760` 个 PortState 兼容镜像的物理删除仍属于 OPT-02 后续。 +- 全局执行:直接执行预编译的 432 个 effort 写入与 328 个 flow/force 赋值,普通 fast solve 不再构造 seeded-id set、遍历 760 个未知量或重复构造 diagnostics。 +- secondary 执行:对 352 未知量的因果块仅保存和写入 176 个 selected flow 槽,普通 fast solve 跳过完整 mutation snapshot、seed set 和 scale/residual 构造。 +- 正确性边界:初始化、事件、显式请求及每 64 次求解仍执行完整残差审计;非有限 assignment、stage 异常或外部机械 x/v 非有限会熔断 v2,并在同次求解回到旧 seed/audit 路径。`SIMULATION_CAUSAL_EXECUTOR_V2=0` 保留一键回滚。 +- 默认决策:在目标 0.01 s 逐位 A/B、故障注入、聚焦测试与完整 0.2 s 验收后,v2 设为通用默认;只在原有 causal compile 证明通过时启用,不满足证明的模型继续走原路径。 +- 微基准:新目标 100 次同状态 RHS 中位时间由 0.305764 s 降至 0.247467 s(单次基准约 `-19.1%`),导数逐位相同;405 次 v2 fast、7 次完整审计,0 次验证失败。 +- 0.01 s 端到端:SciPy Jacobian 下总墙钟 15.160 → 13.172 s(`-13.1%`),积分 14.092 → 12.122 s(`-14.0%`);`nfev/njev/nlu=526/48/149`、物理解哈希 `0e64c6f...` 均相同。 +- 历史 0.2 s solver-only:旧空格路径、SHA `42e2d627...` 与 0.002 s 网格下曾以 132.305 s 完成;报告 `runs/2026-08-17-solver-only-v1.json` 和旧 `runs/2026-08-17-extension-decision.json` 已在 manifest 中标为 `historicalOnly`,不得作为新权威输入的 golden 或耗时预测来源。 +- 当前 production 0.2 s:新 SHA `170463d6...` 与 0.01 s 网格下 worker 墙钟 135.824 s、CPU 139.105 s、峰值 RSS 189,874,176 B;`nfev/njev/nlu=5755/307/1081`,接受步 1696,2 个信号分段,0 状态切换/重试。50,615 次闭合全部 seeded,主 v2 fast/audit 为 21,771/341,审计失败、运行时验证失败和旧路径回退均为 0,最大缩放残差 `1.0947e-16`。 +- 当前 P0 报告与 golden:`runs/2026-08-17-production-v2-0.2.json` 通过全部验收门;`goldens/production-0.2s-v1.json` 对 134 个投影结果键的 3 个检查点共比较 402 个值,并独立校验 output contract。本目标仍使用 SciPy Jacobian,不能把该成绩归因于半解析 Jacobian。 +- 当时的延期决策:`runs/2026-08-17-production-v2-extension-decision.json` 绑定新报告 SHA;`1 s` 的 1018.680 s 由 `135.8240278 × 5 × 1.5` 保守外推,超过 900 s soft budget,因此在该里程碑先未启动 1/5/10 s。后续实测结论统一记录在 OPT-09,不用该历史外推覆盖实测。 ### OPT-02 建立扁平数值 IR 和数组执行内核 **目标**:把组件对象、字典查找和端口读写转换成稳定的数值执行计划,为 NumPy、Numba 或原生后端提供共同基础。 -**当前状态**:构建阶段已有一定预绑定,但 RHS 仍以 Python 对象和方法调用为主。已预热、启用物性缓存时,采样剖析约有 15,774 次 Python 调用/RHS;不同缓存上下文会明显改变该数字,所以后续必须统一测量口径。 +**当前状态**:已启动第一版独立、可执行的 schema v1 参考 IR,但尚未接管默认热路径。它把结构程序与运行绑定分离,包含 `440 canonical / 760 compatibility` 双层槽、稳定结构签名、NumPy workspace、按 component 批量 effort 计算、六阶段 flow 执行、逐阶段观察器和可选事务模式。权威目标可编译为 `112` 个 effort 坐标、`328` 个 flow 坐标和 `320` 个逻辑别名消元,flow stages 为 `[110, 130, 49, 33, 5, 1]`。 + +该原型目前只覆盖全局因果代数计划;secondary、stream、结果提取、模式重编译、自定义适配器和原生后端均未接入。PortState 仍是兼容镜像。事务模式目前只保证受控返回失败的回滚,writer/MemoryError/BaseException 语义尚未冻结;结构签名也未包含组件实现版本和后端,因此不能作为持久缓存键。 **工作项**: -- [ ] 定义最小数值 IR:连续槽、常量、参数、状态、代数量、模式位和操作码。 +- [x] 定义首批最小代数 IR:canonical/compatibility 双层槽、稳定绑定、常量和分阶段操作码。 - [ ] 将组件方程、因果规则、stream 传播和结果提取分成明确执行阶段。 -- [ ] 先实现可逐项对照的纯 Python/NumPy 参考后端。 -- [ ] 添加 IR 与当前对象执行器逐操作/逐阶段差分测试。 +- [x] 实现可执行的纯 Python/NumPy 全局因果参考后端。 +- [x] 添加 IR 与当前对象执行器的结构签名、逐槽和逐阶段差分测试。 - [ ] 评估 Numba 与 C/C++ 后端;在 IR 稳定前不绑定单一编译技术。 - [ ] 对动态自定义组件保留对象适配层和明确的性能降级提示。 - [ ] 缓存编译结果,并以模型结构、组件版本和数值后端作为缓存键。 @@ -240,11 +306,17 @@ | 指标 | 当前 | 原型后 | 完成后 | | --- | ---: | ---: | ---: | -| Python 调用/单 RHS | 约 15,774 | 待填 | 待填 | +| Python 调用/单 RHS | 9,423 | 5,955(OPT-01 默认内核;参考 IR 尚未接线) | 待填 | | 临时分配字节/单 RHS | 待测 | 待填 | 待填 | -| RHS 中位时间 | 约 2 ms(现有微基准口径) | 待填 | 待填 | +| RHS 中位时间 | 250.742 ms / 100 次 | 218.343 ms / 100 次(OPT-01) | 待填 | | `2.10 s` 积分时间 | 122.180 s | 待填 | 待填 | +#### 2026-08-17 / 因果数值 IR schema v1 + +- 新增独立参考实现 `app/simulation/solvers/causal_ir.py`,将结构程序与运行绑定分离,覆盖 `440 canonical / 760 compatibility` 双层槽、`112` 个 effort 坐标、`328` 个 flow 坐标、`320` 个逻辑别名及六阶段 flow 计划。 +- `tests/test_causal_numeric_ir.py` 已覆盖结构签名、逐槽、逐阶段、观察器和受控事务回滚差分。 +- 该 IR 尚未接管默认 RHS,当前不能把 OPT-01 的调用数或墙钟收益归因于 IR;secondary、stream、结果提取、事件后模式计划和原生后端仍待接入。 + ### OPT-03 稀疏 Jacobian 数值层与解析/半解析演进 **目标**:先建立可审计、可回滚的 callable sparse Jacobian 数值层,再逐步把组件、因果代数计划、stream 和物性的局部导数传播进来。完整稀疏有限差分、受审计 secant 和真正的解析/半解析 Jacobian 是三个不同阶段,必须分别记录和验收。 @@ -392,11 +464,18 @@ - 代码备份:仍使用进入 Jacobian 优化前建立的 `backup/jacobian-before-20260817-6bb0591`,精确指向 `6bb0591d320d0c448ee8d224dd44127bfe3ce00f`。 - 证据文件:`app/simulation/solvers/jacobian.py`、`app/simulation/solvers/tangent.py`、`app/simulation/solvers/solver.py`、`app/simulation/systems/generic.py`、`app/simulation/core/medium.py`、`app/simulation/components/amesim/media/mediums.py`、`app/simulation/components/amesim/mechanical/pistons.py`、`app/simulation/components/amesim/storage/chambers.py`、`app/simulation/components/amesim/flow/pipes.py`、`app/simulation/components/amesim/mechanical/translational.py`、`tests/test_sparse_secant_jacobian.py`、`tests/test_analytic_tangent_primitives.py`、`tests/test_three_piston_tangent.py` +#### 2026-08-17 / 名字无关的受支持活塞支路编译器 + +- 将原三条固定实例扩展为按组件类型、端口域、连接、机械状态 owner/slot、因果 reach 与 stream 影响证明自动发现任意数量的受支持支路;通用路径不固定组件实例名、支路数或状态 offset,旧三活塞入口仅作为兼容 wrapper。 +- 新主目标自动发现 8 条 MECMAS21→PNRP17→PNCH012→PNL0001/LSTP 支路,覆盖 16 个机械状态列 `104..119` 与 84 条可达赋值;理论剩余 FD 颜色由 52 降至 36。 +- 平滑工作点 16 列对完整 RHS 中心差分通过;初始接触边界会类型化回退完整 52 色数值 Jacobian,不会静默使用错误列。 +- 0.01 s A/B 显示该目标早期 56 次 Jacobian 中只有 16 次使用精确列、40 次因流量局部斜率/接触边界安全回退;单独半解析总墙钟为 17.522 s,慢于 SciPy 的 15.160 s。当前目标因此继续使用默认 SciPy Jacobian,半解析保持显式 opt-in,下一步应做支路分区回退或扩大光滑模式覆盖,而不是放宽守卫。 + ### OPT-04 stream 拓扑传播与物性成组复用 **目标**:让无环 stream 网络一次传播,只对真正的强连通块迭代;同一状态反算的物性量成组计算和复用。 -**当前状态**:stream 求解器已预绑定组件、端口和连接,物性层也有单次运行精确缓存;但每次求解仍构造临时字典/列表、重复调用连接焓计算,尚未编译 SCC/DAG。热流体外层固定点最多 25 次,本模型实测最多 3 次。 +**当前状态**:stream 求解器已预绑定组件、端口和连接,物性层也有单次运行精确缓存;但每次求解仍构造临时字典/列表、重复调用连接焓计算,尚未编译 SCC/DAG。热流体外层固定点上限仍为 25 次:production `0.2 s` 实测最多 3 次,修复后延长到 `2/5/10 s` 实测最多 18–23 次;修复前在 `t≈1.8595–1.8603 s` 会耗尽 25 次。当前已补充试探点事务回滚和类型化可恢复失败,并由 StreamResolver 为所有覆盖温度参考更新钩子的组件统一刷新连接参考;SCC/DAG 传播与物性成组复用尚未实现。 **工作项**: @@ -407,6 +486,8 @@ - [ ] 将 `p/T/rho/h/s` 等同源物性组织为状态包,按精确输入键成组复用。 - [ ] 增加缓存命中、SCC 迭代、失效原因和物性调用次数指标。 - [ ] 评估脏标记传播,但必须证明事件和反向流切换时不会复用陈旧值。 +- [x] 为热流体外层 25 次耗尽提供类型化可恢复失败和单次 RHS 事务回滚,避免失败试探点污染下一次尝试;这是鲁棒性前置,不代表 SCC/DAG 优化已经完成。 +- [x] StreamResolver 按组件行为预编译所有覆盖 `update_flow_temperature_references` 的组件,并在每轮 stream 更新后统一刷新温度参考;物理岛边界同时识别 stream outflow 与温度参考钩子覆盖。 **验收条件**: @@ -417,7 +498,7 @@ | 指标 | 当前 | 完成后 | | --- | ---: | ---: | | stream 块 / 未知量 | 9 / 192 | 待填 | -| 最大热流体迭代 | 3 | 待填 | +| 最大热流体迭代 | production 0.2 s:3;修复后 2/5/10 s:18–23;恢复阈值:25 | 待填 | | `2.10 s` 压力闭合 | 57,601 | 待填 | | 物性调用 / 缓存命中率 | 待测 | 待填 | @@ -425,7 +506,7 @@ **目标**:减少量纲差异造成的不必要小步和 Jacobian 重建,同时维持事件与守恒精度。 -**当前状态**:模型中不同物理量的量级差异大。历史试验显示机械绝对容差放宽可能带来约 16% 收益,但属于精度策略变化;热流体固定点容差的简单放宽曾使表现变差,不能直接采用。 +**当前状态**:模型中不同物理量的量级差异大。历史试验显示机械绝对容差放宽可能带来约 16% 收益,但属于精度策略变化;热流体固定点容差的简单放宽曾使表现变差,不能直接采用。当前已先完成不改变容差契约的可恢复试探步:按积分器实际 `h_abs` 对半退避,并在首次接受后恢复分段步长上限。Generic 显式 opt-in,即使模型无状态事件、断点或取消回调,也会进入支持重建的 stepwise 路径;普通 `integrate_ode` 调用者的默认路径不变。分量 `atol`、缩放和标准/快速配置仍未开始。 **工作项**: @@ -435,6 +516,8 @@ - [ ] 统计限制步长的状态分量、误差拒步和 Jacobian 重建原因。 - [ ] 对事件前后、接触临界区和稳态区分别评估步长上限策略。 - [ ] 建立严格/标准/快速配置,但默认配置必须有明确精度契约。 +- [x] 对可恢复的热流体闭合失败使用积分器实际试探步 `h_abs` 对半回退;最多 16 次且不低于 64 ULP,恢复步仅设置 `first_step`,首次接受后恢复分段 `maxStep` 上限并记录 attempted/next step。 +- [x] 为 eventless Generic 显式启用 `recoverable_trial_retries`,使没有状态事件、断点或取消回调的通用模型也能选择 stepwise 恢复;该参数默认关闭,避免改变其他调用者的直接 `solve_ivp` 语义。 **验收条件**: @@ -487,7 +570,7 @@ **目标**:区分“内部慢步”和“真正无进度”,并让长任务可取消、可限流、不会拖垮服务进程。 -**当前状态**:已有 stream 进度和取消检查;前端无进度阈值约 60 s。本次 2.05 s 附近可见最大间隔约 7.5 s,且中间有接受步与 CPU 活动,因此没有触发真实无进度条件。 +**当前状态**:已有 stream 进度和取消检查;前端无进度阈值约 60 s。历史 2.05 s 附近可见最大间隔约 7.5 s,且中间有接受步与 CPU 活动,因此没有触发真实无进度条件。当前又补充了热流体失败位置、迭代尾部、最差端口及求解器恢复轨迹;`5 s / maxStep=0.05 s` 在 1200 s soft budget 后合作取消并保留 `t=4.2523535 s` 的部分诊断,属于预算终止而非 solver failure。单格 max-step 矩阵不再把“没有跨步长比较对”误判为失败。 **工作项**: @@ -497,6 +580,9 @@ - [ ] 限制并发仿真 worker、队列长度和单任务 CPU/内存预算。 - [ ] 超时报告最后活动阶段、模拟时刻、步长和关键计数,而非只返回通用错误。 - [ ] 添加故意慢 RHS、死循环防护、客户端断连和多任务竞争测试。 +- [x] 热流体失败记录 RHS 时刻、最近迭代尾部、最大增量/尺度/容差、最差端口及带符号差值,并保留求解器逐次恢复的 attempted/next step 与原因。 +- [x] 矩阵报告分别记录外层 `soft_timeout` 和 worker 的合作 `cancelled`,避免把预算取消误记为求解器数值失败。 +- [x] 单格 max-step 矩阵将空的跨步长比较集合视为“不适用”而非失败;最终 `2 s / 0.02 s` 单格复验整体通过且 `comparisonFailureCount=0`。 **验收条件**: @@ -508,7 +594,42 @@ **目标**:用实测替代“0.81 s 或 2.10 s 可以外推到 10 s”的假设。 -**当前状态**:`2.10 s` 已成功;`10 s` 尚未运行和建立资源预算。模型可能在后续出现新的事件、模式、接触切换或数值尺度问题。 +**当前状态**:新主目标的 solver-only `1 s / maxStep=0.05 s` 已完成,worker 墙钟 `182.111 s`。修复前,`tStop=2 s` 与 `tStop=5 s` 在同一 `maxStep=0.05 s` 下具有相同的首次失败时刻和求解统计,均在 `t=1.859512845 s` 耗尽热流体外层 25 次;四档 `maxStep` 的失败时刻集中在 `1.8595–1.8603 s`。这说明远端 `tStop` 不是直接失败原因,它只决定运行是否到达该局部数值困难区。 + +PNL00R stream 语义、单次 RHS 事务回滚和基于实际试探步的恢复完成后,production `2 s` 的 `maxStep=0.01/0.02/0.05/0.10 s` 四个单元均到达 `2.0 s`,`caseFailureCount=0`。矩阵命令整体退出码仍为 1,原因是跨 `maxStep` 的严格状态一致性门未通过,而不是任何单元运行失败:差异集中在事件后的 8 个 MECMAS21 速度和 8 个加速度;在差异最大的一组跨 `maxStep` 终点比较中,绝对差约 `1.01e-6–1.12e-6`。`0.05/0.10 s` 两档则逐位一致。因此当前结论是“2 s 运行失败已解决”,但“跨步长数值等价”尚未签收,不能据此批准长时 golden。 + +`5 s / maxStep=0.02 s` 已完成,worker 墙钟 `696.418 s`,0 次可恢复重试,最大热流体迭代 19,`nfev/njev/nlu=18736/1347/4988`。`maxStep=0.05 s` 在 1200 s soft budget 后由 runner 合作取消,停止于 `t=4.2523535 s`,此前仅发生 1 次已成功恢复的试探步;它是有界预算结果,不是 solver failure,也不能与已完成的 `0.02 s` 单元做终点一致性签收。形成该阶段记录时,`10 s / maxStep=0.02 s` 尚在运行;完成结果及其后追加的通用接线复验见下方收口记录。 + +| `tStop` | `maxStep` | lane / 结果 | worker 墙钟或预算 | 可恢复重试 | 说明 | +| ---: | ---: | --- | ---: | ---: | --- | +| 1 s | 0.05 s | solver-only / 完成 | 182.111 s | —(旧版未记录) | 首次延长门通过 | +| 2 s | 0.01 s | production / 完成 | 324.727 s | 8 | 最大热流体迭代 19 | +| 2 s | 0.02 s | production / 完成 | 292.035 s | 0 | 首次 recovery 矩阵当时最快;最大热流体迭代 19 | +| 2 s | 0.05 s | production / 完成 | 450.425 s | 1 | 最大热流体迭代 18 | +| 2 s | 0.10 s | production / 完成 | 448.033 s | 1 | 与 0.05 s 路径逐位一致,上限未实际约束 | +| 2 s | 0.02 s | production / 最终通用接线复验完成 | 301.782 s | 0 | 2 次事件;单格矩阵整体通过 | +| 5 s | 0.02 s | production / 完成 | 696.418 s | 0 | 最大热流体迭代 19;`18736/1347/4988` | +| 5 s | 0.05 s | production / soft budget 合作取消 | 1200 s | 1 | 停止于 4.2523535 s;不是 solver failure | +| 10 s | 0.02 s | production / 历史:最终通用接线前单元完成 | 803.622 s | 0 | 接线前历史证据,不作为最终性能口径 | +| 10 s | 0.02 s | production / 最终通用接线后完成 | 1602.733 s | 1 | orchestration 1604.152 s;`45455/3075/15282`;接受步 9569;启动 6;事件 2 | + +#### 2026-08-17 / PNL00R 正确性、热流体事务与实际步长恢复 + +- PNL00R 的端口温度参考改为同侧连接对端的温度参考焓:连接到 node 时使用对端组件的 `temperature_reference_h`,普通组件则使用常规 `connected_h`(即连接端口的 `h_outflow`);零容积元件自身的 `h_outflow` 仍保持对侧传播语义。42 项 PNL00R/stream 相关测试通过。 +- 单次 RHS 事务会回滚物理端口、flow、物性缓存、因果绑定及相关诊断,防止失败试探点污染下一次尝试。只有热流体外层 25 次耗尽被分类为可恢复错误;`StreamSolveError` 和 secondary `AlgebraicSolveError` 仍保持致命错误语义。 +- 事务开销的 7×100 RHS 微基准为关闭 `0.813488 s`、开启 `0.829156 s`,增加 `1.926%`,导数逐位一致。 +- 聚焦组合回归共 163 项通过、1 项跳过。修复后 production `0.2 s` worker 墙钟 `128.296 s`,402 个 golden 值通过,最大绝对差 `0.0171461`、最大容差比 `0.151304`,output contract 不变。 +- 证据:`runs/2026-08-17-production-thermofluid-recovery-v1-0.2.json`、`runs/2026-08-17-production-2s-max-step-matrix-v1.json`、`runs/2026-08-17-production-2s-max-step-matrix-recovery-v2.json`、`runs/2026-08-17-production-5s-max-step-matrix-recovery-v1.json`。 + +#### 2026-08-17 / 最终通用接线后的 `10 s` repeat 与收口 + +- 最终通用接线后的 `runs/2026-08-17-production-10s-max-step-0p02-general-recovery-v3.json` 完成到 `10.0 s`:worker 墙钟 `1602.733 s`、orchestration 墙钟 `1604.152 s`,`nfev/njev/nlu=45455/3075/15282`,接受步 9569,solver 启动 6 次,2 次状态事件。运行在 `t=6.9640458 s` 发生 1 次热流体可恢复失败并以 1 次重试继续完成,最大热流体迭代 23,最大缩放残差 `1.082e-16`;1717 条序列、1,722,151 个标量全部有限。 +- `runs/2026-08-17-production-10s-max-step-0p02-recovery-v1.json` 的 worker `803.622 s` 结果明确属于上述两项最终通用接线之前的历史运行,只保留为阶段性正确性和故障定位证据,不作为最终版本的性能数据。 +- 该接线前历史 10 s 报告的运行单元和 case acceptance 均通过,但旧版单格矩阵因 `sameHorizonAcrossMaxSteps=[]` 被空比较器误判,导致报告顶层 `passed=false` 和旧退出码 1;这不是仿真或数值验收失败。空比较器缺陷已经修复,最终接线后的 10 s repeat 与 `2 s / maxStep=0.02 s` 单格报告均整体 `passed=true`;后者另明确记录 `caseFailureCount=0`、`comparisonFailureCount=0`。 +- 旧 10 s 报告生成时曾根据目标的状态事件与拓扑边界推断两项最终接线不会改变已覆盖边界;该推断作为历史说明保留,现在已由最终接线后的完整 10 s repeat 直接取代。 +- 最终接线前后 `0.01 s` 输出逐值一致。两次 production `0.2 s` final candidate 运行也彼此逐值相同并均完成到终点,但两次对旧批准 golden 都只有 `398/402` 个值通过:同样的 4 个 `t=0.2 s` 派生 MECMAS21 加速度超出旧容差,最大容差比均为 `1.373`。因此不覆盖或重新批准旧 golden;应先独立确认派生加速度语义或调整投影契约。 +- 最终 `2 s / maxStep=0.02 s` 复验 worker 墙钟 `301.782 s`,0 次热流体失败/可恢复重试,2 次状态事件,单格矩阵整体通过。真实 SciPy RK45/BDF 的 direct 与 opt-in stepwise A/B 在无失败时采样、状态及 `nfev/njev/nlu` 一致。完整 `unittest discover` 共 828 项,OK(3 项跳过)。 +- 证据:`runs/2026-08-17-production-general-recovery-v2-smoke.json`、`runs/2026-08-17-production-general-recovery-v2-0.2.json`、`runs/2026-08-17-production-general-recovery-v2-repeat-0.2.json`、`runs/2026-08-17-production-2s-max-step-0p02-general-recovery-v3.json`、`runs/2026-08-17-production-10s-max-step-0p02-recovery-v1.json`、`runs/2026-08-17-production-10s-max-step-0p02-general-recovery-v3.json`。 **工作项**: @@ -517,6 +638,7 @@ - [ ] 为长跑设置阶段性检查点,支持定位首次偏差而非只比较终点。 - [ ] 将每项 P1 优化分别加入 `10 s` A/B,不把多个改动混成一个结果。 - [ ] 根据首次基线制定合理的 CI 频率和资源门槛。 +- [x] 最终通用接线后的当前工作树完成首次 `10 s / maxStep=0.02 s` 单次运行并保存完整统计;连续 3 次验收仍待后续。 **验收条件**: @@ -554,6 +676,8 @@ | 本文复杂 XML `0.81 s` | 必测 | 必测 | 必测 | 必测 | 必测 | 必测 | | 本文复杂 XML `2.10 s` | 必测 | 必测 | 必测 | 必测 | 必测 | 必测 | | 本文复杂 XML `10 s` | 必测 | 必测 | 必测 | 必测 | 必测 | 必测 | +| 主目标 `test-mql-8` `0.2 s` | 必测 | 必测 | 必测 | 必测 | 必测 | 必测 | +| 主目标 `test-mql-8` `1/5/10 s` | 必测 | 必测 | 必测 | 必测 | 必测 | 必测 | 当前相关回归套件包括: @@ -562,8 +686,14 @@ - `tests/test_pressure_flow_causal_execution.py` - `tests/test_stream_pressure_block_solver.py` - `tests/test_core_solver.py` +- `tests/test_causal_numeric_ir.py` +- `tests/test_thermofluid_recovery.py` +- `tests/test_amesim_pnl00r_component.py` +- `tests/test_stream_resolver_execution_plan.py` +- `tests/test_thermofluid_closure_plan.py` +- `tests/test_max_step_matrix.py` -这些测试目前覆盖部分关键机制,但不能替代复杂 XML 的端到端数值和长时回归。 +这些测试目前覆盖部分关键机制,但不能替代复杂 XML 的端到端数值和长时回归。最终完整 `unittest discover` 共 828 项,OK(3 项跳过)。 ## 7. 单项更新模板 @@ -593,6 +723,11 @@ | 2026-08-17 | 工作树基于 `6bb0591d`;备份 `backup/jacobian-before-20260817-6bb0591` | OPT-03 | callable sparse Jacobian、真实计数、分段重置、取消、严格 seed 0 与实验 secant | 121 项相关测试通过;另 1 项既有 fixture 路径错误;30 色候选事件不等价,seed 0 候选恢复相同哈希 | 30 色历史候选有收益但不正确;seed 0 候选略慢且缓存 0 命中 | 默认 SciPy;移除多 seed/缓存;保留接入基础;解析/半解析继续后续 | | 2026-08-17 | 工作树基于 `6bb0591d`;同一备份分支 | OPT-03 首批半解析切片 | exact-columns subset FD、类型化回退/诊断、三活塞 6 列与 34 条因果赋值;31→25 个 FD 颜色;新增 Ideal/PR、PNRP、PNCH012、PNL0001、LSTP、MECMAS 切向原语 | focused 86 + adjacent 164 = 250 项通过;closure 12/13,唯一失败为既有 fixture 路径;局部列对中心 FD 最大相对误差 `1.897e-8`;默认容差轨迹仍超严格逐点门槛,但随 rtol 收紧约 4.67×/5.15× 收敛且事件一致 | `0.81 s` 三次墙钟中位数 61.203→56.708 s,Jac RHS 8096(估计)→5985(实计);最终 `2.10 s` 单次 126.211→116.512 s,正常越过 2.05 s,事件/启动/样本均与基线一致 | 首批目标切片完成,OPT-03 总体仍部分实现;默认 SciPy,`semi-analytic` 显式 opt-in;待通用 stream/其余列、正式锁定环境独立预热和 10 s 验证 | | 2026-08-17 | 同一 OPT-03 工作树;3 组相邻 A/B | OPT-03 重复性能复核 | 原始 `0.81 s`,每组先 SciPy 后 `semi-analytic`,运行期间无并发仿真负载 | 三组求解统计、哈希、事件和输出网格各自完全稳定;Jacobian RHS 8096(估计)→5985(实计) | 总墙钟中位数 61.203→56.708 s(`-7.34%`),积分中位数 59.725→55.631 s(`-6.85%`) | 保持显式 opt-in;仍需正式锁定环境独立预热、严格轨迹契约和 10 s 验证 | +| 2026-08-17 | 工作树基于 `16a7eb2d`;备份 `backup/general-solver-v1-before-20260817-16a7eb2` | OPT-00/01/03/09 通用求解器 v1(历史输入) | 初版 `test-mql-8` runner/正确性门;默认低分配因果执行器 v2;名字无关的 8 支路/16 列半解析编译器 | 旧 SHA `42e2d627...` 下 0.01 s v1/v2 物理解逐位相同;0.2 s 全有限且 0 审计/回退失败 | v2 RHS 微基准 `-19.1%`;0.01 s 总墙钟 `-13.1%`;旧 0.2 s 132.305 s | v2 升为默认并保留 opt-out;旧报告标为 `historicalOnly`,不得生成新 golden | +| 2026-08-17 | 同一工作树;新权威 SHA `170463d6...` | OPT-00 P0 基础闭环 | 固化无空格 XML/JSON、参考依赖约束、runner v2、state golden、output contract、三层 CI 和有界延期决策 | production 0.2 s 全有限;402 个 golden 值逐项重放误差 0;信号分段/机械事件/残差/审计/回退门均通过;全量共 792 项,OK(3 项跳过) | worker 135.824 s;1 s 保守预测 1018.680 s,未启动 1/5/10 s | P0 基础设施完成,完整 OPT-00/09 仍部分实现;先优化算法,再恢复长时递进 | +| 2026-08-17 | 同一工作树 | OPT-01/02 因果坐标与参考 IR | `760` 个兼容槽压缩为 `440` 个逻辑坐标;独立 schema v1 参考 IR 覆盖 `112+328` 坐标和 320 个逻辑别名 | kernel on/off、兼容槽、状态导数、结构签名和逐阶段差分通过;审计/验证/回退失败均为 0 | Python 调用 `-36.8%`,RHS 微基准 `-12.9%`,production 0.2 s 单次 `-3.68%` | OPT-01 基本完成;IR 暂不接管默认热路径 | +| 2026-08-17 | 同一工作树 | OPT-00/04/05/08/09 热流体恢复与延长矩阵 | 修正 PNL00R 温度 stream 参考;加入 RHS 事务、类型化闭合失败、基于 `h_abs` 的对半重试和完整诊断 | production 0.2 s golden 通过;2 s 四档 `maxStep` 均完成且 `caseFailureCount=0`,但跨步长严格门因近零机械 `a/v` 差异未过;5 s 的 0.02 s 档完成,0.05 s 档为预算取消而非 solver failure | 2 s worker 墙钟为 324.727/292.035/450.425/448.033 s;5 s 的 0.02 s 档为 696.418 s、0 retry、`18736/1347/4988`,0.05 s 档在 1200 s 预算停止于 4.2523535 s | 原 1.86 s 致命失败已恢复;暂以 0.02 s 作为延长测试首选但不修改正式默认值或批准 golden;10 s 的 0.02 s 档进行中 | +| 2026-08-17 | 同一工作树;最终通用接线与 10 s repeat | OPT-04/05/08/09 `10 s` 最终收口 | eventless Generic opt-in stepwise recovery;StreamResolver 刷新全部温度参考 override;修复单格矩阵空比较器 | 0.01 s 接线前后逐值一致;两次 0.2 s final candidate 彼此逐值相同且均为旧 golden 398/402,同样 4 个终点派生 MECMAS21 `a` 超差、最大容差比 1.373,未覆盖 golden;最终 2 s 单格通过;真实 SciPy direct/stepwise A/B 等价;完整 unittest 828 项 OK(3 项跳过) | 最终接线后 10 s worker/orchestration 1602.733/1604.152 s,`45455/3075/15282`,接受步 9569、启动 6、事件 2;`t=6.9640458 s` 的 1 次热流体失败经 1 次重试恢复,最大迭代 23、残差 `1.082e-16`,1717 序列/1,722,151 标量全有限;最终 2 s worker 301.782 s | 最终通用接线后的 10 s 已完成;803.622 s 旧报告只作接线前历史证据、不作最终性能;旧 exit 1 仅为空比较器缺陷;旧 golden 保留,连续 3 次 10 s 仍待后续 | ## 9. 相关文档 diff --git a/docs/update-log/更新日志-2026-08-17.md b/docs/update-log/更新日志-2026-08-17.md index 5f42cb3..eebcac4 100644 --- a/docs/update-log/更新日志-2026-08-17.md +++ b/docs/update-log/更新日志-2026-08-17.md @@ -51,3 +51,35 @@ - 根据同日其他项目会话的最终记录补全上述文档管理、性能诊断和求解器优化工作,并与当前源码、测试及优化任务账本交叉核对。 - 在当前项目 `.venv` 中重新运行 Jacobian、切向原语、三活塞、core solver、稀疏结构和 XML 仿真的定向测试,共 86 项全部通过;`git diff --check` 通过。 - 热流体闭合套件当前仍为 12/13,通过项不受影响;唯一失败是测试继续读取已经移动的旧 fixture 路径。测试资源移动属于用户操作,本日志未将其计入其他会话的完成成果。 + +## 19:40 + +- 将用户提供的 `test-mql-8` XML 接入主回归目标并锁定输入哈希;新增支持软取消、硬超时、预算判断、检查点、信号分段和机械事件验收的递进回归运行器,延长测试只在子进程内存中覆盖 `tStop`。 +- 默认启用可回滚的因果执行器 v2,普通 RHS 不再重复构造集合或扫描全部 760 个代数未知量,secondary 块只维护 176 个必要 flow 槽;初始化、事件和每 64 次求解仍执行完整残差审计。 +- 半解析 Jacobian 改为按组件类型和端口拓扑自动发现支路:该模型识别 8 条支路、16 个精确列,理论有限差分颜色数由 52 降至 36;短测因 40/56 次边界回退而慢于 SciPy,因此继续保留为显式实验模式,未设为默认。 +- 同状态 RHS 微基准约提升 19.1%,`0.01 s` 端到端由 `15.160 s` 降至 `13.172 s`且物理解哈希一致;`0.2 s` solver-only 运行正常完成,审计、运行时验证和旧路径回退均为 0。 +- 聚焦测试 143 项通过、1 项长测跳过;全量 779 项中 773 项通过、1 项跳过,其余 5 项为既有缺失 fixture。按当时 `0.2 s` 耗时预算,`1/5/10 s` 暂缓执行,checkpoint 和依赖环境尚未批准为发布基线。 + +## 20:01 + +- 完成大型工程导入后的端口几何重测,建模页与结果页分别记忆视口,仅首次打开、导入或加载时自动适配;多组件移动和复制支持整块自由端口吸附,并在拖动时立即断开块外接触连接。 +- 新增点击端口接线、空白处添加折点、`Esc` 取消、路由写入工程 JSON、内部线段拖动及未连接交叉线的电路图式线桥。 +- 结果曲线支持框选、单轴、撤销和自动缩放,并保持切页后的缩放状态;使用大型 `test-mql-8` 工程验证导入、适配和连线端点。 +- 前端 E2E `104/104`、TypeScript 检查、生产构建和 `git diff --check` 全部通过;仅保留非阻断的 bundle 大小提示。 + +## 22:35 + +- 将用户提供的 `test-mql-8.xml` 与 `test-mql-8.json` 纳入权威回归输入校验,自动检查双哈希、模型结构和仿真配置;回归运行器支持独立覆盖 `tStop`、`sampleStep`、`maxStep`,并将 `production` 设为默认验收通道。 +- 建立 `0.01 s` smoke 和 `0.2/1/5/10 s` 递进门禁、软硬超时及超预算暂缓;production `0.2 s` 正常完成,并批准包含 3 个检查点、134 个投影键、共 402 个状态值的 golden,另行校验输出形状合同。 +- 增加 Python 3.12.3 与直接依赖参考约束,以及短测、历史模型夜间回归和主目标周期长测三层 CI;全量后端共运行 `792` 项测试,结果为 OK,其中 `3` 项按条件跳过,测试后无遗留仿真进程。 +- P0 已形成支持后续优化的基础闭环,但仍缺关键压力、流量和守恒量投影、发布级完整依赖锁、干净环境重建、远端 CI 首次验证及正式环境三次 `2.10 s` 复测;按当时预算仍未启动 `1/5/10 s`。 + +## 22:40 + +- 确认 P0 门禁已足以进入 OPT-01,并允许启动 OPT-02 的小型参考数值 IR;后续扩围统一执行“结构测试 → `0.01 s` smoke → production `0.2 s` golden A/B → 性能对比”,但当前状态尚不代表求解器已完成发布验收。 + +## 22:48 + +- 修复同一组件不同端口引出线路交叉时漏画线桥的问题,并让结果页系统图复用线桥;连接线支持拖动水平段、竖直段和拐点,直线可自动转换为可调正交折线。 +- 结果曲线增加以鼠标位置为中心的滚轮缩放、坐标轴单轴缩放、中键拖动平移、框选放大、越过数据范围及负数区域、恢复原始尺寸;缩放与游标互斥,空白视口禁用游标,并移除点击后的黑色焦点框。 +- 使用 `test-mql-8.json` 完成专项验证;前端 E2E `109/109`、TypeScript 检查、项目 Node 24 下的生产构建和 `git diff --check` 全部通过。 diff --git a/docs/update-log/更新日志-2026-08-18.md b/docs/update-log/更新日志-2026-08-18.md new file mode 100644 index 0000000..c5d8b88 --- /dev/null +++ b/docs/update-log/更新日志-2026-08-18.md @@ -0,0 +1,12 @@ +# 更新日志 2026-08-18 + +## 02:28 + +- 完成 P0 基础回归闭环,并推进 OPT-01:将 760 个兼容代数槽压缩为 440 个逻辑坐标,逻辑消去 320 个 effort 别名;全局 RHS 微基准约提升 12.9%,Python 调用数约下降 36.8%。 +- 建立独立可执行的因果数值 IR schema v1,分离结构程序与运行时绑定并支持逐阶段对照和事务回滚;该 IR 仍是参考实现,尚未接管默认 RHS 热路径。 +- 修复 PNL00R 上游连接温度引用语义;为热流体闭合增加事务快照、类型化失败诊断、试探态回滚和基于实际试探步长的减步重试,并修复成功恢复后最大步长被永久限制的问题;无状态事件的 Generic 系统也可使用可恢复积分路径。 +- `1 s`、`2 s`、`5 s` 和 `10 s` 递进长测均取得完成结果,`maxStep=0.02` 是当前长测首选;另一个 `5 s/maxStep=0.05` 单元在模拟时刻约 `4.252 s` 因 1200 秒预算合作取消,属于预算控制而非求解失败。 +- 最终 `10 s/maxStep=0.02` 单次运行在约 `1602.7 s` 完成,1717 条序列、1,722,151 个标量全部有限并经历 2 次机械状态转换;模拟时刻约 `6.964 s` 的一次热流体试探态失败经事务回滚、减步和 1 次重试后继续完成,因果审计、运行时验证和旧路径回退均为 0。 +- 相同 `maxStep=0.02` 的 `2 s` 与 `10 s` 运行在公共严格前缀检查点逐值一致,确认此前约 `1.86 s` 的失败来自不可恢复的试探态闭合处理,而不是远端 `tStop` 直接改变物理方程。 +- 全量后端共运行 `828` 项测试,结果为 OK,其中 `3` 项按条件跳过;差异检查通过且测试后无遗留仿真进程。 +- 当前仍有明确限制:跨最大步长严格比较尚未全部通过;最终 `0.2 s` 候选相对旧 golden 为 `398/402`,4 个超差项均是终点派生加速度,旧 golden 未被覆盖;最终 `10 s` 仅完成一次,仍需三次中位数、资源稳定性和新 golden 决策。 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3b91b4f..3e5aca0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -54,6 +54,7 @@ import { Position, ReactFlow, ReactFlowProvider, + ViewportPortal, SelectionMode, addEdge, applyNodeChanges, @@ -69,6 +70,7 @@ import { type OnConnectStart, type OnNodeDrag, type OnSelectionChangeFunc, + type Viewport, useEdgesState, useNodesState, useReactFlow, @@ -110,6 +112,15 @@ import { CONTACT_AWARE_EDGE_TYPE, contactAwareEdgeTypes, } from "./ContactAwareEdge"; +import { + buildOrthogonalRoutePoints, + detectEdgeCrossingJumps, + edgePointIsFinite, + orthogonalEdgePoints, + orthogonalPolylinePath, + type EdgeRouteData, + type EdgeRoutePoint, +} from "./edgeRouting"; import { ParameterTable, loadParameterColumnLayout, @@ -161,29 +172,35 @@ type ActivePortConnection = { mode: "drag" | "click"; }; +type PortConnectionDraft = { + pointer: EdgeRoutePoint | null; + waypoints: EdgeRoutePoint[]; +}; + type ContactSnapMatch = { connection: Connection; + draggedNodeId: string; draggedPortName: string; targetNodeId: string; targetPortName: string; }; type ContactSnapCandidate = { - draggedNodeId: string; matches: ContactSnapMatch[]; - snappedPosition: { x: number; y: number }; + positionDelta: { x: number; y: number }; }; type ContactDragSession = { contactEdgeIds: Set; disconnected: boolean; + draggedNodeIds: Set; effectiveEdges: SimulationEdge[]; lastCandidate: ContactSnapCandidate | null; lastEvaluatedPosition: { x: number; y: number } | null; lastSnapRadius: number; multipleNodes: boolean; nodeId: string; - snapSearchContext: ContactSnapSearchContext | null; + snapSearchContext: ContactSnapSearchContext; startPosition: { x: number; y: number }; }; @@ -408,6 +425,7 @@ type ProjectEdgePayload = { targetHandle: string; data: { isContactEdge: boolean; + routePoints?: EdgeRoutePoint[]; }; }; @@ -473,6 +491,7 @@ type PendingPaste = { edges: SimulationEdge[]; nodes: SimulationNode[]; repairedReferenceCount: number; + snapOffset: { x: number; y: number }; }; type PendingPasteMiddlePointer = { @@ -2211,6 +2230,13 @@ function nodePortFlowPlacement(node: SimulationNode, portName: string | null) { ); } +function resultNodePortFlowPlacement( + node: SimulationResultsSnapshot["project"]["nodes"][number], + portName: string | null, +) { + return nodePortFlowPlacement(node as unknown as SimulationNode, portName); +} + function connectionForContactPorts( draggedNode: SimulationNode, draggedPort: PortDefinition, @@ -2235,6 +2261,7 @@ function connectionForContactPorts( type ContactSnapPair = ContactSnapMatch & { distance: number; + draggedNodeOrder: number; draggedPortOrder: number; targetNodeOrder: number; targetPortOrder: number; @@ -2253,11 +2280,15 @@ type ContactSnapIndexedTarget = { type ContactSnapSearchContext = { buckets: Map; cellSize: number; - draggedNodeId: string; + draggedNodeIdsKey: string; occupiedPorts: Set; targetByPortKey: Map; }; +function contactSnapDraggedNodeIdsKey(nodeIds: ReadonlySet) { + return [...nodeIds].sort().join("\u001f"); +} + function contactSnapCell(value: number, cellSize: number) { return Math.floor(value / cellSize); } @@ -2293,9 +2324,11 @@ function compareContactSnapPairs( ) { return ( first.distance - second.distance || + first.draggedNodeOrder - second.draggedNodeOrder || first.draggedPortOrder - second.draggedPortOrder || first.targetNodeOrder - second.targetNodeOrder || first.targetPortOrder - second.targetPortOrder || + first.draggedNodeId.localeCompare(second.draggedNodeId) || first.draggedPortName.localeCompare(second.draggedPortName) || first.targetNodeId.localeCompare(second.targetNodeId) || first.targetPortName.localeCompare(second.targetPortName) @@ -2303,7 +2336,7 @@ function compareContactSnapPairs( } function buildContactSnapSearchContext( - draggedNodeId: string, + draggedNodeIds: ReadonlySet, nodes: SimulationNode[], edges: SimulationEdge[], ): ContactSnapSearchContext { @@ -2320,7 +2353,7 @@ function buildContactSnapSearchContext( const buckets = new Map(); const targetByPortKey = new Map(); nodes.forEach((targetNode, targetNodeOrder) => { - if (targetNode.id === draggedNodeId) { + if (draggedNodeIds.has(targetNode.id)) { return; } const targetPortOrder = new Map( @@ -2355,101 +2388,114 @@ function buildContactSnapSearchContext( return { buckets, cellSize, - draggedNodeId, + draggedNodeIdsKey: contactSnapDraggedNodeIdsKey(draggedNodeIds), occupiedPorts, targetByPortKey, }; } function availableContactSnapPairs( - draggedNode: SimulationNode, + draggedNodes: SimulationNode[], nodes: SimulationNode[], edges: SimulationEdge[], maximumDistance: number, searchContext?: ContactSnapSearchContext, ) { + const draggedNodeIds = new Set(draggedNodes.map((node) => node.id)); + const draggedNodeIdsKey = contactSnapDraggedNodeIdsKey(draggedNodeIds); const resolvedContext = - searchContext?.draggedNodeId === draggedNode.id + searchContext?.draggedNodeIdsKey === draggedNodeIdsKey ? searchContext - : buildContactSnapSearchContext(draggedNode.id, nodes, edges); + : buildContactSnapSearchContext(draggedNodeIds, nodes, edges); const { occupiedPorts } = resolvedContext; - const pairs: ContactSnapPair[] = []; - const draggedPortOrder = new Map( - draggedNode.data.ports.map((port, index) => [port.name, index] as const), - ); - const draggedPlacements = nodePortFlowPlacements(draggedNode).filter( - (placement) => - !occupiedPorts.has( - portConnectionKey(draggedNode.id, placement.port.name), - ), - ); const maximumDistanceSquared = maximumDistance * maximumDistance; - for (const draggedPlacement of draggedPlacements) { - for (const indexedTarget of contactSnapTargetsNear( - resolvedContext, - draggedPlacement.flowX, - draggedPlacement.flowY, - maximumDistance, - )) { - const { placement: targetPlacement, targetNode } = indexedTarget; - if (!arePortsCompatible(draggedPlacement.port, targetPlacement.port)) { - continue; + + draggedNodes.forEach((draggedNode, draggedNodeOrder) => { + const draggedPortOrder = new Map( + draggedNode.data.ports.map((port, index) => [port.name, index] as const), + ); + const draggedPlacements = nodePortFlowPlacements(draggedNode).filter( + (placement) => + !occupiedPorts.has( + portConnectionKey(draggedNode.id, placement.port.name), + ), + ); + for (const draggedPlacement of draggedPlacements) { + for (const indexedTarget of contactSnapTargetsNear( + resolvedContext, + draggedPlacement.flowX, + draggedPlacement.flowY, + maximumDistance, + )) { + const { placement: targetPlacement, targetNode } = indexedTarget; + if (!arePortsCompatible(draggedPlacement.port, targetPlacement.port)) { + continue; + } + const deltaX = targetPlacement.flowX - draggedPlacement.flowX; + const deltaY = targetPlacement.flowY - draggedPlacement.flowY; + const distanceSquared = deltaX * deltaX + deltaY * deltaY; + if (distanceSquared > maximumDistanceSquared) { + continue; + } + pairs.push({ + connection: connectionForContactPorts( + draggedNode, + draggedPlacement.port, + targetNode, + targetPlacement.port, + ), + distance: Math.sqrt(distanceSquared), + draggedNodeId: draggedNode.id, + draggedNodeOrder, + draggedPortName: draggedPlacement.port.name, + draggedPortOrder: + draggedPortOrder.get(draggedPlacement.port.name) ?? + Number.MAX_SAFE_INTEGER, + targetNodeId: targetNode.id, + targetNodeOrder: indexedTarget.targetNodeOrder, + targetPortOrder: indexedTarget.targetPortOrder, + targetPortName: targetPlacement.port.name, + }); } - const deltaX = targetPlacement.flowX - draggedPlacement.flowX; - const deltaY = targetPlacement.flowY - draggedPlacement.flowY; - const distanceSquared = deltaX * deltaX + deltaY * deltaY; - if (distanceSquared > maximumDistanceSquared) { - continue; - } - const connection = connectionForContactPorts( - draggedNode, - draggedPlacement.port, - targetNode, - targetPlacement.port, - ); - pairs.push({ - connection, - distance: Math.sqrt(distanceSquared), - draggedPortOrder: - draggedPortOrder.get(draggedPlacement.port.name) ?? - Number.MAX_SAFE_INTEGER, - draggedPortName: draggedPlacement.port.name, - targetNodeId: targetNode.id, - targetNodeOrder: indexedTarget.targetNodeOrder, - targetPortOrder: indexedTarget.targetPortOrder, - targetPortName: targetPlacement.port.name, - }); } - } + }); return pairs.sort(compareContactSnapPairs); } function maximumContactSnapMatches(pairs: ContactSnapPair[]) { - // 用二分图最大匹配保证同一批吸附中,每个拖动端口和目标端口都只占用一次。 + // 二分图最大匹配的两侧都使用完整 nodeId:portName,避免块内多个 + // 组件拥有同名 port_1 时互相覆盖。 const pairsByDraggedPort = new Map(); pairs.forEach((pair) => { - const current = pairsByDraggedPort.get(pair.draggedPortName) ?? []; + const key = portConnectionKey(pair.draggedNodeId, pair.draggedPortName); + const current = pairsByDraggedPort.get(key) ?? []; current.push(pair); - pairsByDraggedPort.set(pair.draggedPortName, current); + pairsByDraggedPort.set(key, current); }); const pairByTargetPort = new Map(); const assignDraggedPort = ( - draggedPortName: string, + draggedPortKey: string, visitedTargetPorts: Set, ): boolean => { - for (const pair of pairsByDraggedPort.get(draggedPortName) ?? []) { + for (const pair of pairsByDraggedPort.get(draggedPortKey) ?? []) { const targetKey = portConnectionKey(pair.targetNodeId, pair.targetPortName); if (visitedTargetPorts.has(targetKey)) { continue; } visitedTargetPorts.add(targetKey); const currentPair = pairByTargetPort.get(targetKey); + const currentDraggedKey = currentPair + ? portConnectionKey( + currentPair.draggedNodeId, + currentPair.draggedPortName, + ) + : null; if ( - currentPair && - !assignDraggedPort(currentPair.draggedPortName, visitedTargetPorts) + currentDraggedKey && + !assignDraggedPort(currentDraggedKey, visitedTargetPorts) ) { continue; } @@ -2459,26 +2505,31 @@ function maximumContactSnapMatches(pairs: ContactSnapPair[]) { return false; }; - for (const draggedPortName of pairsByDraggedPort.keys()) { - assignDraggedPort(draggedPortName, new Set()); + for (const draggedPortKey of pairsByDraggedPort.keys()) { + assignDraggedPort(draggedPortKey, new Set()); } return [...pairByTargetPort.values()].sort(compareContactSnapPairs); } function findContactSnapCandidate( - draggedNode: SimulationNode, + draggedNodes: SimulationNode[], nodes: SimulationNode[], edges: SimulationEdge[], maximumDistance: number, searchContext?: ContactSnapSearchContext, ) { + if (draggedNodes.length === 0) { + return null; + } + const draggedNodeIds = new Set(draggedNodes.map((node) => node.id)); + const draggedNodeIdsKey = contactSnapDraggedNodeIdsKey(draggedNodeIds); const resolvedContext = - searchContext?.draggedNodeId === draggedNode.id + searchContext?.draggedNodeIdsKey === draggedNodeIdsKey ? searchContext - : buildContactSnapSearchContext(draggedNode.id, nodes, edges); + : buildContactSnapSearchContext(draggedNodeIds, nodes, edges); const nearbyPairs = availableContactSnapPairs( - draggedNode, + draggedNodes, nodes, edges, maximumDistance, @@ -2488,35 +2539,32 @@ function findContactSnapCandidate( if (!anchor) { return null; } - const draggedPlacement = nodePortFlowPlacement( - draggedNode, - anchor.draggedPortName, + const draggedNode = draggedNodes.find( + (node) => node.id === anchor.draggedNodeId, ); - const indexedTarget = resolvedContext.targetByPortKey.get( + const draggedPlacement = draggedNode + ? nodePortFlowPlacement(draggedNode, anchor.draggedPortName) + : undefined; + const targetPlacement = resolvedContext.targetByPortKey.get( portConnectionKey(anchor.targetNodeId, anchor.targetPortName), - ); - const targetPlacement = indexedTarget?.placement; + )?.placement; if (!draggedPlacement || !targetPlacement) { return null; } - const snappedPosition = { - x: - draggedNode.position.x + - targetPlacement.flowX - - draggedPlacement.flowX, - y: - draggedNode.position.y + - targetPlacement.flowY - - draggedPlacement.flowY, + const positionDelta = { + x: targetPlacement.flowX - draggedPlacement.flowX, + y: targetPlacement.flowY - draggedPlacement.flowY, }; - const snappedDraggedNode = { - ...draggedNode, - position: snappedPosition, - }; - // 多个接口必须共享同一个组件落点;只连接该落点下真正重合的接口, - // 避免一个组件同时被互相冲突的接口位置“拉向”不同方向。 + const snappedDraggedNodes = draggedNodes.map((node) => ({ + ...node, + position: { + x: node.position.x + positionDelta.x, + y: node.position.y + positionDelta.y, + }, + })); + // 整个块只采用一个平移量;仅建立该统一落点下真正重合的端口。 const exactPairs = availableContactSnapPairs( - snappedDraggedNode, + snappedDraggedNodes, nodes, edges, CONTACT_GEOMETRY_TOLERANCE, @@ -2525,19 +2573,19 @@ function findContactSnapCandidate( const matches = maximumContactSnapMatches(exactPairs).map( ({ connection, + draggedNodeId, draggedPortName, targetNodeId, targetPortName, - }) => ({ connection, draggedPortName, targetNodeId, targetPortName }), + }) => ({ + connection, + draggedNodeId, + draggedPortName, + targetNodeId, + targetPortName, + }), ); - if (matches.length === 0) { - return null; - } - return { - draggedNodeId: draggedNode.id, - matches, - snappedPosition, - }; + return matches.length > 0 ? { matches, positionDelta } : null; } function sameContactSnapCandidate( @@ -2548,19 +2596,17 @@ function sameContactSnapCandidate( first === second || (Boolean(first) && Boolean(second) && - first?.draggedNodeId === second?.draggedNodeId && - first?.snappedPosition.x === second?.snappedPosition.x && - first?.snappedPosition.y === second?.snappedPosition.y && + first?.positionDelta.x === second?.positionDelta.x && + first?.positionDelta.y === second?.positionDelta.y && first?.matches.length === second?.matches.length && first?.matches.every((match, index) => { const other = second?.matches[index]; - if (!other) { - return false; - } - return ( - match.draggedPortName === other.draggedPortName && - match.targetNodeId === other.targetNodeId && - match.targetPortName === other.targetPortName + return Boolean( + other && + match.draggedNodeId === other.draggedNodeId && + match.draggedPortName === other.draggedPortName && + match.targetNodeId === other.targetNodeId && + match.targetPortName === other.targetPortName, ); })) ); @@ -2582,6 +2628,55 @@ function isContactEdgeGeometry(nodes: SimulationNode[], edge: SimulationEdge) { ); } +function routePointsForEdge(edge: SimulationEdge) { + const routePoints = (edge.data as EdgeRouteData | undefined)?.routePoints; + return Array.isArray(routePoints) && routePoints.every(edgePointIsFinite) + ? routePoints + : []; +} + +function translateEdgeRoute( + edge: SimulationEdge, + deltaX: number, + deltaY: number, +): SimulationEdge { + const routePoints = routePointsForEdge(edge); + if ( + routePoints.length === 0 || + (Math.abs(deltaX) <= 0.001 && Math.abs(deltaY) <= 0.001) + ) { + return edge; + } + return { + ...edge, + data: { + ...edge.data, + routePoints: routePoints.map((point) => ({ + x: point.x + deltaX, + y: point.y + deltaY, + })), + }, + }; +} + +function translateInternalEdgeRoutes( + edges: SimulationEdge[], + movedNodeIds: ReadonlySet, + deltaX: number, + deltaY: number, +) { + let changed = false; + const translated = edges.map((edge) => { + if (!movedNodeIds.has(edge.source) || !movedNodeIds.has(edge.target)) { + return edge; + } + const next = translateEdgeRoute(edge, deltaX, deltaY); + changed ||= next !== edge; + return next; + }); + return changed ? translated : edges; +} + type NodePortGeometryIndex = Map< string, Map @@ -3774,6 +3869,103 @@ function FlowViewportScrollbars({ ); } +function connectionDraftTargetPosition( + source: EdgeRoutePoint, + target: EdgeRoutePoint, +) { + const deltaX = target.x - source.x; + const deltaY = target.y - source.y; + if (Math.abs(deltaX) >= Math.abs(deltaY)) { + return deltaX >= 0 ? Position.Left : Position.Right; + } + return deltaY >= 0 ? Position.Top : Position.Bottom; +} + +function ConnectionDraftOverlay({ + activeConnection, + draft, + nodes, +}: { + activeConnection: ActivePortConnection | null; + draft: PortConnectionDraft | null; + nodes: SimulationNode[]; +}) { + if (!activeConnection || activeConnection.mode !== "click" || !draft?.pointer) { + return null; + } + const source = nodes.find((node) => node.id === activeConnection.nodeId); + const sourcePlacement = source + ? nodePortFlowPlacement(source, activeConnection.portName) + : undefined; + if (!sourcePlacement) { + return null; + } + const sourcePoint = { + x: sourcePlacement.flowX, + y: sourcePlacement.flowY, + }; + const routePoints = buildOrthogonalRoutePoints( + sourcePoint, + draft.pointer, + sourcePlacement.position, + connectionDraftTargetPosition(sourcePoint, draft.pointer), + draft.waypoints, + ); + const points = [sourcePoint, ...routePoints, draft.pointer]; + return ( + + + + ); +} + +function PortGeometryRefresh({ + nodeIds, + requestKey, +}: { + nodeIds: string[]; + requestKey: number; +}) { + const zoom = useStore((state) => state.transform[2]); + const updateNodeInternals = useUpdateNodeInternals(); + const lastRefreshKeyRef = useRef(""); + + useLayoutEffect(() => { + const refreshKey = `${requestKey}:${zoom}:${nodeIds.join("\u001f")}`; + if (lastRefreshKeyRef.current === refreshKey || nodeIds.length === 0) { + return; + } + lastRefreshKeyRef.current = refreshKey; + const frame = window.requestAnimationFrame(() => { + updateNodeInternals(nodeIds); + }); + return () => window.cancelAnimationFrame(frame); + }, [nodeIds, requestKey, updateNodeInternals, zoom]); + + return null; +} + function isVisibleComponentLibrary(library: ComponentLibraryDefinition) { return library.id !== "experimental"; } @@ -3811,6 +4003,8 @@ function FlowWorkbench() { useState>({}); const [activePortConnection, setActivePortConnection] = useState(null); + const [portConnectionDraft, setPortConnectionDraft] = + useState(null); const [contactSnapCandidate, setContactSnapCandidate] = useState(null); const [nodeDragActive, setNodeDragActive] = useState(false); @@ -3891,6 +4085,8 @@ function FlowWorkbench() { const futureRef = useRef([]); const clipboardRef = useRef(null); const pendingPasteRef = useRef(null); + const portConnectionDraftRef = useRef(null); + const modelingViewportRef = useRef({ x: 0, y: 0, zoom: 1 }); const lastFlowPointerRef = useRef<{ x: number; y: number } | null>(null); const pendingPasteMiddlePointerRef = useRef( null, @@ -3914,7 +4110,6 @@ function FlowWorkbench() { } | null>(null); const consoleEntryIdRef = useRef(0); const activeSimulationIdRef = useRef(null); - const previousActiveViewRef = useRef(activeView); const paletteIconPreviewTimerRef = useRef(null); const suppressCancelledConnectionRef = useRef(false); const contactDragSessionRef = useRef(null); @@ -3929,6 +4124,7 @@ function FlowWorkbench() { projectNameRef.current = projectName; simulationConfigRef.current = simulationConfig; pendingPasteRef.current = pendingPaste; + portConnectionDraftRef.current = portConnectionDraft; const clearContactSnapCandidate = useCallback(() => { setContactSnapCandidate(null); @@ -3942,6 +4138,8 @@ function FlowWorkbench() { flowState.cancelConnection(); // React Flow 12 的拖拽取消不会同时清理点击连线起点。 reactFlowStore.setState({ connectionClickStartHandle: null }); + portConnectionDraftRef.current = null; + setPortConnectionDraft(null); setActivePortConnection(null); setNodeDragActive(false); contactDragSessionRef.current = null; @@ -4746,7 +4944,7 @@ function FlowWorkbench() { new Set( contactSnapCandidate?.matches.map((match) => portConnectionKey( - contactSnapCandidate.draggedNodeId, + match.draggedNodeId, match.draggedPortName, ), ) ?? [], @@ -4920,6 +5118,37 @@ function FlowWorkbench() { renderedNodeCacheRef.current = renderedNodeComputation.cache; }, [renderedNodeComputation.cache]); const renderedNodes = renderedNodeComputation.nodes; + + function handleEdgeRouteEditStart() { + recordHistory(); + } + + function handleEdgeRoutePointsChange( + edgeId: string, + routePoints: EdgeRoutePoint[], + ) { + setEdges((currentEdges) => { + let changed = false; + const nextEdges = currentEdges.map((edge) => { + if (edge.id !== edgeId) { + return edge; + } + changed = true; + return { + ...edge, + data: { + ...edge.data, + isContactEdge: false, + routePoints: routePoints.map((point) => ({ ...point })), + }, + }; + }); + const resolvedEdges = changed ? nextEdges : currentEdges; + edgesRef.current = resolvedEdges; + return resolvedEdges; + }); + } + const edgeIndexesByNodeId = useMemo(() => { const indexes = new Map(); edges.forEach((edge, edgeIndex) => { @@ -5067,7 +5296,10 @@ function FlowWorkbench() { className: className || undefined, data: { ...edge.data, + editable: !contact, isContactEdge: contact, + onRouteEditStart: handleEdgeRouteEditStart, + onRoutePointsChange: handleEdgeRoutePointsChange, }, focusable: contact ? false : edge.focusable, selectable: contact ? false : edge.selectable, @@ -5124,10 +5356,75 @@ function FlowWorkbench() { renderedEdgesSelectedIdRef.current = renderedEdgeComputation.selectedEdgeId; }, [renderedEdgeComputation]); const renderedEdges = renderedEdgeComputation.edges; + const crossingJumpsByEdgeId = useMemo(() => { + const nodeById = new Map(nodes.map((node) => [node.id, node] as const)); + return detectEdgeCrossingJumps( + renderedEdges.flatMap((edge) => { + if (edge.data?.isContactEdge === true) { + return []; + } + const sourceNode = nodeById.get(edge.source); + const targetNode = nodeById.get(edge.target); + const source = sourceNode + ? nodePortFlowPlacement(sourceNode, edge.sourceHandle ?? null) + : undefined; + const target = targetNode + ? nodePortFlowPlacement(targetNode, edge.targetHandle ?? null) + : undefined; + if (!source || !target) { + return []; + } + return [ + { + id: edge.id, + nodeIds: [edge.source, edge.target] as const, + points: orthogonalEdgePoints({ + routePoints: routePointsForEdge(edge), + sourcePosition: source.position, + sourceX: source.flowX, + sourceY: source.flowY, + targetPosition: target.position, + targetX: target.flowX, + targetY: target.flowY, + }), + }, + ]; + }), + ); + }, [nodes, renderedEdges]); + const routedRenderedEdges = useMemo( + () => + renderedEdges.map((edge) => { + const crossingJumps = crossingJumpsByEdgeId.get(edge.id); + return crossingJumps?.length + ? { + ...edge, + data: { + ...edge.data, + crossingJumps, + }, + } + : edge; + }), + [crossingJumpsByEdgeId, renderedEdges], + ); const flowNodes = useMemo(() => { if (!pendingPaste) { return renderedNodes; } + const pendingConnectedPortKeys = new Set(); + pendingPaste.edges.forEach((edge) => { + if (edge.sourceHandle) { + pendingConnectedPortKeys.add( + portConnectionKey(edge.source, edge.sourceHandle), + ); + } + if (edge.targetHandle) { + pendingConnectedPortKeys.add( + portConnectionKey(edge.target, edge.targetHandle), + ); + } + }); const previewNodes = pendingPaste.nodes.map((node) => ({ ...node, className: [ @@ -5149,7 +5446,17 @@ function FlowWorkbench() { ...node.data, hideConnectedPortIndexLabels: true, portConnectionStates: Object.fromEntries( - node.data.ports.map((port) => [port.name, "connected"]), + node.data.ports.map((port) => { + const key = portConnectionKey(node.id, port.name); + return [ + port.name, + pendingConnectedPortKeys.has(key) + ? "connected" + : contactSnapOriginPortKeys.has(key) + ? "origin" + : "idle", + ]; + }), ) as Record, }, })); @@ -5157,10 +5464,10 @@ function FlowWorkbench() { ...renderedNodes.map((node) => ({ ...node, selected: false })), ...previewNodes, ]; - }, [pendingPaste, renderedNodes]); + }, [contactSnapOriginPortKeys, pendingPaste, renderedNodes]); const flowEdges = useMemo(() => { if (!pendingPaste) { - return renderedEdges; + return routedRenderedEdges; } const previewEdges = pendingPaste.edges.map((edge) => { const contact = isContactEdgeGeometry(pendingPaste.nodes, edge); @@ -5181,7 +5488,7 @@ function FlowWorkbench() { }; }); return [ - ...renderedEdges.map((edge) => ({ + ...routedRenderedEdges.map((edge) => ({ ...edge, className: edge.className ?.split(/\s+/) @@ -5191,7 +5498,7 @@ function FlowWorkbench() { })), ...previewEdges, ]; - }, [pendingPaste, renderedEdges]); + }, [pendingPaste, routedRenderedEdges]); const updateHistoryAvailability = useCallback(() => { setHistoryAvailability({ @@ -5438,73 +5745,86 @@ function FlowWorkbench() { setNodeDragActive(true); recordHistory(); clearContactSnapCandidate(); - const effectiveNodes = nodesRef.current.map((node) => - node.id === draggedNode.id - ? { ...node, position: { ...draggedNode.position } } - : node, + const movingNodes = + draggedNodes.length > 0 ? draggedNodes : [draggedNode]; + const draggedNodeIds = new Set(movingNodes.map((node) => node.id)); + const draggedNodeById = new Map( + movingNodes.map((node) => [node.id, node] as const), ); - const multipleNodes = draggedNodes.length > 1; + const effectiveNodes = nodesRef.current.map((node) => { + const moved = draggedNodeById.get(node.id); + return moved + ? { ...node, position: { ...moved.position } } + : node; + }); const contactEdgeIds = new Set( - multipleNodes - ? [] - : (edgeIndexesByNodeId.get(draggedNode.id) ?? []).flatMap( - (edgeIndex) => { - const edge = edgesRef.current[edgeIndex]; - if (!edge) { - return []; - } - const cachedEntry = renderedEdgeCacheRef.current.get(edge.id); - const cachedContact = - cachedEntry?.sourceEdge === edge - ? cachedEntry.renderedEdge.data?.isContactEdge - : undefined; - return (typeof cachedContact === "boolean" - ? cachedContact - : isContactEdgeGeometry(effectiveNodes, edge)) - ? [edge.id] - : []; - }, - ), + edgesRef.current.flatMap((edge) => { + const sourceMoves = draggedNodeIds.has(edge.source); + const targetMoves = draggedNodeIds.has(edge.target); + // Only a contact crossing the block boundary is disconnected. + // Internal contact topology moves with the block and remains hidden. + return sourceMoves !== targetMoves && + isContactEdgeGeometry(effectiveNodes, edge) + ? [edge.id] + : []; + }), ); contactDragSessionRef.current = { contactEdgeIds, disconnected: false, + draggedNodeIds, effectiveEdges: edgesRef.current, lastCandidate: null, lastEvaluatedPosition: null, lastSnapRadius: 0, - multipleNodes, + multipleNodes: draggedNodeIds.size > 1, nodeId: draggedNode.id, - snapSearchContext: multipleNodes - ? null - : buildContactSnapSearchContext( - draggedNode.id, - effectiveNodes, - edgesRef.current, - ), + snapSearchContext: buildContactSnapSearchContext( + draggedNodeIds, + effectiveNodes, + edgesRef.current, + ), startPosition: { ...draggedNode.position }, }; }, - [clearContactSnapCandidate, edgeIndexesByNodeId, recordHistory], + [clearContactSnapCandidate, recordHistory], ); const handleNodeDrag = useCallback>( - (_event, draggedNode) => { + (_event, draggedNode, draggedNodes) => { const session = contactDragSessionRef.current; if (!session || session.nodeId !== draggedNode.id) { return; } - + const movingNodes = + draggedNodes.length > 0 ? draggedNodes : [draggedNode]; + const draggedNodeById = new Map( + movingNodes.map((node) => [node.id, node] as const), + ); + const effectiveNodes = nodesRef.current.map((node) => { + const moved = draggedNodeById.get(node.id); + return moved + ? { ...node, position: { ...moved.position } } + : node; + }); + const effectiveDraggedNodes = effectiveNodes.filter((node) => + session.draggedNodeIds.has(node.id), + ); + const effectivePrimary = + effectiveDraggedNodes.find((node) => node.id === session.nodeId) ?? + draggedNode; const screenDistance = Math.hypot( - draggedNode.position.x - session.startPosition.x, - draggedNode.position.y - session.startPosition.y, + effectivePrimary.position.x - session.startPosition.x, + effectivePrimary.position.y - session.startPosition.y, ) * reactFlow.getZoom(); + const disconnectDistance = session.multipleNodes + ? 0.5 + : CONTACT_DISCONNECT_DISTANCE_PX; if ( - !session.multipleNodes && !session.disconnected && session.contactEdgeIds.size > 0 && - screenDistance >= CONTACT_DISCONNECT_DISTANCE_PX + screenDistance >= disconnectDistance ) { const remainingEdges = session.effectiveEdges.filter( (edge) => !session.contactEdgeIds.has(edge.id), @@ -5512,8 +5832,8 @@ function FlowWorkbench() { session.disconnected = true; session.effectiveEdges = remainingEdges; session.snapSearchContext = buildContactSnapSearchContext( - draggedNode.id, - nodesRef.current, + session.draggedNodeIds, + effectiveNodes, remainingEdges, ); edgesRef.current = remainingEdges; @@ -5523,104 +5843,130 @@ function FlowWorkbench() { ); } - if (session.multipleNodes) { - clearContactSnapCandidate(); - return; - } - const effectiveDraggedNode = { - ...(nodesRef.current.find((node) => node.id === draggedNode.id) ?? - draggedNode), - position: { ...draggedNode.position }, - }; const snapRadius = contactSnapRadiusForZoom(reactFlow.getZoom()); const candidate = findContactSnapCandidate( - effectiveDraggedNode, - nodesRef.current, + effectiveDraggedNodes, + effectiveNodes, session.effectiveEdges, snapRadius, - session.snapSearchContext ?? undefined, + session.snapSearchContext, ); session.lastCandidate = candidate; - session.lastEvaluatedPosition = { ...effectiveDraggedNode.position }; + session.lastEvaluatedPosition = { ...effectivePrimary.position }; session.lastSnapRadius = snapRadius; setContactSnapCandidate((current) => sameContactSnapCandidate(current, candidate) ? current : candidate, ); + const previewBlockDelta = { + x: effectivePrimary.position.x - session.startPosition.x, + y: effectivePrimary.position.y - session.startPosition.y, + }; + const previewEdges = translateInternalEdgeRoutes( + session.effectiveEdges, + session.draggedNodeIds, + previewBlockDelta.x, + previewBlockDelta.y, + ); + if (previewEdges !== session.effectiveEdges) { + edgesRef.current = previewEdges; + setEdges(previewEdges); + } }, - [clearContactSnapCandidate, reactFlow, setEdges], + [reactFlow, setEdges], ); const handleNodeDragStop = useCallback>( - (_event, draggedNode) => { + (_event, draggedNode, draggedNodes) => { setNodeDragActive(false); const session = contactDragSessionRef.current; if (!session || session.nodeId !== draggedNode.id) { clearContactSnapCandidate(); return; } - const effectiveDraggedNode = { - ...(nodesRef.current.find((node) => node.id === draggedNode.id) ?? - draggedNode), - position: { ...draggedNode.position }, - }; - const effectiveNodes = nodesRef.current.map((node) => - node.id === draggedNode.id ? effectiveDraggedNode : node, + const movingNodes = + draggedNodes.length > 0 ? draggedNodes : [draggedNode]; + const draggedNodeById = new Map( + movingNodes.map((node) => [node.id, node] as const), ); + const effectiveNodes = nodesRef.current.map((node) => { + const moved = draggedNodeById.get(node.id); + return moved + ? { ...node, position: { ...moved.position } } + : node; + }); + const effectiveDraggedNodes = effectiveNodes.filter((node) => + session.draggedNodeIds.has(node.id), + ); + const effectivePrimary = + effectiveDraggedNodes.find((node) => node.id === session.nodeId) ?? + draggedNode; const snapRadius = contactSnapRadiusForZoom(reactFlow.getZoom()); const canReuseLastCandidate = - session.lastEvaluatedPosition?.x === effectiveDraggedNode.position.x && - session.lastEvaluatedPosition.y === effectiveDraggedNode.position.y && + session.lastEvaluatedPosition?.x === effectivePrimary.position.x && + session.lastEvaluatedPosition.y === effectivePrimary.position.y && session.lastSnapRadius === snapRadius; - const candidate = session.multipleNodes - ? null - : canReuseLastCandidate - ? session.lastCandidate - : findContactSnapCandidate( - effectiveDraggedNode, - effectiveNodes, - session.effectiveEdges, - snapRadius, - session.snapSearchContext ?? undefined, - ); + const candidate = canReuseLastCandidate + ? session.lastCandidate + : findContactSnapCandidate( + effectiveDraggedNodes, + effectiveNodes, + session.effectiveEdges, + snapRadius, + session.snapSearchContext, + ); contactDragSessionRef.current = null; clearContactSnapCandidate(); - if (!candidate) { - return; - } - - const snappedNodes = effectiveNodes.map((node) => - node.id === candidate.draggedNodeId - ? { - ...node, - position: { ...candidate.snappedPosition }, - } - : node, + const finalNodes = candidate + ? effectiveNodes.map((node) => + session.draggedNodeIds.has(node.id) + ? { + ...node, + position: { + x: node.position.x + candidate.positionDelta.x, + y: node.position.y + candidate.positionDelta.y, + }, + } + : node, + ) + : effectiveNodes; + const finalPrimary = + finalNodes.find((node) => node.id === session.nodeId) ?? + effectivePrimary; + const blockDelta = { + x: finalPrimary.position.x - session.startPosition.x, + y: finalPrimary.position.y - session.startPosition.y, + }; + let connectedEdges = session.effectiveEdges.filter( + (edge) => + !session.contactEdgeIds.has(edge.id) || + isContactEdgeGeometry(finalNodes, edge), ); - const createdEdges: SimulationEdge[] = []; + connectedEdges = translateInternalEdgeRoutes( + connectedEdges, + session.draggedNodeIds, + blockDelta.x, + blockDelta.y, + ); + const timestamp = Date.now(); - let connectedEdges = session.effectiveEdges; - candidate.matches.forEach((match, index) => { + candidate?.matches.forEach((match, index) => { const contactEdge: SimulationEdge = { ...match.connection, id: `edge-contact-${match.connection.source}-${match.connection.sourceHandle}-${match.connection.target}-${match.connection.targetHandle}-${timestamp}-${index}`, type: CONTACT_AWARE_EDGE_TYPE, }; if ( - !canConnectPorts(snappedNodes, connectedEdges, contactEdge) || - !isContactEdgeGeometry(snappedNodes, contactEdge) + canConnectPorts(finalNodes, connectedEdges, contactEdge) && + isContactEdgeGeometry(finalNodes, contactEdge) ) { - return; + connectedEdges = addEdge(contactEdge, connectedEdges); } - connectedEdges = addEdge(contactEdge, connectedEdges); - createdEdges.push(contactEdge); }); - if (createdEdges.length === 0) { - return; - } - nodesRef.current = snappedNodes; + + nodesRef.current = finalNodes; edgesRef.current = connectedEdges; - setNodes(snappedNodes); + setNodes(finalNodes); setEdges(connectedEdges); setSelectedEdgeId(null); }, @@ -5646,6 +5992,20 @@ function FlowWorkbench() { return; } suppressCancelledConnectionRef.current = false; + if (mode === "click") { + const placement = nodePortFlowPlacement( + nodesRef.current.find((node) => node.id === nodeId) as SimulationNode, + portName, + ); + const draft: PortConnectionDraft = { + pointer: placement + ? { x: placement.flowX, y: placement.flowY } + : null, + waypoints: [], + }; + portConnectionDraftRef.current = draft; + setPortConnectionDraft(draft); + } setActivePortConnection((current) => { if (mode === "drag" && current?.mode === "click") { return current; @@ -5665,24 +6025,73 @@ function FlowWorkbench() { const endClickPortConnection = useCallback(() => { suppressCancelledConnectionRef.current = false; + portConnectionDraftRef.current = null; + setPortConnectionDraft(null); setActivePortConnection(null); }, []); const onConnect = useCallback( (connection: Connection) => { - if (suppressCancelledConnectionRef.current) { + const clearConnectionUi = () => { + portConnectionDraftRef.current = null; + setPortConnectionDraft(null); setActivePortConnection(null); + }; + if (suppressCancelledConnectionRef.current) { + clearConnectionUi(); return; } if (!canConnectPorts(nodesRef.current, edgesRef.current, connection)) { - setActivePortConnection(null); + clearConnectionUi(); return; } + + let routePoints: EdgeRoutePoint[] | undefined; + const draft = portConnectionDraftRef.current; + if (activePortConnection?.mode === "click" && draft) { + const sourceNode = nodesRef.current.find( + (node) => node.id === connection.source, + ); + const targetNode = nodesRef.current.find( + (node) => node.id === connection.target, + ); + const sourcePlacement = sourceNode + ? nodePortFlowPlacement(sourceNode, connection.sourceHandle ?? null) + : undefined; + const targetPlacement = targetNode + ? nodePortFlowPlacement(targetNode, connection.targetHandle ?? null) + : undefined; + const originIsSource = + activePortConnection.nodeId === connection.source && + activePortConnection.portName === connection.sourceHandle; + const originIsTarget = + activePortConnection.nodeId === connection.target && + activePortConnection.portName === connection.targetHandle; + if (sourcePlacement && targetPlacement && (originIsSource || originIsTarget)) { + const orderedWaypoints = originIsSource + ? draft.waypoints + : [...draft.waypoints].reverse(); + routePoints = buildOrthogonalRoutePoints( + { x: sourcePlacement.flowX, y: sourcePlacement.flowY }, + { x: targetPlacement.flowX, y: targetPlacement.flowY }, + sourcePlacement.position, + targetPlacement.position, + orderedWaypoints, + ); + } + } + recordHistory(); setEdges((currentEdges) => { const nextEdges = addEdge( { ...connection, + data: { + isContactEdge: false, + ...(routePoints && routePoints.length > 0 + ? { routePoints } + : {}), + }, id: `edge-${connection.source}-${connection.sourceHandle}-${connection.target}-${connection.targetHandle}-${Date.now()}`, type: CONTACT_AWARE_EDGE_TYPE, }, @@ -5692,9 +6101,9 @@ function FlowWorkbench() { return nextEdges; }); suppressCancelledConnectionRef.current = false; - setActivePortConnection(null); + clearConnectionUi(); }, - [recordHistory, setEdges], + [activePortConnection, recordHistory, setEdges], ); const isValidConnection = useCallback( @@ -6085,32 +6494,67 @@ function FlowWorkbench() { const positionPendingPasteAtScreenPoint = useCallback( (screenPoint: { x: number; y: number }) => { + const current = pendingPasteRef.current; + if (!current) { + return; + } const nextAnchor = flowPointForScreenPoint(screenPoint); - setPendingPaste((current) => { - if (!current) { - return current; - } - const deltaX = nextAnchor.x - current.anchor.x; - const deltaY = nextAnchor.y - current.anchor.y; - if (deltaX === 0 && deltaY === 0) { - return current; - } - const next = { - ...current, - anchor: nextAnchor, - nodes: current.nodes.map((node) => ({ - ...node, - position: { - x: node.position.x + deltaX, - y: node.position.y + deltaY, - }, - })), - }; - pendingPasteRef.current = next; - return next; - }); + // Positions currently include the previous snap offset. Remove it while + // following the raw pointer, then derive a fresh block-wide snap. + const rawDelta = { + x: nextAnchor.x - current.anchor.x - current.snapOffset.x, + y: nextAnchor.y - current.anchor.y - current.snapOffset.y, + }; + const pendingNodeIds = new Set(current.nodes.map((node) => node.id)); + const rawNodes = current.nodes.map((node) => ({ + ...node, + position: { + x: node.position.x + rawDelta.x, + y: node.position.y + rawDelta.y, + }, + })); + const rawEdges = translateInternalEdgeRoutes( + current.edges, + pendingNodeIds, + rawDelta.x, + rawDelta.y, + ); + const allNodes = [...nodesRef.current, ...rawNodes]; + const allEdges = [...edgesRef.current, ...rawEdges]; + const candidate = findContactSnapCandidate( + rawNodes, + allNodes, + allEdges, + contactSnapRadiusForZoom(reactFlow.getZoom()), + ); + const snapOffset = candidate?.positionDelta ?? { x: 0, y: 0 }; + const nextNodes = rawNodes.map((node) => ({ + ...node, + position: { + x: node.position.x + snapOffset.x, + y: node.position.y + snapOffset.y, + }, + })); + const nextEdges = translateInternalEdgeRoutes( + rawEdges, + pendingNodeIds, + snapOffset.x, + snapOffset.y, + ); + const next = { + ...current, + anchor: nextAnchor, + edges: nextEdges, + nodes: nextNodes, + snapOffset, + }; + pendingPasteRef.current = next; + setPendingPaste(next); + setContactSnapCandidate((existing) => + sameContactSnapCandidate(existing, candidate) ? existing : candidate, + ); }, - [flowPointForScreenPoint, setPendingPaste], + [flowPointForScreenPoint, reactFlow, setPendingPaste], ); const syncPendingPasteToPointer = useCallback(() => { @@ -6127,12 +6571,22 @@ function FlowWorkbench() { } const next = { ...current, + // A rotated block gets a fresh automatic internal route. Absolute + // manual bends from the previous orientation must not remain behind. + edges: current.edges.map((edge) => ({ + ...edge, + data: { + ...edge.data, + routePoints: undefined, + }, + })), nodes: current.nodes.map(rotateNodeClockwise), }; pendingPasteRef.current = next; return next; }); - }, [setPendingPaste]); + clearContactSnapCandidate(); + }, [clearContactSnapCandidate, setPendingPaste]); const cancelPendingPaste = useCallback(() => { if (!pendingPasteRef.current) { @@ -6141,8 +6595,9 @@ function FlowWorkbench() { pendingPasteRef.current = null; pendingPasteMiddlePointerRef.current = null; setPendingPaste(null); + clearContactSnapCandidate(); appendConsoleEntry("info", "已取消粘贴放置"); - }, [appendConsoleEntry]); + }, [appendConsoleEntry, clearContactSnapCandidate]); const commitPendingPaste = useCallback(() => { const pending = pendingPasteRef.current; @@ -6167,40 +6622,92 @@ function FlowWorkbench() { pendingPasteRef.current = null; pendingPasteMiddlePointerRef.current = null; setPendingPaste(null); + clearContactSnapCandidate(); appendConsoleEntry( "error", `粘贴已取消:检测到重复${conflictingNode ? `节点 ID ${conflictingNode.id}` : `连接 ID ${conflictingEdge?.id}`}`, ); return; } - recordHistory(); - const pastedNodes = pending.nodes.map((node) => ({ ...node, selected: true })); - const pastedEdges = pending.edges.map((edge) => ({ ...edge, selected: false })); + + const pendingNodeIds = new Set(pending.nodes.map((node) => node.id)); + const combinedNodes = [...nodesRef.current, ...pending.nodes]; + const combinedEdges = [...edgesRef.current, ...pending.edges]; + const candidate = findContactSnapCandidate( + pending.nodes, + combinedNodes, + combinedEdges, + contactSnapRadiusForZoom(reactFlow.getZoom()), + ); + const placementDelta = candidate?.positionDelta ?? { x: 0, y: 0 }; + const placedNodes = pending.nodes.map((node) => ({ + ...node, + position: { + x: node.position.x + placementDelta.x, + y: node.position.y + placementDelta.y, + }, + selected: true, + })); + const placedInternalEdges = translateInternalEdgeRoutes( + pending.edges, + pendingNodeIds, + placementDelta.x, + placementDelta.y, + ).map((edge) => ({ ...edge, selected: false })); const nextNodes = [ ...nodesRef.current.map((node) => ({ ...node, selected: false })), - ...pastedNodes, + ...placedNodes, ]; - const nextEdges = [ + let nextEdges: SimulationEdge[] = [ ...edgesRef.current.map((edge) => ({ ...edge, selected: false })), - ...pastedEdges, + ...placedInternalEdges, ]; + const timestamp = Date.now(); + let attachedPortCount = 0; + candidate?.matches.forEach((match, index) => { + const contactEdge: SimulationEdge = { + ...match.connection, + id: `edge-contact-${match.connection.source}-${match.connection.sourceHandle}-${match.connection.target}-${match.connection.targetHandle}-${timestamp}-paste-${index}`, + type: CONTACT_AWARE_EDGE_TYPE, + }; + if ( + canConnectPorts(nextNodes, nextEdges, contactEdge) && + isContactEdgeGeometry(nextNodes, contactEdge) + ) { + nextEdges = addEdge(contactEdge, nextEdges); + attachedPortCount += 1; + } + }); + + recordHistory(); nodesRef.current = nextNodes; edgesRef.current = nextEdges; setNodes(nextNodes); setEdges(nextEdges); - setSelectedNodeId(pastedNodes.length === 1 ? pastedNodes[0].id : null); + setSelectedNodeId(placedNodes.length === 1 ? placedNodes[0].id : null); setSelectedEdgeId(null); pendingPasteRef.current = null; pendingPasteMiddlePointerRef.current = null; setPendingPaste(null); - appendConsoleEntry("info", `已粘贴 ${pastedNodes.length} 个组件`); + clearContactSnapCandidate(); + appendConsoleEntry( + "info", + `已粘贴 ${placedNodes.length} 个组件${attachedPortCount > 0 ? `,并吸附连接 ${attachedPortCount} 个端口` : ""}`, + ); if (pending.repairedReferenceCount > 0) { appendConsoleEntry( "warning", `${pending.repairedReferenceCount} 个粘贴的介质物性引用没有对应介质定义,已切回 0 — 空气(理想气体,内置默认)`, ); } - }, [appendConsoleEntry, recordHistory, setEdges, setNodes]); + }, [ + appendConsoleEntry, + clearContactSnapCandidate, + reactFlow, + recordHistory, + setEdges, + setNodes, + ]); const pasteSelection = useCallback(() => { const clipboard = clipboardRef.current; @@ -6289,6 +6796,10 @@ function FlowWorkbench() { x: (copiedBounds.minX + copiedBounds.maxX) / 2, y: (copiedBounds.minY + copiedBounds.maxY) / 2, }; + const initialPasteDelta = { + x: anchor.x - copiedCenter.x, + y: anchor.y - copiedCenter.y, + }; const idMap = new Map(); const reservedNodeIds = new Set(nodesRef.current.map((node) => node.id)); const usedNames = new Set(nodesRef.current.map((node) => node.data.label.trim())); @@ -6329,8 +6840,8 @@ function FlowWorkbench() { ...cloneValue(node), id, position: { - x: anchor.x + node.position.x - copiedCenter.x, - y: anchor.y + node.position.y - copiedCenter.y, + x: node.position.x + initialPasteDelta.x, + y: node.position.y + initialPasteDelta.y, }, selected: false, data: { @@ -6344,13 +6855,17 @@ function FlowWorkbench() { const pastedEdges = clipboard.edges.map((edge, index) => { const id = takeNextCopiedEdgeId(index, reservedEdgeIds); reservedEdgeIds.add(id); - return { - ...cloneValue(edge), - id, - source: idMap.get(edge.source) as string, - target: idMap.get(edge.target) as string, - selected: false, - }; + return translateEdgeRoute( + { + ...cloneValue(edge), + id, + source: idMap.get(edge.source) as string, + target: idMap.get(edge.target) as string, + selected: false, + }, + initialPasteDelta.x, + initialPasteDelta.y, + ); }); const nextPendingPaste = { @@ -6358,6 +6873,7 @@ function FlowWorkbench() { edges: pastedEdges, nodes: pastedNodes, repairedReferenceCount: repairedPastedReferenceCount, + snapOffset: { x: 0, y: 0 }, } satisfies PendingPaste; cancelPortConnection(); setSelectedNodeId(null); @@ -6409,8 +6925,23 @@ function FlowWorkbench() { if (pendingPasteRef.current) { positionPendingPasteAtScreenPoint(lastFlowPointerRef.current); } + const connectionDraft = portConnectionDraftRef.current; + if (connectionDraft) { + const pointer = reactFlow.screenToFlowPosition( + { x: event.clientX, y: event.clientY }, + { snapToGrid: false }, + ); + if ( + connectionDraft.pointer?.x !== pointer.x || + connectionDraft.pointer?.y !== pointer.y + ) { + const nextDraft = { ...connectionDraft, pointer }; + portConnectionDraftRef.current = nextDraft; + setPortConnectionDraft(nextDraft); + } + } }, - [positionPendingPasteAtScreenPoint], + [positionPendingPasteAtScreenPoint, reactFlow], ); const handleFlowPointerUpCapture = useCallback( @@ -7106,16 +7637,10 @@ function FlowWorkbench() { }, []); useEffect(() => { - const previousView = previousActiveViewRef.current; - previousActiveViewRef.current = activeView; if (activeView !== "modeling") { cancelPortConnection(); - return; } - if (activeView === "modeling" && previousView !== "modeling") { - requestModelingFitView(); - } - }, [activeView, cancelPortConnection, requestModelingFitView]); + }, [activeView, cancelPortConnection]); useEffect(() => { if (activeView === "results" && hasUnreadResults) { @@ -7264,12 +7789,35 @@ function FlowWorkbench() { cancelPortConnection(); }, [cancelPortConnection]); const handleModelingPaneClick = useCallback( - (_event: ReactMouseEvent) => { + (event: ReactMouseEvent) => { setSelectedNodeId(null); setSelectedEdgeId(null); + const connectionDraft = portConnectionDraftRef.current; + if (activePortConnection?.mode === "click" && connectionDraft) { + const waypoint = reactFlow.screenToFlowPosition( + { x: event.clientX, y: event.clientY }, + { snapToGrid: false }, + ); + const previousWaypoint = connectionDraft.waypoints.at(-1); + if ( + !previousWaypoint || + Math.hypot( + waypoint.x - previousWaypoint.x, + waypoint.y - previousWaypoint.y, + ) > 1 + ) { + const nextDraft = { + pointer: waypoint, + waypoints: [...connectionDraft.waypoints, waypoint], + }; + portConnectionDraftRef.current = nextDraft; + setPortConnectionDraft(nextDraft); + } + return; + } cancelPortConnection(); }, - [cancelPortConnection], + [activePortConnection, cancelPortConnection, reactFlow], ); const handleModelingSelectionChange = useCallback< OnSelectionChangeFunc @@ -7284,14 +7832,17 @@ function FlowWorkbench() { if (activeView === "results") { return resultSnapshot ? ( - + + + ) : (
{ if (pendingPasteRef.current) { @@ -8131,6 +8685,7 @@ function FlowWorkbench() { connectionRadius={24} connectionDragThreshold={0} defaultEdgeOptions={MODELING_DEFAULT_EDGE_OPTIONS} + defaultViewport={modelingViewportRef.current} edges={flowEdges} edgeTypes={contactAwareEdgeTypes} elementsSelectable={!pendingPaste} @@ -8155,7 +8710,10 @@ function FlowWorkbench() { onNodeDragStop={handleNodeDragStop} onNodesChange={handleNodesChange} onNodesDelete={handleModelingNodesDelete} - onMove={syncPendingPasteToPointer} + onMove={(_event, viewport) => { + modelingViewportRef.current = { ...viewport }; + syncPendingPasteToPointer(); + }} onPaneClick={handleModelingPaneClick} onSelectionChange={handleModelingSelectionChange} onSelectionEnd={finishTightSelection} @@ -8180,12 +8738,26 @@ function FlowWorkbench() { expectedNodeCount={renderedNodes.length} requestKey={modelingFitRequest} /> + node.id)} + requestKey={modelingFitRequest} + /> + {pendingPaste ? ( 待放置:左键放置 · 中键旋转 · Esc 取消 ) : null} + {!pendingPaste && activePortConnection?.mode === "click" ? ( + + 连接模式:单击空白添加折点 · 单击兼容端口完成 · Esc 取消 + + ) : null} {recoveryProject ? ( 检测到上次自动保存的工程 @@ -8394,6 +8966,7 @@ function buildProjectPayload( ) { throw new Error(`连接 ${edge.id} 缺少明确的接口标识`); } + const routePoints = routePointsForEdge(edge); return { id: edge.id, source: edge.source, @@ -8404,6 +8977,13 @@ function buildProjectPayload( isContactEdge: contactGeometry ? isContactEdgeGeometryInIndex(contactGeometry, edge) : Boolean(edge.data?.isContactEdge), + ...(routePoints.length > 0 + ? { + routePoints: routePoints.map((point) => ({ + ...point, + })), + } + : {}), }, }; }), @@ -8850,10 +9430,16 @@ function loadProjectEdges(edges: ProjectEdgePayload[], nodes: SimulationNode[]) ); const occupiedPortKeys = new Set(); edges.forEach((edge) => { + const routePoints = Array.isArray(edge.data.routePoints) + ? edge.data.routePoints.map((point) => ({ ...point })) + : undefined; const candidate: SimulationEdge = { ...edge, - data: { ...edge.data }, - type: "step", + data: { + ...edge.data, + ...(routePoints && routePoints.length > 0 ? { routePoints } : {}), + }, + type: CONTACT_AWARE_EDGE_TYPE, }; const reason = loadedEdgeContractIssue( nodeById, @@ -9851,7 +10437,7 @@ function isProjectNodePayloadValue(value: unknown): value is ProjectNodePayload } function isProjectEdgePayloadValue(value: unknown): value is ProjectEdgePayload { - return ( + if (!( isProjectRecord(value) && typeof value.id === "string" && value.id.length > 0 && @@ -9865,6 +10451,21 @@ function isProjectEdgePayloadValue(value: unknown): value is ProjectEdgePayload value.targetHandle.length > 0 && isProjectRecord(value.data) && typeof value.data.isContactEdge === "boolean" + )) { + return false; + } + const routePoints = value.data.routePoints; + return ( + routePoints === undefined || + (Array.isArray(routePoints) && + routePoints.every( + (point) => + isProjectRecord(point) && + typeof point.x === "number" && + Number.isFinite(point.x) && + typeof point.y === "number" && + Number.isFinite(point.y), + )) ); } diff --git a/frontend/src/ContactAwareEdge.tsx b/frontend/src/ContactAwareEdge.tsx index 341b328..7ffe95f 100644 --- a/frontend/src/ContactAwareEdge.tsx +++ b/frontend/src/ContactAwareEdge.tsx @@ -1,18 +1,59 @@ +import { + useEffect, + useRef, + type PointerEvent as ReactPointerEvent, +} from "react"; import { BaseEdge, - getSmoothStepPath, + useReactFlow, type EdgeProps, type EdgeTypes, } from "@xyflow/react"; +import { + createOrthogonalSegmentDetour, + edgeSegmentAxis, + moveOrthogonalCorner, + moveOrthogonalSegment, + orthogonalEdgePoints, + orthogonalPolylinePath, + type EdgeRouteData, + type EdgeRoutePoint, +} from "./edgeRouting"; export const CONTACT_AWARE_EDGE_TYPE = "contact-aware"; -export function ContactAwareEdge({ +type RouteDrag = { + basePoints: EdgeRoutePoint[]; + cleanup: () => void; + kind: "corner" | "detour" | "segment"; + pointerId: number; + routeIndex: number; + startPointer: EdgeRoutePoint; +}; + +function longestSegmentIndex(points: EdgeRoutePoint[]) { + let bestIndex = 0; + let bestLength = -1; + for (let index = 0; index < points.length - 1; index += 1) { + const length = Math.hypot( + points[index + 1].x - points[index].x, + points[index + 1].y - points[index].y, + ); + if (length > bestLength) { + bestIndex = index; + bestLength = length; + } + } + return bestIndex; +} + +function RoutedEdge({ data, id, interactionWidth, markerEnd, markerStart, + selected, sourcePosition, sourceX, sourceY, @@ -21,32 +62,198 @@ export function ContactAwareEdge({ targetX, targetY, }: EdgeProps) { - const isContactEdge = data?.isContactEdge === true; - if (isContactEdge) { - return null; - } - - const [edgePath] = getSmoothStepPath({ + const reactFlow = useReactFlow(); + const dragRef = useRef(null); + const edgeData = (data ?? {}) as EdgeRouteData; + useEffect( + () => () => { + dragRef.current?.cleanup(); + dragRef.current = null; + }, + [], + ); + const points = orthogonalEdgePoints({ + routePoints: edgeData.routePoints, sourcePosition, sourceX, sourceY, targetPosition, targetX, targetY, - borderRadius: 0, }); + const edgePath = orthogonalPolylinePath(points, edgeData.crossingJumps); + + const beginRouteDrag = ( + event: ReactPointerEvent, + basePoints: EdgeRoutePoint[], + kind: RouteDrag["kind"], + routeIndex: number, + ) => { + event.preventDefault(); + event.stopPropagation(); + dragRef.current?.cleanup(); + + const drag: RouteDrag = { + basePoints: basePoints.map((point) => ({ ...point })), + cleanup: () => undefined, + kind, + pointerId: event.pointerId, + routeIndex, + startPointer: reactFlow.screenToFlowPosition( + { x: event.clientX, y: event.clientY }, + { snapToGrid: false }, + ), + }; + const moveRoute = (pointerEvent: PointerEvent) => { + if ( + dragRef.current !== drag || + pointerEvent.pointerId !== drag.pointerId + ) { + return; + } + pointerEvent.preventDefault(); + pointerEvent.stopPropagation(); + const pointer = reactFlow.screenToFlowPosition( + { x: pointerEvent.clientX, y: pointerEvent.clientY }, + { snapToGrid: false }, + ); + const delta = { + x: pointer.x - drag.startPointer.x, + y: pointer.y - drag.startPointer.y, + }; + const nextPoints = + drag.kind === "corner" + ? moveOrthogonalCorner(drag.basePoints, drag.routeIndex, delta) + : drag.kind === "detour" + ? createOrthogonalSegmentDetour( + drag.basePoints, + drag.routeIndex, + delta, + ) + : moveOrthogonalSegment( + drag.basePoints, + drag.routeIndex, + delta, + ); + edgeData.onRoutePointsChange?.(id, nextPoints.slice(1, -1)); + }; + const finishRoute = (pointerEvent: PointerEvent) => { + if ( + dragRef.current !== drag || + pointerEvent.pointerId !== drag.pointerId + ) { + return; + } + pointerEvent.preventDefault(); + pointerEvent.stopPropagation(); + drag.cleanup(); + dragRef.current = null; + }; + drag.cleanup = () => { + window.removeEventListener("pointermove", moveRoute); + window.removeEventListener("pointerup", finishRoute); + window.removeEventListener("pointercancel", finishRoute); + }; + dragRef.current = drag; + window.addEventListener("pointermove", moveRoute, { passive: false }); + window.addEventListener("pointerup", finishRoute); + window.addEventListener("pointercancel", finishRoute); + edgeData.onRouteEditStart?.(id); + }; + + const editable = selected && edgeData.editable === true; + const internalSegments = points + .slice(0, -1) + .map((start, index) => ({ end: points[index + 1], index, start })) + .filter(({ index }) => index > 0 && index < points.length - 2); + const internalCorners = points + .map((point, index) => ({ index, point })) + .filter(({ index }) => index > 0 && index < points.length - 1); + const fallbackIndex = longestSegmentIndex(points); + const fallbackStart = points[fallbackIndex]; + const fallbackEnd = points[fallbackIndex + 1]; + return ( - + <> + + {editable + ? internalSegments.map(({ end, index, start }) => ( + + + beginRouteDrag(event, points, "segment", index) + } + x1={start.x} + x2={end.x} + y1={start.y} + y2={end.y} + /> + + )) + : null} + {editable + ? internalCorners.map(({ index, point }) => ( + + beginRouteDrag(event, points, "corner", index) + } + r={4.5} + /> + )) + : null} + {editable && internalCorners.length === 0 && fallbackEnd ? ( + + beginRouteDrag(event, points, "detour", fallbackIndex) + } + r={5} + /> + ) : null} + ); } +export function ContactAwareEdge(props: EdgeProps) { + if (props.data?.isContactEdge === true) { + return null; + } + return ; +} + export const contactAwareEdgeTypes: EdgeTypes = { [CONTACT_AWARE_EDGE_TYPE]: ContactAwareEdge, }; diff --git a/frontend/src/SimulationResultsView.tsx b/frontend/src/SimulationResultsView.tsx index 6f055f3..8698c75 100644 --- a/frontend/src/SimulationResultsView.tsx +++ b/frontend/src/SimulationResultsView.tsx @@ -1,9 +1,10 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useId, useMemo, useRef, useState } from "react"; import type { ChangeEvent as ReactChangeEvent, DragEvent, KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent, + WheelEvent as ReactWheelEvent, } from "react"; import { ChartNoAxesCombined, @@ -13,11 +14,13 @@ import { FolderOpen, ImageDown, ListChecks, + Maximize2, Move, Scaling, Sheet, Trash2, X, + ZoomIn, } from "lucide-react"; import { ConnectionLineType, @@ -27,21 +30,26 @@ import { type Edge, type Node, type NodeTypes, + type Position, + type Viewport, } from "@xyflow/react"; import { CanvasGridBackground, type CanvasGridVisibility, } from "./CanvasGridBackground"; import { hasComponentSymbol } from "./ComponentSymbol"; -import { - WorkspaceViewTabs, - type WorkspaceView, -} from "./WorkspaceViewTabs"; +import { WorkspaceViewTabs, type WorkspaceView } from "./WorkspaceViewTabs"; import { AutoFitView } from "./AutoFitView"; import { CONTACT_AWARE_EDGE_TYPE, contactAwareEdgeTypes, } from "./ContactAwareEdge"; +import { + detectEdgeCrossingJumps, + edgePointIsFinite, + orthogonalEdgePoints, + type EdgeRouteData, +} from "./edgeRouting"; type ResultPortDefinition = { name: string; @@ -84,11 +92,17 @@ type ResultProjectEdge = { target: string; sourceHandle: string; targetHandle: string; - data: { + data: EdgeRouteData & { isContactEdge: boolean; }; }; +type ResultPortFlowPlacement = { + flowX: number; + flowY: number; + position: Position; +}; + type ResultSimulationConfig = { t_start: number; t_stop: number; @@ -179,6 +193,10 @@ type SimulationResultsViewProps = { nodeTypes: NodeTypes; onViewChange: (view: WorkspaceView) => void; onSnapshotLoad: (snapshot: SimulationResultsSnapshot) => void; + resolvePortPlacement: ( + node: ResultProjectNode, + portName: string | null, + ) => ResultPortFlowPlacement | undefined; }; type EmptySimulationResultsViewProps = { @@ -199,9 +217,23 @@ type ChartWindow = { height: number; unit: string; cursorEnabled: boolean; + zoomEnabled: boolean; + viewport: ChartViewport; + zoomHistory: ChartViewport[]; zIndex: number; }; +type ChartViewportRange = { + start: number; + end: number; +}; + +type ChartViewport = { + x: ChartViewportRange; + y: ChartViewportRange; + stackedY: Record; +}; + type ResultUnitOption = { value: string; label: string; @@ -250,6 +282,8 @@ const RESULT_FILE_MIME = "application/vnd.system-simulation.result+json"; const MAX_RESULT_FILE_SIZE = 250 * 1024 * 1024; const RESULT_LAYOUT_KEY_PREFIX = "system-simulation-flow:result-layout:"; const RESULT_PANE_LAYOUT_KEY = "system-simulation-flow:result-pane-layout"; +const RESULT_SYSTEM_VIEWPORT_KEY_PREFIX = + "system-simulation-flow:result-system-viewport:"; const CHART_WINDOW_WIDTH = 420; const CHART_WINDOW_HEIGHT = 260; const MULTI_CHART_WINDOW_WIDTH = 560; @@ -262,6 +296,10 @@ const MIN_CHART_WIDTH = 440; const MIN_SYSTEM_HEIGHT = 180; const MIN_VARIABLE_HEIGHT = 180; const PANE_KEYBOARD_STEP = 20; +const MAX_CHART_ZOOM_HISTORY = 24; +const CHART_ZOOM_DRAG_THRESHOLD = 6; +const MIN_CHART_VIEWPORT_SPAN = 1e-6; +const MAX_CHART_VIEWPORT_SPAN = 1e6; const CHART_COLORS = [ "#1d6fb8", "#c2413b", @@ -288,9 +326,7 @@ const RESULT_UNIT_OPTIONS: Record = { scaledResultUnit("cm2", "cm²", 1e-4), scaledResultUnit("mm2", "mm²", 1e-6), ], - heat_transfer_coefficient: [ - scaledResultUnit("W/(m2*K)", "W/(m²·K)", 1), - ], + heat_transfer_coefficient: [scaledResultUnit("W/(m2*K)", "W/(m²·K)", 1)], pressure: PRESSURE_RESULT_UNITS, absolute_pressure: PRESSURE_RESULT_UNITS, temperature: [ @@ -334,9 +370,7 @@ const RESULT_UNIT_OPTIONS: Record = { scaledResultUnit("cm", "cm", 1e-2), scaledResultUnit("mm", "mm", 1e-3), ], - velocity: [ - scaledResultUnit("m/s", "m/s", 1), - ], + velocity: [scaledResultUnit("m/s", "m/s", 1)], }; export function SimulationResultsView({ @@ -345,6 +379,7 @@ export function SimulationResultsView({ nodeTypes, onViewChange, onSnapshotLoad, + resolvePortPlacement, }: SimulationResultsViewProps) { const reactFlow = useReactFlow(); const resultStatus = simulationResultStatus(snapshot.result); @@ -360,10 +395,12 @@ export function SimulationResultsView({ const [activePaneResize, setActivePaneResize] = useState(null); const [csvDownloadPending, setCsvDownloadPending] = useState(false); - const [activeMultiPickerWindowId, setActiveMultiPickerWindowId] = - useState(null); - const [multiDropTargetWindowId, setMultiDropTargetWindowId] = - useState(null); + const [activeMultiPickerWindowId, setActiveMultiPickerWindowId] = useState< + string | null + >(null); + const [multiDropTargetWindowId, setMultiDropTargetWindowId] = useState< + string | null + >(null); const workbenchRef = useRef(null); const workspaceRef = useRef(null); const dragStateRef = useRef(null); @@ -373,6 +410,9 @@ export function SimulationResultsView({ const topZIndexRef = useRef( Math.max(1, ...chartWindows.map((window) => window.zIndex)), ); + const initialSystemViewportRef = useRef( + loadResultSystemViewport(snapshot.id), + ); useEffect(() => { storeChartWindows(snapshot.id, chartWindows); @@ -389,7 +429,11 @@ export function SimulationResultsView({ return; } setPaneLayout((current) => { - const next = clampResultsPaneLayout(current, bounds.width, bounds.height); + const next = clampResultsPaneLayout( + current, + bounds.width, + bounds.height, + ); return sameResultsPaneLayout(current, next) ? current : next; }); }; @@ -428,16 +472,8 @@ export function SimulationResultsView({ width, height, ); - const x = clampWindowCoordinate( - chartWindow.x, - width, - size.width, - ); - const y = clampWindowCoordinate( - chartWindow.y, - height, - size.height, - ); + const x = clampWindowCoordinate(chartWindow.x, width, size.width); + const y = clampWindowCoordinate(chartWindow.y, height, size.height); const windowChanged = x !== chartWindow.x || y !== chartWindow.y || @@ -503,15 +539,57 @@ export function SimulationResultsView({ })), [connectedPortKeys, selectedNodeId, snapshot.project.nodes], ); + const resultCrossingJumpsByEdgeId = useMemo(() => { + const nodesById = new Map( + snapshot.project.nodes.map((node) => [node.id, node] as const), + ); + return detectEdgeCrossingJumps( + snapshot.project.edges.flatMap((edge) => { + if (edge.data.isContactEdge) { + return []; + } + const sourceNode = nodesById.get(edge.source); + const targetNode = nodesById.get(edge.target); + const source = sourceNode + ? resolvePortPlacement(sourceNode, edge.sourceHandle) + : undefined; + const target = targetNode + ? resolvePortPlacement(targetNode, edge.targetHandle) + : undefined; + if (!source || !target) { + return []; + } + return [ + { + id: edge.id, + nodeIds: [edge.source, edge.target] as const, + points: orthogonalEdgePoints({ + routePoints: edge.data.routePoints, + sourcePosition: source.position, + sourceX: source.flowX, + sourceY: source.flowY, + targetPosition: target.position, + targetX: target.flowX, + targetY: target.flowY, + }), + }, + ]; + }), + ); + }, [resolvePortPlacement, snapshot.project.edges, snapshot.project.nodes]); const readOnlyEdges = useMemo( () => snapshot.project.edges.map((edge) => ({ ...edge, + data: { + ...edge.data, + crossingJumps: resultCrossingJumpsByEdgeId.get(edge.id), + }, focusable: false, selectable: false, type: CONTACT_AWARE_EDGE_TYPE, })), - [snapshot.project.edges], + [resultCrossingJumpsByEdgeId, snapshot.project.edges], ); const selectedVariables = useMemo( () => @@ -534,12 +612,9 @@ export function SimulationResultsView({ () => snapshot.result.variables .filter( - (variable) => - (snapshot.result.series[variable.key]?.length ?? 0) > 0, + (variable) => (snapshot.result.series[variable.key]?.length ?? 0) > 0, ) - .sort((first, second) => - first.key.localeCompare(second.key, "zh-CN"), - ), + .sort((first, second) => first.key.localeCompare(second.key, "zh-CN")), [snapshot.result.series, snapshot.result.variables], ); const setPaneSize = (kind: PaneResizeKind, requestedSize: number) => { @@ -685,20 +760,15 @@ export function SimulationResultsView({ variableKeys: [variableKey], hiddenVariableKeys: [], variableUnits: { [variableKey]: variable?.unit ?? "" }, - x: clampWindowCoordinate( - position.x, - bounds.width, - size.width, - ), - y: clampWindowCoordinate( - position.y, - bounds.height, - size.height, - ), + x: clampWindowCoordinate(position.x, bounds.width, size.width), + y: clampWindowCoordinate(position.y, bounds.height, size.height), width: size.width, height: size.height, unit: variable?.unit ?? "", cursorEnabled: false, + zoomEnabled: false, + viewport: defaultChartViewport(), + zoomHistory: [], zIndex, }, ]); @@ -742,6 +812,9 @@ export function SimulationResultsView({ height: size.height, unit: "", cursorEnabled: false, + zoomEnabled: false, + viewport: defaultChartViewport(), + zoomHistory: [], zIndex, }, ]); @@ -757,11 +830,17 @@ export function SimulationResultsView({ if (window.id !== windowId || window.kind === "single") { return window; } - const hiddenVariableKeys = window.hiddenVariableKeys.includes(variableKey) + const hiddenVariableKeys = window.hiddenVariableKeys.includes( + variableKey, + ) ? window.hiddenVariableKeys.filter((key) => key !== variableKey) : [...window.hiddenVariableKeys, variableKey]; return { ...window, + cursorEnabled: + hiddenVariableKeys.length >= window.variableKeys.length + ? false + : window.cursorEnabled, hiddenVariableKeys, }; }), @@ -789,6 +868,9 @@ export function SimulationResultsView({ variableUnits: {}, unit: "", cursorEnabled: false, + zoomEnabled: false, + viewport: defaultChartViewport(), + zoomHistory: [], }; } const variableKeys = window.variableKeys.filter( @@ -806,6 +888,10 @@ export function SimulationResultsView({ ([key]) => key !== variableKey, ), ), + viewport: chartViewportWithoutStackedY(window.viewport, variableKey), + zoomHistory: window.zoomHistory.map((viewport) => + chartViewportWithoutStackedY(viewport, variableKey), + ), }; }), ); @@ -860,6 +946,8 @@ export function SimulationResultsView({ hiddenVariableKeys: [], variableUnits: { [variableKey]: variable.unit }, unit: variable.unit, + viewport: defaultChartViewport(), + zoomHistory: [], }; } if (window.variableKeys.includes(variableKey)) { @@ -931,11 +1019,7 @@ export function SimulationResultsView({ const moveChartWindow = (event: ReactPointerEvent) => { const dragState = dragStateRef.current; const workspace = workspaceRef.current; - if ( - !dragState || - dragState.pointerId !== event.pointerId || - !workspace - ) { + if (!dragState || dragState.pointerId !== event.pointerId || !workspace) { return; } const bounds = workspace.getBoundingClientRect(); @@ -1062,12 +1146,68 @@ export function SimulationResultsView({ setChartWindows((current) => current.map((window) => window.id === windowId - ? { ...window, cursorEnabled: !window.cursorEnabled } + ? { + ...window, + cursorEnabled: !window.cursorEnabled, + zoomEnabled: false, + } : window, ), ); }; + const toggleChartWindowZoom = (windowId: string) => { + setChartWindows((current) => + current.map((window) => + window.id === windowId + ? { + ...window, + cursorEnabled: false, + zoomEnabled: !window.zoomEnabled, + } + : window, + ), + ); + }; + + const updateChartWindowViewport = ( + windowId: string, + requestedViewport: ChartViewport, + ) => { + const viewport = normalizeChartViewport(requestedViewport); + setChartWindows((current) => + current.map((window) => { + if ( + window.id !== windowId || + sameChartViewport(window.viewport, viewport) + ) { + return window; + } + return { + ...window, + cursorEnabled: false, + viewport, + zoomHistory: [], + }; + }), + ); + }; + + const undoChartWindowZoom = (windowId: string) => { + setChartWindows((current) => + current.map((window) => { + if (window.id !== windowId || isDefaultChartViewport(window.viewport)) { + return window; + } + return { + ...window, + viewport: defaultChartViewport(), + zoomHistory: [], + }; + }), + ); + }; + const downloadResultsCsv = async () => { if (csvDownloadPending) { return; @@ -1173,6 +1313,7 @@ export function SimulationResultsView({ colorMode="light" connectionLineType={ConnectionLineType.Step} connectionMode={ConnectionMode.Loose} + defaultViewport={initialSystemViewportRef.current ?? undefined} deleteKeyCode={null} edges={readOnlyEdges} edgeTypes={contactAwareEdgeTypes} @@ -1188,6 +1329,9 @@ export function SimulationResultsView({ nodesFocusable onNodeClick={(_, node) => setSelectedNodeId(node.id)} onPaneClick={() => setSelectedNodeId(null)} + onMoveEnd={(_, viewport) => + storeResultSystemViewport(snapshot.id, viewport) + } panOnDrag selectionOnDrag={false} zoomOnDoubleClick={false} @@ -1195,7 +1339,7 @@ export function SimulationResultsView({ 0} expectedNodeCount={readOnlyNodes.length} - fitOnMount + fitOnMount={initialSystemViewportRef.current === null} requestKey={snapshot.id} /> @@ -1214,9 +1358,7 @@ export function SimulationResultsView({ resizePaneWithKeyboard(event, "system-height") } onPointerCancel={stopPaneResize} - onPointerDown={(event) => - startPaneResize(event, "system-height") - } + onPointerDown={(event) => startPaneResize(event, "system-height")} onPointerMove={movePaneResize} onPointerUp={stopPaneResize} role="separator" @@ -1271,9 +1413,7 @@ export function SimulationResultsView({ resizePaneWithKeyboard(event, "inspector-width") } onPointerCancel={stopPaneResize} - onPointerDown={(event) => - startPaneResize(event, "inspector-width") - } + onPointerDown={(event) => startPaneResize(event, "inspector-width")} onPointerMove={movePaneResize} onPointerUp={stopPaneResize} role="separator" @@ -1318,11 +1458,7 @@ export function SimulationResultsView({ title="新建异单位上下对比窗口" type="button" > -