Source code for skbase.base._meta

#!/usr/bin/env python3 -u
# -*- coding: utf-8 -*-
# copyright: skbase developers, BSD-3-Clause License (see LICENSE file)
# BaseMetaObject and BaseMetaEstimator reuse code developed in scikit-learn and sktime.
# These elements are copyrighted by the respective
# scikit-learn developers (BSD-3-Clause License) and sktime (BSD-3-Clause) developers.
# For conditions see licensing:
# scikit-learn: https://github.com/scikit-learn/scikit-learn/blob/main/COPYING
# sktime:  https://github.com/sktime/sktime/blob/main/LICENSE
"""Implements functionality for meta objects composed of other objects."""

from inspect import isclass

from skbase.base._base import BaseEstimator, BaseObject
from skbase.base._pretty_printing._object_html_repr import _VisualBlock
from skbase.utils._iter import _format_seq_to_str, make_strings_unique
from skbase.validate import is_named_object_tuple

__author__ = ["mloning", "fkiraly", "RNKuhns"]
__all__ = ["BaseMetaEstimator", "BaseMetaObject"]


class _MetaObjectMixin:
    """Parameter and tag management for objects composed of named objects.

    Allows objects to get and set nested parameters when a parameter of the the
    class has values that follow the named object specification. For example,
    in a pipeline class with the the "step" parameter accepting named objects,
    this would allow `get_params` and `set_params` to retrieve and update the
    parameters of the objects in each step.

    Notes
    -----
    Partly adapted from sklearn utils.metaestimator.py and sktime's
    _HeterogenousMetaEstimator.
    """

    # for default get_params/set_params from _HeterogenousMetaEstimator
    # _steps_attr points to the attribute of self
    # which contains the heterogeneous set of estimators
    # this must be an iterable of (name: str, estimator) pairs for the default
    _tags = {"named_object_parameters": "steps"}

    def is_composite(self):
        """Check if the object is composite.

        A composite object is an object which contains objects as parameter values.

        Returns
        -------
        bool
            Whether self contains a parameter whose value is a BaseObject,
            list of (str, BaseObject) tuples or dict[str, BaseObject].
        """
        # children of this class are always composite
        return True

    def get_params(self, deep=True):
        """Get a dict of parameters values for this object.

        This expands on `get_params` of standard `BaseObject` by also retrieving
        components parameters when ``deep=True`` a component's follows the named
        object API (either sequence of str, BaseObject tuples or dict[str, BaseObject]).

        Parameters
        ----------
        deep : bool, default=True
            Whether to return parameters of components.

            - If True, will return a dict of parameter name : value for this object,
              including parameters of components.
            - If False, will return a dict of parameter name : value for this object,
              but not include parameters of components.

        Returns
        -------
        dict[str, Any]
            Dictionary of parameter name and value pairs. Includes direct parameters
            and indirect parameters whose values implement `get_params` or follow
            the named object API (either sequence of str, BaseObject tuples or
            dict[str, BaseObject]).

            - If ``deep=False`` the name-value pairs for this object's direct
              parameters (you can see these via `get_param_names`) are returned.
            - If ``deep=True`` then the parameter name-value pairs are returned
              for direct and component (indirect) parameters.

              - When a BaseObject's direct parameter value implements `get_params`
                the component parameters are returned as
                `[direct_param_name]__[component_param_name]` for 1st level components.
                Arbitrary levels of component recursion are supported (if the
                component has parameter's whose values are objects that implement
                `get_params`). In this case, return parameters follow
                `[direct_param_name]__[component_param_name]__[param_name]` format.
              - When a BaseObject's direct parameter value is a sequence of
                (name, BaseObject) tuples or dict[str, BaseObject] the parameters name
                and value pairs of all component objects are returned. The
                parameter naming follows ``scikit-learn`` convention of treating
                named component objects like they are direct parameters; therefore,
                the names are assigned as `[component_param_name]__[param_name]`.
        """
        # Use tag interface that will be available when mixin is used
        named_object_attr = self.get_tag("named_object_parameters")  # type: ignore
        return self._get_params(named_object_attr, deep=deep)

    def set_params(self, **kwargs):
        """Set the object's direct parameters and the parameters of components.

        Valid parameter keys can be listed with ``get_params()``.

        Like `BaseObject` implementation it allows values of indirect parameters
        of a component to be set when a parameter's value is an object that
        implements `set_params`. This also also expands the functionality to
        allow parameter to allow the indirect parameters of components to be set
        when a parameter's values follow the named object API (either sequence
        of str, BaseObject tuples or dict[str, BaseObject]).

        Returns
        -------
        Self
            Instance of self.
        """
        # Use tag interface that will be available when mixin is used
        named_object_attr = self.get_tag("named_object_parameters")  # type: ignore
        return self._set_params(named_object_attr, **kwargs)

    def _get_fitted_params(self):
        """Get fitted parameters.

        Method implements logic to retrieve fitted parameters. It is called from
        get_fitted_params.

        Returns
        -------
        dict[str, Any]
            Fitted parameters where keys represent the parameters name (with
            trailing "_" removed) and the corresponding value is the value of
            the parameter learned during fit.
        """
        fitted_params = self._get_fitted_params_default()

        fitted_named_object_attr = self.get_tag(
            "fitted_named_object_parameters"
        )  # type: ignore

        named_objects_fitted_params = self._get_params(
            fitted_named_object_attr, fitted=True
        )

        fitted_params.update(named_objects_fitted_params)

        return fitted_params

    def _get_params(self, attr, deep=True, fitted=False):
        """Logic for getting parameters on meta objects/estimators.

        Separates out logic for parameter getting on meta objects from public API point.

        Parameters
        ----------
        attr : str
            Name of parameter whose values should contain named objects.
        deep : bool, default=True
            Whether to return parameters of components.

            - If True, will return a dict of parameter name : value for this object,
              including parameters of components.
            - If False, will return a dict of parameter name : value for this object,
              but not include parameters of components.

        fitted : bool, default=False
            Whether to retrieve the fitted params learned when `fit` is called on
            ``estimator`` instead of the instances parameters.

            - If False, then retrieve instance parameters like typical.
            - If True, the retrieves the parameters learned during "fitting" and
              stored in attributes ending in "_" (private attributes excluded).

        Returns
        -------
        dict[str, Any]
            Dictionary of parameter name and value pairs. Includes direct parameters
            and indirect parameters whose values implement `get_params` or follow
            the named object API (either sequence of str, BaseObject tuples or
            dict[str, BaseObject]).
        """
        # Set variables that let us use same code for retrieving params or fitted params
        if fitted:
            method_shallow = "_get_fitted_params"
            method_public = "get_fitted_params"
            deepkw = {}
        else:
            method_shallow = "get_params"
            method_public = "get_params"
            deepkw = {"deep": deep}

        # Get the direct params/fitted params
        out = getattr(super(), method_shallow)(**deepkw)

        if deep and hasattr(self, attr):
            named_objects = getattr(self, attr)
            named_objects_ = [
                (x[0], x[1])
                for x in self._coerce_to_named_object_tuples(
                    named_objects, make_unique=False
                )
            ]
            out.update(named_objects_)
            for name, obj in named_objects_:
                # checks estimator has the method we want to call
                cond1 = hasattr(obj, method_public)
                # checks estimator is fitted if calling get_fitted_params
                is_fitted = hasattr(obj, "is_fitted") and obj.is_fitted
                # if we call get_params and not get_fitted_params, this is True
                cond2 = not fitted or is_fitted
                # check both conditions together
                if cond1 and cond2:
                    for key, value in getattr(obj, method_public)(**deepkw).items():
                        out["%s__%s" % (name, key)] = value
        return out

    def _set_params(self, attr: str, **params):
        """Logic for setting parameters on meta objects/estimators.

        Separates out logic for parameter setting on meta objects from public API point.

        Parameters
        ----------
        attr : str
            Name of parameter whose values should contain named objects.

        Returns
        -------
        Self
            Instance of self.
        """
        if not params:
            return self

        # Track whether we handle any params locally.
        # This is needed because we pop params before calling super(),
        # and if we pop ALL params, super() will return early without
        # calling reset(), which would be inconsistent with BaseObject behavior.
        # See issue #412.
        params_handled_locally = False

        # Ensure strict ordering of parameter setting:
        # 1. All steps
        if attr in params:
            setattr(self, attr, params.pop(attr))
            params_handled_locally = True
        # 2. Step replacement
        items = getattr(self, attr)
        names = []
        if items and isinstance(items, (list, tuple)):
            names = list(zip(*items))[0]
        for name in list(params.keys()):
            if "__" not in name and name in names:
                self._replace_object(attr, name, params.pop(name))
                params_handled_locally = True
        # 3. Step parameters and other initialisation arguments
        super().set_params(**params)  # type: ignore

        # If we handled params locally and super() got nothing (empty dict),
        # it would have returned early without calling reset().
        # To maintain consistency with BaseObject, we must call reset() ourselves.
        if params_handled_locally and not params:
            self.reset()

        return self

    def _replace_object(self, attr, name, new_val) -> None:
        """Replace an object in attribute that contains named objects.

        Replaces the object with name ``name`` in attribute ``attr`` with ``new_val``.
        ``getattr(self, attr)`` is assumed to contain a list of (str, object) tuples.

        Directly mutates the list pointed to by ``attr``, in place.

        Parameters
        ----------
        attr : str
            Name of parameter whose values should contain named objects.
        name : str
            Name of object to replace.
        new_val : Any
            New value to replace object with.

        Returns
        -------
        None
        """
        # assumes `name` is a valid object name
        new_objects = list(getattr(self, attr))
        for i, obj_tpl in enumerate(new_objects):
            object_name = obj_tpl[0]
            if object_name == name:
                new_tpl = list(obj_tpl)
                new_tpl[1] = new_val
                new_objects[i] = tuple(new_tpl)
                break
        setattr(self, attr, new_objects)

    def _check_names(self, names, make_unique=True):
        """Validate that names of named objects follow API rules.

        The names for named objects should:

        - Be unique,
        - Not be the name of one of the object's direct parameters,
        - Not contain "__" (which is reserved to denote components in get/set params).

        Parameters
        ----------
        names : list[str] | tuple[str]
            The sequence of names from named objects.
        make_unique : bool, default=True
            Whether to coerce names to unique strings if they are not.

        Returns
        -------
        list[str] | tuple[str]
            A sequence of unique string names that follow named object API rules.
        """
        if len(set(names)) != len(names):
            raise ValueError("Names provided are not unique: {0!r}".format(list(names)))
        # Get names that match direct parameter
        invalid_names = set(names).intersection(self.get_params(deep=False))
        invalid_names = invalid_names.union({name for name in names if "__" in name})
        if invalid_names:
            raise ValueError(
                "Object names conflict with constructor argument or "
                "contain '__': {0!r}".format(sorted(invalid_names))
            )
        if make_unique:
            names = make_strings_unique(names)

        return names

    def _coerce_object_tuple(self, obj, clone=False):
        """Coerce object or (str, BaseObject) tuple to (str, BaseObject) tuple.

        Used to make sure input will work with expected named object tuple API format.

        Parameters
        ----------
        objs : BaseObject or (str, BaseObject) tuple
            Assumes that this has been checked, no checks are performed.
        clone : bool, default = False.
            Whether to return clone of estimator in obj (True) or a reference (False).

        Returns
        -------
        tuple[str, BaseObject]
            Named object tuple.

            - If `obj` was an object then returns (obj.__class__.__name__, obj).
            - If `obj` was already a (name, object) tuple it is returned (a copy
              is returned if ``clone=True``).
        """
        if isinstance(obj, tuple) and len(obj) >= 2:
            _obj = obj[1]
            name = obj[0]

        else:
            if isinstance(obj, tuple) and len(obj) == 1:
                _obj = obj[0]
            else:
                _obj = obj
            name = type(_obj).__name__

        if clone:
            _obj = _obj.clone()
        return (name, _obj)

    def _check_objects(
        self,
        objs,
        attr_name=None,
        cls_type=None,
        allow_dict=False,
        allow_mix=True,
        allow_empty=False,
        clone=True,
    ):
        """Check that objects is a list of objects or sequence of named objects.

        This method serves as an orchestration layer that handles tag resolution
        and delegates validation to _validate_and_coerce_objects.

        Note: This method is an exception to the standard pattern where private
        methods receive all configuration as parameters. Since _check_objects
        serves as a semi-public entry point (used in tests and potentially by
        subclasses), it handles tag resolution internally for convenience.

        Parameters
        ----------
        objs : Any
            Should be list of objects, a list of (str, object) tuples or a
            dict[str, objects]. Any objects should `cls_type` class.
        attr_name : str, default=None
            Name of checked attribute in error messages. If None, the value is
            auto-detected from the "named_object_parameters" tag.
        cls_type : class or tuple of classes, default=BaseEstimator.
            class(es) that all objects are checked to be an instance of.
        allow_dict : bool, default=False
            Whether ``objs`` can be a dictionary mapping str names to objects.
        allow_mix : bool, default=True
            Whether mix of objects and (str, objects) is allowed in ``objs``.
        allow_empty : bool, default=False
            Whether ``objs`` can be empty.
        clone : bool, default=True
            Whether objects or named objects in ``objs`` are returned as clones
            (True) or references (False).

        Returns
        -------
        list[tuple[str, BaseObject]]
            List of tuples following named object API.

            - If `objs` was already a list of (str, object) tuples then either the
              same named objects (as with other cases cloned versions are
              returned if ``clone=True``).
            - If `objs` was a dict[str, object] then the named objects are unpacked
              into a list of (str, object) tuples.
            - If `objs` was a list of objects then string names were generated based
               on the object's class names (with coercion to unique strings if
               necessary).

        Raises
        ------
        TypeError
            If `objs` is not a list of (str, object) tuples or a dict[str, objects].
            Also raised if objects in `objs` are not instances of `cls_type`
            or `cls_type is not None, a class or tuple of classes.
            Also raised if attr_name is None and the "named_object_parameters"
            tag is not set to a non-empty string.
        """
        if attr_name is None:
            attr_name = self.get_tag("named_object_parameters")

            # Validate that the tag returned a usable value
            if not isinstance(attr_name, str) or not attr_name:
                raise TypeError(
                    "The 'attr_name' parameter was not provided and could not be "
                    "auto-detected. Either provide 'attr_name' explicitly or ensure "
                    "the 'named_object_parameters' tag is set to a non-empty string. "
                    f"Current tag value: {attr_name!r}"
                )

        validated_objs = self._validate_objects(
            objs=objs,
            attr_name=attr_name,
            cls_type=cls_type,
            allow_dict=allow_dict,
            allow_mix=allow_mix,
            allow_empty=allow_empty,
        )

        # Coercion (transform to named object tuples)
        return self._coerce_to_named_object_tuples(
            validated_objs, clone=clone, make_unique=True
        )

    @staticmethod
    def _validate_objects(
        objs,
        attr_name,
        cls_type=None,
        allow_dict=False,
        allow_mix=True,
        allow_empty=False,
    ):
        """Validate objects without coercing to named object tuples.

        This is pure validation logic with no instance dependencies.
        It validates structure and types but does not transform the objects.

        Parameters
        ----------
        objs : Any
            Should be list of objects, a list of (str, object) tuples or a
            dict[str, objects]. Any objects should be `cls_type` class.
        attr_name : str
            Name of checked attribute in error messages. Must be provided.
        cls_type : class or tuple of classes, optional
            Class(es) that all objects are checked to be an instance of.
            If None, defaults to BaseObject.
        allow_dict : bool, default=False
            Whether ``objs`` can be a dictionary mapping str names to objects.
        allow_mix : bool, default=True
            Whether mix of objects and (str, objects) is allowed in ``objs``.
        allow_empty : bool, default=False
            Whether ``objs`` can be empty.

        Returns
        -------
        list or dict
            The validated objects as-is (not coerced to tuples).
            Returns the same type as input after validation.

        Raises
        ------
        TypeError
            If `objs` is not a list of (str, object) tuples or a dict[str, objects].
            Also raised if objects in `objs` are not instances of `cls_type`
            or `cls_type is not None, a class or tuple of classes.
        """
        # Construct error message
        msg = (
            f"Invalid {attr_name!r} attribute, {attr_name!r} should be a list "
            "of objects, or a list of (string, object) tuples. "
        )

        # Determine and validate cls_type
        if cls_type is None:
            cls_type = BaseObject
            _class_name = "BaseObject"
        elif isclass(cls_type):
            _class_name = cls_type.__name__  # type: ignore
        elif isinstance(cls_type, tuple) and all(isclass(c) for c in cls_type):
            _class_name = _format_seq_to_str(
                [c.__name__ for c in cls_type], last_sep="or"
            )
        else:
            raise TypeError("`cls_type` must be a class or tuple of classes.")

        msg += f"All objects in {attr_name!r} must be of type {_class_name}"

        # Check basic structure
        if (
            objs is None
            or (not allow_empty and len(objs) == 0)
            or not (isinstance(objs, list) or (allow_dict and isinstance(objs, dict)))
        ):
            raise TypeError(msg)

        def is_obj_is_tuple(obj):
            """Check whether obj is estimator of right type, or (str, est) tuple."""
            is_est = isinstance(obj, cls_type)
            is_tuple = is_named_object_tuple(obj, object_type=cls_type)

            return is_est, is_tuple

        # Validate dictionary elements
        if isinstance(objs, dict) and not all(
            isinstance(name, str) and isinstance(obj, cls_type)
            for name, obj in objs.items()
        ):
            raise TypeError(msg)

        # Validate list elements
        elif not all(any(is_obj_is_tuple(x)) for x in objs):
            raise TypeError(msg)

        # Check for mixed types if not allowed
        msg_no_mix = (
            f"Elements of {attr_name} must either all be objects, "
            f"or all (str, objects) tuples. A mix of the two is not allowed."
        )
        if not allow_mix and not all(is_obj_is_tuple(x)[0] for x in objs):
            if not all(is_obj_is_tuple(x)[1] for x in objs):
                raise TypeError(msg_no_mix)

        # Return validated objects as-is (coercion happens in _check_objects)
        return objs

    def _get_names_and_objects(self, named_objects, make_unique=False):
        """Return lists of names and object from input that follows named object API.

        Handles input that is dictionary mapping str names of object instances or
        input that is a list of (str, object) tuples.

        Parameters
        ----------
        named_objects : list[tuple[str, object], ...], list[object], dict[str, object]
            The objects whose names should be returned.
        make_unique : bool, default=False
            Whether names should be made unique.

        Returns
        -------
        names : list[str]
            Lists of the names and objects that were input.
        objs : list[BaseObject]
            The
        """
        if isinstance(named_objects, dict):
            names, objs = zip(*named_objects.items())
        else:
            names, objs = zip(*[self._coerce_object_tuple(x) for x in named_objects])

        # Optionally make names unique
        if make_unique:
            names = make_strings_unique(names)
        return list(names), list(objs)

    def _coerce_to_named_object_tuples(self, objs, clone=False, make_unique=True):
        """Coerce sequence of objects or named objects to list of (str, obj) tuples.

        Input that is sequence of objects, list of (str, obj) tuples or
        dict[str, object] will be coerced to list of (str, obj) tuples on return.

        Parameters
        ----------
        objs : list of objects, list of (str, object tuples) or dict[str, object]
            The input should be coerced to list of (str, object) tuples. Should
            be a sequence of objects, or follow named object API.
        clone : bool, default=False.
            Whether objects in the returned list of (str, object) tuples are
            cloned (True) or references (False).
        make_unique : bool, default=True
            Whether the str names in the returned list of (str, object) tuples
            should be coerced to unique str values (if str names in input
            are already unique they will not be changed).

        Returns
        -------
        list[tuple[str, BaseObject]]
            List of tuples following named object API.

            - If `objs` was already a list of (str, object) tuples then either the
              same named objects (as with other cases cloned versions are
              returned if ``clone=True``).
            - If `objs` was a dict[str, object] then the named objects are unpacked
              into a list of (str, object) tuples.
            - If `objs` was a list of objects then string names were generated based
               on the object's class names (with coercion to unique strings if
               necessary).
        """
        if isinstance(objs, dict):
            named_objects = [(k, v) for k, v in objs.items()]
        else:
            # Otherwise get named object format
            named_objects = [
                self._coerce_object_tuple(obj, clone=clone) for obj in objs
            ]
        if make_unique:
            # Unpack names and objects while making names unique
            names, objs = self._get_names_and_objects(
                named_objects, make_unique=make_unique
            )
            # Repack the objects
            named_objects = list(zip(names, objs))
        return named_objects

    def _dunder_concat(
        self,
        other,
        base_class,
        composite_class,
        attr_name="steps",
        concat_order="left",
        composite_params=None,
    ):
        """Logic to concatenate pipelines for dunder parsing.

        This is useful in concrete heterogeneous meta-objects that implement
        dunders for easy concatenation of pipeline-like composites.

        Parameters
        ----------
        other : BaseObject subclass
            An object inheriting from `composite_class` or `base_class`, otherwise
            `NotImplemented` is returned.
        base_class : BaseObject subclass
            Class assumed as base class for self and `other`. ,
            and estimator components of composite_class, in case of concatenation
        composite_class : BaseMetaObject or BaseMetaEstimator subclass
            Class that has parameter `attr_name` stored in attribute of same name
            that contains list of base_class objects, list of (str, base_class)
            tuples, or a mixture thereof.
        attr_name : str, default="steps"
            Name of the attribute that contains base_class objects,
            list of (str, base_class) tuples. Concatenation is done for this attribute.
        concat_order : {"left", "right"}, default="left"
            Specifies ordering for concatenation.

            - If "left", resulting attr_name will be like
              self.attr_name + other.attr_name.
            - If "right", resulting attr_name will be like
              other.attr_name + self.attr_name.

        composite_params : dict, default=None
            Parameters of the composite are always set accordingly
            i.e., contains key-value pairs, and composite_class has key set to value.

        Returns
        -------
        BaseMetaObject or BaseMetaEstimator
            Instance of `composite_class`, where `attr_name` is set so that self and
            other are "concatenated".

            - If other is instance of `composite_class` then instance of
              `composite_class`, where `attr_name` is a concatenation of
              ``self.attr_name`` and ``other.attr_name``.
            - If `other` is instance of `base_class`, then instance of `composite_class`
              is returned where `attr_name` is set so that so that
              composite_class(attr_name=other) is returned.
            - If str are all the class names of est, list of est only is used instead
        """
        # Validate input
        if concat_order not in ["left", "right"]:
            raise ValueError(
                f"`concat_order` must be 'left' or 'right', but found {concat_order!r}."
            )
        if not isinstance(attr_name, str):
            raise TypeError(f"`attr_name` must be str, but found {type(attr_name)}.")
        if not isclass(composite_class):
            raise TypeError("`composite_class` must be a class.")
        if not isclass(base_class):
            raise TypeError("`base_class` must be a class.")
        if not issubclass(composite_class, base_class):
            raise ValueError("`composite_class` must be a subclass of base_class.")
        if not isinstance(self, composite_class):
            raise TypeError("self must be an instance of `composite_class`.")

        def concat(x, y):
            if concat_order == "left":
                return x + y
            else:
                return y + x

        # get attr_name from self and other
        # can be list of ests, list of (str, est) tuples, or list of mixture of these
        self_attr = getattr(self, attr_name)

        # from that, obtain ests, and original names (may be non-unique)
        # we avoid _make_strings_unique call too early to avoid blow-up of string
        self_names, self_objs = self._get_names_and_objects(self_attr)
        if isinstance(other, composite_class):
            other_attr = getattr(other, attr_name)
            other_names, other_objs = other._get_names_and_objects(other_attr)
        elif isinstance(other, base_class):
            other_names = [type(other).__name__]
            other_objs = [other]
        elif is_named_object_tuple(other, object_type=base_class):
            other_names = [other[0]]
            other_objs = [other[1]]
        else:
            return NotImplemented

        new_names = concat(self_names, other_names)
        new_objs = concat(self_objs, other_objs)
        # create the "steps" param for the composite
        # if all the names are equal to class names, we eat them away
        if all(type(x[1]).__name__ == x[0] for x in zip(new_names, new_objs)):
            step_param = {attr_name: list(new_objs)}
        else:
            step_param = {attr_name: list(zip(new_names, new_objs))}

        # retrieve other parameters, from composite_params attribute
        if composite_params is None:
            composite_params = {}
        else:
            composite_params = composite_params.copy()

        # construct the composite with both step and additional params
        composite_params.update(step_param)
        return composite_class(**composite_params)

    def _sk_visual_block_(self):
        """Logic to help render meta estimator as visual HTML block."""
        # Use tag interface that will be available when mixin is used
        named_object_attr_name = self.get_tag("named_object_parameters")  # type: ignore
        named_object_attr = getattr(self, named_object_attr_name)
        named_objects = self._coerce_to_named_object_tuples(named_object_attr)
        _, objs = self._get_names_and_objects(named_objects)

        def _get_name(name, obj):
            if obj is None or obj == "passthrough":
                return f"{name}: passthrough"
            # Is an estimator
            return f"{name}: {obj.__class__.__name__}"

        names = [_get_name(name, est) for name, est in named_objects]
        name_details = [str(obj) for obj in objs]
        return _VisualBlock(
            "serial",
            objs,
            names=names,
            name_details=name_details,
            dash_wrapped=False,
        )


