diff --git a/config/validation/handler_dispatch_entrypoint_baseline.yaml b/config/validation/handler_dispatch_entrypoint_baseline.yaml index 6800591e59..1bc227677a 100644 --- a/config/validation/handler_dispatch_entrypoint_baseline.yaml +++ b/config/validation/handler_dispatch_entrypoint_baseline.yaml @@ -64,9 +64,6 @@ known_entrypointless: - contract: node_impact_analyzer_compute handler: HandlerImpactAnalysis module: omnibase_infra.nodes.node_impact_analyzer_compute.handlers.handler_impact_analysis - - contract: node_invariant_evaluate_compute - handler: HandlerInvariantEvaluate - module: omnibase_infra.nodes.node_invariant_evaluate_compute.handlers.handler_invariant_evaluate - contract: node_llm_embedding_effect handler: HandlerEmbeddingOpenaiCompatible module: omnibase_infra.nodes.node_llm_embedding_effect.handlers.handler_embedding_openai_compatible diff --git a/scripts/ci/adequacy_receipts/omnibase_infra.nodes.node_invariant_evaluate_compute.handflip.json b/scripts/ci/adequacy_receipts/omnibase_infra.nodes.node_invariant_evaluate_compute.handflip.json new file mode 100644 index 0000000000..a5c5111762 --- /dev/null +++ b/scripts/ci/adequacy_receipts/omnibase_infra.nodes.node_invariant_evaluate_compute.handflip.json @@ -0,0 +1,30 @@ +{ + "node_id": "omnibase_infra.nodes.node_invariant_evaluate_compute", + "receipt_schema": "handflip_proof.v1", + "canonical_handler_module": "omnibase_infra.nodes.node_invariant_evaluate_compute.handlers.handler_invariant_evaluate", + "legacy_handler_module": "omnibase_infra.nodes.node_invariant_evaluate_compute.handlers.handler_invariant_evaluate", + "base_ref": "71ab1bf6deb4252edd9593f547dd5a4b4e60939b", + "preserved_symbols": [ + "_new_evaluator", + "HandlerInvariantEvaluate.handler_type", + "HandlerInvariantEvaluate.handler_category" + ], + "parity": { + "status": "pass", + "test_ids": [ + "tests/unit/nodes/node_invariant_evaluate_compute/test_invariant_evaluate_dispatch_entrypoint_omn14822.py::test_handlers_expose_handle_entrypoint_and_no_legacy_shim", + "tests/unit/nodes/node_invariant_evaluate_compute/test_invariant_evaluate_dispatch_entrypoint_omn14822.py::test_real_dispatch_callback_reaches_handle[E1_field_pass]", + "tests/unit/nodes/node_invariant_evaluate_compute/test_invariant_evaluate_dispatch_entrypoint_omn14822.py::test_real_dispatch_callback_reaches_handle[E2_field_fail]", + "tests/unit/nodes/node_invariant_evaluate_compute/test_invariant_evaluate_dispatch_entrypoint_omn14822.py::test_real_dispatch_callback_reaches_handle[E3_threshold_pass]", + "tests/unit/nodes/node_invariant_evaluate_compute/test_invariant_evaluate_dispatch_entrypoint_omn14822.py::test_selected_corpus_behavior_equivalence" + ], + "selected_input_hashes": [ + "sha256:9e2e2ac7be0837d02bc93096520f8f912d09a57ef63079feb44b2358ff08d22c", + "sha256:6b70bb828f605f1ff532a3d428c125f0a423eaf18a466f3496c4c055f4a5b73e", + "sha256:81c9ecb1241b362df1f81ddbd81cd5d67b83fac78750ff66d64a011a4f39374e", + "sha256:06d62d280f141d26dff4989272c6c03a365a6d7e91cbca813263e3230cc56184", + "sha256:67994ba911698033ed2b8e4c2d56fa6ad17f443066327e4b7f836a8beb611603", + "sha256:a8158baa3a5f43e7ae66b76a3f26c5536df615030617cb53965ca20f01297ec1" + ] + } +} diff --git a/scripts/ci/adequacy_receipts/omnibase_infra.nodes.node_invariant_evaluate_compute.json b/scripts/ci/adequacy_receipts/omnibase_infra.nodes.node_invariant_evaluate_compute.json new file mode 100644 index 0000000000..e9700d106b --- /dev/null +++ b/scripts/ci/adequacy_receipts/omnibase_infra.nodes.node_invariant_evaluate_compute.json @@ -0,0 +1,26 @@ +{ + "branch_coverage_pct": 18.42, + "candidate_count": 6, + "coverage_target": 80.0, + "handler_module": "omnibase_infra.nodes.node_invariant_evaluate_compute.handlers.handler_invariant_evaluate", + "handler_module_sha256": "sha256:8ec577f6fac4a3dd1a67a583c77ac165946b14a2f97b0531911b0d8a752d8db7", + "meets_target": false, + "node_id": "omnibase_infra.nodes.node_invariant_evaluate_compute", + "receipt_schema": "adequacy_receipt.v1", + "recorded_at": "2026-07-20T02:52:07.401132+00:00", + "recorder_schema": "adequacy_receipt.v1", + "selected_count": 6, + "selected_input_hashes": [ + "sha256:9e2e2ac7be0837d02bc93096520f8f912d09a57ef63079feb44b2358ff08d22c", + "sha256:6b70bb828f605f1ff532a3d428c125f0a423eaf18a466f3496c4c055f4a5b73e", + "sha256:81c9ecb1241b362df1f81ddbd81cd5d67b83fac78750ff66d64a011a4f39374e", + "sha256:06d62d280f141d26dff4989272c6c03a365a6d7e91cbca813263e3230cc56184", + "sha256:67994ba911698033ed2b8e4c2d56fa6ad17f443066327e4b7f836a8beb611603", + "sha256:a8158baa3a5f43e7ae66b76a3f26c5536df615030617cb53965ca20f01297ec1" + ], + "uncovered_waiver": { + "reason": "OMN-14822 hand-flips node_invariant_evaluate_compute to canonical def-B (OMN-14781 path): the three thin evaluator delegations move onto HandlerInvariantEvaluate/Batch/All.handle(request), and the shared _new_evaluator helper plus the HandlerInvariantEvaluate classification properties are re-derived byte-identical base_ref<->HEAD by the ratchet from git. Equivalence is proven by that git-verbatim preservation of the handler-module glue plus the green runtime-dispatch + full-corpus parity tests over the selected input corpus, not by a broad branch-coverage claim over the legacy handler body; the real invariant-evaluation logic lives in the untouched InvariantEvaluator (evaluator_invariant.py), whose branches are covered by test_evaluator_invariant_branches.py, so the thin handler module's own branch coverage over the deterministic dispatch corpus is measured but intentionally not the equivalence proof.", + "uncovered_branch_count": 0 + }, + "volatile_mask": [] +} diff --git a/scripts/ci/canonical_handler_shape_baseline.py b/scripts/ci/canonical_handler_shape_baseline.py index 3d4ca07a2c..a7f1592bfe 100644 --- a/scripts/ci/canonical_handler_shape_baseline.py +++ b/scripts/ci/canonical_handler_shape_baseline.py @@ -45,7 +45,6 @@ "omnibase_infra.nodes.node_emit_daemon_runtime", "omnibase_infra.nodes.node_github_pr_poller_effect", "omnibase_infra.nodes.node_impact_analyzer_compute", - "omnibase_infra.nodes.node_invariant_evaluate_compute", "omnibase_infra.nodes.node_kafka_replay_compute", "omnibase_infra.nodes.node_ledger_projection_compute", "omnibase_infra.nodes.node_ledger_projection_compute.handlers", diff --git a/src/omnibase_infra/nodes/node_invariant_evaluate_compute/contract.yaml b/src/omnibase_infra/nodes/node_invariant_evaluate_compute/contract.yaml index 40fd8efb8f..9c4a18b5ca 100644 --- a/src/omnibase_infra/nodes/node_invariant_evaluate_compute/contract.yaml +++ b/src/omnibase_infra/nodes/node_invariant_evaluate_compute/contract.yaml @@ -1,12 +1,17 @@ # SPDX-FileCopyrightText: 2025 OmniNode.ai Inc. # SPDX-License-Identifier: MIT +# Related Tickets: +# - OMN-11548: node_invariant_evaluate_compute (original migration) +# - OMN-14822: canonical def-B flip — each operation now routes to its own +# def-B handler class exposing handle(request) -> response (Class-B Tier-1, +# parent epic OMN-14355; hand-flip proof path OMN-14781). name: "node_invariant_evaluate_compute" contract_name: "node_invariant_evaluate_compute" node_name: "node_invariant_evaluate_compute" contract_version: major: 1 minor: 0 - patch: 0 + patch: 1 node_version: major: 1 minor: 0 @@ -30,27 +35,21 @@ handler_routing: - operation: "invariant.evaluate" handler: name: "HandlerInvariantEvaluate" - function: "handle_invariant_evaluate" module: "omnibase_infra.nodes.node_invariant_evaluate_compute.handlers.handler_invariant_evaluate" - type: "async" input_model: "ModelInvariantEvaluateInput" output_model: "ModelInvariantResult" description: "Evaluate one invariant against an output payload." - operation: "invariant.evaluate_batch" handler: - name: "HandlerInvariantEvaluate" - function: "handle_invariant_evaluate_batch" + name: "HandlerInvariantEvaluateBatch" module: "omnibase_infra.nodes.node_invariant_evaluate_compute.handlers.handler_invariant_evaluate" - type: "async" input_model: "ModelInvariantEvaluateBatchInput" output_model: "list[ModelInvariantResult]" description: "Evaluate an invariant set and return ordered results." - operation: "invariant.evaluate_all" handler: - name: "HandlerInvariantEvaluate" - function: "handle_invariant_evaluate_all" + name: "HandlerInvariantEvaluateAll" module: "omnibase_infra.nodes.node_invariant_evaluate_compute.handlers.handler_invariant_evaluate" - type: "async" input_model: "ModelInvariantEvaluateAllInput" output_model: "ModelEvaluationSummary" description: "Evaluate an invariant set and return summary statistics." diff --git a/src/omnibase_infra/nodes/node_invariant_evaluate_compute/handlers/__init__.py b/src/omnibase_infra/nodes/node_invariant_evaluate_compute/handlers/__init__.py index 8afe785c10..1b9fd2c7ed 100644 --- a/src/omnibase_infra/nodes/node_invariant_evaluate_compute/handlers/__init__.py +++ b/src/omnibase_infra/nodes/node_invariant_evaluate_compute/handlers/__init__.py @@ -4,13 +4,13 @@ """Handlers for invariant evaluation compute.""" from omnibase_infra.nodes.node_invariant_evaluate_compute.handlers.handler_invariant_evaluate import ( - handle_invariant_evaluate, - handle_invariant_evaluate_all, - handle_invariant_evaluate_batch, + HandlerInvariantEvaluate, + HandlerInvariantEvaluateAll, + HandlerInvariantEvaluateBatch, ) __all__ = [ - "handle_invariant_evaluate", - "handle_invariant_evaluate_all", - "handle_invariant_evaluate_batch", + "HandlerInvariantEvaluate", + "HandlerInvariantEvaluateAll", + "HandlerInvariantEvaluateBatch", ] diff --git a/src/omnibase_infra/nodes/node_invariant_evaluate_compute/handlers/handler_invariant_evaluate.py b/src/omnibase_infra/nodes/node_invariant_evaluate_compute/handlers/handler_invariant_evaluate.py index 8d098abfbb..8a49faf9de 100644 --- a/src/omnibase_infra/nodes/node_invariant_evaluate_compute/handlers/handler_invariant_evaluate.py +++ b/src/omnibase_infra/nodes/node_invariant_evaluate_compute/handlers/handler_invariant_evaluate.py @@ -1,7 +1,7 @@ # SPDX-FileCopyrightText: 2025 OmniNode.ai Inc. # SPDX-License-Identifier: MIT -"""Runtime handlers for invariant evaluation compute.""" +"""Runtime handlers for invariant evaluation compute (canonical definition B).""" from __future__ import annotations @@ -17,8 +17,15 @@ ) +def _new_evaluator( + allowed_import_paths: list[str] | None, +) -> InvariantEvaluator: + """Create a per-request evaluator to avoid sharing mutable schema cache.""" + return InvariantEvaluator(allowed_import_paths=allowed_import_paths) + + class HandlerInvariantEvaluate: - """Handler descriptor for deterministic invariant evaluation operations.""" + """Canonical def-B handler: evaluate one invariant against an output payload.""" @property def handler_type(self) -> EnumHandlerType: @@ -28,49 +35,62 @@ def handler_type(self) -> EnumHandlerType: def handler_category(self) -> EnumHandlerTypeCategory: return EnumHandlerTypeCategory.COMPUTE + async def handle( + self, request: ModelInvariantEvaluateInput + ) -> ModelInvariantResult: + """Evaluate one invariant against an output payload.""" + evaluator = _new_evaluator(request.allowed_import_paths) + return evaluator.evaluate(request.invariant, request.output) -def _new_evaluator( - allowed_import_paths: list[str] | None, -) -> InvariantEvaluator: - """Create a per-request evaluator to avoid sharing mutable schema cache.""" - return InvariantEvaluator(allowed_import_paths=allowed_import_paths) +class HandlerInvariantEvaluateBatch: + """Canonical def-B handler: evaluate an invariant set, ordered per-invariant results.""" -async def handle_invariant_evaluate( - input_data: ModelInvariantEvaluateInput, -) -> ModelInvariantResult: - """Evaluate one invariant against an output payload.""" - evaluator = _new_evaluator(input_data.allowed_import_paths) - return evaluator.evaluate(input_data.invariant, input_data.output) + @property + def handler_type(self) -> EnumHandlerType: + return EnumHandlerType.COMPUTE_HANDLER + @property + def handler_category(self) -> EnumHandlerTypeCategory: + return EnumHandlerTypeCategory.COMPUTE -async def handle_invariant_evaluate_batch( - input_data: ModelInvariantEvaluateBatchInput, -) -> list[ModelInvariantResult]: - """Evaluate an invariant set and return ordered per-invariant results.""" - evaluator = _new_evaluator(input_data.allowed_import_paths) - return evaluator.evaluate_batch( - input_data.invariant_set, - input_data.output, - enabled_only=input_data.enabled_only, - ) + async def handle( + self, request: ModelInvariantEvaluateBatchInput + ) -> list[ModelInvariantResult]: + """Evaluate an invariant set and return ordered per-invariant results.""" + evaluator = _new_evaluator(request.allowed_import_paths) + return evaluator.evaluate_batch( + request.invariant_set, + request.output, + enabled_only=request.enabled_only, + ) -async def handle_invariant_evaluate_all( - input_data: ModelInvariantEvaluateAllInput, -) -> ModelEvaluationSummary: - """Evaluate an invariant set and return aggregate summary statistics.""" - evaluator = _new_evaluator(input_data.allowed_import_paths) - return evaluator.evaluate_all( - input_data.invariant_set, - input_data.output, - fail_fast=input_data.fail_fast, - ) +class HandlerInvariantEvaluateAll: + """Canonical def-B handler: evaluate an invariant set, aggregate summary statistics.""" + + @property + def handler_type(self) -> EnumHandlerType: + return EnumHandlerType.COMPUTE_HANDLER + + @property + def handler_category(self) -> EnumHandlerTypeCategory: + return EnumHandlerTypeCategory.COMPUTE + + async def handle( + self, request: ModelInvariantEvaluateAllInput + ) -> ModelEvaluationSummary: + """Evaluate an invariant set and return aggregate summary statistics.""" + evaluator = _new_evaluator(request.allowed_import_paths) + return evaluator.evaluate_all( + request.invariant_set, + request.output, + fail_fast=request.fail_fast, + ) __all__ = [ "HandlerInvariantEvaluate", - "handle_invariant_evaluate", - "handle_invariant_evaluate_all", - "handle_invariant_evaluate_batch", + "HandlerInvariantEvaluateAll", + "HandlerInvariantEvaluateBatch", ] diff --git a/tests/integration/test_omn_11548_invariant_evaluate_compute.py b/tests/integration/test_omn_11548_invariant_evaluate_compute.py index cebd10039f..f3a3ba4d1a 100644 --- a/tests/integration/test_omn_11548_invariant_evaluate_compute.py +++ b/tests/integration/test_omn_11548_invariant_evaluate_compute.py @@ -1,7 +1,12 @@ # SPDX-FileCopyrightText: 2025 OmniNode.ai Inc. # SPDX-License-Identifier: MIT -"""Integration coverage for the OMN-11548 invariant evaluation compute node.""" +"""Integration coverage for the OMN-11548 invariant evaluation compute node. + +OMN-14822 flipped the node to canonical def-B: contract routing now binds each +operation to a def-B handler class (``name`` + ``module``, no ``function``), and +the behavior lives on ``HandlerInvariantEvaluate.handle(request) -> response``. +""" from __future__ import annotations @@ -13,7 +18,7 @@ from omnibase_core.enums import EnumInvariantType, EnumSeverity from omnibase_core.models.invariant import ModelInvariant from omnibase_infra.nodes.node_invariant_evaluate_compute.handlers import ( - handle_invariant_evaluate, + HandlerInvariantEvaluate, ) from omnibase_infra.nodes.node_invariant_evaluate_compute.models import ( ModelInvariantEvaluateInput, @@ -34,9 +39,11 @@ async def test_invariant_evaluate_compute_contract_routes_to_handler() -> None: handler = contract["handler_routing"]["handlers"][0]["handler"] assert handler["module"].endswith("handler_invariant_evaluate") - assert handler["function"] == "handle_invariant_evaluate" + assert handler["name"] == "HandlerInvariantEvaluate" + # Canonical def-B routing binds by name+module only; no legacy function field. + assert "function" not in handler - result = await handle_invariant_evaluate( + result = await HandlerInvariantEvaluate().handle( ModelInvariantEvaluateInput( invariant=ModelInvariant( name="Status Present", diff --git a/tests/unit/nodes/node_invariant_evaluate_compute/test_handler_invariant_evaluate.py b/tests/unit/nodes/node_invariant_evaluate_compute/test_handler_invariant_evaluate.py index 75bc717e05..9596976927 100644 --- a/tests/unit/nodes/node_invariant_evaluate_compute/test_handler_invariant_evaluate.py +++ b/tests/unit/nodes/node_invariant_evaluate_compute/test_handler_invariant_evaluate.py @@ -1,7 +1,7 @@ # SPDX-FileCopyrightText: 2025 OmniNode.ai Inc. # SPDX-License-Identifier: MIT -"""Tests for node_invariant_evaluate_compute.""" +"""Tests for node_invariant_evaluate_compute (canonical def-B handlers).""" from __future__ import annotations @@ -14,9 +14,9 @@ InvariantEvaluator, ) from omnibase_infra.nodes.node_invariant_evaluate_compute.handlers import ( - handle_invariant_evaluate, - handle_invariant_evaluate_all, - handle_invariant_evaluate_batch, + HandlerInvariantEvaluate, + HandlerInvariantEvaluateAll, + HandlerInvariantEvaluateBatch, ) from omnibase_infra.nodes.node_invariant_evaluate_compute.models import ( ModelInvariantEvaluateAllInput, @@ -55,10 +55,27 @@ def test_node_invariant_evaluate_compute_is_declarative_shell() -> None: assert isinstance(node, NodeInvariantEvaluateCompute) +@pytest.mark.unit +def test_handlers_expose_canonical_handle_entrypoint() -> None: + """Every def-B handler must expose a callable ``handle`` dispatch entrypoint. + + Auto-wiring's ``_make_dispatch_callback`` binds ``handle_async`` then ``handle``; + a handler exposing neither binds ``_missing_handle`` and raises on first dispatch. + """ + for handler_cls in ( + HandlerInvariantEvaluate, + HandlerInvariantEvaluateBatch, + HandlerInvariantEvaluateAll, + ): + assert callable(getattr(handler_cls, "handle", None)), ( + f"{handler_cls.__name__} exposes no callable handle() entrypoint." + ) + + @pytest.mark.unit @pytest.mark.asyncio async def test_handle_invariant_evaluate_returns_single_result() -> None: - result = await handle_invariant_evaluate( + result = await HandlerInvariantEvaluate().handle( ModelInvariantEvaluateInput( invariant=_field_presence_invariant(), output={"status": "ok"}, @@ -81,7 +98,7 @@ async def test_handle_invariant_evaluate_batch_preserves_order() -> None: ], ) - results = await handle_invariant_evaluate_batch( + results = await HandlerInvariantEvaluateBatch().handle( ModelInvariantEvaluateBatchInput( invariant_set=invariant_set, output={"status": "ok", "score": 0.8}, @@ -107,7 +124,7 @@ async def test_handle_invariant_evaluate_all_summarizes_failures() -> None: ], ) - summary = await handle_invariant_evaluate_all( + summary = await HandlerInvariantEvaluateAll().handle( ModelInvariantEvaluateAllInput( invariant_set=invariant_set, output={"score": 0.2}, diff --git a/tests/unit/nodes/node_invariant_evaluate_compute/test_invariant_evaluate_dispatch_entrypoint_omn14822.py b/tests/unit/nodes/node_invariant_evaluate_compute/test_invariant_evaluate_dispatch_entrypoint_omn14822.py new file mode 100644 index 0000000000..02fca838e7 --- /dev/null +++ b/tests/unit/nodes/node_invariant_evaluate_compute/test_invariant_evaluate_dispatch_entrypoint_omn14822.py @@ -0,0 +1,262 @@ +# SPDX-FileCopyrightText: 2025 OmniNode.ai Inc. +# SPDX-License-Identifier: MIT +"""OMN-14822 — the invariant-evaluate handlers are REACHABLE through real dispatch. + +RED-against-EXISTS-but-WRONG proof for the canonical def-B flip (hand-flip, OMN-14781). + +Before this ticket ``node_invariant_evaluate_compute`` declared three +``operation_match`` operations whose only executable code was module-level async +functions (``handle_invariant_evaluate`` / ``_batch`` / ``_all``). The descriptor class +``HandlerInvariantEvaluate`` exposed NO ``handle`` / ``handle_async``. Auto-wiring's +``_make_dispatch_callback`` looks for ``handle_async`` then ``handle``; finding neither +it binds ``_missing_handle``, which raises:: + + ModelOnexError: Auto-wired handler HandlerInvariantEvaluate does not expose a + callable handle() or handle_async() dispatch entrypoint. + +...on the FIRST real dispatch. So the node was contract-declared, wired, ingress-valid +and CI-green while being non-executable through the real dispatch path. + +These tests drive the REAL production dispatch callback over the REAL def-B handler +classes (no fake handler, no patched entrypoint). They fail against the pre-flip +descriptor-only handler (``_missing_handle`` → ``ModelOnexError``) and pass only once the +def-B ``handle`` entrypoints exist. The flip is a hand-flip that moves the three thin +delegations onto ``Handler*.handle``; the shared ``_new_evaluator`` helper and the +``HandlerInvariantEvaluate`` classification properties are byte-identical base_ref↔HEAD, +which the canonical-shape ratchet re-derives from git (the ``.handflip.json`` proof). +The ``InvariantEvaluator`` business logic (``evaluator_invariant.py``) is untouched. + +``build_selected_corpus`` below is the exact SELECTED input corpus bound (by +``input_hash``) into both the adequacy receipt +(``scripts/ci/adequacy_receipts/omnibase_infra.nodes.node_invariant_evaluate_compute.json``) +and the hand-flip proof (``...handflip.json``). It uses fixed UUIDs / timestamps so the +input hashes are deterministic and reproducible. +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +import pytest + +from omnibase_core.enums import EnumInvariantType, EnumSeverity +from omnibase_core.models.errors import ModelOnexError +from omnibase_core.models.events.model_event_envelope import ModelEventEnvelope +from omnibase_core.models.invariant import ModelInvariant, ModelInvariantSet +from omnibase_infra.enums import EnumDispatchStatus +from omnibase_infra.nodes.node_invariant_evaluate_compute.handlers.handler_invariant_evaluate import ( + HandlerInvariantEvaluate, + HandlerInvariantEvaluateAll, + HandlerInvariantEvaluateBatch, +) +from omnibase_infra.nodes.node_invariant_evaluate_compute.models import ( + ModelInvariantEvaluateAllInput, + ModelInvariantEvaluateBatchInput, + ModelInvariantEvaluateInput, +) +from omnibase_infra.runtime.auto_wiring.handler_wiring import _make_dispatch_callback + +pytestmark = [pytest.mark.unit] + +# Deterministic ids/timestamps so the SELECTED corpus hashes reproducibly. +_TS = datetime(2026, 1, 1, 0, 0, 0, tzinfo=UTC) + + +def _inv( + uid: str, name: str, itype: EnumInvariantType, sev: EnumSeverity, cfg: dict +) -> ModelInvariant: + return ModelInvariant(id=UUID(uid), name=name, type=itype, severity=sev, config=cfg) + + +def _iset(uid: str, name: str, invs: list[ModelInvariant]) -> ModelInvariantSet: + return ModelInvariantSet( + id=UUID(uid), created_at=_TS, name=name, target="test", invariants=invs + ) + + +_FIELD = _inv( + "00000000-0000-0000-0000-000000000001", + "Field Check", + EnumInvariantType.FIELD_PRESENCE, + EnumSeverity.CRITICAL, + {"fields": ["status"]}, +) +_THRESHOLD = _inv( + "00000000-0000-0000-0000-000000000002", + "Threshold Check", + EnumInvariantType.THRESHOLD, + EnumSeverity.WARNING, + {"metric_name": "score", "min_value": 0.5}, +) + + +def build_selected_corpus() -> list[tuple[str, str, object]]: + """The (case_id, operation, input) SELECTED corpus bound into both receipts. + + Deterministic: fixed invariant/set ids + timestamps → reproducible input hashes. + """ + single_set = _iset( + "00000000-0000-0000-0000-000000000010", "Mixed", [_FIELD, _THRESHOLD] + ) + return [ + ( + "E1_field_pass", + "invariant.evaluate", + ModelInvariantEvaluateInput(invariant=_FIELD, output={"status": "ok"}), + ), + ( + "E2_field_fail", + "invariant.evaluate", + ModelInvariantEvaluateInput(invariant=_FIELD, output={"other": "x"}), + ), + ( + "E3_threshold_pass", + "invariant.evaluate", + ModelInvariantEvaluateInput(invariant=_THRESHOLD, output={"score": 0.8}), + ), + ( + "B1_batch_mixed", + "invariant.evaluate_batch", + ModelInvariantEvaluateBatchInput( + invariant_set=single_set, output={"status": "ok", "score": 0.8} + ), + ), + ( + "A1_all_fail_soft", + "invariant.evaluate_all", + ModelInvariantEvaluateAllInput( + invariant_set=single_set, output={"score": 0.2}, fail_fast=False + ), + ), + ( + "A2_all_fail_fast", + "invariant.evaluate_all", + ModelInvariantEvaluateAllInput( + invariant_set=single_set, output={"score": 0.2}, fail_fast=True + ), + ), + ] + + +_CORPUS = build_selected_corpus() +_EVAL_CASES = [ + (cid, payload) for cid, op, payload in _CORPUS if op == "invariant.evaluate" +] +_EVAL_IDS = [c[0] for c in _EVAL_CASES] + + +@pytest.mark.unit +def test_handlers_expose_handle_entrypoint_and_no_legacy_shim() -> None: + """Bare invariant: auto-wiring can only bind handle/handle_async (RED pre-flip). + + Also asserts the flip is a real move, not a retained shim: the legacy module-level + ``handle_invariant_evaluate*`` functions are gone. + """ + for cls in ( + HandlerInvariantEvaluate, + HandlerInvariantEvaluateBatch, + HandlerInvariantEvaluateAll, + ): + assert callable(getattr(cls, "handle", None)) or callable( + getattr(cls, "handle_async", None) + ), f"{cls.__name__} exposes neither handle() nor handle_async()." + + import omnibase_infra.nodes.node_invariant_evaluate_compute.handlers.handler_invariant_evaluate as mod + + for legacy in ( + "handle_invariant_evaluate", + "handle_invariant_evaluate_batch", + "handle_invariant_evaluate_all", + ): + assert not hasattr(mod, legacy), ( + f"legacy free function {legacy} must be removed (no retained shim)." + ) + + +@pytest.mark.unit +@pytest.mark.asyncio +@pytest.mark.parametrize(("case_id", "payload"), _EVAL_CASES, ids=_EVAL_IDS) +async def test_real_dispatch_callback_reaches_handle( + case_id: str, payload: ModelInvariantEvaluateInput +) -> None: + """LOAD-BEARING: a real payload dispatched through the REAL auto-wiring callback + reaches ``handle`` and yields a SUCCESS dispatch result. + + Against the descriptor-only pre-flip handler this raises ModelOnexError + (_missing_handle) rather than returning a result — that raise IS the bug this + flip closes. ``operation_match`` declares no ``event_model``, so this exercises + the untyped def-B coercion arm (OMN-14716) exactly as production does. + """ + callback = _make_dispatch_callback(HandlerInvariantEvaluate(), None) + envelope: ModelEventEnvelope[object] = ModelEventEnvelope( + payload=payload, + correlation_id=uuid4(), + event_type="ModelInvariantEvaluateInput", + ) + result = await callback(envelope) + + assert result is not None, "Dispatch produced no result — handle never ran." + assert result.status is EnumDispatchStatus.SUCCESS, ( + f"Expected SUCCESS dispatch status, got {result.status!r}." + ) + assert result.output_count == 1 + attestation = result.output_events[0] + assert attestation.invariant_name == payload.invariant.name + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_missing_handle_raises_before_flip_is_the_red() -> None: + """Documents the exact RED the flip closes: a descriptor-only handler exposing + only the legacy operation function binds ``_missing_handle`` and raises.""" + + class _DescriptorOnlyLikeLegacy: + """Stand-in with only the legacy operation method (the pre-flip shape).""" + + async def handle_invariant_evaluate( + self, request: ModelInvariantEvaluateInput + ) -> object: + raise AssertionError("unreachable: auto-wiring never binds this name") + + callback = _make_dispatch_callback(_DescriptorOnlyLikeLegacy(), None) + envelope: ModelEventEnvelope[object] = ModelEventEnvelope( + payload=ModelInvariantEvaluateInput(invariant=_FIELD, output={"status": "ok"}), + correlation_id=uuid4(), + event_type="ModelInvariantEvaluateInput", + ) + with pytest.raises(ModelOnexError, match="does not expose a callable handle"): + await callback(envelope) + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_selected_corpus_behavior_equivalence() -> None: + """Behavior equivalence over the full SELECTED corpus, all three operations, driven + through the REAL def-B ``handle`` entrypoints (parity attestation for the receipts).""" + handlers = { + "invariant.evaluate": HandlerInvariantEvaluate(), + "invariant.evaluate_batch": HandlerInvariantEvaluateBatch(), + "invariant.evaluate_all": HandlerInvariantEvaluateAll(), + } + results: dict[str, object] = {} + for case_id, operation, payload in _CORPUS: + results[case_id] = await handlers[operation].handle(payload) + + # evaluate op + assert results["E1_field_pass"].passed is True + assert results["E2_field_fail"].passed is False + assert results["E3_threshold_pass"].passed is True + # batch op — ordered, both pass on compliant output + batch = results["B1_batch_mixed"] + assert [r.invariant_name for r in batch] == ["Field Check", "Threshold Check"] + assert all(r.passed for r in batch) + # all op — soft: both fail, aggregate counts + soft = results["A1_all_fail_soft"] + assert soft.overall_passed is False + assert soft.critical_failures == 1 + assert soft.warning_failures == 1 + # all op — fail_fast stops at first critical failure + fast = results["A2_all_fail_fast"] + assert fast.overall_passed is False + assert fast.critical_failures == 1