From 595170f71e85707b1b63ce85a44a69bbf09a8b42 Mon Sep 17 00:00:00 2001 From: Jonah Gray Date: Wed, 10 Dec 2025 20:17:15 +0000 Subject: [PATCH 1/5] feat(contracts): upgrade ModelContractReducer to v1.5.0 schema [OMN-648] - Add `fingerprint` field with regex validation (:) - Rename `state_transitions` to `state_machine` with alias support - Add `frozen=True` to ModelContractReducer for thread safety - Add `frozen=True` to ModelFSMSubcontract for immutability - Add `populate_by_name=True` for YAML alias flexibility - Update INTERFACE_VERSION to 1.5.0 for both models - Add comprehensive test suite (50 tests) for v1.5.0 features This is a prerequisite for OMN-260 (contract upgrades) and OMN-257 (validation). --- .../contracts/model_contract_reducer.py | 46 +- .../subcontracts/model_fsm_subcontract.py | 4 +- .../test_model_contract_reducer_v150.py | 531 ++++++++++++++++++ 3 files changed, 562 insertions(+), 19 deletions(-) create mode 100644 tests/unit/models/contracts/test_model_contract_reducer_v150.py diff --git a/src/omnibase_core/models/contracts/model_contract_reducer.py b/src/omnibase_core/models/contracts/model_contract_reducer.py index 8c04520dd0..8c3cd049cd 100644 --- a/src/omnibase_core/models/contracts/model_contract_reducer.py +++ b/src/omnibase_core/models/contracts/model_contract_reducer.py @@ -74,7 +74,7 @@ class ModelContractReducer(MixinNodeTypeValidator, ModelContractBase): """ # Interface version for code generation stability - INTERFACE_VERSION: ClassVar[ModelSemVer] = ModelSemVer(major=1, minor=0, patch=0) + INTERFACE_VERSION: ClassVar[ModelSemVer] = ModelSemVer(major=1, minor=5, patch=0) # Default node type for REDUCER contracts (used by MixinNodeTypeValidator) _DEFAULT_NODE_TYPE: ClassVar[EnumNodeType] = EnumNodeType.REDUCER_GENERIC @@ -88,6 +88,13 @@ class ModelContractReducer(MixinNodeTypeValidator, ModelContractBase): description="Unique correlation ID for traceability", ) + # Contract fingerprint for drift detection + fingerprint: str | None = Field( + default=None, + description="Contract fingerprint in format : for drift detection", + pattern=r"^\d+\.\d+\.\d+:[a-f0-9]{12}$", + ) + node_type: EnumNodeType = Field( default=EnumNodeType.REDUCER_GENERIC, description="Node type classification for 4-node architecture", @@ -189,10 +196,12 @@ class ModelContractReducer(MixinNodeTypeValidator, ModelContractBase): # === SUBCONTRACT COMPOSITION === # These fields provide clean subcontract integration - # FSM subcontract (simplified type to avoid complex union) - state_transitions: object | None = Field( + # FSM subcontract for state machine definition + # Alias supports legacy YAML contracts using state_transitions field name + state_machine: ModelFSMSubcontract | None = Field( default=None, - description="FSM subcontract (embedded definition or $ref reference)", + alias="state_transitions", + description="FSM subcontract for state machine definition", ) # Event-driven architecture subcontract @@ -318,11 +327,8 @@ def validate_node_specific_config( ), ) - # Validate FSM subcontract if it's not a $ref - if self.state_transitions and isinstance( - self.state_transitions, - ModelFSMSubcontract, - ): + # Validate FSM subcontract if present + if self.state_machine is not None: self._validate_fsm_subcontract() # Validate subcontract constraints @@ -349,22 +355,26 @@ def validate_subcontract_constraints( contract_data = self.model_dump() violations = [] - # REDUCER nodes should have state_transitions for proper state management - if "state_transitions" not in contract_data: + # REDUCER nodes should have state_machine for proper state management + # Check both field name and alias for YAML contract validation + if ( + "state_machine" not in contract_data + and "state_transitions" not in contract_data + ): violations.append( - "⚠️ MISSING SUBCONTRACT: REDUCER nodes should have state_transitions subcontracts", + "MISSING SUBCONTRACT: REDUCER nodes should have state_machine subcontracts", ) violations.append( - " 💡 Add state_transitions for proper stateful workflow management", + " Add state_machine for proper stateful workflow management", ) # All nodes should have event_type subcontracts if "event_type" not in contract_data: violations.append( - "⚠️ MISSING SUBCONTRACT: All nodes should define event_type subcontracts", + "MISSING SUBCONTRACT: All nodes should define event_type subcontracts", ) violations.append( - " 💡 Add event_type configuration for event-driven architecture", + " Add event_type configuration for event-driven architecture", ) if violations: @@ -387,8 +397,8 @@ def _validate_fsm_subcontract(self) -> None: Contract-driven validation - validates what's in the FSM definition. """ - fsm = self.state_transitions - if not isinstance(fsm, ModelFSMSubcontract): + fsm = self.state_machine + if fsm is None: return # Basic structural validation @@ -459,10 +469,12 @@ def _validate_fsm_subcontract(self) -> None: model_config = ConfigDict( extra="forbid", # Strict validation - reject unknown fields + frozen=True, # Thread safety and immutability use_enum_values=False, # Keep enum objects, don't convert to strings validate_assignment=True, str_strip_whitespace=True, validate_default=True, + populate_by_name=True, # Allow both field name and alias for YAML flexibility ) def to_yaml(self) -> str: diff --git a/src/omnibase_core/models/contracts/subcontracts/model_fsm_subcontract.py b/src/omnibase_core/models/contracts/subcontracts/model_fsm_subcontract.py index c64b32b77d..ab5ea7d0f0 100644 --- a/src/omnibase_core/models/contracts/subcontracts/model_fsm_subcontract.py +++ b/src/omnibase_core/models/contracts/subcontracts/model_fsm_subcontract.py @@ -44,7 +44,7 @@ class ModelFSMSubcontract(BaseModel): """ # Interface version for code generation stability - INTERFACE_VERSION: ClassVar[ModelSemVer] = ModelSemVer(major=1, minor=0, patch=0) + INTERFACE_VERSION: ClassVar[ModelSemVer] = ModelSemVer(major=1, minor=5, patch=0) # Model version for instance tracking version: ModelSemVer = Field( @@ -274,6 +274,6 @@ def validate_transition_states_exist(self) -> "ModelFSMSubcontract": model_config = ConfigDict( extra="ignore", # Allow extra fields from YAML contracts + frozen=True, # Immutability after creation for thread safety use_enum_values=False, # Keep enum objects, don't convert to strings - validate_assignment=True, ) diff --git a/tests/unit/models/contracts/test_model_contract_reducer_v150.py b/tests/unit/models/contracts/test_model_contract_reducer_v150.py new file mode 100644 index 0000000000..c1f00f2d35 --- /dev/null +++ b/tests/unit/models/contracts/test_model_contract_reducer_v150.py @@ -0,0 +1,531 @@ +""" +Comprehensive unit tests for ModelContractReducer v1.5.0 changes. + +Tests the v1.5.0 updates to ModelContractReducer including: +- Fingerprint field validation (valid/invalid patterns) +- state_machine alias backwards compatibility with state_transitions +- Frozen model behavior (immutability) +- Interface version verification + +Test Categories: +1. Fingerprint Validation Tests +2. State Machine Alias Tests (backwards compatibility) +3. Frozen Model Tests (immutability) +4. Interface Version Tests + +Requirements from v1.5.0: +- fingerprint field: pattern r"^\\d+\\.\\d+\\.\\d+:[a-f0-9]{12}$" (e.g., "1.0.0:a1b2c3d4e5f6") +- state_machine field with alias="state_transitions" for YAML backwards compatibility +- frozen=True in model_config for thread safety and immutability +- INTERFACE_VERSION = ModelSemVer(major=1, minor=5, patch=0) +""" + +from typing import Any + +import pytest +from pydantic import ValidationError + +from omnibase_core.enums import EnumNodeType +from omnibase_core.models.contracts.model_contract_reducer import ModelContractReducer +from omnibase_core.models.contracts.subcontracts.model_fsm_state_definition import ( + ModelFSMStateDefinition, +) +from omnibase_core.models.contracts.subcontracts.model_fsm_state_transition import ( + ModelFSMStateTransition, +) +from omnibase_core.models.contracts.subcontracts.model_fsm_subcontract import ( + ModelFSMSubcontract, +) +from omnibase_core.models.primitives.model_semver import ModelSemVer + + +def get_minimal_reducer_contract_data(**overrides: Any) -> dict[str, Any]: + """Create minimal valid ModelContractReducer data with optional overrides. + + This helper provides the required base class fields (name, version, description, + input_model, output_model) with sensible defaults, allowing tests to override + specific fields as needed. + """ + base_data: dict[str, Any] = { + "name": "test_reducer_contract", + "version": ModelSemVer(major=1, minor=0, patch=0), + "description": "Test reducer contract for v1.5.0 validation", + "input_model": "omnibase_core.models.test.ModelTestInput", + "output_model": "omnibase_core.models.test.ModelTestOutput", + "node_type": EnumNodeType.REDUCER_GENERIC, + } + base_data.update(overrides) + return base_data + + +def create_minimal_reducer_contract(**overrides: Any) -> ModelContractReducer: + """Create a minimal valid ModelContractReducer instance with optional overrides.""" + return ModelContractReducer(**get_minimal_reducer_contract_data(**overrides)) + + +@pytest.mark.timeout(30) +@pytest.mark.unit +class TestModelContractReducerFingerprint: + """Tests for fingerprint field validation.""" + + def test_fingerprint_valid_format_basic(self) -> None: + """Test that valid fingerprint format is accepted (basic semver).""" + contract = create_minimal_reducer_contract(fingerprint="1.0.0:a1b2c3d4e5f6") + assert contract.fingerprint == "1.0.0:a1b2c3d4e5f6" + + def test_fingerprint_valid_format_zero_version(self) -> None: + """Test that valid fingerprint with 0.0.0 version is accepted.""" + contract = create_minimal_reducer_contract(fingerprint="0.0.0:000000000000") + assert contract.fingerprint == "0.0.0:000000000000" + + def test_fingerprint_valid_format_large_version(self) -> None: + """Test that valid fingerprint with large version numbers is accepted.""" + contract = create_minimal_reducer_contract( + fingerprint="100.200.300:abcdef123456" + ) + assert contract.fingerprint == "100.200.300:abcdef123456" + + def test_fingerprint_valid_format_current_version(self) -> None: + """Test that valid fingerprint with current project version is accepted.""" + contract = create_minimal_reducer_contract(fingerprint="0.4.0:123456789abc") + assert contract.fingerprint == "0.4.0:123456789abc" + + def test_fingerprint_valid_format_all_hex_chars(self) -> None: + """Test that all valid hex characters (a-f, 0-9) are accepted.""" + contract = create_minimal_reducer_contract(fingerprint="1.5.0:abcdef012345") + assert contract.fingerprint == "1.5.0:abcdef012345" + + def test_fingerprint_none_allowed(self) -> None: + """Test that fingerprint can be None (optional field).""" + contract = create_minimal_reducer_contract(fingerprint=None) + assert contract.fingerprint is None + + def test_fingerprint_default_is_none(self) -> None: + """Test that fingerprint defaults to None when not provided.""" + contract = create_minimal_reducer_contract() + assert contract.fingerprint is None + + def test_fingerprint_invalid_missing_colon(self) -> None: + """Test that fingerprint without colon separator is rejected.""" + with pytest.raises(ValidationError) as exc_info: + create_minimal_reducer_contract(fingerprint="1.0.0a1b2c3d4e5f6") + assert "fingerprint" in str(exc_info.value).lower() + + def test_fingerprint_invalid_wrong_semver_format(self) -> None: + """Test that fingerprint with wrong semver format is rejected.""" + with pytest.raises(ValidationError) as exc_info: + create_minimal_reducer_contract(fingerprint="1.0:a1b2c3d4e5f6") + assert "fingerprint" in str(exc_info.value).lower() + + def test_fingerprint_invalid_hex_too_short(self) -> None: + """Test that fingerprint with hex shorter than 12 chars is rejected.""" + with pytest.raises(ValidationError) as exc_info: + create_minimal_reducer_contract(fingerprint="1.0.0:a1b2c3d4e5f") + assert "fingerprint" in str(exc_info.value).lower() + + def test_fingerprint_invalid_hex_too_long(self) -> None: + """Test that fingerprint with hex longer than 12 chars is rejected.""" + with pytest.raises(ValidationError) as exc_info: + create_minimal_reducer_contract(fingerprint="1.0.0:a1b2c3d4e5f67") + assert "fingerprint" in str(exc_info.value).lower() + + def test_fingerprint_invalid_uppercase_hex(self) -> None: + """Test that fingerprint with uppercase hex chars is rejected.""" + with pytest.raises(ValidationError) as exc_info: + create_minimal_reducer_contract(fingerprint="1.0.0:A1B2C3D4E5F6") + assert "fingerprint" in str(exc_info.value).lower() + + def test_fingerprint_invalid_non_hex_chars(self) -> None: + """Test that fingerprint with non-hex characters (g-z) is rejected.""" + with pytest.raises(ValidationError) as exc_info: + create_minimal_reducer_contract(fingerprint="1.0.0:ghijklmnopqr") + assert "fingerprint" in str(exc_info.value).lower() + + def test_fingerprint_invalid_negative_version(self) -> None: + """Test that fingerprint with negative version is rejected.""" + with pytest.raises(ValidationError) as exc_info: + create_minimal_reducer_contract(fingerprint="-1.0.0:a1b2c3d4e5f6") + assert "fingerprint" in str(exc_info.value).lower() + + def test_fingerprint_invalid_empty_string(self) -> None: + """Test that empty string fingerprint is rejected.""" + with pytest.raises(ValidationError) as exc_info: + create_minimal_reducer_contract(fingerprint="") + assert "fingerprint" in str(exc_info.value).lower() + + def test_fingerprint_invalid_special_chars_in_hex(self) -> None: + """Test that fingerprint with special chars in hex is rejected.""" + with pytest.raises(ValidationError) as exc_info: + create_minimal_reducer_contract(fingerprint="1.0.0:a1b2c3-d4e5f") + assert "fingerprint" in str(exc_info.value).lower() + + def test_fingerprint_invalid_spaces(self) -> None: + """Test that fingerprint with spaces is rejected.""" + with pytest.raises(ValidationError) as exc_info: + create_minimal_reducer_contract(fingerprint="1.0.0: a1b2c3d4e5f6") + assert "fingerprint" in str(exc_info.value).lower() + + +@pytest.mark.timeout(30) +@pytest.mark.unit +class TestModelContractReducerStateMachineAlias: + """Tests for state_machine field with state_transitions alias.""" + + def _create_minimal_fsm_subcontract(self) -> ModelFSMSubcontract: + """Create a minimal valid FSM subcontract for testing.""" + model_version = ModelSemVer(major=1, minor=0, patch=0) + return ModelFSMSubcontract( + version=model_version, + state_machine_name="test_fsm", + state_machine_version=model_version, + description="Test FSM for reducer contract", + states=[ + ModelFSMStateDefinition( + version=model_version, + state_name="idle", + state_type="operational", + description="Initial idle state", + ), + ModelFSMStateDefinition( + version=model_version, + state_name="processing", + state_type="operational", + description="Processing state", + ), + ModelFSMStateDefinition( + version=model_version, + state_name="completed", + state_type="terminal", + description="Terminal completed state", + is_terminal=True, + ), + ], + initial_state="idle", + terminal_states=["completed"], + transitions=[ + ModelFSMStateTransition( + version=model_version, + transition_name="start_processing", + from_state="idle", + to_state="processing", + trigger="start", + ), + ModelFSMStateTransition( + version=model_version, + transition_name="complete_processing", + from_state="processing", + to_state="completed", + trigger="finish", + ), + ], + ) + + def test_state_machine_field_direct_access(self) -> None: + """Test accessing state_machine field directly.""" + fsm = self._create_minimal_fsm_subcontract() + contract = create_minimal_reducer_contract(state_machine=fsm) + assert contract.state_machine is not None + assert contract.state_machine.state_machine_name == "test_fsm" + assert contract.state_machine.initial_state == "idle" + + def test_state_machine_none_by_default(self) -> None: + """Test that state_machine is None by default.""" + contract = create_minimal_reducer_contract() + assert contract.state_machine is None + + def test_state_transitions_alias_backwards_compat_via_model_validate(self) -> None: + """Test that state_transitions alias works for YAML/dict compatibility.""" + fsm = self._create_minimal_fsm_subcontract() + # Simulate YAML parsing: use state_transitions key (the alias) + contract_data = get_minimal_reducer_contract_data( + state_transitions=fsm.model_dump() + ) + contract = ModelContractReducer.model_validate(contract_data) + assert contract.state_machine is not None + assert contract.state_machine.state_machine_name == "test_fsm" + + def test_state_machine_via_dict_with_field_name(self) -> None: + """Test that state_machine can be set via dict using field name.""" + fsm = self._create_minimal_fsm_subcontract() + contract_data = get_minimal_reducer_contract_data( + state_machine=fsm.model_dump() + ) + contract = ModelContractReducer.model_validate(contract_data) + assert contract.state_machine is not None + assert contract.state_machine.state_machine_name == "test_fsm" + + def test_state_machine_with_fsm_subcontract_instance(self) -> None: + """Test state_machine field with ModelFSMSubcontract instance.""" + fsm = self._create_minimal_fsm_subcontract() + contract = create_minimal_reducer_contract(state_machine=fsm) + + # Verify FSM properties are accessible + assert contract.state_machine is not None + assert isinstance(contract.state_machine, ModelFSMSubcontract) + assert contract.state_machine.state_machine_name == "test_fsm" + assert contract.state_machine.initial_state == "idle" + assert len(contract.state_machine.states) == 3 + assert len(contract.state_machine.transitions) == 2 + assert "completed" in contract.state_machine.terminal_states + + def test_state_machine_preserves_fsm_configuration(self) -> None: + """Test that FSM configuration is preserved in state_machine field.""" + fsm = self._create_minimal_fsm_subcontract() + contract = create_minimal_reducer_contract(state_machine=fsm) + + assert contract.state_machine is not None + # Verify specific FSM configuration + state_names = [s.state_name for s in contract.state_machine.states] + assert "idle" in state_names + assert "processing" in state_names + assert "completed" in state_names + + # Verify transitions + transition_triggers = [t.trigger for t in contract.state_machine.transitions] + assert "start" in transition_triggers + assert "finish" in transition_triggers + + def test_populate_by_name_allows_both_field_and_alias(self) -> None: + """Test that populate_by_name=True allows both field name and alias.""" + fsm = self._create_minimal_fsm_subcontract() + + # Test with field name (state_machine) + contract1 = ModelContractReducer.model_validate( + get_minimal_reducer_contract_data(state_machine=fsm.model_dump()) + ) + assert contract1.state_machine is not None + + # Test with alias (state_transitions) + contract2 = ModelContractReducer.model_validate( + get_minimal_reducer_contract_data(state_transitions=fsm.model_dump()) + ) + assert contract2.state_machine is not None + + # Both should result in same state_machine content + assert ( + contract1.state_machine.state_machine_name + == contract2.state_machine.state_machine_name + ) + + +@pytest.mark.timeout(30) +@pytest.mark.unit +class TestModelContractReducerFrozen: + """Tests for frozen model behavior.""" + + def test_model_is_frozen(self) -> None: + """Test that model has frozen=True in config.""" + assert ModelContractReducer.model_config.get("frozen") is True + + def test_mutation_raises_error_fingerprint(self) -> None: + """Test that attempting to modify fingerprint raises ValidationError.""" + contract = create_minimal_reducer_contract(fingerprint="1.0.0:a1b2c3d4e5f6") + with pytest.raises(ValidationError): + contract.fingerprint = "2.0.0:fedcba654321" # type: ignore[misc] + + def test_mutation_raises_error_node_type(self) -> None: + """Test that attempting to modify node_type raises ValidationError.""" + contract = create_minimal_reducer_contract() + with pytest.raises(ValidationError): + contract.node_type = EnumNodeType.COMPUTE_GENERIC # type: ignore[misc] + + def test_mutation_raises_error_order_preserving(self) -> None: + """Test that attempting to modify order_preserving raises ValidationError.""" + contract = create_minimal_reducer_contract(order_preserving=False) + with pytest.raises(ValidationError): + contract.order_preserving = True # type: ignore[misc] + + def test_mutation_raises_error_incremental_processing(self) -> None: + """Test that attempting to modify incremental_processing raises ValidationError.""" + contract = create_minimal_reducer_contract(incremental_processing=True) + with pytest.raises(ValidationError): + contract.incremental_processing = False # type: ignore[misc] + + def test_mutation_raises_error_result_caching_enabled(self) -> None: + """Test that attempting to modify result_caching_enabled raises ValidationError.""" + contract = create_minimal_reducer_contract(result_caching_enabled=True) + with pytest.raises(ValidationError): + contract.result_caching_enabled = False # type: ignore[misc] + + def test_mutation_raises_error_partial_results_enabled(self) -> None: + """Test that attempting to modify partial_results_enabled raises ValidationError.""" + contract = create_minimal_reducer_contract(partial_results_enabled=True) + with pytest.raises(ValidationError): + contract.partial_results_enabled = False # type: ignore[misc] + + def test_frozen_preserves_immutability_correlation_id(self) -> None: + """Test that correlation_id cannot be changed after creation.""" + from uuid import uuid4 + + contract = create_minimal_reducer_contract() + original_correlation_id = contract.correlation_id + new_uuid = uuid4() + + with pytest.raises(ValidationError): + contract.correlation_id = new_uuid # type: ignore[misc] + + # Original value should be unchanged + assert contract.correlation_id == original_correlation_id + + def test_frozen_preserves_immutability_node_name(self) -> None: + """Test that node_name cannot be changed after creation.""" + contract = create_minimal_reducer_contract(node_name="original_name") + with pytest.raises(ValidationError): + contract.node_name = "new_name" # type: ignore[misc] + + # Original value should be unchanged + assert contract.node_name == "original_name" + + def test_frozen_model_not_hashable_with_nested_mutable_objects(self) -> None: + """Test that frozen model with nested non-frozen objects is not hashable. + + This is expected behavior: ModelContractReducer contains nested objects like + ModelPerformanceRequirements that are not frozen/hashable, making the + parent model unhashable even though it has frozen=True in its config. + This is a limitation of Pydantic's frozen model implementation. + """ + contract = create_minimal_reducer_contract(fingerprint="1.0.0:a1b2c3d4e5f6") + + # Frozen models with nested mutable objects should NOT be hashable + with pytest.raises(TypeError, match="unhashable type"): + hash(contract) + + def test_frozen_model_immutability_enforced_despite_hash_limitation(self) -> None: + """Test that immutability is still enforced even though hashing fails. + + The frozen=True config still provides value by preventing field modifications, + even if the model cannot be hashed due to nested mutable objects. + """ + contract = create_minimal_reducer_contract(fingerprint="1.0.0:a1b2c3d4e5f6") + + # Verify immutability is still enforced + with pytest.raises(ValidationError): + contract.fingerprint = "2.0.0:000000000000" # type: ignore[misc] + + # Original value should be unchanged + assert contract.fingerprint == "1.0.0:a1b2c3d4e5f6" + + +@pytest.mark.timeout(30) +@pytest.mark.unit +class TestModelContractReducerVersion: + """Tests for interface version.""" + + def test_interface_version_is_150(self) -> None: + """Test that INTERFACE_VERSION is 1.5.0.""" + version = ModelContractReducer.INTERFACE_VERSION + assert version.major == 1 + assert version.minor == 5 + assert version.patch == 0 + + def test_interface_version_is_model_semver(self) -> None: + """Test that INTERFACE_VERSION is a ModelSemVer instance.""" + version = ModelContractReducer.INTERFACE_VERSION + assert isinstance(version, ModelSemVer) + + def test_interface_version_string_representation(self) -> None: + """Test that INTERFACE_VERSION has correct string representation.""" + version = ModelContractReducer.INTERFACE_VERSION + assert str(version) == "1.5.0" + + def test_interface_version_is_class_variable(self) -> None: + """Test that INTERFACE_VERSION is a class variable, not instance variable.""" + # Should be accessible from class + assert hasattr(ModelContractReducer, "INTERFACE_VERSION") + + # Should be same object for all instances + contract1 = create_minimal_reducer_contract(name="contract1") + contract2 = create_minimal_reducer_contract(name="contract2") + assert contract1.INTERFACE_VERSION is contract2.INTERFACE_VERSION + assert ModelContractReducer.INTERFACE_VERSION is contract1.INTERFACE_VERSION + + +@pytest.mark.timeout(30) +@pytest.mark.unit +class TestModelFSMSubcontractVersion: + """Tests for ModelFSMSubcontract interface version (related to v1.5.0 changes).""" + + def test_fsm_subcontract_interface_version_is_150(self) -> None: + """Test that ModelFSMSubcontract INTERFACE_VERSION is 1.5.0.""" + version = ModelFSMSubcontract.INTERFACE_VERSION + assert version.major == 1 + assert version.minor == 5 + assert version.patch == 0 + + def test_fsm_subcontract_interface_version_is_model_semver(self) -> None: + """Test that ModelFSMSubcontract INTERFACE_VERSION is a ModelSemVer instance.""" + version = ModelFSMSubcontract.INTERFACE_VERSION + assert isinstance(version, ModelSemVer) + + def test_fsm_subcontract_frozen_model(self) -> None: + """Test that ModelFSMSubcontract has frozen=True in config.""" + assert ModelFSMSubcontract.model_config.get("frozen") is True + + +@pytest.mark.timeout(30) +@pytest.mark.unit +class TestModelContractReducerModelConfig: + """Tests for model configuration settings.""" + + def test_extra_forbid_rejects_unknown_fields(self) -> None: + """Test that extra='forbid' rejects unknown fields.""" + with pytest.raises(ValidationError) as exc_info: + ModelContractReducer( + **get_minimal_reducer_contract_data(), + unknown_field="should_fail", # type: ignore[call-arg] + ) + assert ( + "extra" in str(exc_info.value).lower() + or "unknown" in str(exc_info.value).lower() + ) + + def test_validate_default_validates_default_values(self) -> None: + """Test that validate_default=True validates default values.""" + # Default contract should validate without errors + contract = create_minimal_reducer_contract() + assert contract is not None + + def test_populate_by_name_enabled(self) -> None: + """Test that populate_by_name=True is configured.""" + assert ModelContractReducer.model_config.get("populate_by_name") is True + + def test_use_enum_values_false(self) -> None: + """Test that use_enum_values=False keeps enum objects.""" + contract = create_minimal_reducer_contract() + # Should be enum object, not string + assert isinstance(contract.node_type, EnumNodeType) + assert contract.node_type == EnumNodeType.REDUCER_GENERIC + + +@pytest.mark.timeout(30) +@pytest.mark.unit +class TestModelContractReducerSerialization: + """Tests for serialization with v1.5.0 fields.""" + + def test_model_dump_includes_fingerprint(self) -> None: + """Test that model_dump includes fingerprint field.""" + contract = create_minimal_reducer_contract(fingerprint="1.0.0:a1b2c3d4e5f6") + dumped = contract.model_dump() + assert "fingerprint" in dumped + assert dumped["fingerprint"] == "1.0.0:a1b2c3d4e5f6" + + def test_model_dump_includes_none_fingerprint(self) -> None: + """Test that model_dump includes None fingerprint when not set.""" + contract = create_minimal_reducer_contract() + dumped = contract.model_dump() + assert "fingerprint" in dumped + assert dumped["fingerprint"] is None + + def test_roundtrip_serialization_preserves_fingerprint(self) -> None: + """Test roundtrip serialization preserves fingerprint.""" + original = create_minimal_reducer_contract(fingerprint="1.5.0:abcdef123456") + dumped = original.model_dump() + restored = ModelContractReducer.model_validate(dumped) + assert restored.fingerprint == original.fingerprint + + def test_to_yaml_includes_fingerprint(self) -> None: + """Test that to_yaml() includes fingerprint field.""" + contract = create_minimal_reducer_contract(fingerprint="1.0.0:a1b2c3d4e5f6") + yaml_str = contract.to_yaml() + assert "fingerprint" in yaml_str + assert "1.0.0:a1b2c3d4e5f6" in yaml_str From 5e5dcce33d31a00fbc1048912a9c6b5c030751cd Mon Sep 17 00:00:00 2001 From: Jonah Gray Date: Wed, 10 Dec 2025 20:29:53 +0000 Subject: [PATCH 2/5] docs(contracts): add comprehensive docstrings for v1.5.0 schema [OMN-648] - Enhance ModelContractReducer module and class docstrings with v1.5.0 details - Document fingerprint field format (:) and purpose - Document state_machine alias mechanism for YAML flexibility - Explain frozen=True thread safety and immutability guarantees - Enhance ModelFSMSubcontract docstrings with immutability documentation - Add Google-style docstrings to all model validators Addresses PR #148 review feedback for clear documentation. --- .../contracts/model_contract_reducer.py | 64 +++++++++++++++---- .../subcontracts/model_fsm_subcontract.py | 59 +++++++++++++++-- 2 files changed, 108 insertions(+), 15 deletions(-) diff --git a/src/omnibase_core/models/contracts/model_contract_reducer.py b/src/omnibase_core/models/contracts/model_contract_reducer.py index 8c3cd049cd..ce3fc7ce40 100644 --- a/src/omnibase_core/models/contracts/model_contract_reducer.py +++ b/src/omnibase_core/models/contracts/model_contract_reducer.py @@ -1,5 +1,5 @@ """ -Reducer Contract Model (Clean ModelArchitecture). +Reducer Contract Model (Clean ModelArchitecture) - v1.5.0 Schema. Specialized contract model for NodeReducer implementations providing: - Reduction operation specifications with subcontract composition @@ -7,6 +7,9 @@ - Support for both FSM patterns and simple infrastructure patterns - Flexible field definitions supporting YAML contract variations - UUID correlation tracking for traceability +- Contract fingerprint for drift detection (v1.5.0) +- Field aliasing for YAML flexibility (state_machine/state_transitions) +- Thread-safe immutable instances via frozen=True Strict typing is enforced: No Any types allowed in implementation. """ @@ -63,13 +66,29 @@ class ModelContractReducer(MixinNodeTypeValidator, ModelContractBase): """ - Contract model for NodeReducer implementations - Clean ModelArchitecture. + Contract model for NodeReducer implementations - v1.5.0 Schema. Specialized contract for data aggregation nodes using subcontract composition for clean separation between node logic and functionality patterns. Supports both FSM complex patterns and simple infrastructure patterns. Includes UUID correlation tracking for full traceability. + Schema Version: + v1.5.0 - Added fingerprint field for contract drift detection, + state_machine field with alias support, and frozen model configuration. + + Key Features: + - fingerprint: Contract identity in format ``:`` enabling + drift detection between YAML contracts and generated code. The semver + portion tracks schema version, while the 12-character SHA256 hash + identifies the specific contract content. + - state_machine: FSM subcontract field with ``alias="state_transitions"`` + allowing YAML contracts to use either field name for flexibility + with existing contracts. + - frozen=True: Model instances are immutable after creation, providing + thread safety for concurrent access and preventing accidental mutation + of contract state during validation or serialization. + Strict typing is enforced: No Any types allowed in implementation. """ @@ -88,10 +107,19 @@ class ModelContractReducer(MixinNodeTypeValidator, ModelContractBase): description="Unique correlation ID for traceability", ) - # Contract fingerprint for drift detection + # Contract fingerprint for drift detection (v1.5.0) + # Format: : where semver is the schema version (e.g., 1.5.0) + # and sha256_12 is the first 12 characters of the SHA256 hash of the contract content. + # Used to detect when YAML contracts drift from generated code. fingerprint: str | None = Field( default=None, - description="Contract fingerprint in format : for drift detection", + description=( + "Contract fingerprint for drift detection. Format: : " + "where semver is the schema version (e.g., '1.5.0') and sha256_12 is the " + "first 12 hex characters of the SHA256 hash of the canonical contract content. " + "Example: '1.5.0:a1b2c3d4e5f6'. Used to detect contract changes between " + "YAML definitions and generated code artifacts." + ), pattern=r"^\d+\.\d+\.\d+:[a-f0-9]{12}$", ) @@ -196,12 +224,19 @@ class ModelContractReducer(MixinNodeTypeValidator, ModelContractBase): # === SUBCONTRACT COMPOSITION === # These fields provide clean subcontract integration - # FSM subcontract for state machine definition - # Alias supports legacy YAML contracts using state_transitions field name + # FSM subcontract for state machine definition (v1.5.0 naming) + # The alias="state_transitions" supports YAML contracts using the legacy field name. + # Both field names are accepted when loading from YAML due to populate_by_name=True. state_machine: ModelFSMSubcontract | None = Field( default=None, alias="state_transitions", - description="FSM subcontract for state machine definition", + description=( + "FSM subcontract defining state machine behavior for reducer nodes. " + "Accepts both 'state_machine' (preferred) and 'state_transitions' (legacy) " + "field names in YAML contracts via the alias mechanism. The subcontract " + "defines states, transitions, initial state, and FSM operations with " + "atomic execution and rollback guarantees for critical operations." + ), ) # Event-driven architecture subcontract @@ -467,13 +502,20 @@ def _validate_fsm_subcontract(self) -> None: ), ) + # Pydantic model configuration (v1.5.0) + # frozen=True ensures thread safety by making instances immutable after creation. + # This prevents accidental mutation during concurrent access, validation, or + # serialization operations. Any attempt to modify fields after instantiation + # will raise a ValidationError. + # populate_by_name=True enables both field names and aliases to be used when + # loading from YAML, supporting legacy contracts using state_transitions. model_config = ConfigDict( extra="forbid", # Strict validation - reject unknown fields - frozen=True, # Thread safety and immutability + frozen=True, # Thread safety and immutability - instances cannot be modified use_enum_values=False, # Keep enum objects, don't convert to strings - validate_assignment=True, - str_strip_whitespace=True, - validate_default=True, + validate_assignment=True, # Validate on any assignment attempt (triggers error if frozen) + str_strip_whitespace=True, # Clean string inputs + validate_default=True, # Validate default values at model definition time populate_by_name=True, # Allow both field name and alias for YAML flexibility ) diff --git a/src/omnibase_core/models/contracts/subcontracts/model_fsm_subcontract.py b/src/omnibase_core/models/contracts/subcontracts/model_fsm_subcontract.py index ab5ea7d0f0..2d552b44f5 100644 --- a/src/omnibase_core/models/contracts/subcontracts/model_fsm_subcontract.py +++ b/src/omnibase_core/models/contracts/subcontracts/model_fsm_subcontract.py @@ -1,7 +1,7 @@ """ FSM (Finite State Machine) Subcontract Model. - +Schema version: v1.5.0 Dedicated subcontract model for finite state machine functionality providing: - State definitions with entry/exit actions and validation rules @@ -9,10 +9,14 @@ - Operation definitions with permissions and atomic guarantees - FSM configuration and management settings - State lifecycle and transition validation +- Thread-safe immutability via frozen=True This model is composed into node contracts that require FSM functionality, providing clean separation between node logic and state machine behavior. +Instances are immutable after creation (frozen=True), enabling safe sharing +across threads without synchronization. + Strict typing is enforced: No Any types allowed in implementation. """ @@ -40,6 +44,19 @@ class ModelFSMSubcontract(BaseModel): transitions, operations, validation, and recovery mechanisms. Designed for composition into node contracts requiring FSM functionality. + Schema Version: + v1.5.0 - Added frozen=True for immutability after creation. + + Immutability and Thread Safety: + This model uses frozen=True (Pydantic ConfigDict), making instances + immutable after creation. This provides thread safety guarantees: + once an instance is created and validated, its state cannot be + modified, allowing safe sharing across threads without locks. + + Model validators (validate_initial_state_exists, etc.) run during + construction before the instance is frozen. After validation + completes, the instance becomes immutable. + Strict typing is enforced: No Any types allowed in implementation. """ @@ -175,7 +192,17 @@ class ModelFSMSubcontract(BaseModel): @model_validator(mode="after") def validate_initial_state_exists(self) -> "ModelFSMSubcontract": - """Validate that initial state is defined in states list[Any].""" + """Validate that initial state is defined in states list. + + Runs during construction before the instance is frozen. Ensures the + initial_state field references a valid state from the states list. + + Returns: + The validated instance (self). + + Raises: + ModelOnexError: If initial_state is not found in states list. + """ state_names = [state.state_name for state in self.states] if self.initial_state not in state_names: msg = f"Initial state '{self.initial_state}' not found in states list[Any]" @@ -195,7 +222,19 @@ def validate_initial_state_exists(self) -> "ModelFSMSubcontract": @model_validator(mode="after") def validate_special_states_exist(self) -> "ModelFSMSubcontract": - """Validate that terminal and error states are defined in states list[Any].""" + """Validate that terminal and error states are defined in states list. + + Runs during construction before the instance is frozen. Ensures all + states listed in terminal_states and error_states reference valid + states from the states list. + + Returns: + The validated instance (self). + + Raises: + ModelOnexError: If any terminal or error state is not found in + states list. + """ state_names = [state.state_name for state in self.states] # Validate terminal states @@ -235,7 +274,19 @@ def validate_special_states_exist(self) -> "ModelFSMSubcontract": @model_validator(mode="after") def validate_transition_states_exist(self) -> "ModelFSMSubcontract": - """Validate that all transition source and target states exist.""" + """Validate that all transition source and target states exist. + + Runs during construction before the instance is frozen. Ensures all + from_state and to_state values in transitions reference valid states. + Supports wildcard transitions where from_state can be '*' to match + any source state. + + Returns: + The validated instance (self). + + Raises: + ModelOnexError: If any transition references a non-existent state. + """ state_names = [state.state_name for state in self.states] # Add wildcard state to supported states for global transitions state_names_with_wildcard = [*state_names, "*"] From acdc20093faaff9ae3dabb919e17338d485711ee Mon Sep 17 00:00:00 2001 From: Jonah Gray Date: Wed, 10 Dec 2025 20:37:54 +0000 Subject: [PATCH 3/5] docs(contracts): clarify validate_assignment config differences [OMN-648] Document why ModelFSMSubcontract omits validate_assignment while ModelContractReducer includes it - both have identical behavior due to frozen=True, but the explicit flag is kept for documentation clarity. --- src/omnibase_core/models/contracts/model_contract_reducer.py | 2 +- .../models/contracts/subcontracts/model_fsm_subcontract.py | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/omnibase_core/models/contracts/model_contract_reducer.py b/src/omnibase_core/models/contracts/model_contract_reducer.py index ce3fc7ce40..7f5847b6e7 100644 --- a/src/omnibase_core/models/contracts/model_contract_reducer.py +++ b/src/omnibase_core/models/contracts/model_contract_reducer.py @@ -513,7 +513,7 @@ def _validate_fsm_subcontract(self) -> None: extra="forbid", # Strict validation - reject unknown fields frozen=True, # Thread safety and immutability - instances cannot be modified use_enum_values=False, # Keep enum objects, don't convert to strings - validate_assignment=True, # Validate on any assignment attempt (triggers error if frozen) + validate_assignment=True, # Explicit for documentation - redundant with frozen=True but kept for clarity str_strip_whitespace=True, # Clean string inputs validate_default=True, # Validate default values at model definition time populate_by_name=True, # Allow both field name and alias for YAML flexibility diff --git a/src/omnibase_core/models/contracts/subcontracts/model_fsm_subcontract.py b/src/omnibase_core/models/contracts/subcontracts/model_fsm_subcontract.py index 2d552b44f5..578449fdc4 100644 --- a/src/omnibase_core/models/contracts/subcontracts/model_fsm_subcontract.py +++ b/src/omnibase_core/models/contracts/subcontracts/model_fsm_subcontract.py @@ -323,6 +323,11 @@ def validate_transition_states_exist(self) -> "ModelFSMSubcontract": ) return self + # Note: validate_assignment is intentionally omitted here because: + # 1. frozen=True already prevents all attribute modifications (raises ValidationError) + # 2. validate_assignment would be redundant - any assignment attempt fails immediately + # 3. This differs from ModelContractReducer which includes validate_assignment=True + # for explicit documentation purposes, but the behavior is identical model_config = ConfigDict( extra="ignore", # Allow extra fields from YAML contracts frozen=True, # Immutability after creation for thread safety From 5c037f34fa6f81c1682594c999d0b3e8a2ff4b43 Mon Sep 17 00:00:00 2001 From: Jonah Gray Date: Wed, 10 Dec 2025 20:54:04 +0000 Subject: [PATCH 4/5] fix(contracts): address PR review feedback for v1.5.0 schema [OMN-648] - Add validate_assignment=True to ModelFSMSubcontract for consistency - Add defense-in-depth docstring to _validate_fsm_subcontract method - Add test for simultaneous state_machine/state_transitions field usage - Add docstring note about frozen model hashing limitations --- .../contracts/model_contract_reducer.py | 26 +++++ .../subcontracts/model_fsm_subcontract.py | 6 +- .../test_model_contract_reducer_v150.py | 109 ++++++++++++++++++ 3 files changed, 136 insertions(+), 5 deletions(-) diff --git a/src/omnibase_core/models/contracts/model_contract_reducer.py b/src/omnibase_core/models/contracts/model_contract_reducer.py index 7f5847b6e7..ad68e5a085 100644 --- a/src/omnibase_core/models/contracts/model_contract_reducer.py +++ b/src/omnibase_core/models/contracts/model_contract_reducer.py @@ -89,6 +89,16 @@ class ModelContractReducer(MixinNodeTypeValidator, ModelContractBase): thread safety for concurrent access and preventing accidental mutation of contract state during validation or serialization. + Note: + While this model uses ``frozen=True`` for immutability, instances containing + nested mutable objects (such as lists, dicts, or non-frozen Pydantic models) + are **not hashable**. This is expected Pydantic behavior: the ``frozen=True`` + configuration prevents field modification but does not make nested mutable + objects hashable. Attempting to call ``hash()`` on such instances will raise + ``TypeError: unhashable type``. See tests in + ``tests/unit/models/contracts/test_model_contract_reducer_v150.py`` for + detailed documentation of this behavior. + Strict typing is enforced: No Any types allowed in implementation. """ @@ -431,6 +441,22 @@ def _validate_fsm_subcontract(self) -> None: Validate FSM subcontract configuration for reducer nodes. Contract-driven validation - validates what's in the FSM definition. + + Defense-in-Depth Pattern: + This validation intentionally duplicates some checks that ModelFSMSubcontract + performs via its @model_validator. This is deliberate: + + 1. FSM validators run during FSM construction (ModelFSMSubcontract instantiation) + 2. Contract validators run during contract validation (ModelContractReducer validation) + 3. These are separate validation phases that may execute independently + + By duplicating critical checks here, we: + - Protect against changes in FSM validation behavior + - Ensure contract-level invariants hold regardless of FSM implementation details + - Provide clearer error messages in the contract validation context + - Enable contracts to enforce stricter requirements than the base FSM model + + This follows the principle: "validate at trust boundaries, not just at the source." """ fsm = self.state_machine if fsm is None: diff --git a/src/omnibase_core/models/contracts/subcontracts/model_fsm_subcontract.py b/src/omnibase_core/models/contracts/subcontracts/model_fsm_subcontract.py index 578449fdc4..44e68ab44c 100644 --- a/src/omnibase_core/models/contracts/subcontracts/model_fsm_subcontract.py +++ b/src/omnibase_core/models/contracts/subcontracts/model_fsm_subcontract.py @@ -323,13 +323,9 @@ def validate_transition_states_exist(self) -> "ModelFSMSubcontract": ) return self - # Note: validate_assignment is intentionally omitted here because: - # 1. frozen=True already prevents all attribute modifications (raises ValidationError) - # 2. validate_assignment would be redundant - any assignment attempt fails immediately - # 3. This differs from ModelContractReducer which includes validate_assignment=True - # for explicit documentation purposes, but the behavior is identical model_config = ConfigDict( extra="ignore", # Allow extra fields from YAML contracts frozen=True, # Immutability after creation for thread safety use_enum_values=False, # Keep enum objects, don't convert to strings + validate_assignment=True, # Explicit for documentation - redundant with frozen=True but kept for clarity ) diff --git a/tests/unit/models/contracts/test_model_contract_reducer_v150.py b/tests/unit/models/contracts/test_model_contract_reducer_v150.py index c1f00f2d35..4279cf9fd4 100644 --- a/tests/unit/models/contracts/test_model_contract_reducer_v150.py +++ b/tests/unit/models/contracts/test_model_contract_reducer_v150.py @@ -307,6 +307,115 @@ def test_populate_by_name_allows_both_field_and_alias(self) -> None: == contract2.state_machine.state_machine_name ) + def test_both_state_machine_and_state_transitions_provided(self) -> None: + """Test behavior when both field name and alias are provided simultaneously. + + Documents Pydantic's behavior when both state_machine (field name) + and state_transitions (alias) are provided in the same dict/YAML input. + + Expected Behavior (Pydantic v2 with extra='forbid'): + - When both the field name AND the alias are present in input data, + Pydantic raises a ValidationError with "Extra inputs are not permitted". + - This occurs because the model has extra='forbid' configured, which + rejects any fields not defined in the model schema. + - When both state_machine (field) and state_transitions (alias) are + provided, Pydantic maps state_transitions to state_machine via the + alias, then sees state_machine as an "extra" undeclared field. + + IMPORTANT: This behavior ensures YAML contracts cannot accidentally + specify both keys with different values, which would create ambiguity. + Contracts MUST use either state_machine OR state_transitions, not both. + """ + # Create two different FSM subcontracts to test the rejection + model_version = ModelSemVer(major=1, minor=0, patch=0) + + # FSM for field name (state_machine) + fsm_field_name = ModelFSMSubcontract( + version=model_version, + state_machine_name="fsm_via_field_name", + state_machine_version=model_version, + description="FSM provided via state_machine field name", + states=[ + ModelFSMStateDefinition( + version=model_version, + state_name="field_initial", + state_type="operational", + description="Initial state from field name FSM", + ), + ModelFSMStateDefinition( + version=model_version, + state_name="field_terminal", + state_type="terminal", + description="Terminal state from field name FSM", + is_terminal=True, + ), + ], + initial_state="field_initial", + terminal_states=["field_terminal"], + transitions=[ + ModelFSMStateTransition( + version=model_version, + transition_name="field_transition", + from_state="field_initial", + to_state="field_terminal", + trigger="complete", + ), + ], + ) + + # FSM for alias (state_transitions) + fsm_alias = ModelFSMSubcontract( + version=model_version, + state_machine_name="fsm_via_alias", + state_machine_version=model_version, + description="FSM provided via state_transitions alias", + states=[ + ModelFSMStateDefinition( + version=model_version, + state_name="alias_initial", + state_type="operational", + description="Initial state from alias FSM", + ), + ModelFSMStateDefinition( + version=model_version, + state_name="alias_terminal", + state_type="terminal", + description="Terminal state from alias FSM", + is_terminal=True, + ), + ], + initial_state="alias_initial", + terminal_states=["alias_terminal"], + transitions=[ + ModelFSMStateTransition( + version=model_version, + transition_name="alias_transition", + from_state="alias_initial", + to_state="alias_terminal", + trigger="complete", + ), + ], + ) + + # Create contract data with BOTH keys present + contract_data = get_minimal_reducer_contract_data() + contract_data["state_machine"] = fsm_field_name.model_dump() + contract_data["state_transitions"] = fsm_alias.model_dump() + + # Pydantic should raise ValidationError due to extra='forbid' + # when both field name and alias are provided simultaneously + with pytest.raises(ValidationError) as exc_info: + ModelContractReducer.model_validate(contract_data) + + # Verify the error is about extra inputs not being permitted + error_str = str(exc_info.value).lower() + assert "extra" in error_str or "not permitted" in error_str, ( + "Error should indicate extra inputs are not permitted. " + "When both state_machine (field) and state_transitions (alias) " + "are provided, Pydantic sees one as an extra field and rejects it " + "because extra='forbid' is configured on ModelContractReducer." + ) + @pytest.mark.timeout(30) @pytest.mark.unit From de514cf14ab8658fa480cedc9f7cc3ebbe20b240 Mon Sep 17 00:00:00 2001 From: Jonah Gray Date: Wed, 10 Dec 2025 21:37:02 +0000 Subject: [PATCH 5/5] fix(contracts): remove backwards-compat property, update all call sites to state_machine [OMN-648] Breaking Change: All code now uses `state_machine` field directly. The `state_transitions` alias ONLY works for YAML/dict parsing (Pydantic alias), NOT for attribute access. No backwards-compatible property provided - no tech debt until v0.4.0. Changes: - Remove @property state_transitions from ModelContractReducer - Update node_reducer.py to use state_machine field - Update mixin_fsm_execution.py docstring examples - Update mixin_metadata.yaml code examples --- src/omnibase_core/mixins/mixin_fsm_execution.py | 10 +++++----- src/omnibase_core/mixins/mixin_metadata.yaml | 12 ++++++------ src/omnibase_core/nodes/node_reducer.py | 15 ++++++++------- 3 files changed, 19 insertions(+), 18 deletions(-) diff --git a/src/omnibase_core/mixins/mixin_fsm_execution.py b/src/omnibase_core/mixins/mixin_fsm_execution.py index ec517210b5..5e3ab0984f 100644 --- a/src/omnibase_core/mixins/mixin_fsm_execution.py +++ b/src/omnibase_core/mixins/mixin_fsm_execution.py @@ -73,7 +73,7 @@ async def execute_fsm_transition( Example: result = await self.execute_fsm_transition( - self.contract.state_transitions, + self.contract.state_machine, trigger="collect_metrics", context={"data_sources": [...]}, ) @@ -130,7 +130,7 @@ async def validate_fsm_contract( Example: errors = await self.validate_fsm_contract( - self.contract.state_transitions + self.contract.state_machine ) if errors: @@ -176,7 +176,7 @@ def reset_fsm_state(self, fsm_contract: ModelFSMSubcontract) -> None: Example: # Reset to clean initial state - self.reset_fsm_state(self.contract.state_transitions) + self.reset_fsm_state(self.contract.state_machine) assert self.get_current_fsm_state() == "idle" """ self._fsm_state = get_initial_state(fsm_contract) @@ -194,7 +194,7 @@ def initialize_fsm_state( Example: # Initialize with context self.initialize_fsm_state( - self.contract.state_transitions, + self.contract.state_machine, context={"batch_size": 1000} ) """ @@ -215,7 +215,7 @@ def is_terminal_state(self, fsm_contract: ModelFSMSubcontract) -> bool: True if current state is terminal, False otherwise Example: - if self.is_terminal_state(self.contract.state_transitions): + if self.is_terminal_state(self.contract.state_machine): print("FSM has reached terminal state") # No more transitions possible """ diff --git a/src/omnibase_core/mixins/mixin_metadata.yaml b/src/omnibase_core/mixins/mixin_metadata.yaml index 5009dc3ba4..30bd5b693d 100644 --- a/src/omnibase_core/mixins/mixin_metadata.yaml +++ b/src/omnibase_core/mixins/mixin_metadata.yaml @@ -2573,7 +2573,7 @@ mixin_fsm_execution: async def process(self, input_data): # FSM-driven processing result = await self.execute_fsm_transition( - self.contract.state_transitions, + self.contract.state_machine, trigger=input_data.trigger, context={"data": input_data.data} ) @@ -2596,7 +2596,7 @@ mixin_fsm_execution: # Validate FSM contract (async operation) errors = await instance.validate_fsm_contract( - contract.state_transitions + contract.state_machine ) if errors: raise ValidationError(f"FSM invalid: {errors}") @@ -2608,13 +2608,13 @@ mixin_fsm_execution: async def process(self, input_data): # Execute transition result = await self.execute_fsm_transition( - self.contract.state_transitions, + self.contract.state_machine, trigger=input_data.trigger, context={"data": input_data.data} ) # Check if reached terminal state - if self.is_terminal_state(self.contract.state_transitions): + if self.is_terminal_state(self.contract.state_machine): # Emit completion intent result.intents.append( ModelIntent( @@ -2666,7 +2666,7 @@ mixin_fsm_execution: async def execute_workflow(self, input_data): # Transition to running await self.execute_fsm_transition( - self.contract.state_transitions, + self.contract.state_machine, trigger="workflow_start", context={"workflow_id": input_data.workflow_id} ) @@ -2675,7 +2675,7 @@ mixin_fsm_execution: # Transition to completed result = await self.execute_fsm_transition( - self.contract.state_transitions, + self.contract.state_machine, trigger="workflow_complete", context={"results": final_results} ) diff --git a/src/omnibase_core/nodes/node_reducer.py b/src/omnibase_core/nodes/node_reducer.py index 0445e861f3..61488beb2f 100644 --- a/src/omnibase_core/nodes/node_reducer.py +++ b/src/omnibase_core/nodes/node_reducer.py @@ -97,7 +97,7 @@ class NodeMyReducer(NodeReducer): # Initialize FSM state node.initialize_fsm_state( - node.contract.state_transitions, + node.contract.state_machine, context={"batch_size": 1000} ) @@ -133,22 +133,23 @@ def __init__(self, container: ModelONEXContainer) -> None: super().__init__(container) # Load FSM contract from node contract - # This assumes the node contract has a state_transitions field + # This assumes the node contract has a state_machine field # If not present, FSM capabilities are not active self.fsm_contract: ModelFSMSubcontract | None = None # Try to load FSM contract if available in node contract - if hasattr(self, "contract") and hasattr(self.contract, "state_transitions"): - self.fsm_contract = self.contract.state_transitions + if hasattr(self, "contract") and hasattr(self.contract, "state_machine"): + self.fsm_contract = self.contract.state_machine # Auto-initialize FSM state if contract is present - self.initialize_fsm_state(self.fsm_contract, context={}) + if self.fsm_contract is not None: + self.initialize_fsm_state(self.fsm_contract, context={}) else: - # FSM capabilities inactive - no state_transitions in contract + # FSM capabilities inactive - no state_machine in contract emit_log_event( LogLevel.DEBUG, f"FSM capabilities inactive for {self.__class__.__name__}: " - "no state_transitions found in contract", + "no state_machine found in contract", {"node_id": str(self.node_id), "node_type": self.__class__.__name__}, )