class _MetaTagLogicMixin:
    """Mixin for tag conjunction, disjunction, chain operations for meta-objects.

    Contains methods to set tags of a meta-object dependent on component objects.
    """

    def _anytagis(self, tag_name, value, estimators):
        """Return whether any estimator in list has tag `tag_name` of value `value`.

        Parameters
        ----------
        tag_name : str, name of the tag to check
        value : value of the tag to check for
        estimators : list of (str, estimator) pairs to query for the tag/value

        Return
        ------
        bool : True iff at least one estimator in the list has value in tag tag_name
        """
        tagis = [est.get_tag(tag_name, value) == value for _, est in estimators]
        return any(tagis)

    def _anytagis_then_set(self, tag_name, value, value_if_not, estimators):
        """Set self's `tag_name` tag to `value` if any estimator on the list has it.

        Writes to self:
        sets the tag `tag_name` to `value` if `_anytagis(tag_name, value)` is True
            otherwise sets the tag `tag_name` to `value_if_not`

        Parameters
        ----------
        tag_name : str, name of the tag
        value : value to check and to set tag to if one of the tag values is `value`
        value_if_not : value to set in self if none of the tag values is `value`
        estimators : list of (str, estimator) pairs to query for the tag/value
        """
        if self._anytagis(tag_name=tag_name, value=value, estimators=estimators):
            self.set_tags(**{tag_name: value})
        else:
            self.set_tags(**{tag_name: value_if_not})

    def _anytag_notnone_val(self, tag_name, estimators):
        """Return first non-'None' value of tag `tag_name` in estimator list.

        Parameters
        ----------
        tag_name : str, name of the tag
        estimators : list of (str, estimator) pairs to query for the tag/value

        Return
        ------
        tag_val : first non-'None' value of tag `tag_name` in estimator list.
        """
        for _, est in estimators:
            tag_val = est.get_tag(tag_name)
            if tag_val != "None":
                return tag_val
        return tag_val

    def _anytag_notnone_set(self, tag_name, estimators):
        """Set self's `tag_name` tag to first non-'None' value in estimator list.

        Writes to self:
        tag with name tag_name, sets to _anytag_notnone_val(tag_name, estimators)

        Parameters
        ----------
        tag_name : str, name of the tag
        estimators : list of (str, estimator) pairs to query for the tag/value
        """
        tag_val = self._anytag_notnone_val(tag_name=tag_name, estimators=estimators)
        if tag_val != "None":
            self.set_tags(**{tag_name: tag_val})

    def _tagchain_is_linked(
        self,
        left_tag_name,
        mid_tag_name,
        estimators,
        left_tag_val=True,
        mid_tag_val=True,
    ):
        """Check whether all tags left of the first mid_tag/val are left_tag/val.

        Useful to check, for instance, whether all instances of estimators
            left of the first missing value imputer can deal with missing values.

        Parameters
        ----------
        left_tag_name : str, name of the left tag
        mid_tag_name : str, name of the middle tag
        estimators : list of (str, estimator) pairs to query for the tag/value
        left_tag_val : value of the left tag, optional, default=True
        mid_tag_val : value of the middle tag, optional, default=True

        Returns
        -------
        chain_is_linked : bool,
            True iff all "left" tag instances `left_tag_name` have value `left_tag_val`
            a "left" tag instance is an instance in estimators which is earlier
            than the first occurrence of `mid_tag_name` with value `mid_tag_val`
        chain_is_complete : bool,
            True iff chain_is_linked is True, and
                there is an occurrence of `mid_tag_name` with value `mid_tag_val`
        """
        for _, est in estimators:
            if est.get_tag(mid_tag_name) == mid_tag_val:
                return True, True
            if not est.get_tag(left_tag_name) == left_tag_val:
                return False, False
        return True, False

    def _tagchain_is_linked_set(
        self,
        left_tag_name,
        mid_tag_name,
        estimators,
        left_tag_val=True,
        mid_tag_val=True,
        left_tag_val_not=False,
        mid_tag_val_not=False,
    ):
        """Check if _tagchain_is_linked, then set self left_tag_name and mid_tag_name.

        Writes to self:
        tag with name left_tag_name, sets to left_tag_val if _tag_chain_is_linked[0]
            otherwise sets to left_tag_val_not
        tag with name mid_tag_name, sets to mid_tag_val if _tag_chain_is_linked[1]
            otherwise sets to mid_tag_val_not

        Parameters
        ----------
        left_tag_name : str, name of the left tag
        mid_tag_name : str, name of the middle tag
        estimators : list of (str, estimator) pairs to query for the tag/value
        left_tag_val : value of the left tag, optional, default=True
        mid_tag_val : value of the middle tag, optional, default=True
        left_tag_val_not : value to set if not linked, optional, default=False
        mid_tag_val_not : value to set if not linked, optional, default=False
        """
        linked, complete = self._tagchain_is_linked(
            left_tag_name=left_tag_name,
            mid_tag_name=mid_tag_name,
            estimators=estimators,
            left_tag_val=left_tag_val,
            mid_tag_val=mid_tag_val,
        )
        if linked:
            self.set_tags(**{left_tag_name: left_tag_val})
        else:
            self.set_tags(**{left_tag_name: left_tag_val_not})
        if complete:
            self.set_tags(**{mid_tag_name: mid_tag_val})
        else:
            self.set_tags(**{mid_tag_name: mid_tag_val_not})


