diff --git a/scripts/crossrepo_contract.py b/scripts/crossrepo_contract.py index 971f20c..0ec0bda 100644 --- a/scripts/crossrepo_contract.py +++ b/scripts/crossrepo_contract.py @@ -859,8 +859,14 @@ def _tiled_chain_case(*, compression: str | None = None, xip: bool = False) -> C def _qdq_case(operator: str) -> ContractCase: - """Build a QDQ Conv or AveragePool model with an int8 ORT reference.""" - output_shape = [1, 1, 4, 4] if operator == "Conv" else [1, 1, 2, 2] + """Build a QDQ Conv, ConvTranspose, or AveragePool model with an int8 ORT reference.""" + if operator == "Conv": + output_shape = [1, 1, 4, 4] + elif operator == "ConvTranspose": + # stride 2, kernel 2, pad 0: 2 * (4 - 1) + 2 = 8 on each spatial axis. + output_shape = [1, 1, 8, 8] + else: + output_shape = [1, 1, 2, 2] model_input = helper.make_tensor_value_info( "input", TensorProto.FLOAT, [1, 1, 4, 4] ) @@ -927,6 +933,43 @@ def _qdq_case(operator: str) -> ContractCase: helper.make_node("Conv", ["input_dq", "weight_dq"], ["raw"]), ] ) + elif operator == "ConvTranspose": + # ONNX ConvTranspose weight is [C_in, C_out, kH, kW]; here 1 -> 1 with a + # 2x2 kernel, stride 2, pad 0, group 1. Weight values are exact + # multiples of the weight scale so the fake-quant is lossless. + weight = numpy_helper.from_array( + np.array([[[[0.5, -0.25], [0.25, 0.75]]]], dtype=np.float32), "weight" + ) + weight_scale = numpy_helper.from_array( + np.array([0.25], dtype=np.float32), "weight_scale" + ) + weight_zero_point = numpy_helper.from_array( + np.array([0], dtype=np.int8), "weight_zero_point" + ) + initializers.extend([weight, weight_scale, weight_zero_point]) + nodes.extend( + [ + helper.make_node( + "QuantizeLinear", + ["weight", "weight_scale", "weight_zero_point"], + ["weight_q"], + ), + helper.make_node( + "DequantizeLinear", + ["weight_q", "weight_scale", "weight_zero_point"], + ["weight_dq"], + ), + helper.make_node( + "ConvTranspose", + ["input_dq", "weight_dq"], + ["raw"], + kernel_shape=[2, 2], + strides=[2, 2], + pads=[0, 0, 0, 0], + group=1, + ), + ] + ) else: nodes.append( helper.make_node( @@ -980,6 +1023,382 @@ def _qdq_case(operator: str) -> ContractCase: ) +def _convtranspose_case() -> ContractCase: + """A standalone float ConvTranspose upsampler (stride 2, kernel 2, pad 0). + + The ONNX ConvTranspose weight is [C_in, C_out, kH, kW]; the compiler + transposes it to the runtime's OHWI layout. Output height/width follow the + standard relation stride * (in - 1) + kernel - pad_begin - pad_end, so a + 4x4 input upsamples to 8x8. The compile and ORT reference models are + identical, as in the other single-operator float cases. + """ + model_input = helper.make_tensor_value_info( + "input", TensorProto.FLOAT, [1, 2, 4, 4] + ) + model_output = helper.make_tensor_value_info( + "output", TensorProto.FLOAT, [1, 3, 8, 8] + ) + rng = np.random.default_rng(19) + weights = numpy_helper.from_array( + rng.normal(0.0, 0.5, size=(2, 3, 2, 2)).astype(np.float32), "weights" + ) + bias = numpy_helper.from_array( + rng.normal(0.0, 0.5, size=(3,)).astype(np.float32), "bias" + ) + model = _model( + "convtranspose", + [ + helper.make_node( + "ConvTranspose", + ["input", "weights", "bias"], + ["output"], + kernel_shape=[2, 2], + strides=[2, 2], + pads=[0, 0, 0, 0], + group=1, + ) + ], + [model_input], + [model_output], + [weights, bias], + ) + return ContractCase( + "float_convtranspose", + model, + model, + {"input": rng.uniform(-1.0, 1.0, size=(1, 2, 4, 4)).astype(np.float32)}, + ("ConvTranspose",), + ) + + +def _conv_then_convtranspose_case() -> ContractCase: + """A strided Conv downsampler feeding a ConvTranspose upsampler. + + Conv (stride 2, kernel 2, pad 0) halves an 8x8 input to 4x4, then + ConvTranspose (stride 2, kernel 2, pad 0) restores 8x8: the encoder-then- + upsample shape that motivates ConvTranspose support. Both stages keep + group 1 so the Conv is not relabeled DepthwiseConv. + """ + model_input = helper.make_tensor_value_info( + "input", TensorProto.FLOAT, [1, 1, 8, 8] + ) + model_output = helper.make_tensor_value_info( + "output", TensorProto.FLOAT, [1, 1, 8, 8] + ) + rng = np.random.default_rng(23) + conv_weights = numpy_helper.from_array( + rng.normal(0.0, 0.5, size=(2, 1, 2, 2)).astype(np.float32), "conv_weights" + ) + convt_weights = numpy_helper.from_array( + rng.normal(0.0, 0.5, size=(2, 1, 2, 2)).astype(np.float32), "convt_weights" + ) + model = _model( + "conv_then_convtranspose", + [ + helper.make_node( + "Conv", + ["input", "conv_weights"], + ["mid"], + kernel_shape=[2, 2], + strides=[2, 2], + pads=[0, 0, 0, 0], + group=1, + ), + helper.make_node( + "ConvTranspose", + ["mid", "convt_weights"], + ["output"], + kernel_shape=[2, 2], + strides=[2, 2], + pads=[0, 0, 0, 0], + group=1, + ), + ], + [model_input], + [model_output], + [conv_weights, convt_weights], + ) + return ContractCase( + "float_conv_then_convtranspose", + model, + model, + {"input": rng.uniform(-1.0, 1.0, size=(1, 1, 8, 8)).astype(np.float32)}, + ("Conv", "ConvTranspose"), + ) + + +def _convtranspose_overlap_case() -> ContractCase: + """A float ConvTranspose whose kernel exceeds its stride, so multiple taps + overlap-and-sum into each output pixel. + + A 4x4 kernel with stride 2 and pad 1 is the classic U-Net upsampler: + output = stride * (in - 1) + kernel - pad_begin - pad_end = 2 * in, so a + 4x4 input doubles to 8x8. Because kernel (4) exceeds stride (2), up to two + taps per axis (four total) accumulate into one output pixel, exercising the + gather kernel's multi-tap accumulation against the ORT oracle rather than + the single-tap stride==kernel path the other ConvTranspose cases cover. + """ + model_input = helper.make_tensor_value_info( + "input", TensorProto.FLOAT, [1, 2, 4, 4] + ) + model_output = helper.make_tensor_value_info( + "output", TensorProto.FLOAT, [1, 3, 8, 8] + ) + rng = np.random.default_rng(29) + weights = numpy_helper.from_array( + rng.normal(0.0, 0.5, size=(2, 3, 4, 4)).astype(np.float32), "weights" + ) + bias = numpy_helper.from_array( + rng.normal(0.0, 0.5, size=(3,)).astype(np.float32), "bias" + ) + model = _model( + "convtranspose_overlap", + [ + helper.make_node( + "ConvTranspose", + ["input", "weights", "bias"], + ["output"], + kernel_shape=[4, 4], + strides=[2, 2], + pads=[1, 1, 1, 1], + group=1, + ) + ], + [model_input], + [model_output], + [weights, bias], + ) + return ContractCase( + "float_convtranspose_overlap", + model, + model, + {"input": rng.uniform(-1.0, 1.0, size=(1, 2, 4, 4)).astype(np.float32)}, + ("ConvTranspose",), + ) + + +def _convtranspose_output_padding_case() -> ContractCase: + """A float ConvTranspose with a nonzero output_padding attribute. + + Kernel 3, stride 2, symmetric pad 1: per the ONNX formula output = stride + * (in - 1) + output_padding + kernel - pad_begin - pad_end, a 4x4 input + with output_padding=0 would upsample to 7x7. Setting output_padding=[1,1] + adds the extra trailing row/column to reach 8x8. The compiler reads that + output shape from ONNX's own shape inference rather than re-deriving it + (the runtime's gather kernel bounds itself against the allocated output + extent, not a shrink formula), so this exercises the case the other + ConvTranspose cases leave untested: the compiled output shape must match + what output_padding actually produces, not what it would be without it. + """ + model_input = helper.make_tensor_value_info( + "input", TensorProto.FLOAT, [1, 2, 4, 4] + ) + model_output = helper.make_tensor_value_info( + "output", TensorProto.FLOAT, [1, 3, 8, 8] + ) + rng = np.random.default_rng(37) + weights = numpy_helper.from_array( + rng.normal(0.0, 0.5, size=(2, 3, 3, 3)).astype(np.float32), "weights" + ) + bias = numpy_helper.from_array( + rng.normal(0.0, 0.5, size=(3,)).astype(np.float32), "bias" + ) + model = _model( + "convtranspose_output_padding", + [ + helper.make_node( + "ConvTranspose", + ["input", "weights", "bias"], + ["output"], + kernel_shape=[3, 3], + strides=[2, 2], + pads=[1, 1, 1, 1], + output_padding=[1, 1], + group=1, + ) + ], + [model_input], + [model_output], + [weights, bias], + ) + return ContractCase( + "float_convtranspose_output_padding", + model, + model, + {"input": rng.uniform(-1.0, 1.0, size=(1, 2, 4, 4)).astype(np.float32)}, + ("ConvTranspose",), + ) + + +def _convtranspose_asymmetric_pad_case() -> ContractCase: + """A float ConvTranspose with asymmetric pads (top != bottom, left != right). + + Kernel 3, stride 2, pads=[0, 1, 1, 0] (ONNX order [h_begin, w_begin, + h_end, w_end]): pad_top=0/pad_bottom=1 on height, pad_left=1/pad_right=0 + on width. Both other ConvTranspose cases in this file use symmetric pads, + so this is the only case where a pad_top/pad_bottom or pad_left/pad_right + mixup in the compiler or the gather kernel's per-axis pad indexing would + surface as a mismatch against the ORT oracle. + """ + model_input = helper.make_tensor_value_info( + "input", TensorProto.FLOAT, [1, 2, 4, 4] + ) + model_output = helper.make_tensor_value_info( + "output", TensorProto.FLOAT, [1, 3, 8, 8] + ) + rng = np.random.default_rng(41) + weights = numpy_helper.from_array( + rng.normal(0.0, 0.5, size=(2, 3, 3, 3)).astype(np.float32), "weights" + ) + bias = numpy_helper.from_array( + rng.normal(0.0, 0.5, size=(3,)).astype(np.float32), "bias" + ) + model = _model( + "convtranspose_asymmetric_pad", + [ + helper.make_node( + "ConvTranspose", + ["input", "weights", "bias"], + ["output"], + kernel_shape=[3, 3], + strides=[2, 2], + pads=[0, 1, 1, 0], + group=1, + ) + ], + [model_input], + [model_output], + [weights, bias], + ) + return ContractCase( + "float_convtranspose_asymmetric_pad", + model, + model, + {"input": rng.uniform(-1.0, 1.0, size=(1, 2, 4, 4)).astype(np.float32)}, + ("ConvTranspose",), + ) + + +def _qdq_convtranspose_per_channel_case() -> ContractCase: + """Per-channel int8 QDQ ConvTranspose with multiple output channels. + + ONNX ConvTranspose weight is [C_in, C_out, kH, kW], so per-output-channel + weight quantization uses axis=1 with a length-C_out scale vector, NOT + axis=0 as for Conv (whose weight is [C_out, C_in, kH, kW]). This case uses + C_in=2, C_out=3 with a distinct per-channel weight scale so a wrong + output-channel axis anywhere in the int8 requant path (the effective-scale + indexing) would diverge from the ORT reference beyond one LSB. Matched to + one LSB like the other int8 cases. + """ + c_in, c_out = 2, 3 + input_shape = [1, c_in, 4, 4] + output_shape = [1, c_out, 8, 8] + model_input = helper.make_tensor_value_info( + "input", TensorProto.FLOAT, input_shape + ) + model_output = helper.make_tensor_value_info( + "output", TensorProto.FLOAT, output_shape + ) + int8_output = helper.make_tensor_value_info( + "output_q", TensorProto.INT8, output_shape + ) + + rng = np.random.default_rng(31) + input_scale = numpy_helper.from_array( + np.array([0.25], dtype=np.float32), "input_scale" + ) + input_zero_point = numpy_helper.from_array( + np.array([0], dtype=np.int8), "input_zero_point" + ) + output_scale = numpy_helper.from_array( + np.array([0.05], dtype=np.float32), "output_scale" + ) + output_zero_point = numpy_helper.from_array( + np.array([0], dtype=np.int8), "output_zero_point" + ) + weight = numpy_helper.from_array( + rng.normal(0.0, 0.2, size=(c_in, c_out, 2, 2)).astype(np.float32), "weight" + ) + # Per output channel (axis=1): one scale and zero-point per C_out. The + # scales differ per channel so a swapped axis mismatches every channel. + weight_scale = numpy_helper.from_array( + np.array([0.02, 0.03, 0.015], dtype=np.float32), "weight_scale" + ) + weight_zero_point = numpy_helper.from_array( + np.zeros(c_out, dtype=np.int8), "weight_zero_point" + ) + initializers = [ + input_scale, + input_zero_point, + output_scale, + output_zero_point, + weight, + weight_scale, + weight_zero_point, + ] + nodes = [ + helper.make_node( + "QuantizeLinear", + ["input", "input_scale", "input_zero_point"], + ["input_q"], + ), + helper.make_node( + "DequantizeLinear", + ["input_q", "input_scale", "input_zero_point"], + ["input_dq"], + ), + helper.make_node( + "QuantizeLinear", + ["weight", "weight_scale", "weight_zero_point"], + ["weight_q"], + axis=1, + ), + helper.make_node( + "DequantizeLinear", + ["weight_q", "weight_scale", "weight_zero_point"], + ["weight_dq"], + axis=1, + ), + helper.make_node( + "ConvTranspose", + ["input_dq", "weight_dq"], + ["raw"], + kernel_shape=[2, 2], + strides=[2, 2], + pads=[0, 0, 0, 0], + group=1, + ), + helper.make_node( + "QuantizeLinear", + ["raw", "output_scale", "output_zero_point"], + ["output_q"], + ), + helper.make_node( + "DequantizeLinear", + ["output_q", "output_scale", "output_zero_point"], + ["output"], + ), + ] + compile_model = _model( + "qdq_convtranspose_per_channel", + nodes, + [model_input], + [model_output], + initializers, + ) + reference_model = copy.deepcopy(compile_model) + del reference_model.graph.output[:] + reference_model.graph.output.extend([int8_output]) + onnx.checker.check_model(reference_model) + return ContractCase( + "int8_convtranspose_per_channel", + compile_model, + reference_model, + {"input": rng.uniform(-1.0, 1.0, size=input_shape).astype(np.float32)}, + ("ConvTranspose",), + ) + + def _linebuffer_conv_chain_case() -> ContractCase: """A padded Conv chain compiled tight enough to be line-buffered. @@ -2059,6 +2478,13 @@ def _run_gate(runtime: Path, work_dir: Path) -> None: _tiled_chain_case(xip=True), _qdq_case("Conv"), _qdq_case("AveragePool"), + _convtranspose_case(), + _conv_then_convtranspose_case(), + _convtranspose_overlap_case(), + _convtranspose_output_padding_case(), + _convtranspose_asymmetric_pad_case(), + _qdq_case("ConvTranspose"), + _qdq_convtranspose_per_channel_case(), _linebuffer_conv_chain_case(), _qdq_conv_chain_case(), _2d_tiled_conv_case(), diff --git a/src/tigris/analysis/validation.py b/src/tigris/analysis/validation.py index 6605444..7ba25f5 100644 --- a/src/tigris/analysis/validation.py +++ b/src/tigris/analysis/validation.py @@ -7,6 +7,7 @@ _chain_fast_bytes, _get_stage_spatial_params, ) +from tigris.capabilities import KERNEL_CAPABILITIES, effective_operators from tigris.emitters.binary.defs import OP_TYPE_MAP from tigris.graph.ir import AnalyzedGraph, Stage @@ -114,9 +115,23 @@ def describe(self) -> str: return ", ".join(issue.describe() for issue in self.issues) +def _routed_operators() -> frozenset[str]: + """Operators reachable through some runtime dispatcher, any backend. + + Wire-encodability (OP_TYPE_MAP) and runtime routing (capabilities) are + two separate contracts. An op can be added to the binary schema before a + kernel exists for it; without this check the compiler would accept such + an op and only fail once the plan reaches a device. + """ + return frozenset().union( + *(effective_operators(backend) for backend in KERNEL_CAPABILITIES) + ) + + def validate_operator_support(ag: AnalyzedGraph) -> OperatorSupportValidation: """Return operators or attributes not representable by the plan/runtime.""" issues: list[UnsupportedOperatorIssue] = [] + routed_operators = _routed_operators() for op in ag.ops: if op.op_type not in OP_TYPE_MAP: issues.append( @@ -124,6 +139,18 @@ def validate_operator_support(ag: AnalyzedGraph) -> OperatorSupportValidation: ) continue + if op.op_type not in routed_operators: + issues.append( + UnsupportedOperatorIssue( + op_name=op.name, + op_type=op.op_type, + reason=( + f"{op.op_type} is wire-encodable but has no runtime kernel" + ), + ) + ) + continue + reasons: list[str] = [] auto_pad = op.attrs.get("auto_pad", "NOTSET") if op.op_type in { @@ -136,6 +163,14 @@ def validate_operator_support(ag: AnalyzedGraph) -> OperatorSupportValidation: } and auto_pad not in ("", "NOTSET"): reasons.append(f"auto_pad={auto_pad!r} requires explicit pads") + if op.op_type == "ConvTranspose": + group = int(op.attrs.get("group", 1)) + if group != 1: + reasons.append(f"group={group} is not implemented (group=1 only)") + dilations = [int(value) for value in op.attrs.get("dilations", [1, 1])] + if any(value != 1 for value in dilations): + reasons.append("ConvTranspose dilation is not implemented") + if op.op_type in {"MaxPool", "AveragePool"}: if int(op.attrs.get("ceil_mode", 0)) != 0: reasons.append("ceil_mode=1 is not encoded") diff --git a/src/tigris/capabilities.py b/src/tigris/capabilities.py index d2c4898..b81d341 100644 --- a/src/tigris/capabilities.py +++ b/src/tigris/capabilities.py @@ -34,6 +34,7 @@ class KernelCapabilities: _FLOAT_REFERENCE_OPERATORS = frozenset({ "Conv", + "ConvTranspose", "DepthwiseConv", "Relu", "Relu6", diff --git a/src/tigris/emitters/binary/writer.py b/src/tigris/emitters/binary/writer.py index 609089d..e31023c 100644 --- a/src/tigris/emitters/binary/writer.py +++ b/src/tigris/emitters/binary/writer.py @@ -1044,8 +1044,8 @@ def _compute_effective_scales(ag: AnalyzedGraph) -> dict[str, np.ndarray]: weights) are not included - they keep their raw tensor scale. """ effective: dict[str, np.ndarray] = {} - weight_ops = {"Conv", "ConvInteger", "DepthwiseConv", "MatMul", "Gemm", - "QLinearConv", "QLinearMatMul"} + weight_ops = {"Conv", "ConvTranspose", "ConvInteger", "DepthwiseConv", + "MatMul", "Gemm", "QLinearConv", "QLinearMatMul"} for op in ag.ops: if op.op_type not in weight_ops: diff --git a/src/tigris/schema/operator-capabilities-v1.json b/src/tigris/schema/operator-capabilities-v1.json index f98a37f..277b3b2 100644 --- a/src/tigris/schema/operator-capabilities-v1.json +++ b/src/tigris/schema/operator-capabilities-v1.json @@ -350,10 +350,10 @@ "opcode": 24, "operator": "ConvTranspose", "routes": { - "cmsis-nn": "unsupported", - "esp-nn": "unsupported", - "reference": "unsupported", - "s8_ref": "unsupported" + "cmsis-nn": "fallback:s8_ref", + "esp-nn": "fallback:s8_ref", + "reference": "native", + "s8_ref": "native" } }, { diff --git a/tests/test_codegen.py b/tests/test_codegen.py index fd3aa4b..b6cde7d 100644 --- a/tests/test_codegen.py +++ b/tests/test_codegen.py @@ -1,6 +1,7 @@ """Tests for C harness generation and XIP plan metadata.""" import struct +from unittest.mock import patch import numpy as np import onnx @@ -12,6 +13,7 @@ from tigris.analysis.memory import compute_memory_timeline from tigris.analysis.partition_spatial import partition_spatial from tigris.analysis.partition_temporal import partition_temporal +from tigris.analysis.validation import OperatorSupportValidation from tigris.cli import cli from tigris.emitters.binary.defs import ( FLAG_XIP, @@ -125,7 +127,19 @@ def quantized_matmul_plan(tmp_path): assert [op.op_type for op in analyzed.ops] == ["MatMul"] plan_path = tmp_path / "quantized_matmul.tgrs" - plan_path.write_bytes(emit_binary_bytes(analyzed)) + # MatMul has no runtime route on any backend, so validate_operator_support + # now correctly rejects it at compile time (the fail-closed gate this + # fixture predates). This fixture exists to exercise codegen's own, + # separate defense-in-depth capability check against an already-serialized + # plan (the same check that also guards a plan compiled by an older + # toolchain version), so bypass only the compile-time gate to construct + # the plan bytes; codegen's check below is untouched and still runs for + # real. + with patch( + "tigris.analysis.validation.validate_operator_support", + return_value=OperatorSupportValidation(issues=()), + ): + plan_path.write_bytes(emit_binary_bytes(analyzed)) return plan_path diff --git a/tests/test_convtranspose_validation.py b/tests/test_convtranspose_validation.py new file mode 100644 index 0000000..60b9a4e --- /dev/null +++ b/tests/test_convtranspose_validation.py @@ -0,0 +1,120 @@ +"""Fail-closed validation for ConvTranspose subset and the general +capabilities cross-check. + +validate_operator_support already rejects operators missing from +OP_TYPE_MAP (wire-encodability). It did not, until this change, also +require a runtime route to exist for a wire-encodable operator, so an +operator like Pad (present in OP_TYPE_MAP, no dispatcher in +tigris.capabilities) compiled successfully and only failed on-device. This +file also covers the ConvTranspose-specific subset the runtime kernels +implement: group == 1 and unit dilation only. +""" + +from onnx import TensorProto + +from tigris.analysis.validation import validate_operator_support +from tigris.capabilities import KERNEL_CAPABILITIES, effective_operators +from tigris.emitters.binary.defs import OP_TYPE_MAP +from tigris.graph.ir import AnalyzedGraph, OpNode, TensorInfo + + +def build_convtranspose_graph(group: int = 1, dilation: int = 1) -> AnalyzedGraph: + """A single-op graph: one ConvTranspose with a configurable group/dilation.""" + op = OpNode( + name="conv_transpose", + op_type="ConvTranspose", + inputs=["input"], + outputs=["output"], + attrs={ + "kernel_shape": [3, 3], + "strides": [2, 2], + "pads": [0, 0, 0, 0], + "dilations": [dilation, dilation], + "group": group, + }, + ) + return AnalyzedGraph( + ops=[op], + tensors={ + "input": TensorInfo("input", (1, 4, 4, 4), TensorProto.FLOAT), + "output": TensorInfo("output", (1, 4, 9, 9), TensorProto.FLOAT), + }, + ) + + +def build_single_op_graph(op_type: str) -> AnalyzedGraph: + """A single-op graph with no operator-specific attrs, for gate tests.""" + op = OpNode( + name="lone_op", + op_type=op_type, + inputs=["input"], + outputs=["output"], + ) + return AnalyzedGraph( + ops=[op], + tensors={ + "input": TensorInfo("input", (1, 4), TensorProto.FLOAT), + "output": TensorInfo("output", (1, 4), TensorProto.FLOAT), + }, + ) + + +def test_convtranspose_group_rejected(): + ag = build_convtranspose_graph(group=2) + + result = validate_operator_support(ag) + + assert not result.supported + assert any("group" in issue.reason.lower() for issue in result.issues) + + +def test_convtranspose_dilation_rejected(): + ag = build_convtranspose_graph(dilation=2) + + result = validate_operator_support(ag) + + assert not result.supported + assert any("dilation" in issue.reason.lower() for issue in result.issues) + + +def test_convtranspose_default_group_and_dilation_is_supported(): + ag = build_convtranspose_graph() + + result = validate_operator_support(ag) + + assert result.supported, result.describe() + + +def test_encodable_op_without_route_rejected(): + # Cross-check: an op present in OP_TYPE_MAP (wire-encodable) but with no + # route on any backend must still be rejected at compile. Confirm at + # least one such op currently exists (it does: Pad, Sub, Div, + # LeakyRelu, BatchNormalization, InstanceNormalization, MatMul, + # ReduceMean, Squeeze, Unsqueeze, GlobalMaxPool, Clip); if that ever + # becomes empty this assertion documents the gate must be exercised via + # a synthetic op_type instead. + routed = frozenset().union( + *(effective_operators(backend) for backend in KERNEL_CAPABILITIES) + ) + unrouted = sorted(set(OP_TYPE_MAP) - routed) + assert unrouted, "no wire-encodable-but-unrouted op found; use a synthetic op_type" + + ag = build_single_op_graph(op_type=unrouted[0]) + + result = validate_operator_support(ag) + + assert not result.supported + assert any( + "no runtime kernel" in issue.reason.lower() + or "unsupported" in issue.reason.lower() + for issue in result.issues + ) + + +def test_routed_operator_is_not_rejected_by_capabilities_gate(): + # Sanity check the new gate does not reject an op that does have a route. + ag = build_single_op_graph(op_type="Relu") + + result = validate_operator_support(ag) + + assert result.supported, result.describe()