def apply_unitary(
    unitary_value: Any,
    args: ApplyUnitaryArgs,
    default: np.ndarray | TDefault = RaiseTypeErrorIfNotProvided,
    *,
    allow_decompose: bool = True,
) -> np.ndarray | TDefault:
    """High performance left-multiplication of a unitary effect onto a tensor.

    Applies the unitary effect of `unitary_value` to the tensor specified in
    `args` by using the following strategies:

    A. Try to use `unitary_value._apply_unitary_(args)`.
        Case a) Method not present or returns `NotImplemented`.
            Continue to next strategy.
        Case b) Method returns `None`.
            Conclude `unitary_value` has no unitary effect.
        Case c) Method returns a numpy array.
            Forward the successful result to the caller.

    B. Try to use `unitary_value._unitary_()`.
        Case a) Method not present or returns `NotImplemented`.
            Continue to next strategy.
        Case b) Method returns `None`.
            Conclude `unitary_value` has no unitary effect.
        Case c) Method returns a numpy array.
            Multiply the matrix onto the target tensor and return to the caller.

    C. Try to use `unitary_value._decompose_()` (if `allow_decompose`).
        Case a) Method not present or returns `NotImplemented` or `None`.
            Continue to next strategy.
        Case b) Method returns an OP_TREE.
            Delegate to `cirq.apply_unitaries`.

    D. Conclude that `unitary_value` has no unitary effect.

    The order that the strategies are tried depends on the number of qubits
    being operated on. For small numbers of qubits (4 or less) the order is
    ABCD. For larger numbers of qubits the order is ACBD (because it is expected
    that decomposing will outperform generating the raw matrix).

    Args:
        unitary_value: The value with a unitary effect to apply to the target.
        args: A mutable `cirq.ApplyUnitaryArgs` object describing the target
            tensor, available workspace, and axes to operate on. The attributes
            of this object will be mutated as part of computing the result.
        default: What should be returned if `unitary_value` doesn't have a
            unitary effect. If not specified, a TypeError is raised instead of
            returning a default value.
        allow_decompose: Defaults to True. If set to False, and applying the
            unitary effect requires decomposing the object, the method will
            pretend the object has no unitary effect.

    Returns:
        If the receiving object does not have a unitary effect, then the
        specified default value is returned (or a TypeError is raised). If
        this occurs, then `target_tensor` should not have been mutated.

        Otherwise the result is the `np.ndarray` instance storing the result.
        This may be `args.target_tensor`, `args.available_workspace`, or some
        other numpy array. It is the caller's responsibility to correctly handle
        all three of these cases. In all cases `args.target_tensor` and
        `args.available_buffer` may have been mutated.

    Raises:
        TypeError: `unitary_value` doesn't have a unitary effect and `default`
            wasn't specified.
    """
    # Decide on order to attempt application strategies.
    if len(args.axes) <= 4:
        strats = [
            _strat_apply_unitary_from_apply_unitary,
            _strat_apply_unitary_from_unitary,
            _strat_apply_unitary_from_decompose,
        ]
    else:
        strats = [
            _strat_apply_unitary_from_apply_unitary,
            _strat_apply_unitary_from_decompose,
            _strat_apply_unitary_from_unitary,
        ]
    if not allow_decompose:
        strats.remove(_strat_apply_unitary_from_decompose)

    # Try each strategy, stopping if one works.
    # Also catch downcasting warnings and throw an error: #2041
    with warnings.catch_warnings():
        warnings.filterwarnings(action="error", category=np.exceptions.ComplexWarning)
        for strat in strats:
            result = strat(unitary_value, args)
            if result is None:
                break
            if result is not NotImplemented:
                return result

    # Don't know how to apply. Fallback to specified default behavior.
    if default is not RaiseTypeErrorIfNotProvided:
        return default
    raise TypeError(
        "cirq.apply_unitary failed. "
        "Value doesn't have a (non-parameterized) unitary effect.\n"
        "\n"
        f"type: {type(unitary_value)}\n"
        f"value: {unitary_value!r}\n"
        "\n"
        "The value failed to satisfy any of the following criteria:\n"
        "- An `_apply_unitary_(self, args) method that returned a value "
        "besides None or NotImplemented.\n"
        "- A `_unitary_(self)` method that returned a value "
        "besides None or NotImplemented.\n"
        "- A `_decompose_(self)` method that returned a "
        "list of unitary operations.\n"
    )
High performance left-multiplication of a unitary effect onto a tensor.

    Applies the unitary effect of `unitary_value` to the tensor specified in
    `args` by using the following strategies:

    A. Try to use `unitary_value._apply_unitary_(args)`.
        Case a) Method not present or returns `NotImplemented`.
            Continue to next strategy.
        Case b) Method returns `None`.
            Conclude `unitary_value` has no unitary effect.
        Case c) Method returns a numpy array.
            Forward the successful result to the caller.

    B. Try to use `unitary_value._unitary_()`.
        Case a) Method not present or returns `NotImplemented`.
            Continue to next strategy.
        Case b) Method returns `None`.
            Conclude `unitary_value` has no unitary effect.
        Case c) Method returns a numpy array.
            Multiply the matrix onto the target tensor and return to the caller.

    C. Try to use `unitary_value._decompose_()` (if `allow_decompose`).
        Case a) Method not present or returns `NotImplemented` or `None`.
            Continue to next strategy.
        Case b) Method returns an OP_TREE.
            Delegate to `cirq.apply_unitaries`.

    D. Conclude that `unitary_value` has no unitary effect.

    The order that the strategies are tried depends on the number of qubits
    being operated on. For small numbers of qubits (4 or less) the order is
    ABCD. For larger numbers of qubits the order is ACBD (because it is expected
    that decomposing will outperform generating the raw matrix).

    Args:
        unitary_value: The value with a unitary effect to apply to the target.
        args: A mutable `cirq.ApplyUnitaryArgs` object describing the target
            tensor, available workspace, and axes to operate on. The attributes
            of this object will be mutated as part of computing the result.
        default: What should be returned if `unitary_value` doesn't have a
            unitary effect. If not specified, a TypeError is raised instead of
            returning a default value.
        allow_decompose: Defaults to True. If set to False, and applying the
            unitary effect requires decomposing the object, the method will
            pretend the object has no unitary effect.

    Returns:
        If the receiving object does not have a unitary effect, then the
        specified default value is returned (or a TypeError is raised). If
        this occurs, then `target_tensor` should not have been mutated.

        Otherwise the result is the `np.ndarray` instance storing the result.
        This may be `args.target_tensor`, `args.available_workspace`, or some
        other numpy array. It is the caller's responsibility to correctly handle
        all three of these cases. In all cases `args.target_tensor` and
        `args.available_buffer` may have been mutated.

    Raises:
        TypeError: `unitary_value` doesn't have a unitary effect and `default`
            wasn't specified.
    
cirq-core/cirq/protocols/apply_unitary_protocol.py