class BaseMetaObject(_MetaObjectMixin, _MetaTagLogicMixin, BaseObject):
    """Parameter and tag management for objects composed of named objects.

    Allows objects to get and set nested parameters when a parameter of the the
    class has values that follow the named object specification. For example,
    in a pipeline class with the the "step" parameter accepting named objects,
    this would allow `get_params` and `set_params` to retrieve and update the
    parameters of the objects in each step.

    Note: if inheriting from an abstract descendant of `BaseObject`, use
    ``BaseMetaObjectMixin`` and not ``BaseMetaObject``.

    See Also
    --------
    BaseMetaObjectMixin :
        Mixin for inheriting from abstract descendants of ``BaseObject``.
        Same as ``BaseMetaObject``, but does not inherit from ``BaseObject``.
    BaseMetaEstimator :
        Expands on ``BaseMetaObject`` by adding functionality for getting fitted
        parameters from a class's component estimators.
    """


class BaseMetaObjectMixin(_MetaObjectMixin, _MetaTagLogicMixin):
    """Parameter and tag management for objects composed of named objects.

    Allows objects to get and set nested parameters when a parameter of the the
    class has values that follow the named object specification. For example,
    in a pipeline class with the the "step" parameter accepting named objects,
    this would allow `get_params` and `set_params` to retrieve and update the
    parameters of the objects in each step.

    Mixin for inheriting from abstract descendants of ``BaseObject``.
    Intended use is inheriting as follows:

    ``class MyAbstractBaseClass(BaseObject)``, and then
    ``class MyConcreteClass(BaseMetaObjectMixin, MyAbstractBaseClass)``

    The mixin will override:
    ``get_params``, ``set_params``, ``_get_params``, ``_set_params``,
    ``_get_fitted_params``, ``_sk_visual_block_``

    See Also
    --------
    BaseMetaEstimatorMixin :
        Expands on ``BaseMetaObjectMixin`` by adding functionality for getting fitted
        parameters from a class's component estimators.
    BaseMetaObject :
        same as ``BaseMetaObjectMixin``, but also inherits from ``BaseObject``.
        Use for a standalone meta-object class.
        Do not use if inheriting from an abstract descendant of ``BaseObject``.
    """


