diff --git a/.github/workflows/solver-regression.yml b/.github/workflows/solver-regression.yml
index b3d58f6..0b23e46 100644
--- a/.github/workflows/solver-regression.yml
+++ b/.github/workflows/solver-regression.yml
@@ -1,58 +1,31 @@
-name: Solver regression
+name: Native backend regression
on:
push:
paths:
- - "app/simulation/**"
+ - "app/**"
+ - "native/**"
+ - "schemas/**"
- "tests/**"
- - "requirements.txt"
+ - "requirements*.txt"
- "constraints/**"
- ".python-version"
- ".gitattributes"
- - "README.md"
- ".github/workflows/solver-regression.yml"
pull_request:
paths:
- - "app/simulation/**"
+ - "app/**"
+ - "native/**"
+ - "schemas/**"
- "tests/**"
- - "requirements.txt"
+ - "requirements*.txt"
- "constraints/**"
- ".python-version"
- ".gitattributes"
- - "README.md"
- ".github/workflows/solver-regression.yml"
schedule:
- - cron: "17 3 * * 1-6"
- - cron: "17 3 * * 0"
+ - cron: "17 3 * * *"
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 }}
@@ -62,142 +35,67 @@ permissions:
contents: read
jobs:
- fixture-byte-contract:
- if: >-
- github.event_name == 'push' ||
- github.event_name == 'pull_request' ||
- (github.event_name == 'workflow_dispatch' && inputs.suite == 'quick')
- strategy:
- fail-fast: false
- matrix:
- os:
- - ubuntu-24.04
- - windows-2022
- runs-on: ${{ matrix.os }}
- timeout-minutes: 5
- steps:
- - uses: actions/checkout@v4
- - uses: actions/setup-python@v5
- with:
- python-version-file: .python-version
- - name: Verify portable regression fixture bytes
- run: python -m unittest tests.test_regression_fixture_line_endings
-
- quick:
- if: >-
- github.event_name == 'push' ||
- github.event_name == 'pull_request' ||
- (github.event_name == 'workflow_dispatch' && inputs.suite == 'quick')
+ contracts:
runs-on: ubuntu-24.04
- timeout-minutes: 15
+ timeout-minutes: 10
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
- constraints/python312-linux-x86_64.lock
- - name: Install hashed Linux release lock
+ cache-dependency-path: constraints/python312-linux-x86_64.lock
+ - name: Install backend runtime without numerical Python packages
run: |
- python -m pip install \
- --force-reinstall \
- -r constraints/python312-linux-x86_64.lock
+ python -m pip install -r constraints/python312-linux-x86_64.lock
python -m pip check
- - name: Run solver foundation tests
+ - name: Validate portable metadata and IR contracts
env:
SYSTEM_SIMULATION_VERIFY_LOCKED_ENV: "1"
run: |
python -W error::ResourceWarning -m unittest \
tests.test_dependency_constraints \
- tests.test_benchmark_regression \
- tests.test_physical_state_v21 \
- tests.test_test_mql_ame_contract \
- 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
+ tests.test_regression_fixture_line_endings \
+ tests.test_component_catalog \
+ tests.test_component_metadata \
+ tests.test_component_registry \
+ tests.test_medium_reference_contract \
+ tests.test_system_xml_v3 \
+ tests.test_system_numeric_ir_v2 \
+ tests.test_native_only_backend
- 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
+ native-windows:
+ runs-on: windows-2022
+ timeout-minutes: 30
+ defaults:
+ run:
+ shell: pwsh
steps:
- uses: actions/checkout@v4
- - uses: actions/setup-python@v5
+ - uses: conda-incubator/setup-miniconda@v3
with:
- python-version-file: .python-version
- cache: pip
- cache-dependency-path: |
- requirements.txt
- constraints/python312-direct.txt
- constraints/python312-linux-x86_64.lock
- - name: Install hashed Linux release lock
+ python-version: "3.12"
+ activate-environment: simulation-native
+ auto-activate-base: false
+ - name: Install native compiler and SUNDIALS
run: |
- python -m pip install \
- --force-reinstall \
- -r constraints/python312-linux-x86_64.lock
+ conda install --yes -c conda-forge sundials=7.4.0 m2w64-gcc
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
+ "SUNDIALS_ROOT=$env:CONDA_PREFIX\Library" >> $env:GITHUB_ENV
+ "SIMULATION_NATIVE_CC=$env:CONDA_PREFIX\Library\mingw-w64\bin\gcc.exe" >> $env:GITHUB_ENV
+ python -m pip install -r requirements-test.txt
python -m pip check
- - name: Run 0.81 and 2.10 second historical regression
+ - name: Run catalog, numerical, API and schema regression
run: |
- mkdir -p artifacts
- python -m app.simulation.benchmark_regression \
- --manifest tests/baselines/simulation/test_mql_full_branches/manifest.json \
- --lane production \
- --output artifacts/test-mql-full-branches.json
+ python -c "from app.simulation.native_codegen.build import toolchain; print(toolchain())"
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
+ python -W error::ResourceWarning -m unittest discover -s tests
+ - name: Complete the 10 second skill fixture in native RK45
+ run: |
+ python -m app.simulation.native_codegen tests/data/native-skill-test.xml --output-dir test/ci-skill --method RK45 --max-step 0.001 --rtol 1e-7 --runs 1 --solve-only
- 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
- constraints/python312-linux-x86_64.lock
- - name: Install hashed Linux release lock
- run: |
- python -m pip install \
- --force-reinstall \
- -r constraints/python312-linux-x86_64.lock
- 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
+ name: native-skill-regression
+ path: test/ci-skill/summary.json
if-no-files-found: warn
diff --git a/app/simulation/ir/__init__.py b/app/simulation/ir/__init__.py
new file mode 100644
index 0000000..1698523
--- /dev/null
+++ b/app/simulation/ir/__init__.py
@@ -0,0 +1,40 @@
+"""Whole-system numeric intermediate representation."""
+
+from .compiler import compile_system_ir
+from .schema import * # noqa: F403 - this package is the public schema facade.
+from .schema import __dict__ as _schema_namespace
+from .validation import (
+ IRValidationIssue,
+ IRValidationReport,
+ SystemIRValidationError,
+ require_valid_system_ir,
+ validate_system_ir,
+)
+
+
+__all__ = [
+ "compile_system_ir",
+ "IRValidationIssue",
+ "IRValidationReport",
+ "SystemIRValidationError",
+ "require_valid_system_ir",
+ "validate_system_ir",
+ *sorted(
+ name
+ for name in _schema_namespace
+ if name.startswith("IR")
+ or name.startswith("SystemIR")
+ or name.startswith("SYSTEM_NUMERIC_IR")
+ or name
+ in {
+ "CURRENT_SYSTEM_IR_VERSION",
+ "NATIVE_NUMERIC_ABI_VERSION",
+ "canonical_json_bytes",
+ "native_artifact_key",
+ "operation_read_slots",
+ "operation_write_slots",
+ }
+ ),
+]
+
+del _schema_namespace
diff --git a/app/simulation/ir/compiler.py b/app/simulation/ir/compiler.py
new file mode 100644
index 0000000..9cd276e
--- /dev/null
+++ b/app/simulation/ir/compiler.py
@@ -0,0 +1,14 @@
+"""Retired object-engine IR adapter; the independent v2 schema is retained.
+
+Current native execution is generated by native_codegen.compile_native_program.
+It does not yet emit the separate System IR v2 execution-plan format.
+"""
+
+
+def compile_system_ir(*args, **kwargs):
+ """Fail explicitly for callers of the removed GenericFluidSystem adapter."""
+ raise NotImplementedError(
+ "The GenericFluidSystem to System IR v2 exporter was retired with the Python "
+ "numerical engine. Use native_codegen.compiler.compile_native_program(network) "
+ "for the current C executable path. System IR v2 schema and validation remain available."
+ )
diff --git a/app/simulation/ir/schema.py b/app/simulation/ir/schema.py
new file mode 100644
index 0000000..5bd902d
--- /dev/null
+++ b/app/simulation/ir/schema.py
@@ -0,0 +1,973 @@
+"""Callback-free data contract for the whole-system numeric IR.
+
+System IR v2 describes a compiled simulation model. It is intentionally a
+pure, immutable data graph: Python functions, model objects, object addresses,
+and run-local diagnostic state are not part of this module's wire contract.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, fields, is_dataclass
+from enum import StrEnum
+from hashlib import sha256
+import json
+from math import isfinite
+from struct import pack
+from typing import ClassVar
+from unicodedata import normalize
+
+
+SYSTEM_NUMERIC_IR_SCHEMA_ID = "system-numeric-ir"
+SYSTEM_NUMERIC_IR_SCHEMA_MAJOR = 2
+SYSTEM_NUMERIC_IR_SCHEMA_MINOR = 0
+SYSTEM_NUMERIC_IR_COMPILER_ID = "generic-fluid-system"
+SYSTEM_NUMERIC_IR_COMPILER_VERSION = "2.0.0"
+NATIVE_NUMERIC_ABI_VERSION = 1
+
+
+class IRDType(StrEnum):
+ FLOAT64 = "float64"
+ INT32 = "int32"
+
+
+class IRBufferKind(StrEnum):
+ TIME = "time"
+ STATE_INPUT = "state_input"
+ DERIVATIVE_OUTPUT = "derivative_output"
+ LOCAL_STATE = "local_state"
+ LOCAL_DERIVATIVE = "local_derivative"
+ ALGEBRAIC = "algebraic"
+ SIGNAL = "signal"
+ PARAMETER = "parameter"
+ CONSTANT = "constant"
+ MODE = "mode"
+ WORK_FLOAT = "work_float"
+ WORK_INT = "work_int"
+ EVENT_OUTPUT = "event_output"
+ JACOBIAN_VALUE = "jacobian_value"
+ RESULT_OUTPUT = "result_output"
+ RUNTIME_INPUT = "runtime_input"
+
+
+class IRKernelPhase(StrEnum):
+ PRIMAL = "primal"
+ RESIDUAL = "residual"
+ DERIVATIVE = "derivative"
+ PROPERTY = "property"
+ EVENT = "event"
+ RESET = "reset"
+ JACOBIAN = "jacobian"
+ OUTPUT = "output"
+
+
+class IRKernelAvailability(StrEnum):
+ NATIVE = "native"
+ REFERENCE_ONLY = "reference_only"
+
+
+class IRPortKind(StrEnum):
+ PHYSICAL = "physical"
+ SIGNAL = "signal"
+
+
+class IRPortNominalRole(StrEnum):
+ INLET = "inlet"
+ OUTLET = "outlet"
+ BIDIRECTIONAL = "bidirectional"
+ INPUT = "input"
+ OUTPUT = "output"
+
+
+class IRPositiveFlowDirection(StrEnum):
+ INTO_COMPONENT = "intoComponent"
+
+
+class IRVariableRole(StrEnum):
+ EFFORT = "effort"
+ FLOW = "flow"
+ STREAM = "stream"
+ SIGNAL = "signal"
+
+
+class IRConnectionRule(StrEnum):
+ EQUAL = "equal"
+ SUM_TO_ZERO = "sumToZero"
+ STREAM_MIX = "streamMix"
+ DIRECTED = "directed"
+
+
+class IREquationOwner(StrEnum):
+ COMPONENT = "component"
+ CONNECTION = "connection"
+
+
+class IREquationRelation(StrEnum):
+ EQUAL = "equal"
+ SUM_TO_ZERO = "sumToZero"
+ CONSTITUTIVE = "constitutive"
+ STATE = "state"
+
+
+class IRPressureFlowScopeKind(StrEnum):
+ NETWORK = "network"
+ PHYSICAL_ISLAND = "physical_island"
+ EQUATION_BLOCK = "equation_block"
+
+
+class IRStageKind(StrEnum):
+ STATE_REDUCE = "state_reduce"
+ SIGNAL = "signal"
+ MECHANICAL_EQUIVALENCE = "mechanical_equivalence"
+ DYNAMIC_VOLUME = "dynamic_volume"
+ PROPERTY = "property"
+ PRESSURE_FLOW = "pressure_flow"
+ STREAM = "stream"
+ TEMPERATURE_REFERENCE = "temperature_reference"
+ THERMOFLUID_FIXED_POINT = "thermofluid_fixed_point"
+ MECHANICAL_ACCELERATION = "mechanical_acceleration"
+ DERIVATIVE_REDUCE = "derivative_reduce"
+ EVENT = "event"
+ JACOBIAN = "jacobian"
+ OUTPUT = "output"
+ RESET = "reset"
+
+
+class IREntryPointKind(StrEnum):
+ RHS = "rhs"
+ EVENTS = "events"
+ JACOBIAN = "jacobian"
+ OUTPUTS = "outputs"
+
+
+class IRBlockKind(StrEnum):
+ SEQUENCE = "sequence"
+ FIXED_POINT = "fixed_point"
+ STREAM_SCC = "stream_scc"
+
+
+class IRStepKind(StrEnum):
+ STAGE = "stage"
+ BLOCK = "block"
+
+
+class IRStateMapKind(StrEnum):
+ SCATTER = "scatter"
+ DERIVATIVE_GATHER = "derivative_gather"
+
+
+class IREventDirection(StrEnum):
+ DECREASING = "decreasing"
+ ANY = "any"
+ INCREASING = "increasing"
+
+
+class IRFailurePolicy(StrEnum):
+ FAIL = "fail"
+ RETRY_SMALLER_STEP = "retry_smaller_step"
+
+
+class IRCacheKind(StrEnum):
+ PROPERTY = "property"
+ PRESSURE_FLOW = "pressure_flow"
+ STREAM = "stream"
+ JACOBIAN = "jacobian"
+ OUTPUT = "output"
+
+
+class IRCapabilityLevel(StrEnum):
+ NATIVE = "native"
+ REFERENCE_ONLY = "reference_only"
+ UNSUPPORTED = "unsupported"
+
+
+class IRDiagnosticSeverity(StrEnum):
+ INFO = "info"
+ WARNING = "warning"
+ ERROR = "error"
+
+
+class IROpcode(StrEnum):
+ FILL = "fill"
+ COPY = "copy"
+ SCATTER = "scatter"
+ LINEAR_COMBINATION = "linear_combination"
+ STATE_MAP = "state_map"
+ KERNEL_CALL = "kernel_call"
+ EFFORT_BROADCAST = "effort_broadcast"
+ FLOW_ASSIGN = "flow_assign"
+ CHECK_FINITE = "check_finite"
+
+
+@dataclass(frozen=True, slots=True)
+class IRSchemaVersion:
+ schema_id: str = SYSTEM_NUMERIC_IR_SCHEMA_ID
+ major: int = SYSTEM_NUMERIC_IR_SCHEMA_MAJOR
+ minor: int = SYSTEM_NUMERIC_IR_SCHEMA_MINOR
+
+
+CURRENT_SYSTEM_IR_VERSION = IRSchemaVersion()
+
+
+@dataclass(frozen=True, slots=True)
+class IRSlotRef:
+ buffer: IRBufferKind
+ index: int
+
+
+@dataclass(frozen=True, slots=True)
+class IRBufferSpec:
+ kind: IRBufferKind
+ dtype: IRDType
+ size: int
+ initial_float_values: tuple[float, ...] = ()
+ initial_int_values: tuple[int, ...] = ()
+
+
+@dataclass(frozen=True, slots=True)
+class IRValueSpec:
+ value_id: str
+ slot: IRSlotRef
+ semantic: str
+ role: str
+ quantity: str
+ unit: str
+ scale: float
+ lower_bound: float | None = None
+ upper_bound: float | None = None
+ owner_component_index: int | None = None
+
+
+@dataclass(frozen=True, slots=True)
+class IRKernelPhaseSpec:
+ """Phase capability tag; each call's ordered slot lists define its C-01 ABI.
+
+ Fixed component-kernel signatures deliberately belong to the C-02 contract.
+ Recording placeholder arities here would make the current reference-only
+ kernels look more strictly specified than they are.
+ """
+
+ phase: IRKernelPhase
+
+
+@dataclass(frozen=True, slots=True)
+class IRKernelSpec:
+ kernel_id: str
+ model_type: str
+ model_version: str
+ implementation_version: str
+ availability: IRKernelAvailability
+ unavailable_reason: str | None
+ phases: tuple[IRKernelPhaseSpec, ...]
+ parameter_count: int
+ state_count: int
+ mode_count: int
+ workspace_float_count: int
+ workspace_int_count: int
+
+
+@dataclass(frozen=True, slots=True)
+class IRComponentInstance:
+ instance_id: str
+ kernel_index: int
+ parameter_slots: tuple[IRSlotRef, ...]
+ state_slots: tuple[IRSlotRef, ...]
+ derivative_slots: tuple[IRSlotRef, ...]
+ mode_slots: tuple[IRSlotRef, ...]
+ port_indices: tuple[int, ...]
+ port_slots: tuple[IRSlotRef, ...]
+ output_indices: tuple[int, ...]
+ workspace_float_slots: tuple[IRSlotRef, ...]
+ workspace_int_slots: tuple[IRSlotRef, ...]
+
+
+@dataclass(frozen=True, slots=True)
+class IRPortVariable:
+ variable_id: str
+ name: str
+ role: IRVariableRole
+ connection_rule: IRConnectionRule
+ quantity: str
+ unit: str
+ result_visible: bool
+ slot: IRSlotRef
+
+
+@dataclass(frozen=True, slots=True)
+class IRPortSpec:
+ port_id: str
+ component_index: int
+ name: str
+ kind: IRPortKind
+ domain: str
+ nominal_role: IRPortNominalRole
+ positive_flow_direction: IRPositiveFlowDirection | None
+ variables: tuple[IRPortVariable, ...]
+
+
+@dataclass(frozen=True, slots=True)
+class IRConnectionVariable:
+ name: str
+ rule: IRConnectionRule
+ endpoint_a_slot: IRSlotRef
+ endpoint_b_slot: IRSlotRef
+
+
+@dataclass(frozen=True, slots=True)
+class IRConnectionSpec:
+ connection_id: str
+ kind: IRPortKind
+ domain: str
+ endpoint_a_port_index: int
+ endpoint_b_port_index: int
+ variables: tuple[IRConnectionVariable, ...]
+
+
+@dataclass(frozen=True, slots=True)
+class IRMediumSpec:
+ medium_id: str
+ name: str
+ implementation_id: str
+ implementation_version: str
+ parameter_slots: tuple[IRSlotRef, ...]
+ component_indices: tuple[int, ...]
+
+
+@dataclass(frozen=True, slots=True)
+class IRCSRPattern:
+ row_count: int
+ column_count: int
+ row_pointers: tuple[int, ...]
+ column_indices: tuple[int, ...]
+
+ @property
+ def nonzero_count(self) -> int:
+ return len(self.column_indices)
+
+
+@dataclass(frozen=True, slots=True)
+class IRCSRMatrix:
+ pattern: IRCSRPattern
+ values: tuple[float, ...]
+
+
+@dataclass(frozen=True, slots=True)
+class IRStateReducer:
+ solver_state_count: int
+ local_state_slots: tuple[IRSlotRef, ...]
+ raw_derivative_slots: tuple[IRSlotRef, ...]
+ state_scatter: IRCSRMatrix
+ derivative_gather: IRCSRMatrix
+ initial_state: tuple[float, ...]
+ absolute_tolerances: tuple[float, ...]
+
+
+@dataclass(frozen=True, slots=True)
+class IRFillOperation:
+ opcode: ClassVar[IROpcode] = IROpcode.FILL
+ target_slots: tuple[IRSlotRef, ...]
+ value: float
+
+
+@dataclass(frozen=True, slots=True)
+class IRCopyOperation:
+ opcode: ClassVar[IROpcode] = IROpcode.COPY
+ source_slot: IRSlotRef
+ target_slot: IRSlotRef
+
+
+@dataclass(frozen=True, slots=True)
+class IRScatterOperation:
+ opcode: ClassVar[IROpcode] = IROpcode.SCATTER
+ source_slot: IRSlotRef
+ target_slots: tuple[IRSlotRef, ...]
+
+
+@dataclass(frozen=True, slots=True)
+class IRLinearCombinationOperation:
+ opcode: ClassVar[IROpcode] = IROpcode.LINEAR_COMBINATION
+ source_slots: tuple[IRSlotRef, ...]
+ weights: tuple[float, ...]
+ target_slot: IRSlotRef
+ bias: float = 0.0
+
+
+@dataclass(frozen=True, slots=True)
+class IRStateMapOperation:
+ opcode: ClassVar[IROpcode] = IROpcode.STATE_MAP
+ map_kind: IRStateMapKind
+ source_slots: tuple[IRSlotRef, ...]
+ target_slots: tuple[IRSlotRef, ...]
+
+
+@dataclass(frozen=True, slots=True)
+class IRKernelCallOperation:
+ opcode: ClassVar[IROpcode] = IROpcode.KERNEL_CALL
+ kernel_index: int
+ component_index: int | None
+ phase: IRKernelPhase
+ read_slots: tuple[IRSlotRef, ...]
+ write_slots: tuple[IRSlotRef, ...]
+ equation_indices: tuple[int, ...] = ()
+
+
+@dataclass(frozen=True, slots=True)
+class IREffortBroadcastOperation:
+ opcode: ClassVar[IROpcode] = IROpcode.EFFORT_BROADCAST
+ variable: str
+ anchor_slot: IRSlotRef
+ residual_slot: IRSlotRef
+ result_slot: IRSlotRef
+ scatter_slots: tuple[IRSlotRef, ...]
+ equation_id: str
+ lower_bound: float | None = None
+ upper_bound: float | None = None
+
+
+@dataclass(frozen=True, slots=True)
+class IRFlowAssignmentOperation:
+ opcode: ClassVar[IROpcode] = IROpcode.FLOW_ASSIGN
+ value_slot: IRSlotRef
+ result_slot: IRSlotRef
+ scatter_slots: tuple[IRSlotRef, ...]
+ equation_id: str
+
+
+@dataclass(frozen=True, slots=True)
+class IRCheckFiniteOperation:
+ opcode: ClassVar[IROpcode] = IROpcode.CHECK_FINITE
+ slots: tuple[IRSlotRef, ...]
+ error_code: str
+
+
+IROperation = (
+ IRFillOperation
+ | IRCopyOperation
+ | IRScatterOperation
+ | IRLinearCombinationOperation
+ | IRStateMapOperation
+ | IRKernelCallOperation
+ | IREffortBroadcastOperation
+ | IRFlowAssignmentOperation
+ | IRCheckFiniteOperation
+)
+
+
+def operation_read_slots(operation: IROperation) -> tuple[IRSlotRef, ...]:
+ if isinstance(operation, IRFillOperation):
+ return ()
+ if isinstance(operation, (IRCopyOperation, IRScatterOperation)):
+ return (operation.source_slot,)
+ if isinstance(operation, (IRLinearCombinationOperation, IRStateMapOperation)):
+ return operation.source_slots
+ if isinstance(operation, IRKernelCallOperation):
+ return operation.read_slots
+ if isinstance(operation, IREffortBroadcastOperation):
+ return (operation.anchor_slot, operation.residual_slot)
+ if isinstance(operation, IRFlowAssignmentOperation):
+ return (operation.value_slot,)
+ if isinstance(operation, IRCheckFiniteOperation):
+ return operation.slots
+ raise TypeError(f"Unsupported IR operation: {type(operation).__name__}.")
+
+
+def operation_write_slots(operation: IROperation) -> tuple[IRSlotRef, ...]:
+ if isinstance(operation, IRFillOperation):
+ return operation.target_slots
+ if isinstance(operation, IRCopyOperation):
+ return (operation.target_slot,)
+ if isinstance(operation, IRScatterOperation):
+ return operation.target_slots
+ if isinstance(operation, IRLinearCombinationOperation):
+ return (operation.target_slot,)
+ if isinstance(operation, IRStateMapOperation):
+ return operation.target_slots
+ if isinstance(operation, IRKernelCallOperation):
+ return operation.write_slots
+ if isinstance(operation, IREffortBroadcastOperation):
+ return (operation.result_slot, *operation.scatter_slots)
+ if isinstance(operation, IRFlowAssignmentOperation):
+ return (operation.result_slot, *operation.scatter_slots)
+ if isinstance(operation, IRCheckFiniteOperation):
+ return ()
+ raise TypeError(f"Unsupported IR operation: {type(operation).__name__}.")
+
+
+@dataclass(frozen=True, slots=True)
+class IRStage:
+ stage_id: str
+ kind: IRStageKind
+ operations: tuple[IROperation, ...]
+ declared_read_slots: tuple[IRSlotRef, ...]
+ declared_write_slots: tuple[IRSlotRef, ...]
+
+
+@dataclass(frozen=True, slots=True)
+class IRStepRef:
+ kind: IRStepKind
+ index: int
+
+
+@dataclass(frozen=True, slots=True)
+class IRConvergenceSpec:
+ monitor_slots: tuple[IRSlotRef, ...]
+ absolute_tolerance: float
+ relative_tolerance: float
+ max_iterations: int
+ relaxation: float
+ rollback_slots: tuple[IRSlotRef, ...]
+ failure_policy: IRFailurePolicy
+
+
+@dataclass(frozen=True, slots=True)
+class IRExecutionBlock:
+ block_id: str
+ kind: IRBlockKind
+ steps: tuple[IRStepRef, ...]
+ convergence: IRConvergenceSpec | None = None
+
+
+@dataclass(frozen=True, slots=True)
+class IREntryPoint:
+ kind: IREntryPointKind
+ steps: tuple[IRStepRef, ...]
+ input_slots: tuple[IRSlotRef, ...]
+ output_slots: tuple[IRSlotRef, ...]
+
+
+@dataclass(frozen=True, slots=True)
+class IRCausalEffortStageRef:
+ variable: str
+ stage_index: int
+
+
+@dataclass(frozen=True, slots=True)
+class IRCausalPlan:
+ plan_id: str
+ scope_component_indices: tuple[int, ...]
+ source_schema_version: int
+ source_structural_signature: str | None
+ fallback_reason: str | None
+ canonical_slots: tuple[IRSlotRef, ...]
+ compatibility_slots: tuple[IRSlotRef, ...]
+ reset_slots: tuple[IRSlotRef, ...]
+ external_effort_slots: tuple[IRSlotRef, ...]
+ effort_stages: tuple[IRCausalEffortStageRef, ...]
+ flow_stage_indices: tuple[int, ...]
+
+
+@dataclass(frozen=True, slots=True)
+class IRAlgebraicUnknown:
+ unknown_id: str
+ component_index: int
+ port_index: int
+ variable: str
+ role: IRVariableRole
+ slot: IRSlotRef
+ scale: float
+ lower_bound: float | None = None
+ upper_bound: float | None = None
+
+
+@dataclass(frozen=True, slots=True)
+class IRPressureFlowEquation:
+ equation_id: str
+ owner: IREquationOwner
+ owner_index: int
+ relation: IREquationRelation
+ role: IRVariableRole | None
+ variable_slots: tuple[IRSlotRef, ...]
+ residual_slot: IRSlotRef
+ scale: float
+
+
+@dataclass(frozen=True, slots=True)
+class IRPressureFlowBlock:
+ block_id: str
+ unknown_indices: tuple[int, ...]
+ equation_indices: tuple[int, ...]
+ jacobian_pattern: IRCSRPattern
+
+
+@dataclass(frozen=True, slots=True)
+class IRPressureFlowScope:
+ scope_id: str
+ kind: IRPressureFlowScopeKind
+ component_indices: tuple[int, ...]
+ unknown_indices: tuple[int, ...]
+ equation_indices: tuple[int, ...]
+ block_indices: tuple[int, ...]
+ causal_plan_index: int | None
+ residual_tolerance: float
+ max_evaluations: int
+ sparse_pattern_trusted: bool
+ sparse_fallback_reason: str | None
+
+
+@dataclass(frozen=True, slots=True)
+class IRPressureFlowPlan:
+ unknowns: tuple[IRAlgebraicUnknown, ...]
+ equations: tuple[IRPressureFlowEquation, ...]
+ blocks: tuple[IRPressureFlowBlock, ...]
+ scopes: tuple[IRPressureFlowScope, ...]
+ global_scope_index: int
+ secondary_scope_indices: tuple[int, ...]
+ pressure_lower_bound: float
+
+
+@dataclass(frozen=True, slots=True)
+class IRStreamSCC:
+ scc_id: str
+ node_slots: tuple[IRSlotRef, ...]
+ block_index: int
+
+
+@dataclass(frozen=True, slots=True)
+class IRStreamEdge:
+ source_scc_index: int
+ target_scc_index: int
+
+
+@dataclass(frozen=True, slots=True)
+class IRStreamPlan:
+ plan_id: str
+ node_slots: tuple[IRSlotRef, ...]
+ strongly_connected_components: tuple[IRStreamSCC, ...]
+ condensed_edges: tuple[IRStreamEdge, ...]
+ topological_scc_indices: tuple[int, ...]
+
+
+@dataclass(frozen=True, slots=True)
+class IRThermofluidPlan:
+ physical_port_indices: tuple[int, ...]
+ global_component_indices: tuple[int, ...]
+ stream_plan_index: int
+ secondary_pressure_scope_indices: tuple[int, ...]
+ sensitive_component_indices: tuple[int, ...]
+ maximum_iterations: int
+ flow_relative_tolerance: float
+ uses_conservative_global_solver: bool
+ conservative_fallback_reason: str | None
+
+
+@dataclass(frozen=True, slots=True)
+class IRTransactionPlan:
+ snapshot_slots: tuple[IRSlotRef, ...]
+ flow_slots: tuple[IRSlotRef, ...]
+ cache_component_indices: tuple[int, ...]
+ cache_attribute_ids: tuple[str, ...]
+ diagnostic_owner_ids: tuple[str, ...]
+ restores_on_recoverable_failure: bool
+ restores_on_fatal_failure: bool
+
+
+@dataclass(frozen=True, slots=True)
+class IRModeValueSpec:
+ value: int
+ name: str
+
+
+@dataclass(frozen=True, slots=True)
+class IRModeSpec:
+ mode_id: str
+ slot: IRSlotRef
+ owner_component_indices: tuple[int, ...]
+ values: tuple[IRModeValueSpec, ...]
+ initial_value: int
+
+
+@dataclass(frozen=True, slots=True)
+class IRFiniteDifferenceColumn:
+ column_index: int
+ value_indices: tuple[int, ...]
+ relative_step: float
+
+
+@dataclass(frozen=True, slots=True)
+class IRJacobianPlan:
+ pattern: IRCSRPattern
+ value_slots: tuple[IRSlotRef, ...]
+ color_groups: tuple[tuple[int, ...], ...]
+ fill_steps: tuple[IRStepRef, ...]
+ analytic_value_indices: tuple[int, ...]
+ local_finite_difference_columns: tuple[IRFiniteDifferenceColumn, ...]
+
+
+@dataclass(frozen=True, slots=True)
+class IRModeGuard:
+ mode_slot: IRSlotRef
+ allowed_values: tuple[int, ...]
+
+
+@dataclass(frozen=True, slots=True)
+class IREventSpec:
+ event_id: str
+ event_kind: str
+ owner_component_indices: tuple[int, ...]
+ root_slot: IRSlotRef
+ direction: IREventDirection
+ terminal: bool
+ priority: int
+ mode_guards: tuple[IRModeGuard, ...]
+ reset_steps: tuple[IRStepRef, ...]
+ invalidated_caches: tuple[IRCacheKind, ...]
+ restarts_integrator: bool
+
+
+@dataclass(frozen=True, slots=True)
+class IROutputSpec:
+ output_id: str
+ component_index: int
+ scope: str
+ port_name: str | None
+ name: str
+ label: str
+ category: str
+ quantity: str
+ unit: str
+ order: int
+ source_slot: IRSlotRef
+ output_slot: IRSlotRef
+ scale: float = 1.0
+ offset: float = 0.0
+
+
+@dataclass(frozen=True, slots=True)
+class IRComponentCapability:
+ component_index: int
+ level: IRCapabilityLevel
+ supported_phases: tuple[IRKernelPhase, ...]
+ missing_features: tuple[str, ...]
+
+
+@dataclass(frozen=True, slots=True)
+class IRCapabilityIssue:
+ code: str
+ severity: IRDiagnosticSeverity
+ scope_id: str
+ message: str
+
+
+@dataclass(frozen=True, slots=True)
+class IRCapabilityReport:
+ system_level: IRCapabilityLevel
+ components: tuple[IRComponentCapability, ...]
+ issues: tuple[IRCapabilityIssue, ...]
+
+
+@dataclass(frozen=True, slots=True)
+class SystemIR:
+ version: IRSchemaVersion
+ model_id: str
+ model_version: str
+ compiler_id: str
+ compiler_version: str
+ numeric_dtype: IRDType
+ buffers: tuple[IRBufferSpec, ...]
+ values: tuple[IRValueSpec, ...]
+ kernels: tuple[IRKernelSpec, ...]
+ components: tuple[IRComponentInstance, ...]
+ mediums: tuple[IRMediumSpec, ...]
+ ports: tuple[IRPortSpec, ...]
+ connections: tuple[IRConnectionSpec, ...]
+ state_reducer: IRStateReducer
+ causal_plans: tuple[IRCausalPlan, ...]
+ pressure_flow: IRPressureFlowPlan
+ stream_plans: tuple[IRStreamPlan, ...]
+ thermofluid: IRThermofluidPlan
+ stages: tuple[IRStage, ...]
+ blocks: tuple[IRExecutionBlock, ...]
+ entry_points: tuple[IREntryPoint, ...]
+ transaction: IRTransactionPlan
+ modes: tuple[IRModeSpec, ...]
+ jacobian: IRJacobianPlan
+ events: tuple[IREventSpec, ...]
+ outputs: tuple[IROutputSpec, ...]
+ capabilities: IRCapabilityReport
+ required_features: tuple[str, ...] = ()
+
+ def canonical_json_bytes(self) -> bytes:
+ return canonical_json_bytes(self)
+
+ def calculate_structural_signature(self) -> str:
+ return sha256(self.canonical_json_bytes()).hexdigest()
+
+ @property
+ def structural_signature(self) -> str:
+ return self.calculate_structural_signature()
+
+
+@dataclass(frozen=True, slots=True)
+class IRNativeBuildIdentity:
+ abi_version: int
+ target_triple: str
+ compiler_id: str
+ compiler_version: str
+ compile_flags: tuple[str, ...]
+ floating_point_policy: str
+ kernel_library_signature: str
+
+
+@dataclass(frozen=True, slots=True)
+class _IRNativeArtifactKeyInput:
+ program_signature: str
+ build: IRNativeBuildIdentity
+
+
+_CANONICAL_TYPE_NAMES: tuple[tuple[type[object], str], ...] = (
+ (IRSchemaVersion, "schema_version"),
+ (IRSlotRef, "slot_ref"),
+ (IRBufferSpec, "buffer"),
+ (IRValueSpec, "value"),
+ (IRKernelPhaseSpec, "kernel_phase"),
+ (IRKernelSpec, "kernel"),
+ (IRComponentInstance, "component"),
+ (IRPortVariable, "port_variable"),
+ (IRPortSpec, "port"),
+ (IRConnectionVariable, "connection_variable"),
+ (IRConnectionSpec, "connection"),
+ (IRMediumSpec, "medium"),
+ (IRCSRPattern, "csr_pattern"),
+ (IRCSRMatrix, "csr_matrix"),
+ (IRStateReducer, "state_reducer"),
+ (IRFillOperation, "operation"),
+ (IRCopyOperation, "operation"),
+ (IRScatterOperation, "operation"),
+ (IRLinearCombinationOperation, "operation"),
+ (IRStateMapOperation, "operation"),
+ (IRKernelCallOperation, "operation"),
+ (IREffortBroadcastOperation, "operation"),
+ (IRFlowAssignmentOperation, "operation"),
+ (IRCheckFiniteOperation, "operation"),
+ (IRStage, "stage"),
+ (IRStepRef, "step_ref"),
+ (IRConvergenceSpec, "convergence"),
+ (IRExecutionBlock, "block"),
+ (IREntryPoint, "entry_point"),
+ (IRCausalEffortStageRef, "causal_effort_stage"),
+ (IRCausalPlan, "causal_plan"),
+ (IRAlgebraicUnknown, "algebraic_unknown"),
+ (IRPressureFlowEquation, "pressure_flow_equation"),
+ (IRPressureFlowBlock, "pressure_flow_block"),
+ (IRPressureFlowScope, "pressure_flow_scope"),
+ (IRPressureFlowPlan, "pressure_flow_plan"),
+ (IRStreamSCC, "stream_scc"),
+ (IRStreamEdge, "stream_edge"),
+ (IRStreamPlan, "stream_plan"),
+ (IRThermofluidPlan, "thermofluid_plan"),
+ (IRTransactionPlan, "transaction_plan"),
+ (IRModeValueSpec, "mode_value"),
+ (IRModeSpec, "mode"),
+ (IRFiniteDifferenceColumn, "finite_difference_column"),
+ (IRJacobianPlan, "jacobian_plan"),
+ (IRModeGuard, "mode_guard"),
+ (IREventSpec, "event"),
+ (IROutputSpec, "output"),
+ (IRComponentCapability, "component_capability"),
+ (IRCapabilityIssue, "capability_issue"),
+ (IRCapabilityReport, "capability_report"),
+ (SystemIR, "system_ir"),
+ (IRNativeBuildIdentity, "native_build"),
+ (_IRNativeArtifactKeyInput, "native_artifact_key_input"),
+)
+
+
+_OPERATION_TYPES = (
+ IRFillOperation,
+ IRCopyOperation,
+ IRScatterOperation,
+ IRLinearCombinationOperation,
+ IRStateMapOperation,
+ IRKernelCallOperation,
+ IREffortBroadcastOperation,
+ IRFlowAssignmentOperation,
+ IRCheckFiniteOperation,
+)
+
+
+def _canonical_type_name(value: object) -> str:
+ value_type = type(value)
+ for candidate, name in _CANONICAL_TYPE_NAMES:
+ if value_type is candidate:
+ return name
+ raise TypeError(f"Unsupported IR schema object: {value_type.__name__}.")
+
+
+def _canonical_float(value: float) -> object:
+ numeric = float(value)
+ if not isfinite(numeric):
+ raise ValueError("Canonical IR JSON does not permit NaN or infinity.")
+ if numeric == 0.0:
+ numeric = 0.0
+ return {"$float64": pack(">d", numeric).hex()}
+
+
+def _canonical_value(value: object) -> object:
+ if value is None or isinstance(value, bool):
+ return value
+ if isinstance(value, StrEnum):
+ return value.value
+ if isinstance(value, int):
+ return value
+ if isinstance(value, float):
+ return _canonical_float(value)
+ if isinstance(value, str):
+ return normalize("NFC", value)
+ if isinstance(value, tuple):
+ return [_canonical_value(item) for item in value]
+ if is_dataclass(value) and not isinstance(value, type):
+ payload: dict[str, object] = {"$type": _canonical_type_name(value)}
+ if isinstance(value, _OPERATION_TYPES):
+ payload["opcode"] = value.opcode.value
+ for item in fields(value):
+ payload[item.name] = _canonical_value(getattr(value, item.name))
+ return payload
+ raise TypeError(
+ "Canonical IR JSON accepts only schema dataclasses, tuples, enums, and "
+ f"scalar values; received {type(value).__name__}."
+ )
+
+
+def canonical_json_bytes(value: object) -> bytes:
+ """Return the exact platform-independent canonical JSON byte sequence."""
+
+ return json.dumps(
+ _canonical_value(value),
+ ensure_ascii=True,
+ allow_nan=False,
+ sort_keys=True,
+ separators=(",", ":"),
+ ).encode("utf-8")
+
+
+def native_artifact_key(
+ program: SystemIR,
+ build: IRNativeBuildIdentity,
+) -> str:
+ """Build cache key; target details never contaminate the program hash."""
+
+ if build.abi_version != NATIVE_NUMERIC_ABI_VERSION:
+ raise ValueError(
+ "Native build ABI does not match NATIVE_NUMERIC_ABI_VERSION."
+ )
+ for field_name, value in (
+ ("target_triple", build.target_triple),
+ ("compiler_id", build.compiler_id),
+ ("compiler_version", build.compiler_version),
+ ("floating_point_policy", build.floating_point_policy),
+ ):
+ if not value:
+ raise ValueError(f"Native build {field_name} must not be empty.")
+ if any(not flag for flag in build.compile_flags):
+ raise ValueError("Native build flags must not contain empty entries.")
+ if (
+ len(build.kernel_library_signature) != 64
+ or any(
+ character not in "0123456789abcdef"
+ for character in build.kernel_library_signature
+ )
+ ):
+ raise ValueError(
+ "Native kernel library signature must be lowercase SHA-256 hex."
+ )
+ payload = _IRNativeArtifactKeyInput(program.structural_signature, build)
+ return sha256(canonical_json_bytes(payload)).hexdigest()
diff --git a/app/simulation/ir/validation.py b/app/simulation/ir/validation.py
new file mode 100644
index 0000000..cf0564e
--- /dev/null
+++ b/app/simulation/ir/validation.py
@@ -0,0 +1,1583 @@
+"""Fail-closed static validation for System Numeric IR v2."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, fields, is_dataclass
+from math import isfinite
+from types import UnionType
+from typing import Union, get_args, get_origin, get_type_hints
+
+from app.simulation.ir import schema as ir
+
+
+SUPPORTED_REQUIRED_FEATURES = frozenset(
+ {
+ "callback_free",
+ "independent_entry_points",
+ "transactional_closure",
+ "fixed_csr_jacobian",
+ "reference_kernel_dispatch",
+ }
+)
+
+
+@dataclass(frozen=True, slots=True)
+class IRValidationIssue:
+ code: str
+ path: str
+ message: str
+
+
+@dataclass(frozen=True, slots=True)
+class IRValidationReport:
+ issues: tuple[IRValidationIssue, ...]
+
+ @property
+ def valid(self) -> bool:
+ return not self.issues
+
+
+class SystemIRValidationError(ValueError):
+ def __init__(self, report: IRValidationReport) -> None:
+ self.report = report
+ summary = "; ".join(
+ f"{issue.code} at {issue.path}: {issue.message}"
+ for issue in report.issues[:8]
+ )
+ if len(report.issues) > 8:
+ summary += f"; and {len(report.issues) - 8} more issue(s)"
+ super().__init__(f"System IR validation failed: {summary}")
+
+
+class _Issues:
+ def __init__(self) -> None:
+ self.items: list[IRValidationIssue] = []
+
+ def add(self, code: str, path: str, message: str) -> None:
+ self.items.append(IRValidationIssue(code, path, message))
+
+ def unique_ids(self, values: tuple[object, ...], attribute: str, path: str) -> None:
+ seen: dict[str, int] = {}
+ for index, value in enumerate(values):
+ identity = str(getattr(value, attribute))
+ if not identity:
+ self.add("EMPTY_ID", f"{path}[{index}].{attribute}", "ID must not be empty.")
+ if identity in seen:
+ self.add(
+ "DUPLICATE_ID",
+ f"{path}[{index}].{attribute}",
+ f"Duplicates {path}[{seen[identity]}].",
+ )
+ else:
+ seen[identity] = index
+
+
+def _finite(value: float | None) -> bool:
+ return value is None or isfinite(float(value))
+
+
+_TYPE_HINT_CACHE: dict[type[object], dict[str, object]] = {}
+
+
+def _matches_runtime_type(value: object, annotation: object) -> bool:
+ origin = get_origin(annotation)
+ if origin in {UnionType, Union}:
+ return any(_matches_runtime_type(value, item) for item in get_args(annotation))
+ if origin is tuple:
+ return type(value) is tuple
+ if annotation is type(None):
+ return value is None
+ if isinstance(annotation, type):
+ return type(value) is annotation
+ return False
+
+
+def _validate_runtime_types(
+ value: object,
+ annotation: object,
+ path: str,
+ issues: _Issues,
+ seen: set[tuple[int, object]],
+) -> None:
+ """Reject values that would serialize differently from their schema type."""
+
+ origin = get_origin(annotation)
+ if origin in {UnionType, Union}:
+ matching = tuple(
+ item for item in get_args(annotation) if _matches_runtime_type(value, item)
+ )
+ if len(matching) != 1:
+ issues.add(
+ "RUNTIME_TYPE_MISMATCH",
+ path,
+ f"Value has type {type(value).__name__}, which does not match the declared union.",
+ )
+ return
+ _validate_runtime_types(value, matching[0], path, issues, seen)
+ return
+ if origin is tuple:
+ if type(value) is not tuple:
+ issues.add(
+ "RUNTIME_TYPE_MISMATCH",
+ path,
+ "Schema arrays must be immutable tuples before serialization.",
+ )
+ return
+ arguments = get_args(annotation)
+ if len(arguments) == 2 and arguments[1] is Ellipsis:
+ for index, item in enumerate(value):
+ _validate_runtime_types(
+ item,
+ arguments[0],
+ f"{path}[{index}]",
+ issues,
+ seen,
+ )
+ return
+ if len(value) != len(arguments):
+ issues.add(
+ "RUNTIME_TYPE_MISMATCH",
+ path,
+ "Tuple length differs from its declared schema type.",
+ )
+ return
+ for index, (item, item_type) in enumerate(zip(value, arguments)):
+ _validate_runtime_types(
+ item,
+ item_type,
+ f"{path}[{index}]",
+ issues,
+ seen,
+ )
+ return
+ if annotation is type(None):
+ if value is not None:
+ issues.add("RUNTIME_TYPE_MISMATCH", path, "Expected null.")
+ return
+ if not isinstance(annotation, type) or type(value) is not annotation:
+ expected_name = getattr(annotation, "__name__", str(annotation))
+ issues.add(
+ "RUNTIME_TYPE_MISMATCH",
+ path,
+ f"Expected {expected_name}, received {type(value).__name__}.",
+ )
+ return
+ if not is_dataclass(value):
+ return
+ marker = (id(value), annotation)
+ if marker in seen:
+ return
+ seen.add(marker)
+ hints = _TYPE_HINT_CACHE.get(annotation)
+ if hints is None:
+ hints = get_type_hints(annotation)
+ _TYPE_HINT_CACHE[annotation] = hints
+ for item in fields(value):
+ _validate_runtime_types(
+ getattr(value, item.name),
+ hints[item.name],
+ f"{path}.{item.name}",
+ issues,
+ seen,
+ )
+
+
+def _validate_csr(
+ pattern: ir.IRCSRPattern,
+ path: str,
+ issues: _Issues,
+) -> None:
+ if pattern.row_count < 0 or pattern.column_count < 0:
+ issues.add("CSR_NEGATIVE_SHAPE", path, "CSR dimensions must be non-negative.")
+ pointers = pattern.row_pointers
+ columns = pattern.column_indices
+ if len(pointers) != pattern.row_count + 1:
+ issues.add("CSR_ROW_POINTER_LENGTH", f"{path}.row_pointers", "Length must be row_count + 1.")
+ return
+ if not pointers or pointers[0] != 0:
+ issues.add("CSR_ROW_POINTER_START", f"{path}.row_pointers", "First pointer must be zero.")
+ return
+ if any(first > second for first, second in zip(pointers, pointers[1:])):
+ issues.add("CSR_ROW_POINTER_ORDER", f"{path}.row_pointers", "Pointers must be monotonic.")
+ return
+ if pointers[-1] != len(columns):
+ issues.add("CSR_NONZERO_COUNT", path, "Final row pointer must equal column count length.")
+ return
+ for row in range(pattern.row_count):
+ row_columns = columns[pointers[row] : pointers[row + 1]]
+ if any(column < 0 or column >= pattern.column_count for column in row_columns):
+ issues.add("CSR_COLUMN_BOUNDS", f"{path}.row[{row}]", "Column index is out of bounds.")
+ if tuple(sorted(set(row_columns))) != row_columns:
+ issues.add("CSR_ROW_CANONICAL", f"{path}.row[{row}]", "Columns must be sorted and unique.")
+
+
+def _operation_slots(operation: ir.IROperation) -> tuple[ir.IRSlotRef, ...]:
+ return (*ir.operation_read_slots(operation), *ir.operation_write_slots(operation))
+
+
+def validate_system_ir(program: ir.SystemIR) -> IRValidationReport:
+ """Validate every cross-reference and numeric invariant in one program."""
+
+ issues = _Issues()
+ _validate_runtime_types(program, ir.SystemIR, "$", issues, set())
+ if issues.items:
+ return IRValidationReport(tuple(issues.items))
+ if program.version.schema_id != ir.SYSTEM_NUMERIC_IR_SCHEMA_ID:
+ issues.add("SCHEMA_ID_UNSUPPORTED", "version.schema_id", "Unknown schema ID.")
+ if program.version.major != ir.SYSTEM_NUMERIC_IR_SCHEMA_MAJOR:
+ issues.add("SCHEMA_MAJOR_UNSUPPORTED", "version.major", "Unknown schema major version.")
+ if program.version.minor < 0:
+ issues.add("SCHEMA_MINOR_INVALID", "version.minor", "Schema minor version cannot be negative.")
+ elif program.version.minor > ir.SYSTEM_NUMERIC_IR_SCHEMA_MINOR:
+ issues.add("SCHEMA_MINOR_UNSUPPORTED", "version.minor", "Schema minor version is newer than this reader.")
+ if program.numeric_dtype is not ir.IRDType.FLOAT64:
+ issues.add("NUMERIC_DTYPE_UNSUPPORTED", "numeric_dtype", "Only float64 programs are supported.")
+ for name, value in (
+ ("model_id", program.model_id),
+ ("model_version", program.model_version),
+ ("compiler_id", program.compiler_id),
+ ("compiler_version", program.compiler_version),
+ ):
+ if not value:
+ issues.add("EMPTY_ID", name, f"{name} must not be empty.")
+ unknown_features = sorted(set(program.required_features) - SUPPORTED_REQUIRED_FEATURES)
+ if unknown_features:
+ issues.add("REQUIRED_FEATURE_UNSUPPORTED", "required_features", ", ".join(unknown_features))
+ if len(set(program.required_features)) != len(program.required_features):
+ issues.add("DUPLICATE_REQUIRED_FEATURE", "required_features", "Features must be unique.")
+
+ buffer_by_kind: dict[ir.IRBufferKind, ir.IRBufferSpec] = {}
+ expected_buffer_dtypes = {
+ kind: (
+ ir.IRDType.INT32
+ if kind in {ir.IRBufferKind.MODE, ir.IRBufferKind.WORK_INT}
+ else ir.IRDType.FLOAT64
+ )
+ for kind in ir.IRBufferKind
+ }
+ for index, buffer in enumerate(program.buffers):
+ path = f"buffers[{index}]"
+ if buffer.kind in buffer_by_kind:
+ issues.add("DUPLICATE_BUFFER", path, f"Buffer {buffer.kind.value} is declared twice.")
+ buffer_by_kind[buffer.kind] = buffer
+ if buffer.size < 0:
+ issues.add("BUFFER_NEGATIVE_SIZE", f"{path}.size", "Size must be non-negative.")
+ if buffer.dtype is not expected_buffer_dtypes[buffer.kind]:
+ issues.add(
+ "BUFFER_DTYPE_INVALID",
+ f"{path}.dtype",
+ f"{buffer.kind.value} requires {expected_buffer_dtypes[buffer.kind].value}.",
+ )
+ if buffer.initial_float_values and buffer.initial_int_values:
+ issues.add("BUFFER_MIXED_INITIAL_VALUES", path, "A buffer cannot have both float and integer initial values.")
+ if buffer.initial_float_values and len(buffer.initial_float_values) != buffer.size:
+ issues.add("BUFFER_INITIAL_SIZE", path, "Float initial values must cover the full buffer.")
+ if buffer.initial_int_values and len(buffer.initial_int_values) != buffer.size:
+ issues.add("BUFFER_INITIAL_SIZE", path, "Integer initial values must cover the full buffer.")
+ if any(not isfinite(float(value)) for value in buffer.initial_float_values):
+ issues.add("NONFINITE_VALUE", path, "Buffer initial values must be finite.")
+ if buffer.dtype is ir.IRDType.FLOAT64 and buffer.initial_int_values:
+ issues.add("BUFFER_INITIAL_TYPE", path, "float64 buffer cannot use integer initial values.")
+ if buffer.dtype is not ir.IRDType.FLOAT64 and buffer.initial_float_values:
+ issues.add("BUFFER_INITIAL_TYPE", path, "Integer buffer cannot use float initial values.")
+ if any(value < -(2**31) or value > 2**31 - 1 for value in buffer.initial_int_values):
+ issues.add("BUFFER_INT32_RANGE", path, "Integer initial values must fit signed int32.")
+
+ required_buffers = set(ir.IRBufferKind)
+ for kind in sorted(required_buffers - set(buffer_by_kind), key=lambda value: value.value):
+ issues.add("MISSING_BUFFER", "buffers", f"Missing {kind.value} buffer.")
+ time_buffer = buffer_by_kind.get(ir.IRBufferKind.TIME)
+ if time_buffer is not None and time_buffer.size != 1:
+ issues.add("TIME_BUFFER_SIZE", "buffers", "The time buffer must contain exactly one float64 value.")
+
+ def valid_slot(slot: ir.IRSlotRef, path: str) -> bool:
+ buffer = buffer_by_kind.get(slot.buffer)
+ if buffer is None:
+ issues.add("SLOT_BUFFER_MISSING", path, f"Buffer {slot.buffer.value} is not declared.")
+ return False
+ if slot.index < 0 or slot.index >= buffer.size:
+ issues.add("SLOT_INDEX_BOUNDS", path, f"Index {slot.index} is outside buffer size {buffer.size}.")
+ return False
+ return True
+
+ issues.unique_ids(program.values, "value_id", "values")
+ value_slots: set[ir.IRSlotRef] = set()
+ for index, value in enumerate(program.values):
+ path = f"values[{index}]"
+ valid_slot(value.slot, f"{path}.slot")
+ if value.slot in value_slots:
+ issues.add("DUPLICATE_VALUE_SLOT", f"{path}.slot", "A numeric slot has more than one value descriptor.")
+ value_slots.add(value.slot)
+ if not value.semantic or not value.role or not value.quantity:
+ issues.add(
+ "VALUE_METADATA_INCOMPLETE",
+ path,
+ "Value semantic, role and quantity must not be empty.",
+ )
+ if not isfinite(value.scale) or value.scale <= 0.0:
+ issues.add("VALUE_SCALE_INVALID", f"{path}.scale", "Scale must be finite and positive.")
+ if not _finite(value.lower_bound) or not _finite(value.upper_bound):
+ issues.add("NONFINITE_VALUE", path, "Bounds must be finite or null.")
+ if value.lower_bound is not None and value.upper_bound is not None and value.lower_bound > value.upper_bound:
+ issues.add("VALUE_BOUNDS_ORDER", path, "Lower bound exceeds upper bound.")
+ if value.owner_component_index is not None and not 0 <= value.owner_component_index < len(program.components):
+ issues.add("COMPONENT_INDEX_BOUNDS", f"{path}.owner_component_index", "Component index is invalid.")
+ expected_value_slots = {
+ ir.IRSlotRef(buffer.kind, index)
+ for buffer in program.buffers
+ for index in range(max(0, buffer.size))
+ }
+ if value_slots != expected_value_slots:
+ issues.add(
+ "VALUE_SLOT_COVERAGE",
+ "values",
+ "Value descriptors must cover every declared buffer slot exactly once.",
+ )
+
+ issues.unique_ids(program.kernels, "kernel_id", "kernels")
+ for index, kernel in enumerate(program.kernels):
+ path = f"kernels[{index}]"
+ if not kernel.model_type or not kernel.model_version or not kernel.implementation_version:
+ issues.add("KERNEL_IDENTITY_INCOMPLETE", path, "Kernel type and versions are required.")
+ counts = (
+ kernel.parameter_count,
+ kernel.state_count,
+ kernel.mode_count,
+ kernel.workspace_float_count,
+ kernel.workspace_int_count,
+ )
+ if any(count < 0 for count in counts):
+ issues.add("KERNEL_NEGATIVE_ARITY", path, "Kernel arities must be non-negative.")
+ phases = tuple(phase.phase for phase in kernel.phases)
+ if not phases:
+ issues.add("KERNEL_PHASE_MISSING", f"{path}.phases", "Every kernel must declare at least one phase.")
+ if len(set(phases)) != len(phases):
+ issues.add("KERNEL_DUPLICATE_PHASE", f"{path}.phases", "Kernel phases must be unique.")
+ if kernel.availability is ir.IRKernelAvailability.NATIVE and kernel.unavailable_reason is not None:
+ issues.add("KERNEL_AVAILABILITY_CONFLICT", path, "Native kernel cannot have an unavailable reason.")
+ if kernel.availability is ir.IRKernelAvailability.REFERENCE_ONLY and not kernel.unavailable_reason:
+ issues.add("KERNEL_AVAILABILITY_REASON_MISSING", path, "Reference-only kernel needs a reason.")
+
+ issues.unique_ids(program.components, "instance_id", "components")
+ for index, component in enumerate(program.components):
+ path = f"components[{index}]"
+ if not 0 <= component.kernel_index < len(program.kernels):
+ issues.add("KERNEL_INDEX_BOUNDS", f"{path}.kernel_index", "Kernel index is invalid.")
+ continue
+ kernel = program.kernels[component.kernel_index]
+ for slot_index, slot in enumerate(
+ (*component.parameter_slots, *component.state_slots, *component.derivative_slots,
+ *component.mode_slots, *component.port_slots, *component.workspace_float_slots,
+ *component.workspace_int_slots)
+ ):
+ valid_slot(slot, f"{path}.slots[{slot_index}]")
+ binding_kinds = (
+ (component.parameter_slots, ir.IRBufferKind.PARAMETER, "parameter_slots"),
+ (component.state_slots, ir.IRBufferKind.LOCAL_STATE, "state_slots"),
+ (
+ component.derivative_slots,
+ ir.IRBufferKind.LOCAL_DERIVATIVE,
+ "derivative_slots",
+ ),
+ (component.mode_slots, ir.IRBufferKind.MODE, "mode_slots"),
+ (
+ component.workspace_float_slots,
+ ir.IRBufferKind.WORK_FLOAT,
+ "workspace_float_slots",
+ ),
+ (
+ component.workspace_int_slots,
+ ir.IRBufferKind.WORK_INT,
+ "workspace_int_slots",
+ ),
+ )
+ for bound_slots, expected_kind, label in binding_kinds:
+ if any(slot.buffer is not expected_kind for slot in bound_slots):
+ issues.add(
+ "COMPONENT_BINDING_BUFFER",
+ f"{path}.{label}",
+ f"Bindings must use the {expected_kind.value} buffer.",
+ )
+ if len(set(bound_slots)) != len(bound_slots):
+ issues.add(
+ "COMPONENT_BINDING_DUPLICATE",
+ f"{path}.{label}",
+ "Bindings within one component must be unique.",
+ )
+ if len(component.parameter_slots) != kernel.parameter_count:
+ issues.add("KERNEL_PARAMETER_ARITY", path, "Component parameter binding count differs from kernel.")
+ if len(component.state_slots) != kernel.state_count or len(component.derivative_slots) != kernel.state_count:
+ issues.add("KERNEL_STATE_ARITY", path, "Component state bindings differ from kernel.")
+ if len(component.mode_slots) != kernel.mode_count:
+ issues.add("KERNEL_MODE_ARITY", path, "Component mode bindings differ from kernel.")
+ if len(component.workspace_float_slots) != kernel.workspace_float_count:
+ issues.add("KERNEL_WORKSPACE_ARITY", path, "Float workspace bindings differ from kernel.")
+ if len(component.workspace_int_slots) != kernel.workspace_int_count:
+ issues.add("KERNEL_WORKSPACE_ARITY", path, "Integer workspace bindings differ from kernel.")
+ if any(port < 0 or port >= len(program.ports) for port in component.port_indices):
+ issues.add("PORT_INDEX_BOUNDS", f"{path}.port_indices", "Port index is invalid.")
+ if any(output < 0 or output >= len(program.outputs) for output in component.output_indices):
+ issues.add("OUTPUT_INDEX_BOUNDS", f"{path}.output_indices", "Output index is invalid.")
+
+ issues.unique_ids(program.ports, "port_id", "ports")
+ port_variable_ids: set[str] = set()
+ for index, port in enumerate(program.ports):
+ path = f"ports[{index}]"
+ if not port.name or not port.domain:
+ issues.add("PORT_METADATA_INCOMPLETE", path, "Port name and domain must not be empty.")
+ if not 0 <= port.component_index < len(program.components):
+ issues.add("COMPONENT_INDEX_BOUNDS", f"{path}.component_index", "Component index is invalid.")
+ if port.kind is ir.IRPortKind.PHYSICAL and port.positive_flow_direction is None:
+ issues.add("PORT_FLOW_DIRECTION_MISSING", path, "Physical ports need a positive-flow direction.")
+ if port.kind is ir.IRPortKind.SIGNAL and port.positive_flow_direction is not None:
+ issues.add("PORT_FLOW_DIRECTION_INVALID", path, "Signal ports cannot define physical flow direction.")
+ names: set[str] = set()
+ for variable_index, variable in enumerate(port.variables):
+ variable_path = f"{path}.variables[{variable_index}]"
+ if not variable.variable_id or not variable.name:
+ issues.add("PORT_VARIABLE_ID_INVALID", variable_path, "Port variable names and IDs must be non-empty.")
+ if not variable.quantity:
+ issues.add("PORT_VARIABLE_METADATA_INCOMPLETE", variable_path, "Port variable quantity must not be empty.")
+ if variable.variable_id in port_variable_ids:
+ issues.add("PORT_VARIABLE_ID_INVALID", variable_path, "Port variable ID must be globally unique.")
+ port_variable_ids.add(variable.variable_id)
+ if variable.name in names:
+ issues.add("PORT_VARIABLE_ID_INVALID", variable_path, "Port variable name must be unique within its port.")
+ names.add(variable.name)
+ valid_slot(variable.slot, f"{variable_path}.slot")
+ expected_rule = {
+ ir.IRVariableRole.EFFORT: ir.IRConnectionRule.EQUAL,
+ ir.IRVariableRole.FLOW: ir.IRConnectionRule.SUM_TO_ZERO,
+ ir.IRVariableRole.STREAM: ir.IRConnectionRule.STREAM_MIX,
+ ir.IRVariableRole.SIGNAL: ir.IRConnectionRule.DIRECTED,
+ }[variable.role]
+ if variable.connection_rule is not expected_rule:
+ issues.add(
+ "PORT_VARIABLE_RULE_INVALID",
+ variable_path,
+ f"{variable.role.value} variables require {expected_rule.value}.",
+ )
+ if 0 <= port.component_index < len(program.components) and index not in program.components[port.component_index].port_indices:
+ issues.add("COMPONENT_PORT_BACKREF", path, "Owning component does not reference this port.")
+
+ for component_index, component in enumerate(program.components):
+ expected_port_indices = tuple(
+ index
+ for index, port in enumerate(program.ports)
+ if port.component_index == component_index
+ )
+ if component.port_indices != expected_port_indices:
+ issues.add(
+ "COMPONENT_PORT_COVERAGE",
+ f"components[{component_index}].port_indices",
+ "Component port indices must exactly cover its owned ports in program order.",
+ )
+ if all(0 <= index < len(program.ports) for index in component.port_indices):
+ expected_port_slots = tuple(
+ variable.slot
+ for port_index in component.port_indices
+ for variable in program.ports[port_index].variables
+ )
+ if component.port_slots != expected_port_slots:
+ issues.add(
+ "COMPONENT_PORT_SLOT_COVERAGE",
+ f"components[{component_index}].port_slots",
+ "Component port slots must match its ordered port-variable bindings.",
+ )
+
+ issues.unique_ids(program.connections, "connection_id", "connections")
+ connected_port_indices: list[int] = []
+ endpoint_pairs: set[tuple[int, int]] = set()
+ for index, connection in enumerate(program.connections):
+ path = f"connections[{index}]"
+ endpoints = (connection.endpoint_a_port_index, connection.endpoint_b_port_index)
+ if endpoints[0] == endpoints[1]:
+ issues.add("CONNECTION_SELF_LOOP", path, "Connection endpoints must be distinct ports.")
+ if any(endpoint < 0 or endpoint >= len(program.ports) for endpoint in endpoints):
+ issues.add("PORT_INDEX_BOUNDS", path, "Connection endpoint is invalid.")
+ continue
+ connected_port_indices.extend(endpoints)
+ endpoint_pair = tuple(sorted(endpoints))
+ if endpoint_pair in endpoint_pairs:
+ issues.add("CONNECTION_DUPLICATE", path, "The same port pair is connected more than once.")
+ endpoint_pairs.add(endpoint_pair)
+ first, second = (program.ports[endpoint] for endpoint in endpoints)
+ if first.kind is not connection.kind or second.kind is not connection.kind or first.domain != connection.domain or second.domain != connection.domain:
+ issues.add("CONNECTION_DOMAIN_MISMATCH", path, "Connection kind/domain differs from an endpoint.")
+ first_variables = {variable.name: variable for variable in first.variables}
+ second_variables = {variable.name: variable for variable in second.variables}
+ connection_names = tuple(variable.name for variable in connection.variables)
+ if (
+ len(set(connection_names)) != len(connection_names)
+ or set(connection_names) != set(first_variables)
+ or set(connection_names) != set(second_variables)
+ ):
+ issues.add(
+ "CONNECTION_VARIABLE_COVERAGE",
+ f"{path}.variables",
+ "Connection variables must cover both endpoint contracts exactly once.",
+ )
+ for variable_index, variable in enumerate(connection.variables):
+ variable_path = f"{path}.variables[{variable_index}]"
+ valid_slot(variable.endpoint_a_slot, f"{variable_path}.endpoint_a_slot")
+ valid_slot(variable.endpoint_b_slot, f"{variable_path}.endpoint_b_slot")
+ if variable.name not in first_variables or variable.name not in second_variables:
+ issues.add("CONNECTION_VARIABLE_MISSING", variable_path, "Variable is absent from an endpoint.")
+ elif (
+ first_variables[variable.name].slot != variable.endpoint_a_slot
+ or second_variables[variable.name].slot != variable.endpoint_b_slot
+ or first_variables[variable.name].connection_rule is not variable.rule
+ or second_variables[variable.name].connection_rule is not variable.rule
+ ):
+ issues.add("CONNECTION_VARIABLE_MISMATCH", variable_path, "Connection variable does not match its ports.")
+ connection_counts: dict[int, int] = {}
+ for port_index in connected_port_indices:
+ connection_counts[port_index] = connection_counts.get(port_index, 0) + 1
+ for port_index, count in connection_counts.items():
+ port = program.ports[port_index]
+ allows_fan_out = (
+ port.kind is ir.IRPortKind.SIGNAL
+ and port.nominal_role is ir.IRPortNominalRole.OUTPUT
+ )
+ if count > 1 and not allows_fan_out:
+ issues.add(
+ "CONNECTION_PORT_REUSED",
+ f"ports[{port_index}]",
+ "Physical ports and signal inputs may have at most one connection.",
+ )
+
+ issues.unique_ids(program.mediums, "medium_id", "mediums")
+ for index, medium in enumerate(program.mediums):
+ path = f"mediums[{index}]"
+ if not medium.name or not medium.implementation_id or not medium.implementation_version:
+ issues.add("MEDIUM_IDENTITY_INCOMPLETE", path, "Medium identity is incomplete.")
+ if len(set(medium.parameter_slots)) != len(medium.parameter_slots):
+ issues.add("MEDIUM_PARAMETER_DUPLICATE", f"{path}.parameter_slots", "Medium parameter slots must be unique.")
+ if len(set(medium.component_indices)) != len(medium.component_indices):
+ issues.add("MEDIUM_COMPONENT_DUPLICATE", f"{path}.component_indices", "Medium component indices must be unique.")
+ for slot_index, slot in enumerate(medium.parameter_slots):
+ valid_slot(slot, f"{path}.parameter_slots[{slot_index}]")
+ if slot.buffer is not ir.IRBufferKind.CONSTANT:
+ issues.add("MEDIUM_PARAMETER_BUFFER", f"{path}.parameter_slots[{slot_index}]", "Medium constants must use the constant buffer.")
+ if any(component < 0 or component >= len(program.components) for component in medium.component_indices):
+ issues.add("COMPONENT_INDEX_BOUNDS", f"{path}.component_indices", "Medium component index is invalid.")
+
+ reducer = program.state_reducer
+ state_buffer = buffer_by_kind.get(ir.IRBufferKind.STATE_INPUT)
+ derivative_buffer = buffer_by_kind.get(ir.IRBufferKind.DERIVATIVE_OUTPUT)
+ if state_buffer is not None and state_buffer.size != reducer.solver_state_count:
+ issues.add("STATE_COUNT_MISMATCH", "state_reducer", "State buffer size differs from reducer count.")
+ if derivative_buffer is not None and derivative_buffer.size != reducer.solver_state_count:
+ issues.add("DERIVATIVE_COUNT_MISMATCH", "state_reducer", "Derivative buffer size differs from reducer count.")
+ if len(reducer.initial_state) != reducer.solver_state_count or len(reducer.absolute_tolerances) != reducer.solver_state_count:
+ issues.add("STATE_METADATA_LENGTH", "state_reducer", "Initial state and tolerances must cover every state.")
+ if any(not isfinite(value) for value in reducer.initial_state):
+ issues.add("NONFINITE_VALUE", "state_reducer.initial_state", "Initial state must be finite.")
+ if (
+ state_buffer is not None
+ and state_buffer.initial_float_values != reducer.initial_state
+ ):
+ issues.add(
+ "STATE_INITIAL_VALUE_MISMATCH",
+ "state_reducer.initial_state",
+ "State-input buffer initial values must exactly equal the reducer initial state.",
+ )
+ if any(not isfinite(value) or value <= 0.0 for value in reducer.absolute_tolerances):
+ issues.add("STATE_TOLERANCE_INVALID", "state_reducer.absolute_tolerances", "Tolerances must be finite and positive.")
+ for index, slot in enumerate(reducer.local_state_slots):
+ valid_slot(slot, f"state_reducer.local_state_slots[{index}]")
+ if slot.buffer is not ir.IRBufferKind.LOCAL_STATE:
+ issues.add("STATE_REDUCER_BUFFER", f"state_reducer.local_state_slots[{index}]", "Local states must use the local_state buffer.")
+ for index, slot in enumerate(reducer.raw_derivative_slots):
+ valid_slot(slot, f"state_reducer.raw_derivative_slots[{index}]")
+ if slot.buffer is not ir.IRBufferKind.LOCAL_DERIVATIVE:
+ issues.add("STATE_REDUCER_BUFFER", f"state_reducer.raw_derivative_slots[{index}]", "Raw derivatives must use the local_derivative buffer.")
+ expected_local_state_slots = tuple(
+ slot for component in program.components for slot in component.state_slots
+ )
+ expected_raw_derivative_slots = tuple(
+ slot for component in program.components for slot in component.derivative_slots
+ )
+ if reducer.local_state_slots != expected_local_state_slots:
+ issues.add(
+ "STATE_REDUCER_LOCAL_COVERAGE",
+ "state_reducer.local_state_slots",
+ "Reducer local-state order must exactly match component state bindings.",
+ )
+ if reducer.raw_derivative_slots != expected_raw_derivative_slots:
+ issues.add(
+ "STATE_REDUCER_DERIVATIVE_COVERAGE",
+ "state_reducer.raw_derivative_slots",
+ "Reducer derivative order must exactly match component derivative bindings.",
+ )
+ _validate_csr(reducer.state_scatter.pattern, "state_reducer.state_scatter.pattern", issues)
+ _validate_csr(reducer.derivative_gather.pattern, "state_reducer.derivative_gather.pattern", issues)
+ if reducer.state_scatter.pattern.row_count != len(reducer.local_state_slots) or reducer.state_scatter.pattern.column_count != reducer.solver_state_count:
+ issues.add("STATE_SCATTER_SHAPE", "state_reducer.state_scatter", "Scatter shape must be local_state_count x solver_state_count.")
+ if reducer.derivative_gather.pattern.row_count != reducer.solver_state_count or reducer.derivative_gather.pattern.column_count != len(reducer.raw_derivative_slots):
+ issues.add("DERIVATIVE_GATHER_SHAPE", "state_reducer.derivative_gather", "Gather shape must be solver_state_count x raw_derivative_count.")
+ if len(reducer.state_scatter.values) != reducer.state_scatter.pattern.nonzero_count:
+ issues.add("CSR_VALUE_COUNT", "state_reducer.state_scatter.values", "Value count differs from pattern.")
+ if len(reducer.derivative_gather.values) != reducer.derivative_gather.pattern.nonzero_count:
+ issues.add("CSR_VALUE_COUNT", "state_reducer.derivative_gather.values", "Value count differs from pattern.")
+ if any(not isfinite(value) for value in (*reducer.state_scatter.values, *reducer.derivative_gather.values)):
+ issues.add("NONFINITE_VALUE", "state_reducer", "Reducer matrix values must be finite.")
+ if (
+ reducer.state_scatter.pattern.row_pointers
+ and any(
+ first == second
+ for first, second in zip(
+ reducer.state_scatter.pattern.row_pointers,
+ reducer.state_scatter.pattern.row_pointers[1:],
+ )
+ )
+ ):
+ issues.add("STATE_SCATTER_EMPTY_ROW", "state_reducer.state_scatter", "Every local state must depend on at least one solver state.")
+ if set(reducer.state_scatter.pattern.column_indices) != set(
+ range(reducer.solver_state_count)
+ ):
+ issues.add("STATE_SCATTER_COLUMN_COVERAGE", "state_reducer.state_scatter", "Every solver state must feed at least one local state.")
+ if set(reducer.derivative_gather.pattern.column_indices) != set(
+ range(len(reducer.raw_derivative_slots))
+ ):
+ issues.add("DERIVATIVE_GATHER_COLUMN_COVERAGE", "state_reducer.derivative_gather", "Every raw derivative must contribute to a solver derivative.")
+ if (
+ reducer.derivative_gather.pattern.row_pointers
+ and any(
+ first == second
+ for first, second in zip(
+ reducer.derivative_gather.pattern.row_pointers,
+ reducer.derivative_gather.pattern.row_pointers[1:],
+ )
+ )
+ ):
+ issues.add("DERIVATIVE_GATHER_EMPTY_ROW", "state_reducer.derivative_gather", "Every solver state must receive at least one derivative contribution.")
+
+ pressure_flow = program.pressure_flow
+ issues.unique_ids(pressure_flow.unknowns, "unknown_id", "pressure_flow.unknowns")
+ issues.unique_ids(pressure_flow.equations, "equation_id", "pressure_flow.equations")
+ issues.unique_ids(pressure_flow.blocks, "block_id", "pressure_flow.blocks")
+ issues.unique_ids(pressure_flow.scopes, "scope_id", "pressure_flow.scopes")
+ for index, unknown in enumerate(pressure_flow.unknowns):
+ path = f"pressure_flow.unknowns[{index}]"
+ valid_slot(unknown.slot, f"{path}.slot")
+ if unknown.slot.buffer is not ir.IRBufferKind.ALGEBRAIC:
+ issues.add("PRESSURE_FLOW_UNKNOWN_BUFFER", f"{path}.slot", "Pressure-flow unknowns must use the algebraic buffer.")
+ if not 0 <= unknown.component_index < len(program.components) or not 0 <= unknown.port_index < len(program.ports):
+ issues.add("PRESSURE_FLOW_UNKNOWN_OWNER", path, "Unknown owner is invalid.")
+ else:
+ port = program.ports[unknown.port_index]
+ matching_variables = tuple(
+ variable
+ for variable in port.variables
+ if variable.name == unknown.variable
+ )
+ if (
+ port.component_index != unknown.component_index
+ or len(matching_variables) != 1
+ or matching_variables[0].variable_id != unknown.unknown_id
+ or matching_variables[0].role is not unknown.role
+ or matching_variables[0].slot != unknown.slot
+ ):
+ issues.add(
+ "PRESSURE_FLOW_UNKNOWN_OWNER",
+ path,
+ "Unknown must match one variable on a port owned by its component.",
+ )
+ if not isfinite(unknown.scale) or unknown.scale <= 0.0:
+ issues.add("VALUE_SCALE_INVALID", f"{path}.scale", "Scale must be finite and positive.")
+ if not _finite(unknown.lower_bound) or not _finite(unknown.upper_bound):
+ issues.add("NONFINITE_VALUE", path, "Unknown bounds must be finite or null.")
+ if unknown.lower_bound is not None and unknown.upper_bound is not None and unknown.lower_bound > unknown.upper_bound:
+ issues.add("VALUE_BOUNDS_ORDER", path, "Unknown lower bound exceeds upper bound.")
+ residual_slots: set[ir.IRSlotRef] = set()
+ for index, equation in enumerate(pressure_flow.equations):
+ path = f"pressure_flow.equations[{index}]"
+ valid_slot(equation.residual_slot, f"{path}.residual_slot")
+ if equation.residual_slot in residual_slots:
+ issues.add(
+ "PRESSURE_FLOW_RESIDUAL_SLOT_DUPLICATE",
+ f"{path}.residual_slot",
+ "Every pressure-flow equation requires a unique residual slot.",
+ )
+ residual_slots.add(equation.residual_slot)
+ if equation.residual_slot.buffer is not ir.IRBufferKind.WORK_FLOAT:
+ issues.add("EQUATION_RESIDUAL_BUFFER", f"{path}.residual_slot", "Equation residuals must use the float workspace.")
+ for slot_index, slot in enumerate(equation.variable_slots):
+ valid_slot(slot, f"{path}.variable_slots[{slot_index}]")
+ if slot.buffer not in {
+ ir.IRBufferKind.ALGEBRAIC,
+ ir.IRBufferKind.LOCAL_STATE,
+ ir.IRBufferKind.SIGNAL,
+ ir.IRBufferKind.PARAMETER,
+ ir.IRBufferKind.CONSTANT,
+ ir.IRBufferKind.MODE,
+ ir.IRBufferKind.RUNTIME_INPUT,
+ }:
+ issues.add(
+ "EQUATION_VARIABLE_BUFFER",
+ f"{path}.variable_slots[{slot_index}]",
+ "Equation variables must be model values, not time or result buffers.",
+ )
+ if len(set(equation.variable_slots)) != len(equation.variable_slots):
+ issues.add("EQUATION_VARIABLE_DUPLICATE", f"{path}.variable_slots", "Equation variables must be unique.")
+ owner_count = len(program.components) if equation.owner is ir.IREquationOwner.COMPONENT else len(program.connections)
+ if not 0 <= equation.owner_index < owner_count:
+ issues.add("EQUATION_OWNER_BOUNDS", path, "Equation owner index is invalid.")
+ if not isfinite(equation.scale) or equation.scale <= 0.0:
+ issues.add("VALUE_SCALE_INVALID", f"{path}.scale", "Equation scale must be finite and positive.")
+ for index, block in enumerate(pressure_flow.blocks):
+ path = f"pressure_flow.blocks[{index}]"
+ if len(set(block.unknown_indices)) != len(block.unknown_indices):
+ issues.add("ALGEBRAIC_BLOCK_DUPLICATE", f"{path}.unknown_indices", "Block unknowns must be unique.")
+ if len(set(block.equation_indices)) != len(block.equation_indices):
+ issues.add("ALGEBRAIC_BLOCK_DUPLICATE", f"{path}.equation_indices", "Block equations must be unique.")
+ if any(item < 0 or item >= len(pressure_flow.unknowns) for item in block.unknown_indices):
+ issues.add("UNKNOWN_INDEX_BOUNDS", path, "Block unknown index is invalid.")
+ if any(item < 0 or item >= len(pressure_flow.equations) for item in block.equation_indices):
+ issues.add("EQUATION_INDEX_BOUNDS", path, "Block equation index is invalid.")
+ _validate_csr(block.jacobian_pattern, f"{path}.jacobian_pattern", issues)
+ if block.jacobian_pattern.row_count != len(block.equation_indices) or block.jacobian_pattern.column_count != len(block.unknown_indices):
+ issues.add("ALGEBRAIC_BLOCK_SHAPE", path, "Block Jacobian shape differs from its rows/columns.")
+ block_unknowns = tuple(
+ item for block in pressure_flow.blocks for item in block.unknown_indices
+ )
+ block_equations = tuple(
+ item for block in pressure_flow.blocks for item in block.equation_indices
+ )
+ if (
+ tuple(sorted(block_unknowns)) != tuple(range(len(pressure_flow.unknowns)))
+ or len(set(block_unknowns)) != len(block_unknowns)
+ ):
+ issues.add("ALGEBRAIC_BLOCK_UNKNOWN_PARTITION", "pressure_flow.blocks", "Blocks must partition all pressure-flow unknowns.")
+ if (
+ tuple(sorted(block_equations)) != tuple(range(len(pressure_flow.equations)))
+ or len(set(block_equations)) != len(block_equations)
+ ):
+ issues.add("ALGEBRAIC_BLOCK_EQUATION_PARTITION", "pressure_flow.blocks", "Blocks must partition all pressure-flow equations.")
+ for index, scope in enumerate(pressure_flow.scopes):
+ path = f"pressure_flow.scopes[{index}]"
+ bounds = (
+ (scope.component_indices, len(program.components), "component"),
+ (scope.unknown_indices, len(pressure_flow.unknowns), "unknown"),
+ (scope.equation_indices, len(pressure_flow.equations), "equation"),
+ (scope.block_indices, len(pressure_flow.blocks), "block"),
+ )
+ for indexes, upper, label in bounds:
+ if any(item < 0 or item >= upper for item in indexes):
+ issues.add(f"{label.upper()}_INDEX_BOUNDS", path, f"Scope {label} index is invalid.")
+ if len(set(indexes)) != len(indexes):
+ issues.add(f"{label.upper()}_INDEX_DUPLICATE", path, f"Scope {label} indices must be unique.")
+ if scope.causal_plan_index is not None and not 0 <= scope.causal_plan_index < len(program.causal_plans):
+ issues.add("CAUSAL_PLAN_INDEX_BOUNDS", path, "Causal plan index is invalid.")
+ if not isfinite(scope.residual_tolerance) or scope.residual_tolerance <= 0.0 or scope.max_evaluations <= 0:
+ issues.add("PRESSURE_FLOW_LIMIT_INVALID", path, "Solver limits must be positive and finite.")
+ if scope.sparse_pattern_trusted == (scope.sparse_fallback_reason is not None):
+ issues.add(
+ "PRESSURE_FLOW_SPARSE_STATUS",
+ path,
+ "A trusted sparse pattern must have no fallback reason; an untrusted one must explain its fallback.",
+ )
+ if all(0 <= item < len(pressure_flow.blocks) for item in scope.block_indices):
+ scoped_unknowns = {
+ item
+ for block_index in scope.block_indices
+ for item in pressure_flow.blocks[block_index].unknown_indices
+ }
+ scoped_equations = {
+ item
+ for block_index in scope.block_indices
+ for item in pressure_flow.blocks[block_index].equation_indices
+ }
+ if scoped_unknowns != set(scope.unknown_indices):
+ issues.add("PRESSURE_FLOW_SCOPE_UNKNOWN_COVERAGE", path, "Scope unknowns must equal its blocks' unknowns.")
+ if scoped_equations != set(scope.equation_indices):
+ issues.add("PRESSURE_FLOW_SCOPE_EQUATION_COVERAGE", path, "Scope equations must equal its blocks' equations.")
+ if not pressure_flow.scopes or not 0 <= pressure_flow.global_scope_index < len(pressure_flow.scopes):
+ issues.add("GLOBAL_SCOPE_INDEX_BOUNDS", "pressure_flow.global_scope_index", "Global scope is invalid.")
+ else:
+ global_scope = pressure_flow.scopes[pressure_flow.global_scope_index]
+ if (
+ global_scope.kind is not ir.IRPressureFlowScopeKind.NETWORK
+ or set(global_scope.component_indices) != set(range(len(program.components)))
+ or set(global_scope.unknown_indices) != set(range(len(pressure_flow.unknowns)))
+ or set(global_scope.equation_indices) != set(range(len(pressure_flow.equations)))
+ or set(global_scope.block_indices) != set(range(len(pressure_flow.blocks)))
+ ):
+ issues.add("GLOBAL_SCOPE_COVERAGE", "pressure_flow.global_scope_index", "Global scope must cover the complete network.")
+ if any(index < 0 or index >= len(pressure_flow.scopes) for index in pressure_flow.secondary_scope_indices):
+ issues.add("SECONDARY_SCOPE_INDEX_BOUNDS", "pressure_flow.secondary_scope_indices", "Secondary scope is invalid.")
+ if (
+ len(set(pressure_flow.secondary_scope_indices))
+ != len(pressure_flow.secondary_scope_indices)
+ or pressure_flow.global_scope_index in pressure_flow.secondary_scope_indices
+ ):
+ issues.add("SECONDARY_SCOPE_INDEX_INVALID", "pressure_flow.secondary_scope_indices", "Secondary scopes must be unique and exclude the global scope.")
+ if not isfinite(pressure_flow.pressure_lower_bound):
+ issues.add("NONFINITE_VALUE", "pressure_flow.pressure_lower_bound", "Pressure bound must be finite.")
+
+ issues.unique_ids(program.stages, "stage_id", "stages")
+ for stage_index, stage in enumerate(program.stages):
+ path = f"stages[{stage_index}]"
+ reads: list[ir.IRSlotRef] = []
+ writes: list[ir.IRSlotRef] = []
+ for operation_index, operation in enumerate(stage.operations):
+ operation_path = f"{path}.operations[{operation_index}]"
+ for slot_index, slot in enumerate(_operation_slots(operation)):
+ valid_slot(slot, f"{operation_path}.slots[{slot_index}]")
+ reads.extend(ir.operation_read_slots(operation))
+ writes.extend(ir.operation_write_slots(operation))
+ if isinstance(operation, ir.IRLinearCombinationOperation):
+ if len(operation.source_slots) != len(operation.weights) or any(not isfinite(value) for value in (*operation.weights, operation.bias)):
+ issues.add("LINEAR_COMBINATION_INVALID", operation_path, "Weights must be finite and match sources.")
+ if isinstance(operation, ir.IRKernelCallOperation):
+ if len(set(operation.read_slots)) != len(operation.read_slots):
+ issues.add("KERNEL_CALL_READ_DUPLICATE", operation_path, "Kernel read slots must be unique.")
+ if len(set(operation.write_slots)) != len(operation.write_slots):
+ issues.add("KERNEL_CALL_WRITE_DUPLICATE", operation_path, "Kernel write slots must be unique.")
+ if not 0 <= operation.kernel_index < len(program.kernels):
+ issues.add("KERNEL_INDEX_BOUNDS", operation_path, "Operation kernel index is invalid.")
+ else:
+ phases = {phase.phase for phase in program.kernels[operation.kernel_index].phases}
+ if operation.phase not in phases:
+ issues.add("KERNEL_PHASE_UNDECLARED", operation_path, "Operation uses an undeclared kernel phase.")
+ if operation.component_index is not None and not 0 <= operation.component_index < len(program.components):
+ issues.add("COMPONENT_INDEX_BOUNDS", operation_path, "Operation component index is invalid.")
+ elif (
+ operation.component_index is not None
+ and 0 <= operation.kernel_index < len(program.kernels)
+ and program.components[operation.component_index].kernel_index
+ != operation.kernel_index
+ ):
+ issues.add(
+ "KERNEL_COMPONENT_MISMATCH",
+ operation_path,
+ "Component-bound operation must use the component's declared kernel.",
+ )
+ if any(item < 0 or item >= len(pressure_flow.equations) for item in operation.equation_indices):
+ issues.add("EQUATION_INDEX_BOUNDS", operation_path, "Operation equation index is invalid.")
+ if len(set(operation.equation_indices)) != len(operation.equation_indices):
+ issues.add("EQUATION_INDEX_DUPLICATE", operation_path, "Operation equation indices must be unique.")
+ if operation.equation_indices and operation.phase is not ir.IRKernelPhase.RESIDUAL:
+ issues.add("KERNEL_EQUATION_PHASE_INVALID", operation_path, "Equation indices are only valid for residual calls.")
+ if (
+ operation.phase is ir.IRKernelPhase.RESIDUAL
+ and all(
+ 0 <= item < len(pressure_flow.equations)
+ for item in operation.equation_indices
+ )
+ ):
+ required_reads = {
+ slot
+ for equation_index in operation.equation_indices
+ for slot in pressure_flow.equations[
+ equation_index
+ ].variable_slots
+ }
+ required_writes = {
+ pressure_flow.equations[equation_index].residual_slot
+ for equation_index in operation.equation_indices
+ }
+ if not required_reads.issubset(operation.read_slots):
+ issues.add("RESIDUAL_CALL_READ_COVERAGE", operation_path, "Residual call reads must cover every referenced equation variable.")
+ if not required_writes.issubset(operation.write_slots):
+ issues.add("RESIDUAL_CALL_WRITE_COVERAGE", operation_path, "Residual call writes must cover every referenced equation residual.")
+ if isinstance(operation, (ir.IREffortBroadcastOperation, ir.IRFlowAssignmentOperation)):
+ if operation.equation_id not in {equation.equation_id for equation in pressure_flow.equations}:
+ issues.add("EQUATION_ID_MISSING", operation_path, "Assignment references an unknown equation.")
+ if len(set(stage.declared_read_slots)) != len(stage.declared_read_slots) or set(stage.declared_read_slots) != set(reads):
+ issues.add("STAGE_READ_SET_MISMATCH", f"{path}.declared_read_slots", "Declared reads must equal the operation read union.")
+ if len(set(stage.declared_write_slots)) != len(stage.declared_write_slots) or set(stage.declared_write_slots) != set(writes):
+ issues.add("STAGE_WRITE_SET_MISMATCH", f"{path}.declared_write_slots", "Declared writes must equal the operation write union.")
+
+ def valid_step(step: ir.IRStepRef, path: str) -> bool:
+ upper = len(program.stages) if step.kind is ir.IRStepKind.STAGE else len(program.blocks)
+ if step.index < 0 or step.index >= upper:
+ issues.add("STEP_INDEX_BOUNDS", path, f"{step.kind.value} index is invalid.")
+ return False
+ return True
+
+ issues.unique_ids(program.blocks, "block_id", "blocks")
+ for block_index, block in enumerate(program.blocks):
+ path = f"blocks[{block_index}]"
+ for step_index, step in enumerate(block.steps):
+ valid_step(step, f"{path}.steps[{step_index}]")
+ iterative_kinds = {ir.IRBlockKind.FIXED_POINT, ir.IRBlockKind.STREAM_SCC}
+ if block.kind in iterative_kinds and block.convergence is None:
+ issues.add("FIXED_POINT_CONVERGENCE_MISSING", path, "Iterative block needs convergence settings.")
+ if block.kind not in iterative_kinds and block.convergence is not None:
+ issues.add("UNEXPECTED_CONVERGENCE", path, "Only iterative blocks may define convergence.")
+ if block.convergence is not None:
+ convergence = block.convergence
+ if (
+ not isfinite(convergence.absolute_tolerance)
+ or convergence.absolute_tolerance < 0.0
+ or not isfinite(convergence.relative_tolerance)
+ or convergence.relative_tolerance < 0.0
+ or convergence.max_iterations <= 0
+ or not isfinite(convergence.relaxation)
+ or not 0.0 < convergence.relaxation <= 1.0
+ ):
+ issues.add("CONVERGENCE_LIMIT_INVALID", f"{path}.convergence", "Convergence limits are invalid.")
+ for slot_index, slot in enumerate((*convergence.monitor_slots, *convergence.rollback_slots)):
+ valid_slot(slot, f"{path}.convergence.slots[{slot_index}]")
+
+ visit_state = [0] * len(program.blocks)
+
+ def visit_block(block_index: int) -> None:
+ if visit_state[block_index] == 1:
+ issues.add("BLOCK_REFERENCE_CYCLE", f"blocks[{block_index}]", "Execution blocks must be acyclic.")
+ return
+ if visit_state[block_index] == 2:
+ return
+ visit_state[block_index] = 1
+ for step in program.blocks[block_index].steps:
+ if step.kind is ir.IRStepKind.BLOCK and 0 <= step.index < len(program.blocks):
+ visit_block(step.index)
+ visit_state[block_index] = 2
+
+ for block_index in range(len(program.blocks)):
+ visit_block(block_index)
+
+ def expanded_stage_indices(
+ steps: tuple[ir.IRStepRef, ...],
+ ) -> tuple[int, ...]:
+ result: list[int] = []
+
+ def append_step(step: ir.IRStepRef, active_blocks: set[int]) -> None:
+ if step.kind is ir.IRStepKind.STAGE:
+ if 0 <= step.index < len(program.stages):
+ result.append(step.index)
+ return
+ if not 0 <= step.index < len(program.blocks) or step.index in active_blocks:
+ return
+ nested_active = {*active_blocks, step.index}
+ for nested in program.blocks[step.index].steps:
+ append_step(nested, nested_active)
+
+ for step in steps:
+ append_step(step, set())
+ return tuple(result)
+
+ expected_entry_inputs = tuple(
+ ir.IRSlotRef(kind, index)
+ for kind in (
+ ir.IRBufferKind.TIME,
+ ir.IRBufferKind.STATE_INPUT,
+ ir.IRBufferKind.RUNTIME_INPUT,
+ )
+ for buffer in (buffer_by_kind.get(kind),)
+ if buffer is not None
+ for index in range(buffer.size)
+ )
+ entry_required_stage = {
+ ir.IREntryPointKind.RHS: ir.IRStageKind.DERIVATIVE_REDUCE,
+ ir.IREntryPointKind.EVENTS: ir.IRStageKind.EVENT,
+ ir.IREntryPointKind.JACOBIAN: ir.IRStageKind.JACOBIAN,
+ ir.IREntryPointKind.OUTPUTS: ir.IRStageKind.OUTPUT,
+ }
+ entry_forbidden_stages = {
+ ir.IREntryPointKind.RHS: {
+ ir.IRStageKind.EVENT,
+ ir.IRStageKind.JACOBIAN,
+ ir.IRStageKind.OUTPUT,
+ ir.IRStageKind.RESET,
+ },
+ ir.IREntryPointKind.EVENTS: {
+ ir.IRStageKind.DERIVATIVE_REDUCE,
+ ir.IRStageKind.JACOBIAN,
+ ir.IRStageKind.OUTPUT,
+ ir.IRStageKind.RESET,
+ },
+ ir.IREntryPointKind.JACOBIAN: {
+ ir.IRStageKind.EVENT,
+ ir.IRStageKind.OUTPUT,
+ ir.IRStageKind.RESET,
+ },
+ ir.IREntryPointKind.OUTPUTS: {
+ ir.IRStageKind.EVENT,
+ ir.IRStageKind.JACOBIAN,
+ ir.IRStageKind.RESET,
+ },
+ }
+
+ entry_kinds = tuple(entry.kind for entry in program.entry_points)
+ expected_entry_kinds = set(ir.IREntryPointKind)
+ if len(entry_kinds) != len(expected_entry_kinds) or set(entry_kinds) != expected_entry_kinds:
+ issues.add("ENTRY_POINT_SET_INVALID", "entry_points", "Exactly rhs, events, jacobian and outputs are required.")
+ for entry_index, entry in enumerate(program.entry_points):
+ path = f"entry_points[{entry_index}]"
+ for step_index, step in enumerate(entry.steps):
+ valid_step(step, f"{path}.steps[{step_index}]")
+ for slot_index, slot in enumerate((*entry.input_slots, *entry.output_slots)):
+ valid_slot(slot, f"{path}.slots[{slot_index}]")
+ output_kinds = {slot.buffer for slot in entry.output_slots}
+ expected_output_kind = {
+ ir.IREntryPointKind.RHS: ir.IRBufferKind.DERIVATIVE_OUTPUT,
+ ir.IREntryPointKind.EVENTS: ir.IRBufferKind.EVENT_OUTPUT,
+ ir.IREntryPointKind.JACOBIAN: ir.IRBufferKind.JACOBIAN_VALUE,
+ ir.IREntryPointKind.OUTPUTS: ir.IRBufferKind.RESULT_OUTPUT,
+ }[entry.kind]
+ if output_kinds - {expected_output_kind}:
+ issues.add("ENTRY_POINT_OUTPUT_BUFFER", path, f"Outputs must use {expected_output_kind.value}.")
+ if entry.input_slots != expected_entry_inputs:
+ issues.add(
+ "ENTRY_POINT_INPUT_CONTRACT",
+ f"{path}.input_slots",
+ "Entry inputs must be ordered time, full solver state and runtime inputs.",
+ )
+ expected_outputs = {
+ ir.IREntryPointKind.RHS: tuple(
+ ir.IRSlotRef(ir.IRBufferKind.DERIVATIVE_OUTPUT, index)
+ for index in range(buffer_by_kind.get(ir.IRBufferKind.DERIVATIVE_OUTPUT).size)
+ )
+ if buffer_by_kind.get(ir.IRBufferKind.DERIVATIVE_OUTPUT) is not None
+ else (),
+ ir.IREntryPointKind.EVENTS: tuple(event.root_slot for event in program.events),
+ ir.IREntryPointKind.JACOBIAN: program.jacobian.value_slots,
+ ir.IREntryPointKind.OUTPUTS: tuple(output.output_slot for output in program.outputs),
+ }[entry.kind]
+ if entry.output_slots != expected_outputs:
+ issues.add(
+ "ENTRY_POINT_OUTPUT_COVERAGE",
+ f"{path}.output_slots",
+ "Entry outputs must exactly match its ordered public result contract.",
+ )
+ reachable_stage_indices = expanded_stage_indices(entry.steps)
+ reachable_kinds = {
+ program.stages[index].kind for index in reachable_stage_indices
+ }
+ if entry_required_stage[entry.kind] not in reachable_kinds:
+ issues.add(
+ "ENTRY_POINT_FINAL_STAGE_MISSING",
+ f"{path}.steps",
+ f"{entry.kind.value} must execute a {entry_required_stage[entry.kind].value} stage.",
+ )
+ forbidden = reachable_kinds & entry_forbidden_stages[entry.kind]
+ if forbidden:
+ issues.add(
+ "ENTRY_POINT_STAGE_FORBIDDEN",
+ f"{path}.steps",
+ "Entry executes incompatible stage kinds: "
+ + ", ".join(sorted(kind.value for kind in forbidden)),
+ )
+ reachable_writes = {
+ slot
+ for index in reachable_stage_indices
+ for slot in program.stages[index].declared_write_slots
+ }
+ if not set(entry.output_slots).issubset(reachable_writes):
+ issues.add(
+ "ENTRY_POINT_OUTPUT_NOT_WRITTEN",
+ f"{path}.steps",
+ "Entry steps do not write every declared output slot.",
+ )
+ forbidden_write_buffers = {
+ ir.IRBufferKind.STATE_INPUT,
+ ir.IRBufferKind.PARAMETER,
+ ir.IRBufferKind.CONSTANT,
+ ir.IRBufferKind.MODE,
+ ir.IRBufferKind.RUNTIME_INPUT,
+ }
+ if any(slot.buffer in forbidden_write_buffers for slot in reachable_writes):
+ issues.add(
+ "ENTRY_POINT_PERSISTENT_WRITE",
+ f"{path}.steps",
+ "Evaluation entries may not mutate state, parameters, constants, modes or runtime inputs.",
+ )
+
+ issues.unique_ids(program.causal_plans, "plan_id", "causal_plans")
+ for index, plan in enumerate(program.causal_plans):
+ path = f"causal_plans[{index}]"
+ if plan.source_schema_version != 1:
+ issues.add("CAUSAL_SOURCE_VERSION_INVALID", path, "Only causal IR v1 metadata is supported.")
+ if plan.source_structural_signature is not None and (
+ len(plan.source_structural_signature) != 64
+ or any(character not in "0123456789abcdef" for character in plan.source_structural_signature)
+ ):
+ issues.add("CAUSAL_SIGNATURE_INVALID", path, "Source signature must be lowercase SHA-256.")
+ if any(item < 0 or item >= len(program.components) for item in plan.scope_component_indices):
+ issues.add("COMPONENT_INDEX_BOUNDS", path, "Causal scope component index is invalid.")
+ for slot_index, slot in enumerate((*plan.canonical_slots, *plan.compatibility_slots, *plan.reset_slots, *plan.external_effort_slots)):
+ valid_slot(slot, f"{path}.slots[{slot_index}]")
+ for stage in plan.effort_stages:
+ if stage.stage_index < 0 or stage.stage_index >= len(program.stages):
+ issues.add("STAGE_INDEX_BOUNDS", path, "Causal effort stage index is invalid.")
+ if any(stage < 0 or stage >= len(program.stages) for stage in plan.flow_stage_indices):
+ issues.add("STAGE_INDEX_BOUNDS", path, "Causal flow stage index is invalid.")
+
+ issues.unique_ids(program.stream_plans, "plan_id", "stream_plans")
+ for plan_index, plan in enumerate(program.stream_plans):
+ path = f"stream_plans[{plan_index}]"
+ scc_ids: set[str] = set()
+ for slot_index, slot in enumerate(plan.node_slots):
+ valid_slot(slot, f"{path}.node_slots[{slot_index}]")
+ partition = tuple(slot for component in plan.strongly_connected_components for slot in component.node_slots)
+ if len(set(partition)) != len(partition) or set(partition) != set(plan.node_slots):
+ issues.add("STREAM_SCC_PARTITION", path, "SCCs must partition stream nodes exactly once.")
+ for scc_index, scc in enumerate(plan.strongly_connected_components):
+ if not scc.scc_id or scc.scc_id in scc_ids:
+ issues.add("STREAM_SCC_ID_INVALID", f"{path}.strongly_connected_components[{scc_index}]", "SCC IDs must be non-empty and unique.")
+ scc_ids.add(scc.scc_id)
+ if scc.block_index < 0 or scc.block_index >= len(program.blocks):
+ issues.add("BLOCK_INDEX_BOUNDS", f"{path}.strongly_connected_components[{scc_index}]", "SCC block index is invalid.")
+ elif program.blocks[scc.block_index].kind is not ir.IRBlockKind.STREAM_SCC:
+ issues.add("STREAM_SCC_BLOCK_KIND", f"{path}.strongly_connected_components[{scc_index}]", "SCCs must reference stream_scc blocks.")
+ elif (
+ program.blocks[scc.block_index].convergence is None
+ or set(program.blocks[scc.block_index].convergence.monitor_slots)
+ != set(scc.node_slots)
+ ):
+ issues.add("STREAM_SCC_MONITOR_COVERAGE", f"{path}.strongly_connected_components[{scc_index}]", "SCC convergence monitors must match its node slots.")
+ scc_count = len(plan.strongly_connected_components)
+ if tuple(sorted(plan.topological_scc_indices)) != tuple(range(scc_count)):
+ issues.add("STREAM_TOPOLOGICAL_ORDER", path, "Topological order must cover each SCC once.")
+ position = {scc: index for index, scc in enumerate(plan.topological_scc_indices)}
+ for edge_index, edge in enumerate(plan.condensed_edges):
+ if not 0 <= edge.source_scc_index < scc_count or not 0 <= edge.target_scc_index < scc_count:
+ issues.add("STREAM_EDGE_BOUNDS", f"{path}.condensed_edges[{edge_index}]", "SCC edge is invalid.")
+ elif position.get(edge.source_scc_index, -1) >= position.get(edge.target_scc_index, -1):
+ issues.add("STREAM_DAG_CYCLE", f"{path}.condensed_edges[{edge_index}]", "Edge contradicts topological order.")
+
+ thermofluid = program.thermofluid
+ if not 0 <= thermofluid.stream_plan_index < len(program.stream_plans):
+ issues.add("STREAM_PLAN_INDEX_BOUNDS", "thermofluid.stream_plan_index", "Stream plan is invalid.")
+ for indexes, upper, label in (
+ (thermofluid.physical_port_indices, len(program.ports), "port"),
+ (thermofluid.global_component_indices, len(program.components), "component"),
+ (thermofluid.sensitive_component_indices, len(program.components), "component"),
+ (thermofluid.secondary_pressure_scope_indices, len(pressure_flow.scopes), "scope"),
+ ):
+ if any(item < 0 or item >= upper for item in indexes):
+ issues.add(f"{label.upper()}_INDEX_BOUNDS", "thermofluid", f"Thermofluid {label} index is invalid.")
+ if len(set(indexes)) != len(indexes):
+ issues.add(f"{label.upper()}_INDEX_DUPLICATE", "thermofluid", f"Thermofluid {label} indices must be unique.")
+ expected_physical_ports = {
+ index for index, port in enumerate(program.ports) if port.kind is ir.IRPortKind.PHYSICAL
+ }
+ if set(thermofluid.physical_port_indices) != expected_physical_ports:
+ issues.add("THERMOFLUID_PORT_COVERAGE", "thermofluid.physical_port_indices", "Thermofluid plan must cover every physical port.")
+ if thermofluid.secondary_pressure_scope_indices != pressure_flow.secondary_scope_indices:
+ issues.add("THERMOFLUID_SCOPE_MISMATCH", "thermofluid.secondary_pressure_scope_indices", "Thermofluid and pressure-flow secondary scopes must match.")
+ if not set(thermofluid.sensitive_component_indices).issubset(
+ thermofluid.global_component_indices
+ ):
+ issues.add("THERMOFLUID_SENSITIVE_SCOPE", "thermofluid.sensitive_component_indices", "Sensitive components must belong to the global closure component set.")
+ if thermofluid.maximum_iterations <= 0 or not isfinite(thermofluid.flow_relative_tolerance) or thermofluid.flow_relative_tolerance <= 0.0:
+ issues.add("THERMOFLUID_LIMIT_INVALID", "thermofluid", "Fixed-point limits must be positive.")
+ if thermofluid.uses_conservative_global_solver != (
+ thermofluid.conservative_fallback_reason is not None
+ ):
+ issues.add("THERMOFLUID_FALLBACK_REASON", "thermofluid", "Conservative fallback flag and reason must be declared together.")
+
+ transaction = program.transaction
+ for slot_index, slot in enumerate((*transaction.snapshot_slots, *transaction.flow_slots)):
+ valid_slot(slot, f"transaction.slots[{slot_index}]")
+ if len(set(transaction.snapshot_slots)) != len(transaction.snapshot_slots):
+ issues.add("TRANSACTION_DUPLICATE_SLOT", "transaction.snapshot_slots", "Snapshot slots must be unique.")
+ if len(set(transaction.flow_slots)) != len(transaction.flow_slots):
+ issues.add("TRANSACTION_DUPLICATE_SLOT", "transaction.flow_slots", "Flow slots must be unique.")
+ if not set(transaction.flow_slots).issubset(set(transaction.snapshot_slots)):
+ issues.add("TRANSACTION_FLOW_COVERAGE", "transaction.flow_slots", "Flow slots must be part of the snapshot.")
+ if any(item < 0 or item >= len(program.components) for item in transaction.cache_component_indices):
+ issues.add("COMPONENT_INDEX_BOUNDS", "transaction.cache_component_indices", "Cache owner is invalid.")
+ if len(set(transaction.cache_component_indices)) != len(transaction.cache_component_indices):
+ issues.add("TRANSACTION_CACHE_OWNER_DUPLICATE", "transaction.cache_component_indices", "Cache owners must be unique.")
+ if len(set(transaction.cache_attribute_ids)) != len(transaction.cache_attribute_ids):
+ issues.add("TRANSACTION_CACHE_ID_DUPLICATE", "transaction.cache_attribute_ids", "Cache attribute IDs must be unique.")
+ if any(not value for value in transaction.cache_attribute_ids):
+ issues.add("TRANSACTION_CACHE_ID_INVALID", "transaction.cache_attribute_ids", "Cache attribute IDs must not be empty.")
+ if len(set(transaction.diagnostic_owner_ids)) != len(transaction.diagnostic_owner_ids) or any(
+ not value for value in transaction.diagnostic_owner_ids
+ ):
+ issues.add("TRANSACTION_DIAGNOSTIC_ID_INVALID", "transaction.diagnostic_owner_ids", "Diagnostic owner IDs must be non-empty and unique.")
+ if "transactional_closure" in program.required_features and not (
+ transaction.restores_on_recoverable_failure
+ and transaction.restores_on_fatal_failure
+ ):
+ issues.add("TRANSACTION_RESTORE_REQUIRED", "transaction", "Transactional closure requires rollback for every failed trial.")
+ for block_index, block in enumerate(program.blocks):
+ if block.convergence is not None and not set(
+ block.convergence.rollback_slots
+ ).issubset(transaction.snapshot_slots):
+ issues.add("TRANSACTION_ROLLBACK_COVERAGE", f"blocks[{block_index}].convergence.rollback_slots", "Iterative rollback slots must belong to the transaction snapshot.")
+ expected_flow_slots = {
+ variable.slot
+ for port in program.ports
+ if port.kind is ir.IRPortKind.PHYSICAL and port.domain == "pneumatic"
+ for variable in port.variables
+ if variable.name == "m_flow"
+ }
+ if set(transaction.flow_slots) != expected_flow_slots:
+ issues.add("TRANSACTION_FLOW_SEMANTICS", "transaction.flow_slots", "Transaction flow slots must exactly cover active pneumatic m_flow variables.")
+ expected_snapshot_slots = {
+ variable.slot
+ for port in program.ports
+ if port.kind is ir.IRPortKind.PHYSICAL
+ for variable in port.variables
+ }
+ if set(transaction.snapshot_slots) != expected_snapshot_slots:
+ issues.add("TRANSACTION_SNAPSHOT_COVERAGE", "transaction.snapshot_slots", "Transaction snapshots must exactly cover mutable physical port values.")
+
+ issues.unique_ids(program.modes, "mode_id", "modes")
+ mode_by_slot: dict[ir.IRSlotRef, ir.IRModeSpec] = {}
+ for index, mode in enumerate(program.modes):
+ path = f"modes[{index}]"
+ valid_slot(mode.slot, f"{path}.slot")
+ if mode.slot.buffer is not ir.IRBufferKind.MODE:
+ issues.add("MODE_BUFFER_INVALID", f"{path}.slot", "Mode must use the mode buffer.")
+ if mode.slot in mode_by_slot:
+ issues.add("MODE_SLOT_DUPLICATE", f"{path}.slot", "Each mode requires a distinct slot.")
+ mode_by_slot[mode.slot] = mode
+ values = tuple(item.value for item in mode.values)
+ names = tuple(item.name for item in mode.values)
+ if any(value < -(2**31) or value > 2**31 - 1 for value in (*values, mode.initial_value)):
+ issues.add("MODE_INT32_RANGE", path, "Mode values and initial value must fit signed int32.")
+ if len(set(values)) != len(values) or len(set(names)) != len(names) or mode.initial_value not in values:
+ issues.add("MODE_VALUES_INVALID", path, "Mode values/names must be unique and include the initial value.")
+ if any(item < 0 or item >= len(program.components) for item in mode.owner_component_indices):
+ issues.add("COMPONENT_INDEX_BOUNDS", path, "Mode owner is invalid.")
+ if len(set(mode.owner_component_indices)) != len(mode.owner_component_indices):
+ issues.add("MODE_OWNER_DUPLICATE", path, "Mode owners must be unique.")
+ mode_buffer = buffer_by_kind.get(ir.IRBufferKind.MODE)
+ if mode_buffer is not None and set(mode_by_slot) != {
+ ir.IRSlotRef(ir.IRBufferKind.MODE, index)
+ for index in range(mode_buffer.size)
+ }:
+ issues.add("MODE_SLOT_COVERAGE", "modes", "Modes must cover every mode buffer slot exactly once.")
+ if mode_buffer is not None and mode_buffer.initial_int_values:
+ for mode in program.modes:
+ if (
+ 0 <= mode.slot.index < len(mode_buffer.initial_int_values)
+ and mode_buffer.initial_int_values[mode.slot.index] != mode.initial_value
+ ):
+ issues.add("MODE_INITIAL_VALUE_MISMATCH", mode.mode_id, "Mode metadata and buffer initial value differ.")
+ for component_index, component in enumerate(program.components):
+ expected_mode_slots = {
+ mode.slot
+ for mode in program.modes
+ if component_index in mode.owner_component_indices
+ }
+ if set(component.mode_slots) != expected_mode_slots:
+ issues.add("COMPONENT_MODE_COVERAGE", f"components[{component_index}].mode_slots", "Component mode bindings must match mode ownership.")
+
+ jacobian = program.jacobian
+ _validate_csr(jacobian.pattern, "jacobian.pattern", issues)
+ if jacobian.pattern.row_count != reducer.solver_state_count or jacobian.pattern.column_count != reducer.solver_state_count:
+ issues.add("JACOBIAN_SHAPE", "jacobian.pattern", "Jacobian must be state_count square.")
+ if len(jacobian.value_slots) != jacobian.pattern.nonzero_count:
+ issues.add("JACOBIAN_VALUE_COUNT", "jacobian.value_slots", "One value slot is required per nonzero.")
+ for slot_index, slot in enumerate(jacobian.value_slots):
+ valid_slot(slot, f"jacobian.value_slots[{slot_index}]")
+ if slot.buffer is not ir.IRBufferKind.JACOBIAN_VALUE:
+ issues.add("JACOBIAN_VALUE_BUFFER", f"jacobian.value_slots[{slot_index}]", "Jacobian values need the Jacobian buffer.")
+ jacobian_buffer = buffer_by_kind.get(ir.IRBufferKind.JACOBIAN_VALUE)
+ expected_jacobian_slots = tuple(
+ ir.IRSlotRef(ir.IRBufferKind.JACOBIAN_VALUE, index)
+ for index in range(jacobian_buffer.size if jacobian_buffer is not None else 0)
+ )
+ if jacobian.value_slots != expected_jacobian_slots:
+ issues.add("JACOBIAN_VALUE_SLOT_COVERAGE", "jacobian.value_slots", "Jacobian value slots must be unique and cover their buffer in CSR order.")
+ colors = tuple(column for group in jacobian.color_groups for column in group)
+ if tuple(sorted(colors)) != tuple(range(reducer.solver_state_count)) or len(set(colors)) != len(colors):
+ issues.add("JACOBIAN_COLOR_PARTITION", "jacobian.color_groups", "Colors must partition all state columns.")
+ if (
+ len(jacobian.pattern.row_pointers) == jacobian.pattern.row_count + 1
+ and jacobian.pattern.row_pointers
+ and jacobian.pattern.row_pointers[0] == 0
+ and jacobian.pattern.row_pointers[-1]
+ == len(jacobian.pattern.column_indices)
+ and all(
+ first <= second
+ for first, second in zip(
+ jacobian.pattern.row_pointers,
+ jacobian.pattern.row_pointers[1:],
+ )
+ )
+ ):
+ rows_by_column: dict[int, set[int]] = {
+ column: set() for column in range(jacobian.pattern.column_count)
+ }
+ for row in range(jacobian.pattern.row_count):
+ for column in jacobian.pattern.column_indices[
+ jacobian.pattern.row_pointers[row]
+ : jacobian.pattern.row_pointers[row + 1]
+ ]:
+ if column in rows_by_column:
+ rows_by_column[column].add(row)
+ for color_index, group in enumerate(jacobian.color_groups):
+ occupied_rows: set[int] = set()
+ for column in group:
+ if column not in rows_by_column:
+ continue
+ if occupied_rows & rows_by_column[column]:
+ issues.add(
+ "JACOBIAN_COLOR_CONFLICT",
+ f"jacobian.color_groups[{color_index}]",
+ "Columns in one color may not share a nonzero row.",
+ )
+ break
+ occupied_rows.update(rows_by_column[column])
+ for step_index, step in enumerate(jacobian.fill_steps):
+ valid_step(step, f"jacobian.fill_steps[{step_index}]")
+ jacobian_entry = next(
+ (
+ entry
+ for entry in program.entry_points
+ if entry.kind is ir.IREntryPointKind.JACOBIAN
+ ),
+ None,
+ )
+ if jacobian_entry is not None and jacobian.fill_steps != jacobian_entry.steps:
+ issues.add("JACOBIAN_FILL_STEP_MISMATCH", "jacobian.fill_steps", "Jacobian fill steps must equal the public Jacobian entry plan.")
+ analytic = set(jacobian.analytic_value_indices)
+ if len(analytic) != len(jacobian.analytic_value_indices):
+ issues.add("JACOBIAN_ANALYTIC_DUPLICATE", "jacobian.analytic_value_indices", "Analytic value indices must be unique.")
+ numerical: set[int] = set()
+ numerical_sequence: list[int] = []
+ seen_columns: set[int] = set()
+ for column_index, column in enumerate(jacobian.local_finite_difference_columns):
+ if not 0 <= column.column_index < reducer.solver_state_count or column.column_index in seen_columns:
+ issues.add("JACOBIAN_FD_COLUMN_INVALID", f"jacobian.local_finite_difference_columns[{column_index}]", "FD column is invalid or duplicated.")
+ seen_columns.add(column.column_index)
+ if not isfinite(column.relative_step) or column.relative_step <= 0.0:
+ issues.add("JACOBIAN_FD_STEP_INVALID", f"jacobian.local_finite_difference_columns[{column_index}]", "FD step must be positive.")
+ numerical.update(column.value_indices)
+ numerical_sequence.extend(column.value_indices)
+ if len(set(column.value_indices)) != len(column.value_indices):
+ issues.add("JACOBIAN_FD_VALUE_DUPLICATE", f"jacobian.local_finite_difference_columns[{column_index}]", "FD value indices within one column must be unique.")
+ for value_index in column.value_indices:
+ if (
+ 0 <= value_index < len(jacobian.pattern.column_indices)
+ and jacobian.pattern.column_indices[value_index]
+ != column.column_index
+ ):
+ issues.add("JACOBIAN_FD_COLUMN_MISMATCH", f"jacobian.local_finite_difference_columns[{column_index}]", "FD values must belong to the declared CSR column.")
+ if len(set(numerical_sequence)) != len(numerical_sequence):
+ issues.add("JACOBIAN_FD_VALUE_DUPLICATE", "jacobian.local_finite_difference_columns", "FD value indices may be assigned only once.")
+ all_value_indices = set(range(jacobian.pattern.nonzero_count))
+ if analytic & numerical or analytic | numerical != all_value_indices:
+ issues.add("JACOBIAN_FILL_COVERAGE", "jacobian", "Every nonzero needs exactly one analytic or local-FD strategy.")
+ if any(index < 0 or index >= jacobian.pattern.nonzero_count for index in analytic | numerical):
+ issues.add("JACOBIAN_VALUE_INDEX_BOUNDS", "jacobian", "Jacobian strategy references an invalid nonzero.")
+
+ issues.unique_ids(program.events, "event_id", "events")
+ event_root_slots: set[ir.IRSlotRef] = set()
+ for index, event in enumerate(program.events):
+ path = f"events[{index}]"
+ valid_slot(event.root_slot, f"{path}.root_slot")
+ if event.root_slot.buffer is not ir.IRBufferKind.EVENT_OUTPUT:
+ issues.add("EVENT_BUFFER_INVALID", f"{path}.root_slot", "Event root must use the event buffer.")
+ if event.root_slot in event_root_slots:
+ issues.add("EVENT_ROOT_DUPLICATE", f"{path}.root_slot", "Each event must have a distinct root slot.")
+ event_root_slots.add(event.root_slot)
+ if any(item < 0 or item >= len(program.components) for item in event.owner_component_indices):
+ issues.add("COMPONENT_INDEX_BOUNDS", path, "Event owner is invalid.")
+ for guard_index, guard in enumerate(event.mode_guards):
+ if any(
+ value < -(2**31) or value > 2**31 - 1
+ for value in guard.allowed_values
+ ):
+ issues.add(
+ "MODE_INT32_RANGE",
+ f"{path}.mode_guards[{guard_index}]",
+ "Mode guard values must fit signed int32.",
+ )
+ if guard.mode_slot not in mode_by_slot:
+ issues.add("EVENT_MODE_MISSING", f"{path}.mode_guards[{guard_index}]", "Guard mode is not declared.")
+ elif not set(guard.allowed_values).issubset({value.value for value in mode_by_slot[guard.mode_slot].values}):
+ issues.add("EVENT_MODE_VALUE_INVALID", f"{path}.mode_guards[{guard_index}]", "Guard value is not declared.")
+ if len({guard.mode_slot for guard in event.mode_guards}) != len(event.mode_guards):
+ issues.add("EVENT_MODE_GUARD_DUPLICATE", f"{path}.mode_guards", "An event may guard each mode only once.")
+ for step_index, step in enumerate(event.reset_steps):
+ valid_step(step, f"{path}.reset_steps[{step_index}]")
+ reset_stage_indices = expanded_stage_indices(event.reset_steps)
+ if any(
+ program.stages[stage_index].kind is not ir.IRStageKind.RESET
+ for stage_index in reset_stage_indices
+ ):
+ issues.add("EVENT_RESET_STAGE_INVALID", f"{path}.reset_steps", "Event reset plans may contain only reset stages.")
+ if len(set(event.invalidated_caches)) != len(event.invalidated_caches):
+ issues.add("EVENT_CACHE_DUPLICATE", f"{path}.invalidated_caches", "Invalidated caches must be unique.")
+ event_buffer = buffer_by_kind.get(ir.IRBufferKind.EVENT_OUTPUT)
+ expected_event_slots = {
+ ir.IRSlotRef(ir.IRBufferKind.EVENT_OUTPUT, index)
+ for index in range(event_buffer.size if event_buffer is not None else 0)
+ }
+ if event_root_slots != expected_event_slots:
+ issues.add("EVENT_ROOT_COVERAGE", "events", "Events must cover every event output slot exactly once.")
+
+ issues.unique_ids(program.outputs, "output_id", "outputs")
+ output_slots: set[ir.IRSlotRef] = set()
+ output_order_keys: set[tuple[int, str, str | None, int]] = set()
+ for index, output in enumerate(program.outputs):
+ path = f"outputs[{index}]"
+ valid_slot(output.source_slot, f"{path}.source_slot")
+ valid_slot(output.output_slot, f"{path}.output_slot")
+ if output.output_slot.buffer is not ir.IRBufferKind.RESULT_OUTPUT:
+ issues.add("OUTPUT_BUFFER_INVALID", f"{path}.output_slot", "Output must use result buffer.")
+ if output.output_slot in output_slots:
+ issues.add("OUTPUT_SLOT_DUPLICATE", f"{path}.output_slot", "Output slot is duplicated.")
+ output_slots.add(output.output_slot)
+ if not output.scope or not output.name or not output.label or not output.category or not output.quantity:
+ issues.add("OUTPUT_METADATA_INCOMPLETE", path, "Output scope, name, label, category and quantity are required.")
+ if output.order < 0:
+ issues.add("OUTPUT_ORDER_INVALID", f"{path}.order", "Output order cannot be negative.")
+ order_key = (
+ output.component_index,
+ output.scope,
+ output.port_name,
+ output.order,
+ )
+ if order_key in output_order_keys:
+ issues.add("OUTPUT_ORDER_DUPLICATE", f"{path}.order", "Output order must be unique within one component scope/port.")
+ output_order_keys.add(order_key)
+ if not 0 <= output.component_index < len(program.components):
+ issues.add("COMPONENT_INDEX_BOUNDS", path, "Output component is invalid.")
+ if not isfinite(output.scale) or output.scale == 0.0 or not isfinite(output.offset):
+ issues.add("OUTPUT_AFFINE_INVALID", path, "Output scale/offset must be finite and scale nonzero.")
+ if index not in program.components[output.component_index].output_indices if 0 <= output.component_index < len(program.components) else False:
+ issues.add("COMPONENT_OUTPUT_BACKREF", path, "Owning component does not reference this output.")
+ result_buffer = buffer_by_kind.get(ir.IRBufferKind.RESULT_OUTPUT)
+ expected_output_slots = {
+ ir.IRSlotRef(ir.IRBufferKind.RESULT_OUTPUT, index)
+ for index in range(result_buffer.size if result_buffer is not None else 0)
+ }
+ if output_slots != expected_output_slots:
+ issues.add("OUTPUT_SLOT_COVERAGE", "outputs", "Outputs must cover every result buffer slot exactly once.")
+ for component_index, component in enumerate(program.components):
+ expected_output_indices = tuple(
+ index
+ for index, output in enumerate(program.outputs)
+ if output.component_index == component_index
+ )
+ if component.output_indices != expected_output_indices:
+ issues.add("COMPONENT_OUTPUT_COVERAGE", f"components[{component_index}].output_indices", "Component outputs must exactly cover its owned outputs in program order.")
+
+ capabilities = program.capabilities
+ component_capabilities = tuple(item.component_index for item in capabilities.components)
+ called_phases_by_component: dict[int, set[ir.IRKernelPhase]] = {
+ index: set() for index in range(len(program.components))
+ }
+ for stage in program.stages:
+ for operation in stage.operations:
+ if (
+ isinstance(operation, ir.IRKernelCallOperation)
+ and operation.component_index is not None
+ and 0 <= operation.component_index < len(program.components)
+ ):
+ called_phases_by_component[operation.component_index].add(
+ operation.phase
+ )
+ if tuple(sorted(component_capabilities)) != tuple(range(len(program.components))) or len(set(component_capabilities)) != len(component_capabilities):
+ issues.add("CAPABILITY_COMPONENT_COVERAGE", "capabilities.components", "Capability report must cover every component once.")
+ for index, capability in enumerate(capabilities.components):
+ if not 0 <= capability.component_index < len(program.components):
+ issues.add("COMPONENT_INDEX_BOUNDS", f"capabilities.components[{index}]", "Capability component is invalid.")
+ continue
+ kernel_index = program.components[capability.component_index].kernel_index
+ if not 0 <= kernel_index < len(program.kernels):
+ continue
+ kernel = program.kernels[kernel_index]
+ kernel_phases = {phase.phase for phase in kernel.phases}
+ if len(set(capability.supported_phases)) != len(capability.supported_phases):
+ issues.add("CAPABILITY_PHASE_DUPLICATE", f"capabilities.components[{index}]", "Supported phases must be unique.")
+ if not set(capability.supported_phases).issubset(kernel_phases):
+ issues.add("CAPABILITY_PHASE_MISMATCH", f"capabilities.components[{index}]", "Capability phases must be declared by the component kernel.")
+ if (
+ capability.level is ir.IRCapabilityLevel.NATIVE
+ and not called_phases_by_component[capability.component_index].issubset(
+ capability.supported_phases
+ )
+ ):
+ issues.add(
+ "CAPABILITY_NATIVE_PHASE_MISSING",
+ f"capabilities.components[{index}].supported_phases",
+ "A native component must support every phase used by its kernel calls.",
+ )
+ if (
+ capability.level is ir.IRCapabilityLevel.NATIVE
+ and kernel.availability is not ir.IRKernelAvailability.NATIVE
+ ):
+ issues.add("CAPABILITY_KERNEL_MISMATCH", f"capabilities.components[{index}]", "A component cannot be native while its kernel is reference-only.")
+ if capability.level is ir.IRCapabilityLevel.NATIVE and capability.missing_features:
+ issues.add("CAPABILITY_MISSING_FEATURE_CONFLICT", f"capabilities.components[{index}]", "Native components cannot list missing features.")
+ if capability.level is not ir.IRCapabilityLevel.NATIVE and not capability.missing_features:
+ issues.add("CAPABILITY_MISSING_FEATURE_REQUIRED", f"capabilities.components[{index}]", "Non-native components must identify missing features.")
+ if len(set(capability.missing_features)) != len(capability.missing_features) or any(
+ not value for value in capability.missing_features
+ ):
+ issues.add("CAPABILITY_MISSING_FEATURE_INVALID", f"capabilities.components[{index}]", "Missing feature IDs must be non-empty and unique.")
+ issue_keys: set[tuple[str, str]] = set()
+ for index, issue in enumerate(capabilities.issues):
+ key = (issue.code, issue.scope_id)
+ if not issue.code or not issue.scope_id or not issue.message:
+ issues.add("CAPABILITY_ISSUE_INCOMPLETE", f"capabilities.issues[{index}]", "Capability issues require code, scope and message.")
+ if key in issue_keys:
+ issues.add("CAPABILITY_ISSUE_DUPLICATE", f"capabilities.issues[{index}]", "Capability issue code/scope pairs must be unique.")
+ issue_keys.add(key)
+ if any(issue.severity is ir.IRDiagnosticSeverity.ERROR for issue in capabilities.issues) and capabilities.system_level is not ir.IRCapabilityLevel.UNSUPPORTED:
+ issues.add("CAPABILITY_LEVEL_CONFLICT", "capabilities.system_level", "Error issues require unsupported system level.")
+ if capabilities.system_level is ir.IRCapabilityLevel.NATIVE and any(
+ item.level is not ir.IRCapabilityLevel.NATIVE for item in capabilities.components
+ ):
+ issues.add("CAPABILITY_LEVEL_CONFLICT", "capabilities.system_level", "Native system contains a non-native component.")
+ if capabilities.system_level is ir.IRCapabilityLevel.NATIVE and any(
+ kernel.availability is not ir.IRKernelAvailability.NATIVE
+ for kernel in program.kernels
+ ):
+ issues.add("CAPABILITY_LEVEL_CONFLICT", "capabilities.system_level", "Native system contains a reference-only kernel.")
+ if (
+ capabilities.system_level is ir.IRCapabilityLevel.NATIVE
+ and transaction.cache_attribute_ids
+ ):
+ issues.add(
+ "CAPABILITY_LEVEL_CONFLICT",
+ "transaction.cache_attribute_ids",
+ "Native systems cannot depend on opaque Python cache attributes.",
+ )
+ if (
+ capabilities.system_level is ir.IRCapabilityLevel.NATIVE
+ and "reference_kernel_dispatch" in program.required_features
+ ):
+ issues.add(
+ "CAPABILITY_LEVEL_CONFLICT",
+ "required_features",
+ "Native systems cannot require reference-kernel dispatch.",
+ )
+ if (
+ capabilities.system_level is not ir.IRCapabilityLevel.UNSUPPORTED
+ and any(
+ item.level is ir.IRCapabilityLevel.UNSUPPORTED
+ for item in capabilities.components
+ )
+ ):
+ issues.add(
+ "CAPABILITY_LEVEL_CONFLICT",
+ "capabilities.system_level",
+ "A system containing an unsupported component must be unsupported.",
+ )
+ if (
+ capabilities.system_level is ir.IRCapabilityLevel.UNSUPPORTED
+ and not any(
+ issue.severity is ir.IRDiagnosticSeverity.ERROR
+ for issue in capabilities.issues
+ )
+ ):
+ issues.add("CAPABILITY_LEVEL_CONFLICT", "capabilities.system_level", "Unsupported systems require an error capability issue.")
+
+ try:
+ program.canonical_json_bytes()
+ except (TypeError, ValueError) as exc:
+ issues.add("CANONICAL_SERIALIZATION_FAILED", "$", str(exc))
+
+ return IRValidationReport(tuple(issues.items))
+
+
+def require_valid_system_ir(program: ir.SystemIR) -> ir.SystemIR:
+ """Return ``program`` or raise one error containing all static issues."""
+
+ report = validate_system_ir(program)
+ if not report.valid:
+ raise SystemIRValidationError(report)
+ return program
diff --git a/docs/other/C语言数值内核实施计划与可行性评估.md b/docs/other/C语言数值内核实施计划与可行性评估.md
index 76bc657..b5f0636 100644
--- a/docs/other/C语言数值内核实施计划与可行性评估.md
+++ b/docs/other/C语言数值内核实施计划与可行性评估.md
@@ -1,6 +1,6 @@
# C 语言数值内核实施计划与可行性评估
-> 文档状态:待评审实施方案
+> 文档状态:历史阶段方案;当前默认 C 且已退役 Python 数值实现,见 [退役记录](Python数值实现退役记录.md)。
> 建立日期:2026-09-02
> 补充日期:2026-09-09;第 10 节结合最新实测,细化 XML → 系统专用 C → 原生 EXE 路线。前九节保留原阶段方案与历史评估;本次仅补充设计,未完成生产内核替换。
> 适用分支:`model-development`
@@ -140,6 +140,8 @@ SIMULATION_NUMERIC_ENGINE=python|native|shadow|auto
#### C-01 完整数值 IR schema v2
+状态:**已完成 v2.0 schema、规范、系统编译器、fail-closed 语义校验和复杂模型结构回归。** 当前结果明确为 `reference_only`,C-02/C-03 尚未完成,默认 Python 求解路径没有切换。
+
完整 IR 必须描述共享数值阶段,以及 RHS、Event、Jacobian 和输出四类独立的按需入口。它们可以复用同一套槽位和依赖信息,但不能被误实现成“每次 RHS 都顺序计算 Event、Jacobian 和输出”。共享 primal 计划为:
```text
@@ -170,11 +172,12 @@ IR 至少描述:
- stream 图、SCC、物性状态包、外层固定点和事务恢复集合;
- event ID、左/右状态、reset、Jacobian 失效和模式计划;
- 固定的 CSR Jacobian 结构和 output projection;
-- IR schema、native ABI、组件模型/实现、介质、编译器、dtype、平台和构建选项组成的结构签名。
+- 完整 IR 内容(含组件模型/实现、介质、dtype、参数和执行计划)组成跨平台内容签名;
+- native artifact key 再把内容签名与 native ABI、目标平台、编译器、构建选项、浮点策略和 kernel 库签名组合。
原生兼容 program 中不得存在 Python callback。schema v1 保留为参考适配器,不直接扩展成生产 ABI。
-完成标准:同一模型重复编译得到字节级稳定的结构和签名;所有索引及读写集合可静态校验;缺少能力时明确拒绝编译。
+完成标准:同一模型重复编译得到字节级稳定的结构和签名;所有索引及读写集合可静态校验;结构可完整描述但缺少原生能力时明确标为 `reference_only`,原生加载器必须拒绝接管。
#### C-02 纯数值组件合同
@@ -398,8 +401,8 @@ Numba 可以在完整数组 IR 后用于 1–2 周的架构验证,但不作为
第一批只做 P0,不直接开始大规模 C 编码:
1. **已完成:** 固定当前权威输入和回归证据的 `LF` 检出规则,重新规范化 Windows 工作树,并增加 Windows/Linux 字节合同测试;未重建语义未变的 golden。
-2. 生成当前模型的组件类型、槽位、阶段、副作用和事件能力矩阵。
-3. 将 schema v2 写成独立规范,先冻结 RHS 阶段、错误、事务、事件和结构签名。
+2. **已完成(C-01 结构层):** 生成当前模型的组件类型、槽位、阶段、副作用和事件能力描述;纯数值 kernel 能力矩阵的实现细节继续归入 C-02。
+3. **已完成:** 将 schema v2 写成独立规范,并冻结 RHS/Event/Jacobian/Outputs 入口、错误能力、事务、事件、Jacobian 和内容签名。
4. 建立对象引擎与扁平 IR 的逐阶段 Shadow runner。
5. 用一条完整机械—气动—管路—接触支路完成 Python 参考闭环。
6. 评审通过后,再建立最小 C ABI 和纵向切片。
diff --git a/docs/other/Python数值实现退役记录.md b/docs/other/Python数值实现退役记录.md
index 7df5e33..6ce8324 100644
--- a/docs/other/Python数值实现退役记录.md
+++ b/docs/other/Python数值实现退役记录.md
@@ -24,3 +24,18 @@
当前版本仍仅提供 Windows x64 原生构建。此前已发现大型 `test-mql-8` 的 CVODE BDF 完整仿真过慢;本次删除旧实现不解决该问题,不能据组件覆盖或短时测试宣称其长时仿真已通过。详见 [组件覆盖记录](C内核组件库覆盖记录.md)。
本地生成的 EXE、DLL、计时日志在 `/test/`,不提交编译产物。源码和冻结基准进入 Git,可重新生成。
+
+## 远端 IR 分支集成
+
+合并保留 `system-optimization` 的 IR v2 schema、Python 数据结构、内容签名和静态校验器。旧 `compile_system_ir(GenericFluidSystem)` 导出器直接依赖已删除的压力流量、机械和闭合执行计划,因此停用并返回明确的迁移提示。当前 C 生成接口是 `native_codegen.compiler.compile_native_program(network)`,尚不输出独立的 System IR v2 执行计划。
+
+从远端提交 `ce353d2` 固化机械、132 状态 MQL、74 状态历史模型的 reference-only IR,保存在 `tests/data/system-ir-v2-reference.json.gz`。保留并迁移了 20 项 IR 结构、签名、索引、阶段和错误校验测试,全部通过。用于捕获参考结果的临时旧代码副本已删除。
+
+CI 同步移除旧 Python 基准命令:Linux 验证元数据和 IR 合同,Windows 配置 C 工具链后运行原生回归。CI 配置已更新,本地验证与远端 CI 的实际运行结果分别记录,不把本地通过当成远端 CI 已通过。
+
+## 最终本地验证
+
+- 后端完整发现 280 项测试,1 项 Linux 锁定环境版本校验按配置跳过。数值、XML、IR 等检查通过;心跳测试改为等待消费确认后,对应 11 项 API 回归通过,消除依赖几毫秒固定等待的时序假设。
+- 前端 TypeScript 检查与 Vite 生产构建通过。
+- Chrome 无界面网页测试 3 项通过:C 结果、缺少旧诊断字段的结果、历史 Python 结果均可显示并在刷新后恢复。
+- `skill-test` 完整 10 s 原生求解通过,计时与设置见上文。
diff --git a/docs/standard/system-numeric-ir-v2.md b/docs/standard/system-numeric-ir-v2.md
new file mode 100644
index 0000000..37f556d
--- /dev/null
+++ b/docs/standard/system-numeric-ir-v2.md
@@ -0,0 +1,264 @@
+# 全系统数值中间表示(System Numeric IR)规范 v2.0
+
+> 2026-09-10 集成说明:保留本规范、schema、签名与静态校验。依赖旧 `GenericFluidSystem` 的 Python 对象导出器已随旧数值内核退役,调用 `compile_system_ir()` 会明确报错。当前可执行 C 路径使用 `native_codegen.compiler.compile_native_program(network)`,尚未按本 v2 结构输出执行计划;不能把当前 C 支持等同于本规范的 native 能力声明。原导出结果固化在 `tests/data/system-ir-v2-reference.json.gz`,继续验证 schema 与复杂计划的错误检查。
+
+状态:C-01 已实现并冻结 v2.0 数据合同;当前编译结果为 `reference_only`,原生 kernel 与执行器属于 C-02/C-03 及后续工作。
+
+适用范围:后端 `GenericFluidSystem` 编译后的整个仿真系统。
+
+机器可读定义:`schemas/system-numeric-ir-v2.schema.json`。
+
+## 1. 定位与边界
+
+System Numeric IR(以下简称 IR)描述“一个已经解析并编译好的系统,数值求解时需要哪些数据、按什么关系执行”。它是系统级合同,不是单个部件的文件。
+
+```text
+XML / 建模 JSON
+ → 模型解析和连接检查
+ → GenericFluidSystem 对象图
+ → System IR v2.0
+ → 参考执行器 / 未来原生执行器
+```
+
+XML 保存用户建立了哪些元件、参数和连线;IR 在此基础上补充求解器真正需要的槽位编号、状态降维、方程块、执行阶段、闭合范围、事务回滚、事件、Jacobian 和输出投影。因此二者看起来相似,但用途和层级不同。
+
+本版本只完成“完整、确定、可校验的数据合同”和从现有系统生成该合同的编译器。它没有替换当前默认 Python 求解路径,不改变现有仿真结果。IR 中禁止保存 Python 函数、闭包、模型对象、对象地址和运行期临时状态。
+
+## 2. 三个权威来源
+
+三份实现共同定义 v2.0:
+
+- `app/simulation/ir/schema.py`:Python 不可变数据类型、枚举、规范序列化和签名算法;
+- `schemas/system-numeric-ir-v2.schema.json`:跨语言 JSON 结构合同;
+- `app/simulation/ir/validation.py`:仅靠 JSON Schema 无法表达的引用、覆盖、拓扑和数值语义校验。
+
+生产者必须同时满足机器 Schema 和语义校验。字段有增删时必须同步修改三处以及合同测试,不能只更新文档。
+
+## 3. 版本、兼容性和严格读取
+
+版本不是顶层整数,而是 `version` 对象:
+
+```json
+{
+ "$type": "schema_version",
+ "schema_id": "system-numeric-ir",
+ "major": 2,
+ "minor": 0
+}
+```
+
+- `major` 改变表示不兼容的字段或执行语义变化;读取方必须拒绝未知主版本。
+- `minor` 用于同一主版本内向前演进;当前读取方拒绝负数和高于自身能力的次版本。
+- JSON Schema 对所有合同对象使用 `additionalProperties: false`,v2.0 读取方不会静默忽略未知字段或枚举值。
+- 原生二进制 ABI 不写入 `SystemIR`,而由独立的 `IRNativeBuildIdentity.abi_version` 管理,当前值为 `1`。
+- 压力流量中的 `causal_plans.source_schema_version == 1` 只表示其来源是既有 causal IR v1;它不是完整系统 IR 的版本,也不能携带 v1 的 Python 回调。
+
+## 4. 线格式和顶层结构
+
+每个 dataclass 序列化后都带有稳定的 `$type`;操作对象还带有 `opcode`。顶层 `$type` 为 `system_ir`,其字段完整集合如下:
+
+| 字段 | 含义 |
+| --- | --- |
+| `version` | IR schema 身份与版本 |
+| `model_id` / `model_version` | 输入系统的稳定身份和调用方提供的模型版本 |
+| `compiler_id` / `compiler_version` | 产生 IR 的编译器身份,当前为 `generic-fluid-system` / `2.0.0` |
+| `numeric_dtype` | 主数值类型,v2.0 只接受 `float64` |
+| `buffers` / `values` | 连续缓冲区及每一个数值槽位的元数据 |
+| `kernels` / `components` | kernel 声明与元件实例绑定 |
+| `mediums` | 介质实现、介质常量和使用该介质的元件 |
+| `ports` / `connections` | 端口变量和系统拓扑 |
+| `state_reducer` | 求解器状态与元件局部状态/导数的线性映射 |
+| `causal_plans` / `pressure_flow` | 因果子计划和完整压力流量方程计划 |
+| `stream_plans` / `thermofluid` | stream SCC/DAG 与热流体外层闭合计划 |
+| `stages` / `blocks` | 无回调操作阶段与复合/迭代执行块 |
+| `entry_points` | `rhs`、`events`、`jacobian`、`outputs` 四个入口 |
+| `transaction` | 试算快照、流量恢复和参考缓存诊断 |
+| `modes` / `events` | 离散模式、根函数、reset 与缓存失效 |
+| `jacobian` | 固定 CSR 结构、着色和局部有限差分计划 |
+| `outputs` | 结果元数据和投影 |
+| `capabilities` | 系统和元件的原生可执行能力及缺口 |
+| `required_features` | 读取/执行该程序必须理解的功能 ID |
+
+所有列表的顺序都是合同的一部分。引用统一采用数组索引或稳定 ID,不能依赖哈希表遍历顺序。
+
+## 5. 规范序列化与内容签名
+
+`canonical_json_bytes()` 是跨平台唯一线表示:
+
+- UTF-8,ASCII 转义开启,JSON 键排序,无无意义空白;
+- 字符串先做 Unicode NFC 规范化;
+- tuple 写成 JSON array,不接受 list、dict、set 或任意对象;
+- 浮点数写成 IEEE-754 binary64 大端十六进制对象,例如 `{"$float64":"3ff0000000000000"}`;
+- `-0.0` 统一为 `+0.0`,NaN 和正负无穷直接拒绝;
+- 枚举写成规范字符串,整数和布尔值保持其 JSON 类型。
+
+`SystemIR.structural_signature` 是上述完整 `SystemIR` 内容字节的 SHA-256 小写十六进制值。它准确回答“这份 IR 内容是否完全相同”,包含参数值和所有计划,因此不声称不同表达形式的数学系统会得到同一签名。操作系统、机器路径、构建时间、编译器和 native flags 不进入这个签名。
+
+## 6. 缓冲区、槽位和值
+
+槽位引用的格式为 `{"$type":"slot_ref","buffer":"...","index":N}`。v2.0 恰好声明以下 16 类缓冲区,每类一次:
+
+| dtype | 缓冲区 |
+| --- | --- |
+| `float64` | `time`、`state_input`、`derivative_output`、`local_state`、`local_derivative`、`algebraic`、`signal`、`parameter`、`constant`、`work_float`、`event_output`、`jacobian_value`、`result_output`、`runtime_input` |
+| `int32` | `mode`、`work_int` |
+
+`time` 的长度必须为 1;`int32` 初值必须在有符号 32 位范围内。每个缓冲区内索引为 `[0, size)`,而且每一个实际槽位必须恰好有一个 `IRValueSpec`。值描述包含稳定 ID、语义、角色、物理量、单位、缩放、可选上下界和可选所属元件。缩放必须为正有限数,边界必须有序且有限。
+
+缓冲区是执行器唯一的数值寻址合同。名称用于诊断,不允许执行器重新用名称查找取代槽位访问。
+
+## 7. Kernel 与元件绑定
+
+`IRKernelSpec` 声明模型类型、模型版本、实现版本、能力、支持的 phase 以及参数/状态/mode/workspace 数量。phase 枚举为:
+
+`primal`、`residual`、`derivative`、`property`、`event`、`reset`、`jacobian`、`output`。
+
+phase 在 C-01 中只是稳定的功能标签。不同模型在同一 phase 下可能有不同输入输出数量,所以不能在 phase 上填写虚假的统一 arity。当前每个 `IRKernelCallOperation` 自身的有序 `read_slots`、`write_slots` 和 `equation_indices` 才是该次调用的权威依赖合同。C-02 将在此基础上冻结每个 `kernel_id + phase` 的纯数值调用签名并验证所有调用实例一致。
+
+`IRComponentInstance` 把一个元件实例绑定到 kernel,并明确列出参数、局部状态、局部导数、mode、端口、输出和两类 workspace。绑定数量必须与 kernel 声明一致;端口和输出必须与其反向所属关系精确一致。
+
+当前编译器把所有既有 Python kernel 标记为 `reference_only`。为便于结构审计,reference kernel 调用声明了保守读集合:可能多读,但不能漏掉模型对象当前可见的数值输入。Python 内部隐藏缓存仍不是原生槽位,因而任何含这类依赖的程序都不得宣称 `native`。
+
+## 8. 介质、端口和连接
+
+介质记录稳定 `medium_id`、名称、实现及其版本、常量槽位和使用它的元件索引。介质参数必须位于 `constant` 缓冲区。
+
+端口分为 `physical` 与 `signal`:
+
+- 物理端口必须声明正流方向,当前统一为 `intoComponent`;
+- signal 端口不得声明物理流向;
+- 变量角色与连接规则固定对应:`effort → equal`、`flow → sumToZero`、`stream → streamMix`、`signal → directed`。
+
+连接必须引用两个已声明、不同、同 kind/同 domain 的端口,并精确覆盖两个端口的全部同名变量合同。物理端口和 signal 输入最多被一条连接占用;signal 输出允许扇出到多个输入。禁止重复端点对和悬空索引。
+
+## 9. 状态降维与导数汇总
+
+`IRStateReducer` 使求解器的 `state_input` 与各元件 `local_state` 分离。`state_reducer.initial_state` 是状态初值的语义描述,必须与 `state_input` 缓冲区的初值逐项完全相同,避免消费者面对两个不同初值:
+
+- `state_scatter` 用 CSR 矩阵把求解器状态散射到有序局部状态槽位;
+- `derivative_gather` 把有序局部导数汇总为 `derivative_output`;
+- `initial_state` 与 `absolute_tolerances` 按求解器状态顺序定义。
+
+这能显式表达共享机械坐标和气动储能状态的降维关系。例如同一气动储能状态可以按体积权重散射到多个局部状态,而不是由执行器临时按对象身份猜测。两个矩阵必须满足 CSR 不变量、维度和值数量合同,并覆盖全部组件状态/导数绑定。
+
+## 10. 压力—流量计划与因果元数据
+
+`IRPressureFlowPlan` 包含:
+
+- `unknowns`:未知量的元件、端口、变量角色、槽位、缩放和边界;
+- `equations`:元件或连接拥有的方程、关系、涉及槽位、残差槽位和缩放;
+- `blocks`:未知量/方程的方块分解及每块 Jacobian CSR 结构;
+- `scopes`:全网、敏感物理岛或方程块作用域及求解限制;
+- `global_scope_index` 和 `secondary_scope_indices`:第一次全网求解与后续局部重算范围;
+- `pressure_lower_bound`:全局压力下界。
+
+全局 scope 必须覆盖完整网络;每个 scope 的未知量和方程必须等于它包含的 blocks 之并集;secondary scope 唯一且不能包含 global scope。每条方程必须拥有唯一的 residual 槽位,防止两个残差互相覆盖。`sparse_pattern_trusted=false` 时必须给出回退原因,可信结构则不得携带回退原因。
+
+`IRCausalPlan` 保存当前 causal IR v1 编译得到的无回调元数据,包括作用域、规范/兼容/重置槽位、外部 effort、effort 阶段和 flow 阶段。它只作为 v2 压力流量计划的一部分,不代替完整系统计划。
+
+## 11. 操作、阶段、执行块和四个入口
+
+v2.0 的无回调 opcode 为:
+
+`fill`、`copy`、`scatter`、`linear_combination`、`state_map`、`kernel_call`、`effort_broadcast`、`flow_assign`、`check_finite`。
+
+`IRStage` 给出 stage kind、操作序列以及声明的读/写集合;声明集合必须与操作读写并集完全一致。`IRExecutionBlock` 可以按顺序引用 stage 或其他 block;引用图必须无环。`fixed_point` 和 `stream_scc` block 必须声明监控槽位、绝对/相对容差、最大迭代、松弛、回滚槽位和失败策略,其他 block 禁止携带收敛合同。
+
+四个入口必须恰好各一个,且入口输入统一按 `time`、完整 `state_input`、完整 `runtime_input` 排列:
+
+| 入口 | 必须到达的结果阶段 | 精确输出缓冲区 | 不允许夹带 |
+| --- | --- | --- | --- |
+| `rhs` | `derivative_reduce` | 全部 `derivative_output` | event、Jacobian、output、reset |
+| `events` | `event` | 按事件顺序的全部 `event_output` 根槽位 | derivative reduce、Jacobian、output、reset |
+| `jacobian` | `jacobian` | 按 CSR 顺序的全部 `jacobian_value` | event、output、reset |
+| `outputs` | `output` | 按结果顺序的全部 `result_output` | event、Jacobian、reset |
+
+入口可以复用前置 primal 阶段,但不能把四个入口合并成“每次 RHS 都把事件、Jacobian 和全部输出计算一遍”。入口执行不得写入 `state_input`、`parameter`、`constant` 或 `mode` 等持久输入。
+
+## 12. Stream、热流体闭合和事务
+
+`IRStreamPlan` 显式列出 stream 节点、强连通分量(SCC)、SCC 间缩点 DAG 和拓扑顺序。每个 SCC 对应一个 `stream_scc` block,循环只在 SCC 内迭代;监控槽位必须覆盖该 SCC 节点。
+
+`IRThermofluidPlan` 覆盖全部物理端口,关联 stream plan、全局元件集合、敏感元件、secondary 压力 scope、最大迭代和流量相对容差。是否使用保守全网求解及原因必须成对出现,避免执行器静默扩大作用域。
+
+`IRTransactionPlan` 冻结一次试探计算需要快照和恢复的端口变量,并单列实际活动气动端口上的 `m_flow`。当前目标系统中的这部分数量为 232;机械模型对象里没有作为活动端口变量出现的隐藏 `m_flow` 字段不会被误算进该集合。所有失败试算必须恢复快照;正常成功返回即为隐式提交,不另设可被误排序的 commit opcode。
+
+`cache_component_indices`、`cache_attribute_ids` 和 `diagnostic_owner_ids` 只记录当前 Python 参考路径中仍需关注的隐藏副作用,供 C-02/C-03 清除和 Shadow 诊断;它们不是原生内存布局。存在 opaque Python cache 属性的系统不能标记为 `native`。
+
+## 13. 模式、事件和 Reset
+
+每个 `IRModeSpec` 记录 int32 mode 槽位、所属元件、合法值及初值。模式槽位必须全部且只被一个 mode 说明,组件 mode 绑定与 owner 关系必须双向覆盖。
+
+每个 `IREventSpec` 记录稳定事件 ID、事件类型、owner、根槽位、触发方向、终止性、优先级、mode guard、reset steps、失效缓存种类以及是否重启积分器。reset 只能引用 `reset` stage,事件根槽位必须精确覆盖 `event_output` 缓冲区。
+
+当前 IR 已能表达现有元件暴露的事件和模式结构;仍隐藏在 Python 信号求解或机械密集输出逻辑中的行为属于 `reference_only` 能力缺口,必须在 C-02/C-08 显式化后才能原生执行。
+
+## 14. Jacobian 合同
+
+`IRJacobianPlan` 包含固定 CSR pattern、与非零项一一对应的 `value_slots`、颜色组、填充值步骤、解析 value 索引和局部有限差分列。
+
+CSR 必须满足:`row_pointers` 长度为行数加一、首项为 0、单调不减、末项等于非零项数量;每行列索引递增、唯一且在范围内。颜色组中的列不能共享同一潜在非零行,列不能重复着色。解析项和有限差分项不得重复或越界;每个有限差分列只能填写该列在 CSR 中确实存在的 value 索引,步长必须为正有限数。Jacobian 入口的执行步骤必须与 `fill_steps` 完全一致。
+
+结构可以保守地多报潜在非零项,但不能漏报可能依赖。结构、模式布局或 kernel 实现改变会自然改变整份 IR 内容签名。
+
+## 15. 输出合同
+
+每个 `IROutputSpec` 包含稳定 output ID、所属元件、scope、可选端口名、内部名、显示标签、类别、物理量、单位、局部顺序、来源槽位、结果槽位以及线性 scale/offset。
+
+`output_id` 和 `result_output` 槽位在全系统唯一。`order` 只在 `(component_index, scope, port_name)` 内排序,因此不同元件出现相同 `order` 是合法的;全局最终列顺序由 `outputs` 数组顺序确定。组件的 `output_indices` 必须精确反向覆盖其所有输出。
+
+## 16. 能力报告与拒绝规则
+
+能力级别只有:
+
+- `native`:所有 kernel phase、状态、事件、事务和缓存都满足原生合同;
+- `reference_only`:数学/结构已描述,但至少一个阶段仍依赖 Python 参考实现;
+- `unsupported`:当前 IR 无法安全表达或执行,必须带 error 级能力问题。
+
+每个元件必须恰好有一条 capability,列出支持 phase 与缺失 feature。系统为 `native` 时所有元件和 kernels 都必须是 native,且不能依赖 opaque Python cache;系统含任意 reference-only 元件时不能伪装为 native。能力问题具有 code、severity、scope ID 和消息,相同 code/scope 不得重复。
+
+当前 `compile_system_ir()` 的输出明确为 `reference_only`,原因是 C-02 的纯数值 kernel 签名与 C-03 的扁平参考执行器尚未完成。这不是 IR 编译失败,也不允许 native loader 越过能力报告运行。标为 native 的系统还必须覆盖所有实际调用 phase,并且不得要求 `reference_kernel_dispatch`。
+
+## 17. 原生构建产物键
+
+二进制缓存身份与 IR 内容签名严格分离。`native_artifact_key(program, build)` 对以下信息再次做规范序列化和 SHA-256:
+
+- `program.structural_signature`;
+- native ABI 版本;
+- target triple;
+- 编译器 ID 与版本;
+- 有序编译 flags;
+- 浮点策略;
+- kernel 库 SHA-256 签名。
+
+ABI 必须等于当前支持值,字符串不能为空,flags 不能含空项,kernel 库签名必须为 64 位小写十六进制。这样相同 IR 在 Windows/Linux 上具有相同内容签名,但得到不同且安全的 native artifact key。
+
+## 18. 编译、校验和消费流程
+
+当前公开入口为:
+
+```python
+from app.simulation.ir import compile_system_ir, require_valid_system_ir
+
+program = compile_system_ir(system, model_version="...")
+require_valid_system_ir(program)
+payload = program.canonical_json_bytes()
+signature = program.structural_signature
+```
+
+`compile_system_ir()` 接收已完成解析和系统构建的 `GenericFluidSystem`,不直接解析 XML。消费者必须先验证,再根据 `capabilities.system_level` 选择参考路径或未来原生路径;不得把“JSON Schema 能读取”误当成“具备 native 执行能力”。
+
+静态校验采用 fail-closed 策略,覆盖:版本与 required feature、全部槽位、数值范围、组件/kernel arity、端口/连接、介质、状态映射、压力流量方程与 scope、阶段读写、block 无环、四入口切片、stream/热流体、事务、mode/event/reset、Jacobian、输出及能力一致性。`require_valid_system_ir()` 聚合错误后拒绝程序。
+
+## 19. C-01 验收边界与后续工作
+
+C-01 的完成标准是:
+
+- 能从当前目标复杂模型和历史 0.81 s 模型生成完整系统级结构;
+- 同一系统重复编译得到字节完全相同的 canonical JSON 和签名;
+- 换行方式、Python 哈希种子和目标平台不会污染 IR 内容身份;
+- 故意破坏引用、覆盖、CSR、事务、入口或能力合同会被拒绝;
+- IR 中没有 callback、对象地址或任意 Python 对象;
+- 默认 Python 仿真路径保持不变。
+
+C-01 不等于已经拥有可运行的 C 后端。下一步 C-02 要冻结每个 kernel 的纯数值签名、隐藏缓存和错误码;C-03 要用扁平 Python 执行器逐槽 Shadow 对照;完成这两项后,才可以建立 C ABI、原生执行器并逐步把 capability 从 `reference_only` 提升为 `native`。
diff --git a/docs/update-log/更新日志-2026-09-10.md b/docs/update-log/更新日志-2026-09-10.md
index e666ac3..dccfd0c 100644
--- a/docs/update-log/更新日志-2026-09-10.md
+++ b/docs/update-log/更新日志-2026-09-10.md
@@ -13,3 +13,9 @@
- 固化 50 个网络、112 组 Python 参考状态,删除后用独立 EXE 回归;补充无需 NumPy/SciPy 导入的后台与代码生成检查。
- 原生 RK45 完成 `skill-test` 10 s,最大步长 0.001 s、rtol 1e-7,预热后纯求解 0.381517 s;前端生产构建通过。
- 当前生效范围与限制以 `docs/other/Python数值实现退役记录.md` 为准,前面的 Python 参考后端描述属于当日较早阶段。
+
+## 01:19
+
+- 合并 `system-optimization` 的 IR v2 规范、结构与校验器;退役依赖旧 Python 系统对象的 IR 导出器,保留 3 个冻结 IR 示例及 20 项校验回归。
+- 后端回归覆盖 280 项(1 项环境锁版本校验按配置跳过);修正心跳测试的毫秒级定时假设后,对应 API 回归通过。前端构建与 3 项 Chrome 结果展示/恢复测试通过。
+- 更新 CI 为 Linux 合同校验、Windows C 求解回归;历史基准保留原始输入与哈希,生成的 EXE/DLL 不进入提交。
diff --git a/schemas/system-numeric-ir-v2.schema.json b/schemas/system-numeric-ir-v2.schema.json
new file mode 100644
index 0000000..1cf2c38
--- /dev/null
+++ b/schemas/system-numeric-ir-v2.schema.json
@@ -0,0 +1,71 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://systemsimulationapp.local/schema/system-numeric-ir-v2.schema.json",
+ "title": "System Numeric IR v2",
+ "description": "Schema for the callback-free canonical JSON emitted by app.simulation.ir.schema.SystemIR.canonical_json_bytes().",
+ "$ref": "#/$defs/system_ir",
+ "$defs": {
+ "float64": {"type":"object","additionalProperties":false,"required":["$float64"],"properties":{"$float64":{"type":"string","pattern":"^(?!8000000000000000$)(?!(?:7ff|fff)[0-9a-f]{13}$)[0-9a-f]{16}$"}}},
+ "str": {"type":"string"},
+ "int": {"type":"integer"},
+ "int32": {"type":"integer","minimum":-2147483648,"maximum":2147483647},
+ "nonnegative_int": {"type":"integer","minimum":0},
+ "positive_int": {"type":"integer","minimum":1},
+ "bool": {"type":"boolean"},
+ "float_or_null": {"anyOf":[{"$ref":"#/$defs/float64"},{"type":"null"}]},
+ "str_or_null": {"anyOf":[{"type":"string"},{"type":"null"}]},
+ "slot_ref": {"type":"object","additionalProperties":false,"required":["$type","buffer","index"],"properties":{"$type":{"const":"slot_ref"},"buffer":{"type":"string","enum":["time","state_input","derivative_output","local_state","local_derivative","algebraic","signal","parameter","constant","mode","work_float","work_int","event_output","jacobian_value","result_output","runtime_input"]},"index":{"$ref":"#/$defs/nonnegative_int"}}},
+ "schema_version": {"type":"object","additionalProperties":false,"required":["$type","schema_id","major","minor"],"properties":{"$type":{"const":"schema_version"},"schema_id":{"const":"system-numeric-ir"},"major":{"const":2},"minor":{"const":0}}},
+ "buffer": {"type":"object","additionalProperties":false,"required":["$type","kind","dtype","size","initial_float_values","initial_int_values"],"properties":{"$type":{"const":"buffer"},"kind":{"type":"string","enum":["time","state_input","derivative_output","local_state","local_derivative","algebraic","signal","parameter","constant","mode","work_float","work_int","event_output","jacobian_value","result_output","runtime_input"]},"dtype":{"type":"string","enum":["float64","int32"]},"size":{"$ref":"#/$defs/nonnegative_int"},"initial_float_values":{"type":"array","items":{"$ref":"#/$defs/float64"}},"initial_int_values":{"type":"array","items":{"$ref":"#/$defs/int32"}}}},
+ "value": {"type":"object","additionalProperties":false,"required":["$type","value_id","slot","semantic","role","quantity","unit","scale","lower_bound","upper_bound","owner_component_index"],"properties":{"$type":{"const":"value"},"value_id":{"type":"string"},"slot":{"$ref":"#/$defs/slot_ref"},"semantic":{"type":"string"},"role":{"type":"string"},"quantity":{"type":"string"},"unit":{"type":"string"},"scale":{"$ref":"#/$defs/float64"},"lower_bound":{"$ref":"#/$defs/float_or_null"},"upper_bound":{"$ref":"#/$defs/float_or_null"},"owner_component_index":{"anyOf":[{"$ref":"#/$defs/nonnegative_int"},{"type":"null"}]} }},
+ "kernel_phase": {"type":"object","additionalProperties":false,"required":["$type","phase"],"properties":{"$type":{"const":"kernel_phase"},"phase":{"type":"string","enum":["primal","residual","derivative","property","event","reset","jacobian","output"]}}},
+ "kernel": {"type":"object","additionalProperties":false,"required":["$type","kernel_id","model_type","model_version","implementation_version","availability","unavailable_reason","phases","parameter_count","state_count","mode_count","workspace_float_count","workspace_int_count"],"properties":{"$type":{"const":"kernel"},"kernel_id":{"type":"string"},"model_type":{"type":"string"},"model_version":{"type":"string"},"implementation_version":{"type":"string"},"availability":{"type":"string","enum":["native","reference_only"]},"unavailable_reason":{"$ref":"#/$defs/str_or_null"},"phases":{"type":"array","items":{"$ref":"#/$defs/kernel_phase"}},"parameter_count":{"$ref":"#/$defs/nonnegative_int"},"state_count":{"$ref":"#/$defs/nonnegative_int"},"mode_count":{"$ref":"#/$defs/nonnegative_int"},"workspace_float_count":{"$ref":"#/$defs/nonnegative_int"},"workspace_int_count":{"$ref":"#/$defs/nonnegative_int"}}},
+ "component": {"type":"object","additionalProperties":false,"required":["$type","instance_id","kernel_index","parameter_slots","state_slots","derivative_slots","mode_slots","port_indices","port_slots","output_indices","workspace_float_slots","workspace_int_slots"],"properties":{"$type":{"const":"component"},"instance_id":{"type":"string"},"kernel_index":{"$ref":"#/$defs/nonnegative_int"},"parameter_slots":{"type":"array","items":{"$ref":"#/$defs/slot_ref"}},"state_slots":{"type":"array","items":{"$ref":"#/$defs/slot_ref"}},"derivative_slots":{"type":"array","items":{"$ref":"#/$defs/slot_ref"}},"mode_slots":{"type":"array","items":{"$ref":"#/$defs/slot_ref"}},"port_indices":{"type":"array","items":{"$ref":"#/$defs/nonnegative_int"}},"port_slots":{"type":"array","items":{"$ref":"#/$defs/slot_ref"}},"output_indices":{"type":"array","items":{"$ref":"#/$defs/nonnegative_int"}},"workspace_float_slots":{"type":"array","items":{"$ref":"#/$defs/slot_ref"}},"workspace_int_slots":{"type":"array","items":{"$ref":"#/$defs/slot_ref"}}}},
+ "port_variable": {"type":"object","additionalProperties":false,"required":["$type","variable_id","name","role","connection_rule","quantity","unit","result_visible","slot"],"properties":{"$type":{"const":"port_variable"},"variable_id":{"type":"string"},"name":{"type":"string"},"role":{"type":"string","enum":["effort","flow","stream","signal"]},"connection_rule":{"type":"string","enum":["equal","sumToZero","streamMix","directed"]},"quantity":{"type":"string"},"unit":{"type":"string"},"result_visible":{"type":"boolean"},"slot":{"$ref":"#/$defs/slot_ref"}}},
+ "port": {"type":"object","additionalProperties":false,"required":["$type","port_id","component_index","name","kind","domain","nominal_role","positive_flow_direction","variables"],"properties":{"$type":{"const":"port"},"port_id":{"type":"string"},"component_index":{"$ref":"#/$defs/nonnegative_int"},"name":{"type":"string"},"kind":{"type":"string","enum":["physical","signal"]},"domain":{"type":"string"},"nominal_role":{"type":"string","enum":["inlet","outlet","bidirectional","input","output"]},"positive_flow_direction":{"anyOf":[{"const":"intoComponent"},{"type":"null"}]},"variables":{"type":"array","items":{"$ref":"#/$defs/port_variable"}}}},
+ "connection_variable": {"type":"object","additionalProperties":false,"required":["$type","name","rule","endpoint_a_slot","endpoint_b_slot"],"properties":{"$type":{"const":"connection_variable"},"name":{"type":"string"},"rule":{"type":"string","enum":["equal","sumToZero","streamMix","directed"]},"endpoint_a_slot":{"$ref":"#/$defs/slot_ref"},"endpoint_b_slot":{"$ref":"#/$defs/slot_ref"}}},
+ "connection": {"type":"object","additionalProperties":false,"required":["$type","connection_id","kind","domain","endpoint_a_port_index","endpoint_b_port_index","variables"],"properties":{"$type":{"const":"connection"},"connection_id":{"type":"string"},"kind":{"type":"string","enum":["physical","signal"]},"domain":{"type":"string"},"endpoint_a_port_index":{"$ref":"#/$defs/nonnegative_int"},"endpoint_b_port_index":{"$ref":"#/$defs/nonnegative_int"},"variables":{"type":"array","items":{"$ref":"#/$defs/connection_variable"}}}},
+ "medium": {"type":"object","additionalProperties":false,"required":["$type","medium_id","name","implementation_id","implementation_version","parameter_slots","component_indices"],"properties":{"$type":{"const":"medium"},"medium_id":{"type":"string"},"name":{"type":"string"},"implementation_id":{"type":"string"},"implementation_version":{"type":"string"},"parameter_slots":{"type":"array","items":{"$ref":"#/$defs/slot_ref"}},"component_indices":{"type":"array","items":{"$ref":"#/$defs/nonnegative_int"}}}},
+ "csr_pattern": {"type":"object","additionalProperties":false,"required":["$type","row_count","column_count","row_pointers","column_indices"],"properties":{"$type":{"const":"csr_pattern"},"row_count":{"$ref":"#/$defs/nonnegative_int"},"column_count":{"$ref":"#/$defs/nonnegative_int"},"row_pointers":{"type":"array","items":{"$ref":"#/$defs/nonnegative_int"}},"column_indices":{"type":"array","items":{"$ref":"#/$defs/nonnegative_int"}}}},
+ "csr_matrix": {"type":"object","additionalProperties":false,"required":["$type","pattern","values"],"properties":{"$type":{"const":"csr_matrix"},"pattern":{"$ref":"#/$defs/csr_pattern"},"values":{"type":"array","items":{"$ref":"#/$defs/float64"}}}},
+ "state_reducer": {"type":"object","additionalProperties":false,"required":["$type","solver_state_count","local_state_slots","raw_derivative_slots","state_scatter","derivative_gather","initial_state","absolute_tolerances"],"properties":{"$type":{"const":"state_reducer"},"solver_state_count":{"$ref":"#/$defs/nonnegative_int"},"local_state_slots":{"type":"array","items":{"$ref":"#/$defs/slot_ref"}},"raw_derivative_slots":{"type":"array","items":{"$ref":"#/$defs/slot_ref"}},"state_scatter":{"$ref":"#/$defs/csr_matrix"},"derivative_gather":{"$ref":"#/$defs/csr_matrix"},"initial_state":{"type":"array","items":{"$ref":"#/$defs/float64"}},"absolute_tolerances":{"type":"array","items":{"$ref":"#/$defs/float64"}}}},
+ "operation": {"oneOf":[{"$ref":"#/$defs/fill"},{"$ref":"#/$defs/copy"},{"$ref":"#/$defs/scatter"},{"$ref":"#/$defs/linear_combination"},{"$ref":"#/$defs/state_map"},{"$ref":"#/$defs/kernel_call"},{"$ref":"#/$defs/effort_broadcast"},{"$ref":"#/$defs/flow_assign"},{"$ref":"#/$defs/check_finite"}]},
+ "fill": {"type":"object","additionalProperties":false,"required":["$type","opcode","target_slots","value"],"properties":{"$type":{"const":"operation"},"opcode":{"const":"fill"},"target_slots":{"type":"array","items":{"$ref":"#/$defs/slot_ref"}},"value":{"$ref":"#/$defs/float64"}}},
+ "copy": {"type":"object","additionalProperties":false,"required":["$type","opcode","source_slot","target_slot"],"properties":{"$type":{"const":"operation"},"opcode":{"const":"copy"},"source_slot":{"$ref":"#/$defs/slot_ref"},"target_slot":{"$ref":"#/$defs/slot_ref"}}},
+ "scatter": {"type":"object","additionalProperties":false,"required":["$type","opcode","source_slot","target_slots"],"properties":{"$type":{"const":"operation"},"opcode":{"const":"scatter"},"source_slot":{"$ref":"#/$defs/slot_ref"},"target_slots":{"type":"array","items":{"$ref":"#/$defs/slot_ref"}}}},
+ "linear_combination": {"type":"object","additionalProperties":false,"required":["$type","opcode","source_slots","weights","target_slot","bias"],"properties":{"$type":{"const":"operation"},"opcode":{"const":"linear_combination"},"source_slots":{"type":"array","items":{"$ref":"#/$defs/slot_ref"}},"weights":{"type":"array","items":{"$ref":"#/$defs/float64"}},"target_slot":{"$ref":"#/$defs/slot_ref"},"bias":{"$ref":"#/$defs/float64"}}},
+ "state_map": {"type":"object","additionalProperties":false,"required":["$type","opcode","map_kind","source_slots","target_slots"],"properties":{"$type":{"const":"operation"},"opcode":{"const":"state_map"},"map_kind":{"type":"string","enum":["scatter","derivative_gather"]},"source_slots":{"type":"array","items":{"$ref":"#/$defs/slot_ref"}},"target_slots":{"type":"array","items":{"$ref":"#/$defs/slot_ref"}}}},
+ "kernel_call": {"type":"object","additionalProperties":false,"required":["$type","opcode","kernel_index","component_index","phase","read_slots","write_slots","equation_indices"],"properties":{"$type":{"const":"operation"},"opcode":{"const":"kernel_call"},"kernel_index":{"$ref":"#/$defs/nonnegative_int"},"component_index":{"anyOf":[{"$ref":"#/$defs/nonnegative_int"},{"type":"null"}]},"phase":{"type":"string","enum":["primal","residual","derivative","property","event","reset","jacobian","output"]},"read_slots":{"type":"array","items":{"$ref":"#/$defs/slot_ref"}},"write_slots":{"type":"array","items":{"$ref":"#/$defs/slot_ref"}},"equation_indices":{"type":"array","items":{"$ref":"#/$defs/nonnegative_int"}}}},
+ "effort_broadcast": {"type":"object","additionalProperties":false,"required":["$type","opcode","variable","anchor_slot","residual_slot","result_slot","scatter_slots","equation_id","lower_bound","upper_bound"],"properties":{"$type":{"const":"operation"},"opcode":{"const":"effort_broadcast"},"variable":{"type":"string"},"anchor_slot":{"$ref":"#/$defs/slot_ref"},"residual_slot":{"$ref":"#/$defs/slot_ref"},"result_slot":{"$ref":"#/$defs/slot_ref"},"scatter_slots":{"type":"array","items":{"$ref":"#/$defs/slot_ref"}},"equation_id":{"type":"string"},"lower_bound":{"$ref":"#/$defs/float_or_null"},"upper_bound":{"$ref":"#/$defs/float_or_null"}}},
+ "flow_assign": {"type":"object","additionalProperties":false,"required":["$type","opcode","value_slot","result_slot","scatter_slots","equation_id"],"properties":{"$type":{"const":"operation"},"opcode":{"const":"flow_assign"},"value_slot":{"$ref":"#/$defs/slot_ref"},"result_slot":{"$ref":"#/$defs/slot_ref"},"scatter_slots":{"type":"array","items":{"$ref":"#/$defs/slot_ref"}},"equation_id":{"type":"string"}}},
+ "check_finite": {"type":"object","additionalProperties":false,"required":["$type","opcode","slots","error_code"],"properties":{"$type":{"const":"operation"},"opcode":{"const":"check_finite"},"slots":{"type":"array","items":{"$ref":"#/$defs/slot_ref"}},"error_code":{"type":"string"}}},
+ "stage": {"type":"object","additionalProperties":false,"required":["$type","stage_id","kind","operations","declared_read_slots","declared_write_slots"],"properties":{"$type":{"const":"stage"},"stage_id":{"type":"string"},"kind":{"type":"string","enum":["state_reduce","signal","mechanical_equivalence","dynamic_volume","property","pressure_flow","stream","temperature_reference","thermofluid_fixed_point","mechanical_acceleration","derivative_reduce","event","jacobian","output","reset"]},"operations":{"type":"array","items":{"$ref":"#/$defs/operation"}},"declared_read_slots":{"type":"array","items":{"$ref":"#/$defs/slot_ref"}},"declared_write_slots":{"type":"array","items":{"$ref":"#/$defs/slot_ref"}}}},
+ "step_ref": {"type":"object","additionalProperties":false,"required":["$type","kind","index"],"properties":{"$type":{"const":"step_ref"},"kind":{"type":"string","enum":["stage","block"]},"index":{"$ref":"#/$defs/nonnegative_int"}}},
+ "convergence": {"type":"object","additionalProperties":false,"required":["$type","monitor_slots","absolute_tolerance","relative_tolerance","max_iterations","relaxation","rollback_slots","failure_policy"],"properties":{"$type":{"const":"convergence"},"monitor_slots":{"type":"array","items":{"$ref":"#/$defs/slot_ref"}},"absolute_tolerance":{"$ref":"#/$defs/float64"},"relative_tolerance":{"$ref":"#/$defs/float64"},"max_iterations":{"$ref":"#/$defs/positive_int"},"relaxation":{"$ref":"#/$defs/float64"},"rollback_slots":{"type":"array","items":{"$ref":"#/$defs/slot_ref"}},"failure_policy":{"type":"string","enum":["fail","retry_smaller_step"]}}},
+ "block": {"type":"object","additionalProperties":false,"required":["$type","block_id","kind","steps","convergence"],"properties":{"$type":{"const":"block"},"block_id":{"type":"string"},"kind":{"type":"string","enum":["sequence","fixed_point","stream_scc"]},"steps":{"type":"array","items":{"$ref":"#/$defs/step_ref"}},"convergence":{"anyOf":[{"$ref":"#/$defs/convergence"},{"type":"null"}]} }},
+ "entry_point": {"type":"object","additionalProperties":false,"required":["$type","kind","steps","input_slots","output_slots"],"properties":{"$type":{"const":"entry_point"},"kind":{"type":"string","enum":["rhs","events","jacobian","outputs"]},"steps":{"type":"array","items":{"$ref":"#/$defs/step_ref"}},"input_slots":{"type":"array","items":{"$ref":"#/$defs/slot_ref"}},"output_slots":{"type":"array","items":{"$ref":"#/$defs/slot_ref"}}}},
+ "causal_effort_stage": {"type":"object","additionalProperties":false,"required":["$type","variable","stage_index"],"properties":{"$type":{"const":"causal_effort_stage"},"variable":{"type":"string"},"stage_index":{"$ref":"#/$defs/nonnegative_int"}}},
+ "causal_plan": {"type":"object","additionalProperties":false,"required":["$type","plan_id","scope_component_indices","source_schema_version","source_structural_signature","fallback_reason","canonical_slots","compatibility_slots","reset_slots","external_effort_slots","effort_stages","flow_stage_indices"],"properties":{"$type":{"const":"causal_plan"},"plan_id":{"type":"string"},"scope_component_indices":{"type":"array","items":{"$ref":"#/$defs/nonnegative_int"}},"source_schema_version":{"$ref":"#/$defs/nonnegative_int"},"source_structural_signature":{"$ref":"#/$defs/str_or_null"},"fallback_reason":{"$ref":"#/$defs/str_or_null"},"canonical_slots":{"type":"array","items":{"$ref":"#/$defs/slot_ref"}},"compatibility_slots":{"type":"array","items":{"$ref":"#/$defs/slot_ref"}},"reset_slots":{"type":"array","items":{"$ref":"#/$defs/slot_ref"}},"external_effort_slots":{"type":"array","items":{"$ref":"#/$defs/slot_ref"}},"effort_stages":{"type":"array","items":{"$ref":"#/$defs/causal_effort_stage"}},"flow_stage_indices":{"type":"array","items":{"$ref":"#/$defs/nonnegative_int"}}}},
+ "algebraic_unknown": {"type":"object","additionalProperties":false,"required":["$type","unknown_id","component_index","port_index","variable","role","slot","scale","lower_bound","upper_bound"],"properties":{"$type":{"const":"algebraic_unknown"},"unknown_id":{"type":"string"},"component_index":{"$ref":"#/$defs/nonnegative_int"},"port_index":{"$ref":"#/$defs/nonnegative_int"},"variable":{"type":"string"},"role":{"type":"string","enum":["effort","flow","stream","signal"]},"slot":{"$ref":"#/$defs/slot_ref"},"scale":{"$ref":"#/$defs/float64"},"lower_bound":{"$ref":"#/$defs/float_or_null"},"upper_bound":{"$ref":"#/$defs/float_or_null"}}},
+ "pressure_flow_equation": {"type":"object","additionalProperties":false,"required":["$type","equation_id","owner","owner_index","relation","role","variable_slots","residual_slot","scale"],"properties":{"$type":{"const":"pressure_flow_equation"},"equation_id":{"type":"string"},"owner":{"type":"string","enum":["component","connection"]},"owner_index":{"$ref":"#/$defs/nonnegative_int"},"relation":{"type":"string","enum":["equal","sumToZero","constitutive","state"]},"role":{"anyOf":[{"type":"string","enum":["effort","flow","stream","signal"]},{"type":"null"}]},"variable_slots":{"type":"array","items":{"$ref":"#/$defs/slot_ref"}},"residual_slot":{"$ref":"#/$defs/slot_ref"},"scale":{"$ref":"#/$defs/float64"}}},
+ "pressure_flow_block": {"type":"object","additionalProperties":false,"required":["$type","block_id","unknown_indices","equation_indices","jacobian_pattern"],"properties":{"$type":{"const":"pressure_flow_block"},"block_id":{"type":"string"},"unknown_indices":{"type":"array","items":{"$ref":"#/$defs/nonnegative_int"}},"equation_indices":{"type":"array","items":{"$ref":"#/$defs/nonnegative_int"}},"jacobian_pattern":{"$ref":"#/$defs/csr_pattern"}}},
+ "pressure_flow_scope": {"type":"object","additionalProperties":false,"required":["$type","scope_id","kind","component_indices","unknown_indices","equation_indices","block_indices","causal_plan_index","residual_tolerance","max_evaluations","sparse_pattern_trusted","sparse_fallback_reason"],"properties":{"$type":{"const":"pressure_flow_scope"},"scope_id":{"type":"string"},"kind":{"type":"string","enum":["network","physical_island","equation_block"]},"component_indices":{"type":"array","items":{"$ref":"#/$defs/nonnegative_int"}},"unknown_indices":{"type":"array","items":{"$ref":"#/$defs/nonnegative_int"}},"equation_indices":{"type":"array","items":{"$ref":"#/$defs/nonnegative_int"}},"block_indices":{"type":"array","items":{"$ref":"#/$defs/nonnegative_int"}},"causal_plan_index":{"anyOf":[{"$ref":"#/$defs/nonnegative_int"},{"type":"null"}]},"residual_tolerance":{"$ref":"#/$defs/float64"},"max_evaluations":{"$ref":"#/$defs/positive_int"},"sparse_pattern_trusted":{"type":"boolean"},"sparse_fallback_reason":{"$ref":"#/$defs/str_or_null"}}},
+ "pressure_flow_plan": {"type":"object","additionalProperties":false,"required":["$type","unknowns","equations","blocks","scopes","global_scope_index","secondary_scope_indices","pressure_lower_bound"],"properties":{"$type":{"const":"pressure_flow_plan"},"unknowns":{"type":"array","items":{"$ref":"#/$defs/algebraic_unknown"}},"equations":{"type":"array","items":{"$ref":"#/$defs/pressure_flow_equation"}},"blocks":{"type":"array","items":{"$ref":"#/$defs/pressure_flow_block"}},"scopes":{"type":"array","items":{"$ref":"#/$defs/pressure_flow_scope"}},"global_scope_index":{"$ref":"#/$defs/nonnegative_int"},"secondary_scope_indices":{"type":"array","items":{"$ref":"#/$defs/nonnegative_int"}},"pressure_lower_bound":{"$ref":"#/$defs/float64"}}},
+ "stream_scc": {"type":"object","additionalProperties":false,"required":["$type","scc_id","node_slots","block_index"],"properties":{"$type":{"const":"stream_scc"},"scc_id":{"type":"string"},"node_slots":{"type":"array","items":{"$ref":"#/$defs/slot_ref"}},"block_index":{"$ref":"#/$defs/nonnegative_int"}}},
+ "stream_edge": {"type":"object","additionalProperties":false,"required":["$type","source_scc_index","target_scc_index"],"properties":{"$type":{"const":"stream_edge"},"source_scc_index":{"$ref":"#/$defs/nonnegative_int"},"target_scc_index":{"$ref":"#/$defs/nonnegative_int"}}},
+ "stream_plan": {"type":"object","additionalProperties":false,"required":["$type","plan_id","node_slots","strongly_connected_components","condensed_edges","topological_scc_indices"],"properties":{"$type":{"const":"stream_plan"},"plan_id":{"type":"string"},"node_slots":{"type":"array","items":{"$ref":"#/$defs/slot_ref"}},"strongly_connected_components":{"type":"array","items":{"$ref":"#/$defs/stream_scc"}},"condensed_edges":{"type":"array","items":{"$ref":"#/$defs/stream_edge"}},"topological_scc_indices":{"type":"array","items":{"$ref":"#/$defs/nonnegative_int"}}}},
+ "thermofluid_plan": {"type":"object","additionalProperties":false,"required":["$type","physical_port_indices","global_component_indices","stream_plan_index","secondary_pressure_scope_indices","sensitive_component_indices","maximum_iterations","flow_relative_tolerance","uses_conservative_global_solver","conservative_fallback_reason"],"properties":{"$type":{"const":"thermofluid_plan"},"physical_port_indices":{"type":"array","items":{"$ref":"#/$defs/nonnegative_int"}},"global_component_indices":{"type":"array","items":{"$ref":"#/$defs/nonnegative_int"}},"stream_plan_index":{"$ref":"#/$defs/nonnegative_int"},"secondary_pressure_scope_indices":{"type":"array","items":{"$ref":"#/$defs/nonnegative_int"}},"sensitive_component_indices":{"type":"array","items":{"$ref":"#/$defs/nonnegative_int"}},"maximum_iterations":{"$ref":"#/$defs/positive_int"},"flow_relative_tolerance":{"$ref":"#/$defs/float64"},"uses_conservative_global_solver":{"type":"boolean"},"conservative_fallback_reason":{"$ref":"#/$defs/str_or_null"}}},
+ "transaction_plan": {"type":"object","additionalProperties":false,"required":["$type","snapshot_slots","flow_slots","cache_component_indices","cache_attribute_ids","diagnostic_owner_ids","restores_on_recoverable_failure","restores_on_fatal_failure"],"properties":{"$type":{"const":"transaction_plan"},"snapshot_slots":{"type":"array","items":{"$ref":"#/$defs/slot_ref"}},"flow_slots":{"type":"array","items":{"$ref":"#/$defs/slot_ref"}},"cache_component_indices":{"type":"array","items":{"$ref":"#/$defs/nonnegative_int"}},"cache_attribute_ids":{"type":"array","items":{"type":"string"}},"diagnostic_owner_ids":{"type":"array","items":{"type":"string"}},"restores_on_recoverable_failure":{"type":"boolean"},"restores_on_fatal_failure":{"type":"boolean"}}},
+ "mode_value": {"type":"object","additionalProperties":false,"required":["$type","value","name"],"properties":{"$type":{"const":"mode_value"},"value":{"$ref":"#/$defs/int32"},"name":{"type":"string"}}},
+ "mode": {"type":"object","additionalProperties":false,"required":["$type","mode_id","slot","owner_component_indices","values","initial_value"],"properties":{"$type":{"const":"mode"},"mode_id":{"type":"string"},"slot":{"$ref":"#/$defs/slot_ref"},"owner_component_indices":{"type":"array","items":{"$ref":"#/$defs/nonnegative_int"}},"values":{"type":"array","items":{"$ref":"#/$defs/mode_value"}},"initial_value":{"$ref":"#/$defs/int32"}}},
+ "finite_difference_column": {"type":"object","additionalProperties":false,"required":["$type","column_index","value_indices","relative_step"],"properties":{"$type":{"const":"finite_difference_column"},"column_index":{"$ref":"#/$defs/nonnegative_int"},"value_indices":{"type":"array","items":{"$ref":"#/$defs/nonnegative_int"}},"relative_step":{"$ref":"#/$defs/float64"}}},
+ "jacobian_plan": {"type":"object","additionalProperties":false,"required":["$type","pattern","value_slots","color_groups","fill_steps","analytic_value_indices","local_finite_difference_columns"],"properties":{"$type":{"const":"jacobian_plan"},"pattern":{"$ref":"#/$defs/csr_pattern"},"value_slots":{"type":"array","items":{"$ref":"#/$defs/slot_ref"}},"color_groups":{"type":"array","items":{"type":"array","items":{"$ref":"#/$defs/nonnegative_int"}}},"fill_steps":{"type":"array","items":{"$ref":"#/$defs/step_ref"}},"analytic_value_indices":{"type":"array","items":{"$ref":"#/$defs/nonnegative_int"}},"local_finite_difference_columns":{"type":"array","items":{"$ref":"#/$defs/finite_difference_column"}}}},
+ "mode_guard": {"type":"object","additionalProperties":false,"required":["$type","mode_slot","allowed_values"],"properties":{"$type":{"const":"mode_guard"},"mode_slot":{"$ref":"#/$defs/slot_ref"},"allowed_values":{"type":"array","items":{"$ref":"#/$defs/int32"}}}},
+ "event": {"type":"object","additionalProperties":false,"required":["$type","event_id","event_kind","owner_component_indices","root_slot","direction","terminal","priority","mode_guards","reset_steps","invalidated_caches","restarts_integrator"],"properties":{"$type":{"const":"event"},"event_id":{"type":"string"},"event_kind":{"type":"string"},"owner_component_indices":{"type":"array","items":{"$ref":"#/$defs/nonnegative_int"}},"root_slot":{"$ref":"#/$defs/slot_ref"},"direction":{"type":"string","enum":["decreasing","any","increasing"]},"terminal":{"type":"boolean"},"priority":{"type":"integer"},"mode_guards":{"type":"array","items":{"$ref":"#/$defs/mode_guard"}},"reset_steps":{"type":"array","items":{"$ref":"#/$defs/step_ref"}},"invalidated_caches":{"type":"array","items":{"type":"string","enum":["property","pressure_flow","stream","jacobian","output"]}},"restarts_integrator":{"type":"boolean"}}},
+ "output": {"type":"object","additionalProperties":false,"required":["$type","output_id","component_index","scope","port_name","name","label","category","quantity","unit","order","source_slot","output_slot","scale","offset"],"properties":{"$type":{"const":"output"},"output_id":{"type":"string"},"component_index":{"$ref":"#/$defs/nonnegative_int"},"scope":{"type":"string"},"port_name":{"$ref":"#/$defs/str_or_null"},"name":{"type":"string"},"label":{"type":"string"},"category":{"type":"string"},"quantity":{"type":"string"},"unit":{"type":"string"},"order":{"$ref":"#/$defs/nonnegative_int"},"source_slot":{"$ref":"#/$defs/slot_ref"},"output_slot":{"$ref":"#/$defs/slot_ref"},"scale":{"$ref":"#/$defs/float64"},"offset":{"$ref":"#/$defs/float64"}}},
+ "component_capability": {"type":"object","additionalProperties":false,"required":["$type","component_index","level","supported_phases","missing_features"],"properties":{"$type":{"const":"component_capability"},"component_index":{"$ref":"#/$defs/nonnegative_int"},"level":{"type":"string","enum":["native","reference_only","unsupported"]},"supported_phases":{"type":"array","items":{"type":"string","enum":["primal","residual","derivative","property","event","reset","jacobian","output"]}},"missing_features":{"type":"array","items":{"type":"string"}}}},
+ "capability_issue": {"type":"object","additionalProperties":false,"required":["$type","code","severity","scope_id","message"],"properties":{"$type":{"const":"capability_issue"},"code":{"type":"string"},"severity":{"type":"string","enum":["info","warning","error"]},"scope_id":{"type":"string"},"message":{"type":"string"}}},
+ "capability_report": {"type":"object","additionalProperties":false,"required":["$type","system_level","components","issues"],"properties":{"$type":{"const":"capability_report"},"system_level":{"type":"string","enum":["native","reference_only","unsupported"]},"components":{"type":"array","items":{"$ref":"#/$defs/component_capability"}},"issues":{"type":"array","items":{"$ref":"#/$defs/capability_issue"}}}},
+ "system_ir": {"type":"object","additionalProperties":false,"required":["$type","version","model_id","model_version","compiler_id","compiler_version","numeric_dtype","buffers","values","kernels","components","mediums","ports","connections","state_reducer","causal_plans","pressure_flow","stream_plans","thermofluid","stages","blocks","entry_points","transaction","modes","jacobian","events","outputs","capabilities","required_features"],"properties":{"$type":{"const":"system_ir"},"version":{"$ref":"#/$defs/schema_version"},"model_id":{"type":"string"},"model_version":{"type":"string"},"compiler_id":{"type":"string"},"compiler_version":{"type":"string"},"numeric_dtype":{"const":"float64"},"buffers":{"type":"array","items":{"$ref":"#/$defs/buffer"}},"values":{"type":"array","items":{"$ref":"#/$defs/value"}},"kernels":{"type":"array","items":{"$ref":"#/$defs/kernel"}},"components":{"type":"array","items":{"$ref":"#/$defs/component"}},"mediums":{"type":"array","items":{"$ref":"#/$defs/medium"}},"ports":{"type":"array","items":{"$ref":"#/$defs/port"}},"connections":{"type":"array","items":{"$ref":"#/$defs/connection"}},"state_reducer":{"$ref":"#/$defs/state_reducer"},"causal_plans":{"type":"array","items":{"$ref":"#/$defs/causal_plan"}},"pressure_flow":{"$ref":"#/$defs/pressure_flow_plan"},"stream_plans":{"type":"array","items":{"$ref":"#/$defs/stream_plan"}},"thermofluid":{"$ref":"#/$defs/thermofluid_plan"},"stages":{"type":"array","items":{"$ref":"#/$defs/stage"}},"blocks":{"type":"array","items":{"$ref":"#/$defs/block"}},"entry_points":{"type":"array","items":{"$ref":"#/$defs/entry_point"}},"transaction":{"$ref":"#/$defs/transaction_plan"},"modes":{"type":"array","items":{"$ref":"#/$defs/mode"}},"jacobian":{"$ref":"#/$defs/jacobian_plan"},"events":{"type":"array","items":{"$ref":"#/$defs/event"}},"outputs":{"type":"array","items":{"$ref":"#/$defs/output"}},"capabilities":{"$ref":"#/$defs/capability_report"},"required_features":{"type":"array","items":{"type":"string"}}}}
+ }
+}
diff --git a/tests/data/system-ir-v2-reference.json.gz b/tests/data/system-ir-v2-reference.json.gz
new file mode 100644
index 0000000..3c0c623
Binary files /dev/null and b/tests/data/system-ir-v2-reference.json.gz differ
diff --git a/tests/ir_reference.py b/tests/ir_reference.py
new file mode 100644
index 0000000..a198c34
--- /dev/null
+++ b/tests/ir_reference.py
@@ -0,0 +1,35 @@
+"""Read the archived IR schema examples without a Python numerical engine."""
+from dataclasses import is_dataclass
+from enum import Enum
+from functools import lru_cache
+import gzip
+import json
+from pathlib import Path
+
+from app.simulation.ir import schema
+
+
+def _decode(value):
+ if isinstance(value, list):
+ return tuple(_decode(item) for item in value)
+ if isinstance(value, dict):
+ if 'enum' in value:
+ cls = getattr(schema, value['enum'])
+ if not isinstance(cls, type) or not issubclass(cls, Enum):
+ raise ValueError('Invalid IR enum in fixture')
+ return cls(value['value'])
+ cls = getattr(schema, value['type'])
+ if not isinstance(cls, type) or not is_dataclass(cls):
+ raise ValueError('Invalid IR record in fixture')
+ return cls(**{key: _decode(item) for key, item in value['fields'].items()})
+ return value
+
+
+@lru_cache(maxsize=1)
+def _references():
+ path = Path(__file__).parent / 'data/system-ir-v2-reference.json.gz'
+ return json.loads(gzip.decompress(path.read_bytes()))['programs']
+
+
+def reference_ir(name):
+ return _decode(_references()[name])
diff --git a/tests/test_generic_system_xml_simulation.py b/tests/test_generic_system_xml_simulation.py
index 5905678..04d5d26 100644
--- a/tests/test_generic_system_xml_simulation.py
+++ b/tests/test_generic_system_xml_simulation.py
@@ -423,6 +423,8 @@ class GenericSystemXmlSimulationTests(unittest.TestCase):
self.assertEqual(snapshot["result"]["status"], "stopped")
def test_streaming_endpoint_keeps_quiet_solver_connection_alive(self) -> None:
+ heartbeat_observed = threading.Event()
+
def delayed_simulation(
_xml_bytes,
progress_callback,
@@ -433,8 +435,10 @@ class GenericSystemXmlSimulationTests(unittest.TestCase):
self.assertIsNotNone(activity_tracker)
activity_tracker.start_integration(0.0)
for trial_time in (0.0487, 0.0488, 0.0489, 0.0490):
+ heartbeat_observed.clear()
activity_tracker.record_rhs(trial_time)
- time.sleep(0.008)
+ # Wait for the consumer, independent of machine scheduling.
+ self.assertTrue(heartbeat_observed.wait(timeout=2.0))
return {
"success": True,
"status": "completed",
@@ -449,10 +453,12 @@ class GenericSystemXmlSimulationTests(unittest.TestCase):
side_effect=delayed_simulation,
),
):
- events = [
- json.loads(line)
- for line in simulation_event_stream(b"")
- ]
+ events = []
+ for line in simulation_event_stream(b""):
+ event = json.loads(line)
+ events.append(event)
+ if event.get("heartbeat"):
+ heartbeat_observed.set()
heartbeats = [event for event in events if event.get("heartbeat") is True]
self.assertGreaterEqual(len(heartbeats), 1)
diff --git a/tests/test_system_numeric_ir_v2.py b/tests/test_system_numeric_ir_v2.py
new file mode 100644
index 0000000..c3639a1
--- /dev/null
+++ b/tests/test_system_numeric_ir_v2.py
@@ -0,0 +1,748 @@
+from __future__ import annotations
+
+from dataclasses import fields, is_dataclass, replace
+import json
+from pathlib import Path
+import unittest
+
+from app.main import compile_reactflow_network, compile_system_xml_network
+from app.simulation.ir import schema as ir_schema
+from app.simulation.ir import (
+ IRBufferKind,
+ IRCapabilityLevel,
+ IRDType,
+ IREntryPointKind,
+ IRKernelAvailability,
+ IRKernelCallOperation,
+ IRNativeBuildIdentity,
+ IRStageKind,
+ IRStepKind,
+ canonical_json_bytes,
+ compile_system_ir,
+ native_artifact_key,
+)
+from app.simulation.ir.validation import (
+ SystemIRValidationError,
+ require_valid_system_ir,
+ validate_system_ir,
+)
+from tests.ir_reference import reference_ir
+from app.system_xml import validate_system_xml_document
+from tests.test_amesim_mechanical_xml import zero_force_mass_project
+
+
+TARGET_XML = Path("tests/data/test-mql-8.xml")
+HISTORICAL_XML = Path("tests/data/test_mql-full-branches-01-04.xml")
+MACHINE_SCHEMA = Path("schemas/system-numeric-ir-v2.schema.json")
+
+
+
+
+def _entry_stage_kinds(program, entry_kind: IREntryPointKind) -> set[IRStageKind]:
+ entry = next(item for item in program.entry_points if item.kind is entry_kind)
+ result: set[IRStageKind] = set()
+ visited_blocks: set[int] = set()
+
+ def visit(step) -> None:
+ if step.kind is IRStepKind.STAGE:
+ result.add(program.stages[step.index].kind)
+ return
+ if step.index in visited_blocks:
+ return
+ visited_blocks.add(step.index)
+ for nested in program.blocks[step.index].steps:
+ visit(nested)
+
+ for step in entry.steps:
+ visit(step)
+ return result
+
+
+def _assert_callback_free(test: unittest.TestCase, value: object) -> None:
+ if is_dataclass(value) and not isinstance(value, type):
+ for item in fields(value):
+ _assert_callback_free(test, getattr(value, item.name))
+ return
+ if isinstance(value, tuple):
+ for item in value:
+ _assert_callback_free(test, item)
+ return
+ test.assertFalse(callable(value), type(value).__name__)
+ test.assertNotIsInstance(value, (dict, list, set))
+
+
+class SystemNumericIRV2ContractTests(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls) -> None:
+ cls.program = reference_ir('mechanical')
+
+ def test_reference_schema_is_statically_valid_and_callback_free(self) -> None:
+ report = validate_system_ir(self.program)
+
+ self.assertTrue(report.valid, report.issues)
+ self.assertIs(require_valid_system_ir(self.program), self.program)
+ _assert_callback_free(self, self.program)
+ with self.assertRaises(TypeError):
+ canonical_json_bytes(lambda: None)
+
+ def test_canonical_bytes_and_signature_are_deterministic(self) -> None:
+ second = reference_ir('mechanical')
+
+ self.assertEqual(
+ self.program.canonical_json_bytes(),
+ second.canonical_json_bytes(),
+ )
+ self.assertEqual(
+ self.program.structural_signature,
+ second.structural_signature,
+ )
+ self.assertEqual(len(self.program.structural_signature), 64)
+ self.assertEqual(
+ self.program.structural_signature,
+ self.program.calculate_structural_signature(),
+ )
+
+ decomposed = replace(self.program, model_id="e\u0301")
+ composed = replace(self.program, model_id="é")
+ self.assertEqual(
+ decomposed.canonical_json_bytes(),
+ composed.canonical_json_bytes(),
+ )
+
+
+ def test_native_artifact_key_is_separate_from_structural_signature(self) -> None:
+ windows = IRNativeBuildIdentity(
+ abi_version=1,
+ target_triple="x86_64-pc-windows-msvc",
+ compiler_id="msvc",
+ compiler_version="19.40",
+ compile_flags=("/O2", "/fp:precise"),
+ floating_point_policy="strict",
+ kernel_library_signature="a" * 64,
+ )
+ linux = replace(
+ windows,
+ target_triple="x86_64-unknown-linux-gnu",
+ compiler_id="gcc",
+ compiler_version="14.2",
+ compile_flags=("-O2", "-fno-fast-math"),
+ )
+
+ signature = self.program.structural_signature
+ self.assertNotEqual(
+ native_artifact_key(self.program, windows),
+ native_artifact_key(self.program, linux),
+ )
+ self.assertEqual(self.program.structural_signature, signature)
+ with self.assertRaises(ValueError):
+ native_artifact_key(
+ self.program,
+ replace(windows, abi_version=windows.abi_version + 1),
+ )
+
+ def test_four_entry_points_are_independent(self) -> None:
+ self.assertEqual(
+ {item.kind for item in self.program.entry_points},
+ set(IREntryPointKind),
+ )
+ rhs_kinds = _entry_stage_kinds(self.program, IREntryPointKind.RHS)
+ event_kinds = _entry_stage_kinds(
+ self.program, IREntryPointKind.EVENTS
+ )
+
+ self.assertIn(IRStageKind.DERIVATIVE_REDUCE, rhs_kinds)
+ self.assertNotIn(IRStageKind.EVENT, rhs_kinds)
+ self.assertNotIn(IRStageKind.JACOBIAN, rhs_kinds)
+ self.assertNotIn(IRStageKind.OUTPUT, rhs_kinds)
+ self.assertIn(IRStageKind.EVENT, event_kinds)
+ self.assertNotIn(IRStageKind.JACOBIAN, event_kinds)
+ self.assertNotIn(IRStageKind.OUTPUT, event_kinds)
+
+ def test_validator_rejects_a_missing_entry_point(self) -> None:
+ broken = replace(
+ self.program,
+ entry_points=self.program.entry_points[:-1],
+ )
+
+ report = validate_system_ir(broken)
+ self.assertFalse(report.valid)
+ self.assertIn(
+ "ENTRY_POINT_SET_INVALID",
+ {item.code for item in report.issues},
+ )
+ with self.assertRaises(SystemIRValidationError):
+ require_valid_system_ir(broken)
+
+ def test_validator_rejects_entry_contract_and_buffer_dtype_corruption(self) -> None:
+ rhs_index = next(
+ index
+ for index, entry in enumerate(self.program.entry_points)
+ if entry.kind is IREntryPointKind.RHS
+ )
+ events_entry = next(
+ entry
+ for entry in self.program.entry_points
+ if entry.kind is IREntryPointKind.EVENTS
+ )
+ corrupted_entries = list(self.program.entry_points)
+ corrupted_entries[rhs_index] = replace(
+ corrupted_entries[rhs_index],
+ steps=events_entry.steps,
+ output_slots=(),
+ )
+ entry_report = validate_system_ir(
+ replace(self.program, entry_points=tuple(corrupted_entries))
+ )
+ self.assertTrue(
+ {
+ "ENTRY_POINT_OUTPUT_COVERAGE",
+ "ENTRY_POINT_FINAL_STAGE_MISSING",
+ "ENTRY_POINT_STAGE_FORBIDDEN",
+ }.issubset({item.code for item in entry_report.issues})
+ )
+
+ state_buffer_index = next(
+ index
+ for index, buffer in enumerate(self.program.buffers)
+ if buffer.kind is IRBufferKind.STATE_INPUT
+ )
+ corrupted_buffers = list(self.program.buffers)
+ corrupted_buffers[state_buffer_index] = replace(
+ corrupted_buffers[state_buffer_index],
+ dtype=IRDType.INT32,
+ initial_float_values=(),
+ initial_int_values=tuple(
+ 0 for _ in range(corrupted_buffers[state_buffer_index].size)
+ ),
+ )
+ dtype_report = validate_system_ir(
+ replace(self.program, buffers=tuple(corrupted_buffers))
+ )
+ self.assertIn(
+ "BUFFER_DTYPE_INVALID",
+ {item.code for item in dtype_report.issues},
+ )
+
+ def test_validator_rejects_a_component_call_bound_to_another_kernel(self) -> None:
+ stage_index, operation_index, operation = next(
+ (stage_index, operation_index, operation)
+ for stage_index, stage in enumerate(self.program.stages)
+ for operation_index, operation in enumerate(stage.operations)
+ if isinstance(operation, IRKernelCallOperation)
+ and operation.component_index is not None
+ )
+ wrong_kernel_index = next(
+ index
+ for index in range(len(self.program.kernels))
+ if index != operation.kernel_index
+ )
+ broken_operations = list(self.program.stages[stage_index].operations)
+ broken_operations[operation_index] = replace(
+ operation,
+ kernel_index=wrong_kernel_index,
+ )
+ broken_stages = list(self.program.stages)
+ broken_stages[stage_index] = replace(
+ broken_stages[stage_index],
+ operations=tuple(broken_operations),
+ )
+
+ report = validate_system_ir(
+ replace(self.program, stages=tuple(broken_stages))
+ )
+
+ self.assertFalse(report.valid)
+ self.assertIn(
+ "KERNEL_COMPONENT_MISMATCH",
+ {item.code for item in report.issues},
+ )
+
+ def test_validator_rejects_native_component_with_missing_called_phases(self) -> None:
+ native_kernels = tuple(
+ replace(
+ kernel,
+ availability=IRKernelAvailability.NATIVE,
+ unavailable_reason=None,
+ )
+ for kernel in self.program.kernels
+ )
+ native_capabilities = tuple(
+ replace(
+ capability,
+ level=IRCapabilityLevel.NATIVE,
+ supported_phases=(),
+ missing_features=(),
+ )
+ for capability in self.program.capabilities.components
+ )
+ broken = replace(
+ self.program,
+ kernels=native_kernels,
+ required_features=tuple(
+ feature
+ for feature in self.program.required_features
+ if feature != "reference_kernel_dispatch"
+ ),
+ transaction=replace(
+ self.program.transaction,
+ cache_attribute_ids=(),
+ ),
+ capabilities=replace(
+ self.program.capabilities,
+ system_level=IRCapabilityLevel.NATIVE,
+ components=native_capabilities,
+ issues=(),
+ ),
+ )
+
+ report = validate_system_ir(broken)
+
+ self.assertFalse(report.valid)
+ self.assertIn(
+ "CAPABILITY_NATIVE_PHASE_MISSING",
+ {item.code for item in report.issues},
+ )
+
+ def test_validator_rejects_runtime_types_that_break_the_wire_schema(self) -> None:
+ outputs = list(self.program.outputs)
+ outputs[0] = replace(outputs[0], scale=1)
+
+ report = validate_system_ir(
+ replace(self.program, outputs=tuple(outputs))
+ )
+
+ self.assertFalse(report.valid)
+ self.assertIn(
+ "RUNTIME_TYPE_MISMATCH",
+ {item.code for item in report.issues},
+ )
+
+ def test_validator_propagates_unsupported_component_to_system_level(self) -> None:
+ capabilities = list(self.program.capabilities.components)
+ capabilities[0] = replace(
+ capabilities[0],
+ level=IRCapabilityLevel.UNSUPPORTED,
+ )
+
+ report = validate_system_ir(
+ replace(
+ self.program,
+ capabilities=replace(
+ self.program.capabilities,
+ components=tuple(capabilities),
+ ),
+ )
+ )
+
+ self.assertFalse(report.valid)
+ self.assertIn(
+ "CAPABILITY_LEVEL_CONFLICT",
+ {item.code for item in report.issues},
+ )
+
+ def test_machine_schema_has_no_dangling_local_references(self) -> None:
+ schema = json.loads(MACHINE_SCHEMA.read_text(encoding="utf-8"))
+ definitions = schema["$defs"]
+ references: list[str] = []
+ pending: list[object] = [schema]
+ while pending:
+ current = pending.pop()
+ if isinstance(current, dict):
+ references.extend(
+ value
+ for key, value in current.items()
+ if key == "$ref" and isinstance(value, str)
+ )
+ pending.extend(current.values())
+ elif isinstance(current, list):
+ pending.extend(current)
+
+ self.assertFalse(
+ {
+ reference
+ for reference in references
+ if reference.startswith("#/$defs/")
+ and reference.removeprefix("#/$defs/") not in definitions
+ }
+ )
+ payload = json.loads(self.program.canonical_json_bytes())
+ self.assertEqual(payload["$type"], "system_ir")
+ self.assertEqual(
+ set(payload),
+ set(definitions["system_ir"]["required"]),
+ )
+ self.assertEqual(
+ definitions["kernel_phase"]["required"],
+ ["$type", "phase"],
+ )
+
+ def test_machine_schema_fields_match_every_serialized_dataclass(self) -> None:
+ definitions = json.loads(
+ MACHINE_SCHEMA.read_text(encoding="utf-8")
+ )["$defs"]
+ skipped_types = {"native_build", "native_artifact_key_input"}
+
+ for value_type, canonical_type in ir_schema._CANONICAL_TYPE_NAMES:
+ if canonical_type in skipped_types:
+ continue
+ definition_name = (
+ value_type.opcode.value
+ if canonical_type == "operation"
+ else canonical_type
+ )
+ definition = definitions[definition_name]
+ expected_fields = {"$type", *(item.name for item in fields(value_type))}
+ if canonical_type == "operation":
+ expected_fields.add("opcode")
+
+ self.assertEqual(
+ set(definition["required"]),
+ expected_fields,
+ definition_name,
+ )
+ self.assertEqual(
+ set(definition["properties"]),
+ expected_fields,
+ definition_name,
+ )
+ self.assertFalse(
+ definition["additionalProperties"],
+ definition_name,
+ )
+
+
+class TargetSystemNumericIRV2Tests(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls) -> None:
+ cls.program = reference_ir('target')
+
+ def test_target_model_is_fully_described(self) -> None:
+ program = self.program
+
+ self.assertEqual(len(program.components), 156)
+ self.assertEqual(len(program.ports), 356)
+ self.assertEqual(len(program.connections), 178)
+ self.assertEqual(program.state_reducer.solver_state_count, 132)
+ self.assertEqual(len(program.pressure_flow.unknowns), 776)
+ self.assertEqual(len(program.pressure_flow.equations), 776)
+ self.assertEqual(len(program.outputs), 1784)
+ self.assertEqual(program.jacobian.pattern.row_count, 132)
+ self.assertEqual(program.jacobian.pattern.column_count, 132)
+ self.assertGreater(program.jacobian.pattern.nonzero_count, 132)
+ self.assertLessEqual(len(program.jacobian.color_groups), 132)
+
+ self.assertEqual(len(program.causal_plans), 1)
+ causal = program.causal_plans[0]
+ self.assertEqual(len(causal.canonical_slots), 452)
+ self.assertEqual(len(causal.compatibility_slots), 776)
+ self.assertIsNone(causal.fallback_reason)
+
+ self.assertEqual(len(program.modes), 2)
+ self.assertEqual(len(program.events), 14)
+ self.assertTrue(program.thermofluid.sensitive_component_indices)
+ self.assertEqual(
+ program.thermofluid.secondary_pressure_scope_indices,
+ program.pressure_flow.secondary_scope_indices,
+ )
+
+ def test_state_reducer_and_all_buffers_have_complete_index_contracts(self) -> None:
+ reducer = self.program.state_reducer
+ self.assertEqual(
+ (reducer.state_scatter.pattern.row_count,
+ reducer.state_scatter.pattern.column_count),
+ (len(reducer.local_state_slots), reducer.solver_state_count),
+ )
+ self.assertEqual(
+ (reducer.derivative_gather.pattern.row_count,
+ reducer.derivative_gather.pattern.column_count),
+ (reducer.solver_state_count, len(reducer.raw_derivative_slots)),
+ )
+
+ by_buffer: dict[IRBufferKind, list[int]] = {
+ buffer.kind: [] for buffer in self.program.buffers
+ }
+ for value in self.program.values:
+ by_buffer[value.slot.buffer].append(value.slot.index)
+ for buffer in self.program.buffers:
+ self.assertEqual(
+ sorted(by_buffer[buffer.kind]),
+ list(range(buffer.size)),
+ buffer.kind,
+ )
+
+ def test_transaction_tracks_exactly_the_active_pneumatic_flows(self) -> None:
+ pneumatic_flow_slots = {
+ variable.slot
+ for port in self.program.ports
+ if port.kind.value == "physical" and port.domain == "pneumatic"
+ for variable in port.variables
+ if variable.name == "m_flow"
+ }
+
+ self.assertEqual(
+ set(self.program.transaction.flow_slots),
+ pneumatic_flow_slots,
+ )
+
+ def test_reference_archive_does_not_claim_native_execution(self) -> None:
+ self.assertIs(
+ self.program.capabilities.system_level,
+ IRCapabilityLevel.REFERENCE_ONLY,
+ )
+ self.assertTrue(self.program.capabilities.components)
+ self.assertTrue(
+ all(
+ item.level is IRCapabilityLevel.REFERENCE_ONLY
+ for item in self.program.capabilities.components
+ )
+ )
+ self.assertIn(
+ "IR_NATIVE_KERNELS_NOT_DECLARED",
+ {item.code for item in self.program.capabilities.issues},
+ )
+
+ def test_validator_rejects_cross_plan_and_sparse_contract_corruption(self) -> None:
+ program = self.program
+ variants: list[tuple[str, object, str]] = []
+
+ components = list(program.components)
+ foreign_port = next(
+ index
+ for index, port in enumerate(program.ports)
+ if port.component_index != 0
+ )
+ components[0] = replace(
+ components[0],
+ port_indices=(*components[0].port_indices, foreign_port),
+ )
+ variants.append(
+ (
+ "component port back-reference",
+ replace(program, components=tuple(components)),
+ "COMPONENT_PORT_COVERAGE",
+ )
+ )
+
+ variants.append(
+ (
+ "thermofluid scope mismatch",
+ replace(
+ program,
+ thermofluid=replace(
+ program.thermofluid,
+ secondary_pressure_scope_indices=(),
+ ),
+ ),
+ "THERMOFLUID_SCOPE_MISMATCH",
+ )
+ )
+
+ equations = list(program.pressure_flow.equations)
+ equations[1] = replace(
+ equations[1],
+ residual_slot=equations[0].residual_slot,
+ )
+ variants.append(
+ (
+ "pressure-flow residual slot alias",
+ replace(
+ program,
+ pressure_flow=replace(
+ program.pressure_flow,
+ equations=tuple(equations),
+ ),
+ ),
+ "PRESSURE_FLOW_RESIDUAL_SLOT_DUPLICATE",
+ )
+ )
+
+ buffers = list(program.buffers)
+ state_buffer_index = next(
+ index
+ for index, buffer in enumerate(buffers)
+ if buffer.kind is IRBufferKind.STATE_INPUT
+ )
+ state_values = list(buffers[state_buffer_index].initial_float_values)
+ state_values[0] += 1.0
+ buffers[state_buffer_index] = replace(
+ buffers[state_buffer_index],
+ initial_float_values=tuple(state_values),
+ )
+ variants.append(
+ (
+ "state initial value disagreement",
+ replace(program, buffers=tuple(buffers)),
+ "STATE_INITIAL_VALUE_MISMATCH",
+ )
+ )
+
+ aliased_values = (
+ program.jacobian.value_slots[0],
+ program.jacobian.value_slots[0],
+ *program.jacobian.value_slots[2:],
+ )
+ variants.append(
+ (
+ "Jacobian value slot alias",
+ replace(
+ program,
+ jacobian=replace(
+ program.jacobian,
+ value_slots=aliased_values,
+ ),
+ ),
+ "JACOBIAN_VALUE_SLOT_COVERAGE",
+ )
+ )
+
+ variants.append(
+ (
+ "Jacobian color conflict",
+ replace(
+ program,
+ jacobian=replace(
+ program.jacobian,
+ color_groups=(
+ tuple(range(program.state_reducer.solver_state_count)),
+ ),
+ ),
+ ),
+ "JACOBIAN_COLOR_CONFLICT",
+ )
+ )
+
+ fd_columns = list(program.jacobian.local_finite_difference_columns)
+ fd_column = fd_columns[0]
+ wrong_value_index = next(
+ index
+ for index, column in enumerate(
+ program.jacobian.pattern.column_indices
+ )
+ if column != fd_column.column_index
+ )
+ fd_columns[0] = replace(
+ fd_column,
+ value_indices=(wrong_value_index, *fd_column.value_indices[1:]),
+ )
+ variants.append(
+ (
+ "Jacobian finite-difference column mismatch",
+ replace(
+ program,
+ jacobian=replace(
+ program.jacobian,
+ local_finite_difference_columns=tuple(fd_columns),
+ ),
+ ),
+ "JACOBIAN_FD_COLUMN_MISMATCH",
+ )
+ )
+
+ capabilities = list(program.capabilities.components)
+ capabilities[0] = replace(
+ capabilities[0],
+ level=IRCapabilityLevel.NATIVE,
+ missing_features=(),
+ )
+ variants.append(
+ (
+ "native capability overclaim",
+ replace(
+ program,
+ capabilities=replace(
+ program.capabilities,
+ components=tuple(capabilities),
+ ),
+ ),
+ "CAPABILITY_KERNEL_MISMATCH",
+ )
+ )
+
+ variants.append(
+ (
+ "duplicate transaction flow",
+ replace(
+ program,
+ transaction=replace(
+ program.transaction,
+ flow_slots=(
+ *program.transaction.flow_slots,
+ program.transaction.flow_slots[0],
+ ),
+ ),
+ ),
+ "TRANSACTION_DUPLICATE_SLOT",
+ )
+ )
+
+ modes = list(program.modes)
+ modes[1] = replace(modes[1], slot=modes[0].slot)
+ variants.append(
+ (
+ "duplicate mode slot",
+ replace(program, modes=tuple(modes)),
+ "MODE_SLOT_DUPLICATE",
+ )
+ )
+
+ gather_pattern = program.state_reducer.derivative_gather.pattern
+ gather_pointers = list(gather_pattern.row_pointers)
+ gather_pointers[1] = gather_pointers[0]
+ variants.append(
+ (
+ "empty derivative row",
+ replace(
+ program,
+ state_reducer=replace(
+ program.state_reducer,
+ derivative_gather=replace(
+ program.state_reducer.derivative_gather,
+ pattern=replace(
+ gather_pattern,
+ row_pointers=tuple(gather_pointers),
+ ),
+ ),
+ ),
+ ),
+ "DERIVATIVE_GATHER_EMPTY_ROW",
+ )
+ )
+
+ for label, corrupted, expected_code in variants:
+ with self.subTest(label=label):
+ report = validate_system_ir(corrupted)
+ self.assertFalse(report.valid)
+ self.assertIn(
+ expected_code,
+ {item.code for item in report.issues},
+ )
+
+ def test_target_reference_reload_is_byte_stable(self) -> None:
+ second = reference_ir('target')
+
+ self.assertEqual(
+ self.program.canonical_json_bytes(),
+ second.canonical_json_bytes(),
+ )
+
+
+class HistoricalSystemNumericIRV2Tests(unittest.TestCase):
+ def test_historical_complex_model_schema_remains_valid(self) -> None:
+ program = reference_ir('historical')
+
+ self.assertTrue(validate_system_ir(program).valid)
+ self.assertEqual(len(program.components), 98)
+ self.assertEqual(len(program.connections), 106)
+ self.assertEqual(program.state_reducer.solver_state_count, 74)
+ self.assertEqual(len(program.pressure_flow.unknowns), 472)
+ self.assertEqual(len(program.pressure_flow.equations), 472)
+ self.assertEqual(len(program.outputs), 1021)
+
+
+if __name__ == "__main__":
+ unittest.main()
+
+class RetiredIRExporterTests(unittest.TestCase):
+ def test_legacy_exporter_fails_with_a_migration_message(self):
+ with self.assertRaisesRegex(NotImplementedError, 'retired'):
+ compile_system_ir(object())