diff --git a/.github/workflows/solver-regression.yml b/.github/workflows/solver-regression.yml index 0b23e46..6d9d3b9 100644 --- a/.github/workflows/solver-regression.yml +++ b/.github/workflows/solver-regression.yml @@ -49,7 +49,7 @@ jobs: run: | python -m pip install -r constraints/python312-linux-x86_64.lock python -m pip check - - name: Validate portable metadata and IR contracts + - name: Validate portable metadata and XML contracts env: SYSTEM_SIMULATION_VERIFY_LOCKED_ENV: "1" run: | @@ -61,7 +61,6 @@ jobs: 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 native-windows: diff --git a/app/simulation/README.md b/app/simulation/README.md index d85da7c..a028539 100644 --- a/app/simulation/README.md +++ b/app/simulation/README.md @@ -14,4 +14,6 @@ 旧 Python 积分器、数值组件方法和固定算例专用求解器已经退役。旧 `/api/reactflow/simulate-testmodel`、`/api/reactflow/simulate-test-mql` 返回 410;使用 `/api/system-xml/simulate` 或流式接口。 +旧 `ir/` 包、专属 schema、规范与测试已删除。当前 C 生成器直接使用经过校验的网络结构,不依赖旧 System IR v2。 + 运行、支持范围与依赖见 [C 后端说明](../../native/README.md)。模型开发规则见 [组件规范](../../docs/standard/component-model-authoring-spec-v1.md)。历史性能、Amesim 差异和旧公式说明位于 `docs/other/` 及 Git 历史,不能作为当前运行入口。 diff --git a/app/simulation/ir/__init__.py b/app/simulation/ir/__init__.py deleted file mode 100644 index 1698523..0000000 --- a/app/simulation/ir/__init__.py +++ /dev/null @@ -1,40 +0,0 @@ -"""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 deleted file mode 100644 index 9cd276e..0000000 --- a/app/simulation/ir/compiler.py +++ /dev/null @@ -1,14 +0,0 @@ -"""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 deleted file mode 100644 index 5bd902d..0000000 --- a/app/simulation/ir/schema.py +++ /dev/null @@ -1,973 +0,0 @@ -"""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 deleted file mode 100644 index cf0564e..0000000 --- a/app/simulation/ir/validation.py +++ /dev/null @@ -1,1583 +0,0 @@ -"""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/app/simulation/native_codegen/compiler.py b/app/simulation/native_codegen/compiler.py index 6b7e577..ec688e2 100644 --- a/app/simulation/native_codegen/compiler.py +++ b/app/simulation/native_codegen/compiler.py @@ -280,7 +280,7 @@ def _compile_storage_anchored_program(network: SimulationNetwork) -> NativeProgr put(c, "port_3.h_outflow", get(a, "h")) for pname in ("port_2", "port_3"): other = adjacent[c.name, pname] - # A resistance-to-resistance stream path requires a fuller IR. + # Resistance-to-resistance streams need the extended pressure/enthalpy closure. if network.components[other[0]] not in chambers: raise NativeCapabilityError(f"{c.name}: native v1 requires valve ports directly connected to storage.") lines.append(f"{w(f'{other[0]}.{other[1]}.m_flow')} = -{get(c, pname + '.m_flow')};") diff --git a/docs/README.md b/docs/README.md index 3103141..6ef2807 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,10 +12,6 @@ 新增或移动文档时,应根据文档用途放入对应目录。目录链接可用于查看其中的全部文档,无需在本文件中逐项维护清单。 -## 重点实施计划 - -- [C 语言数值内核实施计划与可行性评估](other/C语言数值内核实施计划与可行性评估.md):后端数值内核的分阶段迁移顺序、验收门、风险和可行性评估。 - ## `update-log` 书写规范 以下规范适用于新建和后续追加的日志。历史日志缺少准确完成时间时,不猜测或补写时间。 diff --git a/docs/other/C语言数值内核实施计划与可行性评估.md b/docs/other/C语言数值内核实施计划与可行性评估.md deleted file mode 100644 index b5f0636..0000000 --- a/docs/other/C语言数值内核实施计划与可行性评估.md +++ /dev/null @@ -1,544 +0,0 @@ -# C 语言数值内核实施计划与可行性评估 - -> 文档状态:历史阶段方案;当前默认 C 且已退役 Python 数值实现,见 [退役记录](Python数值实现退役记录.md)。 -> 建立日期:2026-09-02 -> 补充日期:2026-09-09;第 10 节结合最新实测,细化 XML → 系统专用 C → 原生 EXE 路线。前九节保留原阶段方案与历史评估;本次仅补充设计,未完成生产内核替换。 -> 适用分支:`model-development` -> 关联文档:[求解器性能优化任务清单](求解器性能优化任务清单.md)、[后端求解逻辑与效率优化调研](后端求解逻辑与效率优化调研.md) -> 范围:后端数值执行内核;不包含前端重写,也不主张把整个 Python 后端改写为 C - -## 1. 结论 - -这条路线值得实施,但正确的目标不是“把现有 Python 代码逐行翻译成 C”,而是: - -1. 保留 Python 作为模型解析、网络编译、任务管理、结果服务和正确性参考; -2. 先把完整的一次 RHS 计算编译成无 Python 回调的扁平数值 IR; -3. 再让 C 内核一次完成整次 RHS、stream、物性、Jacobian 和事件计算; -4. Python 旧引擎长期保留为不支持模型的兼容路径和故障回退路径。 - -总体技术可行性为**中高**。仓库已经具备因果计划、参考 IR、稀疏 Jacobian 结构、性能埋点和 AMESim 回归基础,因此不是从零开始。主要难点不是 C 语法,而是完整冻结当前模型的计算顺序、副作用、事件和回滚语义。 - -“实现稳定的 C 数值后端”具有较高可能性;“仅靠换成 C 就达到接近 AMESim 的速度”目前不能承诺。高刚度 LSTP 接触会让积分器产生大量纳秒级小步,有限差分 Jacobian 又会重复调用 RHS。C 能降低每次计算的成本,但不会自动减少这些计算次数。真正的大幅收益需要同时完成: - -- 完整扁平 IR; -- 整体 C RHS; -- 稀疏解析或半解析 Jacobian; -- 正确的接触事件和模式切换; -- 必要时再评估原生积分器。 - -## 2. 推荐的最终边界 - -```text -XML / JSON - ↓ -Python:解析、校验、组件注册、网络编译 - ↓ -完整扁平 IR:槽位、阶段、依赖、模式、事件、Jacobian 结构 - ↓ 一次跨语言调用完成整次计算 -C 数值内核:RHS / stream / 物性 / Jacobian / Event - ↓ -SciPy BDF(首阶段保留)或后续通过验证的原生积分器 - ↓ -Python:采样、结果编码、API、任务状态和诊断 -``` - -以下边界必须坚持: - -- 不在每个组件上来回调用 Python 和 C;一次 RHS 最多进行一次主要跨语言调用。 -- C 兼容模型的热路径内不得调用 Python callback。 -- C 内核不直接解析 XML,不管理 HTTP,也不承担组件编辑和数据库职责。 -- 自定义 Python 组件不能静默降级为跨语言逐组件调用;只要整模型不满足原生能力合同,就明确走 Python 引擎。 -- 迁移期间始终保留 `python`、`native`、`shadow` 三种模式;通过长时发布门后才能增加 `auto` 默认选择。 - -建议统一配置为: - -```text -SIMULATION_NUMERIC_ENGINE=python|native|shadow|auto -``` - -其中 `shadow` 由 Python 控制正式积分,C 只接收相同输入并逐阶段比较,不允许影响 Python 工作区。 - -## 3. 当前基础与首个阻断 - -### 3.1 已有基础 - -- `causal_ir.py` 已有 schema v1、结构签名、预分配工作区、阶段观察和事务回滚原型。 -- 当前因果执行器已经消去一部分重复逻辑坐标,并证明了数组化和预编译执行计划的方向有效。 -- 已有 Jacobian 稀疏图、局部切向原语和显式实验回退路径。 -- 已有 AMESim 结果读取、物理投影、递进时域 runner、性能统计、取消和故障注入测试。 -- 当前主模型约有 156 个运行组件、20 种组件类型和 132 个连续状态,适合用“能力矩阵 + 分阶段覆盖”推进。 - -### 3.2 当前 IR 的缺口 - -现有 schema v1 只覆盖全局因果代数计划,绑定仍依赖 Python 的 reader、writer 和 evaluator 回调。以下内容尚未进入同一个无回调执行计划: - -- 状态写入和信号传播; -- dynamic volume 和物性状态包; -- secondary 压力/流量块; -- stream SCC/DAG 和热流体外层闭合; -- 状态导数; -- 离散 mode、事件检测、reset 和重启; -- Jacobian 数值填充; -- 结果投影。 - -因此,当前 IR 不能直接包一层 C 接口后就获得预期收益。 - -### 3.3 P0 跨平台换行阻断(已解决) - -当前 Windows 工作树中的权威输入按原始字节计算时与 manifest 不一致;进一步核对确认,差异全部来自 Git 检出后的 `CRLF` 换行,而不是模型内容变化: - -| 文件 | Windows 工作树原始字节 | 将 `CRLF` 还原为 `LF` 后 | manifest 记录值 | -| --- | --- | --- | --- | -| `tests/data/test-mql-8.xml` | 106,188 B;2,078 个 `CRLF`;SHA-256 `e7eef641...b801` | 104,110 B;`0a2d9331...7b0b` | 104,110 B;`0a2d9331...7b0b` | -| `tests/data/test-mql-8.json` | 282,796 B;10,204 个 `CRLF`;SHA-256 `51e1acb3...11e2` | 272,592 B;`b44bf540...fbe0` | 272,592 B;`b44bf540...fbe0` | -| `AmesimModels/test_mql.ame` | 21,708,800 B;`cbc3aadd...c20fbb` | 不适用 | 一致 | - -因此没有证据表明 fixture 语义发生漂移,也不应仅因这一差异重建 golden。真正的问题是:manifest 按原始字节锁定输入,而 `.gitattributes` 没有固定这两个文本 fixture 的换行;当前加载器又直接校验工作树原始字节,所以同一提交在 Windows 上可能被拒绝、在 Linux 上通过。 - -该阻断已于 2026-09-02 完成本地修复:`.gitattributes` 已将两个主模型输入、历史 0.81 s 输入和 `tests/baselines/simulation` 下的 JSON 证据固定为 `LF`,当前 Windows 工作树也已从 Git 索引重新检出为 `LF`。自动测试会同时检查属性规则、禁止原始 `CR` 字节,并按 manifest 复核输入大小和 SHA-256;CI 已配置为在 Ubuntu 和 Windows 上分别执行这一合同,远端运行证据待提交后取得。 - -本地验收显示这些文件均为 `index=LF / worktree=LF / eol=lf`,两个 regression manifest 可在 Windows 正常完整加载,现有 manifest 和 golden 无需更新。未来只有在规范化后的内容确实变化时,才进入“模型变更、重建 manifest/golden”的流程。历史性能数字仍需按其代码版本看待,但不因换行差异失效。 - -## 4. 按重要性排序的实施计划 - -优先级定义: - -- `P0`:正确性和架构前置,不完成就不能安全编写生产 C 内核; -- `P1`:形成可用且有明显收益的 C 后端; -- `P2`:P1 证明有效后再实施的增强项。 - -表中的工期是单名熟悉现有求解器的开发者的粗略有效工作量,不是交付承诺,也不包含长时仿真排队时间。 - -| 顺序 | ID | 优先级 | 工作项 | 可行性 | 粗略工作量 | 主要依赖 | -| ---: | --- | --- | --- | --- | --- | --- | -| 1 | C-00 | P0 | 固定权威基准的跨平台合同,建立双引擎与回滚合同 | 高 | 1–2 人周 | 现有 OPT-00 | -| 2 | C-01 | P0 | 定义完整数值 IR schema v2 | 高 | 3–5 人周 | C-00、OPT-01/02 | -| 3 | C-02 | P0 | 建立纯数值组件 kernel 合同和能力矩阵 | 中高 | 3–6 人周 | C-01 | -| 4 | C-03 | P0 | 完成 Python 扁平参考执行器与 Shadow 差分 | 中高 | 3–5 人周 | C-01、C-02 | -| 5 | C-04 | P1 | 稳定 C ABI、构建链和隔离 worker | 高 | 3–5 人周 | C-01,可并行 | -| 6 | C-05 | P1 | 完成一个闭环代表子系统的 C 纵向切片 | 高 | 2–4 人周 | C-03、C-04 | -| 7 | C-06 | P1 | 扩展到完整内置 RHS、stream 和物性 | 中高 | 5–9 人周 | C-05、OPT-04 | -| 8 | C-07 | P1 | 稀疏解析/半解析 Jacobian 与局部回退 | 中 | 6–12 人周 | C-02、C-06、OPT-03 | -| 9 | C-08 | P1 | LSTP/MECMAS 事件、模式和默认灰度 | 中 | 4–8 人周 | C-06、C-07、OPT-05/06 | -| 10 | C-09 | P2 | 模型专用 C 代码生成和编译缓存 | 中 | 4–8 人周 | C-06~C-08 | -| 11 | C-10 | P2 | 评估 CVODE;有真实 DAE 需求时再评估 IDA | 中/当前低 | 4–10 人周 | C-06~C-08 | -| 12 | C-11 | P2 | 输出、部署、并发和发布收口 | 中高 | 3–6 人周 | 原生默认候选 | - -### 4.1 P0:先固定语义和参考答案 - -#### C-00 固定权威基准、双引擎和回滚合同 - -工作内容: - -- [x] 固定 `test-mql-8.xml/json`、历史输入和回归证据的跨平台 `LF` 字节合同,实际重新规范化当前 Windows 工作树,并用 Git EOL 状态、原始 SHA-256 和双平台 CI 保护该合同;未重建语义未变的 golden。 -- 重新加载 manifest 并执行 Python `0.01 s` smoke;只有发现规范化后的内容或物理结果确实变化时,才重新生成结构清单和 golden。 -- 冻结当前 Python 引擎为参考实现,定义 `python/native/shadow/auto` 的启用条件。 -- 原生不支持的模型只允许在编译或加载阶段整模型回退;正式验收时禁止静默回退。 -- 每个阶段使用独立小提交,记录输入哈希、环境、二进制哈希和回滚开关。 - -完成标准:相同输入能够稳定强制走 Python;关闭原生后行为与当前参考提交一致;模型、环境和结果来源都能追溯。 - -#### C-01 完整数值 IR schema v2 - -状态:**已完成 v2.0 schema、规范、系统编译器、fail-closed 语义校验和复杂模型结构回归。** 当前结果明确为 `reference_only`,C-02/C-03 尚未完成,默认 Python 求解路径没有切换。 - -完整 IR 必须描述共享数值阶段,以及 RHS、Event、Jacobian 和输出四类独立的按需入口。它们可以复用同一套槽位和依赖信息,但不能被误实现成“每次 RHS 都顺序计算 Event、Jacobian 和输出”。共享 primal 计划为: - -```text -state scatter -→ signal -→ mechanical equivalence / dynamic volume -→ property bundle -→ first pressure-flow closure -→ stream SCC/DAG -→ temperature reference update -→ sensitive-island closure / thermofluid fixed point -→ mechanical acceleration -``` - -四类入口分别编译自己的依赖切片: - -```text -eval_rhs = required primal stages → derivative -eval_events = required primal stages → event values -eval_jacobian = RHS primal / local derivative or JVP → CSR values -eval_outputs = required primal stages → output projection -``` - -IR 至少描述: - -- 连续状态、代数坐标、参数、常量、离散模式和工作区槽位; -- 每个阶段和 opcode 的读写集合、单位、缩放、上下界和错误来源; -- stream 图、SCC、物性状态包、外层固定点和事务恢复集合; -- event ID、左/右状态、reset、Jacobian 失效和模式计划; -- 固定的 CSR Jacobian 结构和 output projection; -- 完整 IR 内容(含组件模型/实现、介质、dtype、参数和执行计划)组成跨平台内容签名; -- native artifact key 再把内容签名与 native ABI、目标平台、编译器、构建选项、浮点策略和 kernel 库签名组合。 - -原生兼容 program 中不得存在 Python callback。schema v1 保留为参考适配器,不直接扩展成生产 ABI。 - -完成标准:同一模型重复编译得到字节级稳定的结构和签名;所有索引及读写集合可静态校验;结构可完整描述但缺少原生能力时明确标为 `reference_only`,原生加载器必须拒绝接管。 - -#### C-02 纯数值组件合同 - -每个内置组件需声明: - -- `kernel_id` 和实现版本; -- 输入、输出、参数、状态和 mode 槽位; -- primal、局部导数/JVP、event 和 reset 能力; -- 支持的正反流、接触和物性范围; -- 类型化错误码和局部数值差分回退边界。 - -组件 kernel 必须满足“相同输入得到相同输出”,不能依赖隐藏的 Python 对象写入。旧组件类继续作为参考实现。 - -首批纵向切片建议选择一条完整的 `MECMAS21 → PNRP17 → PNCH012 → PNL0001/LSTP00A` 支路及其介质/stream 闭合,因为它同时覆盖机械、气动、物性、流量和高刚度接触。 - -#### C-03 Python 扁平参考执行器 - -先用 Python/NumPy 完整执行 schema v2,目标是验证语义,而不是追求最终速度。它应摆脱 `PortState` 作为主语义载体,能在每个阶段与当前对象引擎比较首个差异。 - -完成标准:代表模型在普通点、反向流点、事件左右和 Jacobian 扰动点逐槽一致;`0.01/0.2/0.81/2.10 s` 的状态、事件、守恒和物理投影满足现有合同。 - -### 4.2 P1:形成真正有价值的 C 后端 - -#### C-04 稳定 C ABI、构建链和隔离 worker - -底层使用稳定纯 C ABI,Python 绑定保持很薄。最小接口应包含: - -```text -model_create -eval_rhs -eval_jacobian -eval_events -apply_event -eval_outputs -model_destroy -``` - -接口使用不透明 `ModelContext`、固定宽度整数、连续 `float64` 数组和显式长度;CSR 结构在编译时固定,运行时只填 values。每个任务独占可变 context/workspace/cache/mode,不使用可变全局缓存,热路径预分配且不进行常规堆分配。 - -首阶段仍由 SciPy BDF 积分,只替换整个 RHS/Jacobian 计算。构建链需覆盖 Windows x64 和 Linux x86_64,固定构建依赖,并禁止 `/fp:fast`、`-ffast-math`、`-march=native` 等会改变事件边界或破坏可移植性的默认选项。 - -原生访问违规、崩溃或死循环不能在同一 Python 进程中安全恢复。因此原生默认启用前,仿真必须运行在可硬终止的隔离 worker 中;C 同时接收取消标志并在组件阶段、stream SCC、Jacobian 和迭代处有界检查。 - -#### C-05 闭环纵向切片和首次 Go/No-Go - -一次跨语言调用应完成代表子系统的整次 RHS,禁止按组件往返。先为该闭环子系统建立独立代表 fixture,执行 Shadow 和短时积分,不立即扩大组件覆盖。由于此阶段尚未覆盖主模型的全部组件,`test-mql-8` 整模型按能力合同回退 Python 是预期行为,不能拿它衡量 C-05 的端到端收益。 - -继续扩面的最低门槛: - -- RHS 微基准至少达到 Python 的 `2×`;目标值为 `3×` 以上; -- 独立代表 fixture 的短时完整仿真中位时间至少降低 25%; -- 代表 fixture 的状态、事件、模式和守恒门全部通过;存在对应 AMESim 投影时也必须通过; -- `nfev/njev/nlu` 和接受步等工作量没有无法解释的变化; -- 无 Python callback、无静默回退、无内存错误。 - -如果 RHS 已快很多而完整仿真几乎不变,应先检查 Jacobian、微步数和跨语言边界,而不是直接继续扩大 C 代码。 - -#### C-06 完整内置 RHS、stream 和物性 - -- 把所有受支持的内置组件纳入能力矩阵;一个组件不支持时整模型明确走 Python。 -- 将 stream 图编译成 SCC 和缩点 DAG:无环部分一次传播,只在循环 SCC 中迭代。 -- 将 `p/T/rho/u/h` 及其导数组成同一物性状态包,按精确输入和阶段统一复用。 -- 移除每轮临时字典/列表,使用预分配数组和原地误差统计。 -- 保留试探状态事务回滚、反向流、温度参考更新和类型化可恢复失败语义。 - -完成标准:主目标使用的全部内置组件可原生执行;stream 迭代、压力流量残差和物性结果不恶化;跨平台字节合同修复后的同一权威输入通过短程与中程回归,且 `0.2 s` 完整仿真中位时间至少降低 30%。 - -#### C-07 稀疏解析/半解析 Jacobian - -这是获得大幅端到端收益的关键步骤。当前历史报告中,有限差分 Jacobian 会贡献大量额外 RHS 调用;仅把 primal RHS 改成 C 仍会重复执行它。 - -- 组件局部导数沿完整 IR 传播,直接填充固定 CSR values。 -- 不支持或非光滑点只对局部组件、状态列或 SCC 做数值差分,不能让一个局部问题恢复全网有限差分。 -- 每次构建记录解析列、局部差分列、回退位置、JVP 审计和装配时间。 -- LSTP 接触、流向切换、饱和及临界流动使用分段导数,并在边界上明确选择事件分段或局部回退。 - -完成标准:有限差分附加 RHS 至少减少 50%,相对完整 C RHS 阶段再降低至少 20% 总时间;事件顺序、模式和物理投影不变。 - -#### C-08 事件、模式和默认灰度 - -- 将 LSTP/MECMAS 的 event、mode、reset、Jacobian 失效和 solver restart 纳入正式 IR/C 合同。 -- 对接触进入、保持、释放,上下端挡、回弹、同时事件、擦边事件和防抖分别测试。 -- 按 `shadow → 显式 native → 模型签名白名单 → auto` 逐级启用。 -- 只有当前权威 `10 s` 连续三次通过后,才允许受支持模型默认走 C。 - -需要特别注意:C-08 不只是把同一接触公式写成 C,还要减少因接触表达方式造成的不必要纳秒级微步。任何软化、容差或事件改动都属于数值算法变化,必须与纯执行优化分开提交和验收。 - -### 4.3 P2:证明 P1 有效后再做 - -#### C-09 模型专用 C 代码生成 - -通用 C IR 解释器稳定后,若 opcode 分派仍是明确热点,再为固定模型生成专用 C。XML 文本不得直接拼入源码;生成器只消费校验后的数值 IR。编译缓存键必须包含完整结构签名、编译器和 flags。 - -只有相对通用 C 执行器额外获得至少约 `1.5×` 的稳定收益,并且编译成本能在重复运行中摊销时才继续。否则保留通用执行器,放弃这一层复杂度。 - -#### C-10 原生积分器 - -- 当前系统是“RHS 内完成代数闭合”的半显式 ODE,先评估 CVODE,不直接切换为 DASSL/IDA。 -- 只有 C RHS、Jacobian、事件、可恢复缩步、partial result、activity 和 cancel 合同稳定后,才对 CVODE 做独立 A/B。 -- CVODE 在相同正确性门下额外降低至少 25% 总时间才值得替换 SciPy。 -- IDA/DASSL 需要新的 `F(t,y,ydot)=0`、代数变量布局、一致初始化、指数和质量矩阵合同。只有真实模型证明无法稳健因果化或 ODE 化时再立项,当前不把它视为提速开关。 - -#### C-11 输出、部署和发布收口 - -吸收按需结果变量、列式输出、结果分块、编译缓存、并发限流、worker 硬终止和原生崩溃恢复。确保 native 崩溃只影响当前任务,服务仍能接收新任务。 - -## 5. 分阶段验证与 Go/No-Go 门 - -| 验证阶段 | 主要内容 | 通过条件 | 未通过时的处理 | -| --- | --- | --- | --- | -| V0 | fixture、环境、Python 和 AMESim 基线 | 来源一致;Python 连续 3 次稳定;现有测试通过 | 基线仍漂移时停止 C 默认路径开发 | -| V1 | 完整 Python IR | 槽位、阶段、读写集合、事件和回滚差分通过 | 先修隐藏副作用和 IR 合同 | -| V2 | C 纵向切片 | 单组件/逐阶段通过;RHS 至少 `2×` | 未达门槛则检查 IR/FFI,必要时停止扩面 | -| V3 | 完整 C RHS + SciPy | `0.01/0.2 s` 通过;总时间至少降低 30%;工作量变化不超过可解释范围 | 不作为纯执行优化合入默认路径 | -| V4 | C Jacobian/Event | FD 附加 RHS 至少减少 50%;相对 V3 再降低 20%;事件/模式一致 | 保留局部差分,禁止整网静默回退 | -| V5 | 长时与鲁棒性 | `1/5/10 s` 递进;10 s 连续 3 次;无泄漏、无静默回退 | 任一短时前驱失败即停止后续长跑 | -| V6 | 默认启用 | Windows/Linux 发布通过;支持模型走 C,不支持模型明确走 Python;可一键关闭 | 保持显式 opt-in | - -正式性能比较统一要求:预热 1 次、完整仿真至少测量 3 次并比较中位数、固定机器与电源模式,冷编译和热缓存分开报告。至少记录: - -- 总时间、积分、RHS、闭合、stream、物性、Jacobian 和输出时间; -- `nfev/njev/nlu`、接受/拒绝步、solver 启动和事件次数; -- Jacobian 附加 RHS、局部数值差分和回退原因; -- Python/C 边界调用次数和耗时; -- 峰值 RSS、C 工作区、IR/ABI 版本、编译器、flags 和二进制哈希。 - -附加硬门:关闭 C 时 Python 路径额外开销不超过 2%;纯执行层替换若使 `nfev/njev/nlu` 或接受步数变化超过 2%,必须按数值语义变化单独调查,不能直接归为性能优化;峰值 RSS 不超过 Python 基线的 110%;10 s 中 C 固定工作区不随步数增长,只有结果数组可随采样数增长。 - -## 6. 正确性验证范围 - -### 6.1 Shadow 阶段观察点 - -两个引擎必须接收完全相同的 `time/state/mode/parameters`,按第 4.1 节的 RHS 阶段逐项比较。差异报告至少包含:阶段、槽位、组件、输入、Python 值、C 值、绝对/相对误差和当前模式。 - -- 槽位布局、模式码、事件 ID、执行顺序和迭代次数要求一致。 -- 连续量使用“每种物理量绝对容差 + 相对容差”,不能用一个绝对容差覆盖压力、流量、温度和位移。 -- NaN/Inf、压力越界、符号错误和模式不同立即失败。 -- 压力流量最大缩放残差继续使用现有 `1e-7` 门。 -- 事件点分别比较左极限、事件处理结果和右极限,不扩大容差掩盖不连续。 - -差分语料至少覆盖:初始状态、接受状态、Jacobian 扰动状态、固定随机种子扰动、压力近似相等、流量换向、接触启停、端挡释放、物性边界,以及历史 `0.69/0.81/2.05 s` 区域。 - -### 6.2 AMESim 物理门 - -Python golden 只用于发现实现漂移,AMESim 结果仍是外部物理基线。正式默认前,应在现有 physical-state-v2.1 基础上至少覆盖: - -- 代表性气室压力、温度和质量; -- PNL 管路压力、流量及换向; -- PNRP 活塞力; -- MECMAS 位移、速度和模式; -- LSTP 间隙、接触力和接触切换; -- 每个主要支路至少一个代表量。 - -检查点至少包含 `0`、`0.04 s` 左右、`0.2`、`0.8 s` 左右、`1`、`2`、`5` 和 `10 s`。AMESim 未保存的内部守恒量继续执行独立绝对残差门。 - -### 6.3 错误、取消和并发 - -必须覆盖 C 返回非有限值、物性域错误、闭合不收敛、未知 opcode、ABI 不匹配、损坏二进制、失败后事务回滚、编译失败、并发 context 隔离、单任务取消、重复创建/销毁无内存增长和 native 崩溃后的服务存活。 - -回退分为三层: - -1. **启动前自动选择**:编译或加载时发现不支持能力,整次任务在开始积分前明确选择 Python;这是正式模式唯一允许的自动换引擎路径。 -2. **受控运行时错误**:native 已开始积分后返回错误时,当前 native 任务直接失败并保留诊断。灰度验证可以从不可变原始请求的 `tStart` 另起一个 Python 重放任务,但必须标为独立重放,不能算作 native 成功。没有完整保存 BDF 历史、事件上下文和已有输出前,禁止从某个 `(t,y,mode)` 假装无缝续跑。 -3. **进程级故障**:访问违规或无法返回时,由父进程硬终止 worker,禁止在受损进程中继续;如需 Python 对照,同样从原始请求重新执行。 - -正式 C 验收要求异常 `fallbackCount=0`。声明过的局部 Jacobian 数值差分不是异常回退,但必须单独计数。 - -## 7. 对现有优化清单的取舍 - -现有优化清单不能整体放弃,应按新路线重组: - -| 现有任务 | 决策 | 在新路线中的位置 | -| --- | --- | --- | -| OPT-00 基线与回归 | 保留并加强 | C-00 和全部发布门 | -| OPT-01 因果代数内核 | 冻结成果 | 作为 IR 编译输入和 Python 回退,不再继续零散微调 | -| OPT-02 扁平 IR | 升为主线 | C-01、C-03、C-05 | -| OPT-03 Jacobian | 升为主线 | C-07 | -| OPT-04 stream/物性 | 升为主线 | C-06 | -| OPT-05 步长、接触和鲁棒性 | 必须保留 | C-08;C 不能消除微步根因 | -| OPT-06 dense output | 部分前置、其余后置 | 事件合同进入 C-01/C-08,插值微调后置 | -| OPT-07 输出与内存 | 暂缓但不删除 | C-11 | -| OPT-08 取消与并发 | 必须保留并提前 | C-04/C-11;原生代码更需要进程隔离 | -| OPT-09 10 s 验收 | 必须保留 | V5/V6 发布门 | -| OPT-10 高指数 DAE | 有条件暂缓 | 只有真实 DAE 需求时进入 C-10 | - -可以停止继续投入的方向: - -- 在现有对象热路径上继续做零散字典和属性访问微优化; -- 继续叠加缺少统一失效合同的通用缓存; -- 优化目标模型中未触发的 `least_squares` 回退; -- 不断增加少量特例半解析列,而不先建立通用组件导数合同; -- 直接对当前对象图使用 Numba; -- 通过修改容差、软化物理或寻找“幸运 maxStep”伪装性能收益。 - -Numba 可以在完整数组 IR 后用于 1–2 周的架构验证,但不作为长期生产依赖。若完整数组 RHS 在 Numba 原型中仍没有明显改善,应先修正 IR 和算法,而不是立即开始大规模 C 重写。 - -## 8. 可行性评估 - -| 目标 | 当前判断 | 原因 | -| --- | --- | --- | -| 验证框架、双引擎和基准恢复 | 高 | 现有 runner、golden、AMESim 读取和埋点可复用 | -| 完整 Python 扁平 IR | 中高 | 结构基础已有,主要工作是显式化隐藏副作用和事件语义 | -| 代表子系统通用 C RHS | 高 | 数值边界清楚,一次调用可避免 FFI 碎片化 | -| 当前全部内置组件的 C RHS | 中高 | 约 20 种类型可逐类迁移,但 stream/物性/模式较复杂 | -| 可维护的稀疏解析/半解析 Jacobian | 中 | 收益大,但非光滑接触、流向切换和导数覆盖难度最高 | -| 模型专用 C 代码生成 | 中 | 收益上限高,但构建、缓存、安全和诊断成本明显增加 | -| CVODE 替换 SciPy | 中,且后置 | 可能减少调度开销,但不会自动解决错误方程或接触微步 | -| IDA/DASSL 直接提速 | 当前低 | 目前缺少真正 DAE 的残差、一致初始化和质量矩阵合同 | -| 接近 AMESim 的总速度 | 中低、待实测 | 缺少同机 AMESim 墙钟基线,且当前主要瓶颈同时包含 Jacobian 和接触微步数 | - -从项目节奏看,建议把目标分成三档;这里与第 4 节一样使用“人周”,多人并行时日历时间可短于人周总量: - -1. **约 15–27 人周:技术决策闭环。** 完成 C-00~C-05,回答完整 IR 是否正确、C RHS 是否有足够收益。 -2. **累计约 20–36 人周:可选原生后端。** 完成 C-06,使主要内置 RHS、stream/物性、双平台构建和受控回退可由用户显式启用。 -3. **累计约 30–56 人周:默认候选。** 完成 C-07/C-08、10 s 长时、并发隔离和发布门。 - -模型专用代码生成和原生积分器属于额外阶段,不应计入首个可用 C 后端的承诺。多人并行可以缩短日历时间,但 IR、组件合同、Jacobian 和事件语义存在强依赖,不能按人数等比例压缩。 - -## 9. 建议立即开展的第一批工作 - -第一批只做 P0,不直接开始大规模 C 编码: - -1. **已完成:** 固定当前权威输入和回归证据的 `LF` 检出规则,重新规范化 Windows 工作树,并增加 Windows/Linux 字节合同测试;未重建语义未变的 golden。 -2. **已完成(C-01 结构层):** 生成当前模型的组件类型、槽位、阶段、副作用和事件能力描述;纯数值 kernel 能力矩阵的实现细节继续归入 C-02。 -3. **已完成:** 将 schema v2 写成独立规范,并冻结 RHS/Event/Jacobian/Outputs 入口、错误能力、事务、事件、Jacobian 和内容签名。 -4. 建立对象引擎与扁平 IR 的逐阶段 Shadow runner。 -5. 用一条完整机械—气动—管路—接触支路完成 Python 参考闭环。 -6. 评审通过后,再建立最小 C ABI 和纵向切片。 - -第一批的退出条件不是“已经写了多少 C”,而是:当前基准可信、同一计算能被无回调 IR 完整表达、差异能定位到具体阶段和槽位。只有达到这个条件,后续 C 工作才具有可预测的收益和可控的回滚成本。 - -## 10. 2026-09-09 补充:XML 自动生成系统专用 C / EXE - -### 10.1 新证据与本次目标 - -用户本次希望解析 XML 后,自动生成对应系统的 C 求解程序。建议把“模型专用代码生成 + 原生积分运行”作为目标架构;开发时仍保留“相同输入逐阶段比较”和“相同 SciPy 积分器仅替换完整 RHS/Jacobian”的诊断阶段,用来分别测量执行语言与积分算法的影响。后者是验证手段,不是最终交付边界。 - -最新证据不能把速度差异全部归因于 Python: - -- 先前特定模型的完整 C EXE 求解仍约 44.82 s;C 剖析中模型求值占 97.07%,雅可比构造占 87.17%。雅可比包含模型求值,两项不能相加。 -- 该 C 版本采用 132 状态的逐列差分,每次雅可比约需 133 次完整 RHS,累计 124,089 次。这个结论针对测试 C 实现;当前 Python 引擎已经有稀疏结构、着色和部分半解析路径,不能把它也描述成始终采用全稠密差分。 -- Amesim 仅关闭 optimized solver,进程 CPU 从 2.890625 s 增至 13.890625 s,结果文件字节、函数/雅可比计数、成功步数均相同。这证明执行优化层本身很重要,但没有揭示其专有优化实现。 -- C 管流中的固定次数二分及频繁未收敛退出是已知热点,不能作为成熟组件内核直接复制到生产生成器。 - -依据:[C 剖析报告](../../test/native-mql8-profile/performance-analysis.md)、[Amesim 优化开关对照](../../test/native-mql8-profile/amesim-optimizer-comparison.json)。这是特定模型对照,不是受控的生产 Python/C 语言收益测量,也不保证新引擎达到 Amesim 的速度。 - -### 10.2 最终执行边界 - -```text -网页导出 System XML - → Python:协议校验、组件版本/参数/连接校验 - → ModelIR:方程/可信组件原语、连续状态、代数变量、事件、依赖 - → 结构编译:合并等价变量、确定计算顺序、划分代数环、生成稀疏结构 - → C emitter:model.c + model.h + manifest.json - → 编译并链接:组件 C 库 + 原生积分运行库 - → 独立 native worker / 模型 EXE:完成初始化、积分、事件、数值采样 - → Python:读取结果、任务状态、现有 API 编码 -``` - -编译和 EXE 均在后端机器执行。Python 可以继续完成低频管理工作;一次积分中的 RHS、代数闭合、物性、Jacobian、Newton 和事件处理全部留在原生进程,不能在每次求值时回调 Python。 - -首个交付可生成模型专用 EXE,便于独立运行、复现和隔离崩溃。之后如实测编译或启动成本成为问题,再用通用 worker 加载缓存模型 DLL;DLL 同样加载在隔离进程中。原生运行时与组件库预先编译,每个新拓扑只生成和编译模型装配部分。 - -### 10.3 XML 不包含完整方程,必须建立 C 组件库 - -现行 XML 保存组件类型、版本、参数和连接,不包含各组件的物理公式。自动编译依赖一份可信映射: - -```text -(model_type, model_version, 支持的结构选项) - → 参数校验 + 状态/端口布局 + 方程或 C kernel + 局部导数 + 事件/reset -``` - -`app/simulation/registry.py` 的注册定义可以继续作为编译前端的数据来源,逐个增加原生能力描述。普通公式可用受限表达式 IR 表达,同时生成数值计算和导数;复杂物性与非线性管流使用经过验证的手写 C 原语,并声明完整输入输出及导数合同。不要依赖自动翻译任意 Python 类。 - -例如“气室 A—管道—气室 B”编译后固定为状态和参数数组下标:求两边物性、算这根管道的流量、按端口方向累加质量/能量导数。运行时不再寻找组件对象、端口名称或连接边。系统专用代码可以直接调用 `pipe_kernel(...)`,无需把物性库和积分器源码重复展开进每一个模型。 - -现有 `EquationResidual` 保存变量名称及已经计算出的 `value`,并没有保存完整公式表达式;`causal_ir.py` 提供部分结构基础,但其执行绑定仍有 Python 回调。因此需要补齐完整数值 IR。当前 `app/simulation/ir/` 仅发现缓存文件,不能视为已有可维护的完整 IR 源码。 - -`test/native-mql8-bdf/generate_model.py` 是验证原型:仍限定特定阀动作、机械组件编号、部分参数和连接形式,并拒绝某些循环闭合。正式编译器应由拓扑和能力合同推导这些内容,不能直接把这份脚本改成 XML 读入便宣称通用。 - -### 10.4 编译器要提前完成的计算 - -| 编译工作 | 运行时收益或正确性要求 | -| --- | --- | -| 合并等压、等速度等别名变量;组装流量/力平衡 | 消除多余未知量,统一端口方向与 SI 单位 | -| 对方程和未知量做匹配,将相互依赖部分划成小块 | 能顺序计算的直接计算,仅对真正代数环迭代;无法配平时定位到组件 | -| 固定状态、代数变量、参数、工作区和输出下标 | 每次求值使用连续数组,避免字典、字符串查找和临时分配 | -| 编译 stream 图及受温度影响的物理子网 | 保留现有无环传播和局部闭合能力,避免退回每轮全网重算 | -| 计算各入口的依赖集合与所有支持模式的稀疏结构 | RHS、事件、Jacobian 和输出按需计算;求 RHS 不自动附带所有输出 | -| 提取几何常量、公共表达式和物性状态包 | 参数不变的部分只初始化一次;相同精确输入下共享结果 | -| 声明事件、离散模式、缓存失效和状态复位 | 阀门跳变、流向切换、接触不依赖碰巧足够小的积分步长 | - -跨积分调用的缓存必须按输入和模式有效性管理,不能仅以时间为键;同一时间可能存在多个 Newton 试探状态或雅可比扰动。试探工作区与已接受状态分开,拒绝步与事件回退不得提交试探产生的模式或缓存。 - -### 10.5 积分库与代数环的选择 - -当前引擎在 RHS 内闭合代数变量,对外给出 ODE。第一版原生全流程建议使用 **SUNDIALS CVODE 的 BDF 模式**。CVODE 提供 C 接口、1–5 阶变阶变步长 BDF,以及稠密、带状、稀疏线性求解接口。它的 Adams/BDF 由调用方选择,不能将其描述成 Amesim/LSODA 那样自动切换两种方法。[CVODE 数学方法](https://sundials.readthedocs.io/en/latest/cvode/Mathematics_link.html) - -生成器负责模型方程、结构和导数,成熟积分库负责步长/阶数、误差控制和求解器历史。不要为每份 XML 重新生成一套 BDF 算法。先锁定可复现的 SUNDIALS 版本、C 接口、构建工具和依赖;Windows 用原生 C 构建链产出 EXE,Linux 对应 ELF 程序。 - -稀疏矩阵可配合 KLU,但先提供正确稀疏 Jacobian,再根据模型规模比较稀疏与稠密线性代数;132 状态案例的 LU 仅占约 2.19%,只换 LU 库难以改变总体耗时。[CVODE 线性求解选择](https://sundials.readthedocs.io/en/latest/cvode/Mathematics_link.html) - -如果某类代数环难以可靠消去,可另行建立 `F(t, y, ydot) = 0` 的 DAE 后端,评估 C 接口的 IDA。此时必须处理代数变量、一致初始化和系统指数;高指数约束不能直接交给 IDA 期待自动解决。[IDA 官方介绍](https://sundials.readthedocs.io/en/latest/ida/Introduction_link.html) - -消去代数变量后,Jacobian 不能只拼接相邻组件的直接偏导。对 - -```text -ydot = f(t, y, z) -0 = g(t, y, z) -``` - -在当前光滑分支、`g_z` 可逆时,需通过线性求解得到 `g_z * dz/dy = -g_y`,再组装 `J = f_y + f_z * dz/dy`,无需显式计算逆矩阵。这样才包含“状态变化 → 代数环重新平衡 → 导数变化”的影响。结构编译也要考虑此过程产生的新依赖。 - -第一版可用保守结构着色差分建立正确基线,再逐类增加局部解析导数或自动微分。旧实测的非零图和 27 色只是某次运行观察,不能直接当作全部流向/接触模式的可靠结构。迭代求解器的导数应针对其收敛方程,不宜把固定次数迭代轨迹直接当作物理方程导数。 - -### 10.6 项目接入点和生成产物 - -| 位置 | 具体接入方式 | -| --- | --- | -| `app/system_xml.py` | 复用现有校验和 `SystemXmlDocument`;补充明确的误差设置传递合同 | -| `app/main.py:compile_system_xml_network` | 抽取共同的版本、参数、介质与连接规范化前端;避免 Python/C 分别解释模型 | -| `app/simulation/solvers/causal_ir.py` 与现有分块/stream 计划 | 复用结构分析结果,转换为无 Python 回调的数值计划 | -| 建议新增 `app/simulation/native_codegen/` | 完整数值 IR、组件原生能力注册、结构编译、C emitter、编译缓存 | -| 建议新增 `native/runtime/`、`native/components/`、`native/include/` | CVODE 适配、代数求解、组件与物性原语、版本化 C ABI | -| 建议新增 `app/simulation/backends/` | Python 与原生 worker 的统一运行接口、结果适配和诊断 | -| `app/main.py:_run_system_xml_simulation_profiled` | 在现有任务/进度/取消接口内选择后端,保持结果变量标识和单位一致 | - -每个模型的缓存目录建议包含: - -```text -/ - model.c / model.h # 状态、端口布局和模型专用计算入口 - manifest.json # 来源、版本、布局、组件映射、支持能力、构建哈希 - model.exe # 链接原生运行库的可执行模型 -``` - -每次运行另建目录,保存数值参数、运行选项、结果和诊断。模型 ABI 至少区分 `initialize`、`rhs/residual`、`jacobian`、`roots`、`apply_event`、`outputs`,通用运行库提供 `run`。RHS 返回导数,事件函数返回零点函数值,输出函数只在需要物理投影时调用。每个运行拥有独立 context,禁止共享可变全局状态。 - -缓存键包括规范化拓扑、组件/介质实现版本、结构参数、IR/ABI 版本、精度、平台、编译器和编译选项。普通数值参数以数组传入,时长、容差、最大步长与输出间隔由运行选项传入;这些修改通常无需重新编译。改变端口数量、状态数量或方程分支结构的参数必须触发重新编译。若把某个普通参数特化进源码,也必须将其值纳入缓存键。 - -生成器只输出可信原语与合法数值,用户组件 ID 通过映射表定位,不作为任意 C 源码或编译命令片段。未支持的组件、模式或算法在编译/启动阶段明确报告;强制原生测试不得静默改用 Python。 - -### 10.7 精度、采样与性能口径 - -本次检查发现两个实际差异: - -- 网页入口 `app/main.py` 的 `rtol` 当前写死为 `1e-6`;此前测试 C 为 `1e-7`。XML v3 尚无 `rtol/atol` 字段,应通过明确的协议演进同时更新 XSD、解析、导出和执行设置,不可只修改某一后端。 -- 当前 `tests/data/test-mql-8.xml` 仍为 `maxStep=0.001`,而此前 JSON 测试副本使用过 `1e30` 和 `0.02`。后续应由同一份规范化模型与独立运行配置驱动比较,不能用文件同名推断设置相同。 - -`rtol` 相同也不等于总体精度相同:需同时固定每类状态的 `atol`、单位/缩放、代数闭合容差、初始化及事件处理。首阶段保持状态定义和方程一致;改变质量/内能为压力/温度等状态形式属于单独的数值方案。 - -`sampleStep` 与内部积分步长分开:使用积分库的插值能力取得输出采样点,不为每个输出点重新启动求解器。已知不连续时刻仍要显式分段,状态事件要定位零点并执行复位/重启。`maxStep=1e30` 只放宽上限,不会使求解器忽略精度或事件自动迈大步。 - -主指标单列求解墙钟与求解 CPU;XML 解析、代码生成、冷编译、初始化、输出投影、文件写入和 HTTP 序列化分别计时。性能模式只计推进所需的求值与事件定位,不计绘图及额外结果投影;正确性模式另行采样曲线验证。积分器要求的初始 RHS/Jacobian 也计入求解工作量,避免从计时中漏掉。 - -### 10.8 推荐实施顺序 - -1. **冻结一份共同输入和运行配置。** 以 `test-mql-8.xml` 为首个完整目标,列出其全部组件类型/模式、公式与原生能力;对齐当前 Python、旧 C 原型和 Amesim 的参数、单位、状态与事件差异。 -2. **完成一条闭环支路的完整 IR 和 C kernel。** 以同一 `t/y/mode/parameters` 比较初始化、物性、闭合、RHS、Jacobian 与事件左右值;覆盖正常、反向流、近零压差和接触状态。 -3. **生成该支路的 C 并链接 CVODE,跑通独立 EXE。** 同时以“相同 SciPy + Python/C RHS”作为执行开销对照,分开判断 C 迁移收益与更换积分器的影响。 -4. **覆盖首个完整模型,并接入现有 API 的显式原生选项。** 将当前 Python 的结构消元、stream 局部执行、事务回滚和稀疏能力迁入;优先修复管流收敛与雅可比重复求值,再考虑低占比线性代数优化。 -5. **验证通用性和再逐步默认启用。** 用组件重命名、连线变化、不同支路数量、反向流和其他受支持参数模式证明生成器不依赖原测试拓扑;按现有物理验收合同检查 Amesim 投影、事件时刻和守恒残差。 - -首个交付应能由任意命名、符合已声明支持范围的 XML 稳定生成可运行 EXE,同时报告实际后端、求解器版本、模型/二进制哈希、计时及求值/迭代计数。本次文档没有执行这项迁移,也没有新增性能测试;提速倍率需待上述共同输入对照完成后报告。 diff --git a/docs/other/Python数值实现退役记录.md b/docs/other/Python数值实现退役记录.md index 6ce8324..2f9d367 100644 --- a/docs/other/Python数值实现退役记录.md +++ b/docs/other/Python数值实现退役记录.md @@ -25,15 +25,15 @@ 本地生成的 EXE、DLL、计时日志在 `/test/`,不提交编译产物。源码和冻结基准进入 Git,可重新生成。 -## 远端 IR 分支集成 +## 远端 IR 分支集成(历史阶段) -合并保留 `system-optimization` 的 IR v2 schema、Python 数据结构、内容签名和静态校验器。旧 `compile_system_ir(GenericFluidSystem)` 导出器直接依赖已删除的压力流量、机械和闭合执行计划,因此停用并返回明确的迁移提示。当前 C 生成接口是 `native_codegen.compiler.compile_native_program(network)`,尚不输出独立的 System IR v2 执行计划。 +合并 `system-optimization` 时曾保留 IR v2 schema、Python 数据结构、内容签名和静态校验器,停用了依赖旧 Python 系统对象的导出器。这些 IR 文件已在随后清理中删除,未接入当前 C 生成或求解路径。 -从远端提交 `ce353d2` 固化机械、132 状态 MQL、74 状态历史模型的 reference-only IR,保存在 `tests/data/system-ir-v2-reference.json.gz`。保留并迁移了 20 项 IR 结构、签名、索引、阶段和错误校验测试,全部通过。用于捕获参考结果的临时旧代码副本已删除。 +当时从远端提交 `ce353d2` 固化了三个 IR 示例并通过 20 项校验测试;专属示例和测试已随 IR 一起删除,历史内容可从 Git 找回。 -CI 同步移除旧 Python 基准命令:Linux 验证元数据和 IR 合同,Windows 配置 C 工具链后运行原生回归。CI 配置已更新,本地验证与远端 CI 的实际运行结果分别记录,不把本地通过当成远端 CI 已通过。 +当前 CI 在 Linux 验证元数据和 XML 合同,在 Windows 配置 C 工具链后运行原生回归。本地验证与远端 CI 的实际运行结果分别记录,不把本地通过当成远端 CI 已通过。 -## 最终本地验证 +## 旧数值实现退役时的本地验证 - 后端完整发现 280 项测试,1 项 Linux 锁定环境版本校验按配置跳过。数值、XML、IR 等检查通过;心跳测试改为等待消费确认后,对应 11 项 API 回归通过,消除依赖几毫秒固定等待的时序假设。 - 前端 TypeScript 检查与 Vite 生产构建通过。 diff --git a/docs/other/仿真性能评估-2026-08-15.md b/docs/other/仿真性能评估-2026-08-15.md deleted file mode 100644 index a22e1a9..0000000 --- a/docs/other/仿真性能评估-2026-08-15.md +++ /dev/null @@ -1,285 +0,0 @@ -# SystemSimulationApp 仿真性能评估(2026-08-15) - -> 代码基线:`model-development@6a06489`,随后只加入本报告所述的可选埋点和基准工具。 -> 本次评估的是前端流式接口实际使用的 System XML 求解路径;所有时间均为本机实测,不代表其他机器的绝对性能。 -> 2026-08-16 已按本报告建议实现“仿真内独立物性缓存”“worker 启动暖机”、高刚度试探压力边界修复、方程关联块闭合、代数稀疏回退、外部 volume 跨域 ODE Jacobian 修正和 dense output 惰性构造;原始基线数据保留用于对照,当前大型 XML 复验见第 11 节。 - -## 1. 结论 - -1. **压力—流量闭合是原始基线的首要热点。** 2026-08-15 深度审计中,三个气动短算例有 71%~85% 的计时落在 `PressureFlowSolver.solve()` 的包含时间内。它同时包含残差组装及其触发的物性调用,不能与物性时间相加;2026-08-16 已完成方程块与稀疏首轮,当前现状见第 11 节。 -2. **物性调用存在很高的完全相同输入重复率。** 按每次代数闭合重置精确输入影子集合后,空气链路、空气分支和氦气阶跃的重复率分别为 91.2%、96.5% 和 82.3%。空气公式很便宜,不能只凭重复率加缓存;Peng–Robinson 氦气更值得优化。 -3. **评估基线已有的两项氦气 LRU 精确缓存有效。** 冷缓存审计中,`properties_from_mU` 命中率 95.5%,`temperature_from_pressure_enthalpy` 命中率 78.6%;21 次配对端到端测试中,暖缓存比每次清空缓存快约 7.9%。这些数据描述 2026-08-15 的原始基线,后续实现见第 9 节。 -4. **长仿真的时间主要花在积分阶段。** 10 s 氦气均压算例耗时约 10.6~11.5 s,其中标准埋点测得积分占 90.5%,初始化约 4.2%,逐采样点后处理约 5.0%。 -5. **结果 JSON 暂不是这些算例的首要矛盾。** 四个短算例的最终 NDJSON 结果约 29~59 KiB,编码中位数约 0.4~1.2 ms;501 个采样点的长算例约 507 KiB,编码约 18.5 ms。 -6. **首次仿真有明显冷启动。** 新 Python 进程第一次短算例约 0.71 s,预热后同类算例约 0.06~0.13 s。剖析表明首次进入 SciPy 求解路径的惰性导入占了主要差额;这是服务首请求延迟,不是稳态吞吐。 -7. **用户提供的高刚度 XML 已能完成 10 s 仿真。** 原始基线在 `0.000175 s` 左右因 `Initial guess is outside of provided bounds` 失败;原因是压力优化下界为 1 Pa,排除了 RK45 合法产生的、仍严格大于 0 Pa 的亚帕试探值。2026-08-16 将优化器压力下界放宽到 0 Pa 后,构成方程仍要求压力严格为正,完整 RK45 仿真通过且没有触发可恢复重试。 -8. **闭合不再固定执行第二次全网压力求解,也不再把一个大物理岛等同于一个求解块。** 每次闭合仍先保证全网成立;stream 更新后,只重算声明为 stream-sensitive 的方程—未知量关联块。物理连通岛只是安全范围,当前 `secondaryBlockCount` 是真实方程块数;无法安全分类的自定义模型会保守回退原全网路径。 -9. **历史物理岛版收益取决于模型拓扑。** `off` 模式配对测试中,空气链、空气分支、氦气阶跃、机械接触和高刚度短算例分别改善 7.9%、6.5%、0.6%、21.4% 和 14.6%。这些数据保留作纵向基线,但该版已由方程关联块实现取代。 -10. **大型分支 XML 的 `0.69 s` 现象已定位并完整跑通。** 输入 SHA-256 为 `2fb95e65f5de0c85a6a17802aef74ea004087323fd00fd8d01acf0184ff71d48`,含 98 个组件、472 个代数未知量、74 个 ODE 状态。根因是外部 volume 跨域耦合在 ODE Jacobian 依赖图中漏 12 个实测显著项,而不是线程死锁;修正后结构由 1092 非零/27 色变为 1284 非零/31 色。最终稳定代码连续三次完整 `0~0.81 s` 用时 79.049 s、74.658 s 和 85.103 s,积分统计均为 `nfev/njev/nlu=3393/226/667`、接受步 1009。 -11. **代数非线性回退已有可信声明图上的稀疏保护链。** 先求本轮未闭合方程块的 union sparse;失败恢复原始 `x0` 后做 global sparse,再失败才做 dense。受控扰动微基准在相同 `max_nfev=20` 下把真实残差回调由 3796 降至 164、墙钟约 7.357 s 降至 0.634 s(约 11.6 倍);活动接触或不可信声明仍走兼容 dense 路径。 -12. **首轮其他优化均按适用范围解释。** worker 暖机已覆盖 sparse `least_squares` 的 LSMR 路径;dense output 仅在跨采样点或需要状态事件时构造,但本次大型 XML 含状态事件,因此没有本案收益。机械 `atol` 的 `1e-12→1e-10` A/B 约快 16%,但会改变机械误差合同,未采用;外层 thermofluid 流量固定点相对容差的 `1e-12→1e-9` A/B 反而增加 BDF 步数并改变轨迹,也未采用。 - -## 2. 埋点实现与污染控制 - -性能开关由进程启动环境变量 `SIMULATIONAPP_PROFILE` 决定: - -| 模式 | 用途 | 记录内容 | 适合场景 | -| --- | --- | --- | --- | -| `off` | 正常运行,默认值 | 不在响应中加入性能数据;装饰器在模块加载时直接返回原函数 | 正式仿真和最终性能对比 | -| `standard` | 低开销阶段统计 | XML 校验、网络编译、系统构造、初始化、积分、后处理、结果组装 | 日常定位“大阶段” | -| `audit` | 深度审计 | 再展开 RHS、代数闭合、压力流量、stream、刷新、导数和物性内核 | 短算例诊断、调用频率与缓存评估 | - -一次运行使用一个 `ContextVar` 隔离的 `PerformanceTrace`,不会把不同仿真任务的阶段计数混在一起。成功或失败的求解结果在 profiling 模式下都会把快照放入 `diagnostics.performance`。主要字段为: - -- 阶段:`calls`、`inclusiveNs`、`selfNs`、`maxNs`、`errors`; -- 物性:上述时间字段,以及介质、操作、缓存查询/命中/未命中; -- audit 专有:闭合内精确输入唯一数/重复数、逆解迭代总数/最大值/收敛与未收敛次数; -- `propertyOutermostNs`:只累计最外层物性调用,避免把嵌套 PR 内核时间重复相加。 - -标准模式只保留低频的大阶段计时。21 次氦气阶跃配对运行中,标准模式相对关闭模式的中位开销为 1.9%;四个短算例分开校准为 0.5%~2.9%。audit 会逐次生成精确指纹并计时,短算例可慢到约 2.5~5 倍,因此 audit 数据用于定位和计数,最终优化收益必须回到 `off` 模式复测。 - -将当前代码的 `off` 模式与备份提交 `6a06489` 同时运行 21 次氦气阶跃,墙钟中位数差为约 0.3%,处于本机噪声范围。也就是说,默认关闭时没有观察到稳定的热路径退化。 - -## 3. 测试方法 - -环境:Windows 11、Python 3.12.3、SciPy 1.18.0、64 位 Intel 处理器。仓库没有 PyInstaller/Nuitka 等可执行文件构建链,本次直接使用项目实际启动后端的 `.venv-win` 解释器。把同一 Python 代码再包成单文件只会混入解包和启动成本,不会使这里的求解内核更接近生产路径。 - -基准工具入口: - -```powershell -.venv-win\Scripts\python.exe -m app.simulation.benchmark_performance ` - --mode audit --warmups 1 --runs 3 ` - --factory "helium_step=tests.test_amesim_pnvo001_signal_xml:high_pressure_helium_step_project" ` - --output app/data/performance-evaluations/helium-step.json -``` - -工具默认传入取消检查回调,从而走与前端流式仿真相同的低层逐步积分路径。它记录墙钟、进程 CPU、最终 NDJSON 编码、输入 SHA-256 和完整性能快照。原始 JSON 写入被 Git 忽略的 `app/data/performance-evaluations/`,避免把机器相关的大量样本提交到仓库。 - -本次代表算例: - -| 算例 | 内容 | 暖机后 `off` 墙钟中位数 | 重复次数 | -| --- | --- | ---: | ---: | -| `air_chain` | 空气气缸—节流孔—管路—储罐 | 62.4 ms | 9 | -| `air_branched` | 空气分支网络 | 130.3 ms | 9 | -| `helium_step` | 高压 PR 氦气、信号阶跃阀 | 65.2 ms | 9 | -| `mechanical_contact` | MECMAS21/LSTP00A 弹性接触 | 33.0 ms | 9 | -| `helium_long` | 10 s PR 氦气均压、501 个输出点 | 10.63 s | 1 | - -短算例先暖机 2 次再测 9 次;缓存 A/B 使用两个同时启动的独立进程各暖机 5 次、测量 21 次,以尽量抵消瞬时系统负载。长算例只测 1 次,因此它只用于判断数量级与阶段占比。 - -## 4. 深度阶段结果 - -下表时间是 audit 中位数,会包含审计自身开销;调用数和相对热点比绝对时间更可靠。 - -| 算例 | RHS | 完整闭合 | 压力流量求解 | 压力流量包含时间占 audit 总时间 | 物性调用 | 闭合内精确重复率 | -| --- | ---: | ---: | ---: | ---: | ---: | ---: | -| `air_chain` | 33 | 37 | 74 | 77.0% | 4,265 | 91.2% | -| `air_branched` | 23 | 26 | 56 | 84.6% | 7,378 | 96.5% | -| `helium_step` | 79 | 102 | 235 | 71.2% | 11,121 | 82.3% | -| `mechanical_contact` | 74 | 78 | 156 | 29.5% | 0 | 不适用 | - -这是 2026-08-15 原始基线的闭合数据:当时每次闭合先做 1 次压力求解,然后最多执行 25 轮 `stream → pressure-flow` 固定点,理论上最多 26 次;四个算例平均为 2.00、2.15、2.30 和 2.00 次/闭合。随后 2026-08-16 的第一版先把后续重算缩到 stream-sensitive 物理连通岛;该历史版本又被当前方程关联块版取代。当前做法是在安全物理范围内只重算敏感方程实际关联的块,没有敏感块时不强制第二次压力求解。这样裁剪的是无效重算,不是删除真实耦合。 - -## 5. 物性调用与缓存结果 - -氦气阶跃的冷缓存 audit 代表运行: - -| 操作 | 调用 | 命中/未命中 | 命中率 | 真实逆解次数 | 平均迭代 | 最大迭代 | 未收敛 | -| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | -| `properties_from_mU` | 1,930 | 1,843 / 87 | 95.5% | 87 | 3.99 | 5 | 0 | -| `temperature_from_pressure_enthalpy` | 398 | 313 / 85 | 78.6% | 85 | 5.00 | 5 | 0 | - -在原始基线中,暖机后以相同配置重复运行,这两项在代表快照中均为 100% 命中,说明当时的进程级精确 LRU 能跨同配置运行复用确定性轨迹。关闭埋点的端到端配对结果为:暖缓存中位数 77.05 ms,每次清空缓存为 83.69 ms;换算为暖缓存约快 7.9%。 - -audit 的自身时间排序还显示:`isentropic_density_pressure_factor` 调用 398 次,`density` 业务入口及 PR 密度内核各调用 1,198 次,PR `compressibility_roots` 调用 1,712 次。同一 `(p,T)` 周围存在“等熵因子内部求密度,随后流量公式再次求密度”的重复机会。这里应优先复用同一闭合内的精确结果或合并 API;不要用四舍五入/容差键缓存,否则会在残差函数中制造平台并影响 ODE/least-squares 的有限差分。 - -空气算例虽然精确重复率更高,但理想气体公式本身只有少量算术。对这些廉价函数增加字典查询可能比重算更慢,应先做专门 A/B,不应套用氦气结论。 - -## 6. 输出与失败样本 - -| 算例 | 最终结果大小 | NDJSON 编码中位数 | -| --- | ---: | ---: | -| `air_chain` | 29.2 KiB | 0.39 ms | -| `air_branched` | 59.1 KiB | 1.21 ms | -| `helium_step` | 55.5 KiB | 1.10 ms | -| `mechanical_contact` | 51.4 KiB | 0.62 ms | -| `helium_long` | 507.4 KiB | 18.48 ms | - -用户高刚度 XML 的输入 SHA-256 为 `27048a99da0a21922d75785b760c3b5d04be3349b8aef6fbfedfd811d87ef1d5`。原始 audit 失败运行记录到 80 次 RHS、83 次闭合、165 次压力流量求解,最后一项各有 1 次错误;其 1.08 s 只代表历史失败路径,不能当作完整模型性能。允许严格正的亚帕试探压力后,同一模型已完成 10 s,当前完整性能结果见第 10 节。 - -## 7. 后续优化顺序 - -1. **[2026-08-16 已落实方程块与稀疏首轮] 优化压力流量执行计划。** 压力/流量方程、显式赋值、热流依赖和方程—未知量关联图已预编译;每轮 stream 更新后只重算敏感方程块。非线性时先做可信未闭合块的 union sparse,失败从原 `x0` 做 global sparse→dense;自定义、活动接触或结构不安全的网络保守回退兼容路径。后续仍可评估 equality group 真正消元和解析 Jacobian。 -2. **[2026-08-16 已落实] 减少 PR 物性重复。** 复用组件当前 `(m,U,V)` 的状态恢复结果,并缓存相同输入的密度和等熵因子;沿用精确键、有界容量和按仿真隔离原则。 -3. **[2026-08-16 已落实] 处理冷启动。** worker 在 FastAPI lifespan 中完成无业务副作用的 SciPy/XSD 微型暖机后再接收请求;不要把约 0.65 s 冷启动归因到每次仿真。 -4. **长算例再看后处理复用。** 当前代表长算例的积分占 90.5%,所以积分/闭合仍优先;当采样更密或变量更多时,再评估复用已接受状态闭合、按需变量和降采样。 -5. **[2026-08-16 已落实] 修复高刚度 XML 的压力试探边界。** 优化器允许严格正的亚帕试探值,物理构成方程仍拒绝零压和负压;该模型已用 10 s RK45 回归验证,不需要把这类合法试探误报为可恢复拒步。 -6. **[2026-08-16 已落实] 补全外部 volume 的跨域 ODE Jacobian 依赖。** 机械位移写入气室容积后,气动储能与机械力平衡必须在状态稀疏图中双向关联;大型分支 XML 据此完整跑通。机械 `atol` 放宽虽有约 16% 的单次改善但改变精度合同;flow 固定点容差放宽反而增加步数,均未采用。 -7. **[2026-08-16 已落实] dense output 惰性构造。** 仅当已接受步跨越下一样本或需要状态事件时构造插值;含状态事件的模型每步仍需要,不将其宣传为大型分支 XML 的收益来源。 - -## 8. 本次评估边界 - -- 没有固定 CPU 亲和性或关闭后台程序,短算例绝对时间存在数毫秒波动,因此以中位数和配对实验为主。 -- audit 会显著改变廉价函数的单次耗时;不能把 audit 的物性毫秒数直接当成关闭埋点后的真实占比。 -- 原始基线的 LRU 命中/未命中来自调用前后的全局 `cache_info()` 差值;本报告当时均为单任务运行。该并发统计限制已由第 9 节的仿真内独立缓存消除。 -- 直接抛出 `HTTPException` 的校验/执行异常会结束 trace,但当前不会把快照附到错误响应;demo 属于返回 `failed` 部分结果的路径,所以本报告能够取得其失败快照。 -- 本批没有测峰值 RSS、1/2/4 并发吞吐、浏览器解析/绘图或 8/32 单元拓扑扩展曲线。 -- 没有为评估引入新的近似缓存、容差调整或求解器算法变更;所有性能结论都与数值优化改动解耦。 - -## 9. 2026-08-16 缓存与启动暖机复验 - -原先两个函数级 LRU 会在 Python 进程内跨仿真共享条目。现已改为每次仿真通过 -`ContextVar` 创建独立缓存,并在运行结束后整体释放;并发任务不会共享缓存或 -命中统计。每个“物性操作 + 介质实例”使用独立的 C 层有界 LRU,默认上限为 -8192 项。当前只缓存四条有明确重复收益的氦气路径:密度、等熵密度—压力因子、 -`properties_from_mU` 和 `temperature_from_pressure_enthalpy`。 - -同一个高压氦气阶跃算例的冷缓存 audit 结果为: - -| 操作 | 调用 | 命中 / 未命中 | 命中率 | -| --- | ---: | ---: | ---: | -| `density` | 578 | 403 / 175 | 69.7% | -| `isentropic_density_pressure_factor` | 398 | 310 / 88 | 77.9% | -| `properties_from_mU` | 1,930 | 1,843 / 87 | 95.5% | -| `temperature_from_pressure_enthalpy` | 398 | 313 / 85 | 78.6% | - -四项合计 2,869 次命中、435 次未命中、435 个最终条目,未发生驱逐。PR 三次根 -计算从原审计的 1,712 次降到 689 次。关闭埋点、各自预热 5 次并测量 21 次时, -缓存开启/完全关闭的墙钟中位数在本机分别为 71.6 ms 和 154.0 ms。这个对比表示 -“四项缓存整体”相对“完全不缓存”的收益,不能误解为相对旧版两个 LRU 又提升 -53.5%。缓存开关两次运行的完整 `series` 和 `final` SHA-256 一致。 - -10 s、501 个输出点的氦气均压算例单次复验中,缓存开启和关闭分别用时 -14.18 s 与 26.64 s;开启时命中 400,571 次、未命中 96,427 次。四个缓存均达到 -各自 8192 项上限,共发生 63,659 次 LRU 驱逐,但仍完成到 10 s。该长算例每种 -配置只测了一次,只能说明容量上限确实生效且仍有收益,不能作为稳定百分比承诺。 - -worker 暖机只执行内存中的一维 BDF、`least_squares`、`brentq`、稀疏 Jacobian -分组和 XSD 编译,不运行用户模型、不写文件、不填充氦气业务缓存。当前暖机还显式 -覆盖携带 `jac_sparsity`、使用 sparse LSMR trust-region 子问题的代数路径,避免真实 -用户任务第一次触发该 SciPy 分支时再承担惰性初始化。三个新进程的 -中位数为:不暖机首个仿真 709.1 ms;启动暖机本身 637.1 ms;暖机后的首个仿真 -69.8 ms,同进程第二次约 68~74 ms。也就是说总初始化成本没有消失,而是被 -移到服务宣告就绪之前。 - -## 10. 2026-08-16 历史物理岛版闭合复验 - -> 本节保留方程关联块实现之前的物理岛版数据,用于纵向对照。它已不是当前执行计划;当前结果见第 11 节。 - -该轮把 signal 源/连接、stream 组件/端口/连接以及气动外部 volume 组件/连接的 -静态查找移到系统构造阶段;运行时仍按每个状态执行实际传播和热力刷新。压力流量 -闭合先进行一次全网求解,再根据预编译的依赖声明,只对 stream-sensitive 的物理 -连通块做后续固定点重算。对未声明依赖的自定义 stream 组件、跨组件方程或非方阵 -物理岛,执行计划保守回退到原全网求解,不以性能换取模型兼容性。 - -该历史版本的 `off` 模式配对结果如下。表中百分比是同一模型、同一数值配置下的墙钟改善,适合 -判断优化方向,不是跨机器速度承诺: - -| 算例 | 历史物理岛版相对原全网闭合的改善 | -| --- | ---: | -| `air_chain` | 7.9% | -| `air_branched` | 6.5% | -| `helium_step` | 0.6% | -| `mechanical_contact` | 21.4% | -| high-stiffness short | 14.6% | - -`helium_step` 在该历史版本里只有一个需要在 stream 后继续求解的敏感物理岛,因此 0.6% 的改善处于 -小幅范围;不能用其他无敏感岛模型的收益夸大氦气模型的效果。积分完成后的后处理 -也没有跳过闭合:每个输出采样点仍重新应用状态、执行完整 `_close_current_state()` -并提取结果,只是闭合内部使用同一安全执行计划。 - -完整 high-stiffness 10 s 算例的历史 `off` 基线约为 28.126 s;本轮全部改动后的 -多次运行中位数为 13.694 s。这个跨版本对比同时包含本轮多项改动,不能把差额全部 -归因于物理岛裁剪。为了单独核对当时的闭合计划,在同版代码上做 optimized/forced-global -对照,墙钟分别为 14.676 s 和 16.929 s,完整 `series` 完全一致;这组受控对比才 -直接反映该模型的执行计划收益。 - -当前诊断已经进一步消除旧命名歧义:`secondaryPhysicalIslandCount` 表示安全分类的 -物理范围,`secondaryBlockCount` 表示方程—未知量关联图中的真实块数, -`secondaryUnknownCount` 表示这些方程块合计未知量。`solveCount` 统计实际求解器调用, -`closurePassCount` 单列发生过压力求解的闭合 pass;`last` 中还包含 -`residualEvaluations`、`jacobianMode`、dense/方程块回退状态。不能再把 -`secondaryBlockCount` 解释为物理岛数。 - -## 11. 2026-08-16 大型分支 XML 与方程块首轮复验 - -验证输入 `test_mql-full-branches-01-04.xml` 的 SHA-256 为 -`2fb95e65f5de0c85a6a17802aef74ea004087323fd00fd8d01acf0184ff71d48`。模型规模如下: - -| 项目 | 数量 | -| --- | ---: | -| 组件 | 98 | -| ODE 状态 | 74 | -| 压力/流量/机械代数未知量与方程 | 472 / 472 | -| 代数声明图结构非零 | 919(约 0.413%) | -| 全部独立代数方程块 | 58 | -| stream 后续敏感方程块 | 9,合计 192 个未知量 | - -### 11.1 `0.69 s` 慢区的根因与完整结果 - -旧代码在 `0.69 s` 左右不是线程死锁:它仍会缓慢前进,但 BDF 在刚性变化区大量 -缩步、重建有限差分 Jacobian 和执行 LU。定位出的结构错误是气动外部 volume -跨域耦合没有完整进入 ODE 状态依赖图。机械位置先写入气室容积,气室压力又反馈到 -机械力平衡;旧图只沿普通物理端口追踪,漏掉这条闭环中的 12 个实测显著导数项。 - -修正后,状态稀疏图由 1092 个非零项、27 个颜色组变为 1284 个非零项、31 个颜色组。 -颜色数增加是因为补上了真实依赖,并非回退到更差算法;完整 Jacobian 让 BDF 少走 -错误 Newton 方向和重复试步。功能收口过程中的较早阶段测量为 92.187 s;最终稳定 -代码连续三次完整 `0~0.81 s` 分别用时 79.049 s、74.658 s 和 85.103 s,数值工作量一致: - -| 指标 | 结果 | -| --- | ---: | -| `nfev` | 3393 | -| `njev` | 226 | -| `nlu` | 667 | -| 接受步 | 1009 | -| 求解器启动次数 | 3 | -| 压力流量求解 | 28008,全部 seeded | -| 方程块/dense 非线性回退 | 0 / 0 | -| 三次 full-response 规范 JSON SHA-256 | `454cd11aece1c4a2296a88e2c1dd592eeace28565e342235fb7a7df34de5b18f` | -| `physical-solution-v1` SHA-256 | `04982f427867801c582fea81c6e2da0b726bd8a61d7894b311e4a807b19e89a7` | - -这里的 full-response 哈希覆盖完整响应,所以诊断字段增删也会改变它。为稳定比较物理 -结果,`physical-solution-v1` 只把 `{status, simulatedUntil, requestedStopTime, series, -final}` 投影为待哈希对象;schema 名只是外部标签,不进入对象。两种口径都使用 -`json.dumps(sort_keys=True,separators=(",",":"),ensure_ascii=False)` 后计算 SHA-256。 -旧 `09b5c7…` 是聚合诊断和最终 union 路径收口前的 full-response 哈希,响应结构不同, -不作为最终结果,也不能与当前口径直接比较。 - -容差 A/B 必须分开解释:机械状态 `atol` 从 `1e-12` 放宽到 `1e-10` 的单次测试约快 -16%,但会改变机械状态与事件的误差合同,当前未采用;外层 thermofluid 流量固定点 -相对容差从 `1e-12` 放宽到 `1e-9` 后,BDF 内部步数反而增加并改变积分轨迹,也未 -采用。完整成功来自依赖图修正和闭合优化,不是牺牲积分精度或闭合精度。 - -### 11.2 stream 方程块与受控 A/B - -第一次压力流量求解仍承担“全网必须成立”的语义;但可信声明图允许它在非线性时只把 -本轮未闭合的独立方程块合并成一个 union sparse 问题,而不是固定构造 472 变量的 -dense 问题。stream 更新后的固定点进一步只处理 9 个敏感方程块、合计 192 个未知量, -不再因为机械总线把拓扑连成一个大物理岛,就重复求解全部 472 个未知量。 - -在相同当前代码、相同 `0~0.01 s` 区间做 optimized/forced-global 配对,墙钟分别为 -16.200 s 和 18.584 s,物理解与 `series` 逐值一致。这组对照隔离的是后续 stream -闭合作用域;它不包含完整 `0.81 s` 慢区的全部收益,不能与最终完整运行直接换算百分比。 - -### 11.3 非线性稀疏回退与其他首轮项 - -全局非线性回退当前采用兼容保护链:可信声明图先求未闭合方程块的 union sparse; -若块解失败,先把所有共享端口未知量恢复到原始 `x0`,再做 global sparse;若 sparse -仍未达到既有残差合同,再次从原 `x0` 做 global dense。活动接触会改变坐标/活动集, -不可信自定义声明也可能漏依赖,这两类不冒险使用静态稀疏图,继续走 dense 兼容路径。 - -受控扰动微基准在相同 `max_nfev=20` 下得到: - -| 路径 | 真实残差回调 | 墙钟 | -| --- | ---: | ---: | -| dense | 3796 | 7.357 s | -| sparse | 164 | 0.634 s | - -同一评估预算下约为 11.6 倍的回退成本改善;两条路径在 20 次优化器评估内都没有收敛, -所以这是“数值 Jacobian 试算成本”微基准,不是整体仿真加速承诺。诊断用 -`residualEvaluations` 记录真实残差回调,避免仅看 SciPy `nfev` 漏掉内部差分调用。 - -worker 暖机现已覆盖带 `jac_sparsity` 的 sparse LSMR `least_squares` 路径。逐步积分的 -dense output 也改为只在当前步跨越下一采样点或需要状态事件定位时构造;本 XML 含 -状态事件,所以每步仍需要插值,这项优化对最终 79.049 s/74.658 s/85.103 s 复验没有收益。 diff --git a/docs/other/后端求解逻辑与效率优化调研.md b/docs/other/后端求解逻辑与效率优化调研.md deleted file mode 100644 index e14d70d..0000000 --- a/docs/other/后端求解逻辑与效率优化调研.md +++ /dev/null @@ -1,597 +0,0 @@ -# SystemSimulationApp 后端求解逻辑与效率优化调研(通俗版) - -> 调研基线:2026-08-15(System XML v3 迁移后);2026-08-16 已补充压力边界、预编译闭合执行计划、方程关联图分块、代数稀疏回退、ODE Jacobian 修正和实测复验的当前状态。 -> 本文所称“主求解路径”是当前前端实际调用的 System XML 流式接口;固定 TestModel 和 Test MQL 接口另行说明。机器相关的实测结果单独见[仿真性能评估 2026-08-15](仿真性能评估-2026-08-15.md)。 - -## 0. 三分钟读懂 - -### 0.1 求解器到底在做什么 - -先不管 ODE、RHS、BDF 这些名字。把一次仿真想成制作一段工程动画: - -1. **检查装配图。** 气管有没有漏接,控制线方向对不对,模型参数是否齐全。 -2. **读取当前“存量”。** 例如气室里有多少气体和能量,质量块现在的位置和速度。 -3. **让当前瞬间自洽。** 根据这些存量,把此刻的压力、流量和力反复对账,直到连接规则和组件方程同时满足。 -4. **计算变化速度。** 得出“下一小段时间内,质量、能量、位置、速度将怎样变化”。 -5. **内部小步前进。** 求解器自己决定每次走多小;变化剧烈时会缩短步长。 -6. **按用户指定时刻留快照。** 内部可能算很多小步,但结果文件只在 XML v3 的 `sampleStep` 指定的时刻保存数值。 -7. **把进度和结果交给前端。** 运行中发心跳/进度,结束时一次性发送完整曲线数据。 - -项目里的技术说法“**半显式 ODE + 每次变化率计算前做代数闭合**”,翻译成人话就是:**会积累的量用时间积分向前推;必须在当前瞬间成立的关系,每次都先对账求平衡。** - -### 0.2 用仓库里的真实案例贯穿全文 - -`tests/test_generic_system_xml_simulation.py:86-145` 有一条完整回归气路: - -```text -高压气缸 低压储气罐 -0.01 m³、500 kPa 0.1 m³、100 kPa - ──> 节流孔 ──> 1 m 管路 ──> -``` - -图中箭头只表示这个初始压差下**预计**的气流方向,不代表物理连线本身有 `source/target` 方向。回归测试检查了: - -- 气缸压力下降; -- 储气罐压力上升; -- 总质量和总能量守恒; -- 把工程 JSON 中物理边的 `source/target` 对调,结果不变。 - -测试只运行 `0~0.01 s`,设置结果采样 `sample_step=0.005 s`、内部上限 `max_step=0.001 s`、`method=BDF`(`tests/test_generic_system_xml_simulation.py:86-145, 254-300, 354-372`)。在 System XML v3 中,前两个字段分别写成 `sampleStep` 和 `maxStep`。它们可以这样理解: - -```text -结果快照: 0 s -------- 0.005 s -------- 0.01 s -内部计算: 0 s - 小步 - 小步 - 小步 - ... - 0.01 s - 每个内部步最多 0.001 s,也可能更短或被重算 -``` - -- `sampleStep`:相机隔多久保存一张结果快照; -- `max_step`:求解器一次内部前进最多能走多远; -- `BDF`:一种适合系统中“有的变化快、有的变化慢”的自适应算法。本文不需要展开它的公式。 - -另一个真实案例位于 `tests/test_amesim_pnvo001_signal_xml.py:87-204, 230-246`:高、低压氦气室之间有一个阀,阶跃信号在 `0.04 s` 从 0 跳到 1。求解器会像遇到“定时闹钟”一样,准确停到事件时刻,更新阀命令,再从该时刻继续积分。 - -### 0.3 常见术语翻译 - -| 技术词 | 先这样理解 | 本项目里的具体含义 | -| --- | --- | --- | -| 动态状态(state) | 会随时间积累的存量 | 气体质量/内能 `[m,U]`,或机械速度/位置 `[v,x]` | -| 代数量 | 当前瞬间的仪表读数 | 压力、流量、连接力等;由当前状态和约束求出 | -| 代数闭合(closure) | 把所有账对平 | 让组件方程、连接守恒和当前状态同时成立 | -| 变化率计算(RHS) | 算下一刻变化有多快 | 输入当前状态,输出 `dm/dt`、`dU/dt`、加速度等 | -| ODE 积分 | 根据变化率向时间前进 | SciPy 的 BDF、Radau、RK45 等 | -| DAE | 状态和瞬时约束一起交给专用求解器 | 当前主内核不是通用 DAE 求解器 | -| stream 焓 | 气体随流动携带的“能量标签” | `h_outflow` 按实际流向传播/混合,不是两端温度相等 | -| 非线性迭代(`least_squares`) | 直接算不出时反复试值 | 压力流量快速路径失败后的回退方案 | -| Jacobian | “改一个量会影响哪些方程”的灵敏度地图 | 可帮助 BDF/Radau 和非线性求解少做试算 | -| dense output | 两个内部步之间的插值尺 | 用来补采样点和定位机械事件 | -| NDJSON | 一行一个 JSON 消息 | 同一 HTTP 响应中依次发送心跳、进度、结果 | -| worker | 后台办事通道 | 当前脚本是一个 Uvicorn worker;流式任务另开求解线程 | - -只想了解系统如何运行,可以读第 0、3、5、6、9、10、13 节;需要改求解器时,再阅读其余技术细节和第 15 节代码索引。 - -## 1. 结论先行 - -1. **[已实现] 当前主内核是“半显式 ODE + RHS 内代数闭合”。** 动态组件只把储能状态交给 ODE 积分器;每次计算导数前,系统先传播信号、刷新热力状态、求压力/流量代数网络、传播变容边界、迭代 stream 焓并更新机械加速度。它不是通用 DAE 求解器,也不等价于完整 Modelica `inStream/actualStream` 语义(`README.md:18-20`、`app/simulation/README.md:174-195`)。 -2. **[已实现] 当前前端主链路是 System XML 流式仿真。** 浏览器生成 XML,经 `POST /api/system-xml/simulate-stream` 发送;后端以 NDJSON 返回心跳与进度,最后在一个 JSON 行中返回完整结果。不是 WebSocket 或标准 SSE。 -3. **[已实现] XML v3 的 `sampleStep` 是输出采样间隔,不是固定积分步长。** 内部的 `max_step`(XML 为 `maxStep`)才是自适应积分步长上限;`BDF/Radau/LSODA/RK45/RK23/DOP853` 均受支持。流式运行因总是提供取消检查,会使用 SciPy 低层求解器逐个已接受步推进。 -4. **[已优化] 每次完整闭合仍先做 1 次语义上的全网压力流量求解,再传播 stream;后续固定点只重算 stream-sensitive 方程实际关联的方程块。** 物理连通岛现在只是安全分类的第一层,真正执行的 `secondaryBlockCount` 是方程—未知量关联图中的块数;无法可信分类的自定义或异常结构仍保守回退原全网路径。积分结束后,每个输出采样点仍执行一次完整闭合并提取结果。 -5. **[已实现] 代数求解已有因果化快路径、方程块和稀疏 `least_squares` 回退。** 对可信内置声明,求解器从方程—未知量关联图编译 `jac_sparsity`,先把本轮未闭合的独立方程块合并成一个 union sparse 问题;若失败,恢复到同一个原始 `x0`,再执行全局 sparse,仍失败才执行兼容的 dense 路径。活动接触或不可信自定义声明不会冒险稀疏化,保留 dense 兼容路径。当前没有解析 Jacobian,但不再是“完全未提供 `jac_sparsity`”。 -6. **[已实现] 当前启动脚本是一个 Uvicorn worker。** 每个流式任务再创建一个无并发上限的 daemon 线程和无界队列;没有进程池、集中任务队列、CPU/内存配额或持久化作业系统。同步仿真端点还会在 `async def` 中直接执行 CPU 密集代码。 -7. **[推断] 优化应分两条线:** - - 单算例速度:减少闭合 pass、代数未知量与残差装配,缓存热物性,改善外层积分尺度/Jacobian,减少后处理重算; - - 服务吞吐与资源稳定性:有界进程 worker、结果分块/按需返回、任务表主动清理和前端结果存储降副本。 -8. **[已修复] 高刚度 XML 不再因合法亚帕试探压力失败。** 压力优化下界由 1 Pa 放宽到 0 Pa,允许严格正的亚帕试探值,同时构成方程仍拒绝零压和负压;同一 10 s RK45 算例已完成且没有可恢复重试。 -9. **[历史实测] 闭合裁剪的收益随拓扑明显变化。** 已被方程块版取代的物理岛版短算例 `off` 配对改善为 0.6%~21.4%;其 high-stiffness 10 s optimized/forced-global 对照为 14.676 s/16.929 s,完整 `series` 一致。数据保留作纵向基线,不能当作当前方程块版结果,也不能把其他拓扑的收益外推给单一氦气敏感岛。 -10. **[实测] 98 组件的大型分支 XML 在 `0.69 s` 附近的长时间停留不是死锁。** 根因是气动外部 volume 跨域耦合在 ODE 有限差分 Jacobian 依赖图中漏了 12 个实测显著项,BDF 因此反复做小步和 Jacobian/LU 工作;修正后结构非零数由 1092 增至 1284、颜色组由 27 增至 31。最终稳定代码连续三次完整 `0~0.81 s` 用时 79.049 s、74.658 s 和 85.103 s,积分统计均为 `nfev/njev/nlu=3393/226/667`、接受步 1009;stream 后续闭合为 9 个方程块、合计 192 个未知量。 - -## 2. 证据标签与范围 - -- **[已实现]**:当前可执行代码或测试直接体现。 -- **[约定]**:配置、Schema、类型或仓库说明规定,但不一定被每条入口完整执行。 -- **[推断]**:从调用结构推导的资源或性能判断,尚无仓库内基准数据。 -- **[发现]**:实现间的不一致、诊断缺口或潜在效率风险。 - -本次覆盖四条后端执行路径: - -| 路径 | 是否为当前 UI 主路径 | 数值行为 | -| --- | --- | --- | -| `POST /api/system-xml/simulate-stream` | 是 | XML 校验、拓扑编译、通用系统积分;NDJSON 进度与最终结果 | -| `POST /api/system-xml/simulate` | 否 | 同一通用求解函数;同步返回、无任务登记/流式进度 | -| `POST /api/reactflow/simulate-testmodel` | 否 | 固定 TestModel 专用闭包与产物生成,不按任意 ReactFlow 边拓扑求解 | -| `POST /api/reactflow/simulate-test-mql` | 否 | 当前只返回结构/采样摘要和全零状态数组,不执行 132 状态时域积分 | - -## 3. 主求解调用链 - -```mermaid -flowchart TD - A["前端校验参数并生成 System XML"] --> B["POST simulate-stream"] - B --> C["安全解析 + XSD v3 + 语义校验"] - C --> D["XML 执行模型直接编译 SimulationNetwork"] - D --> E["准备检查与求解器构造"] - E --> F["一致初始代数闭合"] - F --> G["自适应 ODE 逐步积分"] - G --> H["每个 RHS:信号/热力/代数/stream/机械闭合"] - G --> I["事件定位、状态重置、求解器重启"] - G --> J["逐采样点完整后处理闭合"] - J --> K["完整结果作为末条 NDJSON 返回"] -``` - -### 3.1 前端形成输入 - -**[已实现]** 前端先检查模型、表达式和仿真设置,再由 `buildSystemXml()` 在浏览器内构造精简 v3 XML。参数表达式会先求值,并按选定显示单位转成 SI 基准数值;求解期间这些组件参数保持静态,不存在前端实时调参或联合仿真输入通道(`frontend/src/App.tsx` 中的参数解析与 `buildSystemXml()`;`app/simulation/core/base.py:65-95`)。 - -前端生成 `simulationId`,通过 `fetch` 发送 XML,请求头携带该 ID;响应类型要求为 `application/x-ndjson`(`frontend/src/App.tsx` 中的运行入口与 `streamSystemSimulation()`)。 - -### 3.2 XML 校验、解析和网络编译 - -主序列见 `app/main.py` 的 `run_system_xml_simulation()`: - -1. `validate_system_xml_document()` 执行大小/安全解析、v3 XSD 和语义校验; -2. XML 解析为只含求解信息的规范化执行模型,不重建 ReactFlow 画布; -3. `compile_system_xml_network()` 创建 `SimulationNetwork`; -4. 构造 `GenericFluidSystem` 与 `SolveIVPConfig`; -5. 调用通用 `simulate()`; -6. 汇总验证、模型接口、数值结果和错误诊断。 - -XML 安全层限制 5 MiB、禁用 DTD/实体和网络访问(`app/system_xml.py` 的安全解析与 `validate_system_xml_document()`)。 - -XML 编译最终进入 `_compile_solver_network()` 的两阶段装配(`app/main.py`): - -- v3 语义层先核对组件 `modelVersion`、完整参数和端点引用;编译层再消费零端口介质定义节点; -- 按气动连通区域解析介质引用; -- 实例化实际组件,并根据注册模型恢复端口类型、角色和变量合同; -- 最后建立网络连接。 - -**[已实现]** v3 不携带坐标、旋转、镜像或端口显示侧,布局不会进入求解。`AmesimForc` 的物理正反由模型参数 `direction=+1/-1` 决定;旋转/镜像图标不会改变力方程(`app/simulation/components/amesim/mechanical/translational.py`)。 - -### 3.3 求解前结构检查 - -创建通用系统前,`generic_simulation_preparation_issues()` 检查(`app/simulation/systems/generic.py:93-201`): - -- 所有物理端口已经连接; -- 压力流量系统的未知量数与方程数相等; -- 网络至少存在一个动态储能组件; -- 每个相连物理岛具有动态储能锚点; -- 不允许多个理想储能元件无阻力直接耦合。 - -这些检查是当前可求解结构的边界,不表示任意声明了 `PortDefinition` 的网络都能被通用求解器处理。 - -## 4. 数学结构与状态组织 - -本节的核心区别只有一个:**有些量要“记住过去并随时间积累”,有些量只要在当前瞬间满足约束。** - -在高压气缸向低压储罐放气的案例里,气室内的气体质量和能量属于前者;端口压力和通过节流孔的瞬时流量属于后者。求解器先保存前者,再用它们求出后者。下面的“动态状态”“代数未知量”只是这两组量的技术名称。 - -### 4.1 动态状态 - -**[已实现]** 动态组件把自身状态拼成全局 ODE 向量: - -- 固定/变容气室及部分动态管路通常使用质量与内能 `[m, U]`;导数由质量流、焓流和换热构成。PNCH023 示例见 `app/simulation/components/amesim/storage/chambers.py:111-218`。 -- PNL0003 具有两个容积单元,使用四维 `[m1, U1, m2, U2]`(`app/simulation/components/amesim/flow/pipes.py:1043-1078`)。 -- MECMAS21 的机械状态是 `[v, x]`,导数是 `[a, v]`(`app/simulation/components/amesim/mechanical/translational.py:569-700`)。 - -`MechanicalStateReducer` 会按机械连接中的 `x/v` 等值关系将刚性相连的质量归组,每组只保留一套 `[v, x]` 状态,从而避免重复积分同一个运动自由度(`app/simulation/solvers/mechanical.py:225-427`)。 - -### 4.2 代数未知量与方程 - -网络从物理端口合同和组件残差构造代数系统(`app/simulation/systems/network.py:159-237`): - -- 气动端口的主要代数变量是 `p` 和 `m_flow`; -- 机械端口包含 `x`、`v`、`f`,其中已归属动态状态的坐标会被状态/等值关系约束; -- 连接对 `effort/equal` 生成两端差值,对 `flow/sumToZero` 生成两端和值; -- 组件再提供状态约束、构成关系、质量/力守恒等残差。 - -stream 焓 `h_outflow` 不直接强制相等;标量信号和气动外部容积也不进入同一通用连接残差,而由专用 resolver 处理。 - -### 4.3 压力流量因果化与非线性回退 - -`PressureFlowSolver` 初始化时预编译方程模板、effort 等值组、显式流/力赋值计划、未知量布局,以及方程声明到未知量的静态关联图。热流闭合先按物理连通关系划定安全范围,再在可信内置模型中按这张关联图拆成更细的方程块。每次 `solve()`: - -1. 从上一解与当前动态状态播种端口未知量; -2. 传播状态拥有的压力、位移和速度; -3. 执行可显式求值的构成关系与守恒关系; -4. 处理单边接触约束; -5. 若缩放后残差不超过 `1e-7`,直接返回且 `evaluations=0`; -6. 否则找出残差未闭合或种子不可行的方程块,把这些互相独立的块合并成一次 sparse `least_squares`; -7. 方程块失败时恢复原始 `x0`,执行全局 sparse `least_squares`;全局 sparse 仍失败时再次恢复同一个 `x0`,最后执行 dense 兼容回退。 - -非线性回退当前参数为 `x_scale="jac"`、`ftol=xtol=gtol=1e-10`、`max_nfev=500`。可信结构会传入由声明图编译的 `jac_sparsity`;活动单边接触会改变坐标和活动集,自定义组件也可能没有完整依赖声明,因此这些情况继续使用 dense 路径。稀疏/分块是加速层,不会把失败候选带入兼容回退。诊断中的 `evaluations` 是 SciPy 报告的优化器 `nfev`,`residualEvaluations` 才包含数值 Jacobian 在内的真实残差回调数,并通过 `jacobianMode`、`denseFallbackUsed` 和块回退字段说明实际路径。 - -**[当前边界]** 第一次压力流量求解仍要保证全网所有方程成立,但“全网语义”不再等于“总是把全部未知量交给一个 dense 优化器”:可信声明图可只求本轮未闭合方程块的 union。后续 stream 固定点则进一步只重算敏感方程块。保守条件会恢复全网 sparse→dense 行为;没有可信依赖声明或活动接触时,仍可能直接恢复 dense,成本会随未知量数快速上升。 - -在用户提供的 `test_mql-full-branches-01-04.xml`(SHA-256 `2fb95e65f5de0c85a6a17802aef74ea004087323fd00fd8d01acf0184ff71d48`)中,472×472 代数关联矩阵只有 919 个结构非零项,密度约 0.413%,可拆为 58 个独立方程块。一个受控扰动实验在相同 `max_nfev=20` 下,dense 与 sparse 分别触发 3796 和 164 次真实残差回调,墙钟约为 7.357 s 和 0.634 s,即同一预算下约 11.6 倍的单次回退成本改善;这项实验只衡量回退开销,不代表两条路径在 20 次评估内已经收敛。 - -## 5. 每次导数计算的代数闭合 - -这一步就是第 0 节所说的“当前瞬间对账”。以高压气缸—节流孔—管路—低压储罐为例,求解器需要同时保证: - -- 接头压力相容; -- 从一个组件流出的质量等于进入另一个组件的质量; -- 节流孔和管路自己的压差—流量关系成立; -- 气体携带的能量按实际流向交给下游; -- 若还有机械活塞或控制信号,它们在同一时刻也要一致。 - -代码先建立全网初始压力解,再传播 stream 焓;只有组件方程明确依赖 stream 的范围,才让 stream 焓和其方程—未知量关联块继续迭代到同一个固定点。这样既让当前 RHS 不依赖上一次调用留下的焓/流量历史、保持有限差分 Jacobian 可重复,又避免无关方程重复对账。物理岛仍用于确认范围不会跨越未声明边界,但不再等同于实际求解块。 - -`GenericFluidSystem._close_current_state()` 的实际顺序见 `app/simulation/systems/generic.py`: - -| 顺序 | 操作 | 目的 | -| ---: | --- | --- | -| 1 | `SignalResolver.solve(time)` | 用构造时预绑定的信号源和连接更新时间信号 | -| 2 | 传播机械 `x/v` 等值关系 | 把当前机械状态同步到刚性连接端口 | -| 3 | `PneumaticVolumeResolver.solve()` | 用预绑定的气动端口、输出组件和连接传播外部 `volume/volume_flow` | -| 4 | 刷新动态组件热力端口 | 由当前 `m/U/V` 和最新体积恢复压力、温度、焓 | -| 5 | 第一次全网 `PressureFlowSolver.solve()` | 建立本轮热流固定点的初始压力和流量 | -| 6 | `StreamResolver.solve()`,必要时最多 25 轮重算敏感方程块 | 用预绑定的组件、端口和连接传播焓;让焓、温度引用和敏感构成流量同时收敛 | -| 7 | 更新机械约束加速度 | 为机械状态导数准备 `a` | - -随后 `rhs()` 才收集各动态组件的导数。 - -因此每次闭合固定有 **1 次全网语义**的压力流量求解;没有敏感方程块时,stream 传播一次后结束,不再有第二次压力求解。若有 `B` 个敏感方程块,每一轮外层热流固定点会把相应块作为一个 union 快路径求解,最多 25 轮;保守回退时恢复原全网求解器。stream 自身仍有相对容差 `1e-9` 和最多 100 次内部迭代,外层热流固定点最多 25 轮,两层上限不能混为一个数。上述大型 XML 的敏感 union 是 9 个方程块、合计 192 个未知量,而不是其单一巨大物理连通岛中的全部 472 个未知量。 - -signal、stream 和外部 volume resolver 已把静态组件列表、端口引用和连接绑定预编译到系统构造阶段;运行时仍执行真实的信号赋值、焓迭代、volume 传播和动态热力刷新。闭合计划只在组件以布尔能力声明明确说明压力流量方程是否依赖 stream、且方程变量声明可信时裁剪;未分类自定义 stream 组件、非法声明、跨组件残差或局部非方阵都会保守回退全网。这个边界避免把“少扫描”误做成“少算物理关系”。 - -## 6. 初始化、积分参数与推进方式 - -可以把初始化理解为“先摆好第 0 帧”,把积分理解为“根据每一帧的变化速度继续制作后续帧”。初始化并不会自动修改所有不合理的初始存量;它主要是在给定初始质量、能量、位置和速度后,求出与之匹配的端口压力、流量和力。 - -### 6.1 初始状态 - -`consistent_initial_state_vector()` 取得组件构造时形成的初值,应用该状态并执行一次完整代数闭合,然后原样返回状态向量(`app/simulation/systems/generic.py:292-296`)。 - -**[重要边界]** 这不是通用 DAE 一致初值求解:它不会联合调整微分状态及导数,只求“给定当前动态状态时”的端口代数变量。固定 TestModel 的专用压力投影/初始化逻辑不能外推为通用 XML 求解器能力。 - -### 6.2 参数来源及真实含义 - -初学者最需要先分清 XML v3 的 `sampleStep` 和 `maxStep`(进入 Python 后分别是 `sample_step` 和 `max_step`): - -- `sampleStep` 只控制**结果多久记一条**; -- `max_step` 才限制**内部一次最多走多远**; -- 自适应求解器可以走得比 `max_step` 更短,也可能先试一步、发现误差过大后退回来重算。 - -所以,把 `sampleStep` 从 0.1 改成 0.01 通常会让输出曲线更密、结果占用更多内存,但它不等价于命令求解器固定每 0.01 秒算一步。 - -| 参数 | 当前来源/默认值 | 实际用途 | -| --- | --- | --- | -| `t_start / t_stop` | 前端与 Pydantic 默认 `0 / 2 s` | 积分区间 | -| `sampleStep`(内部 `sample_step`) | 默认 `0.1 s` | 仅生成输出 `t_eval` 采样网格;工程 JSON 仍暂名 `simulation.step` | -| `maxStep`(内部 `max_step`) | 默认 `0.005 s` | 自适应求解器内部已接受步的上限 | -| `method` | 默认 `BDF` | BDF、Radau、LSODA、RK45、RK23、DOP853 | -| `rtol` | 通用 XML 路径硬编码 `1e-6` | 外层 ODE 相对误差;用户不可配置 | -| `atol` | `SolveIVPConfig` 标量默认 `1e-8`;机械状态收紧到 `min(default, 1e-12)` | 热力状态使用默认值,机械速度/位置使用更紧的分量容差;用户不可配置 | -| `first_step` | 默认 `None` | 交给 SciPy;用户不可配置 | -| 代数残差容差 | `1e-7` | 压力流量快速路径/接受标准 | -| 代数最大评估 | `500` | 单次 `least_squares` 上限 | -| stream 容差/迭代 | `1e-9 / 100` | 焓传播固定点 | -| 采样数上限 | 无固定业务上限 | 输出规模受运行时可表示范围和可用资源约束,不限制 RHS 次数或事件数 | - -前端/Pydantic 默认值见 `frontend/src/App.tsx` 的仿真默认配置和 `app/main.py:131-137`;通用路径构造 `SolveIVPConfig` 见 `app/main.py:684-701`;采样网格见 `app/simulation/systems/generic.py:204-225`。 - -**[已实现]** `sampleStep` 生成的采样网格会确保包含 `t_stop`,不再设置固定点数上限;仅在数值非有限、当前运行时无法表示点数或时间无法严格递增时预先拒绝。它不会把 BDF 变成固定步算法。实际 RHS 次数由自适应误差控制、Jacobian 估计、拒绝步、事件重启和 `max_step` 共同决定。 - -### 6.3 逐步推进分派 - -`integrate_ode()` 位于 `app/simulation/solvers/solver.py:917-1009`: - -- 有取消检查、信号断点或机械状态事件时,使用 SciPy 的低层 BDF/DOP853/LSODA/RK23/RK45/Radau 类逐步推进; -- 无上述需求时,可一次调用常规 `solve_ivp`; -- 当前流式接口总会传 `cancel_check`,所以总走逐步路径; -- 同步 XML 接口只有在没有断点/状态事件时才可能走一次性 `solve_ivp`。 - -逐步路径在每个已接受步上: - -1. 检查取消; -2. 推进一步; -3. 仅当本步跨越下一个 `t_eval` 样本或需要机械状态事件检测时构造 dense output,并插入样本; -4. 检测状态事件; -5. 报告进度; -6. 必要时重建求解器。 - -**[已优化]** 没有跨采样点、也没有状态事件时,不再为每个已接受步无条件构造插值对象。该优化对稀疏采样、无机械事件的模型有收益;用户大型分支 XML 含机械状态事件,所以仍必须在每步保留用于事件定位的 dense output,本轮实测中这项优化对该 XML 没有收益。 - -Peng–Robinson 试探状态越界会抛 `RecoverableTrialStateError`;代码回到最后已接受状态,将 `max_step` 减半,最多重试 16 次(`app/simulation/solvers/solver.py:528-914`)。 - -高刚度 XML 的历史失败不是“所有亚帕压力都不物理”,而是优化器原 1 Pa 下界过严:RK45 的内部自适应试探会短暂给出仍严格大于 0 Pa、但小于 1 Pa 的压力种子。当前 `PRESSURE_LOWER_BOUND_PA=0.0`,合法正压试探交回 RK45 自身判断,零压/负压仍由构成方程拒绝。该模型 10 s RK45 已通过且可恢复重试数为 0,因此这里没有用异常重启掩盖真实模型错误。 - -### 6.4 大型分支 XML 的 `0.69 s` 慢区 - -用户 XML 包含 98 个组件、472 个代数未知量和 74 个 ODE 状态。旧代码不是在 `0.69 s` 死锁,而是 BDF 到达这一刚性变化区后开始大量缩步、重建有限差分 Jacobian 并做 LU 分解。定位发现,气动外部 volume resolver 把机械位移写入气室容积,形成“机械位置 → 气室热力/压力 → 气动力”的跨域闭环;旧 ODE 稀疏依赖图只沿普通物理端口追踪,漏掉了这条外部 volume 边的 12 个实测显著导数项。 - -修正采用保守的双向跨域依赖:接收外部容积的气动储能状态依赖相关机械状态,机械力平衡也依赖被耦合的气动状态。结构非零数因此由 1092 增至 1284,有限差分颜色组由 27 增至 31。颜色组稍多不是退化:旧图更小是因为漏项,给 BDF 的 Jacobian 数值不完整,导致后续重复试步的总成本更高。 - -功能收口过程中的较早阶段测量为 92.187 s;最终稳定代码连续三次完整运行到 `0.81 s`,分别用时 79.049 s、74.658 s 和 85.103 s。三次积分统计均为 `nfev=3393`、`njev=226`、`nlu=667`、接受步 1009、求解器启动 3 次;压力流量求解 28008 次,全部由 seeded 快路径满足残差合同,没有触发方程块或 dense 非线性回退。 - -三次完整响应按规范 JSON 序列化后的 SHA-256 均为 `454cd11aece1c4a2296a88e2c1dd592eeace28565e342235fb7a7df34de5b18f`。为避免诊断字段增删造成“物理结果没变但响应哈希变化”,另定义外部标签 `physical-solution-v1`:待哈希对象只投影 `{status, simulatedUntil, requestedStopTime, series, final}`,标签本身不放入对象;用 `json.dumps(sort_keys=True,separators=(",",":"),ensure_ascii=False)` 规范化后 SHA-256 为 `04982f427867801c582fea81c6e2da0b726bd8a61d7894b311e4a807b19e89a7`。旧 `09b5c7…` 是聚合诊断和最终 union 路径收口前的 full-response 哈希,受响应结构影响,不能与当前哈希直接比较。 - -本轮还区分验证了两种容易混淆的“容差”。机械状态 `atol` 从 `1e-12` 放宽到 `1e-10` 的单次 A/B 约快 16%,但这会改变机械状态与事件的误差合同,当前证据不足,未采用。另一项是外层 thermofluid **流量固定点**相对容差从 `1e-12` 放宽到 `1e-9`;它反而增加 BDF 内部步数并改变积分轨迹,也未采用。这里的正式修复是补全依赖图,不是通过放宽精度或闭合容差掩盖问题。 - -仓库有固定 RK4 回退,但通用压力流量求解器本身依赖 SciPy;因此它不能被视为一般流体网络在无 SciPy 环境下的完整替代方案。 - -## 7. 事件、取消与停止 - -### 7.1 信号离散时刻 - -STEP0、UD00 等信号源提供离散事件时刻。积分器先推进到事件左侧的相邻浮点时刻,再在精确事件时间更新信号并重建求解器,连续动态状态保持不变(`app/simulation/solvers/signal.py:70-99`、`app/simulation/components/amesim/signals/sources.py:128-141`)。 - -### 7.2 机械端挡 - -机械状态事件在已接受步的 dense state 上检查端挡穿越,最多 60 次二分定位;命中后按塑性/恢复系数重置位置和速度并重启积分器。同一时刻最多允许 64 次链式状态重置(`app/simulation/solvers/mechanical.py:430-627`、`app/simulation/solvers/solver.py:92-166`)。 - -**[约定]** 这是为信号断点与机械端挡编写的专用事件框架,不是可由任意组件声明残差事件的通用高指数 DAE 框架。 - -### 7.3 协作取消 - -流式任务的取消端点只设置共享 `threading.Event`。积分器在已接受步、重试边界等检查点协作停止;若正在执行一次耗时的热物性、stream 或 `least_squares` 调用,取消不能立即抢占(`app/main.py:535-547`、`app/simulation/solvers/solver.py:917-1009`)。 - -停止后若至少已有两个有效采样点,通用系统可整理并返回部分结果;相关行为由 `tests/test_generic_system_xml_simulation.py:433-533` 覆盖。 - -## 8. 后处理与现有诊断 - -### 8.1 结果后处理 - -积分完成后,`GenericFluidSystem.simulate()` 重置机械约束模式,并对每个 `solution.t`: - -1. 重新应用状态; -2. 再执行一次完整 `_close_current_state()`; -3. 提取所有组件级及端口级公开结果变量。 - -见 `app/simulation/systems/generic.py:397-474`。 - -**[推断]** 采样密集或结果变量多时,这会形成明显的第二计算阶段;此时内存中还保留积分状态矩阵,CPU 与内存峰值可能重叠。 - -**[当前边界]** 本轮优化没有复用积分期间的闭合快照,也没有跳过后处理对账。每个输出点仍执行完整 `_close_current_state()`;变化仅在于该闭合内部使用同一套预编译绑定和敏感方程块执行计划。 - -### 8.2 当前返回的诊断 - -**[已实现]** 结果包含: - -- 状态数、采样数; -- 压力流量 `solveCount`、`closurePassCount`、`secondaryPhysicalIslandCount`、真实方程 `secondaryBlockCount`、`secondaryUnknownCount`、保守回退原因、最大残差、最大单次评估数和最后求解作用域; -- stream 最大迭代数; -- 停止状态及部分错误上下文。 - -**[已实现]** 积分诊断已经包含分段及汇总的 `nfev/njev/nlu`、已接受步、求解器启动、状态迁移和可恢复重试数。设置 `SIMULATIONAPP_PROFILE=standard|audit` 后,响应还会加入分阶段墙钟时间;audit 进一步记录物性调用、精确重复、缓存命中和逆解迭代。 - -**[已修复]** 闭合现在聚合所有实际压力求解的最大残差和最大单次评估数,`last` 与 `lastScope` 指向最后一个真实求解作用域,不再被一个未执行或较早 pass 的局部变量覆盖。`solveCount` 统计求解器调用次数,`closurePassCount` 单列发生过压力求解的固定点 pass;`secondaryPhysicalIslandCount` 只表示安全分类得到的物理范围,`secondaryBlockCount` 明确表示方程关联块数,不能再把两者混称为“块”。代数诊断还返回真实 `residualEvaluations`、`jacobianMode`、dense/方程块回退状态。子作用域失败时,API 返回 `scopeKind` 和 `scopeComponents`。仍缺少峰值 RSS、任务队列深度等服务级指标;结果字节和编码时间目前由离线基准工具测量,不进入常规 API 响应。 - -## 9. 求解时前后端交流 - -主路径可以压缩为下图: - -```text -浏览器 ──一次 POST:完整 System XML──────────────> 后端 -浏览器 <──同一长连接:心跳、进度、心跳、进度──── 后端求解线程 -浏览器 <──最后一个消息:完整结果 JSON─────────── 后端 -浏览器 ──需要时另发取消 POST───────────────────> 后端 -``` - -这里的“流式”主要是**进度消息流式**,不是每算出一段曲线就立刻传一段曲线。最终数值序列仍在末尾一次性返回。 - -### 9.1 当前协议 - -| 阶段 | 通信 | 当前行为 | -| --- | --- | --- | -| 提交 | 一个 HTTP POST | 请求体为完整 XML;`X-Simulation-Id` 标识任务 | -| 运行 | 同一响应上的 NDJSON | 进度事件、错误事件、5 秒心跳 | -| 完成 | 同一 NDJSON 流最后一行 | 一次性携带完整 `SimulationResult` | -| 用户取消 | 另一个短 POST | 设置协作取消事件;原流继续等待终态 | -| 流断开/停滞恢复 | GET 状态 | 每 500 ms 轮询,最多 30 秒 | - -后端路由见 `app/main.py:589-634, 773-906`,前端解析、取消和恢复见 `frontend/src/App.tsx` 中的 `streamSystemSimulation()` 及相邻任务控制函数。 - -**[已实现]** 后端每 5 秒无队列事件时直接发送 heartbeat。通用系统按进度至少变化 0.25% 才发送积分进度,通常至多约 400 条积分进度事件(`app/simulation/systems/generic.py:318-343`)。 - -前端规则: - -- 30 秒没有收到任何字节:连接超时; -- 15 分钟只收到心跳而没有真实积分进度:判定 stalled 并请求取消; -- 正常运行不是轮询,轮询仅用于异常恢复。 - -**[已缓解]** 合法但单个已接受步/闭合超过原 60 秒阈值时,前端会误判停滞;浏览器警钟现延长为 15 分钟,30 秒断流检测保持不变。该警钟仍以“最后一次非心跳积分进度”为依据,尚未细分 RHS、Jacobian 和闭合活动。后端结果事件的 `phase` 使用 `completed/stopped/stalled/failed`,前端事件类型却声明 `"complete"`;运行时当前没有按该字段做严格校验,所以契约漂移尚未直接报错(`app/main.py:825-840`、`frontend/src/App.tsx` 的流式事件类型)。 - -### 9.2 开发和部署连接数 - -**[已实现]** 开发态 Vite 将 `/api` 代理到 `127.0.0.1:8000`(`frontend/vite.config.ts:4-10`),所以一个流式仿真在开发态占用浏览器→Vite、Vite→FastAPI 两段长连接;若生产部署由 FastAPI/反向代理直接提供 API,则具体连接层数取决于部署。 - -仓库没有 WebSocket 路由、`EventSource` 或 `text/event-stream`;当前 NDJSON 只是普通 HTTP 分块响应。 - -## 10. CPU、线程、内存、网络和磁盘占用 - -先区分两个问题: - -- **一个算例跑得快不快**:主要看每次变化率计算做了多少轮闭合、非线性试算和热物性计算; -- **多人同时运行稳不稳**:主要看并发任务是否有上限、是否能使用多个进程、每个结果在内存中保留多少份。 - -“每个任务开一个线程”不等于“每个任务独占一个 CPU 核”。Python 组件逻辑、SciPy 数值核和底层 BLAS 的实际并行程度取决于运行环境;仓库没有 CPU/RAM 实测数据,所以本节只给代码可证明的结构和数量级。 - -### 10.1 进程与线程 - -- `bat/start-all.bat` 与 `bat/start-all.sh` 分别启动 Vite 与 FastAPI。 -- `bat/start-backend.bat` 与 `bat/start-backend.sh` 的 Uvicorn 命令没有 `--workers`,当前脚本即单进程单 worker。 -- 每个流式仿真创建一个 daemon `threading.Thread` 和一个无界 `queue.Queue`;没有信号量、线程池或排队上限(`app/main.py:773-880`)。 -- 全局任务字典只在读写元数据时持锁,不限制同时启动的求解数量。 -- `POST /api/system-xml/simulate` 是 `async def`,但直接执行同步 CPU 求解;若调用该端点,会占用当前 Uvicorn 事件循环。 - -**[推断]** 单个求解主要是串行 Python 全网扫描加 SciPy 数值核,常会持续消耗一个核心;多任务线程不保证线性利用多核,还可能出现 GIL 竞争、SciPy/BLAS 原生线程过度订阅和内存峰值相叠。仓库没有固定 BLAS 线程数,具体 CPU 占用必须在目标部署环境实测。 - -### 10.2 内存数量级 - -不计 Python 对象常数项,主峰值可写为: - -```text -O(组件 + 连接 + 代数结构) -+ O(采样数 × 动态状态数) -+ O(采样数 × 公开结果变量数) -``` - -当前不设置固定采样点数上限,调用方必须根据模型输出变量数和可用内存选择 `sampleStep`。放大因素包括: - -- 积分状态矩阵与后处理 `series` 在后处理阶段同时存在; -- 最终完整结果保存在全局任务记录中,又被编码为一个大型 NDJSON 行; -- 前端收到结果后执行 `structuredClone`,再 `JSON.stringify` 写入 `sessionStorage`(`frontend/src/App.tsx` 的结果快照与恢复逻辑); -- 图表会把数值数组映射为对象点数组,多个曲线窗口会产生更多前端副本; -- CSV 导出把完整结果再次上传,后端在 `StringIO` 中一次性构造完整 CSV(`frontend/src/SimulationResultsView.tsx:1041`、`app/main.py:299-377`)。 - -`SIMULATION_TASK_RETENTION_SECONDS=600`,但过期任务只在注册下一个任务时清理;没有新任务时,最后一批终态结果可能一直保留到进程退出(`app/main.py:491-520`)。 - -### 10.3 队列、网络和磁盘 - -- 任务队列是无界的,但进度被 0.25% 节流;正常单任务队列通常不大,客户端变慢或终态序列化时仍没有硬上限。 -- 最终数值序列不分块,网络、后端 JSON 编码、前端字符串缓冲与 `JSON.parse` 会在完成时形成瞬时峰值。 -- 通用 XML 求解本身不写仿真产物,结果主要驻留内存。 -- 固定 TestModel 与 public Test MQL runner 会在 `app/data/simulation-runs` 下写时间戳产物;这不是主流式路径的磁盘行为。 - -## 11. 其他求解入口不能与主路径混同 - -### 11.1 固定 TestModel - -`POST /api/reactflow/simulate-testmodel` 从所选类型/参数中提取固定数量的气瓶、贮箱、管/孔板来构造专用 `TestModelClosure`,不消费用户的任意节点边拓扑;它复用 `integrate_ode`,并写 CSV、SVG 和报告产物(`app/main.py:1363-1447`、`app/simulation/examples/testmodel/run.py`)。 - -它是回归/演示算例,不是通用 ReactFlow 网络求解器。 - -### 11.2 Test MQL - -`POST /api/reactflow/simulate-test-mql` 当前忽略任意拓扑和主要积分配置;`TestMqlSystem.simulate()` 构造 132 状态的全零 `y`,只返回结构数量随时间的摘要(`app/main.py:1450-1481`、`app/simulation/examples/test_mql/system.py:6302-6318`)。 - -完整的 112 个气动状态与 20 个机械状态闭包存在于独立诊断/comparison 代码,但没有接入这个公开 API;仓库仍将其描述为校准阶段,不能宣称与 AMESim 全时域等价。 - -## 12. 当前明确的效率热点 - -| 热点 | 代码证据 | 影响范围 | 判断 | -| --- | --- | --- | --- | -| 每次闭合的压力流量重算 | `generic.py` 的预编译热流闭合计划 | 每个 RHS、初始化、每个结果采样点 | [已优化] 固定 1 次全网语义初解;后续只重算 stream-sensitive 方程关联块,不再把整个敏感物理岛重复求解 | -| stream 每轮复制/比较焓并刷新 | `stream.py` | 每个闭合,最多 100 轮 | [静态预编译已完成] 组件、端口和连接已预绑定;每轮必要的数值复制、比较和刷新仍保留 | -| 非线性回退使用有限差分 least-squares | `algebraic.py` | 快速路径失效时 | [首轮已优化] 可信声明图先做未闭合方程块 union sparse;失败恢复 `x0` 后做 global sparse→dense;接触/不可信结构保留 dense | -| 外层 ODE Jacobian 稀疏依赖图 | `generic.py`、`solver.py` | BDF/Radau 的每步/Newton | [已修复] 已传 `jac_sparsity`;补上外部 volume 的跨域双向依赖,用户 XML 为 1284 非零/31 色 | -| 热物性重复反算 | `mediums.py` 及各动态组件 refresh | 每个 RHS/闭合 pass | [已优化] 2026-08-16 已加入仿真隔离的四项氦气精确 LRU;代表算例 2,869/435 次命中/未命中,PR 三次根调用由 1,712 降至 689 | -| 每采样点完整后处理闭合 | `generic.py:397-474` | 输出点 × 全网 | [已实现] | -| dense output 插值对象 | `solver.py` | 流式逐步路径 | [已优化] 仅跨样本或需要状态事件时构造;含状态事件的用户 XML 每步仍需要,因此无本案收益 | -| 无界求解线程与任务结果驻留 | `main.py:491-520, 773-880` | 并发任务 | [已实现] 稳定性风险,不等于单算例变慢 | -| 完整结果单行 JSON 与前端多副本 | `main.py:825-840`、`App.tsx:8872-8958` | 大输出 | [已实现] 内存/网络热点 | - -下面是 2026-08-16 **物理岛版首轮实现的历史 `off` 基线**。这些数据仍可说明旧执行计划相对更早“每轮全网”的收益,但该实现已由方程关联块版取代,不能把表中的“块”解释为当前 `secondaryBlockCount`: - -| 算例 | 墙钟改善 | -| --- | ---: | -| `air_chain` | 7.9% | -| `air_branched` | 6.5% | -| `helium_step` | 0.6% | -| `mechanical_contact` | 21.4% | -| high-stiffness short | 14.6% | - -完整 high-stiffness 10 s 的历史基线约 28.126 s,本轮全部改动后的中位数为 -13.694 s;这个跨版本差额不能归到某一项优化。当前代码上单独强制恢复全网后续 -闭合的受控对照为 optimized 14.676 s、forced-global 16.929 s,完整 `series` -一致。`helium_step` 只有一个仍需重算的敏感岛,改善仅 0.6%;这说明收益取决于 -可跳过多少无关网络,不能宣称单一氦岛也有两位数提升。后处理的逐采样点完整闭合 -仍然保留。 - -当前方程关联块版另用用户大型分支 XML 做了受控短区间 A/B:输入 SHA-256 为 `2fb95e65f5de0c85a6a17802aef74ea004087323fd00fd8d01acf0184ff71d48`,含 98 个组件、472 个代数未知量和 74 个 ODE 状态。stream 后续求解的 9 个方程块合计 192 个未知量;`0~0.01 s` optimized 与 forced-global 分别为 16.200 s 和 18.584 s,物理解与 `series` 逐值一致。最终稳定代码完整 `0~0.81 s` 连续三次为 79.049 s、74.658 s 和 85.103 s,积分统计均为 `nfev/njev/nlu=3393/226/667`。短区间 A/B 只归因于后续 stream 闭合作用域,完整运行同时包含 Jacobian 修正和最终执行路径收口,二者不能混算成一个百分比。 - -## 13. 优化建议排序 - -以下按**预期综合收益**排序;同档位优先低风险、低难度项。排序同时参考代码结构和 2026-08-15 的阶段/物性实测,但尚未覆盖大规模拓扑与多任务吞吐。“单算例”指一个模型的墙钟时间,“吞吐”指多任务服务能力。 - -### 13.1 先看人话版 - -在改算法前,应先给各阶段计时和计数;这本身不直接加速,但能防止优化错地方。之后可按下面顺序理解主要方案: - -| 顺序 | 人话方案 | 为什么可能更快 | 主要风险 | -| ---: | --- | --- | --- | -| 1 | 少做重复“瞬时对账” | [方程块首轮已完成] 初解保持全网语义,后续只重算 stream-sensitive 的关联方程块 | 自定义/异常结构必须继续保守回退,不能漏掉真实耦合 | -| 2 | 先整理方程,再求解 | [稀疏首轮已完成] 可信声明图将未闭合块合并求解;大模型回退时减少数值 Jacobian 试算 | 接触活动集和不可信自定义声明必须走兼容回退 | -| 3 | 给不同状态使用合适的“尺子” | 质量、内能、位置、速度量级差异很大;合理缩放可减少无效内部步 | 容差改变会影响精度和事件时刻 | -| 4 | 相同输入不要重复查热物性 | 同一轮闭合中常以相同状态反算压力、温度等 | 缓存失效不严谨会产生错误结果 | -| 5 | 只计算、保存和传输需要的曲线 | 采样多、变量多时,可同时减少后处理、内存和网络开销 | 会改变默认结果合同,需要保留完整模式 | -| 6 | 给并发任务设固定“办理窗口” | 有界进程 worker 可防止无限建线程,并更好利用多核 | 对单个算例未必更快,跨进程取消和结果传递更复杂 | - -下面的完整表把这些方向进一步拆成 12 项,并明确收益、风险和实施难度。 - -| 排名 | 建议 | 主要收益对象 | 预期收益 | 风险 | 实施难度 | -| ---: | --- | --- | --- | --- | --- | -| 1 | [方程块首轮已完成] 将 `_close_current_state` 编译为按能力/依赖启用的执行计划:signal/stream/volume 预绑定;首次压力求解保留全网语义,仅在 stream 确实使构成关系变脏时重算相关方程关联块,并保留全网回退和耦合迭代上限。 | 单算例 | 历史物理岛版实测 0.6%~21.4%;大型 XML 方程块版短区间 16.200 s 对 18.584 s | 中:错误裁剪会破坏耦合一致性,需持续回归自定义模型 | 已完成首轮 | -| 2 | [稀疏首轮已完成] 可信方程声明图已用于 union block sparse 和 global sparse→dense 回退;后续继续评估 equality group 真正消元、解析 Jacobian,以及活动接触的安全分块。 | 单算例、大网络 | 扰动实验同预算残差回调 3796→164,约 11.6 倍;实际收益取决于是否触发非线性回退 | 高:接触活动集与错误声明会影响收敛 | 首轮已完成,继续深化 | -| 3 | [依赖图已修复] BDF/Radau 已使用状态 `jac_sparsity`,外部 volume 跨域漏边已补;后续再评估状态缩放和可配置分量级 `rtol/atol`,不要简单全局放宽容差。 | 单算例、刚性网络 | 大型 XML 已从 `0.69 s` 慢区定位并完整跑通;机械 `atol` A/B 虽约快 16%但改变精度合同,flow 固定点放宽则增加步数,均未采用 | 中高:会改变误差轨迹/事件时刻 | 稀疏图首轮完成,缩放待评估 | -| 4 | [已完成] 氦气高成本物性已按单次仿真做精确、有界缓存;dynamic components 及 signal/stream/volume 的静态组件、端口和连接也已预绑定。 | 单算例 | 已取得可见收益,且减少固定拓扑的重复查找 | 中:缓存失效错误会污染物理结果 | 已完成 | -| 5 | 改造结果选择和后处理:允许选择变量、采样/降采样;避免对不需要的变量和时间点执行完整闭合,必要时复用积分期间已接受的闭合快照。 | 单算例、内存 | 长仿真/多变量时高 | 中:结果合同与复用精度 | 中高 | -| 6 | 引入有界作业队列和固定大小的进程 worker;统一让同步端点也进入执行器,并设置最大并发、排队长度和结果尺寸。 | 吞吐、稳定性 | 高;单任务速度通常不变 | 中高:跨进程取消和序列化 | 高 | -| 7 | 进度与结果解耦:NDJSON 只发送进度和 `resultId`,结果按变量/时间块压缩下载或外部存储;前端改用 TypedArray/IndexedDB,图表先降采样。 | 内存、网络、UI | 大结果时高 | 中:需要版本化协议 | 中高 | -| 8 | 主动定时清理任务表,限制任务数/结果字节;成功交付后只保留摘要或引用。将无界进度队列改为“最新进度槽 + 不可丢终态槽”。 | 稳定性 | 中到高 | 低中 | 低中 | -| 9 | [首轮已完成] 只在当前步跨越下一采样点或需要状态事件检测时构造 dense output;后续记录并优化事件重启,大量周期 UD00 事件再评估惰性调度。 | 单算例、事件密集模型 | 无事件且采样稀疏时可减少插值对象;本次含状态事件 XML 无收益 | 低到中 | 首轮已完成 | -| 10 | CSV 在浏览器直接生成或按 `resultId` 服务端流式生成,避免全量 series 重新上传与 `StringIO` 全量复制。 | 内存、网络 | 中 | 低 | 低中 | -| 11 | 用共享 Schema/OpenAPI 生成前后端事件类型,修正 `complete/completed`;停滞依据服务端活动计数/已接受步时间戳并允许按模型调节。 | 可靠性、减少误杀重算 | 中 | 低 | 低中 | -| 12 | 长期评估支持稀疏残差/Jacobian 的 DAE 求解器,将外层 ODE 与内层代数 least-squares 统一成状态—代数系统。 | 复杂大模型 | 潜在很高 | 很高:架构与验证成本大 | 很高 | - -结果访问器和每轮剩余临时容器不属于第 4 项已经完成的 signal/stream/volume 静态预绑定;前者应结合第 5 项后处理改造单独基准,不把尚未实测的小项混入已完成收益。 - -### 13.2 推荐落地顺序 - -低侵入阶段/物性观测、积分计数和代表算例首轮基准已经落地,但不把“加指标”误列为直接加速。下一步建议: - -1. 继续汇总压力流量快路径命中、非线性 `nfev`、真实 `residualEvaluations` 和残差装配时间;单次诊断已能区分 block sparse、global sparse 和 dense 回退; -2. 用同一套基准把方程声明图 A/B 扩展到更多自定义组件、活动接触和保守回退路径;大型 98 组件 XML 与首轮短算例已经覆盖可信内置路径; -3. 补 1/8/32 单元规模曲线、1/2/4 并发吞吐和峰值 RSS; -4. 记录状态/结果数组字节、任务队列深度;结果 JSON 字节可继续由基准工具测量; -5. 再决定 equality group 真正消元/解析 Jacobian、结果按需计算和进程 worker 的实施深度。 - -每项算法改动都应继续验证质量/能量守恒、正反流、stream 混合、机械端挡、信号断点、取消部分结果和 AMESim/TestModel 基线。相关测试证据包括 `tests/test_generic_system_xml_simulation.py:244-533`、`tests/test_core_solver.py:19-508`、`tests/test_amesim_mechanical_public_components.py`。 - -## 14. 已实现、约定与推断的边界汇总 - -### 已实现 - -- System XML 校验、拓扑编译和通用半显式 ODE/代数求解主链。 -- 气动、机械和信号的专用闭合顺序。 -- 压力流量显式因果化快路径,以及可信声明图上的 union block sparse、global sparse→dense `least_squares` 兼容回退;失败候选不会污染原始 `x0`。 -- signal/stream/外部 volume 静态绑定,以及“全网语义初解 + stream-sensitive 方程块重算 + 保守全网回退”的闭合执行计划。 -- 明确区分物理岛、方程块和方程块未知量的诊断,并记录实际残差回调、Jacobian 模式、最后作用域和子块失败作用域。 -- BDF/Radau 状态 `jac_sparsity`,以及外部 volume 跨域双向依赖修正。 -- dense output 按采样跨越/状态事件惰性构造;状态事件模型仍保持每步插值能力。 -- 允许严格正亚帕试探压力的高刚度 RK45 路径;零压和负压仍不接受。 -- 自适应积分、输出采样、信号断点、机械端挡、协作取消和部分结果。 -- NDJSON 长响应、心跳、取消端点、异常恢复轮询和任务状态表。 -- 单 Uvicorn worker、每任务 daemon 线程、完整终态结果驻留与浏览器多副本行为。 - -### 约定 - -- 内核定位为半显式 ODE/代数 MVP,而非任意 DAE。 -- XML/组件运行参数使用 SI 基准值。 -- 采样上限、超时、心跳和任务名义保留时长。 -- 组件参数在单次运行中静态;时间变化通过信号源等模型表达。 - -### 已有初步实测、仍需扩大样本 - -- 压力流量闭合是当前代表气动短算例的首要热点;物性调用具有高精确重复率,仿真隔离的四项 PR 氦气缓存已取得可见端到端收益。 -- 长氦气代表算例的积分阶段占约 90.5%,后处理约 5%。 -- 物理岛版闭合执行计划的历史短算例配对收益为 0.6%~21.4%;完整 high-stiffness optimized/forced-global 历史对照为 14.676 s/16.929 s,数值序列一致。这些基线保留用于纵向比较,但已不是当前方程块实现。 -- 当前大型分支 XML 的 stream 后续闭合为 9 个方程块/192 个未知量;`0~0.01 s` optimized/forced-global 为 16.200 s/18.584 s 且物理解/`series` 逐值一致。最终稳定代码完整 `0~0.81 s` 连续三次为 79.049 s/74.658 s/85.103 s,积分统计一致。 -- 代数 sparse 扰动实验将真实残差回调由 3796 降至 164(约 11.6 倍耗时改善);它是回退微基准,不能外推为所有仿真的整体加速倍数。 -- 上述结论仍需在更大拓扑、更多真实工程和固定硬件环境复测。 - -### 推断及必须继续实测 - -- 单个任务实际占用几个核心、SciPy/BLAS 原生线程数和多任务扩展曲线。 -- 典型/最大工程的峰值 RSS、结果 JSON 大小、浏览器内存副本和 sessionStorage 成功率。 -- 各优化的实际收益;表中排序应在观测数据出现后更新。 - -## 15. 关键文件与符号索引 - -| 主题 | 文件与位置 | 关键符号 | -| --- | --- | --- | -| API 主入口与任务流 | `app/main.py` | `run_system_xml_simulation()`、`simulation_event_stream()` | -| JSON/XML 网络编译 | `app/main.py` | `compile_reactflow_network()`、`compile_system_xml_network()`、`_compile_solver_network()` | -| XML v3 校验/解析 | `app/system_xml.py`、`schemas/system-simulation-v3.xsd` | `SystemXmlDocument`、`validate_system_xml_document()` | -| 通用系统准备与仿真 | `app/simulation/systems/generic.py:93-474` | `GenericFluidSystem`、`_close_current_state()` | -| 压力流量代数闭合 | `app/simulation/solvers/algebraic.py` | `PressureFlowSolver.solve()`、方程关联图、sparse→dense 回退 | -| stream 方程块闭合 | `app/simulation/solvers/algebraic_blocks.py` | `StreamPressureBlockSolver` | -| stream 焓 | `app/simulation/solvers/stream.py:29-119` | `StreamResolver.solve()` | -| 标量信号 | `app/simulation/solvers/signal.py:40-109` | `SignalResolver`、`signal_event_times()` | -| 气动外部容积 | `app/simulation/solvers/pneumatic_volume.py:21-93` | `PneumaticVolumeResolver` | -| 机械因果化与事件 | `app/simulation/solvers/mechanical.py:225-627` | `MechanicalStateReducer` | -| ODE 推进 | `app/simulation/solvers/solver.py:37-1009` | `SolveIVPConfig`、`integrate_ode()` | -| 启动暖机 | `app/simulation/warmup.py` | BDF、稀疏分组、`least_squares` sparse LSMR 路径 | -| 可选性能埋点 | `app/simulation/performance.py` | `profile_run()`、`profile_phase()`、`profile_property()` | -| 可重复性能基准 | `app/simulation/benchmark_performance.py` | `python -m app.simulation.benchmark_performance` | -| 前端流式协议 | `frontend/src/App.tsx` | `streamSystemSimulation()`、取消/轮询 | -| 启动方式 | `bat/start-backend.bat`、`bat/start-backend.sh` | Uvicorn 单 worker 命令 | -| 主路径回归测试 | `tests/test_generic_system_xml_simulation.py`、`tests/test_core_solver.py` | 通用仿真、事件、取消 | diff --git a/docs/other/求解器性能优化任务清单.md b/docs/other/求解器性能优化任务清单.md deleted file mode 100644 index 716241f..0000000 --- a/docs/other/求解器性能优化任务清单.md +++ /dev/null @@ -1,863 +0,0 @@ -# 求解器性能与鲁棒性优化任务清单 - -> 用途:记录求解器优化的现状、证据、实施顺序和验收结果,供后续开发前后对比与持续更新。 -> 首次建立:2026-08-17 -> 基线代码:`6bb0591d320d0c448ee8d224dd44127bfe3ce00f`(本地 `model-development`) -> 基线模型:`tests/data/test_mql-full-branches-01-04.xml` -> 模型 SHA-256:`2fb95e65f5de0c85a6a17802aef74ea004087323fd00fd8d01acf0184ff71d48` -> 当前主固化目标:`tests/data/test-mql-8.xml` -> 主目标 XML SHA-256:`0a2d9331df9eb5974daec25a61c1238ba32b1742d933ffc8b16ce316c5627b0b` -> 配套项目 JSON:`tests/data/test-mql-8.json`,SHA-256 `b44bf540ccd1c293fe2af2b9b9052b540abf83961ad955a0f6a4ab40fbe0bb18` -> AMESim 权威物理基线归档:`AmesimModels/test_mql.ame`,SHA-256 `cbc3aadd4569a49b3a63e5d66d4143ec16126c0f950df73fb637e07673c20fbb` - -## 1. 使用规则 - -本文档不是一次性的建议列表,而是优化工作的验收账本。 - -- 状态统一使用:`未开始`、`进行中`、`部分实现`、`已完成`、`阻塞`、`不采用`。 -- 只有同时完成代码、自动测试、基准复测和本文档更新后,任务才可标记为“已完成”。 -- 每次性能对比必须记录代码提交、工作树状态、输入哈希、解释器与依赖版本、硬件和运行参数。 -- 正确性门槛先于速度收益。若结果越过误差契约,即使运行更快也不能合入默认路径。 -- AMESim 归档中的仿真结果是物理数值正确性的唯一基线;每次正式回归都必须按投影逐项计算并保存当前值、AMESim 基线值、绝对误差和相对误差。 -- Python exact/state golden 仅用于检测确定性、实现漂移和输出契约变化,不得单独或与本地 physical golden 一起批准物理正确性。 -- AMESim 基线为 0 时相对误差在数学上未定义,报告写为 `null` 并用绝对误差判定;AMESim 未保存的内部守恒量必须明确标记为不可外部比较,并继续执行独立绝对残差门。 -- 容差、模型方程或输出字段发生变化时,必须单独说明;不得将其伪装成纯性能优化。 -- 墙钟时间只在同一台机器、相同负载和相同环境下直接比较;跨环境以工作量计数和正确性指标为主。 -- 每项优化都应保留明确的关闭开关或旧路径,直到新路径经过复杂模型和通用回归验证。 - -## 2. 当前结论与基线 - -### 2.1 关于 2.05 s 卡死 - -当前随附 XML 的磁盘配置是 `tStop=0.81 s`,因此原文件本身不会运行到 2.05 s。将停止时间仅在内存中改为 `2.10 s` 后,当前代码已经完整越过 2.05 s 并正常结束: - -- `2.040432 s`:墙钟 `114.065 s` -- `2.046141 s`:墙钟 `120.746 s`,期间 CPU 时间持续增长 -- `2.051691 s`:墙钟 `121.548 s` -- `2.100000 s`:完成积分并进入后处理 -- 总运行完成,无重试、无非线性回退,也没有无进度死锁 - -因此,该历史输入的证据支持“此前的 2.05 s 卡死在当时版本中没有复现”;该区间仍存在数秒级慢推进。本节形成时尚未验证 -`10 s`,不能由该次结果外推保证。后续主目标曾以历史 `maxStep=0.02 s` 完成单次 `10 s`,但当前权威 -`maxStep=0.001 s` 的 `10 s` 基线仍未运行,两者不得混用。 - -### 2.2 环境说明 - -首次历史复测时仓库 `.venv` 尚不完整,因此当时使用现有 `/opt/srm-trial-review/.venv`: - -| 项目 | 本次值 | -| --- | --- | -| Python | 3.12.3 | -| NumPy | 2.4.6 | -| SciPy | 1.17.1 | -| 求解器 | BDF | -| 输出步长 | 0.01 s | -| 执行路径 | stream/cancel-check | - -该环境满足仓库依赖范围,但并非已经锁定的正式项目环境。当前物理解哈希与历史调研文档不同,所以逐位结果基线必须在正式锁定环境中再次确认。 - -### 2.3 当前实测基线 - -| 指标 | 原始 `0.81 s` | 仅内存延长至 `2.10 s` | -| --- | ---: | ---: | -| 状态 | 完成 | 完成,越过 2.05 s | -| 墙钟时间 | 63.779 s | 126.211 s | -| 积分时间 | 62.116 s | 122.180 s | -| 后处理时间 | 1.118 s | 2.703 s | -| 最大 RSS | 165,464 KiB | 198,348 KiB | -| 输出样本数 | 82 | 213 | -| 状态数 | 74 | 74 | -| `nfev / njev / nlu` | 3763 / 253 / 761 | 6734 / 487 / 1507 | -| 接受步 | 1076 | 1857 | -| 分段启动 | 3 | 5 | -| 状态切换 | 0 | 2 | -| 重试 | 0 | 0 | -| 有限差分附加 RHS 估计 | 7843 | 15097 | -| 压力闭合次数 | 30,502 | 57,601 | -| 非线性/块/稠密回退 | 0 / 0 / 0 | 0 / 0 / 0 | -| 最大热流体外迭代 | 3 | 3 | -| Jacobian 稀疏度 | 1284 nnz / 31 色 | 1284 nnz / 31 色 | - -补充观察: - -- 积分占总耗时约 97%,当前首要瓶颈不是后处理。 -- `2.10 s` 运行中,估计总 RHS 工作量约为 `6734 + 15097 = 21831`;有限差分扰动约占 69%。 -- 压力闭合约为每次估计 RHS 2.64 次,但全部走已播种的因果路径,没有触发 `least_squares`。 -- 全局因果执行已启用:快速执行 22,216 次,完整残差审计 351 次,审计失败 0 次,旧路径回退 0 次,审计间隔为 64。 -- 当前结果哈希仅作为本次环境的诊断记录:`0.81 s` 为 `c6354c97...`,`2.10 s` 为 `efef49f8...`;它们暂不作为跨环境验收标准。 - -### 2.4 当前模型结构基线 - -| 项目 | 数量 | -| --- | ---: | -| XML 组件 / 编译组件 | 99 / 98 | -| 连接 | 106 | -| 连续状态 | 74 | -| 代数未知量 / 方程 | 472 / 472 | -| 原始关联矩阵非零元 / 方程块 | 919 / 58 | -| effort 未知量 / flow 未知量 | 272 / 200 | -| effort 等价组 / 可消去重复 effort | 68 / 204 | -| 显式 flow/force 赋值 | 200 | -| stream 块 / stream 未知量 | 9 / 192 | -| 结果变量 | 1,021 | - -### 2.5 新主固化目标 `test-mql-8` - -自 2026-08-17 起,后续通用求解器优化以 `tests/data/test-mql-8.xml` 为主固化目标;配套 `test-mql-8.json` 用于校验项目结构,但 XML 是权威执行输入。原 `test_mql-full-branches-01-04.xml` 继续保留为历史慢区、2.05 s 与首批半解析 Jacobian 的回归样例。runner 只在内存中覆盖 `tStop/sampleStep/maxStep`,不得改写权威输入。 - -| 项目 | 主目标值 | -| --- | ---: | -| 运行组件 / 连接 | 156 / 178 | -| 动态组件 / 连续状态 | 58 / 132 | -| 代数未知量 / 方程 | 776 / 776 | -| ODE Jacobian 结构 | 3280 nnz / 52 色 | -| 因果 effort / flow 赋值 | 440 / 336 | -| secondary 代数块 / 未知量 | 12 / 368 | -| 结果变量 | 1,784 | -| 原始 `tStop / sampleStep / maxStep` | 10 / 0.01 / 0.001 s | -| 信号断点 | 0.04、0.8 s | - -本轮正式环境使用仓库 `.venv`:Python 3.12.3、NumPy 2.5.2、SciPy 1.18.0。`.python-version` 与 `constraints/python312-direct.txt` 固定跨平台开发参考;`constraints/python312-linux-x86_64.lock` 则固定 Linux x86_64 发布环境的 22 个直接/传递包、wheel SHA-256,并强制 binary-only 与 hash 校验。README、CI 与依赖契约测试使用同一安装口径。机器可读 manifest 与 runner 分别位于 `tests/baselines/simulation/test_mql_8/manifest.json` 和 `app/simulation/benchmark_regression.py`;默认顺序为 `0.01 smoke → 0.2 → 1 → 5 → 10 s`,每档均有合作取消、硬终止、资源记录与后续档延迟门,且 `sampleStep` 与 `maxStep` 可按 lane 独立覆盖。 - -## 3. 总体验收协议 - -每个优化 PR 至少执行以下分层验证;高风险改动不得只用单点输出或单个哈希判断正确性。 - -### 3.1 快速结构检查(CI) - -- [x] 模型输入 SHA-256 与固定 fixture 一致。 -- [x] 组件、连接、状态、代数方程和 stream 结构数量符合预期。 -- [x] Jacobian 结构至少覆盖已知跨域依赖,并通过稠密数值扰动抽查。 -- [x] 因果计划覆盖率、回退原因和审计失败数可观测。 - -### 3.2 数值检查点 - -至少覆盖以下区间和模式边界: - -- [ ] `0.68–0.71 s`:历史慢区。 -- [ ] `0.79–0.81 s`:原始模型终点及信号事件附近。 -- [ ] `2.00–2.10 s`:此前报告卡死区间和状态切换。 -- [x] `10 s / maxStep=0.02 s`:2026-08-17 最终通用接线版本完成一次历史长时间模式变化运行。 -- [ ] `10 s / maxStep=0.001 s`:当前权威配置尚未运行;连续 3 次、批准 golden 与完整步长矩阵仍属于 OPT-09。 - -每个检查点比较:连续状态、关键压力/流量/位移/速度、事件时刻与顺序、模式状态、有限性、最大缩放残差及守恒量。 - -### 3.3 性能记录 - -每次正式对比至少预热 1 次、测量 3 次并报告中位数,同时保存: - -- 总时间、积分时间、后处理时间、CPU 利用率、峰值 RSS。 -- `nfev`、`njev`、`nlu`、接受/拒绝步、分段和重试次数。 -- SciPy 模式的有限差分 RHS 估计;callable 模式的真实扰动、基准和 Jv 审计 RHS 计数;Jacobian 颜色数与构建时间。 -- 代数闭合次数、快速因果次数、完整审计次数和各类回退次数。 -- stream/热流体迭代次数、物性缓存命中率、事件候选与定位次数。 -- 输出标量数、编码字节数、传输字节数和后处理峰值内存。 - -### 3.4 P0 最大积分步长路径鲁棒性门 - -权威工程场景固定为 `test-mql-8.json` 经浏览器导出并由服务执行的同一系统语义,方法为 BDF。短基线使用 -`tStop=0.2 s`、`sampleStep=0.01 s`、`maxStep=0.001 s`;长基线只将 `tStop` 延长到 -`10 s`。`sampleStep` 是输出网格,`maxStep` 是积分步长上限,报告与诊断不得混用两者。 - -- **时域延长不变量**:固定模型、容差、方法和 `maxStep` 时,如果较短的 `tStop=T1` 能完成,则 - `T2>T1` 的运行不得因数值错误在 `T1` 之前提前结束。只比较严格位于 `T1` 之前的公共检查点; - 短任务终点的 accepted endpoint 与长任务内部插值单独标记,不作位级误判。 -- **步长细化可解性不变量**:在约定工程区间内,如果较大的 `maxStep` 能完成同一时域,则更小 - `maxStep` 不得反而出现不可恢复数值失败。更小上限可以更慢;若仅因工作量增加超过预算,必须归类为 - `budget_limited` 并证明仍持续推进,不能记为 solver failure 或借此选择一个“幸运步长”。 -- [x] 已对账 JSON、浏览器生成 System XML 的参数直传代码、服务请求和 worker 最终回显:`BDF / 0.2 s / 0.01 s / 0.001 s` 没有被前端或后端改写;JSON/XML 权威哈希及配对契约测试通过。 -- [x] 已复现并分类浏览器 `t≈0.0489 s` 计算超时:同参流式 API 能完整到达 `0.2 s`,但修复前浏览器曾把一次仍有 CPU 活动的约 70 s 慢步误判为 `SOLVER_STALLED` 并主动取消。该结果是“服务假超时 + 后端真实慢区”,不是该状态的不可恢复数值失败或网络断流。 -- [x] 已串行完成 `tStop={0.2,1} s × maxStep={0.001,0.002,0.005,0.01,0.02} s` 的 10 个短时单元:全部到达终点,`caseFailureCount=0`,无 NaN/Inf、不可恢复数值错误、热流体恢复或超时;同 `maxStep` 的 `0.2→1 s` 严格公共前缀五档均通过。 -- [x] 已将同一五档矩阵延长到 `2 s`:5 个单元全部到达终点、0 次恢复重试,`1→2 s` 的严格公共前缀五档逐位一致,机械事件顺序一致且时刻跨度不超过 `9.58 µs`。 -- [ ] 继续延长到 `5 s → 10 s`。2026-08-19 的 5 s 尝试在用户要求下中止且未生成聚合报告;当前代码的权威 `10 s / 0.001 s` 未运行。 -- [x] 已人工分层复核跨 `maxStep` 结果:`0.2/1 s` 的积分 `v/x` 无超差;`2 s` 的压力、守恒、离散模式和事件通过,差异集中于接触后的近零 `v/a` 及 `1.85–1.90 s` 流量换向附近。 -- [ ] 将上述分层判据自动接入矩阵 runner。当前报告顶层仍因旧的统一 state comparator 把派生 `a`(以及 2 s 接触后的近零 `v`)计为 comparison failure,不能写成“矩阵整体 passed”。 -- [ ] 每个 `0.01 s` 模拟区间记录墙钟、实际 `h_abs`/BDF 阶次、接受/拒绝步、重启、`nfev/njev/nlu`、Jacobian 构建、stream/热流体闭合与恢复轨迹;性能悬崖必须能定位到具体阶段和组件。 -- [x] 内部 RHS、solver step、stream/热流体闭合仍有活动时,服务持续发送活动心跳;真实浏览器已证明接受进度平台期不会再因 60 s 规则被误杀。 -- [ ] 真正无活动时仍须实现可硬终止的隔离 worker,并报告最后阶段、时刻、步长和计数;当前线程内合作取消不能杀死永不返回的本地调用。 -- [ ] 将终止结果明确分类为 `numerical_failure`、`service_timeout_worker_active`、`active_slow_trial`、`true_stall` 或 `budget_limited`;浏览器超时始终属于工程路径未通过,但在证据不足时不得冒充数值失败。 -- [ ] 任一会改变数值路径、事件语义、容差或默认求解策略的修复,必须先形成“复现证据 → 首个异常阶段 → 根因假设 → 最小方案 → A/B 判据 → 回退方式”,提交审阅后再实施;每轮只修改一个概念并先复跑原失败单元。 -- [x] 用户后续明确批准先定位并解决 70 s 慢区;已只接受保持数值语义的精确热路径优化,并拒绝改变接触轨迹或表现更差的容差/Jacobian 候选。 -- [ ] 恢复长时测试时,从完整的 5 s 报告继续,再取得当前优化版本的 `10 s / 0.001 s` 权威基线;历史 `0.02 s` 报告不得替代。 - -该门的目标不是寻找一个“碰巧能跑”的固定 `maxStep`,也不是要求所有步长得到位级相同轨迹,而是让合理工程区间内的 -`maxStep` 只影响可解释的误差与成本,不决定仿真能否完成。 - -#### 2026-08-18 / 浏览器 `0.0489 s` 超时的修复前定位(步骤 1–3) - -- 输入对账:权威 JSON/XML SHA-256 分别为 `b44bf540...` / `0a2d9331...`;诊断请求只把 XML 的 `tStop` 从 `10` 改为 `0.2`,请求载荷 SHA-256 为 `2e9d6577...`。前端 `resolveSimulationConfig` 与 `buildSystemXml` 对四个数值及方法直接序列化,后端最终回显 `tStop=0.2`、`sampleStep=0.01`、`maxStep=0.001`、`method=BDF`。 -- API 实测:真实 `/api/system-xml/simulate-stream` 于 `164.954 s` 完成,`status=completed`、`success=true`、`simulatedUntil=0.2`、21 个采样点、2054 个接受步、0 次可恢复失败;`nfev/njev/nlu=6190/267/982`,与同配置离线 worker 轨迹一致。 -- 决定性时间线:最后一次普通进度为 `t=0.048668428726 s`(15:29:03.676),下一次为 `t=0.049248338602 s`(15:30:14.045),间隔 `70.369 s`。期间后端每约 5 s 持续发送 heartbeat,求解线程采样约 `99%` 单核 CPU;第 60.416 s 的 heartbeat 到达时,前端按现有规则必然先抛出 `SOLVER_STALLED`。 -- 直接根因:前端只以“非 heartbeat 的累计 accepted progress”刷新 60 s 计时;Generic 又把普通进度节流为总时域的 `0.25%`(本例为 `0.0005 s`)。因此内部 RHS/Jacobian/闭合仍在运行、甚至接受微步时,也可能被错误取消。30 s 网络 idle 门没有触发,因为 heartbeat 始终存在。 -- 时域放大效应:该 `0.25%` 门槛随 `tStop` 变为 `0.2/1/2/5/10 s → 0.0005/0.0025/0.005/0.0125/0.025 s`。相同物理公共前缀在更长任务中会更少发送普通进度,更容易被 60 s 规则误杀;这会直接破坏时域延长不变量,不能通过单纯提高超时常数根治。 -- 当时尚未归因的性能问题:后端确有约 70 s 满核慢区;修复前协议没有 RHS 调用数、trial time、Jacobian/闭合阶段和任务级活动序列,不能直接断言具体数值根因。 -- 当时建议 A/B:将 accepted-time 平台期改为“活跃慢步”提示,并增加节流的任务级 activity telemetry;该方案已于后续实现并通过真实浏览器复验。 -- 当时建议 C:记录 step/RHS/Jacobian/闭合增量以定位慢区;当前已完成离线 step/RHS/闭合及组件归因,但仍缺 BDF order、全程 `h_abs` 和 SciPy 内部有限差分 Jacobian 的实时精确分类。 - -#### 2026-08-19 / 慢区归因、精确优化与浏览器复验 - -- 数值根因:原 `0.048668428726→0.049248338602 s` 区间包含约 1385 个接受步,其中 1223 步小于 `1e-8 s`、186 步小于 `1e-9 s`,步长中位数 `1.409e-9 s`、最小值 `6.125e-11 s`;8 个微步簇与 8 个高刚度 `LSTP00A` 接触依次激活一一对应。因此它是刚性接触层中的真实慢推进,不是单次调用死锁。 -- 工作量归因:该区间 `nfev/njev/nlu=3527/90/453`,约 4786 次额外 RHS 来自 SciPy 稀疏有限差分 Jacobian;RHS 墙钟绝大部分位于 `_close_current_state`。全部压力/流量代数解走已播种因果路径,无非线性、块或稠密回退。 -- 服务修复:后端增加 `activitySequence/activityKind/currentTrialTime/rhsCallCount/acceptedStepSequence/acceptedTime` 及 solver/Jacobian/闭合计数快照;5 s heartbeat 携带快照。前端仅在 `integrating` 阶段的 accepted 与 activity 同时连续 60 s 不变时判停,activity 继续推进时只提示慢步,缺少新字段时也不误杀;30 s 完全无字节的传输门保持不变。 -- 精确优化:因果 sum-to-zero 直接赋值、PNL0001 循环不变量/摩阻不变量与 equation-level 直接 reader 均保留完整残差审计、显式 capability 门和 opt-out;短 A/B 逐位一致。接触感知容差和半解析 Jacobian 候选因改变接触瞬态或收益不足未升为默认,正式配置继续使用 `legacy` 机械容差与 SciPy Jacobian。 -- worker/API 效果:当前 production worker `0.2 s / 0.001 s` 为 `147.634 s`,相对旧批准基线 `159.607 s` 缩短 `7.50%`;真实流式 API 于 `147.299 s` 完成。普通进度最长空窗由 `70.369 s` 降到 `57.185 s`,但空窗内 activity 持续推进。 -- 真实浏览器:隔离 Chromium→Vite→FastAPI 链路使用同一 JSON,`BDF / 0.2 / 0.01 / 0.001 s` 于 `156.136 s` 完成,输出 21 点;无取消请求、stream error 或 page error,activity sequence 从 `25114` 增至 `66670`,13 个 heartbeat 均携带活动证据。存在一条无关资源 404 控制台消息,不影响仿真验收。 -- 当前边界:线程内取消仍不能硬杀永不返回的 native 调用;尚无断流重连;实时 `jacobianEvaluationCount` 不能看穿 SciPy 内部有限差分构建。它们继续留在 OPT-08,不影响本次“活跃慢步不再被浏览器误杀”的结论。 -- 证据:`runs/2026-08-18-production-slow-region-exact-v1-0.2.json`(SHA-256 `34268e58...`)和 `runs/2026-08-18-production-browser-live-activity-v1-0.2.json`(SHA-256 `1f72746c...`)。 - -## 4. 优化任务总览 - -优先级定义:`P0` 为基线或正确性前置,`P1` 为主要性能收益,`P2` 为第二阶段,`P3` 为战略性或条件性工作。 - -| ID | 优先级 | 任务 | 当前状态 | 难度 | 预期价值 | 主要依赖 | -| --- | --- | --- | --- | --- | --- | --- | -| OPT-00 | P0 | 固化复现、环境和回归基线 | 已完成(本地 P0 基础闭环;远端 CI 运营证据待补) | 中 | 很高 | 无 | -| OPT-01 | P1 | 完成因果代数内核与坐标消元 | 基本完成(主要矛盾闭环) | 中高 | 中高 | OPT-00 | -| OPT-02 | P1 | 建立扁平数值 IR 和数组执行内核 | 部分实现(参考 IR) | 很高 | 很高 | OPT-01 | -| OPT-03 | P1 | 稀疏 Jacobian 数值层与解析/半解析演进 | 部分实现 | 很高 | 很高 | OPT-00;解析链可与 OPT-02 分阶段 | -| OPT-04 | P1 | stream 拓扑传播与物性成组复用 | 部分实现 | 中高 | 中高 | OPT-00 | -| OPT-05 | P0/P1 | 最大积分步长路径鲁棒性、状态缩放和步长策略 | 进行中(0.2/1/2 s 可解性主阻断解除;分层契约与 5/10 s 待续) | 中高 | 很高 | OPT-00 | -| OPT-06 | P2 | 事件检测与 dense output 按需化 | 部分实现 | 中 | 中 | OPT-00 | -| OPT-07 | P2 | 输出、后处理和传输内存优化 | 未开始 | 中 | 中高(长仿真) | OPT-00 | -| OPT-08 | P0/P2 | 进度、取消和服务并发鲁棒性 | 部分实现(`0.0489 s` 假超时闭环;真停滞、断连与并发仍待) | 中 | 很高 | OPT-00、OPT-05 P0 门 | -| OPT-09 | P0/P2 | 建立 10 s 长时验证与模式覆盖 | 进行中(0.2/1/2 s 已完成;5 s 中止无报告;权威 10 s 未运行) | 中高 | 很高 | OPT-00、OPT-05 P0 门、OPT-08 服务门 | -| OPT-10 | P3 | 明确高指数 DAE/强非光滑系统边界 | 未开始 | 很高 | 条件性 | OPT-09 | - -当前顺序:`OPT-00 本地基础闭环已收口 → OPT-05 将跨步长比较器改为分层契约 → OPT-08 补真停滞/断连/并发边界 → 按用户要求暂停 5/10 s → 恢复时先生成完整 5 s 报告,再运行 OPT-09 的 10 s / 0.001 s 权威基线`。OPT-01/02/03/04 的既有成果保留;任何后续收益都不得替代分类正确性与长时验收。 - -## 5. 详细任务 - -### OPT-00 固化复现、环境和回归基线 - -**目标**:先让“是否更快、是否仍正确、是否又卡住”可以稳定复现和自动判断。 - -**当前状态**:已完成本地 P0 基础闭环。2026-08-18 的 production runner、发布锁、golden、physical-state-v2.1、历史 `2.10 s` 三次复测和本地自动测试证据继续有效;2026-08-19 又用真实浏览器完成权威 JSON 的 `BDF / tStop=0.2 s / sampleStep=0.01 s / maxStep=0.001 s`,消除了 `t≈0.0489 s` 的假超时。`0.2/1 s × 五档 maxStep` 的 10 个单元全部完成,同 `maxStep` 严格公共前缀通过。跨步长分层契约、真停滞硬终止和 5/10 s 长时验证分别继续归 OPT-05、OPT-08、OPT-09,不再阻塞 OPT-00 的本地基础设施收口;远端 CI 首次运营证据仍待提交后补充。 - -**工作项**: - -- [x] 将新主目标 XML/JSON 放入固定 fixture 路径,并在 manifest/测试中校验双哈希、字节数和配对配置;提交本轮工作时必须一并纳入版本控制。 -- [x] 建立 Python 3.12.3 与六个直接依赖的跨平台参考约束,并建立 Linux x86_64 的 22 个直接/传递包、binary-only wheel SHA-256 发布锁;空 venv 离线安装、`pip check` 与依赖契约均通过。 -- [x] 将临时探针整理为仓库内可重复运行的 benchmark,不依赖 `/tmp` 文件。 -- [x] 添加 `0.81 s` 和仅改 `tStop=2.10 s` 的历史标准运行入口。 -- [x] 添加模型结构快照断言;结构有意变化时显式更新原因。 -- [x] 建立 `physical-state-v2` 的首批 state/checkpoint/event 投影;其 Python golden 现仅作为 production `0.2 s` 的确定性与实现回归诊断。 -- [x] 将关键压力、质量流量、三类质量守恒、总储气质量和离散模式加入 `physical-state-v2.1`,绑定 AMESim 单位/符号变换,并在每次运行时以 AMESim reference values 执行物理门。 -- [x] 将无数值的完整输出形状契约与物理状态 golden 分开;完整 API 序列化契约若需逐字段稳定性,后续另行定义。 -- [x] 建立短 CI、夜间 `0.81/2.10 s`、定期递进至 `10 s` 的三层 workflow;远端首次执行待提交后确认。 -- [x] 保存带环境、仓库、输入、运行统计和验收结果的机器可读 JSON 报告。 -- [x] 以权威 JSON 经浏览器生成 XML 并走流式 API 的真实路径完成 `0.2 s / 0.001 s`,与 production worker 对账输入、生效参数和结果;定位并消除前端对 `t≈0.0489 s` 活跃慢区的假超时。 -- [x] 完成第 3.4 节的 `0.2/1 s × 五档 maxStep` P0 单元可解性门及同 `maxStep` 时域延长不变量;跨 `maxStep` 的自动分层数值契约继续归 OPT-05。 - -**验收条件**: - -- [x] 干净环境可按发布锁一条安装命令复现:全新空 venv 使用 22 个锁定 wheel 与 SHA-256 完成安装,`pip check`、锁定环境契约和最终 quick workflow 同口径测试通过。 -- [x] 正式 production 环境严格串行 3 次完成 `0.81/2.10 s`;两档检查点、状态、事件、输出契约和除计时外的诊断逐值一致,无非有限值或非预期回退。 -- [x] 新主目标 `0.2 s` 性能报告完整记录环境、提交、工作树、输入哈希和统计口径。 -- [x] 浏览器、流式服务和 worker 三条路径对权威 `0.2 s / 0.001 s` 均能完成;内部活动持续时不再发生无证据的 60 s 假停滞。 - -范围边界:真正无活动或单次 native 调用永不返回时的硬终止属于 OPT-08 服务隔离验收,不再作为 OPT-00 基线基础设施的完成条件。 - -**前后对比**: - -| 指标 | 当前 | 完成后 | -| --- | --- | --- | -| 正式锁定环境 | Python 3.12.3 + 22 包 hash lock | 空 venv 安装、`pip check`、依赖契约通过 | -| 复杂模型自动回归 | 新主目标 0.01/0.2 分层门禁 | 1/5/10 递进入口 + 历史 0.81/2.10 production 入口 | -| 物理解哈希 | 142 个状态键 × 3 检查点 | Python state golden 作确定性诊断 + output shape + AMESim 当次相对误差物理门 | -| `2.10 s` 连续成功率 | 3/3 | worker 墙钟 113.497–115.868 s,逐值一致且 0 回退 | - -#### 2026-08-18 / AMESim 权威基线对齐与 OPT-00 收口 - -- 状态:部分实现 → 已完成(本地验收)。AME 归档固定 SHA-256 `cbc3aadd...`;XML/JSON 修正后的 SHA-256 分别为 `0a2d9331...`、`b44bf540...`,仿真配置统一为 `0→10 s / sampleStep=0.01 s / maxStep=0.001 s`。 -- 权威契约:直接解析 AME 的 117 个 COMP 与 40 个建模 LINE,推导出 157 个项目节点、178 条连接、20 类组件和 1092 个参数;单位转 SI、表压转绝压、公式等价、DIRECT/接触/线模型拓扑以及 XML↔JSON 逐 ID/端口均有自动测试。 -- 权威物理门:production `0.2 s` 每次都把当前 physical-state-v2.1 投影直接与 AMESim reference values 比较并保存相对误差;25 项参与判定,2 个 `t=0.04 s` 跳变流量保留误差但因左右极限语义不参与判定,另有 6 项无 AMESim 数据的内部守恒量单独执行绝对残差门。Python 142 个状态键 × 3 个检查点的 426 值 golden 仅作确定性诊断,不再批准物理正确性。 -- 历史最终报告:`runs/2026-08-18-production-opt00-approved-replay-0.2.json`,SHA-256 `2e27cd54...`;worker/orchestration 墙钟 `159.607/160.473 s`。其中 Python 值零重放误差是确定性证据;物理结论以该次结果对 AMESim 的最差相对误差 `0.1393485%` 为准,最大质量守恒残差为 `1.82146e-17 kg/s`。 -- 历史稳定性:production `0.81/2.10 s` 严格串行运行三次,所有 case 通过;`2.10 s` worker 墙钟分别为 `114.075/113.497/115.868 s`,机械事件时刻、检查点、1200 个状态值和除性能计时外的诊断逐值一致。 -- 环境与自动化:新增 22 包 Linux x86_64 hash lock,并在全新空 venv 完成离线安装和 `pip check`;最终 quick workflow 同口径为 179 项通过(2 项预期跳过),完整后端为 849 项通过(3 项预期跳过)。 -- 递进边界:由最终 `0.2 s` worker 时间按 `×5×1.5` 外推,`1 s` 为 `1197.053 s`,略低于 1200 s soft budget,因此记录为下一阶段 `eligible`;本次 OPT-00 不启动 1/5/10 s,后续长时递进仍归 OPT-09。 -- 远端说明:workflow 已使用相同 hash lock 与测试命令;本轮未获授权提交/推送,因此没有声称远端 CI 已运行,提交后的首次托管运行作为运营证据补充。 - -#### 2026-08-17 / `test-mql-8` 固化 runner v2 - -- 状态:进行中 → 部分实现(P0 基础闭环)。新权威 XML/JSON、双哈希、结构快照、参考环境约束、分层 manifest、机器可读报告、批准的 production `0.2 s` golden 和仓库内 runner 已建立;发布级依赖锁、关键代数量投影以及该里程碑时尚未运行的 1/5/10 s 结果仍待后续。 -- runner 行为:默认严格按 `0.01 smoke → 0.2 → 1 → 5 → 10 s` 递进;smoke 不参与耗时外推。soft deadline 先经 stdin 合作取消,hard deadline 再 terminate/kill;失败、超时、物理验收失败或下一档预测超过预算时,剩余档位统一标记 `deferred`。 -- 已执行正确性门:完成并到达终点、非空且全有限的输出序列、采样时间严格递增、检查点及状态值、最大缩放残差、预期信号事件及其实际积分分段、机械切换次数/时刻、golden 来源报告与布局哈希、逐状态容差比较和独立 output-shape contract。 -- 两条 lane:该 2026-08-17 里程碑的 manifest 中,`solver-only` 在内存把 `sampleStep` 改为 0.02 s,并把 `maxStep` 固定为 0.05 s,用于算法迭代;当时 SHA `170463d6...` 的 `production` 源值为 `sampleStep/maxStep=0.01/0.01 s`。当前 2026-08-18 权威 AME/XML/JSON 已统一为 `sampleStep/maxStep=0.01/0.001 s`;旧报告仅作历史证据。 -- 进程鲁棒性:软取消、硬终止、子进程提前关闭 stdin 的 BrokenPipe 和 stdout/stderr 资源清理均有自动测试。 -- 备份:`backup/general-solver-v1-before-20260817-16a7eb2` 精确指向进入本轮前的 `16a7eb2d6c2f01b23e3bdc7781a6cf6cc3fbe369`。 -- P0 证据:`tests/baselines/simulation/test_mql_8/runs/2026-08-17-production-v2-0.2.json`、`goldens/production-0.2s-v1.json` 与 `runs/2026-08-17-production-v2-extension-decision.json`。 -- 自动验证:CI 同口径快速基础套件共 149 项,OK(2 项长时测试按开关跳过);全量后端 discover 共 792 项,OK(3 项长时/可选测试跳过)。原有 5 个失败均确认是仓库整理后的旧文档/XML/CSV 路径,并已修正为现有 fixture 路径。 - -递进复测命令: - -```bash -PYTHONPATH=. .venv/bin/python -m app.simulation.benchmark_regression \ - --manifest tests/baselines/simulation/test_mql_8/manifest.json \ - --lane production \ - --output tests/baselines/simulation/test_mql_8/runs/latest-production.json -``` - -正式验收默认使用 `production`,从而对 `0.2 s` 当前结果执行 AMESim 权威物理基线比较;Python 数值 golden 同时输出确定性诊断但不作为物理通过依据。算法迭代若需降低输出成本,可显式改为 `--lane solver-only`。仅重跑首个正式基线档可加 `--case 0.2s`。 -命令退出码约定:`0` 表示所有选定档完成,`2` 表示依据预算安全暂缓后续档,`1` 表示运行失败或正确性验收失败。显式选择 `1s/5s/10s` 时,runner 仍会自动补齐并先执行所有较短前置档。 - -### OPT-01 完成因果代数内核与坐标消元 - -**目标**:在已存在的因果快速路径上,真正移除运行时冗余坐标和对象访问,而不是再次实现一套同类快速路径。 - -**当前状态**:新主目标的主要矛盾已经在执行层闭环。原有 `760` 个 PortState 兼容代数槽由 `432` 个 effort 槽和 `328` 个 flow/force 槽组成;当前内核将其编译为 `112` 个 effort 等价组和 `328` 条显式赋值,即 `440` 个逻辑坐标,在求解执行层消去 `320` 个 effort 别名。全局与 secondary stream 块均使用预分配 workspace、按 component 批量计算 anchor 并直接 scatter,完整残差仍在初始化、事件和每 64 次求解时审计。 - -这里的“消去”是逻辑求解坐标消元:stream、状态导数和结果提取仍直接读取 `760` 个 PortState 兼容镜像,因此对象槽尚未物理删除;这属于 OPT-02 后续。旧 `472/204/68/200` 是历史 `test_mql-full-branches-01-04.xml` 的规模,只保留为历史基线,不再描述当前主目标。 - -**工作项**: - -- [x] 将 112 个 effort 等价组压缩为独立逻辑坐标,在执行层消去 320 个重复 effort 别名。 -- [x] 将 328 条显式 flow/force 规则预编译为稳定阶段和槽绑定。 -- [x] 用预分配 workspace、批量 component anchor 和直接属性 scatter 减少热路径对象遍历、临时集合与重复缩放。 -- [ ] 仅清理会被当前计划写入的槽,避免每次全量清零和复制。 -- [x] 保留初始化、事件后、显式请求或固定间隔的完整残差审计。 -- [x] 自定义组件、声明缺失、审计失败、非有限外部 effort 或奇异结构自动回退旧求解器。 -- [x] 输出逻辑/兼容坐标数、消元数、显式规则覆盖率、审计率、失败原因和回退次数。 - -“仅清理当前计划写入槽”暂不勾选:当前 flow 目标仍先清零再赋值,以保持既有 `target = -residual(target=0)` 语义逐位一致;在 IR 能证明目标系数与历史无关前不移除这一步。 - -**验收条件**: - -- [x] 新主目标因果 flow/force 覆盖率为 `328/328`,0.01/0.2 s 中审计、运行时验证和旧路径回退均为 0。 -- [x] kernel on/off 的状态导数、760 个兼容代数槽、积分统计、物理解与输出契约一致。 -- [x] 自定义组件、接触模型、非因果结构和故障注入的回退测试通过。 -- [x] 在 0.01 s 与 production 0.2 s 证明端到端不退化并取得单次收益;严格性能签收仍需补 3 次中位数。 - -**风险与回滚**:别名写回、事件后模式改变和不完整依赖声明可能造成静默错误。新路径必须可通过配置关闭,并在审计失败时记录首个违规方程与变量。 - -| 指标 | 当前 | 完成后 | -| --- | ---: | ---: | -| 兼容代数槽 | 760 | 760(逻辑坐标 440) | -| 重复 effort 别名 | 320 | 逻辑消去 320;兼容镜像保留 | -| 已预热 Python 调用/单 RHS | 9,423 | 5,955(`-36.8%`) | -| 全局代数 solve 中位时间 | 0.708890 ms | 0.521711 ms(`-26.4%`) | -| 整体 RHS 中位时间 | 250.742 ms / 100 次 | 218.343 ms / 100 次(`-12.9%`) | -| 0.01 s worker 墙钟 | 13.5983 s | 12.5878 s(`-7.43%`) | -| production 0.2 s worker 墙钟 | 135.8240 s | 130.8233 s(单次 `-3.68%`) | -| 因果审计 / 运行时验证 / 旧路径回退失败 | 0 / 0 / 0 | 0 / 0 / 0 | - -#### 2026-08-17 / 通用因果执行器 v2 - -- 状态:该段记录低分配执行器 v2 的首版里程碑;后续因果坐标内核已将新主目标的 `760` 个兼容槽压缩为 `440` 个逻辑坐标,OPT-01 当前已达到“基本完成(主要矛盾闭环)”。`760` 个 PortState 兼容镜像的物理删除仍属于 OPT-02 后续。 -- 全局执行:直接执行预编译的 432 个 effort 写入与 328 个 flow/force 赋值,普通 fast solve 不再构造 seeded-id set、遍历 760 个未知量或重复构造 diagnostics。 -- secondary 执行:对 352 未知量的因果块仅保存和写入 176 个 selected flow 槽,普通 fast solve 跳过完整 mutation snapshot、seed set 和 scale/residual 构造。 -- 正确性边界:初始化、事件、显式请求及每 64 次求解仍执行完整残差审计;非有限 assignment、stage 异常或外部机械 x/v 非有限会熔断 v2,并在同次求解回到旧 seed/audit 路径。`SIMULATION_CAUSAL_EXECUTOR_V2=0` 保留一键回滚。 -- 默认决策:在目标 0.01 s 逐位 A/B、故障注入、聚焦测试与完整 0.2 s 验收后,v2 设为通用默认;只在原有 causal compile 证明通过时启用,不满足证明的模型继续走原路径。 -- 微基准:新目标 100 次同状态 RHS 中位时间由 0.305764 s 降至 0.247467 s(单次基准约 `-19.1%`),导数逐位相同;405 次 v2 fast、7 次完整审计,0 次验证失败。 -- 0.01 s 端到端:SciPy Jacobian 下总墙钟 15.160 → 13.172 s(`-13.1%`),积分 14.092 → 12.122 s(`-14.0%`);`nfev/njev/nlu=526/48/149`、物理解哈希 `0e64c6f...` 均相同。 -- 历史 0.2 s solver-only:旧空格路径、SHA `42e2d627...` 与 0.002 s 网格下曾以 132.305 s 完成;报告 `runs/2026-08-17-solver-only-v1.json` 和旧 `runs/2026-08-17-extension-decision.json` 已在 manifest 中标为 `historicalOnly`,不得作为新权威输入的 golden 或耗时预测来源。 -- 当前 production 0.2 s:新 SHA `170463d6...` 与 0.01 s 网格下 worker 墙钟 135.824 s、CPU 139.105 s、峰值 RSS 189,874,176 B;`nfev/njev/nlu=5755/307/1081`,接受步 1696,2 个信号分段,0 状态切换/重试。50,615 次闭合全部 seeded,主 v2 fast/audit 为 21,771/341,审计失败、运行时验证失败和旧路径回退均为 0,最大缩放残差 `1.0947e-16`。 -- 当前 P0 报告与 golden:`runs/2026-08-17-production-v2-0.2.json` 通过全部验收门;`goldens/production-0.2s-v1.json` 对 134 个投影结果键的 3 个检查点共比较 402 个值,并独立校验 output contract。本目标仍使用 SciPy Jacobian,不能把该成绩归因于半解析 Jacobian。 -- 当时的延期决策:`runs/2026-08-17-production-v2-extension-decision.json` 绑定新报告 SHA;`1 s` 的 1018.680 s 由 `135.8240278 × 5 × 1.5` 保守外推,超过 900 s soft budget,因此在该里程碑先未启动 1/5/10 s。后续实测结论统一记录在 OPT-09,不用该历史外推覆盖实测。 - -### OPT-02 建立扁平数值 IR 和数组执行内核 - -**目标**:把组件对象、字典查找和端口读写转换成稳定的数值执行计划,为 NumPy、Numba 或原生后端提供共同基础。 - -**当前状态**:已启动第一版独立、可执行的 schema v1 参考 IR,但尚未接管默认热路径。它把结构程序与运行绑定分离,包含 `440 canonical / 760 compatibility` 双层槽、稳定结构签名、NumPy workspace、按 component 批量 effort 计算、六阶段 flow 执行、逐阶段观察器和可选事务模式。权威目标可编译为 `112` 个 effort 坐标、`328` 个 flow 坐标和 `320` 个逻辑别名消元,flow stages 为 `[110, 130, 49, 33, 5, 1]`。 - -该原型目前只覆盖全局因果代数计划;secondary、stream、结果提取、模式重编译、自定义适配器和原生后端均未接入。PortState 仍是兼容镜像。事务模式目前只保证受控返回失败的回滚,writer/MemoryError/BaseException 语义尚未冻结;结构签名也未包含组件实现版本和后端,因此不能作为持久缓存键。 - -**工作项**: - -- [x] 定义首批最小代数 IR:canonical/compatibility 双层槽、稳定绑定、常量和分阶段操作码。 -- [ ] 将组件方程、因果规则、stream 传播和结果提取分成明确执行阶段。 -- [x] 实现可执行的纯 Python/NumPy 全局因果参考后端。 -- [x] 添加 IR 与当前对象执行器的结构签名、逐槽和逐阶段差分测试。 -- [ ] 评估 Numba 与 C/C++ 后端;在 IR 稳定前不绑定单一编译技术。 -- [ ] 对动态自定义组件保留对象适配层和明确的性能降级提示。 -- [ ] 缓存编译结果,并以模型结构、组件版本和数值后端作为缓存键。 - -**验收条件**: - -- [ ] 全部现有组件族通过新旧执行器差分测试。 -- [ ] 事件切换后能正确重编译或选择预编译模式计划。 -- [ ] 明显降低 Python 调用数、对象分配和 RHS 中位时间,并改善完整仿真墙钟。 -- [ ] 不以牺牲异常信息、取消检查或回退能力换取速度。 - -| 指标 | 当前 | 原型后 | 完成后 | -| --- | ---: | ---: | ---: | -| Python 调用/单 RHS | 9,423 | 5,955(OPT-01 默认内核;参考 IR 尚未接线) | 待填 | -| 临时分配字节/单 RHS | 待测 | 待填 | 待填 | -| RHS 中位时间 | 250.742 ms / 100 次 | 218.343 ms / 100 次(OPT-01) | 待填 | -| `2.10 s` 积分时间 | 122.180 s | 待填 | 待填 | - -#### 2026-08-17 / 因果数值 IR schema v1 - -- 新增独立参考实现 `app/simulation/solvers/causal_ir.py`,将结构程序与运行绑定分离,覆盖 `440 canonical / 760 compatibility` 双层槽、`112` 个 effort 坐标、`328` 个 flow 坐标、`320` 个逻辑别名及六阶段 flow 计划。 -- `tests/test_causal_numeric_ir.py` 已覆盖结构签名、逐槽、逐阶段、观察器和受控事务回滚差分。 -- 该 IR 尚未接管默认 RHS,当前不能把 OPT-01 的调用数或墙钟收益归因于 IR;secondary、stream、结果提取、事件后模式计划和原生后端仍待接入。 - -### OPT-03 稀疏 Jacobian 数值层与解析/半解析演进 - -**目标**:先建立可审计、可回滚的 callable sparse Jacobian 数值层,再逐步把组件、因果代数计划、stream 和物性的局部导数传播进来。完整稀疏有限差分、受审计 secant 和真正的解析/半解析 Jacobian 是三个不同阶段,必须分别记录和验收。 - -**当前状态**:数值层基础与实验候选已经实现;首批“证明门控”的三活塞 6 列半解析切片已经接入,但通用组件、stream SCC 和其余状态列仍未覆盖,因此 OPT-03 总体继续标记为“部分实现”。默认执行路径继续使用 SciPy `jac_sparsity`,半解析路径只允许通过 `SIMULATION_ODE_JACOBIAN_MODE=semi-analytic` 显式启用。 - -现有实现包括: - -- direct 和 stepwise BDF/Radau 均可接收 callable `jac`;信号断点、状态事件和可恢复重启会清空 Jacobian 数值状态并重新构建,显式积分器完全忽略该对象。 -- 新增独立的 sparse numerical Jacobian 内核,隔离并检查 SciPy 私有 `num_jac/group_columns` 接口。 -- 每个 solver segment 记录完整构建、有限差分扰动、基准 RHS、Jv 审计、secant 复用/失败和装配时间;SciPy 模式的估计值不再伪装成 callable 模式的真实计数。 -- 4 条无离散端挡模式歧义的机械运动学行直接装配为 `d(x')/d(v)=1`;带端挡的行继续数值差分。 -- callable 内核新增 `exact_columns=(indices, provider)`:已提供精确导数的列从分组有限差分中移除,其余列仍按原始保守结构做 subset FD;原始色数、剩余色数、单次真实 FD、精确列构建及回退次数/原因都进入分段诊断。 -- 精确列提供器用类型化 `ExactColumnsUnavailable` 表达当前点不可用;同一次构建会恢复原始 seed 0 的完整数值 Jacobian,避免把未知导数静默当成 0。模型编译证明失败、SciPy 私有接口不兼容或配置关闭时则直接保留原生 SciPy 路径。 -- 首批目标是三条同构活塞支路的 6 个机械状态列 `(20, 21, 38, 39, 54, 55)`。编译器只有在组件类型、连接拓扑、因果赋值计划、机械等价组和 stream 影响范围都满足证明条件时才启用;该 XML 中共覆盖 34 条 reachable assignments,FD 颜色由 31 降至 25,另由提供器装配 6 列。 -- 已增加 Ideal/PR 介质 `m/U/V` 物性线性化,以及 PNRP、PNCH012、PNL0001、LSTP、MECMAS 的局部切向原语;每个原语都返回 `valid/reason`,以便在非光滑接触、临界流动或不支持的模式上拒绝解析近似。 -- Jacobian 内部每次 RHS 都执行取消检查;不安全的共享模型基准缓存已经撤销。随后实现的一次性 generation/dirty token 安全版本在正式 `0.81 s` 中 `253` 次 Jacobian 请求命中 `0` 次:BDF 首次构建前会做初始步长试算,后续构建前也会留下 Newton 试探状态,模型并不位于请求的基准点。该版本没有节省 RHS,最终也已删除。 -- `SIMULATION_ODE_JACOBIAN_MODE=scipy` 是默认和回滚路径;小型全稠密结构或 SciPy 私有接口不兼容时也回到该路径。 - -显式 `SIMULATION_ODE_JACOBIAN_MODE=optimized` 仍构造完整稀疏有限差分 Jacobian,不使用 secant。最终实现严格固定 SciPy seed 0,并从原始 `1284 nnz` 保守结构生成 31 色扰动批次;移除精确行不会重新着色。曾试验的 seed 54 为 30 色,结构虽未删边,却改变了事件敏感模型的运行轨迹,因此多 seed 自动择优已经从代码中删除。 - -历史 30 色候选有性能收益,但没有通过事件/状态等价验收: - -- 最终安全版本的 `0.81 s` 单次相邻 A/B 中,optimized 积分 `55.034 s`、总墙钟 `56.957 s`,SciPy 基线积分 `59.924 s`、总墙钟 `61.953 s`,分别约改善 `8.2% / 8.1%`。 -- `0.81 s` 中 callable 实际 Jacobian RHS(扰动加基准)为 `7,103`,SciPy 估计为 `8,096`,约减少 `12.3%`;`nfev/njev/nlu` 为 `3467/228/670`,基线为 `3763/253/761`。 -- 两条 `0.81 s` 轨迹具有相同结果键、采样时刻、0 次状态切换和约 `1e-16` 的最大代数残差,但最终 74 维状态的最大差异为 `51.59 × (atol + rtol·|y|)`,最差状态相对差约 `5.2e-5`,超过当前拟定的严格等价门槛。 -- `2.10 s` optimized 仍成功越过 2.05 s,积分 `115.928 s`,而 SciPy 基线为 `122.180 s`;但 optimized 出现 `4` 次状态切换、`7` 次 solver 启动和 `215` 个样本,基线为 `2 / 5 / 213`。因此该候选的事件等价验收失败,不能设为默认。 -- 短变体隔离显示:seed 0 callable(有或没有 4 条精确行)在 `0.01 s` 的最终 74 维状态与 SciPy 逐项一致;轨迹分叉来自 30 色 seed 54,而不是精确运动学行。这提示事件敏感模型需要更多运行中 Jacobian 漏边/弱依赖审计,不能只依赖初始点结构测试。 - -最终 seed 0 安全候选的正式 `0.81 s` 探针与 SciPy 基线具有相同的物理解哈希 `c6354c97...`、`3763/253/761` 的 `nfev/njev/nlu`、`1076` 个接受步、3 次 solver 启动、0 次状态切换和 `30,502` 次压力闭合。实际 Jacobian 内部 RHS 为 `7,872 + 253 = 8,125`;安全 token 缓存命中为 0。积分时间 `60.972 s`、探针总墙钟 `62.945 s`,相邻 SciPy 基线为 `59.924/61.953 s`,没有净收益并略有退化。因此安全缓存已删除,seed 0 callable 只保留为后续解析行接入与诊断基础,不进入默认路径;无需为一个已经失败收益门槛的候选继续做 `2.10 s` 性能复测。 - -`SIMULATION_ODE_JACOBIAN_MODE=hybrid` 另提供实验性的数值 secant 原型:最多连续复用一次,复用前执行确定性方向 Jv 审计,失败或审计无信息量会在同一次调用中完整刷新。目标模型的早期探针中候选审计普遍失败;用 seed 0 的旧完整 Jacobian 做 `0.01 s` 探针时,39 次复用审计全部失败,额外产生 39 次 Jv RHS,实际复用仍为 0。因此它目前既不是解析 Jacobian,也没有可声明的端到端收益。 - -**已完成的数值层工作**: - -- [x] direct/stepwise BDF、Radau callable `jac` 接线;显式方法隔离。 -- [x] breakpoint、事件、可恢复重启后的强制重建与分段计数。 -- [x] 完整稀疏有限差分内核、严格 seed 0 着色、4 条安全精确行。 -- [x] exact-columns subset FD、类型化同次完整回退和原始/剩余色数及回退诊断。 -- [x] 真实 RHS/装配计数,以及 SciPy 估计口径分离。 -- [x] Jacobian 内部有界取消检查;撤销不安全缓存及命中为 0 的安全 token 缓存。 -- [x] 稠密结构、兼容问题和配置关闭时保留 SciPy 路径。 -- [x] 最多一次复用、Jv 审计、无信息审计拒绝和失败完整刷新测试。 -- [x] 复杂 XML `0.81/2.10 s` 单次性能与事件探针。 -- [x] 同一代码版本完成 3 组相邻 `0.81 s` A/B,报告中位数与范围。 -- [ ] 为事件敏感模型定义并通过状态、事件时刻/顺序和模式等价契约。 -- [ ] 在正式锁定环境完成独立预热后的 3 次 A/B,复核中位数与离散度。 - -**解析/半解析后续工作**: - -- [x] 为首批 Ideal/PR、PNRP、PNCH012、PNL0001、LSTP、MECMAS 路径定义带有效性诊断的局部切向契约。 -- [x] 对目标三活塞 6 列沿 34 条可证明因果赋值传播导数,并从 FD 分组中排除这些列。 -- [ ] 将局部导数/JVP 契约扩展到其余基础与自定义组件。 -- [ ] 将因果传播推广到目标切片以外的状态列和代数计划。 -- [ ] 对 stream SCC 推导显式或隐式小块导数。 -- [ ] 对物性函数提供解析导数、可靠自动微分或受控局部差分接口。 -- [ ] 在接触、饱和、开关和临界模式附近使用分段导数与局部回退。 -- [ ] 对自定义组件缺失的导数声明生成明确诊断,不得静默置零。 -- [ ] 在 `0.68–0.71`、`0.79–0.81`、事件两侧和 `2.00–2.10 s` 检查点执行稠密数值漏边审计与随机方向 JVP。 - -**验收条件**: - -- [x] 历史 external-volume 跨域结构护栏与初始点稠密数值漏边测试继续通过。 -- [x] callable 接线、分段重置、取消、显式方法隔离、secant 上限和审计失败回退有自动测试。 -- [ ] `0.81/2.10 s` 的连续状态、事件时刻/顺序、模式和残差满足统一契约;当前 30 色候选未通过。 -- [ ] 默认候选在锁定环境的 3 次中位墙钟有净收益,小模型无显著退化。 -- [x] 首批目标切向原语和 6 列通过逐列中心差分、模式分支与局部回退验证。 -- [ ] 通用组件级解析/半解析导数通过随机方向 JVP、逐列抽查和局部回退验证。 - -**风险与回滚**:历史 external-volume 漏边说明“颜色更少”本身不是正确性证据。不同合法颜色组合也可能暴露保守结构中未声明的弱依赖,并改变非光滑接触附近的事件序列。默认保持 `scipy`;`optimized/hybrid` 仅显式实验。非光滑点的解析或 secant 近似未必可靠,事件分段重置、审计和旧路径必须长期保留。 - -| 历史实验指标 | SciPy 基线 | 已撤销的 30 色候选 | 验收 | -| --- | ---: | ---: | --- | -| 保守结构 / 实际 FD 颜色 | 1284 nnz / 31 | 1284 nnz / 30(seed 54) | 结构不删边 | -| 精确装配行 | 0 | 4 条运动学行 | 短变体证明不改变轨迹 | -| `0.81 s` Jacobian RHS(含基准) | 估计 8,096 | 实际 7,103 | `-12.3%` | -| `0.81 s` `nfev/njev/nlu` | 3763 / 253 / 761 | 3467 / 228 / 670 | 工作量下降 | -| `0.81 s` 积分 / 总墙钟 | 59.924 / 61.953 s | 55.034 / 56.957 s | 单次约 `-8.2% / -8.1%` | -| `0.81 s` 最大最终状态误差尺度 | 参考 | 51.59 | 未通过 | -| `2.10 s` Jacobian RHS(含基准) | 估计 15,584 | 实际 14,556 | `-6.6%` | -| `2.10 s` `nfev/njev/nlu` | 6734 / 487 / 1507 | 6606 / 468 / 1469 | 工作量小幅下降 | -| `2.10 s` 积分时间 | 122.180 s | 115.928 s | 单次约 `-5.1%` | -| `2.10 s` 状态切换 / solver 启动 / 样本 | 2 / 5 / 213 | 4 / 7 / 215 | 未通过 | - -| 最终安全候选指标(`0.81 s`) | SciPy 基线 | seed 0 callable | 验收 | -| --- | ---: | ---: | --- | -| 保守结构 / FD 颜色 | 1284 nnz / 31 | 1284 nnz / 31(seed 0) | 相同扰动批次 | -| `nfev/njev/nlu` | 3763 / 253 / 761 | 3763 / 253 / 761 | 相同 | -| 接受步 / solver 启动 / 状态切换 | 1076 / 3 / 0 | 1076 / 3 / 0 | 相同 | -| 压力闭合 | 30,502 | 30,502 | 相同 | -| 物理解哈希 | `c6354c97...` | `c6354c97...` | 通过 | -| 安全基准 RHS 缓存命中 | 不适用 | 0 / 253 | 无收益,代码已删除 | -| 积分 / 探针总墙钟 | 59.924 / 61.953 s | 60.972 / 62.945 s | 略有退化,未通过收益门槛 | - -#### 2026-08-17 / 工作树基于 `6bb0591d` - -- 状态:未开始 → 部分实现(数值接入层完成;30 色候选未通过事件等价,seed 0 候选未通过收益门槛;解析/半解析传播未开始) -- 代码备份:`backup/jacobian-before-20260817-6bb0591`,精确指向 `6bb0591d320d0c448ee8d224dd44127bfe3ce00f`。该分支只备份 tracked 代码基线,不包含当时未跟踪的本文档。 -- 运行环境:Python 3.12.3、NumPy 2.4.6、SciPy 1.17.1;输入 SHA-256 `2fb95e65...`;`2.10 s` 仅内存覆盖停止时间,磁盘 XML 未修改。 -- 正确性结果:Jacobian 内核、core solver、Generic sparsity 和 Generic XML 共 57 项通过;压力因果、stream 块、机械接触、PNRP17、代数稀疏与方程块另 52 项通过,external-volume 漏边护栏继续通过。热流体闭合计划 13 项中 12 项通过,剩余 1 项因用户已将 fixture 移至 `tests/data/fixtures/`、旧测试仍读取 `tests/fixtures/` 而报既有 `FileNotFoundError`,与本次改动无关。30 色候选在 `2.10 s` 的事件数由 2 变为 4;最终 seed 0 候选在 `0.81 s` 恢复相同物理解哈希与求解统计。 -- 性能结果:见上表。数字均为同机相邻单次结果,不是 3 次中位数;最终 seed 0 候选没有减少求解工作并略慢。 -- 卡死结果:历史 30 色探针在 `2.040187 s @ 106.499 s`、`2.051323 s @ 113.996 s`、`2.065299 s @ 115.472 s` 持续推进并完成到 2.10 s;默认 SciPy 的正式探针同样越过 2.05 s 并完成,无重试、无死锁。 -- 安全收口:默认保持 SciPy;移除多 seed 自动择优、不安全共享缓存和零命中的安全 token 缓存;Jacobian 内部保留取消检查;无信息 Jv 审计强制刷新;显式 solver 不观察或重置 Jacobian。 -- 决策:保留严格 seed 0 的 callable/诊断/精确行基础和显式实验开关;30 色、基准缓存与 secant 均不进入默认路径。下一阶段优先建立多检查点弱依赖审计和组件级局部导数,不再以颜色数或数值缓存单独作为优化成功标准。 -- 证据文件:`app/simulation/solvers/jacobian.py`、`app/simulation/solvers/solver.py`、`app/simulation/systems/generic.py`、`tests/test_sparse_secant_jacobian.py`、`tests/test_core_solver.py`、`tests/test_generic_jacobian_sparsity.py`、`tests/test_generic_system_xml_simulation.py` - -#### 2026-08-17 / 首批三活塞半解析 6 列切片 - -- 状态:部分实现 → 部分实现(首批目标切片完成并通过局部导数验证;OPT-03 的通用解析/半解析覆盖尚未完成)。 -- 实现范围:新增 exact-columns subset FD 接口、类型化同次完整数值回退和分段诊断;为三条目标活塞支路编译状态列 `(20, 21, 38, 39, 54, 55)`,沿 34 条可达因果赋值传播切向量,使剩余 FD 颜色从 31 降到 25。 -- 局部导数:实现 Ideal/PR 介质 `m/U/V` 物性线性化,以及 PNRP、PNCH012、PNL0001、LSTP、MECMAS 的几何、压力、质量/能量、流量/力和接触模式切向原语;原语显式报告 `valid/reason`。 -- 证明与回退:组件类型、连接拓扑、因果计划、机械组和静态 stream 影响范围必须全部满足编译证明。causal/stream/custom/兼容性证明不成立时不安装 callable,继续使用原生 SciPy;运行点进入非光滑接触边界、临界流动、陈旧 primal 或其他不支持模式时抛出类型化 `ExactColumnsUnavailable`,同一次构建恢复原始 seed 0 完整数值 Jacobian。任何不可证明项都不会静默填 0。 -- 配置边界:默认仍为 `SIMULATION_ODE_JACOBIAN_MODE=scipy`;首批路径仅通过 `semi-analytic` 显式 opt-in,不替换生产默认值。 -- 自动测试:focused 套件 86 项、adjacent 套件 164 项,共 250 项通过。热流体 closure 计划另为 12/13 项通过;唯一失败仍是旧测试读取 `tests/fixtures/`、而 fixture 已被用户移至 `tests/data/fixtures/` 导致的既有 `FileNotFoundError`,与本轮 Jacobian 改动无关。 -- 局部正确性:在平滑检查点,SciPy 分组有限差分漏掉 `J[19,20] ≈ -3201.486`;半解析列相对独立中心差分的最大相对误差为 `1.897e-8`。inactive/active 接触分支、过期 primal、非因果计划和不支持拓扑均覆盖了成功或回退路径。 -- 轨迹正确性:默认容差下,两条 `0.81 s` 轨迹最差点为 `t=0.65 s` 的能量状态 `state[33]`,原始相对差 `8.24e-5`,缩放误差 `82.36`;事件数和顺序一致,但尚未满足拟定的严格逐点轨迹门槛。提高精度后互差收敛:`rtol=1e-7` 时最大绝对/相对差为 `0.081965 / 1.592e-6`,`rtol=1e-8` 时为 `0.0175357 / 3.09062e-7`,分别缩小约 `4.67× / 5.15×`,且两组事件均一致。这支持“求解路径差异随容差收敛”,但不足以把候选升为默认。 -- 性能口径:`0.81 s` 已在同机、同一工作树连续完成 3 组相邻 A/B;表中时间为中位数,括号给出 3 次范围。测试使用现有 `/opt/srm-trial-review/.venv`,没有独立预热且依赖版本未由项目锁文件固定,因此仍需在正式锁定环境复核,不能单独作为切换默认值的依据。`2.10 s` 为最终 one-shot primal 捕获版本的单次复跑;此前数学路径相同的预备运行墙钟为 `111.068 s`,本次为 `116.512 s`,长程时间仍需重复测量。 - -| 最终 `0.81 s` 三次指标 | SciPy 基线 | `semi-analytic` 候选 | 变化/说明 | -| --- | ---: | ---: | --- | -| FD 颜色 / 精确状态列 | 31 / 0 | 25 / 6 | 目标列为 20、21、38、39、54、55 | -| `nfev/njev/nlu` | 3763 / 253 / 761 | 3650 / 228 / 711 | 求解工作下降 | -| 接受步 / solver 启动 / 状态事件 / 样本 | 1076 / 3 / 0 / 82 | 1056 / 3 / 0 / 82 | 事件和输出网格一致 | -| Jacobian RHS | 8,096(估计) | 5,985(实计) | `-26.1%` | -| 精确列构建 / 类型化回退 | 不适用 | 224 / 4 | 4 次恢复完整数值构建 | -| 压力闭合 | 30,502 | 25,672 | `-15.8%` | -| 积分时间中位数(范围) | 59.725 s(59.568–60.188) | 55.631 s(55.432–56.307) | 中位数 `-6.85%` | -| 总墙钟中位数(范围) | 61.203 s(61.070–61.704) | 56.708 s(56.508–57.410) | 中位数 `-7.34%`;逐组改善 6.96%–7.47% | - -| 延长至 `2.10 s` 单次指标 | SciPy 基线 | `semi-analytic` 候选 | 变化/说明 | -| --- | ---: | ---: | --- | -| 状态 | 完成,越过 2.05 s | 完成,越过 2.05 s | 最终版本越过 2.05 s 的墙钟为 111.660 s | -| `nfev/njev/nlu` | 6734 / 487 / 1507 | 6246 / 445 / 1328 | 求解工作下降 | -| 接受步 | 1857 | 1753 | `-104` | -| solver 启动 / 状态切换 / 样本 | 5 / 2 / 213 | 5 / 2 / 213 | 事件计数和输出网格一致 | -| Jacobian RHS | 15,584(估计) | 11,771(实计) | `-24.5%` | -| 类型化回退 | 不适用 | 26 | 非平滑/不支持点恢复完整数值构建 | -| 压力闭合 | 57,601 | 48,248 | `-16.2%` | -| 积分时间 | 122.180 s | 112.825 s | 单次 `-7.7%` | -| 总墙钟 | 126.211 s | 116.512 s | 单次 `-7.7%` | - -- 卡死复核:最终 `semi-analytic` 候选在墙钟 `111.660 s` 越过模拟时刻 `2.05 s`,随后于 `116.512 s` 完成到 `2.10 s`;与 SciPy 基线一样未出现无进度死锁。 -- 未覆盖范围:通用 stream SCC 导数、目标三支路以外的组件/状态列、自定义组件导数契约、正式锁定环境的独立预热复测,以及 `10 s` 长时模式覆盖。 -- 决策:保留首批半解析切片和自动回退作为显式实验路径;OPT-03 继续为“部分实现”,默认继续使用 SciPy。完成上述通用覆盖、严格轨迹契约和重复基准前,不切换默认值。 -- 代码备份:仍使用进入 Jacobian 优化前建立的 `backup/jacobian-before-20260817-6bb0591`,精确指向 `6bb0591d320d0c448ee8d224dd44127bfe3ce00f`。 -- 证据文件:`app/simulation/solvers/jacobian.py`、`app/simulation/solvers/tangent.py`、`app/simulation/solvers/solver.py`、`app/simulation/systems/generic.py`、`app/simulation/core/medium.py`、`app/simulation/components/amesim/media/mediums.py`、`app/simulation/components/amesim/mechanical/pistons.py`、`app/simulation/components/amesim/storage/chambers.py`、`app/simulation/components/amesim/flow/pipes.py`、`app/simulation/components/amesim/mechanical/translational.py`、`tests/test_sparse_secant_jacobian.py`、`tests/test_analytic_tangent_primitives.py`、`tests/test_three_piston_tangent.py` - -#### 2026-08-17 / 名字无关的受支持活塞支路编译器 - -- 将原三条固定实例扩展为按组件类型、端口域、连接、机械状态 owner/slot、因果 reach 与 stream 影响证明自动发现任意数量的受支持支路;通用路径不固定组件实例名、支路数或状态 offset,旧三活塞入口仅作为兼容 wrapper。 -- 新主目标自动发现 8 条 MECMAS21→PNRP17→PNCH012→PNL0001/LSTP 支路,覆盖 16 个机械状态列 `104..119` 与 84 条可达赋值;理论剩余 FD 颜色由 52 降至 36。 -- 平滑工作点 16 列对完整 RHS 中心差分通过;初始接触边界会类型化回退完整 52 色数值 Jacobian,不会静默使用错误列。 -- 0.01 s A/B 显示该目标早期 56 次 Jacobian 中只有 16 次使用精确列、40 次因流量局部斜率/接触边界安全回退;单独半解析总墙钟为 17.522 s,慢于 SciPy 的 15.160 s。当前目标因此继续使用默认 SciPy Jacobian,半解析保持显式 opt-in,下一步应做支路分区回退或扩大光滑模式覆盖,而不是放宽守卫。 - -### OPT-04 stream 拓扑传播与物性成组复用 - -**目标**:让无环 stream 网络一次传播,只对真正的强连通块迭代;同一状态反算的物性量成组计算和复用。 - -**当前状态**:stream 求解器已预绑定组件、端口和连接,物性层也有单次运行精确缓存;但每次求解仍构造临时字典/列表、重复调用连接焓计算,尚未编译 SCC/DAG。热流体外层固定点上限仍为 25 次:production `0.2 s` 实测最多 3 次;2026-08-17 较大 `maxStep` 的历史延长运行在 `2/5/10 s` 实测最多 18–23 次,修复前在 `t≈1.8595–1.8603 s` 会耗尽 25 次。当前已补充试探点事务回滚和类型化可恢复失败,并由 StreamResolver 为所有覆盖温度参考更新钩子的组件统一刷新连接参考;SCC/DAG 传播与物性成组复用尚未实现。 - -**工作项**: - -- [ ] 构建 stream 图的 SCC,并将缩点图编译为拓扑顺序。 -- [ ] 对单节点和无环段使用一次传播,仅在循环 SCC 内迭代。 -- [ ] 使用预分配数组和原地误差统计,避免每轮临时字典/列表。 -- [ ] 缓存同一求解阶段的连接焓结果,避免返回前重复计算。 -- [ ] 将 `p/T/rho/h/s` 等同源物性组织为状态包,按精确输入键成组复用。 -- [ ] 增加缓存命中、SCC 迭代、失效原因和物性调用次数指标。 -- [ ] 评估脏标记传播,但必须证明事件和反向流切换时不会复用陈旧值。 -- [x] 为热流体外层 25 次耗尽提供类型化可恢复失败和单次 RHS 事务回滚,避免失败试探点污染下一次尝试;这是鲁棒性前置,不代表 SCC/DAG 优化已经完成。 -- [x] StreamResolver 按组件行为预编译所有覆盖 `update_flow_temperature_references` 的组件,并在每轮 stream 更新后统一刷新温度参考;物理岛边界同时识别 stream outflow 与温度参考钩子覆盖。 - -**验收条件**: - -- [ ] 无环、单环、多环、反向流和事件后拓扑测试全部通过。 -- [ ] 复杂模型的最大 stream/热流体迭代不增加,残差不恶化。 -- [ ] 量化减少物性调用、临时分配、压力闭合或 RHS 时间。 - -| 指标 | 当前 | 完成后 | -| --- | ---: | ---: | -| stream 块 / 未知量 | 9 / 192 | 待填 | -| 最大热流体迭代 | production 0.2 s:3;2026-08-17 较大 maxStep 历史 2/5/10 s:18–23;恢复阈值:25 | 待填 | -| `2.10 s` 压力闭合 | 57,601 | 待填 | -| 物性调用 / 缓存命中率 | 待测 | 待填 | - -### OPT-05 最大积分步长路径鲁棒性、状态缩放和步长策略 - -**目标**:首先保证在合理 `maxStep` 工程区间内,步长上限只影响可解释的误差和成本,而不决定仿真能否完成;随后再减少量纲差异造成的不必要小步和 Jacobian 重建,同时维持事件与守恒精度。 - -**当前状态**:进行中,但 0.2/1/2 s 的可解性主阻断已经解除。原浏览器 `t≈0.0489 s` 问题已分解为“前端 accepted-progress 误杀”和“8 个高刚度接触引发的大量纳秒级 BDF 微步”;activity-aware watchdog 已修复前者,保持数值语义的因果/PNL 热路径优化降低了后者的工作量。`0.2/1/2 s × 五档 maxStep` 共 15 个单元全部完成,0 次恢复重试,且 `0.2→1→2 s` 同 `maxStep` 的严格公共前缀逐位一致;没有出现更小 `maxStep` 独有的不可恢复失败。任务仍未完成,因为 runner 尚未自动分层积分状态、派生量和物理投影,2 s 接触后的近零 `v/a` 与 `1.85–1.90 s` 流量换向仍有跨步长敏感性,5/10 s 也尚未完成。 - -**工作项**: - -- [ ] 按状态物理量、标称值和工程容差建立分量 `atol`/缩放规则。 -- [ ] 为未提供标称值的组件定义安全默认值并输出诊断。 -- [ ] 分开积分误差、代数残差、stream 固定点和事件定位容差。 -- [ ] 统计限制步长的状态分量、误差拒步和 Jacobian 重建原因。 -- [ ] 对事件前后、接触临界区和稳态区分别评估步长上限策略。 -- [ ] 建立严格/标准/快速配置,但默认配置必须有明确精度契约。 -- [x] 用完全相同的 JSON 生成请求,对浏览器流式路径、真实 API 与 production worker 做同参 A/B,逐层核对 requested/effective `tStop/sampleStep/maxStep/method`。 -- [x] 围绕 `t=0.04–0.05 s` 记录接受步、Jacobian/RHS/闭合工作量并定位首次性能悬崖到 8 个 `LSTP00A` 高刚度接触的微步簇。 -- [ ] 补齐逐 `0.01 s` 的 BDF order、全程 `h_abs`、拒步与 SciPy 内部有限差分 Jacobian 阶段时间线;现有实时 `jacobianEvaluationCount` 只覆盖显式 callable Jacobian。 -- [x] 在改变算法前提交定位报告与候选方案供审阅,明确证据、影响面、正确性风险、预期计数变化和回退方式;获准后逐概念 A/B,拒绝了改变轨迹或收益门未过的候选。 -- [x] 审计失败试探的事务恢复、缓存、端口和离散模式;故障注入证明失败后可重放,失败诊断保留且不会污染下一条缩步路径。 -- [ ] 只在证据支持时对事件、接触、流向或闭合边界使用局部步长上限、有界缩步或模式感知策略;禁止靠全程硬编码某个“幸运” `maxStep` 收口。 -- [x] 完成第 3.4 节的 `0.2/1 s × 五档` 短时矩阵和 `2 s × 五档` 延长矩阵;15 个单元全部完成,严格公共前缀验证到 2 s。 -- [ ] 恢复时继续生成可签收的 5 s 报告和权威 10 s 报告,并将分层矩阵设为每次步长/缩放改动的 P0 回归。 -- [x] 对可恢复的热流体闭合失败使用积分器实际试探步 `h_abs` 对半回退;最多 16 次且不低于 64 ULP,恢复步仅设置 `first_step`,首次接受后恢复分段 `maxStep` 上限并记录 attempted/next step。 -- [x] 为 eventless Generic 显式启用 `recoverable_trial_retries`,使没有状态事件、断点或取消回调的通用模型也能选择 stepwise 恢复;该参数默认关闭,避免改变其他调用者的直接 `solve_ivp` 语义。 - -**验收条件**: - -- [ ] 每个配置都有状态、事件、残差和守恒误差界限。 -- [ ] 标准配置在复杂模型上减少拒步或分解工作,不引入模式遗漏。 -- [ ] 所有收益报告同时给出误差变化,禁止只报告墙钟。 -- [ ] 满足第 3.4 节 P0 门:短时二维矩阵满足时域延长与步长细化不变量,事件/模式/守恒满足分类契约,任何性能悬崖都有可复现的阶段与组件归因。 - -#### 2026-08-19 / 权威五档短时与 2 s 矩阵 - -所有单元均为 production lane、BDF、`sampleStep=0.01 s`,并只在内存覆盖 `tStop/maxStep`: - -| `tStop` | `maxStep=.001` | `.002` | `.005` | `.01` | `.02` | -| ---: | ---: | ---: | ---: | ---: | ---: | -| 0.2 s | 145.942 s | 166.070 s | 156.667 s | 156.431 s | 157.374 s | -| 1 s | 198.351 s | 214.308 s | 210.164 s | 214.616 s | 209.664 s | -| 2 s | 348.040 s | 306.140 s | 333.424 s | 341.867 s | 335.172 s | - -- 完成性:15/15 单元到达终点,单元 `matrixAcceptance.passed=true`;无 soft/hard timeout、NaN/Inf、热流体失败或恢复重试。最大缩放残差为 `9.56e-17–1.09e-16`。 -- 时域不变量:五个 `maxStep` 的 `0.2→1 s` 与 `1→2 s` 严格公共前缀逐位一致;短任务终点不参与严格前缀比较。2 s 的两次机械事件顺序一致,时刻最大跨度 `9.57425e-6 s`,小于 `2e-5 s` 门限。 -- 分层结果:0.2 s 的 9/10、1 s 的 10/10、2 s 的 10/10 跨步长 pair 在旧顶层比较器中为红,但没有单元失败。0.2/1 s 红项全部是派生 `a`;2 s 为 1111 个 `a` 与 720 个事件后近零 `v`,`x` 及其余状态无超差。physical-state-v2.1 的压力、守恒和离散模式通过;流量差异集中在 `t=0.04 s` 左右极限和 2 s 的 `1.85–1.90 s` 换向区。 -- 性能结论:耗时随 `maxStep` 非单调,0.2/1 s 单次最快为 `.001`,2 s 单次最快为 `.002`;不能据单次结果选择“幸运步长”或修改正式默认值。 -- 检查点边界:早期 0.2 s 报告中的请求 `.048/.0489 s` 实际映射到输出网格 `.05 s`,不得作为精确慢区检查点;runner 现已拒绝 off-grid 检查点,慢区使用 activity/step trace 取证。 -- 证据:`runs/2026-08-18-production-0.2s-max-step-robust-v1.json`(SHA-256 `ec5480af...`)、`runs/2026-08-18-production-1s-max-step-robust-v1.json`(`1f1c639b...`)和 `runs/2026-08-18-production-2s-max-step-robust-v1.json`(`93367d0f...`)。 - -### OPT-06 事件检测与 dense output 按需化 - -**目标**:避免在绝大多数没有事件候选、也不跨输出采样点的接受步上创建 dense output。 - -**当前状态**:已有事件候选筛选和部分非事件优化,但只要存在状态转换处理器,接受步仍可能构造 dense output。`2.10 s` 有 1857 个接受步而只有 2 次状态切换,存在减少插值构造的空间。 - -**工作项**: - -- [ ] 在构造 dense output 前执行低成本端点符号/模式候选检查。 -- [ ] 仅在跨输出采样点或存在事件候选时创建插值器。 -- [ ] 将输出插值与事件定位的生命周期和精度需求分离。 -- [ ] 统计候选数、误报数、定位次数、dense output 构造数和耗时。 - -**验收条件**: - -- [ ] 同时事件、擦边事件、抖动防护和多模式顺序测试通过。 -- [ ] 事件时刻误差不超契约,事件顺序和最终模式不变。 -- [ ] 完整模型 dense output 构造数与耗时明显下降。 - -### OPT-07 输出、后处理和传输内存优化 - -**目标**:在长仿真中控制结果生成、JSON 编码、前端复制和峰值内存。 - -**当前状态**:历史复杂 XML 有 1,021 个结果变量;当前主目标有 1,784 个结果变量,加时间轴共 1,785 条序列。`10 s / 0.01 s` 的 1001 个采样点预计产生 1,786,785 个标量。现路径会对每个样本重新闭合、追加全部结果,并把完整结果作为一个 NDJSON 消息发送。它不是本次接触慢区的主因,但会成为长时间运行的显著成本。 - -**工作项**: - -- [ ] 支持结果变量白名单、分组和按需派生量。 -- [ ] 将积分内部采样、结果存储采样和显示采样分离。 -- [ ] 对显示路径提供服务端降采样,同时保留可选完整数据模式。 -- [ ] 分块编码和传输结果,或返回 `resultId` 后分页/流式获取。 -- [ ] 评估前端 TypedArray/列式数据,减少嵌套对象和重复复制。 -- [ ] 避免后处理中对每个样本重复执行不必要的完整闭合。 -- [ ] 记录原始标量数、编码/传输字节数、后处理时间和峰值 RSS。 - -**验收条件**: - -- [ ] 完整输出模式保持现有 API 契约,或通过显式版本升级迁移。 -- [ ] 精简模式的变量选择和降采样行为可预测、可测试。 -- [ ] `10 s` 基准中后处理时间、传输字节和峰值 RSS 有量化改善。 - -### OPT-08 进度、取消和服务并发鲁棒性 - -**目标**:区分“内部慢步”和“真正无进度”,并让长任务可取消、可限流、不会拖垮服务进程。 - -**当前状态**:部分实现,浏览器 P0 假超时已闭环。后端现在分别上报 accepted progress 与 RHS/solver step/热流体闭合等内部活动,5 s heartbeat 携带 activity 快照;前端在 `integrating` 阶段有活动遥测时,仅在 accepted 和 activity 同时连续 60 s 不变后请求停止,活动继续增长时保持运行;缺少活动遥测的旧后端使用 15 分钟保守兜底,30 s 完全无字节的断流门不变。真实浏览器 `0.2 s / 0.001 s` 已完整到达终点,原 `0.0489 s` 慢区内 activity 持续增长且未触发取消。剩余边界是:线程内 cooperative cancel 不能硬杀永不返回的 native/Python 调用,客户端断流不能重连到原任务,尚无并发 worker/队列/资源租约的完整门控,SciPy 内部有限差分 Jacobian 也不能由当前实时字段精确分类。 - -**工作项**: - -- [x] 分别上报模拟时间、接受步、内部 RHS/solver step/闭合活动和墙钟心跳。 -- [x] 将“运行中但步很慢”与“求解器无活动”使用不同状态和超时策略;缺少新 telemetry 的旧后端也不会被前端自动误杀。 -- [ ] 在代数闭合、stream 迭代、Jacobian 构建和后处理内加入有界取消检查。 -- [ ] 限制并发仿真 worker、队列长度和单任务 CPU/内存预算。 -- [ ] 超时报告最后活动阶段、模拟时刻、步长和关键计数,而非只返回通用错误。 -- [ ] 添加故意慢 RHS、死循环防护、客户端断连和多任务竞争测试。 -- [x] 将活动心跳扩展到 RHS、solver step、显式 Jacobian、stream/热流体闭合和恢复循环,并携带 activity sequence/kind、current trial time、RHS/accepted/solver/Jacobian/closure 计数。 -- [ ] 补充实时 `h_abs`、BDF order、SciPy 内部有限差分 Jacobian 阶段和任务级 CPU delta;当前 `jacobianEvaluationCount` 不能代表 SciPy 内部 `njev`。 -- [ ] 验证客户端流断开、浏览器本地 watchdog 和显式取消的语义不同;客户端误判或断连不得在无用户授权时静默丢失仍健康运行的 worker 结果。 -- [x] 热流体失败记录 RHS 时刻、最近迭代尾部、最大增量/尺度/容差、最差端口及带符号差值,并保留求解器逐次恢复的 attempted/next step 与原因。 -- [x] 矩阵报告分别记录外层 `soft_timeout` 和 worker 的合作 `cancelled`,避免把预算取消误记为求解器数值失败。 -- [x] 单格 max-step 矩阵将空的跨步长比较集合视为“不适用”而非失败;最终 `2 s / 0.02 s` 单格复验整体通过且 `comparisonFailureCount=0`。 - -**验收条件**: - -- [x] 正常活跃慢步不会被误判为死锁;真实浏览器在原慢区持续收到活动 heartbeat 并正常完成。 -- [ ] 真实无活动或单次调用永不返回时,能在约定时间内硬终止并给出诊断。 -- [ ] 取消请求在每个主要阶段都能在有界时间内生效。 -- [ ] 并发压力下服务仍能响应健康检查和新请求拒绝/排队逻辑。 -- [x] 权威 `0.2 s / 0.001 s` 浏览器路径完成且不发生假超时;有活动遥测时仅 accepted 与 activity 同时连续 60 s 不变才判停,内部活动持续时保持运行;缺少活动遥测时采用 15 分钟兼容兜底。 - -### OPT-09 建立 10 s 长时验证与模式覆盖 - -**目标**:用权威 JSON 的 `BDF / tStop=10 s / sampleStep=0.01 s / maxStep=0.001 s` 完整实测,替代“短仿真或较大 `maxStep` 可以外推到最终工程场景”的假设。 - -**当前状态**:进行中,当前代码的权威 `10 s / 0.001 s` 长基线尚未运行。权威五档已经全部完成到 `2 s`,同 `maxStep` 的 `0.2→1→2 s` 严格公共前缀逐位一致;这支持“延长 tStop 不改变已覆盖轨迹”。2026-08-19 的 5 s 三档尝试约于 `15:14:14 UTC` 启动:`.001` 子进程约 `15:24:09` 结束并进入 `.005`,约 `9 分 55 秒`;`.005` 运行约 60 秒后按用户要求中止,`.02` 未启动。由于 runner 只在整组完成后落盘,本次没有 5 s 聚合报告,不能把 `.001` 写成正式通过。既有 `10 s / maxStep=0.02 s` 只作为历史算法可行性与恢复证据,不能签收当前 JSON 的工程基线。 - -以下 2026-08-17 的结果均为历史恢复与接线证据,不代表当前 `.001 s` 权威长时验收。修复前,`tStop=2 s` 与 `tStop=5 s` 在同一 `maxStep=0.05 s` 下具有相同的首次失败时刻和求解统计,均在 `t=1.859512845 s` 耗尽热流体外层 25 次;四档 `maxStep` 的失败时刻集中在 `1.8595–1.8603 s`。这说明远端 `tStop` 不是直接失败原因,它只决定运行是否到达该局部数值困难区。 - -PNL00R stream 语义、单次 RHS 事务回滚和基于实际试探步的恢复完成后,production `2 s` 的 `maxStep=0.01/0.02/0.05/0.10 s` 四个单元均到达 `2.0 s`,`caseFailureCount=0`。矩阵命令整体退出码仍为 1,原因是跨 `maxStep` 的严格状态一致性门未通过,而不是任何单元运行失败:差异集中在事件后的 8 个 MECMAS21 速度和 8 个加速度;在差异最大的一组跨 `maxStep` 终点比较中,绝对差约 `1.01e-6–1.12e-6`。`0.05/0.10 s` 两档则逐位一致。因此当时结论是“2 s 运行失败已解决”,但“跨步长数值等价”尚未签收,不能据此批准长时 golden。 - -`5 s / maxStep=0.02 s` 已完成,worker 墙钟 `696.418 s`,0 次可恢复重试,最大热流体迭代 19,`nfev/njev/nlu=18736/1347/4988`。`maxStep=0.05 s` 在 1200 s soft budget 后由 runner 合作取消,停止于 `t=4.2523535 s`,此前仅发生 1 次已成功恢复的试探步;它是有界预算结果,不是 solver failure,也不能与已完成的 `0.02 s` 单元做终点一致性签收。形成该阶段记录时,`10 s / maxStep=0.02 s` 尚在运行;完成结果及其后追加的通用接线复验见下方收口记录。 - -| 历史 `tStop` | 历史 `maxStep` | lane / 结果 | worker 墙钟或预算 | 可恢复重试 | 说明 | -| ---: | ---: | --- | ---: | ---: | --- | -| 1 s | 0.05 s | solver-only / 完成 | 182.111 s | —(旧版未记录) | 首次延长门通过 | -| 2 s | 0.01 s | production / 完成 | 324.727 s | 8 | 最大热流体迭代 19 | -| 2 s | 0.02 s | production / 完成 | 292.035 s | 0 | 首次 recovery 矩阵当时最快;最大热流体迭代 19 | -| 2 s | 0.05 s | production / 完成 | 450.425 s | 1 | 最大热流体迭代 18 | -| 2 s | 0.10 s | production / 完成 | 448.033 s | 1 | 与 0.05 s 路径逐位一致,上限未实际约束 | -| 2 s | 0.02 s | production / 最终通用接线复验完成 | 301.782 s | 0 | 2 次事件;单格矩阵整体通过 | -| 5 s | 0.02 s | production / 完成 | 696.418 s | 0 | 最大热流体迭代 19;`18736/1347/4988` | -| 5 s | 0.05 s | production / soft budget 合作取消 | 1200 s | 1 | 停止于 4.2523535 s;不是 solver failure | -| 10 s | 0.02 s | production / 历史:最终通用接线前单元完成 | 803.622 s | 0 | 接线前历史证据,不作为最终性能口径 | -| 10 s | 0.02 s | production / 最终通用接线后完成 | 1602.733 s | 1 | orchestration 1604.152 s;`45455/3075/15282`;接受步 9569;启动 6;事件 2 | - -#### 2026-08-17 / PNL00R 正确性、热流体事务与实际步长恢复 - -- PNL00R 的端口温度参考改为同侧连接对端的温度参考焓:连接到 node 时使用对端组件的 `temperature_reference_h`,普通组件则使用常规 `connected_h`(即连接端口的 `h_outflow`);零容积元件自身的 `h_outflow` 仍保持对侧传播语义。42 项 PNL00R/stream 相关测试通过。 -- 单次 RHS 事务会回滚物理端口、flow、物性缓存、因果绑定及相关诊断,防止失败试探点污染下一次尝试。只有热流体外层 25 次耗尽被分类为可恢复错误;`StreamSolveError` 和 secondary `AlgebraicSolveError` 仍保持致命错误语义。 -- 事务开销的 7×100 RHS 微基准为关闭 `0.813488 s`、开启 `0.829156 s`,增加 `1.926%`,导数逐位一致。 -- 聚焦组合回归共 163 项通过、1 项跳过。修复后 production `0.2 s` worker 墙钟 `128.296 s`,402 个 golden 值通过,最大绝对差 `0.0171461`、最大容差比 `0.151304`,output contract 不变。 -- 证据:`runs/2026-08-17-production-thermofluid-recovery-v1-0.2.json`、`runs/2026-08-17-production-2s-max-step-matrix-v1.json`、`runs/2026-08-17-production-2s-max-step-matrix-recovery-v2.json`、`runs/2026-08-17-production-5s-max-step-matrix-recovery-v1.json`。 - -#### 2026-08-17 / 最终通用接线后的 `10 s` repeat 与收口 - -- 最终通用接线后的 `runs/2026-08-17-production-10s-max-step-0p02-general-recovery-v3.json` 完成到 `10.0 s`:worker 墙钟 `1602.733 s`、orchestration 墙钟 `1604.152 s`,`nfev/njev/nlu=45455/3075/15282`,接受步 9569,solver 启动 6 次,2 次状态事件。运行在 `t=6.9640458 s` 发生 1 次热流体可恢复失败并以 1 次重试继续完成,最大热流体迭代 23,最大缩放残差 `1.082e-16`;1717 条序列、1,722,151 个标量全部有限。 -- `runs/2026-08-17-production-10s-max-step-0p02-recovery-v1.json` 的 worker `803.622 s` 结果明确属于上述两项最终通用接线之前的历史运行,只保留为阶段性正确性和故障定位证据,不作为最终版本的性能数据。 -- 该接线前历史 10 s 报告的运行单元和 case acceptance 均通过,但旧版单格矩阵因 `sameHorizonAcrossMaxSteps=[]` 被空比较器误判,导致报告顶层 `passed=false` 和旧退出码 1;这不是仿真或数值验收失败。空比较器缺陷已经修复,最终接线后的 10 s repeat 与 `2 s / maxStep=0.02 s` 单格报告均整体 `passed=true`;后者另明确记录 `caseFailureCount=0`、`comparisonFailureCount=0`。 -- 旧 10 s 报告生成时曾根据目标的状态事件与拓扑边界推断两项最终接线不会改变已覆盖边界;该推断作为历史说明保留,现在已由最终接线后的完整 10 s repeat 直接取代。 -- 最终接线前后 `0.01 s` 输出逐值一致。两次 production `0.2 s` final candidate 运行也彼此逐值相同并均完成到终点,但两次对旧批准 golden 都只有 `398/402` 个值通过:同样的 4 个 `t=0.2 s` 派生 MECMAS21 加速度超出旧容差,最大容差比均为 `1.373`。因此不覆盖或重新批准旧 golden;应先独立确认派生加速度语义或调整投影契约。 -- 最终 `2 s / maxStep=0.02 s` 复验 worker 墙钟 `301.782 s`,0 次热流体失败/可恢复重试,2 次状态事件,单格矩阵整体通过。真实 SciPy RK45/BDF 的 direct 与 opt-in stepwise A/B 在无失败时采样、状态及 `nfev/njev/nlu` 一致。完整 `unittest discover` 共 828 项,OK(3 项跳过)。 -- 证据:`runs/2026-08-17-production-general-recovery-v2-smoke.json`、`runs/2026-08-17-production-general-recovery-v2-0.2.json`、`runs/2026-08-17-production-general-recovery-v2-repeat-0.2.json`、`runs/2026-08-17-production-2s-max-step-0p02-general-recovery-v3.json`、`runs/2026-08-17-production-10s-max-step-0p02-recovery-v1.json`、`runs/2026-08-17-production-10s-max-step-0p02-general-recovery-v3.json`。 - -**工作项**: - -- [x] 在 OPT-00 的 `0.2/1 s` 短时门通过后,使用同一 JSON 和五档 `maxStep` 延长到 `2 s`;5 个单元全部完成且 0 次恢复重试。 -- [ ] 按用户要求暂停后,恢复时从头生成完整 5 s 报告,再运行权威 `10 s / 0.001 s` 当前优化版本基线。 -- [ ] 首次长基线不得因总墙钟较长而提前当作性能失败;只有 worker、CPU 和内部活动心跳均停止并满足真停滞条件时才有界终止。若发现致命正确性问题,只做使基线可完成的最小修复,然后从 `t=0` 重新运行。 -- [ ] 在正式锁定环境运行当前优化版本基线 `10 s`,设置心跳、资源上限和可恢复日志;用户已明确授权在首次 10 s 前先解决 70 s 慢区。 -- [ ] 保存事件、模式、步长、拒步、Jacobian、闭合和内存随模拟时间的时间线。 -- [ ] 为长跑设置阶段性检查点,支持定位首次偏差而非只比较终点。 -- [ ] 将每项 P1 优化分别加入 `10 s` A/B,不把多个改动混成一个结果。 -- [ ] 根据首次基线制定合理的 CI 频率和资源门槛。 -- [x] 2026-08-17 最终通用接线版本完成一次历史 `10 s / maxStep=0.02 s` 运行并保存完整统计;它不计入当前权威基线。 -- [ ] 首次 `10 s / maxStep=0.001 s` 完整报告生成后,才根据各阶段墙钟与内部计数决定性能优化目标;旧 `0.02 s` 报告不得用于跳过该顺序。 - -**验收条件**: - -- [ ] 权威 `10 s / 0.001 s` 首次基线到达 `t=10`,输出 `0..10 s` 共 1001 个采样时刻且全部有限;事件、模式、关键状态、压力/流量和守恒量满足契约。 -- [ ] 连续 3 次完成 `10 s`,没有无解释回退、NaN/Inf 或资源失控。 -- [ ] 全程模式、事件、关键状态和守恒量满足契约。 -- [ ] 可从日志快速判断任何慢区属于积分、Jacobian、闭合、事件还是输出。 -- [ ] 同一 `maxStep=0.001 s` 下,`0.2/1/2/5/10 s` 的严格公共前缀按分类契约一致;短任务终点单独标记 terminal,不与长任务内部插值作位级误判。 -- [ ] 最终 `10 s` 至少完成 `maxStep=0.001/0.005/0.02 s` 三个代表档,并逐步补齐 `0.002/0.01 s`;较小步长不得出现较大步长没有的可复现数值失败或更早真停滞。 - -### OPT-10 明确高指数 DAE 和强非光滑系统边界 - -**目标**:明确当前通用求解能力的工程边界,并决定是否值得引入真正的 DAE/互补问题求解器。 - -**当前状态**:当前架构更适合结构明确、可唯一闭合、状态较连续的规则 index-1 类系统。超硬非光滑接触、临界抖动、近奇异代数系统、更高指数 DAE 和依赖声明不完整的自定义组件仍是薄弱点。 - -**工作项**: - -- [ ] 建立小型基准族:刚性接触、反复开闭、近奇异闭合、尺度跨越、自定义漏依赖和 index-2/3 示例。 -- [ ] 对每类系统定义“支持”“降级支持”“明确拒绝”,并给出诊断。 -- [ ] 评估质量矩阵 DAE、指数约简、互补/半光滑方法与现有架构的成本。 -- [ ] 只有真实模型需求和基准证明必要时,才启动通用 DAE 后端项目。 - -**验收条件**: - -- [ ] 文档与运行时错误能明确说明能力边界,不出现静默错误。 -- [ ] 若启动新后端,有独立设计、基准和迁移计划,不与普通 RHS 性能优化混合。 - -## 6. 统一回归矩阵 - -| 场景 | 结构 | 数值状态 | 事件/模式 | 回退 | 性能 | 长时内存 | -| --- | --- | --- | --- | --- | --- | --- | -| 小型线性组件 | 必测 | 必测 | 不适用 | 必测 | 冒烟 | 不适用 | -| 非线性压力/流量 | 必测 | 必测 | 可选 | 必测 | 必测 | 可选 | -| stream 无环/成环/反向流 | 必测 | 必测 | 必测 | 必测 | 必测 | 可选 | -| 接触与模式切换 | 必测 | 必测 | 必测 | 必测 | 必测 | 可选 | -| 自定义组件与漏依赖 | 必测 | 必测 | 可选 | 必测 | 可选 | 不适用 | -| 本文复杂 XML `0.81 s` | 必测 | 必测 | 必测 | 必测 | 必测 | 必测 | -| 本文复杂 XML `2.10 s` | 必测 | 必测 | 必测 | 必测 | 必测 | 必测 | -| 本文复杂 XML `10 s` | 必测 | 必测 | 必测 | 必测 | 必测 | 必测 | -| 主目标 `test-mql-8` `0.2 s` | 必测 | 必测 | 必测 | 必测 | 必测 | 必测 | -| 主目标 `test-mql-8` `1/2/5/10 s` | 必测 | 必测 | 必测 | 必测 | 必测 | 必测 | -| 权威 JSON 浏览器/流式 API `0.2 s / maxStep=0.001 s` | 必测 | 必测 | 必测 | 必测 | 必测(含内部活动心跳) | 可选 | -| 权威 worker `0.2/1/2 s × maxStep={0.001,0.002,0.005,0.01,0.02} s` | 必测 | 必测(分类容差) | 必测 | 必测 | 必测(串行矩阵) | 可选 | -| 权威 JSON `10 s / maxStep=0.001 s` | 必测 | 必测 | 必测 | 必测 | 必测(完整时间线) | 必测 | - -2026-08-17 的 `maxStep=0.01/0.02/0.05/0.10 s` 延长结果继续作为恢复机制与历史路径证据,但不替代 -当前权威 `maxStep=0.001 s` 的 browser/API/worker 0.2 s 门已经完成,但历史结果仍不能替代尚未运行的 10 s 最终基线。 - -当前相关回归套件包括: - -- `tests/test_sparse_secant_jacobian.py` -- `tests/test_generic_jacobian_sparsity.py` -- `tests/test_pressure_flow_causal_execution.py` -- `tests/test_stream_pressure_block_solver.py` -- `tests/test_core_solver.py` -- `tests/test_causal_numeric_ir.py` -- `tests/test_thermofluid_recovery.py` -- `tests/test_amesim_pnl00r_component.py` -- `tests/test_stream_resolver_execution_plan.py` -- `tests/test_thermofluid_closure_plan.py` -- `tests/test_max_step_matrix.py` - -这些测试目前覆盖部分关键机制,但不能替代复杂 XML 的端到端数值和长时回归。2026-08-19 当前工作树完整 -`unittest discover` 共 896 项,OK(3 项跳过);前端 activity watchdog 聚焦测试 8/8、真实 live 浏览器 E2E 1/1 通过。 - -## 7. 单项更新模板 - -完成一个原型或 PR 后,在对应任务下追加以下记录: - -```markdown -#### YYYY-MM-DD / - -- 状态:未开始 → 进行中 / 部分实现 → 已完成 -- 实现范围: -- 未覆盖范围: -- 运行环境: -- 输入与配置: -- 正确性结果: -- 性能结果(中位数与离散度): -- 回退/审计结果: -- 风险或已知退化: -- 决策:合入默认路径 / 继续实验 / 回滚 / 不采用 -- 证据文件或 CI 链接: -``` - -## 8. 总体更新记录 - -| 日期 | 代码/分支 | 任务 | 变化 | 正确性 | 性能 | 决策 | -| --- | --- | --- | --- | --- | --- | --- | -| 2026-08-17 | `6bb0591d` | 基线 | 原始 `0.81 s` 完成;内存延长 `2.10 s` 完成并越过 2.05 s | 无卡死;当前环境哈希与历史不同,待正式环境复核 | 63.779 s / 126.211 s(单次) | 建立任务清单,先完成 OPT-00 | -| 2026-08-17 | 工作树基于 `6bb0591d`;备份 `backup/jacobian-before-20260817-6bb0591` | OPT-03 | callable sparse Jacobian、真实计数、分段重置、取消、严格 seed 0 与实验 secant | 121 项相关测试通过;另 1 项既有 fixture 路径错误;30 色候选事件不等价,seed 0 候选恢复相同哈希 | 30 色历史候选有收益但不正确;seed 0 候选略慢且缓存 0 命中 | 默认 SciPy;移除多 seed/缓存;保留接入基础;解析/半解析继续后续 | -| 2026-08-17 | 工作树基于 `6bb0591d`;同一备份分支 | OPT-03 首批半解析切片 | exact-columns subset FD、类型化回退/诊断、三活塞 6 列与 34 条因果赋值;31→25 个 FD 颜色;新增 Ideal/PR、PNRP、PNCH012、PNL0001、LSTP、MECMAS 切向原语 | focused 86 + adjacent 164 = 250 项通过;closure 12/13,唯一失败为既有 fixture 路径;局部列对中心 FD 最大相对误差 `1.897e-8`;默认容差轨迹仍超严格逐点门槛,但随 rtol 收紧约 4.67×/5.15× 收敛且事件一致 | `0.81 s` 三次墙钟中位数 61.203→56.708 s,Jac RHS 8096(估计)→5985(实计);最终 `2.10 s` 单次 126.211→116.512 s,正常越过 2.05 s,事件/启动/样本均与基线一致 | 首批目标切片完成,OPT-03 总体仍部分实现;默认 SciPy,`semi-analytic` 显式 opt-in;待通用 stream/其余列、正式锁定环境独立预热和 10 s 验证 | -| 2026-08-17 | 同一 OPT-03 工作树;3 组相邻 A/B | OPT-03 重复性能复核 | 原始 `0.81 s`,每组先 SciPy 后 `semi-analytic`,运行期间无并发仿真负载 | 三组求解统计、哈希、事件和输出网格各自完全稳定;Jacobian RHS 8096(估计)→5985(实计) | 总墙钟中位数 61.203→56.708 s(`-7.34%`),积分中位数 59.725→55.631 s(`-6.85%`) | 保持显式 opt-in;仍需正式锁定环境独立预热、严格轨迹契约和 10 s 验证 | -| 2026-08-17 | 工作树基于 `16a7eb2d`;备份 `backup/general-solver-v1-before-20260817-16a7eb2` | OPT-00/01/03/09 通用求解器 v1(历史输入) | 初版 `test-mql-8` runner/正确性门;默认低分配因果执行器 v2;名字无关的 8 支路/16 列半解析编译器 | 旧 SHA `42e2d627...` 下 0.01 s v1/v2 物理解逐位相同;0.2 s 全有限且 0 审计/回退失败 | v2 RHS 微基准 `-19.1%`;0.01 s 总墙钟 `-13.1%`;旧 0.2 s 132.305 s | v2 升为默认并保留 opt-out;旧报告标为 `historicalOnly`,不得生成新 golden | -| 2026-08-17 | 同一工作树;新权威 SHA `170463d6...` | OPT-00 P0 基础闭环 | 固化无空格 XML/JSON、参考依赖约束、runner v2、state golden、output contract、三层 CI 和有界延期决策 | production 0.2 s 全有限;402 个 golden 值逐项重放误差 0;信号分段/机械事件/残差/审计/回退门均通过;全量共 792 项,OK(3 项跳过) | worker 135.824 s;1 s 保守预测 1018.680 s,未启动 1/5/10 s | P0 基础设施完成,完整 OPT-00/09 仍部分实现;先优化算法,再恢复长时递进 | -| 2026-08-17 | 同一工作树 | OPT-01/02 因果坐标与参考 IR | `760` 个兼容槽压缩为 `440` 个逻辑坐标;独立 schema v1 参考 IR 覆盖 `112+328` 坐标和 320 个逻辑别名 | kernel on/off、兼容槽、状态导数、结构签名和逐阶段差分通过;审计/验证/回退失败均为 0 | Python 调用 `-36.8%`,RHS 微基准 `-12.9%`,production 0.2 s 单次 `-3.68%` | OPT-01 基本完成;IR 暂不接管默认热路径 | -| 2026-08-17 | 同一工作树 | OPT-00/04/05/08/09 热流体恢复与延长矩阵 | 修正 PNL00R 温度 stream 参考;加入 RHS 事务、类型化闭合失败、基于 `h_abs` 的对半重试和完整诊断 | production 0.2 s golden 通过;2 s 四档 `maxStep` 均完成且 `caseFailureCount=0`,但跨步长严格门因近零机械 `a/v` 差异未过;5 s 的 0.02 s 档完成,0.05 s 档为预算取消而非 solver failure | 2 s worker 墙钟为 324.727/292.035/450.425/448.033 s;5 s 的 0.02 s 档为 696.418 s、0 retry、`18736/1347/4988`,0.05 s 档在 1200 s 预算停止于 4.2523535 s | 原 1.86 s 致命失败已恢复;暂以 0.02 s 作为延长测试首选但不修改正式默认值或批准 golden;10 s 的 0.02 s 档进行中 | -| 2026-08-17 | 同一工作树;最终通用接线与 10 s repeat | OPT-04/05/08/09 `10 s` 最终收口 | eventless Generic opt-in stepwise recovery;StreamResolver 刷新全部温度参考 override;修复单格矩阵空比较器 | 0.01 s 接线前后逐值一致;两次 0.2 s final candidate 彼此逐值相同且均为旧 golden 398/402,同样 4 个终点派生 MECMAS21 `a` 超差、最大容差比 1.373,未覆盖 golden;最终 2 s 单格通过;真实 SciPy direct/stepwise A/B 等价;完整 unittest 828 项 OK(3 项跳过) | 最终接线后 10 s worker/orchestration 1602.733/1604.152 s,`45455/3075/15282`,接受步 9569、启动 6、事件 2;`t=6.9640458 s` 的 1 次热流体失败经 1 次重试恢复,最大迭代 23、残差 `1.082e-16`,1717 序列/1,722,151 标量全有限;最终 2 s worker 301.782 s | 最终通用接线后的 10 s 已完成;803.622 s 旧报告只作接线前历史证据、不作最终性能;旧 exit 1 仅为空比较器缺陷;旧 golden 保留,连续 3 次 10 s 仍待后续 | -| 2026-08-18 | 工作树基于 `684d287`;AME SHA `cbc3aadd...` | OPT-00 完成 | AME→XML/JSON 权威契约、22 包发布锁、双 golden、最终 replay 与历史 2.10 s 三次复测 | AME 25 项外部评估通过;状态 426/426、物理 33/33 本地重放零误差;quick 179、全量 849 项通过 | 0.2 s worker 159.607 s;2.10 s 三次 113.497–115.868 s | OPT-00 本地验收完成;1 s 预算内 eligible,长时递进转 OPT-09;远端 CI 待提交触发 | -| 2026-08-18 | 同一权威 AME/XML/JSON 工作树 | OPT-00/05/08/09 步长鲁棒性重新打开 | 浏览器在 `BDF / 0.2 s / sampleStep=0.01 s / maxStep=0.001 s` 下于 `t≈0.0489 s` 计算超时,当前工程路径判定失败;新增 browser/API/worker 对账、`0.2/1 s × 五档` 短时矩阵、内部活动心跳和权威 10 s 门 | 失败事实已确认,具体根因尚未区分为数值真停滞、内部慢步、后处理/传输或 60 s 服务假超时;离线 OPT-00 证据保留但不足以签收浏览器工程路径 | 暂不使用旧 `0.02 s` 长跑推断 `0.001 s`;先定位并提交方案审阅,短时门通过后再完整取得未经本次性能优化的 `10 s / 0.001 s` 基线 | OPT-00 工程端到端门重新打开;OPT-05 提升为 P0/P1,OPT-08 为 P0 服务门,OPT-09 只认 `0.001 s` 权威长基线 | -| 2026-08-18 | 同一工作树;API 诊断任务 `diag-opt00-api-20260818` | OPT-00/05/08 步骤 1–3 定位 | 同参 API `164.954 s` 完成;普通进度 `0.048668→0.049248 s` 间隔 `70.369 s`,期间 5 s heartbeat 与约 99% 单核 CPU 持续 | 参数未改写、0 数值/恢复失败;浏览器在第 `60.416 s` heartbeat 必然先触发 `SOLVER_STALLED`,确认“前端误杀 + 后端真实慢区” | 暂不修改数值算法;建议先把 accepted 平台期改为慢步警告,并增加 activity telemetry,再用 step/RHS/Jacobian/闭合增量定位慢区 | 修改意见已提交待审;在获批前停止后续修复和延长测试 | -| 2026-08-19 | 同一权威工作树;真实浏览器与精确慢区优化 | OPT-00/05/08 本地 P0 收口 | activity telemetry 与 activity-aware watchdog;定位 8 个高刚度 LSTP 接触微步簇;因果 direct-sum/direct-reader 与 PNL 循环不变量;改变轨迹或收益不足的容差/Jacobian 候选未启用 | worker/API/browser 均完成 `0.2 s / 0.001 s`;浏览器 21 点、0 cancel/stream/page error,activity `25114→66670`;AMESim physical-state-v2.1 通过;后端 896 项 OK(3 skip),前端 watchdog 8/8、live E2E 1/1 | worker `159.607→147.634 s`(`-7.50%`);API 147.299 s;浏览器 156.136 s;普通进度最大空窗 `70.369→57.185 s` | OPT-00 本地基础闭环完成;OPT-08 的活跃慢步误杀关闭,真停滞硬杀/断连/并发仍待 | -| 2026-08-19 | 同一工作树;0.2/1/2 s 五档串行矩阵 | OPT-05/09 步长与时域鲁棒性 | `maxStep={.001,.002,.005,.01,.02}` 的 15 个单元全部完成;严格公共前缀验证 `0.2→1→2 s`;runner 拒绝 off-grid 检查点 | 15/15 单元通过、0 timeout/NaN/热流体失败/恢复重试;顶层 comparison 红项分层为 0.2/1 s 派生 `a`,2 s 接触后近零 `v/a` 与局部流量换向;压力/守恒/模式/事件通过 | 0.2 s 为 145.942–166.070 s;1 s 为 198.351–214.616 s;2 s 为 306.140–348.040 s,耗时对 maxStep 非单调 | 可解性与时域延长主阻断解除;自动分层契约仍待。5 s 首格进程约 595 s 后转入第二格,第二格约 60 s 时按用户要求中止且无聚合报告;10 s 未启动 | - -## 9. 相关文档 - -- [后端求解逻辑与效率优化调研](./后端求解逻辑与效率优化调研.md) -- [仿真性能评估-2026-08-15](./仿真性能评估-2026-08-15.md) -- [文档目录说明](../README.md) diff --git a/docs/standard/system-numeric-ir-v2.md b/docs/standard/system-numeric-ir-v2.md deleted file mode 100644 index 37f556d..0000000 --- a/docs/standard/system-numeric-ir-v2.md +++ /dev/null @@ -1,264 +0,0 @@ -# 全系统数值中间表示(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 dccfd0c..bccd6f6 100644 --- a/docs/update-log/更新日志-2026-09-10.md +++ b/docs/update-log/更新日志-2026-09-10.md @@ -19,3 +19,9 @@ - 合并 `system-optimization` 的 IR v2 规范、结构与校验器;退役依赖旧 Python 系统对象的 IR 导出器,保留 3 个冻结 IR 示例及 20 项校验回归。 - 后端回归覆盖 280 项(1 项环境锁版本校验按配置跳过);修正心跳测试的毫秒级定时假设后,对应 API 回归通过。前端构建与 3 项 Chrome 结果展示/恢复测试通过。 - 更新 CI 为 Linux 合同校验、Windows C 求解回归;历史基准保留原始输入与哈希,生成的 EXE/DLL 不进入提交。 + +## 11:14 + +- 删除未接入当前 C 路径的旧 IR Python 包、专属 schema、规范、冻结示例与测试,同步移除 CI 测试入口。C 生成继续直接使用校验后的网络结构,求解算法未改动。 +- 删除旧求解器优化任务清单、后端效率调研、2026-08-15 性能评估及 Python/C 迁移可行性计划,清理文档索引。保留 C 实现记录和数值回归基准,新优化计划后续重列。 +- 本地 37 项相关测试中 36 项通过、1 项 Linux 锁定环境检查按配置跳过,包含 50 个网络的冻结数值对照。`skill-test` 完成 10 s,RK45 最大步长 0.001 s、rtol 1e-7,单次纯求解 0.293462 s;本次用于确认清理后正常运行,不作为性能优化结论。 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5af3af7..ab72b8d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -74,7 +74,6 @@ import { useEdgesState, useNodesState, useReactFlow, - useStore, useStoreApi, useUpdateNodeInternals, } from "@xyflow/react"; @@ -90,6 +89,7 @@ import { type SimulationResultsSnapshot, } from "./SimulationResultsView"; import { AutoFitView } from "./AutoFitView"; +import { useScrollbarViewport } from "./useScrollbarViewport"; import { DockedSimulationConsole } from "./DockedSimulationConsole"; import { ComponentSymbol, @@ -1760,7 +1760,6 @@ function SimulationComponentNodeImpl({ selected, }: SimulationComponentNodeProps) { const updateNodeInternals = useUpdateNodeInternals(); - const viewportZoom = useStore((flowState) => flowState.transform[2]); const symbolContainerRef = useRef(null); const rotation = normalizeNodeRotation(data.rotation); const mirrored = Boolean(data.mirrored); @@ -1817,7 +1816,6 @@ function SimulationComponentNodeImpl({ rotation, ) : []; - const inverseViewportScale = viewportZoom > 0 ? 1 / viewportZoom : 1; const nodeLayoutStyle = { width: renderedSize.width, height: renderedSize.height, @@ -1905,8 +1903,7 @@ function SimulationComponentNodeImpl({ style={{ left: x, top: y, - transform: `scale(${inverseViewportScale}) translate(-50%, -50%)`, - transformOrigin: "0 0", + transform: "translate(-50%, -50%)", }} title={`${port.name} (${port.domain})`} type={portHandleType(port)} @@ -1916,10 +1913,6 @@ function SimulationComponentNodeImpl({ className="port-handle-glyph" preserveAspectRatio="xMidYMid meet" shapeRendering="geometricPrecision" - style={{ - transform: `scale(${viewportZoom})`, - transformOrigin: "center", - }} viewBox="0 0 6 6" > {port.kind === "physical" ? ( @@ -3752,11 +3745,14 @@ function FlowViewportScrollbars({ nodes: SimulationNode[]; }) { const reactFlow = useReactFlow(); - const transform = useStore((flowState) => flowState.transform); - const canvasWidth = useStore((flowState) => flowState.width); - const canvasHeight = useStore((flowState) => flowState.height); + const { + transform, + width: canvasWidth, + height: canvasHeight, + } = useScrollbarViewport(); const horizontalRef = useRef(null); const verticalRef = useRef(null); + const synchronizedScrollRef = useRef({ left: 0, top: 0 }); const [scrollbarViewport, setScrollbarViewport] = useState({ horizontalWidth: 0, verticalHeight: 0, @@ -3903,6 +3899,12 @@ function FlowViewportScrollbars({ if (vertical && Math.abs(vertical.scrollTop - expectedScrollTop) > 0.5) { vertical.scrollTop = expectedScrollTop; } + // 浏览器可能对滚动位置取整或裁切;记录真正写入的位置,避免稍后 + // 到达的原生 scroll 事件被当作用户拖动,反向覆盖刚恢复/缩放的视口。 + synchronizedScrollRef.current = { + left: horizontal?.scrollLeft ?? 0, + top: vertical?.scrollTop ?? 0, + }; }, [expectedScrollLeft, expectedScrollTop, horizontalContentWidth, verticalContentHeight]); const setHorizontalViewportFromScroll = (nextScrollLeft: number) => { @@ -3911,10 +3913,10 @@ function FlowViewportScrollbars({ (horizontalScrollRange > 0 ? (nextScrollLeft / horizontalScrollRange) * horizontalWorldRange : 0); + const currentViewport = reactFlow.getViewport(); void reactFlow.setViewport({ - x: -nextVisibleLeft * zoom, - y: transform[1], - zoom, + ...currentViewport, + x: -nextVisibleLeft * currentViewport.zoom, }); }; const setVerticalViewportFromScroll = (nextScrollTop: number) => { @@ -3923,10 +3925,10 @@ function FlowViewportScrollbars({ (verticalScrollRange > 0 ? (nextScrollTop / verticalScrollRange) * verticalWorldRange : 0); + const currentViewport = reactFlow.getViewport(); void reactFlow.setViewport({ - x: transform[0], - y: -nextVisibleTop * zoom, - zoom, + ...currentViewport, + y: -nextVisibleTop * currentViewport.zoom, }); }; @@ -3941,7 +3943,7 @@ function FlowViewportScrollbars({ className="flow-viewport-scrollbar flow-viewport-scrollbar-horizontal nowheel nopan" onScroll={(event) => { const nextScrollLeft = event.currentTarget.scrollLeft; - if (Math.abs(nextScrollLeft - expectedScrollLeft) <= 0.5) { + if (Math.abs(nextScrollLeft - synchronizedScrollRef.current.left) <= 0.5) { return; } setHorizontalViewportFromScroll(nextScrollLeft); @@ -3992,7 +3994,7 @@ function FlowViewportScrollbars({ className="flow-viewport-scrollbar flow-viewport-scrollbar-vertical nowheel nopan" onScroll={(event) => { const nextScrollTop = event.currentTarget.scrollTop; - if (Math.abs(nextScrollTop - expectedScrollTop) <= 0.5) { + if (Math.abs(nextScrollTop - synchronizedScrollRef.current.top) <= 0.5) { return; } setVerticalViewportFromScroll(nextScrollTop); @@ -4117,12 +4119,11 @@ function PortGeometryRefresh({ nodeIds: string[]; requestKey: number; }) { - const zoom = useStore((state) => state.transform[2]); const updateNodeInternals = useUpdateNodeInternals(); const lastRefreshKeyRef = useRef(""); useLayoutEffect(() => { - const refreshKey = `${requestKey}:${zoom}:${nodeIds.join("\u001f")}`; + const refreshKey = `${requestKey}:${nodeIds.join("\u001f")}`; if (lastRefreshKeyRef.current === refreshKey || nodeIds.length === 0) { return; } @@ -4131,7 +4132,7 @@ function PortGeometryRefresh({ updateNodeInternals(nodeIds); }); return () => window.cancelAnimationFrame(frame); - }, [nodeIds, requestKey, updateNodeInternals, zoom]); + }, [nodeIds, requestKey, updateNodeInternals]); return null; } @@ -4257,6 +4258,7 @@ function FlowWorkbench() { const pendingPasteRef = useRef(null); const portConnectionDraftRef = useRef(null); const modelingViewportRef = useRef({ x: 0, y: 0, zoom: 1 }); + const modelingCanvasRef = useRef(null); const lastFlowPointerRef = useRef<{ x: number; y: number } | null>(null); const pendingPasteMiddlePointerRef = useRef( null, @@ -4983,6 +4985,9 @@ function FlowWorkbench() { return; } if (view !== "modeling") { + if (modelingCanvasRef.current) { + modelingViewportRef.current = reactFlow.getViewport(); + } cancelPortConnection(); } setActiveView(view); @@ -4994,7 +4999,7 @@ function FlowWorkbench() { window.history.pushState({ workspaceView: view }, "", nextHash); } }, - [appendConsoleEntry, cancelPortConnection], + [appendConsoleEntry, cancelPortConnection, reactFlow], ); const loadSimulationResultsSnapshot = useCallback( @@ -8862,27 +8867,34 @@ function FlowWorkbench() { "flow-canvas", activePortConnection?.mode === "click" ? "connection-planning" : "", ].filter(Boolean).join(" ")} - onClickCapture={handleFlowClickCapture} - onDragOver={(event) => { - if (pendingPasteRef.current) { - event.dataTransfer.dropEffect = "none"; - return; + ref={modelingCanvasRef} + style={ + { + "--port-handle-hit-scale": + 1 / Math.max(modelingViewportRef.current.zoom, 0.01), + } as CSSProperties } - event.preventDefault(); - event.dataTransfer.dropEffect = "move"; - }} - onDrop={onDrop} - onPointerCancelCapture={() => { - pendingPasteMiddlePointerRef.current = null; - setNodeDragActive(false); - contactDragSessionRef.current = null; - clearContactSnapCandidate(); - handleTightSelectionPointerCancel(); - }} - onPointerDownCapture={handleFlowPointerDownCapture} - onPointerMoveCapture={handleFlowPointerMoveCapture} - onPointerUpCapture={handleFlowPointerUpCapture} - > + onClickCapture={handleFlowClickCapture} + onDragOver={(event) => { + if (pendingPasteRef.current) { + event.dataTransfer.dropEffect = "none"; + return; + } + event.preventDefault(); + event.dataTransfer.dropEffect = "move"; + }} + onDrop={onDrop} + onPointerCancelCapture={() => { + pendingPasteMiddlePointerRef.current = null; + setNodeDragActive(false); + contactDragSessionRef.current = null; + clearContactSnapCandidate(); + handleTightSelectionPointerCancel(); + }} + onPointerDownCapture={handleFlowPointerDownCapture} + onPointerMoveCapture={handleFlowPointerMoveCapture} + onPointerUpCapture={handleFlowPointerUpCapture} + > { + const canvas = modelingCanvasRef.current; + if (!canvas) return; + // 较早的平移结束事件可能晚于下一次缩放到达,读取当前视口, + // 避免旧事件覆盖最新缩放或页面切换时保存的位置。 + const viewport = reactFlow.getViewport(); + modelingViewportRef.current = viewport; + canvas.style.setProperty( + "--port-handle-hit-scale", + String(1 / Math.max(viewport.zoom, 0.01)), + ); + }} onPaneClick={handleModelingPaneClick} onSelectionChange={handleModelingSelectionChange} onSelectionEnd={finishTightSelection} diff --git a/frontend/src/AutoFitView.tsx b/frontend/src/AutoFitView.tsx index 286f7b4..0d9d1a5 100644 --- a/frontend/src/AutoFitView.tsx +++ b/frontend/src/AutoFitView.tsx @@ -18,7 +18,14 @@ export function AutoFitView({ padding = 0.18, requestKey, }: AutoFitViewProps) { + const lastHandledRequestKey = useRef( + fitOnMount ? null : requestKey, + ); const expectedNodesAreMeasured = useStore((state) => { + // 完成适应窗口后,后续每帧的视口通知无需再遍历整个节点表。 + if (Object.is(lastHandledRequestKey.current, requestKey) || !enabled) { + return false; + } if (expectedNodeCount <= 0 || state.nodeLookup.size !== expectedNodeCount) { return false; } @@ -35,9 +42,6 @@ export function AutoFitView({ return true; }); const { fitView } = useReactFlow(); - const lastHandledRequestKey = useRef( - fitOnMount ? null : requestKey, - ); useLayoutEffect(() => { if (Object.is(lastHandledRequestKey.current, requestKey)) { diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 6dd8d81..a7b6930 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -1330,6 +1330,14 @@ textarea { background: #f8fafc; } +/* 每个元件独立参与布局与合成,缩放时避免反复重绘整张复杂系统图。 + * 不使用 paint/size containment:端口、序号和透明命中区仍可超出节点边界。 + */ +.flow-canvas .react-flow__node { + contain: layout style; + will-change: transform; +} + .modeling-canvas-stack { display: grid; grid-template-rows: minmax(0, 1fr) auto; @@ -1698,6 +1706,8 @@ textarea { background: transparent; content: ""; pointer-events: inherit; + transform: scale(var(--port-handle-hit-scale, 1)); + transform-origin: center; } .port-handle-physical { diff --git a/frontend/src/useScrollbarViewport.ts b/frontend/src/useScrollbarViewport.ts new file mode 100644 index 0000000..d7a1440 --- /dev/null +++ b/frontend/src/useScrollbarViewport.ts @@ -0,0 +1,63 @@ +import { useLayoutEffect, useState } from "react"; +import { useStoreApi, type Transform } from "@xyflow/react"; + +type ScrollbarViewport = { + transform: Transform; + width: number; + height: number; +}; + +const ZOOM_SCROLLBAR_SETTLE_MS = 120; + +// 原生滚动条修改内容尺寸后读取 scrollLeft/Top 会强制布局。 +// 缩放期间保持其几何稳定,停稳后同步;平移仍逐帧跟随画布。 +export function useScrollbarViewport(): ScrollbarViewport { + const store = useStoreApi(); + const [viewport, setViewport] = useState(() => { + const { transform, width, height } = store.getState(); + return { transform, width, height }; + }); + + useLayoutEffect(() => { + let frame = 0; + let settleTimer = 0; + const publish = () => { + frame = 0; + const { transform, width, height } = store.getState(); + setViewport((previous) => + previous.width === width && previous.height === height && + previous.transform.every((value, index) => value === transform[index]) + ? previous + : { transform, width, height }, + ); + }; + const schedule = () => { + if (!frame) frame = requestAnimationFrame(publish); + }; + const unsubscribe = store.subscribe((current, previous) => { + if (current.width !== previous.width || current.height !== previous.height) { + schedule(); + } else if (current.transform !== previous.transform) { + if (current.transform[2] !== previous.transform[2] || settleTimer) { + cancelAnimationFrame(frame); + frame = 0; + clearTimeout(settleTimer); + settleTimer = window.setTimeout(() => { + settleTimer = 0; + schedule(); + }, ZOOM_SCROLLBAR_SETTLE_MS); + } else { + schedule(); + } + } + }); + schedule(); + return () => { + unsubscribe(); + cancelAnimationFrame(frame); + clearTimeout(settleTimer); + }; + }, [store]); + + return viewport; +} diff --git a/frontend/tests/e2e/component-symbols.spec.ts b/frontend/tests/e2e/component-symbols.spec.ts index 04dc03e..0294918 100644 --- a/frontend/tests/e2e/component-symbols.spec.ts +++ b/frontend/tests/e2e/component-symbols.spec.ts @@ -3319,7 +3319,7 @@ test("端口图标随画布缩小且物理端口始终保持标准圆形", async ?.map(Number) ?? []; return values[0] ?? 1; }); - const readPortShapes = async () => { + const readPortShapes = async (currentZoom: number) => { const shapes = await scalablePorts.evaluateAll((elements) => elements.map((element) => { const handleBounds = element.getBoundingClientRect(); @@ -3352,8 +3352,8 @@ test("端口图标随画布缩小且物理端口始终保持标准圆形", async }), ); for (const shape of shapes) { - expect(Math.abs(shape.handleWidth - 6)).toBeLessThan(0.05); - expect(Math.abs(shape.handleHeight - 6)).toBeLessThan(0.05); + expect(Math.abs(shape.handleWidth - 6 * currentZoom)).toBeLessThan(0.1); + expect(Math.abs(shape.handleHeight - 6 * currentZoom)).toBeLessThan(0.1); expect(shape.glyphWidth).not.toBeNull(); expect(shape.glyphHeight).not.toBeNull(); expect(Math.abs(shape.glyphWidth! - shape.glyphHeight!)).toBeLessThan(0.01); @@ -3371,7 +3371,10 @@ test("端口图标随画布缩小且物理端口始终保持标准圆形", async return shapes; }; const initialZoom = await readViewportZoom(); - const initialShapes = await readPortShapes(); + const initialShapes = await readPortShapes(initialZoom); + const initialPortStyles = await scalablePorts.evaluateAll((elements) => + elements.map((element) => element.getAttribute("style")), + ); for (const shape of initialShapes) { expect(Math.abs(shape.glyphWidth! - 6 * initialZoom)).toBeLessThan(0.05); expect(Math.abs(shape.glyphHeight! - 6 * initialZoom)).toBeLessThan(0.05); @@ -3391,7 +3394,23 @@ test("端口图标随画布缩小且物理端口始终保持标准圆形", async .poll(async () => (await readViewportZoom()) / initialZoom) .toBeLessThan(0.7); const zoomedZoom = await readViewportZoom(); - const zoomedShapes = await readPortShapes(); + await expect + .poll(async () => { + const hitScale = await page + .locator(".flow-canvas") + .evaluate((element) => + Number( + element.style.getPropertyValue("--port-handle-hit-scale"), + ), + ); + return Math.abs(hitScale - 1 / zoomedZoom); + }) + .toBeLessThan(0.02); + const zoomedShapes = await readPortShapes(zoomedZoom); + const zoomedPortStyles = await scalablePorts.evaluateAll((elements) => + elements.map((element) => element.getAttribute("style")), + ); + expect(zoomedPortStyles).toEqual(initialPortStyles); const zoomRatio = zoomedZoom / initialZoom; for (const [index, shape] of zoomedShapes.entries()) { expect(Math.abs(shape.glyphWidth! - 6 * zoomedZoom)).toBeLessThan(0.1); @@ -3400,6 +3419,25 @@ test("端口图标随画布缩小且物理端口始终保持标准圆形", async Math.abs(shape.glyphWidth! / initialShapes[index].glyphWidth! - zoomRatio), ).toBeLessThan(0.02); } + + // 可见端口随画布缩小,但透明命中区仍保持易于点击的屏幕尺寸。 + const firstZoomedPort = scalablePorts.first(); + const firstZoomedPortBounds = await firstZoomedPort.boundingBox(); + expect(firstZoomedPortBounds).not.toBeNull(); + const hitPortName = await page.evaluate( + ({ x, y }) => + document + .elementFromPoint(x, y) + ?.closest(".port-handle") + ?.getAttribute("data-port-name") ?? null, + { + x: firstZoomedPortBounds!.x - 3, + y: firstZoomedPortBounds!.y + firstZoomedPortBounds!.height / 2, + }, + ); + expect(hitPortName).toBe( + await firstZoomedPort.getAttribute("data-port-name"), + ); }); test("放大画面后接触吸附仍保留足够的画布判定半径", async ({ page }) => { diff --git a/frontend/tests/e2e/edge-interactions.spec.ts b/frontend/tests/e2e/edge-interactions.spec.ts index 166dcbf..cb676bf 100644 --- a/frontend/tests/e2e/edge-interactions.spec.ts +++ b/frontend/tests/e2e/edge-interactions.spec.ts @@ -549,6 +549,82 @@ test("正交连接线可拖动内部线段,且无连接交叉点显示跨线 expect(savedRoute?.length).toBeGreaterThan(1); }); +test("缩小画布后连接线端点仍与端口图标边缘对齐", async ({ page }) => { + await page.addInitScript((storedProject) => { + window.localStorage.setItem( + "system-simulation-flow:project:demo-system", + JSON.stringify(storedProject), + ); + }, wideProject); + await page.goto("/"); + await page.getByRole("button", { name: "加载工程", exact: true }).click(); + + const viewport = page.locator(".flow-canvas .react-flow__viewport"); + const readViewportZoom = () => + viewport.evaluate((element) => { + const values = + getComputedStyle(element) + .transform.match(/-?\d*\.?\d+(?:e[-+]?\d+)?/gi) + ?.map(Number) ?? []; + return values[0] ?? 1; + }); + const initialZoom = await readViewportZoom(); + const pane = page.locator(".flow-canvas .react-flow__pane"); + const paneBounds = await pane.boundingBox(); + expect(paneBounds).not.toBeNull(); + await page.mouse.move( + paneBounds!.x + paneBounds!.width / 2, + paneBounds!.y + paneBounds!.height / 2, + ); + await page.keyboard.down("Control"); + await page.mouse.wheel(0, 720); + await page.keyboard.up("Control"); + await expect + .poll(async () => (await readViewportZoom()) / initialZoom) + .toBeLessThan(0.7); + const zoom = await readViewportZoom(); + + const alignment = await page.evaluate(() => { + const path = document.querySelector( + '.flow-canvas .react-flow__edge[data-id="edge-1"] .react-flow__edge-path', + ); + const source = document.querySelector( + '.flow-canvas .react-flow__node[data-id="generic_sensor_1"] .port-handle[data-port-name="port_b"]', + ); + const target = document.querySelector( + '.flow-canvas .react-flow__node[data-id="generic_sensor_2"] .port-handle[data-port-name="port_a"]', + ); + const matrix = path?.getScreenCTM(); + if (!path || !source || !target || !matrix) { + return null; + } + const start = path.getPointAtLength(0).matrixTransform(matrix); + const end = path.getPointAtLength(path.getTotalLength()).matrixTransform(matrix); + const sourceBounds = source.getBoundingClientRect(); + const targetBounds = target.getBoundingClientRect(); + return { + sourceGapX: Math.abs(start.x - sourceBounds.right), + sourceGapY: Math.abs( + start.y - (sourceBounds.top + sourceBounds.height / 2), + ), + sourceWidth: sourceBounds.width, + targetGapX: Math.abs(end.x - targetBounds.left), + targetGapY: Math.abs( + end.y - (targetBounds.top + targetBounds.height / 2), + ), + targetWidth: targetBounds.width, + }; + }); + + expect(alignment).not.toBeNull(); + expect(Math.abs(alignment!.sourceWidth - 6 * zoom)).toBeLessThan(0.1); + expect(Math.abs(alignment!.targetWidth - 6 * zoom)).toBeLessThan(0.1); + expect(alignment!.sourceGapX).toBeLessThan(0.75); + expect(alignment!.sourceGapY).toBeLessThan(0.75); + expect(alignment!.targetGapX).toBeLessThan(0.75); + expect(alignment!.targetGapY).toBeLessThan(0.75); +}); + test("直线首次折弯后即使原手柄卸载,拖动仍连续且保持正交", async ({ page, }) => { diff --git a/frontend/tests/e2e/fit-view.spec.ts b/frontend/tests/e2e/fit-view.spec.ts index 71f3519..d923c5b 100644 --- a/frontend/tests/e2e/fit-view.spec.ts +++ b/frontend/tests/e2e/fit-view.spec.ts @@ -257,18 +257,18 @@ test("普通滚轮纵向平移,Ctrl 滚轮缩放且滚动条和中键拖动保 await expect .poll(async () => (await readModelingViewport(page)).zoom) .not.toBeCloseTo(beforeZoom.zoom, 2); - const horizontalAfterZoom = await horizontalScrollbar.evaluate( - (element) => element.scrollLeft, - ); - const verticalAfterZoom = await verticalScrollbar.evaluate( - (element) => element.scrollTop, - ); - expect( - Math.hypot( + await expect.poll(async () => { + const horizontalAfterZoom = await horizontalScrollbar.evaluate( + (element) => element.scrollLeft, + ); + const verticalAfterZoom = await verticalScrollbar.evaluate( + (element) => element.scrollTop, + ); + return Math.hypot( horizontalAfterZoom - horizontalBeforeZoom, verticalAfterZoom - verticalBeforeZoom, - ), - ).toBeGreaterThan(1); + ); + }).toBeGreaterThan(1); const beforeMiddleDrag = await readModelingViewport(page); await page.mouse.move(pointer.x, pointer.y); @@ -404,6 +404,67 @@ test("导入大型工程 JSON 后非接触连线端点与端口保持对齐", as }); +test("大型工程连续缩放时滚动条不逐帧重排且停稳后同步", async ({ page }) => { + await prepareApp(page); + await page.goto("/"); + await page.locator('input[type="file"]').setInputFiles(MQL_8_PROJECT_PATH); + await expectAllNodesInsideCanvas(page, ".flow-canvas", mql8Project.nodes.length); + await expect(page.getByRole("scrollbar", { name: "水平滚动画布" })) + .toHaveAttribute("aria-valuemax", /[1-9]/); + + const changes = await page.evaluate(async () => { + const canvas = document.querySelector(".flow-canvas")!; + const pane = canvas.querySelector(".react-flow__pane")!; + const viewport = canvas.querySelector(".react-flow__viewport")!; + const scrollbars = canvas.querySelector(".flow-viewport-scrollbars")!; + const bounds = canvas.getBoundingClientRect(); + let viewportChanges = 0; + let scrollbarChanges = 0; + const observer = new MutationObserver((mutations) => { + for (const mutation of mutations) { + if (mutation.target === viewport) viewportChanges++; + if (scrollbars.contains(mutation.target)) scrollbarChanges++; + } + }); + observer.observe(viewport, { attributes: true, attributeFilter: ["style"] }); + observer.observe(scrollbars, { + attributes: true, attributeFilter: ["style"], subtree: true, + }); + // 连续输入覆盖整个手势,避免驱动侧等待导致其被拆成多个独立缩放。 + for (let index = 0; index < 12; index++) { + pane.dispatchEvent(new WheelEvent("wheel", { + bubbles: true, cancelable: true, ctrlKey: true, deltaY: -8, + clientX: bounds.x + bounds.width / 2, + clientY: bounds.y + bounds.height / 2, + })); + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + } + observer.disconnect(); + return { viewportChanges, scrollbarChanges }; + }); + expect(changes.viewportChanges).toBeGreaterThan(5); + expect(changes.scrollbarChanges).toBeLessThan(changes.viewportChanges / 2); + + await expect.poll(async () => { + const viewport = await readModelingViewport(page); + const scale = await page.locator(".flow-canvas").evaluate((canvas) => + Number(canvas.style.getPropertyValue("--port-handle-hit-scale")), + ); + return Math.abs(scale * viewport.zoom - 1); + }).toBeLessThan(0.01); + const alignment = await readVisibleEdgeEndpointAlignment(page, mql8Project.edges); + expect(alignment.missingHandles).toEqual([]); + expect(alignment.checkedEdgeCount).toBeGreaterThan(20); + expect(alignment.maxDistance).toBeLessThanOrEqual(4); + const zoom = (await readModelingViewport(page)).zoom; + const horizontal = page.getByRole("scrollbar", { name: "水平滚动画布" }); + await horizontal.focus(); + const before = await readModelingViewport(page); + await horizontal.press("ArrowRight"); + await expect.poll(async () => (await readModelingViewport(page)).x).toBeLessThan(before.x); + expect((await readModelingViewport(page)).zoom).toBeCloseTo(zoom, 5); +}); + test("恢复自动保存工程后自动适配建模画布", async ({ page }) => { await prepareApp(page); await page.addInitScript((project) => { diff --git a/schemas/system-numeric-ir-v2.schema.json b/schemas/system-numeric-ir-v2.schema.json deleted file mode 100644 index 1cf2c38..0000000 --- a/schemas/system-numeric-ir-v2.schema.json +++ /dev/null @@ -1,71 +0,0 @@ -{ - "$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 deleted file mode 100644 index 3c0c623..0000000 Binary files a/tests/data/system-ir-v2-reference.json.gz and /dev/null differ diff --git a/tests/ir_reference.py b/tests/ir_reference.py deleted file mode 100644 index a198c34..0000000 --- a/tests/ir_reference.py +++ /dev/null @@ -1,35 +0,0 @@ -"""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_native_codegen.py b/tests/test_native_codegen.py index 01c5a16..98e8f96 100644 --- a/tests/test_native_codegen.py +++ b/tests/test_native_codegen.py @@ -37,7 +37,7 @@ class NativeInputTests(unittest.TestCase): with self.assertRaises((ValueError, ZeroDivisionError)): arithmetic_value(bad) - def test_ir_has_complete_state_and_output_mapping(self): + def test_generated_program_has_complete_state_and_output_mapping(self): program = compile_native_program(network()) self.assertEqual(len(program.state_keys), 12) self.assertEqual(len(program.variables), 175) diff --git a/tests/test_system_numeric_ir_v2.py b/tests/test_system_numeric_ir_v2.py deleted file mode 100644 index c3639a1..0000000 --- a/tests/test_system_numeric_ir_v2.py +++ /dev/null @@ -1,748 +0,0 @@ -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())