class BaseMetaEstimator(_MetaObjectMixin, _MetaTagLogicMixin, BaseEstimator):
    """Parameter and tag management for estimators composed of named objects.

    Allows estimators to get and set nested parameters when a parameter of the the
    class has values that follow the named object specification. For example,
    in a pipeline class with the the "step" parameter accepting named objects,
    this would allow `get_params` and `set_params` to retrieve and update the
    parameters of the objects in each step.

    Note: if inheriting from an abstract descendant of `BaseEstimator`, use
    ``BaseMetaEstimatorMixin`` and not ``BaseMetaEstimator``.

    See Also
    --------
    BaseMetaEstimatorMixin :
        Mixin for inheriting from abstract descendants of ``BaseObject``.
        Same as ``BaseMetaObject``, but does not inherit from ``BaseObject``.
    BaseMetaObject :
        Provides similar functionality to  `BaseMetaEstimator`,
        but does not have the estimator interface for fitting and fitted parameters.
    """


class BaseMetaEstimatorMixin(_MetaObjectMixin, _MetaTagLogicMixin):
    """Parameter and tag management for estimators composed of named objects.

    Allows estimators to get and set nested parameters when a parameter of the the
    class has values that follow the named object specification. For example,
    in a pipeline class with the the "step" parameter accepting named objects,
    this would allow `get_params` and `set_params` to retrieve and update the
    parameters of the objects in each step.

    Mixin for inheriting from abstract descendants of ``BaseEstimator``.
    Intended use is inheriting as follows:

    ``class MyAbstractBaseClass(BaseEstimator)``, and then
    ``class MyConcreteClass(BaseMetaEstimatorMixin, MyAbstractBaseClass)``

    Note: the order of inheritance is important.

    The mixin will override:
    ``get_params``, ``set_params``, ``_get_params``, ``_set_params``,
    ``_get_fitted_params``, ``_sk_visual_block_``

    See Also
    --------
    BaseMetaObjectMixin :
        Provides similar functionality to  `BaseMetaEstimatorMixin`,
        but does not have the estimator interface for fitting and fitted parameters.
    BaseMetaEstimator :
        same as ``BaseMetaEstimatorMixin``, but also inherits from ``BaseEstimator``.
        Use for a standalone meta-estimator class.
        Do not use if inheriting from an abstract descendant of ``BaseEstimator``.
    """