# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements.  See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership.  The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License.  You may obtain a copy of the License at
#
#   http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied.  See the License for the
# specific language governing permissions and limitations
# under the License.
# pylint: disable=invalid-name, unused-argument, too-many-lines
# pylint: disable=import-outside-toplevel, use-list-literal
# pylint: disable=no-value-for-parameter, unused-variable
# pylint: disable=unexpected-keyword-arg, unused-import, too-many-function-args
# ruff: noqa: RUF005
"""Tensorflow lite frontend."""

import functools
import itertools
import math

import numpy as np

import tvm
from tvm import relax, tirx
from tvm.relax import op as _op

from .tflite_flexbuffer import FlexBufferDecoder

__all__ = ["from_tflite"]


def to_int_list(np_array):
    """Convert a np array to a python int list.

    Note: This function converts np.int32 to python's int.
    If we don't do this conversion, numpy's automatic upcast will make
    the shape / parameters be converted to int64 IntImm in relax and
    cause problems in relax/TOPI.
    """
    return [int(x) for x in np_array]


class ExprTable:
    """Table storing Relax expressions by names."""

    def __init__(self):
        self.exprs = {}
        self.params = {}
        self.const_ctr = 1
        self.in_padding = False

    def new_const(self, value, shape=None, dtype="float32", source_name=None):
        """Construct a new var expr and add to exprs dictionary"""
        name = f"_param_{self.const_ctr}"
        self.const_ctr += 1
        self.exprs[name] = relax.const(value, dtype)
        self.params[name] = (self.exprs[name], value)

        return self.exprs[name]

    def get_expr(self, name):
        return self.exprs[name]

    def set_expr(self, name, expr, force_override=False):
        # assert isinstance(expr, _expr.Expr)
        # if name exists, we should override the value
        # otherwise, we can not get like x = func(x) work.
        # One example is CoreML preprocess, which will override
        # the same name of input.
        # However, according to git log, Find keras frontend depends
        # on this property, so we add one force_override to control it.
        if name not in self.exprs or force_override:
            self.exprs[name] = expr

    def has_expr(self, name):
        return name in self.exprs


class TensorWrapper:
    """Tensor wrapper for TFLite Tensor"""

    def __init__(self, tensor_idx, tensor, buffer, qnn_params=None):
        self.tensor_idx = tensor_idx
        self.tensor = tensor
        self.buffer = buffer
        self.qnn_params = qnn_params


class OperatorConverter:
    """Operator Converted for converting TFLite ops to Relax ops"""

    _SUPPORTED_QUANTIZED_OPS = frozenset(
        {
            "ABS",
            "ADD",
            "ATAN2",
            "CEIL",
            "CONCATENATION",
            "CONV_2D",
            "COS",
            "DEPTHWISE_CONV_2D",
            "DEQUANTIZE",
            "DETECTION_POSTPROCESS",
            "DIV",
            "EQUAL",
            "EXP",
            "FLOOR",
            "FLOOR_DIV",
            "FLOOR_MOD",
            "FULLY_CONNECTED",
            "GREATER",
            "GREATER_EQUAL",
            "HARD_SWISH",
            "LEAKY_RELU",
            "LESS",
            "LESS_EQUAL",
            "LOG",
            "LOGISTIC",
            "LOG_SOFTMAX",
            "MAXIMUM",
            "MEAN",
            "MINIMUM",
            "MUL",
            "NEG",
            "NOT_EQUAL",
            "POW",
            "QUANTIZE",
            "REDUCE_MAX",
            "REDUCE_MIN",
            "REDUCE_PROD",
            "RELU",
            "RELU6",
            "RELU_N1_TO_1",
            "RESHAPE",
            "RESIZE_BILINEAR",
            "ROUND",
            "RSQRT",
            "SIN",
            "SOFTMAX",
            "SQRT",
            "SQUARED_DIFFERENCE",
            "SUB",
            "SUM",
            "TAN",
            "TANH",
            "TRANSPOSE_CONV",
        }
    )

    def __init__(self, model, subgraph, exp_tab, ctx, conversion_state=None):
        from tflite.ActivationFunctionType import ActivationFunctionType
        from tflite.BuiltinOperator import BuiltinOperator
        from tflite.BuiltinOptions import BuiltinOptions

        self.model = model
        self.subgraph = subgraph
        self.exp_tab = exp_tab
        self.builtin_op_code = build_str_map(BuiltinOperator())
        self.activation_fn_type = build_str_map(ActivationFunctionType())
        self.builtin_options = build_str_map(BuiltinOptions())
        self.prefetched_nodes = {}
        self.allow_custom_ops = False
        self.bb = ctx
        if conversion_state is None:
            conversion_state = {
                "lowered_subgraphs": {},
                "lowered_if_functions": {},
                "lowered_while_functions": {},
                "lowering_stack": [],
                "module_builder": ctx,
                "resource_values": {},
                "hashtable_values": {},
                "in_call_once_init": False,
            }
        else:
            conversion_state.setdefault("module_builder", ctx)
            conversion_state.setdefault("resource_values", {})
            conversion_state.setdefault("hashtable_values", {})
            conversion_state.setdefault("in_call_once_init", False)
        self.conversion_state = conversion_state
        self.resource_handles = {}
        self.hashtable_handles = {}

        # Add more operators
        self.convert_map = {
            "ABS": functools.partial(self._convert_unary_elemwise, relax_op=_op.abs),
            "ADD": functools.partial(self._convert_elemwise, relax_op=_op.add),
            "ADD_N": self.convert_add_n,
            "ARG_MAX": functools.partial(self._convert_arg_min_max, relax_op=_op.argmax),
            "ARG_MIN": functools.partial(self._convert_arg_min_max, relax_op=_op.argmin),
            "ASSIGN_VARIABLE": self.convert_assign_variable,
            "ATAN2": functools.partial(self._convert_elemwise, relax_op=_op.atan2),
            "AVERAGE_POOL_2D": functools.partial(self.convert_pool2d, pool_type="average"),
            "BATCH_TO_SPACE_ND": self.convert_batch_to_space_nd,
            "BATCH_MATMUL": self.convert_batch_matmul,
            "BIDIRECTIONAL_SEQUENCE_LSTM": self.convert_bidirectional_sequence_lstm,
            "BIDIRECTIONAL_SEQUENCE_RNN": self.convert_bidirectional_sequence_rnn,
            "BITCAST": self.convert_bitcast,
            "BITWISE_XOR": functools.partial(self._convert_elemwise, relax_op=_op.bitwise_xor),
            "BROADCAST_ARGS": self.convert_broadcast_args,
            "BROADCAST_TO": self.convert_broadcast_to,
            "BUCKETIZE": self.convert_bucketize,
            "CALL": self.convert_call,
            "CALL_ONCE": self.convert_call_once,
            "COMPLEX_ABS": self.convert_complex_abs,
            "CAST": self.convert_cast,
            "CEIL": functools.partial(self._convert_unary_elemwise, relax_op=_op.ceil),
            "CONCATENATION": self.convert_concatenation,
            "CONV_2D": functools.partial(self.convert_conv, conv_type="conv2d"),
            "CONV_3D": self.convert_conv3d,
            "CONV_3D_TRANSPOSE": self.convert_conv3d_transpose,
            "COS": functools.partial(self._convert_unary_elemwise, relax_op=_op.cos),
            "CUMSUM": self.convert_cumsum,
            "DENSIFY": self.convert_densify,
            "DEPTH_TO_SPACE": self.convert_depth_to_space,
            "DEPTHWISE_CONV_2D": functools.partial(self.convert_conv, conv_type="depthwise"),
            "DELEGATE": functools.partial(self.convert_operator_marker, op_name="DELEGATE"),
            "DEQUANTIZE": self.convert_dequantize,
            "DETECTION_POSTPROCESS": self.convert_detection_postprocess,
            "DILATE": self.convert_dilate,
            "DIV": functools.partial(self._convert_elemwise, relax_op=_op.divide),
            "ELU": self.convert_elu,
            "EMBEDDING_LOOKUP": self.convert_embedding_lookup,
            "EMBEDDING_LOOKUP_SPARSE": self.convert_embedding_lookup_sparse,
            "EQUAL": functools.partial(
                self._convert_elemwise, relax_op=_op.equal, comparison_op=True
            ),
            "EXP": functools.partial(self._convert_unary_elemwise, relax_op=_op.exp),
            "EXPAND_DIMS": self.convert_expand_dims,
            "FAKE_QUANT": self.convert_fake_quant,
            "FILL": self.convert_fill,
            "FLOOR_DIV": functools.partial(self._convert_elemwise, relax_op=_op.floor_divide),
            "FLOOR_MOD": functools.partial(self._convert_elemwise, relax_op=_op.floor_mod),
            "FLOOR": functools.partial(self._convert_unary_elemwise, relax_op=_op.floor),
            "FULLY_CONNECTED": self.convert_fully_connected,
            "GATHER": self.convert_gather,
            "GATHER_ND": self.convert_gather_nd,
            "GREATER_EQUAL": functools.partial(
                self._convert_elemwise, relax_op=_op.greater_equal, comparison_op=True
            ),
            "GREATER": functools.partial(
                self._convert_elemwise, relax_op=_op.greater, comparison_op=True
            ),
            "GELU": self.convert_gelu,
            "HARD_SWISH": self.convert_hard_swish,
            "HASHTABLE": self.convert_hashtable,
            "HASHTABLE_FIND": self.convert_hashtable_find,
            "HASHTABLE_IMPORT": self.convert_hashtable_import,
            "HASHTABLE_LOOKUP": self.convert_hashtable_lookup,
            "HASHTABLE_SIZE": self.convert_hashtable_size,
            "IF": self.convert_if,
            "IMAG": self.convert_imag,
            "L2_NORMALIZATION": self.convert_l2_normalization,
            "L2_POOL_2D": functools.partial(self.convert_pool2d, pool_type="l2"),
            "LEAKY_RELU": self.convert_leaky_relu,
            "LESS_EQUAL": functools.partial(
                self._convert_elemwise, relax_op=_op.less_equal, comparison_op=True
            ),
            "LESS": functools.partial(
                self._convert_elemwise, relax_op=_op.less, comparison_op=True
            ),
            "LOCAL_RESPONSE_NORMALIZATION": self.convert_lrn,
            "LOG": functools.partial(self._convert_unary_elemwise, relax_op=_op.log),
            "LOG_SOFTMAX": self.convert_log_softmax,
            "LOGICAL_AND": functools.partial(
                self._convert_logical_binary, relax_op=_op.logical_and
            ),
            "LOGICAL_NOT": self.convert_logical_not,
            "LOGICAL_OR": functools.partial(self._convert_logical_binary, relax_op=_op.logical_or),
            "LOGISTIC": self.convert_logistic,
            "LSTM": self.convert_lstm,
            "MATRIX_DIAG": self.convert_matrix_diag,
            "MATRIX_SET_DIAG": self.convert_matrix_set_diag,
            "MAX_POOL_2D": functools.partial(self.convert_pool2d, pool_type="max"),
            "MAXIMUM": functools.partial(self._convert_elemwise, relax_op=_op.maximum),
            "MEAN": functools.partial(self._convert_reduce, relax_op=_op.mean),
            "MINIMUM": functools.partial(self._convert_elemwise, relax_op=_op.minimum),
            "MIRROR_PAD": self.convert_mirror_pad,
            "MUL": functools.partial(self._convert_elemwise, relax_op=_op.multiply),
            "MULTINOMIAL": self.convert_multinomial,
            "NEG": functools.partial(self._convert_unary_elemwise, relax_op=_op.negative),
            "NOT_EQUAL": functools.partial(
                self._convert_elemwise, relax_op=_op.not_equal, comparison_op=True
            ),
            "ONE_HOT": self.convert_one_hot,
            "PACK": self.convert_pack,
            "PAD": self.convert_pad,
            "PADV2": self.convert_pad,
            "PLACEHOLDER_FOR_GREATER_OP_CODES": functools.partial(
                self.convert_operator_marker, op_name="PLACEHOLDER_FOR_GREATER_OP_CODES"
            ),
            "POW": functools.partial(self._convert_elemwise, relax_op=_op.power),
            "PRELU": self.convert_prelu,
            "RANGE": self.convert_range,
            "RANK": self.convert_rank,
            "QUANTIZE": self.convert_quantize,
            "RANDOM_STANDARD_NORMAL": self.convert_random_standard_normal,
            "RANDOM_UNIFORM": self.convert_random_uniform,
            "READ_VARIABLE": self.convert_read_variable,
            "REAL": self.convert_real,
            "REDUCE_ALL": functools.partial(self._convert_reduce_bool, relax_op=_op.min),
            "REDUCE_ANY": functools.partial(self._convert_reduce_bool, relax_op=_op.max),
            "REDUCE_MAX": functools.partial(self._convert_reduce, relax_op=_op.max),
            "REDUCE_MIN": functools.partial(self._convert_reduce, relax_op=_op.min),
            "REDUCE_PROD": functools.partial(self._convert_reduce, relax_op=_op.prod),
            "RELU": self.convert_relu,
            "RELU_0_TO_1": self.convert_relu_0_to_1,
            "RELU6": self.convert_relu6,
            "RELU_N1_TO_1": self.convert_relu_n1_to_1,
            "RESHAPE": self.convert_reshape,
            "RESIZE_BILINEAR": self.convert_resize_bilinear,
            "RESIZE_NEAREST_NEIGHBOR": self.convert_resize_nearest_neighbor,
            "RFFT2D": self.convert_rfft2d,
            "RIGHT_SHIFT": functools.partial(self._convert_elemwise, relax_op=_op.right_shift),
            "ROUND": functools.partial(self._convert_unary_elemwise, relax_op=_op.round),
            "RSQRT": functools.partial(self._convert_unary_elemwise, relax_op=_op.rsqrt),
            "REVERSE_SEQUENCE": self.convert_reverse_sequence,
            "REVERSE_V2": self.convert_reverse_v2,
            "SCATTER_ND": self.convert_scatter_nd,
            "SELECT": self.convert_select,
            "SELECT_V2": self.convert_select,
            "SEGMENT_SUM": functools.partial(
                self._convert_segment_op, op_name="SEGMENT_SUM", reduction="add"
            ),
            "SHAPE": self.convert_shape,
            "SIGN": functools.partial(self._convert_unary_elemwise, relax_op=_op.sign),
            "SIN": functools.partial(self._convert_unary_elemwise, relax_op=_op.sin),
            "SLICE": self.convert_slice,
            "SOFTMAX": self.convert_softmax,
            "SPACE_TO_BATCH_ND": self.convert_space_to_batch_nd,
            "SPACE_TO_DEPTH": self.convert_space_to_depth,
            "SPARSE_TO_DENSE": self.convert_sparse_to_dense,
            "SPLIT": self.convert_split,
            "SPLIT_V": self.convert_split_v,
            "SQRT": functools.partial(self._convert_unary_elemwise, relax_op=_op.sqrt),
            "SQUARE": self.convert_square,
            "SQUARED_DIFFERENCE": self.convert_squared_difference,
            "STABLEHLO_ABS": functools.partial(self._convert_stablehlo_unary, relax_op=_op.abs),
            "STABLEHLO_ADD": functools.partial(self._convert_stablehlo_binary, relax_op=_op.add),
            "STABLEHLO_AND": self._convert_stablehlo_and,
            "STABLEHLO_BROADCAST_IN_DIM": self._convert_stablehlo_broadcast_in_dim,
            "STABLEHLO_CBRT": self._convert_stablehlo_cbrt,
            "STABLEHLO_CLAMP": self._convert_stablehlo_clamp,
            "STABLEHLO_COMPARE": self._convert_stablehlo_compare,
            "STABLEHLO_COMPOSITE": self._convert_stablehlo_composite,
            "STABLEHLO_CONCATENATE": self._convert_stablehlo_concatenate,
            "STABLEHLO_CONVOLUTION": self._convert_stablehlo_convolution,
            "STABLEHLO_CONVERT": self._convert_stablehlo_convert,
            "STABLEHLO_COSINE": functools.partial(self._convert_stablehlo_unary, relax_op=_op.cos),
            "STABLEHLO_CUSTOM_CALL": self._convert_stablehlo_custom_call,
            "STABLEHLO_DIVIDE": functools.partial(
                self._convert_stablehlo_binary, relax_op=_op.divide
            ),
            "STABLEHLO_DOT_GENERAL": self._convert_stablehlo_dot_general,
            "STABLEHLO_DYNAMIC_SLICE": self._convert_stablehlo_dynamic_slice,
            "STABLEHLO_DYNAMIC_UPDATE_SLICE": self._convert_stablehlo_dynamic_update_slice,
            "STABLEHLO_EXPONENTIAL": functools.partial(
                self._convert_stablehlo_unary, relax_op=_op.exp
            ),
            "STABLEHLO_FLOOR": functools.partial(self._convert_stablehlo_unary, relax_op=_op.floor),
            "STABLEHLO_GATHER": self._convert_stablehlo_gather,
            "STABLEHLO_IOTA": self._convert_stablehlo_iota,
            "STABLEHLO_LOG": functools.partial(self._convert_stablehlo_unary, relax_op=_op.log),
            "STABLEHLO_LOGISTIC": functools.partial(
                self._convert_stablehlo_unary, relax_op=_op.sigmoid
            ),
            "STABLEHLO_MAXIMUM": functools.partial(
                self._convert_stablehlo_binary, relax_op=_op.maximum
            ),
            "STABLEHLO_MINIMUM": functools.partial(
                self._convert_stablehlo_binary, relax_op=_op.minimum
            ),
            "STABLEHLO_MULTIPLY": functools.partial(
                self._convert_stablehlo_binary, relax_op=_op.multiply
            ),
            "STABLEHLO_NEGATE": functools.partial(
                self._convert_stablehlo_unary, relax_op=_op.negative
            ),
            "STABLEHLO_OR": self._convert_stablehlo_or,
            "STABLEHLO_PAD": self._convert_stablehlo_pad,
            "STABLEHLO_POWER": functools.partial(
                self._convert_stablehlo_binary, relax_op=_op.power
            ),
            "STABLEHLO_REDUCE": self._convert_stablehlo_reduce,
            "STABLEHLO_REDUCE_WINDOW": self._convert_stablehlo_reduce_window,
            "STABLEHLO_REMAINDER": self._convert_stablehlo_remainder,
            "STABLEHLO_RNG_BIT_GENERATOR": self._convert_stablehlo_rng_bit_generator,
            "STABLEHLO_RSQRT": functools.partial(self._convert_stablehlo_unary, relax_op=_op.rsqrt),
            "STABLEHLO_SCATTER": self._convert_stablehlo_scatter,
            "STABLEHLO_SELECT": functools.partial(
                self._convert_stablehlo_ternary, relax_op=_op.where
            ),
            "STABLEHLO_SHIFT_LEFT": functools.partial(
                self._convert_stablehlo_binary, relax_op=_op.left_shift
            ),
            "STABLEHLO_SORT": self._convert_stablehlo_sort,
            "STABLEHLO_SUBTRACT": functools.partial(
                self._convert_stablehlo_binary, relax_op=_op.subtract
            ),
            "STABLEHLO_TANH": functools.partial(self._convert_stablehlo_unary, relax_op=_op.tanh),
            "STABLEHLO_WHILE": self._convert_stablehlo_while,
            "SQUEEZE": self.convert_squeeze,
            "STRIDED_SLICE": self.convert_strided_slice,
            "SUB": functools.partial(self._convert_elemwise, relax_op=_op.subtract),
            "SUM": functools.partial(self._convert_reduce, relax_op=_op.sum),
            "SVDF": self.convert_svdf,
            "TAN": functools.partial(self._convert_unary_elemwise, relax_op=_op.tan),
            "TANH": self.convert_tanh,
            "TILE": self.convert_tile,
            "TOPK_V2": self.convert_topk_v2,
            "TRANSPOSE_CONV": self.convert_transpose_conv,
            "TRANSPOSE": self.convert_transpose,
            "UNPACK": self.convert_unpack,
            "UNIDIRECTIONAL_SEQUENCE_RNN": self.convert_unidirectional_sequence_rnn,
            "UNIQUE": self.convert_unique,
            "UNSORTED_SEGMENT_MAX": functools.partial(
                self._convert_segment_op, op_name="UNSORTED_SEGMENT_MAX", reduction="max"
            ),
            "UNSORTED_SEGMENT_MIN": functools.partial(
                self._convert_segment_op, op_name="UNSORTED_SEGMENT_MIN", reduction="min"
            ),
            "UNSORTED_SEGMENT_PROD": functools.partial(
                self._convert_segment_op, op_name="UNSORTED_SEGMENT_PROD", reduction="mul"
            ),
            "UNSORTED_SEGMENT_SUM": functools.partial(
                self._convert_segment_op, op_name="UNSORTED_SEGMENT_SUM", reduction="add"
            ),
            "UNIDIRECTIONAL_SEQUENCE_LSTM": self.convert_unidirectional_sequence_lstm,
            "VAR_HANDLE": self.convert_var_handle,
            "WHERE": self.convert_select,
            "WHILE": self.convert_while,
            "ZEROS_LIKE": self.convert_zeros_like,
            "NON_MAX_SUPPRESSION_V4": self.convert_nms_v4,
            "NON_MAX_SUPPRESSION_V5": self.convert_nms_v5,
        }

    def check_unsupported_ops(self):
        """Check unsupported TFLite ops in our converter."""
        unsupported_ops_set = set()
        dynamic_range_ops_set = set()
        unsupported_quantized_ops_set = set()
        for op_idx in range(self.subgraph.OperatorsLength()):
            op = self.subgraph.Operators(op_idx)
            op_code_str = self.get_op_code_str(op)
            if op_code_str not in self.convert_map:
                unsupported_ops_set.add(op_code_str)
                continue

            # Trying to exclude "dynamic range quantization" optimized ops as not supported in TVM
            input_tensors = self.get_input_tensors(op)
            output_tensors = self.get_output_tensors(op)
            qnn_in_cnt = len([_.qnn_params for _ in input_tensors[0:1] if _.qnn_params is not None])
            qnn_weight_cnt = len(
                [_.qnn_params for _ in input_tensors[1:] if _.qnn_params is not None]
            )
            qnn_out_cnt = len([_.qnn_params for _ in output_tensors if _.qnn_params is not None])

            if qnn_in_cnt == 0 and qnn_out_cnt == 0 and qnn_weight_cnt > 0:
                dynamic_range_ops_set.add(op_code_str)

            if (
                qnn_in_cnt + qnn_weight_cnt + qnn_out_cnt > 0
                and op_code_str not in self._SUPPORTED_QUANTIZED_OPS
            ):
                unsupported_quantized_ops_set.add(op_code_str)

        raise_msg = ""

        if unsupported_ops_set:
            ops = str(list(unsupported_ops_set)).strip("[,]")
            raise_msg += f"The following operators are not supported in frontend TFLite: {ops}\n"

        if dynamic_range_ops_set:
            ops = str(list(dynamic_range_ops_set)).strip("[,]")
            raise_msg += (
                f"The following operators are likely to have dynamic range quantization: {ops}. "
                f"If you are running an optimized graph, please turn off dynamic range "
                f"quantization or use full integer quantization\n"
            )

        if unsupported_quantized_ops_set:
            ops = ", ".join(f"'{op}'" for op in sorted(unsupported_quantized_ops_set))
            raise_msg += (
                f"The following quantized TFLite operators are not supported in frontend "
                f"TFLite yet: {ops}. Quantized operators require explicit QDQ lowering "
                f"to avoid applying Relax ops directly to quantized integer tensors.\n"
            )

        if len(raise_msg) > 0:
            raise tvm.error.OpNotImplemented(raise_msg)

    def convert_operator_marker(self, op, op_name):
        """Reject TFLite marker builtins with an explicit diagnostic."""
        raise tvm.error.OpNotImplemented(
            f"TFLite operator marker {op_name} is not a Relax tensor operator"
        )

    def unbind(self, data, axis=1):
        """
        This is a modified version compared to the one in common.py.
        The onnx version takes a relax.Expr.Call, the tflite
        version a TensorWrapper. Also this version by default splits
        along axis 1 and not axis 0 as the onnx version.

         Parameters
         ----------
         data : tvm.relax.frontend.tflite.TensorWrapper
             Input tensor
         axis : int
             Axis along which tensor is split.
         Returns
         -------
         result : List[relax.Expr]
             The sequence of computed tensors
        """
        shape = to_int_list(self.get_tensor_shape(data))
        if axis >= len(shape):
            msg = "Please check input dim, it shouldn't be greater than or equal to rank."
            raise AttributeError(msg)

        selections = shape[axis]
        shape.pop(axis)
        timestep = 0  # Reshape to make time step as the first dim
        shape.insert(timestep, selections)
        res_split = relax.op.split(
            relax.op.reshape(self.get_expr(data.tensor_idx), tuple(shape)), selections, timestep
        )
        ret = []
        for i in range(selections):
            ret.append(_op.squeeze(res_split[i], axis=[timestep]))
        return relax.Tuple(relax.Tuple(ret), selections)

    def _infer_shape(self, arg):
        return self.bb.normalize(arg).ty.shape

    def convert_op_to_relax(self):
        """Convert TFLite ops to relax ops"""
        for op_idx in range(self.subgraph.OperatorsLength()):
            op = self.subgraph.Operators(op_idx)
            op_code_str = self.get_op_code_str(op)
            output_tensors = self.get_output_tensors(op)

            from tflite.Operator import Operator

            assert isinstance(op, Operator)
            ret = self.convert_map[op_code_str](op=op)

            # In case the Op can be prefetched, the output can be optimized out
            if ret is None:
                continue

            ret = self.bb.normalize(ret)

            if len(output_tensors) == 1:
                tensor_idx = output_tensors[0].tensor_idx
                self.exp_tab.set_expr(get_tensor_name(self.subgraph, tensor_idx), ret)
            else:
                for idx, output_tensor in enumerate(output_tensors):
                    self.exp_tab.set_expr(
                        get_tensor_name(self.subgraph, output_tensor.tensor_idx), ret[idx]
                    )

    @staticmethod
    def _decode_tflite_string(value):
        """Decode a TFLite string field."""
        if value is None:
            return ""
        if isinstance(value, bytes | bytearray):
            return value.decode("utf-8")
        return str(value)

    def _get_var_handle_resource_key(self, op, fallback_tensor=None):
        """Return a stable resource key for a VAR_HANDLE op."""
        container = ""
        shared_name = ""
        if op.BuiltinOptions() is not None:
            try:
                from tflite.VarHandleOptions import VarHandleOptions

                opts = self._get_builtin_options(op, VarHandleOptions)
                if hasattr(opts, "Container"):
                    container = self._decode_tflite_string(opts.Container())
                if hasattr(opts, "SharedName"):
                    shared_name = self._decode_tflite_string(opts.SharedName())
            except (ImportError, ModuleNotFoundError):
                pass

        if container or shared_name:
            return (container, shared_name)
        if fallback_tensor is not None:
            return ("", get_tensor_name(self.subgraph, fallback_tensor.tensor_idx))
        raise tvm.error.OpNotImplemented("VAR_HANDLE requires VarHandleOptions")

    def _get_resource_key_for_handle(self, tensor, op_name):
        tensor_name = get_tensor_name(self.subgraph, tensor.tensor_idx)
        if tensor_name not in self.resource_handles:
            raise tvm.error.OpNotImplemented(
                f"{op_name} requires a VAR_HANDLE in the same TFLite subgraph"
            )
        return self.resource_handles[tensor_name]

    def convert_var_handle(self, op):
        """Convert a TFLite VAR_HANDLE into an importer-local resource handle."""
        input_tensors = self.get_input_tensors(op)
        output_tensors = self.get_output_tensors(op)
        if len(input_tensors) != 0 or len(output_tensors) != 1:
            raise tvm.error.OpNotImplemented("VAR_HANDLE expects no inputs and one output")

        resource_key = self._get_var_handle_resource_key(op, output_tensors[0])
        resource_tensor_name = get_tensor_name(self.subgraph, output_tensors[0].tensor_idx)
        self.resource_handles[resource_tensor_name] = resource_key
        return None

    def convert_assign_variable(self, op):
        """Convert the CALL_ONCE initialization subset of ASSIGN_VARIABLE."""
        if not self.conversion_state["in_call_once_init"]:
            raise tvm.error.OpNotImplemented(
                "ASSIGN_VARIABLE outside CALL_ONCE initialization is not supported by the "
                "Relax TFLite frontend yet because it requires mutable resource state modeling."
            )

        input_tensors = self.get_input_tensors(op)
        output_tensors = self.get_output_tensors(op)
        if len(input_tensors) != 2 or len(output_tensors) != 0:
            raise tvm.error.OpNotImplemented(
                "ASSIGN_VARIABLE expects a resource handle and value input with no outputs"
            )

        resource_key = self._get_resource_key_for_handle(input_tensors[0], "ASSIGN_VARIABLE")
        self.conversion_state["resource_values"][resource_key] = self.get_tensor_expr(
            input_tensors[1]
        )
        return None

    def convert_read_variable(self, op):
        """Convert READ_VARIABLE for resources initialized by CALL_ONCE."""
        input_tensors = self.get_input_tensors(op)
        output_tensors = self.get_output_tensors(op)
        if len(input_tensors) != 1 or len(output_tensors) != 1:
            raise tvm.error.OpNotImplemented("READ_VARIABLE expects one input and one output")

        resource_key = self._get_resource_key_for_handle(input_tensors[0], "READ_VARIABLE")
        resource_values = self.conversion_state["resource_values"]
        if resource_key not in resource_values:
            raise tvm.error.OpNotImplemented(
                "READ_VARIABLE requires a resource initialized by a supported CALL_ONCE subgraph"
            )
        return resource_values[resource_key]

    def _is_tflite_string_type(self, tensor_type):
        from tflite.TensorType import TensorType

        return hasattr(TensorType, "STRING") and tensor_type == TensorType.STRING

    def _is_supported_hashtable_type_pair(self, key_dtype, value_dtype):
        from tflite.TensorType import TensorType

        return (key_dtype == TensorType.INT64 and self._is_tflite_string_type(value_dtype)) or (
            self._is_tflite_string_type(key_dtype) and value_dtype == TensorType.INT64
        )

    def _get_hashtable_key(self, op, fallback_tensor=None):
        """Return a stable key and TFLite dtype pair for a HASHTABLE resource."""
        table_id = None
        key_dtype = None
        value_dtype = None
        if op.BuiltinOptions() is not None:
            try:
                from tflite.HashtableOptions import HashtableOptions

                opts = self._get_builtin_options(op, HashtableOptions)
                table_id = int(opts.TableId())
                key_dtype = int(opts.KeyDtype())
                value_dtype = int(opts.ValueDtype())
            except (ImportError, ModuleNotFoundError):
                pass

        if key_dtype is None or value_dtype is None:
            raise tvm.error.OpNotImplemented("HASHTABLE requires HashtableOptions")
        if not self._is_supported_hashtable_type_pair(key_dtype, value_dtype):
            raise tvm.error.OpNotImplemented(
                "TFLite HASHTABLE only supports int64/string or string/int64 tables"
            )

        if table_id is not None:
            return table_id, key_dtype, value_dtype
        if fallback_tensor is not None:
            return (
                get_tensor_name(self.subgraph, fallback_tensor.tensor_idx),
                key_dtype,
                value_dtype,
            )
        raise tvm.error.OpNotImplemented("HASHTABLE requires HashtableOptions")

    def _get_hashtable_info_for_handle(self, tensor, op_name):
        tensor_name = get_tensor_name(self.subgraph, tensor.tensor_idx)
        if tensor_name not in self.hashtable_handles:
            raise tvm.error.OpNotImplemented(
                f"{op_name} requires a HASHTABLE in the same TFLite subgraph"
            )
        return self.hashtable_handles[tensor_name]

    @staticmethod
    def _get_tensor_shape_tuple(tensor_wrapper):
        if tensor_wrapper.tensor.ShapeLength() == 0:
            return ()
        return tuple(int(dim) for dim in tensor_wrapper.tensor.ShapeAsNumpy())

    @staticmethod
    def _has_tensor_buffer_data(tensor_wrapper):
        return (
            tensor_wrapper.buffer is not None
            and hasattr(tensor_wrapper.buffer, "DataLength")
            and tensor_wrapper.buffer.DataLength() > 0
        )

    def _get_string_tensor_value(self, tensor_wrapper, op_name):
        """Decode a constant TFLite string tensor buffer."""
        if not self._is_tflite_string_type(tensor_wrapper.tensor.Type()):
            raise tvm.error.OpNotImplemented(f"{op_name} requires a TensorType.STRING tensor")
        if not self._has_tensor_buffer_data(tensor_wrapper):
            raise tvm.error.OpNotImplemented(f"{op_name} requires a constant string tensor")

        data = bytes(tensor_wrapper.buffer.DataAsNumpy())
        if len(data) < 4:
            raise tvm.error.OpNotImplemented(f"{op_name} has an invalid string tensor buffer")

        count = int(np.frombuffer(data, dtype="<i4", count=1)[0])
        if count < 0:
            raise tvm.error.OpNotImplemented(f"{op_name} has an invalid string tensor count")

        header_size = 4 * (count + 2)
        if len(data) < header_size:
            raise tvm.error.OpNotImplemented(f"{op_name} has an invalid string tensor offsets")

        offsets = np.frombuffer(data, dtype="<i4", count=count + 1, offset=4).astype(np.int64)
        if np.any(offsets < header_size) or np.any(offsets > len(data)):
            raise tvm.error.OpNotImplemented(f"{op_name} has out-of-bounds string tensor offsets")
        if np.any(offsets[:-1] > offsets[1:]):
            raise tvm.error.OpNotImplemented(f"{op_name} has non-monotonic string tensor offsets")

        try:
            values = [
                data[int(offsets[i]) : int(offsets[i + 1])].decode("utf-8") for i in range(count)
            ]
        except UnicodeDecodeError as e:
            raise tvm.error.OpNotImplemented(f"{op_name} has invalid UTF-8 string data: {e}") from e
        shape = self._get_tensor_shape_tuple(tensor_wrapper)
        expected_count = math.prod(shape) if shape else 1
        if expected_count != count:
            raise tvm.error.OpNotImplemented(
                f"{op_name} string tensor buffer count does not match its shape"
            )
        return np.array(values, dtype=object).reshape(shape)

    def convert_hashtable(self, op):
        """Convert a TFLite HASHTABLE into an importer-local table handle."""
        input_tensors = self.get_input_tensors(op)
        output_tensors = self.get_output_tensors(op)
        if len(input_tensors) != 0 or len(output_tensors) != 1:
            raise tvm.error.OpNotImplemented("HASHTABLE expects no inputs and one output")

        table_key, key_dtype, value_dtype = self._get_hashtable_key(op, output_tensors[0])
        table_tensor_name = get_tensor_name(self.subgraph, output_tensors[0].tensor_idx)
        self.hashtable_handles[table_tensor_name] = {
            "table_key": table_key,
            "key_dtype": key_dtype,
            "value_dtype": value_dtype,
        }
        return None

    def convert_hashtable_import(self, op):
        """Convert static metadata for the CALL_ONCE HASHTABLE_IMPORT subset."""
        if not self.conversion_state["in_call_once_init"]:
            raise tvm.error.OpNotImplemented(
                "HASHTABLE_IMPORT outside CALL_ONCE initialization is not supported by the "
                "Relax TFLite frontend yet because it requires mutable resource state modeling."
            )

        input_tensors = self.get_input_tensors(op)
        output_tensors = self.get_output_tensors(op)
        if len(input_tensors) != 3 or len(output_tensors) != 0:
            raise tvm.error.OpNotImplemented(
                "HASHTABLE_IMPORT expects table, keys, and values inputs with no outputs"
            )

        table_info = self._get_hashtable_info_for_handle(input_tensors[0], "HASHTABLE_IMPORT")
        key_tensor = input_tensors[1]
        value_tensor = input_tensors[2]
        if (
            key_tensor.tensor.Type() != table_info["key_dtype"]
            or value_tensor.tensor.Type() != table_info["value_dtype"]
        ):
            raise tvm.error.OpNotImplemented("HASHTABLE_IMPORT key/value dtypes mismatch")
        key_shape = self._get_tensor_shape_tuple(key_tensor)
        value_shape = self._get_tensor_shape_tuple(value_tensor)
        if key_shape != value_shape:
            raise tvm.error.OpNotImplemented("HASHTABLE_IMPORT requires keys and values same shape")
        if not self._has_tensor_buffer_data(key_tensor) or not self._has_tensor_buffer_data(
            value_tensor
        ):
            raise tvm.error.OpNotImplemented("HASHTABLE_IMPORT requires constant keys and values")

        if self._is_tflite_string_type(table_info["key_dtype"]):
            keys = self._get_string_tensor_value(key_tensor, "HASHTABLE_IMPORT")
        else:
            keys = self.get_tensor_value(key_tensor)
        if self._is_tflite_string_type(table_info["value_dtype"]):
            values = self._get_string_tensor_value(value_tensor, "HASHTABLE_IMPORT")
        else:
            values = self.get_tensor_value(value_tensor)

        if np.unique(keys).size != keys.size:
            raise tvm.error.OpNotImplemented(
                "HASHTABLE_IMPORT with duplicate keys is not supported"
            )

        hashtable_values = self.conversion_state["hashtable_values"]
        table_key = table_info["table_key"]
        if table_key not in hashtable_values:
            hashtable_values[table_key] = {
                "size": math.prod(key_shape) if key_shape else 1,
                "key_dtype": table_info["key_dtype"],
                "value_dtype": table_info["value_dtype"],
                "keys": keys,
                "values": values,
            }
        return None

    def convert_hashtable_find(self, op):
        """Convert the constant-foldable string-to-int64 HASHTABLE_FIND subset."""
        from tflite.TensorType import TensorType

        input_tensors = self.get_input_tensors(op)
        output_tensors = self.get_output_tensors(op)
        if len(input_tensors) != 3 or len(output_tensors) != 1:
            raise tvm.error.OpNotImplemented(
                "HASHTABLE_FIND expects table, query, and default inputs with one output"
            )

        table_tensor, query_tensor, default_tensor = input_tensors
        output_tensor = output_tensors[0]
        table_info = self._get_hashtable_info_for_handle(table_tensor, "HASHTABLE_FIND")
        table_key = table_info["table_key"]
        hashtable_values = self.conversion_state["hashtable_values"]
        if table_key not in hashtable_values:
            raise tvm.error.OpNotImplemented(
                "HASHTABLE_FIND requires a table initialized by a supported CALL_ONCE subgraph"
            )
        table_values = hashtable_values[table_key]

        if (
            query_tensor.tensor.Type() != table_values["key_dtype"]
            or default_tensor.tensor.Type() != table_values["value_dtype"]
            or output_tensor.tensor.Type() != table_values["value_dtype"]
        ):
            raise tvm.error.OpNotImplemented("HASHTABLE_FIND key/value dtypes mismatch")

        if not (
            self._is_tflite_string_type(table_values["key_dtype"])
            and table_values["value_dtype"] == TensorType.INT64
        ):
            raise tvm.error.OpNotImplemented(
                "HASHTABLE_FIND only supports constant string -> int64 tables"
            )
        if not self._has_tensor_buffer_data(query_tensor):
            raise tvm.error.OpNotImplemented(
                "HASHTABLE_FIND with runtime string queries is not supported"
            )
        if not self._has_tensor_buffer_data(default_tensor):
            raise tvm.error.OpNotImplemented("HASHTABLE_FIND requires constant default values")

        query_shape = self._get_tensor_shape_tuple(query_tensor)
        output_shape = self._get_tensor_shape_tuple(output_tensor)
        if output_shape != query_shape:
            raise tvm.error.OpNotImplemented("HASHTABLE_FIND output shape must match query shape")

        query_values = self._get_string_tensor_value(query_tensor, "HASHTABLE_FIND")
        default_values = self.get_tensor_value(default_tensor)
        default_shape = self._get_tensor_shape_tuple(default_tensor)
        if default_shape == () or default_values.size == 1:
            result = np.full(output_shape, int(default_values.item()), dtype=np.int64)
        elif default_shape == query_shape:
            result = default_values.astype(np.int64).copy()
        else:
            raise tvm.error.OpNotImplemented(
                "HASHTABLE_FIND default value must be scalar or match query shape"
            )

        table_map = dict(
            zip(
                table_values["keys"].reshape(-1).tolist(),
                table_values["values"].reshape(-1).astype(np.int64).tolist(),
            )
        )
        for index, key in np.ndenumerate(query_values):
            if key in table_map:
                result[index] = table_map[key]
        return relax.const(result.astype(np.int64), "int64")

    def convert_hashtable_lookup(self, op):
        """Convert TFLite HASHTABLE_LOOKUP for non-string value tensors."""
        from tflite.TensorType import TensorType

        input_tensors = self.get_input_tensors(op)
        output_tensors = self.get_output_tensors(op)
        if len(input_tensors) != 3 or len(output_tensors) != 2:
            raise tvm.error.OpNotImplemented(
                "HASHTABLE_LOOKUP expects lookup, key, and value inputs with two outputs"
            )

        lookup_tensor, key_tensor, value_tensor = input_tensors
        output_tensor, hits_tensor = output_tensors

        if (
            lookup_tensor.tensor.Type() != TensorType.INT32
            or key_tensor.tensor.Type() != TensorType.INT32
        ):
            raise tvm.error.OpNotImplemented(
                "HASHTABLE_LOOKUP requires int32 lookup and key tensors"
            )
        if self._is_tflite_string_type(value_tensor.tensor.Type()):
            raise tvm.error.OpNotImplemented(
                "HASHTABLE_LOOKUP with TensorType.STRING values is not supported"
            )
        if value_tensor.tensor.Type() != output_tensor.tensor.Type():
            raise tvm.error.OpNotImplemented(
                "HASHTABLE_LOOKUP output dtype must match the value tensor dtype"
            )
        if hits_tensor.tensor.Type() != TensorType.UINT8:
            raise tvm.error.OpNotImplemented("HASHTABLE_LOOKUP hits output must be uint8")

        lookup_shape = to_int_list(self.get_tensor_shape(lookup_tensor))
        key_shape = to_int_list(self.get_tensor_shape(key_tensor))
        value_shape = to_int_list(self.get_tensor_shape(value_tensor))
        output_shape = to_int_list(self.get_tensor_shape(output_tensor))
        hits_shape = to_int_list(self.get_tensor_shape(hits_tensor))

        if len(lookup_shape) != 1 or len(key_shape) != 1 or len(value_shape) < 1:
            raise tvm.error.OpNotImplemented(
                "HASHTABLE_LOOKUP requires rank-1 lookup/key and rank>=1 value tensors"
            )
        if key_shape[0] != value_shape[0]:
            raise tvm.error.OpNotImplemented(
                "HASHTABLE_LOOKUP requires key and value tensors to agree on row count"
            )
        if key_shape[0] == 0:
            raise tvm.error.OpNotImplemented(
                "HASHTABLE_LOOKUP requires a non-empty key/value table"
            )
        if output_shape != [lookup_shape[0]] + value_shape[1:]:
            raise tvm.error.OpNotImplemented(
                "HASHTABLE_LOOKUP output shape must match lookup count and value tail shape"
            )
        if hits_shape != [lookup_shape[0]]:
            raise tvm.error.OpNotImplemented(
                "HASHTABLE_LOOKUP hits output shape must match lookup count"
            )

        lookup = self.get_tensor_expr(lookup_tensor)
        key = self.get_tensor_expr(key_tensor)
        value = self.get_tensor_expr(value_tensor)

        positions = relax.op.bucketize(lookup, key, out_int32=True, right=False)
        candidate_keys = relax.op.take(key, positions, axis=0, mode="clip")
        in_range = relax.op.less(positions, relax.const(key_shape[0], "int32"))
        found = relax.op.logical_and(in_range, relax.op.equal(candidate_keys, lookup))

        gathered_values = relax.op.take(value, positions, axis=0, mode="clip")
        output_dtype = self.get_tensor_type_str(output_tensor.tensor.Type())
        zero_values = relax.op.zeros(output_shape, output_dtype)

        if len(value_shape) > 1:
            found_values = relax.op.expand_dims(found, axis=list(range(1, len(value_shape))))
            found_values = relax.op.broadcast_to(found_values, output_shape)
        else:
            found_values = found

        output = relax.op.where(found_values, gathered_values, zero_values)
        hits = relax.op.astype(found, "uint8")
        return relax.Tuple([output, hits])

    def convert_hashtable_size(self, op):
        """Convert HASHTABLE_SIZE for a statically imported TFLite hashtable."""
        input_tensors = self.get_input_tensors(op)
        output_tensors = self.get_output_tensors(op)
        if len(input_tensors) != 1 or len(output_tensors) != 1:
            raise tvm.error.OpNotImplemented("HASHTABLE_SIZE expects one input and one output")

        from tflite.TensorType import TensorType

        if output_tensors[0].tensor.Type() != TensorType.INT64:
            raise tvm.error.OpNotImplemented("HASHTABLE_SIZE output must be int64")
        table_info = self._get_hashtable_info_for_handle(input_tensors[0], "HASHTABLE_SIZE")
        table_key = table_info["table_key"]
        hashtable_values = self.conversion_state["hashtable_values"]
        if table_key not in hashtable_values:
            raise tvm.error.OpNotImplemented(
                "HASHTABLE_SIZE requires a table initialized by a supported CALL_ONCE subgraph"
            )
        return relax.const(np.array([hashtable_values[table_key]["size"]], dtype=np.int64), "int64")

    def get_op_code_str(self, op):
        """Get TFLite ops string representation"""

        from tflite.BuiltinOperator import BuiltinOperator

        op_code_list_idx = op.OpcodeIndex()

        op_c = self.model.OperatorCodes(op_code_list_idx)
        # In TFlite 2.4.x there was a change where the type of the field that contained
        # the builtin code changed from int8 to int32 in the flat buffer representation.
        # However, to retain support for old flat buffers that were created, they retained
        # the original 8 bit field, but named it "deprecated_builtin_code" in TFLite 2.4.
        # This means that the API function BuiltinCode() which originally returned the value
        # of the 8 bit field would now look for the value in the new int32 field in the
        # schema and DeprecatedBuiltinCode() will look at the old 8 bit field.
        # In TFLite 2.4, if the opcode value is less than 127, it can be in either field
        # (however, if it is only in the "builtin_code" field, the model is not backward
        # compatible), so similarly to TFLite 2.4 reader, we'll pick the higher value of the
        # two fields.
        # Remember however that this value came into existence only after Tensorflow
        # lite 2.4.x and hence encase it in a try -except block.
        # Phew !
        try:
            opc = max(op_c.DeprecatedBuiltinCode(), op_c.BuiltinCode())
        except AttributeError:
            # In versions before 2.4 the int8 field that holds the builtin code is accessed
            # by BuiltinCode() and DeprecatedBuiltinCode() doesn't exist
            opc = op_c.BuiltinCode()

        op_code_id = opc
        try:
            op_code_str = self.builtin_op_code[op_code_id]
        except KeyError:
            raise NotImplementedError(
                "TFLite operator with code "
                + str(op_code_id)
                + " is not supported by this version of the fbs schema."
            )
        if op_code_id == BuiltinOperator.CUSTOM:
            # Custom operator
            custom_op_code_str = self.model.OperatorCodes(op_code_list_idx).CustomCode()

            if self.allow_custom_ops:
                return "CUSTOM"

            if custom_op_code_str == b"TFLite_Detection_PostProcess":
                return "DETECTION_POSTPROCESS"

            raise NotImplementedError("Custom operators are currently not supported")
        return op_code_str

    def get_input_tensors(self, op):
        operator_inputs = op.InputsAsNumpy()
        return self.get_tensors(operator_inputs)

    def get_output_tensors(self, op):
        operator_outputs = op.OutputsAsNumpy()
        return self.get_tensors(operator_outputs)

    def get_tensors(self, tensors_idx_list):
        """Get tensor wrapper list from given TFLite tensor index list"""
        return_list = list()
        for tensor_idx in self._indices_or_empty(tensors_idx_list):
            if tensor_idx < 0:
                return_list.append(TensorWrapper(tensor_idx, 0, 0))
                continue

            tensor = self.subgraph.Tensors(tensor_idx)
            buffer_idx = tensor.Buffer()
            buffer = self.model.Buffers(buffer_idx)

            # Check if the tensors are quantized. Parse if yes.
            qnn_params = None
            tflite_qnn_params = tensor.Quantization()
            if tflite_qnn_params is not None:
                # TFLite supports both per-tensor and per-axis (aka channel) quantization.  For
                # per-tensor quantization, scale and zero points are scalar values.  For per-axis
                # quantization, scale and zero points for the weights are tensors (activations are
                # per-tensor quantized). However, the TFLite quantization spec puts restrictions on
                # zero points for per-axis quantization.  Specifically, the zero point is a tensor
                # but all values are 0. More information can be found here -
                # https://www.tensorflow.org/lite/performance/quantization_spec

                tflite_scale = tflite_qnn_params.ScaleAsNumpy()
                tflite_zero_point = tflite_qnn_params.ZeroPointAsNumpy()
                is_qnn_params_valid = True

                # Handle Per-axis and per-tensor cases
                if isinstance(tflite_scale, np.ndarray):
                    assert isinstance(tflite_zero_point, np.ndarray)

                    # Tensor - Per-axis quantization
                    if tflite_scale.size != 1 and tflite_zero_point.size != 1:
                        scale = tflite_scale
                        # Ensure that all zero points are zeros
                        zero_point = tflite_zero_point
                        if not np.all(zero_point == 0):
                            raise tvm.error.OpAttributeInvalid(
                                "TFLite per-axis quantization restricts all zero points to be"
                                + " 0, but a non-zero value is observed"
                            )
                        zero_point = int(zero_point[0])

                    # Scalar - Per-tensor quantization
                    elif tflite_scale.size == 1 and tflite_zero_point.size == 1:
                        scale = float(tflite_scale[0])
                        zero_point = int(tflite_zero_point[0])

                    else:
                        raise NotImplementedError(
                            f"Quantized type {type(tflite_scale)} (scale) and  "
                            f"{type(tflite_zero_point)} (zero point) not supported"
                        )
                elif tflite_scale == 0 and tflite_zero_point == 0:
                    # Handle corner case for ops like quantized reshape whose second operand (shape)
                    # has zero scale and zero zero point. This is not used.
                    is_qnn_params_valid = False
                else:
                    raise NotImplementedError(f"Quantized type {type(tflite_scale)} not supported")

                # Check that the scale and zero points are valid.
                if is_qnn_params_valid:
                    qnn_params = dict()
                    qnn_params["scale"] = relax.const(scale, "float32")
                    qnn_params["zero_point"] = relax.const(zero_point, "int32")
                    qnn_params["axis"] = int(tflite_qnn_params.QuantizedDimension())
            return_list.append(TensorWrapper(tensor_idx, tensor, buffer, qnn_params))
        return return_list

    def get_tensor_type_as_numpy(self, tensor_wrapper):
        """Returns np.dtype out of TensorType"""
        assert isinstance(tensor_wrapper, TensorWrapper)

        from tflite.TensorType import TensorType

        return {
            TensorType.UINT8: np.uint8,
            TensorType.INT8: np.int8,
            TensorType.INT16: np.int16,
            TensorType.FLOAT16: np.float16,
            TensorType.FLOAT32: np.float32,
            TensorType.INT32: np.int32,
            TensorType.INT64: np.int64,
            TensorType.UINT32: np.uint32,
            TensorType.UINT64: np.uint64,
            TensorType.BOOL: np.bool_,
            TensorType.COMPLEX64: np.complex64,
        }[tensor_wrapper.tensor.Type()]

    # pylint: disable=no-else-return
    def get_tensor_value(self, tensor_wrapper, is_sparse=False):
        """Get tensor buffer value from given tensor wrapper.

        Returns the raw TFLite buffer reinterpreted via ``get_tensor_type_as_numpy``: for
        COMPLEX64 this is a ``np.complex64`` ndarray with the TFLite shape (no pair axis).
        This is distinct from ``get_tensor_expr``, which returns the *Relax* representation
        (float32 real/imag pair with a trailing (2,) axis for COMPLEX64). Use
        ``get_tensor_expr`` for Relax IR construction; use ``get_tensor_value`` only when
        you need the literal TFLite buffer (e.g. fft_length constant parsing).
        """
        assert isinstance(tensor_wrapper, TensorWrapper)

        dtype = self.get_tensor_type_as_numpy(tensor_wrapper)
        data = tensor_wrapper.buffer.DataAsNumpy()

        if tensor_wrapper.tensor.ShapeLength() != 0:
            shape = to_int_list(self.get_tensor_shape(tensor_wrapper))
        else:
            shape = []

        if is_sparse:
            return np.frombuffer(data, dtype=dtype)
        else:
            return np.frombuffer(data, dtype=dtype).reshape(shape)

    def get_tensor_type_str(self, tensor_type):
        """Get tensor type string representation when given TFLite tensor type"""

        from tflite.TensorType import TensorType

        if tensor_type == TensorType.INT8:
            return "int8"
        if tensor_type == TensorType.INT16:
            return "int16"
        if tensor_type == TensorType.UINT8:
            return "uint8"
        if tensor_type == TensorType.FLOAT16:
            return "float16"
        if tensor_type == TensorType.FLOAT32:
            return "float32"
        if tensor_type == TensorType.INT32:
            return "int32"
        if tensor_type == TensorType.INT64:
            return "int64"
        if tensor_type == TensorType.UINT32:
            return "uint32"
        if tensor_type == TensorType.UINT64:
            return "uint64"
        if tensor_type == TensorType.BOOL:
            return "bool"
        if tensor_type == TensorType.COMPLEX64:
            return "complex64"
        raise NotImplementedError(f"Tensor type {tensor_type!s} is currently not supported")

    def _is_tflite_complex64_type(self, tensor_type):
        """Return whether a TFLite tensor type is COMPLEX64."""
        from tflite.TensorType import TensorType

        return tensor_type == TensorType.COMPLEX64

    def _unwrap_tflite_tensor(self, tensor):
        """Return the underlying tflite.Tensor, unwrapping TensorWrapper if needed."""
        if isinstance(tensor, TensorWrapper):
            return tensor.tensor
        return tensor

    def _get_relax_tensor_dtype(self, tensor):
        """Return the Relax dtype used to represent a TFLite tensor."""
        tensor = self._unwrap_tflite_tensor(tensor)
        tensor_type = tensor.Type() if hasattr(tensor, "Type") else tensor
        if self._is_tflite_complex64_type(tensor_type):
            return "float32"
        return self.get_tensor_type_str(tensor_type)

    def _get_relax_tensor_shape(self, tensor):
        """Return the Relax shape used to represent a TFLite tensor.

        For COMPLEX64 tensors, the trailing (2,) axis encodes the real/imag pair.
        Returns an empty tuple () for rank-0 tensors. Shape elements are Python ints
        (not numpy scalars) so the result is safe to feed into TIRX ``T.Buffer(shape, ...)``.
        """
        tensor = self._unwrap_tflite_tensor(tensor)
        shape = to_int_list(tensor.ShapeAsNumpy()) if tensor.ShapeLength() > 0 else ()
        if self._is_tflite_complex64_type(tensor.Type()):
            return tuple(shape) + (2,)
        return tuple(shape)

    def _get_shape_expr_from_tensor(self, shape_tensor, prefix):
        """Convert a TFLite shape tensor to a Relax shape expression."""
        if self.has_expr(shape_tensor.tensor_idx):
            dims_expr = self.get_expr(shape_tensor.tensor_idx)
            dims_ndim = int(self.get_tensor_shape(shape_tensor)[0])
            dims_dtype = self.get_tensor_type_str(shape_tensor.tensor.Type())
            dims_expr = self.bb.match_cast(dims_expr, relax.TensorType([dims_ndim], dims_dtype))
            dims_expr = self.bb.normalize(relax.op.astype(dims_expr, "int64"))
            shape_dataflow_var = self.bb.emit(relax.op.tensor_to_shape(dims_expr))
            shape_vars = [tirx.Var(f"{prefix}_{i}", "int64") for i in range(dims_ndim)]
            self.bb.match_cast(shape_dataflow_var, relax.ShapeType(shape_vars))
            return relax.ShapeExpr(shape_vars), shape_vars

        dims = to_int_list(self.get_tensor_value(shape_tensor))
        return dims, dims

    def flatten_to_nd(self, x, nd=3):
        """Flatten input tensor to nd rank"""
        shape = x.ty.shape
        ndims = len(shape)
        if ndims == nd:
            return x
        new_shape = [-1] + [int(shape[i]) for i in range(ndims - nd + 1, ndims)]
        return relax.op.reshape(x, new_shape)

    def has_same_qnn_params(self, lhs_tensor, rhs_tensor):
        lhs_scale = lhs_tensor.qnn_params["scale"]
        rhs_scale = rhs_tensor.qnn_params["scale"]
        lhs_zero_point = lhs_tensor.qnn_params["zero_point"]
        rhs_zero_point = rhs_tensor.qnn_params["zero_point"]
        # 0.1 + 0.2 != 0.3
        return np.allclose(
            lhs_scale.data.numpy(), rhs_scale.data.numpy(), rtol=1e-5, atol=1e-5
        ) and np.allclose(
            lhs_zero_point.data.numpy(), rhs_zero_point.data.numpy(), rtol=1e-5, atol=1e-5
        )

    def quantize(self, expr, tensor_to_quantize):
        """Helper function to quantize a tensor with Relax"""
        tensor_type = tensor_to_quantize.tensor.Type()
        tensor_type_str = self.get_tensor_type_str(tensor_type)
        quantized = relax.op.quantize(
            data=expr,
            scale=tensor_to_quantize.qnn_params["scale"],
            zero_point=tensor_to_quantize.qnn_params["zero_point"],
            axis=tensor_to_quantize.qnn_params["axis"],
            out_dtype=tensor_type_str,
        )
        return quantized

    def dequantize(self, expr, tensor):
        """Helper function to dequantize a tensor with Relax"""
        dequantized = relax.op.dequantize(
            data=expr,
            scale=tensor.qnn_params["scale"],
            zero_point=tensor.qnn_params["zero_point"],
            axis=tensor.qnn_params["axis"],
        )
        return dequantized

    def is_quantized(self, op):
        """Check if an input tensor is quantized."""
        input_tensors = self.get_input_tensors(op)
        first_tensor = input_tensors[0]
        return first_tensor.qnn_params is not None

    def convert_qnn_fused_activation_function(
        self, expr, fused_activation_fn, scale, zero_point, dtype
    ):
        """Convert TFLite fused activation function. The expr is an input quantized tensor with
        scale and zero point"""

        from tflite.ActivationFunctionType import ActivationFunctionType

        # Quantize a float value to an quantized integer value
        def quantize(x):
            return float(round(x / scale) + zero_point)

        # Get min/max of the output dtype. This will be used to ensure that clip a_min/a_max are not
        # beyond the dtype range.
        qmin = float(tvm.tirx.min_value(dtype).value)
        qmax = float(tvm.tirx.max_value(dtype).value)

        # The input expr is a quantized tensor with its scale and zero point. We calculate the
        # suitable clip off points based on these scale and zero point.
        if fused_activation_fn == ActivationFunctionType.NONE:
            return expr
        if fused_activation_fn == ActivationFunctionType.RELU6:
            return relax.op.clip(expr, min=max(qmin, quantize(0)), max=min(qmax, quantize(6.0)))
        if fused_activation_fn == ActivationFunctionType.RELU_N1_TO_1:
            return relax.op.clip(expr, min=max(qmin, quantize(-1.0)), max=min(qmax, quantize(1.0)))
        if fused_activation_fn == ActivationFunctionType.RELU:
            return relax.op.clip(expr, min=max(qmin, quantize(0.0)), max=qmax)

        fused_activation_fn_str = self.activation_fn_type[fused_activation_fn]
        raise tvm.error.OpNotImplemented(
            f"Quantized activation {fused_activation_fn_str} is not supported yet."
        )

    def convert_reshape(self, op):
        """Convert TFLite reshape"""

        from tflite.BuiltinOptions import BuiltinOptions
        from tflite.ReshapeOptions import ReshapeOptions

        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) in (1, 2), "input tensors should not be empty"

        output_tensors = self.get_output_tensors(op)
        assert len(output_tensors) == 1, "There should be only 1 output tensor"

        input_tensor = input_tensors[0]
        input_tensor_idx = input_tensor.tensor_idx

        if len(input_tensors) == 2:
            shape_tensor = input_tensors[1]
            if self.has_expr(shape_tensor.tensor_idx):
                target_expr = self.get_expr(shape_tensor.tensor_idx)
                """
                target_value, success = try_infer_value(
                    target_expr,
                    parameters={
                        k: tvm.runtime.tensor(np.array(v)) for k, v in self.exp_tab.params.items()
                    },
                )
                """
                target_value, success = target_expr, False
                if success:
                    # convert to flattened list
                    from itertools import chain

                    try:
                        target_shape = list(chain(*target_value))
                    except TypeError:
                        target_shape = list(chain(target_value))
                else:
                    target_shape = target_expr
            else:
                target_shape = self.get_tensor_value(shape_tensor)
                # convert to flattened list
                from itertools import chain

                try:
                    target_shape = list(chain(*target_shape))
                except TypeError:
                    target_shape = list(chain(target_shape))

        else:
            assert op.BuiltinOptionsType() == BuiltinOptions.ReshapeOptions
            op_options = op.BuiltinOptions()
            reshape_options = ReshapeOptions()
            reshape_options.Init(op_options.Bytes, op_options.Pos)
            target_shape = to_int_list(reshape_options.NewShapeAsNumpy())

        in_expr = self.get_expr(input_tensor_idx)

        # If the tensors are quantized, ensure that input/output qnn params are same.

        input_tensor_type_str = self.get_tensor_type_str(input_tensor.tensor.Type())
        if input_tensor.qnn_params and input_tensor_type_str == "int8":
            # TFLite 2.x quantization spec requires qnn params to be same and dtype to be int8.
            # For TFLite 1.x, dtype can be uint8 and qnn params can be different
            output_tensor = output_tensors[0]
            assert self.has_same_qnn_params(input_tensor, output_tensor), (
                "TFLite reshape requires input and output scale and zero points to be equal"
            )

        if input_tensor.qnn_params and input_tensor_type_str == "uint8":
            output_tensor = output_tensors[0]
            if not self.has_same_qnn_params(input_tensor, output_tensor):
                in_f32 = self.dequantize(in_expr, input_tensor)
                out = relax.op.reshape(in_f32, shape=relax.ShapeExpr(target_shape))
                out = self.quantize(out, output_tensor)
                return out

        out = relax.op.reshape(in_expr, shape=relax.ShapeExpr(target_shape))
        return out

    def _convert_resize(self, method, op):
        """Generic method to Convert TFLite RESIZE operators"""

        from tflite.BuiltinOptions import BuiltinOptions
        from tflite.ResizeBilinearOptions import ResizeBilinearOptions

        # ResizeNearestNeighborOptions was added in tflite v1.13
        tflite_ver = 1120
        if hasattr(BuiltinOptions, "ResizeNearestNeighborOptions"):
            tflite_ver = 1130

        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 2, "input tensors length should be 2"

        # images, 4-D Tensor with shape NHWC.
        input_tensor = input_tensors[0]
        in_expr = self.get_expr(input_tensor.tensor_idx)

        output_tensors = self.get_output_tensors(op)
        assert len(output_tensors) == 1, "output tensors length should be 1"
        output_tensor = output_tensors[0]

        # size - 1-D int32 Tensor of 2 elements: new_height, new_width
        target_size = tuple(self.get_tensor_value(input_tensors[1]))

        # Options - align_corners (bool)
        resize_options = None
        align_corners = False
        bilinear_method = method == "linear"
        if bilinear_method:
            assert op.BuiltinOptionsType() == BuiltinOptions.ResizeBilinearOptions
            resize_options = ResizeBilinearOptions()
        elif tflite_ver >= 1130:
            assert op.BuiltinOptionsType() == BuiltinOptions.ResizeNearestNeighborOptions
            from tflite.ResizeNearestNeighborOptions import ResizeNearestNeighborOptions

            resize_options = ResizeNearestNeighborOptions()

        if resize_options is not None:
            op_options = op.BuiltinOptions()
            resize_options.Init(op_options.Bytes, op_options.Pos)
            align_corners = resize_options.AlignCorners()
            half_pixel_centers = resize_options.HalfPixelCenters()

        # Use layout NHWC
        coord_trans = "align_corners" if align_corners else "asymmetric"
        coord_trans = "half_pixel" if half_pixel_centers else coord_trans

        rounding_method = ""
        if method == "nearest_neighbor":
            if not align_corners and half_pixel_centers:
                rounding_method = "round_prefer_ceil"

        if bilinear_method and input_tensor.qnn_params:
            in_expr = self.dequantize(in_expr, input_tensor)
        out = relax.op.image.resize2d(
            in_expr, target_size, None, "NHWC", method, coord_trans, rounding_method
        )
        if bilinear_method and output_tensor.qnn_params:
            out = self.quantize(out, output_tensor)
        return out

    def convert_resize_bilinear(self, op):
        """Convert TFLite RESIZE_BILINEAR"""
        return self._convert_resize("linear", op)

    def convert_resize_nearest_neighbor(self, op):
        """Convert TFLite RESIZE_NEAREST_NEIGHBOR"""
        return self._convert_resize("nearest_neighbor", op)

    def convert_l2_normalization(self, op):
        """Convert TFLite L2_NORMALIZATION"""

        from tflite.BuiltinOptions import BuiltinOptions
        from tflite.L2NormOptions import L2NormOptions

        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 1, "input tensors length should be 1"
        input_tensor = input_tensors[0]
        in_expr = self.get_expr(input_tensor.tensor_idx)

        output_tensors = self.get_output_tensors(op)
        assert len(output_tensors) == 1, "output tensors length should be 1"
        output_tensor = output_tensors[0]

        assert op.BuiltinOptionsType() == BuiltinOptions.L2NormOptions
        op_options = op.BuiltinOptions()
        l2_norm_options = L2NormOptions()
        l2_norm_options.Init(op_options.Bytes, op_options.Pos)
        fused_activation_fn = l2_norm_options.FusedActivationFunction()

        # TFLite supports normalization only over the last dim
        input_tensor_rank = len(input_tensor.tensor.ShapeAsNumpy())

        if self.is_quantized(op):
            raise tvm.error.OpNotImplemented(
                "TFLite quantized L2_NORMALIZATION operator is not supported yet."
            )

        # TFL uses only the default epsilon value
        # Implement L2 normalization: output = input / sqrt(sum(input^2) + eps)
        # L2 normalization is applied along the last axis
        squared = relax.op.square(in_expr)
        sum_squared = relax.op.sum(squared, axis=input_tensor_rank - 1, keepdims=True)
        denom = relax.op.sqrt(relax.op.add(sum_squared, relax.const(1e-12, "float32")))
        out = relax.op.divide(in_expr, denom)

        # if we have fused activation fn
        if output_tensor.qnn_params:
            raise tvm.error.OpNotImplemented(
                "TFLite quantized L2_NORMALIZATION operator is not supported yet."
            )
        out = self.convert_fused_activation_function(out, fused_activation_fn)

        return out

    def convert_lrn(self, op):
        """Convert TFLite LOCAL_RESPONSE_NORMALIZATION"""

        from tflite.BuiltinOptions import BuiltinOptions
        from tflite.LocalResponseNormalizationOptions import LocalResponseNormalizationOptions

        if self.is_quantized(op):
            raise tvm.error.OpNotImplemented("TFlite quantized LRN operator is not supported yet.")

        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 1, "input tensors length should be 1"
        input_tensor = input_tensors[0]
        in_expr = self.get_expr(input_tensor.tensor_idx)

        output_tensors = self.get_output_tensors(op)
        assert len(output_tensors) == 1, "output tensors length should be 1"

        assert op.BuiltinOptionsType() == BuiltinOptions.LocalResponseNormalizationOptions
        op_options = op.BuiltinOptions()
        lrn_options = LocalResponseNormalizationOptions()
        lrn_options.Init(op_options.Bytes, op_options.Pos)
        radius = lrn_options.Radius()
        bias = lrn_options.Bias()
        alpha = lrn_options.Alpha()
        beta = lrn_options.Beta()
        size = (radius * 2) + 1
        alpha = alpha * size
        axis = 3  # NHWC format
        data_shape = to_int_list(self.get_tensor_shape(input_tensor))
        in_type = self.get_tensor_type_str(input_tensor.tensor.Type())

        # Relax currently does not expose a dedicated LRN op. Implement NHWC channel LRN
        # by pooling squared values over the channel axis.
        squared = self.bb.normalize(relax.op.square(in_expr))
        squared_2d = _op.reshape(squared, [-1, data_shape[axis], 1, 1])
        pooled = self.bb.normalize(
            relax.op.nn.avg_pool2d(
                squared_2d,
                pool_size=[size, 1],
                strides=[1, 1],
                padding=[radius, 0, radius, 0],
                layout="NHWC",
                count_include_pad=True,
            )
        )
        pooled = self.bb.normalize(_op.reshape(pooled, data_shape))
        denom = relax.op.power(
            relax.op.add(
                relax.const(bias, in_type), relax.op.multiply(relax.const(alpha, in_type), pooled)
            ),
            relax.const(beta, in_type),
        )
        out = relax.op.divide(in_expr, denom)

        return out

    def convert_logistic(self, op):
        """Convert TFLite LOGISTIC"""
        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 1, "input tensors length should be 1"

        input_tensor = input_tensors[0]
        in_expr = self.get_expr(input_tensor.tensor_idx)

        output_tensors = self.get_output_tensors(op)
        assert len(output_tensors) == 1, "output tensors length should be 1"
        output_tensor = output_tensors[0]

        if input_tensor.qnn_params:
            in_expr = self.dequantize(in_expr, input_tensor)
        out = relax.op.sigmoid(in_expr)
        if output_tensor.qnn_params:
            out = self.quantize(out, output_tensor)

        return out

    def convert_softmax(self, op):
        """Convert TFLite softmax"""
        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 1, "input tensors length should be 1"

        input_tensor = input_tensors[0]
        input_tensor_idx = input_tensor.tensor_idx

        output_tensors = self.get_output_tensors(op)
        assert len(output_tensors) == 1, "output tensors length should be 1"
        output_tensor = output_tensors[0]

        params = {"axis": -1}  # -1 is channel
        in_expr = self.get_expr(input_tensor_idx)

        # TODO - Naive softmax int8 implementation leads to bad accuracy. Currently, we can
        # dequantize to FP32 and perform softmax on FP32. We can investigate an integer only softmax
        # implementation in future.
        if input_tensor.qnn_params:
            in_expr = self.dequantize(in_expr, input_tensor)

        out = relax.op.nn.softmax(in_expr, **params)

        # Go back to integer dataype if the original operator was quantized.
        if output_tensor.qnn_params:
            out = self.quantize(out, output_tensor)

        return out

    def convert_tanh(self, op):
        """Convert TFLite TANH"""
        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 1, "input tensors length should be 1"
        input_tensor = input_tensors[0]
        in_expr = self.get_expr(input_tensor.tensor_idx)

        output_tensors = self.get_output_tensors(op)
        assert len(output_tensors) == 1, "output tensors length should be 1"
        output_tensor = output_tensors[0]

        if input_tensor.qnn_params:
            in_expr = self.dequantize(in_expr, input_tensor)
        out = relax.op.tanh(in_expr)
        if output_tensor.qnn_params:
            out = self.quantize(out, output_tensor)
        return out

    def convert_range(self, op):
        """Convert TFLite Range.

        Constant bounds lower directly to ``relax.op.arange``. Runtime (dynamic)
        scalar bounds are handled by computing the element count in-graph,
        lifting it to a symbolic output dimension, and rebuilding the values as
        ``arange(0, count) * delta + start`` (see ``_convert_dynamic_range``).
        """

        from tflite.TensorType import TensorType

        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 3, "input tensors length should be 3"

        start, limit, delta = input_tensors[0], input_tensors[1], input_tensors[2]

        # out type inference
        if delta.tensor.Type() == TensorType.FLOAT32:
            out_type = self.get_tensor_type_str(delta.tensor.Type())
        else:
            out_type = self.get_tensor_type_str(start.tensor.Type())

        def is_dynamic(tensor):
            return self.has_expr(tensor.tensor_idx) and not isinstance(
                self.get_expr(tensor.tensor_idx), relax.Constant
            )

        def static_scalar(tensor):
            if self.has_expr(tensor.tensor_idx):
                value = self.get_expr(tensor.tensor_idx).data.numpy()
            else:
                value = self.get_tensor_value(tensor)
            # TFLite RANGE operands are scalar tensors in the flatbuffer.
            assert value.size == 1, "RANGE scalar input must have exactly one element"
            return value.item()

        if not (is_dynamic(start) or is_dynamic(limit) or is_dynamic(delta)):
            return relax.op.arange(
                static_scalar(start), static_scalar(limit), static_scalar(delta), out_type
            )

        return self._convert_dynamic_range(start, limit, delta, out_type)

    def _scalar_tensor_to_dim(self, expr, name):
        """Lift a runtime scalar Relax expr to a symbolic ``tirx.Var`` dimension.

        Mirrors the ``tensor_to_shape`` + ``match_cast`` bridge used by
        ``_get_shape_expr_from_tensor`` so a data-dependent scalar can be used as
        a ``Expr`` (e.g. an output length). The scalar is cast to int64 first.
        """
        expr = self.bb.normalize(relax.op.astype(expr, "int64"))
        expr = self.bb.normalize(relax.op.reshape(expr, (1,)))
        expr = self.bb.match_cast(expr, relax.TensorType([1], "int64"))
        shape_var = self.bb.emit(relax.op.tensor_to_shape(expr))
        dim = tirx.Var(name, "int64")
        self.bb.match_cast(shape_var, relax.ShapeType([dim]))
        return dim

    def _convert_dynamic_range(self, start, limit, delta, out_type):
        """RANGE with dynamic (runtime) scalar bounds, for int and float dtypes.

        ``relax.op.arange`` only accepts compile-time ``Expr`` bounds, and its
        struct-info length formula lacks a negative-step branch, so feeding
        symbolic bounds directly would mis-declare descending ranges. Instead the
        element count ``max(0, ceil((limit - start) / delta))`` is computed
        in-graph and lifted to one symbolic dimension ``L`` (so the declared and
        runtime lengths match by construction); values are rebuilt as
        ``arange(0, L) * delta + start``.
        """
        # int ranges work in int64 for an exact, sign-agnostic count; float
        # ranges work in the output float dtype.
        work_type = out_type if out_type.startswith("float") else "int64"

        def scalar_expr(tensor):
            return self.bb.normalize(relax.op.astype(self.get_tensor_expr(tensor), work_type))

        start_e = scalar_expr(start)
        limit_e = scalar_expr(limit)
        delta_e = scalar_expr(delta)

        if work_type.startswith("float"):
            count = relax.op.ceil(relax.op.divide(relax.op.subtract(limit_e, start_e), delta_e))
        else:
            # ceil((limit - start) / delta) == -floordiv(start - limit, delta),
            # which stays exact and handles negative delta without a float cast.
            count = relax.op.negative(
                relax.op.floor_divide(relax.op.subtract(start_e, limit_e), delta_e)
            )
        count = relax.op.maximum(count, relax.const(0, work_type))
        dim = self._scalar_tensor_to_dim(count, "range_len")

        positions = self.bb.normalize(
            relax.op.astype(relax.op.arange(0, dim, 1, "int64"), work_type)
        )
        out = relax.op.add(relax.op.multiply(positions, delta_e), start_e)
        return out if work_type == out_type else relax.op.astype(out, out_type)

    def convert_rank(self, op):
        """Convert TFLite RANK."""
        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 1, "input tensors length should be 1"

        output_tensors = self.get_output_tensors(op)
        assert len(output_tensors) == 1, "output tensors length should be 1"
        output_dtype = self.get_tensor_type_str(output_tensors[0].tensor.Type())

        rank = len(self.get_tensor_shape(input_tensors[0]))
        return relax.const(rank, dtype=output_dtype)

    def convert_shape(self, op):
        """Convert TFLite Shape"""

        from tflite.BuiltinOptions import BuiltinOptions
        from tflite.ShapeOptions import ShapeOptions
        from tflite.TensorType import TensorType

        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 1, "input tensors length should be 1"

        assert op.BuiltinOptionsType() == BuiltinOptions.ShapeOptions
        op_options = op.BuiltinOptions()
        shape_options = ShapeOptions()
        shape_options.Init(op_options.Bytes, op_options.Pos)

        # SHAPE must materialize as a tensor output in Relax, not just symbolic shape info.
        out = relax.op.shape_to_tensor(relax.op.shape_of(self.get_tensor_expr(input_tensors[0])))
        if shape_options.OutType() == TensorType.INT32:
            out = relax.op.astype(out, "int32")

        return out

    def convert_bucketize(self, op):
        """Convert TFLite BUCKETIZE."""
        from tflite.BucketizeOptions import BucketizeOptions
        from tflite.BuiltinOptions import BuiltinOptions
        from tflite.TensorType import TensorType

        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 1, "input tensors length should be 1"

        assert op.BuiltinOptionsType() == BuiltinOptions.BucketizeOptions
        op_options = op.BuiltinOptions()
        bucketize_options = BucketizeOptions()
        bucketize_options.Init(op_options.Bytes, op_options.Pos)

        boundaries = self.bb.normalize(
            relax.const(bucketize_options.BoundariesAsNumpy(), dtype="float32")
        )
        out_tensor = self.get_output_tensors(op)[0]
        out_int32 = out_tensor.tensor.Type() == TensorType.INT32
        return relax.op.bucketize(
            self.get_tensor_expr(input_tensors[0]), boundaries, out_int32=out_int32, right=False
        )

    def convert_relu(self, op):
        """Convert TFLite ReLU"""

        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 1, "input tensors length should be 1"
        input_tensor = input_tensors[0]
        in_expr = self.get_expr(input_tensor.tensor_idx)

        output_tensors = self.get_output_tensors(op)
        assert len(output_tensors) == 1, "output tensors length should be 1"
        output_tensor = output_tensors[0]

        if input_tensor.qnn_params:
            in_f32 = self.dequantize(in_expr, input_tensor)
            out = relax.op.nn.relu(in_f32)
            out = self.quantize(out, output_tensor)
        else:
            out = relax.op.nn.relu(in_expr)

        return out

    def convert_relu_0_to_1(self, op):
        """Convert TFLite RELU_0_TO_1."""
        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 1, "input tensors length should be 1"
        input_tensor = input_tensors[0]
        in_expr = self.get_expr(input_tensor.tensor_idx)

        output_tensors = self.get_output_tensors(op)
        assert len(output_tensors) == 1, "output tensors length should be 1"
        output_tensor = output_tensors[0]

        if input_tensor.qnn_params:
            in_f32 = self.dequantize(in_expr, input_tensor)
            out = relax.op.clip(in_f32, 0, 1)
            out = self.quantize(out, output_tensor)
        else:
            out = relax.op.clip(in_expr, 0, 1)

        return out

    def convert_hard_swish(self, op):
        """Convert TFLite Hard swish"""
        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 1, "input tensors length should be 1"
        input_tensor = input_tensors[0]
        in_expr = self.get_expr(input_tensor.tensor_idx)

        output_tensors = self.get_output_tensors(op)
        assert len(output_tensors) == 1, "output tensors length should be 1"
        output_tensor = output_tensors[0]

        def _relu6(data):
            return relax.op.clip(data, min=0.0, max=6.0)

        def _hard_swish(data):
            return data * _relu6(data + relax.const(3.0)) / relax.const(6.0)

        # Dequantize if the input is quantized.
        if input_tensor.qnn_params:
            in_expr = self.dequantize(in_expr, input_tensor)

        # Perform hardswish
        out = _hard_swish(in_expr)

        # Go back to integer dataype if the original operator was quantized.
        if output_tensor.qnn_params:
            out = self.quantize(out, output_tensor)

        return out

    def convert_relu6(self, op):
        """Convert TFLite ReLU6"""

        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 1, "input tensors length should be 1"
        input_tensor = input_tensors[0]
        in_expr = self.get_expr(input_tensor.tensor_idx)

        output_tensors = self.get_output_tensors(op)
        assert len(output_tensors) == 1, "output tensors length should be 1"
        output_tensor = output_tensors[0]

        if input_tensor.qnn_params:
            in_f32 = self.dequantize(in_expr, input_tensor)
            out = relax.op.clip(in_f32, min=0, max=6)
            out = self.quantize(out, output_tensor)
        else:
            out = relax.op.clip(in_expr, min=0, max=6)

        return out

    def convert_leaky_relu(self, op):
        """Convert TFLite LEAKY_RELU"""

        from tflite.BuiltinOptions import BuiltinOptions
        from tflite.LeakyReluOptions import LeakyReluOptions

        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 1, "input tensors length should be 1"
        input_tensor = input_tensors[0]
        in_expr = self.get_expr(input_tensor.tensor_idx)

        assert op.BuiltinOptionsType() == BuiltinOptions.LeakyReluOptions
        op_options = op.BuiltinOptions()
        leaky_relu_options = LeakyReluOptions()
        leaky_relu_options.Init(op_options.Bytes, op_options.Pos)
        alpha_tensor = leaky_relu_options.Alpha()

        output_tensors = self.get_output_tensors(op)
        assert len(output_tensors) == 1, "output tensors length should be 1"
        output_tensor = output_tensors[0]

        if input_tensor.qnn_params:
            in_expr = self.dequantize(in_expr, input_tensor)
        out = relax.op.nn.leakyrelu(in_expr, alpha_tensor)
        if output_tensor.qnn_params:
            out = self.quantize(out, output_tensor)

        return out

    def convert_relu_n1_to_1(self, op):
        """Convert TFLite RELU_N1_TO_1"""
        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 1, "input tensors length should be 1"
        input_tensor = input_tensors[0]
        in_expr = self.get_expr(input_tensor.tensor_idx)

        output_tensors = self.get_output_tensors(op)
        assert len(output_tensors) == 1, "output tensors length should be 1"
        output_tensor = output_tensors[0]

        if input_tensor.qnn_params:
            in_f32 = self.dequantize(in_expr, input_tensor)
            out = relax.op.clip(in_f32, min=-1, max=1)
            out = self.quantize(out, output_tensor)
        else:
            out = relax.op.clip(in_expr, min=-1, max=1)

        return out

    def convert_log_softmax(self, op):
        """Convert TFLite LOG_SOFTMAX"""
        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 1, "input tensors length should be 1"
        input_tensor = input_tensors[0]
        in_expr = self.get_expr(input_tensor.tensor_idx)

        output_tensors = self.get_output_tensors(op)
        assert len(output_tensors) == 1, "output tensors length should be 1"
        output_tensor = output_tensors[0]

        if input_tensor.qnn_params:
            in_expr = self.dequantize(in_expr, input_tensor)
        out = relax.op.nn.log_softmax(in_expr)
        if output_tensor.qnn_params:
            out = self.quantize(out, output_tensor)

        return out

    def convert_concatenation(self, op):
        """Convert TFLite concatenation"""

        from tflite.BuiltinOptions import BuiltinOptions
        from tflite.ConcatenationOptions import ConcatenationOptions

        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) >= 1, "input tensors should greater than 1"
        in_exprs = [self.get_tensor_expr(_) for _ in input_tensors]

        output_tensors = self.get_output_tensors(op)
        assert len(output_tensors) == 1, "output tensors length should be 1"
        output_tensor = output_tensors[0]

        assert op.BuiltinOptionsType() == BuiltinOptions.ConcatenationOptions
        op_options = op.BuiltinOptions()
        concatenation_options = ConcatenationOptions()
        concatenation_options.Init(op_options.Bytes, op_options.Pos)
        concatenation_axis = concatenation_options.Axis()
        fused_activation_fn = concatenation_options.FusedActivationFunction()

        if not input_tensors[0].qnn_params:
            out = relax.op.concat(in_exprs, axis=concatenation_axis)
        else:
            in_f32s = [
                self.dequantize(expr, tensor) for expr, tensor in zip(in_exprs, input_tensors)
            ]
            out = relax.op.concat(in_f32s, axis=concatenation_axis)
            out = self.quantize(out, output_tensor)

        # Handle fused activations
        if output_tensor.qnn_params:
            scale_val = get_scalar_from_constant(output_tensor.qnn_params["scale"])
            zero_point_val = get_scalar_from_constant(output_tensor.qnn_params["zero_point"])
            output_tensor_type_str = self.get_tensor_type_str(output_tensor.tensor.Type())
            out = self.convert_qnn_fused_activation_function(
                expr=out,
                fused_activation_fn=fused_activation_fn,
                scale=scale_val,
                zero_point=zero_point_val,
                dtype=output_tensor_type_str,
            )
        else:
            out = self.convert_fused_activation_function(out, fused_activation_fn)

        return out

    def _convert_unary_elemwise(self, op, relax_op):
        """Generic method to convert TFLite unary elemwise functions"""
        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 1, "input tensors length should be 1"

        input_tensor = input_tensors[0]
        in_expr = self.get_expr(input_tensor.tensor_idx)

        output_tensors = self.get_output_tensors(op)
        assert len(output_tensors) == 1, "output tensors length should be 1"
        output_tensor = output_tensors[0]

        if input_tensor.qnn_params:
            in_expr = self.dequantize(in_expr, input_tensor)
        out = relax_op(in_expr)
        if output_tensor.qnn_params:
            out = self.quantize(out, output_tensor)
        return out

    def _convert_stablehlo_unary(self, op, relax_op):
        """Convert a unary StableHLO TFLite builtin operator.

        StableHLO builtins do not have TFLite fused activation attributes. Keep
        this path independent from the regular TFLite elemwise/QNN helpers so
        StableHLO semantics are mapped directly to Relax operators.
        """
        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 1, "input tensors length should be 1"

        assert len(self.get_output_tensors(op)) == 1, "output tensors length should be 1"

        in_expr = self.get_tensor_expr(input_tensors[0])
        return relax_op(in_expr)

    def _convert_stablehlo_binary(self, op, relax_op):
        """Convert a binary StableHLO TFLite builtin operator.

        StableHLO builtins do not have TFLite fused activation attributes. Keep
        this path independent from the regular TFLite elemwise/QNN helpers so
        StableHLO semantics are mapped directly to Relax operators.
        """
        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 2, "input tensors length should be 2"

        assert len(self.get_output_tensors(op)) == 1, "output tensors length should be 1"

        lhs_expr = self.get_tensor_expr(input_tensors[0])
        rhs_expr = self.get_tensor_expr(input_tensors[1])
        return relax_op(lhs_expr, rhs_expr)

    def _convert_stablehlo_and(self, op):
        """Convert StableHLO AND for bool and integer tensors."""
        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 2, "input tensors length should be 2"

        assert len(self.get_output_tensors(op)) == 1, "output tensors length should be 1"

        lhs = self.get_tensor_expr(input_tensors[0])
        rhs = self.get_tensor_expr(input_tensors[1])
        dtype = lhs.ty.dtype.dtype
        if dtype == "bool":
            op_fn = _op.logical_and
        elif dtype.startswith(("int", "uint")):
            op_fn = _op.bitwise_and
        else:
            raise tvm.error.OpNotImplemented(f"STABLEHLO_AND with dtype {dtype} is not supported")
        return self.bb.normalize(op_fn(lhs, rhs))

    def _convert_stablehlo_or(self, op):
        """Convert StableHLO OR for bool and integer tensors."""
        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 2, "input tensors length should be 2"

        assert len(self.get_output_tensors(op)) == 1, "output tensors length should be 1"

        lhs = self.get_tensor_expr(input_tensors[0])
        rhs = self.get_tensor_expr(input_tensors[1])
        dtype = lhs.ty.dtype.dtype
        if dtype == "bool":
            op_fn = _op.logical_or
        elif dtype.startswith(("int", "uint")):
            op_fn = _op.bitwise_or
        else:
            raise tvm.error.OpNotImplemented(f"STABLEHLO_OR with dtype {dtype} is not supported")
        return self.bb.normalize(op_fn(lhs, rhs))

    def _convert_stablehlo_ternary(self, op, relax_op):
        """Convert a ternary StableHLO TFLite builtin operator.

        StableHLO builtins do not have TFLite fused activation attributes. Keep
        this path independent from the regular TFLite elemwise/QNN helpers so
        StableHLO semantics are mapped directly to Relax operators.
        """
        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 3, "input tensors length should be 3"

        assert len(self.get_output_tensors(op)) == 1, "output tensors length should be 1"

        arg0 = self.get_tensor_expr(input_tensors[0])
        arg1 = self.get_tensor_expr(input_tensors[1])
        arg2 = self.get_tensor_expr(input_tensors[2])
        return relax_op(arg0, arg1, arg2)

    def _get_stablehlo_options(self, op, options_cls):
        """Parse BuiltinOptions2 for a StableHLO TFLite builtin operator.

        Returns an initialized options object of the given class.
        """
        from tflite.BuiltinOptions2 import BuiltinOptions2

        op_options = op.BuiltinOptions2()
        if op_options is None:
            # A malformed flatbuffer may declare a BuiltinOptions2 type without
            # carrying the actual options table. Fail cleanly instead of raising
            # an opaque AttributeError when accessing the missing payload.
            raise tvm.error.OpNotImplemented(
                f"{options_cls.__name__} is required but missing from the operator"
            )
        # Look up the expected BuiltinOptions2 enum value by matching the class
        # name to an enum member (e.g. StablehloConcatenateOptions → 1).
        options_type = getattr(BuiltinOptions2, options_cls.__name__, None)
        if options_type is not None:
            assert op.BuiltinOptions2Type() == options_type, (
                f"Unexpected BuiltinOptions2 type: expected "
                f"{options_cls.__name__}, got {op.BuiltinOptions2Type()}"
            )
        result = options_cls()
        result.Init(op_options.Bytes, op_options.Pos)
        return result

    def _get_static_tensor_shape(self, tensor, op_name):
        """Return a statically-known TFLite tensor shape as Python ints."""
        try:
            return [int(dim) for dim in self.get_tensor_shape(tensor)]
        except (TypeError, ValueError) as err:
            raise tvm.error.OpNotImplemented(
                f"{op_name} requires statically-known tensor shapes"
            ) from err

    def _get_stablehlo_i64_vector(self, vector, default):
        """Convert an optional StableHLO int64 vector field to a Python int list."""
        if vector is None or isinstance(vector, int):
            return list(default)
        return [int(v) for v in vector]

    def _ensure_stablehlo_float_dtype(self, expr, op_name):
        """Return expr dtype if the StableHLO subset supports it."""
        dtype = expr.ty.dtype.dtype
        if not dtype.startswith("float"):
            raise tvm.error.OpNotImplemented(f"{op_name} with dtype {dtype} is not supported")
        return dtype

    def _convert_stablehlo_cbrt(self, op):
        """Convert STABLEHLO_CBRT to a sign-preserving Relax expression."""
        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 1, "input tensors length should be 1"
        assert len(self.get_output_tensors(op)) == 1

        data = self.get_tensor_expr(input_tensors[0])
        dtype = self._ensure_stablehlo_float_dtype(data, "STABLEHLO_CBRT")
        zero = relax.const(0, dtype)
        exponent = relax.const(1.0 / 3.0, dtype)

        is_negative = self.bb.normalize(relax.op.less(data, zero))
        negative_base = self.bb.normalize(relax.op.negative(data))
        negative_root = self.bb.normalize(relax.op.power(negative_base, exponent))
        negative_result = self.bb.normalize(relax.op.negative(negative_root))
        positive_result = self.bb.normalize(relax.op.power(data, exponent))
        return self.bb.normalize(relax.op.where(is_negative, negative_result, positive_result))

    def _convert_stablehlo_remainder(self, op):
        """Convert STABLEHLO_REMAINDER to truncating remainder for float tensors."""
        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 2, "input tensors length should be 2"
        assert len(self.get_output_tensors(op)) == 1

        lhs = self.get_tensor_expr(input_tensors[0])
        rhs = self.get_tensor_expr(input_tensors[1])
        self._ensure_stablehlo_float_dtype(lhs, "STABLEHLO_REMAINDER")
        self._ensure_stablehlo_float_dtype(rhs, "STABLEHLO_REMAINDER")

        quotient = self.bb.normalize(relax.op.divide(lhs, rhs))
        truncated = self.bb.normalize(relax.op.trunc(quotient))
        product = self.bb.normalize(relax.op.multiply(rhs, truncated))
        return self.bb.normalize(relax.op.subtract(lhs, product))

    def _get_stablehlo_simple_body_op(self, body_subgraph_index, parent_op_name, input_count):
        """Return the single operator from a simple StableHLO body subgraph."""
        if body_subgraph_index <= 0 or body_subgraph_index >= self.model.SubgraphsLength():
            raise tvm.error.OpNotImplemented(
                f"{parent_op_name} requires a valid non-main body subgraph"
            )

        body_subgraph = self.model.Subgraphs(body_subgraph_index)
        if (
            body_subgraph.InputsLength() != input_count
            or body_subgraph.OutputsLength() != 1
            or body_subgraph.OperatorsLength() != 1
        ):
            raise tvm.error.OpNotImplemented(
                f"{parent_op_name} only supports single-op body subgraphs"
            )

        return body_subgraph.Operators(0)

    def _check_stablehlo_reduce_init(
        self, init_tensor, reducer_name, parent_op_name="STABLEHLO_REDUCE"
    ):
        """Validate that the StableHLO reduce init value matches the Relax identity."""
        if self.has_expr(init_tensor.tensor_idx):
            raise tvm.error.OpNotImplemented(
                f"{parent_op_name} with dynamic init values is not supported"
            )

        init_value = np.asarray(self.get_tensor_value(init_tensor))
        if init_value.shape not in [(), (1,)]:
            raise tvm.error.OpNotImplemented(f"{parent_op_name} requires scalar init values")

        dtype = init_value.dtype
        scalar = init_value.item()
        if reducer_name == "STABLEHLO_ADD":
            is_identity = bool(np.isclose(scalar, 0))
        elif reducer_name == "STABLEHLO_MULTIPLY":
            is_identity = bool(np.isclose(scalar, 1))
        elif reducer_name == "STABLEHLO_MAXIMUM":
            if np.issubdtype(dtype, np.floating):
                is_identity = bool(np.isneginf(scalar))
            elif np.issubdtype(dtype, np.integer):
                is_identity = scalar == np.iinfo(dtype).min
            else:
                is_identity = False
        elif reducer_name == "STABLEHLO_MINIMUM":
            if np.issubdtype(dtype, np.floating):
                is_identity = bool(np.isposinf(scalar))
            elif np.issubdtype(dtype, np.integer):
                is_identity = scalar == np.iinfo(dtype).max
            else:
                is_identity = False
        else:
            raise tvm.error.OpNotImplemented(
                f"{parent_op_name} reducer {reducer_name} is not supported"
            )

        if not is_identity:
            raise tvm.error.OpNotImplemented(
                f"{parent_op_name} init value must match the reducer identity"
            )

    def _convert_stablehlo_reduce(self, op):
        """Convert the single-input STABLEHLO_REDUCE subset to Relax reductions."""
        from tflite.StablehloReduceOptions import StablehloReduceOptions

        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 2, "input tensors length should be 2"
        assert len(self.get_output_tensors(op)) == 1

        opts = self._get_stablehlo_options(op, StablehloReduceOptions)
        dimensions = self._get_stablehlo_i64_vector(opts.DimensionsAsNumpy(), [])
        body_op = self._get_stablehlo_simple_body_op(
            int(opts.BodySubgraphIndex()), "STABLEHLO_REDUCE", 2
        )
        reducer_name = self.get_op_code_str(body_op)

        reducers = {
            "STABLEHLO_ADD": relax.op.sum,
            "STABLEHLO_MAXIMUM": relax.op.max,
            "STABLEHLO_MINIMUM": relax.op.min,
            "STABLEHLO_MULTIPLY": relax.op.prod,
        }
        if reducer_name not in reducers:
            raise tvm.error.OpNotImplemented(
                f"STABLEHLO_REDUCE reducer {reducer_name} is not supported"
            )

        self._check_stablehlo_reduce_init(input_tensors[1], reducer_name)
        data = self.get_tensor_expr(input_tensors[0])
        return self.bb.normalize(reducers[reducer_name](data, axis=dimensions, keepdims=False))

    def _convert_stablehlo_reduce_window(self, op):
        """Convert the NHWC 2D max-pool STABLEHLO_REDUCE_WINDOW subset."""
        from tflite.StablehloReduceWindowOptions import StablehloReduceWindowOptions

        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 2, "input tensors length should be 2"
        assert len(self.get_output_tensors(op)) == 1

        opts = self._get_stablehlo_options(op, StablehloReduceWindowOptions)
        body_op = self._get_stablehlo_simple_body_op(
            int(opts.BodySubgraphIndex()), "STABLEHLO_REDUCE_WINDOW", 2
        )
        reducer_name = self.get_op_code_str(body_op)
        if reducer_name != "STABLEHLO_MAXIMUM":
            raise tvm.error.OpNotImplemented(
                "STABLEHLO_REDUCE_WINDOW only supports MAXIMUM reducer windows"
            )
        self._check_stablehlo_reduce_init(input_tensors[1], reducer_name, "STABLEHLO_REDUCE_WINDOW")

        data_shape = self._get_static_tensor_shape(input_tensors[0], "STABLEHLO_REDUCE_WINDOW")
        if len(data_shape) != 4:
            raise tvm.error.OpNotImplemented("STABLEHLO_REDUCE_WINDOW only supports 4D input")

        window_dimensions = self._get_stablehlo_i64_vector(opts.WindowDimensionsAsNumpy(), [])
        window_strides = self._get_stablehlo_i64_vector(
            opts.WindowStridesAsNumpy(), [1] * len(window_dimensions)
        )
        base_dilations = self._get_stablehlo_i64_vector(
            opts.BaseDilationsAsNumpy(), [1] * len(window_dimensions)
        )
        window_dilations = self._get_stablehlo_i64_vector(
            opts.WindowDilationsAsNumpy(), [1] * len(window_dimensions)
        )
        padding = self._get_stablehlo_i64_vector(
            opts.PaddingAsNumpy(), [0] * (2 * len(window_dimensions))
        )

        if (
            len(window_dimensions) != 4
            or len(window_strides) != 4
            or len(base_dilations) != 4
            or len(window_dilations) != 4
            or len(padding) != 8
        ):
            raise tvm.error.OpNotImplemented(
                "STABLEHLO_REDUCE_WINDOW only supports rank-4 window attributes"
            )
        if window_dimensions[0] != 1 or window_dimensions[3] != 1:
            raise tvm.error.OpNotImplemented(
                "STABLEHLO_REDUCE_WINDOW only supports pooling over spatial dimensions"
            )
        if window_strides[0] != 1 or window_strides[3] != 1:
            raise tvm.error.OpNotImplemented(
                "STABLEHLO_REDUCE_WINDOW only supports unit batch/channel strides"
            )
        if base_dilations != [1, 1, 1, 1]:
            raise tvm.error.OpNotImplemented(
                "STABLEHLO_REDUCE_WINDOW with base dilation is not supported"
            )
        if padding[0] != 0 or padding[1] != 0 or padding[6] != 0 or padding[7] != 0:
            raise tvm.error.OpNotImplemented(
                "STABLEHLO_REDUCE_WINDOW only supports spatial padding"
            )

        data = self.get_tensor_expr(input_tensors[0])
        return self.bb.normalize(
            relax.op.nn.max_pool2d(
                data,
                pool_size=[window_dimensions[1], window_dimensions[2]],
                strides=[window_strides[1], window_strides[2]],
                padding=[padding[2], padding[4], padding[3], padding[5]],
                dilation=[window_dilations[1], window_dilations[2]],
                layout="NHWC",
                out_layout="NHWC",
            )
        )

    def _convert_stablehlo_scatter(self, op):
        """Convert the canonical point-update STABLEHLO_SCATTER subset."""
        from tflite.StablehloScatterOptions import StablehloScatterOptions

        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 3, "input tensors length should be 3"
        assert len(self.get_output_tensors(op)) == 1

        opts = self._get_stablehlo_options(op, StablehloScatterOptions)
        operand_shape = self._get_static_tensor_shape(input_tensors[0], "STABLEHLO_SCATTER")
        indices_shape = self._get_static_tensor_shape(input_tensors[1], "STABLEHLO_SCATTER")
        updates_shape = self._get_static_tensor_shape(input_tensors[2], "STABLEHLO_SCATTER")
        operand_rank = len(operand_shape)
        indices_rank = len(indices_shape)

        update_window_dims = self._get_stablehlo_i64_vector(opts.UpdateWindowDimsAsNumpy(), [])
        inserted_window_dims = self._get_stablehlo_i64_vector(opts.InsertedWindowDimsAsNumpy(), [])
        scatter_dims_to_operand_dims = self._get_stablehlo_i64_vector(
            opts.ScatterDimsToOperandDimsAsNumpy(), []
        )
        index_vector_dim = int(opts.IndexVectorDim())

        if indices_rank == 0 or index_vector_dim != indices_rank - 1:
            raise tvm.error.OpNotImplemented(
                "STABLEHLO_SCATTER only supports trailing index-vector dimensions"
            )
        if update_window_dims:
            raise tvm.error.OpNotImplemented(
                "STABLEHLO_SCATTER only supports point updates without update windows"
            )
        if inserted_window_dims != list(range(operand_rank)):
            raise tvm.error.OpNotImplemented(
                "STABLEHLO_SCATTER only supports point updates for every operand dimension"
            )
        if scatter_dims_to_operand_dims != list(range(operand_rank)):
            raise tvm.error.OpNotImplemented(
                "STABLEHLO_SCATTER only supports canonical scatter-to-operand dimensions"
            )
        if indices_shape[-1] != operand_rank or updates_shape != indices_shape[:-1]:
            raise tvm.error.OpNotImplemented(
                "STABLEHLO_SCATTER requires point update shapes to match scatter indices"
            )

        body_op = self._get_stablehlo_simple_body_op(
            int(opts.UpdateComputationSubgraphIndex()), "STABLEHLO_SCATTER", 2
        )
        reducer_name = self.get_op_code_str(body_op)
        reductions = {
            "STABLEHLO_ADD": "add",
            "STABLEHLO_MAXIMUM": "max",
            "STABLEHLO_MINIMUM": "min",
            "STABLEHLO_MULTIPLY": "mul",
        }
        if reducer_name not in reductions:
            raise tvm.error.OpNotImplemented(
                f"STABLEHLO_SCATTER reducer {reducer_name} is not supported"
            )

        operand = self.get_tensor_expr(input_tensors[0])
        indices = self.get_tensor_expr(input_tensors[1])
        updates = self.get_tensor_expr(input_tensors[2])
        return self.bb.normalize(
            relax.op.scatter_nd(operand, indices, updates, reductions[reducer_name])
        )

    def _convert_stablehlo_composite(self, op):
        """Convert STABLEHLO_COMPOSITE by inlining a simple decomposition subgraph."""
        from tflite.StableHLOCompositeOptions import StableHLOCompositeOptions

        input_tensors = self.get_input_tensors(op)
        output_tensors = self.get_output_tensors(op)
        if len(output_tensors) != 1:
            raise tvm.error.OpNotImplemented(
                "STABLEHLO_COMPOSITE only supports single-output decompositions"
            )

        opts = self._get_stablehlo_options(op, StableHLOCompositeOptions)
        composite_name = opts.Name()
        composite_name = (
            composite_name.decode("utf-8") if composite_name is not None else "<unnamed>"
        )
        if opts.CompositeAttributesLength() != 0:
            raise tvm.error.OpNotImplemented(
                f"STABLEHLO_COMPOSITE {composite_name} with composite attributes is not supported"
            )

        decomposition_subgraph_index = int(opts.DecompositionSubgraphIndex())
        if (
            decomposition_subgraph_index <= 0
            or decomposition_subgraph_index >= self.model.SubgraphsLength()
        ):
            raise tvm.error.OpNotImplemented(
                f"STABLEHLO_COMPOSITE {composite_name} requires a valid decomposition subgraph"
            )
        decomposition_subgraph = self.model.Subgraphs(decomposition_subgraph_index)
        if decomposition_subgraph.InputsLength() != len(input_tensors):
            raise tvm.error.OpNotImplemented(
                f"STABLEHLO_COMPOSITE {composite_name} decomposition input count mismatch"
            )
        if decomposition_subgraph.OutputsLength() != 1:
            raise tvm.error.OpNotImplemented(
                f"STABLEHLO_COMPOSITE {composite_name} only supports single-output decompositions"
            )

        decomposition_exp_tab = ExprTable()
        decomposition_converter = OperatorConverter(
            self.model, decomposition_subgraph, decomposition_exp_tab, self.bb
        )
        for decomposition_input_idx, composite_input in zip(
            decomposition_subgraph.InputsAsNumpy(), input_tensors
        ):
            decomposition_input_name = get_tensor_name(
                decomposition_subgraph, int(decomposition_input_idx)
            )
            decomposition_exp_tab.set_expr(
                decomposition_input_name,
                self.get_tensor_expr(composite_input),
                force_override=True,
            )

        decomposition_converter.check_unsupported_ops()
        decomposition_converter.convert_op_to_relax()
        decomposition_output_idx = int(decomposition_subgraph.Outputs(0))
        decomposition_output_tensor = decomposition_converter.get_tensors(
            [decomposition_output_idx]
        )[0]
        for const_expr, value in decomposition_exp_tab.params.values():
            param_name = f"_param_{self.exp_tab.const_ctr}"
            self.exp_tab.const_ctr += 1
            self.exp_tab.params[param_name] = (const_expr, value)
        return decomposition_converter.get_tensor_expr(decomposition_output_tensor)

    def _convert_stablehlo_sort(self, op):
        """Convert the single-input STABLEHLO_SORT subset to Relax sort."""
        from tflite.StablehloCompareOptions import StablehloCompareOptions
        from tflite.StablehloComparisonDirection import StablehloComparisonDirection
        from tflite.StablehloComparisonType import StablehloComparisonType
        from tflite.StablehloSortOptions import StablehloSortOptions

        input_tensors = self.get_input_tensors(op)
        output_tensors = self.get_output_tensors(op)
        if len(input_tensors) != 1 or len(output_tensors) != 1:
            raise tvm.error.OpNotImplemented(
                "STABLEHLO_SORT only supports single-input single-output sort"
            )

        opts = self._get_stablehlo_options(op, StablehloSortOptions)
        if opts.IsStable():
            raise tvm.error.OpNotImplemented("STABLEHLO_SORT stable sort is not supported")

        body_op = self._get_stablehlo_simple_body_op(
            int(opts.ComparatorSubgraphIndex()), "STABLEHLO_SORT", 2
        )
        comparator_name = self.get_op_code_str(body_op)
        if comparator_name != "STABLEHLO_COMPARE":
            raise tvm.error.OpNotImplemented(
                f"STABLEHLO_SORT comparator {comparator_name} is not supported"
            )

        compare_opts = self._get_stablehlo_options(body_op, StablehloCompareOptions)
        if (
            compare_opts.CompareType()
            == StablehloComparisonType.STABLEHLO_COMPARISON_TYPE_FLOAT_TOTAL_ORDER
        ):
            raise tvm.error.OpNotImplemented(
                "STABLEHLO_SORT with TOTALORDER comparator is not supported"
            )

        direction = compare_opts.ComparisonDirection()
        _DIR = StablehloComparisonDirection
        if direction == _DIR.STABLEHLO_COMPARISON_DIRECTION_LT:
            descending = False
        elif direction == _DIR.STABLEHLO_COMPARISON_DIRECTION_GT:
            descending = True
        else:
            raise tvm.error.OpNotImplemented("STABLEHLO_SORT only supports LT or GT comparators")

        data = self.get_tensor_expr(input_tensors[0])
        return self.bb.normalize(
            relax.op.sort(data, axis=int(opts.Dimension()), descending=descending)
        )

    def _convert_stablehlo_custom_call(self, op):
        """Convert supported annotation-only STABLEHLO_CUSTOM_CALL targets."""
        from tflite.StablehloCustomCallOptions import StablehloCustomCallOptions

        input_tensors = self.get_input_tensors(op)
        output_tensors = self.get_output_tensors(op)
        opts = self._get_stablehlo_options(op, StablehloCustomCallOptions)
        call_target_name = self._decode_tflite_string(opts.CallTargetName())

        if call_target_name == "Sharding":
            # TensorFlow treats Sharding custom calls as metadata annotations
            # and may erase them by replacing the op with its input. Mirror
            # that identity semantics for the safe single-input/single-output
            # subset. The sharding spec in backend_config is intentionally
            # dropped for single-device import. TFLite has no runtime kernel
            # for general STABLEHLO_CUSTOM_CALL targets.
            if opts.HasSideEffect():
                raise tvm.error.OpNotImplemented(
                    "STABLEHLO_CUSTOM_CALL Sharding with side effects is not supported"
                )
            if opts.CalledComputationsLength() != 0:
                raise tvm.error.OpNotImplemented(
                    "STABLEHLO_CUSTOM_CALL Sharding with called computations is not supported"
                )
            if len(input_tensors) != 1 or len(output_tensors) != 1:
                raise tvm.error.OpNotImplemented(
                    "STABLEHLO_CUSTOM_CALL Sharding requires one input and one output"
                )
            self._check_tensor_metadata_match(
                input_tensors[0], output_tensors[0], "STABLEHLO_CUSTOM_CALL", "Sharding"
            )
            return self.get_tensor_expr(input_tensors[0])

        target = call_target_name or "<empty>"
        raise tvm.error.OpNotImplemented(f"STABLEHLO_CUSTOM_CALL target {target} is not supported")

    def _convert_stablehlo_rng_bit_generator(self, op):
        """Convert STABLEHLO_RNG_BIT_GENERATOR to a bit-exact call_tir kernel."""
        from tflite.RngAlgorithm import RngAlgorithm
        from tflite.StablehloRngBitGeneratorOptions import StablehloRngBitGeneratorOptions

        op_name = "STABLEHLO_RNG_BIT_GENERATOR"
        input_tensors = self.get_input_tensors(op)
        output_tensors = self.get_output_tensors(op)
        if len(input_tensors) != 1 or len(output_tensors) != 2:
            raise tvm.error.OpNotImplemented(f"{op_name} expects one input and two outputs")

        opts = self._get_stablehlo_options(op, StablehloRngBitGeneratorOptions)
        algorithm_enum = opts.Algorithm()
        # DEFAULT resolves to PHILOX in the TFLite runtime kernel.
        if algorithm_enum == RngAlgorithm.THREEFRY:
            algorithm = "threefry"
        elif algorithm_enum in (RngAlgorithm.PHILOX, RngAlgorithm.DEFAULT):
            algorithm = "philox"
        else:
            raise tvm.error.OpNotImplemented(
                f"{op_name} algorithm {algorithm_enum} is not supported"
            )

        state_tensor = input_tensors[0]
        if self.get_tensor_type_str(state_tensor.tensor.Type()) != "uint64":
            raise tvm.error.OpNotImplemented(f"{op_name} requires a uint64 initial state")
        state_shape = self._get_static_tensor_shape(state_tensor, op_name)
        if len(state_shape) != 1:
            raise tvm.error.OpNotImplemented(f"{op_name} requires a 1-D initial state")
        state_len = int(state_shape[0])
        # State-length constraints mirror the TFLite runtime kernel.
        if algorithm == "threefry" and state_len != 2:
            raise tvm.error.OpNotImplemented(f"{op_name} THREEFRY requires a u64[2] state")
        if algorithm == "philox" and state_len not in (2, 3):
            raise tvm.error.OpNotImplemented(f"{op_name} PHILOX requires a u64[2] or u64[3] state")

        out_state_tensor, out_tensor = output_tensors
        if self.get_tensor_type_str(out_state_tensor.tensor.Type()) != "uint64":
            raise tvm.error.OpNotImplemented(f"{op_name} output state must be uint64")
        out_state_shape = self._get_static_tensor_shape(out_state_tensor, op_name)
        if list(out_state_shape) != list(state_shape):
            raise tvm.error.OpNotImplemented(
                f"{op_name} output state shape must match the initial state"
            )
        out_dtype = self.get_tensor_type_str(out_tensor.tensor.Type())
        if out_dtype not in ("int32", "int64", "uint32", "uint64"):
            raise tvm.error.OpNotImplemented(f"{op_name} output dtype {out_dtype} is not supported")
        out_shape = tuple(self._get_static_tensor_shape(out_tensor, op_name))

        prim_func = _build_stablehlo_rng_bit_generator_primfunc(
            algorithm, state_len, out_dtype, out_shape
        )
        module_builder = self.conversion_state["module_builder"]
        func_name = f"tflite_stablehlo_rng_{algorithm}_{out_state_tensor.tensor_idx}"
        gv = module_builder.add_func(prim_func, func_name)
        state_expr = self.get_tensor_expr(state_tensor)
        call = relax.call_tir(
            gv,
            [state_expr],
            [
                relax.TensorType(tuple(state_shape), "uint64"),
                relax.TensorType(out_shape, out_dtype),
            ],
        )
        return self.bb.normalize(call)

    def _convert_stablehlo_while(self, op):
        """Convert STABLEHLO_WHILE to a recursive Relax private function."""
        from tflite.StablehloWhileOptions import StablehloWhileOptions

        opts = self._get_stablehlo_options(op, StablehloWhileOptions)
        return self._convert_while_like(
            op,
            "STABLEHLO_WHILE",
            int(opts.CondSubgraphIndex()),
            int(opts.BodySubgraphIndex()),
            "tflite_stablehlo_while",
        )

    def _get_builtin_options(self, op, options_cls):
        """Parse BuiltinOptions for a TFLite builtin operator."""
        from tflite.BuiltinOptions import BuiltinOptions

        op_options = op.BuiltinOptions()
        if op_options is None:
            raise tvm.error.OpNotImplemented(f"{options_cls.__name__} is required")

        options_type = getattr(BuiltinOptions, options_cls.__name__, None)
        if options_type is not None and op.BuiltinOptionsType() != options_type:
            raise tvm.error.OpNotImplemented(
                f"Unexpected BuiltinOptions type: expected "
                f"{options_cls.__name__}, got {op.BuiltinOptionsType()}"
            )
        result = options_cls()
        result.Init(op_options.Bytes, op_options.Pos)
        return result

    def _get_subgraph(self, subgraph_index, op_name, allow_main=False):
        """Return a validated TFLite subgraph by index."""
        if subgraph_index < 0 or subgraph_index >= self.model.SubgraphsLength():
            raise tvm.error.OpNotImplemented(f"{op_name} requires a valid subgraph index")
        if not allow_main and subgraph_index == 0:
            raise tvm.error.OpNotImplemented(f"{op_name} cannot target the main subgraph")
        return self.model.Subgraphs(subgraph_index)

    def _make_tuple_or_single(self, exprs):
        """Return a single expression or Relax tuple for a list of expressions."""
        if len(exprs) == 1:
            return exprs[0]
        return relax.Tuple(exprs)

    def _indices_or_empty(self, indices):
        """Return a TFLite index vector, using an empty list for absent vectors."""
        return indices if indices is not None else []

    def _check_subgraph_io(self, subgraph_index, op_name, input_count=None, output_count=None):
        """Validate a referenced subgraph's input and output counts."""
        subgraph = self._get_subgraph(subgraph_index, op_name)
        if input_count is not None and subgraph.InputsLength() != input_count:
            raise tvm.error.OpNotImplemented(f"{op_name} subgraph input count mismatch")
        if output_count is not None and subgraph.OutputsLength() != output_count:
            raise tvm.error.OpNotImplemented(f"{op_name} subgraph output count mismatch")
        return subgraph

    def _check_subgraph_interface(
        self,
        subgraph_index,
        op_name,
        input_tensors=None,
        output_tensors=None,
        input_count=None,
        output_count=None,
    ):
        """Validate a referenced subgraph's arity and tensor metadata."""
        if input_tensors is not None:
            input_count = len(input_tensors)
        if output_tensors is not None:
            output_count = len(output_tensors)

        subgraph = self._check_subgraph_io(
            subgraph_index, op_name, input_count=input_count, output_count=output_count
        )
        if input_tensors is not None:
            self._check_subgraph_tensor_metadata(
                subgraph,
                op_name,
                "subgraph input",
                subgraph.InputsAsNumpy(),
                input_tensors,
            )
        if output_tensors is not None:
            self._check_subgraph_tensor_metadata(
                subgraph,
                op_name,
                "subgraph output",
                subgraph.OutputsAsNumpy(),
                output_tensors,
            )
        return subgraph

    def _get_tensor_metadata(self, tensor):
        """Return static shape and dtype metadata for a TFLite tensor."""
        if isinstance(tensor, TensorWrapper):
            tensor = tensor.tensor
        shape = self._get_relax_tensor_shape(tensor)
        dtype = self._get_relax_tensor_dtype(tensor)
        return shape, dtype

    def _check_tensor_metadata_match(self, actual, expected, op_name, tensor_role):
        """Validate that two TFLite tensors have matching static metadata."""
        if self._get_tensor_metadata(actual) != self._get_tensor_metadata(expected):
            raise tvm.error.OpNotImplemented(f"{op_name} {tensor_role} tensor metadata mismatch")

    def _check_subgraph_tensor_metadata(
        self, subgraph, op_name, tensor_role, subgraph_indices, expected_tensors
    ):
        """Validate referenced subgraph tensor metadata against caller tensors."""
        for subgraph_index, expected_tensor in zip(
            self._indices_or_empty(subgraph_indices), expected_tensors
        ):
            self._check_tensor_metadata_match(
                subgraph.Tensors(int(subgraph_index)),
                expected_tensor,
                op_name,
                tensor_role,
            )

    def _require_scalar_bool_tensor(self, tensor, op_name):
        """Validate that a TFLite tensor is a scalar bool tensor."""
        if isinstance(tensor, TensorWrapper):
            tensor = tensor.tensor
        dtype = self.get_tensor_type_str(tensor.Type())
        if dtype != "bool" or tensor.ShapeLength() != 0:
            raise tvm.error.OpNotImplemented(f"{op_name} requires a scalar bool condition")

    def _get_subgraph_params(self, subgraph):
        """Create Relax parameters for a TFLite subgraph."""
        params = []
        exp_tab = ExprTable()
        for input_index in self._indices_or_empty(subgraph.InputsAsNumpy()):
            tensor = subgraph.Tensors(int(input_index))
            input_name = get_tensor_name(subgraph, int(input_index))
            shape = self._get_relax_tensor_shape(tensor)
            dtype = self._get_relax_tensor_dtype(tensor)
            param = relax.Var(input_name, relax.TensorType(shape=shape, dtype=dtype))
            exp_tab.set_expr(input_name, param)
            params.append(param)
        return params, exp_tab

    def _get_tensor_param(self, tensor_wrapper):
        """Create a Relax parameter from TFLite tensor metadata."""
        name = get_tensor_name(self.subgraph, tensor_wrapper.tensor_idx)
        shape = self._get_relax_tensor_shape(tensor_wrapper)
        dtype = self._get_relax_tensor_dtype(tensor_wrapper)
        return relax.Var(name, relax.TensorType(shape=shape, dtype=dtype))

    def _lower_subgraph_to_function(self, subgraph_index, function_name_hint, op_name="CALL"):
        """Lower a TFLite subgraph into a private Relax function."""
        lowered_subgraphs = self.conversion_state["lowered_subgraphs"]
        if subgraph_index in lowered_subgraphs:
            return lowered_subgraphs[subgraph_index]

        lowering_stack = self.conversion_state["lowering_stack"]
        if subgraph_index in lowering_stack:
            raise tvm.error.OpNotImplemented(
                f"Recursive TFLite {op_name} subgraphs are not supported"
            )

        subgraph = self._get_subgraph(subgraph_index, op_name)
        lowering_stack.append(subgraph_index)
        try:
            params, subgraph_exp_tab = self._get_subgraph_params(subgraph)
            subgraph_bb = relax.BlockBuilder()
            with subgraph_bb.function(function_name_hint, params=params, private=True):
                with subgraph_bb.dataflow():
                    subgraph_converter = type(self)(
                        self.model,
                        subgraph,
                        subgraph_exp_tab,
                        subgraph_bb,
                        self.conversion_state,
                    )
                    subgraph_converter.check_unsupported_ops()
                    subgraph_converter.convert_op_to_relax()
                    output_tensors = subgraph_converter.get_tensors(subgraph.OutputsAsNumpy())
                    outputs = [
                        subgraph_converter.get_tensor_expr(tensor) for tensor in output_tensors
                    ]
                    output = subgraph_bb.emit_output(self._make_tuple_or_single(outputs))
                subgraph_bb.emit_func_output(output)

            subgraph_mod = subgraph_bb.get()
            module_builder = self.conversion_state["module_builder"]
            gv = module_builder.add_func(subgraph_mod[function_name_hint], function_name_hint)
            lowered_subgraphs[subgraph_index] = gv
            return gv
        finally:
            lowering_stack.pop()

    def _bind_call_outputs(self, call, output_count):
        """Return per-output expressions from a single or tuple-valued call."""
        if output_count == 1:
            return [call]
        return [call[index] for index in range(output_count)]

    def _lower_if_to_function(
        self,
        then_subgraph_index,
        else_subgraph_index,
        input_tensors,
        branch_input_count,
        output_count,
    ):
        """Lower a TFLite IF op into a private Relax function."""
        cache_key = (then_subgraph_index, else_subgraph_index, branch_input_count, output_count)
        lowered_if_functions = self.conversion_state["lowered_if_functions"]
        if cache_key in lowered_if_functions:
            return lowered_if_functions[cache_key]

        then_func = self._lower_subgraph_to_function(
            then_subgraph_index,
            f"tflite_if_then_subgraph_{then_subgraph_index}",
            op_name="IF",
        )
        else_func = self._lower_subgraph_to_function(
            else_subgraph_index,
            f"tflite_if_else_subgraph_{else_subgraph_index}",
            op_name="IF",
        )
        if_name = f"tflite_if_subgraph_{then_subgraph_index}_{else_subgraph_index}"
        params = [self._get_tensor_param(tensor) for tensor in input_tensors]
        cond = params[0]
        branch_args = params[1:]

        if_bb = relax.BlockBuilder()
        with if_bb.function(if_name, params=params, private=True):
            result = relax.If(
                cond,
                relax.Call(then_func, branch_args),
                relax.Call(else_func, branch_args),
            )
            if_bb.emit_func_output(result)
        if_func = if_bb.get()[if_name]
        module_builder = self.conversion_state["module_builder"]
        gv = module_builder.add_func(if_func, if_name)
        lowered_if_functions[cache_key] = gv
        return gv

    def _lower_while_to_function(
        self,
        cond_subgraph_index,
        body_subgraph_index,
        loop_var_count,
        cond_func,
        body_func,
        body_subgraph,
        function_prefix="tflite_while",
    ):
        """Lower a TFLite WHILE op into a recursive private Relax function."""
        cache_key = (function_prefix, cond_subgraph_index, body_subgraph_index, loop_var_count)
        lowered_while_functions = self.conversion_state["lowered_while_functions"]
        if cache_key in lowered_while_functions:
            return lowered_while_functions[cache_key]

        loop_name = f"{function_prefix}_subgraph_{cond_subgraph_index}_{body_subgraph_index}"
        params, _ = self._get_subgraph_params(body_subgraph)
        dummy_body = self._make_tuple_or_single(params)
        module_builder = self.conversion_state["module_builder"]
        loop_gv = module_builder.add_func(relax.Function(params, dummy_body), loop_name)
        lowered_while_functions[cache_key] = loop_gv

        loop_bb = relax.BlockBuilder()
        with loop_bb.function(loop_name, params=params, private=True):
            cond = loop_bb.emit(relax.Call(cond_func, params), "while_cond")
            next_state = relax.Call(body_func, params)
            next_args = self._bind_call_outputs(next_state, loop_var_count)
            true_branch = relax.Call(loop_gv, next_args)
            false_branch = self._make_tuple_or_single(params)
            result = relax.If(cond, true_branch, false_branch)
            loop_bb.emit_func_output(result)
        loop_func = loop_bb.get()[loop_name]
        module_builder.update_func(loop_gv, loop_func)
        return loop_gv

    def convert_call(self, op):
        """Convert TFLite CALL to a Relax private function call."""
        from tflite.CallOptions import CallOptions

        opts = self._get_builtin_options(op, CallOptions)
        subgraph_index = int(opts.Subgraph())
        input_tensors = self.get_input_tensors(op)
        output_tensors = self.get_output_tensors(op)
        self._check_subgraph_interface(
            subgraph_index,
            "CALL",
            input_tensors=input_tensors,
            output_tensors=output_tensors,
        )

        callee = self._lower_subgraph_to_function(
            subgraph_index, f"tflite_call_subgraph_{subgraph_index}", op_name="CALL"
        )
        args = [self.get_tensor_expr(tensor) for tensor in input_tensors]
        return relax.Call(callee, args)

    def convert_if(self, op):
        """Convert TFLite IF to Relax If with private branch functions."""
        from tflite.IfOptions import IfOptions

        opts = self._get_builtin_options(op, IfOptions)
        then_subgraph_index = int(opts.ThenSubgraphIndex())
        else_subgraph_index = int(opts.ElseSubgraphIndex())
        input_tensors = self.get_input_tensors(op)
        output_tensors = self.get_output_tensors(op)
        if len(input_tensors) < 1:
            raise tvm.error.OpNotImplemented("IF requires a condition input")

        self._require_scalar_bool_tensor(input_tensors[0], "IF")
        branch_input_count = len(input_tensors) - 1
        output_count = len(output_tensors)
        branch_input_tensors = input_tensors[1:]
        self._check_subgraph_interface(
            then_subgraph_index,
            "IF",
            input_tensors=branch_input_tensors,
            output_tensors=output_tensors,
        )
        self._check_subgraph_interface(
            else_subgraph_index,
            "IF",
            input_tensors=branch_input_tensors,
            output_tensors=output_tensors,
        )

        if_func = self._lower_if_to_function(
            then_subgraph_index,
            else_subgraph_index,
            input_tensors,
            branch_input_count,
            output_count,
        )
        args = [self.get_tensor_expr(tensor) for tensor in input_tensors]
        return relax.Call(if_func, args)

    def _convert_while_like(
        self, op, op_name, cond_subgraph_index, body_subgraph_index, function_prefix
    ):
        """Convert a TFLite while-like operator with referenced cond/body subgraphs."""
        input_tensors = self.get_input_tensors(op)
        output_tensors = self.get_output_tensors(op)
        loop_var_count = len(input_tensors)
        if loop_var_count == 0:
            raise tvm.error.OpNotImplemented(f"{op_name} requires loop-carried inputs")
        if len(output_tensors) != loop_var_count:
            raise tvm.error.OpNotImplemented(f"{op_name} output count must match input count")

        cond_subgraph = self._check_subgraph_interface(
            cond_subgraph_index,
            op_name,
            input_tensors=input_tensors,
            output_count=1,
        )
        body_subgraph = self._check_subgraph_interface(
            body_subgraph_index,
            op_name,
            input_tensors=input_tensors,
            output_tensors=input_tensors,
        )
        for input_tensor, output_tensor in zip(input_tensors, output_tensors):
            self._check_tensor_metadata_match(input_tensor, output_tensor, op_name, "loop state")
        cond_output = cond_subgraph.Tensors(int(cond_subgraph.Outputs(0)))
        self._require_scalar_bool_tensor(cond_output, op_name)

        cond_func = self._lower_subgraph_to_function(
            cond_subgraph_index,
            f"{function_prefix}_cond_subgraph_{cond_subgraph_index}",
            op_name=op_name,
        )
        body_func = self._lower_subgraph_to_function(
            body_subgraph_index,
            f"{function_prefix}_body_subgraph_{body_subgraph_index}",
            op_name=op_name,
        )

        loop_gv = self._lower_while_to_function(
            cond_subgraph_index,
            body_subgraph_index,
            loop_var_count,
            cond_func,
            body_func,
            body_subgraph,
            function_prefix=function_prefix,
        )

        args = [self.get_tensor_expr(tensor) for tensor in input_tensors]
        return relax.Call(loop_gv, args)

    def convert_while(self, op):
        """Convert TFLite WHILE to a recursive Relax private function."""
        from tflite.WhileOptions import WhileOptions

        opts = self._get_builtin_options(op, WhileOptions)
        return self._convert_while_like(
            op,
            "WHILE",
            int(opts.CondSubgraphIndex()),
            int(opts.BodySubgraphIndex()),
            "tflite_while",
        )

    def convert_call_once(self, op):
        """Convert TFLite CALL_ONCE for no-op and resource-variable initialization subsets."""
        from tflite.CallOnceOptions import CallOnceOptions

        opts = self._get_builtin_options(op, CallOnceOptions)
        init_subgraph_index = int(opts.InitSubgraphIndex())
        input_tensors = self.get_input_tensors(op)
        output_tensors = self.get_output_tensors(op)
        if len(input_tensors) != 0 or len(output_tensors) != 0:
            raise tvm.error.OpNotImplemented("CALL_ONCE with inputs or outputs is not supported")

        init_subgraph = self._get_subgraph(init_subgraph_index, "CALL_ONCE")
        if init_subgraph.InputsLength() != 0 or init_subgraph.OutputsLength() != 0:
            raise tvm.error.OpNotImplemented(
                "CALL_ONCE with non-empty init subgraph I/O is not supported"
            )
        if init_subgraph.OperatorsLength() != 0:
            self._convert_call_once_init_subgraph(init_subgraph)
        return None

    def _convert_call_once_init_subgraph(self, init_subgraph):
        """Convert the resource-variable initialization subset of a CALL_ONCE subgraph."""
        supported_init_ops = {"VAR_HANDLE", "ASSIGN_VARIABLE", "HASHTABLE", "HASHTABLE_IMPORT"}
        for op_idx in range(init_subgraph.OperatorsLength()):
            op_name = self.get_op_code_str(init_subgraph.Operators(op_idx))
            if op_name not in supported_init_ops:
                raise tvm.error.OpNotImplemented(
                    f"CALL_ONCE init subgraph operator {op_name} is not supported"
                )

        old_in_call_once_init = self.conversion_state["in_call_once_init"]
        self.conversion_state["in_call_once_init"] = True
        try:
            # The supported init ops below only update importer state and return None.
            # If future CALL_ONCE ops emit Relax bindings, revisit sharing the parent builder.
            subgraph_converter = type(self)(
                self.model,
                init_subgraph,
                ExprTable(),
                self.bb,
                self.conversion_state,
            )
            subgraph_converter.check_unsupported_ops()
            subgraph_converter.convert_op_to_relax()
        finally:
            self.conversion_state["in_call_once_init"] = old_in_call_once_init

    def _convert_stablehlo_convert(self, op):
        """Convert STABLEHLO_CONVERT to Relax (astype).

        Reads the output tensor dtype from the TFLite schema and applies
        relax.op.astype.  This path is intentionally separate from the
        generic _convert_stablehlo_unary helper because the output dtype
        is operator-level metadata, not a Relax op parameter.
        """
        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 1, "input tensors length should be 1"

        output_tensors = self.get_output_tensors(op)
        assert len(output_tensors) == 1, "output tensors length should be 1"

        in_expr = self.get_tensor_expr(input_tensors[0])
        output_dtype = self.get_tensor_type_str(output_tensors[0].tensor.Type())
        return self.bb.normalize(relax.op.astype(in_expr, output_dtype))

    def _convert_stablehlo_clamp(self, op):
        """Convert STABLEHLO_CLAMP to Relax.

        StableHLO clamp(min, operand, max) → R.minimum(R.maximum(operand, min), max).
        """
        # NOTE: R.clip is not used here because it only accepts scalar Expr
        # min/max, not tensor inputs.
        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 3, "input tensors length should be 3"

        assert len(self.get_output_tensors(op)) == 1

        min_expr = self.get_tensor_expr(input_tensors[0])
        operand_expr = self.get_tensor_expr(input_tensors[1])
        max_expr = self.get_tensor_expr(input_tensors[2])

        clamped = self.bb.normalize(relax.op.maximum(operand_expr, min_expr))
        return self.bb.normalize(relax.op.minimum(clamped, max_expr))

    def _convert_stablehlo_concatenate(self, op):
        """Convert STABLEHLO_CONCATENATE to Relax."""
        from tflite.StablehloConcatenateOptions import StablehloConcatenateOptions

        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) >= 1, "input tensors length should be >= 1"
        assert len(self.get_output_tensors(op)) == 1

        opts = self._get_stablehlo_options(op, StablehloConcatenateOptions)
        dim = opts.Dimension()

        in_exprs = [self.get_tensor_expr(t) for t in input_tensors]
        return self.bb.normalize(relax.op.concat(in_exprs, axis=dim))

    def _convert_stablehlo_broadcast_in_dim(self, op):
        """Convert STABLEHLO_BROADCAST_IN_DIM to Relax."""
        from tflite.StablehloBroadcastInDimOptions import StablehloBroadcastInDimOptions

        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 1
        output_tensors = self.get_output_tensors(op)
        assert len(output_tensors) == 1

        opts = self._get_stablehlo_options(op, StablehloBroadcastInDimOptions)
        broadcast_dims = [int(d) for d in opts.BroadcastDimensionsAsNumpy()]

        in_expr = self.get_tensor_expr(input_tensors[0])
        input_shape = [int(d) for d in self.get_tensor_shape(input_tensors[0])]
        output_shape = [int(d) for d in self.get_tensor_shape(output_tensors[0])]

        # Map input dims to output dims via broadcast_dims, filling
        # unmapped positions with 1 so broadcast_to covers them.
        intermediate_shape = [1] * len(output_shape)
        for i, d in enumerate(broadcast_dims):
            intermediate_shape[d] = input_shape[i]

        reshaped = self.bb.normalize(relax.op.reshape(in_expr, intermediate_shape))
        return self.bb.normalize(relax.op.broadcast_to(reshaped, output_shape))

    def _convert_stablehlo_iota(self, op):
        """Convert STABLEHLO_IOTA to Relax (arange + broadcast)."""
        from tflite.StablehloIotaOptions import StablehloIotaOptions

        output_tensors = self.get_output_tensors(op)
        assert len(output_tensors) == 1

        opts = self._get_stablehlo_options(op, StablehloIotaOptions)
        iota_dim = opts.IotaDimension()

        output_tensor = output_tensors[0]
        output_shape = [int(d) for d in self.get_tensor_shape(output_tensor)]
        output_dtype = self.get_tensor_type_str(output_tensor.tensor.Type())

        # arange along the iota dimension
        size = output_shape[iota_dim]
        arange_1d = self.bb.normalize(relax.op.arange(0, size, 1, output_dtype))

        # reshape to [1, ..., size, ..., 1]
        broadcast_shape = [1] * len(output_shape)
        broadcast_shape[iota_dim] = size
        arange_reshaped = self.bb.normalize(relax.op.reshape(arange_1d, broadcast_shape))

        # broadcast to full output shape
        return self.bb.normalize(relax.op.broadcast_to(arange_reshaped, output_shape))

    def _convert_stablehlo_compare(self, op):
        """Convert STABLEHLO_COMPARE to Relax binary comparison ops."""
        from tflite.StablehloCompareOptions import StablehloCompareOptions
        from tflite.StablehloComparisonDirection import StablehloComparisonDirection

        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 2
        assert len(self.get_output_tensors(op)) == 1

        from tflite.StablehloComparisonType import StablehloComparisonType

        opts = self._get_stablehlo_options(op, StablehloCompareOptions)
        direction = opts.ComparisonDirection()
        compare_type = opts.CompareType()

        # TOTALORDER compare is not expressible via Relax comparison ops.
        if compare_type == StablehloComparisonType.STABLEHLO_COMPARISON_TYPE_FLOAT_TOTAL_ORDER:
            raise tvm.error.OpNotImplemented(
                "STABLEHLO_COMPARE with TOTALORDER comparison type is not supported"
            )

        _DIR = StablehloComparisonDirection
        direction_map = {
            _DIR.STABLEHLO_COMPARISON_DIRECTION_EQ: relax.op.equal,
            _DIR.STABLEHLO_COMPARISON_DIRECTION_NE: relax.op.not_equal,
            _DIR.STABLEHLO_COMPARISON_DIRECTION_GE: relax.op.greater_equal,
            _DIR.STABLEHLO_COMPARISON_DIRECTION_GT: relax.op.greater,
            _DIR.STABLEHLO_COMPARISON_DIRECTION_LE: relax.op.less_equal,
            _DIR.STABLEHLO_COMPARISON_DIRECTION_LT: relax.op.less,
        }
        relax_fn = direction_map.get(direction)
        if relax_fn is None:
            raise tvm.error.OpNotImplemented(
                f"Unsupported StableHLO comparison direction: {direction}"
            )

        lhs = self.get_tensor_expr(input_tensors[0])
        rhs = self.get_tensor_expr(input_tensors[1])
        return self.bb.normalize(relax_fn(lhs, rhs))

    def _convert_stablehlo_pad(self, op):
        """Convert STABLEHLO_PAD to Relax (nn.pad).

        Maps edge padding to R.nn.pad with constant mode.  Interior padding
        (dilation) is not supported in the first version.
        """
        from tflite.StablehloPadOptions import StablehloPadOptions

        input_tensors = self.get_input_tensors(op)
        # operand + padding_value
        assert len(input_tensors) == 2, "input tensors length should be 2"
        assert len(self.get_output_tensors(op)) == 1

        opts = self._get_stablehlo_options(op, StablehloPadOptions)
        edge_low = [int(d) for d in opts.EdgePaddingLowAsNumpy()]
        edge_high = [int(d) for d in opts.EdgePaddingHighAsNumpy()]
        interior = [int(d) for d in opts.InteriorPaddingAsNumpy()]

        if any(d != 0 for d in interior):
            raise tvm.error.OpNotImplemented(
                "STABLEHLO_PAD with interior (dilation) padding is not supported"
            )
        if any(d < 0 for d in edge_low) or any(d < 0 for d in edge_high):
            raise tvm.error.OpNotImplemented(
                "STABLEHLO_PAD with negative edge padding (crop) is not supported"
            )

        operand = self.get_tensor_expr(input_tensors[0])

        # R.nn.pad only supports a static Python float pad_value.
        pad_value_tensor = input_tensors[1]
        if not self.has_expr(pad_value_tensor.tensor_idx):
            pad_val = float(self.get_tensor_value(pad_value_tensor))
        else:
            raise tvm.error.OpNotImplemented(
                "STABLEHLO_PAD with dynamic padding value is not supported"
            )

        # R.nn.pad with flat pad_width: [lo0, hi0, lo1, hi1, ...]
        pad_width = []
        for lo, hi in zip(edge_low, edge_high):
            pad_width.extend([lo, hi])

        return self.bb.normalize(relax.op.nn.pad(operand, pad_width=pad_width, pad_value=pad_val))

    def _convert_stablehlo_dynamic_slice(self, op):
        """Convert STABLEHLO_DYNAMIC_SLICE to Relax (dynamic_strided_slice).

        Start indices are assumed to be constant (non-dynamic) values stored
        in the flatbuffer.  Truly dynamic (runtime) start indices require
        Relax arithmetic to compute begin/end from scalar inputs and are not
        yet supported.
        """
        from tflite.StablehloDynamicSliceOptions import StablehloDynamicSliceOptions

        input_tensors = self.get_input_tensors(op)
        # operand + N start-index scalars
        assert len(input_tensors) >= 2
        ndim = len(input_tensors) - 1
        assert len(self.get_output_tensors(op)) == 1

        opts = self._get_stablehlo_options(op, StablehloDynamicSliceOptions)
        slice_sizes = [int(d) for d in opts.SliceSizesAsNumpy()]
        assert len(slice_sizes) == ndim

        operand = self.get_tensor_expr(input_tensors[0])

        # Build constant 1D tensors for begin, end, strides
        # (assumes start values are constant in the flatbuffer)
        # TODO: support dynamic start indices via Relax arithmetic
        if any(self.has_expr(t.tensor_idx) for t in input_tensors[1:]):
            raise tvm.error.OpNotImplemented(
                "STABLEHLO_DYNAMIC_SLICE with dynamic start indices is not supported"
            )
        start_vals = [int(self.get_tensor_value(t)) for t in input_tensors[1:]]
        operand_shape = [int(d) for d in self.get_tensor_shape(input_tensors[0])]
        for start, size, dim in zip(start_vals, slice_sizes, operand_shape):
            if start < 0 or start + size > dim:
                raise tvm.error.OpNotImplemented(
                    "STABLEHLO_DYNAMIC_SLICE with out-of-bounds start indices is not supported"
                )
        end_vals = [s + sz for s, sz in zip(start_vals, slice_sizes)]
        stride_vals = [1] * ndim

        def _const_1d(values, dtype="int64"):
            arr = np.array(values, dtype=dtype)
            return self.bb.normalize(relax.const(arr, dtype=dtype))

        begin = _const_1d(start_vals)
        end = _const_1d(end_vals)
        strides = _const_1d(stride_vals)

        return self.bb.normalize(relax.op.dynamic_strided_slice(operand, begin, end, strides))

    def _convert_stablehlo_dynamic_update_slice(self, op):
        """Convert STABLEHLO_DYNAMIC_UPDATE_SLICE to Relax.

        Lowers to ``relax.op.scatter_nd``. Constant start indices build the index
        grid at compile time; runtime (dynamic) start indices build it in-graph
        with ``arange`` + broadcast, clamping each start to
        ``[0, operand_dim - update_dim]`` per StableHLO semantics.
        """
        input_tensors = self.get_input_tensors(op)
        # operand + update + N start-index scalars
        assert len(input_tensors) >= 3, "input tensors length should be >= 3"
        assert len(self.get_output_tensors(op)) == 1

        operand_tensor = input_tensors[0]
        update_tensor = input_tensors[1]
        start_tensors = input_tensors[2:]

        op_name = "STABLEHLO_DYNAMIC_UPDATE_SLICE"
        operand_shape = self._get_static_tensor_shape(operand_tensor, op_name)
        update_shape = self._get_static_tensor_shape(update_tensor, op_name)
        rank = len(operand_shape)
        if len(update_shape) != rank or len(start_tensors) != rank:
            raise tvm.error.OpNotImplemented(
                "STABLEHLO_DYNAMIC_UPDATE_SLICE requires operand, update, "
                "and start-index ranks to match"
            )
        for dim, size in zip(operand_shape, update_shape):
            if size > dim:
                raise tvm.error.OpNotImplemented(
                    "STABLEHLO_DYNAMIC_UPDATE_SLICE update shape must be smaller than "
                    "or equal to operand shape for all dimensions"
                )

        operand = self.get_tensor_expr(operand_tensor)
        update = self.get_tensor_expr(update_tensor)

        if any(self.has_expr(t.tensor_idx) for t in start_tensors):
            indices = self._build_dynamic_update_slice_indices(
                start_tensors, operand_shape, update_shape, rank
            )
            return self.bb.normalize(relax.op.scatter_nd(operand, indices, update, "update"))

        start_vals = [int(np.asarray(self.get_tensor_value(t)).item()) for t in start_tensors]
        for start, size, dim in zip(start_vals, update_shape, operand_shape):
            if start < 0 or start + size > dim:
                raise tvm.error.OpNotImplemented(
                    "STABLEHLO_DYNAMIC_UPDATE_SLICE with out-of-bounds update "
                    "indices is not supported"
                )

        update_indices = np.indices(update_shape, dtype=np.int64)
        for axis, start in enumerate(start_vals):
            update_indices[axis] += start
        update_indices = np.moveaxis(update_indices, 0, -1)

        indices = self.bb.normalize(relax.const(update_indices, dtype="int64"))
        return self.bb.normalize(relax.op.scatter_nd(operand, indices, update, "update"))

    def _build_dynamic_update_slice_indices(self, start_tensors, operand_shape, update_shape, rank):
        """Build the scatter_nd index grid for runtime DYNAMIC_UPDATE_SLICE starts.

        Returns an int64 tensor of shape ``(*update_shape, rank)`` where axis ``a``
        holds ``arange(update_shape[a]) + clamp(start[a], 0, operand_dim - update_dim)``,
        broadcast over the other axes (StableHLO clamps out-of-range starts).
        """
        axis_indices = []
        for axis in range(rank):
            start_expr = self.bb.normalize(
                relax.op.astype(self.get_tensor_expr(start_tensors[axis]), "int64")
            )
            max_start = operand_shape[axis] - update_shape[axis]
            start_expr = relax.op.maximum(start_expr, relax.const(0, "int64"))
            start_expr = relax.op.minimum(start_expr, relax.const(max_start, "int64"))

            base = relax.op.arange(0, update_shape[axis], 1, "int64")
            idx = relax.op.add(base, start_expr)

            broadcast_shape = [1] * rank
            broadcast_shape[axis] = update_shape[axis]
            idx = self.bb.normalize(relax.op.reshape(idx, broadcast_shape))
            idx = self.bb.normalize(relax.op.broadcast_to(idx, update_shape))
            idx = self.bb.normalize(relax.op.expand_dims(idx, axis=-1))
            axis_indices.append(idx)

        return self.bb.normalize(relax.op.concat(axis_indices, axis=-1))

    def _convert_stablehlo_dot_general(self, op):
        """Convert the canonical 2D STABLEHLO_DOT_GENERAL subset to Relax matmul."""
        from tflite.StablehloDotGeneralOptions import StablehloDotGeneralOptions

        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 2, "input tensors length should be 2"
        assert len(self.get_output_tensors(op)) == 1

        opts = self._get_stablehlo_options(op, StablehloDotGeneralOptions)
        lhs_batch_dims = self._get_stablehlo_i64_vector(opts.LhsBatchingDimensionsAsNumpy(), [])
        rhs_batch_dims = self._get_stablehlo_i64_vector(opts.RhsBatchingDimensionsAsNumpy(), [])
        lhs_contract_dims = self._get_stablehlo_i64_vector(
            opts.LhsContractingDimensionsAsNumpy(), []
        )
        rhs_contract_dims = self._get_stablehlo_i64_vector(
            opts.RhsContractingDimensionsAsNumpy(), []
        )

        lhs_shape = self._get_static_tensor_shape(input_tensors[0], "STABLEHLO_DOT_GENERAL")
        rhs_shape = self._get_static_tensor_shape(input_tensors[1], "STABLEHLO_DOT_GENERAL")
        if len(lhs_shape) != 2 or len(rhs_shape) != 2:
            raise tvm.error.OpNotImplemented("STABLEHLO_DOT_GENERAL only supports 2D matmul")
        if lhs_batch_dims or rhs_batch_dims:
            raise tvm.error.OpNotImplemented(
                "STABLEHLO_DOT_GENERAL with batching dimensions is not supported"
            )
        if lhs_contract_dims != [1] or rhs_contract_dims != [0]:
            raise tvm.error.OpNotImplemented(
                "STABLEHLO_DOT_GENERAL only supports canonical contracting dimensions"
            )

        lhs = self.get_tensor_expr(input_tensors[0])
        rhs = self.get_tensor_expr(input_tensors[1])
        return self.bb.normalize(relax.op.matmul(lhs, rhs))

    def _convert_stablehlo_convolution(self, op):
        """Convert the canonical 2D NHWC/HWIO STABLEHLO_CONVOLUTION subset."""
        from tflite.StablehloConvolutionOptions import StablehloConvolutionOptions

        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 2, "input tensors length should be 2"
        assert len(self.get_output_tensors(op)) == 1

        opts = self._get_stablehlo_options(op, StablehloConvolutionOptions)
        input_spatial_dims = self._get_stablehlo_i64_vector(
            opts.InputSpatialDimensionsAsNumpy(), []
        )
        kernel_spatial_dims = self._get_stablehlo_i64_vector(
            opts.KernelSpatialDimensionsAsNumpy(), []
        )
        output_spatial_dims = self._get_stablehlo_i64_vector(
            opts.OutputSpatialDimensionsAsNumpy(), []
        )
        if input_spatial_dims != [1, 2]:
            raise tvm.error.OpNotImplemented(
                "STABLEHLO_CONVOLUTION only supports NHWC input layout"
            )
        if kernel_spatial_dims != [0, 1]:
            raise tvm.error.OpNotImplemented(
                "STABLEHLO_CONVOLUTION only supports HWIO kernel layout"
            )
        if output_spatial_dims != [1, 2]:
            raise tvm.error.OpNotImplemented(
                "STABLEHLO_CONVOLUTION only supports NHWC output layout"
            )

        if (
            int(opts.InputBatchDimension()) != 0
            or int(opts.InputFeatureDimension()) != 3
            or int(opts.KernelInputFeatureDimension()) != 2
            or int(opts.KernelOutputFeatureDimension()) != 3
            or int(opts.OutputBatchDimension()) != 0
            or int(opts.OutputFeatureDimension()) != 3
        ):
            raise tvm.error.OpNotImplemented(
                "STABLEHLO_CONVOLUTION only supports canonical NHWC/HWIO dimension numbers"
            )
        if int(opts.BatchGroupCount()) != 1:
            raise tvm.error.OpNotImplemented(
                "STABLEHLO_CONVOLUTION with batch_group_count > 1 is not supported"
            )
        if int(opts.FeatureGroupCount()) != 1:
            raise tvm.error.OpNotImplemented(
                "STABLEHLO_CONVOLUTION with feature_group_count > 1 is not supported"
            )

        data_shape = self._get_static_tensor_shape(input_tensors[0], "STABLEHLO_CONVOLUTION")
        kernel_shape = self._get_static_tensor_shape(input_tensors[1], "STABLEHLO_CONVOLUTION")
        if len(data_shape) != 4 or len(kernel_shape) != 4:
            raise tvm.error.OpNotImplemented("STABLEHLO_CONVOLUTION only supports 2D convolution")
        if data_shape[3] != kernel_shape[2]:
            raise tvm.error.OpNotImplemented(
                "STABLEHLO_CONVOLUTION input channels must match kernel input channels"
            )

        window_strides = self._get_stablehlo_i64_vector(opts.WindowStridesAsNumpy(), [1, 1])
        padding = self._get_stablehlo_i64_vector(opts.PaddingAsNumpy(), [0, 0, 0, 0])
        lhs_dilation = self._get_stablehlo_i64_vector(opts.LhsDilationAsNumpy(), [1, 1])
        rhs_dilation = self._get_stablehlo_i64_vector(opts.RhsDilationAsNumpy(), [1, 1])
        window_reversal = opts.WindowReversalAsNumpy()
        window_reversal = (
            [False, False] if window_reversal is None else [bool(v) for v in window_reversal]
        )

        if len(window_strides) != 2 or len(rhs_dilation) != 2:
            raise tvm.error.OpNotImplemented(
                "STABLEHLO_CONVOLUTION only supports two spatial dimensions"
            )
        if lhs_dilation != [1, 1]:
            raise tvm.error.OpNotImplemented(
                "STABLEHLO_CONVOLUTION with lhs dilation is not supported"
            )
        if any(window_reversal):
            raise tvm.error.OpNotImplemented(
                "STABLEHLO_CONVOLUTION with window reversal is not supported"
            )
        if len(padding) != 4:
            raise tvm.error.OpNotImplemented(
                "STABLEHLO_CONVOLUTION only supports 2D low/high padding"
            )

        # StableHLO stores padding as [low_h, high_h, low_w, high_w].
        relax_padding = [padding[0], padding[2], padding[1], padding[3]]
        data = self.get_tensor_expr(input_tensors[0])
        kernel = self.get_tensor_expr(input_tensors[1])
        self._ensure_stablehlo_float_dtype(data, "STABLEHLO_CONVOLUTION")
        self._ensure_stablehlo_float_dtype(kernel, "STABLEHLO_CONVOLUTION")
        return self.bb.normalize(
            relax.op.nn.conv2d(
                data,
                kernel,
                strides=window_strides,
                padding=relax_padding,
                dilation=rhs_dilation,
                data_layout="NHWC",
                kernel_layout="HWIO",
            )
        )

    def _convert_stablehlo_gather(self, op):
        """Convert STABLEHLO_GATHER to Relax (take-equivalent subset only).

        Only handles gather patterns equivalent to R.take along a single axis.
        Multi-dimensional gathers, index_vector_dim != rank(indices)-1, and
        non-trivial slice_sizes raise OpNotImplemented.
        """
        from tflite.StablehloGatherOptions import StablehloGatherOptions

        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 2, "input tensors length should be 2"
        output_tensors = self.get_output_tensors(op)
        assert len(output_tensors) == 1

        opts = self._get_stablehlo_options(op, StablehloGatherOptions)
        offset_dims = [int(d) for d in opts.OffsetDimsAsNumpy()]
        collapsed_slice_dims = [int(d) for d in opts.CollapsedSliceDimsAsNumpy()]
        start_index_map = [int(d) for d in opts.StartIndexMapAsNumpy()]
        slice_sizes = [int(d) for d in opts.SliceSizesAsNumpy()]
        index_vector_dim = int(opts.IndexVectorDim())

        data_tensor, indices_tensor = input_tensors
        data_shape = [int(d) for d in self.get_tensor_shape(data_tensor)]
        indices_shape = [int(d) for d in self.get_tensor_shape(indices_tensor)]
        output_shape = [int(d) for d in self.get_tensor_shape(output_tensors[0])]

        if len(start_index_map) != 1:
            raise tvm.error.OpNotImplemented(
                "STABLEHLO_GATHER only supports one start_index_map entry"
            )
        axis = start_index_map[0]
        if axis < 0 or axis >= len(data_shape):
            raise tvm.error.OpNotImplemented(f"Unsupported STABLEHLO_GATHER axis: {axis}")
        if collapsed_slice_dims != [axis]:
            raise tvm.error.OpNotImplemented(
                "STABLEHLO_GATHER only supports collapsed_slice_dims matching the gather axis"
            )
        if len(slice_sizes) != len(data_shape):
            raise tvm.error.OpNotImplemented("STABLEHLO_GATHER slice_sizes must match operand rank")
        for i, (size, dim) in enumerate(zip(slice_sizes, data_shape)):
            expected = 1 if i == axis else dim
            if size != expected:
                raise tvm.error.OpNotImplemented(
                    "STABLEHLO_GATHER only supports take-equivalent slice_sizes"
                )
        if index_vector_dim != len(indices_shape) - 1:
            raise tvm.error.OpNotImplemented(
                "STABLEHLO_GATHER only supports trailing index_vector_dim"
            )
        if not indices_shape or indices_shape[index_vector_dim] != 1:
            raise tvm.error.OpNotImplemented("STABLEHLO_GATHER only supports index vector size 1")

        indices_batch_shape = indices_shape[:index_vector_dim]
        expected_offset_dims = list(range(axis)) + list(
            range(axis + len(indices_batch_shape), len(data_shape) + len(indices_batch_shape) - 1)
        )
        if offset_dims != expected_offset_dims:
            raise tvm.error.OpNotImplemented(
                "STABLEHLO_GATHER offset_dims do not match Relax take output layout"
            )

        expected_output_shape = data_shape[:axis] + indices_batch_shape + data_shape[axis + 1 :]
        if output_shape != expected_output_shape:
            raise tvm.error.OpNotImplemented(
                "STABLEHLO_GATHER output shape does not match Relax take semantics"
            )

        data = self.get_tensor_expr(data_tensor)
        indices = self.get_tensor_expr(indices_tensor)
        indices = self.bb.normalize(relax.op.reshape(indices, indices_batch_shape))
        return self.bb.normalize(relax.op.take(data, indices, axis=axis, mode="fast"))

    def convert_elu(self, op):
        """Convert TFLite ELU"""
        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 1, "input tensors length should be 1"

        input_tensor = input_tensors[0]
        in_expr = self.get_expr(input_tensor.tensor_idx)
        exp_type = self.get_tensor_type_str(input_tensor.tensor.Type())
        out = relax.const(-1.0, exp_type) * relax.op.nn.relu(
            relax.const(1.0, exp_type) - relax.op.exp(in_expr)
        ) + relax.op.nn.relu(in_expr)

        return out

    def convert_gelu(self, op):
        """Convert TFLite GELU"""
        if self.is_quantized(op):
            raise tvm.error.OpNotImplemented(
                "The TFLite to Relax converter does not support quantized GELU operator yet."
            )

        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 1, "input tensors length should be 1"

        input_tensor = input_tensors[0]
        in_expr = self.get_expr(input_tensor.tensor_idx)
        in_type = self.get_tensor_type_str(input_tensor.tensor.Type())

        return in_expr * (
            relax.const(0.5, dtype=in_type)
            + relax.op.erf(in_expr * relax.const(0.5**0.5, dtype=in_type))
            * relax.const(0.5, dtype=in_type)
        )

    def convert_square(self, op):
        """Convert TFLite SQUARE"""
        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 1, "input tensors length should be 1"
        input_tensor = input_tensors[0]
        in_expr = self.get_expr(input_tensor.tensor_idx)

        output_tensors = self.get_output_tensors(op)
        assert len(output_tensors) == 1, "output tensors length should be 1"
        output_tensor = output_tensors[0]

        exp_type = self.get_tensor_type_str(output_tensor.tensor.Type())
        out = relax.op.power(in_expr, relax.const(2, exp_type))

        return out

    def _convert_elemwise(self, op, relax_op, comparison_op=False):
        """Generic method to Convert TFLite elemwise"""

        from tflite.AddOptions import AddOptions
        from tflite.BuiltinOptions import BuiltinOptions
        from tflite.DivOptions import DivOptions
        from tflite.MulOptions import MulOptions
        from tflite.SubOptions import SubOptions

        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 2, "input tensors length should be 2"

        lhs_tensor = input_tensors[0]
        rhs_tensor = input_tensors[1]
        lhs_expr = self.get_tensor_expr(lhs_tensor)
        rhs_expr = self.get_tensor_expr(rhs_tensor)
        input_is_quantized = lhs_tensor.qnn_params is not None or rhs_tensor.qnn_params is not None

        output_tensors = self.get_output_tensors(op)
        assert len(output_tensors) == 1, "output tensors length should be 1"
        output_tensor = output_tensors[0]

        if input_is_quantized:
            if lhs_tensor.qnn_params:
                lhs_expr = self.dequantize(lhs_expr, lhs_tensor)
            if rhs_tensor.qnn_params:
                rhs_expr = self.dequantize(rhs_expr, rhs_tensor)

        out = relax_op(lhs_expr, rhs_expr)

        # Options (fused_activation_function)
        options = None
        if op.BuiltinOptionsType() == BuiltinOptions.AddOptions:
            options = AddOptions()
        elif op.BuiltinOptionsType() == BuiltinOptions.SubOptions:
            options = SubOptions()
        elif op.BuiltinOptionsType() == BuiltinOptions.MulOptions:
            options = MulOptions()
        elif op.BuiltinOptionsType() == BuiltinOptions.DivOptions:
            options = DivOptions()

        if options is not None:
            op_options = op.BuiltinOptions()
            options.Init(op_options.Bytes, op_options.Pos)
            fused_activation_fn = options.FusedActivationFunction()

            out = self.convert_fused_activation_function(out, fused_activation_fn)

        if input_is_quantized and not comparison_op:
            if not output_tensor.qnn_params:
                raise tvm.error.OpAttributeInvalid(
                    "Quantized TFLite elemwise operator output must have quantization parameters"
                )
            out = self.quantize(out, output_tensor)
        return out

    def convert_add_n(self, op):
        """Convert TFLite ADD_N"""
        output_tensors = self.get_output_tensors(op)
        assert len(output_tensors) == 1, "output tensors length should be 1"

        input_tensors = self.get_input_tensors(op)
        assert not input_tensors[0].qnn_params, "TFLite does not support quantized ADD_N."
        lhs_expr = self.get_tensor_expr(input_tensors[0])
        for rhs_tensor in input_tensors[1:]:
            assert not rhs_tensor.qnn_params, "TFLite does not support quantized ADD_N"
            rhs_expr = self.get_tensor_expr(rhs_tensor)
            lhs_expr = relax.op.add(lhs_expr, rhs_expr)
        return lhs_expr

    def convert_cumsum(self, op):
        """Convert TFLite CUMSUM"""
        if self.is_quantized(op):
            raise tvm.error.OpNotImplemented(
                "The TFLite to Relax converter does not support quantized CUMSUM operator yet."
            )

        from tflite.BuiltinOptions import BuiltinOptions
        from tflite.CumsumOptions import CumsumOptions

        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 2, "input tensors length should be 2"

        input_expr = self.get_tensor_expr(input_tensors[0])

        if self.has_expr(input_tensors[1].tensor_idx):
            raise tvm.error.OpNotImplemented(
                "The TFLite to Relax converter does not support dynamic axis for CUMSUM yet."
            )
        axis = self.get_tensor_value(input_tensors[1])
        if isinstance(axis, np.ndarray):
            assert axis.size == 1, "only one value is expected."
            axis = int(axis.flat[0])

        assert op.BuiltinOptionsType() == BuiltinOptions.CumsumOptions
        op_options = op.BuiltinOptions()
        cumsum_options = CumsumOptions()
        cumsum_options.Init(op_options.Bytes, op_options.Pos)
        exclusive = cumsum_options.Exclusive()
        if cumsum_options.Reverse():
            raise tvm.error.OpNotImplemented(
                "The TFLite to Relax converter does not support reverse CUMSUM operator yet."
            )

        output_tensors = self.get_output_tensors(op)
        assert len(output_tensors) == 1, "output tensors length should be 1"

        out_dtype = self.get_tensor_type_str(output_tensors[0].tensor.Type())

        out = relax.op.cumsum(input_expr, axis, out_dtype, exclusive)

        return out

    def convert_squared_difference(self, op):
        """Convert TFLite SQUARED DIFFERENCE"""
        # Check if the input tensor is quantized, call QNN op
        # (https://github.com/tensorflow/tflite-micro/blob/bc35c3ed9c7ab93b3a13b46fce936f854bcfce2c
        # /tensorflow/lite/micro/kernels/squared_difference.cc#L157)
        if self.is_quantized(op):
            input_tensors = self.get_input_tensors(op)
            output_tensors = self.get_output_tensors(op)
            lhs_expr = self.get_tensor_expr(input_tensors[0])
            rhs_expr = self.get_tensor_expr(input_tensors[1])
            assert len(input_tensors) == 2, "input tensors length should be 2"
            assert len(output_tensors) == 1, "output tensors length should be 1"
            lhs_expr_f32 = self.dequantize(lhs_expr, input_tensors[0])
            rhs_expr_f32 = self.dequantize(rhs_expr, input_tensors[1])
            out_f32 = relax.op.subtract(lhs_expr_f32, rhs_expr_f32)
            return self.quantize(out_f32 * out_f32, output_tensors[0])

        difference = self._convert_elemwise(op, _op.subtract)
        # _convert_elemwise has guaranteed only have one output tensor
        exp_type = self.get_tensor_type_str(self.get_output_tensors(op)[0].tensor.Type())
        out = relax.op.power(difference, relax.const(2, exp_type))
        return out

    def _convert_logical_binary(self, relax_op, op):
        """Generic method to convert logical binary ops"""
        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 2, "input tensors length should be 2"

        lhs_tensor = input_tensors[0]
        lhs_expr = self.get_tensor_expr(lhs_tensor)
        rhs_tensor = input_tensors[1]
        rhs_expr = self.get_tensor_expr(rhs_tensor)
        out = relax_op(lhs_expr, rhs_expr)

        return out

    def convert_logical_not(self, op):
        """Convert tflite LOGICAL_NOT"""
        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 1, "input tensors length should be 1"

        data = self.get_expr(input_tensors[0].tensor_idx)
        out = relax.op.logical_not(data)

        return out

    def convert_gather(self, op):
        """Method to Convert TFLite GATHER operator"""

        from tflite.BuiltinOptions import BuiltinOptions
        from tflite.GatherOptions import GatherOptions
        from tflite.TensorType import TensorType

        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 2, "input tensors length should be 2"

        data = self.get_tensor_expr(input_tensors[0])
        indices = input_tensors[1]
        indices_type = indices.tensor.Type()
        assert indices_type in (TensorType.INT32, TensorType.INT64)

        assert op.BuiltinOptionsType() == BuiltinOptions.GatherOptions
        op_options = op.BuiltinOptions()
        gather_options = GatherOptions()
        gather_options.Init(op_options.Bytes, op_options.Pos)
        axis = gather_options.Axis()

        # Check the indices are with in bounds.
        data_shape = to_int_list(self.get_tensor_shape(input_tensors[0]))
        data_dim = len(data_shape)

        axis = data_dim + axis if axis < 0 else axis
        assert axis >= 0, "Axis out of bounds"
        assert axis < data_dim, "Axis out of bounds"

        if self.has_expr(indices.tensor_idx):
            indices_expr = relax.op.astype(self.get_expr(indices.tensor_idx), "int32")
        else:
            indices_val = self.get_tensor_value(indices)
            indices_expr = self.exp_tab.new_const(
                indices_val,
                dtype=self.get_tensor_type_str(indices_type),
                source_name=indices.tensor.Name(),
            )
            indices_shape = list(indices_val.shape)
            indices_len = len(indices_shape)

            out_shape = data_shape[:axis] + indices_shape[:] + data_shape[axis + 1 :]

            loopover = [range(s) for s in out_shape]
            for idx in list(itertools.product(*loopover)):
                real_indices = (
                    list(idx[:axis])
                    + [indices_val[idx[axis : axis + indices_len]]]
                    + list(idx[axis + indices_len :])
                )
                if np.any(np.subtract(data_shape, real_indices) < 0):
                    raise ValueError("TFLite out of bound indices are not supported.")

        # Use mode 'fast' since indices are already checked within bounds.
        out = relax.op.take(data, indices_expr, axis=axis, mode="fast")
        return out

    def convert_gather_nd(self, op):
        """Method to Convert TFLite GATHER_ND operator"""

        from tflite.TensorType import TensorType

        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 2, "input tensors length should be 2"

        for t in input_tensors:
            assert not t.qnn_params, "Quantized input is not expected."

        data = self.get_tensor_expr(input_tensors[0])
        indices = self.get_tensor_expr(input_tensors[1])

        indices_type = input_tensors[1].tensor.Type()
        assert indices_type in (TensorType.INT32, TensorType.INT64)

        indices_dims = len(self._infer_shape(indices))
        indices_t = relax.op.permute_dims(indices, axes=[-1] + list(range(indices_dims - 1)))
        if indices_type == TensorType.INT32:
            # Relax gather_nd requires int64 indices.
            indices_t = relax.op.astype(indices_t, "int64")

        out = relax.op.gather_nd(data, indices_t)
        return out

    def convert_strided_slice(self, op):
        """Method to Convert TFLite STRIDED_SLICE operator.
        NOTE: Eventhough tensorflow supports begin_mask, end_mask, ellipsis_mask, new_axis_mask
        and shrink_axis_mask, tflite doesn't support these and expect these values to be zero.
        But in future, they may open up the mask implementation, so kept the implementation
        same as tensorflow.

        This op extracts a slice of size (end - begin) / stride from the given input tensor.
        Starting at the location specified by begin the slice continues by adding stride to the
        index until all dimensions are not less than end. Note that a stride can be negative,
        which causes a reverse slice.

        For slice input[val0, val1, ..., valn], begin/end/strides will be vectors of length n.

        In each mask field(begin_mask, end_mask, ellipsis_mask, new_axis_mask, shrink_axis_mask)
        the ith bit will correspond to the ith val.

        If the ith bit of begin_mask is set, begin[i] is ignored and the fullest possible range
        in that dimension is used instead.

        If the ith bit of ellipsis_mask is set, as many unspecified dimensions as needed will be
        inserted between other dimensions. Only one non-zero bit is allowed in ellipsis_mask.

        If the ith bit of new_axis_mask is set, then begin, end, and stride are ignored and a
        new length 1 dimension is added at this point in the output tensor.

        If the ith bit of shrink_axis_mask is set, it implies that the ith specification shrinks
        the dimensionality by 1, taking on the value at index begin[i]. end[i] and strides[i]
        are ignored in this case.
        begin and end are zero-indexed. strides entries must be non-zero.

        TVM Relax implementation of doesn't support mask, so the mask values are processed in
        this function and begin/end/strides are updated accordingly. If any mask is present, and
        since tvm doesn't support mask computation directly, the output need a final reshape.
        """

        from tflite.BuiltinOptions import BuiltinOptions
        from tflite.StridedSliceOptions import StridedSliceOptions

        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 4, "input tensors length should be 4"

        data_expr = self.get_expr(input_tensors[0].tensor_idx)

        begin = list(self.get_tensor_value(input_tensors[1]))
        end = list(self.get_tensor_value(input_tensors[2]))
        stride = list(self.get_tensor_value(input_tensors[3]))

        assert op.BuiltinOptionsType() == BuiltinOptions.StridedSliceOptions
        op_options = op.BuiltinOptions()
        options = StridedSliceOptions()
        options.Init(op_options.Bytes, op_options.Pos)
        begin_mask = options.BeginMask()
        end_mask = options.EndMask()
        ellipsis_mask = options.EllipsisMask()
        new_axis_mask = options.NewAxisMask()
        shrink_axis_mask = options.ShrinkAxisMask()

        data_shape = to_int_list(self.get_tensor_shape(input_tensors[0]))
        data_dim = len(data_shape)
        stride_dim = len(stride)

        def _transform_mask(stride_dim, ellipsis_mask):
            """Handle mask inputs to create new begin, end, stride and output shape"""
            m_begin = [0] * data_dim
            m_end = [0] * data_dim
            m_stride = [0] * data_dim
            fshape_indices = []
            # Count new axis after ellipsis_mask, consider while applying ellipsis_mask.
            ellipsis_seen = False
            new_axes_after_ellipsis = 0
            for i in range(stride_dim):
                mask = 1 << i
                if ellipsis_seen and (mask & new_axis_mask) != 0:
                    new_axes_after_ellipsis += 1
                if (mask & ellipsis_mask) != 0:
                    ellipsis_seen = True
            if not ellipsis_seen:
                # Used later for extending the stride attributes in the below loop.
                ellipsis_mask |= 1 << stride_dim
                stride_dim += 1
            final_index = 0
            for index in range(stride_dim):
                mask = 1 << index
                if mask & ellipsis_mask:
                    # Identify the end index for applying ellipsis_mask
                    to_index = min(
                        ((data_dim - (stride_dim - index)) + 1 + new_axes_after_ellipsis), data_dim
                    )
                    for i in range(final_index, to_index):
                        m_begin[final_index] = 0
                        m_end[final_index] = data_shape[final_index]
                        m_stride[final_index] = 1
                        fshape_indices.append(final_index)
                        final_index += 1
                elif mask & new_axis_mask:
                    fshape_indices.append(-1)
                elif not mask & new_axis_mask:
                    if final_index == len(m_begin):
                        break
                    if mask & begin_mask:
                        m_begin[final_index] = data_shape[final_index] if stride[index] < 0 else 0
                    elif begin[index]:
                        m_begin[final_index] = begin[index]
                    if mask & end_mask:
                        if stride[index] < 0:
                            # Relax negative-step slicing excludes the end index, so an
                            # unspecified lower bound needs one extra step past index 0.
                            m_end[final_index] = -data_shape[final_index] - 1
                        else:
                            m_end[final_index] = data_shape[final_index]
                    elif end[index]:
                        m_end[final_index] = end[index]
                    m_stride[final_index] = stride[index]
                    if mask & shrink_axis_mask:
                        # Tensorflow make axis with shrink_axis_mask as dimension 1
                        m_begin[final_index] = (
                            data_shape[final_index] + begin[index]
                            if begin[index] < 0
                            else begin[index]
                        )
                        m_end[final_index] = m_begin[final_index] + 1
                        m_stride[final_index] = 1
                        fshape_indices.append(-2)
                    else:
                        fshape_indices.append(final_index)

                    final_index += 1
            return m_begin, m_end, m_stride, fshape_indices

        fshape_indices = None
        if begin_mask or end_mask or ellipsis_mask or new_axis_mask or shrink_axis_mask:
            begin, end, stride, fshape_indices = _transform_mask(stride_dim, ellipsis_mask)

        begin = [int(i) for i in begin]
        end = [int(i) for i in end]
        stride = [int(i) for i in stride]
        axes = list(range(len(begin)))
        out = relax.op.strided_slice(data_expr, axes=axes, begin=begin, end=end, strides=stride)
        out_shape = self.bb.normalize(out).ty.shape
        if not fshape_indices:
            fshape_indices = range(len(out_shape))

        # Create final output shape.
        final_output = []
        final_len = len(fshape_indices)
        for gather_index in fshape_indices:
            if gather_index == -1:
                final_output.append(1)
                final_len += 1
            elif gather_index == -2:
                final_len -= 1
            else:
                final_output.append(out_shape[gather_index])

        if final_len == 0:
            return relax.op.squeeze(out, axis=tuple(range(len(fshape_indices))))

        if not final_output:
            return out
        return relax.op.reshape(out, shape=tuple(final_output))

    def convert_zeros_like(self, op):
        """Convert TFLite ZEROS LIKE"""
        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 1, "input tensors length should be 1"

        input_tensor = input_tensors[0]
        in_expr = self.get_expr(input_tensor.tensor_idx)
        out = relax.op.zeros_like(in_expr)

        return out

    def convert_fill(self, op):
        """Convert TFLite FILL"""
        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 2, "input tensors length should be 2"

        dims_tensor = input_tensors[0]
        in_value_expr = self.get_expr(input_tensors[1].tensor_idx)

        out_shape, _ = self._get_shape_expr_from_tensor(dims_tensor, "fill_dim")
        out = relax.op.full(out_shape, in_value_expr)

        return out

    def _get_random_options(self, op):
        """Return the seed pair for random TFLite operators.

        The runtime imports seeded TFLite random ops with stateless semantics, so identical
        non-zero seed pairs produce identical results on every invocation. The seed pair
        (0, 0) is forwarded as the TF non-deterministic case.
        """
        from tflite.BuiltinOptions import BuiltinOptions
        from tflite.RandomOptions import RandomOptions

        if op.BuiltinOptionsType():
            assert op.BuiltinOptionsType() == BuiltinOptions.RandomOptions
            random_options = RandomOptions()
            op_options = op.BuiltinOptions()
            random_options.Init(op_options.Bytes, op_options.Pos)
            return int(random_options.Seed()), int(random_options.Seed2())
        return 0, 0

    def _check_random_output_dtype(self, op_name, output_dtype, supported_dtypes):
        if output_dtype not in supported_dtypes:
            supported = ", ".join(supported_dtypes)
            raise tvm.error.OpNotImplemented(
                f"The TFLite {op_name} converter currently supports output dtype(s) "
                f"{supported} only, but got {output_dtype}."
            )

    def convert_random_uniform(self, op):
        """Convert TFLite RANDOM_UNIFORM using stateless seeded RNG semantics."""
        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 1, "input tensors length should be 1"

        output_tensors = self.get_output_tensors(op)
        assert len(output_tensors) == 1, "output tensors length should be 1"
        output_tensor = output_tensors[0]
        output_dtype = self.get_tensor_type_str(output_tensor.tensor.Type())
        self._check_random_output_dtype("RANDOM_UNIFORM", output_dtype, ["float32"])

        out_shape, _ = self._get_shape_expr_from_tensor(input_tensors[0], "random_uniform_dim")
        seed, seed2 = self._get_random_options(op)
        return relax.op.call_dps_packed(
            "tvm.contrib.random.uniform",
            (seed, seed2, 0.0, 1.0),
            out_ty=relax.TensorType(out_shape, output_dtype),
        )

    def convert_random_standard_normal(self, op):
        """Convert TFLite RANDOM_STANDARD_NORMAL using stateless seeded RNG semantics."""
        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 1, "input tensors length should be 1"

        output_tensors = self.get_output_tensors(op)
        assert len(output_tensors) == 1, "output tensors length should be 1"
        output_tensor = output_tensors[0]
        output_dtype = self.get_tensor_type_str(output_tensor.tensor.Type())
        self._check_random_output_dtype("RANDOM_STANDARD_NORMAL", output_dtype, ["float32"])

        out_shape, _ = self._get_shape_expr_from_tensor(
            input_tensors[0], "random_standard_normal_dim"
        )
        seed, seed2 = self._get_random_options(op)
        return relax.op.call_dps_packed(
            "tvm.contrib.random.normal",
            (seed, seed2, 0.0, 1.0),
            out_ty=relax.TensorType(out_shape, output_dtype),
        )

    def convert_multinomial(self, op):
        """Convert TFLite MULTINOMIAL using stateless seeded RNG semantics."""
        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 2, "input tensors length should be 2"

        logits_tensor, num_samples_tensor = input_tensors
        logits_expr = self.get_tensor_expr(logits_tensor)
        batch_size = self.get_tensor_shape(logits_tensor)[0]
        if self.has_expr(num_samples_tensor.tensor_idx):
            scalar_expr = self.get_expr(num_samples_tensor.tensor_idx)
            scalar_dtype = self.get_tensor_type_str(num_samples_tensor.tensor.Type())
            scalar_expr = self.bb.match_cast(scalar_expr, relax.TensorType([], scalar_dtype))
            scalar_expr = self.bb.normalize(relax.op.astype(scalar_expr, "int64"))
            scalar_expr = self.bb.normalize(relax.op.reshape(scalar_expr, [1]))
            shape_dataflow_var = self.bb.emit(relax.op.tensor_to_shape(scalar_expr))
            num_samples = tirx.Var("multinomial_num_samples", "int64")
            self.bb.match_cast(shape_dataflow_var, relax.ShapeType([num_samples]))
        else:
            value = self.get_tensor_value(num_samples_tensor)
            assert value.size == 1, (
                "TFLite MULTINOMIAL num_samples must be a scalar tensor, "
                f"but got {value.size} values"
            )
            num_samples = int(value.item())
        output_batch = batch_size * num_samples

        output_tensors = self.get_output_tensors(op)
        assert len(output_tensors) == 1, "output tensors length should be 1"
        output_tensor = output_tensors[0]
        output_dtype = self.get_tensor_type_str(output_tensor.tensor.Type())
        self._check_random_output_dtype("MULTINOMIAL", output_dtype, ["int32", "int64"])

        seed, seed2 = self._get_random_options(op)
        uniform_sample = relax.op.call_dps_packed(
            "tvm.contrib.random.uniform",
            (seed, seed2, 0.0, 1.0),
            out_ty=relax.TensorType([output_batch, 1], "float32"),
        )
        sample_indices = relax.op.reshape(
            relax.op.broadcast_to(
                relax.op.expand_dims(relax.op.arange(batch_size, dtype="int64"), axis=[1]),
                relax.ShapeExpr([batch_size, num_samples]),
            ),
            relax.ShapeExpr([output_batch, 1]),
        )
        sampled = relax.op.multinomial_from_uniform(
            relax.op.nn.softmax(logits_expr, axis=-1),
            uniform_sample,
            sample_indices,
            dtype=output_dtype,
        )
        return relax.op.reshape(sampled, relax.ShapeExpr([batch_size, num_samples]))

    def _convert_reduce(self, relax_op, op):
        """Generic method to Convert TFLite REDUCE operators"""

        from tflite.BuiltinOptions import BuiltinOptions
        from tflite.ReducerOptions import ReducerOptions

        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 2, "input tensors length should be 2"

        # input_tensor
        input_tensor = input_tensors[0]
        in_expr = self.get_expr(input_tensor.tensor_idx)

        # axis
        axis_value = self.get_tensor_value(input_tensors[1])
        axis = tuple(axis_value) if len(axis_value.shape) > 0 else tuple((axis_value.item(),))

        # Options - keep_dims (bool)
        # In case Options are not present, set keep_dims to False(default)
        if op.BuiltinOptionsType():
            assert op.BuiltinOptionsType() == BuiltinOptions.ReducerOptions
            reduce_options = ReducerOptions()
            op_options = op.BuiltinOptions()
            reduce_options.Init(op_options.Bytes, op_options.Pos)
            keep_dims = reduce_options.KeepDims()
        else:
            keep_dims = False

        if input_tensor.qnn_params:
            in_expr = self.dequantize(in_expr, input_tensor)

        out = relax_op(in_expr, axis, keep_dims)

        # Finally if the reduce is quantized. Quantize the output.
        output_tensors = self.get_output_tensors(op)
        assert len(output_tensors) == 1, "output tensors length should be 1"
        output_tensor = output_tensors[0]
        if output_tensor.qnn_params:
            out = self.quantize(out, output_tensor)

        return out

    def _convert_reduce_bool(self, relax_op, op):
        """Convert TFLite REDUCE_ANY / REDUCE_ALL (bool-only ops).

        Relax max/min are undefined on bool, so cast through int8.
        """
        from tflite.BuiltinOptions import BuiltinOptions
        from tflite.ReducerOptions import ReducerOptions

        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 2, "input tensors length should be 2"

        input_tensor = input_tensors[0]
        in_expr = self.get_expr(input_tensor.tensor_idx)

        axis_value = self.get_tensor_value(input_tensors[1])
        axis = tuple(axis_value) if len(axis_value.shape) > 0 else tuple((axis_value.item(),))

        if op.BuiltinOptionsType():
            assert op.BuiltinOptionsType() == BuiltinOptions.ReducerOptions
            reduce_options = ReducerOptions()
            op_options = op.BuiltinOptions()
            reduce_options.Init(op_options.Bytes, op_options.Pos)
            keep_dims = reduce_options.KeepDims()
        else:
            keep_dims = False

        in_expr = relax.op.astype(in_expr, "int8")
        out = relax_op(in_expr, axis, keep_dims)
        return relax.op.astype(out, "bool")

    def _convert_arg_min_max(self, op, relax_op):
        """Generic method converting TFLite arg_min_max"""

        from tflite.ArgMaxOptions import ArgMaxOptions
        from tflite.ArgMinOptions import ArgMinOptions
        from tflite.BuiltinOptions import BuiltinOptions

        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 2, "two input tensor arguments expected"

        output_tensors = self.get_output_tensors(op)
        assert len(output_tensors) == 1, "one output tensor expected"

        input_tensor = input_tensors[0]
        in_expr = self.get_expr(input_tensor.tensor_idx)
        axis_tensor = input_tensors[1]
        # In Tensorflow, `axis` argument is a Tensor, not attribute. We
        # support the case where it inputs from a scalar constant.
        axis_value = self.get_tensor_value(axis_tensor)
        assert axis_value.size == 1
        axis_value = axis_value.item()

        if op.BuiltinOptionsType() == BuiltinOptions.ArgMinOptions:
            arg_min_max_options = ArgMinOptions()
        elif op.BuiltinOptionsType() == BuiltinOptions.ArgMaxOptions:
            arg_min_max_options = ArgMaxOptions()
        op_options = op.BuiltinOptions()
        arg_min_max_options.Init(op_options.Bytes, op_options.Pos)

        # set keepdims to True since tflite 1.13 removes all dims of size 1
        # WARNING: all other versions of tflite > 1.13 need keepdims=False
        out = relax_op(in_expr, axis=axis_value, keepdims=False)

        return out

    def convert_fully_connected(self, op):
        """Convert TFLite fully connected"""

        from tflite.BuiltinOptions import BuiltinOptions
        from tflite.FullyConnectedOptions import FullyConnectedOptions
        from tflite.TensorType import TensorType

        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) in (2, 3), "input tensors length should be two or three"

        input_tensor = input_tensors[0]
        weight_tensor = input_tensors[1]

        output_tensors = self.get_output_tensors(op)
        assert len(output_tensors) == 1, "output tensors length should be 1"
        output_tensor = output_tensors[0]
        output_tensor_type = output_tensor.tensor.Type()
        output_tensor_type_str = self.get_tensor_type_str(output_tensor_type)

        weight_tensor_shape = to_int_list(self.get_tensor_shape(weight_tensor))

        # Weight should have only 2 dimensions(TFLite convention)
        assert len(weight_tensor_shape) == 2, "Weight should be only 2-dim"

        # Input shape: [i_batch_size, ..., n_inputs]
        # Filter shape: [n_inputs, n_units]
        #
        # As we will transform Fully_Connected Input to MatMul
        # Weight require a transpose
        in_expr = self.get_tensor_expr(input_tensor)

        # TODO: Change the output shape calculation based on keep_dim option
        assert op.BuiltinOptionsType() == BuiltinOptions.FullyConnectedOptions
        op_options = op.BuiltinOptions()
        fully_connected_options = FullyConnectedOptions()
        fully_connected_options.Init(op_options.Bytes, op_options.Pos)
        fused_activation_fn = fully_connected_options.FusedActivationFunction()
        keep_num_dims = fully_connected_options.KeepNumDims()

        # weight tensor type should be INT8/UINT8 (quantization) or FLOAT32
        weight_tensor_type = weight_tensor.tensor.Type()
        assert weight_tensor_type in (
            TensorType.INT8,
            TensorType.UINT8,
            TensorType.FLOAT32,
        )

        weight_expr = self.get_tensor_expr(weight_tensor)
        weight_expr = relax.op.permute_dims(weight_expr, [1, 0])

        if input_tensor.qnn_params:
            # Dequantize input and weight (OC remapped from axis 0 to 1)
            in_f32 = self.dequantize(in_expr, input_tensor)
            weight_axis = weight_tensor.qnn_params["axis"]
            if weight_axis != 0:
                raise tvm.error.OpAttributeInvalid(
                    f"FC weight QuantizedDimension() must be 0 (output-channel "
                    f"axis in [OC,IC] layout), got {weight_axis}"
                )
            w_f32 = relax.op.dequantize(
                weight_expr,
                scale=weight_tensor.qnn_params["scale"],
                zero_point=weight_tensor.qnn_params["zero_point"],
                axis=1,
            )
            out = relax.op.matmul(in_f32, w_f32)
        else:
            out = relax.op.matmul(in_expr, weight_expr)

        # if we have bias
        if len(input_tensors) == 3:
            bias_tensor = input_tensors[2]
            if bias_tensor.tensor_idx != -1:
                bias_tensor_type = bias_tensor.tensor.Type()
                # bias tensor type should be INT32 (quantization) or FLOAT32
                assert bias_tensor_type in (
                    TensorType.INT32,
                    TensorType.INT64,
                    TensorType.FLOAT32,
                )
                bias_tensor_type_str = self.get_tensor_type_str(bias_tensor_type)
                if self.has_expr(bias_tensor.tensor_idx):
                    bias_expr = self.get_expr(bias_tensor.tensor_idx)
                else:
                    bias_expr = self.exp_tab.new_const(
                        self.get_tensor_value(bias_tensor),
                        dtype=bias_tensor_type_str,
                        source_name=bias_tensor.tensor.Name(),
                    )
                if bias_tensor.qnn_params:
                    bias_expr = self.dequantize(bias_expr, bias_tensor)
                elif input_tensor.qnn_params and bias_tensor_type in (
                    TensorType.INT32,
                    TensorType.INT64,
                ):
                    bias_scale = relax.op.multiply(
                        input_tensor.qnn_params["scale"],
                        weight_tensor.qnn_params["scale"],
                    )
                    bias_expr = relax.op.dequantize(
                        bias_expr,
                        scale=bias_scale,
                        zero_point=relax.const(0, "int32"),
                        axis=0,
                    )
                out = relax.op.add(out, bias_expr)

        # Finally if the dense is quantized. Quantize the output.
        if output_tensor.qnn_params:
            out = self.quantize(out, output_tensor)

            # Call activation function
            output_scale_val = get_scalar_from_constant(output_tensor.qnn_params["scale"])
            output_zero_point_val = get_scalar_from_constant(output_tensor.qnn_params["zero_point"])
            out = self.convert_qnn_fused_activation_function(
                expr=out,
                fused_activation_fn=fused_activation_fn,
                scale=output_scale_val,
                zero_point=output_zero_point_val,
                dtype=output_tensor_type_str,
            )

        else:
            out = self.convert_fused_activation_function(out, fused_activation_fn)

        # Change the output shape calculation based on keep_dim option
        if keep_num_dims:
            input_shape = self._infer_shape(self.get_tensor_expr(input_tensor))
            output_shape = to_int_list(input_shape)[:-1] + [weight_tensor_shape[0]]
            out = relax.op.reshape(out, output_shape)

        return out

    def convert_squeeze(self, op):
        """Convert TFLite squeeze"""

        from tflite.BuiltinOptions import BuiltinOptions
        from tflite.SqueezeOptions import SqueezeOptions

        input_tensors = self.get_input_tensors(op)
        output_tensors = self.get_output_tensors(op)
        assert len(input_tensors) == 1, "input tensors length should be 1"
        assert len(output_tensors) == 1, "output tensors length should be 1"
        input_tensor = input_tensors[0]
        input_tensor_idx = input_tensor.tensor_idx

        assert op.BuiltinOptionsType() == BuiltinOptions.SqueezeOptions
        op_options = op.BuiltinOptions()
        squeeze_options = SqueezeOptions()
        squeeze_options.Init(op_options.Bytes, op_options.Pos)
        squeeze_axis = squeeze_options.SqueezeDimsAsNumpy()

        in_expr = self.get_expr(input_tensor_idx)
        out = relax.op.squeeze(in_expr, axis=tuple(squeeze_axis))

        return out

    def convert_fused_activation_function(self, in_expr, fused_activation_fn):
        """Convert TFLite fused activation function"""

        from tflite.ActivationFunctionType import ActivationFunctionType

        if fused_activation_fn == ActivationFunctionType.NONE:
            return in_expr
        if fused_activation_fn == ActivationFunctionType.RELU6:
            return relax.op.clip(in_expr, min=0, max=6)
        if fused_activation_fn == ActivationFunctionType.RELU:
            return relax.op.nn.relu(in_expr)
        if fused_activation_fn == ActivationFunctionType.RELU_N1_TO_1:
            return relax.op.clip(in_expr, min=-1, max=1)
        if fused_activation_fn == ActivationFunctionType.TANH:
            return relax.op.tanh(in_expr)
        fused_activation_fn_str = self.activation_fn_type[fused_activation_fn]
        raise tvm.error.OpNotImplemented(
            f"Fused activation {fused_activation_fn_str} is not supported yet."
        )

    def convert_conv(self, op, conv_type):
        """convolution implementation."""

        from tflite.BuiltinOptions import BuiltinOptions
        from tflite.Conv2DOptions import Conv2DOptions
        from tflite.DepthwiseConv2DOptions import DepthwiseConv2DOptions
        from tflite.Padding import Padding
        from tflite.TensorType import TensorType

        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) >= 2, "input tensors length should be >= 2"

        input_tensor = input_tensors[0]
        input_tensor_idx = input_tensor.tensor_idx
        weight_tensor = input_tensors[1]

        output_tensors = self.get_output_tensors(op)
        assert len(output_tensors) == 1, "output tensors length should be 1"
        output_tensor = output_tensors[0]
        output_tensor_type = output_tensor.tensor.Type()
        output_tensor_type_str = self.get_tensor_type_str(output_tensor_type)

        is_depthwise_conv = False
        if conv_type == "conv2d":
            assert op.BuiltinOptionsType() == BuiltinOptions.Conv2DOptions
            op_options = op.BuiltinOptions()
            conv_options = Conv2DOptions()
            conv_options.Init(op_options.Bytes, op_options.Pos)
        elif conv_type == "depthwise":
            is_depthwise_conv = True
            assert op.BuiltinOptionsType() == BuiltinOptions.DepthwiseConv2DOptions
            op_options = op.BuiltinOptions()
            conv_options = DepthwiseConv2DOptions()
            conv_options.Init(op_options.Bytes, op_options.Pos)
            depth_multiplier = conv_options.DepthMultiplier()
        else:
            raise tvm.error.OpNotImplemented(
                f"Operator {conv_type} is not supported for frontend TFLite."
            )

        stride_h = conv_options.StrideH()
        stride_w = conv_options.StrideW()
        dilation_h = conv_options.DilationHFactor()
        dilation_w = conv_options.DilationWFactor()
        padding = conv_options.Padding()
        fused_activation_fn = conv_options.FusedActivationFunction()

        _, input_h, input_w, input_c = to_int_list(self.get_tensor_shape(input_tensor))

        if is_depthwise_conv:
            # TFLite depthwise convolution kernel layout is:
            # 1 KH KW C(input_c * depth_multiplier)
            _, kernel_h, kernel_w, in_channels = to_int_list(self.get_tensor_shape(weight_tensor))
            assert in_channels == input_c * depth_multiplier
        else:
            output_channels, kernel_h, kernel_w, in_channels = to_int_list(
                self.get_tensor_shape(weight_tensor)
            )

        dilated_kernel_h = dilation_h * (kernel_h - 1) + 1
        dilated_kernel_w = dilation_w * (kernel_w - 1) + 1

        params = {
            # "kernel_size": [kernel_h, kernel_w],
            "strides": [stride_h, stride_w],
            "dilation": [dilation_h, dilation_w],
            "padding": [0, 0],
            "data_layout": "NHWC",
        }

        if is_depthwise_conv:
            params["groups"] = int(input_c)
            # If number of input channels is 1, treat as normal
            # convolution.
            params["kernel_layout"] = "HWIO" if input_c == 1 else "HWOI"
        else:
            # params["channels"] = int(output_channels)
            params["kernel_layout"] = "HWIO"
            if input_c != in_channels:
                assert input_c % in_channels == 0, (
                    "Input channels is not divisible of kernel in_channels."
                )
                params["groups"] = int(input_c / in_channels)

        # weight tensor type should be INT8/UINT8 (quantization) or FLOAT32
        weight_tensor_type = weight_tensor.tensor.Type()
        assert weight_tensor_type in (
            TensorType.INT8,
            TensorType.UINT8,
            TensorType.FLOAT32,
        )
        weight_tensor_type_str = self.get_tensor_type_str(weight_tensor_type)

        in_expr = self.get_expr(input_tensor_idx)

        # TFLite converts float32 models to float16 models by introducing
        # a Dequantize op in every op that contains a float32 values.
        # (weights, biases, and constants etc. )
        # So conv op may have weight and bias as tensors instead of values.
        if self.has_expr(weight_tensor.tensor_idx):
            weight_expr = self.get_expr(weight_tensor.tensor_idx)
            if is_depthwise_conv:
                weight_expr = relax.op.reshape(
                    weight_expr, (kernel_h, kernel_w, input_c, depth_multiplier)
                )
            else:
                weight_expr = relax.op.permute_dims(weight_expr, axes=(1, 2, 3, 0))
        else:
            if self.is_prefetched(weight_tensor.tensor_idx):
                weight_value = self.get_prefetched_node(weight_tensor.tensor_idx)
            else:
                weight_value = self.get_tensor_value(weight_tensor)

            # TFLite kernel layout:
            # convolution:
            # OC KH KW IC, we require KH KW IC OC (HWIO)
            # depthwise convolution:
            # 1 KH KW C(input_c * depth_multiplier), we require
            # KH KW IC M (depth_multiplier) (HWOI)
            if is_depthwise_conv:
                weight_value = weight_value.reshape(kernel_h, kernel_w, input_c, depth_multiplier)
            else:
                weight_value = weight_value.transpose((1, 2, 3, 0))

            weight_expr = self.exp_tab.new_const(
                weight_value, dtype=weight_tensor_type_str, source_name=weight_tensor.tensor.Name()
            )

        if padding == Padding.VALID:
            pass
        elif padding == Padding.SAME:
            pad_top, pad_bottom = get_pad_value(input_h, dilated_kernel_h, stride_h)

            pad_left, pad_right = get_pad_value(input_w, dilated_kernel_w, stride_w)
            do_pad = not (pad_top == 0 and pad_bottom == 0 and pad_left == 0 and pad_right == 0)
            if do_pad:
                params["padding"] = [pad_top, pad_left, pad_bottom, pad_right]

        else:
            raise tvm.error.OpAttributeUnImplemented(
                f"Padding format {padding} is not supported for operator Conv."
            )

        if input_tensor.qnn_params:
            # Dequantize input activation
            in_f32 = self.dequantize(in_expr, input_tensor)
            # Dequantize weight with per-channel axis remap.
            # TFLite weight original layout: [OC, KH, KW, IC]
            # After transpose to HWIO: [KH, KW, IC, OC]
            # QuantizedDimension() == 0 (OC in original) → axis 3 in HWIO.
            weight_axis = weight_tensor.qnn_params["axis"]
            if is_depthwise_conv:
                if weight_axis != 0:
                    raise tvm.error.OpNotImplemented(
                        "Per-channel quantized depthwise convolution is not supported "
                        "because the channel axis changes semantics after the "
                        "[1,KH,KW,C*M] → [KH,KW,C,M] reshape."
                    )
            else:
                if weight_axis != 0:
                    raise tvm.error.OpAttributeInvalid(
                        f"Conv2D weight QuantizedDimension() must be 0 (output-channel "
                        f"axis in [OC,KH,KW,IC] layout), got {weight_axis}"
                    )
                weight_axis = 3
            w_f32 = relax.op.dequantize(
                weight_expr,
                scale=weight_tensor.qnn_params["scale"],
                zero_point=weight_tensor.qnn_params["zero_point"],
                axis=weight_axis,
            )
            # Float convolution
            out = relax.op.nn.conv2d(in_f32, w_f32, **params)
        else:
            out = relax.op.nn.conv2d(in_expr, weight_expr, **params)

        # if we have bias
        if len(input_tensors) == 3:
            bias_tensor = input_tensors[2]
            bias_tensor_type = bias_tensor.tensor.Type()
            # bias tensor type should be INT32 (int8 qnn) or INT64 (int16 qnn) or FLOAT32
            assert bias_tensor_type in (
                TensorType.INT32,
                TensorType.INT64,
                TensorType.FLOAT32,
            )
            bias_tensor_type_str = self.get_tensor_type_str(bias_tensor_type)
            if self.has_expr(bias_tensor.tensor_idx):
                bias_expr = self.get_expr(bias_tensor.tensor_idx)
            else:
                bias_expr = self.exp_tab.new_const(
                    self.get_tensor_value(bias_tensor),
                    dtype=bias_tensor_type_str,
                    source_name=bias_tensor.tensor.Name(),
                )
            # For quantized conv, INT32/INT64 bias must be dequantized
            # to float32 before adding to the float conv output.
            if bias_tensor.qnn_params:
                bias_expr = self.dequantize(bias_expr, bias_tensor)
            elif input_tensor.qnn_params and bias_tensor_type in (
                TensorType.INT32,
                TensorType.INT64,
            ):
                bias_expr = relax.op.dequantize(
                    bias_expr,
                    scale=relax.op.multiply(
                        input_tensor.qnn_params["scale"],
                        weight_tensor.qnn_params["scale"],
                    ),
                    zero_point=relax.const(0, "int32"),
                    axis=0,
                )
            out = relax.op.add(out, bias_expr)

        # Handle fused activation.
        if output_tensor.qnn_params:
            # Quantize the float output using the output tensor's qnn params.
            out = self.quantize(out, output_tensor)

            # Call quantized activation function
            output_scale_val = get_scalar_from_constant(output_tensor.qnn_params["scale"])
            output_zero_point_val = get_scalar_from_constant(output_tensor.qnn_params["zero_point"])
            out = self.convert_qnn_fused_activation_function(
                expr=out,
                fused_activation_fn=fused_activation_fn,
                scale=output_scale_val,
                zero_point=output_zero_point_val,
                dtype=output_tensor_type_str,
            )
        else:
            out = self.convert_fused_activation_function(out, fused_activation_fn)
        return out

    def convert_conv3d(self, op):
        """3D convolution implementation."""

        from tflite.BuiltinOptions import BuiltinOptions
        from tflite.Conv3DOptions import Conv3DOptions
        from tflite.Padding import Padding
        from tflite.TensorType import TensorType

        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) >= 2, "input tensors length should be >= 2"

        input_tensor = input_tensors[0]
        input_tensor_idx = input_tensor.tensor_idx
        weight_tensor = input_tensors[1]

        output_tensors = self.get_output_tensors(op)
        assert len(output_tensors) == 1, "output tensors length should be 1"
        output_tensor = output_tensors[0]

        assert op.BuiltinOptionsType() == BuiltinOptions.Conv3DOptions
        op_options = op.BuiltinOptions()
        conv3d_options = Conv3DOptions()
        conv3d_options.Init(op_options.Bytes, op_options.Pos)

        stride_d = conv3d_options.StrideD()
        stride_h = conv3d_options.StrideH()
        stride_w = conv3d_options.StrideW()
        dilation_d = conv3d_options.DilationDFactor()
        dilation_h = conv3d_options.DilationHFactor()
        dilation_w = conv3d_options.DilationWFactor()
        padding = conv3d_options.Padding()
        fused_activation_fn = conv3d_options.FusedActivationFunction()

        _, input_d, input_h, input_w, input_c = to_int_list(self.get_tensor_shape(input_tensor))
        # TFLite Conv3D kernel layout is already DHWIO:
        # KD KH KW IC OC
        kernel_d, kernel_h, kernel_w, in_channels, output_channels = to_int_list(
            self.get_tensor_shape(weight_tensor)
        )

        dilated_kernel_d = dilation_d * (kernel_d - 1) + 1
        dilated_kernel_h = dilation_h * (kernel_h - 1) + 1
        dilated_kernel_w = dilation_w * (kernel_w - 1) + 1

        params = {
            "strides": [stride_d, stride_h, stride_w],
            "dilation": [dilation_d, dilation_h, dilation_w],
            "padding": [0, 0, 0, 0, 0, 0],
            "data_layout": "NDHWC",
        }

        params["kernel_layout"] = "DHWIO"
        if input_c != in_channels:
            assert input_c % in_channels == 0, (
                "Input channels is not divisible by kernel in_channels."
            )
            params["groups"] = int(input_c / in_channels)

        # weight tensor type should be INT8/UINT8 (quantization) or FLOAT32
        weight_tensor_type = weight_tensor.tensor.Type()
        assert weight_tensor_type in (
            TensorType.INT8,
            TensorType.UINT8,
            TensorType.FLOAT32,
        )
        weight_tensor_type_str = self.get_tensor_type_str(weight_tensor_type)

        in_expr = self.get_expr(input_tensor_idx)

        # TFLite Conv3D kernel is already in DHWIO layout, no transpose needed.
        if self.has_expr(weight_tensor.tensor_idx):
            weight_expr = self.get_expr(weight_tensor.tensor_idx)
        else:
            if self.is_prefetched(weight_tensor.tensor_idx):
                weight_value = self.get_prefetched_node(weight_tensor.tensor_idx)
            else:
                weight_value = self.get_tensor_value(weight_tensor)

            weight_expr = self.exp_tab.new_const(
                weight_value, dtype=weight_tensor_type_str, source_name=weight_tensor.tensor.Name()
            )

        if padding == Padding.VALID:
            pass
        elif padding == Padding.SAME:
            pad_front, pad_back = get_pad_value(input_d, dilated_kernel_d, stride_d)
            pad_top, pad_bottom = get_pad_value(input_h, dilated_kernel_h, stride_h)
            pad_left, pad_right = get_pad_value(input_w, dilated_kernel_w, stride_w)

            do_pad = not (
                pad_front == 0
                and pad_back == 0
                and pad_top == 0
                and pad_bottom == 0
                and pad_left == 0
                and pad_right == 0
            )
            if do_pad:
                params["padding"] = [pad_front, pad_top, pad_left, pad_back, pad_bottom, pad_right]
        else:
            raise tvm.error.OpAttributeUnImplemented(
                f"Padding format {padding} is not supported for operator Conv3D."
            )

        if input_tensor.qnn_params:
            raise tvm.error.OpNotImplemented(
                "Quantized Conv3D is not yet supported in the Relax frontend."
            )

        out = relax.op.nn.conv3d(in_expr, weight_expr, **params)

        # if we have bias
        if len(input_tensors) == 3:
            bias_tensor = input_tensors[2]
            if bias_tensor.tensor_idx != -1:
                bias_tensor_type = bias_tensor.tensor.Type()
                # bias tensor type should be INT32 (int8 qnn) or INT64 (int16 qnn) or FLOAT32
                assert bias_tensor_type in (TensorType.INT32, TensorType.INT64, TensorType.FLOAT32)
                bias_tensor_type_str = self.get_tensor_type_str(bias_tensor_type)
                if self.has_expr(bias_tensor.tensor_idx):
                    bias_expr = self.get_expr(bias_tensor.tensor_idx)
                else:
                    bias_expr = self.exp_tab.new_const(
                        self.get_tensor_value(bias_tensor),
                        dtype=bias_tensor_type_str,
                        source_name=bias_tensor.tensor.Name(),
                    )
                out = relax.op.add(out, bias_expr)

        # Handle fused activation.
        if output_tensor.qnn_params:
            raise tvm.error.OpNotImplemented(
                "Quantized Conv3D is not yet supported in the Relax frontend."
            )

        out = self.convert_fused_activation_function(out, fused_activation_fn)
        return out

    def convert_conv3d_transpose(self, op):
        """3D transposed convolution implementation."""

        from tflite.BuiltinOptions import BuiltinOptions
        from tflite.Conv3DOptions import Conv3DOptions
        from tflite.Padding import Padding
        from tflite.TensorType import TensorType

        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) >= 3, "input tensors length should be >= 3"

        # TFLite CONV_3D_TRANSPOSE input order:
        # [0] output_shape, [1] weight, [2] data, [3] bias (optional)
        weight_tensor = input_tensors[1]
        input_tensor = input_tensors[2]
        input_tensor_idx = input_tensor.tensor_idx

        output_tensors = self.get_output_tensors(op)
        assert len(output_tensors) == 1, "output tensors length should be 1"
        output_tensor = output_tensors[0]

        assert op.BuiltinOptionsType() == BuiltinOptions.Conv3DOptions
        op_options = op.BuiltinOptions()
        conv3d_options = Conv3DOptions()
        conv3d_options.Init(op_options.Bytes, op_options.Pos)

        stride_d = conv3d_options.StrideD()
        stride_h = conv3d_options.StrideH()
        stride_w = conv3d_options.StrideW()
        dilation_d = conv3d_options.DilationDFactor()
        dilation_h = conv3d_options.DilationHFactor()
        dilation_w = conv3d_options.DilationWFactor()
        padding = conv3d_options.Padding()
        fused_activation_fn = conv3d_options.FusedActivationFunction()

        _, input_d, input_h, input_w, input_c = to_int_list(self.get_tensor_shape(input_tensor))

        # TFLite Conv3DTranspose kernel layout is DHWOI:
        # KD KH KW OC IC
        kernel_d, kernel_h, kernel_w, output_channels, in_channels = to_int_list(
            self.get_tensor_shape(weight_tensor)
        )

        dilated_kernel_d = dilation_d * (kernel_d - 1) + 1
        dilated_kernel_h = dilation_h * (kernel_h - 1) + 1
        dilated_kernel_w = dilation_w * (kernel_w - 1) + 1

        params = {
            "strides": [stride_d, stride_h, stride_w],
            "dilation": [dilation_d, dilation_h, dilation_w],
            "padding": [0, 0, 0, 0, 0, 0],
            "output_padding": [0, 0, 0],
            "data_layout": "NDHWC",
            "kernel_layout": "DHWOI",
        }

        if input_c != in_channels:
            assert input_c % in_channels == 0, (
                "Input channels is not divisible by kernel in_channels."
            )
            params["groups"] = int(input_c / in_channels)

        # weight tensor type should be INT8/UINT8 (quantization) or FLOAT32
        weight_tensor_type = weight_tensor.tensor.Type()
        assert weight_tensor_type in (
            TensorType.INT8,
            TensorType.UINT8,
            TensorType.FLOAT32,
        )
        weight_tensor_type_str = self.get_tensor_type_str(weight_tensor_type)

        in_expr = self.get_expr(input_tensor_idx)

        # TFLite Conv3DTranspose kernel is already in DHWOI layout, no transpose needed.
        if self.has_expr(weight_tensor.tensor_idx):
            weight_expr = self.get_expr(weight_tensor.tensor_idx)
        else:
            if self.is_prefetched(weight_tensor.tensor_idx):
                weight_value = self.get_prefetched_node(weight_tensor.tensor_idx)
            else:
                weight_value = self.get_tensor_value(weight_tensor)

            weight_expr = self.exp_tab.new_const(
                weight_value, dtype=weight_tensor_type_str, source_name=weight_tensor.tensor.Name()
            )

        if padding == Padding.VALID:
            pass
        elif padding == Padding.SAME:
            # For transposed convolution with SAME padding:
            # target output_size = input_size * stride
            # total_pad = max(0, dilated_kernel - stride)
            for dim_kernel, dim_stride, label in [
                (dilated_kernel_d, stride_d, "D"),
                (dilated_kernel_h, stride_h, "H"),
                (dilated_kernel_w, stride_w, "W"),
            ]:
                total_pad = max(0, dim_kernel - dim_stride)
                pad_before = total_pad // 2
                pad_after = total_pad - pad_before
                idx = {"D": 0, "H": 1, "W": 2}[label]
                params["padding"][idx] = pad_before
                params["padding"][idx + 3] = pad_after

                # output_padding handles the case when stride > dilated_kernel
                output_pad = max(0, dim_stride - dim_kernel)
                params["output_padding"][idx] = output_pad
        else:
            raise tvm.error.OpAttributeUnImplemented(
                f"Padding format {padding} is not supported for operator Conv3DTranspose."
            )

        if input_tensor.qnn_params:
            raise tvm.error.OpNotImplemented(
                "Quantized Conv3DTranspose is not yet supported in the Relax frontend."
            )

        out = relax.op.nn.conv3d_transpose(in_expr, weight_expr, **params)

        # if we have bias (input_tensors[3])
        if len(input_tensors) >= 4:
            bias_tensor = input_tensors[3]
            if bias_tensor.tensor_idx != -1:
                bias_tensor_type = bias_tensor.tensor.Type()
                # bias tensor type should be INT32 (int8 qnn) or INT64 (int16 qnn) or FLOAT32
                assert bias_tensor_type in (TensorType.INT32, TensorType.INT64, TensorType.FLOAT32)
                bias_tensor_type_str = self.get_tensor_type_str(bias_tensor_type)
                if self.has_expr(bias_tensor.tensor_idx):
                    bias_expr = self.get_expr(bias_tensor.tensor_idx)
                else:
                    bias_expr = self.exp_tab.new_const(
                        self.get_tensor_value(bias_tensor),
                        dtype=bias_tensor_type_str,
                        source_name=bias_tensor.tensor.Name(),
                    )
                out = relax.op.add(out, bias_expr)

        # Handle fused activation.
        if output_tensor.qnn_params:
            raise tvm.error.OpNotImplemented(
                "Quantized Conv3DTranspose is not yet supported in the Relax frontend."
            )

        out = self.convert_fused_activation_function(out, fused_activation_fn)
        return out

    def convert_split(self, op):
        """split implementation."""

        from tflite.BuiltinOptions import BuiltinOptions
        from tflite.SplitOptions import SplitOptions

        input_tensors = self.get_input_tensors(op)

        assert len(input_tensors) == 2, "input tensors length should be == 2"

        axis_tensor = input_tensors[0]
        split_axis = self.get_tensor_value(axis_tensor)
        input_tensor = input_tensors[1]
        input_tensor_idx = input_tensor.tensor_idx

        assert op.BuiltinOptionsType() == BuiltinOptions.SplitOptions
        op_options = op.BuiltinOptions()
        split_options = SplitOptions()
        split_options.Init(op_options.Bytes, op_options.Pos)
        num_splits = split_options.NumSplits()

        in_expr = self.get_expr(input_tensor_idx)
        out = relax.op.split(in_expr, num_splits, axis=int(split_axis))
        # Relay does not like a TupleWrapper of 1 element, further this
        # only shows up with tf1.13 if we use a split with num_splits==1.
        # In tf 1.14 this doesn't appear as it is automatically a reshape
        # operation.
        if isinstance(out, relax.Tuple):
            if out.size == 1:
                out = out[0]

        return out

    def convert_split_v(self, op):
        """SPLIT_V implementation."""
        input_tensors = self.get_input_tensors(op)
        output_tensors = self.get_output_tensors(op)

        assert len(input_tensors) == 3, "input tensors length should be 3"

        input_tensor = input_tensors[0]
        input_tensor_idx = input_tensor.tensor_idx
        in_expr = self.get_expr(input_tensor_idx)

        axis_tensor = input_tensors[2]
        split_axis = int(self.get_tensor_value(axis_tensor))

        size_splits_tensor = input_tensors[1]

        if self.has_expr(size_splits_tensor.tensor_idx):
            # Dynamic size_splits case: decompose into dynamic strided slices.
            size_splits_expr = self.get_expr(size_splits_tensor.tensor_idx)
            cumsum = relax.op.cumsum(size_splits_expr, axis=0, dtype="int64")
            # Pad a leading zero so that cumsum[i-1] can be read uniformly
            # via strided_slice even for i == 0.
            zero = relax.const(np.array([0], dtype="int64"), "int64")
            padded_cumsum = relax.op.concat([zero, cumsum], axis=0)
            # TFLite fixes the tuple arity in the graph, even when the split
            # sizes themselves are supplied at runtime.
            num_splits = len(output_tensors)
            rank = len(in_expr.ty.shape)

            # end_base is the full input shape; only split_axis changes per slice.
            end_base = relax.op.shape_to_tensor(relax.op.shape_of(in_expr))
            begin_base = relax.const(np.zeros((rank,), dtype="int64"), "int64")
            strides = relax.const(np.ones((rank,), dtype="int64"), "int64")
            scatter_idx = relax.const([split_axis], "int64")

            outputs = []
            for i in range(num_splits):
                start_val = relax.op.strided_slice(padded_cumsum, axes=[0], begin=[i], end=[i + 1])
                end_val = relax.op.strided_slice(
                    padded_cumsum, axes=[0], begin=[i + 1], end=[i + 2]
                )

                begin = relax.op.scatter_elements(begin_base, scatter_idx, start_val)
                end = relax.op.scatter_elements(end_base, scatter_idx, end_val)
                slice_i = relax.op.dynamic_strided_slice(in_expr, begin, end, strides)
                outputs.append(slice_i)

            out = relax.Tuple(outputs)
        else:
            # Static size_splits case
            size_splits = list(self.get_tensor_value(size_splits_tensor))
            size_splits = tuple(np.cumsum(size_splits)[:-1])
            out = relax.op.split(in_expr, size_splits, axis=split_axis)

        # Relay does not like a TupleWrapper of 1 element, further this
        # only shows up with tf1.13 if we use a split with num_splits==1.
        # In tf 1.14 this doesn't appear as it is automatically a reshape
        # operation.
        if isinstance(out, relax.Tuple) and len(out.fields) == 1:
            out = out[0]

        return out

    def convert_slice(self, op):
        """Convert TFLite SLICE"""
        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 3, "input tensors length should be == 3"
        input_tensor = input_tensors[0]
        in_expr = self.get_expr(input_tensor.tensor_idx)

        begin = list(self.get_tensor_value(input_tensors[1]))
        size = list(self.get_tensor_value(input_tensors[2]))
        # strided_slice(Relax) needs the slice's end indices, not the size
        end = size
        input_tensor_shape = to_int_list(self.get_tensor_shape(input_tensor))
        input_tensor_rank = len(input_tensor_shape)
        for i in range(input_tensor_rank):
            if size[i] == -1:
                end[i] = input_tensor_shape[i]
            else:
                end[i] += begin[i]

        # Create axes list for all dimensions being sliced
        axes = list(range(input_tensor_rank))
        begin = [int(v) for v in begin]
        end = [int(v) for v in end]
        out = relax.op.strided_slice(in_expr, axes=axes, begin=begin, end=end)
        return out

    def convert_scatter_nd(self, op):
        """Convert TFLite SCATTER_ND"""
        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 3, "SCATTER_ND should have 3 input tensors"
        indices = self.get_tensor_expr(input_tensors[0])
        updates = self.get_tensor_expr(input_tensors[1])
        shape_tensor = input_tensors[2]

        output_tensors = self.get_output_tensors(op)
        assert len(output_tensors) == 1, "SCATTER_ND should have 1 output tensor"
        updates_dtype = self.get_tensor_type_str(output_tensors[0].tensor.Type())

        if self.has_expr(shape_tensor.tensor_idx):
            shape_expr = self.get_expr(shape_tensor.tensor_idx)
            shape_expr = self.bb.normalize(relax.op.astype(shape_expr, "int64"))
            shape = self.bb.emit_output(relax.op.tensor_to_shape(shape_expr))
        else:
            shape = to_int_list(self.get_tensor_value(shape_tensor))

        indices_dims = len(self._infer_shape(indices))
        indices = relax.op.permute_dims(indices, axes=[-1] + list(range(indices_dims - 1)))

        data = relax.op.zeros(shape, updates_dtype)
        return relax.op.scatter_nd(data, indices, updates, "update")

    def convert_unique(self, op):
        """Convert TFLite UNIQUE."""
        from tflite.TensorType import TensorType
        from tflite.UniqueOptions import UniqueOptions

        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 1, "UNIQUE should have 1 input tensor"

        output_tensors = self.get_output_tensors(op)
        assert len(output_tensors) == 2, "UNIQUE should have 2 output tensors"

        unique_options = UniqueOptions()
        op_options = op.BuiltinOptions()
        unique_options.Init(op_options.Bytes, op_options.Pos)

        unique = self.bb.normalize(
            relax.op.unique(
                self.get_tensor_expr(input_tensors[0]),
                sorted=False,
                return_index=False,
                return_inverse=True,
                return_counts=False,
                axis=None,
            )
        )
        values = self.bb.emit(relax.TupleGetItem(unique, 0))
        inverse_indices = self.bb.emit(relax.TupleGetItem(unique, 1))

        idx_out_type = unique_options.IdxOutType()
        if idx_out_type == TensorType.INT32:
            inverse_indices = self.bb.emit(relax.op.astype(inverse_indices, "int32"))

        return relax.Tuple([values, inverse_indices])

    def _get_segment_scatter_base(self, output_shape, output_dtype, reduction):
        """Create the identity base tensor for scatter-based segment reductions."""
        if reduction == "add":
            return relax.op.zeros(output_shape, output_dtype)
        if reduction == "mul":
            return relax.op.full(output_shape, relax.const(1, output_dtype), output_dtype)
        if reduction == "max":
            np_dtype = np.dtype(output_dtype)
            if np.issubdtype(np_dtype, np.floating):
                identity = np.finfo(np_dtype).min
            elif np.issubdtype(np_dtype, np.integer):
                identity = np.iinfo(np_dtype).min
            else:
                raise tvm.error.OpNotImplemented(
                    f"UNSORTED_SEGMENT_MAX does not support output dtype {output_dtype}."
                )
            return relax.op.full(output_shape, relax.const(identity, output_dtype), output_dtype)
        if reduction == "min":
            np_dtype = np.dtype(output_dtype)
            if np.issubdtype(np_dtype, np.floating):
                identity = np.finfo(np_dtype).max
            elif np.issubdtype(np_dtype, np.integer):
                identity = np.iinfo(np_dtype).max
            else:
                raise tvm.error.OpNotImplemented(
                    f"UNSORTED_SEGMENT_MIN does not support output dtype {output_dtype}."
                )
            return relax.op.full(output_shape, relax.const(identity, output_dtype), output_dtype)

        raise ValueError(f"Unsupported segment reduction mode: {reduction}")

    def _get_segment_num_segments(self, op_name, input_tensors):
        if op_name == "SEGMENT_SUM":
            segment_ids_tensor = input_tensors[1]
            if self.has_expr(segment_ids_tensor.tensor_idx):
                raise tvm.error.OpNotImplemented(
                    "TFLite SEGMENT_SUM with runtime segment_ids is not supported, "
                    "because TFLite does not encode a reliable output segment count."
                )
            segment_ids = self.get_tensor_value(segment_ids_tensor)
            if np.any(segment_ids < 0):
                raise tvm.error.OpNotImplemented(
                    "TFLite SEGMENT_SUM with negative segment ids is not supported."
                )
            return int(np.max(segment_ids)) + 1 if segment_ids.size else 0

        num_segments_tensor = input_tensors[2]
        if self.has_expr(num_segments_tensor.tensor_idx):
            raise tvm.error.OpNotImplemented(
                f"TFLite {op_name} with runtime num_segments is not supported."
            )
        num_segments_value = self.get_tensor_value(num_segments_tensor)
        assert num_segments_value.size == 1, f"{op_name} num_segments should be a scalar tensor"
        num_segments = int(num_segments_value.item())
        assert num_segments >= 0, f"{op_name} num_segments should be non-negative"
        return num_segments

    def _convert_segment_op(self, op, op_name, reduction):
        """Convert TFLite segment ops through relax.op.scatter_nd."""
        from tflite.TensorType import TensorType

        input_tensors = self.get_input_tensors(op)
        expected_inputs = 2 if op_name == "SEGMENT_SUM" else 3
        assert len(input_tensors) == expected_inputs, (
            f"{op_name} should have {expected_inputs} input tensors"
        )

        data_tensor = input_tensors[0]
        segment_ids_tensor = input_tensors[1]
        for t in input_tensors:
            assert not t.qnn_params, "Quantized input is not expected."

        segment_ids_type = segment_ids_tensor.tensor.Type()
        assert segment_ids_type in (TensorType.INT32, TensorType.INT64)
        if op_name != "SEGMENT_SUM":
            num_segments_type = input_tensors[2].tensor.Type()
            assert num_segments_type in (TensorType.INT32, TensorType.INT64)
        if not self.has_expr(segment_ids_tensor.tensor_idx):
            segment_ids_value = self.get_tensor_value(segment_ids_tensor)
            if np.any(segment_ids_value < 0):
                raise tvm.error.OpNotImplemented(
                    f"TFLite {op_name} with negative segment ids is not supported."
                )

        output_tensors = self.get_output_tensors(op)
        assert len(output_tensors) == 1, f"{op_name} should have 1 output tensor"
        output_tensor = output_tensors[0]
        output_dtype = self.get_tensor_type_str(output_tensor.tensor.Type())

        data_shape = to_int_list(self.get_tensor_shape(data_tensor))
        segment_ids_shape = to_int_list(self.get_tensor_shape(segment_ids_tensor))
        segment_ids_rank = len(segment_ids_shape)
        assert data_shape[:segment_ids_rank] == segment_ids_shape, (
            f"{op_name} requires segment_ids shape to match a prefix of data shape"
        )
        num_segments = self._get_segment_num_segments(op_name, input_tensors)
        output_shape = [num_segments] + data_shape[segment_ids_rank:]

        data = self.get_tensor_expr(data_tensor)
        segment_ids = self.get_tensor_expr(segment_ids_tensor)
        indices = relax.op.expand_dims(segment_ids, axis=[segment_ids_rank])

        base = self._get_segment_scatter_base(output_shape, output_dtype, reduction)
        return relax.op.scatter_nd(base, indices, data, reduction)

    def convert_select(self, op):
        """Convert TFLite SELECT"""
        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 3, "input tensors length should be == 3"
        cond = self.get_tensor_expr(input_tensors[0])
        x = self.get_tensor_expr(input_tensors[1])
        y = self.get_tensor_expr(input_tensors[2])

        out = relax.op.where(cond, x, y)

        return out

    def convert_transpose(self, op):
        """transpose implementation."""
        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 2, "input tensors length should be 2"
        input_tensor = input_tensors[0]
        input_tensor_idx = input_tensor.tensor_idx

        in_expr = self.get_expr(input_tensor_idx)

        # axis
        in_axis = tuple(self.get_tensor_value(input_tensors[1]))

        if not in_axis:
            out = relax.op.permute_dims(in_expr)
        else:
            out = relax.op.permute_dims(in_expr, in_axis)

        return out

    def convert_reverse_sequence(self, op):
        """Convert TFLite REVERSE_SEQUENCE"""

        from tflite.BuiltinOptions import BuiltinOptions
        from tflite.ReverseSequenceOptions import ReverseSequenceOptions

        if self.is_quantized(op):
            raise tvm.error.OpNotImplemented(
                "TFLite does not support quantized REVERSE_SEQUENCE operator yet."
            )

        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 2, "input tensors length should be 2"

        in_expr = self.get_tensor_expr(input_tensors[0])
        length_expr = self.get_tensor_expr(input_tensors[1])

        assert op.BuiltinOptionsType() == BuiltinOptions.ReverseSequenceOptions
        op_options = op.BuiltinOptions()
        options = ReverseSequenceOptions()
        options.Init(op_options.Bytes, op_options.Pos)
        batch_axis = options.BatchDim()
        seq_axis = options.SeqDim()

        return relax.op.reverse_sequence(in_expr, length_expr, seq_axis, batch_axis)

    def convert_bitcast(self, op):
        """Convert TFLite BITCAST"""
        input_tensors = self.get_input_tensors(op)
        output_tensors = self.get_output_tensors(op)
        assert len(input_tensors) == 1, "input tensors length should be 1"
        assert len(output_tensors) == 1, "output tensors length should be 1"

        in_expr = self.get_tensor_expr(input_tensors[0])
        input_dtype = self.get_tensor_type_str(input_tensors[0].tensor.Type())
        output_dtype = self.get_tensor_type_str(output_tensors[0].tensor.Type())
        input_shape = to_int_list(self.get_tensor_shape(input_tensors[0]))
        output_shape = to_int_list(self.get_tensor_shape(output_tensors[0]))

        input_nbytes = int(np.prod(input_shape)) * np.dtype(input_dtype).itemsize
        output_nbytes = int(np.prod(output_shape)) * np.dtype(output_dtype).itemsize
        assert input_nbytes == output_nbytes, (
            "TFLite BITCAST requires input.nbytes == output.nbytes, "
            f"but got input={input_nbytes} bytes, output={output_nbytes} bytes"
        )

        return relax.op.memory.view(in_expr, shape=output_shape, dtype=output_dtype)

    def convert_rfft2d(self, op):
        """Convert TFLite RFFT2D for static no-padding/no-truncation shapes."""
        from tflite.TensorType import TensorType

        input_tensors = self.get_input_tensors(op)
        output_tensors = self.get_output_tensors(op)
        if len(input_tensors) != 2 or len(output_tensors) != 1:
            raise tvm.error.OpNotImplemented("RFFT2D expects two inputs and one output")

        data_tensor, fft_length_tensor = input_tensors
        output_tensor = output_tensors[0]
        if data_tensor.tensor.Type() != TensorType.FLOAT32:
            raise tvm.error.OpNotImplemented("RFFT2D input must be float32")
        if not self._is_tflite_complex64_type(output_tensor.tensor.Type()):
            raise tvm.error.OpNotImplemented("RFFT2D output must be COMPLEX64")
        if (
            data_tensor.tensor.Sparsity() is not None
            or fft_length_tensor.tensor.Sparsity() is not None
        ):
            raise tvm.error.OpNotImplemented("RFFT2D does not support sparse inputs")

        input_shape = tuple(to_int_list(self.get_tensor_shape(data_tensor)))
        tflite_output_shape = tuple(to_int_list(self.get_tensor_shape(output_tensor)))
        if len(input_shape) < 2:
            raise tvm.error.OpNotImplemented("RFFT2D input rank must be at least 2")

        try:
            fft_length_value = self.get_tensor_value_or_prefetched(fft_length_tensor)
        except (ValueError, TypeError):
            raise tvm.error.OpNotImplemented("RFFT2D requires a constant fft_length") from None
        if fft_length_value is None:
            raise tvm.error.OpNotImplemented("RFFT2D requires a constant fft_length")
        # Reject non-integer fft_length tensors before astype("int64") can silently
        # truncate (e.g. float32 2.7 -> int64 2).
        if not np.issubdtype(fft_length_value.dtype, np.integer):
            raise tvm.error.OpNotImplemented(
                f"RFFT2D fft_length must be an integer tensor, got dtype {fft_length_value.dtype!r}"
            )
        fft_length = fft_length_value.astype("int64")
        if tuple(fft_length.shape) != (2,):
            raise tvm.error.OpNotImplemented("RFFT2D fft_length must be a length-2 tensor")

        height = int(fft_length[0])
        width = int(fft_length[1])
        if height <= 0 or width <= 0:
            raise tvm.error.OpNotImplemented(
                f"RFFT2D fft_length must be positive, got ({height}, {width})"
            )
        if height != int(input_shape[-2]) or width != int(input_shape[-1]):
            raise tvm.error.OpNotImplemented(
                "RFFT2D currently supports fft_length matching the input spatial shape"
            )
        expected_tflite_output_shape = input_shape[:-2] + (height, width // 2 + 1)
        if tflite_output_shape != expected_tflite_output_shape:
            raise tvm.error.OpNotImplemented("RFFT2D output shape does not match fft_length")

        relax_output_shape = self._get_relax_tensor_shape(output_tensor)
        # Dispatch: power-of-2 sizes use the O(N^2 log N) Cooley-Tukey FFT kernel;
        # the remaining (odd / non-power-of-2) shapes fall back to the O(N^4) DFT
        # reference. Both kernels share the same call_tir contract, so the
        # downstream code is kernel-agnostic.
        if _is_power_of_2(height) and _is_power_of_2(width):
            prim_func = _build_tflite_rfft2d_fft_primfunc(input_shape, relax_output_shape)
        else:
            prim_func = _build_tflite_rfft2d_primfunc(input_shape, relax_output_shape)
        module_builder = self.conversion_state["module_builder"]
        func_name = f"tflite_rfft2d_{output_tensor.tensor_idx}"
        gv = module_builder.add_func(prim_func, func_name)
        data_expr = self.get_tensor_expr(data_tensor)
        call = relax.call_tir(
            gv,
            [data_expr],
            relax.TensorType(relax_output_shape, "float32"),
        )
        return self.bb.normalize(call)

    def convert_broadcast_args(self, op):
        """Convert TFLite BROADCAST_ARGS"""
        input_tensors = self.get_input_tensors(op)
        output_tensors = self.get_output_tensors(op)
        assert len(input_tensors) == 2, "input tensors length should be 2"
        assert len(output_tensors) == 1, "output tensors length should be 1"

        s0 = self.get_tensor_expr(input_tensors[0])
        s1 = self.get_tensor_expr(input_tensors[1])
        s0_len = to_int_list(self.get_tensor_shape(input_tensors[0]))[0]
        s1_len = to_int_list(self.get_tensor_shape(input_tensors[1]))[0]
        out_dtype = self.get_tensor_type_str(input_tensors[0].tensor.Type())

        # Left-pad the shorter input with 1s to length target_len.
        target_len = tirx.max(s0_len, s1_len)
        one = relax.const(1, dtype=out_dtype)
        s0 = relax.op.concat(
            [relax.op.full([target_len - s0_len], one, dtype=out_dtype), s0], axis=0
        )
        s1 = relax.op.concat(
            [relax.op.full([target_len - s1_len], one, dtype=out_dtype), s1], axis=0
        )
        # Per-dim broadcast. If either side is 1 take the other, else elementwise max.
        s0_is_one = relax.op.equal(s0, one)
        s1_is_one = relax.op.equal(s1, one)
        return relax.op.where(
            s0_is_one,
            s1,
            relax.op.where(s1_is_one, s0, relax.op.maximum(s0, s1)),
        )

    def convert_cast(self, op):
        """Convert TFLite CAST"""

        from tflite.BuiltinOptions import BuiltinOptions
        from tflite.CastOptions import CastOptions

        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 1, "input tensors length should be 1"
        input_tensor = input_tensors[0]
        in_expr = self.get_expr(input_tensor.tensor_idx)

        # MLIR-based converter outputs no BuiltinOptions for Cast operator. In this
        # case the output type can be derived from the Cast operator output tensor.
        # When TOCO converter is used there will be "normal" BuiltinOptions.CastOptions
        # with output type.
        if op.BuiltinOptions() is not None:
            assert op.BuiltinOptionsType() == BuiltinOptions.CastOptions
            op_options = op.BuiltinOptions()
            cast_options = CastOptions()
            cast_options.Init(op_options.Bytes, op_options.Pos)
            cast_dtype = cast_options.OutDataType()
        else:
            cast_dtype = self.get_output_tensors(op)[0].tensor.Type()

        out = relax.op.astype(in_expr, self.get_tensor_type_str(cast_dtype))

        return out

    def convert_tile(self, op):
        """tile implementation."""
        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 2, "input tensors length should be 2"
        input_tensor = input_tensors[0]
        input_tensor_idx = input_tensor.tensor_idx

        in_expr = self.get_expr(input_tensor_idx)

        reps = tuple(self.get_tensor_value(input_tensors[1]))

        out = relax.op.tile(in_expr, reps)

        return out

    def convert_topk_v2(self, op):
        """Convert TFLite TOPK_v2"""
        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 2, "input tensors length should be 2"
        input_tensor = input_tensors[0]
        input_tensor_idx = input_tensor.tensor_idx
        in_expr = self.get_expr(input_tensor_idx)
        k = self.get_tensor_value(input_tensors[1])
        out = relax.op.topk(in_expr, int(k))

        return out

    def convert_pool2d(self, op, pool_type):
        """pool2d implementation."""

        from tflite.BuiltinOptions import BuiltinOptions
        from tflite.Padding import Padding
        from tflite.Pool2DOptions import Pool2DOptions

        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 1, "input tensors length should be 1"
        input_tensor = input_tensors[0]
        input_tensor_idx = input_tensor.tensor_idx

        output_tensors = self.get_output_tensors(op)
        assert len(output_tensors) == 1, "output tensors should be 1"
        output_tensor = output_tensors[0]
        output_tensor_type = output_tensor.tensor.Type()
        output_tensor_type_str = self.get_tensor_type_str(output_tensor_type)

        assert op.BuiltinOptionsType() == BuiltinOptions.Pool2DOptions
        op_options = op.BuiltinOptions()
        pool2d_options = Pool2DOptions()
        pool2d_options.Init(op_options.Bytes, op_options.Pos)
        stride_h = pool2d_options.StrideH()
        stride_w = pool2d_options.StrideW()
        padding = pool2d_options.Padding()
        filter_h = pool2d_options.FilterHeight()
        filter_w = pool2d_options.FilterWidth()
        fused_activation_fn = pool2d_options.FusedActivationFunction()

        params = {
            "pool_size": (filter_h, filter_w),
            "strides": (stride_h, stride_w),
            "padding": [0, 0],
            "layout": "NHWC",
        }

        in_expr = self.get_expr(input_tensor_idx)

        _, input_h, input_w, _ = to_int_list(self.get_tensor_shape(input_tensor))

        if padding == Padding.VALID:
            pass
        elif padding == Padding.SAME:
            pad_top, pad_bottom = get_pad_value(input_h, filter_h, stride_h)
            pad_left, pad_right = get_pad_value(input_w, filter_w, stride_w)
            params["padding"] = [pad_top, pad_left, pad_bottom, pad_right]
        else:
            raise tvm.error.OpAttributeUnImplemented(
                f"Padding format {padding} for operator Pool2D is not supported."
            )

        if pool_type == "average":
            if input_tensor.qnn_params:
                assert self.has_same_qnn_params(input_tensor, output_tensor), (
                    "TFLite avg_pool2dreshape requires input and output scale"
                    "and zero points to be equal"
                )
                out = relax.op.astype(in_expr, "int32")
                out = relax.op.nn.avg_pool2d(out, **params)
                out = relax.op.astype(out, output_tensor_type_str)
            else:
                out = relax.op.nn.avg_pool2d(in_expr, **params)
        elif pool_type == "max":
            if input_tensor.qnn_params:
                assert self.has_same_qnn_params(input_tensor, output_tensor), (
                    "qnn.op.max_pool2d requires input and output qnn params to be same"
                )
            out = relax.op.nn.max_pool2d(in_expr, **params)
        elif pool_type == "l2":
            # L2_POOL_2D is equivalent to square_root(avg_pool(square(in_data)))
            # TFLite does not have support for quantised L2_POOL_2D op.
            assert not input_tensor.qnn_params, (
                "As TFLite does not have support for quantized L2_POOL_2D, \
                Quantized input is not expected."
            )
            exp_type = self.get_tensor_type_str(output_tensor.tensor.Type())
            square_exp = relax.op.power(in_expr, relax.const(2, exp_type))
            avg_pool_exp = relax.op.nn.avg_pool2d(square_exp, **params)
            out = relax.op.sqrt(avg_pool_exp)
        else:
            raise tvm.error.OpNotImplemented(
                f"Operator {pool_type} pool is not supported for frontend TFLite."
            )

        # Handle fused activations
        if output_tensor.qnn_params:
            scale_val = get_scalar_from_constant(output_tensor.qnn_params["scale"])
            zero_point_val = get_scalar_from_constant(output_tensor.qnn_params["zero_point"])
            out = self.convert_qnn_fused_activation_function(
                expr=out,
                fused_activation_fn=fused_activation_fn,
                scale=scale_val,
                zero_point=zero_point_val,
                dtype=output_tensor_type_str,
            )
        else:
            out = self.convert_fused_activation_function(out, fused_activation_fn)

        return out

    def convert_pad(self, op):
        """Convert TFLite PAD/PADV2 \
           TFLite treats PAD and PADV2 operators identically"""

        input_tensors = self.get_input_tensors(op)

        # TFLite PAD/PADV2 only supports CONSTANT mode
        assert len(input_tensors) == 2 or len(input_tensors) == 3, (
            "input tensor's length should be 2 for PAD and 3 for PADV2"
        )

        if len(input_tensors) == 3:
            assert input_tensors[0].tensor.Type() == input_tensors[2].tensor.Type(), (
                "constant_values tensor must be of same type as input tensor"
            )

        input_tensor = input_tensors[0]
        in_expr = self.get_expr(input_tensor.tensor_idx)

        # paddings
        pad_list = self.get_tensor_value(input_tensors[1])

        # convert list of lists to tuple of tuples
        paddings = []
        for val in pad_list:
            paddings += val.tolist()

        # Set the pad value, by default 0, unless constant_values parameter is provided
        pad_value = 0

        if input_tensor.qnn_params:
            # Check that input and output tensor have same qnn params.
            output_tensors = self.get_output_tensors(op)
            output_tensor = output_tensors[0]
            assert self.has_same_qnn_params(input_tensor, output_tensor), (
                "TFLite PADV2 requires input and output scale and zero points to be equal"
            )

            # The pad value for quantized pad is the input zero point by default.
            pad_value = float(input_tensor.qnn_params["zero_point"].data.numpy())

        if len(input_tensors) == 3:
            pad_value = self.get_tensor_value(input_tensors[2])
            if isinstance(pad_value, np.ndarray):
                pad_value = pad_value.tolist()
            if isinstance(pad_value, list):
                assert len(pad_value) == 1, "Only one constant value is expected."
                pad_value = pad_value[0]
            if input_tensor.qnn_params:
                # Check that input tensor and constant_values have same qnn params.
                assert self.has_same_qnn_params(input_tensor, input_tensors[2]), (
                    "TFLite PADV2 requires input and constant_values tensors' \
                        scale and zero points to be equal"
                )

        out = relax.op.nn.pad(in_expr, pad_width=paddings, pad_value=pad_value)
        return out

    def convert_mirror_pad(self, op):
        """Convert TFLite MIRROR_PAD"""

        from tflite.BuiltinOptions import BuiltinOptions
        from tflite.MirrorPadOptions import MirrorPadOptions

        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 2, "input tensors length should be 2"

        # tensor
        input_tensor = input_tensors[0]
        in_expr = self.get_expr(input_tensor.tensor_idx)

        # paddings
        pad_list = self.get_tensor_value(input_tensors[1])
        # convert list of lists to tuple of tuples
        paddings = tuple(tuple(val.astype(np.int32)) for val in pad_list)

        assert op.BuiltinOptionsType() == BuiltinOptions.MirrorPadOptions
        op_options = op.BuiltinOptions()
        mirror_pad_options = MirrorPadOptions()
        mirror_pad_options.Init(op_options.Bytes, op_options.Pos)
        mode_byte = mirror_pad_options.Mode()

        mode = "REFLECT" if mode_byte == 0 else "SYMMETRIC"
        if mode == "SYMMETRIC":
            raise tvm.error.OpAttributeUnImplemented(
                "MIRROR_PAD with SYMMETRIC mode is not yet supported."
            )
        # Flatten tuple-of-tuples to a list for relax.op.nn.pad
        flat_pads = [int(v) for pair in paddings for v in pair]
        out = relax.op.nn.pad(in_expr, flat_pads, pad_mode="reflect")

        return out

    def convert_pack(self, op):
        """Convert TFLite pack"""

        from tflite.BuiltinOptions import BuiltinOptions
        from tflite.PackOptions import PackOptions

        input_tensors = self.get_input_tensors(op)
        output_tensors = self.get_output_tensors(op)
        assert len(output_tensors) == 1, "output tensors length should be 1"

        if input_tensors[0].qnn_params:
            output_tensor = output_tensors[0]
            assert self.has_same_qnn_params(input_tensors[0], output_tensor), (
                "TFLite pack requires input and output scale and zero points to be equal"
            )

            for input_tensor in input_tensors:
                assert self.has_same_qnn_params(input_tensors[0], input_tensor), (
                    "TFLite pack requires all input tensors to have same scale and zero point"
                )

        assert op.BuiltinOptionsType() == BuiltinOptions.PackOptions
        op_options = op.BuiltinOptions()
        pack_options = PackOptions()
        pack_options.Init(op_options.Bytes, op_options.Pos)
        pack_axis = pack_options.Axis()
        pack_values_count = pack_options.ValuesCount()
        assert len(input_tensors) == pack_values_count, "Discordance in input values count"

        in_exprs = [self.get_tensor_expr(_) for _ in input_tensors]
        in_exprs_reshaped = [_op.expand_dims(_, axis=pack_axis) for _ in in_exprs]
        out = relax.op.concat(in_exprs_reshaped, pack_axis)
        return out

    def convert_unpack(self, op):
        """Convert TFLite unpack"""

        from tflite.BuiltinOptions import BuiltinOptions
        from tflite.UnpackOptions import UnpackOptions

        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 1, "input tensors length should be 1"
        input_tensor = input_tensors[0]
        in_expr = self.get_expr(input_tensor.tensor_idx)
        assert op.BuiltinOptionsType() == BuiltinOptions.UnpackOptions
        op_options = op.BuiltinOptions()
        unpack_options = UnpackOptions()
        unpack_options.Init(op_options.Bytes, op_options.Pos)
        num_unpacks = unpack_options.Num()
        unpack_axis = unpack_options.Axis()

        # Relax doesn't support 'unpack' operator so we use 'split' & 'squeeze' instead.
        # We have to do 'squeeze' along the split axis.
        # Relax expects squeeze_axis to be List.
        squeeze_axis = [unpack_axis]

        # Relax doesn't like TupleWrapper of 1 element so we isolate the case of unpacking
        # a tensor by an axis with len(axis) == 1. For reference see convert_split().
        # Such unpacking will result in the same tensor so we omit 'split' and only squeeze
        # along the axis of dim == 1.
        if num_unpacks == 1:
            squeezed = relax.op.squeeze(in_expr, axis=squeeze_axis)
            if isinstance(squeezed, relax.Tuple):
                squeezed = squeezed[0]
        else:
            splitted = relax.op.split(in_expr, indices_or_sections=num_unpacks, axis=unpack_axis)
            squeezed = relax.Tuple(
                [
                    _op.squeeze(relax.TupleGetItem(splitted, i), axis=squeeze_axis)
                    for i in range(num_unpacks)
                ]
            )

        return squeezed

    def convert_lstm(self, op):
        """Convert TFLite LSTM (single-step).

        Standard LSTM cell with FULL kernel and coupled input-forget gate.
        Peephole, projection, and layer norm are not supported.

        Inputs (24 tensors, many optional):
          [0]  input                      [batch, input_size]
          [1]  input_to_input_weights     (optional, -1 => coupled)
          [2]  input_to_forget_weights    [num_units, input_size]
          [3]  input_to_cell_weights      [num_units, input_size]
          [4]  input_to_output_weights    [num_units, input_size]
          [5]  recurrent_to_input_weights (optional)
          [6]  recurrent_to_forget_weights [num_units, num_units]
          [7]  recurrent_to_cell_weights  [num_units, num_units]
          [8]  recurrent_to_output_weights [num_units, num_units]
          [9-11] cell_to_*_weights        (optional, not supported)
          [12] input_gate_bias            (optional)
          [13] forget_gate_bias           [num_units]
          [14] cell_bias                  [num_units]
          [15] output_gate_bias           [num_units]
          [16-17] projection_weights/bias (optional, not supported)
          [18] output_state               [batch, num_units]
          [19] cell_state                 [batch, num_units]
          [20-23] layer_norm              (optional, not supported)

        Output:
          [0] output  [batch, num_units]

        Cell (coupled input-forget):
          f = sigmoid(x @ W_f.T + h @ R_f.T + b_f)
          i = 1 - f
          g = tanh(x @ W_c.T + h @ R_c.T + b_c)
          o = sigmoid(x @ W_o.T + h @ R_o.T + b_o)
          c_new = f * c_prev + i * g
          h_new = fused_activation(o * tanh(c_new))
        """
        from tflite.BuiltinOptions import BuiltinOptions
        from tflite.LSTMOptions import LSTMOptions

        if self.is_quantized(op):
            raise tvm.error.OpNotImplemented("TFLite quantized LSTM is not supported yet.")

        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 24, (
            f"input tensors length should be 24, got {len(input_tensors)}"
        )

        output_tensors = self.get_output_tensors(op)
        assert len(output_tensors) >= 1, "output tensors length should be at least 1"

        assert op.BuiltinOptionsType() == BuiltinOptions.LSTMOptions
        op_options = op.BuiltinOptions()
        lstm_opts = LSTMOptions()
        lstm_opts.Init(op_options.Bytes, op_options.Pos)

        fused_activation_fn = lstm_opts.FusedActivationFunction()
        cell_clip = lstm_opts.CellClip()
        proj_clip = lstm_opts.ProjClip()

        in_expr = self.get_tensor_expr(input_tensors[0])

        # Only coupled input-forget gate is supported.
        if input_tensors[1].tensor_idx != -1 or input_tensors[5].tensor_idx != -1:
            raise tvm.error.OpNotImplemented("Only coupled input-forget LSTM is supported.")

        # Peephole, projection, and layer norm are not modeled yet.
        if (
            any(t.tensor_idx != -1 for t in input_tensors[9:12])
            or any(t.tensor_idx != -1 for t in input_tensors[16:18])
            or any(t.tensor_idx != -1 for t in input_tensors[20:24])
        ):
            raise tvm.error.OpNotImplemented(
                "Peephole, projection, and layer norm LSTM are not supported yet."
            )

        # Weights.
        w_f = self.get_tensor_expr(input_tensors[2])
        w_c = self.get_tensor_expr(input_tensors[3])
        w_o = self.get_tensor_expr(input_tensors[4])

        r_f = self.get_tensor_expr(input_tensors[6])
        r_c = self.get_tensor_expr(input_tensors[7])
        r_o = self.get_tensor_expr(input_tensors[8])

        # Biases.
        b_f = self.get_tensor_expr(input_tensors[13])
        b_c = self.get_tensor_expr(input_tensors[14])
        b_o = self.get_tensor_expr(input_tensors[15])

        # State inputs.
        h_prev = self.get_tensor_expr(input_tensors[18])
        c_prev = self.get_tensor_expr(input_tensors[19])

        # Coupled input-forget gate.
        f = relax.op.sigmoid(
            relax.op.add(
                relax.op.add(
                    relax.op.matmul(in_expr, relax.op.permute_dims(w_f)),
                    relax.op.matmul(h_prev, relax.op.permute_dims(r_f)),
                ),
                b_f,
            )
        )
        i = relax.op.subtract(
            relax.const(1.0, "float32"),
            f,
        )

        # Cell candidate.
        g = relax.op.tanh(
            relax.op.add(
                relax.op.add(
                    relax.op.matmul(in_expr, relax.op.permute_dims(w_c)),
                    relax.op.matmul(h_prev, relax.op.permute_dims(r_c)),
                ),
                b_c,
            )
        )

        # Output gate.
        o = relax.op.sigmoid(
            relax.op.add(
                relax.op.add(
                    relax.op.matmul(in_expr, relax.op.permute_dims(w_o)),
                    relax.op.matmul(h_prev, relax.op.permute_dims(r_o)),
                ),
                b_o,
            )
        )

        # Cell state update with optional clipping.
        c_new = relax.op.add(
            relax.op.multiply(f, c_prev),
            relax.op.multiply(i, g),
        )
        if cell_clip > 0:
            c_new = relax.op.clip(c_new, -cell_clip, cell_clip)

        # Hidden state.
        # TFLite applies the fused activation to the cell state before the
        # output gate multiply.
        h_new = relax.op.multiply(
            o, self.convert_fused_activation_function(c_new, fused_activation_fn)
        )
        if proj_clip > 0:
            h_new = relax.op.clip(h_new, -proj_clip, proj_clip)

        # Update state tensors in the expression table for subsequent ops.
        self.exp_tab.set_expr(
            get_tensor_name(self.subgraph, input_tensors[18].tensor_idx),
            h_new,
            force_override=True,
        )
        self.exp_tab.set_expr(
            get_tensor_name(self.subgraph, input_tensors[19].tensor_idx),
            c_new,
            force_override=True,
        )

        return h_new

    def convert_svdf(self, op):
        """Convert TFLite SVDF (single-step).

        Structured-Vectorized Bidirectional Filter for keyword spotting.

        Inputs (5 tensors):
          [0] input           [batch, input_size]
          [1] feature_weights [num_filters, input_size]
          [2] time_weights    [num_filters, memory_size]
          [3] bias            [num_filters]           (optional)
          [4] state           [batch, num_filters * memory_size]  (variable)

        Output:
          [0] output  [batch, num_units]

        Computation:
          feat = x @ W_feat.T                              # feature projection
          state_r = reshape(state, [B, F, memory_size])    # ring buffer
          time = sum(state_r * time_weights, axis=-1)      # time filtering
          out = activation(sum(reshape(time, [B, U, rank]), axis=-1) + bias)
        """
        from tflite.BuiltinOptions import BuiltinOptions
        from tflite.SVDFOptions import SVDFOptions

        if self.is_quantized(op):
            raise tvm.error.OpNotImplemented("TFLite quantized SVDF is not supported yet.")

        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 5, (
            f"input tensors length should be 5, got {len(input_tensors)}"
        )

        output_tensors = self.get_output_tensors(op)
        assert len(output_tensors) >= 1, "output tensors length should be at least 1"

        assert op.BuiltinOptionsType() == BuiltinOptions.SVDFOptions
        op_options = op.BuiltinOptions()
        svdf_opts = SVDFOptions()
        svdf_opts.Init(op_options.Bytes, op_options.Pos)

        rank = svdf_opts.Rank()
        fused_activation_fn = svdf_opts.FusedActivationFunction()

        in_expr = self.get_tensor_expr(input_tensors[0])
        feat_weights = self.get_tensor_expr(input_tensors[1])
        time_weights = self.get_tensor_expr(input_tensors[2])

        batch_size = self.get_tensor_shape(input_tensors[0])[0]
        if isinstance(batch_size, np.integer | int):
            batch_size = int(batch_size)
        num_filters = to_int_list(self.get_tensor_shape(input_tensors[1]))[0]
        if num_filters % rank != 0:
            raise tvm.error.OpNotImplemented("SVDF num_filters must be divisible by rank.")
        num_units = num_filters // rank
        memory_size = to_int_list(self.get_tensor_shape(input_tensors[2]))[1]

        # Feature projection: [batch, input_size] @ [input_size, num_filters]
        feat = relax.op.matmul(in_expr, relax.op.permute_dims(feat_weights))

        # Time filtering: reshape state -> weight -> reduce.
        state_expr = self.get_tensor_expr(input_tensors[4])
        state_3d = relax.op.reshape(state_expr, (batch_size, num_filters, memory_size))

        # time_weights: [num_filters, memory_size], broadcast to [1, num_filters, memory_size]
        tw_3d = relax.op.reshape(time_weights, (1, num_filters, memory_size))
        time_weighted = relax.op.multiply(state_3d, tw_3d)
        time_output = relax.op.sum(time_weighted, axis=-1, keepdims=False)
        reduced = relax.op.reshape(time_output, (batch_size, num_units, rank))
        result = relax.op.sum(reduced, axis=-1, keepdims=False)

        # Add bias if present
        if input_tensors[3].tensor_idx != -1:
            bias_expr = self.get_tensor_expr(input_tensors[3])
            result = relax.op.add(result, bias_expr)

        result = self.convert_fused_activation_function(result, fused_activation_fn)

        # Update state tensor in the expression table for subsequent steps.
        # SVDF state is a FIFO ring-buffer: shift left by 1, append new feat.
        feat_3d = relax.op.expand_dims(feat, axis=-1)
        if memory_size > 1:
            shifted_state = relax.op.strided_slice(
                state_3d, axes=[2], begin=[1], end=[int(memory_size)]
            )
            new_state_3d = relax.op.concat([shifted_state, feat_3d], axis=2)
        else:
            new_state_3d = feat_3d
        new_state = relax.op.reshape(new_state_3d, (batch_size, num_filters * memory_size))
        self.exp_tab.set_expr(
            get_tensor_name(self.subgraph, input_tensors[4].tensor_idx),
            new_state,
            force_override=True,
        )

        return result

    def convert_unidirectional_sequence_rnn(self, op):
        """Convert TFLite UNIDIRECTIONAL_SEQUENCE_RNN.

        Inputs (5 tensors):
          [0] input          [batch, time, input_size]  (or [time, batch, input_size] if time_major)
          [1] input_weights  [num_units, input_size]
          [2] recurrent_weights [num_units, num_units]
          [3] bias           [num_units]
          [4] hidden_state   [batch, num_units]  (variable, zero-initialised)

        Output:
          [0] output  [batch, time, num_units]

        Cell equation:
          h_t = fused_activation(x_t @ W.T + h_{t-1} @ Wr.T + b)
        """
        from tflite.BuiltinOptions import BuiltinOptions
        from tflite.SequenceRNNOptions import SequenceRNNOptions

        if self.is_quantized(op):
            raise tvm.error.OpNotImplemented(
                "TFLite quantized UNIDIRECTIONAL_SEQUENCE_RNN is not supported yet."
            )

        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 5, "input tensors length should be 5"

        input_tensor = input_tensors[0]
        weights_tensor = input_tensors[1]
        recurrent_tensor = input_tensors[2]
        bias_tensor = input_tensors[3]
        hidden_state_tensor = input_tensors[4]

        output_tensors = self.get_output_tensors(op)
        assert len(output_tensors) >= 1, "output tensors length should be at least 1"

        assert op.BuiltinOptionsType() == BuiltinOptions.SequenceRNNOptions
        op_options = op.BuiltinOptions()
        seq_rnn_options = SequenceRNNOptions()
        seq_rnn_options.Init(op_options.Bytes, op_options.Pos)
        time_major = seq_rnn_options.TimeMajor()
        fused_activation_fn = seq_rnn_options.FusedActivationFunction()

        # Constant weight/bias expressions.
        weights_expr = self.get_tensor_expr(weights_tensor)  # [num_units, input_size]
        recurrent_expr = self.get_tensor_expr(recurrent_tensor)  # [num_units, num_units]

        # bias is optional (tensor_idx == -1 when absent); default to zeros.
        if bias_tensor.tensor_idx != -1:
            bias_expr = self.get_tensor_expr(bias_tensor)  # [num_units]
        else:
            num_units = int(self.get_tensor_shape(weights_tensor)[0])
            bias_dtype = self.get_tensor_type_str(weights_tensor.tensor.Type())
            bias_expr = relax.op.zeros((num_units,), dtype=bias_dtype)

        # Transpose to [input_size, num_units] and [num_units, num_units] for x @ W.T.
        w_t = relax.op.permute_dims(weights_expr)
        wr_t = relax.op.permute_dims(recurrent_expr)

        # Resolve the input expression; normalise to batch-major [batch, time, input_size].
        # Only the time dimension must be static (needed for unrolling); batch may be dynamic.
        in_expr = self.get_tensor_expr(input_tensor)
        in_shape = self.get_tensor_shape(input_tensor)
        if time_major:
            in_expr = relax.op.permute_dims(in_expr, [1, 0, 2])
            num_steps = int(in_shape[0])
        else:
            num_steps = int(in_shape[1])

        # Initial hidden state: use the model's tensor value when available (non-zero init or
        # graph input), otherwise fall back to zeros for the common variable-tensor case.
        h_dtype = self.get_tensor_type_str(hidden_state_tensor.tensor.Type())
        if self.has_expr(hidden_state_tensor.tensor_idx) or (
            hidden_state_tensor.buffer is not None and hidden_state_tensor.buffer.DataLength() > 0
        ):
            h = self.get_tensor_expr(hidden_state_tensor)
        else:
            h_shape = tuple(to_int_list(self.get_tensor_shape(hidden_state_tensor)))
            h = relax.op.zeros(h_shape, dtype=h_dtype)

        # Unroll over the time axis.
        # relax.op.split with 1 section returns the tensor directly; handle uniformly.
        if num_steps == 1:
            steps = [relax.op.squeeze(in_expr, axis=[1])]
        else:
            splits = relax.op.split(in_expr, num_steps, axis=1)
            steps = [relax.op.squeeze(splits[i], axis=[1]) for i in range(num_steps)]

        outputs = []
        for x_t in steps:  # x_t: [batch, input_size]
            gates = relax.op.add(
                relax.op.add(relax.op.matmul(x_t, w_t), relax.op.matmul(h, wr_t)),
                bias_expr,
            )
            h = self.convert_fused_activation_function(gates, fused_activation_fn)
            outputs.append(h)

        # Stack timestep outputs: [batch, time, num_units].
        return relax.op.stack(outputs, axis=1)

    def convert_unidirectional_sequence_lstm(self, op):
        """Convert TFLite UNIDIRECTIONAL_SEQUENCE_LSTM.

        Inputs (24 tensors, same layout as single-step LSTM):
          [0]  input                       [batch, time, input_size]
          [1]  input_to_input_weights      [num_units, input_size]   (optional)
          [2]  input_to_forget_weights     [num_units, input_size]
          [3]  input_to_cell_weights       [num_units, input_size]
          [4]  input_to_output_weights     [num_units, input_size]
          [5]  recurrent_to_input_weights  [num_units, num_units]   (optional)
          [6]  recurrent_to_forget_weights [num_units, num_units]
          [7]  recurrent_to_cell_weights   [num_units, num_units]
          [8]  recurrent_to_output_weights [num_units, num_units]
          [9]  cell_to_input_weights       [num_units]              (optional)
          [10] cell_to_forget_weights      [num_units]              (optional)
          [11] cell_to_output_weights      [num_units]              (optional)
          [12] input_gate_bias             [num_units]              (optional)
          [13] forget_gate_bias            [num_units]
          [14] cell_gate_bias              [num_units]
          [15] output_gate_bias            [num_units]
          [16] projection_weights          [num_units, num_units]   (optional)
          [17] projection_bias             [num_units]              (optional)
          [18] output_state                [batch, num_units]       (variable)
          [19] cell_state                  [batch, num_units]       (variable)
          [20-23] optional layer norm weights

        Output:
          [0] output  [batch, time, num_units]

        Uses coupled input-forget gate (i = 1 - f) for the FULL kernel.
        """
        from tflite.BuiltinOptions import BuiltinOptions
        from tflite.UnidirectionalSequenceLSTMOptions import UnidirectionalSequenceLSTMOptions

        if self.is_quantized(op):
            raise tvm.error.OpNotImplemented(
                "TFLite quantized UNIDIRECTIONAL_SEQUENCE_LSTM is not supported yet."
            )

        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 24, (
            f"input tensors length should be 24, got {len(input_tensors)}"
        )

        output_tensors = self.get_output_tensors(op)
        assert len(output_tensors) >= 1, "output tensors length should be at least 1"

        assert op.BuiltinOptionsType() == BuiltinOptions.UnidirectionalSequenceLSTMOptions
        op_options = op.BuiltinOptions()
        lstm_opts = UnidirectionalSequenceLSTMOptions()
        lstm_opts.Init(op_options.Bytes, op_options.Pos)
        time_major = lstm_opts.TimeMajor()
        fused_activation_fn = lstm_opts.FusedActivationFunction()
        cell_clip = lstm_opts.CellClip()
        proj_clip = lstm_opts.ProjClip()

        # Only coupled input-forget gate is supported.
        if input_tensors[1].tensor_idx != -1 or input_tensors[5].tensor_idx != -1:
            raise tvm.error.OpNotImplemented("Only coupled input-forget LSTM is supported.")
        if any(input_tensors[idx].tensor_idx != -1 for idx in [9, 10, 11]):
            raise tvm.error.OpNotImplemented("TFLite peephole LSTM is not supported yet.")
        if any(input_tensors[idx].tensor_idx != -1 for idx in [16, 17]):
            raise tvm.error.OpNotImplemented("TFLite projection LSTM is not supported yet.")
        if any(input_tensors[idx].tensor_idx != -1 for idx in [20, 21, 22, 23]):
            raise tvm.error.OpNotImplemented("TFLite layer-norm LSTM is not supported yet.")

        # Weights (transposed once outside the loop).
        w_f_t = relax.op.permute_dims(self.get_tensor_expr(input_tensors[2]))
        w_c_t = relax.op.permute_dims(self.get_tensor_expr(input_tensors[3]))
        w_o_t = relax.op.permute_dims(self.get_tensor_expr(input_tensors[4]))
        r_f_t = relax.op.permute_dims(self.get_tensor_expr(input_tensors[6]))
        r_c_t = relax.op.permute_dims(self.get_tensor_expr(input_tensors[7]))
        r_o_t = relax.op.permute_dims(self.get_tensor_expr(input_tensors[8]))

        # Biases.
        b_f = self.get_tensor_expr(input_tensors[13])
        b_c = self.get_tensor_expr(input_tensors[14])
        b_o = self.get_tensor_expr(input_tensors[15])

        # Initial states.
        h = self.get_tensor_expr(input_tensors[18])
        c = self.get_tensor_expr(input_tensors[19])

        # Resolve the input expression; normalise to batch-major [batch, time, input_size].
        in_expr = self.get_tensor_expr(input_tensors[0])
        in_shape = self.get_tensor_shape(input_tensors[0])
        if time_major:
            in_expr = relax.op.permute_dims(in_expr, [1, 0, 2])
            num_steps = int(in_shape[0])
        else:
            num_steps = int(in_shape[1])

        # Unroll over the time axis.
        if num_steps == 1:
            steps = [relax.op.squeeze(in_expr, axis=[1])]
        else:
            splits = relax.op.split(in_expr, num_steps, axis=1)
            steps = [relax.op.squeeze(splits[i], axis=[1]) for i in range(num_steps)]

        one = relax.const(1.0, "float32")
        outputs = []
        for x_t in steps:
            f = relax.op.sigmoid(
                relax.op.add(
                    relax.op.add(
                        relax.op.matmul(x_t, w_f_t),
                        relax.op.matmul(h, r_f_t),
                    ),
                    b_f,
                )
            )
            i = relax.op.subtract(one, f)
            g = self.convert_fused_activation_function(
                relax.op.add(
                    relax.op.add(relax.op.matmul(x_t, w_c_t), relax.op.matmul(h, r_c_t)),
                    b_c,
                ),
                fused_activation_fn,
            )
            o = relax.op.sigmoid(
                relax.op.add(
                    relax.op.add(
                        relax.op.matmul(x_t, w_o_t),
                        relax.op.matmul(h, r_o_t),
                    ),
                    b_o,
                )
            )

            c_new = relax.op.add(relax.op.multiply(f, c), relax.op.multiply(i, g))
            if cell_clip > 0.0:
                c_new = relax.op.clip(c_new, -cell_clip, cell_clip)

            h_new = relax.op.multiply(
                o, self.convert_fused_activation_function(c_new, fused_activation_fn)
            )
            if proj_clip > 0.0:
                h_new = relax.op.clip(h_new, -proj_clip, proj_clip)
            outputs.append(h_new)
            h, c = h_new, c_new

        h_out = relax.op.stack(outputs, axis=1)
        if time_major:
            h_out = relax.op.permute_dims(h_out, [1, 0, 2])

        # Update state tensors in the expression table for subsequent ops.
        self.exp_tab.set_expr(
            get_tensor_name(self.subgraph, input_tensors[18].tensor_idx),
            h,
            force_override=True,
        )
        self.exp_tab.set_expr(
            get_tensor_name(self.subgraph, input_tensors[19].tensor_idx),
            c,
            force_override=True,
        )

        return h_out

    def convert_bidirectional_sequence_rnn(self, op):
        """Convert TFLite BIDIRECTIONAL_SEQUENCE_RNN.

        Inputs (9 tensors, aux_input not supported):
          [0] input                [batch, time, input_size]
          [1] fw_weights           [num_units, input_size]
          [2] fw_recurrent_weights [num_units, num_units]
          [3] fw_bias              [num_units]
          [4] fw_hidden_state      [batch, num_units]         (variable)
          [5] bw_weights           [num_units, input_size]
          [6] bw_recurrent_weights [num_units, num_units]
          [7] bw_bias              [num_units]
          [8] bw_hidden_state      [batch, num_units]         (variable)

        Output (merge_outputs=True):
          [0] output  [batch, time, 2 * num_units]  (fw and bw concatenated)

        Output (merge_outputs=False):
          [0] fw_output  [batch, time, num_units]
          [1] bw_output  [batch, time, num_units]
        """
        from tflite.BidirectionalSequenceRNNOptions import BidirectionalSequenceRNNOptions
        from tflite.BuiltinOptions import BuiltinOptions

        if self.is_quantized(op):
            raise tvm.error.OpNotImplemented(
                "TFLite quantized BIDIRECTIONAL_SEQUENCE_RNN is not supported yet."
            )

        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 12, (
            f"input tensors length should be 12, got {len(input_tensors)}"
        )

        output_tensors = self.get_output_tensors(op)
        assert len(output_tensors) >= 1, "output tensors length should be at least 1"

        assert op.BuiltinOptionsType() == BuiltinOptions.BidirectionalSequenceRNNOptions
        op_options = op.BuiltinOptions()
        rnn_opts = BidirectionalSequenceRNNOptions()
        rnn_opts.Init(op_options.Bytes, op_options.Pos)
        time_major = rnn_opts.TimeMajor()
        fused_activation_fn = rnn_opts.FusedActivationFunction()
        merge_outputs = rnn_opts.MergeOutputs()
        if any(input_tensors[idx].tensor_idx != -1 for idx in [9, 10, 11]):
            raise tvm.error.OpNotImplemented(
                "TFLite BIDIRECTIONAL_SEQUENCE_RNN aux input is not supported yet."
            )

        # Forward weights and biases.
        fw_weights_expr = self.get_tensor_expr(input_tensors[1])
        fw_recurrent_expr = self.get_tensor_expr(input_tensors[2])
        fw_bias_expr = self.get_tensor_expr(input_tensors[3])
        fw_w_t = relax.op.permute_dims(fw_weights_expr)
        fw_wr_t = relax.op.permute_dims(fw_recurrent_expr)

        # Backward weights and biases.
        bw_weights_expr = self.get_tensor_expr(input_tensors[5])
        bw_recurrent_expr = self.get_tensor_expr(input_tensors[6])
        bw_bias_expr = self.get_tensor_expr(input_tensors[7])
        bw_w_t = relax.op.permute_dims(bw_weights_expr)
        bw_wr_t = relax.op.permute_dims(bw_recurrent_expr)

        # Resolve the input expression; normalise to batch-major [batch, time, input_size].
        in_expr = self.get_tensor_expr(input_tensors[0])
        in_shape = self.get_tensor_shape(input_tensors[0])
        if time_major:
            in_expr = relax.op.permute_dims(in_expr, [1, 0, 2])
            num_steps = int(in_shape[0])
        else:
            num_steps = int(in_shape[1])

        # Initial hidden states.
        def _get_hidden_state(tensor):
            if self.has_expr(tensor.tensor_idx) or (
                tensor.buffer is not None and tensor.buffer.DataLength() > 0
            ):
                return self.get_tensor_expr(tensor)
            dtype = self.get_tensor_type_str(tensor.tensor.Type())
            h_shape = tuple(to_int_list(self.get_tensor_shape(tensor)))
            return relax.op.zeros(h_shape, dtype=dtype)

        fw_h = _get_hidden_state(input_tensors[4])
        bw_h = _get_hidden_state(input_tensors[8])

        # Unroll over the time axis.
        if num_steps == 1:
            steps = [relax.op.squeeze(in_expr, axis=[1])]
        else:
            splits = relax.op.split(in_expr, num_steps, axis=1)
            steps = [relax.op.squeeze(splits[i], axis=[1]) for i in range(num_steps)]

        # Forward pass.
        fw_outputs = []
        for x_t in steps:
            gates = relax.op.add(
                relax.op.add(relax.op.matmul(x_t, fw_w_t), relax.op.matmul(fw_h, fw_wr_t)),
                fw_bias_expr,
            )
            fw_h = self.convert_fused_activation_function(gates, fused_activation_fn)
            fw_outputs.append(fw_h)

        # Backward pass (process steps in reverse).
        bw_outputs = []
        for x_t in reversed(steps):
            gates = relax.op.add(
                relax.op.add(relax.op.matmul(x_t, bw_w_t), relax.op.matmul(bw_h, bw_wr_t)),
                bw_bias_expr,
            )
            bw_h = self.convert_fused_activation_function(gates, fused_activation_fn)
            bw_outputs.append(bw_h)
        bw_outputs.reverse()

        fw_stacked = relax.op.stack(fw_outputs, axis=1)  # [batch, time, num_units]
        bw_stacked = relax.op.stack(bw_outputs, axis=1)  # [batch, time, num_units]
        if time_major:
            fw_stacked = relax.op.permute_dims(fw_stacked, [1, 0, 2])
            bw_stacked = relax.op.permute_dims(bw_stacked, [1, 0, 2])

        # Update state tensors in the expression table for subsequent ops.
        self.exp_tab.set_expr(
            get_tensor_name(self.subgraph, input_tensors[4].tensor_idx),
            fw_h,
            force_override=True,
        )
        self.exp_tab.set_expr(
            get_tensor_name(self.subgraph, input_tensors[8].tensor_idx),
            bw_h,
            force_override=True,
        )

        if merge_outputs:
            return relax.op.concat([fw_stacked, bw_stacked], axis=-1)
        else:
            return relax.Tuple([fw_stacked, bw_stacked])

    def convert_bidirectional_sequence_lstm(self, op):
        """Convert TFLite BIDIRECTIONAL_SEQUENCE_LSTM.

        Inputs (48 tensors, indices 0-17 forward LSTM, 18-34 backward LSTM, 35-38 states,
        39-47 optional aux inputs, which are not supported):

        Forward LSTM cell (indices 0-17, same layout as single-step LSTM):
          [0]  input (shared)              [batch, time, input_size]
          [1]  fw_input_to_input_weights   (optional)
          [2]  fw_input_to_forget_weights
          [3]  fw_input_to_cell_weights
          [4]  fw_input_to_output_weights
          [5]  fw_recurrent_to_input_wts   (optional)
          [6]  fw_recurrent_to_forget_wts
          [7]  fw_recurrent_to_cell_wts
          [8]  fw_recurrent_to_output_wts
          [9-11] fw cell_to_*_weights      (optional, not supported)
          [12] fw_input_gate_bias          (optional)
          [13] fw_forget_gate_bias
          [14] fw_cell_gate_bias
          [15] fw_output_gate_bias
          [16] fw_projection_weights       (optional, not supported)
          [17] fw_projection_bias          (optional, not supported)

        Backward LSTM cell (indices 18-34, same layout as fw):
          [19] bw_input_to_forget_weights
          [20] bw_input_to_cell_weights
          [21] bw_input_to_output_weights
          [23] bw_recurrent_to_forget_wts
          [24] bw_recurrent_to_cell_wts
          [25] bw_recurrent_to_output_wts
          [30] bw_forget_gate_bias
          [31] bw_cell_gate_bias
          [32] bw_output_gate_bias

        State tensors:
          [35] fw_activation_state  [batch, num_units]
          [36] fw_cell_state        [batch, num_units]
          [37] bw_activation_state  [batch, num_units]
          [38] bw_cell_state        [batch, num_units]

        Output (merge_outputs=True):
          [0] output  [batch, time, 2 * num_units]

        Output (merge_outputs=False):
          [0] fw_output  [batch, time, num_units]
          [1] bw_output  [batch, time, num_units]
        """
        from tflite.BidirectionalSequenceLSTMOptions import BidirectionalSequenceLSTMOptions
        from tflite.BuiltinOptions import BuiltinOptions

        if self.is_quantized(op):
            raise tvm.error.OpNotImplemented(
                "TFLite quantized BIDIRECTIONAL_SEQUENCE_LSTM is not supported yet."
            )

        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 48, (
            f"input tensors length should be 48, got {len(input_tensors)}"
        )

        output_tensors = self.get_output_tensors(op)
        assert len(output_tensors) >= 1, "output tensors length should be at least 1"

        assert op.BuiltinOptionsType() == BuiltinOptions.BidirectionalSequenceLSTMOptions
        op_options = op.BuiltinOptions()
        lstm_opts = BidirectionalSequenceLSTMOptions()
        lstm_opts.Init(op_options.Bytes, op_options.Pos)
        time_major = lstm_opts.TimeMajor()
        fused_activation_fn = lstm_opts.FusedActivationFunction()
        merge_outputs = lstm_opts.MergeOutputs()
        cell_clip = lstm_opts.CellClip()
        proj_clip = lstm_opts.ProjClip()

        # ── Forward LSTM weights (transposed once outside the loop) ──
        if input_tensors[1].tensor_idx != -1 or input_tensors[5].tensor_idx != -1:
            raise tvm.error.OpNotImplemented("Only coupled input-forget LSTM is supported.")
        if any(input_tensors[idx].tensor_idx != -1 for idx in [9, 10, 11]):
            raise tvm.error.OpNotImplemented("TFLite peephole LSTM is not supported yet.")
        if any(input_tensors[idx].tensor_idx != -1 for idx in [16, 17]):
            raise tvm.error.OpNotImplemented("TFLite projection LSTM is not supported yet.")

        fw_w_f_t = relax.op.permute_dims(self.get_tensor_expr(input_tensors[2]))
        fw_w_c_t = relax.op.permute_dims(self.get_tensor_expr(input_tensors[3]))
        fw_w_o_t = relax.op.permute_dims(self.get_tensor_expr(input_tensors[4]))
        fw_r_f_t = relax.op.permute_dims(self.get_tensor_expr(input_tensors[6]))
        fw_r_c_t = relax.op.permute_dims(self.get_tensor_expr(input_tensors[7]))
        fw_r_o_t = relax.op.permute_dims(self.get_tensor_expr(input_tensors[8]))
        fw_b_f = self.get_tensor_expr(input_tensors[13])
        fw_b_c = self.get_tensor_expr(input_tensors[14])
        fw_b_o = self.get_tensor_expr(input_tensors[15])

        # ── Backward LSTM weights (transposed once outside the loop) ──
        if input_tensors[18].tensor_idx != -1 or input_tensors[22].tensor_idx != -1:
            raise tvm.error.OpNotImplemented("Only coupled input-forget LSTM is supported.")
        if any(input_tensors[idx].tensor_idx != -1 for idx in [26, 27, 28]):
            raise tvm.error.OpNotImplemented("TFLite peephole LSTM is not supported yet.")
        if any(input_tensors[idx].tensor_idx != -1 for idx in [33, 34]):
            raise tvm.error.OpNotImplemented("TFLite projection LSTM is not supported yet.")
        if any(input_tensors[idx].tensor_idx != -1 for idx in range(39, 48)):
            raise tvm.error.OpNotImplemented(
                "TFLite BIDIRECTIONAL_SEQUENCE_LSTM aux input is not supported yet."
            )

        bw_w_f_t = relax.op.permute_dims(self.get_tensor_expr(input_tensors[19]))
        bw_w_c_t = relax.op.permute_dims(self.get_tensor_expr(input_tensors[20]))
        bw_w_o_t = relax.op.permute_dims(self.get_tensor_expr(input_tensors[21]))
        bw_r_f_t = relax.op.permute_dims(self.get_tensor_expr(input_tensors[23]))
        bw_r_c_t = relax.op.permute_dims(self.get_tensor_expr(input_tensors[24]))
        bw_r_o_t = relax.op.permute_dims(self.get_tensor_expr(input_tensors[25]))
        bw_b_f = self.get_tensor_expr(input_tensors[30])
        bw_b_c = self.get_tensor_expr(input_tensors[31])
        bw_b_o = self.get_tensor_expr(input_tensors[32])

        # ── Initial states ──
        fw_h = self.get_tensor_expr(input_tensors[35])
        fw_c = self.get_tensor_expr(input_tensors[36])
        bw_h = self.get_tensor_expr(input_tensors[37])
        bw_c = self.get_tensor_expr(input_tensors[38])

        # ── Unroll input ──
        in_expr = self.get_tensor_expr(input_tensors[0])
        in_shape = self.get_tensor_shape(input_tensors[0])
        if time_major:
            in_expr = relax.op.permute_dims(in_expr, [1, 0, 2])
            num_steps = int(in_shape[0])
        else:
            num_steps = int(in_shape[1])

        if num_steps == 1:
            steps = [relax.op.squeeze(in_expr, axis=[1])]
        else:
            splits = relax.op.split(in_expr, num_steps, axis=1)
            steps = [relax.op.squeeze(splits[i], axis=[1]) for i in range(num_steps)]

        one = relax.const(1.0, "float32")

        def _lstm_step(x_t, h, c, w_f_t, w_c_t, w_o_t, r_f_t, r_c_t, r_o_t, b_f, b_c, b_o):
            """Single LSTM step with coupled input-forget gate."""
            f = relax.op.sigmoid(
                relax.op.add(
                    relax.op.add(
                        relax.op.matmul(x_t, w_f_t),
                        relax.op.matmul(h, r_f_t),
                    ),
                    b_f,
                )
            )
            i = relax.op.subtract(one, f)
            g = self.convert_fused_activation_function(
                relax.op.add(
                    relax.op.add(relax.op.matmul(x_t, w_c_t), relax.op.matmul(h, r_c_t)),
                    b_c,
                ),
                fused_activation_fn,
            )
            o = relax.op.sigmoid(
                relax.op.add(
                    relax.op.add(
                        relax.op.matmul(x_t, w_o_t),
                        relax.op.matmul(h, r_o_t),
                    ),
                    b_o,
                )
            )
            c_new = relax.op.add(relax.op.multiply(f, c), relax.op.multiply(i, g))
            if cell_clip > 0.0:
                c_new = relax.op.clip(c_new, -cell_clip, cell_clip)
            h_new = relax.op.multiply(
                o, self.convert_fused_activation_function(c_new, fused_activation_fn)
            )
            if proj_clip > 0.0:
                h_new = relax.op.clip(h_new, -proj_clip, proj_clip)
            return h_new, c_new

        # ── Forward pass ──
        fw_outputs = []
        for x_t in steps:
            fw_h, fw_c = _lstm_step(
                x_t,
                fw_h,
                fw_c,
                fw_w_f_t,
                fw_w_c_t,
                fw_w_o_t,
                fw_r_f_t,
                fw_r_c_t,
                fw_r_o_t,
                fw_b_f,
                fw_b_c,
                fw_b_o,
            )
            fw_outputs.append(fw_h)

        # ── Backward pass ──
        bw_outputs = []
        for x_t in reversed(steps):
            bw_h, bw_c = _lstm_step(
                x_t,
                bw_h,
                bw_c,
                bw_w_f_t,
                bw_w_c_t,
                bw_w_o_t,
                bw_r_f_t,
                bw_r_c_t,
                bw_r_o_t,
                bw_b_f,
                bw_b_c,
                bw_b_o,
            )
            bw_outputs.append(bw_h)
        bw_outputs.reverse()

        fw_stacked = relax.op.stack(fw_outputs, axis=1)
        bw_stacked = relax.op.stack(bw_outputs, axis=1)
        if time_major:
            fw_stacked = relax.op.permute_dims(fw_stacked, [1, 0, 2])
            bw_stacked = relax.op.permute_dims(bw_stacked, [1, 0, 2])

        # Update state tensors in the expression table for subsequent ops.
        self.exp_tab.set_expr(
            get_tensor_name(self.subgraph, input_tensors[35].tensor_idx),
            fw_h,
            force_override=True,
        )
        self.exp_tab.set_expr(
            get_tensor_name(self.subgraph, input_tensors[36].tensor_idx),
            fw_c,
            force_override=True,
        )
        self.exp_tab.set_expr(
            get_tensor_name(self.subgraph, input_tensors[37].tensor_idx),
            bw_h,
            force_override=True,
        )
        self.exp_tab.set_expr(
            get_tensor_name(self.subgraph, input_tensors[38].tensor_idx),
            bw_c,
            force_override=True,
        )

        if merge_outputs:
            return relax.op.concat([fw_stacked, bw_stacked], axis=-1)
        else:
            return relax.Tuple([fw_stacked, bw_stacked])

    def convert_batch_to_space_nd(self, op):
        """batch_to_space_nd implementation."""

        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 3, "input tensors length should be 3"

        input_tensor = input_tensors[0]
        input_tensor_idx = input_tensor.tensor_idx
        in_expr = self.get_expr(input_tensor_idx)

        block_shape = to_int_list(self.get_tensor_value(input_tensors[1]))
        crops = self.get_tensor_value(input_tensors[2])
        crop_begin = to_int_list(crops[:, 0])
        crop_end = to_int_list(crops[:, 1])

        output_tensors = self.get_output_tensors(op)
        assert len(output_tensors) == 1, "output tensors length should be 1"
        output_tensor = output_tensors[0]
        output_shape = to_int_list(self.get_tensor_shape(output_tensor))
        output_dtype = self.get_tensor_type_str(output_tensor.tensor.Type())

        out = relax.op.call_dps_packed(
            "topi.nn.batch_to_space_nd",
            (
                in_expr,
                relax.ShapeExpr(block_shape),
                relax.ShapeExpr(crop_begin),
                relax.ShapeExpr(crop_end),
            ),
            out_ty=relax.TensorType(output_shape, output_dtype),
        )

        return out

    def convert_broadcast_to(self, op):
        """Convert TFLite BROADCAST_TO"""
        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 2, "input tensors length should be 2"
        data = self.get_tensor_expr(input_tensors[0])
        shape_tensor = input_tensors[1]
        if self.has_expr(shape_tensor.tensor_idx):
            shape_expr = self.get_expr(shape_tensor.tensor_idx)
            shape = self.bb.emit_output(relax.op.tensor_to_shape(shape_expr))
        else:
            shape = to_int_list(self.get_tensor_value(shape_tensor))
        return relax.op.broadcast_to(data, shape)

    def convert_embedding_lookup(self, op):
        """Convert TFLite EMBEDDING_LOOKUP"""
        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 2, "input tensors length should be 2"
        params = self.get_tensor_expr(input_tensors[0])
        indices_tensor = input_tensors[1]
        if self.has_expr(indices_tensor.tensor_idx):
            indices = relax.op.astype(self.get_expr(indices_tensor.tensor_idx), "int32")
        else:
            indices = self.get_tensor_expr(indices_tensor)
        return relax.op.take(params, indices, axis=0)

    def convert_embedding_lookup_sparse(self, op):
        """Convert TFLite EMBEDDING_LOOKUP_SPARSE."""
        from tflite.CombinerType import CombinerType
        from tflite.EmbeddingLookupSparseOptions import EmbeddingLookupSparseOptions
        from tflite.TensorType import TensorType

        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 5, "EMBEDDING_LOOKUP_SPARSE should have 5 input tensors"
        output_tensors = self.get_output_tensors(op)
        assert len(output_tensors) == 1, "EMBEDDING_LOOKUP_SPARSE should have 1 output tensor"

        ids_tensor, indices_tensor, dense_shape_tensor, weights_tensor, params_tensor = (
            input_tensors
        )
        output_tensor = output_tensors[0]

        for tensor in input_tensors:
            assert not tensor.qnn_params, "Quantized input is not expected."

        assert ids_tensor.tensor.Type() == TensorType.INT32
        assert indices_tensor.tensor.Type() == TensorType.INT32
        assert dense_shape_tensor.tensor.Type() == TensorType.INT32
        assert weights_tensor.tensor.Type() == TensorType.FLOAT32
        assert params_tensor.tensor.Type() == TensorType.FLOAT32
        assert output_tensor.tensor.Type() == TensorType.FLOAT32

        ids_shape = to_int_list(self.get_tensor_shape(ids_tensor))
        indices_shape = to_int_list(self.get_tensor_shape(indices_tensor))
        dense_shape_shape = to_int_list(self.get_tensor_shape(dense_shape_tensor))
        weights_shape = to_int_list(self.get_tensor_shape(weights_tensor))
        params_shape = to_int_list(self.get_tensor_shape(params_tensor))

        assert len(ids_shape) == 1, "EMBEDDING_LOOKUP_SPARSE ids must be rank 1"
        assert len(indices_shape) == 2, "EMBEDDING_LOOKUP_SPARSE indices must be rank 2"
        assert len(dense_shape_shape) == 1, "EMBEDDING_LOOKUP_SPARSE dense_shape must be rank 1"
        assert len(weights_shape) == 1, "EMBEDDING_LOOKUP_SPARSE weights must be rank 1"
        assert len(params_shape) >= 2, "EMBEDDING_LOOKUP_SPARSE params must be rank >= 2"
        assert indices_shape[0] == ids_shape[0], (
            "EMBEDDING_LOOKUP_SPARSE ids and indices must agree on lookup count"
        )
        assert weights_shape[0] == ids_shape[0], (
            "EMBEDDING_LOOKUP_SPARSE ids and weights must agree on lookup count"
        )

        if self.has_expr(dense_shape_tensor.tensor_idx):
            raise tvm.error.OpNotImplemented(
                "TFLite EMBEDDING_LOOKUP_SPARSE with runtime dense_shape is not supported."
            )

        dense_shape = to_int_list(self.get_tensor_value(dense_shape_tensor))
        lookup_rank = indices_shape[1]
        assert len(dense_shape) == lookup_rank, (
            "EMBEDDING_LOOKUP_SPARSE dense_shape length must match indices width"
        )
        assert lookup_rank >= 1, "EMBEDDING_LOOKUP_SPARSE indices width must be positive"
        if not self.has_expr(ids_tensor.tensor_idx):
            ids_value = self.get_tensor_value(ids_tensor)
            if np.any(ids_value < 0):
                raise tvm.error.OpNotImplemented(
                    "TFLite EMBEDDING_LOOKUP_SPARSE with negative ids is not supported."
                )

        params = self.get_tensor_expr(params_tensor)
        ids = self.get_tensor_expr(ids_tensor)
        weights = self.get_tensor_expr(weights_tensor)
        indices = self.get_tensor_expr(indices_tensor)

        ids = relax.op.astype(ids, "int32")
        lookup = relax.op.take(params, ids, axis=0)

        embedding_tail_shape = params_shape[1:]
        output_prefix_shape = dense_shape[:-1]
        output_shape = output_prefix_shape + embedding_tail_shape

        # Aggregation buckets are defined by every sparse index dimension except the last one.
        bucket_indices = relax.op.strided_slice(indices, axes=[1], begin=[0], end=[lookup_rank - 1])

        weight_expand_shape = [ids_shape[0]] + [1] * len(embedding_tail_shape)
        weighted_lookup = relax.op.multiply(lookup, relax.op.reshape(weights, weight_expand_shape))

        value_base = relax.const(np.zeros(output_shape, dtype=np.float32), "float32")
        summed_lookup = relax.op.scatter_nd(value_base, bucket_indices, weighted_lookup, "add")

        op_options = op.BuiltinOptions()
        sparse_options = EmbeddingLookupSparseOptions()
        sparse_options.Init(op_options.Bytes, op_options.Pos)
        combiner = sparse_options.Combiner()
        if combiner == CombinerType.SUM:
            return summed_lookup

        count_shape = output_prefix_shape
        count_base = relax.const(np.zeros(count_shape, dtype=np.float32), "float32")
        bucket_count_updates = relax.const(np.ones(ids_shape, dtype=np.float32), "float32")
        bucket_counts = relax.op.scatter_nd(count_base, bucket_indices, bucket_count_updates, "add")
        if combiner == CombinerType.MEAN:
            denominator_updates = weights
        elif combiner == CombinerType.SQRTN:
            denominator_updates = relax.op.multiply(weights, weights)
        else:
            raise tvm.error.OpNotImplemented(
                f"Unsupported TFLite EMBEDDING_LOOKUP_SPARSE combiner value {combiner}"
            )

        denominator = relax.op.scatter_nd(count_base, bucket_indices, denominator_updates, "add")
        if combiner == CombinerType.SQRTN:
            denominator = relax.op.sqrt(denominator)

        broadcast_shape = count_shape + [1] * len(embedding_tail_shape)
        denominator = relax.op.reshape(denominator, broadcast_shape)
        denominator = relax.op.broadcast_to(denominator, output_shape)
        normalized = relax.op.divide(summed_lookup, denominator)
        bucket_counts = relax.op.reshape(bucket_counts, broadcast_shape)
        bucket_counts = relax.op.broadcast_to(bucket_counts, output_shape)
        return relax.op.where(
            relax.op.greater(bucket_counts, relax.const(0.0, "float32")), normalized, value_base
        )

    def convert_batch_matmul(self, op):
        """batch_matmul implementation."""

        from tflite.BatchMatMulOptions import BatchMatMulOptions

        input_tensors = self.get_input_tensors(op)

        assert len(input_tensors) == 2, "two input tensor arguments expected"

        if self.is_quantized(op):
            raise NotImplementedError(
                "Quantized BATCH_MATMUL is not yet supported in the Relax frontend"
            )

        batch_matmul_options = BatchMatMulOptions()
        op_options = op.BuiltinOptions()
        batch_matmul_options.Init(op_options.Bytes, op_options.Pos)

        input_a = self.get_expr(input_tensors[0].tensor_idx)
        input_b = self.get_expr(input_tensors[1].tensor_idx)

        shape_a = list(input_a.ty.shape)
        shape_b = list(input_b.ty.shape)
        rank_a = len(shape_a)
        rank_b = len(shape_b)

        if rank_a > 2 or rank_b > 2:
            # Broadcast batch dimensions
            new_a_shape = [1] * max(0, rank_b - rank_a) + [int(s) for s in shape_a]
            new_b_shape = [1] * max(0, rank_a - rank_b) + [int(s) for s in shape_b]
            max_rank = max(rank_a, rank_b)

            batch_shape = [max(new_a_shape[i], new_b_shape[i]) for i in range(max_rank - 2)]

            a_broadcast = batch_shape + [int(shape_a[-2]), int(shape_a[-1])]
            b_broadcast = batch_shape + [int(shape_b[-2]), int(shape_b[-1])]

            if [int(s) for s in shape_a] != a_broadcast:
                input_a = relax.op.broadcast_to(input_a, a_broadcast)
            if [int(s) for s in shape_b] != b_broadcast:
                input_b = relax.op.broadcast_to(input_b, b_broadcast)

            input_a = self.flatten_to_nd(input_a, 3)
            input_b = self.flatten_to_nd(input_b, 3)

            adj_x = batch_matmul_options.AdjX()
            adj_y = batch_matmul_options.AdjY()

            if adj_x:
                input_a = relax.op.permute_dims(input_a, [0, 2, 1])
            if adj_y:
                input_b = relax.op.permute_dims(input_b, [0, 2, 1])

            output = relax.op.matmul(input_a, input_b)

            # Compute output matmul dims from original shapes
            m_dim = int(shape_a[-1]) if adj_x else int(shape_a[-2])
            n_dim = int(shape_b[-2]) if adj_y else int(shape_b[-1])
            final_shape = [int(s) for s in shape_a[: rank_a - 2]] + [m_dim, n_dim]
            return relax.op.reshape(output, final_shape)

        # rank <= 2: use matmul directly
        if batch_matmul_options.AdjX():
            input_a = relax.op.permute_dims(input_a)
        if batch_matmul_options.AdjY():
            input_b = relax.op.permute_dims(input_b)
        return relax.op.matmul(input_a, input_b)

    def convert_space_to_batch_nd(self, op):
        """space_to_batch_nd implementation."""
        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 3, "input tensors length should be 3"

        input_tensor = input_tensors[0]
        input_tensor_idx = input_tensor.tensor_idx
        in_expr = self.get_expr(input_tensor_idx)

        block_shape = to_int_list(self.get_tensor_value(input_tensors[1]))
        paddings = self.get_tensor_value(input_tensors[2])
        pad_before = to_int_list(paddings[:, 0])
        pad_after = to_int_list(paddings[:, 1])

        output_tensors = self.get_output_tensors(op)
        assert len(output_tensors) == 1, "output tensors length should be 1"
        output_tensor = output_tensors[0]
        output_shape = to_int_list(self.get_tensor_shape(output_tensor))
        output_dtype = self.get_tensor_type_str(output_tensor.tensor.Type())

        out = relax.op.call_dps_packed(
            "topi.nn.space_to_batch_nd",
            (
                in_expr,
                relax.ShapeExpr(block_shape),
                relax.ShapeExpr(pad_before),
                relax.ShapeExpr(pad_after),
                0.0,
            ),
            out_ty=relax.TensorType(output_shape, output_dtype),
        )

        return out

    def convert_depth_to_space(self, op):
        """Convert TFLite DEPTH_TO_SPACE"""

        from tflite.BuiltinOptions import BuiltinOptions
        from tflite.DepthToSpaceOptions import DepthToSpaceOptions

        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 1, "input tensors length should be 1"

        input_tensor = input_tensors[0]
        in_expr = self.get_expr(input_tensor.tensor_idx)

        assert op.BuiltinOptionsType() == BuiltinOptions.DepthToSpaceOptions
        op_options = op.BuiltinOptions()
        depth_to_space_options = DepthToSpaceOptions()
        depth_to_space_options.Init(op_options.Bytes, op_options.Pos)
        block_size = depth_to_space_options.BlockSize()

        # TFLite uses NHWC layout: (N, H, W, C) -> (N, H*bs, W*bs, C/(bs*bs))
        input_shape = self.get_tensor_shape(input_tensor)
        n, h, w, c = input_shape
        out_c = c // (block_size**2)
        out = relax.op.reshape(in_expr, (n, h, w, block_size, block_size, out_c))
        out = relax.op.permute_dims(out, [0, 1, 3, 2, 4, 5])
        out = relax.op.reshape(out, (n, h * block_size, w * block_size, out_c))

        return out

    def convert_space_to_depth(self, op):
        """Convert TFLite SPACE_TO_DEPTH"""

        from tflite.BuiltinOptions import BuiltinOptions
        from tflite.SpaceToDepthOptions import SpaceToDepthOptions

        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 1, "input tensors length should be 1"

        input_tensor = input_tensors[0]
        in_expr = self.get_expr(input_tensor.tensor_idx)

        assert op.BuiltinOptionsType() == BuiltinOptions.SpaceToDepthOptions
        op_options = op.BuiltinOptions()
        space_to_depth_options = SpaceToDepthOptions()
        space_to_depth_options.Init(op_options.Bytes, op_options.Pos)
        block_size = space_to_depth_options.BlockSize()

        # TFLite uses NHWC layout: (N, H, W, C) -> (N, H/bs, W/bs, C*bs*bs)
        input_shape = self.get_tensor_shape(input_tensor)
        n, h, w, c = input_shape
        out = relax.op.reshape(
            in_expr, (n, h // block_size, block_size, w // block_size, block_size, c)
        )
        out = relax.op.permute_dims(out, [0, 1, 3, 2, 4, 5])
        out = relax.op.reshape(
            out, (n, h // block_size, w // block_size, c * block_size * block_size)
        )

        return out

    def convert_sparse_to_dense(self, op):
        """Convert TFLite SPARSE_TO_DENSE"""
        from tflite.TensorType import TensorType

        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 4, "input tensors length should be 4"

        indices, values = input_tensors[0], input_tensors[2]
        default_value = input_tensors[3]
        output_shape = input_tensors[1]

        for t in input_tensors:
            assert not t.qnn_params, "Quantized input is not expected."

        for t in [indices, output_shape]:
            t_type = t.tensor.Type()
            assert t_type in (TensorType.INT32, TensorType.INT64)

        output_tensors = self.get_output_tensors(op)
        output_tensor = output_tensors[0]
        output_shape_val = to_int_list(self.get_tensor_shape(output_tensor))
        output_dtype = self.get_tensor_type_str(output_tensor.tensor.Type())

        indices_expr = self.get_tensor_expr(indices)
        values_expr = self.get_tensor_expr(values)
        default_value_expr = self.get_tensor_expr(default_value)
        output_shape_expr = relax.const(list(self.get_tensor_value(output_shape)), "int32")

        out = relax.op.call_dps_packed(
            "topi.sparse_to_dense",
            (indices_expr, output_shape_expr, values_expr, default_value_expr),
            out_ty=relax.TensorType(output_shape_val, output_dtype),
        )

        return out

    def convert_prelu(self, op):
        """Convert TFLite PReLU"""
        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 2, "input tensors length should be 2"

        input_tensor = input_tensors[0]
        alpha_tensor = input_tensors[1]
        data_shape = to_int_list(self.get_tensor_shape(input_tensor))
        in_expr = self.get_tensor_expr(input_tensor)
        alpha_expr = self.get_tensor_expr(alpha_tensor)
        alpha_expr = self.bb.normalize(relax.op.broadcast_to(alpha_expr, data_shape))
        alpha_expr = self.bb.normalize(relax.op.reshape(alpha_expr, [-1]))
        out = relax.op.nn.prelu(_op.reshape(in_expr, [-1]), alpha_expr, axis=0)
        out = relax.op.reshape(out, data_shape)
        return out

    def convert_transpose_conv(self, op):
        """Convert TFLite TRANSPOSE_CONV"""

        from tflite.BuiltinOptions import BuiltinOptions
        from tflite.Padding import Padding
        from tflite.TensorType import TensorType
        from tflite.TransposeConvOptions import TransposeConvOptions

        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) >= 3, "input tensors length should be >= 3"

        # Input (data) Tensor. NHWC layout
        input_tensor = input_tensors[2]
        _, _, _, input_c = to_int_list(self.get_tensor_shape(input_tensor))
        # Weights tensor. TFLite uses OHWI layout
        weights_tensor = input_tensors[1]
        out_channels, kernel_h, kernel_w, in_channels = to_int_list(
            self.get_tensor_shape(weights_tensor)
        )

        assert input_c == in_channels, (
            "Input channel in the filter should match to channel in the input"
        )
        # output_shape Tensor. NHWC layout
        output_shape_tensor = input_tensors[0]

        output_tensors = self.get_output_tensors(op)
        assert len(output_tensors) == 1, "output tensors length should be 1"
        output_tensor = output_tensors[0]
        output_tensor_type = output_tensor.tensor.Type()
        output_tensor_type_str = self.get_tensor_type_str(output_tensor_type)

        assert op.BuiltinOptionsType() == BuiltinOptions.TransposeConvOptions
        op_options = op.BuiltinOptions()
        deconv_options = TransposeConvOptions()
        deconv_options.Init(op_options.Bytes, op_options.Pos)

        padding = deconv_options.Padding()
        stride_h = deconv_options.StrideH()
        stride_w = deconv_options.StrideW()
        assert padding in (
            Padding.VALID,
            Padding.SAME,
        ), f"Padding format {padding} is not supported for operator TRANSPOSE_CONV"

        # Data
        in_expr = self.get_expr(input_tensor.tensor_idx)

        # Weights
        weights_tensor_type = weights_tensor.tensor.Type()
        # weights tensor type should be UINT8 (quantization) or FLOAT32
        assert weights_tensor_type in (
            TensorType.INT8,
            TensorType.UINT8,
            TensorType.FLOAT32,
        )
        weight_tensor_type_str = self.get_tensor_type_str(weights_tensor_type)

        if self.has_expr(weights_tensor.tensor_idx):
            weight_expr_iohw = self.get_expr(weights_tensor.tensor_idx)
            weight_expr_iohw = relax.op.permute_dims(weight_expr_iohw, axes=(3, 0, 1, 2))
        else:
            weight_value_ohwi = self.get_tensor_value_or_prefetched(weights_tensor)
            # Relax kernel_layout should be OIHW
            # Relax weights layout should be different from kernel_layout - it should be IOHW
            weight_value_iohw = np.transpose(weight_value_ohwi, (3, 0, 1, 2))
            weight_expr_iohw = self.exp_tab.new_const(
                weight_value_iohw,
                dtype=weight_tensor_type_str,
                source_name=weights_tensor.tensor.Name(),
            )

        # Output shape value
        output_shape_value = self.get_tensor_value(output_shape_tensor)
        # Relax expects filter output channel to match to output tensor channel.
        assert out_channels == output_shape_value[3], (
            "Output channel in the filter should match to channel in the output_shape"
        )

        if padding == Padding.SAME:
            output_h, output_w = output_shape_value[1], output_shape_value[2]
            pad_top, pad_bottom = get_pad_value(output_h, kernel_h, stride_h)
            pad_left, pad_right = get_pad_value(output_w, kernel_w, stride_w)
            padding = (pad_top, pad_left, pad_bottom, pad_right)
        else:
            padding = (0, 0, 0, 0)

        if input_tensor.qnn_params:
            in_f32 = self.dequantize(in_expr, input_tensor)
            weight_axis = weights_tensor.qnn_params["axis"]
            if weight_axis != 0:
                raise tvm.error.OpAttributeInvalid(
                    f"TransposeConv weight QuantizedDimension() must be 0 "
                    f"(output-channel axis in OHWI layout), got {weight_axis}"
                )
            w_f32 = relax.op.dequantize(
                weight_expr_iohw,
                scale=weights_tensor.qnn_params["scale"],
                zero_point=weights_tensor.qnn_params["zero_point"],
                axis=1,
            )
            out = relax.op.nn.conv2d_transpose(
                in_f32,
                w_f32,
                strides=(stride_h, stride_w),
                padding=padding,
                data_layout="NHWC",
                kernel_layout="IOHW",
                out_dtype="float32",
            )
        else:
            out = relax.op.nn.conv2d_transpose(
                in_expr,
                weight_expr_iohw,
                strides=(stride_h, stride_w),
                padding=padding,
                data_layout="NHWC",
                kernel_layout="IOHW",
                out_dtype=output_tensor_type_str,
            )

        # Checking if there is a fused bias
        if len(input_tensors) == 4:
            bias_tensor = input_tensors[3]
            bias_tensor_type = bias_tensor.tensor.Type()
            # bias tensor type should be INT32 (quantization) or FLOAT32
            assert bias_tensor_type in (
                TensorType.INT32,
                TensorType.INT64,
                TensorType.FLOAT32,
            )
            bias_tensor_type_str = self.get_tensor_type_str(bias_tensor_type)
            if self.has_expr(bias_tensor.tensor_idx):
                bias_expr = self.get_expr(bias_tensor.tensor_idx)
            else:
                bias_expr = self.exp_tab.new_const(
                    self.get_tensor_value(bias_tensor),
                    dtype=bias_tensor_type_str,
                    source_name=bias_tensor.tensor.Name(),
                )
            if bias_tensor.qnn_params:
                bias_expr = self.dequantize(bias_expr, bias_tensor)
            elif input_tensor.qnn_params and bias_tensor_type in (
                TensorType.INT32,
                TensorType.INT64,
            ):
                bias_scale = relax.op.multiply(
                    input_tensor.qnn_params["scale"],
                    weights_tensor.qnn_params["scale"],
                )
                bias_expr = relax.op.dequantize(
                    bias_expr,
                    scale=bias_scale,
                    zero_point=relax.const(0, "int32"),
                    axis=0,
                )
            out = relax.op.add(out, bias_expr)

        if output_tensor.qnn_params:
            out = self.quantize(out, output_tensor)
        return out

    def convert_quantize(self, op):
        """Convert TFLite Quantize"""

        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 1, "input tensors length should be 1"
        input_tensor = input_tensors[0]
        input_tensor_type_str = self.get_tensor_type_str(input_tensor.tensor.Type())
        in_expr = self.get_tensor_expr(input_tensor)

        output_tensors = self.get_output_tensors(op)
        assert len(output_tensors) == 1, "output tensors length should be 1"
        output_tensor = output_tensors[0]

        # The output must be quantized
        assert output_tensor.qnn_params

        # TFLite Quantize op can also act as Requantize op
        if input_tensor_type_str == "float32":
            out = self.quantize(in_expr, output_tensor)
        else:
            in_f32 = self.dequantize(in_expr, input_tensor)
            out = self.quantize(in_f32, output_tensor)
        return out

    def convert_dequantize(self, op):
        """Convert TFLite Dequantize"""
        from tflite.TensorType import TensorType

        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 1, "input tensors length should be 1"
        input_tensor = input_tensors[0]

        if input_tensor.tensor.Type() == TensorType.FLOAT16:
            dtype = self.get_tensor_type_str(input_tensor.tensor.Type())
            input_value = self.get_tensor_value(input_tensor)
            in_expr = self.exp_tab.new_const(
                input_value, dtype=dtype, source_name=input_tensor.tensor.Name()
            )
            out = relax.op.astype(in_expr, "float32")
            return out

        in_expr = self.get_expr(input_tensor.tensor_idx)

        # The input must be quantized
        assert input_tensor.qnn_params
        # Dequantize the input.
        out = self.dequantize(in_expr, input_tensor)

        return out

    def convert_dilate(self, op):
        """Convert TFLite DILATE"""
        input_tensors = self.get_input_tensors(op)
        output_tensors = self.get_output_tensors(op)
        assert len(input_tensors) == 3, "input tensors length should be 3"
        assert len(output_tensors) == 1, "output tensors length should be 1"

        in_expr = self.get_tensor_expr(input_tensors[0])
        in_shape = to_int_list(self.get_tensor_shape(input_tensors[0]))
        in_dtype = self.get_tensor_type_str(input_tensors[0].tensor.Type())
        n_dims = len(in_shape)

        dilations_tensor = input_tensors[1]
        padding_expr = self.get_tensor_expr(input_tensors[2])

        # Runtime dilations bind tensor values to TIR Vars for symbolic
        # per-axis math.
        if self.has_expr(dilations_tensor.tensor_idx):
            dilations_expr = self.get_expr(dilations_tensor.tensor_idx)
            dilations_expr = self.bb.match_cast(dilations_expr, relax.TensorType([n_dims], "int32"))
            dilations_int64 = self.bb.normalize(relax.op.astype(dilations_expr, "int64"))
            shape_var = self.bb.emit(relax.op.tensor_to_shape(dilations_int64))
            stride_vars = [tirx.Var(f"dilate_stride_{i}", "int64") for i in range(n_dims)]
            self.bb.match_cast(shape_var, relax.ShapeType(stride_vars))
            strides = stride_vars
        else:
            strides = to_int_list(self.get_tensor_value(dilations_tensor))

        # Per axis: reshape to add a size-1 stride-axis, concat (s-1) padding
        # values along it, reshape to merge axes (length d*s), trim trailing
        # pad to TFLite's output dim formula (d-1)*s + 1.
        result = in_expr
        current_shape = list(in_shape)
        axes = list(range(n_dims))
        ones = [1] * n_dims
        for axis in range(n_dims):
            d = current_shape[axis]
            s = strides[axis]
            expanded_shape = current_shape[: axis + 1] + [1] + current_shape[axis + 1 :]
            expanded = relax.op.reshape(result, expanded_shape)
            pad_shape = list(expanded_shape)
            pad_shape[axis + 1] = s - 1
            pad = relax.op.full(pad_shape, padding_expr, dtype=in_dtype)
            concatted = relax.op.concat([expanded, pad], axis=axis + 1)
            merged_shape = list(current_shape)
            merged_shape[axis] = d * s
            merged = relax.op.reshape(concatted, merged_shape)
            # (d - 1) * s + 1 is the output dim along this axis.
            final_dim = (d - 1) * s + 1
            end = list(merged_shape)
            end[axis] = final_dim
            result = relax.op.strided_slice(
                merged, axes=axes, begin=[0] * n_dims, end=end, strides=ones
            )
            current_shape = list(merged_shape)
            current_shape[axis] = final_dim

        return result

    def convert_detection_postprocess(self, op):
        """Convert TFLite_Detection_PostProcess"""
        flexbuffer = op.CustomOptionsAsNumpy().tobytes()
        custom_options = FlexBufferDecoder(flexbuffer).decode()

        use_regular_nms = bool(custom_options.get("use_regular_nms", False))

        required_attrs = [
            "num_classes",
            "max_detections",
            "detections_per_class",
            "nms_iou_threshold",
            "nms_score_threshold",
            "x_scale",
            "y_scale",
            "w_scale",
            "h_scale",
        ]
        missing_attrs = [key for key in required_attrs if key not in custom_options]
        if missing_attrs:
            raise ValueError(
                "DETECTION_POSTPROCESS custom options miss required attributes: "
                + ", ".join(missing_attrs)
            )

        num_classes = int(custom_options["num_classes"])
        max_detections = int(custom_options["max_detections"])
        detections_per_class = int(custom_options["detections_per_class"])
        iou_threshold = float(custom_options["nms_iou_threshold"])
        score_threshold = float(custom_options["nms_score_threshold"])
        x_scale = float(custom_options["x_scale"])
        y_scale = float(custom_options["y_scale"])
        w_scale = float(custom_options["w_scale"])
        h_scale = float(custom_options["h_scale"])

        if num_classes <= 0:
            raise ValueError("DETECTION_POSTPROCESS requires num_classes > 0.")
        if max_detections <= 0:
            raise ValueError("DETECTION_POSTPROCESS requires max_detections > 0.")
        if detections_per_class <= 0:
            raise ValueError("DETECTION_POSTPROCESS requires detections_per_class > 0.")
        if not 0.0 <= iou_threshold <= 1.0:
            raise ValueError("DETECTION_POSTPROCESS requires nms_iou_threshold in [0, 1].")
        if x_scale <= 0.0 or y_scale <= 0.0 or w_scale <= 0.0 or h_scale <= 0.0:
            raise ValueError("DETECTION_POSTPROCESS requires x/y/w/h_scale to be > 0.")

        inputs = self.get_input_tensors(op)
        assert len(inputs) == 3, "inputs length should be 3"
        cls_pred = self.get_expr(inputs[1].tensor_idx)
        loc_prob = self.get_expr(inputs[0].tensor_idx)
        batch_size = inputs[1].tensor.Shape(0)
        anchor_values = self.get_tensor_value(inputs[2])
        anchor_boxes = len(anchor_values)
        anchor_type = self.get_tensor_type_str(inputs[2].tensor.Type())
        anchor_expr = self.exp_tab.new_const(
            anchor_values, dtype=anchor_type, source_name=inputs[2].tensor.Name()
        )

        if inputs[0].qnn_params:
            loc_prob = self.dequantize(loc_prob, inputs[0])
        if inputs[1].qnn_params:
            cls_pred = self.dequantize(cls_pred, inputs[1])
        if inputs[2].qnn_params:
            anchor_expr = self.dequantize(anchor_expr, inputs[2])

        # loc_prob coords are in yxhw format
        # need to convert to xywh
        loc_coords = relax.op.split(loc_prob, 4, axis=2)
        loc_prob = relax.op.concat(
            [loc_coords[1], loc_coords[0], loc_coords[3], loc_coords[2]], axis=2
        )
        # reshape loc_prob tensor so is can be consumed by
        # multibox_transform_loc
        loc_prob = relax.op.reshape(loc_prob, [batch_size, anchor_boxes * 4])

        # anchor coords are in yxhw format
        # need to convert to ltrb
        anchor_coords = relax.op.split(anchor_expr, 4, axis=1)
        anchor_y = anchor_coords[0]
        anchor_x = anchor_coords[1]
        anchor_h = anchor_coords[2]
        anchor_w = anchor_coords[3]
        plus_half = relax.const(0.5, dtype="float32")
        minus_half = relax.const(-0.5, dtype="float32")
        anchor_l = relax.op.add(anchor_x, relax.op.multiply(anchor_w, minus_half))
        anchor_r = relax.op.add(anchor_x, relax.op.multiply(anchor_w, plus_half))
        anchor_t = relax.op.add(anchor_y, relax.op.multiply(anchor_h, minus_half))
        anchor_b = relax.op.add(anchor_y, relax.op.multiply(anchor_h, plus_half))
        anchor_expr = relax.op.concat([anchor_l, anchor_t, anchor_r, anchor_b], axis=1)
        anchor_expr = relax.op.expand_dims(anchor_expr, 0)

        # attributes for multibox_transform_loc
        multibox_transform_loc_attrs = {}
        multibox_transform_loc_attrs["clip"] = False
        multibox_transform_loc_attrs["threshold"] = 0.0 if use_regular_nms else score_threshold
        multibox_transform_loc_attrs["variances"] = (
            1 / x_scale,
            1 / y_scale,
            1 / w_scale,
            1 / h_scale,
        )
        multibox_transform_loc_attrs["keep_background"] = use_regular_nms

        multibox_res = self.bb.emit(
            relax.op.vision.multibox_transform_loc(
                # reshape cls_pred so it can be consumed by
                # multibox_transform_loc
                relax.op.permute_dims(cls_pred, [0, 2, 1]),
                loc_prob,
                anchor_expr,
                **multibox_transform_loc_attrs,
            )
        )
        transformed_boxes = self.bb.emit(relax.TupleGetItem(multibox_res, 0))
        transformed_scores = self.bb.emit(relax.TupleGetItem(multibox_res, 1))

        if use_regular_nms:
            nms_out = self.bb.emit(
                relax.op.vision.all_class_non_max_suppression(
                    transformed_boxes,
                    transformed_scores,
                    relax.const(detections_per_class, "int64"),
                    relax.const(iou_threshold, "float32"),
                    relax.const(score_threshold, "float32"),
                    output_format="tensorflow",
                )
            )
            selected_indices = self.bb.emit(relax.TupleGetItem(nms_out, 0))
            selected_scores = self.bb.emit(relax.TupleGetItem(nms_out, 1))
            num_detections = self.bb.emit(relax.TupleGetItem(nms_out, 2))
            class_id_from_score = None
        else:
            topk_res = self.bb.emit(
                relax.op.topk(transformed_scores, k=1, axis=1, ret_type="both", largest=True)
            )
            max_scores = self.bb.emit(relax.TupleGetItem(topk_res, 0))
            class_id_from_score = self.bb.emit(relax.TupleGetItem(topk_res, 1))
            nms_out = self.bb.emit(
                relax.op.vision.all_class_non_max_suppression(
                    transformed_boxes,
                    max_scores,
                    relax.const(max_detections, "int64"),
                    relax.const(iou_threshold, "float32"),
                    relax.const(score_threshold, "float32"),
                    output_format="tensorflow",
                )
            )
            selected_indices = self.bb.emit(relax.TupleGetItem(nms_out, 0))
            selected_scores = self.bb.emit(relax.TupleGetItem(nms_out, 1))
            num_detections = self.bb.emit(relax.TupleGetItem(nms_out, 2))
            class_id_from_score = relax.op.squeeze(class_id_from_score, axis=[1])

        selected_score_slots = selected_scores.ty.shape.values[1]
        selected_detection_positions = relax.op.expand_dims(
            relax.op.arange(selected_score_slots, dtype="int64"), axis=0
        )
        selected_valid_detection_mask = relax.op.less(
            selected_detection_positions, relax.op.expand_dims(num_detections, axis=1)
        )
        masked_selected_scores = relax.op.where(
            selected_valid_detection_mask,
            selected_scores,
            relax.const(-1.0, "float32"),
        )
        topk_scores_res = self.bb.emit(
            relax.op.topk(
                masked_selected_scores, k=max_detections, axis=1, ret_type="both", largest=True
            )
        )
        detection_scores = self.bb.emit(relax.TupleGetItem(topk_scores_res, 0))
        top_positions = self.bb.emit(relax.TupleGetItem(topk_scores_res, 1))
        num_detections = relax.op.minimum(
            num_detections, relax.const([max_detections], dtype="int64")
        )
        detection_positions = relax.op.expand_dims(
            relax.op.arange(max_detections, dtype="int64"), axis=0
        )
        valid_detection_mask = relax.op.less(
            detection_positions, relax.op.expand_dims(num_detections, axis=1)
        )
        top_positions_expanded = relax.op.expand_dims(top_positions, axis=2)
        top_positions_for_pairs = relax.op.repeat(top_positions_expanded, 2, axis=2)
        top_index_pairs = relax.op.gather_elements(
            selected_indices, top_positions_for_pairs, axis=1
        )
        top_box_ids = relax.op.squeeze(
            relax.op.strided_slice(top_index_pairs, axes=[2], begin=[1], end=[2]),
            axis=[2],
        )
        top_box_ids_for_gather = relax.op.expand_dims(relax.op.astype(top_box_ids, "int64"), axis=2)
        detection_boxes = relax.op.gather_nd(
            transformed_boxes, top_box_ids_for_gather, batch_dims=1
        )

        if use_regular_nms:
            detection_classes = relax.op.squeeze(
                relax.op.strided_slice(top_index_pairs, axes=[2], begin=[0], end=[1]),
                axis=[2],
            )
            detection_classes = relax.op.astype(detection_classes, "int32")
        else:
            top_box_ids_for_class = relax.op.expand_dims(
                relax.op.astype(top_box_ids, "int64"), axis=2
            )
            detection_classes = relax.op.gather_nd(
                class_id_from_score, top_box_ids_for_class, batch_dims=1
            )

        detection_mask = relax.op.expand_dims(valid_detection_mask, axis=2)
        detection_boxes = relax.op.where(
            detection_mask,
            detection_boxes,
            relax.op.zeros((batch_size, max_detections, 4), dtype="float32"),
        )
        detection_classes = relax.op.where(
            valid_detection_mask,
            detection_classes,
            relax.op.zeros((batch_size, max_detections), dtype="int32"),
        )
        detection_scores = relax.op.where(
            valid_detection_mask,
            detection_scores,
            relax.op.zeros((batch_size, max_detections), dtype="float32"),
        )
        detection_classes = relax.op.astype(detection_classes, "float32")
        num_detections = relax.op.astype(num_detections, "float32")
        return relax.Tuple([detection_boxes, detection_classes, detection_scores, num_detections])

    def convert_nms_v4(self, op):
        """Convert TFLite NonMaxSuppressionV4"""
        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 5, "input tensor length should be 5"

        boxes = self.get_tensor_expr(input_tensors[0])
        scores = self.get_tensor_expr(input_tensors[1])

        max_output_size = self.get_tensor_value(input_tensors[2])
        iou_threshold = self.get_tensor_value(input_tensors[3])
        score_threshold = self.get_tensor_value(input_tensors[4])

        if isinstance(max_output_size, np.ndarray):
            assert max_output_size.size == 1, "only one value is expected."
            max_output_size = int(max_output_size)

        if isinstance(iou_threshold, np.ndarray):
            assert iou_threshold.size == 1, "only one value is expected."
            iou_threshold = float(iou_threshold)

        if isinstance(score_threshold, np.ndarray):
            assert score_threshold.size == 1, "only one value is expected."
            score_threshold = float(score_threshold)

        scores_expand = relax.op.expand_dims(scores, axis=-1)
        data = relax.op.concat([scores_expand, boxes], axis=-1)
        data = relax.op.expand_dims(data, axis=0)

        valid_counts_ret = relax.op.vision.get_valid_counts(
            data, score_threshold=score_threshold, id_index=-1, score_index=0
        )
        count = valid_counts_ret[0]
        data = valid_counts_ret[1]
        indices = valid_counts_ret[2]

        nms_ret = relax.op.vision.non_max_suppression(
            data=data,
            valid_count=count,
            indices=indices,
            max_output_size=max_output_size,
            iou_threshold=iou_threshold,
            force_suppress=True,
            top_k=-1,
            coord_start=1,
            score_index=0,
            id_index=-1,
            return_indices=True,
            invalid_to_bottom=False,
        )

        selected_indices = relax.op.squeeze(nms_ret[0], axis=[0])
        selected_indices = relax.op.strided_slice(
            selected_indices, axes=[0], begin=[0], end=[max_output_size]
        )
        num_valid = relax.op.reshape(nms_ret[1], [])

        return relax.Tuple([selected_indices, num_valid])

    def convert_nms_v5(self, op):
        """Convert TFLite NonMaxSuppressionV5"""
        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 6, "input tensor length should be 6"

        boxes = self.get_tensor_expr(input_tensors[0])
        scores = self.get_tensor_expr(input_tensors[1])

        max_output_size = self.get_tensor_value(input_tensors[2])
        iou_threshold = self.get_tensor_value(input_tensors[3])
        score_threshold = self.get_tensor_value(input_tensors[4])
        soft_nms_sigma = self.get_tensor_value(input_tensors[5])

        if isinstance(max_output_size, np.ndarray):
            assert max_output_size.size == 1, "only one value is expected."
            max_output_size = int(max_output_size)

        if isinstance(iou_threshold, np.ndarray):
            assert iou_threshold.size == 1, "only one value is expected."
            iou_threshold = float(iou_threshold)

        if isinstance(score_threshold, np.ndarray):
            assert score_threshold.size == 1, "only one value is expected."
            score_threshold = float(score_threshold)

        if isinstance(soft_nms_sigma, np.ndarray):
            assert soft_nms_sigma.size == 1, "only one value is expected."
            soft_nms_sigma = float(soft_nms_sigma)

        scores_expand = relax.op.expand_dims(scores, axis=-1)
        data = relax.op.concat([scores_expand, boxes], axis=-1)
        data = relax.op.expand_dims(data, axis=0)

        valid_counts_ret = relax.op.vision.get_valid_counts(
            data, score_threshold=score_threshold, id_index=-1, score_index=0
        )
        count = valid_counts_ret[0]
        data = valid_counts_ret[1]
        indices = valid_counts_ret[2]

        nms_ret = relax.op.vision.non_max_suppression(
            data=data,
            valid_count=count,
            indices=indices,
            max_output_size=max_output_size,
            iou_threshold=iou_threshold,
            force_suppress=True,
            top_k=-1,
            coord_start=1,
            score_index=0,
            id_index=-1,
            return_indices=True,
            invalid_to_bottom=False,
            soft_nms_sigma=soft_nms_sigma,
            score_threshold=score_threshold,
        )

        if soft_nms_sigma > 0.0:
            processed_data = relax.op.squeeze(nms_ret[0], axis=[0])
            indices_from_nms = nms_ret[1]
            num_valid_from_nms = nms_ret[2]
        else:
            indices_from_nms = nms_ret[0]
            num_valid_from_nms = nms_ret[1]

        selected_indices = relax.op.squeeze(indices_from_nms, axis=[0])
        selected_indices = relax.op.strided_slice(
            selected_indices, axes=[0], begin=[0], end=[max_output_size]
        )
        num_valid = relax.op.reshape(num_valid_from_nms, [])

        if soft_nms_sigma > 0.0:
            # Extract decayed scores from the processed data (score_index=0)
            selected_scores = relax.op.strided_slice(processed_data, axes=[1], begin=[0], end=[1])
            selected_scores = relax.op.squeeze(selected_scores, axis=[1])
            selected_scores = relax.op.strided_slice(
                selected_scores, axes=[0], begin=[0], end=[max_output_size]
            )
            selected_scores = relax.op.clip(
                selected_scores, min=0.0, max=float(np.finfo("float32").max)
            )
        else:
            # Clamp out-of-bound padded indices to prevent take() crash.
            num_boxes = int(self.get_tensor_shape(input_tensors[0])[0])
            safe_indices = relax.op.clip(selected_indices, min=0, max=num_boxes - 1)
            selected_scores = relax.op.take(scores, safe_indices, axis=0)

        out = relax.Tuple([selected_indices, selected_scores, num_valid])
        return out

    def convert_expand_dims(self, op):
        """Convert TFLite EXPAND_DIMS"""
        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 2, "input tensors length should be 2"

        if input_tensors[0].qnn_params:
            # Check that input and output tensor have same qnn params.
            output_tensors = self.get_output_tensors(op)
            assert self.has_same_qnn_params(input_tensors[0], output_tensors[0]), (
                "TFLite EXPAND_DIMS requires input and output tensors' \
                    scale and zero points to be equal"
            )

        input_expr = self.get_tensor_expr(input_tensors[0])
        axis = self.get_tensor_value(input_tensors[1])
        if isinstance(axis, np.ndarray):
            assert axis.size == 1, "only one value is expected."
            axis = int(axis.flat[0])

        ndims = len(input_tensors[0].tensor.ShapeAsNumpy())
        assert -1 - ndims <= axis <= ndims, "axis out of range"

        out = relax.op.expand_dims(input_expr, axis, 1)

        return out

    def convert_one_hot(self, op):
        """Convert TFLite ONE_HOT"""

        from tflite.BuiltinOptions import BuiltinOptions
        from tflite.OneHotOptions import OneHotOptions

        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 4, "Input tensor's length should be 4"

        # Ensuring input isn't quantized
        assert all(not i.qnn_params for i in input_tensors), "Quantized input is not expected."

        # TFlite ONE_HOT requires both on_value
        # and off_value, making dtype redundant.
        indices = input_tensors[0]
        depth = input_tensors[1]
        on_value = input_tensors[2]
        off_value = input_tensors[3]

        assert on_value.tensor.Type() == off_value.tensor.Type(), (
            "on_value and off_value should be the same type"
        )

        # Getting relax expr for indices
        indices_expr = self.get_expr(indices.tensor_idx)

        # Getting depth value
        depth = self.get_tensor_value(depth)
        if isinstance(depth, np.ndarray):
            depth = int(depth)

        # Getting Axis from Option (Attributes)
        assert op.BuiltinOptionsType() == BuiltinOptions.OneHotOptions
        op_options = op.BuiltinOptions()
        one_hot_options = OneHotOptions()
        one_hot_options.Init(op_options.Bytes, op_options.Pos)
        axis = one_hot_options.Axis()

        # Extract scalar values for on_value and off_value as Expr
        dtype = self.get_tensor_type_str(on_value.tensor.Type())
        on_val = self.get_tensor_value(on_value).item()
        off_val = self.get_tensor_value(off_value).item()
        if "float" in dtype:
            on_prim = relax.prim_value(tvm.tirx.FloatImm(dtype, float(on_val)))
            off_prim = relax.prim_value(tvm.tirx.FloatImm(dtype, float(off_val)))
        else:
            on_prim = relax.prim_value(tvm.tirx.IntImm(dtype, int(on_val)))
            off_prim = relax.prim_value(tvm.tirx.IntImm(dtype, int(off_val)))

        out = relax.op.one_hot(indices_expr, on_prim, off_prim, depth, axis)

        return out

    def convert_reverse_v2(self, op):
        """Convert TFLite REVERSE_V2"""
        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 2, "input tensor's length should be 2"

        input_expr = self.get_expr(input_tensors[0].tensor_idx)

        # Getting axis value
        axis = self.get_tensor_value(input_tensors[1])
        if isinstance(axis, np.ndarray):
            assert len(axis) == 1, "TFLite does not support multi-axis yet"
            axis = int(axis.flat[0])

        out = relax.op.flip(input_expr, axis)
        return out

    def convert_matrix_set_diag(self, op):
        """Convert TFLite MATRIX_SET_DIAG"""

        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 2, "input tensor's length should be 2"

        assert input_tensors[0].tensor.Type() == input_tensors[1].tensor.Type(), (
            "input and diagonal should be the same type of tensors"
        )

        if input_tensors[0].qnn_params:
            # Check that input and output tensor have same qnn params.
            output_tensors = self.get_output_tensors(op)
            assert self.has_same_qnn_params(input_tensors[0], output_tensors[0]), (
                "TFLite MATRIX_SET_DIAG requires input and output tensors' \
                    scale and zero points to be equal"
            )

            # Check that input and diagonal tensor have same qnn params.
            assert self.has_same_qnn_params(input_tensors[0], input_tensors[1]), (
                "TFLite MATRIX_SET_DIAG requires input and diagonal tensors' \
                    scale and zero points to be equal"
            )

        input_expr = self.get_tensor_expr(input_tensors[0])
        diagonal_expr = self.get_tensor_expr(input_tensors[1])

        output_tensors = self.get_output_tensors(op)
        output_tensor = output_tensors[0]
        output_shape = to_int_list(self.get_tensor_shape(output_tensor))
        output_dtype = self.get_tensor_type_str(output_tensor.tensor.Type())

        # topi.matrix_set_diag(
        #     input, diagonal, k1, k2, super_diag_right_align, sub_diag_right_align
        # )
        # TFLite MATRIX_SET_DIAG only sets the main diagonal, so k1=0, k2=0
        out = relax.op.call_dps_packed(
            "topi.matrix_set_diag",
            (
                input_expr,
                diagonal_expr,
                relax.const(0),
                relax.const(0),
                relax.const(False),
                relax.const(False),
            ),
            out_ty=relax.TensorType(output_shape, output_dtype),
        )
        return out

    def convert_matrix_diag(self, op):
        """Convert TFLite MATRIX_DIAG"""
        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 1, "input tensor's length should be 1"

        diagonal = input_tensors[0]

        if diagonal.qnn_params:
            # Check that diagonal and output tensor have same qnn params.
            output_tensors = self.get_output_tensors(op)
            assert self.has_same_qnn_params(diagonal, output_tensors[0]), (
                "TFLite MATRIX_DIAG requires diagonal and output tensors' \
                    scale and zero points to be equal"
            )

        output_tensors = self.get_output_tensors(op)
        output_tensor = output_tensors[0]
        output_shape = to_int_list(self.get_tensor_shape(output_tensor))
        output_dtype = self.get_tensor_type_str(output_tensor.tensor.Type())

        diagonal_expr = self.get_tensor_expr(diagonal)
        zeros_expr = relax.op.zeros(output_shape, output_dtype)

        # topi.matrix_set_diag(
        #     input, diagonal, k1, k2, super_diag_right_align, sub_diag_right_align
        # )
        # TFLite MATRIX_DIAG only sets the main diagonal, so k1=0, k2=0
        out = relax.op.call_dps_packed(
            "topi.matrix_set_diag",
            (
                zeros_expr,
                diagonal_expr,
                relax.const(0),
                relax.const(0),
                relax.const(False),
                relax.const(False),
            ),
            out_ty=relax.TensorType(output_shape, output_dtype),
        )
        return out

    def convert_densify(self, op):
        """Convert TFLite DENSIFY"""
        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 1, "input tensors length should be 1"

        output_tensors = self.get_output_tensors(op)
        assert len(output_tensors) == 1, "output tensors length should be 1"
        output_tensor = output_tensors[0]

        sparse_weight_tensor = input_tensors[0]
        sparse_weight_tensor_type_str = self.get_tensor_type_str(sparse_weight_tensor.tensor.Type())

        # NOTE: With current implementation in TFLite, Densify Op does not need to be present
        # in runtime.
        # TODO(ANSHUMAN87): we need to use the sparse_indices output
        # from below function and use that in sparse_to_dense Op.
        # Once the stack corruption issue is resolved in sparse_to_dense Op.
        _, dense_weight = prepare_dense_matrix_from_sparse(
            sparse_weight_tensor.tensor,
            self.get_tensor_value(sparse_weight_tensor, is_sparse=True),
            sparse_weight_tensor_type_str,
        )

        self.set_prefetched_node(output_tensor.tensor_idx, dense_weight)

    def convert_fake_quant(self, op):
        """Convert TFLite FAKE_QUANT"""
        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 1, "input tensors length should be 1"

        input_tensor = input_tensors[0]
        in_expr = self.get_expr(input_tensor.tensor_idx)

        from tflite.BuiltinOptions import BuiltinOptions
        from tflite.FakeQuantOptions import FakeQuantOptions

        assert op.BuiltinOptionsType() == BuiltinOptions.FakeQuantOptions

        op_options = op.BuiltinOptions()
        fake_quant_options = FakeQuantOptions()
        fake_quant_options.Init(op_options.Bytes, op_options.Pos)

        opt_min = fake_quant_options.Min()
        opt_max = fake_quant_options.Max()
        narrow_range = fake_quant_options.NarrowRange()
        num_bits = fake_quant_options.NumBits()

        assert 2 <= num_bits <= 16

        quant_min = 1 if narrow_range else 0
        quant_max = (1 << num_bits) - 1
        scale = (opt_max - opt_min) / (quant_max - quant_min)

        zero_point_from_min = quant_min - opt_min / scale
        if zero_point_from_min <= quant_min:
            nudged_zero_point = quant_min
        elif zero_point_from_min >= quant_max:
            nudged_zero_point = quant_max
        else:
            nudged_zero_point = round(zero_point_from_min)

        nudged_min = (quant_min - nudged_zero_point) * scale
        nudged_max = (quant_max - nudged_zero_point) * scale

        nudged_min_expr = relax.const(nudged_min, "float32")
        clamped = relax.op.clip(in_expr, nudged_min, nudged_max)
        clamped_shifted = relax.op.subtract(clamped, nudged_min_expr)

        half = relax.const(0.5, "float32")
        one = relax.const(1.0, "float32")
        scale_expr = relax.const(scale, "float32")
        inv_scale = relax.op.divide(one, scale_expr)
        rounded = relax.op.floor(_op.add(_op.multiply(clamped_shifted, inv_scale), half))
        return relax.op.add(_op.multiply(rounded, scale_expr), nudged_min_expr)

    def convert_real(self, op):
        """Convert TFLite REAL op.

        TFLite complex64 tensors are represented as float32[..., 2] in Relax,
        where index 0 = real part, index 1 = imaginary part along the last axis
        """
        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 1, "input tensors length should be 1"
        input_tensor = self.get_expr(input_tensors[0].tensor_idx)
        # slice last axis at index 0, and squeeze to remove the last axis
        real = _op.strided_slice(input_tensor, begin=[0], end=[1], strides=[1], axes=[-1])
        return _op.squeeze(real, axis=[-1])

    def convert_imag(self, op):
        """Convert TFLite IMAG op.

        See convert_real for representation of complex64 tensors in Relax.
        """
        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 1, "input tensors length should be 1"
        input_tensor = self.get_expr(input_tensors[0].tensor_idx)
        # slice last axis at index 1, and squeeze to remove the last axis
        imag = _op.strided_slice(input_tensor, begin=[1], end=[2], strides=[1], axes=[-1])
        return _op.squeeze(imag, axis=[-1])

    def convert_complex_abs(self, op):
        """Convert TFLite COMPLEX_ABS op: sqrt(real^2 + imag^2)

        See convert_real for the float32[..., 2] complex representation convention.
        """
        input_tensors = self.get_input_tensors(op)
        assert len(input_tensors) == 1, "input tensors length should be 1"
        input_tensor = self.get_expr(input_tensors[0].tensor_idx)
        real = self.bb.emit(
            _op.strided_slice(input_tensor, begin=[0], end=[1], strides=[1], axes=[-1])
        )
        real = self.bb.emit(_op.squeeze(real, axis=[-1]))
        imag = self.bb.emit(
            _op.strided_slice(input_tensor, begin=[1], end=[2], strides=[1], axes=[-1])
        )
        imag = self.bb.emit(_op.squeeze(imag, axis=[-1]))
        real_sq = self.bb.emit(_op.multiply(real, real))
        imag_sq = self.bb.emit(_op.multiply(imag, imag))
        sum_expr = self.bb.emit(_op.add(real_sq, imag_sq))
        return _op.sqrt(sum_expr)

    def get_expr(self, input_tensor_idx):
        return self.exp_tab.get_expr(get_tensor_name(self.subgraph, input_tensor_idx))

    def has_expr(self, input_tensor_idx):
        return self.exp_tab.has_expr(get_tensor_name(self.subgraph, input_tensor_idx))

    def is_prefetched(self, input_tensor_idx):
        return (
            self.prefetched_nodes.get(get_tensor_name(self.subgraph, input_tensor_idx)) is not None
        )

    def set_prefetched_node(self, input_tensor_idx, value):
        self.prefetched_nodes[get_tensor_name(self.subgraph, input_tensor_idx)] = value

    def get_prefetched_node(self, input_tensor_idx):
        return self.prefetched_nodes[get_tensor_name(self.subgraph, input_tensor_idx)]

    def get_tensor_value_or_prefetched(self, tensor, is_sparse=False):
        if self.is_prefetched(tensor.tensor_idx):
            return self.get_prefetched_node(tensor.tensor_idx)
        return self.get_tensor_value(tensor, is_sparse)

    def get_tensor_expr(self, tensor, is_sparse=False):
        """Return the Relax expr for tensor."""
        if self.has_expr(tensor.tensor_idx):
            return self.get_expr(tensor.tensor_idx)

        type_str = self.get_tensor_type_str(tensor.tensor.Type())
        value = self.get_tensor_value_or_prefetched(tensor, is_sparse)
        # complex64 constants have no native Relax dtype. Reinterpret the
        # interleaved float32 storage as float32[..., 2] to match the
        # convention used for input tensors.
        if type_str == "complex64":
            value = value.view(np.float32).reshape(value.shape + (2,))
            type_str = "float32"
        return self.exp_tab.new_const(value, dtype=type_str, source_name=tensor.tensor.Name())

    def get_tensor_shape(self, tensor_wrapper):
        """Returns the TFLite tensor shape, inferring it if the TFLite shape is empty.

        This returns the *raw TFLite* shape (no pair axis), even for COMPLEX64 tensors.
        It is distinct from ``_get_relax_tensor_shape``, which returns the *Relax*
        representation (TFLite shape with a trailing (2,) axis appended for COMPLEX64).
        Operators that build Relax IR for COMPLEX64 inputs should use
        ``_get_relax_tensor_shape``; operators that need the TFLite shape for validation
        (e.g. comparing against the model's declared output shape) should use this method.
        """
        assert isinstance(tensor_wrapper, TensorWrapper), "Expecting TensorWrapper here"
        return (
            tensor_wrapper.tensor.ShapeAsNumpy()
            if tensor_wrapper.tensor.ShapeLength() > 0
            else self._infer_shape(self.get_tensor_expr(tensor_wrapper))
        )


def _is_power_of_2(n):
    """Return True iff ``n`` is a positive power of 2."""
    return n > 0 and (n & (n - 1)) == 0


def _bit_reversal_swap_pairs(n):
    """Return the (i, j) index pairs (i < j) for the bit-reversal permutation of length n.

    For a Cooley-Tukey radix-2 FFT, the input must be permuted by bit-reversing
    each index in log2(n) bits before the butterfly stages. Precomputing the
    swap pairs as constants is much cheaper in TIR than computing the
    bit-reverse on the fly.
    """
    assert _is_power_of_2(n), f"bit-reversal requires power of 2, got {n}"
    length = n.bit_length() - 1  # log2(n)
    swaps = []
    for i in range(1, n):
        j = 0
        for k in range(length):
            if i & (1 << k):
                j |= 1 << (length - 1 - k)
        if i < j:
            swaps.append((i, j))
    return swaps


def _build_tflite_rfft2d_primfunc(input_shape, output_pair_shape):
    """Build a reference TIR kernel for TFLite RFFT2D.

    The TFLite frontend represents complex tensors as float32 real/imag pairs
    with a trailing dimension of size 2 because TVM does not have a native
    complex64 dtype. This kernel computes the unnormalized 2-D real FFT over
    the last two input dimensions and writes that pair representation.

    All trig and accumulation are in float32, so the result agrees with
    ``np.fft.rfft2`` to about ``1e-5`` absolute tolerance for typical input
    sizes. Higher-precision backends should override this kernel.

    Notes
    -----
    This is a **naive O(B * H * W * H * W) DFT**, not an FFT. For an input of
    spatial shape (H, W) the inner sum runs H*W times per output position, and
    there are H*W' output positions per batch (W' = W // 2 + 1). This is
    intentionally simple for correctness validation against
    ``np.fft.rfft2``; production use cases with large spatial dimensions should
    override the kernel with an FFT-based implementation. The outer
    (batch, out_y, out_x) iteration is structured as S-TIR spatial axes so a
    downstream ``tvm.tir.schedule`` pass can parallelize it.
    """
    from tvm.script.parser import tirx as T

    batch = 1
    for dim in input_shape[:-2]:
        batch *= int(dim)
    height = int(input_shape[-2])
    width = int(input_shape[-1])
    out_width = int(output_pair_shape[-2])
    input_total = batch * height * width
    output_complex_total = batch * height * out_width
    neg_two_pi = np.float32(-2.0 * math.pi)

    @T.prim_func(private=True, s_tir=True, check_well_formed=False)
    def kernel(
        data: T.Buffer(input_shape, "float32"), output: T.Buffer(output_pair_shape, "float32")
    ):
        # Flat 1D aliases of the multi-dim buffers. The kernel is rank-agnostic
        # over the leading batch dimensions, so collapsing the index space
        # avoids special-casing 2D / 3D / 4D input shapes.
        data_flat = T.decl_buffer((input_total,), "float32", data=data.data)
        output_flat = T.decl_buffer((output_complex_total * 2,), "float32", data=output.data)
        neg_two_pi_const = T.float32(neg_two_pi)

        for b_idx, out_y, out_x in T.grid(batch, height, out_width):
            with T.sblock("rfft2d"):
                v_b, v_oy, v_ox = T.axis.remap("SSS", [b_idx, out_y, out_x])
                real_sum = T.float32(0)
                imag_sum = T.float32(0)
                input_base = v_b * height * width
                for in_y, in_x in T.grid(height, width):
                    phase_y = T.Cast("float32", v_oy) * T.Cast("float32", in_y) / T.float32(height)
                    phase_x = T.Cast("float32", v_ox) * T.Cast("float32", in_x) / T.float32(width)
                    angle = neg_two_pi_const * (phase_y + phase_x)
                    value = data_flat[input_base + in_y * width + in_x]
                    real_sum = real_sum + value * T.cos(angle)
                    imag_sum = imag_sum + value * T.sin(angle)
                flat_out_idx = ((v_b * height + v_oy) * out_width + v_ox) * 2
                output_flat[flat_out_idx] = real_sum
                output_flat[flat_out_idx + 1] = imag_sum

    return kernel


def _build_tflite_rfft2d_fft_primfunc(input_shape, output_pair_shape):
    """Build a 2D Cooley-Tukey FFT TIR kernel for TFLite RFFT2D.

    Precondition: both ``input_shape[-2]`` (height) and ``input_shape[-1]``
    (width) must be positive powers of 2. The frontend dispatches to this
    kernel via ``_is_power_of_2`` checks; the DFT reference kernel handles
    the remaining cases (odd / non-power-of-2 sizes).

    Algorithm
    ---------
    1. Copy the real input into a scratch complex buffer (imag = 0) of shape
       ``(B * H * W,)``.
    2. For each batch and each row, run an in-place radix-2 1D FFT of length
       ``W`` along the width axis.
    3. For each batch and each column, run an in-place radix-2 1D FFT of
       length ``H`` along the height axis (with stride ``W``).
    4. Write the first ``W // 2 + 1`` complex bins per row to the output
       pair representation.

    The bit-reversal permutation required by iterative Cooley-Tukey is done
    by emitting the (i, j) swap pairs directly in the TIR source (one
    inlined swap per pair), avoiding the need for runtime index tables.

    Complexity is ``O(B * H * W * (log2(H) + log2(W)))``, vs the DFT
    reference kernel's ``O(B * H * W * H * W)``.
    """
    from tvm.script.parser import tirx as T

    batch = 1
    for dim in input_shape[:-2]:
        batch *= int(dim)
    height = int(input_shape[-2])
    width = int(input_shape[-1])
    out_width = int(output_pair_shape[-2])
    input_total = batch * height * width
    output_complex_total = batch * height * out_width
    # Cast to Python float so the f-string-interpolated repr is a plain number
    # (np.float32's repr is "np.float32(...)", which the TIR parser can't resolve).
    neg_two_pi = float(np.float32(-2.0 * math.pi))
    log2_w = int(math.log2(width))
    log2_h = int(math.log2(height))

    if not (_is_power_of_2(height) and _is_power_of_2(width)):
        raise ValueError(
            f"_build_tflite_rfft2d_fft_primfunc requires power-of-2 height and width, "
            f"got H={height}, W={width}"
        )

    # Precompute the bit-reversal swap pairs at Python level. These are
    # constant for a given FFT length and will be inlined in the TIR source.
    # Each emitted line is indented 16 spaces (4 levels: top → b_idx loop →
    # sblock → row/col loop body) so it lands inside the for loop when
    # concatenated into the primfunc source.
    row_swap_stmts = []
    for i, j in _bit_reversal_swap_pairs(width):
        row_swap_stmts.append(
            f"                i_idx = row_base + {i}\n"
            f"                j_idx = row_base + {j}\n"
            f"                tmp_r = scratch_real[i_idx]\n"
            f"                scratch_real[i_idx] = scratch_real[j_idx]\n"
            f"                scratch_real[j_idx] = tmp_r\n"
            f"                tmp_i = scratch_imag[i_idx]\n"
            f"                scratch_imag[i_idx] = scratch_imag[j_idx]\n"
            f"                scratch_imag[j_idx] = tmp_i\n"
        )
    row_swaps_code = "".join(row_swap_stmts) if row_swap_stmts else "                pass\n"

    col_swap_stmts = []
    for i, j in _bit_reversal_swap_pairs(height):
        col_swap_stmts.append(
            f"                i_idx = col_base + {i * width}\n"
            f"                j_idx = col_base + {j * width}\n"
            f"                tmp_r = scratch_real[i_idx]\n"
            f"                scratch_real[i_idx] = scratch_real[j_idx]\n"
            f"                scratch_real[j_idx] = tmp_r\n"
            f"                tmp_i = scratch_imag[i_idx]\n"
            f"                scratch_imag[i_idx] = scratch_imag[j_idx]\n"
            f"                scratch_imag[j_idx] = tmp_i\n"
        )
    col_swaps_code = "".join(col_swap_stmts) if col_swap_stmts else "                pass\n"

    # Build the per-stage butterfly code with the stage loop fully unrolled
    # at primfunc-construction time. After unrolling, all loop bounds
    # (block_start, k) are compile-time integers, so the TIR parser doesn't
    # need to reason about runtime loop extents and the scheduler can
    # see static twiddle factors instead of runtime trig calls.
    def _stage_stmts(stage_count, length, indent, stride=1, base_expr="row_base"):
        """Generate fully-unrolled Cooley-Tukey butterfly stage bodies.

        ``base_expr`` is the TIR expression holding the base offset of the
        FFT being transformed (e.g. ``"row_base"`` for rows or
        ``"col_base"`` for columns). ``stride`` is the integer distance
        between adjacent butterfly taps: 1 for the row-FFT (contiguous
        elements) and ``width`` for the column-FFT (strided access).
        """
        out = []
        for stage in range(1, stage_count + 1):
            m_val = 1 << stage
            half_val = m_val >> 1
            for block_start in range(0, length, m_val):
                for k in range(half_val):
                    if stride == 1:
                        a_idx = f"{base_expr} + {block_start} + {k}"
                        b_idx_expr = f"{base_expr} + {block_start} + {k} + {half_val}"
                    else:
                        a_idx = f"{base_expr} + ({block_start} + {k}) * {stride}"
                        b_idx_expr = f"{base_expr} + ({block_start} + {k} + {half_val}) * {stride}"
                    angle_val = float(np.float32(neg_two_pi * k / m_val))
                    w_real_val = float(np.float32(math.cos(angle_val)))
                    w_imag_val = float(np.float32(math.sin(angle_val)))
                    out.extend(
                        [
                            f"{indent}a_idx = {a_idx}\n",
                            f"{indent}b_idx_local = {b_idx_expr}\n",
                            f"{indent}t_real = scratch_real[b_idx_local] * T.float32({w_real_val!r}) - scratch_imag[b_idx_local] * T.float32({w_imag_val!r})\n",
                            f"{indent}t_imag = scratch_real[b_idx_local] * T.float32({w_imag_val!r}) + scratch_imag[b_idx_local] * T.float32({w_real_val!r})\n",
                            f"{indent}u_real = scratch_real[a_idx]\n",
                            f"{indent}u_imag = scratch_imag[a_idx]\n",
                            f"{indent}scratch_real[a_idx] = u_real + t_real\n",
                            f"{indent}scratch_imag[a_idx] = u_imag + t_imag\n",
                            f"{indent}scratch_real[b_idx_local] = u_real - t_real\n",
                            f"{indent}scratch_imag[b_idx_local] = u_imag - t_imag\n",
                        ]
                    )
        return "".join(out)

    row_stages_code = _stage_stmts(
        log2_w, width, "                ", stride=1, base_expr="row_base"
    )
    col_stages_code = _stage_stmts(
        log2_h, height, "                ", stride=width, base_expr="col_base"
    )

    # Build the primfunc source. The bit-reversal swaps are inlined (one
    # unconditional block per (i, j) pair) and the butterfly stages are
    # fully unrolled, so the TIR parser sees ordinary statements rather than
    # runtime table lookups or runtime-magnitude loops. The body is wrapped
    # in a single S-TIR block over the batch dimension so the Relax
    # pipeline (which expects an SBlockRealize at the primfunc body) accepts
    # this kernel.
    primfunc_source = (
        "from tvm.script.parser import tirx as T\n"
        "@T.prim_func(private=True, s_tir=True, check_well_formed=False)\n"
        "def kernel(\n"
        f"    data: T.Buffer({tuple(int(x) for x in input_shape)}, 'float32'),\n"
        f"    output: T.Buffer({tuple(int(x) for x in output_pair_shape)}, 'float32'),\n"
        "):\n"
        f"    data_flat = T.decl_buffer(({input_total},), 'float32', data=data.data)\n"
        f"    output_flat = T.decl_buffer(({output_complex_total * 2},), 'float32', data=output.data)\n"
        f"    scratch_real = T.decl_buffer(({input_total},), 'float32')\n"
        f"    scratch_imag = T.decl_buffer(({input_total},), 'float32')\n"
        f"    for b_idx in T.serial({batch}):\n"
        f"        with T.sblock('rfft2d_fft'):\n"
        f"            v_b = T.axis.remap('S', [b_idx])\n"
        f"            # Initialize scratch from real input; imag = 0.\n"
        f"            for i in T.serial({height * width}):\n"
        f"                src = v_b * {height * width} + i\n"
        f"                scratch_real[src] = data_flat[src]\n"
        f"                scratch_imag[src] = T.float32(0)\n"
        f"            # FFT along width axis (one 1D FFT per row).\n"
        f"            for row in T.serial({height}):\n"
        f"                row_base = v_b * {height * width} + row * {width}\n"
        f"                # Bit-reversal permutation (inlined swaps).\n"
        f"{row_swaps_code}"
        f"                # Cooley-Tukey butterfly stages (fully unrolled).\n"
        f"{row_stages_code}"
        f"            # FFT along height axis (one 1D FFT per column, stride = width).\n"
        f"            for col in T.serial({width}):\n"
        f"                col_base = v_b * {height * width} + col\n"
        f"                # Bit-reversal permutation (strided, inlined swaps).\n"
        f"{col_swaps_code}"
        f"                # Cooley-Tukey butterfly stages (strided, fully unrolled).\n"
        f"{col_stages_code}"
        f"            # Write the first out_width complex bins per row to output.\n"
        f"            for row in T.serial({height}):\n"
        f"                for out_x in T.serial({out_width}):\n"
        f"                    src = v_b * {height * width} + row * {width} + out_x\n"
        f"                    dst = ((v_b * {height} + row) * {out_width} + out_x) * 2\n"
        f"                    output_flat[dst] = scratch_real[src]\n"
        f"                    output_flat[dst + 1] = scratch_imag[src]\n"
    )

    namespace = {"T": T, "tirx": T}
    # Register the generated source in linecache so the TIR parser (which calls
    # inspect.getsourcelines) can find it. The fake filename is stable across
    # calls — we include input/output shapes so the cache is self-disambiguating
    # for any callers who want to introspect the generated source.
    import linecache as _linecache

    fake_file = f"<tflite_rfft2d_fft_primfunc H={height} W={width} outW={out_width}>"
    _linecache.cache[fake_file] = (
        len(primfunc_source.splitlines()),
        None,
        [line + "\n" for line in primfunc_source.splitlines()],
        fake_file,
    )
    code = compile(primfunc_source, fake_file, "exec")
    exec(code, namespace)
    return namespace["kernel"]


# Constants for the Random123 counter-based PRNGs used by STABLEHLO_RNG_BIT_GENERATOR,
# matching tensorflow/lite/kernels/rng_util.cc.
_STABLEHLO_RNG_THREEFRY_PARITY = 0x1BD11BDA
_STABLEHLO_RNG_PHILOX_MUL_A = 0xD2511F53
_STABLEHLO_RNG_PHILOX_MUL_B = 0xCD9E8D57
_STABLEHLO_RNG_PHILOX_WEYL_A = 0x9E3779B9
_STABLEHLO_RNG_PHILOX_WEYL_B = 0xBB67AE85


def _build_stablehlo_rng_bit_generator_primfunc(algorithm, state_len, out_dtype, out_shape):
    """Build a bit-exact TIR kernel for STABLEHLO_RNG_BIT_GENERATOR.

    Mirrors the TFLite runtime kernel (tensorflow/lite/kernels/rng_bit_generator.cc),
    implementing the Random123 Threefry2x32 (20 rounds) and Philox4x32 (10 rounds)
    counter-based PRNGs. The kernel reinterprets the uint64 state as uint32 words,
    advances a 64-bit block counter, and packs the generated words into the output
    tensor. The updated state keeps the key unchanged and only advances the counter,
    which is the only behaviour the runtime relies on.
    """
    from tvm.script.parser import tirx as T

    total = 1
    for dim in out_shape:
        total *= int(dim)
    is_64bit = out_dtype in ("int64", "uint64")
    block_words = 2 if algorithm == "threefry" else 4
    out_word_count = total * (2 if is_64bit else 1)
    num_blocks = (out_word_count + block_words - 1) // block_words
    writes_per_block = block_words // (2 if is_64bit else 1)
    parity = _STABLEHLO_RNG_THREEFRY_PARITY
    mul_a, mul_b = _STABLEHLO_RNG_PHILOX_MUL_A, _STABLEHLO_RNG_PHILOX_MUL_B
    weyl_a, weyl_b = _STABLEHLO_RNG_PHILOX_WEYL_A, _STABLEHLO_RNG_PHILOX_WEYL_B

    def _u32(value):
        return T.Cast("uint32", value)

    def _u64(value):
        return T.Cast("uint64", value)

    def _store_value(words, write_index):
        # Pack the generated uint32 words into one output element, reinterpreting
        # the bit pattern into the (possibly signed) output dtype.
        if is_64bit:
            low = _u64(words[2 * write_index])
            high = _u64(words[2 * write_index + 1])
            return T.reinterpret(out_dtype, low | (high << T.uint64(32)))
        return T.reinterpret(out_dtype, words[write_index])

    if algorithm == "threefry":

        @T.prim_func(private=True, s_tir=True)
        def kernel(
            initial_state: T.Buffer((state_len,), "uint64"),
            output_state: T.Buffer((state_len,), "uint64"),
            output: T.Buffer(out_shape, out_dtype),
        ):
            # A single opaque structured block keeps the imperative kernel as a
            # well-formed block-structured PrimFunc, as required by the Relax
            # pipeline (e.g. HasReshapePattern).
            with T.sblock("rng_bit_generator"):
                state_key = initial_state[0]
                state_counter = initial_state[1]
                key_0 = _u32(state_key & T.uint64(0xFFFFFFFF))
                key_1 = _u32(state_key >> T.uint64(32))
                output_state[0] = state_key
                output_state[1] = state_counter + T.uint64(num_blocks)
                out_flat = T.decl_buffer((total,), out_dtype, data=output.data)
                keys = T.decl_buffer((3,), "uint32", scope="local")
                rotations = T.decl_buffer((8,), "uint32", scope="local")
                ctr = T.decl_buffer((2,), "uint32", scope="local")
                keys[0] = key_0
                keys[1] = key_1
                keys[2] = key_0 ^ key_1 ^ T.uint32(parity)
                rotations[0] = T.uint32(13)
                rotations[1] = T.uint32(15)
                rotations[2] = T.uint32(26)
                rotations[3] = T.uint32(6)
                rotations[4] = T.uint32(17)
                rotations[5] = T.uint32(29)
                rotations[6] = T.uint32(16)
                rotations[7] = T.uint32(24)
                for block in T.serial(num_blocks):
                    counter = state_counter + _u64(block)
                    ctr[0] = _u32(counter & T.uint64(0xFFFFFFFF)) + key_0
                    ctr[1] = _u32(counter >> T.uint64(32)) + key_1
                    for group in T.serial(5):
                        for step in T.serial(4):
                            rot = rotations[(group * 4 + step) % 8]
                            ctr[0] = ctr[0] + ctr[1]
                            ctr[1] = (ctr[1] << rot) | (ctr[1] >> (T.uint32(32) - rot))
                            ctr[1] = ctr[1] ^ ctr[0]
                        ctr[0] = ctr[0] + keys[(group + 1) % 3]
                        ctr[1] = ctr[1] + keys[(group + 2) % 3] + _u32(group + 1)
                    for write_index in T.serial(writes_per_block):
                        element = block * writes_per_block + write_index
                        if element < total:
                            out_flat[element] = _store_value(ctr, write_index)

        return kernel

    @T.prim_func(private=True, s_tir=True)
    def kernel(
        initial_state: T.Buffer((state_len,), "uint64"),
        output_state: T.Buffer((state_len,), "uint64"),
        output: T.Buffer(out_shape, out_dtype),
    ):
        with T.sblock("rng_bit_generator"):
            state_key = initial_state[0]
            state_counter = initial_state[1]
            key_0 = _u32(state_key & T.uint64(0xFFFFFFFF))
            key_1 = _u32(state_key >> T.uint64(32))
            output_state[0] = state_key
            output_state[1] = state_counter + T.uint64(num_blocks)
            out_flat = T.decl_buffer((total,), out_dtype, data=output.data)
            ctr = T.decl_buffer((4,), "uint32", scope="local")
            keys = T.decl_buffer((2,), "uint32", scope="local")
            high_ctr = T.decl_buffer((2,), "uint32", scope="local")
            if state_len == 3:
                # PHILOX u64[3]: the third state word feeds the high counter and
                # is passed through to the output state unchanged.
                high_state = initial_state[2]
                output_state[2] = high_state
                high_ctr[0] = _u32(high_state & T.uint64(0xFFFFFFFF))
                high_ctr[1] = _u32(high_state >> T.uint64(32))
            else:
                high_ctr[0] = key_0
                high_ctr[1] = key_1
            for block in T.serial(num_blocks):
                counter = state_counter + _u64(block)
                ctr[0] = _u32(counter & T.uint64(0xFFFFFFFF))
                ctr[1] = _u32(counter >> T.uint64(32))
                ctr[2] = high_ctr[0]
                ctr[3] = high_ctr[1]
                keys[0] = key_0
                keys[1] = key_1
                for _round in T.serial(10):
                    prod_0 = T.uint64(mul_a) * _u64(ctr[0])
                    prod_1 = T.uint64(mul_b) * _u64(ctr[2])
                    new_0 = _u32(prod_1 >> T.uint64(32)) ^ ctr[1] ^ keys[0]
                    new_1 = _u32(prod_1 & T.uint64(0xFFFFFFFF))
                    new_2 = _u32(prod_0 >> T.uint64(32)) ^ ctr[3] ^ keys[1]
                    new_3 = _u32(prod_0 & T.uint64(0xFFFFFFFF))
                    ctr[0] = new_0
                    ctr[1] = new_1
                    ctr[2] = new_2
                    ctr[3] = new_3
                    keys[0] = keys[0] + T.uint32(weyl_a)
                    keys[1] = keys[1] + T.uint32(weyl_b)
                for write_index in T.serial(writes_per_block):
                    element = block * writes_per_block + write_index
                    if element < total:
                        out_flat[element] = _store_value(ctr, write_index)

    return kernel


# pylint: disable=no-else-return
def prepare_dense_matrix_from_sparse(sparse_tensor, sparse_tensor_value, sparse_tensor_type):
    """Prepare sparse indices and dense matrix from TFLite sparse parameters."""
    # The function is implemented based on TFLite sparse parameter specifications
    # Please refer
    # https://github.com/tensorflow/tensorflow/blob/master/tensorflow/lite/schema/schema.fbs#L89
    # for details about each parameters
    sparsity = sparse_tensor.Sparsity()
    dense_shape = sparse_tensor.ShapeAsNumpy()
    orig_rank = len(dense_shape)

    # The traversal order of the dimensions defined in the `shape` field of the to be dense tensor.
    traversal_order = sparsity.TraversalOrderAsNumpy()

    # For an n-dimensional tensor with a k-dimensional block (0 <= k <= n),
    # stores how a block dimension in (dn, ..., dn+k-1) maps to the original
    # tensor dimension in (d0, ..., dn). It's stored in the order of (dn, ..., dn+k-1).
    # If not block-sparse, this field is NULL.
    block_map = sparsity.BlockMapAsNumpy()

    total_rank = sparsity.TraversalOrderLength()
    dense_mat = np.full(shape=dense_shape, fill_value=0, dtype=sparse_tensor_type).flatten()

    from enum import Enum

    # NOTE: Here the Vector term is borrowed from TFLite spec.
    class VectorType(Enum):
        Empty = 0
        Int32 = 1
        Uint16 = 2
        Uint8 = 3

    def _get_vector_flag(v_type):
        if VectorType(v_type) == VectorType.Int32:
            return N.Int32Flags
        elif VectorType(v_type) == VectorType.Uint16:
            return N.Uint16Flags
        elif VectorType(v_type) == VectorType.Uint8:
            return N.Uint8Flags
        else:
            raise tvm.error.OpNotImplemented(f"The provided type {v_type} is not supported")

    def _get_flattened_index(indices, shape):
        index = 0
        sub_elements = 1
        for i in reversed(range(0, len(dense_shape))):
            index += indices[i] * sub_elements
            sub_elements *= shape[i]
        return index

    # DimensionMetadata per dimension: the metadata needed for
    #     each dimension to locate the non-zero values in the original dense tensor
    #     inline with traversal order parameter.
    #
    # sp_format has 2 possible values: {DENSE = 0, SPARSE_CSR = 1}
    # If format = DENSE{0} : DenseSize represents size of that dimension
    # If format = SPARSE_CSR{1} : array_segments represents how to segment the indices array,
    #      each segment corresponds to one element in the previous dimension. array_indices
    #      represents the index of the non-zero elements within this dimension
    #      (as those in the CSR matrix format, where the first array is row pointers
    #       and the second array is column indices).
    sp_format = np.zeros(sparsity.DimMetadataLength())
    dim_metadata = [None] * (2 * sparsity.DimMetadataLength())

    # Below loop will fetch all meta data per dimension based on format type
    # Dense or Sparse and will put it in an agnostic array for easy access
    # while preparing dense buffer or indices.
    for i in range(sparsity.DimMetadataLength()):
        sp_format[i] = sparsity.DimMetadata(i).Format()
        if sp_format[i] == 0:
            dim_metadata[2 * i] = [sparsity.DimMetadata(i).DenseSize()]
        else:
            from flatbuffers import number_types as N

            dim_metadata[2 * i] = (
                sparsity.DimMetadata(i)
                .ArraySegments()
                .GetVectorAsNumpy(
                    flags=_get_vector_flag(sparsity.DimMetadata(i).ArraySegmentsType()), off=4
                )
            )
            dim_metadata[2 * i + 1] = (
                sparsity.DimMetadata(i)
                .ArrayIndices()
                .GetVectorAsNumpy(
                    flags=_get_vector_flag(sparsity.DimMetadata(i).ArrayIndicesType()), off=4
                )
            )

    block_dim = 0
    block_size = np.zeros(sparsity.BlockMapLength())

    # Block size parameter if encoded in BSR format
    for i in range(orig_rank):
        if block_dim < sparsity.BlockMapLength() and block_map[block_dim] == i:
            orig_dim = traversal_order[orig_rank + block_dim]
            block_size[block_dim] = sparsity.DimMetadata(orig_dim).DenseSize()
            block_dim += 1

    indices_list = []

    # Below function iterates through each applicable indices per dimension
    # based on format type specified and finally produce the dense matrix and the NZ indices.
    def _def_prepare_dense_matrix_from_sparse(indices, level, prev_idx):
        if level == len(indices):
            start_pos = 0
            orig_idx = np.zeros(orig_rank, dtype="int32")
            while start_pos < orig_rank:
                orig_idx[traversal_order[start_pos]] = indices[start_pos]
                start_pos += 1
            while start_pos < len(indices):
                block_idx = traversal_order[start_pos] - orig_rank
                orig_dim = block_map[block_idx]
                orig_idx[orig_dim] = orig_idx[orig_dim] * block_size[block_idx] + indices[start_pos]
                start_pos += 1
            indices_list.append(orig_idx)
            nonlocal value_idx
            dense_mat[_get_flattened_index(orig_idx, dense_shape)] = sparse_tensor_value[value_idx]
            value_idx += 1
        else:
            metadata_idx = 2 * level
            if sp_format[level] == 0:
                shape_of_level = dim_metadata[metadata_idx][0]
                for idx in range(shape_of_level):
                    indices[level] = idx
                    _def_prepare_dense_matrix_from_sparse(
                        indices, level + 1, prev_idx * shape_of_level + idx
                    )
            else:
                array_segments = dim_metadata[metadata_idx]
                array_indices = dim_metadata[metadata_idx + 1]
                for idx in range(array_segments[prev_idx], array_segments[prev_idx + 1]):
                    indices[level] = array_indices[idx]
                    _def_prepare_dense_matrix_from_sparse(indices, level + 1, idx)

    indices = np.zeros(total_rank)
    value_idx = 0
    _def_prepare_dense_matrix_from_sparse(indices, 0, 0)
    return np.array(indices_list, dtype="int32"), dense_mat.reshape(dense_shape)


def get_scalar_from_constant(expr):
    """Returns scalar value from Relax constant scalar."""
    assert isinstance(expr, relax.Constant) and not expr.data.shape, (
        "Expr is not a constant scalar."
    )
    value = expr.data.numpy()
    assert value.dtype == np.dtype(np.int32) or value.dtype == np.dtype(np.float32), (
        "value must be float32/int32"
    )
    return value.item(0)


def get_tensor_from_constant(expr):
    """Returns tensor of values from Relax constant node."""
    assert isinstance(expr, relax.const)
    value = expr.data.numpy()
    assert value.dtype == np.dtype(np.int32) or value.dtype == np.dtype(np.float32), (
        "value must be float32/int32"
    )
    return value


def build_str_map(obj):
    """Build string map of TFLite enum int value

    Parameters
    ----------
    obj:
        TFLite class which contains enum int value, such as BuiltInOptions

    Returns
    -------
        String representation map of TFLite class enum int value
    """
    ret = {}
    for field_name in dir(obj):
        if not field_name.startswith("_"):
            field_value = getattr(obj, field_name)
            if isinstance(field_value, int):
                ret[field_value] = field_name
    return ret


# SAME padding: https://www.tensorflow.org/api_guides/python/nn
def get_pad_value(data, kernel, stride):
    """Get the pad tuple of value for SAME padding

    Parameters
    ----------
    data:
        1D input data

    kernel:
        1D input kernel

    stride:
        1D input stride

    Returns
    -------
        pad tuple of value
    """

    out = math.ceil(float(data) / float(stride))
    pad = max(0, (out - 1) * stride + kernel - data)
    pad_before = pad // 2
    pad_after = pad - pad_before
    return pad_before, pad_after


def get_tensor_name(subgraph, tensor_idx):
    """Get the tensor name.

    Parameters
    ----------
    subgraph:
        tflite.Subgraph.Subgraph

    tensor:
        tensor index in subgraph

    Returns
    -------
        tensor name in UTF-8 encoding
    """
    tensor_name = subgraph.Tensors(tensor_idx).Name()
    if tensor_name is not None:
        tensor_name = tensor_name.decode("utf-8")
    else:
        tensor_name = "tvmgen_tensor_" + str(tensor_idx)
    return tensor_name


def _decode_type(n):
    _tflite_m = {
        0: "float32",
        1: "float16",
        2: "int32",
        3: "uint8",
        4: "int64",
        5: "string",
        6: "bool",
        7: "int16",
        8: "complex64",
        9: "int8",
        12: "uint64",
        15: "uint32",
    }
    return _tflite_m[n]


def _input_type(model):
    subgraph_count = model.SubgraphsLength()
    assert subgraph_count > 0
    shape_dict = {}
    dtype_dict = {}
    subgraph = model.Subgraphs(0)
    inputs_count = subgraph.InputsLength()
    # TFLite subgraphs can validly have zero inputs (e.g. constant-only RANGE models).
    for input_index in range(inputs_count):
        input_ = subgraph.Inputs(input_index)
        assert subgraph.TensorsLength() > input_
        tensor = subgraph.Tensors(input_)
        input_shape = tuple(tensor.ShapeAsNumpy())
        tensor_type = tensor.Type()
        input_name = get_tensor_name(subgraph, input_)
        input_dtype = _decode_type(tensor_type)
        shape_dict[input_name] = input_shape
        dtype_dict[input_name] = input_dtype

    return shape_dict, dtype_dict


def from_tflite(
    model,
    shape_dict: dict[str, tuple[int]] | None = None,
    dtype_dict: dict[str, str] | None = None,
    op_converter=OperatorConverter,
):
    """Convert from tflite model into compatible relax Function.

    Parameters
    ----------
    model:
        tflite.Model or tflite.Model.Model (depending on tflite version)

    shape_dict : dict of str to int list/tuple
        Input shapes of the model.

    dtype_dict : dict of str to str
        Input types of the model.

    Returns
    -------
    mod : tvm.IRModule
        The relax module for compilation.

    params : dict of str to tvm.nd.NDArray
        The parameter dict to be used by relax

    Examples
    --------
    Users can use TFLiteConverter to convert a concrete function followed by
    tflite.Model.Model.GetRootAsModel to get a tflite.Model object
    The following codes show how to convert a simple concrete function to a Relax program.

    .. code-block:: python

        import tensorflow as tf
        from tvm.relax.frontend.tflite import from_tflite


        # A concrete function be defined from network architecture or invoking a model
        # from keras applications

        # Define a tf.Module containing a tf.function with network definition
        class Conv2DModule(tf.Module):
            @tf.function(
                input_signature=[
                    tf.TensorSpec(shape=(1, 128, 128, 32), dtype=tf.float32),
                    tf.TensorSpec(shape=(3, 3, 32, 32), dtype=tf.float32),
                ]
            )
            def func(self, data, kernel):
                return tf.nn.conv2d(
                    input=(1, 128, 128, 32),
                    filters=(3, 3, 32, 32),
                    data_format="NHWC",
                    strides=(1, 1, 1, 1),
                    padding="SAME",
                )
        concrete_func = Conv2DModule().func.get_concrete_function()

        # Alternatively you can use tensorflow.keras.application wrapped arounf a tf.function
        class NetworkModule(tf.Module):
            def __init__(self):
                self.model = net(weights="imagenet", include_top=True)

            @tf.function
            def func(self, data):
                return self.model(data, training=False)

        model = NetworkModule()
        concrete_func = model.func.get_concrete_function(
            tf.TensorSpec(shape=shape, dtype=tf.float32)
        )


        # Which ever the way we have created the concrete function make tflite.Model as shown below

        converter = tf.lite.TFLiteConverter.from_concrete_functions([concrete_func])
        converter.target_spec.supported_ops = [
            tf.lite.OpsSet.TFLITE_BUILTINS,
            tf.lite.OpsSet.SELECT_TF_OPS,
        ]
        tflite_model = tf.lite.Model.Model.GetRootAsModel(converter.convert(), 0)

        # Call the imported to conver the tflite.Model to relax.Module
        mod = from_tflite(tflite_model)

        # Print out the imported model.
        print(mod.script())

    """
    # TFLite.Model.Model has changed to TFLite.Model from 1.14 to 2.1
    try:
        import tflite

        assert isinstance(model, tflite.Model)
    except ImportError as err:
        raise ImportError(
            "tflite is required by the TFLite frontend. Install it with: pip install tflite"
        ) from err
    except TypeError:
        import tflite.Model

        assert isinstance(model, tflite.Model.Model)

    _shape_dict, _dtype_dict = _input_type(model)
    if shape_dict is not None:
        _shape_dict.update(shape_dict)
    if dtype_dict is not None:
        _dtype_dict.update(dtype_dict)

    # Only Subgraphs(0) is converted into Relax main. Additional subgraphs are
    # region/control-flow bodies referenced by specific TFLite ops and are
    # consumed by those op converters as needed.
    assert model.SubgraphsLength() >= 1, "TFLite model must contain at least one subgraph"
    subgraph = model.Subgraphs(0)

    # model inputs / outputs
    model_inputs = subgraph.InputsAsNumpy()
    model_outputs = subgraph.OutputsAsNumpy()

    bb = relax.BlockBuilder()  # pylint: disable=invalid-name

    with bb.function("main"):
        input_list = []
        with bb.dataflow() as df:  # noqa: F841  # pylint: disable=invalid-name, unused-variable
            exp_tab = ExprTable()
            for model_input in model_inputs:
                model_input_name = get_tensor_name(subgraph, model_input)
                shape = _shape_dict[model_input_name] if model_input_name in _shape_dict else None
                dtype = (
                    _dtype_dict[model_input_name] if model_input_name in _dtype_dict else "float32"
                )
                if dtype == "complex64":
                    dtype = "float32"
                    if shape is not None:
                        shape = tuple(shape) + (2,)
                if dtype == "string":
                    # Relax has no string tensor type, so TFLite TensorType.STRING graph
                    # inputs cannot be represented. This also covers runtime string queries
                    # for ops like HASHTABLE_FIND, whose constant-foldable subset is handled
                    # in the op converter.
                    raise tvm.error.OpNotImplemented(
                        "Relax TFLite frontend does not support TensorType.STRING graph inputs "
                        "(e.g. runtime string queries)"
                    )
                input_var = relax.Var(
                    name=model_input_name,
                    ty=relax.TensorType(shape=shape, dtype=dtype),
                )
                exp_tab.set_expr(model_input_name, input_var)
                input_list.append(input_var)

            # op code in model
            op_converter = op_converter(model, subgraph, exp_tab, bb)
            op_converter.check_unsupported_ops()
            op_converter.convert_op_to_relax()

            # params and outputs
            # Resolve outputs through tensor wrappers so constant/prefetched outputs are handled.
            output_tensors = op_converter.get_tensors(model_outputs)
            outputs = [op_converter.get_tensor_expr(tensor) for tensor in output_tensors]
            outputs = outputs[0] if len(outputs) == 1 else relax.Tuple(outputs)
            output_var = bb.emit_output(outputs)

        bb.emit_func_output(output_var, input_list)

        relax_mod = bb.get()
        # Attach attributes.
        param_value_list = []
        if exp_tab.params:
            _, param_value_list = map(list, zip(*exp_tab.params.values()))
        func_attrs = {}
        func_attrs["num_input"] = len(input_list)
        func_attrs["params"] = [tvm.runtime.tensor(arr) for arr in param_value_list]
        relax_mod["main"] = relax_mod["main"].with_attrs(func_attrs)

        return relax_mod
